@glassly/bluetooth-sdk 0.1.0-dev.98 → 0.2.8-dev.444

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +1075 -257
  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 +1434 -504
  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
@@ -125,14 +126,18 @@ private enum OsEventType: Int32 {
125
126
  case abnormalExit = 6
126
127
  case systemExit = 7
127
128
  case imuDataReport = 8 // IMU_DATA_REPORT — Sys_ItemEvent carries imuData
128
- // 9/10 are tap start/end on newer stock firmware. The Glassly CFW (older
129
- // base) deliberately reuses the same values for its forwarded long-press /
130
- // long-press-release SysEvents (ET_LONG=9 and ET_REL=10, emitted only while
131
- // our fbguard lease is active), so CFW hold
132
- // gestures arrive here as tap_start/tap_end with no extra mapping — just
133
- // recognized on long-press instead of raw finger-down.
134
- case tapStart = 9 // finger down on the touch bar
135
- case tapEnd = 10 // finger lifted
129
+ // 9/10 are the SDK 0.0.14 LONG_PRESS/RELEASE pair. The 2.2.9 CFW forwards
130
+ // them (ET_LONG/ET_REL) while our fbguard lease is active; the gesture
131
+ // names stay tap_start/tap_end for API stability.
132
+ case tapStart = 9 // press recognized (hold start)
133
+ case tapEnd = 10 // released
134
+ case tapThenHold = 11 // CFW extension: 2.2.9 tap-then-hold (stock Menu gesture)
135
+ // 12 is head-up (delivered through the settings path, not here); 13 was an
136
+ // experimental temple edge and is burned. 14 is the Glassly CFW's ring
137
+ // touch-down (R1 subtype 0x0d, GLASSLYCFW/37 on 2.3.0.24). Only the ring
138
+ // produces it and the stock sender leaves source 0, so the id itself is the
139
+ // ring marker. Unknown ring wire types arrive as 127 and are dropped.
140
+ case ringTouchDown = 14
136
141
  }
137
142
 
138
143
  /// g2_settingCommandId from g2_setting.proto
@@ -479,7 +484,7 @@ private enum EvenHubProto {
479
484
  borderWidth: Int32 = 0, borderColor: Int32 = 0, borderRadius: Int32 = 0,
480
485
  paddingLength: Int32 = 0, containerID: Int32,
481
486
  containerName: String? = nil, isEventCapture: Bool = false,
482
- content: String? = nil
487
+ content: String? = nil, textColor: Int32? = nil
483
488
  ) -> Data {
484
489
  var w = ProtobufWriter()
485
490
  w.writeInt32Field(1, x) // XPosition
@@ -498,6 +503,12 @@ private enum EvenHubProto {
498
503
  if let content = content {
499
504
  w.writeStringField(12, content) // Content
500
505
  }
506
+ if let textColor = textColor {
507
+ // SDK 0.0.14 text brightness level 0-4 (4 = default). Field number
508
+ // is a guess: our proto dump is 2.1.0-era (last field 12) and the
509
+ // 2.2.9 firmware's tag isn't extracted — verify on-device.
510
+ w.writeInt32Field(13, textColor) // TextColor (unverified)
511
+ }
501
512
  return w.data
502
513
  }
503
514
 
@@ -1586,14 +1597,67 @@ private class G2ReceiveManager {
1586
1597
  /// All access is guarded by `lock`. Resuming a `CheckedContinuation` from any thread is safe, and
1587
1598
  /// `arm`/`resolve` clear the slot atomically so neither the duplicate L/R ACK nor the timeout can
1588
1599
  /// resume the same continuation twice.
1589
- 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
+
1590
1617
  private let lock = NSLock()
1591
1618
  private var session: Int?
1592
1619
  private var fragment: Int32?
1593
1620
  private var cont: CheckedContinuation<Bool, Never>?
1621
+ /// Names the current arming. A timeout carries the token of the arming it was
1622
+ /// started for, so a timer left over from an earlier transfer can never fail a
1623
+ /// later one that happens to reuse the same (session, fragment) key.
1624
+ private var token: UInt64 = 0
1625
+ /// The timeout task of the current arming, cancelled once the ack lands.
1626
+ private var timer: Task<Void, Never>?
1594
1627
  /// Wall-clock ms of the last successful fragment ack — a cheap page-liveness
1595
1628
  /// signal (a torn-down page cannot ack image transfers).
1596
1629
  private var lastSuccessMs: Int64 = 0
1630
+ private var lastOutcome: (at: TimeInterval, reason: String)?
1631
+
1632
+ func outcome() -> (at: TimeInterval, reason: String)? {
1633
+ lock.lock()
1634
+ defer { lock.unlock() }
1635
+ return lastOutcome
1636
+ }
1637
+
1638
+ /// Arm before sending, including transports that can reply synchronously.
1639
+ /// Cancellation resolves the waiter so shutdown never waits out an ACK timeout.
1640
+ @MainActor
1641
+ func wait(session: Int, fragment: Int32, timeoutNs: UInt64, send: () -> Void) async -> Bool {
1642
+ await withTaskCancellationHandler {
1643
+ await withCheckedContinuation { cont in
1644
+ let token = arm(session: session, fragment: fragment, cont: cont)
1645
+ guard !Task.isCancelled else {
1646
+ timeout(token: token)
1647
+ return
1648
+ }
1649
+ let timer = Task.detached { [self] in
1650
+ try? await Task.sleep(nanoseconds: timeoutNs)
1651
+ guard !Task.isCancelled else { return }
1652
+ timeout(token: token)
1653
+ }
1654
+ attachTimer(timer, token: token)
1655
+ send()
1656
+ }
1657
+ } onCancel: {
1658
+ self.resolve(session: session, fragment: fragment, success: false)
1659
+ }
1660
+ }
1597
1661
 
1598
1662
  /// Milliseconds since the last successful ack, or Int64.max if none yet.
1599
1663
  func msSinceLastSuccess() -> Int64 {
@@ -1604,13 +1668,49 @@ private final class ImgAckBox {
1604
1668
  return Int64(Date().timeIntervalSince1970 * 1000) - last
1605
1669
  }
1606
1670
 
1607
- /// Install the awaiting continuation for `(session, fragment)`.
1608
- func arm(session: Int, fragment: Int32, cont: CheckedContinuation<Bool, Never>) {
1671
+ /// Install the awaiting continuation for `(session, fragment)`; returns the arming's token.
1672
+ func arm(session: Int, fragment: Int32, cont: CheckedContinuation<Bool, Never>) -> UInt64 {
1609
1673
  lock.lock()
1674
+ token &+= 1
1675
+ let t = token
1610
1676
  self.session = session
1611
1677
  self.fragment = fragment
1612
1678
  self.cont = cont
1679
+ lastOutcome = nil
1680
+ let stale = timer
1681
+ timer = nil
1682
+ lock.unlock()
1683
+ stale?.cancel()
1684
+ return t
1685
+ }
1686
+
1687
+ /// Attach the timeout task for `token`; a task for an arming that is already over is cancelled.
1688
+ func attachTimer(_ task: Task<Void, Never>, token: UInt64) {
1689
+ lock.lock()
1690
+ let current = self.token == token && cont != nil
1691
+ if current {
1692
+ timer = task
1693
+ }
1694
+ lock.unlock()
1695
+ if !current {
1696
+ task.cancel()
1697
+ }
1698
+ }
1699
+
1700
+ /// Time out the arming named by `token`, if it is still the one awaiting an ack.
1701
+ func timeout(token: UInt64) {
1702
+ lock.lock()
1703
+ guard self.token == token, let c = cont else {
1704
+ lock.unlock()
1705
+ return
1706
+ }
1707
+ session = nil
1708
+ fragment = nil
1709
+ cont = nil
1710
+ timer = nil
1711
+ lastOutcome = (ProcessInfo.processInfo.systemUptime, "timeout")
1613
1712
  lock.unlock()
1713
+ c.resume(returning: false)
1614
1714
  }
1615
1715
 
1616
1716
  /// Resume the continuation if it matches `(session, fragment)`. Returns true if it fired.
@@ -1624,18 +1724,17 @@ private final class ImgAckBox {
1624
1724
  self.session = nil
1625
1725
  self.fragment = nil
1626
1726
  cont = nil
1727
+ lastOutcome = (ProcessInfo.processInfo.systemUptime, success ? "ack" : "nack")
1728
+ let t = timer
1729
+ timer = nil
1627
1730
  if success {
1628
1731
  lastSuccessMs = Int64(Date().timeIntervalSince1970 * 1000)
1629
1732
  }
1630
1733
  lock.unlock()
1734
+ t?.cancel()
1631
1735
  c.resume(returning: success)
1632
1736
  return true
1633
1737
  }
1634
-
1635
- /// Time out the continuation for `(session, fragment)` (resumes false if still armed).
1636
- func timeout(session: Int, fragment: Int32) {
1637
- _ = resolve(session: session, fragment: fragment, success: false)
1638
- }
1639
1738
  }
1640
1739
 
1641
1740
  // MARK: - G2 Class (SGCManager implementation)
@@ -1704,31 +1803,104 @@ final class G2: NSObject, SGCManager {
1704
1803
  let hasMic = true
1705
1804
  nonisolated let firmwareFlasher = G2Flasher()
1706
1805
 
1707
- /// Glassly CFW advertises this capability string as top-level field 100 in
1708
- /// its G2 settings response. Stock firmware does not emit the field.
1709
- private var cfwCapabilityString: String?
1710
- /// True once the connection is classified either way (CFW detected, or a
1711
- /// definitive settings response without field 100). Until then legacy text
1712
- /// would race the classification and split draw/clear across render paths.
1713
- private var cfwClassified = false
1714
- private var cfwFeatures: Set<String> = []
1715
- /// "EVENCFW/NN" revision. Gates features with no capability token (mode-9 copies: revision 15).
1716
- 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
+
1717
1851
  private var cfwSceneActive = false
1718
1852
  /// Rate limit on resurrecting the page for a blank scene; a non-blank one rebuilds immediately.
1719
1853
  private var cfwLastIdleResurrectAt: Date?
1720
1854
  private static let cfwIdleResurrectMinIntervalMs: Double = 5000
1721
- private static let cfwSceneContainerID: Int32 = 10
1722
1855
  private let cfwRenderer = G2CfwRenderer()
1856
+ /// Private SID-0xf0 message transport (GLASSLYCFW/31): one deflate
1857
+ /// context per connection, one stream per message, ACK-gated exactly like
1858
+ /// the old image carrier was. Payloads still go out on the LEFT arm; the
1859
+ /// right lens processes them over the frame bridge and its ACK returns
1860
+ /// through the left. No EvenHub image container is involved any more.
1861
+ private var cfwTransport = G2CfwTransport.Encoder()
1862
+ private var cfwStreamSequence = UInt8.random(in: 0 ... 255)
1863
+ private let cfwAckBox = G2CfwAckBox()
1864
+ /// The container set is in CFW shape: the blank "dashboard" text object
1865
+ /// only (touch capture + mic), no stock text or image containers.
1866
+ private var cfwPageInstalled = false
1723
1867
  /// Deltas composite onto a shadow seeded by a mode-6 keyframe, so payloads drain in order.
1724
1868
  /// Kinds tag that chain: only `.full` may drop queued fulls/deltas; `.seed`/`.moveBundle` survive.
1725
- private enum CfwPayloadKind { case seed, full, delta, moveBundle, upload }
1869
+ private enum CfwPayloadKind { case seed, full, delta, moveBundle, upload, scene }
1726
1870
  private struct CfwPayload {
1727
1871
  let kind: CfwPayloadKind
1728
1872
  let data: Data
1873
+ /// Host frame this payload puts on the glasses — reported once acked.
1874
+ var presents: SceneFrame.PresentTag? = nil
1875
+ var originAt: TimeInterval?
1876
+ var frameSeq = 0
1877
+ var appId = ""
1878
+ /// Lenses this payload is for (scene SHOWs split per lens once a frame uses element depth).
1879
+ var lenses: UInt8 = G2CfwTransport.lensBoth
1729
1880
  }
1730
1881
 
1731
1882
  private var cfwPendingPayloads: [CfwPayload] = []
1883
+ /// Bumped by every clear so an encoder suspended on its actor when the clear arrived is discarded.
1884
+ private var cfwClearSerial = 0
1885
+ /// Latest unencoded state. Only reconcileDisplay encodes it, once the
1886
+ /// previous payload chain has drained, bounding work under backpressure.
1887
+ private var cfwPendingFrame: SceneFrame? {
1888
+ didSet {
1889
+ if cfwPendingFrame != nil {
1890
+ cfwPendingFrameAt = G2DisplayDiagnostics.now
1891
+ }
1892
+ }
1893
+ }
1894
+
1895
+ private var cfwPendingFrameAt = G2DisplayDiagnostics.now
1896
+ // Keep bounded render/BLE timings in phone logs so background stalls can
1897
+ // be diagnosed from bug reports (one summary and at most one stall per second).
1898
+ private let displayDiagnostics = G2DisplayDiagnostics(enabled: true)
1899
+ private var diagnosticFrameSeq = 0
1900
+ private var diagnosticAppId = ""
1901
+ /// No-op frames may join the payload currently awaiting its last fragment ACK.
1902
+ private var cfwInFlightPresentTag: SceneFrame.PresentTag?
1903
+ private var cfwPayloadInFlight = false
1732
1904
  private var cfwShadowSeeded = false
1733
1905
  private var cfwLastFrame: SceneFrame?
1734
1906
  private var cfwLastBoxes: G2CfwRenderer.LensBoxes?
@@ -1764,40 +1936,62 @@ final class G2: NSObject, SGCManager {
1764
1936
  /// the CFW capability string is seen again.
1765
1937
  private var wakeLeaseWanted = false
1766
1938
 
1767
- private var supportsCfwSceneRendering: Bool {
1768
- cfwCapabilityString != nil
1769
- && ["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
1770
1968
  // Escape hatch (default ON): OFF falls back to stock containers. Reconnect to switch cleanly.
1771
1969
  && (DeviceStore.shared.get("bluetooth", "cfw_scene_renderer") as? Bool ?? true)
1772
1970
  }
1773
1971
 
1774
- private var hasCfwCarrier: Bool {
1775
- imageContainers.count == 1
1776
- && imageContainers[0].id == Self.cfwSceneContainerID
1777
- && imageContainers[0].x == 0 && imageContainers[0].y == 0
1778
- && imageContainers[0].width == 576 && imageContainers[0].height == 288
1779
- }
1780
-
1781
- /// Mode-9 rect copies have no capability token; they shipped with
1782
- /// protocol revision 15 alongside the texture/font modes.
1783
- private var supportsCfwRectCopy: Bool {
1784
- cfwProtocolVersion >= 15
1972
+ private var hasCfwPage: Bool {
1973
+ cfwPageInstalled && textContainers.isEmpty && imageContainers.isEmpty
1785
1974
  }
1786
1975
 
1787
- /// Mode-15 builtin-font string draws (`font15` capability token).
1788
- private var supportsCfwBuiltinText: Bool {
1789
- 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
1790
1979
  }
1791
1980
 
1792
- /// Mode-12 uploads + mode-14 cached-glyph runs (mono text as draws).
1793
- private var supportsCfwTextureGlyphs: Bool {
1794
- cfwFeatures.contains("texcache12") && cfwFeatures.contains("texstr14")
1795
- }
1981
+ /// Tagged commits report settings field 129 once the scene has settled.
1982
+ private var supportsCfwSceneSettle: Bool { supportsCfwVectorScene }
1796
1983
 
1797
- /// Mode-12 uploads + mode-13 cached-image draws (repeated icons as draws).
1798
- private var supportsCfwTextureImages: Bool {
1799
- cfwFeatures.contains("texcache12") && cfwFeatures.contains("teximg13")
1800
- }
1984
+ /// Residency on the glasses is unknown (a send failed): the next vector frame redefines what it shows.
1985
+ private var cfwSceneInvalid = true
1986
+ /// Object-cache session epoch shared by both lenses (GLASSLYCFW/38); 0 until
1987
+ /// the RESET that opens a cache generation has been acknowledged.
1988
+ private var cfwCacheEpoch: UInt16 = 0
1989
+ /// Request id stamped into cache messages whose encoder left it 0.
1990
+ private var cfwCacheRequest: UInt16 = 0
1991
+ /// Cache generation whose RESET (and warm-up tween) went out.
1992
+ private var cfwSceneWarmedGeneration = -1
1993
+ /// Frames re-encoded after a MISSING reply, bounded per frame.
1994
+ private var cfwMissingRetries = 0
1801
1995
 
1802
1996
  /// Connection state
1803
1997
  private var connectionState: String = ConnTypes.DISCONNECTED
@@ -1837,6 +2031,19 @@ final class G2: NSObject, SGCManager {
1837
2031
  private var recoveryInFlight = false
1838
2032
  private var lastRecoveryRebuildMs: Int64 = 0
1839
2033
  private let RECOVERY_DEBOUNCE_MS: Int64 = 1500
2034
+ // A SHUTDOWN_PAGE we send is answered ~300 ms later by the firmware's
2035
+ // teardown trio (shutdown notify, systemExit, 08011A00). Creating the next
2036
+ // page before that lands makes the trio read as the NEW page dying:
2037
+ // pageCreated flips false, the toggle recovers, and the firmware sees
2038
+ // shutdown→create→create inside half a second — the rebuild storm the G2
2039
+ // answers by dropping the link (GLASSLYCFW/29 died on every dashboard
2040
+ // open this way). Resurrection and recovery wait out this window instead.
2041
+ private var lastPageShutdownSentMs: Int64 = 0
2042
+ private let PAGE_SHUTDOWN_SETTLE_MS: Int64 = 400
2043
+ private var pageShutdownSettleWakeArmed = false
2044
+ // Stock notifications panel hold: page rebuilds, fbguard lease and CFW frames stay
2045
+ // down while the panel opens so EvenHub sends cannot snatch the screen back.
2046
+ private var notificationsPanelHold = false
1840
2047
  // A systemExit that arrives while the page acked a carrier fragment this
1841
2048
  // recently is a phantom (the firmware emits exits even for healthy pages)
1842
2049
  // and is dropped entirely — see the SysEvent handler.
@@ -1965,7 +2172,7 @@ final class G2: NSObject, SGCManager {
1965
2172
  /// Current appId to associate EvenHub pages with (enables menu selection events)
1966
2173
  /// Set to the first menu item's appId so glasses know our page belongs to the menu
1967
2174
  private var activeMenuAppId: Int32?
1968
- private var lastClickTimestamp: Int64?
2175
+ private var touchDebounce = G2TouchDebounce()
1969
2176
  private var lastEvenHubResponseTimestamp: Int64?
1970
2177
  private var lastMenuSelectTimestamp: Int64?
1971
2178
  private var lastGestureCtrlTimestamp: Int64?
@@ -1977,11 +2184,7 @@ final class G2: NSObject, SGCManager {
1977
2184
  let y: Int32
1978
2185
  let width: Int32
1979
2186
  let height: Int32
1980
- var name: String {
1981
- // The known-good CFW packet names its carrier "img00"; match it byte-for-byte
1982
- // (the name appears in both the page create and every image send).
1983
- id == G2.cfwSceneContainerID ? "img00" : "img-\(id)"
1984
- }
2187
+ var name: String { "img-\(id)" }
1985
2188
 
1986
2189
  var bmpData: Data
1987
2190
  /// Set true when `bmpData` changes and the new pixels haven't been pushed to the glasses yet.
@@ -2092,39 +2295,34 @@ final class G2: NSObject, SGCManager {
2092
2295
 
2093
2296
  // MARK: - BLE Sending
2094
2297
 
2095
- // Per-side FIFO write queues, drained by a single paced async writer per side.
2096
- //
2097
- // We write `.withoutResponse` directly and pace with a small `Task.sleep` between packets —
2098
- // exactly like G1 (`G1.swift attemptSend`). We deliberately do NOT gate on
2099
- // `canSendWriteWithoutResponse` / wait for `peripheralIsReady(toSendWriteWithoutResponse:)`:
2100
- // iOS suppresses that "ready" callback for a backgrounded bluetooth-central app (G2 has no
2101
- // active AVAudioSession — glasses-mic audio arrives over BLE), so a gated drain DEADLOCKS in the
2102
- // background: the queue grows for the whole bg window and floods the glasses on resume — the
2103
- // captions "freeze in bg, then flood" bug. G1 never gates and never floods; this matches it.
2104
- // The pace replaces the gate's overflow protection (the original reason for gating). Any packet
2105
- // CoreBluetooth still drops self-heals: text is re-sent (TextContainer.pendingSends) and image
2106
- // fragments retry on their ACK (`awaitImageAck`).
2107
- private var leftWriteQueue: [Data] = []
2108
- private var rightWriteQueue: [Data] = []
2298
+ /// Per-side FIFO writers. Stock and background CFW use 6 ms packet spacing.
2299
+ /// Foreground CFW drains available credits in bounded bursts. Background
2300
+ /// readiness can remain false for seconds, so polling it is insufficient.
2301
+ /// Firmware ACKs, timeouts and resync still determine CFW delivery success.
2302
+ private struct BleWrite {
2303
+ let data: Data
2304
+ let queuedAt: TimeInterval
2305
+ let trace: G2DisplayDiagnostics.Fragment?
2306
+ let isPending: (() -> Bool)?
2307
+ }
2308
+
2309
+ private var leftWriteQueue: [BleWrite] = []
2310
+ private var rightWriteQueue: [BleWrite] = []
2109
2311
  private var leftDraining = false
2110
2312
  private var rightDraining = false
2111
- // Pace between consecutive packets (~G1's chunk pacing). Off any external callback, so the drain
2112
- // keeps making progress in the background instead of waiting for a callback iOS won't deliver.
2113
- private let writePaceNanos: UInt64 = 6_000_000
2114
- // Diagnostic: warn if a side's queue ever backs up (it shouldn't now — the drainer is always
2115
- // making progress). Rate-limited. Prefixed "BGCAP:" so it's easy to grep/strip after validation.
2116
- private var bgcapDepthLogAt: Double = 0
2117
-
2118
- private func sendToGlasses(_ packets: [Data], left: Bool = false, right: Bool = true) {
2313
+ private let writePaceNanos = G2WritePacing.packetDelayNs
2314
+ private func sendToGlasses(_ packets: [Data], left: Bool = false, right: Bool = true, trace: G2DisplayDiagnostics.Fragment? = nil, isPending: (() -> Bool)? = nil) {
2119
2315
  // OTA owns both links exclusively, including its short setup/teardown
2120
2316
  // windows. Normal display/settings/navigation traffic must not interleave.
2121
2317
  guard !firmwareFlashLifecycleActive, !firmwareFlasher.isActive else { return }
2318
+ let queuedAt = G2DisplayDiagnostics.now
2319
+ let writes = packets.map { BleWrite(data: $0, queuedAt: queuedAt, trace: trace, isPending: isPending) }
2122
2320
  if right {
2123
- rightWriteQueue.append(contentsOf: packets)
2321
+ rightWriteQueue.append(contentsOf: writes)
2124
2322
  startDrain(right: true)
2125
2323
  }
2126
2324
  if left {
2127
- leftWriteQueue.append(contentsOf: packets)
2325
+ leftWriteQueue.append(contentsOf: writes)
2128
2326
  startDrain(right: false)
2129
2327
  }
2130
2328
  }
@@ -2148,10 +2346,9 @@ final class G2: NSObject, SGCManager {
2148
2346
  }
2149
2347
  }
2150
2348
 
2151
- /// Drain one side's queue: write each packet directly (`.withoutResponse`), paced by a small
2152
- /// sleep. No `canSend` gate (see note above). Runs until the queue is empty, then clears the
2153
- /// per-side flag so the next enqueue restarts it.
2349
+ /// Drain until empty, yielding after each CFW burst so UI/ACK work can run.
2154
2350
  private func drainLoop(right: Bool) async {
2351
+ var burstCount = 0
2155
2352
  while true {
2156
2353
  guard let peripheral = right ? rightPeripheral : leftPeripheral,
2157
2354
  let char = right ? rightWriteChar : leftWriteChar
@@ -2173,26 +2370,64 @@ final class G2: NSObject, SGCManager {
2173
2370
  }
2174
2371
  return
2175
2372
  }
2176
- if depth > 20 {
2177
- let now = Date().timeIntervalSince1970
2178
- if now - bgcapDepthLogAt >= 1.0 {
2179
- Bridge.log("BGCAP: g2 write queue depth=\(depth) side=\(right ? "R" : "L") (draining, not blocked)")
2180
- 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()
2181
2382
  }
2383
+ displayDiagnostics.count("txExpiredPackets")
2384
+ continue
2385
+ }
2386
+ // Re-evaluate on every iteration, including after a credit wait:
2387
+ // backgrounding must release an already-blocked foreground drain.
2388
+ let cfwEnabled = cfwCapabilityString != nil
2389
+ let pacing = G2WritePacing(cfwEnabled: cfwEnabled, applicationState: UIApplication.shared.applicationState)
2390
+ let peripheralReady = peripheral.canSendWriteWithoutResponse
2391
+ if !pacing.canWrite(peripheralReady: peripheralReady) {
2392
+ burstCount = 0
2393
+ let stalledAt = G2DisplayDiagnostics.now
2394
+ try? await Task.sleep(nanoseconds: writePaceNanos)
2395
+ displayDiagnostics.timing(right ? "creditWaitMsR" : "creditWaitMsL", (G2DisplayDiagnostics.now - stalledAt) * 1000)
2396
+ continue
2182
2397
  }
2183
2398
  let packet = right ? rightWriteQueue.removeFirst() : leftWriteQueue.removeFirst()
2184
- peripheral.writeValue(packet, for: char, type: .withoutResponse)
2185
- 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
+ }
2186
2421
  }
2187
2422
  }
2188
2423
 
2189
- private func sendEvenHubCommand(_ payload: Data, left: Bool = false, right: Bool = true) {
2424
+ private func sendEvenHubCommand(_ payload: Data, left: Bool = false, right: Bool = true, trace: G2DisplayDiagnostics.Fragment? = nil) {
2190
2425
  let packets = sendManager.buildPackets(
2191
2426
  serviceId: ServiceID.evenHub.rawValue,
2192
2427
  payload: payload,
2193
2428
  reserveFlag: true
2194
2429
  )
2195
- sendToGlasses(packets, left: left, right: right)
2430
+ sendToGlasses(packets, left: left, right: right, trace: trace)
2196
2431
  }
2197
2432
 
2198
2433
  private func sendDevSettingsCommand(_ payload: Data, left: Bool = false, right: Bool = true) {
@@ -2454,11 +2689,10 @@ final class G2: NSObject, SGCManager {
2454
2689
  DeviceStore.shared.get("bluetooth", "calendar_events") as? [[String: Any]] ?? []
2455
2690
  sendCalendarEvents(calendarEvents)
2456
2691
 
2457
- // push ANCS notification config (display options + app filter) and
2458
- // 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
2459
2694
  publishAncsAuthorization()
2460
- sendNotificationCtrl()
2461
- sendNotificationFilter()
2695
+ reapplyNotificationConfig()
2462
2696
  }
2463
2697
 
2464
2698
  // MARK: - Heartbeats
@@ -2496,6 +2730,7 @@ final class G2: NSObject, SGCManager {
2496
2730
  try? await Task.sleep(nanoseconds: 100_000_000)
2497
2731
  guard !Task.isCancelled else { break }
2498
2732
  self?.displayDirtySignal?.yield()
2733
+ self?.logDisplayDiagnostics()
2499
2734
  }
2500
2735
  }
2501
2736
  displayReconcileTask = Task { [weak self] in
@@ -2526,6 +2761,32 @@ final class G2: NSObject, SGCManager {
2526
2761
  displayDirtySignal?.yield()
2527
2762
  }
2528
2763
 
2764
+ private func logDisplayDiagnostics() {
2765
+ guard displayDiagnostics.isSampleDue else { return }
2766
+ if let line = displayDiagnostics.session([
2767
+ "cfw": cfwCapabilityString ?? "stock",
2768
+ "fpsLimit": DeviceStore.shared.get("bluetooth", "display_max_fps") as? Int ?? 8,
2769
+ "mtuL": leftPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 0,
2770
+ "mtuR": rightPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 0,
2771
+ ]) {
2772
+ Bridge.log(line)
2773
+ }
2774
+ let busy = !leftWriteQueue.isEmpty || !rightWriteQueue.isEmpty || !cfwPendingPayloads.isEmpty || cfwPendingFrame != nil
2775
+ if let line = displayDiagnostics.sample(context: [
2776
+ "app": cfwLastFrame?.appId ?? "", "seq": cfwLastFrame?.seq ?? 0,
2777
+ "page": pageCreated, "pageGeneration": pageGeneration,
2778
+ "qL": leftWriteQueue.count, "qR": rightWriteQueue.count,
2779
+ // Present only while that side has no write credit.
2780
+ "blockedL": leftPeripheral?.canSendWriteWithoutResponse == false,
2781
+ "blockedR": rightPeripheral?.canSendWriteWithoutResponse == false,
2782
+ "payloadQueue": cfwPendingPayloads.count, "pendingFrame": cfwPendingFrame != nil,
2783
+ "mic": evenHubMicIntent,
2784
+ "background": UIApplication.shared.applicationState != .active,
2785
+ ], busy: busy) {
2786
+ Bridge.log(line)
2787
+ }
2788
+ }
2789
+
2529
2790
  private func sendEvenHubHeartbeat() {
2530
2791
  let isFullyBooted = DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
2531
2792
  guard isFullyBooted else { return }
@@ -2565,24 +2826,67 @@ final class G2: NSObject, SGCManager {
2565
2826
  setDashboardMenu(menuItems)
2566
2827
  }
2567
2828
 
2568
- // MARK: - Notifications (on-glass ANCS display options + app filter)
2829
+ // MARK: - Notifications (SGCManager)
2830
+
2831
+ /// The config is stored whether or not it can be pushed now; device-ready
2832
+ /// re-applies it against the firmware that actually came up.
2833
+ func setGlassesNotificationConfig(_ config: GlassesNotificationConfig) throws {
2834
+ notificationConfig = config
2835
+ let ready = DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
2836
+ guard ready else {
2837
+ notificationPhase = "stale"
2838
+ publishNotificationStatus()
2839
+ return
2840
+ }
2841
+ applyNotificationConfig(config)
2842
+ }
2843
+
2844
+ func glassesNotificationStatus() -> GlassesNotificationStatus {
2845
+ GlassesNotificationStatus(
2846
+ supported: true,
2847
+ feed: "ancs",
2848
+ access: ancsAccess,
2849
+ phase: notificationPhase,
2850
+ config: notificationConfig,
2851
+ error: nil
2852
+ )
2853
+ }
2854
+
2855
+ /// The glasses run their own ANCS client, so content never crosses the phone link.
2856
+ func uploadPhoneNotification(_: PhoneNotificationUpload) throws {
2857
+ throw GlassesNotificationError.unsupported
2858
+ }
2859
+
2860
+ /// Whether the glasses may read the phone's notification stream. No
2861
+ /// peripheral yet means the consent state is simply not known.
2862
+ private var ancsAccess: String {
2863
+ guard leftPeripheral != nil || rightPeripheral != nil else { return "unknown" }
2864
+ let authorized = (leftPeripheral?.ancsAuthorized ?? false)
2865
+ || (rightPeripheral?.ancsAuthorized ?? false)
2866
+ return authorized ? "granted" : "denied"
2867
+ }
2868
+
2869
+ private func reapplyNotificationConfig() {
2870
+ guard let config = notificationConfig else { return }
2871
+ applyNotificationConfig(config)
2872
+ }
2569
2873
 
2570
- /// Push NotificationControl (master toggle, auto-display popup, on-screen
2571
- /// duration, avoid-disturb) from the DeviceStore `notification_ctrl` dict.
2572
- /// Full state is retransmitted on every change — that matches the stock app
2874
+ /// Full state is retransmitted on every apply — that matches the stock app
2573
2875
  /// and the glasses echo it back as the ack.
2574
- func sendNotificationCtrl() {
2575
- let ctrl = DeviceStore.shared.get("bluetooth", "notification_ctrl") as? [String: Any] ?? [:]
2576
- let enabled = ctrl["enabled"] as? Bool ?? true
2577
- let autoDisplay = ctrl["auto_display"] as? Bool ?? true
2578
- let dispTime = ctrl["disp_time"] as? Int ?? 5
2579
- 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()
2580
2883
 
2884
+ let dispTime = G2NotificationTuning.snapPopupSeconds(config.popupSeconds)
2581
2885
  var ctrlW = ProtobufWriter()
2582
- ctrlW.writeInt32Field(1, enabled ? 1 : 0) // notifEnable
2583
- ctrlW.writeInt32Field(2, autoDisplay ? 1 : 0) // autoDispEnable
2584
- ctrlW.writeInt32Field(3, Int32(dispTime)) // dispTime (0/5/7/10 s)
2585
- 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
2586
2890
 
2587
2891
  var pkg = ProtobufWriter()
2588
2892
  pkg.writeInt32Field(1, NotificationCmd.ctrl.rawValue)
@@ -2590,33 +2894,45 @@ final class G2: NSObject, SGCManager {
2590
2894
  pkg.writeMessageField(3, ctrlW.data)
2591
2895
  sendNotificationCommand(pkg.data)
2592
2896
  Bridge.log(
2593
- "G2: Sent notification ctrl enabled=\(enabled) autoDisplay=\(autoDisplay) dispTime=\(dispTime) avoidDisturb=\(avoidDisturb)"
2897
+ "G2: Sent notification ctrl enabled=\(config.enabled) popups=\(config.popups) dispTime=\(dispTime) hold=\(config.holdDuringApps)"
2594
2898
  )
2595
- }
2899
+ syncAncsRelay()
2596
2900
 
2597
- /// Push the ANCS app allowlist from the DeviceStore `notification_filter`
2598
- /// dict ({enabled, apps: [{id, name}]}). The list travels as the
2599
- /// `user/notify_whitelist.json` file (the protobuf whitelist message only
2600
- /// carries the enable toggle); the WHITELIST_CTRL toggle goes after the
2601
- /// file so the firmware reloads with the new list in place.
2602
- func sendNotificationFilter() {
2603
- let filter = DeviceStore.shared.get("bluetooth", "notification_filter") as? [String: Any] ?? [:]
2604
- let enabled = filter["enabled"] as? Bool ?? false
2605
- let apps = filter["apps"] as? [[String: Any]] ?? []
2606
2901
  Task { [weak self] in
2607
- await self?.pushNotificationFilter(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
+ }
2608
2912
  }
2609
2913
  }
2610
2914
 
2611
- private func pushNotificationFilter(enabled: Bool, apps: [[String: Any]]) async {
2612
- let entries = apps.compactMap { app -> (id: String, name: String)? in
2613
- guard let id = app["id"] as? String, !id.isEmpty else { return nil }
2614
- return (id: id, name: app["name"] as? String ?? id)
2615
- }
2915
+ /// The allowlist travels as the `user/notify_whitelist.json` file (the
2916
+ /// protobuf whitelist message only carries the enable toggle); the
2917
+ /// WHITELIST_CTRL toggle goes after the file so the firmware reloads with
2918
+ /// the new list in place.
2919
+ /// Returns whether the allowlist file reached the glasses.
2920
+ private func pushNotificationFilter(for config: GlassesNotificationConfig) async -> Bool {
2921
+ let enabled = G2NotificationTuning.filterEnabled(for: config)
2922
+ let entries = config.allowedApps.map { (id: $0.id, name: $0.name) }
2616
2923
  // The file is written even when filtering is off so a later enable
2617
2924
  // toggles against the user's current list, not a stale one.
2618
2925
  let json = G2NotifyFile.whitelistJson(filterEnabled: enabled, apps: entries)
2619
- await sendNotifyFile(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
+ }
2620
2936
 
2621
2937
  var wlW = ProtobufWriter()
2622
2938
  wlW.writeInt32Field(1, enabled ? 0 : 1) // whitelistDisable (disabled = fail-open)
@@ -2626,20 +2942,37 @@ final class G2: NSObject, SGCManager {
2626
2942
  pkg.writeMessageField(6, wlW.data)
2627
2943
  sendNotificationCommand(pkg.data)
2628
2944
  Bridge.log("G2: Sent notification filter enabled=\(enabled) apps=\(entries.count)")
2945
+ return pushed
2946
+ }
2947
+
2948
+ private func publishNotificationStatus() {
2949
+ Bridge.sendTypedMessage(
2950
+ "glasses_notification_status", body: glassesNotificationStatus().dictionary
2951
+ )
2629
2952
  }
2630
2953
 
2631
2954
  /// File-transfer push (services 0xC4/0xC5 on the dedicated file
2632
2955
  /// characteristic): FILE_CHECK header → START → DATA → END, per arm. The
2633
2956
  /// glasses cache by checksum but still expect the data packets either way,
2634
2957
  /// so no branching on the CACHE_HIT/MISS response is needed.
2635
- private func sendNotifyFile(named filename: String, content: Data) async {
2636
- guard !firmwareFlashLifecycleActive, !firmwareFlasher.isActive else { return }
2637
- 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
+ )
2638
2968
  var pushedSides: [String] = []
2969
+ var skippedArm = false
2639
2970
  for right in [true, false] {
2640
- guard let peripheral = right ? rightPeripheral : leftPeripheral,
2641
- let char = right ? rightFileWriteChar : leftFileWriteChar
2642
- 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
+ }
2643
2976
  let writeType: CBCharacteristicWriteType =
2644
2977
  char.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse
2645
2978
 
@@ -2663,6 +2996,7 @@ final class G2: NSObject, SGCManager {
2663
2996
  Bridge.log(
2664
2997
  "G2: Pushed \(filename) (\(content.count) bytes) to [\(pushedSides.joined(separator: ","))]"
2665
2998
  )
2999
+ return !pushedSides.isEmpty && !skippedArm
2666
3000
  }
2667
3001
 
2668
3002
  // MARK: - SGCManager: Display Control
@@ -2705,9 +3039,7 @@ final class G2: NSObject, SGCManager {
2705
3039
  // Bridge.log("G2: sendTextWall(\(text.prefix(50))...)")
2706
3040
 
2707
3041
  // ignore events while the ER dashboard is open:
2708
- let useNativeDashboard =
2709
- DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
2710
- if useNativeDashboard && dashboardShowing > 0 {
3042
+ if dashboardShowing > 0 {
2711
3043
  return
2712
3044
  }
2713
3045
 
@@ -2731,7 +3063,7 @@ final class G2: NSObject, SGCManager {
2731
3063
  if supportsCfwSceneRendering {
2732
3064
  if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
2733
3065
  // A blank wall is the idle state: keep the carrier alive for touch and mic.
2734
- let carrierWasConfigured = installCfwCarrierIfNeeded()
3066
+ let carrierWasConfigured = installCfwPageIfNeeded()
2735
3067
  cfwClear()
2736
3068
  if !carrierWasConfigured, pageCreated {
2737
3069
  // Carrier just replaced live stock containers (see clearDisplay).
@@ -2740,7 +3072,7 @@ final class G2: NSObject, SGCManager {
2740
3072
  return
2741
3073
  }
2742
3074
  let frame = SceneFrame(
2743
- appId: "legacy-text", epoch: 0, replay: true,
3075
+ appId: "legacy-text", epoch: 0, replay: false,
2744
3076
  elements: [
2745
3077
  SceneElement(
2746
3078
  id: "legacy-text", type: "text", x: rx, y: ry, w: rw, h: rh,
@@ -2780,9 +3112,6 @@ final class G2: NSObject, SGCManager {
2780
3112
  )
2781
3113
  return
2782
3114
  }
2783
- Bridge.log(
2784
- "G2: sendText() - reusing container \(container.id) for rect \(rx),\(ry) \(rw)x\(rh)"
2785
- )
2786
3115
  return
2787
3116
  }
2788
3117
 
@@ -2803,7 +3132,6 @@ final class G2: NSObject, SGCManager {
2803
3132
  }
2804
3133
 
2805
3134
  func sendDoubleTextWall(_ top: String, _ bottom: String) async {
2806
- Bridge.log("G2: sendDoubleTextWall() - top: \(top), bottom: \(bottom)")
2807
3135
  // G2 doesn't have native double text wall, combine them
2808
3136
  let combined = top + "\n\n" + bottom
2809
3137
  await sendTextWall(combined)
@@ -2909,6 +3237,8 @@ final class G2: NSObject, SGCManager {
2909
3237
  Bridge.log("G2: applySceneFrame — structural changes, ONE coalesced rebuild for the whole frame")
2910
3238
  await coalescedPageRebuild()
2911
3239
  }
3240
+ // Stock firmware: text sends are unacked; the awaited verbs above are the best signal there is.
3241
+ frame.reportPresented(true)
2912
3242
  }
2913
3243
 
2914
3244
  /// Render `frame` phone-side and queue it for the CFW carrier. Installs the
@@ -2916,15 +3246,21 @@ final class G2: NSObject, SGCManager {
2916
3246
  /// rebuilds the page; `rebuildState` re-seeds and re-queues from
2917
3247
  /// `cfwLastFrame` once the new page is up.
2918
3248
  private func applyCfwScene(_ frame: SceneFrame) async {
2919
- 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()
2920
3259
  cfwLastFrame = frame
2921
3260
  // Lease before the frame: a fresh FB_ACQUIRE resets preservation, and
2922
3261
  // the frame queued below is what re-arms it.
2923
3262
  startFbLease()
2924
- guard await enqueueCfwFrame(frame) else {
2925
- Bridge.log("G2: CFW scene encode failed; retaining the previous frame")
2926
- return
2927
- }
3263
+ cfwPendingFrame = frame
2928
3264
  cfwSceneActive = true
2929
3265
  signalDisplayDirty()
2930
3266
 
@@ -2934,17 +3270,13 @@ final class G2: NSObject, SGCManager {
2934
3270
  }
2935
3271
  }
2936
3272
 
2937
- /// Stock's window placement for the current height/depth settings.
2938
- /// `pinDepth` forces depth level 1, the only level where both lenses share
2939
- /// an x offset — required whenever the frame uses mode-15 text draws,
2940
- /// which have no per-lens variant and would otherwise land 16 px apart
2941
- /// from the window content (double vision). Height still follows the user.
2942
- private func cfwBoxes(for frame: SceneFrame, pinDepth: Bool) -> G2CfwRenderer.LensBoxes {
3273
+ /// Apply the global placement to every scene; miniapps only supply relative element depth.
3274
+ private func cfwBoxes(for frame: SceneFrame) -> G2CfwRenderer.LensBoxes {
2943
3275
  let height = DeviceStore.shared.get("bluetooth", "dashboard_height") as? Int ?? 4
2944
- let depth = pinDepth ? 1 : (DeviceStore.shared.get("bluetooth", "dashboard_depth") as? Int ?? 2)
3276
+ let depth = DeviceStore.shared.get("bluetooth", "dashboard_depth") as? Int ?? 2
2945
3277
  return G2CfwRenderer.lensBoxes(
2946
3278
  canvasWidth: frame.canvasWidth, canvasHeight: frame.canvasHeight,
2947
- heightLevel: height, depthLevel: depth
3279
+ heightLevel: height, depthLevel: G2CfwRenderer.depthLevel(forSetting: depth)
2948
3280
  )
2949
3281
  }
2950
3282
 
@@ -2957,15 +3289,21 @@ final class G2: NSObject, SGCManager {
2957
3289
  }
2958
3290
 
2959
3291
  /// Forget the on-glass shadow: the next frame is preceded by a black keyframe.
2960
- 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
2961
3296
  cfwPendingPayloads.removeAll()
3297
+ cfwSceneInvalid = true
2962
3298
  cfwShadowSeeded = false
2963
3299
  cfwLastBoxes = nil
2964
3300
  cfwPrevRaster = nil
2965
3301
  cfwLastTextDraws = []
2966
3302
  cfwDeltasSinceFull = 0
2967
- // 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.
2968
3305
  cfwCacheGeneration += 1
3306
+ cfwCacheEpoch = 0
2969
3307
  }
2970
3308
 
2971
3309
  /// The CFW context is PER LENS — each arm runs its own firmware instance
@@ -2982,14 +3320,17 @@ final class G2: NSObject, SGCManager {
2982
3320
  sendToGlasses(packets, left: true, right: true)
2983
3321
  }
2984
3322
 
2985
- /// Acquire the fbguard lease and keep renewing it while CFW scene
2986
- /// rendering owns the panel. A fresh acquire resets the firmware's
2987
- /// preservation (the next presented direct frame re-arms it), so this runs
2988
- /// BEFORE the frame is queued. Stops with a release as soon as scene mode
2989
- /// is inactive or the page went down, so a stock surface (dashboard, nav)
2990
- /// 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.
2991
3332
  private func startFbLease() {
2992
- guard fbLeaseTask == nil else { return }
3333
+ guard fbLeaseTask == nil, !notificationsPanelHold else { return }
2993
3334
  sendFaceclawFbControl(acquire: true)
2994
3335
  fbLeaseTask = Task { @MainActor [weak self] in
2995
3336
  while !Task.isCancelled {
@@ -2998,7 +3339,7 @@ final class G2: NSObject, SGCManager {
2998
3339
  // supportsCfwSceneRendering: the cfw_scene_renderer escape
2999
3340
  // hatch may have been flipped off mid-session — without this
3000
3341
  // check the lease renews forever while frames go stock.
3001
- if self.cfwSceneActive, self.pageCreated, self.dashboardShowing == 0,
3342
+ if self.pageCreated, self.dashboardShowing == 0, !self.navigationModeActive,
3002
3343
  self.supportsCfwSceneRendering
3003
3344
  {
3004
3345
  self.sendFaceclawFbControl(acquire: true)
@@ -3016,8 +3357,9 @@ final class G2: NSObject, SGCManager {
3016
3357
  if sendRelease {
3017
3358
  sendFaceclawFbControl(acquire: false)
3018
3359
  }
3019
- // Lease end frees the on-glass texture cache; the model must re-learn.
3360
+ // Lease end frees the on-glass object cache; the model must re-learn.
3020
3361
  cfwCacheGeneration += 1
3362
+ cfwCacheEpoch = 0
3021
3363
  }
3022
3364
 
3023
3365
  /// Enable or disable Glassly ownership of the stock wake paths. While the
@@ -3076,6 +3418,276 @@ final class G2: NSObject, SGCManager {
3076
3418
  }
3077
3419
  }
3078
3420
 
3421
+ // MARK: - BLE link speed (CFW revision 26)
3422
+
3423
+ /// Revision 26 lets the phone choose the connection profile: stock (the
3424
+ /// default: 15-30 ms, then the one-minute slow-mode timer) or fast (7.5 ms,
3425
+ /// latency 0, slow mode suppressed). Each lens has its own link, so the
3426
+ /// control (G2BleLink, field 127) goes to both arms. The flag lives in lens
3427
+ /// RAM and boots stock, so a wanted FAST is re-sent on every CFW detection.
3428
+ private var supportsBleLinkControl: Bool { supportsCfwTransport }
3429
+
3430
+ /// The debug setting: caller intent, read at send time.
3431
+ private var fastBleWanted: Bool {
3432
+ DeviceStore.shared.get("bluetooth", "cfw_fast_ble") as? Bool ?? false
3433
+ }
3434
+
3435
+ /// Last field-128 status seen, so only changes are logged.
3436
+ private var lastBleLinkStatus: G2BleLink.Status?
3437
+
3438
+ private func sendBleLinkControl(_ op: G2BleLink.Op) {
3439
+ let packets = sendManager.buildPackets(
3440
+ serviceId: ServiceID.g2Setting.rawValue,
3441
+ payload: G2BleLink.controlPacket(op: op),
3442
+ reserveFlag: true
3443
+ )
3444
+ sendToGlasses(packets, left: true, right: true)
3445
+ Bridge.log("G2: BLE link control -> \(op)")
3446
+ }
3447
+
3448
+ /// Push the setting after CFW detection. Only a wanted FAST is sent: the
3449
+ /// firmware boots on the stock profile, so STOCK would be a no-op.
3450
+ private func syncBleLink() {
3451
+ guard supportsBleLinkControl, fastBleWanted else { return }
3452
+ sendBleLinkControl(.fast)
3453
+ }
3454
+
3455
+ /// Live cfw_fast_ble switch. A stock device, an older CFW, or a
3456
+ /// pre-classification connect reads the setting later on detection.
3457
+ func cfwFastBleToggled(_ enabled: Bool) async {
3458
+ guard supportsBleLinkControl else {
3459
+ Bridge.log("G2: cfw_fast_ble \(enabled ? "ON" : "OFF") — no link control on this firmware; applies on next CFW detection")
3460
+ return
3461
+ }
3462
+ sendBleLinkControl(enabled ? .fast : .stock)
3463
+ }
3464
+
3465
+ // MARK: - ANCS relay (CFW revision 24)
3466
+
3467
+ /// The stock firmware reads the phone's notifications over ANCS on the
3468
+ /// right lens but only reports the source app; revision 24 relays the
3469
+ /// whole notification as sid-0x09 field-125 records (G2AncsRelay). Only the
3470
+ /// right lens subscribes to ANCS and only it can notify the phone, so the
3471
+ /// control packets go to that arm alone.
3472
+ private var supportsAncsRelay: Bool {
3473
+ cfwClassification?.isGlassly == true
3474
+ }
3475
+
3476
+ /// The user's notification master toggle, the same one pushed on-glass with
3477
+ /// the config: with it off the relay stays disabled so no notification
3478
+ /// traffic crosses the link.
3479
+ private var ancsRelayWanted: Bool {
3480
+ notificationConfig?.enabled ?? true
3481
+ }
3482
+
3483
+ private func sendAncsControl(_ payload: Data) {
3484
+ let packets = sendManager.buildPackets(
3485
+ serviceId: ServiceID.g2Setting.rawValue,
3486
+ payload: payload,
3487
+ reserveFlag: true
3488
+ )
3489
+ sendToGlasses(packets, left: false, right: true)
3490
+ }
3491
+
3492
+ // MARK: - BLE keyboard bridge (CFW revision 39)
3493
+
3494
+ /// Keyboard controls are settings requests for the right lens alone: only
3495
+ /// it hosts the keyboard link and only it emits field-131 events.
3496
+ private func sendKeyboardControl(_ payload: Data) {
3497
+ let packets = sendManager.buildPackets(
3498
+ serviceId: ServiceID.g2Setting.rawValue,
3499
+ payload: payload,
3500
+ reserveFlag: true
3501
+ )
3502
+ sendToGlasses(packets, left: false, right: true)
3503
+ }
3504
+
3505
+ func keyboardScan() async throws -> [[String: Any]] {
3506
+ try await keyboardBridge.scan().map(G2KeyboardBridge.advertValues)
3507
+ }
3508
+
3509
+ func keyboardConnect(address: String, addressType: Int, name: String) async throws {
3510
+ try await keyboardBridge.connect(address: address, addressType: addressType, name: name)
3511
+ }
3512
+
3513
+ func keyboardReconnect() async throws {
3514
+ try await keyboardBridge.reconnect()
3515
+ }
3516
+
3517
+ func keyboardDisconnect() async throws {
3518
+ try await keyboardBridge.disconnect()
3519
+ }
3520
+
3521
+ func keyboardForget() async {
3522
+ await keyboardBridge.forget()
3523
+ }
3524
+
3525
+ func keyboardPasskey(_ pin: Int) async throws {
3526
+ try await keyboardBridge.passkey(pin)
3527
+ }
3528
+
3529
+ func keyboardCompare(_ accept: Bool) async throws {
3530
+ try await keyboardBridge.compare(accept)
3531
+ }
3532
+
3533
+ func keyboardStatus() async -> [String: Any] {
3534
+ await keyboardBridge.status()
3535
+ }
3536
+
3537
+ /// Arm the relay lease and keep it renewed every 45 s inside the firmware's
3538
+ /// 90 s expiry. ENABLE doubles as the renewal: the firmware treats it as a
3539
+ /// plain extension while the lease is live, and unlike RENEW it also
3540
+ /// recovers a lease the glasses lost (lens reset, missed first packet).
3541
+ private func startAncsRelay() {
3542
+ guard supportsAncsRelay, ancsRelayTask == nil else { return }
3543
+ ancsRelay.reset()
3544
+ ancsRelayLeaseHeld = false
3545
+ ancsRelayTask = Task { @MainActor [weak self] in
3546
+ while !Task.isCancelled {
3547
+ try? await Task.sleep(nanoseconds: 45_000_000_000)
3548
+ guard let self, !Task.isCancelled else { return }
3549
+ self.ancsRelay.prune(olderThan: 30)
3550
+ self.syncAncsRelay()
3551
+ }
3552
+ }
3553
+ syncAncsRelay()
3554
+ }
3555
+
3556
+ /// ENABLE while the master toggle is on; one DISABLE when it goes off.
3557
+ /// Runs on every renewal tick and whenever the toggle is pushed on-glass;
3558
+ /// a no-op until CFW detection has started the relay.
3559
+ private func syncAncsRelay() {
3560
+ guard ancsRelayTask != nil else { return }
3561
+ if ancsRelayWanted {
3562
+ sendAncsControl(G2AncsRelay.controlPacket(op: .enable))
3563
+ ancsRelayLeaseHeld = true
3564
+ } else if ancsRelayLeaseHeld {
3565
+ sendAncsControl(G2AncsRelay.controlPacket(op: .disable))
3566
+ ancsRelayLeaseHeld = false
3567
+ }
3568
+ }
3569
+
3570
+ private func stopAncsRelay(sendDisable: Bool) {
3571
+ ancsRelayTask?.cancel()
3572
+ ancsRelayTask = nil
3573
+ if sendDisable, ancsRelayLeaseHeld {
3574
+ sendAncsControl(G2AncsRelay.controlPacket(op: .disable))
3575
+ }
3576
+ ancsRelayLeaseHeld = false
3577
+ ancsRelay.reset()
3578
+ }
3579
+
3580
+ /// Perform a relayed notification's positive (open) or negative (clear)
3581
+ /// action on the phone through the glasses' ANCS control point.
3582
+ func ancsNotificationAction(notificationId: String, positive: Bool) {
3583
+ guard supportsAncsRelay, notificationId.hasPrefix("ancs-"),
3584
+ let uid = UInt32(notificationId.dropFirst("ancs-".count))
3585
+ else { return }
3586
+ sendAncsControl(G2AncsRelay.actionPacket(uid: uid, positive: positive))
3587
+ }
3588
+
3589
+ /// One field-125 record from the right lens. Completed notifications feed
3590
+ /// the same phone_notification / phone_notification_dismissed events as
3591
+ /// Android's listener and Mentra Live's relay; APP records feed the
3592
+ /// seen-apps store behind the notification filter UI.
3593
+ private func handleAncsRelayRecord(_ record: Data) {
3594
+ guard let event = ancsRelay.ingest(record) else { return }
3595
+ let now = Int64(Date().timeIntervalSince1970 * 1000)
3596
+ switch event {
3597
+ case let .notification(n):
3598
+ // iOS replays every existing notification when the lens
3599
+ // subscribes; those are not new arrivals and would flood the glass.
3600
+ if n.flags.contains(.preExisting) {
3601
+ Bridge.log("G2: ANCS relay skipping pre-existing \(n.notificationId) from \(n.appIdentifier)")
3602
+ return
3603
+ }
3604
+ let content = [n.subtitle, n.message].filter { !$0.isEmpty }.joined(separator: "\n")
3605
+ Bridge.sendTypedMessage(
3606
+ "phone_notification",
3607
+ body: [
3608
+ "notificationId": n.notificationId,
3609
+ "app": ancsRelay.appNames[n.appIdentifier] ?? n.appIdentifier,
3610
+ "title": n.title,
3611
+ "content": content,
3612
+ "priority": n.flags.contains(.important) ? "1" : "0",
3613
+ "timestamp": G2AncsRelay.timestampMs(date: n.date),
3614
+ "packageName": n.appIdentifier,
3615
+ ]
3616
+ )
3617
+ case let .removed(uid, appIdentifier):
3618
+ Bridge.sendTypedMessage(
3619
+ "phone_notification_dismissed",
3620
+ body: [
3621
+ "notificationId": "ancs-\(uid)",
3622
+ "notificationKey": "ancs-\(uid)",
3623
+ "packageName": appIdentifier ?? "",
3624
+ "timestamp": now,
3625
+ ]
3626
+ )
3627
+ case let .appName(appIdentifier, displayName):
3628
+ Bridge.sendTypedMessage(
3629
+ "notification_app_seen",
3630
+ body: ["appId": appIdentifier, "displayName": displayName, "timestamp": now]
3631
+ )
3632
+ case let .status(s):
3633
+ Bridge.log(
3634
+ "G2: ANCS relay status enabled=\(s.enabled) side=\(s.side) sent=\(s.sent) drops=\(s.drops) sendErrors=\(s.sendErrors)"
3635
+ )
3636
+ }
3637
+ }
3638
+
3639
+ /// Queue `frame` as a retained-scene patch: the firmware keeps the slots,
3640
+ /// tweens transitions itself, and renders every frame into its own buffer.
3641
+ /// Returns false when the frame does not fit, so the raster path takes it.
3642
+ private func enqueueCfwVectorFrame(_ frame: SceneFrame) async -> Bool {
3643
+ let generation = cfwCacheGeneration
3644
+ let clearSerial = cfwClearSerial
3645
+ let boxes = cfwBoxes(for: frame)
3646
+ let backlogged = cfwPendingPayloads.filter { $0.kind == .scene }.count >= Self.cfwMaxQueuedUpdates
3647
+ if backlogged {
3648
+ cfwPendingPayloads.removeAll { $0.kind == .scene }
3649
+ cfwSceneInvalid = true
3650
+ }
3651
+ if cfwLastBoxes != boxes {
3652
+ cfwSceneInvalid = true
3653
+ }
3654
+ guard let encoded = await cfwRenderer.encodeVector(
3655
+ frame, boxes: boxes, cacheGeneration: cfwCacheGeneration, invalidate: cfwSceneInvalid,
3656
+ inlineText: true, tag: supportsCfwSceneSettle ? frame.seq & 0xFFFF : nil
3657
+ ) else {
3658
+ displayDiagnostics.count("vectorFallback")
3659
+ cfwSceneInvalid = true
3660
+ return false
3661
+ }
3662
+ guard generation == cfwCacheGeneration, clearSerial == cfwClearSerial else { return false }
3663
+ cfwSceneInvalid = false
3664
+ displayDiagnostics.count("vectorFrames")
3665
+ cfwLastBoxes = boxes
3666
+ // A later raster frame must start from a keyframe: the panel shows the scene, not the shadow.
3667
+ cfwDeltasSinceFull = Self.cfwMaxDeltasBetweenFullFrames
3668
+ if encoded.uploads.isEmpty, !encoded.changed,
3669
+ Date().timeIntervalSince1970 - cfwLastPayloadQueuedAt < Self.cfwIdleRefreshSeconds
3670
+ {
3671
+ cfwAttachPresentTag(frame)
3672
+ return true
3673
+ }
3674
+ for upload in encoded.uploads {
3675
+ cfwPendingPayloads.append(CfwPayload(kind: .upload, data: upload))
3676
+ }
3677
+ displayDiagnostics.count("cachePuts", encoded.puts)
3678
+ displayDiagnostics.count("cacheHits", encoded.hits)
3679
+ if encoded.shows.count > 1 { displayDiagnostics.count("stereoFrames") }
3680
+ // The frame is on the glasses once its last SHOW is acked.
3681
+ for (index, show) in encoded.shows.enumerated() {
3682
+ cfwPendingPayloads.append(CfwPayload(
3683
+ kind: .scene, data: show.payload,
3684
+ presents: index == encoded.shows.count - 1 ? frame.presentTag : nil, lenses: show.lenses
3685
+ ))
3686
+ }
3687
+ cfwLastPayloadQueuedAt = Date().timeIntervalSince1970
3688
+ return true
3689
+ }
3690
+
3079
3691
  /// Encode `frame` and queue the smallest update that reproduces it: dirty
3080
3692
  /// mode-3 rect deltas against the previous raster, a mode-9 rect copy when
3081
3693
  /// a vertical scroll is detected, and mode-15 builtin-font text draws for
@@ -3085,13 +3697,28 @@ final class G2: NSObject, SGCManager {
3085
3697
  /// change is large, the queue backed up, or on the periodic insurance
3086
3698
  /// keyframe. Seeds the shadow with mode-6 black when it is fresh.
3087
3699
  private func enqueueCfwFrame(_ frame: SceneFrame) async -> Bool {
3088
- let useBuiltinText = supportsCfwBuiltinText
3089
- && frame.elements.contains { $0.type == "text" && $0.font == "builtin" }
3090
- // Modes 13/14 have no per-lens variant, so they need the lens-equal window.
3091
- let useTextureGlyphs = supportsCfwTextureGlyphs
3092
- && frame.elements.contains { $0.type == "text" && $0.font == "mono" }
3093
- let pinDepth = useBuiltinText || useTextureGlyphs
3094
- 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
3095
3722
  let movedBoxes = cfwLastBoxes != nil && cfwLastBoxes != boxes
3096
3723
  let backlogged = cfwPendingPayloads
3097
3724
  .filter { $0.kind != .seed && $0.kind != .upload }.count >= Self.cfwMaxQueuedUpdates
@@ -3114,7 +3741,7 @@ final class G2: NSObject, SGCManager {
3114
3741
  // phone's metrics under-measured can spill into the outer panel
3115
3742
  // margin, where no in-window rect or full frame ever repaints.
3116
3743
  // Blank the old window's right margin whenever draws change.
3117
- // (Builtin frames pin depth, so the old window is lens-equal.)
3744
+ // (Builtin draws are only used when the old window is lens-equal.)
3118
3745
  if !cfwLastTextDraws.isEmpty, let last = cfwLastBoxes, last.left == last.right {
3119
3746
  let edgeX = Int32(last.left.x) + last.width
3120
3747
  let bandW = Int32(G2CfwRenderer.frameWidth) - edgeX
@@ -3129,7 +3756,7 @@ final class G2: NSObject, SGCManager {
3129
3756
  }
3130
3757
  let fids = (0 ..< G2CfwRenderer.requiredFidCount).map { _ in nextCfwFrameId() }
3131
3758
  // Copies would smear firmware-drawn glyphs the raster knows nothing about.
3132
- let allowCopy = supportsCfwRectCopy && textDraws.isEmpty
3759
+ let allowCopy = textDraws.isEmpty
3133
3760
  guard let update = await cfwRenderer.encodeUpdate(
3134
3761
  frame, boxes: boxes,
3135
3762
  previousRaster: mustFull ? nil : cfwPrevRaster,
@@ -3139,10 +3766,11 @@ final class G2: NSObject, SGCManager {
3139
3766
  marginBlankBoxes: marginBlank,
3140
3767
  omitBuiltinText: useBuiltinText,
3141
3768
  textureGlyphsEnabled: useTextureGlyphs,
3142
- textureImagesEnabled: supportsCfwTextureImages && pinDepth,
3769
+ textureImagesEnabled: useBuiltinText || useTextureGlyphs,
3143
3770
  cacheGeneration: cfwCacheGeneration
3144
3771
  ) else { return false }
3145
3772
 
3773
+ guard generation == cfwCacheGeneration else { return false }
3146
3774
  cfwPrevRaster = update.raster
3147
3775
  cfwLastTextDraws = textDraws
3148
3776
  cfwLastBoxes = boxes
@@ -3151,7 +3779,9 @@ final class G2: NSObject, SGCManager {
3151
3779
  cfwPendingPayloads.append(CfwPayload(kind: .upload, data: upload))
3152
3780
  }
3153
3781
  guard let payload = update.payload else {
3154
- // Visually identical frame — the shadow already shows it.
3782
+ // Visually identical frame — the shadow already shows it, or a
3783
+ // queued payload is about to.
3784
+ cfwAttachPresentTag(frame)
3155
3785
  return true
3156
3786
  }
3157
3787
  if update.usedFullFrame {
@@ -3174,7 +3804,8 @@ final class G2: NSObject, SGCManager {
3174
3804
  cfwPendingPayloads.append(CfwPayload(kind: .seed, data: seed))
3175
3805
  cfwShadowSeeded = true
3176
3806
  if textDraws.isEmpty {
3177
- cfwPendingPayloads.removeAll { $0.kind == .full || $0.kind == .delta }
3807
+ cfwPendingPayloads.removeAll { $0.kind == .full || $0.kind == .delta || $0.kind == .scene }
3808
+ cfwPendingPayloads[cfwPendingPayloads.count - 1].presents = frame.presentTag
3178
3809
  cfwLastPayloadQueuedAt = Date().timeIntervalSince1970
3179
3810
  return true
3180
3811
  }
@@ -3185,34 +3816,46 @@ final class G2: NSObject, SGCManager {
3185
3816
  }
3186
3817
  if update.usedFullFrame {
3187
3818
  // A full overwrites the window; seeds, uploads, and move-blanks must still survive.
3188
- cfwPendingPayloads.removeAll { $0.kind == .full || $0.kind == .delta }
3819
+ cfwPendingPayloads.removeAll { $0.kind == .full || $0.kind == .delta || $0.kind == .scene }
3189
3820
  let kind: CfwPayloadKind = movedBoxes ? .moveBundle : .full
3190
- cfwPendingPayloads.append(CfwPayload(kind: kind, data: payload))
3821
+ cfwPendingPayloads.append(CfwPayload(kind: kind, data: payload, presents: frame.presentTag))
3191
3822
  } else {
3192
- cfwPendingPayloads.append(CfwPayload(kind: .delta, data: payload))
3823
+ cfwPendingPayloads.append(CfwPayload(kind: .delta, data: payload, presents: frame.presentTag))
3193
3824
  }
3194
3825
  cfwLastPayloadQueuedAt = Date().timeIntervalSince1970
3195
3826
  return true
3196
3827
  }
3197
3828
 
3198
- /// Make the container set the CFW carrier shape (one 576×288 image container,
3199
- /// no text) — the shape `createPageWithContainers` recognises as the carrier
3200
- /// page. Returns true if it was ALREADY configured, so callers can tell
3201
- /// 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.
3202
3849
  @discardableResult
3203
- private func installCfwCarrierIfNeeded() -> Bool {
3204
- if textContainers.isEmpty && hasCfwCarrier {
3850
+ private func installCfwPageIfNeeded() -> Bool {
3851
+ if hasCfwPage {
3205
3852
  return true
3206
3853
  }
3207
3854
  textContainers.removeAll()
3208
3855
  sceneTextByElement.removeAll()
3209
3856
  sceneImageByElement.removeAll()
3210
- imageContainers = [
3211
- ImgContainer(
3212
- id: Self.cfwSceneContainerID, x: 0, y: 0, width: 576, height: 288,
3213
- bmpData: Data(), dirty: false
3214
- ),
3215
- ]
3857
+ imageContainers.removeAll()
3858
+ cfwPageInstalled = true
3216
3859
  cfwResetShadow()
3217
3860
  return false
3218
3861
  }
@@ -3232,7 +3875,10 @@ final class G2: NSObject, SGCManager {
3232
3875
  return true
3233
3876
  }
3234
3877
 
3235
- /// Blank the panel (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.
3236
3882
  ///
3237
3883
  /// The carrier container itself is deliberately NOT torn down: on CFW the
3238
3884
  /// idle screen is the black carrier page, which keeps absorbing touch events
@@ -3240,10 +3886,28 @@ final class G2: NSObject, SGCManager {
3240
3886
  /// still goes false — that tracks "a miniapp scene is on glass", which
3241
3887
  /// gates reposition/replay, not the page's existence.
3242
3888
  private func cfwClear() {
3889
+ cfwPendingFrame?.reportPresented(false, reason: "cleared")
3890
+ cfwPendingFrame = nil
3891
+ // Invalidate an encoder suspended on its actor when clear arrived.
3892
+ cfwClearSerial += 1
3243
3893
  cfwLastFrame = nil
3244
3894
  cfwSceneActive = false
3245
- cfwPendingPayloads.removeAll { $0.kind != .seed }
3246
- 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() {
3247
3911
  cfwPendingPayloads.append(CfwPayload(kind: .seed, data: seed))
3248
3912
  cfwShadowSeeded = true
3249
3913
  cfwLastBoxes = nil
@@ -3266,10 +3930,10 @@ final class G2: NSObject, SGCManager {
3266
3930
  // its normal paths.
3267
3931
  guard cfwClassified, cfwCapabilityString != nil else { return }
3268
3932
  if enabled {
3269
- if installCfwCarrierIfNeeded() {
3933
+ if installCfwPageIfNeeded() {
3270
3934
  return
3271
- } // already the carrier page
3272
- 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")
3273
3937
  await requestPageRebuild()
3274
3938
  } else {
3275
3939
  Bridge.log("G2: cfw_scene_renderer OFF — exiting CFW surface to a blank stock page")
@@ -3277,7 +3941,8 @@ final class G2: NSObject, SGCManager {
3277
3941
  cfwLastFrame = nil
3278
3942
  cfwSceneActive = false
3279
3943
  cfwResetShadow()
3280
- imageContainers.removeAll() // drops the carrier
3944
+ cfwPageInstalled = false
3945
+ imageContainers.removeAll()
3281
3946
  textContainers.removeAll()
3282
3947
  sceneTextByElement.removeAll()
3283
3948
  sceneImageByElement.removeAll()
@@ -3288,12 +3953,8 @@ final class G2: NSObject, SGCManager {
3288
3953
  /// Height/depth changed: re-place the retained frame (stock moves its window the same way).
3289
3954
  private func cfwReposition() {
3290
3955
  guard supportsCfwSceneRendering, cfwSceneActive, let frame = cfwLastFrame else { return }
3291
- Task { [weak self] in
3292
- guard let self else { return }
3293
- if await self.enqueueCfwFrame(frame) {
3294
- self.signalDisplayDirty()
3295
- }
3296
- }
3956
+ cfwPendingFrame = frame
3957
+ signalDisplayDirty()
3297
3958
  }
3298
3959
 
3299
3960
  func onSceneReplay(_: String) async {
@@ -3493,7 +4154,7 @@ final class G2: NSObject, SGCManager {
3493
4154
  /// applySceneFrame batch — without batching, each remove would rebuild).
3494
4155
  func clearSceneElements(_ elementIds: [String]) async {
3495
4156
  if supportsCfwSceneRendering {
3496
- let carrierWasConfigured = installCfwCarrierIfNeeded()
4157
+ let carrierWasConfigured = installCfwPageIfNeeded()
3497
4158
  cfwClear()
3498
4159
  if !carrierWasConfigured, pageCreated {
3499
4160
  // Carrier just replaced live stock containers (see clearDisplay).
@@ -3554,7 +4215,7 @@ final class G2: NSObject, SGCManager {
3554
4215
  // Idle on CFW = the black carrier page, kept up for touch + mic.
3555
4216
  // Install it even on a first-ever clear (no scene rendered yet) so
3556
4217
  // the idle state is the same page everywhere.
3557
- let carrierWasConfigured = installCfwCarrierIfNeeded()
4218
+ let carrierWasConfigured = installCfwPageIfNeeded()
3558
4219
  cfwClear()
3559
4220
  if !carrierWasConfigured || !pageCreated {
3560
4221
  signalDisplayDirty()
@@ -3609,35 +4270,27 @@ final class G2: NSObject, SGCManager {
3609
4270
  }
3610
4271
  }
3611
4272
 
3612
- /// Send a bitmap to an image container as fragmented updateImageRawData packets. Stock
3613
- /// firmware silently drops the first stream after a fresh page, so its first real transfer is
3614
- /// 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.
3615
4276
  @discardableResult
3616
4277
  private func sendImageData(containerID: Int32, containerName: String, bmpData: Data) async -> Bool {
3617
4278
  guard !bmpData.isEmpty else { return true }
3618
4279
 
3619
- let isCfwCarrier = containerID == Self.cfwSceneContainerID
3620
- if !isCfwCarrier, !stockImageStreamWarmedUp {
4280
+ if !stockImageStreamWarmedUp {
3621
4281
  let warmupOk = await sendImageTransfer(
3622
- containerID: containerID,
3623
- containerName: containerName,
3624
- bmpData: bmpData,
3625
- isCfwCarrier: false
4282
+ containerID: containerID, containerName: containerName, bmpData: bmpData
3626
4283
  )
3627
4284
  guard warmupOk else { return false }
3628
4285
  stockImageStreamWarmedUp = true
3629
4286
  }
3630
4287
 
3631
4288
  return await sendImageTransfer(
3632
- containerID: containerID,
3633
- containerName: containerName,
3634
- bmpData: bmpData,
3635
- isCfwCarrier: isCfwCarrier
4289
+ containerID: containerID, containerName: containerName, bmpData: bmpData
3636
4290
  )
3637
4291
  }
3638
4292
 
3639
- /// Perform one logical image stream, retrying the whole transfer with a fresh session after a
3640
- /// failed fragment ACK.
4293
+ /// Perform one logical stock image stream, retrying with a fresh session.
3641
4294
  ///
3642
4295
  /// The glasses reply ONCE per fragment with an ImgResCmd ErrorCode (4=success, 5=failed).
3643
4296
  /// All fragments in an attempt share a session id, and each fragment awaits its own ACK for up
@@ -3645,21 +4298,13 @@ final class G2: NSObject, SGCManager {
3645
4298
  /// counts as a failure and the entire image is re-sent with a fresh session up to
3646
4299
  /// `IMG_MAX_ATTEMPTS` times, backing off `IMG_RETRY_DELAY_NS` before each re-attempt.
3647
4300
  /// On exhausting all attempts it logs a warning and returns false.
3648
- private func sendImageTransfer(
3649
- containerID: Int32, containerName: String, bmpData: Data, isCfwCarrier: Bool
3650
- ) async -> Bool {
3651
- // CFW carrier transfers use the verified wire format: 3800-byte
3652
- // fragments, MapSessionId pinned to the container id instead of using
3653
- // the rotating session counter, and frames
3654
- // written to the LEFT arm (acks still return on the right). Stock
3655
- // containers keep the shipped 4096/rotating-session/right-arm shape.
3656
- let fragmentSize = isCfwCarrier ? 3800 : 4096
4301
+ private func sendImageTransfer(containerID: Int32, containerName: String, bmpData: Data) async -> Bool {
4302
+ let fragmentSize = 4096
3657
4303
  let totalSize = Int32(bmpData.count)
3658
4304
  let fragmentCount = (bmpData.count + fragmentSize - 1) / fragmentSize
3659
-
3660
- // Bridge.log(
3661
- // "G2: sendImageData(\(containerName)) - \(fragmentCount) fragments, \(bmpData.count) bytes"
3662
- // )
4305
+ let transferStarted = G2DisplayDiagnostics.now
4306
+ defer { displayDiagnostics.timing("transferMs", (G2DisplayDiagnostics.now - transferStarted) * 1000) }
4307
+ let generation = pageGeneration
3663
4308
 
3664
4309
  for attempt in 1 ... IMG_MAX_ATTEMPTS {
3665
4310
  // Back off before each RE-attempt: the ack didn't come, so the link
@@ -3667,21 +4312,20 @@ final class G2: NSObject, SGCManager {
3667
4312
  if attempt > 1 {
3668
4313
  try? await Task.sleep(nanoseconds: IMG_RETRY_DELAY_NS)
3669
4314
  }
4315
+ guard !Task.isCancelled, pageGeneration == generation else { return false }
3670
4316
  // One session id per WHOLE image transfer (per attempt). The glasses key their
3671
4317
  // reassembly buffer on MapSessionId, so every fragment of this image must reuse the
3672
4318
  // same session id with an incrementing MapFragmentIndex; the per-fragment ACK is
3673
- // correlated by the (session, fragmentIndex) pair. A retry uses a fresh session so a
3674
- // 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.
3675
4321
  imageSessionCounter = (imageSessionCounter + 1) % 256
3676
- let sessionId = isCfwCarrier ? Int(containerID) : imageSessionCounter
4322
+ let sessionId = imageSessionCounter
3677
4323
 
3678
4324
  var fragmentIndex: Int32 = 0
3679
4325
  var offset = 0
3680
4326
  var transferOk = true
3681
- // if attempt > 1 {
3682
- // Bridge.log("G2: sendImageData(\(containerName)) - attempt \(attempt) starting")
3683
- // }
3684
4327
  while offset < bmpData.count {
4328
+ guard !Task.isCancelled, pageGeneration == generation else { return false }
3685
4329
  let end = min(offset + fragmentSize, bmpData.count)
3686
4330
  let fragment = bmpData[offset ..< end]
3687
4331
 
@@ -3695,21 +4339,30 @@ final class G2: NSObject, SGCManager {
3695
4339
  mapFragmentPacketSize: Int32(fragment.count),
3696
4340
  mapRawData: Data(fragment)
3697
4341
  )
3698
- // Send the fragment directly: the reconcile loop already serializes whole image
3699
- // sends, and the per-fragment ACK gate below provides pacing — no transmit queue.
3700
- // Carrier frames go to the LEFT arm; the ack still returns on
3701
- // the right and our notify path is
3702
- // arm-agnostic); everything else keeps the right arm.
3703
- sendEvenHubCommand(msg, left: isCfwCarrier, right: !isCfwCarrier)
3704
- // Bridge.log("G2: img_sen: session=\(sessionId) fragment=\(fragmentIndex)")
3705
-
3706
4342
  // Gate on THIS fragment's ACK before sending the next (the ACK provides pacing).
3707
4343
  // Timeout/img_failed → abandon the attempt and retry the whole image.
3708
- let 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 }
3709
4365
  if !ok {
3710
- Bridge.log(
3711
- "G2: img_sen: session=\(sessionId) fragment=\(fragmentIndex) failed"
3712
- )
3713
4366
  transferOk = false
3714
4367
  break
3715
4368
  }
@@ -3719,33 +4372,131 @@ final class G2: NSObject, SGCManager {
3719
4372
  }
3720
4373
 
3721
4374
  if transferOk {
3722
- // Bridge.log("G2: img_sen: container=\(containerName) - success=true")
3723
4375
  return true
3724
4376
  }
3725
4377
  }
3726
4378
 
3727
- Bridge.log("G2: img_sen: container=\(containerName) - failed after \(IMG_MAX_ATTEMPTS) attempts")
3728
4379
  return false
3729
4380
  }
3730
4381
 
4382
+ /// Send one custom message over the SID-0xf0 transport to the lenses
4383
+ /// `lenses` selects (both by default) and wait for their processing ACKs.
4384
+ /// Always written to the LEFT arm; the right lens receives it over the
4385
+ /// frame bridge and its reply returns through the left. Deltas, rect copies
4386
+ /// and tweens are not safe to replay after an ambiguous loss, so a failure
4387
+ /// is reported (the caller resyncs from a keyframe) and the deflate context
4388
+ /// restarts, as the glasses have already dropped theirs on a NACK.
4389
+ private func sendCfwMessage(_ original: Data, lenses: UInt8 = G2CfwTransport.lensBoth) async -> Bool {
4390
+ guard !original.isEmpty else { return true }
4391
+ var message = original
4392
+ if G2CfwTransport.isCacheMessage(original) {
4393
+ cfwCacheRequest = cfwCacheRequest >= 0xFFFE ? 1 : cfwCacheRequest + 1
4394
+ message = G2CfwTransport.stamp(original, epoch: cfwCacheEpoch, request: cfwCacheRequest)
4395
+ }
4396
+ let started = G2DisplayDiagnostics.now
4397
+ defer { displayDiagnostics.timing("transferMs", (G2DisplayDiagnostics.now - started) * 1000) }
4398
+ guard let record = cfwTransport.record(message, targets: lenses) else {
4399
+ displayDiagnostics.count("encodeErrors")
4400
+ return false
4401
+ }
4402
+ let streamId = cfwStreamSequence
4403
+ let packets = G2CfwTransport.packets(stream: record, sequence: streamId, targets: lenses)
4404
+ cfwStreamSequence &+= UInt8(truncatingIfNeeded: packets.count)
4405
+ let trace = G2DisplayDiagnostics.Fragment()
4406
+ let timeout = G2CfwTransport.ackTimeoutNs(streamBytes: record.count)
4407
+ let ok = await cfwAckBox.wait(streamId: streamId, lenses: lenses, timeoutNs: timeout) { isPending in
4408
+ // Arm before enqueueing: replies arrive on the BLE queue and can beat
4409
+ // this task's next main-actor continuation.
4410
+ sendToGlasses(packets, left: true, right: false, trace: trace, isPending: isPending)
4411
+ }
4412
+ let resumedAt = G2DisplayDiagnostics.now
4413
+ let outcome = cfwAckBox.outcome()
4414
+ let reason = Task.isCancelled ? "cancelled" : (outcome?.reason ?? "unknown")
4415
+ let totalMs = (resumedAt - trace.queuedAt) * 1000
4416
+ displayDiagnostics.count("fragments")
4417
+ displayDiagnostics.count("streamBytes", record.count)
4418
+ if let first = trace.firstWrite, let last = trace.lastWrite {
4419
+ displayDiagnostics.timing("submitMs", (first - trace.queuedAt) * 1000)
4420
+ displayDiagnostics.timing("writeMs", (last - first) * 1000)
4421
+ if let outcome {
4422
+ displayDiagnostics.timing("ackMs", (outcome.at - last) * 1000)
4423
+ displayDiagnostics.timing("resumeMs", (resumedAt - outcome.at) * 1000)
4424
+ }
4425
+ }
4426
+ if !ok {
4427
+ displayDiagnostics.count("errors")
4428
+ if reason == "timeout" { displayDiagnostics.count("timeouts") }
4429
+ if reason == "nack" { displayDiagnostics.count("nacks") }
4430
+ // The glasses drop their inflater on any failure; ours must follow.
4431
+ cfwTransport.resetContext()
4432
+ }
4433
+ if totalMs >= 200 || !ok, let line = displayDiagnostics.stall(fields: [
4434
+ "app": diagnosticAppId, "seq": diagnosticFrameSeq,
4435
+ "stream": Int(streamId), "packets": packets.count, "lenses": Int(lenses),
4436
+
4437
+ "mode": Int(message.first ?? 0), "bytes": message.count, "wire": record.count,
4438
+ "totalMs": Int(totalMs), "result": reason,
4439
+ "written": trace.lastWrite != nil, "qL": leftWriteQueue.count, "qR": rightWriteQueue.count,
4440
+ "page": pageCreated,
4441
+ ]) {
4442
+ Bridge.log(line)
4443
+ }
4444
+ return ok
4445
+ }
4446
+
4447
+ /// The object cache's session: before the first cache message of a
4448
+ /// generation both lenses are RESET to a fresh phone-chosen epoch and the
4449
+ /// warm-up tween runs. Returns false when the reset itself failed.
4450
+ private func cfwEnsureCacheSession() async -> Bool {
4451
+ guard supportsCfwVectorScene else { return true }
4452
+ if cfwCacheEpoch != 0, cfwSceneWarmedGeneration == cfwCacheGeneration { return true }
4453
+ let generation = cfwCacheGeneration
4454
+ cfwCacheEpoch = UInt16.random(in: 1 ... 0xFFFE)
4455
+ var reset = G2CfwScene.resetPayload
4456
+ reset.append(UInt8(min(255, G2CfwTransport.maxChunkBytes + 10))) // reply capacity of this link
4457
+ guard await sendCfwMessage(reset), generation == cfwCacheGeneration else {
4458
+ cfwCacheEpoch = 0
4459
+ return false
4460
+ }
4461
+ var store = Int.max
4462
+ for reply in cfwAckBox.cacheReplies().values {
4463
+ if reply.epoch != cfwCacheEpoch {
4464
+ Bridge.log("G2: cache reset kept epoch \(reply.epoch) on lens \(reply.lens)")
4465
+ cfwCacheEpoch = 0
4466
+ return false
4467
+ }
4468
+ if reply.storeBytes > 0 { store = min(store, reply.storeBytes) }
4469
+ }
4470
+ if store != Int.max { await cfwRenderer.setStoreCapacity(bytes: store) }
4471
+ cfwSceneWarmedGeneration = generation
4472
+ _ = await sendCfwMessage(G2CfwScene.warmUpShow)
4473
+ return true
4474
+ }
4475
+
4476
+ /// Outcome of a cache message beyond its ACK: what the lenses replied.
4477
+ private enum CfwCacheOutcome { case applied, stale, missing([UInt32]), refused(String) }
4478
+
4479
+ private func cfwCacheOutcome() -> CfwCacheOutcome {
4480
+ var missing: [UInt32] = []
4481
+ for reply in cfwAckBox.cacheReplies().values {
4482
+ switch reply.status {
4483
+ case G2CfwTransport.CacheReply.stale: return .stale
4484
+ case G2CfwTransport.CacheReply.missing: missing.append(contentsOf: reply.missingIds)
4485
+ case G2CfwTransport.CacheReply.applied, G2CfwTransport.CacheReply.unchanged,
4486
+ G2CfwTransport.CacheReply.delta, G2CfwTransport.CacheReply.snapshot: continue
4487
+ default: return .refused("lens \(reply.lens): \(reply.summary)")
4488
+ }
4489
+ }
4490
+ return missing.isEmpty ? .applied : .missing(missing)
4491
+ }
4492
+
3731
4493
  /// Suspend until correlateImageAck() resolves the ACK for this `(sessionId, fragmentIndex)`, or
3732
4494
  /// `IMG_ACK_TIMEOUT_NS` elapses (whichever comes first). Returns whether the fragment was acked
3733
4495
  /// successfully; a timeout returns false. All fragments of one image share a session id, so the
3734
4496
  /// fragment index distinguishes their ACKs. The continuation lives in [ImgAckBox], whose lock
3735
4497
  /// makes the ACK-resolve and the timeout mutually exclusive — exactly one resumes it.
3736
- private func awaitImageAck(sessionId: Int, fragmentIndex: Int32) async -> Bool {
3737
- let timeoutNs = IMG_ACK_TIMEOUT_NS
3738
- let box = imgAckBox
3739
- return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
3740
- box.arm(session: sessionId, fragment: fragmentIndex, cont: cont)
3741
-
3742
- // Detached (not @MainActor): the timeout must fire on its own schedule regardless of how
3743
- // busy the main actor is. The box's lock makes racing with resolve() safe.
3744
- Task.detached {
3745
- try? await Task.sleep(nanoseconds: timeoutNs)
3746
- box.timeout(session: sessionId, fragment: fragmentIndex)
3747
- }
3748
- }
4498
+ private func awaitImageAck(sessionId: Int, fragmentIndex: Int32, send: () -> Void) async -> Bool {
4499
+ await imgAckBox.wait(session: sessionId, fragment: fragmentIndex, timeoutNs: IMG_ACK_TIMEOUT_NS, send: send)
3749
4500
  }
3750
4501
 
3751
4502
  /// Resume the pending image-ACK continuation for `(session, fragmentIndex)` (if it matches the
@@ -3777,9 +4528,7 @@ final class G2: NSObject, SGCManager {
3777
4528
  let rh = height ?? G2.defaultImgContainer.height
3778
4529
 
3779
4530
  // ignore events while the ER dashboard is open:
3780
- let useNativeDashboard =
3781
- DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
3782
- if useNativeDashboard && dashboardShowing > 0 {
4531
+ if dashboardShowing > 0 {
3783
4532
  return false
3784
4533
  }
3785
4534
 
@@ -3844,18 +4593,57 @@ final class G2: NSObject, SGCManager {
3844
4593
  return true
3845
4594
  }
3846
4595
 
3847
- /// Push pending display state to the glasses: dirty text containers first (one updateText each,
3848
- /// with a redundant resend per `pendingSends`), then dirty image containers one at a time. The
3849
- /// loop awaits this, and this awaits each `sendImageData` in turn, so image sends never overlap
3850
- /// and `imgAckBox` is never clobbered. A failed image send leaves the container dirty for the
3851
- /// 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.
3852
4604
  private func reconcileDisplay() async {
4605
+ guard !Task.isCancelled else { return }
3853
4606
  // Native nav mode owns the screen: hold ALL EvenHub display sends (text,
3854
4607
  // images, page resurrection). Container state keeps accumulating; the
3855
4608
  // nav-exit recovery rebuilds the page and flushes it.
3856
4609
  if navigationModeActive {
3857
4610
  return
3858
4611
  }
4612
+ if !notificationsPanelHold, cfwPendingPayloads.isEmpty, let frame = cfwPendingFrame {
4613
+ let originAt = cfwPendingFrameAt
4614
+ cfwPendingFrame = nil
4615
+ let started = G2DisplayDiagnostics.now
4616
+ let encoded = await enqueueCfwFrame(frame)
4617
+ let encodeMs = (G2DisplayDiagnostics.now - started) * 1000
4618
+ displayDiagnostics.timing("encodeMs", encodeMs)
4619
+ displayDiagnostics.timing("frameQueueMs", (started - originAt) * 1000)
4620
+ displayDiagnostics.count("framesEncoded")
4621
+ displayDiagnostics.count("elements", frame.elements.count)
4622
+ displayDiagnostics.count("animatedElements", frame.elements.filter { $0.transitionMs > 0 }.count)
4623
+ if encoded {
4624
+ for i in cfwPendingPayloads.indices {
4625
+ cfwPendingPayloads[i].frameSeq = frame.seq
4626
+ cfwPendingPayloads[i].appId = frame.appId
4627
+ displayDiagnostics.count("payloadBytes", cfwPendingPayloads[i].data.count)
4628
+ if cfwPendingPayloads[i].kind == .upload {
4629
+ displayDiagnostics.count("uploadBytes", cfwPendingPayloads[i].data.count)
4630
+ }
4631
+ }
4632
+ if !cfwPendingPayloads.isEmpty {
4633
+ cfwPendingPayloads[cfwPendingPayloads.count - 1].originAt = originAt
4634
+ }
4635
+ } else {
4636
+ displayDiagnostics.count("encodeErrors")
4637
+ frame.reportPresented(false, reason: "scene encode failed or invalidated")
4638
+ }
4639
+ if encodeMs >= 100, let line = displayDiagnostics.stall(fields: [
4640
+ "stage": "encode", "seq": frame.seq, "app": frame.appId,
4641
+ "encodeMs": Int(encodeMs), "elements": frame.elements.count, "ok": encoded,
4642
+ ]) {
4643
+ Bridge.log(line)
4644
+ }
4645
+ }
4646
+ guard !Task.isCancelled else { return }
3859
4647
  // Page is dead but content is waiting (e.g. captions kept arriving while iOS had us
3860
4648
  // suspended and the firmware tore the session down). Rebuild the page ONCE here — the
3861
4649
  // reconcile loop is coalesced (1-deep signal buffer), so a burst of buffered sendText
@@ -3863,8 +4651,6 @@ final class G2: NSObject, SGCManager {
3863
4651
  // rebuildState() recreates the page, re-pushes the current text/image, and re-arms the
3864
4652
  // mic iff intent says so. Skip while the native dashboard owns the screen.
3865
4653
  if !pageCreated {
3866
- let useNativeDashboard =
3867
- DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
3868
4654
  // Stock path: only resurrect for non-blank content — don't rebuild just to render a
3869
4655
  // clearDisplay's blank, or a clear burst churns the page back up pointlessly.
3870
4656
  // (CFW is the exception below: its blank IS the idle page.)
@@ -3879,39 +4665,28 @@ final class G2: NSObject, SGCManager {
3879
4665
  // Idle carrier: the black seed alone. This is what the
3880
4666
  // rebuild→systemExit→rebuild storm guard used to block, so it is
3881
4667
  // rate-limited below rather than allowed once per reconcile tick.
3882
- let cfwIdleCarrier = !cfwPendingPayloads.isEmpty && !cfwHasScene && hasCfwCarrier
4668
+ let cfwIdleCarrier = !cfwPendingPayloads.isEmpty && !cfwHasScene && cfwPageInstalled
3883
4669
  let hasPendingImage = imageContainers.contains { $0.dirty && !$0.bmpData.isEmpty }
3884
4670
  || cfwHasScene
3885
4671
  let wants = hasPendingText || hasPendingImage || cfwIdleCarrier
3886
4672
  // Consume the idle-resurrect slot ONLY when this tick would actually
3887
4673
  // rebuild — burning it behind the dashboard gate would stall a real
3888
4674
  // resurrection for the whole backoff window.
3889
- if wants, !(useNativeDashboard && dashboardShowing > 0),
4675
+ if wants, dashboardShowing == 0,
3890
4676
  hasPendingText || hasPendingImage || cfwIdlePageResurrectAllowed()
3891
4677
  {
3892
- // Probe-before-rebuild: a phantom exit may have marked a healthy
3893
- // page dead (static pages give the ack-window filter nothing to
3894
- // go on). Try the queued carrier payload against the "dead" page
3895
- // first — an ack proves it alive and skips the shutdown+create
3896
- // whose own teardown would elicit the next exit. A timeout
3897
- // confirms real death and falls through to the rebuild.
3898
- if cfwHasScene || cfwIdleCarrier,
3899
- let carrier = imageContainers.first(where: { $0.id == Self.cfwSceneContainerID }),
3900
- let probe = cfwPendingPayloads.first
3901
- {
3902
- let ok = await sendImageData(
3903
- containerID: carrier.id, containerName: carrier.name, bmpData: probe.data
4678
+ // Our own SHUTDOWN_PAGE is still being answered: creating now
4679
+ // would race the teardown trio (see lastPageShutdownSentMs).
4680
+ let settle = pageShutdownSettleRemainingMs()
4681
+ if settle > 0 {
4682
+ Bridge.log(
4683
+ "G2: reconcileDisplay() - page down, \(settle)ms left in the shutdown settle window; deferring the rebuild"
3904
4684
  )
3905
- if ok {
3906
- Bridge.log("G2: page-down probe acked — phantom exit, page alive (no rebuild)")
3907
- pageCreated = true
3908
- if !cfwPendingPayloads.isEmpty {
3909
- cfwPendingPayloads.removeFirst()
3910
- }
3911
- signalDisplayDirty() // drain the rest normally
3912
- return
3913
- }
4685
+ armPageShutdownSettleWake(afterMs: settle)
4686
+ return
3914
4687
  }
4688
+ // (Transport ACKs need no page, so they cannot probe a "dead" page
4689
+ // the way carrier image ACKs once did.)
3915
4690
  Bridge.log("G2: reconcileDisplay() - page down with pending content, rebuilding once")
3916
4691
  await rebuildState()
3917
4692
  }
@@ -3921,11 +4696,8 @@ final class G2: NSObject, SGCManager {
3921
4696
  // Text: synchronous, no ACK. Send one update per container with pending sends and decrement.
3922
4697
  for i in textContainers.indices where textContainers[i].pendingSends > 0 {
3923
4698
  let container = textContainers[i]
3924
- // Byte-exact content log (debugDescription escapes \n and non-ASCII)
3925
- // — artifact forensics: match on-glass rendering to what was sent.
3926
- Bridge.log(
3927
- "G2: updateText id=\(container.id) rect=\(container.x),\(container.y) \(container.width)x\(container.height) bytes=\(container.content.utf8.count) content=\(container.content.debugDescription)"
3928
- )
4699
+ displayDiagnostics.count("stockTextWrites")
4700
+ displayDiagnostics.count("stockTextBytes", container.content.utf8.count)
3929
4701
  let msg = EvenHubProto.updateTextMessage(
3930
4702
  containerID: container.id,
3931
4703
  containerName: container.name,
@@ -3937,16 +4709,82 @@ final class G2: NSObject, SGCManager {
3937
4709
  textContainers[i].pendingSends -= 1
3938
4710
  }
3939
4711
 
3940
- // 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
3941
4723
  // fast producer can't starve the ticker; the remainder goes out on the next cycle.
3942
- if let carrier = imageContainers.first(where: { $0.id == Self.cfwSceneContainerID }) {
4724
+ if supportsCfwSceneRendering, cfwPageInstalled {
3943
4725
  var drained = 0
3944
4726
  while pageCreated, drained < 4, !cfwPendingPayloads.isEmpty {
3945
4727
  drained += 1
3946
4728
  let payload = cfwPendingPayloads.removeFirst()
3947
- let ok = await sendImageData(
3948
- containerID: carrier.id, containerName: carrier.name, bmpData: payload.data
3949
- )
4729
+ cfwPayloadInFlight = true
4730
+ cfwInFlightPresentTag = payload.presents
4731
+ diagnosticFrameSeq = payload.frameSeq
4732
+ diagnosticAppId = payload.appId
4733
+ var ok = true
4734
+ if G2CfwTransport.isCacheMessage(payload.data) {
4735
+ ok = await cfwEnsureCacheSession()
4736
+ }
4737
+ if ok {
4738
+ ok = await sendCfwMessage(payload.data, lenses: payload.lenses)
4739
+ }
4740
+ var recover: (() -> Void)?
4741
+
4742
+ if ok, G2CfwTransport.isCacheMessage(payload.data) {
4743
+ switch cfwCacheOutcome() {
4744
+ case .applied:
4745
+ cfwMissingRetries = 0
4746
+ case .stale:
4747
+ // A lens lost its cache (lease lapse, reboot): new session, redefine everything.
4748
+ displayDiagnostics.count("cacheStale")
4749
+ ok = false
4750
+ recover = { [self] in cfwCacheGeneration += 1; cfwCacheEpoch = 0; cfwSceneInvalid = true }
4751
+ case let .missing(ids):
4752
+ displayDiagnostics.count("cacheMissing")
4753
+ ok = false
4754
+ let retry = cfwMissingRetries < 2
4755
+ cfwMissingRetries += 1
4756
+ recover = { [self] in
4757
+ Task { await cfwRenderer.forget(ids: ids) }
4758
+ if !retry { cfwSceneInvalid = true }
4759
+ }
4760
+ case let .refused(why):
4761
+ displayDiagnostics.count("cacheRefused")
4762
+ Bridge.log("G2: cache message refused: \(why)")
4763
+ ok = false
4764
+ recover = { [self] in cfwCacheGeneration += 1; cfwCacheEpoch = 0; cfwSceneInvalid = true }
4765
+ }
4766
+ }
4767
+ if ok {
4768
+ // Both lenses acked and applied: the firmware has executed the
4769
+ // payload and presents on its next frame tick.
4770
+ recordCfwPayloadAck(payload)
4771
+ cfwInFlightPresentTag?.report(true)
4772
+ }
4773
+ if !ok {
4774
+ cfwInFlightPresentTag?.report(false, reason: recover != nil ? "cache rejected" : "message transport failed")
4775
+ }
4776
+ cfwInFlightPresentTag = nil
4777
+ cfwPayloadInFlight = false
4778
+ if let recover {
4779
+ // Re-encode the last frame against the corrected mirror and try again.
4780
+ recover()
4781
+ cfwPendingPayloads.removeAll { $0.kind == .scene || $0.kind == .upload }
4782
+ if pageCreated, supportsCfwSceneRendering, cfwPageInstalled, let frame = cfwLastFrame {
4783
+ _ = await enqueueCfwFrame(frame)
4784
+ }
4785
+ break
4786
+ }
4787
+ guard !Task.isCancelled else { return }
3950
4788
  if !ok {
3951
4789
  // A lost payload leaves the on-glass shadow stale; further
3952
4790
  // deltas would composite onto it and corrupt the panel.
@@ -3955,15 +4793,29 @@ final class G2: NSObject, SGCManager {
3955
4793
  // the page down mid-transfer is re-seeded by rebuildState;
3956
4794
  // refilling the queue here just fuels the page-resurrection
3957
4795
  // loop (rebuild→systemExit→rebuild every debounce tick).
3958
- if pageCreated, supportsCfwSceneRendering, hasCfwCarrier {
3959
- 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)
3960
4804
  if let frame = cfwLastFrame {
3961
- _ = await enqueueCfwFrame(frame)
4805
+ cfwPendingFrame = frame
4806
+ }
4807
+ if keepCache {
4808
+ await cfwRenderer.forgetUploads(dropped)
3962
4809
  }
3963
4810
  }
3964
4811
  break
3965
4812
  }
3966
4813
  }
4814
+ // A finite upload burst can exhaust the four-payload budget after
4815
+ // its producer signal was consumed. Continue without a 100 ms tick gap.
4816
+ if pageCreated, !cfwPendingPayloads.isEmpty || cfwPendingFrame != nil {
4817
+ signalDisplayDirty()
4818
+ }
3967
4819
  }
3968
4820
 
3969
4821
  // Images: ACK-gated, exactly one in flight. Cap iterations defensively so a container that
@@ -4058,6 +4910,7 @@ final class G2: NSObject, SGCManager {
4058
4910
 
4059
4911
  /// shutdown and rebuild everything, re-sends all data to the glasses:
4060
4912
  private func rebuildPage() async {
4913
+ lastPageShutdownSentMs = Int64(Date().timeIntervalSince1970 * 1000)
4061
4914
  let msg = EvenHubProto.shutdownMessage()
4062
4915
  sendEvenHubCommand(msg)
4063
4916
  pageCreated = false
@@ -4073,15 +4926,19 @@ final class G2: NSObject, SGCManager {
4073
4926
  Bridge.log("G2: rebuildState() skipped — navigation mode owns the screen")
4074
4927
  return
4075
4928
  }
4929
+ if notificationsPanelHold {
4930
+ Bridge.log("G2: rebuildState() skipped — notifications panel hold")
4931
+ return
4932
+ }
4076
4933
  Bridge.log("G2: rebuildState()")
4077
4934
  // Stock containers left by a torn-down page would rebuild as the evt-0 page, not the carrier.
4078
4935
  if supportsCfwSceneRendering {
4079
- _ = installCfwCarrierIfNeeded()
4936
+ _ = installCfwPageIfNeeded()
4080
4937
  }
4081
4938
  // A stray text container here means createPageWithContainers builds the evt-0 shape
4082
- // instead of the carrier, and every CFW payload is rejected (img_res errorCode=4).
4939
+ // instead of the CFW page.
4083
4940
  Bridge.log(
4084
- "G2: rebuildState() post-carrier cfw=\(supportsCfwSceneRendering) carrier=\(hasCfwCarrier)"
4941
+ "G2: rebuildState() post-install cfw=\(supportsCfwSceneRendering) cfwPage=\(hasCfwPage)"
4085
4942
  + " text=\(textContainers.map { $0.id }) img=\(imageContainers.map { $0.id })"
4086
4943
  )
4087
4944
  // recreate the containers (sets pageCreated = true; embeds text content directly):
@@ -4098,12 +4955,11 @@ final class G2: NSObject, SGCManager {
4098
4955
  )
4099
4956
  imageContainers[i].dirty = true
4100
4957
  }
4101
- // The CFW shadow lives in the carrier's display allocation, which the new
4102
- // page just re-created: seed it again and re-send the retained frame.
4103
- if supportsCfwSceneRendering, hasCfwCarrier {
4958
+ // Re-seed the on-glass shadow and re-send the retained frame for the new page.
4959
+ if supportsCfwSceneRendering, cfwPageInstalled {
4104
4960
  cfwResetShadow()
4105
4961
  if let frame = cfwLastFrame {
4106
- _ = await enqueueCfwFrame(frame)
4962
+ cfwPendingFrame = frame
4107
4963
  } else if let seed = G2CfwRenderer.blackPayload() {
4108
4964
  cfwPendingPayloads.append(CfwPayload(kind: .seed, data: seed))
4109
4965
  cfwShadowSeeded = true
@@ -4123,6 +4979,30 @@ final class G2: NSObject, SGCManager {
4123
4979
  restartMicIfAlreadyEnabled()
4124
4980
  }
4125
4981
 
4982
+ /// Milliseconds left in the settle window after a SHUTDOWN_PAGE we sent; 0 outside it.
4983
+ private func pageShutdownSettleRemainingMs() -> Int64 {
4984
+ guard lastPageShutdownSentMs > 0 else { return 0 }
4985
+ let elapsed = Int64(Date().timeIntervalSince1970 * 1000) - lastPageShutdownSentMs
4986
+ return max(0, PAGE_SHUTDOWN_SETTLE_MS - elapsed)
4987
+ }
4988
+
4989
+ /// Once the settle window ends, bring the page back exactly once: through
4990
+ /// recovery when the mic needs a live page, else through the reconcile
4991
+ /// loop, which only resurrects for real content (a blank stays down).
4992
+ private func armPageShutdownSettleWake(afterMs: Int64) {
4993
+ guard !pageShutdownSettleWakeArmed else { return }
4994
+ pageShutdownSettleWakeArmed = true
4995
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(afterMs) + 10)) { [weak self] in
4996
+ guard let self else { return }
4997
+ self.pageShutdownSettleWakeArmed = false
4998
+ if !self.pageCreated, self.evenHubMicIntent {
4999
+ self.recoverPageAndMic(reason: "shutdown-settled")
5000
+ } else {
5001
+ self.signalDisplayDirty()
5002
+ }
5003
+ }
5004
+ }
5005
+
4126
5006
  /// Single coalesced recovery: rebuild the page + re-arm sensor streams from intent, but never
4127
5007
  /// stack rebuilds. The firmware spams systemExit/dashboard-close ~1×/sec; without this
4128
5008
  /// guard each one triggered a rebuild that was torn down again → rebuild→exit→rebuild
@@ -4134,6 +5014,15 @@ final class G2: NSObject, SGCManager {
4134
5014
  if navigationModeActive {
4135
5015
  return
4136
5016
  }
5017
+ // The firmware is still answering our own SHUTDOWN_PAGE (its systemExit
5018
+ // and 08011A00 land ~300 ms after it): this is the old page dying, not
5019
+ // something to recover from. The settle wake brings the page back once.
5020
+ let settle = pageShutdownSettleRemainingMs()
5021
+ if settle > 0 {
5022
+ Bridge.log("G2: recover(\(reason)) skipped — \(settle)ms left in the shutdown settle window")
5023
+ armPageShutdownSettleWake(afterMs: settle)
5024
+ return
5025
+ }
4137
5026
  let now = Int64(Date().timeIntervalSince1970 * 1000)
4138
5027
  // If the page is already alive and the mic matches intent, there's nothing to recover —
4139
5028
  // this is a spurious/phantom firmware event (it spams close/exit even when our page is
@@ -4512,8 +5401,11 @@ final class G2: NSObject, SGCManager {
4512
5401
  }
4513
5402
 
4514
5403
  func setDashboardDepthOnly(_ depth: Int) {
4515
- let clamped = Int32(min(max(depth, 0), 2))
4516
- 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))")
4517
5409
  let msg = G2SettingProto.setScreenDepth(
4518
5410
  magicRandom: sendManager.nextMagicRandom(),
4519
5411
  level: clamped
@@ -4535,19 +5427,15 @@ final class G2: NSObject, SGCManager {
4535
5427
  // MARK: - Private Display Helpers
4536
5428
 
4537
5429
  private func createPageWithContainers() {
4538
- // CFW carrier page: byte-identical to the known-good mixed-image page packet.
4539
- // One full-canvas "dashboard" text widget (id 1, content " ", event
4540
- // capture, exact compact field shape with no border/padding fields) plus
4541
- // the 576×288 carrier image container, and widgetId 10000 on the page.
4542
- // Our generic shape (1×1 evt-0 stub, extra fields, no widgetId) is what
4543
- // the known-good client does NOT send.
4544
- // With CFW scene rendering active the ONLY legal page is the carrier
4545
- // shape: one 576×288 image container (id 10) and no stock text
4546
- // containers. A stray stock container here rebuilds the evt-0 page
4547
- // shape and the firmware then rejects every CFW payload (img_res
4548
- // errorCode=4). Every rebuild path funnels through this function, so
4549
- // enforce the invariant here rather than trusting each caller to have
4550
- // run installCfwCarrierIfNeeded() first.
5430
+ // CFW page: one full-canvas "dashboard" text widget (id 1, content " ",
5431
+ // event capture, exact compact field shape with no border/padding
5432
+ // fields) and widgetId 10000 on the page — the blank text container the
5433
+ // firmware keeps for touch forwarding and the mic. Custom payloads ride
5434
+ // the SID-0xf0 transport and need no image container. With CFW scene
5435
+ // rendering active this is the ONLY legal page: a stray stock container
5436
+ // here would rebuild the evt-0 page shape. Every rebuild path funnels
5437
+ // through this function, so enforce the invariant here rather than
5438
+ // trusting each caller to have run installCfwPageIfNeeded() first.
4551
5439
  if supportsCfwSceneRendering {
4552
5440
  if !textContainers.isEmpty {
4553
5441
  Bridge.log(
@@ -4555,21 +5443,16 @@ final class G2: NSObject, SGCManager {
4555
5443
  )
4556
5444
  textContainers.removeAll()
4557
5445
  }
4558
- let strayImages = imageContainers.filter { $0.id != Self.cfwSceneContainerID }
4559
- if !strayImages.isEmpty {
5446
+ if !imageContainers.isEmpty {
4560
5447
  Bridge.log(
4561
- "G2: CFW page — dropping stray image containers \(strayImages.map { $0.id })"
5448
+ "G2: CFW page — dropping stray image containers \(imageContainers.map { $0.id })"
4562
5449
  )
4563
- imageContainers.removeAll { $0.id != Self.cfwSceneContainerID }
4564
- }
4565
- if !hasCfwCarrier {
4566
- _ = installCfwCarrierIfNeeded()
5450
+ imageContainers.removeAll()
4567
5451
  }
5452
+ cfwPageInstalled = true
4568
5453
  }
4569
5454
 
4570
- let isCfwCarrierPage = textContainers.isEmpty
4571
- && imageContainers.count == 1
4572
- && imageContainers[0].id == Self.cfwSceneContainerID
5455
+ let isCfwCarrierPage = supportsCfwSceneRendering && textContainers.isEmpty && imageContainers.isEmpty
4573
5456
 
4574
5457
  // Dedicated event-capture container: id 0, 1x1, borderless, empty — the
4575
5458
  // designated event-capture slot per the RE demos ("container 0 is
@@ -4624,15 +5507,6 @@ final class G2: NSObject, SGCManager {
4624
5507
  // iterate all image containers, remove any entrys with duplicate id's, and ensure the ids in the imageContainerIDPool is up-to-date:
4625
5508
  var seenIDs = Set<Int32>()
4626
5509
  imageContainers = imageContainers.filter { c in
4627
- // The CFW carrier is INTENTIONALLY empty: its pixels ride the
4628
- // ordered payload queue (cfwPendingPayloads), never bmpData. The
4629
- // empty-container prune below silently dropped it from every page
4630
- // since the queue rework — the firmware then had no container to
4631
- // receive ANY CFW payload (mode 3/6/8, even plain BMPs), so all
4632
- // direct rendering went dark while stock text kept working.
4633
- if c.id == Self.cfwSceneContainerID {
4634
- return seenIDs.insert(c.id).inserted
4635
- }
4636
5510
  guard !c.bmpData.isEmpty else {
4637
5511
  Bridge.log("G2: removing empty image container \(c.id)")
4638
5512
  return false
@@ -4749,17 +5623,18 @@ final class G2: NSObject, SGCManager {
4749
5623
  // Intent is "mic on". The mic only exists inside a live EvenHub page, so we
4750
5624
  // toggle it off then back on (the firmware needs the off→on edge to re-arm).
4751
5625
  evenHubMicIntent = true
5626
+ // Held: intent is recorded; the post-hold rebuild re-arms the mic.
5627
+ if notificationsPanelHold {
5628
+ return
5629
+ }
4752
5630
  DeviceStore.shared.apply("glasses", "micEnabled", true)
4753
5631
  let msg = EvenHubProto.audioControlMessage(enable: false)
4754
5632
  sendEvenHubCommand(msg)
4755
5633
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
4756
5634
  guard let self = self else { return }
4757
- let useNativeDashboard =
4758
- DeviceStore.shared.get("bluetooth", "use_native_dashboard") as? Bool ?? false
4759
- // Bridge.log("G2: setMicEnabled - useNativeDashboard=\(useNativeDashboard), dashboardShowing=\(dashboardShowing)")
4760
5635
  // Dashboard owns the screen + session right now — don't arm the mic into a
4761
5636
  // page the dashboard has taken over; recovery re-arms on dashboard close.
4762
- if useNativeDashboard && dashboardShowing > 0 {
5637
+ if dashboardShowing > 0 {
4763
5638
  return
4764
5639
  }
4765
5640
  // Never send audioControl(enable:true) without a live page — no page means no
@@ -4870,13 +5745,15 @@ final class G2: NSObject, SGCManager {
4870
5745
  startupPageCreated = false
4871
5746
  pageCreated = false
4872
5747
  stockImageStreamWarmedUp = false
5748
+ cfwTransport = G2CfwTransport.Encoder()
5749
+ cfwStreamSequence = UInt8.random(in: 0 ... 255)
4873
5750
  dashboardShowing = 0
4874
5751
  dashboardOpening = false
5752
+ notificationsPanelHold = false
4875
5753
  heartbeatCounter = 0
4876
- cfwCapabilityString = nil
4877
- cfwClassified = false
4878
- cfwFeatures.removeAll()
4879
- cfwProtocolVersion = 0
5754
+ cancelSensorCommands()
5755
+ cfwClassification = nil
5756
+ publishRingBattery(.disconnected)
4880
5757
  cfwSceneActive = false
4881
5758
  // Fresh connection gets a fresh idle-resurrect budget.
4882
5759
  cfwLastIdleResurrectAt = nil
@@ -4884,6 +5761,11 @@ final class G2: NSObject, SGCManager {
4884
5761
  cfwResetShadow()
4885
5762
  stopFbLease(sendRelease: false) // link is gone; the leases fail open in 90 s
4886
5763
  stopWakeLease(sendRelease: false) // wakeLeaseWanted persists; re-armed on CFW re-detection
5764
+ stopAncsRelay(sendDisable: false) // re-armed on CFW re-detection
5765
+ keyboardBridge.linkLost() // the remembered keyboard reconnects on CFW re-detection
5766
+ notificationPhase = "stale"
5767
+ lastAppliedNotificationConfig = nil // the next connection gets a full re-push
5768
+ publishNotificationStatus()
4887
5769
  DeviceStore.shared.apply("glasses", "connected", false)
4888
5770
  DeviceStore.shared.apply("glasses", "fullyBooted", false)
4889
5771
  }
@@ -5012,9 +5894,19 @@ final class G2: NSObject, SGCManager {
5012
5894
  /// 3. SKILL{skillId=NOTIFICATION, skillParam=show, ...} — dispatches the intent
5013
5895
  /// The SKILL step alone is ignored by firmware; the preceding ENTER+ASK
5014
5896
  /// supply the session context that lets the glasses act on the SKILL.
5897
+ /// Our EvenHub page is torn down first and every path that would bring it
5898
+ /// back (recovery rebuilds, fbguard lease, CFW frames, mic re-arm) is held
5899
+ /// for 3 s, or the sends race the stock panel for the screen.
5015
5900
  func showNotificationsPanel() async {
5016
5901
  Bridge.log("G2: showNotificationsPanel()")
5017
5902
 
5903
+ notificationsPanelHold = true
5904
+ stopFbLease(sendRelease: true)
5905
+ sendEvenHubCommand(EvenHubProto.shutdownMessage())
5906
+ pageCreated = false
5907
+ // Page death takes the mic with it; intent is untouched so the post-hold rebuild re-arms.
5908
+ DeviceStore.shared.apply("glasses", "micEnabled", false)
5909
+
5018
5910
  let enterPayload = EvenAIProto.aiCtrl(
5019
5911
  magicRandom: sendManager.nextMagicRandom(),
5020
5912
  status: 2 // EVEN_AI_ENTER
@@ -5035,26 +5927,25 @@ final class G2: NSObject, SGCManager {
5035
5927
  text: " ",
5036
5928
  streamEnable: 1, fTextEnd: 1
5037
5929
  )
5930
+
5931
+ try? await Task.sleep(nanoseconds: 3_000_000_000)
5932
+ notificationsPanelHold = false
5933
+ Bridge.log("G2: notifications resurrect re-enabled")
5038
5934
  }
5039
5935
 
5040
- func dbg1() {
5041
- Task { await showNotificationsPanel() }
5042
- // setCalendarWidgetFirst()
5043
- // 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()
5044
5944
  }
5045
5945
 
5046
- func dbg2() {
5047
- Task {
5048
- dashboardShowing = 1
5049
- dashboardOpening = true
5050
- let msg = EvenHubProto.shutdownMessage()
5051
- sendEvenHubCommand(msg)
5052
- pageCreated = false
5053
- try? await Task.sleep(nanoseconds: 300_000_000) // 300ms to settle
5054
- await showNotificationsPanel()
5055
- }
5056
- // navigationModeActive ? exitNavigationMode(arrived: false) : enterNavigationMode()
5057
- // Task { await runAuthSequence() }
5946
+ func dbg4() {
5947
+ Bridge.log("G2: dbg4() — stop wake lease")
5948
+ stopWakeLease(sendRelease: true)
5058
5949
  }
5059
5950
 
5060
5951
  // MARK: - Native navigation mode (service 0x08)
@@ -5110,6 +6001,7 @@ final class G2: NSObject, SGCManager {
5110
6001
  func exitNavigationMode(arrived: Bool) {
5111
6002
  guard navigationModeActive else { return }
5112
6003
  Bridge.log("G2: exitNavigationMode(arrived: \(arrived))")
6004
+ sensorNavigationRecovering = true
5113
6005
  navHeartbeatTask?.cancel()
5114
6006
  navHeartbeatTask = nil
5115
6007
  navigationModeActive = false
@@ -5136,6 +6028,7 @@ final class G2: NSObject, SGCManager {
5136
6028
  await MainActor.run {
5137
6029
  guard let self = self, !self.navigationModeActive else { return }
5138
6030
  self.pageCreated = false
6031
+ self.sensorNavigationRecovering = false
5139
6032
  self.recoverPageAndMic(reason: "nav-exit")
5140
6033
  }
5141
6034
  }
@@ -5505,12 +6398,15 @@ final class G2: NSObject, SGCManager {
5505
6398
  startupPageCreated = false
5506
6399
  pageCreated = false
5507
6400
  stockImageStreamWarmedUp = false
6401
+ cfwTransport = G2CfwTransport.Encoder()
6402
+ cfwStreamSequence = UInt8.random(in: 0 ... 255)
6403
+ cfwPageInstalled = false
5508
6404
  dashboardShowing = 0
5509
6405
  dashboardOpening = false
5510
- cfwCapabilityString = nil
5511
- cfwClassified = false
5512
- cfwFeatures.removeAll()
5513
- cfwProtocolVersion = 0
6406
+ cancelSensorCommands()
6407
+ cfwClassification = nil
6408
+ publishRingBattery(.disconnected)
6409
+ keyboardBridge.linkLost()
5514
6410
  cfwSceneActive = false
5515
6411
  cfwLastIdleResurrectAt = nil
5516
6412
  cfwLastFrame = nil
@@ -5696,9 +6592,8 @@ final class G2: NSObject, SGCManager {
5696
6592
 
5697
6593
  // MARK: - Incoming Data Handling
5698
6594
 
5699
- private func handleNotifyData(_ data: Data, from peripheral: CBPeripheral) async {
6595
+ private func handleNotifyData(_ data: Data, sourceKey: String, arrival: G2InputArrival) {
5700
6596
  // Distinguish left vs right peripheral so multi-packet reassembly doesn't collide
5701
- let sourceKey = peripheral === leftPeripheral ? "L" : "R"
5702
6597
  guard let result = receiveManager.handlePacket(data, sourceKey: sourceKey) else { return }
5703
6598
  // Bridge.log(
5704
6599
  // "G2: handleNotifyData() - serviceId=\(result.serviceId), payload=\(result.payload.count) bytes"
@@ -5707,7 +6602,7 @@ final class G2: NSObject, SGCManager {
5707
6602
  // Route based on service ID
5708
6603
  switch result.serviceId {
5709
6604
  case ServiceID.evenHub.rawValue:
5710
- handleEvenHubResponse(result.payload)
6605
+ handleEvenHubResponse(result.payload, arrival: arrival)
5711
6606
  case ServiceID.deviceSettings.rawValue:
5712
6607
  handleDevSettingsResponse(result.payload, sourceKey: sourceKey)
5713
6608
  case ServiceID.g2Setting.rawValue:
@@ -5717,7 +6612,7 @@ final class G2: NSObject, SGCManager {
5717
6612
  case ServiceID.dashboard.rawValue:
5718
6613
  handleDashboardResponse(result.payload)
5719
6614
  case ServiceID.gestureCtrl.rawValue:
5720
- await handleGestureCtrl(result.payload)
6615
+ Task { await handleGestureCtrl(result.payload) }
5721
6616
  case ServiceID.navigation.rawValue:
5722
6617
  handleNavigationResponse(result.payload)
5723
6618
  case ServiceID.evenAI.rawValue:
@@ -5726,6 +6621,8 @@ final class G2: NSObject, SGCManager {
5726
6621
  handleEvenHubCtrlResponse(result.payload)
5727
6622
  case ServiceID.notification.rawValue:
5728
6623
  handleNotificationResponse(result.payload)
6624
+ case ServiceID.cfwStream.rawValue:
6625
+ break // ACK/NACK and cache replies are correlated inline in correlateCfwAck()
5729
6626
  default:
5730
6627
  Bridge.log(
5731
6628
  "G2: Unhandled service \(result.serviceId) (\(result.payload.count) bytes): \(result.payload.map { String(format: "%02X", $0) }.joined())"
@@ -5806,18 +6703,18 @@ final class G2: NSObject, SGCManager {
5806
6703
  "timestamp": now,
5807
6704
  ]
5808
6705
  )
5809
- Bridge.sendTypedMessage(
5810
- "phone_notification",
5811
- body: [
5812
- "notificationId": "ancs-\(appID)-\(now)",
5813
- "app": name.isEmpty ? appID : name,
5814
- "title": "",
5815
- "content": "",
5816
- "priority": "0",
5817
- "timestamp": now,
5818
- "packageName": appID,
5819
- ]
5820
- )
6706
+ // Bridge.sendTypedMessage(
6707
+ // "phone_notification",
6708
+ // body: [
6709
+ // "notificationId": "ancs-\(appID)-\(now)",
6710
+ // "app": name.isEmpty ? appID : name,
6711
+ // "title": "",
6712
+ // "content": "",
6713
+ // "priority": "0",
6714
+ // "timestamp": now,
6715
+ // "packageName": appID,
6716
+ // ]
6717
+ // )
5821
6718
  }
5822
6719
  }
5823
6720
  }
@@ -5858,16 +6755,13 @@ final class G2: NSObject, SGCManager {
5858
6755
  guard let compassData = fields[10] as? Data else { return }
5859
6756
  var cReader = ProtobufReader(compassData)
5860
6757
  let cFields = cReader.parseFields()
5861
- guard let heading = cFields[1] as? Int32 else { return }
5862
- // Heading in degrees, 0…359.
5863
- Bridge.log("G2: compass heading=\(heading)°")
5864
- Bridge.sendTypedMessage(
5865
- "compass_heading",
5866
- body: [
5867
- "heading": Int(heading),
5868
- "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
5869
- ]
5870
- )
6758
+ // Proto3 omits the scalar for magnetic north (zero degrees).
6759
+ guard let heading = G2Sensors.heading(cFields) else { return }
6760
+ var body: [String: Any] = ["heading": Int(heading), "timestamp": Int64(Date().timeIntervalSince1970 * 1000)]
6761
+ if let data = fields[100] as? Data, let diagnostics = G2Sensors.compass(data) {
6762
+ body["diagnostics"] = diagnostics
6763
+ }
6764
+ Bridge.sendTypedMessage("compass_heading", body: body)
5871
6765
 
5872
6766
  case NavigationCmd.osNotifyCompassCalibrateStart.rawValue:
5873
6767
  Bridge.log("G2: compass calibration started — wearer should look around")
@@ -5908,7 +6802,7 @@ final class G2: NSObject, SGCManager {
5908
6802
  }
5909
6803
  }
5910
6804
 
5911
- private func handleEvenHubResponse(_ payload: Data) {
6805
+ private func handleEvenHubResponse(_ payload: Data, arrival: G2InputArrival) {
5912
6806
  // Parse evenhub_main_msg_ctx: field 1 = Cmd (varint), field 13 = DevEvent (submessage)
5913
6807
  var reader = ProtobufReader(payload)
5914
6808
  let fields = reader.parseFields()
@@ -5931,7 +6825,7 @@ final class G2: NSObject, SGCManager {
5931
6825
  if cmdValue == EvenHubResponseCmd.osNotifyEventToApp.rawValue {
5932
6826
  // Device event from glasses (touch/gesture or sensor data)
5933
6827
  guard let devEventData = fields[13] as? Data else { return }
5934
- handleTouchEvent(devEventData)
6828
+ handleTouchEvent(devEventData, arrival: arrival)
5935
6829
  } else if cmdValue == 17 {
5936
6830
  // Miniapp selection from glasses dashboard menu (cmdId=17)
5937
6831
  // Dedup: L and R peripherals both deliver this event, so debounce or
@@ -6074,15 +6968,15 @@ final class G2: NSObject, SGCManager {
6074
6968
  return (x, y, z)
6075
6969
  }
6076
6970
 
6077
- private func handleTouchEvent(_ devEventData: Data) {
6971
+ private func handleTouchEvent(_ devEventData: Data, arrival: G2InputArrival) {
6078
6972
  // Parse SendDeviceEvent: field 1=ListEvent, field 2=TextEvent, field 3=SysEvent
6079
6973
  var reader = ProtobufReader(devEventData)
6080
6974
  let fields = reader.parseFields()
6081
6975
 
6082
- let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
6976
+ let timestamp = arrival.timestamp
6083
6977
 
6084
6978
  // Classify IMU reports before applying the click debounce. Keepalive samples arrive
6085
- // continuously and must not update `lastClickTimestamp`, or a real gesture delivered
6979
+ // continuously and must not update the debounce, or a real gesture delivered
6086
6980
  // within 100 ms of a sample can be dropped.
6087
6981
  var parsedSysFields: [Int: Any]?
6088
6982
  if let sysData = fields[3] as? Data {
@@ -6090,13 +6984,14 @@ final class G2: NSObject, SGCManager {
6090
6984
  let sysFields = sysReader.parseFields()
6091
6985
  if (sysFields[1] as? Int32) == OsEventType.imuDataReport.rawValue {
6092
6986
  lastImuReportTimestamp = timestamp
6987
+ displayDiagnostics.count("imuReceived")
6093
6988
  let shouldForwardSamples =
6094
6989
  DeviceStore.shared.get("bluetooth", "imu_enabled") as? Bool ?? false
6095
6990
  if shouldForwardSamples,
6096
6991
  let imuData = sysFields[3] as? Data,
6097
6992
  let imu = parseImuReportData(imuData)
6098
6993
  {
6099
- Bridge.log("G2: IMU data report: \(imu.x), \(imu.y), \(imu.z)")
6994
+ displayDiagnostics.count("imuSamples")
6100
6995
  Bridge.sendAccelEvent(x: imu.x, y: imu.y, z: imu.z, timestamp: timestamp)
6101
6996
  }
6102
6997
  return
@@ -6104,20 +6999,31 @@ final class G2: NSObject, SGCManager {
6104
6999
  parsedSysFields = sysFields
6105
7000
  }
6106
7001
 
6107
- // tap_start/tap_end bracket every gesture: the end edge lands within the
6108
- // debounce window of its own start, and the click event fires right after
6109
- // tap_end. Exempt the edges from the debounce (check AND update) or they
6110
- // either get dropped or swallow the real gesture that follows.
6111
7002
  let sysEventTypeRaw = parsedSysFields?[1] as? Int32
6112
- let isTapEdge =
6113
- sysEventTypeRaw == OsEventType.tapStart.rawValue
6114
- || sysEventTypeRaw == OsEventType.tapEnd.rawValue
6115
- if !isTapEdge {
6116
- if lastClickTimestamp != nil && timestamp - lastClickTimestamp! < 100 {
6117
- // Bridge.log("G2: Double click ignored (too soon)")
6118
- return
6119
- }
6120
- 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
6121
7027
  }
6122
7028
 
6123
7029
  // if we are receiving touch events we are fully booted:
@@ -6155,10 +7061,11 @@ final class G2: NSObject, SGCManager {
6155
7061
  // whose own shutdown elicits the next exit: the 1-2s
6156
7062
  // rebuild→systemExit→rebuild loop. Drop the event entirely — no
6157
7063
  // state change, no touch-stream forward to miniapps.
7064
+ // (Only stock image ACKs count: SID-0xf0 replies need no page.)
6158
7065
  if eventType == .systemExit || eventType == .abnormalExit, pageCreated {
6159
7066
  let sinceAck = imgAckBox.msSinceLastSuccess()
6160
7067
  if sinceAck < PHANTOM_EXIT_ACK_WINDOW_MS {
6161
- Bridge.log("G2: \(eventType) ignored — carrier acked \(sinceAck)ms ago (phantom exit)")
7068
+ Bridge.log("G2: \(eventType) ignored — a stock image acked \(sinceAck)ms ago (phantom exit)")
6162
7069
  return
6163
7070
  }
6164
7071
  }
@@ -6173,7 +7080,6 @@ final class G2: NSObject, SGCManager {
6173
7080
  timestamp: timestamp,
6174
7081
  source: eventSource
6175
7082
  )
6176
- Bridge.log("G2: SysEvent → \(eventType) \(eventSource)")
6177
7083
 
6178
7084
  if eventSource == 2 {
6179
7085
  // controller must be connected and fully booted:
@@ -6247,6 +7153,8 @@ final class G2: NSObject, SGCManager {
6247
7153
  case .systemExit: return "system_exit"
6248
7154
  case .tapStart: return "tap_start"
6249
7155
  case .tapEnd: return "tap_end"
7156
+ case .tapThenHold: return "tap_then_hold"
7157
+ case .ringTouchDown: return "touch_down"
6250
7158
  case .imuDataReport: return nil
6251
7159
  case .abnormalExit: return nil // don't report abnormal exits as gestures
6252
7160
  }
@@ -6376,6 +7284,12 @@ final class G2: NSObject, SGCManager {
6376
7284
  }
6377
7285
  }
6378
7286
 
7287
+ private func publishRingBattery(_ battery: G2RingBattery) {
7288
+ DeviceStore.shared.apply("glasses", "ringConnected", battery.connected)
7289
+ DeviceStore.shared.apply("glasses", "ringBatteryLevel", battery.level)
7290
+ DeviceStore.shared.apply("glasses", "ringCharging", battery.charging)
7291
+ }
7292
+
6379
7293
  private func handleG2SettingResponse(_ payload: Data) {
6380
7294
  // Parse G2SettingPackage: field 1=commandId, field 4=DeviceReceiveRequestFromAPP (response), field 5=DeviceSendInfoToAPP
6381
7295
  var reader = ProtobufReader(payload)
@@ -6396,6 +7310,28 @@ final class G2: NSObject, SGCManager {
6396
7310
  if let sendData = fields[5] as? Data {
6397
7311
  parseDeviceSendToApp(sendData)
6398
7312
  }
7313
+ if let data = fields[105] as? Data, var reading = G2Sensors.light(data) {
7314
+ reading["timestamp"] = Int64(Date().timeIntervalSince1970 * 1000)
7315
+ Bridge.sendTypedMessage("light_sensor", body: reading)
7316
+ }
7317
+ if let data = fields[106] as? Data, let battery = G2RingBattery.decode(data) {
7318
+ publishRingBattery(battery)
7319
+ }
7320
+ if let record = fields[125] as? Data {
7321
+ handleAncsRelayRecord(record)
7322
+ }
7323
+ if let data = fields[129] as? Data, data.count >= 2 {
7324
+ Bridge.sendSceneSettled(tag: Int(data[data.startIndex]) | (Int(data[data.startIndex + 1]) << 8))
7325
+ }
7326
+ if let data = fields[128] as? Data, let status = G2BleLink.decodeStatus(data),
7327
+ status != lastBleLinkStatus
7328
+ {
7329
+ lastBleLinkStatus = status
7330
+ Bridge.log("G2: BLE link \(status.summary)")
7331
+ }
7332
+ if let data = fields[131] as? Data {
7333
+ keyboardBridge.accept(data)
7334
+ }
6399
7335
 
6400
7336
  // Firmware identity is connection-sticky: firmware cannot change
6401
7337
  // without a reboot/disconnect, while individual settings packets can
@@ -6419,6 +7355,8 @@ final class G2: NSObject, SGCManager {
6419
7355
  let active = supportsCfwSceneRendering
6420
7356
  DeviceStore.shared.apply("glasses", "sceneCanvasWidth", active ? 576 : 0)
6421
7357
  DeviceStore.shared.apply("glasses", "sceneCanvasHeight", active ? 288 : 0)
7358
+ DeviceStore.shared.apply("glasses", "sceneVector", supportsCfwVectorScene)
7359
+ DeviceStore.shared.apply("glasses", "sceneSettle", supportsCfwSceneSettle)
6422
7360
  }
6423
7361
 
6424
7362
  private func updateCfwCapabilities(_ data: Data?, definitive: Bool) {
@@ -6429,19 +7367,20 @@ final class G2: NSObject, SGCManager {
6429
7367
  field100: data, definitive: definitive, currentCapability: cfwCapabilityString
6430
7368
  )
6431
7369
  else { return }
6432
- cfwClassified = true
7370
+ let wasDifferent = result != cfwClassification
7371
+ cfwClassification = result
6433
7372
  if result.isCfw {
6434
- let wasDifferent = result.capabilityString != cfwCapabilityString
6435
- cfwCapabilityString = result.capabilityString
6436
- cfwFeatures = result.features
6437
- cfwProtocolVersion = result.protocolVersion
6438
7373
  if wasDifferent {
6439
- Bridge.log("G2: Glassly CFW detected — \(result.capabilityString ?? "")")
7374
+ Bridge.log("G2: CFW detected — \(result.capabilityString ?? "")")
6440
7375
  rearmWakeLeaseIfWanted()
7376
+ startAncsRelay()
7377
+ syncBleLink()
7378
+ keyboardBridge.cfwDetected(revision: result.revision)
6441
7379
  }
6442
7380
  } else {
6443
- cfwFeatures.removeAll(keepingCapacity: true)
6444
- cfwProtocolVersion = 0
7381
+ publishRingBattery(.disconnected)
7382
+ stopAncsRelay(sendDisable: false)
7383
+ keyboardBridge.linkLost()
6445
7384
  }
6446
7385
  }
6447
7386
 
@@ -6460,8 +7399,8 @@ final class G2: NSObject, SGCManager {
6460
7399
  DeviceStore.shared.get("glasses", "rightFirmwareVersion") as? String
6461
7400
  ?? DeviceStore.shared.get("glasses", "leftFirmwareVersion") as? String ?? ""
6462
7401
  let displayVersion: String
6463
- if cfwCapabilityString != nil {
6464
- displayVersion = baseVersion.isEmpty ? "Glassly CFW" : "\(baseVersion) (Glassly CFW)"
7402
+ if let name = cfwClassification?.displayName {
7403
+ displayVersion = baseVersion.isEmpty ? name : "\(baseVersion) (\(name))"
6465
7404
  } else {
6466
7405
  displayVersion = baseVersion
6467
7406
  }
@@ -6514,18 +7453,12 @@ final class G2: NSObject, SGCManager {
6514
7453
  }
6515
7454
  }
6516
7455
 
6517
- private func handleMenuResponse(_ data: Data) {
7456
+ private func handleMenuResponse(_: Data) {
6518
7457
  // meun_main_msg_ctx response from glasses (ack of our menu send)
6519
7458
  // (informational only)
6520
- Bridge.log(
6521
- "G2: menu response: \(data.prefix(32).map { String(format: "%02X", $0) }.joined())"
6522
- )
6523
7459
  }
6524
7460
 
6525
7461
  private func handleDashboardResponse(_ payload: Data) {
6526
- Bridge.log(
6527
- "G2: dashboard response: \(payload.map { String(format: "%02X", $0) }.joined())"
6528
- )
6529
7462
  var reader = ProtobufReader(payload)
6530
7463
  let fields = reader.parseFields()
6531
7464
  let cmd = fields[1] as? Int32 ?? -1
@@ -6554,11 +7487,8 @@ final class G2: NSObject, SGCManager {
6554
7487
  }
6555
7488
  }
6556
7489
 
6557
- private func handleEvenHubCtrlResponse(_ data: Data) {
7490
+ private func handleEvenHubCtrlResponse(_: Data) {
6558
7491
  // EvenHub CTRL channel response (informational only)
6559
- Bridge.log(
6560
- "G2: evenHubCtrl response: \(data.prefix(8).map { String(format: "%02X", $0) }.joined())"
6561
- )
6562
7492
  }
6563
7493
 
6564
7494
  private func handleGestureCtrl(_ data: Data) async {
@@ -6771,6 +7701,7 @@ extension G2: CBCentralManagerDelegate {
6771
7701
  let authorized = (leftPeripheral?.ancsAuthorized ?? false)
6772
7702
  || (rightPeripheral?.ancsAuthorized ?? false)
6773
7703
  DeviceStore.shared.apply("glasses", "ancsAuthorized", authorized)
7704
+ publishNotificationStatus()
6774
7705
  }
6775
7706
 
6776
7707
  nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
@@ -6861,12 +7792,14 @@ extension G2: CBCentralManagerDelegate {
6861
7792
  self.startupPageCreated = false
6862
7793
  self.pageCreated = false
6863
7794
  self.stockImageStreamWarmedUp = false
7795
+ self.cfwTransport = G2CfwTransport.Encoder()
7796
+ self.cfwStreamSequence = UInt8.random(in: 0 ... 255)
7797
+ self.cfwPageInstalled = false
6864
7798
  self.dashboardShowing = 0
6865
7799
  self.dashboardOpening = false
6866
- self.cfwCapabilityString = nil
6867
- self.cfwClassified = false
6868
- self.cfwFeatures.removeAll()
6869
- self.cfwProtocolVersion = 0
7800
+ self.cancelSensorCommands()
7801
+ self.cfwClassification = nil
7802
+ self.publishRingBattery(.disconnected)
6870
7803
  self.cfwSceneActive = false
6871
7804
  // Fresh connection gets a fresh idle-resurrect budget.
6872
7805
  self.cfwLastIdleResurrectAt = nil
@@ -6877,6 +7810,8 @@ extension G2: CBCentralManagerDelegate {
6877
7810
  // after the reconnect.
6878
7811
  self.stopFbLease(sendRelease: false) // link is gone; the leases fail open in 90 s
6879
7812
  self.stopWakeLease(sendRelease: false) // wakeLeaseWanted persists; re-armed on CFW re-detection
7813
+ self.stopAncsRelay(sendDisable: false)
7814
+ self.keyboardBridge.linkLost()
6880
7815
  DeviceStore.shared.apply("glasses", "connected", false)
6881
7816
  DeviceStore.shared.apply("glasses", "fullyBooted", false)
6882
7817
 
@@ -6933,26 +7868,6 @@ extension G2: CBPeripheralDelegate {
6933
7868
 
6934
7869
  for char in characteristics {
6935
7870
  let uuid = char.uuid
6936
- let props = char.properties
6937
-
6938
- // Log all characteristics with their properties for debugging
6939
- var propStr: [String] = []
6940
- if props.contains(.read) {
6941
- propStr.append("read")
6942
- }
6943
- if props.contains(.write) {
6944
- propStr.append("write")
6945
- }
6946
- if props.contains(.writeWithoutResponse) {
6947
- propStr.append("writeNoResp")
6948
- }
6949
- if props.contains(.notify) {
6950
- propStr.append("notify")
6951
- }
6952
- if props.contains(.indicate) {
6953
- propStr.append("indicate")
6954
- }
6955
- Bridge.log("G2: \(side) char \(uuid) props=[\(propStr.joined(separator: ","))]")
6956
7871
 
6957
7872
  if uuid == G2BLE.CHAR_WRITE {
6958
7873
  Bridge.log("G2: Found WRITE char on \(side)")
@@ -7020,6 +7935,7 @@ extension G2: CBPeripheralDelegate {
7020
7935
  error: Error?
7021
7936
  ) {
7022
7937
  guard let data = characteristic.value, error == nil else { return }
7938
+ let arrival = G2InputArrival()
7023
7939
 
7024
7940
  if characteristic.uuid == G2Flasher.dataNotifyUUID {
7025
7941
  firmwareFlasher.receive(
@@ -7044,21 +7960,28 @@ extension G2: CBPeripheralDelegate {
7044
7960
  // intermittent "glasses stopped responding to image sends". See [ImgAckBox].
7045
7961
  if characteristic.uuid == G2BLE.CHAR_NOTIFY {
7046
7962
  correlateImageAck(data)
7963
+ correlateCfwAck(data)
7047
7964
  }
7048
7965
 
7049
- Task { @MainActor [weak self] in
7050
- guard let self = self else { return }
7051
-
7052
- if characteristic.uuid == G2BLE.AUDIO_NOTIFY {
7053
- // Audio data - forward to mic system
7054
- await self.handleAudioData(data)
7055
- } else if characteristic.uuid == G2BLE.CHAR_NOTIFY {
7056
- // Protocol data
7057
- await self.handleNotifyData(data, from: peripheral)
7058
- } else if characteristic.uuid == G2BLE.FILE_NOTIFY {
7059
- // File-transfer responses (CACHE_HIT/MISS, transfer status) —
7060
- // observability for the notify-whitelist push.
7061
- 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).
7062
7985
  Bridge.log(
7063
7986
  "G2: FILE notify (\(side)): \(data.map { String(format: "%02X", $0) }.joined())"
7064
7987
  )
@@ -7097,20 +8020,25 @@ extension G2: CBPeripheralDelegate {
7097
8020
  var reader = ProtobufReader(payload)
7098
8021
  let fields = reader.parseFields()
7099
8022
  guard let resData = fields[6] as? Data else { return } // field 6 = ImgResCmd
7100
- var resReader = ProtobufReader(resData)
7101
- let resFields = resReader.parseFields()
7102
- guard let errorCode = resFields[8] as? Int32,
7103
- let ackSession = resFields[3] as? Int32
7104
- else { return }
7105
- let ackFragment = (resFields[6] as? Int32) ?? 0
7106
- Bridge.log(
7107
- "G2: img_res: session=\(ackSession) fragment=\(ackFragment) errorCode=\(errorCode) success=\(errorCode == 4)"
7108
- )
8023
+ guard let ack = ImgAckBox.decodeResult(resData) else { return }
7109
8024
  completeImageAck(
7110
- session: Int(ackSession), fragmentIndex: ackFragment, success: errorCode == 4
8025
+ session: ack.session, fragmentIndex: ack.fragment, success: ack.success
7111
8026
  )
7112
8027
  }
7113
8028
 
8029
+ /// Read-only, SINGLE-PACKET parse of a raw notify frame that resolves an in-flight SID-0xf0
8030
+ /// ACK/NACK directly (off the main actor). Replies always fit one packet. Idempotent via
8031
+ /// [G2CfwAckBox]; the main-thread path ignores the service.
8032
+ nonisolated func correlateCfwAck(_ rawData: Data) {
8033
+ guard let body = G2CfwTransport.replyBody(fromNotifyFrame: rawData) else { return }
8034
+ if let reply = G2CfwTransport.parseCacheReply(body) {
8035
+ cfwAckBox.record(reply)
8036
+ return
8037
+ }
8038
+ guard let ack = G2CfwTransport.parseAck(body) else { return }
8039
+ cfwAckBox.resolve(ack)
8040
+ }
8041
+
7114
8042
  nonisolated func peripheral(
7115
8043
  _: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?
7116
8044
  ) {
@@ -7128,8 +8056,10 @@ extension G2: CBPeripheralDelegate {
7128
8056
  Task { @MainActor [weak self] in
7129
8057
  guard let self = self else { return }
7130
8058
  if peripheral === self.rightPeripheral {
8059
+ self.displayDiagnostics.count("readyCallbacksR")
7131
8060
  self.startDrain(right: true)
7132
8061
  } else if peripheral === self.leftPeripheral {
8062
+ self.displayDiagnostics.count("readyCallbacksL")
7133
8063
  self.startDrain(right: false)
7134
8064
  }
7135
8065
  }