@mentra/bluetooth-sdk 0.1.0

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 (278) hide show
  1. package/README.md +278 -0
  2. package/android/build.gradle +167 -0
  3. package/android/lc3Lib/CMakeLists.txt +6 -0
  4. package/android/lc3Lib/build.gradle +109 -0
  5. package/android/lc3Lib/proguard-rules.pro +33 -0
  6. package/android/lc3Lib/src/main/AndroidManifest.xml +2 -0
  7. package/android/lc3Lib/src/main/cpp/CMakeLists.txt +8 -0
  8. package/android/lc3Lib/src/main/cpp/google_opus_stuff/CMakeLists.txt +30 -0
  9. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/CMakeLists.txt +7 -0
  10. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/ogg_opus_encoder.cc +96 -0
  11. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/ogg_opus_encoder.h +53 -0
  12. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/Makefile.am +6 -0
  13. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/config_types.h +26 -0
  14. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/config_types.h.in +26 -0
  15. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/ogg.h +209 -0
  16. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/os_types.h +158 -0
  17. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus.h +981 -0
  18. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_custom.h +342 -0
  19. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_defines.h +799 -0
  20. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_multistream.h +660 -0
  21. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_projection.h +568 -0
  22. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_types.h +166 -0
  23. package/android/lc3Lib/src/main/cpp/google_opus_stuff/ogg_opus_encoder.cc +268 -0
  24. package/android/lc3Lib/src/main/cpp/google_opus_stuff/ogg_opus_encoder.h +115 -0
  25. package/android/lc3Lib/src/main/cpp/google_opus_stuff/opus_tools/opus_header.h +59 -0
  26. package/android/lc3Lib/src/main/cpp/liblc3/CMakeLists.txt +38 -0
  27. package/android/lc3Lib/src/main/cpp/liblc3/include/lc3.h +309 -0
  28. package/android/lc3Lib/src/main/cpp/liblc3/include/lc3_private.h +162 -0
  29. package/android/lc3Lib/src/main/cpp/liblc3/include/rnnoise.h +114 -0
  30. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/CMakeLists.txt +19 -0
  31. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/attdet.c +92 -0
  32. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/attdet.h +44 -0
  33. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bits.c +375 -0
  34. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bits.h +315 -0
  35. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bwdet.c +129 -0
  36. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bwdet.h +69 -0
  37. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/common.h +148 -0
  38. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/energy.c +70 -0
  39. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/energy.h +43 -0
  40. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/fastmath.h +158 -0
  41. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/lc3.c +702 -0
  42. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf.c +893 -0
  43. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf.h +111 -0
  44. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf_arm.h +506 -0
  45. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf_neon.h +281 -0
  46. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/makefile.mk +35 -0
  47. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct.c +452 -0
  48. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct.h +57 -0
  49. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct_neon.h +296 -0
  50. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/meson.build +46 -0
  51. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/plc.c +61 -0
  52. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/plc.h +57 -0
  53. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/sns.c +880 -0
  54. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/sns.h +103 -0
  55. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/spec.c +904 -0
  56. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/spec.h +119 -0
  57. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tables.c +3457 -0
  58. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tables.h +94 -0
  59. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tns.c +457 -0
  60. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tns.h +99 -0
  61. package/android/lc3Lib/src/main/cpp/liblc3/liblc3.cpp +159 -0
  62. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/_kiss_fft_guts.h +182 -0
  63. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/arch.h +261 -0
  64. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/celt_lpc.c +279 -0
  65. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/celt_lpc.h +59 -0
  66. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/common.h +48 -0
  67. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/compile.sh +3 -0
  68. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/denoise.c +646 -0
  69. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/kiss_fft.c +601 -0
  70. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/kiss_fft.h +203 -0
  71. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/opus_types.h +159 -0
  72. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/pitch.c +526 -0
  73. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/pitch.h +149 -0
  74. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn.c +178 -0
  75. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn.h +69 -0
  76. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_data.c +11051 -0
  77. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_data.h +34 -0
  78. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_reader.c +168 -0
  79. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_train.py +66 -0
  80. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/tansig_table.h +45 -0
  81. package/android/lc3Lib/src/main/java/com/mentra/lc3Lib/Lc3Cpp.java +38 -0
  82. package/android/lc3Lib/third_party/CMakeLists.txt +23 -0
  83. package/android/lc3Lib/third_party/CMakeLists_libogg.txt +18 -0
  84. package/android/lc3Lib/third_party/CMakeLists_libopus.txt +18 -0
  85. package/android/lc3Lib/third_party/CMakeLists_opus-tools.txt +23 -0
  86. package/android/lc3Lib/third_party/opus_tools/src/AUTHORS +5 -0
  87. package/android/lc3Lib/third_party/opus_tools/src/CMakeLists.txt +4 -0
  88. package/android/lc3Lib/third_party/opus_tools/src/COPYING +371 -0
  89. package/android/lc3Lib/third_party/opus_tools/src/ChangeLog +3 -0
  90. package/android/lc3Lib/third_party/opus_tools/src/Makefile.am +42 -0
  91. package/android/lc3Lib/third_party/opus_tools/src/Makefile.unix +23 -0
  92. package/android/lc3Lib/third_party/opus_tools/src/NEWS +0 -0
  93. package/android/lc3Lib/third_party/opus_tools/src/README +0 -0
  94. package/android/lc3Lib/third_party/opus_tools/src/autogen.sh +115 -0
  95. package/android/lc3Lib/third_party/opus_tools/src/configure.ac +293 -0
  96. package/android/lc3Lib/third_party/opus_tools/src/src/arch.h +239 -0
  97. package/android/lc3Lib/third_party/opus_tools/src/src/audio-in.c +1046 -0
  98. package/android/lc3Lib/third_party/opus_tools/src/src/diag_range.c +245 -0
  99. package/android/lc3Lib/third_party/opus_tools/src/src/diag_range.h +28 -0
  100. package/android/lc3Lib/third_party/opus_tools/src/src/info_opus.c +320 -0
  101. package/android/lc3Lib/third_party/opus_tools/src/src/info_opus.h +51 -0
  102. package/android/lc3Lib/third_party/opus_tools/src/src/lpc.c +157 -0
  103. package/android/lc3Lib/third_party/opus_tools/src/src/lpc.h +27 -0
  104. package/android/lc3Lib/third_party/opus_tools/src/src/opus_header.c +286 -0
  105. package/android/lc3Lib/third_party/opus_tools/src/src/opus_header.h +59 -0
  106. package/android/lc3Lib/third_party/opus_tools/src/src/opusdec.c +884 -0
  107. package/android/lc3Lib/third_party/opus_tools/src/src/opusenc.c +1021 -0
  108. package/android/lc3Lib/third_party/opus_tools/src/src/opusenc.h +101 -0
  109. package/android/lc3Lib/third_party/opus_tools/src/src/opusinfo.c +639 -0
  110. package/android/lc3Lib/third_party/opus_tools/src/src/opusinfo.h +51 -0
  111. package/android/lc3Lib/third_party/opus_tools/src/src/os_support.h +167 -0
  112. package/android/lc3Lib/third_party/opus_tools/src/src/resample.c +1137 -0
  113. package/android/lc3Lib/third_party/opus_tools/src/src/speex_resampler.h +344 -0
  114. package/android/lc3Lib/third_party/opus_tools/src/src/stack_alloc.h +115 -0
  115. package/android/lc3Lib/third_party/opus_tools/src/src/wav_io.c +125 -0
  116. package/android/lc3Lib/third_party/opus_tools/src/src/wav_io.h +62 -0
  117. package/android/lc3Lib/third_party/opus_tools/src/src/wave_out.c +223 -0
  118. package/android/lc3Lib/third_party/opus_tools/src/src/wave_out.h +60 -0
  119. package/android/settings.gradle +1 -0
  120. package/android/src/main/AndroidManifest.xml +40 -0
  121. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +528 -0
  122. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +610 -0
  123. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +1616 -0
  124. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +314 -0
  125. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothModels.kt +1710 -0
  126. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +554 -0
  127. package/android/src/main/java/com/mentra/bluetoothsdk/ObservableStore.kt +68 -0
  128. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +158 -0
  129. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +934 -0
  130. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +175 -0
  131. package/android/src/main/java/com/mentra/bluetoothsdk/services/PhoneMic.kt +1109 -0
  132. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +3974 -0
  133. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +3551 -0
  134. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +1327 -0
  135. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +6959 -0
  136. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1655 -0
  137. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +53675 -0
  138. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +202 -0
  139. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +224 -0
  140. package/android/src/main/java/com/mentra/bluetoothsdk/stt/STTTools.kt +340 -0
  141. package/android/src/main/java/com/mentra/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +465 -0
  142. package/android/src/main/java/com/mentra/bluetoothsdk/stt/VadGateSpeechPolicy.kt +221 -0
  143. package/android/src/main/java/com/mentra/bluetoothsdk/utils/AES.java +106 -0
  144. package/android/src/main/java/com/mentra/bluetoothsdk/utils/AudioSessionMonitor.kt +189 -0
  145. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BitmapJavaUtils.java +169 -0
  146. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +179 -0
  147. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +44 -0
  148. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G1Text.kt +436 -0
  149. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleRelayNaming.java +29 -0
  150. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +76 -0
  151. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +791 -0
  152. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +186 -0
  153. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +589 -0
  154. package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +303 -0
  155. package/android/src/main/java/com/mentra/bluetoothsdk/utils/SmartGlassesConnectionState.java +9 -0
  156. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/ByteUtilAudioPlayer.java +655 -0
  157. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Lc3Player.java +441 -0
  158. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PCMAudioPlayer.java +431 -0
  159. package/android/src/main/res/values/strings.xml +4 -0
  160. package/app.plugin.js +1 -0
  161. package/build/BluetoothSdk.types.d.ts +498 -0
  162. package/build/BluetoothSdk.types.d.ts.map +1 -0
  163. package/build/BluetoothSdk.types.js +23 -0
  164. package/build/BluetoothSdk.types.js.map +1 -0
  165. package/build/BluetoothSdkModule.d.ts +71 -0
  166. package/build/BluetoothSdkModule.d.ts.map +1 -0
  167. package/build/BluetoothSdkModule.js +106 -0
  168. package/build/BluetoothSdkModule.js.map +1 -0
  169. package/build/index.d.ts +3 -0
  170. package/build/index.d.ts.map +1 -0
  171. package/build/index.js +5 -0
  172. package/build/index.js.map +1 -0
  173. package/expo-module.config.json +9 -0
  174. package/ios/BluetoothSdkModule.swift +665 -0
  175. package/ios/MentraBluetoothSDK.podspec +77 -0
  176. package/ios/Packages/CoreObjC/CoreObjC.xcodeproj/project.pbxproj +213 -0
  177. package/ios/Packages/CoreObjC/PcmConverter.h +22 -0
  178. package/ios/Packages/CoreObjC/PcmConverter.m +266 -0
  179. package/ios/Packages/CoreObjC/attdet.c +92 -0
  180. package/ios/Packages/CoreObjC/attdet.h +44 -0
  181. package/ios/Packages/CoreObjC/bits.c +375 -0
  182. package/ios/Packages/CoreObjC/bits.h +315 -0
  183. package/ios/Packages/CoreObjC/bwdet.c +129 -0
  184. package/ios/Packages/CoreObjC/bwdet.h +69 -0
  185. package/ios/Packages/CoreObjC/common.h +151 -0
  186. package/ios/Packages/CoreObjC/energy.c +70 -0
  187. package/ios/Packages/CoreObjC/energy.h +43 -0
  188. package/ios/Packages/CoreObjC/fastmath.h +158 -0
  189. package/ios/Packages/CoreObjC/lc3.c +704 -0
  190. package/ios/Packages/CoreObjC/lc3.h +313 -0
  191. package/ios/Packages/CoreObjC/lc3_cpp.h +283 -0
  192. package/ios/Packages/CoreObjC/lc3_private.h +163 -0
  193. package/ios/Packages/CoreObjC/ltpf.c +905 -0
  194. package/ios/Packages/CoreObjC/ltpf.h +111 -0
  195. package/ios/Packages/CoreObjC/ltpf_arm.h +506 -0
  196. package/ios/Packages/CoreObjC/ltpf_neon.h +281 -0
  197. package/ios/Packages/CoreObjC/makefile.mk +35 -0
  198. package/ios/Packages/CoreObjC/mdct.c +469 -0
  199. package/ios/Packages/CoreObjC/mdct.h +57 -0
  200. package/ios/Packages/CoreObjC/mdct_neon.h +296 -0
  201. package/ios/Packages/CoreObjC/meson.build +61 -0
  202. package/ios/Packages/CoreObjC/plc.c +61 -0
  203. package/ios/Packages/CoreObjC/plc.h +57 -0
  204. package/ios/Packages/CoreObjC/rnnoise.h +114 -0
  205. package/ios/Packages/CoreObjC/sns.c +880 -0
  206. package/ios/Packages/CoreObjC/sns.h +103 -0
  207. package/ios/Packages/CoreObjC/spec.c +907 -0
  208. package/ios/Packages/CoreObjC/spec.h +119 -0
  209. package/ios/Packages/CoreObjC/tables.c +3457 -0
  210. package/ios/Packages/CoreObjC/tables.h +94 -0
  211. package/ios/Packages/CoreObjC/tns.c +457 -0
  212. package/ios/Packages/CoreObjC/tns.h +99 -0
  213. package/ios/Packages/SherpaOnnx/Model/joiner.onnx +0 -0
  214. package/ios/Packages/SherpaOnnx/Model/tokens.txt +502 -0
  215. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +1659 -0
  216. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/cargs.h +162 -0
  217. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/c-api.h +1852 -0
  218. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/cxx-api.h +674 -0
  219. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +44 -0
  220. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
  221. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/sherpa-onnx.a +0 -0
  222. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
  223. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/sherpa-onnx.a +0 -0
  224. package/ios/Packages/VAD/Common/VAD/VADQuality.swift +41 -0
  225. package/ios/Packages/VAD/Common/VAD/VADState.swift +26 -0
  226. package/ios/Packages/VAD/Common/VAD/VADStrategy.swift +29 -0
  227. package/ios/Packages/VAD/Common/VAD/VADType.swift +14 -0
  228. package/ios/Packages/VAD/Data/Configuration.swift +68 -0
  229. package/ios/Packages/VAD/Data/FrameSize.swift +39 -0
  230. package/ios/Packages/VAD/Data/Record.swift +13 -0
  231. package/ios/Packages/VAD/Data/Result.swift +22 -0
  232. package/ios/Packages/VAD/Data/SampleRate.swift +48 -0
  233. package/ios/Packages/VAD/Silero/Model/silero_vad.onnx +0 -0
  234. package/ios/Packages/VAD/Silero/SileroVAD.swift +284 -0
  235. package/ios/Packages/VAD/Silero/SileroVADStrategy.swift +64 -0
  236. package/ios/Packages/libbz2/module.modulemap +5 -0
  237. package/ios/Packages/libbz2/shim.h +1 -0
  238. package/ios/Source/Bridge.swift +353 -0
  239. package/ios/Source/Bridging-Header.h +16 -0
  240. package/ios/Source/DeviceManager.swift +1478 -0
  241. package/ios/Source/DeviceStore.swift +295 -0
  242. package/ios/Source/MentraBluetoothSDK.swift +2439 -0
  243. package/ios/Source/ObservableStore.swift +88 -0
  244. package/ios/Source/PrivacyInfo.xcprivacy +23 -0
  245. package/ios/Source/controllers/ControllerManager.swift +187 -0
  246. package/ios/Source/controllers/R1.swift +827 -0
  247. package/ios/Source/services/PhoneMic.swift +662 -0
  248. package/ios/Source/sgcs/Frame.swift +651 -0
  249. package/ios/Source/sgcs/G1.swift +2505 -0
  250. package/ios/Source/sgcs/G2.swift +3730 -0
  251. package/ios/Source/sgcs/Mach1.swift +543 -0
  252. package/ios/Source/sgcs/MentraLive.swift +4708 -0
  253. package/ios/Source/sgcs/MentraNex.swift +2479 -0
  254. package/ios/Source/sgcs/SGCManager.swift +243 -0
  255. package/ios/Source/sgcs/Simulated.swift +270 -0
  256. package/ios/Source/sgcs/mentraos_ble.pb.swift +3811 -0
  257. package/ios/Source/stt/STTTools.swift +154 -0
  258. package/ios/Source/stt/SherpaOnnxTranscriber.swift +401 -0
  259. package/ios/Source/utils/AudioSessionMonitor.swift +283 -0
  260. package/ios/Source/utils/Constants.swift +74 -0
  261. package/ios/Source/utils/Enums.swift +95 -0
  262. package/ios/Source/utils/G1Text.swift +2067 -0
  263. package/ios/Source/utils/JSCExperiment.swift +241 -0
  264. package/ios/Source/utils/MemoryMonitor.swift +44 -0
  265. package/ios/Source/utils/MessageChunker.swift +164 -0
  266. package/ios/Source/utils/Models.swift +135 -0
  267. package/ios/Source/utils/PhoneAudioMonitor.swift +226 -0
  268. package/ios/Source/utils/TarBz2Extractor.swift +206 -0
  269. package/package.json +78 -0
  270. package/plugin/build/index.d.ts +6 -0
  271. package/plugin/build/index.js +12 -0
  272. package/plugin/build/withAndroid.d.ts +4 -0
  273. package/plugin/build/withAndroid.js +80 -0
  274. package/plugin/build/withIos.d.ts +4 -0
  275. package/plugin/build/withIos.js +64 -0
  276. package/src/BluetoothSdk.types.ts +581 -0
  277. package/src/BluetoothSdkModule.ts +259 -0
  278. package/src/index.ts +4 -0
@@ -0,0 +1,4708 @@
1
+ //
2
+ // MentraLive.swift
3
+ // AOS
4
+ //
5
+ // Created by Matthew Fosse on 7/3/25.
6
+ //
7
+
8
+ //
9
+ // MentraLiveManager.swift
10
+ // MentraOS_Manager
11
+ //
12
+ // Converted from MentraLiveSGC.java
13
+ //
14
+
15
+ import Combine
16
+ import CoreBluetooth
17
+ import Foundation
18
+ import UIKit
19
+
20
+ // MARK: - Supporting Types
21
+
22
+ struct MentraLiveDevice {
23
+ let name: String
24
+ let address: String
25
+ }
26
+
27
+ // MARK: - BlePhotoUploadService
28
+
29
+ class BlePhotoUploadService {
30
+ static let TAG = "BlePhotoUploadService"
31
+
32
+ /// Callback protocol
33
+ protocol UploadCallback {
34
+ func onSuccess(requestId: String)
35
+ func onError(requestId: String, error: String)
36
+ }
37
+
38
+ enum PhotoUploadError: LocalizedError {
39
+ case decodingFailed
40
+ case avifNotSupported
41
+ case uploadFailed(String)
42
+ case invalidData
43
+
44
+ var errorDescription: String? {
45
+ switch self {
46
+ case .decodingFailed:
47
+ return "Failed to decode image data"
48
+ case .avifNotSupported:
49
+ return "AVIF format not supported on this iOS version"
50
+ case let .uploadFailed(message):
51
+ return "Upload failed: \(message)"
52
+ case .invalidData:
53
+ return "Invalid image data"
54
+ }
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Process image data and upload to webhook
60
+ * - Parameters:
61
+ * - imageData: Raw image data (AVIF or JPEG)
62
+ * - requestId: Original request ID for tracking
63
+ * - webhookUrl: Destination webhook URL
64
+ * - authToken: Optional authentication token for upload
65
+ * - callback: Callback for success/error
66
+ */
67
+ static func processAndUploadPhoto(
68
+ imageData: Data,
69
+ requestId: String,
70
+ webhookUrl: String,
71
+ authToken: String?
72
+ ) {
73
+ Task {
74
+ do {
75
+ Bridge.log(
76
+ "\(TAG): Processing BLE photo for upload. Image size: \(imageData.count) bytes"
77
+ )
78
+
79
+ // 1. Decode image (AVIF or JPEG) to UIImage
80
+ guard let image = decodeImage(imageData: imageData) else {
81
+ throw NSError(
82
+ domain: "BlePhotoUpload",
83
+ code: -1,
84
+ userInfo: [NSLocalizedDescriptionKey: "Failed to decode image data"]
85
+ )
86
+ }
87
+
88
+ Bridge.log(
89
+ "\(TAG): Decoded image to bitmap: \(Int(image.size.width))x\(Int(image.size.height))"
90
+ )
91
+
92
+ // 2. Convert to JPEG for upload (in case it was AVIF)
93
+ guard let jpegData = image.jpegData(compressionQuality: 0.9) else {
94
+ throw NSError(
95
+ domain: "BlePhotoUpload",
96
+ code: -2,
97
+ userInfo: [NSLocalizedDescriptionKey: "Failed to convert image to JPEG"]
98
+ )
99
+ }
100
+
101
+ Bridge.log("\(TAG): Converted to JPEG for upload. Size: \(jpegData.count) bytes")
102
+
103
+ // 3. Upload to webhook
104
+ try await uploadToWebhook(
105
+ jpegData: jpegData,
106
+ requestId: requestId,
107
+ webhookUrl: webhookUrl,
108
+ authToken: authToken
109
+ )
110
+
111
+ Bridge.log("\(TAG): Photo uploaded successfully for requestId: \(requestId)")
112
+
113
+ // DispatchQueue.main.async {
114
+ // callback.onSuccess(requestId: requestId)
115
+ // }
116
+
117
+ } catch {
118
+ Bridge.log(
119
+ "\(TAG): Error processing BLE photo for requestId: \(requestId), error: \(error)"
120
+ )
121
+
122
+ // DispatchQueue.main.async {
123
+ // callback.onError(requestId: requestId, error: error.localizedDescription)
124
+ // }
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Decode image data (AVIF or JPEG) to UIImage
131
+ */
132
+ private static func decodeImage(imageData: Data) -> UIImage? {
133
+ // First try standard UIImage decoding (works for JPEG, PNG, etc)
134
+ if let image = UIImage(data: imageData) {
135
+ return image
136
+ }
137
+
138
+ // If that fails, try AVIF decoding
139
+ // Note: AVIF support requires iOS 16+ or a third-party library
140
+ if #available(iOS 16.0, *) {
141
+ // iOS 16+ has native AVIF support
142
+ return UIImage(data: imageData)
143
+ } else {
144
+ // For older iOS versions, you would need to integrate a third-party
145
+ // AVIF decoder library like libavif
146
+ Bridge.log("\(TAG): AVIF decoding not supported on this iOS version")
147
+ return nil
148
+ }
149
+ }
150
+
151
+ private static func uploadToWebhook(
152
+ jpegData: Data,
153
+ requestId: String,
154
+ webhookUrl: String,
155
+ authToken: String?
156
+ ) async throws {
157
+ guard let url = URL(string: webhookUrl) else {
158
+ Bridge.log("LIVE: Invalid webhook URL: \(webhookUrl)")
159
+ return
160
+ }
161
+
162
+ var request = URLRequest(url: url)
163
+ request.httpMethod = "POST"
164
+ request.timeoutInterval = 30
165
+
166
+ // Add auth header if provided
167
+ if let authToken, !authToken.isEmpty {
168
+ request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
169
+ }
170
+
171
+ // Create multipart form data
172
+ let boundary = UUID().uuidString
173
+ request.setValue(
174
+ "multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type"
175
+ )
176
+
177
+ var body = Data()
178
+
179
+ // Add requestId field
180
+ body.append("--\(boundary)\r\n".data(using: .utf8)!)
181
+ body.append(
182
+ "Content-Disposition: form-data; name=\"requestId\"\r\n\r\n".data(using: .utf8)!
183
+ )
184
+ body.append("\(requestId)\r\n".data(using: .utf8)!)
185
+
186
+ // Add source field
187
+ body.append("--\(boundary)\r\n".data(using: .utf8)!)
188
+ body.append("Content-Disposition: form-data; name=\"source\"\r\n\r\n".data(using: .utf8)!)
189
+ body.append("ble_transfer\r\n".data(using: .utf8)!)
190
+
191
+ // Add photo field
192
+ body.append("--\(boundary)\r\n".data(using: .utf8)!)
193
+ body.append(
194
+ "Content-Disposition: form-data; name=\"photo\"; filename=\"\(requestId).jpg\"\r\n"
195
+ .data(using: .utf8)!
196
+ )
197
+ body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
198
+ body.append(jpegData)
199
+ body.append("\r\n".data(using: .utf8)!)
200
+
201
+ // Close multipart form
202
+ body.append("--\(boundary)--\r\n".data(using: .utf8)!)
203
+
204
+ request.httpBody = body
205
+
206
+ print("LIVE: Uploading photo to webhook: \(webhookUrl)")
207
+
208
+ do {
209
+ let (data, response) = try await URLSession.shared.data(for: request)
210
+
211
+ guard let httpResponse = response as? HTTPURLResponse else {
212
+ throw PhotoUploadError.uploadFailed("Invalid response")
213
+ }
214
+
215
+ if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
216
+ let errorBody = String(data: data, encoding: .utf8) ?? "No response body"
217
+ throw PhotoUploadError.uploadFailed(
218
+ "Upload failed with code \(httpResponse.statusCode): \(errorBody)"
219
+ )
220
+ }
221
+
222
+ print("LIVE: Upload successful. Response code: \(httpResponse.statusCode)")
223
+
224
+ } catch {
225
+ if error is PhotoUploadError {
226
+ throw error
227
+ } else {
228
+ throw PhotoUploadError.uploadFailed(error.localizedDescription)
229
+ }
230
+ }
231
+ }
232
+ }
233
+
234
+ extension Data {
235
+ mutating func append(_ string: String) {
236
+ if let data = string.data(using: .utf8) {
237
+ append(data)
238
+ }
239
+ }
240
+ }
241
+
242
+ private enum K900ProtocolUtils {
243
+ // Protocol constants
244
+ static let CMD_START_CODE: [UInt8] = [0x23, 0x23] // ##
245
+ static let CMD_END_CODE: [UInt8] = [0x24, 0x24] // $$
246
+ static let CMD_TYPE_STRING: UInt8 = 0x30 // String/JSON type
247
+
248
+ // JSON Field constants
249
+ static let FIELD_C = "C" // Command/Content field
250
+ static let FIELD_V = "V" // Version field
251
+ static let FIELD_B = "B" // Body field
252
+
253
+ // Command types
254
+ static let CMD_TYPE_PHOTO: UInt8 = 0x31
255
+ static let CMD_TYPE_VIDEO: UInt8 = 0x32
256
+ static let CMD_TYPE_MUSIC: UInt8 = 0x33
257
+ static let CMD_TYPE_AUDIO: UInt8 = 0x34
258
+ static let CMD_TYPE_DATA: UInt8 = 0x35
259
+
260
+ // File transfer constants
261
+ static let FILE_PACK_SIZE = 400 // Max data size per packet
262
+ static let LENGTH_FILE_START = 2
263
+ static let LENGTH_FILE_TYPE = 1
264
+ static let LENGTH_FILE_PACKSIZE = 2
265
+ static let LENGTH_FILE_PACKINDEX = 2
266
+ static let LENGTH_FILE_SIZE = 4
267
+ static let LENGTH_FILE_NAME = 16
268
+ static let LENGTH_FILE_FLAG = 2
269
+ static let LENGTH_FILE_VERIFY = 1
270
+ static let LENGTH_FILE_END = 2
271
+
272
+ struct FilePacketInfo {
273
+ var fileType: UInt8 = 0
274
+ var packSize: UInt16 = 0
275
+ var packIndex: UInt16 = 0
276
+ var fileSize: UInt32 = 0
277
+ var fileName: String = ""
278
+ var flags: UInt16 = 0
279
+ var data: Data = .init()
280
+ var verifyCode: UInt8 = 0
281
+ var isValid: Bool = false
282
+ }
283
+
284
+ static func extractFilePacket(_ protocolData: Data) -> FilePacketInfo? {
285
+ guard protocolData.count >= 31 else {
286
+ return nil
287
+ }
288
+
289
+ var info = FilePacketInfo()
290
+ var pos = LENGTH_FILE_START // Skip start code
291
+
292
+ // File type
293
+ info.fileType = protocolData[pos]
294
+ pos += LENGTH_FILE_TYPE
295
+
296
+ // Pack size (big-endian)
297
+ info.packSize = (UInt16(protocolData[pos]) << 8) | UInt16(protocolData[pos + 1])
298
+ pos += LENGTH_FILE_PACKSIZE
299
+
300
+ // Pack index (big-endian)
301
+ info.packIndex = (UInt16(protocolData[pos]) << 8) | UInt16(protocolData[pos + 1])
302
+ pos += LENGTH_FILE_PACKINDEX
303
+
304
+ // File size (big-endian)
305
+ info.fileSize =
306
+ (UInt32(protocolData[pos]) << 24) | (UInt32(protocolData[pos + 1]) << 16)
307
+ | (UInt32(protocolData[pos + 2]) << 8) | UInt32(protocolData[pos + 3])
308
+ pos += LENGTH_FILE_SIZE
309
+
310
+ // File name
311
+ let nameBytes = protocolData.subdata(in: pos ..< (pos + LENGTH_FILE_NAME))
312
+
313
+ // Find null terminator
314
+ var nameLen = 0
315
+ for i in 0 ..< LENGTH_FILE_NAME {
316
+ if nameBytes[i] == 0 { break }
317
+ nameLen += 1
318
+ }
319
+
320
+ if let fileName = String(data: nameBytes.subdata(in: 0 ..< nameLen), encoding: .utf8) {
321
+ info.fileName = fileName
322
+ }
323
+ pos += LENGTH_FILE_NAME
324
+
325
+ // Flags (big-endian)
326
+ info.flags = (UInt16(protocolData[pos]) << 8) | UInt16(protocolData[pos + 1])
327
+ pos += LENGTH_FILE_FLAG
328
+
329
+ // Verify packet has enough data
330
+ let requiredLength = pos + Int(info.packSize) + LENGTH_FILE_VERIFY + LENGTH_FILE_END
331
+ if protocolData.count < requiredLength {
332
+ print(
333
+ "K900ProtocolUtils: File packet too short for data. Need: \(requiredLength), Have: \(protocolData.count), packSize=\(info.packSize), pos=\(pos)"
334
+ )
335
+ return nil
336
+ }
337
+
338
+ // Data
339
+ info.data = protocolData.subdata(in: pos ..< (pos + Int(info.packSize)))
340
+ pos += Int(info.packSize)
341
+
342
+ // Verify code
343
+ info.verifyCode = protocolData[pos]
344
+ pos += LENGTH_FILE_VERIFY
345
+
346
+ // Check end code
347
+ if protocolData[pos] != CMD_END_CODE[0] || protocolData[pos + 1] != CMD_END_CODE[1] {
348
+ return nil
349
+ }
350
+
351
+ // Calculate and verify checksum
352
+ var checkSum = 0
353
+ for byte in info.data {
354
+ checkSum += Int(byte)
355
+ }
356
+ let calculatedVerify = UInt8(checkSum & 0xFF)
357
+
358
+ info.isValid = (calculatedVerify == info.verifyCode)
359
+
360
+ if !info.isValid {
361
+ print(
362
+ "K900ProtocolUtils: File packet checksum failed. Expected: \(String(format: "%02X", info.verifyCode)), Calculated: \(String(format: "%02X", calculatedVerify))"
363
+ )
364
+ } else {
365
+ print(
366
+ "K900ProtocolUtils: File packet extracted successfully: index=\(info.packIndex), size=\(info.packSize), fileName=\(info.fileName)"
367
+ )
368
+ }
369
+
370
+ return info
371
+ }
372
+ }
373
+
374
+ private struct FileTransferSession {
375
+ let fileName: String
376
+ let fileSize: Int // NOTE: May be "fake" (inflated) due to BES firmware workaround
377
+ var actualPackSize: Int = 0 // Actual pack size from first received packet
378
+ var totalPackets: Int
379
+ var expectedNextPacket: Int = 0
380
+ var receivedPackets: [Int: Data] = [:]
381
+ let startTime: Date
382
+ var isComplete: Bool = false
383
+ var isAnnounced: Bool = false
384
+
385
+ /// BES2700 firmware hardcodes FILE_PACK_SIZE=400 when calculating totalPack.
386
+ /// Android glasses "lie" about fileSize to make BES expect correct packet count.
387
+ private static let BES_HARDCODED_PACK_SIZE = 400
388
+
389
+ init(fileName: String, fileSize: Int, announcedPackets: Int? = nil) {
390
+ self.fileName = fileName
391
+ self.fileSize = fileSize
392
+ let computedPackets =
393
+ (fileSize + K900ProtocolUtils.FILE_PACK_SIZE - 1) / K900ProtocolUtils.FILE_PACK_SIZE
394
+ if let announced = announcedPackets, announced > 0 {
395
+ totalPackets = announced
396
+ isAnnounced = true
397
+ } else {
398
+ totalPackets = computedPackets
399
+ isAnnounced = false
400
+ }
401
+ startTime = Date()
402
+ }
403
+
404
+ mutating func updateAnnouncedPackets(_ announced: Int) {
405
+ guard announced > 0 else { return }
406
+ totalPackets = announced
407
+ isAnnounced = true
408
+ if expectedNextPacket >= totalPackets {
409
+ expectedNextPacket = min(expectedNextPacket, max(totalPackets - 1, 0))
410
+ }
411
+ }
412
+
413
+ /// Recalculate total packets based on actual pack size from received packet.
414
+ /// Detects BES lie: if fileSize is multiple of 400 but actual pack size differs.
415
+ mutating func recalculateTotalPackets(actualPackSize: Int) {
416
+ guard actualPackSize > 0, actualPackSize <= K900ProtocolUtils.FILE_PACK_SIZE else { return }
417
+
418
+ self.actualPackSize = actualPackSize
419
+
420
+ // Detect BES lie: if fileSize is exact multiple of 400, glasses used the lie strategy
421
+ let isBesLie =
422
+ (fileSize % Self.BES_HARDCODED_PACK_SIZE == 0)
423
+ && (actualPackSize != Self.BES_HARDCODED_PACK_SIZE)
424
+
425
+ let newTotalPackets: Int
426
+ if isBesLie {
427
+ // BES lie detected: totalPackets = fileSize / 400
428
+ newTotalPackets = fileSize / Self.BES_HARDCODED_PACK_SIZE
429
+ print(
430
+ "📦 BES Lie detected! fakeFileSize=\(fileSize), totalPackets=\(newTotalPackets), actualPackSize=\(actualPackSize)"
431
+ )
432
+ } else {
433
+ // Normal case: calculate based on actual pack size
434
+ newTotalPackets = (fileSize + actualPackSize - 1) / actualPackSize
435
+ }
436
+
437
+ if newTotalPackets != totalPackets {
438
+ print(
439
+ "📦 Recalculating totalPackets: \(totalPackets) -> \(newTotalPackets) (packSize=\(actualPackSize), fileSize=\(fileSize))"
440
+ )
441
+ totalPackets = newTotalPackets
442
+ }
443
+ }
444
+
445
+ mutating func addPacket(_ index: Int, data: Data) -> Bool {
446
+ guard index >= 0 else { return false }
447
+
448
+ // On first packet, recalculate total packets only when we do not already
449
+ // have an authoritative pack size from protocol metadata.
450
+ if receivedPackets.isEmpty && actualPackSize == 0 && !data.isEmpty {
451
+ recalculateTotalPackets(actualPackSize: data.count)
452
+ }
453
+
454
+ if index >= totalPackets {
455
+ totalPackets = index + 1
456
+ }
457
+
458
+ guard receivedPackets[index] == nil else {
459
+ return false
460
+ }
461
+
462
+ receivedPackets[index] = data
463
+
464
+ while receivedPackets[expectedNextPacket] != nil, expectedNextPacket < totalPackets {
465
+ expectedNextPacket += 1
466
+ }
467
+
468
+ isComplete = (receivedPackets.count == totalPackets)
469
+ return true
470
+ }
471
+
472
+ func isFinalPacket(_ index: Int) -> Bool {
473
+ index == totalPackets - 1
474
+ }
475
+
476
+ func missingPacketIndices() -> [Int] {
477
+ guard totalPackets > receivedPackets.count else { return [] }
478
+ return (0 ..< totalPackets).compactMap { receivedPackets[$0] == nil ? $0 : nil }
479
+ }
480
+
481
+ /// Assemble file from received packets.
482
+ /// NOTE: Calculates actual file size from received data, NOT from header fileSize,
483
+ /// because fileSize may be "fake" (inflated) due to BES firmware workaround.
484
+ func assembleFile() -> Data? {
485
+ guard isComplete else { return nil }
486
+
487
+ // Calculate actual file size by summing all received packet sizes
488
+ let actualFileSize = receivedPackets.values.reduce(0) { $0 + $1.count }
489
+
490
+ print(
491
+ "📦 Assembling file: headerFileSize=\(fileSize), actualFileSize=\(actualFileSize), totalPackets=\(totalPackets)"
492
+ )
493
+
494
+ var fileData = Data(capacity: actualFileSize)
495
+
496
+ for i in 0 ..< totalPackets {
497
+ if let packet = receivedPackets[i] {
498
+ fileData.append(packet)
499
+ }
500
+ }
501
+
502
+ return fileData
503
+ }
504
+ }
505
+
506
+ private struct BlePhotoTransfer {
507
+ let bleImgId: String
508
+ let requestId: String
509
+ let webhookUrl: String
510
+ var authToken: String?
511
+ var session: FileTransferSession?
512
+ let phoneStartTime: Date
513
+ var bleTransferStartTime: Date?
514
+ var glassesCompressionDurationMs: Int64 = 0
515
+
516
+ init(bleImgId: String, requestId: String, webhookUrl: String) {
517
+ self.bleImgId = bleImgId
518
+ self.requestId = requestId
519
+ self.webhookUrl = webhookUrl
520
+ phoneStartTime = Date()
521
+ }
522
+ }
523
+
524
+ private enum BleIncidentLogRelayKind {
525
+ case firmware
526
+ case logcat
527
+ }
528
+
529
+ private final class BleIncidentLogRelayEntry {
530
+ let fileBaseKey: String
531
+ let incidentId: String
532
+ let apiBaseUrl: String
533
+ let kind: BleIncidentLogRelayKind
534
+ var session: FileTransferSession?
535
+
536
+ init(
537
+ fileBaseKey: String, incidentId: String, apiBaseUrl: String, kind: BleIncidentLogRelayKind
538
+ ) {
539
+ self.fileBaseKey = fileBaseKey
540
+ self.incidentId = incidentId
541
+ self.apiBaseUrl = apiBaseUrl
542
+ self.kind = kind
543
+ }
544
+ }
545
+
546
+ // MARK: - CBCentralManagerDelegate
547
+
548
+ extension MentraLive: CBCentralManagerDelegate {
549
+ func centralManagerDidUpdateState(_ central: CBCentralManager) {
550
+ switch central.state {
551
+ case .poweredOn:
552
+ Bridge.log("LIVE: Bluetooth powered on")
553
+ // If we have a saved device, try to reconnect
554
+ if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
555
+ !savedDeviceName.isEmpty
556
+ {
557
+ startScan()
558
+ }
559
+
560
+ case .poweredOff:
561
+ Bridge.log("LIVE: Bluetooth is powered off")
562
+ updateConnectionState(ConnTypes.DISCONNECTED)
563
+
564
+ case .unauthorized:
565
+ Bridge.log("LIVE: Bluetooth is unauthorized")
566
+ updateConnectionState(ConnTypes.DISCONNECTED)
567
+
568
+ case .unsupported:
569
+ Bridge.log("LIVE: Bluetooth is unsupported")
570
+ updateConnectionState(ConnTypes.DISCONNECTED)
571
+
572
+ default:
573
+ Bridge.log("LIVE: Bluetooth state: \(central.state.rawValue)")
574
+ }
575
+ }
576
+
577
+ func handleDiscoveredPeripheral(_ peripheral: CBPeripheral, rssi: NSNumber? = nil) {
578
+ guard let name = peripheral.name else { return }
579
+
580
+ // Check for compatible device names
581
+ if name == "Xy_A" || name.hasPrefix("XyBLE_") || name.hasPrefix("MENTRA_LIVE_BLE")
582
+ || name.hasPrefix("MENTRA_LIVE_BT") || name.lowercased().hasPrefix("mentra_live")
583
+ {
584
+ let glassType = name == "Xy_A" ? "Standard" : "K900"
585
+ Bridge.log("Found compatible \(glassType) glasses device: \(name)")
586
+
587
+ // Store the peripheral
588
+ discoveredPeripherals[name] = peripheral
589
+
590
+ emitDiscoveredDevice(name, identifier: peripheral.identifier.uuidString, rssi: rssi?.intValue)
591
+
592
+ // Check if this is the device we want to connect to
593
+ if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
594
+ savedDeviceName == name
595
+ {
596
+ Bridge.log("Found our remembered device by name, connecting: \(name)")
597
+ // stopScan()
598
+ centralManager?.stopScan()
599
+ isScanning = false
600
+ connectToDevice(peripheral)
601
+ }
602
+ }
603
+ }
604
+
605
+ func centralManager(
606
+ _: CBCentralManager, didDiscover peripheral: CBPeripheral,
607
+ advertisementData _: [String: Any], rssi: NSNumber
608
+ ) {
609
+ handleDiscoveredPeripheral(peripheral, rssi: rssi)
610
+ }
611
+
612
+ func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
613
+ Bridge.log("Connected to GATT server, discovering services...")
614
+
615
+ stopConnectionTimeout()
616
+ isConnecting = false
617
+ connectedPeripheral = peripheral
618
+
619
+ // Save device name and address for future reconnection
620
+ if let name = peripheral.name {
621
+ UserDefaults.standard.set(name, forKey: PREFS_DEVICE_NAME)
622
+ Bridge.log("Saved device name for future reconnection: \(name)")
623
+ DeviceStore.shared.apply("glasses", "bluetoothName", name)
624
+ }
625
+ // Persist peripheral UUID so DeviceManager can sync it to RN settings
626
+ DeviceStore.shared.apply("bluetooth", "device_address", peripheral.identifier.uuidString)
627
+
628
+ // Audio Pairing: Setup Bluetooth audio after BLE connection
629
+ if let deviceName = peripheral.name {
630
+ Bridge.log("BLE connection established, setting up audio...")
631
+ // setupAudioPairing(deviceName: deviceName)
632
+ }
633
+
634
+ // Discover services
635
+ peripheral.discoverServices([SERVICE_UUID])
636
+
637
+ // Reset reconnect attempts
638
+ reconnectAttempts = 0
639
+ }
640
+
641
+ func centralManager(
642
+ _: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error _: Error?
643
+ ) {
644
+ Bridge.log("LIVE: Disconnected from GATT server")
645
+
646
+ isConnecting = false
647
+
648
+ connectedPeripheral = nil
649
+ fullyBooted = false
650
+ connected = false
651
+ updateConnectionState(ConnTypes.DISCONNECTED)
652
+ rgbLedAuthorityClaimed = false
653
+
654
+ stopAllTimers()
655
+
656
+ // Clean up characteristics
657
+ txCharacteristic = nil
658
+ rxCharacteristic = nil
659
+
660
+ // Attempt reconnection if not killed
661
+ if !isKilled {
662
+ handleReconnection()
663
+ }
664
+ }
665
+
666
+ func centralManager(_: CBCentralManager, didFailToConnect _: CBPeripheral, error: Error?) {
667
+ Bridge.log(
668
+ "LIVE: Failed to connect to peripheral: \(error?.localizedDescription ?? "Unknown error")"
669
+ )
670
+
671
+ stopConnectionTimeout()
672
+ isConnecting = false
673
+ connectedPeripheral = nil
674
+ updateConnectionState(ConnTypes.DISCONNECTED)
675
+
676
+ if !isKilled {
677
+ handleReconnection()
678
+ }
679
+ }
680
+ }
681
+
682
+ // MARK: - CBPeripheralDelegate
683
+
684
+ extension MentraLive: CBPeripheralDelegate {
685
+ func peripheral(_: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) {
686
+ signalStrengthReadInFlight = false
687
+ if let error {
688
+ Bridge.log("LIVE: Error reading RSSI: \(error.localizedDescription)")
689
+ } else {
690
+ updateSignalStrength(Int(truncating: RSSI))
691
+ }
692
+ }
693
+
694
+ func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
695
+ if let error {
696
+ Bridge.log("LIVE: Error discovering services: \(error.localizedDescription)")
697
+ centralManager?.cancelPeripheralConnection(peripheral)
698
+ return
699
+ }
700
+
701
+ guard let services = peripheral.services else { return }
702
+
703
+ for service in services where service.uuid == SERVICE_UUID {
704
+ Bridge.log("LIVE: Found UART service, discovering characteristics...")
705
+ peripheral.discoverCharacteristics(
706
+ [
707
+ TX_CHAR_UUID, RX_CHAR_UUID, FILE_READ_UUID, FILE_WRITE_UUID, LC3_READ_UUID,
708
+ LC3_WRITE_UUID,
709
+ ], for: service
710
+ )
711
+ }
712
+ }
713
+
714
+ func peripheral(
715
+ _ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?
716
+ ) {
717
+ if let error {
718
+ Bridge.log("LIVE: Error discovering characteristics: \(error.localizedDescription)")
719
+ centralManager?.cancelPeripheralConnection(peripheral)
720
+ return
721
+ }
722
+
723
+ guard let characteristics = service.characteristics else { return }
724
+
725
+ for characteristic in characteristics {
726
+ // Log characteristic properties for debugging
727
+ let props = characteristic.properties
728
+ let propsStr = [
729
+ props.contains(.notify) ? "NOTIFY" : nil,
730
+ props.contains(.indicate) ? "INDICATE" : nil,
731
+ props.contains(.read) ? "READ" : nil,
732
+ props.contains(.write) ? "WRITE" : nil,
733
+ props.contains(.writeWithoutResponse) ? "WRITE_NO_RESPONSE" : nil,
734
+ ].compactMap { $0 }.joined(separator: " ")
735
+ Bridge.log("📋 Characteristic \(characteristic.uuid): properties=[\(propsStr)]")
736
+
737
+ if characteristic.uuid == TX_CHAR_UUID {
738
+ txCharacteristic = characteristic
739
+ Bridge.log("LIVE: ✅ Found TX characteristic")
740
+ } else if characteristic.uuid == RX_CHAR_UUID {
741
+ rxCharacteristic = characteristic
742
+ Bridge.log(
743
+ "LIVE: ✅ Found RX characteristic - hasNotify=\(props.contains(.notify)), hasIndicate=\(props.contains(.indicate))"
744
+ )
745
+ } else if characteristic.uuid == FILE_READ_UUID {
746
+ fileReadCharacteristic = characteristic
747
+ Bridge.log("LIVE: 📁 Found FILE_READ characteristic (72FF)!")
748
+ } else if characteristic.uuid == FILE_WRITE_UUID {
749
+ fileWriteCharacteristic = characteristic
750
+ Bridge.log("LIVE: 📁 Found FILE_WRITE characteristic (73FF)!")
751
+ } else if characteristic.uuid == LC3_READ_UUID {
752
+ lc3ReadCharacteristic = characteristic
753
+ Bridge.log("LIVE: 🎤 Found LC3_READ characteristic (audio input)!")
754
+ } else if characteristic.uuid == LC3_WRITE_UUID {
755
+ lc3WriteCharacteristic = characteristic
756
+ Bridge.log("LIVE: 🎤 Found LC3_WRITE characteristic (audio output)!")
757
+ }
758
+ }
759
+
760
+ // Check if we have both characteristics
761
+ if let tx = txCharacteristic, let rx = rxCharacteristic {
762
+ Bridge.log("LIVE: ✅ Both TX and RX characteristics found - BLE connection ready")
763
+ Bridge.log("LIVE: 🔄 Waiting for glasses SOC to become ready...")
764
+
765
+ // Don't set connected=true here - wait for SOC to be ready (fullyBooted=true)
766
+ // DeviceStore handles connected state based on fullyBooted
767
+
768
+ // Keep state as connecting until glasses are ready
769
+ updateConnectionState(ConnTypes.CONNECTING)
770
+
771
+ // Request MTU size
772
+ let mtuSize = peripheral.maximumWriteValueLength(for: .withResponse)
773
+ Bridge.log("LIVE: Current MTU size: \(mtuSize + 3) bytes")
774
+
775
+ // Enable notifications on RX characteristic
776
+ peripheral.setNotifyValue(true, for: rx)
777
+
778
+ // Enable notifications on file characteristics if available
779
+ if let fileRead = fileReadCharacteristic {
780
+ peripheral.setNotifyValue(true, for: fileRead)
781
+ }
782
+
783
+ // Enable notifications on LC3 audio characteristic if device supports it
784
+ if supportsLC3Audio, let lc3Read = lc3ReadCharacteristic {
785
+ peripheral.setNotifyValue(true, for: lc3Read)
786
+ Bridge.log("LIVE: 🎤 Enabled LC3 audio notifications")
787
+ }
788
+
789
+ // Start readiness check loop
790
+ startSignalStrengthPolling()
791
+ startReadinessCheckLoop()
792
+ } else {
793
+ Bridge.log("LIVE: Required BLE characteristics not found")
794
+ if txCharacteristic == nil {
795
+ Bridge.log("LIVE: TX characteristic not found")
796
+ }
797
+ if rxCharacteristic == nil {
798
+ Bridge.log("LIVE: RX characteristic not found")
799
+ }
800
+ centralManager?.cancelPeripheralConnection(peripheral)
801
+ }
802
+ }
803
+
804
+ func peripheral(
805
+ _: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?
806
+ ) {
807
+ // Bridge.log("LIVE: DEBUG: didUpdateValueFor CALLED - characteristic: \(characteristic.uuid), dataSize: \(characteristic.value?.count ?? 0)")
808
+ // Log raw hex for debugging glasses_ready issue
809
+ if let data = characteristic.value {
810
+ let hexString = data.prefix(50).map { String(format: "%02X ", $0) }.joined()
811
+ // Bridge.log("LIVE: DEBUG: RAW HEX (first 50): \(hexString)")
812
+ }
813
+ if let error {
814
+ Bridge.log(
815
+ "LIVE: Error updating value for characteristic: \(error.localizedDescription)"
816
+ )
817
+ return
818
+ }
819
+
820
+ guard let data = characteristic.value else {
821
+ Bridge.log("LIVE: Characteristic value is nil")
822
+ return
823
+ }
824
+
825
+ let threadId = Thread.current.hash
826
+ let uuid = characteristic.uuid
827
+
828
+ // Bridge.log("Thread-\(threadId): 🎉 didUpdateValueFor CALLBACK TRIGGERED! Characteristic: \(uuid)")
829
+ // if uuid == RX_CHAR_UUID {
830
+ // Bridge.log("Thread-\(threadId): 🎯 RECEIVED DATA ON RX CHARACTERISTIC (Peripheral's TX)")
831
+ // } else if uuid == TX_CHAR_UUID {
832
+ // Bridge.log("Thread-\(threadId): 🎯 RECEIVED DATA ON TX CHARACTERISTIC (Peripheral's RX)")
833
+ // }
834
+ // Bridge.log("Thread-\(threadId): 🔍 Processing received data - \(data.count) bytes")
835
+
836
+ // Handle LC3 audio data separately (dedicated characteristic for LC3-capable devices)
837
+ if uuid == LC3_READ_UUID && supportsLC3Audio {
838
+ // Bridge.log("LIVE: Received data on LC3_READ characteristic (audio input)")
839
+ processLc3AudioPacket(data)
840
+ return
841
+ }
842
+
843
+ // Handle regular data (JSON messages, file transfers, etc.)
844
+ processReceivedData(data)
845
+ }
846
+
847
+ func peripheral(_: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?) {
848
+ if let error {
849
+ Bridge.log("LIVE: Error writing characteristic: \(error.localizedDescription)")
850
+ } else {
851
+ Bridge.log("LIVE: Characteristic write successful")
852
+ }
853
+ }
854
+
855
+ func peripheral(
856
+ _: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic,
857
+ error: Error?
858
+ ) {
859
+ if let error {
860
+ Bridge.log("LIVE: Error updating notification state: \(error.localizedDescription)")
861
+ } else {
862
+ Bridge.log(
863
+ "Notification state updated for \(characteristic.uuid): \(characteristic.isNotifying ? "ON" : "OFF")"
864
+ )
865
+
866
+ if characteristic.uuid == RX_CHAR_UUID, characteristic.isNotifying {
867
+ Bridge.log("LIVE: 🔔 Ready to receive data via notifications")
868
+ }
869
+ }
870
+ }
871
+
872
+ func peripheralDidUpdateRSSI(_ peripheral: CBPeripheral, error: Error?) {
873
+ if let error {
874
+ Bridge.log("LIVE: Error reading RSSI: \(error.localizedDescription)")
875
+ } else {
876
+ Bridge.log("LIVE: RSSI: \(peripheral.readRSSI())")
877
+ }
878
+ }
879
+ }
880
+
881
+ enum MentraLiveError: Error {
882
+ case bluetoothNotAvailable
883
+ case bluetoothNotPowered
884
+ case connectionTimeout
885
+ case missingCharacteristics
886
+ case missingPermissions
887
+ }
888
+
889
+ enum MentraLiveConnectionState {
890
+ case disconnected
891
+ case connecting
892
+ case connected
893
+ }
894
+
895
+ /// Type aliases for compatibility
896
+ typealias JSONObject = [String: Any]
897
+
898
+ // MARK: - Main Manager Class
899
+
900
+ @MainActor
901
+ class MentraLive: NSObject, SGCManager {
902
+ // Feature Flags
903
+ // BLOCK_AUDIO_DUPLEX: When true, suspends LC3 mic while phone is playing audio via A2DP
904
+ // to avoid overloading the MCU. Set to false to allow simultaneous A2DP + LC3 mic.
905
+ private let BLOCK_AUDIO_DUPLEX = false
906
+
907
+ var connectionState: String = ConnTypes.DISCONNECTED
908
+
909
+ /// Mirrors Android `updateConnectionState` — RN home reads `glasses.connectionState` for reconnecting UI.
910
+ private func updateConnectionState(_ state: String) {
911
+ connectionState = state
912
+ DeviceStore.shared.apply("glasses", "connectionState", state)
913
+ // Drop OTA caches when fully disconnected — avoids leaking session/step state from
914
+ // a previous pairing into the next one (would otherwise surface as wrong overall_percent
915
+ // or stale lastBesOtaProgress on the next OTA).
916
+ if state == ConnTypes.DISCONNECTED {
917
+ stopSignalStrengthPolling()
918
+ DeviceStore.shared.apply("glasses", "signalStrength", -1)
919
+ DeviceStore.shared.apply("glasses", "signalStrengthUpdatedAt", 0)
920
+ resetOtaCache()
921
+ }
922
+ }
923
+
924
+ func setDashboardPosition(_: Int, _: Int) {}
925
+ func setSilentMode(_: Bool) {}
926
+ func exit() {}
927
+ func showDashboard() {}
928
+ func displayBitmap(base64ImageData _: String) async -> Bool {
929
+ return true
930
+ }
931
+
932
+ func sendDoubleTextWall(_: String, _: String) {}
933
+ func setHeadUpAngle(_: Int) {}
934
+ func getBatteryStatus() {}
935
+ func setBrightness(_: Int, autoMode _: Bool) {}
936
+ func clearDisplay() {}
937
+ func sendTextWall(_: String) {}
938
+ func ping() {
939
+ Bridge.log("LIVE: ping()")
940
+ keepAwake()
941
+ }
942
+
943
+ func connectController() {}
944
+ func disconnectController() {}
945
+
946
+ func dbg1() {}
947
+ func dbg2() {}
948
+
949
+ func forget() {
950
+ Bridge.log("LIVE: Forgetting Mentra Live glasses")
951
+
952
+ // Stop scanning first
953
+ if isScanning {
954
+ stopScan()
955
+ emitStopScanEvent()
956
+ }
957
+
958
+ // Then do full cleanup (disconnect + clear all references)
959
+ destroy()
960
+ }
961
+
962
+ var type = "Mentra Live"
963
+ var hasMic = true
964
+
965
+ func setMicEnabled(_ enabled: Bool) {
966
+ Bridge.log("LIVE: 🎤 Microphone state change requested: \(enabled)")
967
+ DeviceStore.shared.apply("glasses", "micEnabled", enabled)
968
+
969
+ // Only enable if device supports LC3 audio
970
+ guard supportsLC3Audio else {
971
+ Bridge.log("LIVE: Device does not support LC3 audio, ignoring mic enable request")
972
+ return
973
+ }
974
+
975
+ // Update shouldUseGlassesMic based on enabled state
976
+ shouldUseGlassesMic = enabled
977
+
978
+ // Update the intent state for the suspend/resume state machine
979
+ micIntentEnabled = enabled
980
+
981
+ if enabled {
982
+ // User wants mic ON
983
+ // Check if we should suspend due to phone audio (only if BLOCK_AUDIO_DUPLEX is enabled)
984
+ if BLOCK_AUDIO_DUPLEX, let monitor = phoneAudioMonitor, monitor.isPlaying() {
985
+ // Phone is currently playing audio - don't start mic yet, mark as suspended
986
+ micSuspendedForAudio = true
987
+ Bridge.log(
988
+ "LIVE: 🎤 Mic requested but phone audio is playing - suspending until audio stops"
989
+ )
990
+ } else {
991
+ // Safe to start mic
992
+ micSuspendedForAudio = false
993
+ Bridge.log("LIVE: 🎤 Microphone enabled, starting audio input handling")
994
+ startMicBeat()
995
+ }
996
+ } else {
997
+ // User wants mic OFF - clear suspended state and stop
998
+ micSuspendedForAudio = false
999
+ Bridge.log("LIVE: 🎤 Microphone disabled, stopping audio input handling")
1000
+ stopMicBeat()
1001
+ }
1002
+ }
1003
+
1004
+ func sortMicRanking(list: [String]) -> [String] {
1005
+ return list
1006
+ }
1007
+
1008
+ // BLE UUIDs
1009
+ private let SERVICE_UUID = CBUUID(string: "00004860-0000-1000-8000-00805f9b34fb")
1010
+ private let RX_CHAR_UUID = CBUUID(string: "000070FF-0000-1000-8000-00805f9b34fb") // Central receives on peripheral's TX
1011
+ private let TX_CHAR_UUID = CBUUID(string: "000071FF-0000-1000-8000-00805f9b34fb") // Central transmits on peripheral's RX
1012
+ private let FILE_READ_UUID = CBUUID(string: "000072FF-0000-1000-8000-00805f9b34fb")
1013
+ private let FILE_WRITE_UUID = CBUUID(string: "000073FF-0000-1000-8000-00805f9b34fb")
1014
+
1015
+ // LC3 Audio UUIDs (for K901+ devices with microphone support)
1016
+ private let LC3_READ_UUID = CBUUID(string: "6E400002-B5A3-F393-E0A9-E50E24DCCA9E")
1017
+ private let LC3_WRITE_UUID = CBUUID(string: "6E400003-B5A3-F393-E0A9-E50E24DCCA9E")
1018
+
1019
+ private let FILE_SAVE_DIR = "MentraLive_Images"
1020
+
1021
+ // NEW: File transfer properties
1022
+ private var fileReadCharacteristic: CBCharacteristic?
1023
+ private var fileWriteCharacteristic: CBCharacteristic?
1024
+ private var activeFileTransfers = [String: FileTransferSession]()
1025
+ private var blePhotoTransfers = [String: BlePhotoTransfer]()
1026
+ private var bleIncidentLogRelays = [String: BleIncidentLogRelayEntry]()
1027
+ private var rgbLedAuthorityClaimed = false
1028
+
1029
+ // LC3 Audio properties
1030
+ private var lc3ReadCharacteristic: CBCharacteristic?
1031
+ private var lc3WriteCharacteristic: CBCharacteristic?
1032
+ private var supportsLC3Audio = true
1033
+ private var lastReceivedLc3Sequence: Int8 = -1
1034
+ private let LC3_FRAME_SIZE = 40 // bytes per LC3 frame
1035
+ private let MICBEAT_INTERVAL_MS: TimeInterval = 30 * 60 // 30 minutes in seconds
1036
+ private var micBeatTimer: Timer?
1037
+ private var micBeatCount = 0
1038
+ private var shouldUseGlassesMic = false
1039
+
1040
+ // LC3 Mic suspend/resume state machine for A2DP conflict avoidance
1041
+ // When phone plays audio via A2DP while LC3 mic is active, it overloads the MCU
1042
+ // So we temporarily suspend the LC3 mic during phone audio playback
1043
+ private var micIntentEnabled = false // User/system WANTS mic enabled
1044
+ private var micSuspendedForAudio = false // Mic temporarily suspended due to phone audio
1045
+ private var phoneAudioMonitor: PhoneAudioMonitor?
1046
+
1047
+ // Timing Constants
1048
+ private let BASE_RECONNECT_DELAY_MS: UInt64 = 1_000_000_000 // 1 second in nanoseconds
1049
+ private let MAX_RECONNECT_DELAY_MS: UInt64 = 30_000_000_000 // 30 seconds
1050
+ private let MAX_RECONNECT_ATTEMPTS = 10
1051
+ private let KEEP_ALIVE_INTERVAL_MS: UInt64 = 5_000_000_000 // 5 seconds
1052
+ private let CONNECTION_TIMEOUT_MS: UInt64 = 100_000_000_000 // 100 seconds
1053
+ private let HEARTBEAT_INTERVAL_MS: TimeInterval = 30.0 // 30 seconds
1054
+ private let BATTERY_REQUEST_EVERY_N_HEARTBEATS = 10
1055
+ private let SIGNAL_STRENGTH_READ_INTERVAL_MS: TimeInterval = 10.0
1056
+ private let MIN_SEND_DELAY_MS: UInt64 = 160_000_000 // 160ms in nanoseconds
1057
+ private let READINESS_CHECK_INTERVAL_MS: TimeInterval = 2.5 // 2.5 seconds
1058
+
1059
+ /// Device Settings Keys
1060
+ private let PREFS_DEVICE_NAME = "MentraLiveLastConnectedDeviceName"
1061
+
1062
+ // MARK: - Properties
1063
+
1064
+ @objc static func requiresMainQueueSetup() -> Bool {
1065
+ true
1066
+ }
1067
+
1068
+ /// BLE Properties
1069
+ private var centralManager: CBCentralManager?
1070
+
1071
+ private var connectedPeripheral: CBPeripheral?
1072
+ private var txCharacteristic: CBCharacteristic?
1073
+ private var rxCharacteristic: CBCharacteristic?
1074
+ private var currentMtu: Int = 23 // Default BLE MTU
1075
+
1076
+ // State Tracking
1077
+ private var isScanning = false
1078
+ private var isConnecting = false
1079
+ private var isKilled = false
1080
+ private var reconnectAttempts = 0
1081
+ private var isNewVersion = false
1082
+ private var globalMessageId = 0
1083
+ private var lastReceivedMessageId = 0
1084
+
1085
+ private var fullyBooted: Bool {
1086
+ get { DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false }
1087
+ set { DeviceStore.shared.apply("glasses", "fullyBooted", newValue) }
1088
+ }
1089
+
1090
+ private var connected: Bool {
1091
+ get { DeviceStore.shared.get("glasses", "connected") as? Bool ?? false }
1092
+ set { DeviceStore.shared.apply("glasses", "connected", newValue) }
1093
+ }
1094
+
1095
+ // Queue Management
1096
+ private let commandQueue = CommandQueue()
1097
+ private let bluetoothQueue = DispatchQueue(label: "MentraLiveBluetooth", qos: .userInitiated)
1098
+ private var lastSendTimeMs: TimeInterval = 0
1099
+
1100
+ // Timers
1101
+ private var heartbeatTimer: Timer?
1102
+ private var heartbeatCounter = 0
1103
+ private var signalStrengthTimer: DispatchSourceTimer?
1104
+ private var signalStrengthReadInFlight = false
1105
+ private var readinessCheckTimer: Timer?
1106
+ private var readinessCheckCounter = 0
1107
+
1108
+ /// BES OTA progress tracking - only send to UI on 5% increments
1109
+ private var lastBesOtaProgress = -1
1110
+
1111
+ // Cached OTA session context from last ota_status — used to fill in session fields for sr_adota
1112
+ private var cachedOtaSessionId: String?
1113
+ private var cachedOtaTotalSteps = 0
1114
+ private var cachedOtaCurrentStep = 0
1115
+ /// Step type sequence (e.g. ["apk","bes"]) from last ota_status; used to compute BES weight in sr_adota.
1116
+ private var cachedOtaStepSequence: [String]?
1117
+
1118
+ // Glasses media volume (K900 cs_getvol / cs_vol, sr_getvol / sr_vol)
1119
+ private let glassesMediaVolumeLock = NSLock()
1120
+ private var glassesMediaVolumeGetCompletion: ((Result<[String: Any], Error>) -> Void)?
1121
+ private var glassesMediaVolumeSetCompletion: ((Result<[String: Any], Error>) -> Void)?
1122
+ private var glassesMediaVolumeTimeoutWorkItem: DispatchWorkItem?
1123
+ private static let glassesMediaVolumeTimeoutSec: TimeInterval = 2.0
1124
+
1125
+ private var connectionTimeoutTimer: Timer?
1126
+ private var reconnectionWorkItem: DispatchWorkItem?
1127
+
1128
+ // MARK: - Initialization
1129
+
1130
+ override init() {
1131
+ super.init()
1132
+ setupCommandQueue()
1133
+
1134
+ // Initialize phone audio monitor for LC3 mic suspend/resume (if enabled)
1135
+ // This detects when phone is playing audio and temporarily suspends LC3 mic
1136
+ // to avoid overloading the MCU when both A2DP output and LC3 mic input are active
1137
+ if BLOCK_AUDIO_DUPLEX {
1138
+ phoneAudioMonitor = PhoneAudioMonitor.getInstance()
1139
+ phoneAudioMonitor?.startMonitoring(listener: self)
1140
+ Bridge.log(
1141
+ "LIVE: 🎵 Phone audio monitor started for LC3 mic suspend/resume (BLOCK_AUDIO_DUPLEX=true)"
1142
+ )
1143
+ } else {
1144
+ Bridge.log("LIVE: 🎵 Phone audio monitor disabled (BLOCK_AUDIO_DUPLEX=false)")
1145
+ }
1146
+ }
1147
+
1148
+ deinit {
1149
+ // Prevent delegate callbacks to deallocated object
1150
+ centralManager?.delegate = nil
1151
+ connectedPeripheral?.delegate = nil
1152
+ Bridge.log("MentraLive: deinitialized")
1153
+ }
1154
+
1155
+ func cleanup() {
1156
+ destroy()
1157
+ }
1158
+
1159
+ // MARK: - React Native Interface
1160
+
1161
+ private var discoveredPeripherals = [String: CBPeripheral]() // name -> peripheral
1162
+
1163
+ func findCompatibleDevices() {
1164
+ Bridge.log("Finding compatible Mentra Live glasses")
1165
+
1166
+ Task {
1167
+ if centralManager == nil {
1168
+ centralManager = CBCentralManager(
1169
+ delegate: self, queue: bluetoothQueue,
1170
+ options: ["CBCentralManagerOptionShowPowerAlertKey": 0]
1171
+ )
1172
+ // wait for the central manager to be fully initialized before we start scanning:
1173
+ try? await Task.sleep(nanoseconds: 100 * 1_000_000) // 100ms
1174
+ }
1175
+
1176
+ // clear the saved device name:
1177
+ UserDefaults.standard.set("", forKey: PREFS_DEVICE_NAME)
1178
+
1179
+ startScan()
1180
+ }
1181
+ }
1182
+
1183
+ func connectById(_ deviceName: String) {
1184
+ Bridge.log("connectById: \(deviceName)")
1185
+ // Save the device name for future reconnection
1186
+ UserDefaults.standard.set(deviceName, forKey: PREFS_DEVICE_NAME)
1187
+
1188
+ // Start scanning to find this specific device
1189
+ if centralManager == nil {
1190
+ centralManager = CBCentralManager(
1191
+ delegate: self, queue: bluetoothQueue,
1192
+ options: ["CBCentralManagerOptionShowPowerAlertKey": 0]
1193
+ )
1194
+ }
1195
+
1196
+ // Check for already-connected peripherals first
1197
+ let connectedPeripherals = centralManager!.retrieveConnectedPeripherals(withServices: [
1198
+ SERVICE_UUID,
1199
+ ])
1200
+ for peripheral in connectedPeripherals {
1201
+ Bridge.log("Found already-connected peripheral: \(peripheral.name ?? "Unknown")")
1202
+ if let name = peripheral.name,
1203
+ name == "Xy_A" || name.hasPrefix("XyBLE_") || name.hasPrefix("MENTRA_LIVE_BLE")
1204
+ || name.hasPrefix("MENTRA_LIVE_BT")
1205
+ {
1206
+ Bridge.log("Found already-connected peripheral: \(name)")
1207
+ discoveredPeripherals[name] = peripheral
1208
+ emitDiscoveredDevice(name)
1209
+
1210
+ // Check if this is the device we want
1211
+ if let savedDeviceName = UserDefaults.standard.string(
1212
+ forKey: PREFS_DEVICE_NAME
1213
+ ),
1214
+ savedDeviceName == name
1215
+ {
1216
+ Bridge.log(
1217
+ "Found our remembered device already connected, connecting: \(name)"
1218
+ )
1219
+ connectToDevice(peripheral)
1220
+ return
1221
+ }
1222
+ }
1223
+ }
1224
+
1225
+ // Will connect when found during scan
1226
+ startScan()
1227
+ }
1228
+
1229
+ func getConnectedBluetoothName() -> String? {
1230
+ return connectedPeripheral?.name
1231
+ }
1232
+
1233
+ @objc func disconnect() {
1234
+ Bridge.log("LIVE: disconnect() -Disconnecting from Mentra Live glasses")
1235
+
1236
+ // if rgbLedAuthorityClaimed {
1237
+ // sendRgbLedControlAuthority(false)
1238
+ // }
1239
+
1240
+ // // Clear any pending messages
1241
+ // pending = nil
1242
+ // pendingMessageTimer?.invalidate()
1243
+ // pendingMessageTimer = nil
1244
+
1245
+ // if let peripheral = connectedPeripheral {
1246
+ // centralManager?.cancelPeripheralConnection(peripheral)
1247
+ // }
1248
+
1249
+ // stopAllTimers()
1250
+ // connectionState = ConnTypes.DISCONNECTED
1251
+ // rgbLedAuthorityClaimed = false
1252
+ destroy()
1253
+ }
1254
+
1255
+ // MARK: - Micbeat System (LC3 Audio Keepalive)
1256
+
1257
+ /// Start the micbeat mechanism to keep LC3 audio streaming active
1258
+ private func startMicBeat() {
1259
+ Bridge.log("LIVE: 🎤 Starting micbeat mechanism")
1260
+ micBeatCount = 0
1261
+
1262
+ // Send initial command to enable custom audio TX
1263
+ sendEnableCustomAudioTxMessage(shouldUseGlassesMic)
1264
+
1265
+ // Stop any existing timer
1266
+ micBeatTimer?.invalidate()
1267
+
1268
+ // Schedule periodic micbeat (every 30 minutes)
1269
+ micBeatTimer = Timer.scheduledTimer(withTimeInterval: MICBEAT_INTERVAL_MS, repeats: true) {
1270
+ [weak self] _ in
1271
+ guard let self = self else { return }
1272
+ Bridge.log("LIVE: 🎤 Sending micbeat - enabling custom audio TX")
1273
+ self.sendEnableCustomAudioTxMessage(self.shouldUseGlassesMic)
1274
+ self.micBeatCount += 1
1275
+ }
1276
+
1277
+ Bridge.log("LIVE: Micbeat scheduled every \(MICBEAT_INTERVAL_MS / 60) minutes")
1278
+ }
1279
+
1280
+ /// Stop the micbeat mechanism
1281
+ private func stopMicBeat() {
1282
+ Bridge.log("LIVE: 🎤 Stopping micbeat mechanism")
1283
+ sendEnableCustomAudioTxMessage(false)
1284
+ micBeatTimer?.invalidate()
1285
+ micBeatTimer = nil
1286
+ micBeatCount = 0
1287
+ }
1288
+
1289
+ /// Send command to enable/disable custom audio TX on glasses
1290
+ @objc func sendEnableCustomAudioTxMessage(_ enabled: Bool) {
1291
+ Bridge.log("LIVE: Setting microphone state to: \(enabled)")
1292
+
1293
+ do {
1294
+ let enableData = try JSONSerialization.data(withJSONObject: ["enable": enabled])
1295
+ let enableString = String(data: enableData, encoding: .utf8) ?? ""
1296
+
1297
+ let command: [String: Any] = [
1298
+ "C": "enable_custom_audio_tx",
1299
+ "B": enableString,
1300
+ ]
1301
+
1302
+ if sendRawK900Command(command) {
1303
+ Bridge.log("LIVE: Sent enable_custom_audio_tx via queue (BES-handled command)")
1304
+ } else {
1305
+ Bridge.log("LIVE: Failed to send enable_custom_audio_tx")
1306
+ }
1307
+ } catch {
1308
+ Bridge.log("Error creating enable_custom_audio_tx request: \(error)")
1309
+ }
1310
+ }
1311
+
1312
+ func requestPhoto(
1313
+ _ requestId: String, appId: String, size: String?, webhookUrl: String?, authToken: String?,
1314
+ compress: String?, flash: Bool, sound: Bool
1315
+ ) {
1316
+ Bridge.log("Requesting photo: \(requestId) for app: \(appId), flash: \(flash), sound: \(sound)")
1317
+
1318
+ var json: [String: Any] = [
1319
+ "type": "take_photo",
1320
+ "requestId": requestId,
1321
+ "appId": appId,
1322
+ ]
1323
+
1324
+ // Always generate BLE ID for potential fallback
1325
+ let bleImgId =
1326
+ "I" + String(format: "%09d", Int(Date().timeIntervalSince1970 * 1000) % 100_000_000)
1327
+ json["bleImgId"] = bleImgId
1328
+ json["transferMethod"] = "auto"
1329
+
1330
+ if let webhookUrl, !webhookUrl.isEmpty {
1331
+ json["webhookUrl"] = webhookUrl
1332
+
1333
+ var transfer = BlePhotoTransfer(
1334
+ bleImgId: bleImgId, requestId: requestId, webhookUrl: webhookUrl
1335
+ )
1336
+
1337
+ // Store authToken for BLE transfer if provided
1338
+ if let authToken, !authToken.isEmpty {
1339
+ transfer.authToken = authToken
1340
+ }
1341
+
1342
+ blePhotoTransfers[bleImgId] = transfer
1343
+ }
1344
+
1345
+ // Add authToken to JSON if provided
1346
+ if let authToken, !authToken.isEmpty {
1347
+ json["authToken"] = authToken
1348
+ }
1349
+
1350
+ // propagate size (default to medium if invalid)
1351
+ if let size, ["small", "medium", "large", "full"].contains(size) {
1352
+ json["size"] = size
1353
+ } else {
1354
+ json["size"] = "medium"
1355
+ }
1356
+
1357
+ // Add compress parameter
1358
+ json["compress"] = compress ?? "none"
1359
+
1360
+ json["flash"] = flash
1361
+ json["sound"] = sound
1362
+
1363
+ Bridge.log("Using auto transfer mode with BLE fallback ID: \(bleImgId)")
1364
+
1365
+ sendJson(json, wakeUp: true)
1366
+ }
1367
+
1368
+ func startStream(_ message: [String: Any]) {
1369
+ Bridge.log("Starting stream")
1370
+ var json = message
1371
+ json.removeValue(forKey: "timestamp")
1372
+ sendJson(json, wakeUp: true)
1373
+ }
1374
+
1375
+ func stopStream() {
1376
+ Bridge.log("Stopping stream")
1377
+ let json: [String: Any] = ["type": "stop_stream"]
1378
+ sendJson(json, wakeUp: true)
1379
+ }
1380
+
1381
+ func sendStreamKeepAlive(_ message: [String: Any]) {
1382
+ Bridge.log("Sending stream keep alive")
1383
+ sendJson(message)
1384
+ }
1385
+
1386
+ @objc func startRecordVideo() {
1387
+ let json: [String: Any] = ["type": "start_record_video"]
1388
+ sendJson(json, wakeUp: true)
1389
+ }
1390
+
1391
+ @objc func stopRecordVideo() {
1392
+ let json: [String: Any] = ["type": "stop_record_video"]
1393
+ sendJson(json, wakeUp: true)
1394
+ }
1395
+
1396
+ @objc func startVideoStream() {
1397
+ let json: [String: Any] = ["type": "start_video_stream"]
1398
+ sendJson(json, wakeUp: true)
1399
+ }
1400
+
1401
+ @objc func stopVideoStream() {
1402
+ let json: [String: Any] = ["type": "stop_video_stream"]
1403
+ sendJson(json, wakeUp: true)
1404
+ }
1405
+
1406
+ // MARK: - Command Queue
1407
+
1408
+ class PendingMessage {
1409
+ init(data: Data, id: String, retries: Int) {
1410
+ self.data = data
1411
+ self.id = id
1412
+ self.retries = retries
1413
+ }
1414
+
1415
+ let data: Data
1416
+ let retries: Int
1417
+ let id: String
1418
+ }
1419
+
1420
+ private var pending: PendingMessage?
1421
+ private var pendingMessageTimer: Timer?
1422
+
1423
+ actor CommandQueue {
1424
+ private var commands: [PendingMessage] = []
1425
+
1426
+ func enqueue(_ command: PendingMessage) {
1427
+ commands.append(command)
1428
+ }
1429
+
1430
+ func pushToFront(_ command: PendingMessage) {
1431
+ commands.insert(command, at: 0)
1432
+ }
1433
+
1434
+ func dequeue() -> PendingMessage? {
1435
+ guard !commands.isEmpty else { return nil }
1436
+ return commands.removeFirst()
1437
+ }
1438
+ }
1439
+
1440
+ private func setupCommandQueue() {
1441
+ Task.detached { [weak self] in
1442
+ guard let self else { return }
1443
+ while true {
1444
+ let pendingIsNil = await MainActor.run { self.pending == nil }
1445
+ if pendingIsNil {
1446
+ if let command = await self.commandQueue.dequeue() {
1447
+ await self.processSendQueue(command)
1448
+ }
1449
+ }
1450
+ try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
1451
+ }
1452
+ }
1453
+ }
1454
+
1455
+ private func processSendQueue(_ message: PendingMessage) async {
1456
+ guard let peripheral = connectedPeripheral,
1457
+ let txChar = txCharacteristic
1458
+ else {
1459
+ return
1460
+ }
1461
+
1462
+ // Enforce rate limiting
1463
+ let currentTime = Date().timeIntervalSince1970 * 1000
1464
+ let timeSinceLastSend = currentTime - lastSendTimeMs
1465
+
1466
+ try? await Task.sleep(nanoseconds: UInt64(1_000_000))
1467
+ lastSendTimeMs = Date().timeIntervalSince1970 * 1000
1468
+
1469
+ // Send the data
1470
+ peripheral.writeValue(message.data, for: txChar, type: .withResponse)
1471
+
1472
+ // don't do the retry system on the old glasses versions
1473
+ if !isNewVersion {
1474
+ return
1475
+ }
1476
+
1477
+ // Only set as pending and track ACK if ID is not "-1"
1478
+ // ID of "-1" means no ACK tracking (e.g., for heartbeats)
1479
+ if message.id != "-1" {
1480
+ // Set the pending message
1481
+ pending = message
1482
+
1483
+ // Start retry timer for 1s
1484
+ DispatchQueue.main.async { [weak self] in
1485
+ self?.pendingMessageTimer?.invalidate()
1486
+ self?.pendingMessageTimer = Timer.scheduledTimer(
1487
+ withTimeInterval: 1, repeats: false
1488
+ ) { _ in
1489
+ self?.handlePendingMessageTimeout()
1490
+ }
1491
+ }
1492
+ }
1493
+ // If ID is "-1", don't track for ACK - just send and forget
1494
+ }
1495
+
1496
+ private func handlePendingMessageTimeout() {
1497
+ guard let pendingMessage = pending else { return }
1498
+
1499
+ Bridge.log(
1500
+ "⚠️ Message timeout - no response for mId: \(pendingMessage.id), retry attempt: \(pendingMessage.retries + 1)/3"
1501
+ )
1502
+
1503
+ // Clear the pending message
1504
+ pending = nil
1505
+
1506
+ // Check if we should retry
1507
+ if pendingMessage.retries < 3 {
1508
+ // Create a new message with incremented retry count
1509
+ let retryMessage = PendingMessage(
1510
+ data: pendingMessage.data,
1511
+ id: pendingMessage.id,
1512
+ retries: pendingMessage.retries + 1
1513
+ )
1514
+
1515
+ // Push to front of queue for immediate retry
1516
+ Task {
1517
+ await self.commandQueue.pushToFront(retryMessage)
1518
+ }
1519
+
1520
+ Bridge.log(
1521
+ "🔄 Retrying message mId: \(pendingMessage.id) (attempt \(retryMessage.retries)/3)"
1522
+ )
1523
+ } else {
1524
+ Bridge.log("❌ Message failed after 3 retries - mId: \(pendingMessage.id)")
1525
+ // Optionally emit an event or callback for failed message
1526
+ }
1527
+ }
1528
+
1529
+ // MARK: - BLE Scanning
1530
+
1531
+ private func startScan() {
1532
+ // guard !isScanning else { return }
1533
+
1534
+ guard centralManager!.state == .poweredOn else {
1535
+ Bridge.log("Attempting to scan but bluetooth is not powered on.")
1536
+ return
1537
+ }
1538
+
1539
+ Bridge.log("Starting BLE scan for Mentra Live glasses")
1540
+ isScanning = true
1541
+
1542
+ startReadinessCheckLoop()
1543
+
1544
+ let scanOptions: [String: Any] = [
1545
+ CBCentralManagerScanOptionAllowDuplicatesKey: false,
1546
+ ]
1547
+
1548
+ // let knownPeripherals = centralManager?.retrieveConnectedPeripherals(withServices: [SERVICE_UUID])
1549
+ // // check already known peripherals:
1550
+ // for peripheral in knownPeripherals {
1551
+ // handleDiscoveredPeripheral(peripheral)
1552
+ // }
1553
+
1554
+ centralManager?.scanForPeripherals(withServices: nil, options: scanOptions)
1555
+
1556
+ // emit already discovered peripherals:
1557
+ for (_, peripheral) in discoveredPeripherals {
1558
+ Bridge.log("LIVE: (Already discovered) peripheral: \(peripheral.name ?? "Unknown")")
1559
+ emitDiscoveredDevice(peripheral.name!)
1560
+ }
1561
+
1562
+ // var dName = DeviceManager.shared.deviceName
1563
+ // if dName.isEmpty {
1564
+ // dName = "MENTRA_LIVE"
1565
+ // }
1566
+
1567
+ // setupAudioPairing(deviceName: dName)
1568
+
1569
+ // // Set scan timeout
1570
+ // DispatchQueue.main.asyncAfter(deadline: .now() + 60.0) { [weak self] in
1571
+ // if self?.isScanning == true {
1572
+ // Bridge.log("Scan timeout reached - stopping BLE scan")
1573
+ // self?.stopScan()
1574
+ // }
1575
+ // }
1576
+ }
1577
+
1578
+ func stopScan() {
1579
+ guard isScanning else { return }
1580
+
1581
+ centralManager?.stopScan()
1582
+ isScanning = false
1583
+ DeviceStore.shared.apply(ObservableStore.bluetoothCategory, "searching", false)
1584
+ Bridge.log("LIVE: BLE scan stopped")
1585
+ }
1586
+
1587
+ // MARK: - Connection Management
1588
+
1589
+ private func connectToDevice(_ peripheral: CBPeripheral) {
1590
+ Bridge.log("LIVE: Connecting to device: \(peripheral.identifier.uuidString)")
1591
+
1592
+ isConnecting = true
1593
+ updateConnectionState(ConnTypes.CONNECTING)
1594
+ connectedPeripheral = peripheral
1595
+ peripheral.delegate = self
1596
+
1597
+ // Set connection timeout
1598
+ startConnectionTimeout()
1599
+
1600
+ centralManager?.connect(peripheral, options: nil)
1601
+ }
1602
+
1603
+ private func handleReconnection() {
1604
+ if isKilled {
1605
+ Bridge.log("LIVE: Reconnection aborted - device has been killed")
1606
+ return
1607
+ }
1608
+
1609
+ // Check if we've exceeded max attempts
1610
+ if reconnectAttempts >= MAX_RECONNECT_ATTEMPTS {
1611
+ Bridge.log("LIVE: Maximum reconnection attempts reached (\(MAX_RECONNECT_ATTEMPTS))")
1612
+ reconnectAttempts = 0
1613
+ updateConnectionState(ConnTypes.DISCONNECTED)
1614
+ connected = false
1615
+ fullyBooted = false
1616
+ return
1617
+ }
1618
+
1619
+ // Calculate delay with exponential backoff
1620
+ let delayNanoseconds = min(
1621
+ BASE_RECONNECT_DELAY_MS * UInt64(1 << reconnectAttempts), MAX_RECONNECT_DELAY_MS
1622
+ )
1623
+ reconnectAttempts += 1
1624
+
1625
+ // RN keys off connectionState for reconnecting affordance during backoff (matches Android).
1626
+ updateConnectionState(ConnTypes.CONNECTING)
1627
+
1628
+ Bridge.log(
1629
+ "LIVE: Scheduling reconnection attempt \(reconnectAttempts) in \(Double(delayNanoseconds) / 1_000_000_000)s (max \(MAX_RECONNECT_ATTEMPTS))"
1630
+ )
1631
+
1632
+ // Schedule reconnection attempt
1633
+ let workItem = DispatchWorkItem { [weak self] in
1634
+ guard let self = self else { return }
1635
+
1636
+ // Use peripheral presence, not connectionState: we stay CONNECTING during backoff until scan/connect.
1637
+ if self.connectedPeripheral == nil, !self.isKilled {
1638
+ // Check for last known device name to start scan
1639
+ if let lastDeviceName = UserDefaults.standard.string(
1640
+ forKey: self.PREFS_DEVICE_NAME
1641
+ ), !lastDeviceName.isEmpty {
1642
+ Bridge.log(
1643
+ "LIVE: Reconnection attempt \(self.reconnectAttempts) - looking for device with name: \(lastDeviceName)"
1644
+ )
1645
+ // Start scan to find this device
1646
+ // The scan will automatically connect if it finds a device with the saved name
1647
+ self.startScan()
1648
+ } else {
1649
+ Bridge.log(
1650
+ "LIVE: Reconnection attempt \(self.reconnectAttempts) - no last device name available"
1651
+ )
1652
+ self.updateConnectionState(ConnTypes.DISCONNECTED)
1653
+ }
1654
+ }
1655
+ }
1656
+
1657
+ // Store the work item so it can be cancelled if needed
1658
+ reconnectionWorkItem = workItem
1659
+
1660
+ // Schedule the work item
1661
+ DispatchQueue.main.asyncAfter(
1662
+ deadline: .now() + .nanoseconds(Int(delayNanoseconds)), execute: workItem
1663
+ )
1664
+ }
1665
+
1666
+ // MARK: - Data Processing
1667
+
1668
+ private func processReceivedData(_ data: Data) {
1669
+ guard data.count > 0 else { return }
1670
+
1671
+ let bytes = [UInt8](data)
1672
+
1673
+ // Log first few bytes for debugging
1674
+ let hexString = data.prefix(16).map { String(format: "%02X ", $0) }.joined()
1675
+ // Bridge.log("LIVE: Processing data packet, first \(min(data.count, 16)) bytes: \(hexString)")
1676
+
1677
+ // Check for K900 protocol format (starts with ##)
1678
+ if data.count >= 7, bytes[0] == 0x23, bytes[1] == 0x23 {
1679
+ processK900ProtocolData(data)
1680
+ return
1681
+ }
1682
+
1683
+ // Check for JSON data
1684
+ if bytes[0] == 0x7B { // '{'
1685
+ if let jsonString = String(data: data, encoding: .utf8),
1686
+ jsonString.hasPrefix("{"), jsonString.hasSuffix("}")
1687
+ {
1688
+ processJsonMessage(jsonString)
1689
+ }
1690
+ }
1691
+ }
1692
+
1693
+ private func processK900ProtocolData(_ data: Data) {
1694
+ let bytes = [UInt8](data)
1695
+
1696
+ let commandType = bytes[2]
1697
+
1698
+ // Check if this is a file transfer packet
1699
+ if commandType == K900ProtocolUtils.CMD_TYPE_PHOTO
1700
+ || commandType == K900ProtocolUtils.CMD_TYPE_VIDEO
1701
+ || commandType == K900ProtocolUtils.CMD_TYPE_AUDIO
1702
+ || commandType == K900ProtocolUtils.CMD_TYPE_DATA
1703
+ {
1704
+ Bridge.log(
1705
+ "📦 DETECTED FILE TRANSFER PACKET (type: 0x\(String(format: "%02X", commandType)))"
1706
+ )
1707
+
1708
+ // Debug: Log the raw data
1709
+ // let hexDump = data.prefix(64).map { String(format: "%02X ", $0) }.joined()
1710
+ // Bridge.log("📦 Raw file packet data length=\(data.count), first 64 bytes: \(hexDump)")
1711
+
1712
+ // The data IS the file packet - it starts with ## and contains the full file packet structure
1713
+ if let packetInfo = K900ProtocolUtils.extractFilePacket(data) {
1714
+ processFilePacket(packetInfo)
1715
+ } else {
1716
+ Bridge.log("Failed to extract or validate file packet")
1717
+ // BES chip handles ACKs automatically
1718
+ }
1719
+
1720
+ return // Exit after processing file packet
1721
+ }
1722
+
1723
+ let payloadLength: Int
1724
+
1725
+ // Determine endianness based on device name
1726
+ if let deviceName = connectedPeripheral?.name,
1727
+ deviceName.hasPrefix("XyBLE_") || deviceName.lowercased().hasPrefix("mentra_live")
1728
+ {
1729
+ // K900 device - big-endian
1730
+ payloadLength = (Int(bytes[3]) << 8) | Int(bytes[4])
1731
+ } else {
1732
+ // Standard device - little-endian
1733
+ payloadLength = (Int(bytes[4]) << 8) | Int(bytes[3])
1734
+ }
1735
+
1736
+ // Bridge.log(
1737
+ // "K900 Protocol - Command: 0x\(String(format: "%02X", commandType)), Payload length: \(payloadLength)"
1738
+ // )
1739
+
1740
+ // Extract payload if it's JSON data
1741
+ if commandType == 0x30, data.count >= payloadLength + 7 {
1742
+ if bytes[5 + payloadLength] == 0x24, bytes[6 + payloadLength] == 0x24 {
1743
+ let payloadData = data.subdata(in: 5 ..< (5 + payloadLength))
1744
+ if let payloadString = String(data: payloadData, encoding: .utf8) {
1745
+ processJsonMessage(payloadString)
1746
+ }
1747
+ }
1748
+ }
1749
+ }
1750
+
1751
+ private func processJsonMessage(_ jsonString: String) {
1752
+ // Bridge.log("Got JSON from glasses: \(jsonString)")
1753
+
1754
+ do {
1755
+ guard let data = jsonString.data(using: .utf8),
1756
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
1757
+ else {
1758
+ return
1759
+ }
1760
+
1761
+ processJsonObject(json)
1762
+ } catch {
1763
+ Bridge.log("Error parsing JSON: \(error)")
1764
+ }
1765
+ }
1766
+
1767
+ private func processJsonObject(_ json: [String: Any]) {
1768
+ // Log ALL incoming JSON objects for debugging
1769
+ // Bridge.log("LIVE: DEBUG: processJsonObject: \(json)")
1770
+
1771
+ // Check for K900 command format
1772
+ if let command = json["C"] as? String {
1773
+ processK900JsonMessage(json)
1774
+ return
1775
+ }
1776
+
1777
+ guard let type = json["type"] as? String else {
1778
+ // Bridge.log("⚠️ JSON has no 'type' field and no 'C' field - ignoring")
1779
+ return
1780
+ }
1781
+
1782
+ // Check if this is an ACK response first (for our phone → glasses messages)
1783
+ if type == "msg_ack" {
1784
+ if let mId = json["mId"] as? Int {
1785
+ Bridge.log("LIVE: Received msg_ack for mId: \(mId)")
1786
+ if String(mId) == pending?.id {
1787
+ Bridge.log("LIVE: Received expected ACK! clearing pending")
1788
+ pending = nil
1789
+ // Cancel the retry timer
1790
+ pendingMessageTimer?.invalidate()
1791
+ pendingMessageTimer = nil
1792
+ } else if pending?.id != nil {
1793
+ Bridge.log(
1794
+ "LIVE: Received unexpected ACK! expected: \(pending!.id), received: \(mId)"
1795
+ )
1796
+ }
1797
+ }
1798
+ return // Don't send ACK for ACKs!
1799
+ }
1800
+
1801
+ // Check for message ID that needs ACK (glasses → phone)
1802
+ // But only if it's NOT an ACK message
1803
+ if let mId = json["mId"] as? Int {
1804
+ Bridge.log("LIVE: Received message with mId: \(mId) - sending ACK back to glasses")
1805
+ sendAckToGlasses(messageId: mId)
1806
+ }
1807
+
1808
+ switch type {
1809
+ case "glasses_ready":
1810
+ handleGlassesReady()
1811
+
1812
+ case "battery_status":
1813
+ let level = json["level"] as? Int ?? batteryLevel
1814
+ let isCharging = json["charging"] as? Bool ?? charging
1815
+ updateBatteryStatus(level: level, isCharging: isCharging)
1816
+
1817
+ case "wifi_status":
1818
+ let connected = json["connected"] as? Bool ?? false
1819
+ let ssid = json["ssid"] as? String ?? ""
1820
+ let ip = json["local_ip"] as? String ?? ""
1821
+ updateWifiStatus(connected: connected, ssid: ssid, ip: ip)
1822
+
1823
+ case "hotspot_status_update":
1824
+ let enabled = json["hotspot_enabled"] as? Bool ?? false
1825
+ let ssid = json["hotspot_ssid"] as? String ?? ""
1826
+ let password = json["hotspot_password"] as? String ?? ""
1827
+ let ip = json["hotspot_gateway_ip"] as? String ?? ""
1828
+ updateHotspotStatus(enabled: enabled, ssid: ssid, password: password, ip: ip)
1829
+
1830
+ case "hotspot_error":
1831
+ let errorMessage = json["error_message"] as? String ?? "Unknown hotspot error"
1832
+ let timestamp =
1833
+ json["timestamp"] as? Int64 ?? Int64(Date().timeIntervalSince1970 * 1000)
1834
+ handleHotspotError(errorMessage: errorMessage, timestamp: timestamp)
1835
+
1836
+ case "wifi_scan_result":
1837
+ handleWifiScanResult(json)
1838
+
1839
+ case "stream_status":
1840
+ emitRtmpStreamStatus(json)
1841
+
1842
+ case "gallery_status":
1843
+ let photoCount = json["photos"] as? Int ?? 0
1844
+ let videoCount = json["videos"] as? Int ?? 0
1845
+ let totalCount = json["total"] as? Int ?? 0
1846
+ let totalSize = json["total_size"] as? Int64 ?? 0
1847
+ let hasContent = json["has_content"] as? Bool ?? false
1848
+ handleGalleryStatus(
1849
+ photoCount: photoCount, videoCount: videoCount,
1850
+ totalCount: totalCount, totalSize: totalSize,
1851
+ hasContent: hasContent
1852
+ )
1853
+
1854
+ case "button_press":
1855
+ handleButtonPress(json)
1856
+
1857
+ // Removed: version_info_1, version_info_2, and version_info cases
1858
+ // Now handled by flexible parsing in the default case below
1859
+
1860
+ case "touch_event":
1861
+ let gestureName = json["gesture_name"] as? String ?? "unknown"
1862
+ let timestamp = parseTimestamp(json["timestamp"])
1863
+ let deviceModel = json["device_model"] as? String ?? deviceModel
1864
+ // Bridge.sendTouchEvent(
1865
+ // deviceModel: deviceModel, gestureName: gestureName, timestamp: timestamp
1866
+ // )
1867
+
1868
+ case "sr_tpevt":
1869
+ // K900 touchpad event - convert to touch_event for frontend
1870
+ if let bodyObj = json["B"] as? [String: Any],
1871
+ let gestureType = bodyObj["type"] as? Int
1872
+ {
1873
+ if let gestureName = mapK900GestureType(gestureType) {
1874
+ Bridge.log(
1875
+ "LIVE: 👆 K900 touchpad event - Type: \(gestureType) -> \(gestureName)"
1876
+ )
1877
+ Bridge.sendTouchEvent(
1878
+ deviceModel: "Mentra Live",
1879
+ gestureName: gestureName,
1880
+ timestamp: Int64(Date().timeIntervalSince1970 * 1000)
1881
+ )
1882
+ } else {
1883
+ Bridge.log("Unknown K900 gesture type: \(gestureType)")
1884
+ }
1885
+ }
1886
+
1887
+ case "swipe_volume_status":
1888
+ let enabled = json["enabled"] as? Bool ?? false
1889
+ let timestamp = parseTimestamp(json["timestamp"])
1890
+ Bridge.sendSwipeVolumeStatus(enabled: enabled, timestamp: timestamp)
1891
+
1892
+ case "switch_status":
1893
+ let switchType = (json["switch_type"] as? Int) ?? (json["switchType"] as? Int) ?? -1
1894
+ let switchValue = (json["switch_value"] as? Int) ?? (json["switchValue"] as? Int) ?? -1
1895
+ let timestamp = parseTimestamp(json["timestamp"])
1896
+ Bridge.sendSwitchStatus(
1897
+ switchType: switchType, value: switchValue, timestamp: timestamp
1898
+ )
1899
+
1900
+ case "rgb_led_control_response":
1901
+ let requestId = json["requestId"] as? String ?? ""
1902
+ let state = json["state"] as? String
1903
+ let success = state == "success" || json["success"] as? Bool == true
1904
+ let error = json["errorCode"] as? String ?? json["error"] as? String
1905
+ Bridge.sendRgbLedControlResponse(requestId: requestId, success: success, error: error)
1906
+
1907
+ case "pong":
1908
+ Bridge.log("LIVE: Received pong response - connection healthy")
1909
+
1910
+ case "imu_response", "imu_stream_response", "imu_gesture_response",
1911
+ "imu_gesture_subscribed", "imu_ack", "imu_error":
1912
+ // Handle IMU-related responses
1913
+ handleImuResponse(json)
1914
+
1915
+ case "keep_alive_ack":
1916
+ emitKeepAliveAck(json)
1917
+
1918
+ case "ble_photo_ready":
1919
+ processBlePhotoReady(json)
1920
+
1921
+ case "ble_photo_complete":
1922
+ processBlePhotoComplete(json)
1923
+
1924
+ case "file_announce":
1925
+ handleFileTransferAnnouncement(json)
1926
+
1927
+ case "transfer_timeout":
1928
+ handleTransferTimeout(json)
1929
+
1930
+ case "transfer_failed":
1931
+ handleTransferFailed(json)
1932
+
1933
+ case "mtk_update_complete":
1934
+ Bridge.log("💾 Received MTK update complete from ASG client")
1935
+
1936
+ let updateMessage =
1937
+ json["message"] as? String ?? "MTK firmware updated. Please restart glasses."
1938
+ let timestamp = parseTimestamp(json["timestamp"])
1939
+
1940
+ Bridge.log("🔄 MTK Update Message: \(updateMessage)")
1941
+
1942
+ // Send to React Native via Bridge
1943
+ Bridge.sendMtkUpdateComplete(message: updateMessage, timestamp: timestamp)
1944
+
1945
+ case "ota_update_available":
1946
+ // Process OTA update available notification from glasses (background mode)
1947
+ Bridge.log("📱 Received ota_update_available from glasses")
1948
+
1949
+ let versionCode = json["version_code"] as? Int64 ?? 0
1950
+ let versionName = json["version_name"] as? String ?? ""
1951
+ let totalSize = json["total_size"] as? Int64 ?? 0
1952
+
1953
+ // Parse updates array
1954
+ var updates: [String] = []
1955
+ if let updatesArray = json["updates"] as? [String] {
1956
+ updates = updatesArray
1957
+ }
1958
+
1959
+ Bridge.log(
1960
+ "📱 OTA available - version: \(versionName) (\(versionCode)), updates: \(updates), size: \(totalSize) bytes"
1961
+ )
1962
+
1963
+ // Send to React Native
1964
+ Bridge.sendOtaUpdateAvailable(
1965
+ versionCode: versionCode,
1966
+ versionName: versionName,
1967
+ updates: updates,
1968
+ totalSize: totalSize
1969
+ )
1970
+
1971
+ case "ota_start_ack":
1972
+ // Glasses acknowledged receipt of ota_start — phone can cancel its retry timer
1973
+ Bridge.log("LIVE: 📱 Received ota_start_ack from glasses")
1974
+ Bridge.sendOtaStartAck()
1975
+
1976
+ case "ota_status":
1977
+ // Short keys (sid/ts/cs/st/sq/sp/op/err) are used by new firmware to keep BLE payload small.
1978
+ // Verbose keys (session_id/total_steps/…) are the fallback for older firmware.
1979
+ let osSessionId = json["sid"] as? String ?? json["session_id"] as? String ?? ""
1980
+ let osTotalSteps = json["ts"] as? Int ?? json["total_steps"] as? Int ?? 0
1981
+ let osCurrentStep = json["cs"] as? Int ?? json["current_step"] as? Int ?? 0
1982
+ let osStepType = json["st"] as? String ?? json["step_type"] as? String ?? "apk"
1983
+ let osPhase = json["phase"] as? String ?? "download"
1984
+ let osStepPercent = json["sp"] as? Int ?? json["step_percent"] as? Int ?? 0
1985
+ let osOverallPercent = json["op"] as? Int ?? json["overall_percent"] as? Int ?? 0
1986
+ let osStatus = json["status"] as? String ?? "idle"
1987
+ let osErrorMessage = json["err"] as? String ?? json["error_message"] as? String
1988
+
1989
+ // If the glasses started a new session, drop any leftover state from the old
1990
+ // one before caching the new values. Without this, lastBesOtaProgress would
1991
+ // stay at e.g. 95 from the previous session and cause us to silently skip the
1992
+ // first few percent of the new BES install.
1993
+ if !osSessionId.isEmpty, let prevSid = cachedOtaSessionId, prevSid != osSessionId {
1994
+ resetOtaCache()
1995
+ }
1996
+
1997
+ cachedOtaSessionId = osSessionId
1998
+ cachedOtaTotalSteps = osTotalSteps
1999
+ cachedOtaCurrentStep = osCurrentStep
2000
+ if let seq = json["sq"] as? [String] ?? json["step_sequence"] as? [String], !seq.isEmpty {
2001
+ cachedOtaStepSequence = seq
2002
+ }
2003
+
2004
+ Bridge.log("LIVE: 📱 OTA status - step \(osCurrentStep)/\(osTotalSteps) \(osPhase) \(osStatus) \(osOverallPercent)%")
2005
+
2006
+ Bridge.sendOtaStatus(
2007
+ sessionId: osSessionId,
2008
+ totalSteps: osTotalSteps,
2009
+ currentStep: osCurrentStep,
2010
+ stepType: osStepType,
2011
+ phase: osPhase,
2012
+ stepPercent: osStepPercent,
2013
+ overallPercent: osOverallPercent,
2014
+ status: osStatus,
2015
+ errorMessage: osErrorMessage
2016
+ )
2017
+
2018
+ case "ota_progress":
2019
+ // Legacy glasses firmware: map to unified ota_status (single RN path).
2020
+ let legacyStage = json["stage"] as? String ?? "download"
2021
+ let legacyStatus = json["status"] as? String ?? "PROGRESS"
2022
+ let legacyProgress = json["progress"] as? Int ?? 0
2023
+ let currentUpdate = json["current_update"] as? String ?? "apk"
2024
+ let err = json["error_message"] as? String
2025
+ let legacyPhase: String = legacyStage == "install" ? "install" : "download"
2026
+ let unified: String
2027
+ if legacyStatus == "FAILED" {
2028
+ unified = "failed"
2029
+ } else if legacyStatus == "FINISHED" {
2030
+ unified = "complete"
2031
+ } else {
2032
+ unified = "in_progress"
2033
+ }
2034
+ Bridge.log(
2035
+ "LIVE: 📱 Legacy ota_progress → ota_status: \(legacyStage) \(legacyStatus) \(legacyProgress)%"
2036
+ )
2037
+ Bridge.sendOtaStatus(
2038
+ sessionId: "",
2039
+ totalSteps: 1,
2040
+ currentStep: 1,
2041
+ stepType: currentUpdate,
2042
+ phase: legacyPhase,
2043
+ stepPercent: legacyProgress,
2044
+ overallPercent: legacyProgress,
2045
+ status: unified,
2046
+ errorMessage: err
2047
+ )
2048
+
2049
+ default:
2050
+ // Flexible version_info parsing - handle any version_info* message
2051
+ if type.hasPrefix("version_info") {
2052
+ Bridge.log("LIVE: Received \(type): \(json)")
2053
+
2054
+ // Extract all fields from JSON (except "type")
2055
+ var fields: [String: Any] = [:]
2056
+ for (key, value) in json {
2057
+ if key != "type" {
2058
+ fields[key] = value
2059
+ }
2060
+ }
2061
+
2062
+ // Update local fields for any we recognize
2063
+ if let appVersion = fields["app_version"] as? String {
2064
+ DeviceStore.shared.apply("glasses", "appVersion", appVersion)
2065
+ }
2066
+ if let buildNumber = fields["build_number"] as? String {
2067
+ isNewVersion = (Int(buildNumber) ?? 0) >= 5
2068
+ DeviceStore.shared.apply("glasses", "buildNumber", buildNumber)
2069
+ }
2070
+ if let deviceModel = fields["device_model"] as? String {
2071
+ DeviceStore.shared.apply("glasses", "deviceModel", deviceModel)
2072
+ }
2073
+ if let androidVersion = fields["android_version"] as? String {
2074
+ DeviceStore.shared.apply("glasses", "androidVersion", androidVersion)
2075
+ }
2076
+ if let otaVersionUrl = fields["ota_version_url"] as? String {
2077
+ DeviceStore.shared.apply("glasses", "otaVersionUrl", otaVersionUrl)
2078
+ }
2079
+ if let firmwareVersion = fields["firmware_version"] as? String {
2080
+ DeviceStore.shared.apply("glasses", "fwVersion", firmwareVersion)
2081
+ }
2082
+ if let besFwVersion = fields["bes_fw_version"] as? String {
2083
+ DeviceStore.shared.apply("glasses", "besFwVersion", besFwVersion)
2084
+ }
2085
+ if let mtkFwVersion = fields["mtk_fw_version"] as? String {
2086
+ // MTK firmware version (e.g., "20241130")
2087
+ // Note: Stored separately from BES version for OTA patch matching
2088
+ DeviceStore.shared.apply("glasses", "mtkFwVersion", mtkFwVersion)
2089
+ }
2090
+ if let btMacAddress = fields["bt_mac_address"] as? String {
2091
+ DeviceStore.shared.apply("glasses", "btMacAddress", btMacAddress)
2092
+ }
2093
+
2094
+ // Send fields immediately to RN - no waiting for other chunks
2095
+ // All fields including mtk_fw_version are forwarded to RN
2096
+ // Bridge.sendTypedMessage("version_info", body: fields)
2097
+
2098
+ // Bridge.log("LIVE: Processed version_info fields and sent to RN")
2099
+ } else {
2100
+ Bridge.log("Unhandled message type: \(type)")
2101
+ }
2102
+ }
2103
+ }
2104
+
2105
+ /// Maps K900 gesture type codes to gesture names
2106
+ /// Compute the weighted overall OTA percentage for a BES progress event arriving via sr_adota.
2107
+ /// Mirrors the weight table in OtaSessionManager.computeStepWeights() on the glasses side.
2108
+ ///
2109
+ /// Weight assignments:
2110
+ /// [apk, mtk, bes] → bes base=50, weight=50
2111
+ /// [apk, bes] → bes base=20, weight=80
2112
+ /// [mtk, bes] → bes base=40, weight=60
2113
+ /// [bes] → bes base=0, weight=100
2114
+ ///
2115
+ /// Drops cached OTA session context. Called when the glasses disconnect or when a new
2116
+ /// session id arrives — without this, stale fields from a previous session would leak
2117
+ /// into sr_adota progress messages (wrong totalSteps, wrong stepSequence, stale
2118
+ /// lastBesOtaProgress that swallows the first few percent of the new install).
2119
+ private func resetOtaCache() {
2120
+ cachedOtaSessionId = nil
2121
+ cachedOtaTotalSteps = 0
2122
+ cachedOtaCurrentStep = 0
2123
+ cachedOtaStepSequence = nil
2124
+ lastBesOtaProgress = -1
2125
+ }
2126
+
2127
+ /// Falls back to raw besProgress when step sequence is unavailable.
2128
+ private func computeBesOverallPercent(besProgress: Int, stepSequence: [String]?) -> Int {
2129
+ guard let seq = stepSequence, !seq.isEmpty else { return besProgress }
2130
+ let hasApk = seq.contains("apk")
2131
+ let hasMtk = seq.contains("mtk")
2132
+ let base: Int
2133
+ let weight: Int
2134
+ if hasApk && hasMtk {
2135
+ base = 50; weight = 50
2136
+ } else if hasApk {
2137
+ base = 20; weight = 80
2138
+ } else if hasMtk {
2139
+ base = 40; weight = 60
2140
+ } else {
2141
+ base = 0; weight = 100
2142
+ }
2143
+ return min(100, base + besProgress * weight / 100)
2144
+ }
2145
+
2146
+ private func mapK900GestureType(_ type: Int) -> String? {
2147
+ switch type {
2148
+ case 0: return "single_tap"
2149
+ case 1: return "double_tap"
2150
+ case 2: return "triple_tap"
2151
+ case 3: return "long_press"
2152
+ case 4: return "forward_swipe"
2153
+ case 5: return "backward_swipe"
2154
+ case 6: return "up_swipe"
2155
+ case 7: return "down_swipe"
2156
+ default: return nil
2157
+ }
2158
+ }
2159
+
2160
+ private func processK900JsonMessage(_ json: [String: Any]) {
2161
+ guard let command = json["C"] as? String else { return }
2162
+
2163
+ // Bridge.log("LIVE: Processing K900 command: \(command)")
2164
+
2165
+ // convert command string (which is a json string) to a json object:
2166
+ let commandJson =
2167
+ try? JSONSerialization.jsonObject(with: command.data(using: .utf8)!) as? [String: Any]
2168
+ processJsonObject(commandJson ?? [:])
2169
+
2170
+ if command.starts(with: "{") {
2171
+ return
2172
+ }
2173
+
2174
+ switch command {
2175
+ case "sr_hrt":
2176
+ if let bodyObj = json["B"] as? [String: Any] {
2177
+ let readyResponse = bodyObj["ready"] as? Int ?? 0
2178
+
2179
+ // Extract battery info from heartbeat
2180
+ let percentage = bodyObj["pt"] as? Int ?? 0
2181
+ let voltage = bodyObj["vt"] as? Int ?? 0
2182
+ let charging = (bodyObj["charg"] as? Int ?? 0) == 1
2183
+
2184
+ // SOC is still booting
2185
+ if readyResponse == 0 {
2186
+ Bridge.log("LIVE: K900 SOC not ready (ready=0)")
2187
+ DeviceStore.shared.apply("glasses", "fullyBooted", false)
2188
+ Bridge.sendTypedMessage("glasses_not_ready", body: [:])
2189
+
2190
+ // Check for low battery during pairing
2191
+ if percentage > 0, percentage <= 20 {
2192
+ Bridge.sendPairFailureEvent("errors:pairingBatteryTooLow")
2193
+ return
2194
+ }
2195
+ }
2196
+
2197
+ // Update battery status if we have valid data
2198
+ if percentage > 0 {
2199
+ updateBatteryStatus(level: percentage, isCharging: charging)
2200
+ if voltage > 0 {
2201
+ let voltageVolts = Double(voltage) / 1000.0
2202
+ // Bridge.log(
2203
+ // "LIVE: Battery from heartbeat - \(percentage)%, \(voltageVolts)V, charging: \(charging)"
2204
+ // )
2205
+ }
2206
+ }
2207
+
2208
+ if readyResponse == 1 {
2209
+ Bridge.log("K900 SOC ready")
2210
+ // Only send phone_ready if we haven't already established connection
2211
+ // This prevents re-initialization on every heartbeat after initial connection
2212
+ // The ready flag is reset on disconnect/reconnect, so this won't prevent proper reconnection
2213
+ if !fullyBooted {
2214
+ let readyMsg: [String: Any] = [
2215
+ "type": "phone_ready",
2216
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
2217
+ ]
2218
+ // Send it through our data channel
2219
+ sendJson(readyMsg, wakeUp: true)
2220
+ }
2221
+ }
2222
+ }
2223
+
2224
+ case "sr_batv":
2225
+ if let body = json["B"] as? [String: Any],
2226
+ let voltage = body["vt"] as? Int,
2227
+ let percentage = body["pt"] as? Int
2228
+ {
2229
+ let voltageVolts = Double(voltage) / 1000.0
2230
+ let isCharging = voltage > 4000
2231
+
2232
+ Bridge.log(
2233
+ "🔋 K900 Battery Status - Voltage: \(voltageVolts)V, Level: \(percentage)%"
2234
+ )
2235
+ updateBatteryStatus(level: percentage, isCharging: isCharging)
2236
+ }
2237
+
2238
+ case "sr_getvol":
2239
+ handleSrGetvol(json)
2240
+
2241
+ case "sr_vol":
2242
+ handleSrVol(json)
2243
+
2244
+ case "sr_shut":
2245
+ Bridge.log("K900 shutdown command received - glasses shutting down")
2246
+ // Mark as killed to prevent reconnection attempts
2247
+ // isKilled = true
2248
+ // // Clean disconnect without reconnection
2249
+ // if let peripheral = connectedPeripheral {
2250
+ // Bridge.log("Disconnecting from glasses due to shutdown")
2251
+ // centralManager?.cancelPeripheralConnection(peripheral)
2252
+ // }
2253
+ // Notify the system that glasses are intentionally disconnected
2254
+ updateConnectionState(ConnTypes.DISCONNECTED)
2255
+
2256
+ case "sr_adota":
2257
+ // BES chip OTA progress — K900 path. Emit ota_status only (legacy ota_progress removed).
2258
+ if let bodyObj = json["B"] as? [String: Any] {
2259
+ let type = bodyObj["type"] as? String ?? ""
2260
+ let rawProgress = bodyObj["progress"] as? Int ?? 0
2261
+
2262
+ // Round to nearest 5% for cleaner UI updates
2263
+ var progress = ((rawProgress + 2) / 5) * 5
2264
+ if progress > 100 { progress = 100 }
2265
+
2266
+ // Only send if progress changed to a new 5% increment
2267
+ let isTerminalStatus = type == "success" || type == "error" || type == "fail"
2268
+ if progress == lastBesOtaProgress && !isTerminalStatus {
2269
+ break // Skip duplicate progress
2270
+ }
2271
+ lastBesOtaProgress = progress
2272
+
2273
+ Bridge.log(
2274
+ "LIVE: 📱 BES OTA progress via sr_adota - type: \(type), raw: \(rawProgress)%, rounded: \(progress)%"
2275
+ )
2276
+
2277
+ // Determine status and error message based on type
2278
+ var besOtaStatus: String
2279
+ var besOtaProgressVal: Int
2280
+ var besOtaErrorMessage: String? = nil
2281
+
2282
+ // Order matters here: check completion (rawProgress >= 100 OR success) BEFORE
2283
+ // type=="update", because some BES firmware emits the final 100% tick with
2284
+ // type=="update" rather than type=="success". Treating that as PROGRESS would
2285
+ // leave the UI stuck at 100% forever.
2286
+ if type == "success" || rawProgress >= 100 {
2287
+ besOtaStatus = "FINISHED"
2288
+ besOtaProgressVal = 100
2289
+ lastBesOtaProgress = -1 // Reset for next OTA
2290
+ } else if type == "error" || type == "fail" {
2291
+ besOtaStatus = "FAILED"
2292
+ besOtaProgressVal = progress
2293
+ besOtaErrorMessage = bodyObj["message"] as? String ?? "BES update failed"
2294
+ lastBesOtaProgress = -1 // Reset for next OTA
2295
+ } else if type == "update" {
2296
+ besOtaStatus = "PROGRESS"
2297
+ besOtaProgressVal = progress
2298
+ } else {
2299
+ // Unknown type, treat as progress
2300
+ besOtaStatus = "PROGRESS"
2301
+ besOtaProgressVal = progress
2302
+ }
2303
+
2304
+ let syntheticStatus: String
2305
+ if besOtaStatus == "FINISHED" {
2306
+ syntheticStatus = "step_complete"
2307
+ } else if besOtaStatus == "FAILED" {
2308
+ syntheticStatus = "failed"
2309
+ } else {
2310
+ syntheticStatus = "in_progress"
2311
+ }
2312
+ let sid = cachedOtaSessionId ?? ""
2313
+ let totalSteps = cachedOtaTotalSteps > 0 ? cachedOtaTotalSteps : 1
2314
+ let currentStep = cachedOtaCurrentStep > 0 ? cachedOtaCurrentStep : 1
2315
+ let besOverallPercent = computeBesOverallPercent(besProgress: besOtaProgressVal, stepSequence: cachedOtaStepSequence)
2316
+ Bridge.sendOtaStatus(
2317
+ sessionId: sid,
2318
+ totalSteps: totalSteps,
2319
+ currentStep: currentStep,
2320
+ stepType: "bes",
2321
+ phase: "install",
2322
+ stepPercent: besOtaProgressVal,
2323
+ overallPercent: besOverallPercent,
2324
+ status: syntheticStatus,
2325
+ errorMessage: besOtaErrorMessage
2326
+ )
2327
+ }
2328
+
2329
+ case "sr_tpevt":
2330
+ // K900 touchpad event - convert to touch_event for frontend
2331
+ if let bodyObj = json["B"] as? [String: Any],
2332
+ let gestureType = bodyObj["type"] as? Int
2333
+ {
2334
+ if let gestureName = mapK900GestureType(gestureType) {
2335
+ Bridge.log(
2336
+ "LIVE: 👆 K900 touchpad event - Type: \(gestureType) -> \(gestureName)"
2337
+ )
2338
+ Bridge.sendTouchEvent(
2339
+ deviceModel: deviceModel,
2340
+ gestureName: gestureName,
2341
+ timestamp: Int64(Date().timeIntervalSince1970 * 1000)
2342
+ )
2343
+ } else {
2344
+ Bridge.log("Unknown K900 gesture type: \(gestureType)")
2345
+ }
2346
+ }
2347
+
2348
+ default:
2349
+ // Bridge.log("Unknown K900 command: \(command)")
2350
+ break
2351
+ }
2352
+ }
2353
+
2354
+ // commands to send to the glasses:
2355
+
2356
+ func requestWifiScan() {
2357
+ Bridge.log("LIVE: Requesting WiFi scan from glasses")
2358
+ let json: [String: Any] = ["type": "request_wifi_scan"]
2359
+ sendJson(json, wakeUp: true)
2360
+ }
2361
+
2362
+ func sendWifiCredentials(_ ssid: String, _ password: String) {
2363
+ Bridge.log("LIVE: Sending WiFi credentials for SSID: \(ssid)")
2364
+
2365
+ guard !ssid.isEmpty else {
2366
+ Bridge.log("LIVE: Cannot set WiFi credentials - SSID is empty")
2367
+ return
2368
+ }
2369
+
2370
+ let json: [String: Any] = [
2371
+ "type": "set_wifi_credentials",
2372
+ "ssid": ssid,
2373
+ "password": password,
2374
+ ]
2375
+
2376
+ sendJson(json, wakeUp: true)
2377
+ }
2378
+
2379
+ func sendHotspotState(_ enabled: Bool) {
2380
+ Bridge.log("LIVE: 🔥 Sending hotspot state: \(enabled)")
2381
+
2382
+ let json: [String: Any] = [
2383
+ "type": "set_hotspot_state",
2384
+ "enabled": enabled,
2385
+ ]
2386
+
2387
+ sendJson(json, wakeUp: true)
2388
+ }
2389
+
2390
+ func sendUserEmailToGlasses(_ email: String) {
2391
+ Bridge.log("LIVE: Sending user email to glasses for crash reporting")
2392
+
2393
+ guard !email.isEmpty else {
2394
+ Bridge.log("LIVE: Cannot send user email - email is empty")
2395
+ return
2396
+ }
2397
+
2398
+ let json: [String: Any] = [
2399
+ "type": "user_email",
2400
+ "email": email,
2401
+ ]
2402
+
2403
+ sendJson(json, wakeUp: true)
2404
+ }
2405
+
2406
+ func sendIncidentId(_ incidentId: String, apiBaseUrl: String?) {
2407
+ var base = (apiBaseUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
2408
+ if base.isEmpty {
2409
+ base = "https://api.mentra.glass"
2410
+ }
2411
+ while base.hasSuffix("/") {
2412
+ base = String(base.dropLast())
2413
+ }
2414
+ let bKey = MentraLive.incidentBleFileBase(incidentId: incidentId, prefix: "B")
2415
+ let lKey = MentraLive.incidentBleFileBase(incidentId: incidentId, prefix: "L")
2416
+ bleIncidentLogRelays[bKey] = BleIncidentLogRelayEntry(
2417
+ fileBaseKey: bKey, incidentId: incidentId, apiBaseUrl: base, kind: .firmware
2418
+ )
2419
+ bleIncidentLogRelays[lKey] = BleIncidentLogRelayEntry(
2420
+ fileBaseKey: lKey, incidentId: incidentId, apiBaseUrl: base, kind: .logcat
2421
+ )
2422
+
2423
+ Bridge.log(
2424
+ "LIVE: Sending incidentId to glasses for log upload: \(incidentId) (BLE relay \(bKey), \(lKey))"
2425
+ )
2426
+ sendJson(["type": "upload_incident_logs", "incidentId": incidentId, "apiBaseUrl": base], wakeUp: true)
2427
+ }
2428
+
2429
+ private static func incidentBleFileBase(incidentId: String, prefix: Character) -> String {
2430
+ var compact = incidentId.replacingOccurrences(of: "-", with: "").lowercased()
2431
+ if compact.count < 15 {
2432
+ compact += String(repeating: "0", count: 15 - compact.count)
2433
+ }
2434
+ return String(prefix) + String(compact.prefix(15))
2435
+ }
2436
+
2437
+ func forgetWifiNetwork(_ ssid: String) {
2438
+ Bridge.log("LIVE: 📶 Sending WiFi forget command for SSID: \(ssid)")
2439
+
2440
+ guard !ssid.isEmpty else {
2441
+ Bridge.log("LIVE: Cannot forget WiFi network - SSID is empty")
2442
+ return
2443
+ }
2444
+
2445
+ let json: [String: Any] = [
2446
+ "type": "forget_wifi",
2447
+ "ssid": ssid,
2448
+ ]
2449
+
2450
+ sendJson(json, wakeUp: true)
2451
+ }
2452
+
2453
+ func queryGalleryStatus() {
2454
+ Bridge.log("LIVE: 📸 Querying gallery status from glasses")
2455
+
2456
+ let json: [String: Any] = [
2457
+ "type": "query_gallery_status",
2458
+ ]
2459
+
2460
+ sendJson(json, wakeUp: true)
2461
+ }
2462
+
2463
+ func sendGalleryMode() {
2464
+ let active = DeviceStore.shared.get("bluetooth", "gallery_mode") as! Bool
2465
+ Bridge.log("LIVE: 📸 Sending gallery mode active to glasses: \(active)")
2466
+
2467
+ let json: [String: Any] = [
2468
+ "type": "save_in_gallery_mode",
2469
+ "active": active,
2470
+ "timestamp": Int(Date().timeIntervalSince1970 * 1000),
2471
+ ]
2472
+
2473
+ sendJson(json, wakeUp: true)
2474
+ }
2475
+
2476
+ /// Send OTA start command to glasses.
2477
+ /// Called when user approves an update (onboarding or background mode).
2478
+ /// Triggers glasses to begin download and installation.
2479
+ func sendOtaStart() {
2480
+ Bridge.log("LIVE: 📱 Sending ota_start command to glasses")
2481
+
2482
+ let json: [String: Any] = [
2483
+ "type": "ota_start",
2484
+ "timestamp": Int(Date().timeIntervalSince1970 * 1000),
2485
+ ]
2486
+
2487
+ sendJson(json, wakeUp: true)
2488
+ }
2489
+
2490
+ func sendOtaQueryStatus() {
2491
+ Bridge.log("LIVE: 📱 Sending ota_query_status command to glasses")
2492
+
2493
+ let json: [String: Any] = [
2494
+ "type": "ota_query_status",
2495
+ "timestamp": Int(Date().timeIntervalSince1970 * 1000),
2496
+ ]
2497
+
2498
+ sendJson(json, wakeUp: true)
2499
+ }
2500
+
2501
+ func keepAwake() {
2502
+ Bridge.log("LIVE: 📱 Sending keep_awake command to glasses")
2503
+
2504
+ let json: [String: Any] = [
2505
+ "type": "keep_awake",
2506
+ "timestamp": Int(Date().timeIntervalSince1970 * 1000),
2507
+ ]
2508
+
2509
+ sendJson(json, wakeUp: true)
2510
+ }
2511
+
2512
+ // MARK: - Message Handlers
2513
+
2514
+ private func handleGlassesReady() {
2515
+ Bridge.log("LIVE: 🎉 Received glasses_ready message - SOC is booted and ready!")
2516
+
2517
+ stopReadinessCheckLoop()
2518
+
2519
+ // Invalidate any version fields from a prior link session so the next version_info
2520
+ // cannot leave a stale build number in RN (ASG is source of truth for PackageInfo).
2521
+ DeviceStore.shared.apply("glasses", "buildNumber", "")
2522
+ DeviceStore.shared.apply("glasses", "appVersion", "")
2523
+ DeviceStore.shared.apply("glasses", "besFwVersion", "")
2524
+ DeviceStore.shared.apply("glasses", "mtkFwVersion", "")
2525
+ Bridge.log("LIVE: Cleared cached version_info fields before refresh")
2526
+
2527
+ // Perform SOC-dependent initialization
2528
+ requestBatteryStatus()
2529
+ requestWifiStatus()
2530
+ requestVersionInfo()
2531
+ sendCoreTokenToAsgClient()
2532
+ sendStoredUserEmailToAsgClient()
2533
+
2534
+ // Send user settings to glasses
2535
+ sendUserSettings()
2536
+
2537
+ // Claim LED control and enable gesture reporting
2538
+ sendRgbLedControlAuthority(true)
2539
+ setTouchEventReporting(true)
2540
+ setSwipeVolumeControl(false)
2541
+
2542
+ // Start heartbeat
2543
+ startHeartbeat()
2544
+
2545
+ // Restore mic state if it was enabled before reconnect
2546
+ if micIntentEnabled {
2547
+ if BLOCK_AUDIO_DUPLEX, let monitor = phoneAudioMonitor, monitor.isPlaying() {
2548
+ micSuspendedForAudio = true
2549
+ Bridge.log(
2550
+ "LIVE: 🎤 Restoring mic intent after reconnect, but phone audio is playing - suspending"
2551
+ )
2552
+ } else {
2553
+ micSuspendedForAudio = false
2554
+ Bridge.log("LIVE: 🎤 Restoring mic state after reconnect")
2555
+ startMicBeat()
2556
+ }
2557
+ }
2558
+
2559
+ fullyBooted = true
2560
+ connected = true
2561
+ updateConnectionState(ConnTypes.CONNECTED)
2562
+ }
2563
+
2564
+ private func handleWifiScanResult(_ json: [String: Any]) {
2565
+ var networks: [[String: Any]] = []
2566
+
2567
+ // First, check for enhanced format (networks_neo)
2568
+ if let networksNeoArray = json["networks_neo"] as? [[String: Any]] {
2569
+ networks = networksNeoArray
2570
+ }
2571
+
2572
+ Bridge.updateWifiScanResults(networks)
2573
+ }
2574
+
2575
+ private func handleButtonPress(_ json: [String: Any]) {
2576
+ let buttonId = json["buttonId"] as? String ?? "unknown"
2577
+ let pressType = json["pressType"] as? String ?? "short"
2578
+
2579
+ Bridge.log("LIVE: Received button press - buttonId: \(buttonId), pressType: \(pressType)")
2580
+ Bridge.sendButtonPress(buttonId: buttonId, pressType: pressType)
2581
+ }
2582
+
2583
+ private func handleVersionInfo(_ json: [String: Any]) {
2584
+ let appVersion = json["app_version"] as? String ?? ""
2585
+ let buildNumber = json["build_number"] as? String ?? ""
2586
+ let deviceModel = json["device_model"] as? String ?? ""
2587
+ let androidVersion = json["android_version"] as? String ?? ""
2588
+ let otaVersionUrl = json["ota_version_url"] as? String ?? ""
2589
+ let firmwareVersion = json["firmware_version"] as? String ?? ""
2590
+ let btMacAddress = json["bt_mac_address"] as? String ?? ""
2591
+
2592
+ DeviceStore.shared.apply("glasses", "appVersion", appVersion)
2593
+ DeviceStore.shared.apply("glasses", "buildNumber", buildNumber)
2594
+ DeviceStore.shared.apply("glasses", "otaVersionUrl", otaVersionUrl)
2595
+ DeviceStore.shared.apply("glasses", "fwVersion", firmwareVersion)
2596
+ DeviceStore.shared.apply("glasses", "btMacAddress", btMacAddress)
2597
+ isNewVersion = (Int(buildNumber) ?? 0) >= 5
2598
+ DeviceStore.shared.apply("glasses", "deviceModel", deviceModel)
2599
+ DeviceStore.shared.apply("glasses", "androidVersion", androidVersion)
2600
+
2601
+ // Detect LC3 audio support: K901+ devices have microphone, K900 does not
2602
+ // supportsLC3Audio = deviceModel != "K900"
2603
+ // hasMic = supportsLC3Audio
2604
+
2605
+ Bridge.log(
2606
+ "Glasses Version - App: \(appVersion), Build: \(buildNumber), Device: \(deviceModel), Android: \(androidVersion), Firmware: \(firmwareVersion), BT MAC: \(btMacAddress), OTA URL: \(otaVersionUrl)"
2607
+ )
2608
+ Bridge.log("LIVE: LC3 Audio Support: \(supportsLC3Audio), Has Mic: \(hasMic)")
2609
+ emitVersionInfo(
2610
+ appVersion: appVersion, buildNumber: buildNumber, deviceModel: deviceModel,
2611
+ androidVersion: androidVersion, otaVersionUrl: otaVersionUrl,
2612
+ firmwareVersion: firmwareVersion,
2613
+ btMacAddress: btMacAddress
2614
+ )
2615
+ }
2616
+
2617
+ private func handleAck(_: [String: Any]) {
2618
+ Bridge.log("LIVE: Received ack")
2619
+ // let messageId = json["mId"] as? Int ?? 0
2620
+ // if let pendingMessage = pending, pendingMessage.id == messageId {
2621
+ // pending = nil
2622
+ // }
2623
+ }
2624
+
2625
+ // MARK: - LC3 Audio Processing
2626
+
2627
+ /// Process LC3 audio packet received from glasses microphone
2628
+ /// Packet format: [0xF1, sequenceNumber, lc3Data...]
2629
+ private func processLc3AudioPacket(_ data: Data) {
2630
+ guard data.count >= 2 else {
2631
+ Bridge.log("LIVE: Invalid LC3 audio packet: too short (\(data.count) bytes)")
2632
+ return
2633
+ }
2634
+
2635
+ // Check for 0xF1 audio header (same as Android)
2636
+ guard data[0] == 0xF1 else {
2637
+ Bridge.log("LIVE: Invalid LC3 packet header: 0x\(String(format: "%02X", data[0]))")
2638
+ return
2639
+ }
2640
+
2641
+ let sequenceNumber = Int8(bitPattern: data[1])
2642
+ let lc3Data = data.subdata(in: 2 ..< data.count)
2643
+
2644
+ // Validate sequence number for packet loss detection
2645
+ if lastReceivedLc3Sequence != -1 && (lastReceivedLc3Sequence &+ 1) != sequenceNumber {
2646
+ Bridge.log(
2647
+ "LIVE: LC3 packet sequence mismatch. Expected: \(lastReceivedLc3Sequence &+ 1), Got: \(sequenceNumber)"
2648
+ )
2649
+ }
2650
+ lastReceivedLc3Sequence = sequenceNumber
2651
+
2652
+ // // Decode LC3 to PCM using existing PcmConverter
2653
+ // let pcmConverter = PcmConverter()
2654
+ // guard let pcmData = pcmConverter.decode(lc3Data) as? Data, pcmData.count > 0 else {
2655
+ // Bridge.log("LIVE: Failed to decode LC3 data to PCM")
2656
+ // return
2657
+ // }
2658
+
2659
+ // // Forward PCM data to DeviceManager for VAD and server transmission (same as Android)
2660
+ // DeviceManager.shared.handlePcm(pcmData)
2661
+
2662
+ // Bridge.log(
2663
+ // "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count) bytes"
2664
+ // )
2665
+ DeviceManager.shared.handleGlassesMicData(lc3Data, 40)
2666
+
2667
+ // Bridge.log(
2668
+ // "LIVE: Processed LC3 audio seq=\(sequenceNumber), \(lc3Data.count)→\(pcmData.count) bytes"
2669
+ // )
2670
+ }
2671
+
2672
+ // MARK: - BLE Photo Transfer Handlers
2673
+
2674
+ private func processBlePhotoReady(_ json: [String: Any]) {
2675
+ let bleImgId = json["bleImgId"] as? String ?? ""
2676
+ let requestId = json["requestId"] as? String ?? ""
2677
+ let compressionDurationMs = json["compressionDurationMs"] as? Int64 ?? 0
2678
+
2679
+ Bridge.log(
2680
+ "LIVE: 📸 BLE photo ready notification: bleImgId=\(bleImgId), requestId=\(requestId)"
2681
+ )
2682
+
2683
+ // Update the transfer with glasses compression duration
2684
+ if var transfer = blePhotoTransfers[bleImgId] {
2685
+ transfer.glassesCompressionDurationMs = compressionDurationMs
2686
+ transfer.bleTransferStartTime = Date() // BLE transfer starts now
2687
+ blePhotoTransfers[bleImgId] = transfer
2688
+ Bridge.log("LIVE: ⏱️ Glasses compression took: \(compressionDurationMs)ms")
2689
+ } else {
2690
+ Bridge.log("LIVE: Received ble_photo_ready for unknown transfer: \(bleImgId)")
2691
+ }
2692
+ }
2693
+
2694
+ private func processBlePhotoComplete(_ json: [String: Any]) {
2695
+ let bleRequestId = json["requestId"] as? String ?? ""
2696
+ let bleBleImgId = json["bleImgId"] as? String ?? ""
2697
+ let bleSuccess = json["success"] as? Bool ?? false
2698
+
2699
+ Bridge.log(
2700
+ "LIVE: BLE photo transfer complete - requestId: \(bleRequestId), bleImgId: \(bleBleImgId), success: \(bleSuccess)"
2701
+ )
2702
+
2703
+ // Send completion notification back to glasses using unified transfer_complete
2704
+ if bleSuccess {
2705
+ sendTransferCompleteConfirmation(fileName: bleBleImgId, success: true)
2706
+ } else {
2707
+ Bridge.log("LIVE: BLE photo transfer failed for requestId: \(bleRequestId)")
2708
+ sendTransferCompleteConfirmation(fileName: bleBleImgId, success: false)
2709
+ }
2710
+ }
2711
+
2712
+ private func handleFileTransferAnnouncement(_ json: [String: Any]) {
2713
+ let fileName = json["fileName"] as? String ?? ""
2714
+ let totalPackets = json["totalPackets"] as? Int ?? 0
2715
+ let fileSize = json["fileSize"] as? Int ?? 0
2716
+
2717
+ guard !fileName.isEmpty, totalPackets > 0 else {
2718
+ Bridge.log("LIVE: 📢 Invalid file transfer announcement: \(json)")
2719
+ return
2720
+ }
2721
+
2722
+ Bridge.log(
2723
+ "LIVE: 📢 File transfer announcement: \(fileName), \(totalPackets) packets, \(fileSize) bytes"
2724
+ )
2725
+
2726
+ if var existing = activeFileTransfers[fileName] {
2727
+ Bridge.log("LIVE: 📢 Restart detected - clearing existing session for \(fileName)")
2728
+ Bridge.log(
2729
+ "LIVE: 📊 Previous session had \(existing.receivedPackets.count)/\(existing.totalPackets) packets"
2730
+ )
2731
+ activeFileTransfers.removeValue(forKey: fileName)
2732
+ }
2733
+
2734
+ var session = FileTransferSession(
2735
+ fileName: fileName, fileSize: fileSize, announcedPackets: totalPackets
2736
+ )
2737
+ session.isAnnounced = true
2738
+ activeFileTransfers[fileName] = session
2739
+
2740
+ let bleImgId = fileName.split(separator: ".").first.map(String.init) ?? ""
2741
+ if var bleTransfer = blePhotoTransfers[bleImgId] {
2742
+ var bleSession =
2743
+ bleTransfer.session
2744
+ ?? FileTransferSession(
2745
+ fileName: fileName, fileSize: fileSize, announcedPackets: totalPackets
2746
+ )
2747
+ bleSession.updateAnnouncedPackets(totalPackets)
2748
+ bleTransfer.session = bleSession
2749
+ blePhotoTransfers[bleImgId] = bleTransfer
2750
+ }
2751
+ }
2752
+
2753
+ private func handleTransferTimeout(_ json: [String: Any]) {
2754
+ let fileName = json["fileName"] as? String ?? ""
2755
+ guard !fileName.isEmpty else {
2756
+ Bridge.log("LIVE: ⏰ Transfer timeout notification missing fileName: \(json)")
2757
+ return
2758
+ }
2759
+
2760
+ Bridge.log("LIVE: ⏰ Transfer timeout for: \(fileName)")
2761
+
2762
+ activeFileTransfers.removeValue(forKey: fileName)
2763
+
2764
+ let bleImgId = fileName.split(separator: ".").first.map(String.init) ?? ""
2765
+ if blePhotoTransfers.removeValue(forKey: bleImgId) != nil {
2766
+ Bridge.log("LIVE: 🧹 Cleaned up timed out BLE photo transfer for: \(bleImgId)")
2767
+ }
2768
+ if bleIncidentLogRelays.removeValue(forKey: bleImgId) != nil {
2769
+ Bridge.log("LIVE: 🧹 Cleaned up timed out BLE incident log relay for: \(bleImgId)")
2770
+ }
2771
+ }
2772
+
2773
+ private func handleTransferFailed(_ json: [String: Any]) {
2774
+ let fileName = json["fileName"] as? String ?? ""
2775
+ let reason = json["reason"] as? String ?? "unknown"
2776
+ let requestId = json["requestId"] as? String ?? ""
2777
+
2778
+ guard !fileName.isEmpty else {
2779
+ Bridge.log("LIVE: ❌ Transfer failed notification missing fileName: \(json)")
2780
+ Bridge.sendPhotoError(
2781
+ requestId: requestId, errorCode: "FILE_NAME_MISSING",
2782
+ errorMessage: "Transfer failed fileName is missing"
2783
+ )
2784
+ return
2785
+ }
2786
+
2787
+ Bridge.log("LIVE: ❌ Transfer failed for: \(fileName) (reason: \(reason))")
2788
+ Bridge.sendPhotoError(
2789
+ requestId: requestId, errorCode: "TRANSFER_FAILED",
2790
+ errorMessage: "Transfer failed for: \(fileName) (reason: \(reason))"
2791
+ )
2792
+
2793
+ if let session = activeFileTransfers.removeValue(forKey: fileName) {
2794
+ Bridge.log(
2795
+ "LIVE: 📊 Transfer stats - Received: \(session.receivedPackets.count)/\(session.totalPackets) packets"
2796
+ )
2797
+ }
2798
+
2799
+ let bleImgId = fileName.split(separator: ".").first.map(String.init) ?? ""
2800
+ if let transfer = blePhotoTransfers.removeValue(forKey: bleImgId) {
2801
+ Bridge.log(
2802
+ "LIVE: 🧹 Cleaned up failed BLE photo transfer for: \(bleImgId) (requestId: \(transfer.requestId))"
2803
+ )
2804
+ }
2805
+ if bleIncidentLogRelays.removeValue(forKey: bleImgId) != nil {
2806
+ Bridge.log("LIVE: 🧹 Cleaned up failed BLE incident log relay for: \(bleImgId)")
2807
+ }
2808
+ }
2809
+
2810
+ // requestMissingPackets() removed - no longer used with ACK system
2811
+ // Phone now sends transfer_complete with success=false to trigger full retry
2812
+
2813
+ // MARK: - File Transfer Processing
2814
+
2815
+ private func processFilePacket(_ packetInfo: K900ProtocolUtils.FilePacketInfo) {
2816
+ // Bridge.log("📦 Processing file packet: \(packetInfo.fileName) [\(packetInfo.packIndex)/\(((packetInfo.fileSize + K900ProtocolUtils.FILE_PACK_SIZE - 1) / K900ProtocolUtils.FILE_PACK_SIZE - 1))] (\(packetInfo.packSize) bytes)")
2817
+
2818
+ // Check if this is a BLE photo transfer we're tracking
2819
+ var bleImgId = packetInfo.fileName
2820
+ if let dotIndex = bleImgId.lastIndex(of: ".") {
2821
+ bleImgId = String(bleImgId[..<dotIndex])
2822
+ }
2823
+
2824
+ if let incidentRelay = bleIncidentLogRelays[bleImgId] {
2825
+ Bridge.log("LIVE: 📦 BLE incident log relay packet for: \(bleImgId)")
2826
+
2827
+ if incidentRelay.session == nil {
2828
+ activeFileTransfers.removeValue(forKey: packetInfo.fileName)
2829
+ var session = FileTransferSession(
2830
+ fileName: packetInfo.fileName, fileSize: Int(packetInfo.fileSize)
2831
+ )
2832
+ session.recalculateTotalPackets(actualPackSize: Int(packetInfo.packSize))
2833
+ incidentRelay.session = session
2834
+ Bridge.log(
2835
+ "LIVE: 📦 Started BLE incident log transfer: \(packetInfo.fileName) (\(packetInfo.fileSize) bytes, \(session.totalPackets) packets)"
2836
+ )
2837
+ }
2838
+
2839
+ guard var session = incidentRelay.session else { return }
2840
+
2841
+ let added = session.addPacket(Int(packetInfo.packIndex), data: packetInfo.data)
2842
+ incidentRelay.session = session
2843
+
2844
+ if added {
2845
+ if session.isComplete {
2846
+ if let payload = session.assembleFile() {
2847
+ uploadBleIncidentLogRelay(
2848
+ relay: incidentRelay, fileName: packetInfo.fileName, data: payload
2849
+ )
2850
+ } else {
2851
+ sendTransferCompleteConfirmation(fileName: packetInfo.fileName, success: false)
2852
+ // Keep relay entry for glasses retry after transfer_complete:false.
2853
+ incidentRelay.session = nil
2854
+ }
2855
+ } else if session.isFinalPacket(Int(packetInfo.packIndex)) {
2856
+ let missing = session.missingPacketIndices()
2857
+ if !missing.isEmpty {
2858
+ Bridge.log(
2859
+ "LIVE: ❌ BLE incident log transfer incomplete. Missing \(missing.count) packets"
2860
+ )
2861
+ sendTransferCompleteConfirmation(fileName: packetInfo.fileName, success: false)
2862
+ // Keep relay entry for glasses retry after transfer_complete:false.
2863
+ incidentRelay.session = nil
2864
+ }
2865
+ }
2866
+ }
2867
+
2868
+ return
2869
+ }
2870
+
2871
+ if bleImgId.hasPrefix("B") || bleImgId.hasPrefix("L") {}
2872
+
2873
+ if var photoTransfer = blePhotoTransfers[bleImgId] {
2874
+ // This is a BLE photo transfer
2875
+ Bridge.log("📦 BLE photo transfer packet for requestId: \(photoTransfer.requestId)")
2876
+
2877
+ // Get or create session for this transfer
2878
+ if photoTransfer.session == nil {
2879
+ var session = FileTransferSession(
2880
+ fileName: packetInfo.fileName,
2881
+ fileSize: Int(packetInfo.fileSize)
2882
+ )
2883
+ photoTransfer.session = session
2884
+ blePhotoTransfers[bleImgId] = photoTransfer
2885
+ Bridge.log(
2886
+ "📦 Started BLE photo transfer: \(packetInfo.fileName) (\(packetInfo.fileSize) bytes, \(session.totalPackets) packets)"
2887
+ )
2888
+ }
2889
+
2890
+ // Add packet to session
2891
+ if var session = photoTransfer.session {
2892
+ let added = session.addPacket(Int(packetInfo.packIndex), data: packetInfo.data)
2893
+ photoTransfer.session = session
2894
+ blePhotoTransfers[bleImgId] = photoTransfer
2895
+
2896
+ if added {
2897
+ if session.isComplete {
2898
+ let transferEndTime = Date()
2899
+ let totalDuration =
2900
+ transferEndTime.timeIntervalSince(photoTransfer.phoneStartTime) * 1000
2901
+ let bleTransferDuration =
2902
+ photoTransfer.bleTransferStartTime != nil
2903
+ ? transferEndTime.timeIntervalSince(photoTransfer.bleTransferStartTime!)
2904
+ * 1000 : 0
2905
+
2906
+ Bridge.log("✅ BLE photo transfer complete: \(packetInfo.fileName)")
2907
+ Bridge.log(
2908
+ "⏱️ Total duration (request to complete): \(Int(totalDuration))ms"
2909
+ )
2910
+ Bridge.log(
2911
+ "⏱️ Glasses compression: \(photoTransfer.glassesCompressionDurationMs)ms"
2912
+ )
2913
+ if bleTransferDuration > 0 {
2914
+ Bridge.log("⏱️ BLE transfer duration: \(Int(bleTransferDuration))ms")
2915
+ Bridge.log(
2916
+ "📊 Transfer rate: \(Int(packetInfo.fileSize) * 1000 / Int(bleTransferDuration)) bytes/sec"
2917
+ )
2918
+ }
2919
+
2920
+ if let imageData = session.assembleFile() {
2921
+ processAndUploadBlePhoto(photoTransfer, imageData: imageData)
2922
+ }
2923
+
2924
+ sendTransferCompleteConfirmation(
2925
+ fileName: packetInfo.fileName, success: true
2926
+ )
2927
+ blePhotoTransfers.removeValue(forKey: bleImgId)
2928
+ } else if session.isFinalPacket(Int(packetInfo.packIndex)) {
2929
+ let missingPackets = session.missingPacketIndices()
2930
+ if !missingPackets.isEmpty {
2931
+ Bridge.log(
2932
+ "❌ BLE photo transfer incomplete after final packet. Missing \(missingPackets.count) packets: \(missingPackets)"
2933
+ )
2934
+ Bridge.log("❌ Telling glasses to retry entire transfer")
2935
+
2936
+ // Tell glasses transfer failed, they will retry
2937
+ sendTransferCompleteConfirmation(
2938
+ fileName: packetInfo.fileName, success: false
2939
+ )
2940
+ blePhotoTransfers.removeValue(forKey: bleImgId)
2941
+ }
2942
+ }
2943
+ }
2944
+ }
2945
+
2946
+ return
2947
+ }
2948
+
2949
+ // Regular file transfer (not a BLE photo)
2950
+ var session = activeFileTransfers[packetInfo.fileName]
2951
+ if session == nil {
2952
+ // New file transfer
2953
+ session = FileTransferSession(
2954
+ fileName: packetInfo.fileName, fileSize: Int(packetInfo.fileSize)
2955
+ )
2956
+ activeFileTransfers[packetInfo.fileName] = session
2957
+
2958
+ Bridge.log(
2959
+ "LIVE: 📦 Started new file transfer: \(packetInfo.fileName) (\(packetInfo.fileSize) bytes, \(session!.totalPackets) packets)"
2960
+ )
2961
+ }
2962
+
2963
+ // Add packet to session
2964
+ if var sess = session {
2965
+ let added = sess.addPacket(Int(packetInfo.packIndex), data: packetInfo.data)
2966
+ activeFileTransfers[packetInfo.fileName] = sess
2967
+
2968
+ if added {
2969
+ Bridge.log(
2970
+ "LIVE: 📦 Packet \(packetInfo.packIndex) received successfully (BES will auto-ACK)"
2971
+ )
2972
+
2973
+ if sess.isComplete {
2974
+ Bridge.log("LIVE: 📦 File transfer complete: \(packetInfo.fileName)")
2975
+
2976
+ if let fileData = sess.assembleFile() {
2977
+ saveReceivedFile(
2978
+ fileName: packetInfo.fileName, fileData: fileData,
2979
+ fileType: packetInfo.fileType
2980
+ )
2981
+ }
2982
+
2983
+ sendTransferCompleteConfirmation(fileName: packetInfo.fileName, success: true)
2984
+ activeFileTransfers.removeValue(forKey: packetInfo.fileName)
2985
+ } else if sess.isFinalPacket(Int(packetInfo.packIndex)) {
2986
+ let missingPackets = sess.missingPacketIndices()
2987
+ if !missingPackets.isEmpty {
2988
+ Bridge.log(
2989
+ "LIVE: ❌ File transfer incomplete after final packet. Missing \(missingPackets.count) packets: \(missingPackets)"
2990
+ )
2991
+ Bridge.log("LIVE: ❌ Telling glasses to retry entire transfer")
2992
+
2993
+ // Tell glasses transfer failed, they will retry
2994
+ sendTransferCompleteConfirmation(
2995
+ fileName: packetInfo.fileName, success: false
2996
+ )
2997
+ activeFileTransfers.removeValue(forKey: packetInfo.fileName)
2998
+ }
2999
+ }
3000
+ } else {
3001
+ Bridge.log("LIVE: 📦 Duplicate or invalid packet: \(packetInfo.packIndex)")
3002
+ }
3003
+ }
3004
+ }
3005
+
3006
+ private func saveReceivedFile(fileName: String, fileData: Data, fileType: UInt8) {
3007
+ do {
3008
+ // Get or create the directory for saving files
3009
+ let documentsDirectory = FileManager.default.urls(
3010
+ for: .documentDirectory, in: .userDomainMask
3011
+ ).first!
3012
+ let saveDirectory = documentsDirectory.appendingPathComponent(FILE_SAVE_DIR)
3013
+
3014
+ if !FileManager.default.fileExists(atPath: saveDirectory.path) {
3015
+ try FileManager.default.createDirectory(
3016
+ at: saveDirectory, withIntermediateDirectories: true
3017
+ )
3018
+ }
3019
+
3020
+ // Generate unique filename with timestamp
3021
+ let dateFormatter = DateFormatter()
3022
+ dateFormatter.dateFormat = "yyyyMMdd_HHmmss"
3023
+ let timestamp = dateFormatter.string(from: Date())
3024
+
3025
+ // Determine file extension based on type
3026
+ var fileExtension = ""
3027
+ switch fileType {
3028
+ case K900ProtocolUtils.CMD_TYPE_PHOTO:
3029
+ // For photos, try to preserve the original extension
3030
+ if let dotIndex = fileName.lastIndex(of: ".") {
3031
+ fileExtension = String(fileName[dotIndex...])
3032
+ } else {
3033
+ fileExtension = ".jpg" // Default to JPEG if no extension
3034
+ }
3035
+ case K900ProtocolUtils.CMD_TYPE_VIDEO:
3036
+ fileExtension = ".mp4"
3037
+ case K900ProtocolUtils.CMD_TYPE_AUDIO:
3038
+ fileExtension = ".wav"
3039
+ default:
3040
+ // Try to get extension from original filename
3041
+ if let dotIndex = fileName.lastIndex(of: ".") {
3042
+ fileExtension = String(fileName[dotIndex...])
3043
+ }
3044
+ }
3045
+
3046
+ // Create unique filename
3047
+ var baseFileName = fileName
3048
+ if let dotIndex = baseFileName.lastIndex(of: ".") {
3049
+ baseFileName = String(baseFileName[..<dotIndex])
3050
+ }
3051
+ let uniqueFileName = "\(baseFileName)_\(timestamp)\(fileExtension)"
3052
+
3053
+ // Save the file
3054
+ let fileURL = saveDirectory.appendingPathComponent(uniqueFileName)
3055
+ try fileData.write(to: fileURL)
3056
+
3057
+ Bridge.log("LIVE: 💾 Saved file: \(fileURL.path)")
3058
+
3059
+ // Notify about the received file
3060
+ notifyFileReceived(filePath: fileURL.path, fileType: fileType)
3061
+
3062
+ } catch {
3063
+ Bridge.log("LIVE: Error saving received file: \(fileName), error: \(error)")
3064
+ }
3065
+ }
3066
+
3067
+ private func notifyFileReceived(filePath: String, fileType: UInt8) {
3068
+ // Create event based on file type
3069
+ let event: [String: Any] = [
3070
+ "type": "file_received",
3071
+ "filePath": filePath,
3072
+ "fileType": String(format: "0x%02X", fileType),
3073
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
3074
+ ]
3075
+ }
3076
+
3077
+ private func uploadBleIncidentLogRelay(
3078
+ relay: BleIncidentLogRelayEntry, fileName: String, data: Data
3079
+ ) {
3080
+ let token = DeviceStore.shared.get("bluetooth", "core_token") as? String ?? ""
3081
+ guard !token.isEmpty else {
3082
+ sendTransferCompleteConfirmation(fileName: fileName, success: false)
3083
+ if let existing = bleIncidentLogRelays[relay.fileBaseKey] {
3084
+ existing.session = nil
3085
+ }
3086
+ return
3087
+ }
3088
+
3089
+ guard var components = URLComponents(string: relay.apiBaseUrl) else {
3090
+ sendTransferCompleteConfirmation(fileName: fileName, success: false)
3091
+ if let existing = bleIncidentLogRelays[relay.fileBaseKey] {
3092
+ existing.session = nil
3093
+ }
3094
+ return
3095
+ }
3096
+ let basePath = components.path.hasSuffix("/")
3097
+ ? String(components.path.dropLast())
3098
+ : components.path
3099
+ components.path = basePath + "/api/incidents/\(relay.incidentId)/logs"
3100
+ guard let url = components.url else {
3101
+ sendTransferCompleteConfirmation(fileName: fileName, success: false)
3102
+ if let existing = bleIncidentLogRelays[relay.fileBaseKey] {
3103
+ existing.session = nil
3104
+ }
3105
+ return
3106
+ }
3107
+
3108
+ var request = URLRequest(url: url)
3109
+ request.httpMethod = "POST"
3110
+ request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
3111
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
3112
+ request.httpBody = data
3113
+
3114
+ URLSession.shared.dataTask(with: request) { _, response, error in
3115
+ let ok: Bool
3116
+ let statusCode: Int?
3117
+ if error != nil {
3118
+ ok = false
3119
+ statusCode = nil
3120
+ } else if let http = response as? HTTPURLResponse {
3121
+ ok = (200 ..< 300).contains(http.statusCode)
3122
+ statusCode = http.statusCode
3123
+ } else {
3124
+ ok = false
3125
+ statusCode = nil
3126
+ }
3127
+ DispatchQueue.main.async {
3128
+ if ok {
3129
+ Bridge.log("LIVE: ✅ Incident log BLE relay uploaded (\(relay.kind))")
3130
+ self.bleIncidentLogRelays.removeValue(forKey: relay.fileBaseKey)
3131
+ } else if let existing = self.bleIncidentLogRelays[relay.fileBaseKey] {
3132
+ // Keep relay entry for glasses retry after transfer_complete:false.
3133
+ existing.session = nil
3134
+ }
3135
+ self.sendTransferCompleteConfirmation(fileName: fileName, success: ok)
3136
+ }
3137
+ }.resume()
3138
+ }
3139
+
3140
+ private func processAndUploadBlePhoto(_ transfer: BlePhotoTransfer, imageData: Data) {
3141
+ Bridge.log("LIVE: Processing BLE photo for upload. RequestId: \(transfer.requestId)")
3142
+
3143
+ BlePhotoUploadService.processAndUploadPhoto(
3144
+ imageData: imageData, requestId: transfer.requestId, webhookUrl: transfer.webhookUrl,
3145
+ authToken: transfer.authToken
3146
+ )
3147
+ }
3148
+
3149
+ private func sendAckToGlasses(messageId: Int) {
3150
+ let json: [String: Any] = [
3151
+ "type": "msg_ack",
3152
+ "mId": messageId,
3153
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
3154
+ ]
3155
+
3156
+ sendJson(json, requireAck: false)
3157
+ }
3158
+
3159
+ private func sendTransferCompleteConfirmation(fileName: String, success: Bool) {
3160
+ let json: [String: Any] = [
3161
+ "type": "transfer_complete",
3162
+ "fileName": fileName,
3163
+ "success": success,
3164
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
3165
+ ]
3166
+
3167
+ sendJson(json, wakeUp: true)
3168
+ Bridge.log(
3169
+ "\(success ? "✅" : "❌") Sent transfer completion confirmation for: \(fileName) (success: \(success))"
3170
+ )
3171
+ }
3172
+
3173
+ // MARK: - Sending Data
3174
+
3175
+ func queueSend(_ data: Data, id: String) {
3176
+ Task {
3177
+ await commandQueue.enqueue(PendingMessage(data: data, id: id, retries: 0))
3178
+ }
3179
+ }
3180
+
3181
+ func sendJson(_ jsonOriginal: [String: Any], wakeUp: Bool = false, requireAck: Bool = true) {
3182
+ do {
3183
+ var json = jsonOriginal
3184
+ var messageId: Int64 = -1
3185
+ var trackingId = "-1" // -1 means no ACK tracking needed
3186
+
3187
+ if isNewVersion, requireAck {
3188
+ messageId = Int64(globalMessageId)
3189
+ json["mId"] = globalMessageId
3190
+ trackingId = String(globalMessageId)
3191
+ globalMessageId += 1
3192
+ }
3193
+
3194
+ let jsonData = try JSONSerialization.data(withJSONObject: json)
3195
+ if let jsonString = String(data: jsonData, encoding: .utf8) {
3196
+ // First check if the message needs chunking
3197
+ // Create a test C-wrapped version to check size
3198
+ var testWrapper: [String: Any] = [K900ProtocolUtils.FIELD_C: jsonString]
3199
+ if wakeUp {
3200
+ testWrapper["W"] = 1
3201
+ }
3202
+ let testData = try JSONSerialization.data(withJSONObject: testWrapper)
3203
+ let testWrappedJson = String(data: testData, encoding: .utf8) ?? ""
3204
+
3205
+ // Check if chunking is needed
3206
+ if MessageChunker.needsChunking(testWrappedJson) {
3207
+ Bridge.log("LIVE: Message exceeds threshold, chunking required")
3208
+
3209
+ // Create chunks
3210
+ let chunks = MessageChunker.createChunks(
3211
+ originalJson: jsonString, messageId: messageId
3212
+ )
3213
+ Bridge.log("LIVE: Sending \(chunks.count) chunks")
3214
+
3215
+ // Send each chunk
3216
+ for (index, chunk) in chunks.enumerated() {
3217
+ let chunkData = try JSONSerialization.data(withJSONObject: chunk)
3218
+ if let chunkStr = String(data: chunkData, encoding: .utf8) {
3219
+ // Pack each chunk using the normal K900 protocol
3220
+ let packedData =
3221
+ packJson(chunkStr, wakeUp: wakeUp && index == 0) ?? Data() // Only wakeup on first chunk
3222
+
3223
+ // Queue the chunk for sending
3224
+ // Only track ACK for the final chunk (which has the mId)
3225
+ // All other chunks get "-1" (no ACK tracking)
3226
+ let isFinalChunk = (index == chunks.count - 1)
3227
+ let chunkTrackingId = (requireAck && isFinalChunk) ? trackingId : "-1"
3228
+ queueSend(packedData, id: chunkTrackingId)
3229
+
3230
+ // Add small delay between chunks to avoid overwhelming the connection
3231
+ if index < chunks.count - 1 {
3232
+ Thread.sleep(forTimeInterval: 0.05) // 50ms delay between chunks
3233
+ }
3234
+ }
3235
+ }
3236
+
3237
+ Bridge.log("LIVE: All chunks queued for transmission")
3238
+ } else {
3239
+ // Normal single message transmission
3240
+ Bridge.log("LIVE: Sending data to glasses: \(jsonString)")
3241
+ let packedData = packJson(jsonString, wakeUp: wakeUp) ?? Data()
3242
+ queueSend(packedData, id: trackingId)
3243
+ }
3244
+ }
3245
+ } catch {
3246
+ Bridge.log("LIVE: Error creating JSON: \(error)")
3247
+ }
3248
+ }
3249
+
3250
+ // MARK: - Status Requests
3251
+
3252
+ private func requestBatteryStatus() {
3253
+ // cs_batv is a K900 protocol command handled directly by BES2700
3254
+ // It doesn't go through MTK Android, so it doesn't use ACK system
3255
+ let command: [String: Any] = [
3256
+ "C": "cs_batv",
3257
+ "V": 1,
3258
+ "B": "",
3259
+ ]
3260
+
3261
+ if sendRawK900Command(command) {
3262
+ Bridge.log("LIVE: Sent cs_batv via queue (BES-handled command)")
3263
+ } else {
3264
+ Bridge.log("LIVE: Failed to send battery request")
3265
+ }
3266
+ }
3267
+
3268
+ private func requestWifiStatus() {
3269
+ let json: [String: Any] = ["type": "request_wifi_status"]
3270
+ sendJson(json, wakeUp: true)
3271
+ }
3272
+
3273
+ func requestVersionInfo() {
3274
+ let json: [String: Any] = ["type": "request_version"]
3275
+ sendJson(json)
3276
+ }
3277
+
3278
+ private func sendCoreTokenToAsgClient() {
3279
+ Bridge.log("Preparing to send coreToken to ASG client")
3280
+
3281
+ let coreToken = DeviceStore.shared.get("bluetooth", "core_token") as? String ?? ""
3282
+ if coreToken.isEmpty {
3283
+ Bridge.log("LIVE: No coreToken available to send to ASG client")
3284
+ return
3285
+ }
3286
+
3287
+ let json: [String: Any] = [
3288
+ "type": "auth_token",
3289
+ "coreToken": coreToken,
3290
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
3291
+ ]
3292
+
3293
+ sendJson(json)
3294
+ }
3295
+
3296
+ /// Send stored user email to the ASG client for Sentry crash reporting
3297
+ private func sendStoredUserEmailToAsgClient() {
3298
+ let storedEmail = DeviceStore.shared.store.get("bluetooth", "auth_email") as? String ?? ""
3299
+
3300
+ guard !storedEmail.isEmpty else {
3301
+ Bridge.log("LIVE: No stored user email to send to ASG client")
3302
+ return
3303
+ }
3304
+
3305
+ Bridge.log("LIVE: Sending stored user email to ASG client")
3306
+ sendUserEmailToGlasses(storedEmail)
3307
+ }
3308
+
3309
+ // MARK: - Power Control Methods
3310
+
3311
+ /**
3312
+ * Send shutdown command to the glasses.
3313
+ * This will initiate a graceful shutdown of the device.
3314
+ */
3315
+ @objc func sendShutdown() {
3316
+ Bridge.log("LIVE: 🔌 Sending shutdown command to glasses")
3317
+ let json: [String: Any] = ["type": "shutdown"]
3318
+ sendJson(json)
3319
+ }
3320
+
3321
+ /**
3322
+ * Send reboot command to the glasses.
3323
+ * This will initiate a reboot of the device.
3324
+ */
3325
+ @objc func sendReboot() {
3326
+ Bridge.log("LIVE: 🔄 Sending reboot command to glasses")
3327
+ let json: [String: Any] = ["type": "reboot"]
3328
+ sendJson(json)
3329
+ }
3330
+
3331
+ // MARK: - IMU Methods
3332
+
3333
+ /**
3334
+ * Request a single IMU reading from the glasses
3335
+ * Power-optimized: sensors turn on briefly then off
3336
+ */
3337
+ @objc func requestImuSingle() {
3338
+ Bridge.log("Requesting single IMU reading")
3339
+ let json: [String: Any] = ["type": "imu_single"]
3340
+ sendJson(json)
3341
+ }
3342
+
3343
+ /**
3344
+ * Start IMU streaming from the glasses
3345
+ * @param rateHz Sampling rate in Hz (1-100)
3346
+ * @param batchMs Batching period in milliseconds (0-1000)
3347
+ */
3348
+ @objc func startImuStream(rateHz: Int, batchMs: Int) {
3349
+ Bridge.log("Starting IMU stream: \(rateHz)Hz, batch: \(batchMs)ms")
3350
+ let json: [String: Any] = [
3351
+ "type": "imu_stream_start",
3352
+ "rate_hz": rateHz,
3353
+ "batch_ms": batchMs,
3354
+ ]
3355
+ sendJson(json)
3356
+ }
3357
+
3358
+ /**
3359
+ * Stop IMU streaming from the glasses
3360
+ */
3361
+ @objc func stopImuStream() {
3362
+ Bridge.log("Stopping IMU stream")
3363
+ let json: [String: Any] = ["type": "imu_stream_stop"]
3364
+ sendJson(json)
3365
+ }
3366
+
3367
+ /**
3368
+ * Subscribe to gesture detection on the glasses
3369
+ * Power-optimized: uses accelerometer-only at low rate
3370
+ * @param gestures Array of gestures to detect ("head_up", "head_down", "nod_yes", "shake_no")
3371
+ */
3372
+ @objc func subscribeToImuGestures(_ gestures: [String]) {
3373
+ Bridge.log("Subscribing to IMU gestures: \(gestures)")
3374
+ let json: [String: Any] = [
3375
+ "type": "imu_subscribe_gesture",
3376
+ "gestures": gestures,
3377
+ ]
3378
+ sendJson(json)
3379
+ }
3380
+
3381
+ /**
3382
+ * Unsubscribe from all gesture detection
3383
+ */
3384
+ @objc func unsubscribeFromImuGestures() {
3385
+ Bridge.log("LIVE: Unsubscribing from IMU gestures")
3386
+ let json: [String: Any] = ["type": "imu_unsubscribe_gesture"]
3387
+ sendJson(json)
3388
+ }
3389
+
3390
+ /**
3391
+ * Handle IMU response from glasses
3392
+ */
3393
+ private func handleImuResponse(_ json: [String: Any]) {
3394
+ guard let type = json["type"] as? String else {
3395
+ Bridge.log("LIVE: IMU response missing type")
3396
+ return
3397
+ }
3398
+
3399
+ switch type {
3400
+ case "imu_response":
3401
+ // Single IMU reading
3402
+ handleSingleImuData(json)
3403
+
3404
+ case "imu_stream_response":
3405
+ // Stream of IMU readings
3406
+ handleStreamImuData(json)
3407
+
3408
+ case "imu_gesture_response":
3409
+ // Gesture detected
3410
+ handleImuGesture(json)
3411
+
3412
+ case "imu_gesture_subscribed":
3413
+ // Gesture subscription confirmed
3414
+ if let gestures = json["gestures"] as? [String] {
3415
+ Bridge.log("LIVE: IMU gesture subscription confirmed: \(gestures)")
3416
+ }
3417
+
3418
+ case "imu_ack":
3419
+ // Command acknowledgment
3420
+ if let message = json["message"] as? String {
3421
+ Bridge.log("LIVE: IMU command acknowledged: \(message)")
3422
+ }
3423
+
3424
+ case "imu_error":
3425
+ // Error response
3426
+ if let error = json["error"] as? String {
3427
+ Bridge.log("LIVE: IMU error: \(error)")
3428
+ }
3429
+
3430
+ default:
3431
+ Bridge.log("LIVE: Unknown IMU response type: \(type)")
3432
+ }
3433
+ }
3434
+
3435
+ private func handleSingleImuData(_ json: [String: Any]) {
3436
+ guard let accel = json["accel"] as? [Double],
3437
+ let gyro = json["gyro"] as? [Double],
3438
+ let mag = json["mag"] as? [Double],
3439
+ let quat = json["quat"] as? [Double],
3440
+ let euler = json["euler"] as? [Double]
3441
+ else {
3442
+ Bridge.log("LIVE: Invalid IMU data format")
3443
+ return
3444
+ }
3445
+
3446
+ Bridge.log(
3447
+ String(
3448
+ format:
3449
+ "LIVE: IMU Single Reading - Accel: [%.2f, %.2f, %.2f], Euler: [%.1f°, %.1f°, %.1f°]",
3450
+ accel[0], accel[1], accel[2],
3451
+ euler[0], euler[1], euler[2]
3452
+ )
3453
+ )
3454
+
3455
+ // Emit event for other components
3456
+ let eventBody: [String: Any] = [
3457
+ "imu_data": [
3458
+ "accel": accel,
3459
+ "gyro": gyro,
3460
+ "mag": mag,
3461
+ "quat": quat,
3462
+ "euler": euler,
3463
+ "timestamp": Date().timeIntervalSince1970 * 1000,
3464
+ ],
3465
+ ]
3466
+ Bridge.sendTypedMessage("imu_data_event", body: eventBody)
3467
+ }
3468
+
3469
+ private func handleStreamImuData(_ json: [String: Any]) {
3470
+ guard let readings = json["readings"] as? [[String: Any]] else {
3471
+ Bridge.log("LIVE: Invalid IMU stream data format")
3472
+ return
3473
+ }
3474
+
3475
+ for reading in readings {
3476
+ handleSingleImuData(reading)
3477
+ }
3478
+ }
3479
+
3480
+ private func handleImuGesture(_ json: [String: Any]) {
3481
+ guard let gesture = json["gesture"] as? String else {
3482
+ Bridge.log("LIVE: Invalid IMU gesture format")
3483
+ return
3484
+ }
3485
+
3486
+ let timestamp = json["timestamp"] as? Double ?? Date().timeIntervalSince1970 * 1000
3487
+
3488
+ Bridge.log("LIVE: IMU Gesture detected: \(gesture)")
3489
+
3490
+ // Emit event for other components
3491
+ let eventBody: [String: Any] = [
3492
+ "imu_gesture": [
3493
+ "gesture": gesture,
3494
+ "timestamp": timestamp,
3495
+ ],
3496
+ ]
3497
+ Bridge.sendTypedMessage("imu_gesture_event", body: eventBody)
3498
+ }
3499
+
3500
+ // MARK: - Update Methods
3501
+
3502
+ private func updateBatteryStatus(level: Int, isCharging: Bool) {
3503
+ DeviceStore.shared.apply("glasses", "batteryLevel", level)
3504
+ DeviceStore.shared.apply("glasses", "charging", isCharging)
3505
+
3506
+ if level >= 0 {
3507
+ Bridge.sendBatteryStatus(level: level, charging: isCharging)
3508
+ }
3509
+ }
3510
+
3511
+ private func updateWifiStatus(connected: Bool, ssid: String, ip: String) {
3512
+ Bridge.log("LIVE: 🌐 Updating WiFi status - connected: \(connected), ssid: \(ssid)")
3513
+ DeviceStore.shared.apply("glasses", "wifiConnected", connected)
3514
+ DeviceStore.shared.apply("glasses", "wifiSsid", ssid)
3515
+ DeviceStore.shared.apply("glasses", "wifiLocalIp", ip)
3516
+ emitWifiStatusChange()
3517
+ }
3518
+
3519
+ private func updateHotspotStatus(enabled: Bool, ssid: String, password: String, ip: String) {
3520
+ Bridge.log("LIVE: 🔥 Updating hotspot status - enabled: \(enabled), ssid: \(ssid)")
3521
+ DeviceStore.shared.apply("glasses", "hotspotEnabled", enabled)
3522
+ DeviceStore.shared.apply("glasses", "hotspotSsid", ssid)
3523
+ DeviceStore.shared.apply("glasses", "hotspotPassword", password)
3524
+ DeviceStore.shared.apply("glasses", "hotspotGatewayIp", ip) // This is the gateway IP from glasses
3525
+ emitHotspotStatusChange()
3526
+ }
3527
+
3528
+ private func handleHotspotError(errorMessage: String, timestamp: Int64) {
3529
+ Bridge.log("LIVE: 🔥 ❌ Hotspot error: \(errorMessage)")
3530
+ emitHotspotError(errorMessage: errorMessage, timestamp: timestamp)
3531
+ }
3532
+
3533
+ private func emitHotspotError(errorMessage: String, timestamp: Int64) {
3534
+ let eventBody: [String: Any] = [
3535
+ "error_message": errorMessage,
3536
+ "timestamp": timestamp,
3537
+ ]
3538
+ Bridge.sendTypedMessage("hotspot_error", body: eventBody)
3539
+ }
3540
+
3541
+ private func handleGalleryStatus(
3542
+ photoCount: Int, videoCount: Int, totalCount: Int,
3543
+ totalSize: Int64, hasContent: Bool
3544
+ ) {
3545
+ Bridge.log(
3546
+ "LIVE: 📸 Received gallery status - photos: \(photoCount), videos: \(videoCount), total size: \(totalSize) bytes"
3547
+ )
3548
+
3549
+ // Emit gallery status event like other status events
3550
+ let eventBody =
3551
+ [
3552
+ "photos": photoCount,
3553
+ "videos": videoCount,
3554
+ "total": totalCount,
3555
+ "total_size": totalSize,
3556
+ "has_content": hasContent,
3557
+ ] as [String: Any]
3558
+ Bridge.sendTypedMessage("gallery_status", body: eventBody)
3559
+ }
3560
+
3561
+ // MARK: - Timers
3562
+
3563
+ private func startHeartbeat() {
3564
+ Bridge.log("LIVE: 💓 Starting heartbeat mechanism")
3565
+ heartbeatCounter = 0
3566
+
3567
+ // Ensure timer is created on main thread (required for RunLoop)
3568
+ DispatchQueue.main.async { [weak self] in
3569
+ guard let self = self else { return }
3570
+ self.heartbeatTimer?.invalidate()
3571
+ self.heartbeatTimer = Timer.scheduledTimer(
3572
+ withTimeInterval: self.HEARTBEAT_INTERVAL_MS, repeats: true
3573
+ ) { [weak self] _ in
3574
+ self?.sendHeartbeat()
3575
+ }
3576
+ }
3577
+ }
3578
+
3579
+ private func stopHeartbeat() {
3580
+ Bridge.log("LIVE: 💓 Stopping heartbeat mechanism")
3581
+
3582
+ // Ensure timer is stopped on main thread (same thread it was created on)
3583
+ DispatchQueue.main.async { [weak self] in
3584
+ self?.heartbeatTimer?.invalidate()
3585
+ self?.heartbeatTimer = nil
3586
+ }
3587
+
3588
+ heartbeatCounter = 0
3589
+ }
3590
+
3591
+ private func startSignalStrengthPolling() {
3592
+ Bridge.log("LIVE: 📶 Starting RSSI polling")
3593
+ stopSignalStrengthPolling()
3594
+
3595
+ requestSignalStrength()
3596
+
3597
+ let interval = DispatchTimeInterval.milliseconds(Int(SIGNAL_STRENGTH_READ_INTERVAL_MS * 1000))
3598
+ signalStrengthTimer = DispatchSource.makeTimerSource(queue: bluetoothQueue)
3599
+ signalStrengthTimer?.schedule(
3600
+ deadline: .now() + interval,
3601
+ repeating: interval
3602
+ )
3603
+ signalStrengthTimer?.setEventHandler { [weak self] in
3604
+ self?.requestSignalStrength()
3605
+ }
3606
+ signalStrengthTimer?.resume()
3607
+ }
3608
+
3609
+ private func stopSignalStrengthPolling() {
3610
+ signalStrengthTimer?.cancel()
3611
+ signalStrengthTimer = nil
3612
+ signalStrengthReadInFlight = false
3613
+ Bridge.log("LIVE: 📶 Stopping RSSI polling")
3614
+ }
3615
+
3616
+ private func requestSignalStrength() {
3617
+ guard let peripheral = connectedPeripheral else { return }
3618
+ guard !signalStrengthReadInFlight else {
3619
+ Bridge.log("LIVE: 📶 Skipping RSSI read - previous read still pending")
3620
+ return
3621
+ }
3622
+
3623
+ signalStrengthReadInFlight = true
3624
+ peripheral.readRSSI()
3625
+ }
3626
+
3627
+ private func updateSignalStrength(_ rssi: Int) {
3628
+ let now = Int(Date().timeIntervalSince1970 * 1000)
3629
+ DeviceStore.shared.apply("glasses", "signalStrength", rssi)
3630
+ DeviceStore.shared.apply("glasses", "signalStrengthUpdatedAt", now)
3631
+ Bridge.log("LIVE: 📶 RSSI: \(rssi) dBm")
3632
+ }
3633
+
3634
+ private func sendHeartbeat() {
3635
+ guard fullyBooted, connectionState == ConnTypes.CONNECTED else {
3636
+ Bridge.log("LIVE: Skipping heartbeat - glasses not fully booted or not connected")
3637
+ return
3638
+ }
3639
+
3640
+ // Send ping message to glasses hardware (no ACK needed for heartbeats)
3641
+ let pingJson: [String: Any] = ["type": "ping"]
3642
+ sendJson(pingJson, requireAck: false)
3643
+
3644
+ // Send heartbeat to AsgClientService for connection monitoring
3645
+ let serviceHeartbeat: [String: Any] = [
3646
+ "type": "service_heartbeat",
3647
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000), // milliseconds
3648
+ "heartbeat_counter": heartbeatCounter,
3649
+ ]
3650
+ sendJson(serviceHeartbeat, requireAck: false)
3651
+
3652
+ heartbeatCounter += 1
3653
+ Bridge.log("LIVE: 💓 Heartbeat #\(heartbeatCounter) sent (BLE ping + service heartbeat)")
3654
+
3655
+ // Request battery status periodically
3656
+ if heartbeatCounter % BATTERY_REQUEST_EVERY_N_HEARTBEATS == 0 {
3657
+ Bridge.log("LIVE: 🔋 Requesting battery status (heartbeat #\(heartbeatCounter))")
3658
+ requestBatteryStatus()
3659
+ }
3660
+ }
3661
+
3662
+ private var readinessCheckDispatchTimer: DispatchSourceTimer?
3663
+
3664
+ private func startReadinessCheckLoop() {
3665
+ Bridge.log("LIVE: startReadinessCheckLoop()")
3666
+ stopReadinessCheckLoop()
3667
+
3668
+ readinessCheckCounter = 0
3669
+ fullyBooted = false
3670
+ connected = false
3671
+
3672
+ Bridge.log("LIVE: 🔄 Starting glasses SOC readiness check loop")
3673
+
3674
+ readinessCheckDispatchTimer = DispatchSource.makeTimerSource(queue: bluetoothQueue)
3675
+ readinessCheckDispatchTimer!.schedule(
3676
+ deadline: .now(), repeating: READINESS_CHECK_INTERVAL_MS
3677
+ )
3678
+
3679
+ readinessCheckDispatchTimer!.setEventHandler { [weak self] in
3680
+ guard let self else { return }
3681
+
3682
+ self.readinessCheckCounter += 1
3683
+ Bridge.log(
3684
+ "LIVE: 🔄 Readiness check #\(self.readinessCheckCounter): waiting for glasses SOC to boot"
3685
+ )
3686
+ // requestReadyK900()
3687
+
3688
+ let readyMsg: [String: Any] = [
3689
+ "type": "phone_ready",
3690
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
3691
+ ]
3692
+ // Send it through our data channel
3693
+ sendJson(readyMsg, wakeUp: true)
3694
+ }
3695
+
3696
+ readinessCheckDispatchTimer!.resume()
3697
+ }
3698
+
3699
+ private func requestReadyK900() {
3700
+ // cs_hrt is a K900 protocol command handled directly by BES2700
3701
+ // It doesn't go through MTK Android, so it doesn't use ACK system
3702
+ let command: [String: Any] = [
3703
+ "C": "cs_hrt", // Heartbeat command for BES2700
3704
+ "B": "", // Empty body
3705
+ ]
3706
+
3707
+ if sendRawK900Command(command) {
3708
+ Bridge.log("LIVE: Sent cs_hrt via queue (BES-handled command)")
3709
+ } else {
3710
+ Bridge.log("LIVE: Failed to send readiness check")
3711
+ }
3712
+ }
3713
+
3714
+ private func stopReadinessCheckLoop() {
3715
+ readinessCheckDispatchTimer?.cancel()
3716
+ readinessCheckDispatchTimer = nil
3717
+ Bridge.log("LIVE: 🔄 Stopped glasses SOC readiness check loop")
3718
+ }
3719
+
3720
+ private func startConnectionTimeout() {
3721
+ connectionTimeoutTimer?.invalidate()
3722
+ connectionTimeoutTimer = Timer.scheduledTimer(
3723
+ withTimeInterval: Double(CONNECTION_TIMEOUT_MS) / 1_000_000_000, repeats: false
3724
+ ) { [weak self] _ in
3725
+ guard let self else { return }
3726
+
3727
+ if self.isConnecting, self.connectionState != ConnTypes.CONNECTED {
3728
+ Bridge.log("LIVE: Connection timeout - closing GATT connection")
3729
+ self.isConnecting = false
3730
+
3731
+ if let peripheral = self.connectedPeripheral {
3732
+ self.centralManager?.cancelPeripheralConnection(peripheral)
3733
+ }
3734
+
3735
+ self.handleReconnection()
3736
+ }
3737
+ }
3738
+ }
3739
+
3740
+ private func stopConnectionTimeout() {
3741
+ connectionTimeoutTimer?.invalidate()
3742
+ connectionTimeoutTimer = nil
3743
+ }
3744
+
3745
+ private func stopAllTimers() {
3746
+ stopHeartbeat()
3747
+ stopSignalStrengthPolling()
3748
+ stopReadinessCheckLoop()
3749
+ stopConnectionTimeout()
3750
+ stopMicBeat() // Stop LC3 audio micbeat
3751
+ pendingMessageTimer?.invalidate()
3752
+ pendingMessageTimer = nil
3753
+ reconnectionWorkItem?.cancel()
3754
+ reconnectionWorkItem = nil
3755
+ }
3756
+
3757
+ // MARK: - Event Emission
3758
+
3759
+ private func emitDiscoveredDevice(_ name: String, identifier: String = "", rssi: Int? = nil) {
3760
+ Bridge.sendDiscoveredDevice("Mentra Live", name, deviceAddress: identifier, rssi: rssi)
3761
+ }
3762
+
3763
+ private func emitStopScanEvent() {
3764
+ // Use the standardized typed message function
3765
+ let body = [
3766
+ "compatible_glasses_search_stop": [
3767
+ "device_model": "Mentra Live",
3768
+ ],
3769
+ ]
3770
+ Bridge.sendTypedMessage("compatible_glasses_search_stop", body: body)
3771
+ }
3772
+
3773
+ // private func emitBatteryLevelEvent(level: Int, charging: Bool) {
3774
+ // let eventBody: [String: Any] = [
3775
+ // "battery_level": level,
3776
+ // "is_charging": charging
3777
+ // ]
3778
+ // emitEvent("BatteryLevelEvent", body: eventBody)
3779
+ // }
3780
+
3781
+ private func emitWifiStatusChange() {
3782
+ Bridge.sendWifiStatusChange(connected: wifiConnected, ssid: wifiSsid, localIp: wifiLocalIp)
3783
+ }
3784
+
3785
+ private func emitHotspotStatusChange() {
3786
+ guard let status = HotspotStatus.fromStoreFields(
3787
+ enabled: hotspotEnabled,
3788
+ ssid: hotspotSsid,
3789
+ password: hotspotPassword,
3790
+ localIp: hotspotGatewayIp
3791
+ ) else {
3792
+ return
3793
+ }
3794
+ Bridge.sendTypedMessage("hotspot_status_change", body: status.values)
3795
+ }
3796
+
3797
+ private func emitRtmpStreamStatus(_ json: [String: Any]) {
3798
+ Bridge.sendTypedMessage("stream_status", body: json)
3799
+ }
3800
+
3801
+ private func emitButtonPress(buttonId: String, pressType: String, timestamp: Int64) {
3802
+ let eventBody: [String: Any] = [
3803
+ "device_model": "Mentra Live",
3804
+ "button_id": buttonId,
3805
+ "press_type": pressType,
3806
+ "timestamp": timestamp,
3807
+ ]
3808
+
3809
+ // emitEvent("onCoreEvent", body: eventBody)
3810
+ }
3811
+
3812
+ private func emitVersionInfo(
3813
+ appVersion: String, buildNumber: String, deviceModel: String, androidVersion: String,
3814
+ otaVersionUrl: String, firmwareVersion: String, btMacAddress: String
3815
+ ) {
3816
+ let eventBody: [String: Any] = [
3817
+ "app_version": appVersion,
3818
+ "build_number": buildNumber,
3819
+ "device_model": deviceModel,
3820
+ "android_version": androidVersion,
3821
+ "ota_version_url": otaVersionUrl,
3822
+ "firmware_version": firmwareVersion,
3823
+ "bt_mac_address": btMacAddress,
3824
+ ]
3825
+
3826
+ Bridge.sendTypedMessage("version_info", body: eventBody)
3827
+ }
3828
+
3829
+ private func emitKeepAliveAck(_ json: [String: Any]) {
3830
+ Bridge.sendTypedMessage("keep_alive_ack", body: json)
3831
+ }
3832
+
3833
+ // MARK: - Cleanup
3834
+
3835
+ private func destroy() {
3836
+ Bridge.log("Destroying MentraLiveManager")
3837
+
3838
+ isKilled = true
3839
+
3840
+ // Stop scanning
3841
+ if isScanning {
3842
+ stopScan()
3843
+ emitStopScanEvent()
3844
+ }
3845
+
3846
+ // Stop phone audio monitor
3847
+ phoneAudioMonitor?.stopMonitoring()
3848
+ Bridge.log("LIVE: 🎵 Phone audio monitor stopped")
3849
+
3850
+ // Stop all timers
3851
+ stopAllTimers()
3852
+
3853
+ // Disconnect BLE
3854
+ if let peripheral = connectedPeripheral {
3855
+ centralManager?.cancelPeripheralConnection(peripheral)
3856
+ }
3857
+
3858
+ DeviceStore.shared.apply("glasses", "connected", false)
3859
+ DeviceStore.shared.apply("glasses", "fullyBooted", false)
3860
+ DeviceStore.shared.apply("glasses", "wifiConnected", false)
3861
+ DeviceStore.shared.apply("glasses", "wifiSsid", "")
3862
+ DeviceStore.shared.apply("glasses", "wifiLocalIp", "")
3863
+ DeviceStore.shared.apply("glasses", "hotspotEnabled", false)
3864
+ DeviceStore.shared.apply("glasses", "hotspotSsid", "")
3865
+ DeviceStore.shared.apply("glasses", "hotspotPassword", "")
3866
+ DeviceStore.shared.apply("glasses", "hotspotGatewayIp", "")
3867
+
3868
+ connectedPeripheral = nil
3869
+ centralManager?.delegate = nil
3870
+ centralManager = nil
3871
+
3872
+ updateConnectionState(ConnTypes.DISCONNECTED)
3873
+ }
3874
+ }
3875
+
3876
+ // MARK: - K900 Protocol Utilities
3877
+
3878
+ extension MentraLive {
3879
+ /**
3880
+ * Pack raw byte data with K900 BES2700 protocol format
3881
+ * Format: ## + command_type + length(2bytes) + data + $$
3882
+ */
3883
+ private func packDataCommand(_ data: Data?, cmdType: UInt8) -> Data? {
3884
+ guard let data else { return nil }
3885
+
3886
+ let dataLength = data.count
3887
+
3888
+ // Command structure: ## + type + length(2 bytes) + data + $$
3889
+ var result = Data(capacity: dataLength + 7) // 2(start) + 1(type) + 2(length) + data + 2(end)
3890
+
3891
+ // Start code ##
3892
+ result.append(contentsOf: K900ProtocolUtils.CMD_START_CODE)
3893
+
3894
+ // Command type
3895
+ result.append(cmdType)
3896
+
3897
+ // Length (2 bytes, big-endian)
3898
+ result.append(UInt8((dataLength >> 8) & 0xFF)) // MSB first
3899
+ result.append(UInt8(dataLength & 0xFF)) // LSB second
3900
+
3901
+ // Copy the data
3902
+ result.append(data)
3903
+
3904
+ // End code $$
3905
+ result.append(contentsOf: K900ProtocolUtils.CMD_END_CODE)
3906
+
3907
+ return result
3908
+ }
3909
+
3910
+ /**
3911
+ * Pack raw byte data with K900 BES2700 protocol format for phone-to-device communication
3912
+ * Format: ## + command_type + length(2bytes) + data + $$
3913
+ * Uses little-endian byte order for length field
3914
+ */
3915
+ private func packDataToK900(_ data: Data?, cmdType: UInt8) -> Data? {
3916
+ guard let data else { return nil }
3917
+
3918
+ let dataLength = data.count
3919
+
3920
+ // Command structure: ## + type + length(2 bytes) + data + $$
3921
+ var result = Data(capacity: dataLength + 7) // 2(start) + 1(type) + 2(length) + data + 2(end)
3922
+
3923
+ // Start code ##
3924
+ result.append(contentsOf: K900ProtocolUtils.CMD_START_CODE)
3925
+
3926
+ // Command type
3927
+ result.append(cmdType)
3928
+
3929
+ // Length (2 bytes, little-endian for phone-to-device)
3930
+ result.append(UInt8(dataLength & 0xFF)) // LSB first
3931
+ result.append(UInt8((dataLength >> 8) & 0xFF)) // MSB second
3932
+
3933
+ // Copy the data
3934
+ result.append(data)
3935
+
3936
+ // End code $$
3937
+ result.append(contentsOf: K900ProtocolUtils.CMD_END_CODE)
3938
+
3939
+ return result
3940
+ }
3941
+
3942
+ /**
3943
+ * Pack a JSON string for phone-to-K900 device communication
3944
+ * 1. Wrap with C-field: {"C": jsonData}
3945
+ * 2. Then pack with BES2700 protocol using little-endian: ## + type + length + {"C": jsonData} + $$
3946
+ */
3947
+ private func packJson(_ jsonData: String?, wakeUp: Bool = false) -> Data? {
3948
+ guard let jsonData else { return nil }
3949
+
3950
+ do {
3951
+ // First wrap with C-field
3952
+ var wrapper: [String: Any] = [K900ProtocolUtils.FIELD_C: jsonData]
3953
+ if wakeUp {
3954
+ wrapper["W"] = 1 // Add W field as seen in MentraLiveSGC (optional)
3955
+ }
3956
+
3957
+ // Convert to string
3958
+ let jsonData = try JSONSerialization.data(withJSONObject: wrapper)
3959
+ guard let wrappedJson = String(data: jsonData, encoding: .utf8) else { return nil }
3960
+
3961
+ // Then pack with BES2700 protocol format using little-endian
3962
+ let jsonBytes = wrappedJson.data(using: .utf8)!
3963
+ return packDataToK900(jsonBytes, cmdType: K900ProtocolUtils.CMD_TYPE_STRING)
3964
+
3965
+ } catch {
3966
+ Bridge.log("Error creating JSON wrapper for K900: \(error)")
3967
+ return nil
3968
+ }
3969
+ }
3970
+
3971
+ /**
3972
+ * Create a C-wrapped JSON object ready for protocol formatting
3973
+ * Format: {"C": content}
3974
+ */
3975
+ private func createCWrappedJson(_ content: String) -> String? {
3976
+ do {
3977
+ let wrapper: [String: Any] = [K900ProtocolUtils.FIELD_C: content]
3978
+ let jsonData = try JSONSerialization.data(withJSONObject: wrapper)
3979
+ return String(data: jsonData, encoding: .utf8)
3980
+ } catch {
3981
+ Bridge.log("Error creating C-wrapped JSON: \(error)")
3982
+ return nil
3983
+ }
3984
+ }
3985
+
3986
+ /**
3987
+ * Check if data follows the K900 BES2700 protocol format
3988
+ * Verifies if data starts with ## markers
3989
+ */
3990
+ private func isK900ProtocolFormat(_ data: Data?) -> Bool {
3991
+ guard let data, data.count >= 7 else { return false }
3992
+
3993
+ let bytes = [UInt8](data)
3994
+ return bytes[0] == K900ProtocolUtils.CMD_START_CODE[0]
3995
+ && bytes[1] == K900ProtocolUtils.CMD_START_CODE[1]
3996
+ }
3997
+
3998
+ // MARK: - Glasses media volume (K900)
3999
+
4000
+ private func k900JsonInt(_ json: [String: Any], _ key: String) -> Int? {
4001
+ if let v = json[key] as? Int { return v }
4002
+ if let n = json[key] as? NSNumber { return n.intValue }
4003
+ return nil
4004
+ }
4005
+
4006
+ /// Parse K900 `B` field as dictionary or JSON string.
4007
+ private func k900ParseBody(_ body: Any?) -> [String: Any]? {
4008
+ if let d = body as? [String: Any] { return d }
4009
+ if let s = body as? String,
4010
+ let data = s.data(using: .utf8),
4011
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
4012
+ {
4013
+ return obj
4014
+ }
4015
+ return nil
4016
+ }
4017
+
4018
+ private func cancelGlassesMediaVolumeTimeout() {
4019
+ glassesMediaVolumeTimeoutWorkItem?.cancel()
4020
+ glassesMediaVolumeTimeoutWorkItem = nil
4021
+ }
4022
+
4023
+ private func scheduleGlassesMediaVolumeTimeout() {
4024
+ cancelGlassesMediaVolumeTimeout()
4025
+ let work = DispatchWorkItem { [weak self] in
4026
+ guard let self else { return }
4027
+ self.glassesMediaVolumeLock.lock()
4028
+ let getC = self.glassesMediaVolumeGetCompletion
4029
+ let setC = self.glassesMediaVolumeSetCompletion
4030
+ self.glassesMediaVolumeGetCompletion = nil
4031
+ self.glassesMediaVolumeSetCompletion = nil
4032
+ self.glassesMediaVolumeLock.unlock()
4033
+ let err = NSError(
4034
+ domain: "MentraLive",
4035
+ code: -1001,
4036
+ userInfo: [NSLocalizedDescriptionKey: "glasses_volume_timeout"]
4037
+ )
4038
+ if let getC {
4039
+ DispatchQueue.main.async { getC(.failure(err)) }
4040
+ }
4041
+ if let setC {
4042
+ DispatchQueue.main.async { setC(.failure(err)) }
4043
+ }
4044
+ }
4045
+ glassesMediaVolumeTimeoutWorkItem = work
4046
+ bluetoothQueue.asyncAfter(deadline: .now() + Self.glassesMediaVolumeTimeoutSec, execute: work)
4047
+ }
4048
+
4049
+ private func failPendingGlassesVolumeGet(_ error: Error) {
4050
+ cancelGlassesMediaVolumeTimeout()
4051
+ glassesMediaVolumeLock.lock()
4052
+ let c = glassesMediaVolumeGetCompletion
4053
+ glassesMediaVolumeGetCompletion = nil
4054
+ glassesMediaVolumeLock.unlock()
4055
+ if let c {
4056
+ DispatchQueue.main.async { c(.failure(error)) }
4057
+ }
4058
+ }
4059
+
4060
+ private func failPendingGlassesVolumeSet(_ error: Error) {
4061
+ cancelGlassesMediaVolumeTimeout()
4062
+ glassesMediaVolumeLock.lock()
4063
+ let c = glassesMediaVolumeSetCompletion
4064
+ glassesMediaVolumeSetCompletion = nil
4065
+ glassesMediaVolumeLock.unlock()
4066
+ if let c {
4067
+ DispatchQueue.main.async { c(.failure(error)) }
4068
+ }
4069
+ }
4070
+
4071
+ private func handleSrGetvol(_ json: [String: Any]) {
4072
+ let body = k900ParseBody(json["B"])
4073
+ var status = k900JsonInt(json, "S") ?? -1
4074
+ if status < 0, let b = body, let s = k900JsonInt(b, "S") {
4075
+ status = s
4076
+ }
4077
+ let vol = body.flatMap { k900JsonInt($0, "vol") } ?? -1
4078
+
4079
+ glassesMediaVolumeLock.lock()
4080
+ let waiting = glassesMediaVolumeGetCompletion != nil
4081
+ glassesMediaVolumeLock.unlock()
4082
+
4083
+ guard waiting else {
4084
+ Bridge.log("LIVE: sr_getvol received with no pending request (status=\(status), vol=\(vol))")
4085
+ return
4086
+ }
4087
+
4088
+ guard vol >= 0, vol <= 15 else {
4089
+ Bridge.log("LIVE: sr_getvol invalid vol=\(vol)")
4090
+ failPendingGlassesVolumeGet(
4091
+ NSError(
4092
+ domain: "MentraLive",
4093
+ code: -1002,
4094
+ userInfo: [NSLocalizedDescriptionKey: "glasses_volume_invalid_response"]
4095
+ )
4096
+ )
4097
+ return
4098
+ }
4099
+
4100
+ Bridge.log("LIVE: sr_getvol received vol=\(vol) (0-15), statusCode=\(status)")
4101
+
4102
+ cancelGlassesMediaVolumeTimeout()
4103
+ glassesMediaVolumeLock.lock()
4104
+ let c = glassesMediaVolumeGetCompletion
4105
+ glassesMediaVolumeGetCompletion = nil
4106
+ glassesMediaVolumeLock.unlock()
4107
+ if let c {
4108
+ DispatchQueue.main.async {
4109
+ c(.success(["vol": vol, "statusCode": status]))
4110
+ }
4111
+ }
4112
+ }
4113
+
4114
+ private func handleSrVol(_ json: [String: Any]) {
4115
+ let status = k900JsonInt(json, "S") ?? -1
4116
+
4117
+ glassesMediaVolumeLock.lock()
4118
+ let waiting = glassesMediaVolumeSetCompletion != nil
4119
+ glassesMediaVolumeLock.unlock()
4120
+
4121
+ guard waiting else {
4122
+ Bridge.log("LIVE: sr_vol received with no pending request (status=\(status))")
4123
+ return
4124
+ }
4125
+
4126
+ cancelGlassesMediaVolumeTimeout()
4127
+ glassesMediaVolumeLock.lock()
4128
+ let c = glassesMediaVolumeSetCompletion
4129
+ glassesMediaVolumeSetCompletion = nil
4130
+ glassesMediaVolumeLock.unlock()
4131
+ if let c {
4132
+ DispatchQueue.main.async {
4133
+ c(.success(["statusCode": status]))
4134
+ }
4135
+ }
4136
+ }
4137
+
4138
+ private func sendGlassesMediaVolumeGetCommand() -> Bool {
4139
+ let command: [String: Any] = [
4140
+ "C": "cs_getvol",
4141
+ "V": 1,
4142
+ "B": "",
4143
+ ]
4144
+ return sendRawK900Command(command, wakeUp: true)
4145
+ }
4146
+
4147
+ private func sendGlassesMediaVolumeSetCommand(level: Int) -> Bool {
4148
+ let clamped = max(0, min(15, level))
4149
+ do {
4150
+ let bodyData = try JSONSerialization.data(withJSONObject: ["vol": clamped])
4151
+ guard let bodyString = String(data: bodyData, encoding: .utf8) else { return false }
4152
+ let command: [String: Any] = [
4153
+ "C": "cs_vol",
4154
+ "V": 1,
4155
+ "B": bodyString,
4156
+ ]
4157
+ return sendRawK900Command(command, wakeUp: true)
4158
+ } catch {
4159
+ Bridge.log("LIVE: Error encoding cs_vol body: \(error)")
4160
+ return false
4161
+ }
4162
+ }
4163
+
4164
+ /// Read glasses media step volume (0–15) via K900 `cs_getvol` / `sr_getvol`.
4165
+ func getGlassesMediaVolume(completion: @escaping (Result<[String: Any], Error>) -> Void) {
4166
+ glassesMediaVolumeLock.lock()
4167
+ if glassesMediaVolumeGetCompletion != nil || glassesMediaVolumeSetCompletion != nil {
4168
+ glassesMediaVolumeLock.unlock()
4169
+ completion(
4170
+ .failure(
4171
+ NSError(
4172
+ domain: "MentraLive",
4173
+ code: -1003,
4174
+ userInfo: [NSLocalizedDescriptionKey: "glasses_volume_busy"]
4175
+ )
4176
+ )
4177
+ )
4178
+ return
4179
+ }
4180
+ glassesMediaVolumeGetCompletion = completion
4181
+ glassesMediaVolumeLock.unlock()
4182
+
4183
+ guard connectionState == ConnTypes.CONNECTED, fullyBooted else {
4184
+ failPendingGlassesVolumeGet(
4185
+ NSError(
4186
+ domain: "MentraLive",
4187
+ code: -1004,
4188
+ userInfo: [NSLocalizedDescriptionKey: "glasses_not_ready"]
4189
+ )
4190
+ )
4191
+ return
4192
+ }
4193
+
4194
+ scheduleGlassesMediaVolumeTimeout()
4195
+ if !sendGlassesMediaVolumeGetCommand() {
4196
+ failPendingGlassesVolumeGet(
4197
+ NSError(
4198
+ domain: "MentraLive",
4199
+ code: -1005,
4200
+ userInfo: [NSLocalizedDescriptionKey: "glasses_volume_send_failed"]
4201
+ )
4202
+ )
4203
+ }
4204
+ }
4205
+
4206
+ /// Set glasses media step volume (0–15) via K900 `cs_vol` / `sr_vol`.
4207
+ func setGlassesMediaVolume(level: Int, completion: @escaping (Result<[String: Any], Error>) -> Void) {
4208
+ glassesMediaVolumeLock.lock()
4209
+ if glassesMediaVolumeGetCompletion != nil || glassesMediaVolumeSetCompletion != nil {
4210
+ glassesMediaVolumeLock.unlock()
4211
+ completion(
4212
+ .failure(
4213
+ NSError(
4214
+ domain: "MentraLive",
4215
+ code: -1003,
4216
+ userInfo: [NSLocalizedDescriptionKey: "glasses_volume_busy"]
4217
+ )
4218
+ )
4219
+ )
4220
+ return
4221
+ }
4222
+ glassesMediaVolumeSetCompletion = completion
4223
+ glassesMediaVolumeLock.unlock()
4224
+
4225
+ guard connectionState == ConnTypes.CONNECTED, fullyBooted else {
4226
+ failPendingGlassesVolumeSet(
4227
+ NSError(
4228
+ domain: "MentraLive",
4229
+ code: -1004,
4230
+ userInfo: [NSLocalizedDescriptionKey: "glasses_not_ready"]
4231
+ )
4232
+ )
4233
+ return
4234
+ }
4235
+
4236
+ scheduleGlassesMediaVolumeTimeout()
4237
+ if !sendGlassesMediaVolumeSetCommand(level: level) {
4238
+ failPendingGlassesVolumeSet(
4239
+ NSError(
4240
+ domain: "MentraLive",
4241
+ code: -1005,
4242
+ userInfo: [NSLocalizedDescriptionKey: "glasses_volume_send_failed"]
4243
+ )
4244
+ )
4245
+ }
4246
+ }
4247
+
4248
+ private func sendRawK900Command(_ command: [String: Any], wakeUp: Bool = false) -> Bool {
4249
+ do {
4250
+ var payload = command
4251
+ if wakeUp {
4252
+ payload["W"] = 1
4253
+ }
4254
+ let commandData = try JSONSerialization.data(withJSONObject: payload)
4255
+ guard
4256
+ let packet = packDataToK900(commandData, cmdType: K900ProtocolUtils.CMD_TYPE_STRING)
4257
+ else {
4258
+ Bridge.log("LIVE: Failed to pack raw K900 command")
4259
+ return false
4260
+ }
4261
+ queueSend(packet, id: "-1")
4262
+ return true
4263
+ } catch {
4264
+ Bridge.log("LIVE: Error building raw K900 command: \(error)")
4265
+ return false
4266
+ }
4267
+ }
4268
+
4269
+ private func sendRgbLedControlAuthority(_ claimControl: Bool) {
4270
+ do {
4271
+ let bodyData = try JSONSerialization.data(withJSONObject: ["on": claimControl])
4272
+ guard let bodyString = String(data: bodyData, encoding: .utf8) else {
4273
+ Bridge.log("LIVE: Failed to encode RGB LED authority body")
4274
+ return
4275
+ }
4276
+
4277
+ let command: [String: Any] = [
4278
+ "C": "android_control_led",
4279
+ "V": 1,
4280
+ "B": bodyString,
4281
+ ]
4282
+
4283
+ if sendRawK900Command(command, wakeUp: true) {
4284
+ rgbLedAuthorityClaimed = claimControl
4285
+ Bridge.log("LIVE: RGB LED authority \(claimControl ? "claimed" : "released")")
4286
+ } else {
4287
+ Bridge.log("LIVE: Failed to send RGB LED authority command")
4288
+ if !claimControl {
4289
+ rgbLedAuthorityClaimed = false
4290
+ }
4291
+ }
4292
+ } catch {
4293
+ Bridge.log("LIVE: Error encoding RGB LED authority payload: \(error)")
4294
+ }
4295
+ }
4296
+
4297
+ private func setTouchEventReporting(_ enable: Bool) {
4298
+ do {
4299
+ let bodyData = try JSONSerialization.data(withJSONObject: [
4300
+ "type": 26, "switch": enable,
4301
+ ])
4302
+ guard let bodyString = String(data: bodyData, encoding: .utf8) else {
4303
+ Bridge.log("LIVE: Failed to encode touch event control payload")
4304
+ return
4305
+ }
4306
+
4307
+ let command: [String: Any] = [
4308
+ "C": "cs_swit",
4309
+ "V": 1,
4310
+ "B": bodyString,
4311
+ ]
4312
+
4313
+ if sendRawK900Command(command, wakeUp: true) {
4314
+ Bridge.log("LIVE: Touch event reporting \(enable ? "enabled" : "disabled")")
4315
+ } else {
4316
+ Bridge.log("LIVE: Failed to send touch event reporting command")
4317
+ }
4318
+ } catch {
4319
+ Bridge.log("LIVE: Error encoding touch event control payload: \(error)")
4320
+ }
4321
+ }
4322
+
4323
+ private func setSwipeVolumeControl(_ enable: Bool) {
4324
+ do {
4325
+ let bodyData = try JSONSerialization.data(withJSONObject: ["switch": enable])
4326
+ guard let bodyString = String(data: bodyData, encoding: .utf8) else {
4327
+ Bridge.log("LIVE: Failed to encode swipe volume payload")
4328
+ return
4329
+ }
4330
+
4331
+ let command: [String: Any] = [
4332
+ "C": "cs_fbvol",
4333
+ "V": 1,
4334
+ "B": bodyString,
4335
+ ]
4336
+
4337
+ if sendRawK900Command(command, wakeUp: true) {
4338
+ Bridge.log("LIVE: Swipe volume control \(enable ? "enabled" : "disabled")")
4339
+ } else {
4340
+ Bridge.log("LIVE: Failed to send swipe volume command")
4341
+ }
4342
+ } catch {
4343
+ Bridge.log("LIVE: Error encoding swipe volume payload: \(error)")
4344
+ }
4345
+ }
4346
+
4347
+ func sendRgbLedControl(
4348
+ requestId: String,
4349
+ packageName: String?,
4350
+ action: String,
4351
+ color: String?,
4352
+ ontime: Int,
4353
+ offtime: Int,
4354
+ count: Int
4355
+ ) {
4356
+ guard connectionState == ConnTypes.CONNECTED, fullyBooted else {
4357
+ Bridge.log("LIVE: Cannot handle RGB LED control - glasses not connected")
4358
+ Bridge.sendRgbLedControlResponse(
4359
+ requestId: requestId, success: false, error: "glasses_not_connected"
4360
+ )
4361
+ return
4362
+ }
4363
+
4364
+ if !rgbLedAuthorityClaimed {
4365
+ sendRgbLedControlAuthority(true)
4366
+ }
4367
+
4368
+ var command: [String: Any] = [
4369
+ "requestId": requestId,
4370
+ ]
4371
+
4372
+ if let packageName, !packageName.isEmpty {
4373
+ command["packageName"] = packageName
4374
+ }
4375
+
4376
+ switch action {
4377
+ case "on":
4378
+ let ledIndex = ledIndex(for: color)
4379
+ command["type"] = "rgb_led_control_on"
4380
+ command["led"] = ledIndex
4381
+ command["ontime"] = ontime
4382
+ command["offtime"] = offtime
4383
+ command["count"] = count
4384
+ case "off":
4385
+ command["type"] = "rgb_led_control_off"
4386
+ default:
4387
+ Bridge.log("LIVE: Unsupported RGB LED action: \(action)")
4388
+ Bridge.sendRgbLedControlResponse(
4389
+ requestId: requestId, success: false, error: "unsupported_action"
4390
+ )
4391
+ return
4392
+ }
4393
+
4394
+ Bridge.log("LIVE: Forwarding RGB LED command to glasses: \(command)")
4395
+ sendJson(command, wakeUp: true)
4396
+ }
4397
+
4398
+ private func ledIndex(for color: String?) -> Int {
4399
+ guard let color else { return 0 }
4400
+ switch color.lowercased() {
4401
+ case "red": return 0
4402
+ case "green": return 1
4403
+ case "blue": return 2
4404
+ case "orange": return 3
4405
+ case "white": return 4
4406
+ default:
4407
+ return 0
4408
+ }
4409
+ }
4410
+
4411
+ private func parseTimestamp(_ value: Any?) -> Int64 {
4412
+ if let int64 = value as? Int64 {
4413
+ return int64
4414
+ }
4415
+ if let intValue = value as? Int {
4416
+ return Int64(intValue)
4417
+ }
4418
+ if let doubleValue = value as? Double {
4419
+ return Int64(doubleValue)
4420
+ }
4421
+ return Int64(Date().timeIntervalSince1970 * 1000)
4422
+ }
4423
+
4424
+ /**
4425
+ * Check if a JSON string is already properly formatted for K900 protocol
4426
+ */
4427
+ private func isCWrappedJson(_ jsonStr: String) -> Bool {
4428
+ do {
4429
+ guard let data = jsonStr.data(using: .utf8) else { return false }
4430
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
4431
+
4432
+ // Check for simple C-wrapping {"C": "content"} - only one field
4433
+ if let json, json.keys.contains(K900ProtocolUtils.FIELD_C), json.count == 1 {
4434
+ return true
4435
+ }
4436
+
4437
+ // Check for full K900 format {"C": "command", "V": val, "B": body}
4438
+ if let json,
4439
+ json.keys.contains(K900ProtocolUtils.FIELD_C),
4440
+ json.keys.contains(K900ProtocolUtils.FIELD_V),
4441
+ json.keys.contains(K900ProtocolUtils.FIELD_B)
4442
+ {
4443
+ return true
4444
+ }
4445
+
4446
+ return false
4447
+ } catch {
4448
+ return false
4449
+ }
4450
+ }
4451
+
4452
+ /**
4453
+ * Extract payload from K900 protocol formatted data received from device
4454
+ * Uses little-endian byte order for length field
4455
+ */
4456
+ private func extractPayloadFromK900(_ protocolData: Data?) -> Data? {
4457
+ guard let protocolData,
4458
+ isK900ProtocolFormat(protocolData),
4459
+ protocolData.count >= 7
4460
+ else {
4461
+ return nil
4462
+ }
4463
+
4464
+ let bytes = [UInt8](protocolData)
4465
+
4466
+ // Extract length (little-endian for device-to-phone)
4467
+ let length = Int(bytes[3]) | (Int(bytes[4]) << 8)
4468
+
4469
+ if length + 7 > protocolData.count {
4470
+ return nil // Invalid length
4471
+ }
4472
+
4473
+ // Extract payload
4474
+ return protocolData.subdata(in: 5 ..< (5 + length))
4475
+ }
4476
+
4477
+ private func sendUserSettings() {
4478
+ Bridge.log("Sending user settings to glasses")
4479
+
4480
+ // Send button video recording settings
4481
+ sendButtonVideoRecordingSettings()
4482
+
4483
+ // Send button max recording time
4484
+ let maxTime = DeviceStore.shared.get("bluetooth", "button_max_recording_time") as! Int
4485
+ sendButtonMaxRecordingTime(maxTime)
4486
+
4487
+ // Send button photo settings
4488
+ sendButtonPhotoSettings()
4489
+
4490
+ // Send button camera LED setting
4491
+ sendButtonCameraLedSetting()
4492
+
4493
+ // Send camera FOV setting (K900 / Mentra Live)
4494
+ sendCameraFovSetting()
4495
+
4496
+ // Send gallery mode state (camera app running status)
4497
+ sendGalleryMode()
4498
+ }
4499
+
4500
+ func sendButtonVideoRecordingSettings() {
4501
+ let settings =
4502
+ DeviceStore.shared.get("bluetooth", "button_video_settings") as? [String: Any] ?? [
4503
+ "width": 1280,
4504
+ "height": 720,
4505
+ "fps": 30,
4506
+ ]
4507
+ let width = settings["width"] as? Int ?? 1280
4508
+ let height = settings["height"] as? Int ?? 720
4509
+ let fps = settings["fps"] as? Int ?? 30
4510
+
4511
+ // Use defaults if not set
4512
+ let finalWidth = width > 0 ? width : 1280
4513
+ let finalHeight = height > 0 ? height : 720
4514
+ let finalFps = fps > 0 ? fps : 30
4515
+
4516
+ Bridge.log(
4517
+ "Sending button video recording settings: \(finalWidth)x\(finalHeight)@\(finalFps)fps"
4518
+ )
4519
+
4520
+ guard connectionState == ConnTypes.CONNECTED else {
4521
+ Bridge.log("Cannot send button video recording settings - not connected")
4522
+ return
4523
+ }
4524
+
4525
+ let json: [String: Any] = [
4526
+ "type": "button_video_recording_setting",
4527
+ "params": [
4528
+ "width": finalWidth,
4529
+ "height": finalHeight,
4530
+ "fps": finalFps,
4531
+ ],
4532
+ ]
4533
+ sendJson(json, wakeUp: true)
4534
+ }
4535
+
4536
+ func sendButtonMaxRecordingTime() {
4537
+ let maxTime = DeviceStore.shared.get("bluetooth", "button_max_recording_time") as? Int ?? 10
4538
+ Bridge.log("Sending button max recording time: \(maxTime) minutes")
4539
+
4540
+ guard connectionState == ConnTypes.CONNECTED else {
4541
+ Bridge.log("Cannot send button max recording time - not connected")
4542
+ return
4543
+ }
4544
+
4545
+ let json: [String: Any] = [
4546
+ "type": "button_max_recording_time",
4547
+ "minutes": maxTime,
4548
+ ]
4549
+ sendJson(json, wakeUp: true)
4550
+ }
4551
+
4552
+ func sendButtonPhotoSettings() {
4553
+ let size = DeviceStore.shared.get("bluetooth", "button_photo_size") as! String
4554
+
4555
+ Bridge.log("Sending button photo setting: \(size)")
4556
+
4557
+ guard connectionState == ConnTypes.CONNECTED else {
4558
+ Bridge.log("Cannot send button photo settings - not connected")
4559
+ return
4560
+ }
4561
+
4562
+ let json: [String: Any] = [
4563
+ "type": "button_photo_setting",
4564
+ "size": size,
4565
+ ]
4566
+ sendJson(json, wakeUp: true)
4567
+ }
4568
+
4569
+ func sendButtonCameraLedSetting() {
4570
+ let enabled = DeviceStore.shared.get("bluetooth", "button_camera_led") as! Bool
4571
+
4572
+ Bridge.log("Sending button camera LED setting: \(enabled)")
4573
+
4574
+ guard connectionState == ConnTypes.CONNECTED else {
4575
+ Bridge.log("Cannot send button camera LED setting - not connected")
4576
+ return
4577
+ }
4578
+
4579
+ let json: [String: Any] = [
4580
+ "type": "button_camera_led",
4581
+ "enabled": enabled,
4582
+ ]
4583
+ sendJson(json, wakeUp: true)
4584
+ }
4585
+
4586
+ func sendCameraFovSetting() {
4587
+ let settings = DeviceStore.shared.get("bluetooth", "camera_fov") as? [String: Any] ?? ["fov": 118, "roi_position": 0]
4588
+ let fov = settings["fov"] as? Int ?? 118
4589
+ let roiPosition = settings["roi_position"] as? Int ?? 0
4590
+
4591
+ Bridge.log("Sending camera FOV setting: fov=\(fov), roi_position=\(roiPosition)")
4592
+
4593
+ guard connectionState == ConnTypes.CONNECTED else {
4594
+ Bridge.log("Cannot send camera FOV setting - not connected")
4595
+ return
4596
+ }
4597
+
4598
+ let json: [String: Any] = [
4599
+ "type": "camera_fov_setting",
4600
+ "params": [
4601
+ "fov": fov,
4602
+ "roi_position": roiPosition,
4603
+ ],
4604
+ ]
4605
+ sendJson(json, wakeUp: true)
4606
+ }
4607
+
4608
+ func startVideoRecording(requestId: String, save: Bool, flash: Bool, sound: Bool) {
4609
+ startVideoRecording(
4610
+ requestId: requestId, save: save, flash: flash, sound: sound, width: 0, height: 0, fps: 0
4611
+ )
4612
+ }
4613
+
4614
+ // MARK: - SGCManager Protocol Compliance
4615
+
4616
+ func sendButtonMaxRecordingTime(_ minutes: Int) {
4617
+ let maxTime = minutes
4618
+
4619
+ Bridge.log("Sending button max recording time: \(maxTime) minutes")
4620
+
4621
+ guard connectionState == ConnTypes.CONNECTED else {
4622
+ Bridge.log("Cannot send button max recording time - not connected")
4623
+ return
4624
+ }
4625
+
4626
+ let json: [String: Any] = [
4627
+ "type": "button_max_recording_time",
4628
+ "minutes": maxTime,
4629
+ ]
4630
+ sendJson(json, wakeUp: true)
4631
+ }
4632
+
4633
+ func startVideoRecording(
4634
+ requestId: String, save: Bool, flash: Bool, sound: Bool, width: Int, height: Int, fps: Int
4635
+ ) {
4636
+ Bridge.log(
4637
+ "Starting video recording on glasses: requestId=\(requestId), save=\(save), flash=\(flash), sound=\(sound), resolution=\(width)x\(height)@\(fps)fps"
4638
+ )
4639
+
4640
+ guard connectionState == ConnTypes.CONNECTED else {
4641
+ Bridge.log("Cannot start video recording - not connected")
4642
+ return
4643
+ }
4644
+
4645
+ var json: [String: Any] = [
4646
+ "type": "start_video_recording",
4647
+ "request_id": requestId,
4648
+ "save": save,
4649
+ "flash": flash,
4650
+ "sound": sound,
4651
+ ]
4652
+
4653
+ // Add video settings if provided
4654
+ if width > 0, height > 0 {
4655
+ json["settings"] = [
4656
+ "width": width,
4657
+ "height": height,
4658
+ "fps": fps > 0 ? fps : 30,
4659
+ ]
4660
+ }
4661
+ sendJson(json)
4662
+ }
4663
+
4664
+ func stopVideoRecording(requestId: String) {
4665
+ Bridge.log("Stopping video recording on glasses: requestId=\(requestId)")
4666
+
4667
+ guard connectionState == ConnTypes.CONNECTED else {
4668
+ Bridge.log("Cannot stop video recording - not connected")
4669
+ return
4670
+ }
4671
+
4672
+ let json: [String: Any] = [
4673
+ "type": "stop_video_recording",
4674
+ "request_id": requestId,
4675
+ ]
4676
+ sendJson(json)
4677
+ }
4678
+ }
4679
+
4680
+ // MARK: - PhoneAudioMonitorListener
4681
+
4682
+ extension MentraLive: PhoneAudioMonitorListener {
4683
+ /// Handle phone audio playback state changes
4684
+ /// Called by PhoneAudioMonitor when phone starts/stops playing audio
4685
+ ///
4686
+ /// State machine logic:
4687
+ /// - When phone starts playing audio: suspend LC3 mic if it was running
4688
+ /// - When phone stops playing audio: resume LC3 mic if it was suspended
4689
+ func onPhoneAudioStateChanged(isPlaying: Bool) {
4690
+ Bridge.log("LIVE: 🎵 Phone audio state changed: \(isPlaying ? "PLAYING" : "STOPPED")")
4691
+
4692
+ if isPlaying {
4693
+ // Phone started playing audio - suspend mic if it was running
4694
+ if micIntentEnabled && !micSuspendedForAudio {
4695
+ Bridge.log("LIVE: 🎤 Phone audio started - suspending LC3 mic to avoid MCU overload")
4696
+ stopMicBeat()
4697
+ micSuspendedForAudio = true
4698
+ }
4699
+ } else {
4700
+ // Phone stopped playing audio - resume mic if it was suspended
4701
+ if micIntentEnabled && micSuspendedForAudio {
4702
+ Bridge.log("LIVE: 🎤 Phone audio stopped - resuming LC3 mic")
4703
+ micSuspendedForAudio = false
4704
+ startMicBeat()
4705
+ }
4706
+ }
4707
+ }
4708
+ }