@glassly/bluetooth-sdk 0.1.0-dev.113 → 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 +5 -2
- 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/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 +3 -2
- package/ios/Source/sgcs/G2Flasher.swift +2 -2
- package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
- 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,9 +1578,12 @@ class G2 : SGCManager() {
|
|
|
1578
1578
|
supportsCfwSceneRendering &&
|
|
1579
1579
|
listOf("scene17", "shapes16", "anim18", "texcache12", "teximg13", "font15").all { it in cfwFeatures }
|
|
1580
1580
|
|
|
1581
|
-
/**
|
|
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
|
+
*/
|
|
1582
1585
|
private val supportsCfwInlineText: Boolean
|
|
1583
|
-
get() =
|
|
1586
|
+
get() = cfwProtocolVersion >= 19
|
|
1584
1587
|
|
|
1585
1588
|
/** The on-glass slot table must be re-SET on the next vector frame. */
|
|
1586
1589
|
private var cfwSceneInvalid = true
|
|
@@ -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
|
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
package com.glassly.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
import com.glassly.bluetoothsdk.sgcs.SceneElement
|
|
4
|
+
import com.glassly.bluetoothsdk.sgcs.SceneFrame
|
|
5
|
+
import org.junit.Assert.assertEquals
|
|
6
|
+
import org.junit.Assert.assertFalse
|
|
7
|
+
import org.junit.Assert.assertNotNull
|
|
8
|
+
import org.junit.Assert.assertNull
|
|
9
|
+
import org.junit.Assert.assertTrue
|
|
10
|
+
import org.junit.Test
|
|
11
|
+
|
|
12
|
+
class DisplayPacerTest {
|
|
13
|
+
private class FakeScheduler : DisplayPacer.Scheduler {
|
|
14
|
+
var delay: Long? = null
|
|
15
|
+
var block: (() -> Unit)? = null
|
|
16
|
+
var scheduled = 0
|
|
17
|
+
|
|
18
|
+
override fun schedule(delayMs: Long, block: () -> Unit): () -> Unit {
|
|
19
|
+
scheduled++
|
|
20
|
+
delay = delayMs
|
|
21
|
+
this.block = block
|
|
22
|
+
return {
|
|
23
|
+
if (this.block === block) {
|
|
24
|
+
this.block = null
|
|
25
|
+
delay = null
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fun fire() {
|
|
31
|
+
val b = block ?: error("no timer armed")
|
|
32
|
+
block = null
|
|
33
|
+
delay = null
|
|
34
|
+
b()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private class Harness(fps: Int = 8) {
|
|
39
|
+
var now = 1_000L
|
|
40
|
+
val scheduler = FakeScheduler()
|
|
41
|
+
val scenes = mutableListOf<SceneFrame>()
|
|
42
|
+
var legacySends = 0
|
|
43
|
+
val pacer =
|
|
44
|
+
DisplayPacer(
|
|
45
|
+
dispatchScene = { scenes.add(it) },
|
|
46
|
+
sendLegacy = { legacySends++ },
|
|
47
|
+
clock = { now },
|
|
48
|
+
scheduler = scheduler,
|
|
49
|
+
).also { it.setMaxFps(fps) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private fun element(id: String, change: String = "created", x: Int = 0) =
|
|
53
|
+
SceneElement(
|
|
54
|
+
id = id, type = "text", x = x, y = 0, w = 10, h = 10, text = id, data = null,
|
|
55
|
+
border = 0, radius = 0, change = change, contentHash = "h-$id",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
private fun frame(
|
|
59
|
+
vararg elements: SceneElement,
|
|
60
|
+
removed: List<String> = emptyList(),
|
|
61
|
+
appId: String = "app",
|
|
62
|
+
replay: Boolean = false,
|
|
63
|
+
epoch: Int = 1,
|
|
64
|
+
) = SceneFrame(appId = appId, epoch = epoch, replay = replay, elements = elements.toList(), removed = removed)
|
|
65
|
+
|
|
66
|
+
private fun SceneFrame.change(id: String) = elements.first { it.id == id }.change
|
|
67
|
+
|
|
68
|
+
@Test
|
|
69
|
+
fun firstSubmitSendsImmediately() {
|
|
70
|
+
val h = Harness()
|
|
71
|
+
h.pacer.submitScene(frame(element("a")))
|
|
72
|
+
assertEquals(1, h.scenes.size)
|
|
73
|
+
assertNull(h.scheduler.block)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@Test
|
|
77
|
+
fun secondSubmitInsideWindowIsHeldUntilTheSlot() {
|
|
78
|
+
val h = Harness(fps = 10) // 100 ms slots
|
|
79
|
+
h.pacer.submitScene(frame(element("a"), epoch = 1))
|
|
80
|
+
h.now += 30
|
|
81
|
+
h.pacer.submitScene(frame(element("a", change = "updated"), epoch = 2))
|
|
82
|
+
assertEquals(1, h.scenes.size)
|
|
83
|
+
assertEquals(70L, h.scheduler.delay)
|
|
84
|
+
h.now += 70
|
|
85
|
+
h.scheduler.fire()
|
|
86
|
+
assertEquals(2, h.scenes.size)
|
|
87
|
+
assertEquals(2, h.scenes[1].epoch)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@Test
|
|
91
|
+
fun rapidFramesCoalesceIntoOneMergedSend() {
|
|
92
|
+
val h = Harness(fps = 10)
|
|
93
|
+
h.pacer.submitScene(frame(element("a"), epoch = 1))
|
|
94
|
+
h.now += 10
|
|
95
|
+
h.pacer.submitScene(frame(element("a", change = "updated"), element("b", change = "created"), epoch = 2))
|
|
96
|
+
h.now += 10
|
|
97
|
+
h.pacer.submitScene(frame(element("a", change = "unchanged"), element("b", change = "moved", x = 5), epoch = 3))
|
|
98
|
+
h.now += 10
|
|
99
|
+
h.pacer.submitScene(
|
|
100
|
+
frame(element("a", change = "unchanged"), element("b", change = "unchanged", x = 5), epoch = 4),
|
|
101
|
+
)
|
|
102
|
+
assertEquals(1, h.scheduler.scheduled)
|
|
103
|
+
h.now += 70
|
|
104
|
+
h.scheduler.fire()
|
|
105
|
+
assertEquals(2, h.scenes.size)
|
|
106
|
+
val sent = h.scenes[1]
|
|
107
|
+
assertEquals(4, sent.epoch)
|
|
108
|
+
assertEquals("updated", sent.change("a"))
|
|
109
|
+
assertEquals("created", sent.change("b"))
|
|
110
|
+
assertEquals(5, sent.elements.first { it.id == "b" }.x)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@Test
|
|
114
|
+
fun updatedThenUnchangedBecomesUpdated() {
|
|
115
|
+
val h = Harness()
|
|
116
|
+
h.pacer.submitScene(frame(element("a")))
|
|
117
|
+
h.now += 1
|
|
118
|
+
h.pacer.submitScene(frame(element("a", change = "moved", x = 3)))
|
|
119
|
+
h.now += 1
|
|
120
|
+
h.pacer.submitScene(frame(element("a", change = "unchanged", x = 3)))
|
|
121
|
+
h.now += 200
|
|
122
|
+
h.scheduler.fire()
|
|
123
|
+
assertEquals("updated", h.scenes[1].change("a"))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@Test
|
|
127
|
+
fun removedThenRecreatedIdIsNotRemoved() {
|
|
128
|
+
val h = Harness()
|
|
129
|
+
h.pacer.submitScene(frame(element("a"), element("b")))
|
|
130
|
+
h.now += 1
|
|
131
|
+
h.pacer.submitScene(frame(element("a", change = "unchanged"), removed = listOf("b")))
|
|
132
|
+
h.now += 1
|
|
133
|
+
h.pacer.submitScene(frame(element("a", change = "unchanged"), element("b", change = "created")))
|
|
134
|
+
h.now += 200
|
|
135
|
+
h.scheduler.fire()
|
|
136
|
+
val sent = h.scenes[1]
|
|
137
|
+
assertTrue(sent.removed.isEmpty())
|
|
138
|
+
assertEquals("created", sent.change("b"))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@Test
|
|
142
|
+
fun pendingRemovesSurviveIntoTheMergedFrame() {
|
|
143
|
+
val h = Harness()
|
|
144
|
+
h.pacer.submitScene(frame(element("a"), element("b"), element("c")))
|
|
145
|
+
h.now += 1
|
|
146
|
+
h.pacer.submitScene(frame(element("a", change = "unchanged"), removed = listOf("b", "c")))
|
|
147
|
+
h.now += 1
|
|
148
|
+
h.pacer.submitScene(frame(element("a", change = "unchanged"), removed = listOf("c")))
|
|
149
|
+
h.now += 200
|
|
150
|
+
h.scheduler.fire()
|
|
151
|
+
assertEquals(listOf("b", "c"), h.scenes[1].removed)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
@Test
|
|
155
|
+
fun appSwitchDiscardsPending() {
|
|
156
|
+
val h = Harness()
|
|
157
|
+
h.pacer.submitScene(frame(element("a"), appId = "one"))
|
|
158
|
+
h.now += 1
|
|
159
|
+
h.pacer.submitScene(frame(element("a", change = "updated"), removed = listOf("z"), appId = "one"))
|
|
160
|
+
h.now += 1
|
|
161
|
+
h.pacer.submitScene(frame(element("b", change = "created"), appId = "two", replay = true))
|
|
162
|
+
h.now += 200
|
|
163
|
+
h.scheduler.fire()
|
|
164
|
+
val sent = h.scenes[1]
|
|
165
|
+
assertEquals("two", sent.appId)
|
|
166
|
+
assertTrue(sent.removed.isEmpty())
|
|
167
|
+
assertEquals(listOf("b"), sent.elements.map { it.id })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@Test
|
|
171
|
+
fun emptyFrameFlushesImmediatelyWithPendingRemoves() {
|
|
172
|
+
val h = Harness()
|
|
173
|
+
h.pacer.submitScene(frame(element("a"), element("b")))
|
|
174
|
+
h.now += 1
|
|
175
|
+
h.pacer.submitScene(frame(element("a", change = "unchanged"), removed = listOf("b")))
|
|
176
|
+
h.now += 1
|
|
177
|
+
h.pacer.flushScene(frame(removed = listOf("a")))
|
|
178
|
+
assertEquals(2, h.scenes.size)
|
|
179
|
+
assertEquals(setOf("a", "b"), h.scenes[1].removed.toSet())
|
|
180
|
+
assertTrue(h.scenes[1].elements.isEmpty())
|
|
181
|
+
assertNull(h.scheduler.block)
|
|
182
|
+
// Nothing left pending: a later timer must not resend.
|
|
183
|
+
h.now += 200
|
|
184
|
+
h.pacer.setMaxFps(8)
|
|
185
|
+
assertEquals(2, h.scenes.size)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@Test
|
|
189
|
+
fun clearViewFlushesImmediatelyAndDropsPendingScene() {
|
|
190
|
+
val h = Harness()
|
|
191
|
+
h.pacer.submitScene(frame(element("a")))
|
|
192
|
+
h.now += 1
|
|
193
|
+
h.pacer.submitScene(frame(element("a", change = "updated")))
|
|
194
|
+
assertNotNull(h.scheduler.block)
|
|
195
|
+
h.pacer.flushLegacy()
|
|
196
|
+
assertEquals(1, h.legacySends)
|
|
197
|
+
assertEquals(1, h.scenes.size)
|
|
198
|
+
assertNull(h.scheduler.block)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@Test
|
|
202
|
+
fun legacyAfterSceneDropsThePendingScene() {
|
|
203
|
+
val h = Harness()
|
|
204
|
+
h.pacer.submitScene(frame(element("a")))
|
|
205
|
+
h.now += 1
|
|
206
|
+
h.pacer.submitScene(frame(element("a", change = "updated")))
|
|
207
|
+
h.now += 1
|
|
208
|
+
h.pacer.submitLegacy()
|
|
209
|
+
h.now += 200
|
|
210
|
+
h.scheduler.fire()
|
|
211
|
+
assertEquals(1, h.scenes.size)
|
|
212
|
+
assertEquals(1, h.legacySends)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
@Test
|
|
216
|
+
fun sceneAfterLegacyDropsThePendingLegacy() {
|
|
217
|
+
val h = Harness()
|
|
218
|
+
h.pacer.submitLegacy()
|
|
219
|
+
assertEquals(1, h.legacySends)
|
|
220
|
+
h.now += 1
|
|
221
|
+
h.pacer.submitLegacy()
|
|
222
|
+
h.now += 1
|
|
223
|
+
h.pacer.submitScene(frame(element("a")))
|
|
224
|
+
h.now += 200
|
|
225
|
+
h.scheduler.fire()
|
|
226
|
+
assertEquals(1, h.legacySends)
|
|
227
|
+
assertEquals(1, h.scenes.size)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
@Test
|
|
231
|
+
fun setMaxFpsRearmsAPendingTimer() {
|
|
232
|
+
val h = Harness(fps = 2) // 500 ms slots
|
|
233
|
+
h.pacer.submitScene(frame(element("a")))
|
|
234
|
+
h.now += 50
|
|
235
|
+
h.pacer.submitScene(frame(element("a", change = "updated")))
|
|
236
|
+
assertEquals(450L, h.scheduler.delay)
|
|
237
|
+
h.pacer.setMaxFps(20) // 50 ms slots: the slot is already due
|
|
238
|
+
assertEquals(2, h.scenes.size)
|
|
239
|
+
assertNull(h.scheduler.block)
|
|
240
|
+
|
|
241
|
+
h.now += 10
|
|
242
|
+
h.pacer.submitScene(frame(element("a", change = "updated")))
|
|
243
|
+
assertEquals(40L, h.scheduler.delay)
|
|
244
|
+
h.pacer.setMaxFps(10)
|
|
245
|
+
assertEquals(90L, h.scheduler.delay)
|
|
246
|
+
assertEquals(2, h.scenes.size)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
@Test
|
|
250
|
+
fun cancelDropsPendingWithoutSending() {
|
|
251
|
+
val h = Harness()
|
|
252
|
+
h.pacer.submitScene(frame(element("a")))
|
|
253
|
+
h.now += 1
|
|
254
|
+
h.pacer.submitScene(frame(element("a", change = "updated")))
|
|
255
|
+
h.pacer.cancel()
|
|
256
|
+
assertNull(h.scheduler.block)
|
|
257
|
+
h.now += 200
|
|
258
|
+
h.pacer.setMaxFps(8)
|
|
259
|
+
assertEquals(1, h.scenes.size)
|
|
260
|
+
assertFalse(h.scheduler.block != null)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
@Test
|
|
264
|
+
fun lateSubmitAfterTheSlotSendsMergedImmediately() {
|
|
265
|
+
val h = Harness(fps = 10)
|
|
266
|
+
h.pacer.submitScene(frame(element("a")))
|
|
267
|
+
h.now += 10
|
|
268
|
+
h.pacer.submitScene(frame(element("a", change = "updated"), element("b", change = "created")))
|
|
269
|
+
// The timer is late; a submit past the slot sends now with the pending fold.
|
|
270
|
+
h.now += 200
|
|
271
|
+
h.pacer.submitScene(frame(element("a", change = "unchanged"), element("b", change = "unchanged")))
|
|
272
|
+
assertEquals(2, h.scenes.size)
|
|
273
|
+
assertEquals("updated", h.scenes[1].change("a"))
|
|
274
|
+
assertEquals("created", h.scenes[1].change("b"))
|
|
275
|
+
assertNull(h.scheduler.block)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"familyBaseVersion": "0.1.0",
|
|
4
|
-
"releaseIdentity": "0.1.0-dev.
|
|
5
|
-
"releaseSetId": "glassly-0.1.0-dev.
|
|
6
|
-
"sourceCommit": "
|
|
4
|
+
"releaseIdentity": "0.1.0-dev.114",
|
|
5
|
+
"releaseSetId": "glassly-0.1.0-dev.114",
|
|
6
|
+
"sourceCommit": "17263133b35c04f044ba0d3acd9f80efc81999d4",
|
|
7
7
|
"otaManifestUrl": null,
|
|
8
8
|
"otaManifestSha256": null
|
|
9
9
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,uBAAuB;IACvC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,IAAI;IACtB,mBAAmB,EAAE,IAAI;CAC1B,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"0.1.0\",\n \"releaseIdentity\": \"0.1.0-dev.
|
|
1
|
+
{"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,uBAAuB;IACvC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,IAAI;IACtB,mBAAmB,EAAE,IAAI;CAC1B,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"0.1.0\",\n \"releaseIdentity\": \"0.1.0-dev.114\",\n \"releaseSetId\": \"glassly-0.1.0-dev.114\",\n \"sourceCommit\": \"17263133b35c04f044ba0d3acd9f80efc81999d4\",\n \"otaManifestUrl\": null,\n \"otaManifestSha256\": null\n})\n"]}
|
|
@@ -11,7 +11,7 @@ enum BluetoothSdkDefaults {
|
|
|
11
11
|
static let voiceActivityDetectionEnabled = false
|
|
12
12
|
static let loudnessGateEnabled = true
|
|
13
13
|
private static let infoSdkVersionKey = "BluetoothSdkVersion"
|
|
14
|
-
private static let swiftPackageSdkVersion = "0.1.0-dev.
|
|
14
|
+
private static let swiftPackageSdkVersion = "0.1.0-dev.114"
|
|
15
15
|
private static let swiftPackageSdkVersionPlaceholder = "__GLASSLY" + "_BLUETOOTH_SDK_VERSION__"
|
|
16
16
|
|
|
17
17
|
private static func normalizedSdkVersion(_ value: String?) -> String? {
|
|
@@ -320,6 +320,21 @@ struct ViewState {
|
|
|
320
320
|
/// native re-dispatch coherent (nav exit re-applies a complete scene).
|
|
321
321
|
var sceneState: SceneFrame?
|
|
322
322
|
|
|
323
|
+
/// Paces scene frames and legacy layouts to display_max_fps; clears bypass it.
|
|
324
|
+
private lazy var pacer = DisplayPacer(
|
|
325
|
+
dispatchScene: { [weak self] frame in self?.dispatchSceneFrame(frame) },
|
|
326
|
+
sendLegacy: { [weak self] in self?.sendCurrentState() }
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
func setDisplayMaxFps(_ fps: Int) {
|
|
330
|
+
pacer.setMaxFps(fps)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/// Drop paced display work (screen off): the retained state repaints on re-enable.
|
|
334
|
+
func cancelPendingDisplay() {
|
|
335
|
+
pacer.cancel()
|
|
336
|
+
}
|
|
337
|
+
|
|
323
338
|
override init() {
|
|
324
339
|
Bridge.log("MAN: init()")
|
|
325
340
|
super.init()
|
|
@@ -669,6 +684,7 @@ struct ViewState {
|
|
|
669
684
|
Bridge.log("Initializing manager for wearable: \(wearable)")
|
|
670
685
|
if sgc != nil && sgc?.type != wearable {
|
|
671
686
|
Bridge.log("MAN: Manager already initialized, cleaning up previous sgc")
|
|
687
|
+
pacer.cancel()
|
|
672
688
|
sgc?.cleanup()
|
|
673
689
|
sgc = nil
|
|
674
690
|
resetSystemTimeSync()
|
|
@@ -1310,7 +1326,11 @@ struct ViewState {
|
|
|
1310
1326
|
}
|
|
1311
1327
|
|
|
1312
1328
|
viewState = newViewState
|
|
1313
|
-
|
|
1329
|
+
if layoutType == "clear_view" {
|
|
1330
|
+
pacer.flushLegacy()
|
|
1331
|
+
} else {
|
|
1332
|
+
pacer.submitLegacy()
|
|
1333
|
+
}
|
|
1314
1334
|
}
|
|
1315
1335
|
|
|
1316
1336
|
/// Parse + store a scene frame, then dispatch it.
|
|
@@ -1349,7 +1369,11 @@ struct ViewState {
|
|
|
1349
1369
|
text: " ", data: nil, animationData: nil
|
|
1350
1370
|
)
|
|
1351
1371
|
|
|
1352
|
-
|
|
1372
|
+
if frame.elements.isEmpty {
|
|
1373
|
+
pacer.flushScene(frame)
|
|
1374
|
+
} else {
|
|
1375
|
+
pacer.submitScene(frame)
|
|
1376
|
+
}
|
|
1353
1377
|
}
|
|
1354
1378
|
|
|
1355
1379
|
/// Guarded scene dispatch - mirrors sendCurrentState's send conditions.
|
|
@@ -1947,6 +1971,7 @@ struct ViewState {
|
|
|
1947
1971
|
}
|
|
1948
1972
|
|
|
1949
1973
|
func disconnect(clearPendingConnection: Bool = true) {
|
|
1974
|
+
pacer.cancel()
|
|
1950
1975
|
sgc?.clearDisplay() // clear the screen
|
|
1951
1976
|
sgc?.disconnect()
|
|
1952
1977
|
sgc = nil // Clear the SGC reference after disconnect
|
|
@@ -115,6 +115,7 @@ class DeviceStore {
|
|
|
115
115
|
store.set("bluetooth", "use_native_dashboard", false)
|
|
116
116
|
// Debug escape hatch: G2 CFW direct-framebuffer rendering (off = stock containers).
|
|
117
117
|
store.set("bluetooth", "cfw_scene_renderer", true)
|
|
118
|
+
store.set("bluetooth", "display_max_fps", DisplayPacer.defaultFps)
|
|
118
119
|
// G2 on-glass ANCS notification behavior + app filter (iOS).
|
|
119
120
|
store.set(
|
|
120
121
|
"bluetooth", "notification_ctrl",
|
|
@@ -231,6 +232,11 @@ class DeviceStore {
|
|
|
231
232
|
Task { await DeviceManager.shared.sgc?.cfwSceneRendererToggled(enabled) }
|
|
232
233
|
}
|
|
233
234
|
|
|
235
|
+
case ("bluetooth", "display_max_fps"):
|
|
236
|
+
if let fps = value as? NSNumber {
|
|
237
|
+
DeviceManager.shared.setDisplayMaxFps(fps.intValue)
|
|
238
|
+
}
|
|
239
|
+
|
|
234
240
|
case ("bluetooth", "dashboard_height"):
|
|
235
241
|
scheduleDashboardHeightToGlasses()
|
|
236
242
|
|
|
@@ -278,6 +284,7 @@ class DeviceStore {
|
|
|
278
284
|
case ("bluetooth", "screen_disabled"):
|
|
279
285
|
if let disabled = value as? Bool {
|
|
280
286
|
if disabled {
|
|
287
|
+
DeviceManager.shared.cancelPendingDisplay()
|
|
281
288
|
DeviceManager.shared.sgc?.exit()
|
|
282
289
|
} else {
|
|
283
290
|
DeviceManager.shared.sgc?.clearDisplay()
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Paces display updates leaving the phone to at most `setMaxFps` per second.
|
|
4
|
+
///
|
|
5
|
+
/// Updates that arrive inside the current slot are held and sent at the next
|
|
6
|
+
/// one, last-wins: a newer scene frame replaces a pending one (with the pending
|
|
7
|
+
/// frame's per-element annotations folded in so per-element devices never miss
|
|
8
|
+
/// a change), a legacy layout replaces a pending scene and vice versa. Clears
|
|
9
|
+
/// (`flushScene` with an empty frame, `flushLegacy`) bypass the pacer and drop
|
|
10
|
+
/// pending work.
|
|
11
|
+
@MainActor
|
|
12
|
+
final class DisplayPacer {
|
|
13
|
+
/// Runs `block` after `delayMs`; the returned closure cancels it.
|
|
14
|
+
typealias Scheduler = (_ delayMs: Int64, _ block: @escaping @MainActor () -> Void) -> () -> Void
|
|
15
|
+
|
|
16
|
+
static let minFps = 2
|
|
17
|
+
static let maxFps = 20
|
|
18
|
+
static let defaultFps = 8
|
|
19
|
+
|
|
20
|
+
static func interval(forFps fps: Int) -> Int64 {
|
|
21
|
+
Int64(1000 / min(max(fps, minFps), maxFps))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static func monotonicMs() -> Int64 {
|
|
25
|
+
Int64(DispatchTime.now().uptimeNanoseconds / 1_000_000)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static func taskScheduler(_ delayMs: Int64, _ block: @escaping @MainActor () -> Void) -> () -> Void {
|
|
29
|
+
let task = Task { @MainActor in
|
|
30
|
+
try? await Task.sleep(nanoseconds: UInt64(max(0, delayMs)) * 1_000_000)
|
|
31
|
+
if Task.isCancelled { return }
|
|
32
|
+
block()
|
|
33
|
+
}
|
|
34
|
+
return { task.cancel() }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Fold pending frame `previous` into `next` (both unsent). The device only
|
|
38
|
+
/// ever sees the merged frame, so an element the pending frame changed must
|
|
39
|
+
/// not read as "unchanged" in the merged one, and ids the pending frame
|
|
40
|
+
/// removed must still be removed unless `next` re-creates them. A different
|
|
41
|
+
/// app is already a from-scratch replay.
|
|
42
|
+
static func merge(_ previous: SceneFrame?, into next: SceneFrame) -> SceneFrame {
|
|
43
|
+
guard let previous, previous.appId == next.appId else { return next }
|
|
44
|
+
var pending: [String: SceneElement] = [:]
|
|
45
|
+
for el in previous.elements { pending[el.id] = el }
|
|
46
|
+
let elements = next.elements.map { el -> SceneElement in
|
|
47
|
+
guard let prior = pending[el.id] else { return el }
|
|
48
|
+
if prior.change == "created" { return el.with(change: "created") }
|
|
49
|
+
if prior.change != "unchanged", el.change == "unchanged" { return el.with(change: "updated") }
|
|
50
|
+
return el
|
|
51
|
+
}
|
|
52
|
+
let painted = Set(next.elements.map(\.id))
|
|
53
|
+
var seen = Set<String>()
|
|
54
|
+
var removed: [String] = []
|
|
55
|
+
for id in previous.removed + next.removed where !painted.contains(id) && seen.insert(id).inserted {
|
|
56
|
+
removed.append(id)
|
|
57
|
+
}
|
|
58
|
+
return SceneFrame(
|
|
59
|
+
appId: next.appId, epoch: next.epoch, replay: previous.replay || next.replay,
|
|
60
|
+
elements: elements, removed: removed,
|
|
61
|
+
canvasWidth: next.canvasWidth, canvasHeight: next.canvasHeight
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private let dispatchScene: (SceneFrame) -> Void
|
|
66
|
+
private let sendLegacy: () -> Void
|
|
67
|
+
private let clock: () -> Int64
|
|
68
|
+
private let scheduler: Scheduler
|
|
69
|
+
|
|
70
|
+
private var intervalMs: Int64 = DisplayPacer.interval(forFps: DisplayPacer.defaultFps)
|
|
71
|
+
private var lastSendAt: Int64 = Int64.min / 2
|
|
72
|
+
private var pendingScene: SceneFrame?
|
|
73
|
+
private var pendingLegacy = false
|
|
74
|
+
private var cancelTimer: (() -> Void)?
|
|
75
|
+
|
|
76
|
+
init(
|
|
77
|
+
dispatchScene: @escaping (SceneFrame) -> Void,
|
|
78
|
+
sendLegacy: @escaping () -> Void,
|
|
79
|
+
clock: @escaping () -> Int64 = DisplayPacer.monotonicMs,
|
|
80
|
+
scheduler: @escaping Scheduler = DisplayPacer.taskScheduler
|
|
81
|
+
) {
|
|
82
|
+
self.dispatchScene = dispatchScene
|
|
83
|
+
self.sendLegacy = sendLegacy
|
|
84
|
+
self.clock = clock
|
|
85
|
+
self.scheduler = scheduler
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func setMaxFps(_ fps: Int) {
|
|
89
|
+
intervalMs = Self.interval(forFps: fps)
|
|
90
|
+
guard hasPending else { return }
|
|
91
|
+
stopTimer()
|
|
92
|
+
let now = clock()
|
|
93
|
+
if now - lastSendAt >= intervalMs {
|
|
94
|
+
take(now)?()
|
|
95
|
+
} else {
|
|
96
|
+
armTimer(now)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Send `frame` at the next slot; replaces any pending update.
|
|
101
|
+
func submitScene(_ frame: SceneFrame) {
|
|
102
|
+
let now = clock()
|
|
103
|
+
pendingScene = Self.merge(pendingScene, into: frame)
|
|
104
|
+
pendingLegacy = false
|
|
105
|
+
submitPending(now)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// Send the current legacy view state at the next slot; replaces any pending update.
|
|
109
|
+
func submitLegacy() {
|
|
110
|
+
let now = clock()
|
|
111
|
+
pendingScene = nil
|
|
112
|
+
pendingLegacy = true
|
|
113
|
+
submitPending(now)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// Immediate send for an empty (clearing) frame; pending removes ride along.
|
|
117
|
+
func flushScene(_ frame: SceneFrame) {
|
|
118
|
+
stopTimer()
|
|
119
|
+
let merged = Self.merge(pendingScene, into: frame)
|
|
120
|
+
pendingScene = nil
|
|
121
|
+
pendingLegacy = false
|
|
122
|
+
lastSendAt = clock()
|
|
123
|
+
dispatchScene(merged)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Immediate legacy send (clear_view); drops any pending scene.
|
|
127
|
+
func flushLegacy() {
|
|
128
|
+
stopTimer()
|
|
129
|
+
pendingScene = nil
|
|
130
|
+
pendingLegacy = false
|
|
131
|
+
lastSendAt = clock()
|
|
132
|
+
sendLegacy()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// Drop pending work without sending (disconnect, screen off, clearDisplay).
|
|
136
|
+
func cancel() {
|
|
137
|
+
stopTimer()
|
|
138
|
+
pendingScene = nil
|
|
139
|
+
pendingLegacy = false
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private var hasPending: Bool { pendingScene != nil || pendingLegacy }
|
|
143
|
+
|
|
144
|
+
private func submitPending(_ now: Int64) {
|
|
145
|
+
if now - lastSendAt >= intervalMs {
|
|
146
|
+
stopTimer()
|
|
147
|
+
take(now)?()
|
|
148
|
+
} else if cancelTimer == nil {
|
|
149
|
+
armTimer(now)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private func stopTimer() {
|
|
154
|
+
cancelTimer?()
|
|
155
|
+
cancelTimer = nil
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// Consume pending work into a send closure and stamp the slot.
|
|
159
|
+
private func take(_ now: Int64) -> (() -> Void)? {
|
|
160
|
+
let scene = pendingScene
|
|
161
|
+
let legacy = pendingLegacy
|
|
162
|
+
pendingScene = nil
|
|
163
|
+
pendingLegacy = false
|
|
164
|
+
if scene == nil, !legacy { return nil }
|
|
165
|
+
lastSendAt = now
|
|
166
|
+
if let scene { return { self.dispatchScene(scene) } }
|
|
167
|
+
return { self.sendLegacy() }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Arm the single trailing timer for the next slot.
|
|
171
|
+
private func armTimer(_ now: Int64) {
|
|
172
|
+
let delay = max(0, lastSendAt + intervalMs - now)
|
|
173
|
+
cancelTimer = scheduler(delay) { [weak self] in
|
|
174
|
+
guard let self else { return }
|
|
175
|
+
self.cancelTimer = nil
|
|
176
|
+
self.take(self.clock())?()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -3,9 +3,9 @@ import Foundation
|
|
|
3
3
|
/// Generated by release CI. Do not edit in a release checkout.
|
|
4
4
|
enum GeneratedReleaseMetadata {
|
|
5
5
|
static let familyBaseVersion = "0.1.0"
|
|
6
|
-
static let releaseIdentity = "0.1.0-dev.
|
|
7
|
-
static let releaseSetId = "glassly-0.1.0-dev.
|
|
8
|
-
static let sourceCommit = "
|
|
6
|
+
static let releaseIdentity = "0.1.0-dev.114"
|
|
7
|
+
static let releaseSetId = "glassly-0.1.0-dev.114"
|
|
8
|
+
static let sourceCommit = "17263133b35c04f044ba0d3acd9f80efc81999d4"
|
|
9
9
|
static let otaManifestUrl = ""
|
|
10
10
|
static let otaManifestSha256 = ""
|
|
11
11
|
}
|
|
Binary file
|
package/ios/Source/sgcs/G2.swift
CHANGED
|
@@ -1810,9 +1810,10 @@ final class G2: NSObject, SGCManager {
|
|
|
1810
1810
|
.allSatisfy(cfwFeatures.contains)
|
|
1811
1811
|
}
|
|
1812
1812
|
|
|
1813
|
-
/// TEXT_INLINE records
|
|
1813
|
+
/// TEXT_INLINE records (contract revision 19; no token, the capability
|
|
1814
|
+
/// response must fit one BLE frame): default text drawn by the glasses' font.
|
|
1814
1815
|
private var supportsCfwInlineText: Bool {
|
|
1815
|
-
|
|
1816
|
+
cfwProtocolVersion >= 19
|
|
1816
1817
|
}
|
|
1817
1818
|
|
|
1818
1819
|
/// The on-glass slot table must be re-SET on the next vector frame.
|
|
@@ -37,8 +37,8 @@ enum G2FirmwareCatalog {
|
|
|
37
37
|
displayName: "Glassly CFW",
|
|
38
38
|
version: "2.2.9.22 CFW",
|
|
39
39
|
fileName: "g2-glassly-cfw-2.2.9.22.evenota.bin",
|
|
40
|
-
sha256: "
|
|
41
|
-
size:
|
|
40
|
+
sha256: "0c5990320d9f48fdca9b539be801f8f231f530fba286353c247dbe7911283f63",
|
|
41
|
+
size: 4_528_170
|
|
42
42
|
),
|
|
43
43
|
]
|
|
44
44
|
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
@testable import BluetoothSDK
|
|
2
|
+
import Foundation
|
|
3
|
+
import XCTest
|
|
4
|
+
|
|
5
|
+
@MainActor
|
|
6
|
+
final class DisplayPacerTests: XCTestCase {
|
|
7
|
+
@MainActor private final class Harness {
|
|
8
|
+
var now: Int64 = 1000
|
|
9
|
+
var delay: Int64?
|
|
10
|
+
var block: (@MainActor () -> Void)?
|
|
11
|
+
var scheduled = 0
|
|
12
|
+
var scenes: [SceneFrame] = []
|
|
13
|
+
var legacySends = 0
|
|
14
|
+
var pacer: DisplayPacer!
|
|
15
|
+
|
|
16
|
+
init(fps: Int = 8) {
|
|
17
|
+
pacer = DisplayPacer(
|
|
18
|
+
dispatchScene: { [unowned self] in self.scenes.append($0) },
|
|
19
|
+
sendLegacy: { [unowned self] in self.legacySends += 1 },
|
|
20
|
+
clock: { [unowned self] in self.now },
|
|
21
|
+
scheduler: { [unowned self] delayMs, block in
|
|
22
|
+
self.scheduled += 1
|
|
23
|
+
self.delay = delayMs
|
|
24
|
+
self.block = block
|
|
25
|
+
let token = self.scheduled
|
|
26
|
+
return {
|
|
27
|
+
if self.scheduled == token {
|
|
28
|
+
self.block = nil
|
|
29
|
+
self.delay = nil
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
pacer.setMaxFps(fps)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func fire() {
|
|
38
|
+
guard let b = block else { XCTFail("no timer armed"); return }
|
|
39
|
+
block = nil
|
|
40
|
+
delay = nil
|
|
41
|
+
b()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private func element(_ id: String, change: String = "created", x: Int32 = 0) -> SceneElement {
|
|
46
|
+
SceneElement(
|
|
47
|
+
id: id, type: "text", x: x, y: 0, w: 10, h: 10, text: id, data: nil, border: 0, radius: 0,
|
|
48
|
+
change: change, contentHash: "h-\(id)"
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private func frame(
|
|
53
|
+
_ elements: [SceneElement], removed: [String] = [], appId: String = "app", replay: Bool = false,
|
|
54
|
+
epoch: Int = 1
|
|
55
|
+
) -> SceneFrame {
|
|
56
|
+
SceneFrame(appId: appId, epoch: epoch, replay: replay, elements: elements, removed: removed)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private func change(_ frame: SceneFrame, _ id: String) -> String? {
|
|
60
|
+
frame.elements.first { $0.id == id }?.change
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func testFirstSubmitSendsImmediately() {
|
|
64
|
+
let h = Harness()
|
|
65
|
+
h.pacer.submitScene(frame([element("a")]))
|
|
66
|
+
XCTAssertEqual(h.scenes.count, 1)
|
|
67
|
+
XCTAssertNil(h.block)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func testSecondSubmitInsideWindowIsHeldUntilTheSlot() {
|
|
71
|
+
let h = Harness(fps: 10)
|
|
72
|
+
h.pacer.submitScene(frame([element("a")], epoch: 1))
|
|
73
|
+
h.now += 30
|
|
74
|
+
h.pacer.submitScene(frame([element("a", change: "updated")], epoch: 2))
|
|
75
|
+
XCTAssertEqual(h.scenes.count, 1)
|
|
76
|
+
XCTAssertEqual(h.delay, 70)
|
|
77
|
+
h.now += 70
|
|
78
|
+
h.fire()
|
|
79
|
+
XCTAssertEqual(h.scenes.count, 2)
|
|
80
|
+
XCTAssertEqual(h.scenes[1].epoch, 2)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func testRapidFramesCoalesceIntoOneMergedSend() {
|
|
84
|
+
let h = Harness(fps: 10)
|
|
85
|
+
h.pacer.submitScene(frame([element("a")], epoch: 1))
|
|
86
|
+
h.now += 10
|
|
87
|
+
h.pacer.submitScene(frame([element("a", change: "updated"), element("b", change: "created")], epoch: 2))
|
|
88
|
+
h.now += 10
|
|
89
|
+
h.pacer.submitScene(frame([element("a", change: "unchanged"), element("b", change: "moved", x: 5)], epoch: 3))
|
|
90
|
+
h.now += 10
|
|
91
|
+
h.pacer.submitScene(
|
|
92
|
+
frame([element("a", change: "unchanged"), element("b", change: "unchanged", x: 5)], epoch: 4)
|
|
93
|
+
)
|
|
94
|
+
XCTAssertEqual(h.scheduled, 1)
|
|
95
|
+
h.now += 70
|
|
96
|
+
h.fire()
|
|
97
|
+
XCTAssertEqual(h.scenes.count, 2)
|
|
98
|
+
let sent = h.scenes[1]
|
|
99
|
+
XCTAssertEqual(sent.epoch, 4)
|
|
100
|
+
XCTAssertEqual(change(sent, "a"), "updated")
|
|
101
|
+
XCTAssertEqual(change(sent, "b"), "created")
|
|
102
|
+
XCTAssertEqual(sent.elements.first { $0.id == "b" }?.x, 5)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
func testUpdatedThenUnchangedBecomesUpdated() {
|
|
106
|
+
let h = Harness()
|
|
107
|
+
h.pacer.submitScene(frame([element("a")]))
|
|
108
|
+
h.now += 1
|
|
109
|
+
h.pacer.submitScene(frame([element("a", change: "moved", x: 3)]))
|
|
110
|
+
h.now += 1
|
|
111
|
+
h.pacer.submitScene(frame([element("a", change: "unchanged", x: 3)]))
|
|
112
|
+
h.now += 200
|
|
113
|
+
h.fire()
|
|
114
|
+
XCTAssertEqual(change(h.scenes[1], "a"), "updated")
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func testRemovedThenRecreatedIdIsNotRemoved() {
|
|
118
|
+
let h = Harness()
|
|
119
|
+
h.pacer.submitScene(frame([element("a"), element("b")]))
|
|
120
|
+
h.now += 1
|
|
121
|
+
h.pacer.submitScene(frame([element("a", change: "unchanged")], removed: ["b"]))
|
|
122
|
+
h.now += 1
|
|
123
|
+
h.pacer.submitScene(frame([element("a", change: "unchanged"), element("b", change: "created")]))
|
|
124
|
+
h.now += 200
|
|
125
|
+
h.fire()
|
|
126
|
+
XCTAssertTrue(h.scenes[1].removed.isEmpty)
|
|
127
|
+
XCTAssertEqual(change(h.scenes[1], "b"), "created")
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func testPendingRemovesSurviveIntoTheMergedFrame() {
|
|
131
|
+
let h = Harness()
|
|
132
|
+
h.pacer.submitScene(frame([element("a"), element("b"), element("c")]))
|
|
133
|
+
h.now += 1
|
|
134
|
+
h.pacer.submitScene(frame([element("a", change: "unchanged")], removed: ["b", "c"]))
|
|
135
|
+
h.now += 1
|
|
136
|
+
h.pacer.submitScene(frame([element("a", change: "unchanged")], removed: ["c"]))
|
|
137
|
+
h.now += 200
|
|
138
|
+
h.fire()
|
|
139
|
+
XCTAssertEqual(h.scenes[1].removed, ["b", "c"])
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func testAppSwitchDiscardsPending() {
|
|
143
|
+
let h = Harness()
|
|
144
|
+
h.pacer.submitScene(frame([element("a")], appId: "one"))
|
|
145
|
+
h.now += 1
|
|
146
|
+
h.pacer.submitScene(frame([element("a", change: "updated")], removed: ["z"], appId: "one"))
|
|
147
|
+
h.now += 1
|
|
148
|
+
h.pacer.submitScene(frame([element("b", change: "created")], appId: "two", replay: true))
|
|
149
|
+
h.now += 200
|
|
150
|
+
h.fire()
|
|
151
|
+
let sent = h.scenes[1]
|
|
152
|
+
XCTAssertEqual(sent.appId, "two")
|
|
153
|
+
XCTAssertTrue(sent.removed.isEmpty)
|
|
154
|
+
XCTAssertEqual(sent.elements.map(\.id), ["b"])
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func testEmptyFrameFlushesImmediatelyWithPendingRemoves() {
|
|
158
|
+
let h = Harness()
|
|
159
|
+
h.pacer.submitScene(frame([element("a"), element("b")]))
|
|
160
|
+
h.now += 1
|
|
161
|
+
h.pacer.submitScene(frame([element("a", change: "unchanged")], removed: ["b"]))
|
|
162
|
+
h.now += 1
|
|
163
|
+
h.pacer.flushScene(frame([], removed: ["a"]))
|
|
164
|
+
XCTAssertEqual(h.scenes.count, 2)
|
|
165
|
+
XCTAssertEqual(Set(h.scenes[1].removed), ["a", "b"])
|
|
166
|
+
XCTAssertTrue(h.scenes[1].elements.isEmpty)
|
|
167
|
+
XCTAssertNil(h.block)
|
|
168
|
+
h.now += 200
|
|
169
|
+
h.pacer.setMaxFps(8)
|
|
170
|
+
XCTAssertEqual(h.scenes.count, 2)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
func testClearViewFlushesImmediatelyAndDropsPendingScene() {
|
|
174
|
+
let h = Harness()
|
|
175
|
+
h.pacer.submitScene(frame([element("a")]))
|
|
176
|
+
h.now += 1
|
|
177
|
+
h.pacer.submitScene(frame([element("a", change: "updated")]))
|
|
178
|
+
XCTAssertNotNil(h.block)
|
|
179
|
+
h.pacer.flushLegacy()
|
|
180
|
+
XCTAssertEqual(h.legacySends, 1)
|
|
181
|
+
XCTAssertEqual(h.scenes.count, 1)
|
|
182
|
+
XCTAssertNil(h.block)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func testLegacyAfterSceneDropsThePendingScene() {
|
|
186
|
+
let h = Harness()
|
|
187
|
+
h.pacer.submitScene(frame([element("a")]))
|
|
188
|
+
h.now += 1
|
|
189
|
+
h.pacer.submitScene(frame([element("a", change: "updated")]))
|
|
190
|
+
h.now += 1
|
|
191
|
+
h.pacer.submitLegacy()
|
|
192
|
+
h.now += 200
|
|
193
|
+
h.fire()
|
|
194
|
+
XCTAssertEqual(h.scenes.count, 1)
|
|
195
|
+
XCTAssertEqual(h.legacySends, 1)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
func testSceneAfterLegacyDropsThePendingLegacy() {
|
|
199
|
+
let h = Harness()
|
|
200
|
+
h.pacer.submitLegacy()
|
|
201
|
+
XCTAssertEqual(h.legacySends, 1)
|
|
202
|
+
h.now += 1
|
|
203
|
+
h.pacer.submitLegacy()
|
|
204
|
+
h.now += 1
|
|
205
|
+
h.pacer.submitScene(frame([element("a")]))
|
|
206
|
+
h.now += 200
|
|
207
|
+
h.fire()
|
|
208
|
+
XCTAssertEqual(h.legacySends, 1)
|
|
209
|
+
XCTAssertEqual(h.scenes.count, 1)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func testSetMaxFpsRearmsAPendingTimer() {
|
|
213
|
+
let h = Harness(fps: 2)
|
|
214
|
+
h.pacer.submitScene(frame([element("a")]))
|
|
215
|
+
h.now += 50
|
|
216
|
+
h.pacer.submitScene(frame([element("a", change: "updated")]))
|
|
217
|
+
XCTAssertEqual(h.delay, 450)
|
|
218
|
+
h.pacer.setMaxFps(20)
|
|
219
|
+
XCTAssertEqual(h.scenes.count, 2)
|
|
220
|
+
XCTAssertNil(h.block)
|
|
221
|
+
|
|
222
|
+
h.now += 10
|
|
223
|
+
h.pacer.submitScene(frame([element("a", change: "updated")]))
|
|
224
|
+
XCTAssertEqual(h.delay, 40)
|
|
225
|
+
h.pacer.setMaxFps(10)
|
|
226
|
+
XCTAssertEqual(h.delay, 90)
|
|
227
|
+
XCTAssertEqual(h.scenes.count, 2)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
func testCancelDropsPendingWithoutSending() {
|
|
231
|
+
let h = Harness()
|
|
232
|
+
h.pacer.submitScene(frame([element("a")]))
|
|
233
|
+
h.now += 1
|
|
234
|
+
h.pacer.submitScene(frame([element("a", change: "updated")]))
|
|
235
|
+
h.pacer.cancel()
|
|
236
|
+
XCTAssertNil(h.block)
|
|
237
|
+
h.now += 200
|
|
238
|
+
h.pacer.setMaxFps(8)
|
|
239
|
+
XCTAssertEqual(h.scenes.count, 1)
|
|
240
|
+
XCTAssertNil(h.block)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
func testLateSubmitAfterTheSlotSendsMergedImmediately() {
|
|
244
|
+
let h = Harness(fps: 10)
|
|
245
|
+
h.pacer.submitScene(frame([element("a")]))
|
|
246
|
+
h.now += 10
|
|
247
|
+
h.pacer.submitScene(frame([element("a", change: "updated"), element("b", change: "created")]))
|
|
248
|
+
h.now += 200
|
|
249
|
+
h.pacer.submitScene(frame([element("a", change: "unchanged"), element("b", change: "unchanged")]))
|
|
250
|
+
XCTAssertEqual(h.scenes.count, 2)
|
|
251
|
+
XCTAssertEqual(change(h.scenes[1], "a"), "updated")
|
|
252
|
+
XCTAssertEqual(change(h.scenes[1], "b"), "created")
|
|
253
|
+
XCTAssertNil(h.block)
|
|
254
|
+
}
|
|
255
|
+
}
|
package/package.json
CHANGED
|
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
|
|
|
12
12
|
export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
|
|
13
13
|
"schemaVersion": 1,
|
|
14
14
|
"familyBaseVersion": "0.1.0",
|
|
15
|
-
"releaseIdentity": "0.1.0-dev.
|
|
16
|
-
"releaseSetId": "glassly-0.1.0-dev.
|
|
17
|
-
"sourceCommit": "
|
|
15
|
+
"releaseIdentity": "0.1.0-dev.114",
|
|
16
|
+
"releaseSetId": "glassly-0.1.0-dev.114",
|
|
17
|
+
"sourceCommit": "17263133b35c04f044ba0d3acd9f80efc81999d4",
|
|
18
18
|
"otaManifestUrl": null,
|
|
19
19
|
"otaManifestSha256": null
|
|
20
20
|
})
|