@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
@@ -19,40 +19,50 @@ final class G2CfwTextureCacheTests: XCTestCase {
19
19
  )
20
20
  }
21
21
 
22
- // MARK: - Cache model
22
+ private func u32(_ v: UInt32) -> [UInt8] {
23
+ [UInt8(v & 0xFF), UInt8((v >> 8) & 0xFF), UInt8((v >> 16) & 0xFF), UInt8((v >> 24) & 0xFF)]
24
+ }
25
+
26
+ // MARK: - Asset mirror
23
27
 
24
- func testAllocationsSkipTheZeroGuardAndPatchTheFontTable() throws {
28
+ func testFontsUploadWholeAsOneAssetWithRelativeGlyphOffsets() throws {
25
29
  let cache = G2CfwTextureCache()
26
30
  cache.reset(generation: 1)
27
- let font = try XCTUnwrap(cache.font(fontSize: 12, cellWidth: 4, lineHeight: 2))
28
- // Bytes 0..1 stay zero (invalid-image guard); the table lands at 2.
29
- XCTAssertEqual(font.tableOffset, 2)
30
-
31
31
  let raster = Data(repeating: 0xFF, count: 8) // 4×2 white cell
32
- XCTAssertTrue(cache.ensureGlyph(font: font, char: UInt8(ascii: "A")) { raster })
33
- // Second ensure is a no-op (no duplicate uploads).
34
- XCTAssertTrue(cache.ensureGlyph(font: font, char: UInt8(ascii: "A")) { raster })
32
+ var rasterized = 0
33
+ let font = try XCTUnwrap(cache.font(fontSize: 12, cellWidth: 4, lineHeight: 2, codes: [65, 66]) { code in
34
+ rasterized += 1
35
+ return code == 65 ? raster : nil
36
+ })
37
+ XCTAssertEqual(rasterized, 2)
38
+ XCTAssertEqual(font.glyphs, [65])
39
+ XCTAssertEqual(font.id, G2CfwTextureCache.hash31("font:12:4x2:ascii"))
40
+ // asking again is a no-op: same entry, nothing rasterized, nothing uploaded twice
41
+ XCTAssertTrue(cache.font(fontSize: 12, cellWidth: 4, lineHeight: 2, codes: [65, 66]) { _ in rasterized += 1; return nil } === font)
42
+ XCTAssertEqual(rasterized, 2)
35
43
 
36
44
  let payloads = cache.takeUploadPayloads()
37
45
  XCTAssertEqual(payloads.count, 1)
38
- let payload = payloads[0]
39
- // [12] + glyph entry: image right after the 192-byte table (offset
40
- // 194), [offset16][len16][w=4][h=2][RLE 8 white pixels = 0x8F] —
41
- // + table-patch entry for slot ('A' - 32) pointing at that offset.
42
- let glyphOffset = 2 + 96 * 2 // 194
43
- let patchOffset = 2 + (Int(UInt8(ascii: "A")) - 32) * 2
44
- XCTAssertEqual(Array(payload), [
45
- 12,
46
- UInt8(glyphOffset & 0xFF), UInt8(glyphOffset >> 8), 3, 0, 4, 2, 0x8F,
47
- UInt8(patchOffset & 0xFF), UInt8(patchOffset >> 8), 2, 0,
48
- UInt8(glyphOffset & 0xFF), UInt8(glyphOffset >> 8),
49
- ])
50
-
46
+ let p = [UInt8](payloads[0])
47
+ XCTAssertEqual(Array(p[0 ..< 5]), [37, 0, 0, 0, 0], "the link stamps epoch and request")
48
+ XCTAssertEqual(p[5], 0)
49
+ XCTAssertEqual(Array(p[6 ..< 10]), u32(font.id))
50
+ XCTAssertEqual(p[14], G2CfwTextureCache.kindFont)
51
+ let total = 96 * 4 + 2 + 1
52
+ XCTAssertEqual(Array(p[15 ..< 19]), u32(UInt32(total)))
53
+ XCTAssertEqual(Array(p[19 ..< 23]), u32(0))
54
+ XCTAssertEqual(Array(p[23 ..< 25]), [UInt8(total & 0xFF), UInt8(total >> 8)])
55
+ let table = Array(p[25 ..< 25 + 96 * 4])
56
+ XCTAssertEqual(Array(table[(65 - 32) * 4 ..< (65 - 32) * 4 + 4]), u32(UInt32(96 * 4)), "glyph A right after the table")
57
+ XCTAssertEqual(Array(table[(66 - 32) * 4 ..< (66 - 32) * 4 + 4]), [0, 0, 0, 0], "absent glyph")
58
+ XCTAssertEqual(Array(p[25 + 96 * 4 ..< 25 + total]), [4, 2, 0x8F])
51
59
  XCTAssertTrue(cache.takeUploadPayloads().isEmpty)
60
+ // a forgotten font re-uploads
61
+ cache.forget(assetIds: [font.id])
62
+ _ = cache.font(fontSize: 12, cellWidth: 4, lineHeight: 2, codes: [65]) { _ in raster }
63
+ XCTAssertEqual(cache.takeUploadPayloads().count, 1)
52
64
  cache.reset(generation: 2)
53
- // After a reset the same glyph re-uploads.
54
- let font2 = try XCTUnwrap(cache.font(fontSize: 12, cellWidth: 4, lineHeight: 2))
55
- XCTAssertTrue(cache.ensureGlyph(font: font2, char: UInt8(ascii: "A")) { raster })
65
+ _ = cache.font(fontSize: 12, cellWidth: 4, lineHeight: 2, codes: [65]) { _ in raster }
56
66
  XCTAssertEqual(cache.takeUploadPayloads().count, 1)
57
67
  }
58
68
 
@@ -62,34 +72,88 @@ final class G2CfwTextureCacheTests: XCTestCase {
62
72
  XCTAssertFalse(cache.noteImageSighting("icon@10x10"))
63
73
  XCTAssertTrue(cache.noteImageSighting("icon@10x10"))
64
74
  let gray = Data(repeating: 0x80, count: 100)
65
- let offset = cache.ensureImage(hash: "icon@10x10", gray: gray, width: 10, height: 10)
66
- XCTAssertNotNil(offset)
67
- XCTAssertEqual(cache.ensureImage(hash: "icon@10x10", gray: gray, width: 10, height: 10), offset)
75
+ let id = cache.ensureImage(hash: "icon@10x10", gray: gray, width: 10, height: 10)
76
+ XCTAssertEqual(id, G2CfwTextureCache.hash31("icon@10x10"))
77
+ XCTAssertEqual(cache.ensureImage(hash: "icon@10x10", gray: gray, width: 10, height: 10), id)
68
78
  XCTAssertNil(cache.ensureImage(hash: "big", gray: Data(count: 300 * 10), width: 300, height: 10))
79
+ XCTAssertEqual(cache.takeUploadPayloads().count, 1)
80
+ XCTAssertTrue(cache.holds(assetId: id!))
81
+ }
82
+
83
+ func testStringsAreBoundedAndLargeAssetsAreChunked() {
84
+ let cache = G2CfwTextureCache()
85
+ cache.reset(generation: 1)
86
+ XCTAssertNil(cache.ensureBytes(key: "empty", bytes: []))
87
+ XCTAssertNil(cache.ensureBytes(key: "nul", bytes: [65, 0]))
88
+ XCTAssertNil(cache.ensureBytes(key: "long", bytes: [UInt8](repeating: 65, count: 129)))
89
+ XCTAssertNotNil(cache.ensureBytes(key: "ok", bytes: [UInt8](repeating: 65, count: 128)))
90
+ let entries = G2CfwTextureCache.putAssetEntries(id: 7, version: 9, kind: 0, data: Data(repeating: 1, count: 20000))
91
+ XCTAssertEqual(entries.count, 3)
92
+ var offset = 0
93
+ for entry in entries {
94
+ let e = [UInt8](entry)
95
+ XCTAssertEqual(Array(e[10 ..< 14]), u32(20000))
96
+ XCTAssertEqual(Array(e[14 ..< 18]), u32(UInt32(offset)))
97
+ let len = Int(e[18]) | Int(e[19]) << 8
98
+ XCTAssertEqual(e.count, 20 + len)
99
+ offset += len
100
+ }
101
+ XCTAssertEqual(offset, 20000)
102
+ }
103
+
104
+ func testUploadPayloadsNameTheAssetsTheyPut() throws {
105
+ let cache = G2CfwTextureCache()
106
+ cache.reset(generation: 1)
107
+ var noise = Data(count: 255 * 255)
108
+ for i in 0 ..< noise.count { noise[i] = UInt8(truncatingIfNeeded: i * 37) }
109
+ let a = try XCTUnwrap(cache.ensureBytes(key: "a", bytes: [65, 66]))
110
+ let big = try XCTUnwrap(cache.ensureImage(hash: "noise", gray: noise, width: 255, height: 255))
111
+ let b = try XCTUnwrap(cache.ensureBytes(key: "b", bytes: [67]))
112
+ let payloads = cache.takeUploadPayloads()
113
+ XCTAssertGreaterThan(payloads.count, 1, "the image spans several payloads")
114
+ let ids = payloads.map(G2CfwTextureCache.assetIds(inUploadPayload:))
115
+ XCTAssertEqual(Set(ids.joined()), [a, big, b])
116
+ XCTAssertEqual(ids.first?.first, a)
117
+ XCTAssertEqual(ids.last?.last, b)
118
+ XCTAssertEqual(G2CfwTextureCache.assetIds(inUploadPayload: Data([38, 0, 0, 0, 0, 0])), [])
119
+ }
120
+
121
+ func testPredictedOverflowForgetsTheLeastRecentlyUsedAssets() {
122
+ let cache = G2CfwTextureCache()
123
+ cache.reset(generation: 1)
124
+ // 255x255 noisy tiles compress poorly: ~65 KiB each, four fill the store
125
+ var noise = Data(count: 255 * 255)
126
+ for i in 0 ..< noise.count { noise[i] = UInt8(truncatingIfNeeded: i * 37) }
127
+ var ids: [UInt32] = []
128
+ for i in 0 ..< 5 {
129
+ ids.append(cache.ensureImage(hash: "n\(i)", gray: noise, width: 255, height: 255)!)
130
+ }
131
+ XCTAssertTrue(cache.exhausted)
132
+ XCTAssertFalse(cache.holds(assetId: ids[0]), "the oldest prediction was dropped")
133
+ XCTAssertTrue(cache.holds(assetId: ids[4]))
134
+ XCTAssertLessThanOrEqual(cache.predictedBytes, G2CfwTextureCache.storeSize)
69
135
  }
70
136
 
71
137
  // MARK: - Draw payload wire formats
72
138
 
73
- func testImageDrawPayloadMatchesMode13Wire() throws {
139
+ func testImageDrawPayloadMatchesMode19Wire() throws {
74
140
  let payload = try XCTUnwrap(
75
- G2CfwTextureCache.imageDrawPayload(offset: 0x1234, x: 300, y: 130, options: 0x0F)
141
+ G2CfwTextureCache.imageDrawPayload(assetId: 0x1234, x: 300, y: -2, options: 0x0F)
76
142
  )
77
- XCTAssertEqual(Array(payload), [13, 0x34, 0x12, 0x2C, 0x01, 130, 0, 0x0F])
143
+ XCTAssertEqual(Array(payload), [19, 0x34, 0x12, 0, 0, 0x2C, 0x01, 0xFE, 0xFF, 0x0F])
144
+ XCTAssertNil(G2CfwTextureCache.imageDrawPayload(assetId: 0, x: 0, y: 0, options: 0))
78
145
  }
79
146
 
80
- func testStringRunPayloadMatchesMode14Wire() throws {
147
+ func testStringRunPayloadMatchesMode20Wire() throws {
81
148
  let chars = Array("Hi".utf8)
82
149
  let payload = try XCTUnwrap(G2CfwTextureCache.stringRunPayload(
83
- fontTableOffset: 2, x: 40, y: 150, options: 0x0F, chars: chars
150
+ fontId: 2, x: 40, y: 150, options: 0x0F, chars: chars
84
151
  ))
85
- XCTAssertEqual(Array(payload), [14, 2, 0, 40, 0, 150, 0, 0x0F, 2] + chars)
152
+ XCTAssertEqual(Array(payload), [20, 2, 0, 0, 0, 40, 0, 150, 0, 0x0F, 2] + chars)
86
153
  // Control/non-ASCII bytes are x-adjust commands on the wire — rejected.
87
- XCTAssertNil(G2CfwTextureCache.stringRunPayload(
88
- fontTableOffset: 2, x: 0, y: 0, options: 0, chars: [10]
89
- ))
90
- XCTAssertNil(G2CfwTextureCache.stringRunPayload(
91
- fontTableOffset: 2, x: 0, y: 0, options: 0, chars: [200]
92
- ))
154
+ XCTAssertNil(G2CfwTextureCache.stringRunPayload(fontId: 2, x: 0, y: 0, options: 0, chars: [10]))
155
+ XCTAssertNil(G2CfwTextureCache.stringRunPayload(fontId: 2, x: 0, y: 0, options: 0, chars: [200]))
156
+ XCTAssertNil(G2CfwTextureCache.stringRunPayload(fontId: 0, x: 0, y: 0, options: 0, chars: chars))
93
157
  }
94
158
 
95
159
  // MARK: - End-to-end mono fast path (actor)
@@ -126,10 +190,10 @@ final class G2CfwTextureCacheTests: XCTestCase {
126
190
  )
127
191
  let second = try XCTUnwrap(secondResult)
128
192
  XCTAssertFalse(second.usedFullFrame)
129
- // The changed cells travel as mode-12 uploads + mode-14 runs; no
193
+ // The changed cells travel as a font-asset PUT + mode-20 runs; no
130
194
  // bitmap delta (0x83) should remain for a pure-ASCII grid change.
131
195
  XCTAssertFalse(second.uploads.isEmpty)
132
- XCTAssertTrue(second.uploads.allSatisfy { $0.first == 12 })
196
+ XCTAssertTrue(second.uploads.allSatisfy { $0.first == 37 })
133
197
  let payload = try XCTUnwrap(second.payload)
134
198
  if payload.first == 8 {
135
199
  var segments: [UInt8] = []
@@ -141,10 +205,10 @@ final class G2CfwTextureCacheTests: XCTestCase {
141
205
  if pos < bytes.count { segments.append(bytes[pos]) }
142
206
  pos += len
143
207
  }
144
- XCTAssertTrue(segments.contains(14))
208
+ XCTAssertTrue(segments.contains(20))
145
209
  XCTAssertFalse(segments.contains(0x83))
146
210
  } else {
147
- XCTAssertEqual(payload.first, 14)
211
+ XCTAssertEqual(payload.first, 20)
148
212
  }
149
213
 
150
214
  // A lens-unequal window must fall back to plain bitmap deltas.
@@ -0,0 +1,259 @@
1
+ @testable import BluetoothSDK
2
+ import Foundation
3
+ import XCTest
4
+ import zlib
5
+
6
+ /// Wire contract of the SID-0xf0 message stream (g2flash message_transport.c,
7
+ /// GLASSLYCFW/31). The probe in g2flash (`send_message_probe.py --dry-run`)
8
+ /// produces the same packets for the same input.
9
+ final class G2CfwTransportTests: XCTestCase {
10
+ private func inflateRecords(_ records: [Data]) throws -> [Data] {
11
+ var stream = z_stream()
12
+ XCTAssertEqual(inflateInit_(&stream, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size)), Z_OK)
13
+ defer { inflateEnd(&stream) }
14
+ var out: [Data] = []
15
+ for record in records {
16
+ let flags = record[0]
17
+ let length = Int(record[1]) | Int(record[2]) << 8
18
+ XCTAssertEqual(length, record.count - 5)
19
+ let body = record.subdata(in: 5 ..< record.count)
20
+ guard flags & G2CfwTransport.flagCompressed != 0 else {
21
+ out.append(body)
22
+ continue
23
+ }
24
+ // Every compressed record ends in a complete SYNC_FLUSH marker.
25
+ XCTAssertEqual(Array(body.suffix(4)), [0x00, 0x00, 0xFF, 0xFF])
26
+ // As the firmware does: a RESET_CONTEXT record starts a fresh inflate stream.
27
+ if flags & G2CfwTransport.flagResetContext != 0 { XCTAssertEqual(inflateReset(&stream), Z_OK) }
28
+ var decoded = Data(count: 0x10000)
29
+
30
+ var input = body
31
+ let produced: Int = input.withUnsafeMutableBytes { inRaw in
32
+ decoded.withUnsafeMutableBytes { outRaw in
33
+ stream.next_in = inRaw.bindMemory(to: Bytef.self).baseAddress
34
+ stream.avail_in = uInt(body.count)
35
+ stream.next_out = outRaw.bindMemory(to: Bytef.self).baseAddress
36
+ stream.avail_out = 0x10000
37
+ XCTAssertEqual(inflate(&stream, Z_NO_FLUSH), Z_OK)
38
+ XCTAssertEqual(stream.avail_in, 0)
39
+ return 0x10000 - Int(stream.avail_out)
40
+ }
41
+ }
42
+ out.append(decoded.prefix(produced))
43
+ }
44
+ return out
45
+ }
46
+
47
+ func testRecordsShareOneContextAndCarryThePlainCrc() throws {
48
+ let encoder = G2CfwTransport.Encoder()
49
+ let first = Data([6] + [UInt8](repeating: 0x00, count: 5000))
50
+ let second = Data([3, 1, 2, 3, 4, 5, 0] + [UInt8](repeating: 0x1F, count: 300))
51
+ let r1 = try XCTUnwrap(encoder.record(first, targets: 3))
52
+ let r2 = try XCTUnwrap(encoder.record(second, targets: 3))
53
+ // First record resets the context (flag 8); the second continues it.
54
+ XCTAssertEqual(r1[0], 3 | 4 | 8)
55
+ XCTAssertEqual(r2[0], 3 | 4)
56
+ XCTAssertEqual(UInt16(r1[3]) | UInt16(r1[4]) << 8, BinaryCodec.crc16CCITT(first))
57
+ XCTAssertEqual(UInt16(r2[3]) | UInt16(r2[4]) << 8, BinaryCodec.crc16CCITT(second))
58
+ XCTAssertLessThan(r1.count, 100) // 5000 zero bytes deflate to a few
59
+ XCTAssertEqual(try inflateRecords([r1, r2]), [first, second])
60
+ // After a NACK/timeout the context restarts and the next record says so.
61
+ encoder.resetContext()
62
+ let r3 = try XCTUnwrap(encoder.record(second, targets: 1))
63
+ XCTAssertEqual(r3[0], 1 | 4 | 8)
64
+ XCTAssertEqual(try inflateRecords([r3]), [second])
65
+ XCTAssertNil(encoder.record(Data(count: 0x10000), targets: 3))
66
+ XCTAssertNil(encoder.record(Data([7, 255]), targets: 4))
67
+ }
68
+
69
+ func testLensTargetedRecordsAreSelfContainedSoTheSkippingLensStaysInSync() throws {
70
+ let encoder = G2CfwTransport.Encoder()
71
+ let shared = Data([38, 0, 0, 0, 0, 1, 0] + [UInt8](repeating: 0x2A, count: 400))
72
+ let leftOnly = Data([38, 0, 0, 1, 0, 1, 0] + [UInt8](repeating: 0x2B, count: 400))
73
+ let rightOnly = Data([38, 0, 0, 2, 0, 1, 0] + [UInt8](repeating: 0x2C, count: 400))
74
+ let r1 = try XCTUnwrap(encoder.record(shared, targets: 3))
75
+ let r2 = try XCTUnwrap(encoder.record(leftOnly, targets: 1))
76
+ let r3 = try XCTUnwrap(encoder.record(rightOnly, targets: 2))
77
+ let r4 = try XCTUnwrap(encoder.record(shared, targets: 3))
78
+ let r5 = try XCTUnwrap(encoder.record(shared, targets: 3))
79
+ XCTAssertEqual(r2[0], 1 | 4 | 8, "a left-only record starts a fresh context")
80
+ XCTAssertEqual(r3[0], 2 | 4 | 8)
81
+ XCTAssertEqual(r4[0], 3 | 4 | 8, "and the record after it starts fresh again")
82
+ XCTAssertEqual(r5[0], 3 | 4, "then the shared context resumes")
83
+ // Each lens inflates exactly the records addressed to it.
84
+ XCTAssertEqual(try inflateRecords([r1, r2, r4, r5]), [shared, leftOnly, shared, shared])
85
+ XCTAssertEqual(try inflateRecords([r1, r3, r4, r5]), [shared, rightOnly, shared, shared])
86
+ }
87
+
88
+ func testPacketsMatchTheProbeFraming() {
89
+
90
+ let stream = Data((0 ..< 500).map { UInt8($0 & 0xFF) })
91
+ let packets = G2CfwTransport.packets(stream: stream, sequence: 254, targets: 3, chunkBytes: 200)
92
+ XCTAssertEqual(packets.count, 3)
93
+ // [aa 21 seq len+2 1 1 f0 0][options][chunk][crc lo][crc hi]
94
+ XCTAssertEqual(Array(packets[0].prefix(9)), [0xAA, 0x21, 254, 203, 1, 1, 0xF0, 0, 3 | 0x80])
95
+ XCTAssertEqual(Array(packets[1].prefix(9)), [0xAA, 0x21, 255, 203, 1, 1, 0xF0, 0, 3])
96
+ XCTAssertEqual(Array(packets[2].prefix(9)), [0xAA, 0x21, 0, 103, 1, 1, 0xF0, 0, 3 | 0x40])
97
+ XCTAssertEqual(packets[2].count, 8 + 1 + 100 + 2)
98
+ for packet in packets {
99
+ let body = packet.subdata(in: 8 ..< packet.count - 2)
100
+ XCTAssertEqual(Array(packet.suffix(2)), BinaryCodec.crc16CCITTLittleEndian(body))
101
+ XCTAssertEqual(Int(packet[3]), packet.count - 8)
102
+ }
103
+ XCTAssertEqual(Array(packets.flatMap { $0.subdata(in: 9 ..< $0.count - 2) }), Array(stream))
104
+ // An empty stream is one RESET|END packet; the probe rejects >252-byte chunks.
105
+ let empty = G2CfwTransport.packets(stream: Data(), sequence: 7, targets: 1)
106
+ XCTAssertEqual(Array(empty[0]), [0xAA, 0x21, 7, 3, 1, 1, 0xF0, 0, 1 | 0x80 | 0x40] + BinaryCodec.crc16CCITTLittleEndian([1 | 0x80 | 0x40]))
107
+ XCTAssertLessThanOrEqual(G2CfwTransport.maxChunkBytes, 252)
108
+ }
109
+
110
+ func testAckParsingIncludesHistoryAndRejectsMalformedReplies() throws {
111
+ let body = Data([1, 7, 0, 0, 2, 0x34, 0x12, 0xCD, 0xAB, 6, 1, 0, 0x10, 0, 0x22, 0x11])
112
+ let ack = try XCTUnwrap(G2CfwTransport.parseAck(body))
113
+ XCTAssertTrue(ack.success)
114
+ XCTAssertEqual(ack.lens, 2)
115
+ XCTAssertEqual(ack.current, .init(streamId: 7, ordinal: 0, size: 0x1234, crc: 0xABCD))
116
+ XCTAssertEqual(ack.entries.count, 2)
117
+ XCTAssertEqual(ack.entries[1], .init(streamId: 6, ordinal: 1, size: 0x10, crc: 0x1122))
118
+ let nack = try XCTUnwrap(G2CfwTransport.parseAck(Data([3, 7, 0, 0, 1, 0, 0, 0, 0])))
119
+ XCTAssertFalse(nack.success)
120
+ XCTAssertNil(G2CfwTransport.parseAck(Data([3, 7, 0, 0, 1, 0, 0, 0, 0, 6, 1, 0, 0, 0, 0, 0]))) // NACK has no history
121
+ XCTAssertNil(G2CfwTransport.parseAck(Data([2, 7, 0, 0, 1, 0, 0, 0, 0])))
122
+ XCTAssertNil(G2CfwTransport.parseAck(Data([1, 7, 0, 0, 3, 0, 0, 0, 0])))
123
+ XCTAssertNil(G2CfwTransport.parseAck(Data([1, 7, 0, 0])))
124
+
125
+ // Notify frame as the glasses send it: [aa 12 seq len 1 1 f0 0][body][crc].
126
+ var frame = Data([0xAA, 0x12, 9, UInt8(body.count + 2), 1, 1, 0xF0, 0])
127
+ frame.append(body)
128
+ frame.append(contentsOf: BinaryCodec.crc16CCITTLittleEndian(body))
129
+ XCTAssertEqual(G2CfwTransport.replyBody(fromNotifyFrame: frame), body)
130
+ var corrupt = frame
131
+ corrupt[10] ^= 1
132
+ XCTAssertNil(G2CfwTransport.replyBody(fromNotifyFrame: corrupt))
133
+ var otherService = frame
134
+ otherService[6] = 0xE0
135
+ XCTAssertNil(G2CfwTransport.replyBody(fromNotifyFrame: otherService))
136
+ }
137
+
138
+ @MainActor
139
+ func testAckBoxCompletesWhenBothLensesAckAndFailsOnNack() async throws {
140
+ let box = G2CfwAckBox()
141
+ let left = try XCTUnwrap(G2CfwTransport.parseAck(Data([1, 5, 0, 0, 1, 2, 0, 0, 0])))
142
+ let right = try XCTUnwrap(G2CfwTransport.parseAck(Data([1, 5, 0, 0, 2, 2, 0, 0, 0])))
143
+ let ok = await box.wait(streamId: 5, lenses: 3, timeoutNs: 1_000_000_000) { _ in
144
+ XCTAssertFalse(box.resolve(left)) // one lens is not enough
145
+ XCTAssertFalse(box.resolve(left)) // duplicate is harmless
146
+ XCTAssertTrue(box.resolve(right))
147
+ }
148
+ XCTAssertTrue(ok)
149
+ XCTAssertEqual(box.outcome()?.reason, "ack")
150
+ XCTAssertLessThan(box.msSinceLastSuccess(), 1000)
151
+
152
+ let nack = try XCTUnwrap(G2CfwTransport.parseAck(Data([3, 6, 0, 0, 2, 0, 0, 0, 0])))
153
+ let failed = await box.wait(streamId: 6, lenses: 3, timeoutNs: 1_000_000_000) { _ in
154
+ XCTAssertFalse(box.resolve(left)) // wrong stream
155
+ XCTAssertTrue(box.resolve(nack))
156
+ }
157
+ XCTAssertFalse(failed)
158
+ XCTAssertEqual(box.outcome()?.reason, "nack")
159
+
160
+ let timedOut = await box.wait(streamId: 7, lenses: 1, timeoutNs: 50_000_000) { _ in }
161
+ XCTAssertFalse(timedOut)
162
+ XCTAssertEqual(box.outcome()?.reason, "timeout")
163
+ }
164
+
165
+ @MainActor
166
+ func testQueuedWritesExpireBeforeCreditsReturnAndStayExpiredWhenStreamIdIsReused() async {
167
+ let box = G2CfwAckBox()
168
+ var staleWrite: (() -> Bool)?
169
+ let timedOut = await box.wait(streamId: 7, lenses: 1, timeoutNs: 20_000_000) { isPending in
170
+ staleWrite = isPending
171
+ XCTAssertTrue(isPending())
172
+ // Simulate no BLE credits: no packet is written and no ACK arrives.
173
+ }
174
+ XCTAssertFalse(timedOut)
175
+ XCTAssertEqual(box.outcome()?.reason, "timeout")
176
+ XCTAssertEqual(staleWrite?(), false)
177
+
178
+ // Recovery can reuse the wire ID after it wraps. An old queue entry
179
+ // must still be discarded, while the recovery's packets may be sent.
180
+ var recoveryWrite: (() -> Bool)?
181
+ let recovered = await box.wait(streamId: 7, lenses: 1, timeoutNs: 1_000_000_000) { isPending in
182
+ recoveryWrite = isPending
183
+ XCTAssertEqual(staleWrite?(), false)
184
+ XCTAssertTrue(isPending())
185
+ box.resolve(G2CfwTransport.Ack(success: true, lens: 1, entries: [.init(streamId: 7, ordinal: 0, size: 1, crc: 0)]))
186
+ }
187
+ XCTAssertTrue(recovered)
188
+ XCTAssertEqual(recoveryWrite?(), false)
189
+ }
190
+
191
+ @MainActor
192
+ func testAckBoxSingleLensAndCancellation() async throws {
193
+ let box = G2CfwAckBox()
194
+ let right = try XCTUnwrap(G2CfwTransport.parseAck(Data([1, 9, 0, 0, 2, 0, 0, 0, 0])))
195
+ let ok = await box.wait(streamId: 9, lenses: 2, timeoutNs: 1_000_000_000) { _ in
196
+ XCTAssertTrue(box.resolve(right))
197
+ }
198
+ XCTAssertTrue(ok)
199
+ var queuedWrite: (() -> Bool)?
200
+ let task = Task { @MainActor in
201
+ await box.wait(streamId: 10, lenses: 3, timeoutNs: 60_000_000_000) { isPending in
202
+ queuedWrite = isPending
203
+ }
204
+ }
205
+ try await Task.sleep(nanoseconds: 20_000_000)
206
+ task.cancel()
207
+ let cancelled = await task.value
208
+ XCTAssertFalse(cancelled)
209
+ XCTAssertEqual(box.outcome()?.reason, "cancelled")
210
+ XCTAssertEqual(queuedWrite?(), false)
211
+ }
212
+
213
+ func testCacheRepliesParseAndStampFillsEpochAndRequest() async throws {
214
+ let body = Data([5, 9, 0, 0, 2, 38, 7, 0, 2, 0x34, 0x12, 5, 0, 0, 0, 2, 1, 1, 0, 0, 0, 0, 1, 0, 0])
215
+ let reply = try XCTUnwrap(G2CfwTransport.parseCacheReply(body))
216
+ XCTAssertEqual(reply.streamId, 9)
217
+ XCTAssertEqual(reply.lens, 2)
218
+ XCTAssertEqual(reply.mode, 38)
219
+ XCTAssertEqual(reply.request, 7)
220
+ XCTAssertEqual(reply.status, G2CfwTransport.CacheReply.missing)
221
+ XCTAssertEqual(reply.epoch, 0x1234)
222
+ XCTAssertEqual(reply.revision, 5)
223
+ XCTAssertEqual(reply.missingIds, [1, 256])
224
+ XCTAssertTrue(reply.summary.hasPrefix("missing"))
225
+ XCTAssertNil(G2CfwTransport.parseCacheReply(Data([1, 9, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])))
226
+ XCTAssertNil(G2CfwTransport.parseAck(body))
227
+
228
+ let show = Data([38, 0, 0, 0, 0, 1, 0, 0, 0, 0])
229
+ XCTAssertTrue(G2CfwTransport.isCacheMessage(show))
230
+ XCTAssertFalse(G2CfwTransport.isCacheMessage(Data([36, 0, 0, 0, 0])))
231
+ XCTAssertEqual(Array(G2CfwTransport.stamp(show, epoch: 0x1234, request: 0x0102).prefix(5)), [38, 0x34, 0x12, 2, 1])
232
+ let tagged = Data([38, 0, 0, 9, 0, 1, 0, 0, 0, 0])
233
+ XCTAssertEqual(Array(G2CfwTransport.stamp(tagged, epoch: 1, request: 7).prefix(5)), [38, 1, 0, 9, 0], "a tagged request is kept")
234
+ let state = Data([40, 0, 0, 0, 0, 0, 0, 0, 0])
235
+ XCTAssertEqual(Array(G2CfwTransport.stamp(state, epoch: 1, request: 7).prefix(5)), [40, 1, 0, 0, 0], "STATE carries a revision, not a request")
236
+
237
+ let box = G2CfwAckBox()
238
+ let armed = expectation(description: "armed")
239
+ Task { @MainActor in
240
+ let ok = await box.wait(streamId: 9, lenses: G2CfwTransport.lensBoth, timeoutNs: 1_000_000_000) { _ in
241
+ box.record(reply)
242
+ box.resolve(G2CfwTransport.Ack(success: true, lens: 2, entries: [.init(streamId: 9, ordinal: 0, size: 1, crc: 0)]))
243
+ box.resolve(G2CfwTransport.Ack(success: true, lens: 1, entries: [.init(streamId: 9, ordinal: 0, size: 1, crc: 0)]))
244
+ }
245
+ XCTAssertTrue(ok)
246
+ XCTAssertEqual(box.cacheReplies()[2], reply)
247
+ XCTAssertNil(box.cacheReplies()[1])
248
+ armed.fulfill()
249
+ }
250
+ await fulfillment(of: [armed], timeout: 2)
251
+ }
252
+
253
+ func testAckTimeoutScalesWithStreamSize() {
254
+ XCTAssertEqual(G2CfwTransport.ackTimeoutNs(streamBytes: 0), 3_500_000_000)
255
+ XCTAssertEqual(G2CfwTransport.ackTimeoutNs(streamBytes: 65_535), 3_500_000_000 + 65_535 * 1_000_000)
256
+ // A 4.5 KB upload chunk acked in ~4.1 s on GLASSLYCFW/39; the deadline must clear it.
257
+ XCTAssertGreaterThan(G2CfwTransport.ackTimeoutNs(streamBytes: 4_500), 6_000_000_000)
258
+ }
259
+ }
@@ -0,0 +1,89 @@
1
+ @testable import BluetoothSDK
2
+ import Foundation
3
+ import XCTest
4
+
5
+ final class G2DisplayDiagnosticsTests: XCTestCase {
6
+ private func decode(_ line: String) throws -> [String: Any] {
7
+ try XCTUnwrap(JSONSerialization.jsonObject(with: Data(line.dropFirst(7).utf8)) as? [String: Any])
8
+ }
9
+
10
+ @MainActor
11
+ func testSamplesAreThrottledResetAndLeaveOutEmptyValues() throws {
12
+ let diagnostics = G2DisplayDiagnostics(enabled: true, now: 0)
13
+ diagnostics.count("framesIn")
14
+ diagnostics.count("txBytes", 100)
15
+ diagnostics.timing("ackMs", 10)
16
+ diagnostics.timing("ackMs", 30.25)
17
+ diagnostics.timing("resumeMs", 0.2)
18
+ XCTAssertNil(diagnostics.sample(context: [:], now: 0.9))
19
+ let line = try XCTUnwrap(diagnostics.sample(
20
+ context: ["app": "com.glassly.dashboard", "qL": 0, "pendingFrame": false, "page": true, "blockedL": false],
21
+ now: 1
22
+ ))
23
+ let first = try decode(line)
24
+ XCTAssertEqual(first["txBytes"] as? Int, 100)
25
+ XCTAssertEqual(first["ackMsAvg"] as? Double, 20.1)
26
+ XCTAssertEqual(first["ackMsMax"] as? Double, 30.3)
27
+ XCTAssertNil(first["ackMsTotal"])
28
+ XCTAssertNil(first["resumeMsAvg"], "a sub-millisecond timing is left out")
29
+ XCTAssertTrue(line.contains("\"ackMsAvg\":20.1,"), "one decimal, no binary float noise: \(line)")
30
+ XCTAssertEqual(first["windowMs"] as? Int, 1000)
31
+ XCTAssertEqual(first["page"] as? Bool, true)
32
+ for key in ["qL", "pendingFrame", "blockedL", "timeouts", "errors"] {
33
+ XCTAssertNil(first[key], key)
34
+ }
35
+ XCTAssertEqual((first["run"] as? String)?.count, 8)
36
+ // Counters and timings reset with the window.
37
+ diagnostics.count("framesIn")
38
+ let second = try decode(XCTUnwrap(diagnostics.sample(context: [:], now: 2)))
39
+ XCTAssertNil(second["txBytes"])
40
+ XCTAssertNil(second["ackMsAvg"])
41
+ XCTAssertEqual(first["run"] as? String, second["run"] as? String)
42
+ XCTAssertEqual(second["id"] as? Int, 2)
43
+ }
44
+
45
+ @MainActor
46
+ func testIdleWindowsAreSkippedUnlessBusyOrLate() throws {
47
+ let diagnostics = G2DisplayDiagnostics(enabled: true, now: 0)
48
+ // IMU reports and heartbeat writes alone are idle.
49
+ diagnostics.count("imuReceived")
50
+ diagnostics.count("txPackets", 2)
51
+ diagnostics.timing("notifyDispatchMs", 0.1)
52
+ XCTAssertNil(diagnostics.sample(context: [:], now: 1))
53
+ // Something queued for the glasses is never idle.
54
+ XCTAssertNotNil(diagnostics.sample(context: ["qL": 3], busy: true, now: 2))
55
+ // A tick held up past the stretched window is logged even when nothing happened.
56
+ XCTAssertNil(diagnostics.sample(context: [:], now: 3))
57
+ let late = try decode(XCTUnwrap(diagnostics.sample(context: [:], now: 26.3)))
58
+ XCTAssertEqual(late["windowMs"] as? Int, 23300)
59
+ }
60
+
61
+ @MainActor
62
+ func testSessionFactsAreLoggedOnChangeAndOnceAMinute() throws {
63
+ let diagnostics = G2DisplayDiagnostics(enabled: true, now: 0)
64
+ let facts: [String: Any] = ["cfw": "GLASSLYCFW/39", "mtuL": 244, "mtuR": 244, "fpsLimit": 8]
65
+ let first = try decode(XCTUnwrap(diagnostics.session(facts, now: 1)))
66
+ XCTAssertEqual(first["type"] as? String, "session")
67
+ XCTAssertEqual(first["cfw"] as? String, "GLASSLYCFW/39")
68
+ XCTAssertNil(diagnostics.session(facts, now: 30))
69
+ var changed = facts
70
+ changed["mtuR"] = 180
71
+ XCTAssertNotNil(diagnostics.session(changed, now: 31))
72
+ XCTAssertNil(diagnostics.session(changed, now: 90))
73
+ XCTAssertNotNil(diagnostics.session(changed, now: 91))
74
+ }
75
+
76
+ @MainActor
77
+ func testStallDetailsAreBoundedButFailureCountersAreNotLost() throws {
78
+ let diagnostics = G2DisplayDiagnostics(enabled: true, now: 0)
79
+ for i in 0 ..< 100 {
80
+ diagnostics.count("errors")
81
+ let line = diagnostics.stall(fields: ["result": "timeout"], now: Double(i) / 100)
82
+ XCTAssertEqual(line != nil, i == 0)
83
+ }
84
+ let sample = try decode(XCTUnwrap(diagnostics.sample(context: [:], now: 2)))
85
+ XCTAssertEqual(sample["errors"] as? Int, 100)
86
+ XCTAssertEqual(sample["windowMs"] as? Int, 2000)
87
+ XCTAssertNotNil(diagnostics.stall(fields: [:], now: 2))
88
+ }
89
+ }
@@ -129,9 +129,10 @@ final class G2FlashProtocolTests: XCTestCase {
129
129
  XCTAssertEqual(Array(longRun), [0, 0, 0, 1])
130
130
  }
131
131
 
132
- func testCfwBlackPayloadUsesModeSixZlibEnvelope() throws {
132
+ func testCfwBlackPayloadUsesModeSixRleEnvelope() throws {
133
+ // Plain RLE follows the mode byte: the SID-0xf0 transport compresses.
133
134
  let payload = try XCTUnwrap(G2CfwRenderer.blackPayload())
134
- XCTAssertEqual(Array(payload.prefix(3)), [6, 0x78, 0x9C])
135
+ XCTAssertEqual(Array(payload.prefix(5)), [6, 0, 0, 0xFF, 0xFF])
135
136
  }
136
137
 
137
138
  private func makeFirmware() -> [UInt8] {