@mentra/bluetooth-sdk 3.2.1-dev.314 → 3.3.0-beta.942

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/README.md +72 -10
  2. package/android/build.gradle +15 -0
  3. package/android/lc3Lib/build.gradle +6 -1
  4. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +368 -0
  5. package/android/src/debug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocol.kt +156 -0
  6. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalytics.kt +63 -14
  7. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTracker.kt +55 -1
  8. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkDefaults.kt +4 -1
  9. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkInstallId.kt +23 -0
  10. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +8 -2
  11. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheck.kt +184 -0
  12. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTracker.kt +388 -0
  13. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +2 -1
  14. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +133 -26
  15. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +37 -13
  16. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedChangelogCatalog.kt +3 -2
  17. package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +6 -6
  18. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +227 -36
  19. package/android/src/main/java/com/mentra/bluetoothsdk/MentraLocalNetworkModule.kt +24 -0
  20. package/android/src/main/java/com/mentra/bluetoothsdk/NetworkDiagnostics.kt +25 -0
  21. package/android/src/main/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulator.kt +13 -2
  22. package/android/src/main/java/com/mentra/bluetoothsdk/debug/BleTraceLogger.kt +3 -0
  23. package/android/src/main/java/com/mentra/bluetoothsdk/requests/DisplayRequests.kt +1 -1
  24. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +33 -0
  25. package/android/src/main/java/com/mentra/bluetoothsdk/services/G2ConnectionRecovery.kt +47 -0
  26. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioConnectionTracker.kt +4 -0
  27. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscovery.kt +70 -0
  28. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.kt +2 -2
  29. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizer.kt +11 -32
  30. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +72 -21
  31. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/LatinTextSanitizer.kt +39 -0
  32. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.kt +194 -40
  33. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +12 -8
  34. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Nimo.kt +314 -395
  35. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodec.kt +247 -0
  36. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinator.kt +238 -0
  37. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasEncoder.kt +58 -0
  38. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSession.kt +180 -0
  39. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +11 -0
  40. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddress.kt +17 -0
  41. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SerializedGattCallback.kt +5 -0
  42. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G2Text.kt +7 -0
  43. package/android/src/release/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnostics.kt +20 -0
  44. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkAnalyticsTrackerTest.kt +58 -3
  45. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUpdateCheckTest.kt +124 -0
  46. package/android/src/test/java/com/mentra/bluetoothsdk/BluetoothSdkUsageTrackerTest.kt +263 -0
  47. package/android/src/test/java/com/mentra/bluetoothsdk/DashboardContentFormatterTest.kt +2 -2
  48. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceIdentityPairingTest.kt +59 -0
  49. package/android/src/test/java/com/mentra/bluetoothsdk/DeviceManagerSceneHandoffTest.kt +666 -0
  50. package/android/src/test/java/com/mentra/bluetoothsdk/G2TextTest.kt +13 -0
  51. package/android/src/test/java/com/mentra/bluetoothsdk/NetworkDiagnosticsTest.kt +55 -0
  52. package/android/src/test/java/com/mentra/bluetoothsdk/ScanCompletionTest.kt +5 -1
  53. package/android/src/test/java/com/mentra/bluetoothsdk/VersionInfoResponseAccumulatorTest.kt +71 -0
  54. package/android/src/test/java/com/mentra/bluetoothsdk/debug/BleTraceLoggerTest.kt +69 -0
  55. package/android/src/test/java/com/mentra/bluetoothsdk/services/G2ConnectionRecoveryTest.kt +51 -0
  56. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/ClassicAudioServiceDiscoveryTest.kt +124 -0
  57. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1PairingTest.kt +105 -0
  58. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +29 -4
  59. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2DisplayThreadTest.kt +120 -0
  60. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/G2PairingTargetTest.kt +90 -0
  61. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveClassicDisconnectTest.kt +133 -0
  62. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveFileTransferSessionTest.kt +55 -0
  63. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveGattCallbackTest.kt +33 -0
  64. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveIncidentDispatchTest.kt +126 -0
  65. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLivePairingTargetTest.kt +65 -0
  66. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraLiveWifiCredentialLogTest.kt +55 -0
  67. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/MentraNexPairingTest.kt +151 -0
  68. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoBitmapAcceptanceTest.kt +105 -0
  69. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCodecTest.kt +233 -0
  70. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasCoordinatorTest.kt +348 -0
  71. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoCanvasSessionTest.kt +339 -0
  72. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoMicReconnectTest.kt +93 -0
  73. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/NimoProtocolTest.kt +11 -0
  74. package/android/src/test/java/com/mentra/bluetoothsdk/sgcs/SelectedDeviceAddressTest.kt +27 -0
  75. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoDiagnosticsTest.kt +287 -0
  76. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoFramebufferProtocolTest.kt +127 -0
  77. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoLifecycleTest.kt +288 -0
  78. package/android/src/testDebug/java/com/mentra/bluetoothsdk/sgcs/NimoReadinessDriverTest.kt +205 -0
  79. package/build/BluetoothSdk.types.d.ts +16 -0
  80. package/build/BluetoothSdk.types.d.ts.map +1 -1
  81. package/build/BluetoothSdk.types.js +1 -0
  82. package/build/BluetoothSdk.types.js.map +1 -1
  83. package/build/_private/BluetoothSdkModule.d.ts +4 -0
  84. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  85. package/build/_private/BluetoothSdkModule.js +5 -1
  86. package/build/_private/BluetoothSdkModule.js.map +1 -1
  87. package/build/_private/MentraLocalNetworkModule.d.ts +23 -0
  88. package/build/_private/MentraLocalNetworkModule.d.ts.map +1 -1
  89. package/build/_private/MentraLocalNetworkModule.js.map +1 -1
  90. package/build/_private/nativeLogConsole.d.ts +19 -0
  91. package/build/_private/nativeLogConsole.d.ts.map +1 -1
  92. package/build/_private/nativeLogConsole.js +12 -0
  93. package/build/_private/nativeLogConsole.js.map +1 -1
  94. package/build/generated/changelogCatalog.d.ts +5 -2
  95. package/build/generated/changelogCatalog.d.ts.map +1 -1
  96. package/build/generated/changelogCatalog.js +6 -2
  97. package/build/generated/changelogCatalog.js.map +1 -1
  98. package/build/generated/releaseMetadata.js +6 -6
  99. package/build/generated/releaseMetadata.js.map +1 -1
  100. package/build/index.d.ts.map +1 -1
  101. package/build/index.js +1 -0
  102. package/build/index.js.map +1 -1
  103. package/build/ota-transport/index.d.ts +4 -0
  104. package/build/ota-transport/index.d.ts.map +1 -1
  105. package/build/ota-transport/index.js +2 -0
  106. package/build/ota-transport/index.js.map +1 -1
  107. package/ios/BluetoothSdkModule.swift +17 -4
  108. package/ios/MentraBluetoothSDK.podspec +3 -2
  109. package/ios/Source/BluetoothBackgroundMode.swift +32 -0
  110. package/ios/Source/BluetoothSdkDefaults.swift +7 -2
  111. package/ios/Source/Bridge.swift +15 -2
  112. package/ios/Source/DeviceManager.swift +102 -66
  113. package/ios/Source/DeviceStore.swift +26 -7
  114. package/ios/Source/GeneratedChangelogCatalog.swift +3 -2
  115. package/ios/Source/GeneratedReleaseMetadata.swift +6 -6
  116. package/ios/Source/MentraBluetoothSDK.swift +313 -54
  117. package/ios/Source/audio/GlassesMicWatchdog.swift +33 -0
  118. package/ios/Source/internal/BluetoothAvailability.swift +6 -0
  119. package/ios/Source/internal/BluetoothSdkAnalytics.swift +87 -13
  120. package/ios/Source/internal/BluetoothSdkAnalyticsTracker.swift +54 -1
  121. package/ios/Source/internal/BluetoothSdkInstallId.swift +21 -0
  122. package/ios/Source/internal/BluetoothSdkUpdateCheck.swift +223 -0
  123. package/ios/Source/internal/BluetoothSdkUsageTracker.swift +428 -0
  124. package/ios/Source/requests/DisplayRequests.swift +1 -1
  125. package/ios/Source/requests/VersionInfoResponseAccumulator.swift +11 -1
  126. package/ios/Source/sgcs/Ar99.swift +1 -2
  127. package/ios/Source/sgcs/G1.swift +68 -38
  128. package/ios/Source/sgcs/G1ConnectionTarget.swift +13 -0
  129. package/ios/Source/sgcs/G2.swift +261 -247
  130. package/ios/Source/sgcs/G2ConnectionSession.swift +101 -0
  131. package/ios/Source/sgcs/Mach1.swift +19 -7
  132. package/ios/Source/sgcs/MentraLive.swift +272 -197
  133. package/ios/Source/sgcs/MentraLiveFileTransferSession.swift +137 -0
  134. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +47 -0
  135. package/ios/Source/sgcs/MentraNex.swift +8 -31
  136. package/ios/Source/sgcs/NexConnectionTarget.swift +15 -0
  137. package/ios/Source/sgcs/Nimo.swift +280 -408
  138. package/ios/Source/sgcs/NimoCanvasCodec.swift +272 -0
  139. package/ios/Source/sgcs/NimoCanvasCoordinator.swift +233 -0
  140. package/ios/Source/sgcs/NimoCanvasImage.swift +53 -0
  141. package/ios/Source/sgcs/NimoCanvasSession.swift +166 -0
  142. package/ios/Source/sgcs/NimoDiscovery.swift +49 -0
  143. package/ios/Source/sgcs/SGCManager.swift +25 -1
  144. package/ios/Source/sgcs/VuzixConnectionTarget.swift +15 -0
  145. package/ios/Source/status/DeviceStatus.swift +1 -0
  146. package/ios/Source/stt/SherpaOnnxTranscriber.swift +49 -189
  147. package/ios/Source/utils/AudioSessionMonitor.swift +44 -8
  148. package/ios/Source/utils/DemandDrivenAudioWorker.swift +150 -0
  149. package/ios/Source/utils/G1Text.swift +2 -24
  150. package/ios/Source/utils/G1TextSanitizer.swift +27 -0
  151. package/ios/Source/utils/G2Text.swift +11 -0
  152. package/ios/Source/utils/LatinTextSanitizer.swift +48 -0
  153. package/ios/Tests/BluetoothAudioRouteTests.swift +26 -0
  154. package/ios/Tests/BluetoothBackgroundModeTests.swift +32 -0
  155. package/ios/Tests/BluetoothSdkAnalyticsTrackerTests.swift +54 -3
  156. package/ios/Tests/BluetoothSdkUpdateCheckTests.swift +153 -0
  157. package/ios/Tests/BluetoothSdkUsageTrackerTests.swift +296 -0
  158. package/ios/Tests/DashboardContentFormatterTests.swift +2 -2
  159. package/ios/Tests/DashboardRenderingTests.swift +31 -0
  160. package/ios/Tests/DemandDrivenAudioWorkerTests.swift +157 -0
  161. package/ios/Tests/DeviceIdentityPairingTests.swift +49 -0
  162. package/ios/Tests/DeviceManagerReconnectTests.swift +362 -0
  163. package/ios/Tests/G1PairingTests.swift +162 -0
  164. package/ios/Tests/G1TextSanitizerTests.swift +32 -0
  165. package/ios/Tests/G2ConnectionSessionTests.swift +172 -0
  166. package/ios/Tests/G2TextTests.swift +10 -0
  167. package/ios/Tests/IncidentLogDispatchTests.swift +111 -0
  168. package/ios/Tests/MentraLiveFileTransferSessionTests.swift +47 -0
  169. package/ios/Tests/MentraLiveReconnectPolicyTests.swift +58 -0
  170. package/ios/Tests/MentraLiveSessionReadinessTests.swift +163 -0
  171. package/ios/Tests/NimoCanvasCodecTests.swift +217 -0
  172. package/ios/Tests/NimoCanvasSessionTests.swift +181 -0
  173. package/ios/Tests/NimoCanvasTransportTests.swift +186 -0
  174. package/ios/Tests/NimoDeviceCommandsTests.swift +16 -0
  175. package/ios/Tests/NimoDiscoveryTests.swift +79 -0
  176. package/ios/Tests/SGCConnectionTargetTests.swift +48 -0
  177. package/ios/Tests/VersionInfoCommandTests.swift +194 -0
  178. package/ios/Tests/VersionInfoResponseAccumulatorTests.swift +65 -0
  179. package/ios/Tests/VideoRecordingCommandTests.swift +81 -0
  180. package/ios/Tests/WifiCredentialLoggingTests.swift +42 -0
  181. package/package.json +2 -2
  182. package/plugin/build/withIos.d.ts +2 -0
  183. package/plugin/build/withIos.js +12 -1
  184. package/src/BluetoothSdk.types.ts +15 -0
  185. package/src/_private/BluetoothSdkModule.ts +10 -1
  186. package/src/_private/MentraLocalNetworkModule.ts +28 -0
  187. package/src/_private/nativeLogConsole.ts +19 -0
  188. package/src/generated/changelogCatalog.ts +6 -2
  189. package/src/generated/releaseMetadata.ts +6 -6
  190. package/src/index.ts +1 -0
  191. 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
+ }
@@ -1,7 +1,7 @@
1
1
  import Foundation
2
2
 
3
3
  enum DashboardContentFormatter {
4
- static let statusHeader = "$TIME12$ $DATE$ $GBATT$ $CONNECTION_STATUS$"
4
+ static let statusHeader = "$TIME12$ $DATE$ $GBATT$"
5
5
 
6
6
  static func template(for content: String) -> String {
7
7
  content.isEmpty ? statusHeader : statusHeader + "\n\n" + content
@@ -7,7 +7,7 @@ enum VersionInfoAccumulatorOutcome {
7
7
  case complete(VersionInfoResult)
8
8
  }
9
9
 
10
- /// One request, explicit modern completion, or the deployed legacy chunk-3 terminal boundary.
10
+ /// One request, explicit modern completion, or the legacy firmware's terminal chunk.
11
11
  final class VersionInfoResponseAccumulator {
12
12
  static let responseChunkKey = "_responseChunk"
13
13
  static let responseRequestIdKey = "_responseRequestId"
@@ -23,6 +23,7 @@ final class VersionInfoResponseAccumulator {
23
23
  private var count: Int?
24
24
  private var sid: String?
25
25
  private var legacyStarted = false
26
+ private var legacyTwoChunkResponse = false
26
27
  private var completed = false
27
28
 
28
29
  init(expectedRequestId: String) {
@@ -63,10 +64,19 @@ final class VersionInfoResponseAccumulator {
63
64
  case "version_info_1":
64
65
  values.removeAll()
65
66
  legacyStarted = true
67
+ legacyTwoChunkResponse = event["buildNumber"] as? String == "27"
66
68
  merge(event)
67
69
  case "version_info_2":
68
70
  guard legacyStarted else { return .ignored }
71
+ // Factory ASG27 sends only chunks 1 and 2. Its second chunk carries the
72
+ // OTA URL and legacy firmware version; ASG31+ moves firmware to chunk 3.
69
73
  merge(event)
74
+ if legacyTwoChunkResponse, values["buildNumber"] as? String == "27",
75
+ let otaUrl = event["otaVersionUrl"] as? String,
76
+ !otaUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
77
+ {
78
+ return finish()
79
+ }
70
80
  case "version_info_3":
71
81
  guard legacyStarted else { return .ignored }
72
82
  merge(event)
@@ -1850,8 +1850,7 @@ final class Ar99: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SGCM
1850
1850
  private func ensureOpusDecoder() {
1851
1851
  guard opusDecoder == nil else { return }
1852
1852
  opusDecoder = Ar99OpusPcmDecoder { pcm in
1853
- DeviceManager.shared.reportGlassesAudioActivity()
1854
- DeviceManager.shared.handlePcm(pcm)
1853
+ DeviceManager.shared.handleGlassesPcm(pcm)
1855
1854
  }
1856
1855
  if opusDecoder == nil {
1857
1856
  Bridge.log("AR99: system Opus decoder unavailable")