@glassly/bluetooth-sdk 0.1.0-dev.98 → 0.2.8-dev.444
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -6
- package/android/build.gradle +7 -7
- package/android/lc3Lib/build.gradle +2 -2
- package/android/src/main/assets/G2Firmware/README.md +27 -2
- package/android/src/main/assets/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
- package/android/src/main/assets/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +38 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +50 -14
- package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +18 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +139 -10
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +28 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +199 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +10 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +4 -4
- package/android/src/main/java/com/glassly/bluetoothsdk/GlassesNotifications.kt +155 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +1075 -257
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2BleLink.kt +119 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +29 -28
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +138 -33
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +673 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +235 -162
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransport.kt +402 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnostics.kt +84 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +9 -9
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Keyboard.kt +689 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardBridge.kt +452 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardHid.kt +208 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFile.kt +181 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2RingBattery.kt +20 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Sensors.kt +67 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2TouchInput.kt +39 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +5 -5
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +14 -14
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/{GlasslyBle.java → NexProto.java} +6462 -7618
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIds.kt +58 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +77 -2
- package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +55 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +24 -149
- package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/G2BleLinkTest.kt +70 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +45 -56
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +78 -5
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +489 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +100 -43
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransportTest.kt +252 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnosticsTest.kt +64 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardTest.kt +271 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFileTest.kt +204 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2RingBatteryTest.kt +56 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2SensorsTest.kt +56 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2TouchInputTest.kt +60 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NexProtoDescriptorTest.kt +32 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIdsTest.kt +58 -0
- package/build/BluetoothSdk.types.d.ts +209 -20
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +1 -1
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +30 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +28 -1
- package/build/generated/changelogCatalog.d.ts.map +1 -1
- package/build/generated/changelogCatalog.js +37 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +4 -4
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +14 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +102 -13
- package/ios/GlasslyBluetoothSDK.podspec +1 -1
- package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +8 -3
- package/ios/Source/BluetoothSDK.swift +53 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +13 -0
- package/ios/Source/DeviceManager.swift +151 -33
- package/ios/Source/DeviceStore.swift +28 -14
- package/ios/Source/DisplayPacer.swift +180 -0
- package/ios/Source/GeneratedChangelogCatalog.swift +10 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +4 -4
- package/ios/Source/GlassesNotifications.swift +181 -0
- package/ios/Source/ObservableStore.swift +14 -2
- package/ios/Source/Resources/G2Firmware/README.md +27 -2
- package/ios/Source/Resources/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
- package/ios/Source/Resources/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
- package/ios/Source/controllers/R1.swift +0 -1
- package/ios/Source/internal/BluetoothAvailability.swift +31 -0
- package/ios/Source/sgcs/G2.swift +1434 -504
- package/ios/Source/sgcs/G2AncsRelay.swift +268 -0
- package/ios/Source/sgcs/G2BleLink.swift +102 -0
- package/ios/Source/sgcs/G2CfwClassification.swift +45 -27
- package/ios/Source/sgcs/G2CfwRenderer.swift +166 -33
- package/ios/Source/sgcs/G2CfwScene.swift +779 -0
- package/ios/Source/sgcs/G2CfwTextureCache.swift +244 -117
- package/ios/Source/sgcs/G2CfwTransport.swift +485 -0
- package/ios/Source/sgcs/G2DisplayDiagnostics.swift +154 -0
- package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
- package/ios/Source/sgcs/G2Flasher.swift +9 -9
- package/ios/Source/sgcs/G2Keyboard.swift +780 -0
- package/ios/Source/sgcs/G2KeyboardBridge.swift +438 -0
- package/ios/Source/sgcs/G2KeyboardHid.swift +290 -0
- package/ios/Source/sgcs/G2NotificationTuning.swift +24 -0
- package/ios/Source/sgcs/G2RingBattery.swift +22 -0
- package/ios/Source/sgcs/G2Sensors.swift +75 -0
- package/ios/Source/sgcs/G2TouchInput.swift +51 -0
- package/ios/Source/sgcs/G2WritePacing.swift +18 -0
- package/ios/Source/sgcs/MentraNex.swift +44 -44
- package/ios/Source/sgcs/SGCManager.swift +120 -13
- package/ios/Source/sgcs/{glassly_ble.pb.swift → nex_ble.pb.swift} +405 -465
- package/ios/Source/speakerid/SpeakerIdEngine.swift +2 -2
- package/ios/Source/status/DeviceStatus.swift +27 -0
- package/ios/Source/stt/SherpaOnnxTranscriber.swift +15 -14
- package/ios/Source/utils/Enums.swift +0 -1
- package/ios/Source/utils/G1Text.swift +0 -1
- package/ios/Source/utils/G2NotifyFile.swift +20 -10
- package/ios/Source/utils/JSCExperiment.swift +6 -6
- package/ios/Source/utils/Models.swift +0 -1
- package/ios/Tests/BluetoothSDKTests/BluetoothAvailabilityTests.swift +29 -0
- package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
- package/ios/Tests/BluetoothSDKTests/G2AncsRelayTests.swift +186 -0
- package/ios/Tests/BluetoothSDKTests/G2BleLinkTests.swift +52 -0
- package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +46 -51
- package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +164 -5
- package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +497 -0
- package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +108 -44
- package/ios/Tests/BluetoothSDKTests/G2CfwTransportTests.swift +259 -0
- package/ios/Tests/BluetoothSDKTests/G2DisplayDiagnosticsTests.swift +89 -0
- package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +3 -2
- package/ios/Tests/BluetoothSDKTests/G2ImageAckTests.swift +104 -0
- package/ios/Tests/BluetoothSDKTests/G2KeyboardTests.swift +274 -0
- package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +21 -5
- package/ios/Tests/BluetoothSDKTests/G2RingBatteryTests.swift +56 -0
- package/ios/Tests/BluetoothSDKTests/G2SensorsTests.swift +56 -0
- package/ios/Tests/BluetoothSDKTests/G2TouchInputTests.swift +71 -0
- package/ios/Tests/BluetoothSDKTests/G2WritePacingTests.swift +35 -0
- package/ios/Tests/BluetoothSDKTests/GlassesNotificationTests.swift +95 -0
- package/package.json +1 -1
- package/plugin/build/withAndroid.js +2 -2
- package/plugin/build/withIos.js +1 -1
- package/scripts/gen-nex-proto.sh +12 -0
- package/scripts/inject-ios-sdk-version.mjs +1 -1
- package/src/BluetoothSdk.types.ts +214 -19
- package/src/_internal.ts +1 -1
- package/src/_private/BluetoothSdkModule.ts +40 -0
- package/src/generated/changelogCatalog.ts +37 -1
- package/src/generated/releaseMetadata.ts +4 -4
- package/src/index.ts +24 -0
|
@@ -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),
|
|
@@ -267,8 +303,16 @@ private enum class OsEventType(val value: Int) {
|
|
|
267
303
|
ABNORMAL_EXIT(6),
|
|
268
304
|
SYSTEM_EXIT(7),
|
|
269
305
|
IMU_DATA_REPORT(8), // IMU_DATA_REPORT — Sys_ItemEvent carries imuData
|
|
270
|
-
|
|
271
|
-
|
|
306
|
+
// 9/10 are the SDK 0.0.14 LONG_PRESS/RELEASE pair; the 2.2.9 CFW forwards
|
|
307
|
+
// them while our fbguard lease is active. Names stay tap_start/tap_end.
|
|
308
|
+
TAP_START(9), // press recognized (hold start)
|
|
309
|
+
TAP_END(10), // released
|
|
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);
|
|
272
316
|
|
|
273
317
|
companion object {
|
|
274
318
|
fun fromInt(v: Int): OsEventType? = entries.find { it.value == v }
|
|
@@ -450,7 +494,8 @@ private object EvenHubProto {
|
|
|
450
494
|
containerID: Int,
|
|
451
495
|
containerName: String? = null,
|
|
452
496
|
isEventCapture: Boolean = false,
|
|
453
|
-
content: String? = null
|
|
497
|
+
content: String? = null,
|
|
498
|
+
textColor: Int? = null
|
|
454
499
|
): ByteArray {
|
|
455
500
|
val w = ProtobufWriter()
|
|
456
501
|
w.writeInt32Field(1, x)
|
|
@@ -465,6 +510,9 @@ private object EvenHubProto {
|
|
|
465
510
|
containerName?.let { w.writeStringField(10, it) }
|
|
466
511
|
w.writeInt32Field(11, if (isEventCapture) 1 else 0)
|
|
467
512
|
content?.let { w.writeStringField(12, it) }
|
|
513
|
+
// SDK 0.0.14 text brightness level 0-4 (4 = default). Field number is a
|
|
514
|
+
// guess: proto dump is 2.1.0-era (last field 12) — verify on-device.
|
|
515
|
+
textColor?.let { w.writeInt32Field(13, it) }
|
|
468
516
|
return w.toByteArray()
|
|
469
517
|
}
|
|
470
518
|
|
|
@@ -1434,23 +1482,31 @@ class G2 : SGCManager() {
|
|
|
1434
1482
|
hasMic = true
|
|
1435
1483
|
}
|
|
1436
1484
|
|
|
1437
|
-
/**
|
|
1438
|
-
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
|
|
1439
1489
|
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
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
|
+
}
|
|
1447
1501
|
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
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
|
|
1454
1510
|
private var cfwSceneActive = false
|
|
1455
1511
|
|
|
1456
1512
|
/**
|
|
@@ -1460,7 +1516,19 @@ class G2 : SGCManager() {
|
|
|
1460
1516
|
* down we back off instead of trading rebuilds with it once per tick.
|
|
1461
1517
|
*/
|
|
1462
1518
|
private var cfwLastIdleResurrectAt: Long? = null
|
|
1463
|
-
|
|
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
|
|
1464
1532
|
|
|
1465
1533
|
/**
|
|
1466
1534
|
* Queued CFW payloads are tagged so coalescing keeps the chain sound:
|
|
@@ -1469,9 +1537,16 @@ class G2 : SGCManager() {
|
|
|
1469
1537
|
* fulls/deltas. SEED (mode-6 black) and MOVE_BUNDLE (blank of an old
|
|
1470
1538
|
* window position) always survive.
|
|
1471
1539
|
*/
|
|
1472
|
-
private enum class CfwPayloadKind { SEED, FULL, DELTA, MOVE_BUNDLE, UPLOAD }
|
|
1540
|
+
private enum class CfwPayloadKind { SEED, FULL, DELTA, MOVE_BUNDLE, UPLOAD, SCENE }
|
|
1473
1541
|
|
|
1474
|
-
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
|
+
)
|
|
1475
1550
|
|
|
1476
1551
|
private val cfwPendingPayloads = ArrayDeque<CfwPayload>()
|
|
1477
1552
|
private var cfwShadowSeeded = false
|
|
@@ -1530,40 +1605,45 @@ class G2 : SGCManager() {
|
|
|
1530
1605
|
// CFW capability string is seen again.
|
|
1531
1606
|
private var wakeLeaseWanted = false
|
|
1532
1607
|
|
|
1533
|
-
private val supportsCfwSceneRendering: Boolean
|
|
1534
|
-
get() =
|
|
1535
|
-
cfwCapabilityString != null &&
|
|
1536
|
-
listOf("img640", "imgz", "rle", "directfb").all { it in cfwFeatures } &&
|
|
1537
|
-
// Escape hatch (default ON): OFF falls back to stock containers. Reconnect to switch cleanly.
|
|
1538
|
-
(DeviceStore.get("bluetooth", "cfw_scene_renderer") as? Boolean ?: true)
|
|
1539
|
-
|
|
1540
|
-
private val hasCfwCarrier: Boolean
|
|
1541
|
-
get() =
|
|
1542
|
-
imageContainers.size == 1 &&
|
|
1543
|
-
imageContainers[0].id == cfwSceneContainerId &&
|
|
1544
|
-
imageContainers[0].x == 0 &&
|
|
1545
|
-
imageContainers[0].y == 0 &&
|
|
1546
|
-
imageContainers[0].width == 576 &&
|
|
1547
|
-
imageContainers[0].height == 288
|
|
1548
|
-
|
|
1549
1608
|
/**
|
|
1550
|
-
*
|
|
1551
|
-
*
|
|
1609
|
+
* Glassly CFW at or above the supported revision. Anything else (stock,
|
|
1610
|
+
* upstream, older Glassly builds) is treated as stock.
|
|
1552
1611
|
*/
|
|
1553
|
-
private val
|
|
1554
|
-
get()
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
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
|
+
}
|
|
1559
1617
|
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
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)
|
|
1563
1623
|
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
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
|
|
1567
1647
|
|
|
1568
1648
|
// BLE
|
|
1569
1649
|
private val context: Context
|
|
@@ -1581,6 +1661,8 @@ class G2 : SGCManager() {
|
|
|
1581
1661
|
private var rightOtaDataNotifyChar: BluetoothGattCharacteristic? = null
|
|
1582
1662
|
private var leftAudioChar: BluetoothGattCharacteristic? = null
|
|
1583
1663
|
private var rightAudioChar: BluetoothGattCharacteristic? = null
|
|
1664
|
+
private var leftFileWriteChar: BluetoothGattCharacteristic? = null
|
|
1665
|
+
private var rightFileWriteChar: BluetoothGattCharacteristic? = null
|
|
1584
1666
|
private var leftInitialized: Boolean = false
|
|
1585
1667
|
private var rightInitialized: Boolean = false
|
|
1586
1668
|
private var isDisconnecting = false
|
|
@@ -1727,12 +1809,26 @@ class G2 : SGCManager() {
|
|
|
1727
1809
|
private var recoveryInFlight = false
|
|
1728
1810
|
private var lastRecoveryRebuildMs: Long = 0
|
|
1729
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
|
|
1730
1825
|
|
|
1731
1826
|
// Dashboard menu state
|
|
1732
1827
|
private var menuAppIdToPackageName: MutableMap<Int, String> = mutableMapOf()
|
|
1733
1828
|
private var dashboardMenuItems: MutableList<MenuProto.MenuItem> = mutableListOf()
|
|
1734
1829
|
private var activeMenuAppId: Int? = null
|
|
1735
|
-
private var
|
|
1830
|
+
private var touchDebounce = G2TouchDebounce()
|
|
1831
|
+
private val displayDiagnostics = G2DisplayDiagnostics(SystemClock.elapsedRealtime())
|
|
1736
1832
|
/** Last IMU report arrival — the data stream doubles as IMU-control delivery confirmation. */
|
|
1737
1833
|
private var lastImuReportTimestamp: Long? = null
|
|
1738
1834
|
private var lastEvenHubResponseTimestamp: Long? = null
|
|
@@ -1928,7 +2024,10 @@ class G2 : SGCManager() {
|
|
|
1928
2024
|
rightGatt?.let { gatt ->
|
|
1929
2025
|
char.value = packet
|
|
1930
2026
|
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
|
1931
|
-
|
|
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)
|
|
1932
2031
|
}
|
|
1933
2032
|
}
|
|
1934
2033
|
}
|
|
@@ -1937,7 +2036,10 @@ class G2 : SGCManager() {
|
|
|
1937
2036
|
leftGatt?.let { gatt ->
|
|
1938
2037
|
char.value = packet
|
|
1939
2038
|
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
|
1940
|
-
|
|
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)
|
|
1941
2043
|
}
|
|
1942
2044
|
}
|
|
1943
2045
|
}
|
|
@@ -1946,7 +2048,8 @@ class G2 : SGCManager() {
|
|
|
1946
2048
|
private fun sendToGlasses(
|
|
1947
2049
|
packets: List<ByteArray>,
|
|
1948
2050
|
left: Boolean = false,
|
|
1949
|
-
right: Boolean = true
|
|
2051
|
+
right: Boolean = true,
|
|
2052
|
+
isPending: () -> Boolean = { true }
|
|
1950
2053
|
) {
|
|
1951
2054
|
// OTA owns both links exclusively. Normal heartbeats/display/settings traffic must not
|
|
1952
2055
|
// interleave with its AA21 frame stream.
|
|
@@ -1955,6 +2058,10 @@ class G2 : SGCManager() {
|
|
|
1955
2058
|
if (packets.isEmpty()) return
|
|
1956
2059
|
// Single-packet sends (the common case for text/settings) go straight through.
|
|
1957
2060
|
if (packets.size == 1) {
|
|
2061
|
+
if (!isPending()) {
|
|
2062
|
+
displayDiagnostics.count("txExpiredPackets")
|
|
2063
|
+
return
|
|
2064
|
+
}
|
|
1958
2065
|
writeOnePacket(packets[0], left, right)
|
|
1959
2066
|
return
|
|
1960
2067
|
}
|
|
@@ -1962,8 +2069,14 @@ class G2 : SGCManager() {
|
|
|
1962
2069
|
// write thread with a blocking gap between packets, so the Android BLE stack can drain each
|
|
1963
2070
|
// write before the next is queued WITHOUT occupying the main looper (which also carries the
|
|
1964
2071
|
// image ACK and would otherwise starve it under load — see [bleWriteExecutor]).
|
|
2072
|
+
val queuedAt = SystemClock.elapsedRealtime()
|
|
1965
2073
|
bleWriteExecutor.execute {
|
|
1966
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)
|
|
1967
2080
|
writeOnePacket(packets[i], left, right)
|
|
1968
2081
|
if (i < packets.size - 1) {
|
|
1969
2082
|
try {
|
|
@@ -2190,6 +2303,7 @@ class G2 : SGCManager() {
|
|
|
2190
2303
|
|
|
2191
2304
|
sendMenuApps()
|
|
2192
2305
|
sendStoredCalendarEvents()
|
|
2306
|
+
reapplyStoredNotificationConfig()
|
|
2193
2307
|
|
|
2194
2308
|
// Re-apply the IMU preference: the store only pushes imu_enabled to the glasses when
|
|
2195
2309
|
// the value changes, so after a reconnect an already-on IMU would otherwise stay off
|
|
@@ -2270,6 +2384,15 @@ class G2 : SGCManager() {
|
|
|
2270
2384
|
displayScope.launch {
|
|
2271
2385
|
while (isActive) {
|
|
2272
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()) }
|
|
2273
2396
|
signal.trySend(Unit)
|
|
2274
2397
|
}
|
|
2275
2398
|
}
|
|
@@ -2346,6 +2469,263 @@ class G2 : SGCManager() {
|
|
|
2346
2469
|
sendCalendarEvents(calendarEvents)
|
|
2347
2470
|
}
|
|
2348
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
|
+
|
|
2349
2729
|
// ---------- SGCManager: Display Control ----------
|
|
2350
2730
|
|
|
2351
2731
|
override fun sendText(text: String) {
|
|
@@ -2408,9 +2788,7 @@ class G2 : SGCManager() {
|
|
|
2408
2788
|
// Bridge.log("G2: sendTextWall(${text.take(50)}...)")
|
|
2409
2789
|
|
|
2410
2790
|
// ignore events while the ER dashboard is open:
|
|
2411
|
-
|
|
2412
|
-
DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
|
|
2413
|
-
if (useNativeDashboard && dashboardShowing > 0) {
|
|
2791
|
+
if (dashboardShowing > 0) {
|
|
2414
2792
|
return
|
|
2415
2793
|
}
|
|
2416
2794
|
|
|
@@ -2433,7 +2811,7 @@ class G2 : SGCManager() {
|
|
|
2433
2811
|
if (supportsCfwSceneRendering) {
|
|
2434
2812
|
if (text.isBlank()) {
|
|
2435
2813
|
// A blank wall is the idle state: keep the carrier alive for touch and mic.
|
|
2436
|
-
val carrierWasConfigured =
|
|
2814
|
+
val carrierWasConfigured = installCfwPageIfNeeded()
|
|
2437
2815
|
cfwClear()
|
|
2438
2816
|
if (!carrierWasConfigured && pageCreated) {
|
|
2439
2817
|
// Carrier just replaced live stock containers (see clearDisplay).
|
|
@@ -2541,7 +2919,7 @@ class G2 : SGCManager() {
|
|
|
2541
2919
|
// Idle on CFW = the black carrier page, kept up for touch + mic.
|
|
2542
2920
|
// Install it even on a first-ever clear (no scene rendered yet) so
|
|
2543
2921
|
// the idle state is the same page everywhere.
|
|
2544
|
-
val carrierWasConfigured =
|
|
2922
|
+
val carrierWasConfigured = installCfwPageIfNeeded()
|
|
2545
2923
|
cfwClear()
|
|
2546
2924
|
if (!carrierWasConfigured || !pageCreated) signalDisplayDirty()
|
|
2547
2925
|
if (!carrierWasConfigured && pageCreated) {
|
|
@@ -2621,8 +2999,7 @@ class G2 : SGCManager() {
|
|
|
2621
2999
|
val rh = height ?: defaultImgHeight
|
|
2622
3000
|
|
|
2623
3001
|
// ignore events while the ER dashboard is open:
|
|
2624
|
-
|
|
2625
|
-
if (useNativeDashboard && dashboardShowing > 0) {
|
|
3002
|
+
if (dashboardShowing > 0) {
|
|
2626
3003
|
return false
|
|
2627
3004
|
}
|
|
2628
3005
|
|
|
@@ -2830,6 +3207,8 @@ class G2 : SGCManager() {
|
|
|
2830
3207
|
coalescedPageRebuild()
|
|
2831
3208
|
}
|
|
2832
3209
|
}
|
|
3210
|
+
// Stock firmware: text sends are unacked; the verbs above are the best signal there is.
|
|
3211
|
+
frame.reportPresented(true)
|
|
2833
3212
|
}
|
|
2834
3213
|
}
|
|
2835
3214
|
|
|
@@ -2839,23 +3218,13 @@ class G2 : SGCManager() {
|
|
|
2839
3218
|
* page. Returns true if it was ALREADY configured, so callers can tell
|
|
2840
3219
|
* whether they need a page rebuild.
|
|
2841
3220
|
*/
|
|
2842
|
-
private fun
|
|
2843
|
-
if (
|
|
3221
|
+
private fun installCfwPageIfNeeded(): Boolean {
|
|
3222
|
+
if (hasCfwPage) return true
|
|
2844
3223
|
textContainers.clear()
|
|
2845
3224
|
sceneTextByElement.clear()
|
|
2846
3225
|
sceneImageByElement.clear()
|
|
2847
3226
|
imageContainers.clear()
|
|
2848
|
-
|
|
2849
|
-
ImgContainer(
|
|
2850
|
-
id = cfwSceneContainerId,
|
|
2851
|
-
x = 0,
|
|
2852
|
-
y = 0,
|
|
2853
|
-
width = 576,
|
|
2854
|
-
height = 288,
|
|
2855
|
-
bmpData = ByteArray(0),
|
|
2856
|
-
dirty = false,
|
|
2857
|
-
)
|
|
2858
|
-
)
|
|
3227
|
+
cfwPageInstalled = true
|
|
2859
3228
|
cfwResetShadow()
|
|
2860
3229
|
return false
|
|
2861
3230
|
}
|
|
@@ -2878,13 +3247,23 @@ class G2 : SGCManager() {
|
|
|
2878
3247
|
|
|
2879
3248
|
/** Install the single carrier and render the whole scene through CFW mode 3. */
|
|
2880
3249
|
private suspend fun applyCfwScene(frame: SceneFrame) {
|
|
2881
|
-
|
|
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()
|
|
2882
3257
|
cfwLastFrame = frame
|
|
2883
3258
|
// Lease before the frame: a fresh FB_ACQUIRE resets preservation, and
|
|
2884
3259
|
// the frame queued below is what re-arms it.
|
|
2885
3260
|
startFbLease()
|
|
2886
|
-
|
|
3261
|
+
val encodeAt = SystemClock.elapsedRealtime()
|
|
3262
|
+
val encoded = enqueueCfwFrame(frame)
|
|
3263
|
+
displayDiagnostics.timing("encodeMs", SystemClock.elapsedRealtime() - encodeAt)
|
|
3264
|
+
if (!encoded) {
|
|
2887
3265
|
Bridge.log("G2: CFW scene encode failed; retaining the previous frame")
|
|
3266
|
+
frame.reportPresented(false, "scene encode failed")
|
|
2888
3267
|
return
|
|
2889
3268
|
}
|
|
2890
3269
|
cfwSceneActive = true
|
|
@@ -2895,21 +3274,12 @@ class G2 : SGCManager() {
|
|
|
2895
3274
|
}
|
|
2896
3275
|
}
|
|
2897
3276
|
|
|
2898
|
-
/**
|
|
2899
|
-
|
|
2900
|
-
* `pinDepth` forces depth level 1, the only level where both lenses share
|
|
2901
|
-
* an x offset — required whenever the frame uses mode-15 text draws,
|
|
2902
|
-
* which have no per-lens variant and would otherwise land 16 px apart
|
|
2903
|
-
* from the window content (double vision). Height still follows the user.
|
|
2904
|
-
*/
|
|
2905
|
-
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 {
|
|
2906
3279
|
val height = (DeviceStore.get("bluetooth", "dashboard_height") as? Number)?.toInt() ?: 4
|
|
2907
|
-
val depth =
|
|
3280
|
+
val depth = (DeviceStore.get("bluetooth", "dashboard_depth") as? Number)?.toInt() ?: 2
|
|
2908
3281
|
return G2CfwRenderer.lensBoxes(
|
|
2909
|
-
frame.canvasWidth,
|
|
2910
|
-
frame.canvasHeight,
|
|
2911
|
-
height,
|
|
2912
|
-
depth,
|
|
3282
|
+
frame.canvasWidth, frame.canvasHeight, height, G2CfwRenderer.depthLevelForSetting(depth),
|
|
2913
3283
|
)
|
|
2914
3284
|
}
|
|
2915
3285
|
|
|
@@ -2921,16 +3291,21 @@ class G2 : SGCManager() {
|
|
|
2921
3291
|
return cfwFrameId
|
|
2922
3292
|
}
|
|
2923
3293
|
|
|
2924
|
-
|
|
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")
|
|
2925
3297
|
cfwPendingPayloads.clear()
|
|
3298
|
+
cfwSceneInvalid = true
|
|
2926
3299
|
cfwShadowSeeded = false
|
|
2927
3300
|
cfwLastBoxes = null
|
|
2928
3301
|
cfwPrevRaster = null
|
|
2929
3302
|
cfwLastTextDraws = emptyList()
|
|
2930
3303
|
cfwDeltasSinceFull = 0
|
|
2931
|
-
|
|
3304
|
+
if (keepCache) return
|
|
3305
|
+
// Queued (unsent) uploads were just dropped, so residency is
|
|
2932
3306
|
// unknown — make the renderer's cache model re-learn.
|
|
2933
3307
|
cfwCacheGeneration += 1
|
|
3308
|
+
cfwCacheEpoch = 0
|
|
2934
3309
|
}
|
|
2935
3310
|
|
|
2936
3311
|
/**
|
|
@@ -2951,15 +3326,18 @@ class G2 : SGCManager() {
|
|
|
2951
3326
|
}
|
|
2952
3327
|
|
|
2953
3328
|
/**
|
|
2954
|
-
* Acquire the fbguard lease and keep renewing it
|
|
2955
|
-
*
|
|
2956
|
-
*
|
|
2957
|
-
*
|
|
2958
|
-
*
|
|
2959
|
-
*
|
|
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.
|
|
2960
3338
|
*/
|
|
2961
3339
|
private fun startFbLease() {
|
|
2962
|
-
if (fbLeaseJob != null) return
|
|
3340
|
+
if (fbLeaseJob != null || notificationsPanelHold) return
|
|
2963
3341
|
sendFaceclawFbControl(acquire = true)
|
|
2964
3342
|
fbLeaseJob =
|
|
2965
3343
|
displayScope.launch {
|
|
@@ -2969,7 +3347,7 @@ class G2 : SGCManager() {
|
|
|
2969
3347
|
// hatch may have been flipped off mid-session — without
|
|
2970
3348
|
// this check the lease renews forever while frames go
|
|
2971
3349
|
// stock.
|
|
2972
|
-
if (
|
|
3350
|
+
if (pageCreated && dashboardShowing == 0 && !navigationModeActive &&
|
|
2973
3351
|
supportsCfwSceneRendering
|
|
2974
3352
|
) {
|
|
2975
3353
|
sendFaceclawFbControl(acquire = true)
|
|
@@ -2985,8 +3363,9 @@ class G2 : SGCManager() {
|
|
|
2985
3363
|
fbLeaseJob?.cancel()
|
|
2986
3364
|
fbLeaseJob = null
|
|
2987
3365
|
if (sendRelease) sendFaceclawFbControl(acquire = false)
|
|
2988
|
-
// Lease end frees the on-glass
|
|
3366
|
+
// Lease end frees the on-glass object cache; the model must re-learn.
|
|
2989
3367
|
cfwCacheGeneration += 1
|
|
3368
|
+
cfwCacheEpoch = 0
|
|
2990
3369
|
}
|
|
2991
3370
|
|
|
2992
3371
|
/**
|
|
@@ -3041,10 +3420,13 @@ class G2 : SGCManager() {
|
|
|
3041
3420
|
}
|
|
3042
3421
|
|
|
3043
3422
|
private fun resetCfwConnectionState() {
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
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
|
|
3048
3430
|
cfwSceneActive = false
|
|
3049
3431
|
// Fresh connection gets a fresh idle-resurrect budget.
|
|
3050
3432
|
cfwLastIdleResurrectAt = null
|
|
@@ -3065,14 +3447,64 @@ class G2 : SGCManager() {
|
|
|
3065
3447
|
* change is large, the queue backed up, or on the periodic insurance
|
|
3066
3448
|
* keyframe. Seeds the shadow with mode-6 black when it is fresh.
|
|
3067
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
|
+
|
|
3068
3495
|
private suspend fun enqueueCfwFrame(frame: SceneFrame): Boolean {
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
val
|
|
3075
|
-
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
|
|
3076
3508
|
val previousBoxes = cfwLastBoxes
|
|
3077
3509
|
val movedBoxes = previousBoxes != null && previousBoxes != boxes
|
|
3078
3510
|
val backlogged =
|
|
@@ -3097,8 +3529,8 @@ class G2 : SGCManager() {
|
|
|
3097
3529
|
// Mode 15 doesn't clip at the window's right edge: a line the phone's
|
|
3098
3530
|
// metrics under-measured can spill into the outer panel margin, where
|
|
3099
3531
|
// no in-window rect or full frame ever repaints. Blank the old
|
|
3100
|
-
// window's right margin whenever draws change. (Builtin
|
|
3101
|
-
//
|
|
3532
|
+
// window's right margin whenever draws change. (Builtin draws are only
|
|
3533
|
+
// used when the old window is lens-equal.)
|
|
3102
3534
|
val marginBlank =
|
|
3103
3535
|
cfwLastBoxes?.takeIf {
|
|
3104
3536
|
textDraws != cfwLastTextDraws && cfwLastTextDraws.isNotEmpty() &&
|
|
@@ -3115,7 +3547,7 @@ class G2 : SGCManager() {
|
|
|
3115
3547
|
val fids = List(G2CfwRenderer.REQUIRED_FID_COUNT) { nextCfwFrameId() }
|
|
3116
3548
|
// Copies are unsafe around firmware-drawn text (they would smear
|
|
3117
3549
|
// glyphs the raster knows nothing about), so text frames skip them.
|
|
3118
|
-
val allowCopy =
|
|
3550
|
+
val allowCopy = textDraws.isEmpty()
|
|
3119
3551
|
val update =
|
|
3120
3552
|
kotlinx.coroutines.withContext(Dispatchers.Default) {
|
|
3121
3553
|
G2CfwRenderer.encodeUpdate(
|
|
@@ -3130,7 +3562,7 @@ class G2 : SGCManager() {
|
|
|
3130
3562
|
marginBlankBoxes = marginBlank,
|
|
3131
3563
|
omitBuiltinText = useBuiltinText,
|
|
3132
3564
|
textureGlyphsEnabled = useTextureGlyphs,
|
|
3133
|
-
textureImagesEnabled =
|
|
3565
|
+
textureImagesEnabled = useBuiltinText || useTextureGlyphs,
|
|
3134
3566
|
cacheGeneration = cfwCacheGeneration,
|
|
3135
3567
|
)
|
|
3136
3568
|
} ?: return false
|
|
@@ -3142,7 +3574,12 @@ class G2 : SGCManager() {
|
|
|
3142
3574
|
for (upload in update.uploads) {
|
|
3143
3575
|
cfwPendingPayloads.addLast(CfwPayload(CfwPayloadKind.UPLOAD, upload))
|
|
3144
3576
|
}
|
|
3145
|
-
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
|
+
}
|
|
3146
3583
|
if (update.usedFullFrame) cfwDeltasSinceFull = 0 else cfwDeltasSinceFull += 1
|
|
3147
3584
|
if (!cfwShadowSeeded) {
|
|
3148
3585
|
// Seed with the frame itself composited on black when the window
|
|
@@ -3164,8 +3601,10 @@ class G2 : SGCManager() {
|
|
|
3164
3601
|
cfwShadowSeeded = true
|
|
3165
3602
|
if (textDraws.isEmpty()) {
|
|
3166
3603
|
cfwPendingPayloads.removeAll {
|
|
3167
|
-
it.kind == CfwPayloadKind.FULL || it.kind == CfwPayloadKind.DELTA
|
|
3604
|
+
it.kind == CfwPayloadKind.FULL || it.kind == CfwPayloadKind.DELTA ||
|
|
3605
|
+
it.kind == CfwPayloadKind.SCENE
|
|
3168
3606
|
}
|
|
3607
|
+
cfwPendingPayloads.last().presents = frame.presentTag
|
|
3169
3608
|
cfwLastPayloadQueuedAt = System.currentTimeMillis()
|
|
3170
3609
|
return true
|
|
3171
3610
|
}
|
|
@@ -3178,16 +3617,29 @@ class G2 : SGCManager() {
|
|
|
3178
3617
|
}
|
|
3179
3618
|
if (update.usedFullFrame) {
|
|
3180
3619
|
// A full overwrites the window; seeds, uploads, and move-blanks must still survive.
|
|
3181
|
-
cfwPendingPayloads.removeAll {
|
|
3620
|
+
cfwPendingPayloads.removeAll {
|
|
3621
|
+
it.kind == CfwPayloadKind.FULL || it.kind == CfwPayloadKind.DELTA || it.kind == CfwPayloadKind.SCENE
|
|
3622
|
+
}
|
|
3182
3623
|
val kind = if (movedBoxes) CfwPayloadKind.MOVE_BUNDLE else CfwPayloadKind.FULL
|
|
3183
|
-
cfwPendingPayloads.addLast(CfwPayload(kind, payload))
|
|
3624
|
+
cfwPendingPayloads.addLast(CfwPayload(kind, payload, frame.presentTag))
|
|
3184
3625
|
} else {
|
|
3185
|
-
cfwPendingPayloads.addLast(CfwPayload(CfwPayloadKind.DELTA, payload))
|
|
3626
|
+
cfwPendingPayloads.addLast(CfwPayload(CfwPayloadKind.DELTA, payload, frame.presentTag))
|
|
3186
3627
|
}
|
|
3187
3628
|
cfwLastPayloadQueuedAt = System.currentTimeMillis()
|
|
3188
3629
|
return true
|
|
3189
3630
|
}
|
|
3190
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
|
+
|
|
3191
3643
|
/**
|
|
3192
3644
|
* Blank the panel (mode-6 black also seeds the shadow) and drop retained scene state.
|
|
3193
3645
|
*
|
|
@@ -3198,13 +3650,29 @@ class G2 : SGCManager() {
|
|
|
3198
3650
|
* gates reposition/replay, not the page's existence.
|
|
3199
3651
|
*/
|
|
3200
3652
|
private fun cfwClear() {
|
|
3653
|
+
cfwClearSerial += 1
|
|
3201
3654
|
cfwLastFrame = null
|
|
3202
3655
|
cfwSceneActive = false
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
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
|
+
}
|
|
3208
3676
|
}
|
|
3209
3677
|
// The panel is (about to be) black — the diff base and any tracked
|
|
3210
3678
|
// text draws no longer describe what is on glass.
|
|
@@ -3235,8 +3703,8 @@ class G2 : SGCManager() {
|
|
|
3235
3703
|
// its normal paths.
|
|
3236
3704
|
if (!cfwClassified || cfwCapabilityString == null) return
|
|
3237
3705
|
if (enabled) {
|
|
3238
|
-
if (
|
|
3239
|
-
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")
|
|
3240
3708
|
requestPageRebuild()
|
|
3241
3709
|
} else {
|
|
3242
3710
|
Bridge.log("G2: cfw_scene_renderer OFF — exiting CFW surface to a blank stock page")
|
|
@@ -3244,7 +3712,8 @@ class G2 : SGCManager() {
|
|
|
3244
3712
|
cfwLastFrame = null
|
|
3245
3713
|
cfwSceneActive = false
|
|
3246
3714
|
cfwResetShadow()
|
|
3247
|
-
|
|
3715
|
+
cfwPageInstalled = false
|
|
3716
|
+
imageContainers.clear()
|
|
3248
3717
|
textContainers.clear()
|
|
3249
3718
|
sceneTextByElement.clear()
|
|
3250
3719
|
sceneImageByElement.clear()
|
|
@@ -3539,7 +4008,7 @@ class G2 : SGCManager() {
|
|
|
3539
4008
|
*/
|
|
3540
4009
|
override fun clearSceneElements(elementIds: List<String>) {
|
|
3541
4010
|
if (supportsCfwSceneRendering) {
|
|
3542
|
-
val carrierWasConfigured =
|
|
4011
|
+
val carrierWasConfigured = installCfwPageIfNeeded()
|
|
3543
4012
|
cfwClear()
|
|
3544
4013
|
if (!carrierWasConfigured && pageCreated) {
|
|
3545
4014
|
// Carrier just replaced live stock containers (see clearDisplay).
|
|
@@ -3629,6 +4098,7 @@ class G2 : SGCManager() {
|
|
|
3629
4098
|
* callers no longer race a detached coroutine. Always invoke on [displayScope].
|
|
3630
4099
|
*/
|
|
3631
4100
|
private suspend fun rebuildPage() {
|
|
4101
|
+
lastPageShutdownSentMs = System.currentTimeMillis()
|
|
3632
4102
|
val msg = EvenHubProto.shutdownMessage()
|
|
3633
4103
|
sendEvenHubCommand(msg)
|
|
3634
4104
|
pageCreated = false
|
|
@@ -3649,10 +4119,14 @@ class G2 : SGCManager() {
|
|
|
3649
4119
|
Bridge.log("G2: rebuildState() skipped — navigation mode owns the screen")
|
|
3650
4120
|
return
|
|
3651
4121
|
}
|
|
4122
|
+
if (notificationsPanelHold) {
|
|
4123
|
+
Bridge.log("G2: rebuildState() skipped — notifications panel hold")
|
|
4124
|
+
return
|
|
4125
|
+
}
|
|
3652
4126
|
Bridge.log("G2: rebuildState()")
|
|
3653
4127
|
// Stock containers left by a torn-down page would rebuild as the evt-0 page, not the carrier.
|
|
3654
4128
|
if (supportsCfwSceneRendering) {
|
|
3655
|
-
|
|
4129
|
+
installCfwPageIfNeeded()
|
|
3656
4130
|
}
|
|
3657
4131
|
// recreate the containers (sets pageCreated = true; embeds text content directly):
|
|
3658
4132
|
createPageWithContainers()
|
|
@@ -3670,8 +4144,8 @@ class G2 : SGCManager() {
|
|
|
3670
4144
|
imageContainers[i].dirty = true
|
|
3671
4145
|
}
|
|
3672
4146
|
}
|
|
3673
|
-
//
|
|
3674
|
-
if (supportsCfwSceneRendering &&
|
|
4147
|
+
// Re-seed the on-glass shadow and re-send the retained frame for the new page.
|
|
4148
|
+
if (supportsCfwSceneRendering && cfwPageInstalled) {
|
|
3675
4149
|
cfwResetShadow()
|
|
3676
4150
|
val frame = cfwLastFrame
|
|
3677
4151
|
if (frame != null) {
|
|
@@ -3753,6 +4227,32 @@ class G2 : SGCManager() {
|
|
|
3753
4227
|
)
|
|
3754
4228
|
}
|
|
3755
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
|
+
|
|
3756
4256
|
/**
|
|
3757
4257
|
* Single coalesced recovery: rebuild the page + re-arm the mic from intent, never stacking
|
|
3758
4258
|
* rebuilds (the firmware spams systemExit/dashboard-close ~1×/sec). Skips when the page is
|
|
@@ -3765,6 +4265,15 @@ class G2 : SGCManager() {
|
|
|
3765
4265
|
if (navigationModeActive) {
|
|
3766
4266
|
return
|
|
3767
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
|
+
}
|
|
3768
4277
|
val now = System.currentTimeMillis()
|
|
3769
4278
|
// Page alive and mic STATE matches INTENT → phantom firmware event, nothing to recover.
|
|
3770
4279
|
val micState = DeviceStore.get("glasses", "micEnabled") as? Boolean ?: false
|
|
@@ -3810,7 +4319,6 @@ class G2 : SGCManager() {
|
|
|
3810
4319
|
// the current text/image, and re-arms the mic iff intent says so. Skip while the native
|
|
3811
4320
|
// dashboard owns the screen.
|
|
3812
4321
|
if (!pageCreated) {
|
|
3813
|
-
val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
|
|
3814
4322
|
// Stock path: only resurrect for non-blank content — don't rebuild just to render a
|
|
3815
4323
|
// clearDisplay's blank, or a clear burst churns the page back up pointlessly.
|
|
3816
4324
|
// (CFW is the exception below: its blank IS the idle page.)
|
|
@@ -3823,7 +4331,7 @@ class G2 : SGCManager() {
|
|
|
3823
4331
|
// Idle carrier: the black seed alone. This is what the
|
|
3824
4332
|
// rebuild→systemExit→rebuild storm guard used to block, so it is
|
|
3825
4333
|
// rate-limited below rather than allowed once per reconcile tick.
|
|
3826
|
-
val cfwIdleCarrier = cfwPendingPayloads.isNotEmpty() && !cfwHasScene &&
|
|
4334
|
+
val cfwIdleCarrier = cfwPendingPayloads.isNotEmpty() && !cfwHasScene && cfwPageInstalled
|
|
3827
4335
|
val hasPendingImage =
|
|
3828
4336
|
imageContainers.any { it.dirty && it.bmpData.isNotEmpty() } ||
|
|
3829
4337
|
cfwHasScene
|
|
@@ -3833,29 +4341,21 @@ class G2 : SGCManager() {
|
|
|
3833
4341
|
// resurrection for the whole backoff window.
|
|
3834
4342
|
if (
|
|
3835
4343
|
wants &&
|
|
3836
|
-
|
|
4344
|
+
dashboardShowing == 0 &&
|
|
3837
4345
|
(hasPendingText || hasPendingImage || cfwIdlePageResurrectAllowed())
|
|
3838
4346
|
) {
|
|
3839
|
-
//
|
|
3840
|
-
//
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
if (carrier != null && probe != null) {
|
|
3849
|
-
val ok = sendImageData(carrier.id, carrier.name, probe.data)
|
|
3850
|
-
if (ok) {
|
|
3851
|
-
Bridge.log("G2: page-down probe acked — phantom exit, page alive (no rebuild)")
|
|
3852
|
-
pageCreated = true
|
|
3853
|
-
if (cfwPendingPayloads.isNotEmpty()) cfwPendingPayloads.removeFirst()
|
|
3854
|
-
signalDisplayDirty() // drain the rest normally
|
|
3855
|
-
return
|
|
3856
|
-
}
|
|
3857
|
-
}
|
|
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
|
|
3858
4356
|
}
|
|
4357
|
+
// (Transport ACKs need no page, so they cannot probe a "dead" page
|
|
4358
|
+
// the way carrier image ACKs once did.)
|
|
3859
4359
|
Bridge.log("G2: reconcileDisplay() - page down with pending content, rebuilding once")
|
|
3860
4360
|
rebuildState()
|
|
3861
4361
|
}
|
|
@@ -3878,11 +4378,55 @@ class G2 : SGCManager() {
|
|
|
3878
4378
|
}
|
|
3879
4379
|
|
|
3880
4380
|
// CFW keyframes and deltas are ordered: a fresh mode-6 shadow must land before mode 3.
|
|
3881
|
-
|
|
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) {
|
|
3882
4390
|
var drained = 0
|
|
3883
4391
|
while (pageCreated && drained < 4 && cfwPendingPayloads.isNotEmpty()) {
|
|
3884
4392
|
drained += 1
|
|
3885
|
-
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
|
+
}
|
|
3886
4430
|
if (!ok) {
|
|
3887
4431
|
// A lost payload leaves the on-glass shadow stale; further
|
|
3888
4432
|
// deltas would composite onto it and corrupt the panel.
|
|
@@ -3891,8 +4435,17 @@ class G2 : SGCManager() {
|
|
|
3891
4435
|
// the page down mid-transfer is re-seeded by rebuildState;
|
|
3892
4436
|
// refilling the queue here just fuels the page-resurrection
|
|
3893
4437
|
// loop (rebuild→systemExit→rebuild every debounce tick).
|
|
3894
|
-
if (pageCreated && supportsCfwSceneRendering &&
|
|
3895
|
-
|
|
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)
|
|
3896
4449
|
cfwLastFrame?.let { enqueueCfwFrame(it) }
|
|
3897
4450
|
}
|
|
3898
4451
|
break
|
|
@@ -3955,13 +4508,7 @@ class G2 : SGCManager() {
|
|
|
3955
4508
|
containerName: String,
|
|
3956
4509
|
bmpData: ByteArray
|
|
3957
4510
|
): Boolean {
|
|
3958
|
-
|
|
3959
|
-
// fragments, MapSessionId pinned to the container id instead of the
|
|
3960
|
-
// rotating session counter, and frames written to the LEFT arm (acks
|
|
3961
|
-
// still return on the right). Stock containers keep the shipped
|
|
3962
|
-
// 4096/rotating-session/right-arm shape.
|
|
3963
|
-
val isCfwCarrier = containerID == cfwSceneContainerId
|
|
3964
|
-
val fragmentSize = if (isCfwCarrier) 3800 else 4096
|
|
4511
|
+
val fragmentSize = 4096
|
|
3965
4512
|
val totalSize = bmpData.size
|
|
3966
4513
|
val fragmentCount = (bmpData.size + fragmentSize - 1) / fragmentSize
|
|
3967
4514
|
|
|
@@ -3980,10 +4527,11 @@ class G2 : SGCManager() {
|
|
|
3980
4527
|
}
|
|
3981
4528
|
// One session id per WHOLE image transfer (per attempt). The glasses key their
|
|
3982
4529
|
// reassembly buffer on MapSessionId, so every fragment reuses it with an incrementing
|
|
3983
|
-
// MapFragmentIndex.
|
|
3984
|
-
// 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).
|
|
3985
4533
|
imageSessionCounter = (imageSessionCounter + 1) % 256
|
|
3986
|
-
val sessionId =
|
|
4534
|
+
val sessionId = imageSessionCounter
|
|
3987
4535
|
|
|
3988
4536
|
var fragmentIndex = 0
|
|
3989
4537
|
var offset = 0
|
|
@@ -4013,7 +4561,7 @@ class G2 : SGCManager() {
|
|
|
4013
4561
|
mapFragmentPacketSize = fragment.size,
|
|
4014
4562
|
mapRawData = fragment
|
|
4015
4563
|
)
|
|
4016
|
-
sendEvenHubCommand(msg
|
|
4564
|
+
sendEvenHubCommand(msg)
|
|
4017
4565
|
|
|
4018
4566
|
// Gate on THIS fragment's ACK before sending the next (the ACK provides pacing).
|
|
4019
4567
|
// null=timeout, false=img_failed → abandon the attempt and retry the whole image.
|
|
@@ -4043,6 +4591,110 @@ class G2 : SGCManager() {
|
|
|
4043
4591
|
return false
|
|
4044
4592
|
}
|
|
4045
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
|
+
|
|
4046
4698
|
/// Bring the Even Realities dashboard (the OS-level home/idle screen) to
|
|
4047
4699
|
/// the foreground by tearing down whatever EvenHub page we currently own.
|
|
4048
4700
|
/// The glasses fall back to the dashboard automatically when no page is up.
|
|
@@ -4084,8 +4736,11 @@ class G2 : SGCManager() {
|
|
|
4084
4736
|
}
|
|
4085
4737
|
|
|
4086
4738
|
override fun setDashboardDepthOnly(depth: Int) {
|
|
4087
|
-
|
|
4088
|
-
|
|
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)")
|
|
4089
4744
|
val msg = G2SettingProto.setScreenDepth(sendManager.nextMagicRandom(), clamped)
|
|
4090
4745
|
sendG2SettingCommand(msg)
|
|
4091
4746
|
cfwReposition()
|
|
@@ -4167,9 +4822,20 @@ class G2 : SGCManager() {
|
|
|
4167
4822
|
* 3. SKILL{skillId=NOTIFICATION, skillParam=show, ...} — dispatches the intent
|
|
4168
4823
|
* The SKILL step alone is ignored by firmware; the preceding ENTER+ASK
|
|
4169
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.
|
|
4170
4828
|
*/
|
|
4171
4829
|
override suspend fun showNotificationsPanel() {
|
|
4172
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
|
+
|
|
4173
4839
|
val enterPayload = EvenAIProto.aiCtrl(
|
|
4174
4840
|
magicRandom = sendManager.nextMagicRandom(),
|
|
4175
4841
|
status = 2 // EVEN_AI_ENTER
|
|
@@ -4194,6 +4860,10 @@ class G2 : SGCManager() {
|
|
|
4194
4860
|
fTextEnd = 1
|
|
4195
4861
|
)
|
|
4196
4862
|
sendEvenAICommand(skillPayload)
|
|
4863
|
+
|
|
4864
|
+
delay(3_000)
|
|
4865
|
+
notificationsPanelHold = false
|
|
4866
|
+
Bridge.log("G2: notifications resurrect re-enabled")
|
|
4197
4867
|
}
|
|
4198
4868
|
|
|
4199
4869
|
override fun setBrightness(level: Int, autoMode: Boolean) {
|
|
@@ -4210,26 +4880,21 @@ class G2 : SGCManager() {
|
|
|
4210
4880
|
// ---------- Private Display Helpers ----------
|
|
4211
4881
|
|
|
4212
4882
|
private fun createPageWithContainers() {
|
|
4213
|
-
// With CFW scene rendering active the ONLY legal page
|
|
4214
|
-
//
|
|
4215
|
-
//
|
|
4216
|
-
//
|
|
4217
|
-
//
|
|
4218
|
-
// rather than trusting each caller to have run
|
|
4219
|
-
// 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.
|
|
4220
4888
|
if (supportsCfwSceneRendering) {
|
|
4221
4889
|
if (textContainers.isNotEmpty()) {
|
|
4222
4890
|
Bridge.log("G2: CFW page — dropping stray text containers ${textContainers.map { it.id }}")
|
|
4223
4891
|
textContainers.clear()
|
|
4224
4892
|
}
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
imageContainers.retainAll { it.id == cfwSceneContainerId }
|
|
4229
|
-
}
|
|
4230
|
-
if (!hasCfwCarrier) {
|
|
4231
|
-
installCfwCarrierIfNeeded()
|
|
4893
|
+
if (imageContainers.isNotEmpty()) {
|
|
4894
|
+
Bridge.log("G2: CFW page — dropping stray image containers ${imageContainers.map { it.id }}")
|
|
4895
|
+
imageContainers.clear()
|
|
4232
4896
|
}
|
|
4897
|
+
cfwPageInstalled = true
|
|
4233
4898
|
}
|
|
4234
4899
|
|
|
4235
4900
|
// Dedicated event-capture container: id 0, 1x1, borderless, empty — the
|
|
@@ -4280,15 +4945,6 @@ class G2 : SGCManager() {
|
|
|
4280
4945
|
// and ensure the ids are still in the imageContainerIDPool:
|
|
4281
4946
|
val seenIDs = mutableSetOf<Int>()
|
|
4282
4947
|
imageContainers.retainAll { c ->
|
|
4283
|
-
// The CFW carrier is INTENTIONALLY empty: its pixels ride the
|
|
4284
|
-
// ordered payload queue (cfwPendingPayloads), never bmpData. The
|
|
4285
|
-
// empty-container prune below silently dropped it from every page
|
|
4286
|
-
// since the queue rework — the firmware then had no container to
|
|
4287
|
-
// receive ANY CFW payload (mode 3/6/8, even plain BMPs), so all
|
|
4288
|
-
// direct rendering went dark while stock text kept working.
|
|
4289
|
-
if (c.id == cfwSceneContainerId) {
|
|
4290
|
-
return@retainAll seenIDs.add(c.id)
|
|
4291
|
-
}
|
|
4292
4948
|
if (c.bmpData.isEmpty()) {
|
|
4293
4949
|
Bridge.log("G2: removing empty image container ${c.id}")
|
|
4294
4950
|
return@retainAll false
|
|
@@ -4507,15 +5163,15 @@ class G2 : SGCManager() {
|
|
|
4507
5163
|
// Intent is "mic on". The mic only exists inside a live EvenHub page, so we toggle it
|
|
4508
5164
|
// off then back on (the firmware needs the off→on edge to re-arm).
|
|
4509
5165
|
evenHubMicIntent = true
|
|
5166
|
+
// Held: intent is recorded; the post-hold rebuild re-arms the mic.
|
|
5167
|
+
if (notificationsPanelHold) return
|
|
4510
5168
|
DeviceStore.apply("glasses", "micEnabled", true)
|
|
4511
5169
|
val msg = EvenHubProto.audioControlMessage(false)
|
|
4512
5170
|
sendEvenHubCommand(msg)
|
|
4513
5171
|
mainHandler.postDelayed({
|
|
4514
|
-
val useNativeDashboard = DeviceStore.get("bluetooth", "use_native_dashboard") as? Boolean ?: false
|
|
4515
|
-
// Bridge.log("G2: setMicEnabled - useNativeDashboard=$useNativeDashboard, dashboardShowing=$dashboardShowing")
|
|
4516
5172
|
// Dashboard owns the screen + session right now — don't arm the mic into a page the
|
|
4517
5173
|
// dashboard has taken over; recovery re-arms on dashboard close.
|
|
4518
|
-
if (
|
|
5174
|
+
if (dashboardShowing > 0) {
|
|
4519
5175
|
return@postDelayed
|
|
4520
5176
|
}
|
|
4521
5177
|
// Never send audioControl(enable=true) without a live page — no page means no mic.
|
|
@@ -4659,16 +5315,18 @@ class G2 : SGCManager() {
|
|
|
4659
5315
|
textContainers.clear()
|
|
4660
5316
|
dashboardShowing = 0
|
|
4661
5317
|
dashboardOpening = false
|
|
5318
|
+
notificationsPanelHold = false
|
|
4662
5319
|
heartbeatCounter = 0
|
|
4663
5320
|
currentBitmapBase64 = ""
|
|
4664
5321
|
menuAppIdToPackageName.clear()
|
|
4665
5322
|
activeMenuAppId = null
|
|
4666
|
-
|
|
5323
|
+
touchDebounce = G2TouchDebounce()
|
|
4667
5324
|
lastMenuSelectTimestamp = null
|
|
4668
5325
|
leftOtaDataWriteChar = null
|
|
4669
5326
|
rightOtaDataWriteChar = null
|
|
4670
5327
|
leftOtaDataNotifyChar = null
|
|
4671
5328
|
rightOtaDataNotifyChar = null
|
|
5329
|
+
resetNotificationsOnDisconnect()
|
|
4672
5330
|
resetCfwConnectionState()
|
|
4673
5331
|
DeviceStore.apply("glasses", "connected", false)
|
|
4674
5332
|
DeviceStore.apply("glasses", "fullyBooted", false)
|
|
@@ -4705,21 +5363,19 @@ class G2 : SGCManager() {
|
|
|
4705
5363
|
sendEvenHubHeartbeat()
|
|
4706
5364
|
}
|
|
4707
5365
|
|
|
4708
|
-
override fun dbg1() {
|
|
4709
|
-
|
|
4710
|
-
}
|
|
5366
|
+
override fun dbg1() {}
|
|
5367
|
+
|
|
5368
|
+
override fun dbg2() {}
|
|
4711
5369
|
|
|
4712
|
-
|
|
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
|
+
}
|
|
4713
5375
|
|
|
4714
|
-
override fun
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
// if (compassRunning) {
|
|
4718
|
-
// startCompass()
|
|
4719
|
-
// } else {
|
|
4720
|
-
// stopCompass()
|
|
4721
|
-
// }
|
|
4722
|
-
displayScope.launch { runAuthSequence() }
|
|
5376
|
+
override fun dbg4() {
|
|
5377
|
+
Bridge.log("G2: dbg4() — stop wake lease")
|
|
5378
|
+
stopWakeLease(sendRelease = true)
|
|
4723
5379
|
}
|
|
4724
5380
|
|
|
4725
5381
|
// ---------- Native navigation mode (service 0x08) ----------
|
|
@@ -4769,6 +5425,7 @@ class G2 : SGCManager() {
|
|
|
4769
5425
|
override fun exitNavigationMode(arrived: Boolean) {
|
|
4770
5426
|
if (!navigationModeActive) return
|
|
4771
5427
|
Bridge.log("G2: exitNavigationMode(arrived: $arrived)")
|
|
5428
|
+
sensorNavigationRecovering = true
|
|
4772
5429
|
navHeartbeatJob?.cancel()
|
|
4773
5430
|
navHeartbeatJob = null
|
|
4774
5431
|
navigationModeActive = false
|
|
@@ -4788,6 +5445,7 @@ class G2 : SGCManager() {
|
|
|
4788
5445
|
delay(600)
|
|
4789
5446
|
if (navigationModeActive) return@launch
|
|
4790
5447
|
pageCreated = false
|
|
5448
|
+
sensorNavigationRecovering = false
|
|
4791
5449
|
recoverPageAndMic(reason = "nav-exit")
|
|
4792
5450
|
}
|
|
4793
5451
|
}
|
|
@@ -5430,6 +6088,7 @@ class G2 : SGCManager() {
|
|
|
5430
6088
|
leftWriteChar = null
|
|
5431
6089
|
leftNotifyChar = null
|
|
5432
6090
|
leftAudioChar = null
|
|
6091
|
+
leftFileWriteChar = null
|
|
5433
6092
|
leftOtaDataWriteChar = null
|
|
5434
6093
|
leftOtaDataNotifyChar = null
|
|
5435
6094
|
} else {
|
|
@@ -5437,6 +6096,7 @@ class G2 : SGCManager() {
|
|
|
5437
6096
|
rightWriteChar = null
|
|
5438
6097
|
rightNotifyChar = null
|
|
5439
6098
|
rightAudioChar = null
|
|
6099
|
+
rightFileWriteChar = null
|
|
5440
6100
|
rightOtaDataWriteChar = null
|
|
5441
6101
|
rightOtaDataNotifyChar = null
|
|
5442
6102
|
}
|
|
@@ -5456,6 +6116,8 @@ class G2 : SGCManager() {
|
|
|
5456
6116
|
rightNotifyChar = null
|
|
5457
6117
|
leftAudioChar = null
|
|
5458
6118
|
rightAudioChar = null
|
|
6119
|
+
leftFileWriteChar = null
|
|
6120
|
+
rightFileWriteChar = null
|
|
5459
6121
|
leftOtaDataWriteChar = null
|
|
5460
6122
|
rightOtaDataWriteChar = null
|
|
5461
6123
|
leftOtaDataNotifyChar = null
|
|
@@ -5537,6 +6199,17 @@ class G2 : SGCManager() {
|
|
|
5537
6199
|
enqueueGattOp { enableNotifications(gatt, char) }
|
|
5538
6200
|
}
|
|
5539
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
|
+
|
|
5540
6213
|
G2BLE.OTA_DATA_WRITE -> {
|
|
5541
6214
|
Bridge.log("G2: Found OTA DATA WRITE char on $side")
|
|
5542
6215
|
if (side == "LEFT") leftOtaDataWriteChar = char
|
|
@@ -5579,7 +6252,8 @@ class G2 : SGCManager() {
|
|
|
5579
6252
|
gatt: BluetoothGatt,
|
|
5580
6253
|
characteristic: BluetoothGattCharacteristic
|
|
5581
6254
|
) {
|
|
5582
|
-
val data = characteristic.value ?: return
|
|
6255
|
+
val data = characteristic.value?.copyOf() ?: return
|
|
6256
|
+
val arrival = G2InputArrival(SystemClock.elapsedRealtime(), System.currentTimeMillis())
|
|
5583
6257
|
|
|
5584
6258
|
val sourceKey = if (side == "LEFT") "L" else "R"
|
|
5585
6259
|
when (characteristic.uuid) {
|
|
@@ -5592,7 +6266,8 @@ class G2 : SGCManager() {
|
|
|
5592
6266
|
// promptly even while the main looper is busy draining a packet burst /
|
|
5593
6267
|
// heartbeats — the cause of the intermittent "glasses stopped responding".
|
|
5594
6268
|
correlateImageAck(data, sourceKey)
|
|
5595
|
-
|
|
6269
|
+
correlateCfwAck(data)
|
|
6270
|
+
mainHandler.post { handleNotifyData(data, sourceKey, arrival) }
|
|
5596
6271
|
}
|
|
5597
6272
|
}
|
|
5598
6273
|
}
|
|
@@ -5696,7 +6371,19 @@ class G2 : SGCManager() {
|
|
|
5696
6371
|
}
|
|
5697
6372
|
}
|
|
5698
6373
|
|
|
5699
|
-
|
|
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)
|
|
5700
6387
|
val result = receiveManager.handlePacket(data, sourceKey) ?: return
|
|
5701
6388
|
|
|
5702
6389
|
val serviceId = result.first
|
|
@@ -5707,7 +6394,7 @@ class G2 : SGCManager() {
|
|
|
5707
6394
|
// Bridge.log("G2: handleNotifyData() - serviceId=$serviceId, payload=${payload.take(32).joinToString("") { String.format("%02X", it) }}")
|
|
5708
6395
|
|
|
5709
6396
|
when (serviceId) {
|
|
5710
|
-
ServiceID.EVEN_HUB.value -> handleEvenHubResponse(payload)
|
|
6397
|
+
ServiceID.EVEN_HUB.value -> handleEvenHubResponse(payload, arrival)
|
|
5711
6398
|
ServiceID.DEVICE_SETTINGS.value -> handleDevSettingsResponse(payload, sourceKey)
|
|
5712
6399
|
ServiceID.G2_SETTING.value -> handleG2SettingResponse(payload)
|
|
5713
6400
|
ServiceID.MENU.value -> handleMenuResponse(payload)
|
|
@@ -5716,6 +6403,7 @@ class G2 : SGCManager() {
|
|
|
5716
6403
|
ServiceID.NAVIGATION.value -> handleNavigationResponse(payload)
|
|
5717
6404
|
ServiceID.EVEN_AI.value -> handleEvenAIResponse(payload)
|
|
5718
6405
|
ServiceID.EVEN_HUB_CTRL.value -> handleEvenHubCtrlResponse(payload)
|
|
6406
|
+
ServiceID.CFW_STREAM.value -> Unit // ACK/NACK replies are correlated inline in correlateCfwAck()
|
|
5719
6407
|
else -> {
|
|
5720
6408
|
Bridge.log(
|
|
5721
6409
|
"G2: Unhandled service ${serviceId.toInt() and 0xFF} (${payload.size} bytes): ${
|
|
@@ -5774,16 +6462,11 @@ class G2 : SGCManager() {
|
|
|
5774
6462
|
val compassData = fields[10] as? ByteArray ?: return
|
|
5775
6463
|
val cReader = ProtobufReader(compassData)
|
|
5776
6464
|
val cFields = cReader.parseFields()
|
|
5777
|
-
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
mapOf(
|
|
5783
|
-
"heading" to heading,
|
|
5784
|
-
"timestamp" to System.currentTimeMillis()
|
|
5785
|
-
)
|
|
5786
|
-
)
|
|
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)
|
|
5787
6470
|
}
|
|
5788
6471
|
|
|
5789
6472
|
NavigationCmd.OS_NOTIFY_COMPASS_CALIBRATE_START.value -> {
|
|
@@ -5827,7 +6510,7 @@ class G2 : SGCManager() {
|
|
|
5827
6510
|
}
|
|
5828
6511
|
}
|
|
5829
6512
|
|
|
5830
|
-
private fun handleEvenHubResponse(payload: ByteArray) {
|
|
6513
|
+
private fun handleEvenHubResponse(payload: ByteArray, arrival: G2InputArrival) {
|
|
5831
6514
|
val reader = ProtobufReader(payload)
|
|
5832
6515
|
val fields = reader.parseFields()
|
|
5833
6516
|
|
|
@@ -5855,7 +6538,7 @@ class G2 : SGCManager() {
|
|
|
5855
6538
|
// handleTouchEvent, AFTER IMU classification — running it here would let
|
|
5856
6539
|
// continuous IMU keepalive traffic swallow real taps (matches iOS).
|
|
5857
6540
|
val devEventData = fields[13] as? ByteArray ?: return
|
|
5858
|
-
handleTouchEvent(devEventData)
|
|
6541
|
+
handleTouchEvent(devEventData, arrival)
|
|
5859
6542
|
} else if (cmdValue == 17) {
|
|
5860
6543
|
// Miniapp selection from glasses dashboard menu (cmdId=17)
|
|
5861
6544
|
// Dedup: L and R peripherals both deliver this event, so debounce or
|
|
@@ -5995,15 +6678,15 @@ class G2 : SGCManager() {
|
|
|
5995
6678
|
return Triple(fx, fy, fz)
|
|
5996
6679
|
}
|
|
5997
6680
|
|
|
5998
|
-
private fun handleTouchEvent(devEventData: ByteArray) {
|
|
6681
|
+
private fun handleTouchEvent(devEventData: ByteArray, arrival: G2InputArrival) {
|
|
5999
6682
|
// Parse SendDeviceEvent: field 1=ListEvent, field 2=TextEvent, field 3=SysEvent
|
|
6000
6683
|
val reader = ProtobufReader(devEventData)
|
|
6001
6684
|
val fields = reader.parseFields()
|
|
6002
6685
|
|
|
6003
|
-
val timestamp =
|
|
6686
|
+
val timestamp = arrival.timestamp
|
|
6004
6687
|
|
|
6005
6688
|
// Classify IMU reports before applying the click debounce. Keepalive samples
|
|
6006
|
-
// arrive continuously and must not update
|
|
6689
|
+
// arrive continuously and must not update the debounce, or a real gesture
|
|
6007
6690
|
// delivered within 100 ms of a sample gets dropped. A malformed IMU report still
|
|
6008
6691
|
// returns here — it must never fall through into gesture mapping.
|
|
6009
6692
|
val parsedSysFields =
|
|
@@ -6013,6 +6696,7 @@ class G2 : SGCManager() {
|
|
|
6013
6696
|
if ((sysFields[1] as? Int) == OsEventType.IMU_DATA_REPORT.value) {
|
|
6014
6697
|
// IMU_Report_Data { x, y, z } as 32-bit floats in field 3.
|
|
6015
6698
|
lastImuReportTimestamp = timestamp
|
|
6699
|
+
displayDiagnostics.count("imuReceived")
|
|
6016
6700
|
val forwardSamples =
|
|
6017
6701
|
DeviceStore.get("bluetooth", "imu_enabled") as? Boolean ?: false
|
|
6018
6702
|
val imuData = sysFields[3] as? ByteArray
|
|
@@ -6027,19 +6711,19 @@ class G2 : SGCManager() {
|
|
|
6027
6711
|
sysFields
|
|
6028
6712
|
}
|
|
6029
6713
|
|
|
6030
|
-
// tap_start/tap_end bracket every gesture: the end edge lands within the
|
|
6031
|
-
// debounce window of its own start, and the click event fires right after
|
|
6032
|
-
// tap_end. Exempt the edges from the debounce (check AND update) or they
|
|
6033
|
-
// either get dropped or swallow the real gesture that follows.
|
|
6034
6714
|
val sysEventTypeRaw = parsedSysFields?.get(1) as? Int
|
|
6035
|
-
val
|
|
6036
|
-
|
|
6037
|
-
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
|
|
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
|
|
6043
6727
|
}
|
|
6044
6728
|
|
|
6045
6729
|
// if we are receiving touch events we are fully booted:
|
|
@@ -6072,7 +6756,7 @@ class G2 : SGCManager() {
|
|
|
6072
6756
|
val last = lastImgAckSuccessMs
|
|
6073
6757
|
val sinceAck = if (last > 0) System.currentTimeMillis() - last else Long.MAX_VALUE
|
|
6074
6758
|
if (sinceAck < PHANTOM_EXIT_ACK_WINDOW_MS) {
|
|
6075
|
-
Bridge.log("G2: $eventType ignored —
|
|
6759
|
+
Bridge.log("G2: $eventType ignored — a stock image acked ${sinceAck}ms ago (phantom exit)")
|
|
6076
6760
|
return@let
|
|
6077
6761
|
}
|
|
6078
6762
|
}
|
|
@@ -6084,7 +6768,6 @@ class G2 : SGCManager() {
|
|
|
6084
6768
|
}
|
|
6085
6769
|
|
|
6086
6770
|
Bridge.sendTouchEvent(DeviceTypes.G2, gestureName, timestamp, eventSource)
|
|
6087
|
-
Bridge.log("G2: SysEvent → $eventType $eventSource")
|
|
6088
6771
|
|
|
6089
6772
|
if (eventSource == 2) {
|
|
6090
6773
|
// controller must be connected and fully booted:
|
|
@@ -6145,6 +6828,8 @@ class G2 : SGCManager() {
|
|
|
6145
6828
|
OsEventType.SYSTEM_EXIT -> "system_exit"
|
|
6146
6829
|
OsEventType.TAP_START -> "tap_start"
|
|
6147
6830
|
OsEventType.TAP_END -> "tap_end"
|
|
6831
|
+
OsEventType.TAP_THEN_HOLD -> "tap_then_hold"
|
|
6832
|
+
OsEventType.RING_TOUCH_DOWN -> "touch_down"
|
|
6148
6833
|
OsEventType.IMU_DATA_REPORT -> null
|
|
6149
6834
|
OsEventType.ABNORMAL_EXIT -> null // don't report abnormal exits as gestures
|
|
6150
6835
|
}
|
|
@@ -6294,6 +6979,12 @@ class G2 : SGCManager() {
|
|
|
6294
6979
|
}
|
|
6295
6980
|
}
|
|
6296
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
|
+
|
|
6297
6988
|
private fun handleG2SettingResponse(payload: ByteArray) {
|
|
6298
6989
|
val reader = ProtobufReader(payload)
|
|
6299
6990
|
val fields = reader.parseFields()
|
|
@@ -6305,6 +6996,20 @@ class G2 : SGCManager() {
|
|
|
6305
6996
|
) {
|
|
6306
6997
|
(fields[4] as? ByteArray)?.let { parseDeviceRequestResponse(it) }
|
|
6307
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) } }
|
|
6308
7013
|
updateCfwCapabilities(
|
|
6309
7014
|
fields[100] as? ByteArray,
|
|
6310
7015
|
definitive = cmdValue == G2SettingCommandId.DEVICE_RECEIVE_REQUEST.value,
|
|
@@ -6313,25 +7018,137 @@ class G2 : SGCManager() {
|
|
|
6313
7018
|
}
|
|
6314
7019
|
}
|
|
6315
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
|
+
|
|
6316
7119
|
private fun updateCfwCapabilities(data: ByteArray?, definitive: Boolean) {
|
|
6317
7120
|
// The sticky/definitive rules live in G2CfwClassification (unit-tested);
|
|
6318
7121
|
// null means the packet proved nothing and prior identity stands.
|
|
6319
7122
|
val result = G2CfwClassification.classify(data, definitive, cfwCapabilityString) ?: return
|
|
6320
|
-
|
|
7123
|
+
val changed = result != cfwClassification
|
|
7124
|
+
cfwClassification = result
|
|
6321
7125
|
if (result.isCfw) {
|
|
6322
|
-
val changed = result.capabilityString != cfwCapabilityString
|
|
6323
|
-
cfwCapabilityString = result.capabilityString
|
|
6324
|
-
cfwFeatures.clear()
|
|
6325
|
-
cfwFeatures.addAll(result.features)
|
|
6326
|
-
cfwProtocolVersion = result.protocolVersion
|
|
6327
7126
|
if (changed) {
|
|
6328
|
-
Bridge.log("G2:
|
|
7127
|
+
Bridge.log("G2: CFW detected — ${result.capabilityString}")
|
|
6329
7128
|
rearmWakeLeaseIfWanted()
|
|
7129
|
+
syncBleLink()
|
|
7130
|
+
val revision = result.revision
|
|
7131
|
+
onKeyboardThread { keyboardBridge.cfwDetected(revision) }
|
|
6330
7132
|
}
|
|
6331
7133
|
} else {
|
|
6332
|
-
|
|
6333
|
-
|
|
7134
|
+
publishRingBattery(G2RingBattery.DISCONNECTED)
|
|
7135
|
+
onKeyboardThread { keyboardBridge.linkLost() }
|
|
6334
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)
|
|
6335
7152
|
}
|
|
6336
7153
|
|
|
6337
7154
|
/**
|
|
@@ -6352,9 +7169,10 @@ class G2 : SGCManager() {
|
|
|
6352
7169
|
val baseVersion =
|
|
6353
7170
|
(DeviceStore.get("glasses", "rightFirmwareVersion") as? String)
|
|
6354
7171
|
?: (DeviceStore.get("glasses", "leftFirmwareVersion") as? String).orEmpty()
|
|
7172
|
+
val name = cfwClassification?.displayName
|
|
6355
7173
|
val displayVersion =
|
|
6356
|
-
if (
|
|
6357
|
-
if (baseVersion.isEmpty())
|
|
7174
|
+
if (name != null) {
|
|
7175
|
+
if (baseVersion.isEmpty()) name else "$baseVersion ($name)"
|
|
6358
7176
|
} else {
|
|
6359
7177
|
baseVersion
|
|
6360
7178
|
}
|