@mentra/bluetooth-sdk 0.1.21-beta.3 → 3.1.0-dev.10

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 (122) hide show
  1. package/README.md +56 -9
  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 +35 -4
  7. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +216 -23
  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/BesOtaHeartbeatGuard.kt +49 -0
  22. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1344 -284
  24. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
  29. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
  30. package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  31. package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  32. package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  33. package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  34. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
  35. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
  36. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  37. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +75 -0
  38. package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
  39. package/build/BluetoothSdk.types.d.ts +58 -2
  40. package/build/BluetoothSdk.types.d.ts.map +1 -1
  41. package/build/BluetoothSdk.types.js.map +1 -1
  42. package/build/_internal.d.ts +2 -0
  43. package/build/_internal.d.ts.map +1 -1
  44. package/build/_internal.js +2 -0
  45. package/build/_internal.js.map +1 -1
  46. package/build/_private/BluetoothSdkModule.d.ts +4 -1
  47. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  48. package/build/_private/BluetoothSdkModule.js +1 -1
  49. package/build/_private/BluetoothSdkModule.js.map +1 -1
  50. package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
  51. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  52. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  53. package/build/_private/sdkOtaManifest.d.ts +3 -12
  54. package/build/_private/sdkOtaManifest.d.ts.map +1 -1
  55. package/build/_private/sdkOtaManifest.js +5 -17
  56. package/build/_private/sdkOtaManifest.js.map +1 -1
  57. package/build/generated/releaseMetadata.d.ts +12 -0
  58. package/build/generated/releaseMetadata.d.ts.map +1 -0
  59. package/build/generated/releaseMetadata.js +10 -0
  60. package/build/generated/releaseMetadata.js.map +1 -0
  61. package/build/index.d.ts +1 -1
  62. package/build/index.d.ts.map +1 -1
  63. package/build/index.js +10 -2
  64. package/build/index.js.map +1 -1
  65. package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
  66. package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
  67. package/build/ota-server/MentraOtaServer.types.js +2 -0
  68. package/build/ota-server/MentraOtaServer.types.js.map +1 -0
  69. package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
  70. package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
  71. package/build/ota-server/MentraOtaServerModule.js +3 -0
  72. package/build/ota-server/MentraOtaServerModule.js.map +1 -0
  73. package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
  74. package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
  75. package/build/ota-server/MentraOtaServerModule.web.js +12 -0
  76. package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
  77. package/build/ota-server/index.d.ts +3 -0
  78. package/build/ota-server/index.d.ts.map +1 -0
  79. package/build/ota-server/index.js +3 -0
  80. package/build/ota-server/index.js.map +1 -0
  81. package/expo-module.config.json +3 -2
  82. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  83. package/ios/BluetoothSdkModule.swift +16 -2
  84. package/ios/LocalIPv4.swift +150 -0
  85. package/ios/LocalOtaServer.swift +309 -0
  86. package/ios/MentraBluetoothSDK.podspec +5 -1
  87. package/ios/MentraOtaServerModule.swift +147 -0
  88. package/ios/MentraPhotoReceiverModule.swift +1 -81
  89. package/ios/Source/BluetoothSdkDefaults.swift +4 -1
  90. package/ios/Source/Bridge.swift +43 -4
  91. package/ios/Source/DeviceManager.swift +154 -19
  92. package/ios/Source/DeviceStore.swift +2 -0
  93. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  94. package/ios/Source/MentraBluetoothSDK.swift +74 -6
  95. package/ios/Source/OtaManifest.swift +5 -7
  96. package/ios/Source/audio/AudioModels.swift +17 -0
  97. package/ios/Source/camera/CameraModels.swift +1 -0
  98. package/ios/Source/events/BluetoothEvents.swift +27 -0
  99. package/ios/Source/sgcs/MentraLive.swift +469 -77
  100. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  101. package/ios/Source/sgcs/SGCManager.swift +11 -0
  102. package/ios/Source/status/DeviceStatus.swift +23 -0
  103. package/ios/Source/types/DeviceModels.swift +23 -2
  104. package/ios/Source/utils/BleJsonCompact.swift +73 -71
  105. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +104 -0
  106. package/package.json +5 -1
  107. package/plugin/build/withIos.d.ts +3 -1
  108. package/plugin/build/withIos.js +22 -10
  109. package/scripts/verify-release-package.mjs +34 -0
  110. package/scripts/write-release-metadata.mjs +154 -0
  111. package/scripts/write-release-metadata.test.mjs +29 -0
  112. package/src/BluetoothSdk.types.ts +69 -3
  113. package/src/_internal.ts +2 -0
  114. package/src/_private/BluetoothSdkModule.ts +5 -5
  115. package/src/_private/MentraLocalNetworkModule.ts +1 -1
  116. package/src/_private/sdkOtaManifest.ts +5 -17
  117. package/src/generated/releaseMetadata.ts +20 -0
  118. package/src/index.ts +19 -2
  119. package/src/ota-server/MentraOtaServer.types.ts +10 -0
  120. package/src/ota-server/MentraOtaServerModule.ts +23 -0
  121. package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
  122. package/src/ota-server/index.ts +2 -0
@@ -0,0 +1,147 @@
1
+ import ExpoModulesCore
2
+ import Foundation
3
+
4
+ /// Expo surface for the hotspot-served OTA file server (OS-1676).
5
+ ///
6
+ /// JS hands over the rewritten manifest body and a map of sha256 -> local file path, and gets
7
+ /// back the base URL the glasses should be pointed at via ota_start. The optional host
8
+ /// argument lets the caller pass an authoritative address; otherwise interface scanning is
9
+ /// used (under a glasses-hotspot session en0 holds the hotspot client address).
10
+ public class MentraOtaServerModule: Module {
11
+ private let serverLock = NSLock()
12
+ private var otaServer: LocalOtaServer?
13
+ private lazy var artifactDownloader = BackgroundOtaArtifactDownloader { [weak self] destination, written, total in
14
+ self?.sendEvent("artifactDownloadProgress", [
15
+ "destination": destination,
16
+ "bytesWritten": written,
17
+ "contentLength": total,
18
+ ])
19
+ }
20
+
21
+ public func definition() -> ModuleDefinition {
22
+ Name("MentraOtaServer")
23
+
24
+ Events("artifactDownloadProgress")
25
+
26
+ AsyncFunction("startOtaServer") { (manifestJson: String, artifactPaths: [String: String], host: String?) -> [String: Any] in
27
+ try self.startOtaServer(manifestJson: manifestJson, artifactPaths: artifactPaths, hostOverride: host)
28
+ }
29
+
30
+ AsyncFunction("stopOtaServer") {
31
+ self.stopOtaServerInternal()
32
+ }
33
+
34
+ AsyncFunction("waitForWifiAddress") { (gateway: String, timeoutMs: Int) -> String in
35
+ try await self.waitForWifiAddress(gateway: gateway, timeoutMs: timeoutMs)
36
+ }
37
+
38
+ AsyncFunction("downloadArtifact") { (source: String, destination: String) in
39
+ try await self.artifactDownloader.download(from: source, to: destination)
40
+ }
41
+
42
+ OnDestroy {
43
+ self.stopOtaServerInternal()
44
+ }
45
+ }
46
+
47
+ private func startOtaServer(
48
+ manifestJson: String,
49
+ artifactPaths: [String: String],
50
+ hostOverride: String?
51
+ ) throws -> [String: Any] {
52
+ serverLock.lock()
53
+ defer { serverLock.unlock() }
54
+
55
+ var artifacts: [String: URL] = [:]
56
+ for (key, path) in artifactPaths {
57
+ let url = path.hasPrefix("file://")
58
+ ? (URL(string: path) ?? URL(fileURLWithPath: String(path.dropFirst("file://".count))))
59
+ : URL(fileURLWithPath: path)
60
+ guard FileManager.default.fileExists(atPath: url.path) else {
61
+ throw OtaServerModuleError("Artifact \(key) not found at \(url.path)")
62
+ }
63
+ artifacts[key] = url
64
+ }
65
+
66
+ guard let host = hostOverride?.isEmpty == false ? hostOverride : LocalIPv4.bestLocalIPv4Address() else {
67
+ throw OtaServerModuleError("No Wi-Fi/LAN IPv4 address found for this phone.")
68
+ }
69
+
70
+ let server = otaServer ?? LocalOtaServer(
71
+ onLog: { [weak self] message in
72
+ self?.emitStatus(message: message)
73
+ }
74
+ )
75
+ otaServer = server
76
+ server.configure(manifestJson: manifestJson, artifacts: artifacts)
77
+
78
+ if let activePort = server.activePort {
79
+ emitStatus(message: "OTA server ready at http://\(host):\(activePort)/version.json")
80
+ return serverResult(host: host, port: activePort)
81
+ }
82
+
83
+ var lastError: Error?
84
+ for port in otaPorts {
85
+ do {
86
+ let actualPort = try server.start(host: host, port: UInt16(port))
87
+ emitStatus(message: "OTA server ready at http://\(host):\(actualPort)/version.json")
88
+ return serverResult(host: host, port: actualPort)
89
+ } catch {
90
+ lastError = error
91
+ emitStatus(message: "Port \(port) unavailable: \(error.localizedDescription)")
92
+ }
93
+ }
94
+
95
+ throw OtaServerModuleError(
96
+ "Could not start OTA server: \(lastError?.localizedDescription ?? "all ports unavailable")"
97
+ )
98
+ }
99
+
100
+ private func waitForWifiAddress(gateway: String, timeoutMs: Int) async throws -> String {
101
+ let deadline = Date().addingTimeInterval(Double(max(timeoutMs, 0)) / 1000.0)
102
+ repeat {
103
+ if let address = LocalIPv4.wifiIPv4Address(matchingGateway: gateway) {
104
+ return address
105
+ }
106
+ try await Task.sleep(nanoseconds: 250_000_000)
107
+ } while Date() < deadline
108
+
109
+ throw OtaServerModuleError("The iPhone did not join the glasses hotspot subnet.")
110
+ }
111
+
112
+ private func stopOtaServerInternal() {
113
+ serverLock.lock()
114
+ defer { serverLock.unlock() }
115
+
116
+ otaServer?.stop()
117
+ emitStatus(message: "OTA server stopped")
118
+ }
119
+
120
+ private func emitStatus(message: String) {
121
+ NSLog("[MentraOtaServer] %@", message)
122
+ }
123
+
124
+ private func serverResult(host: String, port: UInt16) -> [String: Any] {
125
+ [
126
+ "baseUrl": "http://\(host):\(port)",
127
+ "manifestUrl": "http://\(host):\(port)/version.json",
128
+ "host": host,
129
+ "port": port,
130
+ ]
131
+ }
132
+
133
+ private let otaPorts = [8791, 8792, 8793, 8794]
134
+ }
135
+
136
+ final class OtaServerModuleError: Exception, @unchecked Sendable {
137
+ private let message: String
138
+
139
+ init(_ message: String) {
140
+ self.message = message
141
+ super.init()
142
+ }
143
+
144
+ override var reason: String {
145
+ message
146
+ }
147
+ }
@@ -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.3"
13
+ private static let infoSdkVersionKey = "MentraBluetoothSdkVersion"
14
+ private static let swiftPackageSdkVersion = "3.1.0-dev.10"
12
15
  private static let swiftPackageSdkVersionPlaceholder = "__MENTRA" + "_BLUETOOTH_SDK_VERSION__"
13
16
 
14
17
  private static func normalizedSdkVersion(_ value: String?) -> String? {
@@ -98,6 +98,23 @@ class Bridge {
98
98
  Bridge.sendTypedMessage("pair_failure", body: data)
99
99
  }
100
100
 
101
+ static func sendPairingInfo(
102
+ hadPreviousBond: Bool,
103
+ pairingCode: String? = nil,
104
+ classicBondReady: Bool = false,
105
+ securePairingCapable: Bool = true,
106
+ protocolVersion: Int = 1
107
+ ) {
108
+ var body: [String: Any] = [
109
+ "had_previous_bond": hadPreviousBond,
110
+ "classic_bond_ready": classicBondReady,
111
+ "secure_pairing_capable": securePairingCapable,
112
+ "protocol_version": protocolVersion,
113
+ ]
114
+ if let pairingCode { body["pairing_code"] = pairingCode }
115
+ Bridge.sendTypedMessage("pairing_info", body: body)
116
+ }
117
+
101
118
  @MainActor
102
119
  static func sendMicPcm(_ data: Data) {
103
120
  Bridge.sendTypedMessage("mic_pcm", body: micPcmEventBody(data))
@@ -108,6 +125,14 @@ class Bridge {
108
125
  Bridge.sendTypedMessage("mic_lc3", body: micLc3EventBody(data))
109
126
  }
110
127
 
128
+ @MainActor
129
+ static func sendMicHealth(_ health: MicHealth, reason: String) {
130
+ var body = health.dictionary
131
+ body["reason"] = reason
132
+ body["timestamp"] = Int64(Date().timeIntervalSince1970 * 1000)
133
+ Bridge.sendTypedMessage("mic_health", body: body)
134
+ }
135
+
111
136
  @MainActor
112
137
  private static func micPcmEventBody(_ data: Data) -> [String: Any] {
113
138
  let voiceActivityDetectionEnabled =
@@ -178,7 +203,10 @@ class Bridge {
178
203
  _ deviceName: String,
179
204
  deviceAddress: String = "",
180
205
  rssi: Int? = nil,
181
- projectName: String? = nil
206
+ projectName: String? = nil,
207
+ pairingMode: Bool? = nil,
208
+ pairingCode: String? = nil,
209
+ securePairingCapable: Bool? = nil
182
210
  ) {
183
211
  Task {
184
212
  await MainActor.run {
@@ -200,6 +228,15 @@ class Bridge {
200
228
  if let rssi {
201
229
  newResult["rssi"] = rssi
202
230
  }
231
+ if let pairingMode {
232
+ newResult["pairingMode"] = pairingMode
233
+ }
234
+ if let pairingCode, !pairingCode.isEmpty {
235
+ newResult["pairingCode"] = pairingCode
236
+ }
237
+ if let securePairingCapable {
238
+ newResult["securePairingCapable"] = securePairingCapable
239
+ }
203
240
  // Keep the public searchResults array stable as glasses are added or removed.
204
241
  // Duplicate discoveries refresh their existing row; only new glasses append.
205
242
  let uniqueResults = mergeStableSearchResults(
@@ -340,6 +377,11 @@ class Bridge {
340
377
  if let systemTimeMs = intValue(values["systemTimeMs"]) ?? intValue(values["system_time_ms"]) {
341
378
  body["systemTimeMs"] = systemTimeMs
342
379
  }
380
+ if let hotspotOtaVersion = intValue(values["hotspotOtaVersion"])
381
+ ?? intValue(values["hotspot_ota_version"])
382
+ {
383
+ body["hotspotOtaVersion"] = hotspotOtaVersion
384
+ }
343
385
  Bridge.sendTypedMessage("version_info", body: body)
344
386
  }
345
387
 
@@ -616,6 +658,3 @@ class Bridge {
616
658
 
617
659
 
618
660
 
619
-
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 {
@@ -704,14 +779,16 @@ struct ViewState {
704
779
  let layoutType = currentViewState.layoutType
705
780
  switch layoutType {
706
781
  case "text_wall":
707
- let text = currentViewState.text
782
+ let text = parsePlaceholders(currentViewState.text)
708
783
  await sgc?.sendTextWall(text)
709
784
  case "double_text_wall":
710
- let topText = currentViewState.topText
711
- let bottomText = currentViewState.bottomText
785
+ let topText = parsePlaceholders(currentViewState.topText)
786
+ let bottomText = parsePlaceholders(currentViewState.bottomText)
712
787
  await sgc?.sendDoubleTextWall(topText, bottomText)
713
788
  case "reference_card":
714
- await sgc?.sendTextWall(currentViewState.title + "\n\n" + currentViewState.text)
789
+ let title = parsePlaceholders(currentViewState.title)
790
+ let text = parsePlaceholders(currentViewState.text)
791
+ await sgc?.sendTextWall(title + "\n\n" + text)
715
792
  case "bitmap_view":
716
793
  // Bridge.log("MAN: Processing bitmap_view layout")
717
794
  guard let data = currentViewState.data else {
@@ -727,11 +804,12 @@ struct ViewState {
727
804
  height: currentViewState.bmpHeight
728
805
  )
729
806
  case "positioned_text":
807
+ let text = parsePlaceholders(currentViewState.text)
730
808
  Bridge.log(
731
- "MAN: positioned_text -> text='\(currentViewState.text)' rect=\(currentViewState.bmpX ?? 0),\(currentViewState.bmpY ?? 0) \(currentViewState.bmpWidth ?? 576)x\(currentViewState.bmpHeight ?? 288)"
809
+ "MAN: positioned_text -> text='\(text)' rect=\(currentViewState.bmpX ?? 0),\(currentViewState.bmpY ?? 0) \(currentViewState.bmpWidth ?? 576)x\(currentViewState.bmpHeight ?? 288)"
732
810
  )
733
811
  await sgc?.sendPositionedText(
734
- currentViewState.text,
812
+ text,
735
813
  x: currentViewState.bmpX ?? 0,
736
814
  y: currentViewState.bmpY ?? 0,
737
815
  width: currentViewState.bmpWidth ?? 576,
@@ -753,6 +831,8 @@ struct ViewState {
753
831
  }
754
832
 
755
833
  func parsePlaceholders(_ text: String) -> String {
834
+ guard text.contains("$") else { return text }
835
+
756
836
  let dateFormatter = DateFormatter()
757
837
  dateFormatter.dateFormat = "M/dd, h:mm"
758
838
  let formattedDate = dateFormatter.string(from: Date())
@@ -924,8 +1004,15 @@ struct ViewState {
924
1004
  return
925
1005
  }
926
1006
  Bridge.log("MAN: handleDeviceReady(): \(sgc.type)")
1007
+ resetMicHealth()
927
1008
 
928
- pendingWearable = ""
1009
+ if !pendingDeviceName.isEmpty {
1010
+ deviceName = pendingDeviceName
1011
+ }
1012
+ if !pendingDeviceAddress.isEmpty {
1013
+ deviceAddress = pendingDeviceAddress
1014
+ }
1015
+ clearPendingConnection()
929
1016
  defaultWearable = sgc.type
930
1017
  searching = false
931
1018
 
@@ -989,9 +1076,30 @@ struct ViewState {
989
1076
  }
990
1077
 
991
1078
  lastSystemTimeSyncConnectionKey = connectionKey
992
- let timestampMs = Int64(Date().timeIntervalSince1970 * 1000)
993
- Bridge.log("MAN: Syncing glasses system time once for connection: \(timestampMs)")
994
- sgc.sendSetSystemTime(timestampMs)
1079
+ pendingSystemTimeSync?.cancel()
1080
+ systemTimeSyncGeneration += 1
1081
+ let generation = systemTimeSyncGeneration
1082
+ let sync = DispatchWorkItem { [weak self] in
1083
+ guard let self,
1084
+ self.lastSystemTimeSyncConnectionKey == connectionKey,
1085
+ self.systemTimeSyncGeneration == generation
1086
+ else {
1087
+ return
1088
+ }
1089
+ self.pendingSystemTimeSync = nil
1090
+ let timestampMs = Int64(Date().timeIntervalSince1970 * 1000)
1091
+ Bridge.log("MAN: Syncing glasses system time once for connection: \(timestampMs)")
1092
+ sgc.sendSetSystemTime(timestampMs)
1093
+ }
1094
+ pendingSystemTimeSync = sync
1095
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: sync)
1096
+ }
1097
+
1098
+ private func resetSystemTimeSync() {
1099
+ pendingSystemTimeSync?.cancel()
1100
+ pendingSystemTimeSync = nil
1101
+ systemTimeSyncGeneration += 1
1102
+ lastSystemTimeSyncConnectionKey = ""
995
1103
  }
996
1104
 
997
1105
  func handleControllerReady() {
@@ -1038,7 +1146,8 @@ struct ViewState {
1038
1146
 
1039
1147
  func handleDeviceDisconnected() {
1040
1148
  Bridge.log("MAN: Device disconnected")
1041
- lastSystemTimeSyncConnectionKey = ""
1149
+ resetSystemTimeSync()
1150
+ resetMicHealth()
1042
1151
  DeviceStore.shared.apply("glasses", "headUp", false)
1043
1152
  DeviceStore.shared.apply("glasses", "voiceActivityDetectionEnabled", BluetoothSdkDefaults.voiceActivityDetectionEnabled)
1044
1153
  // shouldSendBootingMessage = true // Reset for next first connect
@@ -1322,6 +1431,11 @@ struct ViewState {
1322
1431
  sgc?.sendHotspotState(enabled)
1323
1432
  }
1324
1433
 
1434
+ func setWifiAdbState(_ enabled: Bool) {
1435
+ Bridge.log("MAN: 🔧 Setting glasses Wi-Fi ADB state: \(enabled)")
1436
+ sgc?.sendWifiAdbState(enabled)
1437
+ }
1438
+
1325
1439
  func setSystemTime(_ timestampMs: Int64) {
1326
1440
  Bridge.log("MAN: Setting glasses system time: \(timestampMs)")
1327
1441
  sgc?.sendSetSystemTime(timestampMs)
@@ -1501,6 +1615,11 @@ struct ViewState {
1501
1615
  )
1502
1616
  }
1503
1617
 
1618
+ func queryVideoRecordingStatus(_ requestId: String) {
1619
+ Bridge.log("MAN: Querying video recording status: requestId=\(requestId)")
1620
+ sgc?.queryVideoRecordingStatus(requestId: requestId)
1621
+ }
1622
+
1504
1623
  func stopVideoRecording(_ requestId: String, _ webhookUrl: String?, _ authToken: String?) {
1505
1624
  Bridge.log(
1506
1625
  "MAN: onStopVideoRecording: requestId=\(requestId), webhook=\((webhookUrl?.isEmpty ?? true) ? "none" : "set")"
@@ -1621,6 +1740,10 @@ struct ViewState {
1621
1740
  return
1622
1741
  }
1623
1742
  initSGC(defaultWearable)
1743
+ if let live = sgc as? MentraLive, live.isPairingYieldActive() {
1744
+ Bridge.log("MAN: connectDefault skipped — Mentra Live pairing yield active")
1745
+ return
1746
+ }
1624
1747
  searching = true
1625
1748
  sgc?.connectById(reconnectTarget)
1626
1749
  connectDefaultController()
@@ -1667,13 +1790,13 @@ struct ViewState {
1667
1790
  }
1668
1791
 
1669
1792
  Task {
1670
- disconnect()
1793
+ disconnect(clearPendingConnection: false)
1671
1794
  try? await Task.sleep(nanoseconds: 100 * 1_000_000) // 100ms
1672
1795
  self.searching = true
1673
- self.deviceName = name
1796
+ self.pendingDeviceName = name
1674
1797
 
1675
1798
  initSGC(self.pendingWearable)
1676
- sgc?.connectById(self.deviceName)
1799
+ sgc?.connectById(name)
1677
1800
  }
1678
1801
  }
1679
1802
 
@@ -1701,11 +1824,12 @@ struct ViewState {
1701
1824
  handleDeviceReady()
1702
1825
  }
1703
1826
 
1704
- func disconnect() {
1827
+ func disconnect(clearPendingConnection: Bool = true) {
1705
1828
  sgc?.clearDisplay() // clear the screen
1706
1829
  sgc?.disconnect()
1707
1830
  sgc = nil // Clear the SGC reference after disconnect
1708
- lastSystemTimeSyncConnectionKey = ""
1831
+ resetSystemTimeSync()
1832
+ resetMicHealth()
1709
1833
  searching = false
1710
1834
  micEnabled = false
1711
1835
  updateMicState()
@@ -1716,6 +1840,7 @@ struct ViewState {
1716
1840
  // previously connected pair can never be reported for the next connection.
1717
1841
  DeviceStore.shared.apply("glasses", "serialNumber", "")
1718
1842
  DeviceStore.shared.apply("glasses", "bluetoothMacAddress", "")
1843
+ DeviceStore.shared.apply("glasses", "wifiMacAddress", "")
1719
1844
  DeviceStore.shared.apply("glasses", "leftMacAddress", "")
1720
1845
  DeviceStore.shared.apply("glasses", "rightMacAddress", "")
1721
1846
  DeviceStore.shared.apply("glasses", "macAddress", "")
@@ -1728,6 +1853,15 @@ struct ViewState {
1728
1853
  DeviceStore.shared.apply("glasses", "controllerConnected", false)
1729
1854
  controller?.disconnect()
1730
1855
  controller = nil // Clear the controller reference after disconnect
1856
+ if clearPendingConnection {
1857
+ self.clearPendingConnection()
1858
+ }
1859
+ }
1860
+
1861
+ private func clearPendingConnection() {
1862
+ pendingDeviceName = ""
1863
+ pendingDeviceAddress = ""
1864
+ pendingWearable = ""
1731
1865
  }
1732
1866
 
1733
1867
  func disconnectController() {
@@ -1747,6 +1881,7 @@ struct ViewState {
1747
1881
  defaultWearable = ""
1748
1882
  deviceName = ""
1749
1883
  deviceAddress = ""
1884
+ clearPendingConnection()
1750
1885
  Bridge.saveSetting("default_wearable", "")
1751
1886
  Bridge.saveSetting("device_name", "")
1752
1887
  Bridge.saveSetting("device_address", "")