@mentra/bluetooth-sdk 0.1.17 → 0.1.18

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 CHANGED
@@ -475,7 +475,7 @@ For bare native iOS apps, use the public SwiftPM repository:
475
475
  https://github.com/Mentra-Community/mentra-bluetooth-sdk-ios.git
476
476
  ```
477
477
 
478
- Select version `0.1.16`, then add the `MentraBluetoothSDK` product to your app target.
478
+ Select version `0.1.18`, then add the `MentraBluetoothSDK` product to your app target.
479
479
 
480
480
  For local SDK development, add this package folder directly in Xcode:
481
481
 
@@ -30,6 +30,18 @@ public class Bridge private constructor() {
30
30
  private const val MIC_CHANNELS = 1
31
31
  private const val LC3_FRAME_DURATION_MS = 10
32
32
  private const val DEFAULT_LC3_FRAME_SIZE_BYTES = 60
33
+ private val AUDIO_TRACE_METADATA_KEYS =
34
+ listOf(
35
+ "sampleRate",
36
+ "bitsPerSample",
37
+ "channels",
38
+ "encoding",
39
+ "frameDurationMs",
40
+ "frameSizeBytes",
41
+ "bitrate",
42
+ "packetizedFromGlasses",
43
+ "voiceActivityDetectionEnabled",
44
+ )
33
45
 
34
46
  @Volatile private var instance: Bridge? = null
35
47
 
@@ -731,13 +743,14 @@ public class Bridge private constructor() {
731
743
  return
732
744
  }
733
745
 
734
- if (shouldTraceTypedMessage(type)) {
746
+ val tracePayload = tracePayloadForTypedMessage(type, mutableBody as Map<String, Any>)
747
+ if (tracePayload != null) {
735
748
  try {
736
749
  BleTraceLogger.logMap(
737
750
  "phone_to_app",
738
751
  "sdk_event_dispatch",
739
752
  type,
740
- mutableBody as Map<String, Any>,
753
+ tracePayload,
741
754
  )
742
755
  } catch (e: Exception) {
743
756
  Log.d(TAG, "BLE trace logging failed for typed message '$type'", e)
@@ -762,8 +775,43 @@ public class Bridge private constructor() {
762
775
  }
763
776
  }
764
777
 
765
- private fun shouldTraceTypedMessage(type: String): Boolean =
766
- type != "log" && type != "mic_pcm" && type != "mic_lc3"
778
+ private fun tracePayloadForTypedMessage(
779
+ type: String,
780
+ body: Map<String, Any>
781
+ ): Map<String, Any>? =
782
+ when {
783
+ type == "log" -> null
784
+ isAudioPayloadEvent(type) -> audioTracePayload(type, body)
785
+ else -> body
786
+ }
787
+
788
+ private fun isAudioPayloadEvent(type: String): Boolean =
789
+ type == "mic_pcm" || type == "mic_lc3"
790
+
791
+ private fun audioTracePayload(type: String, body: Map<String, Any>): Map<String, Any> {
792
+ val payload = HashMap<String, Any>()
793
+ payload["type"] = type
794
+ payload["timestamp"] = System.currentTimeMillis()
795
+ payload["payloadOmitted"] = true
796
+ payload["payloadOmittedReason"] = "audio"
797
+
798
+ val audioBytes =
799
+ when (type) {
800
+ "mic_pcm" -> (body["pcm"] as? ByteArray)?.size
801
+ "mic_lc3" -> (body["lc3"] as? ByteArray)?.size
802
+ else -> null
803
+ }
804
+ audioBytes?.let { payload["audioBytes"] = it }
805
+
806
+ AUDIO_TRACE_METADATA_KEYS.forEach { key ->
807
+ val value = body[key]
808
+ if (value != null) {
809
+ payload[key] = value
810
+ }
811
+ }
812
+
813
+ return payload
814
+ }
767
815
  }
768
816
 
769
817
  init {
@@ -104,6 +104,7 @@ final class LocalPhotoUploadServer {
104
104
  }
105
105
 
106
106
  private func handle(_ connection: NWConnection) {
107
+ traceWifiInput("photo_receiver_connection", connection: connection, state: nil)
107
108
  connection.start(queue: queue)
108
109
  receive(connection, state: RequestReadState())
109
110
  }
@@ -171,6 +172,14 @@ final class LocalPhotoUploadServer {
171
172
  let request = HttpRequest.parse(headerText)
172
173
  state.request = request
173
174
  onLog("\(request.method) \(request.path)")
175
+ var requestTraceValues: [String: Any] = [
176
+ "method": request.method,
177
+ "path": request.path,
178
+ ]
179
+ if let contentLength = request.headers["content-length"].flatMap(Int.init) {
180
+ requestTraceValues["contentLength"] = contentLength
181
+ }
182
+ traceWifiInput("photo_receiver_request", connection: connection, state: state, values: requestTraceValues)
174
183
 
175
184
  if request.method == "GET", request.path == "/" || request.path == "/health" {
176
185
  writeJson(connection, status: 200, body: #"{"ok":true,"service":"mentra-photo-upload-receiver"}"#)
@@ -263,6 +272,29 @@ final class LocalPhotoUploadServer {
263
272
  )
264
273
 
265
274
  onLog("upload fields=\(parsed.fields.keys.joined(separator: ",")) requestId=\(requestId ?? "") bytes=\(photoPart.byteCount) saved=\(photoFile.path)")
275
+ var uploadReceivedTraceValues: [String: Any] = [
276
+ "durationMs": Int(Date().timeIntervalSince1970 * 1000 - state.startMs),
277
+ "photoBytes": photoPart.byteCount,
278
+ "contentLength": state.contentLength,
279
+ "method": request.method,
280
+ "path": request.path,
281
+ ]
282
+ if let requestId {
283
+ uploadReceivedTraceValues["requestId"] = requestId
284
+ }
285
+ traceWifiInput("photo_receiver_upload_received", connection: connection, state: state, values: uploadReceivedTraceValues)
286
+ var uploadResponseTraceValues: [String: Any] = [
287
+ "statusCode": 200,
288
+ "success": true,
289
+ "durationMs": Int(Date().timeIntervalSince1970 * 1000 - state.startMs),
290
+ "contentLength": state.contentLength,
291
+ "method": request.method,
292
+ "path": request.path,
293
+ ]
294
+ if let requestId {
295
+ uploadResponseTraceValues["requestId"] = requestId
296
+ }
297
+ traceWifiOutput("photo_receiver_response", connection: connection, state: state, values: uploadResponseTraceValues)
266
298
  writeJson(
267
299
  connection,
268
300
  status: 200,
@@ -370,6 +402,51 @@ final class LocalPhotoUploadServer {
370
402
  }
371
403
  }
372
404
 
405
+ private func traceWifiInput(
406
+ _ type: String,
407
+ connection: NWConnection,
408
+ state: RequestReadState?,
409
+ values: [String: Any] = [:]
410
+ ) {
411
+ traceWifi(direction: "wifi_to_phone", layer: "wifi_http_input", type: type, connection: connection, state: state, values: values)
412
+ }
413
+
414
+ private func traceWifiOutput(
415
+ _ type: String,
416
+ connection: NWConnection,
417
+ state: RequestReadState?,
418
+ values: [String: Any] = [:]
419
+ ) {
420
+ traceWifi(direction: "phone_to_wifi", layer: "wifi_http_output", type: type, connection: connection, state: state, values: values)
421
+ }
422
+
423
+ private func traceWifi(
424
+ direction: String,
425
+ layer: String,
426
+ type: String,
427
+ connection: NWConnection,
428
+ state: RequestReadState?,
429
+ values: [String: Any]
430
+ ) {
431
+ var payload = endpointPayload(type: type, connection: connection)
432
+ if let state {
433
+ payload["startMs"] = state.startMs
434
+ }
435
+ for (key, value) in values {
436
+ payload[key] = value
437
+ }
438
+ BleTraceLogger.logMap(direction: direction, layer: layer, type: type, payload: payload)
439
+ }
440
+
441
+ private func endpointPayload(type: String, connection: NWConnection) -> [String: Any] {
442
+ var payload: [String: Any] = ["type": type]
443
+ if case let .hostPort(host, port) = connection.endpoint {
444
+ payload["remoteHost"] = String(describing: host)
445
+ payload["remotePort"] = Int(port.rawValue)
446
+ }
447
+ return payload
448
+ }
449
+
373
450
  private struct HttpRequest {
374
451
  let method: String
375
452
  let path: String
@@ -613,6 +690,7 @@ final class LocalPhotoUploadServer {
613
690
  }
614
691
 
615
692
  private final class RequestReadState {
693
+ let startMs = Date().timeIntervalSince1970 * 1000
616
694
  var buffer = Data()
617
695
  var request: HttpRequest?
618
696
  var contentLength = 0
@@ -79,6 +79,16 @@ public class MentraPhotoReceiverModule: Module {
79
79
  }
80
80
 
81
81
  private func handlePhotoUpload(_ upload: PhotoUpload) {
82
+ BleTraceLogger.logMap(
83
+ direction: "phone_to_app",
84
+ layer: "photo_receiver_event",
85
+ type: "photo_upload",
86
+ payload: [
87
+ "requestId": upload.requestId ?? "",
88
+ "fileName": upload.photoFile.lastPathComponent,
89
+ "byteCount": upload.byteCount,
90
+ ]
91
+ )
82
92
  sendEvent("photoUpload", [
83
93
  "requestId": upload.requestId as Any,
84
94
  "fileUri": upload.photoFile.absoluteString,
@@ -7,7 +7,7 @@ enum BluetoothSdkDefaults {
7
7
  }
8
8
 
9
9
  static let voiceActivityDetectionEnabled = false
10
- private static let swiftPackageSdkVersion = "0.1.17"
10
+ private static let swiftPackageSdkVersion = "0.1.18"
11
11
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
12
12
 
13
13
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -15,6 +15,17 @@ class Bridge {
15
15
  private static let micChannels = 1
16
16
  private static let lc3FrameDurationMs = 10
17
17
  private static let defaultLc3FrameSizeBytes = 60
18
+ private static let audioTraceMetadataKeys = [
19
+ "sampleRate",
20
+ "bitsPerSample",
21
+ "channels",
22
+ "encoding",
23
+ "frameDurationMs",
24
+ "frameSizeBytes",
25
+ "bitrate",
26
+ "packetizedFromGlasses",
27
+ "voiceActivityDetectionEnabled",
28
+ ]
18
29
  private static let eventSinkLock = NSLock()
19
30
  private static let defaultEventSinkId = "default"
20
31
  private static var eventSinks: [String: (String, [String: Any]) -> Void] = [:]
@@ -486,7 +497,59 @@ class Bridge {
486
497
  static func sendTypedMessage(_ type: String, body: [String: Any]) {
487
498
  var body = body
488
499
  body["type"] = type
500
+ if let tracePayload = tracePayloadForTypedMessage(type, body: body) {
501
+ BleTraceLogger.logMap(
502
+ direction: "phone_to_app",
503
+ layer: "sdk_event_dispatch",
504
+ type: type,
505
+ payload: tracePayload
506
+ )
507
+ }
489
508
  // Send directly using type as event name - no JSON serialization
490
509
  dispatchEvent(type, body)
491
510
  }
511
+
512
+ private static func tracePayloadForTypedMessage(_ type: String, body: [String: Any]) -> [String: Any]? {
513
+ if type == "log" {
514
+ return nil
515
+ }
516
+ if isAudioPayloadEvent(type) {
517
+ return audioTracePayload(type, body: body)
518
+ }
519
+ return body
520
+ }
521
+
522
+ private static func isAudioPayloadEvent(_ type: String) -> Bool {
523
+ type == "mic_pcm" || type == "mic_lc3"
524
+ }
525
+
526
+ private static func audioTracePayload(_ type: String, body: [String: Any]) -> [String: Any] {
527
+ var payload: [String: Any] = [
528
+ "type": type,
529
+ "timestamp": Int(Date().timeIntervalSince1970 * 1000),
530
+ "payloadOmitted": true,
531
+ "payloadOmittedReason": "audio",
532
+ ]
533
+
534
+ switch type {
535
+ case "mic_pcm":
536
+ if let data = body["pcm"] as? Data {
537
+ payload["audioBytes"] = data.count
538
+ }
539
+ case "mic_lc3":
540
+ if let data = body["lc3"] as? Data {
541
+ payload["audioBytes"] = data.count
542
+ }
543
+ default:
544
+ break
545
+ }
546
+
547
+ for key in audioTraceMetadataKeys {
548
+ if let value = body[key] {
549
+ payload[key] = value
550
+ }
551
+ }
552
+
553
+ return payload
554
+ }
492
555
  }
@@ -0,0 +1,192 @@
1
+ import Foundation
2
+ import os.log
3
+ import UIKit
4
+
5
+ enum BleTraceLogger {
6
+ private static let log = OSLog(subsystem: "com.mentra.bluetoothsdk", category: "MentraBleTrace")
7
+ private static let maxPayloadChars = 3000
8
+ private static let k900Type = "k900"
9
+ private static let sensitiveKeyParts = ["password", "pass", "token", "secret", "authorization", "auth", "email"]
10
+
11
+ static func logJson(
12
+ direction: String,
13
+ layer: String,
14
+ payload: [String: Any]?,
15
+ bytes: Int? = nil,
16
+ sourceFile: String = #fileID,
17
+ sourceFunction: String = #function,
18
+ sourceLine: Int = #line
19
+ ) {
20
+ guard let payload else {
21
+ emit(format(
22
+ direction: direction,
23
+ layer: layer,
24
+ source: source(file: sourceFile, function: sourceFunction, line: sourceLine),
25
+ type: "null",
26
+ bytes: bytes,
27
+ payload: "null"
28
+ ))
29
+ return
30
+ }
31
+
32
+ let sanitized = sanitizeDictionary(payload)
33
+ emit(format(
34
+ direction: direction,
35
+ layer: layer,
36
+ source: source(file: sourceFile, function: sourceFunction, line: sourceLine),
37
+ type: extractType(from: payload),
38
+ bytes: bytes,
39
+ payload: jsonString(sanitized)
40
+ ))
41
+ }
42
+
43
+ static func logMap(
44
+ direction: String,
45
+ layer: String,
46
+ type: String?,
47
+ payload: [String: Any],
48
+ bytes: Int? = nil,
49
+ sourceFile: String = #fileID,
50
+ sourceFunction: String = #function,
51
+ sourceLine: Int = #line
52
+ ) {
53
+ let sanitized = sanitizeDictionary(payload)
54
+ emit(format(
55
+ direction: direction,
56
+ layer: layer,
57
+ source: source(file: sourceFile, function: sourceFunction, line: sourceLine),
58
+ type: type ?? extractType(from: sanitized),
59
+ bytes: bytes,
60
+ payload: jsonString(sanitized)
61
+ ))
62
+ }
63
+
64
+ static func logLifecycle(component: String, event: String, extra: [String: Any] = [:]) {
65
+ var payload: [String: Any] = [
66
+ "event": event,
67
+ "component": component,
68
+ "pid": ProcessInfo.processInfo.processIdentifier,
69
+ "model": UIDevice.current.model,
70
+ "systemVersion": UIDevice.current.systemVersion,
71
+ ]
72
+ for (key, value) in extra {
73
+ payload[key] = value
74
+ }
75
+ logMap(direction: "phone_app", layer: "app_lifecycle", type: event, payload: payload)
76
+ }
77
+
78
+ private static func emit(_ line: String) {
79
+ os_log("%{public}@", log: log, type: .info, line)
80
+ }
81
+
82
+ private static func format(
83
+ direction: String,
84
+ layer: String,
85
+ source: String,
86
+ type: String,
87
+ bytes: Int?,
88
+ payload: String
89
+ ) -> String {
90
+ let bytesText = bytes.map { " bytes=\($0)" } ?? ""
91
+ return "BLE_TRACE direction=\(direction) layer=\(layer) source=\(source) type=\(type)\(bytesText) payload=\(truncate(payload))"
92
+ }
93
+
94
+ private static func extractType(from payload: [String: Any]) -> String {
95
+ if let type = payload["type"] as? String, !type.isEmpty {
96
+ return type
97
+ }
98
+ if let cValue = payload["C"] as? String, !cValue.isEmpty {
99
+ if let data = cValue.data(using: .utf8),
100
+ let inner = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
101
+ let type = inner["type"] as? String,
102
+ !type.isEmpty
103
+ {
104
+ return type
105
+ }
106
+ return extractK900Type(cValue)
107
+ }
108
+ return "unknown"
109
+ }
110
+
111
+ private static func extractK900Type(_ value: String) -> String {
112
+ value == "sr_log" ? "\(k900Type):sr_log" : k900Type
113
+ }
114
+
115
+ private static func sanitizeDictionary(_ value: [String: Any]) -> [String: Any] {
116
+ var output: [String: Any] = [:]
117
+ for (key, child) in value {
118
+ output[key] = sanitizeValue(key: key, value: child)
119
+ }
120
+ return output
121
+ }
122
+
123
+ private static func sanitizeArray(_ value: [Any]) -> [Any] {
124
+ value.map { sanitizeValue(key: nil, value: $0) }
125
+ }
126
+
127
+ private static func sanitizeValue(key: String?, value: Any) -> Any {
128
+ if let key, sensitiveKeyParts.contains(where: { key.localizedCaseInsensitiveContains($0) }) {
129
+ return "<redacted>"
130
+ }
131
+
132
+ if key == "C", let stringValue = value as? String {
133
+ if let data = stringValue.data(using: .utf8),
134
+ let parsed = try? JSONSerialization.jsonObject(with: data)
135
+ {
136
+ return jsonString(sanitizeJsonValue(parsed))
137
+ }
138
+ return sanitizeK900Command(stringValue)
139
+ }
140
+
141
+ return sanitizeJsonValue(value)
142
+ }
143
+
144
+ private static func sanitizeJsonValue(_ value: Any) -> Any {
145
+ switch value {
146
+ case let dictionary as [String: Any]:
147
+ return sanitizeDictionary(dictionary)
148
+ case let array as [Any]:
149
+ return sanitizeArray(array)
150
+ case let data as Data:
151
+ return "<data \(data.count) bytes>"
152
+ case let url as URL:
153
+ return url.absoluteString
154
+ case let bool as Bool:
155
+ return bool
156
+ case let number as NSNumber:
157
+ return number
158
+ case let string as String:
159
+ return string
160
+ case _ as NSNull:
161
+ return NSNull()
162
+ default:
163
+ return String(describing: value)
164
+ }
165
+ }
166
+
167
+ private static func sanitizeK900Command(_ value: String) -> String {
168
+ value == "sr_log" ? value : "<non-json C payload>"
169
+ }
170
+
171
+ private static func jsonString(_ value: Any) -> String {
172
+ guard JSONSerialization.isValidJSONObject(value),
173
+ let data = try? JSONSerialization.data(withJSONObject: value),
174
+ let string = String(data: data, encoding: .utf8)
175
+ else {
176
+ return String(describing: value)
177
+ }
178
+ return string
179
+ }
180
+
181
+ private static func source(file: String, function: String, line: Int) -> String {
182
+ "\(function)(\(file):\(line))"
183
+ }
184
+
185
+ private static func truncate(_ value: String) -> String {
186
+ guard value.count > maxPayloadChars else {
187
+ return value
188
+ }
189
+ let endIndex = value.index(value.startIndex, offsetBy: maxPayloadChars)
190
+ return "\(value[..<endIndex])...(truncated \(value.count - maxPayloadChars) chars)"
191
+ }
192
+ }
@@ -1134,9 +1134,13 @@ extension MentraLive: CBPeripheralDelegate {
1134
1134
 
1135
1135
  nonisolated func peripheral(_: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?) {
1136
1136
  let errorDescription = error?.localizedDescription
1137
- DispatchQueue.main.async {
1137
+ DispatchQueue.main.async { [weak self] in
1138
1138
  if let errorDescription {
1139
1139
  Bridge.log("LIVE: Error writing characteristic: \(errorDescription)")
1140
+ self?.logBleWriteTrace("write_callback", self?.pending?.trace, extra: [
1141
+ "success": false,
1142
+ "errorMessage": errorDescription,
1143
+ ])
1140
1144
  } else {
1141
1145
  Bridge.log("LIVE: Characteristic write successful")
1142
1146
  }
@@ -1358,6 +1362,8 @@ class MentraLive: NSObject, SGCManager {
1358
1362
  private let SIGNAL_STRENGTH_READ_INTERVAL_MS: TimeInterval = 10.0
1359
1363
  private let MIN_SEND_DELAY_MS: UInt64 = 160_000_000 // 160ms in nanoseconds
1360
1364
  private let READINESS_CHECK_INTERVAL_MS: TimeInterval = 2.5 // 2.5 seconds
1365
+ private let SIGNIFICANT_BLE_TRACE_DELAY_MS: TimeInterval = 250
1366
+ private let SIGNIFICANT_BLE_TRACE_QUEUE_SIZE = 5
1361
1367
 
1362
1368
  /// Device Settings Keys
1363
1369
  private let PREFS_DEVICE_NAME = "MentraLiveLastConnectedDeviceName"
@@ -1385,6 +1391,7 @@ class MentraLive: NSObject, SGCManager {
1385
1391
  private var isNewVersion = false
1386
1392
  private var globalMessageId = 0
1387
1393
  private var lastReceivedMessageId = 0
1394
+ private var bleWriteTraceSequence = 0
1388
1395
 
1389
1396
  private var fullyBooted: Bool {
1390
1397
  get { DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false }
@@ -1741,15 +1748,40 @@ class MentraLive: NSObject, SGCManager {
1741
1748
  // MARK: - Command Queue
1742
1749
 
1743
1750
  class PendingMessage {
1744
- init(data: Data, id: String, retries: Int) {
1751
+ init(data: Data, id: String, retries: Int, trace: BleWriteTrace? = nil) {
1745
1752
  self.data = data
1746
1753
  self.id = id
1747
1754
  self.retries = retries
1755
+ self.trace = trace
1748
1756
  }
1749
1757
 
1750
1758
  let data: Data
1751
1759
  let retries: Int
1752
1760
  let id: String
1761
+ let trace: BleWriteTrace?
1762
+ }
1763
+
1764
+ struct OutgoingBleCommandTraceInfo {
1765
+ let commandType: String
1766
+ let requestId: String?
1767
+ let appId: String?
1768
+ let messageId: Int64?
1769
+ }
1770
+
1771
+ struct BleWriteTrace {
1772
+ let sequence: Int
1773
+ let commandType: String
1774
+ let requestId: String?
1775
+ let appId: String?
1776
+ let messageId: Int64?
1777
+ let chunkId: String?
1778
+ let chunkIndex: Int?
1779
+ let totalChunks: Int?
1780
+ let payloadBytes: Int?
1781
+ let packedBytes: Int
1782
+ let wakeup: Bool
1783
+ let chunked: Bool
1784
+ let queuedAtMs: TimeInterval
1753
1785
  }
1754
1786
 
1755
1787
  private var pending: PendingMessage?
@@ -1758,12 +1790,14 @@ class MentraLive: NSObject, SGCManager {
1758
1790
  actor CommandQueue {
1759
1791
  private var commands: [PendingMessage] = []
1760
1792
 
1761
- func enqueue(_ command: PendingMessage) {
1793
+ func enqueue(_ command: PendingMessage) -> Int {
1762
1794
  commands.append(command)
1795
+ return commands.count
1763
1796
  }
1764
1797
 
1765
- func pushToFront(_ command: PendingMessage) {
1798
+ func pushToFront(_ command: PendingMessage) -> Int {
1766
1799
  commands.insert(command, at: 0)
1800
+ return commands.count
1767
1801
  }
1768
1802
 
1769
1803
  func dequeue() -> PendingMessage? {
@@ -1791,18 +1825,28 @@ class MentraLive: NSObject, SGCManager {
1791
1825
  guard let peripheral = connectedPeripheral,
1792
1826
  let txChar = txCharacteristic
1793
1827
  else {
1828
+ logBleWriteTrace("write_dropped", message.trace, extra: [
1829
+ "errorMessage": "missing peripheral or TX characteristic",
1830
+ ])
1794
1831
  return
1795
1832
  }
1796
1833
 
1797
1834
  // Enforce rate limiting
1798
1835
  let currentTime = Date().timeIntervalSince1970 * 1000
1799
1836
  let timeSinceLastSend = currentTime - lastSendTimeMs
1837
+ let queueDelayMs = message.trace.map { currentTime - $0.queuedAtMs }
1800
1838
 
1801
1839
  try? await Task.sleep(nanoseconds: UInt64(1_000_000))
1802
1840
  lastSendTimeMs = Date().timeIntervalSince1970 * 1000
1803
1841
 
1804
1842
  // Send the data
1805
1843
  peripheral.writeValue(message.data, for: txChar, type: .withResponse)
1844
+ logBleWriteTrace("write_requested", message.trace, extra: [
1845
+ "queueDelayMs": queueDelayMs,
1846
+ "timeSinceLastSendMs": timeSinceLastSend,
1847
+ "currentMtu": currentMtu,
1848
+ "writeType": "withResponse",
1849
+ ])
1806
1850
 
1807
1851
  // don't do the retry system on the old glasses versions
1808
1852
  if !isNewVersion {
@@ -1844,12 +1888,19 @@ class MentraLive: NSObject, SGCManager {
1844
1888
  let retryMessage = PendingMessage(
1845
1889
  data: pendingMessage.data,
1846
1890
  id: pendingMessage.id,
1847
- retries: pendingMessage.retries + 1
1891
+ retries: pendingMessage.retries + 1,
1892
+ trace: pendingMessage.trace
1848
1893
  )
1849
1894
 
1850
1895
  // Push to front of queue for immediate retry
1851
1896
  Task {
1852
- await self.commandQueue.pushToFront(retryMessage)
1897
+ let queueSize = await self.commandQueue.pushToFront(retryMessage)
1898
+ await MainActor.run {
1899
+ self.logBleWriteTrace("retry_queued", retryMessage.trace, extra: [
1900
+ "retryDelayMs": 0,
1901
+ "queueSizeAfterAdd": queueSize,
1902
+ ])
1903
+ }
1853
1904
  }
1854
1905
 
1855
1906
  Bridge.log(
@@ -2102,6 +2153,7 @@ class MentraLive: NSObject, SGCManager {
2102
2153
  private func processJsonObject(_ json: [String: Any]) {
2103
2154
  // Log ALL incoming JSON objects for debugging
2104
2155
  // Bridge.log("LIVE: DEBUG: processJsonObject: \(json)")
2156
+ BleTraceLogger.logJson(direction: "glasses_to_phone", layer: "sdk_ble_event", payload: json)
2105
2157
 
2106
2158
  if MessageChunker.isChunkedMessage(json) {
2107
2159
  processChunkedJsonObject(json)
@@ -3651,8 +3703,211 @@ class MentraLive: NSObject, SGCManager {
3651
3703
  // MARK: - Sending Data
3652
3704
 
3653
3705
  func queueSend(_ data: Data, id: String) {
3706
+ queueSend(data, id: id, trace: nil)
3707
+ }
3708
+
3709
+ func queueSend(_ data: Data, id: String, trace: BleWriteTrace?) {
3710
+ let significantQueueSize = SIGNIFICANT_BLE_TRACE_QUEUE_SIZE
3654
3711
  Task {
3655
- await commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0))
3712
+ let queueSize = await commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0, trace: trace))
3713
+ guard trace != nil, queueSize >= significantQueueSize else {
3714
+ return
3715
+ }
3716
+ await MainActor.run {
3717
+ self.logBleChunkTrace("queued", trace, extra: [
3718
+ "queueSizeAfterAdd": queueSize,
3719
+ ])
3720
+ }
3721
+ }
3722
+ }
3723
+
3724
+ private func outgoingBleCommandTraceInfo(_ json: [String: Any]) -> OutgoingBleCommandTraceInfo {
3725
+ OutgoingBleCommandTraceInfo(
3726
+ commandType: nonBlankString(json["type"]) ?? "unknown",
3727
+ requestId: nonBlankString(json["requestId"]),
3728
+ appId: nonBlankString(json["appId"]),
3729
+ messageId: traceInt64(json["mId"])
3730
+ )
3731
+ }
3732
+
3733
+ private func createBleWriteTrace(
3734
+ commandInfo: OutgoingBleCommandTraceInfo,
3735
+ chunkId: String?,
3736
+ chunkIndex: Int?,
3737
+ totalChunks: Int?,
3738
+ payloadBytes: Int?,
3739
+ packedBytes: Int,
3740
+ wakeup: Bool,
3741
+ chunked: Bool
3742
+ ) -> BleWriteTrace {
3743
+ let sequence = bleWriteTraceSequence
3744
+ bleWriteTraceSequence += 1
3745
+ return BleWriteTrace(
3746
+ sequence: sequence,
3747
+ commandType: commandInfo.commandType,
3748
+ requestId: commandInfo.requestId,
3749
+ appId: commandInfo.appId,
3750
+ messageId: commandInfo.messageId,
3751
+ chunkId: chunkId,
3752
+ chunkIndex: chunkIndex,
3753
+ totalChunks: totalChunks,
3754
+ payloadBytes: payloadBytes,
3755
+ packedBytes: packedBytes,
3756
+ wakeup: wakeup,
3757
+ chunked: chunked,
3758
+ queuedAtMs: Date().timeIntervalSince1970 * 1000
3759
+ )
3760
+ }
3761
+
3762
+ private func logBleChunkTrace(
3763
+ _ stage: String,
3764
+ _ trace: BleWriteTrace?,
3765
+ extra: [String: Any?] = [:]
3766
+ ) {
3767
+ logBleTrace(layer: "sdk_ble_chunk", stage: stage, trace: trace, extra: extra)
3768
+ }
3769
+
3770
+ private func logBleWriteTrace(
3771
+ _ stage: String,
3772
+ _ trace: BleWriteTrace?,
3773
+ extra: [String: Any?] = [:]
3774
+ ) {
3775
+ logBleTrace(layer: "sdk_ble_write", stage: stage, trace: trace, extra: extra)
3776
+ }
3777
+
3778
+ private func logBleTrace(
3779
+ layer: String,
3780
+ stage: String,
3781
+ trace: BleWriteTrace?,
3782
+ extra: [String: Any?] = [:]
3783
+ ) {
3784
+ guard let trace,
3785
+ let warningReason = bleTraceWarningReason(stage: stage, extra: extra)
3786
+ else {
3787
+ return
3788
+ }
3789
+
3790
+ var payload: [String: Any] = [
3791
+ "level": "warning",
3792
+ "warningReason": warningReason,
3793
+ "stage": stage,
3794
+ "sequence": trace.sequence,
3795
+ "commandType": trace.commandType,
3796
+ "packedBytes": trace.packedBytes,
3797
+ "wakeup": trace.wakeup,
3798
+ "chunked": trace.chunked,
3799
+ "queuedAtMs": trace.queuedAtMs,
3800
+ ]
3801
+ if let requestId = trace.requestId { payload["requestId"] = requestId }
3802
+ if let appId = trace.appId { payload["appId"] = appId }
3803
+ if let messageId = trace.messageId { payload["messageId"] = messageId }
3804
+ if let chunkId = trace.chunkId { payload["chunkId"] = chunkId }
3805
+ if let chunkIndex = trace.chunkIndex {
3806
+ payload["chunkIndex"] = chunkIndex
3807
+ payload["chunkNumber"] = chunkIndex + 1
3808
+ }
3809
+ if let totalChunks = trace.totalChunks { payload["totalChunks"] = totalChunks }
3810
+ if let payloadBytes = trace.payloadBytes { payload["payloadBytes"] = payloadBytes }
3811
+ for (key, value) in extra {
3812
+ if let value {
3813
+ payload[key] = value
3814
+ }
3815
+ }
3816
+
3817
+ BleTraceLogger.logMap(direction: "phone_to_glasses", layer: layer, type: trace.commandType, payload: payload)
3818
+ }
3819
+
3820
+ private func bleTraceWarningReason(stage: String, extra: [String: Any?]) -> String? {
3821
+ if extraValue(extra, "errorClass") != nil || extraValue(extra, "errorMessage") != nil {
3822
+ return "ble_write_error"
3823
+ }
3824
+ if let success = extraValue(extra, "success") as? Bool, !success {
3825
+ return "ble_write_failed"
3826
+ }
3827
+ if let writeAccepted = extraValue(extra, "writeAccepted") as? Bool, !writeAccepted {
3828
+ return "ble_write_rejected"
3829
+ }
3830
+ if traceDouble(extraValue(extra, "queueDelayMs")).map({ $0 >= SIGNIFICANT_BLE_TRACE_DELAY_MS }) == true {
3831
+ return "queue_delay"
3832
+ }
3833
+ if traceDouble(extraValue(extra, "callbackDelayMs")).map({ $0 >= SIGNIFICANT_BLE_TRACE_DELAY_MS }) == true {
3834
+ return "write_callback_delay"
3835
+ }
3836
+ if traceDouble(extraValue(extra, "remainingDelayMs")).map({ $0 >= SIGNIFICANT_BLE_TRACE_DELAY_MS }) == true {
3837
+ return "rate_limit_delay"
3838
+ }
3839
+ if traceDouble(extraValue(extra, "nextProcessDelayMs")).map({ $0 >= SIGNIFICANT_BLE_TRACE_DELAY_MS }) == true {
3840
+ return "next_process_delay"
3841
+ }
3842
+ if extraValue(extra, "retryDelayMs") != nil {
3843
+ return "write_retry"
3844
+ }
3845
+ if stage == "queued",
3846
+ traceInt(extraValue(extra, "queueSizeAfterAdd")).map({ $0 >= SIGNIFICANT_BLE_TRACE_QUEUE_SIZE }) == true
3847
+ {
3848
+ return "queue_depth"
3849
+ }
3850
+ return nil
3851
+ }
3852
+
3853
+ private func extraValue(_ extra: [String: Any?], _ key: String) -> Any? {
3854
+ extra[key] ?? nil
3855
+ }
3856
+
3857
+ private func nonBlankString(_ value: Any?) -> String? {
3858
+ guard let value else { return nil }
3859
+ let string: String
3860
+ if let value = value as? String {
3861
+ string = value
3862
+ } else {
3863
+ string = String(describing: value)
3864
+ }
3865
+ return string.isEmpty ? nil : string
3866
+ }
3867
+
3868
+ private func traceInt64(_ value: Any?) -> Int64? {
3869
+ guard let value else { return nil }
3870
+ switch value {
3871
+ case let value as Int64:
3872
+ return value
3873
+ case let value as Int:
3874
+ return Int64(value)
3875
+ case let value as NSNumber:
3876
+ return value.int64Value
3877
+ case let value as String:
3878
+ return Int64(value)
3879
+ default:
3880
+ return nil
3881
+ }
3882
+ }
3883
+
3884
+ private func traceInt(_ value: Any?) -> Int? {
3885
+ guard let value else { return nil }
3886
+ switch value {
3887
+ case let value as Int:
3888
+ return value
3889
+ case let value as NSNumber:
3890
+ return value.intValue
3891
+ case let value as String:
3892
+ return Int(value)
3893
+ default:
3894
+ return nil
3895
+ }
3896
+ }
3897
+
3898
+ private func traceDouble(_ value: Any?) -> Double? {
3899
+ guard let value else { return nil }
3900
+ switch value {
3901
+ case let value as Double:
3902
+ return value
3903
+ case let value as Int:
3904
+ return Double(value)
3905
+ case let value as NSNumber:
3906
+ return value.doubleValue
3907
+ case let value as String:
3908
+ return Double(value)
3909
+ default:
3910
+ return nil
3656
3911
  }
3657
3912
  }
3658
3913
 
@@ -3671,6 +3926,14 @@ class MentraLive: NSObject, SGCManager {
3671
3926
 
3672
3927
  let jsonData = try JSONSerialization.data(withJSONObject: json)
3673
3928
  if let jsonString = String(data: jsonData, encoding: .utf8) {
3929
+ let commandInfo = outgoingBleCommandTraceInfo(json)
3930
+ BleTraceLogger.logJson(
3931
+ direction: "phone_to_glasses",
3932
+ layer: "sdk_ble_command",
3933
+ payload: json,
3934
+ bytes: jsonData.count
3935
+ )
3936
+
3674
3937
  // First check if the message needs chunking
3675
3938
  // Create a test C-wrapped version to check size
3676
3939
  var testWrapper: [String: Any] = [K900ProtocolUtils.FIELD_C: jsonString]
@@ -3707,7 +3970,21 @@ class MentraLive: NSObject, SGCManager {
3707
3970
  // All other chunks get "-1" (no ACK tracking)
3708
3971
  let isFinalChunk = (index == chunks.count - 1)
3709
3972
  let chunkTrackingId = (requireAck && isFinalChunk) ? trackingId : "-1"
3710
- queueSend(packedData, id: chunkTrackingId)
3973
+ let trace = createBleWriteTrace(
3974
+ commandInfo: commandInfo,
3975
+ chunkId: chunk["id"] as? String,
3976
+ chunkIndex: index,
3977
+ totalChunks: chunks.count,
3978
+ payloadBytes: chunkStr.data(using: .utf8)?.count,
3979
+ packedBytes: packedData.count,
3980
+ wakeup: wakeUp && index == 0,
3981
+ chunked: true
3982
+ )
3983
+ logBleChunkTrace("created", trace, extra: [
3984
+ "chunkJsonBytes": chunkStr.data(using: .utf8)?.count,
3985
+ "messageBytes": jsonData.count,
3986
+ ])
3987
+ queueSend(packedData, id: chunkTrackingId, trace: trace)
3711
3988
 
3712
3989
  // Add small delay between chunks to avoid overwhelming the connection
3713
3990
  if index < chunks.count - 1 {
@@ -3724,7 +4001,20 @@ class MentraLive: NSObject, SGCManager {
3724
4001
  }
3725
4002
  Bridge.log("LIVE: Sending data to glasses: \(jsonString)")
3726
4003
  let packedData = packJson(jsonString, wakeUp: wakeUp) ?? Data()
3727
- queueSend(packedData, id: trackingId)
4004
+ let trace = createBleWriteTrace(
4005
+ commandInfo: commandInfo,
4006
+ chunkId: nil,
4007
+ chunkIndex: nil,
4008
+ totalChunks: nil,
4009
+ payloadBytes: jsonData.count,
4010
+ packedBytes: packedData.count,
4011
+ wakeup: wakeUp,
4012
+ chunked: false
4013
+ )
4014
+ logBleChunkTrace("created", trace, extra: [
4015
+ "messageBytes": jsonData.count,
4016
+ ])
4017
+ queueSend(packedData, id: trackingId, trace: trace)
3728
4018
  }
3729
4019
  }
3730
4020
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mentra/bluetooth-sdk",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",