@mentra/bluetooth-sdk 3.2.1-dev.311 → 3.2.1

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 (133) hide show
  1. package/README.md +4 -4
  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 +6 -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 +129 -25
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -2
  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/requests/DisplayRequests.kt +1 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  18. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +101 -22
  19. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  20. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  29. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  31. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  39. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  40. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  41. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  42. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  43. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  45. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  48. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  50. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  51. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  52. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  53. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  54. package/build/BluetoothSdk.types.d.ts +13 -0
  55. package/build/BluetoothSdk.types.d.ts.map +1 -1
  56. package/build/BluetoothSdk.types.js +1 -0
  57. package/build/BluetoothSdk.types.js.map +1 -1
  58. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  59. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  60. package/build/_private/BluetoothSdkModule.js +3 -0
  61. package/build/_private/BluetoothSdkModule.js.map +1 -1
  62. package/build/generated/changelogCatalog.d.ts +2 -2
  63. package/build/generated/changelogCatalog.js +2 -2
  64. package/build/generated/changelogCatalog.js.map +1 -1
  65. package/build/generated/releaseMetadata.js +5 -5
  66. package/build/generated/releaseMetadata.js.map +1 -1
  67. package/build/index.d.ts.map +1 -1
  68. package/build/index.js +1 -0
  69. package/build/index.js.map +1 -1
  70. package/ios/BluetoothSdkModule.swift +9 -0
  71. package/ios/MentraBluetoothSDK.podspec +3 -2
  72. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  73. package/ios/Source/BluetoothSdkDefaults.swift +6 -1
  74. package/ios/Source/Bridge.swift +15 -2
  75. package/ios/Source/DeviceManager.swift +96 -63
  76. package/ios/Source/DeviceStore.swift +26 -7
  77. package/ios/Source/GeneratedChangelogCatalog.swift +2 -2
  78. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  79. package/ios/Source/MentraBluetoothSDK.swift +18 -0
  80. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  81. package/ios/Source/requests/DisplayRequests.swift +1 -1
  82. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  83. package/ios/Source/sgcs/Ar99.swift +1 -2
  84. package/ios/Source/sgcs/G1.swift +68 -38
  85. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  86. package/ios/Source/sgcs/G2.swift +261 -247
  87. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  88. package/ios/Source/sgcs/Mach1.swift +19 -7
  89. package/ios/Source/sgcs/MentraLive.swift +201 -156
  90. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  91. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  92. package/ios/Source/sgcs/MentraNex.swift +8 -31
  93. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  94. package/ios/Source/sgcs/Nimo.swift +280 -408
  95. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  96. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  97. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  98. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  99. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  100. package/ios/Source/sgcs/SGCManager.swift +24 -0
  101. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  102. package/ios/Source/status/DeviceStatus.swift +1 -0
  103. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  104. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  105. package/ios/Source/utils/G2Text.swift +11 -0
  106. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  107. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  108. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  109. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  110. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  111. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  112. package/ios/Tests/G1PairingTests.swift +162 -0
  113. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  114. package/ios/Tests/G2TextTests.swift +10 -0
  115. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  116. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  117. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  118. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  119. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  120. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  121. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  122. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  123. package/ios/Tests/VersionInfoCommandTests.swift +53 -0
  124. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  125. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  126. package/package.json +2 -2
  127. package/plugin/build/withIos.d.ts +2 -0
  128. package/plugin/build/withIos.js +12 -1
  129. package/src/BluetoothSdk.types.ts +14 -0
  130. package/src/_private/BluetoothSdkModule.ts +8 -0
  131. package/src/generated/changelogCatalog.ts +2 -2
  132. package/src/generated/releaseMetadata.ts +5 -5
  133. package/src/index.ts +1 -0
@@ -0,0 +1,269 @@
1
+ import Foundation
2
+ import zlib
3
+
4
+ /// Dynamic Layout V1, shared with the Android NIMO adapter. All limits are checked
5
+ /// before sending anything; an invalid scene leaves the last accepted scene intact.
6
+ enum NimoCanvasCodec {
7
+ static let width = 500
8
+ static let height = 220
9
+ static let maxStream = 12288
10
+ static let appId = 0xFD
11
+ static let lineHeight = 20
12
+
13
+ enum Invalid: Error { case value(String) }
14
+
15
+ static func require(_ condition: Bool, _ message: String = "Invalid canvas value") throws {
16
+ if !condition { throw Invalid.value(message) }
17
+ }
18
+
19
+ struct Object {
20
+ let type: Int
21
+ let payload: Data
22
+ var textBytes = 0
23
+ var pixels = 0
24
+ }
25
+
26
+ static func imageBytes(_ input: String) throws -> Data {
27
+ try require(input.utf8.count <= 2_800_000, "Encoded image exceeds limit")
28
+ var encoded = input
29
+ var mime: String?
30
+ for kind in ["png", "bmp", "jpeg"] where input.hasPrefix("data:image/\(kind);base64,") {
31
+ mime = kind
32
+ encoded = String(input.dropFirst("data:image/\(kind);base64,".count))
33
+ }
34
+ try require(!input.hasPrefix("data:") || mime != nil, "Unsupported image data URI")
35
+ try require(encoded.utf8.count % 4 == 0, "Base64 must be padded")
36
+ guard let data = Data(base64Encoded: encoded) else { throw Invalid.value("Invalid Base64") }
37
+ try require((24 ... 2_000_000).contains(data.count), "Image file exceeds limit")
38
+ let png = data.prefix(8) == Data([0x89, 0x50, 0x4E, 0x47, 13, 10, 26, 10])
39
+ let bmp = data.count >= 54 && data.prefix(2) == Data([0x42, 0x4D])
40
+ let jpeg = data.prefix(3) == Data([0xFF, 0xD8, 0xFF])
41
+ let kind: String? = png ? "png" : bmp ? "bmp" : jpeg ? "jpeg" : nil
42
+ try require(kind != nil && (mime == nil || mime == kind), "Image must be PNG, BMP or JPEG matching its MIME type")
43
+ return data
44
+ }
45
+
46
+ static func region(_ x: Int, _ y: Int, _ w: Int, _ h: Int) throws {
47
+ try require((0 ..< width).contains(x) && (0 ..< height).contains(y)
48
+ && (1 ... width).contains(w) && (1 ... height).contains(h))
49
+ try require(x + w <= width && y + h <= height, "Region exceeds 500x220")
50
+ }
51
+
52
+ static func line(_ x0: Int, _ y0: Int, _ x1: Int, _ y1: Int, stroke: Int = 1, intensity: Int = 255) throws -> Object {
53
+ try region(x0, y0, 1, 1)
54
+ try region(x1, y1, 1, 1)
55
+ try require((1 ... 32).contains(stroke) && (0 ... 255).contains(intensity))
56
+ return Object(type: 0, payload: words(x0, y0, x1, y1) + bytes(stroke, intensity))
57
+ }
58
+
59
+ static func rectangle(_ x: Int, _ y: Int, _ w: Int, _ h: Int, stroke: Int = 1,
60
+ radius: Int = 0, intensity: Int = 255, filled: Bool = false) throws -> Object
61
+ {
62
+ try region(x, y, w, h)
63
+ try require((0 ... 32).contains(stroke) && (0 ... 255).contains(radius) && (0 ... 255).contains(intensity))
64
+ return Object(type: 1, payload: words(x, y, w, h) + bytes(stroke, radius, intensity, filled ? 1 : 0))
65
+ }
66
+
67
+ static func circle(_ x: Int, _ y: Int, radius: Int, stroke: Int = 1, intensity: Int = 255, filled: Bool = false) throws -> Object {
68
+ try require((1 ... 110).contains(radius))
69
+ try region(x, y, 1, 1)
70
+ try require(x - radius >= 0 && y - radius >= 0 && x + radius < width && y + radius < height)
71
+ try require((0 ... 32).contains(stroke) && (0 ... 255).contains(intensity))
72
+ return Object(type: 2, payload: words(x, y, radius) + bytes(stroke, intensity, filled ? 1 : 0))
73
+ }
74
+
75
+ static func label(_ text: String, _ x: Int, _ y: Int, _ w: Int, _ h: Int,
76
+ font: Int = 0, language: Int = 0, intensity: Int = 255) throws -> Object
77
+ {
78
+ try region(x, y, w, h)
79
+ try require((0 ... 3).contains(font) && (0 ... 20).contains(language) && (0 ... 255).contains(intensity))
80
+ let utf8 = Data(text.utf8)
81
+ try require((1 ... 2048).contains(utf8.count) && !utf8.contains(0), "Label exceeds 2048 UTF-8 bytes or contains NUL")
82
+ return Object(type: 3, payload: words(x, y, w, h, language) + bytes(font, intensity) + words(utf8.count) + utf8,
83
+ textBytes: utf8.count)
84
+ }
85
+
86
+ /// Empty rows still consume vertical space. The host already wrapped each row.
87
+ static func textRows(_ text: String, _ x: Int, _ y: Int, _ w: Int, _ h: Int) throws -> [Object] {
88
+ try region(x, y, w, h)
89
+ try require(text.utf16.count <= 8192 && !text.utf8.contains(0))
90
+ let rows = text.components(separatedBy: "\n")
91
+ try require(rows.count <= 64)
92
+ return try rows.enumerated().compactMap { index, row in
93
+ let offset = index * lineHeight
94
+ guard offset < h, !row.isEmpty else { return nil }
95
+ return try label(row, x, y + offset, w, min(lineHeight, h - offset))
96
+ }
97
+ }
98
+
99
+ static func text(_ text: String, _ x: Int, _ y: Int, _ w: Int, _ h: Int, border: Int, radius: Int) throws -> [Object] {
100
+ try region(x, y, w, h)
101
+ try require((0 ... 32).contains(border) && (0 ... 255).contains(radius))
102
+ var objects: [Object] = []
103
+ if border > 0 { try objects.append(rectangle(x, y, w, h, stroke: border, radius: radius)) }
104
+ if !text.isEmpty, !text.contains("\n") {
105
+ try objects.append(label(text, x, y, w, h))
106
+ } else {
107
+ objects += try textRows(text, x, y, w, h)
108
+ }
109
+ return objects
110
+ }
111
+
112
+ /// 00=white, 11=black. Pack continuously across rows; pad with black.
113
+ static func pack2(_ gray: Data) throws -> Data {
114
+ try require((1 ... 110_000).contains(gray.count))
115
+ var output = Data(repeating: 0xFF, count: (gray.count + 3) / 4)
116
+ for (i, value) in gray.enumerated() {
117
+ let shift = 6 - 2 * (i % 4)
118
+ let code = min(3, (255 - Int(value) + 42) / 85)
119
+ output[i / 4] = (output[i / 4] & ~UInt8(3 << shift)) | UInt8(code << shift)
120
+ }
121
+ return output
122
+ }
123
+
124
+ static func rle(_ data: Data) throws -> Data {
125
+ try require(data.count <= 110_000)
126
+ let input = [UInt8](data)
127
+ var output = Data()
128
+ var i = 0
129
+ func run(_ start: Int) -> Int {
130
+ var n = 1
131
+ while start + n < input.count, n < 127, input[start + n] == input[start] {
132
+ n += 1
133
+ }
134
+ return n
135
+ }
136
+ while i < input.count {
137
+ let count = run(i)
138
+ if count >= 3 {
139
+ output.append(contentsOf: [UInt8(count), input[i]])
140
+ i += count
141
+ } else {
142
+ let start = i
143
+ i += count
144
+ while i < input.count, i - start < 127 {
145
+ let next = run(i)
146
+ if next >= 3 { break }
147
+ i += min(next, 127 - (i - start))
148
+ }
149
+ output.append(UInt8(0x80 | (i - start)))
150
+ output.append(contentsOf: input[start ..< i])
151
+ }
152
+ }
153
+ return output
154
+ }
155
+
156
+ static func bitmap(_ x: Int, _ y: Int, _ w: Int, _ h: Int, gray: Data) throws -> Object {
157
+ try region(x, y, w, h)
158
+ try require(gray.count == w * h)
159
+ let packed = try pack2(gray)
160
+ let runs = try rle(packed)
161
+ let compressed = try zlib(packed)
162
+ let compression = runs.count <= compressed.count ? 3 : 7
163
+ let body = compression == 3 ? runs : compressed
164
+ let packet = bytes(0) + words(w, h) + bytes(2, compression) + dword(packed.count) + dword(body.count) + body
165
+ try require(packet.count <= 65535)
166
+ return Object(type: 4, payload: words(x, y, w, h, 0, 1, packet.count) + packet, pixels: gray.count)
167
+ }
168
+
169
+ static func scene(_ elements: [SceneElement], decodeImage: (String, Int, Int) throws -> Data) throws -> Data {
170
+ try require(elements.count <= 64)
171
+ var pixels = 0
172
+ // Validate every region and the aggregate image budget before decoding any image.
173
+ for e in elements {
174
+ try region(Int(e.x), Int(e.y), Int(e.w), Int(e.h))
175
+ try require(["text", "rect", "image"].contains(e.type)
176
+ && (0 ... 32).contains(e.border) && (0 ... 255).contains(e.radius))
177
+ if e.type == "image" {
178
+ try require(e.data != nil)
179
+ pixels += Int(e.w) * Int(e.h)
180
+ try require(pixels <= 110_000, "Frame exceeds image pixel budget")
181
+ }
182
+ }
183
+ var objects: [Object] = []
184
+ for e in elements {
185
+ let (x, y, w, h) = (Int(e.x), Int(e.y), Int(e.w), Int(e.h))
186
+ switch e.type {
187
+ case "text":
188
+ if e.border > 0 { try objects.append(rectangle(x, y, w, h, stroke: Int(e.border), radius: Int(e.radius))) }
189
+ objects += try textRows(e.text ?? "", x, y, w, h)
190
+ case "rect": try objects.append(rectangle(x, y, w, h, stroke: max(1, Int(e.border)), radius: Int(e.radius)))
191
+ case "image": try objects.append(bitmap(x, y, w, h, gray: decodeImage(e.data!, w, h)))
192
+ default: break // Validated above.
193
+ }
194
+ try require(objects.count <= 64)
195
+ }
196
+ return try replace(objects)
197
+ }
198
+
199
+ static func replace(_ objects: [Object]) throws -> Data {
200
+ try require(objects.count <= 64, "More than 64 canvas objects")
201
+ try require(objects.reduce(0) { $0 + $1.textBytes } <= 8192, "Frame text exceeds 8192 bytes")
202
+ try require(objects.reduce(0) { $0 + $1.pixels } <= 110_000, "Frame exceeds image pixel budget")
203
+ try require(3 + objects.reduce(0) { $0 + 3 + $1.payload.count } <= maxStream - 8, "Frame exceeds reassembly budget")
204
+ var output = words(objects.count) + bytes(1)
205
+ for object in objects {
206
+ try require((0 ... 4).contains(object.type))
207
+ output += bytes(object.type) + words(object.payload.count) + object.payload
208
+ }
209
+ return output
210
+ }
211
+
212
+ static func frames(key: Int, content: Data = Data(), writeCapacity: Int) throws -> [Data] {
213
+ try require((20 ... 512).contains(writeCapacity))
214
+ let payload: Data
215
+ switch key {
216
+ case 1: payload = bytes(appId, 0)
217
+ case 3: payload = bytes(appId)
218
+ case 4: payload = bytes(appId, 0, 0, 0) + content
219
+ default: throw Invalid.value("Unsupported canvas command")
220
+ }
221
+ try require(payload.count + 4 <= maxStream)
222
+ let stream = bytes(7, key) + words(payload.count) + payload
223
+ let chunkSize = writeCapacity - 8
224
+ let count = (stream.count + chunkSize - 1) / chunkSize
225
+ return (0 ..< count).map { index in
226
+ let chunk = stream.subdata(in: index * chunkSize ..< min(stream.count, (index + 1) * chunkSize))
227
+ return bytes(0xBF, 0) + words(chunk.count, Int(nimoCrc16(chunk)), index == count - 1 ? 0 : index + 1) + chunk
228
+ }
229
+ }
230
+
231
+ /// Canvas responses count the status byte in the application payload length.
232
+ static func response(_ packet: Data) -> (key: Int, payload: Data)? {
233
+ let packet = Data(packet)
234
+ guard packet.count >= 13, packet[0] == 0xBF, packet[1] == 0 else { return nil }
235
+ func word(_ i: Int) -> Int {
236
+ Int(packet[i]) | (Int(packet[i + 1]) << 8)
237
+ }
238
+ guard word(2) == packet.count - 8, word(6) == 0, packet[8] == 7,
239
+ word(4) == Int(nimoCrc16(packet.subdata(in: 8 ..< packet.count))),
240
+ word(10) == packet.count - 12, [1, 3, 4].contains(Int(packet[9])) else { return nil }
241
+ return (Int(packet[9]), packet.subdata(in: 12 ..< packet.count))
242
+ }
243
+
244
+ private static func zlib(_ input: Data) throws -> Data {
245
+ var size = compressBound(uLong(input.count))
246
+ var output = Data(count: Int(size))
247
+ let status = output.withUnsafeMutableBytes { dst in
248
+ input.withUnsafeBytes { src in
249
+ compress2(dst.bindMemory(to: UInt8.self).baseAddress!, &size,
250
+ src.bindMemory(to: UInt8.self).baseAddress!, uLong(input.count), 6)
251
+ }
252
+ }
253
+ try require(status == Z_OK, "Image compression failed")
254
+ output.count = Int(size)
255
+ return output
256
+ }
257
+
258
+ private static func bytes(_ values: Int...) -> Data {
259
+ Data(values.map { UInt8(truncatingIfNeeded: $0) })
260
+ }
261
+
262
+ private static func words(_ values: Int...) -> Data {
263
+ Data(values.flatMap { [UInt8(truncatingIfNeeded: $0), UInt8(truncatingIfNeeded: $0 >> 8)] })
264
+ }
265
+
266
+ private static func dword(_ value: Int) -> Data {
267
+ bytes(value, value >> 8, value >> 16, value >> 24)
268
+ }
269
+ }
@@ -0,0 +1,233 @@
1
+ import Foundation
2
+
3
+ typealias NimoScheduler = (TimeInterval, @escaping () -> Void) -> () -> Void
4
+
5
+ /// All callers run on Main, including the injected scheduler and transport callbacks.
6
+ final class NimoCanvasCoordinator {
7
+ private final class Flight {
8
+ let key: Int
9
+ var finalStarted = false
10
+ var complete = false
11
+ var earlyAck: Data?
12
+ init(key: Int) {
13
+ self.key = key
14
+ }
15
+ }
16
+
17
+ private let schedule: NimoScheduler
18
+ private let writeCapacity: () -> Int
19
+ private let enqueue: ([Data], @escaping () -> Void, @escaping () -> Void) -> Bool
20
+ private let reconnect: (String) -> Void
21
+ private let rejected: (Int) -> Void
22
+ private let session = NimoCanvasSession()
23
+ private var flight: Flight?
24
+ private var cancelRetry: (() -> Void)?
25
+ private var cancelDeadline: (() -> Void)?
26
+
27
+ init(schedule: @escaping NimoScheduler, writeCapacity: @escaping () -> Int,
28
+ enqueue: @escaping ([Data], @escaping () -> Void, @escaping () -> Void) -> Bool,
29
+ reconnect: @escaping (String) -> Void, rejected: @escaping (Int) -> Void)
30
+ {
31
+ self.schedule = schedule; self.writeCapacity = writeCapacity; self.enqueue = enqueue
32
+ self.reconnect = reconnect; self.rejected = rejected
33
+ }
34
+
35
+ func offer(_ bytes: Data, scope: String, force: Bool = false) {
36
+ run(session.offer(bytes, scope: scope, force: force))
37
+ }
38
+
39
+ func activate() {
40
+ run(session.activate())
41
+ }
42
+
43
+ func readiness(_ value: Bool, confirmed: Bool = false) {
44
+ if !value { cancelRetry?(); cancelRetry = nil }
45
+ run(session.readiness(value, confirmed: confirmed))
46
+ }
47
+
48
+ func exit() {
49
+ cancelRetry?(); cancelRetry = nil
50
+ run(session.exit())
51
+ }
52
+
53
+ func nativeApp(_ appId: Int, entered: Bool) {
54
+ run(session.nativeApp(appId, entered: entered))
55
+ }
56
+
57
+ func disconnected() {
58
+ cancelRetry?(); cancelRetry = nil
59
+ cancelDeadline?(); cancelDeadline = nil
60
+ flight = nil
61
+ session.disconnected()
62
+ }
63
+
64
+ func response(key: Int, payload: Data) {
65
+ guard let current = flight, session.acceptsResponse(key: key, payload: payload) else { return }
66
+ if !current.complete {
67
+ if current.finalStarted, current.earlyAck == nil { current.earlyAck = payload }
68
+ return
69
+ }
70
+ cancelDeadline?(); cancelDeadline = nil
71
+ flight = nil
72
+ run(session.response(key: key, payload: payload))
73
+ }
74
+
75
+ private func run(_ actions: [NimoCanvasSession.Action]) {
76
+ for action in actions {
77
+ switch action {
78
+ case let .send(key, frame, ticket):
79
+ cancelRetry?(); cancelRetry = nil
80
+ cancelDeadline?()
81
+ let current = Flight(key: key)
82
+ flight = current
83
+ cancelDeadline = schedule(45) { [weak self] in
84
+ guard let self, self.flight === current else { return }
85
+ self.run(self.session.timeout(ticket))
86
+ }
87
+ do {
88
+ let frames = try NimoCanvasCodec.frames(key: key, content: frame, writeCapacity: writeCapacity())
89
+ let queued = enqueue(frames, { [weak self] in
90
+ if self?.flight === current { current.finalStarted = true }
91
+ }, { [weak self] in
92
+ guard let self, self.flight === current else { return }
93
+ current.complete = true
94
+ if let ack = current.earlyAck { self.response(key: key, payload: ack) }
95
+ })
96
+ if !queued, flight === current { disconnected(); reconnect("Canvas transport unavailable") }
97
+ } catch {
98
+ disconnected(); reconnect("Canvas framing failed: \(error)")
99
+ }
100
+ case let .reconnect(reason): disconnected(); reconnect(reason)
101
+ case let .rejected(status):
102
+ rejected(status)
103
+ if status == 7 {
104
+ cancelRetry?()
105
+ cancelRetry = schedule(1) { [weak self] in
106
+ guard let self else { return }
107
+ self.cancelRetry = nil
108
+ self.run(self.session.retryNotReady())
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ /// Serializes characteristic writes. A missing callback retires the link instead
117
+ /// of allowing a late callback to acknowledge a different fragment.
118
+ final class NimoWriteQueue<Characteristic: AnyObject> {
119
+ private final class Write {
120
+ let characteristic: Characteristic
121
+ let bytes: Data
122
+ let started: () -> Void
123
+ let completed: () -> Void
124
+ var callbackReceived = false
125
+ init(_ characteristic: Characteristic, _ bytes: Data, _ started: @escaping () -> Void, _ completed: @escaping () -> Void) {
126
+ self.characteristic = characteristic; self.bytes = bytes; self.started = started; self.completed = completed
127
+ }
128
+ }
129
+
130
+ private let schedule: NimoScheduler
131
+ private let write: (Characteristic, Data) -> Bool
132
+ private let failure: (String) -> Void
133
+ private var connection: AnyObject?
134
+ private var generation: UInt64 = 0
135
+ private var queue: [Write] = []
136
+ private var current: Write?
137
+ private var cancelDeadline: (() -> Void)?
138
+
139
+ init(schedule: @escaping NimoScheduler, write: @escaping (Characteristic, Data) -> Bool, failure: @escaping (String) -> Void) {
140
+ self.schedule = schedule; self.write = write; self.failure = failure
141
+ }
142
+
143
+ func connected(_ connection: AnyObject) {
144
+ reset(); self.connection = connection
145
+ }
146
+
147
+ func reset() {
148
+ generation &+= 1
149
+ connection = nil; current = nil; queue.removeAll()
150
+ cancelDeadline?(); cancelDeadline = nil
151
+ }
152
+
153
+ @discardableResult
154
+ func enqueue(_ connection: AnyObject, characteristic: Characteristic, frames: [Data],
155
+ finalStarted: @escaping () -> Void = {}, completed: @escaping () -> Void = {}) -> Bool
156
+ {
157
+ guard connection === self.connection, !frames.isEmpty else { return false }
158
+ guard queue.count + frames.count <= 1100 else { fail("Fragment chain exceeds write budget"); return false }
159
+ for (i, frame) in frames.enumerated() {
160
+ let last = i == frames.count - 1
161
+ queue.append(Write(characteristic, frame, last ? finalStarted : {}, last ? completed : {}))
162
+ }
163
+ drain()
164
+ return true
165
+ }
166
+
167
+ func written(_ connection: AnyObject, characteristic: Characteristic, success: Bool) {
168
+ guard connection === self.connection, let item = current,
169
+ characteristic === item.characteristic, !item.callbackReceived else { return }
170
+ item.callbackReceived = true
171
+ cancelDeadline?(); cancelDeadline = nil
172
+ guard success else { fail("GATT write failed"); return }
173
+ let expected = generation
174
+ _ = schedule(0.005) { [weak self] in
175
+ guard let self, self.generation == expected, self.current === item else { return }
176
+ self.current = nil
177
+ item.completed()
178
+ self.drain()
179
+ }
180
+ }
181
+
182
+ private func drain() {
183
+ guard connection != nil, current == nil, !queue.isEmpty else { return }
184
+ let item = queue.removeFirst()
185
+ current = item
186
+ let expected = generation
187
+ guard write(item.characteristic, item.bytes) else { fail("GATT transport unavailable"); return }
188
+ item.started()
189
+ cancelDeadline = schedule(3) { [weak self] in
190
+ guard let self, self.generation == expected, self.current === item, !item.callbackReceived else { return }
191
+ self.fail("GATT write timed out")
192
+ }
193
+ }
194
+
195
+ private func fail(_ reason: String) {
196
+ reset(); failure(reason)
197
+ }
198
+ }
199
+
200
+ /// Coalesces pending encodes and fences running results on scene change, Exit and disconnect.
201
+ final class NimoCanvasEncoder {
202
+ private let worker: NimoScheduler
203
+ private let main: NimoScheduler
204
+ private let failure: (Error) -> Void
205
+ private var generation: UInt64 = 0
206
+ private var cancelPending: (() -> Void)?
207
+
208
+ init(main: @escaping NimoScheduler, worker: @escaping NimoScheduler, failure: @escaping (Error) -> Void) {
209
+ self.main = main; self.worker = worker; self.failure = failure
210
+ }
211
+
212
+ /// submit/invalidate and delivery run on Main; the worker only reads its captured request.
213
+ func submit(encode: @escaping () throws -> Data, deliver: @escaping (Data) -> Void) {
214
+ invalidate()
215
+ let expected = generation
216
+ cancelPending = worker(0) { [weak self] in
217
+ let result = Result { try encode() }
218
+ guard let self else { return }
219
+ _ = self.main(0) { [weak self] in
220
+ guard let self, self.generation == expected else { return }
221
+ self.cancelPending = nil
222
+ switch result {
223
+ case let .success(bytes): deliver(bytes)
224
+ case let .failure(error): self.failure(error)
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ func invalidate() {
231
+ generation &+= 1; cancelPending?(); cancelPending = nil
232
+ }
233
+ }
@@ -0,0 +1,53 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+ import ImageIO
4
+
5
+ /// Bounded image decoding independent of UIKit/AppKit and the Bluetooth actor.
6
+ enum NimoCanvasImage {
7
+ static func source(_ input: String) throws -> CGImage {
8
+ let data = try NimoCanvasCodec.imageBytes(input)
9
+ guard let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
10
+ let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
11
+ let w = properties[kCGImagePropertyPixelWidth] as? Int,
12
+ let h = properties[kCGImagePropertyPixelHeight] as? Int
13
+ else {
14
+ throw NimoCanvasCodec.Invalid.value("Invalid image")
15
+ }
16
+ try NimoCanvasCodec.require((1 ... 4096).contains(w) && (1 ... 4096).contains(h))
17
+ try NimoCanvasCodec.require(w * h <= 4_000_000, "Decoded image exceeds limit")
18
+ guard let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
19
+ throw NimoCanvasCodec.Invalid.value("Image decode failed")
20
+ }
21
+ return image
22
+ }
23
+
24
+ static func decode(_ input: String, width: Int, height: Int) throws -> Data {
25
+ try grayscale(source(input), width: width, height: height)
26
+ }
27
+
28
+ static func grayscale(_ image: CGImage, width: Int, height: Int) throws -> Data {
29
+ try NimoCanvasCodec.region(0, 0, width, height)
30
+ guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8,
31
+ bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(),
32
+ bitmapInfo: CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue)
33
+ else {
34
+ throw NimoCanvasCodec.Invalid.value("Image allocation failed")
35
+ }
36
+ context.setFillColor(red: 0, green: 0, blue: 0, alpha: 1)
37
+ context.fill(CGRect(x: 0, y: 0, width: width, height: height))
38
+ context.interpolationQuality = .medium
39
+ context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
40
+ guard let buffer = context.data?.assumingMemoryBound(to: UInt8.self) else {
41
+ throw NimoCanvasCodec.Invalid.value("Image buffer unavailable")
42
+ }
43
+ var gray = Data(capacity: width * height)
44
+ for y in 0 ..< height {
45
+ for x in 0 ..< width {
46
+ let offset = y * context.bytesPerRow + x * 4
47
+ let luminance = (77 * Int(buffer[offset]) + 150 * Int(buffer[offset + 1]) + 29 * Int(buffer[offset + 2]) + 128) >> 8
48
+ gray.append(UInt8(luminance))
49
+ }
50
+ }
51
+ return gray
52
+ }
53
+ }