@glassly/bluetooth-sdk 0.1.0-dev.104 → 0.1.0-dev.106
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 +17 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +3 -3
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +66 -3
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +35 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +415 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +10 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +2 -2
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +16 -2
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +2 -2
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +175 -0
- package/build/BluetoothSdk.types.d.ts +2 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/generated/releaseMetadata.js +3 -3
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +16 -2
- 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 +48 -3
- package/ios/Source/sgcs/G2CfwRenderer.swift +37 -0
- package/ios/Source/sgcs/G2CfwScene.swift +394 -0
- package/ios/Source/sgcs/G2CfwTextureCache.swift +10 -0
- package/ios/Source/sgcs/G2Flasher.swift +2 -2
- package/ios/Source/sgcs/SGCManager.swift +34 -3
- package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +187 -0
- package/package.json +1 -1
- package/src/BluetoothSdk.types.ts +2 -0
- package/src/generated/releaseMetadata.ts +3 -3
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Retained vector scene for the Glassly CFW (firmware modes 16-18).
|
|
4
|
+
///
|
|
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.
|
|
13
|
+
final class G2CfwScene {
|
|
14
|
+
static let maxSlots = 128
|
|
15
|
+
static let recordBytes = 20
|
|
16
|
+
static let framePeriodMs = 33.0
|
|
17
|
+
static let textLineHeight = 27
|
|
18
|
+
static let maxTextBytes = 64
|
|
19
|
+
static let maxTileSize = 255
|
|
20
|
+
/// Mode-13/15 options: identity ramp, transparent source color 0.
|
|
21
|
+
static let textOptions: UInt8 = 0x1F
|
|
22
|
+
static let imageOptions: UInt8 = 0x0F
|
|
23
|
+
|
|
24
|
+
enum ShapeType: UInt8 {
|
|
25
|
+
case line = 1, rect, rectFill, circle, circleFill, tri, triFill, quad, quadFill
|
|
26
|
+
case bezier2, bezier3, arc, pie, image, text, textCached
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
struct Slot: Equatable {
|
|
30
|
+
var type: UInt8
|
|
31
|
+
var color: UInt8
|
|
32
|
+
var width: UInt8
|
|
33
|
+
var params: [Int16]
|
|
34
|
+
|
|
35
|
+
init(_ type: ShapeType, color: UInt8, width: UInt8 = 0, _ params: [Int16]) {
|
|
36
|
+
self.type = type.rawValue
|
|
37
|
+
self.color = color
|
|
38
|
+
self.width = width
|
|
39
|
+
var padded = params
|
|
40
|
+
while padded.count < 8 { padded.append(0) }
|
|
41
|
+
self.params = Array(padded.prefix(8))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
struct ElementState {
|
|
46
|
+
let id: String
|
|
47
|
+
let contentHash: String
|
|
48
|
+
var slots: [Slot]
|
|
49
|
+
var indices: [Int]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
struct Encoded {
|
|
53
|
+
let payload: Data
|
|
54
|
+
let uploads: [Data]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private(set) var generation = -1
|
|
58
|
+
private(set) var elements: [ElementState] = []
|
|
59
|
+
private var needsRepack = true
|
|
60
|
+
|
|
61
|
+
func reset(generation: Int) {
|
|
62
|
+
self.generation = generation
|
|
63
|
+
elements.removeAll()
|
|
64
|
+
needsRepack = true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// The on-glass slot table is unknown (a send failed, the window moved): the next frame re-SETs everything.
|
|
68
|
+
func invalidate() {
|
|
69
|
+
needsRepack = true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// MARK: - Element → slots
|
|
73
|
+
|
|
74
|
+
/// Geometry parameters a TWEEN may interpolate, per type. Texture offsets and lengths never tween.
|
|
75
|
+
static func tweenableMask(_ type: UInt8) -> UInt16 {
|
|
76
|
+
switch ShapeType(rawValue: type) {
|
|
77
|
+
case .line: return 0x0F
|
|
78
|
+
case .rect, .rectFill, .arc, .pie: return 0x1F
|
|
79
|
+
case .circle, .circleFill: return 0x07
|
|
80
|
+
case .tri, .triFill, .bezier2: return 0x3F
|
|
81
|
+
case .quad, .quadFill, .bezier3: return 0xFF
|
|
82
|
+
case .image, .text, .textCached: return 0x03
|
|
83
|
+
case .none: return 0
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static func easingCurve(_ easing: String) -> [UInt8] {
|
|
88
|
+
switch easing {
|
|
89
|
+
case "linear": return [0, 0, 255, 255]
|
|
90
|
+
case "ease-in": return [107, 0, 255, 255]
|
|
91
|
+
case "ease-out": return [0, 0, 148, 255]
|
|
92
|
+
case "ease-in-out": return [107, 0, 148, 255]
|
|
93
|
+
default: return [64, 26, 64, 255] // CSS "ease"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static func transitionFrames(ms: Int32) -> UInt8 {
|
|
98
|
+
guard ms > 0 else { return 0 }
|
|
99
|
+
let frames = Int((Double(ms) / framePeriodMs).rounded())
|
|
100
|
+
return UInt8(max(2, min(255, frames)))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private static func gray(_ element: SceneElement) -> UInt8 {
|
|
104
|
+
element.color < 0 ? 15 : UInt8(max(0, min(15, element.color)))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private static func stroke(_ element: SceneElement) -> UInt8 {
|
|
108
|
+
UInt8(max(1, min(255, element.border)))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private static func point(_ element: SceneElement, _ i: Int, ox: Int, oy: Int) -> (Int16, Int16) {
|
|
112
|
+
guard element.points.count >= 2 * i + 2 else { return (0, 0) }
|
|
113
|
+
return (Self.clampCoord(Int(element.points[2 * i]) + ox), Self.clampCoord(Int(element.points[2 * i + 1]) + oy))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private static func clampCoord(_ v: Int) -> Int16 {
|
|
117
|
+
Int16(max(-1024, min(1023, v)))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private static func circleGeometry(_ element: SceneElement, ox: Int, oy: Int) -> (Int16, Int16, Int16) {
|
|
121
|
+
let w = Int(element.w), h = Int(element.h)
|
|
122
|
+
let r = max(0, (min(w, h) - 1) / 2)
|
|
123
|
+
return (clampCoord(Int(element.x) + w / 2 + ox), clampCoord(Int(element.y) + h / 2 + oy), Int16(r))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Slots for one element, or nil when the texture cache cannot hold its content.
|
|
127
|
+
func slots(
|
|
128
|
+
for element: SceneElement, originX ox: Int, originY oy: Int,
|
|
129
|
+
cache: G2CfwTextureCache, rasterize: (SceneElement, Int, Int) -> Data?
|
|
130
|
+
) -> [Slot]? {
|
|
131
|
+
let x = Self.clampCoord(Int(element.x) + ox)
|
|
132
|
+
let y = Self.clampCoord(Int(element.y) + oy)
|
|
133
|
+
let w = Int16(max(0, min(2047, Int(element.w))))
|
|
134
|
+
let h = Int16(max(0, min(2047, Int(element.h))))
|
|
135
|
+
let color = Self.gray(element)
|
|
136
|
+
let radius = Int16(max(0, min(2047, Int(element.radius))))
|
|
137
|
+
switch element.type {
|
|
138
|
+
case "rect":
|
|
139
|
+
var out: [Slot] = []
|
|
140
|
+
if element.fill {
|
|
141
|
+
out.append(Slot(.rectFill, color: color, [x, y, w, h, radius]))
|
|
142
|
+
if element.border > 0 {
|
|
143
|
+
out.append(Slot(.rect, color: color, width: Self.stroke(element), [x, y, w, h, radius]))
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
out.append(Slot(.rect, color: color, width: Self.stroke(element), [x, y, w, h, radius]))
|
|
147
|
+
}
|
|
148
|
+
return out
|
|
149
|
+
case "circle":
|
|
150
|
+
let (cx, cy, r) = Self.circleGeometry(element, ox: ox, oy: oy)
|
|
151
|
+
if element.fill { return [Slot(.circleFill, color: color, [cx, cy, r])] }
|
|
152
|
+
return [Slot(.circle, color: color, width: Self.stroke(element), [cx, cy, r])]
|
|
153
|
+
case "arc", "pie":
|
|
154
|
+
let (cx, cy, r) = Self.circleGeometry(element, ox: ox, oy: oy)
|
|
155
|
+
let a0 = Self.clampCoord(Int(element.startDeg)), a1 = Self.clampCoord(Int(element.endDeg))
|
|
156
|
+
if element.type == "pie" { return [Slot(.pie, color: color, [cx, cy, r, a0, a1])] }
|
|
157
|
+
return [Slot(.arc, color: color, width: Self.stroke(element), [cx, cy, r, a0, a1])]
|
|
158
|
+
case "line":
|
|
159
|
+
let (x1, y1) = Self.point(element, 0, ox: ox, oy: oy)
|
|
160
|
+
let (x2, y2) = Self.point(element, 1, ox: ox, oy: oy)
|
|
161
|
+
return [Slot(.line, color: color, width: Self.stroke(element), [x1, y1, x2, y2])]
|
|
162
|
+
case "triangle", "quad":
|
|
163
|
+
let n = element.type == "triangle" ? 3 : 4
|
|
164
|
+
var params: [Int16] = []
|
|
165
|
+
for i in 0 ..< n {
|
|
166
|
+
let (px, py) = Self.point(element, i, ox: ox, oy: oy)
|
|
167
|
+
params.append(contentsOf: [px, py])
|
|
168
|
+
}
|
|
169
|
+
let filled: ShapeType = n == 3 ? .triFill : .quadFill
|
|
170
|
+
let stroked: ShapeType = n == 3 ? .tri : .quad
|
|
171
|
+
if element.fill { return [Slot(filled, color: color, params)] }
|
|
172
|
+
return [Slot(stroked, color: color, width: Self.stroke(element), params)]
|
|
173
|
+
case "bezier":
|
|
174
|
+
let n = element.points.count / 2
|
|
175
|
+
guard n == 3 || n == 4 else { return [] }
|
|
176
|
+
var params: [Int16] = []
|
|
177
|
+
for i in 0 ..< n {
|
|
178
|
+
let (px, py) = Self.point(element, i, ox: ox, oy: oy)
|
|
179
|
+
params.append(contentsOf: [px, py])
|
|
180
|
+
}
|
|
181
|
+
return [Slot(n == 3 ? .bezier2 : .bezier3, color: color, width: Self.stroke(element), params)]
|
|
182
|
+
case "text" where element.font == "builtin":
|
|
183
|
+
return builtinTextSlots(element, x: x, y: y, cache: cache)
|
|
184
|
+
case "text", "image":
|
|
185
|
+
return rasterSlots(element, x: x, y: y, cache: cache, rasterize: rasterize)
|
|
186
|
+
default:
|
|
187
|
+
return []
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private func builtinTextSlots(_ element: SceneElement, x: Int16, y: Int16, cache: G2CfwTextureCache) -> [Slot]? {
|
|
192
|
+
var out: [Slot] = []
|
|
193
|
+
if element.border > 0 {
|
|
194
|
+
out.append(Slot(.rect, color: 15, width: Self.stroke(element),
|
|
195
|
+
[x, y, Int16(min(2047, Int(element.w))), Int16(min(2047, Int(element.h))),
|
|
196
|
+
Int16(max(0, min(2047, Int(element.radius))))]))
|
|
197
|
+
}
|
|
198
|
+
let maxLines = max(1, Int(element.h) / Self.textLineHeight)
|
|
199
|
+
let lines = (element.text ?? "").split(separator: "\n", omittingEmptySubsequences: false).prefix(maxLines)
|
|
200
|
+
for (index, line) in lines.enumerated() {
|
|
201
|
+
let bytes = Self.textBytes(String(line))
|
|
202
|
+
if bytes.isEmpty { continue }
|
|
203
|
+
guard let offset = cache.ensureBytes(key: "t:" + String(line), bytes: bytes) else { return nil }
|
|
204
|
+
let lineY = Int(y) + index * Self.textLineHeight
|
|
205
|
+
out.append(Slot(.text, color: Self.textOptions,
|
|
206
|
+
[x, Self.clampCoord(lineY), Int16(bitPattern: UInt16(offset)), Int16(bytes.count)]))
|
|
207
|
+
}
|
|
208
|
+
return out
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// UTF-8 bytes of a line without firmware control bytes (1..31), cut at a codepoint boundary.
|
|
212
|
+
static func textBytes(_ line: String) -> [UInt8] {
|
|
213
|
+
let cleaned = line.unicodeScalars.filter { $0.value >= 32 }
|
|
214
|
+
var bytes: [UInt8] = []
|
|
215
|
+
for scalar in cleaned {
|
|
216
|
+
let encoded = Array(String(scalar).utf8)
|
|
217
|
+
if bytes.count + encoded.count > maxTextBytes { break }
|
|
218
|
+
bytes.append(contentsOf: encoded)
|
|
219
|
+
}
|
|
220
|
+
return bytes
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private func rasterSlots(
|
|
224
|
+
_ element: SceneElement, x: Int16, y: Int16, cache: G2CfwTextureCache,
|
|
225
|
+
rasterize: (SceneElement, Int, Int) -> Data?
|
|
226
|
+
) -> [Slot]? {
|
|
227
|
+
let w = Int(element.w), h = Int(element.h)
|
|
228
|
+
guard w > 0, h > 0, let gray = rasterize(element, w, h), gray.count == w * h else { return [] }
|
|
229
|
+
var out: [Slot] = []
|
|
230
|
+
var ty = 0
|
|
231
|
+
while ty < h {
|
|
232
|
+
let th = min(Self.maxTileSize, h - ty)
|
|
233
|
+
var tx = 0
|
|
234
|
+
while tx < w {
|
|
235
|
+
let tw = min(Self.maxTileSize, w - tx)
|
|
236
|
+
var tile = Data(capacity: tw * th)
|
|
237
|
+
for row in 0 ..< th {
|
|
238
|
+
let start = (ty + row) * w + tx
|
|
239
|
+
tile.append(gray.subdata(in: start ..< start + tw))
|
|
240
|
+
}
|
|
241
|
+
let key = "\(element.contentHash)@\(w)x\(h):\(tx),\(ty)"
|
|
242
|
+
guard let offset = cache.ensureImage(hash: key, gray: tile, width: tw, height: th) else { return nil }
|
|
243
|
+
out.append(Slot(.image, color: Self.imageOptions,
|
|
244
|
+
[Self.clampCoord(Int(x) + tx), Self.clampCoord(Int(y) + ty),
|
|
245
|
+
Int16(bitPattern: UInt16(offset))]))
|
|
246
|
+
tx += tw
|
|
247
|
+
}
|
|
248
|
+
ty += th
|
|
249
|
+
}
|
|
250
|
+
return out
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// MARK: - Frame encoding
|
|
254
|
+
|
|
255
|
+
/// One mode-17 patch (COMMIT, plus CLEAR on a repack) and the mode-12 uploads
|
|
256
|
+
/// that must precede it. Nil when the frame does not fit the slot table or
|
|
257
|
+
/// the texture cache; the caller then falls back to the raster path.
|
|
258
|
+
func encode(
|
|
259
|
+
_ frame: SceneFrame, originX: Int, originY: Int, cache: G2CfwTextureCache,
|
|
260
|
+
rasterize: (SceneElement, Int, Int) -> Data?
|
|
261
|
+
) -> Encoded? {
|
|
262
|
+
struct Desired {
|
|
263
|
+
let id: String
|
|
264
|
+
let contentHash: String
|
|
265
|
+
let slots: [Slot]
|
|
266
|
+
let frames: UInt8
|
|
267
|
+
let curve: [UInt8]
|
|
268
|
+
}
|
|
269
|
+
var desired: [Desired] = []
|
|
270
|
+
for element in frame.elements {
|
|
271
|
+
guard let slots = slots(for: element, originX: originX, originY: originY, cache: cache, rasterize: rasterize)
|
|
272
|
+
else { return nil }
|
|
273
|
+
desired.append(Desired(
|
|
274
|
+
id: element.id, contentHash: element.contentHash, slots: slots,
|
|
275
|
+
frames: Self.transitionFrames(ms: element.transitionMs),
|
|
276
|
+
curve: Self.easingCurve(element.easing)
|
|
277
|
+
))
|
|
278
|
+
}
|
|
279
|
+
let total = desired.reduce(0) { $0 + $1.slots.count }
|
|
280
|
+
guard total <= Self.maxSlots else { return nil }
|
|
281
|
+
|
|
282
|
+
var previous: [String: ElementState] = [:]
|
|
283
|
+
for state in elements { previous[state.id] = state }
|
|
284
|
+
|
|
285
|
+
var repack = needsRepack || frame.replay
|
|
286
|
+
var assigned: [[Int]] = []
|
|
287
|
+
if !repack {
|
|
288
|
+
var used = Set<Int>()
|
|
289
|
+
for item in desired {
|
|
290
|
+
if let prev = previous[item.id], prev.slots.count == item.slots.count { used.formUnion(prev.indices) }
|
|
291
|
+
}
|
|
292
|
+
var next = (used.max() ?? -1) + 1
|
|
293
|
+
var lastIndex = -1
|
|
294
|
+
for item in desired {
|
|
295
|
+
let indices: [Int]
|
|
296
|
+
if let prev = previous[item.id], prev.slots.count == item.slots.count {
|
|
297
|
+
indices = prev.indices
|
|
298
|
+
} else {
|
|
299
|
+
indices = (0 ..< item.slots.count).map { next + $0 }
|
|
300
|
+
next += item.slots.count
|
|
301
|
+
}
|
|
302
|
+
if let first = indices.first, first <= lastIndex { repack = true; break }
|
|
303
|
+
lastIndex = indices.last ?? lastIndex
|
|
304
|
+
assigned.append(indices)
|
|
305
|
+
}
|
|
306
|
+
if next > Self.maxSlots { repack = true }
|
|
307
|
+
}
|
|
308
|
+
if repack {
|
|
309
|
+
assigned.removeAll()
|
|
310
|
+
var next = 0
|
|
311
|
+
for item in desired {
|
|
312
|
+
assigned.append((0 ..< item.slots.count).map { next + $0 })
|
|
313
|
+
next += item.slots.count
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
var ops = Data()
|
|
318
|
+
if !repack {
|
|
319
|
+
let kept = Set(desired.map(\.id))
|
|
320
|
+
for state in elements where !kept.contains(state.id) {
|
|
321
|
+
for index in state.indices { ops.append(contentsOf: [1, UInt8(index)]) }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
var committed: [ElementState] = []
|
|
325
|
+
for (item, indices) in zip(desired, assigned) {
|
|
326
|
+
let prev = repack ? nil : previous[item.id]
|
|
327
|
+
for (k, slot) in item.slots.enumerated() {
|
|
328
|
+
let index = UInt8(indices[k])
|
|
329
|
+
if let prev, prev.slots.count == item.slots.count {
|
|
330
|
+
let old = prev.slots[k]
|
|
331
|
+
if old == slot { continue }
|
|
332
|
+
if item.frames >= 2, let tween = Self.tweenRecord(from: old, to: slot, index: index,
|
|
333
|
+
frames: item.frames, curve: item.curve) {
|
|
334
|
+
ops.append(tween)
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
ops.append(Self.setRecord(slot, index: index))
|
|
339
|
+
}
|
|
340
|
+
committed.append(ElementState(id: item.id, contentHash: item.contentHash, slots: item.slots, indices: indices))
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
var payload = Data([17, repack ? 0x03 : 0x01, 0])
|
|
344
|
+
payload.append(ops)
|
|
345
|
+
elements = committed
|
|
346
|
+
needsRepack = false
|
|
347
|
+
return Encoded(payload: payload, uploads: cache.takeUploadPayloads())
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/// `[0][slot][type][flags=visible][color][width][p0..p7 LE]`
|
|
351
|
+
static func setRecord(_ slot: Slot, index: UInt8) -> Data {
|
|
352
|
+
var out = Data([0, index, slot.type, 1, slot.color, slot.width])
|
|
353
|
+
for value in slot.params {
|
|
354
|
+
let bits = UInt16(bitPattern: value)
|
|
355
|
+
out.append(UInt8(bits & 0xFF))
|
|
356
|
+
out.append(UInt8(bits >> 8))
|
|
357
|
+
}
|
|
358
|
+
return out
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/// `[5][slot][mask:u16][frames][curve x4][value:i16 per set bit]`, or nil when the
|
|
362
|
+
/// change is not a pure geometry/stroke change of the same shape type.
|
|
363
|
+
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 }
|
|
365
|
+
let tweenable = tweenableMask(new.type)
|
|
366
|
+
var mask: UInt16 = 0
|
|
367
|
+
var values: [Int16] = []
|
|
368
|
+
for k in 0 ..< 8 where old.params[k] != new.params[k] {
|
|
369
|
+
guard tweenable & (1 << k) != 0 else { return nil }
|
|
370
|
+
mask |= 1 << k
|
|
371
|
+
values.append(new.params[k])
|
|
372
|
+
}
|
|
373
|
+
let isTexture = new.type >= ShapeType.image.rawValue
|
|
374
|
+
if old.color != new.color {
|
|
375
|
+
guard !isTexture else { return nil }
|
|
376
|
+
mask |= 0x100
|
|
377
|
+
values.append(Int16(new.color))
|
|
378
|
+
}
|
|
379
|
+
if old.width != new.width {
|
|
380
|
+
guard !isTexture else { return nil }
|
|
381
|
+
mask |= 0x200
|
|
382
|
+
values.append(Int16(new.width))
|
|
383
|
+
}
|
|
384
|
+
guard mask != 0 else { return nil }
|
|
385
|
+
var out = Data([5, index, UInt8(mask & 0xFF), UInt8(mask >> 8), frames])
|
|
386
|
+
out.append(contentsOf: curve.prefix(4))
|
|
387
|
+
for value in values {
|
|
388
|
+
let bits = UInt16(bitPattern: value)
|
|
389
|
+
out.append(UInt8(bits & 0xFF))
|
|
390
|
+
out.append(UInt8(bits >> 8))
|
|
391
|
+
}
|
|
392
|
+
return out
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -133,6 +133,16 @@ final class G2CfwTextureCache {
|
|
|
133
133
|
return offset
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/// The cache offset of a raw byte string (builtin-font text for scene TEXT
|
|
137
|
+
/// slots), uploading it on first use.
|
|
138
|
+
func ensureBytes(key: String, bytes: [UInt8]) -> Int? {
|
|
139
|
+
if let offset = images[key] { return offset }
|
|
140
|
+
guard !bytes.isEmpty, let offset = allocate(bytes.count) else { return nil }
|
|
141
|
+
images[key] = offset
|
|
142
|
+
pendingUploads.append((offset: offset, data: Data(bytes)))
|
|
143
|
+
return offset
|
|
144
|
+
}
|
|
145
|
+
|
|
136
146
|
/// Drain the pending mode-12 entries into `[12][offset16][len16][data]...`
|
|
137
147
|
/// payloads, each at most `maxUploadPayloadBytes`. An entry larger than
|
|
138
148
|
/// the budget gets a payload of its own (the BLE fragmenter splits it).
|
|
@@ -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: "fe6051085507269c8007bc1245bfe8bd10d5c566540389ae976daaf753f536f9",
|
|
41
|
+
size: 4_527_476
|
|
42
42
|
),
|
|
43
43
|
]
|
|
44
44
|
|
|
@@ -5,7 +5,7 @@ import Foundation
|
|
|
5
5
|
/// against the previous frame sent to this device.
|
|
6
6
|
struct SceneElement: Sendable {
|
|
7
7
|
let id: String
|
|
8
|
-
let type: String // "text" | "rect" | "image"
|
|
8
|
+
let type: String // "text" | "rect" | "image" | "circle" | "arc" | "pie" | "line" | "triangle" | "quad" | "bezier"
|
|
9
9
|
let x: Int32
|
|
10
10
|
let y: Int32
|
|
11
11
|
let w: Int32
|
|
@@ -20,11 +20,24 @@ struct SceneElement: Sendable {
|
|
|
20
20
|
/// height (grid text); anything else is the default proportional face.
|
|
21
21
|
let font: String
|
|
22
22
|
let fontSize: Int32
|
|
23
|
+
/// Shapes: solid fill instead of an outline.
|
|
24
|
+
let fill: Bool
|
|
25
|
+
/// Gray level 0..15, or -1 for the default (brightest).
|
|
26
|
+
let color: Int32
|
|
27
|
+
/// Point shapes: x0, y0, x1, y1, … in canvas pixels.
|
|
28
|
+
let points: [Int32]
|
|
29
|
+
let startDeg: Int32
|
|
30
|
+
let endDeg: Int32
|
|
31
|
+
/// Transition duration in ms (0 = none) and easing name.
|
|
32
|
+
let transitionMs: Int32
|
|
33
|
+
let easing: String
|
|
23
34
|
|
|
24
35
|
init(
|
|
25
36
|
id: String, type: String, x: Int32, y: Int32, w: Int32, h: Int32, text: String?, data: String?,
|
|
26
37
|
border: Int32, radius: Int32, change: String, contentHash: String,
|
|
27
|
-
font: String = "default", fontSize: Int32 = 0
|
|
38
|
+
font: String = "default", fontSize: Int32 = 0,
|
|
39
|
+
fill: Bool = false, color: Int32 = -1, points: [Int32] = [], startDeg: Int32 = 0, endDeg: Int32 = 0,
|
|
40
|
+
transitionMs: Int32 = 0, easing: String = "ease"
|
|
28
41
|
) {
|
|
29
42
|
self.id = id
|
|
30
43
|
self.type = type
|
|
@@ -40,13 +53,31 @@ struct SceneElement: Sendable {
|
|
|
40
53
|
self.contentHash = contentHash
|
|
41
54
|
self.font = font
|
|
42
55
|
self.fontSize = fontSize
|
|
56
|
+
self.fill = fill
|
|
57
|
+
self.color = color
|
|
58
|
+
self.points = points
|
|
59
|
+
self.startDeg = startDeg
|
|
60
|
+
self.endDeg = endDeg
|
|
61
|
+
self.transitionMs = transitionMs
|
|
62
|
+
self.easing = easing
|
|
43
63
|
}
|
|
44
64
|
|
|
45
65
|
func with(change: String) -> SceneElement {
|
|
46
66
|
SceneElement(
|
|
47
67
|
id: id, type: type, x: x, y: y, w: w, h: h, text: text, data: data,
|
|
48
68
|
border: border, radius: radius, change: change, contentHash: contentHash,
|
|
49
|
-
font: font, fontSize: fontSize
|
|
69
|
+
font: font, fontSize: fontSize, fill: fill, color: color, points: points,
|
|
70
|
+
startDeg: startDeg, endDeg: endDeg, transitionMs: transitionMs, easing: easing
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// The same element placed at the canvas origin (for standalone rasterization).
|
|
75
|
+
func atOrigin() -> SceneElement {
|
|
76
|
+
SceneElement(
|
|
77
|
+
id: id, type: type, x: 0, y: 0, w: w, h: h, text: text, data: data,
|
|
78
|
+
border: border, radius: radius, change: change, contentHash: contentHash,
|
|
79
|
+
font: font, fontSize: fontSize, fill: fill, color: color, points: points,
|
|
80
|
+
startDeg: startDeg, endDeg: endDeg, transitionMs: transitionMs, easing: easing
|
|
50
81
|
)
|
|
51
82
|
}
|
|
52
83
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
@testable import BluetoothSDK
|
|
2
|
+
import Foundation
|
|
3
|
+
import XCTest
|
|
4
|
+
|
|
5
|
+
final class G2CfwSceneTests: XCTestCase {
|
|
6
|
+
private func element(
|
|
7
|
+
_ id: String, type: String, x: Int32 = 0, y: Int32 = 0, w: Int32 = 0, h: Int32 = 0,
|
|
8
|
+
text: String? = nil, border: Int32 = 0, radius: Int32 = 0, font: String = "default",
|
|
9
|
+
fill: Bool = false, color: Int32 = -1, points: [Int32] = [], startDeg: Int32 = 0, endDeg: Int32 = 0,
|
|
10
|
+
transitionMs: Int32 = 0, easing: String = "ease", hash: String = "h"
|
|
11
|
+
) -> SceneElement {
|
|
12
|
+
SceneElement(
|
|
13
|
+
id: id, type: type, x: x, y: y, w: w, h: h, text: text, data: nil, border: border, radius: radius,
|
|
14
|
+
change: "created", contentHash: hash, font: font, fill: fill, color: color, points: points,
|
|
15
|
+
startDeg: startDeg, endDeg: endDeg, transitionMs: transitionMs, easing: easing
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private func frame(_ elements: [SceneElement], replay: Bool = false) -> SceneFrame {
|
|
20
|
+
SceneFrame(appId: "app", epoch: 1, replay: replay, elements: elements, removed: [])
|
|
21
|
+
}
|
|
22
|
+
|
|
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
|
|
26
|
+
Data(repeating: 0xFF, count: w * h)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Little-endian int16 bytes of each value, concatenated.
|
|
31
|
+
private func le(_ values: Int...) -> [UInt8] {
|
|
32
|
+
var out: [UInt8] = []
|
|
33
|
+
for v in values {
|
|
34
|
+
let bits = UInt16(bitPattern: Int16(v))
|
|
35
|
+
out.append(UInt8(bits & 0xFF))
|
|
36
|
+
out.append(UInt8(bits >> 8))
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func testShapesBecomeSetRecordsWithOffsetGeometry() throws {
|
|
42
|
+
let scene = G2CfwScene()
|
|
43
|
+
let cache = G2CfwTextureCache()
|
|
44
|
+
cache.reset(generation: 1)
|
|
45
|
+
scene.reset(generation: 1)
|
|
46
|
+
let encoded = try XCTUnwrap(encode(scene, cache, [
|
|
47
|
+
element("l", type: "line", border: 3, color: 12, points: [10, 20, 30, 40]),
|
|
48
|
+
element("c", type: "circle", x: 100, y: 100, w: 41, h: 41, fill: true),
|
|
49
|
+
element("a", type: "arc", x: 0, y: 0, w: 101, h: 101, border: 4, startDeg: -90, endDeg: 180),
|
|
50
|
+
element("r", type: "rect", x: 1, y: 2, w: 30, h: 20, border: 2, radius: 6, fill: true),
|
|
51
|
+
]))
|
|
52
|
+
XCTAssertTrue(encoded.uploads.isEmpty)
|
|
53
|
+
let bytes = [UInt8](encoded.payload)
|
|
54
|
+
// header: mode 17, COMMIT|CLEAR on the first (repack) frame, black background
|
|
55
|
+
XCTAssertEqual(Array(bytes[0 ..< 3]), [17, 0x03, 0])
|
|
56
|
+
// line at slot 0: type 1, visible, color 12, width 3, points shifted by the origin
|
|
57
|
+
XCTAssertEqual(Array(bytes[3 ..< 9]), [0, 0, 1, 1, 12, 3])
|
|
58
|
+
XCTAssertEqual(Array(bytes[9 ..< 17]), le(42, 116, 62, 136))
|
|
59
|
+
// filled circle at slot 1: center + radius from the box
|
|
60
|
+
let circle = 3 + 22
|
|
61
|
+
XCTAssertEqual(Array(bytes[circle ..< circle + 6]), [0, 1, 5, 1, 15, 0])
|
|
62
|
+
XCTAssertEqual(Array(bytes[circle + 6 ..< circle + 12]), le(152, 216, 20))
|
|
63
|
+
// arc at slot 2 keeps its angles
|
|
64
|
+
let arc = circle + 22
|
|
65
|
+
XCTAssertEqual(Array(bytes[arc ..< arc + 6]), [0, 2, 12, 1, 15, 4])
|
|
66
|
+
XCTAssertEqual(Array(bytes[arc + 12 ..< arc + 16]), le(-90, 180))
|
|
67
|
+
// filled rect with a border is two slots: fill then stroke
|
|
68
|
+
let rect = arc + 22
|
|
69
|
+
XCTAssertEqual(Array(bytes[rect ..< rect + 6]), [0, 3, 3, 1, 15, 0])
|
|
70
|
+
XCTAssertEqual(Array(bytes[rect + 22 ..< rect + 28]), [0, 4, 2, 1, 15, 2])
|
|
71
|
+
XCTAssertEqual(bytes.count, rect + 44)
|
|
72
|
+
XCTAssertEqual(scene.elements.map(\.indices), [[0], [1], [2], [3, 4]])
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func testMovedElementWithTransitionBecomesTween() throws {
|
|
76
|
+
let scene = G2CfwScene()
|
|
77
|
+
let cache = G2CfwTextureCache()
|
|
78
|
+
cache.reset(generation: 1)
|
|
79
|
+
scene.reset(generation: 1)
|
|
80
|
+
_ = try XCTUnwrap(encode(scene, cache, [
|
|
81
|
+
element("ball", type: "circle", x: 0, y: 0, w: 21, h: 21, fill: true),
|
|
82
|
+
]))
|
|
83
|
+
let next = try XCTUnwrap(encode(scene, cache, [
|
|
84
|
+
element("ball", type: "circle", x: 200, y: 0, w: 21, h: 21, fill: true, transitionMs: 330, easing: "linear"),
|
|
85
|
+
]))
|
|
86
|
+
let bytes = [UInt8](next.payload)
|
|
87
|
+
XCTAssertEqual(Array(bytes[0 ..< 3]), [17, 0x01, 0])
|
|
88
|
+
// TWEEN slot 0, mask p0 only, 10 frames, linear curve, target x
|
|
89
|
+
XCTAssertEqual(Array(bytes[3 ..< 12]), [5, 0, 0x01, 0x00, 10, 0, 0, 255, 255])
|
|
90
|
+
XCTAssertEqual(Array(bytes[12 ..< 14]), le(242))
|
|
91
|
+
XCTAssertEqual(bytes.count, 14)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func testGeometryChangeWithoutTransitionAndTypeChangeReSet() throws {
|
|
95
|
+
let scene = G2CfwScene()
|
|
96
|
+
let cache = G2CfwTextureCache()
|
|
97
|
+
cache.reset(generation: 1)
|
|
98
|
+
scene.reset(generation: 1)
|
|
99
|
+
_ = try XCTUnwrap(encode(scene, cache, [element("s", type: "circle", w: 11, h: 11)]))
|
|
100
|
+
let moved = try XCTUnwrap(encode(scene, cache, [element("s", type: "circle", x: 5, w: 11, h: 11)]))
|
|
101
|
+
XCTAssertEqual([UInt8](moved.payload)[3 ..< 5], [0, 0])
|
|
102
|
+
let retyped = try XCTUnwrap(encode(scene, cache, [
|
|
103
|
+
element("s", type: "circle", x: 5, w: 11, h: 11, fill: true, transitionMs: 500),
|
|
104
|
+
]))
|
|
105
|
+
XCTAssertEqual([UInt8](retyped.payload)[3 ..< 6], [0, 0, 5])
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func testRemovalDeletesSlotsAndReorderRepacks() throws {
|
|
109
|
+
let scene = G2CfwScene()
|
|
110
|
+
let cache = G2CfwTextureCache()
|
|
111
|
+
cache.reset(generation: 1)
|
|
112
|
+
scene.reset(generation: 1)
|
|
113
|
+
_ = try XCTUnwrap(encode(scene, cache, [
|
|
114
|
+
element("a", type: "circle", w: 11, h: 11),
|
|
115
|
+
element("b", type: "circle", w: 11, h: 11),
|
|
116
|
+
]))
|
|
117
|
+
let dropped = try XCTUnwrap(encode(scene, cache, [element("b", type: "circle", w: 11, h: 11)]))
|
|
118
|
+
XCTAssertEqual([UInt8](dropped.payload), [17, 0x01, 0, 1, 0])
|
|
119
|
+
XCTAssertEqual(scene.elements.map(\.indices), [[1]])
|
|
120
|
+
// "a" returns in front of "b": slot order would break paint order, so the frame repacks
|
|
121
|
+
let reordered = try XCTUnwrap(encode(scene, cache, [
|
|
122
|
+
element("a", type: "circle", w: 11, h: 11),
|
|
123
|
+
element("b", type: "circle", w: 11, h: 11),
|
|
124
|
+
]))
|
|
125
|
+
XCTAssertEqual([UInt8](reordered.payload)[1], 0x03)
|
|
126
|
+
XCTAssertEqual(scene.elements.map(\.indices), [[0], [1]])
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func testBuiltinTextAndRasterTilesUseTheTextureCache() throws {
|
|
130
|
+
let scene = G2CfwScene()
|
|
131
|
+
let cache = G2CfwTextureCache()
|
|
132
|
+
cache.reset(generation: 1)
|
|
133
|
+
scene.reset(generation: 1)
|
|
134
|
+
let encoded = try XCTUnwrap(encode(scene, cache, [
|
|
135
|
+
element("t", type: "text", x: 10, y: 10, w: 200, h: 60, text: "hi\nthere", font: "builtin"),
|
|
136
|
+
element("i", type: "image", x: 0, y: 0, w: 300, h: 10, hash: "img"),
|
|
137
|
+
]))
|
|
138
|
+
XCTAssertEqual(encoded.uploads.count, 1)
|
|
139
|
+
// "hi" then "there" in the cache right after the zero guard
|
|
140
|
+
let expectedUpload: [UInt8] = [12, 2, 0, 2, 0, 0x68, 0x69, 4, 0, 5, 0]
|
|
141
|
+
XCTAssertEqual(Array(encoded.uploads[0].prefix(11)), expectedUpload)
|
|
142
|
+
let bytes = [UInt8](encoded.payload)
|
|
143
|
+
// two TEXT slots (27 px line pitch) + two IMAGE tiles (255 + 45 px)
|
|
144
|
+
XCTAssertEqual(scene.elements.map(\.indices), [[0, 1], [2, 3]])
|
|
145
|
+
XCTAssertEqual(Array(bytes[3 ..< 6]), [0, 0, 15])
|
|
146
|
+
XCTAssertEqual(Array(bytes[9 ..< 17]), le(42, 106, 2, 2))
|
|
147
|
+
let second = 3 + 22
|
|
148
|
+
XCTAssertEqual(Array(bytes[second + 6 ..< second + 14]), le(42, 133, 4, 5))
|
|
149
|
+
let tile2 = 3 + 3 * 22
|
|
150
|
+
XCTAssertEqual(Array(bytes[tile2 ..< tile2 + 3]), [0, 3, 14])
|
|
151
|
+
XCTAssertEqual(Array(bytes[tile2 + 6 ..< tile2 + 10]), le(32 + 255, 96))
|
|
152
|
+
// identical content re-renders without new uploads or ops
|
|
153
|
+
let again = try XCTUnwrap(encode(scene, cache, [
|
|
154
|
+
element("t", type: "text", x: 10, y: 10, w: 200, h: 60, text: "hi\nthere", font: "builtin"),
|
|
155
|
+
element("i", type: "image", x: 0, y: 0, w: 300, h: 10, hash: "img"),
|
|
156
|
+
]))
|
|
157
|
+
XCTAssertTrue(again.uploads.isEmpty)
|
|
158
|
+
XCTAssertEqual([UInt8](again.payload), [17, 0x01, 0])
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func testTooManySlotsOrFullCacheReturnsNil() {
|
|
162
|
+
let scene = G2CfwScene()
|
|
163
|
+
let cache = G2CfwTextureCache()
|
|
164
|
+
cache.reset(generation: 1)
|
|
165
|
+
scene.reset(generation: 1)
|
|
166
|
+
let many = (0 ..< 129).map { element("s\($0)", type: "circle", w: 11, h: 11) }
|
|
167
|
+
XCTAssertNil(encode(scene, cache, many))
|
|
168
|
+
let huge = element("big", type: "image", w: 2000, h: 2000, hash: "big")
|
|
169
|
+
let full = scene.encode(frame([huge]), originX: 0, originY: 0, cache: cache) { _, w, h in
|
|
170
|
+
var noise = Data(count: w * h)
|
|
171
|
+
for i in 0 ..< noise.count { noise[i] = UInt8(truncatingIfNeeded: i * 37) }
|
|
172
|
+
return noise
|
|
173
|
+
}
|
|
174
|
+
XCTAssertNil(full)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
func testTextBytesStripControlsAndCutAtCodepoints() {
|
|
178
|
+
XCTAssertEqual(G2CfwScene.textBytes("a\u{01}b"), Array("ab".utf8))
|
|
179
|
+
let long = String(repeating: "é", count: 40) // 80 UTF-8 bytes
|
|
180
|
+
XCTAssertEqual(G2CfwScene.textBytes(long).count, 64)
|
|
181
|
+
XCTAssertEqual(G2CfwScene.transitionFrames(ms: 0), 0)
|
|
182
|
+
XCTAssertEqual(G2CfwScene.transitionFrames(ms: 10), 2)
|
|
183
|
+
XCTAssertEqual(G2CfwScene.transitionFrames(ms: 990), 30)
|
|
184
|
+
XCTAssertEqual(G2CfwScene.transitionFrames(ms: 60000), 255)
|
|
185
|
+
XCTAssertEqual(G2CfwScene.easingCurve("ease-in-out"), [107, 0, 148, 255])
|
|
186
|
+
}
|
|
187
|
+
}
|