@glassly/bluetooth-sdk 0.1.0-dev.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (548) hide show
  1. package/README.md +563 -0
  2. package/android/build.gradle +450 -0
  3. package/android/gradle.properties.example +9 -0
  4. package/android/lc3Lib/CMakeLists.txt +6 -0
  5. package/android/lc3Lib/build.gradle +212 -0
  6. package/android/lc3Lib/proguard-rules.pro +33 -0
  7. package/android/lc3Lib/src/main/AndroidManifest.xml +2 -0
  8. package/android/lc3Lib/src/main/cpp/CMakeLists.txt +8 -0
  9. package/android/lc3Lib/src/main/cpp/google_opus_stuff/CMakeLists.txt +30 -0
  10. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/CMakeLists.txt +7 -0
  11. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/ogg_opus_encoder.cc +96 -0
  12. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/ogg_opus_encoder.h +53 -0
  13. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/Makefile.am +6 -0
  14. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/config_types.h +26 -0
  15. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/config_types.h.in +26 -0
  16. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/ogg.h +209 -0
  17. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/os_types.h +158 -0
  18. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus.h +981 -0
  19. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_custom.h +342 -0
  20. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_defines.h +799 -0
  21. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_multistream.h +660 -0
  22. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_projection.h +568 -0
  23. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_types.h +166 -0
  24. package/android/lc3Lib/src/main/cpp/google_opus_stuff/ogg_opus_encoder.cc +268 -0
  25. package/android/lc3Lib/src/main/cpp/google_opus_stuff/ogg_opus_encoder.h +115 -0
  26. package/android/lc3Lib/src/main/cpp/google_opus_stuff/opus_tools/opus_header.h +59 -0
  27. package/android/lc3Lib/src/main/cpp/liblc3/CMakeLists.txt +38 -0
  28. package/android/lc3Lib/src/main/cpp/liblc3/include/lc3.h +309 -0
  29. package/android/lc3Lib/src/main/cpp/liblc3/include/lc3_private.h +162 -0
  30. package/android/lc3Lib/src/main/cpp/liblc3/include/rnnoise.h +114 -0
  31. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/CMakeLists.txt +19 -0
  32. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/attdet.c +92 -0
  33. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/attdet.h +44 -0
  34. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bits.c +375 -0
  35. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bits.h +315 -0
  36. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bwdet.c +129 -0
  37. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bwdet.h +69 -0
  38. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/common.h +148 -0
  39. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/energy.c +70 -0
  40. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/energy.h +43 -0
  41. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/fastmath.h +158 -0
  42. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/lc3.c +702 -0
  43. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf.c +893 -0
  44. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf.h +111 -0
  45. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf_arm.h +506 -0
  46. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf_neon.h +281 -0
  47. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/makefile.mk +35 -0
  48. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct.c +452 -0
  49. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct.h +57 -0
  50. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct_neon.h +296 -0
  51. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/meson.build +46 -0
  52. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/plc.c +61 -0
  53. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/plc.h +57 -0
  54. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/sns.c +880 -0
  55. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/sns.h +103 -0
  56. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/spec.c +904 -0
  57. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/spec.h +119 -0
  58. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tables.c +3457 -0
  59. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tables.h +94 -0
  60. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tns.c +457 -0
  61. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tns.h +99 -0
  62. package/android/lc3Lib/src/main/cpp/liblc3/liblc3.cpp +159 -0
  63. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/_kiss_fft_guts.h +182 -0
  64. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/arch.h +261 -0
  65. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/celt_lpc.c +279 -0
  66. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/celt_lpc.h +59 -0
  67. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/common.h +48 -0
  68. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/compile.sh +3 -0
  69. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/denoise.c +646 -0
  70. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/kiss_fft.c +601 -0
  71. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/kiss_fft.h +203 -0
  72. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/opus_types.h +159 -0
  73. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/pitch.c +526 -0
  74. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/pitch.h +149 -0
  75. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn.c +178 -0
  76. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn.h +69 -0
  77. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_data.c +11051 -0
  78. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_data.h +34 -0
  79. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_reader.c +168 -0
  80. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_train.py +66 -0
  81. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/tansig_table.h +45 -0
  82. package/android/lc3Lib/src/main/java/com/glassly/lc3Lib/Lc3Cpp.java +38 -0
  83. package/android/lc3Lib/third_party/CMakeLists.txt +23 -0
  84. package/android/lc3Lib/third_party/CMakeLists_libogg.txt +18 -0
  85. package/android/lc3Lib/third_party/CMakeLists_libopus.txt +18 -0
  86. package/android/lc3Lib/third_party/CMakeLists_opus-tools.txt +23 -0
  87. package/android/lc3Lib/third_party/opus_tools/src/AUTHORS +5 -0
  88. package/android/lc3Lib/third_party/opus_tools/src/CMakeLists.txt +4 -0
  89. package/android/lc3Lib/third_party/opus_tools/src/COPYING +371 -0
  90. package/android/lc3Lib/third_party/opus_tools/src/ChangeLog +3 -0
  91. package/android/lc3Lib/third_party/opus_tools/src/Makefile.am +42 -0
  92. package/android/lc3Lib/third_party/opus_tools/src/Makefile.unix +23 -0
  93. package/android/lc3Lib/third_party/opus_tools/src/NEWS +0 -0
  94. package/android/lc3Lib/third_party/opus_tools/src/README +0 -0
  95. package/android/lc3Lib/third_party/opus_tools/src/autogen.sh +115 -0
  96. package/android/lc3Lib/third_party/opus_tools/src/configure.ac +293 -0
  97. package/android/lc3Lib/third_party/opus_tools/src/src/arch.h +239 -0
  98. package/android/lc3Lib/third_party/opus_tools/src/src/audio-in.c +1046 -0
  99. package/android/lc3Lib/third_party/opus_tools/src/src/diag_range.c +245 -0
  100. package/android/lc3Lib/third_party/opus_tools/src/src/diag_range.h +28 -0
  101. package/android/lc3Lib/third_party/opus_tools/src/src/info_opus.c +320 -0
  102. package/android/lc3Lib/third_party/opus_tools/src/src/info_opus.h +51 -0
  103. package/android/lc3Lib/third_party/opus_tools/src/src/lpc.c +157 -0
  104. package/android/lc3Lib/third_party/opus_tools/src/src/lpc.h +27 -0
  105. package/android/lc3Lib/third_party/opus_tools/src/src/opus_header.c +286 -0
  106. package/android/lc3Lib/third_party/opus_tools/src/src/opus_header.h +59 -0
  107. package/android/lc3Lib/third_party/opus_tools/src/src/opusdec.c +884 -0
  108. package/android/lc3Lib/third_party/opus_tools/src/src/opusenc.c +1021 -0
  109. package/android/lc3Lib/third_party/opus_tools/src/src/opusenc.h +101 -0
  110. package/android/lc3Lib/third_party/opus_tools/src/src/opusinfo.c +639 -0
  111. package/android/lc3Lib/third_party/opus_tools/src/src/opusinfo.h +51 -0
  112. package/android/lc3Lib/third_party/opus_tools/src/src/os_support.h +167 -0
  113. package/android/lc3Lib/third_party/opus_tools/src/src/resample.c +1137 -0
  114. package/android/lc3Lib/third_party/opus_tools/src/src/speex_resampler.h +344 -0
  115. package/android/lc3Lib/third_party/opus_tools/src/src/stack_alloc.h +115 -0
  116. package/android/lc3Lib/third_party/opus_tools/src/src/wav_io.c +125 -0
  117. package/android/lc3Lib/third_party/opus_tools/src/src/wav_io.h +62 -0
  118. package/android/lc3Lib/third_party/opus_tools/src/src/wave_out.c +223 -0
  119. package/android/lc3Lib/third_party/opus_tools/src/src/wave_out.h +60 -0
  120. package/android/settings.gradle +4 -0
  121. package/android/silero/LICENSE.md +17 -0
  122. package/android/silero/build.gradle +50 -0
  123. package/android/silero/proguard-rules.pro +24 -0
  124. package/android/silero/src/androidTest/assets/hello.wav +0 -0
  125. package/android/silero/src/androidTest/java/com/konovalov/vad/silero/VadSileroTest.kt +79 -0
  126. package/android/silero/src/main/assets/LICENSE +21 -0
  127. package/android/silero/src/main/assets/silero_vad.onnx +0 -0
  128. package/android/silero/src/main/java/com/konovalov/vad/silero/Vad.kt +160 -0
  129. package/android/silero/src/main/java/com/konovalov/vad/silero/VadSilero.kt +449 -0
  130. package/android/silero/src/main/java/com/konovalov/vad/silero/config/FrameSize.kt +15 -0
  131. package/android/silero/src/main/java/com/konovalov/vad/silero/config/Mode.kt +14 -0
  132. package/android/silero/src/main/java/com/konovalov/vad/silero/config/SampleRate.kt +12 -0
  133. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/AudioUtils.kt +47 -0
  134. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/TensorMap.kt +41 -0
  135. package/android/silero/src/test/java/com/konovalov/vad/silero/utils/AudioUtilsTest.kt +44 -0
  136. package/android/src/main/AndroidManifest.xml +42 -0
  137. package/android/src/main/assets/G2Firmware/README.md +11 -0
  138. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +2325 -0
  139. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkAnalytics.kt +216 -0
  140. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkDebug.kt +14 -0
  141. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkDefaults.kt +7 -0
  142. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +1088 -0
  143. package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +953 -0
  144. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +2528 -0
  145. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +360 -0
  146. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +8 -0
  147. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
  148. package/android/src/main/java/com/glassly/bluetoothsdk/LocalNetworkModule.kt +414 -0
  149. package/android/src/main/java/com/glassly/bluetoothsdk/ObservableStore.kt +88 -0
  150. package/android/src/main/java/com/glassly/bluetoothsdk/OtaManifest.kt +191 -0
  151. package/android/src/main/java/com/glassly/bluetoothsdk/ReleaseChangelog.kt +61 -0
  152. package/android/src/main/java/com/glassly/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  153. package/android/src/main/java/com/glassly/bluetoothsdk/audio/AudioModels.kt +144 -0
  154. package/android/src/main/java/com/glassly/bluetoothsdk/camera/CameraModels.kt +486 -0
  155. package/android/src/main/java/com/glassly/bluetoothsdk/connection/ConnectionModels.kt +78 -0
  156. package/android/src/main/java/com/glassly/bluetoothsdk/controllers/ControllerManager.kt +158 -0
  157. package/android/src/main/java/com/glassly/bluetoothsdk/controllers/R1.kt +937 -0
  158. package/android/src/main/java/com/glassly/bluetoothsdk/debug/BleTraceLogger.kt +165 -0
  159. package/android/src/main/java/com/glassly/bluetoothsdk/events/BluetoothEvents.kt +178 -0
  160. package/android/src/main/java/com/glassly/bluetoothsdk/internal/MapParsing.kt +140 -0
  161. package/android/src/main/java/com/glassly/bluetoothsdk/net/LocalIpv4.kt +57 -0
  162. package/android/src/main/java/com/glassly/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  163. package/android/src/main/java/com/glassly/bluetoothsdk/otaserver/OtaServerModule.kt +120 -0
  164. package/android/src/main/java/com/glassly/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistry.java +108 -0
  165. package/android/src/main/java/com/glassly/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +622 -0
  166. package/android/src/main/java/com/glassly/bluetoothsdk/photoreceiver/PhotoReceiverModule.kt +156 -0
  167. package/android/src/main/java/com/glassly/bluetoothsdk/requests/DisplayRequests.kt +56 -0
  168. package/android/src/main/java/com/glassly/bluetoothsdk/services/Foreground.kt +272 -0
  169. package/android/src/main/java/com/glassly/bluetoothsdk/services/PhoneMic.kt +1195 -0
  170. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/Ar99.java +2653 -0
  171. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/BesOtaHeartbeatGuard.kt +49 -0
  172. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
  173. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G1.kt +3878 -0
  174. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  175. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +6428 -0
  176. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +49 -0
  177. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +1241 -0
  178. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +270 -0
  179. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +291 -0
  180. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +411 -0
  181. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/GlasslyBle.java +43189 -0
  182. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/Mach1.kt +1280 -0
  183. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +10817 -0
  184. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  185. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  186. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +2011 -0
  187. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/Nimo.kt +2532 -0
  188. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +470 -0
  189. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/Simulated.kt +228 -0
  190. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
  191. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
  192. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
  193. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
  194. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
  195. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
  196. package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +525 -0
  197. package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +669 -0
  198. package/android/src/main/java/com/glassly/bluetoothsdk/status/RuntimeState.kt +201 -0
  199. package/android/src/main/java/com/glassly/bluetoothsdk/status/WifiHotspotStatus.kt +189 -0
  200. package/android/src/main/java/com/glassly/bluetoothsdk/streaming/StreamModels.kt +518 -0
  201. package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +391 -0
  202. package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +518 -0
  203. package/android/src/main/java/com/glassly/bluetoothsdk/stt/VadGateSpeechPolicy.kt +229 -0
  204. package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +248 -0
  205. package/android/src/main/java/com/glassly/bluetoothsdk/types/DeviceModels.kt +96 -0
  206. package/android/src/main/java/com/glassly/bluetoothsdk/utils/AES.java +106 -0
  207. package/android/src/main/java/com/glassly/bluetoothsdk/utils/AudioSessionMonitor.kt +189 -0
  208. package/android/src/main/java/com/glassly/bluetoothsdk/utils/AvifExifStripper.java +573 -0
  209. package/android/src/main/java/com/glassly/bluetoothsdk/utils/BitmapJavaUtils.java +169 -0
  210. package/android/src/main/java/com/glassly/bluetoothsdk/utils/BleJsonCompact.java +496 -0
  211. package/android/src/main/java/com/glassly/bluetoothsdk/utils/BlePhotoUploadService.java +677 -0
  212. package/android/src/main/java/com/glassly/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  213. package/android/src/main/java/com/glassly/bluetoothsdk/utils/Constants.kt +46 -0
  214. package/android/src/main/java/com/glassly/bluetoothsdk/utils/G1Text.kt +436 -0
  215. package/android/src/main/java/com/glassly/bluetoothsdk/utils/HeifExifTagReader.java +199 -0
  216. package/android/src/main/java/com/glassly/bluetoothsdk/utils/IncidentLogBleRelayNaming.java +29 -0
  217. package/android/src/main/java/com/glassly/bluetoothsdk/utils/IncidentLogBleUploadService.java +77 -0
  218. package/android/src/main/java/com/glassly/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  219. package/android/src/main/java/com/glassly/bluetoothsdk/utils/K900ProtocolUtils.java +827 -0
  220. package/android/src/main/java/com/glassly/bluetoothsdk/utils/MessageChunkReassembler.java +228 -0
  221. package/android/src/main/java/com/glassly/bluetoothsdk/utils/MessageChunker.java +325 -0
  222. package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +698 -0
  223. package/android/src/main/java/com/glassly/bluetoothsdk/utils/PhoneAudioMonitor.kt +348 -0
  224. package/android/src/main/java/com/glassly/bluetoothsdk/utils/SmartGlassesConnectionState.java +9 -0
  225. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
  226. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/ByteUtilAudioPlayer.java +655 -0
  227. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/Lc3Player.java +441 -0
  228. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/PCMAudioPlayer.java +431 -0
  229. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  230. package/android/src/main/res/values/strings.xml +4 -0
  231. package/android/src/test/java/com/glassly/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  232. package/android/src/test/java/com/glassly/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
  233. package/android/src/test/java/com/glassly/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  234. package/android/src/test/java/com/glassly/bluetoothsdk/ReleaseChangelogTest.kt +13 -0
  235. package/android/src/test/java/com/glassly/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  236. package/android/src/test/java/com/glassly/bluetoothsdk/StreamStatusEventTest.kt +29 -0
  237. package/android/src/test/java/com/glassly/bluetoothsdk/camera/PhotoRequestTest.kt +141 -0
  238. package/android/src/test/java/com/glassly/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  239. package/android/src/test/java/com/glassly/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistryTest.java +75 -0
  240. package/android/src/test/java/com/glassly/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  241. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
  242. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
  243. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
  244. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/EvenRleTest.kt +63 -0
  245. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  246. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +89 -0
  247. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +344 -0
  248. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +200 -0
  249. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocolTest.kt +131 -0
  250. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  251. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +133 -0
  252. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
  253. package/android/src/test/java/com/glassly/bluetoothsdk/utils/AvifExifStripperTest.java +46 -0
  254. package/android/src/test/java/com/glassly/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  255. package/android/src/test/java/com/glassly/bluetoothsdk/utils/BleJsonCompactTest.java +319 -0
  256. package/android/src/test/java/com/glassly/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +107 -0
  257. package/android/src/test/java/com/glassly/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  258. package/android/src/test/java/com/glassly/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  259. package/android/src/test/java/com/glassly/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
  260. package/android/src/test/resources/avif_with_exif.avif +0 -0
  261. package/app.plugin.js +3 -0
  262. package/build/BluetoothSdk.types.d.ts +1354 -0
  263. package/build/BluetoothSdk.types.d.ts.map +1 -0
  264. package/build/BluetoothSdk.types.js +41 -0
  265. package/build/BluetoothSdk.types.js.map +1 -0
  266. package/build/_internal.d.ts +17 -0
  267. package/build/_internal.d.ts.map +1 -0
  268. package/build/_internal.js +16 -0
  269. package/build/_internal.js.map +1 -0
  270. package/build/_private/BluetoothSdkModule.d.ts +172 -0
  271. package/build/_private/BluetoothSdkModule.d.ts.map +1 -0
  272. package/build/_private/BluetoothSdkModule.js +300 -0
  273. package/build/_private/BluetoothSdkModule.js.map +1 -0
  274. package/build/_private/LocalNetworkModule.d.ts +38 -0
  275. package/build/_private/LocalNetworkModule.d.ts.map +1 -0
  276. package/build/_private/LocalNetworkModule.js +3 -0
  277. package/build/_private/LocalNetworkModule.js.map +1 -0
  278. package/build/_private/cameraRequestPayload.d.ts +4 -0
  279. package/build/_private/cameraRequestPayload.d.ts.map +1 -0
  280. package/build/_private/cameraRequestPayload.js +32 -0
  281. package/build/_private/cameraRequestPayload.js.map +1 -0
  282. package/build/_private/photoRequestPayload.d.ts +6 -0
  283. package/build/_private/photoRequestPayload.d.ts.map +1 -0
  284. package/build/_private/photoRequestPayload.js +86 -0
  285. package/build/_private/photoRequestPayload.js.map +1 -0
  286. package/build/_private/sdkOtaManifest.d.ts +11 -0
  287. package/build/_private/sdkOtaManifest.d.ts.map +1 -0
  288. package/build/_private/sdkOtaManifest.js +15 -0
  289. package/build/_private/sdkOtaManifest.js.map +1 -0
  290. package/build/changelogs.d.ts +7 -0
  291. package/build/changelogs.d.ts.map +1 -0
  292. package/build/changelogs.js +46 -0
  293. package/build/changelogs.js.map +1 -0
  294. package/build/debug.d.ts +3 -0
  295. package/build/debug.d.ts.map +1 -0
  296. package/build/debug.js +8 -0
  297. package/build/debug.js.map +1 -0
  298. package/build/generated/changelogCatalog.d.ts +9 -0
  299. package/build/generated/changelogCatalog.d.ts.map +1 -0
  300. package/build/generated/changelogCatalog.js +12 -0
  301. package/build/generated/changelogCatalog.js.map +1 -0
  302. package/build/generated/releaseMetadata.d.ts +12 -0
  303. package/build/generated/releaseMetadata.d.ts.map +1 -0
  304. package/build/generated/releaseMetadata.js +10 -0
  305. package/build/generated/releaseMetadata.js.map +1 -0
  306. package/build/index.d.ts +6 -0
  307. package/build/index.d.ts.map +1 -0
  308. package/build/index.js +171 -0
  309. package/build/index.js.map +1 -0
  310. package/build/ota-server/OtaServer.types.d.ts +14 -0
  311. package/build/ota-server/OtaServer.types.d.ts.map +1 -0
  312. package/build/ota-server/OtaServer.types.js +2 -0
  313. package/build/ota-server/OtaServer.types.js.map +1 -0
  314. package/build/ota-server/OtaServerModule.d.ts +21 -0
  315. package/build/ota-server/OtaServerModule.d.ts.map +1 -0
  316. package/build/ota-server/OtaServerModule.js +3 -0
  317. package/build/ota-server/OtaServerModule.js.map +1 -0
  318. package/build/ota-server/OtaServerModule.web.d.ts +10 -0
  319. package/build/ota-server/OtaServerModule.web.d.ts.map +1 -0
  320. package/build/ota-server/OtaServerModule.web.js +12 -0
  321. package/build/ota-server/OtaServerModule.web.js.map +1 -0
  322. package/build/ota-server/index.d.ts +3 -0
  323. package/build/ota-server/index.d.ts.map +1 -0
  324. package/build/ota-server/index.js +3 -0
  325. package/build/ota-server/index.js.map +1 -0
  326. package/build/ota-transport/index.d.ts +67 -0
  327. package/build/ota-transport/index.d.ts.map +1 -0
  328. package/build/ota-transport/index.js +58 -0
  329. package/build/ota-transport/index.js.map +1 -0
  330. package/build/photo-receiver/PhotoReceiver.types.d.ts +18 -0
  331. package/build/photo-receiver/PhotoReceiver.types.d.ts.map +1 -0
  332. package/build/photo-receiver/PhotoReceiver.types.js +2 -0
  333. package/build/photo-receiver/PhotoReceiver.types.js.map +1 -0
  334. package/build/photo-receiver/PhotoReceiverModule.d.ts +10 -0
  335. package/build/photo-receiver/PhotoReceiverModule.d.ts.map +1 -0
  336. package/build/photo-receiver/PhotoReceiverModule.js +3 -0
  337. package/build/photo-receiver/PhotoReceiverModule.js.map +1 -0
  338. package/build/photo-receiver/PhotoReceiverModule.web.d.ts +10 -0
  339. package/build/photo-receiver/PhotoReceiverModule.web.d.ts.map +1 -0
  340. package/build/photo-receiver/PhotoReceiverModule.web.js +12 -0
  341. package/build/photo-receiver/PhotoReceiverModule.web.js.map +1 -0
  342. package/build/photo-receiver/index.d.ts +3 -0
  343. package/build/photo-receiver/index.d.ts.map +1 -0
  344. package/build/photo-receiver/index.js +3 -0
  345. package/build/photo-receiver/index.js.map +1 -0
  346. package/build/react/index.d.ts +5 -0
  347. package/build/react/index.d.ts.map +1 -0
  348. package/build/react/index.js +4 -0
  349. package/build/react/index.js.map +1 -0
  350. package/build/react/useBluetooth.d.ts +100 -0
  351. package/build/react/useBluetooth.d.ts.map +1 -0
  352. package/build/react/useBluetooth.js +167 -0
  353. package/build/react/useBluetooth.js.map +1 -0
  354. package/build/react/useBluetoothEvent.d.ts +6 -0
  355. package/build/react/useBluetoothEvent.d.ts.map +1 -0
  356. package/build/react/useBluetoothEvent.js +21 -0
  357. package/build/react/useBluetoothEvent.js.map +1 -0
  358. package/build/react/useBluetoothScan.d.ts +22 -0
  359. package/build/react/useBluetoothScan.d.ts.map +1 -0
  360. package/build/react/useBluetoothScan.js +135 -0
  361. package/build/react/useBluetoothScan.js.map +1 -0
  362. package/build/react/useBluetoothStatus.d.ts +16 -0
  363. package/build/react/useBluetoothStatus.d.ts.map +1 -0
  364. package/build/react/useBluetoothStatus.js +137 -0
  365. package/build/react/useBluetoothStatus.js.map +1 -0
  366. package/build/react/useGlassesConnection.d.ts +29 -0
  367. package/build/react/useGlassesConnection.d.ts.map +1 -0
  368. package/build/react/useGlassesConnection.js +153 -0
  369. package/build/react/useGlassesConnection.js.map +1 -0
  370. package/build/types/index.d.ts +3 -0
  371. package/build/types/index.d.ts.map +1 -0
  372. package/build/types/index.js +2 -0
  373. package/build/types/index.js.map +1 -0
  374. package/expo-module.config.json +14 -0
  375. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  376. package/ios/BluetoothSdkModule.swift +1034 -0
  377. package/ios/GlasslyBluetoothSDK.podspec +106 -0
  378. package/ios/LocalIPv4.swift +150 -0
  379. package/ios/LocalOtaServer.swift +309 -0
  380. package/ios/LocalPhotoUploadServer.swift +754 -0
  381. package/ios/OtaServerModule.swift +147 -0
  382. package/ios/Packages/CoreObjC/CoreObjC.xcodeproj/project.pbxproj +213 -0
  383. package/ios/Packages/CoreObjC/PcmConverter.h +22 -0
  384. package/ios/Packages/CoreObjC/PcmConverter.m +266 -0
  385. package/ios/Packages/CoreObjC/attdet.c +92 -0
  386. package/ios/Packages/CoreObjC/attdet.h +44 -0
  387. package/ios/Packages/CoreObjC/bits.c +375 -0
  388. package/ios/Packages/CoreObjC/bits.h +315 -0
  389. package/ios/Packages/CoreObjC/bwdet.c +129 -0
  390. package/ios/Packages/CoreObjC/bwdet.h +69 -0
  391. package/ios/Packages/CoreObjC/common.h +151 -0
  392. package/ios/Packages/CoreObjC/energy.c +70 -0
  393. package/ios/Packages/CoreObjC/energy.h +43 -0
  394. package/ios/Packages/CoreObjC/fastmath.h +158 -0
  395. package/ios/Packages/CoreObjC/include/PcmConverter.h +22 -0
  396. package/ios/Packages/CoreObjC/lc3.c +704 -0
  397. package/ios/Packages/CoreObjC/lc3.h +313 -0
  398. package/ios/Packages/CoreObjC/lc3_cpp.h +283 -0
  399. package/ios/Packages/CoreObjC/lc3_private.h +163 -0
  400. package/ios/Packages/CoreObjC/ltpf.c +905 -0
  401. package/ios/Packages/CoreObjC/ltpf.h +111 -0
  402. package/ios/Packages/CoreObjC/ltpf_arm.h +506 -0
  403. package/ios/Packages/CoreObjC/ltpf_neon.h +281 -0
  404. package/ios/Packages/CoreObjC/makefile.mk +35 -0
  405. package/ios/Packages/CoreObjC/mdct.c +469 -0
  406. package/ios/Packages/CoreObjC/mdct.h +57 -0
  407. package/ios/Packages/CoreObjC/mdct_neon.h +296 -0
  408. package/ios/Packages/CoreObjC/meson.build +61 -0
  409. package/ios/Packages/CoreObjC/plc.c +61 -0
  410. package/ios/Packages/CoreObjC/plc.h +57 -0
  411. package/ios/Packages/CoreObjC/rnnoise.h +114 -0
  412. package/ios/Packages/CoreObjC/sns.c +880 -0
  413. package/ios/Packages/CoreObjC/sns.h +103 -0
  414. package/ios/Packages/CoreObjC/spec.c +907 -0
  415. package/ios/Packages/CoreObjC/spec.h +119 -0
  416. package/ios/Packages/CoreObjC/tables.c +3457 -0
  417. package/ios/Packages/CoreObjC/tables.h +94 -0
  418. package/ios/Packages/CoreObjC/tns.c +457 -0
  419. package/ios/Packages/CoreObjC/tns.h +99 -0
  420. package/ios/Packages/SherpaOnnx/Model/joiner.onnx +0 -0
  421. package/ios/Packages/SherpaOnnx/Model/tokens.txt +502 -0
  422. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +2383 -0
  423. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +48 -0
  424. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/c-api.h +4689 -0
  425. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/cxx-api.h +2036 -0
  426. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
  427. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/c-api.h +4689 -0
  428. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/cxx-api.h +2036 -0
  429. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
  430. package/ios/Packages/libbz2/module.modulemap +5 -0
  431. package/ios/Packages/libbz2/shim.h +1 -0
  432. package/ios/PhotoReceiverModule.swift +129 -0
  433. package/ios/Source/BluetoothSDK.swift +2303 -0
  434. package/ios/Source/BluetoothSDKDebug.swift +12 -0
  435. package/ios/Source/BluetoothSdkDefaults.swift +29 -0
  436. package/ios/Source/Bridge.swift +708 -0
  437. package/ios/Source/Bridging-Header.h +16 -0
  438. package/ios/Source/DeviceManager.swift +2109 -0
  439. package/ios/Source/DeviceStore.swift +336 -0
  440. package/ios/Source/GeneratedChangelogCatalog.swift +7 -0
  441. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  442. package/ios/Source/ObservableStore.swift +118 -0
  443. package/ios/Source/OtaManifest.swift +211 -0
  444. package/ios/Source/PrivacyInfo.xcprivacy +23 -0
  445. package/ios/Source/ReleaseChangelog.swift +65 -0
  446. package/ios/Source/Resources/G2Firmware/README.md +10 -0
  447. package/ios/Source/audio/AudioModels.swift +178 -0
  448. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  449. package/ios/Source/camera/CameraModels.swift +866 -0
  450. package/ios/Source/connection/ScanSession.swift +27 -0
  451. package/ios/Source/controllers/ControllerManager.swift +184 -0
  452. package/ios/Source/controllers/R1.swift +829 -0
  453. package/ios/Source/errors/BluetoothSdkError.swift +19 -0
  454. package/ios/Source/events/BluetoothEvents.swift +342 -0
  455. package/ios/Source/internal/BleTraceLogger.swift +192 -0
  456. package/ios/Source/internal/BluetoothAvailability.swift +78 -0
  457. package/ios/Source/internal/BluetoothSdkAnalytics.swift +199 -0
  458. package/ios/Source/internal/ValueParsing.swift +99 -0
  459. package/ios/Source/requests/DisplayRequests.swift +82 -0
  460. package/ios/Source/services/PhoneMic.swift +662 -0
  461. package/ios/Source/sgcs/Ar99.swift +2451 -0
  462. package/ios/Source/sgcs/Frame.swift +649 -0
  463. package/ios/Source/sgcs/G1.swift +2554 -0
  464. package/ios/Source/sgcs/G2.swift +6887 -0
  465. package/ios/Source/sgcs/G2CfwClassification.swift +43 -0
  466. package/ios/Source/sgcs/G2CfwRenderer.swift +1279 -0
  467. package/ios/Source/sgcs/G2CfwTextureCache.swift +233 -0
  468. package/ios/Source/sgcs/G2FlashProtocol.swift +312 -0
  469. package/ios/Source/sgcs/G2Flasher.swift +773 -0
  470. package/ios/Source/sgcs/Mach1.swift +541 -0
  471. package/ios/Source/sgcs/MentraLive.swift +7011 -0
  472. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  473. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  474. package/ios/Source/sgcs/MentraNex.swift +3221 -0
  475. package/ios/Source/sgcs/Nimo.swift +1886 -0
  476. package/ios/Source/sgcs/SGCManager.swift +608 -0
  477. package/ios/Source/sgcs/Simulated.swift +274 -0
  478. package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
  479. package/ios/Source/sgcs/glassly_ble.pb.swift +4354 -0
  480. package/ios/Source/speakerid/SpeakerIdEngine.swift +573 -0
  481. package/ios/Source/status/DeviceStatus.swift +981 -0
  482. package/ios/Source/status/RuntimeState.swift +359 -0
  483. package/ios/Source/status/WifiHotspotStatus.swift +345 -0
  484. package/ios/Source/streaming/StreamModels.swift +693 -0
  485. package/ios/Source/stt/STTTools.swift +191 -0
  486. package/ios/Source/stt/SherpaOnnxTranscriber.swift +466 -0
  487. package/ios/Source/tts/TTSTools.swift +171 -0
  488. package/ios/Source/types/DeviceModels.swift +177 -0
  489. package/ios/Source/utils/AudioSessionMonitor.swift +283 -0
  490. package/ios/Source/utils/BinaryCodec.swift +61 -0
  491. package/ios/Source/utils/BleJsonCompact.swift +405 -0
  492. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  493. package/ios/Source/utils/Constants.swift +78 -0
  494. package/ios/Source/utils/Enums.swift +95 -0
  495. package/ios/Source/utils/G1Text.swift +2096 -0
  496. package/ios/Source/utils/JSCExperiment.swift +241 -0
  497. package/ios/Source/utils/MemoryMonitor.swift +44 -0
  498. package/ios/Source/utils/MessageChunkReassembler.swift +190 -0
  499. package/ios/Source/utils/MessageChunker.swift +294 -0
  500. package/ios/Source/utils/Models.swift +135 -0
  501. package/ios/Source/utils/PhoneAudioMonitor.swift +230 -0
  502. package/ios/Source/utils/TarBz2Extractor.swift +257 -0
  503. package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +78 -0
  504. package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +372 -0
  505. package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +162 -0
  506. package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +182 -0
  507. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +160 -0
  508. package/ios/Tests/ReleaseChangelogTests.swift +13 -0
  509. package/package.json +129 -0
  510. package/plugin/build/index.d.ts +10 -0
  511. package/plugin/build/index.js +12 -0
  512. package/plugin/build/withAndroid.d.ts +3 -0
  513. package/plugin/build/withAndroid.js +190 -0
  514. package/plugin/build/withIos.d.ts +5 -0
  515. package/plugin/build/withIos.js +108 -0
  516. package/scripts/inject-ios-sdk-version.mjs +65 -0
  517. package/scripts/public-ota-api.test.mjs +37 -0
  518. package/scripts/verify-release-package.mjs +45 -0
  519. package/scripts/write-release-metadata.mjs +152 -0
  520. package/scripts/write-release-metadata.test.mjs +37 -0
  521. package/src/BluetoothSdk.types.ts +1623 -0
  522. package/src/_internal.ts +16 -0
  523. package/src/_private/BluetoothSdkModule.ts +684 -0
  524. package/src/_private/LocalNetworkModule.ts +50 -0
  525. package/src/_private/cameraRequestPayload.ts +34 -0
  526. package/src/_private/photoRequestPayload.ts +90 -0
  527. package/src/_private/sdkOtaManifest.ts +18 -0
  528. package/src/changelogs.ts +46 -0
  529. package/src/debug.ts +9 -0
  530. package/src/generated/changelogCatalog.ts +13 -0
  531. package/src/generated/releaseMetadata.ts +20 -0
  532. package/src/index.ts +321 -0
  533. package/src/ota-server/OtaServer.types.ts +10 -0
  534. package/src/ota-server/OtaServerModule.ts +23 -0
  535. package/src/ota-server/OtaServerModule.web.ts +17 -0
  536. package/src/ota-server/index.ts +2 -0
  537. package/src/ota-transport/index.ts +116 -0
  538. package/src/photo-receiver/PhotoReceiver.types.ts +20 -0
  539. package/src/photo-receiver/PhotoReceiverModule.ts +11 -0
  540. package/src/photo-receiver/PhotoReceiverModule.web.ts +17 -0
  541. package/src/photo-receiver/index.ts +2 -0
  542. package/src/react/index.ts +21 -0
  543. package/src/react/useBluetooth.ts +309 -0
  544. package/src/react/useBluetoothEvent.ts +35 -0
  545. package/src/react/useBluetoothScan.ts +173 -0
  546. package/src/react/useBluetoothStatus.ts +180 -0
  547. package/src/react/useGlassesConnection.ts +202 -0
  548. package/src/types/index.ts +12 -0
@@ -0,0 +1,3221 @@
1
+ //
2
+ // MentraNex.swift
3
+ //
4
+ // Created by Gemini on 2024-07-29.
5
+ //
6
+
7
+ import Combine
8
+ import CoreBluetooth
9
+ import Foundation
10
+ import SwiftProtobuf
11
+ import UIKit
12
+
13
+ /// Helper extension for debugging
14
+ extension Data {
15
+ func toHexString() -> String {
16
+ map { String(format: "%02x", $0) }.joined(separator: " ")
17
+ }
18
+ }
19
+
20
+ /// Nex firmware expects tier 1–4 in protobuf `DisplayDistanceConfig.distance_cm` (name is legacy, not cm).
21
+ /// Keep in sync with `NexProtobufUtils.dashboardDepthToDistanceCm` (Android `NexSGCUtils.kt`).
22
+ enum NexDashboardDisplayWire {
23
+ static let depthMin = 1
24
+ static let depthMax = 4
25
+
26
+ static func depthToWireTier(_ depth: Int) -> UInt32 {
27
+ UInt32(min(max(depth, depthMin), depthMax))
28
+ }
29
+
30
+ /// Read an `Any?` value from the store, default to `depthMin`, and clamp to valid range.
31
+ static func clampDepthFromStore(_ value: Any?) -> Int {
32
+ let raw = value as? Int ?? depthMin
33
+ return min(max(raw, depthMin), depthMax)
34
+ }
35
+ }
36
+
37
+ @MainActor
38
+ @objc(MentraNexSGC)
39
+ class MentraNexSGC: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate, SGCManager {
40
+ func sendIncidentId(_: String, apiBaseUrl _: String?) {}
41
+
42
+ func sendJson(_: [String: Any], wakeUp _: Bool, requireAck _: Bool) {}
43
+
44
+ func setMicEnabled(_ enabled: Bool) {
45
+ shouldUseGlassesMic = enabled
46
+ if enabled {
47
+ startMicBeat()
48
+ } else {
49
+ stopMicBeat()
50
+ }
51
+ }
52
+
53
+ func requestPhoto(_: PhotoRequest) {}
54
+
55
+ func startStream(_: [String: Any]) {}
56
+
57
+ func stopStream() {}
58
+
59
+ func sendStreamKeepAlive(_: [String: Any]) {}
60
+
61
+ func startVideoRecording(requestId _: String, save _: Bool, sound _: Bool) {}
62
+
63
+ func stopVideoRecording(requestId _: String) {}
64
+
65
+ func sendButtonPhotoSettings() {}
66
+
67
+ func sendButtonVideoRecordingSettings() {}
68
+
69
+ func sendButtonMaxRecordingTime() {}
70
+
71
+ func sendCameraFovSetting() {}
72
+
73
+ func setBrightness(_ level: Int, autoMode: Bool) {
74
+ updateGlassesBrightness(level)
75
+ updateGlassesAutoBrightness(autoMode)
76
+ }
77
+
78
+ func sendText(_ text: String) async {
79
+ await sendTextWall(text)
80
+ }
81
+
82
+ func sendDoubleTextWall(_ top: String, _ bottom: String) async {
83
+ await sendTextWall("\(top)\n\(bottom)")
84
+ }
85
+
86
+ func displayBitmap(base64ImageData: String, x _: Int32? = nil, y _: Int32? = nil, width: Int32? = nil, height: Int32? = nil) async -> Bool {
87
+ guard let imageData = Data(base64Encoded: base64ImageData),
88
+ let image = UIImage(data: imageData)
89
+ else {
90
+ Bridge.log("NEX: Failed to decode base64 image payload")
91
+ return false
92
+ }
93
+ // Same glasses-native pipeline as the canvas path: scale to the target
94
+ // size when given, invert + dither, encode a real 1-bit BMP (the old
95
+ // conversion emitted raw RGBA the firmware couldn't decode).
96
+ // Legacy callers can hand us zero/negative dims — clamp to the canvas
97
+ // so the scale/encode path can't divide by zero or allocate garbage.
98
+ let rawWidth = width ?? Int32(image.cgImage?.width ?? Int(image.size.width * image.scale))
99
+ let rawHeight = height ?? Int32(image.cgImage?.height ?? Int(image.size.height * image.scale))
100
+ let pixelWidth = min(max(rawWidth, 1), 500)
101
+ let pixelHeight = min(max(rawHeight, 1), 220)
102
+ let scaled = scaledImage(image, toWidth: pixelWidth, height: pixelHeight)
103
+ guard let bmpData = convertImageToNex1BitBmp(scaled) else {
104
+ Bridge.log("NEX: Failed to convert UIImage to 1-bit BMP")
105
+ return false
106
+ }
107
+ displayBitmapData(bmpData, width: Int(pixelWidth), height: Int(pixelHeight))
108
+ return true
109
+ }
110
+
111
+ // MARK: - Canvas scene verbs (display.render() pipeline)
112
+
113
+ // Retained-mode canvas registry: elementId → firmware component. Mirrors
114
+ // MentraNex.kt. Text ids come from the firmware pool 1–6, bitmaps from
115
+ // 10–13 (mos_display_canvas_view.c); insertion order drives real oldest-out
116
+ // eviction when a pool is exhausted.
117
+ private struct CanvasElement {
118
+ let firmwareId: UInt32
119
+ let isBitmap: Bool
120
+ var rect: String
121
+ }
122
+
123
+ private var canvasElements: [(key: String, value: CanvasElement)] = []
124
+ private var currentLayoutId: String?
125
+ private let canvasTextIdPool: [UInt32] = [1, 2, 3, 4, 5, 6]
126
+ private let canvasBitmapIdPool: [UInt32] = [10, 11, 12, 13]
127
+
128
+ private func canvasElementIndex(_ key: String) -> Int? {
129
+ canvasElements.firstIndex(where: { $0.key == key })
130
+ }
131
+
132
+ private func sendCanvasCommand(_ configure: (inout Glassly_Ble_PhoneToGlasses) -> Void) {
133
+ var msg = Glassly_Ble_PhoneToGlasses()
134
+ configure(&msg)
135
+ guard let data = try? msg.serializedData() else { return }
136
+ queueDataWithOptimalChunking(data, packetType: PACKET_TYPE_PROTOBUF, waitTimeMs: 10)
137
+ }
138
+
139
+ /**
140
+ Handle a scene/layout change. DeviceManager sweeps the previous app's
141
+ elements before a cross-app frame arrives, so the registry is usually empty
142
+ here and switching costs nothing. Stale components are deleted
143
+ individually, NOT via CanvasClear — clear also exits the canvas VIEW (the
144
+ first create re-activates it), which reads as a full-screen flash.
145
+ */
146
+ private func ensureLayout(_ layoutId: String?) {
147
+ guard let layoutId, layoutId != currentLayoutId else { return }
148
+ if !canvasElements.isEmpty {
149
+ for (_, el) in canvasElements {
150
+ sendCanvasCommand { $0.canvasDeleteComponent = .with { $0.id = el.firmwareId } }
151
+ }
152
+ canvasElements.removeAll()
153
+ }
154
+ currentLayoutId = layoutId
155
+ }
156
+
157
+ /// Replay frames repaint from scratch: forget the registry so every element
158
+ /// takes the CREATE path (create-on-existing-id is replace in firmware;
159
+ /// updates to dead component ids are dropped silently).
160
+ func onSceneReplay(_ appId: String) async {
161
+ canvasElements.removeAll()
162
+ currentLayoutId = appId
163
+ }
164
+
165
+ /// Free firmware id from the type's pool, evicting the oldest same-type
166
+ /// element (delete on glasses + registry) when the pool is exhausted.
167
+ private func allocFirmwareId(isBitmap: Bool) -> UInt32? {
168
+ let pool = isBitmap ? canvasBitmapIdPool : canvasTextIdPool
169
+ let used = Set(canvasElements.filter { $0.value.isBitmap == isBitmap }.map(\.value.firmwareId))
170
+ if let free = pool.first(where: { !used.contains($0) }) {
171
+ return free
172
+ }
173
+ guard let oldestIdx = canvasElements.firstIndex(where: { $0.value.isBitmap == isBitmap }) else {
174
+ return nil
175
+ }
176
+ let oldest = canvasElements.remove(at: oldestIdx)
177
+ Bridge.log("NEX: pool full — evicting oldest \(isBitmap ? "bitmap" : "text") element '\(oldest.key)' (fw id \(oldest.value.firmwareId))")
178
+ sendCanvasCommand { $0.canvasDeleteComponent = .with { $0.id = oldest.value.firmwareId } }
179
+ return oldest.value.firmwareId
180
+ }
181
+
182
+ func drawLayoutText(
183
+ _ text: String, x: Int32, y: Int32, width: Int32, height: Int32,
184
+ borderWidth: Int32, borderRadius: Int32, elementId: String, layoutId: String?
185
+ ) async {
186
+ ensureLayout(layoutId)
187
+ let rect = "\(x),\(y),\(width)x\(height),\(borderWidth),\(borderRadius)"
188
+ if let i = canvasElementIndex(elementId), !canvasElements[i].value.isBitmap {
189
+ let fid = canvasElements[i].value.firmwareId
190
+ if canvasElements[i].value.rect != rect {
191
+ // Geometry moved/restyled — recreate the box at the SAME
192
+ // firmware id (create-on-existing-id = replace), then set text.
193
+ sendCanvasCommand {
194
+ $0.canvasCreateComponent = .with {
195
+ $0.id = fid
196
+ $0.type = .canvasTextbox
197
+ $0.x = UInt32(max(0, x)); $0.y = UInt32(max(0, y))
198
+ $0.width = UInt32(max(0, width)); $0.height = UInt32(max(0, height))
199
+ $0.borderWidth = UInt32(max(0, borderWidth))
200
+ $0.borderRadius = UInt32(max(0, borderRadius))
201
+ }
202
+ }
203
+ canvasElements[i].value.rect = rect
204
+ }
205
+ sendCanvasCommand { $0.canvasUpdateText = .with { $0.id = fid; $0.text = self.sanitizeDisplayText(text) } }
206
+ return
207
+ }
208
+ guard let fid = allocFirmwareId(isBitmap: false) else {
209
+ Bridge.log("NEX: text pool exhausted — dropping element '\(elementId)'")
210
+ return
211
+ }
212
+ canvasElements.append((key: elementId, value: CanvasElement(firmwareId: fid, isBitmap: false, rect: rect)))
213
+ sendCanvasCommand {
214
+ $0.canvasCreateComponent = .with {
215
+ $0.id = fid
216
+ $0.type = .canvasTextbox
217
+ $0.x = UInt32(max(0, x)); $0.y = UInt32(max(0, y))
218
+ $0.width = UInt32(max(0, width)); $0.height = UInt32(max(0, height))
219
+ $0.borderWidth = UInt32(max(0, borderWidth))
220
+ $0.borderRadius = UInt32(max(0, borderRadius))
221
+ }
222
+ }
223
+ sendCanvasCommand { $0.canvasUpdateText = .with { $0.id = fid; $0.text = self.sanitizeDisplayText(text) } }
224
+ }
225
+
226
+ func drawLayoutBitmap(
227
+ base64ImageData: String, x: Int32, y: Int32, width: Int32, height: Int32,
228
+ elementId: String, layoutId: String?
229
+ ) async -> Bool {
230
+ ensureLayout(layoutId)
231
+ guard let imageData = Data(base64Encoded: base64ImageData),
232
+ let image = UIImage(data: imageData)
233
+ else {
234
+ Bridge.log("NEX: drawLayoutBitmap failed to decode base64 image")
235
+ return false
236
+ }
237
+ // Scale phone-side to the component box (never on glasses), then run
238
+ // the glasses-native pipeline: invert → Floyd–Steinberg dither → real
239
+ // 1-bit BMP encode (Android parity; the old convertUIImageToBmpData
240
+ // emitted raw RGBA, which the firmware BMP decoder can't read).
241
+ let scaled = scaledImage(image, toWidth: width, height: height)
242
+ guard let bmpData = convertImageToNex1BitBmp(scaled) else {
243
+ Bridge.log("NEX: drawLayoutBitmap failed to convert image")
244
+ return false
245
+ }
246
+
247
+ let rect = "\(x),\(y),\(width)x\(height)"
248
+ let fid: UInt32
249
+ if let i = canvasElementIndex(elementId), canvasElements[i].value.isBitmap {
250
+ fid = canvasElements[i].value.firmwareId
251
+ if canvasElements[i].value.rect != rect {
252
+ sendCanvasCommand {
253
+ $0.canvasCreateComponent = .with {
254
+ $0.id = fid
255
+ $0.type = .canvasBitmap
256
+ $0.x = UInt32(max(0, x)); $0.y = UInt32(max(0, y))
257
+ $0.width = UInt32(max(0, width)); $0.height = UInt32(max(0, height))
258
+ }
259
+ }
260
+ canvasElements[i].value.rect = rect
261
+ }
262
+ } else {
263
+ guard let allocated = allocFirmwareId(isBitmap: true) else {
264
+ Bridge.log("NEX: bitmap pool exhausted — dropping element '\(elementId)'")
265
+ return false
266
+ }
267
+ fid = allocated
268
+ canvasElements.append((key: elementId, value: CanvasElement(firmwareId: fid, isBitmap: true, rect: rect)))
269
+ sendCanvasCommand {
270
+ $0.canvasCreateComponent = .with {
271
+ $0.id = fid
272
+ $0.type = .canvasBitmap
273
+ $0.x = UInt32(max(0, x)); $0.y = UInt32(max(0, y))
274
+ $0.width = UInt32(max(0, width)); $0.height = UInt32(max(0, height))
275
+ }
276
+ }
277
+ }
278
+
279
+ // Stream pixels into the component: CanvasUpdateImage header, then the
280
+ // same 0xB0 chunk transport the legacy DisplayImage path uses.
281
+ let streamId = String(format: "%04X", Int.random(in: 0 ... 0xFFFF))
282
+ let totalChunks = Int(ceil(Double(bmpData.count) / Double(bmpChunkSize)))
283
+ sendCanvasCommand {
284
+ $0.canvasUpdateImage = .with {
285
+ $0.id = fid
286
+ $0.streamID = streamId
287
+ $0.totalChunks = UInt32(totalChunks)
288
+ }
289
+ }
290
+ sendImageChunks(streamId: streamId, imageData: bmpData)
291
+ return true
292
+ }
293
+
294
+ func removeLayoutElement(_ elementId: String, layoutId _: String?) async {
295
+ guard let i = canvasElementIndex(elementId) else { return }
296
+ let el = canvasElements.remove(at: i)
297
+ sendCanvasCommand { $0.canvasDeleteComponent = .with { $0.id = el.value.firmwareId } }
298
+ }
299
+
300
+ private func scaledImage(_ image: UIImage, toWidth width: Int32, height: Int32) -> UIImage {
301
+ let size = CGSize(width: CGFloat(max(1, width)), height: CGFloat(max(1, height)))
302
+ if image.size == size { return image }
303
+ UIGraphicsBeginImageContextWithOptions(size, false, 1)
304
+ image.draw(in: CGRect(origin: .zero, size: size))
305
+ let out = UIGraphicsGetImageFromCurrentImageContext() ?? image
306
+ UIGraphicsEndImageContext()
307
+ return out
308
+ }
309
+
310
+ func showDashboard() {
311
+ exit()
312
+ }
313
+
314
+ func setDashboardPosition(_ height: Int, _ depth: Int) {
315
+ // Same order as Android MentraNex: display_height then display_distance.
316
+ updateGlassesDisplayHeight(height)
317
+ updateGlassesDisplayDistance(depth: depth)
318
+ }
319
+
320
+ func setDashboardHeightOnly(_ height: Int) {
321
+ updateGlassesDisplayHeight(height)
322
+ }
323
+
324
+ func setDashboardDepthOnly(_ depth: Int) {
325
+ updateGlassesDisplayDistance(depth: depth)
326
+ }
327
+
328
+ func setHeadUpAngle(_ angle: Int) {
329
+ updateGlassesHeadUpAngle(angle)
330
+ }
331
+
332
+ func getBatteryStatus() {
333
+ queryBatteryStatus()
334
+ }
335
+
336
+ func setSilentMode(_: Bool) {}
337
+
338
+ func exit() {
339
+ queueChunks([[0x18]], waitTimeMs: 100)
340
+ }
341
+
342
+ func sendShutdown() {}
343
+
344
+ func sendReboot() {}
345
+
346
+ func sendRgbLedControl(
347
+ requestId _: String, packageName _: String?, action _: String, color _: String?, onDurationMs _: Int,
348
+ offDurationMs _: Int, count _: Int
349
+ ) {}
350
+
351
+ func forget() {
352
+ destroy()
353
+ }
354
+
355
+ func connectById(_ id: String) {
356
+ savePreferredDeviceId(id)
357
+ connect(name: id)
358
+ }
359
+
360
+ func getConnectedBluetoothName() -> String? {
361
+ peripheral?.name
362
+ }
363
+
364
+ func cleanup() {
365
+ destroy()
366
+ }
367
+
368
+ func ping() {
369
+ Bridge.log("NEX: ping() is host-side no-op for this transport")
370
+ }
371
+
372
+ func connectController() {}
373
+ func disconnectController() {}
374
+
375
+ func dbg1() {}
376
+ func dbg2() {}
377
+
378
+ func requestWifiScan(scanId _: String?) {}
379
+
380
+ func sendWifiCredentials(_: String, _: String) {}
381
+
382
+ func forgetWifiNetwork(_: String) {}
383
+
384
+ func sendHotspotState(_: Bool) {}
385
+
386
+ func sendOtaStart(otaVersionUrl: String?) {}
387
+ func sendOtaQueryStatus() {}
388
+
389
+ func sendUserEmailToGlasses(_: String) {}
390
+
391
+ func queryGalleryStatus() {}
392
+
393
+ @objc static func requiresMainQueueSetup() -> Bool {
394
+ true
395
+ }
396
+
397
+ func sendGalleryMode() {}
398
+
399
+ func requestVersionInfo() {
400
+ Bridge.log("MentraNex: requestVersionInfo - not supported on MentraNex")
401
+ }
402
+
403
+ // MARK: - Properties
404
+
405
+ private var centralManager: CBCentralManager?
406
+
407
+ private var peripheral: CBPeripheral?
408
+ private var writeCharacteristic: CBCharacteristic?
409
+ private var notifyCharacteristic: CBCharacteristic?
410
+ private var _isScanning = false
411
+ private var isConnecting = false
412
+ private var nexReady = false
413
+ private var isDisconnecting = false
414
+ private var reconnectionTimer: Timer?
415
+ private var reconnectionAttempts = 0
416
+ private let maxReconnectionAttempts = -1 // -1 for unlimited
417
+ private let reconnectionInterval: TimeInterval = 2.0
418
+ private var peripheralToConnectName: String?
419
+ /// True while the current scan is a user-initiated discovery scan (the "scan for devices"
420
+ /// list). Discovery must NOT short-circuit into reconnecting the last paired device by
421
+ /// stored UUID / saved name — otherwise, once a device has been connected, every later
422
+ /// discovery scan silently reconnects the old glasses instead of listing nearby devices.
423
+ private var isDiscoveryScan = false
424
+ private let INITIAL_CONNECTION_DELAY_MS: UInt64 = 350
425
+ private let DELAY_BETWEEN_CHUNKS_SEND_MS: UInt64 = 10
426
+
427
+ // Heartbeat tracking (like Java implementation)
428
+ private var heartbeatCount = 0
429
+ private var lastHeartbeatSentTime: TimeInterval = 0
430
+ private var lastHeartbeatReceivedTime: TimeInterval = 0
431
+
432
+ // Microphone beat system (like Java implementation)
433
+ private var micBeatTimer: Timer?
434
+ private var micBeatCount = 0
435
+ private let MICBEAT_INTERVAL_MS: TimeInterval = 30 * 60 // 30 minutes like Java
436
+ private var shouldUseGlassesMic = true
437
+ private var isMicrophoneEnabled = true
438
+ private var microphoneStateBeforeDisconnection = false
439
+
440
+ // Whitelist system (like Java implementation)
441
+ private var whiteListedAlready = false
442
+ private let WHITELIST_CMD: UInt8 = 0x04
443
+
444
+ /// Protobuf version tracking (like Java implementation)
445
+ private var protobufVersionPosted = false
446
+
447
+ /// Device discovery cache (like MentraLive)
448
+ private var discoveredPeripherals = [String: CBPeripheral]() // name -> peripheral
449
+ private var lastConnectionTimestamp: TimeInterval = 0
450
+ private var lastReceivedLc3Sequence = -1
451
+ private var currentImageChunks: [[UInt8]] = []
452
+ private var isImageSendProgressing = false
453
+ private var servicesReady = false
454
+ private var serviceReadyWaiters: [CheckedContinuation<Void, Never>] = []
455
+ private var pendingWriteContinuation: CheckedContinuation<Void, Never>?
456
+ // Separate from pendingWriteContinuation (which is flow-control for
457
+ // .withoutResponse): this one is resumed by didWriteValueFor, i.e. the ATT
458
+ // ack for a .withResponse write. Kept distinct so a peripheralIsReady
459
+ // flow-control callback can't spuriously resume an in-flight acked write.
460
+ private var pendingAckContinuation: CheckedContinuation<Void, Never>?
461
+
462
+ // MARK: - Published Properties (G1-compatible)
463
+
464
+ @Published var vadActive: Bool = false
465
+ @Published var deviceReady: Bool = false
466
+
467
+ // Audio properties (G1-compatible)
468
+ @Published var compressedVoiceData: Data = .init()
469
+ @Published var aiListening: Bool = false
470
+
471
+ // Device info properties
472
+ @Published var deviceFirmwareVersion: String = ""
473
+ @Published var deviceHardwareModel: String = ""
474
+
475
+ // IMU data properties
476
+ @Published var accelerometer: [Float] = [0.0, 0.0, 0.0]
477
+ @Published var gyroscope: [Float] = [0.0, 0.0, 0.0]
478
+ @Published var magnetometer: [Float] = [0.0, 0.0, 0.0]
479
+
480
+ // Button state properties
481
+ @Published var lastButtonPressed: Int = -1
482
+ @Published var lastButtonState: String = ""
483
+
484
+ // Head gesture properties
485
+ @Published var lastHeadGesture: String = ""
486
+ @Published var headUpAngle: Int = 0
487
+
488
+ // Enhanced device persistence (from Java implementation)
489
+ private let PREFS_DEVICE_NAME = "MentraNexLastConnectedDeviceName"
490
+ private let PREFS_DEVICE_ADDRESS = "MentraNexLastConnectedDeviceAddress"
491
+ private let PREFS_DEVICE_ID = "SavedNexIdKey"
492
+ private let SHARED_PREFS_NAME = "NexGlassesPrefs"
493
+
494
+ // Device state tracking (ported from Java)
495
+ private var savedDeviceName: String?
496
+ private var savedDeviceAddress: String?
497
+ private var preferredDeviceId: String?
498
+ private var isKilled = false
499
+ private var scanOnPowerOn = false
500
+
501
+ private let bluetoothQueue = DispatchQueue(label: "MentraNexBluetooth", qos: .userInitiated)
502
+
503
+ /// Protocol-required connectionState (String)
504
+ var connectionState: String = ConnTypes.DISCONNECTED
505
+
506
+ // Protocol-required properties
507
+ var type: String = DeviceTypes.NEX
508
+ var ready: Bool {
509
+ get { nexReady }
510
+ set { nexReady = newValue }
511
+ }
512
+
513
+ var hasMic: Bool = true
514
+
515
+ private var peripheralUUID: UUID? {
516
+ get {
517
+ if let uuidString = UserDefaults.standard.string(forKey: "nexPeripheralUUID") {
518
+ return UUID(uuidString: uuidString)
519
+ }
520
+ return nil
521
+ }
522
+ set {
523
+ if let newValue {
524
+ UserDefaults.standard.set(newValue.uuidString, forKey: "nexPeripheralUUID")
525
+ } else {
526
+ UserDefaults.standard.removeObject(forKey: "nexPeripheralUUID")
527
+ }
528
+ }
529
+ }
530
+
531
+ /// Custom Bluetooth queue for better performance (like G1)
532
+ private static let _bluetoothQueue = DispatchQueue(
533
+ label: "com.glassly.nex.bluetooth", qos: .background
534
+ )
535
+
536
+ static var instance: MentraNexSGC?
537
+
538
+ // MARK: - Singleton Access
539
+
540
+ @objc static func getInstance() -> MentraNexSGC {
541
+ if let existing = instance, existing.centralManager != nil {
542
+ return existing
543
+ }
544
+ instance = MentraNexSGC()
545
+ return instance!
546
+ }
547
+
548
+ // UUIDs from MentraNexSGC.java
549
+ private let MAIN_SERVICE_UUID = CBUUID(string: "00004860-0000-1000-8000-00805f9b34fb")
550
+ private let WRITE_CHAR_UUID = CBUUID(string: "000071FF-0000-1000-8000-00805f9b34fb")
551
+ private let NOTIFY_CHAR_UUID = CBUUID(string: "000070FF-0000-1000-8000-00805f9b34fb")
552
+
553
+ // Packet types from MentraNexSGC.java
554
+ private let PACKET_TYPE_JSON: UInt8 = 0x01
555
+ private let PACKET_TYPE_PROTOBUF: UInt8 = 0x02
556
+ private let PACKET_TYPE_AUDIO: UInt8 = 0xA0
557
+ private let PACKET_TYPE_IMAGE: UInt8 = 0xB0
558
+
559
+ // MTU Configuration (iOS-optimized)
560
+ private let MTU_MAX_IOS = 185 // iOS maximum (platform limitation)
561
+ private let MTU_DEFAULT = 23 // Default BLE MTU
562
+ private var currentMTU = 23 // Currently negotiated MTU
563
+ private var deviceMaxMTU = 23 // Device's maximum capability
564
+ private var maxChunkSize = 176 // Calculated optimal chunk size
565
+ private var bmpChunkSize = 176 // Image chunk size (iOS-optimized)
566
+ private var protobufSeq: UInt8 = 0 // Rolling sequence for fragmented control messages
567
+
568
+ // MARK: - Command Queue (modeled after ERG1Manager)
569
+
570
+ private struct BufferedCommand {
571
+ let chunks: [[UInt8]]
572
+ let waitTimeMs: Int
573
+ let chunkDelayMs: Int
574
+ // true → fragments written .withResponse (firmware ATT-acks each, worker
575
+ // waits for the ack before the next). false → .withoutResponse (captions):
576
+ // fire-and-forget for throughput, newest caption supersedes the last.
577
+ let ack: Bool
578
+
579
+ init(chunks: [[UInt8]], waitTimeMs: Int = 0, chunkDelayMs: Int = 8, ack: Bool = true) {
580
+ self.chunks = chunks
581
+ self.waitTimeMs = waitTimeMs
582
+ self.chunkDelayMs = chunkDelayMs
583
+ self.ack = ack
584
+ }
585
+ }
586
+
587
+ private actor CommandQueue {
588
+ private var commands: [BufferedCommand] = []
589
+ private var continuations: [CheckedContinuation<BufferedCommand, Never>] = []
590
+
591
+ func enqueue(_ command: BufferedCommand) {
592
+ if let continuation = continuations.first {
593
+ continuations.removeFirst()
594
+ continuation.resume(returning: command)
595
+ } else {
596
+ commands.append(command)
597
+ }
598
+ }
599
+
600
+ func dequeue() async -> BufferedCommand {
601
+ if let command = commands.first {
602
+ commands.removeFirst()
603
+ return command
604
+ }
605
+
606
+ return await withCheckedContinuation { continuation in
607
+ continuations.append(continuation)
608
+ }
609
+ }
610
+
611
+ func clear() {
612
+ commands.removeAll()
613
+ }
614
+ }
615
+
616
+ private let commandQueue = CommandQueue()
617
+ private var isQueueWorkerRunning = false
618
+
619
+ // MARK: - Text wall coalescing (G2-style)
620
+
621
+ // Captions arrive per interim transcript (several/sec while speaking) but are
622
+ // only worth showing if they're the freshest text. A single latest-wins slot +
623
+ // a 100 ms drain ticker caps glasses-bound text writes at 10/sec and discards
624
+ // stale interim results instead of queueing them. Without this, continuous
625
+ // speech while the phone is locked backlogs the FIFO commandQueue (drain slows
626
+ // at relaxed connection intervals) and the app does unbounded background work.
627
+ // Mirrors G2's pendingTextMsg/drainEvenHubQueue. One repair resend covers a
628
+ // dropped final caption (writes are no-ack); mid-stream drops are repaired by
629
+ // the next update anyway.
630
+ private let textWallLock = NSLock()
631
+ private var pendingTextWall: Data?
632
+ private var lastTextWall: Data?
633
+ private var textWallResendsRemaining = 0
634
+ private let TEXT_WALL_RESEND_COUNT = 1
635
+ private var textWallDrainTask: Task<Void, Never>?
636
+
637
+ // MARK: - Initialization
638
+
639
+ override private init() {
640
+ super.init()
641
+ Bridge.log("NEX: 🚀 MentraNexSGC initialization started")
642
+
643
+ // Load saved device information (from Java implementation)
644
+ loadSavedDeviceInfo()
645
+
646
+ // Using custom Bluetooth queue for better performance (like G1)
647
+ Bridge.log("NEX: 📱 Creating CBCentralManager with custom Bluetooth queue")
648
+ centralManager = CBCentralManager(delegate: self, queue: MentraNexSGC._bluetoothQueue)
649
+
650
+ Bridge.log("NEX: ✅ MentraNexSGC initialization completed")
651
+ Bridge.log("NEX: 📱 Central Manager created: \(centralManager != nil ? "YES" : "NO")")
652
+ if let centralManager {
653
+ Bridge.log("NEX: 📱 Initial Bluetooth State: \(centralManager.state.rawValue)")
654
+ }
655
+
656
+ Bridge.log(
657
+ "NEX: 💾 Loaded saved device - Name: \(savedDeviceName ?? "None"), Address: \(savedDeviceAddress ?? "None")"
658
+ )
659
+ DeviceStore.shared.apply("glasses", "micEnabled", false)
660
+ }
661
+
662
+ private func setupCommandQueue() {
663
+ if isQueueWorkerRunning { return }
664
+ isQueueWorkerRunning = true
665
+
666
+ Task.detached { [weak self] in
667
+ guard let self else { return }
668
+ while true {
669
+ let command = await self.commandQueue.dequeue()
670
+ await self.processCommand(command)
671
+ }
672
+ }
673
+ }
674
+
675
+ private func queueChunks(_ chunks: [[UInt8]], waitTimeMs: Int = 0, chunkDelayMs: Int = 10, ack: Bool = true) {
676
+ let cmd = BufferedCommand(
677
+ chunks: chunks, waitTimeMs: waitTimeMs, chunkDelayMs: chunkDelayMs, ack: ack
678
+ )
679
+ Task { [weak self] in
680
+ await self?.commandQueue.enqueue(cmd)
681
+ }
682
+ }
683
+
684
+ private func waitUntilServicesReady() async {
685
+ if servicesReady {
686
+ return
687
+ }
688
+ await withCheckedContinuation { continuation in
689
+ serviceReadyWaiters.append(continuation)
690
+ }
691
+ }
692
+
693
+ private func setServicesReady(_ ready: Bool) {
694
+ servicesReady = ready
695
+ guard ready else { return }
696
+ releaseServiceWaiters()
697
+ }
698
+
699
+ private func releaseServiceWaiters() {
700
+ let waiters = serviceReadyWaiters
701
+ serviceReadyWaiters.removeAll()
702
+ waiters.forEach { $0.resume() }
703
+ }
704
+
705
+ /// Suspends until CoreBluetooth signals it can accept another write-without-response
706
+ /// (resumed by peripheralIsReady(toSendWriteWithoutResponse:), or by the disconnect
707
+ /// handlers so an in-flight send can't wedge the queue across a drop).
708
+ private func waitUntilReadyToWrite() async {
709
+ await withCheckedContinuation { continuation in
710
+ pendingWriteContinuation = continuation
711
+ }
712
+ }
713
+
714
+ private func resumePendingWrite() {
715
+ pendingWriteContinuation?.resume()
716
+ pendingWriteContinuation = nil
717
+ }
718
+
719
+ /// Suspends until the ATT ack for a .withResponse write arrives (resumed by
720
+ /// didWriteValueFor, or by the disconnect handlers so an in-flight acked
721
+ /// write can't wedge the queue across a drop).
722
+ private func waitUntilAcked() async {
723
+ await withCheckedContinuation { continuation in
724
+ pendingAckContinuation = continuation
725
+ }
726
+ }
727
+
728
+ private func resumePendingAck() {
729
+ pendingAckContinuation?.resume()
730
+ pendingAckContinuation = nil
731
+ }
732
+
733
+ private func emitBleCommandSent(_ packetData: Data) {
734
+ guard packetData.first == PACKET_TYPE_PROTOBUF else { return }
735
+ guard packetData.count > 1 else { return }
736
+ let payload = packetData.subdata(in: 1 ..< packetData.count)
737
+ let commandName: String
738
+ if let phoneToGlasses = try? Glassly_Ble_PhoneToGlasses(serializedData: payload) {
739
+ commandName = String(describing: phoneToGlasses.payload)
740
+ } else {
741
+ commandName = "UNKNOWN"
742
+ }
743
+ Bridge.sendTypedMessage("send_command_to_ble", body: [
744
+ "command": commandName,
745
+ "commandText": packetData.toHexString(),
746
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
747
+ ])
748
+ }
749
+
750
+ private func emitBleCommandReceived(_ packetData: Data, payloadDescription: String) {
751
+ Bridge.sendTypedMessage("receive_command_from_ble", body: [
752
+ "command": payloadDescription,
753
+ "commandText": packetData.toHexString(),
754
+ "timestamp": Int64(Date().timeIntervalSince1970 * 1000),
755
+ ])
756
+ }
757
+
758
+ /// Splits a serialized protobuf message into BLE-sized fragments, each prefixed with a
759
+ /// 4-byte transport header [packetType][seq][totalChunks][chunkIndex] so the firmware can
760
+ /// reassemble messages larger than one MTU. Single-fragment messages set totalChunks = 1
761
+ /// and are decoded directly by the firmware's fast path.
762
+ private func queueDataWithOptimalChunking(
763
+ _ data: Data, packetType: UInt8 = 0x02, waitTimeMs: Int = 0, emitTelemetry: Bool = true, ack: Bool = true
764
+ ) {
765
+ // Telemetry: report the full logical command (type byte + protobuf) before fragmenting.
766
+ // Callers on high-rate paths (caption text walls, up to 10/sec) pass
767
+ // emitTelemetry: false — emitBleCommandSent re-parses the protobuf,
768
+ // hex-dumps the packet, and crosses to the JS thread per call.
769
+ if emitTelemetry {
770
+ var packetData = Data([packetType])
771
+ packetData.append(data)
772
+ emitBleCommandSent(packetData)
773
+ }
774
+
775
+ let headerSize = 4 // [packetType][seq][totalChunks][chunkIndex]
776
+ let effectiveChunkSize = max(1, maxChunkSize - headerSize)
777
+ let totalChunks = data.isEmpty
778
+ ? 1 : Int(ceil(Double(data.count) / Double(effectiveChunkSize)))
779
+
780
+ guard totalChunks <= 255 else {
781
+ Bridge.log(
782
+ "NEX: ❌ Protobuf message too large to fragment (\(totalChunks) chunks) - dropping"
783
+ )
784
+ return
785
+ }
786
+
787
+ let seq = protobufSeq
788
+ protobufSeq = protobufSeq &+ 1
789
+
790
+ var chunks: [[UInt8]] = []
791
+ var offset = 0
792
+ var index = 0
793
+ while offset < data.count || (index == 0 && data.isEmpty) {
794
+ let end = min(offset + effectiveChunkSize, data.count)
795
+ var frame: [UInt8] = [packetType, seq, UInt8(totalChunks), UInt8(index)]
796
+ if end > offset {
797
+ frame.append(contentsOf: data.subdata(in: offset ..< end))
798
+ }
799
+ chunks.append(frame)
800
+ offset = end
801
+ index += 1
802
+ }
803
+
804
+ // No per-send log: fires for every outbound message (up to 10/sec text
805
+ // walls during captions).
806
+ // Bridge.log(
807
+ // "NEX: 📦 Fragmented protobuf into \(chunks.count) chunk(s) (seq=\(seq), max payload \(effectiveChunkSize) bytes)"
808
+ // )
809
+ queueChunks(chunks, waitTimeMs: waitTimeMs, ack: ack)
810
+ }
811
+
812
+ private func processCommand(_ command: BufferedCommand) async {
813
+ guard let peripheral, let writeCharacteristic else {
814
+ Bridge.log("NEX: ⚠️ processCommand: peripheral/characteristic not ready")
815
+ return
816
+ }
817
+
818
+ await waitUntilServicesReady()
819
+
820
+ // Send each chunk sequentially
821
+ for (index, chunk) in command.chunks.enumerated() {
822
+ // let timeSinceConnection = Date().timeIntervalSince1970 * 1000 - lastConnectionTimestamp
823
+ // if timeSinceConnection < Double(INITIAL_CONNECTION_DELAY_MS) {
824
+ // let remainingMs = UInt64(Double(INITIAL_CONNECTION_DELAY_MS) - timeSinceConnection)
825
+ // try? await Task.sleep(nanoseconds: remainingMs * 1_000_000)
826
+ // }
827
+ let data = Data(chunk)
828
+ // Bridge.log(
829
+ // "NEX: 📦 Sending chunk \(index) of \(command.chunks.count) to \(peripheral.name ?? "Unknown")"
830
+ // )
831
+ // Bridge.log("NEX: 📦 Chunk data: \(data.toHexString())")
832
+ if command.ack {
833
+ // Acked path (everything except captions): write WITH response so the
834
+ // firmware ATT-acks each fragment, then wait for that ack
835
+ // (didWriteValueFor) before sending the next — reliable delivery, one
836
+ // outstanding write at a time.
837
+ peripheral.writeValue(data, for: writeCharacteristic, type: .withResponse)
838
+ await waitUntilAcked()
839
+ } else {
840
+ // Captions: write WITHOUT response. A write-with-response is one
841
+ // outstanding request gated on a remote round trip, which puts the
842
+ // (screen-off-throttled) app thread in the path of every fragment and
843
+ // makes captions crawl in the background. .withoutResponse lets
844
+ // CoreBluetooth batch fragments into connection events; the newest
845
+ // caption supersedes the last, so a dropped fragment self-heals.
846
+ peripheral.writeValue(data, for: writeCharacteristic, type: .withoutResponse)
847
+ }
848
+
849
+ // // Delay between chunks except maybe after the last chunk if waitTime will handle it
850
+ // if index < command.chunks.count - 1 {
851
+ // try? await Task.sleep(nanoseconds: UInt64(command.chunkDelayMs) * 1_000_000)
852
+ // }
853
+ // try? await Task.sleep(nanoseconds: DELAY_BETWEEN_CHUNKS_SEND_MS * 1_000_000)
854
+ }
855
+
856
+ // Optional wait after the command
857
+ if command.waitTimeMs > 0 {
858
+ try? await Task.sleep(nanoseconds: UInt64(command.waitTimeMs) * 1_000_000)
859
+ }
860
+ }
861
+
862
+ // MARK: - Device Persistence (ported from Java)
863
+
864
+ private func loadSavedDeviceInfo() {
865
+ savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME)
866
+ savedDeviceAddress = UserDefaults.standard.string(forKey: PREFS_DEVICE_ADDRESS)
867
+ preferredDeviceId = UserDefaults.standard.string(forKey: PREFS_DEVICE_ID)
868
+
869
+ Bridge.log(
870
+ "NEX: 💾 Loaded device info - Name: \(savedDeviceName ?? "None"), Address: \(savedDeviceAddress ?? "None"), ID: \(preferredDeviceId ?? "None")"
871
+ )
872
+ }
873
+
874
+ private func savePairedDeviceInfo(name: String?, address: String?) {
875
+ if let name {
876
+ UserDefaults.standard.set(name, forKey: PREFS_DEVICE_NAME)
877
+ savedDeviceName = name
878
+ Bridge.log("NEX: 💾 Saved device name: \(name)")
879
+ }
880
+
881
+ if let address {
882
+ UserDefaults.standard.set(address, forKey: PREFS_DEVICE_ADDRESS)
883
+ savedDeviceAddress = address
884
+ Bridge.log("NEX: 💾 Saved device address: \(address)")
885
+ }
886
+ }
887
+
888
+ @objc func savePreferredDeviceId(_ deviceId: String) {
889
+ UserDefaults.standard.set(deviceId, forKey: PREFS_DEVICE_ID)
890
+ preferredDeviceId = deviceId
891
+ Bridge.log("NEX: 💾 Saved preferred device ID: \(deviceId)")
892
+ }
893
+
894
+ @objc func clearSavedDeviceInfo() {
895
+ UserDefaults.standard.removeObject(forKey: PREFS_DEVICE_NAME)
896
+ UserDefaults.standard.removeObject(forKey: PREFS_DEVICE_ADDRESS)
897
+ UserDefaults.standard.removeObject(forKey: PREFS_DEVICE_ID)
898
+
899
+ savedDeviceName = nil
900
+ savedDeviceAddress = nil
901
+ preferredDeviceId = nil
902
+ peripheralUUID = nil
903
+
904
+ Bridge.log("NEX: 🗑️ Cleared all saved device information")
905
+ }
906
+
907
+ // MARK: - Enhanced Device Filtering (ported from Java)
908
+
909
+ private func isCompatibleNexDevice(_ deviceName: String) -> Bool {
910
+ // Keep in parity with Android MentraNex.kt scan filter.
911
+ let compatiblePrefixes = [
912
+ "Nex1-",
913
+ "MENTRA_DISPLAY_",
914
+ ]
915
+
916
+ for prefix in compatiblePrefixes {
917
+ if deviceName.contains(prefix) {
918
+ Bridge.log("NEX: ✅ Device '\(deviceName)' matches compatible prefix: \(prefix)")
919
+ return true
920
+ }
921
+ }
922
+
923
+ return false
924
+ }
925
+
926
+ private func extractDeviceId(from deviceName: String) -> String? {
927
+ // Extract device ID pattern similar to Java implementation
928
+ let patterns = [
929
+ "Glassly_([0-9A-Fa-f]+)",
930
+ "NEX_([0-9A-Fa-f]+)",
931
+ "MENTRA_NEX_([0-9A-Fa-f]+)",
932
+ "MENTRA_DISPLAY_([0-9A-Fa-f]+)",
933
+ ]
934
+
935
+ for pattern in patterns {
936
+ let regex = try? NSRegularExpression(pattern: pattern)
937
+ let range = NSRange(deviceName.startIndex ..< deviceName.endIndex, in: deviceName)
938
+ if let match = regex?.firstMatch(in: deviceName, options: [], range: range),
939
+ let matchRange = Range(match.range(at: 1), in: deviceName)
940
+ {
941
+ let deviceId = String(deviceName[matchRange])
942
+ Bridge.log("NEX: 🏷️ Extracted device ID: \(deviceId) from \(deviceName)")
943
+ return deviceId
944
+ }
945
+ }
946
+
947
+ Bridge.log("NEX: ⚠️ Could not extract device ID from: \(deviceName)")
948
+ return nil
949
+ }
950
+
951
+ // MARK: - Connection Logic (enhanced from G1)
952
+
953
+ @objc(connectByName:)
954
+ func connect(name: String) {
955
+ Bridge.log("NEX-CONN: 🔗 connect(name:) called with \(name)")
956
+ if _isScanning {
957
+ stopScan()
958
+ }
959
+ peripheralToConnectName = name
960
+ isDiscoveryScan = false
961
+ startScan()
962
+ }
963
+
964
+ private func connectByUUID() -> Bool {
965
+ guard let uuid = peripheralUUID else {
966
+ Bridge.log("NEX-CONN: 🔵 No stored UUID to connect by.")
967
+ return false
968
+ }
969
+
970
+ guard let centralManager else {
971
+ Bridge.log("NEX-CONN: ❌ Central Manager is nil, cannot connect by UUID.")
972
+ return false
973
+ }
974
+
975
+ Bridge.log(
976
+ "NEX-CONN: 🔵 Attempting to retrieve peripheral with stored UUID: \(uuid.uuidString)"
977
+ )
978
+ let peripherals = centralManager.retrievePeripherals(withIdentifiers: [uuid])
979
+
980
+ guard let peripheralToConnect = peripherals.first else {
981
+ Bridge.log(
982
+ "NEX-CONN: 🔵 Could not find peripheral for stored UUID. Will proceed to scan."
983
+ )
984
+ return false
985
+ }
986
+
987
+ // The stored UUID is a single "last connected" identifier. When the caller is
988
+ // targeting a specific device by name, only take this fast-path if the cached
989
+ // peripheral IS that device — otherwise we'd silently reconnect the *previously*
990
+ // paired glasses, and since iOS's connect() never times out and startScan()
991
+ // returns early on success, pairing the new device would hang forever.
992
+ // (Mirrors Android, which reconnects to the target device's own address, never a
993
+ // global "last" one. peripheralToConnectName == nil means an auto-reconnect with no
994
+ // specific target, so the cached device is exactly what we want — keep using it.)
995
+ if let targetName = peripheralToConnectName,
996
+ !(peripheralToConnect.name?.contains(targetName) ?? false)
997
+ {
998
+ Bridge.log(
999
+ "NEX-CONN: 🔵 Stored UUID is '\(peripheralToConnect.name ?? "unnamed")' but target is '\(targetName)'. Skipping UUID fast-path; will scan for the target."
1000
+ )
1001
+ return false
1002
+ }
1003
+
1004
+ Bridge.log(
1005
+ "NEX-CONN: 🔵 Found peripheral by UUID: \(peripheralToConnect.name ?? "Unknown"). Initiating connection."
1006
+ )
1007
+ peripheral = peripheralToConnect
1008
+ centralManager.connect(peripheralToConnect, options: nil)
1009
+ return true
1010
+ }
1011
+
1012
+ private func startReconnectionTimer() {
1013
+ Bridge.log("NEX-CONN: 🔄 Starting reconnection timer...")
1014
+ stopReconnectionTimer() // Ensure no existing timer is running
1015
+ reconnectionAttempts = 0
1016
+
1017
+ DispatchQueue.main.async {
1018
+ self.reconnectionTimer = Timer.scheduledTimer(
1019
+ timeInterval: self.reconnectionInterval,
1020
+ target: self,
1021
+ selector: #selector(self.attemptReconnection),
1022
+ userInfo: nil,
1023
+ repeats: true
1024
+ )
1025
+ }
1026
+ }
1027
+
1028
+ private func stopReconnectionTimer() {
1029
+ if reconnectionTimer != nil {
1030
+ Bridge.log("NEX-CONN: 🛑 Stopping reconnection timer.")
1031
+ reconnectionTimer?.invalidate()
1032
+ reconnectionTimer = nil
1033
+ }
1034
+ }
1035
+
1036
+ @objc private func attemptReconnection() {
1037
+ if nexReady {
1038
+ Bridge.log("NEX-CONN: ✅ Already connected, stopping reconnection attempts.")
1039
+ stopReconnectionTimer()
1040
+ return
1041
+ }
1042
+
1043
+ if maxReconnectionAttempts != -1, reconnectionAttempts >= maxReconnectionAttempts {
1044
+ Bridge.log("NEX-CONN: ❌ Max reconnection attempts reached.")
1045
+ stopReconnectionTimer()
1046
+ return
1047
+ }
1048
+
1049
+ reconnectionAttempts += 1
1050
+ Bridge.log("NEX-CONN: 🔄 Attempting reconnection (\(reconnectionAttempts))...")
1051
+ isDiscoveryScan = false
1052
+ startScan()
1053
+ }
1054
+
1055
+ // MARK: - Public Methods
1056
+
1057
+ private func startScan() {
1058
+ Bridge.log("NEX-CONN: 🔍 startScan called")
1059
+
1060
+ isDisconnecting = false // Reset intentional disconnect flag
1061
+
1062
+ guard let centralManager else {
1063
+ Bridge.log("NEX-CONN: ❌ Central Manager is nil!")
1064
+ return
1065
+ }
1066
+
1067
+ guard centralManager.state == .poweredOn else {
1068
+ Bridge.log(
1069
+ "NEX-CONN: ❌ Bluetooth not powered on. State: \(centralManager.state.rawValue)"
1070
+ )
1071
+ return
1072
+ }
1073
+
1074
+ // Reconnect short-circuits below are for the targeted connect/reconnect paths only.
1075
+ // A user-initiated discovery scan must fall through to scanForPeripherals so the
1076
+ // device list populates even after we've previously paired (which persisted a UUID).
1077
+ if !isDiscoveryScan {
1078
+ // First, try to reconnect using stored UUID (faster and works in background)
1079
+ if connectByUUID() {
1080
+ Bridge.log("NEX-CONN: 🔄 Attempting connection with stored UUID. Halting scan.")
1081
+ return
1082
+ }
1083
+
1084
+ // If that fails, check for already-connected system devices
1085
+ let connectedPeripherals = centralManager.retrieveConnectedPeripherals(withServices: [
1086
+ MAIN_SERVICE_UUID,
1087
+ ])
1088
+ if let targetName = peripheralToConnectName,
1089
+ let existingPeripheral = connectedPeripherals.first(where: {
1090
+ $0.name?.contains(targetName) == true
1091
+ })
1092
+ {
1093
+ Bridge.log(
1094
+ "NEX-CONN: 📱 Found already connected peripheral that matches target: \(existingPeripheral.name ?? "Unknown")"
1095
+ )
1096
+ if peripheral == nil {
1097
+ peripheral = existingPeripheral
1098
+ centralManager.connect(existingPeripheral, options: nil)
1099
+ return
1100
+ }
1101
+ }
1102
+
1103
+ // Check if we have a saved device name to reconnect to (like MentraLive)
1104
+ if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
1105
+ !savedDeviceName.isEmpty
1106
+ {
1107
+ Bridge.log("NEX-CONN: 🔄 Looking for saved device: \(savedDeviceName)")
1108
+ // This will be handled in didDiscover when the device is found
1109
+ }
1110
+ }
1111
+
1112
+ Bridge.log("NEX-CONN: ✅ Bluetooth is powered on, starting scan...")
1113
+ _isScanning = true
1114
+ connectionState = ConnTypes.SCANNING
1115
+ DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.SCANNING)
1116
+
1117
+ // Scan for ALL devices, not just those with specific services
1118
+ // Use same options as G1 scanner for consistency
1119
+ let scanOptions: [String: Any] = [
1120
+ CBCentralManagerScanOptionAllowDuplicatesKey: false, // Don't allow duplicate advertisements
1121
+ ]
1122
+ centralManager.scanForPeripherals(withServices: nil, options: scanOptions)
1123
+
1124
+ Bridge.log("NEX-CONN: 🚀 Scan started successfully")
1125
+
1126
+ // Re-emit already discovered peripherals (like MentraLive)
1127
+ for (_, peripheral) in discoveredPeripherals {
1128
+ Bridge.log(
1129
+ "NEX-CONN: 📡 (Re-emitting from cache) peripheral: \(peripheral.name ?? "Unknown")"
1130
+ )
1131
+ if let name = peripheral.name {
1132
+ emitDiscoveredDevice(name)
1133
+ }
1134
+ }
1135
+
1136
+ // No auto-stop timer (like G1) - manual control
1137
+ Bridge.log("NEX-CONN: 💡 To stop scanning manually, call: MentraNexSGC.shared.stopScan()")
1138
+ }
1139
+
1140
+ @objc func stopScan() {
1141
+ centralManager?.stopScan()
1142
+ _isScanning = false
1143
+ // The flag describes the scan that's currently running; once it stops (10s discovery
1144
+ // timeout, manual stop, or the stop inside connect()), clear it so a later
1145
+ // reconnect/autoconnect scan isn't wrongly treated as discovery and suppressed.
1146
+ isDiscoveryScan = false
1147
+ Bridge.log("NEX-CONN: 🛑 Stopped scanning.")
1148
+ }
1149
+
1150
+ @objc func isScanning() -> Bool {
1151
+ _isScanning
1152
+ }
1153
+
1154
+ @objc func isConnected() -> Bool {
1155
+ nexReady && connectionState == ConnTypes.CONNECTED
1156
+ }
1157
+
1158
+ @objc func getConnectionState() -> String {
1159
+ switch connectionState {
1160
+ case ConnTypes.DISCONNECTED:
1161
+ return "disconnected"
1162
+ case ConnTypes.CONNECTING:
1163
+ return "connecting"
1164
+ case ConnTypes.CONNECTED:
1165
+ return "connected"
1166
+ case ConnTypes.SCANNING:
1167
+ return "scanning"
1168
+ default:
1169
+ return "disconnected"
1170
+ }
1171
+ }
1172
+
1173
+ // MARK: - MTU Information Access
1174
+
1175
+ @objc func getCurrentMTU() -> Int {
1176
+ currentMTU
1177
+ }
1178
+
1179
+ @objc func getMaxChunkSize() -> Int {
1180
+ maxChunkSize
1181
+ }
1182
+
1183
+ @objc func getDeviceMaxMTU() -> Int {
1184
+ deviceMaxMTU
1185
+ }
1186
+
1187
+ @objc func getMTUInfo() -> [String: Any] {
1188
+ [
1189
+ "current_mtu": currentMTU,
1190
+ "device_max_mtu": deviceMaxMTU,
1191
+ "max_chunk_size": maxChunkSize,
1192
+ "bmp_chunk_size": bmpChunkSize,
1193
+ "mtu_negotiated": nexReady,
1194
+ ]
1195
+ }
1196
+
1197
+ @objc func findCompatibleDevices() {
1198
+ Bridge.log("NEX-DISCOVERY: Finding compatible devices.")
1199
+
1200
+ // Clear specific connect target, but keep saved pairing data like Android.
1201
+ peripheralToConnectName = nil
1202
+ // Pure discovery: don't let startScan short-circuit into reconnecting the last
1203
+ // paired device, and don't auto-connect a saved device found mid-scan.
1204
+ isDiscoveryScan = true
1205
+
1206
+ Task {
1207
+ if centralManager == nil {
1208
+ centralManager = CBCentralManager(
1209
+ delegate: self, queue: bluetoothQueue,
1210
+ options: ["CBCentralManagerOptionShowPowerAlertKey": 0]
1211
+ )
1212
+ // wait for the central manager to be fully initialized before we start scanning:
1213
+ try? await Task.sleep(nanoseconds: 100 * 1_000_000) // 100ms
1214
+ }
1215
+
1216
+ if centralManager?.state == .poweredOn {
1217
+ startScan()
1218
+ DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak self] in
1219
+ self?.stopScan()
1220
+ }
1221
+ } else {
1222
+ Bridge.log("NEX-DISCOVERY: Bluetooth not ready, will scan on power on.")
1223
+ scanOnPowerOn = true
1224
+ }
1225
+ }
1226
+ }
1227
+
1228
+ // Characters the Nex font can render: letters, digits, whitespace, and a small
1229
+ // punctuation set. Everything else (CJK, emoji, smart quotes, …) is stripped when
1230
+ // Chinese captions are off. Matches UNSUPPORTED_GLYPH_REGEX in NexSGCUtils.kt.
1231
+ private static let unsupportedGlyphPattern = #"[^A-Za-z0-9 \r\n\.,!\?;:\-\[\]\(\)\{\}'"\+=/]"#
1232
+
1233
+ /// Sanitize text bound for the glasses. When Chinese captions are disabled (the
1234
+ /// default) the Nex font can't render CJK/emoji/etc., so em-dashes are normalised
1235
+ /// to hyphens and unsupported glyphs are dropped; when enabled, text passes through
1236
+ /// untouched. Every text path to the display funnels through here so captions and
1237
+ /// layout text filter identically. Mirrors sanitizeDisplayText in NexSGCUtils.kt.
1238
+ private func sanitizeDisplayText(_ text: String) -> String {
1239
+ let chineseCaptionsEnabled = DeviceStore.shared.get("bluetooth", "nex_chinese_captions") as? Bool ?? false
1240
+ if chineseCaptionsEnabled { return text }
1241
+ let normalized = text.replacingOccurrences(of: "—", with: "-")
1242
+ return normalized.replacingOccurrences(
1243
+ of: MentraNexSGC.unsupportedGlyphPattern, with: "", options: .regularExpression
1244
+ )
1245
+ }
1246
+
1247
+ func sendTextWall(_ text: String) async {
1248
+ guard nexReady else {
1249
+ Bridge.log("NEX: Not ready to display text. Device not initialized.")
1250
+ return
1251
+ }
1252
+
1253
+ // sanitizeDisplayText applies the Chinese-captions gate internally: off (default)
1254
+ // strips glyphs the Nex font can't render, on passes the text through unmodified.
1255
+ let sanitizedText = sanitizeDisplayText(text)
1256
+ // No per-call log: this runs for every interim transcript (several/sec
1257
+ // during continuous speech) and each Bridge.log costs the JS thread.
1258
+ // Bridge.log("NEX: Displaying text wall: '\(sanitizedText)'")
1259
+
1260
+ let displayText = Glassly_Ble_DisplayText.with {
1261
+ $0.text = sanitizedText
1262
+ $0.size = 48
1263
+ $0.x = 20
1264
+ $0.y = 260
1265
+ $0.color = 10000
1266
+ }
1267
+
1268
+ let phoneToGlasses = Glassly_Ble_PhoneToGlasses.with {
1269
+ $0.displayText = displayText
1270
+ }
1271
+
1272
+ let protobufData = try! phoneToGlasses.serializedData()
1273
+ // Latest-wins: overwrite the pending slot; the 100 ms drain ticker sends it.
1274
+ // Do NOT enqueue on commandQueue — that's what backlogs under continuous speech.
1275
+ textWallLock.lock()
1276
+ pendingTextWall = protobufData
1277
+ textWallLock.unlock()
1278
+ }
1279
+
1280
+ private func startTextWallDrain() {
1281
+ textWallDrainTask?.cancel()
1282
+ textWallDrainTask = Task { [weak self] in
1283
+ while !Task.isCancelled {
1284
+ try? await Task.sleep(nanoseconds: 100_000_000)
1285
+ guard !Task.isCancelled else { break }
1286
+ self?.drainPendingTextWall()
1287
+ }
1288
+ }
1289
+ }
1290
+
1291
+ private func stopTextWallDrain() {
1292
+ textWallDrainTask?.cancel()
1293
+ textWallDrainTask = nil
1294
+ textWallLock.lock()
1295
+ pendingTextWall = nil
1296
+ lastTextWall = nil
1297
+ textWallResendsRemaining = 0
1298
+ textWallLock.unlock()
1299
+ }
1300
+
1301
+ private func drainPendingTextWall() {
1302
+ textWallLock.lock()
1303
+ let msg = pendingTextWall
1304
+ pendingTextWall = nil
1305
+ var toSend: Data?
1306
+ if let msg {
1307
+ lastTextWall = msg
1308
+ textWallResendsRemaining = TEXT_WALL_RESEND_COUNT
1309
+ toSend = msg
1310
+ } else if textWallResendsRemaining > 0, let last = lastTextWall {
1311
+ textWallResendsRemaining -= 1
1312
+ toSend = last
1313
+ }
1314
+ textWallLock.unlock()
1315
+ guard let toSend else { return }
1316
+ // Captions go out unacked (.withoutResponse) for throughput; every other
1317
+ // command uses acked writes. Mirrors MentraNex.kt.
1318
+ queueDataWithOptimalChunking(toSend, packetType: PACKET_TYPE_PROTOBUF, emitTelemetry: false, ack: false)
1319
+ }
1320
+
1321
+ @objc func displayTextLine(_ text: String) {
1322
+ Task { await sendTextWall(text) }
1323
+ }
1324
+
1325
+ @objc func displayDoubleTextWall(_ textTop: String, textBottom: String) {
1326
+ let combinedText = "\(textTop)\n\(textBottom)"
1327
+ Task { await sendTextWall(combinedText) }
1328
+ }
1329
+
1330
+ @objc func displayReferenceCardSimple(_ title: String, body: String) {
1331
+ let combinedText = "\(title)\n\n\(body)"
1332
+ Task { await sendTextWall(combinedText) }
1333
+ }
1334
+
1335
+ @objc func displayRowsCard(_ rowStrings: [String]) {
1336
+ let combinedText = rowStrings.joined(separator: "\n")
1337
+ Task { await sendTextWall(combinedText) }
1338
+ }
1339
+
1340
+ @objc func displayBulletList(_ title: String, bullets: [String]) {
1341
+ var text = title
1342
+ if !title.isEmpty {
1343
+ text += "\n"
1344
+ }
1345
+ text += bullets.map { "• \($0)" }.joined(separator: "\n")
1346
+ Task { await sendTextWall(text) }
1347
+ }
1348
+
1349
+ @objc func displayScrollingText(_ text: String) {
1350
+ guard nexReady else {
1351
+ Bridge.log("NEX: Not ready to display scrolling text. Device not initialized.")
1352
+ return
1353
+ }
1354
+
1355
+ Bridge.log("NEX: Displaying scrolling text: '\(text)'")
1356
+
1357
+ let displayScrollingText = Glassly_Ble_DisplayScrollingText.with {
1358
+ $0.text = text
1359
+ $0.size = 48
1360
+ $0.x = 20
1361
+ $0.y = 50
1362
+ $0.width = 200
1363
+ $0.height = 100
1364
+ $0.speed = 50
1365
+ $0.pauseMs = 10
1366
+ $0.loop = true
1367
+ $0.align = .center
1368
+ $0.lineSpacing = 2
1369
+ }
1370
+
1371
+ let phoneToGlasses = Glassly_Ble_PhoneToGlasses.with {
1372
+ $0.displayScrollingText = displayScrollingText
1373
+ }
1374
+
1375
+ let protobufData = try! phoneToGlasses.serializedData()
1376
+ queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
1377
+ }
1378
+
1379
+ // MARK: - Display Image Commands
1380
+
1381
+ @objc func displayBitmap(_ bitmap: UIImage) {
1382
+ guard nexReady else {
1383
+ Bridge.log("NEX: Not ready to display bitmap. Device not initialized.")
1384
+ return
1385
+ }
1386
+
1387
+ Bridge.log("NEX: Displaying bitmap image")
1388
+
1389
+ // Convert UIImage to raw bitmap data
1390
+ guard let bmpData = convertUIImageToBmpData(bitmap) else {
1391
+ Bridge.log("NEX: Failed to convert UIImage to BMP data")
1392
+ return
1393
+ }
1394
+
1395
+ displayBitmapData(bmpData, width: Int(bitmap.size.width), height: Int(bitmap.size.height))
1396
+ }
1397
+
1398
+ @objc func displayBitmapFromData(_ bmpData: Data, width: Int, height: Int) {
1399
+ displayBitmapData(bmpData, width: width, height: height)
1400
+ }
1401
+
1402
+ private func displayBitmapData(_ bmpData: Data, width: Int, height: Int) {
1403
+ guard nexReady else {
1404
+ Bridge.log("NEX: Not ready to display bitmap data. Device not initialized.")
1405
+ return
1406
+ }
1407
+
1408
+ Bridge.log("NEX: Displaying bitmap data (\(bmpData.count) bytes, \(width)x\(height))")
1409
+
1410
+ // Generate stream ID for image transfer
1411
+ let streamId = String(format: "%04X", Int.random(in: 0 ... 0xFFFF))
1412
+ let totalChunks = Int(ceil(Double(bmpData.count) / Double(bmpChunkSize)))
1413
+
1414
+ // Send display image command first
1415
+ let displayImage = Glassly_Ble_DisplayImage.with {
1416
+ $0.streamID = streamId
1417
+ $0.totalChunks = UInt32(totalChunks)
1418
+ $0.x = 0
1419
+ $0.y = 0
1420
+ $0.width = UInt32(width)
1421
+ $0.height = UInt32(height)
1422
+ $0.encoding = "raw"
1423
+ }
1424
+
1425
+ let phoneToGlasses = Glassly_Ble_PhoneToGlasses.with {
1426
+ $0.msgID = "img_start_1"
1427
+ $0.displayImage = displayImage
1428
+ }
1429
+
1430
+ let protobufData = try! phoneToGlasses.serializedData()
1431
+ queueDataWithOptimalChunking(
1432
+ protobufData, packetType: PACKET_TYPE_PROTOBUF, waitTimeMs: 100
1433
+ )
1434
+
1435
+ // Send image chunks
1436
+ sendImageChunks(streamId: streamId, imageData: bmpData)
1437
+ }
1438
+
1439
+ private func sendImageChunks(streamId: String, imageData: Data) {
1440
+ let streamIdInt = Int(streamId, radix: 16) ?? 0
1441
+ let totalChunks = Int(ceil(Double(imageData.count) / Double(bmpChunkSize)))
1442
+
1443
+ var chunks: [[UInt8]] = []
1444
+
1445
+ for i in 0 ..< totalChunks {
1446
+ let start = i * bmpChunkSize
1447
+ let end = min(start + bmpChunkSize, imageData.count)
1448
+ let chunkData = imageData.subdata(in: start ..< end)
1449
+
1450
+ var header: [UInt8] = [
1451
+ PACKET_TYPE_IMAGE, // 0xB0
1452
+ UInt8((streamIdInt >> 8) & 0xFF), // Stream ID high byte
1453
+ UInt8(streamIdInt & 0xFF), // Stream ID low byte
1454
+ UInt8(i & 0xFF), // Chunk index
1455
+ ]
1456
+ header.append(contentsOf: chunkData)
1457
+ chunks.append(header)
1458
+ }
1459
+
1460
+ Bridge.log("NEX: Sending \(chunks.count) image chunks")
1461
+ currentImageChunks = chunks
1462
+ isImageSendProgressing = true
1463
+ queueChunks(chunks, waitTimeMs: 50)
1464
+ }
1465
+
1466
+ private func convertUIImageToBmpData(_ image: UIImage) -> Data? {
1467
+ // This is a simplified conversion - in production you'd want proper BMP encoding
1468
+ guard let cgImage = image.cgImage else { return nil }
1469
+
1470
+ let width = cgImage.width
1471
+ let height = cgImage.height
1472
+ let bytesPerPixel = 4
1473
+ let bytesPerRow = width * bytesPerPixel
1474
+ let bitsPerComponent = 8
1475
+
1476
+ var pixelData = Data(count: width * height * bytesPerPixel)
1477
+
1478
+ pixelData.withUnsafeMutableBytes { bytes in
1479
+ guard
1480
+ let context = CGContext(
1481
+ data: bytes.bindMemory(to: UInt8.self).baseAddress,
1482
+ width: width,
1483
+ height: height,
1484
+ bitsPerComponent: bitsPerComponent,
1485
+ bytesPerRow: bytesPerRow,
1486
+ space: CGColorSpaceCreateDeviceRGB(),
1487
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
1488
+ )
1489
+ else { return }
1490
+
1491
+ context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
1492
+ }
1493
+
1494
+ return pixelData
1495
+ }
1496
+
1497
+ // MARK: - Glasses-native 1-bit BMP pipeline (parity with Android's decodeBitmapForNex)
1498
+
1499
+ /**
1500
+ Decode → invert → Floyd–Steinberg dither → 1-bit BMP encode, mirroring
1501
+ Android's `decodeBitmapForNex` + `BitmapJavaUtils.convertBitmapTo1BitBmpBytes`.
1502
+
1503
+ The panel is 1-bpp and renders white-on-black; the firmware BMP decoder
1504
+ normalises palette polarity, so the only way to flip the on-glass result is
1505
+ to invert the actual pixel content. Dithering preserves gradients as dot
1506
+ patterns instead of a hard 50% threshold. Output byte layout matches the
1507
+ Android encoder exactly: 14-byte file header + 40-byte BITMAPINFOHEADER +
1508
+ 8-byte palette (index 0 = white, 1 = black), rows padded to 4 bytes,
1509
+ bottom-to-top, bit set when the (post-invert, post-dither) pixel is dark.
1510
+ */
1511
+ private func convertImageToNex1BitBmp(_ image: UIImage) -> Data? {
1512
+ guard let cgImage = image.cgImage else { return nil }
1513
+ let width = cgImage.width
1514
+ let height = cgImage.height
1515
+ guard width > 0, height > 0 else { return nil }
1516
+
1517
+ // RGBA readback.
1518
+ var rgba = [UInt8](repeating: 0, count: width * height * 4)
1519
+ guard
1520
+ let context = CGContext(
1521
+ data: &rgba,
1522
+ width: width,
1523
+ height: height,
1524
+ bitsPerComponent: 8,
1525
+ bytesPerRow: width * 4,
1526
+ space: CGColorSpaceCreateDeviceRGB(),
1527
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
1528
+ )
1529
+ else { return nil }
1530
+ context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
1531
+
1532
+ // Luminance (Rec. 601), INVERTED — same order as Android (invert, then dither).
1533
+ var lum = [Float](repeating: 0, count: width * height)
1534
+ for i in 0 ..< (width * height) {
1535
+ let r = Float(rgba[i * 4])
1536
+ let g = Float(rgba[i * 4 + 1])
1537
+ let b = Float(rgba[i * 4 + 2])
1538
+ lum[i] = 255.0 - (0.299 * r + 0.587 * g + 0.114 * b)
1539
+ }
1540
+
1541
+ // Floyd–Steinberg error diffusion (7/3/5/1 over 16, right/below neighbours).
1542
+ var dark = [Bool](repeating: false, count: width * height)
1543
+ for y in 0 ..< height {
1544
+ for x in 0 ..< width {
1545
+ let idx = y * width + x
1546
+ let old = lum[idx]
1547
+ let newVal: Float = old < 128 ? 0 : 255
1548
+ let err = old - newVal
1549
+ dark[idx] = newVal < 128
1550
+ if x + 1 < width { lum[idx + 1] += err * 7 / 16 }
1551
+ if y + 1 < height {
1552
+ if x >= 1 { lum[idx + width - 1] += err * 3 / 16 }
1553
+ lum[idx + width] += err * 5 / 16
1554
+ if x + 1 < width { lum[idx + width + 1] += err * 1 / 16 }
1555
+ }
1556
+ }
1557
+ }
1558
+
1559
+ // 1-bpp BMP encode (Android layout, invert=false palette).
1560
+ let rowSizeBytes = ((width + 31) / 32) * 4
1561
+ let imageSize = rowSizeBytes * height
1562
+ let dataOffset = 62
1563
+ var bmp = Data(capacity: dataOffset + imageSize)
1564
+
1565
+ func putU16(_ v: UInt16) {
1566
+ bmp.append(UInt8(v & 0xFF)); bmp.append(UInt8(v >> 8))
1567
+ }
1568
+ func putU32(_ v: UInt32) {
1569
+ bmp.append(UInt8(v & 0xFF)); bmp.append(UInt8((v >> 8) & 0xFF))
1570
+ bmp.append(UInt8((v >> 16) & 0xFF)); bmp.append(UInt8((v >> 24) & 0xFF))
1571
+ }
1572
+
1573
+ bmp.append(UInt8(ascii: "B")); bmp.append(UInt8(ascii: "M"))
1574
+ putU32(UInt32(dataOffset + imageSize)) // file size
1575
+ putU16(0); putU16(0) // reserved
1576
+ putU32(UInt32(dataOffset)) // pixel data offset
1577
+ putU32(40) // DIB header size
1578
+ putU32(UInt32(width))
1579
+ putU32(UInt32(height)) // positive => bottom-to-top
1580
+ putU16(1) // planes
1581
+ putU16(1) // bits per pixel
1582
+ putU32(0) // BI_RGB
1583
+ putU32(UInt32(imageSize))
1584
+ putU32(2835); putU32(2835) // 72 DPI
1585
+ putU32(2) // palette colors
1586
+ putU32(0) // important colors
1587
+ // Palette: index 0 = white, index 1 = black (Android invert=false).
1588
+ bmp.append(contentsOf: [0xFF, 0xFF, 0xFF, 0x00])
1589
+ bmp.append(contentsOf: [0x00, 0x00, 0x00, 0x00])
1590
+
1591
+ var row = [UInt8](repeating: 0, count: rowSizeBytes)
1592
+ for y in 0 ..< height {
1593
+ let py = height - 1 - y // BMP rows are bottom-to-top
1594
+ for i in 0 ..< rowSizeBytes { row[i] = 0 }
1595
+ for x in 0 ..< width where dark[py * width + x] {
1596
+ row[x / 8] |= UInt8(0x80 >> (x % 8))
1597
+ }
1598
+ bmp.append(contentsOf: row)
1599
+ }
1600
+ return bmp
1601
+ }
1602
+
1603
+ // MARK: - Display Control Commands
1604
+
1605
+ @objc func clearDisplay() {
1606
+ guard nexReady else {
1607
+ Bridge.log("NEX: Not ready to clear display. Device not initialized.")
1608
+ return
1609
+ }
1610
+
1611
+ Bridge.log("NEX: Clearing display")
1612
+
1613
+ // Tear down any canvas components and forget the registry — clear_view
1614
+ // wipes the whole screen, canvas included.
1615
+ if !canvasElements.isEmpty || currentLayoutId != nil {
1616
+ canvasElements.removeAll()
1617
+ currentLayoutId = nil
1618
+ sendCanvasCommand { $0.canvasClear = Glassly_Ble_CanvasClear() }
1619
+ }
1620
+
1621
+ // Drop any pending/resendable text wall so a stale caption can't
1622
+ // repaint the display after this clear.
1623
+ textWallLock.lock()
1624
+ pendingTextWall = nil
1625
+ lastTextWall = nil
1626
+ textWallResendsRemaining = 0
1627
+ textWallLock.unlock()
1628
+
1629
+ let clearDisplay = Glassly_Ble_ClearDisplay()
1630
+
1631
+ let phoneToGlasses = Glassly_Ble_PhoneToGlasses.with {
1632
+ $0.msgID = "clear_disp_001"
1633
+ $0.clearDisplay_p = clearDisplay
1634
+ }
1635
+
1636
+ let protobufData = try! phoneToGlasses.serializedData()
1637
+ queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
1638
+ }
1639
+
1640
+ @objc func blankScreen() {
1641
+ clearDisplay()
1642
+ }
1643
+
1644
+ @objc func showHomeScreen() {
1645
+ Bridge.log("NEX: Showing home screen")
1646
+ clearDisplay()
1647
+ }
1648
+
1649
+ @objc func exitAllFunctions() {
1650
+ guard nexReady else {
1651
+ Bridge.log("NEX: Not ready to exit functions. Device not initialized.")
1652
+ return
1653
+ }
1654
+
1655
+ Bridge.log("NEX: Exiting all functions")
1656
+
1657
+ // Send exit command (0x18 from Android implementation)
1658
+ let exitCommand: [UInt8] = [0x18]
1659
+ queueChunks([exitCommand], waitTimeMs: 100)
1660
+ }
1661
+
1662
+ // MARK: - Configuration Commands
1663
+
1664
+ @objc func updateGlassesBrightness(_ brightness: Int) {
1665
+ guard nexReady else {
1666
+ Bridge.log("NEX: Not ready to update brightness. Device not initialized.")
1667
+ return
1668
+ }
1669
+
1670
+ let validBrightness: Int
1671
+ if brightness != -1 {
1672
+ validBrightness = (max(0, min(100, brightness)) * 63) / 100
1673
+ } else {
1674
+ validBrightness = (30 * 63) / 100
1675
+ }
1676
+ Bridge.log("NEX: Setting brightness to wire value \(validBrightness)")
1677
+
1678
+ let brightnessConfig = Glassly_Ble_BrightnessConfig.with {
1679
+ $0.value = UInt32(validBrightness)
1680
+ }
1681
+
1682
+ let phoneToGlasses = Glassly_Ble_PhoneToGlasses.with {
1683
+ $0.brightness = brightnessConfig
1684
+ }
1685
+
1686
+ let protobufData = try! phoneToGlasses.serializedData()
1687
+ queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
1688
+ }
1689
+
1690
+ @objc func updateGlassesAutoBrightness(_ enabled: Bool) {
1691
+ guard nexReady else {
1692
+ Bridge.log("NEX: Not ready to update auto brightness. Device not initialized.")
1693
+ return
1694
+ }
1695
+
1696
+ Bridge.log("NEX: Setting auto brightness to \(enabled)")
1697
+
1698
+ let autoBrightnessConfig = Glassly_Ble_AutoBrightnessConfig.with {
1699
+ $0.enabled = enabled
1700
+ }
1701
+
1702
+ let phoneToGlasses = Glassly_Ble_PhoneToGlasses.with {
1703
+ $0.autoBrightness = autoBrightnessConfig
1704
+ }
1705
+
1706
+ let protobufData = try! phoneToGlasses.serializedData()
1707
+ queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
1708
+ }
1709
+
1710
+ @objc func updateGlassesHeadUpAngle(_ angle: Int) {
1711
+ guard nexReady else {
1712
+ Bridge.log("NEX: Not ready to update head-up angle. Device not initialized.")
1713
+ return
1714
+ }
1715
+
1716
+ // Validate angle range (0-60 degrees)
1717
+ let validAngle = max(0, min(60, angle))
1718
+ Bridge.log("NEX: Setting head-up angle to \(validAngle) degrees")
1719
+
1720
+ let headUpAngleConfig = Glassly_Ble_HeadUpAngleConfig.with {
1721
+ $0.angle = UInt32(validAngle)
1722
+ }
1723
+
1724
+ let phoneToGlasses = Glassly_Ble_PhoneToGlasses.with {
1725
+ $0.headUpAngle = headUpAngleConfig
1726
+ }
1727
+
1728
+ let protobufData = try! phoneToGlasses.serializedData()
1729
+ queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
1730
+ }
1731
+
1732
+ @objc func updateGlassesDisplayHeight(_ height: Int) {
1733
+ guard nexReady else {
1734
+ Bridge.log("NEX: Not ready to update display height. Device not initialized.")
1735
+ return
1736
+ }
1737
+
1738
+ // Validate height range (0-8)
1739
+ let validHeight = max(0, min(8, height))
1740
+ Bridge.log("NEX: Setting display height to \(validHeight)")
1741
+
1742
+ let displayHeightConfig = Glassly_Ble_DisplayHeightConfig.with {
1743
+ $0.height = UInt32(validHeight)
1744
+ }
1745
+
1746
+ let phoneToGlasses = Glassly_Ble_PhoneToGlasses.with {
1747
+ $0.displayHeight = displayHeightConfig
1748
+ }
1749
+
1750
+ let protobufData = try! phoneToGlasses.serializedData()
1751
+ queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
1752
+ }
1753
+
1754
+ private func updateGlassesDisplayDistance(depth: Int) {
1755
+ guard nexReady else {
1756
+ Bridge.log("NEX: Not ready to update display distance. Device not initialized.")
1757
+ return
1758
+ }
1759
+
1760
+ let tier = NexDashboardDisplayWire.depthToWireTier(depth)
1761
+ Bridge.log("NEX: Setting display distance tier \(tier) in distance_cm field (dashboard depth \(depth))")
1762
+
1763
+ let displayDistanceConfig = Glassly_Ble_DisplayDistanceConfig.with {
1764
+ $0.distanceCm = tier
1765
+ }
1766
+
1767
+ let phoneToGlasses = Glassly_Ble_PhoneToGlasses.with {
1768
+ $0.displayDistance = displayDistanceConfig
1769
+ }
1770
+
1771
+ let protobufData = try! phoneToGlasses.serializedData()
1772
+ queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
1773
+ }
1774
+
1775
+ func sendVoiceActivityDetectionSetting() {
1776
+ let enabled = DeviceStore.shared.get("bluetooth", "voice_activity_detection_enabled") as? Bool
1777
+ ?? BluetoothSdkDefaults.voiceActivityDetectionEnabled
1778
+ Bridge.log("NEX: 🎤 Sending Voice Activity Detection setting to glasses: \(enabled)")
1779
+
1780
+ guard nexReady else {
1781
+ Bridge.log("NEX: Not ready to send VAD setting. Device not initialized.")
1782
+ return
1783
+ }
1784
+
1785
+ let vadConfig = Glassly_Ble_VadEnabledConfig.with {
1786
+ $0.enabled = enabled
1787
+ }
1788
+
1789
+ let phoneToGlasses = Glassly_Ble_PhoneToGlasses.with {
1790
+ $0.vadEnabled = vadConfig
1791
+ }
1792
+
1793
+ let protobufData = try! phoneToGlasses.serializedData()
1794
+ queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
1795
+ Bridge.sendVoiceActivityDetectionStatus(enabled)
1796
+ }
1797
+
1798
+ @objc func setMicrophoneEnabled(_ enabled: Bool) {
1799
+ isMicrophoneEnabled = enabled
1800
+ DeviceStore.shared.apply("glasses", "micEnabled", enabled)
1801
+
1802
+ guard nexReady else {
1803
+ Bridge.log("NEX: Not ready to set microphone state. Device not initialized.")
1804
+ return
1805
+ }
1806
+
1807
+ Bridge.log("NEX: Setting microphone enabled: \(enabled)")
1808
+
1809
+ let micStateConfig = Glassly_Ble_MicStateConfig.with {
1810
+ $0.enabled = enabled
1811
+ }
1812
+
1813
+ let phoneToGlasses = Glassly_Ble_PhoneToGlasses.with {
1814
+ $0.micState = micStateConfig
1815
+ }
1816
+
1817
+ let protobufData = try! phoneToGlasses.serializedData()
1818
+ queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
1819
+
1820
+ // Update aiListening state when microphone state changes (G1-compatible)
1821
+ if enabled, !vadActive {
1822
+ // Only set aiListening if VAD isn't already controlling it
1823
+ aiListening = enabled
1824
+ }
1825
+ }
1826
+
1827
+ /// G1-compatible alias for microphone control
1828
+ func setMicEnabled(enabled: Bool) async -> Bool {
1829
+ setMicrophoneEnabled(enabled)
1830
+ return true
1831
+ }
1832
+
1833
+ func sortMicRanking(list: [String]) -> [String] {
1834
+ return list
1835
+ }
1836
+
1837
+ // MARK: - Status Query Commands
1838
+
1839
+ @objc func queryBatteryStatus() {
1840
+ guard nexReady else {
1841
+ Bridge.log("NEX: Not ready to query battery status. Device not initialized.")
1842
+ return
1843
+ }
1844
+
1845
+ Bridge.log("NEX: Querying battery status")
1846
+
1847
+ let batteryStateRequest = Glassly_Ble_BatteryStateRequest()
1848
+
1849
+ let phoneToGlasses = Glassly_Ble_PhoneToGlasses.with {
1850
+ $0.batteryState = batteryStateRequest
1851
+ }
1852
+
1853
+ let protobufData = try! phoneToGlasses.serializedData()
1854
+ queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
1855
+ }
1856
+
1857
+ @objc func queryGlassesInfo() {
1858
+ guard nexReady else {
1859
+ Bridge.log("NEX: Not ready to query glasses info. Device not initialized.")
1860
+ return
1861
+ }
1862
+
1863
+ Bridge.log("NEX: Querying glasses information")
1864
+
1865
+ let glassesInfoRequest = Glassly_Ble_GlassesInfoRequest()
1866
+
1867
+ let phoneToGlasses = Glassly_Ble_PhoneToGlasses.with {
1868
+ $0.glassesInfo = glassesInfoRequest
1869
+ }
1870
+
1871
+ let protobufData = try! phoneToGlasses.serializedData()
1872
+ queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
1873
+ }
1874
+
1875
+ // MARK: - Utility Methods
1876
+
1877
+ @objc func isDeviceReady() -> Bool {
1878
+ nexReady && connectionState == ConnTypes.CONNECTED
1879
+ }
1880
+
1881
+ @objc func getDeviceInfo() -> [String: Any] {
1882
+ [
1883
+ "device_ready": nexReady,
1884
+ "connection_state": getConnectionState(),
1885
+ "current_mtu": currentMTU,
1886
+ "device_max_mtu": deviceMaxMTU,
1887
+ "max_chunk_size": maxChunkSize,
1888
+ "bmp_chunk_size": bmpChunkSize,
1889
+ "device_name": peripheral?.name ?? "Unknown",
1890
+ "device_id": peripheral?.identifier.uuidString ?? "Unknown",
1891
+ ]
1892
+ }
1893
+
1894
+ // MARK: - Advanced Display Methods
1895
+
1896
+ @objc func displayCustomContent(_ content: String) {
1897
+ // For now, treat custom content as regular text
1898
+ Task { await sendTextWall(content) }
1899
+ }
1900
+
1901
+ @objc func setUpdatingScreen(_ updating: Bool) {
1902
+ Bridge.log("NEX: Set updating screen: \(updating)")
1903
+ // This could be used to prevent display updates during certain operations
1904
+ // Implementation depends on specific requirements
1905
+ }
1906
+
1907
+ // MARK: - Data Processing and Event Listeners
1908
+
1909
+ private func processReceivedData(_ data: Data) {
1910
+ guard data.count > 0 else { return }
1911
+
1912
+ let packetType = data[0]
1913
+ Bridge.log("NEX: Processing packet type: 0x\(String(format: "%02X", packetType))")
1914
+
1915
+ switch packetType {
1916
+ case PACKET_TYPE_JSON:
1917
+ if data.count > 1 {
1918
+ let jsonData = data.subdata(in: 1 ..< data.count)
1919
+ processJsonData(jsonData)
1920
+ }
1921
+
1922
+ case PACKET_TYPE_PROTOBUF:
1923
+ if data.count > 1 {
1924
+ let protobufData = data.subdata(in: 1 ..< data.count)
1925
+ processProtobufData(protobufData)
1926
+ }
1927
+
1928
+ case PACKET_TYPE_AUDIO:
1929
+ if data.count > 2 {
1930
+ let sequenceNumber = data[1]
1931
+ let audioData = data.subdata(in: 2 ..< data.count)
1932
+ processAudioData(audioData, sequenceNumber: sequenceNumber)
1933
+ }
1934
+
1935
+ case PACKET_TYPE_IMAGE:
1936
+ processImageData(data)
1937
+
1938
+ default:
1939
+ Bridge.log("NEX: Unknown packet type: 0x\(String(format: "%02X", packetType))")
1940
+ }
1941
+ }
1942
+
1943
+ private func processJsonData(_ jsonData: Data) {
1944
+ guard let jsonString = String(data: jsonData, encoding: .utf8) else {
1945
+ Bridge.log("NEX: Failed to decode JSON data")
1946
+ return
1947
+ }
1948
+
1949
+ Bridge.log("NEX: Processing JSON: \(jsonString)")
1950
+
1951
+ do {
1952
+ guard let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
1953
+ let type = json["type"] as? String
1954
+ else {
1955
+ return
1956
+ }
1957
+
1958
+ switch type {
1959
+ case "battery_status":
1960
+ handleBatteryStatusJson(json)
1961
+ case "device_info":
1962
+ handleDeviceInfoJson(json)
1963
+ case "button_event":
1964
+ handleButtonEventJson(json)
1965
+ case "vad_event":
1966
+ handleVadEventJson(json)
1967
+ case "imu_data":
1968
+ handleImuDataJson(json)
1969
+ case "head_gesture":
1970
+ handleHeadGestureJson(json)
1971
+ default:
1972
+ Bridge.log("NEX: Unhandled JSON type: \(type)")
1973
+ }
1974
+ } catch {
1975
+ Bridge.log("NEX: Error parsing JSON: \(error)")
1976
+ }
1977
+ }
1978
+
1979
+ private func processProtobufData(_ protobufData: Data) {
1980
+ do {
1981
+ let glassesToPhone = try Glassly_Ble_GlassesToPhone(serializedData: protobufData)
1982
+ // No per-message log: String(describing: payload) stringifies the whole
1983
+ // protobuf and every Bridge.log costs the JS thread.
1984
+ // Bridge.log("NEX: Processing protobuf payload case: \(glassesToPhone.payload)")
1985
+
1986
+ let fullPacket = Data([PACKET_TYPE_PROTOBUF]) + protobufData
1987
+ emitBleCommandReceived(fullPacket, payloadDescription: String(describing: glassesToPhone.payload))
1988
+
1989
+ switch glassesToPhone.payload {
1990
+ case let .canvasResult(canvasResult):
1991
+ // Ack for CanvasCreateComponent / CanvasClear (updates are
1992
+ // unacked). Non-OK (INVALID / OVERSIZE / OOM) invalidates the
1993
+ // registry entry so the next frame recreates the component —
1994
+ // and a silent blank screen becomes a diagnosable log line.
1995
+ if canvasResult.code != .ok {
1996
+ Bridge.log("NEX: CANVAS_RESULT id=\(canvasResult.id) code=\(canvasResult.code) — dropping registry entry for recreate")
1997
+ canvasElements.removeAll { $0.value.firmwareId == canvasResult.id }
1998
+ }
1999
+
2000
+ case let .batteryStatus(batteryStatus):
2001
+ handleBatteryStatusProtobuf(batteryStatus)
2002
+
2003
+ case let .chargingState(chargingState):
2004
+ handleChargingStateProtobuf(chargingState)
2005
+
2006
+ case let .deviceInfo(deviceInfo):
2007
+ handleDeviceInfoProtobuf(deviceInfo)
2008
+
2009
+ case let .headPosition(headPosition):
2010
+ handleHeadPositionProtobuf(headPosition)
2011
+
2012
+ case let .headUpAngleSet(headUpAngleResponse):
2013
+ handleHeadUpAngleResponseProtobuf(headUpAngleResponse)
2014
+
2015
+ case let .vadEvent(vadEvent):
2016
+ handleVadEventProtobuf(vadEvent)
2017
+
2018
+ case let .imageTransferComplete(transferComplete):
2019
+ handleImageTransferCompleteProtobuf(transferComplete)
2020
+
2021
+ case let .imuData(imuData):
2022
+ handleImuDataProtobuf(imuData)
2023
+
2024
+ case let .buttonEvent(buttonEvent):
2025
+ handleButtonEventProtobuf(buttonEvent)
2026
+
2027
+ case let .headGesture(headGesture):
2028
+ handleHeadGestureProtobuf(headGesture)
2029
+
2030
+ // Note: VersionResponse not available in current protobuf structure
2031
+
2032
+ case .none:
2033
+ Bridge.log("NEX: Protobuf payload not set")
2034
+
2035
+ default:
2036
+ Bridge.log("NEX: Unhandled protobuf payload type")
2037
+ }
2038
+
2039
+ } catch {
2040
+ Bridge.log("NEX: Error parsing protobuf data: \(error)")
2041
+ }
2042
+ }
2043
+
2044
+ private func processAudioData(_ audioData: Data, sequenceNumber: UInt8) {
2045
+ // No per-packet log: fires 20x/sec while the mic streams; each Bridge.log
2046
+ // costs the JS thread. Keep only the sequence-mismatch log below (rare,
2047
+ // fires on actual packet loss).
2048
+ if lastReceivedLc3Sequence != -1, UInt8((lastReceivedLc3Sequence + 1) & 0xFF) != sequenceNumber {
2049
+ Bridge.log("NEX: LC3 packet sequence mismatch. Expected \((lastReceivedLc3Sequence + 1) & 0xFF), got \(sequenceNumber)")
2050
+ }
2051
+ lastReceivedLc3Sequence = Int(sequenceNumber)
2052
+
2053
+ // Update @Published property (G1-compatible approach)
2054
+ // Create packet with sequence number prefix like G1 expects
2055
+ var packetData = Data()
2056
+ packetData.append(sequenceNumber)
2057
+ packetData.append(audioData)
2058
+
2059
+ compressedVoiceData = packetData
2060
+ DeviceManager.shared.handleGlassesMicData(audioData, 40)
2061
+ }
2062
+
2063
+ private func processImageData(_ imageData: Data) {
2064
+ Bridge.log("NEX: Received image data: \(imageData.count) bytes")
2065
+ // Image data processing can be implemented based on specific requirements
2066
+ }
2067
+
2068
+ // MARK: - Protobuf Event Handlers
2069
+
2070
+ private func handleBatteryStatusProtobuf(_ batteryStatus: Glassly_Ble_BatteryStatus) {
2071
+ let level = Int(batteryStatus.level)
2072
+ let isCharging = batteryStatus.charging
2073
+
2074
+ Bridge.log("NEX: 🔋 Battery Status - Level: \(level)%, Charging: \(isCharging)")
2075
+
2076
+ // Update @Published properties (G1-compatible approach)
2077
+ DeviceStore.shared.apply("glasses", "batteryLevel", level)
2078
+ DeviceStore.shared.apply("glasses", "charging", isCharging)
2079
+ }
2080
+
2081
+ private func handleChargingStateProtobuf(_ chargingState: Glassly_Ble_ChargingState) {
2082
+ let chargingState = chargingState.state == .charging
2083
+
2084
+ Bridge.log("NEX: 🔌 Charging State: \(chargingState ? "CHARGING" : "NOT_CHARGING")")
2085
+
2086
+ // Update @Published property (G1-compatible approach)
2087
+ DeviceStore.shared.apply("glasses", "charging", chargingState)
2088
+ }
2089
+
2090
+ private func handleDeviceInfoProtobuf(_ deviceInfo: Glassly_Ble_DeviceInfo) {
2091
+ Bridge.log("NEX: 📱 Device Info: \(deviceInfo)")
2092
+
2093
+ // Update @Published properties (G1-compatible approach)
2094
+ DeviceStore.shared.apply("glasses", "deviceFirmwareVersion", deviceInfo.fwVersion)
2095
+ DeviceStore.shared.apply("glasses", "deviceHardwareModel", deviceInfo.hwModel)
2096
+ }
2097
+
2098
+ private func handleHeadPositionProtobuf(_ headPosition: Glassly_Ble_HeadPosition) {
2099
+ let angle = Int(headPosition.angle)
2100
+
2101
+ Bridge.log("NEX: 📐 Head Position - Angle: \(angle)°")
2102
+
2103
+ // Update @Published property (G1-compatible approach)
2104
+ headUpAngle = angle
2105
+ }
2106
+
2107
+ private func handleHeadUpAngleResponseProtobuf(_ response: Glassly_Ble_HeadUpAngleResponse) {
2108
+ let success = response.success
2109
+
2110
+ Bridge.log("NEX: 📐 Head Up Angle Set Response - Success: \(success)")
2111
+
2112
+ // Emit response event
2113
+ let eventBody: [String: Any] = [
2114
+ "head_up_angle_set_result": success,
2115
+ "device_model": "Mentra Display",
2116
+ "timestamp": Date().timeIntervalSince1970 * 1000,
2117
+ ]
2118
+
2119
+ emitEvent("HeadUpAngleResponseEvent", body: eventBody)
2120
+ }
2121
+
2122
+ private func handleVadEventProtobuf(_ vadEvent: Glassly_Ble_VadEvent) {
2123
+ let vadActiveState = vadEvent.state == .active
2124
+
2125
+ Bridge.log("NEX: 🎤 VAD Event - Voice Activity: \(vadActiveState)")
2126
+
2127
+ // Update @Published properties (G1-compatible approach)
2128
+ vadActive = vadActiveState
2129
+ aiListening = vadActiveState // Mirror G1's aiListening behavior
2130
+ }
2131
+
2132
+ private func handleImageTransferCompleteProtobuf(
2133
+ _ transferComplete: Glassly_Ble_ImageTransferComplete
2134
+ ) {
2135
+ let status = transferComplete.status
2136
+ let missingChunks = transferComplete.missingChunks
2137
+
2138
+ Bridge.log("NEX: 🖼️ Image Transfer Complete - Status: \(status)")
2139
+
2140
+ switch status {
2141
+ case .ok:
2142
+ Bridge.log("NEX: Image transfer completed successfully")
2143
+ currentImageChunks.removeAll()
2144
+ isImageSendProgressing = false
2145
+
2146
+ case .incomplete:
2147
+ Bridge.log("NEX: Image transfer incomplete - Missing chunks: \(missingChunks)")
2148
+ resendImageMissingChunks(missingChunks)
2149
+
2150
+ default:
2151
+ Bridge.log("NEX: Unknown image transfer status")
2152
+ }
2153
+
2154
+ // Emit image transfer complete event
2155
+ let eventBody: [String: Any] = [
2156
+ "image_transfer_complete": [
2157
+ "status": status == .ok ? "success" : "incomplete",
2158
+ "missing_chunks": missingChunks,
2159
+ "timestamp": Date().timeIntervalSince1970 * 1000,
2160
+ ],
2161
+ ]
2162
+
2163
+ emitEvent("ImageTransferCompleteEvent", body: eventBody)
2164
+ }
2165
+
2166
+ private func resendImageMissingChunks(_ missingChunks: [UInt32]) {
2167
+ guard isImageSendProgressing, !currentImageChunks.isEmpty, !missingChunks.isEmpty else {
2168
+ return
2169
+ }
2170
+ let retransmit = missingChunks.compactMap { index -> [UInt8]? in
2171
+ let i = Int(index)
2172
+ guard i >= 0, i < currentImageChunks.count else { return nil }
2173
+ return currentImageChunks[i]
2174
+ }
2175
+ if !retransmit.isEmpty {
2176
+ Bridge.log("NEX: Resending \(retransmit.count) missing image chunks")
2177
+ queueChunks(retransmit)
2178
+ }
2179
+ }
2180
+
2181
+ private func handleImuDataProtobuf(_ imuData: Glassly_Ble_ImuData) {
2182
+ Bridge.log("NEX: 📊 IMU Data: \(imuData)")
2183
+
2184
+ // Update @Published properties (G1-compatible approach)
2185
+ accelerometer = [imuData.accel.x, imuData.accel.y, imuData.accel.z]
2186
+ gyroscope = [imuData.gyro.x, imuData.gyro.y, imuData.gyro.z]
2187
+ magnetometer = [imuData.mag.x, imuData.mag.y, imuData.mag.z]
2188
+ }
2189
+
2190
+ private func handleButtonEventProtobuf(_ buttonEvent: Glassly_Ble_ButtonEvent) {
2191
+ let buttonNumber = Int(buttonEvent.button.rawValue)
2192
+ let buttonState = buttonEvent.state
2193
+
2194
+ Bridge.log("NEX: 🔘 Button Event - Button: \(buttonNumber), State: \(buttonState)")
2195
+
2196
+ // Update @Published properties (G1-compatible approach)
2197
+ lastButtonPressed = buttonNumber
2198
+ lastButtonState = "\(buttonState.rawValue)"
2199
+ }
2200
+
2201
+ private func handleHeadGestureProtobuf(_ headGesture: Glassly_Ble_HeadGesture) {
2202
+ let gestureType = headGesture.gesture
2203
+
2204
+ Bridge.log("NEX: 👤 Head Gesture: \(gestureType)")
2205
+
2206
+ // Update @Published properties (G1-compatible approach)
2207
+ switch gestureType {
2208
+ case .headUp:
2209
+ DeviceStore.shared.apply("glasses", "headUp", true)
2210
+ lastHeadGesture = "headUp"
2211
+ case .nod:
2212
+ lastHeadGesture = "nod"
2213
+ case .shake:
2214
+ lastHeadGesture = "shake"
2215
+ default:
2216
+ Bridge.log("NEX: Unknown head gesture type: \(gestureType)")
2217
+ lastHeadGesture = "unknown"
2218
+ }
2219
+ }
2220
+
2221
+ // MARK: - JSON Event Handlers
2222
+
2223
+ private func handleBatteryStatusJson(_ json: [String: Any]) {
2224
+ let level = json["level"] as? Int ?? -1
2225
+ let isCharging = json["charging"] as? Bool ?? false
2226
+
2227
+ Bridge.log("NEX: 🔋 JSON Battery Status - Level: \(level)%, Charging: \(isCharging)")
2228
+
2229
+ // Update @Published properties (G1-compatible approach)
2230
+ DeviceStore.shared.apply("glasses", "batteryLevel", level)
2231
+ DeviceStore.shared.apply("glasses", "charging", isCharging)
2232
+ }
2233
+
2234
+ private func handleDeviceInfoJson(_ json: [String: Any]) {
2235
+ Bridge.log("NEX: 📱 JSON Device Info: \(json)")
2236
+
2237
+ let eventBody: [String: Any] = [
2238
+ "device_info": json,
2239
+ "timestamp": Date().timeIntervalSince1970 * 1000,
2240
+ ]
2241
+
2242
+ emitEvent("DeviceInfoEvent", body: eventBody)
2243
+ }
2244
+
2245
+ private func handleButtonEventJson(_ json: [String: Any]) {
2246
+ let buttonId = json["button_id"] as? String ?? "unknown"
2247
+ let pressType = json["press_type"] as? String ?? "short"
2248
+
2249
+ Bridge.log("NEX: 🔘 JSON Button Event - Button: \(buttonId), Type: \(pressType)")
2250
+
2251
+ let eventBody: [String: Any] = [
2252
+ "button_press": [
2253
+ "device_model": "Mentra Display",
2254
+ "button_id": buttonId,
2255
+ "press_type": pressType,
2256
+ "timestamp": Date().timeIntervalSince1970 * 1000,
2257
+ ],
2258
+ ]
2259
+
2260
+ emitEvent("ButtonPressEvent", body: eventBody)
2261
+ }
2262
+
2263
+ private func handleVadEventJson(_ json: [String: Any]) {
2264
+ let vadActiveState = json["vad"] as? Bool ?? false
2265
+
2266
+ Bridge.log("NEX: 🎤 JSON VAD Event - Voice Activity: \(vadActiveState)")
2267
+
2268
+ // Update @Published properties (G1-compatible approach)
2269
+ vadActive = vadActiveState
2270
+ aiListening = vadActiveState // Mirror G1's aiListening behavior
2271
+ }
2272
+
2273
+ private func handleImuDataJson(_ json: [String: Any]) {
2274
+ Bridge.log("NEX: 📊 JSON IMU Data: \(json)")
2275
+
2276
+ let eventBody: [String: Any] = [
2277
+ "imu_data": json,
2278
+ "timestamp": Date().timeIntervalSince1970 * 1000,
2279
+ ]
2280
+
2281
+ emitEvent("ImuDataEvent", body: eventBody)
2282
+ }
2283
+
2284
+ private func handleHeadGestureJson(_ json: [String: Any]) {
2285
+ let gesture = json["gesture"] as? String ?? "unknown"
2286
+
2287
+ Bridge.log("NEX: 👤 JSON Head Gesture: \(gesture)")
2288
+
2289
+ let eventBody: [String: Any] = [
2290
+ "head_gesture": [
2291
+ "gesture": gesture,
2292
+ "timestamp": Date().timeIntervalSince1970 * 1000,
2293
+ ],
2294
+ ]
2295
+
2296
+ emitEvent("HeadGestureEvent", body: eventBody)
2297
+ }
2298
+
2299
+ // MARK: - Event Emission Helper
2300
+
2301
+ private func emitEvent(_ eventName: String, body: [String: Any]) {
2302
+ // Use the standardized Bridge.sendTypedMessage helper for consistent type field handling
2303
+ Bridge.sendTypedMessage(eventName, body: body)
2304
+ Bridge.log("NEX: 📡 Emitted \(eventName) via Bridge.sendTypedMessage")
2305
+ }
2306
+
2307
+ // MARK: - Heartbeat Management
2308
+
2309
+ private func notifyHeartbeatSent(_ timestamp: TimeInterval) {
2310
+ lastHeartbeatSentTime = timestamp
2311
+ Bridge.sendTypedMessage("heartbeat_sent", body: [
2312
+ "timestamp": timestamp,
2313
+ ])
2314
+ }
2315
+
2316
+ private func notifyHeartbeatReceived(_ timestamp: TimeInterval) {
2317
+ lastHeartbeatReceivedTime = timestamp
2318
+ Bridge.sendTypedMessage("heartbeat_received", body: [
2319
+ "timestamp": timestamp,
2320
+ ])
2321
+ }
2322
+
2323
+ @objc func getLastHeartbeatSentTime() -> TimeInterval {
2324
+ lastHeartbeatSentTime
2325
+ }
2326
+
2327
+ @objc func getLastHeartbeatReceivedTime() -> TimeInterval {
2328
+ lastHeartbeatReceivedTime
2329
+ }
2330
+
2331
+ // MARK: - Java-Compatible Initialization Methods
2332
+
2333
+ private func startMicBeat() {
2334
+ Bridge.log("NEX: Starting micbeat (30 min interval)")
2335
+
2336
+ if micBeatCount > 0 {
2337
+ stopMicBeat()
2338
+ }
2339
+
2340
+ sendSetMicEnabled(true, delaySeconds: 0.01)
2341
+ micBeatCount += 1
2342
+
2343
+ // Schedule periodic mic beat (like Java lines 1753-1762)
2344
+ micBeatTimer = Timer.scheduledTimer(withTimeInterval: MICBEAT_INTERVAL_MS, repeats: true) {
2345
+ [weak self] _ in
2346
+ guard let self else { return }
2347
+ Bridge.log("NEX: SENDING MIC BEAT")
2348
+ self.sendSetMicEnabled(self.shouldUseGlassesMic, delaySeconds: 0.001)
2349
+ }
2350
+ }
2351
+
2352
+ private func stopMicBeat() {
2353
+ sendSetMicEnabled(false, delaySeconds: 0.01)
2354
+ micBeatTimer?.invalidate()
2355
+ micBeatTimer = nil
2356
+ micBeatCount = 0
2357
+ Bridge.log("NEX: Stopped mic beat")
2358
+ }
2359
+
2360
+ private func sendSetMicEnabled(_ enabled: Bool, delaySeconds: TimeInterval) {
2361
+ DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
2362
+ self?.setMicrophoneEnabled(enabled)
2363
+ }
2364
+ }
2365
+
2366
+ private func sendWhiteListCommand() {
2367
+ guard !whiteListedAlready else {
2368
+ Bridge.log("NEX: Whitelist already sent, skipping")
2369
+ return
2370
+ }
2371
+ whiteListedAlready = true
2372
+
2373
+ Bridge.log("NEX: Sending whitelist command")
2374
+
2375
+ // Create whitelist JSON exactly like Java (lines 2642-2680)
2376
+ let whitelistJson = createWhitelistJson()
2377
+ let chunks = createWhitelistChunks(json: whitelistJson)
2378
+
2379
+ // Send chunks with delay like Java
2380
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { // 10ms delay
2381
+ self.queueChunks(chunks)
2382
+ }
2383
+ }
2384
+
2385
+ private func createWhitelistJson() -> String {
2386
+ // Exact JSON structure from Java implementation (lines 2653-2680)
2387
+ let whitelistDict: [String: Any] = [
2388
+ "calendar_enable": false,
2389
+ "call_enable": false,
2390
+ "msg_enable": false,
2391
+ "ios_mail_enable": false,
2392
+ "app": [
2393
+ "list": [
2394
+ ["id": "com.augment.os", "name": "AugmentOS"],
2395
+ ],
2396
+ "enable": true,
2397
+ ],
2398
+ ]
2399
+
2400
+ do {
2401
+ let jsonData = try JSONSerialization.data(withJSONObject: whitelistDict)
2402
+ if let jsonString = String(data: jsonData, encoding: .utf8) {
2403
+ Bridge.log("NEX: Created whitelist JSON: \(jsonString)")
2404
+ return jsonString
2405
+ }
2406
+ } catch {
2407
+ Bridge.log("NEX: Error creating whitelist JSON: \(error)")
2408
+ }
2409
+
2410
+ return "{}"
2411
+ }
2412
+
2413
+ private func createWhitelistChunks(json: String) -> [[UInt8]] {
2414
+ // Exact chunking logic from Java (lines 2703-2728)
2415
+ guard let jsonData = json.data(using: .utf8) else { return [] }
2416
+
2417
+ let totalChunks = Int(ceil(Double(jsonData.count) / Double(maxChunkSize)))
2418
+ var chunks: [[UInt8]] = []
2419
+
2420
+ for i in 0 ..< totalChunks {
2421
+ let start = i * maxChunkSize
2422
+ let end = min(start + maxChunkSize, jsonData.count)
2423
+ let payloadChunk = jsonData.subdata(in: start ..< end)
2424
+
2425
+ // Create header: [WHITELIST_CMD, total_chunks, chunk_index] (Java lines 2714-2717)
2426
+ var header: [UInt8] = [
2427
+ WHITELIST_CMD, // Command ID (0x04)
2428
+ UInt8(totalChunks), // Total number of chunks
2429
+ UInt8(i), // Current chunk index
2430
+ ]
2431
+
2432
+ // Combine header and payload (Java lines 2720-2725)
2433
+ header.append(contentsOf: payloadChunk)
2434
+ chunks.append(header)
2435
+ }
2436
+
2437
+ Bridge.log("NEX: Created \(chunks.count) whitelist chunks")
2438
+ return chunks
2439
+ }
2440
+
2441
+ private func postProtobufSchemaVersionInfo() {
2442
+ guard !protobufVersionPosted else {
2443
+ Bridge.log("NEX: Protobuf version already posted, skipping")
2444
+ return
2445
+ }
2446
+ protobufVersionPosted = true
2447
+
2448
+ Bridge.log("NEX: 📋 Posting protobuf schema version info")
2449
+
2450
+ // Emit protobuf schema version event like Java (lines 3709-3728)
2451
+ let eventBody: [String: Any] = [
2452
+ "protobuf_schema_version": [
2453
+ "schema_version": 1, // Default version
2454
+ "build_info": "Schema v1 | glassly_ble.proto",
2455
+ "device_model": "Mentra Display",
2456
+ ],
2457
+ ]
2458
+
2459
+ // emitEvent("ProtobufSchemaVersionEvent", body: eventBody)
2460
+ }
2461
+
2462
+ /// Save microphone state before disconnection (like Java implementation)
2463
+ private func saveMicrophoneStateBeforeDisconnection() {
2464
+ UserDefaults.standard.set(isMicrophoneEnabled, forKey: "microphoneStateBeforeDisconnection")
2465
+ microphoneStateBeforeDisconnection = isMicrophoneEnabled
2466
+ Bridge.log("NEX: Saved microphone state before disconnection: \(isMicrophoneEnabled)")
2467
+ }
2468
+
2469
+ @objc func disconnect() {
2470
+ Bridge.log("NEX: 🔌 User-initiated disconnect")
2471
+ // Light teardown: drop the link but stay able to reconnect.
2472
+ sendIntentionalDisconnectThen { [weak self] in self?.finalizeDisconnect() }
2473
+ }
2474
+
2475
+ /// Best-effort: tell the glasses this disconnect is intentional so they return to
2476
+ /// the welcome screen immediately rather than holding the last frame through the
2477
+ /// firmware's unexpected-disconnect grace period, then run `teardown` after a short
2478
+ /// window to let the write flush. Falls straight through if nothing is connected.
2479
+ private func sendIntentionalDisconnectThen(_ teardown: @escaping () -> Void) {
2480
+ isDisconnecting = true
2481
+ stopReconnectionTimer()
2482
+ guard peripheral != nil, servicesReady else {
2483
+ teardown()
2484
+ return
2485
+ }
2486
+ sendDisconnectRequest()
2487
+ MentraNexSGC._bluetoothQueue.asyncAfter(deadline: .now() + 0.25, execute: teardown)
2488
+ }
2489
+
2490
+ private func sendDisconnectRequest() {
2491
+ let phoneToGlasses = Glassly_Ble_PhoneToGlasses.with {
2492
+ $0.disconnect = Glassly_Ble_DisconnectRequest()
2493
+ }
2494
+ guard let protobufData = try? phoneToGlasses.serializedData() else {
2495
+ Bridge.log("NEX: ⚠️ Failed to serialize DisconnectRequest")
2496
+ return
2497
+ }
2498
+ Bridge.log("NEX: 📤 Sending DisconnectRequest before teardown")
2499
+ queueDataWithOptimalChunking(protobufData, packetType: PACKET_TYPE_PROTOBUF)
2500
+ }
2501
+
2502
+ private func finalizeDisconnect() {
2503
+ if let peripheral {
2504
+ // Save microphone state before disconnection (like Java implementation)
2505
+ saveMicrophoneStateBeforeDisconnection()
2506
+
2507
+ // Stop mic beat system
2508
+ stopMicBeat()
2509
+
2510
+ connectionState = ConnTypes.DISCONNECTED
2511
+ centralManager?.cancelPeripheralConnection(peripheral)
2512
+ }
2513
+ setServicesReady(false)
2514
+ releaseServiceWaiters()
2515
+ Task { await commandQueue.clear() }
2516
+ resumePendingWrite()
2517
+ resumePendingAck()
2518
+ stopReconnectionTimer()
2519
+ stopTextWallDrain()
2520
+ }
2521
+
2522
+ // MARK: - Lifecycle Management (ported from Java)
2523
+
2524
+ @objc func destroy() {
2525
+ // Route through the shared path so forget()/cleanup() also signal an
2526
+ // intentional disconnect to the glasses before the link goes down.
2527
+ sendIntentionalDisconnectThen { [weak self] in self?.performDestroy() }
2528
+ }
2529
+
2530
+ private func performDestroy() {
2531
+ Bridge.log("NEX: 💥 Destroying MentraNexSGC instance")
2532
+
2533
+ isKilled = true
2534
+ isDisconnecting = true
2535
+
2536
+ // Stop all timers
2537
+ // Save microphone state before destruction (like Java implementation)
2538
+ saveMicrophoneStateBeforeDisconnection()
2539
+
2540
+ // Stop mic beat system (like Java implementation)
2541
+ stopMicBeat()
2542
+
2543
+ stopReconnectionTimer()
2544
+
2545
+ // Disconnect from peripheral
2546
+ if let peripheral {
2547
+ centralManager?.cancelPeripheralConnection(peripheral)
2548
+ }
2549
+
2550
+ // Stop scanning
2551
+ if _isScanning {
2552
+ stopScan()
2553
+ }
2554
+
2555
+ // Clear all references
2556
+ peripheral = nil
2557
+ writeCharacteristic = nil
2558
+ notifyCharacteristic = nil
2559
+ centralManager?.delegate = nil
2560
+ centralManager = nil
2561
+
2562
+ // Clear discovery cache
2563
+ discoveredPeripherals.removeAll()
2564
+ setServicesReady(false)
2565
+ releaseServiceWaiters()
2566
+ Task { await commandQueue.clear() }
2567
+ resumePendingWrite()
2568
+ resumePendingAck()
2569
+
2570
+ Bridge.log("NEX: ✅ MentraNexSGC destroyed successfully")
2571
+ // Reset initialization flags
2572
+ whiteListedAlready = false
2573
+ protobufVersionPosted = false
2574
+ currentImageChunks.removeAll()
2575
+ isImageSendProgressing = false
2576
+ currentMTU = MTU_DEFAULT
2577
+ deviceMaxMTU = MTU_DEFAULT
2578
+ maxChunkSize = MTU_DEFAULT - 10
2579
+ bmpChunkSize = MTU_DEFAULT - 20
2580
+ updateConnectedState(isConnected: false)
2581
+ }
2582
+
2583
+ @objc func reset() {
2584
+ Bridge.log("NEX: 🔄 Resetting MentraNexSGC to fresh state")
2585
+
2586
+ // Disconnect current connection
2587
+ disconnect()
2588
+
2589
+ // Clear all saved device information
2590
+ clearSavedDeviceInfo()
2591
+
2592
+ // Clear discovery cache
2593
+ discoveredPeripherals.removeAll()
2594
+
2595
+ // Reset internal state
2596
+ isKilled = false
2597
+ isDisconnecting = false
2598
+ nexReady = false
2599
+ reconnectionAttempts = 0
2600
+ peripheralToConnectName = nil
2601
+
2602
+ Bridge.log("NEX: ✅ Reset complete - ready for fresh pairing")
2603
+ // Reset initialization flags (like Java implementation)
2604
+ whiteListedAlready = false
2605
+ protobufVersionPosted = false
2606
+ heartbeatCount = 0
2607
+ micBeatCount = 0
2608
+ shouldUseGlassesMic = true
2609
+ microphoneStateBeforeDisconnection = false
2610
+ currentImageChunks.removeAll()
2611
+ isImageSendProgressing = false
2612
+ updateConnectedState(isConnected: false)
2613
+ }
2614
+
2615
+ // MARK: - Helper Methods (like G1)
2616
+
2617
+ private func getConnectedDevices() -> [CBPeripheral] {
2618
+ guard let centralManager else { return [] }
2619
+ // Retrieve peripherals already connected that expose our main service
2620
+ return centralManager.retrieveConnectedPeripherals(withServices: [])
2621
+ }
2622
+
2623
+ private func emitDiscoveredDevice(_ name: String) {
2624
+ // Emit device discovery event using standardized typed message function
2625
+ Bridge.log("NEX: 📡 Emitting discovered device: \(name)")
2626
+ Bridge.sendDiscoveredDevice(DeviceTypes.NEX, name)
2627
+ }
2628
+
2629
+ private func updateConnectedState(isConnected: Bool) {
2630
+ connectionState = isConnected ? ConnTypes.CONNECTED : ConnTypes.DISCONNECTED
2631
+ DeviceStore.shared.apply("glasses", "connected", isConnected)
2632
+ DeviceStore.shared.apply("glasses", "fullyBooted", isConnected)
2633
+ DeviceStore.shared.apply("glasses", "connectionState", isConnected ? ConnTypes.CONNECTED : ConnTypes.DISCONNECTED)
2634
+ }
2635
+
2636
+ @objc func checkBluetoothState() {
2637
+ Bridge.log("NEX: 🔍 Checking Bluetooth State...")
2638
+ if let centralManager {
2639
+ Bridge.log("NEX: 📱 Central Manager exists: YES")
2640
+ Bridge.log("NEX: 📱 Current Bluetooth State: \(centralManager.state.rawValue)")
2641
+
2642
+ switch centralManager.state {
2643
+ case .poweredOn:
2644
+ Bridge.log("NEX: ✅ Bluetooth is ready for scanning")
2645
+
2646
+ if let savedDeviceName = UserDefaults.standard.string(forKey: PREFS_DEVICE_NAME),
2647
+ !savedDeviceName.isEmpty
2648
+ {
2649
+ Bridge.log("NEX: 🔄 Looking for saved device: \(savedDeviceName)")
2650
+ // This will be handled in didDiscover when the device is found
2651
+ startScan()
2652
+ }
2653
+ case .poweredOff:
2654
+ Bridge.log("NEX: ❌ Bluetooth is turned off")
2655
+ case .resetting:
2656
+ Bridge.log("NEX: 🔄 Bluetooth is resetting")
2657
+ case .unauthorized:
2658
+ Bridge.log("NEX: ❌ Bluetooth permission denied")
2659
+ case .unsupported:
2660
+ Bridge.log("NEX: ❌ Bluetooth not supported")
2661
+ case .unknown:
2662
+ Bridge.log("NEX: ❓ Bluetooth state unknown")
2663
+ @unknown default:
2664
+ Bridge.log("NEX: ❓ Unknown Bluetooth state: \(centralManager.state.rawValue)")
2665
+ }
2666
+ } else {
2667
+ Bridge.log("NEX: ❌ Central Manager is nil!")
2668
+ }
2669
+ }
2670
+
2671
+ // MARK: - CBCentralManagerDelegate
2672
+
2673
+ func centralManagerDidUpdateState(_ central: CBCentralManager) {
2674
+ Bridge.log("NEX: 🔄 Bluetooth state changed to: \(central.state.rawValue)")
2675
+
2676
+ switch central.state {
2677
+ case .poweredOn:
2678
+ Bridge.log("NEX: ✅ Bluetooth is On and ready for scanning")
2679
+ if scanOnPowerOn || peripheralToConnectName != nil {
2680
+ Bridge.log("NEX: 🚀 Triggering scan after power on.")
2681
+ scanOnPowerOn = false
2682
+ startScan()
2683
+ }
2684
+ case .poweredOff:
2685
+ Bridge.log("NEX: ❌ Bluetooth is Off - user needs to enable Bluetooth")
2686
+ connectionState = ConnTypes.DISCONNECTED
2687
+ case .resetting:
2688
+ Bridge.log("NEX: 🔄 Bluetooth is resetting - wait for completion")
2689
+ connectionState = ConnTypes.DISCONNECTED
2690
+ case .unauthorized:
2691
+ Bridge.log("NEX: ❌ Bluetooth is unauthorized - check app permissions")
2692
+ connectionState = ConnTypes.DISCONNECTED
2693
+ case .unsupported:
2694
+ Bridge.log("NEX: ❌ Bluetooth is unsupported on this device")
2695
+ connectionState = ConnTypes.DISCONNECTED
2696
+ case .unknown:
2697
+ Bridge.log("NEX: ❓ Bluetooth state is unknown - may be initializing")
2698
+ @unknown default:
2699
+ Bridge.log("NEX: ❓ A new Bluetooth state was introduced: \(central.state.rawValue)")
2700
+ }
2701
+ }
2702
+
2703
+ func centralManager(
2704
+ _: CBCentralManager, didDiscover peripheral: CBPeripheral,
2705
+ advertisementData _: [String: Any], rssi RSSI: NSNumber
2706
+ ) {
2707
+ guard let deviceName = peripheral.name else {
2708
+ // Bridge.log("NEX-CONN: 🚫 Ignoring device with no name")
2709
+ return
2710
+ }
2711
+
2712
+ guard isCompatibleNexDevice(deviceName) else {
2713
+ return
2714
+ }
2715
+
2716
+ Bridge.log("NEX-CONN: 🎯 === Compatible Nex Device Found ===")
2717
+ Bridge.log("NEX-CONN: 📱 Device Name: \(deviceName)")
2718
+ Bridge.log("NEX-CONN: 📶 RSSI: \(RSSI) dBm")
2719
+
2720
+ // Store the peripheral in cache (like MentraLive)
2721
+ discoveredPeripherals[deviceName] = peripheral
2722
+
2723
+ // Always emit the discovered device for the UI list
2724
+ emitDiscoveredDevice(deviceName)
2725
+
2726
+ // Auto-connect logic based on target or saved device (from Java MentraNexSGC)
2727
+ var shouldConnect = false
2728
+ var connectionReason = ""
2729
+
2730
+ // Check if this matches our target device name for connection
2731
+ if let targetName = peripheralToConnectName, deviceName.contains(targetName) {
2732
+ shouldConnect = true
2733
+ connectionReason = "Target device name match: \(targetName)"
2734
+ }
2735
+ // During a user-initiated discovery scan, only list devices — never auto-connect a
2736
+ // saved/preferred device, so the user can pick a different one.
2737
+ else if isDiscoveryScan {
2738
+ shouldConnect = false
2739
+ }
2740
+ // Check if this matches our saved device for reconnection
2741
+ else if let savedName = savedDeviceName, deviceName == savedName {
2742
+ shouldConnect = true
2743
+ connectionReason = "Saved device reconnection: \(savedName)"
2744
+ }
2745
+ // Check if this matches preferred device ID
2746
+ else if let preferredId = preferredDeviceId {
2747
+ if let extractedId = extractDeviceId(from: deviceName), extractedId == preferredId {
2748
+ shouldConnect = true
2749
+ connectionReason = "Preferred device ID match: \(preferredId)"
2750
+ }
2751
+ }
2752
+
2753
+ if shouldConnect {
2754
+ connectToFoundDevice(peripheral, reason: connectionReason)
2755
+ }
2756
+ }
2757
+
2758
+ // MARK: - Enhanced Connection Helper
2759
+
2760
+ private func connectToFoundDevice(_ peripheral: CBPeripheral, reason: String) {
2761
+ guard self.peripheral == nil else {
2762
+ Bridge.log(
2763
+ "NEX-CONN: ⚠️ Already connected/connecting to a device, ignoring new connect request for '\(peripheral.name ?? "Unknown")'"
2764
+ )
2765
+ return
2766
+ }
2767
+
2768
+ Bridge.log(
2769
+ "NEX-CONN: 🔗 Connecting to device '\(peripheral.name ?? "Unknown")' - Reason: \(reason)"
2770
+ )
2771
+
2772
+ // Stop scanning since we found our target
2773
+ if _isScanning {
2774
+ stopScan()
2775
+ }
2776
+
2777
+ // Store the peripheral and initiate connection
2778
+ self.peripheral = peripheral
2779
+ isConnecting = true
2780
+ connectionState = ConnTypes.CONNECTING
2781
+ DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTING)
2782
+
2783
+ // Use connection options for better reliability (from Java implementation)
2784
+ let connectionOptions: [String: Any] = [
2785
+ CBConnectPeripheralOptionNotifyOnConnectionKey: true,
2786
+ CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
2787
+ CBConnectPeripheralOptionNotifyOnNotificationKey: true,
2788
+ ]
2789
+
2790
+ centralManager?.connect(peripheral, options: connectionOptions)
2791
+
2792
+ Bridge.log("NEX-CONN: 🚀 Connection initiated with enhanced options")
2793
+ }
2794
+
2795
+ func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
2796
+ Bridge.log("NEX-CONN: ✅ Successfully connected to \(peripheral.name ?? "unknown device").")
2797
+ isConnecting = false
2798
+ peripheralUUID = peripheral.identifier // Persist UUID
2799
+ stopReconnectionTimer() // Successfully connected, stop trying to reconnect.
2800
+
2801
+ // Enhanced device info saving (from Java implementation)
2802
+ let deviceName = peripheral.name
2803
+ let deviceAddress = peripheral.identifier.uuidString
2804
+
2805
+ // Save all device information for future reconnection
2806
+ savePairedDeviceInfo(name: deviceName, address: deviceAddress)
2807
+
2808
+ // Extract and save device ID if possible
2809
+ if let deviceName, let deviceId = extractDeviceId(from: deviceName) {
2810
+ savePreferredDeviceId(deviceId)
2811
+ }
2812
+
2813
+ Bridge.log("NEX-CONN: 💾 Device information saved for reliable reconnection")
2814
+ peripheral.delegate = self
2815
+ Bridge.log("NEX-CONN: 🔍 Discovering services...")
2816
+ setServicesReady(false)
2817
+ peripheral.discoverServices([MAIN_SERVICE_UUID])
2818
+
2819
+ // Reset any failed connection attempt counters
2820
+ reconnectionAttempts = 0
2821
+ Bridge.log("NEX-CONN: 🔄 Reset reconnection attempts counter")
2822
+ }
2823
+
2824
+ func centralManager(
2825
+ _: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?
2826
+ ) {
2827
+ Bridge.log(
2828
+ "NEX-CONN: ❌ Failed to connect to peripheral \(peripheral.name ?? "Unknown"). Error: \(error?.localizedDescription ?? "unknown")"
2829
+ )
2830
+ isConnecting = false
2831
+ connectionState = ConnTypes.DISCONNECTED
2832
+ setServicesReady(false)
2833
+ releaseServiceWaiters()
2834
+ Task { await commandQueue.clear() }
2835
+ resumePendingWrite()
2836
+ resumePendingAck()
2837
+ self.peripheral = nil // Reset peripheral on failure to allow reconnection
2838
+ // Optionally, start reconnection attempts here
2839
+ if !isDisconnecting, !isKilled {
2840
+ startReconnectionTimer()
2841
+ }
2842
+ }
2843
+
2844
+ func centralManager(
2845
+ _: CBCentralManager, didDisconnectPeripheral disconnectedPeripheral: CBPeripheral,
2846
+ error: Error?
2847
+ ) {
2848
+ Bridge.log(
2849
+ "NEX-CONN: 🔌 Disconnected from peripheral: \(disconnectedPeripheral.name ?? "Unknown")"
2850
+ )
2851
+
2852
+ if let error {
2853
+ Bridge.log("NEX-CONN: ⚠️ Disconnect error: \(error.localizedDescription)")
2854
+ }
2855
+
2856
+ // The glasses lose their canvas on disconnect — forget the component
2857
+ // registry so post-reconnect frames take the CREATE path (firmware
2858
+ // silently drops updates to dead component ids). The host replays the
2859
+ // current scene after reconnect.
2860
+ canvasElements.removeAll()
2861
+ currentLayoutId = nil
2862
+
2863
+ // Reset connection state
2864
+ // Save microphone state before disconnection (like Java implementation)
2865
+ saveMicrophoneStateBeforeDisconnection()
2866
+ setServicesReady(false)
2867
+ releaseServiceWaiters()
2868
+ Task { await commandQueue.clear() }
2869
+ resumePendingWrite()
2870
+ resumePendingAck()
2871
+
2872
+ // Reset protobuf version posted flag for next connection (like Java implementation)
2873
+ protobufVersionPosted = false
2874
+
2875
+ // Stop mic beat system (like Java implementation)
2876
+ stopMicBeat()
2877
+ stopTextWallDrain()
2878
+
2879
+ nexReady = false
2880
+ deviceReady = false
2881
+ // batteryLevel = -1
2882
+ // charging = false
2883
+ DeviceStore.shared.apply("glasses", "batteryLevel", -1)
2884
+ DeviceStore.shared.apply("glasses", "charging", false)
2885
+ vadActive = false
2886
+ compressedVoiceData = .init()
2887
+ aiListening = false
2888
+ deviceFirmwareVersion = ""
2889
+ deviceHardwareModel = ""
2890
+ accelerometer = [0.0, 0.0, 0.0]
2891
+ gyroscope = [0.0, 0.0, 0.0]
2892
+ magnetometer = [0.0, 0.0, 0.0]
2893
+ lastButtonPressed = -1
2894
+ lastButtonState = ""
2895
+ lastHeadGesture = ""
2896
+ headUpAngle = 0
2897
+
2898
+ peripheral = nil
2899
+ writeCharacteristic = nil
2900
+ notifyCharacteristic = nil
2901
+ connectionState = ConnTypes.DISCONNECTED
2902
+ currentMTU = MTU_DEFAULT
2903
+ deviceMaxMTU = MTU_DEFAULT
2904
+ maxChunkSize = MTU_DEFAULT - 10
2905
+ bmpChunkSize = MTU_DEFAULT - 20
2906
+ currentImageChunks.removeAll()
2907
+ isImageSendProgressing = false
2908
+ updateConnectedState(isConnected: false)
2909
+
2910
+ // Clear command queue if needed
2911
+ if isQueueWorkerRunning {
2912
+ Bridge.log("NEX-CONN: 🧹 Clearing command queue due to disconnection")
2913
+ }
2914
+
2915
+ if !isDisconnecting, !isKilled {
2916
+ Bridge.log("NEX-CONN: 🔄 Unintentional disconnect detected. Attempting reconnection...")
2917
+
2918
+ // Enhanced reconnection strategy from Java implementation
2919
+ if let savedName = savedDeviceName {
2920
+ Bridge.log("NEX-CONN: 🎯 Will attempt to reconnect to saved device: \(savedName)")
2921
+ }
2922
+
2923
+ startReconnectionTimer()
2924
+ } else {
2925
+ Bridge.log(
2926
+ "NEX-CONN: ✅ Intentional disconnect (isDisconnecting: \(isDisconnecting), isKilled: \(isKilled))"
2927
+ )
2928
+
2929
+ if isDisconnecting {
2930
+ // Don't clear device info on intentional disconnect - user might reconnect later
2931
+ Bridge.log("NEX-CONN: 💾 Keeping device info for potential future reconnection")
2932
+ }
2933
+ }
2934
+ }
2935
+
2936
+ // MARK: - MTU Negotiation (iOS-specific implementation)
2937
+
2938
+ private func requestOptimalMTU(for peripheral: CBPeripheral) {
2939
+ Bridge.log("NEX-CONN: negotiating MTU")
2940
+ Bridge.log("NEX: 🔍 iOS MTU Discovery (Platform Limitation: max \(MTU_MAX_IOS) bytes)")
2941
+ Bridge.log("NEX: 🎯 iOS maximum: \(MTU_MAX_IOS) bytes, default: \(MTU_DEFAULT) bytes")
2942
+
2943
+ // iOS MTU is automatically negotiated - we can only discover the current value
2944
+ // No manual MTU request available on iOS (platform limitation)
2945
+
2946
+ // Get current MTU capability (iOS-specific approach). Query for .withoutResponse
2947
+ // since that's the write type the caption path uses; its limit can differ from
2948
+ // .withResponse, and sizing chunks to it avoids oversized writes being dropped.
2949
+ let maxWriteLength = peripheral.maximumWriteValueLength(for: .withoutResponse)
2950
+ let actualMTU = maxWriteLength + 3 // Add L2CAP header size
2951
+
2952
+ Bridge.log("NEX: 📊 iOS MTU Discovery Results:")
2953
+ Bridge.log("NEX: 📏 Max write length: \(maxWriteLength) bytes")
2954
+ Bridge.log("NEX: 📡 Effective MTU: \(actualMTU) bytes")
2955
+
2956
+ // Validate against iOS limitations
2957
+ let validatedMTU = min(actualMTU, MTU_MAX_IOS)
2958
+ if actualMTU > MTU_MAX_IOS {
2959
+ Bridge.log("NEX: 🔧 Clamping MTU from \(actualMTU) to iOS maximum: \(MTU_MAX_IOS)")
2960
+ }
2961
+
2962
+ // Process MTU result immediately (iOS doesn't have callback like Android)
2963
+ onMTUNegotiated(mtu: validatedMTU, success: true)
2964
+
2965
+ // After MTU is set, start device initialization sequence (from Java implementation)
2966
+ initializeNexDevice()
2967
+ }
2968
+
2969
+ private func onMTUNegotiated(mtu: Int, success: Bool) {
2970
+ Bridge.log("NEX-CONN: 🔄 MTU Negotiation Result: Success=\(success), Device MTU=\(mtu)")
2971
+
2972
+ if success, mtu > MTU_DEFAULT {
2973
+ // Store device capability and calculate actual negotiated MTU
2974
+ deviceMaxMTU = mtu
2975
+ // iOS limitation: Use actual MTU but cap at iOS maximum
2976
+ currentMTU = min(MTU_MAX_IOS, mtu)
2977
+
2978
+ Bridge.log("NEX: 🎯 iOS MTU Configuration Complete:")
2979
+ Bridge.log("NEX: 🍎 iOS Platform Max: \(MTU_MAX_IOS) bytes")
2980
+ Bridge.log("NEX: 📡 Device Supports: \(deviceMaxMTU) bytes")
2981
+ Bridge.log("NEX: 🤝 Final MTU: \(currentMTU) bytes")
2982
+
2983
+ // Calculate optimal chunk sizes based on iOS MTU constraints
2984
+ maxChunkSize = currentMTU - 10 // Reserve 10 bytes for headers
2985
+ bmpChunkSize = currentMTU - 20
2986
+
2987
+ Bridge.log("NEX: 📦 Optimized Chunk Sizes:")
2988
+ Bridge.log("NEX: 📄 Data Chunk Size: \(maxChunkSize) bytes")
2989
+ Bridge.log("NEX: 🖼️ Image Chunk Size: \(bmpChunkSize) bytes")
2990
+
2991
+ } else {
2992
+ Bridge.log("NEX: ⚠️ MTU negotiation failed or using minimum, applying iOS defaults")
2993
+ currentMTU = MTU_DEFAULT
2994
+ deviceMaxMTU = MTU_DEFAULT
2995
+ maxChunkSize = 20 // Very conservative for 23-byte MTU
2996
+ bmpChunkSize = 20 // Very conservative for 23-byte MTU
2997
+
2998
+ Bridge.log("NEX: 📋 iOS Fallback Configuration:")
2999
+ Bridge.log("NEX: 📊 Default MTU: \(MTU_DEFAULT) bytes")
3000
+ Bridge.log("NEX: 📦 Data Chunk Size: \(maxChunkSize) bytes")
3001
+ Bridge.log("NEX: 🖼️ Image Chunk Size: \(bmpChunkSize) bytes")
3002
+ Bridge.log("NEX: ⚠️ Using minimal chunks due to MTU limitation")
3003
+ }
3004
+
3005
+ // Device is now ready for communication
3006
+ Bridge.log("NEX-CONN: ✅ Device initialization complete - ready for communication")
3007
+ nexReady = true
3008
+ connectionState = ConnTypes.CONNECTED
3009
+ lastConnectionTimestamp = Date().timeIntervalSince1970 * 1000
3010
+ updateConnectedState(isConnected: true)
3011
+
3012
+ // Update @Published property for device ready state
3013
+ deviceReady = true
3014
+
3015
+ // Initialize command queue worker to process queued commands
3016
+ setupCommandQueue()
3017
+
3018
+ // Start the 100 ms latest-wins drain for caption text walls
3019
+ startTextWallDrain()
3020
+
3021
+ // Emit device ready event to React Native
3022
+ // emitDeviceReady()
3023
+ }
3024
+
3025
+ // MARK: - Device Initialization (ported from Java MentraNexSGC)
3026
+
3027
+ private func initializeNexDevice() {
3028
+ Bridge.log("NEX-CONN: 🚀 Starting Nex device initialization (matching Java sequence)")
3029
+
3030
+ // Exact Java initialization sequence from lines 648-691:
3031
+
3032
+ // 1. Do first battery status query (Java line 650)
3033
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { // 10ms delay like Java
3034
+ Bridge.log("NEX: 🔋 Sending first battery status query")
3035
+ self.queryBatteryStatus()
3036
+ }
3037
+
3038
+ // 2. Restore previous microphone state (Java lines 657-665)
3039
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { // 20ms delay
3040
+ let shouldRestoreMic = UserDefaults.standard.bool(
3041
+ forKey: "microphoneStateBeforeDisconnection"
3042
+ )
3043
+ Bridge.log("NEX: 🎤 Restoring microphone state to: \(shouldRestoreMic)")
3044
+
3045
+ if shouldRestoreMic {
3046
+ self.startMicBeat()
3047
+ } else {
3048
+ self.stopMicBeat()
3049
+ }
3050
+ }
3051
+
3052
+ // 3. Enable AugmentOS notification key (Java line 668)
3053
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { // 30ms delay
3054
+ self.sendWhiteListCommand()
3055
+ }
3056
+
3057
+ // 4. Show home screen to turn on the NexGlasses display (Java line 673)
3058
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { // 50ms delay
3059
+ self.showHomeScreen()
3060
+ }
3061
+
3062
+ // 5. Post protobuf schema version information (Java lines 684-687)
3063
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { // 100ms delay
3064
+ self.postProtobufSchemaVersionInfo()
3065
+ }
3066
+
3067
+ // 6. Version request is removed in current schema; Android sends no-op.
3068
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
3069
+ Bridge.log("NEX: Skipping version request; schema removed VersionRequest")
3070
+ }
3071
+
3072
+ // 7. Push current glasses-side Voice Activity Detection setting
3073
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
3074
+ self.sendVoiceActivityDetectionSetting()
3075
+ }
3076
+
3077
+ Bridge.log("NEX-CONN: ✅ Java-compatible initialization sequence started")
3078
+ }
3079
+
3080
+ private func emitDeviceReady() {
3081
+ let eventBody: [String: Any] = [
3082
+ "device_ready": [
3083
+ "model_name": "Mentra Display",
3084
+ "mtu_negotiated": currentMTU,
3085
+ "max_chunk_size": maxChunkSize,
3086
+ "connection_state": "ready",
3087
+ ],
3088
+ ]
3089
+
3090
+ // Use the standardized Bridge.sendTypedMessage helper for consistent type field handling
3091
+ Bridge.sendTypedMessage("device_ready", body: eventBody)
3092
+ Bridge.log("NEX: 📡 Emitted device ready event with MTU: \(currentMTU)")
3093
+ }
3094
+
3095
+ // MARK: - CBPeripheralDelegate
3096
+
3097
+ func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
3098
+ if let error {
3099
+ Bridge.log("NEX-CONN: ❌ Error discovering services: \(error.localizedDescription)")
3100
+ return
3101
+ }
3102
+
3103
+ guard let services = peripheral.services else {
3104
+ Bridge.log("NEX-CONN: ⚠️ No services found for peripheral.")
3105
+ return
3106
+ }
3107
+ for service in services {
3108
+ if service.uuid == MAIN_SERVICE_UUID {
3109
+ Bridge.log("NEX-CONN: ✅ Found main service. Discovering characteristics...")
3110
+ peripheral.discoverCharacteristics(
3111
+ [WRITE_CHAR_UUID, NOTIFY_CHAR_UUID], for: service
3112
+ )
3113
+ }
3114
+ }
3115
+ }
3116
+
3117
+ func peripheral(
3118
+ _ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?
3119
+ ) {
3120
+ if let error {
3121
+ Bridge.log(
3122
+ "NEX-CONN: ❌ Error discovering characteristics: \(error.localizedDescription)"
3123
+ )
3124
+ return
3125
+ }
3126
+
3127
+ guard let characteristics = service.characteristics else {
3128
+ Bridge.log("NEX-CONN: ⚠️ No characteristics found for service \(service.uuid).")
3129
+ return
3130
+ }
3131
+ for characteristic in characteristics {
3132
+ if characteristic.uuid == WRITE_CHAR_UUID {
3133
+ Bridge.log("NEX-CONN: ✅ Found write characteristic.")
3134
+ writeCharacteristic = characteristic
3135
+ } else if characteristic.uuid == NOTIFY_CHAR_UUID {
3136
+ Bridge.log(
3137
+ "NEX-CONN: ✅ Found notify characteristic. Subscribing for notifications."
3138
+ )
3139
+ notifyCharacteristic = characteristic
3140
+ peripheral.setNotifyValue(true, for: characteristic)
3141
+ }
3142
+ }
3143
+
3144
+ if writeCharacteristic != nil, notifyCharacteristic != nil {
3145
+ Bridge.log(
3146
+ "NEX-CONN: ✅ All required characteristics discovered. Proceeding to MTU negotiation."
3147
+ )
3148
+
3149
+ // Start MTU negotiation like Java implementation
3150
+ requestOptimalMTU(for: peripheral)
3151
+ }
3152
+ }
3153
+
3154
+ func peripheral(
3155
+ _: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?
3156
+ ) {
3157
+ if let error {
3158
+ Bridge.log("NEX-CONN: ❌ Error on updating value: \(error.localizedDescription)")
3159
+ return
3160
+ }
3161
+
3162
+ guard let data = characteristic.value else {
3163
+ Bridge.log("NEX-CONN: ⚠️ Received notification with no data.")
3164
+ return
3165
+ }
3166
+ // No per-notification logging here: this fires for EVERY inbound packet
3167
+ // (~20/sec audio alone), and Bridge.log is a typed message the JS thread
3168
+ // must process — plus the toHexString() is O(n) per packet. This was a
3169
+ // top contributor to the background CPU kill (cpu_resource_fatal.ips).
3170
+ // Bridge.log("NEX-CONN: 📥 Received data (\(data.count) bytes): \(data.toHexString())")
3171
+
3172
+ // Process the received data based on packet type
3173
+ processReceivedData(data)
3174
+ }
3175
+
3176
+ func peripheral(
3177
+ _: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?
3178
+ ) {
3179
+ if let error {
3180
+ Bridge.log(
3181
+ "NEX-CONN: ❌ Error writing value to \(characteristic.uuid): \(error.localizedDescription)"
3182
+ )
3183
+ resumePendingAck()
3184
+ return
3185
+ }
3186
+ resumePendingAck()
3187
+ }
3188
+
3189
+ func peripheralIsReady(toSendWriteWithoutResponse _: CBPeripheral) {
3190
+ // CoreBluetooth can accept more write-without-response data; unblock the sender.
3191
+ resumePendingWrite()
3192
+ }
3193
+
3194
+ func peripheral(
3195
+ _: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic,
3196
+ error: Error?
3197
+ ) {
3198
+ if let error {
3199
+ Bridge.log(
3200
+ "NEX-CONN: ❌ Error changing notification state for \(characteristic.uuid): \(error.localizedDescription)"
3201
+ )
3202
+ return
3203
+ }
3204
+
3205
+ if characteristic.isNotifying {
3206
+ Bridge.log(
3207
+ "NEX-CONN: ✅ Successfully subscribed to notifications for characteristic \(characteristic.uuid.uuidString)."
3208
+ )
3209
+ if characteristic.uuid == NOTIFY_CHAR_UUID {
3210
+ setServicesReady(true)
3211
+ }
3212
+ } else {
3213
+ Bridge.log(
3214
+ "NEX-CONN: unsubscribed from notifications for characteristic \(characteristic.uuid.uuidString)."
3215
+ )
3216
+ if characteristic.uuid == NOTIFY_CHAR_UUID {
3217
+ setServicesReady(false)
3218
+ }
3219
+ }
3220
+ }
3221
+ }