@glassly/bluetooth-sdk 0.1.0-dev.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (548) hide show
  1. package/README.md +563 -0
  2. package/android/build.gradle +450 -0
  3. package/android/gradle.properties.example +9 -0
  4. package/android/lc3Lib/CMakeLists.txt +6 -0
  5. package/android/lc3Lib/build.gradle +212 -0
  6. package/android/lc3Lib/proguard-rules.pro +33 -0
  7. package/android/lc3Lib/src/main/AndroidManifest.xml +2 -0
  8. package/android/lc3Lib/src/main/cpp/CMakeLists.txt +8 -0
  9. package/android/lc3Lib/src/main/cpp/google_opus_stuff/CMakeLists.txt +30 -0
  10. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/CMakeLists.txt +7 -0
  11. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/ogg_opus_encoder.cc +96 -0
  12. package/android/lc3Lib/src/main/cpp/google_opus_stuff/jni/ogg_opus_encoder.h +53 -0
  13. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/Makefile.am +6 -0
  14. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/config_types.h +26 -0
  15. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/config_types.h.in +26 -0
  16. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/ogg.h +209 -0
  17. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libogg/os_types.h +158 -0
  18. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus.h +981 -0
  19. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_custom.h +342 -0
  20. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_defines.h +799 -0
  21. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_multistream.h +660 -0
  22. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_projection.h +568 -0
  23. package/android/lc3Lib/src/main/cpp/google_opus_stuff/libopus/opus_types.h +166 -0
  24. package/android/lc3Lib/src/main/cpp/google_opus_stuff/ogg_opus_encoder.cc +268 -0
  25. package/android/lc3Lib/src/main/cpp/google_opus_stuff/ogg_opus_encoder.h +115 -0
  26. package/android/lc3Lib/src/main/cpp/google_opus_stuff/opus_tools/opus_header.h +59 -0
  27. package/android/lc3Lib/src/main/cpp/liblc3/CMakeLists.txt +38 -0
  28. package/android/lc3Lib/src/main/cpp/liblc3/include/lc3.h +309 -0
  29. package/android/lc3Lib/src/main/cpp/liblc3/include/lc3_private.h +162 -0
  30. package/android/lc3Lib/src/main/cpp/liblc3/include/rnnoise.h +114 -0
  31. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/CMakeLists.txt +19 -0
  32. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/attdet.c +92 -0
  33. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/attdet.h +44 -0
  34. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bits.c +375 -0
  35. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bits.h +315 -0
  36. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bwdet.c +129 -0
  37. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/bwdet.h +69 -0
  38. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/common.h +148 -0
  39. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/energy.c +70 -0
  40. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/energy.h +43 -0
  41. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/fastmath.h +158 -0
  42. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/lc3.c +702 -0
  43. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf.c +893 -0
  44. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf.h +111 -0
  45. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf_arm.h +506 -0
  46. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/ltpf_neon.h +281 -0
  47. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/makefile.mk +35 -0
  48. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct.c +452 -0
  49. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct.h +57 -0
  50. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/mdct_neon.h +296 -0
  51. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/meson.build +46 -0
  52. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/plc.c +61 -0
  53. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/plc.h +57 -0
  54. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/sns.c +880 -0
  55. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/sns.h +103 -0
  56. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/spec.c +904 -0
  57. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/spec.h +119 -0
  58. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tables.c +3457 -0
  59. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tables.h +94 -0
  60. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tns.c +457 -0
  61. package/android/lc3Lib/src/main/cpp/liblc3/liblc3/tns.h +99 -0
  62. package/android/lc3Lib/src/main/cpp/liblc3/liblc3.cpp +159 -0
  63. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/_kiss_fft_guts.h +182 -0
  64. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/arch.h +261 -0
  65. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/celt_lpc.c +279 -0
  66. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/celt_lpc.h +59 -0
  67. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/common.h +48 -0
  68. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/compile.sh +3 -0
  69. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/denoise.c +646 -0
  70. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/kiss_fft.c +601 -0
  71. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/kiss_fft.h +203 -0
  72. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/opus_types.h +159 -0
  73. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/pitch.c +526 -0
  74. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/pitch.h +149 -0
  75. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn.c +178 -0
  76. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn.h +69 -0
  77. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_data.c +11051 -0
  78. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_data.h +34 -0
  79. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_reader.c +168 -0
  80. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/rnn_train.py +66 -0
  81. package/android/lc3Lib/src/main/cpp/liblc3/rnnoise/tansig_table.h +45 -0
  82. package/android/lc3Lib/src/main/java/com/glassly/lc3Lib/Lc3Cpp.java +38 -0
  83. package/android/lc3Lib/third_party/CMakeLists.txt +23 -0
  84. package/android/lc3Lib/third_party/CMakeLists_libogg.txt +18 -0
  85. package/android/lc3Lib/third_party/CMakeLists_libopus.txt +18 -0
  86. package/android/lc3Lib/third_party/CMakeLists_opus-tools.txt +23 -0
  87. package/android/lc3Lib/third_party/opus_tools/src/AUTHORS +5 -0
  88. package/android/lc3Lib/third_party/opus_tools/src/CMakeLists.txt +4 -0
  89. package/android/lc3Lib/third_party/opus_tools/src/COPYING +371 -0
  90. package/android/lc3Lib/third_party/opus_tools/src/ChangeLog +3 -0
  91. package/android/lc3Lib/third_party/opus_tools/src/Makefile.am +42 -0
  92. package/android/lc3Lib/third_party/opus_tools/src/Makefile.unix +23 -0
  93. package/android/lc3Lib/third_party/opus_tools/src/NEWS +0 -0
  94. package/android/lc3Lib/third_party/opus_tools/src/README +0 -0
  95. package/android/lc3Lib/third_party/opus_tools/src/autogen.sh +115 -0
  96. package/android/lc3Lib/third_party/opus_tools/src/configure.ac +293 -0
  97. package/android/lc3Lib/third_party/opus_tools/src/src/arch.h +239 -0
  98. package/android/lc3Lib/third_party/opus_tools/src/src/audio-in.c +1046 -0
  99. package/android/lc3Lib/third_party/opus_tools/src/src/diag_range.c +245 -0
  100. package/android/lc3Lib/third_party/opus_tools/src/src/diag_range.h +28 -0
  101. package/android/lc3Lib/third_party/opus_tools/src/src/info_opus.c +320 -0
  102. package/android/lc3Lib/third_party/opus_tools/src/src/info_opus.h +51 -0
  103. package/android/lc3Lib/third_party/opus_tools/src/src/lpc.c +157 -0
  104. package/android/lc3Lib/third_party/opus_tools/src/src/lpc.h +27 -0
  105. package/android/lc3Lib/third_party/opus_tools/src/src/opus_header.c +286 -0
  106. package/android/lc3Lib/third_party/opus_tools/src/src/opus_header.h +59 -0
  107. package/android/lc3Lib/third_party/opus_tools/src/src/opusdec.c +884 -0
  108. package/android/lc3Lib/third_party/opus_tools/src/src/opusenc.c +1021 -0
  109. package/android/lc3Lib/third_party/opus_tools/src/src/opusenc.h +101 -0
  110. package/android/lc3Lib/third_party/opus_tools/src/src/opusinfo.c +639 -0
  111. package/android/lc3Lib/third_party/opus_tools/src/src/opusinfo.h +51 -0
  112. package/android/lc3Lib/third_party/opus_tools/src/src/os_support.h +167 -0
  113. package/android/lc3Lib/third_party/opus_tools/src/src/resample.c +1137 -0
  114. package/android/lc3Lib/third_party/opus_tools/src/src/speex_resampler.h +344 -0
  115. package/android/lc3Lib/third_party/opus_tools/src/src/stack_alloc.h +115 -0
  116. package/android/lc3Lib/third_party/opus_tools/src/src/wav_io.c +125 -0
  117. package/android/lc3Lib/third_party/opus_tools/src/src/wav_io.h +62 -0
  118. package/android/lc3Lib/third_party/opus_tools/src/src/wave_out.c +223 -0
  119. package/android/lc3Lib/third_party/opus_tools/src/src/wave_out.h +60 -0
  120. package/android/settings.gradle +4 -0
  121. package/android/silero/LICENSE.md +17 -0
  122. package/android/silero/build.gradle +50 -0
  123. package/android/silero/proguard-rules.pro +24 -0
  124. package/android/silero/src/androidTest/assets/hello.wav +0 -0
  125. package/android/silero/src/androidTest/java/com/konovalov/vad/silero/VadSileroTest.kt +79 -0
  126. package/android/silero/src/main/assets/LICENSE +21 -0
  127. package/android/silero/src/main/assets/silero_vad.onnx +0 -0
  128. package/android/silero/src/main/java/com/konovalov/vad/silero/Vad.kt +160 -0
  129. package/android/silero/src/main/java/com/konovalov/vad/silero/VadSilero.kt +449 -0
  130. package/android/silero/src/main/java/com/konovalov/vad/silero/config/FrameSize.kt +15 -0
  131. package/android/silero/src/main/java/com/konovalov/vad/silero/config/Mode.kt +14 -0
  132. package/android/silero/src/main/java/com/konovalov/vad/silero/config/SampleRate.kt +12 -0
  133. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/AudioUtils.kt +47 -0
  134. package/android/silero/src/main/java/com/konovalov/vad/silero/utils/TensorMap.kt +41 -0
  135. package/android/silero/src/test/java/com/konovalov/vad/silero/utils/AudioUtilsTest.kt +44 -0
  136. package/android/src/main/AndroidManifest.xml +42 -0
  137. package/android/src/main/assets/G2Firmware/README.md +11 -0
  138. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +2325 -0
  139. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkAnalytics.kt +216 -0
  140. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkDebug.kt +14 -0
  141. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkDefaults.kt +7 -0
  142. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +1088 -0
  143. package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +953 -0
  144. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +2528 -0
  145. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +360 -0
  146. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +8 -0
  147. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +11 -0
  148. package/android/src/main/java/com/glassly/bluetoothsdk/LocalNetworkModule.kt +414 -0
  149. package/android/src/main/java/com/glassly/bluetoothsdk/ObservableStore.kt +88 -0
  150. package/android/src/main/java/com/glassly/bluetoothsdk/OtaManifest.kt +191 -0
  151. package/android/src/main/java/com/glassly/bluetoothsdk/ReleaseChangelog.kt +61 -0
  152. package/android/src/main/java/com/glassly/bluetoothsdk/ScopedNetworkReadiness.kt +17 -0
  153. package/android/src/main/java/com/glassly/bluetoothsdk/audio/AudioModels.kt +144 -0
  154. package/android/src/main/java/com/glassly/bluetoothsdk/camera/CameraModels.kt +486 -0
  155. package/android/src/main/java/com/glassly/bluetoothsdk/connection/ConnectionModels.kt +78 -0
  156. package/android/src/main/java/com/glassly/bluetoothsdk/controllers/ControllerManager.kt +158 -0
  157. package/android/src/main/java/com/glassly/bluetoothsdk/controllers/R1.kt +937 -0
  158. package/android/src/main/java/com/glassly/bluetoothsdk/debug/BleTraceLogger.kt +165 -0
  159. package/android/src/main/java/com/glassly/bluetoothsdk/events/BluetoothEvents.kt +178 -0
  160. package/android/src/main/java/com/glassly/bluetoothsdk/internal/MapParsing.kt +140 -0
  161. package/android/src/main/java/com/glassly/bluetoothsdk/net/LocalIpv4.kt +57 -0
  162. package/android/src/main/java/com/glassly/bluetoothsdk/otaserver/LocalOtaServer.kt +304 -0
  163. package/android/src/main/java/com/glassly/bluetoothsdk/otaserver/OtaServerModule.kt +120 -0
  164. package/android/src/main/java/com/glassly/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistry.java +108 -0
  165. package/android/src/main/java/com/glassly/bluetoothsdk/photoreceiver/LocalPhotoUploadServer.kt +622 -0
  166. package/android/src/main/java/com/glassly/bluetoothsdk/photoreceiver/PhotoReceiverModule.kt +156 -0
  167. package/android/src/main/java/com/glassly/bluetoothsdk/requests/DisplayRequests.kt +56 -0
  168. package/android/src/main/java/com/glassly/bluetoothsdk/services/Foreground.kt +272 -0
  169. package/android/src/main/java/com/glassly/bluetoothsdk/services/PhoneMic.kt +1195 -0
  170. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/Ar99.java +2653 -0
  171. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/BesOtaHeartbeatGuard.kt +49 -0
  172. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/BesOtaProgressMapper.kt +27 -0
  173. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G1.kt +3878 -0
  174. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G1TextSanitizer.kt +38 -0
  175. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +6428 -0
  176. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +49 -0
  177. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +1241 -0
  178. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +270 -0
  179. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +291 -0
  180. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +411 -0
  181. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/GlasslyBle.java +43189 -0
  182. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/Mach1.kt +1280 -0
  183. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +10817 -0
  184. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLiveL2capChannel.kt +274 -0
  185. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLivePairingAdvertisement.kt +54 -0
  186. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +2011 -0
  187. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/Nimo.kt +2532 -0
  188. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +470 -0
  189. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/Simulated.kt +228 -0
  190. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/Ar99OtaManager.java +566 -0
  191. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaByteUtils.java +18 -0
  192. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaCommandConstants.java +35 -0
  193. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaCrc32Util.java +13 -0
  194. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaGattTransport.java +11 -0
  195. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/ar99/ota/OtaProtocol.java +92 -0
  196. package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +525 -0
  197. package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +669 -0
  198. package/android/src/main/java/com/glassly/bluetoothsdk/status/RuntimeState.kt +201 -0
  199. package/android/src/main/java/com/glassly/bluetoothsdk/status/WifiHotspotStatus.kt +189 -0
  200. package/android/src/main/java/com/glassly/bluetoothsdk/streaming/StreamModels.kt +518 -0
  201. package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +391 -0
  202. package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +518 -0
  203. package/android/src/main/java/com/glassly/bluetoothsdk/stt/VadGateSpeechPolicy.kt +229 -0
  204. package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +248 -0
  205. package/android/src/main/java/com/glassly/bluetoothsdk/types/DeviceModels.kt +96 -0
  206. package/android/src/main/java/com/glassly/bluetoothsdk/utils/AES.java +106 -0
  207. package/android/src/main/java/com/glassly/bluetoothsdk/utils/AudioSessionMonitor.kt +189 -0
  208. package/android/src/main/java/com/glassly/bluetoothsdk/utils/AvifExifStripper.java +573 -0
  209. package/android/src/main/java/com/glassly/bluetoothsdk/utils/BitmapJavaUtils.java +169 -0
  210. package/android/src/main/java/com/glassly/bluetoothsdk/utils/BleJsonCompact.java +496 -0
  211. package/android/src/main/java/com/glassly/bluetoothsdk/utils/BlePhotoUploadService.java +677 -0
  212. package/android/src/main/java/com/glassly/bluetoothsdk/utils/BleWireProtocol.java +108 -0
  213. package/android/src/main/java/com/glassly/bluetoothsdk/utils/Constants.kt +46 -0
  214. package/android/src/main/java/com/glassly/bluetoothsdk/utils/G1Text.kt +436 -0
  215. package/android/src/main/java/com/glassly/bluetoothsdk/utils/HeifExifTagReader.java +199 -0
  216. package/android/src/main/java/com/glassly/bluetoothsdk/utils/IncidentLogBleRelayNaming.java +29 -0
  217. package/android/src/main/java/com/glassly/bluetoothsdk/utils/IncidentLogBleUploadService.java +77 -0
  218. package/android/src/main/java/com/glassly/bluetoothsdk/utils/K900LengthCodec.java +115 -0
  219. package/android/src/main/java/com/glassly/bluetoothsdk/utils/K900ProtocolUtils.java +827 -0
  220. package/android/src/main/java/com/glassly/bluetoothsdk/utils/MessageChunkReassembler.java +228 -0
  221. package/android/src/main/java/com/glassly/bluetoothsdk/utils/MessageChunker.java +325 -0
  222. package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +698 -0
  223. package/android/src/main/java/com/glassly/bluetoothsdk/utils/PhoneAudioMonitor.kt +348 -0
  224. package/android/src/main/java/com/glassly/bluetoothsdk/utils/SmartGlassesConnectionState.java +9 -0
  225. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/Ar99OpusPcmDecoder.java +398 -0
  226. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/ByteUtilAudioPlayer.java +655 -0
  227. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/Lc3Player.java +441 -0
  228. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/PCMAudioPlayer.java +431 -0
  229. package/android/src/main/java/com/glassly/bluetoothsdk/utils/audio/PcmStreamPlayer.kt +328 -0
  230. package/android/src/main/res/values/strings.xml +4 -0
  231. package/android/src/test/java/com/glassly/bluetoothsdk/BluetoothSdkExceptionTest.kt +18 -0
  232. package/android/src/test/java/com/glassly/bluetoothsdk/OtaManifestDowngradeTest.kt +86 -0
  233. package/android/src/test/java/com/glassly/bluetoothsdk/OtaStartResponsePolicyTest.kt +30 -0
  234. package/android/src/test/java/com/glassly/bluetoothsdk/ReleaseChangelogTest.kt +13 -0
  235. package/android/src/test/java/com/glassly/bluetoothsdk/ScopedNetworkReadinessTest.kt +39 -0
  236. package/android/src/test/java/com/glassly/bluetoothsdk/StreamStatusEventTest.kt +29 -0
  237. package/android/src/test/java/com/glassly/bluetoothsdk/camera/PhotoRequestTest.kt +141 -0
  238. package/android/src/test/java/com/glassly/bluetoothsdk/otaserver/LocalOtaServerTest.kt +82 -0
  239. package/android/src/test/java/com/glassly/bluetoothsdk/photoreceiver/LocalPhotoReceiverRegistryTest.java +75 -0
  240. package/android/src/test/java/com/glassly/bluetoothsdk/services/ForegroundServiceTypeTest.kt +69 -0
  241. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/Ar99AdvertisementMatcherTest.java +22 -0
  242. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/BesOtaHeartbeatGuardTest.kt +73 -0
  243. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/BesOtaProgressMapperTest.kt +67 -0
  244. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/EvenRleTest.kt +63 -0
  245. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G1TextSanitizerTest.kt +31 -0
  246. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +89 -0
  247. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +344 -0
  248. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +200 -0
  249. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocolTest.kt +131 -0
  250. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/MentraLiveGattCharacteristicsTest.kt +39 -0
  251. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/MentraLivePairingAdvertisementParserTest.kt +133 -0
  252. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NimoProtocolTest.kt +333 -0
  253. package/android/src/test/java/com/glassly/bluetoothsdk/utils/AvifExifStripperTest.java +46 -0
  254. package/android/src/test/java/com/glassly/bluetoothsdk/utils/BinaryMessageChunkerTest.java +84 -0
  255. package/android/src/test/java/com/glassly/bluetoothsdk/utils/BleJsonCompactTest.java +319 -0
  256. package/android/src/test/java/com/glassly/bluetoothsdk/utils/BlePhotoUploadServiceTest.java +107 -0
  257. package/android/src/test/java/com/glassly/bluetoothsdk/utils/K900ProtocolUtilsEndiannessTest.java +172 -0
  258. package/android/src/test/java/com/glassly/bluetoothsdk/utils/K900ProtocolUtilsTest.java +24 -0
  259. package/android/src/test/java/com/glassly/bluetoothsdk/utils/PhoneAudioMonitorTest.kt +45 -0
  260. package/android/src/test/resources/avif_with_exif.avif +0 -0
  261. package/app.plugin.js +3 -0
  262. package/build/BluetoothSdk.types.d.ts +1354 -0
  263. package/build/BluetoothSdk.types.d.ts.map +1 -0
  264. package/build/BluetoothSdk.types.js +41 -0
  265. package/build/BluetoothSdk.types.js.map +1 -0
  266. package/build/_internal.d.ts +17 -0
  267. package/build/_internal.d.ts.map +1 -0
  268. package/build/_internal.js +16 -0
  269. package/build/_internal.js.map +1 -0
  270. package/build/_private/BluetoothSdkModule.d.ts +172 -0
  271. package/build/_private/BluetoothSdkModule.d.ts.map +1 -0
  272. package/build/_private/BluetoothSdkModule.js +300 -0
  273. package/build/_private/BluetoothSdkModule.js.map +1 -0
  274. package/build/_private/LocalNetworkModule.d.ts +38 -0
  275. package/build/_private/LocalNetworkModule.d.ts.map +1 -0
  276. package/build/_private/LocalNetworkModule.js +3 -0
  277. package/build/_private/LocalNetworkModule.js.map +1 -0
  278. package/build/_private/cameraRequestPayload.d.ts +4 -0
  279. package/build/_private/cameraRequestPayload.d.ts.map +1 -0
  280. package/build/_private/cameraRequestPayload.js +32 -0
  281. package/build/_private/cameraRequestPayload.js.map +1 -0
  282. package/build/_private/photoRequestPayload.d.ts +6 -0
  283. package/build/_private/photoRequestPayload.d.ts.map +1 -0
  284. package/build/_private/photoRequestPayload.js +86 -0
  285. package/build/_private/photoRequestPayload.js.map +1 -0
  286. package/build/_private/sdkOtaManifest.d.ts +11 -0
  287. package/build/_private/sdkOtaManifest.d.ts.map +1 -0
  288. package/build/_private/sdkOtaManifest.js +15 -0
  289. package/build/_private/sdkOtaManifest.js.map +1 -0
  290. package/build/changelogs.d.ts +7 -0
  291. package/build/changelogs.d.ts.map +1 -0
  292. package/build/changelogs.js +46 -0
  293. package/build/changelogs.js.map +1 -0
  294. package/build/debug.d.ts +3 -0
  295. package/build/debug.d.ts.map +1 -0
  296. package/build/debug.js +8 -0
  297. package/build/debug.js.map +1 -0
  298. package/build/generated/changelogCatalog.d.ts +9 -0
  299. package/build/generated/changelogCatalog.d.ts.map +1 -0
  300. package/build/generated/changelogCatalog.js +12 -0
  301. package/build/generated/changelogCatalog.js.map +1 -0
  302. package/build/generated/releaseMetadata.d.ts +12 -0
  303. package/build/generated/releaseMetadata.d.ts.map +1 -0
  304. package/build/generated/releaseMetadata.js +10 -0
  305. package/build/generated/releaseMetadata.js.map +1 -0
  306. package/build/index.d.ts +6 -0
  307. package/build/index.d.ts.map +1 -0
  308. package/build/index.js +171 -0
  309. package/build/index.js.map +1 -0
  310. package/build/ota-server/OtaServer.types.d.ts +14 -0
  311. package/build/ota-server/OtaServer.types.d.ts.map +1 -0
  312. package/build/ota-server/OtaServer.types.js +2 -0
  313. package/build/ota-server/OtaServer.types.js.map +1 -0
  314. package/build/ota-server/OtaServerModule.d.ts +21 -0
  315. package/build/ota-server/OtaServerModule.d.ts.map +1 -0
  316. package/build/ota-server/OtaServerModule.js +3 -0
  317. package/build/ota-server/OtaServerModule.js.map +1 -0
  318. package/build/ota-server/OtaServerModule.web.d.ts +10 -0
  319. package/build/ota-server/OtaServerModule.web.d.ts.map +1 -0
  320. package/build/ota-server/OtaServerModule.web.js +12 -0
  321. package/build/ota-server/OtaServerModule.web.js.map +1 -0
  322. package/build/ota-server/index.d.ts +3 -0
  323. package/build/ota-server/index.d.ts.map +1 -0
  324. package/build/ota-server/index.js +3 -0
  325. package/build/ota-server/index.js.map +1 -0
  326. package/build/ota-transport/index.d.ts +67 -0
  327. package/build/ota-transport/index.d.ts.map +1 -0
  328. package/build/ota-transport/index.js +58 -0
  329. package/build/ota-transport/index.js.map +1 -0
  330. package/build/photo-receiver/PhotoReceiver.types.d.ts +18 -0
  331. package/build/photo-receiver/PhotoReceiver.types.d.ts.map +1 -0
  332. package/build/photo-receiver/PhotoReceiver.types.js +2 -0
  333. package/build/photo-receiver/PhotoReceiver.types.js.map +1 -0
  334. package/build/photo-receiver/PhotoReceiverModule.d.ts +10 -0
  335. package/build/photo-receiver/PhotoReceiverModule.d.ts.map +1 -0
  336. package/build/photo-receiver/PhotoReceiverModule.js +3 -0
  337. package/build/photo-receiver/PhotoReceiverModule.js.map +1 -0
  338. package/build/photo-receiver/PhotoReceiverModule.web.d.ts +10 -0
  339. package/build/photo-receiver/PhotoReceiverModule.web.d.ts.map +1 -0
  340. package/build/photo-receiver/PhotoReceiverModule.web.js +12 -0
  341. package/build/photo-receiver/PhotoReceiverModule.web.js.map +1 -0
  342. package/build/photo-receiver/index.d.ts +3 -0
  343. package/build/photo-receiver/index.d.ts.map +1 -0
  344. package/build/photo-receiver/index.js +3 -0
  345. package/build/photo-receiver/index.js.map +1 -0
  346. package/build/react/index.d.ts +5 -0
  347. package/build/react/index.d.ts.map +1 -0
  348. package/build/react/index.js +4 -0
  349. package/build/react/index.js.map +1 -0
  350. package/build/react/useBluetooth.d.ts +100 -0
  351. package/build/react/useBluetooth.d.ts.map +1 -0
  352. package/build/react/useBluetooth.js +167 -0
  353. package/build/react/useBluetooth.js.map +1 -0
  354. package/build/react/useBluetoothEvent.d.ts +6 -0
  355. package/build/react/useBluetoothEvent.d.ts.map +1 -0
  356. package/build/react/useBluetoothEvent.js +21 -0
  357. package/build/react/useBluetoothEvent.js.map +1 -0
  358. package/build/react/useBluetoothScan.d.ts +22 -0
  359. package/build/react/useBluetoothScan.d.ts.map +1 -0
  360. package/build/react/useBluetoothScan.js +135 -0
  361. package/build/react/useBluetoothScan.js.map +1 -0
  362. package/build/react/useBluetoothStatus.d.ts +16 -0
  363. package/build/react/useBluetoothStatus.d.ts.map +1 -0
  364. package/build/react/useBluetoothStatus.js +137 -0
  365. package/build/react/useBluetoothStatus.js.map +1 -0
  366. package/build/react/useGlassesConnection.d.ts +29 -0
  367. package/build/react/useGlassesConnection.d.ts.map +1 -0
  368. package/build/react/useGlassesConnection.js +153 -0
  369. package/build/react/useGlassesConnection.js.map +1 -0
  370. package/build/types/index.d.ts +3 -0
  371. package/build/types/index.d.ts.map +1 -0
  372. package/build/types/index.js +2 -0
  373. package/build/types/index.js.map +1 -0
  374. package/expo-module.config.json +14 -0
  375. package/ios/BackgroundOtaArtifactDownloader.swift +125 -0
  376. package/ios/BluetoothSdkModule.swift +1034 -0
  377. package/ios/GlasslyBluetoothSDK.podspec +106 -0
  378. package/ios/LocalIPv4.swift +150 -0
  379. package/ios/LocalOtaServer.swift +309 -0
  380. package/ios/LocalPhotoUploadServer.swift +754 -0
  381. package/ios/OtaServerModule.swift +147 -0
  382. package/ios/Packages/CoreObjC/CoreObjC.xcodeproj/project.pbxproj +213 -0
  383. package/ios/Packages/CoreObjC/PcmConverter.h +22 -0
  384. package/ios/Packages/CoreObjC/PcmConverter.m +266 -0
  385. package/ios/Packages/CoreObjC/attdet.c +92 -0
  386. package/ios/Packages/CoreObjC/attdet.h +44 -0
  387. package/ios/Packages/CoreObjC/bits.c +375 -0
  388. package/ios/Packages/CoreObjC/bits.h +315 -0
  389. package/ios/Packages/CoreObjC/bwdet.c +129 -0
  390. package/ios/Packages/CoreObjC/bwdet.h +69 -0
  391. package/ios/Packages/CoreObjC/common.h +151 -0
  392. package/ios/Packages/CoreObjC/energy.c +70 -0
  393. package/ios/Packages/CoreObjC/energy.h +43 -0
  394. package/ios/Packages/CoreObjC/fastmath.h +158 -0
  395. package/ios/Packages/CoreObjC/include/PcmConverter.h +22 -0
  396. package/ios/Packages/CoreObjC/lc3.c +704 -0
  397. package/ios/Packages/CoreObjC/lc3.h +313 -0
  398. package/ios/Packages/CoreObjC/lc3_cpp.h +283 -0
  399. package/ios/Packages/CoreObjC/lc3_private.h +163 -0
  400. package/ios/Packages/CoreObjC/ltpf.c +905 -0
  401. package/ios/Packages/CoreObjC/ltpf.h +111 -0
  402. package/ios/Packages/CoreObjC/ltpf_arm.h +506 -0
  403. package/ios/Packages/CoreObjC/ltpf_neon.h +281 -0
  404. package/ios/Packages/CoreObjC/makefile.mk +35 -0
  405. package/ios/Packages/CoreObjC/mdct.c +469 -0
  406. package/ios/Packages/CoreObjC/mdct.h +57 -0
  407. package/ios/Packages/CoreObjC/mdct_neon.h +296 -0
  408. package/ios/Packages/CoreObjC/meson.build +61 -0
  409. package/ios/Packages/CoreObjC/plc.c +61 -0
  410. package/ios/Packages/CoreObjC/plc.h +57 -0
  411. package/ios/Packages/CoreObjC/rnnoise.h +114 -0
  412. package/ios/Packages/CoreObjC/sns.c +880 -0
  413. package/ios/Packages/CoreObjC/sns.h +103 -0
  414. package/ios/Packages/CoreObjC/spec.c +907 -0
  415. package/ios/Packages/CoreObjC/spec.h +119 -0
  416. package/ios/Packages/CoreObjC/tables.c +3457 -0
  417. package/ios/Packages/CoreObjC/tables.h +94 -0
  418. package/ios/Packages/CoreObjC/tns.c +457 -0
  419. package/ios/Packages/CoreObjC/tns.h +99 -0
  420. package/ios/Packages/SherpaOnnx/Model/joiner.onnx +0 -0
  421. package/ios/Packages/SherpaOnnx/Model/tokens.txt +502 -0
  422. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +2383 -0
  423. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/Info.plist +48 -0
  424. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/c-api.h +4689 -0
  425. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/Headers/sherpa-onnx/c-api/cxx-api.h +2036 -0
  426. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64/libsherpa-onnx.a +0 -0
  427. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/c-api.h +4689 -0
  428. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/Headers/sherpa-onnx/c-api/cxx-api.h +2036 -0
  429. package/ios/Packages/SherpaOnnx/sherpa-onnx.xcframework/ios-arm64_x86_64-simulator/libsherpa-onnx.a +0 -0
  430. package/ios/Packages/libbz2/module.modulemap +5 -0
  431. package/ios/Packages/libbz2/shim.h +1 -0
  432. package/ios/PhotoReceiverModule.swift +129 -0
  433. package/ios/Source/BluetoothSDK.swift +2303 -0
  434. package/ios/Source/BluetoothSDKDebug.swift +12 -0
  435. package/ios/Source/BluetoothSdkDefaults.swift +29 -0
  436. package/ios/Source/Bridge.swift +708 -0
  437. package/ios/Source/Bridging-Header.h +16 -0
  438. package/ios/Source/DeviceManager.swift +2109 -0
  439. package/ios/Source/DeviceStore.swift +336 -0
  440. package/ios/Source/GeneratedChangelogCatalog.swift +7 -0
  441. package/ios/Source/GeneratedReleaseMetadata.swift +11 -0
  442. package/ios/Source/ObservableStore.swift +118 -0
  443. package/ios/Source/OtaManifest.swift +211 -0
  444. package/ios/Source/PrivacyInfo.xcprivacy +23 -0
  445. package/ios/Source/ReleaseChangelog.swift +65 -0
  446. package/ios/Source/Resources/G2Firmware/README.md +10 -0
  447. package/ios/Source/audio/AudioModels.swift +178 -0
  448. package/ios/Source/audio/PcmStreamPlayer.swift +410 -0
  449. package/ios/Source/camera/CameraModels.swift +866 -0
  450. package/ios/Source/connection/ScanSession.swift +27 -0
  451. package/ios/Source/controllers/ControllerManager.swift +184 -0
  452. package/ios/Source/controllers/R1.swift +829 -0
  453. package/ios/Source/errors/BluetoothSdkError.swift +19 -0
  454. package/ios/Source/events/BluetoothEvents.swift +342 -0
  455. package/ios/Source/internal/BleTraceLogger.swift +192 -0
  456. package/ios/Source/internal/BluetoothAvailability.swift +78 -0
  457. package/ios/Source/internal/BluetoothSdkAnalytics.swift +199 -0
  458. package/ios/Source/internal/ValueParsing.swift +99 -0
  459. package/ios/Source/requests/DisplayRequests.swift +82 -0
  460. package/ios/Source/services/PhoneMic.swift +662 -0
  461. package/ios/Source/sgcs/Ar99.swift +2451 -0
  462. package/ios/Source/sgcs/Frame.swift +649 -0
  463. package/ios/Source/sgcs/G1.swift +2554 -0
  464. package/ios/Source/sgcs/G2.swift +6887 -0
  465. package/ios/Source/sgcs/G2CfwClassification.swift +43 -0
  466. package/ios/Source/sgcs/G2CfwRenderer.swift +1279 -0
  467. package/ios/Source/sgcs/G2CfwTextureCache.swift +233 -0
  468. package/ios/Source/sgcs/G2FlashProtocol.swift +312 -0
  469. package/ios/Source/sgcs/G2Flasher.swift +773 -0
  470. package/ios/Source/sgcs/Mach1.swift +541 -0
  471. package/ios/Source/sgcs/MentraLive.swift +7011 -0
  472. package/ios/Source/sgcs/MentraLiveL2capChannel.swift +183 -0
  473. package/ios/Source/sgcs/MentraLivePairingAdvertisement.swift +64 -0
  474. package/ios/Source/sgcs/MentraNex.swift +3221 -0
  475. package/ios/Source/sgcs/Nimo.swift +1886 -0
  476. package/ios/Source/sgcs/SGCManager.swift +608 -0
  477. package/ios/Source/sgcs/Simulated.swift +274 -0
  478. package/ios/Source/sgcs/ar99/ota/Ar99OtaManager.swift +637 -0
  479. package/ios/Source/sgcs/glassly_ble.pb.swift +4354 -0
  480. package/ios/Source/speakerid/SpeakerIdEngine.swift +573 -0
  481. package/ios/Source/status/DeviceStatus.swift +981 -0
  482. package/ios/Source/status/RuntimeState.swift +359 -0
  483. package/ios/Source/status/WifiHotspotStatus.swift +345 -0
  484. package/ios/Source/streaming/StreamModels.swift +693 -0
  485. package/ios/Source/stt/STTTools.swift +191 -0
  486. package/ios/Source/stt/SherpaOnnxTranscriber.swift +466 -0
  487. package/ios/Source/tts/TTSTools.swift +171 -0
  488. package/ios/Source/types/DeviceModels.swift +177 -0
  489. package/ios/Source/utils/AudioSessionMonitor.swift +283 -0
  490. package/ios/Source/utils/BinaryCodec.swift +61 -0
  491. package/ios/Source/utils/BleJsonCompact.swift +405 -0
  492. package/ios/Source/utils/BleWireProtocol.swift +92 -0
  493. package/ios/Source/utils/Constants.swift +78 -0
  494. package/ios/Source/utils/Enums.swift +95 -0
  495. package/ios/Source/utils/G1Text.swift +2096 -0
  496. package/ios/Source/utils/JSCExperiment.swift +241 -0
  497. package/ios/Source/utils/MemoryMonitor.swift +44 -0
  498. package/ios/Source/utils/MessageChunkReassembler.swift +190 -0
  499. package/ios/Source/utils/MessageChunker.swift +294 -0
  500. package/ios/Source/utils/Models.swift +135 -0
  501. package/ios/Source/utils/PhoneAudioMonitor.swift +230 -0
  502. package/ios/Source/utils/TarBz2Extractor.swift +257 -0
  503. package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +78 -0
  504. package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +372 -0
  505. package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +162 -0
  506. package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +182 -0
  507. package/ios/Tests/MentraLivePairingAdvertisementTests.swift +160 -0
  508. package/ios/Tests/ReleaseChangelogTests.swift +13 -0
  509. package/package.json +129 -0
  510. package/plugin/build/index.d.ts +10 -0
  511. package/plugin/build/index.js +12 -0
  512. package/plugin/build/withAndroid.d.ts +3 -0
  513. package/plugin/build/withAndroid.js +190 -0
  514. package/plugin/build/withIos.d.ts +5 -0
  515. package/plugin/build/withIos.js +108 -0
  516. package/scripts/inject-ios-sdk-version.mjs +65 -0
  517. package/scripts/public-ota-api.test.mjs +37 -0
  518. package/scripts/verify-release-package.mjs +45 -0
  519. package/scripts/write-release-metadata.mjs +152 -0
  520. package/scripts/write-release-metadata.test.mjs +37 -0
  521. package/src/BluetoothSdk.types.ts +1623 -0
  522. package/src/_internal.ts +16 -0
  523. package/src/_private/BluetoothSdkModule.ts +684 -0
  524. package/src/_private/LocalNetworkModule.ts +50 -0
  525. package/src/_private/cameraRequestPayload.ts +34 -0
  526. package/src/_private/photoRequestPayload.ts +90 -0
  527. package/src/_private/sdkOtaManifest.ts +18 -0
  528. package/src/changelogs.ts +46 -0
  529. package/src/debug.ts +9 -0
  530. package/src/generated/changelogCatalog.ts +13 -0
  531. package/src/generated/releaseMetadata.ts +20 -0
  532. package/src/index.ts +321 -0
  533. package/src/ota-server/OtaServer.types.ts +10 -0
  534. package/src/ota-server/OtaServerModule.ts +23 -0
  535. package/src/ota-server/OtaServerModule.web.ts +17 -0
  536. package/src/ota-server/index.ts +2 -0
  537. package/src/ota-transport/index.ts +116 -0
  538. package/src/photo-receiver/PhotoReceiver.types.ts +20 -0
  539. package/src/photo-receiver/PhotoReceiverModule.ts +11 -0
  540. package/src/photo-receiver/PhotoReceiverModule.web.ts +17 -0
  541. package/src/photo-receiver/index.ts +2 -0
  542. package/src/react/index.ts +21 -0
  543. package/src/react/useBluetooth.ts +309 -0
  544. package/src/react/useBluetoothEvent.ts +35 -0
  545. package/src/react/useBluetoothScan.ts +173 -0
  546. package/src/react/useBluetoothStatus.ts +180 -0
  547. package/src/react/useGlassesConnection.ts +202 -0
  548. package/src/types/index.ts +12 -0
@@ -0,0 +1,1886 @@
1
+ //
2
+ // Nimo.swift
3
+ //
4
+ // SGC for Nimo glasses. Native implementation of the vendor's BLE protocol:
5
+ // a 0xBF-framed UART channel (service 7033) for commands/content, plus a
6
+ // separate H-T-L-V Opus microphone channel (char 2025).
7
+ //
8
+
9
+ import AVFoundation
10
+ import CoreBluetooth
11
+ import Foundation
12
+ import UIKit
13
+
14
+ // MARK: - Nimo BLE Constants
15
+
16
+ enum NimoBLE {
17
+ // UART-style service: TX = phone→glasses commands, RX = glasses→phone notifications.
18
+ // Short UUIDs from the vendor SDK expanded onto the Bluetooth base UUID.
19
+ static let SERVICE_UUID = CBUUID(string: "00007033-0000-1000-8000-00805F9B34FB")
20
+ static let CHAR_TX = CBUUID(string: "00002021-0000-1000-8000-00805F9B34FB")
21
+ static let CHAR_RX = CBUUID(string: "00002022-0000-1000-8000-00805F9B34FB")
22
+ static let CHAR_MIC = CBUUID(string: "00002025-0000-1000-8000-00805F9B34FB")
23
+
24
+ static let NAME_PREFIX = "nimo"
25
+ // iOS ANCS side-channel devices advertise "<name>_ble" — never the data channel.
26
+ static let BLE_NAME_SUFFIX = "_ble"
27
+
28
+ static let CHUNK_SIZE = 501
29
+ static let INTER_FRAME_DELAY_MS = 5
30
+ }
31
+
32
+ // MARK: - Nimo Protocol Constants
33
+ // Byte values follow the glasses firmware protocol and must not be changed.
34
+
35
+ enum NimoProtocol {
36
+ static let FRAME_MAGIC: UInt8 = 0xBF
37
+
38
+ // status bits
39
+ static let STATUS_ERR: UInt8 = 0x01
40
+ static let STATUS_ACK: UInt8 = 0x02
41
+
42
+ // command categories
43
+ static let CMD_GET_PARAMETER = 0x02
44
+ static let CMD_SET_PARAMETER = 0x03
45
+ static let CMD_INSTRUCTION_REPORT = 0x06
46
+ static let CMD_CONTROL_INSTRUCTION = 0x07
47
+ static let CMD_CONTROL_NOTIFICATION = 0x09
48
+
49
+ // get parameter keys
50
+ static let GET_BATTERY = 0x06
51
+ static let GET_VERSION = 0x0A
52
+ static let GET_VERSION_DETAIL = 0x0B
53
+ static let GET_TWS_STATUS = 0x16
54
+
55
+ // set parameter keys
56
+ static let SET_TIME = 0x01
57
+ static let SET_BRIGHTNESS = 0x02
58
+ static let SET_DISTANCE = 0x03
59
+ static let SET_ANGLE = 0x04
60
+ static let SET_HEADUP_DISPLAY = 0x0D
61
+ static let SET_AUTO_BRIGHTNESS = 0x0E
62
+ static let SET_DISPLAY_OFF = 0x0F
63
+ static let SET_PHONE_TYPE = 0x14
64
+ static let SET_HEIGHT_LEVEL = 0x17
65
+
66
+ // control instruction keys
67
+ static let CTRL_ENTER_APP = 0x01
68
+ static let CTRL_QUIT_APP = 0x03
69
+ static let CTRL_UPDATE_CONTENT = 0x04
70
+
71
+ // notification keys
72
+ static let NOTIFICATION_SEND = 0x01
73
+
74
+ // report keys (cmd 0x06)
75
+ static let REPORT_INPUT = 0x01
76
+ static let REPORT_APP = 0x02
77
+ static let REPORT_TWS = 0x03
78
+ static let REPORT_BUSINESS = 0x04
79
+ static let REPORT_GATT_STATE = 0x05
80
+
81
+ // business report ids
82
+ static let BUSINESS_HEARTBEAT = 0x03
83
+ static let BUSINESS_BATTERY = 0x05
84
+
85
+ // input event codes
86
+ static let INPUT_HEAD_UP = 0x01
87
+ static let INPUT_HEAD_DOWN = 0x02
88
+ static let INPUT_CLICK_RIGHT = 0x03
89
+ static let INPUT_DOUBLE_CLICK_RIGHT = 0x04
90
+ static let INPUT_LONG_PRESS_RIGHT = 0x05
91
+ static let INPUT_TOUCH_PRESS_RIGHT = 0x06
92
+ static let INPUT_TOUCH_RELEASE_RIGHT = 0x07
93
+ static let INPUT_CLICK_LEFT = 0x13
94
+ static let INPUT_DOUBLE_CLICK_LEFT = 0x14
95
+ static let INPUT_LONG_PRESS_LEFT = 0x15
96
+ static let INPUT_TOUCH_PRESS_LEFT = 0x16
97
+ static let INPUT_TOUCH_RELEASE_LEFT = 0x17
98
+
99
+ // app state report phases
100
+ static let STATE_ENTER = 0x01
101
+ static let STATE_EXIT = 0x03
102
+
103
+ // app ids
104
+ static let APP_ID_DASHBOARD = 0x00
105
+ static let APP_ID_NAV = 0x01
106
+ static let APP_ID_ASR_NOTE = 0x04
107
+ static let APP_ID_PROMPTER = 0x06
108
+ static let APP_ID_AI_TALK = 0x07
109
+
110
+ // enterApp modes
111
+ static let APP_MODE_STANDALONE = 0x00
112
+
113
+ // widget resTypes
114
+ static let WIDGET_TEXT_NEW = 0x00
115
+ static let WIDGET_PICTURE = 0x80
116
+
117
+ // navigation widget resIds (appId 0x01): mini map 0x00, arrow 0x01, turn text 0x02,
118
+ // status bar 0x03 (raw), tip 0x04, large map 0x05.
119
+ static let NAV_RES_MINI_MAP = 0x00
120
+ static let NAV_RES_TURN_TEXT = 0x02
121
+ static let NAV_RES_LARGE_MAP = 0x05
122
+
123
+ // navigation image widget sizes (hard firmware requirements; see IMAGE_PROTOCOL).
124
+ static let NAV_MINI_MAP_SIZE = 160
125
+ static let NAV_LARGE_MAP_WIDTH = 452
126
+ static let NAV_LARGE_MAP_HEIGHT = 170
127
+
128
+ // phone types
129
+ static let PHONE_TYPE_IOS = 0x01
130
+
131
+ // image header
132
+ static let COM_IMAGE_HEADER: UInt8 = 0x16
133
+ static let FORMAT_2BPP = 0x02
134
+ static let COMPRESSION_NONE = 0x00
135
+ static let COMPRESSION_ZLIB = 0x07
136
+
137
+ static let MAX_BRIGHTNESS_LEVEL = 16
138
+ }
139
+
140
+ // MARK: - CRC16-CCITT
141
+
142
+ /// CRC-16/CCITT-FALSE: init 0xFFFF, poly 0x1021, no reflection, no final XOR.
143
+ /// Computed over the application payload (app header + data), NOT the transport header.
144
+ func nimoCrc16(_ data: Data) -> UInt16 {
145
+ BinaryCodec.crc16CCITT(data)
146
+ }
147
+
148
+ // MARK: - Frame Codec
149
+
150
+ /// Pure bytes-in/bytes-out frame codec for the Nimo 0xBF transport.
151
+ ///
152
+ /// Frame layout (little-endian):
153
+ /// - 8-byte transport header: [magic][status][len(2)][crc16(2)][index(2)]
154
+ /// - 4-byte application header (requests): [cmd][key][len(2)]
155
+ /// - responses/reports carry an extra status byte: [cmd][key][len(2)][status][data...]
156
+ enum NimoFrameCodec {
157
+ static func transportHeader(_ payload: Data, index: Int = 0, needsAck: Bool = true) -> Data {
158
+ var header = Data(count: 8)
159
+ header[0] = NimoProtocol.FRAME_MAGIC
160
+ header[1] = needsAck ? NimoProtocol.STATUS_ACK : 0
161
+ header[2] = UInt8(payload.count & 0xFF)
162
+ header[3] = UInt8((payload.count >> 8) & 0xFF)
163
+ let crc = nimoCrc16(payload)
164
+ header[4] = UInt8(crc & 0xFF)
165
+ header[5] = UInt8((crc >> 8) & 0xFF)
166
+ header[6] = UInt8(index & 0xFF)
167
+ header[7] = UInt8((index >> 8) & 0xFF)
168
+ return header
169
+ }
170
+
171
+ static func applicationHeader(cmd: Int, key: Int, payloadSize: Int) -> Data {
172
+ return Data([
173
+ UInt8(cmd & 0xFF),
174
+ UInt8(key & 0xFF),
175
+ UInt8(payloadSize & 0xFF),
176
+ UInt8((payloadSize >> 8) & 0xFF),
177
+ ])
178
+ }
179
+
180
+ /// One complete single frame: transport header + app header + payload.
181
+ static func encodeFrame(
182
+ cmd: Int, key: Int, payload: Data = Data(), index: Int = 0, needsAck: Bool = true
183
+ ) -> Data {
184
+ let transportPayload = applicationHeader(cmd: cmd, key: key, payloadSize: payload.count) + payload
185
+ return transportHeader(transportPayload, index: index, needsAck: needsAck) + transportPayload
186
+ }
187
+
188
+ /// Content update (cmd=0x07 key=0x04) blind-sliced into 501-byte chunks, each wrapped in its
189
+ /// own transport header. Index rule: last chunk = 0, others = i+1 (first = 1); single chunk = 0.
190
+ static func updateContentFrames(
191
+ appId: Int, layoutId: Int, resId: Int, resType: Int, content: Data,
192
+ chunkSize: Int = NimoBLE.CHUNK_SIZE
193
+ ) -> [Data] {
194
+ // App header payloadSize includes the 4-byte [appId][layoutId][resId][resType] prefix.
195
+ let appHeader = applicationHeader(
196
+ cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION,
197
+ key: NimoProtocol.CTRL_UPDATE_CONTENT,
198
+ payloadSize: 4 + content.count
199
+ )
200
+ var full = appHeader
201
+ full.append(contentsOf: [
202
+ UInt8(appId & 0xFF), UInt8(layoutId & 0xFF), UInt8(resId & 0xFF), UInt8(resType & 0xFF),
203
+ ])
204
+ full.append(content)
205
+
206
+ let chunkCount = (full.count + chunkSize - 1) / chunkSize
207
+ var frames: [Data] = []
208
+ for i in 0 ..< chunkCount {
209
+ let start = i * chunkSize
210
+ let end = min(start + chunkSize, full.count)
211
+ let chunk = full.subdata(in: start ..< end)
212
+ let isLast = i == chunkCount - 1
213
+ let index = isLast ? 0 : i + 1
214
+ frames.append(transportHeader(chunk, index: index) + chunk)
215
+ }
216
+ return frames
217
+ }
218
+
219
+ /// 15-byte image header. `originalSize` MUST be the uncompressed pixel byte count
220
+ /// (the glasses use it to allocate the decompression buffer).
221
+ static func imageHeader(
222
+ width: Int, height: Int, formatBpp: Int, compression: Int, originalSize: Int,
223
+ compressedSize: Int
224
+ ) -> Data {
225
+ var p = Data(count: 15)
226
+ p[0] = NimoProtocol.COM_IMAGE_HEADER
227
+ p[1] = UInt8(width & 0xFF)
228
+ p[2] = UInt8((width >> 8) & 0xFF)
229
+ p[3] = UInt8(height & 0xFF)
230
+ p[4] = UInt8((height >> 8) & 0xFF)
231
+ p[5] = UInt8(formatBpp & 0xFF)
232
+ p[6] = UInt8(compression & 0xFF)
233
+ p[7] = UInt8(originalSize & 0xFF)
234
+ p[8] = UInt8((originalSize >> 8) & 0xFF)
235
+ p[9] = UInt8((originalSize >> 16) & 0xFF)
236
+ p[10] = UInt8((originalSize >> 24) & 0xFF)
237
+ p[11] = UInt8(compressedSize & 0xFF)
238
+ p[12] = UInt8((compressedSize >> 8) & 0xFF)
239
+ p[13] = UInt8((compressedSize >> 16) & 0xFF)
240
+ p[14] = UInt8((compressedSize >> 24) & 0xFF)
241
+ return p
242
+ }
243
+
244
+ /// 9-byte device time: [year(2 LE)][month][day][hour][min][sec][week][zone]
245
+ /// week: Sunday=0..Saturday=6; zone: signed, 15-minute units, clamped to ±48.
246
+ static func encodeDeviceTime(_ date: Date = Date()) -> Data {
247
+ let cal = Calendar.current
248
+ let c = cal.dateComponents(
249
+ [.year, .month, .day, .hour, .minute, .second, .weekday], from: date
250
+ )
251
+ var b = Data(count: 9)
252
+ let year = c.year ?? 2000
253
+ b[0] = UInt8(year & 0xFF)
254
+ b[1] = UInt8((year >> 8) & 0xFF)
255
+ b[2] = UInt8((c.month ?? 1) & 0xFF)
256
+ b[3] = UInt8((c.day ?? 1) & 0xFF)
257
+ b[4] = UInt8((c.hour ?? 0) & 0xFF)
258
+ b[5] = UInt8((c.minute ?? 0) & 0xFF)
259
+ b[6] = UInt8((c.second ?? 0) & 0xFF)
260
+ // Calendar weekday: Sunday=1..Saturday=7 → protocol Sunday=0..Saturday=6
261
+ b[7] = UInt8(((c.weekday ?? 1) - 1) & 0xFF)
262
+ let offsetMinutes = TimeZone.current.secondsFromGMT(for: date) / 60
263
+ let zone = max(-48, min(48, offsetMinutes / 15))
264
+ b[8] = UInt8(bitPattern: Int8(zone))
265
+ return b
266
+ }
267
+
268
+ /// A decoded frame. `cmd`/`key`/`statusCode`/`data` are nil when the payload is too short.
269
+ struct DecodedFrame {
270
+ let transportStatus: UInt8
271
+ let index: Int
272
+ let cmd: Int?
273
+ let key: Int?
274
+ let statusCode: Int?
275
+ let data: Data?
276
+ }
277
+
278
+ /// Decodes one complete frame (responses and reports both use the 5-byte
279
+ /// [cmd][key][len(2)][status] application header). Returns nil on transport
280
+ /// error, bad CRC, or truncation.
281
+ static func decode(_ frame: Data) -> DecodedFrame? {
282
+ guard frame.count >= 8, frame[0] == NimoProtocol.FRAME_MAGIC else { return nil }
283
+ let transportStatus = frame[1]
284
+ let payloadLen = Int(frame[2]) | (Int(frame[3]) << 8)
285
+ let crcValue = UInt16(frame[4]) | (UInt16(frame[5]) << 8)
286
+ let index = Int(frame[6]) | (Int(frame[7]) << 8)
287
+ guard frame.count >= 8 + payloadLen else { return nil }
288
+ guard transportStatus & NimoProtocol.STATUS_ERR == 0 else { return nil }
289
+
290
+ let payload = frame.subdata(in: 8 ..< 8 + payloadLen)
291
+ guard nimoCrc16(payload) == crcValue else { return nil }
292
+
293
+ guard payload.count >= 5 else {
294
+ return DecodedFrame(
295
+ transportStatus: transportStatus, index: index, cmd: nil, key: nil,
296
+ statusCode: nil, data: nil
297
+ )
298
+ }
299
+ let data = payload.count > 5 ? payload.subdata(in: 5 ..< payload.count) : Data()
300
+ return DecodedFrame(
301
+ transportStatus: transportStatus,
302
+ index: index,
303
+ cmd: Int(payload[0]),
304
+ key: Int(payload[1]),
305
+ statusCode: Int(payload[4]),
306
+ data: data
307
+ )
308
+ }
309
+ }
310
+
311
+ // MARK: - Receive Assembler
312
+
313
+ /// Reassembles multi-packet responses. Fragments are grouped by (cmd,key); each fragment carries
314
+ /// the full 5-byte response app header. The first fragment (index==1) keeps its header, the
315
+ /// continuation fragments (index>1) and the last fragment (index==0) contribute only their data
316
+ /// sections, concatenated in ascending index order with the last (0) treated as largest. The
317
+ /// merged message is re-framed (lengths and CRC recomputed) so `NimoFrameCodec.decode` can parse
318
+ /// it. Single packets (index==0, no cached group) pass through unchanged.
319
+ class NimoReceiveAssembler {
320
+ private class Pending {
321
+ var firstAppPayload: Data?
322
+ var dataByIndex: [Int: Data] = [:]
323
+ let startTime = Date()
324
+ }
325
+
326
+ private var pending: [Int: Pending] = [:]
327
+
328
+ func ingest(_ packet: Data) -> [Data] {
329
+ guard packet.count >= 8 else { return [] }
330
+ let payloadLen = Int(packet[2]) | (Int(packet[3]) << 8)
331
+ let index = Int(packet[6]) | (Int(packet[7]) << 8)
332
+ guard packet.count >= 8 + payloadLen else { return [] }
333
+ let appPayload = packet.subdata(in: 8 ..< 8 + payloadLen)
334
+
335
+ guard appPayload.count >= 2 else {
336
+ return index == 0 ? [packet] : []
337
+ }
338
+
339
+ let cmd = Int(appPayload[0])
340
+ let key = Int(appPayload[1])
341
+ let groupKey = (cmd << 8) | key
342
+
343
+ if index == 0 {
344
+ guard let p = pending.removeValue(forKey: groupKey) else { return [packet] }
345
+ p.dataByIndex[Int.max] = dataSection(appPayload)
346
+ return [reframe(p)]
347
+ }
348
+
349
+ if index == 1 {
350
+ // A new first fragment while an old first fragment is cached means the previous
351
+ // round lost its last packet — drop the stale group and start over.
352
+ if let existing = pending[groupKey], existing.firstAppPayload != nil {
353
+ pending.removeValue(forKey: groupKey)
354
+ }
355
+ }
356
+
357
+ let p = pending[groupKey] ?? Pending()
358
+ pending[groupKey] = p
359
+ if index == 1 {
360
+ p.firstAppPayload = appPayload
361
+ }
362
+ p.dataByIndex[index] = dataSection(appPayload)
363
+ return []
364
+ }
365
+
366
+ func cleanup(timeoutSeconds: TimeInterval = 10) {
367
+ let now = Date()
368
+ pending = pending.filter { now.timeIntervalSince($0.value.startTime) < timeoutSeconds }
369
+ }
370
+
371
+ func reset() {
372
+ pending.removeAll()
373
+ }
374
+
375
+ private func dataSection(_ appPayload: Data) -> Data {
376
+ return appPayload.count <= 5 ? Data() : appPayload.subdata(in: 5 ..< appPayload.count)
377
+ }
378
+
379
+ private func reframe(_ p: Pending) -> Data {
380
+ var merged = Data()
381
+ for k in p.dataByIndex.keys.sorted() {
382
+ merged.append(p.dataByIndex[k]!)
383
+ }
384
+ let first = p.firstAppPayload
385
+ let cmd: UInt8 = (first?.count ?? 0) >= 1 ? first![0] : 0
386
+ let key: UInt8 = (first?.count ?? 0) >= 2 ? first![1] : 0
387
+ let status: UInt8 = (first?.count ?? 0) >= 5 ? first![4] : 0
388
+
389
+ var appPayload = Data([
390
+ cmd, key, UInt8(merged.count & 0xFF), UInt8((merged.count >> 8) & 0xFF), status,
391
+ ])
392
+ appPayload.append(merged)
393
+ return NimoFrameCodec.transportHeader(appPayload, index: 0) + appPayload
394
+ }
395
+ }
396
+
397
+ // MARK: - Mic Audio Parser
398
+
399
+ /// H-T-L-V parser for the mic channel (char 2025). Packets: [0x52][type][len(2 LE)][payload].
400
+ /// For Opus packets the payload is [SN(2 LE)][frameCnt][reserved] then frameCnt frames of
401
+ /// [frameLen(1)][body] where opusLen = body[3] and the Opus bytes are body[8 .. 8+opusLen].
402
+ /// Slicing by opusLen (not frameLen-8) is mandatory — the tail holds CRC/padding bytes.
403
+ enum NimoAudioParser {
404
+ static let HEADER: UInt8 = 0x52
405
+ static let TYPE_STOP = 0x00
406
+ static let TYPE_START = 0x01
407
+ static let TYPE_OPUS_LEFT = 0x02
408
+ static let TYPE_OPUS_RIGHT = 0x03
409
+
410
+ struct Packet {
411
+ let type: Int
412
+ let sequence: Int
413
+ let opusFrames: [Data]
414
+ }
415
+
416
+ static func parse(_ data: Data) -> Packet? {
417
+ guard data.count >= 4, data[0] == HEADER else { return nil }
418
+ let type = Int(data[1])
419
+ let len = Int(data[2]) | (Int(data[3]) << 8)
420
+ guard data.count >= 4 + len else { return Packet(type: type, sequence: 0, opusFrames: []) }
421
+ guard type == TYPE_OPUS_LEFT || type == TYPE_OPUS_RIGHT else {
422
+ return Packet(type: type, sequence: 0, opusFrames: [])
423
+ }
424
+
425
+ let payload = data.subdata(in: 4 ..< 4 + len)
426
+ guard payload.count >= 4 else { return Packet(type: type, sequence: 0, opusFrames: []) }
427
+ let sn = Int(payload[0]) | (Int(payload[1]) << 8)
428
+ let frameCnt = Int(payload[2])
429
+ var offset = 4
430
+ var frames: [Data] = []
431
+ var i = 0
432
+ while i < frameCnt, offset < payload.count {
433
+ let frameLen = Int(payload[offset])
434
+ offset += 1
435
+ guard offset + frameLen <= payload.count else { break }
436
+ let body = payload.subdata(in: offset ..< offset + frameLen)
437
+ offset += frameLen
438
+ i += 1
439
+ if frameLen < 8 { continue }
440
+ let opusLen = Int(body[3])
441
+ if 8 + opusLen > frameLen { continue }
442
+ frames.append(body.subdata(in: 8 ..< 8 + opusLen))
443
+ }
444
+ return Packet(type: type, sequence: sn, opusFrames: frames)
445
+ }
446
+ }
447
+
448
+ // MARK: - Opus Decoder (AVAudioConverter)
449
+
450
+ /// Decodes the glasses' 16 kHz mono Opus frames to 16-bit PCM via the system
451
+ /// Opus decoder, so no native Opus library has to be vendored.
452
+ private class NimoOpusDecoder {
453
+ private let opusFormat: AVAudioFormat
454
+ private let pcmFormat: AVAudioFormat
455
+ private var converter: AVAudioConverter?
456
+
457
+ init?() {
458
+ var desc = AudioStreamBasicDescription(
459
+ mSampleRate: 16000,
460
+ mFormatID: kAudioFormatOpus,
461
+ mFormatFlags: 0,
462
+ mBytesPerPacket: 0,
463
+ mFramesPerPacket: 320, // 20 ms at 16 kHz
464
+ mBytesPerFrame: 0,
465
+ mChannelsPerFrame: 1,
466
+ mBitsPerChannel: 0,
467
+ mReserved: 0
468
+ )
469
+ guard let inFormat = AVAudioFormat(streamDescription: &desc),
470
+ let outFormat = AVAudioFormat(
471
+ commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: true
472
+ )
473
+ else { return nil }
474
+ opusFormat = inFormat
475
+ pcmFormat = outFormat
476
+ converter = AVAudioConverter(from: inFormat, to: outFormat)
477
+ if converter == nil {
478
+ return nil
479
+ }
480
+ }
481
+
482
+ func decode(_ opusFrame: Data) -> Data? {
483
+ guard let converter, !opusFrame.isEmpty else { return nil }
484
+
485
+ let compressed = AVAudioCompressedBuffer(
486
+ format: opusFormat, packetCapacity: 1, maximumPacketSize: opusFrame.count
487
+ )
488
+ opusFrame.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
489
+ compressed.data.copyMemory(from: raw.baseAddress!, byteCount: opusFrame.count)
490
+ }
491
+ compressed.byteLength = UInt32(opusFrame.count)
492
+ compressed.packetCount = 1
493
+ compressed.packetDescriptions?.pointee = AudioStreamPacketDescription(
494
+ mStartOffset: 0, mVariableFramesInPacket: 0, mDataByteSize: UInt32(opusFrame.count)
495
+ )
496
+
497
+ // Up to 40 ms of output per frame, to be safe.
498
+ guard let pcmBuffer = AVAudioPCMBuffer(pcmFormat: pcmFormat, frameCapacity: 640) else {
499
+ return nil
500
+ }
501
+ var fed = false
502
+ var error: NSError?
503
+ let status = converter.convert(to: pcmBuffer, error: &error) { _, outStatus in
504
+ if fed {
505
+ outStatus.pointee = .noDataNow
506
+ return nil
507
+ }
508
+ fed = true
509
+ outStatus.pointee = .haveData
510
+ return compressed
511
+ }
512
+ if status == .error {
513
+ Bridge.log("NIMO: Opus decode error: \(error?.localizedDescription ?? "unknown")")
514
+ return nil
515
+ }
516
+ guard pcmBuffer.frameLength > 0, let channel = pcmBuffer.int16ChannelData else {
517
+ return nil
518
+ }
519
+ return Data(bytes: channel[0], count: Int(pcmBuffer.frameLength) * 2)
520
+ }
521
+ }
522
+
523
+ // MARK: - Reconnection Manager
524
+
525
+ actor NimoReconnectionManager {
526
+ private var task: Task<Void, Never>?
527
+ private let intervalSeconds: UInt64
528
+
529
+ init(intervalSeconds: UInt64 = 30) {
530
+ self.intervalSeconds = intervalSeconds
531
+ }
532
+
533
+ func start(_ onAttempt: @escaping () async -> Bool) {
534
+ stop()
535
+ task = Task {
536
+ var attempts = 0
537
+ while !Task.isCancelled {
538
+ try? await Task.sleep(nanoseconds: intervalSeconds * 1_000_000_000)
539
+ if Task.isCancelled { return }
540
+ attempts += 1
541
+ Bridge.log("NIMO: Reconnection attempt \(attempts)")
542
+ if await onAttempt() {
543
+ Bridge.log("NIMO: Reconnection successful, stopping")
544
+ return
545
+ }
546
+ }
547
+ }
548
+ }
549
+
550
+ func stop() {
551
+ task?.cancel()
552
+ task = nil
553
+ }
554
+ }
555
+
556
+ // MARK: - Nimo Class
557
+
558
+ class Nimo: NSObject, SGCManager {
559
+ var type: String = DeviceTypes.NIMO
560
+ let hasMic = true
561
+
562
+ private static let _bluetoothQueue = DispatchQueue(label: "BluetoothNimo", qos: .userInitiated)
563
+
564
+ private enum Const {
565
+ static let twsTimeoutSeconds: TimeInterval = 10
566
+ static let ackTimeoutSeconds: TimeInterval = 5
567
+ static let pairingTimeoutSeconds: TimeInterval = 15
568
+ static let batteryPollSeconds: TimeInterval = 30
569
+ static let textQueueTickSeconds: TimeInterval = 0.1
570
+ static let writeWatchdogSeconds: TimeInterval = 1
571
+ // Fall back to the other ear if the preferred one goes quiet for this long.
572
+ static let micSideFallbackSeconds: TimeInterval = 2
573
+ }
574
+
575
+ // The text surface every sendTextWall renders into. The ASR note view (appId 0x04,
576
+ // note text resId 0x00) is a plain text page that renders pushed text directly;
577
+ // Prompter (0x06) was tried first but did not display pushed text on hardware.
578
+ private let textAppId = NimoProtocol.APP_ID_ASR_NOTE
579
+ private let textLayoutId = 0
580
+ private let textResId = 0
581
+
582
+ // BLE
583
+ private var centralManager: CBCentralManager?
584
+ private var peripheral: CBPeripheral?
585
+ private var txChar: CBCharacteristic?
586
+ private var rxChar: CBCharacteristic?
587
+ private var micChar: CBCharacteristic?
588
+ private var isDisconnecting = false
589
+
590
+ // Device search
591
+ private var DEVICE_SEARCH_ID = "NOT_SET"
592
+
593
+ private var lastDeviceName: String? {
594
+ get { UserDefaults.standard.string(forKey: "nimo_lastDeviceName") }
595
+ set { UserDefaults.standard.set(newValue, forKey: "nimo_lastDeviceName") }
596
+ }
597
+
598
+ private var lastDeviceUUID: String? {
599
+ get { UserDefaults.standard.string(forKey: "nimo_lastDeviceUUID") }
600
+ set { UserDefaults.standard.set(newValue, forKey: "nimo_lastDeviceUUID") }
601
+ }
602
+
603
+ private let reconnectionManager = NimoReconnectionManager()
604
+ private let receiveAssembler = NimoReceiveAssembler()
605
+
606
+ // Handshake
607
+ private enum HandshakeState {
608
+ case idle
609
+ case awaitingTws
610
+ case awaitingTimeAck
611
+ case ready
612
+ }
613
+
614
+ private var handshakeState = HandshakeState.idle
615
+ private var twsConnected = false
616
+ private var twsTimeoutItem: DispatchWorkItem?
617
+ private var pairingTimeoutItem: DispatchWorkItem?
618
+
619
+ // Pending acks keyed by (cmd << 8) | key
620
+ private struct PendingAck {
621
+ let onResult: (Bool) -> Void
622
+ let timeoutItem: DispatchWorkItem
623
+ }
624
+
625
+ private var pendingAcks: [Int: PendingAck] = [:]
626
+
627
+ // Serialized write queue: writes use .withResponse, the next write goes out after
628
+ // didWriteValueFor (plus a 5 ms inter-frame delay), with a watchdog to unstick the queue.
629
+ private struct QueuedWrite {
630
+ let char: CBCharacteristic
631
+ let bytes: Data
632
+ }
633
+
634
+ private var writeQueue: [QueuedWrite] = []
635
+ private var writeInFlight = false
636
+ private var writeWatchdogItem: DispatchWorkItem?
637
+
638
+ // Timers
639
+ private var batteryPollTimer: Timer?
640
+ private var textQueueTimer: Timer?
641
+
642
+ // Text rendering
643
+ private var pendingText: String?
644
+ private var textAppEntered = false
645
+ private var navAppEntered = false
646
+ private var currentGlassesAppId = -1
647
+
648
+ // Battery
649
+ private var lastBatteryLevel = -1
650
+ private var lastCharging = false
651
+
652
+ // Version info
653
+ private var firmwareVersionPacked = ""
654
+ private var firmwareVersionDetail = ""
655
+
656
+ // Mic audio
657
+ private var opusDecoder: NimoOpusDecoder?
658
+ private var lastRightPacketTime = Date.distantPast
659
+ private var preferRightMic = true
660
+
661
+ // MARK: - SGCManager: Connection Management
662
+
663
+ func findCompatibleDevices() {
664
+ Bridge.log("NIMO: findCompatibleDevices()")
665
+ DEVICE_SEARCH_ID = "NOT_SET"
666
+ DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.SCANNING)
667
+ startScan()
668
+ }
669
+
670
+ func connectById(_ id: String) {
671
+ Bridge.log("NIMO: connectById(\(id))")
672
+ DEVICE_SEARCH_ID = id
673
+ DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTING)
674
+ isDisconnecting = false
675
+ startPairingTimeout()
676
+ startScan()
677
+ }
678
+
679
+ func stopScan() {
680
+ centralManager?.stopScan()
681
+ }
682
+
683
+ func disconnect() {
684
+ Bridge.log("NIMO: disconnect()")
685
+ isDisconnecting = true
686
+ cancelPairingTimeout()
687
+ cancelTwsTimeout()
688
+ stopScan()
689
+ stopTimers()
690
+ Task { await reconnectionManager.stop() }
691
+ failAllPendingAcks()
692
+
693
+ if let peripheral {
694
+ centralManager?.cancelPeripheralConnection(peripheral)
695
+ }
696
+ peripheral = nil
697
+ txChar = nil
698
+ rxChar = nil
699
+ micChar = nil
700
+ resetSessionState()
701
+
702
+ DeviceStore.shared.apply("glasses", "connected", false)
703
+ DeviceStore.shared.apply("glasses", "fullyBooted", false)
704
+ DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
705
+ }
706
+
707
+ func forget() {
708
+ Bridge.log("NIMO: forget()")
709
+ disconnect()
710
+ lastDeviceName = nil
711
+ lastDeviceUUID = nil
712
+ DEVICE_SEARCH_ID = "NOT_SET"
713
+ }
714
+
715
+ func cleanup() {
716
+ disconnect()
717
+ opusDecoder = nil
718
+ }
719
+
720
+ func getConnectedBluetoothName() -> String? {
721
+ return peripheral?.name
722
+ }
723
+
724
+ func ping() {
725
+ sendFrame(
726
+ NimoFrameCodec.encodeFrame(
727
+ cmd: NimoProtocol.CMD_GET_PARAMETER, key: NimoProtocol.GET_TWS_STATUS
728
+ )
729
+ )
730
+ }
731
+
732
+ func dbg1() {}
733
+ func dbg2() {}
734
+ func connectController() {}
735
+ func disconnectController() {}
736
+
737
+ // MARK: - SGCManager: Audio Control
738
+
739
+ func setMicEnabled(_ enabled: Bool) {
740
+ Bridge.log("NIMO: setMicEnabled(\(enabled))")
741
+ guard let micChar else {
742
+ Bridge.log("NIMO: mic characteristic not available")
743
+ return
744
+ }
745
+ DeviceStore.shared.apply("glasses", "micEnabled", enabled)
746
+ if enabled {
747
+ if opusDecoder == nil {
748
+ opusDecoder = NimoOpusDecoder()
749
+ if opusDecoder == nil {
750
+ Bridge.log("NIMO: system Opus decoder unavailable — glasses mic disabled")
751
+ }
752
+ }
753
+ enqueueWrite(micChar, Data([0x52, 0x01, 0x00, 0x00]))
754
+ } else {
755
+ enqueueWrite(micChar, Data([0x52, 0x00, 0x00, 0x00]))
756
+ }
757
+ }
758
+
759
+ func sortMicRanking(list: [String]) -> [String] {
760
+ return list
761
+ }
762
+
763
+ // MARK: - SGCManager: Messaging
764
+
765
+ func sendJson(_: [String: Any], wakeUp _: Bool, requireAck _: Bool) {
766
+ Bridge.log("NIMO: sendJson - not supported")
767
+ }
768
+
769
+ // MARK: - SGCManager: Display Control
770
+
771
+ func setBrightness(_ level: Int, autoMode: Bool) {
772
+ Bridge.log("NIMO: setBrightness(\(level), auto=\(autoMode))")
773
+ sendFrame(
774
+ NimoFrameCodec.encodeFrame(
775
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
776
+ key: NimoProtocol.SET_AUTO_BRIGHTNESS,
777
+ payload: Data([autoMode ? 1 : 0])
778
+ )
779
+ )
780
+ if !autoMode {
781
+ // The firmware takes a 0–16 level, not a 0–100 percent.
782
+ let clamped = max(0, min(100, level))
783
+ let lvl = Int((Double(clamped) / 100.0 * Double(NimoProtocol.MAX_BRIGHTNESS_LEVEL)).rounded())
784
+ sendFrame(
785
+ NimoFrameCodec.encodeFrame(
786
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
787
+ key: NimoProtocol.SET_BRIGHTNESS,
788
+ payload: Data([UInt8(lvl)])
789
+ )
790
+ )
791
+ }
792
+ }
793
+
794
+ func clearDisplay() {
795
+ // Keep the text app/page alive — a blank update avoids re-entering the app next time.
796
+ pendingText = " "
797
+ }
798
+
799
+ func sendText(_ text: String) async {
800
+ await sendTextWall(text)
801
+ }
802
+
803
+ func sendTextWall(_ text: String) async {
804
+ // Coalesced: only the most recent pending text survives until the next 100ms drain.
805
+ pendingText = text
806
+ }
807
+
808
+ func sendDoubleTextWall(_ top: String, _ bottom: String) async {
809
+ await sendTextWall(top + "\n\n" + bottom)
810
+ }
811
+
812
+ func sendPositionedText(
813
+ _ text: String, x _: Int32, y _: Int32, width _: Int32, height _: Int32,
814
+ borderWidth _: Int32, borderRadius _: Int32
815
+ ) async {
816
+ // Navigation pushes turn text via positioned_text. Nimo widgets have fixed geometry, so
817
+ // position/border are ignored — funnel the text through the same coalesced path as
818
+ // sendTextWall so it renders on the ASR note page. Without this override the base no-op
819
+ // silently dropped all navigation text.
820
+ // Bridge.log("NIMO: sendPositionedText(text=\(text))")
821
+ // pendingText = text
822
+ }
823
+
824
+ func displayBitmap(
825
+ base64ImageData: String, x _: Int32?, y _: Int32?, width _: Int32?, height _: Int32?
826
+ ) async -> Bool {
827
+ // Nimo widgets have fixed geometry; x/y are not honored. Renders into the navigation
828
+ // large-map widget (appId 0x01, resId 0x05, 452x170 2bpp) — the full-width nav map,
829
+ // which shows far more than the small 160x160 mini-map (resId 0x00). bitmapToGrayscale
830
+ // aspect-fits onto black, so a non-matching source aspect letterboxes rather than
831
+ // distorts. The nav app must be foregrounded before pushing content (mirrors the text
832
+ // path). TODO: hardware-verify the large-map widget renders and the nav app-mode.
833
+ let targetWidth = NimoProtocol.NAV_LARGE_MAP_WIDTH
834
+ let targetHeight = NimoProtocol.NAV_LARGE_MAP_HEIGHT
835
+ Bridge.log("NIMO: displayBitmap → nav large map (navAppEntered=\(navAppEntered))")
836
+ guard let imageData = Data(base64Encoded: base64ImageData),
837
+ let image = UIImage(data: imageData)
838
+ else {
839
+ Bridge.log("NIMO: displayBitmap — could not decode image")
840
+ return false
841
+ }
842
+ guard let grayscale = bitmapToGrayscale(image, width: targetWidth, height: targetHeight)
843
+ else { return false }
844
+ let packed = packL8To2bpp(grayscale)
845
+ let (payload, compression) = compressAdaptive(packed)
846
+ if !navAppEntered {
847
+ sendFrame(
848
+ NimoFrameCodec.encodeFrame(
849
+ cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION,
850
+ key: NimoProtocol.CTRL_ENTER_APP,
851
+ payload: Data([UInt8(NimoProtocol.APP_ID_NAV), UInt8(NimoProtocol.APP_MODE_STANDALONE)])
852
+ )
853
+ )
854
+ // Optimistic; corrected by app-state reports if the glasses refuse/exit.
855
+ navAppEntered = true
856
+ }
857
+ let content =
858
+ NimoFrameCodec.imageHeader(
859
+ width: targetWidth,
860
+ height: targetHeight,
861
+ formatBpp: NimoProtocol.FORMAT_2BPP,
862
+ compression: compression,
863
+ originalSize: packed.count,
864
+ compressedSize: compression == NimoProtocol.COMPRESSION_NONE ? 0 : payload.count
865
+ ) + payload
866
+ let frames = NimoFrameCodec.updateContentFrames(
867
+ appId: NimoProtocol.APP_ID_NAV,
868
+ layoutId: 0,
869
+ resId: NimoProtocol.NAV_RES_LARGE_MAP,
870
+ resType: NimoProtocol.WIDGET_PICTURE,
871
+ content: content
872
+ )
873
+ enqueueFrames(frames)
874
+ return true
875
+ }
876
+
877
+ func showDashboard() {
878
+ Bridge.log("NIMO: showDashboard()")
879
+ textAppEntered = false
880
+ navAppEntered = false
881
+ sendFrame(
882
+ NimoFrameCodec.encodeFrame(
883
+ cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION,
884
+ key: NimoProtocol.CTRL_ENTER_APP,
885
+ payload: Data([
886
+ UInt8(NimoProtocol.APP_ID_DASHBOARD), UInt8(NimoProtocol.APP_MODE_STANDALONE),
887
+ ])
888
+ )
889
+ )
890
+ }
891
+
892
+ func setDashboardPosition(_ height: Int, _ depth: Int) {
893
+ Bridge.log("NIMO: setDashboardPosition(\(height), \(depth))")
894
+ sendFrame(
895
+ NimoFrameCodec.encodeFrame(
896
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
897
+ key: NimoProtocol.SET_HEIGHT_LEVEL,
898
+ payload: Data([UInt8(max(0, min(10, height)))])
899
+ )
900
+ )
901
+ sendFrame(
902
+ NimoFrameCodec.encodeFrame(
903
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
904
+ key: NimoProtocol.SET_DISTANCE,
905
+ payload: Data([UInt8(max(0, min(10, depth)))])
906
+ )
907
+ )
908
+ }
909
+
910
+ // MARK: - SGCManager: Device Control
911
+
912
+ func setHeadUpAngle(_ angle: Int) {
913
+ let clamped = max(0, min(90, angle))
914
+ Bridge.log("NIMO: setHeadUpAngle(\(clamped))")
915
+ // Enable the head-up display gesture, then set the wake angle.
916
+ sendFrame(
917
+ NimoFrameCodec.encodeFrame(
918
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
919
+ key: NimoProtocol.SET_HEADUP_DISPLAY,
920
+ payload: Data([1])
921
+ )
922
+ )
923
+ // setAngle payload is [optType, deg]. TODO: hardware-verify optType semantics.
924
+ sendFrame(
925
+ NimoFrameCodec.encodeFrame(
926
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
927
+ key: NimoProtocol.SET_ANGLE,
928
+ payload: Data([0x01, UInt8(clamped)])
929
+ )
930
+ )
931
+ }
932
+
933
+ func getBatteryStatus() {
934
+ sendFrame(
935
+ NimoFrameCodec.encodeFrame(
936
+ cmd: NimoProtocol.CMD_GET_PARAMETER, key: NimoProtocol.GET_BATTERY
937
+ )
938
+ )
939
+ }
940
+
941
+ func setSilentMode(_ enabled: Bool) {
942
+ Bridge.log("NIMO: setSilentMode(\(enabled))")
943
+ // TODO: hardware-verify that display-off matches Glassly silent-mode semantics.
944
+ sendFrame(
945
+ NimoFrameCodec.encodeFrame(
946
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
947
+ key: NimoProtocol.SET_DISPLAY_OFF,
948
+ payload: Data([enabled ? 1 : 0])
949
+ )
950
+ )
951
+ }
952
+
953
+ func exit() {
954
+ Bridge.log("NIMO: exit()")
955
+ let appId = currentGlassesAppId >= 0 ? currentGlassesAppId : textAppId
956
+ textAppEntered = false
957
+ navAppEntered = false
958
+ sendFrame(
959
+ NimoFrameCodec.encodeFrame(
960
+ cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION,
961
+ key: NimoProtocol.CTRL_QUIT_APP,
962
+ payload: Data([UInt8(appId & 0xFF)])
963
+ )
964
+ )
965
+ }
966
+
967
+ func sendShutdown() {
968
+ Bridge.log("NIMO: sendShutdown - not supported")
969
+ }
970
+
971
+ func sendReboot() {
972
+ Bridge.log("NIMO: sendReboot - not supported")
973
+ }
974
+
975
+ func sendRgbLedControl(
976
+ requestId: String, packageName _: String?, action _: String, color _: String?,
977
+ onDurationMs _: Int, offDurationMs _: Int, count _: Int
978
+ ) {
979
+ Bridge.sendRgbLedControlResponse(requestId: requestId, success: false, error: "device_not_supported")
980
+ }
981
+
982
+ // MARK: - Notifications
983
+
984
+ /// Pushes a notification (cmd=0x09 key=0x01, fixed 319-byte struct:
985
+ /// appId(1) + title(51, UTF-8 zero-padded) + time(9) + contentLen(2 LE) + content(256)).
986
+ func sendNotification(notificationAppId: Int, title: String, content: String) {
987
+ var payload = Data(count: 319)
988
+ payload[0] = UInt8(max(0, min(255, notificationAppId)))
989
+ writeUtf8Padded(&payload, offset: 1, text: title, maxLen: 51)
990
+ let time = NimoFrameCodec.encodeDeviceTime()
991
+ payload.replaceSubrange(52 ..< 61, with: time)
992
+ let contentBytes = Data(content.utf8)
993
+ let clen = min(contentBytes.count, 256)
994
+ payload[61] = UInt8(clen & 0xFF)
995
+ payload[62] = UInt8((clen >> 8) & 0xFF)
996
+ writeUtf8Padded(&payload, offset: 63, text: content, maxLen: 256)
997
+ sendFrame(
998
+ NimoFrameCodec.encodeFrame(
999
+ cmd: NimoProtocol.CMD_CONTROL_NOTIFICATION,
1000
+ key: NimoProtocol.NOTIFICATION_SEND,
1001
+ payload: payload
1002
+ )
1003
+ )
1004
+ }
1005
+
1006
+ private func writeUtf8Padded(_ dest: inout Data, offset: Int, text: String, maxLen: Int) {
1007
+ var bytes = Data(text.utf8)
1008
+ if bytes.count > maxLen {
1009
+ // Truncate on a UTF-8 boundary
1010
+ var end = maxLen
1011
+ while end > 0, bytes[end] & 0xC0 == 0x80 { end -= 1 }
1012
+ bytes = bytes.subdata(in: 0 ..< end)
1013
+ }
1014
+ dest.replaceSubrange(offset ..< offset + bytes.count, with: bytes)
1015
+ }
1016
+
1017
+ // MARK: - SGCManager: Camera & Media (no camera)
1018
+
1019
+ func requestPhoto(
1020
+ _: String, appId _: String, size _: String?, webhookUrl _: String?, authToken _: String?,
1021
+ compress _: String?, flash _: Bool, save _: Bool, sound _: Bool, exposureTimeNs _: Double?,
1022
+ iso _: Int?
1023
+ ) {
1024
+ Bridge.log("NIMO: requestPhoto - not supported (no camera)")
1025
+ }
1026
+
1027
+ func requestPhoto(_: PhotoRequest) {
1028
+ Bridge.log("NIMO: requestPhoto(PhotoRequest) - not supported (no camera)")
1029
+ }
1030
+
1031
+ func startStream(_: [String: Any]) {
1032
+ Bridge.log("NIMO: startStream - not supported")
1033
+ }
1034
+
1035
+ func stopStream() {
1036
+ Bridge.log("NIMO: stopStream - not supported")
1037
+ }
1038
+
1039
+ func sendStreamKeepAlive(_: [String: Any]) {}
1040
+
1041
+ func startVideoRecording(requestId _: String, save _: Bool, sound _: Bool) {
1042
+ Bridge.log("NIMO: startVideoRecording - not supported")
1043
+ }
1044
+
1045
+ func stopVideoRecording(requestId _: String) {
1046
+ Bridge.log("NIMO: stopVideoRecording - not supported")
1047
+ }
1048
+
1049
+ func sendButtonPhotoSettings() {}
1050
+ func sendButtonVideoRecordingSettings() {}
1051
+ func sendButtonMaxRecordingTime() {}
1052
+ func sendButtonCameraLedSetting() {}
1053
+ func sendCameraFovSetting() {}
1054
+
1055
+ // MARK: - SGCManager: Network (no WiFi)
1056
+
1057
+ func requestWifiScan(scanId _: String?) {}
1058
+ func sendWifiCredentials(_: String, _: String) {}
1059
+ func forgetWifiNetwork(_: String) {}
1060
+ func sendHotspotState(_: Bool) {}
1061
+ func sendOtaStart(otaVersionUrl: String?) {}
1062
+ func sendOtaQueryStatus() {}
1063
+
1064
+ // MARK: - SGCManager: User Context / Gallery / Version
1065
+
1066
+ func sendUserEmailToGlasses(_: String) {}
1067
+ func sendIncidentId(_: String, apiBaseUrl _: String?) {}
1068
+ func queryGalleryStatus() {}
1069
+ func sendGalleryMode() {}
1070
+
1071
+ func requestVersionInfo() {
1072
+ sendFrame(
1073
+ NimoFrameCodec.encodeFrame(
1074
+ cmd: NimoProtocol.CMD_GET_PARAMETER, key: NimoProtocol.GET_VERSION
1075
+ )
1076
+ )
1077
+ sendFrame(
1078
+ NimoFrameCodec.encodeFrame(
1079
+ cmd: NimoProtocol.CMD_GET_PARAMETER, key: NimoProtocol.GET_VERSION_DETAIL
1080
+ )
1081
+ )
1082
+ }
1083
+
1084
+ // MARK: - BLE Scanning
1085
+
1086
+ private func isNimoMainDevice(_ name: String) -> Bool {
1087
+ let lower = name.lowercased()
1088
+ return lower.hasPrefix(NimoBLE.NAME_PREFIX) && !lower.hasSuffix(NimoBLE.BLE_NAME_SUFFIX)
1089
+ }
1090
+
1091
+ @discardableResult
1092
+ private func startScan() -> Bool {
1093
+ Bridge.log("NIMO: startScan()")
1094
+ if centralManager == nil {
1095
+ centralManager = CBCentralManager(
1096
+ delegate: self, queue: Nimo._bluetoothQueue,
1097
+ options: [CBCentralManagerOptionShowPowerAlertKey: 0]
1098
+ )
1099
+ }
1100
+ guard centralManager!.state == .poweredOn else {
1101
+ // centralManagerDidUpdateState retries the scan once the radio is on.
1102
+ Bridge.log("NIMO: Bluetooth not powered on yet")
1103
+ return false
1104
+ }
1105
+
1106
+ // Fast path: reconnect to the cached peripheral UUID.
1107
+ if connectByUUID() {
1108
+ return true
1109
+ }
1110
+
1111
+ centralManager!.scanForPeripherals(
1112
+ withServices: nil,
1113
+ options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
1114
+ )
1115
+ return true
1116
+ }
1117
+
1118
+ private func connectByUUID() -> Bool {
1119
+ guard DEVICE_SEARCH_ID != "NOT_SET", !DEVICE_SEARCH_ID.isEmpty else { return false }
1120
+ guard lastDeviceName == DEVICE_SEARCH_ID,
1121
+ let uuidString = lastDeviceUUID,
1122
+ let uuid = UUID(uuidString: uuidString)
1123
+ else { return false }
1124
+ guard let known = centralManager?.retrievePeripherals(withIdentifiers: [uuid]).first
1125
+ else { return false }
1126
+
1127
+ Bridge.log("NIMO: connectByUUID - \(known.name ?? uuidString)")
1128
+ peripheral = known
1129
+ known.delegate = self
1130
+ centralManager?.connect(known, options: nil)
1131
+ return true
1132
+ }
1133
+
1134
+ private func startPairingTimeout() {
1135
+ cancelPairingTimeout()
1136
+ let item = DispatchWorkItem { [weak self] in
1137
+ guard let self else { return }
1138
+ if !self.fullyBooted {
1139
+ Bridge.log("NIMO: pairing timeout — handshake never completed")
1140
+ Bridge.sendPairFailureEvent("errors:pairNeedDisconnect")
1141
+ }
1142
+ }
1143
+ pairingTimeoutItem = item
1144
+ DispatchQueue.main.asyncAfter(deadline: .now() + Const.pairingTimeoutSeconds, execute: item)
1145
+ }
1146
+
1147
+ private func cancelPairingTimeout() {
1148
+ pairingTimeoutItem?.cancel()
1149
+ pairingTimeoutItem = nil
1150
+ }
1151
+
1152
+ // MARK: - Write Queue
1153
+
1154
+ private func drainWriteQueue() {
1155
+ if writeInFlight { return }
1156
+ guard !writeQueue.isEmpty else { return }
1157
+ guard let peripheral else {
1158
+ writeQueue.removeAll()
1159
+ return
1160
+ }
1161
+ let item = writeQueue.removeFirst()
1162
+ writeInFlight = true
1163
+ peripheral.writeValue(item.bytes, for: item.char, type: .withResponse)
1164
+
1165
+ // Watchdog: if the write callback never arrives, unblock the queue.
1166
+ writeWatchdogItem?.cancel()
1167
+ let watchdog = DispatchWorkItem { [weak self] in
1168
+ guard let self else { return }
1169
+ if self.writeInFlight {
1170
+ Bridge.log("NIMO: write watchdog fired — forcing queue drain")
1171
+ self.writeInFlight = false
1172
+ self.drainWriteQueue()
1173
+ }
1174
+ }
1175
+ writeWatchdogItem = watchdog
1176
+ DispatchQueue.main.asyncAfter(deadline: .now() + Const.writeWatchdogSeconds, execute: watchdog)
1177
+ }
1178
+
1179
+ private func onWriteCompleted() {
1180
+ DispatchQueue.main.asyncAfter(
1181
+ deadline: .now() + .milliseconds(NimoBLE.INTER_FRAME_DELAY_MS)
1182
+ ) { [weak self] in
1183
+ guard let self else { return }
1184
+ self.writeInFlight = false
1185
+ self.drainWriteQueue()
1186
+ }
1187
+ }
1188
+
1189
+ private func enqueueWrite(_ char: CBCharacteristic, _ bytes: Data) {
1190
+ writeQueue.append(QueuedWrite(char: char, bytes: bytes))
1191
+ drainWriteQueue()
1192
+ }
1193
+
1194
+ private func sendFrame(_ frame: Data) {
1195
+ guard let txChar else { return }
1196
+ enqueueWrite(txChar, frame)
1197
+ }
1198
+
1199
+ private func enqueueFrames(_ frames: [Data]) {
1200
+ guard let txChar else { return }
1201
+ for frame in frames {
1202
+ writeQueue.append(QueuedWrite(char: txChar, bytes: frame))
1203
+ }
1204
+ drainWriteQueue()
1205
+ }
1206
+
1207
+ // MARK: - Pending ACKs
1208
+
1209
+ private func sendAwaitingAck(
1210
+ cmd: Int, key: Int, payload: Data, onResult: @escaping (Bool) -> Void
1211
+ ) {
1212
+ let ackKey = (cmd << 8) | key
1213
+ // Only one in-flight ack per (cmd,key); fail any previous waiter.
1214
+ if let previous = pendingAcks.removeValue(forKey: ackKey) {
1215
+ previous.timeoutItem.cancel()
1216
+ previous.onResult(false)
1217
+ }
1218
+ let timeoutItem = DispatchWorkItem { [weak self] in
1219
+ guard let self else { return }
1220
+ self.pendingAcks.removeValue(forKey: ackKey)?.onResult(false)
1221
+ }
1222
+ pendingAcks[ackKey] = PendingAck(onResult: onResult, timeoutItem: timeoutItem)
1223
+ DispatchQueue.main.asyncAfter(deadline: .now() + Const.ackTimeoutSeconds, execute: timeoutItem)
1224
+ sendFrame(NimoFrameCodec.encodeFrame(cmd: cmd, key: key, payload: payload, needsAck: true))
1225
+ }
1226
+
1227
+ private func resolvePendingAck(cmd: Int, key: Int, success: Bool) {
1228
+ let ackKey = (cmd << 8) | key
1229
+ if let ack = pendingAcks.removeValue(forKey: ackKey) {
1230
+ ack.timeoutItem.cancel()
1231
+ ack.onResult(success)
1232
+ }
1233
+ }
1234
+
1235
+ private func failAllPendingAcks() {
1236
+ let acks = pendingAcks.values
1237
+ pendingAcks.removeAll()
1238
+ for ack in acks {
1239
+ ack.timeoutItem.cancel()
1240
+ ack.onResult(false)
1241
+ }
1242
+ }
1243
+
1244
+ // MARK: - Handshake
1245
+
1246
+ private func startHandshake() {
1247
+ Bridge.log("NIMO: starting handshake (awaiting TWS service-connection state)")
1248
+ handshakeState = .awaitingTws
1249
+ if twsConnected {
1250
+ proceedToTimeSync()
1251
+ return
1252
+ }
1253
+ cancelTwsTimeout()
1254
+ let item = DispatchWorkItem { [weak self] in
1255
+ guard let self else { return }
1256
+ if self.handshakeState == .awaitingTws {
1257
+ Bridge.log("NIMO: TWS state timeout — handshake failed, will reconnect")
1258
+ self.handshakeFailed()
1259
+ }
1260
+ }
1261
+ twsTimeoutItem = item
1262
+ DispatchQueue.main.asyncAfter(deadline: .now() + Const.twsTimeoutSeconds, execute: item)
1263
+ // Also actively query in case the glasses don't push the report unprompted.
1264
+ ping()
1265
+ }
1266
+
1267
+ private func cancelTwsTimeout() {
1268
+ twsTimeoutItem?.cancel()
1269
+ twsTimeoutItem = nil
1270
+ }
1271
+
1272
+ private func proceedToTimeSync() {
1273
+ guard handshakeState == .awaitingTws else { return }
1274
+ cancelTwsTimeout()
1275
+ handshakeState = .awaitingTimeAck
1276
+ Bridge.log("NIMO: TWS OK — sending setTime (awaiting ACK)")
1277
+ sendAwaitingAck(
1278
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
1279
+ key: NimoProtocol.SET_TIME,
1280
+ payload: NimoFrameCodec.encodeDeviceTime()
1281
+ ) { [weak self] ok in
1282
+ guard let self else { return }
1283
+ if !ok {
1284
+ Bridge.log("NIMO: setTime ACK failed/timed out — handshake failed")
1285
+ self.handshakeFailed()
1286
+ } else {
1287
+ self.finishHandshake()
1288
+ }
1289
+ }
1290
+ }
1291
+
1292
+ private func finishHandshake() {
1293
+ Bridge.log("NIMO: handshake complete — fully connected")
1294
+ handshakeState = .ready
1295
+ sendFrame(
1296
+ NimoFrameCodec.encodeFrame(
1297
+ cmd: NimoProtocol.CMD_SET_PARAMETER,
1298
+ key: NimoProtocol.SET_PHONE_TYPE,
1299
+ payload: Data([UInt8(NimoProtocol.PHONE_TYPE_IOS)]),
1300
+ needsAck: false
1301
+ )
1302
+ )
1303
+ getBatteryStatus()
1304
+ requestVersionInfo()
1305
+
1306
+ cancelPairingTimeout()
1307
+ Task { await reconnectionManager.stop() }
1308
+ DeviceStore.shared.apply("glasses", "connected", true)
1309
+ DeviceStore.shared.apply("glasses", "fullyBooted", true)
1310
+ DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.CONNECTED)
1311
+ startTimers()
1312
+ }
1313
+
1314
+ private func handshakeFailed() {
1315
+ handshakeState = .idle
1316
+ if let peripheral {
1317
+ centralManager?.cancelPeripheralConnection(peripheral)
1318
+ }
1319
+ // The didDisconnectPeripheral path handles cleanup + reconnection.
1320
+ }
1321
+
1322
+ private func resetSessionState() {
1323
+ handshakeState = .idle
1324
+ twsConnected = false
1325
+ textAppEntered = false
1326
+ navAppEntered = false
1327
+ currentGlassesAppId = -1
1328
+ pendingText = nil
1329
+ receiveAssembler.reset()
1330
+ writeQueue.removeAll()
1331
+ writeInFlight = false
1332
+ writeWatchdogItem?.cancel()
1333
+ writeWatchdogItem = nil
1334
+ failAllPendingAcks()
1335
+ stopTimers()
1336
+ }
1337
+
1338
+ private func startReconnectionTimer() {
1339
+ Task {
1340
+ await reconnectionManager.start { [weak self] in
1341
+ guard let self else { return false }
1342
+ if await MainActor.run(body: {
1343
+ DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
1344
+ }) {
1345
+ return true
1346
+ }
1347
+ Bridge.log("NIMO: Attempting reconnection...")
1348
+ await MainActor.run {
1349
+ self.isDisconnecting = false
1350
+ self.startScan()
1351
+ }
1352
+ return false
1353
+ }
1354
+ }
1355
+ }
1356
+
1357
+ // MARK: - Timers (battery poll keepalive + text queue)
1358
+
1359
+ private func startTimers() {
1360
+ stopTimers()
1361
+ // The battery poll doubles as a keepalive so iOS bluetoothd never sees the link
1362
+ // as unused and reclaims it.
1363
+ batteryPollTimer = Timer.scheduledTimer(
1364
+ withTimeInterval: Const.batteryPollSeconds, repeats: true
1365
+ ) { [weak self] _ in
1366
+ Task { @MainActor in
1367
+ self?.getBatteryStatus()
1368
+ }
1369
+ }
1370
+ textQueueTimer = Timer.scheduledTimer(
1371
+ withTimeInterval: Const.textQueueTickSeconds, repeats: true
1372
+ ) { [weak self] _ in
1373
+ Task { @MainActor in
1374
+ self?.drainTextQueue()
1375
+ }
1376
+ }
1377
+ }
1378
+
1379
+ private func stopTimers() {
1380
+ batteryPollTimer?.invalidate()
1381
+ batteryPollTimer = nil
1382
+ textQueueTimer?.invalidate()
1383
+ textQueueTimer = nil
1384
+ }
1385
+
1386
+ // MARK: - Text Rendering
1387
+
1388
+ private func drainTextQueue() {
1389
+ guard let text = pendingText, handshakeState == .ready else { return }
1390
+ pendingText = nil
1391
+
1392
+ // All text (text_wall / double_text_wall / reference_card / positioned_text) renders on
1393
+ // the ASR note page — the only text surface confirmed to render on hardware.
1394
+ if !textAppEntered {
1395
+ sendFrame(
1396
+ NimoFrameCodec.encodeFrame(
1397
+ cmd: NimoProtocol.CMD_CONTROL_INSTRUCTION,
1398
+ key: NimoProtocol.CTRL_ENTER_APP,
1399
+ payload: Data([UInt8(textAppId), UInt8(NimoProtocol.APP_MODE_STANDALONE)])
1400
+ )
1401
+ )
1402
+ // Optimistic; corrected by app-state reports if the glasses refuse/exit.
1403
+ textAppEntered = true
1404
+ }
1405
+
1406
+ let frames = NimoFrameCodec.updateContentFrames(
1407
+ appId: textAppId,
1408
+ layoutId: textLayoutId,
1409
+ resId: textResId,
1410
+ resType: NimoProtocol.WIDGET_TEXT_NEW,
1411
+ content: Data(text.utf8)
1412
+ )
1413
+ enqueueFrames(frames)
1414
+ }
1415
+
1416
+ // MARK: - Incoming Data
1417
+
1418
+ private func handleRxPacket(_ packet: Data) {
1419
+ receiveAssembler.cleanup()
1420
+ for frame in receiveAssembler.ingest(packet) {
1421
+ guard let decoded = NimoFrameCodec.decode(frame),
1422
+ let cmd = decoded.cmd, let key = decoded.key
1423
+ else { continue }
1424
+ if cmd == NimoProtocol.CMD_INSTRUCTION_REPORT {
1425
+ handleReport(key: key, data: decoded.data ?? Data())
1426
+ } else {
1427
+ handleResponse(
1428
+ cmd: cmd, key: key, statusCode: decoded.statusCode ?? 1,
1429
+ data: decoded.data ?? Data()
1430
+ )
1431
+ }
1432
+ }
1433
+ }
1434
+
1435
+ private func handleReport(key: Int, data: Data) {
1436
+ switch key {
1437
+ case NimoProtocol.REPORT_INPUT:
1438
+ if !data.isEmpty {
1439
+ handleInputEvent(Int(data[data.startIndex]))
1440
+ }
1441
+ case NimoProtocol.REPORT_APP:
1442
+ if data.count >= 2 {
1443
+ let appId = Int(data[data.startIndex])
1444
+ let phase = Int(data[data.startIndex + 1])
1445
+ Bridge.log("NIMO: app state report appId=\(appId) phase=\(phase)")
1446
+ switch phase {
1447
+ case NimoProtocol.STATE_ENTER:
1448
+ currentGlassesAppId = appId
1449
+ if appId != textAppId { textAppEntered = false }
1450
+ if appId != NimoProtocol.APP_ID_NAV { navAppEntered = false }
1451
+ case NimoProtocol.STATE_EXIT:
1452
+ if appId == currentGlassesAppId { currentGlassesAppId = -1 }
1453
+ if appId == textAppId { textAppEntered = false }
1454
+ if appId == NimoProtocol.APP_ID_NAV { navAppEntered = false }
1455
+ default:
1456
+ break
1457
+ }
1458
+ }
1459
+ case NimoProtocol.REPORT_TWS:
1460
+ if !data.isEmpty {
1461
+ onTwsState(Int(data[data.startIndex]) >= 1)
1462
+ }
1463
+ case NimoProtocol.REPORT_BUSINESS:
1464
+ handleBusinessReport(data)
1465
+ case NimoProtocol.REPORT_GATT_STATE:
1466
+ break
1467
+ default:
1468
+ Bridge.log("NIMO: unknown report key=\(key)")
1469
+ }
1470
+ }
1471
+
1472
+ private func handleBusinessReport(_ data: Data) {
1473
+ guard !data.isEmpty else { return }
1474
+ let bytes = [UInt8](data)
1475
+ let id = Int(bytes[0])
1476
+ let v = Array(bytes.dropFirst())
1477
+ switch id {
1478
+ case NimoProtocol.BUSINESS_HEARTBEAT:
1479
+ // [leftMv(2)][rightMv(2)][btSysStatus(4)][twsStatus(1)][slaveGatt(1)]
1480
+ if v.count >= 10 {
1481
+ onTwsState(Int(v[8]) >= 1)
1482
+ }
1483
+ case NimoProtocol.BUSINESS_BATTERY:
1484
+ if v.count >= 4 {
1485
+ applyBattery(
1486
+ left: Int(v[0]), right: Int(v[1]),
1487
+ leftCharging: v[2] == 1, rightCharging: v[3] == 1
1488
+ )
1489
+ }
1490
+ default:
1491
+ break
1492
+ }
1493
+ }
1494
+
1495
+ private func onTwsState(_ connected: Bool) {
1496
+ twsConnected = connected
1497
+ if connected, handshakeState == .awaitingTws {
1498
+ proceedToTimeSync()
1499
+ }
1500
+ if !connected, handshakeState == .ready {
1501
+ Bridge.log("NIMO: TWS service dropped mid-session (arm removed/off?)")
1502
+ }
1503
+ }
1504
+
1505
+ private func handleInputEvent(_ code: Int) {
1506
+ let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
1507
+ switch code {
1508
+ case NimoProtocol.INPUT_HEAD_UP:
1509
+ DeviceStore.shared.apply("glasses", "headUp", true)
1510
+ Bridge.sendHeadUp(true)
1511
+ case NimoProtocol.INPUT_HEAD_DOWN:
1512
+ DeviceStore.shared.apply("glasses", "headUp", false)
1513
+ Bridge.sendHeadUp(false)
1514
+ case NimoProtocol.INPUT_CLICK_RIGHT, NimoProtocol.INPUT_CLICK_LEFT:
1515
+ Bridge.sendTouchEvent(
1516
+ deviceModel: DeviceTypes.NIMO, gestureName: "single_tap", timestamp: timestamp
1517
+ )
1518
+ case NimoProtocol.INPUT_DOUBLE_CLICK_RIGHT, NimoProtocol.INPUT_DOUBLE_CLICK_LEFT:
1519
+ Bridge.sendTouchEvent(
1520
+ deviceModel: DeviceTypes.NIMO, gestureName: "double_tap", timestamp: timestamp
1521
+ )
1522
+ case NimoProtocol.INPUT_LONG_PRESS_RIGHT, NimoProtocol.INPUT_LONG_PRESS_LEFT:
1523
+ Bridge.sendTouchEvent(
1524
+ deviceModel: DeviceTypes.NIMO, gestureName: "long_press", timestamp: timestamp
1525
+ )
1526
+ case NimoProtocol.INPUT_TOUCH_PRESS_RIGHT,
1527
+ NimoProtocol.INPUT_TOUCH_RELEASE_RIGHT,
1528
+ NimoProtocol.INPUT_TOUCH_PRESS_LEFT,
1529
+ NimoProtocol.INPUT_TOUCH_RELEASE_LEFT:
1530
+ // Raw press/release transitions are too chatty to forward; taps cover the UX.
1531
+ break
1532
+ default:
1533
+ Bridge.log("NIMO: unknown input event code=\(code)")
1534
+ }
1535
+ }
1536
+
1537
+ private func handleResponse(cmd: Int, key: Int, statusCode: Int, data: Data) {
1538
+ resolvePendingAck(cmd: cmd, key: key, success: statusCode == 0)
1539
+
1540
+ guard cmd == NimoProtocol.CMD_GET_PARAMETER, statusCode == 0 else { return }
1541
+ let bytes = [UInt8](data)
1542
+ switch key {
1543
+ case NimoProtocol.GET_BATTERY:
1544
+ if bytes.count >= 4 {
1545
+ applyBattery(
1546
+ left: Int(bytes[0]), right: Int(bytes[1]),
1547
+ leftCharging: bytes[2] == 1, rightCharging: bytes[3] == 1
1548
+ )
1549
+ }
1550
+ case NimoProtocol.GET_VERSION:
1551
+ if bytes.count >= 4 {
1552
+ let v =
1553
+ UInt32(bytes[0]) | (UInt32(bytes[1]) << 8) | (UInt32(bytes[2]) << 16)
1554
+ | (UInt32(bytes[3]) << 24)
1555
+ let major = (v >> 28) & 0xF
1556
+ let minor = (v >> 21) & 0x7F
1557
+ let micro = (v >> 12) & 0x1FF
1558
+ let build = v & 0xFFF
1559
+ firmwareVersionPacked = "\(major).\(minor).\(micro).\(build)"
1560
+ emitVersionInfo()
1561
+ }
1562
+ case NimoProtocol.GET_VERSION_DETAIL:
1563
+ var end = bytes.count
1564
+ while end > 0, bytes[end - 1] == 0 { end -= 1 }
1565
+ firmwareVersionDetail =
1566
+ String(bytes: bytes[0 ..< end], encoding: .utf8)?
1567
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
1568
+ emitVersionInfo()
1569
+ case NimoProtocol.GET_TWS_STATUS:
1570
+ if !bytes.isEmpty {
1571
+ onTwsState(Int(bytes[0]) >= 1)
1572
+ }
1573
+ default:
1574
+ break
1575
+ }
1576
+ }
1577
+
1578
+ private func applyBattery(left: Int, right: Int, leftCharging: Bool, rightCharging: Bool) {
1579
+ // Two independent arms → report the conservative (lower) level.
1580
+ let level = min(left, right)
1581
+ let charging = leftCharging || rightCharging
1582
+ if level != lastBatteryLevel || charging != lastCharging {
1583
+ lastBatteryLevel = level
1584
+ lastCharging = charging
1585
+ DeviceStore.shared.apply("glasses", "batteryLevel", level)
1586
+ DeviceStore.shared.apply("glasses", "charging", charging)
1587
+ Bridge.sendBatteryStatus(level: level, charging: charging)
1588
+ }
1589
+ }
1590
+
1591
+ private func emitVersionInfo() {
1592
+ let version = firmwareVersionDetail.isEmpty ? firmwareVersionPacked : firmwareVersionDetail
1593
+ guard !version.isEmpty else { return }
1594
+ DeviceStore.shared.apply("glasses", "firmwareVersion", version)
1595
+ Bridge.sendVersionInfo(["firmwareVersion": version])
1596
+ }
1597
+
1598
+ // MARK: - Mic Audio
1599
+
1600
+ private func handleMicPacket(_ data: Data) {
1601
+ guard let packet = NimoAudioParser.parse(data) else { return }
1602
+ let now = Date()
1603
+ switch packet.type {
1604
+ case NimoAudioParser.TYPE_OPUS_RIGHT:
1605
+ lastRightPacketTime = now
1606
+ preferRightMic = true
1607
+ case NimoAudioParser.TYPE_OPUS_LEFT:
1608
+ // Only fall back to the left ear when the right has gone quiet —
1609
+ // forwarding both would duplicate the audio.
1610
+ if preferRightMic,
1611
+ now.timeIntervalSince(lastRightPacketTime) < Const.micSideFallbackSeconds
1612
+ {
1613
+ return
1614
+ }
1615
+ preferRightMic = false
1616
+ default:
1617
+ return
1618
+ }
1619
+
1620
+ guard let opusDecoder else { return }
1621
+ for opusFrame in packet.opusFrames {
1622
+ if let pcm = opusDecoder.decode(opusFrame), !pcm.isEmpty {
1623
+ DeviceManager.shared.handlePcm(pcm)
1624
+ }
1625
+ }
1626
+ }
1627
+
1628
+ // MARK: - Bitmap Helpers
1629
+
1630
+ /// Aspect-fit the image into `width`x`height` on black, then convert to L8 grayscale.
1631
+ private func bitmapToGrayscale(_ image: UIImage, width: Int, height: Int) -> Data? {
1632
+ guard let cgImage = image.cgImage else { return nil }
1633
+ let colorSpace = CGColorSpaceCreateDeviceGray()
1634
+ guard let context = CGContext(
1635
+ data: nil,
1636
+ width: width,
1637
+ height: height,
1638
+ bitsPerComponent: 8,
1639
+ bytesPerRow: width,
1640
+ space: colorSpace,
1641
+ bitmapInfo: CGImageAlphaInfo.none.rawValue
1642
+ ) else { return nil }
1643
+
1644
+ context.setFillColor(gray: 0, alpha: 1)
1645
+ context.fill(CGRect(x: 0, y: 0, width: width, height: height))
1646
+ let scale = min(CGFloat(width) / CGFloat(cgImage.width), CGFloat(height) / CGFloat(cgImage.height))
1647
+ let dw = CGFloat(cgImage.width) * scale
1648
+ let dh = CGFloat(cgImage.height) * scale
1649
+ let rect = CGRect(
1650
+ x: (CGFloat(width) - dw) / 2, y: (CGFloat(height) - dh) / 2, width: dw, height: dh
1651
+ )
1652
+ context.interpolationQuality = .medium
1653
+ context.draw(cgImage, in: rect)
1654
+
1655
+ guard let buffer = context.data else { return nil }
1656
+ // CGContext rows are top-down here; copy row by row in case bytesPerRow != width.
1657
+ // Invert luminance (255 - value): Glassly app bitmaps follow the platform convention
1658
+ // of dark content on a light background, but Nimo's additive display lights up high
1659
+ // 2bpp values (3 = white). Without inverting, the background lights the whole lens and
1660
+ // the content stays dark — the inverted look. Flipping renders content lit on a dark lens.
1661
+ var gray = Data(capacity: width * height)
1662
+ let bytesPerRow = context.bytesPerRow
1663
+ let base = buffer.assumingMemoryBound(to: UInt8.self)
1664
+ for row in 0 ..< height {
1665
+ let rowOffset = row * bytesPerRow
1666
+ for col in 0 ..< width {
1667
+ gray.append(255 - base[rowOffset + col])
1668
+ }
1669
+ }
1670
+ return gray
1671
+ }
1672
+
1673
+ /// L8 → 2bpp: thresholds 0x40/0x80/0xC0, 4 pixels per byte, MSB first.
1674
+ private func packL8To2bpp(_ l8: Data) -> Data {
1675
+ let bytes = [UInt8](l8)
1676
+ let outBytes = (bytes.count + 3) >> 2
1677
+ var dst = Data(count: outBytes)
1678
+ for i in 0 ..< outBytes {
1679
+ var packed: UInt8 = 0
1680
+ for j in 0 ..< 4 {
1681
+ let idx = i * 4 + j
1682
+ var value: UInt8 = 0
1683
+ if idx < bytes.count {
1684
+ let px = bytes[idx]
1685
+ if px < 0x40 {
1686
+ value = 0
1687
+ } else if px < 0x80 {
1688
+ value = 1
1689
+ } else if px < 0xC0 {
1690
+ value = 2
1691
+ } else {
1692
+ value = 3
1693
+ }
1694
+ }
1695
+ packed |= (value & 0x03) << UInt8(6 - 2 * j)
1696
+ }
1697
+ dst[i] = packed
1698
+ }
1699
+ return dst
1700
+ }
1701
+
1702
+ /// zlib-compress (RFC1950); falls back to uncompressed when not smaller.
1703
+ /// Apple's Compression framework emits raw deflate, so the 2-byte zlib header
1704
+ /// and adler32 trailer the firmware expects are added manually.
1705
+ private func compressAdaptive(_ data: Data) -> (Data, Int) {
1706
+ guard !data.isEmpty,
1707
+ let compressed = BinaryCodec.zlibRFC1950(data),
1708
+ compressed.count < data.count
1709
+ else { return (data, NimoProtocol.COMPRESSION_NONE) }
1710
+ return (compressed, NimoProtocol.COMPRESSION_ZLIB)
1711
+ }
1712
+ }
1713
+
1714
+ // MARK: - CBCentralManagerDelegate
1715
+
1716
+ extension Nimo: CBCentralManagerDelegate {
1717
+ nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
1718
+ let state = central.state
1719
+ DispatchQueue.main.async { [weak self] in
1720
+ guard let self else { return }
1721
+ Bridge.log("NIMO: Bluetooth state: \(state.rawValue)")
1722
+ if state == .poweredOn {
1723
+ self.startScan()
1724
+ }
1725
+ }
1726
+ }
1727
+
1728
+ nonisolated func centralManager(
1729
+ _ central: CBCentralManager,
1730
+ didDiscover peripheral: CBPeripheral,
1731
+ advertisementData: [String: Any],
1732
+ rssi: NSNumber
1733
+ ) {
1734
+ guard
1735
+ let name = peripheral.name
1736
+ ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String
1737
+ else { return }
1738
+ let rssiValue = rssi.intValue
1739
+
1740
+ DispatchQueue.main.async { [weak self] in
1741
+ guard let self else { return }
1742
+ guard self.isNimoMainDevice(name) else { return }
1743
+
1744
+ Bridge.sendDiscoveredDevice(DeviceTypes.NIMO, name, rssi: rssiValue)
1745
+
1746
+ guard self.DEVICE_SEARCH_ID != "NOT_SET" else { return }
1747
+ guard name == self.DEVICE_SEARCH_ID else { return }
1748
+ guard self.peripheral == nil else { return }
1749
+
1750
+ Bridge.log("NIMO: Connecting to \(name)")
1751
+ self.stopScan()
1752
+ self.lastDeviceName = name
1753
+ self.lastDeviceUUID = peripheral.identifier.uuidString
1754
+ self.peripheral = peripheral
1755
+ peripheral.delegate = self
1756
+ central.connect(peripheral, options: nil)
1757
+ }
1758
+ }
1759
+
1760
+ nonisolated func centralManager(_: CBCentralManager, didConnect peripheral: CBPeripheral) {
1761
+ DispatchQueue.main.async { [weak self] in
1762
+ guard let self else { return }
1763
+ Bridge.log("NIMO: Connected to \(peripheral.name ?? "unknown")")
1764
+ self.lastDeviceUUID = peripheral.identifier.uuidString
1765
+ peripheral.discoverServices([NimoBLE.SERVICE_UUID])
1766
+ }
1767
+ }
1768
+
1769
+ nonisolated func centralManager(
1770
+ _: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?
1771
+ ) {
1772
+ DispatchQueue.main.async { [weak self] in
1773
+ guard let self else { return }
1774
+ Bridge.log(
1775
+ "NIMO: Failed to connect \(peripheral.name ?? "unknown"): \(error?.localizedDescription ?? "unknown")"
1776
+ )
1777
+ self.peripheral = nil
1778
+ self.startReconnectionTimer()
1779
+ }
1780
+ }
1781
+
1782
+ nonisolated func centralManager(
1783
+ _: CBCentralManager, didDisconnectPeripheral _: CBPeripheral, error: Error?
1784
+ ) {
1785
+ DispatchQueue.main.async { [weak self] in
1786
+ guard let self else { return }
1787
+ Bridge.log("NIMO: Disconnected: \(error?.localizedDescription ?? "clean")")
1788
+ if self.isDisconnecting { return }
1789
+
1790
+ self.peripheral = nil
1791
+ self.txChar = nil
1792
+ self.rxChar = nil
1793
+ self.micChar = nil
1794
+ self.resetSessionState()
1795
+
1796
+ DeviceStore.shared.apply("glasses", "connected", false)
1797
+ DeviceStore.shared.apply("glasses", "fullyBooted", false)
1798
+ DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
1799
+ self.startReconnectionTimer()
1800
+ }
1801
+ }
1802
+ }
1803
+
1804
+ // MARK: - CBPeripheralDelegate
1805
+
1806
+ extension Nimo: CBPeripheralDelegate {
1807
+ nonisolated func peripheral(_ peripheral: CBPeripheral, didDiscoverServices _: Error?) {
1808
+ guard let services = peripheral.services else { return }
1809
+ for service in services where service.uuid == NimoBLE.SERVICE_UUID {
1810
+ peripheral.discoverCharacteristics(nil, for: service)
1811
+ }
1812
+ }
1813
+
1814
+ nonisolated func peripheral(
1815
+ _ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error _: Error?
1816
+ ) {
1817
+ guard service.uuid == NimoBLE.SERVICE_UUID,
1818
+ let characteristics = service.characteristics
1819
+ else { return }
1820
+
1821
+ DispatchQueue.main.async { [weak self] in
1822
+ guard let self else { return }
1823
+ for char in characteristics {
1824
+ switch char.uuid {
1825
+ case NimoBLE.CHAR_TX:
1826
+ self.txChar = char
1827
+ case NimoBLE.CHAR_RX:
1828
+ self.rxChar = char
1829
+ peripheral.setNotifyValue(true, for: char)
1830
+ case NimoBLE.CHAR_MIC:
1831
+ self.micChar = char
1832
+ peripheral.setNotifyValue(true, for: char)
1833
+ default:
1834
+ break
1835
+ }
1836
+ }
1837
+ Bridge.log(
1838
+ "NIMO: chars tx=\(self.txChar != nil) rx=\(self.rxChar != nil) mic=\(self.micChar != nil)"
1839
+ )
1840
+ }
1841
+ }
1842
+
1843
+ nonisolated func peripheral(
1844
+ _: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic,
1845
+ error: Error?
1846
+ ) {
1847
+ DispatchQueue.main.async { [weak self] in
1848
+ guard let self else { return }
1849
+ if let error {
1850
+ Bridge.log("NIMO: notify enable failed: \(error.localizedDescription)")
1851
+ return
1852
+ }
1853
+ // RX notifications live → the data channel is usable; run the handshake.
1854
+ if characteristic.uuid == NimoBLE.CHAR_RX, self.handshakeState == .idle {
1855
+ self.startHandshake()
1856
+ }
1857
+ }
1858
+ }
1859
+
1860
+ nonisolated func peripheral(
1861
+ _: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?
1862
+ ) {
1863
+ guard let data = characteristic.value, error == nil else { return }
1864
+ let uuid = characteristic.uuid
1865
+ DispatchQueue.main.async { [weak self] in
1866
+ guard let self else { return }
1867
+ if uuid == NimoBLE.CHAR_RX {
1868
+ self.handleRxPacket(data)
1869
+ } else if uuid == NimoBLE.CHAR_MIC {
1870
+ self.handleMicPacket(data)
1871
+ }
1872
+ }
1873
+ }
1874
+
1875
+ nonisolated func peripheral(
1876
+ _: CBPeripheral, didWriteValueFor _: CBCharacteristic, error: Error?
1877
+ ) {
1878
+ DispatchQueue.main.async { [weak self] in
1879
+ guard let self else { return }
1880
+ if let error {
1881
+ Bridge.log("NIMO: Write error: \(error.localizedDescription)")
1882
+ }
1883
+ self.onWriteCompleted()
1884
+ }
1885
+ }
1886
+ }