@glassly/bluetooth-sdk 0.1.0-dev.111 → 0.1.0-dev.113

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.
@@ -3,9 +3,9 @@ package com.glassly.bluetoothsdk
3
3
  /** Generated by release CI. Do not edit in a release checkout. */
4
4
  internal object GeneratedReleaseMetadata {
5
5
  const val FAMILY_BASE_VERSION: String = "0.1.0"
6
- const val RELEASE_IDENTITY: String = "0.1.0-dev.111"
7
- const val RELEASE_SET_ID: String = "glassly-0.1.0-dev.111"
8
- const val SOURCE_COMMIT: String = "fa91a3665743b2d5e5ed12f8cf738b715c4d8458"
6
+ const val RELEASE_IDENTITY: String = "0.1.0-dev.113"
7
+ const val RELEASE_SET_ID: String = "glassly-0.1.0-dev.113"
8
+ const val SOURCE_COMMIT: String = "e63da43b467758373b960b0ee9167ef29938a11c"
9
9
  const val OTA_MANIFEST_URL: String = ""
10
10
  const val OTA_MANIFEST_SHA256: String = ""
11
11
  }
@@ -1578,6 +1578,10 @@ class G2 : SGCManager() {
1578
1578
  supportsCfwSceneRendering &&
1579
1579
  listOf("scene17", "shapes16", "anim18", "texcache12", "teximg13", "font15").all { it in cfwFeatures }
1580
1580
 
1581
+ /** TEXT_INLINE records: default text drawn by the glasses' font, no cache upload. */
1582
+ private val supportsCfwInlineText: Boolean
1583
+ get() = "inlinetext17" in cfwFeatures
1584
+
1581
1585
  /** The on-glass slot table must be re-SET on the next vector frame. */
1582
1586
  private var cfwSceneInvalid = true
1583
1587
 
@@ -3104,9 +3108,10 @@ class G2 : SGCManager() {
3104
3108
  }
3105
3109
  if (cfwLastBoxes != boxes) cfwSceneInvalid = true
3106
3110
  val invalidate = cfwSceneInvalid
3111
+ val inlineText = supportsCfwInlineText
3107
3112
  val encoded =
3108
3113
  kotlinx.coroutines.withContext(Dispatchers.Default) {
3109
- G2CfwRenderer.encodeVector(frame, boxes, cfwCacheGeneration, invalidate)
3114
+ G2CfwRenderer.encodeVector(frame, boxes, cfwCacheGeneration, invalidate, inlineText)
3110
3115
  }
3111
3116
  if (encoded == null) {
3112
3117
  Bridge.log("G2: vector scene does not fit; using the raster path for this frame")
@@ -87,6 +87,7 @@ internal object G2CfwRenderer {
87
87
  boxes: LensBoxes,
88
88
  cacheGeneration: Int,
89
89
  invalidate: Boolean,
90
+ inlineText: Boolean,
90
91
  ): G2CfwScene.Encoded? {
91
92
  if (textureCache.generation != cacheGeneration || textureCache.exhausted) {
92
93
  textureCache.reset(cacheGeneration)
@@ -103,11 +104,11 @@ internal object G2CfwRenderer {
103
104
  h,
104
105
  )
105
106
  }
106
- scene.encode(frame, boxes.leftX, boxes.leftY, textureCache, rasterizeOne)?.let { return it }
107
+ scene.encode(frame, boxes.leftX, boxes.leftY, textureCache, rasterizeOne, inlineText)?.let { return it }
107
108
  // Out of texture cache mid-frame: start a fresh cache and repack once.
108
109
  textureCache.reset(cacheGeneration)
109
110
  scene.reset(cacheGeneration)
110
- return scene.encode(frame, boxes.leftX, boxes.leftY, textureCache, rasterizeOne)
111
+ return scene.encode(frame, boxes.leftX, boxes.leftY, textureCache, rasterizeOne, inlineText)
111
112
  }
112
113
  private val glyphRasterMemo = mutableMapOf<String, ByteArray>()
113
114
 
@@ -6,13 +6,14 @@ import java.io.ByteArrayOutputStream
6
6
  * Retained vector scene for the Glassly CFW (firmware modes 16-18).
7
7
  *
8
8
  * Every scene element becomes one or more firmware slots (paint order = slot
9
- * order): vector shapes map 1:1, builtin-font text becomes one TEXT slot per
10
- * line with its bytes in the texture cache, and everything else (proportional
11
- * text, images) is rasterized on the phone and drawn as cached IMAGE slots
12
- * tiled to the 255 px slot limit. Frames diff against the last committed
13
- * layout: unchanged slots cost nothing, geometry changes on an element with a
14
- * transition become firmware TWEENs, and anything else is re-SET. Slot indices
15
- * stay stable per element id; a reorder or an over-budget frame repacks.
9
+ * order): vector shapes map 1:1; default and builtin text becomes one inline
10
+ * TEXT slot per line drawn by the glasses' font (or a cache-backed TEXT slot on
11
+ * firmware without inline records); mono text and images are rasterized on
12
+ * the phone and drawn as cached IMAGE slots tiled to the 255 px slot limit.
13
+ * Frames diff against the last committed layout: unchanged slots cost nothing,
14
+ * geometry changes on an element with a transition become firmware TWEENs, and
15
+ * anything else is re-SET. Slot indices stay stable per element id; a reorder
16
+ * or an over-budget frame repacks.
16
17
  */
17
18
  internal class G2CfwScene {
18
19
  companion object {
@@ -20,7 +21,9 @@ internal class G2CfwScene {
20
21
  const val RECORD_BYTES = 20
21
22
  const val FRAME_PERIOD_MS = 33.0
22
23
  const val TEXT_LINE_HEIGHT = 27
24
+ const val TEXT_INSET = 2
23
25
  const val MAX_TEXT_BYTES = 64
26
+ const val MAX_INLINE_BYTES = 128
24
27
  const val MAX_TILE_SIZE = 255
25
28
  /** Mode-13/15 options: identity ramp, transparent source color 0. */
26
29
  const val TEXT_OPTIONS = 0x1F
@@ -42,6 +45,7 @@ internal class G2CfwScene {
42
45
  const val TYPE_IMAGE = 14
43
46
  const val TYPE_TEXT = 15
44
47
  const val TYPE_TEXT_CACHED = 16
48
+ const val TYPE_TEXT_INLINE = 17
45
49
 
46
50
  /** Geometry parameters a TWEEN may interpolate, per type. Texture offsets and lengths never tween. */
47
51
  fun tweenableMask(type: Int): Int =
@@ -52,6 +56,7 @@ internal class G2CfwScene {
52
56
  TYPE_TRI, TYPE_TRI_FILL, TYPE_BEZIER2 -> 0x3F
53
57
  TYPE_QUAD, TYPE_QUAD_FILL, TYPE_BEZIER3 -> 0xFF
54
58
  TYPE_IMAGE, TYPE_TEXT, TYPE_TEXT_CACHED -> 0x03
59
+ TYPE_TEXT_INLINE -> 0x0F
55
60
  else -> 0
56
61
  }
57
62
 
@@ -71,7 +76,7 @@ internal class G2CfwScene {
71
76
  }
72
77
 
73
78
  /** UTF-8 bytes of a line without firmware control bytes (1..31), cut at a codepoint boundary. */
74
- fun textBytes(line: String): ByteArray {
79
+ fun textBytes(line: String, limit: Int = MAX_TEXT_BYTES): ByteArray {
75
80
  val out = ByteArrayOutputStream()
76
81
  var i = 0
77
82
  while (i < line.length) {
@@ -79,7 +84,7 @@ internal class G2CfwScene {
79
84
  i += Character.charCount(cp)
80
85
  if (cp < 32) continue
81
86
  val encoded = String(Character.toChars(cp)).toByteArray(Charsets.UTF_8)
82
- if (out.size() + encoded.size > MAX_TEXT_BYTES) break
87
+ if (out.size() + encoded.size > limit) break
83
88
  out.write(encoded)
84
89
  }
85
90
  return out.toByteArray()
@@ -96,7 +101,10 @@ internal class G2CfwScene {
96
101
  out.write((value ushr 8) and 0xFF)
97
102
  }
98
103
 
99
- /** `[0][slot][type][flags=visible][color][width][p0..p7 LE]` */
104
+ /**
105
+ * `[0][slot][type][flags=visible][color][width][p0..p7 LE]`, or for TEXT_INLINE
106
+ * `[0][slot][17][1][color][width][x][y][w][h][len][bytes]`.
107
+ */
100
108
  fun setRecord(slot: Slot, index: Int): ByteArray {
101
109
  val out = ByteArrayOutputStream()
102
110
  out.write(0)
@@ -105,7 +113,13 @@ internal class G2CfwScene {
105
113
  out.write(1)
106
114
  out.write(slot.color)
107
115
  out.write(slot.width)
108
- for (value in slot.params) le(out, value)
116
+ val inline = slot.type == TYPE_TEXT_INLINE
117
+ for (k in 0 until (if (inline) 4 else 8)) le(out, slot.params[k])
118
+ if (inline) {
119
+ val bytes = slot.text.take(MAX_INLINE_BYTES).toByteArray()
120
+ out.write(bytes.size)
121
+ out.write(bytes)
122
+ }
109
123
  return out.toByteArray()
110
124
  }
111
125
 
@@ -114,7 +128,7 @@ internal class G2CfwScene {
114
128
  * change is not a pure geometry/stroke change of the same shape type.
115
129
  */
116
130
  fun tweenRecord(old: Slot, new: Slot, index: Int, frames: Int, curve: IntArray): ByteArray? {
117
- if (old.type != new.type) return null
131
+ if (old.type != new.type || !old.text.contentEquals(new.text)) return null
118
132
  val tweenable = tweenableMask(new.type)
119
133
  var mask = 0
120
134
  val values = mutableListOf<Int>()
@@ -126,7 +140,9 @@ internal class G2CfwScene {
126
140
  }
127
141
  val isTexture = new.type >= TYPE_IMAGE
128
142
  if (old.color != new.color) {
129
- if (isTexture) return null
143
+ // Inline text may fade (low nibble) as long as its option bits stay put.
144
+ val inlineFade = new.type == TYPE_TEXT_INLINE && (old.color and 0xF0) == (new.color and 0xF0)
145
+ if (isTexture && !inlineFade) return null
130
146
  mask = mask or 0x100
131
147
  values.add(new.color)
132
148
  }
@@ -147,15 +163,19 @@ internal class G2CfwScene {
147
163
  }
148
164
  }
149
165
 
150
- /** One firmware slot: type, 4-bit color (or options byte), stroke width, eight int16 parameters. */
151
- class Slot(val type: Int, val color: Int, val width: Int, params: List<Int>) {
166
+ /**
167
+ * One firmware slot: type, 4-bit color (or options byte), stroke width, eight
168
+ * int16 parameters, and the record's own bytes for TEXT_INLINE.
169
+ */
170
+ class Slot(val type: Int, val color: Int, val width: Int, params: List<Int>, val text: ByteArray = ByteArray(0)) {
152
171
  val params: IntArray = IntArray(8) { params.getOrElse(it) { 0 } }
153
172
 
154
173
  override fun equals(other: Any?): Boolean =
155
174
  other is Slot && other.type == type && other.color == color && other.width == width &&
156
- other.params.contentEquals(params)
175
+ other.params.contentEquals(params) && other.text.contentEquals(text)
157
176
 
158
- override fun hashCode(): Int = ((type * 31 + color) * 31 + width) * 31 + params.contentHashCode()
177
+ override fun hashCode(): Int =
178
+ (((type * 31 + color) * 31 + width) * 31 + params.contentHashCode()) * 31 + text.contentHashCode()
159
179
  }
160
180
 
161
181
  class ElementState(val id: String, val contentHash: String, val slots: List<Slot>, val indices: List<Int>)
@@ -189,11 +209,15 @@ internal class G2CfwScene {
189
209
  return Triple(clampCoord(element.x + element.w / 2 + ox), clampCoord(element.y + element.h / 2 + oy), r)
190
210
  }
191
211
 
192
- /** Slots for one element, or null when the texture cache cannot hold its content. */
212
+ /**
213
+ * Slots for one element, or null when the texture cache cannot hold its content.
214
+ * [inlineText] selects TEXT_INLINE records for default/builtin text.
215
+ */
193
216
  fun slotsFor(
194
217
  element: SceneElement,
195
218
  ox: Int,
196
219
  oy: Int,
220
+ inlineText: Boolean,
197
221
  cache: G2CfwTextureCache,
198
222
  rasterize: (SceneElement, Int, Int) -> ByteArray?,
199
223
  ): List<Slot>? {
@@ -245,13 +269,46 @@ internal class G2CfwScene {
245
269
  val params = (0 until n).flatMap { point(element, it, ox, oy).toList() }
246
270
  listOf(Slot(if (n == 3) TYPE_BEZIER2 else TYPE_BEZIER3, color, stroke(element), params))
247
271
  }
248
- "text" -> if (element.font == "builtin") builtinTextSlots(element, x, y, cache)
249
- else rasterSlots(element, x, y, cache, rasterize)
272
+ "text" ->
273
+ when {
274
+ element.font == "mono" -> rasterSlots(element, x, y, cache, rasterize)
275
+ inlineText -> inlineTextSlots(element, x, y)
276
+ element.font == "builtin" -> builtinTextSlots(element, x, y, cache)
277
+ else -> rasterSlots(element, x, y, cache, rasterize)
278
+ }
250
279
  "image" -> rasterSlots(element, x, y, cache, rasterize)
251
280
  else -> emptyList()
252
281
  }
253
282
  }
254
283
 
284
+ /** One TEXT_INLINE slot per line, drawn by the glasses' font at the phone-raster 2 px inset, clipped to the box. */
285
+ private fun inlineTextSlots(element: SceneElement, x: Int, y: Int): List<Slot> {
286
+ val out = mutableListOf<Slot>()
287
+ val w = element.w.coerceIn(0, 2047)
288
+ val h = element.h.coerceIn(0, 2047)
289
+ if (element.border > 0) {
290
+ out.add(Slot(TYPE_RECT, gray(element), stroke(element), listOf(x, y, w, h, element.radius.coerceIn(0, 2047))))
291
+ }
292
+ val options = 0x10 or gray(element)
293
+ val maxLines = maxOf(1, element.h / TEXT_LINE_HEIGHT)
294
+ val lines = (element.text ?: "").split("\n").take(maxLines)
295
+ for ((index, line) in lines.withIndex()) {
296
+ val bytes = textBytes(line, MAX_INLINE_BYTES)
297
+ if (bytes.isEmpty()) continue
298
+ val lineY = y + TEXT_INSET + index * TEXT_LINE_HEIGHT
299
+ val clipW = maxOf(0, element.w - TEXT_INSET).coerceAtMost(2047)
300
+ val clipH = maxOf(0, element.h - TEXT_INSET - index * TEXT_LINE_HEIGHT).coerceAtMost(2047)
301
+ out.add(
302
+ Slot(
303
+ TYPE_TEXT_INLINE, options, 0,
304
+ listOf(clampCoord(x + TEXT_INSET), clampCoord(lineY), clipW, clipH, bytes.size),
305
+ bytes,
306
+ ),
307
+ )
308
+ }
309
+ return out
310
+ }
311
+
255
312
  private fun builtinTextSlots(element: SceneElement, x: Int, y: Int, cache: G2CfwTextureCache): List<Slot>? {
256
313
  val out = mutableListOf<Slot>()
257
314
  if (element.border > 0) {
@@ -324,10 +381,11 @@ internal class G2CfwScene {
324
381
  originY: Int,
325
382
  cache: G2CfwTextureCache,
326
383
  rasterize: (SceneElement, Int, Int) -> ByteArray?,
384
+ inlineText: Boolean = false,
327
385
  ): Encoded? {
328
386
  val desired = mutableListOf<Desired>()
329
387
  for (element in frame.elements) {
330
- val slots = slotsFor(element, originX, originY, cache, rasterize) ?: return null
388
+ val slots = slotsFor(element, originX, originY, inlineText, cache, rasterize) ?: return null
331
389
  desired.add(
332
390
  Desired(element.id, element.contentHash, slots, transitionFrames(element.transitionMs), easingCurve(element.easing)),
333
391
  )
@@ -50,8 +50,8 @@ internal object G2FirmwareCatalog {
50
50
  displayName = "Glassly CFW",
51
51
  version = "2.2.9.22 CFW",
52
52
  fileName = "g2-glassly-cfw-2.2.9.22.evenota.bin",
53
- sha256 = "fe6051085507269c8007bc1245bfe8bd10d5c566540389ae976daaf753f536f9",
54
- size = 4_527_476,
53
+ sha256 = "560d4d94344573d11ba58690221c0ea7d5a5e8c8d29c12f158e81078484b815c",
54
+ size = 4_528_184,
55
55
  ),
56
56
  )
57
57
 
@@ -35,8 +35,8 @@ class G2CfwSceneTest {
35
35
  private fun frame(elements: List<SceneElement>, replay: Boolean = false) =
36
36
  SceneFrame("app", 1, replay, elements, emptyList())
37
37
 
38
- private fun encode(scene: G2CfwScene, cache: G2CfwTextureCache, elements: List<SceneElement>) =
39
- scene.encode(frame(elements), 32, 96, cache) { _, w, h -> ByteArray(w * h) { 0xFF.toByte() } }
38
+ private fun encode(scene: G2CfwScene, cache: G2CfwTextureCache, elements: List<SceneElement>, inlineText: Boolean = false) =
39
+ scene.encode(frame(elements), 32, 96, cache, { _, w, h -> ByteArray(w * h) { 0xFF.toByte() } }, inlineText)
40
40
 
41
41
  private fun le(v: Int): List<Byte> = listOf((v and 0xFF).toByte(), ((v ushr 8) and 0xFF).toByte())
42
42
 
@@ -158,7 +158,7 @@ class G2CfwSceneTest {
158
158
  val (scene, cache) = fresh()
159
159
  assertNull(encode(scene, cache, (0 until 129).map { element("s$it", "circle", w = 11, h = 11) }))
160
160
  val huge = element("big", "image", w = 2000, h = 2000, hash = "big")
161
- val full = scene.encode(frame(listOf(huge)), 0, 0, cache) { _, w, h -> ByteArray(w * h) { (it * 37).toByte() } }
161
+ val full = scene.encode(frame(listOf(huge)), 0, 0, cache, { _, w, h -> ByteArray(w * h) { (it * 37).toByte() } })
162
162
  assertNull(full)
163
163
  }
164
164
 
@@ -172,4 +172,38 @@ class G2CfwSceneTest {
172
172
  assertEquals(255, G2CfwScene.transitionFrames(60000))
173
173
  assertArrayEquals(intArrayOf(107, 0, 148, 255), G2CfwScene.easingCurve("ease-in-out"))
174
174
  }
175
+
176
+ @Test
177
+ fun inlineTextRecordsCarryTheirBytesAndAnimate() {
178
+ val (scene, cache) = fresh()
179
+ val mono = element("m", "text", y = 200, w = 100, h = 30, text = "grid", font = "mono", hash = "mono")
180
+ val first = requireNotNull(
181
+ encode(scene, cache, listOf(element("t", "text", x = 10, y = 10, w = 200, h = 60, text = "hi\nthere", color = 9), mono), true),
182
+ )
183
+ assertEquals(1, first.uploads.size)
184
+ assertEquals(listOf(listOf(0, 1), listOf(2)), scene.elements.map { it.indices })
185
+ val p = first.payload
186
+ assertEquals(listOf<Byte>(0, 0, 17, 1, 0x19, 0), bytes(p, 3, 9))
187
+ assertEquals(le(44) + le(108) + le(198) + le(58), bytes(p, 9, 17))
188
+ assertEquals(listOf<Byte>(2, 0x68, 0x69), bytes(p, 17, 20))
189
+ assertEquals(listOf<Byte>(0, 1, 17), bytes(p, 20, 23))
190
+ assertEquals(le(44) + le(135) + le(198) + le(31), bytes(p, 26, 34))
191
+ assertEquals("there".toByteArray().toList(), bytes(p, 35, 40))
192
+ assertEquals(0.toByte(), p[40])
193
+ assertEquals(14.toByte(), p[42])
194
+
195
+ val moved = requireNotNull(
196
+ encode(scene, cache, listOf(element("t", "text", x = 10, y = 40, w = 200, h = 60, text = "hi\nthere", color = 9, transitionMs = 330), mono), true),
197
+ )
198
+ assertEquals(listOf<Byte>(5, 0, 0x02, 0x00, 10), bytes(moved.payload, 3, 8))
199
+ val retexted = requireNotNull(
200
+ encode(scene, cache, listOf(element("t", "text", x = 10, y = 40, w = 200, h = 60, text = "ho\nthere", color = 9, transitionMs = 330), mono), true),
201
+ )
202
+ assertEquals(listOf<Byte>(0, 0, 17), bytes(retexted.payload, 3, 6))
203
+ val faded = requireNotNull(
204
+ encode(scene, cache, listOf(element("t", "text", x = 10, y = 40, w = 200, h = 60, text = "ho\nthere", color = 3, transitionMs = 330), mono), true),
205
+ )
206
+ assertEquals(listOf<Byte>(5, 0, 0x00, 0x01, 10), bytes(faded.payload, 3, 8))
207
+ assertEquals(le(0x13), bytes(faded.payload, 12, 14))
208
+ }
175
209
  }
@@ -1,9 +1,9 @@
1
1
  export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
2
2
  "schemaVersion": 1,
3
3
  "familyBaseVersion": "0.1.0",
4
- "releaseIdentity": "0.1.0-dev.111",
5
- "releaseSetId": "glassly-0.1.0-dev.111",
6
- "sourceCommit": "fa91a3665743b2d5e5ed12f8cf738b715c4d8458",
4
+ "releaseIdentity": "0.1.0-dev.113",
5
+ "releaseSetId": "glassly-0.1.0-dev.113",
6
+ "sourceCommit": "e63da43b467758373b960b0ee9167ef29938a11c",
7
7
  "otaManifestUrl": null,
8
8
  "otaManifestSha256": null
9
9
  });
@@ -1 +1 @@
1
- {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,uBAAuB;IACvC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,IAAI;IACtB,mBAAmB,EAAE,IAAI;CAC1B,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"0.1.0\",\n \"releaseIdentity\": \"0.1.0-dev.111\",\n \"releaseSetId\": \"glassly-0.1.0-dev.111\",\n \"sourceCommit\": \"fa91a3665743b2d5e5ed12f8cf738b715c4d8458\",\n \"otaManifestUrl\": null,\n \"otaManifestSha256\": null\n})\n"]}
1
+ {"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,uBAAuB;IACvC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,IAAI;IACtB,mBAAmB,EAAE,IAAI;CAC1B,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"0.1.0\",\n \"releaseIdentity\": \"0.1.0-dev.113\",\n \"releaseSetId\": \"glassly-0.1.0-dev.113\",\n \"sourceCommit\": \"e63da43b467758373b960b0ee9167ef29938a11c\",\n \"otaManifestUrl\": null,\n \"otaManifestSha256\": null\n})\n"]}
@@ -11,7 +11,7 @@ enum BluetoothSdkDefaults {
11
11
  static let voiceActivityDetectionEnabled = false
12
12
  static let loudnessGateEnabled = true
13
13
  private static let infoSdkVersionKey = "BluetoothSdkVersion"
14
- private static let swiftPackageSdkVersion = "0.1.0-dev.111"
14
+ private static let swiftPackageSdkVersion = "0.1.0-dev.113"
15
15
  private static let swiftPackageSdkVersionPlaceholder = "__GLASSLY" + "_BLUETOOTH_SDK_VERSION__"
16
16
 
17
17
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -3,9 +3,9 @@ import Foundation
3
3
  /// Generated by release CI. Do not edit in a release checkout.
4
4
  enum GeneratedReleaseMetadata {
5
5
  static let familyBaseVersion = "0.1.0"
6
- static let releaseIdentity = "0.1.0-dev.111"
7
- static let releaseSetId = "glassly-0.1.0-dev.111"
8
- static let sourceCommit = "fa91a3665743b2d5e5ed12f8cf738b715c4d8458"
6
+ static let releaseIdentity = "0.1.0-dev.113"
7
+ static let releaseSetId = "glassly-0.1.0-dev.113"
8
+ static let sourceCommit = "e63da43b467758373b960b0ee9167ef29938a11c"
9
9
  static let otaManifestUrl = ""
10
10
  static let otaManifestSha256 = ""
11
11
  }
@@ -1810,6 +1810,11 @@ final class G2: NSObject, SGCManager {
1810
1810
  .allSatisfy(cfwFeatures.contains)
1811
1811
  }
1812
1812
 
1813
+ /// TEXT_INLINE records: default text drawn by the glasses' font, no cache upload.
1814
+ private var supportsCfwInlineText: Bool {
1815
+ cfwFeatures.contains("inlinetext17")
1816
+ }
1817
+
1813
1818
  /// The on-glass slot table must be re-SET on the next vector frame.
1814
1819
  private var cfwSceneInvalid = true
1815
1820
 
@@ -3126,7 +3131,8 @@ final class G2: NSObject, SGCManager {
3126
3131
  }
3127
3132
  if cfwLastBoxes != boxes { cfwSceneInvalid = true }
3128
3133
  guard let encoded = await cfwRenderer.encodeVector(
3129
- frame, boxes: boxes, cacheGeneration: cfwCacheGeneration, invalidate: cfwSceneInvalid
3134
+ frame, boxes: boxes, cacheGeneration: cfwCacheGeneration, invalidate: cfwSceneInvalid,
3135
+ inlineText: supportsCfwInlineText
3130
3136
  ) else {
3131
3137
  Bridge.log("G2: vector scene does not fit; using the raster path for this frame")
3132
3138
  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: "560d4d94344573d11ba58690221c0ea7d5a5e8c8d29c12f158e81078484b815c",
41
+ size: 4_528_184
42
42
  ),
43
43
  ]
44
44
 
@@ -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.111",
3
+ "version": "0.1.0-dev.113",
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.111",
16
- "releaseSetId": "glassly-0.1.0-dev.111",
17
- "sourceCommit": "fa91a3665743b2d5e5ed12f8cf738b715c4d8458",
15
+ "releaseIdentity": "0.1.0-dev.113",
16
+ "releaseSetId": "glassly-0.1.0-dev.113",
17
+ "sourceCommit": "e63da43b467758373b960b0ee9167ef29938a11c",
18
18
  "otaManifestUrl": null,
19
19
  "otaManifestSha256": null
20
20
  })