@mentra/bluetooth-sdk 3.2.1-beta.319 → 3.2.1-beta.354

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 (83) hide show
  1. package/README.md +1 -1
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  3. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +4 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +67 -12
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  10. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  13. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +64 -14
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +73 -3
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +257 -395
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +225 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +168 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  24. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +557 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  29. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +308 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +270 -0
  34. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  35. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  36. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +194 -0
  37. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +199 -0
  38. package/build/BluetoothSdk.types.d.ts +11 -0
  39. package/build/BluetoothSdk.types.d.ts.map +1 -1
  40. package/build/BluetoothSdk.types.js.map +1 -1
  41. package/build/_private/BluetoothSdkModule.d.ts +2 -0
  42. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  43. package/build/_private/BluetoothSdkModule.js +3 -0
  44. package/build/_private/BluetoothSdkModule.js.map +1 -1
  45. package/build/generated/changelogCatalog.d.ts +1 -1
  46. package/build/generated/changelogCatalog.js +1 -1
  47. package/build/generated/changelogCatalog.js.map +1 -1
  48. package/build/generated/releaseMetadata.js +5 -5
  49. package/build/generated/releaseMetadata.js.map +1 -1
  50. package/build/index.d.ts.map +1 -1
  51. package/build/index.js +1 -0
  52. package/build/index.js.map +1 -1
  53. package/ios/BluetoothSdkModule.swift +5 -0
  54. package/ios/MentraBluetoothSDK.podspec +3 -2
  55. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  56. package/ios/Source/Bridge.swift +15 -2
  57. package/ios/Source/DeviceManager.swift +23 -10
  58. package/ios/Source/DeviceStore.swift +26 -7
  59. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  60. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  61. package/ios/Source/MentraBluetoothSDK.swift +4 -0
  62. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  63. package/ios/Source/sgcs/G2.swift +2 -2
  64. package/ios/Source/sgcs/MentraLive.swift +66 -3
  65. package/ios/Source/sgcs/Nimo.swift +182 -371
  66. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  67. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +219 -0
  68. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  69. package/ios/Source/sgcs/NimoCanvasSession.swift +150 -0
  70. package/ios/Source/sgcs/SGCManager.swift +24 -0
  71. package/ios/Source/utils/G2Text.swift +11 -0
  72. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  73. package/ios/Tests/G2TextTests.swift +10 -0
  74. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  75. package/ios/Tests/NimoCanvasSessionTests.swift +112 -0
  76. package/ios/Tests/NimoCanvasTransportTests.swift +161 -0
  77. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  78. package/package.json +2 -2
  79. package/src/BluetoothSdk.types.ts +11 -0
  80. package/src/_private/BluetoothSdkModule.ts +6 -0
  81. package/src/generated/changelogCatalog.ts +1 -1
  82. package/src/generated/releaseMetadata.ts +5 -5
  83. package/src/index.ts +1 -0
@@ -96,6 +96,10 @@ protocol SGCManager {
96
96
 
97
97
  // MARK: - Display Control
98
98
 
99
+ /// Full-frame adapters preserve the active scene instead of showing temporary overlays.
100
+ var showBrightnessConfirmation: Bool { get }
101
+ var showConnectionConfirmation: Bool { get }
102
+ var sceneHandoffRequiresClear: Bool { get }
99
103
  func setBrightness(_ level: Int, autoMode: Bool)
100
104
  func clearDisplay()
101
105
  func sendText(_ text: String) async
@@ -228,6 +232,10 @@ protocol SGCManager {
228
232
 
229
233
  func sendLoudnessGateSetting()
230
234
 
235
+ // MARK: - Auto power-off when unworn
236
+
237
+ func sendAutoPowerOffSetting()
238
+
231
239
  // MARK: - Mic tuning (super-mode only)
232
240
 
233
241
  func sendMicTuningSetting()
@@ -254,6 +262,18 @@ protocol SGCManager {
254
262
  /// doesn't seem to work for concurrency reasons :(
255
263
  /// we can make read-only getters for convienence though:
256
264
  extension SGCManager {
265
+ var showBrightnessConfirmation: Bool {
266
+ true
267
+ }
268
+
269
+ var showConnectionConfirmation: Bool {
270
+ true
271
+ }
272
+
273
+ var sceneHandoffRequiresClear: Bool {
274
+ true
275
+ }
276
+
257
277
  var isMicSuspendedForAudio: Bool {
258
278
  false
259
279
  }
@@ -435,6 +455,10 @@ extension SGCManager {
435
455
 
436
456
  func sendLoudnessGateSetting() {}
437
457
 
458
+ // MARK: - Auto power-off (default no-op — Mentra Live supports this)
459
+
460
+ func sendAutoPowerOffSetting() {}
461
+
438
462
  // MARK: - Mic tuning (default no-op — Mentra Live supports this)
439
463
 
440
464
  func sendMicTuningSetting() {}
@@ -0,0 +1,11 @@
1
+ import Foundation
2
+
3
+ /// Empty interior rows shut down some EvenHub firmware pages. Keep the row
4
+ /// without exposing this protocol workaround to miniapps or other devices.
5
+ enum G2Text {
6
+ static func containerContent(_ text: String) -> String {
7
+ if text.isEmpty { return " " }
8
+ return text.components(separatedBy: "\n")
9
+ .map { $0.isEmpty ? "\u{200B}" : $0 }.joined(separator: "\n")
10
+ }
11
+ }
@@ -15,6 +15,36 @@ final class DashboardRenderingTests: XCTestCase {
15
15
  return (manager, display)
16
16
  }
17
17
 
18
+ func testFullFrameDashboardHandoffDoesNotClearActiveScene() async {
19
+ let (manager, display) = setupDashboard()
20
+ display.sceneHandoffRequiresClear = false
21
+ await manager.setDashboardContent("new dashboard")
22
+ XCTAssertEqual(display.cleanupCount, 0)
23
+ XCTAssertEqual(display.clearDisplayCount, 0)
24
+ XCTAssertEqual(display.texts.count, 1)
25
+ XCTAssertTrue(display.texts[0].hasSuffix("new dashboard"))
26
+ }
27
+
28
+ func testHiddenSceneHandoffCannotClearMainView() async {
29
+ let (manager, display) = setupDashboard()
30
+ DeviceStore.shared.apply("glasses", "headUp", false)
31
+ manager.displayEvent([
32
+ "view": "dashboard",
33
+ "scene": ["appId": "replacement", "sceneEpoch": 2, "elements": [[String: Any]]()],
34
+ ])
35
+ await Task.yield()
36
+ XCTAssertEqual(display.cleanupCount, 0)
37
+ XCTAssertEqual(display.clearDisplayCount, 0)
38
+ XCTAssertTrue(display.scenes.isEmpty)
39
+ }
40
+
41
+ func testNimoOptsOutOfDestructiveTransientOverlays() {
42
+ let device: SGCManager = Nimo()
43
+ XCTAssertFalse(device.sceneHandoffRequiresClear)
44
+ XCTAssertFalse(device.showBrightnessConfirmation)
45
+ XCTAssertFalse(device.showConnectionConfirmation)
46
+ }
47
+
18
48
  func testSetterWaitsForCleanupAndLatestContentWins() async {
19
49
  let (manager, display) = setupDashboard()
20
50
  var firstReturned = false
@@ -74,6 +104,7 @@ final class DashboardRenderingTests: XCTestCase {
74
104
  private final class PausedDisplay: SGCManager {
75
105
  var type = "Test display"
76
106
  let hasMic = false
107
+ var sceneHandoffRequiresClear = true
77
108
  let cleanupStarted = XCTestExpectation(description: "cleanup suspended")
78
109
  var cleanupCount = 0
79
110
  var clearDisplayCount = 0
@@ -0,0 +1,10 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class G2TextTests: XCTestCase {
5
+ func testBlankRowsAreEncodedOnlyAtTheDeviceBoundary() {
6
+ XCTAssertEqual(G2Text.containerContent("hello\n\nworld\n"), "hello\n\u{200B}\nworld\n\u{200B}")
7
+ XCTAssertEqual(G2Text.containerContent(""), " ")
8
+ XCTAssertEqual(G2Text.containerContent("hello\nworld"), "hello\nworld")
9
+ }
10
+ }
@@ -0,0 +1,204 @@
1
+ import CoreGraphics
2
+ import ImageIO
3
+ @testable import MentraBluetoothSDK
4
+ import XCTest
5
+ import zlib
6
+
7
+ final class NimoCanvasCodecTests: XCTestCase {
8
+ private func hex(_ data: Data) -> String {
9
+ data.map { String(format: "%02X", $0) }.joined()
10
+ }
11
+
12
+ private func packet(_ hex: String) -> Data {
13
+ let chars = Array(hex)
14
+ return Data(stride(from: 0, to: chars.count, by: 2).map { UInt8(String(chars[$0 ... $0 + 1]), radix: 16)! })
15
+ }
16
+
17
+ func testVendorGoldenPacketsMatchAndroid() throws {
18
+ let frame = try NimoCanvasCodec.replace([
19
+ NimoCanvasCodec.rectangle(20, 20, 120, 50, stroke: 2, radius: 8),
20
+ NimoCanvasCodec.label("Hello Glass", 160, 30, 300, 40, font: 2),
21
+ ])
22
+ XCTAssertEqual(try hex(NimoCanvasCodec.frames(key: 4, content: frame, writeCapacity: 512)[0]),
23
+ "BF003600F72D000007043200FD000000020001010C0014001400780032000208FF00031900A0001E002C012800000002FF0B0048656C6C6F20476C617373")
24
+ XCTAssertEqual(try hex(NimoCanvasCodec.frames(key: 1, writeCapacity: 512)[0]), "BF000600F5E4000007010200FD00")
25
+ XCTAssertEqual(try hex(NimoCanvasCodec.frames(key: 3, writeCapacity: 512)[0]), "BF00050086E4000007030100FD")
26
+ }
27
+
28
+ func testPolarityOddPaddingAndImageHeader() throws {
29
+ let gray = Data([0, 85, 170, 255, 255])
30
+ XCTAssertEqual(try NimoCanvasCodec.pack2(gray), Data([0xE4, 0x3F]))
31
+ let bitmap = try NimoCanvasCodec.bitmap(0, 0, 5, 1, gray: gray)
32
+ XCTAssertEqual(bitmap.payload[10], 1)
33
+ XCTAssertEqual(bitmap.payload[19], 2)
34
+ XCTAssertEqual(bitmap.payload[20], 3)
35
+ XCTAssertEqual(bitmap.payload[21], 2)
36
+ XCTAssertEqual(bitmap.payload.dropFirst(29), Data([0x82, 0xE4, 0x3F]))
37
+ }
38
+
39
+ func testRleRunAndLiteralBoundariesRoundTrip() throws {
40
+ for size in [1, 2, 3, 126, 127, 128, 254, 27500] {
41
+ for source in [Data(repeating: 33, count: size), Data((0 ..< size).map { UInt8($0 % 251) })] {
42
+ let encoded = try [UInt8](NimoCanvasCodec.rle(source))
43
+ var decoded = Data(), i = 0
44
+ while i < encoded.count {
45
+ let tag = encoded[i]; i += 1
46
+ let count = Int(tag & 127)
47
+ XCTAssertGreaterThan(count, 0)
48
+ if tag & 128 != 0 { decoded.append(contentsOf: encoded[i ..< i + count]); i += count }
49
+ else { decoded.append(Data(repeating: encoded[i], count: count)); i += 1 }
50
+ }
51
+ XCTAssertEqual(decoded, source)
52
+ }
53
+ }
54
+ }
55
+
56
+ func testZlibImageInflatesToPackedPixels() throws {
57
+ let pixels: [UInt8] = (0 ..< 32768).map { index in
58
+ let tone = ((index / 17) ^ (index / 43)) % 4
59
+ return UInt8(tone * 85)
60
+ }
61
+ let gray = Data(pixels)
62
+ let bitmap = try NimoCanvasCodec.bitmap(0, 0, 256, 128, gray: gray)
63
+ XCTAssertEqual(bitmap.payload[20], 7)
64
+ let compressed = Data(bitmap.payload.dropFirst(29))
65
+ var count: uLong = 8192
66
+ var output = Data(count: Int(count))
67
+ let status = output.withUnsafeMutableBytes { dst in
68
+ compressed.withUnsafeBytes { src in
69
+ uncompress(dst.bindMemory(to: UInt8.self).baseAddress!, &count,
70
+ src.bindMemory(to: UInt8.self).baseAddress!, uLong(compressed.count))
71
+ }
72
+ }
73
+ XCTAssertEqual(status, Z_OK)
74
+ XCTAssertEqual(output, try NimoCanvasCodec.pack2(gray))
75
+ }
76
+
77
+ func testFragmentationRespectsCoreBluetoothCapacityAndCrc() throws {
78
+ let content = try NimoCanvasCodec.replace([NimoCanvasCodec.label(String(repeating: "X", count: 2048), 0, 0, 500, 220)])
79
+ var baseline: Data?
80
+ for capacity in [20, 64, 185, 244, 512] {
81
+ let frames = try NimoCanvasCodec.frames(key: 4, content: content, writeCapacity: capacity)
82
+ var stream = Data()
83
+ for (index, frame) in frames.enumerated() {
84
+ XCTAssertLessThanOrEqual(frame.count, capacity)
85
+ XCTAssertEqual(frame[1], 0)
86
+ XCTAssertEqual(Int(frame[6]) | Int(frame[7]) << 8, index == frames.count - 1 ? 0 : index + 1)
87
+ XCTAssertEqual(UInt16(frame[4]) | UInt16(frame[5]) << 8, nimoCrc16(Data(frame.dropFirst(8))))
88
+ stream += frame.dropFirst(8)
89
+ }
90
+ if let baseline { XCTAssertEqual(stream, baseline) } else { baseline = stream }
91
+ }
92
+ XCTAssertThrowsError(try NimoCanvasCodec.frames(key: 4, content: content, writeCapacity: 19))
93
+ XCTAssertThrowsError(try NimoCanvasCodec.frames(key: 4, content: Data(count: 12281), writeCapacity: 512))
94
+ }
95
+
96
+ func testStrictCanvasResponseParsing() {
97
+ let good = packet("BF000900D87900000704050000FD000000")
98
+ XCTAssertEqual(NimoCanvasCodec.response(good)?.payload, Data([0, 0xFD, 0, 0, 0]))
99
+ XCTAssertNil(NimoCanvasCodec.response(good + Data([0])))
100
+ for index in [0, 1, 2, 4, 6, 8, 10] {
101
+ var bad = good; bad[index] ^= 1
102
+ XCTAssertNil(NimoCanvasCodec.response(bad))
103
+ }
104
+ let body = Data([7, 4, 1, 0, 7])
105
+ let statusOnly = NimoFrameCodec.transportHeader(body, needsAck: false) + body
106
+ XCTAssertEqual(NimoCanvasCodec.response(statusOnly)?.payload, Data([7]))
107
+ }
108
+
109
+ func testUtf8RowsAndGeometryValidation() throws {
110
+ XCTAssertEqual(try NimoCanvasCodec.label("😀é", 0, 0, 100, 20).textBytes, 6)
111
+ XCTAssertThrowsError(try NimoCanvasCodec.label("a\0b", 0, 0, 100, 20))
112
+ XCTAssertThrowsError(try NimoCanvasCodec.label(String(repeating: "é", count: 1025), 0, 0, 100, 20))
113
+ let rows = try NimoCanvasCodec.textRows("first\n\nthird", 12, 15, 400, 100)
114
+ XCTAssertEqual(rows.count, 2)
115
+ XCTAssertEqual(rows.map { $0.payload[2] }, [15, 55])
116
+ XCTAssertEqual(rows[1].payload[6], 20)
117
+ XCTAssertThrowsError(try NimoCanvasCodec.rectangle(Int.max, 0, 10, 1))
118
+ XCTAssertThrowsError(try NimoCanvasCodec.line(0, 0, 500, 219))
119
+ XCTAssertThrowsError(try NimoCanvasCodec.circle(0, 10, radius: 10))
120
+ XCTAssertEqual(try NimoCanvasCodec.circle(10, 10, radius: 10).payload.count, 9)
121
+ }
122
+
123
+ func testFrameBudgetsBeforeRasterDecode() throws {
124
+ XCTAssertEqual(try NimoCanvasCodec.replace([]), Data([0, 0, 1]))
125
+ let line = try NimoCanvasCodec.line(0, 0, 1, 1)
126
+ XCTAssertThrowsError(try NimoCanvasCodec.replace(Array(repeating: line, count: 65)))
127
+ let text = try NimoCanvasCodec.label(String(repeating: "a", count: 2048), 0, 0, 500, 220)
128
+ XCTAssertThrowsError(try NimoCanvasCodec.replace(Array(repeating: text, count: 5)))
129
+ let image = SceneElement(id: "image", type: "image", x: 0, y: 0, w: 500, h: 220, text: nil,
130
+ data: "unused", border: 0, radius: 0, change: "unchanged", contentHash: "")
131
+ var decoded = 0
132
+ XCTAssertThrowsError(try NimoCanvasCodec.scene([image, image]) { _, _, _ in decoded += 1; return Data() })
133
+ XCTAssertEqual(decoded, 0)
134
+ XCTAssertThrowsError(try NimoCanvasCodec.replace([.init(type: 4, payload: Data(count: 12278))]))
135
+ }
136
+
137
+ func testHostFrameReservationBoundsNativeEncoding() throws {
138
+ var seed: UInt32 = 42
139
+ func noise(_ count: Int) -> Data {
140
+ Data((0 ..< count).map { _ in
141
+ seed = seed &* 1_664_525 &+ 1_013_904_223
142
+ return UInt8(truncatingIfNeeded: seed >> 24)
143
+ })
144
+ }
145
+ for size in [1, 5, 80, 160, 200] {
146
+ let image = try NimoCanvasCodec.bitmap(0, 0, size, size, gray: noise(size * size))
147
+ let packed = (size * size + 3) / 4
148
+ let reserved = 32 + packed + (packed + 126) / 127
149
+ XCTAssertLessThanOrEqual(3 + image.payload.count, reserved)
150
+ }
151
+ // Retained prefixes of the host's over-budget regression scenes.
152
+ let rows = try NimoCanvasCodec.textRows(Array(repeating: "row", count: 11).joined(separator: "\n"), 0, 0, 500, 220)
153
+ XCTAssertLessThanOrEqual(try NimoCanvasCodec.replace(Array(repeating: rows, count: 5).flatMap { $0 }).count, 12280)
154
+ let images = try [200, 80].map { try NimoCanvasCodec.bitmap(0, 0, $0, $0, gray: noise($0 * $0)) }
155
+ XCTAssertLessThanOrEqual(try NimoCanvasCodec.replace(images).count, 12280)
156
+ }
157
+
158
+ func testWholeScenePreservesOrderAndIgnoresDiffAnnotations() throws {
159
+ let text = SceneElement(id: "caption", type: "text", x: 10, y: 20, w: 100, h: 40, text: "hello",
160
+ data: nil, border: 2, radius: 3, change: "unchanged", contentHash: "")
161
+ let expected = try NimoCanvasCodec.replace([
162
+ NimoCanvasCodec.rectangle(10, 20, 100, 40, stroke: 2, radius: 3),
163
+ NimoCanvasCodec.label("hello", 10, 20, 100, 20),
164
+ ])
165
+ XCTAssertEqual(try NimoCanvasCodec.scene([text]) { _, _, _ in XCTFail("Not an image"); return Data() }, expected)
166
+ }
167
+
168
+ func testImageKindAndDimensionsAreValidatedBeforeDecode() throws {
169
+ let png = Data([0x89, 0x50, 0x4E, 0x47, 13, 10, 26, 10]) + Data(count: 24)
170
+ XCTAssertEqual(try NimoCanvasCodec.imageBytes(png.base64EncodedString()), png)
171
+ XCTAssertThrowsError(try NimoCanvasCodec.imageBytes("data:image/bmp;base64," + png.base64EncodedString()))
172
+ XCTAssertThrowsError(try NimoCanvasCodec.imageBytes("data:image/jpeg;base64,AAAA"))
173
+ XCTAssertThrowsError(try NimoCanvasCodec.imageBytes("Qk0"))
174
+ XCTAssertThrowsError(try NimoCanvasImage.source(png.base64EncodedString()))
175
+ XCTAssertThrowsError(try NimoCanvasCodec.imageBytes(String(repeating: "a", count: 2_800_001)))
176
+ }
177
+
178
+ func testJpegPixelsAreDecodedForRawAndDataUriInputs() throws {
179
+ let rgba = Data(repeating: 255, count: 16)
180
+ let provider = CGDataProvider(data: rgba as CFData)!
181
+ let image = try XCTUnwrap(CGImage(width: 2, height: 2, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: 8,
182
+ space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue),
183
+ provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent))
184
+ let bytes = NSMutableData()
185
+ let destination = try XCTUnwrap(CGImageDestinationCreateWithData(bytes, "public.jpeg" as CFString, 1, nil))
186
+ CGImageDestinationAddImage(destination, image, nil)
187
+ XCTAssertTrue(CGImageDestinationFinalize(destination))
188
+ let raw = (bytes as Data).base64EncodedString()
189
+ for input in [raw, "data:image/jpeg;base64," + raw] {
190
+ XCTAssertEqual(try NimoCanvasImage.decode(input, width: 2, height: 2), Data(repeating: 255, count: 4))
191
+ }
192
+ XCTAssertThrowsError(try NimoCanvasImage.source("data:image/png;base64," + raw))
193
+ }
194
+
195
+ func testRasterKeepsTopRowAndLuminanceWithoutInversion() throws {
196
+ // Top row red/green; bottom row blue/white. Match the Android integer luminance formula.
197
+ let rgba = Data([255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255])
198
+ let provider = CGDataProvider(data: rgba as CFData)!
199
+ let image = try XCTUnwrap(CGImage(width: 2, height: 2, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: 8,
200
+ space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue),
201
+ provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent))
202
+ XCTAssertEqual(try NimoCanvasImage.grayscale(image, width: 2, height: 2), Data([77, 149, 29, 255]))
203
+ }
204
+ }
@@ -0,0 +1,112 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class NimoCanvasSessionTests: XCTestCase {
5
+ private let a = Data([0, 0, 1])
6
+ private let b = Data([1, 0, 1])
7
+ private func ack(_ session: NimoCanvasSession, _ key: Int, status: UInt8 = 0) -> [NimoCanvasSession.Action] {
8
+ session.response(key: key, payload: status == 0 ? (key == 4 ? Data([0, 0xFD, 0, 0, 0]) : Data([0, 0xFD])) : Data([status]))
9
+ }
10
+
11
+ private func sentKey(_ actions: [NimoCanvasSession.Action]) -> Int? {
12
+ guard case let .send(key, _, _) = actions.first else { return nil }
13
+ return key
14
+ }
15
+
16
+ private func connected(_ session: NimoCanvasSession) {
17
+ XCTAssertTrue(session.offer(a, scope: "app:1").isEmpty)
18
+ XCTAssertEqual(sentKey(session.readiness(true)), 1)
19
+ XCTAssertEqual(sentKey(ack(session, 1)), 4)
20
+ XCTAssertTrue(ack(session, 4).isEmpty)
21
+ }
22
+
23
+ func testReadinessLaunchAndLatestSceneWins() {
24
+ let session = NimoCanvasSession()
25
+ XCTAssertTrue(session.offer(a, scope: "app:1").isEmpty)
26
+ XCTAssertEqual(sentKey(session.readiness(true)), 1)
27
+ XCTAssertTrue(session.offer(b, scope: "app:1").isEmpty)
28
+ guard case let .send(key, frame, _) = ack(session, 1).first else { return XCTFail("Missing update") }
29
+ XCTAssertEqual(key, 4); XCTAssertEqual(frame, b)
30
+ XCTAssertTrue(ack(session, 4).isEmpty)
31
+ XCTAssertTrue(session.offer(b, scope: "app:1").isEmpty)
32
+ XCTAssertEqual(sentKey(session.offer(b, scope: "app:2")), 4)
33
+ }
34
+
35
+ func testForcedReplayAndReconnectDiscardAcceptedState() {
36
+ let session = NimoCanvasSession(); connected(session)
37
+ XCTAssertEqual(sentKey(session.offer(a, scope: "app:1", force: true)), 4)
38
+ _ = ack(session, 4)
39
+ session.disconnected()
40
+ XCTAssertEqual(sentKey(session.readiness(true)), 1)
41
+ XCTAssertEqual(sentKey(ack(session, 1)), 4)
42
+ }
43
+
44
+ func testStatusSevenRequiresFreshReadinessAndBoundsRetries() {
45
+ let session = NimoCanvasSession()
46
+ _ = session.offer(a, scope: "app:1"); _ = session.readiness(true)
47
+ for _ in 0 ..< 3 {
48
+ XCTAssertEqual(ack(session, 1, status: 7), [.rejected(7)])
49
+ XCTAssertTrue(session.readiness(true).isEmpty)
50
+ XCTAssertEqual(sentKey(session.readiness(true, confirmed: true)), 1)
51
+ }
52
+ _ = ack(session, 1, status: 7)
53
+ XCTAssertTrue(session.readiness(true, confirmed: true).isEmpty)
54
+ XCTAssertTrue(session.readiness(false).isEmpty)
55
+ XCTAssertEqual(sentKey(session.readiness(true)), 1)
56
+ }
57
+
58
+ func testWrongEchoAndWrongKeyDoNotAdvance() {
59
+ let session = NimoCanvasSession()
60
+ _ = session.offer(a, scope: "app:1"); _ = session.readiness(true)
61
+ XCTAssertTrue(session.response(key: 1, payload: Data([0])).isEmpty)
62
+ XCTAssertTrue(session.response(key: 1, payload: Data([0, 1])).isEmpty)
63
+ XCTAssertTrue(ack(session, 4).isEmpty)
64
+ XCTAssertEqual(sentKey(ack(session, 1)), 4)
65
+ }
66
+
67
+ func testLockedAndRejectedFramesDoNotSpin() {
68
+ let session = NimoCanvasSession(); connected(session)
69
+ _ = session.offer(b, scope: "app:1")
70
+ XCTAssertEqual(ack(session, 4, status: 5), [.rejected(5)])
71
+ XCTAssertTrue(session.offer(b, scope: "app:1", force: true).isEmpty)
72
+ XCTAssertEqual(sentKey(session.offer(a, scope: "app:2")), 4)
73
+ XCTAssertEqual(ack(session, 4, status: 8), [.rejected(8)])
74
+ XCTAssertTrue(session.readiness(true, confirmed: true).isEmpty)
75
+ XCTAssertTrue(session.offer(b, scope: "new").isEmpty)
76
+ }
77
+
78
+ func testTimeoutRequiresNewConnectionAndStaleTicketCannotResetIt() {
79
+ let session = NimoCanvasSession()
80
+ _ = session.offer(a, scope: "app")
81
+ guard case let .send(_, _, ticket) = session.readiness(true).first else { return XCTFail() }
82
+ XCTAssertEqual(session.timeout(ticket), [.reconnect("Canvas ACK timeout; reconnect before reusing a command key")])
83
+ XCTAssertTrue(ack(session, 1).isEmpty)
84
+ session.disconnected()
85
+ XCTAssertEqual(sentKey(session.readiness(true)), 1)
86
+ XCTAssertTrue(session.timeout(ticket).isEmpty)
87
+ }
88
+
89
+ func testReadinessLossDuringFlightRetiresTransport() {
90
+ let session = NimoCanvasSession(); connected(session)
91
+ _ = session.offer(b, scope: "app")
92
+ XCTAssertEqual(session.readiness(false), [.reconnect("Readiness lost during canvas command")])
93
+ }
94
+
95
+ func testExitReportPreservesNewHostScene() {
96
+ let session = NimoCanvasSession(); connected(session)
97
+ XCTAssertEqual(sentKey(session.exit()), 3)
98
+ _ = session.offer(b, scope: "new")
99
+ XCTAssertTrue(session.nativeApp(0xFD, entered: false).isEmpty)
100
+ XCTAssertEqual(sentKey(ack(session, 3)), 1)
101
+ guard case let .send(_, frame, _) = ack(session, 1).first else { return XCTFail() }
102
+ XCTAssertEqual(frame, b)
103
+ }
104
+
105
+ func testNativeTakeoverSuppressesReplayUntilHostOffersAgain() {
106
+ let session = NimoCanvasSession(); connected(session)
107
+ XCTAssertTrue(session.nativeApp(1, entered: true).isEmpty)
108
+ XCTAssertTrue(session.readiness(true, confirmed: true).isEmpty)
109
+ XCTAssertEqual(sentKey(session.offer(b, scope: "new")), 1)
110
+ XCTAssertEqual(session.nativeApp(1, entered: true), [.reconnect("Native app transition interrupted canvas command")])
111
+ }
112
+ }
@@ -0,0 +1,161 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ private final class NimoTestClock {
5
+ private final class Job {
6
+ let time: TimeInterval
7
+ let task: () -> Void
8
+ var cancelled = false
9
+ init(_ time: TimeInterval, _ task: @escaping () -> Void) {
10
+ self.time = time; self.task = task
11
+ }
12
+ }
13
+
14
+ private var now: TimeInterval = 0
15
+ private var jobs: [Job] = []
16
+ lazy var schedule: NimoScheduler = { [unowned self] delay, task in
17
+ let job = Job(self.now + delay, task)
18
+ self.jobs.append(job)
19
+ return { job.cancelled = true }
20
+ }
21
+
22
+ func advance(_ seconds: TimeInterval = 0) {
23
+ let end = now + seconds
24
+ while let job = jobs.filter({ !$0.cancelled && $0.time <= end }).min(by: { $0.time < $1.time }) {
25
+ jobs.removeAll { $0 === job }
26
+ now = job.time
27
+ job.task()
28
+ }
29
+ now = end
30
+ }
31
+ }
32
+
33
+ final class NimoCanvasTransportTests: XCTestCase {
34
+ private let frame = Data([0, 0, 1])
35
+
36
+ func testEarlyAckOnlyAdvancesAfterFinalWriteCompletes() {
37
+ let clock = NimoTestClock()
38
+ var chains: [[Data]] = [], starts: [() -> Void] = [], completes: [() -> Void] = []
39
+ let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 20 }, enqueue: { frames, start, complete in
40
+ chains.append(frames); starts.append(start); completes.append(complete); return true
41
+ }, reconnect: { XCTFail($0) }, rejected: { XCTFail("Unexpected rejection \($0)") })
42
+ canvas.offer(frame, scope: "app")
43
+ canvas.readiness(true)
44
+ canvas.response(key: 1, payload: Data([0, 0xFD])) // Before final fragment: ignore.
45
+ starts[0]()
46
+ XCTAssertEqual(chains.count, 1)
47
+ canvas.response(key: 1, payload: Data([0, 0xFD])) // Before GATT callback: buffer.
48
+ XCTAssertEqual(chains.count, 1)
49
+ completes[0]()
50
+ XCTAssertEqual(chains.count, 2)
51
+ XCTAssertEqual(chains[1][0][9], 4)
52
+ starts[1](); completes[1]()
53
+ canvas.response(key: 4, payload: Data([0, 0xFD, 0, 0, 0]))
54
+ clock.advance(60)
55
+ }
56
+
57
+ func testTimeoutAndOldCompletionCannotAdvanceNewGeneration() {
58
+ let clock = NimoTestClock()
59
+ var completions: [() -> Void] = [], reasons: [String] = [], count = 0
60
+ let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 512 }, enqueue: { _, start, complete in
61
+ count += 1; start(); completions.append(complete); return true
62
+ }, reconnect: { reasons.append($0) }, rejected: { _ in })
63
+ canvas.offer(frame, scope: "app"); canvas.readiness(true)
64
+ clock.advance(45)
65
+ XCTAssertEqual(reasons.count, 1)
66
+ canvas.readiness(true)
67
+ completions[0]()
68
+ canvas.response(key: 1, payload: Data([0, 0xFD]))
69
+ XCTAssertEqual(count, 2) // ACK buffered for the new flight until its own write completes.
70
+ completions[1]()
71
+ XCTAssertEqual(count, 3)
72
+ canvas.disconnected()
73
+ }
74
+
75
+ func testQueueWatchdogRetiresRatherThanSkippingFragment() {
76
+ let clock = NimoTestClock(), connection = NSObject(), characteristic = NSObject()
77
+ var bytes: [Data] = [], failures: [String] = []
78
+ let queue = NimoWriteQueue<NSObject>(schedule: clock.schedule, write: { _, data in bytes.append(data); return true }, failure: { failures.append($0) })
79
+ queue.connected(connection)
80
+ queue.enqueue(connection, characteristic: characteristic, frames: [Data([1]), Data([2])])
81
+ clock.advance(3)
82
+ XCTAssertEqual(bytes, [Data([1])]); XCTAssertEqual(failures, ["GATT write timed out"])
83
+ queue.written(connection, characteristic: characteristic, success: true)
84
+ clock.advance(1)
85
+ XCTAssertEqual(bytes.count, 1)
86
+ }
87
+
88
+ func testQueueRejectsOldPeripheralAndCharacteristicAndPacesWrites() {
89
+ let clock = NimoTestClock(), old = NSObject(), current = NSObject(), characteristic = NSObject()
90
+ var count = 0, completed = 0
91
+ let queue = NimoWriteQueue<NSObject>(schedule: clock.schedule, write: { _, _ in count += 1; return true }, failure: { XCTFail($0) })
92
+ queue.connected(old)
93
+ queue.enqueue(old, characteristic: characteristic, frames: [Data([0])])
94
+ queue.connected(current)
95
+ queue.enqueue(current, characteristic: characteristic, frames: [Data([1]), Data([2])], completed: { completed += 1 })
96
+ queue.written(old, characteristic: characteristic, success: true)
97
+ queue.written(current, characteristic: NSObject(), success: true)
98
+ clock.advance(0.01)
99
+ XCTAssertEqual(count, 2)
100
+ queue.written(current, characteristic: characteristic, success: true)
101
+ clock.advance(0.004)
102
+ XCTAssertEqual(count, 2)
103
+ clock.advance(0.001)
104
+ XCTAssertEqual(count, 3)
105
+ queue.written(current, characteristic: characteristic, success: true)
106
+ clock.advance(0.005)
107
+ XCTAssertEqual(completed, 1)
108
+ clock.advance(10)
109
+ }
110
+
111
+ func testGattErrorAbortsRemainingChain() {
112
+ let clock = NimoTestClock(), connection = NSObject(), characteristic = NSObject()
113
+ var count = 0, failures = 0
114
+ let queue = NimoWriteQueue<NSObject>(schedule: clock.schedule, write: { _, _ in count += 1; return true }, failure: { _ in failures += 1 })
115
+ queue.connected(connection)
116
+ queue.enqueue(connection, characteristic: characteristic, frames: [Data([1]), Data([2])])
117
+ queue.written(connection, characteristic: characteristic, success: false)
118
+ clock.advance(4)
119
+ XCTAssertEqual(count, 1); XCTAssertEqual(failures, 1)
120
+ }
121
+
122
+ func testLatestOnlyEncoderAndExitFenceRunningResults() {
123
+ let main = NimoTestClock(), worker = NimoTestClock()
124
+ var delivered: [Data] = [], encoded = 0
125
+ let encoder = NimoCanvasEncoder(main: main.schedule, worker: worker.schedule, failure: { XCTFail("\($0)") })
126
+ encoder.submit(encode: { encoded += 1; return Data([1]) }, deliver: { delivered.append($0) })
127
+ encoder.submit(encode: { encoded += 1; return Data([2]) }, deliver: { delivered.append($0) })
128
+ worker.advance()
129
+ XCTAssertEqual(encoded, 1)
130
+ encoder.invalidate() // Exit/disconnect while the finished encode awaits Main.
131
+ main.advance()
132
+ XCTAssertTrue(delivered.isEmpty)
133
+ encoder.submit(encode: { Data([3]) }, deliver: { delivered.append($0) })
134
+ worker.advance(); main.advance()
135
+ XCTAssertEqual(delivered, [Data([3])])
136
+ }
137
+
138
+ func testCompleteSceneThroughWriteQueueAndBusinessAcks() throws {
139
+ let clock = NimoTestClock(), connection = NSObject(), characteristic = NSObject()
140
+ var written: [Data] = []
141
+ let queue = NimoWriteQueue<NSObject>(schedule: clock.schedule, write: { _, data in written.append(data); return true }, failure: { XCTFail($0) })
142
+ queue.connected(connection)
143
+ let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 20 }, enqueue: { frames, started, completed in
144
+ queue.enqueue(connection, characteristic: characteristic, frames: frames, finalStarted: started, completed: completed)
145
+ }, reconnect: { XCTFail($0) }, rejected: { XCTFail("Rejected \($0)") })
146
+ let content = try NimoCanvasCodec.replace([NimoCanvasCodec.label("Captions", 0, 0, 500, 20)])
147
+ canvas.offer(content, scope: "captions:1"); canvas.readiness(true)
148
+ queue.written(connection, characteristic: characteristic, success: true); clock.advance(0.005)
149
+ let launchAck = Data([0, 0xFD])
150
+ canvas.response(key: 1, payload: launchAck)
151
+ let expected = try NimoCanvasCodec.frames(key: 4, content: content, writeCapacity: 20)
152
+ for _ in expected {
153
+ queue.written(connection, characteristic: characteristic, success: true); clock.advance(0.005)
154
+ }
155
+ XCTAssertEqual(Array(written.dropFirst()), expected)
156
+ canvas.response(key: 4, payload: Data([0, 0xFD, 0, 0, 0]))
157
+ canvas.offer(content, scope: "captions:1")
158
+ XCTAssertEqual(written.count, expected.count + 1)
159
+ canvas.disconnected(); queue.reset()
160
+ }
161
+ }
@@ -117,6 +117,71 @@ final class VersionInfoResponseAccumulatorTests: XCTestCase {
117
117
  }
118
118
  }
119
119
 
120
+ func testFactoryAsg27CompletesAfterItsTwoChunks() {
121
+ // Exact January wire shape after Bridge normalization; firmware can be unknown.
122
+ for firmware in ["", "17.26.1.13"] {
123
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
124
+ _ = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "27", "appVersion": "27.0"]))
125
+ guard case let .complete(result) = accumulator.accept(chunk("version_info_2", nil, [
126
+ "buildNumber": "", "otaVersionUrl": "https://ota.example/live_version.json", "firmwareVersion": firmware,
127
+ ])) else { return XCTFail("ASG27 has no third chunk") }
128
+ XCTAssertEqual(result.buildNumber, "27")
129
+ XCTAssertEqual(result.otaVersionUrl, "https://ota.example/live_version.json")
130
+ XCTAssertEqual(result.firmwareVersion, firmware)
131
+ }
132
+ }
133
+
134
+ func testOtherLegacyBuildsStillRequireTheirThirdChunk() {
135
+ for build in ["", "26", "28", "31", "37", "42"] {
136
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
137
+ _ = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": build]))
138
+ guard case .waiting = accumulator.accept(chunk("version_info_2", nil, [
139
+ "otaVersionUrl": "https://ota.example/version.json",
140
+ ])) else { return XCTFail("Unknown or three-chunk builds must wait: " + build) }
141
+ guard case .complete = accumulator.accept(chunk("version_info_3", nil, ["besFirmwareVersion": "new"]))
142
+ else { return XCTFail("Legacy third chunk must complete") }
143
+ }
144
+ }
145
+
146
+ func testFactorySecondChunkCannotCompleteWithoutFirstChunkAndOtaUrl() {
147
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
148
+ let second = chunk("version_info_2", nil, ["otaVersionUrl": "https://ota.example/version.json"])
149
+ guard case .ignored = accumulator.accept(second) else { return XCTFail("Trailing chunk is stale") }
150
+ _ = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "27"]))
151
+ for values in [[:], ["otaVersionUrl": ""], ["otaVersionUrl": " \n"]] as [[String: Any]] {
152
+ guard case .waiting = accumulator.accept(chunk("version_info_2", nil, values))
153
+ else { return XCTFail("Incomplete second chunk cannot complete") }
154
+ }
155
+ guard case .complete = accumulator.accept(second) else { return XCTFail("Actual terminal chunk") }
156
+ }
157
+
158
+ func testSecondChunkCannotChangeBuildIntoFactoryProtocol() {
159
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
160
+ _ = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "31"]))
161
+ guard case .waiting = accumulator.accept(chunk("version_info_2", nil, [
162
+ "buildNumber": "27", "otaVersionUrl": "https://ota.example/version.json",
163
+ ])) else { return XCTFail("Only the first chunk declares the protocol") }
164
+ }
165
+
166
+ func testRepeatedFirstChunkClearsFactoryCompletionRule() {
167
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
168
+ _ = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "27"]))
169
+ _ = accumulator.accept(chunk("version_info_1", nil, ["buildNumber": "31"]))
170
+ guard case .waiting = accumulator.accept(chunk("version_info_2", nil, [
171
+ "otaVersionUrl": "https://ota.example/version.json",
172
+ ])) else { return XCTFail("The latest first chunk determines the protocol") }
173
+ }
174
+
175
+ func testFactoryBuildCannotBypassModernCorrelation() {
176
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
177
+ _ = accumulator.accept(chunk("version_info_1", "request-1", ["buildNumber": "27"]))
178
+ guard case .ignored = accumulator.accept(chunk("version_info_2", nil, [
179
+ "otaVersionUrl": "https://ota.example/version.json",
180
+ ])) else { return XCTFail("Uncorrelated terminal chunk cannot complete a modern request") }
181
+ guard case .complete = accumulator.accept(chunk("version_info_3", "request-1", ["besFirmwareVersion": "new"]))
182
+ else { return XCTFail("Modern completeness metadata remains authoritative") }
183
+ }
184
+
120
185
  func testModernFinalChunkWaitsForMissingFirstChunk() {
121
186
  let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
122
187
  guard case .waiting = accumulator.accept(chunk("version_info_3", "request-1", ["besFirmwareVersion": "new"])) else {