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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +47 -6
  2. package/android/build.gradle +7 -7
  3. package/android/lc3Lib/build.gradle +2 -2
  4. package/android/src/main/assets/G2Firmware/README.md +27 -2
  5. package/android/src/main/assets/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  6. package/android/src/main/assets/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  7. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +38 -0
  8. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +50 -14
  9. package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +18 -0
  10. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +139 -10
  11. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +28 -1
  12. package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +199 -0
  13. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +10 -1
  14. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +4 -4
  15. package/android/src/main/java/com/glassly/bluetoothsdk/GlassesNotifications.kt +155 -0
  16. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +1065 -255
  17. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2BleLink.kt +119 -0
  18. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +29 -28
  19. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +138 -33
  20. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +673 -0
  21. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +235 -162
  22. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransport.kt +402 -0
  23. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnostics.kt +84 -0
  24. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
  25. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +9 -9
  26. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Keyboard.kt +689 -0
  27. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardBridge.kt +452 -0
  28. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardHid.kt +208 -0
  29. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFile.kt +181 -0
  30. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2RingBattery.kt +20 -0
  31. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Sensors.kt +67 -0
  32. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2TouchInput.kt +39 -0
  33. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +5 -5
  34. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +14 -14
  35. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/{GlasslyBle.java → NexProto.java} +6462 -7618
  36. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIds.kt +58 -0
  37. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +77 -2
  38. package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +1 -1
  39. package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +55 -0
  40. package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +1 -1
  41. package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -1
  42. package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +1 -1
  43. package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +24 -149
  44. package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
  45. package/android/src/test/java/com/glassly/bluetoothsdk/G2BleLinkTest.kt +70 -0
  46. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +45 -56
  47. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +78 -5
  48. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +489 -0
  49. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +100 -43
  50. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransportTest.kt +252 -0
  51. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnosticsTest.kt +64 -0
  52. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardTest.kt +271 -0
  53. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFileTest.kt +204 -0
  54. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2RingBatteryTest.kt +56 -0
  55. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2SensorsTest.kt +56 -0
  56. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2TouchInputTest.kt +60 -0
  57. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NexProtoDescriptorTest.kt +32 -0
  58. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIdsTest.kt +58 -0
  59. package/build/BluetoothSdk.types.d.ts +209 -20
  60. package/build/BluetoothSdk.types.d.ts.map +1 -1
  61. package/build/BluetoothSdk.types.js.map +1 -1
  62. package/build/_internal.d.ts +1 -1
  63. package/build/_internal.d.ts.map +1 -1
  64. package/build/_internal.js.map +1 -1
  65. package/build/_private/BluetoothSdkModule.d.ts +30 -1
  66. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  67. package/build/_private/BluetoothSdkModule.js.map +1 -1
  68. package/build/generated/changelogCatalog.d.ts +28 -1
  69. package/build/generated/changelogCatalog.d.ts.map +1 -1
  70. package/build/generated/changelogCatalog.js +37 -1
  71. package/build/generated/changelogCatalog.js.map +1 -1
  72. package/build/generated/releaseMetadata.js +4 -4
  73. package/build/generated/releaseMetadata.js.map +1 -1
  74. package/build/index.d.ts +1 -1
  75. package/build/index.d.ts.map +1 -1
  76. package/build/index.js +14 -0
  77. package/build/index.js.map +1 -1
  78. package/ios/BluetoothSdkModule.swift +102 -13
  79. package/ios/GlasslyBluetoothSDK.podspec +1 -1
  80. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +8 -3
  81. package/ios/Source/BluetoothSDK.swift +53 -0
  82. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  83. package/ios/Source/Bridge.swift +13 -0
  84. package/ios/Source/DeviceManager.swift +151 -33
  85. package/ios/Source/DeviceStore.swift +28 -14
  86. package/ios/Source/DisplayPacer.swift +180 -0
  87. package/ios/Source/GeneratedChangelogCatalog.swift +10 -1
  88. package/ios/Source/GeneratedReleaseMetadata.swift +4 -4
  89. package/ios/Source/GlassesNotifications.swift +181 -0
  90. package/ios/Source/ObservableStore.swift +14 -2
  91. package/ios/Source/Resources/G2Firmware/README.md +27 -2
  92. package/ios/Source/Resources/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  93. package/ios/Source/Resources/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  94. package/ios/Source/controllers/R1.swift +0 -1
  95. package/ios/Source/internal/BluetoothAvailability.swift +31 -0
  96. package/ios/Source/sgcs/G2.swift +1420 -495
  97. package/ios/Source/sgcs/G2AncsRelay.swift +268 -0
  98. package/ios/Source/sgcs/G2BleLink.swift +102 -0
  99. package/ios/Source/sgcs/G2CfwClassification.swift +45 -27
  100. package/ios/Source/sgcs/G2CfwRenderer.swift +166 -33
  101. package/ios/Source/sgcs/G2CfwScene.swift +779 -0
  102. package/ios/Source/sgcs/G2CfwTextureCache.swift +244 -117
  103. package/ios/Source/sgcs/G2CfwTransport.swift +485 -0
  104. package/ios/Source/sgcs/G2DisplayDiagnostics.swift +154 -0
  105. package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
  106. package/ios/Source/sgcs/G2Flasher.swift +9 -9
  107. package/ios/Source/sgcs/G2Keyboard.swift +780 -0
  108. package/ios/Source/sgcs/G2KeyboardBridge.swift +438 -0
  109. package/ios/Source/sgcs/G2KeyboardHid.swift +290 -0
  110. package/ios/Source/sgcs/G2NotificationTuning.swift +24 -0
  111. package/ios/Source/sgcs/G2RingBattery.swift +22 -0
  112. package/ios/Source/sgcs/G2Sensors.swift +75 -0
  113. package/ios/Source/sgcs/G2TouchInput.swift +51 -0
  114. package/ios/Source/sgcs/G2WritePacing.swift +18 -0
  115. package/ios/Source/sgcs/MentraNex.swift +44 -44
  116. package/ios/Source/sgcs/SGCManager.swift +120 -13
  117. package/ios/Source/sgcs/{glassly_ble.pb.swift → nex_ble.pb.swift} +405 -465
  118. package/ios/Source/speakerid/SpeakerIdEngine.swift +2 -2
  119. package/ios/Source/status/DeviceStatus.swift +27 -0
  120. package/ios/Source/stt/SherpaOnnxTranscriber.swift +15 -14
  121. package/ios/Source/utils/Enums.swift +0 -1
  122. package/ios/Source/utils/G1Text.swift +0 -1
  123. package/ios/Source/utils/G2NotifyFile.swift +20 -10
  124. package/ios/Source/utils/JSCExperiment.swift +6 -6
  125. package/ios/Source/utils/Models.swift +0 -1
  126. package/ios/Tests/BluetoothSDKTests/BluetoothAvailabilityTests.swift +29 -0
  127. package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
  128. package/ios/Tests/BluetoothSDKTests/G2AncsRelayTests.swift +186 -0
  129. package/ios/Tests/BluetoothSDKTests/G2BleLinkTests.swift +52 -0
  130. package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +46 -51
  131. package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +164 -5
  132. package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +497 -0
  133. package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +108 -44
  134. package/ios/Tests/BluetoothSDKTests/G2CfwTransportTests.swift +259 -0
  135. package/ios/Tests/BluetoothSDKTests/G2DisplayDiagnosticsTests.swift +89 -0
  136. package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +3 -2
  137. package/ios/Tests/BluetoothSDKTests/G2ImageAckTests.swift +104 -0
  138. package/ios/Tests/BluetoothSDKTests/G2KeyboardTests.swift +274 -0
  139. package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +21 -5
  140. package/ios/Tests/BluetoothSDKTests/G2RingBatteryTests.swift +56 -0
  141. package/ios/Tests/BluetoothSDKTests/G2SensorsTests.swift +56 -0
  142. package/ios/Tests/BluetoothSDKTests/G2TouchInputTests.swift +71 -0
  143. package/ios/Tests/BluetoothSDKTests/G2WritePacingTests.swift +35 -0
  144. package/ios/Tests/BluetoothSDKTests/GlassesNotificationTests.swift +95 -0
  145. package/package.json +1 -1
  146. package/plugin/build/withAndroid.js +2 -2
  147. package/plugin/build/withIos.js +1 -1
  148. package/scripts/gen-nex-proto.sh +12 -0
  149. package/scripts/inject-ios-sdk-version.mjs +1 -1
  150. package/src/BluetoothSdk.types.ts +214 -19
  151. package/src/_internal.ts +1 -1
  152. package/src/_private/BluetoothSdkModule.ts +40 -0
  153. package/src/generated/changelogCatalog.ts +37 -1
  154. package/src/generated/releaseMetadata.ts +4 -4
  155. package/src/index.ts +24 -0
@@ -19,10 +19,18 @@ import android.graphics.Paint
19
19
  import android.graphics.Rect
20
20
  import android.os.Handler
21
21
  import android.os.Looper
22
+ import android.os.SystemClock
22
23
  import android.util.Base64
23
24
  import com.glassly.bluetoothsdk.Bridge
24
25
  import com.glassly.bluetoothsdk.DeviceManager
25
26
  import com.glassly.bluetoothsdk.DeviceStore
27
+ import com.glassly.bluetoothsdk.GlassesNotificationAccess
28
+ import com.glassly.bluetoothsdk.GlassesNotificationConfig
29
+ import com.glassly.bluetoothsdk.GlassesNotificationFeeds
30
+ import com.glassly.bluetoothsdk.GlassesNotificationPhases
31
+ import com.glassly.bluetoothsdk.GlassesNotificationStatus
32
+ import com.glassly.bluetoothsdk.NotificationUploadOutcomes
33
+ import com.glassly.bluetoothsdk.PhoneNotificationUpload
26
34
  import com.glassly.bluetoothsdk.utils.DeviceTypes
27
35
  import java.io.ByteArrayOutputStream
28
36
  import java.util.TimeZone
@@ -37,7 +45,11 @@ import kotlinx.coroutines.channels.Channel
37
45
  import kotlinx.coroutines.delay
38
46
  import kotlinx.coroutines.isActive
39
47
  import kotlinx.coroutines.launch
48
+ import kotlinx.coroutines.sync.Mutex
49
+ import kotlinx.coroutines.sync.withLock
40
50
  import kotlinx.coroutines.withTimeoutOrNull
51
+ import kotlinx.coroutines.withContext
52
+ import org.json.JSONObject
41
53
 
42
54
  // ---------- G2 Protocol Constants ----------
43
55
 
@@ -46,6 +58,10 @@ private object G2BLE {
46
58
  val CHAR_WRITE: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E5401")
47
59
  val CHAR_NOTIFY: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E5402")
48
60
  val AUDIO_NOTIFY: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E6402")
61
+ // File-transfer characteristics: notification content / allowlist pushes ride
62
+ // the 0xC4/0xC5 services here, not the main 5401 protocol char.
63
+ val FILE_WRITE: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E7401")
64
+ val FILE_NOTIFY: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E7402")
49
65
  val OTA_DATA_WRITE: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E0001")
50
66
  val OTA_DATA_NOTIFY: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E0002")
51
67
  val SERVICE_UUID: UUID = UUID.fromString("00002760-08C2-11E1-9073-0E8AC72E0000")
@@ -55,12 +71,17 @@ private object G2BLE {
55
71
  const val SOURCE_PHONE: Byte = 1
56
72
  const val DEST_GLASSES: Byte = 2
57
73
  const val MAX_PACKET_PAYLOAD: Int = 236
74
+
75
+ // File-transfer services, same AA-21 envelope as every other service.
76
+ const val FILE_SERVICE_CMD: Byte = 0xC4.toByte() // FILE_CHECK header, START (0x01), END (0x02)
77
+ const val FILE_SERVICE_DATA: Byte = 0xC5.toByte() // raw file bytes
58
78
  }
59
79
 
60
80
  // Service IDs from service_id_def.proto
61
81
  private enum class ServiceID(val value: Byte) {
62
82
  DASHBOARD(0x01), // UI_BACKGROUND_DASHBOARD_APP_ID
63
83
  MENU(0x03), // UI_FOREGROUND_MEUN_ID (typo is intentional — matches Even's proto)
84
+ NOTIFICATION(0x04), // UI_FOREGROUND_NOTIFICATION_ID
64
85
  EVEN_AI(0x07), // UI_FOREGROUND_EVEN_AI_ID
65
86
  NAVIGATION(0x08), // UI_BACKGROUND_NAVIGATION_ID (compass/heading lives here)
66
87
  G2_SETTING(0x09), // UI_SETTING_APP_ID
@@ -68,13 +89,28 @@ private enum class ServiceID(val value: Byte) {
68
89
  ONBOARDING(0x10), // UI_ONBOARDING_APP_ID
69
90
  DEVICE_SETTINGS(0x80.toByte()), // UX_DEVICE_SETTINGS_APP_ID
70
91
  EVEN_HUB_CTRL(0x81.toByte()), // EvenHub CTRL channel (init/registration)
71
- EVEN_HUB(0xE0.toByte()); // UI_BACKGROUND_EVENHUB_APP_ID
92
+ EVEN_HUB(0xE0.toByte()), // UI_BACKGROUND_EVENHUB_APP_ID
93
+ CFW_STREAM(0xF0.toByte()); // private Glassly CFW message streams (GLASSLYCFW/31)
72
94
 
73
95
  companion object {
74
96
  fun fromByte(b: Byte): ServiceID? = entries.find { it.value == b }
75
97
  }
76
98
  }
77
99
 
100
+ // eNotificationCommandId from notification.proto (service 0x04)
101
+ private object NotificationCmd {
102
+ const val CTRL = 1 // NotificationControl in field 3
103
+ const val WHITELIST_CTRL = 3 // NotificationWhitelistCtrl in field 6
104
+ }
105
+
106
+ private const val FILE_START: Byte = 0x01
107
+ private const val FILE_END: Byte = 0x02
108
+ private const val MAX_PENDING_UPLOADS = 8
109
+
110
+ // File-push pacing, matching what the firmware tolerates between phases.
111
+ private const val FILE_HEADER_SETTLE_MS = 150L
112
+ private const val FILE_PHASE_GAP_MS = 50L
113
+
78
114
  // EvenHub command IDs from EvenHub.proto
79
115
  private enum class EvenHubCmd(val value: Int) {
80
116
  CREATE_STARTUP_PAGE(0),
@@ -271,7 +307,12 @@ private enum class OsEventType(val value: Int) {
271
307
  // them while our fbguard lease is active. Names stay tap_start/tap_end.
272
308
  TAP_START(9), // press recognized (hold start)
273
309
  TAP_END(10), // released
274
- TAP_THEN_HOLD(11); // CFW extension: 2.2.9 tap-then-hold (stock Menu gesture)
310
+ TAP_THEN_HOLD(11), // CFW extension: 2.2.9 tap-then-hold (stock Menu gesture)
311
+ // 12 is head-up (settings path), 13 is burned (experimental temple edge). 14 is
312
+ // the Glassly CFW's ring touch-down (R1 subtype 0x0d, GLASSLYCFW/37 on 2.3.0.24);
313
+ // only the ring produces it and the id itself marks ring provenance. Unknown
314
+ // ring wire types arrive as 127 and are dropped.
315
+ RING_TOUCH_DOWN(14);
275
316
 
276
317
  companion object {
277
318
  fun fromInt(v: Int): OsEventType? = entries.find { it.value == v }
@@ -1441,23 +1482,31 @@ class G2 : SGCManager() {
1441
1482
  hasMic = true
1442
1483
  }
1443
1484
 
1444
- /** Glassly CFW identity is sticky for one BLE connection and resets only on disconnect. */
1445
- private var cfwCapabilityString: String? = null
1485
+ /** null until settings classify the connection as stock, upstream, or Glassly. */
1486
+ private data class SensorCommand(val data: ByteArray, val result: CompletableDeferred<Boolean>)
1487
+ private val sensorCommands = ArrayDeque<SensorCommand>()
1488
+ private var sensorNavigationRecovering = false
1446
1489
 
1447
- /**
1448
- * True once the connection is classified either way (CFW detected, or a
1449
- * definitive settings response without field 100). Until then legacy text
1450
- * would race the classification and split draw/clear across render paths.
1451
- */
1452
- @Volatile private var cfwClassified = false
1453
- private val cfwFeatures = mutableSetOf<String>()
1490
+ suspend fun glassesSensorControl(sensor: String, operation: String, options: Map<String, Double>): Boolean =
1491
+ withContext(Dispatchers.Main) {
1492
+ if (!supportsCfwTransport || navigationModeActive || sensorCommands.size >= 8)
1493
+ return@withContext false
1494
+ val data = G2Sensors.command(sensor, operation, options) ?: return@withContext false
1495
+ val command = SensorCommand(data, CompletableDeferred())
1496
+ sensorCommands.addLast(command)
1497
+ signalDisplayDirty()
1498
+ try { withTimeoutOrNull(10_000) { command.result.await() } ?: false }
1499
+ finally { sensorCommands.remove(command) }
1500
+ }
1454
1501
 
1455
- /**
1456
- * Protocol revision from the capability string's "EVENCFW/NN" token.
1457
- * Gates wire features that have no dedicated capability token (mode-9
1458
- * rect copies shipped with revision 15).
1459
- */
1460
- private var cfwProtocolVersion = 0
1502
+ private fun cancelSensorCommands() {
1503
+ sensorNavigationRecovering = false
1504
+ while (sensorCommands.isNotEmpty()) sensorCommands.removeFirst().result.complete(false)
1505
+ }
1506
+
1507
+ @Volatile private var cfwClassification: G2CfwClassification? = null
1508
+ private val cfwClassified: Boolean get() = cfwClassification != null
1509
+ private val cfwCapabilityString: String? get() = cfwClassification?.capabilityString
1461
1510
  private var cfwSceneActive = false
1462
1511
 
1463
1512
  /**
@@ -1467,7 +1516,19 @@ class G2 : SGCManager() {
1467
1516
  * down we back off instead of trading rebuilds with it once per tick.
1468
1517
  */
1469
1518
  private var cfwLastIdleResurrectAt: Long? = null
1470
- private val cfwSceneContainerId = 10
1519
+
1520
+ /**
1521
+ * Private SID-0xf0 message transport (GLASSLYCFW/31): one deflate context
1522
+ * per connection, one stream per message, ACK-gated exactly like the old
1523
+ * image carrier was. Payloads still go out on the LEFT arm; the right lens
1524
+ * processes them over the frame bridge and its ACK returns through the
1525
+ * left. No EvenHub image container is involved any more.
1526
+ */
1527
+ private var cfwTransport = G2CfwTransport.Encoder()
1528
+ private var cfwStreamSequence = (0..255).random()
1529
+ private val cfwAckBox = G2CfwAckBox()
1530
+ /** The container set is in CFW shape: no stock text or image containers. */
1531
+ private var cfwPageInstalled = false
1471
1532
 
1472
1533
  /**
1473
1534
  * Queued CFW payloads are tagged so coalescing keeps the chain sound:
@@ -1476,9 +1537,16 @@ class G2 : SGCManager() {
1476
1537
  * fulls/deltas. SEED (mode-6 black) and MOVE_BUNDLE (blank of an old
1477
1538
  * window position) always survive.
1478
1539
  */
1479
- private enum class CfwPayloadKind { SEED, FULL, DELTA, MOVE_BUNDLE, UPLOAD }
1540
+ private enum class CfwPayloadKind { SEED, FULL, DELTA, MOVE_BUNDLE, UPLOAD, SCENE }
1480
1541
 
1481
- private class CfwPayload(val kind: CfwPayloadKind, val data: ByteArray)
1542
+ private class CfwPayload(
1543
+ val kind: CfwPayloadKind,
1544
+ val data: ByteArray,
1545
+ /** Host frame this payload puts on the glasses — reported once acked. */
1546
+ var presents: SceneFrame.PresentTag? = null,
1547
+ /** Lenses this payload is for (scene SHOWs split per lens once a frame uses element depth). */
1548
+ val lenses: Int = G2CfwTransport.LENS_BOTH,
1549
+ )
1482
1550
 
1483
1551
  private val cfwPendingPayloads = ArrayDeque<CfwPayload>()
1484
1552
  private var cfwShadowSeeded = false
@@ -1537,40 +1605,45 @@ class G2 : SGCManager() {
1537
1605
  // CFW capability string is seen again.
1538
1606
  private var wakeLeaseWanted = false
1539
1607
 
1540
- private val supportsCfwSceneRendering: Boolean
1541
- get() =
1542
- cfwCapabilityString != null &&
1543
- listOf("img640", "imgz", "rle", "directfb").all { it in cfwFeatures } &&
1544
- // Escape hatch (default ON): OFF falls back to stock containers. Reconnect to switch cleanly.
1545
- (DeviceStore.get("bluetooth", "cfw_scene_renderer") as? Boolean ?: true)
1546
-
1547
- private val hasCfwCarrier: Boolean
1548
- get() =
1549
- imageContainers.size == 1 &&
1550
- imageContainers[0].id == cfwSceneContainerId &&
1551
- imageContainers[0].x == 0 &&
1552
- imageContainers[0].y == 0 &&
1553
- imageContainers[0].width == 576 &&
1554
- imageContainers[0].height == 288
1555
-
1556
1608
  /**
1557
- * Mode-9 rect copies have no capability token; they shipped with protocol
1558
- * revision 15 alongside the texture/font modes.
1609
+ * Glassly CFW at or above the supported revision. Anything else (stock,
1610
+ * upstream, older Glassly builds) is treated as stock.
1559
1611
  */
1560
- private val supportsCfwRectCopy: Boolean
1561
- get() = cfwProtocolVersion >= 15
1562
-
1563
- /** Mode-15 builtin-font string draws (`font15` capability token). */
1564
- private val supportsCfwBuiltinText: Boolean
1565
- get() = "font15" in cfwFeatures
1612
+ private val supportsCfwTransport: Boolean
1613
+ get() {
1614
+ val classification = cfwClassification ?: return false
1615
+ return classification.isGlassly && (classification.revision ?: 0) >= G2CfwClassification.MINIMUM_REVISION
1616
+ }
1566
1617
 
1567
- /** Mode-12 uploads + mode-14 cached-glyph runs (mono text as draws). */
1568
- private val supportsCfwTextureGlyphs: Boolean
1569
- get() = "texcache12" in cfwFeatures && "texstr14" in cfwFeatures
1618
+ override val supportsCfwSceneRendering: Boolean
1619
+ get() =
1620
+ supportsCfwTransport &&
1621
+ // Escape hatch (default ON): OFF falls back to stock containers. Reconnect to switch cleanly.
1622
+ (DeviceStore.get("bluetooth", "cfw_scene_renderer") as? Boolean ?: true)
1570
1623
 
1571
- /** Mode-12 uploads + mode-13 cached-image draws (repeated icons as draws). */
1572
- private val supportsCfwTextureImages: Boolean
1573
- get() = "texcache12" in cfwFeatures && "teximg13" in cfwFeatures
1624
+ private val hasCfwPage: Boolean
1625
+ get() = cfwPageInstalled && textContainers.isEmpty() && imageContainers.isEmpty()
1626
+
1627
+ /** Glassly adds retained vector scenes and animation to upstream's raster protocol. */
1628
+ private val supportsCfwVectorScene: Boolean
1629
+ get() = supportsCfwSceneRendering && cfwClassification?.isGlassly == true
1630
+
1631
+ /** Tagged commits report settings field 129 once the scene has settled. */
1632
+ private val supportsCfwSceneSettle: Boolean
1633
+ get() = supportsCfwVectorScene
1634
+
1635
+ /** The on-glass slot table must be re-SET on the next vector frame. */
1636
+ private var cfwSceneInvalid = true
1637
+ /** Texture-cache generation whose first vector frame was preceded by the warm-up tween. */
1638
+ private var cfwSceneWarmedGeneration = -1
1639
+ /** Object-cache session epoch shared by both lenses (GLASSLYCFW/38); 0 until the generation's RESET was acknowledged. */
1640
+ private var cfwCacheEpoch = 0
1641
+ /** Request id stamped into cache messages whose encoder left it 0. */
1642
+ private var cfwCacheRequest = 0
1643
+ /** Frames re-encoded after a MISSING reply, bounded per frame. */
1644
+ private var cfwMissingRetries = 0
1645
+ /** Bumped by every clear so an encoder suspended when the clear arrived is discarded. */
1646
+ private var cfwClearSerial = 0
1574
1647
 
1575
1648
  // BLE
1576
1649
  private val context: Context
@@ -1588,6 +1661,8 @@ class G2 : SGCManager() {
1588
1661
  private var rightOtaDataNotifyChar: BluetoothGattCharacteristic? = null
1589
1662
  private var leftAudioChar: BluetoothGattCharacteristic? = null
1590
1663
  private var rightAudioChar: BluetoothGattCharacteristic? = null
1664
+ private var leftFileWriteChar: BluetoothGattCharacteristic? = null
1665
+ private var rightFileWriteChar: BluetoothGattCharacteristic? = null
1591
1666
  private var leftInitialized: Boolean = false
1592
1667
  private var rightInitialized: Boolean = false
1593
1668
  private var isDisconnecting = false
@@ -1734,12 +1809,26 @@ class G2 : SGCManager() {
1734
1809
  private var recoveryInFlight = false
1735
1810
  private var lastRecoveryRebuildMs: Long = 0
1736
1811
  private val RECOVERY_DEBOUNCE_MS: Long = 1500
1812
+ // A SHUTDOWN_PAGE we send is answered ~300 ms later by the firmware's
1813
+ // teardown trio (shutdown notify, systemExit, 08011A00). Creating the next
1814
+ // page before that lands makes the trio read as the NEW page dying:
1815
+ // pageCreated flips false, the toggle recovers, and the firmware sees
1816
+ // shutdown→create→create inside half a second — the rebuild storm the G2
1817
+ // answers by dropping the link (GLASSLYCFW/29 died on every dashboard
1818
+ // open this way). Resurrection and recovery wait out this window instead.
1819
+ private var lastPageShutdownSentMs: Long = 0
1820
+ private val PAGE_SHUTDOWN_SETTLE_MS: Long = 400
1821
+ private var pageShutdownSettleWakeArmed = false
1822
+ // Stock notifications panel hold: page rebuilds, fbguard lease and CFW frames stay
1823
+ // down while the panel opens so EvenHub sends cannot snatch the screen back.
1824
+ @Volatile private var notificationsPanelHold = false
1737
1825
 
1738
1826
  // Dashboard menu state
1739
1827
  private var menuAppIdToPackageName: MutableMap<Int, String> = mutableMapOf()
1740
1828
  private var dashboardMenuItems: MutableList<MenuProto.MenuItem> = mutableListOf()
1741
1829
  private var activeMenuAppId: Int? = null
1742
- private var lastClickTimestamp: Long? = null
1830
+ private var touchDebounce = G2TouchDebounce()
1831
+ private val displayDiagnostics = G2DisplayDiagnostics(SystemClock.elapsedRealtime())
1743
1832
  /** Last IMU report arrival — the data stream doubles as IMU-control delivery confirmation. */
1744
1833
  private var lastImuReportTimestamp: Long? = null
1745
1834
  private var lastEvenHubResponseTimestamp: Long? = null
@@ -1935,7 +2024,10 @@ class G2 : SGCManager() {
1935
2024
  rightGatt?.let { gatt ->
1936
2025
  char.value = packet
1937
2026
  char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
1938
- bgcapNoteWriteResult(gatt.writeCharacteristic(char)) // BGCAP
2027
+ val accepted = gatt.writeCharacteristic(char)
2028
+ bgcapNoteWriteResult(accepted)
2029
+ displayDiagnostics.count(if (accepted) "txPackets" else "writeRejected")
2030
+ if (accepted) displayDiagnostics.count("txBytes", packet.size)
1939
2031
  }
1940
2032
  }
1941
2033
  }
@@ -1944,7 +2036,10 @@ class G2 : SGCManager() {
1944
2036
  leftGatt?.let { gatt ->
1945
2037
  char.value = packet
1946
2038
  char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
1947
- bgcapNoteWriteResult(gatt.writeCharacteristic(char)) // BGCAP
2039
+ val accepted = gatt.writeCharacteristic(char)
2040
+ bgcapNoteWriteResult(accepted)
2041
+ displayDiagnostics.count(if (accepted) "txPackets" else "writeRejected")
2042
+ if (accepted) displayDiagnostics.count("txBytes", packet.size)
1948
2043
  }
1949
2044
  }
1950
2045
  }
@@ -1953,7 +2048,8 @@ class G2 : SGCManager() {
1953
2048
  private fun sendToGlasses(
1954
2049
  packets: List<ByteArray>,
1955
2050
  left: Boolean = false,
1956
- right: Boolean = true
2051
+ right: Boolean = true,
2052
+ isPending: () -> Boolean = { true }
1957
2053
  ) {
1958
2054
  // OTA owns both links exclusively. Normal heartbeats/display/settings traffic must not
1959
2055
  // interleave with its AA21 frame stream.
@@ -1962,6 +2058,10 @@ class G2 : SGCManager() {
1962
2058
  if (packets.isEmpty()) return
1963
2059
  // Single-packet sends (the common case for text/settings) go straight through.
1964
2060
  if (packets.size == 1) {
2061
+ if (!isPending()) {
2062
+ displayDiagnostics.count("txExpiredPackets")
2063
+ return
2064
+ }
1965
2065
  writeOnePacket(packets[0], left, right)
1966
2066
  return
1967
2067
  }
@@ -1969,8 +2069,14 @@ class G2 : SGCManager() {
1969
2069
  // write thread with a blocking gap between packets, so the Android BLE stack can drain each
1970
2070
  // write before the next is queued WITHOUT occupying the main looper (which also carries the
1971
2071
  // image ACK and would otherwise starve it under load — see [bleWriteExecutor]).
2072
+ val queuedAt = SystemClock.elapsedRealtime()
1972
2073
  bleWriteExecutor.execute {
1973
2074
  for (i in packets.indices) {
2075
+ if (!isPending()) {
2076
+ displayDiagnostics.count("txExpiredPackets", packets.size - i)
2077
+ return@execute
2078
+ }
2079
+ if (i == 0) displayDiagnostics.timing("writeQueueMs", SystemClock.elapsedRealtime() - queuedAt)
1974
2080
  writeOnePacket(packets[i], left, right)
1975
2081
  if (i < packets.size - 1) {
1976
2082
  try {
@@ -2197,6 +2303,7 @@ class G2 : SGCManager() {
2197
2303
 
2198
2304
  sendMenuApps()
2199
2305
  sendStoredCalendarEvents()
2306
+ reapplyStoredNotificationConfig()
2200
2307
 
2201
2308
  // Re-apply the IMU preference: the store only pushes imu_enabled to the glasses when
2202
2309
  // the value changes, so after a reconnect an already-on IMU would otherwise stay off
@@ -2277,6 +2384,15 @@ class G2 : SGCManager() {
2277
2384
  displayScope.launch {
2278
2385
  while (isActive) {
2279
2386
  delay(EVEN_HUB_QUEUE_TICK_MS)
2387
+ val now = SystemClock.elapsedRealtime()
2388
+ val wallTime = System.currentTimeMillis()
2389
+ displayDiagnostics.session(now, wallTime, mapOf(
2390
+ "platform" to "android", "cfw" to (cfwCapabilityString ?: "stock"),
2391
+ ))?.let { Bridge.log("G2PERF " + JSONObject(it).toString()) }
2392
+ displayDiagnostics.sample(now, wallTime, mapOf(
2393
+ "app" to (cfwLastFrame?.appId ?: ""), "seq" to (cfwLastFrame?.seq ?: 0),
2394
+ "payloadQueue" to cfwPendingPayloads.size, "page" to pageCreated,
2395
+ ), busy = cfwPendingPayloads.isNotEmpty())?.let { Bridge.log("G2PERF " + JSONObject(it).toString()) }
2280
2396
  signal.trySend(Unit)
2281
2397
  }
2282
2398
  }
@@ -2353,6 +2469,263 @@ class G2 : SGCManager() {
2353
2469
  sendCalendarEvents(calendarEvents)
2354
2470
  }
2355
2471
 
2472
+ // ---------- Glasses Notifications (phone-uploaded content) ----------
2473
+
2474
+ private val notificationScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
2475
+ private val filePushMutex = Mutex()
2476
+ private val messageIds = NotificationMessageIds()
2477
+ private val uploadQueue = ArrayDeque<PhoneNotificationUpload>()
2478
+ private var uploadWorker: Job? = null
2479
+ private var notificationConfig: GlassesNotificationConfig? = null
2480
+ private var notificationPhase: String = GlassesNotificationPhases.IDLE
2481
+ private var notificationError: String? = null
2482
+
2483
+ /** Firmware popup durations; anything else is snapped to the nearest, ties up. */
2484
+ private val popupDurations = listOf(5, 7, 10)
2485
+
2486
+ override fun setGlassesNotificationConfig(config: GlassesNotificationConfig) {
2487
+ synchronized(uploadQueue) { notificationConfig = config }
2488
+ if (!config.enabled) {
2489
+ cancelQueuedUploads("disabled")
2490
+ }
2491
+ applyNotificationConfig(config)
2492
+ }
2493
+
2494
+ override fun glassesNotificationStatus(): GlassesNotificationStatus =
2495
+ synchronized(uploadQueue) {
2496
+ GlassesNotificationStatus(
2497
+ supported = true,
2498
+ feed = GlassesNotificationFeeds.PHONE_UPLOAD,
2499
+ access = GlassesNotificationAccess.NOT_NEEDED,
2500
+ phase = notificationPhase,
2501
+ config = notificationConfig,
2502
+ error = notificationError
2503
+ )
2504
+ }
2505
+
2506
+ override fun uploadPhoneNotification(upload: PhoneNotificationUpload) {
2507
+ val config =
2508
+ synchronized(uploadQueue) { notificationConfig }
2509
+ ?: throw IllegalStateException("Glasses notification config has not been applied")
2510
+ if (!config.enabled) {
2511
+ throw IllegalStateException("Glasses notifications are disabled")
2512
+ }
2513
+ val dropped =
2514
+ synchronized(uploadQueue) {
2515
+ var evicted: PhoneNotificationUpload? = null
2516
+ if (uploadQueue.size >= MAX_PENDING_UPLOADS) {
2517
+ evicted = uploadQueue.removeFirstOrNull()
2518
+ }
2519
+ uploadQueue.addLast(upload)
2520
+ evicted
2521
+ }
2522
+ dropped?.let {
2523
+ reportUpload(it.notificationId, NotificationUploadOutcomes.DROPPED, "queue full")
2524
+ }
2525
+ startUploadWorker()
2526
+ }
2527
+
2528
+ private fun startUploadWorker() {
2529
+ synchronized(uploadQueue) {
2530
+ if (uploadWorker?.isActive == true) return
2531
+ uploadWorker =
2532
+ notificationScope.launch {
2533
+ while (true) {
2534
+ val next = synchronized(uploadQueue) { uploadQueue.removeFirstOrNull() } ?: break
2535
+ deliverUpload(next)
2536
+ }
2537
+ }
2538
+ }
2539
+ }
2540
+
2541
+ private suspend fun deliverUpload(upload: PhoneNotificationUpload) {
2542
+ val msgId = messageIds.idFor(upload.notificationId)
2543
+ val json =
2544
+ G2NotificationFile.notificationJson(upload, msgId, System.currentTimeMillis())
2545
+ val pushed =
2546
+ try {
2547
+ pushFile(G2NotificationFile.FILENAME, json, G2NotificationFile.FILE_TYPE_NOTIFICATION)
2548
+ } catch (e: Exception) {
2549
+ Bridge.log("G2: notification upload failed for ${upload.packageName}: ${e.message}")
2550
+ reportUpload(upload.notificationId, NotificationUploadOutcomes.FAILED, e.message ?: "push threw")
2551
+ return
2552
+ }
2553
+ if (pushed) {
2554
+ reportUpload(upload.notificationId, NotificationUploadOutcomes.SENT, "pushed")
2555
+ } else {
2556
+ reportUpload(upload.notificationId, NotificationUploadOutcomes.FAILED, "file push unavailable")
2557
+ }
2558
+ }
2559
+
2560
+ private fun cancelQueuedUploads(detail: String) {
2561
+ val pending = synchronized(uploadQueue) {
2562
+ val copy = uploadQueue.toList()
2563
+ uploadQueue.clear()
2564
+ copy
2565
+ }
2566
+ pending.forEach {
2567
+ reportUpload(it.notificationId, NotificationUploadOutcomes.CANCELLED, detail)
2568
+ }
2569
+ }
2570
+
2571
+ private fun reportUpload(notificationId: String, outcome: String, detail: String) {
2572
+ Bridge.sendTypedMessage(
2573
+ "notification_upload_result",
2574
+ mapOf("notificationId" to notificationId, "outcome" to outcome, "detail" to detail)
2575
+ )
2576
+ }
2577
+
2578
+ private fun publishNotificationStatus() {
2579
+ val body = glassesNotificationStatus().toMap().mapNotNull { (key, value) ->
2580
+ value?.let { key to it }
2581
+ }.toMap()
2582
+ Bridge.sendTypedMessage("glasses_notification_status", body)
2583
+ }
2584
+
2585
+ /** 0 stays 0 (firmware default); anything else snaps to 5/7/10, ties up. */
2586
+ private fun snapPopupSeconds(seconds: Int): Int {
2587
+ if (seconds <= 0) return 0
2588
+ var best = popupDurations.first()
2589
+ var bestDistance = Int.MAX_VALUE
2590
+ for (candidate in popupDurations) {
2591
+ val distance = kotlin.math.abs(candidate - seconds)
2592
+ if (distance < bestDistance || (distance == bestDistance && candidate > best)) {
2593
+ best = candidate
2594
+ bestDistance = distance
2595
+ }
2596
+ }
2597
+ return best
2598
+ }
2599
+
2600
+ private fun sendNotificationCommand(payload: ByteArray) {
2601
+ val packets =
2602
+ sendManager.buildPackets(
2603
+ serviceId = ServiceID.NOTIFICATION.value,
2604
+ payload = payload,
2605
+ reserveFlag = true
2606
+ )
2607
+ sendToGlasses(packets)
2608
+ }
2609
+
2610
+ private fun applyNotificationConfig(config: GlassesNotificationConfig) {
2611
+ if (leftGatt == null && rightGatt == null) {
2612
+ synchronized(uploadQueue) {
2613
+ notificationPhase = GlassesNotificationPhases.STALE
2614
+ notificationError = null
2615
+ }
2616
+ publishNotificationStatus()
2617
+ return
2618
+ }
2619
+ try {
2620
+ val ctrl = ProtobufWriter()
2621
+ ctrl.writeInt32Field(1, if (config.enabled) 1 else 0) // notifEnable
2622
+ ctrl.writeInt32Field(2, if (config.popups) 1 else 0) // autoDispEnable
2623
+ ctrl.writeInt32Field(3, snapPopupSeconds(config.popupSeconds)) // dispTime
2624
+ ctrl.writeInt32Field(5, if (config.holdDuringApps) 1 else 0) // avoidDisturbEnable
2625
+
2626
+ val ctrlPkg = ProtobufWriter()
2627
+ ctrlPkg.writeInt32Field(1, NotificationCmd.CTRL)
2628
+ ctrlPkg.writeInt32Field(2, sendManager.nextMagicRandom())
2629
+ ctrlPkg.writeMessageField(3, ctrl.toByteArray())
2630
+ sendNotificationCommand(ctrlPkg.toByteArray())
2631
+
2632
+ // The phone filters before upload, so the on-glass allowlist stays off
2633
+ // and no allowlist file is pushed.
2634
+ val whitelist = ProtobufWriter()
2635
+ whitelist.writeInt32Field(1, 1) // whitelistDisable
2636
+ val whitelistPkg = ProtobufWriter()
2637
+ whitelistPkg.writeInt32Field(1, NotificationCmd.WHITELIST_CTRL)
2638
+ whitelistPkg.writeInt32Field(2, sendManager.nextMagicRandom())
2639
+ whitelistPkg.writeMessageField(6, whitelist.toByteArray())
2640
+ sendNotificationCommand(whitelistPkg.toByteArray())
2641
+
2642
+ synchronized(uploadQueue) {
2643
+ notificationPhase = GlassesNotificationPhases.APPLIED
2644
+ notificationError = null
2645
+ }
2646
+ Bridge.log(
2647
+ "G2: applied notification config enabled=${config.enabled} popups=${config.popups} " +
2648
+ "seconds=${snapPopupSeconds(config.popupSeconds)} blocked=${config.blockedApps.size}"
2649
+ )
2650
+ } catch (e: Exception) {
2651
+ synchronized(uploadQueue) {
2652
+ notificationPhase = GlassesNotificationPhases.ERROR
2653
+ notificationError = e.message ?: "apply failed"
2654
+ }
2655
+ Bridge.log("G2: notification config apply failed: ${e.message}")
2656
+ }
2657
+ publishNotificationStatus()
2658
+ }
2659
+
2660
+ private fun reapplyStoredNotificationConfig() {
2661
+ val config = synchronized(uploadQueue) { notificationConfig } ?: return
2662
+ applyNotificationConfig(config)
2663
+ }
2664
+
2665
+ /**
2666
+ * FILE_CHECK header → START → DATA → END on the file characteristic, per
2667
+ * arm (right first). Returns false while a flash owns the links or a
2668
+ * connected arm never discovered the characteristic.
2669
+ */
2670
+ private suspend fun pushFile(filename: String, content: ByteArray, fileType: Int): Boolean =
2671
+ filePushMutex.withLock {
2672
+ if (firmwareFlasher.active) return@withLock false
2673
+ val header = G2NotificationFile.fileCheckHeader(filename, content, fileType)
2674
+ var pushedRight = false
2675
+ var skippedArm = false
2676
+ // Right arm carries the display; left is kept in sync when it has the char.
2677
+ for (right in listOf(true, false)) {
2678
+ val gatt = (if (right) rightGatt else leftGatt) ?: continue
2679
+ val char = (if (right) rightFileWriteChar else leftFileWriteChar)
2680
+ if (char == null) {
2681
+ skippedArm = true
2682
+ continue
2683
+ }
2684
+ writeFilePackets(gatt, char, G2BLE.FILE_SERVICE_CMD, header)
2685
+ delay(FILE_HEADER_SETTLE_MS)
2686
+ writeFilePackets(gatt, char, G2BLE.FILE_SERVICE_CMD, byteArrayOf(FILE_START))
2687
+ delay(FILE_PHASE_GAP_MS)
2688
+ writeFilePackets(gatt, char, G2BLE.FILE_SERVICE_DATA, content)
2689
+ delay(FILE_PHASE_GAP_MS)
2690
+ writeFilePackets(gatt, char, G2BLE.FILE_SERVICE_CMD, byteArrayOf(FILE_END))
2691
+ delay(FILE_PHASE_GAP_MS)
2692
+ if (right) pushedRight = true
2693
+ }
2694
+ Bridge.log("G2: pushed $filename (${content.size} bytes) right=$pushedRight skipped=$skippedArm")
2695
+ pushedRight && !skippedArm
2696
+ }
2697
+
2698
+ @Suppress("deprecation")
2699
+ private suspend fun writeFilePackets(
2700
+ gatt: BluetoothGatt,
2701
+ char: BluetoothGattCharacteristic,
2702
+ serviceId: Byte,
2703
+ payload: ByteArray
2704
+ ) {
2705
+ val packets = sendManager.buildPackets(serviceId = serviceId, payload = payload)
2706
+ for (packet in packets) {
2707
+ withContext(Dispatchers.Main) {
2708
+ char.value = packet
2709
+ char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
2710
+ gatt.writeCharacteristic(char)
2711
+ }
2712
+ delay(BLE_PACKET_GAP_MS)
2713
+ }
2714
+ }
2715
+
2716
+ private fun resetNotificationsOnDisconnect() {
2717
+ cancelQueuedUploads("disconnected")
2718
+ uploadWorker?.cancel()
2719
+ uploadWorker = null
2720
+ leftFileWriteChar = null
2721
+ rightFileWriteChar = null
2722
+ synchronized(uploadQueue) {
2723
+ notificationPhase = GlassesNotificationPhases.STALE
2724
+ notificationError = null
2725
+ }
2726
+ publishNotificationStatus()
2727
+ }
2728
+
2356
2729
  // ---------- SGCManager: Display Control ----------
2357
2730
 
2358
2731
  override fun sendText(text: String) {
@@ -2415,9 +2788,7 @@ class G2 : SGCManager() {
2415
2788
  // Bridge.log("G2: sendTextWall(${text.take(50)}...)")
2416
2789
 
2417
2790
  // ignore events while the ER dashboard is open:
2418
- val useNativeDashboard =
2419
- DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
2420
- if (useNativeDashboard && dashboardShowing > 0) {
2791
+ if (dashboardShowing > 0) {
2421
2792
  return
2422
2793
  }
2423
2794
 
@@ -2440,7 +2811,7 @@ class G2 : SGCManager() {
2440
2811
  if (supportsCfwSceneRendering) {
2441
2812
  if (text.isBlank()) {
2442
2813
  // A blank wall is the idle state: keep the carrier alive for touch and mic.
2443
- val carrierWasConfigured = installCfwCarrierIfNeeded()
2814
+ val carrierWasConfigured = installCfwPageIfNeeded()
2444
2815
  cfwClear()
2445
2816
  if (!carrierWasConfigured && pageCreated) {
2446
2817
  // Carrier just replaced live stock containers (see clearDisplay).
@@ -2548,7 +2919,7 @@ class G2 : SGCManager() {
2548
2919
  // Idle on CFW = the black carrier page, kept up for touch + mic.
2549
2920
  // Install it even on a first-ever clear (no scene rendered yet) so
2550
2921
  // the idle state is the same page everywhere.
2551
- val carrierWasConfigured = installCfwCarrierIfNeeded()
2922
+ val carrierWasConfigured = installCfwPageIfNeeded()
2552
2923
  cfwClear()
2553
2924
  if (!carrierWasConfigured || !pageCreated) signalDisplayDirty()
2554
2925
  if (!carrierWasConfigured && pageCreated) {
@@ -2628,8 +2999,7 @@ class G2 : SGCManager() {
2628
2999
  val rh = height ?: defaultImgHeight
2629
3000
 
2630
3001
  // ignore events while the ER dashboard is open:
2631
- val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
2632
- if (useNativeDashboard && dashboardShowing > 0) {
3002
+ if (dashboardShowing > 0) {
2633
3003
  return false
2634
3004
  }
2635
3005
 
@@ -2837,6 +3207,8 @@ class G2 : SGCManager() {
2837
3207
  coalescedPageRebuild()
2838
3208
  }
2839
3209
  }
3210
+ // Stock firmware: text sends are unacked; the verbs above are the best signal there is.
3211
+ frame.reportPresented(true)
2840
3212
  }
2841
3213
  }
2842
3214
 
@@ -2846,23 +3218,13 @@ class G2 : SGCManager() {
2846
3218
  * page. Returns true if it was ALREADY configured, so callers can tell
2847
3219
  * whether they need a page rebuild.
2848
3220
  */
2849
- private fun installCfwCarrierIfNeeded(): Boolean {
2850
- if (textContainers.isEmpty() && hasCfwCarrier) return true
3221
+ private fun installCfwPageIfNeeded(): Boolean {
3222
+ if (hasCfwPage) return true
2851
3223
  textContainers.clear()
2852
3224
  sceneTextByElement.clear()
2853
3225
  sceneImageByElement.clear()
2854
3226
  imageContainers.clear()
2855
- imageContainers.add(
2856
- ImgContainer(
2857
- id = cfwSceneContainerId,
2858
- x = 0,
2859
- y = 0,
2860
- width = 576,
2861
- height = 288,
2862
- bmpData = ByteArray(0),
2863
- dirty = false,
2864
- )
2865
- )
3227
+ cfwPageInstalled = true
2866
3228
  cfwResetShadow()
2867
3229
  return false
2868
3230
  }
@@ -2885,13 +3247,23 @@ class G2 : SGCManager() {
2885
3247
 
2886
3248
  /** Install the single carrier and render the whole scene through CFW mode 3. */
2887
3249
  private suspend fun applyCfwScene(frame: SceneFrame) {
2888
- val carrierWasConfigured = installCfwCarrierIfNeeded()
3250
+ displayDiagnostics.count("framesIn")
3251
+ // Held: retain the frame so the post-hold rebuild re-sends it, but paint nothing now.
3252
+ if (notificationsPanelHold) {
3253
+ cfwLastFrame = frame
3254
+ return
3255
+ }
3256
+ val carrierWasConfigured = installCfwPageIfNeeded()
2889
3257
  cfwLastFrame = frame
2890
3258
  // Lease before the frame: a fresh FB_ACQUIRE resets preservation, and
2891
3259
  // the frame queued below is what re-arms it.
2892
3260
  startFbLease()
2893
- if (!enqueueCfwFrame(frame)) {
3261
+ val encodeAt = SystemClock.elapsedRealtime()
3262
+ val encoded = enqueueCfwFrame(frame)
3263
+ displayDiagnostics.timing("encodeMs", SystemClock.elapsedRealtime() - encodeAt)
3264
+ if (!encoded) {
2894
3265
  Bridge.log("G2: CFW scene encode failed; retaining the previous frame")
3266
+ frame.reportPresented(false, "scene encode failed")
2895
3267
  return
2896
3268
  }
2897
3269
  cfwSceneActive = true
@@ -2902,21 +3274,12 @@ class G2 : SGCManager() {
2902
3274
  }
2903
3275
  }
2904
3276
 
2905
- /**
2906
- * Stock's window placement for the current height/depth settings.
2907
- * `pinDepth` forces depth level 1, the only level where both lenses share
2908
- * an x offset — required whenever the frame uses mode-15 text draws,
2909
- * which have no per-lens variant and would otherwise land 16 px apart
2910
- * from the window content (double vision). Height still follows the user.
2911
- */
2912
- private fun cfwBoxes(frame: SceneFrame, pinDepth: Boolean): G2CfwRenderer.LensBoxes {
3277
+ /** Apply global placement to every scene; miniapps only supply relative element depth. */
3278
+ private fun cfwBoxes(frame: SceneFrame): G2CfwRenderer.LensBoxes {
2913
3279
  val height = (DeviceStore.get("bluetooth", "dashboard_height") as? Number)?.toInt() ?: 4
2914
- val depth = if (pinDepth) 1 else (DeviceStore.get("bluetooth", "dashboard_depth") as? Number)?.toInt() ?: 2
3280
+ val depth = (DeviceStore.get("bluetooth", "dashboard_depth") as? Number)?.toInt() ?: 2
2915
3281
  return G2CfwRenderer.lensBoxes(
2916
- frame.canvasWidth,
2917
- frame.canvasHeight,
2918
- height,
2919
- depth,
3282
+ frame.canvasWidth, frame.canvasHeight, height, G2CfwRenderer.depthLevelForSetting(depth),
2920
3283
  )
2921
3284
  }
2922
3285
 
@@ -2928,16 +3291,21 @@ class G2 : SGCManager() {
2928
3291
  return cfwFrameId
2929
3292
  }
2930
3293
 
2931
- private fun cfwResetShadow() {
3294
+ /** `keepCache` keeps the object-cache session; the caller forgets the uploads it drops. */
3295
+ private fun cfwResetShadow(keepCache: Boolean = false) {
3296
+ displayDiagnostics.count("resync")
2932
3297
  cfwPendingPayloads.clear()
3298
+ cfwSceneInvalid = true
2933
3299
  cfwShadowSeeded = false
2934
3300
  cfwLastBoxes = null
2935
3301
  cfwPrevRaster = null
2936
3302
  cfwLastTextDraws = emptyList()
2937
3303
  cfwDeltasSinceFull = 0
2938
- // Queued (unsent) uploads were just dropped, so glyph residency is
3304
+ if (keepCache) return
3305
+ // Queued (unsent) uploads were just dropped, so residency is
2939
3306
  // unknown — make the renderer's cache model re-learn.
2940
3307
  cfwCacheGeneration += 1
3308
+ cfwCacheEpoch = 0
2941
3309
  }
2942
3310
 
2943
3311
  /**
@@ -2958,15 +3326,18 @@ class G2 : SGCManager() {
2958
3326
  }
2959
3327
 
2960
3328
  /**
2961
- * Acquire the fbguard lease and keep renewing it while CFW scene rendering
2962
- * owns the panel. A fresh acquire resets the firmware's preservation (the
2963
- * next presented direct frame re-arms it), so this runs BEFORE the frame is
2964
- * queued. Stops with a release as soon as scene mode is inactive or the
2965
- * page went down, so a stock surface (dashboard, nav) is never frozen
2966
- * under a preserved direct frame.
3329
+ * Acquire the fbguard lease and keep renewing it for as long as our CFW page
3330
+ * is up, as upstream Faceclaw does for its whole session. A fresh acquire
3331
+ * resets the firmware's preservation (the next presented direct frame
3332
+ * re-arms it), so this runs BEFORE the frame is queued. The lease is
3333
+ * deliberately NOT tied to cfwSceneActive: a blank panel (HIDE) keeps the
3334
+ * on-glass object cache, and releasing the lease would free it, turning the
3335
+ * next dashboard open into a cold RESET + full upload. Stock surfaces
3336
+ * (dashboard, nav, notifications) release explicitly before they take the
3337
+ * panel, so nothing is frozen under a preserved direct frame.
2967
3338
  */
2968
3339
  private fun startFbLease() {
2969
- if (fbLeaseJob != null) return
3340
+ if (fbLeaseJob != null || notificationsPanelHold) return
2970
3341
  sendFaceclawFbControl(acquire = true)
2971
3342
  fbLeaseJob =
2972
3343
  displayScope.launch {
@@ -2976,7 +3347,7 @@ class G2 : SGCManager() {
2976
3347
  // hatch may have been flipped off mid-session — without
2977
3348
  // this check the lease renews forever while frames go
2978
3349
  // stock.
2979
- if (cfwSceneActive && pageCreated && dashboardShowing == 0 &&
3350
+ if (pageCreated && dashboardShowing == 0 && !navigationModeActive &&
2980
3351
  supportsCfwSceneRendering
2981
3352
  ) {
2982
3353
  sendFaceclawFbControl(acquire = true)
@@ -2992,8 +3363,9 @@ class G2 : SGCManager() {
2992
3363
  fbLeaseJob?.cancel()
2993
3364
  fbLeaseJob = null
2994
3365
  if (sendRelease) sendFaceclawFbControl(acquire = false)
2995
- // Lease end frees the on-glass texture cache; the model must re-learn.
3366
+ // Lease end frees the on-glass object cache; the model must re-learn.
2996
3367
  cfwCacheGeneration += 1
3368
+ cfwCacheEpoch = 0
2997
3369
  }
2998
3370
 
2999
3371
  /**
@@ -3048,10 +3420,13 @@ class G2 : SGCManager() {
3048
3420
  }
3049
3421
 
3050
3422
  private fun resetCfwConnectionState() {
3051
- cfwCapabilityString = null
3052
- cfwClassified = false
3053
- cfwFeatures.clear()
3054
- cfwProtocolVersion = 0
3423
+ cancelSensorCommands()
3424
+ cfwClassification = null
3425
+ cfwTransport = G2CfwTransport.Encoder()
3426
+ cfwStreamSequence = (0..255).random()
3427
+ cfwPageInstalled = false
3428
+ publishRingBattery(G2RingBattery.DISCONNECTED)
3429
+ onKeyboardThread { keyboardBridge.linkLost() } // the remembered keyboard reconnects on CFW re-detection
3055
3430
  cfwSceneActive = false
3056
3431
  // Fresh connection gets a fresh idle-resurrect budget.
3057
3432
  cfwLastIdleResurrectAt = null
@@ -3072,14 +3447,64 @@ class G2 : SGCManager() {
3072
3447
  * change is large, the queue backed up, or on the periodic insurance
3073
3448
  * keyframe. Seeds the shadow with mode-6 black when it is fresh.
3074
3449
  */
3450
+ /**
3451
+ * Queue [frame] as a retained-scene patch: the firmware keeps the slots,
3452
+ * tweens transitions itself, and renders every frame into its own buffer.
3453
+ * Returns false when the frame does not fit, so the raster path takes it.
3454
+ */
3455
+ private suspend fun enqueueCfwVectorFrame(frame: SceneFrame): Boolean {
3456
+ val generation = cfwCacheGeneration
3457
+ val clearSerial = cfwClearSerial
3458
+ val boxes = cfwBoxes(frame)
3459
+ val backlogged = cfwPendingPayloads.count { it.kind == CfwPayloadKind.SCENE } >= CFW_MAX_QUEUED_UPDATES
3460
+ if (backlogged) {
3461
+ cfwPendingPayloads.removeAll { it.kind == CfwPayloadKind.SCENE }
3462
+ cfwSceneInvalid = true
3463
+ }
3464
+ if (cfwLastBoxes != boxes) cfwSceneInvalid = true
3465
+ val invalidate = cfwSceneInvalid
3466
+ val encoded =
3467
+ kotlinx.coroutines.withContext(Dispatchers.Default) {
3468
+ G2CfwRenderer.encodeVector(
3469
+ frame, boxes, cfwCacheGeneration, invalidate, inlineText = true,
3470
+ tag = if (supportsCfwSceneSettle) frame.seq and 0xFFFF else null,
3471
+ )
3472
+ }
3473
+ if (encoded == null) {
3474
+ Bridge.log("G2: vector scene does not fit; using the raster path for this frame")
3475
+ cfwSceneInvalid = true
3476
+ return false
3477
+ }
3478
+ if (generation != cfwCacheGeneration || clearSerial != cfwClearSerial) return false
3479
+ cfwSceneInvalid = false
3480
+ cfwLastBoxes = boxes
3481
+ // A later raster frame must start from a keyframe: the panel shows the scene, not the shadow.
3482
+ cfwDeltasSinceFull = CFW_MAX_DELTAS_BETWEEN_FULL_FRAMES
3483
+ for (upload in encoded.uploads) {
3484
+ cfwPendingPayloads.addLast(CfwPayload(CfwPayloadKind.UPLOAD, upload))
3485
+ }
3486
+ // The frame is on the glasses once its last SHOW is acked.
3487
+ encoded.shows.forEachIndexed { index, show ->
3488
+ val presents = if (index == encoded.shows.lastIndex) frame.presentTag else null
3489
+ cfwPendingPayloads.addLast(CfwPayload(CfwPayloadKind.SCENE, show.payload, presents, show.lenses))
3490
+ }
3491
+ cfwLastPayloadQueuedAt = System.currentTimeMillis()
3492
+ return true
3493
+ }
3494
+
3075
3495
  private suspend fun enqueueCfwFrame(frame: SceneFrame): Boolean {
3076
- val useBuiltinText =
3077
- supportsCfwBuiltinText && frame.elements.any { it.type == "text" && it.font == "builtin" }
3078
- // Modes 13/14 have no per-lens variant, so they need the lens-equal window.
3079
- val useTextureGlyphs =
3080
- supportsCfwTextureGlyphs && frame.elements.any { it.type == "text" && it.font == "mono" }
3081
- val pinDepth = useBuiltinText || useTextureGlyphs
3082
- val boxes = cfwBoxes(frame, pinDepth = pinDepth)
3496
+ // Mono text rides the scene as TEXT_CACHED runs (glyphs upload once per
3497
+ // lease), so any frame that fits the slot table takes the vector path.
3498
+ if (supportsCfwVectorScene && enqueueCfwVectorFrame(frame)) return true
3499
+ // Raster fallback cannot preserve SVG contours or rotation. Report a dropped frame instead.
3500
+ if (frame.elements.any { it.type == "svg" || it.rotationQ8 != 0 }) return false
3501
+ val hasMonoText = frame.elements.any { it.type == "text" && it.font == "mono" }
3502
+ val boxes = cfwBoxes(frame)
3503
+ val lensEqual = boxes.leftX == boxes.rightX && boxes.leftY == boxes.rightY
3504
+ // Absolute text/cache draws cannot shift per eye. Bake text into the
3505
+ // stereo raster at non-neutral depth instead of overriding global placement.
3506
+ val useBuiltinText = lensEqual && frame.elements.any { it.type == "text" && it.font == "builtin" }
3507
+ val useTextureGlyphs = lensEqual && hasMonoText
3083
3508
  val previousBoxes = cfwLastBoxes
3084
3509
  val movedBoxes = previousBoxes != null && previousBoxes != boxes
3085
3510
  val backlogged =
@@ -3104,8 +3529,8 @@ class G2 : SGCManager() {
3104
3529
  // Mode 15 doesn't clip at the window's right edge: a line the phone's
3105
3530
  // metrics under-measured can spill into the outer panel margin, where
3106
3531
  // no in-window rect or full frame ever repaints. Blank the old
3107
- // window's right margin whenever draws change. (Builtin frames pin
3108
- // depth, so the old window is lens-equal.)
3532
+ // window's right margin whenever draws change. (Builtin draws are only
3533
+ // used when the old window is lens-equal.)
3109
3534
  val marginBlank =
3110
3535
  cfwLastBoxes?.takeIf {
3111
3536
  textDraws != cfwLastTextDraws && cfwLastTextDraws.isNotEmpty() &&
@@ -3122,7 +3547,7 @@ class G2 : SGCManager() {
3122
3547
  val fids = List(G2CfwRenderer.REQUIRED_FID_COUNT) { nextCfwFrameId() }
3123
3548
  // Copies are unsafe around firmware-drawn text (they would smear
3124
3549
  // glyphs the raster knows nothing about), so text frames skip them.
3125
- val allowCopy = supportsCfwRectCopy && textDraws.isEmpty()
3550
+ val allowCopy = textDraws.isEmpty()
3126
3551
  val update =
3127
3552
  kotlinx.coroutines.withContext(Dispatchers.Default) {
3128
3553
  G2CfwRenderer.encodeUpdate(
@@ -3137,7 +3562,7 @@ class G2 : SGCManager() {
3137
3562
  marginBlankBoxes = marginBlank,
3138
3563
  omitBuiltinText = useBuiltinText,
3139
3564
  textureGlyphsEnabled = useTextureGlyphs,
3140
- textureImagesEnabled = supportsCfwTextureImages && pinDepth,
3565
+ textureImagesEnabled = useBuiltinText || useTextureGlyphs,
3141
3566
  cacheGeneration = cfwCacheGeneration,
3142
3567
  )
3143
3568
  } ?: return false
@@ -3149,7 +3574,12 @@ class G2 : SGCManager() {
3149
3574
  for (upload in update.uploads) {
3150
3575
  cfwPendingPayloads.addLast(CfwPayload(CfwPayloadKind.UPLOAD, upload))
3151
3576
  }
3152
- val payload = update.payload ?: return true // visually identical frame
3577
+ val payload = update.payload
3578
+ if (payload == null) {
3579
+ // Visually identical frame — the shadow already shows it, or a queued payload is about to.
3580
+ cfwAttachPresentTag(frame)
3581
+ return true
3582
+ }
3153
3583
  if (update.usedFullFrame) cfwDeltasSinceFull = 0 else cfwDeltasSinceFull += 1
3154
3584
  if (!cfwShadowSeeded) {
3155
3585
  // Seed with the frame itself composited on black when the window
@@ -3171,8 +3601,10 @@ class G2 : SGCManager() {
3171
3601
  cfwShadowSeeded = true
3172
3602
  if (textDraws.isEmpty()) {
3173
3603
  cfwPendingPayloads.removeAll {
3174
- it.kind == CfwPayloadKind.FULL || it.kind == CfwPayloadKind.DELTA
3604
+ it.kind == CfwPayloadKind.FULL || it.kind == CfwPayloadKind.DELTA ||
3605
+ it.kind == CfwPayloadKind.SCENE
3175
3606
  }
3607
+ cfwPendingPayloads.last().presents = frame.presentTag
3176
3608
  cfwLastPayloadQueuedAt = System.currentTimeMillis()
3177
3609
  return true
3178
3610
  }
@@ -3185,16 +3617,29 @@ class G2 : SGCManager() {
3185
3617
  }
3186
3618
  if (update.usedFullFrame) {
3187
3619
  // A full overwrites the window; seeds, uploads, and move-blanks must still survive.
3188
- cfwPendingPayloads.removeAll { it.kind == CfwPayloadKind.FULL || it.kind == CfwPayloadKind.DELTA }
3620
+ cfwPendingPayloads.removeAll {
3621
+ it.kind == CfwPayloadKind.FULL || it.kind == CfwPayloadKind.DELTA || it.kind == CfwPayloadKind.SCENE
3622
+ }
3189
3623
  val kind = if (movedBoxes) CfwPayloadKind.MOVE_BUNDLE else CfwPayloadKind.FULL
3190
- cfwPendingPayloads.addLast(CfwPayload(kind, payload))
3624
+ cfwPendingPayloads.addLast(CfwPayload(kind, payload, frame.presentTag))
3191
3625
  } else {
3192
- cfwPendingPayloads.addLast(CfwPayload(CfwPayloadKind.DELTA, payload))
3626
+ cfwPendingPayloads.addLast(CfwPayload(CfwPayloadKind.DELTA, payload, frame.presentTag))
3193
3627
  }
3194
3628
  cfwLastPayloadQueuedAt = System.currentTimeMillis()
3195
3629
  return true
3196
3630
  }
3197
3631
 
3632
+ /**
3633
+ * A frame that needs no payload of its own is on the glasses once the
3634
+ * last queued frame-carrying payload is acked; with nothing queued it is
3635
+ * there already. Frame numbers only go up, so the tag can be overwritten.
3636
+ */
3637
+ private fun cfwAttachPresentTag(frame: SceneFrame) {
3638
+ val tag = frame.presentTag ?: return
3639
+ val last = cfwPendingPayloads.lastOrNull { it.kind != CfwPayloadKind.UPLOAD }
3640
+ if (last != null) last.presents = tag else frame.reportPresented(true)
3641
+ }
3642
+
3198
3643
  /**
3199
3644
  * Blank the panel (mode-6 black also seeds the shadow) and drop retained scene state.
3200
3645
  *
@@ -3205,13 +3650,29 @@ class G2 : SGCManager() {
3205
3650
  * gates reposition/replay, not the page's existence.
3206
3651
  */
3207
3652
  private fun cfwClear() {
3653
+ cfwClearSerial += 1
3208
3654
  cfwLastFrame = null
3209
3655
  cfwSceneActive = false
3210
- cfwPendingPayloads.removeAll { it.kind != CfwPayloadKind.SEED }
3211
- G2CfwRenderer.blackPayload()?.let {
3212
- cfwPendingPayloads.addLast(CfwPayload(CfwPayloadKind.SEED, it))
3213
- cfwShadowSeeded = true
3214
- cfwLastBoxes = null
3656
+ for (payload in cfwPendingPayloads) {
3657
+ payload.presents?.report(false, "cleared")
3658
+ }
3659
+ // A blank supersedes every unsent operation, including older blanks.
3660
+ cfwPendingPayloads.clear()
3661
+ if (supportsCfwVectorScene) {
3662
+ // The object cache on the glasses is KEPT: a HIDE freezes and blanks, so the
3663
+ // next scene (a reopened dashboard) is one SHOW of references. cfwLastBoxes
3664
+ // therefore stays: nulling it would make the next frame invalidate the scene
3665
+ // model and redefine every object. A PUT dropped from the queue above is
3666
+ // caught by the MISSING reply and retried.
3667
+ cfwPendingPayloads.addLast(CfwPayload(CfwPayloadKind.SEED, G2CfwScene.HIDE_PAYLOAD))
3668
+ G2CfwRenderer.sceneHidden()
3669
+ cfwShadowSeeded = true // HIDE clears the shadow to black, like a keyframe
3670
+ } else {
3671
+ G2CfwRenderer.blackPayload()?.let {
3672
+ cfwPendingPayloads.addLast(CfwPayload(CfwPayloadKind.SEED, it))
3673
+ cfwShadowSeeded = true
3674
+ cfwLastBoxes = null
3675
+ }
3215
3676
  }
3216
3677
  // The panel is (about to be) black — the diff base and any tracked
3217
3678
  // text draws no longer describe what is on glass.
@@ -3242,8 +3703,8 @@ class G2 : SGCManager() {
3242
3703
  // its normal paths.
3243
3704
  if (!cfwClassified || cfwCapabilityString == null) return
3244
3705
  if (enabled) {
3245
- if (installCfwCarrierIfNeeded()) return // already the carrier page
3246
- Bridge.log("G2: cfw_scene_renderer ON — swapping stock containers for the CFW carrier")
3706
+ if (installCfwPageIfNeeded()) return // already the CFW page
3707
+ Bridge.log("G2: cfw_scene_renderer ON — swapping stock containers for the CFW page")
3247
3708
  requestPageRebuild()
3248
3709
  } else {
3249
3710
  Bridge.log("G2: cfw_scene_renderer OFF — exiting CFW surface to a blank stock page")
@@ -3251,7 +3712,8 @@ class G2 : SGCManager() {
3251
3712
  cfwLastFrame = null
3252
3713
  cfwSceneActive = false
3253
3714
  cfwResetShadow()
3254
- imageContainers.clear() // drops the carrier
3715
+ cfwPageInstalled = false
3716
+ imageContainers.clear()
3255
3717
  textContainers.clear()
3256
3718
  sceneTextByElement.clear()
3257
3719
  sceneImageByElement.clear()
@@ -3546,7 +4008,7 @@ class G2 : SGCManager() {
3546
4008
  */
3547
4009
  override fun clearSceneElements(elementIds: List<String>) {
3548
4010
  if (supportsCfwSceneRendering) {
3549
- val carrierWasConfigured = installCfwCarrierIfNeeded()
4011
+ val carrierWasConfigured = installCfwPageIfNeeded()
3550
4012
  cfwClear()
3551
4013
  if (!carrierWasConfigured && pageCreated) {
3552
4014
  // Carrier just replaced live stock containers (see clearDisplay).
@@ -3636,6 +4098,7 @@ class G2 : SGCManager() {
3636
4098
  * callers no longer race a detached coroutine. Always invoke on [displayScope].
3637
4099
  */
3638
4100
  private suspend fun rebuildPage() {
4101
+ lastPageShutdownSentMs = System.currentTimeMillis()
3639
4102
  val msg = EvenHubProto.shutdownMessage()
3640
4103
  sendEvenHubCommand(msg)
3641
4104
  pageCreated = false
@@ -3656,10 +4119,14 @@ class G2 : SGCManager() {
3656
4119
  Bridge.log("G2: rebuildState() skipped — navigation mode owns the screen")
3657
4120
  return
3658
4121
  }
4122
+ if (notificationsPanelHold) {
4123
+ Bridge.log("G2: rebuildState() skipped — notifications panel hold")
4124
+ return
4125
+ }
3659
4126
  Bridge.log("G2: rebuildState()")
3660
4127
  // Stock containers left by a torn-down page would rebuild as the evt-0 page, not the carrier.
3661
4128
  if (supportsCfwSceneRendering) {
3662
- installCfwCarrierIfNeeded()
4129
+ installCfwPageIfNeeded()
3663
4130
  }
3664
4131
  // recreate the containers (sets pageCreated = true; embeds text content directly):
3665
4132
  createPageWithContainers()
@@ -3677,8 +4144,8 @@ class G2 : SGCManager() {
3677
4144
  imageContainers[i].dirty = true
3678
4145
  }
3679
4146
  }
3680
- // A new page allocation has a fresh CFW shadow. Re-seed it before the retained scene.
3681
- if (supportsCfwSceneRendering && hasCfwCarrier) {
4147
+ // Re-seed the on-glass shadow and re-send the retained frame for the new page.
4148
+ if (supportsCfwSceneRendering && cfwPageInstalled) {
3682
4149
  cfwResetShadow()
3683
4150
  val frame = cfwLastFrame
3684
4151
  if (frame != null) {
@@ -3760,6 +4227,32 @@ class G2 : SGCManager() {
3760
4227
  )
3761
4228
  }
3762
4229
 
4230
+ /** Milliseconds left in the settle window after a SHUTDOWN_PAGE we sent; 0 outside it. */
4231
+ private fun pageShutdownSettleRemainingMs(): Long {
4232
+ if (lastPageShutdownSentMs <= 0) return 0
4233
+ val elapsed = System.currentTimeMillis() - lastPageShutdownSentMs
4234
+ return maxOf(0, PAGE_SHUTDOWN_SETTLE_MS - elapsed)
4235
+ }
4236
+
4237
+ /**
4238
+ * Once the settle window ends, bring the page back exactly once: through recovery when the
4239
+ * mic needs a live page, else through the reconcile loop, which only resurrects for real
4240
+ * content (a blank stays down).
4241
+ */
4242
+ private fun armPageShutdownSettleWake(afterMs: Long) {
4243
+ if (pageShutdownSettleWakeArmed) return
4244
+ pageShutdownSettleWakeArmed = true
4245
+ displayScope.launch {
4246
+ delay(afterMs + 10)
4247
+ pageShutdownSettleWakeArmed = false
4248
+ if (!pageCreated && evenHubMicIntent) {
4249
+ recoverPageAndMic("shutdown-settled")
4250
+ } else {
4251
+ signalDisplayDirty()
4252
+ }
4253
+ }
4254
+ }
4255
+
3763
4256
  /**
3764
4257
  * Single coalesced recovery: rebuild the page + re-arm the mic from intent, never stacking
3765
4258
  * rebuilds (the firmware spams systemExit/dashboard-close ~1×/sec). Skips when the page is
@@ -3772,6 +4265,15 @@ class G2 : SGCManager() {
3772
4265
  if (navigationModeActive) {
3773
4266
  return
3774
4267
  }
4268
+ // The firmware is still answering our own SHUTDOWN_PAGE (its systemExit and 08011A00
4269
+ // land ~300 ms after it): this is the old page dying, not something to recover from.
4270
+ // The settle wake brings the page back once.
4271
+ val settle = pageShutdownSettleRemainingMs()
4272
+ if (settle > 0) {
4273
+ Bridge.log("G2: recover($reason) skipped — ${settle}ms left in the shutdown settle window")
4274
+ armPageShutdownSettleWake(settle)
4275
+ return
4276
+ }
3775
4277
  val now = System.currentTimeMillis()
3776
4278
  // Page alive and mic STATE matches INTENT → phantom firmware event, nothing to recover.
3777
4279
  val micState = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
@@ -3817,7 +4319,6 @@ class G2 : SGCManager() {
3817
4319
  // the current text/image, and re-arms the mic iff intent says so. Skip while the native
3818
4320
  // dashboard owns the screen.
3819
4321
  if (!pageCreated) {
3820
- val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
3821
4322
  // Stock path: only resurrect for non-blank content — don't rebuild just to render a
3822
4323
  // clearDisplay's blank, or a clear burst churns the page back up pointlessly.
3823
4324
  // (CFW is the exception below: its blank IS the idle page.)
@@ -3830,7 +4331,7 @@ class G2 : SGCManager() {
3830
4331
  // Idle carrier: the black seed alone. This is what the
3831
4332
  // rebuild→systemExit→rebuild storm guard used to block, so it is
3832
4333
  // rate-limited below rather than allowed once per reconcile tick.
3833
- val cfwIdleCarrier = cfwPendingPayloads.isNotEmpty() && !cfwHasScene && hasCfwCarrier
4334
+ val cfwIdleCarrier = cfwPendingPayloads.isNotEmpty() && !cfwHasScene && cfwPageInstalled
3834
4335
  val hasPendingImage =
3835
4336
  imageContainers.any { it.dirty && it.bmpData.isNotEmpty() } ||
3836
4337
  cfwHasScene
@@ -3840,29 +4341,21 @@ class G2 : SGCManager() {
3840
4341
  // resurrection for the whole backoff window.
3841
4342
  if (
3842
4343
  wants &&
3843
- !(useNativeDashboard && dashboardShowing > 0) &&
4344
+ dashboardShowing == 0 &&
3844
4345
  (hasPendingText || hasPendingImage || cfwIdlePageResurrectAllowed())
3845
4346
  ) {
3846
- // Probe-before-rebuild: a phantom exit may have marked a healthy
3847
- // page dead (static pages give the ack-window filter nothing to
3848
- // go on). Try the queued carrier payload against the "dead" page
3849
- // first — an ack proves it alive and skips the shutdown+create
3850
- // whose own teardown would elicit the next exit. A timeout
3851
- // confirms real death and falls through to the rebuild.
3852
- if (cfwHasScene || cfwIdleCarrier) {
3853
- val carrier = imageContainers.firstOrNull { it.id == cfwSceneContainerId }
3854
- val probe = cfwPendingPayloads.firstOrNull()
3855
- if (carrier != null && probe != null) {
3856
- val ok = sendImageData(carrier.id, carrier.name, probe.data)
3857
- if (ok) {
3858
- Bridge.log("G2: page-down probe acked — phantom exit, page alive (no rebuild)")
3859
- pageCreated = true
3860
- if (cfwPendingPayloads.isNotEmpty()) cfwPendingPayloads.removeFirst()
3861
- signalDisplayDirty() // drain the rest normally
3862
- return
3863
- }
3864
- }
4347
+ // Our own SHUTDOWN_PAGE is still being answered: creating now
4348
+ // would race the teardown trio (see lastPageShutdownSentMs).
4349
+ val settle = pageShutdownSettleRemainingMs()
4350
+ if (settle > 0) {
4351
+ Bridge.log(
4352
+ "G2: reconcileDisplay() - page down, ${settle}ms left in the shutdown settle window; deferring the rebuild",
4353
+ )
4354
+ armPageShutdownSettleWake(settle)
4355
+ return
3865
4356
  }
4357
+ // (Transport ACKs need no page, so they cannot probe a "dead" page
4358
+ // the way carrier image ACKs once did.)
3866
4359
  Bridge.log("G2: reconcileDisplay() - page down with pending content, rebuilding once")
3867
4360
  rebuildState()
3868
4361
  }
@@ -3885,11 +4378,55 @@ class G2 : SGCManager() {
3885
4378
  }
3886
4379
 
3887
4380
  // CFW keyframes and deltas are ordered: a fresh mode-6 shadow must land before mode 3.
3888
- imageContainers.firstOrNull { it.id == cfwSceneContainerId }?.let { carrier ->
4381
+ var sensorsDrained = 0
4382
+ while (pageCreated && !navigationModeActive && !sensorNavigationRecovering && sensorsDrained < 4 && sensorCommands.isNotEmpty()) {
4383
+ if (!supportsCfwTransport) break
4384
+ sensorsDrained += 1
4385
+ val command = sensorCommands.removeFirst()
4386
+ command.result.complete(sendCfwMessage(command.data))
4387
+ }
4388
+
4389
+ if (supportsCfwSceneRendering && cfwPageInstalled) {
3889
4390
  var drained = 0
3890
4391
  while (pageCreated && drained < 4 && cfwPendingPayloads.isNotEmpty()) {
3891
4392
  drained += 1
3892
- val ok = sendImageData(carrier.id, carrier.name, cfwPendingPayloads.removeFirst().data)
4393
+ val payload = cfwPendingPayloads.removeFirst()
4394
+ val cacheMessage = G2CfwTransport.isCacheMessage(payload.data)
4395
+ var ok = if (cacheMessage) cfwEnsureCacheSession() else true
4396
+ if (ok) ok = sendCfwMessage(payload.data, payload.lenses)
4397
+ var recover: (() -> Unit)? = null
4398
+ if (ok && cacheMessage) {
4399
+ when (val outcome = cfwCacheOutcome()) {
4400
+ is CfwCacheOutcome.Applied -> cfwMissingRetries = 0
4401
+ is CfwCacheOutcome.Stale -> {
4402
+ ok = false
4403
+ recover = { cfwCacheGeneration += 1; cfwCacheEpoch = 0; cfwSceneInvalid = true }
4404
+ }
4405
+ is CfwCacheOutcome.Missing -> {
4406
+ ok = false
4407
+ val retry = cfwMissingRetries < 2
4408
+ cfwMissingRetries += 1
4409
+ recover = { G2CfwRenderer.forget(outcome.ids); if (!retry) cfwSceneInvalid = true }
4410
+ }
4411
+ is CfwCacheOutcome.Refused -> {
4412
+ Bridge.log("G2: cache message refused: ${outcome.why}")
4413
+ ok = false
4414
+ recover = { cfwCacheGeneration += 1; cfwCacheEpoch = 0; cfwSceneInvalid = true }
4415
+ }
4416
+ }
4417
+ }
4418
+ // Both lenses acked and applied: the firmware has executed the payload and presents on its next frame tick.
4419
+ if (ok) payload.presents?.let {
4420
+ displayDiagnostics.count("framesAcked")
4421
+ it.report(true)
4422
+ }
4423
+ if (recover != null) {
4424
+ payload.presents?.report(false, "cache rejected")
4425
+ recover()
4426
+ cfwPendingPayloads.removeAll { it.kind == CfwPayloadKind.SCENE || it.kind == CfwPayloadKind.UPLOAD }
4427
+ if (pageCreated && supportsCfwSceneRendering && cfwPageInstalled) cfwLastFrame?.let { enqueueCfwFrame(it) }
4428
+ break
4429
+ }
3893
4430
  if (!ok) {
3894
4431
  // A lost payload leaves the on-glass shadow stale; further
3895
4432
  // deltas would composite onto it and corrupt the panel.
@@ -3898,8 +4435,17 @@ class G2 : SGCManager() {
3898
4435
  // the page down mid-transfer is re-seeded by rebuildState;
3899
4436
  // refilling the queue here just fuels the page-resurrection
3900
4437
  // loop (rebuild→systemExit→rebuild every debounce tick).
3901
- if (pageCreated && supportsCfwSceneRendering && hasCfwCarrier) {
3902
- cfwResetShadow()
4438
+ if (pageCreated && supportsCfwSceneRendering && cfwPageInstalled) {
4439
+ // A transport failure leaves the cache session intact (a lens
4440
+ // that lost it answers STALE), so only the uploads that may not
4441
+ // have landed are re-sent — not every asset. A failed session
4442
+ // RESET (epoch back to 0) still starts over.
4443
+ val keepCache = cfwCacheEpoch != 0
4444
+ val dropped = (listOf(payload) + cfwPendingPayloads)
4445
+ .filter { it.kind == CfwPayloadKind.UPLOAD }
4446
+ .map { it.data }
4447
+ cfwResetShadow(keepCache)
4448
+ if (keepCache) G2CfwRenderer.forgetUploads(dropped)
3903
4449
  cfwLastFrame?.let { enqueueCfwFrame(it) }
3904
4450
  }
3905
4451
  break
@@ -3962,13 +4508,7 @@ class G2 : SGCManager() {
3962
4508
  containerName: String,
3963
4509
  bmpData: ByteArray
3964
4510
  ): Boolean {
3965
- // CFW carrier transfers use the verified wire format: 3800-byte
3966
- // fragments, MapSessionId pinned to the container id instead of the
3967
- // rotating session counter, and frames written to the LEFT arm (acks
3968
- // still return on the right). Stock containers keep the shipped
3969
- // 4096/rotating-session/right-arm shape.
3970
- val isCfwCarrier = containerID == cfwSceneContainerId
3971
- val fragmentSize = if (isCfwCarrier) 3800 else 4096
4511
+ val fragmentSize = 4096
3972
4512
  val totalSize = bmpData.size
3973
4513
  val fragmentCount = (bmpData.size + fragmentSize - 1) / fragmentSize
3974
4514
 
@@ -3987,10 +4527,11 @@ class G2 : SGCManager() {
3987
4527
  }
3988
4528
  // One session id per WHOLE image transfer (per attempt). The glasses key their
3989
4529
  // reassembly buffer on MapSessionId, so every fragment reuses it with an incrementing
3990
- // MapFragmentIndex. A retry uses a fresh session so a stale ACK from a prior attempt
3991
- // can't match.
4530
+ // MapFragmentIndex. Every transfer takes a fresh session — a retry, and the next
4531
+ // payload on the CFW carrier too — so a stale ACK from an earlier transfer can never
4532
+ // complete this one's await early (the timeout here is already per-await).
3992
4533
  imageSessionCounter = (imageSessionCounter + 1) % 256
3993
- val sessionId = if (isCfwCarrier) containerID else imageSessionCounter
4534
+ val sessionId = imageSessionCounter
3994
4535
 
3995
4536
  var fragmentIndex = 0
3996
4537
  var offset = 0
@@ -4020,7 +4561,7 @@ class G2 : SGCManager() {
4020
4561
  mapFragmentPacketSize = fragment.size,
4021
4562
  mapRawData = fragment
4022
4563
  )
4023
- sendEvenHubCommand(msg, left = isCfwCarrier, right = !isCfwCarrier)
4564
+ sendEvenHubCommand(msg)
4024
4565
 
4025
4566
  // Gate on THIS fragment's ACK before sending the next (the ACK provides pacing).
4026
4567
  // null=timeout, false=img_failed → abandon the attempt and retry the whole image.
@@ -4050,6 +4591,110 @@ class G2 : SGCManager() {
4050
4591
  return false
4051
4592
  }
4052
4593
 
4594
+ /**
4595
+ * Send one custom message over the SID-0xf0 transport to the lenses [lenses]
4596
+ * selects (both by default) and wait for their processing ACKs. Always
4597
+ * written to the LEFT arm; the right lens
4598
+
4599
+ * receives it over the frame bridge and its reply returns through the
4600
+ * left. Deltas, rect copies and tweens are not safe to replay after an
4601
+ * ambiguous loss, so a failure is reported (the caller resyncs from a
4602
+ * keyframe) and the deflate context restarts, as the glasses have already
4603
+ * dropped theirs on a NACK.
4604
+ */
4605
+ private suspend fun sendCfwMessage(original: ByteArray, lenses: Int = G2CfwTransport.LENS_BOTH): Boolean {
4606
+ if (original.isEmpty()) return true
4607
+ var message = original
4608
+ if (G2CfwTransport.isCacheMessage(original)) {
4609
+ cfwCacheRequest = if (cfwCacheRequest >= 0xFFFE) 1 else cfwCacheRequest + 1
4610
+ message = G2CfwTransport.stamp(original, cfwCacheEpoch, cfwCacheRequest)
4611
+ }
4612
+ val record = cfwTransport.record(message, lenses) ?: return false
4613
+ val streamId = cfwStreamSequence
4614
+ val packets = G2CfwTransport.packets(record, streamId, lenses)
4615
+ cfwStreamSequence = (cfwStreamSequence + packets.size) and 0xFF
4616
+ // Arm before enqueueing: replies arrive on the BLE callback thread and
4617
+ // can beat this coroutine's next resumption.
4618
+ val ack = cfwAckBox.arm(streamId, lenses)
4619
+
4620
+ val startedAt = SystemClock.elapsedRealtime()
4621
+ var outcome = "cancelled"
4622
+ val ok = try {
4623
+ sendToGlasses(packets, left = true, right = false, isPending = { cfwAckBox.isPending(ack) })
4624
+ val result = withTimeoutOrNull(G2CfwTransport.ackTimeoutMs(record.size)) { ack.await() }
4625
+ outcome = if (result == null) "timeout" else if (result) "ack" else "nack"
4626
+ result == true
4627
+ } finally {
4628
+ // Cancellation must expire queued writes too. Identity, rather than
4629
+ // the wrapping stream ID, prevents a later send reviving old packets.
4630
+ cfwAckBox.disarm(ack, outcome)
4631
+ displayDiagnostics.timing("transferMs", SystemClock.elapsedRealtime() - startedAt)
4632
+ if (outcome != "ack") {
4633
+ cfwTransport.resetContext()
4634
+ displayDiagnostics.count("errors")
4635
+ if (outcome == "timeout") displayDiagnostics.count("timeouts")
4636
+ if (outcome == "nack") displayDiagnostics.count("nacks")
4637
+ }
4638
+ }
4639
+ if (!ok) {
4640
+ Bridge.log(
4641
+ "G2: cfw stream $streamId (${packets.size} packets, mode ${message[0].toInt() and 0xFF}, " +
4642
+ "${message.size} bytes) failed: ${cfwAckBox.lastOutcome.ifEmpty { "timeout" }}"
4643
+ )
4644
+ }
4645
+ return ok
4646
+ }
4647
+
4648
+ /**
4649
+ * The object cache's session: before the first cache message of a generation both
4650
+ * lenses are RESET to a fresh phone-chosen epoch and the warm-up tween runs.
4651
+ */
4652
+ private suspend fun cfwEnsureCacheSession(): Boolean {
4653
+ if (!supportsCfwVectorScene) return true
4654
+ if (cfwCacheEpoch != 0 && cfwSceneWarmedGeneration == cfwCacheGeneration) return true
4655
+ val generation = cfwCacheGeneration
4656
+ cfwCacheEpoch = (1..0xFFFE).random()
4657
+ val reset = G2CfwScene.RESET_PAYLOAD + byteArrayOf(minOf(255, G2CfwTransport.MAX_CHUNK_BYTES + 10).toByte())
4658
+ if (!sendCfwMessage(reset) || generation != cfwCacheGeneration) {
4659
+ cfwCacheEpoch = 0
4660
+ return false
4661
+ }
4662
+ var store = Int.MAX_VALUE
4663
+ for (reply in cfwAckBox.cacheReplies().values) {
4664
+ if (reply.epoch != cfwCacheEpoch) {
4665
+ Bridge.log("G2: cache reset kept epoch ${reply.epoch} on lens ${reply.lens}")
4666
+ cfwCacheEpoch = 0
4667
+ return false
4668
+ }
4669
+ if (reply.storeBytes > 0) store = minOf(store, reply.storeBytes)
4670
+ }
4671
+ if (store != Int.MAX_VALUE) G2CfwRenderer.setStoreCapacity(store)
4672
+ cfwSceneWarmedGeneration = generation
4673
+ sendCfwMessage(G2CfwScene.WARM_UP_SHOW)
4674
+ return true
4675
+ }
4676
+
4677
+ private sealed class CfwCacheOutcome {
4678
+ object Applied : CfwCacheOutcome()
4679
+ object Stale : CfwCacheOutcome()
4680
+ class Missing(val ids: List<Int>) : CfwCacheOutcome()
4681
+ class Refused(val why: String) : CfwCacheOutcome()
4682
+ }
4683
+
4684
+ private fun cfwCacheOutcome(): CfwCacheOutcome {
4685
+ val missing = mutableListOf<Int>()
4686
+ for (reply in cfwAckBox.cacheReplies().values) {
4687
+ when (reply.status) {
4688
+ G2CfwTransport.CacheReply.STALE -> return CfwCacheOutcome.Stale
4689
+ G2CfwTransport.CacheReply.MISSING -> missing.addAll(reply.missingIds)
4690
+ G2CfwTransport.CacheReply.APPLIED, G2CfwTransport.CacheReply.UNCHANGED,
4691
+ G2CfwTransport.CacheReply.DELTA, G2CfwTransport.CacheReply.SNAPSHOT -> Unit
4692
+ else -> return CfwCacheOutcome.Refused("lens ${reply.lens}: ${reply.summary}")
4693
+ }
4694
+ }
4695
+ return if (missing.isEmpty()) CfwCacheOutcome.Applied else CfwCacheOutcome.Missing(missing)
4696
+ }
4697
+
4053
4698
  /// Bring the Even Realities dashboard (the OS-level home/idle screen) to
4054
4699
  /// the foreground by tearing down whatever EvenHub page we currently own.
4055
4700
  /// The glasses fall back to the dashboard automatically when no page is up.
@@ -4091,8 +4736,11 @@ class G2 : SGCManager() {
4091
4736
  }
4092
4737
 
4093
4738
  override fun setDashboardDepthOnly(depth: Int) {
4094
- val clamped = depth.coerceIn(0, 2)
4095
- Bridge.log("G2: setDashboardDepthOnly($clamped)")
4739
+ // The setting is 1-based (1..3, see the position screen); the firmware's
4740
+ // xCoordinateLevel is 0..2. Sending the setting straight collapsed the
4741
+ // two far levels onto one.
4742
+ val clamped = G2CfwRenderer.depthLevelForSetting(depth)
4743
+ Bridge.log("G2: setDashboardDepthOnly(depth=$depth level=$clamped)")
4096
4744
  val msg = G2SettingProto.setScreenDepth(sendManager.nextMagicRandom(), clamped)
4097
4745
  sendG2SettingCommand(msg)
4098
4746
  cfwReposition()
@@ -4174,9 +4822,20 @@ class G2 : SGCManager() {
4174
4822
  * 3. SKILL{skillId=NOTIFICATION, skillParam=show, ...} — dispatches the intent
4175
4823
  * The SKILL step alone is ignored by firmware; the preceding ENTER+ASK
4176
4824
  * supply the session context that lets the glasses act on the SKILL.
4825
+ * Our EvenHub page is torn down first and every path that would bring it
4826
+ * back (recovery rebuilds, fbguard lease, CFW frames, mic re-arm) is held
4827
+ * for 3 s, or the sends race the stock panel for the screen.
4177
4828
  */
4178
4829
  override suspend fun showNotificationsPanel() {
4179
4830
  Bridge.log("G2: showNotificationsPanel()")
4831
+
4832
+ notificationsPanelHold = true
4833
+ stopFbLease(sendRelease = true)
4834
+ sendEvenHubCommand(EvenHubProto.shutdownMessage())
4835
+ pageCreated = false
4836
+ // Page death takes the mic with it; intent is untouched so the post-hold rebuild re-arms.
4837
+ DeviceStore.apply("glasses", "micEnabled", false)
4838
+
4180
4839
  val enterPayload = EvenAIProto.aiCtrl(
4181
4840
  magicRandom = sendManager.nextMagicRandom(),
4182
4841
  status = 2 // EVEN_AI_ENTER
@@ -4201,6 +4860,10 @@ class G2 : SGCManager() {
4201
4860
  fTextEnd = 1
4202
4861
  )
4203
4862
  sendEvenAICommand(skillPayload)
4863
+
4864
+ delay(3_000)
4865
+ notificationsPanelHold = false
4866
+ Bridge.log("G2: notifications resurrect re-enabled")
4204
4867
  }
4205
4868
 
4206
4869
  override fun setBrightness(level: Int, autoMode: Boolean) {
@@ -4217,26 +4880,21 @@ class G2 : SGCManager() {
4217
4880
  // ---------- Private Display Helpers ----------
4218
4881
 
4219
4882
  private fun createPageWithContainers() {
4220
- // With CFW scene rendering active the ONLY legal page is the carrier
4221
- // shape: one 576×288 image container (the framebuffer) and no stock
4222
- // text containers. A stray stock container rebuilds the stock page
4223
- // shape and the firmware then rejects every CFW payload. Every rebuild
4224
- // path funnels through this function, so enforce the invariant here
4225
- // rather than trusting each caller to have run
4226
- // installCfwCarrierIfNeeded() first.
4883
+ // With CFW scene rendering active the ONLY legal page has no stock text
4884
+ // or image containers: custom payloads ride the SID-0xf0 transport, and
4885
+ // the page exists for touch capture and the mic. Every rebuild path
4886
+ // funnels through this function, so enforce the invariant here rather
4887
+ // than trusting each caller to have run installCfwPageIfNeeded() first.
4227
4888
  if (supportsCfwSceneRendering) {
4228
4889
  if (textContainers.isNotEmpty()) {
4229
4890
  Bridge.log("G2: CFW page — dropping stray text containers ${textContainers.map { it.id }}")
4230
4891
  textContainers.clear()
4231
4892
  }
4232
- val strayImages = imageContainers.filter { it.id != cfwSceneContainerId }
4233
- if (strayImages.isNotEmpty()) {
4234
- Bridge.log("G2: CFW page — dropping stray image containers ${strayImages.map { it.id }}")
4235
- imageContainers.retainAll { it.id == cfwSceneContainerId }
4236
- }
4237
- if (!hasCfwCarrier) {
4238
- installCfwCarrierIfNeeded()
4893
+ if (imageContainers.isNotEmpty()) {
4894
+ Bridge.log("G2: CFW page — dropping stray image containers ${imageContainers.map { it.id }}")
4895
+ imageContainers.clear()
4239
4896
  }
4897
+ cfwPageInstalled = true
4240
4898
  }
4241
4899
 
4242
4900
  // Dedicated event-capture container: id 0, 1x1, borderless, empty — the
@@ -4287,15 +4945,6 @@ class G2 : SGCManager() {
4287
4945
  // and ensure the ids are still in the imageContainerIDPool:
4288
4946
  val seenIDs = mutableSetOf<Int>()
4289
4947
  imageContainers.retainAll { c ->
4290
- // The CFW carrier is INTENTIONALLY empty: its pixels ride the
4291
- // ordered payload queue (cfwPendingPayloads), never bmpData. The
4292
- // empty-container prune below silently dropped it from every page
4293
- // since the queue rework — the firmware then had no container to
4294
- // receive ANY CFW payload (mode 3/6/8, even plain BMPs), so all
4295
- // direct rendering went dark while stock text kept working.
4296
- if (c.id == cfwSceneContainerId) {
4297
- return@retainAll seenIDs.add(c.id)
4298
- }
4299
4948
  if (c.bmpData.isEmpty()) {
4300
4949
  Bridge.log("G2: removing empty image container ${c.id}")
4301
4950
  return@retainAll false
@@ -4514,15 +5163,15 @@ class G2 : SGCManager() {
4514
5163
  // Intent is "mic on". The mic only exists inside a live EvenHub page, so we toggle it
4515
5164
  // off then back on (the firmware needs the off→on edge to re-arm).
4516
5165
  evenHubMicIntent = true
5166
+ // Held: intent is recorded; the post-hold rebuild re-arms the mic.
5167
+ if (notificationsPanelHold) return
4517
5168
  DeviceStore.apply("glasses", "micEnabled", true)
4518
5169
  val msg = EvenHubProto.audioControlMessage(false)
4519
5170
  sendEvenHubCommand(msg)
4520
5171
  mainHandler.postDelayed({
4521
- val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
4522
- // Bridge.log("G2: setMicEnabled - useNativeDashboard=$useNativeDashboard, dashboardShowing=$dashboardShowing")
4523
5172
  // Dashboard owns the screen + session right now — don't arm the mic into a page the
4524
5173
  // dashboard has taken over; recovery re-arms on dashboard close.
4525
- if (useNativeDashboard && dashboardShowing > 0) {
5174
+ if (dashboardShowing > 0) {
4526
5175
  return@postDelayed
4527
5176
  }
4528
5177
  // Never send audioControl(enable=true) without a live page — no page means no mic.
@@ -4666,16 +5315,18 @@ class G2 : SGCManager() {
4666
5315
  textContainers.clear()
4667
5316
  dashboardShowing = 0
4668
5317
  dashboardOpening = false
5318
+ notificationsPanelHold = false
4669
5319
  heartbeatCounter = 0
4670
5320
  currentBitmapBase64 = ""
4671
5321
  menuAppIdToPackageName.clear()
4672
5322
  activeMenuAppId = null
4673
- lastClickTimestamp = null
5323
+ touchDebounce = G2TouchDebounce()
4674
5324
  lastMenuSelectTimestamp = null
4675
5325
  leftOtaDataWriteChar = null
4676
5326
  rightOtaDataWriteChar = null
4677
5327
  leftOtaDataNotifyChar = null
4678
5328
  rightOtaDataNotifyChar = null
5329
+ resetNotificationsOnDisconnect()
4679
5330
  resetCfwConnectionState()
4680
5331
  DeviceStore.apply("glasses", "connected", false)
4681
5332
  DeviceStore.apply("glasses", "fullyBooted", false)
@@ -4712,21 +5363,19 @@ class G2 : SGCManager() {
4712
5363
  sendEvenHubHeartbeat()
4713
5364
  }
4714
5365
 
4715
- override fun dbg1() {
4716
- displayScope.launch { showNotificationsPanel() }
4717
- }
5366
+ override fun dbg1() {}
5367
+
5368
+ override fun dbg2() {}
4718
5369
 
4719
- private var compassRunning = false
5370
+ /** Raw wake lease start — no durable intent; a reconnect drops it. */
5371
+ override fun dbg3() {
5372
+ Bridge.log("G2: dbg3() — start wake lease")
5373
+ startWakeLease()
5374
+ }
4720
5375
 
4721
- override fun dbg2() {
4722
- // compassRunning = !compassRunning
4723
- // Bridge.log("G2: dbg2() — ${if (compassRunning) "start" else "stop"} compass")
4724
- // if (compassRunning) {
4725
- // startCompass()
4726
- // } else {
4727
- // stopCompass()
4728
- // }
4729
- displayScope.launch { runAuthSequence() }
5376
+ override fun dbg4() {
5377
+ Bridge.log("G2: dbg4() — stop wake lease")
5378
+ stopWakeLease(sendRelease = true)
4730
5379
  }
4731
5380
 
4732
5381
  // ---------- Native navigation mode (service 0x08) ----------
@@ -4776,6 +5425,7 @@ class G2 : SGCManager() {
4776
5425
  override fun exitNavigationMode(arrived: Boolean) {
4777
5426
  if (!navigationModeActive) return
4778
5427
  Bridge.log("G2: exitNavigationMode(arrived: $arrived)")
5428
+ sensorNavigationRecovering = true
4779
5429
  navHeartbeatJob?.cancel()
4780
5430
  navHeartbeatJob = null
4781
5431
  navigationModeActive = false
@@ -4795,6 +5445,7 @@ class G2 : SGCManager() {
4795
5445
  delay(600)
4796
5446
  if (navigationModeActive) return@launch
4797
5447
  pageCreated = false
5448
+ sensorNavigationRecovering = false
4798
5449
  recoverPageAndMic(reason = "nav-exit")
4799
5450
  }
4800
5451
  }
@@ -5437,6 +6088,7 @@ class G2 : SGCManager() {
5437
6088
  leftWriteChar = null
5438
6089
  leftNotifyChar = null
5439
6090
  leftAudioChar = null
6091
+ leftFileWriteChar = null
5440
6092
  leftOtaDataWriteChar = null
5441
6093
  leftOtaDataNotifyChar = null
5442
6094
  } else {
@@ -5444,6 +6096,7 @@ class G2 : SGCManager() {
5444
6096
  rightWriteChar = null
5445
6097
  rightNotifyChar = null
5446
6098
  rightAudioChar = null
6099
+ rightFileWriteChar = null
5447
6100
  rightOtaDataWriteChar = null
5448
6101
  rightOtaDataNotifyChar = null
5449
6102
  }
@@ -5463,6 +6116,8 @@ class G2 : SGCManager() {
5463
6116
  rightNotifyChar = null
5464
6117
  leftAudioChar = null
5465
6118
  rightAudioChar = null
6119
+ leftFileWriteChar = null
6120
+ rightFileWriteChar = null
5466
6121
  leftOtaDataWriteChar = null
5467
6122
  rightOtaDataWriteChar = null
5468
6123
  leftOtaDataNotifyChar = null
@@ -5544,6 +6199,17 @@ class G2 : SGCManager() {
5544
6199
  enqueueGattOp { enableNotifications(gatt, char) }
5545
6200
  }
5546
6201
 
6202
+ G2BLE.FILE_WRITE -> {
6203
+ Bridge.log("G2: Found FILE WRITE char on $side")
6204
+ if (side == "LEFT") leftFileWriteChar = char
6205
+ else rightFileWriteChar = char
6206
+ }
6207
+
6208
+ G2BLE.FILE_NOTIFY -> {
6209
+ Bridge.log("G2: Found FILE NOTIFY char on $side")
6210
+ enqueueGattOp { enableNotifications(gatt, char) }
6211
+ }
6212
+
5547
6213
  G2BLE.OTA_DATA_WRITE -> {
5548
6214
  Bridge.log("G2: Found OTA DATA WRITE char on $side")
5549
6215
  if (side == "LEFT") leftOtaDataWriteChar = char
@@ -5586,7 +6252,8 @@ class G2 : SGCManager() {
5586
6252
  gatt: BluetoothGatt,
5587
6253
  characteristic: BluetoothGattCharacteristic
5588
6254
  ) {
5589
- val data = characteristic.value ?: return
6255
+ val data = characteristic.value?.copyOf() ?: return
6256
+ val arrival = G2InputArrival(SystemClock.elapsedRealtime(), System.currentTimeMillis())
5590
6257
 
5591
6258
  val sourceKey = if (side == "LEFT") "L" else "R"
5592
6259
  when (characteristic.uuid) {
@@ -5599,7 +6266,8 @@ class G2 : SGCManager() {
5599
6266
  // promptly even while the main looper is busy draining a packet burst /
5600
6267
  // heartbeats — the cause of the intermittent "glasses stopped responding".
5601
6268
  correlateImageAck(data, sourceKey)
5602
- mainHandler.post { handleNotifyData(data, sourceKey) }
6269
+ correlateCfwAck(data)
6270
+ mainHandler.post { handleNotifyData(data, sourceKey, arrival) }
5603
6271
  }
5604
6272
  }
5605
6273
  }
@@ -5703,7 +6371,19 @@ class G2 : SGCManager() {
5703
6371
  }
5704
6372
  }
5705
6373
 
5706
- private fun handleNotifyData(data: ByteArray, sourceKey: String) {
6374
+ /**
6375
+ * Read-only, single-packet parse of a raw notify frame that resolves an in-flight SID-0xf0
6376
+ * ACK/NACK directly on the BLE callback thread. Replies always fit one packet.
6377
+ */
6378
+ private fun correlateCfwAck(rawData: ByteArray) {
6379
+ val body = G2CfwTransport.replyBody(rawData) ?: return
6380
+ G2CfwTransport.parseCacheReply(body)?.let { cfwAckBox.record(it); return }
6381
+ val ack = G2CfwTransport.parseAck(body) ?: return
6382
+ cfwAckBox.resolve(ack)
6383
+ }
6384
+
6385
+ private fun handleNotifyData(data: ByteArray, sourceKey: String, arrival: G2InputArrival) {
6386
+ displayDiagnostics.timing("notifyDispatchMs", SystemClock.elapsedRealtime() - arrival.elapsedMs)
5707
6387
  val result = receiveManager.handlePacket(data, sourceKey) ?: return
5708
6388
 
5709
6389
  val serviceId = result.first
@@ -5714,7 +6394,7 @@ class G2 : SGCManager() {
5714
6394
  // Bridge.log("G2: handleNotifyData() - serviceId=$serviceId, payload=${payload.take(32).joinToString("") { String.format("%02X", it) }}")
5715
6395
 
5716
6396
  when (serviceId) {
5717
- ServiceID.EVEN_HUB.value -> handleEvenHubResponse(payload)
6397
+ ServiceID.EVEN_HUB.value -> handleEvenHubResponse(payload, arrival)
5718
6398
  ServiceID.DEVICE_SETTINGS.value -> handleDevSettingsResponse(payload, sourceKey)
5719
6399
  ServiceID.G2_SETTING.value -> handleG2SettingResponse(payload)
5720
6400
  ServiceID.MENU.value -> handleMenuResponse(payload)
@@ -5723,6 +6403,7 @@ class G2 : SGCManager() {
5723
6403
  ServiceID.NAVIGATION.value -> handleNavigationResponse(payload)
5724
6404
  ServiceID.EVEN_AI.value -> handleEvenAIResponse(payload)
5725
6405
  ServiceID.EVEN_HUB_CTRL.value -> handleEvenHubCtrlResponse(payload)
6406
+ ServiceID.CFW_STREAM.value -> Unit // ACK/NACK replies are correlated inline in correlateCfwAck()
5726
6407
  else -> {
5727
6408
  Bridge.log(
5728
6409
  "G2: Unhandled service ${serviceId.toInt() and 0xFF} (${payload.size} bytes): ${
@@ -5781,16 +6462,11 @@ class G2 : SGCManager() {
5781
6462
  val compassData = fields[10] as? ByteArray ?: return
5782
6463
  val cReader = ProtobufReader(compassData)
5783
6464
  val cFields = cReader.parseFields()
5784
- val heading = cFields[1] as? Int ?: return
5785
- // Heading in degrees, 0…359.
5786
- Bridge.log("G2: compass heading=$heading°")
5787
- Bridge.sendTypedMessage(
5788
- "compass_heading",
5789
- mapOf(
5790
- "heading" to heading,
5791
- "timestamp" to System.currentTimeMillis()
5792
- )
5793
- )
6465
+ // Proto3 omits the scalar for magnetic north (zero degrees).
6466
+ val heading = G2Sensors.heading(cFields) ?: return
6467
+ val body = mutableMapOf<String, Any>("heading" to heading, "timestamp" to System.currentTimeMillis())
6468
+ (fields[100] as? ByteArray)?.let { G2Sensors.compass(it) }?.let { body["diagnostics"] = it }
6469
+ Bridge.sendTypedMessage("compass_heading", body)
5794
6470
  }
5795
6471
 
5796
6472
  NavigationCmd.OS_NOTIFY_COMPASS_CALIBRATE_START.value -> {
@@ -5834,7 +6510,7 @@ class G2 : SGCManager() {
5834
6510
  }
5835
6511
  }
5836
6512
 
5837
- private fun handleEvenHubResponse(payload: ByteArray) {
6513
+ private fun handleEvenHubResponse(payload: ByteArray, arrival: G2InputArrival) {
5838
6514
  val reader = ProtobufReader(payload)
5839
6515
  val fields = reader.parseFields()
5840
6516
 
@@ -5862,7 +6538,7 @@ class G2 : SGCManager() {
5862
6538
  // handleTouchEvent, AFTER IMU classification — running it here would let
5863
6539
  // continuous IMU keepalive traffic swallow real taps (matches iOS).
5864
6540
  val devEventData = fields[13] as? ByteArray ?: return
5865
- handleTouchEvent(devEventData)
6541
+ handleTouchEvent(devEventData, arrival)
5866
6542
  } else if (cmdValue == 17) {
5867
6543
  // Miniapp selection from glasses dashboard menu (cmdId=17)
5868
6544
  // Dedup: L and R peripherals both deliver this event, so debounce or
@@ -6002,15 +6678,15 @@ class G2 : SGCManager() {
6002
6678
  return Triple(fx, fy, fz)
6003
6679
  }
6004
6680
 
6005
- private fun handleTouchEvent(devEventData: ByteArray) {
6681
+ private fun handleTouchEvent(devEventData: ByteArray, arrival: G2InputArrival) {
6006
6682
  // Parse SendDeviceEvent: field 1=ListEvent, field 2=TextEvent, field 3=SysEvent
6007
6683
  val reader = ProtobufReader(devEventData)
6008
6684
  val fields = reader.parseFields()
6009
6685
 
6010
- val timestamp = System.currentTimeMillis()
6686
+ val timestamp = arrival.timestamp
6011
6687
 
6012
6688
  // Classify IMU reports before applying the click debounce. Keepalive samples
6013
- // arrive continuously and must not update lastClickTimestamp, or a real gesture
6689
+ // arrive continuously and must not update the debounce, or a real gesture
6014
6690
  // delivered within 100 ms of a sample gets dropped. A malformed IMU report still
6015
6691
  // returns here — it must never fall through into gesture mapping.
6016
6692
  val parsedSysFields =
@@ -6020,6 +6696,7 @@ class G2 : SGCManager() {
6020
6696
  if ((sysFields[1] as? Int) == OsEventType.IMU_DATA_REPORT.value) {
6021
6697
  // IMU_Report_Data { x, y, z } as 32-bit floats in field 3.
6022
6698
  lastImuReportTimestamp = timestamp
6699
+ displayDiagnostics.count("imuReceived")
6023
6700
  val forwardSamples =
6024
6701
  DeviceStore.get("bluetooth", "imu_enabled") as? Boolean ?: false
6025
6702
  val imuData = sysFields[3] as? ByteArray
@@ -6034,19 +6711,19 @@ class G2 : SGCManager() {
6034
6711
  sysFields
6035
6712
  }
6036
6713
 
6037
- // tap_start/tap_end bracket every gesture: the end edge lands within the
6038
- // debounce window of its own start, and the click event fires right after
6039
- // tap_end. Exempt the edges from the debounce (check AND update) or they
6040
- // either get dropped or swallow the real gesture that follows.
6041
6714
  val sysEventTypeRaw = parsedSysFields?.get(1) as? Int
6042
- val isTapEdge =
6043
- sysEventTypeRaw == OsEventType.TAP_START.value || sysEventTypeRaw == OsEventType.TAP_END.value
6044
- if (!isTapEdge) {
6045
- val lastClick = lastClickTimestamp
6046
- if (lastClick != null && timestamp - lastClick < 100) {
6047
- return
6048
- }
6049
- lastClickTimestamp = timestamp
6715
+ val ring = parsedSysFields?.let { G2RingInput.parse(it) }
6716
+ val accepted = touchDebounce.accepts(sysEventTypeRaw, ring, arrival.elapsedMs)
6717
+ val inputLog = JSONObject().put("receivedAt", timestamp)
6718
+ .put("handledAt", System.currentTimeMillis())
6719
+ .put("dispatchMs", SystemClock.elapsedRealtime() - arrival.elapsedMs)
6720
+ .put("accepted", accepted).put("event", sysEventTypeRaw)
6721
+ .put("source", parsedSysFields?.get(2) as? Int)
6722
+ ring?.let { inputLog.put("wireType", it.wireType).put("ringTick", it.tick) }
6723
+ Bridge.log("G2INPUT $inputLog")
6724
+ if (!accepted) {
6725
+ displayDiagnostics.count("touchDebounced")
6726
+ return
6050
6727
  }
6051
6728
 
6052
6729
  // if we are receiving touch events we are fully booted:
@@ -6079,7 +6756,7 @@ class G2 : SGCManager() {
6079
6756
  val last = lastImgAckSuccessMs
6080
6757
  val sinceAck = if (last > 0) System.currentTimeMillis() - last else Long.MAX_VALUE
6081
6758
  if (sinceAck < PHANTOM_EXIT_ACK_WINDOW_MS) {
6082
- Bridge.log("G2: $eventType ignored — carrier acked ${sinceAck}ms ago (phantom exit)")
6759
+ Bridge.log("G2: $eventType ignored — a stock image acked ${sinceAck}ms ago (phantom exit)")
6083
6760
  return@let
6084
6761
  }
6085
6762
  }
@@ -6091,7 +6768,6 @@ class G2 : SGCManager() {
6091
6768
  }
6092
6769
 
6093
6770
  Bridge.sendTouchEvent(DeviceTypes.G2, gestureName, timestamp, eventSource)
6094
- Bridge.log("G2: SysEvent → $eventType $eventSource")
6095
6771
 
6096
6772
  if (eventSource == 2) {
6097
6773
  // controller must be connected and fully booted:
@@ -6153,6 +6829,7 @@ class G2 : SGCManager() {
6153
6829
  OsEventType.TAP_START -> "tap_start"
6154
6830
  OsEventType.TAP_END -> "tap_end"
6155
6831
  OsEventType.TAP_THEN_HOLD -> "tap_then_hold"
6832
+ OsEventType.RING_TOUCH_DOWN -> "touch_down"
6156
6833
  OsEventType.IMU_DATA_REPORT -> null
6157
6834
  OsEventType.ABNORMAL_EXIT -> null // don't report abnormal exits as gestures
6158
6835
  }
@@ -6302,6 +6979,12 @@ class G2 : SGCManager() {
6302
6979
  }
6303
6980
  }
6304
6981
 
6982
+ private fun publishRingBattery(battery: G2RingBattery) {
6983
+ DeviceStore.apply("glasses", "ringConnected", battery.connected)
6984
+ DeviceStore.apply("glasses", "ringBatteryLevel", battery.level)
6985
+ DeviceStore.apply("glasses", "ringCharging", battery.charging)
6986
+ }
6987
+
6305
6988
  private fun handleG2SettingResponse(payload: ByteArray) {
6306
6989
  val reader = ProtobufReader(payload)
6307
6990
  val fields = reader.parseFields()
@@ -6313,6 +6996,20 @@ class G2 : SGCManager() {
6313
6996
  ) {
6314
6997
  (fields[4] as? ByteArray)?.let { parseDeviceRequestResponse(it) }
6315
6998
  (fields[5] as? ByteArray)?.let { parseDeviceSendToApp(it) }
6999
+ (fields[105] as? ByteArray)?.let { G2Sensors.light(it) }?.let {
7000
+ Bridge.sendTypedMessage("light_sensor", it + ("timestamp" to System.currentTimeMillis()))
7001
+ }
7002
+ (fields[106] as? ByteArray)?.let { G2RingBattery.decode(it) }?.let { publishRingBattery(it) }
7003
+ (fields[129] as? ByteArray)?.takeIf { it.size >= 2 }?.let {
7004
+ Bridge.sendSceneSettled((it[0].toInt() and 0xFF) or ((it[1].toInt() and 0xFF) shl 8))
7005
+ }
7006
+ (fields[128] as? ByteArray)?.let { G2BleLink.decodeStatus(it) }?.let { status ->
7007
+ if (status != lastBleLinkStatus) {
7008
+ lastBleLinkStatus = status
7009
+ Bridge.log("G2: BLE link ${status.summary}")
7010
+ }
7011
+ }
7012
+ (fields[131] as? ByteArray)?.let { record -> onKeyboardThread { keyboardBridge.accept(record) } }
6316
7013
  updateCfwCapabilities(
6317
7014
  fields[100] as? ByteArray,
6318
7015
  definitive = cmdValue == G2SettingCommandId.DEVICE_RECEIVE_REQUEST.value,
@@ -6321,25 +7018,137 @@ class G2 : SGCManager() {
6321
7018
  }
6322
7019
  }
6323
7020
 
7021
+ // ---------- BLE link speed (CFW revision 26) ----------
7022
+
7023
+ /**
7024
+ * Revision 26 lets the phone choose the connection profile: stock (the
7025
+ * default: 15-30 ms, then the one-minute slow-mode timer) or fast (7.5 ms,
7026
+ * latency 0, slow mode suppressed). Each lens has its own link, so the
7027
+ * control (G2BleLink, field 127) goes to both arms. The flag lives in lens
7028
+ * RAM and boots stock, so a wanted FAST is re-sent on every CFW detection.
7029
+ */
7030
+ private val supportsBleLinkControl: Boolean
7031
+ get() = supportsCfwTransport
7032
+
7033
+ /** The debug setting: caller intent, read at send time. */
7034
+ private val fastBleWanted: Boolean
7035
+ get() = DeviceStore.get("bluetooth", "cfw_fast_ble") as? Boolean ?: false
7036
+
7037
+ /** Last field-128 status seen, so only changes are logged. */
7038
+ @Volatile private var lastBleLinkStatus: G2BleLink.Status? = null
7039
+
7040
+ // ---------- BLE keyboard bridge (CFW revision 39) ----------
7041
+
7042
+ /**
7043
+ * One HID-over-GATT keyboard hosted by the right lens, driven over
7044
+ * settings fields 130/131 (G2KeyboardBridge). The bridge is main-thread
7045
+ * confined; BLE-thread callers post into it.
7046
+ */
7047
+ private val keyboardScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
7048
+ private val keyboardBridge: G2KeyboardBridge by lazy {
7049
+ G2KeyboardBridge(keyboardScope, context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)) { payload ->
7050
+ sendKeyboardControl(payload)
7051
+ }
7052
+ }
7053
+
7054
+ /** Keyboard controls are settings requests for the right lens alone. */
7055
+ private fun sendKeyboardControl(payload: ByteArray) {
7056
+ val packets =
7057
+ sendManager.buildPackets(
7058
+ serviceId = ServiceID.G2_SETTING.value,
7059
+ payload = payload,
7060
+ reserveFlag = true
7061
+ )
7062
+ sendToGlasses(packets, left = false, right = true)
7063
+ }
7064
+
7065
+ private fun onKeyboardThread(block: () -> Unit) {
7066
+ if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
7067
+ }
7068
+
7069
+ suspend fun keyboardScan(): List<Map<String, Any>> =
7070
+ withContext(Dispatchers.Main.immediate) { keyboardBridge.scan().map(G2KeyboardBridge::advertValues) }
7071
+
7072
+ suspend fun keyboardConnect(address: String, addressType: Int, name: String) =
7073
+ withContext(Dispatchers.Main.immediate) { keyboardBridge.connect(address, addressType, name) }
7074
+
7075
+ suspend fun keyboardReconnect() = withContext(Dispatchers.Main.immediate) { keyboardBridge.reconnect() }
7076
+
7077
+ suspend fun keyboardDisconnect() = withContext(Dispatchers.Main.immediate) { keyboardBridge.disconnect() }
7078
+
7079
+ suspend fun keyboardForget() = withContext(Dispatchers.Main.immediate) { keyboardBridge.forget() }
7080
+
7081
+ suspend fun keyboardPasskey(pin: Int) = withContext(Dispatchers.Main.immediate) { keyboardBridge.passkey(pin) }
7082
+
7083
+ suspend fun keyboardCompare(accept: Boolean) = withContext(Dispatchers.Main.immediate) { keyboardBridge.compare(accept) }
7084
+
7085
+ suspend fun keyboardStatus(): Map<String, Any> = withContext(Dispatchers.Main.immediate) { keyboardBridge.status() }
7086
+
7087
+ private fun sendBleLinkControl(op: G2BleLink.Op) {
7088
+ val packets =
7089
+ sendManager.buildPackets(
7090
+ serviceId = ServiceID.G2_SETTING.value,
7091
+ payload = G2BleLink.controlPacket(op),
7092
+ reserveFlag = true
7093
+ )
7094
+ sendToGlasses(packets, left = true, right = true)
7095
+ Bridge.log("G2: BLE link control -> $op")
7096
+ }
7097
+
7098
+ /**
7099
+ * Push the setting after CFW detection. Only a wanted FAST is sent: the
7100
+ * firmware boots on the stock profile, so STOCK would be a no-op.
7101
+ */
7102
+ private fun syncBleLink() {
7103
+ if (!supportsBleLinkControl || !fastBleWanted) return
7104
+ sendBleLinkControl(G2BleLink.Op.FAST)
7105
+ }
7106
+
7107
+ /**
7108
+ * Live cfw_fast_ble switch. A stock device, an older CFW, or a
7109
+ * pre-classification connect reads the setting later on detection.
7110
+ */
7111
+ override suspend fun cfwFastBleToggled(enabled: Boolean) {
7112
+ if (!supportsBleLinkControl) {
7113
+ Bridge.log("G2: cfw_fast_ble ${if (enabled) "ON" else "OFF"} — no link control on this firmware; applies on next CFW detection")
7114
+ return
7115
+ }
7116
+ sendBleLinkControl(if (enabled) G2BleLink.Op.FAST else G2BleLink.Op.STOCK)
7117
+ }
7118
+
6324
7119
  private fun updateCfwCapabilities(data: ByteArray?, definitive: Boolean) {
6325
7120
  // The sticky/definitive rules live in G2CfwClassification (unit-tested);
6326
7121
  // null means the packet proved nothing and prior identity stands.
6327
7122
  val result = G2CfwClassification.classify(data, definitive, cfwCapabilityString) ?: return
6328
- cfwClassified = true
7123
+ val changed = result != cfwClassification
7124
+ cfwClassification = result
6329
7125
  if (result.isCfw) {
6330
- val changed = result.capabilityString != cfwCapabilityString
6331
- cfwCapabilityString = result.capabilityString
6332
- cfwFeatures.clear()
6333
- cfwFeatures.addAll(result.features)
6334
- cfwProtocolVersion = result.protocolVersion
6335
7126
  if (changed) {
6336
- Bridge.log("G2: Glassly CFW detected — ${result.capabilityString}")
7127
+ Bridge.log("G2: CFW detected — ${result.capabilityString}")
6337
7128
  rearmWakeLeaseIfWanted()
7129
+ syncBleLink()
7130
+ val revision = result.revision
7131
+ onKeyboardThread { keyboardBridge.cfwDetected(revision) }
6338
7132
  }
6339
7133
  } else {
6340
- cfwFeatures.clear()
6341
- cfwProtocolVersion = 0
7134
+ publishRingBattery(G2RingBattery.DISCONNECTED)
7135
+ onKeyboardThread { keyboardBridge.linkLost() }
6342
7136
  }
7137
+ publishSceneCanvas()
7138
+ }
7139
+
7140
+ /**
7141
+ * Publish the CFW scene canvas and vector-scene support into the glasses
7142
+ * status once classification lands; zeros/false on stock so a stale value
7143
+ * cannot survive a CFW→stock swap.
7144
+ */
7145
+ private fun publishSceneCanvas() {
7146
+ if (!cfwClassified) return
7147
+ val active = supportsCfwSceneRendering
7148
+ DeviceStore.apply("glasses", "sceneCanvasWidth", if (active) 576 else 0)
7149
+ DeviceStore.apply("glasses", "sceneCanvasHeight", if (active) 288 else 0)
7150
+ DeviceStore.apply("glasses", "sceneVector", supportsCfwVectorScene)
7151
+ DeviceStore.apply("glasses", "sceneSettle", supportsCfwSceneSettle)
6343
7152
  }
6344
7153
 
6345
7154
  /**
@@ -6360,9 +7169,10 @@ class G2 : SGCManager() {
6360
7169
  val baseVersion =
6361
7170
  (DeviceStore.get("glasses", "rightFirmwareVersion") as? String)
6362
7171
  ?: (DeviceStore.get("glasses", "leftFirmwareVersion") as? String).orEmpty()
7172
+ val name = cfwClassification?.displayName
6363
7173
  val displayVersion =
6364
- if (cfwCapabilityString != null) {
6365
- if (baseVersion.isEmpty()) "Glassly CFW" else "$baseVersion (Glassly CFW)"
7174
+ if (name != null) {
7175
+ if (baseVersion.isEmpty()) name else "$baseVersion ($name)"
6366
7176
  } else {
6367
7177
  baseVersion
6368
7178
  }