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