@mentra/bluetooth-sdk 3.2.1 → 3.3.0-beta.954
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 +68 -6
- package/android/build.gradle +15 -0
- package/android/lc3Lib/build.gradle +6 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +1 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +2 -2
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +4 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +2 -1
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +223 -36
- package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +93 -18
- package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +3 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
- package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
- package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +20 -7
- package/build/BluetoothSdk.types.d.ts +3 -0
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js +2 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
- package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
- package/build/_private/MentraLocalNetworkModule.js.map +1 -1
- package/build/_private/nativeLogConsole.d.ts +19 -0
- package/build/_private/nativeLogConsole.d.ts.map +1 -1
- package/build/_private/nativeLogConsole.js +12 -0
- package/build/_private/nativeLogConsole.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +4 -1
- package/build/generated/changelogCatalog.d.ts.map +1 -1
- package/build/generated/changelogCatalog.js +5 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +6 -6
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/ota-transport/index.d.ts +4 -0
- package/build/ota-transport/index.d.ts.map +1 -1
- package/build/ota-transport/index.js +2 -0
- package/build/ota-transport/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +8 -4
- package/ios/Source/BluetoothSdkDefaults.swift +2 -2
- package/ios/Source/DeviceManager.swift +6 -3
- package/ios/Source/GeneratedChangelogCatalog.swift +2 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
- package/ios/Source/MentraBluetoothSDK.swift +295 -54
- package/ios/Source/internal/BluetoothAvailability.swift +6 -0
- package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
- package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
- package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
- package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
- package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
- package/ios/Source/sgcs/MentraLive.swift +71 -41
- package/ios/Source/sgcs/NimoCanvasCodec.swift +4 -1
- package/ios/Source/sgcs/SGCManager.swift +1 -1
- package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
- package/ios/Source/utils/G1Text.swift +2 -24
- package/ios/Source/utils/G1TextSanitizer.swift +27 -0
- package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
- package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
- package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
- package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
- package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
- package/ios/Tests/G1TextSanitizerTests.swift +32 -0
- package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
- package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
- package/ios/Tests/NimoCanvasCodecTests.swift +17 -4
- package/ios/Tests/VersionInfoCommandTests.swift +142 -1
- package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
- package/package.json +2 -2
- package/src/BluetoothSdk.types.ts +1 -0
- package/src/_private/BluetoothSdkModule.ts +2 -1
- package/src/_private/MentraLocalNetworkModule.ts +28 -0
- package/src/_private/nativeLogConsole.ts +19 -0
- package/src/generated/changelogCatalog.ts +5 -1
- package/src/generated/releaseMetadata.ts +6 -6
- package/src/ota-transport/index.ts +10 -1
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Pure decision logic for the Mentra Live usage events: one event per photo,
|
|
4
|
+
/// recording, stream, mic session, setup attempt, OTA attempt and timed-out
|
|
5
|
+
/// glasses command. It owns no threads and no I/O; `BluetoothSdkAnalytics` feeds
|
|
6
|
+
/// it facts and captures what it returns, and only when analytics is enabled.
|
|
7
|
+
///
|
|
8
|
+
/// Durations are measured on the phone's clock between the start and end
|
|
9
|
+
/// statuses it observes. A recording or stream whose end never reaches this
|
|
10
|
+
/// process (the app was killed mid-stream) produces only its start.
|
|
11
|
+
struct BluetoothSdkUsageTracker {
|
|
12
|
+
static let eventPhotoTaken = "bluetooth_sdk_photo_taken"
|
|
13
|
+
static let eventVideoRecorded = "bluetooth_sdk_video_recorded"
|
|
14
|
+
static let eventStreamStarted = "bluetooth_sdk_stream_started"
|
|
15
|
+
static let eventStreamEnded = "bluetooth_sdk_stream_ended"
|
|
16
|
+
static let eventMicSession = "bluetooth_sdk_mic_session"
|
|
17
|
+
static let eventWifiSetup = "bluetooth_sdk_wifi_setup"
|
|
18
|
+
static let eventHotspotStarted = "bluetooth_sdk_hotspot_started"
|
|
19
|
+
static let eventOtaFinished = "bluetooth_sdk_ota_finished"
|
|
20
|
+
static let eventRequestFailed = "bluetooth_sdk_request_failed"
|
|
21
|
+
|
|
22
|
+
static let maxRequestTimeoutsDefault = 100
|
|
23
|
+
/// Glasses report button-capture photo failures under this request id (ASG MediaCaptureService).
|
|
24
|
+
static let localPhotoRequestId = "local"
|
|
25
|
+
/// Button-started recordings use `local_video_<ts>_<rand>` request ids on the glasses.
|
|
26
|
+
static let localVideoRequestPrefix = "local_video_"
|
|
27
|
+
|
|
28
|
+
private static let maxOpenRecordings = 16
|
|
29
|
+
private static let maxOtaSessions = 16
|
|
30
|
+
private static let maxDetailLength = 200
|
|
31
|
+
private static let triggerApi = "api"
|
|
32
|
+
private static let triggerButton = "button"
|
|
33
|
+
private static let otaComplete = "complete"
|
|
34
|
+
private static let otaFailed = "failed"
|
|
35
|
+
private static let otaIdle = "idle"
|
|
36
|
+
private static let legacyTerminalStreamStates: Set<String> = ["stopped", "reconnect_failed"]
|
|
37
|
+
private static let liveStreamStates: Set<String> = ["streaming", "reconnected"]
|
|
38
|
+
private static let urlPattern = try! NSRegularExpression(pattern: #"[A-Za-z][A-Za-z0-9+.\-]*://\S+"#)
|
|
39
|
+
|
|
40
|
+
private struct OpenRecording {
|
|
41
|
+
var startedAtMs: Int64?
|
|
42
|
+
var width: Int?
|
|
43
|
+
var height: Int?
|
|
44
|
+
var fps: Int?
|
|
45
|
+
var save: Bool?
|
|
46
|
+
var maxDurationMinutes: Int?
|
|
47
|
+
var stoppedByApp = false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private struct OpenStream {
|
|
51
|
+
let streamId: String
|
|
52
|
+
let streamProtocol: String
|
|
53
|
+
/// Set by the first live status; nil while the start is still pending.
|
|
54
|
+
var startedAtMs: Int64?
|
|
55
|
+
var reconnectAttempts = 0
|
|
56
|
+
var stoppedByApp = false
|
|
57
|
+
var bitrate: Int64?
|
|
58
|
+
var fps: Double?
|
|
59
|
+
var droppedFrames: Int64?
|
|
60
|
+
var temperatureC: Double?
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private struct OpenOta {
|
|
64
|
+
let startedAtMs: Int64
|
|
65
|
+
let fromBuildNumber: String
|
|
66
|
+
let fromBesFirmwareVersion: String
|
|
67
|
+
let fromMtkFirmwareVersion: String
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private let maxRequestTimeouts: Int
|
|
71
|
+
private var recordings = BoundedMap<String, OpenRecording>(capacity: BluetoothSdkUsageTracker.maxOpenRecordings)
|
|
72
|
+
private var stream: OpenStream?
|
|
73
|
+
private var micSource: String?
|
|
74
|
+
private var micStartedAtMs: Int64 = 0
|
|
75
|
+
private var micTranscribed = false
|
|
76
|
+
private var otaSessions = BoundedMap<String, OpenOta>(capacity: BluetoothSdkUsageTracker.maxOtaSessions)
|
|
77
|
+
private var finishedOtaSessions = BoundedMap<String, Bool>(capacity: BluetoothSdkUsageTracker.maxOtaSessions)
|
|
78
|
+
private var requestTimeouts = 0
|
|
79
|
+
|
|
80
|
+
init(maxRequestTimeouts: Int = BluetoothSdkUsageTracker.maxRequestTimeoutsDefault) {
|
|
81
|
+
self.maxRequestTimeouts = maxRequestTimeouts
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func photoSettled(
|
|
85
|
+
size: String,
|
|
86
|
+
mode: String,
|
|
87
|
+
compress: String,
|
|
88
|
+
save: Bool,
|
|
89
|
+
transferMethod: String,
|
|
90
|
+
latencyMs: Int64,
|
|
91
|
+
fileSizeBytes: Int64?,
|
|
92
|
+
errorCode: String?
|
|
93
|
+
) -> AnalyticsEvent {
|
|
94
|
+
var properties: [String: Any] = ["trigger": Self.triggerApi]
|
|
95
|
+
Self.putOutcome(&properties, errorCode)
|
|
96
|
+
properties["latency_ms"] = latencyMs
|
|
97
|
+
properties["photo_size"] = size
|
|
98
|
+
properties["photo_mode"] = mode
|
|
99
|
+
properties["compress"] = compress
|
|
100
|
+
properties["save_to_gallery"] = save
|
|
101
|
+
properties["transfer_method"] = transferMethod
|
|
102
|
+
if let fileSizeBytes, fileSizeBytes > 0 { properties["file_size_bytes"] = fileSizeBytes }
|
|
103
|
+
return AnalyticsEvent(name: Self.eventPhotoTaken, properties: properties)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// A button photo the glasses could not take; successful button photos never reach the phone.
|
|
107
|
+
func buttonPhotoFailed(errorCode: String?) -> AnalyticsEvent {
|
|
108
|
+
var properties: [String: Any] = ["trigger": Self.triggerButton]
|
|
109
|
+
Self.putOutcome(&properties, errorCode ?? "photo_request_failed")
|
|
110
|
+
return AnalyticsEvent(name: Self.eventPhotoTaken, properties: properties)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// Records the API parameters of a recording so its end event can report them.
|
|
114
|
+
mutating func videoRequested(requestId: String, width: Int, height: Int, fps: Int, save: Bool, maxDurationMinutes: Int) {
|
|
115
|
+
recordings[requestId] = OpenRecording(
|
|
116
|
+
width: width > 0 ? width : nil,
|
|
117
|
+
height: height > 0 ? height : nil,
|
|
118
|
+
fps: fps > 0 ? fps : nil,
|
|
119
|
+
save: save,
|
|
120
|
+
maxDurationMinutes: maxDurationMinutes > 0 ? maxDurationMinutes : nil
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
mutating func videoStopRequested(requestId: String) {
|
|
125
|
+
guard var recording = recordings[requestId] else { return }
|
|
126
|
+
recording.stoppedByApp = true
|
|
127
|
+
recordings[requestId] = recording
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Glasses report every recording, button-started ones included, through
|
|
131
|
+
/// `video_recording_status`. A recording is reported once: when it stops, or
|
|
132
|
+
/// when a command for it fails. `not_recording` answers a stop for a recording
|
|
133
|
+
/// that already ended and is not a recording of its own.
|
|
134
|
+
mutating func videoStatus(requestId: String, status: String, success: Bool, nowMs: Int64) -> AnalyticsEvent? {
|
|
135
|
+
guard !requestId.isBlank else { return nil }
|
|
136
|
+
if success {
|
|
137
|
+
switch status {
|
|
138
|
+
case "recording_started":
|
|
139
|
+
var recording = recordings[requestId] ?? OpenRecording()
|
|
140
|
+
if recording.startedAtMs == nil { recording.startedAtMs = nowMs }
|
|
141
|
+
recordings[requestId] = recording
|
|
142
|
+
return nil
|
|
143
|
+
case "recording_stopped":
|
|
144
|
+
guard let recording = recordings.removeValue(forKey: requestId) else { return nil }
|
|
145
|
+
return videoEvent(requestId: requestId, recording: recording, nowMs: nowMs, errorCode: nil)
|
|
146
|
+
default:
|
|
147
|
+
return nil
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if status == "not_recording" {
|
|
151
|
+
recordings.removeValue(forKey: requestId)
|
|
152
|
+
return nil
|
|
153
|
+
}
|
|
154
|
+
let recording = recordings.removeValue(forKey: requestId) ?? OpenRecording()
|
|
155
|
+
return videoEvent(
|
|
156
|
+
requestId: requestId,
|
|
157
|
+
recording: recording,
|
|
158
|
+
nowMs: nowMs,
|
|
159
|
+
errorCode: status.isBlank ? "video_recording_failed" : status
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private func videoEvent(requestId: String, recording: OpenRecording, nowMs: Int64, errorCode: String?) -> AnalyticsEvent {
|
|
164
|
+
var properties: [String: Any] = [
|
|
165
|
+
"trigger": requestId.hasPrefix(Self.localVideoRequestPrefix) ? Self.triggerButton : Self.triggerApi,
|
|
166
|
+
]
|
|
167
|
+
Self.putOutcome(&properties, errorCode)
|
|
168
|
+
if let startedAtMs = recording.startedAtMs { properties["duration_ms"] = max(nowMs - startedAtMs, 0) }
|
|
169
|
+
if let width = recording.width { properties["width"] = width }
|
|
170
|
+
if let height = recording.height { properties["height"] = height }
|
|
171
|
+
if let fps = recording.fps { properties["fps"] = fps }
|
|
172
|
+
if let save = recording.save { properties["save_to_gallery"] = save }
|
|
173
|
+
if let maxDurationMinutes = recording.maxDurationMinutes { properties["max_duration_minutes"] = maxDurationMinutes }
|
|
174
|
+
properties["stopped_by_app"] = recording.stoppedByApp
|
|
175
|
+
return AnalyticsEvent(name: Self.eventVideoRecorded, properties: properties)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// Opens the stream as its start command is sent, so a status that arrives
|
|
179
|
+
/// before `startStream` returns is still attributed to it. A new start
|
|
180
|
+
/// supersedes a stream that was live (the glasses stop it), so that stream is
|
|
181
|
+
/// reported as `replaced`.
|
|
182
|
+
mutating func streamRequested(streamId: String, streamUrl: String, nowMs: Int64) -> AnalyticsEvent? {
|
|
183
|
+
var replaced: AnalyticsEvent?
|
|
184
|
+
if let open = stream, open.startedAtMs != nil, open.streamId != streamId {
|
|
185
|
+
replaced = Self.streamEndedEvent(open, endState: "replaced", errorDetails: nil, nowMs: nowMs)
|
|
186
|
+
}
|
|
187
|
+
stream = OpenStream(streamId: streamId, streamProtocol: Self.streamProtocol(streamUrl))
|
|
188
|
+
return replaced
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
func streamStartSettled(
|
|
192
|
+
streamUrl: String,
|
|
193
|
+
width: Int?,
|
|
194
|
+
height: Int?,
|
|
195
|
+
fps: Int?,
|
|
196
|
+
bitrate: Int?,
|
|
197
|
+
captureAudio: Bool,
|
|
198
|
+
glassesNetwork: String,
|
|
199
|
+
latencyMs: Int64,
|
|
200
|
+
errorCode: String?
|
|
201
|
+
) -> AnalyticsEvent {
|
|
202
|
+
var properties: [String: Any] = [:]
|
|
203
|
+
Self.putOutcome(&properties, errorCode)
|
|
204
|
+
properties["protocol"] = Self.streamProtocol(streamUrl)
|
|
205
|
+
if let width { properties["width"] = width }
|
|
206
|
+
if let height { properties["height"] = height }
|
|
207
|
+
if let fps { properties["fps"] = fps }
|
|
208
|
+
if let bitrate { properties["bitrate"] = bitrate }
|
|
209
|
+
properties["capture_audio"] = captureAudio
|
|
210
|
+
properties["glasses_network"] = glassesNetwork
|
|
211
|
+
properties["latency_ms"] = latencyMs
|
|
212
|
+
return AnalyticsEvent(name: Self.eventStreamStarted, properties: properties)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
mutating func streamStopRequested() {
|
|
216
|
+
stream?.stoppedByApp = true
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/// Folds one accepted `stream_status` into the open stream and reports the
|
|
220
|
+
/// stream once, on its terminal status. Glasses with stream revisions flag the
|
|
221
|
+
/// terminal status; older glasses only send a final `stopped` or
|
|
222
|
+
/// `reconnect_failed`. A stream that never went live ends silently: its
|
|
223
|
+
/// `bluetooth_sdk_stream_started` already reported the failure.
|
|
224
|
+
mutating func streamStatus(
|
|
225
|
+
streamId: String?,
|
|
226
|
+
state: String,
|
|
227
|
+
terminal: Bool?,
|
|
228
|
+
reconnectAttempt: Int?,
|
|
229
|
+
bitrate: Int64?,
|
|
230
|
+
fps: Double?,
|
|
231
|
+
droppedFrames: Int64?,
|
|
232
|
+
temperatureC: Double?,
|
|
233
|
+
errorDetails: String?,
|
|
234
|
+
nowMs: Int64
|
|
235
|
+
) -> AnalyticsEvent? {
|
|
236
|
+
guard var open = stream else { return nil }
|
|
237
|
+
if let streamId, !streamId.isBlank, streamId != open.streamId { return nil }
|
|
238
|
+
if Self.liveStreamStates.contains(state), open.startedAtMs == nil { open.startedAtMs = nowMs }
|
|
239
|
+
if let reconnectAttempt { open.reconnectAttempts = max(open.reconnectAttempts, reconnectAttempt) }
|
|
240
|
+
if let bitrate { open.bitrate = bitrate }
|
|
241
|
+
if let fps { open.fps = fps }
|
|
242
|
+
if let droppedFrames { open.droppedFrames = droppedFrames }
|
|
243
|
+
if let temperatureC { open.temperatureC = temperatureC }
|
|
244
|
+
let ended = terminal == true || (terminal == nil && Self.legacyTerminalStreamStates.contains(state))
|
|
245
|
+
guard ended else {
|
|
246
|
+
stream = open
|
|
247
|
+
return nil
|
|
248
|
+
}
|
|
249
|
+
stream = nil
|
|
250
|
+
guard open.startedAtMs != nil else { return nil }
|
|
251
|
+
return Self.streamEndedEvent(open, endState: state, errorDetails: errorDetails, nowMs: nowMs)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private static func streamEndedEvent(_ open: OpenStream, endState: String, errorDetails: String?, nowMs: Int64) -> AnalyticsEvent {
|
|
255
|
+
var properties: [String: Any] = [
|
|
256
|
+
"protocol": open.streamProtocol,
|
|
257
|
+
"end_state": endState,
|
|
258
|
+
"stopped_by_app": open.stoppedByApp,
|
|
259
|
+
"reconnect_attempts": open.reconnectAttempts,
|
|
260
|
+
]
|
|
261
|
+
if let startedAtMs = open.startedAtMs { properties["duration_ms"] = max(nowMs - startedAtMs, 0) }
|
|
262
|
+
if let detail = Self.sanitizedDetail(errorDetails) { properties["error_code"] = detail }
|
|
263
|
+
if let bitrate = open.bitrate { properties["bitrate"] = bitrate }
|
|
264
|
+
if let fps = open.fps { properties["fps"] = fps }
|
|
265
|
+
if let droppedFrames = open.droppedFrames { properties["dropped_frames"] = droppedFrames }
|
|
266
|
+
if let temperatureC = open.temperatureC { properties["temperature_c"] = temperatureC }
|
|
267
|
+
return AnalyticsEvent(name: Self.eventStreamEnded, properties: properties)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/// Follows the active mic source. A session ends when the source turns off or
|
|
271
|
+
/// changes; `local_transcription` is true if on-device transcription ran at
|
|
272
|
+
/// any point during it.
|
|
273
|
+
mutating func observeMic(currentMic: String, transcribing: Bool, nowMs: Int64) -> AnalyticsEvent? {
|
|
274
|
+
let next: String? = currentMic.isBlank ? nil : currentMic
|
|
275
|
+
let previous = micSource
|
|
276
|
+
if next == previous {
|
|
277
|
+
if next != nil, transcribing { micTranscribed = true }
|
|
278
|
+
return nil
|
|
279
|
+
}
|
|
280
|
+
let ended = previous.map {
|
|
281
|
+
AnalyticsEvent(
|
|
282
|
+
name: Self.eventMicSession,
|
|
283
|
+
properties: [
|
|
284
|
+
"duration_ms": max(nowMs - micStartedAtMs, 0),
|
|
285
|
+
"mic_source": $0,
|
|
286
|
+
"local_transcription": micTranscribed,
|
|
287
|
+
]
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
micSource = next
|
|
291
|
+
micStartedAtMs = nowMs
|
|
292
|
+
micTranscribed = next != nil && transcribing
|
|
293
|
+
return ended
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
func wifiSetupSettled(latencyMs: Int64, errorCode: String?) -> AnalyticsEvent {
|
|
297
|
+
var properties: [String: Any] = [:]
|
|
298
|
+
Self.putOutcome(&properties, errorCode)
|
|
299
|
+
properties["latency_ms"] = latencyMs
|
|
300
|
+
return AnalyticsEvent(name: Self.eventWifiSetup, properties: properties)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
func hotspotStartSettled(latencyMs: Int64, errorCode: String?) -> AnalyticsEvent {
|
|
304
|
+
var properties: [String: Any] = [:]
|
|
305
|
+
Self.putOutcome(&properties, errorCode)
|
|
306
|
+
properties["latency_ms"] = latencyMs
|
|
307
|
+
return AnalyticsEvent(name: Self.eventHotspotStarted, properties: properties)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// One event per OTA session, on `complete` or `failed`. The "from" versions are
|
|
311
|
+
/// the glasses versions when this process first saw the session in progress.
|
|
312
|
+
mutating func otaStatus(
|
|
313
|
+
sessionId: String,
|
|
314
|
+
status: String,
|
|
315
|
+
stepType: String,
|
|
316
|
+
totalSteps: Int,
|
|
317
|
+
errorMessage: String?,
|
|
318
|
+
fromBuildNumber: String,
|
|
319
|
+
fromBesFirmwareVersion: String,
|
|
320
|
+
fromMtkFirmwareVersion: String,
|
|
321
|
+
nowMs: Int64
|
|
322
|
+
) -> AnalyticsEvent? {
|
|
323
|
+
if finishedOtaSessions.contains(sessionId) { return nil }
|
|
324
|
+
if status != Self.otaComplete, status != Self.otaFailed {
|
|
325
|
+
if status != Self.otaIdle, !otaSessions.contains(sessionId) {
|
|
326
|
+
otaSessions[sessionId] = OpenOta(
|
|
327
|
+
startedAtMs: nowMs,
|
|
328
|
+
fromBuildNumber: fromBuildNumber,
|
|
329
|
+
fromBesFirmwareVersion: fromBesFirmwareVersion,
|
|
330
|
+
fromMtkFirmwareVersion: fromMtkFirmwareVersion
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
return nil
|
|
334
|
+
}
|
|
335
|
+
finishedOtaSessions[sessionId] = true
|
|
336
|
+
let open = otaSessions.removeValue(forKey: sessionId)
|
|
337
|
+
let success = status == Self.otaComplete
|
|
338
|
+
var properties: [String: Any] = ["success": success]
|
|
339
|
+
if !success {
|
|
340
|
+
if !stepType.isBlank { properties["failed_step_type"] = stepType }
|
|
341
|
+
if let detail = Self.sanitizedDetail(errorMessage) { properties["error_message"] = detail }
|
|
342
|
+
}
|
|
343
|
+
if totalSteps > 0 { properties["total_steps"] = totalSteps }
|
|
344
|
+
if let open {
|
|
345
|
+
properties["duration_ms"] = max(nowMs - open.startedAtMs, 0)
|
|
346
|
+
if !open.fromBuildNumber.isBlank { properties["from_build_number"] = open.fromBuildNumber }
|
|
347
|
+
if !open.fromBesFirmwareVersion.isBlank { properties["from_bes_firmware_version"] = open.fromBesFirmwareVersion }
|
|
348
|
+
if !open.fromMtkFirmwareVersion.isBlank { properties["from_mtk_firmware_version"] = open.fromMtkFirmwareVersion }
|
|
349
|
+
}
|
|
350
|
+
return AnalyticsEvent(name: Self.eventOtaFinished, properties: properties)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/// At most `maxRequestTimeouts` per SDK instance, so a failure loop cannot run up event volume.
|
|
354
|
+
mutating func requestTimedOut(operation: String) -> AnalyticsEvent? {
|
|
355
|
+
guard requestTimeouts < maxRequestTimeouts else { return nil }
|
|
356
|
+
requestTimeouts += 1
|
|
357
|
+
return AnalyticsEvent(
|
|
358
|
+
name: Self.eventRequestFailed,
|
|
359
|
+
properties: ["operation": operation, "error_code": "request_timeout"]
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/// Mirrors the glasses' scheme mapping in ASG StreamCommandHandler.
|
|
364
|
+
static func streamProtocol(_ streamUrl: String) -> String {
|
|
365
|
+
let trimmed = streamUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
366
|
+
guard let separator = trimmed.range(of: "://") else { return "unknown" }
|
|
367
|
+
switch trimmed[..<separator.lowerBound].lowercased() {
|
|
368
|
+
case "srt": return "srt"
|
|
369
|
+
case "rtmp", "rtmps": return "rtmp"
|
|
370
|
+
case "http", "https": return "whip"
|
|
371
|
+
default: return "unknown"
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/// Free-text details may carry stream or upload URLs; those never leave the phone.
|
|
376
|
+
static func sanitizedDetail(_ value: String?) -> String? {
|
|
377
|
+
guard let value else { return nil }
|
|
378
|
+
let range = NSRange(value.startIndex..., in: value)
|
|
379
|
+
let replaced = urlPattern.stringByReplacingMatches(in: value, range: range, withTemplate: "<url>")
|
|
380
|
+
let trimmed = replaced.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
381
|
+
guard !trimmed.isEmpty else { return nil }
|
|
382
|
+
return String(trimmed.prefix(maxDetailLength))
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private static func putOutcome(_ properties: inout [String: Any], _ errorCode: String?) {
|
|
386
|
+
properties["success"] = errorCode == nil
|
|
387
|
+
if let detail = sanitizedDetail(errorCode) { properties["error_code"] = detail }
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/// Insertion-ordered map that drops its oldest entry beyond `capacity`, so a
|
|
392
|
+
/// stream of ids whose end never arrives cannot grow without bound.
|
|
393
|
+
private struct BoundedMap<Key: Hashable, Value> {
|
|
394
|
+
private let capacity: Int
|
|
395
|
+
private var order: [Key] = []
|
|
396
|
+
private var storage: [Key: Value] = [:]
|
|
397
|
+
|
|
398
|
+
init(capacity: Int) {
|
|
399
|
+
self.capacity = capacity
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
func contains(_ key: Key) -> Bool {
|
|
403
|
+
storage[key] != nil
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
subscript(key: Key) -> Value? {
|
|
407
|
+
get { storage[key] }
|
|
408
|
+
set {
|
|
409
|
+
guard let newValue else {
|
|
410
|
+
removeValue(forKey: key)
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
if storage.updateValue(newValue, forKey: key) == nil {
|
|
414
|
+
order.append(key)
|
|
415
|
+
if order.count > capacity {
|
|
416
|
+
storage.removeValue(forKey: order.removeFirst())
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
@discardableResult
|
|
423
|
+
mutating func removeValue(forKey key: Key) -> Value? {
|
|
424
|
+
guard let removed = storage.removeValue(forKey: key) else { return nil }
|
|
425
|
+
order.removeAll { $0 == key }
|
|
426
|
+
return removed
|
|
427
|
+
}
|
|
428
|
+
}
|
|
@@ -923,7 +923,7 @@ extension MentraLive: CBCentralManagerDelegate {
|
|
|
923
923
|
self.isConnecting = false
|
|
924
924
|
|
|
925
925
|
self.connectedPeripheral = nil
|
|
926
|
-
self.
|
|
926
|
+
self.clearPhysicalReadiness()
|
|
927
927
|
self.connected = false
|
|
928
928
|
self.glassesSessionId = nil // Fresh BLE session starts with no sid known
|
|
929
929
|
self.streamControlVersion = 0
|
|
@@ -1349,7 +1349,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1349
1349
|
// Queued writes are session-bound: transmitting them into the NEXT session
|
|
1350
1350
|
// (e.g. a stale handshake whose reply activates v2 before the new session
|
|
1351
1351
|
// negotiated) is the bug class this clear removes.
|
|
1352
|
-
|
|
1352
|
+
commandQueue.removeAll()
|
|
1353
1353
|
stopSignalStrengthPolling()
|
|
1354
1354
|
DeviceStore.shared.apply("glasses", "signalStrength", -1)
|
|
1355
1355
|
DeviceStore.shared.apply("glasses", "signalStrengthUpdatedAt", 0)
|
|
@@ -1617,6 +1617,16 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1617
1617
|
set { DeviceStore.shared.apply("glasses", "connected", newValue) }
|
|
1618
1618
|
}
|
|
1619
1619
|
|
|
1620
|
+
private func clearPhysicalReadiness() {
|
|
1621
|
+
let readinessWasPending = connected && !fullyBooted
|
|
1622
|
+
fullyBooted = false
|
|
1623
|
+
if readinessWasPending {
|
|
1624
|
+
// Logical ASG readiness already emitted false without disconnecting.
|
|
1625
|
+
// The real disconnect must still perform the cleanup apply deduplicates.
|
|
1626
|
+
DeviceManager.shared.handleDeviceDisconnected()
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1620
1630
|
// Queue Management
|
|
1621
1631
|
private let commandQueue = CommandQueue()
|
|
1622
1632
|
private let bluetoothQueue = DispatchQueue(label: "MentraLiveBluetooth", qos: .userInitiated)
|
|
@@ -1744,8 +1754,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
1744
1754
|
// Publish disconnect immediately for Phone A UI. didDisconnectPeripheral may lag
|
|
1745
1755
|
// or race with cancelPeripheralConnection; do not wait on it for stand-down.
|
|
1746
1756
|
isConnecting = false
|
|
1757
|
+
clearPhysicalReadiness()
|
|
1747
1758
|
connected = false
|
|
1748
|
-
fullyBooted = false
|
|
1749
1759
|
glassesSessionId = nil
|
|
1750
1760
|
streamControlVersion = 0
|
|
1751
1761
|
readinessCompletedThisBleSession = false
|
|
@@ -2123,8 +2133,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2123
2133
|
/// Wire-session generation at enqueue time; the drain loop drops entries from a
|
|
2124
2134
|
/// previous session so a stale write (worst case: an old handshake whose reply
|
|
2125
2135
|
/// would activate v2 pre-negotiation) can never cross a session boundary. This is
|
|
2126
|
-
/// the deterministic guard
|
|
2127
|
-
/// cleanup that may land after a reconnect has already resumed the drain loop.
|
|
2136
|
+
/// the deterministic guard if a consumer already dequeued a previous-session write.
|
|
2128
2137
|
let generation: Int
|
|
2129
2138
|
}
|
|
2130
2139
|
|
|
@@ -2166,7 +2175,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2166
2175
|
Bridge.log("LIVE: Cleared pending ACK mId \(pendingMessage.id) because \(reason)")
|
|
2167
2176
|
}
|
|
2168
2177
|
|
|
2169
|
-
|
|
2178
|
+
@MainActor final class CommandQueue {
|
|
2170
2179
|
private var commands: [PendingMessage] = []
|
|
2171
2180
|
|
|
2172
2181
|
func enqueue(_ command: PendingMessage) -> Int {
|
|
@@ -2282,15 +2291,11 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2282
2291
|
)
|
|
2283
2292
|
|
|
2284
2293
|
// Push to front of queue for immediate retry
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
"queueSizeAfterAdd": queueSize,
|
|
2291
|
-
])
|
|
2292
|
-
}
|
|
2293
|
-
}
|
|
2294
|
+
let queueSize = commandQueue.pushToFront(retryMessage)
|
|
2295
|
+
logBleWriteTrace("retry_queued", retryMessage.trace, extra: [
|
|
2296
|
+
"retryDelayMs": 0,
|
|
2297
|
+
"queueSizeAfterAdd": queueSize,
|
|
2298
|
+
])
|
|
2294
2299
|
|
|
2295
2300
|
Bridge.log(
|
|
2296
2301
|
"🔄 Retrying message mId: \(pendingMessage.id) (attempt \(retryMessage.retries)/3)"
|
|
@@ -2528,8 +2533,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
2528
2533
|
Bridge.log("LIVE: Maximum reconnection attempts reached (\(MAX_RECONNECT_ATTEMPTS))")
|
|
2529
2534
|
reconnectAttempts = 0
|
|
2530
2535
|
updateConnectionState(ConnTypes.DISCONNECTED)
|
|
2536
|
+
clearPhysicalReadiness()
|
|
2531
2537
|
connected = false
|
|
2532
|
-
fullyBooted = false
|
|
2533
2538
|
glassesSessionId = nil
|
|
2534
2539
|
streamControlVersion = 0
|
|
2535
2540
|
readinessCompletedThisBleSession = false
|
|
@@ -3143,6 +3148,11 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3143
3148
|
if type.hasPrefix("version_info") {
|
|
3144
3149
|
Bridge.log("LIVE: Received \(type)")
|
|
3145
3150
|
|
|
3151
|
+
// A new process can report its build before glasses_ready resets the
|
|
3152
|
+
// wire epoch. Publish its pending readiness before those version fields
|
|
3153
|
+
// can complete OTA and trigger the next request.
|
|
3154
|
+
handleGlassesSessionId(json)
|
|
3155
|
+
|
|
3146
3156
|
// Extract all fields from JSON (except "type")
|
|
3147
3157
|
var fields: [String: Any] = [:]
|
|
3148
3158
|
fields["version_info_type"] = type
|
|
@@ -3215,7 +3225,6 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3215
3225
|
// running them per chunk is safe.
|
|
3216
3226
|
parsePeerWireCaps(json)
|
|
3217
3227
|
maybeSendWireHandshake()
|
|
3218
|
-
handleGlassesSessionId(json)
|
|
3219
3228
|
|
|
3220
3229
|
Bridge.sendVersionInfo(fields, responseChunk: type)
|
|
3221
3230
|
} else {
|
|
@@ -3714,7 +3723,19 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3714
3723
|
sendJson(json, wakeUp: true)
|
|
3715
3724
|
}
|
|
3716
3725
|
|
|
3717
|
-
|
|
3726
|
+
var incidentLogTransportReady: Bool {
|
|
3727
|
+
connectedPeripheral?.state == .connected && txCharacteristic != nil
|
|
3728
|
+
}
|
|
3729
|
+
|
|
3730
|
+
func sendIncidentId(_ incidentId: String, apiBaseUrl: String?) throws {
|
|
3731
|
+
guard incidentLogTransportReady else {
|
|
3732
|
+
throw BluetoothSdkError(code: "glasses_not_connected", message: "Cannot request incident logs because the glasses BLE link is not ready.")
|
|
3733
|
+
}
|
|
3734
|
+
// Token sync may happen after glasses_ready; never rely on the last pairing's token.
|
|
3735
|
+
let coreToken = DeviceStore.shared.get("bluetooth", "core_token") as? String ?? ""
|
|
3736
|
+
guard !coreToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
3737
|
+
throw BluetoothSdkError(code: "core_token_unavailable", message: "Cannot request incident logs without the current Core access token.")
|
|
3738
|
+
}
|
|
3718
3739
|
var base = (apiBaseUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
3719
3740
|
if base.isEmpty {
|
|
3720
3741
|
base = "https://api.mentra.glass"
|
|
@@ -3731,10 +3752,20 @@ class MentraLive: NSObject, SGCManager {
|
|
|
3731
3752
|
fileBaseKey: lKey, incidentId: incidentId, apiBaseUrl: base, kind: .logcat
|
|
3732
3753
|
)
|
|
3733
3754
|
|
|
3734
|
-
|
|
3735
|
-
"
|
|
3736
|
-
|
|
3737
|
-
|
|
3755
|
+
let auth: [String: Any] = [
|
|
3756
|
+
"type": "auth_token", "coreToken": coreToken,
|
|
3757
|
+
"timestamp": Int64(Date().timeIntervalSince1970 * 1000),
|
|
3758
|
+
]
|
|
3759
|
+
// MainActor queue insertion preserves auth-before-upload order, including all fragments.
|
|
3760
|
+
guard sendJson(auth, wakeUp: true, requireAck: true, bridgeLogging: true),
|
|
3761
|
+
sendJson(["type": "upload_incident_logs", "incidentId": incidentId, "apiBaseUrl": base],
|
|
3762
|
+
wakeUp: true, requireAck: true, bridgeLogging: true)
|
|
3763
|
+
else {
|
|
3764
|
+
bleIncidentLogRelays.removeValue(forKey: bKey)
|
|
3765
|
+
bleIncidentLogRelays.removeValue(forKey: lKey)
|
|
3766
|
+
throw BluetoothSdkError(code: "incident_dispatch_failed", message: "Could not queue the glasses incident-log request.")
|
|
3767
|
+
}
|
|
3768
|
+
Bridge.log("LIVE: Queued incidentId to glasses for log upload: \(incidentId) (BLE relay \(bKey), \(lKey))")
|
|
3738
3769
|
}
|
|
3739
3770
|
|
|
3740
3771
|
private static func incidentBleFileBase(incidentId: String, prefix: Character) -> String {
|
|
@@ -4671,20 +4702,13 @@ class MentraLive: NSObject, SGCManager {
|
|
|
4671
4702
|
|
|
4672
4703
|
func queueSend(_ data: Data, id: String, trace: BleWriteTrace?) {
|
|
4673
4704
|
let significantQueueSize = SIGNIFICANT_BLE_TRACE_QUEUE_SIZE
|
|
4674
|
-
//
|
|
4675
|
-
// session reset, and a pre-reset payload stamped with the new generation would
|
|
4676
|
-
// defeat the stale-write guard in the drain loop.
|
|
4705
|
+
// Insert synchronously on MainActor so fragments and dependent commands retain call order.
|
|
4677
4706
|
let generation = wireSessionGeneration
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
await MainActor.run {
|
|
4684
|
-
self.logBleChunkTrace("queued", trace, extra: [
|
|
4685
|
-
"queueSizeAfterAdd": queueSize,
|
|
4686
|
-
])
|
|
4687
|
-
}
|
|
4707
|
+
let queueSize = commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0, trace: trace, generation: generation))
|
|
4708
|
+
if trace != nil, queueSize >= significantQueueSize {
|
|
4709
|
+
logBleChunkTrace("queued", trace, extra: [
|
|
4710
|
+
"queueSizeAfterAdd": queueSize,
|
|
4711
|
+
])
|
|
4688
4712
|
}
|
|
4689
4713
|
}
|
|
4690
4714
|
|
|
@@ -5201,7 +5225,10 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5201
5225
|
bridgeLogging: bridgeLogging
|
|
5202
5226
|
)
|
|
5203
5227
|
}
|
|
5204
|
-
|
|
5228
|
+
// Credentials reach the wire unchanged; diagnostic logs omit their values.
|
|
5229
|
+
let loggedPayload = json["password"] == nil && json["coreToken"] == nil
|
|
5230
|
+
? jsonString : "<\(commandInfo.commandType) with credentials omitted>"
|
|
5231
|
+
transportLog("LIVE: Sending data to glasses: \(loggedPayload)", bridgeLogging: bridgeLogging)
|
|
5205
5232
|
let packedData =
|
|
5206
5233
|
packJson(
|
|
5207
5234
|
jsonString,
|
|
@@ -5724,7 +5751,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5724
5751
|
stopReadinessCheckLoop()
|
|
5725
5752
|
|
|
5726
5753
|
readinessCheckCounter = 0
|
|
5727
|
-
|
|
5754
|
+
clearPhysicalReadiness()
|
|
5728
5755
|
connected = false
|
|
5729
5756
|
glassesSessionId = nil
|
|
5730
5757
|
streamControlVersion = 0
|
|
@@ -5996,7 +6023,7 @@ class MentraLive: NSObject, SGCManager {
|
|
|
5996
6023
|
// Stop all timers
|
|
5997
6024
|
stopAllTimers()
|
|
5998
6025
|
resetWireNegotiationState()
|
|
5999
|
-
|
|
6026
|
+
commandQueue.removeAll() // stale writes die with the session
|
|
6000
6027
|
closeL2capFileChannel()
|
|
6001
6028
|
|
|
6002
6029
|
// Disconnect BLE
|
|
@@ -6005,8 +6032,8 @@ class MentraLive: NSObject, SGCManager {
|
|
|
6005
6032
|
centralManager?.cancelPeripheralConnection(peripheral)
|
|
6006
6033
|
}
|
|
6007
6034
|
|
|
6035
|
+
clearPhysicalReadiness()
|
|
6008
6036
|
DeviceStore.shared.apply("glasses", "connected", false)
|
|
6009
|
-
DeviceStore.shared.apply("glasses", "fullyBooted", false)
|
|
6010
6037
|
DeviceStore.shared.apply("glasses", "wifiConnected", false)
|
|
6011
6038
|
DeviceStore.shared.apply("glasses", "wifiSsid", "")
|
|
6012
6039
|
DeviceStore.shared.apply("glasses", "wifiLocalIp", "")
|
|
@@ -6143,8 +6170,8 @@ extension MentraLive {
|
|
|
6143
6170
|
/// pre-sid build) or differing from the recorded one = restart; same sid = no action.
|
|
6144
6171
|
/// A restart is a LOGICAL session reset: send phone_ready immediately (bypassing the
|
|
6145
6172
|
/// sr_hrt heartbeat's stale-readiness suppression); the returning glasses_ready runs
|
|
6146
|
-
/// the full existing remote-wire-reset flow.
|
|
6147
|
-
///
|
|
6173
|
+
/// the full existing remote-wire-reset flow. Keep the physical connection intact,
|
|
6174
|
+
/// but mark readiness pending until that new wire epoch has been established.
|
|
6148
6175
|
private func handleGlassesSessionId(_ json: [String: Any]) {
|
|
6149
6176
|
guard let sid = json["sid"] as? String, !sid.isEmpty else { return }
|
|
6150
6177
|
let previous = glassesSessionId
|
|
@@ -6159,6 +6186,9 @@ extension MentraLive {
|
|
|
6159
6186
|
"LIVE: 🔁 Glasses session changed (\(previous ?? "<pre-sid build>") -> \(sid)) - " +
|
|
6160
6187
|
"asg restarted under a live link, re-running readiness"
|
|
6161
6188
|
)
|
|
6189
|
+
// Emit the existing connected-but-not-ready status without invoking the
|
|
6190
|
+
// physical-disconnect side effects of DeviceStore.apply(fullyBooted: false).
|
|
6191
|
+
DeviceStore.shared.set("glasses", "fullyBooted", false)
|
|
6162
6192
|
sendPhoneReady(reason: "glasses session changed")
|
|
6163
6193
|
Bridge.sendTypedMessage(
|
|
6164
6194
|
"glasses_session_changed",
|