@mentra/bluetooth-sdk 0.1.21-beta.2 → 0.1.21-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +17 -9
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +4 -0
- 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 +222 -110
- 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/build/BluetoothSdk.types.d.ts +2 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +17 -11
- package/ios/Source/DeviceStore.swift +3 -0
- package/ios/Source/sgcs/G2.swift +108 -41
- package/ios/Source/sgcs/MentraLive.swift +76 -61
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +2 -0
|
@@ -704,14 +704,16 @@ struct ViewState {
|
|
|
704
704
|
let layoutType = currentViewState.layoutType
|
|
705
705
|
switch layoutType {
|
|
706
706
|
case "text_wall":
|
|
707
|
-
let text = currentViewState.text
|
|
707
|
+
let text = parsePlaceholders(currentViewState.text)
|
|
708
708
|
await sgc?.sendTextWall(text)
|
|
709
709
|
case "double_text_wall":
|
|
710
|
-
let topText = currentViewState.topText
|
|
711
|
-
let bottomText = currentViewState.bottomText
|
|
710
|
+
let topText = parsePlaceholders(currentViewState.topText)
|
|
711
|
+
let bottomText = parsePlaceholders(currentViewState.bottomText)
|
|
712
712
|
await sgc?.sendDoubleTextWall(topText, bottomText)
|
|
713
713
|
case "reference_card":
|
|
714
|
-
|
|
714
|
+
let title = parsePlaceholders(currentViewState.title)
|
|
715
|
+
let text = parsePlaceholders(currentViewState.text)
|
|
716
|
+
await sgc?.sendTextWall(title + "\n\n" + text)
|
|
715
717
|
case "bitmap_view":
|
|
716
718
|
// Bridge.log("MAN: Processing bitmap_view layout")
|
|
717
719
|
guard let data = currentViewState.data else {
|
|
@@ -727,11 +729,12 @@ struct ViewState {
|
|
|
727
729
|
height: currentViewState.bmpHeight
|
|
728
730
|
)
|
|
729
731
|
case "positioned_text":
|
|
732
|
+
let text = parsePlaceholders(currentViewState.text)
|
|
730
733
|
Bridge.log(
|
|
731
|
-
"MAN: positioned_text -> text='\(
|
|
734
|
+
"MAN: positioned_text -> text='\(text)' rect=\(currentViewState.bmpX ?? 0),\(currentViewState.bmpY ?? 0) \(currentViewState.bmpWidth ?? 576)x\(currentViewState.bmpHeight ?? 288)"
|
|
732
735
|
)
|
|
733
736
|
await sgc?.sendPositionedText(
|
|
734
|
-
|
|
737
|
+
text,
|
|
735
738
|
x: currentViewState.bmpX ?? 0,
|
|
736
739
|
y: currentViewState.bmpY ?? 0,
|
|
737
740
|
width: currentViewState.bmpWidth ?? 576,
|
|
@@ -753,6 +756,8 @@ struct ViewState {
|
|
|
753
756
|
}
|
|
754
757
|
|
|
755
758
|
func parsePlaceholders(_ text: String) -> String {
|
|
759
|
+
guard text.contains("$") else { return text }
|
|
760
|
+
|
|
756
761
|
let dateFormatter = DateFormatter()
|
|
757
762
|
dateFormatter.dateFormat = "M/dd, h:mm"
|
|
758
763
|
let formattedDate = dateFormatter.string(from: Date())
|
|
@@ -1712,11 +1717,13 @@ struct ViewState {
|
|
|
1712
1717
|
shouldSendBootingMessage = true // Reset for next first connect
|
|
1713
1718
|
// clear glasses properties:
|
|
1714
1719
|
DeviceStore.shared.apply("glasses", "deviceModel", "")
|
|
1715
|
-
//
|
|
1716
|
-
// previously connected pair
|
|
1717
|
-
// connection (e.g. switching from G1/Ar99, which populate it from the
|
|
1718
|
-
// advertisement, to a model that never writes it, like G2).
|
|
1720
|
+
// Device identifiers are session-bound. Clear them on every disconnect so a
|
|
1721
|
+
// previously connected pair can never be reported for the next connection.
|
|
1719
1722
|
DeviceStore.shared.apply("glasses", "serialNumber", "")
|
|
1723
|
+
DeviceStore.shared.apply("glasses", "bluetoothMacAddress", "")
|
|
1724
|
+
DeviceStore.shared.apply("glasses", "leftMacAddress", "")
|
|
1725
|
+
DeviceStore.shared.apply("glasses", "rightMacAddress", "")
|
|
1726
|
+
DeviceStore.shared.apply("glasses", "macAddress", "")
|
|
1720
1727
|
DeviceStore.shared.apply("glasses", "fullyBooted", false)
|
|
1721
1728
|
DeviceStore.shared.apply("glasses", "connected", false)
|
|
1722
1729
|
DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
|
|
@@ -1807,4 +1814,3 @@ struct ViewState {
|
|
|
1807
1814
|
cancellables.removeAll()
|
|
1808
1815
|
}
|
|
1809
1816
|
}
|
|
1810
|
-
|
|
@@ -35,6 +35,9 @@ class DeviceStore {
|
|
|
35
35
|
store.set("glasses", "caseCharging", false)
|
|
36
36
|
store.set("glasses", "caseBatteryLevel", -1)
|
|
37
37
|
store.set("glasses", "headUp", false)
|
|
38
|
+
store.set("glasses", "bluetoothMacAddress", "")
|
|
39
|
+
store.set("glasses", "leftMacAddress", "")
|
|
40
|
+
store.set("glasses", "rightMacAddress", "")
|
|
38
41
|
store.set("glasses", "serialNumber", "")
|
|
39
42
|
store.set("glasses", "style", "")
|
|
40
43
|
store.set("glasses", "color", "")
|
package/ios/Source/sgcs/G2.swift
CHANGED
|
@@ -11,6 +11,14 @@ import CoreBluetooth
|
|
|
11
11
|
import Foundation
|
|
12
12
|
import UIKit
|
|
13
13
|
|
|
14
|
+
/// Keep a low-rate, non-audio EvenHub sensor stream active so iOS continues receiving BLE
|
|
15
|
+
/// notifications while the Mentra App is backgrounded. The samples stay internal unless IMU was
|
|
16
|
+
/// explicitly enabled through Mentra's developer-facing setting.
|
|
17
|
+
private let g2ImuKeepaliveEnabled = true
|
|
18
|
+
private let g2ImuKeepaliveReportPace: Int32 = 1000
|
|
19
|
+
private let g2ImuConfirmationDelay: TimeInterval = 2
|
|
20
|
+
private let g2ImuMaxControlAttempts = 3
|
|
21
|
+
|
|
14
22
|
// MARK: - Data Little-Endian Helpers (for BMP construction)
|
|
15
23
|
|
|
16
24
|
extension Data {
|
|
@@ -1524,6 +1532,8 @@ class G2: NSObject, SGCManager {
|
|
|
1524
1532
|
private var foregroundObserver: NSObjectProtocol?
|
|
1525
1533
|
private var startupPageCreated: Bool = false // createStartUpPageContainer can only be called once
|
|
1526
1534
|
private var pageCreated: Bool = false
|
|
1535
|
+
private var pageGeneration: UInt64 = 0
|
|
1536
|
+
private var lastImuReportTimestamp: Int64?
|
|
1527
1537
|
// Live hardware truth: is the firmware mic actually streaming. DISTINCT from the
|
|
1528
1538
|
// glasses/micEnabled DeviceStore flag, which is *intent* (does the user want the mic on).
|
|
1529
1539
|
// Cleared on every page teardown (the firmware kills the mic with the page) WITHOUT touching
|
|
@@ -2959,11 +2969,17 @@ class G2: NSObject, SGCManager {
|
|
|
2959
2969
|
}
|
|
2960
2970
|
signalDisplayDirty()
|
|
2961
2971
|
|
|
2962
|
-
try? await Task.sleep(nanoseconds: 300_000_000)
|
|
2972
|
+
try? await Task.sleep(nanoseconds: 300_000_000) // 300ms to settle
|
|
2973
|
+
syncImuReportingWithIntent()
|
|
2974
|
+
confirmImuReporting(
|
|
2975
|
+
forPageGeneration: pageGeneration,
|
|
2976
|
+
commandSentAt: Int64(Date().timeIntervalSince1970 * 1000),
|
|
2977
|
+
attemptsRemaining: g2ImuMaxControlAttempts - 1
|
|
2978
|
+
)
|
|
2963
2979
|
restartMicIfAlreadyEnabled()
|
|
2964
2980
|
}
|
|
2965
2981
|
|
|
2966
|
-
/// Single coalesced recovery: rebuild the page + re-arm
|
|
2982
|
+
/// Single coalesced recovery: rebuild the page + re-arm sensor streams from intent, but never
|
|
2967
2983
|
/// stack rebuilds. The firmware spams systemExit/dashboard-close ~1×/sec; without this
|
|
2968
2984
|
/// guard each one triggered a rebuild that was torn down again → rebuild→exit→rebuild
|
|
2969
2985
|
/// storm. At most one rebuild in flight, and at most one per RECOVERY_DEBOUNCE_MS.
|
|
@@ -3481,6 +3497,8 @@ class G2: NSObject, SGCManager {
|
|
|
3481
3497
|
appId: activeMenuAppId
|
|
3482
3498
|
)
|
|
3483
3499
|
}
|
|
3500
|
+
pageGeneration &+= 1
|
|
3501
|
+
lastImuReportTimestamp = nil
|
|
3484
3502
|
sendEvenHubCommand(msg)
|
|
3485
3503
|
pageCreated = true
|
|
3486
3504
|
}
|
|
@@ -3492,6 +3510,62 @@ class G2: NSObject, SGCManager {
|
|
|
3492
3510
|
}
|
|
3493
3511
|
}
|
|
3494
3512
|
|
|
3513
|
+
/// Keep the firmware IMU stream alive at its slowest supported pace even when no miniapp
|
|
3514
|
+
/// requested motion data. Incoming keepalive samples are discarded in `handleTouchEvent`.
|
|
3515
|
+
private func syncImuReportingWithIntent(
|
|
3516
|
+
_ intentEnabled: Bool? = nil,
|
|
3517
|
+
requestedReportPace: Int32 = EvenHubProto.imuPaceP100
|
|
3518
|
+
) {
|
|
3519
|
+
guard pageCreated else { return }
|
|
3520
|
+
|
|
3521
|
+
let shouldForwardSamples =
|
|
3522
|
+
intentEnabled
|
|
3523
|
+
?? (DeviceStore.shared.get("bluetooth", "imu_enabled") as? Bool ?? false)
|
|
3524
|
+
let shouldStream = shouldForwardSamples || g2ImuKeepaliveEnabled
|
|
3525
|
+
let reportPace =
|
|
3526
|
+
shouldForwardSamples ? requestedReportPace : g2ImuKeepaliveReportPace
|
|
3527
|
+
|
|
3528
|
+
Bridge.log(
|
|
3529
|
+
"G2: syncing IMU stream — hardware=\(shouldStream), forward=\(shouldForwardSamples), pace=\(reportPace)"
|
|
3530
|
+
)
|
|
3531
|
+
let msg = EvenHubProto.imuControlMessage(
|
|
3532
|
+
enable: shouldStream,
|
|
3533
|
+
reportFrq: reportPace,
|
|
3534
|
+
magicRandom: sendManager.nextMagicRandom()
|
|
3535
|
+
)
|
|
3536
|
+
sendEvenHubCommand(msg)
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3539
|
+
/// EvenHub does not ACK IMU control commands, so use the data stream itself as confirmation.
|
|
3540
|
+
/// A page-generation guard prevents a delayed retry from targeting a replacement page.
|
|
3541
|
+
private func confirmImuReporting(
|
|
3542
|
+
forPageGeneration generation: UInt64,
|
|
3543
|
+
commandSentAt: Int64,
|
|
3544
|
+
attemptsRemaining: Int
|
|
3545
|
+
) {
|
|
3546
|
+
guard attemptsRemaining > 0 else { return }
|
|
3547
|
+
|
|
3548
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + g2ImuConfirmationDelay) { [weak self] in
|
|
3549
|
+
guard let self,
|
|
3550
|
+
self.pageCreated,
|
|
3551
|
+
self.pageGeneration == generation,
|
|
3552
|
+
g2ImuKeepaliveEnabled
|
|
3553
|
+
|| (DeviceStore.shared.get("bluetooth", "imu_enabled") as? Bool ?? false),
|
|
3554
|
+
!(self.lastImuReportTimestamp.map { $0 >= commandSentAt } ?? false)
|
|
3555
|
+
else { return }
|
|
3556
|
+
|
|
3557
|
+
Bridge.log(
|
|
3558
|
+
"G2: no IMU report received for page generation \(generation); retrying control (\(attemptsRemaining) remaining)"
|
|
3559
|
+
)
|
|
3560
|
+
self.syncImuReportingWithIntent()
|
|
3561
|
+
self.confirmImuReporting(
|
|
3562
|
+
forPageGeneration: generation,
|
|
3563
|
+
commandSentAt: Int64(Date().timeIntervalSince1970 * 1000),
|
|
3564
|
+
attemptsRemaining: attemptsRemaining - 1
|
|
3565
|
+
)
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
|
|
3495
3569
|
func restartMic() {
|
|
3496
3570
|
// Intent is "mic on". The mic only exists inside a live EvenHub page, so we
|
|
3497
3571
|
// toggle it off then back on (the firmware needs the off→on edge to re-arm).
|
|
@@ -3829,21 +3903,7 @@ class G2: NSObject, SGCManager {
|
|
|
3829
3903
|
await rebuildState()
|
|
3830
3904
|
}
|
|
3831
3905
|
|
|
3832
|
-
|
|
3833
|
-
guard let self = self else { return }
|
|
3834
|
-
let msg = EvenHubProto.imuControlMessage(
|
|
3835
|
-
enable: enabled, reportFrq: reportFrq,
|
|
3836
|
-
magicRandom: self.sendManager.nextMagicRandom()
|
|
3837
|
-
)
|
|
3838
|
-
self.sendEvenHubCommand(msg)
|
|
3839
|
-
}
|
|
3840
|
-
|
|
3841
|
-
// If we just asked for a page, give it a moment to be created first.
|
|
3842
|
-
if enabled, !pageCreated {
|
|
3843
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: send)
|
|
3844
|
-
} else {
|
|
3845
|
-
send()
|
|
3846
|
-
}
|
|
3906
|
+
syncImuReportingWithIntent(enabled, requestedReportPace: reportFrq)
|
|
3847
3907
|
}
|
|
3848
3908
|
|
|
3849
3909
|
// MARK: - SGCManager: Device Control
|
|
@@ -4367,16 +4427,9 @@ class G2: NSObject, SGCManager {
|
|
|
4367
4427
|
return
|
|
4368
4428
|
}
|
|
4369
4429
|
|
|
4370
|
-
|
|
4371
4430
|
if cmdValue == EvenHubResponseCmd.osNotifyEventToApp.rawValue {
|
|
4372
|
-
//
|
|
4431
|
+
// Device event from glasses (touch/gesture or sensor data)
|
|
4373
4432
|
guard let devEventData = fields[13] as? Data else { return }
|
|
4374
|
-
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
|
4375
|
-
if lastClickTimestamp != nil && timestamp - lastClickTimestamp! < 100 {
|
|
4376
|
-
// Bridge.log("G2: Double click ignored (too soon)")
|
|
4377
|
-
return
|
|
4378
|
-
}
|
|
4379
|
-
lastClickTimestamp = timestamp
|
|
4380
4433
|
handleTouchEvent(devEventData)
|
|
4381
4434
|
} else if cmdValue == 17 {
|
|
4382
4435
|
// Miniapp selection from glasses dashboard menu (cmdId=17)
|
|
@@ -4526,6 +4579,35 @@ class G2: NSObject, SGCManager {
|
|
|
4526
4579
|
|
|
4527
4580
|
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
|
4528
4581
|
|
|
4582
|
+
// Classify IMU reports before applying the click debounce. Keepalive samples arrive
|
|
4583
|
+
// continuously and must not update `lastClickTimestamp`, or a real gesture delivered
|
|
4584
|
+
// within 100 ms of a sample can be dropped.
|
|
4585
|
+
var parsedSysFields: [Int: Any]?
|
|
4586
|
+
if let sysData = fields[3] as? Data {
|
|
4587
|
+
var sysReader = ProtobufReader(sysData)
|
|
4588
|
+
let sysFields = sysReader.parseFields()
|
|
4589
|
+
if (sysFields[1] as? Int32) == OsEventType.imuDataReport.rawValue {
|
|
4590
|
+
lastImuReportTimestamp = timestamp
|
|
4591
|
+
let shouldForwardSamples =
|
|
4592
|
+
DeviceStore.shared.get("bluetooth", "imu_enabled") as? Bool ?? false
|
|
4593
|
+
if shouldForwardSamples,
|
|
4594
|
+
let imuData = sysFields[3] as? Data,
|
|
4595
|
+
let imu = parseImuReportData(imuData)
|
|
4596
|
+
{
|
|
4597
|
+
Bridge.log("G2: IMU data report: \(imu.x), \(imu.y), \(imu.z)")
|
|
4598
|
+
Bridge.sendAccelEvent(x: imu.x, y: imu.y, z: imu.z, timestamp: timestamp)
|
|
4599
|
+
}
|
|
4600
|
+
return
|
|
4601
|
+
}
|
|
4602
|
+
parsedSysFields = sysFields
|
|
4603
|
+
}
|
|
4604
|
+
|
|
4605
|
+
if lastClickTimestamp != nil && timestamp - lastClickTimestamp! < 100 {
|
|
4606
|
+
// Bridge.log("G2: Double click ignored (too soon)")
|
|
4607
|
+
return
|
|
4608
|
+
}
|
|
4609
|
+
lastClickTimestamp = timestamp
|
|
4610
|
+
|
|
4529
4611
|
// if we are receiving touch events we are fully booted:
|
|
4530
4612
|
setFullyConnected()
|
|
4531
4613
|
|
|
@@ -4534,22 +4616,7 @@ class G2: NSObject, SGCManager {
|
|
|
4534
4616
|
// "G2: handleTouchEvent: \(devEventData.map { String(format: "%02X", $0) }.joined())")
|
|
4535
4617
|
|
|
4536
4618
|
// SysEvent (field 3) - system-level gestures
|
|
4537
|
-
if let
|
|
4538
|
-
var sysReader = ProtobufReader(sysData)
|
|
4539
|
-
let sysFields = sysReader.parseFields()
|
|
4540
|
-
|
|
4541
|
-
// IMU data report: eventType == IMU_DATA_REPORT (8), imuData in field 3
|
|
4542
|
-
// (IMU_Report_Data { x, y, z } as 32-bit floats). Handle and return before
|
|
4543
|
-
// the gesture-mapping path.
|
|
4544
|
-
if (sysFields[1] as? Int32) == OsEventType.imuDataReport.rawValue,
|
|
4545
|
-
let imuData = sysFields[3] as? Data,
|
|
4546
|
-
let imu = parseImuReportData(imuData)
|
|
4547
|
-
{
|
|
4548
|
-
Bridge.log("G2: IMU data report: \(imu.x), \(imu.y), \(imu.z)")
|
|
4549
|
-
Bridge.sendAccelEvent(x: imu.x, y: imu.y, z: imu.z, timestamp: timestamp)
|
|
4550
|
-
return
|
|
4551
|
-
}
|
|
4552
|
-
|
|
4619
|
+
if let sysFields = parsedSysFields {
|
|
4553
4620
|
var eventType: OsEventType? = nil
|
|
4554
4621
|
var eventSource: Int32? = nil
|
|
4555
4622
|
if let normalType = sysFields[1] as? Int32 {
|
|
@@ -1282,13 +1282,14 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1282
1282
|
/// Mirrors Android `updateConnectionState` — RN home reads `glasses.connectionState` for reconnecting UI.
|
|
1283
1283
|
private func updateConnectionState(_ state: String) {
|
|
1284
1284
|
if state == ConnTypes.DISCONNECTED {
|
|
1285
|
-
//
|
|
1286
|
-
//
|
|
1287
|
-
//
|
|
1288
|
-
// clearing on CONNECTED would wipe
|
|
1289
|
-
// glasses_ready (e.g. ASG restart) re-publishes CONNECTED (iOS has no equal-state
|
|
1290
|
-
//
|
|
1285
|
+
// Device identity is session-bound. Clear it on disconnect so a previous pair's
|
|
1286
|
+
// identifiers cannot be associated with the next connection. Connect must NOT clear
|
|
1287
|
+
// them: DeviceManager.disconnect already wipes them before any new connection, and
|
|
1288
|
+
// clearing on CONNECTED would wipe still-valid identity mid-session when a same-link
|
|
1289
|
+
// glasses_ready (e.g. ASG restart) re-publishes CONNECTED (iOS has no equal-state early
|
|
1290
|
+
// return, unlike Android).
|
|
1291
1291
|
DeviceStore.shared.apply("glasses", "serialNumber", "")
|
|
1292
|
+
DeviceStore.shared.apply("glasses", "bluetoothMacAddress", "")
|
|
1292
1293
|
}
|
|
1293
1294
|
connectionState = state
|
|
1294
1295
|
DeviceStore.shared.apply("glasses", "connectionState", state)
|
|
@@ -1296,6 +1297,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1296
1297
|
// a previous pairing into the next one (would otherwise surface as wrong overall_percent
|
|
1297
1298
|
// or stale lastBesOtaProgress on the next OTA).
|
|
1298
1299
|
if state == ConnTypes.DISCONNECTED {
|
|
1300
|
+
resetPendingAckState(reason: "BLE session ended")
|
|
1299
1301
|
resetWireNegotiationState()
|
|
1300
1302
|
resetAncsRelayState()
|
|
1301
1303
|
// Queued writes are session-bound: transmitting them into the NEXT session
|
|
@@ -1451,6 +1453,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1451
1453
|
private let KEEP_ALIVE_INTERVAL_MS: UInt64 = 5_000_000_000 // 5 seconds
|
|
1452
1454
|
private let CONNECTION_TIMEOUT_MS: UInt64 = 100_000_000_000 // 100 seconds
|
|
1453
1455
|
private let HEARTBEAT_INTERVAL_MS: TimeInterval = 30.0 // 30 seconds
|
|
1456
|
+
private let BES_OTA_HEARTBEAT_LEASE_SEC: TimeInterval = 120.0
|
|
1454
1457
|
private let BATTERY_REQUEST_EVERY_N_HEARTBEATS = 10
|
|
1455
1458
|
private let SIGNAL_STRENGTH_READ_INTERVAL_MS: TimeInterval = 10.0
|
|
1456
1459
|
private let MIN_SEND_DELAY_MS: UInt64 = 160_000_000 // 160ms in nanoseconds
|
|
@@ -1545,6 +1548,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1545
1548
|
|
|
1546
1549
|
/// BES OTA progress tracking - only send to UI on 5% increments
|
|
1547
1550
|
private var lastBesOtaProgress = -1
|
|
1551
|
+
private var besOtaHeartbeatSuppressedUntil: TimeInterval = 0
|
|
1548
1552
|
|
|
1549
1553
|
// Cached OTA session context from last ota_status — used to fill in session fields for sr_adota
|
|
1550
1554
|
private var cachedOtaSessionId: String?
|
|
@@ -1940,6 +1944,18 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1940
1944
|
private var pending: PendingMessage?
|
|
1941
1945
|
private var pendingMessageTimer: Timer?
|
|
1942
1946
|
|
|
1947
|
+
/// ACK state belongs to the BLE session that sent the message. If the link drops while an ACK
|
|
1948
|
+
/// is outstanding, keeping `pending` set permanently blocks the command queue because the
|
|
1949
|
+
/// timeout timer is stopped during disconnect. Release both together before reconnecting.
|
|
1950
|
+
private func resetPendingAckState(reason: String) {
|
|
1951
|
+
pendingMessageTimer?.invalidate()
|
|
1952
|
+
pendingMessageTimer = nil
|
|
1953
|
+
|
|
1954
|
+
guard let pendingMessage = pending else { return }
|
|
1955
|
+
pending = nil
|
|
1956
|
+
Bridge.log("LIVE: Cleared pending ACK mId \(pendingMessage.id) because \(reason)")
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1943
1959
|
actor CommandQueue {
|
|
1944
1960
|
private var commands: [PendingMessage] = []
|
|
1945
1961
|
|
|
@@ -2668,6 +2684,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2668
2684
|
if let seq = json["sq"] as? [String] ?? json["step_sequence"] as? [String], !seq.isEmpty {
|
|
2669
2685
|
cachedOtaStepSequence = seq
|
|
2670
2686
|
}
|
|
2687
|
+
updateBesOtaHeartbeatGuard(stepType: osStepType, phase: osPhase, status: osStatus)
|
|
2671
2688
|
|
|
2672
2689
|
Bridge.log("LIVE: 📱 OTA status - step \(osCurrentStep)/\(osTotalSteps) \(osPhase) \(osStatus) \(osOverallPercent)%")
|
|
2673
2690
|
|
|
@@ -2701,6 +2718,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2701
2718
|
} else {
|
|
2702
2719
|
unified = "in_progress"
|
|
2703
2720
|
}
|
|
2721
|
+
updateBesOtaHeartbeatGuard(stepType: currentUpdate, phase: legacyPhase, status: unified)
|
|
2704
2722
|
Bridge.log(
|
|
2705
2723
|
"LIVE: 📱 Legacy ota_progress → ota_status: \(legacyStage) \(legacyStatus) \(legacyProgress)%"
|
|
2706
2724
|
)
|
|
@@ -2760,10 +2778,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2760
2778
|
if let systemTimeMs = fields["system_time_ms"] as? NSNumber {
|
|
2761
2779
|
DeviceStore.shared.apply("glasses", "systemTimeMs", systemTimeMs.int64Value)
|
|
2762
2780
|
}
|
|
2763
|
-
if let bluetoothMacAddress = fields
|
|
2781
|
+
if let bluetoothMacAddress = nonEmptyStringValue(fields, "bt_mac_address") {
|
|
2764
2782
|
DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
|
|
2765
2783
|
}
|
|
2766
|
-
if let serialNumber = fields
|
|
2784
|
+
if let serialNumber = nonEmptyStringValue(fields, "serial_number") {
|
|
2767
2785
|
DeviceStore.shared.apply("glasses", "serialNumber", serialNumber)
|
|
2768
2786
|
}
|
|
2769
2787
|
if let systemTimeMs = fields["system_time_ms"] as? NSNumber {
|
|
@@ -2828,6 +2846,19 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2828
2846
|
cachedOtaCurrentStep = 0
|
|
2829
2847
|
cachedOtaStepSequence = nil
|
|
2830
2848
|
lastBesOtaProgress = -1
|
|
2849
|
+
// The bounded UART-ownership lease is transport state, not session-cache state. Keep it
|
|
2850
|
+
// through a BLE reconnect so heartbeats cannot resume while BES still owns the UART.
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
private func updateBesOtaHeartbeatGuard(stepType: String, phase: String, status: String) {
|
|
2854
|
+
guard stepType.lowercased() == "bes", phase.lowercased() == "install" else { return }
|
|
2855
|
+
switch status.lowercased() {
|
|
2856
|
+
case "failed", "complete", "step_complete", "finished", "success":
|
|
2857
|
+
besOtaHeartbeatSuppressedUntil = 0
|
|
2858
|
+
default:
|
|
2859
|
+
besOtaHeartbeatSuppressedUntil =
|
|
2860
|
+
ProcessInfo.processInfo.systemUptime + BES_OTA_HEARTBEAT_LEASE_SEC
|
|
2861
|
+
}
|
|
2831
2862
|
}
|
|
2832
2863
|
|
|
2833
2864
|
/// Falls back to raw besProgress when step sequence is unavailable.
|
|
@@ -2999,62 +3030,41 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2999
3030
|
var progress = ((rawProgress + 2) / 5) * 5
|
|
3000
3031
|
if progress > 100 { progress = 100 }
|
|
3001
3032
|
|
|
3002
|
-
// Only send if progress changed to a new 5% increment
|
|
3003
3033
|
let isTerminalStatus = type == "success" || type == "error" || type == "fail"
|
|
3004
|
-
if progress == lastBesOtaProgress && !isTerminalStatus {
|
|
3005
|
-
break // Skip duplicate progress
|
|
3006
|
-
}
|
|
3007
|
-
lastBesOtaProgress = progress
|
|
3008
|
-
|
|
3009
|
-
Bridge.log(
|
|
3010
|
-
"LIVE: 📱 BES OTA progress via sr_adota - type: \(type), raw: \(rawProgress)%, rounded: \(progress)%"
|
|
3011
|
-
)
|
|
3012
|
-
|
|
3013
|
-
// Determine status and error message based on type
|
|
3014
|
-
var besOtaStatus: String
|
|
3015
3034
|
var besOtaProgressVal: Int
|
|
3016
3035
|
var besOtaErrorMessage: String? = nil
|
|
3017
3036
|
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3037
|
+
let failed = type == "error" || type == "fail"
|
|
3038
|
+
let succeeded = type == "success"
|
|
3039
|
+
if isTerminalStatus {
|
|
3040
|
+
besOtaHeartbeatSuppressedUntil = 0
|
|
3041
|
+
} else {
|
|
3042
|
+
// Refresh before progress de-duplication: raw progress proves the BES
|
|
3043
|
+
// transfer is active even when the rounded UI value is unchanged.
|
|
3044
|
+
besOtaHeartbeatSuppressedUntil =
|
|
3045
|
+
ProcessInfo.processInfo.systemUptime + BES_OTA_HEARTBEAT_LEASE_SEC
|
|
3046
|
+
}
|
|
3047
|
+
if succeeded {
|
|
3024
3048
|
besOtaProgressVal = 100
|
|
3025
|
-
|
|
3026
|
-
} else if type == "error" || type == "fail" {
|
|
3027
|
-
besOtaStatus = "FAILED"
|
|
3049
|
+
} else if failed {
|
|
3028
3050
|
besOtaProgressVal = progress
|
|
3029
3051
|
besOtaErrorMessage = bodyObj["message"] as? String ?? "BES update failed"
|
|
3030
|
-
lastBesOtaProgress = -1 // Reset for next OTA
|
|
3031
|
-
} else if type == "update" {
|
|
3032
|
-
besOtaStatus = "PROGRESS"
|
|
3033
|
-
besOtaProgressVal = progress
|
|
3034
3052
|
} else {
|
|
3035
|
-
//
|
|
3036
|
-
|
|
3037
|
-
besOtaProgressVal = progress
|
|
3053
|
+
// A raw update:100 precedes whole-image CRC/apply and is not terminal.
|
|
3054
|
+
besOtaProgressVal = min(progress, 95)
|
|
3038
3055
|
}
|
|
3039
3056
|
|
|
3040
|
-
|
|
3041
|
-
if
|
|
3042
|
-
|
|
3043
|
-
// whose BES step is the LAST step never gets a follow-up ota_status from
|
|
3044
|
-
// the glasses — consumers mapping on this synthetic status would otherwise
|
|
3045
|
-
// never see a terminal state. Emit "complete" for the final step;
|
|
3046
|
-
// mid-session BES steps keep "step_complete" so session-level trackers
|
|
3047
|
-
// advance normally. Unknown sessions (cachedOtaTotalSteps == 0, e.g.
|
|
3048
|
-
// legacy glasses that never sent an ota_status) conservatively keep
|
|
3049
|
-
// "step_complete".
|
|
3050
|
-
syntheticStatus = (cachedOtaTotalSteps > 0 && cachedOtaCurrentStep >= cachedOtaTotalSteps)
|
|
3051
|
-
? "complete"
|
|
3052
|
-
: "step_complete"
|
|
3053
|
-
} else if besOtaStatus == "FAILED" {
|
|
3054
|
-
syntheticStatus = "failed"
|
|
3055
|
-
} else {
|
|
3056
|
-
syntheticStatus = "in_progress"
|
|
3057
|
+
// Only send if nonterminal display progress changed to a new 5% increment.
|
|
3058
|
+
if besOtaProgressVal == lastBesOtaProgress && !isTerminalStatus {
|
|
3059
|
+
break
|
|
3057
3060
|
}
|
|
3061
|
+
lastBesOtaProgress = isTerminalStatus ? -1 : besOtaProgressVal
|
|
3062
|
+
|
|
3063
|
+
Bridge.log(
|
|
3064
|
+
"LIVE: 📱 BES OTA progress via sr_adota - type: \(type), raw: \(rawProgress)%, rounded: \(progress)%, display: \(besOtaProgressVal)%"
|
|
3065
|
+
)
|
|
3066
|
+
|
|
3067
|
+
let syntheticStatus = succeeded ? "step_complete" : (failed ? "failed" : "in_progress")
|
|
3058
3068
|
let sid = cachedOtaSessionId ?? ""
|
|
3059
3069
|
let totalSteps = cachedOtaTotalSteps > 0 ? cachedOtaTotalSteps : 1
|
|
3060
3070
|
let currentStep = cachedOtaCurrentStep > 0 ? cachedOtaCurrentStep : 1
|
|
@@ -3441,15 +3451,17 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3441
3451
|
let androidVersion = json["android_version"] as? String ?? ""
|
|
3442
3452
|
let otaVersionUrl = json["ota_version_url"] as? String ?? ""
|
|
3443
3453
|
let firmwareVersion = json["firmware_version"] as? String ?? ""
|
|
3444
|
-
let bluetoothMacAddress = json
|
|
3445
|
-
let serialNumber = json
|
|
3454
|
+
let bluetoothMacAddress = nonEmptyStringValue(json, "bt_mac_address")
|
|
3455
|
+
let serialNumber = nonEmptyStringValue(json, "serial_number")
|
|
3446
3456
|
|
|
3447
3457
|
DeviceStore.shared.apply("glasses", "appVersion", appVersion)
|
|
3448
3458
|
DeviceStore.shared.apply("glasses", "buildNumber", buildNumber)
|
|
3449
3459
|
DeviceStore.shared.apply("glasses", "otaVersionUrl", otaVersionUrl)
|
|
3450
3460
|
DeviceStore.shared.apply("glasses", "firmwareVersion", firmwareVersion)
|
|
3451
|
-
|
|
3452
|
-
|
|
3461
|
+
if let bluetoothMacAddress {
|
|
3462
|
+
DeviceStore.shared.apply("glasses", "bluetoothMacAddress", bluetoothMacAddress)
|
|
3463
|
+
}
|
|
3464
|
+
if let serialNumber {
|
|
3453
3465
|
DeviceStore.shared.apply("glasses", "serialNumber", serialNumber)
|
|
3454
3466
|
}
|
|
3455
3467
|
isNewVersion = (Int(buildNumber) ?? 0) >= 5
|
|
@@ -3462,14 +3474,14 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3462
3474
|
// hasMic = supportsLC3Audio
|
|
3463
3475
|
|
|
3464
3476
|
Bridge.log(
|
|
3465
|
-
"Glasses Version - App: \(appVersion), Build: \(buildNumber), Device: \(deviceModel), Android: \(androidVersion), Firmware: \(firmwareVersion), BT MAC: \(bluetoothMacAddress), OTA URL: \(otaVersionUrl)"
|
|
3477
|
+
"Glasses Version - App: \(appVersion), Build: \(buildNumber), Device: \(deviceModel), Android: \(androidVersion), Firmware: \(firmwareVersion), BT MAC available: \(bluetoothMacAddress != nil), OTA URL: \(otaVersionUrl)"
|
|
3466
3478
|
)
|
|
3467
3479
|
Bridge.log("LIVE: LC3 Audio Support: \(supportsLC3Audio), Has Mic: \(hasMic)")
|
|
3468
3480
|
emitVersionInfo(
|
|
3469
3481
|
appVersion: appVersion, buildNumber: buildNumber, deviceModel: deviceModel,
|
|
3470
3482
|
androidVersion: androidVersion, otaVersionUrl: otaVersionUrl,
|
|
3471
3483
|
firmwareVersion: firmwareVersion,
|
|
3472
|
-
bluetoothMacAddress: bluetoothMacAddress
|
|
3484
|
+
bluetoothMacAddress: bluetoothMacAddress ?? ""
|
|
3473
3485
|
)
|
|
3474
3486
|
}
|
|
3475
3487
|
|
|
@@ -5065,6 +5077,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5065
5077
|
Bridge.log("LIVE: Skipping heartbeat - glasses not fully booted or not connected")
|
|
5066
5078
|
return
|
|
5067
5079
|
}
|
|
5080
|
+
if ProcessInfo.processInfo.systemUptime < besOtaHeartbeatSuppressedUntil {
|
|
5081
|
+
Bridge.log("LIVE: Skipping heartbeat while BES OTA owns the UART")
|
|
5082
|
+
return
|
|
5083
|
+
}
|
|
5068
5084
|
|
|
5069
5085
|
// Send ping message to glasses hardware (no ACK needed for heartbeats)
|
|
5070
5086
|
let pingJson: [String: Any] = ["type": "ping"]
|
|
@@ -5178,8 +5194,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5178
5194
|
stopReadinessCheckLoop()
|
|
5179
5195
|
stopConnectionTimeout()
|
|
5180
5196
|
stopMicBeat() // Stop LC3 audio micbeat
|
|
5181
|
-
|
|
5182
|
-
pendingMessageTimer = nil
|
|
5197
|
+
resetPendingAckState(reason: "transport timers stopped")
|
|
5183
5198
|
reconnectionWorkItem?.cancel()
|
|
5184
5199
|
reconnectionWorkItem = nil
|
|
5185
5200
|
}
|
package/package.json
CHANGED
|
@@ -1201,6 +1201,8 @@ export interface OtaUpdateInfo {
|
|
|
1201
1201
|
updates: string[] // ["apk", "mtk", "bes"]
|
|
1202
1202
|
totalSize: number
|
|
1203
1203
|
cacheReady?: boolean
|
|
1204
|
+
/** Exact BES target selected from the OTA manifest, when a BES step is pending. */
|
|
1205
|
+
besVersion?: string
|
|
1204
1206
|
/** True when the APK step installs an older build than the glasses currently run (exact-pin manifests only). */
|
|
1205
1207
|
isDowngrade?: boolean
|
|
1206
1208
|
}
|