@glassly/bluetooth-sdk 0.1.0-dev.26

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 (548) hide show
  1. package/README.md +563 -0
  2. package/android/build.gradle +450 -0
  3. package/android/gradle.properties.example +9 -0
  4. package/android/lc3Lib/CMakeLists.txt +6 -0
  5. package/android/lc3Lib/build.gradle +212 -0
  6. package/android/lc3Lib/proguard-rules.pro +33 -0
  7. package/android/lc3Lib/src/main/AndroidManifest.xml +2 -0
  8. package/android/lc3Lib/src/main/cpp/CMakeLists.txt +8 -0
  9. package/android/lc3Lib/src/main/cpp/google_opus_stuff/CMakeLists.txt +30 -0
  10. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/CMakeLists.txt +7 -0
  11. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/ogg_opus_encoder.cc +96 -0
  12. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/ogg_opus_encoder.h +53 -0
  13. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/Makefile.am +6 -0
  14. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/config_types.h +26 -0
  15. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/config_types.h.in +26 -0
  16. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/ogg.h +209 -0
  17. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/os_types.h +158 -0
  18. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus.h +981 -0
  19. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_custom.h +342 -0
  20. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_defines.h +799 -0
  21. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_multistream.h +660 -0
  22. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_projection.h +568 -0
  23. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_types.h +166 -0
  24. package/android/lc3Lib/src/main/cpp/google_opus_stuff/ogg_opus_encoder.cc +268 -0
  25. package/android/lc3Lib/src/main/cpp/google_opus_stuff/ogg_opus_encoder.h +115 -0
  26. package/android/lc3Lib/src/main/cpp/google_opus_stuff/opus_tools/opus_header.h +59 -0
  27. package/android/lc3Lib/src/main/cpp/liblc3/CMakeLists.txt +38 -0
  28. package/android/lc3Lib/src/main/cpp/liblc3/include/lc3.h +309 -0
  29. package/android/lc3Lib/src/main/cpp/liblc3/include/lc3_private.h +162 -0
  30. package/android/lc3Lib/src/main/cpp/liblc3/include/rnnoise.h +114 -0
  31. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/CMakeLists.txt +19 -0
  32. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/attdet.c +92 -0
  33. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/attdet.h +44 -0
  34. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bits.c +375 -0
  35. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bits.h +315 -0
  36. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bwdet.c +129 -0
  37. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bwdet.h +69 -0
  38. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/common.h +148 -0
  39. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/energy.c +70 -0
  40. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/energy.h +43 -0
  41. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/fastmath.h +158 -0
  42. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/lc3.c +702 -0
  43. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf.c +893 -0
  44. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf.h +111 -0
  45. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf_arm.h +506 -0
  46. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf_neon.h +281 -0
  47. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/makefile.mk +35 -0
  48. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct.c +452 -0
  49. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct.h +57 -0
  50. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct_neon.h +296 -0
  51. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/meson.build +46 -0
  52. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/plc.c +61 -0
  53. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/plc.h +57 -0
  54. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/sns.c +880 -0
  55. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/sns.h +103 -0
  56. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/spec.c +904 -0
  57. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/spec.h +119 -0
  58. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tables.c +3457 -0
  59. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tables.h +94 -0
  60. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tns.c +457 -0
  61. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tns.h +99 -0
  62. package/android/lc3Lib/src/main/cpp/liblc3/liblc3.cpp +159 -0
  63. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/_kiss_fft_guts.h +182 -0
  64. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/arch.h +261 -0
  65. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/celt_lpc.c +279 -0
  66. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/celt_lpc.h +59 -0
  67. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/common.h +48 -0
  68. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/compile.sh +3 -0
  69. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/denoise.c +646 -0
  70. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/kiss_fft.c +601 -0
  71. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/kiss_fft.h +203 -0
  72. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/opus_types.h +159 -0
  73. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/pitch.c +526 -0
  74. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/pitch.h +149 -0
  75. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn.c +178 -0
  76. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn.h +69 -0
  77. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_data.c +11051 -0
  78. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_data.h +34 -0
  79. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_reader.c +168 -0
  80. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_train.py +66 -0
  81. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/tansig_table.h +45 -0
  82. package/android/lc3Lib/src/main/java/com/glassly/lc3Lib/Lc3Cpp.java +38 -0
  83. package/android/lc3Lib/third_party/CMakeLists.txt +23 -0
  84. package/android/lc3Lib/third_party/CMakeLists_libogg.txt +18 -0
  85. package/android/lc3Lib/third_party/CMakeLists_libopus.txt +18 -0
  86. package/android/lc3Lib/third_party/CMakeLists_opus-tools.txt +23 -0
  87. package/android/lc3Lib/third_party/opus_tools/src/AUTHORS +5 -0
  88. package/android/lc3Lib/third_party/opus_tools/src/CMakeLists.txt +4 -0
  89. package/android/lc3Lib/third_party/opus_tools/src/COPYING +371 -0
  90. package/android/lc3Lib/third_party/opus_tools/src/ChangeLog +3 -0
  91. package/android/lc3Lib/third_party/opus_tools/src/Makefile.am +42 -0
  92. package/android/lc3Lib/third_party/opus_tools/src/Makefile.unix +23 -0
  93. package/android/lc3Lib/third_party/opus_tools/src/NEWS +0 -0
  94. package/android/lc3Lib/third_party/opus_tools/src/README +0 -0
  95. package/android/lc3Lib/third_party/opus_tools/src/autogen.sh +115 -0
  96. package/android/lc3Lib/third_party/opus_tools/src/configure.ac +293 -0
  97. package/android/lc3Lib/third_party/opus_tools/src/src/arch.h +239 -0
  98. package/android/lc3Lib/third_party/opus_tools/src/src/audio-in.c +1046 -0
  99. package/android/lc3Lib/third_party/opus_tools/src/src/diag_range.c +245 -0
  100. package/android/lc3Lib/third_party/opus_tools/src/src/diag_range.h +28 -0
  101. package/android/lc3Lib/third_party/opus_tools/src/src/info_opus.c +320 -0
  102. package/android/lc3Lib/third_party/opus_tools/src/src/info_opus.h +51 -0
  103. package/android/lc3Lib/third_party/opus_tools/src/src/lpc.c +157 -0
  104. package/android/lc3Lib/third_party/opus_tools/src/src/lpc.h +27 -0
  105. package/android/lc3Lib/third_party/opus_tools/src/src/opus_header.c +286 -0
  106. package/android/lc3Lib/third_party/opus_tools/src/src/opus_header.h +59 -0
  107. package/android/lc3Lib/third_party/opus_tools/src/src/opusdec.c +884 -0
  108. package/android/lc3Lib/third_party/opus_tools/src/src/opusenc.c +1021 -0
  109. package/android/lc3Lib/third_party/opus_tools/src/src/opusenc.h +101 -0
  110. package/android/lc3Lib/third_party/opus_tools/src/src/opusinfo.c +639 -0
  111. package/android/lc3Lib/third_party/opus_tools/src/src/opusinfo.h +51 -0
  112. package/android/lc3Lib/third_party/opus_tools/src/src/os_support.h +167 -0
  113. package/android/lc3Lib/third_party/opus_tools/src/src/resample.c +1137 -0
  114. package/android/lc3Lib/third_party/opus_tools/src/src/speex_resampler.h +344 -0
  115. package/android/lc3Lib/third_party/opus_tools/src/src/stack_alloc.h +115 -0
  116. package/android/lc3Lib/third_party/opus_tools/src/src/wav_io.c +125 -0
  117. package/android/lc3Lib/third_party/opus_tools/src/src/wav_io.h +62 -0
  118. package/android/lc3Lib/third_party/opus_tools/src/src/wave_out.c +223 -0
  119. package/android/lc3Lib/third_party/opus_tools/src/src/wave_out.h +60 -0
  120. package/android/settings.gradle +4 -0
  121. package/android/silero/LICENSE.md +17 -0
  122. package/android/silero/build.gradle +50 -0
  123. package/android/silero/proguard-rules.pro +24 -0
  124. package/android/silero/src/androidTest/assets/hello.wav +0 -0
  125. package/android/silero/src/androidTest/java/com/konovalov/vad/silero/VadSileroTest.kt +79 -0
  126. package/android/silero/src/main/assets/LICENSE +21 -0
  127. package/android/silero/src/main/assets/silero_vad.onnx +0 -0
  128. package/android/silero/src/main/java/com/konovalov/vad/silero/Vad.kt +160 -0
  129. package/android/silero/src/main/java/com/konovalov/vad/silero/VadSilero.kt +449 -0
  130. package/android/silero/src/main/java/com/konovalov/vad/silero/config/FrameSize.kt +15 -0
  131. package/android/silero/src/main/java/com/konovalov/vad/silero/config/Mode.kt +14 -0
  132. package/android/silero/src/main/java/com/konovalov/vad/silero/config/SampleRate.kt +12 -0
  133. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/AudioUtils.kt +47 -0
  134. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/TensorMap.kt +41 -0
  135. package/android/silero/src/test/java/com/konovalov/vad/silero/utils/AudioUtilsTest.kt +44 -0
  136. package/android/src/main/AndroidManifest.xml +42 -0
  137. package/android/src/main/assets/G2Firmware/README.md +11 -0
  138. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +2325 -0
  139. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkAnalytics.kt +216 -0
  140. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkDebug.kt +14 -0
  141. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkDefaults.kt +7 -0
  142. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +1088 -0
  143. package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +953 -0
  144. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +2528 -0
  145. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +360 -0
  146. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +8 -0
  147. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
  148. package/android/src/main/java/com/glassly/bluetoothsdk/LocalNetworkModule.kt +414 -0
  149. package/android/src/main/java/com/glassly/bluetoothsdk/ObservableStore.kt +88 -0
  150. package/android/src/main/java/com/glassly/bluetoothsdk/OtaManifest.kt +191 -0
  151. package/android/src/main/java/com/glassly/bluetoothsdk/ReleaseChangelog.kt +61 -0
  152. package/android/src/main/java/com/glassly/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  153. package/android/src/main/java/com/glassly/bluetoothsdk/audio/AudioModels.kt +144 -0
  154. package/android/src/main/java/com/glassly/bluetoothsdk/camera/CameraModels.kt +486 -0
  155. package/android/src/main/java/com/glassly/bluetoothsdk/connection/ConnectionModels.kt +78 -0
  156. package/android/src/main/java/com/glassly/bluetoothsdk/controllers/ControllerManager.kt +158 -0
  157. package/android/src/main/java/com/glassly/bluetoothsdk/controllers/R1.kt +937 -0
  158. package/android/src/main/java/com/glassly/bluetoothsdk/debug/BleTraceLogger.kt +165 -0
  159. package/android/src/main/java/com/glassly/bluetoothsdk/events/BluetoothEvents.kt +178 -0
  160. package/android/src/main/java/com/glassly/bluetoothsdk/internal/MapParsing.kt +140 -0
  161. package/android/src/main/java/com/glassly/bluetoothsdk/net/LocalIpv4.kt +57 -0
  162. package/android/src/main/java/com/glassly/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  163. package/android/src/main/java/com/glassly/bluetoothsdk/otaserver/OtaServerModule.kt +120 -0
  164. package/android/src/main/java/com/glassly/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistry.java +108 -0
  165. package/android/src/main/java/com/glassly/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +622 -0
  166. package/android/src/main/java/com/glassly/bluetoothsdk/photoreceiver/PhotoReceiverModule.kt +156 -0
  167. package/android/src/main/java/com/glassly/bluetoothsdk/requests/DisplayRequests.kt +56 -0
  168. package/android/src/main/java/com/glassly/bluetoothsdk/services/Foreground.kt +272 -0
  169. package/android/src/main/java/com/glassly/bluetoothsdk/services/PhoneMic.kt +1195 -0
  170. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/Ar99.java +2653 -0
  171. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/BesOtaHeartbeatGuard.kt +49 -0
  172. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
  173. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G1.kt +3878 -0
  174. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  175. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +6428 -0
  176. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +49 -0
  177. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +1241 -0
  178. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +270 -0
  179. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +291 -0
  180. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +411 -0
  181. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/GlasslyBle.java +43189 -0
  182. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/Mach1.kt +1280 -0
  183. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +10817 -0
  184. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  185. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  186. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +2011 -0
  187. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/Nimo.kt +2532 -0
  188. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +470 -0
  189. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/Simulated.kt +228 -0
  190. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
  191. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
  192. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
  193. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
  194. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
  195. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
  196. package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +525 -0
  197. package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +669 -0
  198. package/android/src/main/java/com/glassly/bluetoothsdk/status/RuntimeState.kt +201 -0
  199. package/android/src/main/java/com/glassly/bluetoothsdk/status/WifiHotspotStatus.kt +189 -0
  200. package/android/src/main/java/com/glassly/bluetoothsdk/streaming/StreamModels.kt +518 -0
  201. package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +391 -0
  202. package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +518 -0
  203. package/android/src/main/java/com/glassly/bluetoothsdk/stt/VadGateSpeechPolicy.kt +229 -0
  204. package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +248 -0
  205. package/android/src/main/java/com/glassly/bluetoothsdk/types/DeviceModels.kt +96 -0
  206. package/android/src/main/java/com/glassly/bluetoothsdk/utils/AES.java +106 -0
  207. package/android/src/main/java/com/glassly/bluetoothsdk/utils/AudioSessionMonitor.kt +189 -0
  208. package/android/src/main/java/com/glassly/bluetoothsdk/utils/AvifExifStripper.java +573 -0
  209. package/android/src/main/java/com/glassly/bluetoothsdk/utils/BitmapJavaUtils.java +169 -0
  210. package/android/src/main/java/com/glassly/bluetoothsdk/utils/BleJsonCompact.java +496 -0
  211. package/android/src/main/java/com/glassly/bluetoothsdk/utils/BlePhotoUploadService.java +677 -0
  212. package/android/src/main/java/com/glassly/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  213. package/android/src/main/java/com/glassly/bluetoothsdk/utils/Constants.kt +46 -0
  214. package/android/src/main/java/com/glassly/bluetoothsdk/utils/G1Text.kt +436 -0
  215. package/android/src/main/java/com/glassly/bluetoothsdk/utils/HeifExifTagReader.java +199 -0
  216. package/android/src/main/java/com/glassly/bluetoothsdk/utils/IncidentLogBleRelayNaming.java +29 -0
  217. package/android/src/main/java/com/glassly/bluetoothsdk/utils/IncidentLogBleUploadService.java +77 -0
  218. package/android/src/main/java/com/glassly/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  219. package/android/src/main/java/com/glassly/bluetoothsdk/utils/K900ProtocolUtils.java +827 -0
  220. package/android/src/main/java/com/glassly/bluetoothsdk/utils/MessageChunkReassembler.java +228 -0
  221. package/android/src/main/java/com/glassly/bluetoothsdk/utils/MessageChunker.java +325 -0
  222. package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +698 -0
  223. package/android/src/main/java/com/glassly/bluetoothsdk/utils/PhoneAudioMonitor.kt +348 -0
  224. package/android/src/main/java/com/glassly/bluetoothsdk/utils/SmartGlassesConnectionState.java +9 -0
  225. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
  226. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/ByteUtilAudioPlayer.java +655 -0
  227. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/Lc3Player.java +441 -0
  228. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/PCMAudioPlayer.java +431 -0
  229. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  230. package/android/src/main/res/values/strings.xml +4 -0
  231. package/android/src/test/java/com/glassly/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  232. package/android/src/test/java/com/glassly/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
  233. package/android/src/test/java/com/glassly/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  234. package/android/src/test/java/com/glassly/bluetoothsdk/ReleaseChangelogTest.kt +13 -0
  235. package/android/src/test/java/com/glassly/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  236. package/android/src/test/java/com/glassly/bluetoothsdk/StreamStatusEventTest.kt +29 -0
  237. package/android/src/test/java/com/glassly/bluetoothsdk/camera/PhotoRequestTest.kt +141 -0
  238. package/android/src/test/java/com/glassly/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  239. package/android/src/test/java/com/glassly/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistryTest.java +75 -0
  240. package/android/src/test/java/com/glassly/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  241. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
  242. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
  243. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
  244. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/EvenRleTest.kt +63 -0
  245. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  246. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +89 -0
  247. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +344 -0
  248. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +200 -0
  249. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocolTest.kt +131 -0
  250. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  251. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +133 -0
  252. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
  253. package/android/src/test/java/com/glassly/bluetoothsdk/utils/AvifExifStripperTest.java +46 -0
  254. package/android/src/test/java/com/glassly/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  255. package/android/src/test/java/com/glassly/bluetoothsdk/utils/BleJsonCompactTest.java +319 -0
  256. package/android/src/test/java/com/glassly/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +107 -0
  257. package/android/src/test/java/com/glassly/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  258. package/android/src/test/java/com/glassly/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  259. package/android/src/test/java/com/glassly/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
  260. package/android/src/test/resources/avif_with_exif.avif +0 -0
  261. package/app.plugin.js +3 -0
  262. package/build/BluetoothSdk.types.d.ts +1354 -0
  263. package/build/BluetoothSdk.types.d.ts.map +1 -0
  264. package/build/BluetoothSdk.types.js +41 -0
  265. package/build/BluetoothSdk.types.js.map +1 -0
  266. package/build/_internal.d.ts +17 -0
  267. package/build/_internal.d.ts.map +1 -0
  268. package/build/_internal.js +16 -0
  269. package/build/_internal.js.map +1 -0
  270. package/build/_private/BluetoothSdkModule.d.ts +172 -0
  271. package/build/_private/BluetoothSdkModule.d.ts.map +1 -0
  272. package/build/_private/BluetoothSdkModule.js +300 -0
  273. package/build/_private/BluetoothSdkModule.js.map +1 -0
  274. package/build/_private/LocalNetworkModule.d.ts +38 -0
  275. package/build/_private/LocalNetworkModule.d.ts.map +1 -0
  276. package/build/_private/LocalNetworkModule.js +3 -0
  277. package/build/_private/LocalNetworkModule.js.map +1 -0
  278. package/build/_private/cameraRequestPayload.d.ts +4 -0
  279. package/build/_private/cameraRequestPayload.d.ts.map +1 -0
  280. package/build/_private/cameraRequestPayload.js +32 -0
  281. package/build/_private/cameraRequestPayload.js.map +1 -0
  282. package/build/_private/photoRequestPayload.d.ts +6 -0
  283. package/build/_private/photoRequestPayload.d.ts.map +1 -0
  284. package/build/_private/photoRequestPayload.js +86 -0
  285. package/build/_private/photoRequestPayload.js.map +1 -0
  286. package/build/_private/sdkOtaManifest.d.ts +11 -0
  287. package/build/_private/sdkOtaManifest.d.ts.map +1 -0
  288. package/build/_private/sdkOtaManifest.js +15 -0
  289. package/build/_private/sdkOtaManifest.js.map +1 -0
  290. package/build/changelogs.d.ts +7 -0
  291. package/build/changelogs.d.ts.map +1 -0
  292. package/build/changelogs.js +46 -0
  293. package/build/changelogs.js.map +1 -0
  294. package/build/debug.d.ts +3 -0
  295. package/build/debug.d.ts.map +1 -0
  296. package/build/debug.js +8 -0
  297. package/build/debug.js.map +1 -0
  298. package/build/generated/changelogCatalog.d.ts +9 -0
  299. package/build/generated/changelogCatalog.d.ts.map +1 -0
  300. package/build/generated/changelogCatalog.js +12 -0
  301. package/build/generated/changelogCatalog.js.map +1 -0
  302. package/build/generated/releaseMetadata.d.ts +12 -0
  303. package/build/generated/releaseMetadata.d.ts.map +1 -0
  304. package/build/generated/releaseMetadata.js +10 -0
  305. package/build/generated/releaseMetadata.js.map +1 -0
  306. package/build/index.d.ts +6 -0
  307. package/build/index.d.ts.map +1 -0
  308. package/build/index.js +171 -0
  309. package/build/index.js.map +1 -0
  310. package/build/ota-server/OtaServer.types.d.ts +14 -0
  311. package/build/ota-server/OtaServer.types.d.ts.map +1 -0
  312. package/build/ota-server/OtaServer.types.js +2 -0
  313. package/build/ota-server/OtaServer.types.js.map +1 -0
  314. package/build/ota-server/OtaServerModule.d.ts +21 -0
  315. package/build/ota-server/OtaServerModule.d.ts.map +1 -0
  316. package/build/ota-server/OtaServerModule.js +3 -0
  317. package/build/ota-server/OtaServerModule.js.map +1 -0
  318. package/build/ota-server/OtaServerModule.web.d.ts +10 -0
  319. package/build/ota-server/OtaServerModule.web.d.ts.map +1 -0
  320. package/build/ota-server/OtaServerModule.web.js +12 -0
  321. package/build/ota-server/OtaServerModule.web.js.map +1 -0
  322. package/build/ota-server/index.d.ts +3 -0
  323. package/build/ota-server/index.d.ts.map +1 -0
  324. package/build/ota-server/index.js +3 -0
  325. package/build/ota-server/index.js.map +1 -0
  326. package/build/ota-transport/index.d.ts +67 -0
  327. package/build/ota-transport/index.d.ts.map +1 -0
  328. package/build/ota-transport/index.js +58 -0
  329. package/build/ota-transport/index.js.map +1 -0
  330. package/build/photo-receiver/PhotoReceiver.types.d.ts +18 -0
  331. package/build/photo-receiver/PhotoReceiver.types.d.ts.map +1 -0
  332. package/build/photo-receiver/PhotoReceiver.types.js +2 -0
  333. package/build/photo-receiver/PhotoReceiver.types.js.map +1 -0
  334. package/build/photo-receiver/PhotoReceiverModule.d.ts +10 -0
  335. package/build/photo-receiver/PhotoReceiverModule.d.ts.map +1 -0
  336. package/build/photo-receiver/PhotoReceiverModule.js +3 -0
  337. package/build/photo-receiver/PhotoReceiverModule.js.map +1 -0
  338. package/build/photo-receiver/PhotoReceiverModule.web.d.ts +10 -0
  339. package/build/photo-receiver/PhotoReceiverModule.web.d.ts.map +1 -0
  340. package/build/photo-receiver/PhotoReceiverModule.web.js +12 -0
  341. package/build/photo-receiver/PhotoReceiverModule.web.js.map +1 -0
  342. package/build/photo-receiver/index.d.ts +3 -0
  343. package/build/photo-receiver/index.d.ts.map +1 -0
  344. package/build/photo-receiver/index.js +3 -0
  345. package/build/photo-receiver/index.js.map +1 -0
  346. package/build/react/index.d.ts +5 -0
  347. package/build/react/index.d.ts.map +1 -0
  348. package/build/react/index.js +4 -0
  349. package/build/react/index.js.map +1 -0
  350. package/build/react/useBluetooth.d.ts +100 -0
  351. package/build/react/useBluetooth.d.ts.map +1 -0
  352. package/build/react/useBluetooth.js +167 -0
  353. package/build/react/useBluetooth.js.map +1 -0
  354. package/build/react/useBluetoothEvent.d.ts +6 -0
  355. package/build/react/useBluetoothEvent.d.ts.map +1 -0
  356. package/build/react/useBluetoothEvent.js +21 -0
  357. package/build/react/useBluetoothEvent.js.map +1 -0
  358. package/build/react/useBluetoothScan.d.ts +22 -0
  359. package/build/react/useBluetoothScan.d.ts.map +1 -0
  360. package/build/react/useBluetoothScan.js +135 -0
  361. package/build/react/useBluetoothScan.js.map +1 -0
  362. package/build/react/useBluetoothStatus.d.ts +16 -0
  363. package/build/react/useBluetoothStatus.d.ts.map +1 -0
  364. package/build/react/useBluetoothStatus.js +137 -0
  365. package/build/react/useBluetoothStatus.js.map +1 -0
  366. package/build/react/useGlassesConnection.d.ts +29 -0
  367. package/build/react/useGlassesConnection.d.ts.map +1 -0
  368. package/build/react/useGlassesConnection.js +153 -0
  369. package/build/react/useGlassesConnection.js.map +1 -0
  370. package/build/types/index.d.ts +3 -0
  371. package/build/types/index.d.ts.map +1 -0
  372. package/build/types/index.js +2 -0
  373. package/build/types/index.js.map +1 -0
  374. package/expo-module.config.json +14 -0
  375. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  376. package/ios/BluetoothSdkModule.swift +1034 -0
  377. package/ios/GlasslyBluetoothSDK.podspec +106 -0
  378. package/ios/LocalIPv4.swift +150 -0
  379. package/ios/LocalOtaServer.swift +309 -0
  380. package/ios/LocalPhotoUploadServer.swift +754 -0
  381. package/ios/OtaServerModule.swift +147 -0
  382. package/ios/Packages/CoreObjC/CoreObjC.xcodeproj/project.pbxproj +213 -0
  383. package/ios/Packages/CoreObjC/PcmConverter.h +22 -0
  384. package/ios/Packages/CoreObjC/PcmConverter.m +266 -0
  385. package/ios/Packages/CoreObjC/attdet.c +92 -0
  386. package/ios/Packages/CoreObjC/attdet.h +44 -0
  387. package/ios/Packages/CoreObjC/bits.c +375 -0
  388. package/ios/Packages/CoreObjC/bits.h +315 -0
  389. package/ios/Packages/CoreObjC/bwdet.c +129 -0
  390. package/ios/Packages/CoreObjC/bwdet.h +69 -0
  391. package/ios/Packages/CoreObjC/common.h +151 -0
  392. package/ios/Packages/CoreObjC/energy.c +70 -0
  393. package/ios/Packages/CoreObjC/energy.h +43 -0
  394. package/ios/Packages/CoreObjC/fastmath.h +158 -0
  395. package/ios/Packages/CoreObjC/include/PcmConverter.h +22 -0
  396. package/ios/Packages/CoreObjC/lc3.c +704 -0
  397. package/ios/Packages/CoreObjC/lc3.h +313 -0
  398. package/ios/Packages/CoreObjC/lc3_cpp.h +283 -0
  399. package/ios/Packages/CoreObjC/lc3_private.h +163 -0
  400. package/ios/Packages/CoreObjC/ltpf.c +905 -0
  401. package/ios/Packages/CoreObjC/ltpf.h +111 -0
  402. package/ios/Packages/CoreObjC/ltpf_arm.h +506 -0
  403. package/ios/Packages/CoreObjC/ltpf_neon.h +281 -0
  404. package/ios/Packages/CoreObjC/makefile.mk +35 -0
  405. package/ios/Packages/CoreObjC/mdct.c +469 -0
  406. package/ios/Packages/CoreObjC/mdct.h +57 -0
  407. package/ios/Packages/CoreObjC/mdct_neon.h +296 -0
  408. package/ios/Packages/CoreObjC/meson.build +61 -0
  409. package/ios/Packages/CoreObjC/plc.c +61 -0
  410. package/ios/Packages/CoreObjC/plc.h +57 -0
  411. package/ios/Packages/CoreObjC/rnnoise.h +114 -0
  412. package/ios/Packages/CoreObjC/sns.c +880 -0
  413. package/ios/Packages/CoreObjC/sns.h +103 -0
  414. package/ios/Packages/CoreObjC/spec.c +907 -0
  415. package/ios/Packages/CoreObjC/spec.h +119 -0
  416. package/ios/Packages/CoreObjC/tables.c +3457 -0
  417. package/ios/Packages/CoreObjC/tables.h +94 -0
  418. package/ios/Packages/CoreObjC/tns.c +457 -0
  419. package/ios/Packages/CoreObjC/tns.h +99 -0
  420. package/ios/Packages/SherpaOnnx/Model/joiner.onnx +0 -0
  421. package/ios/Packages/SherpaOnnx/Model/tokens.txt +502 -0
  422. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +2383 -0
  423. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +48 -0
  424. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/c-api.h +4689 -0
  425. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/cxx-api.h +2036 -0
  426. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
  427. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/c-api.h +4689 -0
  428. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/cxx-api.h +2036 -0
  429. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
  430. package/ios/Packages/libbz2/module.modulemap +5 -0
  431. package/ios/Packages/libbz2/shim.h +1 -0
  432. package/ios/PhotoReceiverModule.swift +129 -0
  433. package/ios/Source/BluetoothSDK.swift +2303 -0
  434. package/ios/Source/BluetoothSDKDebug.swift +12 -0
  435. package/ios/Source/BluetoothSdkDefaults.swift +29 -0
  436. package/ios/Source/Bridge.swift +708 -0
  437. package/ios/Source/Bridging-Header.h +16 -0
  438. package/ios/Source/DeviceManager.swift +2109 -0
  439. package/ios/Source/DeviceStore.swift +336 -0
  440. package/ios/Source/GeneratedChangelogCatalog.swift +7 -0
  441. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  442. package/ios/Source/ObservableStore.swift +118 -0
  443. package/ios/Source/OtaManifest.swift +211 -0
  444. package/ios/Source/PrivacyInfo.xcprivacy +23 -0
  445. package/ios/Source/ReleaseChangelog.swift +65 -0
  446. package/ios/Source/Resources/G2Firmware/README.md +10 -0
  447. package/ios/Source/audio/AudioModels.swift +178 -0
  448. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  449. package/ios/Source/camera/CameraModels.swift +866 -0
  450. package/ios/Source/connection/ScanSession.swift +27 -0
  451. package/ios/Source/controllers/ControllerManager.swift +184 -0
  452. package/ios/Source/controllers/R1.swift +829 -0
  453. package/ios/Source/errors/BluetoothSdkError.swift +19 -0
  454. package/ios/Source/events/BluetoothEvents.swift +342 -0
  455. package/ios/Source/internal/BleTraceLogger.swift +192 -0
  456. package/ios/Source/internal/BluetoothAvailability.swift +78 -0
  457. package/ios/Source/internal/BluetoothSdkAnalytics.swift +199 -0
  458. package/ios/Source/internal/ValueParsing.swift +99 -0
  459. package/ios/Source/requests/DisplayRequests.swift +82 -0
  460. package/ios/Source/services/PhoneMic.swift +662 -0
  461. package/ios/Source/sgcs/Ar99.swift +2451 -0
  462. package/ios/Source/sgcs/Frame.swift +649 -0
  463. package/ios/Source/sgcs/G1.swift +2554 -0
  464. package/ios/Source/sgcs/G2.swift +6887 -0
  465. package/ios/Source/sgcs/G2CfwClassification.swift +43 -0
  466. package/ios/Source/sgcs/G2CfwRenderer.swift +1279 -0
  467. package/ios/Source/sgcs/G2CfwTextureCache.swift +233 -0
  468. package/ios/Source/sgcs/G2FlashProtocol.swift +312 -0
  469. package/ios/Source/sgcs/G2Flasher.swift +773 -0
  470. package/ios/Source/sgcs/Mach1.swift +541 -0
  471. package/ios/Source/sgcs/MentraLive.swift +7011 -0
  472. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  473. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  474. package/ios/Source/sgcs/MentraNex.swift +3221 -0
  475. package/ios/Source/sgcs/Nimo.swift +1886 -0
  476. package/ios/Source/sgcs/SGCManager.swift +608 -0
  477. package/ios/Source/sgcs/Simulated.swift +274 -0
  478. package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
  479. package/ios/Source/sgcs/glassly_ble.pb.swift +4354 -0
  480. package/ios/Source/speakerid/SpeakerIdEngine.swift +573 -0
  481. package/ios/Source/status/DeviceStatus.swift +981 -0
  482. package/ios/Source/status/RuntimeState.swift +359 -0
  483. package/ios/Source/status/WifiHotspotStatus.swift +345 -0
  484. package/ios/Source/streaming/StreamModels.swift +693 -0
  485. package/ios/Source/stt/STTTools.swift +191 -0
  486. package/ios/Source/stt/SherpaOnnxTranscriber.swift +466 -0
  487. package/ios/Source/tts/TTSTools.swift +171 -0
  488. package/ios/Source/types/DeviceModels.swift +177 -0
  489. package/ios/Source/utils/AudioSessionMonitor.swift +283 -0
  490. package/ios/Source/utils/BinaryCodec.swift +61 -0
  491. package/ios/Source/utils/BleJsonCompact.swift +405 -0
  492. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  493. package/ios/Source/utils/Constants.swift +78 -0
  494. package/ios/Source/utils/Enums.swift +95 -0
  495. package/ios/Source/utils/G1Text.swift +2096 -0
  496. package/ios/Source/utils/JSCExperiment.swift +241 -0
  497. package/ios/Source/utils/MemoryMonitor.swift +44 -0
  498. package/ios/Source/utils/MessageChunkReassembler.swift +190 -0
  499. package/ios/Source/utils/MessageChunker.swift +294 -0
  500. package/ios/Source/utils/Models.swift +135 -0
  501. package/ios/Source/utils/PhoneAudioMonitor.swift +230 -0
  502. package/ios/Source/utils/TarBz2Extractor.swift +257 -0
  503. package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +78 -0
  504. package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +372 -0
  505. package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +162 -0
  506. package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +182 -0
  507. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +160 -0
  508. package/ios/Tests/ReleaseChangelogTests.swift +13 -0
  509. package/package.json +129 -0
  510. package/plugin/build/index.d.ts +10 -0
  511. package/plugin/build/index.js +12 -0
  512. package/plugin/build/withAndroid.d.ts +3 -0
  513. package/plugin/build/withAndroid.js +190 -0
  514. package/plugin/build/withIos.d.ts +5 -0
  515. package/plugin/build/withIos.js +108 -0
  516. package/scripts/inject-ios-sdk-version.mjs +65 -0
  517. package/scripts/public-ota-api.test.mjs +37 -0
  518. package/scripts/verify-release-package.mjs +45 -0
  519. package/scripts/write-release-metadata.mjs +152 -0
  520. package/scripts/write-release-metadata.test.mjs +37 -0
  521. package/src/BluetoothSdk.types.ts +1623 -0
  522. package/src/_internal.ts +16 -0
  523. package/src/_private/BluetoothSdkModule.ts +684 -0
  524. package/src/_private/LocalNetworkModule.ts +50 -0
  525. package/src/_private/cameraRequestPayload.ts +34 -0
  526. package/src/_private/photoRequestPayload.ts +90 -0
  527. package/src/_private/sdkOtaManifest.ts +18 -0
  528. package/src/changelogs.ts +46 -0
  529. package/src/debug.ts +9 -0
  530. package/src/generated/changelogCatalog.ts +13 -0
  531. package/src/generated/releaseMetadata.ts +20 -0
  532. package/src/index.ts +321 -0
  533. package/src/ota-server/OtaServer.types.ts +10 -0
  534. package/src/ota-server/OtaServerModule.ts +23 -0
  535. package/src/ota-server/OtaServerModule.web.ts +17 -0
  536. package/src/ota-server/index.ts +2 -0
  537. package/src/ota-transport/index.ts +116 -0
  538. package/src/photo-receiver/PhotoReceiver.types.ts +20 -0
  539. package/src/photo-receiver/PhotoReceiverModule.ts +11 -0
  540. package/src/photo-receiver/PhotoReceiverModule.web.ts +17 -0
  541. package/src/photo-receiver/index.ts +2 -0
  542. package/src/react/index.ts +21 -0
  543. package/src/react/useBluetooth.ts +309 -0
  544. package/src/react/useBluetoothEvent.ts +35 -0
  545. package/src/react/useBluetoothScan.ts +173 -0
  546. package/src/react/useBluetoothStatus.ts +180 -0
  547. package/src/react/useGlassesConnection.ts +202 -0
  548. package/src/types/index.ts +12 -0
@@ -0,0 +1,2303 @@
1
+ import CoreBluetooth
2
+ import Foundation
3
+
4
+ @MainActor
5
+ private final class ActiveStreamKeepAlive {
6
+ let streamId: String
7
+ let intervalSeconds: Int
8
+ var pendingAckId: String?
9
+ var missedAckCount = 0
10
+ var task: Task<Void, Never>?
11
+ /// Missed-ACK counting only begins once the stream is confirmed live/coming up, so a slow
12
+ /// startup (glasses can't ACK until they reach starting/streaming) can't trip a false
13
+ /// keep-alive timeout before the stream is ever up.
14
+ var armed = false
15
+
16
+ init(streamId: String, intervalSeconds: Int) {
17
+ self.streamId = streamId
18
+ self.intervalSeconds = intervalSeconds
19
+ }
20
+ }
21
+
22
+ @MainActor
23
+ private final class ActiveScanSession {
24
+ let model: DeviceModel
25
+ let onResults: ([Device]) -> Void
26
+ let onComplete: ([Device]) -> Void
27
+ var latestResults: [Device] = []
28
+ var timeoutTask: Task<Void, Never>?
29
+ weak var publicSession: ScanSession?
30
+
31
+ init(
32
+ model: DeviceModel,
33
+ onResults: @escaping ([Device]) -> Void,
34
+ onComplete: @escaping ([Device]) -> Void
35
+ ) {
36
+ self.model = model
37
+ self.onResults = onResults
38
+ self.onComplete = onComplete
39
+ }
40
+ }
41
+
42
+ @MainActor
43
+ private final class PendingWifiScan {
44
+ let pending: PendingResponse<[WifiScanResult]>
45
+ let scanId: String
46
+ var latestResults: [WifiScanResult] = []
47
+ /// Chunks accumulated from scanId-echoing glasses, deduplicated by SSID;
48
+ /// resolved only when the glasses flag the scan complete.
49
+ var accumulated: [WifiScanResult] = []
50
+
51
+ init(pending: PendingResponse<[WifiScanResult]>, scanId: String) {
52
+ self.pending = pending
53
+ self.scanId = scanId
54
+ }
55
+ }
56
+
57
+ private enum WifiStatusOperation {
58
+ case connect
59
+ case forget
60
+ }
61
+
62
+ @MainActor
63
+ private final class PendingWifiStatusRequest {
64
+ let operation: WifiStatusOperation
65
+ let ssid: String
66
+ let pending: PendingResponse<WifiStatusEvent>
67
+
68
+ init(operation: WifiStatusOperation, ssid: String, pending: PendingResponse<WifiStatusEvent>) {
69
+ self.operation = operation
70
+ self.ssid = ssid
71
+ self.pending = pending
72
+ }
73
+ }
74
+
75
+ @MainActor
76
+ private final class PendingHotspotStatusRequest {
77
+ let enabled: Bool
78
+ let pending: PendingResponse<HotspotStatusEvent>
79
+
80
+ init(enabled: Bool, pending: PendingResponse<HotspotStatusEvent>) {
81
+ self.enabled = enabled
82
+ self.pending = pending
83
+ }
84
+ }
85
+
86
+ @MainActor
87
+ private final class PendingVideoRecordingRequest {
88
+ let expectedStatus: String
89
+ let pending: PendingResponse<VideoRecordingStatusEvent>
90
+ let waitForUpload: Bool
91
+ var stoppedEvent: VideoRecordingStatusEvent?
92
+ var uploadSucceeded = false
93
+
94
+ init(expectedStatus: String, pending: PendingResponse<VideoRecordingStatusEvent>, waitForUpload: Bool = false) {
95
+ self.expectedStatus = expectedStatus
96
+ self.pending = pending
97
+ self.waitForUpload = waitForUpload
98
+ }
99
+ }
100
+
101
+ /// seq records send order (assigned and handed to the BLE queue in a single
102
+ /// MainActor turn) so that an id-carrying status for a newer start can
103
+ /// identify which older in-flight starts it preempted.
104
+ @MainActor
105
+ private final class PendingStreamStart {
106
+ let seq: Int
107
+ let pending: PendingResponse<StreamStatusEvent>
108
+ /// Set when an id-carrying error arrives: a fatal publisher error never
109
+ /// reaches the reconnect machinery and winds down with a streamId-less
110
+ /// stopped, so the stash both attributes that stopped to this start and
111
+ /// preserves the real error details for the rejection.
112
+ var lastError: StreamStatusEvent?
113
+
114
+ init(seq: Int, pending: PendingResponse<StreamStatusEvent>) {
115
+ self.seq = seq
116
+ self.pending = pending
117
+ }
118
+ }
119
+
120
+ @MainActor
121
+ private final class PendingResponse<T> {
122
+ private let operation: String
123
+ private var continuation: CheckedContinuation<T, Error>?
124
+ private var timeoutTask: Task<Void, Never>?
125
+ private var result: Result<T, Error>?
126
+ private var completed = false
127
+
128
+ init(operation: String) {
129
+ self.operation = operation
130
+ }
131
+
132
+ func resolve(_ value: T) {
133
+ guard !completed else { return }
134
+ completed = true
135
+ result = .success(value)
136
+ timeoutTask?.cancel()
137
+ continuation?.resume(returning: value)
138
+ continuation = nil
139
+ }
140
+
141
+ func reject(_ error: Error) {
142
+ guard !completed else { return }
143
+ completed = true
144
+ result = .failure(error)
145
+ timeoutTask?.cancel()
146
+ continuation?.resume(throwing: error)
147
+ continuation = nil
148
+ }
149
+
150
+ func wait(timeoutMs: Int = 15000) async throws -> T {
151
+ if let result {
152
+ return try result.get()
153
+ }
154
+ return try await withCheckedThrowingContinuation { continuation in
155
+ self.continuation = continuation
156
+ timeoutTask = Task { @MainActor [weak self] in
157
+ do {
158
+ try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000)
159
+ } catch {
160
+ return
161
+ }
162
+ self?.reject(
163
+ BluetoothSdkError(
164
+ code: "request_timeout",
165
+ message: "\(self?.operation ?? "Request") timed out waiting for glasses response."
166
+ )
167
+ )
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ @MainActor
174
+ public final class BluetoothSDK {
175
+ private static let wifiScanTimeoutMs = 20000
176
+ // A photo response is terminal only after capture, encoding, transport, and upload.
177
+ // Max-quality BLE fallback can legitimately exceed the generic command deadline.
178
+ private static let photoRequestTimeoutMs = 30000
179
+ private static let otaBesVersionWaitMs = 5000
180
+ private static let otaMtkVersionWaitMs = 2000
181
+ private static let otaVersionPollMs = 100
182
+ private static let defaultStreamKeepAliveIntervalSeconds = 5
183
+
184
+ public weak var delegate: BluetoothSDKDelegate?
185
+
186
+ private let configuration: BluetoothSDKConfiguration
187
+ private var discoveredDeviceNames = Set<String>()
188
+ private var bluetoothAvailabilityListenerId: UUID?
189
+ private var shouldRestoreGlassesOnBluetoothRestore = false
190
+ private var shouldRestoreControllerOnBluetoothRestore = false
191
+ private var bridgeEventSinkId: String?
192
+ private var storeListenerId: String?
193
+ private let defaultDeviceKeys: Set<String> = ["default_wearable", "device_name", "device_address", "project_name"]
194
+ private let videoUploadStopTimeoutMs = 10 * 60 * 1000
195
+ private var suppressDefaultDeviceEvents = false
196
+ private var defaultDeviceApplyGeneration = 0
197
+ private var activeScanSessions: [UUID: ActiveScanSession] = [:]
198
+ private var activeStreamKeepAlive: ActiveStreamKeepAlive?
199
+ private let analytics: BluetoothSdkAnalytics
200
+ private var pendingPhotoRequests: [String: PendingResponse<PhotoResponseEvent>] = [:]
201
+ private var pendingCameraStatusRequests: [String: PendingResponse<CameraStatusEvent>] = [:]
202
+ private var pendingVideoRecordingRequests: [String: PendingVideoRecordingRequest] = [:]
203
+ private var pendingRgbLedRequests: [String: PendingResponse<RgbLedControlResponseEvent>] = [:]
204
+ private var pendingSettingsRequests: [String: PendingResponse<SettingsAckEvent>] = [:]
205
+ private var pendingStreamStarts: [String: PendingStreamStart] = [:]
206
+ private var streamStartSeq = 0
207
+ // seq is the stop's slot in the shared streamStartSeq order (drawn in the
208
+ // same await-free MainActor turn that sends the command): every pending
209
+ // start with a lower seq reached the glasses' FIFO before this stop.
210
+ private var pendingStreamStop: (streamId: String?, seq: Int, pending: PendingResponse<StreamStatusEvent>)?
211
+ private var pendingGalleryStatus: PendingResponse<GalleryStatusEvent>?
212
+ private var pendingOtaQuery: PendingResponse<OtaQueryResult>?
213
+ private var pendingOtaStart: PendingResponse<OtaStartAckEvent>?
214
+ private var pendingWifiScan: PendingWifiScan?
215
+ private var wifiScanTask: Task<[WifiScanResult], Error>?
216
+ private var pendingWifiStatus: PendingWifiStatusRequest?
217
+ private var pendingHotspotStatus: PendingHotspotStatusRequest?
218
+ private var pendingVersionInfo: PendingResponse<VersionInfoResult>?
219
+ private var configuredOtaVersionUrl: String?
220
+
221
+ public init(configuration: BluetoothSDKConfiguration = .default) {
222
+ self.configuration = configuration
223
+ analytics = BluetoothSdkAnalytics(configuration: configuration.analytics)
224
+ bluetoothAvailabilityListenerId = BluetoothAvailability.shared.addStateListener { [weak self] state in
225
+ Task { @MainActor [weak self] in
226
+ self?.handleBluetoothAvailability(state)
227
+ }
228
+ }
229
+ bridgeEventSinkId = Bridge.addEventSink { [weak self] eventName, data in
230
+ // Bridge.dispatchEvent always invokes sinks on the main thread, in the
231
+ // FIFO order events were dispatched (correlated WiFi-scan chunks rely
232
+ // on that order). A fresh Task per event can reach the MainActor out
233
+ // of creation order and reorder chunks, so consume synchronously
234
+ // while still on main.
235
+ if Thread.isMainThread {
236
+ MainActor.assumeIsolated {
237
+ self?.dispatchBridgeEvent(eventName, data)
238
+ }
239
+ } else {
240
+ // Defensive fallback only — Bridge.dispatchEvent should never take
241
+ // this path. A serial main-queue hop still preserves FIFO order,
242
+ // unlike an unstructured Task.
243
+ DispatchQueue.main.async {
244
+ MainActor.assumeIsolated {
245
+ self?.dispatchBridgeEvent(eventName, data)
246
+ }
247
+ }
248
+ }
249
+ }
250
+ storeListenerId = DeviceStore.shared.store.addListener { [weak self] category, changes in
251
+ Task { @MainActor [weak self] in
252
+ self?.dispatchStoreUpdate(category, changes)
253
+ }
254
+ }
255
+ analytics.initializeGlassesStatus(glassesStatus)
256
+ analytics.captureStarted()
257
+ }
258
+
259
+ public var state: BluetoothState {
260
+ BluetoothState(glassesStatus: glassesStatus, bluetoothStatus: bluetoothStatus)
261
+ }
262
+
263
+ public var glasses: GlassesRuntimeState {
264
+ state.glasses
265
+ }
266
+
267
+ public var sdkState: PhoneSdkRuntimeState {
268
+ state.sdk
269
+ }
270
+
271
+ public var scanState: BluetoothScanState {
272
+ state.scan
273
+ }
274
+
275
+ var glassesStatus: GlassesStatus {
276
+ GlassesStatus(values: DeviceStore.shared.store.getCategory("glasses"))
277
+ }
278
+
279
+ var bluetoothStatus: BluetoothStatus {
280
+ BluetoothStatus(values: DeviceStore.shared.store.getCategory(ObservableStore.bluetoothCategory))
281
+ }
282
+
283
+ public var defaultDevice: Device? {
284
+ currentDefaultDevice()
285
+ }
286
+
287
+ private func requireGlassesConnected(operation: String) throws {
288
+ guard glassesStatus.connected else {
289
+ throw BluetoothSdkError(
290
+ code: "glasses_not_connected",
291
+ message: "Cannot \(operation) because glasses are not connected."
292
+ )
293
+ }
294
+ }
295
+
296
+ public func getDefaultDevice() -> Device? {
297
+ currentDefaultDevice()
298
+ }
299
+
300
+ public func setDefaultDevice(_ device: Device?) {
301
+ guard let device else {
302
+ clearDefaultDevice()
303
+ return
304
+ }
305
+ defaultDeviceApplyGeneration += 1
306
+ let generation = defaultDeviceApplyGeneration
307
+ suppressDefaultDeviceEvents = true
308
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "default_wearable", device.model.deviceType)
309
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_name", device.name)
310
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_address", device.identifier ?? "")
311
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "project_name", device.projectName ?? "")
312
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_name", "")
313
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_address", "")
314
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_secure_pairing_capable", "")
315
+ finishDefaultDeviceApply(generation: generation)
316
+ }
317
+
318
+ public func clearDefaultDevice() {
319
+ defaultDeviceApplyGeneration += 1
320
+ let generation = defaultDeviceApplyGeneration
321
+ suppressDefaultDeviceEvents = true
322
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "default_wearable", "")
323
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_name", "")
324
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "device_address", "")
325
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "project_name", "")
326
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_name", "")
327
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_address", "")
328
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_secure_pairing_capable", "")
329
+ finishDefaultDeviceApply(generation: generation)
330
+ }
331
+
332
+ public func startScan(model: DeviceModel) throws {
333
+ if model != .simulated {
334
+ try BluetoothAvailability.shared.requirePoweredOn(operation: "scan for glasses")
335
+ }
336
+ discoveredDeviceNames.removeAll()
337
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searching", true)
338
+ DeviceManager.shared.findCompatibleDevices(model.deviceType)
339
+ }
340
+
341
+ public func stopScan() {
342
+ stopScan(reason: .cancelled)
343
+ }
344
+
345
+ private func stopScan(reason: ScanStopReason) {
346
+ DeviceManager.shared.stopScan()
347
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searching", false)
348
+ delegate?.bluetoothSDK(self, didStopScan: reason)
349
+ }
350
+
351
+ @discardableResult
352
+ public func scan(
353
+ model: DeviceModel,
354
+ timeout: TimeInterval = 15,
355
+ onResults: @escaping ([Device]) -> Void,
356
+ onComplete: @escaping ([Device]) -> Void = { _ in }
357
+ ) throws -> ScanSession {
358
+ let normalizedTimeout = timeout > 0 && timeout.isFinite ? timeout : 15
359
+ let id = UUID()
360
+ let activeSession = ActiveScanSession(
361
+ model: model,
362
+ onResults: onResults,
363
+ onComplete: onComplete
364
+ )
365
+ let publicSession = ScanSession { [weak self] in
366
+ self?.finishScanSession(id, reason: .cancelled, shouldStopScan: true)
367
+ }
368
+ activeSession.publicSession = publicSession
369
+ activeScanSessions[id] = activeSession
370
+
371
+ do {
372
+ emitScanResults([], forSession: id)
373
+ try startScan(model: model)
374
+ emitScanResults(bluetoothStatus.searchResults.filter { $0.model == model }, forSession: id)
375
+ activeSession.timeoutTask = Task { [weak self] in
376
+ let nanoseconds = UInt64(normalizedTimeout * 1_000_000_000)
377
+ try? await Task.sleep(nanoseconds: nanoseconds)
378
+ await self?.finishScanSession(id, reason: .completed, shouldStopScan: true)
379
+ }
380
+ return publicSession
381
+ } catch {
382
+ activeScanSessions[id] = nil
383
+ publicSession.markStopped()
384
+ throw error
385
+ }
386
+ }
387
+
388
+ public func connect(to device: Device, options: ConnectOptions = ConnectOptions()) throws {
389
+ clearBluetoothRestoreIntent()
390
+ if device.model != .simulated {
391
+ try BluetoothAvailability.shared.requirePoweredOn(operation: "connect to glasses")
392
+ }
393
+ let isController = ControllerTypes.ALL.contains(device.model.deviceType)
394
+ if options.cancelExistingConnectionAttempt {
395
+ if isController {
396
+ DeviceManager.shared.disconnectController()
397
+ } else {
398
+ cancelConnectionAttempt()
399
+ }
400
+ }
401
+ if options.saveAsDefault && !isController {
402
+ setDefaultDevice(device)
403
+ } else if !isController {
404
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_name", device.name)
405
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_address", device.identifier ?? "")
406
+ DeviceStore.shared.apply(
407
+ ObservableStore.bluetoothCategory,
408
+ "pending_device_secure_pairing_capable",
409
+ device.securePairingCapable ?? ""
410
+ )
411
+ }
412
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_wearable", device.model.deviceType)
413
+ DeviceManager.shared.connectByName(device.name)
414
+ }
415
+
416
+ public func connectDefault(options: ConnectOptions = ConnectOptions()) throws {
417
+ clearBluetoothRestoreIntent()
418
+ guard let device = currentDefaultDevice() else {
419
+ throw BluetoothSdkError(
420
+ code: "default_device_missing",
421
+ message: "Set a default glasses device before calling connectDefault."
422
+ )
423
+ }
424
+ if device.model != .simulated {
425
+ try BluetoothAvailability.shared.requirePoweredOn(operation: "connect to glasses")
426
+ }
427
+ if options.cancelExistingConnectionAttempt {
428
+ cancelConnectionAttempt()
429
+ }
430
+ DeviceManager.shared.connectDefault()
431
+ }
432
+
433
+ public func cancelConnectionAttempt() {
434
+ clearBluetoothRestoreIntent()
435
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_name", "")
436
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_address", "")
437
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_secure_pairing_capable", "")
438
+ DeviceManager.shared.disconnect()
439
+ }
440
+
441
+ func connectSimulated() {
442
+ clearBluetoothRestoreIntent()
443
+ DeviceManager.shared.connectSimulated()
444
+ }
445
+
446
+ public func disconnect() {
447
+ clearBluetoothRestoreIntent()
448
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_name", "")
449
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_address", "")
450
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "pending_device_secure_pairing_capable", "")
451
+ DeviceManager.shared.disconnect()
452
+ }
453
+
454
+ public func forget() {
455
+ clearBluetoothRestoreIntent()
456
+ DeviceManager.shared.forget()
457
+ }
458
+
459
+ public func displayText(_ text: String, x: Int = 0, y: Int = 0, size: Int = 24) async throws {
460
+ try await displayText(DisplayTextRequest(text: text, x: x, y: y, size: size))
461
+ }
462
+
463
+ public func displayText(_ request: DisplayTextRequest) async throws {
464
+ DeviceManager.shared.displayText(request.dictionary)
465
+ }
466
+
467
+ func displayEvent(_ request: DisplayEventRequest) async throws {
468
+ DeviceManager.shared.displayEvent(request.values)
469
+ }
470
+
471
+ // MARK: - Native navigation (glasses-rendered nav UI)
472
+
473
+ func navigationModeStart() {
474
+ DeviceManager.shared.navigationModeStart()
475
+ }
476
+
477
+ func navigationModeStop(arrived: Bool) {
478
+ DeviceManager.shared.navigationModeStop(arrived: arrived)
479
+ }
480
+
481
+ func navigationUpdateInfo(_ params: [String: Any]) {
482
+ DeviceManager.shared.navigationUpdateInfo(params)
483
+ }
484
+
485
+ func navigationMiniMap(_ params: [String: Any]) {
486
+ DeviceManager.shared.navigationMiniMap(params)
487
+ }
488
+
489
+ func navigationOverviewMap(_ params: [String: Any]) {
490
+ DeviceManager.shared.navigationOverviewMap(params)
491
+ }
492
+
493
+ public func clearDisplay() async throws {
494
+ DeviceManager.shared.sgc?.clearDisplay()
495
+ }
496
+
497
+ public func showDashboard() {
498
+ DeviceManager.shared.showDashboard()
499
+ }
500
+
501
+ public func toggleHeadUp() {
502
+ DeviceManager.shared.toggleHeadUp()
503
+ }
504
+
505
+ public func showNotificationsPanel() {
506
+ DeviceManager.shared.showNotificationsPanel()
507
+ }
508
+
509
+ func setBrightness(_ level: Int, autoMode: Bool? = nil) async throws {
510
+ if let autoMode {
511
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "auto_brightness", autoMode)
512
+ }
513
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "brightness", level)
514
+ }
515
+
516
+ func setAutoBrightness(enabled: Bool) async throws {
517
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "auto_brightness", enabled)
518
+ }
519
+
520
+ public func setDashboardPosition(height: Int, depth: Int) async throws {
521
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "dashboard_height", height)
522
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "dashboard_depth", depth)
523
+ }
524
+
525
+ public func setDashboardPosition(_ request: DashboardPositionRequest) async throws {
526
+ try await setDashboardPosition(height: request.height, depth: request.depth)
527
+ }
528
+
529
+ func setDashboardMenu(_ items: [DashboardMenuItem]) async throws {
530
+ DeviceStore.shared.apply(
531
+ ObservableStore.bluetoothCategory,
532
+ "menu_apps",
533
+ items.map(\.dictionary)
534
+ )
535
+ }
536
+
537
+ func setCalendarEvents(_ events: [CalendarEvent]) async throws {
538
+ DeviceStore.shared.apply(
539
+ ObservableStore.bluetoothCategory,
540
+ "calendar_events",
541
+ events.map(\.dictionary)
542
+ )
543
+ }
544
+
545
+ public func setHeadUpAngle(_ angleDegrees: Int) async throws {
546
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "head_up_angle", angleDegrees)
547
+ }
548
+
549
+ public func setScreenDisabled(_ disabled: Bool) async throws {
550
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "screen_disabled", disabled)
551
+ }
552
+
553
+ public func setGalleryModeEnabled(_ enabled: Bool) async throws -> SettingsAckEvent {
554
+ try await performSettingsCommand(
555
+ setting: "gallery_mode",
556
+ updateStore: { _ in DeviceStore.shared.set(ObservableStore.bluetoothCategory, "gallery_mode", enabled) },
557
+ send: { requestId in try DeviceManager.shared.sendGalleryMode(requestId: requestId, enabled: enabled) }
558
+ )
559
+ }
560
+
561
+ private func performSettingsCommand(
562
+ setting: String,
563
+ updateStore: (SettingsAckEvent) -> Void,
564
+ send: (String) throws -> Void
565
+ ) async throws -> SettingsAckEvent {
566
+ let requestId = "settings-\(setting)-\(UUID().uuidString)"
567
+ let pending = PendingResponse<SettingsAckEvent>(operation: "set \(setting)")
568
+ pendingSettingsRequests[requestId] = pending
569
+ do {
570
+ try send(requestId)
571
+ let ack = try await pending.wait()
572
+ updateStore(ack)
573
+ pendingSettingsRequests.removeValue(forKey: requestId)
574
+ return ack
575
+ } catch {
576
+ pendingSettingsRequests.removeValue(forKey: requestId)
577
+ throw error
578
+ }
579
+ }
580
+
581
+ public func setVoiceActivityDetectionEnabled(_ enabled: Bool) async throws {
582
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "voice_activity_detection_enabled", enabled)
583
+ }
584
+
585
+ public func setLoudnessGateEnabled(_ enabled: Bool) async throws {
586
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "loudness_gate_enabled", enabled)
587
+ }
588
+
589
+ @available(*, deprecated, message: "Sticky action-button photo presets are deprecated. Prefer per-request requestPhoto(...) options (e.g. mode .text for text sensor size/crop, or explicit per-shot fields). This method still works but will be removed in a future release.")
590
+ public func setPhotoCaptureDefaults(_ settings: PhotoCaptureDefaults) async throws -> SettingsAckEvent {
591
+ try await performSettingsCommand(
592
+ setting: "button_photo",
593
+ updateStore: { _ in
594
+ if settings.resetCaptureTuning == true {
595
+ // Mirror Android: clear all cached scan-tuning keys so reconnect sync
596
+ // does not replay stale values after a reset.
597
+ let cat = ObservableStore.bluetoothCategory
598
+ for key in ["button_photo_zsl_mfnr", "button_photo_mfnr", "button_photo_zsl", "button_photo_noise_reduction",
599
+ "button_photo_edge_enhancement", "button_photo_isp_digital_gain",
600
+ "button_photo_isp_analog_gain", "button_photo_ae_exposure_divisor",
601
+ "button_photo_iso_cap", "button_photo_compress", "button_photo_sound"]
602
+ {
603
+ DeviceStore.shared.remove(cat, key)
604
+ }
605
+ }
606
+ if let size = settings.size {
607
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_size", size.rawValue)
608
+ }
609
+ if let mfnr = settings.mfnr {
610
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_mfnr", mfnr)
611
+ }
612
+ if let zsl = settings.zsl {
613
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_zsl", zsl)
614
+ }
615
+ if let noiseReduction = settings.noiseReduction {
616
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_noise_reduction", noiseReduction)
617
+ }
618
+ if let edgeEnhancement = settings.edgeEnhancement {
619
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_edge_enhancement", edgeEnhancement)
620
+ }
621
+ if let ispDigitalGain = settings.ispDigitalGain {
622
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_isp_digital_gain", ispDigitalGain)
623
+ }
624
+ if let ispAnalogGain = settings.ispAnalogGain {
625
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_isp_analog_gain", ispAnalogGain)
626
+ }
627
+ if let aeExposureDivisor = settings.aeExposureDivisor {
628
+ DeviceStore.shared.set(
629
+ ObservableStore.bluetoothCategory,
630
+ "button_photo_ae_exposure_divisor",
631
+ aeExposureDivisor
632
+ )
633
+ }
634
+ if let isoCap = settings.isoCap {
635
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_iso_cap", isoCap)
636
+ }
637
+ if let compress = settings.compress {
638
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_compress", compress)
639
+ }
640
+ if let sound = settings.sound {
641
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_photo_sound", sound)
642
+ }
643
+ },
644
+ send: { requestId in
645
+ try DeviceManager.shared.sendButtonPhotoSettings(requestId: requestId, settings: settings)
646
+ }
647
+ )
648
+ }
649
+
650
+ public func setVideoRecordingDefaults(_ defaults: VideoRecordingDefaults) async throws -> SettingsAckEvent {
651
+ try await performSettingsCommand(
652
+ setting: "button_video_recording",
653
+ updateStore: { _ in
654
+ DeviceStore.shared.set(
655
+ ObservableStore.bluetoothCategory,
656
+ "button_video_settings",
657
+ ["width": defaults.width, "height": defaults.height, "fps": defaults.fps]
658
+ )
659
+ // Keep legacy cache keys readable for older internal callers during migration.
660
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_width", defaults.width)
661
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_height", defaults.height)
662
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_video_fps", defaults.fps)
663
+ },
664
+ send: { requestId in
665
+ try DeviceManager.shared.sendButtonVideoRecordingSettings(
666
+ requestId: requestId,
667
+ width: defaults.width,
668
+ height: defaults.height,
669
+ fps: defaults.fps
670
+ )
671
+ }
672
+ )
673
+ }
674
+
675
+ public func setMaxVideoRecordingDuration(minutes: Int) async throws -> SettingsAckEvent {
676
+ try await performSettingsCommand(
677
+ setting: "button_max_recording_time",
678
+ updateStore: { _ in
679
+ DeviceStore.shared.set(ObservableStore.bluetoothCategory, "button_max_recording_time", minutes)
680
+ },
681
+ send: { requestId in
682
+ try DeviceManager.shared.sendButtonMaxRecordingTime(requestId: requestId, minutes: minutes)
683
+ }
684
+ )
685
+ }
686
+
687
+ public func setCameraFov(_ fov: CameraFov) async throws -> CameraFovResult {
688
+ let ack = try await performSettingsCommand(
689
+ setting: "camera_fov",
690
+ updateStore: { _ in },
691
+ send: { requestId in
692
+ try DeviceManager.shared.sendCameraFovSetting(
693
+ requestId: requestId,
694
+ fov: fov.fov,
695
+ roiPosition: fov.roiPosition.rawValue
696
+ )
697
+ }
698
+ )
699
+ let result = try CameraFovResult.from(ack: ack, fallback: fov)
700
+ DeviceStore.shared.set(
701
+ ObservableStore.bluetoothCategory,
702
+ "camera_fov",
703
+ ["fov": result.fov, "roi_position": result.roiPosition.rawValue]
704
+ )
705
+ return result
706
+ }
707
+
708
+ /// Compatibility path for ASG clients that support only the older one-way FOV command.
709
+ public func setLegacyCameraFov(_ fov: CameraFov) throws -> CameraFovResult {
710
+ try DeviceManager.shared.sendLegacyCameraFovSetting(
711
+ fov: fov.fov,
712
+ roiPosition: fov.roiPosition.rawValue
713
+ )
714
+ return CameraFovResult(
715
+ requestId: "legacy",
716
+ fov: fov.fov,
717
+ roiPosition: fov.roiPosition,
718
+ timestamp: Int(Date().timeIntervalSince1970 * 1000)
719
+ )
720
+ }
721
+
722
+ /// Restores the persistent base FOV without requiring a settings acknowledgement.
723
+ public func restoreLegacyCameraFov() throws {
724
+ try DeviceManager.shared.restoreLegacyCameraFovSetting()
725
+ }
726
+
727
+ public func setCameraFovOverride(
728
+ leaseId: String,
729
+ fov: CameraFov,
730
+ ttlMs: Int
731
+ ) async throws -> CameraFovResult {
732
+ let ack = try await performSettingsCommand(
733
+ setting: "camera_fov_override",
734
+ updateStore: { _ in },
735
+ send: { requestId in
736
+ try DeviceManager.shared.sendCameraFovOverride(
737
+ requestId: requestId,
738
+ leaseId: leaseId,
739
+ fov: fov.fov,
740
+ roiPosition: fov.roiPosition.rawValue,
741
+ ttlMs: ttlMs
742
+ )
743
+ }
744
+ )
745
+ return try CameraFovResult.from(ack: ack, fallback: fov)
746
+ }
747
+
748
+ public func releaseCameraFovOverride(leaseId: String) async throws -> SettingsAckEvent {
749
+ try await performSettingsCommand(
750
+ setting: "camera_fov_override",
751
+ updateStore: { _ in },
752
+ send: { requestId in
753
+ try DeviceManager.shared.releaseCameraFovOverride(
754
+ requestId: requestId,
755
+ leaseId: leaseId
756
+ )
757
+ }
758
+ )
759
+ }
760
+
761
+ public func setCameraTuningConfig(anrOn: Bool, gainOn: Bool) async throws -> SettingsAckEvent {
762
+ return try await performSettingsCommand(
763
+ setting: "camera_tuning",
764
+ updateStore: { _ in },
765
+ send: { requestId in
766
+ try DeviceManager.shared.sendCameraTuningConfig(
767
+ requestId: requestId,
768
+ anrOn: anrOn,
769
+ gainOn: gainOn
770
+ )
771
+ }
772
+ )
773
+ }
774
+
775
+ public func setMicState(
776
+ enabled: Bool,
777
+ useGlassesMic: Bool = true,
778
+ sendTranscript: Bool = false,
779
+ sendLc3Data: Bool = false
780
+ ) {
781
+ if enabled {
782
+ DeviceStore.shared.apply(
783
+ ObservableStore.bluetoothCategory,
784
+ "preferred_mic",
785
+ useGlassesMic ? MicPreference.glasses.rawValue : MicPreference.phone.rawValue
786
+ )
787
+ }
788
+ applyMicState(
789
+ sendPcmData: enabled,
790
+ sendTranscript: enabled && sendTranscript,
791
+ sendLc3Data: enabled && sendLc3Data
792
+ )
793
+ }
794
+
795
+ private func applyMicState(
796
+ sendPcmData: Bool,
797
+ sendTranscript: Bool,
798
+ sendLc3Data: Bool
799
+ ) {
800
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "should_send_pcm", sendPcmData)
801
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "should_send_lc3", sendLc3Data)
802
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "should_send_transcript", sendTranscript)
803
+ DeviceManager.shared.setMicState()
804
+ }
805
+
806
+ public func setPreferredMic(_ preferredMic: MicPreference) {
807
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "preferred_mic", preferredMic.rawValue)
808
+ }
809
+
810
+ public func setOwnAppAudioPlaying(_ playing: Bool) {
811
+ PhoneAudioMonitor.getInstance().setOwnAppAudioPlaying(playing)
812
+ }
813
+
814
+ public func getGlassesMediaVolume() async throws -> GlassesMediaVolumeGetResult {
815
+ try GlassesMediaVolumeGetResult(values: await DeviceManager.shared.getGlassesMediaVolume())
816
+ }
817
+
818
+ public func setGlassesMediaVolume(_ level: Int) async throws -> GlassesMediaVolumeSetResult {
819
+ guard (0 ... 15).contains(level) else {
820
+ throw BluetoothSdkError(
821
+ code: "invalid_volume_level",
822
+ message: "Glasses media volume must be between 0 and 15."
823
+ )
824
+ }
825
+ return try GlassesMediaVolumeSetResult(values: await DeviceManager.shared.setGlassesMediaVolume(level: level))
826
+ }
827
+
828
+ public func requestWifiScan() async throws -> [WifiScanResult] {
829
+ if let existing = wifiScanTask {
830
+ // Join the in-flight scan instead of failing with request_in_flight;
831
+ // the scan screen auto-starts a scan on mount and can be pushed twice.
832
+ return try await existing.value
833
+ }
834
+ let pending = PendingResponse<[WifiScanResult]>(operation: "WiFi scan request")
835
+ let request = PendingWifiScan(pending: pending, scanId: "scan-\(UUID().uuidString)")
836
+ pendingWifiScan = request
837
+ // Claim the scan-results store for this scan before the command goes out,
838
+ // so a delayed chunk from an older, abandoned scan can no longer mutate it.
839
+ Bridge.claimWifiScanResults(scanId: request.scanId)
840
+ DeviceManager.shared.requestWifiScan(scanId: request.scanId)
841
+ let task = Task { @MainActor [weak self] () async throws -> [WifiScanResult] in
842
+ defer {
843
+ if let self {
844
+ if self.pendingWifiScan === request {
845
+ self.pendingWifiScan = nil
846
+ }
847
+ self.wifiScanTask = nil
848
+ }
849
+ }
850
+ do {
851
+ // The glasses wait up to 15s for scan-results broadcasts before sending
852
+ // scan_complete, so give them longer than that before falling back.
853
+ return try await pending.wait(timeoutMs: BluetoothSDK.wifiScanTimeoutMs)
854
+ } catch {
855
+ if (error as? BluetoothSdkError)?.code == "request_timeout",
856
+ !request.latestResults.isEmpty
857
+ {
858
+ return request.latestResults
859
+ }
860
+ throw error
861
+ }
862
+ }
863
+ wifiScanTask = task
864
+ return try await task.value
865
+ }
866
+
867
+ public func sendWifiCredentials(ssid: String, password: String) async throws -> WifiStatusEvent {
868
+ guard pendingWifiStatus == nil else {
869
+ throw BluetoothSdkError(
870
+ code: "request_in_flight",
871
+ message: "A WiFi status command is already waiting for a glasses response."
872
+ )
873
+ }
874
+ let pending = PendingResponse<WifiStatusEvent>(operation: "WiFi connect request")
875
+ pendingWifiStatus = PendingWifiStatusRequest(operation: .connect, ssid: ssid, pending: pending)
876
+ DeviceManager.shared.sendWifiCredentials(ssid, password)
877
+ do {
878
+ let event = try await pending.wait()
879
+ if pendingWifiStatus?.pending === pending {
880
+ pendingWifiStatus = nil
881
+ }
882
+ return event
883
+ } catch {
884
+ if pendingWifiStatus?.pending === pending {
885
+ pendingWifiStatus = nil
886
+ }
887
+ throw error
888
+ }
889
+ }
890
+
891
+ public func forgetWifiNetwork(ssid: String) async throws -> WifiStatusEvent {
892
+ guard pendingWifiStatus == nil else {
893
+ throw BluetoothSdkError(
894
+ code: "request_in_flight",
895
+ message: "A WiFi status command is already waiting for a glasses response."
896
+ )
897
+ }
898
+ let pending = PendingResponse<WifiStatusEvent>(operation: "WiFi forget request")
899
+ pendingWifiStatus = PendingWifiStatusRequest(operation: .forget, ssid: ssid, pending: pending)
900
+ DeviceManager.shared.forgetWifiNetwork(ssid)
901
+ do {
902
+ let event = try await pending.wait()
903
+ if pendingWifiStatus?.pending === pending {
904
+ pendingWifiStatus = nil
905
+ }
906
+ return event
907
+ } catch {
908
+ if pendingWifiStatus?.pending === pending {
909
+ pendingWifiStatus = nil
910
+ }
911
+ throw error
912
+ }
913
+ }
914
+
915
+ public func setHotspotState(enabled: Bool) async throws -> HotspotStatusEvent {
916
+ guard pendingHotspotStatus == nil else {
917
+ throw BluetoothSdkError(
918
+ code: "request_in_flight",
919
+ message: "A hotspot command is already waiting for a glasses response."
920
+ )
921
+ }
922
+ let pending = PendingResponse<HotspotStatusEvent>(
923
+ operation: "hotspot \(enabled ? "enable" : "disable") request"
924
+ )
925
+ pendingHotspotStatus = PendingHotspotStatusRequest(enabled: enabled, pending: pending)
926
+ DeviceManager.shared.setHotspotState(enabled)
927
+ do {
928
+ let event = try await pending.wait()
929
+ if pendingHotspotStatus?.pending === pending {
930
+ pendingHotspotStatus = nil
931
+ }
932
+ return event
933
+ } catch {
934
+ if pendingHotspotStatus?.pending === pending {
935
+ pendingHotspotStatus = nil
936
+ }
937
+ throw error
938
+ }
939
+ }
940
+
941
+ /// Fire-and-forget Mentra Live Wi-Fi ADB toggle. Glasses do not ack this
942
+ /// command, so there is no pending-response wait (unlike hotspot / Wi-Fi forget).
943
+ public func setWifiAdbState(enabled: Bool) async throws {
944
+ try requireGlassesConnected(operation: "toggle Wi-Fi ADB")
945
+ DeviceManager.shared.setWifiAdbState(enabled)
946
+ }
947
+
948
+ func setSystemTime(timestampMs: Int64) {
949
+ DeviceManager.shared.setSystemTime(timestampMs)
950
+ }
951
+
952
+ public func requestPhoto(_ request: PhotoRequest) async throws -> PhotoResponseEvent {
953
+ let routedRequest = nonBlankRequestId(request.requestId).map { request.withRequestId($0) }
954
+ ?? request.withRequestId(generatedCameraRequestId("photo"))
955
+ Bridge.log(
956
+ "NATIVE: PHOTO PIPELINE [3b/6] BluetoothSdk.requestPhoto requestId=\(routedRequest.requestId)"
957
+ )
958
+ let pending = PendingResponse<PhotoResponseEvent>(operation: "photo request \(routedRequest.requestId)")
959
+ pendingPhotoRequests[routedRequest.requestId] = pending
960
+ DeviceManager.shared.requestPhoto(routedRequest)
961
+ do {
962
+ let event = try await pending.wait(timeoutMs: BluetoothSDK.photoRequestTimeoutMs)
963
+ pendingPhotoRequests.removeValue(forKey: routedRequest.requestId)
964
+ return event
965
+ } catch {
966
+ pendingPhotoRequests.removeValue(forKey: routedRequest.requestId)
967
+ throw error
968
+ }
969
+ }
970
+
971
+ public func warmUpCamera(
972
+ requestId: String? = nil,
973
+ size: PhotoSize,
974
+ mode: PhotoMode = .photo,
975
+ exposureTimeNs: Double?,
976
+ durationMs: Int,
977
+ zsl: Bool? = nil,
978
+ mfnr: Bool? = nil
979
+ ) async throws -> CameraStatusEvent {
980
+ let effectiveRequestId = nonBlankRequestId(requestId) ?? generatedCameraRequestId("warm")
981
+ let pending = PendingResponse<CameraStatusEvent>(operation: "camera warm up \(effectiveRequestId)")
982
+ pendingCameraStatusRequests[effectiveRequestId] = pending
983
+ do {
984
+ // Inside the catch so an unsupported-device throw also clears the pending entry.
985
+ try DeviceManager.shared.warmUpCamera(
986
+ requestId: effectiveRequestId,
987
+ size: size,
988
+ mode: mode,
989
+ exposureTimeNs: exposureTimeNs,
990
+ durationMs: durationMs,
991
+ zsl: zsl,
992
+ mfnr: mfnr
993
+ )
994
+ let event = try await pending.wait()
995
+ pendingCameraStatusRequests.removeValue(forKey: effectiveRequestId)
996
+ return event
997
+ } catch {
998
+ pendingCameraStatusRequests.removeValue(forKey: effectiveRequestId)
999
+ throw error
1000
+ }
1001
+ }
1002
+
1003
+ public func stopCameraWarmUp(requestId: String) throws {
1004
+ guard let effectiveRequestId = nonBlankRequestId(requestId) else {
1005
+ throw BluetoothSdkError(
1006
+ code: "invalid_request", message: "A warm-up request ID is required."
1007
+ )
1008
+ }
1009
+ try DeviceManager.shared.stopCameraWarmUp(requestId: effectiveRequestId)
1010
+ }
1011
+
1012
+ public func queryGalleryStatus() async throws -> GalleryStatusEvent {
1013
+ if pendingGalleryStatus != nil {
1014
+ throw BluetoothSdkError(
1015
+ code: "request_in_flight",
1016
+ message: "A gallery status query is already waiting for a glasses response."
1017
+ )
1018
+ }
1019
+ let pending = PendingResponse<GalleryStatusEvent>(operation: "gallery status query")
1020
+ pendingGalleryStatus = pending
1021
+ DeviceManager.shared.queryGalleryStatus()
1022
+ do {
1023
+ let event = try await pending.wait()
1024
+ if pendingGalleryStatus === pending {
1025
+ pendingGalleryStatus = nil
1026
+ }
1027
+ return event
1028
+ } catch {
1029
+ if pendingGalleryStatus === pending {
1030
+ pendingGalleryStatus = nil
1031
+ }
1032
+ throw error
1033
+ }
1034
+ }
1035
+
1036
+ public func startStream(_ request: StreamRequest) async throws -> StreamStatusEvent {
1037
+ try await startStream(request, startSdkKeepAlive: true)
1038
+ }
1039
+
1040
+ func startExternallyManagedStream(_ request: StreamRequest) async throws -> StreamStatusEvent {
1041
+ try await startStream(request, startSdkKeepAlive: false)
1042
+ }
1043
+
1044
+ private func startStream(_ request: StreamRequest, startSdkKeepAlive: Bool) async throws -> StreamStatusEvent {
1045
+ var values = request.values
1046
+ let streamId = stringValue(values, "streamId").flatMap { $0.isEmpty ? nil : $0 } ?? "sdk-\(UUID().uuidString)"
1047
+ values["streamId"] = streamId
1048
+ let pending = PendingResponse<StreamStatusEvent>(operation: "start stream \(streamId)")
1049
+ // seq assignment through the BLE hand-off must stay await-free so this
1050
+ // MainActor turn is one critical section: rejectPreemptedStreamStarts
1051
+ // only works if seq order equals the order the commands reach the
1052
+ // glasses' FIFO.
1053
+ streamStartSeq += 1
1054
+ pendingStreamStarts[streamId] = PendingStreamStart(seq: streamStartSeq, pending: pending)
1055
+ stopStreamKeepAliveMonitor()
1056
+ DeviceManager.shared.startStream(values)
1057
+ do {
1058
+ let event = try await pending.wait(timeoutMs: 30000)
1059
+ pendingStreamStarts.removeValue(forKey: streamId)
1060
+ if startSdkKeepAlive {
1061
+ startStreamKeepAliveMonitor(
1062
+ streamId: streamId,
1063
+ intervalSeconds: Self.defaultStreamKeepAliveIntervalSeconds
1064
+ )
1065
+ }
1066
+ return event
1067
+ } catch {
1068
+ pendingStreamStarts.removeValue(forKey: streamId)
1069
+ throw error
1070
+ }
1071
+ }
1072
+
1073
+ func sendExternallyManagedStreamKeepAlive(_ request: StreamKeepAliveRequest) {
1074
+ DeviceManager.shared.keepStreamAlive(request.values)
1075
+ }
1076
+
1077
+ public func rgbLedControl(_ request: RgbLedRequest) async throws -> RgbLedControlResponseEvent {
1078
+ let pending = PendingResponse<RgbLedControlResponseEvent>(operation: "RGB LED command \(request.requestId)")
1079
+ pendingRgbLedRequests[request.requestId] = pending
1080
+ DeviceManager.shared.rgbLedControl(
1081
+ requestId: request.requestId,
1082
+ packageName: request.packageName,
1083
+ action: request.action.rawValue,
1084
+ color: request.color?.rawValue,
1085
+ onDurationMs: request.onDurationMs,
1086
+ offDurationMs: request.offDurationMs,
1087
+ count: request.count
1088
+ )
1089
+ do {
1090
+ let event = try await pending.wait()
1091
+ pendingRgbLedRequests.removeValue(forKey: request.requestId)
1092
+ return event
1093
+ } catch {
1094
+ pendingRgbLedRequests.removeValue(forKey: request.requestId)
1095
+ throw error
1096
+ }
1097
+ }
1098
+
1099
+ public func stopStream() async throws -> StreamStatusEvent {
1100
+ guard pendingStreamStop == nil else {
1101
+ throw BluetoothSdkError(
1102
+ code: "request_in_flight",
1103
+ message: "A stream stop command is already waiting for a glasses response."
1104
+ )
1105
+ }
1106
+ let pending = PendingResponse<StreamStatusEvent>(operation: "stop stream")
1107
+ // The seq draw and the BLE hand-off stay in this single await-free
1108
+ // MainActor turn, sharing startStream's ordering critical section:
1109
+ // the stop takes its own slot in the send order, so its ack can
1110
+ // separate the pending starts the glasses consumed before it (lower
1111
+ // seq) from starts sent after it (higher seq).
1112
+ streamStartSeq += 1
1113
+ pendingStreamStop = (streamId: activeStreamKeepAlive?.streamId, seq: streamStartSeq, pending: pending)
1114
+ stopStreamKeepAliveMonitor()
1115
+ DeviceManager.shared.stopStream()
1116
+ do {
1117
+ let event = try await pending.wait(timeoutMs: 15000)
1118
+ if pendingStreamStop?.pending === pending {
1119
+ pendingStreamStop = nil
1120
+ }
1121
+ return event
1122
+ } catch {
1123
+ if pendingStreamStop?.pending === pending {
1124
+ pendingStreamStop = nil
1125
+ }
1126
+ throw error
1127
+ }
1128
+ }
1129
+
1130
+ public func startVideoRecording(_ request: VideoRecordingRequest) async throws -> VideoRecordingStatusEvent {
1131
+ guard !request.requestId.isEmpty else {
1132
+ throw BluetoothSdkError(code: "missing_request_id", message: "requestId is required to start video recording.")
1133
+ }
1134
+ try requireGlassesConnected(operation: "start video recording")
1135
+ let pending = PendingResponse<VideoRecordingStatusEvent>(
1136
+ operation: "start video recording \(request.requestId)"
1137
+ )
1138
+ guard pendingVideoRecordingRequests[request.requestId] == nil else {
1139
+ throw BluetoothSdkError(
1140
+ code: "request_in_flight",
1141
+ message: "A video recording command is already waiting for requestId \(request.requestId)."
1142
+ )
1143
+ }
1144
+ pendingVideoRecordingRequests[request.requestId] = PendingVideoRecordingRequest(
1145
+ expectedStatus: "recording_started",
1146
+ pending: pending
1147
+ )
1148
+ DeviceManager.shared.startVideoRecording(
1149
+ request.requestId,
1150
+ request.save,
1151
+ request.sound,
1152
+ request.width,
1153
+ request.height,
1154
+ request.fps,
1155
+ request.maxRecordingTimeMinutes
1156
+ )
1157
+ do {
1158
+ let event = try await pending.wait()
1159
+ pendingVideoRecordingRequests.removeValue(forKey: request.requestId)
1160
+ return event
1161
+ } catch {
1162
+ pendingVideoRecordingRequests.removeValue(forKey: request.requestId)
1163
+ throw error
1164
+ }
1165
+ }
1166
+
1167
+ public func stopVideoRecording(
1168
+ requestId: String, webhookUrl: String? = nil, authToken: String? = nil
1169
+ ) async throws -> VideoRecordingStatusEvent {
1170
+ guard !requestId.isEmpty else {
1171
+ throw BluetoothSdkError(code: "missing_request_id", message: "requestId is required to stop video recording.")
1172
+ }
1173
+ try requireGlassesConnected(operation: "stop video recording")
1174
+ let pending = PendingResponse<VideoRecordingStatusEvent>(operation: "stop video recording \(requestId)")
1175
+ guard pendingVideoRecordingRequests[requestId] == nil else {
1176
+ throw BluetoothSdkError(
1177
+ code: "request_in_flight",
1178
+ message: "A video recording command is already waiting for requestId \(requestId)."
1179
+ )
1180
+ }
1181
+ let waitForUpload = !(webhookUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
1182
+ pendingVideoRecordingRequests[requestId] = PendingVideoRecordingRequest(
1183
+ expectedStatus: "recording_stopped",
1184
+ pending: pending,
1185
+ waitForUpload: waitForUpload
1186
+ )
1187
+ DeviceManager.shared.stopVideoRecording(requestId, webhookUrl, authToken)
1188
+ do {
1189
+ let timeoutMs = waitForUpload ? videoUploadStopTimeoutMs : 15000
1190
+ let event = try await pending.wait(timeoutMs: timeoutMs)
1191
+ pendingVideoRecordingRequests.removeValue(forKey: requestId)
1192
+ return event
1193
+ } catch {
1194
+ pendingVideoRecordingRequests.removeValue(forKey: requestId)
1195
+ throw error
1196
+ }
1197
+ }
1198
+
1199
+ public func queryVideoRecordingStatus(requestId: String) async throws -> VideoRecordingStatusEvent {
1200
+ guard !requestId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
1201
+ throw BluetoothSdkError(
1202
+ code: "missing_request_id",
1203
+ message: "requestId is required to query video recording status."
1204
+ )
1205
+ }
1206
+ try requireGlassesConnected(operation: "query video recording status")
1207
+ let pending = PendingResponse<VideoRecordingStatusEvent>(operation: "query video recording status \(requestId)")
1208
+ guard pendingVideoRecordingRequests[requestId] == nil else {
1209
+ throw BluetoothSdkError(
1210
+ code: "request_in_flight",
1211
+ message: "A video recording command is already waiting for requestId \(requestId)."
1212
+ )
1213
+ }
1214
+ pendingVideoRecordingRequests[requestId] = PendingVideoRecordingRequest(
1215
+ expectedStatus: "recording_status",
1216
+ pending: pending
1217
+ )
1218
+ DeviceManager.shared.queryVideoRecordingStatus(requestId)
1219
+ do {
1220
+ let event = try await pending.wait()
1221
+ pendingVideoRecordingRequests.removeValue(forKey: requestId)
1222
+ return event
1223
+ } catch {
1224
+ pendingVideoRecordingRequests.removeValue(forKey: requestId)
1225
+ throw error
1226
+ }
1227
+ }
1228
+
1229
+ public func requestVersionInfo() async throws -> VersionInfoResult {
1230
+ guard pendingVersionInfo == nil else {
1231
+ throw BluetoothSdkError(
1232
+ code: "request_in_flight",
1233
+ message: "A version info request is already waiting for a glasses response."
1234
+ )
1235
+ }
1236
+ let pending = PendingResponse<VersionInfoResult>(operation: "version info request")
1237
+ pendingVersionInfo = pending
1238
+ DeviceManager.shared.requestVersionInfo()
1239
+ do {
1240
+ let status = try await pending.wait()
1241
+ if pendingVersionInfo === pending {
1242
+ pendingVersionInfo = nil
1243
+ }
1244
+ return status
1245
+ } catch {
1246
+ if pendingVersionInfo === pending {
1247
+ pendingVersionInfo = nil
1248
+ }
1249
+ throw error
1250
+ }
1251
+ }
1252
+
1253
+ public func availableG2FirmwareArtifacts() throws -> [[String: Any]] {
1254
+ try requireGlassesConnected(operation: "list G2 firmware")
1255
+ return try DeviceManager.shared.availableG2FirmwareArtifacts()
1256
+ }
1257
+
1258
+ public func startG2FirmwareFlash(target: String) async throws -> [String: Any] {
1259
+ try requireGlassesConnected(operation: "flash G2 firmware")
1260
+ return try await DeviceManager.shared.startG2FirmwareFlash(target: target)
1261
+ }
1262
+
1263
+ /// Select the HTTP(S) manifest used by subsequent OTA checks and installs.
1264
+ public func setOtaVersionUrl(_ otaVersionUrl: String) throws {
1265
+ configuredOtaVersionUrl = try OtaManifestChecker.normalizeHttpUrl(otaVersionUrl)
1266
+ }
1267
+
1268
+ /// Return the configured OTA manifest, or this SDK release's default manifest.
1269
+ public func getOtaVersionUrl() throws -> String {
1270
+ try configuredOtaVersionUrl ?? OtaManifestDefaults.defaultOtaVersionUrl()
1271
+ }
1272
+
1273
+ /// Fetch the configured OTA manifest and return whether any ASG/BES/MTK update is available.
1274
+ public func checkForOtaUpdate() async throws -> Bool {
1275
+ let status = await getFreshGlassesStatus()
1276
+ guard status.connected else {
1277
+ throw BluetoothSdkError(
1278
+ code: "glasses_not_connected",
1279
+ message: "Cannot check OTA update because glasses are not connected."
1280
+ )
1281
+ }
1282
+ guard !status.buildNumber.isEmpty else {
1283
+ throw BluetoothSdkError(
1284
+ code: "missing_glasses_version",
1285
+ message: "Cannot check OTA update because glasses build number is unavailable."
1286
+ )
1287
+ }
1288
+
1289
+ let manifestUrl = try resolveOtaVersionUrl(status: status)
1290
+ let manifest = try await OtaManifestChecker.fetch(manifestUrl)
1291
+ let otaStatus = try await waitForOtaManifestStatus(status, manifest: manifest)
1292
+ return try OtaManifestChecker.hasUpdate(
1293
+ currentBuildNumber: otaStatus.buildNumber,
1294
+ currentMtkVersion: otaStatus.mtkFirmwareVersion,
1295
+ currentBesVersion: otaStatus.besFirmwareVersion,
1296
+ manifest: manifest
1297
+ )
1298
+ }
1299
+
1300
+ /// Return bundled release changelogs crossed between two coordinated product versions, newest first.
1301
+ public func getReleaseChangelogs(
1302
+ fromVersion: String? = nil,
1303
+ toVersion: String? = nil
1304
+ ) throws -> [ReleaseChangelog] {
1305
+ try ReleaseChangelogCatalog.select(fromVersion: fromVersion, toVersion: toVersion)
1306
+ }
1307
+
1308
+ /// Ask connected Mentra Live glasses to report the current OTA install/session status.
1309
+ private func queryOtaStatus() async throws -> OtaQueryResult {
1310
+ try await performOtaQuery(operation: "OTA status query") {
1311
+ DeviceManager.shared.sendOtaQueryStatus()
1312
+ }
1313
+ }
1314
+
1315
+ private func performOtaQuery(
1316
+ operation: String,
1317
+ sendRequest: () -> Void
1318
+ ) async throws -> OtaQueryResult {
1319
+ if pendingOtaQuery != nil {
1320
+ throw BluetoothSdkError(
1321
+ code: "request_in_flight",
1322
+ message: "An OTA status query is already waiting for a glasses response."
1323
+ )
1324
+ }
1325
+ let pending = PendingResponse<OtaQueryResult>(operation: operation)
1326
+ pendingOtaQuery = pending
1327
+ sendRequest()
1328
+ do {
1329
+ let result = try await pending.wait()
1330
+ if pendingOtaQuery === pending {
1331
+ pendingOtaQuery = nil
1332
+ }
1333
+ return result
1334
+ } catch {
1335
+ if pendingOtaQuery === pending {
1336
+ pendingOtaQuery = nil
1337
+ }
1338
+ throw error
1339
+ }
1340
+ }
1341
+
1342
+ /// Start the OTA flow after your app has presented the available update to the user.
1343
+ public func startOtaUpdate() async throws -> OtaStartAckEvent {
1344
+ let status = await getFreshGlassesStatus()
1345
+ let otaVersionUrl = try resolveOtaVersionUrl(status: status)
1346
+ return try await startOtaUpdate(otaVersionUrl: otaVersionUrl)
1347
+ }
1348
+
1349
+ private func startOtaCommand(otaVersionUrl: String) async throws -> OtaStartAckEvent {
1350
+ if pendingOtaStart != nil {
1351
+ throw BluetoothSdkError(
1352
+ code: "request_in_flight",
1353
+ message: "An OTA start command is already waiting for a glasses response."
1354
+ )
1355
+ }
1356
+ let pending = PendingResponse<OtaStartAckEvent>(operation: "OTA start command")
1357
+ pendingOtaStart = pending
1358
+ DeviceManager.shared.sendOtaStart(otaVersionUrl: otaVersionUrl)
1359
+ do {
1360
+ let event = try await pending.wait()
1361
+ if pendingOtaStart === pending {
1362
+ pendingOtaStart = nil
1363
+ }
1364
+ return event
1365
+ } catch {
1366
+ if pendingOtaStart === pending {
1367
+ pendingOtaStart = nil
1368
+ }
1369
+ throw error
1370
+ }
1371
+ }
1372
+
1373
+ func startOtaUpdate(otaVersionUrl: String) async throws -> OtaStartAckEvent {
1374
+ try await startOtaCommand(otaVersionUrl: otaVersionUrl)
1375
+ }
1376
+
1377
+ func sendOtaQueryStatus() async throws -> OtaQueryResult {
1378
+ try await queryOtaStatus()
1379
+ }
1380
+
1381
+ func startAr99OtaFromFile(_ path: String) throws -> Bool {
1382
+ try requireGlassesConnected(operation: "start AR99 OTA")
1383
+ return try DeviceManager.shared.startAr99OtaFromFile(path)
1384
+ }
1385
+
1386
+ func cancelAr99Ota() {
1387
+ DeviceManager.shared.cancelAr99Ota()
1388
+ }
1389
+
1390
+ func sendAr99FactoryReset() throws {
1391
+ try requireGlassesConnected(operation: "factory reset AR99")
1392
+ try DeviceManager.shared.sendAr99FactoryReset()
1393
+ }
1394
+
1395
+ private func getFreshGlassesStatus() async -> GlassesStatus {
1396
+ let status = glassesStatus
1397
+ if !status.connected || !status.buildNumber.isEmpty {
1398
+ return status
1399
+ }
1400
+
1401
+ do {
1402
+ let versionInfo = try await requestVersionInfo()
1403
+ let values = status.values.merging(versionInfo.dictionary) { existing, new in
1404
+ if let newString = new as? String, newString.isEmpty {
1405
+ return existing
1406
+ }
1407
+ return new
1408
+ }
1409
+ return GlassesStatus(values: values)
1410
+ } catch {
1411
+ return status
1412
+ }
1413
+ }
1414
+
1415
+ private func waitForOtaManifestStatus(_ initialStatus: GlassesStatus, manifest: OtaManifest) async throws -> GlassesStatus {
1416
+ var status = initialStatus
1417
+ if OtaManifestChecker.hasBesFirmware(manifest), status.besFirmwareVersion.isEmpty {
1418
+ status = await waitForGlassesStatus(status, timeoutMs: Self.otaBesVersionWaitMs) {
1419
+ !$0.connected || !$0.besFirmwareVersion.isEmpty
1420
+ }
1421
+ }
1422
+
1423
+ if OtaManifestChecker.hasMtkPatches(manifest), status.mtkFirmwareVersion.isEmpty {
1424
+ status = await waitForGlassesStatus(status, timeoutMs: Self.otaMtkVersionWaitMs) {
1425
+ !$0.connected || !$0.mtkFirmwareVersion.isEmpty
1426
+ }
1427
+ }
1428
+
1429
+ guard status.connected else {
1430
+ throw BluetoothSdkError(
1431
+ code: "glasses_not_connected",
1432
+ message: "Cannot check OTA update because glasses disconnected."
1433
+ )
1434
+ }
1435
+ return status
1436
+ }
1437
+
1438
+ private func waitForGlassesStatus(
1439
+ _ initialStatus: GlassesStatus,
1440
+ timeoutMs: Int,
1441
+ isReady: (GlassesStatus) -> Bool
1442
+ ) async -> GlassesStatus {
1443
+ let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1000)
1444
+ var status = initialStatus
1445
+ while Date() < deadline {
1446
+ status = glassesStatus
1447
+ if isReady(status) {
1448
+ return status
1449
+ }
1450
+
1451
+ let remainingMs = max(0, Int(deadline.timeIntervalSinceNow * 1000))
1452
+ let sleepMs = min(Self.otaVersionPollMs, remainingMs)
1453
+ if sleepMs <= 0 {
1454
+ break
1455
+ }
1456
+ try? await Task.sleep(nanoseconds: UInt64(sleepMs) * 1_000_000)
1457
+ }
1458
+ return glassesStatus
1459
+ }
1460
+
1461
+ private func resolveOtaVersionUrl(status: GlassesStatus) throws -> String {
1462
+ let deviceUrl = status.otaVersionUrl.trimmingCharacters(in: .whitespacesAndNewlines)
1463
+ if isLegacyAsgOtaStartBuild(status.buildNumber) {
1464
+ return OtaManifestDefaults.legacyProdOtaVersionUrl
1465
+ }
1466
+ // SDK consumers are pinned to the manifest built for their SDK version.
1467
+ // A future glasses-advertised URL should not silently change that pairing.
1468
+ if let configuredOtaVersionUrl {
1469
+ return configuredOtaVersionUrl
1470
+ }
1471
+ return try OtaManifestDefaults.defaultOtaVersionUrl()
1472
+ }
1473
+
1474
+ private func isLegacyAsgOtaStartBuild(_ buildNumber: String) -> Bool {
1475
+ guard let parsed = Int(buildNumber) else { return false }
1476
+ return parsed < 39
1477
+ }
1478
+
1479
+ func sendShutdown() {
1480
+ DeviceManager.shared.sendShutdown()
1481
+ }
1482
+
1483
+ func sendReboot() {
1484
+ DeviceManager.shared.sendReboot()
1485
+ }
1486
+
1487
+ func sendIncidentId(_ incidentId: String, apiBaseUrl: String? = nil) {
1488
+ DeviceManager.shared.sendIncidentId(incidentId, apiBaseUrl: apiBaseUrl)
1489
+ }
1490
+
1491
+ public func invalidate() {
1492
+ stopStreamKeepAliveMonitor()
1493
+ if let bluetoothAvailabilityListenerId {
1494
+ BluetoothAvailability.shared.removeStateListener(bluetoothAvailabilityListenerId)
1495
+ self.bluetoothAvailabilityListenerId = nil
1496
+ }
1497
+ if let bridgeEventSinkId {
1498
+ Bridge.removeEventSink(bridgeEventSinkId)
1499
+ self.bridgeEventSinkId = nil
1500
+ }
1501
+ if let storeListenerId {
1502
+ DeviceStore.shared.store.removeListener(storeListenerId)
1503
+ self.storeListenerId = nil
1504
+ }
1505
+ delegate = nil
1506
+ }
1507
+
1508
+ private func handleBluetoothAvailability(_ state: CBManagerState) {
1509
+ switch state {
1510
+ case .poweredOff, .resetting, .unauthorized, .unsupported:
1511
+ handleBluetoothUnavailable()
1512
+ case .poweredOn:
1513
+ handleBluetoothRestored()
1514
+ case .unknown:
1515
+ break
1516
+ @unknown default:
1517
+ handleBluetoothUnavailable()
1518
+ }
1519
+ }
1520
+
1521
+ private func handleBluetoothUnavailable() {
1522
+ cancelActiveScanSessions(reason: .cancelled)
1523
+ clearBluetoothDiscoveryState()
1524
+ disconnectActiveConnections()
1525
+ }
1526
+
1527
+ private func disconnectActiveConnections() {
1528
+ if glassesStatus.controllerConnected {
1529
+ DeviceManager.shared.disconnectController()
1530
+ shouldRestoreControllerOnBluetoothRestore = true
1531
+ }
1532
+ if glassesStatus.deviceModel == DeviceTypes.SIMULATED
1533
+ || DeviceManager.shared.sgc?.type.contains(DeviceTypes.SIMULATED) == true
1534
+ {
1535
+ return
1536
+ }
1537
+ if glassesStatus.connected || glassesStatus.connectionState != .disconnected {
1538
+ DeviceManager.shared.disconnect()
1539
+ shouldRestoreGlassesOnBluetoothRestore = true
1540
+ }
1541
+ }
1542
+
1543
+ /// Reconnect only what `handleBluetoothUnavailable` tore down, never a
1544
+ /// connection the user closed themselves (explicit connect/disconnect
1545
+ /// calls clear the restore intent).
1546
+ private func handleBluetoothRestored() {
1547
+ let restoreGlasses = shouldRestoreGlassesOnBluetoothRestore
1548
+ let restoreController = shouldRestoreControllerOnBluetoothRestore
1549
+ clearBluetoothRestoreIntent()
1550
+
1551
+ if restoreGlasses, !glassesStatus.connected, glassesStatus.connectionState == .disconnected {
1552
+ DeviceManager.shared.connectDefault() // also restores the controller
1553
+ } else if restoreController, !glassesStatus.controllerConnected {
1554
+ DeviceManager.shared.connectDefaultController()
1555
+ }
1556
+ }
1557
+
1558
+ private func clearBluetoothRestoreIntent() {
1559
+ shouldRestoreGlassesOnBluetoothRestore = false
1560
+ shouldRestoreControllerOnBluetoothRestore = false
1561
+ }
1562
+
1563
+ private func clearBluetoothDiscoveryState() {
1564
+ discoveredDeviceNames.removeAll()
1565
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searching", false)
1566
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searchingController", false)
1567
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searchResults", [] as [[String: Any]])
1568
+ }
1569
+
1570
+ private func cancelActiveScanSessions(reason: ScanStopReason) {
1571
+ let ids = Array(activeScanSessions.keys)
1572
+ guard !ids.isEmpty else {
1573
+ if bluetoothStatus.searching || bluetoothStatus.searchingController {
1574
+ stopScan(reason: reason)
1575
+ }
1576
+ return
1577
+ }
1578
+ for (index, id) in ids.enumerated() {
1579
+ // Stop the underlying scan once (first session); the rest only
1580
+ // complete their callbacks.
1581
+ finishScanSession(id, reason: reason, shouldStopScan: index == 0)
1582
+ }
1583
+ }
1584
+
1585
+ private func startStreamKeepAliveMonitor(streamId: String, intervalSeconds requestedIntervalSeconds: Int) {
1586
+ let intervalSeconds = requestedIntervalSeconds > 0 ? requestedIntervalSeconds : Self.defaultStreamKeepAliveIntervalSeconds
1587
+ let tracker = ActiveStreamKeepAlive(streamId: streamId, intervalSeconds: intervalSeconds)
1588
+ activeStreamKeepAlive = tracker
1589
+ sendNextStreamKeepAlive(for: tracker)
1590
+ }
1591
+
1592
+ private func stopStreamKeepAliveMonitor() {
1593
+ activeStreamKeepAlive?.task?.cancel()
1594
+ activeStreamKeepAlive = nil
1595
+ }
1596
+
1597
+ private func sendNextStreamKeepAlive(for tracker: ActiveStreamKeepAlive) {
1598
+ guard activeStreamKeepAlive === tracker else { return }
1599
+
1600
+ if tracker.armed, tracker.pendingAckId != nil {
1601
+ tracker.missedAckCount += 1
1602
+ if tracker.missedAckCount >= 3 {
1603
+ activeStreamKeepAlive = nil
1604
+ tracker.task?.cancel()
1605
+ let event = StreamStatusEvent(
1606
+ status: .error(
1607
+ streamId: tracker.streamId,
1608
+ errorDetails: "Stream keep-alive timed out after \(tracker.missedAckCount) missed ACKs",
1609
+ timestamp: Int(Date().timeIntervalSince1970 * 1000),
1610
+ resolvedConfig: nil
1611
+ )
1612
+ )
1613
+ delegate?.bluetoothSDK(self, didReceive: .streamStatus(event))
1614
+ stopStreamKeepAliveMonitor()
1615
+ DeviceManager.shared.stopStream()
1616
+ return
1617
+ }
1618
+ }
1619
+
1620
+ let ackId = "ack-\(Int(Date().timeIntervalSince1970 * 1000))"
1621
+ tracker.pendingAckId = ackId
1622
+ DeviceManager.shared.keepStreamAlive(
1623
+ StreamKeepAliveRequest(streamId: tracker.streamId, ackId: ackId).values
1624
+ )
1625
+
1626
+ tracker.task?.cancel()
1627
+ tracker.task = Task { @MainActor [weak self, weak tracker] in
1628
+ guard let tracker else { return }
1629
+ try? await Task.sleep(nanoseconds: UInt64(tracker.intervalSeconds) * 1_000_000_000)
1630
+ self?.sendNextStreamKeepAlive(for: tracker)
1631
+ }
1632
+ }
1633
+
1634
+ private func handleStreamKeepAliveAck(_ event: KeepAliveAckEvent) -> Bool {
1635
+ guard let tracker = activeStreamKeepAlive,
1636
+ event.streamId == tracker.streamId,
1637
+ event.ackId == tracker.pendingAckId
1638
+ else {
1639
+ return false
1640
+ }
1641
+ tracker.pendingAckId = nil
1642
+ tracker.missedAckCount = 0
1643
+ return true
1644
+ }
1645
+
1646
+ private func handleStreamStatusForKeepAlive(_ status: StreamStatus) {
1647
+ guard let streamId = status.streamId,
1648
+ activeStreamKeepAlive?.streamId == streamId
1649
+ else {
1650
+ return
1651
+ }
1652
+
1653
+ switch status.state {
1654
+ case .stopped, .stopping, .error, .reconnectFailed:
1655
+ stopStreamKeepAliveMonitor()
1656
+ default:
1657
+ // A non-terminal status means the stream is live or coming up and the glasses can
1658
+ // now ACK; arm the missed-ACK detector from here so a slow startup before the first
1659
+ // ACK can't trip a false keep-alive timeout. On the arming transition, drop any
1660
+ // pre-arm bookkeeping so a stale unacked id can't immediately count as a miss.
1661
+ if let tracker = activeStreamKeepAlive, !tracker.armed {
1662
+ tracker.armed = true
1663
+ tracker.pendingAckId = nil
1664
+ tracker.missedAckCount = 0
1665
+ }
1666
+ }
1667
+ }
1668
+
1669
+ private func handleStreamStatusForRequests(_ event: StreamStatusEvent) {
1670
+ if let (streamId, start) = matchingStreamStart(for: event) {
1671
+ // Preemption is only proven by statuses the start path emits after
1672
+ // the glasses' stopAllServices has run: initializing, reconnecting,
1673
+ // reconnected, streaming. An id-stamped `error` can be a
1674
+ // command-level preflight rejection (#3488 firmware) emitted
1675
+ // synchronously before stopAllServices, and `stopped` is a
1676
+ // stop-ack for a previous stream — treating either as a winner
1677
+ // could reject an older start that is still healthy.
1678
+ if let eventStreamId = event.streamId, !eventStreamId.isEmpty {
1679
+ switch event.state {
1680
+ case .initializing, .reconnecting, .reconnected, .streaming:
1681
+ rejectPreemptedStreamStarts(winnerSeq: start.seq)
1682
+ default:
1683
+ break
1684
+ }
1685
+ }
1686
+ switch event.state {
1687
+ // `reconnected` also proves the stream is live: when an async start
1688
+ // failure triggers the glasses' publisher retry, a successful retry
1689
+ // reports `reconnected` instead of `streaming`.
1690
+ case .streaming, .reconnected:
1691
+ pendingStreamStarts.removeValue(forKey: streamId)
1692
+ start.pending.resolve(event)
1693
+ case .reconnectFailed, .stopped:
1694
+ pendingStreamStarts.removeValue(forKey: streamId)
1695
+ start.pending.reject(streamStatusError(start.lastError ?? event, code: "stream_start_failed"))
1696
+ case .error:
1697
+ if let eventStreamId = event.streamId, !eventStreamId.isEmpty, event.willRetry == true {
1698
+ // The glasses flag errors their publisher will retry with
1699
+ // `willRetry` (emitting side lands in PR #3488), so keep the
1700
+ // start pending for the retry's verdict — `reconnected` or
1701
+ // `streaming` on success, `reconnect_failed` or the
1702
+ // streamId-less `stopped` wind-down (which reports these
1703
+ // stashed details) on failure.
1704
+ start.lastError = event
1705
+ } else {
1706
+ // An id-less error is the glasses' command-level rejection
1707
+ // (missing URL, low battery, no WiFi) emitted before any
1708
+ // publisher starts; an id-carrying error without `willRetry`
1709
+ // is terminal (`camera_busy` emits an error and nothing else).
1710
+ // Old firmware never sends `willRetry`, so its errors always
1711
+ // fail fast here — the shipped pre-#3487 Android behavior.
1712
+ pendingStreamStarts.removeValue(forKey: streamId)
1713
+ start.pending.reject(streamStatusError(event, code: "stream_start_failed"))
1714
+ }
1715
+ default:
1716
+ break
1717
+ }
1718
+ }
1719
+
1720
+ if let stop = pendingStreamStop, streamStatus(event, matches: stop.streamId) {
1721
+ if isAlreadyStoppedStreamStatus(event) {
1722
+ if pendingStreamStop?.pending === stop.pending {
1723
+ pendingStreamStop = nil
1724
+ }
1725
+ rejectStreamStartsSuperseded(byStopSeq: stop.seq)
1726
+ stop.pending.resolve(stoppedStreamEvent(from: event, fallbackStreamId: stop.streamId))
1727
+ } else if event.state == .error || event.state == .reconnectFailed {
1728
+ if pendingStreamStop?.pending === stop.pending {
1729
+ pendingStreamStop = nil
1730
+ }
1731
+ stop.pending.reject(streamStatusError(event, code: "stream_stop_failed"))
1732
+ }
1733
+ }
1734
+ }
1735
+
1736
+ /// The glasses process BLE commands FIFO and every start_stream begins by
1737
+ /// stopping whatever runs, so an id-carrying status proving start X's
1738
+ /// start path ran on the glasses also proves every lower-seq start has
1739
+ /// already been preempted. Their only verdict on current firmware is a
1740
+ /// streamId-less stopped, which the id-less heuristic deliberately
1741
+ /// ignores — without this they would run out the 30s timeout instead of
1742
+ /// failing fast.
1743
+ private func rejectPreemptedStreamStarts(winnerSeq: Int) {
1744
+ for (streamId, start) in pendingStreamStarts where start.seq < winnerSeq {
1745
+ pendingStreamStarts.removeValue(forKey: streamId)
1746
+ start.pending.reject(
1747
+ BluetoothSdkError(
1748
+ code: "stream_preempted",
1749
+ message: "start stream \(streamId) was preempted by a newer start_stream; "
1750
+ + "the glasses run a single stream and the last request wins."
1751
+ )
1752
+ )
1753
+ }
1754
+ }
1755
+
1756
+ /// The glasses process BLE commands FIFO, so this stop's ack also settles
1757
+ /// every start sent before it: whatever those starts brought up (or would
1758
+ /// have brought up) has been stopped, and no further status will arrive
1759
+ /// for them. Without this they would run out the 30s start timeout.
1760
+ /// Starts sent after the stop keep waiting for their own statuses.
1761
+ private func rejectStreamStartsSuperseded(byStopSeq stopSeq: Int) {
1762
+ for (streamId, start) in pendingStreamStarts where start.seq < stopSeq {
1763
+ pendingStreamStarts.removeValue(forKey: streamId)
1764
+ start.pending.reject(
1765
+ BluetoothSdkError(
1766
+ code: "stream_stopped",
1767
+ message: "start stream \(streamId) was superseded by a stop_stream issued after it."
1768
+ )
1769
+ )
1770
+ }
1771
+ }
1772
+
1773
+ private func matchingStreamStart(for event: StreamStatusEvent) -> (String, PendingStreamStart)? {
1774
+ if let streamId = event.streamId, !streamId.isEmpty {
1775
+ guard let start = pendingStreamStarts[streamId] else { return nil }
1776
+ return (streamId, start)
1777
+ }
1778
+ if event.state == .error {
1779
+ // An id-less error is a command-level preflight rejection (missing
1780
+ // URL, low battery, no WiFi) emitted synchronously, while lifecycle
1781
+ // statuses arrive async — so with overlapping starts the wire is
1782
+ // genuinely ambiguous about which start it answers. Attribute it to
1783
+ // the newest pending start: the common case is a later start
1784
+ // failing preflight while an earlier one is already emitting
1785
+ // lifecycle statuses, and at worst this swaps which start carries
1786
+ // the error instead of letting both time out.
1787
+ guard let entry = pendingStreamStarts.max(by: { $0.value.seq < $1.value.seq }) else { return nil }
1788
+ return (entry.key, entry.value)
1789
+ }
1790
+ if pendingStreamStarts.count == 1, let entry = pendingStreamStarts.first {
1791
+ // A streamId-less STOPPED is the glasses' stop-ack for a PREVIOUS
1792
+ // stream (their stop ack carries no streamId), not a verdict on the
1793
+ // pending start — a start_stream that replaces a running publisher
1794
+ // emits exactly this sequence (stopped -> initializing -> streaming).
1795
+ // Attributing it here would reject a start that is about to succeed.
1796
+ // After a stashed `willRetry` error for the pending start, though,
1797
+ // the stopped is the retrying publisher's wind-down (the stop-ack
1798
+ // always precedes any status for the new start), so it is that
1799
+ // start's verdict.
1800
+ if event.state == .stopped, entry.value.lastError == nil {
1801
+ return nil
1802
+ }
1803
+ return (entry.key, entry.value)
1804
+ }
1805
+ return nil
1806
+ }
1807
+
1808
+ private func streamStatus(_ event: StreamStatusEvent, matches streamId: String?) -> Bool {
1809
+ guard let streamId, !streamId.isEmpty else { return true }
1810
+ guard let eventStreamId = event.streamId, !eventStreamId.isEmpty else { return true }
1811
+ return eventStreamId == streamId
1812
+ }
1813
+
1814
+ private func isAlreadyStoppedStreamStatus(_ event: StreamStatusEvent) -> Bool {
1815
+ if event.state == .stopped {
1816
+ return true
1817
+ }
1818
+ guard case let .error(_, errorDetails, _, _) = event.status else {
1819
+ return false
1820
+ }
1821
+ return ["not_streaming", "already_stopped", "not streaming"].contains(errorDetails.lowercased())
1822
+ }
1823
+
1824
+ private func stoppedStreamEvent(from event: StreamStatusEvent, fallbackStreamId: String?) -> StreamStatusEvent {
1825
+ StreamStatusEvent(
1826
+ status: .lifecycle(
1827
+ state: .stopped,
1828
+ streamId: event.streamId ?? fallbackStreamId,
1829
+ timestamp: event.status.timestamp ?? Int(Date().timeIntervalSince1970 * 1000),
1830
+ resolvedConfig: event.resolvedConfig
1831
+ )
1832
+ )
1833
+ }
1834
+
1835
+ private func streamStatusError(_ event: StreamStatusEvent, code: String) -> BluetoothSdkError {
1836
+ let message: String
1837
+ if case let .error(_, errorDetails, _, _) = event.status {
1838
+ message = errorDetails
1839
+ } else {
1840
+ message = "Stream status \(event.state.rawValue)"
1841
+ }
1842
+ return BluetoothSdkError(code: code, message: message)
1843
+ }
1844
+
1845
+ private func handlePhotoResponseForRequests(_ event: PhotoResponseEvent) {
1846
+ guard let pending = pendingPhotoRequests[event.requestId] else { return }
1847
+ switch event.response {
1848
+ case .success:
1849
+ pending.resolve(event)
1850
+ case let .error(_, errorCode, errorMessage, _):
1851
+ pending.reject(
1852
+ BluetoothSdkError(
1853
+ code: errorCode ?? "photo_request_failed",
1854
+ message: errorMessage
1855
+ )
1856
+ )
1857
+ }
1858
+ }
1859
+
1860
+ private func handleCameraStatusForRequests(_ event: CameraStatusEvent) {
1861
+ guard let pending = pendingCameraStatusRequests[event.requestId] else { return }
1862
+ switch event.state.lowercased() {
1863
+ case "ready":
1864
+ pending.resolve(event)
1865
+ case "error":
1866
+ pending.reject(
1867
+ BluetoothSdkError(
1868
+ code: event.errorCode ?? "camera_warm_up_failed",
1869
+ message: event.errorMessage ?? "Camera warm-up failed."
1870
+ )
1871
+ )
1872
+ default:
1873
+ // "warming"/"stopped" are progress updates; leave the pending promise alone.
1874
+ break
1875
+ }
1876
+ }
1877
+
1878
+ private func handleVideoRecordingStatusForRequests(_ event: VideoRecordingStatusEvent) {
1879
+ guard let request = pendingVideoRecordingRequests[event.requestId] else { return }
1880
+ if event.success {
1881
+ if event.status == request.expectedStatus {
1882
+ if request.waitForUpload {
1883
+ request.stoppedEvent = event
1884
+ if request.uploadSucceeded {
1885
+ request.pending.resolve(event)
1886
+ }
1887
+ } else {
1888
+ request.pending.resolve(event)
1889
+ }
1890
+ }
1891
+ } else {
1892
+ request.pending.reject(
1893
+ BluetoothSdkError(
1894
+ code: event.status.isEmpty ? "video_recording_failed" : event.status,
1895
+ message: event.details ?? "Video recording command failed."
1896
+ )
1897
+ )
1898
+ }
1899
+ }
1900
+
1901
+ private func handleMediaUploadForRequests(_ event: MediaUploadEvent) {
1902
+ guard event.isVideo, let request = pendingVideoRecordingRequests[event.requestId], request.waitForUpload else {
1903
+ return
1904
+ }
1905
+ if event.isSuccess {
1906
+ if let stoppedEvent = request.stoppedEvent {
1907
+ request.pending.resolve(stoppedEvent)
1908
+ } else {
1909
+ request.uploadSucceeded = true
1910
+ }
1911
+ } else {
1912
+ request.pending.reject(
1913
+ BluetoothSdkError(
1914
+ code: "video_upload_failed",
1915
+ message: event.errorMessage ?? "Video upload failed."
1916
+ )
1917
+ )
1918
+ }
1919
+ }
1920
+
1921
+ private func handleRgbLedResponseForRequests(_ event: RgbLedControlResponseEvent) {
1922
+ guard let pending = pendingRgbLedRequests[event.requestId] else { return }
1923
+ if event.state == "success" {
1924
+ pending.resolve(event)
1925
+ } else {
1926
+ pending.reject(
1927
+ BluetoothSdkError(
1928
+ code: event.errorCode ?? "rgb_led_control_failed",
1929
+ message: event.errorCode ?? "RGB LED command failed."
1930
+ )
1931
+ )
1932
+ }
1933
+ }
1934
+
1935
+ private func handleSettingsAckForRequests(_ event: SettingsAckEvent) {
1936
+ guard let pending = pendingSettingsRequests[event.requestId] else { return }
1937
+ if isFailureStatus(event.status) {
1938
+ let fallbackSetting = event.setting.isEmpty ? event.requestId : event.setting
1939
+ pending.reject(
1940
+ BluetoothSdkError(
1941
+ code: event.errorCode ?? "\(event.setting.isEmpty ? "settings" : event.setting)_failed",
1942
+ message: event.errorMessage ?? "Settings command \(fallbackSetting) failed."
1943
+ )
1944
+ )
1945
+ } else {
1946
+ pending.resolve(event)
1947
+ }
1948
+ }
1949
+
1950
+ private func isFailureStatus(_ status: String) -> Bool {
1951
+ ["error", "failed", "failure", "rejected"].contains(status.lowercased())
1952
+ }
1953
+
1954
+ private func handleWifiScanResultsForRequests(_ results: [WifiScanResult]) {
1955
+ guard let request = pendingWifiScan else { return }
1956
+ if pendingWifiScan === request {
1957
+ pendingWifiScan = nil
1958
+ }
1959
+ request.pending.resolve(results)
1960
+ }
1961
+
1962
+ /// scanId-echoing glasses: results for another scan are ignored instead of
1963
+ /// resolving the pending request, and matching chunks accumulate until the
1964
+ /// glasses flag the scan complete.
1965
+ private func handleCorrelatedWifiScanChunk(
1966
+ scanId: String,
1967
+ results: [WifiScanResult],
1968
+ scanComplete: Bool
1969
+ ) {
1970
+ guard let request = pendingWifiScan, request.scanId == scanId else { return }
1971
+ for network in results where !request.accumulated.contains(where: { $0.ssid == network.ssid }) {
1972
+ request.accumulated.append(network)
1973
+ }
1974
+ if !request.accumulated.isEmpty {
1975
+ request.latestResults = request.accumulated
1976
+ }
1977
+ guard scanComplete else { return }
1978
+ if pendingWifiScan === request {
1979
+ pendingWifiScan = nil
1980
+ }
1981
+ request.pending.resolve(request.accumulated)
1982
+ }
1983
+
1984
+ private func updateWifiScanLatestResults(_ results: [WifiScanResult]) {
1985
+ guard !results.isEmpty else { return }
1986
+ pendingWifiScan?.latestResults = results
1987
+ }
1988
+
1989
+ private func handleWifiStatusForRequests(_ event: WifiStatusEvent) {
1990
+ guard let request = pendingWifiStatus else { return }
1991
+ // A wifi_status carrying the explicit error field is the glasses' failure
1992
+ // verdict for the in-flight connect: reject now instead of running out the
1993
+ // request timeout. Only the error field counts as failure — the glasses'
1994
+ // connect sequence emits a debounced bare connected=false ~1-2s after
1995
+ // credentials while association is still in progress, and rejecting on that
1996
+ // would kill every connect attempt early.
1997
+ if request.operation == .connect, let error = event.error {
1998
+ if pendingWifiStatus === request {
1999
+ pendingWifiStatus = nil
2000
+ }
2001
+ request.pending.reject(
2002
+ BluetoothSdkError(
2003
+ code: error,
2004
+ message: "Glasses failed to join \"\(request.ssid)\": \(error)"
2005
+ )
2006
+ )
2007
+ return
2008
+ }
2009
+ guard wifiStatusMatches(event.status, request: request) else { return }
2010
+ if pendingWifiStatus === request {
2011
+ pendingWifiStatus = nil
2012
+ }
2013
+ request.pending.resolve(event)
2014
+ }
2015
+
2016
+ private func wifiStatusMatches(_ status: WifiStatus, request: PendingWifiStatusRequest) -> Bool {
2017
+ switch request.operation {
2018
+ case .connect:
2019
+ if case let .connected(ssid, _) = status {
2020
+ return ssid == request.ssid
2021
+ }
2022
+ return false
2023
+ case .forget:
2024
+ switch status {
2025
+ case .disconnected:
2026
+ return true
2027
+ case let .connected(ssid, _):
2028
+ return ssid != request.ssid
2029
+ }
2030
+ }
2031
+ }
2032
+
2033
+ private func handleHotspotStatusForRequests(_ event: HotspotStatusEvent) {
2034
+ guard let request = pendingHotspotStatus else { return }
2035
+ guard hotspotStatusMatches(event.status, enabled: request.enabled) else { return }
2036
+ if pendingHotspotStatus === request {
2037
+ pendingHotspotStatus = nil
2038
+ }
2039
+ request.pending.resolve(event)
2040
+ }
2041
+
2042
+ private func hotspotStatusMatches(_ status: HotspotStatus, enabled: Bool) -> Bool {
2043
+ if enabled {
2044
+ return status.isEnabled
2045
+ }
2046
+ return status == .disabled
2047
+ }
2048
+
2049
+ private func handleHotspotErrorForRequests(_ event: HotspotErrorEvent) {
2050
+ guard let request = pendingHotspotStatus else { return }
2051
+ if pendingHotspotStatus === request {
2052
+ pendingHotspotStatus = nil
2053
+ }
2054
+ request.pending.reject(
2055
+ BluetoothSdkError(
2056
+ code: "hotspot_command_failed",
2057
+ message: event.message ?? "Hotspot command failed."
2058
+ )
2059
+ )
2060
+ }
2061
+
2062
+ private func dispatchStoreUpdate(_ category: String, _ changes: [String: Any]) {
2063
+ switch ObservableStore.normalizeCategory(category) {
2064
+ case "glasses":
2065
+ analytics.observeGlassesStatus(glassesStatus)
2066
+ let nextState = state
2067
+ delegate?.bluetoothSDK(self, didUpdate: nextState)
2068
+ delegate?.bluetoothSDK(self, didUpdateGlasses: nextState.glasses)
2069
+ case ObservableStore.bluetoothCategory:
2070
+ let nextState = state
2071
+ delegate?.bluetoothSDK(self, didUpdate: nextState)
2072
+ delegate?.bluetoothSDK(self, didUpdateSdkState: nextState.sdk)
2073
+ delegate?.bluetoothSDK(self, didUpdateScan: nextState.scan)
2074
+ if !suppressDefaultDeviceEvents && changes.keys.contains(where: { defaultDeviceKeys.contains($0) }) {
2075
+ dispatchDefaultDeviceChanged()
2076
+ }
2077
+ dispatchDiscoveredDevices(changes["searchResults"])
2078
+ dispatchScanResults(changes["searchResults"])
2079
+ default:
2080
+ break
2081
+ }
2082
+ }
2083
+
2084
+ private func dispatchDefaultDeviceChanged() {
2085
+ delegate?.bluetoothSDK(self, didChangeDefaultDevice: currentDefaultDevice())
2086
+ }
2087
+
2088
+ private func finishDefaultDeviceApply(generation: Int) {
2089
+ Task { @MainActor [weak self] in
2090
+ guard let self, generation == self.defaultDeviceApplyGeneration else { return }
2091
+ self.suppressDefaultDeviceEvents = false
2092
+ self.dispatchDefaultDeviceChanged()
2093
+ }
2094
+ }
2095
+
2096
+ private func currentDefaultDevice() -> Device? {
2097
+ let core = DeviceStore.shared.store.getCategory(ObservableStore.bluetoothCategory)
2098
+ guard let model = core["default_wearable"] as? String, !model.isEmpty else { return nil }
2099
+ guard let name = core["device_name"] as? String, !name.isEmpty else { return nil }
2100
+ let identifier = (core["device_address"] as? String).flatMap { $0.isEmpty ? nil : $0 }
2101
+ let projectName = (core["project_name"] as? String).flatMap { $0.isEmpty ? nil : $0 }
2102
+ return Device(
2103
+ model: DeviceModel.fromDeviceType(model),
2104
+ name: name,
2105
+ identifier: identifier,
2106
+ projectName: projectName
2107
+ )
2108
+ }
2109
+
2110
+ private func dispatchDiscoveredDevices(_ rawSearchResults: Any?) {
2111
+ guard let results = rawSearchResults as? [[String: Any]] else { return }
2112
+ for result in results {
2113
+ guard let name = result["name"] as? String else { continue }
2114
+ guard discoveredDeviceNames.insert(name).inserted else { continue }
2115
+ guard let device = Device(values: result) else { continue }
2116
+ delegate?.bluetoothSDK(self, didDiscover: device)
2117
+ }
2118
+ }
2119
+
2120
+ private func dispatchScanResults(_ rawSearchResults: Any?) {
2121
+ guard let results = rawSearchResults as? [[String: Any]] else { return }
2122
+ let devices = results.compactMap(Device.init(values:))
2123
+ for id in Array(activeScanSessions.keys) {
2124
+ guard let activeSession = activeScanSessions[id] else { continue }
2125
+ emitScanResults(devices.filter { $0.model == activeSession.model }, forSession: id)
2126
+ }
2127
+ }
2128
+
2129
+ private func emitScanResults(_ devices: [Device], forSession id: UUID) {
2130
+ guard let activeSession = activeScanSessions[id] else { return }
2131
+ activeSession.latestResults = devices
2132
+ activeSession.onResults(devices)
2133
+ }
2134
+
2135
+ private func finishScanSession(_ id: UUID, reason: ScanStopReason, shouldStopScan: Bool) {
2136
+ guard let activeSession = activeScanSessions.removeValue(forKey: id) else { return }
2137
+ activeSession.timeoutTask?.cancel()
2138
+ activeSession.publicSession?.markStopped()
2139
+ if shouldStopScan {
2140
+ stopScan(reason: reason)
2141
+ }
2142
+ activeSession.onComplete(activeSession.latestResults)
2143
+ }
2144
+
2145
+ private func dispatchBridgeEvent(_ eventName: String, _ data: [String: Any]) {
2146
+ switch eventName {
2147
+ case "log":
2148
+ delegate?.bluetoothSDK(self, didLog: data["message"] as? String ?? data.description)
2149
+ case "button_press":
2150
+ let event = ButtonPressEvent(
2151
+ buttonId: data["buttonId"] as? String ?? "",
2152
+ pressType: data["pressType"] as? String ?? "",
2153
+ timestamp: intValue(data["timestamp"])
2154
+ )
2155
+ delegate?.bluetoothSDK(self, didReceive: .buttonPress(event))
2156
+ case "touch_event":
2157
+ delegate?.bluetoothSDK(self, didReceive: .touch(TouchEvent(values: data)))
2158
+ case "mic_pcm":
2159
+ let event = MicPcmEvent(values: data)
2160
+ if !event.pcm.isEmpty {
2161
+ delegate?.bluetoothSDK(self, didReceiveMicPcm: event)
2162
+ }
2163
+ case "mic_lc3":
2164
+ let event = MicLc3Event(values: data)
2165
+ if !event.lc3.isEmpty {
2166
+ delegate?.bluetoothSDK(self, didReceiveMicLc3: event)
2167
+ }
2168
+ case "mic_health":
2169
+ delegate?.bluetoothSDK(self, didReceive: .micHealth(MicHealthEvent(values: data)))
2170
+ case "local_transcription":
2171
+ let event = LocalTranscriptionEvent(
2172
+ text: data["text"] as? String ?? "",
2173
+ isFinal: data["isFinal"] as? Bool ?? false,
2174
+ values: data
2175
+ )
2176
+ delegate?.bluetoothSDK(self, didReceive: .localTranscription(event))
2177
+ case "voice_activity_detection_status":
2178
+ delegate?.bluetoothSDK(
2179
+ self,
2180
+ didReceive: .voiceActivityDetectionStatus(VoiceActivityDetectionStatusEvent(values: data))
2181
+ )
2182
+ case "speaking_status":
2183
+ delegate?.bluetoothSDK(
2184
+ self,
2185
+ didReceive: .speakingStatus(SpeakingStatusEvent(values: data))
2186
+ )
2187
+ case "hotspot_status_change":
2188
+ let event = HotspotStatusEvent(values: data)
2189
+ handleHotspotStatusForRequests(event)
2190
+ delegate?.bluetoothSDK(self, didReceive: .hotspotStatus(event))
2191
+ case "wifi_status_change":
2192
+ let event = WifiStatusEvent(values: data)
2193
+ handleWifiStatusForRequests(event)
2194
+ delegate?.bluetoothSDK(self, didReceive: .wifiStatus(event))
2195
+ case "wifi_scan_result":
2196
+ let networks = (data["networks"] as? [[String: Any]])?.map(WifiScanResult.init(values:)) ?? []
2197
+ let hasCompletionFlag = data.keys.contains("scanComplete") || data.keys.contains("scan_complete")
2198
+ let scanComplete = data["scanComplete"] as? Bool ?? data["scan_complete"] as? Bool ?? false
2199
+ let scanId = (data["scanId"] as? String).flatMap { $0.isEmpty ? nil : $0 }
2200
+ if let scanId {
2201
+ handleCorrelatedWifiScanChunk(scanId: scanId, results: networks, scanComplete: scanComplete)
2202
+ } else {
2203
+ // Old firmware: no correlation id, keep the pre-scanId heuristics.
2204
+ updateWifiScanLatestResults(networks)
2205
+ if scanComplete || !hasCompletionFlag {
2206
+ handleWifiScanResultsForRequests(networks)
2207
+ }
2208
+ }
2209
+ delegate?.bluetoothSDK(self, didReceive: .raw(name: "wifi_scan_result", values: data))
2210
+ case "hotspot_error":
2211
+ let event = HotspotErrorEvent(values: data)
2212
+ handleHotspotErrorForRequests(event)
2213
+ delegate?.bluetoothSDK(self, didReceive: .hotspotError(event))
2214
+ case "gallery_status":
2215
+ let event = GalleryStatusEvent(values: data)
2216
+ pendingGalleryStatus?.resolve(event)
2217
+ delegate?.bluetoothSDK(self, didReceive: .raw(name: "gallery_status", values: event.values))
2218
+ case "pairing_info":
2219
+ delegate?.bluetoothSDK(self, didReceive: .raw(name: "pairing_info", values: data))
2220
+ case "entering_pairing_mode", "owner_replaced":
2221
+ delegate?.bluetoothSDK(self, didReceive: .raw(name: eventName, values: data))
2222
+ case "photo_response":
2223
+ let event = PhotoResponseEvent(values: data)
2224
+ handlePhotoResponseForRequests(event)
2225
+ delegate?.bluetoothSDK(self, didReceive: .photoResponse(event))
2226
+ case "photo_status":
2227
+ delegate?.bluetoothSDK(self, didReceive: .photoStatus(PhotoStatusEvent(values: data)))
2228
+ case "camera_status":
2229
+ let event = CameraStatusEvent(values: data)
2230
+ handleCameraStatusForRequests(event)
2231
+ delegate?.bluetoothSDK(self, didReceive: .cameraStatus(event))
2232
+ case "video_recording_status":
2233
+ let event = VideoRecordingStatusEvent(values: data)
2234
+ handleVideoRecordingStatusForRequests(event)
2235
+ delegate?.bluetoothSDK(self, didReceive: .videoRecordingStatus(event))
2236
+ case "media_success", "media_error":
2237
+ let event = MediaUploadEvent(values: data)
2238
+ handleMediaUploadForRequests(event)
2239
+ delegate?.bluetoothSDK(self, didReceive: .mediaUpload(event))
2240
+ case "rgb_led_control_response":
2241
+ let event = RgbLedControlResponseEvent(values: data)
2242
+ handleRgbLedResponseForRequests(event)
2243
+ delegate?.bluetoothSDK(self, didReceive: .rgbLedControlResponse(event))
2244
+ case "stream_status":
2245
+ let event = StreamStatusEvent(values: data)
2246
+ handleStreamStatusForRequests(event)
2247
+ handleStreamStatusForKeepAlive(event.status)
2248
+ delegate?.bluetoothSDK(self, didReceive: .streamStatus(event))
2249
+ case "keep_alive_ack":
2250
+ let event = KeepAliveAckEvent(values: data)
2251
+ if !handleStreamKeepAliveAck(event) {
2252
+ delegate?.bluetoothSDK(self, didReceive: .keepAliveAck(event))
2253
+ }
2254
+ case "ota_start_ack":
2255
+ var values = data
2256
+ values["type"] = "ota_start_ack"
2257
+ let event = OtaStartAckEvent(values: values)
2258
+ pendingOtaStart?.resolve(event)
2259
+ delegate?.bluetoothSDK(self, didReceive: .otaStartAck(event))
2260
+ case "ota_status":
2261
+ var resultValues = data
2262
+ resultValues["type"] = "ota_status"
2263
+ pendingOtaQuery?.resolve(OtaQueryResult(values: resultValues))
2264
+ let event = OtaStatusEvent(values: resultValues)
2265
+ if let errorCode = otaStartRejectionErrorCode(event) {
2266
+ pendingOtaStart?.reject(
2267
+ BluetoothSdkError(code: errorCode, message: "Glasses rejected OTA start: \(errorCode)")
2268
+ )
2269
+ }
2270
+ delegate?.bluetoothSDK(self, didReceive: .otaStatus(event))
2271
+ case "settings_ack":
2272
+ let event = SettingsAckEvent(values: data)
2273
+ handleSettingsAckForRequests(event)
2274
+ delegate?.bluetoothSDK(self, didReceive: .settingsAck(event))
2275
+ case "version_info":
2276
+ let event = VersionInfoResult(values: data)
2277
+ pendingVersionInfo?.resolve(event)
2278
+ delegate?.bluetoothSDK(self, didReceive: .versionInfo(event))
2279
+ case "compatible_glasses_search_stop":
2280
+ delegate?.bluetoothSDK(self, didStopScan: .completed)
2281
+ case "pair_failure":
2282
+ delegate?.bluetoothSDK(
2283
+ self,
2284
+ didFail: BluetoothSdkError(
2285
+ code: "pair_failure",
2286
+ message: data["error"] as? String ?? data.description
2287
+ )
2288
+ )
2289
+ default:
2290
+ delegate?.bluetoothSDK(self, didReceive: .raw(name: eventName, values: data))
2291
+ }
2292
+ }
2293
+ }
2294
+
2295
+ /// OTA status messages are not request-correlated, so only known pre-ack failures settle a start.
2296
+ private func otaStartRejectionErrorCode(_ event: OtaStatusEvent) -> String? {
2297
+ guard event.status.lowercased() == "failed",
2298
+ event.errorMessage?.lowercased() == "battery_low"
2299
+ else {
2300
+ return nil
2301
+ }
2302
+ return "battery_low"
2303
+ }