@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.
- package/README.md +38 -2
- package/android/build.gradle +7 -4
- package/android/lc3Lib/build.gradle +18 -4
- package/android/silero/build.gradle +9 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +16 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +39 -3
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +198 -17
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +2 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +112 -8
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +93 -12
- package/android/src/main/java/com/mentra/bluetoothsdk/OtaManifest.kt +5 -7
- package/android/src/main/java/com/mentra/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/audio/AudioModels.kt +15 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/events/BluetoothEvents.kt +24 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/net/LocalIpv4.kt +57 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/otaserver/MentraOtaServerModule.kt +120 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/photoreceiver/MentraPhotoReceiverModule.kt +2 -49
- package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +61 -30
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1174 -191
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +8 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/status/DeviceStatus.kt +17 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/types/DeviceModels.kt +12 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/BleJsonCompact.java +74 -79
- package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +3 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
- package/build/BluetoothSdk.types.d.ts +61 -2
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +2 -0
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js +2 -0
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +4 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +1 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
- package/build/_private/MentraLocalNetworkModule.js.map +1 -1
- package/build/_private/sdkOtaManifest.d.ts +3 -12
- package/build/_private/sdkOtaManifest.d.ts.map +1 -1
- package/build/_private/sdkOtaManifest.js +5 -17
- package/build/_private/sdkOtaManifest.js.map +1 -1
- package/build/generated/releaseMetadata.d.ts +12 -0
- package/build/generated/releaseMetadata.d.ts.map +1 -0
- package/build/generated/releaseMetadata.js +10 -0
- package/build/generated/releaseMetadata.js.map +1 -0
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +10 -2
- package/build/index.js.map +1 -1
- package/build/ota-server/MentraOtaServer.types.d.ts +14 -0
- package/build/ota-server/MentraOtaServer.types.d.ts.map +1 -0
- package/build/ota-server/MentraOtaServer.types.js +2 -0
- package/build/ota-server/MentraOtaServer.types.js.map +1 -0
- package/build/ota-server/MentraOtaServerModule.d.ts +21 -0
- package/build/ota-server/MentraOtaServerModule.d.ts.map +1 -0
- package/build/ota-server/MentraOtaServerModule.js +3 -0
- package/build/ota-server/MentraOtaServerModule.js.map +1 -0
- package/build/ota-server/MentraOtaServerModule.web.d.ts +10 -0
- package/build/ota-server/MentraOtaServerModule.web.d.ts.map +1 -0
- package/build/ota-server/MentraOtaServerModule.web.js +12 -0
- package/build/ota-server/MentraOtaServerModule.web.js.map +1 -0
- package/build/ota-server/index.d.ts +3 -0
- package/build/ota-server/index.d.ts.map +1 -0
- package/build/ota-server/index.js +3 -0
- package/build/ota-server/index.js.map +1 -0
- package/expo-module.config.json +3 -2
- package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
- package/ios/BluetoothSdkModule.swift +16 -2
- package/ios/LocalIPv4.swift +150 -0
- package/ios/LocalOtaServer.swift +309 -0
- package/ios/MentraBluetoothSDK.podspec +5 -1
- package/ios/MentraOtaServerModule.swift +147 -0
- package/ios/MentraPhotoReceiverModule.swift +1 -81
- package/ios/Source/BluetoothSdkDefaults.swift +4 -1
- package/ios/Source/Bridge.swift +47 -3
- package/ios/Source/DeviceManager.swift +135 -10
- package/ios/Source/DeviceStore.swift +2 -0
- package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
- package/ios/Source/MentraBluetoothSDK.swift +74 -6
- package/ios/Source/OtaManifest.swift +5 -7
- package/ios/Source/audio/AudioModels.swift +17 -0
- package/ios/Source/camera/CameraModels.swift +1 -0
- package/ios/Source/events/BluetoothEvents.swift +27 -0
- package/ios/Source/sgcs/MentraLive.swift +439 -30
- package/ios/Source/sgcs/SGCManager.swift +11 -0
- package/ios/Source/status/DeviceStatus.swift +23 -0
- package/ios/Source/types/DeviceModels.swift +23 -2
- package/ios/Source/utils/BleJsonCompact.swift +73 -71
- package/package.json +5 -1
- package/plugin/build/withIos.d.ts +3 -1
- package/plugin/build/withIos.js +22 -10
- package/scripts/verify-release-package.mjs +34 -0
- package/scripts/write-release-metadata.mjs +154 -0
- package/scripts/write-release-metadata.test.mjs +29 -0
- package/src/BluetoothSdk.types.ts +72 -3
- package/src/_internal.ts +2 -0
- package/src/_private/BluetoothSdkModule.ts +5 -5
- package/src/_private/MentraLocalNetworkModule.ts +1 -1
- package/src/_private/sdkOtaManifest.ts +5 -17
- package/src/generated/releaseMetadata.ts +20 -0
- package/src/index.ts +19 -2
- package/src/ota-server/MentraOtaServer.types.ts +10 -0
- package/src/ota-server/MentraOtaServerModule.ts +23 -0
- package/src/ota-server/MentraOtaServerModule.web.ts +17 -0
- package/src/ota-server/index.ts +2 -0
|
@@ -5,7 +5,8 @@ import android.net.Network
|
|
|
5
5
|
import android.net.NetworkCapabilities
|
|
6
6
|
import android.net.NetworkRequest
|
|
7
7
|
import android.net.wifi.WifiNetworkSpecifier
|
|
8
|
-
import android.os.
|
|
8
|
+
import android.os.Handler
|
|
9
|
+
import android.os.Looper
|
|
9
10
|
import android.util.Base64
|
|
10
11
|
import android.util.Log
|
|
11
12
|
import expo.modules.kotlin.Promise
|
|
@@ -14,6 +15,7 @@ import expo.modules.kotlin.modules.ModuleDefinition
|
|
|
14
15
|
import java.io.File
|
|
15
16
|
import java.io.FileOutputStream
|
|
16
17
|
import java.net.HttpURLConnection
|
|
18
|
+
import java.net.Inet4Address
|
|
17
19
|
import java.net.URL
|
|
18
20
|
import java.util.concurrent.ConcurrentHashMap
|
|
19
21
|
import java.util.concurrent.Executors
|
|
@@ -22,14 +24,18 @@ import java.util.concurrent.Executors
|
|
|
22
24
|
class MentraLocalNetworkModule : Module() {
|
|
23
25
|
private companion object {
|
|
24
26
|
const val TAG = "MentraLocalNetwork"
|
|
27
|
+
const val NETWORK_READINESS_TIMEOUT_MS = 5_000L
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
private val lock = Any()
|
|
28
31
|
private val executor = Executors.newCachedThreadPool()
|
|
32
|
+
private val handler = Handler(Looper.getMainLooper())
|
|
29
33
|
private val activeConnections = ConcurrentHashMap<String, HttpURLConnection>()
|
|
34
|
+
private val networkReadiness = ScopedNetworkReadiness<Network>()
|
|
30
35
|
private var localNetwork: Network? = null
|
|
31
36
|
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
|
32
37
|
private var pendingConnectPromise: Promise? = null
|
|
38
|
+
private var pendingReadinessTimeout: Runnable? = null
|
|
33
39
|
|
|
34
40
|
override fun definition() = ModuleDefinition {
|
|
35
41
|
Name("MentraLocalNetwork")
|
|
@@ -74,7 +80,9 @@ class MentraLocalNetworkModule : Module() {
|
|
|
74
80
|
AsyncFunction("cancel") { requestId: String -> cancel(requestId) }
|
|
75
81
|
AsyncFunction("disconnect") { disconnect() }
|
|
76
82
|
|
|
77
|
-
OnDestroy {
|
|
83
|
+
OnDestroy {
|
|
84
|
+
disconnect()
|
|
85
|
+
}
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
private fun connectivityManager(): ConnectivityManager =
|
|
@@ -83,11 +91,6 @@ class MentraLocalNetworkModule : Module() {
|
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
private fun connect(ssid: String, password: String, promise: Promise) {
|
|
86
|
-
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
|
87
|
-
promise.reject("ERR_UNSUPPORTED_ANDROID", "Scoped local WiFi requires Android 10 or newer", null)
|
|
88
|
-
return
|
|
89
|
-
}
|
|
90
|
-
|
|
91
94
|
disconnect()
|
|
92
95
|
val manager = connectivityManager()
|
|
93
96
|
val specifier =
|
|
@@ -105,21 +108,55 @@ class MentraLocalNetworkModule : Module() {
|
|
|
105
108
|
val callback =
|
|
106
109
|
object : ConnectivityManager.NetworkCallback() {
|
|
107
110
|
override fun onAvailable(network: Network) {
|
|
108
|
-
val connectPromise: Promise?
|
|
109
111
|
synchronized(lock) {
|
|
110
112
|
if (networkCallback !== this) {
|
|
111
113
|
Log.d(TAG, "Ignoring onAvailable from a stale network request")
|
|
112
114
|
return
|
|
113
115
|
}
|
|
116
|
+
networkReadiness.onAvailable(network)
|
|
117
|
+
}
|
|
118
|
+
Log.i(
|
|
119
|
+
TAG,
|
|
120
|
+
"Captured glasses WiFi network=$network; waiting for foreground capability " +
|
|
121
|
+
"processBoundNetwork=${manager.boundNetworkForProcess}",
|
|
122
|
+
)
|
|
123
|
+
scheduleReadinessTimeout(this, ssid)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
|
|
127
|
+
val connectPromise: Promise?
|
|
128
|
+
synchronized(lock) {
|
|
129
|
+
if (networkCallback !== this || localNetwork != null || pendingConnectPromise == null) return
|
|
130
|
+
if (!networkReadiness.isReady(
|
|
131
|
+
network,
|
|
132
|
+
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_FOREGROUND),
|
|
133
|
+
)
|
|
134
|
+
) {
|
|
135
|
+
return
|
|
136
|
+
}
|
|
114
137
|
localNetwork = network
|
|
115
138
|
connectPromise = pendingConnectPromise
|
|
116
139
|
pendingConnectPromise = null
|
|
140
|
+
cancelReadinessTimeoutLocked()
|
|
117
141
|
}
|
|
118
142
|
Log.i(
|
|
119
143
|
TAG,
|
|
120
|
-
"
|
|
144
|
+
"Glasses WiFi network=$network is foreground and ready " +
|
|
145
|
+
"processBoundNetwork=${manager.boundNetworkForProcess}",
|
|
146
|
+
)
|
|
147
|
+
val localAddress =
|
|
148
|
+
manager.getLinkProperties(network)
|
|
149
|
+
?.linkAddresses
|
|
150
|
+
?.firstOrNull { it.address is Inet4Address }
|
|
151
|
+
?.address
|
|
152
|
+
?.hostAddress
|
|
153
|
+
connectPromise?.resolve(
|
|
154
|
+
buildMap<String, Any> {
|
|
155
|
+
put("connected", true)
|
|
156
|
+
put("ssid", ssid)
|
|
157
|
+
if (!localAddress.isNullOrBlank()) put("localAddress", localAddress)
|
|
158
|
+
},
|
|
121
159
|
)
|
|
122
|
-
connectPromise?.resolve(mapOf("connected" to true, "ssid" to ssid))
|
|
123
160
|
}
|
|
124
161
|
|
|
125
162
|
override fun onUnavailable() {
|
|
@@ -135,13 +172,17 @@ class MentraLocalNetworkModule : Module() {
|
|
|
135
172
|
|
|
136
173
|
override fun onLost(network: Network) {
|
|
137
174
|
synchronized(lock) {
|
|
138
|
-
if (networkCallback !== this ||
|
|
175
|
+
if (networkCallback !== this ||
|
|
176
|
+
(localNetwork != network && !networkReadiness.contains(network))
|
|
177
|
+
) {
|
|
139
178
|
Log.d(TAG, "Ignoring onLost from a stale network request")
|
|
140
179
|
return
|
|
141
180
|
}
|
|
142
181
|
localNetwork = null
|
|
182
|
+
networkReadiness.clear()
|
|
143
183
|
}
|
|
144
184
|
cancelAll()
|
|
185
|
+
rejectPendingConnect("ERR_LOCAL_WIFI_LOST", "Lost connection to $ssid")
|
|
145
186
|
clearNetworkCallback(this)
|
|
146
187
|
sendEvent("networkLost", mapOf("ssid" to ssid))
|
|
147
188
|
}
|
|
@@ -289,6 +330,39 @@ class MentraLocalNetworkModule : Module() {
|
|
|
289
330
|
activeConnections.keys.toList().forEach(::cancel)
|
|
290
331
|
}
|
|
291
332
|
|
|
333
|
+
private fun scheduleReadinessTimeout(
|
|
334
|
+
callback: ConnectivityManager.NetworkCallback,
|
|
335
|
+
ssid: String,
|
|
336
|
+
) {
|
|
337
|
+
val timeout =
|
|
338
|
+
Runnable {
|
|
339
|
+
val connectPromise = synchronized(lock) {
|
|
340
|
+
if (networkCallback !== callback || pendingConnectPromise == null) return@Runnable
|
|
341
|
+
pendingReadinessTimeout = null
|
|
342
|
+
val promise = pendingConnectPromise
|
|
343
|
+
pendingConnectPromise = null
|
|
344
|
+
promise
|
|
345
|
+
}
|
|
346
|
+
connectPromise?.reject(
|
|
347
|
+
"ERR_LOCAL_WIFI_NOT_READY",
|
|
348
|
+
"Connected to $ssid but the network did not become ready",
|
|
349
|
+
null,
|
|
350
|
+
)
|
|
351
|
+
clearNetworkCallback(callback)
|
|
352
|
+
}
|
|
353
|
+
synchronized(lock) {
|
|
354
|
+
if (networkCallback !== callback || pendingConnectPromise == null) return
|
|
355
|
+
cancelReadinessTimeoutLocked()
|
|
356
|
+
pendingReadinessTimeout = timeout
|
|
357
|
+
}
|
|
358
|
+
handler.postDelayed(timeout, NETWORK_READINESS_TIMEOUT_MS)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private fun cancelReadinessTimeoutLocked() {
|
|
362
|
+
pendingReadinessTimeout?.let(handler::removeCallbacks)
|
|
363
|
+
pendingReadinessTimeout = null
|
|
364
|
+
}
|
|
365
|
+
|
|
292
366
|
private fun disconnect() {
|
|
293
367
|
cancelAll()
|
|
294
368
|
val manager = appContext.reactContext?.getSystemService(ConnectivityManager::class.java)
|
|
@@ -297,6 +371,8 @@ class MentraLocalNetworkModule : Module() {
|
|
|
297
371
|
callback = networkCallback
|
|
298
372
|
networkCallback = null
|
|
299
373
|
localNetwork = null
|
|
374
|
+
networkReadiness.clear()
|
|
375
|
+
cancelReadinessTimeoutLocked()
|
|
300
376
|
}
|
|
301
377
|
if (callback != null && manager != null) {
|
|
302
378
|
try {
|
|
@@ -311,7 +387,12 @@ class MentraLocalNetworkModule : Module() {
|
|
|
311
387
|
private fun clearNetworkCallback(callback: ConnectivityManager.NetworkCallback) {
|
|
312
388
|
val manager = appContext.reactContext?.getSystemService(ConnectivityManager::class.java)
|
|
313
389
|
synchronized(lock) {
|
|
314
|
-
if (networkCallback == callback)
|
|
390
|
+
if (networkCallback == callback) {
|
|
391
|
+
networkCallback = null
|
|
392
|
+
localNetwork = null
|
|
393
|
+
networkReadiness.clear()
|
|
394
|
+
cancelReadinessTimeoutLocked()
|
|
395
|
+
}
|
|
315
396
|
}
|
|
316
397
|
if (manager != null) {
|
|
317
398
|
try {
|
|
@@ -7,21 +7,19 @@ import org.json.JSONArray
|
|
|
7
7
|
import org.json.JSONObject
|
|
8
8
|
|
|
9
9
|
internal object OtaManifestDefaults {
|
|
10
|
-
private const val SDK_OTA_RELEASE_BASE_URL =
|
|
11
|
-
"https://github.com/Mentra-Community/MentraOS/releases/download/bluetooth-sdk-ota"
|
|
12
10
|
// ASG builds before 39 ignore ota_start.ota_version_url, so SDK checks must
|
|
13
11
|
// use the same legacy production manifest those glasses will install from.
|
|
14
12
|
const val LEGACY_PROD_OTA_VERSION_URL = "https://ota.mentraglass.com/prod_live_version.json"
|
|
15
13
|
|
|
16
14
|
fun defaultOtaVersionUrl(): String {
|
|
17
|
-
val
|
|
18
|
-
if (
|
|
15
|
+
val manifestUrl = GeneratedReleaseMetadata.OTA_MANIFEST_URL.trim()
|
|
16
|
+
if (manifestUrl.isBlank()) {
|
|
19
17
|
throw BluetoothSdkException(
|
|
20
|
-
"
|
|
21
|
-
"
|
|
18
|
+
"ota_manifest_unconfigured",
|
|
19
|
+
"This source-built Bluetooth SDK has no embedded OTA manifest. Configure one with the debug API.",
|
|
22
20
|
)
|
|
23
21
|
}
|
|
24
|
-
return
|
|
22
|
+
return manifestUrl
|
|
25
23
|
}
|
|
26
24
|
}
|
|
27
25
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk
|
|
2
|
+
|
|
3
|
+
internal class ScopedNetworkReadiness<T> {
|
|
4
|
+
private var candidate: T? = null
|
|
5
|
+
|
|
6
|
+
fun onAvailable(network: T) {
|
|
7
|
+
candidate = network
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
fun isReady(network: T, isForeground: Boolean): Boolean = candidate == network && isForeground
|
|
11
|
+
|
|
12
|
+
fun contains(network: T): Boolean = candidate == network
|
|
13
|
+
|
|
14
|
+
fun clear() {
|
|
15
|
+
candidate = null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -94,6 +94,21 @@ data class MicLc3Event(
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
internal data class MicHealth(
|
|
98
|
+
val sequenceGapEvents: Long,
|
|
99
|
+
val decodeFailures: Long,
|
|
100
|
+
val lastLc3ReceivedAt: Long?,
|
|
101
|
+
val lastPcmProducedAt: Long?,
|
|
102
|
+
) {
|
|
103
|
+
fun toMap(): Map<String, Any> =
|
|
104
|
+
buildMap {
|
|
105
|
+
put("sequenceGapEvents", sequenceGapEvents)
|
|
106
|
+
put("decodeFailures", decodeFailures)
|
|
107
|
+
lastLc3ReceivedAt?.let { put("lastLc3ReceivedAt", it) }
|
|
108
|
+
lastPcmProducedAt?.let { put("lastPcmProducedAt", it) }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
97
112
|
data class LocalTranscriptionEvent(
|
|
98
113
|
val text: String,
|
|
99
114
|
val isFinal: Boolean,
|
|
@@ -39,6 +39,29 @@ data class SpeakingStatusEvent(
|
|
|
39
39
|
val values: Map<String, Any>,
|
|
40
40
|
)
|
|
41
41
|
|
|
42
|
+
data class MicHealthEvent(
|
|
43
|
+
val reason: String,
|
|
44
|
+
val sequenceGapEvents: Long,
|
|
45
|
+
val decodeFailures: Long,
|
|
46
|
+
val lastLc3ReceivedAt: Long?,
|
|
47
|
+
val lastPcmProducedAt: Long?,
|
|
48
|
+
val timestamp: Long,
|
|
49
|
+
val values: Map<String, Any>,
|
|
50
|
+
) {
|
|
51
|
+
companion object {
|
|
52
|
+
internal fun fromMap(values: Map<String, Any>): MicHealthEvent =
|
|
53
|
+
MicHealthEvent(
|
|
54
|
+
reason = stringValue(values, "reason") ?: "",
|
|
55
|
+
sequenceGapEvents = longValue(values, "sequenceGapEvents") ?: 0,
|
|
56
|
+
decodeFailures = longValue(values, "decodeFailures") ?: 0,
|
|
57
|
+
lastLc3ReceivedAt = longValue(values, "lastLc3ReceivedAt"),
|
|
58
|
+
lastPcmProducedAt = longValue(values, "lastPcmProducedAt"),
|
|
59
|
+
timestamp = longValue(values, "timestamp") ?: System.currentTimeMillis(),
|
|
60
|
+
values = values,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
42
65
|
data class OtaStartAckEvent(
|
|
43
66
|
val timestamp: Long?,
|
|
44
67
|
val values: Map<String, Any>,
|
|
@@ -144,6 +167,7 @@ interface MentraBluetoothSdkListener {
|
|
|
144
167
|
fun onVersionInfo(event: VersionInfoResult) {}
|
|
145
168
|
fun onMicPcm(event: MicPcmEvent) {}
|
|
146
169
|
fun onMicLc3(event: MicLc3Event) {}
|
|
170
|
+
fun onMicHealth(event: MicHealthEvent) {}
|
|
147
171
|
fun onLocalTranscription(event: LocalTranscriptionEvent) {}
|
|
148
172
|
fun onDefaultDeviceChanged(device: Device?) {}
|
|
149
173
|
fun onLog(message: String) {}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.net
|
|
2
|
+
|
|
3
|
+
import java.net.Inet4Address
|
|
4
|
+
import java.net.NetworkInterface
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Best-effort local IPv4 discovery for phone-hosted servers the glasses reach over WiFi
|
|
8
|
+
* (photo upload receiver, hotspot-served OTA). Prefers WiFi-ish interfaces carrying RFC1918
|
|
9
|
+
* addresses — under a glasses-hotspot session wlan0 holds the 192.168.43.x client address.
|
|
10
|
+
*/
|
|
11
|
+
object LocalIpv4 {
|
|
12
|
+
fun bestLocalIpv4Address(): String? {
|
|
13
|
+
val wifiPrivateCandidates = mutableListOf<Inet4Address>()
|
|
14
|
+
val wifiCandidates = mutableListOf<Inet4Address>()
|
|
15
|
+
val privateCandidates = mutableListOf<Inet4Address>()
|
|
16
|
+
val otherCandidates = mutableListOf<Inet4Address>()
|
|
17
|
+
val interfaces = NetworkInterface.getNetworkInterfaces()?.toList().orEmpty()
|
|
18
|
+
for (networkInterface in interfaces) {
|
|
19
|
+
if (!networkInterface.isUp || networkInterface.isLoopback) {
|
|
20
|
+
continue
|
|
21
|
+
}
|
|
22
|
+
val addresses = networkInterface.inetAddresses.toList()
|
|
23
|
+
.filterIsInstance<Inet4Address>()
|
|
24
|
+
.filter { address ->
|
|
25
|
+
!address.isLoopbackAddress &&
|
|
26
|
+
!address.isLinkLocalAddress
|
|
27
|
+
}
|
|
28
|
+
if (isWifiInterface(networkInterface.name)) {
|
|
29
|
+
wifiPrivateCandidates += addresses.filter { isPrivateIpv4(it.hostAddress.orEmpty()) }
|
|
30
|
+
wifiCandidates += addresses
|
|
31
|
+
} else {
|
|
32
|
+
privateCandidates += addresses.filter { isPrivateIpv4(it.hostAddress.orEmpty()) }
|
|
33
|
+
otherCandidates += addresses
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
wifiPrivateCandidates.firstOrNull() ?:
|
|
39
|
+
wifiCandidates.firstOrNull() ?:
|
|
40
|
+
privateCandidates.firstOrNull() ?:
|
|
41
|
+
otherCandidates.firstOrNull()
|
|
42
|
+
)?.hostAddress
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private fun isWifiInterface(name: String): Boolean {
|
|
46
|
+
return name.startsWith("wlan") ||
|
|
47
|
+
name.startsWith("p2p") ||
|
|
48
|
+
name.startsWith("ap") ||
|
|
49
|
+
name.startsWith("swlan")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private fun isPrivateIpv4(host: String): Boolean {
|
|
53
|
+
return host.startsWith("192.168.") ||
|
|
54
|
+
host.startsWith("10.") ||
|
|
55
|
+
host.matches(Regex("^172\\.(1[6-9]|2[0-9]|3[0-1])\\..*"))
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
package com.mentra.bluetoothsdk.otaserver
|
|
2
|
+
|
|
3
|
+
import com.mentra.bluetoothsdk.debug.BleTraceLogger
|
|
4
|
+
import kotlinx.coroutines.CoroutineScope
|
|
5
|
+
import kotlinx.coroutines.Dispatchers
|
|
6
|
+
import kotlinx.coroutines.Job
|
|
7
|
+
import kotlinx.coroutines.SupervisorJob
|
|
8
|
+
import kotlinx.coroutines.cancel
|
|
9
|
+
import kotlinx.coroutines.isActive
|
|
10
|
+
import kotlinx.coroutines.launch
|
|
11
|
+
import java.io.BufferedInputStream
|
|
12
|
+
import java.io.ByteArrayOutputStream
|
|
13
|
+
import java.io.EOFException
|
|
14
|
+
import java.io.File
|
|
15
|
+
import java.io.InputStream
|
|
16
|
+
import java.io.OutputStream
|
|
17
|
+
import java.net.InetSocketAddress
|
|
18
|
+
import java.net.ServerSocket
|
|
19
|
+
import java.net.Socket
|
|
20
|
+
import java.net.SocketException
|
|
21
|
+
import java.nio.charset.StandardCharsets
|
|
22
|
+
import java.util.Locale
|
|
23
|
+
import java.util.concurrent.Semaphore
|
|
24
|
+
import org.json.JSONObject
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Static HTTP server for hotspot-served glasses OTA (OS-1676).
|
|
28
|
+
*
|
|
29
|
+
* Serves the rewritten OTA manifest at /version.json and pre-downloaded artifacts at
|
|
30
|
+
* /artifacts/<sha256> to the glasses over the glasses-hotspot link. Same hand-rolled
|
|
31
|
+
* socket structure as [LocalPhotoUploadServer] —
|
|
32
|
+
* no third-party HTTP dependency in the SDK.
|
|
33
|
+
*/
|
|
34
|
+
class LocalOtaServer(
|
|
35
|
+
private val onLog: (String) -> Unit,
|
|
36
|
+
) : AutoCloseable {
|
|
37
|
+
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
38
|
+
private var serverSocket: ServerSocket? = null
|
|
39
|
+
private var serverJob: Job? = null
|
|
40
|
+
private val clientSlots = Semaphore(MAX_ACTIVE_CLIENTS)
|
|
41
|
+
|
|
42
|
+
@Volatile
|
|
43
|
+
private var manifestJson: String = "{}"
|
|
44
|
+
|
|
45
|
+
@Volatile
|
|
46
|
+
private var artifacts: Map<String, File> = emptyMap()
|
|
47
|
+
|
|
48
|
+
@Volatile
|
|
49
|
+
var running: Boolean = false
|
|
50
|
+
private set
|
|
51
|
+
|
|
52
|
+
val activePort: Int?
|
|
53
|
+
get() = serverSocket
|
|
54
|
+
?.takeIf { running && !it.isClosed }
|
|
55
|
+
?.localPort
|
|
56
|
+
|
|
57
|
+
/** Swap the served manifest and artifact set. Safe while the server is running. */
|
|
58
|
+
fun configure(manifestJson: String, artifacts: Map<String, File>) {
|
|
59
|
+
this.manifestJson = manifestJson
|
|
60
|
+
this.artifacts = artifacts
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
fun start(host: String, port: Int): Int {
|
|
64
|
+
val activeSocket = serverSocket
|
|
65
|
+
if (running && activeSocket != null && !activeSocket.isClosed && activeSocket.localPort == port) {
|
|
66
|
+
onLog("Already listening on $host:${activeSocket.localPort}")
|
|
67
|
+
return activeSocket.localPort
|
|
68
|
+
}
|
|
69
|
+
stop()
|
|
70
|
+
val socket = ServerSocket()
|
|
71
|
+
try {
|
|
72
|
+
socket.reuseAddress = true
|
|
73
|
+
socket.bind(InetSocketAddress(host, port))
|
|
74
|
+
serverSocket = socket
|
|
75
|
+
running = true
|
|
76
|
+
serverJob = scope.launch {
|
|
77
|
+
acceptLoop(socket)
|
|
78
|
+
}
|
|
79
|
+
} catch (error: Throwable) {
|
|
80
|
+
try {
|
|
81
|
+
socket.close()
|
|
82
|
+
} catch (_: Throwable) {
|
|
83
|
+
}
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
86
|
+
onLog("OTA server listening on $host:${socket.localPort}")
|
|
87
|
+
return socket.localPort
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fun stop() {
|
|
91
|
+
running = false
|
|
92
|
+
serverJob?.cancel()
|
|
93
|
+
serverJob = null
|
|
94
|
+
try {
|
|
95
|
+
serverSocket?.close()
|
|
96
|
+
} catch (_: Throwable) {
|
|
97
|
+
}
|
|
98
|
+
serverSocket = null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
override fun close() {
|
|
102
|
+
stop()
|
|
103
|
+
scope.cancel()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private fun acceptLoop(socket: ServerSocket) {
|
|
107
|
+
while (scope.isActive && !socket.isClosed) {
|
|
108
|
+
try {
|
|
109
|
+
val client = socket.accept()
|
|
110
|
+
if (!clientSlots.tryAcquire()) {
|
|
111
|
+
rejectClient(client)
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
scope.launch {
|
|
115
|
+
try {
|
|
116
|
+
handleClient(client)
|
|
117
|
+
} finally {
|
|
118
|
+
clientSlots.release()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch (error: SocketException) {
|
|
122
|
+
if (running) onLog("Accept failed: ${error.message}")
|
|
123
|
+
} catch (error: Throwable) {
|
|
124
|
+
if (running) onLog("Accept failed: ${error.message ?: error::class.java.simpleName}")
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private fun handleClient(socket: Socket) {
|
|
130
|
+
socket.use { client ->
|
|
131
|
+
val requestStartMs = System.currentTimeMillis()
|
|
132
|
+
client.soTimeout = SOCKET_TIMEOUT_MS
|
|
133
|
+
val input = BufferedInputStream(client.getInputStream())
|
|
134
|
+
val output = client.getOutputStream()
|
|
135
|
+
var request: HttpRequest? = null
|
|
136
|
+
var status = 0
|
|
137
|
+
var responseBytes = 0L
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
val headerText = readHeaders(input).toString(StandardCharsets.ISO_8859_1)
|
|
141
|
+
request = HttpRequest.parse(headerText)
|
|
142
|
+
onLog("${request.method} ${request.path} from ${client.inetAddress.hostAddress}")
|
|
143
|
+
|
|
144
|
+
if (request.method != "GET") {
|
|
145
|
+
status = 405
|
|
146
|
+
writeJson(output, status, """{"ok":false,"error":"method_not_allowed"}""")
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
when {
|
|
151
|
+
request.path == "/version.json" -> {
|
|
152
|
+
val body = manifestJson.toByteArray(StandardCharsets.UTF_8)
|
|
153
|
+
status = 200
|
|
154
|
+
responseBytes = body.size.toLong()
|
|
155
|
+
writeBody(output, status, "application/json", body)
|
|
156
|
+
}
|
|
157
|
+
request.path.startsWith("/artifacts/") -> {
|
|
158
|
+
val key = request.path.removePrefix("/artifacts/")
|
|
159
|
+
val file = artifacts[key]
|
|
160
|
+
if (file == null || !file.isFile) {
|
|
161
|
+
status = 404
|
|
162
|
+
writeJson(output, status, """{"ok":false,"error":"artifact_not_found"}""")
|
|
163
|
+
} else {
|
|
164
|
+
status = 200
|
|
165
|
+
responseBytes = file.length()
|
|
166
|
+
writeFile(output, file)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else -> {
|
|
170
|
+
status = 404
|
|
171
|
+
writeJson(output, status, """{"ok":false,"error":"not_found"}""")
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch (error: Throwable) {
|
|
175
|
+
onLog("request failed: ${error.message ?: error::class.java.simpleName}")
|
|
176
|
+
if (status == 0) {
|
|
177
|
+
status = 500
|
|
178
|
+
try {
|
|
179
|
+
writeJson(output, status, """{"ok":false,"error":"server_error"}""")
|
|
180
|
+
} catch (_: Throwable) {
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
trace(client, request, status, responseBytes, requestStartMs)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private fun writeFile(output: OutputStream, file: File) {
|
|
190
|
+
val fileLength = file.length()
|
|
191
|
+
output.write("HTTP/1.1 200 OK\r\n".toByteArray(StandardCharsets.US_ASCII))
|
|
192
|
+
output.write("Content-Type: application/octet-stream\r\n".toByteArray(StandardCharsets.US_ASCII))
|
|
193
|
+
output.write("Content-Length: $fileLength\r\n".toByteArray(StandardCharsets.US_ASCII))
|
|
194
|
+
output.write("Connection: close\r\n\r\n".toByteArray(StandardCharsets.US_ASCII))
|
|
195
|
+
file.inputStream().use { fileInput ->
|
|
196
|
+
val buffer = ByteArray(STREAM_CHUNK_BYTES)
|
|
197
|
+
var remaining = fileLength
|
|
198
|
+
while (remaining > 0) {
|
|
199
|
+
val read = fileInput.read(buffer, 0, minOf(buffer.size.toLong(), remaining).toInt())
|
|
200
|
+
if (read <= 0) throw EOFException("artifact truncated while streaming")
|
|
201
|
+
output.write(buffer, 0, read)
|
|
202
|
+
remaining -= read
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
output.flush()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private fun rejectClient(socket: Socket) {
|
|
209
|
+
socket.use { client ->
|
|
210
|
+
try {
|
|
211
|
+
writeJson(client.getOutputStream(), 503, """{"ok":false,"error":"too_many_connections"}""")
|
|
212
|
+
} catch (_: Throwable) {
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private fun readHeaders(input: InputStream): ByteArray {
|
|
218
|
+
val out = ByteArrayOutputStream()
|
|
219
|
+
val delimiter = byteArrayOf(13, 10, 13, 10)
|
|
220
|
+
var matched = 0
|
|
221
|
+
while (true) {
|
|
222
|
+
val byte = input.read()
|
|
223
|
+
if (byte == -1) throw EOFException("Socket closed before HTTP headers completed")
|
|
224
|
+
out.write(byte)
|
|
225
|
+
matched = if (byte.toByte() == delimiter[matched]) {
|
|
226
|
+
matched + 1
|
|
227
|
+
} else if (byte.toByte() == delimiter[0]) {
|
|
228
|
+
1
|
|
229
|
+
} else {
|
|
230
|
+
0
|
|
231
|
+
}
|
|
232
|
+
if (matched == delimiter.size) return out.toByteArray()
|
|
233
|
+
if (out.size() > MAX_HEADER_BYTES) throw IllegalArgumentException("HTTP headers too large")
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private fun writeJson(output: OutputStream, status: Int, body: String) {
|
|
238
|
+
writeBody(output, status, "application/json", body.toByteArray(StandardCharsets.UTF_8))
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private fun writeBody(
|
|
242
|
+
output: OutputStream,
|
|
243
|
+
status: Int,
|
|
244
|
+
contentType: String,
|
|
245
|
+
body: ByteArray,
|
|
246
|
+
) {
|
|
247
|
+
val reason = when (status) {
|
|
248
|
+
200 -> "OK"
|
|
249
|
+
404 -> "Not Found"
|
|
250
|
+
405 -> "Method Not Allowed"
|
|
251
|
+
503 -> "Service Unavailable"
|
|
252
|
+
else -> "Internal Server Error"
|
|
253
|
+
}
|
|
254
|
+
output.write("HTTP/1.1 $status $reason\r\n".toByteArray(StandardCharsets.US_ASCII))
|
|
255
|
+
output.write("Content-Type: $contentType\r\n".toByteArray(StandardCharsets.US_ASCII))
|
|
256
|
+
output.write("Content-Length: ${body.size}\r\n".toByteArray(StandardCharsets.US_ASCII))
|
|
257
|
+
output.write("Connection: close\r\n\r\n".toByteArray(StandardCharsets.US_ASCII))
|
|
258
|
+
output.write(body)
|
|
259
|
+
output.flush()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private fun trace(
|
|
263
|
+
client: Socket,
|
|
264
|
+
request: HttpRequest?,
|
|
265
|
+
status: Int,
|
|
266
|
+
responseBytes: Long,
|
|
267
|
+
startMs: Long,
|
|
268
|
+
) {
|
|
269
|
+
val payload = JSONObject()
|
|
270
|
+
try {
|
|
271
|
+
payload.put("type", "ota_server_request")
|
|
272
|
+
payload.put("remoteHost", client.inetAddress?.hostAddress ?: "")
|
|
273
|
+
payload.put("method", request?.method ?: "")
|
|
274
|
+
payload.put("path", request?.path ?: "")
|
|
275
|
+
payload.put("statusCode", status)
|
|
276
|
+
payload.put("responseBytes", responseBytes)
|
|
277
|
+
payload.put("durationMs", System.currentTimeMillis() - startMs)
|
|
278
|
+
BleTraceLogger.logJson("phone_to_wifi", "wifi_http_output", payload, null)
|
|
279
|
+
} catch (_: Throwable) {
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private data class HttpRequest(
|
|
284
|
+
val method: String,
|
|
285
|
+
val path: String,
|
|
286
|
+
) {
|
|
287
|
+
companion object {
|
|
288
|
+
fun parse(headerText: String): HttpRequest {
|
|
289
|
+
val lines = headerText.split("\r\n").filter { it.isNotBlank() }
|
|
290
|
+
val requestParts = lines.firstOrNull()?.split(" ").orEmpty()
|
|
291
|
+
val method = requestParts.getOrNull(0)?.uppercase(Locale.US).orEmpty()
|
|
292
|
+
val path = requestParts.getOrNull(1)?.substringBefore("?").orEmpty()
|
|
293
|
+
return HttpRequest(method, path)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
companion object {
|
|
299
|
+
private const val MAX_HEADER_BYTES = 64 * 1024
|
|
300
|
+
private const val MAX_ACTIVE_CLIENTS = 2
|
|
301
|
+
private const val SOCKET_TIMEOUT_MS = 20_000
|
|
302
|
+
private const val STREAM_CHUNK_BYTES = 64 * 1024
|
|
303
|
+
}
|
|
304
|
+
}
|