@glassly/bluetooth-sdk 0.1.0-dev.98 → 0.2.8-dev.444
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 +1075 -257
- 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 +1434 -504
- 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
|
|
@@ -125,14 +126,18 @@ private enum OsEventType: Int32 {
|
|
|
125
126
|
case abnormalExit = 6
|
|
126
127
|
case systemExit = 7
|
|
127
128
|
case imuDataReport = 8 // IMU_DATA_REPORT — Sys_ItemEvent carries imuData
|
|
128
|
-
// 9/10 are
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
129
|
+
// 9/10 are the SDK 0.0.14 LONG_PRESS/RELEASE pair. The 2.2.9 CFW forwards
|
|
130
|
+
// them (ET_LONG/ET_REL) while our fbguard lease is active; the gesture
|
|
131
|
+
// names stay tap_start/tap_end for API stability.
|
|
132
|
+
case tapStart = 9 // press recognized (hold start)
|
|
133
|
+
case tapEnd = 10 // released
|
|
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
|
|
136
141
|
}
|
|
137
142
|
|
|
138
143
|
/// g2_settingCommandId from g2_setting.proto
|
|
@@ -479,7 +484,7 @@ private enum EvenHubProto {
|
|
|
479
484
|
borderWidth: Int32 = 0, borderColor: Int32 = 0, borderRadius: Int32 = 0,
|
|
480
485
|
paddingLength: Int32 = 0, containerID: Int32,
|
|
481
486
|
containerName: String? = nil, isEventCapture: Bool = false,
|
|
482
|
-
content: String? = nil
|
|
487
|
+
content: String? = nil, textColor: Int32? = nil
|
|
483
488
|
) -> Data {
|
|
484
489
|
var w = ProtobufWriter()
|
|
485
490
|
w.writeInt32Field(1, x) // XPosition
|
|
@@ -498,6 +503,12 @@ private enum EvenHubProto {
|
|
|
498
503
|
if let content = content {
|
|
499
504
|
w.writeStringField(12, content) // Content
|
|
500
505
|
}
|
|
506
|
+
if let textColor = textColor {
|
|
507
|
+
// SDK 0.0.14 text brightness level 0-4 (4 = default). Field number
|
|
508
|
+
// is a guess: our proto dump is 2.1.0-era (last field 12) and the
|
|
509
|
+
// 2.2.9 firmware's tag isn't extracted — verify on-device.
|
|
510
|
+
w.writeInt32Field(13, textColor) // TextColor (unverified)
|
|
511
|
+
}
|
|
501
512
|
return w.data
|
|
502
513
|
}
|
|
503
514
|
|
|
@@ -1586,14 +1597,67 @@ private class G2ReceiveManager {
|
|
|
1586
1597
|
/// All access is guarded by `lock`. Resuming a `CheckedContinuation` from any thread is safe, and
|
|
1587
1598
|
/// `arm`/`resolve` clear the slot atomically so neither the duplicate L/R ACK nor the timeout can
|
|
1588
1599
|
/// resume the same continuation twice.
|
|
1589
|
-
|
|
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
|
+
|
|
1590
1617
|
private let lock = NSLock()
|
|
1591
1618
|
private var session: Int?
|
|
1592
1619
|
private var fragment: Int32?
|
|
1593
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>?
|
|
1594
1627
|
/// Wall-clock ms of the last successful fragment ack — a cheap page-liveness
|
|
1595
1628
|
/// signal (a torn-down page cannot ack image transfers).
|
|
1596
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
|
+
}
|
|
1597
1661
|
|
|
1598
1662
|
/// Milliseconds since the last successful ack, or Int64.max if none yet.
|
|
1599
1663
|
func msSinceLastSuccess() -> Int64 {
|
|
@@ -1604,13 +1668,49 @@ private final class ImgAckBox {
|
|
|
1604
1668
|
return Int64(Date().timeIntervalSince1970 * 1000) - last
|
|
1605
1669
|
}
|
|
1606
1670
|
|
|
1607
|
-
/// Install the awaiting continuation for `(session, fragment)
|
|
1608
|
-
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 {
|
|
1609
1673
|
lock.lock()
|
|
1674
|
+
token &+= 1
|
|
1675
|
+
let t = token
|
|
1610
1676
|
self.session = session
|
|
1611
1677
|
self.fragment = fragment
|
|
1612
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")
|
|
1613
1712
|
lock.unlock()
|
|
1713
|
+
c.resume(returning: false)
|
|
1614
1714
|
}
|
|
1615
1715
|
|
|
1616
1716
|
/// Resume the continuation if it matches `(session, fragment)`. Returns true if it fired.
|
|
@@ -1624,18 +1724,17 @@ private final class ImgAckBox {
|
|
|
1624
1724
|
self.session = nil
|
|
1625
1725
|
self.fragment = nil
|
|
1626
1726
|
cont = nil
|
|
1727
|
+
lastOutcome = (ProcessInfo.processInfo.systemUptime, success ? "ack" : "nack")
|
|
1728
|
+
let t = timer
|
|
1729
|
+
timer = nil
|
|
1627
1730
|
if success {
|
|
1628
1731
|
lastSuccessMs = Int64(Date().timeIntervalSince1970 * 1000)
|
|
1629
1732
|
}
|
|
1630
1733
|
lock.unlock()
|
|
1734
|
+
t?.cancel()
|
|
1631
1735
|
c.resume(returning: success)
|
|
1632
1736
|
return true
|
|
1633
1737
|
}
|
|
1634
|
-
|
|
1635
|
-
/// Time out the continuation for `(session, fragment)` (resumes false if still armed).
|
|
1636
|
-
func timeout(session: Int, fragment: Int32) {
|
|
1637
|
-
_ = resolve(session: session, fragment: fragment, success: false)
|
|
1638
|
-
}
|
|
1639
1738
|
}
|
|
1640
1739
|
|
|
1641
1740
|
// MARK: - G2 Class (SGCManager implementation)
|
|
@@ -1704,31 +1803,104 @@ final class G2: NSObject, SGCManager {
|
|
|
1704
1803
|
let hasMic = true
|
|
1705
1804
|
nonisolated let firmwareFlasher = G2Flasher()
|
|
1706
1805
|
|
|
1707
|
-
///
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
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
|
+
|
|
1717
1851
|
private var cfwSceneActive = false
|
|
1718
1852
|
/// Rate limit on resurrecting the page for a blank scene; a non-blank one rebuilds immediately.
|
|
1719
1853
|
private var cfwLastIdleResurrectAt: Date?
|
|
1720
1854
|
private static let cfwIdleResurrectMinIntervalMs: Double = 5000
|
|
1721
|
-
private static let cfwSceneContainerID: Int32 = 10
|
|
1722
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
|
|
1723
1867
|
/// Deltas composite onto a shadow seeded by a mode-6 keyframe, so payloads drain in order.
|
|
1724
1868
|
/// Kinds tag that chain: only `.full` may drop queued fulls/deltas; `.seed`/`.moveBundle` survive.
|
|
1725
|
-
private enum CfwPayloadKind { case seed, full, delta, moveBundle, upload }
|
|
1869
|
+
private enum CfwPayloadKind { case seed, full, delta, moveBundle, upload, scene }
|
|
1726
1870
|
private struct CfwPayload {
|
|
1727
1871
|
let kind: CfwPayloadKind
|
|
1728
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
|
|
1729
1880
|
}
|
|
1730
1881
|
|
|
1731
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
|
|
1732
1904
|
private var cfwShadowSeeded = false
|
|
1733
1905
|
private var cfwLastFrame: SceneFrame?
|
|
1734
1906
|
private var cfwLastBoxes: G2CfwRenderer.LensBoxes?
|
|
@@ -1764,40 +1936,62 @@ final class G2: NSObject, SGCManager {
|
|
|
1764
1936
|
/// the CFW capability string is seen again.
|
|
1765
1937
|
private var wakeLeaseWanted = false
|
|
1766
1938
|
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
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
|
|
1770
1968
|
// Escape hatch (default ON): OFF falls back to stock containers. Reconnect to switch cleanly.
|
|
1771
1969
|
&& (DeviceStore.shared.get("bluetooth", "cfw_scene_renderer") as? Bool ?? true)
|
|
1772
1970
|
}
|
|
1773
1971
|
|
|
1774
|
-
private var
|
|
1775
|
-
|
|
1776
|
-
&& imageContainers[0].id == Self.cfwSceneContainerID
|
|
1777
|
-
&& imageContainers[0].x == 0 && imageContainers[0].y == 0
|
|
1778
|
-
&& imageContainers[0].width == 576 && imageContainers[0].height == 288
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
/// Mode-9 rect copies have no capability token; they shipped with
|
|
1782
|
-
/// protocol revision 15 alongside the texture/font modes.
|
|
1783
|
-
private var supportsCfwRectCopy: Bool {
|
|
1784
|
-
cfwProtocolVersion >= 15
|
|
1972
|
+
private var hasCfwPage: Bool {
|
|
1973
|
+
cfwPageInstalled && textContainers.isEmpty && imageContainers.isEmpty
|
|
1785
1974
|
}
|
|
1786
1975
|
|
|
1787
|
-
///
|
|
1788
|
-
private var
|
|
1789
|
-
|
|
1976
|
+
/// Glassly adds retained vector scenes and animation to upstream's raster protocol.
|
|
1977
|
+
private var supportsCfwVectorScene: Bool {
|
|
1978
|
+
supportsCfwSceneRendering && cfwClassification?.isGlassly == true
|
|
1790
1979
|
}
|
|
1791
1980
|
|
|
1792
|
-
///
|
|
1793
|
-
private var
|
|
1794
|
-
cfwFeatures.contains("texcache12") && cfwFeatures.contains("texstr14")
|
|
1795
|
-
}
|
|
1981
|
+
/// Tagged commits report settings field 129 once the scene has settled.
|
|
1982
|
+
private var supportsCfwSceneSettle: Bool { supportsCfwVectorScene }
|
|
1796
1983
|
|
|
1797
|
-
///
|
|
1798
|
-
private var
|
|
1799
|
-
|
|
1800
|
-
|
|
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
|
|
1801
1995
|
|
|
1802
1996
|
/// Connection state
|
|
1803
1997
|
private var connectionState: String = ConnTypes.DISCONNECTED
|
|
@@ -1837,6 +2031,19 @@ final class G2: NSObject, SGCManager {
|
|
|
1837
2031
|
private var recoveryInFlight = false
|
|
1838
2032
|
private var lastRecoveryRebuildMs: Int64 = 0
|
|
1839
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
|
|
1840
2047
|
// A systemExit that arrives while the page acked a carrier fragment this
|
|
1841
2048
|
// recently is a phantom (the firmware emits exits even for healthy pages)
|
|
1842
2049
|
// and is dropped entirely — see the SysEvent handler.
|
|
@@ -1965,7 +2172,7 @@ final class G2: NSObject, SGCManager {
|
|
|
1965
2172
|
/// Current appId to associate EvenHub pages with (enables menu selection events)
|
|
1966
2173
|
/// Set to the first menu item's appId so glasses know our page belongs to the menu
|
|
1967
2174
|
private var activeMenuAppId: Int32?
|
|
1968
|
-
private var
|
|
2175
|
+
private var touchDebounce = G2TouchDebounce()
|
|
1969
2176
|
private var lastEvenHubResponseTimestamp: Int64?
|
|
1970
2177
|
private var lastMenuSelectTimestamp: Int64?
|
|
1971
2178
|
private var lastGestureCtrlTimestamp: Int64?
|
|
@@ -1977,11 +2184,7 @@ final class G2: NSObject, SGCManager {
|
|
|
1977
2184
|
let y: Int32
|
|
1978
2185
|
let width: Int32
|
|
1979
2186
|
let height: Int32
|
|
1980
|
-
var name: String {
|
|
1981
|
-
// The known-good CFW packet names its carrier "img00"; match it byte-for-byte
|
|
1982
|
-
// (the name appears in both the page create and every image send).
|
|
1983
|
-
id == G2.cfwSceneContainerID ? "img00" : "img-\(id)"
|
|
1984
|
-
}
|
|
2187
|
+
var name: String { "img-\(id)" }
|
|
1985
2188
|
|
|
1986
2189
|
var bmpData: Data
|
|
1987
2190
|
/// Set true when `bmpData` changes and the new pixels haven't been pushed to the glasses yet.
|
|
@@ -2092,39 +2295,34 @@ final class G2: NSObject, SGCManager {
|
|
|
2092
2295
|
|
|
2093
2296
|
// MARK: - BLE Sending
|
|
2094
2297
|
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
private var
|
|
2108
|
-
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] = []
|
|
2109
2311
|
private var leftDraining = false
|
|
2110
2312
|
private var rightDraining = false
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
private let writePaceNanos: UInt64 = 6_000_000
|
|
2114
|
-
// Diagnostic: warn if a side's queue ever backs up (it shouldn't now — the drainer is always
|
|
2115
|
-
// making progress). Rate-limited. Prefixed "BGCAP:" so it's easy to grep/strip after validation.
|
|
2116
|
-
private var bgcapDepthLogAt: Double = 0
|
|
2117
|
-
|
|
2118
|
-
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) {
|
|
2119
2315
|
// OTA owns both links exclusively, including its short setup/teardown
|
|
2120
2316
|
// windows. Normal display/settings/navigation traffic must not interleave.
|
|
2121
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) }
|
|
2122
2320
|
if right {
|
|
2123
|
-
rightWriteQueue.append(contentsOf:
|
|
2321
|
+
rightWriteQueue.append(contentsOf: writes)
|
|
2124
2322
|
startDrain(right: true)
|
|
2125
2323
|
}
|
|
2126
2324
|
if left {
|
|
2127
|
-
leftWriteQueue.append(contentsOf:
|
|
2325
|
+
leftWriteQueue.append(contentsOf: writes)
|
|
2128
2326
|
startDrain(right: false)
|
|
2129
2327
|
}
|
|
2130
2328
|
}
|
|
@@ -2148,10 +2346,9 @@ final class G2: NSObject, SGCManager {
|
|
|
2148
2346
|
}
|
|
2149
2347
|
}
|
|
2150
2348
|
|
|
2151
|
-
/// Drain
|
|
2152
|
-
/// sleep. No `canSend` gate (see note above). Runs until the queue is empty, then clears the
|
|
2153
|
-
/// per-side flag so the next enqueue restarts it.
|
|
2349
|
+
/// Drain until empty, yielding after each CFW burst so UI/ACK work can run.
|
|
2154
2350
|
private func drainLoop(right: Bool) async {
|
|
2351
|
+
var burstCount = 0
|
|
2155
2352
|
while true {
|
|
2156
2353
|
guard let peripheral = right ? rightPeripheral : leftPeripheral,
|
|
2157
2354
|
let char = right ? rightWriteChar : leftWriteChar
|
|
@@ -2173,26 +2370,64 @@ final class G2: NSObject, SGCManager {
|
|
|
2173
2370
|
}
|
|
2174
2371
|
return
|
|
2175
2372
|
}
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
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()
|
|
2181
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
|
|
2182
2397
|
}
|
|
2183
2398
|
let packet = right ? rightWriteQueue.removeFirst() : leftWriteQueue.removeFirst()
|
|
2184
|
-
|
|
2185
|
-
|
|
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
|
+
}
|
|
2186
2421
|
}
|
|
2187
2422
|
}
|
|
2188
2423
|
|
|
2189
|
-
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) {
|
|
2190
2425
|
let packets = sendManager.buildPackets(
|
|
2191
2426
|
serviceId: ServiceID.evenHub.rawValue,
|
|
2192
2427
|
payload: payload,
|
|
2193
2428
|
reserveFlag: true
|
|
2194
2429
|
)
|
|
2195
|
-
sendToGlasses(packets, left: left, right: right)
|
|
2430
|
+
sendToGlasses(packets, left: left, right: right, trace: trace)
|
|
2196
2431
|
}
|
|
2197
2432
|
|
|
2198
2433
|
private func sendDevSettingsCommand(_ payload: Data, left: Bool = false, right: Bool = true) {
|
|
@@ -2454,11 +2689,10 @@ final class G2: NSObject, SGCManager {
|
|
|
2454
2689
|
DeviceStore.shared.get("bluetooth", "calendar_events") as? [[String: Any]] ?? []
|
|
2455
2690
|
sendCalendarEvents(calendarEvents)
|
|
2456
2691
|
|
|
2457
|
-
// push
|
|
2458
|
-
//
|
|
2692
|
+
// re-push the stored notification config and surface the current ANCS
|
|
2693
|
+
// authorization state to the UI
|
|
2459
2694
|
publishAncsAuthorization()
|
|
2460
|
-
|
|
2461
|
-
sendNotificationFilter()
|
|
2695
|
+
reapplyNotificationConfig()
|
|
2462
2696
|
}
|
|
2463
2697
|
|
|
2464
2698
|
// MARK: - Heartbeats
|
|
@@ -2496,6 +2730,7 @@ final class G2: NSObject, SGCManager {
|
|
|
2496
2730
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
2497
2731
|
guard !Task.isCancelled else { break }
|
|
2498
2732
|
self?.displayDirtySignal?.yield()
|
|
2733
|
+
self?.logDisplayDiagnostics()
|
|
2499
2734
|
}
|
|
2500
2735
|
}
|
|
2501
2736
|
displayReconcileTask = Task { [weak self] in
|
|
@@ -2526,6 +2761,32 @@ final class G2: NSObject, SGCManager {
|
|
|
2526
2761
|
displayDirtySignal?.yield()
|
|
2527
2762
|
}
|
|
2528
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
|
+
|
|
2529
2790
|
private func sendEvenHubHeartbeat() {
|
|
2530
2791
|
let isFullyBooted = DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
|
|
2531
2792
|
guard isFullyBooted else { return }
|
|
@@ -2565,24 +2826,67 @@ final class G2: NSObject, SGCManager {
|
|
|
2565
2826
|
setDashboardMenu(menuItems)
|
|
2566
2827
|
}
|
|
2567
2828
|
|
|
2568
|
-
// MARK: - Notifications (
|
|
2829
|
+
// MARK: - Notifications (SGCManager)
|
|
2830
|
+
|
|
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
|
+
}
|
|
2569
2873
|
|
|
2570
|
-
///
|
|
2571
|
-
/// duration, avoid-disturb) from the DeviceStore `notification_ctrl` dict.
|
|
2572
|
-
/// Full state is retransmitted on every change — that matches the stock app
|
|
2874
|
+
/// Full state is retransmitted on every apply — that matches the stock app
|
|
2573
2875
|
/// and the glasses echo it back as the ack.
|
|
2574
|
-
func
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2876
|
+
private func applyNotificationConfig(_ config: GlassesNotificationConfig) {
|
|
2877
|
+
if config == lastAppliedNotificationConfig {
|
|
2878
|
+
return
|
|
2879
|
+
}
|
|
2880
|
+
lastAppliedNotificationConfig = config
|
|
2881
|
+
notificationPhase = "applying"
|
|
2882
|
+
publishNotificationStatus()
|
|
2580
2883
|
|
|
2884
|
+
let dispTime = G2NotificationTuning.snapPopupSeconds(config.popupSeconds)
|
|
2581
2885
|
var ctrlW = ProtobufWriter()
|
|
2582
|
-
ctrlW.writeInt32Field(1, enabled ? 1 : 0) // notifEnable
|
|
2583
|
-
ctrlW.writeInt32Field(2,
|
|
2584
|
-
ctrlW.writeInt32Field(3,
|
|
2585
|
-
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
|
|
2586
2890
|
|
|
2587
2891
|
var pkg = ProtobufWriter()
|
|
2588
2892
|
pkg.writeInt32Field(1, NotificationCmd.ctrl.rawValue)
|
|
@@ -2590,33 +2894,45 @@ final class G2: NSObject, SGCManager {
|
|
|
2590
2894
|
pkg.writeMessageField(3, ctrlW.data)
|
|
2591
2895
|
sendNotificationCommand(pkg.data)
|
|
2592
2896
|
Bridge.log(
|
|
2593
|
-
"G2: Sent notification ctrl enabled=\(enabled)
|
|
2897
|
+
"G2: Sent notification ctrl enabled=\(config.enabled) popups=\(config.popups) dispTime=\(dispTime) hold=\(config.holdDuringApps)"
|
|
2594
2898
|
)
|
|
2595
|
-
|
|
2899
|
+
syncAncsRelay()
|
|
2596
2900
|
|
|
2597
|
-
/// Push the ANCS app allowlist from the DeviceStore `notification_filter`
|
|
2598
|
-
/// dict ({enabled, apps: [{id, name}]}). The list travels as the
|
|
2599
|
-
/// `user/notify_whitelist.json` file (the protobuf whitelist message only
|
|
2600
|
-
/// carries the enable toggle); the WHITELIST_CTRL toggle goes after the
|
|
2601
|
-
/// file so the firmware reloads with the new list in place.
|
|
2602
|
-
func sendNotificationFilter() {
|
|
2603
|
-
let filter = DeviceStore.shared.get("bluetooth", "notification_filter") as? [String: Any] ?? [:]
|
|
2604
|
-
let enabled = filter["enabled"] as? Bool ?? false
|
|
2605
|
-
let apps = filter["apps"] as? [[String: Any]] ?? []
|
|
2606
2901
|
Task { [weak self] in
|
|
2607
|
-
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
|
+
}
|
|
2608
2912
|
}
|
|
2609
2913
|
}
|
|
2610
2914
|
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
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) }
|
|
2616
2923
|
// The file is written even when filtering is off so a later enable
|
|
2617
2924
|
// toggles against the user's current list, not a stale one.
|
|
2618
2925
|
let json = G2NotifyFile.whitelistJson(filterEnabled: enabled, apps: entries)
|
|
2619
|
-
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
|
+
}
|
|
2620
2936
|
|
|
2621
2937
|
var wlW = ProtobufWriter()
|
|
2622
2938
|
wlW.writeInt32Field(1, enabled ? 0 : 1) // whitelistDisable (disabled = fail-open)
|
|
@@ -2626,20 +2942,37 @@ final class G2: NSObject, SGCManager {
|
|
|
2626
2942
|
pkg.writeMessageField(6, wlW.data)
|
|
2627
2943
|
sendNotificationCommand(pkg.data)
|
|
2628
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
|
+
)
|
|
2629
2952
|
}
|
|
2630
2953
|
|
|
2631
2954
|
/// File-transfer push (services 0xC4/0xC5 on the dedicated file
|
|
2632
2955
|
/// characteristic): FILE_CHECK header → START → DATA → END, per arm. The
|
|
2633
2956
|
/// glasses cache by checksum but still expect the data packets either way,
|
|
2634
2957
|
/// so no branching on the CACHE_HIT/MISS response is needed.
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
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
|
+
)
|
|
2638
2968
|
var pushedSides: [String] = []
|
|
2969
|
+
var skippedArm = false
|
|
2639
2970
|
for right in [true, false] {
|
|
2640
|
-
guard let peripheral = right ? rightPeripheral : leftPeripheral
|
|
2641
|
-
|
|
2642
|
-
|
|
2971
|
+
guard let peripheral = right ? rightPeripheral : leftPeripheral else { continue }
|
|
2972
|
+
guard let char = right ? rightFileWriteChar : leftFileWriteChar else {
|
|
2973
|
+
skippedArm = true
|
|
2974
|
+
continue
|
|
2975
|
+
}
|
|
2643
2976
|
let writeType: CBCharacteristicWriteType =
|
|
2644
2977
|
char.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse
|
|
2645
2978
|
|
|
@@ -2663,6 +2996,7 @@ final class G2: NSObject, SGCManager {
|
|
|
2663
2996
|
Bridge.log(
|
|
2664
2997
|
"G2: Pushed \(filename) (\(content.count) bytes) to [\(pushedSides.joined(separator: ","))]"
|
|
2665
2998
|
)
|
|
2999
|
+
return !pushedSides.isEmpty && !skippedArm
|
|
2666
3000
|
}
|
|
2667
3001
|
|
|
2668
3002
|
// MARK: - SGCManager: Display Control
|
|
@@ -2705,9 +3039,7 @@ final class G2: NSObject, SGCManager {
|
|
|
2705
3039
|
// Bridge.log("G2: sendTextWall(\(text.prefix(50))...)")
|
|
2706
3040
|
|
|
2707
3041
|
// ignore events while the ER dashboard is open:
|
|
2708
|
-
|
|
2709
|
-
DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
|
|
2710
|
-
if useNativeDashboard && dashboardShowing > 0 {
|
|
3042
|
+
if dashboardShowing > 0 {
|
|
2711
3043
|
return
|
|
2712
3044
|
}
|
|
2713
3045
|
|
|
@@ -2731,7 +3063,7 @@ final class G2: NSObject, SGCManager {
|
|
|
2731
3063
|
if supportsCfwSceneRendering {
|
|
2732
3064
|
if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
2733
3065
|
// A blank wall is the idle state: keep the carrier alive for touch and mic.
|
|
2734
|
-
let carrierWasConfigured =
|
|
3066
|
+
let carrierWasConfigured = installCfwPageIfNeeded()
|
|
2735
3067
|
cfwClear()
|
|
2736
3068
|
if !carrierWasConfigured, pageCreated {
|
|
2737
3069
|
// Carrier just replaced live stock containers (see clearDisplay).
|
|
@@ -2740,7 +3072,7 @@ final class G2: NSObject, SGCManager {
|
|
|
2740
3072
|
return
|
|
2741
3073
|
}
|
|
2742
3074
|
let frame = SceneFrame(
|
|
2743
|
-
appId: "legacy-text", epoch: 0, replay:
|
|
3075
|
+
appId: "legacy-text", epoch: 0, replay: false,
|
|
2744
3076
|
elements: [
|
|
2745
3077
|
SceneElement(
|
|
2746
3078
|
id: "legacy-text", type: "text", x: rx, y: ry, w: rw, h: rh,
|
|
@@ -2780,9 +3112,6 @@ final class G2: NSObject, SGCManager {
|
|
|
2780
3112
|
)
|
|
2781
3113
|
return
|
|
2782
3114
|
}
|
|
2783
|
-
Bridge.log(
|
|
2784
|
-
"G2: sendText() - reusing container \(container.id) for rect \(rx),\(ry) \(rw)x\(rh)"
|
|
2785
|
-
)
|
|
2786
3115
|
return
|
|
2787
3116
|
}
|
|
2788
3117
|
|
|
@@ -2803,7 +3132,6 @@ final class G2: NSObject, SGCManager {
|
|
|
2803
3132
|
}
|
|
2804
3133
|
|
|
2805
3134
|
func sendDoubleTextWall(_ top: String, _ bottom: String) async {
|
|
2806
|
-
Bridge.log("G2: sendDoubleTextWall() - top: \(top), bottom: \(bottom)")
|
|
2807
3135
|
// G2 doesn't have native double text wall, combine them
|
|
2808
3136
|
let combined = top + "\n\n" + bottom
|
|
2809
3137
|
await sendTextWall(combined)
|
|
@@ -2909,6 +3237,8 @@ final class G2: NSObject, SGCManager {
|
|
|
2909
3237
|
Bridge.log("G2: applySceneFrame — structural changes, ONE coalesced rebuild for the whole frame")
|
|
2910
3238
|
await coalescedPageRebuild()
|
|
2911
3239
|
}
|
|
3240
|
+
// Stock firmware: text sends are unacked; the awaited verbs above are the best signal there is.
|
|
3241
|
+
frame.reportPresented(true)
|
|
2912
3242
|
}
|
|
2913
3243
|
|
|
2914
3244
|
/// Render `frame` phone-side and queue it for the CFW carrier. Installs the
|
|
@@ -2916,15 +3246,21 @@ final class G2: NSObject, SGCManager {
|
|
|
2916
3246
|
/// rebuilds the page; `rebuildState` re-seeds and re-queues from
|
|
2917
3247
|
/// `cfwLastFrame` once the new page is up.
|
|
2918
3248
|
private func applyCfwScene(_ frame: SceneFrame) async {
|
|
2919
|
-
|
|
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()
|
|
2920
3259
|
cfwLastFrame = frame
|
|
2921
3260
|
// Lease before the frame: a fresh FB_ACQUIRE resets preservation, and
|
|
2922
3261
|
// the frame queued below is what re-arms it.
|
|
2923
3262
|
startFbLease()
|
|
2924
|
-
|
|
2925
|
-
Bridge.log("G2: CFW scene encode failed; retaining the previous frame")
|
|
2926
|
-
return
|
|
2927
|
-
}
|
|
3263
|
+
cfwPendingFrame = frame
|
|
2928
3264
|
cfwSceneActive = true
|
|
2929
3265
|
signalDisplayDirty()
|
|
2930
3266
|
|
|
@@ -2934,17 +3270,13 @@ final class G2: NSObject, SGCManager {
|
|
|
2934
3270
|
}
|
|
2935
3271
|
}
|
|
2936
3272
|
|
|
2937
|
-
///
|
|
2938
|
-
|
|
2939
|
-
/// an x offset — required whenever the frame uses mode-15 text draws,
|
|
2940
|
-
/// which have no per-lens variant and would otherwise land 16 px apart
|
|
2941
|
-
/// from the window content (double vision). Height still follows the user.
|
|
2942
|
-
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 {
|
|
2943
3275
|
let height = DeviceStore.shared.get("bluetooth", "dashboard_height") as? Int ?? 4
|
|
2944
|
-
let depth =
|
|
3276
|
+
let depth = DeviceStore.shared.get("bluetooth", "dashboard_depth") as? Int ?? 2
|
|
2945
3277
|
return G2CfwRenderer.lensBoxes(
|
|
2946
3278
|
canvasWidth: frame.canvasWidth, canvasHeight: frame.canvasHeight,
|
|
2947
|
-
heightLevel: height, depthLevel: depth
|
|
3279
|
+
heightLevel: height, depthLevel: G2CfwRenderer.depthLevel(forSetting: depth)
|
|
2948
3280
|
)
|
|
2949
3281
|
}
|
|
2950
3282
|
|
|
@@ -2957,15 +3289,21 @@ final class G2: NSObject, SGCManager {
|
|
|
2957
3289
|
}
|
|
2958
3290
|
|
|
2959
3291
|
/// Forget the on-glass shadow: the next frame is preceded by a black keyframe.
|
|
2960
|
-
|
|
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
|
|
2961
3296
|
cfwPendingPayloads.removeAll()
|
|
3297
|
+
cfwSceneInvalid = true
|
|
2962
3298
|
cfwShadowSeeded = false
|
|
2963
3299
|
cfwLastBoxes = nil
|
|
2964
3300
|
cfwPrevRaster = nil
|
|
2965
3301
|
cfwLastTextDraws = []
|
|
2966
3302
|
cfwDeltasSinceFull = 0
|
|
2967
|
-
|
|
3303
|
+
guard !keepCache else { return }
|
|
3304
|
+
// Dropped queued uploads leave residency unknown; make the cache model re-learn.
|
|
2968
3305
|
cfwCacheGeneration += 1
|
|
3306
|
+
cfwCacheEpoch = 0
|
|
2969
3307
|
}
|
|
2970
3308
|
|
|
2971
3309
|
/// The CFW context is PER LENS — each arm runs its own firmware instance
|
|
@@ -2982,14 +3320,17 @@ final class G2: NSObject, SGCManager {
|
|
|
2982
3320
|
sendToGlasses(packets, left: true, right: true)
|
|
2983
3321
|
}
|
|
2984
3322
|
|
|
2985
|
-
/// Acquire the fbguard lease and keep renewing it
|
|
2986
|
-
///
|
|
2987
|
-
/// preservation (the next presented direct
|
|
2988
|
-
/// BEFORE the frame is queued.
|
|
2989
|
-
/// is
|
|
2990
|
-
///
|
|
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.
|
|
2991
3332
|
private func startFbLease() {
|
|
2992
|
-
guard fbLeaseTask == nil else { return }
|
|
3333
|
+
guard fbLeaseTask == nil, !notificationsPanelHold else { return }
|
|
2993
3334
|
sendFaceclawFbControl(acquire: true)
|
|
2994
3335
|
fbLeaseTask = Task { @MainActor [weak self] in
|
|
2995
3336
|
while !Task.isCancelled {
|
|
@@ -2998,7 +3339,7 @@ final class G2: NSObject, SGCManager {
|
|
|
2998
3339
|
// supportsCfwSceneRendering: the cfw_scene_renderer escape
|
|
2999
3340
|
// hatch may have been flipped off mid-session — without this
|
|
3000
3341
|
// check the lease renews forever while frames go stock.
|
|
3001
|
-
if self.
|
|
3342
|
+
if self.pageCreated, self.dashboardShowing == 0, !self.navigationModeActive,
|
|
3002
3343
|
self.supportsCfwSceneRendering
|
|
3003
3344
|
{
|
|
3004
3345
|
self.sendFaceclawFbControl(acquire: true)
|
|
@@ -3016,8 +3357,9 @@ final class G2: NSObject, SGCManager {
|
|
|
3016
3357
|
if sendRelease {
|
|
3017
3358
|
sendFaceclawFbControl(acquire: false)
|
|
3018
3359
|
}
|
|
3019
|
-
// Lease end frees the on-glass
|
|
3360
|
+
// Lease end frees the on-glass object cache; the model must re-learn.
|
|
3020
3361
|
cfwCacheGeneration += 1
|
|
3362
|
+
cfwCacheEpoch = 0
|
|
3021
3363
|
}
|
|
3022
3364
|
|
|
3023
3365
|
/// Enable or disable Glassly ownership of the stock wake paths. While the
|
|
@@ -3076,6 +3418,276 @@ final class G2: NSObject, SGCManager {
|
|
|
3076
3418
|
}
|
|
3077
3419
|
}
|
|
3078
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
|
+
|
|
3079
3691
|
/// Encode `frame` and queue the smallest update that reproduces it: dirty
|
|
3080
3692
|
/// mode-3 rect deltas against the previous raster, a mode-9 rect copy when
|
|
3081
3693
|
/// a vertical scroll is detected, and mode-15 builtin-font text draws for
|
|
@@ -3085,13 +3697,28 @@ final class G2: NSObject, SGCManager {
|
|
|
3085
3697
|
/// change is large, the queue backed up, or on the periodic insurance
|
|
3086
3698
|
/// keyframe. Seeds the shadow with mode-6 black when it is fresh.
|
|
3087
3699
|
private func enqueueCfwFrame(_ frame: SceneFrame) async -> Bool {
|
|
3088
|
-
let
|
|
3089
|
-
|
|
3090
|
-
//
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
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
|
|
3095
3722
|
let movedBoxes = cfwLastBoxes != nil && cfwLastBoxes != boxes
|
|
3096
3723
|
let backlogged = cfwPendingPayloads
|
|
3097
3724
|
.filter { $0.kind != .seed && $0.kind != .upload }.count >= Self.cfwMaxQueuedUpdates
|
|
@@ -3114,7 +3741,7 @@ final class G2: NSObject, SGCManager {
|
|
|
3114
3741
|
// phone's metrics under-measured can spill into the outer panel
|
|
3115
3742
|
// margin, where no in-window rect or full frame ever repaints.
|
|
3116
3743
|
// Blank the old window's right margin whenever draws change.
|
|
3117
|
-
// (Builtin
|
|
3744
|
+
// (Builtin draws are only used when the old window is lens-equal.)
|
|
3118
3745
|
if !cfwLastTextDraws.isEmpty, let last = cfwLastBoxes, last.left == last.right {
|
|
3119
3746
|
let edgeX = Int32(last.left.x) + last.width
|
|
3120
3747
|
let bandW = Int32(G2CfwRenderer.frameWidth) - edgeX
|
|
@@ -3129,7 +3756,7 @@ final class G2: NSObject, SGCManager {
|
|
|
3129
3756
|
}
|
|
3130
3757
|
let fids = (0 ..< G2CfwRenderer.requiredFidCount).map { _ in nextCfwFrameId() }
|
|
3131
3758
|
// Copies would smear firmware-drawn glyphs the raster knows nothing about.
|
|
3132
|
-
let allowCopy =
|
|
3759
|
+
let allowCopy = textDraws.isEmpty
|
|
3133
3760
|
guard let update = await cfwRenderer.encodeUpdate(
|
|
3134
3761
|
frame, boxes: boxes,
|
|
3135
3762
|
previousRaster: mustFull ? nil : cfwPrevRaster,
|
|
@@ -3139,10 +3766,11 @@ final class G2: NSObject, SGCManager {
|
|
|
3139
3766
|
marginBlankBoxes: marginBlank,
|
|
3140
3767
|
omitBuiltinText: useBuiltinText,
|
|
3141
3768
|
textureGlyphsEnabled: useTextureGlyphs,
|
|
3142
|
-
textureImagesEnabled:
|
|
3769
|
+
textureImagesEnabled: useBuiltinText || useTextureGlyphs,
|
|
3143
3770
|
cacheGeneration: cfwCacheGeneration
|
|
3144
3771
|
) else { return false }
|
|
3145
3772
|
|
|
3773
|
+
guard generation == cfwCacheGeneration else { return false }
|
|
3146
3774
|
cfwPrevRaster = update.raster
|
|
3147
3775
|
cfwLastTextDraws = textDraws
|
|
3148
3776
|
cfwLastBoxes = boxes
|
|
@@ -3151,7 +3779,9 @@ final class G2: NSObject, SGCManager {
|
|
|
3151
3779
|
cfwPendingPayloads.append(CfwPayload(kind: .upload, data: upload))
|
|
3152
3780
|
}
|
|
3153
3781
|
guard let payload = update.payload else {
|
|
3154
|
-
// 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)
|
|
3155
3785
|
return true
|
|
3156
3786
|
}
|
|
3157
3787
|
if update.usedFullFrame {
|
|
@@ -3174,7 +3804,8 @@ final class G2: NSObject, SGCManager {
|
|
|
3174
3804
|
cfwPendingPayloads.append(CfwPayload(kind: .seed, data: seed))
|
|
3175
3805
|
cfwShadowSeeded = true
|
|
3176
3806
|
if textDraws.isEmpty {
|
|
3177
|
-
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
|
|
3178
3809
|
cfwLastPayloadQueuedAt = Date().timeIntervalSince1970
|
|
3179
3810
|
return true
|
|
3180
3811
|
}
|
|
@@ -3185,34 +3816,46 @@ final class G2: NSObject, SGCManager {
|
|
|
3185
3816
|
}
|
|
3186
3817
|
if update.usedFullFrame {
|
|
3187
3818
|
// A full overwrites the window; seeds, uploads, and move-blanks must still survive.
|
|
3188
|
-
cfwPendingPayloads.removeAll { $0.kind == .full || $0.kind == .delta }
|
|
3819
|
+
cfwPendingPayloads.removeAll { $0.kind == .full || $0.kind == .delta || $0.kind == .scene }
|
|
3189
3820
|
let kind: CfwPayloadKind = movedBoxes ? .moveBundle : .full
|
|
3190
|
-
cfwPendingPayloads.append(CfwPayload(kind: kind, data: payload))
|
|
3821
|
+
cfwPendingPayloads.append(CfwPayload(kind: kind, data: payload, presents: frame.presentTag))
|
|
3191
3822
|
} else {
|
|
3192
|
-
cfwPendingPayloads.append(CfwPayload(kind: .delta, data: payload))
|
|
3823
|
+
cfwPendingPayloads.append(CfwPayload(kind: .delta, data: payload, presents: frame.presentTag))
|
|
3193
3824
|
}
|
|
3194
3825
|
cfwLastPayloadQueuedAt = Date().timeIntervalSince1970
|
|
3195
3826
|
return true
|
|
3196
3827
|
}
|
|
3197
3828
|
|
|
3198
|
-
///
|
|
3199
|
-
///
|
|
3200
|
-
///
|
|
3201
|
-
|
|
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.
|
|
3202
3849
|
@discardableResult
|
|
3203
|
-
private func
|
|
3204
|
-
if
|
|
3850
|
+
private func installCfwPageIfNeeded() -> Bool {
|
|
3851
|
+
if hasCfwPage {
|
|
3205
3852
|
return true
|
|
3206
3853
|
}
|
|
3207
3854
|
textContainers.removeAll()
|
|
3208
3855
|
sceneTextByElement.removeAll()
|
|
3209
3856
|
sceneImageByElement.removeAll()
|
|
3210
|
-
imageContainers
|
|
3211
|
-
|
|
3212
|
-
id: Self.cfwSceneContainerID, x: 0, y: 0, width: 576, height: 288,
|
|
3213
|
-
bmpData: Data(), dirty: false
|
|
3214
|
-
),
|
|
3215
|
-
]
|
|
3857
|
+
imageContainers.removeAll()
|
|
3858
|
+
cfwPageInstalled = true
|
|
3216
3859
|
cfwResetShadow()
|
|
3217
3860
|
return false
|
|
3218
3861
|
}
|
|
@@ -3232,7 +3875,10 @@ final class G2: NSObject, SGCManager {
|
|
|
3232
3875
|
return true
|
|
3233
3876
|
}
|
|
3234
3877
|
|
|
3235
|
-
/// 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.
|
|
3236
3882
|
///
|
|
3237
3883
|
/// The carrier container itself is deliberately NOT torn down: on CFW the
|
|
3238
3884
|
/// idle screen is the black carrier page, which keeps absorbing touch events
|
|
@@ -3240,10 +3886,28 @@ final class G2: NSObject, SGCManager {
|
|
|
3240
3886
|
/// still goes false — that tracks "a miniapp scene is on glass", which
|
|
3241
3887
|
/// gates reposition/replay, not the page's existence.
|
|
3242
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
|
|
3243
3893
|
cfwLastFrame = nil
|
|
3244
3894
|
cfwSceneActive = false
|
|
3245
|
-
|
|
3246
|
-
|
|
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() {
|
|
3247
3911
|
cfwPendingPayloads.append(CfwPayload(kind: .seed, data: seed))
|
|
3248
3912
|
cfwShadowSeeded = true
|
|
3249
3913
|
cfwLastBoxes = nil
|
|
@@ -3266,10 +3930,10 @@ final class G2: NSObject, SGCManager {
|
|
|
3266
3930
|
// its normal paths.
|
|
3267
3931
|
guard cfwClassified, cfwCapabilityString != nil else { return }
|
|
3268
3932
|
if enabled {
|
|
3269
|
-
if
|
|
3933
|
+
if installCfwPageIfNeeded() {
|
|
3270
3934
|
return
|
|
3271
|
-
} // already the
|
|
3272
|
-
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")
|
|
3273
3937
|
await requestPageRebuild()
|
|
3274
3938
|
} else {
|
|
3275
3939
|
Bridge.log("G2: cfw_scene_renderer OFF — exiting CFW surface to a blank stock page")
|
|
@@ -3277,7 +3941,8 @@ final class G2: NSObject, SGCManager {
|
|
|
3277
3941
|
cfwLastFrame = nil
|
|
3278
3942
|
cfwSceneActive = false
|
|
3279
3943
|
cfwResetShadow()
|
|
3280
|
-
|
|
3944
|
+
cfwPageInstalled = false
|
|
3945
|
+
imageContainers.removeAll()
|
|
3281
3946
|
textContainers.removeAll()
|
|
3282
3947
|
sceneTextByElement.removeAll()
|
|
3283
3948
|
sceneImageByElement.removeAll()
|
|
@@ -3288,12 +3953,8 @@ final class G2: NSObject, SGCManager {
|
|
|
3288
3953
|
/// Height/depth changed: re-place the retained frame (stock moves its window the same way).
|
|
3289
3954
|
private func cfwReposition() {
|
|
3290
3955
|
guard supportsCfwSceneRendering, cfwSceneActive, let frame = cfwLastFrame else { return }
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
if await self.enqueueCfwFrame(frame) {
|
|
3294
|
-
self.signalDisplayDirty()
|
|
3295
|
-
}
|
|
3296
|
-
}
|
|
3956
|
+
cfwPendingFrame = frame
|
|
3957
|
+
signalDisplayDirty()
|
|
3297
3958
|
}
|
|
3298
3959
|
|
|
3299
3960
|
func onSceneReplay(_: String) async {
|
|
@@ -3493,7 +4154,7 @@ final class G2: NSObject, SGCManager {
|
|
|
3493
4154
|
/// applySceneFrame batch — without batching, each remove would rebuild).
|
|
3494
4155
|
func clearSceneElements(_ elementIds: [String]) async {
|
|
3495
4156
|
if supportsCfwSceneRendering {
|
|
3496
|
-
let carrierWasConfigured =
|
|
4157
|
+
let carrierWasConfigured = installCfwPageIfNeeded()
|
|
3497
4158
|
cfwClear()
|
|
3498
4159
|
if !carrierWasConfigured, pageCreated {
|
|
3499
4160
|
// Carrier just replaced live stock containers (see clearDisplay).
|
|
@@ -3554,7 +4215,7 @@ final class G2: NSObject, SGCManager {
|
|
|
3554
4215
|
// Idle on CFW = the black carrier page, kept up for touch + mic.
|
|
3555
4216
|
// Install it even on a first-ever clear (no scene rendered yet) so
|
|
3556
4217
|
// the idle state is the same page everywhere.
|
|
3557
|
-
let carrierWasConfigured =
|
|
4218
|
+
let carrierWasConfigured = installCfwPageIfNeeded()
|
|
3558
4219
|
cfwClear()
|
|
3559
4220
|
if !carrierWasConfigured || !pageCreated {
|
|
3560
4221
|
signalDisplayDirty()
|
|
@@ -3609,35 +4270,27 @@ final class G2: NSObject, SGCManager {
|
|
|
3609
4270
|
}
|
|
3610
4271
|
}
|
|
3611
4272
|
|
|
3612
|
-
/// Send a bitmap to
|
|
3613
|
-
/// firmware silently drops the first stream after a fresh page,
|
|
3614
|
-
///
|
|
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.
|
|
3615
4276
|
@discardableResult
|
|
3616
4277
|
private func sendImageData(containerID: Int32, containerName: String, bmpData: Data) async -> Bool {
|
|
3617
4278
|
guard !bmpData.isEmpty else { return true }
|
|
3618
4279
|
|
|
3619
|
-
|
|
3620
|
-
if !isCfwCarrier, !stockImageStreamWarmedUp {
|
|
4280
|
+
if !stockImageStreamWarmedUp {
|
|
3621
4281
|
let warmupOk = await sendImageTransfer(
|
|
3622
|
-
containerID: containerID,
|
|
3623
|
-
containerName: containerName,
|
|
3624
|
-
bmpData: bmpData,
|
|
3625
|
-
isCfwCarrier: false
|
|
4282
|
+
containerID: containerID, containerName: containerName, bmpData: bmpData
|
|
3626
4283
|
)
|
|
3627
4284
|
guard warmupOk else { return false }
|
|
3628
4285
|
stockImageStreamWarmedUp = true
|
|
3629
4286
|
}
|
|
3630
4287
|
|
|
3631
4288
|
return await sendImageTransfer(
|
|
3632
|
-
containerID: containerID,
|
|
3633
|
-
containerName: containerName,
|
|
3634
|
-
bmpData: bmpData,
|
|
3635
|
-
isCfwCarrier: isCfwCarrier
|
|
4289
|
+
containerID: containerID, containerName: containerName, bmpData: bmpData
|
|
3636
4290
|
)
|
|
3637
4291
|
}
|
|
3638
4292
|
|
|
3639
|
-
/// Perform one logical image stream, retrying
|
|
3640
|
-
/// failed fragment ACK.
|
|
4293
|
+
/// Perform one logical stock image stream, retrying with a fresh session.
|
|
3641
4294
|
///
|
|
3642
4295
|
/// The glasses reply ONCE per fragment with an ImgResCmd ErrorCode (4=success, 5=failed).
|
|
3643
4296
|
/// All fragments in an attempt share a session id, and each fragment awaits its own ACK for up
|
|
@@ -3645,21 +4298,13 @@ final class G2: NSObject, SGCManager {
|
|
|
3645
4298
|
/// counts as a failure and the entire image is re-sent with a fresh session up to
|
|
3646
4299
|
/// `IMG_MAX_ATTEMPTS` times, backing off `IMG_RETRY_DELAY_NS` before each re-attempt.
|
|
3647
4300
|
/// On exhausting all attempts it logs a warning and returns false.
|
|
3648
|
-
private func sendImageTransfer(
|
|
3649
|
-
|
|
3650
|
-
) async -> Bool {
|
|
3651
|
-
// CFW carrier transfers use the verified wire format: 3800-byte
|
|
3652
|
-
// fragments, MapSessionId pinned to the container id instead of using
|
|
3653
|
-
// the rotating session counter, and frames
|
|
3654
|
-
// written to the LEFT arm (acks still return on the right). Stock
|
|
3655
|
-
// containers keep the shipped 4096/rotating-session/right-arm shape.
|
|
3656
|
-
let fragmentSize = isCfwCarrier ? 3800 : 4096
|
|
4301
|
+
private func sendImageTransfer(containerID: Int32, containerName: String, bmpData: Data) async -> Bool {
|
|
4302
|
+
let fragmentSize = 4096
|
|
3657
4303
|
let totalSize = Int32(bmpData.count)
|
|
3658
4304
|
let fragmentCount = (bmpData.count + fragmentSize - 1) / fragmentSize
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
// )
|
|
4305
|
+
let transferStarted = G2DisplayDiagnostics.now
|
|
4306
|
+
defer { displayDiagnostics.timing("transferMs", (G2DisplayDiagnostics.now - transferStarted) * 1000) }
|
|
4307
|
+
let generation = pageGeneration
|
|
3663
4308
|
|
|
3664
4309
|
for attempt in 1 ... IMG_MAX_ATTEMPTS {
|
|
3665
4310
|
// Back off before each RE-attempt: the ack didn't come, so the link
|
|
@@ -3667,21 +4312,20 @@ final class G2: NSObject, SGCManager {
|
|
|
3667
4312
|
if attempt > 1 {
|
|
3668
4313
|
try? await Task.sleep(nanoseconds: IMG_RETRY_DELAY_NS)
|
|
3669
4314
|
}
|
|
4315
|
+
guard !Task.isCancelled, pageGeneration == generation else { return false }
|
|
3670
4316
|
// One session id per WHOLE image transfer (per attempt). The glasses key their
|
|
3671
4317
|
// reassembly buffer on MapSessionId, so every fragment of this image must reuse the
|
|
3672
4318
|
// same session id with an incrementing MapFragmentIndex; the per-fragment ACK is
|
|
3673
|
-
// correlated by the (session, fragmentIndex) pair.
|
|
3674
|
-
// 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.
|
|
3675
4321
|
imageSessionCounter = (imageSessionCounter + 1) % 256
|
|
3676
|
-
let sessionId =
|
|
4322
|
+
let sessionId = imageSessionCounter
|
|
3677
4323
|
|
|
3678
4324
|
var fragmentIndex: Int32 = 0
|
|
3679
4325
|
var offset = 0
|
|
3680
4326
|
var transferOk = true
|
|
3681
|
-
// if attempt > 1 {
|
|
3682
|
-
// Bridge.log("G2: sendImageData(\(containerName)) - attempt \(attempt) starting")
|
|
3683
|
-
// }
|
|
3684
4327
|
while offset < bmpData.count {
|
|
4328
|
+
guard !Task.isCancelled, pageGeneration == generation else { return false }
|
|
3685
4329
|
let end = min(offset + fragmentSize, bmpData.count)
|
|
3686
4330
|
let fragment = bmpData[offset ..< end]
|
|
3687
4331
|
|
|
@@ -3695,21 +4339,30 @@ final class G2: NSObject, SGCManager {
|
|
|
3695
4339
|
mapFragmentPacketSize: Int32(fragment.count),
|
|
3696
4340
|
mapRawData: Data(fragment)
|
|
3697
4341
|
)
|
|
3698
|
-
// Send the fragment directly: the reconcile loop already serializes whole image
|
|
3699
|
-
// sends, and the per-fragment ACK gate below provides pacing — no transmit queue.
|
|
3700
|
-
// Carrier frames go to the LEFT arm; the ack still returns on
|
|
3701
|
-
// the right and our notify path is
|
|
3702
|
-
// arm-agnostic); everything else keeps the right arm.
|
|
3703
|
-
sendEvenHubCommand(msg, left: isCfwCarrier, right: !isCfwCarrier)
|
|
3704
|
-
// Bridge.log("G2: img_sen: session=\(sessionId) fragment=\(fragmentIndex)")
|
|
3705
|
-
|
|
3706
4342
|
// Gate on THIS fragment's ACK before sending the next (the ACK provides pacing).
|
|
3707
4343
|
// Timeout/img_failed → abandon the attempt and retry the whole image.
|
|
3708
|
-
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 }
|
|
3709
4365
|
if !ok {
|
|
3710
|
-
Bridge.log(
|
|
3711
|
-
"G2: img_sen: session=\(sessionId) fragment=\(fragmentIndex) failed"
|
|
3712
|
-
)
|
|
3713
4366
|
transferOk = false
|
|
3714
4367
|
break
|
|
3715
4368
|
}
|
|
@@ -3719,33 +4372,131 @@ final class G2: NSObject, SGCManager {
|
|
|
3719
4372
|
}
|
|
3720
4373
|
|
|
3721
4374
|
if transferOk {
|
|
3722
|
-
// Bridge.log("G2: img_sen: container=\(containerName) - success=true")
|
|
3723
4375
|
return true
|
|
3724
4376
|
}
|
|
3725
4377
|
}
|
|
3726
4378
|
|
|
3727
|
-
Bridge.log("G2: img_sen: container=\(containerName) - failed after \(IMG_MAX_ATTEMPTS) attempts")
|
|
3728
4379
|
return false
|
|
3729
4380
|
}
|
|
3730
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
|
+
|
|
3731
4493
|
/// Suspend until correlateImageAck() resolves the ACK for this `(sessionId, fragmentIndex)`, or
|
|
3732
4494
|
/// `IMG_ACK_TIMEOUT_NS` elapses (whichever comes first). Returns whether the fragment was acked
|
|
3733
4495
|
/// successfully; a timeout returns false. All fragments of one image share a session id, so the
|
|
3734
4496
|
/// fragment index distinguishes their ACKs. The continuation lives in [ImgAckBox], whose lock
|
|
3735
4497
|
/// makes the ACK-resolve and the timeout mutually exclusive — exactly one resumes it.
|
|
3736
|
-
private func awaitImageAck(sessionId: Int, fragmentIndex: Int32) async -> Bool {
|
|
3737
|
-
|
|
3738
|
-
let box = imgAckBox
|
|
3739
|
-
return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
|
3740
|
-
box.arm(session: sessionId, fragment: fragmentIndex, cont: cont)
|
|
3741
|
-
|
|
3742
|
-
// Detached (not @MainActor): the timeout must fire on its own schedule regardless of how
|
|
3743
|
-
// busy the main actor is. The box's lock makes racing with resolve() safe.
|
|
3744
|
-
Task.detached {
|
|
3745
|
-
try? await Task.sleep(nanoseconds: timeoutNs)
|
|
3746
|
-
box.timeout(session: sessionId, fragment: fragmentIndex)
|
|
3747
|
-
}
|
|
3748
|
-
}
|
|
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)
|
|
3749
4500
|
}
|
|
3750
4501
|
|
|
3751
4502
|
/// Resume the pending image-ACK continuation for `(session, fragmentIndex)` (if it matches the
|
|
@@ -3777,9 +4528,7 @@ final class G2: NSObject, SGCManager {
|
|
|
3777
4528
|
let rh = height ?? G2.defaultImgContainer.height
|
|
3778
4529
|
|
|
3779
4530
|
// ignore events while the ER dashboard is open:
|
|
3780
|
-
|
|
3781
|
-
DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
|
|
3782
|
-
if useNativeDashboard && dashboardShowing > 0 {
|
|
4531
|
+
if dashboardShowing > 0 {
|
|
3783
4532
|
return false
|
|
3784
4533
|
}
|
|
3785
4534
|
|
|
@@ -3844,18 +4593,57 @@ final class G2: NSObject, SGCManager {
|
|
|
3844
4593
|
return true
|
|
3845
4594
|
}
|
|
3846
4595
|
|
|
3847
|
-
///
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
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.
|
|
3852
4604
|
private func reconcileDisplay() async {
|
|
4605
|
+
guard !Task.isCancelled else { return }
|
|
3853
4606
|
// Native nav mode owns the screen: hold ALL EvenHub display sends (text,
|
|
3854
4607
|
// images, page resurrection). Container state keeps accumulating; the
|
|
3855
4608
|
// nav-exit recovery rebuilds the page and flushes it.
|
|
3856
4609
|
if navigationModeActive {
|
|
3857
4610
|
return
|
|
3858
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 }
|
|
3859
4647
|
// Page is dead but content is waiting (e.g. captions kept arriving while iOS had us
|
|
3860
4648
|
// suspended and the firmware tore the session down). Rebuild the page ONCE here — the
|
|
3861
4649
|
// reconcile loop is coalesced (1-deep signal buffer), so a burst of buffered sendText
|
|
@@ -3863,8 +4651,6 @@ final class G2: NSObject, SGCManager {
|
|
|
3863
4651
|
// rebuildState() recreates the page, re-pushes the current text/image, and re-arms the
|
|
3864
4652
|
// mic iff intent says so. Skip while the native dashboard owns the screen.
|
|
3865
4653
|
if !pageCreated {
|
|
3866
|
-
let useNativeDashboard =
|
|
3867
|
-
DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
|
|
3868
4654
|
// Stock path: only resurrect for non-blank content — don't rebuild just to render a
|
|
3869
4655
|
// clearDisplay's blank, or a clear burst churns the page back up pointlessly.
|
|
3870
4656
|
// (CFW is the exception below: its blank IS the idle page.)
|
|
@@ -3879,39 +4665,28 @@ final class G2: NSObject, SGCManager {
|
|
|
3879
4665
|
// Idle carrier: the black seed alone. This is what the
|
|
3880
4666
|
// rebuild→systemExit→rebuild storm guard used to block, so it is
|
|
3881
4667
|
// rate-limited below rather than allowed once per reconcile tick.
|
|
3882
|
-
let cfwIdleCarrier = !cfwPendingPayloads.isEmpty && !cfwHasScene &&
|
|
4668
|
+
let cfwIdleCarrier = !cfwPendingPayloads.isEmpty && !cfwHasScene && cfwPageInstalled
|
|
3883
4669
|
let hasPendingImage = imageContainers.contains { $0.dirty && !$0.bmpData.isEmpty }
|
|
3884
4670
|
|| cfwHasScene
|
|
3885
4671
|
let wants = hasPendingText || hasPendingImage || cfwIdleCarrier
|
|
3886
4672
|
// Consume the idle-resurrect slot ONLY when this tick would actually
|
|
3887
4673
|
// rebuild — burning it behind the dashboard gate would stall a real
|
|
3888
4674
|
// resurrection for the whole backoff window.
|
|
3889
|
-
if wants,
|
|
4675
|
+
if wants, dashboardShowing == 0,
|
|
3890
4676
|
hasPendingText || hasPendingImage || cfwIdlePageResurrectAllowed()
|
|
3891
4677
|
{
|
|
3892
|
-
//
|
|
3893
|
-
//
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
if cfwHasScene || cfwIdleCarrier,
|
|
3899
|
-
let carrier = imageContainers.first(where: { $0.id == Self.cfwSceneContainerID }),
|
|
3900
|
-
let probe = cfwPendingPayloads.first
|
|
3901
|
-
{
|
|
3902
|
-
let ok = await sendImageData(
|
|
3903
|
-
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"
|
|
3904
4684
|
)
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
pageCreated = true
|
|
3908
|
-
if !cfwPendingPayloads.isEmpty {
|
|
3909
|
-
cfwPendingPayloads.removeFirst()
|
|
3910
|
-
}
|
|
3911
|
-
signalDisplayDirty() // drain the rest normally
|
|
3912
|
-
return
|
|
3913
|
-
}
|
|
4685
|
+
armPageShutdownSettleWake(afterMs: settle)
|
|
4686
|
+
return
|
|
3914
4687
|
}
|
|
4688
|
+
// (Transport ACKs need no page, so they cannot probe a "dead" page
|
|
4689
|
+
// the way carrier image ACKs once did.)
|
|
3915
4690
|
Bridge.log("G2: reconcileDisplay() - page down with pending content, rebuilding once")
|
|
3916
4691
|
await rebuildState()
|
|
3917
4692
|
}
|
|
@@ -3921,11 +4696,8 @@ final class G2: NSObject, SGCManager {
|
|
|
3921
4696
|
// Text: synchronous, no ACK. Send one update per container with pending sends and decrement.
|
|
3922
4697
|
for i in textContainers.indices where textContainers[i].pendingSends > 0 {
|
|
3923
4698
|
let container = textContainers[i]
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
Bridge.log(
|
|
3927
|
-
"G2: updateText id=\(container.id) rect=\(container.x),\(container.y) \(container.width)x\(container.height) bytes=\(container.content.utf8.count) content=\(container.content.debugDescription)"
|
|
3928
|
-
)
|
|
4699
|
+
displayDiagnostics.count("stockTextWrites")
|
|
4700
|
+
displayDiagnostics.count("stockTextBytes", container.content.utf8.count)
|
|
3929
4701
|
let msg = EvenHubProto.updateTextMessage(
|
|
3930
4702
|
containerID: container.id,
|
|
3931
4703
|
containerName: container.name,
|
|
@@ -3937,16 +4709,82 @@ final class G2: NSObject, SGCManager {
|
|
|
3937
4709
|
textContainers[i].pendingSends -= 1
|
|
3938
4710
|
}
|
|
3939
4711
|
|
|
3940
|
-
|
|
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
|
|
3941
4723
|
// fast producer can't starve the ticker; the remainder goes out on the next cycle.
|
|
3942
|
-
if
|
|
4724
|
+
if supportsCfwSceneRendering, cfwPageInstalled {
|
|
3943
4725
|
var drained = 0
|
|
3944
4726
|
while pageCreated, drained < 4, !cfwPendingPayloads.isEmpty {
|
|
3945
4727
|
drained += 1
|
|
3946
4728
|
let payload = cfwPendingPayloads.removeFirst()
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
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 }
|
|
3950
4788
|
if !ok {
|
|
3951
4789
|
// A lost payload leaves the on-glass shadow stale; further
|
|
3952
4790
|
// deltas would composite onto it and corrupt the panel.
|
|
@@ -3955,15 +4793,29 @@ final class G2: NSObject, SGCManager {
|
|
|
3955
4793
|
// the page down mid-transfer is re-seeded by rebuildState;
|
|
3956
4794
|
// refilling the queue here just fuels the page-resurrection
|
|
3957
4795
|
// loop (rebuild→systemExit→rebuild every debounce tick).
|
|
3958
|
-
if pageCreated, supportsCfwSceneRendering,
|
|
3959
|
-
|
|
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)
|
|
3960
4804
|
if let frame = cfwLastFrame {
|
|
3961
|
-
|
|
4805
|
+
cfwPendingFrame = frame
|
|
4806
|
+
}
|
|
4807
|
+
if keepCache {
|
|
4808
|
+
await cfwRenderer.forgetUploads(dropped)
|
|
3962
4809
|
}
|
|
3963
4810
|
}
|
|
3964
4811
|
break
|
|
3965
4812
|
}
|
|
3966
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
|
+
}
|
|
3967
4819
|
}
|
|
3968
4820
|
|
|
3969
4821
|
// Images: ACK-gated, exactly one in flight. Cap iterations defensively so a container that
|
|
@@ -4058,6 +4910,7 @@ final class G2: NSObject, SGCManager {
|
|
|
4058
4910
|
|
|
4059
4911
|
/// shutdown and rebuild everything, re-sends all data to the glasses:
|
|
4060
4912
|
private func rebuildPage() async {
|
|
4913
|
+
lastPageShutdownSentMs = Int64(Date().timeIntervalSince1970 * 1000)
|
|
4061
4914
|
let msg = EvenHubProto.shutdownMessage()
|
|
4062
4915
|
sendEvenHubCommand(msg)
|
|
4063
4916
|
pageCreated = false
|
|
@@ -4073,15 +4926,19 @@ final class G2: NSObject, SGCManager {
|
|
|
4073
4926
|
Bridge.log("G2: rebuildState() skipped — navigation mode owns the screen")
|
|
4074
4927
|
return
|
|
4075
4928
|
}
|
|
4929
|
+
if notificationsPanelHold {
|
|
4930
|
+
Bridge.log("G2: rebuildState() skipped — notifications panel hold")
|
|
4931
|
+
return
|
|
4932
|
+
}
|
|
4076
4933
|
Bridge.log("G2: rebuildState()")
|
|
4077
4934
|
// Stock containers left by a torn-down page would rebuild as the evt-0 page, not the carrier.
|
|
4078
4935
|
if supportsCfwSceneRendering {
|
|
4079
|
-
_ =
|
|
4936
|
+
_ = installCfwPageIfNeeded()
|
|
4080
4937
|
}
|
|
4081
4938
|
// A stray text container here means createPageWithContainers builds the evt-0 shape
|
|
4082
|
-
// instead of the
|
|
4939
|
+
// instead of the CFW page.
|
|
4083
4940
|
Bridge.log(
|
|
4084
|
-
"G2: rebuildState() post-
|
|
4941
|
+
"G2: rebuildState() post-install cfw=\(supportsCfwSceneRendering) cfwPage=\(hasCfwPage)"
|
|
4085
4942
|
+ " text=\(textContainers.map { $0.id }) img=\(imageContainers.map { $0.id })"
|
|
4086
4943
|
)
|
|
4087
4944
|
// recreate the containers (sets pageCreated = true; embeds text content directly):
|
|
@@ -4098,12 +4955,11 @@ final class G2: NSObject, SGCManager {
|
|
|
4098
4955
|
)
|
|
4099
4956
|
imageContainers[i].dirty = true
|
|
4100
4957
|
}
|
|
4101
|
-
//
|
|
4102
|
-
|
|
4103
|
-
if supportsCfwSceneRendering, hasCfwCarrier {
|
|
4958
|
+
// Re-seed the on-glass shadow and re-send the retained frame for the new page.
|
|
4959
|
+
if supportsCfwSceneRendering, cfwPageInstalled {
|
|
4104
4960
|
cfwResetShadow()
|
|
4105
4961
|
if let frame = cfwLastFrame {
|
|
4106
|
-
|
|
4962
|
+
cfwPendingFrame = frame
|
|
4107
4963
|
} else if let seed = G2CfwRenderer.blackPayload() {
|
|
4108
4964
|
cfwPendingPayloads.append(CfwPayload(kind: .seed, data: seed))
|
|
4109
4965
|
cfwShadowSeeded = true
|
|
@@ -4123,6 +4979,30 @@ final class G2: NSObject, SGCManager {
|
|
|
4123
4979
|
restartMicIfAlreadyEnabled()
|
|
4124
4980
|
}
|
|
4125
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
|
+
|
|
4126
5006
|
/// Single coalesced recovery: rebuild the page + re-arm sensor streams from intent, but never
|
|
4127
5007
|
/// stack rebuilds. The firmware spams systemExit/dashboard-close ~1×/sec; without this
|
|
4128
5008
|
/// guard each one triggered a rebuild that was torn down again → rebuild→exit→rebuild
|
|
@@ -4134,6 +5014,15 @@ final class G2: NSObject, SGCManager {
|
|
|
4134
5014
|
if navigationModeActive {
|
|
4135
5015
|
return
|
|
4136
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
|
+
}
|
|
4137
5026
|
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
|
4138
5027
|
// If the page is already alive and the mic matches intent, there's nothing to recover —
|
|
4139
5028
|
// this is a spurious/phantom firmware event (it spams close/exit even when our page is
|
|
@@ -4512,8 +5401,11 @@ final class G2: NSObject, SGCManager {
|
|
|
4512
5401
|
}
|
|
4513
5402
|
|
|
4514
5403
|
func setDashboardDepthOnly(_ depth: Int) {
|
|
4515
|
-
|
|
4516
|
-
|
|
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))")
|
|
4517
5409
|
let msg = G2SettingProto.setScreenDepth(
|
|
4518
5410
|
magicRandom: sendManager.nextMagicRandom(),
|
|
4519
5411
|
level: clamped
|
|
@@ -4535,19 +5427,15 @@ final class G2: NSObject, SGCManager {
|
|
|
4535
5427
|
// MARK: - Private Display Helpers
|
|
4536
5428
|
|
|
4537
5429
|
private func createPageWithContainers() {
|
|
4538
|
-
// CFW
|
|
4539
|
-
//
|
|
4540
|
-
//
|
|
4541
|
-
//
|
|
4542
|
-
//
|
|
4543
|
-
// the
|
|
4544
|
-
//
|
|
4545
|
-
//
|
|
4546
|
-
//
|
|
4547
|
-
// shape and the firmware then rejects every CFW payload (img_res
|
|
4548
|
-
// errorCode=4). Every rebuild path funnels through this function, so
|
|
4549
|
-
// enforce the invariant here rather than trusting each caller to have
|
|
4550
|
-
// 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.
|
|
4551
5439
|
if supportsCfwSceneRendering {
|
|
4552
5440
|
if !textContainers.isEmpty {
|
|
4553
5441
|
Bridge.log(
|
|
@@ -4555,21 +5443,16 @@ final class G2: NSObject, SGCManager {
|
|
|
4555
5443
|
)
|
|
4556
5444
|
textContainers.removeAll()
|
|
4557
5445
|
}
|
|
4558
|
-
|
|
4559
|
-
if !strayImages.isEmpty {
|
|
5446
|
+
if !imageContainers.isEmpty {
|
|
4560
5447
|
Bridge.log(
|
|
4561
|
-
"G2: CFW page — dropping stray image containers \(
|
|
5448
|
+
"G2: CFW page — dropping stray image containers \(imageContainers.map { $0.id })"
|
|
4562
5449
|
)
|
|
4563
|
-
imageContainers.removeAll
|
|
4564
|
-
}
|
|
4565
|
-
if !hasCfwCarrier {
|
|
4566
|
-
_ = installCfwCarrierIfNeeded()
|
|
5450
|
+
imageContainers.removeAll()
|
|
4567
5451
|
}
|
|
5452
|
+
cfwPageInstalled = true
|
|
4568
5453
|
}
|
|
4569
5454
|
|
|
4570
|
-
let isCfwCarrierPage = textContainers.isEmpty
|
|
4571
|
-
&& imageContainers.count == 1
|
|
4572
|
-
&& imageContainers[0].id == Self.cfwSceneContainerID
|
|
5455
|
+
let isCfwCarrierPage = supportsCfwSceneRendering && textContainers.isEmpty && imageContainers.isEmpty
|
|
4573
5456
|
|
|
4574
5457
|
// Dedicated event-capture container: id 0, 1x1, borderless, empty — the
|
|
4575
5458
|
// designated event-capture slot per the RE demos ("container 0 is
|
|
@@ -4624,15 +5507,6 @@ final class G2: NSObject, SGCManager {
|
|
|
4624
5507
|
// iterate all image containers, remove any entrys with duplicate id's, and ensure the ids in the imageContainerIDPool is up-to-date:
|
|
4625
5508
|
var seenIDs = Set<Int32>()
|
|
4626
5509
|
imageContainers = imageContainers.filter { c in
|
|
4627
|
-
// The CFW carrier is INTENTIONALLY empty: its pixels ride the
|
|
4628
|
-
// ordered payload queue (cfwPendingPayloads), never bmpData. The
|
|
4629
|
-
// empty-container prune below silently dropped it from every page
|
|
4630
|
-
// since the queue rework — the firmware then had no container to
|
|
4631
|
-
// receive ANY CFW payload (mode 3/6/8, even plain BMPs), so all
|
|
4632
|
-
// direct rendering went dark while stock text kept working.
|
|
4633
|
-
if c.id == Self.cfwSceneContainerID {
|
|
4634
|
-
return seenIDs.insert(c.id).inserted
|
|
4635
|
-
}
|
|
4636
5510
|
guard !c.bmpData.isEmpty else {
|
|
4637
5511
|
Bridge.log("G2: removing empty image container \(c.id)")
|
|
4638
5512
|
return false
|
|
@@ -4749,17 +5623,18 @@ final class G2: NSObject, SGCManager {
|
|
|
4749
5623
|
// Intent is "mic on". The mic only exists inside a live EvenHub page, so we
|
|
4750
5624
|
// toggle it off then back on (the firmware needs the off→on edge to re-arm).
|
|
4751
5625
|
evenHubMicIntent = true
|
|
5626
|
+
// Held: intent is recorded; the post-hold rebuild re-arms the mic.
|
|
5627
|
+
if notificationsPanelHold {
|
|
5628
|
+
return
|
|
5629
|
+
}
|
|
4752
5630
|
DeviceStore.shared.apply("glasses", "micEnabled", true)
|
|
4753
5631
|
let msg = EvenHubProto.audioControlMessage(enable: false)
|
|
4754
5632
|
sendEvenHubCommand(msg)
|
|
4755
5633
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
4756
5634
|
guard let self = self else { return }
|
|
4757
|
-
let useNativeDashboard =
|
|
4758
|
-
DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
|
|
4759
|
-
// Bridge.log("G2: setMicEnabled - useNativeDashboard=\(useNativeDashboard), dashboardShowing=\(dashboardShowing)")
|
|
4760
5635
|
// Dashboard owns the screen + session right now — don't arm the mic into a
|
|
4761
5636
|
// page the dashboard has taken over; recovery re-arms on dashboard close.
|
|
4762
|
-
if
|
|
5637
|
+
if dashboardShowing > 0 {
|
|
4763
5638
|
return
|
|
4764
5639
|
}
|
|
4765
5640
|
// Never send audioControl(enable:true) without a live page — no page means no
|
|
@@ -4870,13 +5745,15 @@ final class G2: NSObject, SGCManager {
|
|
|
4870
5745
|
startupPageCreated = false
|
|
4871
5746
|
pageCreated = false
|
|
4872
5747
|
stockImageStreamWarmedUp = false
|
|
5748
|
+
cfwTransport = G2CfwTransport.Encoder()
|
|
5749
|
+
cfwStreamSequence = UInt8.random(in: 0 ... 255)
|
|
4873
5750
|
dashboardShowing = 0
|
|
4874
5751
|
dashboardOpening = false
|
|
5752
|
+
notificationsPanelHold = false
|
|
4875
5753
|
heartbeatCounter = 0
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
cfwProtocolVersion = 0
|
|
5754
|
+
cancelSensorCommands()
|
|
5755
|
+
cfwClassification = nil
|
|
5756
|
+
publishRingBattery(.disconnected)
|
|
4880
5757
|
cfwSceneActive = false
|
|
4881
5758
|
// Fresh connection gets a fresh idle-resurrect budget.
|
|
4882
5759
|
cfwLastIdleResurrectAt = nil
|
|
@@ -4884,6 +5761,11 @@ final class G2: NSObject, SGCManager {
|
|
|
4884
5761
|
cfwResetShadow()
|
|
4885
5762
|
stopFbLease(sendRelease: false) // link is gone; the leases fail open in 90 s
|
|
4886
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()
|
|
4887
5769
|
DeviceStore.shared.apply("glasses", "connected", false)
|
|
4888
5770
|
DeviceStore.shared.apply("glasses", "fullyBooted", false)
|
|
4889
5771
|
}
|
|
@@ -5012,9 +5894,19 @@ final class G2: NSObject, SGCManager {
|
|
|
5012
5894
|
/// 3. SKILL{skillId=NOTIFICATION, skillParam=show, ...} — dispatches the intent
|
|
5013
5895
|
/// The SKILL step alone is ignored by firmware; the preceding ENTER+ASK
|
|
5014
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.
|
|
5015
5900
|
func showNotificationsPanel() async {
|
|
5016
5901
|
Bridge.log("G2: showNotificationsPanel()")
|
|
5017
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
|
+
|
|
5018
5910
|
let enterPayload = EvenAIProto.aiCtrl(
|
|
5019
5911
|
magicRandom: sendManager.nextMagicRandom(),
|
|
5020
5912
|
status: 2 // EVEN_AI_ENTER
|
|
@@ -5035,26 +5927,25 @@ final class G2: NSObject, SGCManager {
|
|
|
5035
5927
|
text: " ",
|
|
5036
5928
|
streamEnable: 1, fTextEnd: 1
|
|
5037
5929
|
)
|
|
5930
|
+
|
|
5931
|
+
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
|
5932
|
+
notificationsPanelHold = false
|
|
5933
|
+
Bridge.log("G2: notifications resurrect re-enabled")
|
|
5038
5934
|
}
|
|
5039
5935
|
|
|
5040
|
-
func dbg1() {
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
|
|
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()
|
|
5044
5944
|
}
|
|
5045
5945
|
|
|
5046
|
-
func
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
dashboardOpening = true
|
|
5050
|
-
let msg = EvenHubProto.shutdownMessage()
|
|
5051
|
-
sendEvenHubCommand(msg)
|
|
5052
|
-
pageCreated = false
|
|
5053
|
-
try? await Task.sleep(nanoseconds: 300_000_000) // 300ms to settle
|
|
5054
|
-
await showNotificationsPanel()
|
|
5055
|
-
}
|
|
5056
|
-
// navigationModeActive ? exitNavigationMode(arrived: false) : enterNavigationMode()
|
|
5057
|
-
// Task { await runAuthSequence() }
|
|
5946
|
+
func dbg4() {
|
|
5947
|
+
Bridge.log("G2: dbg4() — stop wake lease")
|
|
5948
|
+
stopWakeLease(sendRelease: true)
|
|
5058
5949
|
}
|
|
5059
5950
|
|
|
5060
5951
|
// MARK: - Native navigation mode (service 0x08)
|
|
@@ -5110,6 +6001,7 @@ final class G2: NSObject, SGCManager {
|
|
|
5110
6001
|
func exitNavigationMode(arrived: Bool) {
|
|
5111
6002
|
guard navigationModeActive else { return }
|
|
5112
6003
|
Bridge.log("G2: exitNavigationMode(arrived: \(arrived))")
|
|
6004
|
+
sensorNavigationRecovering = true
|
|
5113
6005
|
navHeartbeatTask?.cancel()
|
|
5114
6006
|
navHeartbeatTask = nil
|
|
5115
6007
|
navigationModeActive = false
|
|
@@ -5136,6 +6028,7 @@ final class G2: NSObject, SGCManager {
|
|
|
5136
6028
|
await MainActor.run {
|
|
5137
6029
|
guard let self = self, !self.navigationModeActive else { return }
|
|
5138
6030
|
self.pageCreated = false
|
|
6031
|
+
self.sensorNavigationRecovering = false
|
|
5139
6032
|
self.recoverPageAndMic(reason: "nav-exit")
|
|
5140
6033
|
}
|
|
5141
6034
|
}
|
|
@@ -5505,12 +6398,15 @@ final class G2: NSObject, SGCManager {
|
|
|
5505
6398
|
startupPageCreated = false
|
|
5506
6399
|
pageCreated = false
|
|
5507
6400
|
stockImageStreamWarmedUp = false
|
|
6401
|
+
cfwTransport = G2CfwTransport.Encoder()
|
|
6402
|
+
cfwStreamSequence = UInt8.random(in: 0 ... 255)
|
|
6403
|
+
cfwPageInstalled = false
|
|
5508
6404
|
dashboardShowing = 0
|
|
5509
6405
|
dashboardOpening = false
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
6406
|
+
cancelSensorCommands()
|
|
6407
|
+
cfwClassification = nil
|
|
6408
|
+
publishRingBattery(.disconnected)
|
|
6409
|
+
keyboardBridge.linkLost()
|
|
5514
6410
|
cfwSceneActive = false
|
|
5515
6411
|
cfwLastIdleResurrectAt = nil
|
|
5516
6412
|
cfwLastFrame = nil
|
|
@@ -5696,9 +6592,8 @@ final class G2: NSObject, SGCManager {
|
|
|
5696
6592
|
|
|
5697
6593
|
// MARK: - Incoming Data Handling
|
|
5698
6594
|
|
|
5699
|
-
private func handleNotifyData(_ data: Data,
|
|
6595
|
+
private func handleNotifyData(_ data: Data, sourceKey: String, arrival: G2InputArrival) {
|
|
5700
6596
|
// Distinguish left vs right peripheral so multi-packet reassembly doesn't collide
|
|
5701
|
-
let sourceKey = peripheral === leftPeripheral ? "L" : "R"
|
|
5702
6597
|
guard let result = receiveManager.handlePacket(data, sourceKey: sourceKey) else { return }
|
|
5703
6598
|
// Bridge.log(
|
|
5704
6599
|
// "G2: handleNotifyData() - serviceId=\(result.serviceId), payload=\(result.payload.count) bytes"
|
|
@@ -5707,7 +6602,7 @@ final class G2: NSObject, SGCManager {
|
|
|
5707
6602
|
// Route based on service ID
|
|
5708
6603
|
switch result.serviceId {
|
|
5709
6604
|
case ServiceID.evenHub.rawValue:
|
|
5710
|
-
handleEvenHubResponse(result.payload)
|
|
6605
|
+
handleEvenHubResponse(result.payload, arrival: arrival)
|
|
5711
6606
|
case ServiceID.deviceSettings.rawValue:
|
|
5712
6607
|
handleDevSettingsResponse(result.payload, sourceKey: sourceKey)
|
|
5713
6608
|
case ServiceID.g2Setting.rawValue:
|
|
@@ -5717,7 +6612,7 @@ final class G2: NSObject, SGCManager {
|
|
|
5717
6612
|
case ServiceID.dashboard.rawValue:
|
|
5718
6613
|
handleDashboardResponse(result.payload)
|
|
5719
6614
|
case ServiceID.gestureCtrl.rawValue:
|
|
5720
|
-
await handleGestureCtrl(result.payload)
|
|
6615
|
+
Task { await handleGestureCtrl(result.payload) }
|
|
5721
6616
|
case ServiceID.navigation.rawValue:
|
|
5722
6617
|
handleNavigationResponse(result.payload)
|
|
5723
6618
|
case ServiceID.evenAI.rawValue:
|
|
@@ -5726,6 +6621,8 @@ final class G2: NSObject, SGCManager {
|
|
|
5726
6621
|
handleEvenHubCtrlResponse(result.payload)
|
|
5727
6622
|
case ServiceID.notification.rawValue:
|
|
5728
6623
|
handleNotificationResponse(result.payload)
|
|
6624
|
+
case ServiceID.cfwStream.rawValue:
|
|
6625
|
+
break // ACK/NACK and cache replies are correlated inline in correlateCfwAck()
|
|
5729
6626
|
default:
|
|
5730
6627
|
Bridge.log(
|
|
5731
6628
|
"G2: Unhandled service \(result.serviceId) (\(result.payload.count) bytes): \(result.payload.map { String(format: "%02X", $0) }.joined())"
|
|
@@ -5806,18 +6703,18 @@ final class G2: NSObject, SGCManager {
|
|
|
5806
6703
|
"timestamp": now,
|
|
5807
6704
|
]
|
|
5808
6705
|
)
|
|
5809
|
-
Bridge.sendTypedMessage(
|
|
5810
|
-
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
)
|
|
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
|
+
// )
|
|
5821
6718
|
}
|
|
5822
6719
|
}
|
|
5823
6720
|
}
|
|
@@ -5858,16 +6755,13 @@ final class G2: NSObject, SGCManager {
|
|
|
5858
6755
|
guard let compassData = fields[10] as? Data else { return }
|
|
5859
6756
|
var cReader = ProtobufReader(compassData)
|
|
5860
6757
|
let cFields = cReader.parseFields()
|
|
5861
|
-
|
|
5862
|
-
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
"
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
"timestamp": Int64(Date().timeIntervalSince1970 * 1000),
|
|
5869
|
-
]
|
|
5870
|
-
)
|
|
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)
|
|
5871
6765
|
|
|
5872
6766
|
case NavigationCmd.osNotifyCompassCalibrateStart.rawValue:
|
|
5873
6767
|
Bridge.log("G2: compass calibration started — wearer should look around")
|
|
@@ -5908,7 +6802,7 @@ final class G2: NSObject, SGCManager {
|
|
|
5908
6802
|
}
|
|
5909
6803
|
}
|
|
5910
6804
|
|
|
5911
|
-
private func handleEvenHubResponse(_ payload: Data) {
|
|
6805
|
+
private func handleEvenHubResponse(_ payload: Data, arrival: G2InputArrival) {
|
|
5912
6806
|
// Parse evenhub_main_msg_ctx: field 1 = Cmd (varint), field 13 = DevEvent (submessage)
|
|
5913
6807
|
var reader = ProtobufReader(payload)
|
|
5914
6808
|
let fields = reader.parseFields()
|
|
@@ -5931,7 +6825,7 @@ final class G2: NSObject, SGCManager {
|
|
|
5931
6825
|
if cmdValue == EvenHubResponseCmd.osNotifyEventToApp.rawValue {
|
|
5932
6826
|
// Device event from glasses (touch/gesture or sensor data)
|
|
5933
6827
|
guard let devEventData = fields[13] as? Data else { return }
|
|
5934
|
-
handleTouchEvent(devEventData)
|
|
6828
|
+
handleTouchEvent(devEventData, arrival: arrival)
|
|
5935
6829
|
} else if cmdValue == 17 {
|
|
5936
6830
|
// Miniapp selection from glasses dashboard menu (cmdId=17)
|
|
5937
6831
|
// Dedup: L and R peripherals both deliver this event, so debounce or
|
|
@@ -6074,15 +6968,15 @@ final class G2: NSObject, SGCManager {
|
|
|
6074
6968
|
return (x, y, z)
|
|
6075
6969
|
}
|
|
6076
6970
|
|
|
6077
|
-
private func handleTouchEvent(_ devEventData: Data) {
|
|
6971
|
+
private func handleTouchEvent(_ devEventData: Data, arrival: G2InputArrival) {
|
|
6078
6972
|
// Parse SendDeviceEvent: field 1=ListEvent, field 2=TextEvent, field 3=SysEvent
|
|
6079
6973
|
var reader = ProtobufReader(devEventData)
|
|
6080
6974
|
let fields = reader.parseFields()
|
|
6081
6975
|
|
|
6082
|
-
let timestamp =
|
|
6976
|
+
let timestamp = arrival.timestamp
|
|
6083
6977
|
|
|
6084
6978
|
// Classify IMU reports before applying the click debounce. Keepalive samples arrive
|
|
6085
|
-
// continuously and must not update
|
|
6979
|
+
// continuously and must not update the debounce, or a real gesture delivered
|
|
6086
6980
|
// within 100 ms of a sample can be dropped.
|
|
6087
6981
|
var parsedSysFields: [Int: Any]?
|
|
6088
6982
|
if let sysData = fields[3] as? Data {
|
|
@@ -6090,13 +6984,14 @@ final class G2: NSObject, SGCManager {
|
|
|
6090
6984
|
let sysFields = sysReader.parseFields()
|
|
6091
6985
|
if (sysFields[1] as? Int32) == OsEventType.imuDataReport.rawValue {
|
|
6092
6986
|
lastImuReportTimestamp = timestamp
|
|
6987
|
+
displayDiagnostics.count("imuReceived")
|
|
6093
6988
|
let shouldForwardSamples =
|
|
6094
6989
|
DeviceStore.shared.get("bluetooth", "imu_enabled") as? Bool ?? false
|
|
6095
6990
|
if shouldForwardSamples,
|
|
6096
6991
|
let imuData = sysFields[3] as? Data,
|
|
6097
6992
|
let imu = parseImuReportData(imuData)
|
|
6098
6993
|
{
|
|
6099
|
-
|
|
6994
|
+
displayDiagnostics.count("imuSamples")
|
|
6100
6995
|
Bridge.sendAccelEvent(x: imu.x, y: imu.y, z: imu.z, timestamp: timestamp)
|
|
6101
6996
|
}
|
|
6102
6997
|
return
|
|
@@ -6104,20 +6999,31 @@ final class G2: NSObject, SGCManager {
|
|
|
6104
6999
|
parsedSysFields = sysFields
|
|
6105
7000
|
}
|
|
6106
7001
|
|
|
6107
|
-
// tap_start/tap_end bracket every gesture: the end edge lands within the
|
|
6108
|
-
// debounce window of its own start, and the click event fires right after
|
|
6109
|
-
// tap_end. Exempt the edges from the debounce (check AND update) or they
|
|
6110
|
-
// either get dropped or swallow the real gesture that follows.
|
|
6111
7002
|
let sysEventTypeRaw = parsedSysFields?[1] as? Int32
|
|
6112
|
-
let
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
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
|
|
6121
7027
|
}
|
|
6122
7028
|
|
|
6123
7029
|
// if we are receiving touch events we are fully booted:
|
|
@@ -6155,10 +7061,11 @@ final class G2: NSObject, SGCManager {
|
|
|
6155
7061
|
// whose own shutdown elicits the next exit: the 1-2s
|
|
6156
7062
|
// rebuild→systemExit→rebuild loop. Drop the event entirely — no
|
|
6157
7063
|
// state change, no touch-stream forward to miniapps.
|
|
7064
|
+
// (Only stock image ACKs count: SID-0xf0 replies need no page.)
|
|
6158
7065
|
if eventType == .systemExit || eventType == .abnormalExit, pageCreated {
|
|
6159
7066
|
let sinceAck = imgAckBox.msSinceLastSuccess()
|
|
6160
7067
|
if sinceAck < PHANTOM_EXIT_ACK_WINDOW_MS {
|
|
6161
|
-
Bridge.log("G2: \(eventType) ignored —
|
|
7068
|
+
Bridge.log("G2: \(eventType) ignored — a stock image acked \(sinceAck)ms ago (phantom exit)")
|
|
6162
7069
|
return
|
|
6163
7070
|
}
|
|
6164
7071
|
}
|
|
@@ -6173,7 +7080,6 @@ final class G2: NSObject, SGCManager {
|
|
|
6173
7080
|
timestamp: timestamp,
|
|
6174
7081
|
source: eventSource
|
|
6175
7082
|
)
|
|
6176
|
-
Bridge.log("G2: SysEvent → \(eventType) \(eventSource)")
|
|
6177
7083
|
|
|
6178
7084
|
if eventSource == 2 {
|
|
6179
7085
|
// controller must be connected and fully booted:
|
|
@@ -6247,6 +7153,8 @@ final class G2: NSObject, SGCManager {
|
|
|
6247
7153
|
case .systemExit: return "system_exit"
|
|
6248
7154
|
case .tapStart: return "tap_start"
|
|
6249
7155
|
case .tapEnd: return "tap_end"
|
|
7156
|
+
case .tapThenHold: return "tap_then_hold"
|
|
7157
|
+
case .ringTouchDown: return "touch_down"
|
|
6250
7158
|
case .imuDataReport: return nil
|
|
6251
7159
|
case .abnormalExit: return nil // don't report abnormal exits as gestures
|
|
6252
7160
|
}
|
|
@@ -6376,6 +7284,12 @@ final class G2: NSObject, SGCManager {
|
|
|
6376
7284
|
}
|
|
6377
7285
|
}
|
|
6378
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
|
+
|
|
6379
7293
|
private func handleG2SettingResponse(_ payload: Data) {
|
|
6380
7294
|
// Parse G2SettingPackage: field 1=commandId, field 4=DeviceReceiveRequestFromAPP (response), field 5=DeviceSendInfoToAPP
|
|
6381
7295
|
var reader = ProtobufReader(payload)
|
|
@@ -6396,6 +7310,28 @@ final class G2: NSObject, SGCManager {
|
|
|
6396
7310
|
if let sendData = fields[5] as? Data {
|
|
6397
7311
|
parseDeviceSendToApp(sendData)
|
|
6398
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
|
+
}
|
|
6399
7335
|
|
|
6400
7336
|
// Firmware identity is connection-sticky: firmware cannot change
|
|
6401
7337
|
// without a reboot/disconnect, while individual settings packets can
|
|
@@ -6419,6 +7355,8 @@ final class G2: NSObject, SGCManager {
|
|
|
6419
7355
|
let active = supportsCfwSceneRendering
|
|
6420
7356
|
DeviceStore.shared.apply("glasses", "sceneCanvasWidth", active ? 576 : 0)
|
|
6421
7357
|
DeviceStore.shared.apply("glasses", "sceneCanvasHeight", active ? 288 : 0)
|
|
7358
|
+
DeviceStore.shared.apply("glasses", "sceneVector", supportsCfwVectorScene)
|
|
7359
|
+
DeviceStore.shared.apply("glasses", "sceneSettle", supportsCfwSceneSettle)
|
|
6422
7360
|
}
|
|
6423
7361
|
|
|
6424
7362
|
private func updateCfwCapabilities(_ data: Data?, definitive: Bool) {
|
|
@@ -6429,19 +7367,20 @@ final class G2: NSObject, SGCManager {
|
|
|
6429
7367
|
field100: data, definitive: definitive, currentCapability: cfwCapabilityString
|
|
6430
7368
|
)
|
|
6431
7369
|
else { return }
|
|
6432
|
-
|
|
7370
|
+
let wasDifferent = result != cfwClassification
|
|
7371
|
+
cfwClassification = result
|
|
6433
7372
|
if result.isCfw {
|
|
6434
|
-
let wasDifferent = result.capabilityString != cfwCapabilityString
|
|
6435
|
-
cfwCapabilityString = result.capabilityString
|
|
6436
|
-
cfwFeatures = result.features
|
|
6437
|
-
cfwProtocolVersion = result.protocolVersion
|
|
6438
7373
|
if wasDifferent {
|
|
6439
|
-
Bridge.log("G2:
|
|
7374
|
+
Bridge.log("G2: CFW detected — \(result.capabilityString ?? "")")
|
|
6440
7375
|
rearmWakeLeaseIfWanted()
|
|
7376
|
+
startAncsRelay()
|
|
7377
|
+
syncBleLink()
|
|
7378
|
+
keyboardBridge.cfwDetected(revision: result.revision)
|
|
6441
7379
|
}
|
|
6442
7380
|
} else {
|
|
6443
|
-
|
|
6444
|
-
|
|
7381
|
+
publishRingBattery(.disconnected)
|
|
7382
|
+
stopAncsRelay(sendDisable: false)
|
|
7383
|
+
keyboardBridge.linkLost()
|
|
6445
7384
|
}
|
|
6446
7385
|
}
|
|
6447
7386
|
|
|
@@ -6460,8 +7399,8 @@ final class G2: NSObject, SGCManager {
|
|
|
6460
7399
|
DeviceStore.shared.get("glasses", "rightFirmwareVersion") as? String
|
|
6461
7400
|
?? DeviceStore.shared.get("glasses", "leftFirmwareVersion") as? String ?? ""
|
|
6462
7401
|
let displayVersion: String
|
|
6463
|
-
if
|
|
6464
|
-
displayVersion = baseVersion.isEmpty ?
|
|
7402
|
+
if let name = cfwClassification?.displayName {
|
|
7403
|
+
displayVersion = baseVersion.isEmpty ? name : "\(baseVersion) (\(name))"
|
|
6465
7404
|
} else {
|
|
6466
7405
|
displayVersion = baseVersion
|
|
6467
7406
|
}
|
|
@@ -6514,18 +7453,12 @@ final class G2: NSObject, SGCManager {
|
|
|
6514
7453
|
}
|
|
6515
7454
|
}
|
|
6516
7455
|
|
|
6517
|
-
private func handleMenuResponse(_
|
|
7456
|
+
private func handleMenuResponse(_: Data) {
|
|
6518
7457
|
// meun_main_msg_ctx response from glasses (ack of our menu send)
|
|
6519
7458
|
// (informational only)
|
|
6520
|
-
Bridge.log(
|
|
6521
|
-
"G2: menu response: \(data.prefix(32).map { String(format: "%02X", $0) }.joined())"
|
|
6522
|
-
)
|
|
6523
7459
|
}
|
|
6524
7460
|
|
|
6525
7461
|
private func handleDashboardResponse(_ payload: Data) {
|
|
6526
|
-
Bridge.log(
|
|
6527
|
-
"G2: dashboard response: \(payload.map { String(format: "%02X", $0) }.joined())"
|
|
6528
|
-
)
|
|
6529
7462
|
var reader = ProtobufReader(payload)
|
|
6530
7463
|
let fields = reader.parseFields()
|
|
6531
7464
|
let cmd = fields[1] as? Int32 ?? -1
|
|
@@ -6554,11 +7487,8 @@ final class G2: NSObject, SGCManager {
|
|
|
6554
7487
|
}
|
|
6555
7488
|
}
|
|
6556
7489
|
|
|
6557
|
-
private func handleEvenHubCtrlResponse(_
|
|
7490
|
+
private func handleEvenHubCtrlResponse(_: Data) {
|
|
6558
7491
|
// EvenHub CTRL channel response (informational only)
|
|
6559
|
-
Bridge.log(
|
|
6560
|
-
"G2: evenHubCtrl response: \(data.prefix(8).map { String(format: "%02X", $0) }.joined())"
|
|
6561
|
-
)
|
|
6562
7492
|
}
|
|
6563
7493
|
|
|
6564
7494
|
private func handleGestureCtrl(_ data: Data) async {
|
|
@@ -6771,6 +7701,7 @@ extension G2: CBCentralManagerDelegate {
|
|
|
6771
7701
|
let authorized = (leftPeripheral?.ancsAuthorized ?? false)
|
|
6772
7702
|
|| (rightPeripheral?.ancsAuthorized ?? false)
|
|
6773
7703
|
DeviceStore.shared.apply("glasses", "ancsAuthorized", authorized)
|
|
7704
|
+
publishNotificationStatus()
|
|
6774
7705
|
}
|
|
6775
7706
|
|
|
6776
7707
|
nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
|
@@ -6861,12 +7792,14 @@ extension G2: CBCentralManagerDelegate {
|
|
|
6861
7792
|
self.startupPageCreated = false
|
|
6862
7793
|
self.pageCreated = false
|
|
6863
7794
|
self.stockImageStreamWarmedUp = false
|
|
7795
|
+
self.cfwTransport = G2CfwTransport.Encoder()
|
|
7796
|
+
self.cfwStreamSequence = UInt8.random(in: 0 ... 255)
|
|
7797
|
+
self.cfwPageInstalled = false
|
|
6864
7798
|
self.dashboardShowing = 0
|
|
6865
7799
|
self.dashboardOpening = false
|
|
6866
|
-
self.
|
|
6867
|
-
self.
|
|
6868
|
-
self.
|
|
6869
|
-
self.cfwProtocolVersion = 0
|
|
7800
|
+
self.cancelSensorCommands()
|
|
7801
|
+
self.cfwClassification = nil
|
|
7802
|
+
self.publishRingBattery(.disconnected)
|
|
6870
7803
|
self.cfwSceneActive = false
|
|
6871
7804
|
// Fresh connection gets a fresh idle-resurrect budget.
|
|
6872
7805
|
self.cfwLastIdleResurrectAt = nil
|
|
@@ -6877,6 +7810,8 @@ extension G2: CBCentralManagerDelegate {
|
|
|
6877
7810
|
// after the reconnect.
|
|
6878
7811
|
self.stopFbLease(sendRelease: false) // link is gone; the leases fail open in 90 s
|
|
6879
7812
|
self.stopWakeLease(sendRelease: false) // wakeLeaseWanted persists; re-armed on CFW re-detection
|
|
7813
|
+
self.stopAncsRelay(sendDisable: false)
|
|
7814
|
+
self.keyboardBridge.linkLost()
|
|
6880
7815
|
DeviceStore.shared.apply("glasses", "connected", false)
|
|
6881
7816
|
DeviceStore.shared.apply("glasses", "fullyBooted", false)
|
|
6882
7817
|
|
|
@@ -6933,26 +7868,6 @@ extension G2: CBPeripheralDelegate {
|
|
|
6933
7868
|
|
|
6934
7869
|
for char in characteristics {
|
|
6935
7870
|
let uuid = char.uuid
|
|
6936
|
-
let props = char.properties
|
|
6937
|
-
|
|
6938
|
-
// Log all characteristics with their properties for debugging
|
|
6939
|
-
var propStr: [String] = []
|
|
6940
|
-
if props.contains(.read) {
|
|
6941
|
-
propStr.append("read")
|
|
6942
|
-
}
|
|
6943
|
-
if props.contains(.write) {
|
|
6944
|
-
propStr.append("write")
|
|
6945
|
-
}
|
|
6946
|
-
if props.contains(.writeWithoutResponse) {
|
|
6947
|
-
propStr.append("writeNoResp")
|
|
6948
|
-
}
|
|
6949
|
-
if props.contains(.notify) {
|
|
6950
|
-
propStr.append("notify")
|
|
6951
|
-
}
|
|
6952
|
-
if props.contains(.indicate) {
|
|
6953
|
-
propStr.append("indicate")
|
|
6954
|
-
}
|
|
6955
|
-
Bridge.log("G2: \(side) char \(uuid) props=[\(propStr.joined(separator: ","))]")
|
|
6956
7871
|
|
|
6957
7872
|
if uuid == G2BLE.CHAR_WRITE {
|
|
6958
7873
|
Bridge.log("G2: Found WRITE char on \(side)")
|
|
@@ -7020,6 +7935,7 @@ extension G2: CBPeripheralDelegate {
|
|
|
7020
7935
|
error: Error?
|
|
7021
7936
|
) {
|
|
7022
7937
|
guard let data = characteristic.value, error == nil else { return }
|
|
7938
|
+
let arrival = G2InputArrival()
|
|
7023
7939
|
|
|
7024
7940
|
if characteristic.uuid == G2Flasher.dataNotifyUUID {
|
|
7025
7941
|
firmwareFlasher.receive(
|
|
@@ -7044,21 +7960,28 @@ extension G2: CBPeripheralDelegate {
|
|
|
7044
7960
|
// intermittent "glasses stopped responding to image sends". See [ImgAckBox].
|
|
7045
7961
|
if characteristic.uuid == G2BLE.CHAR_NOTIFY {
|
|
7046
7962
|
correlateImageAck(data)
|
|
7963
|
+
correlateCfwAck(data)
|
|
7047
7964
|
}
|
|
7048
7965
|
|
|
7049
|
-
|
|
7050
|
-
|
|
7051
|
-
|
|
7052
|
-
|
|
7053
|
-
|
|
7054
|
-
|
|
7055
|
-
|
|
7056
|
-
|
|
7057
|
-
|
|
7058
|
-
|
|
7059
|
-
|
|
7060
|
-
|
|
7061
|
-
|
|
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).
|
|
7062
7985
|
Bridge.log(
|
|
7063
7986
|
"G2: FILE notify (\(side)): \(data.map { String(format: "%02X", $0) }.joined())"
|
|
7064
7987
|
)
|
|
@@ -7097,20 +8020,25 @@ extension G2: CBPeripheralDelegate {
|
|
|
7097
8020
|
var reader = ProtobufReader(payload)
|
|
7098
8021
|
let fields = reader.parseFields()
|
|
7099
8022
|
guard let resData = fields[6] as? Data else { return } // field 6 = ImgResCmd
|
|
7100
|
-
|
|
7101
|
-
let resFields = resReader.parseFields()
|
|
7102
|
-
guard let errorCode = resFields[8] as? Int32,
|
|
7103
|
-
let ackSession = resFields[3] as? Int32
|
|
7104
|
-
else { return }
|
|
7105
|
-
let ackFragment = (resFields[6] as? Int32) ?? 0
|
|
7106
|
-
Bridge.log(
|
|
7107
|
-
"G2: img_res: session=\(ackSession) fragment=\(ackFragment) errorCode=\(errorCode) success=\(errorCode == 4)"
|
|
7108
|
-
)
|
|
8023
|
+
guard let ack = ImgAckBox.decodeResult(resData) else { return }
|
|
7109
8024
|
completeImageAck(
|
|
7110
|
-
session:
|
|
8025
|
+
session: ack.session, fragmentIndex: ack.fragment, success: ack.success
|
|
7111
8026
|
)
|
|
7112
8027
|
}
|
|
7113
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
|
+
|
|
7114
8042
|
nonisolated func peripheral(
|
|
7115
8043
|
_: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?
|
|
7116
8044
|
) {
|
|
@@ -7128,8 +8056,10 @@ extension G2: CBPeripheralDelegate {
|
|
|
7128
8056
|
Task { @MainActor [weak self] in
|
|
7129
8057
|
guard let self = self else { return }
|
|
7130
8058
|
if peripheral === self.rightPeripheral {
|
|
8059
|
+
self.displayDiagnostics.count("readyCallbacksR")
|
|
7131
8060
|
self.startDrain(right: true)
|
|
7132
8061
|
} else if peripheral === self.leftPeripheral {
|
|
8062
|
+
self.displayDiagnostics.count("readyCallbacksL")
|
|
7133
8063
|
self.startDrain(right: false)
|
|
7134
8064
|
}
|
|
7135
8065
|
}
|