@glassly/bluetooth-sdk 0.1.0-dev.112 → 0.1.0-dev.114
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/android/src/main/assets/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +24 -2
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +7 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +198 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +3 -3
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +9 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +3 -2
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +79 -21
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +2 -2
- package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +37 -3
- package/build/generated/releaseMetadata.js +3 -3
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSDK.swift +1 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +27 -2
- package/ios/Source/DeviceStore.swift +7 -0
- package/ios/Source/DisplayPacer.swift +179 -0
- package/ios/Source/GeneratedReleaseMetadata.swift +3 -3
- package/ios/Source/Resources/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
- package/ios/Source/sgcs/G2.swift +8 -1
- package/ios/Source/sgcs/G2CfwRenderer.swift +5 -3
- package/ios/Source/sgcs/G2CfwScene.swift +67 -19
- package/ios/Source/sgcs/G2Flasher.swift +2 -2
- package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
- package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +46 -2
- package/package.json +1 -1
- package/src/generated/releaseMetadata.ts +3 -3
|
Binary file
|
|
@@ -703,6 +703,17 @@ class DeviceManager {
|
|
|
703
703
|
// native re-dispatch coherent (nav exit re-applies a complete scene, not
|
|
704
704
|
// whatever element happened to arrive last).
|
|
705
705
|
private var sceneState: SceneFrame? = null
|
|
706
|
+
|
|
707
|
+
/** Paces scene frames and legacy layouts to display_max_fps; clears bypass it. */
|
|
708
|
+
private val pacer =
|
|
709
|
+
DisplayPacer(dispatchScene = { dispatchSceneFrame(it) }, sendLegacy = { sendCurrentState() }).also {
|
|
710
|
+
it.setMaxFps((DeviceStore.store.get("bluetooth", "display_max_fps") as? Number)?.toInt() ?: DisplayPacer.DEFAULT_FPS)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
fun setDisplayMaxFps(fps: Int) = pacer.setMaxFps(fps)
|
|
714
|
+
|
|
715
|
+
/** Drop paced display work (screen off): the retained state repaints on re-enable. */
|
|
716
|
+
fun cancelPendingDisplay() = pacer.cancel()
|
|
706
717
|
// MARK: - End Unique
|
|
707
718
|
|
|
708
719
|
// MARK: - Voice Data Handling
|
|
@@ -1208,6 +1219,7 @@ class DeviceManager {
|
|
|
1208
1219
|
if (sgc != null && sgc?.type != wearable) {
|
|
1209
1220
|
Bridge.log("MAN: Manager already initialized, cleaning up previous sgc")
|
|
1210
1221
|
Bridge.log("MAN: Cleaning up previous sgc type: ${sgc?.type}")
|
|
1222
|
+
pacer.cancel()
|
|
1211
1223
|
sgc?.cleanup()
|
|
1212
1224
|
sgc = null
|
|
1213
1225
|
resetSystemTimeSync()
|
|
@@ -1580,6 +1592,7 @@ class DeviceManager {
|
|
|
1580
1592
|
|
|
1581
1593
|
fun clearDisplay() {
|
|
1582
1594
|
Bridge.log("MAN: Clearing Display")
|
|
1595
|
+
pacer.cancel()
|
|
1583
1596
|
sgc?.clearDisplay()
|
|
1584
1597
|
}
|
|
1585
1598
|
|
|
@@ -1647,7 +1660,11 @@ class DeviceManager {
|
|
|
1647
1660
|
}
|
|
1648
1661
|
|
|
1649
1662
|
viewState = newViewState
|
|
1650
|
-
|
|
1663
|
+
if (layoutType == "clear_view") {
|
|
1664
|
+
pacer.flushLegacy()
|
|
1665
|
+
} else {
|
|
1666
|
+
pacer.submitLegacy()
|
|
1667
|
+
}
|
|
1651
1668
|
}
|
|
1652
1669
|
|
|
1653
1670
|
/** Parse + store a scene frame, then dispatch it. */
|
|
@@ -1682,7 +1699,11 @@ class DeviceManager {
|
|
|
1682
1699
|
frame.copy(replay = true, elements = frame.elements.map { it.copy(change = "created") })
|
|
1683
1700
|
viewState = ViewState(" ", " ", " ", "scene", " ", null, null)
|
|
1684
1701
|
|
|
1685
|
-
|
|
1702
|
+
if (frame.elements.isEmpty()) {
|
|
1703
|
+
pacer.flushScene(frame)
|
|
1704
|
+
} else {
|
|
1705
|
+
pacer.submitScene(frame)
|
|
1706
|
+
}
|
|
1686
1707
|
}
|
|
1687
1708
|
|
|
1688
1709
|
/** Guarded scene dispatch — mirrors sendCurrentState's send conditions. */
|
|
@@ -2306,6 +2327,7 @@ class DeviceManager {
|
|
|
2306
2327
|
}
|
|
2307
2328
|
|
|
2308
2329
|
fun disconnect(clearPendingIdentity: Boolean = true) {
|
|
2330
|
+
pacer.cancel()
|
|
2309
2331
|
sgc?.clearDisplay()
|
|
2310
2332
|
sgc?.disconnect()
|
|
2311
2333
|
sgc = null // Clear the SGC reference after disconnect
|
|
@@ -132,6 +132,7 @@ object DeviceStore {
|
|
|
132
132
|
store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
|
|
133
133
|
store.set("bluetooth", "loudness_gate_enabled", BluetoothSdkDefaults.LOUDNESS_GATE_ENABLED)
|
|
134
134
|
store.set("bluetooth", "screen_disabled", false)
|
|
135
|
+
store.set("bluetooth", "display_max_fps", DisplayPacer.DEFAULT_FPS)
|
|
135
136
|
store.set("bluetooth", "button_photo_size", "max")
|
|
136
137
|
store.set("bluetooth", "button_max_recording_time", 10)
|
|
137
138
|
store.set("bluetooth", "camera_fov", mapOf("fov" to 118, "roi_position" to 0))
|
|
@@ -241,6 +242,11 @@ object DeviceStore {
|
|
|
241
242
|
}
|
|
242
243
|
}
|
|
243
244
|
}
|
|
245
|
+
"bluetooth" to "display_max_fps" -> {
|
|
246
|
+
(value as? Number)?.let { fps ->
|
|
247
|
+
DeviceManager.getInstance().setDisplayMaxFps(fps.toInt())
|
|
248
|
+
}
|
|
249
|
+
}
|
|
244
250
|
"bluetooth" to "dashboard_height" -> {
|
|
245
251
|
scheduleDashboardHeightToGlasses()
|
|
246
252
|
}
|
|
@@ -294,6 +300,7 @@ object DeviceStore {
|
|
|
294
300
|
"bluetooth" to "screen_disabled" -> {
|
|
295
301
|
(value as? Boolean)?.let { disabled ->
|
|
296
302
|
if (disabled) {
|
|
303
|
+
DeviceManager.getInstance().cancelPendingDisplay()
|
|
297
304
|
DeviceManager.getInstance().sgc?.exit()
|
|
298
305
|
} else {
|
|
299
306
|
DeviceManager.getInstance().sgc?.clearDisplay()
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
package com.glassly.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
import android.os.Handler
|
|
4
|
+
import android.os.Looper
|
|
5
|
+
import com.glassly.bluetoothsdk.sgcs.SceneFrame
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Paces display updates leaving the phone to at most [setMaxFps] per second.
|
|
9
|
+
*
|
|
10
|
+
* Updates that arrive inside the current slot are held and sent at the next
|
|
11
|
+
* one, last-wins: a newer scene frame replaces a pending one (with the pending
|
|
12
|
+
* frame's per-element annotations folded in so per-element devices never miss
|
|
13
|
+
* a change), a legacy layout replaces a pending scene and vice versa. Clears
|
|
14
|
+
* ([flushScene] with an empty frame, [flushLegacy]) bypass the pacer and drop
|
|
15
|
+
* pending work.
|
|
16
|
+
*
|
|
17
|
+
* Safe to call from any thread; the trailing send runs on the scheduler's
|
|
18
|
+
* thread (main looper by default). Sinks are always invoked outside the lock.
|
|
19
|
+
*/
|
|
20
|
+
class DisplayPacer(
|
|
21
|
+
private val dispatchScene: (SceneFrame) -> Unit,
|
|
22
|
+
private val sendLegacy: () -> Unit,
|
|
23
|
+
private val clock: () -> Long = { System.nanoTime() / 1_000_000 },
|
|
24
|
+
private val scheduler: Scheduler = MainScheduler(),
|
|
25
|
+
) {
|
|
26
|
+
/** Runs [block] after [delayMs]; the returned function cancels it. */
|
|
27
|
+
fun interface Scheduler {
|
|
28
|
+
fun schedule(delayMs: Long, block: () -> Unit): () -> Unit
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private class MainScheduler : Scheduler {
|
|
32
|
+
private val handler = Handler(Looper.getMainLooper())
|
|
33
|
+
|
|
34
|
+
override fun schedule(delayMs: Long, block: () -> Unit): () -> Unit {
|
|
35
|
+
val runnable = Runnable { block() }
|
|
36
|
+
handler.postDelayed(runnable, delayMs)
|
|
37
|
+
return { handler.removeCallbacks(runnable) }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
companion object {
|
|
42
|
+
const val MIN_FPS = 2
|
|
43
|
+
const val MAX_FPS = 20
|
|
44
|
+
const val DEFAULT_FPS = 8
|
|
45
|
+
|
|
46
|
+
fun intervalFor(fps: Int): Long = 1000L / fps.coerceIn(MIN_FPS, MAX_FPS)
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Fold pending frame [previous] into [next] (both unsent). The device
|
|
50
|
+
* only ever sees the merged frame, so an element the pending frame
|
|
51
|
+
* changed must not read as "unchanged" in the merged one, and ids the
|
|
52
|
+
* pending frame removed must still be removed unless [next] re-creates
|
|
53
|
+
* them. A different app is already a from-scratch replay.
|
|
54
|
+
*/
|
|
55
|
+
fun merge(previous: SceneFrame?, next: SceneFrame): SceneFrame {
|
|
56
|
+
if (previous == null || previous.appId != next.appId) return next
|
|
57
|
+
val pending = previous.elements.associateBy { it.id }
|
|
58
|
+
val elements =
|
|
59
|
+
next.elements.map { el ->
|
|
60
|
+
val prior = pending[el.id] ?: return@map el
|
|
61
|
+
when {
|
|
62
|
+
prior.change == "created" -> el.copy(change = "created")
|
|
63
|
+
prior.change != "unchanged" && el.change == "unchanged" -> el.copy(change = "updated")
|
|
64
|
+
else -> el
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
val painted = next.elements.mapTo(HashSet()) { it.id }
|
|
68
|
+
val removed = (previous.removed + next.removed).distinct().filter { it !in painted }
|
|
69
|
+
return next.copy(replay = previous.replay || next.replay, elements = elements, removed = removed)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private val lock = Any()
|
|
74
|
+
private var intervalMs: Long = intervalFor(DEFAULT_FPS)
|
|
75
|
+
private var lastSendAt: Long = Long.MIN_VALUE / 2
|
|
76
|
+
private var pendingScene: SceneFrame? = null
|
|
77
|
+
private var pendingLegacy = false
|
|
78
|
+
private var cancelTimer: (() -> Unit)? = null
|
|
79
|
+
|
|
80
|
+
fun setMaxFps(fps: Int) {
|
|
81
|
+
var send: (() -> Unit)? = null
|
|
82
|
+
synchronized(lock) {
|
|
83
|
+
intervalMs = intervalFor(fps)
|
|
84
|
+
if (!hasPending()) return
|
|
85
|
+
stopTimer()
|
|
86
|
+
send = armOrTake(clock())
|
|
87
|
+
}
|
|
88
|
+
send?.invoke()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Send [frame] at the next slot; replaces any pending update. */
|
|
92
|
+
fun submitScene(frame: SceneFrame) {
|
|
93
|
+
var send: (() -> Unit)? = null
|
|
94
|
+
synchronized(lock) {
|
|
95
|
+
val now = clock()
|
|
96
|
+
val merged = merge(pendingScene, frame)
|
|
97
|
+
pendingScene = merged
|
|
98
|
+
pendingLegacy = false
|
|
99
|
+
if (now - lastSendAt >= intervalMs) {
|
|
100
|
+
stopTimer()
|
|
101
|
+
send = take(now)
|
|
102
|
+
} else if (cancelTimer == null) {
|
|
103
|
+
armTimer(now)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
send?.invoke()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Send the current legacy view state at the next slot; replaces any pending update. */
|
|
110
|
+
fun submitLegacy() {
|
|
111
|
+
var send: (() -> Unit)? = null
|
|
112
|
+
synchronized(lock) {
|
|
113
|
+
val now = clock()
|
|
114
|
+
pendingScene = null
|
|
115
|
+
pendingLegacy = true
|
|
116
|
+
if (now - lastSendAt >= intervalMs) {
|
|
117
|
+
stopTimer()
|
|
118
|
+
send = take(now)
|
|
119
|
+
} else if (cancelTimer == null) {
|
|
120
|
+
armTimer(now)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
send?.invoke()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Immediate send for an empty (clearing) frame; pending removes ride along. */
|
|
127
|
+
fun flushScene(frame: SceneFrame) {
|
|
128
|
+
val merged: SceneFrame
|
|
129
|
+
synchronized(lock) {
|
|
130
|
+
stopTimer()
|
|
131
|
+
merged = merge(pendingScene, frame)
|
|
132
|
+
pendingScene = null
|
|
133
|
+
pendingLegacy = false
|
|
134
|
+
lastSendAt = clock()
|
|
135
|
+
}
|
|
136
|
+
dispatchScene(merged)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Immediate legacy send (clear_view); drops any pending scene. */
|
|
140
|
+
fun flushLegacy() {
|
|
141
|
+
synchronized(lock) {
|
|
142
|
+
stopTimer()
|
|
143
|
+
pendingScene = null
|
|
144
|
+
pendingLegacy = false
|
|
145
|
+
lastSendAt = clock()
|
|
146
|
+
}
|
|
147
|
+
sendLegacy()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Drop pending work without sending (disconnect, screen off, clearDisplay). */
|
|
151
|
+
fun cancel() {
|
|
152
|
+
synchronized(lock) {
|
|
153
|
+
stopTimer()
|
|
154
|
+
pendingScene = null
|
|
155
|
+
pendingLegacy = false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private fun hasPending() = pendingScene != null || pendingLegacy
|
|
160
|
+
|
|
161
|
+
private fun stopTimer() {
|
|
162
|
+
cancelTimer?.invoke()
|
|
163
|
+
cancelTimer = null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Lock held. Either sends now (returns the send) or arms the slot timer. */
|
|
167
|
+
private fun armOrTake(now: Long): (() -> Unit)? {
|
|
168
|
+
if (now - lastSendAt >= intervalMs) return take(now)
|
|
169
|
+
armTimer(now)
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Lock held. Consume pending work into a send closure and stamp the slot. */
|
|
174
|
+
private fun take(now: Long): (() -> Unit)? {
|
|
175
|
+
val scene = pendingScene
|
|
176
|
+
val legacy = pendingLegacy
|
|
177
|
+
pendingScene = null
|
|
178
|
+
pendingLegacy = false
|
|
179
|
+
if (scene == null && !legacy) return null
|
|
180
|
+
lastSendAt = now
|
|
181
|
+
return if (scene != null) ({ dispatchScene(scene) }) else ({ sendLegacy() })
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Lock held. Arm the single trailing timer for the next slot. */
|
|
185
|
+
private fun armTimer(now: Long) {
|
|
186
|
+
val delay = (lastSendAt + intervalMs - now).coerceAtLeast(0)
|
|
187
|
+
cancelTimer = scheduler.schedule(delay) { onTimer() }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private fun onTimer() {
|
|
191
|
+
var send: (() -> Unit)? = null
|
|
192
|
+
synchronized(lock) {
|
|
193
|
+
cancelTimer = null
|
|
194
|
+
send = take(clock())
|
|
195
|
+
}
|
|
196
|
+
send?.invoke()
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -3,9 +3,9 @@ package com.glassly.bluetoothsdk
|
|
|
3
3
|
/** Generated by release CI. Do not edit in a release checkout. */
|
|
4
4
|
internal object GeneratedReleaseMetadata {
|
|
5
5
|
const val FAMILY_BASE_VERSION: String = "0.1.0"
|
|
6
|
-
const val RELEASE_IDENTITY: String = "0.1.0-dev.
|
|
7
|
-
const val RELEASE_SET_ID: String = "glassly-0.1.0-dev.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
6
|
+
const val RELEASE_IDENTITY: String = "0.1.0-dev.114"
|
|
7
|
+
const val RELEASE_SET_ID: String = "glassly-0.1.0-dev.114"
|
|
8
|
+
const val SOURCE_COMMIT: String = "17263133b35c04f044ba0d3acd9f80efc81999d4"
|
|
9
9
|
const val OTA_MANIFEST_URL: String = ""
|
|
10
10
|
const val OTA_MANIFEST_SHA256: String = ""
|
|
11
11
|
}
|
|
@@ -1578,6 +1578,13 @@ class G2 : SGCManager() {
|
|
|
1578
1578
|
supportsCfwSceneRendering &&
|
|
1579
1579
|
listOf("scene17", "shapes16", "anim18", "texcache12", "teximg13", "font15").all { it in cfwFeatures }
|
|
1580
1580
|
|
|
1581
|
+
/**
|
|
1582
|
+
* TEXT_INLINE records (contract revision 19; no token, the capability
|
|
1583
|
+
* response must fit one BLE frame): default text drawn by the glasses' font.
|
|
1584
|
+
*/
|
|
1585
|
+
private val supportsCfwInlineText: Boolean
|
|
1586
|
+
get() = cfwProtocolVersion >= 19
|
|
1587
|
+
|
|
1581
1588
|
/** The on-glass slot table must be re-SET on the next vector frame. */
|
|
1582
1589
|
private var cfwSceneInvalid = true
|
|
1583
1590
|
|
|
@@ -3104,9 +3111,10 @@ class G2 : SGCManager() {
|
|
|
3104
3111
|
}
|
|
3105
3112
|
if (cfwLastBoxes != boxes) cfwSceneInvalid = true
|
|
3106
3113
|
val invalidate = cfwSceneInvalid
|
|
3114
|
+
val inlineText = supportsCfwInlineText
|
|
3107
3115
|
val encoded =
|
|
3108
3116
|
kotlinx.coroutines.withContext(Dispatchers.Default) {
|
|
3109
|
-
G2CfwRenderer.encodeVector(frame, boxes, cfwCacheGeneration, invalidate)
|
|
3117
|
+
G2CfwRenderer.encodeVector(frame, boxes, cfwCacheGeneration, invalidate, inlineText)
|
|
3110
3118
|
}
|
|
3111
3119
|
if (encoded == null) {
|
|
3112
3120
|
Bridge.log("G2: vector scene does not fit; using the raster path for this frame")
|
|
@@ -87,6 +87,7 @@ internal object G2CfwRenderer {
|
|
|
87
87
|
boxes: LensBoxes,
|
|
88
88
|
cacheGeneration: Int,
|
|
89
89
|
invalidate: Boolean,
|
|
90
|
+
inlineText: Boolean,
|
|
90
91
|
): G2CfwScene.Encoded? {
|
|
91
92
|
if (textureCache.generation != cacheGeneration || textureCache.exhausted) {
|
|
92
93
|
textureCache.reset(cacheGeneration)
|
|
@@ -103,11 +104,11 @@ internal object G2CfwRenderer {
|
|
|
103
104
|
h,
|
|
104
105
|
)
|
|
105
106
|
}
|
|
106
|
-
scene.encode(frame, boxes.leftX, boxes.leftY, textureCache, rasterizeOne)?.let { return it }
|
|
107
|
+
scene.encode(frame, boxes.leftX, boxes.leftY, textureCache, rasterizeOne, inlineText)?.let { return it }
|
|
107
108
|
// Out of texture cache mid-frame: start a fresh cache and repack once.
|
|
108
109
|
textureCache.reset(cacheGeneration)
|
|
109
110
|
scene.reset(cacheGeneration)
|
|
110
|
-
return scene.encode(frame, boxes.leftX, boxes.leftY, textureCache, rasterizeOne)
|
|
111
|
+
return scene.encode(frame, boxes.leftX, boxes.leftY, textureCache, rasterizeOne, inlineText)
|
|
111
112
|
}
|
|
112
113
|
private val glyphRasterMemo = mutableMapOf<String, ByteArray>()
|
|
113
114
|
|
|
@@ -6,13 +6,14 @@ import java.io.ByteArrayOutputStream
|
|
|
6
6
|
* Retained vector scene for the Glassly CFW (firmware modes 16-18).
|
|
7
7
|
*
|
|
8
8
|
* Every scene element becomes one or more firmware slots (paint order = slot
|
|
9
|
-
* order): vector shapes map 1:1
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* tiled to the 255 px slot limit.
|
|
13
|
-
* layout: unchanged slots cost nothing,
|
|
14
|
-
* transition become firmware TWEENs, and
|
|
15
|
-
* stay stable per element id; a reorder
|
|
9
|
+
* order): vector shapes map 1:1; default and builtin text becomes one inline
|
|
10
|
+
* TEXT slot per line drawn by the glasses' font (or a cache-backed TEXT slot on
|
|
11
|
+
* firmware without inline records); mono text and images are rasterized on
|
|
12
|
+
* the phone and drawn as cached IMAGE slots tiled to the 255 px slot limit.
|
|
13
|
+
* Frames diff against the last committed layout: unchanged slots cost nothing,
|
|
14
|
+
* geometry changes on an element with a transition become firmware TWEENs, and
|
|
15
|
+
* anything else is re-SET. Slot indices stay stable per element id; a reorder
|
|
16
|
+
* or an over-budget frame repacks.
|
|
16
17
|
*/
|
|
17
18
|
internal class G2CfwScene {
|
|
18
19
|
companion object {
|
|
@@ -20,7 +21,9 @@ internal class G2CfwScene {
|
|
|
20
21
|
const val RECORD_BYTES = 20
|
|
21
22
|
const val FRAME_PERIOD_MS = 33.0
|
|
22
23
|
const val TEXT_LINE_HEIGHT = 27
|
|
24
|
+
const val TEXT_INSET = 2
|
|
23
25
|
const val MAX_TEXT_BYTES = 64
|
|
26
|
+
const val MAX_INLINE_BYTES = 128
|
|
24
27
|
const val MAX_TILE_SIZE = 255
|
|
25
28
|
/** Mode-13/15 options: identity ramp, transparent source color 0. */
|
|
26
29
|
const val TEXT_OPTIONS = 0x1F
|
|
@@ -42,6 +45,7 @@ internal class G2CfwScene {
|
|
|
42
45
|
const val TYPE_IMAGE = 14
|
|
43
46
|
const val TYPE_TEXT = 15
|
|
44
47
|
const val TYPE_TEXT_CACHED = 16
|
|
48
|
+
const val TYPE_TEXT_INLINE = 17
|
|
45
49
|
|
|
46
50
|
/** Geometry parameters a TWEEN may interpolate, per type. Texture offsets and lengths never tween. */
|
|
47
51
|
fun tweenableMask(type: Int): Int =
|
|
@@ -52,6 +56,7 @@ internal class G2CfwScene {
|
|
|
52
56
|
TYPE_TRI, TYPE_TRI_FILL, TYPE_BEZIER2 -> 0x3F
|
|
53
57
|
TYPE_QUAD, TYPE_QUAD_FILL, TYPE_BEZIER3 -> 0xFF
|
|
54
58
|
TYPE_IMAGE, TYPE_TEXT, TYPE_TEXT_CACHED -> 0x03
|
|
59
|
+
TYPE_TEXT_INLINE -> 0x0F
|
|
55
60
|
else -> 0
|
|
56
61
|
}
|
|
57
62
|
|
|
@@ -71,7 +76,7 @@ internal class G2CfwScene {
|
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
/** UTF-8 bytes of a line without firmware control bytes (1..31), cut at a codepoint boundary. */
|
|
74
|
-
fun textBytes(line: String): ByteArray {
|
|
79
|
+
fun textBytes(line: String, limit: Int = MAX_TEXT_BYTES): ByteArray {
|
|
75
80
|
val out = ByteArrayOutputStream()
|
|
76
81
|
var i = 0
|
|
77
82
|
while (i < line.length) {
|
|
@@ -79,7 +84,7 @@ internal class G2CfwScene {
|
|
|
79
84
|
i += Character.charCount(cp)
|
|
80
85
|
if (cp < 32) continue
|
|
81
86
|
val encoded = String(Character.toChars(cp)).toByteArray(Charsets.UTF_8)
|
|
82
|
-
if (out.size() + encoded.size >
|
|
87
|
+
if (out.size() + encoded.size > limit) break
|
|
83
88
|
out.write(encoded)
|
|
84
89
|
}
|
|
85
90
|
return out.toByteArray()
|
|
@@ -96,7 +101,10 @@ internal class G2CfwScene {
|
|
|
96
101
|
out.write((value ushr 8) and 0xFF)
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* `[0][slot][type][flags=visible][color][width][p0..p7 LE]`, or for TEXT_INLINE
|
|
106
|
+
* `[0][slot][17][1][color][width][x][y][w][h][len][bytes]`.
|
|
107
|
+
*/
|
|
100
108
|
fun setRecord(slot: Slot, index: Int): ByteArray {
|
|
101
109
|
val out = ByteArrayOutputStream()
|
|
102
110
|
out.write(0)
|
|
@@ -105,7 +113,13 @@ internal class G2CfwScene {
|
|
|
105
113
|
out.write(1)
|
|
106
114
|
out.write(slot.color)
|
|
107
115
|
out.write(slot.width)
|
|
108
|
-
|
|
116
|
+
val inline = slot.type == TYPE_TEXT_INLINE
|
|
117
|
+
for (k in 0 until (if (inline) 4 else 8)) le(out, slot.params[k])
|
|
118
|
+
if (inline) {
|
|
119
|
+
val bytes = slot.text.take(MAX_INLINE_BYTES).toByteArray()
|
|
120
|
+
out.write(bytes.size)
|
|
121
|
+
out.write(bytes)
|
|
122
|
+
}
|
|
109
123
|
return out.toByteArray()
|
|
110
124
|
}
|
|
111
125
|
|
|
@@ -114,7 +128,7 @@ internal class G2CfwScene {
|
|
|
114
128
|
* change is not a pure geometry/stroke change of the same shape type.
|
|
115
129
|
*/
|
|
116
130
|
fun tweenRecord(old: Slot, new: Slot, index: Int, frames: Int, curve: IntArray): ByteArray? {
|
|
117
|
-
if (old.type != new.type) return null
|
|
131
|
+
if (old.type != new.type || !old.text.contentEquals(new.text)) return null
|
|
118
132
|
val tweenable = tweenableMask(new.type)
|
|
119
133
|
var mask = 0
|
|
120
134
|
val values = mutableListOf<Int>()
|
|
@@ -126,7 +140,9 @@ internal class G2CfwScene {
|
|
|
126
140
|
}
|
|
127
141
|
val isTexture = new.type >= TYPE_IMAGE
|
|
128
142
|
if (old.color != new.color) {
|
|
129
|
-
|
|
143
|
+
// Inline text may fade (low nibble) as long as its option bits stay put.
|
|
144
|
+
val inlineFade = new.type == TYPE_TEXT_INLINE && (old.color and 0xF0) == (new.color and 0xF0)
|
|
145
|
+
if (isTexture && !inlineFade) return null
|
|
130
146
|
mask = mask or 0x100
|
|
131
147
|
values.add(new.color)
|
|
132
148
|
}
|
|
@@ -147,15 +163,19 @@ internal class G2CfwScene {
|
|
|
147
163
|
}
|
|
148
164
|
}
|
|
149
165
|
|
|
150
|
-
/**
|
|
151
|
-
|
|
166
|
+
/**
|
|
167
|
+
* One firmware slot: type, 4-bit color (or options byte), stroke width, eight
|
|
168
|
+
* int16 parameters, and the record's own bytes for TEXT_INLINE.
|
|
169
|
+
*/
|
|
170
|
+
class Slot(val type: Int, val color: Int, val width: Int, params: List<Int>, val text: ByteArray = ByteArray(0)) {
|
|
152
171
|
val params: IntArray = IntArray(8) { params.getOrElse(it) { 0 } }
|
|
153
172
|
|
|
154
173
|
override fun equals(other: Any?): Boolean =
|
|
155
174
|
other is Slot && other.type == type && other.color == color && other.width == width &&
|
|
156
|
-
other.params.contentEquals(params)
|
|
175
|
+
other.params.contentEquals(params) && other.text.contentEquals(text)
|
|
157
176
|
|
|
158
|
-
override fun hashCode(): Int =
|
|
177
|
+
override fun hashCode(): Int =
|
|
178
|
+
(((type * 31 + color) * 31 + width) * 31 + params.contentHashCode()) * 31 + text.contentHashCode()
|
|
159
179
|
}
|
|
160
180
|
|
|
161
181
|
class ElementState(val id: String, val contentHash: String, val slots: List<Slot>, val indices: List<Int>)
|
|
@@ -189,11 +209,15 @@ internal class G2CfwScene {
|
|
|
189
209
|
return Triple(clampCoord(element.x + element.w / 2 + ox), clampCoord(element.y + element.h / 2 + oy), r)
|
|
190
210
|
}
|
|
191
211
|
|
|
192
|
-
/**
|
|
212
|
+
/**
|
|
213
|
+
* Slots for one element, or null when the texture cache cannot hold its content.
|
|
214
|
+
* [inlineText] selects TEXT_INLINE records for default/builtin text.
|
|
215
|
+
*/
|
|
193
216
|
fun slotsFor(
|
|
194
217
|
element: SceneElement,
|
|
195
218
|
ox: Int,
|
|
196
219
|
oy: Int,
|
|
220
|
+
inlineText: Boolean,
|
|
197
221
|
cache: G2CfwTextureCache,
|
|
198
222
|
rasterize: (SceneElement, Int, Int) -> ByteArray?,
|
|
199
223
|
): List<Slot>? {
|
|
@@ -245,13 +269,46 @@ internal class G2CfwScene {
|
|
|
245
269
|
val params = (0 until n).flatMap { point(element, it, ox, oy).toList() }
|
|
246
270
|
listOf(Slot(if (n == 3) TYPE_BEZIER2 else TYPE_BEZIER3, color, stroke(element), params))
|
|
247
271
|
}
|
|
248
|
-
"text" ->
|
|
249
|
-
|
|
272
|
+
"text" ->
|
|
273
|
+
when {
|
|
274
|
+
element.font == "mono" -> rasterSlots(element, x, y, cache, rasterize)
|
|
275
|
+
inlineText -> inlineTextSlots(element, x, y)
|
|
276
|
+
element.font == "builtin" -> builtinTextSlots(element, x, y, cache)
|
|
277
|
+
else -> rasterSlots(element, x, y, cache, rasterize)
|
|
278
|
+
}
|
|
250
279
|
"image" -> rasterSlots(element, x, y, cache, rasterize)
|
|
251
280
|
else -> emptyList()
|
|
252
281
|
}
|
|
253
282
|
}
|
|
254
283
|
|
|
284
|
+
/** One TEXT_INLINE slot per line, drawn by the glasses' font at the phone-raster 2 px inset, clipped to the box. */
|
|
285
|
+
private fun inlineTextSlots(element: SceneElement, x: Int, y: Int): List<Slot> {
|
|
286
|
+
val out = mutableListOf<Slot>()
|
|
287
|
+
val w = element.w.coerceIn(0, 2047)
|
|
288
|
+
val h = element.h.coerceIn(0, 2047)
|
|
289
|
+
if (element.border > 0) {
|
|
290
|
+
out.add(Slot(TYPE_RECT, gray(element), stroke(element), listOf(x, y, w, h, element.radius.coerceIn(0, 2047))))
|
|
291
|
+
}
|
|
292
|
+
val options = 0x10 or gray(element)
|
|
293
|
+
val maxLines = maxOf(1, element.h / TEXT_LINE_HEIGHT)
|
|
294
|
+
val lines = (element.text ?: "").split("\n").take(maxLines)
|
|
295
|
+
for ((index, line) in lines.withIndex()) {
|
|
296
|
+
val bytes = textBytes(line, MAX_INLINE_BYTES)
|
|
297
|
+
if (bytes.isEmpty()) continue
|
|
298
|
+
val lineY = y + TEXT_INSET + index * TEXT_LINE_HEIGHT
|
|
299
|
+
val clipW = maxOf(0, element.w - TEXT_INSET).coerceAtMost(2047)
|
|
300
|
+
val clipH = maxOf(0, element.h - TEXT_INSET - index * TEXT_LINE_HEIGHT).coerceAtMost(2047)
|
|
301
|
+
out.add(
|
|
302
|
+
Slot(
|
|
303
|
+
TYPE_TEXT_INLINE, options, 0,
|
|
304
|
+
listOf(clampCoord(x + TEXT_INSET), clampCoord(lineY), clipW, clipH, bytes.size),
|
|
305
|
+
bytes,
|
|
306
|
+
),
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
return out
|
|
310
|
+
}
|
|
311
|
+
|
|
255
312
|
private fun builtinTextSlots(element: SceneElement, x: Int, y: Int, cache: G2CfwTextureCache): List<Slot>? {
|
|
256
313
|
val out = mutableListOf<Slot>()
|
|
257
314
|
if (element.border > 0) {
|
|
@@ -324,10 +381,11 @@ internal class G2CfwScene {
|
|
|
324
381
|
originY: Int,
|
|
325
382
|
cache: G2CfwTextureCache,
|
|
326
383
|
rasterize: (SceneElement, Int, Int) -> ByteArray?,
|
|
384
|
+
inlineText: Boolean = false,
|
|
327
385
|
): Encoded? {
|
|
328
386
|
val desired = mutableListOf<Desired>()
|
|
329
387
|
for (element in frame.elements) {
|
|
330
|
-
val slots = slotsFor(element, originX, originY, cache, rasterize) ?: return null
|
|
388
|
+
val slots = slotsFor(element, originX, originY, inlineText, cache, rasterize) ?: return null
|
|
331
389
|
desired.add(
|
|
332
390
|
Desired(element.id, element.contentHash, slots, transitionFrames(element.transitionMs), easingCurve(element.easing)),
|
|
333
391
|
)
|
|
@@ -50,8 +50,8 @@ internal object G2FirmwareCatalog {
|
|
|
50
50
|
displayName = "Glassly CFW",
|
|
51
51
|
version = "2.2.9.22 CFW",
|
|
52
52
|
fileName = "g2-glassly-cfw-2.2.9.22.evenota.bin",
|
|
53
|
-
sha256 = "
|
|
54
|
-
size =
|
|
53
|
+
sha256 = "0c5990320d9f48fdca9b539be801f8f231f530fba286353c247dbe7911283f63",
|
|
54
|
+
size = 4_528_170,
|
|
55
55
|
),
|
|
56
56
|
)
|
|
57
57
|
|