@mentra/bluetooth-sdk 0.1.21-beta.5 → 3.1.0-dev.7

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 (114) hide show
  1. package/README.md +38 -2
  2. package/android/build.gradle +7 -4
  3. package/android/lc3Lib/build.gradle +18 -4
  4. package/android/silero/build.gradle +9 -1
  5. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +16 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +39 -3
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +198 -17
  8. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -0
  9. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +112 -8
  11. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
  12. package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
  13. package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  14. package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
  15. package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
  16. package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
  17. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  18. package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
  19. package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
  20. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
  21. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1174 -191
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  24. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  26. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  27. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  28. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  29. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  30. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  33. package/build/BluetoothSdk.types.d.ts +61 -2
  34. package/build/BluetoothSdk.types.d.ts.map +1 -1
  35. package/build/BluetoothSdk.types.js.map +1 -1
  36. package/build/_internal.d.ts +2 -0
  37. package/build/_internal.d.ts.map +1 -1
  38. package/build/_internal.js +2 -0
  39. package/build/_internal.js.map +1 -1
  40. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  41. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  42. package/build/_private/BluetoothSdkModule.js +1 -1
  43. package/build/_private/BluetoothSdkModule.js.map +1 -1
  44. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  45. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  46. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  47. package/build/_private/sdkOtaManifest.d.ts +3 -12
  48. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  49. package/build/_private/sdkOtaManifest.js +5 -17
  50. package/build/_private/sdkOtaManifest.js.map +1 -1
  51. package/build/generated/releaseMetadata.d.ts +12 -0
  52. package/build/generated/releaseMetadata.d.ts.map +1 -0
  53. package/build/generated/releaseMetadata.js +10 -0
  54. package/build/generated/releaseMetadata.js.map +1 -0
  55. package/build/index.d.ts +1 -1
  56. package/build/index.d.ts.map +1 -1
  57. package/build/index.js +10 -2
  58. package/build/index.js.map +1 -1
  59. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  60. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  61. package/build/ota-server/MentraOtaServer.types.js +2 -0
  62. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  63. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  64. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  65. package/build/ota-server/MentraOtaServerModule.js +3 -0
  66. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  67. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  68. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  69. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  70. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  71. package/build/ota-server/index.d.ts +3 -0
  72. package/build/ota-server/index.d.ts.map +1 -0
  73. package/build/ota-server/index.js +3 -0
  74. package/build/ota-server/index.js.map +1 -0
  75. package/expo-module.config.json +3 -2
  76. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  77. package/ios/BluetoothSdkModule.swift +16 -2
  78. package/ios/LocalIPv4.swift +150 -0
  79. package/ios/LocalOtaServer.swift +309 -0
  80. package/ios/MentraBluetoothSDK.podspec +5 -1
  81. package/ios/MentraOtaServerModule.swift +147 -0
  82. package/ios/MentraPhotoReceiverModule.swift +1 -81
  83. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  84. package/ios/Source/Bridge.swift +47 -3
  85. package/ios/Source/DeviceManager.swift +135 -10
  86. package/ios/Source/DeviceStore.swift +2 -0
  87. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  88. package/ios/Source/MentraBluetoothSDK.swift +74 -6
  89. package/ios/Source/OtaManifest.swift +5 -7
  90. package/ios/Source/audio/AudioModels.swift +17 -0
  91. package/ios/Source/camera/CameraModels.swift +1 -0
  92. package/ios/Source/events/BluetoothEvents.swift +27 -0
  93. package/ios/Source/sgcs/MentraLive.swift +439 -30
  94. package/ios/Source/sgcs/SGCManager.swift +11 -0
  95. package/ios/Source/status/DeviceStatus.swift +23 -0
  96. package/ios/Source/types/DeviceModels.swift +23 -2
  97. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  98. package/package.json +5 -1
  99. package/plugin/build/withIos.d.ts +3 -1
  100. package/plugin/build/withIos.js +22 -10
  101. package/scripts/verify-release-package.mjs +34 -0
  102. package/scripts/write-release-metadata.mjs +154 -0
  103. package/scripts/write-release-metadata.test.mjs +29 -0
  104. package/src/BluetoothSdk.types.ts +72 -3
  105. package/src/_internal.ts +2 -0
  106. package/src/_private/BluetoothSdkModule.ts +5 -5
  107. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  108. package/src/_private/sdkOtaManifest.ts +5 -17
  109. package/src/generated/releaseMetadata.ts +20 -0
  110. package/src/index.ts +19 -2
  111. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  112. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  113. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  114. package/src/ota-server/index.ts +2 -0
@@ -32,7 +32,7 @@ public class MentraPhotoReceiverModule: Module {
32
32
  receiverLock.lock()
33
33
  defer { receiverLock.unlock() }
34
34
 
35
- guard let host = bestLocalIPv4Address() else {
35
+ guard let host = LocalIPv4.bestLocalIPv4Address() else {
36
36
  throw PhotoReceiverError("No Wi-Fi/LAN IPv4 address found for this phone.")
37
37
  }
38
38
 
@@ -104,66 +104,6 @@ public class MentraPhotoReceiverModule: Module {
104
104
  ])
105
105
  }
106
106
 
107
- private func bestLocalIPv4Address() -> String? {
108
- var interfaces: UnsafeMutablePointer<ifaddrs>?
109
- guard getifaddrs(&interfaces) == 0, let first = interfaces else {
110
- return nil
111
- }
112
- defer { freeifaddrs(interfaces) }
113
-
114
- var preferredPrivate: String?
115
- var preferred: String?
116
- var privateFallback: String?
117
- var fallback: String?
118
- var cursor: UnsafeMutablePointer<ifaddrs>? = first
119
- while let current = cursor {
120
- defer { cursor = current.pointee.ifa_next }
121
- let interface = current.pointee
122
- guard let addressPointer = interface.ifa_addr,
123
- addressPointer.pointee.sa_family == UInt8(AF_INET) else {
124
- continue
125
- }
126
-
127
- let name = String(cString: interface.ifa_name)
128
- let flags = interface.ifa_flags
129
- guard (flags & UInt32(IFF_UP)) != 0,
130
- (flags & UInt32(IFF_LOOPBACK)) == 0 else {
131
- continue
132
- }
133
-
134
- var address = addressPointer.pointee
135
- var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
136
- let result = getnameinfo(
137
- &address,
138
- socklen_t(address.sa_len),
139
- &hostname,
140
- socklen_t(hostname.count),
141
- nil,
142
- 0,
143
- NI_NUMERICHOST
144
- )
145
- guard result == 0 else {
146
- continue
147
- }
148
-
149
- let ip = String(cString: hostname)
150
- guard ip != "127.0.0.1", !isLinkLocalIPv4(ip) else {
151
- continue
152
- }
153
- if isPreferredLocalInterface(name), isPrivateIPv4(ip) {
154
- preferredPrivate = preferredPrivate ?? ip
155
- } else if isPreferredLocalInterface(name) {
156
- preferred = preferred ?? ip
157
- } else if isPrivateIPv4(ip) {
158
- privateFallback = privateFallback ?? ip
159
- } else {
160
- fallback = fallback ?? ip
161
- }
162
- }
163
-
164
- return preferredPrivate ?? preferred ?? privateFallback ?? fallback
165
- }
166
-
167
107
  private func receiverResult(uploadUrl: String, host: String, port: UInt16) -> [String: Any] {
168
108
  [
169
109
  "uploadUrl": uploadUrl,
@@ -172,26 +112,6 @@ public class MentraPhotoReceiverModule: Module {
172
112
  ]
173
113
  }
174
114
 
175
- private func isPreferredLocalInterface(_ name: String) -> Bool {
176
- name == "en0" ||
177
- name.hasPrefix("bridge") ||
178
- name.hasPrefix("ap") ||
179
- name.hasPrefix("awdl") ||
180
- name.hasPrefix("llw")
181
- }
182
-
183
- private func isPrivateIPv4(_ ip: String) -> Bool {
184
- if ip.hasPrefix("192.168.") || ip.hasPrefix("10.") {
185
- return true
186
- }
187
- let parts = ip.split(separator: ".").compactMap { Int($0) }
188
- return parts.count == 4 && parts[0] == 172 && (16...31).contains(parts[1])
189
- }
190
-
191
- private func isLinkLocalIPv4(_ ip: String) -> Bool {
192
- ip.hasPrefix("169.254.")
193
- }
194
-
195
115
  private let photoPorts = [8787, 8788, 8789, 8790]
196
116
  }
197
117
 
@@ -3,12 +3,15 @@ import Foundation
3
3
  /// Defaults for the public Bluetooth SDK surface.
4
4
  enum BluetoothSdkDefaults {
5
5
  static var sdkVersion: String? {
6
+ // Release exports replace the source placeholder; Expo builds stamp the host Info.plist.
6
7
  normalizedSdkVersion(swiftPackageSdkVersion)
8
+ ?? normalizedSdkVersion(Bundle.main.object(forInfoDictionaryKey: infoSdkVersionKey) as? String)
7
9
  }
8
10
 
9
11
  static let voiceActivityDetectionEnabled = false
10
12
  static let loudnessGateEnabled = true
11
- private static let swiftPackageSdkVersion = "0.1.21-beta.5"
13
+ private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
14
+ private static let swiftPackageSdkVersion = "3.1.0-dev.7"
12
15
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
13
16
 
14
17
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -98,6 +98,27 @@ class Bridge {
98
98
  Bridge.sendTypedMessage("pair_failure", body: data)
99
99
  }
100
100
 
101
+ static func sendPairingInfo(
102
+ hadPreviousBond: Bool,
103
+ transferId: String? = nil,
104
+ pairingCode: String? = nil,
105
+ classicBondReady: Bool = false,
106
+ securePairingCapable: Bool = true,
107
+ protocolVersion: Int = 1,
108
+ binding: String? = nil
109
+ ) {
110
+ var body: [String: Any] = [
111
+ "had_previous_bond": hadPreviousBond,
112
+ "classic_bond_ready": classicBondReady,
113
+ "secure_pairing_capable": securePairingCapable,
114
+ "protocol_version": protocolVersion,
115
+ ]
116
+ if let transferId { body["transfer_id"] = transferId }
117
+ if let pairingCode { body["pairing_code"] = pairingCode }
118
+ if let binding { body["binding"] = binding }
119
+ Bridge.sendTypedMessage("pairing_info", body: body)
120
+ }
121
+
101
122
  @MainActor
102
123
  static func sendMicPcm(_ data: Data) {
103
124
  Bridge.sendTypedMessage("mic_pcm", body: micPcmEventBody(data))
@@ -108,6 +129,14 @@ class Bridge {
108
129
  Bridge.sendTypedMessage("mic_lc3", body: micLc3EventBody(data))
109
130
  }
110
131
 
132
+ @MainActor
133
+ static func sendMicHealth(_ health: MicHealth, reason: String) {
134
+ var body = health.dictionary
135
+ body["reason"] = reason
136
+ body["timestamp"] = Int64(Date().timeIntervalSince1970 * 1000)
137
+ Bridge.sendTypedMessage("mic_health", body: body)
138
+ }
139
+
111
140
  @MainActor
112
141
  private static func micPcmEventBody(_ data: Data) -> [String: Any] {
113
142
  let voiceActivityDetectionEnabled =
@@ -178,7 +207,10 @@ class Bridge {
178
207
  _ deviceName: String,
179
208
  deviceAddress: String = "",
180
209
  rssi: Int? = nil,
181
- projectName: String? = nil
210
+ projectName: String? = nil,
211
+ pairingMode: Bool? = nil,
212
+ pairingCode: String? = nil,
213
+ securePairingCapable: Bool? = nil
182
214
  ) {
183
215
  Task {
184
216
  await MainActor.run {
@@ -200,6 +232,15 @@ class Bridge {
200
232
  if let rssi {
201
233
  newResult["rssi"] = rssi
202
234
  }
235
+ if let pairingMode {
236
+ newResult["pairingMode"] = pairingMode
237
+ }
238
+ if let pairingCode, !pairingCode.isEmpty {
239
+ newResult["pairingCode"] = pairingCode
240
+ }
241
+ if let securePairingCapable {
242
+ newResult["securePairingCapable"] = securePairingCapable
243
+ }
203
244
  // Keep the public searchResults array stable as glasses are added or removed.
204
245
  // Duplicate discoveries refresh their existing row; only new glasses append.
205
246
  let uniqueResults = mergeStableSearchResults(
@@ -340,6 +381,11 @@ class Bridge {
340
381
  if let systemTimeMs = intValue(values["systemTimeMs"]) ?? intValue(values["system_time_ms"]) {
341
382
  body["systemTimeMs"] = systemTimeMs
342
383
  }
384
+ if let hotspotOtaVersion = intValue(values["hotspotOtaVersion"])
385
+ ?? intValue(values["hotspot_ota_version"])
386
+ {
387
+ body["hotspotOtaVersion"] = hotspotOtaVersion
388
+ }
343
389
  Bridge.sendTypedMessage("version_info", body: body)
344
390
  }
345
391
 
@@ -617,5 +663,3 @@ class Bridge {
617
663
 
618
664
 
619
665
 
620
-
621
-
@@ -123,6 +123,16 @@ struct ViewState {
123
123
  set { DeviceStore.shared.apply("bluetooth", "pending_wearable", newValue) }
124
124
  }
125
125
 
126
+ private var pendingDeviceName: String {
127
+ get { DeviceStore.shared.get("bluetooth", "pending_device_name") as? String ?? "" }
128
+ set { DeviceStore.shared.apply("bluetooth", "pending_device_name", newValue) }
129
+ }
130
+
131
+ private var pendingDeviceAddress: String {
132
+ get { DeviceStore.shared.get("bluetooth", "pending_device_address") as? String ?? "" }
133
+ set { DeviceStore.shared.apply("bluetooth", "pending_device_address", newValue) }
134
+ }
135
+
126
136
  private var deviceName: String {
127
137
  get { DeviceStore.shared.get("bluetooth", "device_name") as? String ?? "" }
128
138
  set { DeviceStore.shared.apply("bluetooth", "device_name", newValue) }
@@ -241,6 +251,8 @@ struct ViewState {
241
251
  }
242
252
 
243
253
  private var lastSystemTimeSyncConnectionKey = ""
254
+ private var pendingSystemTimeSync: DispatchWorkItem?
255
+ private var systemTimeSyncGeneration = 0
244
256
 
245
257
  private var systemMicUnavailable: Bool {
246
258
  get { DeviceStore.shared.get("bluetooth", "systemMicUnavailable") as? Bool ?? false }
@@ -294,6 +306,11 @@ struct ViewState {
294
306
  /// Last time we received an LC3 frame from the glasses (used by the mic
295
307
  /// inactivity watchdog).
296
308
  private var lastLc3Event: Date?
309
+ private var sequenceGapEvents: Int64 = 0
310
+ private var decodeFailures: Int64 = 0
311
+ private var lastLc3ReceivedAt: Int64?
312
+ private var lastPcmProducedAt: Int64?
313
+ private var lastLc3Sequence: Int?
297
314
  private var micReinitTimer: Timer?
298
315
 
299
316
  /// STT:
@@ -396,29 +413,87 @@ struct ViewState {
396
413
  * Decodes the glasses LC3 to PCM, then forwards to handlePcm for processing.
397
414
  * This matches Android behavior - glasses forward raw LC3, DeviceManager handles encoding.
398
415
  */
399
- func handleGlassesMicData(_ lc3Data: Data, _ frameSize: Int = 20) {
400
- lastLc3Event = Date()
416
+ func handleGlassesMicData(_ lc3Data: Data, _ frameSize: Int = 20, sequenceNumber: Int? = nil) {
417
+ recordLc3Packet(sequenceNumber: sequenceNumber)
401
418
  guard let lc3Converter = lc3Converter else {
402
419
  Bridge.log("MAN: LC3 converter not initialized")
420
+ recordMicDecodeFailure()
403
421
  return
404
422
  }
405
423
 
406
424
  guard lc3Data.count > 2 else {
407
425
  Bridge.log("MAN: Received invalid LC3 data size: \(lc3Data.count)")
426
+ recordMicDecodeFailure()
408
427
  return
409
428
  }
410
429
 
411
430
  let pcmData = lc3Converter.decode(lc3Data, frameSize: frameSize) as Data
412
431
  guard pcmData.count > 0 else {
413
432
  Bridge.log("MAN: Failed to decode glasses LC3 audio")
433
+ recordMicDecodeFailure()
414
434
  return
415
435
  }
416
436
  // Forward to handlePcm which handles SDK audio events and encoding.
437
+ lastPcmProducedAt = nowMs()
417
438
  handlePcm(pcmData)
418
439
  }
419
440
 
420
441
  func reportGlassesAudioActivity() {
421
442
  lastLc3Event = Date()
443
+ lastPcmProducedAt = nowMs()
444
+ }
445
+
446
+ private func micHealth() -> MicHealth {
447
+ MicHealth(
448
+ sequenceGapEvents: sequenceGapEvents,
449
+ decodeFailures: decodeFailures,
450
+ lastLc3ReceivedAt: lastLc3ReceivedAt,
451
+ lastPcmProducedAt: lastPcmProducedAt
452
+ )
453
+ }
454
+
455
+ func resetMicSequenceBaseline() {
456
+ lastLc3Sequence = nil
457
+ }
458
+
459
+ private func nowMs() -> Int64 {
460
+ Int64(Date().timeIntervalSince1970 * 1000)
461
+ }
462
+
463
+ private func resetMicHealth() {
464
+ sequenceGapEvents = 0
465
+ decodeFailures = 0
466
+ lastLc3ReceivedAt = nil
467
+ lastPcmProducedAt = nil
468
+ lastLc3Sequence = nil
469
+ lastLc3Event = nil
470
+ }
471
+
472
+ private func recordLc3Packet(sequenceNumber: Int?) {
473
+ let receivedAt = nowMs()
474
+ lastLc3ReceivedAt = receivedAt
475
+ lastLc3Event = Date()
476
+ var hasGap = false
477
+ if let sequenceNumber {
478
+ let normalized = sequenceNumber & 0xFF
479
+ if let previous = lastLc3Sequence {
480
+ let expected = (previous + 1) & 0xFF
481
+ if normalized != expected {
482
+ hasGap = true
483
+ sequenceGapEvents += 1
484
+ Bridge.log("MAN: LC3 packet sequence mismatch. Expected: \(expected), Got: \(normalized)")
485
+ }
486
+ }
487
+ lastLc3Sequence = normalized
488
+ }
489
+ if hasGap {
490
+ Bridge.sendMicHealth(micHealth(), reason: "sequence_gap")
491
+ }
492
+ }
493
+
494
+ private func recordMicDecodeFailure() {
495
+ decodeFailures += 1
496
+ Bridge.sendMicHealth(micHealth(), reason: "decode_failure")
422
497
  }
423
498
 
424
499
  func handlePcm(_ pcmData: Data) {
@@ -613,7 +688,7 @@ struct ViewState {
613
688
  Bridge.log("MAN: Manager already initialized, cleaning up previous sgc")
614
689
  sgc?.cleanup()
615
690
  sgc = nil
616
- lastSystemTimeSyncConnectionKey = ""
691
+ resetSystemTimeSync()
617
692
  }
618
693
 
619
694
  if sgc != nil {
@@ -929,7 +1004,16 @@ struct ViewState {
929
1004
  return
930
1005
  }
931
1006
  Bridge.log("MAN: handleDeviceReady(): \(sgc.type)")
1007
+ resetMicHealth()
932
1008
 
1009
+ if !pendingDeviceName.isEmpty {
1010
+ deviceName = pendingDeviceName
1011
+ }
1012
+ if !pendingDeviceAddress.isEmpty {
1013
+ deviceAddress = pendingDeviceAddress
1014
+ }
1015
+ pendingDeviceName = ""
1016
+ pendingDeviceAddress = ""
933
1017
  pendingWearable = ""
934
1018
  defaultWearable = sgc.type
935
1019
  searching = false
@@ -994,9 +1078,30 @@ struct ViewState {
994
1078
  }
995
1079
 
996
1080
  lastSystemTimeSyncConnectionKey = connectionKey
997
- let timestampMs = Int64(Date().timeIntervalSince1970 * 1000)
998
- Bridge.log("MAN: Syncing glasses system time once for connection: \(timestampMs)")
999
- sgc.sendSetSystemTime(timestampMs)
1081
+ pendingSystemTimeSync?.cancel()
1082
+ systemTimeSyncGeneration += 1
1083
+ let generation = systemTimeSyncGeneration
1084
+ let sync = DispatchWorkItem { [weak self] in
1085
+ guard let self,
1086
+ self.lastSystemTimeSyncConnectionKey == connectionKey,
1087
+ self.systemTimeSyncGeneration == generation
1088
+ else {
1089
+ return
1090
+ }
1091
+ self.pendingSystemTimeSync = nil
1092
+ let timestampMs = Int64(Date().timeIntervalSince1970 * 1000)
1093
+ Bridge.log("MAN: Syncing glasses system time once for connection: \(timestampMs)")
1094
+ sgc.sendSetSystemTime(timestampMs)
1095
+ }
1096
+ pendingSystemTimeSync = sync
1097
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: sync)
1098
+ }
1099
+
1100
+ private func resetSystemTimeSync() {
1101
+ pendingSystemTimeSync?.cancel()
1102
+ pendingSystemTimeSync = nil
1103
+ systemTimeSyncGeneration += 1
1104
+ lastSystemTimeSyncConnectionKey = ""
1000
1105
  }
1001
1106
 
1002
1107
  func handleControllerReady() {
@@ -1043,7 +1148,8 @@ struct ViewState {
1043
1148
 
1044
1149
  func handleDeviceDisconnected() {
1045
1150
  Bridge.log("MAN: Device disconnected")
1046
- lastSystemTimeSyncConnectionKey = ""
1151
+ resetSystemTimeSync()
1152
+ resetMicHealth()
1047
1153
  DeviceStore.shared.apply("glasses", "headUp", false)
1048
1154
  DeviceStore.shared.apply("glasses", "voiceActivityDetectionEnabled", BluetoothSdkDefaults.voiceActivityDetectionEnabled)
1049
1155
  // shouldSendBootingMessage = true // Reset for next first connect
@@ -1327,6 +1433,11 @@ struct ViewState {
1327
1433
  sgc?.sendHotspotState(enabled)
1328
1434
  }
1329
1435
 
1436
+ func setWifiAdbState(_ enabled: Bool) {
1437
+ Bridge.log("MAN: 🔧 Setting glasses Wi-Fi ADB state: \(enabled)")
1438
+ sgc?.sendWifiAdbState(enabled)
1439
+ }
1440
+
1330
1441
  func setSystemTime(_ timestampMs: Int64) {
1331
1442
  Bridge.log("MAN: Setting glasses system time: \(timestampMs)")
1332
1443
  sgc?.sendSetSystemTime(timestampMs)
@@ -1506,6 +1617,11 @@ struct ViewState {
1506
1617
  )
1507
1618
  }
1508
1619
 
1620
+ func queryVideoRecordingStatus(_ requestId: String) {
1621
+ Bridge.log("MAN: Querying video recording status: requestId=\(requestId)")
1622
+ sgc?.queryVideoRecordingStatus(requestId: requestId)
1623
+ }
1624
+
1509
1625
  func stopVideoRecording(_ requestId: String, _ webhookUrl: String?, _ authToken: String?) {
1510
1626
  Bridge.log(
1511
1627
  "MAN: onStopVideoRecording: requestId=\(requestId), webhook=\((webhookUrl?.isEmpty ?? true) ? "none" : "set")"
@@ -1626,6 +1742,10 @@ struct ViewState {
1626
1742
  return
1627
1743
  }
1628
1744
  initSGC(defaultWearable)
1745
+ if let live = sgc as? MentraLive, live.isPairingYieldActive() {
1746
+ Bridge.log("MAN: connectDefault skipped — Mentra Live pairing yield active")
1747
+ return
1748
+ }
1629
1749
  searching = true
1630
1750
  sgc?.connectById(reconnectTarget)
1631
1751
  connectDefaultController()
@@ -1675,10 +1795,10 @@ struct ViewState {
1675
1795
  disconnect()
1676
1796
  try? await Task.sleep(nanoseconds: 100 * 1_000_000) // 100ms
1677
1797
  self.searching = true
1678
- self.deviceName = name
1798
+ self.pendingDeviceName = name
1679
1799
 
1680
1800
  initSGC(self.pendingWearable)
1681
- sgc?.connectById(self.deviceName)
1801
+ sgc?.connectById(name)
1682
1802
  }
1683
1803
  }
1684
1804
 
@@ -1710,7 +1830,8 @@ struct ViewState {
1710
1830
  sgc?.clearDisplay() // clear the screen
1711
1831
  sgc?.disconnect()
1712
1832
  sgc = nil // Clear the SGC reference after disconnect
1713
- lastSystemTimeSyncConnectionKey = ""
1833
+ resetSystemTimeSync()
1834
+ resetMicHealth()
1714
1835
  searching = false
1715
1836
  micEnabled = false
1716
1837
  updateMicState()
@@ -1721,6 +1842,7 @@ struct ViewState {
1721
1842
  // previously connected pair can never be reported for the next connection.
1722
1843
  DeviceStore.shared.apply("glasses", "serialNumber", "")
1723
1844
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", "")
1845
+ DeviceStore.shared.apply("glasses", "wifiMacAddress", "")
1724
1846
  DeviceStore.shared.apply("glasses", "leftMacAddress", "")
1725
1847
  DeviceStore.shared.apply("glasses", "rightMacAddress", "")
1726
1848
  DeviceStore.shared.apply("glasses", "macAddress", "")
@@ -1752,6 +1874,9 @@ struct ViewState {
1752
1874
  defaultWearable = ""
1753
1875
  deviceName = ""
1754
1876
  deviceAddress = ""
1877
+ pendingDeviceName = ""
1878
+ pendingDeviceAddress = ""
1879
+ pendingWearable = ""
1755
1880
  Bridge.saveSetting("default_wearable", "")
1756
1881
  Bridge.saveSetting("device_name", "")
1757
1882
  Bridge.saveSetting("device_address", "")
@@ -27,6 +27,7 @@ class DeviceStore {
27
27
  store.set("glasses", "connectionState", ConnTypes.DISCONNECTED)
28
28
  store.set("glasses", "deviceModel", "")
29
29
  store.set("glasses", "firmwareVersion", "")
30
+ store.set("glasses", "hotspotOtaVersion", 0)
30
31
  store.set("glasses", "micEnabled", false)
31
32
  store.set("glasses", "voiceActivityDetectionEnabled", BluetoothSdkDefaults.voiceActivityDetectionEnabled)
32
33
  store.set("glasses", "bluetoothClassicConnected", false)
@@ -36,6 +37,7 @@ class DeviceStore {
36
37
  store.set("glasses", "caseBatteryLevel", -1)
37
38
  store.set("glasses", "headUp", false)
38
39
  store.set("glasses", "bluetoothMacAddress", "")
40
+ store.set("glasses", "wifiMacAddress", "")
39
41
  store.set("glasses", "leftMacAddress", "")
40
42
  store.set("glasses", "rightMacAddress", "")
41
43
  store.set("glasses", "serialNumber", "")
@@ -0,0 +1,11 @@
1
+ import Foundation
2
+
3
+ /// Generated by release CI. Do not edit in a release checkout.
4
+ enum GeneratedReleaseMetadata {
5
+ static let familyBaseVersion = "3.1.0"
6
+ static let releaseIdentity = "3.1.0-dev.7"
7
+ static let releaseSetId = "mentra-3.1.0-dev.7"
8
+ static let sourceCommit = "872697a6395c31d30ec75d64f039eaa763dcbb51"
9
+ static let otaManifestUrl = "https://github.com/Mentra-Community/MentraOS/releases/download/mentra-v3.1.0-dev.7/mentra-live-ota-3.1.0-dev.7.json"
10
+ static let otaManifestSha256 = "8fbea7589345658dc5dffcb394229ffc5c52fbe75352acb8f6827d83290ce750"
11
+ }
@@ -309,6 +309,8 @@ public final class MentraBluetoothSDK {
309
309
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_name", device.name)
310
310
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_address", device.identifier ?? "")
311
311
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "project_name", device.projectName ?? "")
312
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_name", "")
313
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_address", "")
312
314
  finishDefaultDeviceApply(generation: generation)
313
315
  }
314
316
 
@@ -320,6 +322,8 @@ public final class MentraBluetoothSDK {
320
322
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_name", "")
321
323
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_address", "")
322
324
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "project_name", "")
325
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_name", "")
326
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_address", "")
323
327
  finishDefaultDeviceApply(generation: generation)
324
328
  }
325
329
 
@@ -394,6 +398,9 @@ public final class MentraBluetoothSDK {
394
398
  }
395
399
  if options.saveAsDefault && !isController {
396
400
  setDefaultDevice(device)
401
+ } else if !isController {
402
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_name", device.name)
403
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_address", device.identifier ?? "")
397
404
  }
398
405
  DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_wearable", device.model.deviceType)
399
406
  DeviceManager.shared.connectByName(device.name)
@@ -418,6 +425,8 @@ public final class MentraBluetoothSDK {
418
425
 
419
426
  public func cancelConnectionAttempt() {
420
427
  clearBluetoothRestoreIntent()
428
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_name", "")
429
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_address", "")
421
430
  DeviceManager.shared.disconnect()
422
431
  }
423
432
 
@@ -428,6 +437,8 @@ public final class MentraBluetoothSDK {
428
437
 
429
438
  public func disconnect() {
430
439
  clearBluetoothRestoreIntent()
440
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_name", "")
441
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_address", "")
431
442
  DeviceManager.shared.disconnect()
432
443
  }
433
444
 
@@ -890,6 +901,13 @@ public final class MentraBluetoothSDK {
890
901
  }
891
902
  }
892
903
 
904
+ /// Fire-and-forget Mentra Live Wi-Fi ADB toggle. Glasses do not ack this
905
+ /// command, so there is no pending-response wait (unlike hotspot / Wi-Fi forget).
906
+ public func setWifiAdbState(enabled: Bool) async throws {
907
+ try requireGlassesConnected(operation: "toggle Wi-Fi ADB")
908
+ DeviceManager.shared.setWifiAdbState(enabled)
909
+ }
910
+
893
911
  func setSystemTime(timestampMs: Int64) {
894
912
  DeviceManager.shared.setSystemTime(timestampMs)
895
913
  }
@@ -1141,6 +1159,36 @@ public final class MentraBluetoothSDK {
1141
1159
  }
1142
1160
  }
1143
1161
 
1162
+ public func queryVideoRecordingStatus(requestId: String) async throws -> VideoRecordingStatusEvent {
1163
+ guard !requestId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
1164
+ throw BluetoothSdkError(
1165
+ code: "missing_request_id",
1166
+ message: "requestId is required to query video recording status."
1167
+ )
1168
+ }
1169
+ try requireGlassesConnected(operation: "query video recording status")
1170
+ let pending = PendingResponse<VideoRecordingStatusEvent>(operation: "query video recording status \(requestId)")
1171
+ guard pendingVideoRecordingRequests[requestId] == nil else {
1172
+ throw BluetoothSdkError(
1173
+ code: "request_in_flight",
1174
+ message: "A video recording command is already waiting for requestId \(requestId)."
1175
+ )
1176
+ }
1177
+ pendingVideoRecordingRequests[requestId] = PendingVideoRecordingRequest(
1178
+ expectedStatus: "recording_status",
1179
+ pending: pending
1180
+ )
1181
+ DeviceManager.shared.queryVideoRecordingStatus(requestId)
1182
+ do {
1183
+ let event = try await pending.wait()
1184
+ pendingVideoRecordingRequests.removeValue(forKey: requestId)
1185
+ return event
1186
+ } catch {
1187
+ pendingVideoRecordingRequests.removeValue(forKey: requestId)
1188
+ throw error
1189
+ }
1190
+ }
1191
+
1144
1192
  public func requestVersionInfo() async throws -> VersionInfoResult {
1145
1193
  guard pendingVersionInfo == nil else {
1146
1194
  throw BluetoothSdkError(
@@ -1165,11 +1213,13 @@ public final class MentraBluetoothSDK {
1165
1213
  }
1166
1214
  }
1167
1215
 
1168
- func setOtaVersionUrl(_ otaVersionUrl: String) throws {
1216
+ /// Select the HTTP(S) manifest used by subsequent OTA checks and installs.
1217
+ public func setOtaVersionUrl(_ otaVersionUrl: String) throws {
1169
1218
  configuredOtaVersionUrl = try OtaManifestChecker.normalizeHttpUrl(otaVersionUrl)
1170
1219
  }
1171
1220
 
1172
- func getOtaVersionUrl() throws -> String {
1221
+ /// Return the configured OTA manifest, or this SDK release's default manifest.
1222
+ public func getOtaVersionUrl() throws -> String {
1173
1223
  try configuredOtaVersionUrl ?? OtaManifestDefaults.defaultOtaVersionUrl()
1174
1224
  }
1175
1225
 
@@ -2057,6 +2107,8 @@ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2057
2107
  if !event.lc3.isEmpty {
2058
2108
  delegate?.mentraBluetoothSDK(self, didReceiveMicLc3: event)
2059
2109
  }
2110
+ case "mic_health":
2111
+ delegate?.mentraBluetoothSDK(self, didReceive: .micHealth(MicHealthEvent(values: data)))
2060
2112
  case "local_transcription":
2061
2113
  let event = LocalTranscriptionEvent(
2062
2114
  text: data["text"] as? String ?? "",
@@ -2105,6 +2157,10 @@ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2105
2157
  let event = GalleryStatusEvent(values: data)
2106
2158
  pendingGalleryStatus?.resolve(event)
2107
2159
  delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: "gallery_status", values: event.values))
2160
+ case "pairing_info":
2161
+ delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: "pairing_info", values: data))
2162
+ case "entering_pairing_mode", "owner_replaced":
2163
+ delegate?.mentraBluetoothSDK(self, didReceive: .raw(name: eventName, values: data))
2108
2164
  case "photo_response":
2109
2165
  let event = PhotoResponseEvent(values: data)
2110
2166
  handlePhotoResponseForRequests(event)
@@ -2147,7 +2203,13 @@ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2147
2203
  var resultValues = data
2148
2204
  resultValues["type"] = "ota_status"
2149
2205
  pendingOtaQuery?.resolve(OtaQueryResult(values: resultValues))
2150
- delegate?.mentraBluetoothSDK(self, didReceive: .otaStatus(OtaStatusEvent(values: resultValues)))
2206
+ let event = OtaStatusEvent(values: resultValues)
2207
+ if let errorCode = otaStartRejectionErrorCode(event) {
2208
+ pendingOtaStart?.reject(
2209
+ BluetoothSdkError(code: errorCode, message: "Glasses rejected OTA start: \(errorCode)")
2210
+ )
2211
+ }
2212
+ delegate?.mentraBluetoothSDK(self, didReceive: .otaStatus(event))
2151
2213
  case "settings_ack":
2152
2214
  let event = SettingsAckEvent(values: data)
2153
2215
  handleSettingsAckForRequests(event)
@@ -2172,6 +2234,12 @@ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2172
2234
  }
2173
2235
  }
2174
2236
 
2175
-
2176
-
2177
-
2237
+ /// OTA status messages are not request-correlated, so only known pre-ack failures settle a start.
2238
+ private func otaStartRejectionErrorCode(_ event: OtaStatusEvent) -> String? {
2239
+ guard event.status.lowercased() == "failed",
2240
+ event.errorMessage?.lowercased() == "battery_low"
2241
+ else {
2242
+ return nil
2243
+ }
2244
+ return "battery_low"
2245
+ }