@mentra/bluetooth-sdk 3.2.0-dev.200 → 3.2.0-dev.206

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 (60) hide show
  1. package/README.md +46 -45
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -9
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +63 -0
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +5 -4
  5. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +1 -1
  6. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
  7. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +298 -181
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +326 -45
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +14 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamControllerProbe.kt +14 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamModels.kt +16 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/streaming/StreamSessionState.kt +32 -0
  17. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
  18. package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/streaming/StreamControllerProbeTest.kt +24 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/streaming/StreamSessionStateTest.kt +43 -0
  23. package/build/BluetoothSdk.types.d.ts +68 -2
  24. package/build/BluetoothSdk.types.d.ts.map +1 -1
  25. package/build/BluetoothSdk.types.js.map +1 -1
  26. package/build/_private/BluetoothSdkModule.d.ts +3 -4
  27. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  28. package/build/_private/BluetoothSdkModule.js.map +1 -1
  29. package/build/generated/changelogCatalog.d.ts +1 -1
  30. package/build/generated/changelogCatalog.js +1 -1
  31. package/build/generated/changelogCatalog.js.map +1 -1
  32. package/build/generated/releaseMetadata.js +5 -5
  33. package/build/generated/releaseMetadata.js.map +1 -1
  34. package/build/index.d.ts +1 -1
  35. package/build/index.d.ts.map +1 -1
  36. package/build/index.js +3 -0
  37. package/build/index.js.map +1 -1
  38. package/ios/BluetoothSdkModule.swift +8 -13
  39. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  40. package/ios/Source/Bridge.swift +63 -0
  41. package/ios/Source/DeviceManager.swift +6 -3
  42. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  43. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  44. package/ios/Source/MentraBluetoothSDK.swift +372 -220
  45. package/ios/Source/WifiRequestResolution.swift +251 -0
  46. package/ios/Source/sgcs/MentraLive.swift +216 -70
  47. package/ios/Source/sgcs/SGCManager.swift +13 -1
  48. package/ios/Source/status/DeviceStatus.swift +28 -0
  49. package/ios/Source/streaming/StreamControllerProbe.swift +18 -0
  50. package/ios/Source/streaming/StreamModels.swift +12 -0
  51. package/ios/Source/streaming/StreamSessionState.swift +29 -0
  52. package/ios/Tests/StreamControllerProbeTests.swift +27 -0
  53. package/ios/Tests/StreamSessionStateTests.swift +38 -0
  54. package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
  55. package/package.json +1 -1
  56. package/src/BluetoothSdk.types.ts +82 -2
  57. package/src/_private/BluetoothSdkModule.ts +4 -4
  58. package/src/generated/changelogCatalog.ts +1 -1
  59. package/src/generated/releaseMetadata.ts +5 -5
  60. package/src/index.ts +11 -0
@@ -1022,17 +1022,17 @@ extension MentraLive: CBCentralManagerDelegate {
1022
1022
  }
1023
1023
 
1024
1024
  #if !os(macOS)
1025
- nonisolated func centralManager(
1026
- _: CBCentralManager, didUpdateANCSAuthorizationFor peripheral: CBPeripheral
1027
- ) {
1028
- DispatchQueue.main.async { [weak self] in
1029
- guard let self, peripheral === self.connectedPeripheral else { return }
1030
- Bridge.log(
1031
- "LIVE: ANCS authorization updated: \(peripheral.ancsAuthorized ? "authorized" : "not authorized")"
1032
- )
1033
- self.enableAncsRelayIfAuthorized()
1025
+ nonisolated func centralManager(
1026
+ _: CBCentralManager, didUpdateANCSAuthorizationFor peripheral: CBPeripheral
1027
+ ) {
1028
+ DispatchQueue.main.async { [weak self] in
1029
+ guard let self, peripheral === self.connectedPeripheral else { return }
1030
+ Bridge.log(
1031
+ "LIVE: ANCS authorization updated: \(peripheral.ancsAuthorized ? "authorized" : "not authorized")"
1032
+ )
1033
+ self.enableAncsRelayIfAuthorized()
1034
+ }
1034
1035
  }
1035
- }
1036
1036
  #endif
1037
1037
 
1038
1038
  nonisolated func centralManager(
@@ -2154,6 +2154,8 @@ class MentraLive: NSObject, SGCManager {
2154
2154
  Bridge.log("Starting stream")
2155
2155
  var json = message
2156
2156
  json.removeValue(forKey: "timestamp")
2157
+ json["controllerProbeVersion"] = 1
2158
+ json["controllerId"] = StreamControllerProbe.controllerId
2157
2159
  sendJson(json, wakeUp: true)
2158
2160
  }
2159
2161
 
@@ -2461,16 +2463,16 @@ class MentraLive: NSObject, SGCManager {
2461
2463
  startConnectionTimeout()
2462
2464
 
2463
2465
  #if os(macOS)
2464
- centralManager?.connect(peripheral, options: nil)
2466
+ centralManager?.connect(peripheral, options: nil)
2465
2467
  #else
2466
- // ANCS is hosted by iOS and is only exposed to authorized accessories.
2467
- // The default requirement lets the system complete that authorization flow
2468
- // before the glasses subscribe; apps that do not relay notifications can opt out.
2469
- Bridge.log("LIVE: ANCS connection requirement \(requiresAncs ? "enabled" : "disabled")")
2470
- centralManager?.connect(
2471
- peripheral,
2472
- options: MentraLiveConnectionOptions.coreBluetoothOptions(requiresAncs: requiresAncs)
2473
- )
2468
+ // ANCS is hosted by iOS and is only exposed to authorized accessories.
2469
+ // The default requirement lets the system complete that authorization flow
2470
+ // before the glasses subscribe; apps that do not relay notifications can opt out.
2471
+ Bridge.log("LIVE: ANCS connection requirement \(requiresAncs ? "enabled" : "disabled")")
2472
+ centralManager?.connect(
2473
+ peripheral,
2474
+ options: MentraLiveConnectionOptions.coreBluetoothOptions(requiresAncs: requiresAncs)
2475
+ )
2474
2476
  #endif
2475
2477
  }
2476
2478
 
@@ -2479,23 +2481,23 @@ class MentraLive: NSObject, SGCManager {
2479
2481
  /// for old mobile clients that never send it.
2480
2482
  private func enableAncsRelayIfAuthorized() {
2481
2483
  #if !os(macOS)
2482
- guard !ancsRelayEnableRequested,
2483
- let peripheral = connectedPeripheral,
2484
- txCharacteristic != nil,
2485
- rxCharacteristic?.isNotifying == true,
2486
- peripheral.ancsAuthorized
2487
- else {
2488
- return
2489
- }
2484
+ guard !ancsRelayEnableRequested,
2485
+ let peripheral = connectedPeripheral,
2486
+ txCharacteristic != nil,
2487
+ rxCharacteristic?.isNotifying == true,
2488
+ peripheral.ancsAuthorized
2489
+ else {
2490
+ return
2491
+ }
2490
2492
 
2491
- let command: [String: Any] = [
2492
- "C": "cs_ancs",
2493
- "B": ["enabled": 1],
2494
- ]
2495
- if sendRawK900Command(command) {
2496
- ancsRelayEnableRequested = true
2497
- Bridge.log("LIVE: Requested ANCS relay from compatible firmware")
2498
- }
2493
+ let command: [String: Any] = [
2494
+ "C": "cs_ancs",
2495
+ "B": ["enabled": 1],
2496
+ ]
2497
+ if sendRawK900Command(command) {
2498
+ ancsRelayEnableRequested = true
2499
+ Bridge.log("LIVE: Requested ANCS relay from compatible firmware")
2500
+ }
2499
2501
  #endif
2500
2502
  }
2501
2503
 
@@ -2796,8 +2798,16 @@ class MentraLive: NSObject, SGCManager {
2796
2798
  // Record the session id BEFORE marking ready: an unsolicited glasses_ready
2797
2799
  // already runs this full remote-reset flow, so recording (not re-triggering)
2798
2800
  // is correct here; version_info detection covers the restart case.
2799
- if let sid = json["sid"] as? String, !sid.isEmpty { glassesSessionId = sid }
2801
+ glassesSessionId = (json["sid"] as? String).flatMap { $0.isEmpty ? nil : $0 }
2802
+ Bridge.sendTypedMessage(
2803
+ "wifi_protocol_session_ready",
2804
+ body: ["sid": glassesSessionId ?? ""]
2805
+ )
2800
2806
  readinessCompletedThisBleSession = true
2807
+ Bridge.sendTypedMessage("stream_control_ready", body: [
2808
+ "sid": json["sid"] as? String ?? "",
2809
+ "streamControlVersion": json["streamControlVersion"] as? Int ?? 0,
2810
+ ])
2801
2811
  handleGlassesReady()
2802
2812
 
2803
2813
  case "battery_status":
@@ -2834,6 +2844,33 @@ class MentraLive: NSObject, SGCManager {
2834
2844
  }
2835
2845
  updateWifiStatus(connected: connected, ssid: ssid, ip: ip, error: wifiError.isEmpty ? nil : wifiError)
2836
2846
 
2847
+ case "wifi_forget_result":
2848
+ guard wifiResponseEnvelopeIsValid(json, allowLegacy: true) else { return }
2849
+ Bridge.sendWifiForgetResult(
2850
+ requestId: json["requestId"] as? String ?? "",
2851
+ sid: json["sid"] as? String ?? "",
2852
+ ssid: json["ssid"] as? String ?? "",
2853
+ protocolVersion: (json["protocol_version"] as? NSNumber)?.intValue ?? 0,
2854
+ outcome: json["outcome"] as? String ?? "",
2855
+ legacyDispatched: json["dispatched"] as? Bool,
2856
+ connected: json["connected"] as? Bool,
2857
+ currentSsid: json["current_ssid"] as? String ?? "",
2858
+ localIp: json["local_ip"] as? String ?? "",
2859
+ error: (json["error"] as? String).flatMap { $0.isEmpty ? nil : $0 }
2860
+ )
2861
+
2862
+ case "saved_wifi_networks":
2863
+ guard wifiResponseEnvelopeIsValid(json, allowLegacy: false) else { return }
2864
+ guard let networks = json["networks"] as? [String] else { return }
2865
+ Bridge.sendSavedWifiNetworks(
2866
+ requestId: json["requestId"] as? String ?? "",
2867
+ sid: json["sid"] as? String ?? "",
2868
+ protocolVersion: (json["protocol_version"] as? NSNumber)?.intValue ?? 0,
2869
+ outcome: json["outcome"] as? String ?? "",
2870
+ networks: networks,
2871
+ error: (json["error"] as? String).flatMap { $0.isEmpty ? nil : $0 }
2872
+ )
2873
+
2837
2874
  case "hotspot_status_update":
2838
2875
  let enabled = json["hotspot_enabled"] as? Bool ?? false
2839
2876
  let ssid = json["hotspot_ssid"] as? String ?? ""
@@ -2938,11 +2975,16 @@ class MentraLive: NSObject, SGCManager {
2938
2975
  let error = json["errorCode"] as? String ?? json["error"] as? String
2939
2976
  Bridge.sendRgbLedControlResponse(requestId: requestId, success: success, error: error)
2940
2977
 
2978
+ case "stream_controller_probe":
2979
+ if let response = StreamControllerProbe.response(json) {
2980
+ sendJson(response)
2981
+ }
2982
+
2941
2983
  case "pong":
2942
2984
  Bridge.log("LIVE: Received pong response - connection healthy")
2943
2985
 
2944
2986
  case "entering_pairing_mode":
2945
- let windowMs = max(5_000, min(180_000, json["window_ms"] as? Int ?? 120_000))
2987
+ let windowMs = max(5000, min(180_000, json["window_ms"] as? Int ?? 120_000))
2946
2988
  Bridge.log("LIVE: Glasses entering pairing mode — yield \(windowMs)ms (no forget)")
2947
2989
  enterPairingYield(windowMs: windowMs)
2948
2990
  let body: [String: Any] = [
@@ -3086,6 +3128,7 @@ class MentraLive: NSObject, SGCManager {
3086
3128
 
3087
3129
  // Extract all fields from JSON (except "type")
3088
3130
  var fields: [String: Any] = [:]
3131
+ fields["version_info_type"] = type
3089
3132
  for (key, value) in json {
3090
3133
  if key != "type" {
3091
3134
  fields[key] = value
@@ -3647,19 +3690,67 @@ class MentraLive: NSObject, SGCManager {
3647
3690
  }
3648
3691
 
3649
3692
  func forgetWifiNetwork(_ ssid: String) {
3650
- Bridge.log("LIVE: 📶 Sending WiFi forget command for SSID: \(ssid)")
3693
+ forgetWifiNetwork(ssid, requestId: nil, sid: nil)
3694
+ }
3651
3695
 
3652
- guard !ssid.isEmpty else {
3653
- Bridge.log("LIVE: Cannot forget WiFi network - SSID is empty")
3654
- return
3696
+ @discardableResult func forgetWifiNetwork(_ ssid: String, requestId: String?, sid: String?) -> Bool {
3697
+ guard let peripheral = connectedPeripheral, peripheral.state == .connected, txCharacteristic != nil,
3698
+ (requestId == nil) == (sid == nil)
3699
+ else { return false }
3700
+ if let requestId, let sid, requestId.isEmpty || sid.isEmpty { return false }
3701
+ transportLog("LIVE: 📶 Sending WiFi forget command for SSID: \(ssid)", bridgeLogging: false)
3702
+
3703
+ guard !ssid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
3704
+ transportLog("LIVE: Cannot forget WiFi network - SSID is empty", bridgeLogging: false)
3705
+ if let requestId {
3706
+ Bridge.sendWifiForgetResult(
3707
+ requestId: requestId,
3708
+ sid: sid ?? "",
3709
+ ssid: ssid,
3710
+ protocolVersion: 1,
3711
+ outcome: "failed",
3712
+ legacyDispatched: nil,
3713
+ connected: nil,
3714
+ currentSsid: "",
3715
+ localIp: "",
3716
+ error: "invalid_ssid"
3717
+ )
3718
+ }
3719
+ return false
3655
3720
  }
3656
3721
 
3657
- let json: [String: Any] = [
3722
+ var json: [String: Any] = [
3658
3723
  "type": "forget_wifi",
3659
3724
  "ssid": ssid,
3660
3725
  ]
3726
+ if let requestId, !requestId.isEmpty {
3727
+ json["requestId"] = requestId
3728
+ }
3729
+ if let sid, !sid.isEmpty {
3730
+ json["sid"] = sid
3731
+ json["protocolVersion"] = 1
3732
+ }
3661
3733
 
3662
- sendJson(json, wakeUp: true)
3734
+ // The coordinator dispatches while isolated on MainActor. Keep this path native-only so a
3735
+ // public log delegate cannot synchronously reset the session before the payload is queued.
3736
+ return sendJson(json, wakeUp: true, requireAck: true, bridgeLogging: false)
3737
+ }
3738
+
3739
+ @discardableResult func requestSavedWifiNetworks(requestId: String, sid: String) -> Bool {
3740
+ guard let peripheral = connectedPeripheral, peripheral.state == .connected, txCharacteristic != nil,
3741
+ !requestId.isEmpty, !sid.isEmpty
3742
+ else { return false }
3743
+ var command: [String: Any] = ["type": "request_saved_wifi_networks", "requestId": requestId]
3744
+ command["protocolVersion"] = 1
3745
+ if !sid.isEmpty {
3746
+ command["sid"] = sid
3747
+ }
3748
+ return sendJson(
3749
+ command,
3750
+ wakeUp: true,
3751
+ requireAck: true,
3752
+ bridgeLogging: false
3753
+ )
3663
3754
  }
3664
3755
 
3665
3756
  func queryGalleryStatus() {
@@ -4822,20 +4913,31 @@ class MentraLive: NSObject, SGCManager {
4822
4913
  wireBytes: Int,
4823
4914
  packetCount: Int,
4824
4915
  protocolVersion: Int,
4825
- direction: String
4916
+ direction: String,
4917
+ bridgeLogging: Bool = true
4826
4918
  ) {
4827
- Bridge.log(
4828
- "BLE_TRACE direction=\(direction) proto=v\(protocolVersion) payload=\(payloadBytes) wire=\(wireBytes) packets=\(packetCount)"
4919
+ transportLog(
4920
+ "BLE_TRACE direction=\(direction) proto=v\(protocolVersion) payload=\(payloadBytes) wire=\(wireBytes) packets=\(packetCount)",
4921
+ bridgeLogging: bridgeLogging
4829
4922
  )
4830
4923
  }
4831
4924
 
4832
- private func sendJsonBinary(
4925
+ private func transportLog(_ message: String, bridgeLogging: Bool) {
4926
+ if bridgeLogging {
4927
+ Bridge.log(message)
4928
+ } else {
4929
+ NSLog("%@", message)
4930
+ }
4931
+ }
4932
+
4933
+ @discardableResult private func sendJsonBinary(
4833
4934
  jsonString: String,
4834
4935
  messageId: Int64,
4835
4936
  trackingId: String,
4836
4937
  wakeUp: Bool,
4837
- requireAck: Bool
4838
- ) {
4938
+ requireAck: Bool,
4939
+ bridgeLogging: Bool
4940
+ ) -> Bool {
4839
4941
  let payload = Data(jsonString.utf8)
4840
4942
  let msgId = UInt16(truncatingIfNeeded: messageId)
4841
4943
  let fragments = MessageChunker.createBinaryFragments(
@@ -4845,8 +4947,8 @@ class MentraLive: NSObject, SGCManager {
4845
4947
  ackRequested: requireAck && messageId >= 0
4846
4948
  )
4847
4949
  guard !fragments.isEmpty else {
4848
- Bridge.log("LIVE: Failed to create binary wire fragments")
4849
- return
4950
+ transportLog("LIVE: Failed to create binary wire fragments", bridgeLogging: bridgeLogging)
4951
+ return false
4850
4952
  }
4851
4953
 
4852
4954
  var totalWireBytes = 0
@@ -4858,7 +4960,7 @@ class MentraLive: NSObject, SGCManager {
4858
4960
  fragCount: fragment.fragCount,
4859
4961
  payload: fragment.payload
4860
4962
  ) else {
4861
- continue
4963
+ return false
4862
4964
  }
4863
4965
  totalWireBytes += packed.count
4864
4966
  let isFinalChunk = index == fragments.count - 1
@@ -4875,12 +4977,30 @@ class MentraLive: NSObject, SGCManager {
4875
4977
  wireBytes: totalWireBytes,
4876
4978
  packetCount: fragments.count,
4877
4979
  protocolVersion: BleWireProtocol.protocolV2,
4878
- direction: "phone_to_glasses"
4980
+ direction: "phone_to_glasses",
4981
+ bridgeLogging: bridgeLogging
4879
4982
  )
4880
- Bridge.log("LIVE: Binary v2 queued \(fragments.count) fragments, wireBytes=\(totalWireBytes)")
4983
+ transportLog(
4984
+ "LIVE: Binary v2 queued \(fragments.count) fragments, wireBytes=\(totalWireBytes)",
4985
+ bridgeLogging: bridgeLogging
4986
+ )
4987
+ return true
4988
+ }
4989
+
4990
+ func sendJson(
4991
+ _ jsonOriginal: [String: Any],
4992
+ wakeUp: Bool = false,
4993
+ requireAck: Bool = true
4994
+ ) {
4995
+ sendJson(jsonOriginal, wakeUp: wakeUp, requireAck: requireAck, bridgeLogging: true)
4881
4996
  }
4882
4997
 
4883
- func sendJson(_ jsonOriginal: [String: Any], wakeUp: Bool = false, requireAck: Bool = true) {
4998
+ @discardableResult private func sendJson(
4999
+ _ jsonOriginal: [String: Any],
5000
+ wakeUp: Bool,
5001
+ requireAck: Bool,
5002
+ bridgeLogging: Bool
5003
+ ) -> Bool {
4884
5004
  do {
4885
5005
  var json = jsonOriginal
4886
5006
  var messageId: Int64 = -1
@@ -4904,14 +5024,14 @@ class MentraLive: NSObject, SGCManager {
4904
5024
  )
4905
5025
 
4906
5026
  if useBinaryWireProtocol, isNewVersion {
4907
- sendJsonBinary(
5027
+ return sendJsonBinary(
4908
5028
  jsonString: jsonString,
4909
5029
  messageId: messageId,
4910
5030
  trackingId: trackingId,
4911
5031
  wakeUp: wakeUp,
4912
- requireAck: requireAck
5032
+ requireAck: requireAck,
5033
+ bridgeLogging: bridgeLogging
4913
5034
  )
4914
- return
4915
5035
  }
4916
5036
 
4917
5037
  // First check if the message needs chunking
@@ -4925,17 +5045,20 @@ class MentraLive: NSObject, SGCManager {
4925
5045
 
4926
5046
  // Check if chunking is needed
4927
5047
  if MessageChunker.needsChunking(testWrappedJson) {
4928
- Bridge.log("LIVE: Message exceeds threshold, chunking required")
5048
+ transportLog("LIVE: Message exceeds threshold, chunking required", bridgeLogging: bridgeLogging)
4929
5049
 
4930
5050
  // Create chunks
4931
5051
  let chunks = MessageChunker.createChunks(
4932
5052
  originalJson: jsonString, messageId: messageId, wakeUp: wakeUp
4933
5053
  )
4934
5054
  guard !chunks.isEmpty else {
4935
- Bridge.log("LIVE: Failed to create BLE chunks within K900 packet limit")
4936
- return
5055
+ transportLog(
5056
+ "LIVE: Failed to create BLE chunks within K900 packet limit",
5057
+ bridgeLogging: bridgeLogging
5058
+ )
5059
+ return false
4937
5060
  }
4938
- Bridge.log("LIVE: Sending \(chunks.count) chunks")
5061
+ transportLog("LIVE: Sending \(chunks.count) chunks", bridgeLogging: bridgeLogging)
4939
5062
 
4940
5063
  // Send each chunk
4941
5064
  for (index, chunk) in chunks.enumerated() {
@@ -4943,7 +5066,11 @@ class MentraLive: NSObject, SGCManager {
4943
5066
  if let chunkStr = String(data: chunkData, encoding: .utf8) {
4944
5067
  // Pack each chunk using the normal K900 protocol
4945
5068
  let packedData =
4946
- packJson(chunkStr, wakeUp: wakeUp && index == 0) ?? Data() // Only wakeup on first chunk
5069
+ packJson(
5070
+ chunkStr,
5071
+ wakeUp: wakeUp && index == 0,
5072
+ bridgeLogging: bridgeLogging
5073
+ ) ?? Data() // Only wakeup on first chunk
4947
5074
 
4948
5075
  // Queue the chunk for sending
4949
5076
  // Only track ACK for the final chunk (which has the mId)
@@ -4964,6 +5091,7 @@ class MentraLive: NSObject, SGCManager {
4964
5091
  "chunkJsonBytes": chunkStr.data(using: .utf8)?.count,
4965
5092
  "messageBytes": jsonData.count,
4966
5093
  ])
5094
+ guard !packedData.isEmpty else { return false }
4967
5095
  queueSend(packedData, id: chunkTrackingId, trace: trace)
4968
5096
 
4969
5097
  // Add small delay between chunks to avoid overwhelming the connection
@@ -4973,14 +5101,22 @@ class MentraLive: NSObject, SGCManager {
4973
5101
  }
4974
5102
  }
4975
5103
 
4976
- Bridge.log("LIVE: All chunks queued for transmission")
5104
+ transportLog("LIVE: All chunks queued for transmission", bridgeLogging: bridgeLogging)
4977
5105
  } else {
4978
5106
  // Normal single message transmission
4979
5107
  if (json["type"] as? String) == "take_photo" {
4980
- Bridge.log("LIVE: PHOTO PIPELINE BLE handoff — sendJson -> queueSend take_photo")
5108
+ transportLog(
5109
+ "LIVE: PHOTO PIPELINE BLE handoff — sendJson -> queueSend take_photo",
5110
+ bridgeLogging: bridgeLogging
5111
+ )
4981
5112
  }
4982
- Bridge.log("LIVE: Sending data to glasses: \(jsonString)")
4983
- let packedData = packJson(jsonString, wakeUp: wakeUp) ?? Data()
5113
+ transportLog("LIVE: Sending data to glasses: \(jsonString)", bridgeLogging: bridgeLogging)
5114
+ let packedData =
5115
+ packJson(
5116
+ jsonString,
5117
+ wakeUp: wakeUp,
5118
+ bridgeLogging: bridgeLogging
5119
+ ) ?? Data()
4984
5120
  let trace = createBleWriteTrace(
4985
5121
  commandInfo: commandInfo,
4986
5122
  chunkId: nil,
@@ -4994,12 +5130,15 @@ class MentraLive: NSObject, SGCManager {
4994
5130
  logBleChunkTrace("created", trace, extra: [
4995
5131
  "messageBytes": jsonData.count,
4996
5132
  ])
5133
+ guard !packedData.isEmpty else { return false }
4997
5134
  queueSend(packedData, id: trackingId, trace: trace)
4998
5135
  }
5136
+ return true
4999
5137
  }
5000
5138
  } catch {
5001
- Bridge.log("LIVE: Error creating JSON: \(error)")
5139
+ transportLog("LIVE: Error creating JSON: \(error)", bridgeLogging: bridgeLogging)
5002
5140
  }
5141
+ return false
5003
5142
  }
5004
5143
 
5005
5144
  // MARK: - Status Requests
@@ -5990,7 +6129,11 @@ extension MentraLive {
5990
6129
  * 1. Wrap with C-field: {"C": jsonData}
5991
6130
  * 2. Then pack with BES2700 protocol using little-endian: ## + type + length + {"C": jsonData} + $$
5992
6131
  */
5993
- private func packJson(_ jsonData: String?, wakeUp: Bool = false) -> Data? {
6132
+ private func packJson(
6133
+ _ jsonData: String?,
6134
+ wakeUp: Bool = false,
6135
+ bridgeLogging: Bool = true
6136
+ ) -> Data? {
5994
6137
  guard let jsonData else { return nil }
5995
6138
 
5996
6139
  do {
@@ -6013,7 +6156,10 @@ extension MentraLive {
6013
6156
  )
6014
6157
 
6015
6158
  } catch {
6016
- Bridge.log("Error creating JSON wrapper for K900: \(error)")
6159
+ transportLog(
6160
+ "Error creating JSON wrapper for K900: \(error)",
6161
+ bridgeLogging: bridgeLogging
6162
+ )
6017
6163
  return nil
6018
6164
  }
6019
6165
  }
@@ -195,8 +195,10 @@ protocol SGCManager {
195
195
  // MARK: - Network Management
196
196
 
197
197
  func requestWifiScan(scanId: String?)
198
+ @discardableResult func requestSavedWifiNetworks(requestId: String, sid: String) -> Bool
198
199
  func sendWifiCredentials(_ ssid: String, _ password: String)
199
200
  func forgetWifiNetwork(_ ssid: String)
201
+ @discardableResult func forgetWifiNetwork(_ ssid: String, requestId: String?, sid: String?) -> Bool
200
202
  func sendHotspotState(_ enabled: Bool)
201
203
  func sendWifiAdbState(_ enabled: Bool)
202
204
  func sendOtaStart(otaVersionUrl: String?)
@@ -232,7 +234,17 @@ protocol SGCManager {
232
234
  /// doesn't seem to work for concurrency reasons :(
233
235
  /// we can make read-only getters for convienence though:
234
236
  extension SGCManager {
235
- var isMicSuspendedForAudio: Bool { false }
237
+ var isMicSuspendedForAudio: Bool {
238
+ false
239
+ }
240
+
241
+ @discardableResult func requestSavedWifiNetworks(requestId _: String, sid _: String) -> Bool {
242
+ false
243
+ }
244
+
245
+ @discardableResult func forgetWifiNetwork(_: String, requestId _: String?, sid _: String?) -> Bool {
246
+ false
247
+ }
236
248
 
237
249
  /// Default: no-op. Only G2 renders positioned text containers; other glasses ignore it.
238
250
  func sendPositionedText(
@@ -376,6 +376,10 @@ public struct VersionInfoResult: CustomStringConvertible {
376
376
  public let appVersion: String
377
377
  public let packageName: String
378
378
  public let hotspotOtaVersion: Int
379
+ public let wifiForgetResultVersion: Int?
380
+ public let savedWifiNetworksVersion: Int?
381
+ public let versionInfoType: String?
382
+ public let sid: String?
379
383
 
380
384
  init(status: GlassesStatus) {
381
385
  androidVersion = status.androidVersion
@@ -388,6 +392,10 @@ public struct VersionInfoResult: CustomStringConvertible {
388
392
  appVersion = status.appVersion
389
393
  packageName = status.packageName
390
394
  hotspotOtaVersion = status.hotspotOtaVersion
395
+ wifiForgetResultVersion = nil
396
+ savedWifiNetworksVersion = nil
397
+ versionInfoType = nil
398
+ sid = nil
391
399
  }
392
400
 
393
401
  init(values: [String: Any]) {
@@ -404,6 +412,14 @@ public struct VersionInfoResult: CustomStringConvertible {
404
412
  intValue(values["hotspotOtaVersion"])
405
413
  ?? intValue(values["hotspot_ota_version"])
406
414
  ?? 0
415
+ wifiForgetResultVersion =
416
+ intValue(values["wifiForgetResultVersion"])
417
+ ?? intValue(values["wifi_forget_result_version"])
418
+ savedWifiNetworksVersion =
419
+ intValue(values["savedWifiNetworksVersion"])
420
+ ?? intValue(values["saved_wifi_networks_version"])
421
+ versionInfoType = stringValue(values, "versionInfoType", "version_info_type")
422
+ sid = stringValue(values, "sid")
407
423
  }
408
424
 
409
425
  public var dictionary: [String: Any] {
@@ -425,6 +441,18 @@ public struct VersionInfoResult: CustomStringConvertible {
425
441
  if !packageName.isEmpty {
426
442
  values["packageName"] = packageName
427
443
  }
444
+ if let wifiForgetResultVersion {
445
+ values["wifiForgetResultVersion"] = wifiForgetResultVersion
446
+ }
447
+ if let savedWifiNetworksVersion {
448
+ values["savedWifiNetworksVersion"] = savedWifiNetworksVersion
449
+ }
450
+ if let versionInfoType {
451
+ values["versionInfoType"] = versionInfoType
452
+ }
453
+ if let sid {
454
+ values["sid"] = sid
455
+ }
428
456
  return values
429
457
  }
430
458
 
@@ -0,0 +1,18 @@
1
+ import CoreFoundation
2
+ import Foundation
3
+
4
+ /// Process-scoped native ownership survives BLE reconnects, not app termination.
5
+ enum StreamControllerProbe {
6
+ static let controllerId = UUID().uuidString
7
+
8
+ static func response(_ values: [String: Any]) -> [String: Any]? {
9
+ guard let version = values["protocolVersion"] as? NSNumber,
10
+ CFGetTypeID(version) != CFBooleanGetTypeID(), version.doubleValue == 1,
11
+ values["controllerId"] as? String == controllerId,
12
+ let streamId = values["streamId"] as? String, !streamId.isEmpty,
13
+ let probeId = values["probeId"] as? String, !probeId.isEmpty
14
+ else { return nil }
15
+ return ["type": "stream_controller_response", "protocolVersion": 1,
16
+ "controllerId": controllerId, "streamId": streamId, "probeId": probeId]
17
+ }
18
+ }
@@ -629,6 +629,10 @@ public enum StreamStatus: CustomStringConvertible, Equatable {
629
629
  public struct StreamStatusEvent: CustomStringConvertible {
630
630
  public let status: StreamStatus
631
631
  public let stats: StreamLiveStats?
632
+ public private(set) var processSessionId: String?
633
+ public private(set) var revision: Int?
634
+ public private(set) var terminal: Bool?
635
+ public private(set) var errorDetails: String?
632
636
  /// True when the glasses will retry the failed publisher themselves
633
637
  /// (emitting side lands in PR #3488); absent on older firmware and on
634
638
  /// events not parsed from a glasses status map. Carried here instead of
@@ -644,6 +648,10 @@ public struct StreamStatusEvent: CustomStringConvertible {
644
648
  status = StreamStatus(values: values)
645
649
  stats = StreamLiveStats(values: values["stats"] as? [String: Any])
646
650
  willRetry = boolValue(values, "willRetry")
651
+ processSessionId = stringValue(values, "sid")
652
+ revision = optionalIntValue(values, "revision")
653
+ terminal = boolValue(values, "terminal")
654
+ errorDetails = stringValue(values, "errorDetails")
647
655
  }
648
656
 
649
657
  public var state: StreamState {
@@ -661,6 +669,10 @@ public struct StreamStatusEvent: CustomStringConvertible {
661
669
  public var values: [String: Any] {
662
670
  var values = status.values
663
671
  values["type"] = "stream_status"
672
+ if let processSessionId { values["sid"] = processSessionId }
673
+ if let revision { values["revision"] = revision }
674
+ if let terminal { values["terminal"] = terminal }
675
+ if let errorDetails { values["errorDetails"] = errorDetails }
664
676
  if let stats {
665
677
  values["stats"] = stats.values
666
678
  }
@@ -0,0 +1,29 @@
1
+ import Foundation
2
+
3
+ /// Observes glasses-owned streaming; delivery gaps never imply publisher failure.
4
+ final class StreamSessionState {
5
+ private(set) var processSessionId: String?
6
+ private(set) var currentStreamId: String?
7
+ private(set) var supported = false
8
+ private var revision: Int = -1
9
+
10
+ func ready(sessionId: String?, controlVersion: Int?) {
11
+ supported = controlVersion == 1 && !(sessionId ?? "").isEmpty
12
+ if processSessionId != sessionId {
13
+ currentStreamId = nil
14
+ revision = -1
15
+ }
16
+ processSessionId = sessionId
17
+ }
18
+
19
+ func accept(_ values: [String: Any]) -> Bool {
20
+ guard supported,
21
+ let sid = values["sid"] as? String, sid == processSessionId,
22
+ let incomingRevision = values["revision"] as? Int,
23
+ incomingRevision >= revision
24
+ else { return false }
25
+ revision = incomingRevision
26
+ currentStreamId = values["terminal"] as? Bool == true ? nil : values["streamId"] as? String
27
+ return true
28
+ }
29
+ }
@@ -0,0 +1,27 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class StreamControllerProbeTests: XCTestCase {
5
+ private func probe() -> [String: Any] {
6
+ ["protocolVersion": 1, "controllerId": StreamControllerProbe.controllerId,
7
+ "streamId": "stream", "probeId": "nonce"]
8
+ }
9
+
10
+ func testAnswersCurrentProcessChallengeWithoutJsOrTimer() {
11
+ let response = StreamControllerProbe.response(probe())
12
+ XCTAssertEqual(response?["type"] as? String, "stream_controller_response")
13
+ XCTAssertEqual(response?["probeId"] as? String, "nonce")
14
+ XCTAssertEqual(response?["streamId"] as? String, "stream")
15
+ }
16
+
17
+ func testRejectsPreviousProcessAndMalformedChallenges() {
18
+ for (key, value) in [("controllerId", "previous-process" as Any),
19
+ ("protocolVersion", 2), ("protocolVersion", true),
20
+ ("probeId", ""), ("streamId", "")]
21
+ {
22
+ var input = probe()
23
+ input[key] = value
24
+ XCTAssertNil(StreamControllerProbe.response(input))
25
+ }
26
+ }
27
+ }