@glassly/bluetooth-sdk 0.1.0-dev.112 → 0.1.0-dev.114

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/android/src/main/assets/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
  2. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +24 -2
  3. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +7 -0
  4. package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +198 -0
  5. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +3 -3
  6. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +9 -1
  7. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +3 -2
  8. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +79 -21
  9. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +2 -2
  10. package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
  11. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +37 -3
  12. package/build/generated/releaseMetadata.js +3 -3
  13. package/build/generated/releaseMetadata.js.map +1 -1
  14. package/ios/Source/BluetoothSDK.swift +1 -0
  15. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  16. package/ios/Source/DeviceManager.swift +27 -2
  17. package/ios/Source/DeviceStore.swift +7 -0
  18. package/ios/Source/DisplayPacer.swift +179 -0
  19. package/ios/Source/GeneratedReleaseMetadata.swift +3 -3
  20. package/ios/Source/Resources/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
  21. package/ios/Source/sgcs/G2.swift +8 -1
  22. package/ios/Source/sgcs/G2CfwRenderer.swift +5 -3
  23. package/ios/Source/sgcs/G2CfwScene.swift +67 -19
  24. package/ios/Source/sgcs/G2Flasher.swift +2 -2
  25. package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
  26. package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +46 -2
  27. package/package.json +1 -1
  28. package/src/generated/releaseMetadata.ts +3 -3
@@ -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
+ }
@@ -35,8 +35,8 @@ class G2CfwSceneTest {
35
35
  private fun frame(elements: List<SceneElement>, replay: Boolean = false) =
36
36
  SceneFrame("app", 1, replay, elements, emptyList())
37
37
 
38
- private fun encode(scene: G2CfwScene, cache: G2CfwTextureCache, elements: List<SceneElement>) =
39
- scene.encode(frame(elements), 32, 96, cache) { _, w, h -> ByteArray(w * h) { 0xFF.toByte() } }
38
+ private fun encode(scene: G2CfwScene, cache: G2CfwTextureCache, elements: List<SceneElement>, inlineText: Boolean = false) =
39
+ scene.encode(frame(elements), 32, 96, cache, { _, w, h -> ByteArray(w * h) { 0xFF.toByte() } }, inlineText)
40
40
 
41
41
  private fun le(v: Int): List<Byte> = listOf((v and 0xFF).toByte(), ((v ushr 8) and 0xFF).toByte())
42
42
 
@@ -158,7 +158,7 @@ class G2CfwSceneTest {
158
158
  val (scene, cache) = fresh()
159
159
  assertNull(encode(scene, cache, (0 until 129).map { element("s$it", "circle", w = 11, h = 11) }))
160
160
  val huge = element("big", "image", w = 2000, h = 2000, hash = "big")
161
- val full = scene.encode(frame(listOf(huge)), 0, 0, cache) { _, w, h -> ByteArray(w * h) { (it * 37).toByte() } }
161
+ val full = scene.encode(frame(listOf(huge)), 0, 0, cache, { _, w, h -> ByteArray(w * h) { (it * 37).toByte() } })
162
162
  assertNull(full)
163
163
  }
164
164
 
@@ -172,4 +172,38 @@ class G2CfwSceneTest {
172
172
  assertEquals(255, G2CfwScene.transitionFrames(60000))
173
173
  assertArrayEquals(intArrayOf(107, 0, 148, 255), G2CfwScene.easingCurve("ease-in-out"))
174
174
  }
175
+
176
+ @Test
177
+ fun inlineTextRecordsCarryTheirBytesAndAnimate() {
178
+ val (scene, cache) = fresh()
179
+ val mono = element("m", "text", y = 200, w = 100, h = 30, text = "grid", font = "mono", hash = "mono")
180
+ val first = requireNotNull(
181
+ encode(scene, cache, listOf(element("t", "text", x = 10, y = 10, w = 200, h = 60, text = "hi\nthere", color = 9), mono), true),
182
+ )
183
+ assertEquals(1, first.uploads.size)
184
+ assertEquals(listOf(listOf(0, 1), listOf(2)), scene.elements.map { it.indices })
185
+ val p = first.payload
186
+ assertEquals(listOf<Byte>(0, 0, 17, 1, 0x19, 0), bytes(p, 3, 9))
187
+ assertEquals(le(44) + le(108) + le(198) + le(58), bytes(p, 9, 17))
188
+ assertEquals(listOf<Byte>(2, 0x68, 0x69), bytes(p, 17, 20))
189
+ assertEquals(listOf<Byte>(0, 1, 17), bytes(p, 20, 23))
190
+ assertEquals(le(44) + le(135) + le(198) + le(31), bytes(p, 26, 34))
191
+ assertEquals("there".toByteArray().toList(), bytes(p, 35, 40))
192
+ assertEquals(0.toByte(), p[40])
193
+ assertEquals(14.toByte(), p[42])
194
+
195
+ val moved = requireNotNull(
196
+ encode(scene, cache, listOf(element("t", "text", x = 10, y = 40, w = 200, h = 60, text = "hi\nthere", color = 9, transitionMs = 330), mono), true),
197
+ )
198
+ assertEquals(listOf<Byte>(5, 0, 0x02, 0x00, 10), bytes(moved.payload, 3, 8))
199
+ val retexted = requireNotNull(
200
+ encode(scene, cache, listOf(element("t", "text", x = 10, y = 40, w = 200, h = 60, text = "ho\nthere", color = 9, transitionMs = 330), mono), true),
201
+ )
202
+ assertEquals(listOf<Byte>(0, 0, 17), bytes(retexted.payload, 3, 6))
203
+ val faded = requireNotNull(
204
+ encode(scene, cache, listOf(element("t", "text", x = 10, y = 40, w = 200, h = 60, text = "ho\nthere", color = 3, transitionMs = 330), mono), true),
205
+ )
206
+ assertEquals(listOf<Byte>(5, 0, 0x00, 0x01, 10), bytes(faded.payload, 3, 8))
207
+ assertEquals(le(0x13), bytes(faded.payload, 12, 14))
208
+ }
175
209
  }
@@ -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.112",
5
- "releaseSetId": "glassly-0.1.0-dev.112",
6
- "sourceCommit": "385c3de9054122bf005790a6b1faf04d3117d512",
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.112\",\n \"releaseSetId\": \"glassly-0.1.0-dev.112\",\n \"sourceCommit\": \"385c3de9054122bf005790a6b1faf04d3117d512\",\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.112"
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.112"
7
- static let releaseSetId = "glassly-0.1.0-dev.112"
8
- static let sourceCommit = "385c3de9054122bf005790a6b1faf04d3117d512"
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
  }