@glassly/bluetooth-sdk 0.1.0-dev.98 → 0.2.8-dev.444

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 (155) hide show
  1. package/README.md +47 -6
  2. package/android/build.gradle +7 -7
  3. package/android/lc3Lib/build.gradle +2 -2
  4. package/android/src/main/assets/G2Firmware/README.md +27 -2
  5. package/android/src/main/assets/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  6. package/android/src/main/assets/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  7. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +38 -0
  8. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +50 -14
  9. package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +18 -0
  10. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +139 -10
  11. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +28 -1
  12. package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +199 -0
  13. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +10 -1
  14. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +4 -4
  15. package/android/src/main/java/com/glassly/bluetoothsdk/GlassesNotifications.kt +155 -0
  16. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +1075 -257
  17. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2BleLink.kt +119 -0
  18. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +29 -28
  19. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +138 -33
  20. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +673 -0
  21. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +235 -162
  22. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransport.kt +402 -0
  23. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnostics.kt +84 -0
  24. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
  25. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +9 -9
  26. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Keyboard.kt +689 -0
  27. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardBridge.kt +452 -0
  28. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardHid.kt +208 -0
  29. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFile.kt +181 -0
  30. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2RingBattery.kt +20 -0
  31. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Sensors.kt +67 -0
  32. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2TouchInput.kt +39 -0
  33. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +5 -5
  34. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +14 -14
  35. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/{GlasslyBle.java → NexProto.java} +6462 -7618
  36. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIds.kt +58 -0
  37. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +77 -2
  38. package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +1 -1
  39. package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +55 -0
  40. package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +1 -1
  41. package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -1
  42. package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +1 -1
  43. package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +24 -149
  44. package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
  45. package/android/src/test/java/com/glassly/bluetoothsdk/G2BleLinkTest.kt +70 -0
  46. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +45 -56
  47. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +78 -5
  48. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +489 -0
  49. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +100 -43
  50. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransportTest.kt +252 -0
  51. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnosticsTest.kt +64 -0
  52. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardTest.kt +271 -0
  53. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFileTest.kt +204 -0
  54. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2RingBatteryTest.kt +56 -0
  55. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2SensorsTest.kt +56 -0
  56. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2TouchInputTest.kt +60 -0
  57. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NexProtoDescriptorTest.kt +32 -0
  58. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIdsTest.kt +58 -0
  59. package/build/BluetoothSdk.types.d.ts +209 -20
  60. package/build/BluetoothSdk.types.d.ts.map +1 -1
  61. package/build/BluetoothSdk.types.js.map +1 -1
  62. package/build/_internal.d.ts +1 -1
  63. package/build/_internal.d.ts.map +1 -1
  64. package/build/_internal.js.map +1 -1
  65. package/build/_private/BluetoothSdkModule.d.ts +30 -1
  66. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  67. package/build/_private/BluetoothSdkModule.js.map +1 -1
  68. package/build/generated/changelogCatalog.d.ts +28 -1
  69. package/build/generated/changelogCatalog.d.ts.map +1 -1
  70. package/build/generated/changelogCatalog.js +37 -1
  71. package/build/generated/changelogCatalog.js.map +1 -1
  72. package/build/generated/releaseMetadata.js +4 -4
  73. package/build/generated/releaseMetadata.js.map +1 -1
  74. package/build/index.d.ts +1 -1
  75. package/build/index.d.ts.map +1 -1
  76. package/build/index.js +14 -0
  77. package/build/index.js.map +1 -1
  78. package/ios/BluetoothSdkModule.swift +102 -13
  79. package/ios/GlasslyBluetoothSDK.podspec +1 -1
  80. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +8 -3
  81. package/ios/Source/BluetoothSDK.swift +53 -0
  82. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  83. package/ios/Source/Bridge.swift +13 -0
  84. package/ios/Source/DeviceManager.swift +151 -33
  85. package/ios/Source/DeviceStore.swift +28 -14
  86. package/ios/Source/DisplayPacer.swift +180 -0
  87. package/ios/Source/GeneratedChangelogCatalog.swift +10 -1
  88. package/ios/Source/GeneratedReleaseMetadata.swift +4 -4
  89. package/ios/Source/GlassesNotifications.swift +181 -0
  90. package/ios/Source/ObservableStore.swift +14 -2
  91. package/ios/Source/Resources/G2Firmware/README.md +27 -2
  92. package/ios/Source/Resources/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  93. package/ios/Source/Resources/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  94. package/ios/Source/controllers/R1.swift +0 -1
  95. package/ios/Source/internal/BluetoothAvailability.swift +31 -0
  96. package/ios/Source/sgcs/G2.swift +1434 -504
  97. package/ios/Source/sgcs/G2AncsRelay.swift +268 -0
  98. package/ios/Source/sgcs/G2BleLink.swift +102 -0
  99. package/ios/Source/sgcs/G2CfwClassification.swift +45 -27
  100. package/ios/Source/sgcs/G2CfwRenderer.swift +166 -33
  101. package/ios/Source/sgcs/G2CfwScene.swift +779 -0
  102. package/ios/Source/sgcs/G2CfwTextureCache.swift +244 -117
  103. package/ios/Source/sgcs/G2CfwTransport.swift +485 -0
  104. package/ios/Source/sgcs/G2DisplayDiagnostics.swift +154 -0
  105. package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
  106. package/ios/Source/sgcs/G2Flasher.swift +9 -9
  107. package/ios/Source/sgcs/G2Keyboard.swift +780 -0
  108. package/ios/Source/sgcs/G2KeyboardBridge.swift +438 -0
  109. package/ios/Source/sgcs/G2KeyboardHid.swift +290 -0
  110. package/ios/Source/sgcs/G2NotificationTuning.swift +24 -0
  111. package/ios/Source/sgcs/G2RingBattery.swift +22 -0
  112. package/ios/Source/sgcs/G2Sensors.swift +75 -0
  113. package/ios/Source/sgcs/G2TouchInput.swift +51 -0
  114. package/ios/Source/sgcs/G2WritePacing.swift +18 -0
  115. package/ios/Source/sgcs/MentraNex.swift +44 -44
  116. package/ios/Source/sgcs/SGCManager.swift +120 -13
  117. package/ios/Source/sgcs/{glassly_ble.pb.swift → nex_ble.pb.swift} +405 -465
  118. package/ios/Source/speakerid/SpeakerIdEngine.swift +2 -2
  119. package/ios/Source/status/DeviceStatus.swift +27 -0
  120. package/ios/Source/stt/SherpaOnnxTranscriber.swift +15 -14
  121. package/ios/Source/utils/Enums.swift +0 -1
  122. package/ios/Source/utils/G1Text.swift +0 -1
  123. package/ios/Source/utils/G2NotifyFile.swift +20 -10
  124. package/ios/Source/utils/JSCExperiment.swift +6 -6
  125. package/ios/Source/utils/Models.swift +0 -1
  126. package/ios/Tests/BluetoothSDKTests/BluetoothAvailabilityTests.swift +29 -0
  127. package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
  128. package/ios/Tests/BluetoothSDKTests/G2AncsRelayTests.swift +186 -0
  129. package/ios/Tests/BluetoothSDKTests/G2BleLinkTests.swift +52 -0
  130. package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +46 -51
  131. package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +164 -5
  132. package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +497 -0
  133. package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +108 -44
  134. package/ios/Tests/BluetoothSDKTests/G2CfwTransportTests.swift +259 -0
  135. package/ios/Tests/BluetoothSDKTests/G2DisplayDiagnosticsTests.swift +89 -0
  136. package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +3 -2
  137. package/ios/Tests/BluetoothSDKTests/G2ImageAckTests.swift +104 -0
  138. package/ios/Tests/BluetoothSDKTests/G2KeyboardTests.swift +274 -0
  139. package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +21 -5
  140. package/ios/Tests/BluetoothSDKTests/G2RingBatteryTests.swift +56 -0
  141. package/ios/Tests/BluetoothSDKTests/G2SensorsTests.swift +56 -0
  142. package/ios/Tests/BluetoothSDKTests/G2TouchInputTests.swift +71 -0
  143. package/ios/Tests/BluetoothSDKTests/G2WritePacingTests.swift +35 -0
  144. package/ios/Tests/BluetoothSDKTests/GlassesNotificationTests.swift +95 -0
  145. package/package.json +1 -1
  146. package/plugin/build/withAndroid.js +2 -2
  147. package/plugin/build/withIos.js +1 -1
  148. package/scripts/gen-nex-proto.sh +12 -0
  149. package/scripts/inject-ios-sdk-version.mjs +1 -1
  150. package/src/BluetoothSdk.types.ts +214 -19
  151. package/src/_internal.ts +1 -1
  152. package/src/_private/BluetoothSdkModule.ts +40 -0
  153. package/src/generated/changelogCatalog.ts +37 -1
  154. package/src/generated/releaseMetadata.ts +4 -4
  155. package/src/index.ts +24 -0
@@ -1,59 +1,83 @@
1
1
  import Foundation
2
2
 
3
- /// Phone-side model of the Glassly CFW's lease-scoped 64 KiB texture cache,
4
- /// plus the mode-12/13/14 wire encoders.
3
+ /// Phone-side mirror of the Glassly CFW's asset store (GLASSLYCFW/38): the
4
+ /// images, cached fonts and strings that scene objects reference by 32-bit id.
5
5
  ///
6
- /// The firmware allocates and zeroes the cache on the first mode-12 write and
7
- /// frees it when the fbguard lease ends, so this model is only valid for one
8
- /// lease/generation: the owner bumps a generation counter whenever the lease
9
- /// cycles (or a send fails and residency becomes unknown) and the model
10
- /// re-learns from scratch — uploads are cheap (a full mono font is a few KiB).
6
+ /// The glasses hold assets in a 256 KiB store behind their own allocator and
7
+ /// evict the least recently used unreferenced ones when room is needed, so the
8
+ /// wire never carries a storage offset: every asset has an id (a hash of what
9
+ /// it is) and a version (a hash of its bytes), and a PUT_ASSET entry uploads
10
+ /// it. This mirror remembers which ids it has queued for upload and predicts
11
+ /// the store's occupancy; when the prediction overflows it forgets what the
12
+ /// glasses are most likely to have evicted. A wrong prediction is harmless:
13
+ /// the firmware answers a SHOW that references an absent asset with MISSING,
14
+ /// and `forget(assetIds:)` makes the next frame re-upload exactly those.
15
+ ///
16
+ /// The model is valid for one cache generation: the owner bumps a generation
17
+ /// counter whenever the on-glass cache is lost (lease end, reset, failed send)
18
+ /// and the model re-learns from scratch.
11
19
  ///
12
20
  /// Cached image format: `[width:u8][height:u8][4bpp RLE]` where the RLE covers
13
21
  /// exactly width*height pixels with NO row-pad nibbles (unlike modes 3/6).
14
- /// Mode-14 fonts are a 96-entry little-endian u16 image-offset table for
15
- /// characters 32..127; a zero entry points at cache offset 0, so the first
16
- /// two bytes of the cache are left zero (width 0 ⇒ invalid image) and real
17
- /// allocations start past them.
22
+ /// A font asset is a 96-entry little-endian u32 table of glyph image offsets
23
+ /// (characters 32..127, relative to the asset; 0 = absent) followed by the
24
+ /// glyph images, uploaded whole.
18
25
  final class G2CfwTextureCache {
19
- static let cacheSize = 64 * 1024
26
+ static let storeSize = 256 * 1024
27
+ static let granule = 16
28
+ static let maxAssets = 256
20
29
  static let fontTableChars = 96 // ASCII 32..127
21
- /// Keep mode-12 payloads comfortably inside one BLE image message.
22
- static let maxUploadPayloadBytes = 3600
30
+ static let fontTableEntryBytes = 4
31
+ /// A PUT message is one transport message; keep them a few round trips
32
+ /// long rather than one 64 KiB stream that acks late.
33
+ static let maxUploadPayloadBytes = 8192
23
34
  /// 0x0F: identity ramp (top color 15), opaque — a full-cell glyph or a
24
35
  /// full-box image drawn opaque self-erases whatever was under it.
25
36
  static let opaqueDrawOptions: UInt8 = 0x0F
37
+ static let kindImage: UInt8 = 0
38
+ static let kindFont: UInt8 = 1
39
+ static let kindString: UInt8 = 2
26
40
 
27
41
  final class FontEntry {
28
- let tableOffset: Int
42
+ let id: UInt32
29
43
  let cellWidth: Int
30
44
  let lineHeight: Int
31
- /// Cache offset per printable char (index = char - 32), nil = not resident.
32
- var glyphOffsets = [Int?](repeating: nil, count: G2CfwTextureCache.fontTableChars)
45
+ /// Wire codes (32..127) whose glyphs the uploaded font carries.
46
+ let glyphs: Set<UInt8>
33
47
 
34
- init(tableOffset: Int, cellWidth: Int, lineHeight: Int) {
35
- self.tableOffset = tableOffset
48
+ init(id: UInt32, cellWidth: Int, lineHeight: Int, glyphs: Set<UInt8>) {
49
+ self.id = id
36
50
  self.cellWidth = cellWidth
37
51
  self.lineHeight = lineHeight
52
+ self.glyphs = glyphs
38
53
  }
39
54
  }
40
55
 
56
+ private struct Asset {
57
+ let id: UInt32
58
+ let version: UInt32
59
+ let bytes: Int // granule-rounded store footprint
60
+ var lastUse: Int
61
+ }
62
+
41
63
  private(set) var generation = -1
42
- private var fonts: [Int32: FontEntry] = [:]
43
- private var images: [String: Int] = [:]
64
+ /// Bytes the glasses could allocate for the store (the RESET reply says; 256 KiB when unknown).
65
+ var storeCapacity = G2CfwTextureCache.storeSize
66
+ private var fonts: [String: FontEntry] = [:]
67
+ /// Asset key → the resident (or queued) asset.
68
+ private var assets: [String: Asset] = [:]
69
+ private var byId: [UInt32: String] = [:]
44
70
  /// Content hashes seen in earlier frames. Streaming content (a minimap
45
71
  /// re-rendered every frame) never repeats a hash, so it never enters the
46
72
  /// cache; static icons promote on their second sighting.
47
73
  private var seenImageHashes: [String] = []
48
- /// Bump allocator. Bytes 0..1 stay zero as the invalid-image guard.
49
- private var nextOffset = 2
50
- /// Mode-12 entries not yet flushed into payloads.
51
- private var pendingUploads: [(offset: Int, data: Data)] = []
52
- /// The bump allocator filled up. The reference client recycles a full
53
- /// cache (`TextureCacheState.reset` "or we ran out of space"); without
54
- /// recovery every later ensure fails and the texture path silently dies
55
- /// for the rest of the lease. The renderer checks this before each frame
56
- /// and starts a fresh generation.
74
+ /// PUT_ASSET entries not yet flushed into payloads.
75
+ private var pendingEntries: [Data] = []
76
+ private var useClock = 0
77
+ /// Predicted store occupancy of everything this mirror believes resident.
78
+ private(set) var predictedBytes = 0
79
+ /// The prediction overflowed the store and the mirror dropped its oldest
80
+ /// assets; advisory for the renderer.
57
81
  private(set) var exhausted = false
58
82
 
59
83
  /// Forget everything and adopt `generation` (the on-glass cache was freed
@@ -61,105 +85,202 @@ final class G2CfwTextureCache {
61
85
  func reset(generation: Int) {
62
86
  self.generation = generation
63
87
  fonts.removeAll()
64
- images.removeAll()
88
+ assets.removeAll()
89
+ byId.removeAll()
65
90
  seenImageHashes.removeAll()
66
- nextOffset = 2
67
- pendingUploads.removeAll()
91
+ pendingEntries.removeAll()
92
+ predictedBytes = 0
93
+ useClock = 0
68
94
  exhausted = false
69
95
  }
70
96
 
71
- private func allocate(_ length: Int) -> Int? {
72
- guard length > 0, nextOffset + length <= Self.cacheSize else {
73
- if length > 0 { exhausted = true }
97
+ /// The glasses reported these asset ids absent: upload them again on next use.
98
+ func forget(assetIds: [UInt32]) {
99
+ for id in assetIds {
100
+ guard let key = byId.removeValue(forKey: id), let asset = assets.removeValue(forKey: key) else { continue }
101
+ predictedBytes -= asset.bytes
102
+ fonts = fonts.filter { $0.value.id != id }
103
+ }
104
+ }
105
+
106
+ /// True when `id` names an asset this mirror believes resident.
107
+ func holds(assetId id: UInt32) -> Bool { byId[id] != nil }
108
+
109
+ /// Nonzero 31-bit FNV-1a of a key: asset ids and content versions.
110
+ static func hash31(_ s: String) -> UInt32 {
111
+ hash31(Data(s.utf8))
112
+ }
113
+
114
+ static func hash31(_ bytes: Data) -> UInt32 {
115
+ var h: UInt32 = 0x811C_9DC5
116
+ for byte in bytes {
117
+ h ^= UInt32(byte)
118
+ h = h &* 0x0100_0193
119
+ }
120
+ h &= 0x7FFF_FFFF
121
+ return h == 0 ? 1 : h
122
+ }
123
+
124
+ private static func footprint(_ length: Int) -> Int {
125
+ (length + granule - 1) / granule * granule
126
+ }
127
+
128
+ /// Register `data` under `key` as an asset of `kind`, queueing its upload.
129
+ /// Nil when it can never fit the store or its id is taken by another key.
130
+ private func ensureAsset(key: String, kind: UInt8, data: Data) -> UInt32? {
131
+ useClock += 1
132
+ if var asset = assets[key] {
133
+ asset.lastUse = useClock
134
+ assets[key] = asset
135
+ return asset.id
136
+ }
137
+ guard !data.isEmpty, data.count <= storeCapacity else { return nil }
138
+ let id = Self.hash31(key)
139
+ if let other = byId[id], other != key {
74
140
  return nil
75
141
  }
76
- defer { nextOffset += length }
77
- return nextOffset
142
+ let footprint = Self.footprint(data.count)
143
+ makeRoom(for: footprint)
144
+ let asset = Asset(id: id, version: Self.hash31(data), bytes: footprint, lastUse: useClock)
145
+ assets[key] = asset
146
+ byId[id] = key
147
+ predictedBytes += footprint
148
+ pendingEntries.append(contentsOf: Self.putAssetEntries(id: id, version: asset.version, kind: kind, data: data))
149
+ return id
78
150
  }
79
151
 
80
- /// The font table for `fontSize`, allocating (and zero-filling on-glass by
81
- /// virtue of the fresh cache) the 192-byte offset table on first use.
82
- func font(fontSize: Int32, cellWidth: Int, lineHeight: Int) -> FontEntry? {
83
- if let existing = fonts[fontSize] { return existing }
84
- guard let tableOffset = allocate(Self.fontTableChars * 2) else { return nil }
85
- let entry = FontEntry(tableOffset: tableOffset, cellWidth: cellWidth, lineHeight: lineHeight)
86
- fonts[fontSize] = entry
87
- return entry
152
+ /// Drop the mirror's least recently used assets until `footprint` fits the
153
+ /// predicted store: the glasses evict the same way, so believing an asset
154
+ /// gone is safer than believing it resident.
155
+ private func makeRoom(for footprint: Int) {
156
+ while predictedBytes + footprint > storeCapacity || assets.count >= Self.maxAssets {
157
+ guard let victim = assets.min(by: { $0.value.lastUse < $1.value.lastUse }) else { break }
158
+ exhausted = true
159
+ assets.removeValue(forKey: victim.key)
160
+ byId.removeValue(forKey: victim.value.id)
161
+ fonts = fonts.filter { $0.value.id != victim.value.id }
162
+ predictedBytes -= victim.value.bytes
163
+ }
88
164
  }
89
165
 
90
- /// Make `char` (ASCII 32..126) resident for `font`, uploading its RLE
91
- /// raster and patching the font table. `rasterize` supplies the full-cell
92
- /// 8-bit grayscale raster (cellWidth × lineHeight) on first upload.
93
- /// Returns false when the cache is full or the raster is unavailable.
94
- func ensureGlyph(font: FontEntry, char: UInt8, rasterize: () -> Data?) -> Bool {
95
- guard char >= 32, char <= 126 else { return false }
96
- let index = Int(char) - 32
97
- if font.glyphOffsets[index] != nil { return true }
98
- guard let gray = rasterize(), gray.count == font.cellWidth * font.lineHeight else { return false }
99
- var image = Data([UInt8(font.cellWidth), UInt8(font.lineHeight)])
100
- image.append(Self.rleEncodePixels(gray))
101
- guard let offset = allocate(image.count) else { return false }
102
- font.glyphOffsets[index] = offset
103
- pendingUploads.append((offset: offset, data: image))
104
- pendingUploads.append((
105
- offset: font.tableOffset + index * 2,
106
- data: Data([UInt8(offset & 0xFF), UInt8((offset >> 8) & 0xFF)])
107
- ))
108
- return true
166
+ /// The font asset for `fontSize`, rasterizing every glyph the variant needs
167
+ /// and uploading the whole font on first use. `rasterize` supplies the
168
+ /// full-cell 8-bit grayscale raster (cellWidth × lineHeight) for a wire
169
+ /// code; a code it cannot raster is left absent from the font.
170
+ func font(
171
+ fontSize: Int32, cellWidth: Int, lineHeight: Int, variant: String = "ascii",
172
+ codes: [UInt8] = Array(32 ... 126), rasterize: (UInt8) -> Data?
173
+ ) -> FontEntry? {
174
+ let key = "font:\(fontSize):\(cellWidth)x\(lineHeight):\(variant)"
175
+ if let existing = fonts[key], assets[key] != nil {
176
+ useClock += 1
177
+ assets[key]?.lastUse = useClock
178
+ return existing
179
+ }
180
+ var table = Data(count: Self.fontTableChars * Self.fontTableEntryBytes)
181
+ var body = Data()
182
+ var glyphs = Set<UInt8>()
183
+ for code in codes where code >= 32 && code <= 127 {
184
+ guard let gray = rasterize(code), gray.count == cellWidth * lineHeight else { continue }
185
+ let offset = table.count + body.count
186
+ let index = (Int(code) - 32) * Self.fontTableEntryBytes
187
+ table[index] = UInt8(offset & 0xFF)
188
+ table[index + 1] = UInt8((offset >> 8) & 0xFF)
189
+ table[index + 2] = UInt8((offset >> 16) & 0xFF)
190
+ table[index + 3] = UInt8((offset >> 24) & 0xFF)
191
+ body.append(UInt8(cellWidth)); body.append(UInt8(lineHeight))
192
+ body.append(Self.rleEncodePixels(gray))
193
+ glyphs.insert(code)
194
+ }
195
+ guard !glyphs.isEmpty, let id = ensureAsset(key: key, kind: Self.kindFont, data: table + body) else { return nil }
196
+ let entry = FontEntry(id: id, cellWidth: cellWidth, lineHeight: lineHeight, glyphs: glyphs)
197
+ fonts[key] = entry
198
+ return entry
109
199
  }
110
200
 
111
201
  /// Record a sighting of an image content hash; true when it was already
112
202
  /// seen in an earlier frame (worth caching).
113
203
  func noteImageSighting(_ hash: String) -> Bool {
114
- if images[hash] != nil { return true }
204
+ if assets[hash] != nil { return true }
115
205
  if seenImageHashes.contains(hash) { return true }
116
206
  seenImageHashes.append(hash)
117
207
  if seenImageHashes.count > 32 { seenImageHashes.removeFirst() }
118
208
  return false
119
209
  }
120
210
 
121
- /// The cache offset of an image, uploading it on first residency.
211
+ /// The asset id of an image, uploading it on first residency.
122
212
  /// `gray` is the width×height 8-bit grayscale raster.
123
- func ensureImage(hash: String, gray: Data, width: Int, height: Int) -> Int? {
124
- if let offset = images[hash] { return offset }
125
- guard width > 0, width <= 255, height > 0, height <= 255,
126
- gray.count == width * height
127
- else { return nil }
213
+ func ensureImage(hash: String, gray: Data, width: Int, height: Int) -> UInt32? {
214
+ if let asset = assets[hash] { useClock += 1; assets[hash]?.lastUse = useClock; return asset.id }
215
+ guard width > 0, width <= 255, height > 0, height <= 255, gray.count == width * height else { return nil }
128
216
  var image = Data([UInt8(width), UInt8(height)])
129
217
  image.append(Self.rleEncodePixels(gray))
130
- guard let offset = allocate(image.count) else { return nil }
131
- images[hash] = offset
132
- pendingUploads.append((offset: offset, data: image))
133
- return offset
218
+ return ensureAsset(key: hash, kind: Self.kindImage, data: image)
134
219
  }
135
220
 
136
- /// Drain the pending mode-12 entries into `[12][offset16][len16][data]...`
137
- /// payloads, each at most `maxUploadPayloadBytes`. An entry larger than
138
- /// the budget gets a payload of its own (the BLE fragmenter splits it).
221
+ /// The asset id of a string (1..128 bytes of UTF-8, no NUL), uploading it on first use.
222
+ func ensureBytes(key: String, bytes: [UInt8]) -> UInt32? {
223
+ guard !bytes.isEmpty, bytes.count <= 128, !bytes.contains(0) else { return nil }
224
+ return ensureAsset(key: key, kind: Self.kindString, data: Data(bytes))
225
+ }
226
+
227
+ /// Drain the pending PUT_ASSET entries into `[37][epoch 0][request 0][entries]`
228
+ /// payloads, each at most `maxUploadPayloadBytes` (the link stamps the epoch
229
+ /// and request). An asset larger than the budget is split into chunks.
139
230
  func takeUploadPayloads() -> [Data] {
140
- guard !pendingUploads.isEmpty else { return [] }
231
+ guard !pendingEntries.isEmpty else { return [] }
141
232
  var payloads: [Data] = []
142
- var current = Data([12])
143
- for entry in pendingUploads {
144
- var record = Data()
145
- record.append(UInt8(entry.offset & 0xFF))
146
- record.append(UInt8((entry.offset >> 8) & 0xFF))
147
- record.append(UInt8(entry.data.count & 0xFF))
148
- record.append(UInt8((entry.data.count >> 8) & 0xFF))
149
- record.append(entry.data)
150
- if current.count > 1, current.count + record.count > Self.maxUploadPayloadBytes {
233
+ var current = Data([37, 0, 0, 0, 0])
234
+ for entry in pendingEntries {
235
+ if current.count > 5, current.count + entry.count > Self.maxUploadPayloadBytes {
151
236
  payloads.append(current)
152
- current = Data([12])
237
+ current = Data([37, 0, 0, 0, 0])
153
238
  }
154
- current.append(record)
239
+ current.append(entry)
155
240
  }
156
- if current.count > 1 { payloads.append(current) }
157
- pendingUploads.removeAll()
241
+ if current.count > 5 { payloads.append(current) }
242
+ pendingEntries.removeAll()
158
243
  return payloads
159
244
  }
160
245
 
246
+ /// Asset ids a `takeUploadPayloads` payload puts, in order.
247
+ static func assetIds(inUploadPayload payload: Data) -> [UInt32] {
248
+ let b = [UInt8](payload)
249
+ guard b.count > 5, b[0] == 37 else { return [] }
250
+ var ids: [UInt32] = []
251
+ var i = 5
252
+ while i + 20 <= b.count, b[i] == 0 {
253
+ let id = UInt32(b[i + 1]) | UInt32(b[i + 2]) << 8 | UInt32(b[i + 3]) << 16 | UInt32(b[i + 4]) << 24
254
+ if ids.last != id { ids.append(id) }
255
+ i += 20 + (Int(b[i + 18]) | Int(b[i + 19]) << 8)
256
+ }
257
+ return ids
258
+ }
259
+
161
260
  // MARK: - Wire encoders
162
261
 
262
+ /// PUT_ASSET entries for one asset: `[0][id:u32][version:u32][kind][total:u32][offset:u32][len:u16][data]`
263
+ /// per chunk, chunks in order.
264
+ static func putAssetEntries(id: UInt32, version: UInt32, kind: UInt8, data: Data) -> [Data] {
265
+ var out: [Data] = []
266
+ var offset = 0
267
+ let chunk = maxUploadPayloadBytes - 32
268
+ repeat {
269
+ let len = min(chunk, data.count - offset)
270
+ var entry = Data([0])
271
+ appendU32(Int(id), to: &entry)
272
+ appendU32(Int(version), to: &entry)
273
+ entry.append(kind)
274
+ appendU32(data.count, to: &entry)
275
+ appendU32(offset, to: &entry)
276
+ entry.append(UInt8(len & 0xFF)); entry.append(UInt8(len >> 8))
277
+ entry.append(data.subdata(in: (data.startIndex + offset) ..< (data.startIndex + offset + len)))
278
+ out.append(entry)
279
+ offset += len
280
+ } while offset < data.count
281
+ return out
282
+ }
283
+
163
284
  /// RLE over a flat 8-bit grayscale pixel sequence (quantized to 4bpp),
164
285
  /// covering exactly the pixel count — no row padding, per the cached-image
165
286
  /// contract. Token forms match modes 3/6.
@@ -195,36 +316,42 @@ final class G2CfwTextureCache {
195
316
  return output
196
317
  }
197
318
 
198
- /// Mode-13 cached-image draw: `[13][offset:u16][x:u16][y:u16][options]`.
199
- static func imageDrawPayload(offset: Int, x: Int, y: Int, options: UInt8) -> Data? {
200
- guard offset >= 0, offset <= 0xFFFF, x >= 0, x <= 0xFFFF, y >= 0, y <= 0xFFFF
201
- else { return nil }
202
- var payload = Data([13])
203
- for value in [offset, x, y] {
204
- payload.append(UInt8(value & 0xFF))
205
- payload.append(UInt8((value >> 8) & 0xFF))
206
- }
319
+ static func appendU32(_ value: Int, to payload: inout Data) {
320
+ payload.append(UInt8(value & 0xFF))
321
+ payload.append(UInt8((value >> 8) & 0xFF))
322
+ payload.append(UInt8((value >> 16) & 0xFF))
323
+ payload.append(UInt8((value >> 24) & 0xFF))
324
+ }
325
+
326
+ static func appendI16(_ value: Int, to payload: inout Data) {
327
+ let bits = UInt16(bitPattern: Int16(clamping: value))
328
+ payload.append(UInt8(bits & 0xFF))
329
+ payload.append(UInt8(bits >> 8))
330
+ }
331
+
332
+ /// Mode-19 image draw: `[19][asset id:u32][x:i16][y:i16][options]`.
333
+ static func imageDrawPayload(assetId: UInt32, x: Int, y: Int, options: UInt8) -> Data? {
334
+ guard assetId != 0, x >= -32768, x <= 32767, y >= -32768, y <= 32767 else { return nil }
335
+ var payload = Data([19])
336
+ appendU32(Int(assetId), to: &payload)
337
+ appendI16(x, to: &payload)
338
+ appendI16(y, to: &payload)
207
339
  payload.append(options)
208
340
  return payload
209
341
  }
210
342
 
211
- /// Mode-14 cached-glyph run:
212
- /// `[14][font-offset:u16][x:u16][y:u16][options][strlen:u8][chars]`.
213
- /// Every char must be 32..126 and resident (the firmware validates each
214
- /// referenced glyph and rejects the whole draw otherwise).
215
- static func stringRunPayload(
216
- fontTableOffset: Int, x: Int, y: Int, options: UInt8, chars: [UInt8]
217
- ) -> Data? {
218
- guard !chars.isEmpty, chars.count <= 255,
219
- fontTableOffset >= 0, fontTableOffset <= cacheSize - fontTableChars * 2,
220
- x >= 0, x <= 0xFFFF, y >= 0, y <= 0xFFFF,
343
+ /// Mode-20 glyph run: `[20][font asset id:u32][x:i16][y:i16][options][strlen:u8][chars]`.
344
+ /// Every char must be 32..126 and present in the font (the firmware
345
+ /// validates each referenced glyph and rejects the whole draw otherwise).
346
+ static func stringRunPayload(fontId: UInt32, x: Int, y: Int, options: UInt8, chars: [UInt8]) -> Data? {
347
+ guard !chars.isEmpty, chars.count <= 255, fontId != 0,
348
+ x >= -32768, x <= 32767, y >= -32768, y <= 32767,
221
349
  chars.allSatisfy({ $0 >= 32 && $0 <= 126 })
222
350
  else { return nil }
223
- var payload = Data([14])
224
- for value in [fontTableOffset, x, y] {
225
- payload.append(UInt8(value & 0xFF))
226
- payload.append(UInt8((value >> 8) & 0xFF))
227
- }
351
+ var payload = Data([20])
352
+ appendU32(Int(fontId), to: &payload)
353
+ appendI16(x, to: &payload)
354
+ appendI16(y, to: &payload)
228
355
  payload.append(options)
229
356
  payload.append(UInt8(chars.count))
230
357
  payload.append(contentsOf: chars)