@mentra/bluetooth-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (278) hide show
  1. package/README.md +278 -0
  2. package/android/build.gradle +167 -0
  3. package/android/lc3Lib/CMakeLists.txt +6 -0
  4. package/android/lc3Lib/build.gradle +109 -0
  5. package/android/lc3Lib/proguard-rules.pro +33 -0
  6. package/android/lc3Lib/src/main/AndroidManifest.xml +2 -0
  7. package/android/lc3Lib/src/main/cpp/CMakeLists.txt +8 -0
  8. package/android/lc3Lib/src/main/cpp/google_opus_stuff/CMakeLists.txt +30 -0
  9. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/CMakeLists.txt +7 -0
  10. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/ogg_opus_encoder.cc +96 -0
  11. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/ogg_opus_encoder.h +53 -0
  12. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/Makefile.am +6 -0
  13. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/config_types.h +26 -0
  14. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/config_types.h.in +26 -0
  15. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/ogg.h +209 -0
  16. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/os_types.h +158 -0
  17. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus.h +981 -0
  18. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_custom.h +342 -0
  19. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_defines.h +799 -0
  20. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_multistream.h +660 -0
  21. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_projection.h +568 -0
  22. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_types.h +166 -0
  23. package/android/lc3Lib/src/main/cpp/google_opus_stuff/ogg_opus_encoder.cc +268 -0
  24. package/android/lc3Lib/src/main/cpp/google_opus_stuff/ogg_opus_encoder.h +115 -0
  25. package/android/lc3Lib/src/main/cpp/google_opus_stuff/opus_tools/opus_header.h +59 -0
  26. package/android/lc3Lib/src/main/cpp/liblc3/CMakeLists.txt +38 -0
  27. package/android/lc3Lib/src/main/cpp/liblc3/include/lc3.h +309 -0
  28. package/android/lc3Lib/src/main/cpp/liblc3/include/lc3_private.h +162 -0
  29. package/android/lc3Lib/src/main/cpp/liblc3/include/rnnoise.h +114 -0
  30. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/CMakeLists.txt +19 -0
  31. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/attdet.c +92 -0
  32. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/attdet.h +44 -0
  33. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bits.c +375 -0
  34. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bits.h +315 -0
  35. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bwdet.c +129 -0
  36. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bwdet.h +69 -0
  37. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/common.h +148 -0
  38. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/energy.c +70 -0
  39. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/energy.h +43 -0
  40. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/fastmath.h +158 -0
  41. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/lc3.c +702 -0
  42. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf.c +893 -0
  43. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf.h +111 -0
  44. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf_arm.h +506 -0
  45. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf_neon.h +281 -0
  46. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/makefile.mk +35 -0
  47. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct.c +452 -0
  48. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct.h +57 -0
  49. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct_neon.h +296 -0
  50. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/meson.build +46 -0
  51. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/plc.c +61 -0
  52. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/plc.h +57 -0
  53. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/sns.c +880 -0
  54. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/sns.h +103 -0
  55. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/spec.c +904 -0
  56. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/spec.h +119 -0
  57. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tables.c +3457 -0
  58. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tables.h +94 -0
  59. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tns.c +457 -0
  60. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tns.h +99 -0
  61. package/android/lc3Lib/src/main/cpp/liblc3/liblc3.cpp +159 -0
  62. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/_kiss_fft_guts.h +182 -0
  63. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/arch.h +261 -0
  64. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/celt_lpc.c +279 -0
  65. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/celt_lpc.h +59 -0
  66. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/common.h +48 -0
  67. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/compile.sh +3 -0
  68. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/denoise.c +646 -0
  69. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/kiss_fft.c +601 -0
  70. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/kiss_fft.h +203 -0
  71. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/opus_types.h +159 -0
  72. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/pitch.c +526 -0
  73. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/pitch.h +149 -0
  74. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn.c +178 -0
  75. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn.h +69 -0
  76. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_data.c +11051 -0
  77. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_data.h +34 -0
  78. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_reader.c +168 -0
  79. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_train.py +66 -0
  80. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/tansig_table.h +45 -0
  81. package/android/lc3Lib/src/main/java/com/mentra/lc3Lib/Lc3Cpp.java +38 -0
  82. package/android/lc3Lib/third_party/CMakeLists.txt +23 -0
  83. package/android/lc3Lib/third_party/CMakeLists_libogg.txt +18 -0
  84. package/android/lc3Lib/third_party/CMakeLists_libopus.txt +18 -0
  85. package/android/lc3Lib/third_party/CMakeLists_opus-tools.txt +23 -0
  86. package/android/lc3Lib/third_party/opus_tools/src/AUTHORS +5 -0
  87. package/android/lc3Lib/third_party/opus_tools/src/CMakeLists.txt +4 -0
  88. package/android/lc3Lib/third_party/opus_tools/src/COPYING +371 -0
  89. package/android/lc3Lib/third_party/opus_tools/src/ChangeLog +3 -0
  90. package/android/lc3Lib/third_party/opus_tools/src/Makefile.am +42 -0
  91. package/android/lc3Lib/third_party/opus_tools/src/Makefile.unix +23 -0
  92. package/android/lc3Lib/third_party/opus_tools/src/NEWS +0 -0
  93. package/android/lc3Lib/third_party/opus_tools/src/README +0 -0
  94. package/android/lc3Lib/third_party/opus_tools/src/autogen.sh +115 -0
  95. package/android/lc3Lib/third_party/opus_tools/src/configure.ac +293 -0
  96. package/android/lc3Lib/third_party/opus_tools/src/src/arch.h +239 -0
  97. package/android/lc3Lib/third_party/opus_tools/src/src/audio-in.c +1046 -0
  98. package/android/lc3Lib/third_party/opus_tools/src/src/diag_range.c +245 -0
  99. package/android/lc3Lib/third_party/opus_tools/src/src/diag_range.h +28 -0
  100. package/android/lc3Lib/third_party/opus_tools/src/src/info_opus.c +320 -0
  101. package/android/lc3Lib/third_party/opus_tools/src/src/info_opus.h +51 -0
  102. package/android/lc3Lib/third_party/opus_tools/src/src/lpc.c +157 -0
  103. package/android/lc3Lib/third_party/opus_tools/src/src/lpc.h +27 -0
  104. package/android/lc3Lib/third_party/opus_tools/src/src/opus_header.c +286 -0
  105. package/android/lc3Lib/third_party/opus_tools/src/src/opus_header.h +59 -0
  106. package/android/lc3Lib/third_party/opus_tools/src/src/opusdec.c +884 -0
  107. package/android/lc3Lib/third_party/opus_tools/src/src/opusenc.c +1021 -0
  108. package/android/lc3Lib/third_party/opus_tools/src/src/opusenc.h +101 -0
  109. package/android/lc3Lib/third_party/opus_tools/src/src/opusinfo.c +639 -0
  110. package/android/lc3Lib/third_party/opus_tools/src/src/opusinfo.h +51 -0
  111. package/android/lc3Lib/third_party/opus_tools/src/src/os_support.h +167 -0
  112. package/android/lc3Lib/third_party/opus_tools/src/src/resample.c +1137 -0
  113. package/android/lc3Lib/third_party/opus_tools/src/src/speex_resampler.h +344 -0
  114. package/android/lc3Lib/third_party/opus_tools/src/src/stack_alloc.h +115 -0
  115. package/android/lc3Lib/third_party/opus_tools/src/src/wav_io.c +125 -0
  116. package/android/lc3Lib/third_party/opus_tools/src/src/wav_io.h +62 -0
  117. package/android/lc3Lib/third_party/opus_tools/src/src/wave_out.c +223 -0
  118. package/android/lc3Lib/third_party/opus_tools/src/src/wave_out.h +60 -0
  119. package/android/settings.gradle +1 -0
  120. package/android/src/main/AndroidManifest.xml +40 -0
  121. package/android/src/main/java/com/mentra/bluetoothsdk/BluetoothSdkModule.kt +528 -0
  122. package/android/src/main/java/com/mentra/bluetoothsdk/Bridge.kt +610 -0
  123. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceManager.kt +1616 -0
  124. package/android/src/main/java/com/mentra/bluetoothsdk/DeviceStore.kt +314 -0
  125. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothModels.kt +1710 -0
  126. package/android/src/main/java/com/mentra/bluetoothsdk/MentraBluetoothSdk.kt +554 -0
  127. package/android/src/main/java/com/mentra/bluetoothsdk/ObservableStore.kt +68 -0
  128. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/ControllerManager.kt +158 -0
  129. package/android/src/main/java/com/mentra/bluetoothsdk/controllers/R1.kt +934 -0
  130. package/android/src/main/java/com/mentra/bluetoothsdk/services/Foreground.kt +175 -0
  131. package/android/src/main/java/com/mentra/bluetoothsdk/services/PhoneMic.kt +1109 -0
  132. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G1.java +3974 -0
  133. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/G2.kt +3551 -0
  134. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Mach1.java +1327 -0
  135. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraLive.java +6959 -0
  136. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraNex.kt +1655 -0
  137. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/MentraosBle.java +53675 -0
  138. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/SGCManager.kt +202 -0
  139. package/android/src/main/java/com/mentra/bluetoothsdk/sgcs/Simulated.kt +224 -0
  140. package/android/src/main/java/com/mentra/bluetoothsdk/stt/STTTools.kt +340 -0
  141. package/android/src/main/java/com/mentra/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +465 -0
  142. package/android/src/main/java/com/mentra/bluetoothsdk/stt/VadGateSpeechPolicy.kt +221 -0
  143. package/android/src/main/java/com/mentra/bluetoothsdk/utils/AES.java +106 -0
  144. package/android/src/main/java/com/mentra/bluetoothsdk/utils/AudioSessionMonitor.kt +189 -0
  145. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BitmapJavaUtils.java +169 -0
  146. package/android/src/main/java/com/mentra/bluetoothsdk/utils/BlePhotoUploadService.java +179 -0
  147. package/android/src/main/java/com/mentra/bluetoothsdk/utils/Constants.kt +44 -0
  148. package/android/src/main/java/com/mentra/bluetoothsdk/utils/G1Text.kt +436 -0
  149. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleRelayNaming.java +29 -0
  150. package/android/src/main/java/com/mentra/bluetoothsdk/utils/IncidentLogBleUploadService.java +76 -0
  151. package/android/src/main/java/com/mentra/bluetoothsdk/utils/K900ProtocolUtils.java +791 -0
  152. package/android/src/main/java/com/mentra/bluetoothsdk/utils/MessageChunker.java +186 -0
  153. package/android/src/main/java/com/mentra/bluetoothsdk/utils/NexSGCUtils.kt +589 -0
  154. package/android/src/main/java/com/mentra/bluetoothsdk/utils/PhoneAudioMonitor.kt +303 -0
  155. package/android/src/main/java/com/mentra/bluetoothsdk/utils/SmartGlassesConnectionState.java +9 -0
  156. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/ByteUtilAudioPlayer.java +655 -0
  157. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/Lc3Player.java +441 -0
  158. package/android/src/main/java/com/mentra/bluetoothsdk/utils/audio/PCMAudioPlayer.java +431 -0
  159. package/android/src/main/res/values/strings.xml +4 -0
  160. package/app.plugin.js +1 -0
  161. package/build/BluetoothSdk.types.d.ts +498 -0
  162. package/build/BluetoothSdk.types.d.ts.map +1 -0
  163. package/build/BluetoothSdk.types.js +23 -0
  164. package/build/BluetoothSdk.types.js.map +1 -0
  165. package/build/BluetoothSdkModule.d.ts +71 -0
  166. package/build/BluetoothSdkModule.d.ts.map +1 -0
  167. package/build/BluetoothSdkModule.js +106 -0
  168. package/build/BluetoothSdkModule.js.map +1 -0
  169. package/build/index.d.ts +3 -0
  170. package/build/index.d.ts.map +1 -0
  171. package/build/index.js +5 -0
  172. package/build/index.js.map +1 -0
  173. package/expo-module.config.json +9 -0
  174. package/ios/BluetoothSdkModule.swift +665 -0
  175. package/ios/MentraBluetoothSDK.podspec +77 -0
  176. package/ios/Packages/CoreObjC/CoreObjC.xcodeproj/project.pbxproj +213 -0
  177. package/ios/Packages/CoreObjC/PcmConverter.h +22 -0
  178. package/ios/Packages/CoreObjC/PcmConverter.m +266 -0
  179. package/ios/Packages/CoreObjC/attdet.c +92 -0
  180. package/ios/Packages/CoreObjC/attdet.h +44 -0
  181. package/ios/Packages/CoreObjC/bits.c +375 -0
  182. package/ios/Packages/CoreObjC/bits.h +315 -0
  183. package/ios/Packages/CoreObjC/bwdet.c +129 -0
  184. package/ios/Packages/CoreObjC/bwdet.h +69 -0
  185. package/ios/Packages/CoreObjC/common.h +151 -0
  186. package/ios/Packages/CoreObjC/energy.c +70 -0
  187. package/ios/Packages/CoreObjC/energy.h +43 -0
  188. package/ios/Packages/CoreObjC/fastmath.h +158 -0
  189. package/ios/Packages/CoreObjC/lc3.c +704 -0
  190. package/ios/Packages/CoreObjC/lc3.h +313 -0
  191. package/ios/Packages/CoreObjC/lc3_cpp.h +283 -0
  192. package/ios/Packages/CoreObjC/lc3_private.h +163 -0
  193. package/ios/Packages/CoreObjC/ltpf.c +905 -0
  194. package/ios/Packages/CoreObjC/ltpf.h +111 -0
  195. package/ios/Packages/CoreObjC/ltpf_arm.h +506 -0
  196. package/ios/Packages/CoreObjC/ltpf_neon.h +281 -0
  197. package/ios/Packages/CoreObjC/makefile.mk +35 -0
  198. package/ios/Packages/CoreObjC/mdct.c +469 -0
  199. package/ios/Packages/CoreObjC/mdct.h +57 -0
  200. package/ios/Packages/CoreObjC/mdct_neon.h +296 -0
  201. package/ios/Packages/CoreObjC/meson.build +61 -0
  202. package/ios/Packages/CoreObjC/plc.c +61 -0
  203. package/ios/Packages/CoreObjC/plc.h +57 -0
  204. package/ios/Packages/CoreObjC/rnnoise.h +114 -0
  205. package/ios/Packages/CoreObjC/sns.c +880 -0
  206. package/ios/Packages/CoreObjC/sns.h +103 -0
  207. package/ios/Packages/CoreObjC/spec.c +907 -0
  208. package/ios/Packages/CoreObjC/spec.h +119 -0
  209. package/ios/Packages/CoreObjC/tables.c +3457 -0
  210. package/ios/Packages/CoreObjC/tables.h +94 -0
  211. package/ios/Packages/CoreObjC/tns.c +457 -0
  212. package/ios/Packages/CoreObjC/tns.h +99 -0
  213. package/ios/Packages/SherpaOnnx/Model/joiner.onnx +0 -0
  214. package/ios/Packages/SherpaOnnx/Model/tokens.txt +502 -0
  215. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +1659 -0
  216. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/cargs.h +162 -0
  217. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/c-api.h +1852 -0
  218. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Headers/sherpa-onnx/c-api/cxx-api.h +674 -0
  219. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +44 -0
  220. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
  221. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/sherpa-onnx.a +0 -0
  222. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
  223. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/sherpa-onnx.a +0 -0
  224. package/ios/Packages/VAD/Common/VAD/VADQuality.swift +41 -0
  225. package/ios/Packages/VAD/Common/VAD/VADState.swift +26 -0
  226. package/ios/Packages/VAD/Common/VAD/VADStrategy.swift +29 -0
  227. package/ios/Packages/VAD/Common/VAD/VADType.swift +14 -0
  228. package/ios/Packages/VAD/Data/Configuration.swift +68 -0
  229. package/ios/Packages/VAD/Data/FrameSize.swift +39 -0
  230. package/ios/Packages/VAD/Data/Record.swift +13 -0
  231. package/ios/Packages/VAD/Data/Result.swift +22 -0
  232. package/ios/Packages/VAD/Data/SampleRate.swift +48 -0
  233. package/ios/Packages/VAD/Silero/Model/silero_vad.onnx +0 -0
  234. package/ios/Packages/VAD/Silero/SileroVAD.swift +284 -0
  235. package/ios/Packages/VAD/Silero/SileroVADStrategy.swift +64 -0
  236. package/ios/Packages/libbz2/module.modulemap +5 -0
  237. package/ios/Packages/libbz2/shim.h +1 -0
  238. package/ios/Source/Bridge.swift +353 -0
  239. package/ios/Source/Bridging-Header.h +16 -0
  240. package/ios/Source/DeviceManager.swift +1478 -0
  241. package/ios/Source/DeviceStore.swift +295 -0
  242. package/ios/Source/MentraBluetoothSDK.swift +2439 -0
  243. package/ios/Source/ObservableStore.swift +88 -0
  244. package/ios/Source/PrivacyInfo.xcprivacy +23 -0
  245. package/ios/Source/controllers/ControllerManager.swift +187 -0
  246. package/ios/Source/controllers/R1.swift +827 -0
  247. package/ios/Source/services/PhoneMic.swift +662 -0
  248. package/ios/Source/sgcs/Frame.swift +651 -0
  249. package/ios/Source/sgcs/G1.swift +2505 -0
  250. package/ios/Source/sgcs/G2.swift +3730 -0
  251. package/ios/Source/sgcs/Mach1.swift +543 -0
  252. package/ios/Source/sgcs/MentraLive.swift +4708 -0
  253. package/ios/Source/sgcs/MentraNex.swift +2479 -0
  254. package/ios/Source/sgcs/SGCManager.swift +243 -0
  255. package/ios/Source/sgcs/Simulated.swift +270 -0
  256. package/ios/Source/sgcs/mentraos_ble.pb.swift +3811 -0
  257. package/ios/Source/stt/STTTools.swift +154 -0
  258. package/ios/Source/stt/SherpaOnnxTranscriber.swift +401 -0
  259. package/ios/Source/utils/AudioSessionMonitor.swift +283 -0
  260. package/ios/Source/utils/Constants.swift +74 -0
  261. package/ios/Source/utils/Enums.swift +95 -0
  262. package/ios/Source/utils/G1Text.swift +2067 -0
  263. package/ios/Source/utils/JSCExperiment.swift +241 -0
  264. package/ios/Source/utils/MemoryMonitor.swift +44 -0
  265. package/ios/Source/utils/MessageChunker.swift +164 -0
  266. package/ios/Source/utils/Models.swift +135 -0
  267. package/ios/Source/utils/PhoneAudioMonitor.swift +226 -0
  268. package/ios/Source/utils/TarBz2Extractor.swift +206 -0
  269. package/package.json +78 -0
  270. package/plugin/build/index.d.ts +6 -0
  271. package/plugin/build/index.js +12 -0
  272. package/plugin/build/withAndroid.d.ts +4 -0
  273. package/plugin/build/withAndroid.js +80 -0
  274. package/plugin/build/withIos.d.ts +4 -0
  275. package/plugin/build/withIos.js +64 -0
  276. package/src/BluetoothSdk.types.ts +581 -0
  277. package/src/BluetoothSdkModule.ts +259 -0
  278. package/src/index.ts +4 -0
@@ -0,0 +1,3730 @@
1
+ //
2
+ // G2.swift
3
+ // MentraOS_Manager
4
+ //
5
+ // Rewritten for EvenHub protocol (G2-native protobuf-based display system)
6
+ // Based on reverse-engineered protocol from ae_g2_rev
7
+ //
8
+
9
+ import Combine
10
+ import CoreBluetooth
11
+ import Foundation
12
+ import UIKit
13
+
14
+ // MARK: - Data Little-Endian Helpers (for BMP construction)
15
+
16
+ private extension Data {
17
+ mutating func appendLittleEndian(_ value: UInt16) {
18
+ var v = value.littleEndian
19
+ Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) }
20
+ }
21
+
22
+ mutating func appendLittleEndian(_ value: UInt32) {
23
+ var v = value.littleEndian
24
+ Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) }
25
+ }
26
+
27
+ mutating func appendLittleEndian(_ value: Int32) {
28
+ var v = value.littleEndian
29
+ Swift.withUnsafeBytes(of: &v) { append(contentsOf: $0) }
30
+ }
31
+ }
32
+
33
+ // MARK: - G2 Protocol Constants
34
+
35
+ private enum G2BLE {
36
+ // EvenHub BLE characteristic UUIDs (NOT the G1 UART UUIDs!)
37
+ static let CHAR_WRITE = CBUUID(string: "00002760-08C2-11E1-9073-0E8AC72E5401")
38
+ static let CHAR_NOTIFY = CBUUID(string: "00002760-08C2-11E1-9073-0E8AC72E5402")
39
+ static let AUDIO_NOTIFY = CBUUID(string: "00002760-08C2-11E1-9073-0E8AC72E6402")
40
+
41
+ /// We discover services by scanning for these characteristics
42
+ /// The service UUID that contains these chars
43
+ static let SERVICE_UUID = CBUUID(string: "00002760-08C2-11E1-9073-0E8AC72E0000")
44
+
45
+ // Transport constants
46
+ static let HEADER_BYTE: UInt8 = 0xAA
47
+ static let SOURCE_PHONE: UInt8 = 1
48
+ static let DEST_GLASSES: UInt8 = 2
49
+ static let MAX_PACKET_PAYLOAD: Int = 236
50
+ }
51
+
52
+ /// Service IDs from service_id_def.proto
53
+ private enum ServiceID: UInt8 {
54
+ case dashboard = 1 // 0x01 - UI_BACKGROUND_DASHBOARD_APP_ID
55
+ case menu = 3 // 0x03 - UI_FOREGROUND_MEUN_ID (typo is intentional — matches Even's proto)
56
+ case evenAI = 7 // 0x07 - UI_FOREGROUND_EVEN_AI_ID
57
+ case g2Setting = 9 // 0x09 - UI_SETTING_APP_ID
58
+ case gestureCtrl = 13 // 0x0D - gesture_ctrl lifecycle signals
59
+ case onboarding = 16 // 0x10 - UI_ONBOARDING_APP_ID
60
+ case deviceSettings = 128 // 0x80 - UX_DEVICE_SETTINGS_APP_ID
61
+ case evenHubCtrl = 129 // 0x81 - EvenHub CTRL channel (init/registration)
62
+ case evenHub = 224 // 0xE0 - UI_BACKGROUND_EVENHUB_APP_ID
63
+ }
64
+
65
+ /// EvenHub command IDs from EvenHub.proto
66
+ private enum EvenHubCmd: Int32 {
67
+ case createStartupPage = 0 // APP_REQUEST_CREATE_STARTUP_PAGE_PACKET
68
+ case updateImageRawData = 3 // APP_UPDATE_IMAGE_RAW_DATA_PACKET
69
+ case updateTextData = 5 // APP_UPDATE_TEXT_DATA_PACKET
70
+ case rebuildPage = 7 // APP_REQUEST_REBUILD_PAGE_PACKET
71
+ case shutdownPage = 9 // APP_REQUEST_SHUTDOWN_PAGE_PACKET
72
+ case heartbeat = 12 // APP_REQUEST_HEARTBEAT_PACKET
73
+ case audioControl = 15 // APP_REQUEST_AUDIO_CTR_PACKET
74
+ }
75
+
76
+ /// EvenHub response command IDs (from glasses → phone)
77
+ private enum EvenHubResponseCmd: Int32 {
78
+ case osNotifyEventToApp = 2 // OS_NOITY_EVENT_TO_APP_PACKET - touch/gesture events
79
+ }
80
+
81
+ /// OsEventTypeList from EvenHub.proto
82
+ private enum OsEventType: Int32 {
83
+ case click = 0
84
+ case scrollTop = 1
85
+ case scrollBottom = 2
86
+ case doubleClick = 3
87
+ case foregroundEnter = 4
88
+ case foregroundExit = 5
89
+ case abnormalExit = 6
90
+ case systemExit = 7
91
+ }
92
+
93
+ /// g2_settingCommandId from g2_setting.proto
94
+ private enum G2SettingCommandId: Int32 {
95
+ case none = 0
96
+ case deviceReceiveInfo = 1 // Send settings TO glasses
97
+ case deviceReceiveRequest = 2 // Request info FROM glasses
98
+ case deviceSendToApp = 3 // Glasses sends info TO app
99
+ case deviceRespondToApp = 4 // Glasses responds to app
100
+ }
101
+
102
+ /// DevCfgCommandId from dev_config_protocol.proto
103
+ private enum DevCfgCommandId: Int32 {
104
+ case authentication = 4
105
+ case pipeRoleChange = 5
106
+ case ringConnectInfo = 6
107
+ case timeSync = 128
108
+ case baseConnHeartBeat = 14
109
+ }
110
+
111
+ // MARK: - CRC16 (matches Python calc_crc)
112
+
113
+ private func calcCRC16(_ data: Data) -> UInt16 {
114
+ var crc: UInt16 = 0xFFFF
115
+ for byte in data {
116
+ crc = ((crc >> 8) | ((crc << 8) & 0xFF00)) ^ UInt16(byte)
117
+ crc ^= (crc & 0xFF) >> 4
118
+ crc ^= (crc << 12) & 0xFFFF
119
+ crc ^= ((crc & 0xFF) << 5) & 0xFFFF
120
+ }
121
+ return crc & 0xFFFF
122
+ }
123
+
124
+ // MARK: - Minimal Protobuf Encoding Helpers
125
+
126
+ // We manually encode protobuf messages rather than using codegen.
127
+ // This keeps dependencies minimal and matches the known field numbers from the .proto files.
128
+
129
+ private struct ProtobufWriter {
130
+ private(set) var data = Data()
131
+
132
+ /// Varint encoding
133
+ mutating func writeVarint(_ value: UInt64) {
134
+ var v = value
135
+ while v > 0x7F {
136
+ data.append(UInt8(v & 0x7F) | 0x80)
137
+ v >>= 7
138
+ }
139
+ data.append(UInt8(v))
140
+ }
141
+
142
+ mutating func writeInt32Field(_ fieldNumber: Int, _ value: Int32) {
143
+ let tag = UInt64(fieldNumber << 3) | 0 // wire type 0 = varint
144
+ writeVarint(tag)
145
+ // protobuf int32 uses varint encoding; negative values use 10 bytes
146
+ if value >= 0 {
147
+ writeVarint(UInt64(value))
148
+ } else {
149
+ writeVarint(UInt64(bitPattern: Int64(value)))
150
+ }
151
+ }
152
+
153
+ mutating func writeStringField(_ fieldNumber: Int, _ value: String) {
154
+ let tag = UInt64(fieldNumber << 3) | 2 // wire type 2 = length-delimited
155
+ writeVarint(tag)
156
+ let utf8 = Array(value.utf8)
157
+ writeVarint(UInt64(utf8.count))
158
+ data.append(contentsOf: utf8)
159
+ }
160
+
161
+ mutating func writeBytesField(_ fieldNumber: Int, _ value: Data) {
162
+ let tag = UInt64(fieldNumber << 3) | 2 // wire type 2 = length-delimited
163
+ writeVarint(tag)
164
+ writeVarint(UInt64(value.count))
165
+ data.append(value)
166
+ }
167
+
168
+ /// Embed a sub-message (length-delimited)
169
+ mutating func writeMessageField(_ fieldNumber: Int, _ subMessage: Data) {
170
+ let tag = UInt64(fieldNumber << 3) | 2
171
+ writeVarint(tag)
172
+ writeVarint(UInt64(subMessage.count))
173
+ data.append(subMessage)
174
+ }
175
+
176
+ mutating func writeBoolField(_ fieldNumber: Int, _ value: Bool) {
177
+ writeInt32Field(fieldNumber, value ? 1 : 0)
178
+ }
179
+ }
180
+
181
+ // MARK: - Minimal Protobuf Decoding Helpers
182
+
183
+ private struct ProtobufReader {
184
+ private let data: Data
185
+ private var offset: Int = 0
186
+
187
+ init(_ data: Data) {
188
+ self.data = data
189
+ }
190
+
191
+ var hasMore: Bool {
192
+ offset < data.count
193
+ }
194
+
195
+ mutating func readVarint() -> UInt64? {
196
+ var result: UInt64 = 0
197
+ var shift: UInt64 = 0
198
+ while offset < data.count {
199
+ let byte = data[data.startIndex + offset]
200
+ offset += 1
201
+ result |= UInt64(byte & 0x7F) << shift
202
+ if byte & 0x80 == 0 { return result }
203
+ shift += 7
204
+ if shift > 63 { return nil }
205
+ }
206
+ return nil
207
+ }
208
+
209
+ /// Returns (fieldNumber, wireType) or nil
210
+ mutating func readTag() -> (Int, Int)? {
211
+ guard let tag = readVarint() else { return nil }
212
+ return (Int(tag >> 3), Int(tag & 0x07))
213
+ }
214
+
215
+ mutating func readInt32() -> Int32? {
216
+ guard let v = readVarint() else { return nil }
217
+ return Int32(truncatingIfNeeded: v)
218
+ }
219
+
220
+ mutating func readBytes() -> Data? {
221
+ guard let len = readVarint() else { return nil }
222
+ let length = Int(len)
223
+ guard offset + length <= data.count else { return nil }
224
+ let result = data[(data.startIndex + offset) ..< (data.startIndex + offset + length)]
225
+ offset += length
226
+ return Data(result)
227
+ }
228
+
229
+ mutating func readString() -> String? {
230
+ guard let bytes = readBytes() else { return nil }
231
+ return String(data: bytes, encoding: .utf8)
232
+ }
233
+
234
+ /// Skip a field value based on wire type
235
+ mutating func skipField(wireType: Int) {
236
+ switch wireType {
237
+ case 0: _ = readVarint() // varint
238
+ case 1: offset += 8 // 64-bit
239
+ case 2: _ = readBytes() // length-delimited
240
+ case 5: offset += 4 // 32-bit
241
+ default: break
242
+ }
243
+ }
244
+
245
+ /// Parse a message into a dictionary of field# -> value
246
+ /// Values are: Int32 for varint, Data for length-delimited
247
+ mutating func parseFields() -> [Int: Any] {
248
+ var fields: [Int: Any] = [:]
249
+ while hasMore {
250
+ guard let (fieldNum, wireType) = readTag() else { break }
251
+ switch wireType {
252
+ case 0: // varint
253
+ if let v = readVarint() { fields[fieldNum] = Int32(truncatingIfNeeded: v) }
254
+ case 2: // length-delimited (submessage or bytes or string)
255
+ if let d = readBytes() { fields[fieldNum] = d }
256
+ default:
257
+ skipField(wireType: wireType)
258
+ }
259
+ }
260
+ return fields
261
+ }
262
+ }
263
+
264
+ // MARK: - EvenHub Protobuf Message Builders
265
+
266
+ private enum EvenHubProto {
267
+ /// Build a TextContainerProperty message
268
+ static func textContainerProperty(
269
+ x: Int32, y: Int32, width: Int32, height: Int32,
270
+ borderWidth: Int32 = 0, borderColor: Int32 = 0, borderRadius: Int32 = 0,
271
+ paddingLength: Int32 = 0, containerID: Int32,
272
+ containerName: String? = nil, isEventCapture: Bool = false,
273
+ content: String? = nil
274
+ ) -> Data {
275
+ var w = ProtobufWriter()
276
+ w.writeInt32Field(1, x) // XPosition
277
+ w.writeInt32Field(2, y) // YPosition
278
+ w.writeInt32Field(3, width) // Width
279
+ w.writeInt32Field(4, height) // Height
280
+ w.writeInt32Field(5, borderWidth) // BorderWidth
281
+ w.writeInt32Field(6, borderColor) // BorderColor
282
+ w.writeInt32Field(7, borderRadius) // BorderRdaius (sic - typo in proto)
283
+ w.writeInt32Field(8, paddingLength) // PaddingLength
284
+ w.writeInt32Field(9, containerID) // ContainerID
285
+ if let name = containerName {
286
+ w.writeStringField(10, name) // ContainerName
287
+ }
288
+ w.writeInt32Field(11, isEventCapture ? 1 : 0) // IsEventCapture
289
+ if let content = content {
290
+ w.writeStringField(12, content) // Content
291
+ }
292
+ return w.data
293
+ }
294
+
295
+ /// Build an ImageContainerProperty message
296
+ static func imageContainerProperty(
297
+ x: Int32, y: Int32, width: Int32, height: Int32,
298
+ containerID: Int32, containerName: String? = nil
299
+ ) -> Data {
300
+ var w = ProtobufWriter()
301
+ w.writeInt32Field(1, x) // XPosition
302
+ w.writeInt32Field(2, y) // YPosition
303
+ w.writeInt32Field(3, width) // Width
304
+ w.writeInt32Field(4, height) // Height
305
+ w.writeInt32Field(5, containerID) // ContainerID
306
+ if let name = containerName {
307
+ w.writeStringField(6, name) // ContainerName
308
+ }
309
+ return w.data
310
+ }
311
+
312
+ /// Build an ImageRawDataUpdate message
313
+ static func imageRawDataUpdate(
314
+ containerID: Int32, containerName: String? = nil,
315
+ mapSessionId: Int32, mapTotalSize: Int32, compressMode: Int32 = 0,
316
+ mapFragmentIndex: Int32, mapFragmentPacketSize: Int32, mapRawData: Data
317
+ ) -> Data {
318
+ var w = ProtobufWriter()
319
+ w.writeInt32Field(1, containerID) // ContainerID
320
+ if let name = containerName {
321
+ w.writeStringField(2, name) // ContainerName
322
+ }
323
+ w.writeInt32Field(3, mapSessionId) // MapSessionId
324
+ w.writeInt32Field(4, mapTotalSize) // MapTotalSize
325
+ w.writeInt32Field(5, compressMode) // CompressMode
326
+ w.writeInt32Field(6, mapFragmentIndex) // MapFragmentIndex
327
+ w.writeInt32Field(7, mapFragmentPacketSize) // MapFragmentPacketSize
328
+ w.writeBytesField(8, mapRawData) // MapRawData
329
+ return w.data
330
+ }
331
+
332
+ /// Build a CreateStartUpPageContainer message
333
+ static func createStartupPageContainer(
334
+ containerTotalNum: Int32,
335
+ textContainers: [Data] = [],
336
+ imageContainers: [Data] = []
337
+ ) -> Data {
338
+ var w = ProtobufWriter()
339
+ w.writeInt32Field(1, containerTotalNum) // ContainerTotalNum
340
+ // field 2 = repeated ListContainerProperty ListObject (not used here)
341
+ for tc in textContainers {
342
+ w.writeMessageField(3, tc) // field 3 = repeated TextObject
343
+ }
344
+ for ic in imageContainers {
345
+ w.writeMessageField(4, ic) // field 4 = repeated ImageObject
346
+ }
347
+ return w.data
348
+ }
349
+
350
+ /// Build a TextContainerUpgrade message
351
+ static func textContainerUpgrade(
352
+ containerID: Int32, contentOffset: Int32 = 0,
353
+ contentLength: Int32, content: String
354
+ ) -> Data {
355
+ var w = ProtobufWriter()
356
+ w.writeInt32Field(1, containerID) // ContainerID
357
+ w.writeInt32Field(3, contentOffset) // ContentOffset
358
+ w.writeInt32Field(4, contentLength) // ContentLength
359
+ w.writeStringField(5, content) // Content
360
+ return w.data
361
+ }
362
+
363
+ /// Build a ShutDownContaniner message (sic - typo in proto)
364
+ static func shutdownContainer(exitMode: Int32 = 0) -> Data {
365
+ var w = ProtobufWriter()
366
+ w.writeInt32Field(1, exitMode) // exitMode
367
+ return w.data
368
+ }
369
+
370
+ /// Build a HeartBeatPacket message
371
+ static func heartbeatPacket(cnt: Int32 = 0) -> Data {
372
+ var w = ProtobufWriter()
373
+ if cnt != 0 {
374
+ w.writeInt32Field(1, cnt) // Cnt
375
+ }
376
+ return w.data
377
+ }
378
+
379
+ /// Build an AudioCtrCmd message
380
+ static func audioCtrCmd(enable: Bool) -> Data {
381
+ var w = ProtobufWriter()
382
+ w.writeInt32Field(1, enable ? 1 : 0) // AudoFuncEn
383
+ return w.data
384
+ }
385
+
386
+ /// Build an evenhub_main_msg_ctx wrapper
387
+ /// appId: optional menu item appId to associate the page with (enables cmdId=17 selection events)
388
+ static func evenHubMessage(
389
+ cmd: EvenHubCmd, subFieldNumber: Int, subMessage: Data, magicRandom: Int32 = 0,
390
+ appId: Int32? = nil
391
+ ) -> Data {
392
+ var w = ProtobufWriter()
393
+ w.writeInt32Field(1, cmd.rawValue) // Cmd (field 1, enum)
394
+ w.writeInt32Field(2, magicRandom) // MagicRandom (field 2)
395
+ w.writeMessageField(subFieldNumber, subMessage) // the actual command payload
396
+ if let appId = appId {
397
+ w.writeInt32Field(5, appId) // Associate page with a menu item appId
398
+ }
399
+ return w.data
400
+ }
401
+
402
+ /// Convenience builders for full evenhub messages
403
+ static func createPageMessage(
404
+ textContainers: [Data] = [], imageContainers: [Data] = [], magicRandom: Int32 = 0,
405
+ appId _: Int32? = nil
406
+ ) -> Data {
407
+ let total = Int32(textContainers.count + imageContainers.count)
408
+ let createMsg = createStartupPageContainer(
409
+ containerTotalNum: total,
410
+ textContainers: textContainers,
411
+ imageContainers: imageContainers
412
+ )
413
+ return evenHubMessage(
414
+ cmd: .createStartupPage, subFieldNumber: 3, subMessage: createMsg,
415
+ magicRandom: magicRandom, appId: nil
416
+ )
417
+ }
418
+
419
+ // RebuildPageContainer: same structure as CreateStartUpPageContainer, but cmd=7, field 7
420
+ static func rebuildPageMessage(
421
+ textContainers: [Data] = [], imageContainers: [Data] = [], magicRandom: Int32 = 0,
422
+ appId: Int32? = nil
423
+ )
424
+ -> Data
425
+ {
426
+ let total = Int32(textContainers.count + imageContainers.count)
427
+ let rebuildMsg = createStartupPageContainer(
428
+ containerTotalNum: total,
429
+ textContainers: textContainers,
430
+ imageContainers: imageContainers
431
+ )
432
+ return evenHubMessage(
433
+ cmd: .rebuildPage, subFieldNumber: 7, subMessage: rebuildMsg, magicRandom: magicRandom,
434
+ appId: appId
435
+ )
436
+ }
437
+
438
+ static func updateImageRawDataMessage(
439
+ containerID: Int32, containerName: String? = nil,
440
+ mapSessionId: Int32, mapTotalSize: Int32, compressMode: Int32 = 0,
441
+ mapFragmentIndex: Int32, mapFragmentPacketSize: Int32, mapRawData: Data
442
+ ) -> Data {
443
+ let updateMsg = imageRawDataUpdate(
444
+ containerID: containerID, containerName: containerName,
445
+ mapSessionId: mapSessionId, mapTotalSize: mapTotalSize,
446
+ compressMode: compressMode,
447
+ mapFragmentIndex: mapFragmentIndex,
448
+ mapFragmentPacketSize: mapFragmentPacketSize,
449
+ mapRawData: mapRawData
450
+ )
451
+ return evenHubMessage(cmd: .updateImageRawData, subFieldNumber: 5, subMessage: updateMsg)
452
+ }
453
+
454
+ static func updateTextMessage(
455
+ containerID: Int32, contentOffset: Int32 = 0, contentLength: Int32, content: String
456
+ ) -> Data {
457
+ let upgradeMsg = textContainerUpgrade(
458
+ containerID: containerID, contentOffset: contentOffset,
459
+ contentLength: contentLength, content: content
460
+ )
461
+ return evenHubMessage(cmd: .updateTextData, subFieldNumber: 9, subMessage: upgradeMsg)
462
+ }
463
+
464
+ static func shutdownMessage(exitMode: Int32 = 0) -> Data {
465
+ let shutdownMsg = shutdownContainer(exitMode: exitMode)
466
+ return evenHubMessage(cmd: .shutdownPage, subFieldNumber: 11, subMessage: shutdownMsg)
467
+ }
468
+
469
+ static func heartbeatMessage(magicRandom: Int32 = 0) -> Data {
470
+ let hbMsg = heartbeatPacket()
471
+ return evenHubMessage(
472
+ cmd: .heartbeat, subFieldNumber: 14, subMessage: hbMsg, magicRandom: magicRandom
473
+ )
474
+ }
475
+
476
+ static func audioControlMessage(enable: Bool, magicRandom: Int32 = 0) -> Data {
477
+ let audioMsg = audioCtrCmd(enable: enable)
478
+ return evenHubMessage(
479
+ cmd: .audioControl, subFieldNumber: 18, subMessage: audioMsg, magicRandom: magicRandom
480
+ )
481
+ }
482
+ }
483
+
484
+ // MARK: - DevSettings Auth Protobuf Builders
485
+
486
+ private enum DevSettingsProto {
487
+ /// DevCfgDataPackage with AUTHENTICATION command
488
+ static func authCmd(magicRandom: Int32) -> Data {
489
+ // DevCfgDataPackage:
490
+ // field 1 = commandId (enum)
491
+ // field 2 = magicRandom (int32)
492
+ // field 3 = authMgr (AuthMgr message)
493
+ var w = ProtobufWriter()
494
+ w.writeInt32Field(1, DevCfgCommandId.authentication.rawValue) // commandId
495
+ w.writeInt32Field(2, magicRandom) // magicRandom
496
+
497
+ // AuthMgr sub-message:
498
+ // field 1 = secAuth (bool)
499
+ // field 2 = phoneType (enum eDevice: PHONE_IOS=3, PHONE_ANDROID=4)
500
+ var authW = ProtobufWriter()
501
+ authW.writeBoolField(1, true) // secAuth
502
+ authW.writeInt32Field(2, 3) // phoneType = PHONE_IOS (eDevice.PHONE_IOS=3)
503
+
504
+ w.writeMessageField(3, authW.data) // authMgr
505
+ return w.data
506
+ }
507
+
508
+ /// DevCfgDataPackage with PIPE_ROLE_CHANGE command
509
+ static func pipeRoleChange(magicRandom: Int32) -> Data {
510
+ var w = ProtobufWriter()
511
+ w.writeInt32Field(1, DevCfgCommandId.pipeRoleChange.rawValue)
512
+ w.writeInt32Field(2, magicRandom)
513
+
514
+ // PipeRoleChange: field 1 = asCmdRole (enum GlassesLR.RIGHT=1)
515
+ var roleW = ProtobufWriter()
516
+ roleW.writeInt32Field(1, 1) // RIGHT
517
+ w.writeMessageField(4, roleW.data) // roleChange (field 4 in DevCfgDataPackage)
518
+ return w.data
519
+ }
520
+
521
+ /// DevCfgDataPackage with TIME_SYNC command
522
+ static func timeSync(magicRandom: Int32) -> Data {
523
+ var w = ProtobufWriter()
524
+ w.writeInt32Field(1, DevCfgCommandId.timeSync.rawValue)
525
+ w.writeInt32Field(2, magicRandom)
526
+
527
+ // TimeSync: field 1 = timestamp (int32), field 2 = timezone (int32)
528
+ var tsW = ProtobufWriter()
529
+ let timestamp = Int32(Date().timeIntervalSince1970)
530
+ tsW.writeInt32Field(1, timestamp)
531
+ let tz = Int32(TimeZone.current.secondsFromGMT() / 3600)
532
+ tsW.writeInt32Field(2, tz)
533
+ w.writeMessageField(128, tsW.data) // timeSync (field 128 in DevCfgDataPackage)
534
+ return w.data
535
+ }
536
+
537
+ /// DevCfgDataPackage with BASE_CONNECT_HEART_BEAT command
538
+ static func baseHeartbeat(magicRandom: Int32) -> Data {
539
+ var w = ProtobufWriter()
540
+ w.writeInt32Field(1, DevCfgCommandId.baseConnHeartBeat.rawValue)
541
+ w.writeInt32Field(2, magicRandom)
542
+
543
+ // BaseConnHeartBeat: empty message
544
+ var hbW = ProtobufWriter()
545
+ _ = hbW // empty
546
+ w.writeMessageField(13, hbW.data) // baseHeartBeat (field 13)
547
+ return w.data
548
+ }
549
+
550
+ /// DevCfgDataPackage with RING_CONNECT_INFO command
551
+ /// Tells the glasses to connect/disconnect to a ring by MAC address.
552
+ /// RingInfo: field 1 = connectRing (bool), field 2 = ringMac (bytes), field 3 = ringName (bytes)
553
+ static func ringConnectInfo(
554
+ magicRandom: Int32, connect: Bool, ringMac: Data, ringName: String = ""
555
+ ) -> Data {
556
+ var w = ProtobufWriter()
557
+ w.writeInt32Field(1, DevCfgCommandId.ringConnectInfo.rawValue) // commandId = RING_CONNECT_INFO (6)
558
+ w.writeInt32Field(2, magicRandom)
559
+
560
+ // RingInfo sub-message (field 5 in DevCfgDataPackage)
561
+ var ringW = ProtobufWriter()
562
+ ringW.writeBoolField(1, connect) // connectRing
563
+ ringW.writeBytesField(2, ringMac) // ringMac (6 bytes)
564
+ if !ringName.isEmpty {
565
+ ringW.writeBytesField(3, Data(ringName.utf8)) // ringName
566
+ }
567
+
568
+ w.writeMessageField(5, ringW.data) // ringInfo (field 5)
569
+ return w.data
570
+ }
571
+ }
572
+
573
+ // MARK: - G2 Settings Protobuf Builders (g2_setting.proto, service ID 9)
574
+
575
+ private enum G2SettingProto {
576
+ /// Set brightness: G2SettingPackage with DeviceReceiveInfo + DeviceReceive_Brightness
577
+ static func setBrightness(magicRandom: Int32, level: Int32, autoAdjust: Bool) -> Data {
578
+ // DeviceReceive_Brightness
579
+ var brightnessW = ProtobufWriter()
580
+ brightnessW.writeInt32Field(1, autoAdjust ? 1 : 0) // autoAdjust
581
+ brightnessW.writeInt32Field(2, level) // brightnessLevel
582
+
583
+ // DeviceReceiveInfoFromAPP
584
+ var infoW = ProtobufWriter()
585
+ infoW.writeMessageField(1, brightnessW.data) // deviceReceiveBrightness (field 1)
586
+
587
+ // G2SettingPackage
588
+ var w = ProtobufWriter()
589
+ w.writeInt32Field(1, G2SettingCommandId.deviceReceiveInfo.rawValue) // commandId
590
+ w.writeInt32Field(2, magicRandom)
591
+ w.writeMessageField(3, infoW.data) // deviceReceiveInfoFromApp (field 3)
592
+ return w.data
593
+ }
594
+
595
+ /// Request battery/version/etc: G2SettingPackage with DeviceReceiveRequest
596
+ static func requestInfo(magicRandom: Int32) -> Data {
597
+ // DeviceReceiveRequestFromAPP - empty message triggers glasses to respond with all fields
598
+ var reqW = ProtobufWriter()
599
+ // Request brightness info type
600
+ reqW.writeInt32Field(1, 1) // settingInfoType = APP_REQUIRE_BASIC_SETTING
601
+
602
+ // G2SettingPackage
603
+ var w = ProtobufWriter()
604
+ w.writeInt32Field(1, G2SettingCommandId.deviceReceiveRequest.rawValue) // commandId
605
+ w.writeInt32Field(2, magicRandom)
606
+ w.writeMessageField(4, reqW.data) // deviceReceiveRequestFromApp (field 4)
607
+ return w.data
608
+ }
609
+
610
+ /// Toggle head-up display on/off
611
+ static func setHeadUpSwitch(magicRandom: Int32, enabled: Bool) -> Data {
612
+ // DeviceReceive_Head_UP_Setting
613
+ var headUpW = ProtobufWriter()
614
+ headUpW.writeInt32Field(1, enabled ? 1 : 0) // headUpSwitch
615
+
616
+ // DeviceReceiveInfoFromAPP
617
+ var infoW = ProtobufWriter()
618
+ infoW.writeMessageField(4, headUpW.data) // deviceReceiveHeadUpSetting (field 4)
619
+
620
+ // G2SettingPackage
621
+ var w = ProtobufWriter()
622
+ w.writeInt32Field(1, G2SettingCommandId.deviceReceiveInfo.rawValue)
623
+ w.writeInt32Field(2, magicRandom)
624
+ w.writeMessageField(3, infoW.data) // deviceReceiveInfoFromApp (field 3)
625
+ return w.data
626
+ }
627
+
628
+ /// Set head-up trigger angle (0-60 degrees)
629
+ static func setHeadUpAngle(magicRandom: Int32, angle: Int32) -> Data {
630
+ // DeviceReceive_Head_UP_Setting
631
+ var headUpW = ProtobufWriter()
632
+ headUpW.writeInt32Field(2, angle) // headUpAngle (field 2)
633
+
634
+ // DeviceReceiveInfoFromAPP
635
+ var infoW = ProtobufWriter()
636
+ infoW.writeMessageField(4, headUpW.data) // deviceReceiveHeadUpSetting (field 4)
637
+
638
+ // G2SettingPackage
639
+ var w = ProtobufWriter()
640
+ w.writeInt32Field(1, G2SettingCommandId.deviceReceiveInfo.rawValue)
641
+ w.writeInt32Field(2, magicRandom)
642
+ w.writeMessageField(3, infoW.data)
643
+ return w.data
644
+ }
645
+
646
+ /// Set screen height (Y coordinate level, 0-12)
647
+ static func setScreenHeight(magicRandom: Int32, level: Int32) -> Data {
648
+ // DeviceReceive_Y_Coordinate
649
+ var yW = ProtobufWriter()
650
+ yW.writeInt32Field(1, level) // yCoordinateLevel
651
+
652
+ // DeviceReceiveInfoFromAPP
653
+ var infoW = ProtobufWriter()
654
+ infoW.writeMessageField(2, yW.data) // deviceReceiveYCoordinate (field 2)
655
+
656
+ // G2SettingPackage
657
+ var w = ProtobufWriter()
658
+ w.writeInt32Field(1, G2SettingCommandId.deviceReceiveInfo.rawValue)
659
+ w.writeInt32Field(2, magicRandom)
660
+ w.writeMessageField(3, infoW.data)
661
+ return w.data
662
+ }
663
+
664
+ /// Set screen depth (X coordinate level, 0-2)
665
+ static func setScreenDepth(magicRandom: Int32, level: Int32) -> Data {
666
+ // DeviceReceive_X_Coordinate
667
+ var xW = ProtobufWriter()
668
+ xW.writeInt32Field(1, level) // xCoordinateLevel
669
+
670
+ // DeviceReceiveInfoFromAPP
671
+ var infoW = ProtobufWriter()
672
+ infoW.writeMessageField(3, xW.data) // deviceReceiveXCoordinate (field 3)
673
+
674
+ // G2SettingPackage
675
+ var w = ProtobufWriter()
676
+ w.writeInt32Field(1, G2SettingCommandId.deviceReceiveInfo.rawValue)
677
+ w.writeInt32Field(2, magicRandom)
678
+ w.writeMessageField(3, infoW.data)
679
+ return w.data
680
+ }
681
+ }
682
+
683
+ // MARK: - Onboarding Protobuf Builders (onboarding.proto, service ID 16)
684
+
685
+ private enum OnboardingProto {
686
+ /// Skip onboarding: OnboardingDataPackage with CONFIG command, processId=FINISH
687
+ static func skipOnboarding(magicRandom: Int32) -> Data {
688
+ // OnboardingConfig: processId = FINISH (4)
689
+ var configW = ProtobufWriter()
690
+ configW.writeInt32Field(1, 4) // processId = FINISH
691
+
692
+ // OnboardingDataPackage
693
+ var w = ProtobufWriter()
694
+ w.writeInt32Field(1, 1) // commandId = CONFIG
695
+ w.writeInt32Field(2, magicRandom)
696
+ w.writeMessageField(3, configW.data) // config (field 3)
697
+ return w.data
698
+ }
699
+ }
700
+
701
+ // MARK: - EvenAI Protobuf Builders (even_ai.proto, service ID 7)
702
+
703
+ private enum EvenAIProto {
704
+ /// EvenAIDataPackage with CONFIG command to toggle Hey Even wakeword
705
+ /// voiceSwitch: 0 = OFF, 1 = ON
706
+ static func setHeyEven(magicRandom: Int32, enabled: Bool) -> Data {
707
+ // EvenAIConfig
708
+ var configW = ProtobufWriter()
709
+ configW.writeInt32Field(1, enabled ? 1 : 0) // voiceSwitch
710
+ configW.writeInt32Field(2, 80) // streamSpeed (always sent)
711
+
712
+ // EvenAIDataPackage
713
+ var w = ProtobufWriter()
714
+ w.writeInt32Field(1, 10) // commandId = CONFIG
715
+ w.writeInt32Field(2, magicRandom)
716
+ w.writeMessageField(13, configW.data) // config (field 13)
717
+ return w.data
718
+ }
719
+ }
720
+
721
+ // MARK: - Menu Protobuf Builders (menu.proto, service ID 3)
722
+
723
+ private enum MenuProto {
724
+ /// Input from RN — packageName + display name + running state
725
+ struct MenuItem {
726
+ let packageName: String
727
+ let name: String
728
+ let running: Bool
729
+ }
730
+
731
+ /// G2 firmware requires minimum 5, maximum 10 menu items
732
+ static let MIN_MENU_SIZE = 5
733
+ static let MAX_MENU_SIZE = 10
734
+ static let MAX_NAME_LENGTH = 15 // 17 char limit minus 2 for running indicator prefix
735
+ /// Placeholder appIds for padding slots (in valid Even range, unique per slot)
736
+ static let PLACEHOLDER_APP_IDS: [Int32] = [10535, 10536, 10537, 10538, 10539]
737
+
738
+ /// Deterministic hash of packageName → numeric appId in range 10029–10534
739
+ /// Even's third-party appIds are all in the 10029–10539 range
740
+ static func packageNameToAppId(_ packageName: String) -> Int32 {
741
+ var hash: Int32 = 0
742
+ for char in packageName.unicodeScalars {
743
+ hash = ((hash &<< 5) &- hash) &+ Int32(char.value)
744
+ }
745
+ // 506 values: 10029–10534 (reserve 10535–10539 for placeholders)
746
+ return 10029 + (abs(hash) % 506)
747
+ }
748
+
749
+ /// meun_main_msg_ctx with APP_SEND_MENU_INFO command
750
+ /// Handles: name truncation (15 chars), running prefix ("● " / " "), padding to 5, cap at 10
751
+ /// Returns (protobuf data, appId→packageName mapping for reverse lookup)
752
+ /// meun_main_msg_ctx with APP_SEND_MENU_INFO command
753
+ /// Handles: name truncation (15 chars), running prefix ("● " / " "), padding to 5, cap at 10
754
+ /// Always prepends the built-in Notification item as the first entry.
755
+ /// Returns (protobuf data, appId→packageName mapping for reverse lookup)
756
+ static func sendMenuInfo(magicRandom: Int32, items: [MenuItem]) -> (Data, [Int32: String]) {
757
+ var appIdMap: [Int32: String] = [:]
758
+
759
+ // Wire items carry either a built-in (itemType=0, no name) or third-party (itemType=1, with name)
760
+ struct WireItem {
761
+ let displayName: String? // nil for built-ins
762
+ let appId: Int32
763
+ let isBuiltIn: Bool
764
+ }
765
+
766
+ var wireItems: [WireItem] = []
767
+
768
+ // Always first: built-in Notification (SID=4)
769
+ wireItems.append(WireItem(displayName: nil, appId: 4, isBuiltIn: true))
770
+
771
+ // Third-party items — leave room for the built-in
772
+ for item in items.prefix(MAX_MENU_SIZE - 1) {
773
+ let appId = packageNameToAppId(item.packageName)
774
+ appIdMap[appId] = item.packageName
775
+
776
+ let truncated =
777
+ item.name.count > MAX_NAME_LENGTH
778
+ ? String(item.name.prefix(MAX_NAME_LENGTH))
779
+ : item.name
780
+ let prefix = item.running ? "● " : ""
781
+ wireItems.append(
782
+ WireItem(displayName: prefix + truncated, appId: appId, isBuiltIn: false)
783
+ )
784
+ }
785
+
786
+ // Pad to MIN_MENU_SIZE with placeholder third-party items
787
+ while wireItems.count < MIN_MENU_SIZE {
788
+ let idx = wireItems.count - 1 // -1 because built-in occupies slot 0
789
+ wireItems.append(
790
+ WireItem(
791
+ displayName: " ---",
792
+ appId: PLACEHOLDER_APP_IDS[idx],
793
+ isBuiltIn: false
794
+ )
795
+ )
796
+ }
797
+
798
+ // MenuInfoSend
799
+ var menuW = ProtobufWriter()
800
+ menuW.writeInt32Field(1, Int32(wireItems.count)) // itemTotalNum
801
+
802
+ for item in wireItems {
803
+ var itemW = ProtobufWriter()
804
+ if item.isBuiltIn {
805
+ itemW.writeInt32Field(1, 0) // itemType = 0 (built-in)
806
+ itemW.writeInt32Field(4, item.appId) // itemAppId = SID
807
+ } else {
808
+ itemW.writeInt32Field(1, 1) // itemType = 1 (third-party)
809
+ itemW.writeInt32Field(2, 1) // iconNum = 1
810
+ itemW.writeStringField(3, item.displayName ?? "") // itemName
811
+ itemW.writeInt32Field(4, item.appId) // itemAppId
812
+ }
813
+ menuW.writeMessageField(2, itemW.data) // repeated item (field 2)
814
+ }
815
+
816
+ // meun_main_msg_ctx
817
+ var w = ProtobufWriter()
818
+ w.writeInt32Field(1, 0) // Cmd = APP_SEND_MENU_INFO (0)
819
+ w.writeInt32Field(2, magicRandom) // MagicRandom
820
+ w.writeMessageField(3, menuW.data) // sendData (field 3)
821
+ return (w.data, appIdMap)
822
+ }
823
+ }
824
+
825
+ // MARK: - EvenBLE Transport Layer
826
+
827
+ /// Builds and splits payloads into BLE packets with the EvenHub transport framing
828
+ private struct EvenBLETransport {
829
+ var syncId: UInt8
830
+
831
+ /// Build one or more framed packets for a payload
832
+ static func buildPackets(
833
+ syncId: UInt8, serviceId: UInt8, payload: Data, reserveFlag: Bool = false
834
+ ) -> [Data] {
835
+ let maxPayload = G2BLE.MAX_PACKET_PAYLOAD
836
+
837
+ // Split payload into chunks
838
+ var chunks: [Data] = []
839
+ var offset = 0
840
+ while offset < payload.count {
841
+ let end = min(offset + maxPayload, payload.count)
842
+ chunks.append(payload[offset ..< end])
843
+ offset = end
844
+ }
845
+ if chunks.isEmpty {
846
+ chunks.append(Data())
847
+ }
848
+
849
+ // If last chunk is exactly max size, we need an extra packet for CRC
850
+ let needExtraCrcPacket = (chunks.last!.count == maxPayload)
851
+ if needExtraCrcPacket {
852
+ chunks.append(Data())
853
+ }
854
+
855
+ let totalPackets = UInt8(chunks.count)
856
+ let crc = calcCRC16(payload)
857
+
858
+ var packets: [Data] = []
859
+ for (i, chunk) in chunks.enumerated() {
860
+ let serialNum = UInt8(i + 1)
861
+ let isLast = (serialNum == totalPackets)
862
+
863
+ // status byte: bit0=notify, bits1-4=resultCode, bit5=reserveFlag, bits6-7=reserve
864
+ let status: UInt8 = (reserveFlag ? 0x20 : 0x00)
865
+
866
+ // payload length includes CRC if last packet
867
+ let payloadLen = UInt8(chunk.count + (isLast ? 2 : 0))
868
+
869
+ var packet = Data()
870
+ packet.append(G2BLE.HEADER_BYTE) // [0] 0xAA
871
+ packet.append((G2BLE.DEST_GLASSES << 4) | G2BLE.SOURCE_PHONE) // [1] src+dst
872
+ packet.append(syncId) // [2] syncId
873
+ packet.append(payloadLen) // [3] payloadLen
874
+ packet.append(totalPackets) // [4] packetTotalNum
875
+ packet.append(serialNum) // [5] packetSerialNum
876
+ packet.append(serviceId) // [6] serviceId
877
+ packet.append(status) // [7] status
878
+
879
+ packet.append(chunk)
880
+
881
+ if isLast {
882
+ packet.append(UInt8(crc & 0xFF)) // CRC low
883
+ packet.append(UInt8((crc >> 8) & 0xFF)) // CRC high
884
+ }
885
+
886
+ packets.append(packet)
887
+ }
888
+
889
+ return packets
890
+ }
891
+ }
892
+
893
+ // MARK: - G2 Send Manager
894
+
895
+ /// Manages syncId counter and sends packets over BLE
896
+ private class G2SendManager {
897
+ private var syncId: UInt8 = 0
898
+ private var magicRandom: UInt8 = 0
899
+
900
+ func nextSyncId() -> UInt8 {
901
+ let id = syncId
902
+ syncId = syncId &+ 1
903
+ return id
904
+ }
905
+
906
+ func nextMagicRandom() -> Int32 {
907
+ let val = magicRandom
908
+ magicRandom = magicRandom &+ 1
909
+ return Int32(val)
910
+ }
911
+
912
+ func buildPackets(serviceId: UInt8, payload: Data, reserveFlag: Bool = false) -> [Data] {
913
+ let sid = nextSyncId()
914
+ return EvenBLETransport.buildPackets(
915
+ syncId: sid, serviceId: serviceId, payload: payload, reserveFlag: reserveFlag
916
+ )
917
+ }
918
+ }
919
+
920
+ // MARK: - G2 Receive Manager (multi-part reassembly)
921
+
922
+ private class G2ReceiveManager {
923
+ private var partials: [String: (Data, UInt8)] = [:] // key -> (accumulated payload, lastSerialNum)
924
+
925
+ func handlePacket(_ rawData: Data, sourceKey: String = "") -> (serviceId: UInt8, payload: Data)? {
926
+ guard rawData.count >= 8 else { return nil }
927
+ guard rawData[0] == G2BLE.HEADER_BYTE else { return nil }
928
+
929
+ let payloadLen = Int(rawData[3])
930
+ let expectedLen = payloadLen + 8
931
+ guard rawData.count >= expectedLen else { return nil }
932
+
933
+ let totalPackets = rawData[4]
934
+ let serialNum = rawData[5]
935
+ let serviceId = rawData[6]
936
+ let status = rawData[7]
937
+ let resultCode = (status >> 1) & 0x0F
938
+
939
+ guard resultCode == 0 else { return nil }
940
+
941
+ let isLast = (serialNum == totalPackets)
942
+ let hasCrc = isLast
943
+ let payloadEnd = 8 + payloadLen - (hasCrc ? 2 : 0)
944
+ let payload = rawData[8 ..< payloadEnd]
945
+
946
+ let syncId = rawData[2]
947
+ // Key partials by source peripheral too — left and right glasses have independent syncId counters
948
+ let key = "\(sourceKey)-\(serviceId)-\(syncId)"
949
+
950
+ if serialNum > 1 {
951
+ guard var existing = partials[key] else { return nil }
952
+ existing.0.append(payload)
953
+ existing.1 = serialNum
954
+ partials[key] = existing
955
+ } else if totalPackets > 1 {
956
+ partials[key] = (Data(payload), serialNum)
957
+ }
958
+
959
+ if !isLast {
960
+ if serialNum == 1 && totalPackets > 1 {
961
+ // Already stored above
962
+ }
963
+ return nil
964
+ }
965
+
966
+ let fullPayload: Data
967
+ if let existing = partials[key] {
968
+ var accumulated = existing.0
969
+ if serialNum > 1 {
970
+ // already appended above
971
+ } else {
972
+ accumulated.append(payload)
973
+ }
974
+ fullPayload = accumulated
975
+ partials.removeValue(forKey: key)
976
+ } else {
977
+ fullPayload = Data(payload)
978
+ }
979
+
980
+ return (serviceId, fullPayload)
981
+ }
982
+ }
983
+
984
+ // MARK: - G2 Class (SGCManager implementation)
985
+
986
+ /// Actor for reconnection logic (matches G1 pattern)
987
+ actor G2ReconnectionManager {
988
+ private var task: Task<Void, Never>?
989
+ private let intervalSeconds: TimeInterval
990
+ private var attempts = 0
991
+ private let maxAttempts: Int // -1 for unlimited
992
+
993
+ init(intervalSeconds: TimeInterval = 30, maxAttempts: Int = -1) {
994
+ self.intervalSeconds = intervalSeconds
995
+ self.maxAttempts = maxAttempts
996
+ }
997
+
998
+ var isRunning: Bool {
999
+ task != nil && task?.isCancelled == false
1000
+ }
1001
+
1002
+ func start(onAttempt: @escaping @Sendable () async -> Bool) {
1003
+ stop()
1004
+ attempts = 0
1005
+
1006
+ task = Task {
1007
+ while !Task.isCancelled {
1008
+ if maxAttempts > 0, attempts >= maxAttempts {
1009
+ Bridge.log("G2: Max reconnection attempts (\(maxAttempts)) reached")
1010
+ break
1011
+ }
1012
+
1013
+ attempts += 1
1014
+ Bridge.log("G2: Reconnection attempt \(attempts)")
1015
+
1016
+ let shouldStop = await onAttempt()
1017
+
1018
+ if shouldStop {
1019
+ Bridge.log("G2: Reconnection successful, stopping")
1020
+ break
1021
+ }
1022
+
1023
+ do {
1024
+ try await Task.sleep(nanoseconds: UInt64(intervalSeconds * 1_000_000_000))
1025
+ } catch {
1026
+ break
1027
+ }
1028
+ }
1029
+ }
1030
+ }
1031
+
1032
+ func stop() {
1033
+ task?.cancel()
1034
+ task = nil
1035
+ attempts = 0
1036
+ }
1037
+ }
1038
+
1039
+ @MainActor
1040
+ class G2: NSObject, SGCManager {
1041
+ func sendIncidentId(_: String, apiBaseUrl _: String?) {}
1042
+
1043
+ var type = DeviceTypes.G2
1044
+ let hasMic = true
1045
+
1046
+ /// Connection state
1047
+ private var connectionState: String = ConnTypes.DISCONNECTED
1048
+
1049
+ // BLE peripherals (L+R)
1050
+ private var centralManager: CBCentralManager?
1051
+ private var leftPeripheral: CBPeripheral?
1052
+ private var rightPeripheral: CBPeripheral?
1053
+ private var leftWriteChar: CBCharacteristic?
1054
+ private var rightWriteChar: CBCharacteristic?
1055
+ private var leftNotifyChar: CBCharacteristic?
1056
+ private var rightNotifyChar: CBCharacteristic?
1057
+ private var rightAudioChar: CBCharacteristic?
1058
+ private var leftAudioChar: CBCharacteristic?
1059
+ private var leftInitialized: Bool = false
1060
+ private var rightInitialized: Bool = false
1061
+ private var leftAuthenticated: Bool = false
1062
+ private var rightAuthenticated: Bool = false
1063
+ private var isDisconnecting = false
1064
+ private var pairingTimeoutTimer: DispatchWorkItem?
1065
+
1066
+ /// Device search
1067
+ var DEVICE_SEARCH_ID = "NOT_SET"
1068
+ /// map device names to serial numbers:
1069
+ private var deviceNameToSerialNumber: [String: String] = [:]
1070
+
1071
+ /// Stored UUIDs per serial number for background reconnection.
1072
+ /// Maps serial number -> peripheral UUID string. Persisted across forget() so previously
1073
+ /// paired devices can reconnect quickly without a fresh scan.
1074
+ private var leftGlassUUIDMap: [String: String] {
1075
+ get { UserDefaults.standard.dictionary(forKey: "g2_leftGlassUUIDMap") as? [String: String] ?? [:] }
1076
+ set { UserDefaults.standard.set(newValue, forKey: "g2_leftGlassUUIDMap") }
1077
+ }
1078
+
1079
+ private var rightGlassUUIDMap: [String: String] {
1080
+ get { UserDefaults.standard.dictionary(forKey: "g2_rightGlassUUIDMap") as? [String: String] ?? [:] }
1081
+ set { UserDefaults.standard.set(newValue, forKey: "g2_rightGlassUUIDMap") }
1082
+ }
1083
+
1084
+ private func leftGlassUUID(forSN sn: String) -> UUID? {
1085
+ return leftGlassUUIDMap[sn].flatMap { UUID(uuidString: $0) }
1086
+ }
1087
+
1088
+ private func rightGlassUUID(forSN sn: String) -> UUID? {
1089
+ return rightGlassUUIDMap[sn].flatMap { UUID(uuidString: $0) }
1090
+ }
1091
+
1092
+ private func setLeftGlassUUID(_ uuid: UUID, forSN sn: String) {
1093
+ var m = leftGlassUUIDMap
1094
+ m[sn] = uuid.uuidString
1095
+ leftGlassUUIDMap = m
1096
+ }
1097
+
1098
+ private func setRightGlassUUID(_ uuid: UUID, forSN sn: String) {
1099
+ var m = rightGlassUUIDMap
1100
+ m[sn] = uuid.uuidString
1101
+ rightGlassUUIDMap = m
1102
+ }
1103
+
1104
+ /// Reconnection
1105
+ private let reconnectionManager = G2ReconnectionManager()
1106
+
1107
+ // Protocol state
1108
+ private let sendManager = G2SendManager()
1109
+ private let receiveManager = G2ReceiveManager()
1110
+ private var foregroundObserver: NSObjectProtocol?
1111
+ private var startupPageCreated: Bool = false // createStartUpPageContainer can only be called once
1112
+ private var pageCreated: Bool = false
1113
+ private var pageHasTextContainer: Bool = false // tracks if current page has a text container
1114
+ private var currentTextContent: String = ""
1115
+ private var currentBitmapBase64: String = ""
1116
+ private var textContainerID: Int32 = 1
1117
+ private var imageSessionCounter: Int = 0
1118
+ private var heartbeatTask: Task<Void, Never>?
1119
+ private var heartbeatCounter: Int = 0
1120
+ private var evenHubQueueTask: Task<Void, Never>?
1121
+ private var pendingTextMsg: Data?
1122
+ private var lastEvenHubMsg: Data?
1123
+ private var lastEvenHubResendsRemaining: Int = 0
1124
+ private let EVEN_HUB_RESEND_COUNT: Int = 1
1125
+ private let evenHubQueueLock = NSLock()
1126
+ private var authStarted: Bool = false
1127
+
1128
+ /// Dashboard menu: appId → packageName mapping for selection reverse lookup
1129
+ private var menuAppIdToPackageName: [Int32: String] = [:]
1130
+ /// Dashboard menu items (stored for re-send on connect)
1131
+ private var dashboardMenuItems: [MenuProto.MenuItem] = []
1132
+ /// Current appId to associate EvenHub pages with (enables menu selection events)
1133
+ /// Set to the first menu item's appId so glasses know our page belongs to the menu
1134
+ private var activeMenuAppId: Int32?
1135
+ private var lastClickTimestamp: Int64?
1136
+ private var lastMenuSelectTimestamp: Int64?
1137
+
1138
+ @Published var aiListening: Bool = false
1139
+
1140
+ static let _bluetoothQueue = DispatchQueue(label: "BluetoothG2", qos: .userInitiated)
1141
+
1142
+ // MARK: - Initialization
1143
+
1144
+ override init() {
1145
+ super.init()
1146
+ }
1147
+
1148
+ deinit {
1149
+ if let observer = foregroundObserver {
1150
+ NotificationCenter.default.removeObserver(observer)
1151
+ }
1152
+ centralManager?.delegate = nil
1153
+ leftPeripheral?.delegate = nil
1154
+ rightPeripheral?.delegate = nil
1155
+ }
1156
+
1157
+ // MARK: - BLE Sending
1158
+
1159
+ private func sendToGlasses(_ packets: [Data], left: Bool = false, right: Bool = true) {
1160
+ for packet in packets {
1161
+ if right, let char = rightWriteChar, let peripheral = rightPeripheral {
1162
+ peripheral.writeValue(packet, for: char, type: .withoutResponse)
1163
+ }
1164
+ if left, let char = leftWriteChar, let peripheral = leftPeripheral {
1165
+ peripheral.writeValue(packet, for: char, type: .withoutResponse)
1166
+ }
1167
+ }
1168
+ }
1169
+
1170
+ private func sendEvenHubCommand(_ payload: Data, left: Bool = false, right: Bool = true) {
1171
+ let packets = sendManager.buildPackets(
1172
+ serviceId: ServiceID.evenHub.rawValue,
1173
+ payload: payload,
1174
+ reserveFlag: true
1175
+ )
1176
+ sendToGlasses(packets, left: left, right: right)
1177
+ }
1178
+
1179
+ private func sendDevSettingsCommand(_ payload: Data, left: Bool = false, right: Bool = true) {
1180
+ let packets = sendManager.buildPackets(
1181
+ serviceId: ServiceID.deviceSettings.rawValue,
1182
+ payload: payload
1183
+ )
1184
+ sendToGlasses(packets, left: left, right: right)
1185
+ }
1186
+
1187
+ private func sendG2SettingCommand(_ payload: Data) {
1188
+ let packets = sendManager.buildPackets(
1189
+ serviceId: ServiceID.g2Setting.rawValue,
1190
+ payload: payload,
1191
+ reserveFlag: true
1192
+ )
1193
+ sendToGlasses(packets)
1194
+ }
1195
+
1196
+ private func sendOnboardingCommand(_ payload: Data) {
1197
+ let packets = sendManager.buildPackets(
1198
+ serviceId: ServiceID.onboarding.rawValue,
1199
+ payload: payload,
1200
+ reserveFlag: true
1201
+ )
1202
+ sendToGlasses(packets)
1203
+ }
1204
+
1205
+ private func sendEvenAICommand(_ payload: Data) {
1206
+ let packets = sendManager.buildPackets(
1207
+ serviceId: ServiceID.evenAI.rawValue,
1208
+ payload: payload,
1209
+ reserveFlag: true
1210
+ )
1211
+ sendToGlasses(packets)
1212
+ }
1213
+
1214
+ private func sendMenuCommand(_ payload: Data) {
1215
+ let packets = sendManager.buildPackets(
1216
+ serviceId: ServiceID.menu.rawValue,
1217
+ payload: payload,
1218
+ reserveFlag: true
1219
+ )
1220
+ sendToGlasses(packets)
1221
+ }
1222
+
1223
+ private func sendGestureCtrlCommand(_ payload: Data) {
1224
+ let packets = sendManager.buildPackets(
1225
+ serviceId: ServiceID.gestureCtrl.rawValue,
1226
+ payload: payload,
1227
+ reserveFlag: true
1228
+ )
1229
+ sendToGlasses(packets)
1230
+ }
1231
+
1232
+ private func sendEvenHubCtrlCommand(_ payload: Data) {
1233
+ let packets = sendManager.buildPackets(
1234
+ serviceId: ServiceID.evenHubCtrl.rawValue,
1235
+ payload: payload,
1236
+ reserveFlag: true
1237
+ )
1238
+ sendToGlasses(packets)
1239
+ }
1240
+
1241
+ private func sendDashboardCommand(_ payload: Data) {
1242
+ let packets = sendManager.buildPackets(
1243
+ serviceId: ServiceID.dashboard.rawValue,
1244
+ payload: payload,
1245
+ reserveFlag: true
1246
+ )
1247
+ sendToGlasses(packets)
1248
+ }
1249
+
1250
+ // MARK: - Authentication Sequence
1251
+
1252
+ private func authLeft() {
1253
+ // Auth to left side
1254
+ if leftPeripheral != nil && leftWriteChar != nil {
1255
+ let authL = DevSettingsProto.authCmd(magicRandom: sendManager.nextMagicRandom())
1256
+ sendDevSettingsCommand(authL, left: true, right: false)
1257
+ }
1258
+ }
1259
+
1260
+ private func authRight() {
1261
+ let authR = DevSettingsProto.authCmd(magicRandom: sendManager.nextMagicRandom())
1262
+ sendDevSettingsCommand(authR, left: false, right: true)
1263
+ }
1264
+
1265
+ private func runAuthSequence() {
1266
+ Bridge.log("G2: Running auth sequence")
1267
+
1268
+ // Auth to left side
1269
+ if leftPeripheral != nil && leftWriteChar != nil {
1270
+ let authL = DevSettingsProto.authCmd(magicRandom: sendManager.nextMagicRandom())
1271
+ sendDevSettingsCommand(authL, left: true, right: false)
1272
+ }
1273
+
1274
+ // Small delay then auth right + pipe role change + time sync
1275
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
1276
+ guard let self = self else { return }
1277
+
1278
+ let authR = DevSettingsProto.authCmd(magicRandom: self.sendManager.nextMagicRandom())
1279
+ self.sendDevSettingsCommand(authR, left: false, right: true)
1280
+
1281
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
1282
+ guard let self = self else { return }
1283
+
1284
+ let roleChange = DevSettingsProto.pipeRoleChange(
1285
+ magicRandom: self.sendManager.nextMagicRandom()
1286
+ )
1287
+ self.sendDevSettingsCommand(roleChange, left: false, right: true)
1288
+
1289
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
1290
+ guard let self = self else { return }
1291
+
1292
+ let timeSync = DevSettingsProto.timeSync(
1293
+ magicRandom: self.sendManager.nextMagicRandom()
1294
+ )
1295
+ self.sendDevSettingsCommand(timeSync)
1296
+
1297
+ // Skip onboarding on connect
1298
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
1299
+ guard let self = self else { return }
1300
+ let onboarding = OnboardingProto.skipOnboarding(
1301
+ magicRandom: self.sendManager.nextMagicRandom()
1302
+ )
1303
+ self.sendOnboardingCommand(onboarding)
1304
+ Bridge.log("G2: Sent onboarding skip (FINISH)")
1305
+
1306
+ // Disable "Hey Even" wakeword on connect
1307
+ let heyEvenOff = EvenAIProto.setHeyEven(
1308
+ magicRandom: self.sendManager.nextMagicRandom(),
1309
+ enabled: false
1310
+ )
1311
+ self.sendEvenAICommand(heyEvenOff)
1312
+ Bridge.log("G2: Disabled Hey Even wakeword")
1313
+
1314
+ // Replicate Even app's full init sequence for menu selection support:
1315
+
1316
+ // 0. Universe settings (g2_setting cmd=1 field3 with field9=universe settings)
1317
+ // Even app's bytes: 4a 0a 08 00 10 00 18 01 20 00 28 01
1318
+ // = field 9 (universe), {1:0, 2:0, 3:1, 4:0, 5:1}
1319
+ var univW = ProtobufWriter()
1320
+ univW.writeInt32Field(1, 1) // DeviceReceiveInfo
1321
+ univW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1322
+ univW.writeMessageField(
1323
+ 3,
1324
+ Data([
1325
+ 0x4A, 0x0A, // field 9, length 10
1326
+ 0x08, 0x00, // unitFormat=0
1327
+ 0x10, 0x00, // distanceUnit=0
1328
+ 0x18, 0x01, // timeFormat=1
1329
+ 0x20, 0x00, // dateFormat=0
1330
+ 0x28, 0x01, // temperatureUnit=1
1331
+ ])
1332
+ )
1333
+ self.sendG2SettingCommand(univW.data)
1334
+
1335
+ // 1. gesture_ctrl init (field1=0, field2=magicRandom)
1336
+ var gestureInitW = ProtobufWriter()
1337
+ gestureInitW.writeInt32Field(1, 0)
1338
+ gestureInitW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1339
+ self.sendGestureCtrlCommand(gestureInitW.data)
1340
+
1341
+ // 2. ui_setting_app (0x0C) — query (cmd=2, field4={settingInfoType=1, autoBrightnessLevel=0})
1342
+ var uiSettW = ProtobufWriter()
1343
+ uiSettW.writeInt32Field(1, 2) // cmd = DeviceReceiveRequest
1344
+ uiSettW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1345
+ uiSettW.writeMessageField(4, Data([0x08, 0x01, 0x10, 0x00])) // {1:1, 2:0}
1346
+ self.sendToGlasses(
1347
+ self.sendManager.buildPackets(
1348
+ serviceId: 0x0C, payload: uiSettW.data, reserveFlag: true
1349
+ )
1350
+ )
1351
+
1352
+ // 3. teleprompter (0x10) — config (cmd=1, field3={1:4})
1353
+ var teleW = ProtobufWriter()
1354
+ teleW.writeInt32Field(1, 1)
1355
+ teleW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1356
+ teleW.writeMessageField(3, Data([0x08, 0x04])) // {1:4}
1357
+ self.sendToGlasses(
1358
+ self.sendManager.buildPackets(
1359
+ serviceId: 0x10, payload: teleW.data, reserveFlag: true
1360
+ )
1361
+ )
1362
+
1363
+ // 4. EvenHub CTRL on service 0x81 (cmd=1, empty field3)
1364
+ var ehCtrlW = ProtobufWriter()
1365
+ ehCtrlW.writeInt32Field(1, 1)
1366
+ ehCtrlW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1367
+ ehCtrlW.writeMessageField(3, Data())
1368
+ self.sendEvenHubCtrlCommand(ehCtrlW.data)
1369
+
1370
+ // 5. calendar (0x04) — config
1371
+ var calW = ProtobufWriter()
1372
+ calW.writeInt32Field(1, 1)
1373
+ calW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1374
+ calW.writeMessageField(
1375
+ 3, Data([0x08, 0x01, 0x10, 0x01, 0x18, 0x05, 0x28, 0x01])
1376
+ )
1377
+ self.sendToGlasses(
1378
+ self.sendManager.buildPackets(
1379
+ serviceId: 0x04, payload: calW.data, reserveFlag: true
1380
+ )
1381
+ )
1382
+
1383
+ // 6. Dashboard init (0x01) — display settings
1384
+ var dashDisplayW = ProtobufWriter()
1385
+ dashDisplayW.writeInt32Field(1, 4) // displayMode
1386
+ dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
1387
+ dashDisplayW.writeMessageField(3, Data([1, 2, 3])) // statusDisplayOrder
1388
+ dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
1389
+ dashDisplayW.writeMessageField(5, Data([1, 3, 2, 2])) // widgetDisplayOrder
1390
+ dashDisplayW.writeInt32Field(6, 1) // halfDayFormat
1391
+ dashDisplayW.writeInt32Field(7, 1) // temperatureUnit
1392
+
1393
+ var dashRecvW = ProtobufWriter()
1394
+ dashRecvW.writeMessageField(2, dashDisplayW.data)
1395
+
1396
+ var dashPkgW = ProtobufWriter()
1397
+ dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
1398
+ dashPkgW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1399
+ dashPkgW.writeMessageField(4, dashRecvW.data)
1400
+ self.sendDashboardCommand(dashPkgW.data)
1401
+
1402
+ // 7. Dashboard REQUEST_NEWS_INFO (cmd=5, field7={1:1})
1403
+ var dashNewsReqW = ProtobufWriter()
1404
+ dashNewsReqW.writeInt32Field(1, 5) // REQUEST_NEWS_INFO
1405
+ dashNewsReqW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1406
+ dashNewsReqW.writeMessageField(7, Data([0x08, 0x01])) // {1:1}
1407
+ self.sendDashboardCommand(dashNewsReqW.data)
1408
+
1409
+ // 8. Gesture control list via g2_setting
1410
+ var gestListW = ProtobufWriter()
1411
+ gestListW.writeInt32Field(1, 1) // DeviceReceiveInfo
1412
+ gestListW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1413
+ // field 3 with field 10 (gestureControlList): 3 items, all app_unable
1414
+ let gestureCtrlPayload = Data([
1415
+ 0x52, 0x18, // field 10, length 24
1416
+ 0x0A, 0x06, 0x08, 0x00, 0x10, 0x00, 0x18, 0x00, // item 1
1417
+ 0x0A, 0x06, 0x08, 0x00, 0x10, 0x01, 0x18, 0x00, // item 2
1418
+ 0x0A, 0x06, 0x08, 0x00, 0x10, 0x02, 0x18, 0x00, // item 3
1419
+ ])
1420
+ gestListW.writeMessageField(3, gestureCtrlPayload)
1421
+ self.sendG2SettingCommand(gestListW.data)
1422
+
1423
+ // 9. Dashboard APP_REQUEST_NEWS_INFO (cmd=7, field9={1:1})
1424
+ var dashAppNewsW = ProtobufWriter()
1425
+ dashAppNewsW.writeInt32Field(1, 7) // APP_REQUEST_NEWS_INFO
1426
+ dashAppNewsW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1427
+ dashAppNewsW.writeMessageField(9, Data([0x08, 0x01])) // {1:1}
1428
+ self.sendDashboardCommand(dashAppNewsW.data)
1429
+
1430
+ Bridge.log("G2: Sent full Even-compatible init sequence")
1431
+ }
1432
+
1433
+ // Start heartbeats after auth
1434
+ self.startHeartbeats()
1435
+
1436
+ Task { await self.reconnectionManager.stop() }
1437
+ Bridge.log("G2: Auth sequence complete, glasses ready")
1438
+
1439
+ // Set device_name so DeviceManager can save it for reconnection
1440
+ if let peripheralName = self.rightPeripheral?.name
1441
+ ?? self.leftPeripheral?.name,
1442
+ let serialNumber = self.deviceNameToSerialNumber[peripheralName]
1443
+ {
1444
+ DeviceStore.shared.apply("bluetooth", "device_name", serialNumber)
1445
+ Bridge.log("G2: Set device_name to \(serialNumber)")
1446
+ }
1447
+
1448
+ // Set bluetooth name and device model for Device Info page
1449
+ let btName =
1450
+ self.rightPeripheral?.name
1451
+ ?? self.leftPeripheral?.name ?? ""
1452
+ DeviceStore.shared.apply("glasses", "bluetoothName", btName)
1453
+ DeviceStore.shared.apply("glasses", "deviceModel", DeviceTypes.G2)
1454
+
1455
+ DeviceStore.shared.apply("glasses", "connected", true)
1456
+ DeviceStore.shared.apply("glasses", "fullyBooted", true)
1457
+
1458
+ // connnect a controller if we have one:
1459
+ self.connectController()
1460
+
1461
+ // Query version + battery info from glasses
1462
+ self.requestDeviceInfo()
1463
+
1464
+ // send dashboard menu if we have stored items
1465
+ self.sendMenuApps()
1466
+ }
1467
+ }
1468
+ }
1469
+ }
1470
+
1471
+ private func runDashboardSequence() {
1472
+ Bridge.log("G2: Running dashboard sequence")
1473
+
1474
+ // send the shutdown command to the glasses:
1475
+ let msg = EvenHubProto.shutdownMessage()
1476
+ sendEvenHubCommand(msg)
1477
+ pageCreated = false
1478
+ currentTextContent = ""
1479
+
1480
+ // // Auth to left side
1481
+ // if leftPeripheral != nil && leftWriteChar != nil {
1482
+ // let authL = DevSettingsProto.authCmd(magicRandom: sendManager.nextMagicRandom())
1483
+ // sendDevSettingsCommand(authL, left: true, right: false)
1484
+ // }
1485
+
1486
+ // // Small delay then auth right + pipe role change + time sync
1487
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
1488
+ guard let self = self else { return }
1489
+ // 1. gesture_ctrl init (field1=0, field2=magicRandom)
1490
+ var gestureInitW = ProtobufWriter()
1491
+ gestureInitW.writeInt32Field(1, 0)
1492
+ gestureInitW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1493
+ self.sendGestureCtrlCommand(gestureInitW.data)
1494
+
1495
+ // 6. Dashboard init (0x01) — display settings
1496
+ var dashDisplayW = ProtobufWriter()
1497
+ dashDisplayW.writeInt32Field(1, 4) // displayMode
1498
+ dashDisplayW.writeInt32Field(2, 3) // statusDisplayCount
1499
+ dashDisplayW.writeMessageField(3, Data([1, 2, 3])) // statusDisplayOrder
1500
+ dashDisplayW.writeInt32Field(4, 4) // widgetDisplayCount
1501
+ dashDisplayW.writeMessageField(5, Data([1, 3, 2, 2])) // widgetDisplayOrder
1502
+ dashDisplayW.writeInt32Field(6, 1) // halfDayFormat
1503
+ dashDisplayW.writeInt32Field(7, 1) // temperatureUnit
1504
+
1505
+ var dashRecvW = ProtobufWriter()
1506
+ dashRecvW.writeMessageField(2, dashDisplayW.data)
1507
+
1508
+ var dashPkgW = ProtobufWriter()
1509
+ dashPkgW.writeInt32Field(1, 2) // Dashboard_Receive
1510
+ dashPkgW.writeInt32Field(2, self.sendManager.nextMagicRandom())
1511
+ dashPkgW.writeMessageField(4, dashRecvW.data)
1512
+ self.sendDashboardCommand(dashPkgW.data)
1513
+ Bridge.log("G2: Sent full Even-compatible init sequence")
1514
+ }
1515
+ }
1516
+
1517
+ // MARK: - Heartbeats
1518
+
1519
+ private func startHeartbeats() {
1520
+ heartbeatTask?.cancel()
1521
+ heartbeatTask = Task { [weak self] in
1522
+ while !Task.isCancelled {
1523
+ try? await Task.sleep(nanoseconds: 5_000_000_000)
1524
+ guard !Task.isCancelled else { break }
1525
+ await MainActor.run {
1526
+ self?.sendEvenHubHeartbeat()
1527
+ self?.sendDevSettingsHeartbeat()
1528
+ }
1529
+ }
1530
+ }
1531
+
1532
+ // EvenHub text command queue: drain the most recent pending updateText every 100ms
1533
+ evenHubQueueTask?.cancel()
1534
+ evenHubQueueTask = Task { [weak self] in
1535
+ while !Task.isCancelled {
1536
+ try? await Task.sleep(nanoseconds: 100_000_000)
1537
+ guard !Task.isCancelled else { break }
1538
+ await MainActor.run {
1539
+ self?.drainEvenHubQueue()
1540
+ }
1541
+ }
1542
+ }
1543
+ }
1544
+
1545
+ private func stopHeartbeats() {
1546
+ heartbeatTask?.cancel()
1547
+ heartbeatTask = nil
1548
+ evenHubQueueTask?.cancel()
1549
+ evenHubQueueTask = nil
1550
+ evenHubQueueLock.lock()
1551
+ pendingTextMsg = nil
1552
+ lastEvenHubMsg = nil
1553
+ lastEvenHubResendsRemaining = 0
1554
+ evenHubQueueLock.unlock()
1555
+ }
1556
+
1557
+ private func sendEvenHubHeartbeat() {
1558
+ let isFullyBooted = DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
1559
+ guard isFullyBooted else { return }
1560
+
1561
+ let msg = EvenHubProto.heartbeatMessage()
1562
+ // Write to BOTH arms. If either side sees no traffic for ~50s while
1563
+ // backgrounded, iOS bluetoothd reclaims the connection as "Unused"
1564
+ // (disconnect reason 722) and tears down the link.
1565
+ sendEvenHubCommand(msg, left: true, right: true)
1566
+
1567
+ // Poll battery every 10 heartbeats (~50 seconds)
1568
+ heartbeatCounter += 1
1569
+ if heartbeatCounter % 10 == 0 {
1570
+ requestDeviceInfo()
1571
+ }
1572
+ }
1573
+
1574
+ private func sendDevSettingsHeartbeat() {
1575
+ let isFullyBooted = DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
1576
+ guard isFullyBooted else { return }
1577
+ let msg = DevSettingsProto.baseHeartbeat(magicRandom: sendManager.nextMagicRandom())
1578
+ sendDevSettingsCommand(msg, left: true, right: true)
1579
+ }
1580
+
1581
+ /// Request battery, version, and other device info via g2_setting service
1582
+ private func requestDeviceInfo() {
1583
+ let msg = G2SettingProto.requestInfo(magicRandom: sendManager.nextMagicRandom())
1584
+ sendG2SettingCommand(msg)
1585
+ // Bridge.log("G2: Requested device info (battery/version)")
1586
+ }
1587
+
1588
+ private func sendMenuApps() {
1589
+ let menuItems = DeviceStore.shared.get("bluetooth", "menu_apps") as? [[String: Any]] ?? []
1590
+ if menuItems.isEmpty {
1591
+ return
1592
+ }
1593
+ setDashboardMenu(menuItems)
1594
+ }
1595
+
1596
+ // MARK: - SGCManager: Display Control
1597
+
1598
+ func sendTextWall(_ text: String) {
1599
+ // Bridge.log("G2: sendTextWall(\(text.prefix(50))...)")
1600
+
1601
+ // ignore events while the dashboard is open:
1602
+ // let isHeadUp = DeviceStore.shared.get("glasses", "headUp") as? Bool ?? false
1603
+ // if isHeadUp {
1604
+ // return
1605
+ // }
1606
+
1607
+ if text.isEmpty {
1608
+ clearDisplay()
1609
+ return
1610
+ }
1611
+
1612
+ if !pageCreated || !pageHasTextContainer {
1613
+ // Need to create/rebuild page with a text container
1614
+ // Bridge.log("G2: sendTextWall() - creating page with text container")
1615
+ createPageWithText(text)
1616
+ } else {
1617
+ // Bridge.log("G2: sendTextWall() - updating text container")
1618
+ updateText(text)
1619
+ }
1620
+ }
1621
+
1622
+ func sendDoubleTextWall(_ top: String, _ bottom: String) {
1623
+ // G2 doesn't have native double text wall, combine them
1624
+ let combined = top + "\n" + bottom
1625
+ sendTextWall(combined)
1626
+ }
1627
+
1628
+ func clearDisplay() {
1629
+ Bridge.log("G2: clearDisplay()")
1630
+ // Don't shutdown the EvenHub page — that kills audio streaming too.
1631
+ // Instead, just clear the text content by sending a space.
1632
+ // if pageCreated {
1633
+ // let msg = EvenHubProto.shutdownMessage()
1634
+ // sendEvenHubCommand(msg)
1635
+ // pageCreated = false
1636
+ // currentTextContent = ""
1637
+ // }
1638
+ if pageCreated {
1639
+ sendTextWall(" ")
1640
+ }
1641
+ }
1642
+
1643
+ /// Send BMP data to an image container via fragmented updateImageRawData
1644
+ private func sendImageData(containerID: Int32, containerName: String, bmpData: Data) async
1645
+ -> Bool
1646
+ {
1647
+ let fragmentSize = 4096
1648
+ imageSessionCounter += 1
1649
+ let sessionId = imageSessionCounter
1650
+ let totalSize = Int32(bmpData.count)
1651
+ var fragmentIndex: Int32 = 0
1652
+ var offset = 0
1653
+
1654
+ while offset < bmpData.count {
1655
+ let end = min(offset + fragmentSize, bmpData.count)
1656
+ let fragment = bmpData[offset ..< end]
1657
+
1658
+ let msg = EvenHubProto.updateImageRawDataMessage(
1659
+ containerID: containerID,
1660
+ containerName: containerName,
1661
+ mapSessionId: Int32(sessionId),
1662
+ mapTotalSize: totalSize,
1663
+ compressMode: 0,
1664
+ mapFragmentIndex: fragmentIndex,
1665
+ mapFragmentPacketSize: Int32(fragment.count),
1666
+ mapRawData: Data(fragment)
1667
+ )
1668
+ sendEvenHubCommand(msg)
1669
+
1670
+ fragmentIndex += 1
1671
+ offset = end
1672
+ try? await Task.sleep(nanoseconds: 200_000_000) // 200ms between fragments
1673
+ }
1674
+
1675
+ Bridge.log(
1676
+ "G2: sendImageData(\(containerName)) - \(fragmentIndex) fragments, \(bmpData.count) bytes"
1677
+ )
1678
+ return true
1679
+ }
1680
+
1681
+ func displayBitmapLoc(rawData: Data, x: Int32, y: Int32, id: Int32) async -> Bool {
1682
+ Bridge.log("G2: displayBitmap() - decoded \(rawData.count) bytes from base64")
1683
+
1684
+ Bridge.log(
1685
+ "G2: displayBitmap() - state: startupPageCreated=\(startupPageCreated), pageCreated=\(pageCreated)"
1686
+ )
1687
+
1688
+ // --- Single-tile approach: scale source to fit 200x100, send as one image container ---
1689
+ guard let bmpData = convertToG2Bmp(rawData, containerWidth: 200, containerHeight: 100)
1690
+ else {
1691
+ Bridge.log("G2: displayBitmap() - failed to convert image to BMP")
1692
+ return false
1693
+ }
1694
+
1695
+ // Center the 200x100 container on the 576x288 canvas
1696
+ let containerW: Int32 = 200
1697
+ let containerH: Int32 = 100
1698
+ let containerX: Int32 = x
1699
+ let containerY: Int32 = y
1700
+ let containerID: Int32 = id
1701
+ let containerName = "img-\(id)"
1702
+
1703
+ let imageContainer = EvenHubProto.imageContainerProperty(
1704
+ x: containerX, y: containerY,
1705
+ width: containerW, height: containerH,
1706
+ containerID: containerID, containerName: containerName
1707
+ )
1708
+
1709
+ let msg: Data
1710
+ if !startupPageCreated {
1711
+ Bridge.log("G2: displayBitmap() - creating startup page with image container")
1712
+ msg = EvenHubProto.createPageMessage(
1713
+ imageContainers: [imageContainer], magicRandom: sendManager.nextMagicRandom(),
1714
+ appId: activeMenuAppId
1715
+ )
1716
+ startupPageCreated = true
1717
+ } else {
1718
+ Bridge.log("G2: displayBitmap() - rebuilding page with image container")
1719
+ msg = EvenHubProto.rebuildPageMessage(
1720
+ imageContainers: [imageContainer], magicRandom: sendManager.nextMagicRandom(),
1721
+ appId: activeMenuAppId
1722
+ )
1723
+ }
1724
+ sendEvenHubCommand(msg)
1725
+ pageCreated = true
1726
+ pageHasTextContainer = false
1727
+ currentTextContent = ""
1728
+ Bridge.log("G2: displayBitmap() - page sent, waiting 1s before sending fragments...")
1729
+ try? await Task.sleep(nanoseconds: 1_000_000_000) // 1s - give glasses time to process page
1730
+
1731
+ // Send the BMP data
1732
+ let success = await sendImageData(
1733
+ containerID: containerID, containerName: containerName, bmpData: bmpData
1734
+ )
1735
+ if !success {
1736
+ Bridge.log("G2: displayBitmap() - failed sending image data")
1737
+ }
1738
+
1739
+ Bridge.log("G2: displayBitmap() - single tile sent, \(bmpData.count) bytes")
1740
+ return success
1741
+ }
1742
+
1743
+ func displayBitmapQuad(base64ImageData: String) async -> Bool {
1744
+ guard let rawData = Data(base64Encoded: base64ImageData) else {
1745
+ Bridge.log("G2: displayBitmapQuad() - failed to decode base64")
1746
+ return false
1747
+ }
1748
+
1749
+ guard let tiles = renderAndSliceTo4Tiles(rawData) else {
1750
+ Bridge.log("G2: displayBitmapQuad() - failed to slice image into tiles")
1751
+ return false
1752
+ }
1753
+
1754
+ // 2x2 grid of 200x100 tiles covering 400x200
1755
+ let container1 = EvenHubProto.imageContainerProperty(
1756
+ x: 0, y: 0, width: 200, height: 100,
1757
+ containerID: 10, containerName: "img-10"
1758
+ )
1759
+ let container2 = EvenHubProto.imageContainerProperty(
1760
+ x: 200, y: 0, width: 200, height: 100,
1761
+ containerID: 11, containerName: "img-11"
1762
+ )
1763
+ let container3 = EvenHubProto.imageContainerProperty(
1764
+ x: 0, y: 100, width: 200, height: 100,
1765
+ containerID: 12, containerName: "img-12"
1766
+ )
1767
+ let container4 = EvenHubProto.imageContainerProperty(
1768
+ x: 200, y: 100, width: 200, height: 100,
1769
+ containerID: 13, containerName: "img-13"
1770
+ )
1771
+
1772
+ let msg: Data
1773
+ if !startupPageCreated {
1774
+ msg = EvenHubProto.createPageMessage(
1775
+ imageContainers: [
1776
+ container1, container2, container3, container4,
1777
+ ], magicRandom: sendManager.nextMagicRandom(), appId: activeMenuAppId
1778
+ )
1779
+ startupPageCreated = true
1780
+ } else {
1781
+ msg = EvenHubProto.rebuildPageMessage(
1782
+ imageContainers: [
1783
+ container1, container2, container3, container4,
1784
+ ], magicRandom: sendManager.nextMagicRandom(), appId: activeMenuAppId
1785
+ )
1786
+ }
1787
+ sendEvenHubCommand(msg)
1788
+ pageCreated = true
1789
+ pageHasTextContainer = false
1790
+ currentTextContent = ""
1791
+
1792
+ try? await Task.sleep(nanoseconds: 1_000_000_000)
1793
+
1794
+ // Send each tile's unique BMP data to its container
1795
+ let success1 = await sendImageData(
1796
+ containerID: 10, containerName: "img-10", bmpData: tiles[0]
1797
+ )
1798
+ let success2 = await sendImageData(
1799
+ containerID: 11, containerName: "img-11", bmpData: tiles[1]
1800
+ )
1801
+ let success3 = await sendImageData(
1802
+ containerID: 12, containerName: "img-12", bmpData: tiles[2]
1803
+ )
1804
+ let success4 = await sendImageData(
1805
+ containerID: 13, containerName: "img-13", bmpData: tiles[3]
1806
+ )
1807
+
1808
+ return success1 && success2 && success3 && success4
1809
+ }
1810
+
1811
+ func displayBitmap(base64ImageData: String) async -> Bool {
1812
+ currentBitmapBase64 = base64ImageData
1813
+ currentTextContent = ""
1814
+ return await displayBitmapQuad(base64ImageData: base64ImageData)
1815
+ }
1816
+
1817
+ /// Upscale BMP pixel data by 2x (200x100 → 400x200) using nearest-neighbor
1818
+ private func upscaleBmp2x(_ bmpData: Data, srcWidth: Int, srcHeight: Int) -> Data? {
1819
+ // Parse the BMP to extract pixel data, then rebuild at 2x
1820
+ // BMP header: 14 bytes file header + 40 bytes DIB header + 64 bytes color table = 118 bytes
1821
+ let headerSize = 14 + 40 + 64
1822
+ guard bmpData.count > headerSize else {
1823
+ Bridge.log("G2: upscaleBmp2x - BMP too small")
1824
+ return nil
1825
+ }
1826
+
1827
+ let srcPaddedRowSize = ((srcWidth + 1) / 2 + 3) & ~3 // 4-bit rows padded to 4 bytes
1828
+ let pixelDataOffset = headerSize
1829
+
1830
+ let dstWidth = srcWidth * 2
1831
+ let dstHeight = srcHeight * 2
1832
+ let dstBytesPerRow = (dstWidth + 1) / 2
1833
+ let dstPaddedRowSize = (dstBytesPerRow + 3) & ~3
1834
+ let dstPixelDataSize = dstPaddedRowSize * dstHeight
1835
+ let dstFileSize = headerSize + dstPixelDataSize
1836
+
1837
+ var dst = Data(capacity: dstFileSize)
1838
+
1839
+ // --- BMP File Header (14 bytes) ---
1840
+ dst.append(contentsOf: [0x42, 0x4D])
1841
+ dst.appendLittleEndian(UInt32(dstFileSize))
1842
+ dst.appendLittleEndian(UInt16(0))
1843
+ dst.appendLittleEndian(UInt16(0))
1844
+ dst.appendLittleEndian(UInt32(headerSize))
1845
+
1846
+ // --- DIB Header (40 bytes) ---
1847
+ dst.appendLittleEndian(UInt32(40))
1848
+ dst.appendLittleEndian(Int32(dstWidth))
1849
+ dst.appendLittleEndian(Int32(dstHeight))
1850
+ dst.appendLittleEndian(UInt16(1))
1851
+ dst.appendLittleEndian(UInt16(4))
1852
+ dst.appendLittleEndian(UInt32(0))
1853
+ dst.appendLittleEndian(UInt32(dstPixelDataSize))
1854
+ dst.appendLittleEndian(Int32(2835))
1855
+ dst.appendLittleEndian(Int32(2835))
1856
+ dst.appendLittleEndian(UInt32(16))
1857
+ dst.appendLittleEndian(UInt32(0))
1858
+
1859
+ // --- Color Table (same 16-entry grayscale) ---
1860
+ for i in 0 ..< 16 {
1861
+ let val = UInt8(i * 17)
1862
+ dst.append(contentsOf: [val, val, val, 0])
1863
+ }
1864
+
1865
+ // --- Pixel Data (nearest-neighbor 2x upscale) ---
1866
+ // BMP is bottom-up, so row 0 = bottom of image
1867
+ // Each dst row maps to srcRow = dstRow / 2
1868
+ for dstRow in 0 ..< dstHeight {
1869
+ let srcRow = dstRow / 2
1870
+ let srcRowOffset = pixelDataOffset + srcRow * srcPaddedRowSize
1871
+ var rowBuf = [UInt8](repeating: 0, count: dstPaddedRowSize)
1872
+
1873
+ for dstCol in 0 ..< dstWidth {
1874
+ let srcCol = dstCol / 2
1875
+
1876
+ // Read 4-bit nibble from source
1877
+ let srcBytePos = srcRowOffset + srcCol / 2
1878
+ guard srcBytePos < bmpData.count else { continue }
1879
+ let srcByte = bmpData[srcBytePos]
1880
+ let nibble: UInt8 = (srcCol % 2 == 0) ? (srcByte >> 4) : (srcByte & 0x0F)
1881
+
1882
+ // Write 4-bit nibble to destination
1883
+ let dstBytePos = dstCol / 2
1884
+ if dstCol % 2 == 0 {
1885
+ rowBuf[dstBytePos] = nibble << 4
1886
+ } else {
1887
+ rowBuf[dstBytePos] |= nibble
1888
+ }
1889
+ }
1890
+ dst.append(contentsOf: rowBuf)
1891
+ }
1892
+
1893
+ Bridge.log(
1894
+ "G2: upscaleBmp2x - \(srcWidth)x\(srcHeight) → \(dstWidth)x\(dstHeight), \(dst.count) bytes"
1895
+ )
1896
+ return dst
1897
+ }
1898
+
1899
+ func displayBitmapOriginal(base64ImageData: String) async -> Bool {
1900
+ guard let rawData = Data(base64Encoded: base64ImageData) else {
1901
+ Bridge.log("G2: displayBitmap() - failed to decode base64")
1902
+ return false
1903
+ }
1904
+
1905
+ Bridge.log("G2: displayBitmap() - decoded \(rawData.count) bytes from base64")
1906
+
1907
+ Bridge.log(
1908
+ "G2: displayBitmap() - state: startupPageCreated=\(startupPageCreated), pageCreated=\(pageCreated)"
1909
+ )
1910
+
1911
+ // --- Single-tile approach: scale source to fit 200x100, send as one image container ---
1912
+ guard let bmpData = convertToG2Bmp(rawData, containerWidth: 200, containerHeight: 100)
1913
+ else {
1914
+ Bridge.log("G2: displayBitmap() - failed to convert image to BMP")
1915
+ return false
1916
+ }
1917
+
1918
+ // Center the 200x100 container on the 576x288 canvas
1919
+ let containerW: Int32 = 200
1920
+ let containerH: Int32 = 100
1921
+ let containerX: Int32 = (576 - containerW) / 2
1922
+ let containerY: Int32 = (288 - containerH) / 2
1923
+ let containerID: Int32 = 10
1924
+ let containerName = "img-single"
1925
+
1926
+ let imageContainer = EvenHubProto.imageContainerProperty(
1927
+ x: containerX, y: containerY,
1928
+ width: containerW, height: containerH,
1929
+ containerID: containerID, containerName: containerName
1930
+ )
1931
+
1932
+ let msg: Data
1933
+ if !startupPageCreated {
1934
+ Bridge.log("G2: displayBitmap() - creating startup page with image container")
1935
+ msg = EvenHubProto.createPageMessage(
1936
+ imageContainers: [imageContainer], magicRandom: sendManager.nextMagicRandom(),
1937
+ appId: activeMenuAppId
1938
+ )
1939
+ startupPageCreated = true
1940
+ } else {
1941
+ Bridge.log("G2: displayBitmap() - rebuilding page with image container")
1942
+ msg = EvenHubProto.rebuildPageMessage(
1943
+ imageContainers: [imageContainer], magicRandom: sendManager.nextMagicRandom(),
1944
+ appId: activeMenuAppId
1945
+ )
1946
+ }
1947
+ sendEvenHubCommand(msg)
1948
+ pageCreated = true
1949
+ pageHasTextContainer = false
1950
+ currentTextContent = ""
1951
+ Bridge.log("G2: displayBitmap() - page sent, waiting 1s before sending fragments...")
1952
+ try? await Task.sleep(nanoseconds: 1_000_000_000) // 1s - give glasses time to process page
1953
+
1954
+ // Send the BMP data
1955
+ let success = await sendImageData(
1956
+ containerID: containerID, containerName: containerName, bmpData: bmpData
1957
+ )
1958
+ if !success {
1959
+ Bridge.log("G2: displayBitmap() - failed sending image data")
1960
+ }
1961
+
1962
+ Bridge.log("G2: displayBitmap() - single tile sent, \(bmpData.count) bytes")
1963
+ return success
1964
+ }
1965
+
1966
+ // MARK: - Bitmap Conversion
1967
+
1968
+ /// Scale source image to fit within containerWidth x containerHeight (maintaining aspect ratio),
1969
+ /// centered on a black background. Output BMP always matches container dimensions exactly.
1970
+ private func convertToG2Bmp(_ data: Data, containerWidth: Int, containerHeight: Int) -> Data? {
1971
+ guard let image = UIImage(data: data), let cgImage = image.cgImage else {
1972
+ Bridge.log("G2: convertToG2Bmp - could not decode image")
1973
+ return nil
1974
+ }
1975
+
1976
+ let srcWidth = cgImage.width
1977
+ let srcHeight = cgImage.height
1978
+
1979
+ // Scale to fit within container (maintain aspect ratio)
1980
+ let scale = min(
1981
+ Double(containerWidth) / Double(srcWidth), Double(containerHeight) / Double(srcHeight)
1982
+ )
1983
+ let scaledW = max(1, Int(Double(srcWidth) * scale))
1984
+ let scaledH = max(1, Int(Double(srcHeight) * scale))
1985
+ // Center within container
1986
+ let offsetX = (containerWidth - scaledW) / 2
1987
+ let offsetY = (containerHeight - scaledH) / 2
1988
+
1989
+ Bridge.log(
1990
+ "G2: convertToG2Bmp - input \(srcWidth)x\(srcHeight) → scaled \(scaledW)x\(scaledH) in \(containerWidth)x\(containerHeight)"
1991
+ )
1992
+
1993
+ // Render to 8-bit grayscale at the CONTAINER size (not scaled size)
1994
+ guard
1995
+ let ctx = CGContext(
1996
+ data: nil,
1997
+ width: containerWidth,
1998
+ height: containerHeight,
1999
+ bitsPerComponent: 8,
2000
+ bytesPerRow: containerWidth,
2001
+ space: CGColorSpaceCreateDeviceGray(),
2002
+ bitmapInfo: CGImageAlphaInfo.none.rawValue
2003
+ )
2004
+ else {
2005
+ Bridge.log("G2: convertToG2Bmp - failed to create CGContext")
2006
+ return nil
2007
+ }
2008
+
2009
+ ctx.setFillColor(gray: 0, alpha: 1)
2010
+ ctx.fill(CGRect(x: 0, y: 0, width: containerWidth, height: containerHeight))
2011
+ ctx.interpolationQuality = .high
2012
+ ctx.draw(cgImage, in: CGRect(x: offsetX, y: offsetY, width: scaledW, height: scaledH))
2013
+
2014
+ guard let renderedImage = ctx.makeImage(),
2015
+ let pixels = renderedImage.dataProvider?.data as Data?
2016
+ else {
2017
+ Bridge.log("G2: convertToG2Bmp - failed to get pixel data")
2018
+ return nil
2019
+ }
2020
+
2021
+ guard
2022
+ let bmp = build4BitBmp(
2023
+ grayscalePixels: pixels, width: containerWidth, height: containerHeight
2024
+ )
2025
+ else {
2026
+ Bridge.log("G2: convertToG2Bmp - failed to build BMP")
2027
+ return nil
2028
+ }
2029
+
2030
+ return bmp
2031
+ }
2032
+
2033
+ // MARK: - Bitmap Conversion (4-tile approach for G2 - kept for future use)
2034
+
2035
+ private static let tileWidth = 200
2036
+ private static let tileHeight = 100
2037
+ // Total image area: 400x200 (2x2 grid of 200x100 tiles)
2038
+
2039
+ /// Render any image to 400x200 grayscale, then slice into 4 tiles (200x100 each).
2040
+ /// Returns 4 BMP Data objects: [top-left, top-right, bottom-left, bottom-right].
2041
+ private func renderAndSliceTo4Tiles(_ data: Data) -> [Data]? {
2042
+ guard let image = UIImage(data: data), let cgImage = image.cgImage else {
2043
+ Bridge.log("G2: renderAndSliceTo4Tiles - could not decode image")
2044
+ return nil
2045
+ }
2046
+
2047
+ let srcWidth = cgImage.width
2048
+ let srcHeight = cgImage.height
2049
+ let totalW = G2.tileWidth * 2 // 400
2050
+ let totalH = G2.tileHeight * 2 // 200
2051
+
2052
+ // Scale source to fit within 400x200 (maintain aspect ratio)
2053
+ let scale = min(Double(totalW) / Double(srcWidth), Double(totalH) / Double(srcHeight))
2054
+ let scaledW = Int(Double(srcWidth) * scale)
2055
+ let scaledH = Int(Double(srcHeight) * scale)
2056
+ let offsetX = (totalW - scaledW) / 2
2057
+ let offsetY = (totalH - scaledH) / 2
2058
+
2059
+ Bridge.log(
2060
+ "G2: renderAndSliceTo4Tiles - input \(srcWidth)x\(srcHeight) → \(scaledW)x\(scaledH) in \(totalW)x\(totalH)"
2061
+ )
2062
+
2063
+ // Render to 400x200 8-bit grayscale
2064
+ guard
2065
+ let ctx = CGContext(
2066
+ data: nil,
2067
+ width: totalW,
2068
+ height: totalH,
2069
+ bitsPerComponent: 8,
2070
+ bytesPerRow: totalW,
2071
+ space: CGColorSpaceCreateDeviceGray(),
2072
+ bitmapInfo: CGImageAlphaInfo.none.rawValue
2073
+ )
2074
+ else {
2075
+ Bridge.log("G2: renderAndSliceTo4Tiles - failed to create CGContext")
2076
+ return nil
2077
+ }
2078
+
2079
+ ctx.setFillColor(gray: 0, alpha: 1)
2080
+ ctx.fill(CGRect(x: 0, y: 0, width: totalW, height: totalH))
2081
+ ctx.interpolationQuality = .high
2082
+ ctx.draw(cgImage, in: CGRect(x: offsetX, y: offsetY, width: scaledW, height: scaledH))
2083
+
2084
+ guard let renderedImage = ctx.makeImage(),
2085
+ let fullPixels = renderedImage.dataProvider?.data as Data?
2086
+ else {
2087
+ Bridge.log("G2: renderAndSliceTo4Tiles - failed to get pixel data")
2088
+ return nil
2089
+ }
2090
+
2091
+ // Slice into 4 tiles and build BMP for each
2092
+ // CGContext origin is bottom-left, but pixel data is top-left row-first
2093
+ let tw = G2.tileWidth // 200
2094
+ let th = G2.tileHeight // 100
2095
+ let tileOrigins = [
2096
+ (0, 0), // top-left
2097
+ (tw, 0), // top-right
2098
+ (0, th), // bottom-left
2099
+ (tw, th), // bottom-right
2100
+ ]
2101
+
2102
+ var tiles: [Data] = []
2103
+ for (ox, oy) in tileOrigins {
2104
+ // Extract tile pixels from the full 400x200 buffer
2105
+ var tilePixels = Data(capacity: tw * th)
2106
+ for row in 0 ..< th {
2107
+ let srcRowStart = (oy + row) * totalW + ox
2108
+ tilePixels.append(fullPixels[srcRowStart ..< (srcRowStart + tw)])
2109
+ }
2110
+ guard let bmp = build4BitBmp(grayscalePixels: tilePixels, width: tw, height: th) else {
2111
+ Bridge.log("G2: renderAndSliceTo4Tiles - failed to build BMP for tile")
2112
+ return nil
2113
+ }
2114
+ tiles.append(bmp)
2115
+ }
2116
+
2117
+ return tiles
2118
+ }
2119
+
2120
+ /// Build a 4-bit indexed BMP file from 8-bit grayscale pixel data.
2121
+ /// BMP rows are stored bottom-up. Each row is padded to a 4-byte boundary.
2122
+ private func build4BitBmp(grayscalePixels: Data, width: Int, height: Int) -> Data? {
2123
+ // 4-bit: 2 pixels per byte, rows padded to 4-byte boundary
2124
+ let bytesPerRow4bit = (width + 1) / 2 // ceil(width / 2)
2125
+ let paddedRowSize = (bytesPerRow4bit + 3) & ~3 // pad to 4-byte boundary
2126
+ let pixelDataSize = paddedRowSize * height
2127
+
2128
+ // BMP file header (14 bytes) + DIB header (40 bytes) + color table (16 * 4 = 64 bytes)
2129
+ let headerSize = 14 + 40 + 64
2130
+ let fileSize = headerSize + pixelDataSize
2131
+
2132
+ var bmp = Data(capacity: fileSize)
2133
+
2134
+ // --- BMP File Header (14 bytes) ---
2135
+ bmp.append(contentsOf: [0x42, 0x4D]) // "BM" signature
2136
+ bmp.appendLittleEndian(UInt32(fileSize)) // File size
2137
+ bmp.appendLittleEndian(UInt16(0)) // Reserved1
2138
+ bmp.appendLittleEndian(UInt16(0)) // Reserved2
2139
+ bmp.appendLittleEndian(UInt32(headerSize)) // Pixel data offset
2140
+
2141
+ // --- DIB Header (BITMAPINFOHEADER, 40 bytes) ---
2142
+ bmp.appendLittleEndian(UInt32(40)) // DIB header size
2143
+ bmp.appendLittleEndian(Int32(width)) // Width
2144
+ bmp.appendLittleEndian(Int32(height)) // Height (positive = bottom-up)
2145
+ bmp.appendLittleEndian(UInt16(1)) // Color planes
2146
+ bmp.appendLittleEndian(UInt16(4)) // Bits per pixel (4-bit)
2147
+ bmp.appendLittleEndian(UInt32(0)) // Compression (none)
2148
+ bmp.appendLittleEndian(UInt32(pixelDataSize)) // Image size
2149
+ bmp.appendLittleEndian(Int32(2835)) // X pixels/meter (~72 DPI)
2150
+ bmp.appendLittleEndian(Int32(2835)) // Y pixels/meter
2151
+ bmp.appendLittleEndian(UInt32(16)) // Colors used
2152
+ bmp.appendLittleEndian(UInt32(0)) // Important colors (0 = all)
2153
+
2154
+ // --- Color Table (16 entries, 4 bytes each: B, G, R, 0) ---
2155
+ for i in 0 ..< 16 {
2156
+ let val = UInt8(i * 17) // 0, 17, 34, ... 255 (evenly spaced grayscale)
2157
+ bmp.append(contentsOf: [val, val, val, 0]) // B, G, R, Reserved
2158
+ }
2159
+
2160
+ // --- Pixel Data (bottom-up rows, 4-bit packed) ---
2161
+ let rowBytes = [UInt8](repeating: 0, count: paddedRowSize)
2162
+ for row in 0 ..< height {
2163
+ // BMP is bottom-up: row 0 in BMP = last row of image
2164
+ let srcRow = height - 1 - row
2165
+ let srcOffset = srcRow * width
2166
+ var rowBuf = rowBytes
2167
+
2168
+ for col in 0 ..< width {
2169
+ let pixelIndex = srcOffset + col
2170
+ guard pixelIndex < grayscalePixels.count else { continue }
2171
+
2172
+ // Map 8-bit grayscale (0-255) to 4-bit index (0-15)
2173
+ let gray8 = grayscalePixels[pixelIndex]
2174
+ let index4 = gray8 >> 4 // divide by 16
2175
+
2176
+ let bytePos = col / 2
2177
+ if col % 2 == 0 {
2178
+ // High nibble
2179
+ rowBuf[bytePos] = index4 << 4
2180
+ } else {
2181
+ // Low nibble
2182
+ rowBuf[bytePos] |= index4
2183
+ }
2184
+ }
2185
+ bmp.append(contentsOf: rowBuf)
2186
+ }
2187
+
2188
+ Bridge.log(
2189
+ "G2: build4BitBmp - \(bmp.count) bytes (header=\(headerSize), pixels=\(pixelDataSize), rows=\(paddedRowSize)x\(height))"
2190
+ )
2191
+ return bmp
2192
+ }
2193
+
2194
+ func showDashboard() {
2195
+ // G2 doesn't have a native dashboard concept via EvenHub
2196
+ }
2197
+
2198
+ func setDashboardPosition(_ height: Int, _ depth: Int) {
2199
+ Bridge.log("G2: setDashboardPosition(height=\(height), depth=\(depth))")
2200
+ setDashboardHeightOnly(height)
2201
+ setDashboardDepthOnly(depth)
2202
+ }
2203
+
2204
+ func setDashboardHeightOnly(_ height: Int) {
2205
+ let clamped = Int32(min(max(height, 0), 12))
2206
+ Bridge.log("G2: setDashboardHeightOnly(\(clamped))")
2207
+ let msg = G2SettingProto.setScreenHeight(
2208
+ magicRandom: sendManager.nextMagicRandom(),
2209
+ level: clamped
2210
+ )
2211
+ sendG2SettingCommand(msg)
2212
+ }
2213
+
2214
+ func setDashboardDepthOnly(_ depth: Int) {
2215
+ let clamped = Int32(min(max(depth, 0), 2))
2216
+ Bridge.log("G2: setDashboardDepthOnly(\(clamped))")
2217
+ let msg = G2SettingProto.setScreenDepth(
2218
+ magicRandom: sendManager.nextMagicRandom(),
2219
+ level: clamped
2220
+ )
2221
+ sendG2SettingCommand(msg)
2222
+ }
2223
+
2224
+ func setBrightness(_ level: Int, autoMode: Bool) {
2225
+ Bridge.log("G2: setBrightness(\(level), auto=\(autoMode))")
2226
+ let msg = G2SettingProto.setBrightness(
2227
+ magicRandom: sendManager.nextMagicRandom(),
2228
+ level: Int32(level),
2229
+ autoAdjust: autoMode
2230
+ )
2231
+ sendG2SettingCommand(msg)
2232
+ }
2233
+
2234
+ // MARK: - Private Display Helpers
2235
+
2236
+ private func createPageWithText(_ text: String) {
2237
+ let tc = EvenHubProto.textContainerProperty(
2238
+ x: 0, y: 0, width: 576, height: 288,
2239
+ borderWidth: 0, borderColor: 0, borderRadius: 0,
2240
+ paddingLength: 4, containerID: textContainerID,
2241
+ containerName: "text-main", isEventCapture: true,
2242
+ content: text
2243
+ )
2244
+
2245
+ let msg: Data
2246
+ if !startupPageCreated {
2247
+ Bridge.log("G2: createPageWithText - using createPageMessage (first time)")
2248
+ msg = EvenHubProto.createPageMessage(
2249
+ textContainers: [tc], magicRandom: sendManager.nextMagicRandom(),
2250
+ appId: activeMenuAppId
2251
+ )
2252
+ startupPageCreated = true
2253
+ } else {
2254
+ Bridge.log("G2: createPageWithText - using rebuildPageMessage")
2255
+ msg = EvenHubProto.rebuildPageMessage(
2256
+ textContainers: [tc], magicRandom: sendManager.nextMagicRandom(),
2257
+ appId: activeMenuAppId
2258
+ )
2259
+ }
2260
+ sendEvenHubCommand(msg)
2261
+ pageCreated = true
2262
+ pageHasTextContainer = true
2263
+ currentTextContent = text
2264
+ currentBitmapBase64 = ""
2265
+ }
2266
+
2267
+ private func updateText(_ text: String) {
2268
+ let msg = EvenHubProto.updateTextMessage(
2269
+ containerID: textContainerID,
2270
+ contentOffset: 0,
2271
+ contentLength: Int32(text.utf8.count),
2272
+ content: text
2273
+ )
2274
+ queueEvenHubCommand(msg)
2275
+ currentTextContent = text
2276
+ currentBitmapBase64 = ""
2277
+ }
2278
+
2279
+ private func queueEvenHubCommand(_ payload: Data) {
2280
+ evenHubQueueLock.lock()
2281
+ pendingTextMsg = payload
2282
+ evenHubQueueLock.unlock()
2283
+ }
2284
+
2285
+ private func drainEvenHubQueue() {
2286
+ evenHubQueueLock.lock()
2287
+ let msg = pendingTextMsg
2288
+ pendingTextMsg = nil
2289
+ let toSend: Data?
2290
+ if let msg = msg {
2291
+ lastEvenHubMsg = msg
2292
+ lastEvenHubResendsRemaining = EVEN_HUB_RESEND_COUNT
2293
+ toSend = msg
2294
+ } else if lastEvenHubResendsRemaining > 0, let last = lastEvenHubMsg {
2295
+ lastEvenHubResendsRemaining -= 1
2296
+ toSend = last
2297
+ } else {
2298
+ toSend = nil
2299
+ }
2300
+ evenHubQueueLock.unlock()
2301
+ guard let toSend = toSend else { return }
2302
+ sendEvenHubCommand(toSend)
2303
+ }
2304
+
2305
+ // MARK: - SGCManager: Audio Control
2306
+
2307
+ func setMicEnabled(_ enabled: Bool) {
2308
+ Bridge.log("G2: setMicEnabled(\(enabled))")
2309
+ let currentEnabled = DeviceStore.shared.get("glasses", "micEnabled") as? Bool ?? false
2310
+ if currentEnabled && enabled {
2311
+ // if already enabled, set to disabled, then send enabled after 500ms:
2312
+ DeviceStore.shared.apply("glasses", "micEnabled", true)
2313
+ let msg = EvenHubProto.audioControlMessage(enable: false)
2314
+ sendEvenHubCommand(msg)
2315
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
2316
+ guard let self = self else { return }
2317
+ let msg = EvenHubProto.audioControlMessage(enable: true)
2318
+ self.sendEvenHubCommand(msg)
2319
+ }
2320
+ return
2321
+ }
2322
+
2323
+ DeviceStore.shared.apply("glasses", "micEnabled", enabled)
2324
+ let msg = EvenHubProto.audioControlMessage(enable: enabled)
2325
+ sendEvenHubCommand(msg)
2326
+ }
2327
+
2328
+ func sortMicRanking(list: [String]) -> [String] {
2329
+ return list
2330
+ }
2331
+
2332
+ // MARK: - SGCManager: Connection Management
2333
+
2334
+ func findCompatibleDevices() {
2335
+ Bridge.log("G2: findCompatibleDevices()")
2336
+ DEVICE_SEARCH_ID = "NOT_SET"
2337
+ startScan()
2338
+ }
2339
+
2340
+ func connectById(_ id: String) {
2341
+ Bridge.log("G2: connectById(\(id))")
2342
+ DEVICE_SEARCH_ID = id
2343
+ startScan()
2344
+ startPairingTimeout()
2345
+ }
2346
+
2347
+ private func startPairingTimeout() {
2348
+ pairingTimeoutTimer?.cancel()
2349
+ let work = DispatchWorkItem { [weak self] in
2350
+ guard let self = self else { return }
2351
+ if self.leftPeripheral != nil && self.rightPeripheral == nil {
2352
+ Bridge.log("G2: pairing timeout — found LEFT but not RIGHT")
2353
+ Bridge.sendPairFailureEvent("errors:pairNeedDisconnect")
2354
+ }
2355
+ }
2356
+ pairingTimeoutTimer = work
2357
+ DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: work)
2358
+ }
2359
+
2360
+ private func cancelPairingTimeout() {
2361
+ pairingTimeoutTimer?.cancel()
2362
+ pairingTimeoutTimer = nil
2363
+ }
2364
+
2365
+ func disconnect() {
2366
+ Bridge.log("G2: disconnect()")
2367
+ isDisconnecting = true
2368
+ cancelPairingTimeout()
2369
+ stopHeartbeats()
2370
+ Task { await reconnectionManager.stop() }
2371
+
2372
+ // Disconnect known peripherals
2373
+ if let left = leftPeripheral {
2374
+ centralManager?.cancelPeripheralConnection(left)
2375
+ }
2376
+ if let right = rightPeripheral {
2377
+ centralManager?.cancelPeripheralConnection(right)
2378
+ }
2379
+
2380
+ // Also disconnect any other G2 peripherals the system still has connected
2381
+ let connected = getConnectedDevices()
2382
+ for peripheral in connected {
2383
+ centralManager?.cancelPeripheralConnection(peripheral)
2384
+ }
2385
+
2386
+ leftInitialized = false
2387
+ rightInitialized = false
2388
+ authStarted = false
2389
+ leftAuthenticated = false
2390
+ rightAuthenticated = false
2391
+ startupPageCreated = false
2392
+ pageCreated = false
2393
+ pageHasTextContainer = false
2394
+ heartbeatCounter = 0
2395
+ DeviceStore.shared.apply("glasses", "connected", false)
2396
+ DeviceStore.shared.apply("glasses", "fullyBooted", false)
2397
+ }
2398
+
2399
+ func forget() {
2400
+ stopHeartbeats()
2401
+ Task { await reconnectionManager.stop() }
2402
+ disconnect()
2403
+ // Note: leftGlassUUIDMap / rightGlassUUIDMap intentionally preserved so a future
2404
+ // pair to the same serial number can reuse the cached peripheral UUID.
2405
+ leftPeripheral = nil
2406
+ rightPeripheral = nil
2407
+ leftWriteChar = nil
2408
+ rightWriteChar = nil
2409
+ leftNotifyChar = nil
2410
+ rightNotifyChar = nil
2411
+ rightAudioChar = nil
2412
+ leftAudioChar = nil
2413
+ DEVICE_SEARCH_ID = "NOT_SET"
2414
+ centralManager?.delegate = nil
2415
+ }
2416
+
2417
+ func cleanup() {
2418
+ disconnect()
2419
+ }
2420
+
2421
+ func getConnectedBluetoothName() -> String? {
2422
+ return rightPeripheral?.name ?? leftPeripheral?.name
2423
+ }
2424
+
2425
+ func ping() {
2426
+ sendEvenHubHeartbeat()
2427
+ }
2428
+
2429
+ func connectController() {
2430
+ let isFullyBooted = DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
2431
+ guard isFullyBooted else {
2432
+ Bridge.log("G2: connectController - g2 not fully booted, ignoring")
2433
+ return
2434
+ }
2435
+
2436
+ guard let mac = DeviceStore.shared.get("glasses", "controllerMacAddress") as? String else {
2437
+ Bridge.log("G2: connectController - no MAC address found")
2438
+ return
2439
+ }
2440
+
2441
+ // Parse "AA:BB:CC:DD:EE:FF" into 6-byte Data
2442
+ let hexParts = mac.split(separator: ":").compactMap { UInt8($0, radix: 16) }
2443
+ guard hexParts.count == 6 else {
2444
+ Bridge.log("G2: connectController - invalid MAC format: \(mac)")
2445
+ return
2446
+ }
2447
+ let macData = Data(hexParts)
2448
+
2449
+ let msg = DevSettingsProto.ringConnectInfo(
2450
+ magicRandom: sendManager.nextMagicRandom(),
2451
+ connect: true,
2452
+ ringMac: macData
2453
+ )
2454
+ sendDevSettingsCommand(msg)
2455
+ Bridge.log("G2: Sent RING_CONNECT_INFO for MAC \(mac)")
2456
+ }
2457
+
2458
+ func disconnectController() {
2459
+ let isFullyBooted = DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
2460
+ guard isFullyBooted else {
2461
+ Bridge.log("G2: disconnectController - g2 not fully booted, ignoring")
2462
+ return
2463
+ }
2464
+
2465
+ guard let mac = DeviceStore.shared.get("glasses", "controllerMacAddress") as? String else {
2466
+ Bridge.log("G2: disconnectController - no MAC address found")
2467
+ return
2468
+ }
2469
+
2470
+ // Parse "AA:BB:CC:DD:EE:FF" into 6-byte Data
2471
+ let hexParts = mac.split(separator: ":").compactMap { UInt8($0, radix: 16) }
2472
+ guard hexParts.count == 6 else {
2473
+ Bridge.log("G2: disconnectController - invalid MAC format: \(mac)")
2474
+ return
2475
+ }
2476
+ let macData = Data(hexParts)
2477
+
2478
+ let msg = DevSettingsProto.ringConnectInfo(
2479
+ magicRandom: sendManager.nextMagicRandom(),
2480
+ connect: false,
2481
+ ringMac: macData
2482
+ )
2483
+ sendDevSettingsCommand(msg)
2484
+
2485
+ // DeviceStore.shared.apply("glasses", "controllerMacAddress", "")
2486
+ DeviceStore.shared.apply("glasses", "controllerConnected", false)
2487
+ DeviceStore.shared.apply("glasses", "controllerFullyBooted", false)
2488
+ Bridge.log("G2: Sent RING_DISCONNECT_INFO for MAC \(mac)")
2489
+ }
2490
+
2491
+ func dbg1() {
2492
+ Bridge.log("G2: dbg1()")
2493
+
2494
+ // // send a shutdown message
2495
+ // let msg = EvenHubProto.shutdownMessage()
2496
+ // sendEvenHubCommand(msg)
2497
+ // pageCreated = false
2498
+ // currentTextContent = ""
2499
+
2500
+ // DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
2501
+ // guard let self = self else { return }
2502
+ // // self.sendShutdown()
2503
+ // // runAuthSequence()
2504
+ // runDashboardSequence()
2505
+ // }
2506
+
2507
+ // connectController("1B:08:26:8E:0E:E6")
2508
+ connectController()
2509
+ }
2510
+
2511
+ func dbg2() {
2512
+ Bridge.log("G2: dbg2()")
2513
+
2514
+ // createPageWithText("test1")
2515
+
2516
+ // let tc = EvenHubProto.textContainerProperty(
2517
+ // x: 0, y: 0, width: 576, height: 288,
2518
+ // borderWidth: 0, borderColor: 0, borderRadius: 0,
2519
+ // paddingLength: 4, containerID: textContainerID,
2520
+ // containerName: "text-main2", isEventCapture: true,
2521
+ // content: "test-dbg1"
2522
+ // )
2523
+
2524
+ // let msg: Data
2525
+ // Bridge.log("G2: dbg2 - sending createPageMessage()")
2526
+ // msg = EvenHubProto.createPageMessage(
2527
+ // textContainers: [tc], magicRandom: sendManager.nextMagicRandom(),
2528
+ // appId: nil)
2529
+
2530
+ // sendEvenHubCommand(msg)
2531
+
2532
+ // // update the text
2533
+ // Bridge.log("G2: sendTextWall() - updating text container")
2534
+ // updateText("test2")
2535
+ }
2536
+
2537
+ // MARK: - SGCManager: Device Control
2538
+
2539
+ func setHeadUpAngle(_ angle: Int) {
2540
+ let clamped = min(max(angle, 0), 60)
2541
+ Bridge.log("G2: setHeadUpAngle(\(clamped))")
2542
+
2543
+ // Enable head-up display
2544
+ let enableMsg = G2SettingProto.setHeadUpSwitch(
2545
+ magicRandom: sendManager.nextMagicRandom(),
2546
+ enabled: true
2547
+ )
2548
+ sendG2SettingCommand(enableMsg)
2549
+
2550
+ // Set the angle
2551
+ let angleMsg = G2SettingProto.setHeadUpAngle(
2552
+ magicRandom: sendManager.nextMagicRandom(),
2553
+ angle: Int32(clamped)
2554
+ )
2555
+ sendG2SettingCommand(angleMsg)
2556
+ }
2557
+
2558
+ func getBatteryStatus() {
2559
+ Bridge.log("G2: getBatteryStatus()")
2560
+ requestDeviceInfo()
2561
+ }
2562
+
2563
+ func setDashboardMenu(_ items: [[String: Any]]) {
2564
+ let menuItems = items.compactMap { dict -> MenuProto.MenuItem? in
2565
+ guard let name = dict["name"] as? String,
2566
+ let packageName = dict["packageName"] as? String
2567
+ else { return nil }
2568
+ let running = dict["running"] as? Bool ?? false
2569
+ return MenuProto.MenuItem(packageName: packageName, name: name, running: running)
2570
+ }
2571
+ dashboardMenuItems = menuItems
2572
+ Bridge.log("G2: setDashboardMenu — sending \(menuItems.count) items")
2573
+ let (msg, appIdMap) = MenuProto.sendMenuInfo(
2574
+ magicRandom: sendManager.nextMagicRandom(),
2575
+ items: menuItems
2576
+ )
2577
+ menuAppIdToPackageName = appIdMap
2578
+ activeMenuAppId = appIdMap.keys.sorted().first
2579
+ sendMenuCommand(msg)
2580
+ }
2581
+
2582
+ func setSilentMode(_: Bool) {
2583
+ // TODO: Implement
2584
+ }
2585
+
2586
+ func exit() {
2587
+ Bridge.log("G2: exit()")
2588
+ clearDisplay()
2589
+ }
2590
+
2591
+ func sendShutdown() {
2592
+ Bridge.log("G2: sendShutdown()")
2593
+ clearDisplay()
2594
+ disconnect()
2595
+ }
2596
+
2597
+ func sendReboot() {
2598
+ // TODO: Implement via dev_settings
2599
+ }
2600
+
2601
+ func sendRgbLedControl(
2602
+ requestId _: String, packageName _: String?, action _: String, color _: String?,
2603
+ ontime _: Int, offtime _: Int, count _: Int
2604
+ ) {
2605
+ // G2 doesn't have RGB LEDs
2606
+ }
2607
+
2608
+ // MARK: - SGCManager: Messaging
2609
+
2610
+ func sendJson(_: [String: Any], wakeUp _: Bool, requireAck _: Bool) {
2611
+ // G2 doesn't use JSON messaging
2612
+ }
2613
+
2614
+ // MARK: - SGCManager: Camera & Media (not supported on G2)
2615
+
2616
+ func requestPhoto(
2617
+ _: String, appId _: String, size _: String?, webhookUrl _: String?, authToken _: String?,
2618
+ compress _: String?, flash _: Bool, sound _: Bool
2619
+ ) {}
2620
+ func startVideoRecording(requestId _: String, save _: Bool, flash _: Bool, sound _: Bool) {}
2621
+ func startStream(_: [String: Any]) {}
2622
+ func stopStream() {}
2623
+ func sendStreamKeepAlive(_: [String: Any]) {}
2624
+ func stopVideoRecording(requestId _: String) {}
2625
+ func sendButtonPhotoSettings() {}
2626
+ func sendButtonVideoRecordingSettings() {}
2627
+ func sendButtonMaxRecordingTime() {}
2628
+ func sendButtonCameraLedSetting() {}
2629
+
2630
+ func sendCameraFovSetting() {}
2631
+
2632
+ // MARK: - SGCManager: Network (G2 has no WiFi)
2633
+
2634
+ func requestWifiScan() {}
2635
+ func sendWifiCredentials(_: String, _: String) {}
2636
+ func forgetWifiNetwork(_: String) {}
2637
+ func sendHotspotState(_: Bool) {}
2638
+ func sendOtaStart() {}
2639
+ func sendOtaQueryStatus() {}
2640
+
2641
+ // MARK: - SGCManager: User Context
2642
+
2643
+ func sendUserEmailToGlasses(_: String) {
2644
+ // TODO: Could send via dev_settings
2645
+ }
2646
+
2647
+ // MARK: - SGCManager: Gallery
2648
+
2649
+ func queryGalleryStatus() {}
2650
+ func sendGalleryMode() {}
2651
+
2652
+ // MARK: - SGCManager: Version Info
2653
+
2654
+ func requestVersionInfo() {
2655
+ Bridge.log("G2: requestVersionInfo()")
2656
+ requestDeviceInfo()
2657
+ }
2658
+
2659
+ // MARK: - BLE Scanning
2660
+
2661
+ @discardableResult
2662
+ private func startScan() -> Bool {
2663
+ Bridge.log("G2: startScan()")
2664
+ if centralManager == nil {
2665
+ centralManager = CBCentralManager(
2666
+ delegate: self, queue: G2._bluetoothQueue,
2667
+ options: [CBCentralManagerOptionShowPowerAlertKey: 0]
2668
+ )
2669
+ }
2670
+
2671
+ isDisconnecting = false
2672
+ guard centralManager!.state == .poweredOn else {
2673
+ Bridge.log("G2: Bluetooth not powered on")
2674
+ return false
2675
+ }
2676
+
2677
+ let devices = getConnectedDevices()
2678
+ Bridge.log("G2: connnectedDevices.count: (\(devices.count))")
2679
+ for device in devices {
2680
+ if let name = device.name, let serialNumber = deviceNameToSerialNumber[name] {
2681
+ Bridge.log("G2: Connected to device: \(name)")
2682
+
2683
+ if name.contains("_L_") && serialNumber.contains(DEVICE_SEARCH_ID) {
2684
+ leftPeripheral = device
2685
+ device.delegate = self
2686
+ device.discoverServices([G2BLE.SERVICE_UUID])
2687
+ centralManager!.connect(
2688
+ leftPeripheral!,
2689
+ options: [
2690
+ CBConnectPeripheralOptionNotifyOnConnectionKey: true,
2691
+ CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
2692
+ ]
2693
+ )
2694
+ } else if name.contains("_R_") && serialNumber.contains(DEVICE_SEARCH_ID) {
2695
+ rightPeripheral = device
2696
+ device.delegate = self
2697
+ device.discoverServices([G2BLE.SERVICE_UUID])
2698
+ centralManager!.connect(
2699
+ rightPeripheral!,
2700
+ options: [
2701
+ CBConnectPeripheralOptionNotifyOnConnectionKey: true,
2702
+ CBConnectPeripheralOptionNotifyOnDisconnectionKey: true,
2703
+ ]
2704
+ )
2705
+ }
2706
+ // we can't emit the serial number here unfortunately:
2707
+ emitDiscoveredDevice(serialNumber)
2708
+ }
2709
+ }
2710
+
2711
+ // Try UUID-based reconnection first
2712
+ if connectByUUID() {
2713
+ return true
2714
+ }
2715
+
2716
+ centralManager!.scanForPeripherals(
2717
+ withServices: nil,
2718
+ options: [
2719
+ CBCentralManagerScanOptionAllowDuplicatesKey: false,
2720
+ ]
2721
+ )
2722
+ return true
2723
+ }
2724
+
2725
+ func stopScan() {
2726
+ centralManager?.stopScan()
2727
+ }
2728
+
2729
+ private func connectByUUID() -> Bool {
2730
+ // don't do this if we don't have a search id set:
2731
+ if DEVICE_SEARCH_ID == "NOT_SET" || DEVICE_SEARCH_ID.isEmpty {
2732
+ Bridge.log("G2: 🔵 No DEVICE_SEARCH_ID set, skipping connect by UUID")
2733
+ return false
2734
+ }
2735
+
2736
+ guard let leftUUID = leftGlassUUID(forSN: DEVICE_SEARCH_ID),
2737
+ let rightUUID = rightGlassUUID(forSN: DEVICE_SEARCH_ID)
2738
+ else { return false }
2739
+
2740
+ let knownLeft = centralManager?.retrievePeripherals(withIdentifiers: [leftUUID])
2741
+ let knownRight = centralManager?.retrievePeripherals(withIdentifiers: [rightUUID])
2742
+
2743
+ guard let left = knownLeft?.first, let right = knownRight?.first else { return false }
2744
+
2745
+ // Validate the cached peripherals match the device the user selected
2746
+ let leftName = left.name ?? ""
2747
+ let rightName = right.name ?? ""
2748
+ // if !leftName.isEmpty && !leftName.contains(DEVICE_SEARCH_ID) {
2749
+ // Bridge.log(
2750
+ // "G2: connectByUUID - cached left '\(leftName)' doesn't match search ID '\(DEVICE_SEARCH_ID)', skipping"
2751
+ // )
2752
+ // return false
2753
+ // }
2754
+ // if !rightName.isEmpty && !rightName.contains(DEVICE_SEARCH_ID) {
2755
+ // Bridge.log(
2756
+ // "G2: connectByUUID - cached right '\(rightName)' doesn't match search ID '\(DEVICE_SEARCH_ID)', skipping"
2757
+ // )
2758
+ // return false
2759
+ // }
2760
+
2761
+ Bridge.log("G2: connectByUUID - left: \(leftName), right: \(rightName)")
2762
+
2763
+ leftPeripheral = left
2764
+ rightPeripheral = right
2765
+ left.delegate = self
2766
+ right.delegate = self
2767
+ centralManager?.connect(left, options: nil)
2768
+ centralManager?.connect(right, options: nil)
2769
+ return true
2770
+ }
2771
+
2772
+ private func getConnectedDevices() -> [CBPeripheral] {
2773
+ // G2 exposes multiple BLE service families (EvenHub 0x2760, Nordic UART 6E40, BAE8).
2774
+ // Check all of them — if the Even app was the last to connect, iOS may have cached
2775
+ // a different service than our primary one, and retrieveConnectedPeripherals only
2776
+ // returns peripherals whose services match.
2777
+ let serviceUUIDs: [CBUUID] = [
2778
+ G2BLE.SERVICE_UUID, // EvenHub: 00002760-...-0000
2779
+ CBUUID(string: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"), // Nordic UART
2780
+ ]
2781
+ var devices: [CBPeripheral] = []
2782
+ for svc in serviceUUIDs {
2783
+ let found = centralManager?.retrieveConnectedPeripherals(withServices: [svc]) ?? []
2784
+ for d in found {
2785
+ if !devices.contains(where: { $0.identifier == d.identifier }) {
2786
+ devices.append(d)
2787
+ }
2788
+ }
2789
+ }
2790
+ return devices
2791
+ }
2792
+
2793
+ private func emitDiscoveredDevice(_ serialNumber: String) {
2794
+ // Extract the numeric ID from name like "Even G2_32_R_3FFA6D" -> "32"
2795
+ // guard let idNumber = extractIdNumber(name) else {
2796
+ // Bridge.log("G2: Could not extract ID from: \(name)")
2797
+ // return
2798
+ // }
2799
+ Bridge.sendDiscoveredDevice(DeviceTypes.G2, serialNumber)
2800
+ }
2801
+
2802
+ private func extractIdNumber(_ name: String) -> Int? {
2803
+ // Name format: "Even G2_XX_L_XXXXXX" or "Even G2_XX_R_XXXXXX"
2804
+ // Extract XX (the numeric ID between G2_ and _L_/_R_)
2805
+ let pattern = "G2_(\\d+)_"
2806
+ guard let regex = try? NSRegularExpression(pattern: pattern),
2807
+ let match = regex.firstMatch(in: name, range: NSRange(name.startIndex..., in: name)),
2808
+ let range = Range(match.range(at: 1), in: name)
2809
+ else {
2810
+ return nil
2811
+ }
2812
+ return Int(name[range])
2813
+ }
2814
+
2815
+ // MARK: - Incoming Data Handling
2816
+
2817
+ private func handleNotifyData(_ data: Data, from peripheral: CBPeripheral) {
2818
+ // Distinguish left vs right peripheral so multi-packet reassembly doesn't collide
2819
+ let sourceKey = peripheral === leftPeripheral ? "L" : "R"
2820
+ guard let result = receiveManager.handlePacket(data, sourceKey: sourceKey) else { return }
2821
+ // Bridge.log(
2822
+ // "G2: handleNotifyData() - serviceId=\(result.serviceId), payload=\(result.payload.count) bytes"
2823
+ // )
2824
+
2825
+ // Route based on service ID
2826
+ switch result.serviceId {
2827
+ case ServiceID.evenHub.rawValue:
2828
+ handleEvenHubResponse(result.payload)
2829
+ case ServiceID.deviceSettings.rawValue:
2830
+ handleDevSettingsResponse(result.payload, sourceKey: sourceKey)
2831
+ case ServiceID.g2Setting.rawValue:
2832
+ handleG2SettingResponse(result.payload)
2833
+ case ServiceID.menu.rawValue:
2834
+ handleMenuResponse(result.payload)
2835
+ case ServiceID.dashboard.rawValue:
2836
+ handleDashboardResponse(result.payload)
2837
+ case ServiceID.gestureCtrl.rawValue:
2838
+ handleGestureCtrl(result.payload)
2839
+ case ServiceID.evenHubCtrl.rawValue:
2840
+ handleEvenHubCtrlResponse(result.payload)
2841
+ default:
2842
+ Bridge.log(
2843
+ "G2: Unhandled service \(result.serviceId) (\(result.payload.count) bytes): \(result.payload.prefix(32).map { String(format: "%02X", $0) }.joined())"
2844
+ )
2845
+ }
2846
+ }
2847
+
2848
+ private func handleEvenHubResponse(_ payload: Data) {
2849
+ // Parse evenhub_main_msg_ctx: field 1 = Cmd (varint), field 13 = DevEvent (submessage)
2850
+ var reader = ProtobufReader(payload)
2851
+ let fields = reader.parseFields()
2852
+
2853
+ guard let cmdValue = fields[1] as? Int32 else {
2854
+ Bridge.log(
2855
+ "G2: EvenHub response - no cmd field, \(payload.count) bytes: \(payload.map { String(format: "%02X", $0) }.joined())"
2856
+ )
2857
+ return
2858
+ }
2859
+
2860
+ // Bridge.log("G2: EvenHub incoming cmd=\(cmdValue), fields=\(Array(fields.keys).sorted())")
2861
+
2862
+ if cmdValue == EvenHubResponseCmd.osNotifyEventToApp.rawValue {
2863
+ // Touch/gesture event from glasses
2864
+ guard let devEventData = fields[13] as? Data else { return }
2865
+ let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
2866
+ if lastClickTimestamp != nil && timestamp - lastClickTimestamp! < 100 {
2867
+ // Bridge.log("G2: Double click ignored (too soon)")
2868
+ return
2869
+ }
2870
+ lastClickTimestamp = timestamp
2871
+ handleTouchEvent(devEventData)
2872
+ } else if cmdValue == 17 {
2873
+ // Miniapp selection from glasses dashboard menu (cmdId=17)
2874
+ // Dedup: L and R peripherals both deliver this event, so debounce or
2875
+ // MantleManager toggles start→stop in quick succession.
2876
+ let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
2877
+ if lastMenuSelectTimestamp != nil && timestamp - lastMenuSelectTimestamp! < 500 {
2878
+ return
2879
+ }
2880
+ lastMenuSelectTimestamp = timestamp
2881
+ // field 20 contains sub-message with field 1 = itemAppId
2882
+ if let selectData = fields[20] as? Data {
2883
+ var selectReader = ProtobufReader(selectData)
2884
+ let selectFields = selectReader.parseFields()
2885
+ if let appId = selectFields[1] as? Int32 {
2886
+ // Resolve appId → packageName using our stored mapping
2887
+ if let packageName = menuAppIdToPackageName[appId] {
2888
+ Bridge.log("G2: Menu miniapp selected — \(packageName)")
2889
+ Bridge.sendMiniappSelected(packageName: packageName)
2890
+ // clear the display after a delay:
2891
+ // DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
2892
+ // self.clearDisplay()
2893
+ // }
2894
+ } else {
2895
+ Bridge.log(
2896
+ "G2: Menu selection ignored — placeholder or unknown appId=\(appId)"
2897
+ )
2898
+ }
2899
+ }
2900
+ }
2901
+ } else {
2902
+ // Log unhandled EvenHub commands (helps debug menu selection and stock dashboard interactions)
2903
+ // Bridge.log(
2904
+ // "G2: EvenHub response cmd=\(cmdValue), \(payload.count) bytes, fields=\(Array(fields.keys).sorted())"
2905
+ // )
2906
+
2907
+ // Parse error codes from responses
2908
+ // field 4 = StartupResCmd, field 6 = ImgResCmd, field 8 = RebuildResCmd, field 10 = TextResCmd
2909
+ for resField in [4, 6, 8, 10] {
2910
+ if let resData = fields[resField] as? Data {
2911
+ var resReader = ProtobufReader(resData)
2912
+ let resFields = resReader.parseFields()
2913
+ if let errorCode = resFields[1] as? Int32 {
2914
+ // 0=page_success, 4=img_success, 5=img_failed, 6=rebuild_success, 7=rebuild_failed, 8=text_success, 9=text_failed
2915
+ // Bridge.log("G2: EvenHub response field\(resField) errorCode=\(errorCode)")
2916
+ if errorCode == 9 {
2917
+ Bridge.log(
2918
+ "G2: WARN: Glasses shutdown our EvenHub page — resetting page state"
2919
+ )
2920
+ startupPageCreated = false
2921
+ pageCreated = false
2922
+ pageHasTextContainer = false
2923
+ currentTextContent = ""
2924
+ }
2925
+ }
2926
+ if let errorCode = resFields[8] as? Int32 {
2927
+ // ImgResCmd has ErrorCode in field 8
2928
+ Bridge.log("G2: EvenHub ImgRes errorCode=\(errorCode)")
2929
+ }
2930
+ }
2931
+ }
2932
+
2933
+ // If glasses sent a shutdown (cmd=9/10), our page is gone — reset state
2934
+ if cmdValue == 9 || cmdValue == 10 {
2935
+ Bridge.log("G2: ERROR: Glasses shutdown our EvenHub page — resetting page state")
2936
+ startupPageCreated = false
2937
+ pageCreated = false
2938
+ pageHasTextContainer = false
2939
+ currentTextContent = ""
2940
+ }
2941
+ }
2942
+ }
2943
+
2944
+ private func setFullyConnected() {
2945
+ let isFullyConnected = DeviceStore.shared.get("glasses", "connected") as? Bool ?? false
2946
+ let isFullyBooted = DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
2947
+ if !isFullyConnected {
2948
+ DeviceStore.shared.apply("glasses", "connected", true)
2949
+ }
2950
+ if !isFullyBooted {
2951
+ DeviceStore.shared.apply("glasses", "fullyBooted", true)
2952
+ }
2953
+ }
2954
+
2955
+ private func setControllerFullyConnected() {
2956
+ let isControllerConnected =
2957
+ DeviceStore.shared.get("glasses", "controllerConnected") as? Bool ?? false
2958
+ let isControllerFullyBooted =
2959
+ DeviceStore.shared.get("glasses", "controllerFullyBooted") as? Bool ?? false
2960
+ if !isControllerConnected {
2961
+ DeviceStore.shared.apply("glasses", "controllerConnected", true)
2962
+ }
2963
+ if !isControllerFullyBooted {
2964
+ DeviceStore.shared.apply("glasses", "controllerFullyBooted", true)
2965
+ }
2966
+ }
2967
+
2968
+ private func handleTouchEvent(_ devEventData: Data) {
2969
+ // Parse SendDeviceEvent: field 1=ListEvent, field 2=TextEvent, field 3=SysEvent
2970
+ var reader = ProtobufReader(devEventData)
2971
+ let fields = reader.parseFields()
2972
+
2973
+ let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
2974
+
2975
+ // if we are receiving touch events we are fully booted:
2976
+ setFullyConnected()
2977
+
2978
+ // Bridge.log("G2: handleTouchEvent: \(fields)")
2979
+ // Bridge.log(
2980
+ // "G2: handleTouchEvent: \(devEventData.map { String(format: "%02X", $0) }.joined())")
2981
+
2982
+ // SysEvent (field 3) - system-level gestures
2983
+ if let sysData = fields[3] as? Data {
2984
+ var sysReader = ProtobufReader(sysData)
2985
+ let sysFields = sysReader.parseFields()
2986
+ var eventType: OsEventType? = nil
2987
+ var eventSource: Int32? = nil
2988
+ if let normalType = sysFields[1] as? Int32 {
2989
+ eventType = OsEventType(rawValue: normalType)
2990
+ } else {
2991
+ eventType = OsEventType.click
2992
+ }
2993
+ if let source = sysFields[2] as? Int32 {
2994
+ eventSource = source
2995
+ }
2996
+
2997
+ // Bridge.log("G2: sysFields: \(sysFields)")
2998
+
2999
+ guard let eventType = eventType else {
3000
+ Bridge.log("G2: unknown event type: \(sysFields)")
3001
+ return
3002
+ }
3003
+
3004
+ guard let gestureName = mapEventTypeToGesture(eventType) else {
3005
+ Bridge.log("G2: no gesture mapping for \(eventType) \(sysFields)")
3006
+ return
3007
+ }
3008
+
3009
+ Bridge.sendTouchEvent(
3010
+ deviceModel: DeviceTypes.G2, gestureName: gestureName,
3011
+ timestamp: timestamp,
3012
+ source: eventSource
3013
+ )
3014
+ Bridge.log("G2: SysEvent → \(eventType) \(eventSource)")
3015
+
3016
+ if eventSource == 2 {
3017
+ // controller must be connected and fully booted:
3018
+ setControllerFullyConnected()
3019
+ }
3020
+
3021
+ if eventType == .doubleClick {
3022
+ // trigger dashboard:
3023
+ let isHeadUp = DeviceStore.shared.get("glasses", "headUp") as? Bool ?? false
3024
+ // toggle head up:
3025
+ DeviceStore.shared.apply("glasses", "headUp", !isHeadUp)
3026
+ if isHeadUp {
3027
+ // Bridge.log("G2: going back to home, clearing display")
3028
+ // clear the display after a delay:
3029
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
3030
+ self.clearDisplay()
3031
+ }
3032
+ }
3033
+ // sendDashboardCommand(DashboardCommand.trigger)
3034
+
3035
+ // toggle head up:
3036
+ // DeviceStore.shared.apply("glasses", "headUp", true)
3037
+ // runDashboardSequence()
3038
+ }
3039
+
3040
+ // if eventType == .foregroundEnter {
3041
+ // Bridge.log("G2: Foreground enter detected")
3042
+ // }
3043
+
3044
+ // if eventType == .click {
3045
+ // Bridge.log("G2: Click detected")
3046
+ // }
3047
+
3048
+ // System exit: glasses killed our EvenHub page (user opened menu or another app)
3049
+ // Reset page state and re-create the page to reclaim EvenHub focus
3050
+ if eventType == .systemExit || eventType == .abnormalExit {
3051
+ let savedText = currentTextContent
3052
+ let savedBitmap = currentBitmapBase64
3053
+ // Bridge.log("G2: System exit detected")
3054
+ startupPageCreated = false
3055
+ pageCreated = false
3056
+ pageHasTextContainer = false
3057
+ currentTextContent = ""
3058
+ currentBitmapBase64 = ""
3059
+ // Firmware kills the mic on system exit; re-arm it if it should be on
3060
+ DeviceStore.shared.apply("glasses", "micEnabled", false)
3061
+ DeviceManager.shared.updateMicState()
3062
+ // Force re-create the page to reclaim EvenHub focus
3063
+ // Task {
3064
+ // try? await Task.sleep(nanoseconds: 1_000_000_000) // 1000ms for glasses to finish transition
3065
+ // if !savedBitmap.isEmpty {
3066
+ // await self.displayBitmap(base64ImageData: savedBitmap)
3067
+ // } else {
3068
+ // self.sendTextWall(savedText.isEmpty ? " " : savedText)
3069
+ // }
3070
+ // }
3071
+ }
3072
+ return
3073
+ }
3074
+
3075
+ // TextEvent (field 2) - tap on text container
3076
+ if let textData = fields[2] as? Data {
3077
+ var textReader = ProtobufReader(textData)
3078
+ let textFields = textReader.parseFields()
3079
+ if let eventTypeRaw = textFields[3] as? Int32,
3080
+ let eventType = OsEventType(rawValue: eventTypeRaw)
3081
+ {
3082
+ guard let gestureName = mapEventTypeToGesture(eventType) else {
3083
+ Bridge.log("G2: no gesture mapping for \(eventType) \(textFields)")
3084
+ return
3085
+ }
3086
+ Bridge.sendTouchEvent(
3087
+ deviceModel: DeviceTypes.G2, gestureName: gestureName, timestamp: timestamp
3088
+ )
3089
+ Bridge.log("G2: TextEvent → \(gestureName)")
3090
+ }
3091
+ return
3092
+ }
3093
+
3094
+ // ListEvent (field 1) - interaction with list container
3095
+ // if let listData = fields[1] as? Data {
3096
+ // var listReader = ProtobufReader(listData)
3097
+ // let listFields = listReader.parseFields()
3098
+ // if let eventTypeRaw = listFields[5] as? Int32,
3099
+ // let eventType = OsEventType(rawValue: eventTypeRaw)
3100
+ // {
3101
+ // let gestureName = mapEventTypeToGesture(eventType)
3102
+ // if let gestureName = gestureName {
3103
+ // Bridge.sendTouchEvent(
3104
+ // deviceModel: DeviceTypes.G2, gestureName: gestureName, timestamp: timestamp
3105
+ // )
3106
+ // Bridge.log("G2: ListEvent → \(gestureName)")
3107
+ // }
3108
+ // }
3109
+ // }
3110
+ }
3111
+
3112
+ private func mapEventTypeToGesture(_ eventType: OsEventType) -> String? {
3113
+ switch eventType {
3114
+ case .click: return "single_tap"
3115
+ case .doubleClick: return "double_tap"
3116
+ case .scrollTop: return "swipe_up"
3117
+ case .scrollBottom: return "swipe_down"
3118
+ case .foregroundEnter: return "foreground_enter"
3119
+ case .foregroundExit: return "foreground_exit"
3120
+ case .systemExit: return "system_exit"
3121
+ case .abnormalExit: return nil // don't report abnormal exits as gestures
3122
+ }
3123
+ }
3124
+
3125
+ private func reconnectController() {
3126
+ let mac = DeviceStore.shared.get("glasses", "controllerMacAddress") as? String ?? ""
3127
+ guard !mac.isEmpty else {
3128
+ Bridge.log("G2: reconnectController - no MAC address found")
3129
+ return
3130
+ }
3131
+ connectController()
3132
+ }
3133
+
3134
+ private func handleDevSettingsResponse(_ data: Data, sourceKey: String) {
3135
+ // DevSettings responses (auth acks, heartbeat acks) — mostly informational
3136
+
3137
+ var reader = ProtobufReader(data)
3138
+ let fields = reader.parseFields()
3139
+
3140
+ let cmdValue = fields[1] as? Int32 ?? -1
3141
+
3142
+ // if the data is just a heartbeat, ignore it:
3143
+ if let cmdValue = fields[1] as? Int32,
3144
+ cmdValue == DevCfgCommandId.baseConnHeartBeat.rawValue
3145
+ {
3146
+ return
3147
+ }
3148
+ // Bridge.log("G2: DevSettings response cmdValue=\(cmdValue)")
3149
+
3150
+ Bridge.log(
3151
+ "G2: DevSettings response: \(data.prefix(32).map { String(format: "%02X", $0) }.joined(separator: ":"))"
3152
+ )
3153
+
3154
+ // RING_CONNECT_INFO response (cmd 6)
3155
+ if cmdValue == DevCfgCommandId.ringConnectInfo.rawValue {
3156
+ // let connStat = fields[4] as? Int32 ?? -1
3157
+ // // if it's 3c or 3d that's disconnected:
3158
+ // if connStat == 0x3c || connStat == 0x3d {
3159
+ // Bridge.log("G2: Ring disconnected")
3160
+ // DeviceStore.shared.apply("glasses", "controllerConnected", false)
3161
+ // DeviceStore.shared.apply("glasses", "controllerFullyBooted", false)
3162
+ // DeviceStore.shared.apply("glasses", "controllerSearching", true)
3163
+ // }
3164
+
3165
+ // Bridge.log("G2: Ring connection status: connStat=\(connStat)")
3166
+
3167
+ // Bridge.log("G2: RingConnectInfo: \(fields)")
3168
+ if let ringData = fields[5] as? Data { // field 5 = ringInfo
3169
+ var ringReader = ProtobufReader(ringData)
3170
+ let ringFields = ringReader.parseFields()
3171
+
3172
+ // Bridge.log("G2: RingInfo: \(ringFields)")
3173
+
3174
+ if ringFields[1] as? Int32 ?? 0 == 1 {
3175
+ Bridge.log("G2: Ring maybe connected?")
3176
+ // DeviceStore.shared.apply("glasses", "controllerConnected", true)
3177
+ DeviceStore.shared.apply("glasses", "controllerFullyBooted", true)
3178
+ }
3179
+
3180
+ if ringFields[4] as? Int32 ?? 0 == 62 {
3181
+ Bridge.log("G2: Ring maybe reconnected?")
3182
+ // DeviceStore.shared.apply("glasses", "controllerConnected", true)
3183
+ DeviceStore.shared.apply("glasses", "controllerFullyBooted", true)
3184
+ }
3185
+ }
3186
+
3187
+ // if the data ends in 2016 that's a disconnect?:
3188
+ // if data.suffix(4) == Data([0x20, 0x16]) {
3189
+ // Bridge.log("G2: Ring disconnected")
3190
+ // DeviceStore.shared.apply("glasses", "controllerConnected", false)
3191
+ // DeviceStore.shared.apply("glasses", "controllerFullyBooted", false)
3192
+ // DeviceStore.shared.apply("glasses", "controllerSearching", true)
3193
+ // }
3194
+
3195
+ if let ringData = fields[5] as? Data { // field 5 = ringInfo
3196
+ var ringReader = ProtobufReader(ringData)
3197
+ let ringFields = ringReader.parseFields()
3198
+ let connStatus = ringFields[4] as? Int32 ?? -1 // field 4 = connStatus
3199
+ Bridge.log(
3200
+ "G2: Ring connection status: connStatus?=\(connStatus))"
3201
+ )
3202
+
3203
+ if connStatus == 22 {
3204
+ Bridge.log("G2: Ring disconnected")
3205
+ DeviceStore.shared.apply("glasses", "controllerFullyBooted", false)
3206
+ DeviceStore.shared.apply("glasses", "controllerSearching", true)
3207
+ reconnectController()
3208
+ }
3209
+
3210
+ if connStatus == 8 {
3211
+ Bridge.log("G2: Ring maybe disconnected?")
3212
+ // DeviceStore.shared.apply("glasses", "controllerConnected", false)
3213
+ // DeviceStore.shared.apply("glasses", "controllerFullyBooted", false)
3214
+ // DeviceStore.shared.apply("glasses", "controllerSearching", true)
3215
+ // reconnectController()
3216
+ }
3217
+ // // DeviceStore.shared.apply("glasses", "ringConnectedToGlasses", connected)
3218
+ }
3219
+ }
3220
+
3221
+ if cmdValue == DevCfgCommandId.authentication.rawValue {
3222
+ // DevCfgDataPackage: field 2 = magicRandom, field 3 = AuthMgr { field 1 = secAuth }
3223
+ let magicRandom = fields[2] as? Int32 ?? -1
3224
+ var secAuth: Bool? = nil
3225
+ if let authData = fields[3] as? Data {
3226
+ var authReader = ProtobufReader(authData)
3227
+ let authFields = authReader.parseFields()
3228
+ if let v = authFields[1] as? Int32 {
3229
+ secAuth = (v != 0)
3230
+ }
3231
+ }
3232
+ let secAuthStr = secAuth.map { $0 ? "true" : "false" } ?? "?"
3233
+ Bridge.log("G2: Authentication response: \(sourceKey) secAuth=\(secAuthStr)")
3234
+ if secAuth == true {
3235
+ if sourceKey == "L" {
3236
+ leftAuthenticated = true
3237
+ } else if sourceKey == "R" {
3238
+ rightAuthenticated = true
3239
+ }
3240
+ if leftAuthenticated && rightAuthenticated {
3241
+ Bridge.log("G2: Both sides authenticated, setting fully booted and connected")
3242
+ setFullyConnected()
3243
+ }
3244
+ }
3245
+ }
3246
+ }
3247
+
3248
+ private func handleG2SettingResponse(_ payload: Data) {
3249
+ // Parse G2SettingPackage: field 1=commandId, field 4=DeviceReceiveRequestFromAPP (response), field 5=DeviceSendInfoToAPP
3250
+ var reader = ProtobufReader(payload)
3251
+ let fields = reader.parseFields()
3252
+
3253
+ // Bridge.log("G2: G2Setting response: \(fields)")
3254
+
3255
+ guard let cmdValue = fields[1] as? Int32 else { return }
3256
+
3257
+ // DeviceReceiveRequest response (glasses sends back requested info)
3258
+ if cmdValue == G2SettingCommandId.deviceReceiveRequest.rawValue
3259
+ || cmdValue == G2SettingCommandId.deviceSendToApp.rawValue
3260
+ {
3261
+ // The response data might be in field 4 (deviceReceiveRequestFromApp) or field 5 (deviceSendInfoToApp)
3262
+ if let requestData = fields[4] as? Data {
3263
+ parseDeviceRequestResponse(requestData)
3264
+ }
3265
+ if let sendData = fields[5] as? Data {
3266
+ parseDeviceSendToApp(sendData)
3267
+ }
3268
+ }
3269
+ }
3270
+
3271
+ private func parseDeviceRequestResponse(_ data: Data) {
3272
+ // DeviceReceiveRequestFromAPP fields:
3273
+ // 5 = leftSoftwareVersion (string), 6 = rightSoftwareVersion (string)
3274
+ // 12 = battery (int32), 13 = chargingStatus (int32)
3275
+ var reader = ProtobufReader(data)
3276
+ let fields = reader.parseFields()
3277
+
3278
+ // Bridge.log("G2: DeviceRequestResponse: \(fields)")
3279
+
3280
+ // Battery
3281
+ if let battery = fields[12] as? Int32 {
3282
+ let level = Int(battery)
3283
+ if level >= 0 && level <= 100 {
3284
+ // Bridge.log("G2: Battery level: \(level)%")
3285
+ DeviceStore.shared.apply("glasses", "batteryLevel", level)
3286
+ }
3287
+ }
3288
+
3289
+ // Charging status
3290
+ if let charging = fields[13] as? Int32 {
3291
+ let isCharging = charging != 0
3292
+ DeviceStore.shared.apply("glasses", "charging", isCharging)
3293
+ // Bridge.log("G2: Charging: \(isCharging)")
3294
+ // Re-send battery status with updated charging info
3295
+ if batteryLevel >= 0 {
3296
+ Bridge.sendBatteryStatus(level: batteryLevel, charging: isCharging)
3297
+ }
3298
+ }
3299
+
3300
+ // Software versions
3301
+ if let leftVer = fields[5] as? Data,
3302
+ let leftVersion = String(data: leftVer, encoding: .utf8)
3303
+ {
3304
+ // Bridge.log("G2: Left firmware: \(leftVersion)")
3305
+ DeviceStore.shared.apply("glasses", "leftFirmwareVersion", leftVersion)
3306
+ }
3307
+ if let rightVer = fields[6] as? Data,
3308
+ let rightVersion = String(data: rightVer, encoding: .utf8)
3309
+ {
3310
+ // Bridge.log("G2: Right firmware: \(rightVersion)")
3311
+ DeviceStore.shared.apply("glasses", "rightFirmwareVersion", rightVersion)
3312
+ // Use right version as the main version
3313
+ DeviceStore.shared.apply("glasses", "fwVersion", rightVersion)
3314
+ }
3315
+ }
3316
+
3317
+ private func handleMenuResponse(_ data: Data) {
3318
+ // meun_main_msg_ctx response from glasses (ack of our menu send)
3319
+ // (informational only)
3320
+ Bridge.log(
3321
+ "G2: menu response: \(data.prefix(32).map { String(format: "%02X", $0) }.joined())"
3322
+ )
3323
+ }
3324
+
3325
+ private func handleDashboardResponse(_ payload: Data) {
3326
+ Bridge.log(
3327
+ "G2: dashboard response: \(payload.prefix(32).map { String(format: "%02X", $0) }.joined())"
3328
+ )
3329
+ var reader = ProtobufReader(payload)
3330
+ let fields = reader.parseFields()
3331
+ let cmd = fields[1] as? Int32 ?? -1
3332
+ let magicRandom = fields[2] as? Int32 ?? 0
3333
+
3334
+ // Parse field 6 (DashboardSendToApp) if present
3335
+ var packageId: Int32 = 0
3336
+ if let f6 = fields[6] as? Data {
3337
+ var subReader = ProtobufReader(f6)
3338
+ let sub = subReader.parseFields()
3339
+ packageId = sub[1] as? Int32 ?? 0
3340
+ }
3341
+
3342
+ // cmd=3 is APP_Respond — glasses sending us info, we should respond with cmd=4 (APP_RECEIVE)
3343
+ // AppRespondToDashboard: field1=packageId, field2=flag (0=success)
3344
+ if cmd == 3 {
3345
+ var appRespW = ProtobufWriter()
3346
+ appRespW.writeInt32Field(1, packageId) // packageId
3347
+ appRespW.writeInt32Field(2, 0) // flag = APP_RECEIVED_SUCCESS
3348
+
3349
+ var pkgW = ProtobufWriter()
3350
+ pkgW.writeInt32Field(1, 4) // commandId = APP_RECEIVE
3351
+ pkgW.writeInt32Field(2, magicRandom)
3352
+ pkgW.writeMessageField(5, appRespW.data) // field5 = appRespond
3353
+ sendDashboardCommand(pkgW.data)
3354
+ }
3355
+ }
3356
+
3357
+ private func handleEvenHubCtrlResponse(_ data: Data) {
3358
+ // EvenHub CTRL channel response (informational only)
3359
+ Bridge.log(
3360
+ "G2: evenHubCtrl response: \(data.prefix(8).map { String(format: "%02X", $0) }.joined())"
3361
+ )
3362
+ }
3363
+
3364
+ private func handleGestureCtrl(_ data: Data) {
3365
+ // gesture_ctrl (service 0x0D): foreground lifecycle signals from glasses
3366
+ // (informational only — log if needed for debugging)
3367
+ // log first few bytes of the response:
3368
+ // Bridge.log(
3369
+ // "G2: gesture_ctrl response: \(data.map { String(format: "%02X", $0) }.joined())"
3370
+ // )
3371
+ // Bridge.log("G2: gesture_ctrl response:")
3372
+
3373
+ // if we got 08011A00 that means we closed the dashboard, which means the mic is probably dead,
3374
+ // so we need to revive it:
3375
+ if data == Data([0x08, 0x01, 0x1A, 0x00]) {
3376
+ Bridge.log("G2: gesture_ctrl response: dashboard closed")
3377
+ // re-send mic on / update mic state:
3378
+ DeviceStore.shared.apply("glasses", "micEnabled", false)
3379
+ DeviceManager.shared.updateMicState() // should set the mic back on if it should be on
3380
+ // // let isHeadUp = DeviceStore.shared.get("glasses", "headUp") as? Bool ?? false
3381
+
3382
+ // // toggle head up:
3383
+ // DeviceStore.shared.apply("glasses", "headUp", false)
3384
+ // // send the current state to the glasses
3385
+ // DeviceManager.shared.sendCurrentState()
3386
+ // reset the text container (different from clearDisplay())
3387
+ sendTextWall(" ")
3388
+ }
3389
+
3390
+ // if we got 08011097012200 that means we selected a menu item:
3391
+ // if data == Data([0x08, 0x01, 0x10, 0x97, 0x01, 0x22, 0x00]) {
3392
+ // Bridge.log("G2: menu item selected, clearing display")
3393
+ // clearDisplay()
3394
+ // }
3395
+ }
3396
+
3397
+ private func parseDeviceSendToApp(_ data: Data) {
3398
+ // DeviceSendInfoToAPP: field 1 = currentRecalibrationStatus, field 2 = silentModeSwitch
3399
+ // Informational — just log for now
3400
+ var reader = ProtobufReader(data)
3401
+ let fields = reader.parseFields()
3402
+ if let silentMode = fields[2] as? Int32 {
3403
+ Bridge.log("G2: Silent mode: \(silentMode != 0)")
3404
+ }
3405
+ }
3406
+
3407
+ private var lastAudioFrame: Data?
3408
+
3409
+ private func handleAudioData(_ data: Data) {
3410
+ // G2 audio arrives on AUDIO_NOTIFY characteristic
3411
+ // Format: ~200+ byte chunks, use first 200 bytes, split into 40-byte LC3 frames
3412
+ // Each frame: LC3, 16kHz, mono, 10ms, 40 bytes
3413
+
3414
+ let usableLength = min(data.count, 200)
3415
+ guard usableLength >= 40 else { return }
3416
+
3417
+ let audioData = Data(data.prefix(usableLength))
3418
+ if lastAudioFrame == audioData {
3419
+ // Bridge.log("G2: audio dup")
3420
+ return
3421
+ }
3422
+ lastAudioFrame = audioData
3423
+
3424
+ // Forward LC3 data to DeviceManager for decoding
3425
+ // G2 uses 40-byte frames (vs G1's 20-byte frames)
3426
+ DeviceManager.shared.handleGlassesMicData(audioData, 40)
3427
+ }
3428
+ }
3429
+
3430
+ // MARK: - CBCentralManagerDelegate
3431
+
3432
+ func extractSN(from data: Data) -> String? {
3433
+ // Android uses startSubIndex=7, byteLength=21 on the FULL scan record
3434
+ // iOS manufacturerData is just the manufacturer-specific payload,
3435
+ // so the offset may differ. You'll need to log the raw bytes and find
3436
+ // where the SN string starts.
3437
+
3438
+ // Skip "ER" prefix (2 bytes), read 14 bytes of SN
3439
+ let snData = data[2 ..< 16]
3440
+ return String(data: snData, encoding: .ascii)?
3441
+ .replacingOccurrences(
3442
+ of: "[\\x00-\\x1F\\x7F]", with: "", options: .regularExpression
3443
+ )
3444
+ }
3445
+
3446
+ /// Extract the BLE MAC from G2 manufacturer data.
3447
+ /// Layout: "ER"(2) + SN(14) + MAC(6, little-endian) + flag(1)
3448
+ /// Returns "AA:BB:CC:DD:EE:FF" (big-endian, colon-separated).
3449
+ func extractMac(from data: Data) -> String? {
3450
+ guard data.count >= 22 else { return nil }
3451
+ let macLE = data[16 ..< 22]
3452
+ return macLE.reversed().map { String(format: "%02X", $0) }.joined(separator: ":")
3453
+ }
3454
+
3455
+ extension G2: CBCentralManagerDelegate {
3456
+ nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
3457
+ let state = central.state
3458
+ DispatchQueue.main.async { [weak self] in
3459
+ guard let self = self else { return }
3460
+ Bridge.log("G2: Bluetooth state: \(state.rawValue)")
3461
+ if state == .poweredOn {
3462
+ _ = self.startScan()
3463
+ }
3464
+ }
3465
+ }
3466
+
3467
+ nonisolated func centralManager(
3468
+ _ central: CBCentralManager,
3469
+ didDiscover peripheral: CBPeripheral,
3470
+ advertisementData: [String: Any],
3471
+ rssi _: NSNumber
3472
+ ) {
3473
+ guard
3474
+ let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey]
3475
+ as? String
3476
+ else { return }
3477
+
3478
+ // G2 glasses have "Even" prefix and "G2" in name, with _L_ or _R_ for side
3479
+ guard name.contains("G2") else { return }
3480
+ guard let mfgData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data,
3481
+ mfgData.count >= 16
3482
+ else { return }
3483
+
3484
+ DispatchQueue.main.async { [weak self] in
3485
+ guard let self = self else { return }
3486
+
3487
+ guard let serialNumber = extractSN(from: mfgData) else {
3488
+ Bridge.log("G2: Could not extract SN from manufacturer data")
3489
+ return
3490
+ }
3491
+ // sn = "S200LACA040040"
3492
+ let mfgHex = mfgData.map { String(format: "%02X", $0) }.joined(separator: " ")
3493
+ Bridge.log("G2: Discovered: \(name) (SN: \(serialNumber)) mfgData[\(mfgData.count)]: \(mfgHex)")
3494
+ self.deviceNameToSerialNumber[name] = serialNumber
3495
+
3496
+ // Save MAC per side; ring's advStart needs the left lens MAC.
3497
+ if let mac = extractMac(from: mfgData) {
3498
+ if name.contains("_L_") {
3499
+ DeviceStore.shared.apply("glasses", "leftMacAddress", mac)
3500
+ DeviceStore.shared.apply("glasses", "btMacAddress", mac)
3501
+ } else if name.contains("_R_") {
3502
+ DeviceStore.shared.apply("glasses", "rightMacAddress", mac)
3503
+ }
3504
+ }
3505
+ // DeviceStore.shared.apply("glasses", "signalStrength", RSSI.intValue)
3506
+
3507
+ // Always emit discovered device to frontend
3508
+ self.emitDiscoveredDevice(serialNumber)
3509
+
3510
+ // If scan-only mode (no search ID set), don't auto-connect
3511
+ guard self.DEVICE_SEARCH_ID != "NOT_SET" else { return }
3512
+
3513
+ // Bridge.log("G2: SN: \(serialNumber), DEVICE_SEARCH_ID: \(self.DEVICE_SEARCH_ID) name: \(name)")
3514
+
3515
+ // Only connect to devices matching our search ID
3516
+ guard serialNumber.contains(self.DEVICE_SEARCH_ID) else { return }
3517
+
3518
+ if name.contains("_L_") {
3519
+ if self.leftPeripheral == nil {
3520
+ self.leftPeripheral = peripheral
3521
+ peripheral.delegate = self
3522
+ central.connect(peripheral, options: nil)
3523
+ // Bridge.log("G2: Connecting to LEFT: \(name)")
3524
+ }
3525
+ } else if name.contains("_R_") {
3526
+ if self.rightPeripheral == nil {
3527
+ self.rightPeripheral = peripheral
3528
+ peripheral.delegate = self
3529
+ central.connect(peripheral, options: nil)
3530
+ // Bridge.log("G2: Connecting to RIGHT: \(name)")
3531
+ }
3532
+ }
3533
+
3534
+ // Stop scanning once we have both
3535
+ if self.leftPeripheral != nil && self.rightPeripheral != nil {
3536
+ self.stopScan()
3537
+ self.cancelPairingTimeout()
3538
+ }
3539
+ }
3540
+ }
3541
+
3542
+ nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
3543
+ DispatchQueue.main.async { [weak self] in
3544
+ guard let self = self else { return }
3545
+ Bridge.log("G2: Connected to \(peripheral.name ?? "unknown")")
3546
+
3547
+ // Store UUID for reconnection, keyed by serial number.
3548
+ let sn = peripheral.name.flatMap { self.deviceNameToSerialNumber[$0] }
3549
+ if let sn = sn {
3550
+ if peripheral === self.leftPeripheral {
3551
+ self.setLeftGlassUUID(peripheral.identifier, forSN: sn)
3552
+ } else if peripheral === self.rightPeripheral {
3553
+ self.setRightGlassUUID(peripheral.identifier, forSN: sn)
3554
+ }
3555
+ } else {
3556
+ Bridge.log("G2: didConnect — no SN for \(peripheral.name ?? "unknown"), skipping UUID save")
3557
+ }
3558
+
3559
+ // Discover services - scan for all since we need to find the EvenHub characteristics
3560
+ peripheral.discoverServices(nil)
3561
+ }
3562
+ }
3563
+
3564
+ nonisolated func centralManager(
3565
+ _: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?
3566
+ ) {
3567
+ DispatchQueue.main.async { [weak self] in
3568
+ guard let self = self else { return }
3569
+ let side = peripheral === self.leftPeripheral ? "LEFT" : "RIGHT"
3570
+ Bridge.log("G2: Disconnected \(side): \(error?.localizedDescription ?? "clean")")
3571
+
3572
+ // Only reconnect if not intentionally disconnecting
3573
+ if self.isDisconnecting { return }
3574
+
3575
+ // Clear both sides to force re-discovery (like G1)
3576
+ self.leftPeripheral = nil
3577
+ self.rightPeripheral = nil
3578
+ self.leftInitialized = false
3579
+ self.rightInitialized = false
3580
+ self.leftWriteChar = nil
3581
+ self.rightWriteChar = nil
3582
+ self.leftNotifyChar = nil
3583
+ self.rightNotifyChar = nil
3584
+ self.leftAudioChar = nil
3585
+ self.rightAudioChar = nil
3586
+ self.authStarted = false
3587
+
3588
+ self.startupPageCreated = false
3589
+ self.pageCreated = false
3590
+ self.pageHasTextContainer = false
3591
+ DeviceStore.shared.apply("glasses", "connected", false)
3592
+ DeviceStore.shared.apply("glasses", "fullyBooted", false)
3593
+
3594
+ // Start persistent reconnection loop (every 30s, unlimited attempts)
3595
+ self.startReconnectionTimer()
3596
+ }
3597
+ }
3598
+
3599
+ private func startReconnectionTimer() {
3600
+ Task {
3601
+ await reconnectionManager.start { [weak self] in
3602
+ guard let self else { return false }
3603
+
3604
+ // Check if already connected
3605
+ if await MainActor.run(body: { DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false }) {
3606
+ Bridge.log("G2: Already connected, stopping reconnection")
3607
+ return true
3608
+ }
3609
+
3610
+ Bridge.log("G2: Attempting reconnection...")
3611
+
3612
+ await MainActor.run {
3613
+ self.startScan()
3614
+ }
3615
+
3616
+ // Return false to keep trying
3617
+ return false
3618
+ }
3619
+ }
3620
+ }
3621
+ }
3622
+
3623
+ // MARK: - CBPeripheralDelegate
3624
+
3625
+ extension G2: CBPeripheralDelegate {
3626
+ nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices _: Error?) {
3627
+ guard let services = peripheral.services else { return }
3628
+ for service in services {
3629
+ peripheral.discoverCharacteristics(nil, for: service)
3630
+ }
3631
+ }
3632
+
3633
+ nonisolated func peripheral(
3634
+ _ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService,
3635
+ error _: Error?
3636
+ ) {
3637
+ guard let characteristics = service.characteristics else { return }
3638
+
3639
+ DispatchQueue.main.async { [weak self] in
3640
+ guard let self = self else { return }
3641
+ let side = peripheral === self.leftPeripheral ? "LEFT" : "RIGHT"
3642
+
3643
+ for char in characteristics {
3644
+ let uuid = char.uuid
3645
+ let props = char.properties
3646
+
3647
+ // Log all characteristics with their properties for debugging
3648
+ var propStr: [String] = []
3649
+ if props.contains(.read) { propStr.append("read") }
3650
+ if props.contains(.write) { propStr.append("write") }
3651
+ if props.contains(.writeWithoutResponse) { propStr.append("writeNoResp") }
3652
+ if props.contains(.notify) { propStr.append("notify") }
3653
+ if props.contains(.indicate) { propStr.append("indicate") }
3654
+ Bridge.log("G2: \(side) char \(uuid) props=[\(propStr.joined(separator: ","))]")
3655
+
3656
+ if uuid == G2BLE.CHAR_WRITE {
3657
+ Bridge.log("G2: Found WRITE char on \(side)")
3658
+ if peripheral === self.leftPeripheral {
3659
+ self.leftWriteChar = char
3660
+ } else {
3661
+ self.rightWriteChar = char
3662
+ }
3663
+ } else if uuid == G2BLE.CHAR_NOTIFY {
3664
+ Bridge.log("G2: Found NOTIFY char on \(side)")
3665
+ if peripheral === self.leftPeripheral {
3666
+ self.leftNotifyChar = char
3667
+ } else {
3668
+ self.rightNotifyChar = char
3669
+ }
3670
+ peripheral.setNotifyValue(true, for: char)
3671
+ } else if uuid == G2BLE.AUDIO_NOTIFY {
3672
+ Bridge.log("G2: Found AUDIO char on \(side)")
3673
+ if peripheral === self.leftPeripheral {
3674
+ self.leftAudioChar = char
3675
+ } else {
3676
+ self.rightAudioChar = char
3677
+ }
3678
+ peripheral.setNotifyValue(true, for: char)
3679
+ }
3680
+ }
3681
+
3682
+ // Check if this side is fully initialized
3683
+ if peripheral === self.leftPeripheral && self.leftWriteChar != nil {
3684
+ self.leftInitialized = true
3685
+ Bridge.log("G2: LEFT initialized")
3686
+ } else if peripheral === self.rightPeripheral && self.rightWriteChar != nil
3687
+ && self.rightNotifyChar != nil
3688
+ {
3689
+ self.rightInitialized = true
3690
+ Bridge.log("G2: RIGHT initialized")
3691
+ }
3692
+
3693
+ // Both sides ready -> run auth (once)
3694
+ if self.leftInitialized && self.rightInitialized && !self.authStarted {
3695
+ self.authStarted = true
3696
+ Bridge.log("G2: Both sides initialized, starting auth sequence")
3697
+ self.runAuthSequence()
3698
+ }
3699
+ }
3700
+ }
3701
+
3702
+ nonisolated func peripheral(
3703
+ _ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic,
3704
+ error: Error?
3705
+ ) {
3706
+ guard let data = characteristic.value, error == nil else { return }
3707
+
3708
+ DispatchQueue.main.async { [weak self] in
3709
+ guard let self = self else { return }
3710
+
3711
+ if characteristic.uuid == G2BLE.AUDIO_NOTIFY {
3712
+ // Audio data - forward to mic system
3713
+ self.handleAudioData(data)
3714
+ } else if characteristic.uuid == G2BLE.CHAR_NOTIFY {
3715
+ // Protocol data
3716
+ self.handleNotifyData(data, from: peripheral)
3717
+ }
3718
+ }
3719
+ }
3720
+
3721
+ nonisolated func peripheral(
3722
+ _: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?
3723
+ ) {
3724
+ if let error = error {
3725
+ DispatchQueue.main.async {
3726
+ Bridge.log("G2: Write error: \(error.localizedDescription)")
3727
+ }
3728
+ }
3729
+ }
3730
+ }