@glassly/bluetooth-sdk 0.1.0-dev.99 → 0.2.8-dev.444
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -6
- package/android/build.gradle +7 -7
- package/android/lc3Lib/build.gradle +2 -2
- package/android/src/main/assets/G2Firmware/README.md +27 -2
- package/android/src/main/assets/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
- package/android/src/main/assets/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +38 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +50 -14
- package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +18 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +139 -10
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +28 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +199 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +10 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +4 -4
- package/android/src/main/java/com/glassly/bluetoothsdk/GlassesNotifications.kt +155 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +1065 -255
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2BleLink.kt +119 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +29 -28
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +138 -33
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +673 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +235 -162
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransport.kt +402 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnostics.kt +84 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +9 -9
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Keyboard.kt +689 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardBridge.kt +452 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardHid.kt +208 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFile.kt +181 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2RingBattery.kt +20 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Sensors.kt +67 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2TouchInput.kt +39 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +5 -5
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +14 -14
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/{GlasslyBle.java → NexProto.java} +6462 -7618
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIds.kt +58 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +77 -2
- package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +55 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +24 -149
- package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/G2BleLinkTest.kt +70 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +45 -56
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +78 -5
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +489 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +100 -43
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransportTest.kt +252 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnosticsTest.kt +64 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardTest.kt +271 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFileTest.kt +204 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2RingBatteryTest.kt +56 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2SensorsTest.kt +56 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2TouchInputTest.kt +60 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NexProtoDescriptorTest.kt +32 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIdsTest.kt +58 -0
- package/build/BluetoothSdk.types.d.ts +209 -20
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +1 -1
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +30 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +28 -1
- package/build/generated/changelogCatalog.d.ts.map +1 -1
- package/build/generated/changelogCatalog.js +37 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +4 -4
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +14 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +102 -13
- package/ios/GlasslyBluetoothSDK.podspec +1 -1
- package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +8 -3
- package/ios/Source/BluetoothSDK.swift +53 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +13 -0
- package/ios/Source/DeviceManager.swift +151 -33
- package/ios/Source/DeviceStore.swift +28 -14
- package/ios/Source/DisplayPacer.swift +180 -0
- package/ios/Source/GeneratedChangelogCatalog.swift +10 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +4 -4
- package/ios/Source/GlassesNotifications.swift +181 -0
- package/ios/Source/ObservableStore.swift +14 -2
- package/ios/Source/Resources/G2Firmware/README.md +27 -2
- package/ios/Source/Resources/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
- package/ios/Source/Resources/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
- package/ios/Source/controllers/R1.swift +0 -1
- package/ios/Source/internal/BluetoothAvailability.swift +31 -0
- package/ios/Source/sgcs/G2.swift +1420 -495
- package/ios/Source/sgcs/G2AncsRelay.swift +268 -0
- package/ios/Source/sgcs/G2BleLink.swift +102 -0
- package/ios/Source/sgcs/G2CfwClassification.swift +45 -27
- package/ios/Source/sgcs/G2CfwRenderer.swift +166 -33
- package/ios/Source/sgcs/G2CfwScene.swift +779 -0
- package/ios/Source/sgcs/G2CfwTextureCache.swift +244 -117
- package/ios/Source/sgcs/G2CfwTransport.swift +485 -0
- package/ios/Source/sgcs/G2DisplayDiagnostics.swift +154 -0
- package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
- package/ios/Source/sgcs/G2Flasher.swift +9 -9
- package/ios/Source/sgcs/G2Keyboard.swift +780 -0
- package/ios/Source/sgcs/G2KeyboardBridge.swift +438 -0
- package/ios/Source/sgcs/G2KeyboardHid.swift +290 -0
- package/ios/Source/sgcs/G2NotificationTuning.swift +24 -0
- package/ios/Source/sgcs/G2RingBattery.swift +22 -0
- package/ios/Source/sgcs/G2Sensors.swift +75 -0
- package/ios/Source/sgcs/G2TouchInput.swift +51 -0
- package/ios/Source/sgcs/G2WritePacing.swift +18 -0
- package/ios/Source/sgcs/MentraNex.swift +44 -44
- package/ios/Source/sgcs/SGCManager.swift +120 -13
- package/ios/Source/sgcs/{glassly_ble.pb.swift → nex_ble.pb.swift} +405 -465
- package/ios/Source/speakerid/SpeakerIdEngine.swift +2 -2
- package/ios/Source/status/DeviceStatus.swift +27 -0
- package/ios/Source/stt/SherpaOnnxTranscriber.swift +15 -14
- package/ios/Source/utils/Enums.swift +0 -1
- package/ios/Source/utils/G1Text.swift +0 -1
- package/ios/Source/utils/G2NotifyFile.swift +20 -10
- package/ios/Source/utils/JSCExperiment.swift +6 -6
- package/ios/Source/utils/Models.swift +0 -1
- package/ios/Tests/BluetoothSDKTests/BluetoothAvailabilityTests.swift +29 -0
- package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
- package/ios/Tests/BluetoothSDKTests/G2AncsRelayTests.swift +186 -0
- package/ios/Tests/BluetoothSDKTests/G2BleLinkTests.swift +52 -0
- package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +46 -51
- package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +164 -5
- package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +497 -0
- package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +108 -44
- package/ios/Tests/BluetoothSDKTests/G2CfwTransportTests.swift +259 -0
- package/ios/Tests/BluetoothSDKTests/G2DisplayDiagnosticsTests.swift +89 -0
- package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +3 -2
- package/ios/Tests/BluetoothSDKTests/G2ImageAckTests.swift +104 -0
- package/ios/Tests/BluetoothSDKTests/G2KeyboardTests.swift +274 -0
- package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +21 -5
- package/ios/Tests/BluetoothSDKTests/G2RingBatteryTests.swift +56 -0
- package/ios/Tests/BluetoothSDKTests/G2SensorsTests.swift +56 -0
- package/ios/Tests/BluetoothSDKTests/G2TouchInputTests.swift +71 -0
- package/ios/Tests/BluetoothSDKTests/G2WritePacingTests.swift +35 -0
- package/ios/Tests/BluetoothSDKTests/GlassesNotificationTests.swift +95 -0
- package/package.json +1 -1
- package/plugin/build/withAndroid.js +2 -2
- package/plugin/build/withIos.js +1 -1
- package/scripts/gen-nex-proto.sh +12 -0
- package/scripts/inject-ios-sdk-version.mjs +1 -1
- package/src/BluetoothSdk.types.ts +214 -19
- package/src/_internal.ts +1 -1
- package/src/_private/BluetoothSdkModule.ts +40 -0
- package/src/generated/changelogCatalog.ts +37 -1
- package/src/generated/releaseMetadata.ts +4 -4
- package/src/index.ts +24 -0
|
@@ -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())
|
|
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)
|
|
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
|
-
/**
|
|
1445
|
-
private
|
|
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
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
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
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
private var
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
1558
|
-
*
|
|
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
|
|
1561
|
-
get()
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
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
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
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
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
2850
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2962
|
-
*
|
|
2963
|
-
*
|
|
2964
|
-
*
|
|
2965
|
-
*
|
|
2966
|
-
*
|
|
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 (
|
|
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
|
|
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
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
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
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
val
|
|
3082
|
-
val boxes = cfwBoxes(frame
|
|
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
|
|
3108
|
-
//
|
|
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 =
|
|
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 =
|
|
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
|
|
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 {
|
|
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
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
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 (
|
|
3246
|
-
Bridge.log("G2: cfw_scene_renderer ON — swapping stock containers for the CFW
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
3681
|
-
if (supportsCfwSceneRendering &&
|
|
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 &&
|
|
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
|
-
|
|
4344
|
+
dashboardShowing == 0 &&
|
|
3844
4345
|
(hasPendingText || hasPendingImage || cfwIdlePageResurrectAllowed())
|
|
3845
4346
|
) {
|
|
3846
|
-
//
|
|
3847
|
-
//
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
3902
|
-
|
|
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
|
-
|
|
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.
|
|
3991
|
-
// can
|
|
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 =
|
|
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
|
|
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
|
-
|
|
4095
|
-
|
|
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
|
|
4221
|
-
//
|
|
4222
|
-
//
|
|
4223
|
-
//
|
|
4224
|
-
//
|
|
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
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
4717
|
-
}
|
|
5366
|
+
override fun dbg1() {}
|
|
5367
|
+
|
|
5368
|
+
override fun dbg2() {}
|
|
4718
5369
|
|
|
4719
|
-
|
|
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
|
|
4722
|
-
|
|
4723
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
|
|
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 =
|
|
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
|
|
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
|
|
6043
|
-
|
|
6044
|
-
|
|
6045
|
-
|
|
6046
|
-
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
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 —
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
6341
|
-
|
|
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 (
|
|
6365
|
-
if (baseVersion.isEmpty())
|
|
7174
|
+
if (name != null) {
|
|
7175
|
+
if (baseVersion.isEmpty()) name else "$baseVersion ($name)"
|
|
6366
7176
|
} else {
|
|
6367
7177
|
baseVersion
|
|
6368
7178
|
}
|