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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +47 -6
  2. package/android/build.gradle +7 -7
  3. package/android/lc3Lib/build.gradle +2 -2
  4. package/android/src/main/assets/G2Firmware/README.md +27 -2
  5. package/android/src/main/assets/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  6. package/android/src/main/assets/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  7. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +38 -0
  8. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +50 -14
  9. package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +18 -0
  10. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +139 -10
  11. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +28 -1
  12. package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +199 -0
  13. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +10 -1
  14. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +4 -4
  15. package/android/src/main/java/com/glassly/bluetoothsdk/GlassesNotifications.kt +155 -0
  16. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +1065 -255
  17. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2BleLink.kt +119 -0
  18. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +29 -28
  19. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +138 -33
  20. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +673 -0
  21. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +235 -162
  22. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransport.kt +402 -0
  23. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnostics.kt +84 -0
  24. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
  25. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +9 -9
  26. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Keyboard.kt +689 -0
  27. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardBridge.kt +452 -0
  28. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardHid.kt +208 -0
  29. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFile.kt +181 -0
  30. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2RingBattery.kt +20 -0
  31. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Sensors.kt +67 -0
  32. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2TouchInput.kt +39 -0
  33. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +5 -5
  34. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +14 -14
  35. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/{GlasslyBle.java → NexProto.java} +6462 -7618
  36. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIds.kt +58 -0
  37. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +77 -2
  38. package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +1 -1
  39. package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +55 -0
  40. package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +1 -1
  41. package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -1
  42. package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +1 -1
  43. package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +24 -149
  44. package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
  45. package/android/src/test/java/com/glassly/bluetoothsdk/G2BleLinkTest.kt +70 -0
  46. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +45 -56
  47. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +78 -5
  48. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +489 -0
  49. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +100 -43
  50. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransportTest.kt +252 -0
  51. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnosticsTest.kt +64 -0
  52. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardTest.kt +271 -0
  53. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFileTest.kt +204 -0
  54. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2RingBatteryTest.kt +56 -0
  55. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2SensorsTest.kt +56 -0
  56. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2TouchInputTest.kt +60 -0
  57. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NexProtoDescriptorTest.kt +32 -0
  58. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIdsTest.kt +58 -0
  59. package/build/BluetoothSdk.types.d.ts +209 -20
  60. package/build/BluetoothSdk.types.d.ts.map +1 -1
  61. package/build/BluetoothSdk.types.js.map +1 -1
  62. package/build/_internal.d.ts +1 -1
  63. package/build/_internal.d.ts.map +1 -1
  64. package/build/_internal.js.map +1 -1
  65. package/build/_private/BluetoothSdkModule.d.ts +30 -1
  66. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  67. package/build/_private/BluetoothSdkModule.js.map +1 -1
  68. package/build/generated/changelogCatalog.d.ts +28 -1
  69. package/build/generated/changelogCatalog.d.ts.map +1 -1
  70. package/build/generated/changelogCatalog.js +37 -1
  71. package/build/generated/changelogCatalog.js.map +1 -1
  72. package/build/generated/releaseMetadata.js +4 -4
  73. package/build/generated/releaseMetadata.js.map +1 -1
  74. package/build/index.d.ts +1 -1
  75. package/build/index.d.ts.map +1 -1
  76. package/build/index.js +14 -0
  77. package/build/index.js.map +1 -1
  78. package/ios/BluetoothSdkModule.swift +102 -13
  79. package/ios/GlasslyBluetoothSDK.podspec +1 -1
  80. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +8 -3
  81. package/ios/Source/BluetoothSDK.swift +53 -0
  82. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  83. package/ios/Source/Bridge.swift +13 -0
  84. package/ios/Source/DeviceManager.swift +151 -33
  85. package/ios/Source/DeviceStore.swift +28 -14
  86. package/ios/Source/DisplayPacer.swift +180 -0
  87. package/ios/Source/GeneratedChangelogCatalog.swift +10 -1
  88. package/ios/Source/GeneratedReleaseMetadata.swift +4 -4
  89. package/ios/Source/GlassesNotifications.swift +181 -0
  90. package/ios/Source/ObservableStore.swift +14 -2
  91. package/ios/Source/Resources/G2Firmware/README.md +27 -2
  92. package/ios/Source/Resources/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  93. package/ios/Source/Resources/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  94. package/ios/Source/controllers/R1.swift +0 -1
  95. package/ios/Source/internal/BluetoothAvailability.swift +31 -0
  96. package/ios/Source/sgcs/G2.swift +1420 -495
  97. package/ios/Source/sgcs/G2AncsRelay.swift +268 -0
  98. package/ios/Source/sgcs/G2BleLink.swift +102 -0
  99. package/ios/Source/sgcs/G2CfwClassification.swift +45 -27
  100. package/ios/Source/sgcs/G2CfwRenderer.swift +166 -33
  101. package/ios/Source/sgcs/G2CfwScene.swift +779 -0
  102. package/ios/Source/sgcs/G2CfwTextureCache.swift +244 -117
  103. package/ios/Source/sgcs/G2CfwTransport.swift +485 -0
  104. package/ios/Source/sgcs/G2DisplayDiagnostics.swift +154 -0
  105. package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
  106. package/ios/Source/sgcs/G2Flasher.swift +9 -9
  107. package/ios/Source/sgcs/G2Keyboard.swift +780 -0
  108. package/ios/Source/sgcs/G2KeyboardBridge.swift +438 -0
  109. package/ios/Source/sgcs/G2KeyboardHid.swift +290 -0
  110. package/ios/Source/sgcs/G2NotificationTuning.swift +24 -0
  111. package/ios/Source/sgcs/G2RingBattery.swift +22 -0
  112. package/ios/Source/sgcs/G2Sensors.swift +75 -0
  113. package/ios/Source/sgcs/G2TouchInput.swift +51 -0
  114. package/ios/Source/sgcs/G2WritePacing.swift +18 -0
  115. package/ios/Source/sgcs/MentraNex.swift +44 -44
  116. package/ios/Source/sgcs/SGCManager.swift +120 -13
  117. package/ios/Source/sgcs/{glassly_ble.pb.swift → nex_ble.pb.swift} +405 -465
  118. package/ios/Source/speakerid/SpeakerIdEngine.swift +2 -2
  119. package/ios/Source/status/DeviceStatus.swift +27 -0
  120. package/ios/Source/stt/SherpaOnnxTranscriber.swift +15 -14
  121. package/ios/Source/utils/Enums.swift +0 -1
  122. package/ios/Source/utils/G1Text.swift +0 -1
  123. package/ios/Source/utils/G2NotifyFile.swift +20 -10
  124. package/ios/Source/utils/JSCExperiment.swift +6 -6
  125. package/ios/Source/utils/Models.swift +0 -1
  126. package/ios/Tests/BluetoothSDKTests/BluetoothAvailabilityTests.swift +29 -0
  127. package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
  128. package/ios/Tests/BluetoothSDKTests/G2AncsRelayTests.swift +186 -0
  129. package/ios/Tests/BluetoothSDKTests/G2BleLinkTests.swift +52 -0
  130. package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +46 -51
  131. package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +164 -5
  132. package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +497 -0
  133. package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +108 -44
  134. package/ios/Tests/BluetoothSDKTests/G2CfwTransportTests.swift +259 -0
  135. package/ios/Tests/BluetoothSDKTests/G2DisplayDiagnosticsTests.swift +89 -0
  136. package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +3 -2
  137. package/ios/Tests/BluetoothSDKTests/G2ImageAckTests.swift +104 -0
  138. package/ios/Tests/BluetoothSDKTests/G2KeyboardTests.swift +274 -0
  139. package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +21 -5
  140. package/ios/Tests/BluetoothSDKTests/G2RingBatteryTests.swift +56 -0
  141. package/ios/Tests/BluetoothSDKTests/G2SensorsTests.swift +56 -0
  142. package/ios/Tests/BluetoothSDKTests/G2TouchInputTests.swift +71 -0
  143. package/ios/Tests/BluetoothSDKTests/G2WritePacingTests.swift +35 -0
  144. package/ios/Tests/BluetoothSDKTests/GlassesNotificationTests.swift +95 -0
  145. package/package.json +1 -1
  146. package/plugin/build/withAndroid.js +2 -2
  147. package/plugin/build/withIos.js +1 -1
  148. package/scripts/gen-nex-proto.sh +12 -0
  149. package/scripts/inject-ios-sdk-version.mjs +1 -1
  150. package/src/BluetoothSdk.types.ts +214 -19
  151. package/src/_internal.ts +1 -1
  152. package/src/_private/BluetoothSdkModule.ts +40 -0
  153. package/src/generated/changelogCatalog.ts +37 -1
  154. package/src/generated/releaseMetadata.ts +4 -4
  155. package/src/index.ts +24 -0
@@ -0,0 +1,119 @@
1
+ package com.glassly.bluetoothsdk.sgcs
2
+
3
+ /**
4
+ * Glassly CFW BLE link speed control (contract revision 26). The firmware
5
+ * boots on the stock connection profile; the phone can switch each lens to
6
+ * the 7.5 ms / latency-0 fast profile (slow mode suppressed) and back. The
7
+ * wire format is pinned by `patches/ble_link.c` in g2flash.
8
+ *
9
+ * Phone -> glasses, G2SettingPackage field 127: `['B','L',1,op]`.
10
+ * Glasses -> phone, field 128 on every settings read reply:
11
+ * `['B','L',1, fast, side, wanted, applied, min16, max16, latency16,
12
+ * liveInterval16, liveLatency16]`
13
+ * Intervals are little-endian in 1.25 ms units. Modes: 0xa3 fast, 0xa4 slow,
14
+ * 0 = update pending. min/max/latency are the profile the last request used;
15
+ * the live pair is the connection as the central granted it (absent from the
16
+ * first revision 26 builds, which sent 13 bytes).
17
+ */
18
+ internal object G2BleLink {
19
+ const val PROTOCOL_VERSION = 1
20
+
21
+ enum class Op(
22
+ val value: Int,
23
+ ) {
24
+ STOCK(0),
25
+ FAST(1),
26
+ QUERY(2),
27
+ }
28
+
29
+ sealed class Mode {
30
+ object Fast : Mode()
31
+
32
+ object Slow : Mode()
33
+
34
+ object Pending : Mode()
35
+
36
+ data class Other(
37
+ val raw: Int,
38
+ ) : Mode()
39
+
40
+ val label: String
41
+ get() =
42
+ when (this) {
43
+ Fast -> "fast"
44
+ Slow -> "slow"
45
+ Pending -> "pending"
46
+ is Other -> "0x%02x".format(raw)
47
+ }
48
+
49
+ companion object {
50
+ fun of(raw: Int): Mode =
51
+ when (raw) {
52
+ 0xA3 -> Fast
53
+ 0xA4 -> Slow
54
+ 0 -> Pending
55
+ else -> Other(raw)
56
+ }
57
+ }
58
+ }
59
+
60
+ data class Status(
61
+ val fast: Boolean,
62
+ val side: Int,
63
+ val wanted: Mode,
64
+ val applied: Mode,
65
+ /** The profile the last request asked for. */
66
+ val requestedMinMs: Double,
67
+ val requestedMaxMs: Double,
68
+ val requestedLatency: Int,
69
+ /** What the central granted; null on firmware that predates the field. */
70
+ val liveIntervalMs: Double?,
71
+ val liveLatency: Int?,
72
+ ) {
73
+ val summary: String
74
+ get() {
75
+ val text = StringBuilder()
76
+ text.append(if (fast) "FAST" else "stock")
77
+ text.append(" wanted=${wanted.label} applied=${applied.label}")
78
+ text.append(" requested %.2f-%.2f ms / latency %d".format(requestedMinMs, requestedMaxMs, requestedLatency))
79
+ if (liveIntervalMs != null && liveLatency != null) {
80
+ text.append(" live %.2f ms / latency %d".format(liveIntervalMs, liveLatency))
81
+ }
82
+ return text.toString()
83
+ }
84
+ }
85
+
86
+ // ---------- Encoding (phone -> glasses) ----------
87
+
88
+ /**
89
+ * G2SettingPackage{commandId=1 (DEVICE_RECEIVE_INFO), magic=0, field 127}.
90
+ * Hand-framed like the firmware's replies: tag 127/wire 2 is 1018 = fa 07.
91
+ */
92
+ fun controlPacket(op: Op): ByteArray =
93
+ byteArrayOf(0x08, 0x01, 0x10, 0x00, 0xFA.toByte(), 0x07, 4, 0x42, 0x4C, PROTOCOL_VERSION.toByte(), op.value.toByte())
94
+
95
+ // ---------- Decoding (glasses -> phone) ----------
96
+
97
+ fun decodeStatus(data: ByteArray): Status? {
98
+ if (data.size < 13 || data[0] != 0x42.toByte() || data[1] != 0x4C.toByte() || data[2] != PROTOCOL_VERSION.toByte()) return null
99
+ val live = data.size >= 17
100
+ return Status(
101
+ fast = data[3].toInt() != 0,
102
+ side = data[4].toInt() and 0xFF,
103
+ wanted = Mode.of(data[5].toInt() and 0xFF),
104
+ applied = Mode.of(data[6].toInt() and 0xFF),
105
+ requestedMinMs = ms(u16(data, 7)),
106
+ requestedMaxMs = ms(u16(data, 9)),
107
+ requestedLatency = u16(data, 11),
108
+ liveIntervalMs = if (live) ms(u16(data, 13)) else null,
109
+ liveLatency = if (live) u16(data, 15) else null,
110
+ )
111
+ }
112
+
113
+ private fun ms(units: Int): Double = units * 1.25
114
+
115
+ private fun u16(
116
+ b: ByteArray,
117
+ i: Int,
118
+ ): Int = (b[i].toInt() and 0xFF) or ((b[i + 1].toInt() and 0xFF) shl 8)
119
+ }
@@ -1,32 +1,35 @@
1
1
  package com.glassly.bluetoothsdk.sgcs
2
2
 
3
- /**
4
- * Stock-vs-Glassly-CFW classification of one G2 settings response's top-level
5
- * field 100 ("EVENCFW/NN feat feat …"; stock firmware never emits the field).
6
- *
7
- * Pure so the identity rules stay unit-testable — they gate the connect
8
- * banner: legacy text drawn before classification and cleared after it splits
9
- * draw/clear across render paths, and the stock text container then never
10
- * leaves the glass.
3
+ /** Firmware identity advertised in settings field 100. Retain its revision for
4
+ * future protocol changes; current features depend only on the firmware family.
11
5
  */
12
- data class G2CfwClassification(
13
- /** null = classified stock. */
14
- val capabilityString: String?,
15
- val features: List<String>,
16
- val protocolVersion: Int,
17
- ) {
18
- val isCfw: Boolean get() = capabilityString != null
6
+ data class G2CfwClassification(val family: Family, val capabilityString: String?) {
7
+ enum class Family { STOCK, UPSTREAM, GLASSLY }
8
+
9
+ /** Numeric contract revision, or null for stock/unknown revision text. */
10
+ val revision: Int?
11
+ get() = capabilityString?.substringBefore(' ')?.substringAfter('/')?.toIntOrNull()?.takeIf { it > 0 }
12
+
13
+ val isCfw: Boolean get() = family != Family.STOCK
14
+ val isGlassly: Boolean get() = family == Family.GLASSLY
15
+ val displayName: String? get() = when (family) {
16
+ Family.STOCK -> null
17
+ Family.UPSTREAM -> "g2flash CFW"
18
+ Family.GLASSLY -> revision?.let { "Glassly CFW v$it" } ?: "Glassly CFW"
19
+ }
19
20
 
20
21
  companion object {
21
- val STOCK = G2CfwClassification(null, emptyList(), 0)
22
+ val STOCK = G2CfwClassification(Family.STOCK, null)
22
23
 
23
24
  /**
24
- * Classify one settings response. Returns null when the packet proves
25
- * nothing: a non-definitive response without the CFW banner (individual
26
- * settings packets can legitimately omit the field or arrive truncated),
27
- * or an absent banner on a connection already classified CFW — firmware
28
- * identity is connection-sticky, and downgrading would reinterpret an
29
- * installed mode-6 carrier as a stock BMP.
25
+ * The one revision gate: everything the app uses (SID-0xf0 transport,
26
+ * scenes, settle reports, fast BLE link) shipped together in this
27
+ * firmware line, so nothing older is special-cased; it is treated as stock.
28
+ */
29
+ const val MINIMUM_REVISION = 39
30
+
31
+ /** Partial settings notifications can omit field 100. Keep the connection's
32
+ * known identity so a missing field cannot switch the active render path.
30
33
  */
31
34
  fun classify(
32
35
  field100: ByteArray?,
@@ -34,13 +37,11 @@ data class G2CfwClassification(
34
37
  currentCapability: String?,
35
38
  ): G2CfwClassification? {
36
39
  val capability = field100?.toString(Charsets.UTF_8)?.trimEnd('\u0000')
40
+ if (capability?.startsWith("GLASSLYCFW/") == true) {
41
+ return G2CfwClassification(Family.GLASSLY, capability)
42
+ }
37
43
  if (capability?.startsWith("EVENCFW/") == true) {
38
- val tokens = capability.split(' ').filter { it.isNotEmpty() }
39
- return G2CfwClassification(
40
- capabilityString = capability,
41
- features = tokens.drop(1),
42
- protocolVersion = tokens.firstOrNull()?.removePrefix("EVENCFW/")?.toIntOrNull() ?: 0,
43
- )
44
+ return G2CfwClassification(Family.UPSTREAM, capability)
44
45
  }
45
46
  if (definitive && currentCapability == null) return STOCK
46
47
  return null
@@ -13,7 +13,6 @@ import android.text.StaticLayout
13
13
  import android.text.TextPaint
14
14
  import android.util.Base64
15
15
  import java.io.ByteArrayOutputStream
16
- import java.util.zip.DeflaterOutputStream
17
16
  import kotlin.math.roundToInt
18
17
 
19
18
  /** Phone-side full-scene rasterizer for Glassly CFW's direct-framebuffer protocol. */
@@ -34,6 +33,9 @@ internal object G2CfwRenderer {
34
33
  val height: Int,
35
34
  )
36
35
 
36
+ /** The global setting is 1..3; the firmware placement tables are zero-based. */
37
+ fun depthLevelForSetting(depth: Int): Int = depth.coerceIn(1, 3) - 1
38
+
37
39
  fun lensBoxes(
38
40
  canvasWidth: Int,
39
41
  canvasHeight: Int,
@@ -74,6 +76,117 @@ internal object G2CfwRenderer {
74
76
  * glyph raster memo survives resets — rasters don't change.
75
77
  */
76
78
  private val textureCache = G2CfwTextureCache()
79
+ /**
80
+ * Retained object cache (modes 37-41); shares the texture cache with the
81
+ * raster path. Two lens mirrors: while every frame is flat only the left
82
+ * one is encoded and its SHOW goes to both lenses; the first frame with
83
+ * global or element depth copies it into the right mirror ([stereoSplit]) and from
84
+ * then on each lens gets its own SHOW whenever the two encodings differ.
85
+ */
86
+ private val scenes = listOf(G2CfwScene(), G2CfwScene())
87
+ private var stereoSplit = false
88
+
89
+ /** One frame of the object cache: the SHOWs to send, in order, each with its lens targets, after the asset [uploads]. */
90
+ class StereoEncoded(
91
+ val shows: List<Show>,
92
+ val uploads: List<ByteArray>,
93
+ /** False when the frame is the one already on screen (nothing new to send). */
94
+ val changed: Boolean,
95
+ val puts: Int,
96
+ val hits: Int,
97
+ val tweens: Int,
98
+ ) {
99
+ class Show(val payload: ByteArray, val lenses: Int)
100
+ }
101
+
102
+ /**
103
+ * Encode [frame] as SHOWs of the object cache (plus the asset PUT messages
104
+ * that must precede them): one SHOW for both lenses while the lens mirrors
105
+ * agree, else one per lens (element depth shifts the two encodings apart).
106
+ * [invalidate] means residency is unknown (a send failed): every object the
107
+ * frame shows is redefined, and the mirrors re-converge. Null when the frame
108
+ * does not fit the object table or the asset store.
109
+ */
110
+ fun encodeVector(
111
+ frame: SceneFrame,
112
+ boxes: LensBoxes,
113
+ cacheGeneration: Int,
114
+ invalidate: Boolean,
115
+ inlineText: Boolean,
116
+ tag: Int? = null,
117
+ ): StereoEncoded? = synchronized(this) {
118
+ if (textureCache.generation != cacheGeneration) textureCache.reset(cacheGeneration)
119
+ if (scenes[0].generation != textureCache.generation) {
120
+ scenes.forEach { it.reset(textureCache.generation) }
121
+ stereoSplit = false
122
+ } else if (invalidate) {
123
+ scenes.forEach { it.invalidate() }
124
+ stereoSplit = false
125
+ }
126
+ // Both mirrors rasterize the same element pixels: do it once per frame.
127
+ val rasters = HashMap<String, ByteArray?>()
128
+ val rasterizeOne: (SceneElement, Int, Int) -> ByteArray? = { element, w, h ->
129
+ rasters.getOrPut("${element.id}|${element.contentHash}|${w}x$h") {
130
+ rasterize(SceneFrame(frame.appId, frame.epoch, false, listOf(element.atOrigin()), emptyList(), w, h), w, h)
131
+ }
132
+ }
133
+ val mono = G2CfwScene.MonoFace({ monoCellMetrics(it) }) { cp, size, cw, lh -> monoGlyphRaster(cp, size, cw, lh) }
134
+ if (!stereoSplit && (boxes.leftX != boxes.rightX || boxes.leftY != boxes.rightY || frame.elements.any { it.depth != 0 })) {
135
+ scenes[1].adopt(scenes[0])
136
+ stereoSplit = true
137
+ }
138
+ val left = scenes[0].encode(
139
+ frame, boxes.leftX, boxes.leftY, textureCache, rasterizeOne, inlineText, mono, tag,
140
+ parallax = if (stereoSplit) 1 else 0,
141
+ ) ?: return null
142
+ if (!stereoSplit) {
143
+ return StereoEncoded(
144
+ listOf(StereoEncoded.Show(left.payload, G2CfwTransport.LENS_BOTH)), left.uploads, left.changed,
145
+ left.puts, left.hits, left.tweens,
146
+ )
147
+ }
148
+ val right = scenes[1].encode(frame, boxes.rightX, boxes.rightY, textureCache, rasterizeOne, inlineText, mono, tag, parallax = -1)
149
+ ?: return null
150
+ val shows = mutableListOf<StereoEncoded.Show>()
151
+ if (left.payload.contentEquals(right.payload)) {
152
+ shows.add(StereoEncoded.Show(left.payload, G2CfwTransport.LENS_BOTH))
153
+ } else {
154
+ // A lens whose frame did not change keeps quiet, unless neither changed (then both SHOWs go).
155
+ val both = left.changed == right.changed
156
+ if (both || left.changed) shows.add(StereoEncoded.Show(left.payload, G2CfwTransport.LENS_LEFT))
157
+ if (both || right.changed) shows.add(StereoEncoded.Show(right.payload, G2CfwTransport.LENS_RIGHT))
158
+ }
159
+ StereoEncoded(
160
+ shows, left.uploads + right.uploads, left.changed || right.changed,
161
+ left.puts + right.puts, left.hits + right.hits, left.tweens + right.tweens,
162
+ )
163
+ }
164
+
165
+ /** The panel went blank while the cache stayed: hidden objects mid-animation are re-snapped on their next show. */
166
+ fun sceneHidden() {
167
+ synchronized(this) { scenes.forEach { it.hidden() } }
168
+ }
169
+
170
+ /** The RESET reply named the store each lens could allocate; predict against the smaller one. */
171
+ fun setStoreCapacity(bytes: Int) {
172
+ synchronized(this) { textureCache.storeCapacity = bytes.coerceIn(G2CfwTextureCache.GRANULE, G2CfwTextureCache.STORE_SIZE) }
173
+ }
174
+
175
+ /** A lens answered MISSING: these ids (objects or assets) are redefined on the next frame. */
176
+ fun forget(ids: Collection<Int>) {
177
+ synchronized(this) {
178
+ scenes.forEach { it.forget(ids) }
179
+
180
+ textureCache.forget(ids)
181
+ }
182
+ }
183
+
184
+ /** Upload payloads that were dropped or failed: their assets may not be resident. */
185
+ fun forgetUploads(payloads: Collection<ByteArray>) {
186
+ synchronized(this) {
187
+ textureCache.forget(payloads.flatMap { G2CfwTextureCache.assetIdsInUploadPayload(it) })
188
+ }
189
+ }
77
190
  private val glyphRasterMemo = mutableMapOf<String, ByteArray>()
78
191
 
79
192
  private fun monoPaint(fontSize: Float): Paint =
@@ -261,10 +374,13 @@ internal object G2CfwRenderer {
261
374
  // A full cache resets between frames (never mid-frame: draws already
262
375
  // planned this frame reference the old offsets). Bumping past the
263
376
  // caller's generation keeps the caller's own bumps monotonic.
264
- if ((useTexGlyphs || useTexImages) && (textureCache.generation != cacheGeneration || textureCache.exhausted)) {
377
+ if ((useTexGlyphs || useTexImages) && textureCache.generation != cacheGeneration) {
265
378
  textureCache.reset(cacheGeneration)
379
+ scenes.forEach { it.reset(cacheGeneration) }
380
+ stereoSplit = false
266
381
  }
267
382
 
383
+
268
384
  var grids: List<MonoGrid> = emptyList()
269
385
  val raster: ByteArray
270
386
  if (useTexGlyphs) {
@@ -509,7 +625,9 @@ internal object G2CfwRenderer {
509
625
  if (grid != null &&
510
626
  elementBoxes.none { it.z > grid.z && it.intersects(rect) }
511
627
  ) {
512
- val font = textureCache.font(grid.fontSize, grid.cellWidth, grid.lineHeight)
628
+ val font = textureCache.font(grid.fontSize, grid.cellWidth, grid.lineHeight) { code ->
629
+ memoizedGlyphRaster(code, grid.fontSize, grid.cellWidth, grid.lineHeight)
630
+ }
513
631
  if (font != null) {
514
632
  val r0 = (rect.y - grid.originY) / grid.lineHeight
515
633
  val r1 = (rect.y + rect.h - 1 - grid.originY) / grid.lineHeight
@@ -544,13 +662,7 @@ internal object G2CfwRenderer {
544
662
  }
545
663
  for (col in c0..c1) {
546
664
  val scalar = grid.scalar(row, col)
547
- val resident =
548
- scalar in 32..126 &&
549
- textureCache.ensureGlyph(font, scalar) {
550
- memoizedGlyphRaster(
551
- scalar, grid.fontSize, grid.cellWidth, grid.lineHeight
552
- )
553
- }
665
+ val resident = scalar in 32..126 && scalar in font.glyphs
554
666
  if (resident) {
555
667
  flushResidual(col)
556
668
  if (runChars.isEmpty()) runStartCol = col
@@ -578,7 +690,7 @@ internal object G2CfwRenderer {
578
690
  for ((runX, runY, chars) in newRuns) {
579
691
  val payload =
580
692
  G2CfwTextureCache.stringRunPayload(
581
- fontTableOffset = font.tableOffset,
693
+ fontId = font.id,
582
694
  x = boxes.leftX + runX, y = boxes.leftY + runY,
583
695
  options = G2CfwTextureCache.OPAQUE_DRAW_OPTIONS, chars = chars,
584
696
  )
@@ -619,11 +731,11 @@ internal object G2CfwRenderer {
619
731
  gray, (y - candidate.box.y0) * w, w,
620
732
  )
621
733
  }
622
- val offset = textureCache.ensureImage(candidate.key, gray, w, h)
734
+ val asset = textureCache.ensureImage(candidate.key, gray, w, h)
623
735
  val draw =
624
- offset?.let {
736
+ asset?.let {
625
737
  G2CfwTextureCache.imageDrawPayload(
626
- offset = it,
738
+ assetId = it,
627
739
  x = boxes.leftX + candidate.box.x0,
628
740
  y = boxes.leftY + candidate.box.y0,
629
741
  options = G2CfwTextureCache.OPAQUE_DRAW_OPTIONS,
@@ -994,7 +1106,7 @@ internal object G2CfwRenderer {
994
1106
  canvas.clipRect(rect)
995
1107
  when (element.type) {
996
1108
  "text" -> if (!skipBuiltinInk) drawText(canvas, element, rect)
997
- "image" -> decodedImages[index]?.let { drawImage(canvas, it, rect) }
1109
+ "image" -> decodedImages[index]?.let { drawImage(canvas, it, rect, element.color) }
998
1110
  }
999
1111
 
1000
1112
  val border = maxOf(if (element.type == "rect") 1 else 0, element.border)
@@ -1064,7 +1176,8 @@ internal object G2CfwRenderer {
1064
1176
  canvas.restoreToCount(save)
1065
1177
  }
1066
1178
 
1067
- private fun drawImage(canvas: Canvas, bitmap: Bitmap, rect: RectF) {
1179
+ /** [color] is the ink level 0..15 (-1 = as drawn): it scales the pixels, as the texture ramp does on vector firmware. */
1180
+ private fun drawImage(canvas: Canvas, bitmap: Bitmap, rect: RectF, color: Int) {
1068
1181
  val scale = minOf(rect.width() / bitmap.width.coerceAtLeast(1), rect.height() / bitmap.height.coerceAtLeast(1))
1069
1182
  val targetWidth = bitmap.width * scale
1070
1183
  val targetHeight = bitmap.height * scale
@@ -1077,7 +1190,9 @@ internal object G2CfwRenderer {
1077
1190
  rect.centerX() + targetWidth / 2f,
1078
1191
  rect.centerY() + targetHeight / 2f,
1079
1192
  ),
1080
- Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG),
1193
+ Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
1194
+ if (color >= 0) alpha = color.coerceIn(0, 15) * 255 / 15
1195
+ },
1081
1196
  )
1082
1197
  }
1083
1198
 
@@ -1102,7 +1217,8 @@ internal object G2CfwRenderer {
1102
1217
  h: Int,
1103
1218
  frameId: Int,
1104
1219
  ): ByteArray? {
1105
- val compressed = zlib(rle) ?: return null
1220
+ if (rle.isEmpty()) return null
1221
+ // Plain RLE: the SID-0xf0 transport deflates the whole message.
1106
1222
  return byteArrayOf(
1107
1223
  0x83.toByte(),
1108
1224
  (lx / 4).toByte(),
@@ -1115,7 +1231,7 @@ internal object G2CfwRenderer {
1115
1231
  (h / 2).toByte(),
1116
1232
  (frameId and 0xFF).toByte(),
1117
1233
  ((frameId ushr 8) and 0xFF).toByte(),
1118
- ) + compressed
1234
+ ) + rle
1119
1235
  }
1120
1236
 
1121
1237
  fun bundle(segments: List<ByteArray>): ByteArray? {
@@ -1158,15 +1274,12 @@ internal object G2CfwRenderer {
1158
1274
  return encodePixels(panel)
1159
1275
  }
1160
1276
 
1161
- fun blackPayload(): ByteArray? {
1162
- val compressed = zlib(rleEncodeSolid(0, FRAME_WIDTH * FRAME_HEIGHT)) ?: return null
1163
- return byteArrayOf(6) + compressed
1164
- }
1277
+ fun blackPayload(): ByteArray? = byteArrayOf(6) + rleEncodeSolid(0, FRAME_WIDTH * FRAME_HEIGHT)
1165
1278
 
1279
+ /** `[6][rle]`: plain RLE, the SID-0xf0 transport deflates the whole message. */
1166
1280
  fun encodePixels(grayscale: ByteArray): ByteArray? {
1167
1281
  if (grayscale.size != FRAME_WIDTH * FRAME_HEIGHT) return null
1168
- val compressed = zlib(rleEncode(pack4Bpp(grayscale))) ?: return null
1169
- return byteArrayOf(6) + compressed
1282
+ return byteArrayOf(6) + rleEncode(pack4Bpp(grayscale))
1170
1283
  }
1171
1284
 
1172
1285
  /**
@@ -1242,12 +1355,4 @@ internal object G2CfwRenderer {
1242
1355
  }
1243
1356
  }
1244
1357
 
1245
- private fun zlib(data: ByteArray): ByteArray? =
1246
- try {
1247
- ByteArrayOutputStream().also { output ->
1248
- DeflaterOutputStream(output).use { it.write(data) }
1249
- }.toByteArray()
1250
- } catch (_: Exception) {
1251
- null
1252
- }
1253
1358
  }