@glassly/bluetooth-sdk 0.1.0-dev.112 → 0.1.0-dev.114
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/assets/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +24 -2
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +7 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +198 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +3 -3
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +9 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +3 -2
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +79 -21
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +2 -2
- package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +37 -3
- package/build/generated/releaseMetadata.js +3 -3
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSDK.swift +1 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +27 -2
- package/ios/Source/DeviceStore.swift +7 -0
- package/ios/Source/DisplayPacer.swift +179 -0
- package/ios/Source/GeneratedReleaseMetadata.swift +3 -3
- package/ios/Source/Resources/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
- package/ios/Source/sgcs/G2.swift +8 -1
- package/ios/Source/sgcs/G2CfwRenderer.swift +5 -3
- package/ios/Source/sgcs/G2CfwScene.swift +67 -19
- package/ios/Source/sgcs/G2Flasher.swift +2 -2
- package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
- package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +46 -2
- package/package.json +1 -1
- package/src/generated/releaseMetadata.ts +3 -3
package/ios/Source/sgcs/G2.swift
CHANGED
|
@@ -1810,6 +1810,12 @@ final class G2: NSObject, SGCManager {
|
|
|
1810
1810
|
.allSatisfy(cfwFeatures.contains)
|
|
1811
1811
|
}
|
|
1812
1812
|
|
|
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.
|
|
1815
|
+
private var supportsCfwInlineText: Bool {
|
|
1816
|
+
cfwProtocolVersion >= 19
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1813
1819
|
/// The on-glass slot table must be re-SET on the next vector frame.
|
|
1814
1820
|
private var cfwSceneInvalid = true
|
|
1815
1821
|
|
|
@@ -3126,7 +3132,8 @@ final class G2: NSObject, SGCManager {
|
|
|
3126
3132
|
}
|
|
3127
3133
|
if cfwLastBoxes != boxes { cfwSceneInvalid = true }
|
|
3128
3134
|
guard let encoded = await cfwRenderer.encodeVector(
|
|
3129
|
-
frame, boxes: boxes, cacheGeneration: cfwCacheGeneration, invalidate: cfwSceneInvalid
|
|
3135
|
+
frame, boxes: boxes, cacheGeneration: cfwCacheGeneration, invalidate: cfwSceneInvalid,
|
|
3136
|
+
inlineText: supportsCfwInlineText
|
|
3130
3137
|
) else {
|
|
3131
3138
|
Bridge.log("G2: vector scene does not fit; using the raster path for this frame")
|
|
3132
3139
|
cfwSceneInvalid = true
|
|
@@ -68,7 +68,7 @@ actor G2CfwRenderer {
|
|
|
68
68
|
/// `invalidate` forces a full re-SET (the on-glass slot table is unknown).
|
|
69
69
|
/// Nil when the frame does not fit the slot table or the texture cache.
|
|
70
70
|
func encodeVector(
|
|
71
|
-
_ frame: SceneFrame, boxes: LensBoxes, cacheGeneration: Int, invalidate: Bool
|
|
71
|
+
_ frame: SceneFrame, boxes: LensBoxes, cacheGeneration: Int, invalidate: Bool, inlineText: Bool
|
|
72
72
|
) -> G2CfwScene.Encoded? {
|
|
73
73
|
if textureCache.generation != cacheGeneration || textureCache.exhausted {
|
|
74
74
|
textureCache.reset(generation: cacheGeneration)
|
|
@@ -90,13 +90,15 @@ actor G2CfwRenderer {
|
|
|
90
90
|
}
|
|
91
91
|
let ox = Int(boxes.left.x)
|
|
92
92
|
let oy = Int(boxes.left.y)
|
|
93
|
-
if let encoded = scene.encode(frame, originX: ox, originY: oy,
|
|
93
|
+
if let encoded = scene.encode(frame, originX: ox, originY: oy, inlineText: inlineText,
|
|
94
|
+
cache: textureCache, rasterize: rasterize) {
|
|
94
95
|
return encoded
|
|
95
96
|
}
|
|
96
97
|
// Out of texture cache mid-frame: start a fresh cache and repack once.
|
|
97
98
|
textureCache.reset(generation: cacheGeneration)
|
|
98
99
|
scene.reset(generation: cacheGeneration)
|
|
99
|
-
return scene.encode(frame, originX: ox, originY: oy,
|
|
100
|
+
return scene.encode(frame, originX: ox, originY: oy, inlineText: inlineText,
|
|
101
|
+
cache: textureCache, rasterize: rasterize)
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
/// Fixed cell metrics of the phone's monospaced face at `fontSize`.
|
|
@@ -3,19 +3,22 @@ import Foundation
|
|
|
3
3
|
/// Retained vector scene for the Glassly CFW (firmware modes 16-18).
|
|
4
4
|
///
|
|
5
5
|
/// Every scene element becomes one or more firmware slots (paint order = slot
|
|
6
|
-
/// order): vector shapes map 1:1
|
|
7
|
-
///
|
|
8
|
-
///
|
|
9
|
-
/// tiled to the 255 px slot limit.
|
|
10
|
-
/// layout: unchanged slots cost nothing,
|
|
11
|
-
/// transition become firmware TWEENs, and
|
|
12
|
-
/// stay stable per element id; a reorder
|
|
6
|
+
/// order): vector shapes map 1:1; default and builtin text becomes one inline
|
|
7
|
+
/// TEXT slot per line drawn by the glasses' font (or a cache-backed TEXT slot on
|
|
8
|
+
/// firmware without inline records); mono text and images are rasterized on
|
|
9
|
+
/// the phone and drawn as cached IMAGE slots tiled to the 255 px slot limit.
|
|
10
|
+
/// Frames diff against the last committed layout: unchanged slots cost nothing,
|
|
11
|
+
/// geometry changes on an element with a transition become firmware TWEENs, and
|
|
12
|
+
/// anything else is re-SET. Slot indices stay stable per element id; a reorder
|
|
13
|
+
/// or an over-budget frame repacks.
|
|
13
14
|
final class G2CfwScene {
|
|
14
15
|
static let maxSlots = 128
|
|
15
16
|
static let recordBytes = 20
|
|
16
17
|
static let framePeriodMs = 33.0
|
|
17
18
|
static let textLineHeight = 27
|
|
19
|
+
static let textInset = 2
|
|
18
20
|
static let maxTextBytes = 64
|
|
21
|
+
static let maxInlineBytes = 128
|
|
19
22
|
static let maxTileSize = 255
|
|
20
23
|
/// Mode-13/15 options: identity ramp, transparent source color 0.
|
|
21
24
|
static let textOptions: UInt8 = 0x1F
|
|
@@ -23,7 +26,7 @@ final class G2CfwScene {
|
|
|
23
26
|
|
|
24
27
|
enum ShapeType: UInt8 {
|
|
25
28
|
case line = 1, rect, rectFill, circle, circleFill, tri, triFill, quad, quadFill
|
|
26
|
-
case bezier2, bezier3, arc, pie, image, text, textCached
|
|
29
|
+
case bezier2, bezier3, arc, pie, image, text, textCached, textInline
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
struct Slot: Equatable {
|
|
@@ -31,14 +34,17 @@ final class G2CfwScene {
|
|
|
31
34
|
var color: UInt8
|
|
32
35
|
var width: UInt8
|
|
33
36
|
var params: [Int16]
|
|
37
|
+
/// TEXT_INLINE bytes carried in the record.
|
|
38
|
+
var text: [UInt8]
|
|
34
39
|
|
|
35
|
-
init(_ type: ShapeType, color: UInt8, width: UInt8 = 0, _ params: [Int16]) {
|
|
40
|
+
init(_ type: ShapeType, color: UInt8, width: UInt8 = 0, _ params: [Int16], text: [UInt8] = []) {
|
|
36
41
|
self.type = type.rawValue
|
|
37
42
|
self.color = color
|
|
38
43
|
self.width = width
|
|
39
44
|
var padded = params
|
|
40
45
|
while padded.count < 8 { padded.append(0) }
|
|
41
46
|
self.params = Array(padded.prefix(8))
|
|
47
|
+
self.text = text
|
|
42
48
|
}
|
|
43
49
|
}
|
|
44
50
|
|
|
@@ -80,6 +86,7 @@ final class G2CfwScene {
|
|
|
80
86
|
case .tri, .triFill, .bezier2: return 0x3F
|
|
81
87
|
case .quad, .quadFill, .bezier3: return 0xFF
|
|
82
88
|
case .image, .text, .textCached: return 0x03
|
|
89
|
+
case .textInline: return 0x0F
|
|
83
90
|
case .none: return 0
|
|
84
91
|
}
|
|
85
92
|
}
|
|
@@ -124,8 +131,9 @@ final class G2CfwScene {
|
|
|
124
131
|
}
|
|
125
132
|
|
|
126
133
|
/// Slots for one element, or nil when the texture cache cannot hold its content.
|
|
134
|
+
/// `inlineText` selects TEXT_INLINE records for default/builtin text.
|
|
127
135
|
func slots(
|
|
128
|
-
for element: SceneElement, originX ox: Int, originY oy: Int,
|
|
136
|
+
for element: SceneElement, originX ox: Int, originY oy: Int, inlineText: Bool,
|
|
129
137
|
cache: G2CfwTextureCache, rasterize: (SceneElement, Int, Int) -> Data?
|
|
130
138
|
) -> [Slot]? {
|
|
131
139
|
let x = Self.clampCoord(Int(element.x) + ox)
|
|
@@ -179,6 +187,10 @@ final class G2CfwScene {
|
|
|
179
187
|
params.append(contentsOf: [px, py])
|
|
180
188
|
}
|
|
181
189
|
return [Slot(n == 3 ? .bezier2 : .bezier3, color: color, width: Self.stroke(element), params)]
|
|
190
|
+
case "text" where element.font == "mono":
|
|
191
|
+
return rasterSlots(element, x: x, y: y, cache: cache, rasterize: rasterize)
|
|
192
|
+
case "text" where inlineText:
|
|
193
|
+
return inlineTextSlots(element, x: x, y: y)
|
|
182
194
|
case "text" where element.font == "builtin":
|
|
183
195
|
return builtinTextSlots(element, x: x, y: y, cache: cache)
|
|
184
196
|
case "text", "image":
|
|
@@ -208,13 +220,40 @@ final class G2CfwScene {
|
|
|
208
220
|
return out
|
|
209
221
|
}
|
|
210
222
|
|
|
223
|
+
/// One TEXT_INLINE slot per line, drawn by the glasses' font at the same 2 px
|
|
224
|
+
/// inset as phone-rasterized text, clipped to the box.
|
|
225
|
+
private func inlineTextSlots(_ element: SceneElement, x: Int16, y: Int16) -> [Slot] {
|
|
226
|
+
var out: [Slot] = []
|
|
227
|
+
let w = Int16(max(0, min(2047, Int(element.w))))
|
|
228
|
+
let h = Int16(max(0, min(2047, Int(element.h))))
|
|
229
|
+
if element.border > 0 {
|
|
230
|
+
out.append(Slot(.rect, color: Self.gray(element), width: Self.stroke(element),
|
|
231
|
+
[x, y, w, h, Int16(max(0, min(2047, Int(element.radius))))]))
|
|
232
|
+
}
|
|
233
|
+
let options = UInt8(0x10) | Self.gray(element)
|
|
234
|
+
let maxLines = max(1, Int(element.h) / Self.textLineHeight)
|
|
235
|
+
let lines = (element.text ?? "").split(separator: "\n", omittingEmptySubsequences: false).prefix(maxLines)
|
|
236
|
+
for (index, line) in lines.enumerated() {
|
|
237
|
+
let bytes = Self.textBytes(String(line), max: Self.maxInlineBytes)
|
|
238
|
+
if bytes.isEmpty { continue }
|
|
239
|
+
let lineY = Int(y) + Self.textInset + index * Self.textLineHeight
|
|
240
|
+
let clipW = max(0, Int(element.w) - Self.textInset)
|
|
241
|
+
let clipH = max(0, Int(element.h) - Self.textInset - index * Self.textLineHeight)
|
|
242
|
+
out.append(Slot(.textInline, color: options,
|
|
243
|
+
[Self.clampCoord(Int(x) + Self.textInset), Self.clampCoord(lineY),
|
|
244
|
+
Int16(min(2047, clipW)), Int16(min(2047, clipH)), Int16(bytes.count)],
|
|
245
|
+
text: bytes))
|
|
246
|
+
}
|
|
247
|
+
return out
|
|
248
|
+
}
|
|
249
|
+
|
|
211
250
|
/// UTF-8 bytes of a line without firmware control bytes (1..31), cut at a codepoint boundary.
|
|
212
|
-
static func textBytes(_ line: String) -> [UInt8] {
|
|
251
|
+
static func textBytes(_ line: String, max limit: Int = maxTextBytes) -> [UInt8] {
|
|
213
252
|
let cleaned = line.unicodeScalars.filter { $0.value >= 32 }
|
|
214
253
|
var bytes: [UInt8] = []
|
|
215
254
|
for scalar in cleaned {
|
|
216
255
|
let encoded = Array(String(scalar).utf8)
|
|
217
|
-
if bytes.count + encoded.count >
|
|
256
|
+
if bytes.count + encoded.count > limit { break }
|
|
218
257
|
bytes.append(contentsOf: encoded)
|
|
219
258
|
}
|
|
220
259
|
return bytes
|
|
@@ -256,8 +295,8 @@ final class G2CfwScene {
|
|
|
256
295
|
/// that must precede it. Nil when the frame does not fit the slot table or
|
|
257
296
|
/// the texture cache; the caller then falls back to the raster path.
|
|
258
297
|
func encode(
|
|
259
|
-
_ frame: SceneFrame, originX: Int, originY: Int,
|
|
260
|
-
rasterize: (SceneElement, Int, Int) -> Data?
|
|
298
|
+
_ frame: SceneFrame, originX: Int, originY: Int, inlineText: Bool = false,
|
|
299
|
+
cache: G2CfwTextureCache, rasterize: (SceneElement, Int, Int) -> Data?
|
|
261
300
|
) -> Encoded? {
|
|
262
301
|
struct Desired {
|
|
263
302
|
let id: String
|
|
@@ -268,7 +307,8 @@ final class G2CfwScene {
|
|
|
268
307
|
}
|
|
269
308
|
var desired: [Desired] = []
|
|
270
309
|
for element in frame.elements {
|
|
271
|
-
guard let slots = slots(for: element, originX: originX, originY: originY,
|
|
310
|
+
guard let slots = slots(for: element, originX: originX, originY: originY, inlineText: inlineText,
|
|
311
|
+
cache: cache, rasterize: rasterize)
|
|
272
312
|
else { return nil }
|
|
273
313
|
desired.append(Desired(
|
|
274
314
|
id: element.id, contentHash: element.contentHash, slots: slots,
|
|
@@ -347,21 +387,27 @@ final class G2CfwScene {
|
|
|
347
387
|
return Encoded(payload: payload, uploads: cache.takeUploadPayloads())
|
|
348
388
|
}
|
|
349
389
|
|
|
350
|
-
/// `[0][slot][type][flags=visible][color][width][p0..p7 LE]
|
|
390
|
+
/// `[0][slot][type][flags=visible][color][width][p0..p7 LE]`, or for TEXT_INLINE
|
|
391
|
+
/// `[0][slot][17][1][color][width][x][y][w][h][len][bytes]`.
|
|
351
392
|
static func setRecord(_ slot: Slot, index: UInt8) -> Data {
|
|
352
393
|
var out = Data([0, index, slot.type, 1, slot.color, slot.width])
|
|
353
|
-
|
|
394
|
+
let count = slot.type == ShapeType.textInline.rawValue ? 4 : 8
|
|
395
|
+
for value in slot.params.prefix(count) {
|
|
354
396
|
let bits = UInt16(bitPattern: value)
|
|
355
397
|
out.append(UInt8(bits & 0xFF))
|
|
356
398
|
out.append(UInt8(bits >> 8))
|
|
357
399
|
}
|
|
400
|
+
if slot.type == ShapeType.textInline.rawValue {
|
|
401
|
+
out.append(UInt8(min(slot.text.count, maxInlineBytes)))
|
|
402
|
+
out.append(contentsOf: slot.text.prefix(maxInlineBytes))
|
|
403
|
+
}
|
|
358
404
|
return out
|
|
359
405
|
}
|
|
360
406
|
|
|
361
407
|
/// `[5][slot][mask:u16][frames][curve x4][value:i16 per set bit]`, or nil when the
|
|
362
408
|
/// change is not a pure geometry/stroke change of the same shape type.
|
|
363
409
|
static func tweenRecord(from old: Slot, to new: Slot, index: UInt8, frames: UInt8, curve: [UInt8]) -> Data? {
|
|
364
|
-
guard old.type == new.type else { return nil }
|
|
410
|
+
guard old.type == new.type, old.text == new.text else { return nil }
|
|
365
411
|
let tweenable = tweenableMask(new.type)
|
|
366
412
|
var mask: UInt16 = 0
|
|
367
413
|
var values: [Int16] = []
|
|
@@ -372,7 +418,9 @@ final class G2CfwScene {
|
|
|
372
418
|
}
|
|
373
419
|
let isTexture = new.type >= ShapeType.image.rawValue
|
|
374
420
|
if old.color != new.color {
|
|
375
|
-
|
|
421
|
+
// Inline text may fade (low nibble) as long as its option bits stay put.
|
|
422
|
+
let inlineFade = new.type == ShapeType.textInline.rawValue && (old.color & 0xF0) == (new.color & 0xF0)
|
|
423
|
+
guard !isTexture || inlineFade else { return nil }
|
|
376
424
|
mask |= 0x100
|
|
377
425
|
values.append(Int16(new.color))
|
|
378
426
|
}
|
|
@@ -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
|
+
}
|
|
@@ -21,8 +21,9 @@ final class G2CfwSceneTests: XCTestCase {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
private func encode(_ scene: G2CfwScene, _ cache: G2CfwTextureCache, _ elements: [SceneElement],
|
|
24
|
-
replay: Bool = false) -> G2CfwScene.Encoded? {
|
|
25
|
-
scene.encode(frame(elements, replay: replay), originX: 32, originY: 96,
|
|
24
|
+
replay: Bool = false, inlineText: Bool = false) -> G2CfwScene.Encoded? {
|
|
25
|
+
scene.encode(frame(elements, replay: replay), originX: 32, originY: 96, inlineText: inlineText,
|
|
26
|
+
cache: cache) { el, w, h in
|
|
26
27
|
Data(repeating: 0xFF, count: w * h)
|
|
27
28
|
}
|
|
28
29
|
}
|
|
@@ -184,4 +185,47 @@ final class G2CfwSceneTests: XCTestCase {
|
|
|
184
185
|
XCTAssertEqual(G2CfwScene.transitionFrames(ms: 60000), 255)
|
|
185
186
|
XCTAssertEqual(G2CfwScene.easingCurve("ease-in-out"), [107, 0, 148, 255])
|
|
186
187
|
}
|
|
188
|
+
|
|
189
|
+
func testInlineTextRecordsCarryTheirBytesAndAnimate() throws {
|
|
190
|
+
let scene = G2CfwScene()
|
|
191
|
+
let cache = G2CfwTextureCache()
|
|
192
|
+
cache.reset(generation: 1)
|
|
193
|
+
scene.reset(generation: 1)
|
|
194
|
+
let first = try XCTUnwrap(encode(scene, cache, [
|
|
195
|
+
element("t", type: "text", x: 10, y: 10, w: 200, h: 60, text: "hi\nthere", color: 9),
|
|
196
|
+
element("m", type: "text", x: 0, y: 200, w: 100, h: 30, text: "grid", font: "mono", hash: "mono"),
|
|
197
|
+
], inlineText: true))
|
|
198
|
+
XCTAssertEqual(first.uploads.count, 1) // only the mono raster hits the cache
|
|
199
|
+
XCTAssertEqual(scene.elements.map(\.indices), [[0, 1], [2]])
|
|
200
|
+
let bytes = [UInt8](first.payload)
|
|
201
|
+
// slot 0: TEXT_INLINE, transparent + gray 9, x/y inset by 2, clip to the box, "hi"
|
|
202
|
+
XCTAssertEqual(Array(bytes[3 ..< 9]), [0, 0, 17, 1, 0x19, 0])
|
|
203
|
+
XCTAssertEqual(Array(bytes[9 ..< 17]), le(44, 108, 198, 58))
|
|
204
|
+
XCTAssertEqual(Array(bytes[17 ..< 20]), [2, 0x68, 0x69])
|
|
205
|
+
// slot 1 follows immediately: 13 + 2 bytes after the first record
|
|
206
|
+
XCTAssertEqual(Array(bytes[20 ..< 23]), [0, 1, 17])
|
|
207
|
+
XCTAssertEqual(Array(bytes[26 ..< 34]), le(44, 135, 198, 31))
|
|
208
|
+
XCTAssertEqual(Array(bytes[35 ..< 40]), Array("there".utf8))
|
|
209
|
+
XCTAssertEqual(bytes[40], 0) // mono raster SET
|
|
210
|
+
XCTAssertEqual(bytes[42], 14)
|
|
211
|
+
|
|
212
|
+
// a new string is a SET even with a transition; a move alone tweens; a fade tweens the color
|
|
213
|
+
let moved = try XCTUnwrap(encode(scene, cache, [
|
|
214
|
+
element("t", type: "text", x: 10, y: 40, w: 200, h: 60, text: "hi\nthere", color: 9, transitionMs: 330),
|
|
215
|
+
element("m", type: "text", x: 0, y: 200, w: 100, h: 30, text: "grid", font: "mono", hash: "mono"),
|
|
216
|
+
], inlineText: true))
|
|
217
|
+
XCTAssertEqual(Array([UInt8](moved.payload)[3 ..< 8]), [5, 0, 0x02, 0x00, 10])
|
|
218
|
+
let retexted = try XCTUnwrap(encode(scene, cache, [
|
|
219
|
+
element("t", type: "text", x: 10, y: 40, w: 200, h: 60, text: "ho\nthere", color: 9, transitionMs: 330),
|
|
220
|
+
element("m", type: "text", x: 0, y: 200, w: 100, h: 30, text: "grid", font: "mono", hash: "mono"),
|
|
221
|
+
], inlineText: true))
|
|
222
|
+
XCTAssertEqual(Array([UInt8](retexted.payload)[3 ..< 6]), [0, 0, 17])
|
|
223
|
+
let faded = try XCTUnwrap(encode(scene, cache, [
|
|
224
|
+
element("t", type: "text", x: 10, y: 40, w: 200, h: 60, text: "ho\nthere", color: 3, transitionMs: 330),
|
|
225
|
+
element("m", type: "text", x: 0, y: 200, w: 100, h: 30, text: "grid", font: "mono", hash: "mono"),
|
|
226
|
+
], inlineText: true))
|
|
227
|
+
let fade = [UInt8](faded.payload)
|
|
228
|
+
XCTAssertEqual(Array(fade[3 ..< 8]), [5, 0, 0x00, 0x01, 10])
|
|
229
|
+
XCTAssertEqual(Array(fade[12 ..< 14]), le(0x13))
|
|
230
|
+
}
|
|
187
231
|
}
|
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
|
})
|