@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.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +52 -4
  3. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +119 -16
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +0 -6
  5. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +22 -21
  6. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +1 -1
  7. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +488 -15
  8. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +457 -31
  9. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +361 -67
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +7501 -3704
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +134 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +0 -6
  14. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +493 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +4 -1
  16. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +4 -3
  18. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +103 -68
  20. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunkReassembler.java +106 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +93 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +111 -11
  23. package/android/src/test/java/com/mentra/bluetoothsdk/camera/PhotoRequestTest.kt +7 -9
  24. package/android/src/test/java/com/mentra/bluetoothsdk/utils/AvifExifStripperTest.java +17 -1
  25. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +158 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  29. package/build/BluetoothSdk.types.d.ts +12 -1
  30. package/build/BluetoothSdk.types.d.ts.map +1 -1
  31. package/build/BluetoothSdk.types.js.map +1 -1
  32. package/build/types/index.d.ts +3 -0
  33. package/build/types/index.d.ts.map +1 -0
  34. package/build/types/index.js +2 -0
  35. package/build/types/index.js.map +1 -0
  36. package/ios/LocalPhotoUploadServer.swift +78 -0
  37. package/ios/MentraPhotoReceiverModule.swift +10 -0
  38. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  39. package/ios/Source/Bridge.swift +63 -0
  40. package/ios/Source/DeviceManager.swift +134 -29
  41. package/ios/Source/DeviceStore.swift +0 -5
  42. package/ios/Source/MentraBluetoothSDK.swift +26 -23
  43. package/ios/Source/OtaManifest.swift +1 -1
  44. package/ios/Source/internal/BleTraceLogger.swift +192 -0
  45. package/ios/Source/sgcs/G2.swift +518 -11
  46. package/ios/Source/sgcs/MentraLive.swift +607 -39
  47. package/ios/Source/sgcs/MentraNex.swift +416 -32
  48. package/ios/Source/sgcs/SGCManager.swift +155 -0
  49. package/ios/Source/sgcs/mentraos_ble.pb.swift +726 -122
  50. package/ios/Source/status/DeviceStatus.swift +0 -8
  51. package/ios/Source/utils/BleJsonCompact.swift +395 -0
  52. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  53. package/ios/Source/utils/MessageChunkReassembler.swift +82 -0
  54. package/ios/Source/utils/MessageChunker.swift +73 -0
  55. package/package.json +14 -7
  56. package/src/BluetoothSdk.types.ts +13 -1
  57. 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
  */