@glassly/bluetooth-sdk 0.1.0-dev.99 → 0.2.8-dev.445
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 +47 -6
- package/android/build.gradle +7 -7
- package/android/lc3Lib/build.gradle +2 -2
- package/android/src/main/assets/G2Firmware/README.md +27 -2
- package/android/src/main/assets/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
- package/android/src/main/assets/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +38 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +50 -14
- package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +18 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +139 -10
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +28 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +199 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +10 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +4 -4
- package/android/src/main/java/com/glassly/bluetoothsdk/GlassesNotifications.kt +155 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +1065 -255
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2BleLink.kt +119 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +29 -28
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +138 -33
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +673 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +235 -162
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransport.kt +402 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnostics.kt +84 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +9 -9
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Keyboard.kt +689 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardBridge.kt +452 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardHid.kt +208 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFile.kt +181 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2RingBattery.kt +20 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Sensors.kt +67 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2TouchInput.kt +39 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +5 -5
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +14 -14
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/{GlasslyBle.java → NexProto.java} +6462 -7618
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIds.kt +58 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +77 -2
- package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +55 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +24 -149
- package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/G2BleLinkTest.kt +70 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +45 -56
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +78 -5
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +489 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +100 -43
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransportTest.kt +252 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnosticsTest.kt +64 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardTest.kt +271 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFileTest.kt +204 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2RingBatteryTest.kt +56 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2SensorsTest.kt +56 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2TouchInputTest.kt +60 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NexProtoDescriptorTest.kt +32 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIdsTest.kt +58 -0
- package/build/BluetoothSdk.types.d.ts +209 -20
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +1 -1
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +30 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +28 -1
- package/build/generated/changelogCatalog.d.ts.map +1 -1
- package/build/generated/changelogCatalog.js +37 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +4 -4
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +14 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +102 -13
- package/ios/GlasslyBluetoothSDK.podspec +1 -1
- package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +8 -3
- package/ios/Source/BluetoothSDK.swift +53 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +13 -0
- package/ios/Source/DeviceManager.swift +151 -33
- package/ios/Source/DeviceStore.swift +28 -14
- package/ios/Source/DisplayPacer.swift +180 -0
- package/ios/Source/GeneratedChangelogCatalog.swift +10 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +4 -4
- package/ios/Source/GlassesNotifications.swift +181 -0
- package/ios/Source/ObservableStore.swift +14 -2
- package/ios/Source/Resources/G2Firmware/README.md +27 -2
- package/ios/Source/Resources/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
- package/ios/Source/Resources/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
- package/ios/Source/controllers/R1.swift +0 -1
- package/ios/Source/internal/BluetoothAvailability.swift +31 -0
- package/ios/Source/sgcs/G2.swift +1420 -495
- package/ios/Source/sgcs/G2AncsRelay.swift +268 -0
- package/ios/Source/sgcs/G2BleLink.swift +102 -0
- package/ios/Source/sgcs/G2CfwClassification.swift +45 -27
- package/ios/Source/sgcs/G2CfwRenderer.swift +166 -33
- package/ios/Source/sgcs/G2CfwScene.swift +779 -0
- package/ios/Source/sgcs/G2CfwTextureCache.swift +244 -117
- package/ios/Source/sgcs/G2CfwTransport.swift +485 -0
- package/ios/Source/sgcs/G2DisplayDiagnostics.swift +154 -0
- package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
- package/ios/Source/sgcs/G2Flasher.swift +9 -9
- package/ios/Source/sgcs/G2Keyboard.swift +780 -0
- package/ios/Source/sgcs/G2KeyboardBridge.swift +438 -0
- package/ios/Source/sgcs/G2KeyboardHid.swift +290 -0
- package/ios/Source/sgcs/G2NotificationTuning.swift +24 -0
- package/ios/Source/sgcs/G2RingBattery.swift +22 -0
- package/ios/Source/sgcs/G2Sensors.swift +75 -0
- package/ios/Source/sgcs/G2TouchInput.swift +51 -0
- package/ios/Source/sgcs/G2WritePacing.swift +18 -0
- package/ios/Source/sgcs/MentraNex.swift +44 -44
- package/ios/Source/sgcs/SGCManager.swift +120 -13
- package/ios/Source/sgcs/{glassly_ble.pb.swift → nex_ble.pb.swift} +405 -465
- package/ios/Source/speakerid/SpeakerIdEngine.swift +2 -2
- package/ios/Source/status/DeviceStatus.swift +27 -0
- package/ios/Source/stt/SherpaOnnxTranscriber.swift +15 -14
- package/ios/Source/utils/Enums.swift +0 -1
- package/ios/Source/utils/G1Text.swift +0 -1
- package/ios/Source/utils/G2NotifyFile.swift +20 -10
- package/ios/Source/utils/JSCExperiment.swift +6 -6
- package/ios/Source/utils/Models.swift +0 -1
- package/ios/Tests/BluetoothSDKTests/BluetoothAvailabilityTests.swift +29 -0
- package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
- package/ios/Tests/BluetoothSDKTests/G2AncsRelayTests.swift +186 -0
- package/ios/Tests/BluetoothSDKTests/G2BleLinkTests.swift +52 -0
- package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +46 -51
- package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +164 -5
- package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +497 -0
- package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +108 -44
- package/ios/Tests/BluetoothSDKTests/G2CfwTransportTests.swift +259 -0
- package/ios/Tests/BluetoothSDKTests/G2DisplayDiagnosticsTests.swift +89 -0
- package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +3 -2
- package/ios/Tests/BluetoothSDKTests/G2ImageAckTests.swift +104 -0
- package/ios/Tests/BluetoothSDKTests/G2KeyboardTests.swift +274 -0
- package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +21 -5
- package/ios/Tests/BluetoothSDKTests/G2RingBatteryTests.swift +56 -0
- package/ios/Tests/BluetoothSDKTests/G2SensorsTests.swift +56 -0
- package/ios/Tests/BluetoothSDKTests/G2TouchInputTests.swift +71 -0
- package/ios/Tests/BluetoothSDKTests/G2WritePacingTests.swift +35 -0
- package/ios/Tests/BluetoothSDKTests/GlassesNotificationTests.swift +95 -0
- package/package.json +1 -1
- package/plugin/build/withAndroid.js +2 -2
- package/plugin/build/withIos.js +1 -1
- package/scripts/gen-nex-proto.sh +12 -0
- package/scripts/inject-ios-sdk-version.mjs +1 -1
- package/src/BluetoothSdk.types.ts +214 -19
- package/src/_internal.ts +1 -1
- package/src/_private/BluetoothSdkModule.ts +40 -0
- package/src/generated/changelogCatalog.ts +37 -1
- package/src/generated/releaseMetadata.ts +4 -4
- package/src/index.ts +24 -0
package/ios/Source/sgcs/G2.swift
CHANGED
|
@@ -79,6 +79,7 @@ private enum ServiceID: UInt8 {
|
|
|
79
79
|
case deviceSettings = 128 // 0x80 - UX_DEVICE_SETTINGS_APP_ID
|
|
80
80
|
case evenHubCtrl = 129 // 0x81 - EvenHub CTRL channel (init/registration)
|
|
81
81
|
case evenHub = 224 // 0xE0 - UI_BACKGROUND_EVENHUB_APP_ID
|
|
82
|
+
case cfwStream = 240 // 0xF0 - private Glassly CFW message streams (GLASSLYCFW/31)
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
/// EvenHub command IDs from EvenHub.proto
|
|
@@ -131,6 +132,12 @@ private enum OsEventType: Int32 {
|
|
|
131
132
|
case tapStart = 9 // press recognized (hold start)
|
|
132
133
|
case tapEnd = 10 // released
|
|
133
134
|
case tapThenHold = 11 // CFW extension: 2.2.9 tap-then-hold (stock Menu gesture)
|
|
135
|
+
// 12 is head-up (delivered through the settings path, not here); 13 was an
|
|
136
|
+
// experimental temple edge and is burned. 14 is the Glassly CFW's ring
|
|
137
|
+
// touch-down (R1 subtype 0x0d, GLASSLYCFW/37 on 2.3.0.24). Only the ring
|
|
138
|
+
// produces it and the stock sender leaves source 0, so the id itself is the
|
|
139
|
+
// ring marker. Unknown ring wire types arrive as 127 and are dropped.
|
|
140
|
+
case ringTouchDown = 14
|
|
134
141
|
}
|
|
135
142
|
|
|
136
143
|
/// g2_settingCommandId from g2_setting.proto
|
|
@@ -1590,14 +1597,67 @@ private class G2ReceiveManager {
|
|
|
1590
1597
|
/// All access is guarded by `lock`. Resuming a `CheckedContinuation` from any thread is safe, and
|
|
1591
1598
|
/// `arm`/`resolve` clear the slot atomically so neither the duplicate L/R ACK nor the timeout can
|
|
1592
1599
|
/// resume the same continuation twice.
|
|
1593
|
-
|
|
1600
|
+
final class ImgAckBox {
|
|
1601
|
+
/// ImgResCmd scalar defaults are zero when omitted on the wire. In
|
|
1602
|
+
/// particular, session zero is valid when our 8-bit session counter wraps.
|
|
1603
|
+
/// Requiring field 3 discarded that ACK and stalled the sender until timeout.
|
|
1604
|
+
static func decodeResult(_ data: Data) -> (session: Int, fragment: Int32, success: Bool)? {
|
|
1605
|
+
var reader = ProtobufReader(data)
|
|
1606
|
+
let fields = reader.parseFields()
|
|
1607
|
+
guard let errorCode = fields[8] as? Int32 else { return nil }
|
|
1608
|
+
guard fields[3] == nil || fields[3] is Int32,
|
|
1609
|
+
fields[6] == nil || fields[6] is Int32 else { return nil }
|
|
1610
|
+
return (
|
|
1611
|
+
session: Int((fields[3] as? Int32) ?? 0),
|
|
1612
|
+
fragment: (fields[6] as? Int32) ?? 0,
|
|
1613
|
+
success: errorCode == 4
|
|
1614
|
+
)
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1594
1617
|
private let lock = NSLock()
|
|
1595
1618
|
private var session: Int?
|
|
1596
1619
|
private var fragment: Int32?
|
|
1597
1620
|
private var cont: CheckedContinuation<Bool, Never>?
|
|
1621
|
+
/// Names the current arming. A timeout carries the token of the arming it was
|
|
1622
|
+
/// started for, so a timer left over from an earlier transfer can never fail a
|
|
1623
|
+
/// later one that happens to reuse the same (session, fragment) key.
|
|
1624
|
+
private var token: UInt64 = 0
|
|
1625
|
+
/// The timeout task of the current arming, cancelled once the ack lands.
|
|
1626
|
+
private var timer: Task<Void, Never>?
|
|
1598
1627
|
/// Wall-clock ms of the last successful fragment ack — a cheap page-liveness
|
|
1599
1628
|
/// signal (a torn-down page cannot ack image transfers).
|
|
1600
1629
|
private var lastSuccessMs: Int64 = 0
|
|
1630
|
+
private var lastOutcome: (at: TimeInterval, reason: String)?
|
|
1631
|
+
|
|
1632
|
+
func outcome() -> (at: TimeInterval, reason: String)? {
|
|
1633
|
+
lock.lock()
|
|
1634
|
+
defer { lock.unlock() }
|
|
1635
|
+
return lastOutcome
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
/// Arm before sending, including transports that can reply synchronously.
|
|
1639
|
+
/// Cancellation resolves the waiter so shutdown never waits out an ACK timeout.
|
|
1640
|
+
@MainActor
|
|
1641
|
+
func wait(session: Int, fragment: Int32, timeoutNs: UInt64, send: () -> Void) async -> Bool {
|
|
1642
|
+
await withTaskCancellationHandler {
|
|
1643
|
+
await withCheckedContinuation { cont in
|
|
1644
|
+
let token = arm(session: session, fragment: fragment, cont: cont)
|
|
1645
|
+
guard !Task.isCancelled else {
|
|
1646
|
+
timeout(token: token)
|
|
1647
|
+
return
|
|
1648
|
+
}
|
|
1649
|
+
let timer = Task.detached { [self] in
|
|
1650
|
+
try? await Task.sleep(nanoseconds: timeoutNs)
|
|
1651
|
+
guard !Task.isCancelled else { return }
|
|
1652
|
+
timeout(token: token)
|
|
1653
|
+
}
|
|
1654
|
+
attachTimer(timer, token: token)
|
|
1655
|
+
send()
|
|
1656
|
+
}
|
|
1657
|
+
} onCancel: {
|
|
1658
|
+
self.resolve(session: session, fragment: fragment, success: false)
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1601
1661
|
|
|
1602
1662
|
/// Milliseconds since the last successful ack, or Int64.max if none yet.
|
|
1603
1663
|
func msSinceLastSuccess() -> Int64 {
|
|
@@ -1608,13 +1668,49 @@ private final class ImgAckBox {
|
|
|
1608
1668
|
return Int64(Date().timeIntervalSince1970 * 1000) - last
|
|
1609
1669
|
}
|
|
1610
1670
|
|
|
1611
|
-
/// Install the awaiting continuation for `(session, fragment)
|
|
1612
|
-
func arm(session: Int, fragment: Int32, cont: CheckedContinuation<Bool, Never>) {
|
|
1671
|
+
/// Install the awaiting continuation for `(session, fragment)`; returns the arming's token.
|
|
1672
|
+
func arm(session: Int, fragment: Int32, cont: CheckedContinuation<Bool, Never>) -> UInt64 {
|
|
1613
1673
|
lock.lock()
|
|
1674
|
+
token &+= 1
|
|
1675
|
+
let t = token
|
|
1614
1676
|
self.session = session
|
|
1615
1677
|
self.fragment = fragment
|
|
1616
1678
|
self.cont = cont
|
|
1679
|
+
lastOutcome = nil
|
|
1680
|
+
let stale = timer
|
|
1681
|
+
timer = nil
|
|
1682
|
+
lock.unlock()
|
|
1683
|
+
stale?.cancel()
|
|
1684
|
+
return t
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/// Attach the timeout task for `token`; a task for an arming that is already over is cancelled.
|
|
1688
|
+
func attachTimer(_ task: Task<Void, Never>, token: UInt64) {
|
|
1689
|
+
lock.lock()
|
|
1690
|
+
let current = self.token == token && cont != nil
|
|
1691
|
+
if current {
|
|
1692
|
+
timer = task
|
|
1693
|
+
}
|
|
1694
|
+
lock.unlock()
|
|
1695
|
+
if !current {
|
|
1696
|
+
task.cancel()
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/// Time out the arming named by `token`, if it is still the one awaiting an ack.
|
|
1701
|
+
func timeout(token: UInt64) {
|
|
1702
|
+
lock.lock()
|
|
1703
|
+
guard self.token == token, let c = cont else {
|
|
1704
|
+
lock.unlock()
|
|
1705
|
+
return
|
|
1706
|
+
}
|
|
1707
|
+
session = nil
|
|
1708
|
+
fragment = nil
|
|
1709
|
+
cont = nil
|
|
1710
|
+
timer = nil
|
|
1711
|
+
lastOutcome = (ProcessInfo.processInfo.systemUptime, "timeout")
|
|
1617
1712
|
lock.unlock()
|
|
1713
|
+
c.resume(returning: false)
|
|
1618
1714
|
}
|
|
1619
1715
|
|
|
1620
1716
|
/// Resume the continuation if it matches `(session, fragment)`. Returns true if it fired.
|
|
@@ -1628,18 +1724,17 @@ private final class ImgAckBox {
|
|
|
1628
1724
|
self.session = nil
|
|
1629
1725
|
self.fragment = nil
|
|
1630
1726
|
cont = nil
|
|
1727
|
+
lastOutcome = (ProcessInfo.processInfo.systemUptime, success ? "ack" : "nack")
|
|
1728
|
+
let t = timer
|
|
1729
|
+
timer = nil
|
|
1631
1730
|
if success {
|
|
1632
1731
|
lastSuccessMs = Int64(Date().timeIntervalSince1970 * 1000)
|
|
1633
1732
|
}
|
|
1634
1733
|
lock.unlock()
|
|
1734
|
+
t?.cancel()
|
|
1635
1735
|
c.resume(returning: success)
|
|
1636
1736
|
return true
|
|
1637
1737
|
}
|
|
1638
|
-
|
|
1639
|
-
/// Time out the continuation for `(session, fragment)` (resumes false if still armed).
|
|
1640
|
-
func timeout(session: Int, fragment: Int32) {
|
|
1641
|
-
_ = resolve(session: session, fragment: fragment, success: false)
|
|
1642
|
-
}
|
|
1643
1738
|
}
|
|
1644
1739
|
|
|
1645
1740
|
// MARK: - G2 Class (SGCManager implementation)
|
|
@@ -1708,31 +1803,104 @@ final class G2: NSObject, SGCManager {
|
|
|
1708
1803
|
let hasMic = true
|
|
1709
1804
|
nonisolated let firmwareFlasher = G2Flasher()
|
|
1710
1805
|
|
|
1711
|
-
///
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
private var
|
|
1806
|
+
/// nil until settings classify the connection as stock, upstream, or Glassly.
|
|
1807
|
+
private struct SensorCommand {
|
|
1808
|
+
let id: UUID
|
|
1809
|
+
let data: Data
|
|
1810
|
+
let complete: (Bool) -> Void
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// Kept separate from scene coalescing: controls must never be dropped by a new frame.
|
|
1814
|
+
private var sensorCommands: [SensorCommand] = []
|
|
1815
|
+
private var sensorNavigationRecovering = false
|
|
1816
|
+
|
|
1817
|
+
func glassesSensorControl(sensor: String, operation: String, options: [String: Double]) async -> Bool {
|
|
1818
|
+
guard supportsCfwTransport, !navigationModeActive,
|
|
1819
|
+
sensorCommands.count < 8,
|
|
1820
|
+
let data = G2Sensors.command(sensor: sensor, operation: operation, options: options) else { return false }
|
|
1821
|
+
let id = UUID()
|
|
1822
|
+
return await withCheckedContinuation { continuation in
|
|
1823
|
+
sensorCommands.append(SensorCommand(id: id, data: data, complete: { continuation.resume(returning: $0) }))
|
|
1824
|
+
signalDisplayDirty()
|
|
1825
|
+
Task { @MainActor [weak self] in
|
|
1826
|
+
try? await Task.sleep(nanoseconds: 10_000_000_000)
|
|
1827
|
+
guard let self, let index = self.sensorCommands.firstIndex(where: { $0.id == id }) else { return }
|
|
1828
|
+
self.sensorCommands.remove(at: index).complete(false)
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
private func cancelSensorCommands() {
|
|
1834
|
+
sensorNavigationRecovering = false
|
|
1835
|
+
let pending = sensorCommands
|
|
1836
|
+
sensorCommands.removeAll()
|
|
1837
|
+
for command in pending {
|
|
1838
|
+
command.complete(false)
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
private var cfwClassification: G2CfwClassification?
|
|
1843
|
+
private var cfwClassified: Bool {
|
|
1844
|
+
cfwClassification != nil
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
private var cfwCapabilityString: String? {
|
|
1848
|
+
cfwClassification?.capabilityString
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1721
1851
|
private var cfwSceneActive = false
|
|
1722
1852
|
/// Rate limit on resurrecting the page for a blank scene; a non-blank one rebuilds immediately.
|
|
1723
1853
|
private var cfwLastIdleResurrectAt: Date?
|
|
1724
1854
|
private static let cfwIdleResurrectMinIntervalMs: Double = 5000
|
|
1725
|
-
private static let cfwSceneContainerID: Int32 = 10
|
|
1726
1855
|
private let cfwRenderer = G2CfwRenderer()
|
|
1856
|
+
/// Private SID-0xf0 message transport (GLASSLYCFW/31): one deflate
|
|
1857
|
+
/// context per connection, one stream per message, ACK-gated exactly like
|
|
1858
|
+
/// the old image carrier was. Payloads still go out on the LEFT arm; the
|
|
1859
|
+
/// right lens processes them over the frame bridge and its ACK returns
|
|
1860
|
+
/// through the left. No EvenHub image container is involved any more.
|
|
1861
|
+
private var cfwTransport = G2CfwTransport.Encoder()
|
|
1862
|
+
private var cfwStreamSequence = UInt8.random(in: 0 ... 255)
|
|
1863
|
+
private let cfwAckBox = G2CfwAckBox()
|
|
1864
|
+
/// The container set is in CFW shape: the blank "dashboard" text object
|
|
1865
|
+
/// only (touch capture + mic), no stock text or image containers.
|
|
1866
|
+
private var cfwPageInstalled = false
|
|
1727
1867
|
/// Deltas composite onto a shadow seeded by a mode-6 keyframe, so payloads drain in order.
|
|
1728
1868
|
/// Kinds tag that chain: only `.full` may drop queued fulls/deltas; `.seed`/`.moveBundle` survive.
|
|
1729
|
-
private enum CfwPayloadKind { case seed, full, delta, moveBundle, upload }
|
|
1869
|
+
private enum CfwPayloadKind { case seed, full, delta, moveBundle, upload, scene }
|
|
1730
1870
|
private struct CfwPayload {
|
|
1731
1871
|
let kind: CfwPayloadKind
|
|
1732
1872
|
let data: Data
|
|
1873
|
+
/// Host frame this payload puts on the glasses — reported once acked.
|
|
1874
|
+
var presents: SceneFrame.PresentTag? = nil
|
|
1875
|
+
var originAt: TimeInterval?
|
|
1876
|
+
var frameSeq = 0
|
|
1877
|
+
var appId = ""
|
|
1878
|
+
/// Lenses this payload is for (scene SHOWs split per lens once a frame uses element depth).
|
|
1879
|
+
var lenses: UInt8 = G2CfwTransport.lensBoth
|
|
1733
1880
|
}
|
|
1734
1881
|
|
|
1735
1882
|
private var cfwPendingPayloads: [CfwPayload] = []
|
|
1883
|
+
/// Bumped by every clear so an encoder suspended on its actor when the clear arrived is discarded.
|
|
1884
|
+
private var cfwClearSerial = 0
|
|
1885
|
+
/// Latest unencoded state. Only reconcileDisplay encodes it, once the
|
|
1886
|
+
/// previous payload chain has drained, bounding work under backpressure.
|
|
1887
|
+
private var cfwPendingFrame: SceneFrame? {
|
|
1888
|
+
didSet {
|
|
1889
|
+
if cfwPendingFrame != nil {
|
|
1890
|
+
cfwPendingFrameAt = G2DisplayDiagnostics.now
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
private var cfwPendingFrameAt = G2DisplayDiagnostics.now
|
|
1896
|
+
// Keep bounded render/BLE timings in phone logs so background stalls can
|
|
1897
|
+
// be diagnosed from bug reports (one summary and at most one stall per second).
|
|
1898
|
+
private let displayDiagnostics = G2DisplayDiagnostics(enabled: true)
|
|
1899
|
+
private var diagnosticFrameSeq = 0
|
|
1900
|
+
private var diagnosticAppId = ""
|
|
1901
|
+
/// No-op frames may join the payload currently awaiting its last fragment ACK.
|
|
1902
|
+
private var cfwInFlightPresentTag: SceneFrame.PresentTag?
|
|
1903
|
+
private var cfwPayloadInFlight = false
|
|
1736
1904
|
private var cfwShadowSeeded = false
|
|
1737
1905
|
private var cfwLastFrame: SceneFrame?
|
|
1738
1906
|
private var cfwLastBoxes: G2CfwRenderer.LensBoxes?
|
|
@@ -1768,40 +1936,62 @@ final class G2: NSObject, SGCManager {
|
|
|
1768
1936
|
/// the CFW capability string is seen again.
|
|
1769
1937
|
private var wakeLeaseWanted = false
|
|
1770
1938
|
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1939
|
+
/// ANCS relay (CFW revision 24): field-125 record reassembly plus the 45 s
|
|
1940
|
+
/// renewal of the firmware's fail-open 90 s relay lease. `ancsRelayLeaseHeld`
|
|
1941
|
+
/// remembers whether the last control sent was ENABLE, so the notification
|
|
1942
|
+
/// master toggle going off sends exactly one DISABLE.
|
|
1943
|
+
private var ancsRelay = G2AncsRelay()
|
|
1944
|
+
private var ancsRelayTask: Task<Void, Never>?
|
|
1945
|
+
private var ancsRelayLeaseHeld = false
|
|
1946
|
+
/// BLE keyboard bridge (CFW revision 39): one HID-over-GATT keyboard hosted
|
|
1947
|
+
/// by the right lens, driven over settings fields 130/131 (G2KeyboardBridge).
|
|
1948
|
+
private lazy var keyboardBridge = G2KeyboardBridge { [weak self] payload in
|
|
1949
|
+
self?.sendKeyboardControl(payload)
|
|
1950
|
+
}
|
|
1951
|
+
private var notificationConfig: GlassesNotificationConfig?
|
|
1952
|
+
private var notificationPhase = "idle"
|
|
1953
|
+
/// Config last pushed on this connection. Device-ready and the engine's
|
|
1954
|
+
/// connection watcher both apply on connect; the second identical apply
|
|
1955
|
+
/// would re-push the allowlist file, which the glasses redisplay even on a
|
|
1956
|
+
/// checksum cache hit.
|
|
1957
|
+
private var lastAppliedNotificationConfig: GlassesNotificationConfig?
|
|
1958
|
+
|
|
1959
|
+
/// Glassly CFW at or above the supported revision. Anything else (stock,
|
|
1960
|
+
/// upstream, older Glassly builds) is treated as stock.
|
|
1961
|
+
private var supportsCfwTransport: Bool {
|
|
1962
|
+
guard let classification = cfwClassification, classification.isGlassly else { return false }
|
|
1963
|
+
return (classification.revision ?? 0) >= G2CfwClassification.minimumRevision
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
var supportsCfwSceneRendering: Bool {
|
|
1967
|
+
supportsCfwTransport
|
|
1774
1968
|
// Escape hatch (default ON): OFF falls back to stock containers. Reconnect to switch cleanly.
|
|
1775
1969
|
&& (DeviceStore.shared.get("bluetooth", "cfw_scene_renderer") as? Bool ?? true)
|
|
1776
1970
|
}
|
|
1777
1971
|
|
|
1778
|
-
private var
|
|
1779
|
-
|
|
1780
|
-
&& imageContainers[0].id == Self.cfwSceneContainerID
|
|
1781
|
-
&& imageContainers[0].x == 0 && imageContainers[0].y == 0
|
|
1782
|
-
&& imageContainers[0].width == 576 && imageContainers[0].height == 288
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
/// Mode-9 rect copies have no capability token; they shipped with
|
|
1786
|
-
/// protocol revision 15 alongside the texture/font modes.
|
|
1787
|
-
private var supportsCfwRectCopy: Bool {
|
|
1788
|
-
cfwProtocolVersion >= 15
|
|
1972
|
+
private var hasCfwPage: Bool {
|
|
1973
|
+
cfwPageInstalled && textContainers.isEmpty && imageContainers.isEmpty
|
|
1789
1974
|
}
|
|
1790
1975
|
|
|
1791
|
-
///
|
|
1792
|
-
private var
|
|
1793
|
-
|
|
1976
|
+
/// Glassly adds retained vector scenes and animation to upstream's raster protocol.
|
|
1977
|
+
private var supportsCfwVectorScene: Bool {
|
|
1978
|
+
supportsCfwSceneRendering && cfwClassification?.isGlassly == true
|
|
1794
1979
|
}
|
|
1795
1980
|
|
|
1796
|
-
///
|
|
1797
|
-
private var
|
|
1798
|
-
cfwFeatures.contains("texcache12") && cfwFeatures.contains("texstr14")
|
|
1799
|
-
}
|
|
1981
|
+
/// Tagged commits report settings field 129 once the scene has settled.
|
|
1982
|
+
private var supportsCfwSceneSettle: Bool { supportsCfwVectorScene }
|
|
1800
1983
|
|
|
1801
|
-
///
|
|
1802
|
-
private var
|
|
1803
|
-
|
|
1804
|
-
|
|
1984
|
+
/// Residency on the glasses is unknown (a send failed): the next vector frame redefines what it shows.
|
|
1985
|
+
private var cfwSceneInvalid = true
|
|
1986
|
+
/// Object-cache session epoch shared by both lenses (GLASSLYCFW/38); 0 until
|
|
1987
|
+
/// the RESET that opens a cache generation has been acknowledged.
|
|
1988
|
+
private var cfwCacheEpoch: UInt16 = 0
|
|
1989
|
+
/// Request id stamped into cache messages whose encoder left it 0.
|
|
1990
|
+
private var cfwCacheRequest: UInt16 = 0
|
|
1991
|
+
/// Cache generation whose RESET (and warm-up tween) went out.
|
|
1992
|
+
private var cfwSceneWarmedGeneration = -1
|
|
1993
|
+
/// Frames re-encoded after a MISSING reply, bounded per frame.
|
|
1994
|
+
private var cfwMissingRetries = 0
|
|
1805
1995
|
|
|
1806
1996
|
/// Connection state
|
|
1807
1997
|
private var connectionState: String = ConnTypes.DISCONNECTED
|
|
@@ -1841,6 +2031,19 @@ final class G2: NSObject, SGCManager {
|
|
|
1841
2031
|
private var recoveryInFlight = false
|
|
1842
2032
|
private var lastRecoveryRebuildMs: Int64 = 0
|
|
1843
2033
|
private let RECOVERY_DEBOUNCE_MS: Int64 = 1500
|
|
2034
|
+
// A SHUTDOWN_PAGE we send is answered ~300 ms later by the firmware's
|
|
2035
|
+
// teardown trio (shutdown notify, systemExit, 08011A00). Creating the next
|
|
2036
|
+
// page before that lands makes the trio read as the NEW page dying:
|
|
2037
|
+
// pageCreated flips false, the toggle recovers, and the firmware sees
|
|
2038
|
+
// shutdown→create→create inside half a second — the rebuild storm the G2
|
|
2039
|
+
// answers by dropping the link (GLASSLYCFW/29 died on every dashboard
|
|
2040
|
+
// open this way). Resurrection and recovery wait out this window instead.
|
|
2041
|
+
private var lastPageShutdownSentMs: Int64 = 0
|
|
2042
|
+
private let PAGE_SHUTDOWN_SETTLE_MS: Int64 = 400
|
|
2043
|
+
private var pageShutdownSettleWakeArmed = false
|
|
2044
|
+
// Stock notifications panel hold: page rebuilds, fbguard lease and CFW frames stay
|
|
2045
|
+
// down while the panel opens so EvenHub sends cannot snatch the screen back.
|
|
2046
|
+
private var notificationsPanelHold = false
|
|
1844
2047
|
// A systemExit that arrives while the page acked a carrier fragment this
|
|
1845
2048
|
// recently is a phantom (the firmware emits exits even for healthy pages)
|
|
1846
2049
|
// and is dropped entirely — see the SysEvent handler.
|
|
@@ -1969,7 +2172,7 @@ final class G2: NSObject, SGCManager {
|
|
|
1969
2172
|
/// Current appId to associate EvenHub pages with (enables menu selection events)
|
|
1970
2173
|
/// Set to the first menu item's appId so glasses know our page belongs to the menu
|
|
1971
2174
|
private var activeMenuAppId: Int32?
|
|
1972
|
-
private var
|
|
2175
|
+
private var touchDebounce = G2TouchDebounce()
|
|
1973
2176
|
private var lastEvenHubResponseTimestamp: Int64?
|
|
1974
2177
|
private var lastMenuSelectTimestamp: Int64?
|
|
1975
2178
|
private var lastGestureCtrlTimestamp: Int64?
|
|
@@ -1981,11 +2184,7 @@ final class G2: NSObject, SGCManager {
|
|
|
1981
2184
|
let y: Int32
|
|
1982
2185
|
let width: Int32
|
|
1983
2186
|
let height: Int32
|
|
1984
|
-
var name: String {
|
|
1985
|
-
// The known-good CFW packet names its carrier "img00"; match it byte-for-byte
|
|
1986
|
-
// (the name appears in both the page create and every image send).
|
|
1987
|
-
id == G2.cfwSceneContainerID ? "img00" : "img-\(id)"
|
|
1988
|
-
}
|
|
2187
|
+
var name: String { "img-\(id)" }
|
|
1989
2188
|
|
|
1990
2189
|
var bmpData: Data
|
|
1991
2190
|
/// Set true when `bmpData` changes and the new pixels haven't been pushed to the glasses yet.
|
|
@@ -2096,39 +2295,34 @@ final class G2: NSObject, SGCManager {
|
|
|
2096
2295
|
|
|
2097
2296
|
// MARK: - BLE Sending
|
|
2098
2297
|
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
private var
|
|
2112
|
-
private var rightWriteQueue: [Data] = []
|
|
2298
|
+
/// Per-side FIFO writers. Stock and background CFW use 6 ms packet spacing.
|
|
2299
|
+
/// Foreground CFW drains available credits in bounded bursts. Background
|
|
2300
|
+
/// readiness can remain false for seconds, so polling it is insufficient.
|
|
2301
|
+
/// Firmware ACKs, timeouts and resync still determine CFW delivery success.
|
|
2302
|
+
private struct BleWrite {
|
|
2303
|
+
let data: Data
|
|
2304
|
+
let queuedAt: TimeInterval
|
|
2305
|
+
let trace: G2DisplayDiagnostics.Fragment?
|
|
2306
|
+
let isPending: (() -> Bool)?
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
private var leftWriteQueue: [BleWrite] = []
|
|
2310
|
+
private var rightWriteQueue: [BleWrite] = []
|
|
2113
2311
|
private var leftDraining = false
|
|
2114
2312
|
private var rightDraining = false
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
private let writePaceNanos: UInt64 = 6_000_000
|
|
2118
|
-
// Diagnostic: warn if a side's queue ever backs up (it shouldn't now — the drainer is always
|
|
2119
|
-
// making progress). Rate-limited. Prefixed "BGCAP:" so it's easy to grep/strip after validation.
|
|
2120
|
-
private var bgcapDepthLogAt: Double = 0
|
|
2121
|
-
|
|
2122
|
-
private func sendToGlasses(_ packets: [Data], left: Bool = false, right: Bool = true) {
|
|
2313
|
+
private let writePaceNanos = G2WritePacing.packetDelayNs
|
|
2314
|
+
private func sendToGlasses(_ packets: [Data], left: Bool = false, right: Bool = true, trace: G2DisplayDiagnostics.Fragment? = nil, isPending: (() -> Bool)? = nil) {
|
|
2123
2315
|
// OTA owns both links exclusively, including its short setup/teardown
|
|
2124
2316
|
// windows. Normal display/settings/navigation traffic must not interleave.
|
|
2125
2317
|
guard !firmwareFlashLifecycleActive, !firmwareFlasher.isActive else { return }
|
|
2318
|
+
let queuedAt = G2DisplayDiagnostics.now
|
|
2319
|
+
let writes = packets.map { BleWrite(data: $0, queuedAt: queuedAt, trace: trace, isPending: isPending) }
|
|
2126
2320
|
if right {
|
|
2127
|
-
rightWriteQueue.append(contentsOf:
|
|
2321
|
+
rightWriteQueue.append(contentsOf: writes)
|
|
2128
2322
|
startDrain(right: true)
|
|
2129
2323
|
}
|
|
2130
2324
|
if left {
|
|
2131
|
-
leftWriteQueue.append(contentsOf:
|
|
2325
|
+
leftWriteQueue.append(contentsOf: writes)
|
|
2132
2326
|
startDrain(right: false)
|
|
2133
2327
|
}
|
|
2134
2328
|
}
|
|
@@ -2152,10 +2346,9 @@ final class G2: NSObject, SGCManager {
|
|
|
2152
2346
|
}
|
|
2153
2347
|
}
|
|
2154
2348
|
|
|
2155
|
-
/// Drain
|
|
2156
|
-
/// sleep. No `canSend` gate (see note above). Runs until the queue is empty, then clears the
|
|
2157
|
-
/// per-side flag so the next enqueue restarts it.
|
|
2349
|
+
/// Drain until empty, yielding after each CFW burst so UI/ACK work can run.
|
|
2158
2350
|
private func drainLoop(right: Bool) async {
|
|
2351
|
+
var burstCount = 0
|
|
2159
2352
|
while true {
|
|
2160
2353
|
guard let peripheral = right ? rightPeripheral : leftPeripheral,
|
|
2161
2354
|
let char = right ? rightWriteChar : leftWriteChar
|
|
@@ -2177,26 +2370,64 @@ final class G2: NSObject, SGCManager {
|
|
|
2177
2370
|
}
|
|
2178
2371
|
return
|
|
2179
2372
|
}
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2373
|
+
// ACK timeout/cancellation can happen before CoreBluetooth grants
|
|
2374
|
+
// credits. Drop that stream before checking credits, so its stale
|
|
2375
|
+
// packets cannot replay ahead of the recovery frame.
|
|
2376
|
+
let next = right ? rightWriteQueue[0] : leftWriteQueue[0]
|
|
2377
|
+
if let isPending = next.isPending, !isPending() {
|
|
2378
|
+
if right {
|
|
2379
|
+
rightWriteQueue.removeFirst()
|
|
2380
|
+
} else {
|
|
2381
|
+
leftWriteQueue.removeFirst()
|
|
2185
2382
|
}
|
|
2383
|
+
displayDiagnostics.count("txExpiredPackets")
|
|
2384
|
+
continue
|
|
2385
|
+
}
|
|
2386
|
+
// Re-evaluate on every iteration, including after a credit wait:
|
|
2387
|
+
// backgrounding must release an already-blocked foreground drain.
|
|
2388
|
+
let cfwEnabled = cfwCapabilityString != nil
|
|
2389
|
+
let pacing = G2WritePacing(cfwEnabled: cfwEnabled, applicationState: UIApplication.shared.applicationState)
|
|
2390
|
+
let peripheralReady = peripheral.canSendWriteWithoutResponse
|
|
2391
|
+
if !pacing.canWrite(peripheralReady: peripheralReady) {
|
|
2392
|
+
burstCount = 0
|
|
2393
|
+
let stalledAt = G2DisplayDiagnostics.now
|
|
2394
|
+
try? await Task.sleep(nanoseconds: writePaceNanos)
|
|
2395
|
+
displayDiagnostics.timing(right ? "creditWaitMsR" : "creditWaitMsL", (G2DisplayDiagnostics.now - stalledAt) * 1000)
|
|
2396
|
+
continue
|
|
2186
2397
|
}
|
|
2187
2398
|
let packet = right ? rightWriteQueue.removeFirst() : leftWriteQueue.removeFirst()
|
|
2188
|
-
|
|
2189
|
-
|
|
2399
|
+
displayDiagnostics.timing("writeQueueMs", (G2DisplayDiagnostics.now - packet.queuedAt) * 1000)
|
|
2400
|
+
peripheral.writeValue(packet.data, for: char, type: .withoutResponse)
|
|
2401
|
+
packet.trace?.wrote()
|
|
2402
|
+
if cfwEnabled, pacing == .timed {
|
|
2403
|
+
displayDiagnostics.count("backgroundPacedPackets")
|
|
2404
|
+
if !peripheralReady {
|
|
2405
|
+
displayDiagnostics.count("backgroundWritesWithoutCredit")
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
displayDiagnostics.count("txBytes", packet.data.count)
|
|
2409
|
+
displayDiagnostics.count("txPackets")
|
|
2410
|
+
displayDiagnostics.count(right ? "txBytesR" : "txBytesL", packet.data.count)
|
|
2411
|
+
if pacing == .creditBurst {
|
|
2412
|
+
burstCount += 1
|
|
2413
|
+
if burstCount == 4 {
|
|
2414
|
+
burstCount = 0
|
|
2415
|
+
await Task.yield()
|
|
2416
|
+
}
|
|
2417
|
+
} else {
|
|
2418
|
+
burstCount = 0
|
|
2419
|
+
try? await Task.sleep(nanoseconds: writePaceNanos)
|
|
2420
|
+
}
|
|
2190
2421
|
}
|
|
2191
2422
|
}
|
|
2192
2423
|
|
|
2193
|
-
private func sendEvenHubCommand(_ payload: Data, left: Bool = false, right: Bool = true) {
|
|
2424
|
+
private func sendEvenHubCommand(_ payload: Data, left: Bool = false, right: Bool = true, trace: G2DisplayDiagnostics.Fragment? = nil) {
|
|
2194
2425
|
let packets = sendManager.buildPackets(
|
|
2195
2426
|
serviceId: ServiceID.evenHub.rawValue,
|
|
2196
2427
|
payload: payload,
|
|
2197
2428
|
reserveFlag: true
|
|
2198
2429
|
)
|
|
2199
|
-
sendToGlasses(packets, left: left, right: right)
|
|
2430
|
+
sendToGlasses(packets, left: left, right: right, trace: trace)
|
|
2200
2431
|
}
|
|
2201
2432
|
|
|
2202
2433
|
private func sendDevSettingsCommand(_ payload: Data, left: Bool = false, right: Bool = true) {
|
|
@@ -2458,11 +2689,10 @@ final class G2: NSObject, SGCManager {
|
|
|
2458
2689
|
DeviceStore.shared.get("bluetooth", "calendar_events") as? [[String: Any]] ?? []
|
|
2459
2690
|
sendCalendarEvents(calendarEvents)
|
|
2460
2691
|
|
|
2461
|
-
// push
|
|
2462
|
-
//
|
|
2692
|
+
// re-push the stored notification config and surface the current ANCS
|
|
2693
|
+
// authorization state to the UI
|
|
2463
2694
|
publishAncsAuthorization()
|
|
2464
|
-
|
|
2465
|
-
sendNotificationFilter()
|
|
2695
|
+
reapplyNotificationConfig()
|
|
2466
2696
|
}
|
|
2467
2697
|
|
|
2468
2698
|
// MARK: - Heartbeats
|
|
@@ -2500,6 +2730,7 @@ final class G2: NSObject, SGCManager {
|
|
|
2500
2730
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
2501
2731
|
guard !Task.isCancelled else { break }
|
|
2502
2732
|
self?.displayDirtySignal?.yield()
|
|
2733
|
+
self?.logDisplayDiagnostics()
|
|
2503
2734
|
}
|
|
2504
2735
|
}
|
|
2505
2736
|
displayReconcileTask = Task { [weak self] in
|
|
@@ -2530,6 +2761,32 @@ final class G2: NSObject, SGCManager {
|
|
|
2530
2761
|
displayDirtySignal?.yield()
|
|
2531
2762
|
}
|
|
2532
2763
|
|
|
2764
|
+
private func logDisplayDiagnostics() {
|
|
2765
|
+
guard displayDiagnostics.isSampleDue else { return }
|
|
2766
|
+
if let line = displayDiagnostics.session([
|
|
2767
|
+
"cfw": cfwCapabilityString ?? "stock",
|
|
2768
|
+
"fpsLimit": DeviceStore.shared.get("bluetooth", "display_max_fps") as? Int ?? 8,
|
|
2769
|
+
"mtuL": leftPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 0,
|
|
2770
|
+
"mtuR": rightPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 0,
|
|
2771
|
+
]) {
|
|
2772
|
+
Bridge.log(line)
|
|
2773
|
+
}
|
|
2774
|
+
let busy = !leftWriteQueue.isEmpty || !rightWriteQueue.isEmpty || !cfwPendingPayloads.isEmpty || cfwPendingFrame != nil
|
|
2775
|
+
if let line = displayDiagnostics.sample(context: [
|
|
2776
|
+
"app": cfwLastFrame?.appId ?? "", "seq": cfwLastFrame?.seq ?? 0,
|
|
2777
|
+
"page": pageCreated, "pageGeneration": pageGeneration,
|
|
2778
|
+
"qL": leftWriteQueue.count, "qR": rightWriteQueue.count,
|
|
2779
|
+
// Present only while that side has no write credit.
|
|
2780
|
+
"blockedL": leftPeripheral?.canSendWriteWithoutResponse == false,
|
|
2781
|
+
"blockedR": rightPeripheral?.canSendWriteWithoutResponse == false,
|
|
2782
|
+
"payloadQueue": cfwPendingPayloads.count, "pendingFrame": cfwPendingFrame != nil,
|
|
2783
|
+
"mic": evenHubMicIntent,
|
|
2784
|
+
"background": UIApplication.shared.applicationState != .active,
|
|
2785
|
+
], busy: busy) {
|
|
2786
|
+
Bridge.log(line)
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2533
2790
|
private func sendEvenHubHeartbeat() {
|
|
2534
2791
|
let isFullyBooted = DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
|
|
2535
2792
|
guard isFullyBooted else { return }
|
|
@@ -2569,24 +2826,67 @@ final class G2: NSObject, SGCManager {
|
|
|
2569
2826
|
setDashboardMenu(menuItems)
|
|
2570
2827
|
}
|
|
2571
2828
|
|
|
2572
|
-
// MARK: - Notifications (
|
|
2829
|
+
// MARK: - Notifications (SGCManager)
|
|
2573
2830
|
|
|
2574
|
-
///
|
|
2575
|
-
///
|
|
2576
|
-
|
|
2831
|
+
/// The config is stored whether or not it can be pushed now; device-ready
|
|
2832
|
+
/// re-applies it against the firmware that actually came up.
|
|
2833
|
+
func setGlassesNotificationConfig(_ config: GlassesNotificationConfig) throws {
|
|
2834
|
+
notificationConfig = config
|
|
2835
|
+
let ready = DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
|
|
2836
|
+
guard ready else {
|
|
2837
|
+
notificationPhase = "stale"
|
|
2838
|
+
publishNotificationStatus()
|
|
2839
|
+
return
|
|
2840
|
+
}
|
|
2841
|
+
applyNotificationConfig(config)
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
func glassesNotificationStatus() -> GlassesNotificationStatus {
|
|
2845
|
+
GlassesNotificationStatus(
|
|
2846
|
+
supported: true,
|
|
2847
|
+
feed: "ancs",
|
|
2848
|
+
access: ancsAccess,
|
|
2849
|
+
phase: notificationPhase,
|
|
2850
|
+
config: notificationConfig,
|
|
2851
|
+
error: nil
|
|
2852
|
+
)
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
/// The glasses run their own ANCS client, so content never crosses the phone link.
|
|
2856
|
+
func uploadPhoneNotification(_: PhoneNotificationUpload) throws {
|
|
2857
|
+
throw GlassesNotificationError.unsupported
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
/// Whether the glasses may read the phone's notification stream. No
|
|
2861
|
+
/// peripheral yet means the consent state is simply not known.
|
|
2862
|
+
private var ancsAccess: String {
|
|
2863
|
+
guard leftPeripheral != nil || rightPeripheral != nil else { return "unknown" }
|
|
2864
|
+
let authorized = (leftPeripheral?.ancsAuthorized ?? false)
|
|
2865
|
+
|| (rightPeripheral?.ancsAuthorized ?? false)
|
|
2866
|
+
return authorized ? "granted" : "denied"
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
private func reapplyNotificationConfig() {
|
|
2870
|
+
guard let config = notificationConfig else { return }
|
|
2871
|
+
applyNotificationConfig(config)
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
/// Full state is retransmitted on every apply — that matches the stock app
|
|
2577
2875
|
/// and the glasses echo it back as the ack.
|
|
2578
|
-
func
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2876
|
+
private func applyNotificationConfig(_ config: GlassesNotificationConfig) {
|
|
2877
|
+
if config == lastAppliedNotificationConfig {
|
|
2878
|
+
return
|
|
2879
|
+
}
|
|
2880
|
+
lastAppliedNotificationConfig = config
|
|
2881
|
+
notificationPhase = "applying"
|
|
2882
|
+
publishNotificationStatus()
|
|
2584
2883
|
|
|
2884
|
+
let dispTime = G2NotificationTuning.snapPopupSeconds(config.popupSeconds)
|
|
2585
2885
|
var ctrlW = ProtobufWriter()
|
|
2586
|
-
ctrlW.writeInt32Field(1, enabled ? 1 : 0) // notifEnable
|
|
2587
|
-
ctrlW.writeInt32Field(2,
|
|
2588
|
-
ctrlW.writeInt32Field(3,
|
|
2589
|
-
ctrlW.writeInt32Field(5,
|
|
2886
|
+
ctrlW.writeInt32Field(1, config.enabled ? 1 : 0) // notifEnable
|
|
2887
|
+
ctrlW.writeInt32Field(2, config.popups ? 1 : 0) // autoDispEnable
|
|
2888
|
+
ctrlW.writeInt32Field(3, dispTime) // dispTime (0/5/7/10 s)
|
|
2889
|
+
ctrlW.writeInt32Field(5, config.holdDuringApps ? 1 : 0) // avoidDisturbEnable
|
|
2590
2890
|
|
|
2591
2891
|
var pkg = ProtobufWriter()
|
|
2592
2892
|
pkg.writeInt32Field(1, NotificationCmd.ctrl.rawValue)
|
|
@@ -2594,33 +2894,45 @@ final class G2: NSObject, SGCManager {
|
|
|
2594
2894
|
pkg.writeMessageField(3, ctrlW.data)
|
|
2595
2895
|
sendNotificationCommand(pkg.data)
|
|
2596
2896
|
Bridge.log(
|
|
2597
|
-
"G2: Sent notification ctrl enabled=\(enabled)
|
|
2897
|
+
"G2: Sent notification ctrl enabled=\(config.enabled) popups=\(config.popups) dispTime=\(dispTime) hold=\(config.holdDuringApps)"
|
|
2598
2898
|
)
|
|
2599
|
-
|
|
2899
|
+
syncAncsRelay()
|
|
2600
2900
|
|
|
2601
|
-
/// Push the ANCS app allowlist from the DeviceStore `notification_filter`
|
|
2602
|
-
/// dict ({enabled, apps: [{id, name}]}). The list travels as the
|
|
2603
|
-
/// `user/notify_whitelist.json` file (the protobuf whitelist message only
|
|
2604
|
-
/// carries the enable toggle); the WHITELIST_CTRL toggle goes after the
|
|
2605
|
-
/// file so the firmware reloads with the new list in place.
|
|
2606
|
-
func sendNotificationFilter() {
|
|
2607
|
-
let filter = DeviceStore.shared.get("bluetooth", "notification_filter") as? [String: Any] ?? [:]
|
|
2608
|
-
let enabled = filter["enabled"] as? Bool ?? false
|
|
2609
|
-
let apps = filter["apps"] as? [[String: Any]] ?? []
|
|
2610
2901
|
Task { [weak self] in
|
|
2611
|
-
await self?.pushNotificationFilter(
|
|
2902
|
+
let landed = await self?.pushNotificationFilter(for: config) ?? false
|
|
2903
|
+
await MainActor.run {
|
|
2904
|
+
guard let self else { return }
|
|
2905
|
+
// A push that never reached the glasses must not dedupe the retry.
|
|
2906
|
+
if !landed, self.lastAppliedNotificationConfig == config {
|
|
2907
|
+
self.lastAppliedNotificationConfig = nil
|
|
2908
|
+
}
|
|
2909
|
+
self.notificationPhase = "applied"
|
|
2910
|
+
self.publishNotificationStatus()
|
|
2911
|
+
}
|
|
2612
2912
|
}
|
|
2613
2913
|
}
|
|
2614
2914
|
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2915
|
+
/// The allowlist travels as the `user/notify_whitelist.json` file (the
|
|
2916
|
+
/// protobuf whitelist message only carries the enable toggle); the
|
|
2917
|
+
/// WHITELIST_CTRL toggle goes after the file so the firmware reloads with
|
|
2918
|
+
/// the new list in place.
|
|
2919
|
+
/// Returns whether the allowlist file reached the glasses.
|
|
2920
|
+
private func pushNotificationFilter(for config: GlassesNotificationConfig) async -> Bool {
|
|
2921
|
+
let enabled = G2NotificationTuning.filterEnabled(for: config)
|
|
2922
|
+
let entries = config.allowedApps.map { (id: $0.id, name: $0.name) }
|
|
2620
2923
|
// The file is written even when filtering is off so a later enable
|
|
2621
2924
|
// toggles against the user's current list, not a stale one.
|
|
2622
2925
|
let json = G2NotifyFile.whitelistJson(filterEnabled: enabled, apps: entries)
|
|
2623
|
-
await sendNotifyFile(
|
|
2926
|
+
let pushed = await sendNotifyFile(
|
|
2927
|
+
named: G2NotifyFile.whitelistFilename, content: json, fileType: .whitelist
|
|
2928
|
+
)
|
|
2929
|
+
// Enabling against a list that never landed (flash in progress, file characteristic not
|
|
2930
|
+
// yet discovered) would filter on whatever is on flash — absent means every notification
|
|
2931
|
+
// is dropped. Leave the firmware toggle alone; device-ready re-pushes the whole config.
|
|
2932
|
+
if enabled, !pushed {
|
|
2933
|
+
Bridge.log("G2: notification filter enable deferred — whitelist file not pushed")
|
|
2934
|
+
return false
|
|
2935
|
+
}
|
|
2624
2936
|
|
|
2625
2937
|
var wlW = ProtobufWriter()
|
|
2626
2938
|
wlW.writeInt32Field(1, enabled ? 0 : 1) // whitelistDisable (disabled = fail-open)
|
|
@@ -2630,20 +2942,37 @@ final class G2: NSObject, SGCManager {
|
|
|
2630
2942
|
pkg.writeMessageField(6, wlW.data)
|
|
2631
2943
|
sendNotificationCommand(pkg.data)
|
|
2632
2944
|
Bridge.log("G2: Sent notification filter enabled=\(enabled) apps=\(entries.count)")
|
|
2945
|
+
return pushed
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
private func publishNotificationStatus() {
|
|
2949
|
+
Bridge.sendTypedMessage(
|
|
2950
|
+
"glasses_notification_status", body: glassesNotificationStatus().dictionary
|
|
2951
|
+
)
|
|
2633
2952
|
}
|
|
2634
2953
|
|
|
2635
2954
|
/// File-transfer push (services 0xC4/0xC5 on the dedicated file
|
|
2636
2955
|
/// characteristic): FILE_CHECK header → START → DATA → END, per arm. The
|
|
2637
2956
|
/// glasses cache by checksum but still expect the data packets either way,
|
|
2638
2957
|
/// so no branching on the CACHE_HIT/MISS response is needed.
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2958
|
+
/// Returns false when the file was skipped for any connected arm (flash in
|
|
2959
|
+
/// progress, file characteristic not discovered) so callers don't act on it.
|
|
2960
|
+
@discardableResult
|
|
2961
|
+
private func sendNotifyFile(
|
|
2962
|
+
named filename: String, content: Data, fileType: G2NotifyFile.FileType
|
|
2963
|
+
) async -> Bool {
|
|
2964
|
+
guard !firmwareFlashLifecycleActive, !firmwareFlasher.isActive else { return false }
|
|
2965
|
+
let header = G2NotifyFile.fileCheckHeader(
|
|
2966
|
+
filename: filename, content: content, fileType: fileType
|
|
2967
|
+
)
|
|
2642
2968
|
var pushedSides: [String] = []
|
|
2969
|
+
var skippedArm = false
|
|
2643
2970
|
for right in [true, false] {
|
|
2644
|
-
guard let peripheral = right ? rightPeripheral : leftPeripheral
|
|
2645
|
-
|
|
2646
|
-
|
|
2971
|
+
guard let peripheral = right ? rightPeripheral : leftPeripheral else { continue }
|
|
2972
|
+
guard let char = right ? rightFileWriteChar : leftFileWriteChar else {
|
|
2973
|
+
skippedArm = true
|
|
2974
|
+
continue
|
|
2975
|
+
}
|
|
2647
2976
|
let writeType: CBCharacteristicWriteType =
|
|
2648
2977
|
char.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse
|
|
2649
2978
|
|
|
@@ -2667,6 +2996,7 @@ final class G2: NSObject, SGCManager {
|
|
|
2667
2996
|
Bridge.log(
|
|
2668
2997
|
"G2: Pushed \(filename) (\(content.count) bytes) to [\(pushedSides.joined(separator: ","))]"
|
|
2669
2998
|
)
|
|
2999
|
+
return !pushedSides.isEmpty && !skippedArm
|
|
2670
3000
|
}
|
|
2671
3001
|
|
|
2672
3002
|
// MARK: - SGCManager: Display Control
|
|
@@ -2709,9 +3039,7 @@ final class G2: NSObject, SGCManager {
|
|
|
2709
3039
|
// Bridge.log("G2: sendTextWall(\(text.prefix(50))...)")
|
|
2710
3040
|
|
|
2711
3041
|
// ignore events while the ER dashboard is open:
|
|
2712
|
-
|
|
2713
|
-
DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
|
|
2714
|
-
if useNativeDashboard && dashboardShowing > 0 {
|
|
3042
|
+
if dashboardShowing > 0 {
|
|
2715
3043
|
return
|
|
2716
3044
|
}
|
|
2717
3045
|
|
|
@@ -2735,7 +3063,7 @@ final class G2: NSObject, SGCManager {
|
|
|
2735
3063
|
if supportsCfwSceneRendering {
|
|
2736
3064
|
if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
2737
3065
|
// A blank wall is the idle state: keep the carrier alive for touch and mic.
|
|
2738
|
-
let carrierWasConfigured =
|
|
3066
|
+
let carrierWasConfigured = installCfwPageIfNeeded()
|
|
2739
3067
|
cfwClear()
|
|
2740
3068
|
if !carrierWasConfigured, pageCreated {
|
|
2741
3069
|
// Carrier just replaced live stock containers (see clearDisplay).
|
|
@@ -2744,7 +3072,7 @@ final class G2: NSObject, SGCManager {
|
|
|
2744
3072
|
return
|
|
2745
3073
|
}
|
|
2746
3074
|
let frame = SceneFrame(
|
|
2747
|
-
appId: "legacy-text", epoch: 0, replay:
|
|
3075
|
+
appId: "legacy-text", epoch: 0, replay: false,
|
|
2748
3076
|
elements: [
|
|
2749
3077
|
SceneElement(
|
|
2750
3078
|
id: "legacy-text", type: "text", x: rx, y: ry, w: rw, h: rh,
|
|
@@ -2784,9 +3112,6 @@ final class G2: NSObject, SGCManager {
|
|
|
2784
3112
|
)
|
|
2785
3113
|
return
|
|
2786
3114
|
}
|
|
2787
|
-
Bridge.log(
|
|
2788
|
-
"G2: sendText() - reusing container \(container.id) for rect \(rx),\(ry) \(rw)x\(rh)"
|
|
2789
|
-
)
|
|
2790
3115
|
return
|
|
2791
3116
|
}
|
|
2792
3117
|
|
|
@@ -2807,7 +3132,6 @@ final class G2: NSObject, SGCManager {
|
|
|
2807
3132
|
}
|
|
2808
3133
|
|
|
2809
3134
|
func sendDoubleTextWall(_ top: String, _ bottom: String) async {
|
|
2810
|
-
Bridge.log("G2: sendDoubleTextWall() - top: \(top), bottom: \(bottom)")
|
|
2811
3135
|
// G2 doesn't have native double text wall, combine them
|
|
2812
3136
|
let combined = top + "\n\n" + bottom
|
|
2813
3137
|
await sendTextWall(combined)
|
|
@@ -2913,6 +3237,8 @@ final class G2: NSObject, SGCManager {
|
|
|
2913
3237
|
Bridge.log("G2: applySceneFrame — structural changes, ONE coalesced rebuild for the whole frame")
|
|
2914
3238
|
await coalescedPageRebuild()
|
|
2915
3239
|
}
|
|
3240
|
+
// Stock firmware: text sends are unacked; the awaited verbs above are the best signal there is.
|
|
3241
|
+
frame.reportPresented(true)
|
|
2916
3242
|
}
|
|
2917
3243
|
|
|
2918
3244
|
/// Render `frame` phone-side and queue it for the CFW carrier. Installs the
|
|
@@ -2920,15 +3246,21 @@ final class G2: NSObject, SGCManager {
|
|
|
2920
3246
|
/// rebuilds the page; `rebuildState` re-seeds and re-queues from
|
|
2921
3247
|
/// `cfwLastFrame` once the new page is up.
|
|
2922
3248
|
private func applyCfwScene(_ frame: SceneFrame) async {
|
|
2923
|
-
|
|
3249
|
+
displayDiagnostics.count("framesIn")
|
|
3250
|
+
if cfwPendingFrame != nil {
|
|
3251
|
+
displayDiagnostics.count("coalesced")
|
|
3252
|
+
}
|
|
3253
|
+
// Held: retain the frame so the post-hold rebuild re-sends it, but paint nothing now.
|
|
3254
|
+
if notificationsPanelHold {
|
|
3255
|
+
cfwLastFrame = frame
|
|
3256
|
+
return
|
|
3257
|
+
}
|
|
3258
|
+
let carrierWasConfigured = installCfwPageIfNeeded()
|
|
2924
3259
|
cfwLastFrame = frame
|
|
2925
3260
|
// Lease before the frame: a fresh FB_ACQUIRE resets preservation, and
|
|
2926
3261
|
// the frame queued below is what re-arms it.
|
|
2927
3262
|
startFbLease()
|
|
2928
|
-
|
|
2929
|
-
Bridge.log("G2: CFW scene encode failed; retaining the previous frame")
|
|
2930
|
-
return
|
|
2931
|
-
}
|
|
3263
|
+
cfwPendingFrame = frame
|
|
2932
3264
|
cfwSceneActive = true
|
|
2933
3265
|
signalDisplayDirty()
|
|
2934
3266
|
|
|
@@ -2938,17 +3270,13 @@ final class G2: NSObject, SGCManager {
|
|
|
2938
3270
|
}
|
|
2939
3271
|
}
|
|
2940
3272
|
|
|
2941
|
-
///
|
|
2942
|
-
|
|
2943
|
-
/// an x offset — required whenever the frame uses mode-15 text draws,
|
|
2944
|
-
/// which have no per-lens variant and would otherwise land 16 px apart
|
|
2945
|
-
/// from the window content (double vision). Height still follows the user.
|
|
2946
|
-
private func cfwBoxes(for frame: SceneFrame, pinDepth: Bool) -> G2CfwRenderer.LensBoxes {
|
|
3273
|
+
/// Apply the global placement to every scene; miniapps only supply relative element depth.
|
|
3274
|
+
private func cfwBoxes(for frame: SceneFrame) -> G2CfwRenderer.LensBoxes {
|
|
2947
3275
|
let height = DeviceStore.shared.get("bluetooth", "dashboard_height") as? Int ?? 4
|
|
2948
|
-
let depth =
|
|
3276
|
+
let depth = DeviceStore.shared.get("bluetooth", "dashboard_depth") as? Int ?? 2
|
|
2949
3277
|
return G2CfwRenderer.lensBoxes(
|
|
2950
3278
|
canvasWidth: frame.canvasWidth, canvasHeight: frame.canvasHeight,
|
|
2951
|
-
heightLevel: height, depthLevel: depth
|
|
3279
|
+
heightLevel: height, depthLevel: G2CfwRenderer.depthLevel(forSetting: depth)
|
|
2952
3280
|
)
|
|
2953
3281
|
}
|
|
2954
3282
|
|
|
@@ -2961,15 +3289,21 @@ final class G2: NSObject, SGCManager {
|
|
|
2961
3289
|
}
|
|
2962
3290
|
|
|
2963
3291
|
/// Forget the on-glass shadow: the next frame is preceded by a black keyframe.
|
|
2964
|
-
|
|
3292
|
+
/// `keepCache` keeps the object-cache session; the caller forgets the uploads it drops.
|
|
3293
|
+
private func cfwResetShadow(keepCache: Bool = false) {
|
|
3294
|
+
displayDiagnostics.count("resync")
|
|
3295
|
+
cfwPendingFrame = nil
|
|
2965
3296
|
cfwPendingPayloads.removeAll()
|
|
3297
|
+
cfwSceneInvalid = true
|
|
2966
3298
|
cfwShadowSeeded = false
|
|
2967
3299
|
cfwLastBoxes = nil
|
|
2968
3300
|
cfwPrevRaster = nil
|
|
2969
3301
|
cfwLastTextDraws = []
|
|
2970
3302
|
cfwDeltasSinceFull = 0
|
|
2971
|
-
|
|
3303
|
+
guard !keepCache else { return }
|
|
3304
|
+
// Dropped queued uploads leave residency unknown; make the cache model re-learn.
|
|
2972
3305
|
cfwCacheGeneration += 1
|
|
3306
|
+
cfwCacheEpoch = 0
|
|
2973
3307
|
}
|
|
2974
3308
|
|
|
2975
3309
|
/// The CFW context is PER LENS — each arm runs its own firmware instance
|
|
@@ -2986,14 +3320,17 @@ final class G2: NSObject, SGCManager {
|
|
|
2986
3320
|
sendToGlasses(packets, left: true, right: true)
|
|
2987
3321
|
}
|
|
2988
3322
|
|
|
2989
|
-
/// Acquire the fbguard lease and keep renewing it
|
|
2990
|
-
///
|
|
2991
|
-
/// preservation (the next presented direct
|
|
2992
|
-
/// BEFORE the frame is queued.
|
|
2993
|
-
/// is
|
|
2994
|
-
///
|
|
3323
|
+
/// Acquire the fbguard lease and keep renewing it for as long as our CFW
|
|
3324
|
+
/// page is up, as upstream Faceclaw does for its whole session. A fresh
|
|
3325
|
+
/// acquire resets the firmware's preservation (the next presented direct
|
|
3326
|
+
/// frame re-arms it), so this runs BEFORE the frame is queued. The lease
|
|
3327
|
+
/// is deliberately NOT tied to `cfwSceneActive`: a blank panel (HIDE)
|
|
3328
|
+
/// keeps the on-glass object cache, and releasing the lease would free it,
|
|
3329
|
+
/// turning the next dashboard open into a cold RESET + full upload. Stock
|
|
3330
|
+
/// surfaces (dashboard, nav, notifications) release explicitly before they
|
|
3331
|
+
/// take the panel, so nothing is frozen under a preserved direct frame.
|
|
2995
3332
|
private func startFbLease() {
|
|
2996
|
-
guard fbLeaseTask == nil else { return }
|
|
3333
|
+
guard fbLeaseTask == nil, !notificationsPanelHold else { return }
|
|
2997
3334
|
sendFaceclawFbControl(acquire: true)
|
|
2998
3335
|
fbLeaseTask = Task { @MainActor [weak self] in
|
|
2999
3336
|
while !Task.isCancelled {
|
|
@@ -3002,7 +3339,7 @@ final class G2: NSObject, SGCManager {
|
|
|
3002
3339
|
// supportsCfwSceneRendering: the cfw_scene_renderer escape
|
|
3003
3340
|
// hatch may have been flipped off mid-session — without this
|
|
3004
3341
|
// check the lease renews forever while frames go stock.
|
|
3005
|
-
if self.
|
|
3342
|
+
if self.pageCreated, self.dashboardShowing == 0, !self.navigationModeActive,
|
|
3006
3343
|
self.supportsCfwSceneRendering
|
|
3007
3344
|
{
|
|
3008
3345
|
self.sendFaceclawFbControl(acquire: true)
|
|
@@ -3020,8 +3357,9 @@ final class G2: NSObject, SGCManager {
|
|
|
3020
3357
|
if sendRelease {
|
|
3021
3358
|
sendFaceclawFbControl(acquire: false)
|
|
3022
3359
|
}
|
|
3023
|
-
// Lease end frees the on-glass
|
|
3360
|
+
// Lease end frees the on-glass object cache; the model must re-learn.
|
|
3024
3361
|
cfwCacheGeneration += 1
|
|
3362
|
+
cfwCacheEpoch = 0
|
|
3025
3363
|
}
|
|
3026
3364
|
|
|
3027
3365
|
/// Enable or disable Glassly ownership of the stock wake paths. While the
|
|
@@ -3080,6 +3418,276 @@ final class G2: NSObject, SGCManager {
|
|
|
3080
3418
|
}
|
|
3081
3419
|
}
|
|
3082
3420
|
|
|
3421
|
+
// MARK: - BLE link speed (CFW revision 26)
|
|
3422
|
+
|
|
3423
|
+
/// Revision 26 lets the phone choose the connection profile: stock (the
|
|
3424
|
+
/// default: 15-30 ms, then the one-minute slow-mode timer) or fast (7.5 ms,
|
|
3425
|
+
/// latency 0, slow mode suppressed). Each lens has its own link, so the
|
|
3426
|
+
/// control (G2BleLink, field 127) goes to both arms. The flag lives in lens
|
|
3427
|
+
/// RAM and boots stock, so a wanted FAST is re-sent on every CFW detection.
|
|
3428
|
+
private var supportsBleLinkControl: Bool { supportsCfwTransport }
|
|
3429
|
+
|
|
3430
|
+
/// The debug setting: caller intent, read at send time.
|
|
3431
|
+
private var fastBleWanted: Bool {
|
|
3432
|
+
DeviceStore.shared.get("bluetooth", "cfw_fast_ble") as? Bool ?? false
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
/// Last field-128 status seen, so only changes are logged.
|
|
3436
|
+
private var lastBleLinkStatus: G2BleLink.Status?
|
|
3437
|
+
|
|
3438
|
+
private func sendBleLinkControl(_ op: G2BleLink.Op) {
|
|
3439
|
+
let packets = sendManager.buildPackets(
|
|
3440
|
+
serviceId: ServiceID.g2Setting.rawValue,
|
|
3441
|
+
payload: G2BleLink.controlPacket(op: op),
|
|
3442
|
+
reserveFlag: true
|
|
3443
|
+
)
|
|
3444
|
+
sendToGlasses(packets, left: true, right: true)
|
|
3445
|
+
Bridge.log("G2: BLE link control -> \(op)")
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
/// Push the setting after CFW detection. Only a wanted FAST is sent: the
|
|
3449
|
+
/// firmware boots on the stock profile, so STOCK would be a no-op.
|
|
3450
|
+
private func syncBleLink() {
|
|
3451
|
+
guard supportsBleLinkControl, fastBleWanted else { return }
|
|
3452
|
+
sendBleLinkControl(.fast)
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
/// Live cfw_fast_ble switch. A stock device, an older CFW, or a
|
|
3456
|
+
/// pre-classification connect reads the setting later on detection.
|
|
3457
|
+
func cfwFastBleToggled(_ enabled: Bool) async {
|
|
3458
|
+
guard supportsBleLinkControl else {
|
|
3459
|
+
Bridge.log("G2: cfw_fast_ble \(enabled ? "ON" : "OFF") — no link control on this firmware; applies on next CFW detection")
|
|
3460
|
+
return
|
|
3461
|
+
}
|
|
3462
|
+
sendBleLinkControl(enabled ? .fast : .stock)
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
// MARK: - ANCS relay (CFW revision 24)
|
|
3466
|
+
|
|
3467
|
+
/// The stock firmware reads the phone's notifications over ANCS on the
|
|
3468
|
+
/// right lens but only reports the source app; revision 24 relays the
|
|
3469
|
+
/// whole notification as sid-0x09 field-125 records (G2AncsRelay). Only the
|
|
3470
|
+
/// right lens subscribes to ANCS and only it can notify the phone, so the
|
|
3471
|
+
/// control packets go to that arm alone.
|
|
3472
|
+
private var supportsAncsRelay: Bool {
|
|
3473
|
+
cfwClassification?.isGlassly == true
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
/// The user's notification master toggle, the same one pushed on-glass with
|
|
3477
|
+
/// the config: with it off the relay stays disabled so no notification
|
|
3478
|
+
/// traffic crosses the link.
|
|
3479
|
+
private var ancsRelayWanted: Bool {
|
|
3480
|
+
notificationConfig?.enabled ?? true
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3483
|
+
private func sendAncsControl(_ payload: Data) {
|
|
3484
|
+
let packets = sendManager.buildPackets(
|
|
3485
|
+
serviceId: ServiceID.g2Setting.rawValue,
|
|
3486
|
+
payload: payload,
|
|
3487
|
+
reserveFlag: true
|
|
3488
|
+
)
|
|
3489
|
+
sendToGlasses(packets, left: false, right: true)
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3492
|
+
// MARK: - BLE keyboard bridge (CFW revision 39)
|
|
3493
|
+
|
|
3494
|
+
/// Keyboard controls are settings requests for the right lens alone: only
|
|
3495
|
+
/// it hosts the keyboard link and only it emits field-131 events.
|
|
3496
|
+
private func sendKeyboardControl(_ payload: Data) {
|
|
3497
|
+
let packets = sendManager.buildPackets(
|
|
3498
|
+
serviceId: ServiceID.g2Setting.rawValue,
|
|
3499
|
+
payload: payload,
|
|
3500
|
+
reserveFlag: true
|
|
3501
|
+
)
|
|
3502
|
+
sendToGlasses(packets, left: false, right: true)
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
func keyboardScan() async throws -> [[String: Any]] {
|
|
3506
|
+
try await keyboardBridge.scan().map(G2KeyboardBridge.advertValues)
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
func keyboardConnect(address: String, addressType: Int, name: String) async throws {
|
|
3510
|
+
try await keyboardBridge.connect(address: address, addressType: addressType, name: name)
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
func keyboardReconnect() async throws {
|
|
3514
|
+
try await keyboardBridge.reconnect()
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
func keyboardDisconnect() async throws {
|
|
3518
|
+
try await keyboardBridge.disconnect()
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
func keyboardForget() async {
|
|
3522
|
+
await keyboardBridge.forget()
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
func keyboardPasskey(_ pin: Int) async throws {
|
|
3526
|
+
try await keyboardBridge.passkey(pin)
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
func keyboardCompare(_ accept: Bool) async throws {
|
|
3530
|
+
try await keyboardBridge.compare(accept)
|
|
3531
|
+
}
|
|
3532
|
+
|
|
3533
|
+
func keyboardStatus() async -> [String: Any] {
|
|
3534
|
+
await keyboardBridge.status()
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
/// Arm the relay lease and keep it renewed every 45 s inside the firmware's
|
|
3538
|
+
/// 90 s expiry. ENABLE doubles as the renewal: the firmware treats it as a
|
|
3539
|
+
/// plain extension while the lease is live, and unlike RENEW it also
|
|
3540
|
+
/// recovers a lease the glasses lost (lens reset, missed first packet).
|
|
3541
|
+
private func startAncsRelay() {
|
|
3542
|
+
guard supportsAncsRelay, ancsRelayTask == nil else { return }
|
|
3543
|
+
ancsRelay.reset()
|
|
3544
|
+
ancsRelayLeaseHeld = false
|
|
3545
|
+
ancsRelayTask = Task { @MainActor [weak self] in
|
|
3546
|
+
while !Task.isCancelled {
|
|
3547
|
+
try? await Task.sleep(nanoseconds: 45_000_000_000)
|
|
3548
|
+
guard let self, !Task.isCancelled else { return }
|
|
3549
|
+
self.ancsRelay.prune(olderThan: 30)
|
|
3550
|
+
self.syncAncsRelay()
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
syncAncsRelay()
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3556
|
+
/// ENABLE while the master toggle is on; one DISABLE when it goes off.
|
|
3557
|
+
/// Runs on every renewal tick and whenever the toggle is pushed on-glass;
|
|
3558
|
+
/// a no-op until CFW detection has started the relay.
|
|
3559
|
+
private func syncAncsRelay() {
|
|
3560
|
+
guard ancsRelayTask != nil else { return }
|
|
3561
|
+
if ancsRelayWanted {
|
|
3562
|
+
sendAncsControl(G2AncsRelay.controlPacket(op: .enable))
|
|
3563
|
+
ancsRelayLeaseHeld = true
|
|
3564
|
+
} else if ancsRelayLeaseHeld {
|
|
3565
|
+
sendAncsControl(G2AncsRelay.controlPacket(op: .disable))
|
|
3566
|
+
ancsRelayLeaseHeld = false
|
|
3567
|
+
}
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
private func stopAncsRelay(sendDisable: Bool) {
|
|
3571
|
+
ancsRelayTask?.cancel()
|
|
3572
|
+
ancsRelayTask = nil
|
|
3573
|
+
if sendDisable, ancsRelayLeaseHeld {
|
|
3574
|
+
sendAncsControl(G2AncsRelay.controlPacket(op: .disable))
|
|
3575
|
+
}
|
|
3576
|
+
ancsRelayLeaseHeld = false
|
|
3577
|
+
ancsRelay.reset()
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
/// Perform a relayed notification's positive (open) or negative (clear)
|
|
3581
|
+
/// action on the phone through the glasses' ANCS control point.
|
|
3582
|
+
func ancsNotificationAction(notificationId: String, positive: Bool) {
|
|
3583
|
+
guard supportsAncsRelay, notificationId.hasPrefix("ancs-"),
|
|
3584
|
+
let uid = UInt32(notificationId.dropFirst("ancs-".count))
|
|
3585
|
+
else { return }
|
|
3586
|
+
sendAncsControl(G2AncsRelay.actionPacket(uid: uid, positive: positive))
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
/// One field-125 record from the right lens. Completed notifications feed
|
|
3590
|
+
/// the same phone_notification / phone_notification_dismissed events as
|
|
3591
|
+
/// Android's listener and Mentra Live's relay; APP records feed the
|
|
3592
|
+
/// seen-apps store behind the notification filter UI.
|
|
3593
|
+
private func handleAncsRelayRecord(_ record: Data) {
|
|
3594
|
+
guard let event = ancsRelay.ingest(record) else { return }
|
|
3595
|
+
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
|
3596
|
+
switch event {
|
|
3597
|
+
case let .notification(n):
|
|
3598
|
+
// iOS replays every existing notification when the lens
|
|
3599
|
+
// subscribes; those are not new arrivals and would flood the glass.
|
|
3600
|
+
if n.flags.contains(.preExisting) {
|
|
3601
|
+
Bridge.log("G2: ANCS relay skipping pre-existing \(n.notificationId) from \(n.appIdentifier)")
|
|
3602
|
+
return
|
|
3603
|
+
}
|
|
3604
|
+
let content = [n.subtitle, n.message].filter { !$0.isEmpty }.joined(separator: "\n")
|
|
3605
|
+
Bridge.sendTypedMessage(
|
|
3606
|
+
"phone_notification",
|
|
3607
|
+
body: [
|
|
3608
|
+
"notificationId": n.notificationId,
|
|
3609
|
+
"app": ancsRelay.appNames[n.appIdentifier] ?? n.appIdentifier,
|
|
3610
|
+
"title": n.title,
|
|
3611
|
+
"content": content,
|
|
3612
|
+
"priority": n.flags.contains(.important) ? "1" : "0",
|
|
3613
|
+
"timestamp": G2AncsRelay.timestampMs(date: n.date),
|
|
3614
|
+
"packageName": n.appIdentifier,
|
|
3615
|
+
]
|
|
3616
|
+
)
|
|
3617
|
+
case let .removed(uid, appIdentifier):
|
|
3618
|
+
Bridge.sendTypedMessage(
|
|
3619
|
+
"phone_notification_dismissed",
|
|
3620
|
+
body: [
|
|
3621
|
+
"notificationId": "ancs-\(uid)",
|
|
3622
|
+
"notificationKey": "ancs-\(uid)",
|
|
3623
|
+
"packageName": appIdentifier ?? "",
|
|
3624
|
+
"timestamp": now,
|
|
3625
|
+
]
|
|
3626
|
+
)
|
|
3627
|
+
case let .appName(appIdentifier, displayName):
|
|
3628
|
+
Bridge.sendTypedMessage(
|
|
3629
|
+
"notification_app_seen",
|
|
3630
|
+
body: ["appId": appIdentifier, "displayName": displayName, "timestamp": now]
|
|
3631
|
+
)
|
|
3632
|
+
case let .status(s):
|
|
3633
|
+
Bridge.log(
|
|
3634
|
+
"G2: ANCS relay status enabled=\(s.enabled) side=\(s.side) sent=\(s.sent) drops=\(s.drops) sendErrors=\(s.sendErrors)"
|
|
3635
|
+
)
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
/// Queue `frame` as a retained-scene patch: the firmware keeps the slots,
|
|
3640
|
+
/// tweens transitions itself, and renders every frame into its own buffer.
|
|
3641
|
+
/// Returns false when the frame does not fit, so the raster path takes it.
|
|
3642
|
+
private func enqueueCfwVectorFrame(_ frame: SceneFrame) async -> Bool {
|
|
3643
|
+
let generation = cfwCacheGeneration
|
|
3644
|
+
let clearSerial = cfwClearSerial
|
|
3645
|
+
let boxes = cfwBoxes(for: frame)
|
|
3646
|
+
let backlogged = cfwPendingPayloads.filter { $0.kind == .scene }.count >= Self.cfwMaxQueuedUpdates
|
|
3647
|
+
if backlogged {
|
|
3648
|
+
cfwPendingPayloads.removeAll { $0.kind == .scene }
|
|
3649
|
+
cfwSceneInvalid = true
|
|
3650
|
+
}
|
|
3651
|
+
if cfwLastBoxes != boxes {
|
|
3652
|
+
cfwSceneInvalid = true
|
|
3653
|
+
}
|
|
3654
|
+
guard let encoded = await cfwRenderer.encodeVector(
|
|
3655
|
+
frame, boxes: boxes, cacheGeneration: cfwCacheGeneration, invalidate: cfwSceneInvalid,
|
|
3656
|
+
inlineText: true, tag: supportsCfwSceneSettle ? frame.seq & 0xFFFF : nil
|
|
3657
|
+
) else {
|
|
3658
|
+
displayDiagnostics.count("vectorFallback")
|
|
3659
|
+
cfwSceneInvalid = true
|
|
3660
|
+
return false
|
|
3661
|
+
}
|
|
3662
|
+
guard generation == cfwCacheGeneration, clearSerial == cfwClearSerial else { return false }
|
|
3663
|
+
cfwSceneInvalid = false
|
|
3664
|
+
displayDiagnostics.count("vectorFrames")
|
|
3665
|
+
cfwLastBoxes = boxes
|
|
3666
|
+
// A later raster frame must start from a keyframe: the panel shows the scene, not the shadow.
|
|
3667
|
+
cfwDeltasSinceFull = Self.cfwMaxDeltasBetweenFullFrames
|
|
3668
|
+
if encoded.uploads.isEmpty, !encoded.changed,
|
|
3669
|
+
Date().timeIntervalSince1970 - cfwLastPayloadQueuedAt < Self.cfwIdleRefreshSeconds
|
|
3670
|
+
{
|
|
3671
|
+
cfwAttachPresentTag(frame)
|
|
3672
|
+
return true
|
|
3673
|
+
}
|
|
3674
|
+
for upload in encoded.uploads {
|
|
3675
|
+
cfwPendingPayloads.append(CfwPayload(kind: .upload, data: upload))
|
|
3676
|
+
}
|
|
3677
|
+
displayDiagnostics.count("cachePuts", encoded.puts)
|
|
3678
|
+
displayDiagnostics.count("cacheHits", encoded.hits)
|
|
3679
|
+
if encoded.shows.count > 1 { displayDiagnostics.count("stereoFrames") }
|
|
3680
|
+
// The frame is on the glasses once its last SHOW is acked.
|
|
3681
|
+
for (index, show) in encoded.shows.enumerated() {
|
|
3682
|
+
cfwPendingPayloads.append(CfwPayload(
|
|
3683
|
+
kind: .scene, data: show.payload,
|
|
3684
|
+
presents: index == encoded.shows.count - 1 ? frame.presentTag : nil, lenses: show.lenses
|
|
3685
|
+
))
|
|
3686
|
+
}
|
|
3687
|
+
cfwLastPayloadQueuedAt = Date().timeIntervalSince1970
|
|
3688
|
+
return true
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3083
3691
|
/// Encode `frame` and queue the smallest update that reproduces it: dirty
|
|
3084
3692
|
/// mode-3 rect deltas against the previous raster, a mode-9 rect copy when
|
|
3085
3693
|
/// a vertical scroll is detected, and mode-15 builtin-font text draws for
|
|
@@ -3089,13 +3697,28 @@ final class G2: NSObject, SGCManager {
|
|
|
3089
3697
|
/// change is large, the queue backed up, or on the periodic insurance
|
|
3090
3698
|
/// keyframe. Seeds the shadow with mode-6 black when it is fresh.
|
|
3091
3699
|
private func enqueueCfwFrame(_ frame: SceneFrame) async -> Bool {
|
|
3092
|
-
let
|
|
3093
|
-
|
|
3094
|
-
//
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3700
|
+
let generation = cfwCacheGeneration
|
|
3701
|
+
// Mono text rides the scene as TEXT_CACHED runs (glyphs upload once per
|
|
3702
|
+
// lease), so any frame that fits the slot table takes the vector path.
|
|
3703
|
+
if supportsCfwVectorScene, !G2CfwScene.prefersRaster(frame), await enqueueCfwVectorFrame(frame) {
|
|
3704
|
+
return true
|
|
3705
|
+
}
|
|
3706
|
+
// Raster fallback cannot preserve SVG contours or rotation. Report a dropped frame instead.
|
|
3707
|
+
if frame.elements.contains(where: { $0.type == "svg" || $0.rotationQ8 != 0 }) {
|
|
3708
|
+
return false
|
|
3709
|
+
}
|
|
3710
|
+
guard generation == cfwCacheGeneration else { return false }
|
|
3711
|
+
// A later vector frame must restore its slots after the raster path
|
|
3712
|
+
// takes panel ownership (including a switch back from bitmap playback).
|
|
3713
|
+
cfwSceneInvalid = true
|
|
3714
|
+
let hasMonoText = frame.elements.contains { $0.type == "text" && $0.font == "mono" }
|
|
3715
|
+
displayDiagnostics.count("rasterFrames")
|
|
3716
|
+
let boxes = cfwBoxes(for: frame)
|
|
3717
|
+
let lensEqual = boxes.left == boxes.right
|
|
3718
|
+
// Absolute text/cache draws cannot shift per eye. Bake text into the
|
|
3719
|
+
// stereo raster at non-neutral depth instead of overriding global placement.
|
|
3720
|
+
let useBuiltinText = lensEqual && frame.elements.contains { $0.type == "text" && $0.font == "builtin" }
|
|
3721
|
+
let useTextureGlyphs = lensEqual && hasMonoText
|
|
3099
3722
|
let movedBoxes = cfwLastBoxes != nil && cfwLastBoxes != boxes
|
|
3100
3723
|
let backlogged = cfwPendingPayloads
|
|
3101
3724
|
.filter { $0.kind != .seed && $0.kind != .upload }.count >= Self.cfwMaxQueuedUpdates
|
|
@@ -3118,7 +3741,7 @@ final class G2: NSObject, SGCManager {
|
|
|
3118
3741
|
// phone's metrics under-measured can spill into the outer panel
|
|
3119
3742
|
// margin, where no in-window rect or full frame ever repaints.
|
|
3120
3743
|
// Blank the old window's right margin whenever draws change.
|
|
3121
|
-
// (Builtin
|
|
3744
|
+
// (Builtin draws are only used when the old window is lens-equal.)
|
|
3122
3745
|
if !cfwLastTextDraws.isEmpty, let last = cfwLastBoxes, last.left == last.right {
|
|
3123
3746
|
let edgeX = Int32(last.left.x) + last.width
|
|
3124
3747
|
let bandW = Int32(G2CfwRenderer.frameWidth) - edgeX
|
|
@@ -3133,7 +3756,7 @@ final class G2: NSObject, SGCManager {
|
|
|
3133
3756
|
}
|
|
3134
3757
|
let fids = (0 ..< G2CfwRenderer.requiredFidCount).map { _ in nextCfwFrameId() }
|
|
3135
3758
|
// Copies would smear firmware-drawn glyphs the raster knows nothing about.
|
|
3136
|
-
let allowCopy =
|
|
3759
|
+
let allowCopy = textDraws.isEmpty
|
|
3137
3760
|
guard let update = await cfwRenderer.encodeUpdate(
|
|
3138
3761
|
frame, boxes: boxes,
|
|
3139
3762
|
previousRaster: mustFull ? nil : cfwPrevRaster,
|
|
@@ -3143,10 +3766,11 @@ final class G2: NSObject, SGCManager {
|
|
|
3143
3766
|
marginBlankBoxes: marginBlank,
|
|
3144
3767
|
omitBuiltinText: useBuiltinText,
|
|
3145
3768
|
textureGlyphsEnabled: useTextureGlyphs,
|
|
3146
|
-
textureImagesEnabled:
|
|
3769
|
+
textureImagesEnabled: useBuiltinText || useTextureGlyphs,
|
|
3147
3770
|
cacheGeneration: cfwCacheGeneration
|
|
3148
3771
|
) else { return false }
|
|
3149
3772
|
|
|
3773
|
+
guard generation == cfwCacheGeneration else { return false }
|
|
3150
3774
|
cfwPrevRaster = update.raster
|
|
3151
3775
|
cfwLastTextDraws = textDraws
|
|
3152
3776
|
cfwLastBoxes = boxes
|
|
@@ -3155,7 +3779,9 @@ final class G2: NSObject, SGCManager {
|
|
|
3155
3779
|
cfwPendingPayloads.append(CfwPayload(kind: .upload, data: upload))
|
|
3156
3780
|
}
|
|
3157
3781
|
guard let payload = update.payload else {
|
|
3158
|
-
// Visually identical frame — the shadow already shows it
|
|
3782
|
+
// Visually identical frame — the shadow already shows it, or a
|
|
3783
|
+
// queued payload is about to.
|
|
3784
|
+
cfwAttachPresentTag(frame)
|
|
3159
3785
|
return true
|
|
3160
3786
|
}
|
|
3161
3787
|
if update.usedFullFrame {
|
|
@@ -3178,7 +3804,8 @@ final class G2: NSObject, SGCManager {
|
|
|
3178
3804
|
cfwPendingPayloads.append(CfwPayload(kind: .seed, data: seed))
|
|
3179
3805
|
cfwShadowSeeded = true
|
|
3180
3806
|
if textDraws.isEmpty {
|
|
3181
|
-
cfwPendingPayloads.removeAll { $0.kind == .full || $0.kind == .delta }
|
|
3807
|
+
cfwPendingPayloads.removeAll { $0.kind == .full || $0.kind == .delta || $0.kind == .scene }
|
|
3808
|
+
cfwPendingPayloads[cfwPendingPayloads.count - 1].presents = frame.presentTag
|
|
3182
3809
|
cfwLastPayloadQueuedAt = Date().timeIntervalSince1970
|
|
3183
3810
|
return true
|
|
3184
3811
|
}
|
|
@@ -3189,34 +3816,46 @@ final class G2: NSObject, SGCManager {
|
|
|
3189
3816
|
}
|
|
3190
3817
|
if update.usedFullFrame {
|
|
3191
3818
|
// A full overwrites the window; seeds, uploads, and move-blanks must still survive.
|
|
3192
|
-
cfwPendingPayloads.removeAll { $0.kind == .full || $0.kind == .delta }
|
|
3819
|
+
cfwPendingPayloads.removeAll { $0.kind == .full || $0.kind == .delta || $0.kind == .scene }
|
|
3193
3820
|
let kind: CfwPayloadKind = movedBoxes ? .moveBundle : .full
|
|
3194
|
-
cfwPendingPayloads.append(CfwPayload(kind: kind, data: payload))
|
|
3821
|
+
cfwPendingPayloads.append(CfwPayload(kind: kind, data: payload, presents: frame.presentTag))
|
|
3195
3822
|
} else {
|
|
3196
|
-
cfwPendingPayloads.append(CfwPayload(kind: .delta, data: payload))
|
|
3823
|
+
cfwPendingPayloads.append(CfwPayload(kind: .delta, data: payload, presents: frame.presentTag))
|
|
3197
3824
|
}
|
|
3198
3825
|
cfwLastPayloadQueuedAt = Date().timeIntervalSince1970
|
|
3199
3826
|
return true
|
|
3200
3827
|
}
|
|
3201
3828
|
|
|
3202
|
-
///
|
|
3203
|
-
///
|
|
3204
|
-
///
|
|
3205
|
-
|
|
3829
|
+
/// A frame that needs no payload of its own is on the glasses once the
|
|
3830
|
+
/// last queued frame-carrying payload is acked; with nothing queued it is
|
|
3831
|
+
/// there already. Frame numbers only go up, so the tag can be overwritten.
|
|
3832
|
+
private func cfwAttachPresentTag(_ frame: SceneFrame) {
|
|
3833
|
+
displayDiagnostics.count("noop")
|
|
3834
|
+
guard let tag = frame.presentTag else { return }
|
|
3835
|
+
if let i = cfwPendingPayloads.lastIndex(where: { $0.kind != .upload }) {
|
|
3836
|
+
cfwPendingPayloads[i].presents = tag
|
|
3837
|
+
} else if cfwPayloadInFlight {
|
|
3838
|
+
cfwInFlightPresentTag = tag
|
|
3839
|
+
} else {
|
|
3840
|
+
frame.reportPresented(true)
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
|
|
3844
|
+
/// Make the container set the CFW page shape (no stock text or image
|
|
3845
|
+
/// containers; `createPageWithContainers` then emits only the blank
|
|
3846
|
+
/// "dashboard" text object for touch capture and the mic). Payloads ride the
|
|
3847
|
+
/// SID-0xf0 transport, which needs no container at all. Returns true if it
|
|
3848
|
+
/// was ALREADY configured, so callers can tell whether they need a rebuild.
|
|
3206
3849
|
@discardableResult
|
|
3207
|
-
private func
|
|
3208
|
-
if
|
|
3850
|
+
private func installCfwPageIfNeeded() -> Bool {
|
|
3851
|
+
if hasCfwPage {
|
|
3209
3852
|
return true
|
|
3210
3853
|
}
|
|
3211
3854
|
textContainers.removeAll()
|
|
3212
3855
|
sceneTextByElement.removeAll()
|
|
3213
3856
|
sceneImageByElement.removeAll()
|
|
3214
|
-
imageContainers
|
|
3215
|
-
|
|
3216
|
-
id: Self.cfwSceneContainerID, x: 0, y: 0, width: 576, height: 288,
|
|
3217
|
-
bmpData: Data(), dirty: false
|
|
3218
|
-
),
|
|
3219
|
-
]
|
|
3857
|
+
imageContainers.removeAll()
|
|
3858
|
+
cfwPageInstalled = true
|
|
3220
3859
|
cfwResetShadow()
|
|
3221
3860
|
return false
|
|
3222
3861
|
}
|
|
@@ -3236,7 +3875,10 @@ final class G2: NSObject, SGCManager {
|
|
|
3236
3875
|
return true
|
|
3237
3876
|
}
|
|
3238
3877
|
|
|
3239
|
-
/// Blank the panel
|
|
3878
|
+
/// Blank the panel and drop the phone's frame state. The object cache on
|
|
3879
|
+
/// the glasses is KEPT: a HIDE freezes and blanks, so the next scene (a
|
|
3880
|
+
/// reopened dashboard, say) is one SHOW of references. On firmware without
|
|
3881
|
+
/// the cache a mode-6 black keyframe blanks and seeds the shadow instead.
|
|
3240
3882
|
///
|
|
3241
3883
|
/// The carrier container itself is deliberately NOT torn down: on CFW the
|
|
3242
3884
|
/// idle screen is the black carrier page, which keeps absorbing touch events
|
|
@@ -3244,10 +3886,28 @@ final class G2: NSObject, SGCManager {
|
|
|
3244
3886
|
/// still goes false — that tracks "a miniapp scene is on glass", which
|
|
3245
3887
|
/// gates reposition/replay, not the page's existence.
|
|
3246
3888
|
private func cfwClear() {
|
|
3889
|
+
cfwPendingFrame?.reportPresented(false, reason: "cleared")
|
|
3890
|
+
cfwPendingFrame = nil
|
|
3891
|
+
// Invalidate an encoder suspended on its actor when clear arrived.
|
|
3892
|
+
cfwClearSerial += 1
|
|
3247
3893
|
cfwLastFrame = nil
|
|
3248
3894
|
cfwSceneActive = false
|
|
3249
|
-
|
|
3250
|
-
|
|
3895
|
+
for payload in cfwPendingPayloads {
|
|
3896
|
+
payload.presents?.report(false, reason: "cleared")
|
|
3897
|
+
}
|
|
3898
|
+
// A blank supersedes every unsent operation, including older blanks.
|
|
3899
|
+
// Repeated blank captions must not build a queue of black frames.
|
|
3900
|
+
cfwPendingPayloads.removeAll()
|
|
3901
|
+
if supportsCfwVectorScene {
|
|
3902
|
+
// The object cache on the glasses is KEPT: a HIDE freezes and blanks,
|
|
3903
|
+
// so the next scene (a reopened dashboard) is one SHOW of references.
|
|
3904
|
+
// `cfwLastBoxes` therefore stays: nulling it would make the next frame
|
|
3905
|
+
// invalidate the scene model and redefine every object. A PUT dropped
|
|
3906
|
+
// from the queue above is caught by the MISSING reply and retried.
|
|
3907
|
+
cfwPendingPayloads.append(CfwPayload(kind: .seed, data: G2CfwScene.hidePayload))
|
|
3908
|
+
Task { await cfwRenderer.sceneHidden() }
|
|
3909
|
+
cfwShadowSeeded = true // HIDE clears the shadow to black, like a keyframe
|
|
3910
|
+
} else if let seed = G2CfwRenderer.blackPayload() {
|
|
3251
3911
|
cfwPendingPayloads.append(CfwPayload(kind: .seed, data: seed))
|
|
3252
3912
|
cfwShadowSeeded = true
|
|
3253
3913
|
cfwLastBoxes = nil
|
|
@@ -3270,10 +3930,10 @@ final class G2: NSObject, SGCManager {
|
|
|
3270
3930
|
// its normal paths.
|
|
3271
3931
|
guard cfwClassified, cfwCapabilityString != nil else { return }
|
|
3272
3932
|
if enabled {
|
|
3273
|
-
if
|
|
3933
|
+
if installCfwPageIfNeeded() {
|
|
3274
3934
|
return
|
|
3275
|
-
} // already the
|
|
3276
|
-
Bridge.log("G2: cfw_scene_renderer ON — swapping stock containers for the CFW
|
|
3935
|
+
} // already the CFW page
|
|
3936
|
+
Bridge.log("G2: cfw_scene_renderer ON — swapping stock containers for the CFW page")
|
|
3277
3937
|
await requestPageRebuild()
|
|
3278
3938
|
} else {
|
|
3279
3939
|
Bridge.log("G2: cfw_scene_renderer OFF — exiting CFW surface to a blank stock page")
|
|
@@ -3281,7 +3941,8 @@ final class G2: NSObject, SGCManager {
|
|
|
3281
3941
|
cfwLastFrame = nil
|
|
3282
3942
|
cfwSceneActive = false
|
|
3283
3943
|
cfwResetShadow()
|
|
3284
|
-
|
|
3944
|
+
cfwPageInstalled = false
|
|
3945
|
+
imageContainers.removeAll()
|
|
3285
3946
|
textContainers.removeAll()
|
|
3286
3947
|
sceneTextByElement.removeAll()
|
|
3287
3948
|
sceneImageByElement.removeAll()
|
|
@@ -3292,12 +3953,8 @@ final class G2: NSObject, SGCManager {
|
|
|
3292
3953
|
/// Height/depth changed: re-place the retained frame (stock moves its window the same way).
|
|
3293
3954
|
private func cfwReposition() {
|
|
3294
3955
|
guard supportsCfwSceneRendering, cfwSceneActive, let frame = cfwLastFrame else { return }
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
if await self.enqueueCfwFrame(frame) {
|
|
3298
|
-
self.signalDisplayDirty()
|
|
3299
|
-
}
|
|
3300
|
-
}
|
|
3956
|
+
cfwPendingFrame = frame
|
|
3957
|
+
signalDisplayDirty()
|
|
3301
3958
|
}
|
|
3302
3959
|
|
|
3303
3960
|
func onSceneReplay(_: String) async {
|
|
@@ -3497,7 +4154,7 @@ final class G2: NSObject, SGCManager {
|
|
|
3497
4154
|
/// applySceneFrame batch — without batching, each remove would rebuild).
|
|
3498
4155
|
func clearSceneElements(_ elementIds: [String]) async {
|
|
3499
4156
|
if supportsCfwSceneRendering {
|
|
3500
|
-
let carrierWasConfigured =
|
|
4157
|
+
let carrierWasConfigured = installCfwPageIfNeeded()
|
|
3501
4158
|
cfwClear()
|
|
3502
4159
|
if !carrierWasConfigured, pageCreated {
|
|
3503
4160
|
// Carrier just replaced live stock containers (see clearDisplay).
|
|
@@ -3558,7 +4215,7 @@ final class G2: NSObject, SGCManager {
|
|
|
3558
4215
|
// Idle on CFW = the black carrier page, kept up for touch + mic.
|
|
3559
4216
|
// Install it even on a first-ever clear (no scene rendered yet) so
|
|
3560
4217
|
// the idle state is the same page everywhere.
|
|
3561
|
-
let carrierWasConfigured =
|
|
4218
|
+
let carrierWasConfigured = installCfwPageIfNeeded()
|
|
3562
4219
|
cfwClear()
|
|
3563
4220
|
if !carrierWasConfigured || !pageCreated {
|
|
3564
4221
|
signalDisplayDirty()
|
|
@@ -3613,35 +4270,27 @@ final class G2: NSObject, SGCManager {
|
|
|
3613
4270
|
}
|
|
3614
4271
|
}
|
|
3615
4272
|
|
|
3616
|
-
/// Send a bitmap to
|
|
3617
|
-
/// firmware silently drops the first stream after a fresh page,
|
|
3618
|
-
///
|
|
4273
|
+
/// Send a bitmap to a stock image container as fragmented updateImageRawData
|
|
4274
|
+
/// packets. Stock firmware silently drops the first stream after a fresh page,
|
|
4275
|
+
/// so its first real transfer is preceded by a sacrificial copy.
|
|
3619
4276
|
@discardableResult
|
|
3620
4277
|
private func sendImageData(containerID: Int32, containerName: String, bmpData: Data) async -> Bool {
|
|
3621
4278
|
guard !bmpData.isEmpty else { return true }
|
|
3622
4279
|
|
|
3623
|
-
|
|
3624
|
-
if !isCfwCarrier, !stockImageStreamWarmedUp {
|
|
4280
|
+
if !stockImageStreamWarmedUp {
|
|
3625
4281
|
let warmupOk = await sendImageTransfer(
|
|
3626
|
-
containerID: containerID,
|
|
3627
|
-
containerName: containerName,
|
|
3628
|
-
bmpData: bmpData,
|
|
3629
|
-
isCfwCarrier: false
|
|
4282
|
+
containerID: containerID, containerName: containerName, bmpData: bmpData
|
|
3630
4283
|
)
|
|
3631
4284
|
guard warmupOk else { return false }
|
|
3632
4285
|
stockImageStreamWarmedUp = true
|
|
3633
4286
|
}
|
|
3634
4287
|
|
|
3635
4288
|
return await sendImageTransfer(
|
|
3636
|
-
containerID: containerID,
|
|
3637
|
-
containerName: containerName,
|
|
3638
|
-
bmpData: bmpData,
|
|
3639
|
-
isCfwCarrier: isCfwCarrier
|
|
4289
|
+
containerID: containerID, containerName: containerName, bmpData: bmpData
|
|
3640
4290
|
)
|
|
3641
4291
|
}
|
|
3642
4292
|
|
|
3643
|
-
/// Perform one logical image stream, retrying
|
|
3644
|
-
/// failed fragment ACK.
|
|
4293
|
+
/// Perform one logical stock image stream, retrying with a fresh session.
|
|
3645
4294
|
///
|
|
3646
4295
|
/// The glasses reply ONCE per fragment with an ImgResCmd ErrorCode (4=success, 5=failed).
|
|
3647
4296
|
/// All fragments in an attempt share a session id, and each fragment awaits its own ACK for up
|
|
@@ -3649,21 +4298,13 @@ final class G2: NSObject, SGCManager {
|
|
|
3649
4298
|
/// counts as a failure and the entire image is re-sent with a fresh session up to
|
|
3650
4299
|
/// `IMG_MAX_ATTEMPTS` times, backing off `IMG_RETRY_DELAY_NS` before each re-attempt.
|
|
3651
4300
|
/// On exhausting all attempts it logs a warning and returns false.
|
|
3652
|
-
private func sendImageTransfer(
|
|
3653
|
-
|
|
3654
|
-
) async -> Bool {
|
|
3655
|
-
// CFW carrier transfers use the verified wire format: 3800-byte
|
|
3656
|
-
// fragments, MapSessionId pinned to the container id instead of using
|
|
3657
|
-
// the rotating session counter, and frames
|
|
3658
|
-
// written to the LEFT arm (acks still return on the right). Stock
|
|
3659
|
-
// containers keep the shipped 4096/rotating-session/right-arm shape.
|
|
3660
|
-
let fragmentSize = isCfwCarrier ? 3800 : 4096
|
|
4301
|
+
private func sendImageTransfer(containerID: Int32, containerName: String, bmpData: Data) async -> Bool {
|
|
4302
|
+
let fragmentSize = 4096
|
|
3661
4303
|
let totalSize = Int32(bmpData.count)
|
|
3662
4304
|
let fragmentCount = (bmpData.count + fragmentSize - 1) / fragmentSize
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
// )
|
|
4305
|
+
let transferStarted = G2DisplayDiagnostics.now
|
|
4306
|
+
defer { displayDiagnostics.timing("transferMs", (G2DisplayDiagnostics.now - transferStarted) * 1000) }
|
|
4307
|
+
let generation = pageGeneration
|
|
3667
4308
|
|
|
3668
4309
|
for attempt in 1 ... IMG_MAX_ATTEMPTS {
|
|
3669
4310
|
// Back off before each RE-attempt: the ack didn't come, so the link
|
|
@@ -3671,21 +4312,20 @@ final class G2: NSObject, SGCManager {
|
|
|
3671
4312
|
if attempt > 1 {
|
|
3672
4313
|
try? await Task.sleep(nanoseconds: IMG_RETRY_DELAY_NS)
|
|
3673
4314
|
}
|
|
4315
|
+
guard !Task.isCancelled, pageGeneration == generation else { return false }
|
|
3674
4316
|
// One session id per WHOLE image transfer (per attempt). The glasses key their
|
|
3675
4317
|
// reassembly buffer on MapSessionId, so every fragment of this image must reuse the
|
|
3676
4318
|
// same session id with an incrementing MapFragmentIndex; the per-fragment ACK is
|
|
3677
|
-
// correlated by the (session, fragmentIndex) pair.
|
|
3678
|
-
// stale ACK from
|
|
4319
|
+
// correlated by the (session, fragmentIndex) pair. Every transfer takes a fresh
|
|
4320
|
+
// session so a stale ACK from an earlier transfer can never be mistaken for this one's.
|
|
3679
4321
|
imageSessionCounter = (imageSessionCounter + 1) % 256
|
|
3680
|
-
let sessionId =
|
|
4322
|
+
let sessionId = imageSessionCounter
|
|
3681
4323
|
|
|
3682
4324
|
var fragmentIndex: Int32 = 0
|
|
3683
4325
|
var offset = 0
|
|
3684
4326
|
var transferOk = true
|
|
3685
|
-
// if attempt > 1 {
|
|
3686
|
-
// Bridge.log("G2: sendImageData(\(containerName)) - attempt \(attempt) starting")
|
|
3687
|
-
// }
|
|
3688
4327
|
while offset < bmpData.count {
|
|
4328
|
+
guard !Task.isCancelled, pageGeneration == generation else { return false }
|
|
3689
4329
|
let end = min(offset + fragmentSize, bmpData.count)
|
|
3690
4330
|
let fragment = bmpData[offset ..< end]
|
|
3691
4331
|
|
|
@@ -3699,21 +4339,30 @@ final class G2: NSObject, SGCManager {
|
|
|
3699
4339
|
mapFragmentPacketSize: Int32(fragment.count),
|
|
3700
4340
|
mapRawData: Data(fragment)
|
|
3701
4341
|
)
|
|
3702
|
-
// Send the fragment directly: the reconcile loop already serializes whole image
|
|
3703
|
-
// sends, and the per-fragment ACK gate below provides pacing — no transmit queue.
|
|
3704
|
-
// Carrier frames go to the LEFT arm; the ack still returns on
|
|
3705
|
-
// the right and our notify path is
|
|
3706
|
-
// arm-agnostic); everything else keeps the right arm.
|
|
3707
|
-
sendEvenHubCommand(msg, left: isCfwCarrier, right: !isCfwCarrier)
|
|
3708
|
-
// Bridge.log("G2: img_sen: session=\(sessionId) fragment=\(fragmentIndex)")
|
|
3709
|
-
|
|
3710
4342
|
// Gate on THIS fragment's ACK before sending the next (the ACK provides pacing).
|
|
3711
4343
|
// Timeout/img_failed → abandon the attempt and retry the whole image.
|
|
3712
|
-
let
|
|
4344
|
+
let trace = G2DisplayDiagnostics.Fragment()
|
|
4345
|
+
let ok = await awaitImageAck(sessionId: sessionId, fragmentIndex: fragmentIndex) {
|
|
4346
|
+
// Register before enqueueing: notifications arrive on the BLE
|
|
4347
|
+
// queue and can beat this task's next main-actor continuation.
|
|
4348
|
+
sendEvenHubCommand(msg, trace: trace)
|
|
4349
|
+
}
|
|
4350
|
+
let outcome = imgAckBox.outcome()
|
|
4351
|
+
let reason = Task.isCancelled ? "cancelled" : (outcome?.reason ?? "unknown")
|
|
4352
|
+
displayDiagnostics.count("fragments")
|
|
4353
|
+
if !ok {
|
|
4354
|
+
displayDiagnostics.count("errors")
|
|
4355
|
+
if reason == "timeout" { displayDiagnostics.count("timeouts") }
|
|
4356
|
+
if reason == "nack" { displayDiagnostics.count("nacks") }
|
|
4357
|
+
if let line = displayDiagnostics.stall(fields: [
|
|
4358
|
+
"session": sessionId, "fragment": fragmentIndex, "fragments": fragmentCount,
|
|
4359
|
+
"bytes": fragment.count, "result": reason, "attempt": attempt, "page": pageCreated,
|
|
4360
|
+
]) {
|
|
4361
|
+
Bridge.log(line)
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4364
|
+
guard !Task.isCancelled, pageGeneration == generation else { return false }
|
|
3713
4365
|
if !ok {
|
|
3714
|
-
Bridge.log(
|
|
3715
|
-
"G2: img_sen: session=\(sessionId) fragment=\(fragmentIndex) failed"
|
|
3716
|
-
)
|
|
3717
4366
|
transferOk = false
|
|
3718
4367
|
break
|
|
3719
4368
|
}
|
|
@@ -3723,33 +4372,131 @@ final class G2: NSObject, SGCManager {
|
|
|
3723
4372
|
}
|
|
3724
4373
|
|
|
3725
4374
|
if transferOk {
|
|
3726
|
-
// Bridge.log("G2: img_sen: container=\(containerName) - success=true")
|
|
3727
4375
|
return true
|
|
3728
4376
|
}
|
|
3729
4377
|
}
|
|
3730
4378
|
|
|
3731
|
-
Bridge.log("G2: img_sen: container=\(containerName) - failed after \(IMG_MAX_ATTEMPTS) attempts")
|
|
3732
4379
|
return false
|
|
3733
4380
|
}
|
|
3734
4381
|
|
|
4382
|
+
/// Send one custom message over the SID-0xf0 transport to the lenses
|
|
4383
|
+
/// `lenses` selects (both by default) and wait for their processing ACKs.
|
|
4384
|
+
/// Always written to the LEFT arm; the right lens receives it over the
|
|
4385
|
+
/// frame bridge and its reply returns through the left. Deltas, rect copies
|
|
4386
|
+
/// and tweens are not safe to replay after an ambiguous loss, so a failure
|
|
4387
|
+
/// is reported (the caller resyncs from a keyframe) and the deflate context
|
|
4388
|
+
/// restarts, as the glasses have already dropped theirs on a NACK.
|
|
4389
|
+
private func sendCfwMessage(_ original: Data, lenses: UInt8 = G2CfwTransport.lensBoth) async -> Bool {
|
|
4390
|
+
guard !original.isEmpty else { return true }
|
|
4391
|
+
var message = original
|
|
4392
|
+
if G2CfwTransport.isCacheMessage(original) {
|
|
4393
|
+
cfwCacheRequest = cfwCacheRequest >= 0xFFFE ? 1 : cfwCacheRequest + 1
|
|
4394
|
+
message = G2CfwTransport.stamp(original, epoch: cfwCacheEpoch, request: cfwCacheRequest)
|
|
4395
|
+
}
|
|
4396
|
+
let started = G2DisplayDiagnostics.now
|
|
4397
|
+
defer { displayDiagnostics.timing("transferMs", (G2DisplayDiagnostics.now - started) * 1000) }
|
|
4398
|
+
guard let record = cfwTransport.record(message, targets: lenses) else {
|
|
4399
|
+
displayDiagnostics.count("encodeErrors")
|
|
4400
|
+
return false
|
|
4401
|
+
}
|
|
4402
|
+
let streamId = cfwStreamSequence
|
|
4403
|
+
let packets = G2CfwTransport.packets(stream: record, sequence: streamId, targets: lenses)
|
|
4404
|
+
cfwStreamSequence &+= UInt8(truncatingIfNeeded: packets.count)
|
|
4405
|
+
let trace = G2DisplayDiagnostics.Fragment()
|
|
4406
|
+
let timeout = G2CfwTransport.ackTimeoutNs(streamBytes: record.count)
|
|
4407
|
+
let ok = await cfwAckBox.wait(streamId: streamId, lenses: lenses, timeoutNs: timeout) { isPending in
|
|
4408
|
+
// Arm before enqueueing: replies arrive on the BLE queue and can beat
|
|
4409
|
+
// this task's next main-actor continuation.
|
|
4410
|
+
sendToGlasses(packets, left: true, right: false, trace: trace, isPending: isPending)
|
|
4411
|
+
}
|
|
4412
|
+
let resumedAt = G2DisplayDiagnostics.now
|
|
4413
|
+
let outcome = cfwAckBox.outcome()
|
|
4414
|
+
let reason = Task.isCancelled ? "cancelled" : (outcome?.reason ?? "unknown")
|
|
4415
|
+
let totalMs = (resumedAt - trace.queuedAt) * 1000
|
|
4416
|
+
displayDiagnostics.count("fragments")
|
|
4417
|
+
displayDiagnostics.count("streamBytes", record.count)
|
|
4418
|
+
if let first = trace.firstWrite, let last = trace.lastWrite {
|
|
4419
|
+
displayDiagnostics.timing("submitMs", (first - trace.queuedAt) * 1000)
|
|
4420
|
+
displayDiagnostics.timing("writeMs", (last - first) * 1000)
|
|
4421
|
+
if let outcome {
|
|
4422
|
+
displayDiagnostics.timing("ackMs", (outcome.at - last) * 1000)
|
|
4423
|
+
displayDiagnostics.timing("resumeMs", (resumedAt - outcome.at) * 1000)
|
|
4424
|
+
}
|
|
4425
|
+
}
|
|
4426
|
+
if !ok {
|
|
4427
|
+
displayDiagnostics.count("errors")
|
|
4428
|
+
if reason == "timeout" { displayDiagnostics.count("timeouts") }
|
|
4429
|
+
if reason == "nack" { displayDiagnostics.count("nacks") }
|
|
4430
|
+
// The glasses drop their inflater on any failure; ours must follow.
|
|
4431
|
+
cfwTransport.resetContext()
|
|
4432
|
+
}
|
|
4433
|
+
if totalMs >= 200 || !ok, let line = displayDiagnostics.stall(fields: [
|
|
4434
|
+
"app": diagnosticAppId, "seq": diagnosticFrameSeq,
|
|
4435
|
+
"stream": Int(streamId), "packets": packets.count, "lenses": Int(lenses),
|
|
4436
|
+
|
|
4437
|
+
"mode": Int(message.first ?? 0), "bytes": message.count, "wire": record.count,
|
|
4438
|
+
"totalMs": Int(totalMs), "result": reason,
|
|
4439
|
+
"written": trace.lastWrite != nil, "qL": leftWriteQueue.count, "qR": rightWriteQueue.count,
|
|
4440
|
+
"page": pageCreated,
|
|
4441
|
+
]) {
|
|
4442
|
+
Bridge.log(line)
|
|
4443
|
+
}
|
|
4444
|
+
return ok
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
/// The object cache's session: before the first cache message of a
|
|
4448
|
+
/// generation both lenses are RESET to a fresh phone-chosen epoch and the
|
|
4449
|
+
/// warm-up tween runs. Returns false when the reset itself failed.
|
|
4450
|
+
private func cfwEnsureCacheSession() async -> Bool {
|
|
4451
|
+
guard supportsCfwVectorScene else { return true }
|
|
4452
|
+
if cfwCacheEpoch != 0, cfwSceneWarmedGeneration == cfwCacheGeneration { return true }
|
|
4453
|
+
let generation = cfwCacheGeneration
|
|
4454
|
+
cfwCacheEpoch = UInt16.random(in: 1 ... 0xFFFE)
|
|
4455
|
+
var reset = G2CfwScene.resetPayload
|
|
4456
|
+
reset.append(UInt8(min(255, G2CfwTransport.maxChunkBytes + 10))) // reply capacity of this link
|
|
4457
|
+
guard await sendCfwMessage(reset), generation == cfwCacheGeneration else {
|
|
4458
|
+
cfwCacheEpoch = 0
|
|
4459
|
+
return false
|
|
4460
|
+
}
|
|
4461
|
+
var store = Int.max
|
|
4462
|
+
for reply in cfwAckBox.cacheReplies().values {
|
|
4463
|
+
if reply.epoch != cfwCacheEpoch {
|
|
4464
|
+
Bridge.log("G2: cache reset kept epoch \(reply.epoch) on lens \(reply.lens)")
|
|
4465
|
+
cfwCacheEpoch = 0
|
|
4466
|
+
return false
|
|
4467
|
+
}
|
|
4468
|
+
if reply.storeBytes > 0 { store = min(store, reply.storeBytes) }
|
|
4469
|
+
}
|
|
4470
|
+
if store != Int.max { await cfwRenderer.setStoreCapacity(bytes: store) }
|
|
4471
|
+
cfwSceneWarmedGeneration = generation
|
|
4472
|
+
_ = await sendCfwMessage(G2CfwScene.warmUpShow)
|
|
4473
|
+
return true
|
|
4474
|
+
}
|
|
4475
|
+
|
|
4476
|
+
/// Outcome of a cache message beyond its ACK: what the lenses replied.
|
|
4477
|
+
private enum CfwCacheOutcome { case applied, stale, missing([UInt32]), refused(String) }
|
|
4478
|
+
|
|
4479
|
+
private func cfwCacheOutcome() -> CfwCacheOutcome {
|
|
4480
|
+
var missing: [UInt32] = []
|
|
4481
|
+
for reply in cfwAckBox.cacheReplies().values {
|
|
4482
|
+
switch reply.status {
|
|
4483
|
+
case G2CfwTransport.CacheReply.stale: return .stale
|
|
4484
|
+
case G2CfwTransport.CacheReply.missing: missing.append(contentsOf: reply.missingIds)
|
|
4485
|
+
case G2CfwTransport.CacheReply.applied, G2CfwTransport.CacheReply.unchanged,
|
|
4486
|
+
G2CfwTransport.CacheReply.delta, G2CfwTransport.CacheReply.snapshot: continue
|
|
4487
|
+
default: return .refused("lens \(reply.lens): \(reply.summary)")
|
|
4488
|
+
}
|
|
4489
|
+
}
|
|
4490
|
+
return missing.isEmpty ? .applied : .missing(missing)
|
|
4491
|
+
}
|
|
4492
|
+
|
|
3735
4493
|
/// Suspend until correlateImageAck() resolves the ACK for this `(sessionId, fragmentIndex)`, or
|
|
3736
4494
|
/// `IMG_ACK_TIMEOUT_NS` elapses (whichever comes first). Returns whether the fragment was acked
|
|
3737
4495
|
/// successfully; a timeout returns false. All fragments of one image share a session id, so the
|
|
3738
4496
|
/// fragment index distinguishes their ACKs. The continuation lives in [ImgAckBox], whose lock
|
|
3739
4497
|
/// makes the ACK-resolve and the timeout mutually exclusive — exactly one resumes it.
|
|
3740
|
-
private func awaitImageAck(sessionId: Int, fragmentIndex: Int32) async -> Bool {
|
|
3741
|
-
|
|
3742
|
-
let box = imgAckBox
|
|
3743
|
-
return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
|
3744
|
-
box.arm(session: sessionId, fragment: fragmentIndex, cont: cont)
|
|
3745
|
-
|
|
3746
|
-
// Detached (not @MainActor): the timeout must fire on its own schedule regardless of how
|
|
3747
|
-
// busy the main actor is. The box's lock makes racing with resolve() safe.
|
|
3748
|
-
Task.detached {
|
|
3749
|
-
try? await Task.sleep(nanoseconds: timeoutNs)
|
|
3750
|
-
box.timeout(session: sessionId, fragment: fragmentIndex)
|
|
3751
|
-
}
|
|
3752
|
-
}
|
|
4498
|
+
private func awaitImageAck(sessionId: Int, fragmentIndex: Int32, send: () -> Void) async -> Bool {
|
|
4499
|
+
await imgAckBox.wait(session: sessionId, fragment: fragmentIndex, timeoutNs: IMG_ACK_TIMEOUT_NS, send: send)
|
|
3753
4500
|
}
|
|
3754
4501
|
|
|
3755
4502
|
/// Resume the pending image-ACK continuation for `(session, fragmentIndex)` (if it matches the
|
|
@@ -3781,9 +4528,7 @@ final class G2: NSObject, SGCManager {
|
|
|
3781
4528
|
let rh = height ?? G2.defaultImgContainer.height
|
|
3782
4529
|
|
|
3783
4530
|
// ignore events while the ER dashboard is open:
|
|
3784
|
-
|
|
3785
|
-
DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
|
|
3786
|
-
if useNativeDashboard && dashboardShowing > 0 {
|
|
4531
|
+
if dashboardShowing > 0 {
|
|
3787
4532
|
return false
|
|
3788
4533
|
}
|
|
3789
4534
|
|
|
@@ -3848,18 +4593,57 @@ final class G2: NSObject, SGCManager {
|
|
|
3848
4593
|
return true
|
|
3849
4594
|
}
|
|
3850
4595
|
|
|
3851
|
-
///
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
4596
|
+
/// Transport completion, not on-glass animation completion or observed display FPS.
|
|
4597
|
+
private func recordCfwPayloadAck(_ payload: CfwPayload) {
|
|
4598
|
+
guard let origin = payload.originAt else { return }
|
|
4599
|
+
displayDiagnostics.count("framesAcked")
|
|
4600
|
+
displayDiagnostics.timing("frameMs", (G2DisplayDiagnostics.now - origin) * 1000)
|
|
4601
|
+
}
|
|
4602
|
+
|
|
4603
|
+
/// Serializes image transfers and coalesces pending scenes before encoding.
|
|
3856
4604
|
private func reconcileDisplay() async {
|
|
4605
|
+
guard !Task.isCancelled else { return }
|
|
3857
4606
|
// Native nav mode owns the screen: hold ALL EvenHub display sends (text,
|
|
3858
4607
|
// images, page resurrection). Container state keeps accumulating; the
|
|
3859
4608
|
// nav-exit recovery rebuilds the page and flushes it.
|
|
3860
4609
|
if navigationModeActive {
|
|
3861
4610
|
return
|
|
3862
4611
|
}
|
|
4612
|
+
if !notificationsPanelHold, cfwPendingPayloads.isEmpty, let frame = cfwPendingFrame {
|
|
4613
|
+
let originAt = cfwPendingFrameAt
|
|
4614
|
+
cfwPendingFrame = nil
|
|
4615
|
+
let started = G2DisplayDiagnostics.now
|
|
4616
|
+
let encoded = await enqueueCfwFrame(frame)
|
|
4617
|
+
let encodeMs = (G2DisplayDiagnostics.now - started) * 1000
|
|
4618
|
+
displayDiagnostics.timing("encodeMs", encodeMs)
|
|
4619
|
+
displayDiagnostics.timing("frameQueueMs", (started - originAt) * 1000)
|
|
4620
|
+
displayDiagnostics.count("framesEncoded")
|
|
4621
|
+
displayDiagnostics.count("elements", frame.elements.count)
|
|
4622
|
+
displayDiagnostics.count("animatedElements", frame.elements.filter { $0.transitionMs > 0 }.count)
|
|
4623
|
+
if encoded {
|
|
4624
|
+
for i in cfwPendingPayloads.indices {
|
|
4625
|
+
cfwPendingPayloads[i].frameSeq = frame.seq
|
|
4626
|
+
cfwPendingPayloads[i].appId = frame.appId
|
|
4627
|
+
displayDiagnostics.count("payloadBytes", cfwPendingPayloads[i].data.count)
|
|
4628
|
+
if cfwPendingPayloads[i].kind == .upload {
|
|
4629
|
+
displayDiagnostics.count("uploadBytes", cfwPendingPayloads[i].data.count)
|
|
4630
|
+
}
|
|
4631
|
+
}
|
|
4632
|
+
if !cfwPendingPayloads.isEmpty {
|
|
4633
|
+
cfwPendingPayloads[cfwPendingPayloads.count - 1].originAt = originAt
|
|
4634
|
+
}
|
|
4635
|
+
} else {
|
|
4636
|
+
displayDiagnostics.count("encodeErrors")
|
|
4637
|
+
frame.reportPresented(false, reason: "scene encode failed or invalidated")
|
|
4638
|
+
}
|
|
4639
|
+
if encodeMs >= 100, let line = displayDiagnostics.stall(fields: [
|
|
4640
|
+
"stage": "encode", "seq": frame.seq, "app": frame.appId,
|
|
4641
|
+
"encodeMs": Int(encodeMs), "elements": frame.elements.count, "ok": encoded,
|
|
4642
|
+
]) {
|
|
4643
|
+
Bridge.log(line)
|
|
4644
|
+
}
|
|
4645
|
+
}
|
|
4646
|
+
guard !Task.isCancelled else { return }
|
|
3863
4647
|
// Page is dead but content is waiting (e.g. captions kept arriving while iOS had us
|
|
3864
4648
|
// suspended and the firmware tore the session down). Rebuild the page ONCE here — the
|
|
3865
4649
|
// reconcile loop is coalesced (1-deep signal buffer), so a burst of buffered sendText
|
|
@@ -3867,8 +4651,6 @@ final class G2: NSObject, SGCManager {
|
|
|
3867
4651
|
// rebuildState() recreates the page, re-pushes the current text/image, and re-arms the
|
|
3868
4652
|
// mic iff intent says so. Skip while the native dashboard owns the screen.
|
|
3869
4653
|
if !pageCreated {
|
|
3870
|
-
let useNativeDashboard =
|
|
3871
|
-
DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
|
|
3872
4654
|
// Stock path: only resurrect for non-blank content — don't rebuild just to render a
|
|
3873
4655
|
// clearDisplay's blank, or a clear burst churns the page back up pointlessly.
|
|
3874
4656
|
// (CFW is the exception below: its blank IS the idle page.)
|
|
@@ -3883,39 +4665,28 @@ final class G2: NSObject, SGCManager {
|
|
|
3883
4665
|
// Idle carrier: the black seed alone. This is what the
|
|
3884
4666
|
// rebuild→systemExit→rebuild storm guard used to block, so it is
|
|
3885
4667
|
// rate-limited below rather than allowed once per reconcile tick.
|
|
3886
|
-
let cfwIdleCarrier = !cfwPendingPayloads.isEmpty && !cfwHasScene &&
|
|
4668
|
+
let cfwIdleCarrier = !cfwPendingPayloads.isEmpty && !cfwHasScene && cfwPageInstalled
|
|
3887
4669
|
let hasPendingImage = imageContainers.contains { $0.dirty && !$0.bmpData.isEmpty }
|
|
3888
4670
|
|| cfwHasScene
|
|
3889
4671
|
let wants = hasPendingText || hasPendingImage || cfwIdleCarrier
|
|
3890
4672
|
// Consume the idle-resurrect slot ONLY when this tick would actually
|
|
3891
4673
|
// rebuild — burning it behind the dashboard gate would stall a real
|
|
3892
4674
|
// resurrection for the whole backoff window.
|
|
3893
|
-
if wants,
|
|
4675
|
+
if wants, dashboardShowing == 0,
|
|
3894
4676
|
hasPendingText || hasPendingImage || cfwIdlePageResurrectAllowed()
|
|
3895
4677
|
{
|
|
3896
|
-
//
|
|
3897
|
-
//
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
if cfwHasScene || cfwIdleCarrier,
|
|
3903
|
-
let carrier = imageContainers.first(where: { $0.id == Self.cfwSceneContainerID }),
|
|
3904
|
-
let probe = cfwPendingPayloads.first
|
|
3905
|
-
{
|
|
3906
|
-
let ok = await sendImageData(
|
|
3907
|
-
containerID: carrier.id, containerName: carrier.name, bmpData: probe.data
|
|
4678
|
+
// Our own SHUTDOWN_PAGE is still being answered: creating now
|
|
4679
|
+
// would race the teardown trio (see lastPageShutdownSentMs).
|
|
4680
|
+
let settle = pageShutdownSettleRemainingMs()
|
|
4681
|
+
if settle > 0 {
|
|
4682
|
+
Bridge.log(
|
|
4683
|
+
"G2: reconcileDisplay() - page down, \(settle)ms left in the shutdown settle window; deferring the rebuild"
|
|
3908
4684
|
)
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
pageCreated = true
|
|
3912
|
-
if !cfwPendingPayloads.isEmpty {
|
|
3913
|
-
cfwPendingPayloads.removeFirst()
|
|
3914
|
-
}
|
|
3915
|
-
signalDisplayDirty() // drain the rest normally
|
|
3916
|
-
return
|
|
3917
|
-
}
|
|
4685
|
+
armPageShutdownSettleWake(afterMs: settle)
|
|
4686
|
+
return
|
|
3918
4687
|
}
|
|
4688
|
+
// (Transport ACKs need no page, so they cannot probe a "dead" page
|
|
4689
|
+
// the way carrier image ACKs once did.)
|
|
3919
4690
|
Bridge.log("G2: reconcileDisplay() - page down with pending content, rebuilding once")
|
|
3920
4691
|
await rebuildState()
|
|
3921
4692
|
}
|
|
@@ -3925,11 +4696,8 @@ final class G2: NSObject, SGCManager {
|
|
|
3925
4696
|
// Text: synchronous, no ACK. Send one update per container with pending sends and decrement.
|
|
3926
4697
|
for i in textContainers.indices where textContainers[i].pendingSends > 0 {
|
|
3927
4698
|
let container = textContainers[i]
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
Bridge.log(
|
|
3931
|
-
"G2: updateText id=\(container.id) rect=\(container.x),\(container.y) \(container.width)x\(container.height) bytes=\(container.content.utf8.count) content=\(container.content.debugDescription)"
|
|
3932
|
-
)
|
|
4699
|
+
displayDiagnostics.count("stockTextWrites")
|
|
4700
|
+
displayDiagnostics.count("stockTextBytes", container.content.utf8.count)
|
|
3933
4701
|
let msg = EvenHubProto.updateTextMessage(
|
|
3934
4702
|
containerID: container.id,
|
|
3935
4703
|
containerName: container.name,
|
|
@@ -3941,16 +4709,82 @@ final class G2: NSObject, SGCManager {
|
|
|
3941
4709
|
textContainers[i].pendingSends -= 1
|
|
3942
4710
|
}
|
|
3943
4711
|
|
|
3944
|
-
|
|
4712
|
+
var sensorsDrained = 0
|
|
4713
|
+
while pageCreated, !navigationModeActive, !sensorNavigationRecovering, sensorsDrained < 4, !sensorCommands.isEmpty,
|
|
4714
|
+
supportsCfwTransport
|
|
4715
|
+
{
|
|
4716
|
+
sensorsDrained += 1
|
|
4717
|
+
let command = sensorCommands.removeFirst()
|
|
4718
|
+
let ok = await sendCfwMessage(command.data)
|
|
4719
|
+
command.complete(ok)
|
|
4720
|
+
}
|
|
4721
|
+
|
|
4722
|
+
// CFW transport: ordered payload queue (keyframe before deltas). Bounded per pass so a
|
|
3945
4723
|
// fast producer can't starve the ticker; the remainder goes out on the next cycle.
|
|
3946
|
-
if
|
|
4724
|
+
if supportsCfwSceneRendering, cfwPageInstalled {
|
|
3947
4725
|
var drained = 0
|
|
3948
4726
|
while pageCreated, drained < 4, !cfwPendingPayloads.isEmpty {
|
|
3949
4727
|
drained += 1
|
|
3950
4728
|
let payload = cfwPendingPayloads.removeFirst()
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
4729
|
+
cfwPayloadInFlight = true
|
|
4730
|
+
cfwInFlightPresentTag = payload.presents
|
|
4731
|
+
diagnosticFrameSeq = payload.frameSeq
|
|
4732
|
+
diagnosticAppId = payload.appId
|
|
4733
|
+
var ok = true
|
|
4734
|
+
if G2CfwTransport.isCacheMessage(payload.data) {
|
|
4735
|
+
ok = await cfwEnsureCacheSession()
|
|
4736
|
+
}
|
|
4737
|
+
if ok {
|
|
4738
|
+
ok = await sendCfwMessage(payload.data, lenses: payload.lenses)
|
|
4739
|
+
}
|
|
4740
|
+
var recover: (() -> Void)?
|
|
4741
|
+
|
|
4742
|
+
if ok, G2CfwTransport.isCacheMessage(payload.data) {
|
|
4743
|
+
switch cfwCacheOutcome() {
|
|
4744
|
+
case .applied:
|
|
4745
|
+
cfwMissingRetries = 0
|
|
4746
|
+
case .stale:
|
|
4747
|
+
// A lens lost its cache (lease lapse, reboot): new session, redefine everything.
|
|
4748
|
+
displayDiagnostics.count("cacheStale")
|
|
4749
|
+
ok = false
|
|
4750
|
+
recover = { [self] in cfwCacheGeneration += 1; cfwCacheEpoch = 0; cfwSceneInvalid = true }
|
|
4751
|
+
case let .missing(ids):
|
|
4752
|
+
displayDiagnostics.count("cacheMissing")
|
|
4753
|
+
ok = false
|
|
4754
|
+
let retry = cfwMissingRetries < 2
|
|
4755
|
+
cfwMissingRetries += 1
|
|
4756
|
+
recover = { [self] in
|
|
4757
|
+
Task { await cfwRenderer.forget(ids: ids) }
|
|
4758
|
+
if !retry { cfwSceneInvalid = true }
|
|
4759
|
+
}
|
|
4760
|
+
case let .refused(why):
|
|
4761
|
+
displayDiagnostics.count("cacheRefused")
|
|
4762
|
+
Bridge.log("G2: cache message refused: \(why)")
|
|
4763
|
+
ok = false
|
|
4764
|
+
recover = { [self] in cfwCacheGeneration += 1; cfwCacheEpoch = 0; cfwSceneInvalid = true }
|
|
4765
|
+
}
|
|
4766
|
+
}
|
|
4767
|
+
if ok {
|
|
4768
|
+
// Both lenses acked and applied: the firmware has executed the
|
|
4769
|
+
// payload and presents on its next frame tick.
|
|
4770
|
+
recordCfwPayloadAck(payload)
|
|
4771
|
+
cfwInFlightPresentTag?.report(true)
|
|
4772
|
+
}
|
|
4773
|
+
if !ok {
|
|
4774
|
+
cfwInFlightPresentTag?.report(false, reason: recover != nil ? "cache rejected" : "message transport failed")
|
|
4775
|
+
}
|
|
4776
|
+
cfwInFlightPresentTag = nil
|
|
4777
|
+
cfwPayloadInFlight = false
|
|
4778
|
+
if let recover {
|
|
4779
|
+
// Re-encode the last frame against the corrected mirror and try again.
|
|
4780
|
+
recover()
|
|
4781
|
+
cfwPendingPayloads.removeAll { $0.kind == .scene || $0.kind == .upload }
|
|
4782
|
+
if pageCreated, supportsCfwSceneRendering, cfwPageInstalled, let frame = cfwLastFrame {
|
|
4783
|
+
_ = await enqueueCfwFrame(frame)
|
|
4784
|
+
}
|
|
4785
|
+
break
|
|
4786
|
+
}
|
|
4787
|
+
guard !Task.isCancelled else { return }
|
|
3954
4788
|
if !ok {
|
|
3955
4789
|
// A lost payload leaves the on-glass shadow stale; further
|
|
3956
4790
|
// deltas would composite onto it and corrupt the panel.
|
|
@@ -3959,15 +4793,29 @@ final class G2: NSObject, SGCManager {
|
|
|
3959
4793
|
// the page down mid-transfer is re-seeded by rebuildState;
|
|
3960
4794
|
// refilling the queue here just fuels the page-resurrection
|
|
3961
4795
|
// loop (rebuild→systemExit→rebuild every debounce tick).
|
|
3962
|
-
if pageCreated, supportsCfwSceneRendering,
|
|
3963
|
-
|
|
4796
|
+
if pageCreated, supportsCfwSceneRendering, cfwPageInstalled {
|
|
4797
|
+
// A transport failure leaves the cache session intact (a lens
|
|
4798
|
+
// that lost it answers STALE), so only the uploads that may not
|
|
4799
|
+
// have landed are re-sent — not every asset. A failed session
|
|
4800
|
+
// RESET (epoch back to 0) still starts over.
|
|
4801
|
+
let keepCache = cfwCacheEpoch != 0
|
|
4802
|
+
let dropped = ([payload] + cfwPendingPayloads).filter { $0.kind == .upload }.map(\.data)
|
|
4803
|
+
cfwResetShadow(keepCache: keepCache)
|
|
3964
4804
|
if let frame = cfwLastFrame {
|
|
3965
|
-
|
|
4805
|
+
cfwPendingFrame = frame
|
|
4806
|
+
}
|
|
4807
|
+
if keepCache {
|
|
4808
|
+
await cfwRenderer.forgetUploads(dropped)
|
|
3966
4809
|
}
|
|
3967
4810
|
}
|
|
3968
4811
|
break
|
|
3969
4812
|
}
|
|
3970
4813
|
}
|
|
4814
|
+
// A finite upload burst can exhaust the four-payload budget after
|
|
4815
|
+
// its producer signal was consumed. Continue without a 100 ms tick gap.
|
|
4816
|
+
if pageCreated, !cfwPendingPayloads.isEmpty || cfwPendingFrame != nil {
|
|
4817
|
+
signalDisplayDirty()
|
|
4818
|
+
}
|
|
3971
4819
|
}
|
|
3972
4820
|
|
|
3973
4821
|
// Images: ACK-gated, exactly one in flight. Cap iterations defensively so a container that
|
|
@@ -4062,6 +4910,7 @@ final class G2: NSObject, SGCManager {
|
|
|
4062
4910
|
|
|
4063
4911
|
/// shutdown and rebuild everything, re-sends all data to the glasses:
|
|
4064
4912
|
private func rebuildPage() async {
|
|
4913
|
+
lastPageShutdownSentMs = Int64(Date().timeIntervalSince1970 * 1000)
|
|
4065
4914
|
let msg = EvenHubProto.shutdownMessage()
|
|
4066
4915
|
sendEvenHubCommand(msg)
|
|
4067
4916
|
pageCreated = false
|
|
@@ -4077,15 +4926,19 @@ final class G2: NSObject, SGCManager {
|
|
|
4077
4926
|
Bridge.log("G2: rebuildState() skipped — navigation mode owns the screen")
|
|
4078
4927
|
return
|
|
4079
4928
|
}
|
|
4929
|
+
if notificationsPanelHold {
|
|
4930
|
+
Bridge.log("G2: rebuildState() skipped — notifications panel hold")
|
|
4931
|
+
return
|
|
4932
|
+
}
|
|
4080
4933
|
Bridge.log("G2: rebuildState()")
|
|
4081
4934
|
// Stock containers left by a torn-down page would rebuild as the evt-0 page, not the carrier.
|
|
4082
4935
|
if supportsCfwSceneRendering {
|
|
4083
|
-
_ =
|
|
4936
|
+
_ = installCfwPageIfNeeded()
|
|
4084
4937
|
}
|
|
4085
4938
|
// A stray text container here means createPageWithContainers builds the evt-0 shape
|
|
4086
|
-
// instead of the
|
|
4939
|
+
// instead of the CFW page.
|
|
4087
4940
|
Bridge.log(
|
|
4088
|
-
"G2: rebuildState() post-
|
|
4941
|
+
"G2: rebuildState() post-install cfw=\(supportsCfwSceneRendering) cfwPage=\(hasCfwPage)"
|
|
4089
4942
|
+ " text=\(textContainers.map { $0.id }) img=\(imageContainers.map { $0.id })"
|
|
4090
4943
|
)
|
|
4091
4944
|
// recreate the containers (sets pageCreated = true; embeds text content directly):
|
|
@@ -4102,12 +4955,11 @@ final class G2: NSObject, SGCManager {
|
|
|
4102
4955
|
)
|
|
4103
4956
|
imageContainers[i].dirty = true
|
|
4104
4957
|
}
|
|
4105
|
-
//
|
|
4106
|
-
|
|
4107
|
-
if supportsCfwSceneRendering, hasCfwCarrier {
|
|
4958
|
+
// Re-seed the on-glass shadow and re-send the retained frame for the new page.
|
|
4959
|
+
if supportsCfwSceneRendering, cfwPageInstalled {
|
|
4108
4960
|
cfwResetShadow()
|
|
4109
4961
|
if let frame = cfwLastFrame {
|
|
4110
|
-
|
|
4962
|
+
cfwPendingFrame = frame
|
|
4111
4963
|
} else if let seed = G2CfwRenderer.blackPayload() {
|
|
4112
4964
|
cfwPendingPayloads.append(CfwPayload(kind: .seed, data: seed))
|
|
4113
4965
|
cfwShadowSeeded = true
|
|
@@ -4127,6 +4979,30 @@ final class G2: NSObject, SGCManager {
|
|
|
4127
4979
|
restartMicIfAlreadyEnabled()
|
|
4128
4980
|
}
|
|
4129
4981
|
|
|
4982
|
+
/// Milliseconds left in the settle window after a SHUTDOWN_PAGE we sent; 0 outside it.
|
|
4983
|
+
private func pageShutdownSettleRemainingMs() -> Int64 {
|
|
4984
|
+
guard lastPageShutdownSentMs > 0 else { return 0 }
|
|
4985
|
+
let elapsed = Int64(Date().timeIntervalSince1970 * 1000) - lastPageShutdownSentMs
|
|
4986
|
+
return max(0, PAGE_SHUTDOWN_SETTLE_MS - elapsed)
|
|
4987
|
+
}
|
|
4988
|
+
|
|
4989
|
+
/// Once the settle window ends, bring the page back exactly once: through
|
|
4990
|
+
/// recovery when the mic needs a live page, else through the reconcile
|
|
4991
|
+
/// loop, which only resurrects for real content (a blank stays down).
|
|
4992
|
+
private func armPageShutdownSettleWake(afterMs: Int64) {
|
|
4993
|
+
guard !pageShutdownSettleWakeArmed else { return }
|
|
4994
|
+
pageShutdownSettleWakeArmed = true
|
|
4995
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(afterMs) + 10)) { [weak self] in
|
|
4996
|
+
guard let self else { return }
|
|
4997
|
+
self.pageShutdownSettleWakeArmed = false
|
|
4998
|
+
if !self.pageCreated, self.evenHubMicIntent {
|
|
4999
|
+
self.recoverPageAndMic(reason: "shutdown-settled")
|
|
5000
|
+
} else {
|
|
5001
|
+
self.signalDisplayDirty()
|
|
5002
|
+
}
|
|
5003
|
+
}
|
|
5004
|
+
}
|
|
5005
|
+
|
|
4130
5006
|
/// Single coalesced recovery: rebuild the page + re-arm sensor streams from intent, but never
|
|
4131
5007
|
/// stack rebuilds. The firmware spams systemExit/dashboard-close ~1×/sec; without this
|
|
4132
5008
|
/// guard each one triggered a rebuild that was torn down again → rebuild→exit→rebuild
|
|
@@ -4138,6 +5014,15 @@ final class G2: NSObject, SGCManager {
|
|
|
4138
5014
|
if navigationModeActive {
|
|
4139
5015
|
return
|
|
4140
5016
|
}
|
|
5017
|
+
// The firmware is still answering our own SHUTDOWN_PAGE (its systemExit
|
|
5018
|
+
// and 08011A00 land ~300 ms after it): this is the old page dying, not
|
|
5019
|
+
// something to recover from. The settle wake brings the page back once.
|
|
5020
|
+
let settle = pageShutdownSettleRemainingMs()
|
|
5021
|
+
if settle > 0 {
|
|
5022
|
+
Bridge.log("G2: recover(\(reason)) skipped — \(settle)ms left in the shutdown settle window")
|
|
5023
|
+
armPageShutdownSettleWake(afterMs: settle)
|
|
5024
|
+
return
|
|
5025
|
+
}
|
|
4141
5026
|
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
|
4142
5027
|
// If the page is already alive and the mic matches intent, there's nothing to recover —
|
|
4143
5028
|
// this is a spurious/phantom firmware event (it spams close/exit even when our page is
|
|
@@ -4516,8 +5401,11 @@ final class G2: NSObject, SGCManager {
|
|
|
4516
5401
|
}
|
|
4517
5402
|
|
|
4518
5403
|
func setDashboardDepthOnly(_ depth: Int) {
|
|
4519
|
-
|
|
4520
|
-
|
|
5404
|
+
// The setting is 1-based (1..3, see the position screen); the firmware's
|
|
5405
|
+
// xCoordinateLevel is 0..2. Sending the setting straight collapsed the
|
|
5406
|
+
// two far levels onto one.
|
|
5407
|
+
let clamped = Int32(G2CfwRenderer.depthLevel(forSetting: depth))
|
|
5408
|
+
Bridge.log("G2: setDashboardDepthOnly(depth=\(depth) level=\(clamped))")
|
|
4521
5409
|
let msg = G2SettingProto.setScreenDepth(
|
|
4522
5410
|
magicRandom: sendManager.nextMagicRandom(),
|
|
4523
5411
|
level: clamped
|
|
@@ -4539,19 +5427,15 @@ final class G2: NSObject, SGCManager {
|
|
|
4539
5427
|
// MARK: - Private Display Helpers
|
|
4540
5428
|
|
|
4541
5429
|
private func createPageWithContainers() {
|
|
4542
|
-
// CFW
|
|
4543
|
-
//
|
|
4544
|
-
//
|
|
4545
|
-
//
|
|
4546
|
-
//
|
|
4547
|
-
// the
|
|
4548
|
-
//
|
|
4549
|
-
//
|
|
4550
|
-
//
|
|
4551
|
-
// shape and the firmware then rejects every CFW payload (img_res
|
|
4552
|
-
// errorCode=4). Every rebuild path funnels through this function, so
|
|
4553
|
-
// enforce the invariant here rather than trusting each caller to have
|
|
4554
|
-
// run installCfwCarrierIfNeeded() first.
|
|
5430
|
+
// CFW page: one full-canvas "dashboard" text widget (id 1, content " ",
|
|
5431
|
+
// event capture, exact compact field shape with no border/padding
|
|
5432
|
+
// fields) and widgetId 10000 on the page — the blank text container the
|
|
5433
|
+
// firmware keeps for touch forwarding and the mic. Custom payloads ride
|
|
5434
|
+
// the SID-0xf0 transport and need no image container. With CFW scene
|
|
5435
|
+
// rendering active this is the ONLY legal page: a stray stock container
|
|
5436
|
+
// here would rebuild the evt-0 page shape. Every rebuild path funnels
|
|
5437
|
+
// through this function, so enforce the invariant here rather than
|
|
5438
|
+
// trusting each caller to have run installCfwPageIfNeeded() first.
|
|
4555
5439
|
if supportsCfwSceneRendering {
|
|
4556
5440
|
if !textContainers.isEmpty {
|
|
4557
5441
|
Bridge.log(
|
|
@@ -4559,21 +5443,16 @@ final class G2: NSObject, SGCManager {
|
|
|
4559
5443
|
)
|
|
4560
5444
|
textContainers.removeAll()
|
|
4561
5445
|
}
|
|
4562
|
-
|
|
4563
|
-
if !strayImages.isEmpty {
|
|
5446
|
+
if !imageContainers.isEmpty {
|
|
4564
5447
|
Bridge.log(
|
|
4565
|
-
"G2: CFW page — dropping stray image containers \(
|
|
5448
|
+
"G2: CFW page — dropping stray image containers \(imageContainers.map { $0.id })"
|
|
4566
5449
|
)
|
|
4567
|
-
imageContainers.removeAll
|
|
4568
|
-
}
|
|
4569
|
-
if !hasCfwCarrier {
|
|
4570
|
-
_ = installCfwCarrierIfNeeded()
|
|
5450
|
+
imageContainers.removeAll()
|
|
4571
5451
|
}
|
|
5452
|
+
cfwPageInstalled = true
|
|
4572
5453
|
}
|
|
4573
5454
|
|
|
4574
|
-
let isCfwCarrierPage = textContainers.isEmpty
|
|
4575
|
-
&& imageContainers.count == 1
|
|
4576
|
-
&& imageContainers[0].id == Self.cfwSceneContainerID
|
|
5455
|
+
let isCfwCarrierPage = supportsCfwSceneRendering && textContainers.isEmpty && imageContainers.isEmpty
|
|
4577
5456
|
|
|
4578
5457
|
// Dedicated event-capture container: id 0, 1x1, borderless, empty — the
|
|
4579
5458
|
// designated event-capture slot per the RE demos ("container 0 is
|
|
@@ -4628,15 +5507,6 @@ final class G2: NSObject, SGCManager {
|
|
|
4628
5507
|
// iterate all image containers, remove any entrys with duplicate id's, and ensure the ids in the imageContainerIDPool is up-to-date:
|
|
4629
5508
|
var seenIDs = Set<Int32>()
|
|
4630
5509
|
imageContainers = imageContainers.filter { c in
|
|
4631
|
-
// The CFW carrier is INTENTIONALLY empty: its pixels ride the
|
|
4632
|
-
// ordered payload queue (cfwPendingPayloads), never bmpData. The
|
|
4633
|
-
// empty-container prune below silently dropped it from every page
|
|
4634
|
-
// since the queue rework — the firmware then had no container to
|
|
4635
|
-
// receive ANY CFW payload (mode 3/6/8, even plain BMPs), so all
|
|
4636
|
-
// direct rendering went dark while stock text kept working.
|
|
4637
|
-
if c.id == Self.cfwSceneContainerID {
|
|
4638
|
-
return seenIDs.insert(c.id).inserted
|
|
4639
|
-
}
|
|
4640
5510
|
guard !c.bmpData.isEmpty else {
|
|
4641
5511
|
Bridge.log("G2: removing empty image container \(c.id)")
|
|
4642
5512
|
return false
|
|
@@ -4753,17 +5623,18 @@ final class G2: NSObject, SGCManager {
|
|
|
4753
5623
|
// Intent is "mic on". The mic only exists inside a live EvenHub page, so we
|
|
4754
5624
|
// toggle it off then back on (the firmware needs the off→on edge to re-arm).
|
|
4755
5625
|
evenHubMicIntent = true
|
|
5626
|
+
// Held: intent is recorded; the post-hold rebuild re-arms the mic.
|
|
5627
|
+
if notificationsPanelHold {
|
|
5628
|
+
return
|
|
5629
|
+
}
|
|
4756
5630
|
DeviceStore.shared.apply("glasses", "micEnabled", true)
|
|
4757
5631
|
let msg = EvenHubProto.audioControlMessage(enable: false)
|
|
4758
5632
|
sendEvenHubCommand(msg)
|
|
4759
5633
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
4760
5634
|
guard let self = self else { return }
|
|
4761
|
-
let useNativeDashboard =
|
|
4762
|
-
DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
|
|
4763
|
-
// Bridge.log("G2: setMicEnabled - useNativeDashboard=\(useNativeDashboard), dashboardShowing=\(dashboardShowing)")
|
|
4764
5635
|
// Dashboard owns the screen + session right now — don't arm the mic into a
|
|
4765
5636
|
// page the dashboard has taken over; recovery re-arms on dashboard close.
|
|
4766
|
-
if
|
|
5637
|
+
if dashboardShowing > 0 {
|
|
4767
5638
|
return
|
|
4768
5639
|
}
|
|
4769
5640
|
// Never send audioControl(enable:true) without a live page — no page means no
|
|
@@ -4874,13 +5745,15 @@ final class G2: NSObject, SGCManager {
|
|
|
4874
5745
|
startupPageCreated = false
|
|
4875
5746
|
pageCreated = false
|
|
4876
5747
|
stockImageStreamWarmedUp = false
|
|
5748
|
+
cfwTransport = G2CfwTransport.Encoder()
|
|
5749
|
+
cfwStreamSequence = UInt8.random(in: 0 ... 255)
|
|
4877
5750
|
dashboardShowing = 0
|
|
4878
5751
|
dashboardOpening = false
|
|
5752
|
+
notificationsPanelHold = false
|
|
4879
5753
|
heartbeatCounter = 0
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
cfwProtocolVersion = 0
|
|
5754
|
+
cancelSensorCommands()
|
|
5755
|
+
cfwClassification = nil
|
|
5756
|
+
publishRingBattery(.disconnected)
|
|
4884
5757
|
cfwSceneActive = false
|
|
4885
5758
|
// Fresh connection gets a fresh idle-resurrect budget.
|
|
4886
5759
|
cfwLastIdleResurrectAt = nil
|
|
@@ -4888,6 +5761,11 @@ final class G2: NSObject, SGCManager {
|
|
|
4888
5761
|
cfwResetShadow()
|
|
4889
5762
|
stopFbLease(sendRelease: false) // link is gone; the leases fail open in 90 s
|
|
4890
5763
|
stopWakeLease(sendRelease: false) // wakeLeaseWanted persists; re-armed on CFW re-detection
|
|
5764
|
+
stopAncsRelay(sendDisable: false) // re-armed on CFW re-detection
|
|
5765
|
+
keyboardBridge.linkLost() // the remembered keyboard reconnects on CFW re-detection
|
|
5766
|
+
notificationPhase = "stale"
|
|
5767
|
+
lastAppliedNotificationConfig = nil // the next connection gets a full re-push
|
|
5768
|
+
publishNotificationStatus()
|
|
4891
5769
|
DeviceStore.shared.apply("glasses", "connected", false)
|
|
4892
5770
|
DeviceStore.shared.apply("glasses", "fullyBooted", false)
|
|
4893
5771
|
}
|
|
@@ -5016,9 +5894,19 @@ final class G2: NSObject, SGCManager {
|
|
|
5016
5894
|
/// 3. SKILL{skillId=NOTIFICATION, skillParam=show, ...} — dispatches the intent
|
|
5017
5895
|
/// The SKILL step alone is ignored by firmware; the preceding ENTER+ASK
|
|
5018
5896
|
/// supply the session context that lets the glasses act on the SKILL.
|
|
5897
|
+
/// Our EvenHub page is torn down first and every path that would bring it
|
|
5898
|
+
/// back (recovery rebuilds, fbguard lease, CFW frames, mic re-arm) is held
|
|
5899
|
+
/// for 3 s, or the sends race the stock panel for the screen.
|
|
5019
5900
|
func showNotificationsPanel() async {
|
|
5020
5901
|
Bridge.log("G2: showNotificationsPanel()")
|
|
5021
5902
|
|
|
5903
|
+
notificationsPanelHold = true
|
|
5904
|
+
stopFbLease(sendRelease: true)
|
|
5905
|
+
sendEvenHubCommand(EvenHubProto.shutdownMessage())
|
|
5906
|
+
pageCreated = false
|
|
5907
|
+
// Page death takes the mic with it; intent is untouched so the post-hold rebuild re-arms.
|
|
5908
|
+
DeviceStore.shared.apply("glasses", "micEnabled", false)
|
|
5909
|
+
|
|
5022
5910
|
let enterPayload = EvenAIProto.aiCtrl(
|
|
5023
5911
|
magicRandom: sendManager.nextMagicRandom(),
|
|
5024
5912
|
status: 2 // EVEN_AI_ENTER
|
|
@@ -5039,26 +5927,25 @@ final class G2: NSObject, SGCManager {
|
|
|
5039
5927
|
text: " ",
|
|
5040
5928
|
streamEnable: 1, fTextEnd: 1
|
|
5041
5929
|
)
|
|
5930
|
+
|
|
5931
|
+
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
|
5932
|
+
notificationsPanelHold = false
|
|
5933
|
+
Bridge.log("G2: notifications resurrect re-enabled")
|
|
5042
5934
|
}
|
|
5043
5935
|
|
|
5044
|
-
func dbg1() {
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5936
|
+
func dbg1() {}
|
|
5937
|
+
|
|
5938
|
+
func dbg2() {}
|
|
5939
|
+
|
|
5940
|
+
/// Raw wake lease start — no durable intent; a reconnect drops it.
|
|
5941
|
+
func dbg3() {
|
|
5942
|
+
Bridge.log("G2: dbg3() — start wake lease")
|
|
5943
|
+
startWakeLease()
|
|
5048
5944
|
}
|
|
5049
5945
|
|
|
5050
|
-
func
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
dashboardOpening = true
|
|
5054
|
-
let msg = EvenHubProto.shutdownMessage()
|
|
5055
|
-
sendEvenHubCommand(msg)
|
|
5056
|
-
pageCreated = false
|
|
5057
|
-
try? await Task.sleep(nanoseconds: 300_000_000) // 300ms to settle
|
|
5058
|
-
await showNotificationsPanel()
|
|
5059
|
-
}
|
|
5060
|
-
// navigationModeActive ? exitNavigationMode(arrived: false) : enterNavigationMode()
|
|
5061
|
-
// Task { await runAuthSequence() }
|
|
5946
|
+
func dbg4() {
|
|
5947
|
+
Bridge.log("G2: dbg4() — stop wake lease")
|
|
5948
|
+
stopWakeLease(sendRelease: true)
|
|
5062
5949
|
}
|
|
5063
5950
|
|
|
5064
5951
|
// MARK: - Native navigation mode (service 0x08)
|
|
@@ -5114,6 +6001,7 @@ final class G2: NSObject, SGCManager {
|
|
|
5114
6001
|
func exitNavigationMode(arrived: Bool) {
|
|
5115
6002
|
guard navigationModeActive else { return }
|
|
5116
6003
|
Bridge.log("G2: exitNavigationMode(arrived: \(arrived))")
|
|
6004
|
+
sensorNavigationRecovering = true
|
|
5117
6005
|
navHeartbeatTask?.cancel()
|
|
5118
6006
|
navHeartbeatTask = nil
|
|
5119
6007
|
navigationModeActive = false
|
|
@@ -5140,6 +6028,7 @@ final class G2: NSObject, SGCManager {
|
|
|
5140
6028
|
await MainActor.run {
|
|
5141
6029
|
guard let self = self, !self.navigationModeActive else { return }
|
|
5142
6030
|
self.pageCreated = false
|
|
6031
|
+
self.sensorNavigationRecovering = false
|
|
5143
6032
|
self.recoverPageAndMic(reason: "nav-exit")
|
|
5144
6033
|
}
|
|
5145
6034
|
}
|
|
@@ -5509,12 +6398,15 @@ final class G2: NSObject, SGCManager {
|
|
|
5509
6398
|
startupPageCreated = false
|
|
5510
6399
|
pageCreated = false
|
|
5511
6400
|
stockImageStreamWarmedUp = false
|
|
6401
|
+
cfwTransport = G2CfwTransport.Encoder()
|
|
6402
|
+
cfwStreamSequence = UInt8.random(in: 0 ... 255)
|
|
6403
|
+
cfwPageInstalled = false
|
|
5512
6404
|
dashboardShowing = 0
|
|
5513
6405
|
dashboardOpening = false
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
6406
|
+
cancelSensorCommands()
|
|
6407
|
+
cfwClassification = nil
|
|
6408
|
+
publishRingBattery(.disconnected)
|
|
6409
|
+
keyboardBridge.linkLost()
|
|
5518
6410
|
cfwSceneActive = false
|
|
5519
6411
|
cfwLastIdleResurrectAt = nil
|
|
5520
6412
|
cfwLastFrame = nil
|
|
@@ -5700,9 +6592,8 @@ final class G2: NSObject, SGCManager {
|
|
|
5700
6592
|
|
|
5701
6593
|
// MARK: - Incoming Data Handling
|
|
5702
6594
|
|
|
5703
|
-
private func handleNotifyData(_ data: Data,
|
|
6595
|
+
private func handleNotifyData(_ data: Data, sourceKey: String, arrival: G2InputArrival) {
|
|
5704
6596
|
// Distinguish left vs right peripheral so multi-packet reassembly doesn't collide
|
|
5705
|
-
let sourceKey = peripheral === leftPeripheral ? "L" : "R"
|
|
5706
6597
|
guard let result = receiveManager.handlePacket(data, sourceKey: sourceKey) else { return }
|
|
5707
6598
|
// Bridge.log(
|
|
5708
6599
|
// "G2: handleNotifyData() - serviceId=\(result.serviceId), payload=\(result.payload.count) bytes"
|
|
@@ -5711,7 +6602,7 @@ final class G2: NSObject, SGCManager {
|
|
|
5711
6602
|
// Route based on service ID
|
|
5712
6603
|
switch result.serviceId {
|
|
5713
6604
|
case ServiceID.evenHub.rawValue:
|
|
5714
|
-
handleEvenHubResponse(result.payload)
|
|
6605
|
+
handleEvenHubResponse(result.payload, arrival: arrival)
|
|
5715
6606
|
case ServiceID.deviceSettings.rawValue:
|
|
5716
6607
|
handleDevSettingsResponse(result.payload, sourceKey: sourceKey)
|
|
5717
6608
|
case ServiceID.g2Setting.rawValue:
|
|
@@ -5721,7 +6612,7 @@ final class G2: NSObject, SGCManager {
|
|
|
5721
6612
|
case ServiceID.dashboard.rawValue:
|
|
5722
6613
|
handleDashboardResponse(result.payload)
|
|
5723
6614
|
case ServiceID.gestureCtrl.rawValue:
|
|
5724
|
-
await handleGestureCtrl(result.payload)
|
|
6615
|
+
Task { await handleGestureCtrl(result.payload) }
|
|
5725
6616
|
case ServiceID.navigation.rawValue:
|
|
5726
6617
|
handleNavigationResponse(result.payload)
|
|
5727
6618
|
case ServiceID.evenAI.rawValue:
|
|
@@ -5730,6 +6621,8 @@ final class G2: NSObject, SGCManager {
|
|
|
5730
6621
|
handleEvenHubCtrlResponse(result.payload)
|
|
5731
6622
|
case ServiceID.notification.rawValue:
|
|
5732
6623
|
handleNotificationResponse(result.payload)
|
|
6624
|
+
case ServiceID.cfwStream.rawValue:
|
|
6625
|
+
break // ACK/NACK and cache replies are correlated inline in correlateCfwAck()
|
|
5733
6626
|
default:
|
|
5734
6627
|
Bridge.log(
|
|
5735
6628
|
"G2: Unhandled service \(result.serviceId) (\(result.payload.count) bytes): \(result.payload.map { String(format: "%02X", $0) }.joined())"
|
|
@@ -5810,18 +6703,18 @@ final class G2: NSObject, SGCManager {
|
|
|
5810
6703
|
"timestamp": now,
|
|
5811
6704
|
]
|
|
5812
6705
|
)
|
|
5813
|
-
Bridge.sendTypedMessage(
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
)
|
|
6706
|
+
// Bridge.sendTypedMessage(
|
|
6707
|
+
// "phone_notification",
|
|
6708
|
+
// body: [
|
|
6709
|
+
// "notificationId": "ancs-\(appID)-\(now)",
|
|
6710
|
+
// "app": name.isEmpty ? appID : name,
|
|
6711
|
+
// "title": "",
|
|
6712
|
+
// "content": "",
|
|
6713
|
+
// "priority": "0",
|
|
6714
|
+
// "timestamp": now,
|
|
6715
|
+
// "packageName": appID,
|
|
6716
|
+
// ]
|
|
6717
|
+
// )
|
|
5825
6718
|
}
|
|
5826
6719
|
}
|
|
5827
6720
|
}
|
|
@@ -5862,16 +6755,13 @@ final class G2: NSObject, SGCManager {
|
|
|
5862
6755
|
guard let compassData = fields[10] as? Data else { return }
|
|
5863
6756
|
var cReader = ProtobufReader(compassData)
|
|
5864
6757
|
let cFields = cReader.parseFields()
|
|
5865
|
-
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
"
|
|
5870
|
-
|
|
5871
|
-
|
|
5872
|
-
"timestamp": Int64(Date().timeIntervalSince1970 * 1000),
|
|
5873
|
-
]
|
|
5874
|
-
)
|
|
6758
|
+
// Proto3 omits the scalar for magnetic north (zero degrees).
|
|
6759
|
+
guard let heading = G2Sensors.heading(cFields) else { return }
|
|
6760
|
+
var body: [String: Any] = ["heading": Int(heading), "timestamp": Int64(Date().timeIntervalSince1970 * 1000)]
|
|
6761
|
+
if let data = fields[100] as? Data, let diagnostics = G2Sensors.compass(data) {
|
|
6762
|
+
body["diagnostics"] = diagnostics
|
|
6763
|
+
}
|
|
6764
|
+
Bridge.sendTypedMessage("compass_heading", body: body)
|
|
5875
6765
|
|
|
5876
6766
|
case NavigationCmd.osNotifyCompassCalibrateStart.rawValue:
|
|
5877
6767
|
Bridge.log("G2: compass calibration started — wearer should look around")
|
|
@@ -5912,7 +6802,7 @@ final class G2: NSObject, SGCManager {
|
|
|
5912
6802
|
}
|
|
5913
6803
|
}
|
|
5914
6804
|
|
|
5915
|
-
private func handleEvenHubResponse(_ payload: Data) {
|
|
6805
|
+
private func handleEvenHubResponse(_ payload: Data, arrival: G2InputArrival) {
|
|
5916
6806
|
// Parse evenhub_main_msg_ctx: field 1 = Cmd (varint), field 13 = DevEvent (submessage)
|
|
5917
6807
|
var reader = ProtobufReader(payload)
|
|
5918
6808
|
let fields = reader.parseFields()
|
|
@@ -5935,7 +6825,7 @@ final class G2: NSObject, SGCManager {
|
|
|
5935
6825
|
if cmdValue == EvenHubResponseCmd.osNotifyEventToApp.rawValue {
|
|
5936
6826
|
// Device event from glasses (touch/gesture or sensor data)
|
|
5937
6827
|
guard let devEventData = fields[13] as? Data else { return }
|
|
5938
|
-
handleTouchEvent(devEventData)
|
|
6828
|
+
handleTouchEvent(devEventData, arrival: arrival)
|
|
5939
6829
|
} else if cmdValue == 17 {
|
|
5940
6830
|
// Miniapp selection from glasses dashboard menu (cmdId=17)
|
|
5941
6831
|
// Dedup: L and R peripherals both deliver this event, so debounce or
|
|
@@ -6078,15 +6968,15 @@ final class G2: NSObject, SGCManager {
|
|
|
6078
6968
|
return (x, y, z)
|
|
6079
6969
|
}
|
|
6080
6970
|
|
|
6081
|
-
private func handleTouchEvent(_ devEventData: Data) {
|
|
6971
|
+
private func handleTouchEvent(_ devEventData: Data, arrival: G2InputArrival) {
|
|
6082
6972
|
// Parse SendDeviceEvent: field 1=ListEvent, field 2=TextEvent, field 3=SysEvent
|
|
6083
6973
|
var reader = ProtobufReader(devEventData)
|
|
6084
6974
|
let fields = reader.parseFields()
|
|
6085
6975
|
|
|
6086
|
-
let timestamp =
|
|
6976
|
+
let timestamp = arrival.timestamp
|
|
6087
6977
|
|
|
6088
6978
|
// Classify IMU reports before applying the click debounce. Keepalive samples arrive
|
|
6089
|
-
// continuously and must not update
|
|
6979
|
+
// continuously and must not update the debounce, or a real gesture delivered
|
|
6090
6980
|
// within 100 ms of a sample can be dropped.
|
|
6091
6981
|
var parsedSysFields: [Int: Any]?
|
|
6092
6982
|
if let sysData = fields[3] as? Data {
|
|
@@ -6094,13 +6984,14 @@ final class G2: NSObject, SGCManager {
|
|
|
6094
6984
|
let sysFields = sysReader.parseFields()
|
|
6095
6985
|
if (sysFields[1] as? Int32) == OsEventType.imuDataReport.rawValue {
|
|
6096
6986
|
lastImuReportTimestamp = timestamp
|
|
6987
|
+
displayDiagnostics.count("imuReceived")
|
|
6097
6988
|
let shouldForwardSamples =
|
|
6098
6989
|
DeviceStore.shared.get("bluetooth", "imu_enabled") as? Bool ?? false
|
|
6099
6990
|
if shouldForwardSamples,
|
|
6100
6991
|
let imuData = sysFields[3] as? Data,
|
|
6101
6992
|
let imu = parseImuReportData(imuData)
|
|
6102
6993
|
{
|
|
6103
|
-
|
|
6994
|
+
displayDiagnostics.count("imuSamples")
|
|
6104
6995
|
Bridge.sendAccelEvent(x: imu.x, y: imu.y, z: imu.z, timestamp: timestamp)
|
|
6105
6996
|
}
|
|
6106
6997
|
return
|
|
@@ -6108,20 +6999,31 @@ final class G2: NSObject, SGCManager {
|
|
|
6108
6999
|
parsedSysFields = sysFields
|
|
6109
7000
|
}
|
|
6110
7001
|
|
|
6111
|
-
// tap_start/tap_end bracket every gesture: the end edge lands within the
|
|
6112
|
-
// debounce window of its own start, and the click event fires right after
|
|
6113
|
-
// tap_end. Exempt the edges from the debounce (check AND update) or they
|
|
6114
|
-
// either get dropped or swallow the real gesture that follows.
|
|
6115
7002
|
let sysEventTypeRaw = parsedSysFields?[1] as? Int32
|
|
6116
|
-
let
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
7003
|
+
let ring = parsedSysFields.flatMap { G2RingInput(fields: $0) }
|
|
7004
|
+
let accepted = touchDebounce.accepts(event: sysEventTypeRaw, ring: ring, receivedAt: arrival.uptime)
|
|
7005
|
+
// One compact record per input (including dropped inputs), with native
|
|
7006
|
+
// receipt and processing times independent of delayed JS log delivery.
|
|
7007
|
+
var inputLog: [String: Any] = [
|
|
7008
|
+
"receivedAt": timestamp,
|
|
7009
|
+
"handledAt": Int64(Date().timeIntervalSince1970 * 1000),
|
|
7010
|
+
"dispatchMs": ((G2DisplayDiagnostics.now - arrival.uptime) * 1000).rounded(),
|
|
7011
|
+
"accepted": accepted,
|
|
7012
|
+
]
|
|
7013
|
+
inputLog["event"] = sysEventTypeRaw
|
|
7014
|
+
inputLog["source"] = parsedSysFields?[2] as? Int32
|
|
7015
|
+
if let ring {
|
|
7016
|
+
inputLog["wireType"] = Int(ring.wireType)
|
|
7017
|
+
inputLog["ringTick"] = Int64(ring.tick)
|
|
7018
|
+
}
|
|
7019
|
+
if let encoded = try? JSONSerialization.data(withJSONObject: inputLog, options: [.sortedKeys]),
|
|
7020
|
+
let line = String(data: encoded, encoding: .utf8)
|
|
7021
|
+
{
|
|
7022
|
+
Bridge.log("G2INPUT " + line)
|
|
7023
|
+
}
|
|
7024
|
+
if !accepted {
|
|
7025
|
+
displayDiagnostics.count("touchDebounced")
|
|
7026
|
+
return
|
|
6125
7027
|
}
|
|
6126
7028
|
|
|
6127
7029
|
// if we are receiving touch events we are fully booted:
|
|
@@ -6159,10 +7061,11 @@ final class G2: NSObject, SGCManager {
|
|
|
6159
7061
|
// whose own shutdown elicits the next exit: the 1-2s
|
|
6160
7062
|
// rebuild→systemExit→rebuild loop. Drop the event entirely — no
|
|
6161
7063
|
// state change, no touch-stream forward to miniapps.
|
|
7064
|
+
// (Only stock image ACKs count: SID-0xf0 replies need no page.)
|
|
6162
7065
|
if eventType == .systemExit || eventType == .abnormalExit, pageCreated {
|
|
6163
7066
|
let sinceAck = imgAckBox.msSinceLastSuccess()
|
|
6164
7067
|
if sinceAck < PHANTOM_EXIT_ACK_WINDOW_MS {
|
|
6165
|
-
Bridge.log("G2: \(eventType) ignored —
|
|
7068
|
+
Bridge.log("G2: \(eventType) ignored — a stock image acked \(sinceAck)ms ago (phantom exit)")
|
|
6166
7069
|
return
|
|
6167
7070
|
}
|
|
6168
7071
|
}
|
|
@@ -6177,7 +7080,6 @@ final class G2: NSObject, SGCManager {
|
|
|
6177
7080
|
timestamp: timestamp,
|
|
6178
7081
|
source: eventSource
|
|
6179
7082
|
)
|
|
6180
|
-
Bridge.log("G2: SysEvent → \(eventType) \(eventSource)")
|
|
6181
7083
|
|
|
6182
7084
|
if eventSource == 2 {
|
|
6183
7085
|
// controller must be connected and fully booted:
|
|
@@ -6252,6 +7154,7 @@ final class G2: NSObject, SGCManager {
|
|
|
6252
7154
|
case .tapStart: return "tap_start"
|
|
6253
7155
|
case .tapEnd: return "tap_end"
|
|
6254
7156
|
case .tapThenHold: return "tap_then_hold"
|
|
7157
|
+
case .ringTouchDown: return "touch_down"
|
|
6255
7158
|
case .imuDataReport: return nil
|
|
6256
7159
|
case .abnormalExit: return nil // don't report abnormal exits as gestures
|
|
6257
7160
|
}
|
|
@@ -6381,6 +7284,12 @@ final class G2: NSObject, SGCManager {
|
|
|
6381
7284
|
}
|
|
6382
7285
|
}
|
|
6383
7286
|
|
|
7287
|
+
private func publishRingBattery(_ battery: G2RingBattery) {
|
|
7288
|
+
DeviceStore.shared.apply("glasses", "ringConnected", battery.connected)
|
|
7289
|
+
DeviceStore.shared.apply("glasses", "ringBatteryLevel", battery.level)
|
|
7290
|
+
DeviceStore.shared.apply("glasses", "ringCharging", battery.charging)
|
|
7291
|
+
}
|
|
7292
|
+
|
|
6384
7293
|
private func handleG2SettingResponse(_ payload: Data) {
|
|
6385
7294
|
// Parse G2SettingPackage: field 1=commandId, field 4=DeviceReceiveRequestFromAPP (response), field 5=DeviceSendInfoToAPP
|
|
6386
7295
|
var reader = ProtobufReader(payload)
|
|
@@ -6401,6 +7310,28 @@ final class G2: NSObject, SGCManager {
|
|
|
6401
7310
|
if let sendData = fields[5] as? Data {
|
|
6402
7311
|
parseDeviceSendToApp(sendData)
|
|
6403
7312
|
}
|
|
7313
|
+
if let data = fields[105] as? Data, var reading = G2Sensors.light(data) {
|
|
7314
|
+
reading["timestamp"] = Int64(Date().timeIntervalSince1970 * 1000)
|
|
7315
|
+
Bridge.sendTypedMessage("light_sensor", body: reading)
|
|
7316
|
+
}
|
|
7317
|
+
if let data = fields[106] as? Data, let battery = G2RingBattery.decode(data) {
|
|
7318
|
+
publishRingBattery(battery)
|
|
7319
|
+
}
|
|
7320
|
+
if let record = fields[125] as? Data {
|
|
7321
|
+
handleAncsRelayRecord(record)
|
|
7322
|
+
}
|
|
7323
|
+
if let data = fields[129] as? Data, data.count >= 2 {
|
|
7324
|
+
Bridge.sendSceneSettled(tag: Int(data[data.startIndex]) | (Int(data[data.startIndex + 1]) << 8))
|
|
7325
|
+
}
|
|
7326
|
+
if let data = fields[128] as? Data, let status = G2BleLink.decodeStatus(data),
|
|
7327
|
+
status != lastBleLinkStatus
|
|
7328
|
+
{
|
|
7329
|
+
lastBleLinkStatus = status
|
|
7330
|
+
Bridge.log("G2: BLE link \(status.summary)")
|
|
7331
|
+
}
|
|
7332
|
+
if let data = fields[131] as? Data {
|
|
7333
|
+
keyboardBridge.accept(data)
|
|
7334
|
+
}
|
|
6404
7335
|
|
|
6405
7336
|
// Firmware identity is connection-sticky: firmware cannot change
|
|
6406
7337
|
// without a reboot/disconnect, while individual settings packets can
|
|
@@ -6424,6 +7355,8 @@ final class G2: NSObject, SGCManager {
|
|
|
6424
7355
|
let active = supportsCfwSceneRendering
|
|
6425
7356
|
DeviceStore.shared.apply("glasses", "sceneCanvasWidth", active ? 576 : 0)
|
|
6426
7357
|
DeviceStore.shared.apply("glasses", "sceneCanvasHeight", active ? 288 : 0)
|
|
7358
|
+
DeviceStore.shared.apply("glasses", "sceneVector", supportsCfwVectorScene)
|
|
7359
|
+
DeviceStore.shared.apply("glasses", "sceneSettle", supportsCfwSceneSettle)
|
|
6427
7360
|
}
|
|
6428
7361
|
|
|
6429
7362
|
private func updateCfwCapabilities(_ data: Data?, definitive: Bool) {
|
|
@@ -6434,19 +7367,20 @@ final class G2: NSObject, SGCManager {
|
|
|
6434
7367
|
field100: data, definitive: definitive, currentCapability: cfwCapabilityString
|
|
6435
7368
|
)
|
|
6436
7369
|
else { return }
|
|
6437
|
-
|
|
7370
|
+
let wasDifferent = result != cfwClassification
|
|
7371
|
+
cfwClassification = result
|
|
6438
7372
|
if result.isCfw {
|
|
6439
|
-
let wasDifferent = result.capabilityString != cfwCapabilityString
|
|
6440
|
-
cfwCapabilityString = result.capabilityString
|
|
6441
|
-
cfwFeatures = result.features
|
|
6442
|
-
cfwProtocolVersion = result.protocolVersion
|
|
6443
7373
|
if wasDifferent {
|
|
6444
|
-
Bridge.log("G2:
|
|
7374
|
+
Bridge.log("G2: CFW detected — \(result.capabilityString ?? "")")
|
|
6445
7375
|
rearmWakeLeaseIfWanted()
|
|
7376
|
+
startAncsRelay()
|
|
7377
|
+
syncBleLink()
|
|
7378
|
+
keyboardBridge.cfwDetected(revision: result.revision)
|
|
6446
7379
|
}
|
|
6447
7380
|
} else {
|
|
6448
|
-
|
|
6449
|
-
|
|
7381
|
+
publishRingBattery(.disconnected)
|
|
7382
|
+
stopAncsRelay(sendDisable: false)
|
|
7383
|
+
keyboardBridge.linkLost()
|
|
6450
7384
|
}
|
|
6451
7385
|
}
|
|
6452
7386
|
|
|
@@ -6465,8 +7399,8 @@ final class G2: NSObject, SGCManager {
|
|
|
6465
7399
|
DeviceStore.shared.get("glasses", "rightFirmwareVersion") as? String
|
|
6466
7400
|
?? DeviceStore.shared.get("glasses", "leftFirmwareVersion") as? String ?? ""
|
|
6467
7401
|
let displayVersion: String
|
|
6468
|
-
if
|
|
6469
|
-
displayVersion = baseVersion.isEmpty ?
|
|
7402
|
+
if let name = cfwClassification?.displayName {
|
|
7403
|
+
displayVersion = baseVersion.isEmpty ? name : "\(baseVersion) (\(name))"
|
|
6470
7404
|
} else {
|
|
6471
7405
|
displayVersion = baseVersion
|
|
6472
7406
|
}
|
|
@@ -6519,18 +7453,12 @@ final class G2: NSObject, SGCManager {
|
|
|
6519
7453
|
}
|
|
6520
7454
|
}
|
|
6521
7455
|
|
|
6522
|
-
private func handleMenuResponse(_
|
|
7456
|
+
private func handleMenuResponse(_: Data) {
|
|
6523
7457
|
// meun_main_msg_ctx response from glasses (ack of our menu send)
|
|
6524
7458
|
// (informational only)
|
|
6525
|
-
Bridge.log(
|
|
6526
|
-
"G2: menu response: \(data.prefix(32).map { String(format: "%02X", $0) }.joined())"
|
|
6527
|
-
)
|
|
6528
7459
|
}
|
|
6529
7460
|
|
|
6530
7461
|
private func handleDashboardResponse(_ payload: Data) {
|
|
6531
|
-
Bridge.log(
|
|
6532
|
-
"G2: dashboard response: \(payload.map { String(format: "%02X", $0) }.joined())"
|
|
6533
|
-
)
|
|
6534
7462
|
var reader = ProtobufReader(payload)
|
|
6535
7463
|
let fields = reader.parseFields()
|
|
6536
7464
|
let cmd = fields[1] as? Int32 ?? -1
|
|
@@ -6559,11 +7487,8 @@ final class G2: NSObject, SGCManager {
|
|
|
6559
7487
|
}
|
|
6560
7488
|
}
|
|
6561
7489
|
|
|
6562
|
-
private func handleEvenHubCtrlResponse(_
|
|
7490
|
+
private func handleEvenHubCtrlResponse(_: Data) {
|
|
6563
7491
|
// EvenHub CTRL channel response (informational only)
|
|
6564
|
-
Bridge.log(
|
|
6565
|
-
"G2: evenHubCtrl response: \(data.prefix(8).map { String(format: "%02X", $0) }.joined())"
|
|
6566
|
-
)
|
|
6567
7492
|
}
|
|
6568
7493
|
|
|
6569
7494
|
private func handleGestureCtrl(_ data: Data) async {
|
|
@@ -6776,6 +7701,7 @@ extension G2: CBCentralManagerDelegate {
|
|
|
6776
7701
|
let authorized = (leftPeripheral?.ancsAuthorized ?? false)
|
|
6777
7702
|
|| (rightPeripheral?.ancsAuthorized ?? false)
|
|
6778
7703
|
DeviceStore.shared.apply("glasses", "ancsAuthorized", authorized)
|
|
7704
|
+
publishNotificationStatus()
|
|
6779
7705
|
}
|
|
6780
7706
|
|
|
6781
7707
|
nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
@@ -6866,12 +7792,14 @@ extension G2: CBCentralManagerDelegate {
|
|
|
6866
7792
|
self.startupPageCreated = false
|
|
6867
7793
|
self.pageCreated = false
|
|
6868
7794
|
self.stockImageStreamWarmedUp = false
|
|
7795
|
+
self.cfwTransport = G2CfwTransport.Encoder()
|
|
7796
|
+
self.cfwStreamSequence = UInt8.random(in: 0 ... 255)
|
|
7797
|
+
self.cfwPageInstalled = false
|
|
6869
7798
|
self.dashboardShowing = 0
|
|
6870
7799
|
self.dashboardOpening = false
|
|
6871
|
-
self.
|
|
6872
|
-
self.
|
|
6873
|
-
self.
|
|
6874
|
-
self.cfwProtocolVersion = 0
|
|
7800
|
+
self.cancelSensorCommands()
|
|
7801
|
+
self.cfwClassification = nil
|
|
7802
|
+
self.publishRingBattery(.disconnected)
|
|
6875
7803
|
self.cfwSceneActive = false
|
|
6876
7804
|
// Fresh connection gets a fresh idle-resurrect budget.
|
|
6877
7805
|
self.cfwLastIdleResurrectAt = nil
|
|
@@ -6882,6 +7810,8 @@ extension G2: CBCentralManagerDelegate {
|
|
|
6882
7810
|
// after the reconnect.
|
|
6883
7811
|
self.stopFbLease(sendRelease: false) // link is gone; the leases fail open in 90 s
|
|
6884
7812
|
self.stopWakeLease(sendRelease: false) // wakeLeaseWanted persists; re-armed on CFW re-detection
|
|
7813
|
+
self.stopAncsRelay(sendDisable: false)
|
|
7814
|
+
self.keyboardBridge.linkLost()
|
|
6885
7815
|
DeviceStore.shared.apply("glasses", "connected", false)
|
|
6886
7816
|
DeviceStore.shared.apply("glasses", "fullyBooted", false)
|
|
6887
7817
|
|
|
@@ -6938,26 +7868,6 @@ extension G2: CBPeripheralDelegate {
|
|
|
6938
7868
|
|
|
6939
7869
|
for char in characteristics {
|
|
6940
7870
|
let uuid = char.uuid
|
|
6941
|
-
let props = char.properties
|
|
6942
|
-
|
|
6943
|
-
// Log all characteristics with their properties for debugging
|
|
6944
|
-
var propStr: [String] = []
|
|
6945
|
-
if props.contains(.read) {
|
|
6946
|
-
propStr.append("read")
|
|
6947
|
-
}
|
|
6948
|
-
if props.contains(.write) {
|
|
6949
|
-
propStr.append("write")
|
|
6950
|
-
}
|
|
6951
|
-
if props.contains(.writeWithoutResponse) {
|
|
6952
|
-
propStr.append("writeNoResp")
|
|
6953
|
-
}
|
|
6954
|
-
if props.contains(.notify) {
|
|
6955
|
-
propStr.append("notify")
|
|
6956
|
-
}
|
|
6957
|
-
if props.contains(.indicate) {
|
|
6958
|
-
propStr.append("indicate")
|
|
6959
|
-
}
|
|
6960
|
-
Bridge.log("G2: \(side) char \(uuid) props=[\(propStr.joined(separator: ","))]")
|
|
6961
7871
|
|
|
6962
7872
|
if uuid == G2BLE.CHAR_WRITE {
|
|
6963
7873
|
Bridge.log("G2: Found WRITE char on \(side)")
|
|
@@ -7025,6 +7935,7 @@ extension G2: CBPeripheralDelegate {
|
|
|
7025
7935
|
error: Error?
|
|
7026
7936
|
) {
|
|
7027
7937
|
guard let data = characteristic.value, error == nil else { return }
|
|
7938
|
+
let arrival = G2InputArrival()
|
|
7028
7939
|
|
|
7029
7940
|
if characteristic.uuid == G2Flasher.dataNotifyUUID {
|
|
7030
7941
|
firmwareFlasher.receive(
|
|
@@ -7049,21 +7960,28 @@ extension G2: CBPeripheralDelegate {
|
|
|
7049
7960
|
// intermittent "glasses stopped responding to image sends". See [ImgAckBox].
|
|
7050
7961
|
if characteristic.uuid == G2BLE.CHAR_NOTIFY {
|
|
7051
7962
|
correlateImageAck(data)
|
|
7963
|
+
correlateCfwAck(data)
|
|
7052
7964
|
}
|
|
7053
7965
|
|
|
7054
|
-
|
|
7055
|
-
|
|
7056
|
-
|
|
7057
|
-
|
|
7058
|
-
|
|
7059
|
-
|
|
7060
|
-
|
|
7061
|
-
|
|
7062
|
-
|
|
7063
|
-
|
|
7064
|
-
|
|
7065
|
-
|
|
7066
|
-
|
|
7966
|
+
if characteristic.uuid == G2BLE.AUDIO_NOTIFY {
|
|
7967
|
+
Task { @MainActor [weak self] in
|
|
7968
|
+
await self?.handleAudioData(data)
|
|
7969
|
+
}
|
|
7970
|
+
return
|
|
7971
|
+
}
|
|
7972
|
+
let isProtocol = characteristic.uuid == G2BLE.CHAR_NOTIFY
|
|
7973
|
+
let isFile = characteristic.uuid == G2BLE.FILE_NOTIFY
|
|
7974
|
+
let peripheralID = peripheral.identifier
|
|
7975
|
+
// Preserve the serial BLE callback order through protobuf reassembly
|
|
7976
|
+
// and gesture dispatch. Independent Tasks need not start FIFO.
|
|
7977
|
+
DispatchQueue.main.async { [weak self] in
|
|
7978
|
+
guard let self else { return }
|
|
7979
|
+
let side = peripheralID == self.leftPeripheral?.identifier ? "L" : "R"
|
|
7980
|
+
if isProtocol {
|
|
7981
|
+
self.displayDiagnostics.timing("notifyDispatchMs", (G2DisplayDiagnostics.now - arrival.uptime) * 1000)
|
|
7982
|
+
self.handleNotifyData(data, sourceKey: side, arrival: arrival)
|
|
7983
|
+
} else if isFile {
|
|
7984
|
+
// File-transfer responses (CACHE_HIT/MISS, transfer status).
|
|
7067
7985
|
Bridge.log(
|
|
7068
7986
|
"G2: FILE notify (\(side)): \(data.map { String(format: "%02X", $0) }.joined())"
|
|
7069
7987
|
)
|
|
@@ -7102,20 +8020,25 @@ extension G2: CBPeripheralDelegate {
|
|
|
7102
8020
|
var reader = ProtobufReader(payload)
|
|
7103
8021
|
let fields = reader.parseFields()
|
|
7104
8022
|
guard let resData = fields[6] as? Data else { return } // field 6 = ImgResCmd
|
|
7105
|
-
|
|
7106
|
-
let resFields = resReader.parseFields()
|
|
7107
|
-
guard let errorCode = resFields[8] as? Int32,
|
|
7108
|
-
let ackSession = resFields[3] as? Int32
|
|
7109
|
-
else { return }
|
|
7110
|
-
let ackFragment = (resFields[6] as? Int32) ?? 0
|
|
7111
|
-
Bridge.log(
|
|
7112
|
-
"G2: img_res: session=\(ackSession) fragment=\(ackFragment) errorCode=\(errorCode) success=\(errorCode == 4)"
|
|
7113
|
-
)
|
|
8023
|
+
guard let ack = ImgAckBox.decodeResult(resData) else { return }
|
|
7114
8024
|
completeImageAck(
|
|
7115
|
-
session:
|
|
8025
|
+
session: ack.session, fragmentIndex: ack.fragment, success: ack.success
|
|
7116
8026
|
)
|
|
7117
8027
|
}
|
|
7118
8028
|
|
|
8029
|
+
/// Read-only, SINGLE-PACKET parse of a raw notify frame that resolves an in-flight SID-0xf0
|
|
8030
|
+
/// ACK/NACK directly (off the main actor). Replies always fit one packet. Idempotent via
|
|
8031
|
+
/// [G2CfwAckBox]; the main-thread path ignores the service.
|
|
8032
|
+
nonisolated func correlateCfwAck(_ rawData: Data) {
|
|
8033
|
+
guard let body = G2CfwTransport.replyBody(fromNotifyFrame: rawData) else { return }
|
|
8034
|
+
if let reply = G2CfwTransport.parseCacheReply(body) {
|
|
8035
|
+
cfwAckBox.record(reply)
|
|
8036
|
+
return
|
|
8037
|
+
}
|
|
8038
|
+
guard let ack = G2CfwTransport.parseAck(body) else { return }
|
|
8039
|
+
cfwAckBox.resolve(ack)
|
|
8040
|
+
}
|
|
8041
|
+
|
|
7119
8042
|
nonisolated func peripheral(
|
|
7120
8043
|
_: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?
|
|
7121
8044
|
) {
|
|
@@ -7133,8 +8056,10 @@ extension G2: CBPeripheralDelegate {
|
|
|
7133
8056
|
Task { @MainActor [weak self] in
|
|
7134
8057
|
guard let self = self else { return }
|
|
7135
8058
|
if peripheral === self.rightPeripheral {
|
|
8059
|
+
self.displayDiagnostics.count("readyCallbacksR")
|
|
7136
8060
|
self.startDrain(right: true)
|
|
7137
8061
|
} else if peripheral === self.leftPeripheral {
|
|
8062
|
+
self.displayDiagnostics.count("readyCallbacksL")
|
|
7138
8063
|
self.startDrain(right: false)
|
|
7139
8064
|
}
|
|
7140
8065
|
}
|