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