@mentra/bluetooth-sdk 3.2.0-dev.195 → 3.2.0-dev.203

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 (63) hide show
  1. package/README.md +64 -45
  2. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +5 -1
  3. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +77 -1
  4. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +12 -6
  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 +318 -36
  8. package/android/src/main/java/com/mentra/bluetoothsdk/MessageAckPolicy.kt +4 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +88 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/WifiRequestResolution.kt +246 -0
  11. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +50 -16
  12. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +104 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +626 -120
  14. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycle.kt +64 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +2 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +76 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +16 -3
  18. package/android/src/test/java/com/mentra/bluetoothsdk/GlassesStatusClassicAudioTest.kt +14 -0
  19. package/android/src/test/java/com/mentra/bluetoothsdk/MessageAckPolicyTest.kt +16 -0
  20. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoBridgeTest.kt +33 -0
  21. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +169 -0
  22. package/android/src/test/java/com/mentra/bluetoothsdk/WifiRequestResolutionTest.kt +271 -0
  23. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceManifestTest.kt +57 -0
  24. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +52 -0
  25. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTrackerTest.kt +131 -0
  26. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +94 -0
  27. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattLifecycleTest.kt +112 -0
  28. package/build/BluetoothSdk.types.d.ts +62 -1
  29. package/build/BluetoothSdk.types.d.ts.map +1 -1
  30. package/build/BluetoothSdk.types.js.map +1 -1
  31. package/build/_private/BluetoothSdkModule.d.ts +3 -2
  32. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  33. package/build/_private/BluetoothSdkModule.js.map +1 -1
  34. package/build/generated/changelogCatalog.d.ts +1 -1
  35. package/build/generated/changelogCatalog.js +1 -1
  36. package/build/generated/changelogCatalog.js.map +1 -1
  37. package/build/generated/releaseMetadata.js +5 -5
  38. package/build/generated/releaseMetadata.js.map +1 -1
  39. package/build/index.d.ts +1 -1
  40. package/build/index.d.ts.map +1 -1
  41. package/build/index.js +3 -0
  42. package/build/index.js.map +1 -1
  43. package/ios/BluetoothSdkModule.swift +8 -2
  44. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  45. package/ios/Source/Bridge.swift +78 -1
  46. package/ios/Source/DeviceManager.swift +13 -6
  47. package/ios/Source/GeneratedChangelogCatalog.swift +1 -1
  48. package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
  49. package/ios/Source/MentraBluetoothSDK.swift +397 -99
  50. package/ios/Source/WifiRequestResolution.swift +251 -0
  51. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +101 -0
  52. package/ios/Source/sgcs/MentraLive.swift +179 -37
  53. package/ios/Source/sgcs/SGCManager.swift +13 -1
  54. package/ios/Source/status/DeviceStatus.swift +30 -3
  55. package/ios/Tests/VersionInfoBridgeTests.swift +32 -0
  56. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +181 -0
  57. package/ios/Tests/WifiRequestResolutionTests.swift +320 -0
  58. package/package.json +1 -1
  59. package/src/BluetoothSdk.types.ts +76 -1
  60. package/src/_private/BluetoothSdkModule.ts +4 -1
  61. package/src/generated/changelogCatalog.ts +1 -1
  62. package/src/generated/releaseMetadata.ts +5 -5
  63. package/src/index.ts +11 -0
@@ -2796,7 +2796,11 @@ class MentraLive: NSObject, SGCManager {
2796
2796
  // Record the session id BEFORE marking ready: an unsolicited glasses_ready
2797
2797
  // already runs this full remote-reset flow, so recording (not re-triggering)
2798
2798
  // is correct here; version_info detection covers the restart case.
2799
- if let sid = json["sid"] as? String, !sid.isEmpty { glassesSessionId = sid }
2799
+ glassesSessionId = (json["sid"] as? String).flatMap { $0.isEmpty ? nil : $0 }
2800
+ Bridge.sendTypedMessage(
2801
+ "wifi_protocol_session_ready",
2802
+ body: ["sid": glassesSessionId ?? ""]
2803
+ )
2800
2804
  readinessCompletedThisBleSession = true
2801
2805
  handleGlassesReady()
2802
2806
 
@@ -2834,6 +2838,33 @@ class MentraLive: NSObject, SGCManager {
2834
2838
  }
2835
2839
  updateWifiStatus(connected: connected, ssid: ssid, ip: ip, error: wifiError.isEmpty ? nil : wifiError)
2836
2840
 
2841
+ case "wifi_forget_result":
2842
+ guard wifiResponseEnvelopeIsValid(json, allowLegacy: true) else { return }
2843
+ Bridge.sendWifiForgetResult(
2844
+ requestId: json["requestId"] as? String ?? "",
2845
+ sid: json["sid"] as? String ?? "",
2846
+ ssid: json["ssid"] as? String ?? "",
2847
+ protocolVersion: (json["protocol_version"] as? NSNumber)?.intValue ?? 0,
2848
+ outcome: json["outcome"] as? String ?? "",
2849
+ legacyDispatched: json["dispatched"] as? Bool,
2850
+ connected: json["connected"] as? Bool,
2851
+ currentSsid: json["current_ssid"] as? String ?? "",
2852
+ localIp: json["local_ip"] as? String ?? "",
2853
+ error: (json["error"] as? String).flatMap { $0.isEmpty ? nil : $0 }
2854
+ )
2855
+
2856
+ case "saved_wifi_networks":
2857
+ guard wifiResponseEnvelopeIsValid(json, allowLegacy: false) else { return }
2858
+ guard let networks = json["networks"] as? [String] else { return }
2859
+ Bridge.sendSavedWifiNetworks(
2860
+ requestId: json["requestId"] as? String ?? "",
2861
+ sid: json["sid"] as? String ?? "",
2862
+ protocolVersion: (json["protocol_version"] as? NSNumber)?.intValue ?? 0,
2863
+ outcome: json["outcome"] as? String ?? "",
2864
+ networks: networks,
2865
+ error: (json["error"] as? String).flatMap { $0.isEmpty ? nil : $0 }
2866
+ )
2867
+
2837
2868
  case "hotspot_status_update":
2838
2869
  let enabled = json["hotspot_enabled"] as? Bool ?? false
2839
2870
  let ssid = json["hotspot_ssid"] as? String ?? ""
@@ -2942,7 +2973,7 @@ class MentraLive: NSObject, SGCManager {
2942
2973
  Bridge.log("LIVE: Received pong response - connection healthy")
2943
2974
 
2944
2975
  case "entering_pairing_mode":
2945
- let windowMs = max(5_000, min(180_000, json["window_ms"] as? Int ?? 120_000))
2976
+ let windowMs = max(5000, min(180_000, json["window_ms"] as? Int ?? 120_000))
2946
2977
  Bridge.log("LIVE: Glasses entering pairing mode — yield \(windowMs)ms (no forget)")
2947
2978
  enterPairingYield(windowMs: windowMs)
2948
2979
  let body: [String: Any] = [
@@ -3086,6 +3117,7 @@ class MentraLive: NSObject, SGCManager {
3086
3117
 
3087
3118
  // Extract all fields from JSON (except "type")
3088
3119
  var fields: [String: Any] = [:]
3120
+ fields["version_info_type"] = type
3089
3121
  for (key, value) in json {
3090
3122
  if key != "type" {
3091
3123
  fields[key] = value
@@ -3157,7 +3189,7 @@ class MentraLive: NSObject, SGCManager {
3157
3189
  maybeSendWireHandshake()
3158
3190
  handleGlassesSessionId(json)
3159
3191
 
3160
- Bridge.sendVersionInfo(fields)
3192
+ Bridge.sendVersionInfo(fields, responseChunk: type)
3161
3193
  } else {
3162
3194
  Bridge.log("Unhandled message type: \(type)")
3163
3195
  }
@@ -3647,19 +3679,67 @@ class MentraLive: NSObject, SGCManager {
3647
3679
  }
3648
3680
 
3649
3681
  func forgetWifiNetwork(_ ssid: String) {
3650
- Bridge.log("LIVE: 📶 Sending WiFi forget command for SSID: \(ssid)")
3682
+ forgetWifiNetwork(ssid, requestId: nil, sid: nil)
3683
+ }
3651
3684
 
3652
- guard !ssid.isEmpty else {
3653
- Bridge.log("LIVE: Cannot forget WiFi network - SSID is empty")
3654
- return
3685
+ @discardableResult func forgetWifiNetwork(_ ssid: String, requestId: String?, sid: String?) -> Bool {
3686
+ guard let peripheral = connectedPeripheral, peripheral.state == .connected, txCharacteristic != nil,
3687
+ (requestId == nil) == (sid == nil)
3688
+ else { return false }
3689
+ if let requestId, let sid, requestId.isEmpty || sid.isEmpty { return false }
3690
+ transportLog("LIVE: 📶 Sending WiFi forget command for SSID: \(ssid)", bridgeLogging: false)
3691
+
3692
+ guard !ssid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
3693
+ transportLog("LIVE: Cannot forget WiFi network - SSID is empty", bridgeLogging: false)
3694
+ if let requestId {
3695
+ Bridge.sendWifiForgetResult(
3696
+ requestId: requestId,
3697
+ sid: sid ?? "",
3698
+ ssid: ssid,
3699
+ protocolVersion: 1,
3700
+ outcome: "failed",
3701
+ legacyDispatched: nil,
3702
+ connected: nil,
3703
+ currentSsid: "",
3704
+ localIp: "",
3705
+ error: "invalid_ssid"
3706
+ )
3707
+ }
3708
+ return false
3655
3709
  }
3656
3710
 
3657
- let json: [String: Any] = [
3711
+ var json: [String: Any] = [
3658
3712
  "type": "forget_wifi",
3659
3713
  "ssid": ssid,
3660
3714
  ]
3715
+ if let requestId, !requestId.isEmpty {
3716
+ json["requestId"] = requestId
3717
+ }
3718
+ if let sid, !sid.isEmpty {
3719
+ json["sid"] = sid
3720
+ json["protocolVersion"] = 1
3721
+ }
3661
3722
 
3662
- sendJson(json, wakeUp: true)
3723
+ // The coordinator dispatches while isolated on MainActor. Keep this path native-only so a
3724
+ // public log delegate cannot synchronously reset the session before the payload is queued.
3725
+ return sendJson(json, wakeUp: true, requireAck: true, bridgeLogging: false)
3726
+ }
3727
+
3728
+ @discardableResult func requestSavedWifiNetworks(requestId: String, sid: String) -> Bool {
3729
+ guard let peripheral = connectedPeripheral, peripheral.state == .connected, txCharacteristic != nil,
3730
+ !requestId.isEmpty, !sid.isEmpty
3731
+ else { return false }
3732
+ var command: [String: Any] = ["type": "request_saved_wifi_networks", "requestId": requestId]
3733
+ command["protocolVersion"] = 1
3734
+ if !sid.isEmpty {
3735
+ command["sid"] = sid
3736
+ }
3737
+ return sendJson(
3738
+ command,
3739
+ wakeUp: true,
3740
+ requireAck: true,
3741
+ bridgeLogging: false
3742
+ )
3663
3743
  }
3664
3744
 
3665
3745
  func queryGalleryStatus() {
@@ -4822,20 +4902,31 @@ class MentraLive: NSObject, SGCManager {
4822
4902
  wireBytes: Int,
4823
4903
  packetCount: Int,
4824
4904
  protocolVersion: Int,
4825
- direction: String
4905
+ direction: String,
4906
+ bridgeLogging: Bool = true
4826
4907
  ) {
4827
- Bridge.log(
4828
- "BLE_TRACE direction=\(direction) proto=v\(protocolVersion) payload=\(payloadBytes) wire=\(wireBytes) packets=\(packetCount)"
4908
+ transportLog(
4909
+ "BLE_TRACE direction=\(direction) proto=v\(protocolVersion) payload=\(payloadBytes) wire=\(wireBytes) packets=\(packetCount)",
4910
+ bridgeLogging: bridgeLogging
4829
4911
  )
4830
4912
  }
4831
4913
 
4832
- private func sendJsonBinary(
4914
+ private func transportLog(_ message: String, bridgeLogging: Bool) {
4915
+ if bridgeLogging {
4916
+ Bridge.log(message)
4917
+ } else {
4918
+ NSLog("%@", message)
4919
+ }
4920
+ }
4921
+
4922
+ @discardableResult private func sendJsonBinary(
4833
4923
  jsonString: String,
4834
4924
  messageId: Int64,
4835
4925
  trackingId: String,
4836
4926
  wakeUp: Bool,
4837
- requireAck: Bool
4838
- ) {
4927
+ requireAck: Bool,
4928
+ bridgeLogging: Bool
4929
+ ) -> Bool {
4839
4930
  let payload = Data(jsonString.utf8)
4840
4931
  let msgId = UInt16(truncatingIfNeeded: messageId)
4841
4932
  let fragments = MessageChunker.createBinaryFragments(
@@ -4845,8 +4936,8 @@ class MentraLive: NSObject, SGCManager {
4845
4936
  ackRequested: requireAck && messageId >= 0
4846
4937
  )
4847
4938
  guard !fragments.isEmpty else {
4848
- Bridge.log("LIVE: Failed to create binary wire fragments")
4849
- return
4939
+ transportLog("LIVE: Failed to create binary wire fragments", bridgeLogging: bridgeLogging)
4940
+ return false
4850
4941
  }
4851
4942
 
4852
4943
  var totalWireBytes = 0
@@ -4858,7 +4949,7 @@ class MentraLive: NSObject, SGCManager {
4858
4949
  fragCount: fragment.fragCount,
4859
4950
  payload: fragment.payload
4860
4951
  ) else {
4861
- continue
4952
+ return false
4862
4953
  }
4863
4954
  totalWireBytes += packed.count
4864
4955
  let isFinalChunk = index == fragments.count - 1
@@ -4875,12 +4966,30 @@ class MentraLive: NSObject, SGCManager {
4875
4966
  wireBytes: totalWireBytes,
4876
4967
  packetCount: fragments.count,
4877
4968
  protocolVersion: BleWireProtocol.protocolV2,
4878
- direction: "phone_to_glasses"
4969
+ direction: "phone_to_glasses",
4970
+ bridgeLogging: bridgeLogging
4971
+ )
4972
+ transportLog(
4973
+ "LIVE: Binary v2 queued \(fragments.count) fragments, wireBytes=\(totalWireBytes)",
4974
+ bridgeLogging: bridgeLogging
4879
4975
  )
4880
- Bridge.log("LIVE: Binary v2 queued \(fragments.count) fragments, wireBytes=\(totalWireBytes)")
4976
+ return true
4977
+ }
4978
+
4979
+ func sendJson(
4980
+ _ jsonOriginal: [String: Any],
4981
+ wakeUp: Bool = false,
4982
+ requireAck: Bool = true
4983
+ ) {
4984
+ sendJson(jsonOriginal, wakeUp: wakeUp, requireAck: requireAck, bridgeLogging: true)
4881
4985
  }
4882
4986
 
4883
- func sendJson(_ jsonOriginal: [String: Any], wakeUp: Bool = false, requireAck: Bool = true) {
4987
+ @discardableResult private func sendJson(
4988
+ _ jsonOriginal: [String: Any],
4989
+ wakeUp: Bool,
4990
+ requireAck: Bool,
4991
+ bridgeLogging: Bool
4992
+ ) -> Bool {
4884
4993
  do {
4885
4994
  var json = jsonOriginal
4886
4995
  var messageId: Int64 = -1
@@ -4904,14 +5013,14 @@ class MentraLive: NSObject, SGCManager {
4904
5013
  )
4905
5014
 
4906
5015
  if useBinaryWireProtocol, isNewVersion {
4907
- sendJsonBinary(
5016
+ return sendJsonBinary(
4908
5017
  jsonString: jsonString,
4909
5018
  messageId: messageId,
4910
5019
  trackingId: trackingId,
4911
5020
  wakeUp: wakeUp,
4912
- requireAck: requireAck
5021
+ requireAck: requireAck,
5022
+ bridgeLogging: bridgeLogging
4913
5023
  )
4914
- return
4915
5024
  }
4916
5025
 
4917
5026
  // First check if the message needs chunking
@@ -4925,17 +5034,20 @@ class MentraLive: NSObject, SGCManager {
4925
5034
 
4926
5035
  // Check if chunking is needed
4927
5036
  if MessageChunker.needsChunking(testWrappedJson) {
4928
- Bridge.log("LIVE: Message exceeds threshold, chunking required")
5037
+ transportLog("LIVE: Message exceeds threshold, chunking required", bridgeLogging: bridgeLogging)
4929
5038
 
4930
5039
  // Create chunks
4931
5040
  let chunks = MessageChunker.createChunks(
4932
5041
  originalJson: jsonString, messageId: messageId, wakeUp: wakeUp
4933
5042
  )
4934
5043
  guard !chunks.isEmpty else {
4935
- Bridge.log("LIVE: Failed to create BLE chunks within K900 packet limit")
4936
- return
5044
+ transportLog(
5045
+ "LIVE: Failed to create BLE chunks within K900 packet limit",
5046
+ bridgeLogging: bridgeLogging
5047
+ )
5048
+ return false
4937
5049
  }
4938
- Bridge.log("LIVE: Sending \(chunks.count) chunks")
5050
+ transportLog("LIVE: Sending \(chunks.count) chunks", bridgeLogging: bridgeLogging)
4939
5051
 
4940
5052
  // Send each chunk
4941
5053
  for (index, chunk) in chunks.enumerated() {
@@ -4943,7 +5055,11 @@ class MentraLive: NSObject, SGCManager {
4943
5055
  if let chunkStr = String(data: chunkData, encoding: .utf8) {
4944
5056
  // Pack each chunk using the normal K900 protocol
4945
5057
  let packedData =
4946
- packJson(chunkStr, wakeUp: wakeUp && index == 0) ?? Data() // Only wakeup on first chunk
5058
+ packJson(
5059
+ chunkStr,
5060
+ wakeUp: wakeUp && index == 0,
5061
+ bridgeLogging: bridgeLogging
5062
+ ) ?? Data() // Only wakeup on first chunk
4947
5063
 
4948
5064
  // Queue the chunk for sending
4949
5065
  // Only track ACK for the final chunk (which has the mId)
@@ -4964,6 +5080,7 @@ class MentraLive: NSObject, SGCManager {
4964
5080
  "chunkJsonBytes": chunkStr.data(using: .utf8)?.count,
4965
5081
  "messageBytes": jsonData.count,
4966
5082
  ])
5083
+ guard !packedData.isEmpty else { return false }
4967
5084
  queueSend(packedData, id: chunkTrackingId, trace: trace)
4968
5085
 
4969
5086
  // Add small delay between chunks to avoid overwhelming the connection
@@ -4973,14 +5090,22 @@ class MentraLive: NSObject, SGCManager {
4973
5090
  }
4974
5091
  }
4975
5092
 
4976
- Bridge.log("LIVE: All chunks queued for transmission")
5093
+ transportLog("LIVE: All chunks queued for transmission", bridgeLogging: bridgeLogging)
4977
5094
  } else {
4978
5095
  // Normal single message transmission
4979
5096
  if (json["type"] as? String) == "take_photo" {
4980
- Bridge.log("LIVE: PHOTO PIPELINE BLE handoff — sendJson -> queueSend take_photo")
5097
+ transportLog(
5098
+ "LIVE: PHOTO PIPELINE BLE handoff — sendJson -> queueSend take_photo",
5099
+ bridgeLogging: bridgeLogging
5100
+ )
4981
5101
  }
4982
- Bridge.log("LIVE: Sending data to glasses: \(jsonString)")
4983
- let packedData = packJson(jsonString, wakeUp: wakeUp) ?? Data()
5102
+ transportLog("LIVE: Sending data to glasses: \(jsonString)", bridgeLogging: bridgeLogging)
5103
+ let packedData =
5104
+ packJson(
5105
+ jsonString,
5106
+ wakeUp: wakeUp,
5107
+ bridgeLogging: bridgeLogging
5108
+ ) ?? Data()
4984
5109
  let trace = createBleWriteTrace(
4985
5110
  commandInfo: commandInfo,
4986
5111
  chunkId: nil,
@@ -4994,12 +5119,15 @@ class MentraLive: NSObject, SGCManager {
4994
5119
  logBleChunkTrace("created", trace, extra: [
4995
5120
  "messageBytes": jsonData.count,
4996
5121
  ])
5122
+ guard !packedData.isEmpty else { return false }
4997
5123
  queueSend(packedData, id: trackingId, trace: trace)
4998
5124
  }
5125
+ return true
4999
5126
  }
5000
5127
  } catch {
5001
- Bridge.log("LIVE: Error creating JSON: \(error)")
5128
+ transportLog("LIVE: Error creating JSON: \(error)", bridgeLogging: bridgeLogging)
5002
5129
  }
5130
+ return false
5003
5131
  }
5004
5132
 
5005
5133
  // MARK: - Status Requests
@@ -5026,7 +5154,14 @@ class MentraLive: NSObject, SGCManager {
5026
5154
  }
5027
5155
 
5028
5156
  func requestVersionInfo() {
5029
- let json: [String: Any] = ["type": "request_version"]
5157
+ requestVersionInfo(requestId: nil)
5158
+ }
5159
+
5160
+ func requestVersionInfo(requestId: String?) {
5161
+ var json: [String: Any] = ["type": "request_version"]
5162
+ if let requestId {
5163
+ json["request_id"] = requestId
5164
+ }
5030
5165
  sendJson(json)
5031
5166
  }
5032
5167
 
@@ -5983,7 +6118,11 @@ extension MentraLive {
5983
6118
  * 1. Wrap with C-field: {"C": jsonData}
5984
6119
  * 2. Then pack with BES2700 protocol using little-endian: ## + type + length + {"C": jsonData} + $$
5985
6120
  */
5986
- private func packJson(_ jsonData: String?, wakeUp: Bool = false) -> Data? {
6121
+ private func packJson(
6122
+ _ jsonData: String?,
6123
+ wakeUp: Bool = false,
6124
+ bridgeLogging: Bool = true
6125
+ ) -> Data? {
5987
6126
  guard let jsonData else { return nil }
5988
6127
 
5989
6128
  do {
@@ -6006,7 +6145,10 @@ extension MentraLive {
6006
6145
  )
6007
6146
 
6008
6147
  } catch {
6009
- Bridge.log("Error creating JSON wrapper for K900: \(error)")
6148
+ transportLog(
6149
+ "Error creating JSON wrapper for K900: \(error)",
6150
+ bridgeLogging: bridgeLogging
6151
+ )
6010
6152
  return nil
6011
6153
  }
6012
6154
  }
@@ -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] {
@@ -420,12 +436,23 @@ public struct VersionInfoResult: CustomStringConvertible {
420
436
  if let systemTimeMs {
421
437
  values["systemTimeMs"] = systemTimeMs
422
438
  }
423
- // Only when known. requestVersionInfo() resolves from ANY version_info chunk, and only
424
- // chunk 1 carries package_name emitting "" from a chunk-3 resolution would overwrite a
425
- // known identity with "absent", which the OTA guard reads as stock.
439
+ // Only when known: per-chunk events may omit package identity. Emitting an empty value
440
+ // would overwrite a known identity, which the OTA guard uses to identify stock.
426
441
  if !packageName.isEmpty {
427
442
  values["packageName"] = packageName
428
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
+ }
429
456
  return values
430
457
  }
431
458
 
@@ -0,0 +1,32 @@
1
+ @testable import MentraBluetoothSDK
2
+ import XCTest
3
+
4
+ final class VersionInfoBridgeTests: XCTestCase {
5
+ func testWireMetadataSurvivesBridgeNormalization() {
6
+ let accumulator = VersionInfoResponseAccumulator(expectedRequestId: "request-1")
7
+ let delivered = expectation(description: "Both version chunks")
8
+ delivered.expectedFulfillmentCount = 2
9
+ var outcomes: [VersionInfoAccumulatorOutcome] = []
10
+ let sink = Bridge.addEventSink { type, body in
11
+ guard type == "version_info" else { return }
12
+ outcomes.append(accumulator.accept(body))
13
+ delivered.fulfill()
14
+ }
15
+ defer { Bridge.removeEventSink(sink) }
16
+ Bridge.sendVersionInfo([
17
+ "request_id": "request-1", "sid": "asg-1", "chunkCount": 2,
18
+ "chunkIndex": 1, "final": false, "build_number": "42",
19
+ ], responseChunk: "version_info_1")
20
+ Bridge.sendVersionInfo([
21
+ "request_id": "request-1", "sid": "asg-1", "chunkCount": 2,
22
+ "chunkIndex": 2, "final": true, "bes_fw_version": "new",
23
+ ], responseChunk: "version_info_3")
24
+ wait(for: [delivered], timeout: 2)
25
+ guard case .waiting = outcomes[0], case let .complete(result) = outcomes[1] else {
26
+ return XCTFail("Expected an explicitly complete combined response")
27
+ }
28
+ XCTAssertEqual(result.buildNumber, "42")
29
+ XCTAssertEqual(result.besFirmwareVersion, "new")
30
+ XCTAssertFalse(result.dictionary.keys.contains { $0.hasPrefix("_response") })
31
+ }
32
+ }