@mentra/bluetooth-sdk 3.3.0-dev.317 → 3.3.0-dev.320

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 (46) hide show
  1. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  2. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +37 -12
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +30 -13
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  6. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +3 -2
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +257 -395
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +225 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +168 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  14. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  15. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +557 -0
  16. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +308 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +270 -0
  21. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  22. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  23. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +194 -0
  24. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +199 -0
  25. package/build/generated/releaseMetadata.js +5 -5
  26. package/build/generated/releaseMetadata.js.map +1 -1
  27. package/ios/MentraBluetoothSDK.podspec +3 -2
  28. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  29. package/ios/Source/DeviceManager.swift +23 -10
  30. package/ios/Source/DeviceStore.swift +19 -7
  31. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  32. package/ios/Source/sgcs/G2.swift +2 -2
  33. package/ios/Source/sgcs/Nimo.swift +182 -371
  34. package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
  35. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +219 -0
  36. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  37. package/ios/Source/sgcs/NimoCanvasSession.swift +150 -0
  38. package/ios/Source/sgcs/SGCManager.swift +16 -0
  39. package/ios/Source/utils/G2Text.swift +11 -0
  40. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  41. package/ios/Tests/G2TextTests.swift +10 -0
  42. package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
  43. package/ios/Tests/NimoCanvasSessionTests.swift +112 -0
  44. package/ios/Tests/NimoCanvasTransportTests.swift +161 -0
  45. package/package.json +2 -2
  46. package/src/generated/releaseMetadata.ts +5 -5
@@ -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,219 @@
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 cancelDeadline: (() -> Void)?
25
+
26
+ init(schedule: @escaping NimoScheduler, writeCapacity: @escaping () -> Int,
27
+ enqueue: @escaping ([Data], @escaping () -> Void, @escaping () -> Void) -> Bool,
28
+ reconnect: @escaping (String) -> Void, rejected: @escaping (Int) -> Void)
29
+ {
30
+ self.schedule = schedule; self.writeCapacity = writeCapacity; self.enqueue = enqueue
31
+ self.reconnect = reconnect; self.rejected = rejected
32
+ }
33
+
34
+ func offer(_ bytes: Data, scope: String, force: Bool = false) {
35
+ run(session.offer(bytes, scope: scope, force: force))
36
+ }
37
+
38
+ func readiness(_ value: Bool, confirmed: Bool = false) {
39
+ run(session.readiness(value, confirmed: confirmed))
40
+ }
41
+
42
+ func exit() {
43
+ run(session.exit())
44
+ }
45
+
46
+ /// An expected Exit report must not discard a newer host encode queued behind Exit.
47
+ func nativeApp(_ appId: Int, entered: Bool) -> Bool {
48
+ let takeover = (appId == NimoCanvasCodec.appId && !entered) || (appId != NimoCanvasCodec.appId && entered)
49
+ let expectedExit = appId == NimoCanvasCodec.appId && !entered && flight?.key == 3
50
+ run(session.nativeApp(appId, entered: entered))
51
+ return takeover && !expectedExit
52
+ }
53
+
54
+ func disconnected() {
55
+ cancelDeadline?(); cancelDeadline = nil
56
+ flight = nil
57
+ session.disconnected()
58
+ }
59
+
60
+ func response(key: Int, payload: Data) {
61
+ guard let current = flight, session.acceptsResponse(key: key, payload: payload) else { return }
62
+ if !current.complete {
63
+ if current.finalStarted, current.earlyAck == nil { current.earlyAck = payload }
64
+ return
65
+ }
66
+ cancelDeadline?(); cancelDeadline = nil
67
+ flight = nil
68
+ run(session.response(key: key, payload: payload))
69
+ }
70
+
71
+ private func run(_ actions: [NimoCanvasSession.Action]) {
72
+ for action in actions {
73
+ switch action {
74
+ case let .send(key, frame, ticket):
75
+ cancelDeadline?()
76
+ let current = Flight(key: key)
77
+ flight = current
78
+ cancelDeadline = schedule(45) { [weak self] in
79
+ guard let self, self.flight === current else { return }
80
+ self.run(self.session.timeout(ticket))
81
+ }
82
+ do {
83
+ let frames = try NimoCanvasCodec.frames(key: key, content: frame, writeCapacity: writeCapacity())
84
+ let queued = enqueue(frames, { [weak self] in
85
+ if self?.flight === current { current.finalStarted = true }
86
+ }, { [weak self] in
87
+ guard let self, self.flight === current else { return }
88
+ current.complete = true
89
+ if let ack = current.earlyAck { self.response(key: key, payload: ack) }
90
+ })
91
+ if !queued, flight === current { disconnected(); reconnect("Canvas transport unavailable") }
92
+ } catch {
93
+ disconnected(); reconnect("Canvas framing failed: \(error)")
94
+ }
95
+ case let .reconnect(reason): disconnected(); reconnect(reason)
96
+ case let .rejected(status): rejected(status)
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ /// Serializes characteristic writes. A missing callback retires the link instead
103
+ /// of allowing a late callback to acknowledge a different fragment.
104
+ final class NimoWriteQueue<Characteristic: AnyObject> {
105
+ private final class Write {
106
+ let characteristic: Characteristic
107
+ let bytes: Data
108
+ let started: () -> Void
109
+ let completed: () -> Void
110
+ var callbackReceived = false
111
+ init(_ characteristic: Characteristic, _ bytes: Data, _ started: @escaping () -> Void, _ completed: @escaping () -> Void) {
112
+ self.characteristic = characteristic; self.bytes = bytes; self.started = started; self.completed = completed
113
+ }
114
+ }
115
+
116
+ private let schedule: NimoScheduler
117
+ private let write: (Characteristic, Data) -> Bool
118
+ private let failure: (String) -> Void
119
+ private var connection: AnyObject?
120
+ private var generation: UInt64 = 0
121
+ private var queue: [Write] = []
122
+ private var current: Write?
123
+ private var cancelDeadline: (() -> Void)?
124
+
125
+ init(schedule: @escaping NimoScheduler, write: @escaping (Characteristic, Data) -> Bool, failure: @escaping (String) -> Void) {
126
+ self.schedule = schedule; self.write = write; self.failure = failure
127
+ }
128
+
129
+ func connected(_ connection: AnyObject) {
130
+ reset(); self.connection = connection
131
+ }
132
+
133
+ func reset() {
134
+ generation &+= 1
135
+ connection = nil; current = nil; queue.removeAll()
136
+ cancelDeadline?(); cancelDeadline = nil
137
+ }
138
+
139
+ @discardableResult
140
+ func enqueue(_ connection: AnyObject, characteristic: Characteristic, frames: [Data],
141
+ finalStarted: @escaping () -> Void = {}, completed: @escaping () -> Void = {}) -> Bool
142
+ {
143
+ guard connection === self.connection, !frames.isEmpty else { return false }
144
+ guard queue.count + frames.count <= 1100 else { fail("Fragment chain exceeds write budget"); return false }
145
+ for (i, frame) in frames.enumerated() {
146
+ let last = i == frames.count - 1
147
+ queue.append(Write(characteristic, frame, last ? finalStarted : {}, last ? completed : {}))
148
+ }
149
+ drain()
150
+ return true
151
+ }
152
+
153
+ func written(_ connection: AnyObject, characteristic: Characteristic, success: Bool) {
154
+ guard connection === self.connection, let item = current,
155
+ characteristic === item.characteristic, !item.callbackReceived else { return }
156
+ item.callbackReceived = true
157
+ cancelDeadline?(); cancelDeadline = nil
158
+ guard success else { fail("GATT write failed"); return }
159
+ let expected = generation
160
+ _ = schedule(0.005) { [weak self] in
161
+ guard let self, self.generation == expected, self.current === item else { return }
162
+ self.current = nil
163
+ item.completed()
164
+ self.drain()
165
+ }
166
+ }
167
+
168
+ private func drain() {
169
+ guard connection != nil, current == nil, !queue.isEmpty else { return }
170
+ let item = queue.removeFirst()
171
+ current = item
172
+ let expected = generation
173
+ guard write(item.characteristic, item.bytes) else { fail("GATT transport unavailable"); return }
174
+ item.started()
175
+ cancelDeadline = schedule(3) { [weak self] in
176
+ guard let self, self.generation == expected, self.current === item, !item.callbackReceived else { return }
177
+ self.fail("GATT write timed out")
178
+ }
179
+ }
180
+
181
+ private func fail(_ reason: String) {
182
+ reset(); failure(reason)
183
+ }
184
+ }
185
+
186
+ /// Coalesces pending encodes and fences running results on scene change, Exit and disconnect.
187
+ final class NimoCanvasEncoder {
188
+ private let worker: NimoScheduler
189
+ private let main: NimoScheduler
190
+ private let failure: (Error) -> Void
191
+ private var generation: UInt64 = 0
192
+ private var cancelPending: (() -> Void)?
193
+
194
+ init(main: @escaping NimoScheduler, worker: @escaping NimoScheduler, failure: @escaping (Error) -> Void) {
195
+ self.main = main; self.worker = worker; self.failure = failure
196
+ }
197
+
198
+ /// submit/invalidate and delivery run on Main; the worker only reads its captured request.
199
+ func submit(encode: @escaping () throws -> Data, deliver: @escaping (Data) -> Void) {
200
+ invalidate()
201
+ let expected = generation
202
+ cancelPending = worker(0) { [weak self] in
203
+ let result = Result { try encode() }
204
+ guard let self else { return }
205
+ _ = self.main(0) { [weak self] in
206
+ guard let self, self.generation == expected else { return }
207
+ self.cancelPending = nil
208
+ switch result {
209
+ case let .success(bytes): deliver(bytes)
210
+ case let .failure(error): self.failure(error)
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ func invalidate() {
217
+ generation &+= 1; cancelPending?(); cancelPending = nil
218
+ }
219
+ }
@@ -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
+ }
@@ -0,0 +1,150 @@
1
+ import Foundation
2
+
3
+ /// Serializes launch/update/exit. A business ACK means acceptance, not visible pixels.
4
+ final class NimoCanvasSession {
5
+ enum Action: Equatable {
6
+ case send(key: Int, frame: Data, ticket: UInt64)
7
+ case reconnect(String)
8
+ case rejected(Int)
9
+ }
10
+
11
+ private struct Flight {
12
+ let key: Int
13
+ let frame: Data
14
+ let ticket: UInt64
15
+ }
16
+
17
+ private var ready = false
18
+ private var active = false
19
+ private var locked = false
20
+ private var launchBlocked = false
21
+ private var waitingReadiness = false
22
+ private var observedNotReady = false
23
+ private var readinessRetries = 0
24
+ private var exitRequested = false
25
+ private var desired: Data?
26
+ private var accepted: Data?
27
+ private var rejected: Data?
28
+ private var scope: String?
29
+ private var forceRevision: UInt64 = 0
30
+ private var sentRevision: UInt64 = 0
31
+ private var ticket: UInt64 = 0
32
+ private var flight: Flight?
33
+
34
+ func offer(_ frame: Data, scope: String, force: Bool = false) -> [Action] {
35
+ if self.scope != scope || force { forceRevision &+= 1 }
36
+ self.scope = scope
37
+ desired = frame
38
+ exitRequested = false
39
+ return pump()
40
+ }
41
+
42
+ /// Only a fresh heartbeat samples both TWS and peer Companion readiness.
43
+ func readiness(_ value: Bool, confirmed: Bool = false) -> [Action] {
44
+ if !value { readinessRetries = 0 }
45
+ if !value, flight != nil { return resetLink("Readiness lost during canvas command") }
46
+ if waitingReadiness {
47
+ if !value { observedNotReady = true }
48
+ else if !observedNotReady {
49
+ if !confirmed || readinessRetries >= 3 { return [] }
50
+ readinessRetries += 1
51
+ }
52
+ }
53
+ ready = value
54
+ if !value { active = false; accepted = nil }
55
+ if value { waitingReadiness = false }
56
+ return pump()
57
+ }
58
+
59
+ /// Keep only the latest desired scene across a new transport generation.
60
+ func disconnected() {
61
+ ready = false; active = false; locked = false; launchBlocked = false
62
+ waitingReadiness = false; observedNotReady = false; readinessRetries = 0
63
+ accepted = nil; rejected = nil; flight = nil
64
+ ticket &+= 1; forceRevision &+= 1
65
+ }
66
+
67
+ func exit() -> [Action] {
68
+ desired = nil; scope = nil; accepted = nil; rejected = nil; exitRequested = true
69
+ return pump()
70
+ }
71
+
72
+ func nativeApp(_ appId: Int, entered: Bool) -> [Action] {
73
+ if (appId == NimoCanvasCodec.appId && !entered) || (appId != NimoCanvasCodec.appId && entered) {
74
+ if flight?.key == 3 {
75
+ active = false; accepted = nil
76
+ if appId != NimoCanvasCodec.appId, entered { desired = nil; rejected = nil; scope = nil }
77
+ return []
78
+ }
79
+ let ambiguous = flight != nil
80
+ desired = nil; accepted = nil; rejected = nil; active = false; exitRequested = false; scope = nil
81
+ if ambiguous { return resetLink("Native app transition interrupted canvas command") }
82
+ }
83
+ return []
84
+ }
85
+
86
+ func acceptsResponse(key: Int, payload: Data) -> Bool {
87
+ guard let current = flight, current.key == key, let status = payload.first else { return false }
88
+ let echo = key == 4 ? Data([0, 0xFD, 0, 0, 0]) : Data([0, 0xFD])
89
+ if status == 0 { return payload == echo }
90
+ return payload.count == 1 || (payload.count == echo.count && payload.dropFirst() == echo.dropFirst())
91
+ }
92
+
93
+ func response(key: Int, payload: Data) -> [Action] {
94
+ guard acceptsResponse(key: key, payload: payload), let current = flight, let first = payload.first else { return [] }
95
+ let status = Int(first)
96
+ flight = nil
97
+ if status != 0 {
98
+ switch status {
99
+ case 7:
100
+ ready = false; waitingReadiness = true; observedNotReady = false; active = false; accepted = nil
101
+ case 8:
102
+ locked = true; active = false; accepted = nil
103
+ case 2:
104
+ return [.rejected(status)] + resetLink("Canvas device timeout is ambiguous")
105
+ default:
106
+ if key == 4 { rejected = current.frame }
107
+ else {
108
+ if key == 1 { launchBlocked = true } else { desired = nil }
109
+ active = false; exitRequested = false
110
+ }
111
+ }
112
+ return [.rejected(status)] + pump()
113
+ }
114
+ switch key {
115
+ case 1: active = true
116
+ case 4: accepted = current.frame; rejected = nil; readinessRetries = 0
117
+ case 3: active = false; accepted = nil; exitRequested = false
118
+ default: break
119
+ }
120
+ return pump()
121
+ }
122
+
123
+ func timeout(_ expectedTicket: UInt64) -> [Action] {
124
+ guard flight?.ticket == expectedTicket else { return [] }
125
+ return resetLink("Canvas ACK timeout; reconnect before reusing a command key")
126
+ }
127
+
128
+ private func resetLink(_ reason: String) -> [Action] {
129
+ ready = false; active = false; accepted = nil; flight = nil
130
+ ticket &+= 1; forceRevision &+= 1
131
+ return [.reconnect(reason)]
132
+ }
133
+
134
+ private func pump() -> [Action] {
135
+ guard ready, !locked, !launchBlocked, !waitingReadiness, flight == nil else { return [] }
136
+ let key: Int
137
+ if exitRequested, active { key = 3 }
138
+ else if exitRequested { exitRequested = false; return [] }
139
+ else if desired == nil { return [] }
140
+ else if !active { key = 1 }
141
+ else if desired == rejected { return [] }
142
+ else if desired == accepted, sentRevision == forceRevision { return [] }
143
+ else { key = 4 }
144
+ let frame = key == 4 ? desired! : Data()
145
+ if key == 4 { sentRevision = forceRevision }
146
+ ticket &+= 1
147
+ flight = Flight(key: key, frame: frame, ticket: ticket)
148
+ return [.send(key: key, frame: frame, ticket: ticket)]
149
+ }
150
+ }