@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
@@ -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, cache: textureCache, rasterize: rasterize) {
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, cache: textureCache, rasterize: rasterize)
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, builtin-font text becomes one TEXT slot per
7
- /// line with its bytes in the texture cache, and everything else (proportional
8
- /// text, images) is rasterized on the phone and drawn as cached IMAGE slots
9
- /// tiled to the 255 px slot limit. Frames diff against the last committed
10
- /// layout: unchanged slots cost nothing, geometry changes on an element with a
11
- /// transition become firmware TWEENs, and anything else is re-SET. Slot indices
12
- /// stay stable per element id; a reorder or an over-budget frame repacks.
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 > maxTextBytes { break }
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, cache: G2CfwTextureCache,
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, cache: cache, rasterize: rasterize)
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
- for value in slot.params {
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
- guard !isTexture else { return nil }
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: "fe6051085507269c8007bc1245bfe8bd10d5c566540389ae976daaf753f536f9",
41
- size: 4_527_476
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, cache: cache) { el, w, h in
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glassly/bluetooth-sdk",
3
- "version": "0.1.0-dev.112",
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.112",
16
- "releaseSetId": "glassly-0.1.0-dev.112",
17
- "sourceCommit": "385c3de9054122bf005790a6b1faf04d3117d512",
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
  })