@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,3 @@
1
+ export { default } from "./MentraOtaServerModule";
2
+ export * from "./MentraOtaServer.types";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/ota-server/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,OAAO,EAAC,MAAM,yBAAyB,CAAA;AAC/C,cAAc,yBAAyB,CAAA","sourcesContent":["export {default} from \"./MentraOtaServerModule\"\nexport * from \"./MentraOtaServer.types\"\n"]}
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "platforms": ["apple", "android"],
3
3
  "apple": {
4
- "modules": ["BluetoothSdkModule", "MentraPhotoReceiverModule"]
4
+ "modules": ["BluetoothSdkModule", "MentraPhotoReceiverModule", "MentraOtaServerModule"]
5
5
  },
6
6
  "android": {
7
7
  "modules": [
8
8
  "com.mentra.bluetoothsdk.BluetoothSdkModule",
9
9
  "com.mentra.bluetoothsdk.MentraLocalNetworkModule",
10
- "com.mentra.bluetoothsdk.photoreceiver.MentraPhotoReceiverModule"
10
+ "com.mentra.bluetoothsdk.photoreceiver.MentraPhotoReceiverModule",
11
+ "com.mentra.bluetoothsdk.otaserver.MentraOtaServerModule"
11
12
  ]
12
13
  }
13
14
  }
@@ -0,0 +1,125 @@
1
+ import Foundation
2
+
3
+ /// Background-capable, native artifact staging for hotspot OTA.
4
+ final class BackgroundOtaArtifactDownloader: NSObject, URLSessionDownloadDelegate, URLSessionTaskDelegate {
5
+ typealias ProgressHandler = (_ destination: String, _ written: Int64, _ total: Int64) -> Void
6
+
7
+ private struct Pending {
8
+ let continuation: CheckedContinuation<[String: Any], Error>
9
+ }
10
+
11
+ private let lock = NSLock()
12
+ private let onProgress: ProgressHandler
13
+ private var pending: [Int: Pending] = [:]
14
+ private lazy var session: URLSession = {
15
+ let configuration = URLSessionConfiguration.background(
16
+ withIdentifier: "com.mentra.bluetooth-sdk.hotspot-ota-artifacts"
17
+ )
18
+ configuration.sessionSendsLaunchEvents = true
19
+ configuration.isDiscretionary = false
20
+ configuration.waitsForConnectivity = true
21
+ return URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
22
+ }()
23
+
24
+ init(onProgress: @escaping ProgressHandler) {
25
+ self.onProgress = onProgress
26
+ super.init()
27
+ }
28
+
29
+ func download(from source: String, to destination: String) async throws -> [String: Any] {
30
+ guard let sourceUrl = URL(string: source),
31
+ sourceUrl.scheme == "https" || sourceUrl.scheme == "http"
32
+ else {
33
+ throw OtaServerError("Invalid artifact URL")
34
+ }
35
+
36
+ if FileManager.default.fileExists(atPath: destination) {
37
+ let size = fileSize(atPath: destination)
38
+ return ["statusCode": 200, "bytesWritten": size]
39
+ }
40
+
41
+ try FileManager.default.createDirectory(
42
+ at: URL(fileURLWithPath: destination).deletingLastPathComponent(),
43
+ withIntermediateDirectories: true
44
+ )
45
+
46
+ return try await withCheckedThrowingContinuation { continuation in
47
+ let task = session.downloadTask(with: sourceUrl)
48
+ task.taskDescription = destination
49
+ lock.lock()
50
+ pending[task.taskIdentifier] = Pending(continuation: continuation)
51
+ lock.unlock()
52
+ task.resume()
53
+ }
54
+ }
55
+
56
+ func urlSession(
57
+ _: URLSession,
58
+ downloadTask: URLSessionDownloadTask,
59
+ didWriteData _: Int64,
60
+ totalBytesWritten: Int64,
61
+ totalBytesExpectedToWrite: Int64
62
+ ) {
63
+ guard let destination = downloadTask.taskDescription else { return }
64
+ onProgress(destination, totalBytesWritten, totalBytesExpectedToWrite)
65
+ }
66
+
67
+ func urlSession(
68
+ _: URLSession,
69
+ downloadTask: URLSessionDownloadTask,
70
+ didFinishDownloadingTo location: URL
71
+ ) {
72
+ guard let destination = downloadTask.taskDescription else { return }
73
+ guard let response = downloadTask.response as? HTTPURLResponse,
74
+ (200 ... 299).contains(response.statusCode)
75
+ else {
76
+ let status = (downloadTask.response as? HTTPURLResponse)?.statusCode ?? 0
77
+ reject(
78
+ taskId: downloadTask.taskIdentifier,
79
+ error: OtaServerError("Artifact download failed with HTTP \(status)")
80
+ )
81
+ return
82
+ }
83
+ let destinationUrl = URL(fileURLWithPath: destination)
84
+ do {
85
+ try? FileManager.default.removeItem(at: destinationUrl)
86
+ try FileManager.default.moveItem(at: location, to: destinationUrl)
87
+ let size = fileSize(atPath: destination)
88
+ resolve(
89
+ taskId: downloadTask.taskIdentifier,
90
+ result: ["statusCode": response.statusCode, "bytesWritten": size]
91
+ )
92
+ } catch {
93
+ reject(taskId: downloadTask.taskIdentifier, error: error)
94
+ }
95
+ }
96
+
97
+ func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
98
+ if let error {
99
+ reject(taskId: task.taskIdentifier, error: error)
100
+ }
101
+ }
102
+
103
+ private func resolve(taskId: Int, result: [String: Any]) {
104
+ lock.lock()
105
+ let item = pending.removeValue(forKey: taskId)
106
+ lock.unlock()
107
+ item?.continuation.resume(returning: result)
108
+ }
109
+
110
+ private func reject(taskId: Int, error: Error) {
111
+ lock.lock()
112
+ let item = pending.removeValue(forKey: taskId)
113
+ lock.unlock()
114
+ item?.continuation.resume(throwing: error)
115
+ }
116
+
117
+ private func fileSize(atPath path: String) -> Int64 {
118
+ guard let value = try? FileManager.default.attributesOfItem(atPath: path)[.size],
119
+ let number = value as? NSNumber
120
+ else {
121
+ return 0
122
+ }
123
+ return number.int64Value
124
+ }
125
+ }
@@ -46,6 +46,9 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
46
46
  "settings_ack",
47
47
  "version_info",
48
48
  "pair_failure",
49
+ "pairing_info",
50
+ "entering_pairing_mode",
51
+ "owner_replaced",
49
52
  "audio_pairing_needed",
50
53
  "audio_connected",
51
54
  "audio_disconnected",
@@ -57,6 +60,7 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
57
60
  "ws_bin",
58
61
  "mic_pcm",
59
62
  "mic_lc3",
63
+ "mic_health",
60
64
  "stream_status",
61
65
  "keep_alive_ack",
62
66
  "mtk_update_complete",
@@ -303,6 +307,11 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
303
307
  return try await sdk.setHotspotState(enabled: enabled).values
304
308
  }
305
309
 
310
+ AsyncFunction("setWifiAdbState") { (enabled: Bool) in
311
+ let sdk = await MainActor.run { self.bluetoothSdk() }
312
+ try await sdk.setWifiAdbState(enabled: enabled)
313
+ }
314
+
306
315
  AsyncFunction("setSystemTime") { (timestampMs: Double) in
307
316
  let maxTimestamp = Double(Int64.max).nextDown
308
317
  guard timestampMs.isFinite,
@@ -562,6 +571,11 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
562
571
  ).values
563
572
  }
564
573
 
574
+ AsyncFunction("queryVideoRecordingStatus") { (requestId: String) in
575
+ let sdk = await MainActor.run { self.bluetoothSdk() }
576
+ return try await sdk.queryVideoRecordingStatus(requestId: requestId).values
577
+ }
578
+
565
579
  // MARK: - Stream Commands
566
580
 
567
581
  AsyncFunction("startStream") { (params: [String: Any]) in
@@ -812,6 +826,8 @@ public class BluetoothSdkModule: Module, MentraBluetoothSDKDelegate {
812
826
  sendEvent("voice_activity_detection_status", status.values)
813
827
  case let .speakingStatus(status):
814
828
  sendEvent("speaking_status", status.values)
829
+ case let .micHealth(health):
830
+ sendEvent("mic_health", health.values)
815
831
  case let .wifiStatus(status):
816
832
  sendEvent("wifi_status_change", status.values)
817
833
  case let .hotspotStatus(status):
@@ -921,5 +937,3 @@ private extension ConnectOptions {
921
937
 
922
938
 
923
939
 
924
-
925
-
@@ -0,0 +1,150 @@
1
+ import Darwin
2
+ import Foundation
3
+
4
+ /// Best-effort local IPv4 discovery for phone-hosted servers the glasses reach over WiFi
5
+ /// (photo upload receiver, hotspot-served OTA). Prefers WiFi-ish interfaces carrying RFC1918
6
+ /// addresses — under a glasses-hotspot session en0 holds the 192.168.43.x client address.
7
+ enum LocalIPv4 {
8
+ /// Return the active Wi-Fi address only when it is on the glasses gateway's /24.
9
+ /// iPhone Wi-Fi is `en0`; accepting AWDL, VPN, cellular, or another private
10
+ /// interface can publish an endpoint that the glasses cannot reach.
11
+ static func wifiIPv4Address(matchingGateway gateway: String) -> String? {
12
+ guard let gatewayPrefix = subnet24Prefix(gateway) else {
13
+ return nil
14
+ }
15
+
16
+ var interfaces: UnsafeMutablePointer<ifaddrs>?
17
+ guard getifaddrs(&interfaces) == 0, let first = interfaces else {
18
+ return nil
19
+ }
20
+ defer { freeifaddrs(interfaces) }
21
+
22
+ var cursor: UnsafeMutablePointer<ifaddrs>? = first
23
+ while let current = cursor {
24
+ defer { cursor = current.pointee.ifa_next }
25
+ let interface = current.pointee
26
+ guard String(cString: interface.ifa_name) == "en0",
27
+ let addressPointer = interface.ifa_addr,
28
+ addressPointer.pointee.sa_family == UInt8(AF_INET),
29
+ (interface.ifa_flags & UInt32(IFF_UP)) != 0
30
+ else {
31
+ continue
32
+ }
33
+
34
+ var address = addressPointer.pointee
35
+ var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
36
+ guard getnameinfo(
37
+ &address,
38
+ socklen_t(address.sa_len),
39
+ &hostname,
40
+ socklen_t(hostname.count),
41
+ nil,
42
+ 0,
43
+ NI_NUMERICHOST
44
+ ) == 0 else {
45
+ continue
46
+ }
47
+
48
+ let ip = String(cString: hostname)
49
+ if subnet24Prefix(ip) == gatewayPrefix {
50
+ return ip
51
+ }
52
+ }
53
+ return nil
54
+ }
55
+
56
+ static func bestLocalIPv4Address() -> String? {
57
+ var interfaces: UnsafeMutablePointer<ifaddrs>?
58
+ guard getifaddrs(&interfaces) == 0, let first = interfaces else {
59
+ return nil
60
+ }
61
+ defer { freeifaddrs(interfaces) }
62
+
63
+ var preferredPrivate: String?
64
+ var preferred: String?
65
+ var privateFallback: String?
66
+ var fallback: String?
67
+ var cursor: UnsafeMutablePointer<ifaddrs>? = first
68
+ while let current = cursor {
69
+ defer { cursor = current.pointee.ifa_next }
70
+ let interface = current.pointee
71
+ guard let addressPointer = interface.ifa_addr,
72
+ addressPointer.pointee.sa_family == UInt8(AF_INET)
73
+ else {
74
+ continue
75
+ }
76
+
77
+ let name = String(cString: interface.ifa_name)
78
+ let flags = interface.ifa_flags
79
+ guard (flags & UInt32(IFF_UP)) != 0,
80
+ (flags & UInt32(IFF_LOOPBACK)) == 0
81
+ else {
82
+ continue
83
+ }
84
+
85
+ var address = addressPointer.pointee
86
+ var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
87
+ let result = getnameinfo(
88
+ &address,
89
+ socklen_t(address.sa_len),
90
+ &hostname,
91
+ socklen_t(hostname.count),
92
+ nil,
93
+ 0,
94
+ NI_NUMERICHOST
95
+ )
96
+ guard result == 0 else {
97
+ continue
98
+ }
99
+
100
+ let ip = String(cString: hostname)
101
+ guard ip != "127.0.0.1", !isLinkLocalIPv4(ip) else {
102
+ continue
103
+ }
104
+ if isPreferredLocalInterface(name), isPrivateIPv4(ip) {
105
+ preferredPrivate = preferredPrivate ?? ip
106
+ } else if isPreferredLocalInterface(name) {
107
+ preferred = preferred ?? ip
108
+ } else if isPrivateIPv4(ip) {
109
+ privateFallback = privateFallback ?? ip
110
+ } else {
111
+ fallback = fallback ?? ip
112
+ }
113
+ }
114
+
115
+ return preferredPrivate ?? preferred ?? privateFallback ?? fallback
116
+ }
117
+
118
+ private static func isPreferredLocalInterface(_ name: String) -> Bool {
119
+ name == "en0" ||
120
+ name.hasPrefix("bridge") ||
121
+ name.hasPrefix("ap") ||
122
+ name.hasPrefix("awdl") ||
123
+ name.hasPrefix("llw")
124
+ }
125
+
126
+ private static func isPrivateIPv4(_ ip: String) -> Bool {
127
+ if ip.hasPrefix("192.168.") || ip.hasPrefix("10.") {
128
+ return true
129
+ }
130
+ let parts = ip.split(separator: ".").compactMap { Int($0) }
131
+ return parts.count == 4 && parts[0] == 172 && (16 ... 31).contains(parts[1])
132
+ }
133
+
134
+ private static func isLinkLocalIPv4(_ ip: String) -> Bool {
135
+ ip.hasPrefix("169.254.")
136
+ }
137
+
138
+ private static func subnet24Prefix(_ ip: String) -> String? {
139
+ let parts = ip.split(separator: ".")
140
+ guard parts.count == 4,
141
+ parts.allSatisfy({ part in
142
+ guard let value = Int(part) else { return false }
143
+ return (0 ... 255).contains(value)
144
+ })
145
+ else {
146
+ return nil
147
+ }
148
+ return parts.prefix(3).joined(separator: ".")
149
+ }
150
+ }
@@ -0,0 +1,309 @@
1
+ import Foundation
2
+ import Network
3
+
4
+ struct OtaServerError: LocalizedError {
5
+ let message: String
6
+
7
+ init(_ message: String) {
8
+ self.message = message
9
+ }
10
+
11
+ var errorDescription: String? { message }
12
+ }
13
+
14
+ /// Static HTTP server for hotspot-served glasses OTA (OS-1676).
15
+ ///
16
+ /// Serves the rewritten OTA manifest at /version.json and pre-downloaded artifacts at
17
+ /// /artifacts/<sha256> to the glasses over the glasses-hotspot link. Mirrors
18
+ /// LocalPhotoUploadServer's NWListener structure.
19
+ final class LocalOtaServer {
20
+ private let queue = DispatchQueue(label: "com.mentra.ota-server")
21
+ private let onLog: (String) -> Void
22
+ private var listener: NWListener?
23
+ private var listenerGeneration = 0
24
+ private(set) var activePort: UInt16?
25
+
26
+ private var manifestJson = "{}"
27
+ private var artifacts: [String: URL] = [:]
28
+
29
+ private static let maxHeaderBytes = 64 * 1024
30
+ private static let streamChunkBytes = 256 * 1024
31
+
32
+ init(onLog: @escaping (String) -> Void) {
33
+ self.onLog = onLog
34
+ }
35
+
36
+ /// Swap the served manifest and artifact set. Safe while the server is running.
37
+ func configure(manifestJson: String, artifacts: [String: URL]) {
38
+ queue.sync {
39
+ self.manifestJson = manifestJson
40
+ self.artifacts = artifacts
41
+ }
42
+ }
43
+
44
+ func start(host: String, port: UInt16) throws -> UInt16 {
45
+ if listener != nil, activePort == port {
46
+ onLog("Already listening on \(host):\(port)")
47
+ return port
48
+ }
49
+ stop()
50
+
51
+ let parameters = NWParameters.tcp
52
+ parameters.allowLocalEndpointReuse = true
53
+ guard let endpointPort = NWEndpoint.Port(rawValue: port) else {
54
+ throw OtaServerError("Invalid port \(port)")
55
+ }
56
+
57
+ parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host(host), port: endpointPort)
58
+ let listener = try NWListener(using: parameters)
59
+ listenerGeneration += 1
60
+ let generation = listenerGeneration
61
+ let started = DispatchSemaphore(value: 0)
62
+ var startError: Error?
63
+ var isReady = false
64
+
65
+ listener.stateUpdateHandler = { [weak self] state in
66
+ guard let self, self.listenerGeneration == generation else {
67
+ return
68
+ }
69
+ switch state {
70
+ case .ready:
71
+ isReady = true
72
+ self.activePort = port
73
+ self.onLog("OTA server listening on \(host):\(port)")
74
+ started.signal()
75
+ case .failed(let error):
76
+ startError = error
77
+ self.activePort = nil
78
+ self.listener = nil
79
+ self.onLog("OTA server failed on \(port): \(error)")
80
+ started.signal()
81
+ case .cancelled:
82
+ self.activePort = nil
83
+ if !isReady {
84
+ startError = OtaServerError("Listener cancelled")
85
+ started.signal()
86
+ }
87
+ default:
88
+ break
89
+ }
90
+ }
91
+
92
+ listener.newConnectionHandler = { [weak self] connection in
93
+ self?.handle(connection)
94
+ }
95
+
96
+ self.listener = listener
97
+ listener.start(queue: queue)
98
+
99
+ if started.wait(timeout: .now() + 2) == .timedOut {
100
+ self.listener = nil
101
+ listener.cancel()
102
+ throw OtaServerError("Timed out starting listener")
103
+ }
104
+ if let startError {
105
+ self.listener = nil
106
+ listener.cancel()
107
+ throw startError
108
+ }
109
+
110
+ return port
111
+ }
112
+
113
+ func stop() {
114
+ listenerGeneration += 1
115
+ listener?.cancel()
116
+ listener = nil
117
+ activePort = nil
118
+ }
119
+
120
+ private final class RequestReadState {
121
+ var buffer = Data()
122
+ }
123
+
124
+ private func handle(_ connection: NWConnection) {
125
+ connection.start(queue: queue)
126
+ receive(connection, state: RequestReadState())
127
+ }
128
+
129
+ private func receive(_ connection: NWConnection, state: RequestReadState) {
130
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) {
131
+ [weak self] data, _, isComplete, error in
132
+ guard let self else {
133
+ connection.cancel()
134
+ return
135
+ }
136
+ if let error {
137
+ self.onLog("request failed: \(error.localizedDescription)")
138
+ connection.cancel()
139
+ return
140
+ }
141
+ if let data, !data.isEmpty {
142
+ state.buffer.append(data)
143
+ }
144
+
145
+ if let headerRange = state.buffer.range(of: Data([13, 10, 13, 10])) {
146
+ let headerData = state.buffer.subdata(in: state.buffer.startIndex..<headerRange.lowerBound)
147
+ let headerText = String(data: headerData, encoding: .isoLatin1) ?? ""
148
+ self.route(connection, headerText: headerText)
149
+ return
150
+ }
151
+ if state.buffer.count > Self.maxHeaderBytes {
152
+ self.writeJson(connection, status: 400, body: #"{"ok":false,"error":"headers_too_large"}"#)
153
+ return
154
+ }
155
+ if isComplete {
156
+ connection.cancel()
157
+ return
158
+ }
159
+ self.receive(connection, state: state)
160
+ }
161
+ }
162
+
163
+ private func route(_ connection: NWConnection, headerText: String) {
164
+ let lines = headerText.components(separatedBy: "\r\n").filter { !$0.isEmpty }
165
+ let requestParts = lines.first?.components(separatedBy: " ") ?? []
166
+ let method = requestParts.count > 0 ? requestParts[0].uppercased() : ""
167
+ let rawPath = requestParts.count > 1 ? requestParts[1] : ""
168
+ let path = rawPath.components(separatedBy: "?")[0]
169
+ onLog("\(method) \(path)")
170
+
171
+ guard method == "GET" else {
172
+ writeJson(connection, status: 405, body: #"{"ok":false,"error":"method_not_allowed"}"#)
173
+ return
174
+ }
175
+
176
+ if path == "/version.json" {
177
+ writeBody(
178
+ connection,
179
+ status: 200,
180
+ contentType: "application/json",
181
+ body: Data(manifestJson.utf8)
182
+ )
183
+ return
184
+ }
185
+
186
+ if path.hasPrefix("/artifacts/") {
187
+ let key = String(path.dropFirst("/artifacts/".count))
188
+ guard let fileUrl = artifacts[key],
189
+ let fileSize = try? fileUrl.resourceValues(forKeys: [.fileSizeKey]).fileSize
190
+ else {
191
+ writeJson(connection, status: 404, body: #"{"ok":false,"error":"artifact_not_found"}"#)
192
+ return
193
+ }
194
+ sendFile(connection, fileUrl: fileUrl, fileLength: Int64(fileSize))
195
+ return
196
+ }
197
+
198
+ writeJson(connection, status: 404, body: #"{"ok":false,"error":"not_found"}"#)
199
+ }
200
+
201
+ private func sendFile(
202
+ _ connection: NWConnection,
203
+ fileUrl: URL,
204
+ fileLength: Int64
205
+ ) {
206
+ let header =
207
+ "HTTP/1.1 200 OK\r\n" +
208
+ "Content-Type: application/octet-stream\r\n" +
209
+ "Content-Length: \(fileLength)\r\n" +
210
+ "Connection: close\r\n\r\n"
211
+
212
+ guard let fileHandle = try? FileHandle(forReadingFrom: fileUrl) else {
213
+ writeJson(connection, status: 500, body: #"{"ok":false,"error":"artifact_unreadable"}"#)
214
+ return
215
+ }
216
+
217
+ connection.send(content: Data(header.utf8), completion: .contentProcessed { [weak self] error in
218
+ guard let self, error == nil else {
219
+ try? fileHandle.close()
220
+ connection.cancel()
221
+ return
222
+ }
223
+ self.streamChunks(connection, fileHandle: fileHandle, remaining: fileLength)
224
+ })
225
+ }
226
+
227
+ private func streamChunks(_ connection: NWConnection, fileHandle: FileHandle, remaining: Int64) {
228
+ if remaining <= 0 {
229
+ try? fileHandle.close()
230
+ connection.send(content: nil, contentContext: .finalMessage, isComplete: true, completion: .contentProcessed { _ in
231
+ connection.cancel()
232
+ })
233
+ return
234
+ }
235
+ let chunkSize = Int(min(Int64(Self.streamChunkBytes), remaining))
236
+ let chunk: Data
237
+ do {
238
+ guard let data = try fileHandle.read(upToCount: chunkSize), !data.isEmpty else {
239
+ throw OtaServerError("artifact truncated while streaming")
240
+ }
241
+ chunk = data
242
+ } catch {
243
+ onLog("stream failed: \(error.localizedDescription)")
244
+ try? fileHandle.close()
245
+ connection.cancel()
246
+ return
247
+ }
248
+ connection.send(content: chunk, completion: .contentProcessed { [weak self] error in
249
+ guard let self else {
250
+ try? fileHandle.close()
251
+ connection.cancel()
252
+ return
253
+ }
254
+ if let error {
255
+ self.onLog("stream failed: \(error.localizedDescription)")
256
+ try? fileHandle.close()
257
+ connection.cancel()
258
+ return
259
+ }
260
+ self.streamChunks(connection, fileHandle: fileHandle, remaining: remaining - Int64(chunk.count))
261
+ })
262
+ }
263
+
264
+ private func writeJson(
265
+ _ connection: NWConnection,
266
+ status: Int,
267
+ body: String
268
+ ) {
269
+ writeBody(
270
+ connection,
271
+ status: status,
272
+ contentType: "application/json",
273
+ body: Data(body.utf8)
274
+ )
275
+ }
276
+
277
+ private func writeBody(
278
+ _ connection: NWConnection,
279
+ status: Int,
280
+ contentType: String,
281
+ body: Data
282
+ ) {
283
+ let reason: String
284
+ switch status {
285
+ case 200:
286
+ reason = "OK"
287
+ case 400:
288
+ reason = "Bad Request"
289
+ case 404:
290
+ reason = "Not Found"
291
+ case 405:
292
+ reason = "Method Not Allowed"
293
+ default:
294
+ reason = "Internal Server Error"
295
+ }
296
+
297
+ let header =
298
+ "HTTP/1.1 \(status) \(reason)\r\n" +
299
+ "Content-Type: \(contentType)\r\n" +
300
+ "Content-Length: \(body.count)\r\n" +
301
+ "Connection: close\r\n" +
302
+ "\r\n"
303
+ var response = Data(header.utf8)
304
+ response.append(body)
305
+ connection.send(content: response, completion: .contentProcessed { _ in
306
+ connection.cancel()
307
+ })
308
+ }
309
+ }
@@ -63,7 +63,11 @@ Pod::Spec.new do |s|
63
63
  native_source_files.concat([
64
64
  "BluetoothSdkModule.swift",
65
65
  "LocalPhotoUploadServer.swift",
66
- "MentraPhotoReceiverModule.swift"
66
+ "MentraPhotoReceiverModule.swift",
67
+ "LocalIPv4.swift",
68
+ "LocalOtaServer.swift",
69
+ "BackgroundOtaArtifactDownloader.swift",
70
+ "MentraOtaServerModule.swift"
67
71
  ]) if include_expo_adapter
68
72
  s.source_files = native_source_files
69
73