@mentra/bluetooth-sdk 0.1.21-beta.3 → 3.1.0-dev.4
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 +38 -2
- 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 +39 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +209 -21
- 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 +1381 -284
- 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/utils/BleJsonCompactTest.java +148 -7
- package/build/BluetoothSdk.types.d.ts +63 -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 +47 -3
- package/ios/Source/DeviceManager.swift +146 -16
- 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 +498 -77
- 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/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 +74 -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,10 +932,10 @@ 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
|
|
|
@@ -939,10 +984,17 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
939
984
|
}
|
|
940
985
|
|
|
941
986
|
nonisolated func centralManager(
|
|
942
|
-
_: CBCentralManager, didDisconnectPeripheral
|
|
987
|
+
_: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?
|
|
943
988
|
) {
|
|
944
989
|
DispatchQueue.main.async { [weak self] in
|
|
945
990
|
guard let self else { return }
|
|
991
|
+
let isCurrent =
|
|
992
|
+
self.connectedPeripheral === peripheral || self.connectingPeripheral === peripheral
|
|
993
|
+
if !isCurrent {
|
|
994
|
+
Bridge.log("LIVE: Ignoring stale disconnect for \(peripheral.identifier)")
|
|
995
|
+
return
|
|
996
|
+
}
|
|
997
|
+
self.connectingPeripheral = nil
|
|
946
998
|
Bridge.log("LIVE: Disconnected from GATT server")
|
|
947
999
|
|
|
948
1000
|
self.isConnecting = false
|
|
@@ -962,6 +1014,11 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
962
1014
|
self.txCharacteristic = nil
|
|
963
1015
|
self.rxCharacteristic = nil
|
|
964
1016
|
|
|
1017
|
+
if self.pairingYieldAwaitingReclaim, Self.isPairingAuthFailure(error) {
|
|
1018
|
+
self.clearSavedGlassesAfterOwnerLoss(reason: "ios_auth_fail")
|
|
1019
|
+
return
|
|
1020
|
+
}
|
|
1021
|
+
|
|
965
1022
|
// Attempt reconnection if not killed
|
|
966
1023
|
if !self.isKilled {
|
|
967
1024
|
self.handleReconnection()
|
|
@@ -969,10 +1026,17 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
969
1026
|
}
|
|
970
1027
|
}
|
|
971
1028
|
|
|
972
|
-
nonisolated func centralManager(_: CBCentralManager, didFailToConnect
|
|
1029
|
+
nonisolated func centralManager(_: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
|
973
1030
|
let errorDescription = error?.localizedDescription ?? "Unknown error"
|
|
974
1031
|
DispatchQueue.main.async { [weak self] in
|
|
975
1032
|
guard let self else { return }
|
|
1033
|
+
let isCurrent =
|
|
1034
|
+
self.connectedPeripheral === peripheral || self.connectingPeripheral === peripheral
|
|
1035
|
+
if !isCurrent {
|
|
1036
|
+
Bridge.log("LIVE: Ignoring stale connect failure for \(peripheral.identifier)")
|
|
1037
|
+
return
|
|
1038
|
+
}
|
|
1039
|
+
self.connectingPeripheral = nil
|
|
976
1040
|
Bridge.log("LIVE: Failed to connect to peripheral: \(errorDescription)")
|
|
977
1041
|
|
|
978
1042
|
self.stopConnectionTimeout()
|
|
@@ -981,6 +1045,11 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
981
1045
|
self.connectedPeripheral = nil
|
|
982
1046
|
self.updateConnectionState(ConnTypes.DISCONNECTED)
|
|
983
1047
|
|
|
1048
|
+
if self.pairingYieldAwaitingReclaim, Self.isPairingAuthFailure(error) {
|
|
1049
|
+
self.clearSavedGlassesAfterOwnerLoss(reason: "ios_auth_fail")
|
|
1050
|
+
return
|
|
1051
|
+
}
|
|
1052
|
+
|
|
984
1053
|
if !self.isKilled {
|
|
985
1054
|
self.handleReconnection()
|
|
986
1055
|
}
|
|
@@ -1276,6 +1345,83 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1276
1345
|
private let BLOCK_AUDIO_DUPLEX = false
|
|
1277
1346
|
private static let voiceActivityDetectionSwitchType = 8
|
|
1278
1347
|
private static let loudnessGateSwitchType = 10
|
|
1348
|
+
private let mentraManufacturerId: UInt16 = 0xB822
|
|
1349
|
+
// Payload-relative offset of the pairing flag, matching Android's index into the
|
|
1350
|
+
// company-id-stripped manufacturer data from getManufacturerSpecificData().
|
|
1351
|
+
private let advManufPairingFlagOffset = 5
|
|
1352
|
+
// CoreBluetooth returns manufacturer data with the 2-byte company id prefix still attached,
|
|
1353
|
+
// whereas Android strips it. Skip the prefix so both platforms read the same payload byte.
|
|
1354
|
+
private let advManufCompanyIdLength = 2
|
|
1355
|
+
private let advPairingDiscoverable: UInt8 = 0x01
|
|
1356
|
+
|
|
1357
|
+
// CoreBluetooth returns manufacturer data prefixed with the 2-byte company id
|
|
1358
|
+
// (little-endian) and does NOT filter by company id itself (unlike Android's
|
|
1359
|
+
// getManufacturerSpecificData(companyId)), so every reader of this data must
|
|
1360
|
+
// verify the company id before trusting any flag/trailer byte.
|
|
1361
|
+
private func mentraManufacturerData(_ advertisementData: [String: Any]) -> Data? {
|
|
1362
|
+
guard let manufData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data,
|
|
1363
|
+
manufData.count >= advManufCompanyIdLength
|
|
1364
|
+
else {
|
|
1365
|
+
return nil
|
|
1366
|
+
}
|
|
1367
|
+
let companyId = UInt16(manufData[0]) | (UInt16(manufData[1]) << 8)
|
|
1368
|
+
guard companyId == mentraManufacturerId else {
|
|
1369
|
+
return nil
|
|
1370
|
+
}
|
|
1371
|
+
return manufData
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/// OS-1615 ads append `flag | version | capability | code_lo | code_hi` after the
|
|
1375
|
+
/// connected byte. Field firmware uses the same 0xB822 company id but then writes
|
|
1376
|
+
/// the XOR'd Classic MAC at those offsets. Length alone is not a pairing flag.
|
|
1377
|
+
private func hasSecurePairingTrailer(_ advertisementData: [String: Any]) -> Bool {
|
|
1378
|
+
let flagIndex = advManufCompanyIdLength + advManufPairingFlagOffset
|
|
1379
|
+
let trailerBase = flagIndex + 1
|
|
1380
|
+
guard let manufData = mentraManufacturerData(advertisementData),
|
|
1381
|
+
manufData.count >= trailerBase + 4
|
|
1382
|
+
else {
|
|
1383
|
+
return false
|
|
1384
|
+
}
|
|
1385
|
+
let version = Int(manufData[trailerBase])
|
|
1386
|
+
let capability = Int(manufData[trailerBase + 1])
|
|
1387
|
+
return (1...15).contains(version) && (capability & 0x01) != 0
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
private func isPairingDiscoverable(_ advertisementData: [String: Any]) -> Bool {
|
|
1391
|
+
guard hasSecurePairingTrailer(advertisementData),
|
|
1392
|
+
let manufData = mentraManufacturerData(advertisementData)
|
|
1393
|
+
else {
|
|
1394
|
+
return false
|
|
1395
|
+
}
|
|
1396
|
+
let flagIndex = advManufCompanyIdLength + advManufPairingFlagOffset
|
|
1397
|
+
return manufData[flagIndex] == advPairingDiscoverable
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
private struct SecurePairingTrailer {
|
|
1401
|
+
let pairingMode: Bool
|
|
1402
|
+
let pairingCode: String?
|
|
1403
|
+
let secureCapable: Bool
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/// Trailer immediately after pairing flag: version | capability | code_lo | code_hi
|
|
1407
|
+
private func parseSecurePairingTrailer(_ advertisementData: [String: Any]) -> SecurePairingTrailer {
|
|
1408
|
+
guard hasSecurePairingTrailer(advertisementData),
|
|
1409
|
+
let manufData = mentraManufacturerData(advertisementData)
|
|
1410
|
+
else {
|
|
1411
|
+
return SecurePairingTrailer(pairingMode: false, pairingCode: nil, secureCapable: false)
|
|
1412
|
+
}
|
|
1413
|
+
let flagIndex = advManufCompanyIdLength + advManufPairingFlagOffset
|
|
1414
|
+
let trailerBase = flagIndex + 1
|
|
1415
|
+
let pairingMode = manufData[flagIndex] == advPairingDiscoverable
|
|
1416
|
+
let codeLo = Int(manufData[trailerBase + 2])
|
|
1417
|
+
let codeHi = Int(manufData[trailerBase + 3])
|
|
1418
|
+
let code = String(format: "%02X%02X", codeHi, codeLo)
|
|
1419
|
+
return SecurePairingTrailer(pairingMode: pairingMode, pairingCode: code, secureCapable: true)
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
private func advertisesPairingFlag(_ advertisementData: [String: Any]) -> Bool {
|
|
1423
|
+
hasSecurePairingTrailer(advertisementData)
|
|
1424
|
+
}
|
|
1279
1425
|
|
|
1280
1426
|
var connectionState: String = ConnTypes.DISCONNECTED
|
|
1281
1427
|
|
|
@@ -1290,6 +1436,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1290
1436
|
// return, unlike Android).
|
|
1291
1437
|
DeviceStore.shared.apply("glasses", "serialNumber", "")
|
|
1292
1438
|
DeviceStore.shared.apply("glasses", "bluetoothMacAddress", "")
|
|
1439
|
+
DeviceStore.shared.apply("glasses", "wifiMacAddress", "")
|
|
1440
|
+
DeviceStore.shared.apply("glasses", "hotspotOtaVersion", 0)
|
|
1293
1441
|
}
|
|
1294
1442
|
connectionState = state
|
|
1295
1443
|
DeviceStore.shared.apply("glasses", "connectionState", state)
|
|
@@ -1297,6 +1445,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1297
1445
|
// a previous pairing into the next one (would otherwise surface as wrong overall_percent
|
|
1298
1446
|
// or stale lastBesOtaProgress on the next OTA).
|
|
1299
1447
|
if state == ConnTypes.DISCONNECTED {
|
|
1448
|
+
resetPendingAckState(reason: "BLE session ended")
|
|
1300
1449
|
resetWireNegotiationState()
|
|
1301
1450
|
resetAncsRelayState()
|
|
1302
1451
|
// Queued writes are session-bound: transmitting them into the NEXT session
|
|
@@ -1342,14 +1491,26 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1342
1491
|
func forget() {
|
|
1343
1492
|
Bridge.log("LIVE: Forgetting Mentra Live glasses")
|
|
1344
1493
|
|
|
1494
|
+
// Clear saved device name so the pairing filter is not bypassed on rescan
|
|
1495
|
+
UserDefaults.standard.removeObject(forKey: PREFS_DEVICE_NAME)
|
|
1496
|
+
|
|
1345
1497
|
// Stop scanning first
|
|
1346
1498
|
if isScanning {
|
|
1347
1499
|
stopScan()
|
|
1348
1500
|
emitStopScanEvent()
|
|
1349
1501
|
}
|
|
1350
1502
|
|
|
1351
|
-
// Then do full cleanup
|
|
1352
|
-
|
|
1503
|
+
// Then do full cleanup after the unpair write can leave GATT.
|
|
1504
|
+
sendJson(["type": "unpair"], wakeUp: true, requireAck: false)
|
|
1505
|
+
unpairFlushWorkItem?.cancel()
|
|
1506
|
+
unpairFlushPending = true
|
|
1507
|
+
let work = DispatchWorkItem { [self] in
|
|
1508
|
+
self.unpairFlushPending = false
|
|
1509
|
+
self.unpairFlushWorkItem = nil
|
|
1510
|
+
self.destroy()
|
|
1511
|
+
}
|
|
1512
|
+
unpairFlushWorkItem = work
|
|
1513
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: work)
|
|
1353
1514
|
}
|
|
1354
1515
|
|
|
1355
1516
|
var type = "Mentra Live"
|
|
@@ -1427,7 +1588,6 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1427
1588
|
private var lc3ReadCharacteristic: CBCharacteristic?
|
|
1428
1589
|
private var lc3WriteCharacteristic: CBCharacteristic?
|
|
1429
1590
|
private var supportsLC3Audio = true
|
|
1430
|
-
private var lastReceivedLc3Sequence: Int8 = -1
|
|
1431
1591
|
private let LC3_FRAME_SIZE = 40 // bytes per LC3 frame
|
|
1432
1592
|
private let MICBEAT_INTERVAL_MS: TimeInterval = 30 * 60 // 30 minutes in seconds
|
|
1433
1593
|
private var micBeatTimer: Timer?
|
|
@@ -1452,6 +1612,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1452
1612
|
private let KEEP_ALIVE_INTERVAL_MS: UInt64 = 5_000_000_000 // 5 seconds
|
|
1453
1613
|
private let CONNECTION_TIMEOUT_MS: UInt64 = 100_000_000_000 // 100 seconds
|
|
1454
1614
|
private let HEARTBEAT_INTERVAL_MS: TimeInterval = 30.0 // 30 seconds
|
|
1615
|
+
private let BES_OTA_HEARTBEAT_LEASE_SEC: TimeInterval = 120.0
|
|
1455
1616
|
private let BATTERY_REQUEST_EVERY_N_HEARTBEATS = 10
|
|
1456
1617
|
private let SIGNAL_STRENGTH_READ_INTERVAL_MS: TimeInterval = 10.0
|
|
1457
1618
|
private let MIN_SEND_DELAY_MS: UInt64 = 160_000_000 // 160ms in nanoseconds
|
|
@@ -1472,6 +1633,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1472
1633
|
private var centralManager: CBCentralManager?
|
|
1473
1634
|
|
|
1474
1635
|
private var connectedPeripheral: CBPeripheral?
|
|
1636
|
+
/// Peripheral for the in-flight connect; used to ignore stale disconnect/fail callbacks.
|
|
1637
|
+
private var connectingPeripheral: CBPeripheral?
|
|
1475
1638
|
private var txCharacteristic: CBCharacteristic?
|
|
1476
1639
|
private var rxCharacteristic: CBCharacteristic?
|
|
1477
1640
|
private let bes2700MtuLimit = 509
|
|
@@ -1481,6 +1644,13 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1481
1644
|
private var isScanning = false
|
|
1482
1645
|
private var isConnecting = false
|
|
1483
1646
|
private var isKilled = false
|
|
1647
|
+
/// Glasses opened pairing window — stand down without forgetting identity/bonds.
|
|
1648
|
+
private var pairingYieldActive = false
|
|
1649
|
+
private var pairingYieldAwaitingReclaim = false
|
|
1650
|
+
private var pairingYieldEndWorkItem: DispatchWorkItem?
|
|
1651
|
+
/// Hold GATT teardown until the unpair write can leave the phone.
|
|
1652
|
+
private var unpairFlushPending = false
|
|
1653
|
+
private var unpairFlushWorkItem: DispatchWorkItem?
|
|
1484
1654
|
private var reconnectAttempts = 0
|
|
1485
1655
|
private var isNewVersion = false
|
|
1486
1656
|
private var peerWireProtocolVersion = 0
|
|
@@ -1546,6 +1716,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1546
1716
|
|
|
1547
1717
|
/// BES OTA progress tracking - only send to UI on 5% increments
|
|
1548
1718
|
private var lastBesOtaProgress = -1
|
|
1719
|
+
private var besOtaHeartbeatSuppressedUntil: TimeInterval = 0
|
|
1549
1720
|
|
|
1550
1721
|
// Cached OTA session context from last ota_status — used to fill in session fields for sr_adota
|
|
1551
1722
|
private var cachedOtaSessionId: String?
|
|
@@ -1598,6 +1769,16 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1598
1769
|
// MARK: - React Native Interface
|
|
1599
1770
|
|
|
1600
1771
|
private var discoveredPeripherals = [String: CBPeripheral]() // name -> peripheral
|
|
1772
|
+
/// Last advertisement pairing trailer for a discovered name. Re-emits (already-
|
|
1773
|
+
/// connected / scan replay) must not drop these flags: omitting them makes a
|
|
1774
|
+
/// secure idle unit look like pairable legacy firmware.
|
|
1775
|
+
private var discoveredAdvPairing = [String: CachedAdvPairing]()
|
|
1776
|
+
|
|
1777
|
+
private struct CachedAdvPairing {
|
|
1778
|
+
let pairingMode: Bool
|
|
1779
|
+
let pairingCode: String?
|
|
1780
|
+
let securePairingCapable: Bool
|
|
1781
|
+
}
|
|
1601
1782
|
|
|
1602
1783
|
func findCompatibleDevices() {
|
|
1603
1784
|
Bridge.log("Finding compatible Mentra Live glasses")
|
|
@@ -1619,7 +1800,89 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1619
1800
|
}
|
|
1620
1801
|
}
|
|
1621
1802
|
|
|
1803
|
+
func isPairingYieldActive() -> Bool {
|
|
1804
|
+
pairingYieldActive
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
private func enterPairingYield(windowMs: Int) {
|
|
1808
|
+
pairingYieldActive = true
|
|
1809
|
+
pairingYieldAwaitingReclaim = false
|
|
1810
|
+
pairingYieldEndWorkItem?.cancel()
|
|
1811
|
+
if isScanning {
|
|
1812
|
+
stopScan()
|
|
1813
|
+
}
|
|
1814
|
+
// Publish disconnect immediately for Phone A UI. didDisconnectPeripheral may lag
|
|
1815
|
+
// or race with cancelPeripheralConnection; do not wait on it for stand-down.
|
|
1816
|
+
isConnecting = false
|
|
1817
|
+
connected = false
|
|
1818
|
+
fullyBooted = false
|
|
1819
|
+
glassesSessionId = nil
|
|
1820
|
+
readinessCompletedThisBleSession = false
|
|
1821
|
+
rgbLedAuthorityClaimed = false
|
|
1822
|
+
stopAllTimers()
|
|
1823
|
+
closeL2capFileChannel()
|
|
1824
|
+
txCharacteristic = nil
|
|
1825
|
+
rxCharacteristic = nil
|
|
1826
|
+
updateConnectionState(ConnTypes.DISCONNECTED)
|
|
1827
|
+
if let peripheral = connectedPeripheral {
|
|
1828
|
+
centralManager?.cancelPeripheralConnection(peripheral)
|
|
1829
|
+
}
|
|
1830
|
+
connectedPeripheral = nil
|
|
1831
|
+
connectingPeripheral = nil
|
|
1832
|
+
// Stand down for the whole window. Probing GATT during yield races the new
|
|
1833
|
+
// phone for the single BLE slot and is exactly what entering_pairing_mode forbids.
|
|
1834
|
+
|
|
1835
|
+
let work = DispatchWorkItem { [weak self] in
|
|
1836
|
+
guard let self else { return }
|
|
1837
|
+
Bridge.log("LIVE: Pairing yield ended — resume reconnect if still owned")
|
|
1838
|
+
self.pairingYieldActive = false
|
|
1839
|
+
self.pairingYieldAwaitingReclaim = true
|
|
1840
|
+
self.pairingYieldEndWorkItem = nil
|
|
1841
|
+
if !self.isKilled, self.connectedPeripheral == nil {
|
|
1842
|
+
self.handleReconnection()
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
pairingYieldEndWorkItem = work
|
|
1846
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(windowMs), execute: work)
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
/// Pairing/auth failures after yield — CoreBluetooth codes, not localized strings.
|
|
1850
|
+
/// Connection timeouts are not owner-loss.
|
|
1851
|
+
private static func isPairingAuthFailure(_ error: Error?) -> Bool {
|
|
1852
|
+
guard let error = error as NSError? else { return false }
|
|
1853
|
+
if error.domain == CBATTErrorDomain {
|
|
1854
|
+
return error.code == CBATTError.insufficientAuthentication.rawValue
|
|
1855
|
+
|| error.code == CBATTError.insufficientEncryption.rawValue
|
|
1856
|
+
}
|
|
1857
|
+
if error.domain == CBErrorDomain {
|
|
1858
|
+
return error.code == CBError.peerRemovedPairingInformation.rawValue
|
|
1859
|
+
}
|
|
1860
|
+
return false
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
private func clearSavedGlassesAfterOwnerLoss(reason: String) {
|
|
1864
|
+
Bridge.log("LIVE: Owner loss (\(reason)) — clearing saved glasses")
|
|
1865
|
+
pairingYieldAwaitingReclaim = false
|
|
1866
|
+
pairingYieldActive = false
|
|
1867
|
+
pairingYieldEndWorkItem?.cancel()
|
|
1868
|
+
pairingYieldEndWorkItem = nil
|
|
1869
|
+
forget()
|
|
1870
|
+
Task { @MainActor in
|
|
1871
|
+
DeviceStore.shared.apply("bluetooth", "default_wearable", "")
|
|
1872
|
+
DeviceStore.shared.apply("bluetooth", "device_name", "")
|
|
1873
|
+
DeviceStore.shared.apply("bluetooth", "device_address", "")
|
|
1874
|
+
}
|
|
1875
|
+
Bridge.saveSetting("default_wearable", "")
|
|
1876
|
+
Bridge.saveSetting("device_name", "")
|
|
1877
|
+
Bridge.saveSetting("device_address", "")
|
|
1878
|
+
Bridge.sendTypedMessage("owner_replaced", body: ["reason": reason])
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1622
1881
|
func connectById(_ deviceName: String) {
|
|
1882
|
+
if pairingYieldActive {
|
|
1883
|
+
Bridge.log("LIVE: connectById blocked — pairing yield active")
|
|
1884
|
+
return
|
|
1885
|
+
}
|
|
1623
1886
|
Bridge.log("connectById: \(deviceName)")
|
|
1624
1887
|
// Save the device name for future reconnection
|
|
1625
1888
|
UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
|
|
@@ -1644,7 +1907,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1644
1907
|
{
|
|
1645
1908
|
Bridge.log("Found already-connected peripheral: \(name)")
|
|
1646
1909
|
discoveredPeripherals[name] = peripheral
|
|
1647
|
-
|
|
1910
|
+
emitDiscoveredDeviceFromCache(name)
|
|
1648
1911
|
|
|
1649
1912
|
// Check if this is the device we want
|
|
1650
1913
|
if let savedDeviceName = UserDefaults.standard.string(
|
|
@@ -1670,6 +1933,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1670
1933
|
}
|
|
1671
1934
|
|
|
1672
1935
|
@objc func disconnect() {
|
|
1936
|
+
if unpairFlushPending {
|
|
1937
|
+
Bridge.log("LIVE: disconnect deferred — waiting for unpair write")
|
|
1938
|
+
return
|
|
1939
|
+
}
|
|
1673
1940
|
Bridge.log("LIVE: disconnect() -Disconnecting from Mentra Live glasses")
|
|
1674
1941
|
|
|
1675
1942
|
// if rgbLedAuthorityClaimed {
|
|
@@ -1695,6 +1962,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1695
1962
|
|
|
1696
1963
|
/// Start the micbeat mechanism to keep LC3 audio streaming active
|
|
1697
1964
|
private func startMicBeat() {
|
|
1965
|
+
// A resumed custom-audio stream starts a new sequence domain.
|
|
1966
|
+
DeviceManager.shared.resetMicSequenceBaseline()
|
|
1698
1967
|
Bridge.log("LIVE: 🎤 Starting micbeat mechanism")
|
|
1699
1968
|
micBeatCount = 0
|
|
1700
1969
|
|
|
@@ -1941,6 +2210,18 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1941
2210
|
private var pending: PendingMessage?
|
|
1942
2211
|
private var pendingMessageTimer: Timer?
|
|
1943
2212
|
|
|
2213
|
+
/// ACK state belongs to the BLE session that sent the message. If the link drops while an ACK
|
|
2214
|
+
/// is outstanding, keeping `pending` set permanently blocks the command queue because the
|
|
2215
|
+
/// timeout timer is stopped during disconnect. Release both together before reconnecting.
|
|
2216
|
+
private func resetPendingAckState(reason: String) {
|
|
2217
|
+
pendingMessageTimer?.invalidate()
|
|
2218
|
+
pendingMessageTimer = nil
|
|
2219
|
+
|
|
2220
|
+
guard let pendingMessage = pending else { return }
|
|
2221
|
+
pending = nil
|
|
2222
|
+
Bridge.log("LIVE: Cleared pending ACK mId \(pendingMessage.id) because \(reason)")
|
|
2223
|
+
}
|
|
2224
|
+
|
|
1944
2225
|
actor CommandQueue {
|
|
1945
2226
|
private var commands: [PendingMessage] = []
|
|
1946
2227
|
|
|
@@ -2079,7 +2360,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2079
2360
|
// MARK: - BLE Scanning
|
|
2080
2361
|
|
|
2081
2362
|
private func startScan() {
|
|
2082
|
-
|
|
2363
|
+
if pairingYieldActive {
|
|
2364
|
+
Bridge.log("LIVE: startScan blocked — pairing yield active")
|
|
2365
|
+
return
|
|
2366
|
+
}
|
|
2083
2367
|
|
|
2084
2368
|
guard centralManager!.state == .poweredOn else {
|
|
2085
2369
|
Bridge.log("Attempting to scan but bluetooth is not powered on.")
|
|
@@ -2087,6 +2371,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2087
2371
|
}
|
|
2088
2372
|
|
|
2089
2373
|
Bridge.log("Starting BLE scan for Mentra Live glasses")
|
|
2374
|
+
discoveredAdvPairing.removeAll()
|
|
2090
2375
|
isScanning = true
|
|
2091
2376
|
|
|
2092
2377
|
startReadinessCheckLoop()
|
|
@@ -2103,11 +2388,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2103
2388
|
|
|
2104
2389
|
centralManager?.scanForPeripherals(withServices: nil, options: scanOptions)
|
|
2105
2390
|
|
|
2106
|
-
//
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
emitDiscoveredDevice(peripheral.name!)
|
|
2110
|
-
}
|
|
2391
|
+
// Fresh advertisements refill pairing metadata. Re-emitting the last scan's
|
|
2392
|
+
// cache would keep a unit pairable after it left pairing mode.
|
|
2393
|
+
emitConnectedDeviceForPairingScan()
|
|
2111
2394
|
|
|
2112
2395
|
// var dName = DeviceManager.shared.deviceName
|
|
2113
2396
|
// if dName.isEmpty {
|
|
@@ -2137,10 +2420,16 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2137
2420
|
// MARK: - Connection Management
|
|
2138
2421
|
|
|
2139
2422
|
private func connectToDevice(_ peripheral: CBPeripheral) {
|
|
2423
|
+
if pairingYieldActive {
|
|
2424
|
+
Bridge.log("LIVE: connectToDevice blocked — pairing yield active")
|
|
2425
|
+
isConnecting = false
|
|
2426
|
+
return
|
|
2427
|
+
}
|
|
2140
2428
|
Bridge.log("LIVE: Connecting to device: \(peripheral.identifier.uuidString)")
|
|
2141
2429
|
|
|
2142
2430
|
isConnecting = true
|
|
2143
2431
|
updateConnectionState(ConnTypes.CONNECTING)
|
|
2432
|
+
connectingPeripheral = peripheral
|
|
2144
2433
|
connectedPeripheral = peripheral
|
|
2145
2434
|
peripheral.delegate = self
|
|
2146
2435
|
|
|
@@ -2185,6 +2474,11 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2185
2474
|
return
|
|
2186
2475
|
}
|
|
2187
2476
|
|
|
2477
|
+
if pairingYieldActive {
|
|
2478
|
+
Bridge.log("LIVE: Reconnection aborted - pairing yield active")
|
|
2479
|
+
return
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2188
2482
|
// Check if we've exceeded max attempts
|
|
2189
2483
|
if reconnectAttempts >= MAX_RECONNECT_ATTEMPTS {
|
|
2190
2484
|
Bridge.log("LIVE: Maximum reconnection attempts reached (\(MAX_RECONNECT_ATTEMPTS))")
|
|
@@ -2217,6 +2511,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2217
2511
|
guard let self = self else { return }
|
|
2218
2512
|
|
|
2219
2513
|
// Use peripheral presence, not connectionState: we stay CONNECTING during backoff until scan/connect.
|
|
2514
|
+
if self.pairingYieldActive {
|
|
2515
|
+
Bridge.log("LIVE: Reconnection aborted - pairing yield active")
|
|
2516
|
+
return
|
|
2517
|
+
}
|
|
2220
2518
|
if self.connectedPeripheral == nil, !self.isKilled {
|
|
2221
2519
|
// Check for last known device name to start scan
|
|
2222
2520
|
if let lastDeviceName = UserDefaults.standard.string(
|
|
@@ -2446,11 +2744,21 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2446
2744
|
|
|
2447
2745
|
switch type {
|
|
2448
2746
|
case "glasses_ready":
|
|
2747
|
+
// A queued glasses_ready from the connection we just cancelled for yield
|
|
2748
|
+
// must not close the pairing window. Only accept readiness on a live GATT.
|
|
2749
|
+
if pairingYieldActive && connectedPeripheral == nil {
|
|
2750
|
+
Bridge.log("LIVE: Ignoring stale glasses_ready during pairing yield")
|
|
2751
|
+
return
|
|
2752
|
+
}
|
|
2449
2753
|
// glasses_ready is a REMOTE wire-session reset: the glasses ran
|
|
2450
2754
|
// onTransportReset() before sending it, so their side is back on legacy
|
|
2451
2755
|
// framing regardless of what this side negotiated earlier. Start a fresh
|
|
2452
2756
|
// wire epoch (clears v2-active state that would otherwise gate the
|
|
2453
2757
|
// handshake off), then negotiate from the caps this message advertises.
|
|
2758
|
+
pairingYieldAwaitingReclaim = false
|
|
2759
|
+
pairingYieldActive = false
|
|
2760
|
+
pairingYieldEndWorkItem?.cancel()
|
|
2761
|
+
pairingYieldEndWorkItem = nil
|
|
2454
2762
|
resetWireNegotiationState()
|
|
2455
2763
|
parsePeerWireCaps(json)
|
|
2456
2764
|
maybeSendWireHandshake()
|
|
@@ -2602,6 +2910,31 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2602
2910
|
case "pong":
|
|
2603
2911
|
Bridge.log("LIVE: Received pong response - connection healthy")
|
|
2604
2912
|
|
|
2913
|
+
case "entering_pairing_mode":
|
|
2914
|
+
let windowMs = max(5_000, min(180_000, json["window_ms"] as? Int ?? 120_000))
|
|
2915
|
+
Bridge.log("LIVE: Glasses entering pairing mode — yield \(windowMs)ms (no forget)")
|
|
2916
|
+
enterPairingYield(windowMs: windowMs)
|
|
2917
|
+
var body: [String: Any] = [
|
|
2918
|
+
"window_ms": windowMs,
|
|
2919
|
+
"reason": json["reason"] as? String ?? "user_gesture",
|
|
2920
|
+
]
|
|
2921
|
+
if let txn = json["txn"] {
|
|
2922
|
+
body["txn"] = txn
|
|
2923
|
+
}
|
|
2924
|
+
Bridge.sendTypedMessage("entering_pairing_mode", body: body)
|
|
2925
|
+
|
|
2926
|
+
case "pairing_info":
|
|
2927
|
+
Bridge.sendPairingInfo(
|
|
2928
|
+
hadPreviousBond: json["had_previous_bond"] as? Bool ?? false,
|
|
2929
|
+
transferId: json["transfer_id"] as? String,
|
|
2930
|
+
pairingCode: json["pairing_code"] as? String,
|
|
2931
|
+
classicBondReady: json["classic_bond_ready"] as? Bool ?? false,
|
|
2932
|
+
// Legacy firmware that omits this field is not secure-capable.
|
|
2933
|
+
securePairingCapable: json["secure_pairing_capable"] as? Bool ?? false,
|
|
2934
|
+
protocolVersion: json["protocol_version"] as? Int ?? 1,
|
|
2935
|
+
binding: json["binding"] as? String
|
|
2936
|
+
)
|
|
2937
|
+
|
|
2605
2938
|
case "imu_response", "imu_stream_response", "imu_gesture_response",
|
|
2606
2939
|
"imu_gesture_subscribed", "imu_ack", "imu_error":
|
|
2607
2940
|
// Handle IMU-related responses
|
|
@@ -2669,6 +3002,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2669
3002
|
if let seq = json["sq"] as? [String] ?? json["step_sequence"] as? [String], !seq.isEmpty {
|
|
2670
3003
|
cachedOtaStepSequence = seq
|
|
2671
3004
|
}
|
|
3005
|
+
updateBesOtaHeartbeatGuard(stepType: osStepType, phase: osPhase, status: osStatus)
|
|
2672
3006
|
|
|
2673
3007
|
Bridge.log("LIVE: 📱 OTA status - step \(osCurrentStep)/\(osTotalSteps) \(osPhase) \(osStatus) \(osOverallPercent)%")
|
|
2674
3008
|
|
|
@@ -2702,6 +3036,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2702
3036
|
} else {
|
|
2703
3037
|
unified = "in_progress"
|
|
2704
3038
|
}
|
|
3039
|
+
updateBesOtaHeartbeatGuard(stepType: currentUpdate, phase: legacyPhase, status: unified)
|
|
2705
3040
|
Bridge.log(
|
|
2706
3041
|
"LIVE: 📱 Legacy ota_progress → ota_status: \(legacyStage) \(legacyStatus) \(legacyProgress)%"
|
|
2707
3042
|
)
|
|
@@ -2761,9 +3096,19 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2761
3096
|
if let systemTimeMs = fields["system_time_ms"] as? NSNumber {
|
|
2762
3097
|
DeviceStore.shared.apply("glasses", "systemTimeMs", systemTimeMs.int64Value)
|
|
2763
3098
|
}
|
|
3099
|
+
if let hotspotOtaVersion = fields["hotspot_ota_version"] as? NSNumber {
|
|
3100
|
+
DeviceStore.shared.apply(
|
|
3101
|
+
"glasses",
|
|
3102
|
+
"hotspotOtaVersion",
|
|
3103
|
+
hotspotOtaVersion.intValue
|
|
3104
|
+
)
|
|
3105
|
+
}
|
|
2764
3106
|
if let bluetoothMacAddress = nonEmptyStringValue(fields, "bt_mac_address") {
|
|
2765
3107
|
DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
|
|
2766
3108
|
}
|
|
3109
|
+
if let wifiMacAddress = nonEmptyStringValue(fields, "wifi_mac_address") {
|
|
3110
|
+
DeviceStore.shared.apply("glasses", "wifiMacAddress", wifiMacAddress)
|
|
3111
|
+
}
|
|
2767
3112
|
if let serialNumber = nonEmptyStringValue(fields, "serial_number") {
|
|
2768
3113
|
DeviceStore.shared.apply("glasses", "serialNumber", serialNumber)
|
|
2769
3114
|
}
|
|
@@ -2829,6 +3174,19 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2829
3174
|
cachedOtaCurrentStep = 0
|
|
2830
3175
|
cachedOtaStepSequence = nil
|
|
2831
3176
|
lastBesOtaProgress = -1
|
|
3177
|
+
// The bounded UART-ownership lease is transport state, not session-cache state. Keep it
|
|
3178
|
+
// through a BLE reconnect so heartbeats cannot resume while BES still owns the UART.
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
private func updateBesOtaHeartbeatGuard(stepType: String, phase: String, status: String) {
|
|
3182
|
+
guard stepType.lowercased() == "bes", phase.lowercased() == "install" else { return }
|
|
3183
|
+
switch status.lowercased() {
|
|
3184
|
+
case "failed", "complete", "step_complete", "finished", "success":
|
|
3185
|
+
besOtaHeartbeatSuppressedUntil = 0
|
|
3186
|
+
default:
|
|
3187
|
+
besOtaHeartbeatSuppressedUntil =
|
|
3188
|
+
ProcessInfo.processInfo.systemUptime + BES_OTA_HEARTBEAT_LEASE_SEC
|
|
3189
|
+
}
|
|
2832
3190
|
}
|
|
2833
3191
|
|
|
2834
3192
|
/// Falls back to raw besProgress when step sequence is unavailable.
|
|
@@ -3000,62 +3358,41 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3000
3358
|
var progress = ((rawProgress + 2) / 5) * 5
|
|
3001
3359
|
if progress > 100 { progress = 100 }
|
|
3002
3360
|
|
|
3003
|
-
// Only send if progress changed to a new 5% increment
|
|
3004
3361
|
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
3362
|
var besOtaProgressVal: Int
|
|
3017
3363
|
var besOtaErrorMessage: String? = nil
|
|
3018
3364
|
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3365
|
+
let failed = type == "error" || type == "fail"
|
|
3366
|
+
let succeeded = type == "success"
|
|
3367
|
+
if isTerminalStatus {
|
|
3368
|
+
besOtaHeartbeatSuppressedUntil = 0
|
|
3369
|
+
} else {
|
|
3370
|
+
// Refresh before progress de-duplication: raw progress proves the BES
|
|
3371
|
+
// transfer is active even when the rounded UI value is unchanged.
|
|
3372
|
+
besOtaHeartbeatSuppressedUntil =
|
|
3373
|
+
ProcessInfo.processInfo.systemUptime + BES_OTA_HEARTBEAT_LEASE_SEC
|
|
3374
|
+
}
|
|
3375
|
+
if succeeded {
|
|
3025
3376
|
besOtaProgressVal = 100
|
|
3026
|
-
|
|
3027
|
-
} else if type == "error" || type == "fail" {
|
|
3028
|
-
besOtaStatus = "FAILED"
|
|
3377
|
+
} else if failed {
|
|
3029
3378
|
besOtaProgressVal = progress
|
|
3030
3379
|
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
3380
|
} else {
|
|
3036
|
-
//
|
|
3037
|
-
|
|
3038
|
-
besOtaProgressVal = progress
|
|
3381
|
+
// A raw update:100 precedes whole-image CRC/apply and is not terminal.
|
|
3382
|
+
besOtaProgressVal = min(progress, 95)
|
|
3039
3383
|
}
|
|
3040
3384
|
|
|
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"
|
|
3385
|
+
// Only send if nonterminal display progress changed to a new 5% increment.
|
|
3386
|
+
if besOtaProgressVal == lastBesOtaProgress && !isTerminalStatus {
|
|
3387
|
+
break
|
|
3058
3388
|
}
|
|
3389
|
+
lastBesOtaProgress = isTerminalStatus ? -1 : besOtaProgressVal
|
|
3390
|
+
|
|
3391
|
+
Bridge.log(
|
|
3392
|
+
"LIVE: 📱 BES OTA progress via sr_adota - type: \(type), raw: \(rawProgress)%, rounded: \(progress)%, display: \(besOtaProgressVal)%"
|
|
3393
|
+
)
|
|
3394
|
+
|
|
3395
|
+
let syntheticStatus = succeeded ? "step_complete" : (failed ? "failed" : "in_progress")
|
|
3059
3396
|
let sid = cachedOtaSessionId ?? ""
|
|
3060
3397
|
let totalSteps = cachedOtaTotalSteps > 0 ? cachedOtaTotalSteps : 1
|
|
3061
3398
|
let currentStep = cachedOtaCurrentStep > 0 ? cachedOtaCurrentStep : 1
|
|
@@ -3210,6 +3547,17 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3210
3547
|
sendJson(json, wakeUp: true)
|
|
3211
3548
|
}
|
|
3212
3549
|
|
|
3550
|
+
func sendWifiAdbState(_ enabled: Bool) {
|
|
3551
|
+
Bridge.log("LIVE: 🔧 Sending Wi-Fi ADB state: \(enabled)")
|
|
3552
|
+
|
|
3553
|
+
let json: [String: Any] = [
|
|
3554
|
+
"type": "set_wifi_adb_state",
|
|
3555
|
+
"enabled": enabled,
|
|
3556
|
+
]
|
|
3557
|
+
|
|
3558
|
+
sendJson(json, wakeUp: true)
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3213
3561
|
func sendSetSystemTime(_ timestampMs: Int64) {
|
|
3214
3562
|
Bridge.log("LIVE: ⏰ Sending set_system_time: \(timestampMs)")
|
|
3215
3563
|
|
|
@@ -3329,7 +3677,6 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3329
3677
|
{
|
|
3330
3678
|
json["ota_version_url"] = otaVersionUrl
|
|
3331
3679
|
}
|
|
3332
|
-
|
|
3333
3680
|
sendJson(json, wakeUp: true)
|
|
3334
3681
|
}
|
|
3335
3682
|
|
|
@@ -3500,17 +3847,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3500
3847
|
return
|
|
3501
3848
|
}
|
|
3502
3849
|
|
|
3503
|
-
let sequenceNumber =
|
|
3850
|
+
let sequenceNumber = Int(data[1])
|
|
3504
3851
|
let lc3Data = data.subdata(in: 2 ..< data.count)
|
|
3505
3852
|
|
|
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
3853
|
// // Decode LC3 to PCM using existing PcmConverter
|
|
3515
3854
|
// let pcmConverter = PcmConverter()
|
|
3516
3855
|
// guard let pcmData = pcmConverter.decode(lc3Data) as? Data, pcmData.count > 0 else {
|
|
@@ -3524,7 +3863,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3524
3863
|
// Bridge.log(
|
|
3525
3864
|
// "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count) bytes"
|
|
3526
3865
|
// )
|
|
3527
|
-
DeviceManager.shared.handleGlassesMicData(lc3Data, 40)
|
|
3866
|
+
DeviceManager.shared.handleGlassesMicData(lc3Data, 40, sequenceNumber: sequenceNumber)
|
|
3528
3867
|
|
|
3529
3868
|
// Bridge.log(
|
|
3530
3869
|
// "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count)→\(pcmData.count) bytes"
|
|
@@ -5068,6 +5407,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5068
5407
|
Bridge.log("LIVE: Skipping heartbeat - glasses not fully booted or not connected")
|
|
5069
5408
|
return
|
|
5070
5409
|
}
|
|
5410
|
+
if ProcessInfo.processInfo.systemUptime < besOtaHeartbeatSuppressedUntil {
|
|
5411
|
+
Bridge.log("LIVE: Skipping heartbeat while BES OTA owns the UART")
|
|
5412
|
+
return
|
|
5413
|
+
}
|
|
5071
5414
|
|
|
5072
5415
|
// Send ping message to glasses hardware (no ACK needed for heartbeats)
|
|
5073
5416
|
let pingJson: [String: Any] = ["type": "ping"]
|
|
@@ -5181,16 +5524,78 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5181
5524
|
stopReadinessCheckLoop()
|
|
5182
5525
|
stopConnectionTimeout()
|
|
5183
5526
|
stopMicBeat() // Stop LC3 audio micbeat
|
|
5184
|
-
|
|
5185
|
-
pendingMessageTimer = nil
|
|
5527
|
+
resetPendingAckState(reason: "transport timers stopped")
|
|
5186
5528
|
reconnectionWorkItem?.cancel()
|
|
5187
5529
|
reconnectionWorkItem = nil
|
|
5188
5530
|
}
|
|
5189
5531
|
|
|
5190
5532
|
// MARK: - Event Emission
|
|
5191
5533
|
|
|
5192
|
-
|
|
5193
|
-
|
|
5534
|
+
/// Pairing scan listens for advertisements. A unit that is already GATT-connected
|
|
5535
|
+
/// has stopped ADV, so emit it as pairable or the scan list stays empty.
|
|
5536
|
+
private func emitConnectedDeviceForPairingScan() {
|
|
5537
|
+
guard connected, let peripheral = connectedPeripheral, let name = peripheral.name,
|
|
5538
|
+
name == "Xy_A" || name.hasPrefix("XyBLE_") || name.hasPrefix("MENTRA_LIVE_BLE")
|
|
5539
|
+
|| name.hasPrefix("MENTRA_LIVE_BT") || name.lowercased().hasPrefix("mentra_live")
|
|
5540
|
+
else {
|
|
5541
|
+
return
|
|
5542
|
+
}
|
|
5543
|
+
Bridge.log("LIVE: Pairing scan: already GATT-connected to \(name) — emitting as pairable (ADV off while connected)")
|
|
5544
|
+
emitDiscoveredDevice(
|
|
5545
|
+
name,
|
|
5546
|
+
identifier: peripheral.identifier.uuidString,
|
|
5547
|
+
pairingMode: true,
|
|
5548
|
+
pairingCode: nil,
|
|
5549
|
+
securePairingCapable: discoveredAdvPairing[name]?.securePairingCapable ?? false
|
|
5550
|
+
)
|
|
5551
|
+
}
|
|
5552
|
+
|
|
5553
|
+
private func cacheAdvPairing(
|
|
5554
|
+
_ name: String,
|
|
5555
|
+
pairingMode: Bool,
|
|
5556
|
+
pairingCode: String?,
|
|
5557
|
+
securePairingCapable: Bool
|
|
5558
|
+
) {
|
|
5559
|
+
discoveredAdvPairing[name] = CachedAdvPairing(
|
|
5560
|
+
pairingMode: pairingMode,
|
|
5561
|
+
pairingCode: pairingCode,
|
|
5562
|
+
securePairingCapable: securePairingCapable
|
|
5563
|
+
)
|
|
5564
|
+
}
|
|
5565
|
+
|
|
5566
|
+
private func emitDiscoveredDeviceFromCache(
|
|
5567
|
+
_ name: String,
|
|
5568
|
+
identifier: String = "",
|
|
5569
|
+
rssi: Int? = nil
|
|
5570
|
+
) {
|
|
5571
|
+
let cached = discoveredAdvPairing[name]
|
|
5572
|
+
emitDiscoveredDevice(
|
|
5573
|
+
name,
|
|
5574
|
+
identifier: identifier,
|
|
5575
|
+
rssi: rssi,
|
|
5576
|
+
pairingMode: cached?.pairingMode,
|
|
5577
|
+
pairingCode: cached?.pairingCode,
|
|
5578
|
+
securePairingCapable: cached?.securePairingCapable
|
|
5579
|
+
)
|
|
5580
|
+
}
|
|
5581
|
+
|
|
5582
|
+
private func emitDiscoveredDevice(
|
|
5583
|
+
_ name: String,
|
|
5584
|
+
identifier: String = "",
|
|
5585
|
+
rssi: Int? = nil,
|
|
5586
|
+
pairingMode: Bool? = nil,
|
|
5587
|
+
pairingCode: String? = nil,
|
|
5588
|
+
securePairingCapable: Bool? = nil
|
|
5589
|
+
) {
|
|
5590
|
+
Bridge.sendDiscoveredDevice(
|
|
5591
|
+
"Mentra Live",
|
|
5592
|
+
name,
|
|
5593
|
+
deviceAddress: identifier,
|
|
5594
|
+
rssi: rssi,
|
|
5595
|
+
pairingMode: pairingMode,
|
|
5596
|
+
pairingCode: pairingCode,
|
|
5597
|
+
securePairingCapable: securePairingCapable
|
|
5598
|
+
)
|
|
5194
5599
|
}
|
|
5195
5600
|
|
|
5196
5601
|
private func emitStopScanEvent() {
|
|
@@ -5279,6 +5684,9 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5279
5684
|
Bridge.log("Destroying MentraLiveManager")
|
|
5280
5685
|
|
|
5281
5686
|
isKilled = true
|
|
5687
|
+
unpairFlushWorkItem?.cancel()
|
|
5688
|
+
unpairFlushWorkItem = nil
|
|
5689
|
+
unpairFlushPending = false
|
|
5282
5690
|
|
|
5283
5691
|
// Stop scanning
|
|
5284
5692
|
if isScanning {
|
|
@@ -5312,6 +5720,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5312
5720
|
DeviceStore.shared.apply("glasses", "hotspotGatewayIp", "")
|
|
5313
5721
|
|
|
5314
5722
|
connectedPeripheral = nil
|
|
5723
|
+
discoveredPeripherals.removeAll()
|
|
5724
|
+
discoveredAdvPairing.removeAll()
|
|
5315
5725
|
centralManager?.delegate = nil
|
|
5316
5726
|
centralManager = nil
|
|
5317
5727
|
|
|
@@ -6521,6 +6931,17 @@ extension MentraLive {
|
|
|
6521
6931
|
stopVideoRecording(requestId: requestId, webhookUrl: nil, authToken: nil)
|
|
6522
6932
|
}
|
|
6523
6933
|
|
|
6934
|
+
func queryVideoRecordingStatus(requestId: String) {
|
|
6935
|
+
guard connectionState == ConnTypes.CONNECTED else {
|
|
6936
|
+
Bridge.log("Cannot query video recording status - not connected")
|
|
6937
|
+
return
|
|
6938
|
+
}
|
|
6939
|
+
sendJson([
|
|
6940
|
+
"type": "get_video_recording_status",
|
|
6941
|
+
"requestId": requestId,
|
|
6942
|
+
])
|
|
6943
|
+
}
|
|
6944
|
+
|
|
6524
6945
|
func stopVideoRecording(requestId: String, webhookUrl: String?, authToken: String?) {
|
|
6525
6946
|
Bridge.log(
|
|
6526
6947
|
"Stopping video recording on glasses: requestId=\(requestId), webhook=\((webhookUrl?.isEmpty ?? true) ? "none" : "set")"
|