@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
@@ -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
 
@@ -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
+ }