@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.
@@ -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
- sendCurrentState()
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
- dispatchSceneFrame(frame)
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.113"
7
- const val RELEASE_SET_ID: String = "glassly-0.1.0-dev.113"
8
- const val SOURCE_COMMIT: String = "e63da43b467758373b960b0ee9167ef29938a11c"
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
- /** TEXT_INLINE records: default text drawn by the glasses' font, no cache upload. */
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() = "inlinetext17" in cfwFeatures
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 = "560d4d94344573d11ba58690221c0ea7d5a5e8c8d29c12f158e81078484b815c",
54
- size = 4_528_184,
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.113",
5
- "releaseSetId": "glassly-0.1.0-dev.113",
6
- "sourceCommit": "e63da43b467758373b960b0ee9167ef29938a11c",
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.113\",\n \"releaseSetId\": \"glassly-0.1.0-dev.113\",\n \"sourceCommit\": \"e63da43b467758373b960b0ee9167ef29938a11c\",\n \"otaManifestUrl\": null,\n \"otaManifestSha256\": null\n})\n"]}
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"]}
@@ -491,6 +491,7 @@ public final class BluetoothSDK {
491
491
  }
492
492
 
493
493
  public func clearDisplay() async throws {
494
+ DeviceManager.shared.cancelPendingDisplay()
494
495
  DeviceManager.shared.sgc?.clearDisplay()
495
496
  }
496
497
 
@@ -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.113"
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
- sendCurrentState()
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
- dispatchSceneFrame(frame)
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.113"
7
- static let releaseSetId = "glassly-0.1.0-dev.113"
8
- static let sourceCommit = "e63da43b467758373b960b0ee9167ef29938a11c"
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
  }
@@ -1810,9 +1810,10 @@ final class G2: NSObject, SGCManager {
1810
1810
  .allSatisfy(cfwFeatures.contains)
1811
1811
  }
1812
1812
 
1813
- /// TEXT_INLINE records: default text drawn by the glasses' font, no cache upload.
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
- cfwFeatures.contains("inlinetext17")
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: "560d4d94344573d11ba58690221c0ea7d5a5e8c8d29c12f158e81078484b815c",
41
- size: 4_528_184
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glassly/bluetooth-sdk",
3
- "version": "0.1.0-dev.113",
3
+ "version": "0.1.0-dev.114",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -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.113",
16
- "releaseSetId": "glassly-0.1.0-dev.113",
17
- "sourceCommit": "e63da43b467758373b960b0ee9167ef29938a11c",
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
  })