@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.
- package/README.md +1 -1
- package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
- package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +67 -12
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +64 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +73 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +257 -395
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +245 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +225 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +168 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
- package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +557 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +220 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +308 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +270 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +194 -0
- package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +199 -0
- package/build/BluetoothSdk.types.d.ts +11 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +2 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +3 -0
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +1 -1
- package/build/generated/changelogCatalog.js +1 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +5 -0
- package/ios/MentraBluetoothSDK.podspec +3 -2
- package/ios/Source/BluetoothSdkDefaults.swift +6 -1
- package/ios/Source/Bridge.swift +15 -2
- package/ios/Source/DeviceManager.swift +23 -10
- package/ios/Source/DeviceStore.swift +26 -7
- package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +4 -0
- package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
- package/ios/Source/sgcs/G2.swift +2 -2
- package/ios/Source/sgcs/MentraLive.swift +66 -3
- package/ios/Source/sgcs/Nimo.swift +182 -371
- package/ios/Source/sgcs/NimoCanvasCodec.swift +269 -0
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +219 -0
- package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
- package/ios/Source/sgcs/NimoCanvasSession.swift +150 -0
- package/ios/Source/sgcs/SGCManager.swift +24 -0
- package/ios/Source/utils/G2Text.swift +11 -0
- package/ios/Tests/DashboardRenderingTests.swift +31 -0
- package/ios/Tests/G2TextTests.swift +10 -0
- package/ios/Tests/NimoCanvasCodecTests.swift +204 -0
- package/ios/Tests/NimoCanvasSessionTests.swift +112 -0
- package/ios/Tests/NimoCanvasTransportTests.swift +161 -0
- package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +11 -0
- package/src/_private/BluetoothSdkModule.ts +6 -0
- package/src/generated/changelogCatalog.ts +1 -1
- package/src/generated/releaseMetadata.ts +5 -5
- 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,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
|
+
}
|