@glassly/bluetooth-sdk 0.1.0-dev.99 → 0.2.8-dev.445

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