@mentra/bluetooth-sdk 0.1.17 → 0.1.19
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/main/java/com/mentra/bluetoothsdk/Bridge.kt +52 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +119 -16
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +22 -21
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +457 -31
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +4 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +103 -68
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
- package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +7 -9
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
- package/build/BluetoothSdk.types.d.ts +12 -1
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/types/index.d.ts +3 -0
- package/build/types/index.d.ts.map +1 -0
- package/build/types/index.js +2 -0
- package/build/types/index.js.map +1 -0
- package/ios/LocalPhotoUploadServer.swift +78 -0
- package/ios/MentraPhotoReceiverModule.swift +10 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +63 -0
- package/ios/Source/DeviceManager.swift +134 -29
- package/ios/Source/DeviceStore.swift +0 -5
- package/ios/Source/MentraBluetoothSDK.swift +26 -23
- package/ios/Source/OtaManifest.swift +1 -1
- package/ios/Source/internal/BleTraceLogger.swift +192 -0
- package/ios/Source/sgcs/G2.swift +518 -11
- package/ios/Source/sgcs/MentraLive.swift +607 -39
- package/ios/Source/sgcs/MentraNex.swift +416 -32
- package/ios/Source/sgcs/SGCManager.swift +155 -0
- package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
- package/ios/Source/status/DeviceStatus.swift +0 -8
- package/ios/Source/utils/BleJsonCompact.swift +395 -0
- package/ios/Source/utils/BleWireProtocol.swift +92 -0
- package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
- package/ios/Source/utils/MessageChunker.swift +73 -0
- package/package.json +14 -7
- package/src/BluetoothSdk.types.ts +13 -1
- package/src/types/index.ts +7 -0
|
@@ -580,10 +580,6 @@ struct BluetoothStatus: CustomStringConvertible {
|
|
|
580
580
|
boolValue(values, "should_send_transcript") ?? false
|
|
581
581
|
}
|
|
582
582
|
|
|
583
|
-
var offlineCaptionsRunning: Bool {
|
|
584
|
-
boolValue(values, "offline_captions_running") ?? false
|
|
585
|
-
}
|
|
586
|
-
|
|
587
583
|
var localSttFallbackActive: Bool {
|
|
588
584
|
boolValue(values, "local_stt_fallback_active") ?? false
|
|
589
585
|
}
|
|
@@ -948,10 +944,6 @@ struct BluetoothStatusUpdate: CustomStringConvertible {
|
|
|
948
944
|
optionalBoolValue(values, "should_send_transcript")
|
|
949
945
|
}
|
|
950
946
|
|
|
951
|
-
var offlineCaptionsRunning: Bool? {
|
|
952
|
-
optionalBoolValue(values, "offline_captions_running")
|
|
953
|
-
}
|
|
954
|
-
|
|
955
947
|
var localSttFallbackActive: Bool? {
|
|
956
948
|
optionalBoolValue(values, "local_stt_fallback_active")
|
|
957
949
|
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// BLE Wire Protocol v2 JSON compaction: short keys, enum ints, omitted defaults, config diff.
|
|
4
|
+
enum BleJsonCompact {
|
|
5
|
+
static let keyResolvedConfigHash = "rch"
|
|
6
|
+
|
|
7
|
+
private static let longToShort: [String: String] = [
|
|
8
|
+
"type": "t",
|
|
9
|
+
"requestId": "r",
|
|
10
|
+
"timestamp": "ts",
|
|
11
|
+
"status": "s",
|
|
12
|
+
"kind": "k",
|
|
13
|
+
"source": "src",
|
|
14
|
+
"requestedCaptureConfig": "rcc",
|
|
15
|
+
"meteredPreview": "mp",
|
|
16
|
+
"exposureTimeNs": "etn",
|
|
17
|
+
"captureMetadata": "cm",
|
|
18
|
+
"aeStateName": "aes",
|
|
19
|
+
"transferMethod": "tm",
|
|
20
|
+
"manual": "m",
|
|
21
|
+
"reconnecting": "rc",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
private static let shortToLong: [String: String] = {
|
|
25
|
+
var map: [String: String] = [:]
|
|
26
|
+
for (longKey, shortKey) in longToShort {
|
|
27
|
+
map[shortKey] = longKey
|
|
28
|
+
}
|
|
29
|
+
return map
|
|
30
|
+
}()
|
|
31
|
+
|
|
32
|
+
private static let photoStatusToInt: [String: Int] = [
|
|
33
|
+
"accepted": 0, "queued": 1, "configuring": 2, "capturing": 3,
|
|
34
|
+
"captured": 4, "uploading": 5, "uploaded": 6, "failed": 7,
|
|
35
|
+
]
|
|
36
|
+
private static let photoStatusFromInt: [Int: String] = {
|
|
37
|
+
var map: [Int: String] = [:]
|
|
38
|
+
for (key, value) in photoStatusToInt { map[value] = key }
|
|
39
|
+
return map
|
|
40
|
+
}()
|
|
41
|
+
|
|
42
|
+
private static let kindToInt = ["lifecycle": 0, "snapshot": 1]
|
|
43
|
+
private static let kindFromInt: [Int: String] = [0: "lifecycle", 1: "snapshot"]
|
|
44
|
+
|
|
45
|
+
private static let sourceToInt = ["sdk": 0, "button": 1]
|
|
46
|
+
private static let sourceFromInt: [Int: String] = [0: "sdk", 1: "button"]
|
|
47
|
+
|
|
48
|
+
private static let aeStateToInt: [String: Int] = [
|
|
49
|
+
"CONVERGED": 0, "SEARCHING": 1, "INACTIVE": 2,
|
|
50
|
+
"LOCKED": 3, "FLASH_REQUIRED": 4, "PRECAPTURE": 5,
|
|
51
|
+
]
|
|
52
|
+
private static let aeStateFromInt: [Int: String] = {
|
|
53
|
+
var map: [Int: String] = [:]
|
|
54
|
+
for (key, value) in aeStateToInt { map[value] = key }
|
|
55
|
+
return map
|
|
56
|
+
}()
|
|
57
|
+
|
|
58
|
+
private static var sessionConnectEpochMs: Int64 = 0
|
|
59
|
+
private static var resolvedConfigSent = false
|
|
60
|
+
private static var currentSessionResolvedConfigHash: String?
|
|
61
|
+
private static var resolvedConfigByHash: [String: [String: Any]] = [:]
|
|
62
|
+
|
|
63
|
+
private static let highRoiMessageTypes: Set<String> = [
|
|
64
|
+
"photo_status",
|
|
65
|
+
"stream_status",
|
|
66
|
+
"wifi_scan_result",
|
|
67
|
+
"start_stream",
|
|
68
|
+
"photo_response",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
private static let chunkMessageTypes: Set<String> = ["ck", "chunked_msg"]
|
|
72
|
+
|
|
73
|
+
static func shouldCompactOutbound(_ messageType: String) -> Bool {
|
|
74
|
+
highRoiMessageTypes.contains(messageType)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
static func supportsCompactInbound(_ messageType: String) -> Bool {
|
|
78
|
+
!messageType.isEmpty
|
|
79
|
+
&& (highRoiMessageTypes.contains(messageType) || chunkMessageTypes.contains(messageType))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static func isCompactWireForm(_ json: [String: Any]) -> Bool {
|
|
83
|
+
json["t"] != nil && json["type"] == nil
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static func extractMessageType(_ json: [String: Any]) -> String {
|
|
87
|
+
if let type = stringValue(json["type"]) {
|
|
88
|
+
return type
|
|
89
|
+
}
|
|
90
|
+
if let compactType = stringValue(json["t"]) {
|
|
91
|
+
return compactType
|
|
92
|
+
}
|
|
93
|
+
return ""
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
static func resetSession() {
|
|
97
|
+
sessionConnectEpochMs = 0
|
|
98
|
+
resolvedConfigSent = false
|
|
99
|
+
currentSessionResolvedConfigHash = nil
|
|
100
|
+
resolvedConfigByHash.removeAll()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
static func markSessionConnected(epochMs: Int64) {
|
|
104
|
+
resetSession()
|
|
105
|
+
sessionConnectEpochMs = epochMs
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
static func isCameraCommandJson(_ jsonData: String) -> Bool {
|
|
109
|
+
jsonData.contains("cs_pho")
|
|
110
|
+
|| jsonData.contains("cs_cpho")
|
|
111
|
+
|| jsonData.contains("cs_vid")
|
|
112
|
+
|| jsonData.contains("\"type\":\"take_photo\"")
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
static func encode(_ json: [String: Any]) -> [String: Any] {
|
|
116
|
+
if isCameraCommandJson(jsonString(json) ?? "") {
|
|
117
|
+
return json
|
|
118
|
+
}
|
|
119
|
+
let messageType = extractMessageType(json)
|
|
120
|
+
if !shouldCompactOutbound(messageType) {
|
|
121
|
+
return json
|
|
122
|
+
}
|
|
123
|
+
return compactObject(json, topLevel: true)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
static func encode(jsonString: String) -> [String: Any]? {
|
|
127
|
+
guard let data = jsonString.data(using: .utf8),
|
|
128
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
129
|
+
else {
|
|
130
|
+
return nil
|
|
131
|
+
}
|
|
132
|
+
return encode(json)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
static func decode(_ json: [String: Any]) -> [String: Any] {
|
|
136
|
+
if isCameraCommandJson(jsonString(json) ?? "") {
|
|
137
|
+
return json
|
|
138
|
+
}
|
|
139
|
+
return expandObject(json, topLevel: true)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
static func decodeIfSupported(_ json: [String: Any]) -> [String: Any]? {
|
|
143
|
+
if isCameraCommandJson(jsonString(json) ?? "") {
|
|
144
|
+
return json
|
|
145
|
+
}
|
|
146
|
+
if !isCompactWireForm(json) {
|
|
147
|
+
return json
|
|
148
|
+
}
|
|
149
|
+
let compactType = stringValue(json["t"]) ?? ""
|
|
150
|
+
if !supportsCompactInbound(compactType) {
|
|
151
|
+
return nil
|
|
152
|
+
}
|
|
153
|
+
return expandObject(json, topLevel: true)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
static func decode(jsonString: String) -> [String: Any]? {
|
|
157
|
+
guard let data = jsonString.data(using: .utf8),
|
|
158
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
159
|
+
else {
|
|
160
|
+
return nil
|
|
161
|
+
}
|
|
162
|
+
return decode(json)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private static func compactObject(_ input: [String: Any], topLevel: Bool) -> [String: Any] {
|
|
166
|
+
var output: [String: Any] = [:]
|
|
167
|
+
let messageType = stringValue(input["type"]) ?? stringValue(input["t"]) ?? ""
|
|
168
|
+
var skipResolvedConfig = false
|
|
169
|
+
|
|
170
|
+
if topLevel, shouldDiffResolvedConfig(messageType),
|
|
171
|
+
let resolvedConfig = input["resolvedConfig"] as? [String: Any]
|
|
172
|
+
{
|
|
173
|
+
let hash = hashConfig(resolvedConfig)
|
|
174
|
+
resolvedConfigByHash[hash] = resolvedConfig
|
|
175
|
+
if resolvedConfigSent, hash == currentSessionResolvedConfigHash {
|
|
176
|
+
output[keyResolvedConfigHash] = hash
|
|
177
|
+
skipResolvedConfig = true
|
|
178
|
+
} else {
|
|
179
|
+
currentSessionResolvedConfigHash = hash
|
|
180
|
+
resolvedConfigSent = true
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (key, value) in input {
|
|
185
|
+
if skipResolvedConfig, key == "resolvedConfig" { continue }
|
|
186
|
+
let shortKey = longToShort[key] ?? key
|
|
187
|
+
guard let compacted = compactValue(longKey: key, value: value, messageType: messageType),
|
|
188
|
+
!shouldOmit(compacted)
|
|
189
|
+
else {
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
output[shortKey] = compacted
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if topLevel {
|
|
196
|
+
compactTimestamp(&output)
|
|
197
|
+
}
|
|
198
|
+
return output
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private static func shouldDiffResolvedConfig(_ messageType: String) -> Bool {
|
|
202
|
+
messageType == "photo_status" || messageType == "stream_status"
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private static func compactValue(longKey: String, value: Any, messageType: String) -> Any? {
|
|
206
|
+
if value is NSNull { return nil }
|
|
207
|
+
if let nested = value as? [String: Any] {
|
|
208
|
+
return compactObject(nested, topLevel: false)
|
|
209
|
+
}
|
|
210
|
+
if let array = value as? [Any] {
|
|
211
|
+
return array.map { item -> Any in
|
|
212
|
+
if let nested = item as? [String: Any] {
|
|
213
|
+
return compactObject(nested, topLevel: false)
|
|
214
|
+
}
|
|
215
|
+
return item
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if let boolValue = value as? Bool { return boolValue }
|
|
219
|
+
if let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() {
|
|
220
|
+
return number
|
|
221
|
+
}
|
|
222
|
+
if let str = value as? String {
|
|
223
|
+
if longKey == "status", messageType == "photo_status", let code = photoStatusToInt[str] {
|
|
224
|
+
return code
|
|
225
|
+
}
|
|
226
|
+
if longKey == "kind", let code = kindToInt[str] { return code }
|
|
227
|
+
if longKey == "source", let code = sourceToInt[str] { return code }
|
|
228
|
+
if longKey == "aeStateName", let code = aeStateToInt[str] { return code }
|
|
229
|
+
return str
|
|
230
|
+
}
|
|
231
|
+
return value
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private static func shouldOmit(_ value: Any) -> Bool {
|
|
235
|
+
if value is NSNull { return true }
|
|
236
|
+
if let boolValue = value as? Bool, !boolValue { return true }
|
|
237
|
+
if let dict = value as? [String: Any], dict.isEmpty { return true }
|
|
238
|
+
if let array = value as? [Any], array.isEmpty { return true }
|
|
239
|
+
return false
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private static func compactTimestamp(_ output: inout [String: Any]) {
|
|
243
|
+
let tsKey: String?
|
|
244
|
+
if output["timestamp"] != nil {
|
|
245
|
+
tsKey = "timestamp"
|
|
246
|
+
} else if output["ts"] != nil {
|
|
247
|
+
tsKey = "ts"
|
|
248
|
+
} else {
|
|
249
|
+
tsKey = nil
|
|
250
|
+
}
|
|
251
|
+
guard let key = tsKey else { return }
|
|
252
|
+
let absolute = int64Value(output[key]) ?? 0
|
|
253
|
+
guard absolute > 0 else { return }
|
|
254
|
+
if sessionConnectEpochMs <= 0 {
|
|
255
|
+
sessionConnectEpochMs = absolute
|
|
256
|
+
}
|
|
257
|
+
output["ts"] = absolute - sessionConnectEpochMs
|
|
258
|
+
if key != "ts" {
|
|
259
|
+
output.removeValue(forKey: key)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private static func expandObject(_ input: [String: Any], topLevel: Bool) -> [String: Any] {
|
|
264
|
+
var output: [String: Any] = [:]
|
|
265
|
+
let messageType = stringValue(input["type"]) ?? stringValue(input["t"]) ?? ""
|
|
266
|
+
|
|
267
|
+
for (key, value) in input {
|
|
268
|
+
if topLevel, key == "ts" {
|
|
269
|
+
output["ts"] = value
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
let longKey = shortToLong[key] ?? key
|
|
273
|
+
guard let expanded = expandValue(longKey: longKey, value: value, messageType: messageType) else {
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
output[longKey] = expanded
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if topLevel {
|
|
280
|
+
expandTimestamp(&output)
|
|
281
|
+
expandResolvedConfigHash(&output)
|
|
282
|
+
cacheResolvedConfigIfPresent(&output)
|
|
283
|
+
}
|
|
284
|
+
return output
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private static func cacheResolvedConfigIfPresent(_ output: inout [String: Any]) {
|
|
288
|
+
guard let resolvedConfig = output["resolvedConfig"] as? [String: Any] else { return }
|
|
289
|
+
let hash = hashConfig(resolvedConfig)
|
|
290
|
+
resolvedConfigByHash[hash] = resolvedConfig
|
|
291
|
+
currentSessionResolvedConfigHash = hash
|
|
292
|
+
resolvedConfigSent = true
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private static func expandResolvedConfigHash(_ output: inout [String: Any]) {
|
|
296
|
+
guard output["resolvedConfig"] == nil,
|
|
297
|
+
let hash = output[keyResolvedConfigHash] as? String,
|
|
298
|
+
let cached = resolvedConfigByHash[hash]
|
|
299
|
+
else {
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
output["resolvedConfig"] = cached
|
|
303
|
+
output.removeValue(forKey: keyResolvedConfigHash)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private static func expandValue(longKey: String, value: Any, messageType: String) -> Any? {
|
|
307
|
+
if value is NSNull { return nil }
|
|
308
|
+
if let nested = value as? [String: Any] {
|
|
309
|
+
return expandObject(nested, topLevel: false)
|
|
310
|
+
}
|
|
311
|
+
if let array = value as? [Any] {
|
|
312
|
+
return array.map { item -> Any in
|
|
313
|
+
if let nested = item as? [String: Any] {
|
|
314
|
+
return expandObject(nested, topLevel: false)
|
|
315
|
+
}
|
|
316
|
+
return item
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if longKey == "status", messageType == "photo_status", let code = value as? Int {
|
|
320
|
+
return photoStatusFromInt[code] ?? value
|
|
321
|
+
}
|
|
322
|
+
if longKey == "kind", let code = value as? Int {
|
|
323
|
+
return kindFromInt[code] ?? value
|
|
324
|
+
}
|
|
325
|
+
if longKey == "source", let code = value as? Int {
|
|
326
|
+
return sourceFromInt[code] ?? value
|
|
327
|
+
}
|
|
328
|
+
if longKey == "aeStateName", let code = value as? Int {
|
|
329
|
+
return aeStateFromInt[code] ?? value
|
|
330
|
+
}
|
|
331
|
+
return value
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private static func expandTimestamp(_ output: inout [String: Any]) {
|
|
335
|
+
guard output["timestamp"] == nil, let delta = int64Value(output["ts"]) else { return }
|
|
336
|
+
let absolute = sessionConnectEpochMs > 0 ? sessionConnectEpochMs + delta : delta
|
|
337
|
+
output["timestamp"] = absolute
|
|
338
|
+
output.removeValue(forKey: "ts")
|
|
339
|
+
if sessionConnectEpochMs <= 0 {
|
|
340
|
+
sessionConnectEpochMs = absolute - delta
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
static func hashConfig(_ config: [String: Any]) -> String {
|
|
345
|
+
fnv1a32Hex(canonicalJsonString(config))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private static func canonicalJsonString(_ object: [String: Any]) -> String {
|
|
349
|
+
let sortedKeys = object.keys.sorted()
|
|
350
|
+
var parts: [String] = []
|
|
351
|
+
for key in sortedKeys {
|
|
352
|
+
guard let value = object[key] else { continue }
|
|
353
|
+
let encodedValue: String
|
|
354
|
+
if let nested = value as? [String: Any] {
|
|
355
|
+
encodedValue = canonicalJsonString(nested)
|
|
356
|
+
} else if let str = value as? String {
|
|
357
|
+
encodedValue = "\"\(str)\""
|
|
358
|
+
} else if let boolValue = value as? Bool {
|
|
359
|
+
encodedValue = boolValue ? "true" : "false"
|
|
360
|
+
} else if let number = value as? NSNumber {
|
|
361
|
+
encodedValue = "\(number)"
|
|
362
|
+
} else {
|
|
363
|
+
encodedValue = "null"
|
|
364
|
+
}
|
|
365
|
+
parts.append("\"\(key)\":\(encodedValue)")
|
|
366
|
+
}
|
|
367
|
+
return "{\(parts.joined(separator: ","))}"
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private static func fnv1a32Hex(_ value: String) -> String {
|
|
371
|
+
var hash: UInt32 = 0x811c9dc5
|
|
372
|
+
for byte in value.utf8 {
|
|
373
|
+
hash ^= UInt32(byte)
|
|
374
|
+
hash = hash &* 0x01000193
|
|
375
|
+
}
|
|
376
|
+
return String(format: "%08x", hash)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private static func stringValue(_ value: Any?) -> String? {
|
|
380
|
+
if let str = value as? String { return str }
|
|
381
|
+
if let number = value as? NSNumber { return number.stringValue }
|
|
382
|
+
return nil
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private static func int64Value(_ value: Any?) -> Int64? {
|
|
386
|
+
if let number = value as? NSNumber { return number.int64Value }
|
|
387
|
+
if let str = value as? String, let parsed = Int64(str) { return parsed }
|
|
388
|
+
return nil
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private static func jsonString(_ json: [String: Any]) -> String? {
|
|
392
|
+
guard let data = try? JSONSerialization.data(withJSONObject: json) else { return nil }
|
|
393
|
+
return String(data: data, encoding: .utf8)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// BLE Wire Protocol v2 shared constants and binary frame helpers.
|
|
4
|
+
/// All multi-byte fields use little-endian byte order.
|
|
5
|
+
enum BleWireProtocol {
|
|
6
|
+
static let cmdTypeBinaryMsg: UInt8 = 0x40
|
|
7
|
+
|
|
8
|
+
static let flagFirstFrag: UInt8 = 0x01
|
|
9
|
+
static let flagLastFrag: UInt8 = 0x02
|
|
10
|
+
static let flagWake: UInt8 = 0x04
|
|
11
|
+
static let flagHandshake: UInt8 = 0x08
|
|
12
|
+
static let flagAckRequested: UInt8 = 0x10
|
|
13
|
+
|
|
14
|
+
static let binaryHeaderSize = 7
|
|
15
|
+
static let maxFragmentPayload = 480
|
|
16
|
+
static let mtuTarget = 509
|
|
17
|
+
|
|
18
|
+
static let handshakePayloadV2 = "v2"
|
|
19
|
+
static let protocolV1 = 1
|
|
20
|
+
static let protocolV2 = 2
|
|
21
|
+
|
|
22
|
+
struct BinaryFragmentInfo {
|
|
23
|
+
let flags: UInt8
|
|
24
|
+
let msgId: UInt16
|
|
25
|
+
let fragIdx: UInt8
|
|
26
|
+
let fragCount: UInt8
|
|
27
|
+
let payload: Data
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static func isBinaryFrame(_ data: Data) -> Bool {
|
|
31
|
+
guard data.count >= 7 else { return false }
|
|
32
|
+
let bytes = [UInt8](data)
|
|
33
|
+
return bytes[0] == 0x23 && bytes[1] == 0x23 && bytes[2] == cmdTypeBinaryMsg
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static func packBinaryFragment(
|
|
37
|
+
flags: UInt8,
|
|
38
|
+
msgId: UInt16,
|
|
39
|
+
fragIdx: UInt8,
|
|
40
|
+
fragCount: UInt8,
|
|
41
|
+
payload: Data
|
|
42
|
+
) -> Data? {
|
|
43
|
+
var inner = Data(capacity: binaryHeaderSize + payload.count)
|
|
44
|
+
inner.append(flags)
|
|
45
|
+
inner.append(UInt8(msgId & 0xFF))
|
|
46
|
+
inner.append(UInt8((msgId >> 8) & 0xFF))
|
|
47
|
+
inner.append(fragIdx)
|
|
48
|
+
inner.append(fragCount)
|
|
49
|
+
let payloadLen = UInt16(payload.count)
|
|
50
|
+
inner.append(UInt8(payloadLen & 0xFF))
|
|
51
|
+
inner.append(UInt8((payloadLen >> 8) & 0xFF))
|
|
52
|
+
inner.append(payload)
|
|
53
|
+
return packDataToK900(inner, cmdType: cmdTypeBinaryMsg)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static func extractBinaryFragmentInfo(_ frame: Data) -> BinaryFragmentInfo? {
|
|
57
|
+
guard isBinaryFrame(frame) else { return nil }
|
|
58
|
+
let bytes = [UInt8](frame)
|
|
59
|
+
let innerLen = Int(bytes[3]) | (Int(bytes[4]) << 8)
|
|
60
|
+
guard innerLen >= binaryHeaderSize, frame.count >= 7 + innerLen else { return nil }
|
|
61
|
+
|
|
62
|
+
let offset = 5
|
|
63
|
+
let payloadLen = Int(bytes[offset + 5]) | (Int(bytes[offset + 6]) << 8)
|
|
64
|
+
guard binaryHeaderSize + payloadLen <= innerLen else { return nil }
|
|
65
|
+
|
|
66
|
+
let payload = frame.subdata(in: (offset + binaryHeaderSize) ..< (offset + binaryHeaderSize + payloadLen))
|
|
67
|
+
return BinaryFragmentInfo(
|
|
68
|
+
flags: bytes[offset],
|
|
69
|
+
msgId: UInt16(bytes[offset + 1]) | (UInt16(bytes[offset + 2]) << 8),
|
|
70
|
+
fragIdx: bytes[offset + 3],
|
|
71
|
+
fragCount: bytes[offset + 4],
|
|
72
|
+
payload: payload
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
static func isHandshakeV2(_ info: BinaryFragmentInfo) -> Bool {
|
|
77
|
+
guard (info.flags & flagHandshake) != 0 else { return false }
|
|
78
|
+
return String(data: info.payload, encoding: .utf8) == handshakePayloadV2
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private static func packDataToK900(_ data: Data, cmdType: UInt8) -> Data? {
|
|
82
|
+
var result = Data(capacity: data.count + 7)
|
|
83
|
+
result.append(contentsOf: [0x23, 0x23])
|
|
84
|
+
result.append(cmdType)
|
|
85
|
+
let dataLength = UInt16(data.count)
|
|
86
|
+
result.append(UInt8(dataLength & 0xFF))
|
|
87
|
+
result.append(UInt8((dataLength >> 8) & 0xFF))
|
|
88
|
+
result.append(data)
|
|
89
|
+
result.append(contentsOf: [0x24, 0x24])
|
|
90
|
+
return result
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -6,6 +6,46 @@ final class MessageChunkReassembler {
|
|
|
6
6
|
|
|
7
7
|
private var activeSessions: [String: ChunkSession] = [:]
|
|
8
8
|
|
|
9
|
+
private var activeBinarySessions: [UInt16: BinarySession] = [:]
|
|
10
|
+
|
|
11
|
+
func addBinaryFragment(msgId: UInt16, fragIdx: Int, fragCount: Int, data: Data) -> Data? {
|
|
12
|
+
cleanupTimedOutSessions()
|
|
13
|
+
|
|
14
|
+
guard fragCount > 0, fragIdx >= 0, fragIdx < fragCount else {
|
|
15
|
+
print("MessageChunkReassembler: Dropping invalid binary fragment for msgId \(msgId)")
|
|
16
|
+
return nil
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if let existing = activeBinarySessions[msgId], existing.fragCount != fragCount {
|
|
20
|
+
activeBinarySessions.removeValue(forKey: msgId)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if activeBinarySessions.count >= Self.maxConcurrentSessions,
|
|
24
|
+
activeBinarySessions[msgId] == nil
|
|
25
|
+
{
|
|
26
|
+
removeOldestBinarySession()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let isNewSession = activeBinarySessions[msgId] == nil
|
|
30
|
+
let session = activeBinarySessions[msgId] ?? BinarySession(msgId: msgId, fragCount: fragCount)
|
|
31
|
+
|
|
32
|
+
guard session.addFragment(index: fragIdx, data: data) else {
|
|
33
|
+
return nil
|
|
34
|
+
}
|
|
35
|
+
if isNewSession {
|
|
36
|
+
activeBinarySessions[msgId] = session
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
guard session.isComplete else {
|
|
40
|
+
return nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let reassembled = session.reassemble()
|
|
44
|
+
activeBinarySessions.removeValue(forKey: msgId)
|
|
45
|
+
print("MessageChunkReassembler: Reassembled \(reassembled.count) bytes from \(fragCount) binary fragments")
|
|
46
|
+
return reassembled
|
|
47
|
+
}
|
|
48
|
+
|
|
9
49
|
func addChunk(_ info: MessageChunker.ChunkInfo) -> String? {
|
|
10
50
|
cleanupTimedOutSessions()
|
|
11
51
|
|
|
@@ -57,6 +97,7 @@ final class MessageChunkReassembler {
|
|
|
57
97
|
|
|
58
98
|
func clear() {
|
|
59
99
|
activeSessions.removeAll()
|
|
100
|
+
activeBinarySessions.removeAll()
|
|
60
101
|
}
|
|
61
102
|
|
|
62
103
|
private func cleanupTimedOutSessions() {
|
|
@@ -64,6 +105,16 @@ final class MessageChunkReassembler {
|
|
|
64
105
|
activeSessions = activeSessions.filter { entry in
|
|
65
106
|
now - entry.value.createdAtMs <= Self.chunkTimeoutMs
|
|
66
107
|
}
|
|
108
|
+
activeBinarySessions = activeBinarySessions.filter { entry in
|
|
109
|
+
now - entry.value.createdAtMs <= Self.chunkTimeoutMs
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private func removeOldestBinarySession() {
|
|
114
|
+
guard let oldest = activeBinarySessions.min(by: { $0.value.createdAtMs < $1.value.createdAtMs }) else {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
activeBinarySessions.removeValue(forKey: oldest.key)
|
|
67
118
|
}
|
|
68
119
|
|
|
69
120
|
private func removeOldestSession() {
|
|
@@ -105,4 +156,35 @@ final class MessageChunkReassembler {
|
|
|
105
156
|
return result
|
|
106
157
|
}
|
|
107
158
|
}
|
|
159
|
+
|
|
160
|
+
private final class BinarySession {
|
|
161
|
+
let msgId: UInt16
|
|
162
|
+
let fragCount: Int
|
|
163
|
+
let createdAtMs: TimeInterval
|
|
164
|
+
private var fragments: [Int: Data] = [:]
|
|
165
|
+
|
|
166
|
+
init(msgId: UInt16, fragCount: Int) {
|
|
167
|
+
self.msgId = msgId
|
|
168
|
+
self.fragCount = fragCount
|
|
169
|
+
self.createdAtMs = Date().timeIntervalSince1970 * 1000
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func addFragment(index: Int, data: Data) -> Bool {
|
|
173
|
+
guard index >= 0, index < fragCount else { return false }
|
|
174
|
+
fragments[index] = data
|
|
175
|
+
return true
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
var isComplete: Bool {
|
|
179
|
+
fragments.count == fragCount
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func reassemble() -> Data {
|
|
183
|
+
var result = Data()
|
|
184
|
+
for index in 0 ..< fragCount {
|
|
185
|
+
result.append(fragments[index] ?? Data())
|
|
186
|
+
}
|
|
187
|
+
return result
|
|
188
|
+
}
|
|
189
|
+
}
|
|
108
190
|
}
|
|
@@ -24,6 +24,8 @@ class MessageChunker {
|
|
|
24
24
|
private static let MIN_CHUNK_DATA_SIZE = 4
|
|
25
25
|
private static let MAX_PACKED_CHUNK_SIZE = 253
|
|
26
26
|
private static let K900_FRAME_OVERHEAD = 7
|
|
27
|
+
private static let MAX_BINARY_FRAGMENT_PAYLOAD = BleWireProtocol.maxFragmentPayload
|
|
28
|
+
private static let MAX_BINARY_FRAME_SIZE = BleWireProtocol.mtuTarget
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* Check if a message needs to be chunked
|
|
@@ -204,6 +206,77 @@ class MessageChunker {
|
|
|
204
206
|
)
|
|
205
207
|
}
|
|
206
208
|
|
|
209
|
+
static func needsBinaryFragmenting(_ payload: Data) -> Bool {
|
|
210
|
+
payload.count > MAX_BINARY_FRAGMENT_PAYLOAD
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
static func needsBinaryFragmenting(_ json: String) -> Bool {
|
|
214
|
+
guard let data = json.data(using: .utf8) else { return false }
|
|
215
|
+
return needsBinaryFragmenting(data)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
struct BinaryFragment {
|
|
219
|
+
let flags: UInt8
|
|
220
|
+
let msgId: UInt16
|
|
221
|
+
let fragIdx: UInt8
|
|
222
|
+
let fragCount: UInt8
|
|
223
|
+
let payload: Data
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
static func createBinaryFragments(
|
|
227
|
+
payload: Data,
|
|
228
|
+
msgId: UInt16 = 0,
|
|
229
|
+
wakeUp: Bool = false,
|
|
230
|
+
ackRequested: Bool = false
|
|
231
|
+
) -> [BinaryFragment] {
|
|
232
|
+
let fragCount = max(1, (payload.count + MAX_BINARY_FRAGMENT_PAYLOAD - 1) / MAX_BINARY_FRAGMENT_PAYLOAD)
|
|
233
|
+
guard fragCount <= 255 else { return [] }
|
|
234
|
+
|
|
235
|
+
var fragments: [BinaryFragment] = []
|
|
236
|
+
for i in 0 ..< fragCount {
|
|
237
|
+
let offset = i * MAX_BINARY_FRAGMENT_PAYLOAD
|
|
238
|
+
let end = min(offset + MAX_BINARY_FRAGMENT_PAYLOAD, payload.count)
|
|
239
|
+
let fragPayload = payload.subdata(in: offset ..< end)
|
|
240
|
+
|
|
241
|
+
var flags: UInt8 = 0
|
|
242
|
+
if i == 0 { flags |= BleWireProtocol.flagFirstFrag }
|
|
243
|
+
if i == fragCount - 1 { flags |= BleWireProtocol.flagLastFrag }
|
|
244
|
+
if wakeUp, i == 0 { flags |= BleWireProtocol.flagWake }
|
|
245
|
+
if ackRequested, i == fragCount - 1 { flags |= BleWireProtocol.flagAckRequested }
|
|
246
|
+
|
|
247
|
+
fragments.append(
|
|
248
|
+
BinaryFragment(
|
|
249
|
+
flags: flags,
|
|
250
|
+
msgId: msgId,
|
|
251
|
+
fragIdx: UInt8(i),
|
|
252
|
+
fragCount: UInt8(fragCount),
|
|
253
|
+
payload: fragPayload
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
print("MessageChunker: Created \(fragments.count) binary fragments for \(payload.count) bytes")
|
|
259
|
+
return fragments
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
static func allBinaryFragmentsFit(_ fragments: [BinaryFragment]) -> Bool {
|
|
263
|
+
for fragment in fragments {
|
|
264
|
+
guard let packed = BleWireProtocol.packBinaryFragment(
|
|
265
|
+
flags: fragment.flags,
|
|
266
|
+
msgId: fragment.msgId,
|
|
267
|
+
fragIdx: fragment.fragIdx,
|
|
268
|
+
fragCount: fragment.fragCount,
|
|
269
|
+
payload: fragment.payload
|
|
270
|
+
) else {
|
|
271
|
+
return false
|
|
272
|
+
}
|
|
273
|
+
if packed.count > MAX_BINARY_FRAME_SIZE {
|
|
274
|
+
return false
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return true
|
|
278
|
+
}
|
|
279
|
+
|
|
207
280
|
/**
|
|
208
281
|
* Container for chunk information
|
|
209
282
|
*/
|