@mentra/bluetooth-sdk 0.1.21-beta.3 → 3.1.0-beta.100
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 +67 -25
- 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/GeneratedChangelogCatalog.kt +7 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +127 -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/ReleaseChangelog.kt +61 -0
- 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 +1393 -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/ReleaseChangelogTest.kt +13 -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 +133 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
- package/build/BluetoothSdk.types.d.ts +84 -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/changelogs.d.ts +7 -0
- package/build/changelogs.d.ts.map +1 -0
- package/build/changelogs.js +44 -0
- package/build/changelogs.js.map +1 -0
- package/build/generated/changelogCatalog.d.ts +6 -0
- package/build/generated/changelogCatalog.d.ts.map +1 -0
- package/build/generated/changelogCatalog.js +8 -0
- package/build/generated/changelogCatalog.js.map +1 -0
- 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 +24 -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/build/ota-transport/index.d.ts +67 -0
- package/build/ota-transport/index.d.ts.map +1 -0
- package/build/ota-transport/index.js +58 -0
- package/build/ota-transport/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/GeneratedChangelogCatalog.swift +6 -0
- package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
- package/ios/Source/MentraBluetoothSDK.swift +91 -6
- package/ios/Source/OtaManifest.swift +5 -7
- package/ios/Source/ReleaseChangelog.swift +65 -0
- 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 +512 -78
- 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 +160 -0
- package/ios/Tests/ReleaseChangelogTests.swift +13 -0
- package/package.json +10 -1
- package/plugin/build/withIos.d.ts +3 -1
- package/plugin/build/withIos.js +22 -10
- package/scripts/public-ota-api.test.mjs +37 -0
- 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 +97 -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/changelogs.ts +44 -0
- package/src/generated/changelogCatalog.ts +9 -0
- package/src/generated/releaseMetadata.ts +20 -0
- package/src/index.ts +43 -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
- package/src/ota-transport/index.ts +116 -0
|
@@ -25,6 +25,35 @@ struct MentraLiveDevice {
|
|
|
25
25
|
let address: String
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
enum MentraLivePendingPairingTarget {
|
|
29
|
+
static func matches(
|
|
30
|
+
connectedName: String,
|
|
31
|
+
connectedIdentifier: String,
|
|
32
|
+
pendingName: String,
|
|
33
|
+
pendingIdentifier: String
|
|
34
|
+
) -> Bool {
|
|
35
|
+
if !pendingIdentifier.isEmpty {
|
|
36
|
+
return connectedIdentifier.caseInsensitiveCompare(pendingIdentifier) == .orderedSame
|
|
37
|
+
}
|
|
38
|
+
return !pendingName.isEmpty && connectedName == pendingName
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static func shouldRecover(
|
|
42
|
+
isConnected: Bool,
|
|
43
|
+
connectedName: String,
|
|
44
|
+
connectedIdentifier: String,
|
|
45
|
+
pendingName: String,
|
|
46
|
+
pendingIdentifier: String
|
|
47
|
+
) -> Bool {
|
|
48
|
+
isConnected && matches(
|
|
49
|
+
connectedName: connectedName,
|
|
50
|
+
connectedIdentifier: connectedIdentifier,
|
|
51
|
+
pendingName: pendingName,
|
|
52
|
+
pendingIdentifier: pendingIdentifier
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
28
57
|
// MARK: - BlePhotoUploadService
|
|
29
58
|
|
|
30
59
|
class BlePhotoUploadService {
|
|
@@ -857,20 +886,66 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
857
886
|
}
|
|
858
887
|
}
|
|
859
888
|
|
|
860
|
-
func handleDiscoveredPeripheral(
|
|
889
|
+
func handleDiscoveredPeripheral(
|
|
890
|
+
_ peripheral: CBPeripheral,
|
|
891
|
+
advertisementData: [String: Any] = [:],
|
|
892
|
+
rssi: NSNumber? = nil
|
|
893
|
+
) {
|
|
861
894
|
guard let name = peripheral.name else { return }
|
|
862
895
|
|
|
863
896
|
// Check for compatible device names
|
|
864
897
|
if name == "Xy_A" || name.hasPrefix("XyBLE_") || name.hasPrefix("MENTRA_LIVE_BLE")
|
|
865
898
|
|| name.hasPrefix("MENTRA_LIVE_BT") || name.lowercased().hasPrefix("mentra_live")
|
|
866
899
|
{
|
|
900
|
+
let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME)
|
|
901
|
+
let isReconnectTarget = savedDeviceName != nil && savedDeviceName == name
|
|
902
|
+
if !isReconnectTarget && advertisesPairingFlag(advertisementData)
|
|
903
|
+
&& !isPairingDiscoverable(advertisementData)
|
|
904
|
+
{
|
|
905
|
+
// Keep nearby secure units visible with pairing-mode guidance, while RN blocks
|
|
906
|
+
// the connection until a pairable advertisement arrives.
|
|
907
|
+
Bridge.log(
|
|
908
|
+
"LIVE: Nearby \(name) is secure firmware not in pairing mode — exposing as non-pairable"
|
|
909
|
+
)
|
|
910
|
+
discoveredPeripherals[name] = peripheral
|
|
911
|
+
cacheAdvPairing(
|
|
912
|
+
name,
|
|
913
|
+
pairingMode: false,
|
|
914
|
+
pairingCode: nil,
|
|
915
|
+
securePairingCapable: true
|
|
916
|
+
)
|
|
917
|
+
emitDiscoveredDeviceFromCache(
|
|
918
|
+
name,
|
|
919
|
+
identifier: peripheral.identifier.uuidString,
|
|
920
|
+
rssi: rssi?.intValue
|
|
921
|
+
)
|
|
922
|
+
return
|
|
923
|
+
}
|
|
924
|
+
|
|
867
925
|
let glassType = name == "Xy_A" ? "Standard" : "K900"
|
|
868
|
-
|
|
926
|
+
let trailer = parseSecurePairingTrailer(advertisementData)
|
|
927
|
+
Bridge.log(
|
|
928
|
+
trailer.secureCapable
|
|
929
|
+
? "Found compatible \(glassType) glasses device: \(name) (pairingMode=\(trailer.pairingMode))"
|
|
930
|
+
: "Found compatible \(glassType) glasses device: \(name) (legacy firmware, pairable)"
|
|
931
|
+
)
|
|
869
932
|
|
|
870
933
|
// Store the peripheral
|
|
871
934
|
discoveredPeripherals[name] = peripheral
|
|
872
935
|
|
|
873
|
-
|
|
936
|
+
cacheAdvPairing(
|
|
937
|
+
name,
|
|
938
|
+
// Field firmware has no pairing flag. Treat it as pairable so the
|
|
939
|
+
// new Mentra App can still find old glasses.
|
|
940
|
+
pairingMode: trailer.secureCapable ? trailer.pairingMode : true,
|
|
941
|
+
pairingCode: trailer.pairingCode,
|
|
942
|
+
securePairingCapable: trailer.secureCapable
|
|
943
|
+
)
|
|
944
|
+
emitDiscoveredDeviceFromCache(
|
|
945
|
+
name,
|
|
946
|
+
identifier: peripheral.identifier.uuidString,
|
|
947
|
+
rssi: rssi?.intValue
|
|
948
|
+
)
|
|
874
949
|
|
|
875
950
|
// Check if this is the device we want to connect to
|
|
876
951
|
if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
|
|
@@ -887,21 +962,42 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
887
962
|
|
|
888
963
|
nonisolated func centralManager(
|
|
889
964
|
_: CBCentralManager, didDiscover peripheral: CBPeripheral,
|
|
890
|
-
advertisementData
|
|
965
|
+
advertisementData: [String: Any], rssi: NSNumber
|
|
891
966
|
) {
|
|
892
967
|
DispatchQueue.main.async { [weak self] in
|
|
893
|
-
self?.handleDiscoveredPeripheral(peripheral, rssi: rssi)
|
|
968
|
+
self?.handleDiscoveredPeripheral(peripheral, advertisementData: advertisementData, rssi: rssi)
|
|
894
969
|
}
|
|
895
970
|
}
|
|
896
971
|
|
|
897
972
|
nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
898
973
|
DispatchQueue.main.async { [weak self] in
|
|
899
974
|
guard let self else { return }
|
|
975
|
+
let matchesActiveAttempt = self.connectingPeripheral === peripheral
|
|
976
|
+
guard MentraLiveConnectionAttemptPolicy.shouldAcceptDidConnect(
|
|
977
|
+
pairingYieldActive: self.pairingYieldActive,
|
|
978
|
+
matchesActiveAttempt: matchesActiveAttempt
|
|
979
|
+
) else {
|
|
980
|
+
Bridge.log(
|
|
981
|
+
"LIVE: Rejecting stale connection callback during pairing yield or after attempt replacement: \(peripheral.identifier)"
|
|
982
|
+
)
|
|
983
|
+
self.stopConnectionTimeout()
|
|
984
|
+
if self.connectingPeripheral === peripheral {
|
|
985
|
+
self.connectingPeripheral = nil
|
|
986
|
+
}
|
|
987
|
+
if self.connectedPeripheral === peripheral {
|
|
988
|
+
self.connectedPeripheral = nil
|
|
989
|
+
}
|
|
990
|
+
self.isConnecting = false
|
|
991
|
+
self.centralManager?.cancelPeripheralConnection(peripheral)
|
|
992
|
+
return
|
|
993
|
+
}
|
|
900
994
|
Bridge.log("Connected to GATT server, discovering services...")
|
|
901
995
|
|
|
902
996
|
self.stopConnectionTimeout()
|
|
903
997
|
self.isConnecting = false
|
|
998
|
+
self.connectingPeripheral = nil
|
|
904
999
|
self.connectedPeripheral = peripheral
|
|
1000
|
+
self.emitConnectedPendingDeviceForPairingScan()
|
|
905
1001
|
|
|
906
1002
|
// Save device name and address for future reconnection
|
|
907
1003
|
if let name = peripheral.name {
|
|
@@ -939,10 +1035,17 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
939
1035
|
}
|
|
940
1036
|
|
|
941
1037
|
nonisolated func centralManager(
|
|
942
|
-
_: CBCentralManager, didDisconnectPeripheral
|
|
1038
|
+
_: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?
|
|
943
1039
|
) {
|
|
944
1040
|
DispatchQueue.main.async { [weak self] in
|
|
945
1041
|
guard let self else { return }
|
|
1042
|
+
let isCurrent =
|
|
1043
|
+
self.connectedPeripheral === peripheral || self.connectingPeripheral === peripheral
|
|
1044
|
+
if !isCurrent {
|
|
1045
|
+
Bridge.log("LIVE: Ignoring stale disconnect for \(peripheral.identifier)")
|
|
1046
|
+
return
|
|
1047
|
+
}
|
|
1048
|
+
self.connectingPeripheral = nil
|
|
946
1049
|
Bridge.log("LIVE: Disconnected from GATT server")
|
|
947
1050
|
|
|
948
1051
|
self.isConnecting = false
|
|
@@ -962,6 +1065,11 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
962
1065
|
self.txCharacteristic = nil
|
|
963
1066
|
self.rxCharacteristic = nil
|
|
964
1067
|
|
|
1068
|
+
if self.pairingYieldAwaitingReclaim, Self.isPairingAuthFailure(error) {
|
|
1069
|
+
self.clearSavedGlassesAfterOwnerLoss(reason: "ios_auth_fail")
|
|
1070
|
+
return
|
|
1071
|
+
}
|
|
1072
|
+
|
|
965
1073
|
// Attempt reconnection if not killed
|
|
966
1074
|
if !self.isKilled {
|
|
967
1075
|
self.handleReconnection()
|
|
@@ -969,10 +1077,17 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
969
1077
|
}
|
|
970
1078
|
}
|
|
971
1079
|
|
|
972
|
-
nonisolated func centralManager(_: CBCentralManager, didFailToConnect
|
|
1080
|
+
nonisolated func centralManager(_: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
|
973
1081
|
let errorDescription = error?.localizedDescription ?? "Unknown error"
|
|
974
1082
|
DispatchQueue.main.async { [weak self] in
|
|
975
1083
|
guard let self else { return }
|
|
1084
|
+
let isCurrent =
|
|
1085
|
+
self.connectedPeripheral === peripheral || self.connectingPeripheral === peripheral
|
|
1086
|
+
if !isCurrent {
|
|
1087
|
+
Bridge.log("LIVE: Ignoring stale connect failure for \(peripheral.identifier)")
|
|
1088
|
+
return
|
|
1089
|
+
}
|
|
1090
|
+
self.connectingPeripheral = nil
|
|
976
1091
|
Bridge.log("LIVE: Failed to connect to peripheral: \(errorDescription)")
|
|
977
1092
|
|
|
978
1093
|
self.stopConnectionTimeout()
|
|
@@ -981,6 +1096,11 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
981
1096
|
self.connectedPeripheral = nil
|
|
982
1097
|
self.updateConnectionState(ConnTypes.DISCONNECTED)
|
|
983
1098
|
|
|
1099
|
+
if self.pairingYieldAwaitingReclaim, Self.isPairingAuthFailure(error) {
|
|
1100
|
+
self.clearSavedGlassesAfterOwnerLoss(reason: "ios_auth_fail")
|
|
1101
|
+
return
|
|
1102
|
+
}
|
|
1103
|
+
|
|
984
1104
|
if !self.isKilled {
|
|
985
1105
|
self.handleReconnection()
|
|
986
1106
|
}
|
|
@@ -1276,6 +1396,39 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1276
1396
|
private let BLOCK_AUDIO_DUPLEX = false
|
|
1277
1397
|
private static let voiceActivityDetectionSwitchType = 8
|
|
1278
1398
|
private static let loudnessGateSwitchType = 10
|
|
1399
|
+
private func pairingAdvertisement(
|
|
1400
|
+
_ advertisementData: [String: Any]
|
|
1401
|
+
) -> MentraLivePairingAdvertisement? {
|
|
1402
|
+
MentraLivePairingAdvertisement.parse(
|
|
1403
|
+
coreBluetoothManufacturerData:
|
|
1404
|
+
advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
|
|
1405
|
+
)
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
private func isPairingDiscoverable(_ advertisementData: [String: Any]) -> Bool {
|
|
1409
|
+
pairingAdvertisement(advertisementData)?.pairingMode == true
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
private struct SecurePairingTrailer {
|
|
1413
|
+
let pairingMode: Bool
|
|
1414
|
+
let pairingCode: String?
|
|
1415
|
+
let secureCapable: Bool
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
private func parseSecurePairingTrailer(_ advertisementData: [String: Any]) -> SecurePairingTrailer {
|
|
1419
|
+
guard let advertisement = pairingAdvertisement(advertisementData) else {
|
|
1420
|
+
return SecurePairingTrailer(pairingMode: false, pairingCode: nil, secureCapable: false)
|
|
1421
|
+
}
|
|
1422
|
+
return SecurePairingTrailer(
|
|
1423
|
+
pairingMode: advertisement.pairingMode,
|
|
1424
|
+
pairingCode: advertisement.pairingCode,
|
|
1425
|
+
secureCapable: true
|
|
1426
|
+
)
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
private func advertisesPairingFlag(_ advertisementData: [String: Any]) -> Bool {
|
|
1430
|
+
pairingAdvertisement(advertisementData) != nil
|
|
1431
|
+
}
|
|
1279
1432
|
|
|
1280
1433
|
var connectionState: String = ConnTypes.DISCONNECTED
|
|
1281
1434
|
|
|
@@ -1290,6 +1443,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1290
1443
|
// return, unlike Android).
|
|
1291
1444
|
DeviceStore.shared.apply("glasses", "serialNumber", "")
|
|
1292
1445
|
DeviceStore.shared.apply("glasses", "bluetoothMacAddress", "")
|
|
1446
|
+
DeviceStore.shared.apply("glasses", "wifiMacAddress", "")
|
|
1447
|
+
DeviceStore.shared.apply("glasses", "hotspotOtaVersion", 0)
|
|
1293
1448
|
}
|
|
1294
1449
|
connectionState = state
|
|
1295
1450
|
DeviceStore.shared.apply("glasses", "connectionState", state)
|
|
@@ -1297,6 +1452,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1297
1452
|
// a previous pairing into the next one (would otherwise surface as wrong overall_percent
|
|
1298
1453
|
// or stale lastBesOtaProgress on the next OTA).
|
|
1299
1454
|
if state == ConnTypes.DISCONNECTED {
|
|
1455
|
+
resetPendingAckState(reason: "BLE session ended")
|
|
1300
1456
|
resetWireNegotiationState()
|
|
1301
1457
|
resetAncsRelayState()
|
|
1302
1458
|
// Queued writes are session-bound: transmitting them into the NEXT session
|
|
@@ -1342,14 +1498,26 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1342
1498
|
func forget() {
|
|
1343
1499
|
Bridge.log("LIVE: Forgetting Mentra Live glasses")
|
|
1344
1500
|
|
|
1501
|
+
// Clear saved device name so the pairing filter is not bypassed on rescan
|
|
1502
|
+
UserDefaults.standard.removeObject(forKey: PREFS_DEVICE_NAME)
|
|
1503
|
+
|
|
1345
1504
|
// Stop scanning first
|
|
1346
1505
|
if isScanning {
|
|
1347
1506
|
stopScan()
|
|
1348
1507
|
emitStopScanEvent()
|
|
1349
1508
|
}
|
|
1350
1509
|
|
|
1351
|
-
// Then do full cleanup
|
|
1352
|
-
|
|
1510
|
+
// Then do full cleanup after the unpair write can leave GATT.
|
|
1511
|
+
sendJson(["type": "unpair"], wakeUp: true, requireAck: false)
|
|
1512
|
+
unpairFlushWorkItem?.cancel()
|
|
1513
|
+
unpairFlushPending = true
|
|
1514
|
+
let work = DispatchWorkItem { [self] in
|
|
1515
|
+
self.unpairFlushPending = false
|
|
1516
|
+
self.unpairFlushWorkItem = nil
|
|
1517
|
+
self.destroy()
|
|
1518
|
+
}
|
|
1519
|
+
unpairFlushWorkItem = work
|
|
1520
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: work)
|
|
1353
1521
|
}
|
|
1354
1522
|
|
|
1355
1523
|
var type = "Mentra Live"
|
|
@@ -1427,7 +1595,6 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1427
1595
|
private var lc3ReadCharacteristic: CBCharacteristic?
|
|
1428
1596
|
private var lc3WriteCharacteristic: CBCharacteristic?
|
|
1429
1597
|
private var supportsLC3Audio = true
|
|
1430
|
-
private var lastReceivedLc3Sequence: Int8 = -1
|
|
1431
1598
|
private let LC3_FRAME_SIZE = 40 // bytes per LC3 frame
|
|
1432
1599
|
private let MICBEAT_INTERVAL_MS: TimeInterval = 30 * 60 // 30 minutes in seconds
|
|
1433
1600
|
private var micBeatTimer: Timer?
|
|
@@ -1452,6 +1619,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1452
1619
|
private let KEEP_ALIVE_INTERVAL_MS: UInt64 = 5_000_000_000 // 5 seconds
|
|
1453
1620
|
private let CONNECTION_TIMEOUT_MS: UInt64 = 100_000_000_000 // 100 seconds
|
|
1454
1621
|
private let HEARTBEAT_INTERVAL_MS: TimeInterval = 30.0 // 30 seconds
|
|
1622
|
+
private let BES_OTA_HEARTBEAT_LEASE_SEC: TimeInterval = 120.0
|
|
1455
1623
|
private let BATTERY_REQUEST_EVERY_N_HEARTBEATS = 10
|
|
1456
1624
|
private let SIGNAL_STRENGTH_READ_INTERVAL_MS: TimeInterval = 10.0
|
|
1457
1625
|
private let MIN_SEND_DELAY_MS: UInt64 = 160_000_000 // 160ms in nanoseconds
|
|
@@ -1472,6 +1640,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1472
1640
|
private var centralManager: CBCentralManager?
|
|
1473
1641
|
|
|
1474
1642
|
private var connectedPeripheral: CBPeripheral?
|
|
1643
|
+
/// Peripheral for the in-flight connect; used to ignore stale disconnect/fail callbacks.
|
|
1644
|
+
private var connectingPeripheral: CBPeripheral?
|
|
1475
1645
|
private var txCharacteristic: CBCharacteristic?
|
|
1476
1646
|
private var rxCharacteristic: CBCharacteristic?
|
|
1477
1647
|
private let bes2700MtuLimit = 509
|
|
@@ -1479,8 +1649,16 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1479
1649
|
|
|
1480
1650
|
// State Tracking
|
|
1481
1651
|
private var isScanning = false
|
|
1652
|
+
private var manualDiscoveryActive = false
|
|
1482
1653
|
private var isConnecting = false
|
|
1483
1654
|
private var isKilled = false
|
|
1655
|
+
/// Glasses opened pairing window — stand down without forgetting identity/bonds.
|
|
1656
|
+
private var pairingYieldActive = false
|
|
1657
|
+
private var pairingYieldAwaitingReclaim = false
|
|
1658
|
+
private var pairingYieldEndWorkItem: DispatchWorkItem?
|
|
1659
|
+
/// Hold GATT teardown until the unpair write can leave the phone.
|
|
1660
|
+
private var unpairFlushPending = false
|
|
1661
|
+
private var unpairFlushWorkItem: DispatchWorkItem?
|
|
1484
1662
|
private var reconnectAttempts = 0
|
|
1485
1663
|
private var isNewVersion = false
|
|
1486
1664
|
private var peerWireProtocolVersion = 0
|
|
@@ -1546,6 +1724,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1546
1724
|
|
|
1547
1725
|
/// BES OTA progress tracking - only send to UI on 5% increments
|
|
1548
1726
|
private var lastBesOtaProgress = -1
|
|
1727
|
+
private var besOtaHeartbeatSuppressedUntil: TimeInterval = 0
|
|
1549
1728
|
|
|
1550
1729
|
// Cached OTA session context from last ota_status — used to fill in session fields for sr_adota
|
|
1551
1730
|
private var cachedOtaSessionId: String?
|
|
@@ -1598,6 +1777,16 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1598
1777
|
// MARK: - React Native Interface
|
|
1599
1778
|
|
|
1600
1779
|
private var discoveredPeripherals = [String: CBPeripheral]() // name -> peripheral
|
|
1780
|
+
/// Last advertisement pairing trailer for a discovered name. Re-emits (already-
|
|
1781
|
+
/// connected / scan replay) must not drop these flags: omitting them makes a
|
|
1782
|
+
/// secure idle unit look like pairable legacy firmware.
|
|
1783
|
+
private var discoveredAdvPairing = [String: CachedAdvPairing]()
|
|
1784
|
+
|
|
1785
|
+
private struct CachedAdvPairing {
|
|
1786
|
+
let pairingMode: Bool
|
|
1787
|
+
let pairingCode: String?
|
|
1788
|
+
let securePairingCapable: Bool
|
|
1789
|
+
}
|
|
1601
1790
|
|
|
1602
1791
|
func findCompatibleDevices() {
|
|
1603
1792
|
Bridge.log("Finding compatible Mentra Live glasses")
|
|
@@ -1615,11 +1804,95 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1615
1804
|
// clear the saved device name:
|
|
1616
1805
|
UserDefaults.standard.set("", forKey: PREFS_DEVICE_NAME)
|
|
1617
1806
|
|
|
1807
|
+
manualDiscoveryActive = true
|
|
1618
1808
|
startScan()
|
|
1809
|
+
emitConnectedPendingDeviceForPairingScan()
|
|
1619
1810
|
}
|
|
1620
1811
|
}
|
|
1621
1812
|
|
|
1813
|
+
func isPairingYieldActive() -> Bool {
|
|
1814
|
+
pairingYieldActive
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
private func enterPairingYield(windowMs: Int) {
|
|
1818
|
+
pairingYieldActive = true
|
|
1819
|
+
pairingYieldAwaitingReclaim = false
|
|
1820
|
+
pairingYieldEndWorkItem?.cancel()
|
|
1821
|
+
if isScanning {
|
|
1822
|
+
stopScan()
|
|
1823
|
+
}
|
|
1824
|
+
// Publish disconnect immediately for Phone A UI. didDisconnectPeripheral may lag
|
|
1825
|
+
// or race with cancelPeripheralConnection; do not wait on it for stand-down.
|
|
1826
|
+
isConnecting = false
|
|
1827
|
+
connected = false
|
|
1828
|
+
fullyBooted = false
|
|
1829
|
+
glassesSessionId = nil
|
|
1830
|
+
readinessCompletedThisBleSession = false
|
|
1831
|
+
rgbLedAuthorityClaimed = false
|
|
1832
|
+
stopAllTimers()
|
|
1833
|
+
closeL2capFileChannel()
|
|
1834
|
+
txCharacteristic = nil
|
|
1835
|
+
rxCharacteristic = nil
|
|
1836
|
+
updateConnectionState(ConnTypes.DISCONNECTED)
|
|
1837
|
+
if let peripheral = connectedPeripheral {
|
|
1838
|
+
centralManager?.cancelPeripheralConnection(peripheral)
|
|
1839
|
+
}
|
|
1840
|
+
connectedPeripheral = nil
|
|
1841
|
+
connectingPeripheral = nil
|
|
1842
|
+
// Stand down for the whole window. Probing GATT during yield races the new
|
|
1843
|
+
// phone for the single BLE slot and is exactly what entering_pairing_mode forbids.
|
|
1844
|
+
|
|
1845
|
+
let work = DispatchWorkItem { [weak self] in
|
|
1846
|
+
guard let self else { return }
|
|
1847
|
+
Bridge.log("LIVE: Pairing yield ended — resume reconnect if still owned")
|
|
1848
|
+
self.pairingYieldActive = false
|
|
1849
|
+
self.pairingYieldAwaitingReclaim = true
|
|
1850
|
+
self.pairingYieldEndWorkItem = nil
|
|
1851
|
+
if !self.isKilled, self.connectedPeripheral == nil {
|
|
1852
|
+
self.handleReconnection()
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
pairingYieldEndWorkItem = work
|
|
1856
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(windowMs), execute: work)
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/// Pairing/auth failures after yield — CoreBluetooth codes, not localized strings.
|
|
1860
|
+
/// Connection timeouts are not owner-loss.
|
|
1861
|
+
private static func isPairingAuthFailure(_ error: Error?) -> Bool {
|
|
1862
|
+
guard let error = error as NSError? else { return false }
|
|
1863
|
+
if error.domain == CBATTErrorDomain {
|
|
1864
|
+
return error.code == CBATTError.insufficientAuthentication.rawValue
|
|
1865
|
+
|| error.code == CBATTError.insufficientEncryption.rawValue
|
|
1866
|
+
}
|
|
1867
|
+
if error.domain == CBErrorDomain {
|
|
1868
|
+
return error.code == CBError.peerRemovedPairingInformation.rawValue
|
|
1869
|
+
}
|
|
1870
|
+
return false
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
private func clearSavedGlassesAfterOwnerLoss(reason: String) {
|
|
1874
|
+
Bridge.log("LIVE: Owner loss (\(reason)) — clearing saved glasses")
|
|
1875
|
+
pairingYieldAwaitingReclaim = false
|
|
1876
|
+
pairingYieldActive = false
|
|
1877
|
+
pairingYieldEndWorkItem?.cancel()
|
|
1878
|
+
pairingYieldEndWorkItem = nil
|
|
1879
|
+
forget()
|
|
1880
|
+
Task { @MainActor in
|
|
1881
|
+
DeviceStore.shared.apply("bluetooth", "default_wearable", "")
|
|
1882
|
+
DeviceStore.shared.apply("bluetooth", "device_name", "")
|
|
1883
|
+
DeviceStore.shared.apply("bluetooth", "device_address", "")
|
|
1884
|
+
}
|
|
1885
|
+
Bridge.saveSetting("default_wearable", "")
|
|
1886
|
+
Bridge.saveSetting("device_name", "")
|
|
1887
|
+
Bridge.saveSetting("device_address", "")
|
|
1888
|
+
Bridge.sendTypedMessage("owner_replaced", body: ["reason": reason])
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1622
1891
|
func connectById(_ deviceName: String) {
|
|
1892
|
+
if pairingYieldActive {
|
|
1893
|
+
Bridge.log("LIVE: connectById blocked — pairing yield active")
|
|
1894
|
+
return
|
|
1895
|
+
}
|
|
1623
1896
|
Bridge.log("connectById: \(deviceName)")
|
|
1624
1897
|
// Save the device name for future reconnection
|
|
1625
1898
|
UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
|
|
@@ -1644,7 +1917,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1644
1917
|
{
|
|
1645
1918
|
Bridge.log("Found already-connected peripheral: \(name)")
|
|
1646
1919
|
discoveredPeripherals[name] = peripheral
|
|
1647
|
-
|
|
1920
|
+
emitDiscoveredDeviceFromCache(name)
|
|
1648
1921
|
|
|
1649
1922
|
// Check if this is the device we want
|
|
1650
1923
|
if let savedDeviceName = UserDefaults.standard.string(
|
|
@@ -1670,6 +1943,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1670
1943
|
}
|
|
1671
1944
|
|
|
1672
1945
|
@objc func disconnect() {
|
|
1946
|
+
if unpairFlushPending {
|
|
1947
|
+
Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
|
|
1948
|
+
return
|
|
1949
|
+
}
|
|
1673
1950
|
Bridge.log("LIVE: disconnect() -Disconnecting from Mentra Live glasses")
|
|
1674
1951
|
|
|
1675
1952
|
// if rgbLedAuthorityClaimed {
|
|
@@ -1695,6 +1972,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1695
1972
|
|
|
1696
1973
|
/// Start the micbeat mechanism to keep LC3 audio streaming active
|
|
1697
1974
|
private func startMicBeat() {
|
|
1975
|
+
// A resumed custom-audio stream starts a new sequence domain.
|
|
1976
|
+
DeviceManager.shared.resetMicSequenceBaseline()
|
|
1698
1977
|
Bridge.log("LIVE: 🎤 Starting micbeat mechanism")
|
|
1699
1978
|
micBeatCount = 0
|
|
1700
1979
|
|
|
@@ -1941,6 +2220,18 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1941
2220
|
private var pending: PendingMessage?
|
|
1942
2221
|
private var pendingMessageTimer: Timer?
|
|
1943
2222
|
|
|
2223
|
+
/// ACK state belongs to the BLE session that sent the message. If the link drops while an ACK
|
|
2224
|
+
/// is outstanding, keeping `pending` set permanently blocks the command queue because the
|
|
2225
|
+
/// timeout timer is stopped during disconnect. Release both together before reconnecting.
|
|
2226
|
+
private func resetPendingAckState(reason: String) {
|
|
2227
|
+
pendingMessageTimer?.invalidate()
|
|
2228
|
+
pendingMessageTimer = nil
|
|
2229
|
+
|
|
2230
|
+
guard let pendingMessage = pending else { return }
|
|
2231
|
+
pending = nil
|
|
2232
|
+
Bridge.log("LIVE: Cleared pending ACK mId \(pendingMessage.id) because \(reason)")
|
|
2233
|
+
}
|
|
2234
|
+
|
|
1944
2235
|
actor CommandQueue {
|
|
1945
2236
|
private var commands: [PendingMessage] = []
|
|
1946
2237
|
|
|
@@ -2079,7 +2370,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2079
2370
|
// MARK: - BLE Scanning
|
|
2080
2371
|
|
|
2081
2372
|
private func startScan() {
|
|
2082
|
-
|
|
2373
|
+
if pairingYieldActive {
|
|
2374
|
+
Bridge.log("LIVE: startScan blocked — pairing yield active")
|
|
2375
|
+
return
|
|
2376
|
+
}
|
|
2083
2377
|
|
|
2084
2378
|
guard centralManager!.state == .poweredOn else {
|
|
2085
2379
|
Bridge.log("Attempting to scan but bluetooth is not powered on.")
|
|
@@ -2087,6 +2381,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2087
2381
|
}
|
|
2088
2382
|
|
|
2089
2383
|
Bridge.log("Starting BLE scan for Mentra Live glasses")
|
|
2384
|
+
discoveredAdvPairing.removeAll()
|
|
2090
2385
|
isScanning = true
|
|
2091
2386
|
|
|
2092
2387
|
startReadinessCheckLoop()
|
|
@@ -2103,12 +2398,6 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2103
2398
|
|
|
2104
2399
|
centralManager?.scanForPeripherals(withServices: nil, options: scanOptions)
|
|
2105
2400
|
|
|
2106
|
-
// emit already discovered peripherals:
|
|
2107
|
-
for (_, peripheral) in discoveredPeripherals {
|
|
2108
|
-
Bridge.log("LIVE: (Already discovered) peripheral: \(peripheral.name ?? "Unknown")")
|
|
2109
|
-
emitDiscoveredDevice(peripheral.name!)
|
|
2110
|
-
}
|
|
2111
|
-
|
|
2112
2401
|
// var dName = DeviceManager.shared.deviceName
|
|
2113
2402
|
// if dName.isEmpty {
|
|
2114
2403
|
// dName = "MENTRA_LIVE"
|
|
@@ -2126,6 +2415,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2126
2415
|
}
|
|
2127
2416
|
|
|
2128
2417
|
func stopScan() {
|
|
2418
|
+
manualDiscoveryActive = false
|
|
2129
2419
|
guard isScanning else { return }
|
|
2130
2420
|
|
|
2131
2421
|
centralManager?.stopScan()
|
|
@@ -2137,10 +2427,16 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2137
2427
|
// MARK: - Connection Management
|
|
2138
2428
|
|
|
2139
2429
|
private func connectToDevice(_ peripheral: CBPeripheral) {
|
|
2430
|
+
if pairingYieldActive {
|
|
2431
|
+
Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
|
|
2432
|
+
isConnecting = false
|
|
2433
|
+
return
|
|
2434
|
+
}
|
|
2140
2435
|
Bridge.log("LIVE: Connecting to device: \(peripheral.identifier.uuidString)")
|
|
2141
2436
|
|
|
2142
2437
|
isConnecting = true
|
|
2143
2438
|
updateConnectionState(ConnTypes.CONNECTING)
|
|
2439
|
+
connectingPeripheral = peripheral
|
|
2144
2440
|
connectedPeripheral = peripheral
|
|
2145
2441
|
peripheral.delegate = self
|
|
2146
2442
|
|
|
@@ -2185,6 +2481,11 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2185
2481
|
return
|
|
2186
2482
|
}
|
|
2187
2483
|
|
|
2484
|
+
if pairingYieldActive {
|
|
2485
|
+
Bridge.log("LIVE: Reconnection aborted - pairing yield active")
|
|
2486
|
+
return
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2188
2489
|
// Check if we've exceeded max attempts
|
|
2189
2490
|
if reconnectAttempts >= MAX_RECONNECT_ATTEMPTS {
|
|
2190
2491
|
Bridge.log("LIVE: Maximum reconnection attempts reached (\(MAX_RECONNECT_ATTEMPTS))")
|
|
@@ -2217,6 +2518,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2217
2518
|
guard let self = self else { return }
|
|
2218
2519
|
|
|
2219
2520
|
// Use peripheral presence, not connectionState: we stay CONNECTING during backoff until scan/connect.
|
|
2521
|
+
if self.pairingYieldActive {
|
|
2522
|
+
Bridge.log("LIVE: Reconnection aborted - pairing yield active")
|
|
2523
|
+
return
|
|
2524
|
+
}
|
|
2220
2525
|
if self.connectedPeripheral == nil, !self.isKilled {
|
|
2221
2526
|
// Check for last known device name to start scan
|
|
2222
2527
|
if let lastDeviceName = UserDefaults.standard.string(
|
|
@@ -2446,11 +2751,21 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2446
2751
|
|
|
2447
2752
|
switch type {
|
|
2448
2753
|
case "glasses_ready":
|
|
2754
|
+
// A queued glasses_ready from the connection we just cancelled for yield
|
|
2755
|
+
// must not close the pairing window. Only accept readiness on a live GATT.
|
|
2756
|
+
if pairingYieldActive && connectedPeripheral == nil {
|
|
2757
|
+
Bridge.log("LIVE: Ignoring stale glasses_ready during pairing yield")
|
|
2758
|
+
return
|
|
2759
|
+
}
|
|
2449
2760
|
// glasses_ready is a REMOTE wire-session reset: the glasses ran
|
|
2450
2761
|
// onTransportReset() before sending it, so their side is back on legacy
|
|
2451
2762
|
// framing regardless of what this side negotiated earlier. Start a fresh
|
|
2452
2763
|
// wire epoch (clears v2-active state that would otherwise gate the
|
|
2453
2764
|
// handshake off), then negotiate from the caps this message advertises.
|
|
2765
|
+
pairingYieldAwaitingReclaim = false
|
|
2766
|
+
pairingYieldActive = false
|
|
2767
|
+
pairingYieldEndWorkItem?.cancel()
|
|
2768
|
+
pairingYieldEndWorkItem = nil
|
|
2454
2769
|
resetWireNegotiationState()
|
|
2455
2770
|
parsePeerWireCaps(json)
|
|
2456
2771
|
maybeSendWireHandshake()
|
|
@@ -2602,6 +2917,26 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2602
2917
|
case "pong":
|
|
2603
2918
|
Bridge.log("LIVE: Received pong response - connection healthy")
|
|
2604
2919
|
|
|
2920
|
+
case "entering_pairing_mode":
|
|
2921
|
+
let windowMs = max(5_000, min(180_000, json["window_ms"] as? Int ?? 120_000))
|
|
2922
|
+
Bridge.log("LIVE: Glasses entering pairing mode — yield \(windowMs)ms (no forget)")
|
|
2923
|
+
enterPairingYield(windowMs: windowMs)
|
|
2924
|
+
let body: [String: Any] = [
|
|
2925
|
+
"window_ms": windowMs,
|
|
2926
|
+
"reason": json["reason"] as? String ?? "user_gesture",
|
|
2927
|
+
]
|
|
2928
|
+
Bridge.sendTypedMessage("entering_pairing_mode", body: body)
|
|
2929
|
+
|
|
2930
|
+
case "pairing_info":
|
|
2931
|
+
Bridge.sendPairingInfo(
|
|
2932
|
+
hadPreviousBond: json["had_previous_bond"] as? Bool ?? false,
|
|
2933
|
+
pairingCode: json["pairing_code"] as? String,
|
|
2934
|
+
classicBondReady: json["classic_bond_ready"] as? Bool ?? false,
|
|
2935
|
+
// Legacy firmware that omits this field is not secure-capable.
|
|
2936
|
+
securePairingCapable: json["secure_pairing_capable"] as? Bool ?? false,
|
|
2937
|
+
protocolVersion: json["protocol_version"] as? Int ?? 1
|
|
2938
|
+
)
|
|
2939
|
+
|
|
2605
2940
|
case "imu_response", "imu_stream_response", "imu_gesture_response",
|
|
2606
2941
|
"imu_gesture_subscribed", "imu_ack", "imu_error":
|
|
2607
2942
|
// Handle IMU-related responses
|
|
@@ -2669,6 +3004,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2669
3004
|
if let seq = json["sq"] as? [String] ?? json["step_sequence"] as? [String], !seq.isEmpty {
|
|
2670
3005
|
cachedOtaStepSequence = seq
|
|
2671
3006
|
}
|
|
3007
|
+
updateBesOtaHeartbeatGuard(stepType: osStepType, phase: osPhase, status: osStatus)
|
|
2672
3008
|
|
|
2673
3009
|
Bridge.log("LIVE: 📱 OTA status - step \(osCurrentStep)/\(osTotalSteps) \(osPhase) \(osStatus) \(osOverallPercent)%")
|
|
2674
3010
|
|
|
@@ -2702,6 +3038,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2702
3038
|
} else {
|
|
2703
3039
|
unified = "in_progress"
|
|
2704
3040
|
}
|
|
3041
|
+
updateBesOtaHeartbeatGuard(stepType: currentUpdate, phase: legacyPhase, status: unified)
|
|
2705
3042
|
Bridge.log(
|
|
2706
3043
|
"LIVE: 📱 Legacy ota_progress → ota_status: \(legacyStage) \(legacyStatus) \(legacyProgress)%"
|
|
2707
3044
|
)
|
|
@@ -2761,9 +3098,19 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2761
3098
|
if let systemTimeMs = fields["system_time_ms"] as? NSNumber {
|
|
2762
3099
|
DeviceStore.shared.apply("glasses", "systemTimeMs", systemTimeMs.int64Value)
|
|
2763
3100
|
}
|
|
3101
|
+
if let hotspotOtaVersion = fields["hotspot_ota_version"] as? NSNumber {
|
|
3102
|
+
DeviceStore.shared.apply(
|
|
3103
|
+
"glasses",
|
|
3104
|
+
"hotspotOtaVersion",
|
|
3105
|
+
hotspotOtaVersion.intValue
|
|
3106
|
+
)
|
|
3107
|
+
}
|
|
2764
3108
|
if let bluetoothMacAddress = nonEmptyStringValue(fields, "bt_mac_address") {
|
|
2765
3109
|
DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
|
|
2766
3110
|
}
|
|
3111
|
+
if let wifiMacAddress = nonEmptyStringValue(fields, "wifi_mac_address") {
|
|
3112
|
+
DeviceStore.shared.apply("glasses", "wifiMacAddress", wifiMacAddress)
|
|
3113
|
+
}
|
|
2767
3114
|
if let serialNumber = nonEmptyStringValue(fields, "serial_number") {
|
|
2768
3115
|
DeviceStore.shared.apply("glasses", "serialNumber", serialNumber)
|
|
2769
3116
|
}
|
|
@@ -2829,6 +3176,19 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2829
3176
|
cachedOtaCurrentStep = 0
|
|
2830
3177
|
cachedOtaStepSequence = nil
|
|
2831
3178
|
lastBesOtaProgress = -1
|
|
3179
|
+
// The bounded UART-ownership lease is transport state, not session-cache state. Keep it
|
|
3180
|
+
// through a BLE reconnect so heartbeats cannot resume while BES still owns the UART.
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
private func updateBesOtaHeartbeatGuard(stepType: String, phase: String, status: String) {
|
|
3184
|
+
guard stepType.lowercased() == "bes", phase.lowercased() == "install" else { return }
|
|
3185
|
+
switch status.lowercased() {
|
|
3186
|
+
case "failed", "complete", "step_complete", "finished", "success":
|
|
3187
|
+
besOtaHeartbeatSuppressedUntil = 0
|
|
3188
|
+
default:
|
|
3189
|
+
besOtaHeartbeatSuppressedUntil =
|
|
3190
|
+
ProcessInfo.processInfo.systemUptime + BES_OTA_HEARTBEAT_LEASE_SEC
|
|
3191
|
+
}
|
|
2832
3192
|
}
|
|
2833
3193
|
|
|
2834
3194
|
/// Falls back to raw besProgress when step sequence is unavailable.
|
|
@@ -3000,62 +3360,41 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3000
3360
|
var progress = ((rawProgress + 2) / 5) * 5
|
|
3001
3361
|
if progress > 100 { progress = 100 }
|
|
3002
3362
|
|
|
3003
|
-
// Only send if progress changed to a new 5% increment
|
|
3004
3363
|
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
3364
|
var besOtaProgressVal: Int
|
|
3017
3365
|
var besOtaErrorMessage: String? = nil
|
|
3018
3366
|
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3367
|
+
let failed = type == "error" || type == "fail"
|
|
3368
|
+
let succeeded = type == "success"
|
|
3369
|
+
if isTerminalStatus {
|
|
3370
|
+
besOtaHeartbeatSuppressedUntil = 0
|
|
3371
|
+
} else {
|
|
3372
|
+
// Refresh before progress de-duplication: raw progress proves the BES
|
|
3373
|
+
// transfer is active even when the rounded UI value is unchanged.
|
|
3374
|
+
besOtaHeartbeatSuppressedUntil =
|
|
3375
|
+
ProcessInfo.processInfo.systemUptime + BES_OTA_HEARTBEAT_LEASE_SEC
|
|
3376
|
+
}
|
|
3377
|
+
if succeeded {
|
|
3025
3378
|
besOtaProgressVal = 100
|
|
3026
|
-
|
|
3027
|
-
} else if type == "error" || type == "fail" {
|
|
3028
|
-
besOtaStatus = "FAILED"
|
|
3379
|
+
} else if failed {
|
|
3029
3380
|
besOtaProgressVal = progress
|
|
3030
3381
|
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
3382
|
} else {
|
|
3036
|
-
//
|
|
3037
|
-
|
|
3038
|
-
besOtaProgressVal = progress
|
|
3383
|
+
// A raw update:100 precedes whole-image CRC/apply and is not terminal.
|
|
3384
|
+
besOtaProgressVal = min(progress, 95)
|
|
3039
3385
|
}
|
|
3040
3386
|
|
|
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"
|
|
3387
|
+
// Only send if nonterminal display progress changed to a new 5% increment.
|
|
3388
|
+
if besOtaProgressVal == lastBesOtaProgress && !isTerminalStatus {
|
|
3389
|
+
break
|
|
3058
3390
|
}
|
|
3391
|
+
lastBesOtaProgress = isTerminalStatus ? -1 : besOtaProgressVal
|
|
3392
|
+
|
|
3393
|
+
Bridge.log(
|
|
3394
|
+
"LIVE: 📱 BES OTA progress via sr_adota - type: \(type), raw: \(rawProgress)%, rounded: \(progress)%, display: \(besOtaProgressVal)%"
|
|
3395
|
+
)
|
|
3396
|
+
|
|
3397
|
+
let syntheticStatus = succeeded ? "step_complete" : (failed ? "failed" : "in_progress")
|
|
3059
3398
|
let sid = cachedOtaSessionId ?? ""
|
|
3060
3399
|
let totalSteps = cachedOtaTotalSteps > 0 ? cachedOtaTotalSteps : 1
|
|
3061
3400
|
let currentStep = cachedOtaCurrentStep > 0 ? cachedOtaCurrentStep : 1
|
|
@@ -3210,6 +3549,17 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3210
3549
|
sendJson(json, wakeUp: true)
|
|
3211
3550
|
}
|
|
3212
3551
|
|
|
3552
|
+
func sendWifiAdbState(_ enabled: Bool) {
|
|
3553
|
+
Bridge.log("LIVE: 🔧 Sending Wi-Fi ADB state: \(enabled)")
|
|
3554
|
+
|
|
3555
|
+
let json: [String: Any] = [
|
|
3556
|
+
"type": "set_wifi_adb_state",
|
|
3557
|
+
"enabled": enabled,
|
|
3558
|
+
]
|
|
3559
|
+
|
|
3560
|
+
sendJson(json, wakeUp: true)
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3213
3563
|
func sendSetSystemTime(_ timestampMs: Int64) {
|
|
3214
3564
|
Bridge.log("LIVE: ⏰ Sending set_system_time: \(timestampMs)")
|
|
3215
3565
|
|
|
@@ -3329,7 +3679,6 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3329
3679
|
{
|
|
3330
3680
|
json["ota_version_url"] = otaVersionUrl
|
|
3331
3681
|
}
|
|
3332
|
-
|
|
3333
3682
|
sendJson(json, wakeUp: true)
|
|
3334
3683
|
}
|
|
3335
3684
|
|
|
@@ -3500,17 +3849,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3500
3849
|
return
|
|
3501
3850
|
}
|
|
3502
3851
|
|
|
3503
|
-
let sequenceNumber =
|
|
3852
|
+
let sequenceNumber = Int(data[1])
|
|
3504
3853
|
let lc3Data = data.subdata(in: 2 ..< data.count)
|
|
3505
3854
|
|
|
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
3855
|
// // Decode LC3 to PCM using existing PcmConverter
|
|
3515
3856
|
// let pcmConverter = PcmConverter()
|
|
3516
3857
|
// guard let pcmData = pcmConverter.decode(lc3Data) as? Data, pcmData.count > 0 else {
|
|
@@ -3524,7 +3865,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3524
3865
|
// Bridge.log(
|
|
3525
3866
|
// "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count) bytes"
|
|
3526
3867
|
// )
|
|
3527
|
-
DeviceManager.shared.handleGlassesMicData(lc3Data, 40)
|
|
3868
|
+
DeviceManager.shared.handleGlassesMicData(lc3Data, 40, sequenceNumber: sequenceNumber)
|
|
3528
3869
|
|
|
3529
3870
|
// Bridge.log(
|
|
3530
3871
|
// "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count)→\(pcmData.count) bytes"
|
|
@@ -5068,6 +5409,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5068
5409
|
Bridge.log("LIVE: Skipping heartbeat - glasses not fully booted or not connected")
|
|
5069
5410
|
return
|
|
5070
5411
|
}
|
|
5412
|
+
if ProcessInfo.processInfo.systemUptime < besOtaHeartbeatSuppressedUntil {
|
|
5413
|
+
Bridge.log("LIVE: Skipping heartbeat while BES OTA owns the UART")
|
|
5414
|
+
return
|
|
5415
|
+
}
|
|
5071
5416
|
|
|
5072
5417
|
// Send ping message to glasses hardware (no ACK needed for heartbeats)
|
|
5073
5418
|
let pingJson: [String: Any] = ["type": "ping"]
|
|
@@ -5181,16 +5526,89 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5181
5526
|
stopReadinessCheckLoop()
|
|
5182
5527
|
stopConnectionTimeout()
|
|
5183
5528
|
stopMicBeat() // Stop LC3 audio micbeat
|
|
5184
|
-
|
|
5185
|
-
pendingMessageTimer = nil
|
|
5529
|
+
resetPendingAckState(reason: "transport timers stopped")
|
|
5186
5530
|
reconnectionWorkItem?.cancel()
|
|
5187
5531
|
reconnectionWorkItem = nil
|
|
5188
5532
|
}
|
|
5189
5533
|
|
|
5190
5534
|
// MARK: - Event Emission
|
|
5191
5535
|
|
|
5192
|
-
|
|
5193
|
-
|
|
5536
|
+
/// A selected pairing target can stop advertising once GATT connects, before readiness
|
|
5537
|
+
/// promotes it to the default device. Re-emit only that explicit pending target; an
|
|
5538
|
+
/// established owner's connected glasses have no pending identity and remain hidden.
|
|
5539
|
+
private func emitConnectedPendingDeviceForPairingScan() {
|
|
5540
|
+
guard manualDiscoveryActive, !pairingYieldActive, let peripheral = connectedPeripheral,
|
|
5541
|
+
let name = peripheral.name,
|
|
5542
|
+
MentraLivePendingPairingTarget.shouldRecover(
|
|
5543
|
+
isConnected: peripheral.state == .connected,
|
|
5544
|
+
connectedName: name,
|
|
5545
|
+
connectedIdentifier: peripheral.identifier.uuidString,
|
|
5546
|
+
pendingName: DeviceStore.shared.get("bluetooth", "pending_device_name") as? String ?? "",
|
|
5547
|
+
pendingIdentifier: DeviceStore.shared.get("bluetooth", "pending_device_address") as? String ?? ""
|
|
5548
|
+
)
|
|
5549
|
+
else {
|
|
5550
|
+
return
|
|
5551
|
+
}
|
|
5552
|
+
|
|
5553
|
+
Bridge.log("LIVE: Pairing scan: recovering connected pending target \(name) (\(peripheral.identifier))")
|
|
5554
|
+
emitDiscoveredDevice(
|
|
5555
|
+
name,
|
|
5556
|
+
identifier: peripheral.identifier.uuidString,
|
|
5557
|
+
pairingMode: true,
|
|
5558
|
+
pairingCode: nil,
|
|
5559
|
+
securePairingCapable: DeviceStore.shared.get(
|
|
5560
|
+
"bluetooth",
|
|
5561
|
+
"pending_device_secure_pairing_capable"
|
|
5562
|
+
) as? Bool
|
|
5563
|
+
)
|
|
5564
|
+
}
|
|
5565
|
+
|
|
5566
|
+
private func cacheAdvPairing(
|
|
5567
|
+
_ name: String,
|
|
5568
|
+
pairingMode: Bool,
|
|
5569
|
+
pairingCode: String?,
|
|
5570
|
+
securePairingCapable: Bool
|
|
5571
|
+
) {
|
|
5572
|
+
discoveredAdvPairing[name] = CachedAdvPairing(
|
|
5573
|
+
pairingMode: pairingMode,
|
|
5574
|
+
pairingCode: pairingCode,
|
|
5575
|
+
securePairingCapable: securePairingCapable
|
|
5576
|
+
)
|
|
5577
|
+
}
|
|
5578
|
+
|
|
5579
|
+
private func emitDiscoveredDeviceFromCache(
|
|
5580
|
+
_ name: String,
|
|
5581
|
+
identifier: String = "",
|
|
5582
|
+
rssi: Int? = nil
|
|
5583
|
+
) {
|
|
5584
|
+
let cached = discoveredAdvPairing[name]
|
|
5585
|
+
emitDiscoveredDevice(
|
|
5586
|
+
name,
|
|
5587
|
+
identifier: identifier,
|
|
5588
|
+
rssi: rssi,
|
|
5589
|
+
pairingMode: cached?.pairingMode,
|
|
5590
|
+
pairingCode: cached?.pairingCode,
|
|
5591
|
+
securePairingCapable: cached?.securePairingCapable
|
|
5592
|
+
)
|
|
5593
|
+
}
|
|
5594
|
+
|
|
5595
|
+
private func emitDiscoveredDevice(
|
|
5596
|
+
_ name: String,
|
|
5597
|
+
identifier: String = "",
|
|
5598
|
+
rssi: Int? = nil,
|
|
5599
|
+
pairingMode: Bool? = nil,
|
|
5600
|
+
pairingCode: String? = nil,
|
|
5601
|
+
securePairingCapable: Bool? = nil
|
|
5602
|
+
) {
|
|
5603
|
+
Bridge.sendDiscoveredDevice(
|
|
5604
|
+
"Mentra Live",
|
|
5605
|
+
name,
|
|
5606
|
+
deviceAddress: identifier,
|
|
5607
|
+
rssi: rssi,
|
|
5608
|
+
pairingMode: pairingMode,
|
|
5609
|
+
pairingCode: pairingCode,
|
|
5610
|
+
securePairingCapable: securePairingCapable
|
|
5611
|
+
)
|
|
5194
5612
|
}
|
|
5195
5613
|
|
|
5196
5614
|
private func emitStopScanEvent() {
|
|
@@ -5279,6 +5697,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5279
5697
|
Bridge.log("Destroying MentraLiveManager")
|
|
5280
5698
|
|
|
5281
5699
|
isKilled = true
|
|
5700
|
+
unpairFlushWorkItem?.cancel()
|
|
5701
|
+
unpairFlushWorkItem = nil
|
|
5702
|
+
unpairFlushPending = false
|
|
5282
5703
|
|
|
5283
5704
|
// Stop scanning
|
|
5284
5705
|
if isScanning {
|
|
@@ -5312,6 +5733,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5312
5733
|
DeviceStore.shared.apply("glasses", "hotspotGatewayIp", "")
|
|
5313
5734
|
|
|
5314
5735
|
connectedPeripheral = nil
|
|
5736
|
+
discoveredPeripherals.removeAll()
|
|
5737
|
+
discoveredAdvPairing.removeAll()
|
|
5315
5738
|
centralManager?.delegate = nil
|
|
5316
5739
|
centralManager = nil
|
|
5317
5740
|
|
|
@@ -6521,6 +6944,17 @@ extension MentraLive {
|
|
|
6521
6944
|
stopVideoRecording(requestId: requestId, webhookUrl: nil, authToken: nil)
|
|
6522
6945
|
}
|
|
6523
6946
|
|
|
6947
|
+
func queryVideoRecordingStatus(requestId: String) {
|
|
6948
|
+
guard connectionState == ConnTypes.CONNECTED else {
|
|
6949
|
+
Bridge.log("Cannot query video recording status - not connected")
|
|
6950
|
+
return
|
|
6951
|
+
}
|
|
6952
|
+
sendJson([
|
|
6953
|
+
"type": "get_video_recording_status",
|
|
6954
|
+
"requestId": requestId,
|
|
6955
|
+
])
|
|
6956
|
+
}
|
|
6957
|
+
|
|
6524
6958
|
func stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
|
|
6525
6959
|
Bridge.log(
|
|
6526
6960
|
"Stopping video recording on glasses: requestId=\(requestId), webhook=\((webhookUrl?.isEmpty ?? true) ? "none" : "set")"
|