@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.
- package/README.md +56 -9
- 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 +35 -4
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +216 -23
- 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/BesOtaHeartbeatGuard.kt +49 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +1344 -284
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
- 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/BesOtaHeartbeatGuardTest.kt +73 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +75 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/utils/BleJsonCompactTest.java +148 -7
- package/build/BluetoothSdk.types.d.ts +58 -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 +43 -4
- package/ios/Source/DeviceManager.swift +154 -19
- 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 +469 -77
- package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
- 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/ios/Tests/MentraLivePairingAdvertisementTests.swift +104 -0
- 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 +69 -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
|
@@ -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
|
|
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? {
|
package/ios/Source/Bridge.swift
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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='\(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
-
|
|
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.
|
|
1796
|
+
self.pendingDeviceName = name
|
|
1674
1797
|
|
|
1675
1798
|
initSGC(self.pendingWearable)
|
|
1676
|
-
sgc?.connectById(
|
|
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
|
-
|
|
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", "")
|