From 29123201ea3ad49142a0ec7d89dccc483a82159a Mon Sep 17 00:00:00 2001 From: 1okko <15377594951@189.cn> Date: Wed, 7 Oct 2026 11:46:04 +0800 Subject: [PATCH] Sync master-dev #2036 (2026-10-06) Apply the commaai/openpilot -> sunnypilot master sync while preserving c3xl-dev customizations: bundled CD210 startup, model mirror fallback, pyserial launch fix, hidden UI controls, vendored tinygrad, and local vehicle changes. Keep the four modeld_v2 warp pkls; skip the 740MB chestnut big-model artifact. --- .github/workflows/compile-warp-kernels.yaml | 73 ++ .gitignore | 2 + SConstruct | 4 +- docs/CARS.md | 14 +- docs/README.md | 4 +- launch_chffrplus.sh | 20 +- openpilot/cereal/log.capnp | 1 + openpilot/cereal/services.py | 3 +- openpilot/common/esim/base.py | 7 + openpilot/common/esim/lpa.py | 2 +- openpilot/common/file_chunker.py | 15 +- openpilot/common/hardware/base.py | 3 + .../common/hardware/comma/power_monitor.py | 13 +- openpilot/common/params_keys.h | 2 +- openpilot/common/qrcode.py | 591 +++++++++- openpilot/common/tests/fixtures/qrcode_0.npz | Bin 0 -> 67335 bytes openpilot/common/tests/fixtures/qrcode_1.npz | Bin 0 -> 64985 bytes openpilot/common/tests/fixtures/qrcode_2.npz | Bin 0 -> 65540 bytes openpilot/common/tests/fixtures/qrcode_3.npz | Bin 0 -> 65135 bytes .../common/tests/fixtures/qrcode_eci.npz | Bin 0 -> 1810 bytes openpilot/common/tests/test_qrcode.py | 178 +++ openpilot/selfdrive/assets/prep-svg.sh | 4 +- openpilot/selfdrive/car/card.py | 3 - openpilot/selfdrive/locationd/locationd.py | 12 +- openpilot/selfdrive/modeld/SConscript | 164 +-- openpilot/selfdrive/modeld/compile_dm_warp.py | 56 - openpilot/selfdrive/modeld/compile_modeld.py | 332 ------ .../selfdrive/modeld/dmonitoringmodeld.py | 28 +- .../selfdrive/modeld/get_model_metadata.py | 55 - openpilot/selfdrive/modeld/helpers.py | 36 +- openpilot/selfdrive/modeld/modeld.py | 176 ++- openpilot/selfdrive/pandad/__init__.py | 4 +- openpilot/selfdrive/pandad/pandad.cc | 4 +- openpilot/selfdrive/selfdrived/selfdrived.py | 7 +- openpilot/selfdrive/test/chestnut.sh | 6 + .../test/process_replay/model_replay.py | 13 +- .../test/process_replay/process_replay.py | 3 +- openpilot/selfdrive/test/scons_build_test.sh | 4 +- openpilot/selfdrive/test/setup_device_ci.sh | 39 +- openpilot/selfdrive/test/test_onroad.py | 44 +- openpilot/selfdrive/test/test_power_draw.py | 21 +- openpilot/selfdrive/ui/lib/prime_state.py | 4 + openpilot/selfdrive/ui/mici/layouts/home.py | 56 +- openpilot/selfdrive/ui/mici/layouts/main.py | 2 +- .../ui/mici/layouts/offroad_alerts.py | 36 +- .../ui/mici/layouts/settings/developer.py | 39 +- .../ui/mici/layouts/settings/device.py | 17 +- .../mici/layouts/settings/network/__init__.py | 51 +- .../mici/layouts/settings/network/esim_ui.py | 412 +++++++ .../settings/network/network_layout.py | 38 +- .../ui/mici/layouts/settings/settings.py | 3 +- .../ui/mici/layouts/settings/software.py | 7 +- .../ui/mici/layouts/settings/toggles.py | 35 +- openpilot/selfdrive/ui/mici/widgets/button.py | 92 +- openpilot/selfdrive/ui/mici/widgets/dialog.py | 37 +- .../settings/vehicle/platform_selector.py | 2 +- .../ui/translations/auto_translate.sh | 2 +- openpilot/sunnypilot/mapd/mapd_installer.py | 3 - .../sunnypilot/modeld_v2/compile_modeld.py | 314 +++-- .../sunnypilot/modeld_v2/compile_modeld_v2.py | 11 + .../modeld_v2/get_model_metadata.py | 51 +- .../sunnypilot/modeld_v2/model_adapters.py | 224 ++++ openpilot/sunnypilot/modeld_v2/modeld.py | 156 +-- .../big_driving_warp_1344x760_tinygrad.pkl | Bin 0 -> 872573 bytes .../big_driving_warp_1928x1208_tinygrad.pkl | Bin 0 -> 872317 bytes .../models/driving_warp_1344x760_tinygrad.pkl | Bin 0 -> 838183 bytes .../driving_warp_1928x1208_tinygrad.pkl | Bin 0 -> 837991 bytes .../modeld_v2/stock_dependencies.py | 184 +++ .../sunnypilot/modeld_v2/tests/helpers.py | 21 +- .../tests/test_combined_pkl_loader.py | 36 +- .../modeld_v2/tests/test_compile_modeld.py | 4 +- .../modeld_v2/tests/test_fallback.py | 2 +- openpilot/sunnypilot/models/fetcher.py | 4 +- openpilot/sunnypilot/models/helpers.py | 2 +- openpilot/sunnypilot/models/model_name.py | 4 +- openpilot/sunnypilot/models/tests/model_hash | 2 +- .../sunnypilot/selfdrive/car/interfaces.py | 9 - .../system/camerad/test/stress_restart.sh | 4 +- openpilot/system/hardware/chestnut/flash.py | 6 +- .../system/hardware/chestnut/monitoring.py | 53 + openpilot/system/hardware/chestnut/status.py | 2 +- openpilot/system/hardware/hardwared.py | 11 +- openpilot/system/manager/manager.py | 4 +- openpilot/system/manager/process.py | 4 +- openpilot/system/sentry.py | 143 --- openpilot/system/tombstoned.py | 22 +- openpilot/system/ui/lib/cellular_manager.py | 157 +++ openpilot/system/ui/lib/wifi_manager.py | 3 +- openpilot/system/ui/widgets/__init__.py | 19 + openpilot/system/ui/widgets/scroller.py | 10 +- openpilot/tools/cabana/.gitignore | 8 +- openpilot/tools/cabana/README.md | 40 +- openpilot/tools/cabana/SConscript | 148 +-- openpilot/tools/cabana/assets/assets.qrc | 5 - openpilot/tools/cabana/cabana | 38 - openpilot/tools/cabana/chart/signalselector.h | 30 - openpilot/tools/cabana/chart/sparkline.h | 26 - openpilot/tools/cabana/chart/tiplabel.h | 12 - openpilot/tools/cabana/core/color.h | 1 - openpilot/tools/cabana/core/message_id.h | 17 +- openpilot/tools/cabana/core/settings.h | 5 +- openpilot/tools/cabana/dbc/dbcmanager.cc | 11 +- openpilot/tools/cabana/dbc/dbcmanager.h | 1 + openpilot/tools/cabana/deqt.md | 53 - openpilot/tools/cabana/settings.cc | 6 +- openpilot/tools/cabana/settings.h | 30 +- .../tools/cabana/streams/abstractstream.cc | 6 +- .../tools/cabana/streams/abstractstream.h | 54 +- openpilot/tools/cabana/streams/livestream.cc | 26 +- openpilot/tools/cabana/streams/livestream.h | 1 - .../tools/cabana/streams/replaystream.cc | 121 +- openpilot/tools/cabana/tests/test_cabana.cc | 247 +++- .../tools/cabana/tests/test_cabana_ui.py | 13 + .../tools/cabana/tools/findsimilarbits.cc | 161 --- .../tools/cabana/tools/findsimilarbits.h | 36 - openpilot/tools/cabana/tools/routeinfo.h | 8 - openpilot/tools/cabana/ui/app.cc | 203 ++++ openpilot/tools/cabana/ui/app.h | 23 + openpilot/tools/cabana/ui/chart/chart.cc | 797 +++++++++++++ openpilot/tools/cabana/ui/chart/chart.h | 143 +++ .../tools/cabana/ui/chart/chartswidget.cc | 650 +++++++++++ .../tools/cabana/ui/chart/chartswidget.h | 165 +++ openpilot/tools/cabana/ui/chart/downsample.h | 39 + .../tools/cabana/ui/chart/signalselector.cc | 160 +++ .../tools/cabana/ui/chart/signalselector.h | 46 + openpilot/tools/cabana/ui/chart/sparkline.cc | 162 +++ openpilot/tools/cabana/ui/chart/sparkline.h | 34 + openpilot/tools/cabana/ui/chart/tiplabel.cc | 144 +++ openpilot/tools/cabana/ui/chart/tiplabel.h | 40 + .../tools/cabana/ui/dialogs/filedialog.cc | 224 ++++ .../tools/cabana/ui/dialogs/filedialog.h | 19 + .../tools/cabana/ui/dialogs/messagebox.cc | 87 ++ .../tools/cabana/ui/dialogs/messagebox.h | 19 + .../tools/cabana/ui/dialogs/routesdialog.cc | 274 +++++ .../tools/cabana/ui/dialogs/routesdialog.h | 55 + .../tools/cabana/ui/dialogs/settingsdialog.cc | 111 ++ .../tools/cabana/ui/dialogs/settingsdialog.h | 24 + .../tools/cabana/ui/dialogs/streamselector.cc | 329 ++++++ .../tools/cabana/ui/dialogs/streamselector.h | 106 ++ openpilot/tools/cabana/ui/dropdown.h | 142 +++ openpilot/tools/cabana/ui/helpoverlay.cc | 199 ++++ openpilot/tools/cabana/ui/helpoverlay.h | 29 + openpilot/tools/cabana/ui/icons.h | 40 + openpilot/tools/cabana/ui/inistate.cc | 152 +++ openpilot/tools/cabana/ui/inistate.h | 28 + .../tools/cabana/{cabana.cc => ui/main.cc} | 97 +- openpilot/tools/cabana/ui/mainwin.cc | 944 +++++++++++++++ openpilot/tools/cabana/ui/mainwin.h | 136 +++ openpilot/tools/cabana/ui/qtstate.cc | 171 +++ openpilot/tools/cabana/ui/qtstate.h | 38 + openpilot/tools/cabana/ui/theme.cc | 180 +++ openpilot/tools/cabana/ui/theme.h | 57 + openpilot/tools/cabana/ui/threadpool.h | 87 ++ openpilot/tools/cabana/ui/tools/findsignal.cc | 330 ++++++ openpilot/tools/cabana/ui/tools/findsignal.h | 59 + .../tools/cabana/ui/tools/findsimilarbits.cc | 180 +++ .../tools/cabana/ui/tools/findsimilarbits.h | 40 + openpilot/tools/cabana/ui/tools/routeinfo.cc | 52 + openpilot/tools/cabana/ui/tools/routeinfo.h | 14 + openpilot/tools/cabana/ui/tools/tooldialog.h | 52 + openpilot/tools/cabana/ui/util.cc | 674 +++++++++++ openpilot/tools/cabana/ui/util.h | 204 ++++ .../tools/cabana/ui/widgets/binaryview.cc | 550 +++++++++ .../tools/cabana/ui/widgets/binaryview.h | 98 ++ .../tools/cabana/ui/widgets/cameraview.cc | 189 +++ .../tools/cabana/ui/widgets/cameraview.h | 124 ++ .../tools/cabana/ui/widgets/detailwidget.cc | 415 +++++++ .../tools/cabana/ui/widgets/detailwidget.h | 114 ++ .../tools/cabana/ui/widgets/historylog.cc | 311 +++++ .../tools/cabana/ui/widgets/historylog.h | 59 + .../tools/cabana/ui/widgets/messagebytes.cc | 63 + .../tools/cabana/ui/widgets/messagebytes.h | 20 + .../tools/cabana/ui/widgets/messageswidget.cc | 515 +++++++++ .../tools/cabana/ui/widgets/messageswidget.h | 93 ++ .../cabana/ui/widgets/scrollabletabbar.cc | 87 ++ .../cabana/ui/widgets/scrollabletabbar.h | 8 + .../tools/cabana/ui/widgets/signalview.cc | 1019 +++++++++++++++++ .../tools/cabana/ui/widgets/signalview.h | 204 ++++ openpilot/tools/cabana/ui/widgets/tabbar.cc | 92 ++ openpilot/tools/cabana/ui/widgets/tabbar.h | 45 + .../tools/cabana/ui/widgets/videowidget.cc | 608 ++++++++++ .../tools/cabana/ui/widgets/videowidget.h | 121 ++ openpilot/tools/cabana/utils/elidedlabel.cc | 29 - openpilot/tools/cabana/utils/elidedlabel.h | 25 - openpilot/tools/cabana/utils/util.cc | 1 - openpilot/tools/cabana/utils/util.h | 216 +--- openpilot/tools/lib/auth.py | 101 +- openpilot/tools/lib/auth_config.py | 3 +- openpilot/tools/lib/file_downloader.py | 10 +- openpilot/tools/lib/route.py | 3 +- openpilot/tools/replay/py_downloader.cc | 108 +- openpilot/tools/replay/py_downloader.h | 3 + openpilot/tools/replay/replay.cc | 18 +- openpilot/tools/replay/replay.h | 2 + openpilot/tools/replay/seg_mgr.cc | 33 +- openpilot/tools/replay/seg_mgr.h | 10 + openpilot/tools/sim/launch_openpilot.sh | 2 +- openpilot/tools/sim/lib/camerad.py | 28 +- pyproject.toml | 3 +- scripts/apply-pr.sh | 4 +- scripts/checkout-pr.sh | 8 +- scripts/jenkins_loop_test.sh | 34 +- scripts/launch_corolla.sh | 2 +- scripts/lint/check_dependencies.py | 37 + scripts/lint/check_nomerge_comments.sh | 4 +- scripts/lint/check_shebang_format.sh | 6 +- scripts/lint/check_shell.py | 132 +++ scripts/lint/lint.sh | 61 +- scripts/retry.sh | 4 +- tools/op.sh | 73 +- tools/release/README.md | 2 +- tools/release/build_release.sh | 24 +- tools/release/build_stripped.sh | 32 +- tools/release/check-dirty.sh | 2 +- tools/release/check-submodules.sh | 14 +- tools/release/release_files.py | 2 +- tools/scripts/adb_ssh.sh | 4 +- tools/scripts/profiling/clpeak/.gitignore | 1 - tools/scripts/profiling/clpeak/build.sh | 22 - .../profiling/clpeak/run_continuously.patch | 13 - tools/scripts/profiling/palanteer/.gitignore | 2 - tools/scripts/profiling/palanteer/setup.sh | 24 - tools/scripts/profiling/perfetto/.gitignore | 7 - tools/scripts/profiling/perfetto/build.sh | 11 - tools/scripts/profiling/perfetto/copy.sh | 6 - tools/scripts/profiling/perfetto/record.sh | 8 - tools/scripts/profiling/perfetto/server.sh | 6 - tools/scripts/profiling/perfetto/traces.sh | 5 - tools/scripts/profiling/py-spy/profile.sh | 21 - tools/scripts/profiling/snapdragon/.gitignore | 1 - tools/scripts/profiling/snapdragon/README.md | 13 - .../profiling/snapdragon/setup-agnos.sh | 7 - .../profiling/snapdragon/setup-profiler.sh | 14 - tools/setup.sh | 16 +- tools/setup_dependencies.sh | 32 +- tools/test_runner.py | 2 +- uv.lock | 589 +++++----- 237 files changed, 16611 insertions(+), 3068 deletions(-) create mode 100644 .github/workflows/compile-warp-kernels.yaml create mode 100644 openpilot/common/tests/fixtures/qrcode_0.npz create mode 100644 openpilot/common/tests/fixtures/qrcode_1.npz create mode 100644 openpilot/common/tests/fixtures/qrcode_2.npz create mode 100644 openpilot/common/tests/fixtures/qrcode_3.npz create mode 100644 openpilot/common/tests/fixtures/qrcode_eci.npz create mode 100644 openpilot/common/tests/test_qrcode.py delete mode 100755 openpilot/selfdrive/modeld/compile_dm_warp.py delete mode 100755 openpilot/selfdrive/modeld/compile_modeld.py delete mode 100755 openpilot/selfdrive/modeld/get_model_metadata.py create mode 100644 openpilot/selfdrive/test/chestnut.sh create mode 100644 openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py create mode 100644 openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py create mode 100644 openpilot/sunnypilot/modeld_v2/model_adapters.py create mode 100644 openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1344x760_tinygrad.pkl create mode 100644 openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1928x1208_tinygrad.pkl create mode 100644 openpilot/sunnypilot/modeld_v2/models/driving_warp_1344x760_tinygrad.pkl create mode 100644 openpilot/sunnypilot/modeld_v2/models/driving_warp_1928x1208_tinygrad.pkl create mode 100644 openpilot/sunnypilot/modeld_v2/stock_dependencies.py create mode 100644 openpilot/system/hardware/chestnut/monitoring.py delete mode 100644 openpilot/system/sentry.py create mode 100644 openpilot/system/ui/lib/cellular_manager.py delete mode 100644 openpilot/tools/cabana/assets/assets.qrc delete mode 100755 openpilot/tools/cabana/cabana delete mode 100644 openpilot/tools/cabana/chart/signalselector.h delete mode 100644 openpilot/tools/cabana/chart/sparkline.h delete mode 100644 openpilot/tools/cabana/chart/tiplabel.h delete mode 100644 openpilot/tools/cabana/deqt.md create mode 100644 openpilot/tools/cabana/tests/test_cabana_ui.py delete mode 100644 openpilot/tools/cabana/tools/findsimilarbits.cc delete mode 100644 openpilot/tools/cabana/tools/findsimilarbits.h delete mode 100644 openpilot/tools/cabana/tools/routeinfo.h create mode 100644 openpilot/tools/cabana/ui/app.cc create mode 100644 openpilot/tools/cabana/ui/app.h create mode 100644 openpilot/tools/cabana/ui/chart/chart.cc create mode 100644 openpilot/tools/cabana/ui/chart/chart.h create mode 100644 openpilot/tools/cabana/ui/chart/chartswidget.cc create mode 100644 openpilot/tools/cabana/ui/chart/chartswidget.h create mode 100644 openpilot/tools/cabana/ui/chart/downsample.h create mode 100644 openpilot/tools/cabana/ui/chart/signalselector.cc create mode 100644 openpilot/tools/cabana/ui/chart/signalselector.h create mode 100644 openpilot/tools/cabana/ui/chart/sparkline.cc create mode 100644 openpilot/tools/cabana/ui/chart/sparkline.h create mode 100644 openpilot/tools/cabana/ui/chart/tiplabel.cc create mode 100644 openpilot/tools/cabana/ui/chart/tiplabel.h create mode 100644 openpilot/tools/cabana/ui/dialogs/filedialog.cc create mode 100644 openpilot/tools/cabana/ui/dialogs/filedialog.h create mode 100644 openpilot/tools/cabana/ui/dialogs/messagebox.cc create mode 100644 openpilot/tools/cabana/ui/dialogs/messagebox.h create mode 100644 openpilot/tools/cabana/ui/dialogs/routesdialog.cc create mode 100644 openpilot/tools/cabana/ui/dialogs/routesdialog.h create mode 100644 openpilot/tools/cabana/ui/dialogs/settingsdialog.cc create mode 100644 openpilot/tools/cabana/ui/dialogs/settingsdialog.h create mode 100644 openpilot/tools/cabana/ui/dialogs/streamselector.cc create mode 100644 openpilot/tools/cabana/ui/dialogs/streamselector.h create mode 100644 openpilot/tools/cabana/ui/dropdown.h create mode 100644 openpilot/tools/cabana/ui/helpoverlay.cc create mode 100644 openpilot/tools/cabana/ui/helpoverlay.h create mode 100644 openpilot/tools/cabana/ui/icons.h create mode 100644 openpilot/tools/cabana/ui/inistate.cc create mode 100644 openpilot/tools/cabana/ui/inistate.h rename openpilot/tools/cabana/{cabana.cc => ui/main.cc} (65%) create mode 100644 openpilot/tools/cabana/ui/mainwin.cc create mode 100644 openpilot/tools/cabana/ui/mainwin.h create mode 100644 openpilot/tools/cabana/ui/qtstate.cc create mode 100644 openpilot/tools/cabana/ui/qtstate.h create mode 100644 openpilot/tools/cabana/ui/theme.cc create mode 100644 openpilot/tools/cabana/ui/theme.h create mode 100644 openpilot/tools/cabana/ui/threadpool.h create mode 100644 openpilot/tools/cabana/ui/tools/findsignal.cc create mode 100644 openpilot/tools/cabana/ui/tools/findsignal.h create mode 100644 openpilot/tools/cabana/ui/tools/findsimilarbits.cc create mode 100644 openpilot/tools/cabana/ui/tools/findsimilarbits.h create mode 100644 openpilot/tools/cabana/ui/tools/routeinfo.cc create mode 100644 openpilot/tools/cabana/ui/tools/routeinfo.h create mode 100644 openpilot/tools/cabana/ui/tools/tooldialog.h create mode 100644 openpilot/tools/cabana/ui/util.cc create mode 100644 openpilot/tools/cabana/ui/util.h create mode 100644 openpilot/tools/cabana/ui/widgets/binaryview.cc create mode 100644 openpilot/tools/cabana/ui/widgets/binaryview.h create mode 100644 openpilot/tools/cabana/ui/widgets/cameraview.cc create mode 100644 openpilot/tools/cabana/ui/widgets/cameraview.h create mode 100644 openpilot/tools/cabana/ui/widgets/detailwidget.cc create mode 100644 openpilot/tools/cabana/ui/widgets/detailwidget.h create mode 100644 openpilot/tools/cabana/ui/widgets/historylog.cc create mode 100644 openpilot/tools/cabana/ui/widgets/historylog.h create mode 100644 openpilot/tools/cabana/ui/widgets/messagebytes.cc create mode 100644 openpilot/tools/cabana/ui/widgets/messagebytes.h create mode 100644 openpilot/tools/cabana/ui/widgets/messageswidget.cc create mode 100644 openpilot/tools/cabana/ui/widgets/messageswidget.h create mode 100644 openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc create mode 100644 openpilot/tools/cabana/ui/widgets/scrollabletabbar.h create mode 100644 openpilot/tools/cabana/ui/widgets/signalview.cc create mode 100644 openpilot/tools/cabana/ui/widgets/signalview.h create mode 100644 openpilot/tools/cabana/ui/widgets/tabbar.cc create mode 100644 openpilot/tools/cabana/ui/widgets/tabbar.h create mode 100644 openpilot/tools/cabana/ui/widgets/videowidget.cc create mode 100644 openpilot/tools/cabana/ui/widgets/videowidget.h delete mode 100644 openpilot/tools/cabana/utils/elidedlabel.cc delete mode 100644 openpilot/tools/cabana/utils/elidedlabel.h create mode 100644 scripts/lint/check_dependencies.py create mode 100644 scripts/lint/check_shell.py delete mode 100644 tools/scripts/profiling/clpeak/.gitignore delete mode 100755 tools/scripts/profiling/clpeak/build.sh delete mode 100644 tools/scripts/profiling/clpeak/run_continuously.patch delete mode 100644 tools/scripts/profiling/palanteer/.gitignore delete mode 100755 tools/scripts/profiling/palanteer/setup.sh delete mode 100644 tools/scripts/profiling/perfetto/.gitignore delete mode 100755 tools/scripts/profiling/perfetto/build.sh delete mode 100755 tools/scripts/profiling/perfetto/copy.sh delete mode 100755 tools/scripts/profiling/perfetto/record.sh delete mode 100755 tools/scripts/profiling/perfetto/server.sh delete mode 100755 tools/scripts/profiling/perfetto/traces.sh delete mode 100755 tools/scripts/profiling/py-spy/profile.sh delete mode 100644 tools/scripts/profiling/snapdragon/.gitignore delete mode 100644 tools/scripts/profiling/snapdragon/README.md delete mode 100755 tools/scripts/profiling/snapdragon/setup-agnos.sh delete mode 100755 tools/scripts/profiling/snapdragon/setup-profiler.sh diff --git a/.github/workflows/compile-warp-kernels.yaml b/.github/workflows/compile-warp-kernels.yaml new file mode 100644 index 000000000..c61a81803 --- /dev/null +++ b/.github/workflows/compile-warp-kernels.yaml @@ -0,0 +1,73 @@ +name: Compile warp for sunnypilot modeld + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * 0' + +jobs: + compile_warps: + runs-on: [self-hosted, chestnut] + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set environment variables + run: | + source /etc/profile + export UV_PROJECT_ENVIRONMENT=${HOME}/venv + export UV_PYTHON_PREFERENCE=managed + export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python + export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT + uv sync --frozen + printenv >> $GITHUB_ENV + + - name: Disable powersave + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable + + - name: Compile Warp Kernels + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}" + mkdir -p openpilot/sunnypilot/modeld_v2/models/ + MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')") + CAMERA_RES=$(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')") + echo "Model size: $MODEL_SIZE" + echo "Camera resolutions: $CAMERA_RES" + + for res in $CAMERA_RES; do + FRAME_PARAM=$(python3 -c "from openpilot.system.camerad.cameras.nv12_info import get_nv12_info; w, h = map(int, '${res}'.split('x')); stride, y_h, uv_h, _ = get_nv12_info(w, h); print(f'{w},{h},{stride},{y_h},{uv_h},{stride * (y_h + uv_h)}')") + + WARP_PKL="openpilot/sunnypilot/modeld_v2/models/driving_warp_${res}_tinygrad.pkl" + echo "Compiling $WARP_PKL on QCOM" + taskset -c 7 env DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1 python3 tinygrad_repo/examples/openpilot/compile_warp.py \ + --frame ${FRAME_PARAM} --warp-to ${MODEL_SIZE} --layout yuv420 --frames 2 --output "${WARP_PKL}" + + BIG_WARP_PKL="openpilot/sunnypilot/modeld_v2/models/big_driving_warp_${res}_tinygrad.pkl" + echo "Compiling $BIG_WARP_PKL on AMD" + taskset -c 7 env DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32 python3 tinygrad_repo/examples/openpilot/compile_warp.py \ + --frame ${FRAME_PARAM} --warp-to ${MODEL_SIZE} --layout yuv420 --frames 2 --output "${BIG_WARP_PKL}" + done + + - name: Re-enable powersave + if: always() + run: | + source ${UV_PROJECT_ENVIRONMENT}/bin/activate + PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable + + - name: Create Pull Request + uses: peter-evans/create-pull-request@9153d834b60caba6d51c9b9510b087acf9f33f83 + with: + author: github-actions[bot] + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "[bot] Update warp pkl for modeld_v2" + title: "[bot] Update modeld_v2 Warp" + branch: "auto/compile-warp-kernels" + base: "master" + delete-branch: true + labels: bot + add-paths: | + openpilot/sunnypilot/modeld_v2/models/*.pkl diff --git a/.gitignore b/.gitignore index 9dee0af9a..0a0284c42 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,8 @@ st[0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z] *.stats *.pkl *.pkl* +!openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl +!openpilot/sunnypilot/modeld_v2/models/*.pkl config.json compile_commands.json compare_runtime*.html diff --git a/SConstruct b/SConstruct index 4e9dedd94..2437f99d9 100644 --- a/SConstruct +++ b/SConstruct @@ -87,7 +87,6 @@ acados_include_dirs = [ # vendored in commaai/dependencies. allowed_system_libs = { "EGL", "GLESv2", "GL", - "Qt5Charts", "Qt5Core", "Qt5Gui", "Qt5Widgets", "dl", "drm", "gbm", "m", "pthread", } @@ -121,6 +120,7 @@ def _libflags(target, source, env, for_signature): env = Environment( ENV={ "PATH": os.environ['PATH'], + "TMPDIR": os.environ.get('TMPDIR', '/tmp'), "PYTHONPATH": os.pathsep.join(submodule_python_paths), "ACADOS_SOURCE_DIR": acados.DIR, "ACADOS_PYTHON_INTERFACE_PATH": acados.TEMPLATE_DIR, @@ -346,6 +346,8 @@ AddPostAction(BUILD_TARGETS or [Dir('.')], prune_cache_dir) def check_build_product_size(target, source, env): limit = 50 * 1024 * 1024 # GitHub max size for t in target: + if str(t).endswith('.pkl'): # chunked during release packaging + continue if hasattr(t, 'isfile') and t.isfile() and (size := os.path.getsize(t.abspath)) > limit: raise SCons.Errors.UserError(f"{t} is {size / (1024 * 1024):.1f} MiB, exceeding the {limit / (1024 * 1024):.1f} MiB limit") if not GetOption('extras'): diff --git a/docs/CARS.md b/docs/CARS.md index 0866436c0..8397e6e52 100644 --- a/docs/CARS.md +++ b/docs/CARS.md @@ -34,7 +34,7 @@ A supported vehicle is one that just works when you install a comma device. All |Chrysler|Pacifica Hybrid 2019-25|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |comma|body|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|None||| |CUPRA[12](#footnotes)|Ateca 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|CUPRA[12](#footnotes)|Born 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|CUPRA|Born 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW MEB connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Dodge|Durango 2020-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Bronco Sport 2021-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Escape 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -99,7 +99,7 @@ A supported vehicle is one that just works when you install a comma device. All |Honda|Fit 2018-20|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Freed 2020|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|HR-V 2019-22|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Honda|HR-V 2023-25|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|HR-V 2023-27|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Insight 2019-22|All|openpilot available[1,5](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Inspire 2018|All|openpilot available[1,5](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|N-Box 2018|All|openpilot available[1,5](#footnotes)|0 mph|11 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -268,8 +268,8 @@ A supported vehicle is one that just works when you install a comma device. All |Škoda[12](#footnotes)|Superb 2015-22[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Tesla[10](#footnotes)|Model 3 (with HW3) 2019-23[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Tesla[10](#footnotes)|Model 3 (with HW4) 2024-25[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Tesla[10](#footnotes)|Model Y (with HW3) 2020-23[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Tesla[10](#footnotes)|Model Y (with HW4) 2024-25[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Tesla[10](#footnotes)|Model Y (with HW3) 2020-24[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Tesla[10](#footnotes)|Model Y (with HW4) 2023-25[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Toyota|Alphard 2019-20|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Alphard Hybrid 2021|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Avalon 2016|Toyota Safety Sense P|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -335,8 +335,8 @@ A supported vehicle is one that just works when you install a comma device. All |Volkswagen[12](#footnotes)|Golf R 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen[12](#footnotes)|Golf SportsVan 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen[12](#footnotes)|Grand California 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|ID.4 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|ID.4 2024-25|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|ID.4 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW MEB connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|ID.4 2024-25|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW MEB connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen[12](#footnotes)|Jetta 2019-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen[12](#footnotes)|Jetta GLI 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Volkswagen|Passat 2015-22[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| @@ -363,7 +363,7 @@ A supported vehicle is one that just works when you install a comma device. All 6See more setup details for Nissan.
7In the non-US market, openpilot requires the car to come equipped with EyeSight with Lane Keep Assistance.
8Enabling longitudinal control (alpha) will disable all EyeSight functionality, including AEB, LDW, and RAB.
-9Some 2023 model years have HW4. To check which hardware type your vehicle has, look for Autopilot computer under Software -> Additional Vehicle Information on your vehicle's touchscreen. See this page for more information.
+9Model years 2023 and 2024 can have either hardware type, depending on build date and factory. To check which hardware type your vehicle has, look for Autopilot computer under Software -> Additional Vehicle Information on your vehicle's touchscreen. See this page for more information.
10See more setup details for Tesla.
11openpilot operates above 28mph for Camry 4CYL L, 4CYL LE and 4CYL SE which don't have Full-Speed Range Dynamic Radar Cruise Control.
12The J533 harness plugs in at the CAN gateway under the dashboard, just above the steering column. More information can be found at this guide.
diff --git a/docs/README.md b/docs/README.md index d6a0126b3..dc6eee690 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,10 +5,10 @@ The site is updated on pushes to master by this [workflow](../.github/workflows/ **1. Build the site** ``` bash -python docs/serve.py --build +op docs --build ``` **2. Run the site locally** (rebuilds on change) ``` bash -python docs/serve.py +op docs ``` diff --git a/launch_chffrplus.sh b/launch_chffrplus.sh index 88bfedf9f..22288645c 100755 --- a/launch_chffrplus.sh +++ b/launch_chffrplus.sh @@ -18,21 +18,21 @@ function agnos_init { sudo chmod 660 /dev/adsprpc-smd /dev/ion /dev/kgsl-3d0 # Check if AGNOS update is required - if [ $(< /VERSION) != "$AGNOS_VERSION" ]; then + if [ "$(< /VERSION)" != "$AGNOS_VERSION" ]; then AGNOS_PY="$DIR/openpilot/common/hardware/comma/agnos.py" MANIFEST="$DIR/openpilot/system/hardware/comma/agnos.json" - if $AGNOS_PY --verify $MANIFEST; then + if "$AGNOS_PY" --verify "$MANIFEST"; then sudo reboot fi while true; do - $DIR/openpilot/common/hardware/comma/updater $AGNOS_PY $MANIFEST + "$DIR/openpilot/common/hardware/comma/updater" "$AGNOS_PY" "$MANIFEST" done fi } function launch { # Remove orphaned git lock if it exists on boot - [ -f "$DIR/.git/index.lock" ] && rm -f $DIR/.git/index.lock + [ -f "$DIR/.git/index.lock" ] && rm -f "$DIR/.git/index.lock" # Check to see if there's a valid overlay-based update available. Conditions # are as follows: @@ -44,7 +44,7 @@ function launch { # that completed successfully and synced to disk. if [ -f "${DIR}/.overlay_init" ]; then - find ${DIR}/.git -newer ${DIR}/.overlay_init | grep -q '.' 2> /dev/null + find "${DIR}/.git" -newer "${DIR}/.overlay_init" | grep -q '.' 2> /dev/null if [ $? -eq 0 ]; then echo "${DIR} has been modified, skipping overlay update installation" else @@ -53,9 +53,9 @@ function launch { echo "Valid overlay update found, installing" LAUNCHER_LOCATION="${BASH_SOURCE[0]}" - mv $DIR /data/safe_staging/old_openpilot - mv "${STAGING_ROOT}/finalized" $DIR - cd $DIR + mv "$DIR" /data/safe_staging/old_openpilot + mv "${STAGING_ROOT}/finalized" "$DIR" + cd "$DIR" echo "Restarting launch script ${LAUNCHER_LOCATION}" unset AGNOS_VERSION @@ -69,7 +69,7 @@ function launch { fi # handle pythonpath - ln -sfn $(pwd) /data/pythonpath + ln -sfn "$(pwd)" /data/pythonpath export PYTHONPATH="$PWD" # submodule package symlinks for PYTHONPATH imports on device. @@ -124,7 +124,7 @@ function launch { # start manager cd openpilot/system/manager - if [ ! -f $DIR/prebuilt ]; then + if [ ! -f "$DIR/prebuilt" ]; then ./build.py fi ./manager.py diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index 5cee3266e..10d5a925d 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -2594,6 +2594,7 @@ struct Event { clocks @35 :Clocks; deviceState @6 :DeviceState; chestnutState @152 :ChestnutState; + chestnutGpuState @153 :ChestnutState; logMessage @18 :Text; errorLogMessage @85 :Text; diff --git a/openpilot/cereal/services.py b/openpilot/cereal/services.py index ed718506f..78c7bde18 100755 --- a/openpilot/cereal/services.py +++ b/openpilot/cereal/services.py @@ -25,7 +25,8 @@ _services: dict[str, tuple] = { "accelerometer": (True, 104., 104), "temperatureSensor": (True, 2., 200), "deviceState": (True, 2., 1), - "chestnutState": (True, 10., 10), + "chestnutState": (True, 10., 1), + "chestnutGpuState": (False, 10.), "touch": (True, 20., 1), "can": (True, 100., 2053, QueueSize.BIG), # decimation gives ~3 msgs in a full segment "controlsState": (True, 100., 10, QueueSize.MEDIUM), diff --git a/openpilot/common/esim/base.py b/openpilot/common/esim/base.py index b783a630b..472b523e2 100644 --- a/openpilot/common/esim/base.py +++ b/openpilot/common/esim/base.py @@ -21,6 +21,13 @@ class Profile: def is_comma(self) -> bool: return self.provider == 'Webbing' and self.iccid.startswith('8985235') + @property + def display_name(self) -> str: + if self.is_comma: + return "comma prime" + name = self.nickname or self.provider or "" + return f"{name} (...{self.iccid[-4:]})" + class LPABase(ABC): @abstractmethod diff --git a/openpilot/common/esim/lpa.py b/openpilot/common/esim/lpa.py index fb128c32e..600869fcb 100644 --- a/openpilot/common/esim/lpa.py +++ b/openpilot/common/esim/lpa.py @@ -613,7 +613,7 @@ def parse_lpa_activation_code(activation_code: str) -> tuple[str, str]: if not activation_code.startswith("LPA:"): raise ValueError("Invalid activation code format") parts = activation_code[4:].split("$") - if len(parts) != 3: + if len(parts) != 3 or not all(parts): raise ValueError("Invalid activation code format") return parts[1], parts[2] diff --git a/openpilot/common/file_chunker.py b/openpilot/common/file_chunker.py index 2d080c3ff..28a7e1212 100755 --- a/openpilot/common/file_chunker.py +++ b/openpilot/common/file_chunker.py @@ -24,6 +24,7 @@ def chunk_file(path, targets): manifest_path, *chunk_paths = targets actual_num_chunks = max(1, math.ceil(os.path.getsize(path) / CHUNK_SIZE)) assert len(chunk_paths) >= actual_num_chunks, f"Allowed {len(chunk_paths)} chunks but needs at least {actual_num_chunks}, for path {path}" + Path(manifest_path).unlink(missing_ok=True) with open(path, 'rb') as f: for chunk_path in chunk_paths: with open(chunk_path, 'wb') as out: @@ -31,14 +32,6 @@ def chunk_file(path, targets): Path(manifest_path).write_text(str(len(chunk_paths))) os.remove(path) -def get_existing_chunks(path): - if os.path.isfile(path): - return [path] - if os.path.isfile(manifest := get_manifest_path(path)): - num_chunks = int(Path(manifest).read_text().strip()) - return _chunk_paths(path, num_chunks) - raise FileNotFoundError(path) - class ChunkStream(io.RawIOBase): def __init__(self, paths): self._paths = iter(paths) @@ -66,11 +59,11 @@ class ChunkStream(io.RawIOBase): def open_file_chunked(path): manifest_path = get_manifest_path(path) - if os.path.isfile(manifest_path): + if os.path.isfile(path): + paths = [path] + elif os.path.isfile(manifest_path): num_chunks = int(Path(manifest_path).read_text().strip()) paths = [get_chunk_name(path, i, num_chunks) for i in range(num_chunks)] - elif os.path.isfile(path): - paths = [path] else: raise FileNotFoundError(path) return io.BufferedReader(ChunkStream(paths)) diff --git a/openpilot/common/hardware/base.py b/openpilot/common/hardware/base.py index 5d8f7770d..7c8842a0e 100644 --- a/openpilot/common/hardware/base.py +++ b/openpilot/common/hardware/base.py @@ -145,6 +145,9 @@ class HardwareBase(ABC): def get_modem_temperatures(self): return [] + def get_modem_state(self) -> dict: + return {} + def initialize_hardware(self): pass diff --git a/openpilot/common/hardware/comma/power_monitor.py b/openpilot/common/hardware/comma/power_monitor.py index 296290dae..9ae2c9e13 100755 --- a/openpilot/common/hardware/comma/power_monitor.py +++ b/openpilot/common/hardware/comma/power_monitor.py @@ -9,22 +9,25 @@ from openpilot.common.realtime import Ratekeeper from openpilot.common.filter_simple import FirstOrderFilter -def read_power(): +def read_power(panda=None): + if panda is not None and panda.get_type() == panda.HW_TYPE_CUATRO: + health = panda.health() + return health['voltage'] * health['current'] / 1e6 with open("/sys/bus/i2c/devices/0-0040/hwmon/hwmon1/power1_input") as f: return int(f.read()) / 1e6 -def sample_power(seconds=5) -> list[float]: +def sample_power(seconds=5, panda=None) -> list[float]: rate = 123 rk = Ratekeeper(rate, print_delay_threshold=None) pwrs = [] for _ in range(rate*seconds): - pwrs.append(read_power()) + pwrs.append(read_power(panda)) rk.keep_time() return pwrs -def get_power(seconds=5): - pwrs = sample_power(seconds) +def get_power(seconds=5, panda=None): + pwrs = sample_power(seconds, panda) return np.mean(pwrs) def wait_for_power(min_pwr, max_pwr, min_secs_in_range, timeout): diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index e56bf7186..9f5f05ceb 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -29,6 +29,7 @@ inline static std::unordered_map keys = { {"ControlsReady", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"CurrentBootlog", {PERSISTENT, STRING}}, {"CurrentRoute", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, STRING}}, + {"DisableDriverCameraIR", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"DisableLogging", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"DisablePowerDown", {PERSISTENT | BACKUP, BOOL}}, {"DisableUpdates", {PERSISTENT | BACKUP, BOOL, "0"}}, @@ -139,7 +140,6 @@ inline static std::unordered_map keys = { {"UptimeOnroad", {PERSISTENT, FLOAT, "0.0"}}, {"ChestnutActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, {"ChestnutLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, - {"ChestnutModelError", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}}, {"Version", {PERSISTENT, STRING}}, // --- sunnypilot params --- // diff --git a/openpilot/common/qrcode.py b/openpilot/common/qrcode.py index b6b345c22..895daece3 100644 --- a/openpilot/common/qrcode.py +++ b/openpilot/common/qrcode.py @@ -1,16 +1,47 @@ -"""Small QR encoder for the UI's byte-mode, error-correction-level-L codes.""" +"""QR code encoding, decoding, and UI textures.""" + +import functools +import itertools import numpy as np import pyray as rl -# Indexes are QR versions. These are the only two Reed-Solomon parameters needed -# for error-correction level L. -_ECC_LEN = (0, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28) -_NUM_BLOCKS = (0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8) +# (ec codewords per block, block count) for levels L, M, Q, H, versions 1-40 +_EC = [ + ((7, 1), (10, 1), (13, 1), (17, 1)), ((10, 1), (16, 1), (22, 1), (28, 1)), ((15, 1), (26, 1), (18, 2), (22, 2)), + ((20, 1), (18, 2), (26, 2), (16, 4)), ((26, 1), (24, 2), (18, 4), (22, 4)), ((18, 2), (16, 4), (24, 4), (28, 4)), + ((20, 2), (18, 4), (18, 6), (26, 5)), ((24, 2), (22, 4), (22, 6), (26, 6)), ((30, 2), (22, 5), (20, 8), (24, 8)), + ((18, 4), (26, 5), (24, 8), (28, 8)), ((20, 4), (30, 5), (28, 8), (24, 11)), ((24, 4), (22, 8), (26, 10), (28, 11)), + ((26, 4), (22, 9), (24, 12), (22, 16)), ((30, 4), (24, 9), (20, 16), (24, 16)), ((22, 6), (24, 10), (30, 12), (24, 18)), + ((24, 6), (28, 10), (24, 17), (30, 16)), ((28, 6), (28, 11), (28, 16), (28, 19)), ((30, 6), (26, 13), (28, 18), (28, 21)), + ((28, 7), (26, 14), (26, 21), (26, 25)), ((28, 8), (26, 16), (30, 20), (28, 25)), ((28, 8), (26, 17), (28, 23), (30, 25)), + ((28, 9), (28, 17), (30, 23), (24, 34)), ((30, 9), (28, 18), (30, 25), (30, 30)), ((30, 10), (28, 20), (30, 27), (30, 32)), + ((26, 12), (28, 21), (30, 29), (30, 35)), ((28, 12), (28, 23), (28, 34), (30, 37)), ((30, 12), (28, 25), (30, 34), (30, 40)), + ((30, 13), (28, 26), (30, 35), (30, 42)), ((30, 14), (28, 28), (30, 38), (30, 45)), ((30, 15), (28, 29), (30, 40), (30, 48)), + ((30, 16), (28, 31), (30, 43), (30, 51)), ((30, 17), (28, 33), (30, 45), (30, 54)), ((30, 18), (28, 35), (30, 48), (30, 57)), + ((30, 19), (28, 37), (30, 51), (30, 60)), ((30, 19), (28, 38), (30, 53), (30, 63)), ((30, 20), (28, 40), (30, 56), (30, 66)), + ((30, 21), (28, 43), (30, 59), (30, 70)), ((30, 22), (28, 45), (30, 62), (30, 74)), ((30, 24), (28, 47), (30, 65), (30, 77)), + ((30, 25), (28, 49), (30, 68), (30, 81)), +] -# 15 format-info bits for level L (01) with mask 0: ((0x08 << 10) | bch_remainder) ^ 0x5412 -_FORMAT_BITS = 0b111011111000100 +# GF(256) with the QR polynomial x^8 + x^4 + x^3 + x^2 + 1: powers of alpha and their logs +_EXP = [1] +for _ in range(254): + _EXP.append(_EXP[-1] << 1 ^ (0x11D if _EXP[-1] & 0x80 else 0)) +_LOG = {v: i for i, v in enumerate(_EXP)} + + +def _bch_format(data: int) -> int: + v = data << 10 + for shift in range(14, 9, -1): + if v >> shift & 1: + v ^= 0x537 << (shift - 10) + return (data << 10 | v) ^ 0x5412 + + +# 15-bit format info indexed by (level bits << 3 | mask). Level bits: L=01, M=00, Q=11, H=10. +_FORMATS = [_bch_format(d) for d in range(32)] def _raw_modules(version: int) -> int: @@ -21,8 +52,24 @@ def _raw_modules(version: int) -> int: return result - (36 if version >= 7 else 0) +def _block_lengths(version: int, level: int) -> list[int]: + """Data codewords per Reed-Solomon block. The last blocks may be one longer.""" + ec, nblocks = _EC[version - 1][level] + total = _raw_modules(version) // 8 - ec * nblocks + return [total // nblocks + (i >= nblocks - total % nblocks) for i in range(nblocks)] + + +def _interleaved(version: int, level: int) -> list[tuple[int, int]]: + """(block, index within block) of each transmitted codeword: data column-major, then ECC column-major.""" + ec, nblocks = _EC[version - 1][level] + lens = _block_lengths(version, level) + data = [(b, i) for i in range(max(lens)) for b in range(nblocks) if i < lens[b]] + ecc = [(b, lens[b] + i) for i in range(ec) for b in range(nblocks)] + return data + ecc + + def _capacity(version: int) -> int: - return _raw_modules(version) // 8 - _ECC_LEN[version] * _NUM_BLOCKS[version] + return sum(_block_lengths(version, 0)) def _append_bits(bits: list[int], value: int, length: int) -> None: @@ -49,37 +96,18 @@ def _data_codewords(data: bytes, version: int) -> bytes: def _codewords(data: bytes, version: int) -> bytes: """Split data codewords into Reed-Solomon blocks and interleave data + ECC.""" data = _data_codewords(data, version) - num_blocks = _NUM_BLOCKS[version] - ecc_len = _ECC_LEN[version] - raw_codewords = _raw_modules(version) // 8 - short_len = raw_codewords // num_blocks - num_short = num_blocks - raw_codewords % num_blocks - divisor = _divisor(ecc_len) - blocks: list[tuple[bytes, bytes]] = [] + divisor = _divisor(_EC[version - 1][0][0]) + blocks = [] offset = 0 - for i in range(num_blocks): - length = short_len - ecc_len + (0 if i < num_short else 1) + for length in _block_lengths(version, 0): block = data[offset:offset + length] - blocks.append((block, _remainder(block, divisor))) + blocks.append(block + _remainder(block, divisor)) offset += length - result = bytearray() - for i in range(short_len - ecc_len + 1): - for block, _ in blocks: - result.extend(block[i:i + 1]) - for i in range(ecc_len): - for _, ecc in blocks: - result.append(ecc[i]) - return bytes(result) + return bytes(blocks[b][i] for b, i in _interleaved(version, 0)) def _multiply(x: int, y: int) -> int: - result = 0 - for _ in range(8): - result = (result << 1) ^ (0x11D if result & 0x80 else 0) - if y & 0x80: - result ^= x - y <<= 1 - return result + return _EXP[(_LOG[x] + _LOG[y]) % 255] if x and y else 0 def _divisor(degree: int) -> bytes: @@ -108,7 +136,7 @@ def _alignment_positions(version: int) -> list[int]: if version == 1: return [] count = version // 7 + 2 - step = ((version * 4 + count * 2 + 1) // (count * 2 - 2)) * 2 + step = (version * 8 + count * 3 + 5) // (count * 4 - 4) * 2 return [6] + [version * 4 + 10 - step * i for i in range(count - 1)][::-1] @@ -171,7 +199,7 @@ class _Qr: def _format(self) -> None: for i in range(15): - bit = ((_FORMAT_BITS >> i) & 1) != 0 + bit = ((_FORMATS[1 << 3 | 0] >> i) & 1) != 0 # level L, mask 0 y_pos = i if i < 6 else i + 1 if i < 8 else self.size - 15 + i self._set_function(8, y_pos, bit) x_pos = self.size - 1 - i if i < 8 else 15 - i if i < 9 else 14 - i @@ -216,3 +244,496 @@ def make_texture(data: str, inverted: bool = False) -> rl.Texture: rl_image.mipmaps = 1 rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 return rl.load_texture_from_image(rl_image) + + +# ---- Symbol structure for decoding ---- + + +class QRError(Exception): + pass + + +_LEVELS = (1, 0, 3, 2) # format info level bits -> column in _EC + +_MASKS = [ + lambda i, j: (i + j) % 2 == 0, + lambda i, j: i % 2 == 0, + lambda i, j: j % 3 == 0, + lambda i, j: (i + j) % 3 == 0, + lambda i, j: (i // 2 + j // 3) % 2 == 0, + lambda i, j: (i * j) % 2 + (i * j) % 3 == 0, + lambda i, j: ((i * j) % 2 + (i * j) % 3) % 2 == 0, + lambda i, j: ((i + j) % 2 + (i * j) % 3) % 2 == 0, +] + +_ALIGNMENT = np.ones((5, 5), dtype=bool) +_ALIGNMENT[1:4, 1:4] = False +_ALIGNMENT[2, 2] = True + + +def _gf_inv(a: int) -> int: + return _EXP[-_LOG[a] % 255] + + +@functools.lru_cache +def _data_coords(version: int) -> tuple[np.ndarray, np.ndarray]: + """(rows, cols) of the data and error correction modules in placement order: two-column zigzag from the right.""" + dim = version * 4 + 17 + func = np.zeros((dim, dim), dtype=bool) # finder, timing, alignment, format, and version modules + func[:9, :9] = func[:9, dim - 8:] = func[dim - 8:, :9] = True + func[6, :] = func[:, 6] = True + positions = _alignment_positions(version) + for r, c in itertools.product(positions, positions): + if (r, c) not in ((6, 6), (6, dim - 7), (dim - 7, 6)): + func[r - 2:r + 3, c - 2:c + 3] = True + if version >= 7: + func[:6, dim - 11:dim - 8] = func[dim - 11:dim - 8, :6] = True + ys = np.arange(dim) + rows, cols = [], [] + # the vertical timing column is skipped, so the pairs left of it start at odd columns + for i, right in enumerate(col if col > 6 else col - 1 for col in range(dim - 1, 0, -2)): + r = np.repeat(ys[::-1] if i % 2 == 0 else ys, 2) + c = np.tile((right, right - 1), dim) + keep = ~func[r, c] + rows.append(r[keep]) + cols.append(c[keep]) + return np.concatenate(rows), np.concatenate(cols) + + +# ---- Matrix decoding ---- + + +def _poly_eval(p: list[int], x: int) -> int: + # p is highest degree first + y = 0 + for c in p: + y = _multiply(y, x) ^ c + return y + + +_EXP_TABLE = np.array(_EXP) +_LOG_TABLE = np.array([_LOG.get(v, 0) for v in range(256)]) + + +def _syndromes(msg: list[int], nsym: int) -> list[int]: + """syn[i] = msg(alpha^i), msg highest degree first.""" + m = np.array(msg) + exponents = np.arange(nsym)[:, None] * (len(msg) - 1 - np.arange(len(msg))) + return np.bitwise_xor.reduce(_EXP_TABLE[(_LOG_TABLE[m] + exponents) % 255] * (m != 0), axis=1).tolist() + + +def _rs_correct(msg: list[int], nsym: int) -> list[int]: + """Corrects up to nsym // 2 errors in a Reed-Solomon codeword, in place.""" + n = len(msg) + syn = _syndromes(msg, nsym) + if not any(syn): + return msg + + # Berlekamp-Massey, sigma is lowest degree first + sigma, prev, L, m, b = [1], [1], 0, 1, 1 + for r in range(nsym): + d = syn[r] + for i in range(1, L + 1): + d ^= _multiply(sigma[i], syn[r - i]) + if d == 0: + m += 1 + continue + coef = _multiply(d, _gf_inv(b)) + shifted = [0] * m + prev + saved = sigma[:] + sigma = sigma + [0] * max(0, len(shifted) - len(sigma)) + for i, c in enumerate(shifted): + sigma[i] ^= _multiply(coef, c) + if 2 * L <= r: + L, prev, b, m = r + 1 - L, saved, d, 1 + else: + m += 1 + sigma = sigma[:L + 1] + if 2 * L > nsym: + raise QRError("too many errors") + + # Chien search: codeword position p has locator alpha^(n-1-p) + positions = [p for p in range(n) if _poly_eval(sigma[::-1], _EXP[(p - n + 1) % 255]) == 0] + if len(positions) != L: + raise QRError("error locator mismatch") + + # solve syn[i] = sum_k e_k * X_k^i for the magnitudes e_k + xlog = [(n - 1 - p) % 255 for p in positions] + A = [[_EXP[(xlog[k] * i) % 255] for k in range(L)] + [syn[i]] for i in range(L)] + for col in range(L): + piv = next((r for r in range(col, L) if A[r][col]), None) + if piv is None: + raise QRError("singular") + A[col], A[piv] = A[piv], A[col] + inv = _gf_inv(A[col][col]) + A[col] = [_multiply(inv, v) for v in A[col]] + for r in range(L): + if r != col and A[r][col]: + f = A[r][col] + A[r] = [a ^ _multiply(f, c) for a, c in zip(A[r], A[col], strict=True)] + for k, p in enumerate(positions): + msg[p] ^= A[k][L] + + if any(_syndromes(msg, nsym)): + raise QRError("uncorrectable") + return msg + + +def _read_format(m: np.ndarray) -> int: + """Returns the closest format info (level bits << 3 | mask) from either copy.""" + dim = m.shape[0] + copies = ([(8, i) for i in range(6)] + [(8, 7), (8, 8), (7, 8)] + [(5 - i, 8) for i in range(6)], + [(dim - 1 - i, 8) for i in range(7)] + [(8, dim - 8 + i) for i in range(8)]) # (row, col), msb first + candidates = [] + for coords in copies: + bits = int("".join(str(int(m[r, c])) for r, c in coords), 2) + candidates += [((bits ^ f).bit_count(), i) for i, f in enumerate(_FORMATS)] + distance, fmt = min(candidates) + if distance > 3: + raise QRError("bad format info") + return fmt + + +_ALNUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:" + +_ECI_ENCODINGS = { + 0: "cp437", 2: "cp437", 1: "iso8859-1", 3: "iso8859-1", + **{i + 2: f"iso8859-{i}" for i in range(2, 17) if i != 12}, + 20: "shift_jis", 21: "cp1250", 22: "cp1251", 23: "cp1252", 24: "cp1256", + 25: "utf-16-be", 26: "utf-8", 27: "ascii", 170: "ascii", 28: "big5", 29: "gb18030", 30: "euc_kr", +} + + +class _Bits: + def __init__(self, data: list[int]): + self._value = int.from_bytes(bytes(data), "big") + self.remaining = len(data) * 8 + + def read(self, n: int) -> int: + if n > self.remaining: + raise QRError("bitstream underflow") + self.remaining -= n + return self._value >> self.remaining & (1 << n) - 1 + + def read_below(self, n: int, limit: int) -> int: + v = self.read(n) + if v >= limit: + raise QRError("value out of range") + return v + + +def _parse_data(data: list[int], version: int) -> str: + bits = _Bits(data) + out: list[str] = [] + encoding = None + band = 0 if version <= 9 else 1 if version <= 26 else 2 + while bits.remaining >= 4: + mode = bits.read(4) + if mode == 0: + break + if mode == 7: # ECI character set assignment + first = bits.read(8) + extra = 0 if first < 0x80 else 8 if first < 0xC0 else 16 if first < 0xE0 else -1 # 1, 2, or 3 byte assignment + if extra < 0: + raise QRError("bad ECI assignment") + assignment = (first & 0x7F >> extra // 8) << extra | bits.read(extra) + encoding = _ECI_ENCODINGS.get(assignment) + if encoding is None: + raise QRError(f"unsupported ECI assignment {assignment}") + elif mode == 1: + n = bits.read((10, 12, 14)[band]) + while n > 0: + k = min(n, 3) # 3 digits in 10 bits, the last 2 or 1 in 7 or 4 + out.append(f"{bits.read_below((4, 7, 10)[k - 1], 10 ** k):0{k}d}") + n -= k + elif mode == 2: + n = bits.read((9, 11, 13)[band]) + while n > 0: + k = min(n, 2) # 2 characters in 11 bits, a last one in 6 + v = bits.read_below((6, 11)[k - 1], 45 ** k) + out.append(_ALNUM[v // 45] * (k - 1) + _ALNUM[v % 45]) + n -= k + elif mode == 4: + n = bits.read((8, 16, 16)[band]) + segment = bytes(bits.read(8) for _ in range(n)) + try: + out.append(segment.decode(encoding or "utf-8")) + except UnicodeDecodeError as e: + if encoding is not None: + raise QRError("invalid ECI byte segment") from e + out.append(segment.decode("latin-1")) + elif mode == 8: + n = bits.read((8, 10, 12)[band]) + for _ in range(n): + v = bits.read(13) + c = (v // 0xC0) << 8 | v % 0xC0 + c += 0x8140 if c < 0x1F00 else 0xC140 + try: + out.append(c.to_bytes(2, "big").decode("shift_jis")) + except UnicodeDecodeError as e: + raise QRError("invalid Kanji character") from e + else: + raise QRError(f"unsupported mode {mode}") + return "".join(out) + + +def decode_matrix(m: np.ndarray) -> str: + """Decodes a square boolean module matrix (True = dark) without a quiet zone.""" + dim = m.shape[0] + if m.shape != (dim, dim) or dim % 4 != 1 or not 21 <= dim <= 177: + raise QRError("bad matrix size") + version = (dim - 17) // 4 + + fmt = _read_format(m) + level = _LEVELS[fmt >> 3] + rows, cols = _data_coords(version) + bits = m[rows, cols] ^ _MASKS[fmt & 7](rows, cols) + codewords = np.packbits(bits[:len(bits) // 8 * 8]).tolist() + + ec, _ = _EC[version - 1][level] + lens = _block_lengths(version, level) + blocks = [[0] * (n + ec) for n in lens] + for (b, i), codeword in zip(_interleaved(version, level), codewords, strict=True): + blocks[b][i] = codeword + + data: list[int] = [] + for block, n in zip(blocks, lens, strict=True): + data += _rs_correct(block, ec)[:n] + return _parse_data(data, version) + + +# ---- Image decoding ---- + + +def _box_sums(a: np.ndarray, radii: tuple[int, ...]) -> list[np.ndarray]: + """Sums over (2r + 1)^2 neighborhoods of the last two axes, edge padded, from one integral image.""" + P = max(radii) + lead = [(0, 0)] * (a.ndim - 2) + cs = np.pad(np.cumsum(np.cumsum(np.pad(a, lead + [(P, P), (P, P)], mode="edge"), -2), -1), lead + [(1, 0), (1, 0)]) + H, W = a.shape[-2:] + out = [] + for r in radii: + lo, hi = P - r, P + r + 1 + out.append(cs[..., hi:hi + H, hi:hi + W] - cs[..., lo:lo + H, hi:hi + W] - cs[..., hi:hi + H, lo:lo + W] + cs[..., lo:lo + H, lo:lo + W]) + return out + + +def _binarize(gray: np.ndarray) -> np.ndarray: + """Adaptive threshold: each pixel against the mean of the surrounding tiles that have contrast.""" + h, w = gray.shape + if h < 21 or w < 21: + raise QRError("image too small") + B = max(8, min(h, w) // 128 * 2) + H, W = -(-h // B), -(-w // B) + padded = np.pad(gray, ((0, H * B - h), (0, W * B - w)), mode="edge") + # block statistics from a subsample are plenty + sub = np.ascontiguousarray(padded[::2, ::2].reshape(H, B // 2, W, B // 2).transpose(0, 2, 1, 3)).reshape(H, W, -1) + blocks = sub.sum(axis=2, dtype=np.uint32) / sub.shape[2] + known = sub.max(axis=2) - sub.min(axis=2) >= 32 + # Flat tiles cannot estimate their own threshold: use the tiles with contrast nearby, then + # further out, then the global midrange. A flat tile is then all dark or all light. + est = np.full((H, W), (blocks.min() + blocks.max()) / 2) + filled = np.zeros((H, W), dtype=bool) + for total, count in _box_sums(np.stack((known * blocks, known.astype(float))), (2, 6)): + fill = ~filled & (count > 0) + est[fill] = total[fill] / count[fill] + filled |= fill + thr = np.where(known, np.minimum(est, 254) + 1, np.where(blocks <= est, 255, 0)).astype(np.uint8) + return (padded.reshape(H, B, W, B) < thr[:, None, :, None]).reshape(H * B, W * B)[:h, :w] + + +class _Runs: + """Run-length table of a padded, flattened binary image with a per-pixel run index.""" + + def __init__(self, padded: np.ndarray): + self.flat = padded.ravel() + self.lines, self.stride = padded.shape + change = self.flat[1:] != self.flat[:-1] + self.starts = np.concatenate(([0], np.flatnonzero(change) + 1)) + self.lengths = np.diff(np.append(self.starts, self.flat.size)).astype(np.int32) + + def run_at(self, line: np.ndarray, pos: np.ndarray) -> np.ndarray: + """Index of the run containing the pixel at `pos` along `line`.""" + return np.searchsorted(self.starts, line * self.stride + pos + 1, side="right") - 1 + + @staticmethod + def _match(lengths: list[np.ndarray], ratios: tuple[int, ...]) -> tuple[np.ndarray, np.ndarray]: + """Checks windows of runs against the ratios, given the length of each run. Returns (ok, module size).""" + S = sum(ratios) + total = sum(lengths[1:], start=lengths[0]) + ok = total >= 2 * S # modules need to be at least 2 px + for L, r in zip(lengths, ratios, strict=True): + ok &= np.abs(2 * S * L - 2 * r * total) <= r * total # integer form of |L - r * total / S| <= r * total / (2 * S) + return ok, total / S + + def scan(self, ratios: tuple[int, ...]) -> np.ndarray: + """Returns the indices of all dark runs starting a window of runs matching the ratios.""" + n = len(ratios) + N = len(self.lengths) - n + 1 + if N <= 0: + return np.zeros(0, dtype=int) + ok, _ = self._match([self.lengths[k:N + k] for k in range(n)], ratios) + ok &= self.flat[self.starts[:N]] + first = np.flatnonzero(ok) + return first[self.starts[first] // self.stride == self.starts[first + n - 1] // self.stride] + + def check(self, first: np.ndarray, ratios: tuple[int, ...]) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Checks the run windows starting at run index `first`. Returns (ok, center position along the line, module size).""" + n, half = len(ratios), len(ratios) // 2 + ok = (first >= 0) & (first + n <= len(self.starts)) + idx = np.clip(first[:, None] + np.arange(n), 0, len(self.starts) - 1) + matched, module = self._match([self.lengths[idx[:, k]] for k in range(n)], ratios) + ok &= matched & self.flat[self.starts[idx[:, 0]]] + ok &= self.starts[idx[:, 0]] // self.stride == self.starts[idx[:, -1]] // self.stride + center = self.starts[idx[:, half]] % self.stride - 1 + self.lengths[idx[:, half]] / 2 + return ok, center, module + + +def _find_patterns(binary: np.ndarray, ratios: tuple[int, ...]) -> list[tuple[float, float, float]]: + """Finds dark/light run patterns with the given module ratios. Returns (x, y, module size).""" + half = len(ratios) // 2 + step = 2 # the center rows of a 2 px finder pattern still get scanned twice + rows_t = _Runs(np.pad(binary[::step], ((0, 0), (1, 1)))) + first = rows_t.scan(ratios) + if len(first) == 0: + return [] + _, cx, hmod = rows_t.check(first, ratios) + row = rows_t.starts[first] // rows_t.stride * step + + xi = cx.astype(int) + xs, col = np.unique(xi, return_inverse=True) + cols_t = _Runs(np.pad(binary[:, xs].T, ((0, 0), (1, 1)))) + ok, cy, vmod = cols_t.check(cols_t.run_at(col, row) - half, ratios) + ok &= (0.5 <= vmod / hmod) & (vmod / hmod <= 2) + line = np.clip(np.rint(cy / step), 0, rows_t.lines - 1).astype(int) + ok2, cx2, hmod2 = rows_t.check(rows_t.run_at(line, xi) - half, ratios) + ok &= ok2 & (0.5 <= hmod2 / vmod) & (hmod2 / vmod <= 2) + + found: list[list[float]] = [] # [x, y, module, count] + for x, y, module in zip(cx2[ok], cy[ok], (hmod2[ok] + vmod[ok]) / 2, strict=True): + for f in found: + if abs(f[0] - x) <= f[2] and abs(f[1] - y) <= f[2] and 0.5 <= f[2] / module <= 2: + c = f[3] + f[0], f[1], f[2], f[3] = (f[0] * c + x) / (c + 1), (f[1] * c + y) / (c + 1), (f[2] * c + module) / (c + 1), c + 1 + break + else: + found.append([x, y, module, 1]) + found.sort(key=lambda f: -f[3]) + return [(f[0], f[1], f[2]) for f in found if f[3] >= 2] + + +def _pick_finders(patterns: list[tuple[float, float, float]]) -> tuple[np.ndarray, np.ndarray, np.ndarray, float]: + """Returns (top-left, top-right, bottom-left) centers and the module size of the most square-looking triple.""" + best = None + for a, b, c in itertools.combinations(patterns[:10], 3): + mods = sorted((a[2], b[2], c[2])) + if mods[2] / mods[0] > 1.5: + continue + pts = [np.array(p[:2]) for p in (a, b, c)] + d = [np.linalg.norm(pts[(i + 1) % 3] - pts[(i + 2) % 3]) for i in range(3)] + tl = int(np.argmax(d)) # opposite the hypotenuse + p1, p2 = pts[(tl + 1) % 3], pts[(tl + 2) % 3] + v1, v2 = p1 - pts[tl], p2 - pts[tl] + n1, n2 = np.linalg.norm(v1), np.linalg.norm(v2) + if n1 == 0 or n2 == 0: + continue + cos = abs(np.dot(v1, v2)) / (n1 * n2) + if cos > 0.35 or not 0.6 <= n1 / n2 <= 1.6: + continue + score = cos + abs(np.log(n1 / n2)) + np.log(mods[2] / mods[0]) + if best is not None and score >= best[0]: + continue + if v1[0] * v2[1] - v1[1] * v2[0] < 0: + p1, p2 = p2, p1 + best = (score, pts[tl], p1, p2, float(sum(mods) / 3)) + if best is None: + raise QRError("no finder patterns") + return best[1:] + + +def _perspective(src: np.ndarray, dst: np.ndarray) -> np.ndarray: + """Homography mapping the four src points onto the four dst points.""" + A = [row for (x, y), (u, v) in zip(src, dst, strict=True) + for row in ([x, y, 1, 0, 0, 0, -u * x, -u * y], [0, 0, 0, x, y, 1, -v * x, -v * y])] + try: + h = np.linalg.solve(np.array(A, dtype=float), np.asarray(dst, dtype=float).ravel()) + except np.linalg.LinAlgError as e: + raise QRError("degenerate geometry") from e + return np.append(h, 1).reshape(3, 3) + + +def _transform(H: np.ndarray, pts: np.ndarray) -> np.ndarray: + p = np.column_stack((pts, np.ones(len(pts)))) @ H.T + return p[:, :2] / p[:, 2:3] + + +def _match_alignment(binary: np.ndarray, est: np.ndarray, offs: np.ndarray, r: int, module: float) -> np.ndarray | None: + h, w = binary.shape + dy = np.arange(max(0, int(est[1]) - r), min(h, int(est[1]) + r)) - est[1] + dx = np.arange(max(0, int(est[0]) - r), min(w, int(est[0]) + r)) - est[0] + if len(dy) == 0 or len(dx) == 0: + return None + y = np.rint(est[1] + dy[:, None, None] + offs[None, None, :, 1]).astype(int) + x = np.rint(est[0] + dx[None, :, None] + offs[None, None, :, 0]).astype(int) + valid = ((y >= 0) & (y < h) & (x >= 0) & (x < w)).all(axis=2) + samples = binary[np.clip(y, 0, h - 1), np.clip(x, 0, w - 1)] + score = np.where(valid, (samples == _ALIGNMENT.ravel()).sum(axis=2), 0) + if score.max() < 23: + return None + hits = np.argwhere(score == score.max()) + centers = np.column_stack((est[0] + dx[hits[:, 1]], est[1] + dy[hits[:, 0]])) + closest = centers[np.argmin(np.linalg.norm(centers - est, axis=1))] + return centers[np.linalg.norm(centers - closest, axis=1) <= module / 2].mean(axis=0) + + +def _locate_alignment(binary: np.ndarray, H: np.ndarray, center: float, module: float) -> np.ndarray | None: + """Template matches the 5x5 alignment pattern around its position estimated from H.""" + grid = np.mgrid[-2:3, -2:3].reshape(2, -1).T[:, ::-1] + center # (25, 2) module coords (x, y) + pts = _transform(H, grid) + # the affine estimate can be off in both position and local scale under perspective + for radius in (2, 4, 8, 16): + for scale in (1.0, 0.8, 1.25, 0.65, 1.5): + found = _match_alignment(binary, pts[12], (pts - pts[12]) * scale, int(module * radius), module) + if found is not None: + return found + return None + + +def _sample(binary: np.ndarray, tl: np.ndarray, tr: np.ndarray, bl: np.ndarray, module: float, dim: int, use_alignment: bool) -> np.ndarray: + src = np.array([(3.5, 3.5), (dim - 3.5, 3.5), (3.5, dim - 3.5), (dim - 3.5, dim - 3.5)]) + dst = np.array([tl, tr, bl, tr + bl - tl]) + H = _perspective(src, dst) + if use_alignment and dim > 21: + align = _locate_alignment(binary, H, dim - 6.5, module) + if align is not None: + src[3], dst[3] = (dim - 6.5, dim - 6.5), align + H = _perspective(src, dst) + + rows, cols = np.mgrid[0:dim, 0:dim] + pts = _transform(H, np.column_stack((cols.ravel() + 0.5, rows.ravel() + 0.5))) + xy = np.rint(pts).astype(int) + h, w = binary.shape + if (xy < 0).any() or (xy[:, 0] >= w).any() or (xy[:, 1] >= h).any(): + raise QRError("code extends outside image") + return binary[xy[:, 1], xy[:, 0]].reshape(dim, dim) + + +def decode(gray: np.ndarray) -> str | None: + """Decodes the QR code in a 2D uint8 grayscale image. Modules need to be at least 2 px. + Returns None if nothing could be decoded.""" + try: + binary = _binarize(gray) + tl, tr, bl, module = _pick_finders(_find_patterns(binary, (1, 1, 3, 1, 1))) + except QRError: + return None + + d = (np.linalg.norm(tr - tl) + np.linalg.norm(bl - tl)) / 2 + dim = int(round((d / module + 7 - 17) / 4)) * 4 + 17 + dims = [cand for cand in (dim, dim - 4, dim + 4) if 21 <= cand <= 177] + for cand, use_alignment, transpose in itertools.product(dims, (True, False), (False, True)): + try: + m = _sample(binary, tl, tr, bl, module, cand, use_alignment) + return decode_matrix(m.T if transpose else m) + except QRError: + pass + return None diff --git a/openpilot/common/tests/fixtures/qrcode_0.npz b/openpilot/common/tests/fixtures/qrcode_0.npz new file mode 100644 index 0000000000000000000000000000000000000000..96f550e5f439d278daf2229532464a4bd343c753 GIT binary patch literal 67335 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�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%0A8eMO4(kk{PsChp6l#(` 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

{@U4myW zQ-=rCG4W-1^GPnn>C61UprhD}DLB`%Z|26sWJSI*p_eY$#@>$cI2#QIuQ$szpc_3O zMP_8aF-H7=6#in2-dOY=#?!PNb69t<&#XM)cx8KdC1?BO0y{IHWMs4)-*Xjfm=##+ zL3fcIxv#|1ghXb%yhnGNW9#%1yWj7)3e_Ns#8EZn9by_i?PwymRs*}r;3p2}ObwMK zT*04*uHgZ{50SU4Ann@ESg@7W)CwycP_%H~t+%S0SajXS^K8^8ses5w;gT3YI(%k) z@ebtR!b^E8q|x`Eu$EIGn`u~jLSLEna06wZTl{fjlA`dLoSLjXnk5~RFu4q3{U^j; z;qGp?S{m25%i9{c=?_`deb#A=0W#}1R`;mpsG%W^rbM_$n^sl=_K=~Wn#d_LveNwX zARYHcw3Ve5ehi>Ky%o=wN$?*x-j6pDs3_{>vMaevVWbr|#cKk*@OypBm7+Fk8i6WG zM!iw#80i#?p2l`K5`(*VmditYOjh%(o}IfN1qMly!RZOFmffWnE-W1389O>>60trx z@KNWMxXfX$_1Nh$Ik-!?Lu)8^F8hEt5)dZyFj%MTjR}TK4kcwuJe#ulAe2h4o4#!d z>MmKxKNLN5wOGAbp(zj2*s3$K1m2wv?;iYt>Y+sS0VemguNRC~m8B^}tm_LD6OiH5 z?P+*gmju@;@JGj)oNe6G1sr&5bddP~3NV7mtGiVjd&9zmD1fnUUHpUFEo(ghO(AX2`M(uHUv=$d|U$5i5^iUfbmFgCL6>R4ZMLw$F(a(j{Lf z@af)1opoo{seN6z1)bAgjt$qdUoKHDg5N5slaTj5&lD587fEw{D*W3>Rd+*3HWcZH zK8!j&q`PpAFmVYu`?ZItNbI&wSaEv!Mlb8VbbfQwoQ{+7*IVA*h&WUk-(Q$ z`F*~RkiEbx$Sb&U;JE-X5_y+8`b}odU961l+o0H5>GuJz{Mp{8RVW>aDYld z2rh-lv2mlEN(dB$c(Im>>MXD@QC%UP1RhD$LtO}V3!&TqT2KIN2Cx88ZN3bvgE9RA zEx{Z>wEvK7d@Hie|2)_8z2i08bL!OF_ddBb^8^RY&MD3G>)M&<;pNMna!y4qf>I}k zquMbtGKwFikMX&y{RJ#xn9FJ};V41>}r9RS=7o zS(@sN93t>*rnSL?c*AMc?|7=+SLGQ!mbRm&=?&|jIv1n`DE?glCVZ9HO->$X#Fsp4 zdV`oHkk)@QsJy03!Ft{>0FR*2*H6@HC8Vfoxj=JGWyzg&sVRBCjZwKKDYf9(HFdVa z$)kaWrR>lyk1Te4N;05rD|(GV3gp;#2qY#^pmK;%!!hVCK&zDt0bc+c0hmD5Le-xOIY1-`kqt8-gYws7cLg80 zmlDWX3U6pdyw{4^9{jgrVXqHO+)3&2eu-cQSPdY#sTHBo zy&8~DQg?H*js-xkhWaA_J+CpQkP8QB7#^o<$OTv!HG*;afU0yZ@9Y%`n$z=+PQU&9O;ToWNUxZUsu>0 zIM5=WKN80S0&TU8B*0!%F@S@;Y7IqHR4}%#0wXnQDKuSaY@QQanKO;Zqd8IdioaAu zXvi{ky^8S(VKaWV3ITWZH@vk0BP|_E(Vez@BJH0pG%QrUW^_b(<(yo!>q+?!Nm8a6 z_r$)Gseg6Pd!AjqGdsRQR5W2WQN*_947((YyEx99@bs!nQy(@Bp(m6L-4}IAL3IOn z%g=w6;G##-iVTpjbm>fVgy}BIf5!1d#*&>wmp2!_erhn{mgbj-DuNSVIFYIh?9naj zEwdlG=WVdSmIY_hC}K4YK`sAcwq{Js$-FadRw_Rsi;cJ7_U`5793BKu^g<;nfN2KC zYbzGlEPDXrC&6i~Ou^<;o0(qC=GS37)`hV|rsK)81Xhy;L6^DmN+0^<{cA;k2a^F7 zCvss=`X~kJkO{XUpHVF#0WS8?zdv{1uKo{K?-(6f*l1f<#ZJez?WAJcwr$(CjgH;1 z(XnlH?2fx*_m?xydGB}b{asam_8w!`8f!mmPRy5!1*}28+1n=wY7!>CqxpFSb64N^ z(|6}0C8?&?*8K=n;+7bIaQC?R#ig)FaO2M@1IlmTv#+~h$jL4Fwn)QZ=M&KhIItiK z`-4UwD3c;cg*hsd(#$~>wzwGdV1?GLK3mfo8;yCtG-_!zT%r02i`kMdNjk}mtEmf2 zJwq=wMv%Fu5nlqVa+?mHycDO*MU3gH@SFCp5t%c})DX}4D2c~&Yj$V{8?O1gy}hHY zjZv5p*jNhiOB&?{tnO?|n0-=`?|!fIpweEXxHaHOZmEa7y*fCliWMHG;xNq-e4auN zG?lytGjzO0WP96>f!XLTem|%n$A_K@5K4mWBsQjHYxfrq!n@)r=u4jBcR>g;=#-z8 zlq2@VyY>VwnbXMQdHUpq64Pp5!C72G7rrMd7YKhA23w`+!2_;$FlRW9*Ei?dI($`YpS zv>CfLF*n9_s6RCi4MlUAe`mE*mB9~jI`m38C?U&L1Dt=PR2q?o9nBjxY*(_CH>P)g zEy0nzFe5mKwNX1g2j6-}hCIqvTR;K@4ruo-QPqAd?LxLMjH2Uh7;~{l9*l%cvWIza zZia}$4P=TsF&xRj@eg~k^(W@{V@~18v<0U^g{6!%hmj;5V!;~E8&3&38-Uc}m`1n^ z(SuDrFzFM`O3o-hwQ$G~_zgh_FVb`hKY%FcPmK>?5D@kCh2sm0+UKe57O(6fCS zk4wCx4cLhF7#f4lIAm!fk!1ygnWyP6=;ZDmz3OMEg!08iR=clz!3#Mc^)(ixSahXw zurLf7)`_pSxp(#jfD8{7DL;nqHMi?}jR`rOu;C4^CM_)rG>{l4miY>5Zp{m&B$OG% z=_b%ixSk>(TN~8J?h}2YD!^BE^h*^D13WJKNk1wbaG}Qn)Kz9Q(}yBU+Z6v&bs!DF zCHrXGgp6HOz2{DC>zyUO_{omX-4KYrc*3<`39=t1euWSz6E-2J|z#1t; z&*pU9PrnyiTn~nPZR{AgZk?-9m$h|d2TMW(aKDob!Zib=dQpOo0A~M)qx+cmigtPL zx^U*h+cQZrjqrmw{h$a;`pqB#_5n)IIBp?lwh)CiQLi{k%80Z@dNqH38WVirphwIG z1;~@fKYlCZ1_3@0gxUd7vO)R5VNDQCcAfA5eIW4yxBx)?^=$eWPzqTXnGErJyS)>O zOY8-%3lQZf9JI=Aea)984Z}($6k|Qs6}4L)*Ucdx#asdP9?80@NC_>$+wR1Pzx4DJ zR)pcfn%4Rt<4S7YvZ5e}?TT?(q-~LgC*VFbR!S8JH>Sxr(vU%4>eD-YTp#*^#KOh#1FAM$Gx7b)f#+)EyHdB_b$j8bK`Vy^v9iskE zOQi+>-!0Yjf3n=NKWO<37urdKgdjQQ@YE8K?pEPq%!xi+6jPqV{T9`GqOn`F*vvxC zb}D0%^H!xIJ)mUEzgWlXj zXYZ5$_DDB!*iH2EGN>xq-k1E$%Os+fYcwO}%;F-d@xxbVG&=6NByx#0fxPHR_%F>^ zYX8UI`h!G@;Jdb~{CvU$kAy7MtbgtI8Kq)(cx(ZuS-iGaCzZGbs^Dr8i2IK>Nrb43 z2TUTnc$%ViK8cu=P8B>Nl#}{(W+$VB8WJFZS+g~`gkP_OwoPcZY-Bd>d!?GK9ZR={ zf=786!qqODw|s<0)6!aOP2GajNkM*nc>mh_DB;@AscrsMoJ$}sq3uUA!DUZ?nJR>P zU8~1Pe2Ul)cq=Evk!tZ(mD6^p%_RVQF1oYjUf#JsdOBE581op{^?a*KPpUVWN$*Dx zC@mtIBP2NP;y=1RhVtpC+i}XrGD&9BYj?fYj4J1d!#UYv3ofsQLmmAb#TImRI_meE zO#5{RF&`N`;>7ZCTso4_J)(BzdeGN8{oR-ac9_|OIrcS%$=#YjaVSB>BaDAy-KfZe zl`0dEdp3Eq&Xtq3wsUw~MFa{e`cMktmMed7h#LToLvhH>%9|oAIPReJyU;!%loD~u zQPWvYW3ITqCJ`Z=LDhzPRNiE6&ptTla^@&jG77(bpy;M+z`TRxj4u;Wqc1eh+02wgFuIOwCCR6#*(asBNJXZ?{%oml6N$S!( zc`Ry>8di>dtJ>Ynd$(ME6+U#qVx!c}25GS!-%U*XhaNWCnk;cyQ^3{3WZ>C|{CxAt zsh-LDOuBGRrafy;qsB#`_Zd?Y*fspPuVBu?c z#$BfqH3x0W!vrWMWs%nzQ@#r!$VIc{xs9R(9N{Hk5M_uS(j2lZwD`KV(4bR@+UAu1 zvUHD^gVxsm?ES#NVE7&G{DKgDr7V_zOOPML_~6XEH)efNvOk)!#oanD4d5C*A~bGs=)<=DS;e#Vwx5dgRj%GV>2y_^<|#8 zDq}p_8pa@56-){EJ9sXvmsM$m_>TwDjI;>thKTXlRvQUk$4aWRAtKdQo&5a+BtI_K zdDsN%dbbo1MDs-G#HVaGgeVjWRU5;wx}sc>X}V=aEAS*@6}z8fsa`;}vQR6D_`KVO zSUk?i3ok&5rcX5$xah%Ym072yi!2EHjDQ&AAD2sB6AM<*R)JqjZqcEHJN;eG)g^*! z3vY3L{q^B^FL)(Bt!%q(j8^HEge&s#_)o0V2CSC)dK>>S_oU@R_l~J!k7lxOR1-FB z=10du=3*7nBfdZ9W%BT9S7r=_ukJ;axwm9HeqenU6(~M}R%uo>ucky7S1CDIZNOHl z9>x*pS1)0_#u10367)q@nrm9F#NV;-Zfb=QSFbB3RaPqlhztLqW>#&l33vjpJpWhN zk*u>#NKLC4KY5^{yey-lZiAkZlU1$4@(G{b5kV1RLe6QUMiWE(i!DM=!9z=2{UexE z>tDHqyS2ZV^9uD5nT#M+T`Xh8GRVy3G4dLYMa4^7^z0la&CqO~e&uwR^SXG$beg1)iKDU_ zUgbHDy&t3sw|lAs6fO)GRn(?q?)Jy}IxGdOumM#yi?4562?Z8o@{VL3gF_y!ip1CY zKYrz*oC~ozXIh417(_*RlRPIt7Y(K?2o0|pN}S48FrDDPLKKd2@vH<1b5U!ztvwa& zXTEq%rs*vbOPz-b7%V?;wmr$NENJXNgSbc(;C-iu)f=@1p$pFe=fw6H6el{0{??aB zN(jyB%TpNWu3IfoG)g6)8I9HrGAtl1n6|h$7`pI!Q1Ro00}x%X0;>>LX_`sG8V{$C5(_BfI|d%46QsDWq4XBF^{^>JFk?cm2Rz@IdNsMO zJAsNmlMfo)oM9pNJ=4E2HdMBC&Q;xiqt&EzqmdVbov(jSNTJzVOP`?E=nDQihwsV~Iw!X5Z8i zylK|}oSIt*c0|i@7V?5jOfx+DuE}J|Cwzh-v)9P=*psRxkXZM-qbIi)+l(Q`J0TsM z(sgT2g!c_FhFP=s&0(wX<5(YAM-D#9Bq z=3DsXfecw-&|;cN6gudm_s*K^?Y@qqS;{&$;d{ST{=*58Em)H;*@=6>lxrfU;`FMR zGj>2RXbsJRYQ*gBp^?&)*(<+O=y3P{PLQtR!aV;wL9(%Z`7~L%O+egVHcl3f|EdO= zSxvsYoL^QFZqu(-oiPW<ui__hcOp3H^`igmDw2dp9FdHKMC^i zKM8WxUv1G6585dQuMO2z!)ADLEKjCw0UqQEpNV6hVUa=l5|U`#OBuBkPK}Z4+O@#m zuBTXLH(NkTIWxFK8!soV!!%{W;F-ZMmE2PUlPO5-SGSpY#XVOr_py5;a4K+m_h#_a zS@$Ni+$NukR*nJ(JMzivHQ3_!@0(-b-^i)=Os}_!(_^foiffsPsoeq5x0+>q66D8l zoGL#;KWKWx&i8GKWr`h&oqsnu`wb0nbed1zl^S${te1gf#JU*OuzEvC1WnhuY^3t_ zB@wS0k_MaV=}LPr`5EaVKL+CEe7JH6HyZT`pJuy!Wd1(z7;=i1pVs>nqIATd0EIr@Gnn`;qk5F zN%;>E{%`q$`rbK`BzdLAJVh_eoifsdADWo<7hg}53S%*Kjep-X%F3j?{DEZ-^kB;N z-n=sjXK)Bj>8|+5q4fE|<&f)TMR;EK6?z}jhje*pE2hHwV^Or1dHHEb%*vL=?Ispp z1>^i7UJGB8&vo{oR`42n<-!V>V&I874@>jm1 ztXKmFj0N<1?$fRg0dY>y0;MsPzbw%rl~ES~T7z~4#4H|_F)x*IxPACXDs!K7mZ~F+ zJ?g6LM!eDGBK#0OL^D|y?toJe!5jP|!aybEY~Vm$Eb#G{zoC2(h7lW}%>rM;V=(RA-Rt9*Sjs#a8yFh)~vmoI=+89Ck`FE4o4^R0aQXy`?r{I0z z4{`tb2&r0!uGQOc*0n77-?;h<@K;y+!fD7Qis^xpM5hPFyH(JsTMI-h0dY0x!AC`= z?lr?Ov(yl!+r!=1eR#73E&6-!&c^Dev%dt6_`H6o@q7Bam}e`&MJ)Jw6VJJMLIa)iJmGLXdAtt{2C#t8s$dIg z1ZEH-GvoD#HNr%IPt#Qg_PJxLQmo`? z{Mfgm_Mlj|*x6le*`n0oh;A33!la$ z$enhE8-7Ny!qUCT}M(irnt=P2?t+-0fbe+ob;Vz4u zW$rG%$;$TR1*}oLm{p%Avp0I2-;8GJ3=UkU_jaPT~Phf{pGif(TYRebcG3#>%C<%HstJvLOw5 zEi6js5*c7Lv37t=Ulr^FD&_4*JX0e2WsEbAIUZQ}#mS~upZHdKF`l|s8eHBM%u7`-bIYL{VP=?Bb_<>uQpH5DhFP9L7%naVbaRhRD zT_{m^V(iBi(*?Pr0C9XTT_%27q>gMBjp(ri#n|Dz?;?p#8g?_|Oe!Q7)V00UdKO-8 z+*yq6pR}>F*E6rDV$u=UQB4xSo6tWWvxL{SorTnf7lw^*VnyF{jv}yR5uZ;Tlqpu2?JsSJBS5k;2c4{sI31($P!gP5dW~!ho z{797h(lZLQ2DJ=xdBtnm!c@gHa)dLfg_~W zY{05cq(PFx-4PeX&hb&WvvLlG8LeL-71#!cy2KDJumK`6TGFLLiw&5DTn}oIVxbmp z$Uwd?MVxdvS@m$mp=!NKYZ@E9QW7360w7hWih(~moAQrKHS z?tI0mNYKP=?T~h0B&+uN)AjABI`E;xMFY6-zV&Z3gK5fQxRA!?ZNHcfSq zJ|G019jH0$oSv@APn66d$dPO7$~#zdgzF>buidl9o$EY`;>)C-9k=q9v5X=CrJsYn z*xEN9o7*}K!!oLR0$wvRzV#?jj>kT(@tkaen?`9)P9y`{(4DkCnK6kazmt-So|Ufn zP-+iQEkKst>$+v&p{79iD{kF{x=%hGWAp-8S~TgnWdOkcnFg*q2UX1}4&;+$;S68` z?6^W0fLe-zAq)U5o{$;n^{qZ*w;+1}_KRZ{;y?gd^?Kk?%D0-38t(1@{5D$Oj(eyO zL|4A!XfO!fEM`c$pt|Bo=yiMEegV~tl|xgz(bE%f0GX+LZ+CYTS!gUq++^;DCs0D$ zFs3Y!F>RWdE{;T%nT*Cl-4-h3gB?=#H?$UiNUP$&iA{Vq(z7|0A zv}<&~p62}+HSroa>A6(=Cj!xMj*=XS&1(pcoMnn^qQStZ>EJYwc6YLeV%Q(I`C(I5 zD^AgAO(xUs`cP-PH|7YMrESLf`tD&SejXIn=*sG z98BC_o+dMM3lQtqBJ#g*fGno$7Hk$K>?~g|m|4F%N^X;{-qQ5zvoCTHD~R>0tNc>J zbDR7pSjHgycQvW@Kd+m=uiERSoD%jst45}?=$^AUBdRX%H!EnkR+)cSQD*rYEQccd z7bu=+T|l}#sXd%QI^?dwT}<;F6x6A%qhrQdQac?-DCb10VDt+|8#XxqO~Ow1bh~qB zd+o=~>Ff5|X`nwrE;d@bZT3b$vnXM<_S++-F9fgeSB&QS`Tc8#KSD8rb96s=Fir0i z=qvHrOK%its34(?`RC&=)h8Z!Px8*J`Ruu0+*-w&F^ldpTy;pGHbWjs0lp7@%@_L zwPrlsLfmS|_*#}mvmR1e`H{7|!YO2V;Z`mLQJ_MIAx>A%Z7L#-^v~L-~NJG*%b}9kAbr@l69Tlc=S>1GPmFLZZ-GdU_QHujefIT>JbnfF)S=X+LKSzGebds6j=C7Fe zrVQ5uzo#O!r7tc8AHmhCZ+k7co9S3NJQFMBh>nLyM~0C7tfR4^fAc}wU`@#;m@P_C z{pZU2k)~9c26o_n*<=~Dnigf;#`dL*e|IA5A^AIIb6e6rT!Bm%mFP;4wx=M`?+CW$ zv;?`0EFZ2gnv+Cz4tyGme<|fkZ%e4&m#JcV?V>iADkC!_x-z@aPJ>=71(QoOh1m-~ zB{z|Cpnsk-RQ}j zRWtH4Nr?lN(0Oh&Q}SaxQd^kk7tA86{UwleXoa@I3Zg*EPzd|p-ze&$O*gg3Z!m3J z>5#b9r1u==AS&WRHdq99k8j<57`menLlm9{;9y%-kJ1h~EHA`X^;1$An&H>KnC^+V z-=X8@k|O21BR#W?yS4AUjD-F6(9Z1{PI})oP5MnQq{9Xj8;Kpi0QwyKC)S#Llt$6VS9jo+|`H$u{i@S?*ctsPiXhF+>M*0RMdA-$9CJ_6B5D)olD zE&jPKVgXLHR=sgOQ=XAz8(bH!FcSC|L|!o`4FH^R2f= ze#mx6gudxbUv#8)vXcZlcR+LoHKP}#5QVl(nFIuB)=>F$5Z7Q`1arAYYzIM)$i}LTkDF7G-ulB(T(zwEMgq4fYWuz))r1TENsMe3zF3pi~fztfYlIOTtEN$cA z(%RGU3+FWL+%#|l(+ehqpGbHzmofYTTces@yw^!-?I?uWGPD_Ye*2+M)TDfm6tFJb z=x;9Y#SRZK0=c55XpYXI2RQ>F-u#}`I{&DUPW4-T;~E?9>N|Si?+*xuc2ee-6~_{0&wgH2Y_46<9H=t<|UjHVTa%#pI?8e?B(Ggf`-6Bbbv98e(2*^5M3Yu z3-(}`Gg6N-zN229I6+X$>4H|%e9)frfKz>c6bG+QT@%k)Q5gRWSHI6xOAyCy-c?S! zY*lA!=46igXj%m!$sKbXZkVF_r$Y-&nvFeu)&sG`Ok@@6zPWmW{mf$&8`8(wV^}0o zN>i%{jUHnKjQRShjn}&8diW*`C%E(Q=zzHk9a8#_0mfJ{fa^i-yKe5RxH=v!6l49s zJK9jrk6}pa;s~72-RilUQZE&6G!?7-s3U1jGr^gH_n&HW?pVkCN?|$Dnhb1`xw{of zcgk5ycBO;^aVBK$Aq7u6AUdVq)#4Et%5%dkG(T*H@rt)+OM7_-7$iqp_bF}~MU@0h z@&U&()v#wT8A81D7=m~){0e*9_KiK(HjF3jYrfUXLKMl~@I9&OZaS#Cy1#Ek_?L=# zcu{w>D|a0d_`v?`B5)$5aTxRyJW!MXonehvI4E2qubl* z9H5ulg3;efuD&HB=!Qt-E;S^v{}^8faLOhC%$8wQ%pi{B9A`T@;7@4k04$$8^UeoR z6R1f$mk>7;ANqjW-lH`|u8ZnAvTlGR4CNSO28$WV^NDJH8io+u)`2dJ0df?=st_aE z%iZ5>@Jp3Ptd-adQIniqqu3jdR#xcQ|nrZrW&U|5pylUS)8!_O>!mq&Hx z)X8|rvCQ2d^vT9%BqiuKpJS8rlCY`vo^3}6=wom z`}eXPnA_2YG;Oa?07;Jh^YMX-sx6oWk~P>}5KRlt@L62PDd4)B>8G(H1mOC7SvQXw z#@!8SSiWZjmVY5BpZ8{ORtQ!?Z|tQV1QWH1yk{ZT;e@8ZHVspR=bU*V2lBCgj54Ow zl02UMXuO%JxyeJX<`9LJQ}RRU%xX3(kk5XuzcuE9KYc8%EVof3S*3N%z*0VnuAaVA zylBy)v-^OV&iMr5!Sdzhbp_QlIg===R2oO}a|+}sAi4XQIR_vNEz*iKI+I6LWFeEe zZs+{4hS!AZviR^IZ@$L_sg)fw4@K1RzS4~}mpJYb%~PYm4(87iX6L`WL#6vZS75<5 z2h6Hijqd*g`@bvFcmL9m|D9yHjoG*?Kws8SPL3~89VaX3O9R2o%KWv2{c_lUDeT$5 zkRF^I#@uFKY>%%If!Pfk_cMPD7ekG|4Jv z{Xl#Jlxec8c^6%}%%PA`^4Fh`kjO4MA)Hm;<;K(Ibhhh>Xa4DnARE;nSA@72cl_I1 zhkx(pfrq{PUwx4~aS!M>sCmCb&vL(f)ListHgny)tIzZGVSVSl&$_VnBaoB4VFxie z2mn`Dw(z;eHldTs^+Hnh!G0T;*dxrSAX?;*VUevvmn-rnu5qKI`B!c47HK*=B@JV7 z=lz5h^IyjDx73iudS>#^OhIcV*v^Jiyh(j{UCa$MtvXb8PMS|uW!1UKr&@o7BVDm0 z5;mTy)Gf7wt}P1sbbj%xYKqD*{4a+7$Eg9gMnz@HpNfkLax2Y8!-84<@+&fJ^+s(XVS7+`mkff3AU(>YzQ)YE@+-_L(cg z+~_HqM*4pCRv4llTtu0D9Ey(af_0p(`9~rDDVzrzHJ{{lk0Q^Vk+^sC?mjI2p+Skl zN!!rVwQX93!uoH({thp>so#cTM*CTkumI(C`pH@^v1MVkVk~p}(?-*`m=mMJgPKkl z{-1o1$JXd&Dnsa<3Y7%~&p*?5E+uTfSBlP7>d%N=)$|ls9hqp{M_}kl$$k$Xzo~?n z?VPS;(-*wi{vb4FnyyOGUJX@_IXwdONO98m3|TEWP*j{>8+ymH zhi*Q1fSRPIz!}+z@~u}J1sR!J0so*A9eT*#Z7_}BmZS8$0NjLs|G4UhM>47bk5azs zhiJ78MeJ})LBv+J1`mN--3-2V!~4TiR4^V$-`sH3Z>vbqp9%*Dkhv79Spvoh9kn-~ z)1EltP~cRXKr=TBRbJ`;eZJ@%>)ejyC+M#gU)MFhNbHrpVRST0M+C=kJ?44pPc(f9 z9>06Jmq#ijj2&9#gN3z)DgMb1W*0D2DJf46$`KH007mxnCF3@gR6)E0f@ zD|G`|U+g1B{j~A_o+F2$L7;{~CtZH$v3CVGdw2(tZusOjM@#l%l1iJLh*BZlVM}~B zGFsGhuV5h;IwsU7u~0E59my@xgLm8AWF>)ng~c4Is$8j4`B1GeUfS0169M@%ySnU^ zKtQV5K>$DfXE(-Lx7f!jadT8lJwWzLR7iY|So4w72)Cr*Oxj52<1b zkr_GJR`0^WLMeO$IuUc1EW0b=Jqi=XQQgT2D9q9MW~(?XC6JGYJ~p9bs0$$gu33GFU@?GitR z(>0I;gp{JYx5!AgCG~(|PtjCsmvCI((Moe{oaeGQuAoPQxeK>5Ei5mqZw8 z=j~YxR8AnC$|p_z$fang0~fJ?3${BN!|Fdnacr@vQssD(=&JBx|8hnK>e1Au9V*6A z$zH@5o3Bt#Fsb$pBBsmYdnIlZEZDPzzoqc`&$?wT6?-bYNo%wF<6H>^?1cJ}~ z3Jxs^3Pswp8)wJV!hv@5g3KCA`Uw`N341AI`;%-$luYI4Np*MAI9dEEOi;UWr9tI` zR=57n?qG4Abw>V*3M6P6oJ|Xpj{yLvp!(s6_Cm=>hUX`3F^auZ>tV#B#YVkstIH-_ z6Cv$kL`hmdx{s(+?c@>>tSiM@l|W6Y65jrX4gF>7njTZ}i95RJoTf6GNvnpSHq+ps z9Eo&iCBx(U5_!XQ2~f>)hN9Ik3Q0j(KM70K70uU+FjT#Qdw1JI!T$q7xo{!XL-{tW z2I?4fSR0Bo;ga26bi@r=EC&)gQZ4Gyd>0zZprcl&zRwG7p4>I(_{rU%gvt$uOC zQduRy0}n6=u^0&u^}a0}3g)svt?QT;QbzIwSu7g}pDs%Vf^ma5S}YY>wf^OT!>Hsj z?l;Zm^szv_O(Swa`hJo8WKW^sVFaVN?b*EbH|zInv8=NC7B7HaL&qk`tY&jG^&Tml zDz5Z3?*duEP4-aT8QO{P`&1EGVDR0EBtToJYk?k$&A`WLBE8)H3Z#|}ELJSejJ5#F z!s}jNGdvjSY8=D}Zv9t`Dy{vpi2>kJD~}l(G!fCSLx1Wmq-lqYodp$BimkoolaTKL z0{6bHnknk=22 zAYfqKNdXoDCac(9lFwOKqO75XI^JKJnhkS#oVKO*4*Qju0Q3Z~_=zIzz(4cU z#7K@%{TF1x2q@aHK~Iuk-vP{Ydb?)W@c1|2Jz!y}9y(7`!ZF%Br3h*b?OJ98?WgEv zrU{&euE`7+KnAFP?dtT$D&{9-eYitBfS-dPC!}zmgb0T!T3c5C(^p$k@xv-qD`%}| zL-PxHaEhMbj)!nPOO_IPluo-pr^{oLOS1HEGST6Pu78r|^ZS)TKN$fYoU(7sq@sg} zJhl?x`hGh=A^oslswFUBsz~|f+T8*_)(v^$Be*Y;ZiFGRFk;6K|6c(Z!Ef}o{~ZC@ zzakwMGduU!nM$)S4VbwxJ2wl<7lxJv#A(d=wF%~8261t6n{aV6o11f(vapz&vT?Jr zGk=k2InBTH#sBA=<@EnVz>oh^W?E~hYJNpPYm#49A!+=>AsP~g)Un?XVT4=u4sn0G zUy3J@8z~7Te`~X%wGvZi7K%19k0yTTf{50^Z>22-qcO&kuJwRori8$PgOv%TcXcpp z{@^^_M*#Gbq%qX9lI&{$E|I<=3&(B+Db*fP`=P(mEZaO zjvap#Pc71ta|-^BE%;>%QyzR_Tu%TxsfojMEFg@nDF@6>h4y`%6%mwBE4I)R5>$OJ zpxISuTDW`ELW{@IRc&;EL zwg}hobcf|Z-Le=qwuT!VP-%1;phaGFtaK}H4RnNz!&)=(CugKgg${Jub2sCX#ZO*^ z58>%FPk-Z1G&mk!J|(D%k;2p5OHos^(yJ7lc5Q)kp4iP!U*M_iANgwxNBv~!&wc}W zZhG3AB4&Ya9KNi%sFwWDm4BVQsSW|MJO_bXN6CJc0=Xj9w9ux?r6lpfw-e&T{T zT|<;25TYfxNs-2p#O6PO8T(L`Ii-fQHaW;i%Z_L0#_T{AZ$ar#$d7TVh=6%y;}Qo{ zxuo}-w8qdTz3rH-Rj9eS>xv~^bc^v8O;D>;t)!yl)%j~d%CBU0XP8ulZcZrV6A<_1 zSeCJ_Me^Ycw^JIB`^9U>FkGE4=0Q&=)#KM|N+SftSuze2)WTu?_!|X;ofJH(OBxUN zJ^VKJDpkrtIlr3v09%qJkw1Qmi!M-AV-zju-3RxJfrvtxJc%sj7*vxlg0uxS{&&@8 z*Ya(ThCE!>w_SM3xa=2&fk?7BLG11=6R#UR1EKe)d;z6ZDcJ!q#kzn}Yl*=LJ2ui~ z6D(eoG(P6??x;zURO#Bwhdp08zvA6;xRE+sd<%KGo-XW_x~_GlxAjHuEslx<4nNmX zs@+~T_Q}{?M<$;9l|vY=_#>zoy{c>{zq%P&R=gC2TaljBI?_98p|TYBd219U z5m|jl@0TZ4zN=QeEowVQQliwHAZE(T8xEY`U)!K9S*FYWG!m1g4Y0%qut}|A?`y3j z=Wl0d3tqiHk2}Y|{F>{IqiolGsyY(rlvl)~Z^|TMmB@Rf8r?)ERJ@k`cNiTre(T$C zSQ6E?y+qXthpSS>Cn(i#)RU^{=5;#f!K8g}@V9(12+u)w1<`s>2*d>C$xKwq6eg;l za%OvQLGSH)<==%&fVb*k<;-WfgI=l9N3=ordda|CI;UI#z~ z7w?AY>T-DDZKKJ!?UK3lc45iIw~JdBp8o(zfW)$tg>a7KZWB9(dt}P64wIZHU^{5y z0yD4hu8c49%k;xZ}JDj zeUcrrkFrGGnGn;Brjj~VCaG2(MYL<2Jv$6vK9S@KoWwvDVw&)J!@AKs1gWa^!JyK6 z3Xu^+;YO!|HY-BX`jr^wsJ!j$a8kCE6Vy&tQWzvNhie|> zqz8bvLf2~>aN|(2N<5iokqn{!U<3N7;LBvFKs-a4jM9t$-a}*d0d4l^5`QOGN)@w( zgyD#kA3O|pbfdj4UeOI;H3Q2gU4FZfNVZ6Gf%Us!h`EOvPc5I(H?7W3do6?B$xlg8 zO(WjZ-Ue8EkIopbZAR>;3zq`v46=6{O7ABFXDU(KE@R5T zjMY~>BQr43NhqemPI=+4+})bplC@m9?uKAQ1j8eLjJY5gr-$5BHLI(sYCNqGUMkg& z3L$En3OB<@WqA)%N-z8)fQE(RpYA`KApC8utt+!_X}hlrTjPFL>pb#bkz4qq#`qzp zF6Cm!7)gF&ixy22%6X!GI~+m$njrqg+6TK41-pR;;NTIwoynP@3I%9Sjqh3XFO9zd za3t7rJq??leiuGLq(CI;SLeI;lnSsK((2dx;FF>3rnTfHz3kM-SDM_@JC4e_NVC0CETL`ZX*9w@!!L+Rf9eWtS+joMnqxb zFY_Ul-Su6bxj7u1(#2y?-$kAqE|B!r3Ernpp8%{WrM^?hb|f_2)RTwP;OMRYaIkE@ zPr{IO{;FHtnTPf4S<@3jxA?KzHHoQBhIwR~PD=OlUpYz5(8DfX=!CW-cHhj184Vb0 zj-5NtBbLegfrngrJxH4-2o}QN0Er8QjAx75&3xal^s}XH9OY5J$oLv>=ic%Y=O4sv3_gBCP0!30R6Ew3@Dy zsg)Ebw2V)_| zcWmp8^`l!B)`$G_;jZ7XrPq$*;~p!McuiMDiHzG~$-U{+E`%?7=6@0S{P({-%A$YW z!nRysio$$YC1g2n?sGEU*jQ*?>F>qRNRpjD^a>h#Q%a9XpfXc;pHn2sD zm+zD?q%3>DNqWYsQt{F*RS?~UjZZ88ME<{9SI{Am`TvgG|3%vYS#UCcAqJT_xS79X zffg)Z&Olakb7mGZW^RiwdNPZJDLWSzhlLpj7niZwe>nz0|CPMW*-XAT%>Ow*;EMF$ zt!wRnBKO+=w60=Xq*tqQh3UF6NNU7oN*8TyO&T^4W{ix(a!n(@J zR7Hq*+Q=aqGD~zI{XJo1&lXJ57J zhu{sHc)`2*#_e1=XyQ93G$n;)Z7L*V7Dfk!uY=KejgVc5U8vMj3(_^gJA42wLPlI z3CD^}gf+{Ry7%VrQmZa*Qy9T9X2VE^_v^i~oHE1NaK431PAd`ZyyB?JvHkFk$w0xQv9*|^u?2;($XtR{6j@;`408bDiY(QeHF>GsSroQL9TOl4*BZfkmf(d z5rI!)@67nmzf4kosFvT^%^gbJM-*#D-d$WnJ*X@bLwJX&j_yv7jr@cEwHM?t%w73u zOA_AJ6eb%hdgm5d>iY$U5z?gl zJc_btq>6~1|G>4~xyHcL%l(H#pbA)exOUU@J*zFL2WHAE67@6ovycWAxRC&ScLMoxoip8^tYs817$Zyv>vLi zHvxbZ1rfoMAx9&?xm_ft>->{;S%M8Y{(N7eu0QLGhU*k~xv3R<1JT!QfAp-;(&~k97RhHgrGvk;=HOywp*H zQZi2xg~>qFUCp%RD?H6i+bbF@`LAz9tI5xx7nWUIwYa#}UO5f{Te-f1t$#;L`71r2|<(mq3C06e~n zRSe0*b?cUSe+?-J>Dc;+Hd@J1T9YQ3tFn$1@)! zhQP8CK&Rs@$s4sAv&%^-VDcInR5^mQ5|(O%#AD;Xjg)Q=3shKA@W6rOs(LKcXl zRQ!FGQBfr6MK>!Hz!-LGZDr}}%!hyW%6@++U5P5xGsXQoJq6Lg*FixD`RN(_x5@Vl zy&Leu`QYt};X@34$V6e^T9<9my$e}&gQz?H^7csyK`pOUETz0pmpjZ@=W^(3ri8{H z*14cnG}Btm*azk7!KJG*f(EUrCZE?Pq%qgl(^3>yhWNQ-4x_-9t zU;1mR$OxKA33R*K^9Q2s)gCRWFMu@|tS^fl`0&4#@D#1g7M<<4#cc;hB*D@Mi!fEq zT&MlkV?S>VQKq`3kaf%s7P=IHJ-&zrw+^MR7vVIHm`4yaVCDU-~O173Gey#*-j&UGO3Di0?k_zd|AAHC6bZ zNV{IfsLugKbX3k(r%5m=11aP!Sh2_`qqJy>tx5v47U>X5;fT}Qn zny`YbeS8?k4+p&Tz!=MB$y+x`br)Yxn+}Aabw^=0Wq_dUKD@2Pe>g&#{bEPu^5AF!t zj?VrX3bFCVfO3vO*rBfQg=Wjs|JT}g$78+z|KGOkP3AE}=-$JuC>dF0SKLz}sf>g& z3XujyS&57?A|u;PDvFkf%tFJcL_)>yb)V1YJbs_Xf8W!2oRiKYp7-mzUh93mU)S?_ zL@m$a))G?Z9+G!uN$L8vck$`;rKj9eVG3K$pLm%*DNQJ>&oxzf&NtGZ7bo-Z1ZU&k zgVnayQ`wJix_&q-{yh<4xwdn->i;J6{OTRckZxoB&wujEcvG=IR{TI=sy3*$R zsxv}*@HSaI+F4C)k-n?h1x=bsGi!fP zrzRih1c&D^qKouXDW|LIs8yLo6YDvZf-I(=7-)9rs-ORGHf;0O?Pc~`)6(YODBpJw z+d8Oq)PQIaAQ#1y&1|`4u6e`Fu+G{zj5>FLb9_uH#mJ_cTi&+!LqqvOc|V3@Jqt-ITaHn=kG6v?@bvOxh&A^QC#&gH^FjY z%Eub1r8>25s7L>s10{qf&)JN7-TmPhL*`3u|3Pc3`EFy}hj!hmP0S;dmLu#|9ldD_ zSNn{M-VRB~^BfM4juu|HVyqEn2B{oYerDOEftTm9`f>7jdHdfVFbxIBGQ zn)-oM9pjl~vCT|Wo#V)p?1=ML8>YCTd#RGD3W-}P`8t}CtrDxRq#fvt98XPs8@__^ z&}h7}xMz}oPu`v@pD-2iCfctb3%@=vj7(P&ZM)AOZyEn=&RJ4|&M1Ge<-Jcr#puz4 zHp|O8iy^&(#~V=VRCH9{b!Rs(r|orDtedw<*%JL1dzxR`x8A2G8Hc;xRWh1iw;1Ia zoPSSADrNxB;ryDB?=zu1cbCt^oVt)^5^R%DI}sYyo@=Exw@!De;oSCkj>%4Gi=+l? z`>G#-bMy6|=nL1Q7b>=P-a`g!S?2nr@9JHt`JSkoa&t@ir%sv>rLC-H_Uv_&rui2d zL&(Fjww26%)LXsOxHaiw-BjoH#r9jzlpe4ZO{^^!jalm18GLNRrm+01wcg*I zlmn@lxkFvsK5a@w`Tv;A4-@v?8u#qf#Zb@NWXj;F{hyf+t{Zq*k|L^B*5VtUpZxJO z@y9w(pP@c&=^*}us;Qymo)zBYp`0`PxDqGJ$29SDjNQ%)r_8UZw9H&kL2lTc$?(n%x->Bt@Obkr-3=up<#=jZskaWrOY@BC40eeQmU(G}}US z#YI{MPtdE!&cWuwUhF}-p8UK#c#26vqIg}|=5;}i@*(+Ej#I4~mooZl8cKaE-hD2HWXIW=i{5j_<< zdy!U88s(!c2yD?x;xft9*O3u=tMGJRy>%LU4lmkj%kq~;XF3w!y3u(p{KokD@!18% z-Zv|8x3hM79z55-dw2faxqkMZkvTn6MzfU2mKRS)O;y4&i)A+nYu8+vh|%p2jC9%7 zEpg$(d4kEc+L>psPbT65i!?9tZrHw6WF#r+Rt8nDns+7g&J<@8TOusc}Vkyjnt>)g9IbPX!Ctl`N}?Q_5v={wiCmtxp&M?OI{YftRRB^ znr&$1RPC&0kjxf~;UR`u#WrW~i1uPL)p&|gw{F*f zu@)%6QkoBw`AwsC|haz_qGff5pENC=t7Hp!ABo)PZk;Ycx94 z-Ctc1av7HOKSKKx}_KV$GWUGF7>#J6^SecWQ`^wUe>B;vI6Xb+TSKpS4wjCwu3zdar_0_l4 zSCSPyiww(OP1+fXmo*({E+ZBS<>V&raU<|8Cx5Kqm~QfY`-G+y2@A%a4<9_t?32^! zl`i8iv&;9UwMPyyDWNXYvtRZiZ!pT|&KXL{%`i2d$zJkZTL134$Z;lq=Ah@uAe$W&g z3?$2xzO#kOl?A){?)AG&)O6wX z)DxzGul%FJP43pKEw#P7sK`81Ld8sdGwVL&khkV`sz$;I`;$JmC(fCqcbNpmIZ8_8 z6$>>CskwadRPiJ?`tYC1K~*drx1WgC%A6Au>J-|FNXIe@VhYuk zxA930$W>u@$~IK=iAT`o`)&pp5>h@!I+picc-EolVzLhJ6~Q+B!f%=)jaTjBko(vw zmvFl??O4q->7493TyV~Xbq*z`I>mbGSM)3F0LyS=CYBAr{ANdO9M0k~X z$gDS1wn1qs8h&V%}cL|hZ#J1eN6QvwCBCy+P-1Bsq zQrX)_PuGP%2-o8X$_qYVe$=(=-r83qimkJO+n7b}<%>re>Cc}V9W#~iB)2RJ{z#O3 zetyh-^_P=oM)SFi|Jz1Wuq2?zLL)J7TMY-??MNgE4a`||5^x+K@k9&?g@mq}f~11H z9}RT<&{z}+M#&7_WQG)w=_%-&iOOYoX`F90&V7u~UJBpuU zlb@y~No^7Q5VEnXzM<~z5b0*qmDRUjNhV7N25tq`4_AhFKU-t}G&Cq*q`{Z>$sXyb zTtQ_Gk~&TueJUwKOfs=4Sw(!oEc&a_!SnUGE#du^w$j1o2Ig}4U8|vQG|i7Brhn#$ ze`Ob3pIp9{%E%DTB^y(8t_OBh6s_Yj%O}jOPqrlYIbA>E^K31}@BY_uk3rHRUDVac zYrY8Uc9l?g8j#_YeH?GUZ+ob|rAeEsT}<`?73hIh$R4`zoi_L^F~+UfQG)MF(C>Otn2<3CTF^vXJcb;^X*?Jg6b=R;iAb-E&jC-ZdO z_w$pp9oK4RMdq(rn%w$i`ca_8oB6rFtQL$2*UllB?lsWx@rxHt+;g%6VfgB)kD=S+xFj`BuX||^E$a7Sa%Ov)NNwMgXU*lN z*=h`vjmPOh=kG+vsLXKl@4lwtB_|xI=6TLcNoaG-!C?b`W6PKb$5+UMid3UsQO=}n z|HJW)iK-6GeGJzJZo^`jHK&n+20#9a%)5y++<>ZYd#M^#wavPlx_dB4*1iiddSEu1 zVPsOZyC^^3WXr78n+1kPOTO~U*gK0o@>q{by)v-9 zJK^pXy)NbWu4iW+yt$poZJ4%cL+j-qH||H*sm{9C+36)`FOg$Qrb|uvE3Ze zRPNBRm)$LPCh3a%zAKlLVCI%731 zYCqOtAO)8!jZtGD2gvWYS>DnoRV zBBHMEck`!cZKADK?i|PNOoR!lij~#vG%9*Q{PGeV^X!rg_&=?J8;9 zLqOuM8;e|X#~S#VbMY?+%Wcf05!KhKAFsC;L>k{NF80HVXqUDcBZ@gk4^r=Qc zW@Zr5=a=>vk##67^lRzeauT)9pIKYp(NMiSTu|D8JOcZ5vhrY!WU z^`#@cV%*ZZ)hip9q@%gwTKBi~Gi7`K>a8ohzkSj)L#l|#YdpL~-T1)Q+|gvGoGQmM z4g^o=e5MtFaiis7YWt6c?l4|W7XfCOYy*ewTRm)jb)47b_C8)_zDHDJ_Ul@g+^D=w zmbe;co@!nFq1Ek6?K@T%-ZUjp*fwz}_C$sBpTuk~(M1p?gG2dz&sOaj9Mi*T*o@gk z6^+~r*K-e%<6wsMZ7Scwp~2z5s6=*0$mA@++;xO0+YxG{0M}RnGr?N=Vz0zLW~Tlp zVZ?M#y}4lNam|gdD?M`;-tY+?8RGn5fb09(H)+r?jTh`gN)|kiQr8JxFW7fn_O=a& zQ)A-lt~XJ7#iL#%EqtWfa^F~!wS#Vvs(ggH-JrL}`uGjbVJ6y&Dg6g%f=;jAn;!8c z#d^JG_FW1Xx*K30k&%lwZ&}*lZ&%LW`;I*&XQ$)HJ9lIZuJX~%8(tQu$!Vf-v;dgIE z#&77A*^Pjpj`k~+N$6B7nf1z$0Kf%SF4}ocLm88hq+$swNP9xM)K?=G%mkU zy7z3}f6#nwTqk4p{X3)0UoDWKT$@m9^p2HnS_tg&K*$?E+^m+#dA2ESLwuq7fP3xB zcDqm3f;CsiXXJ24f+CSgS0;+ttnP`G<*Xj(w4wGDM6Z_-zT#BD+`DHtu-0($`X1tCF{5A!L3~!HA@z=q0iQOk(M3!Cj5W?kn z;2wW5Q;wBvj#Ryny!-l_?axH0S>Hds_ukT=x%T*mg4waBqwZ5dg9E#4ycq`fw0lbm zM!t>mNLs0-X81g~r6dqDHHIK6O0&r->V}(a$Qf=O*7oc_XCO6ZayC7%JxN<(IRB_A zhhVT0wO;cGCS0O^I6B5XVIHHVTx`DPMjkX$>tTj;#toW=n zWVKK|Sh|m4AR;k-;CQ}Y4+hZ9Fa!MSxBaO+~e@ike&Wvc@q&4yF@NenJw7M&0o9S!K_wex8Si-Zfc*9 z6`RY>uYN*&O>cE9lDOm}5kwgbVK;yEje?N(Op+UNq^$9?mH&hqktf3I9rF?A-KT{d zOBEJ~jH?`>6I=(-!DZw3t)s*%M+f}K-*BVUN|^>JfuZ8T0=91z3cgjIoDnCzk>dBh zZ`WV*fo(KuSy{q*?s=qv(*7N@f+o(~0T25g@$IP;MyxmDu1g5RT>G-^4oF5iuenhF z@`XWRP%R~VXV{AGHz}v4?C3r5XRnWcnjhqm#o4d@q}4K4v*qw2h?I?#yyxG-~{NLQuDuC07mn=05mSEsN3K7mcYa))T^=4~8$(?j2F_%&t(?&f&b zJ|ro)-1L35A$c^nlw{J{trx~z)T5PF&liC?uux&*mc_&wG^^N1WHJjCLJZs&P z74~h^>(YF^1XW34(kXskpmE@kAG1{ziHO)f{~+eDpmuR#<{gtyi_2HG2yeU-l)GSk z>w!(__wekh@mFT4AIB1-5x#bbo{XbBdHUL6&h7qN$t4CZ<3jqe7P_u&v2QubmINM3 zYDvCGeQ=g}%5YF&LtJ%@hfbNJqHl$u>X>@}0S9HjfuB5nKK1|0Vtd*D zKCUoGbeJ_qp>ae8nuf;^k#Nlr^cu)C3ITLEu_zh{bkb1p?7{$k4IGgM!|7Nu4ha_% zu^5nbApi5YQuCife?;Vee@~y5o|F^mXt%p(SCWY_`h zn|n<}Jl`EVt}@{IxjbcJA^Ge{xk0H_WAZ4y+$a3~$?@QB16Q-R&FTG!U-(4E+~e8) z*K8D)jsX+#cnlq+DNtxCjWvbO(yJhl@dO5qih+kT3<=L*5J(g(jzUIK2t>G`Nk*cH zL^1>QkJ*p^Yi`>A)$BJZ|KIGuZ^CbE+S9)st?%v`m^wYoTS{N8npC=D5aZaA;c8nn z)H~VW`GjJX@u=*(>&U9}oZiTeOF{3~nU0@dO>|Yt5U6+l@^#PAr1|9@@08HptHxjP zto2}H*X4lwJ_wd}m@>k18h??sS*(pSgMa_|6~V^(3W=n_BP$+H2l{6$Jh*~;fOZFg zKm!3Ya0nclgaUao6oF2|6KF(G^Pr*_7%Y(aVX+uIycS}u&##CUJLa1&!Ov!`+0Tey z;s2mu_6m=|(U1%@l}@5EU=RzYvWTE60(*fYgA39~NFt2!V;M9o4xErgBO>9Os0=g` zhP(cL{q!p$vhGVpHT>+!-w?Uj5eOUsO9izxG7VmQ;ZS5M0fRv?=oA>Xbm}F>F7W)hagQH;nR;T?HQLxZo`vst_fmQt3Mid7EK_C!WQ)MVR zos2?KkyIc(#L{3S4F}RGG`Q|dCgV^bkc*?z2}A~wWi=8*LsO_o6cP+3V*a$MViwf? zZ-A)t8{#oeKtzHYld-H@BQyq{NTrZ*I0}J)Bv7dY3LQ^@y+WZ-R1_A6!QrWB0uth% zKqp|abR=9vq5sjw6P@Lz8i2OrH^h%z2n4V>f~*UQMkQnE6ci2xeu5-Za16*A@Wv1W zjs>@wfv$&01a?T!0j800Xa*V1N~mZQ1xx!oq5Qh4?<)$_9RcmwZ-@yz2m~65Mzw%sRhKK_?B?5y2F-ixE>0}m(CyIb4qL7d|(I}`7c=&h(D!^eNSP6f$5wt`|I)LWz z8{$hi?LyN@G(1EZ3Jsx823k-W4o`;b@if>n9EFI%)9K)#Br=Ikf&V}~g4s>jTO0xQ zkWQgc{}dg4ZYH0|fOh^j#4s@g0^q3NE;Nv*rs5bVR!9&CkogH1c*70B0p=1hXex?? zCc{)b4v7NihR*=0PXp2h&v<^2n-Pp1i=(69nAoFhnw(50W7L5>OO63E+T(|L@bmUpKT1n7Bn_84#s#E{cVifW(F-0mT6w^uNJbQFNd` zAdrz5ND&k`HvQ8+Vv$nTet=f>8)CQ;t6HH*G|-*Gk+4`a_#b3bBFJch0pLw=8Vgzm zklwIlG66mY9K50_G_V@>fkr`5(U?C*#}f-K(*;1|g)I8>s*+W~b1)1X3e2G+kuV#9 zMPrC84l+6lLkE3xBBWO&o(4FOZRk{JB4EE^>X-s+fCC@LVgH^d{&gQWi)g1=0Gh{d zh+XR-uj0s5Jh18#sZfR}P$g*;2u#3dK-xr7QDh)MgRG5(SR`WTXet4kItpY}8VU!T zyd>nGR(0aOu+U{dtNRV{v<~bp1r0$)pp)?oIuvyV1<$}h`$Is%fC!vnlc+c%_#L`5G6WJD4Lb5<$Qwi|i3qA3+gLWc&0j>eEEv_Iz6b?hVN1%QTxivP3c zgxUZi%Sp*_nHEogv3}S%h(i<&iwCd4(V@pjW1wpz!9EkAE5HFcAdv`O#VS}R3<2`S zpKL7fag?0~#Dd=tFF7I*Py*pb0X#Dhkz_IzM~2Ui#p4)c0vXyx7BeA@iep_OU~yif z@K^>3o~;!Xi{5h)4~RiH)x*~aBvfC#3M8K4ME0S`e#aG=T1WT3Io zrV#<31|2K|!UQ4#8U&y}M53WW)1Y#Jatn&^ALf`}b575qtv3$PuKtGjnF`$_15bmr zji*ES0goc2JP;`+kzn0G?FEU)g0n(fN~3{kWFYs3eu%zgI19NQp~Jc3@tbsf)R2H9t)+D3Xj0> zER1GtAq>Z1D9{K&cf^3sj!yZbud?KySrW}Gx#pj3^g9N<+`k|O{p&umWEokVDN9E2 zXT)u>u#f+ONaL@FEb$?0`YHfNBxS(A}3%G2(ZWm zent$dgNpnwP!;@&$hw=wxdG2NWsRo(j41XLD$2hw z`1&g%Ybt>C=AQKy|7S$kL8w;$!i>PLh^&LXNNA^iKhNQP553&KaLo5BBI`_zb?){1 edCn>z{u?K3c2-QsJN_QyI4bp57DQW2jrMo-! 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⋙hffI8A9#N#;jCY#)#qjamhi} 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 np.ndarray: + bits = np.unpackbits(FIXTURES[key]) + size = math.isqrt(len(bits)) + return bits[:size * size].reshape(size, size).astype(bool) + + +def render(matrix: np.ndarray, box: int = 6, border: int = 4) -> np.ndarray: + img = np.repeat(np.repeat(np.pad(matrix, border), box, axis=0), box, axis=1) + return np.where(img, 0, 255).astype(np.uint8) + + +def make(data: str, version: int | None = None, level: int = 0, box: int = 6, border: int = 4): + matrix = fixture(hashlib.sha256(f"{version}:{level}:{data}".encode()).hexdigest()) + return matrix, render(matrix, box, border) + + +def warp(img: np.ndarray, H: np.ndarray) -> np.ndarray: + """Bilinear resampling through the output -> input homography H, white outside the image.""" + h, w = img.shape + rows, cols = np.mgrid[0:h, 0:w] + pts = qr._transform(H, np.column_stack((cols.ravel() + 0.5, rows.ravel() + 0.5))) - 0.5 + x0, y0 = np.floor(pts[:, 0]).astype(int), np.floor(pts[:, 1]).astype(int) + fx, fy = pts[:, 0] - x0, pts[:, 1] - y0 + padded = np.pad(img.astype(float), 1, constant_values=255) + + def at(y, x): + return padded[np.clip(y + 1, 0, h + 1), np.clip(x + 1, 0, w + 1)] + + out = at(y0, x0) * (1 - fx) * (1 - fy) + at(y0, x0 + 1) * fx * (1 - fy) + at(y0 + 1, x0) * (1 - fx) * fy + at(y0 + 1, x0 + 1) * fx * fy + return np.clip(out, 0, 255).astype(np.uint8).reshape(h, w) + + +def rotate(img: np.ndarray, angle: float) -> np.ndarray: + h, w = img.shape + t = np.radians(angle) + R = np.array([[np.cos(t), -np.sin(t)], [np.sin(t), np.cos(t)]]) + center = np.array([w / 2, h / 2]) + corners = np.array([(0, 0), (w, 0), (w, h), (0, h)], dtype=float) + return warp(img, qr._perspective((corners - center) @ R.T + center, corners)) + + +class TestQRCode(OpenpilotTestCase): + def test_alignment_positions(self): + assert qr._alignment_positions(7) == [6, 22, 38] + assert qr._alignment_positions(32) == [6, 34, 60, 86, 112, 138] + assert qr._alignment_positions(40) == [6, 30, 58, 86, 114, 142, 170] + + def test_all_versions(self): + for version in range(1, 41): + for level in range(4): + with self.subTest(version=version, level=level): + data = "".join(chr(ord("a") + i % 26) for i in range(version)) + matrix, img = make(data, version, level, box=3) + assert qr.decode_matrix(matrix) == data + assert qr.decode(img) == data + + def test_modes(self): + for data in ["0123456789012345", "HELLO WORLD $1.50", LPA, "こんにちは", "ünïcødé", "mixed 123 ABC xyz"]: + with self.subTest(data=data): + matrix, img = make(data) + assert qr.decode_matrix(matrix) == data + assert qr.decode(img) == data + + def test_error_correction(self): + matrix, _ = make(LPA, level=2) + rng = np.random.default_rng(0) + flipped = matrix.copy() + for r, c in rng.integers(9, matrix.shape[0] - 9, size=(40, 2)): + flipped[r, c] ^= True + assert qr.decode_matrix(flipped) == LPA + + def test_large_modules(self): + for data in ["0123456789012345", "HELLO WORLD $1.50", LPA, "mixed 123 ABC xyz"]: + for box in [16, 20, 24, 32]: + for dark, light in [(0, 255), (60, 200), (140, 250)]: + with self.subTest(data=data, box=box, dark=dark): + _, img = make(data, box=box) + img = np.where(img == 0, dark, light).astype(np.uint8) + assert qr.decode(img) == data + + def test_image_edges(self): + # a code touching the image edge must not lose the rows and columns left over from tiling + matrix, _ = make(LPA) + for size in (200, 203): + with self.subTest(size=size): + img = np.full((size, size), 255, dtype=np.uint8) + code = render(matrix, box=5, border=0) + img[size - code.shape[0]:, size - code.shape[1]:] = code + assert qr.decode(img) == LPA + + def test_eci(self): + # qrcode_eci.npz: packed Segno 1.6.6 matrices, generated with mode='byte', + # eci=True, micro=False and the named encoding. Mixed also includes numeric, + # alphanumeric, and Kanji segments after changing the byte encoding twice. + cases = { + "iso8859-5": "Привет", "utf-16-be": "héllo", "utf-8": "こんにちは", + "shift_jis": "日本語", "cp1251": "Привет", "iso8859-1": "héllo", + "mixed": "hélloПривет日本語123ABC漢字", + } + for encoding, expected in cases.items(): + with self.subTest(encoding=encoding): + matrix = fixture(encoding) + assert qr.decode_matrix(matrix) == expected + assert qr.decode(render(matrix)) == expected + + def test_parse_data(self): + def parse(stream: str) -> str: + stream += '0' * (-len(stream) % 8) + return qr._parse_data([int(stream[i:i + 8], 2) for i in range(0, len(stream), 8)], 1) + + def eci(assignment: str, payload: bytes = b'A') -> str: + return parse('0111' + assignment + '0100' + f'{len(payload):08b}' + ''.join(f'{b:08b}' for b in payload) + '0000') + + # ASCII assignment 170 uses the two-byte ECI representation. + assert eci('1000000010101010') == 'A' + for assignment in ['00001110', '1000001111100111', '110000010000000000000000', '11100000']: + with self.subTest(assignment=assignment), self.assertRaises(qr.QRError): + eci(assignment) + with self.assertRaises(qr.QRError): + eci('00011010', b'\xff') # Invalid UTF-8 must not fall back to Latin-1. + + # out-of-range numeric, alphanumeric, and Kanji values are format errors, not crashes + for stream in ['0001' + '0000000011' + '1111111111', '0001' + '0000000010' + '1111111', + '0010' + '000000010' + '11111111111', '0010' + '000000001' + '111111', + '1000' + '00000001' + '0000000111111']: + with self.subTest(stream=stream), self.assertRaises(qr.QRError): + parse(stream) + + def test_rotation(self): + for angle in [0, 90, 180, 270, 25, 110]: + with self.subTest(angle=angle): + _, img = make(LPA, box=8, border=12) + assert qr.decode(rotate(img, angle)) == LPA + + def test_mirrored(self): + _, img = make(LPA) + assert qr.decode(img[:, ::-1]) == LPA + + def test_perspective_and_noise(self): + _, img = make(LPA, box=10, border=8) + h, w = img.shape + corners = np.array([(40, 60), (w - 20, 30), (w - 60, h - 40), (30, h - 90)]) + arr = warp(img, qr._perspective(corners, np.array([(0, 0), (w, 0), (w, h), (0, h)]))).astype(float) + rng = np.random.default_rng(1) + arr = arr * 0.6 + 60 + rng.normal(0, 12, arr.shape) # low contrast + noise + # uneven lighting + arr += np.linspace(-40, 40, w)[None, :] + assert qr.decode(np.clip(arr, 0, 255).astype(np.uint8)) == LPA + + def test_no_code(self): + rng = np.random.default_rng(2) + assert qr.decode(rng.integers(0, 256, size=(240, 320), dtype=np.uint8)) is None + assert qr.decode(np.full((240, 320), 200, dtype=np.uint8)) is None + + def test_encoder_roundtrip(self): + for version in range(1, 21): + with self.subTest(version=version): + assert qr.decode_matrix(np.array(qr._Qr(version, b"hello").modules)) == "hello" diff --git a/openpilot/selfdrive/assets/prep-svg.sh b/openpilot/selfdrive/assets/prep-svg.sh index 567c17da3..ee6ba0a3a 100755 --- a/openpilot/selfdrive/assets/prep-svg.sh +++ b/openpilot/selfdrive/assets/prep-svg.sh @@ -23,8 +23,8 @@ done # sudo apt install inkscape -for svg in $(find $DIR -type f | grep svg$); do - bunx svgo $svg --multipass --pretty --indent 2 +for svg in $(find "$DIR" -type f | grep svg$); do + bunx svgo "$svg" --multipass --pretty --indent 2 # convert to PNG png="${svg%.svg}.png" diff --git a/openpilot/selfdrive/car/card.py b/openpilot/selfdrive/car/card.py index e253788e5..ddfe383d6 100755 --- a/openpilot/selfdrive/car/card.py +++ b/openpilot/selfdrive/car/card.py @@ -186,9 +186,6 @@ class Car: # card is driven by can recv, expected at 100Hz self.rk = Ratekeeper(100, print_delay_threshold=None) - # log fingerprint in sentry - sunnypilot_interfaces.log_fingerprint(self.CP) - def state_update(self) -> tuple[car.CarState, custom.CarStateSP, structs.RadarDataT | None]: """carState update loop, driven by can""" diff --git a/openpilot/selfdrive/locationd/locationd.py b/openpilot/selfdrive/locationd/locationd.py index 9fbec991d..3c76793e1 100755 --- a/openpilot/selfdrive/locationd/locationd.py +++ b/openpilot/selfdrive/locationd/locationd.py @@ -278,12 +278,12 @@ def main(): estimator = LocationEstimator(DEBUG) filter_initialized = False - critcal_services = ["accelerometer", "gyroscope", "cameraOdometry"] + critical_services = ["accelerometer", "gyroscope", "cameraOdometry"] observation_input_invalid = defaultdict(int) - input_invalid_limit = {s: round(INPUT_INVALID_LIMIT * (SERVICE_LIST[s].frequency / 20.)) for s in critcal_services} - input_invalid_threshold = {s: input_invalid_limit[s] - 0.5 for s in critcal_services} - input_invalid_decay = {s: calculate_invalid_input_decay(input_invalid_limit[s], INPUT_INVALID_RECOVERY, SERVICE_LIST[s].frequency) for s in critcal_services} + input_invalid_limit = {s: round(INPUT_INVALID_LIMIT * (SERVICE_LIST[s].frequency / 20.)) for s in critical_services} + input_invalid_threshold = {s: input_invalid_limit[s] - 0.5 for s in critical_services} + input_invalid_decay = {s: calculate_invalid_input_decay(input_invalid_limit[s], INPUT_INVALID_RECOVERY, SERVICE_LIST[s].frequency) for s in critical_services} initial_pose_data = params.get("LocationFilterInitialState") if initial_pose_data is not None: @@ -313,7 +313,7 @@ def main(): if valid: t = log_mono_time * 1e-9 res = estimator.handle_log(t, which, msg) - if which not in critcal_services: + if which not in critical_services: continue if res == HandleLogResult.TIMING_INVALID: @@ -328,7 +328,7 @@ def main(): filter_initialized = sm.all_checks() and sensor_all_checks(acc_msgs, gyro_msgs, sensor_valid, sensor_recv_time, sensor_alive, SIMULATION) if sm.updated["cameraOdometry"]: - critical_service_inputs_valid = all(observation_input_invalid[s] < input_invalid_threshold[s] for s in critcal_services) + critical_service_inputs_valid = all(observation_input_invalid[s] < input_invalid_threshold[s] for s in critical_services) inputs_valid = sm.all_valid() and critical_service_inputs_valid sensors_valid = sensor_all_checks(acc_msgs, gyro_msgs, sensor_valid, sensor_recv_time, sensor_alive, SIMULATION) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index d19afd21d..2aac90fe6 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -1,142 +1,90 @@ import glob -import json import os import time from SCons.Script import Action, Value -from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_existing_chunks from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE -from openpilot.selfdrive.modeld.constants import ModelConstants -from openpilot.selfdrive.modeld.helpers import TG_INPUT_DEVICES_PATH, chestnut_present, modeld_pkl_path +from openpilot.selfdrive.modeld.helpers import chestnut_present +from openpilot.system.camerad.cameras.nv12_info import get_nv12_info Import('env', 'arch') -chunker_file = File("#openpilot/common/file_chunker.py") lenv = env.Clone() +lenv.PrependENVPath('PYTHONPATH', Dir('#tinygrad_repo').abspath) tinygrad_root = env.Dir("#").abspath tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "/**", recursive=True, root_dir=tinygrad_root) if 'pycache' not in x and os.path.isfile(os.path.join(tinygrad_root, x))] -def estimate_pickle_max_size(onnx_size): - # QCOM programs for models with spatial recurrent features can approach 2x - # the ONNX size. Overestimating only adds an empty trailing chunk. - return 2.0 * onnx_size + 10 * 1024 * 1024 +camera_configs = [(c.width, c.height) for c in (_ar_ox_fisheye, _os_fisheye)] if arch == 'comma_arm64': - from openpilot.common.hardware import HARDWARE - camera = _os_fisheye if HARDWARE.get_device_type() == "mici" else _ar_ox_fisheye - camera_configs = [(camera.width, camera.height)] - tg_backend = 'QCOM' - tg_flags = f'DEV={tg_backend} IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1' + tg_flags = 'DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1' else: - camera_configs = [(c.width, c.height) for c in (_ar_ox_fisheye, _os_fisheye)] - tg_backend = 'CPU' - tg_flags = f'DEV=CPU' if arch == 'Darwin' else 'DEV=CPU:LLVM' - -tg_devices = { # which device to put jit inputs to at runtime - 'openpilot.selfdrive.modeld.dmonitoringmodeld': { - 'default': {'DEV': tg_backend} - }, -} + # JIT=2 disables graph batching, which produces incorrect outputs after buffers change. + tg_flags = 'DEV=METAL JIT=2' if arch == 'Darwin' else 'DEV=CPU:LLVM' CHESTNUT = chestnut_present() if CHESTNUT: - chestnut_tg_flags = 'DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_OCCUPANCY_OPT=1' + chestnut_tg_flags = 'DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32' # the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it chestnut_lock = File("models/.chestnut.lock").abspath -def write_tg_devices(target, source, env): - with open(str(target[0]), "w") as f: - json.dump(tg_devices, f) - f.write("\n") - -tg_devices_node = lenv.Command( - str(TG_INPUT_DEVICES_PATH), - [Value(tg_devices)], - write_tg_devices, -) - # tinygrad calls brew which needs a $HOME in the env mac_brew_string = f'HOME={os.path.expanduser("~")}' if arch == 'Darwin' else '' -modeld_dir = Dir("#openpilot/selfdrive/modeld").abspath -compile_modeld_script = [ - File(f"{modeld_dir}/compile_modeld.py"), - File(f"{modeld_dir}/get_model_metadata.py"), - File("#openpilot/system/camerad/cameras/nv12_info.py"), - File("#openpilot/common/hardware/hw.py"), -] -model_w, model_h = MEDMODEL_INPUT_SIZE -frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ +warp_deps = [File("#openpilot/system/camerad/cameras/nv12_info.py")] +compiler = Dir('#tinygrad_repo/examples/openpilot').abspath +# CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. +taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' -if not os.getenv('SKIP_TINYGRAD_COMPILE'): - for chestnut in [False, True] if CHESTNUT else [False]: - target_pkl_path = File(modeld_pkl_path(chestnut)).abspath - file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags) - driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath) - camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in camera_configs) - # CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. - taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' - cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_modeld.py ' - f'--model-size {model_w}x{model_h} ' - f'--camera-resolutions {camera_res_args} ' - f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} ' - f'--output {target_pkl_path} --frame-skip {frame_skip}') - onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps) - chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum)) - def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets): - from openpilot.system.hardware.chestnut.flash import link_up - # chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars - for _ in range(10): - if link_up(): - break - time.sleep(1) - else: - print("Chestnut not ready, skipping big model build") - return - if ret := env.Execute(command): - return ret - chunk_file(pkl, chunks) - def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets): - chunk_file(pkl, chunks) - actions = Action(do_compile, " [CHESTNUT] $TARGET") if chestnut else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] - node = lenv.Command( - chunk_targets, - tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(camera_res_args), Value(chunk_targets), chunker_file], - actions, - ) +def chestnut_action(command): + def do_compile(target, source, env): + from openpilot.system.hardware.chestnut.flash import link_up + # chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars + for _ in range(10): + if link_up(): + break + time.sleep(1) + else: + print("Chestnut not ready, skipping warp build") + return + return env.Execute(command) + return Action(do_compile, " [CHESTNUT] $TARGET") + +def compile_model(onnx_path, pkl_path): + onnx_path, target_pkl_path = File(onnx_path).abspath, File(pkl_path).abspath + cmd = (f'{tg_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_onnx.py" ' + f'"{onnx_path}" "{target_pkl_path}" --device-input "*" --out-of-band --benchmark-runs 1') + lenv.Command( + target_pkl_path, + tinygrad_files + [onnx_path, Value(cmd)], + Action(cmd, " [ONNX] $TARGET"), + ) + +compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygrad.pkl') +compile_model('models/driving_supercombo.onnx', 'models/driving_tinygrad.pkl') + +model_w, model_h = MEDMODEL_INPUT_SIZE +for chestnut in [False, True] if CHESTNUT else [False]: + file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags) + for cam_w, cam_h in camera_configs: + warp_pkl_path = File(f"models/{file_prefix}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath + stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) + cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_warp.py" ' + f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{stride * (y_height + uv_height)} ' + f'--warp-to {model_w}x{model_h} --layout yuv420 --frames 2 ' + f'--output {warp_pkl_path}') + action = chestnut_action(cmd) if chestnut else cmd + node = lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], action) if chestnut: lenv.SideEffect(chestnut_lock, node) -# get model metadata -fn = File(f"models/dmonitoring_model").abspath -script_files = [File(Dir("#openpilot/selfdrive/modeld").File("get_model_metadata.py").abspath)] -cmd = f'{tg_flags} {mac_brew_string} python3 {Dir("#openpilot/selfdrive/modeld").abspath}/get_model_metadata.py {fn}.onnx' -lenv.Command(fn + "_metadata.pkl", [fn + ".onnx"] + tinygrad_files + script_files + [tg_devices_node], cmd) - dm_w, dm_h = DM_INPUT_SIZE -compile_dm_warp_script = [File(f"{modeld_dir}/compile_dm_warp.py")] for cam_w, cam_h in camera_configs: dm_pkl_path = File(f"models/dm_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath - cmd = (f'{tg_flags} {mac_brew_string} python3 {modeld_dir}/compile_dm_warp.py ' - f'--camera-resolution {cam_w}x{cam_h} --warp-to {dm_w}x{dm_h} ' - f'--output {dm_pkl_path}') - lenv.Command(dm_pkl_path, tinygrad_files + compile_dm_warp_script + compile_modeld_script + [tg_devices_node], cmd) - -def tg_compile(flags, model_name): - pythonpath_string = 'PYTHONPATH="${PYTHONPATH}:' + env.Dir("#tinygrad_repo").abspath + '"' - fn = File(f"models/{model_name}").abspath - pkl = fn + "_tinygrad.pkl" - onnx_path = fn + ".onnx" - chunk_targets = get_chunk_targets(pkl, estimate_pickle_max_size(os.path.getsize(onnx_path))) - def do_chunk(target, source, env): - chunk_file(pkl, chunk_targets) - return lenv.Command( - chunk_targets, - [onnx_path] + tinygrad_files + [Value(chunk_targets), chunker_file, tg_devices_node], - [f'{pythonpath_string} {flags} python3 {Dir("#tinygrad_repo").abspath}/examples/openpilot/compile3.py {fn}.onnx {pkl}', - Action(do_chunk, " [CHUNK] $TARGET")], - ) - -tg_compile(tg_flags, 'dmonitoring_model') + stride, y_height, uv_height, frame_size = get_nv12_info(cam_w, cam_h) + cmd = (f'{tg_flags} {mac_brew_string} python3 "{compiler}/compile_warp.py" ' + f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{frame_size} --warp-to {dm_w}x{dm_h} ' + f'--layout luma --border-fill 16 --transform-device NPY --output {dm_pkl_path}') + lenv.Command(dm_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], cmd) diff --git a/openpilot/selfdrive/modeld/compile_dm_warp.py b/openpilot/selfdrive/modeld/compile_dm_warp.py deleted file mode 100755 index 16e0d39fb..000000000 --- a/openpilot/selfdrive/modeld/compile_dm_warp.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import pickle -import time - -from tinygrad.tensor import Tensor -from tinygrad.device import Device -from tinygrad.engine.jit import TinyJit - -from openpilot.system.camerad.cameras.nv12_info import get_nv12_info -from openpilot.selfdrive.modeld.compile_modeld import NV12Frame, warp_perspective_tinygrad, _parse_size - - -def make_warp_dm(nv12: NV12Frame, dm_w, dm_h): - cam_w, cam_h, stride, _, _, _ = nv12 - stride_pad = stride - cam_w - - def warp_dm(input_frame, M_inv): - M_inv = M_inv.to(Device.DEFAULT).realize() - return warp_perspective_tinygrad(input_frame[:cam_h*stride], M_inv, - (dm_w, dm_h), (cam_h, cam_w), stride_pad, border_fill_val=16).reshape(-1, dm_h * dm_w) # Y - return warp_dm - - -def compile_dm_warp(nv12: NV12Frame, dm_w, dm_h, pkl_path): - print(f"Compiling DM warp for {nv12.width}x{nv12.height} -> {dm_w}x{dm_h}...") - - warp_dm_jit = TinyJit(make_warp_dm(nv12, dm_w, dm_h), prune=True) - - for i in range(10): - frame = Tensor.randint(nv12.size, low=0, high=256, dtype='uint8').realize() - M_inv = Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY') - Device.default.synchronize() - st = time.perf_counter() - warp_dm_jit(frame, M_inv).realize() - mt = time.perf_counter() - Device.default.synchronize() - et = time.perf_counter() - print(f" [{i+1}/10] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") - - with open(pkl_path, "wb") as f: - pickle.dump(warp_dm_jit, f) - print(f" Saved to {pkl_path}") - - -if __name__ == "__main__": - p = argparse.ArgumentParser() - p.add_argument('--camera-resolution', type=_parse_size, required=True, help='camera resolution WxH') - p.add_argument('--warp-to', type=_parse_size, required=True, help='DM input WxH') - p.add_argument('--output', required=True) - args = p.parse_args() - - cam_w, cam_h = args.camera_resolution - nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - dm_w, dm_h = args.warp_to - compile_dm_warp(nv12, dm_w, dm_h, args.output) diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py deleted file mode 100755 index d851c3cc8..000000000 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ /dev/null @@ -1,332 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import atexit -import math -import os -import tempfile -import time -import shutil -from functools import partial -from collections import namedtuple - -import numpy as np - -from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob - -def _patch_tinygrad_fetch_fw(): - import hashlib - import pathlib - import zstandard - from tinygrad import helpers - _orig = helpers.fetch_fw - def fetch_fw(path, name, sha256): - p = pathlib.Path(f"/lib/firmware/{path}/{name}.zst") - if p.is_file(): - blob = zstandard.ZstdDecompressor().stream_reader(p.read_bytes()).read() - if hashlib.sha256(blob).hexdigest() == sha256: - return blob - return _orig(path, name, sha256) - helpers.fetch_fw = fetch_fw -_patch_tinygrad_fetch_fw() - - -from tinygrad.tensor import Tensor -from tinygrad.helpers import Context -from tinygrad.device import Device -from tinygrad.engine.jit import TinyJit - - -NV12Frame = namedtuple("NV12Frame", ['width', 'height', 'stride', 'y_height', 'uv_height', 'size']) -MODELD_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs'] - - -def nv12_copy_size(stride: int, y_height: int, uv_height: int) -> int: - # Retain the padded Y and UV plane storage, but skip the trailing kernel/guard allocation. - return stride * (y_height + uv_height) - - -def warp_perspective_tinygrad(src_flat, M_inv, dst_shape, src_shape, stride_pad, border_fill_val=None): - w_dst, h_dst = dst_shape - h_src, w_src = src_shape - - x = Tensor.arange(w_dst).reshape(1, w_dst).expand(h_dst, w_dst).reshape(-1) - y = Tensor.arange(h_dst).reshape(h_dst, 1).expand(h_dst, w_dst).reshape(-1) - - # inline 3x3 matmul as elementwise to avoid reduce op (enables fusion with gather) - src_x = M_inv[0, 0] * x + M_inv[0, 1] * y + M_inv[0, 2] - src_y = M_inv[1, 0] * x + M_inv[1, 1] * y + M_inv[1, 2] - src_w = M_inv[2, 0] * x + M_inv[2, 1] * y + M_inv[2, 2] - - src_x = src_x / src_w - src_y = src_y / src_w - - x_round = Tensor.round(src_x) - y_round = Tensor.round(src_y) - x_nn_clipped = x_round.clip(0, w_src - 1).cast('int') - y_nn_clipped = y_round.clip(0, h_src - 1).cast('int') - idx = y_nn_clipped * (w_src + stride_pad) + x_nn_clipped - sampled = src_flat[idx] - - if border_fill_val is None: - return sampled - - in_bounds = ((x_round >= 0) & (x_round <= w_src - 1) & - (y_round >= 0) & (y_round <= h_src - 1)).cast(sampled.dtype) - return sampled * in_bounds + Tensor(border_fill_val, dtype=sampled.dtype) * (1 - in_bounds) - - -def frames_to_tensor(frames): - H = (frames.shape[0] * 2) // 3 - W = frames.shape[1] - in_img1 = Tensor.cat(frames[0:H:2, 0::2], - frames[1:H:2, 0::2], - frames[0:H:2, 1::2], - frames[1:H:2, 1::2], - frames[H:H+H//4].reshape((H//2, W//2)), - frames[H+H//4:H+H//2].reshape((H//2, W//2)), dim=0).reshape((6, H//2, W//2)) - return in_img1 - - -def make_frame_prepare(nv12: NV12Frame, model_w, model_h): - cam_w, cam_h, stride, y_height, uv_height, _ = nv12 - uv_offset = stride * y_height - stride_pad = stride - cam_w - - def frame_prepare_tinygrad(input_frame, M_inv): - # UV_SCALE @ M_inv @ UV_SCALE_INV simplifies to elementwise scaling - M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=Device.DEFAULT) - # deinterleave NV12 UV plane (UVUV... -> separate U, V) - uv = input_frame[uv_offset:uv_offset + uv_height * stride].reshape(uv_height, stride) - with Context(SPLIT_REDUCEOP=0): - y = warp_perspective_tinygrad(input_frame[:cam_h*stride], - M_inv, (model_w, model_h), - (cam_h, cam_w), stride_pad).realize() - u = warp_perspective_tinygrad(uv[:cam_h//2, :cam_w:2].flatten(), - M_inv_uv, (model_w//2, model_h//2), - (cam_h//2, cam_w//2), 0).realize() - v = warp_perspective_tinygrad(uv[:cam_h//2, 1:cam_w:2].flatten(), - M_inv_uv, (model_w//2, model_h//2), - (cam_h//2, cam_w//2), 0).realize() - yuv = y.cat(u).cat(v).reshape((model_h * 3 // 2, model_w)) - tensor = frames_to_tensor(yuv) - return tensor - return frame_prepare_tinygrad - - -def get_policy_npy_shapes(input_shapes): - dp = input_shapes['desire_pulse'] # (1, 25, 8) - tc = input_shapes['traffic_convention'] # (1, 2) - at = input_shapes['action_t'] # (1, 2) - fb = input_shapes['features_buffer'] # (1, T-1, ...) e.g. (1, 24, 32, 512) with spatial features - feat_dim = math.prod(fb[2:]) - # TODO prev_feat shouldn't exist and be handled inside the JIT, but corrupt on QCOM for now - shapes = {'desire': (dp[2],), 'traffic_convention': tuple(tc), 'action_t': tuple(at), 'prev_feat': (fb[0], feat_dim)} - return shapes, [math.prod(s) for s in shapes.values()] - - -def make_input_queues(input_shapes, frame_skip, device, frame_copy_size): - img = input_shapes['img'] # (1, 12, 128, 256) - fb = input_shapes['features_buffer'] # (1, T-1, ...), past features only; the model appends the current frame's feature - feat_dim = math.prod(fb[2:]) - dp = input_shapes['desire_pulse'] # (1, 25, 8) - n_frames = img[1] // 6 - img_buf_shape = (frame_skip * (n_frames - 1) + 1, 6, img[2], img[3]) - - policy_shapes, _ = get_policy_npy_shapes(input_shapes) - shapes = {'tfm': (3, 3), 'big_tfm': (3, 3)} | policy_shapes - sizes = [math.prod(s) for s in shapes.values()] - packed_npy_size = sum(sizes) * np.dtype(np.float32).itemsize - packed_input = np.zeros(packed_npy_size + 2 * frame_copy_size, dtype=np.uint8) - packed_npy_inputs = packed_input[:packed_npy_size].view(np.float32) - frames = packed_input[packed_npy_size:] - frame_views = {'img': frames[:frame_copy_size], 'big_img': frames[frame_copy_size:]} - # views into the packed inputs, to be refilled at runtime - npy = {k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed_npy_inputs, np.cumsum(sizes[:-1])), strict=True)} - input_queues = { - 'img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), - 'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), - 'feat_q': Tensor(np.zeros((frame_skip * fb[1], fb[0], feat_dim), dtype=np.float32), device=device).contiguous().realize(), - 'desire_q': Tensor(np.zeros((frame_skip * dp[1], dp[0], dp[2]), dtype=np.float32), device=device).contiguous().realize(), - 'packed_npy_inputs': Tensor(packed_input, device='NPY').realize(), - } - return input_queues, npy, frame_views - - -def shift_and_sample(buf, new_val, sample_fn): - buf.assign(buf[1:].cat(new_val, dim=0).contiguous()) - return sample_fn(buf) - - -def sample_skip(buf, frame_skip): - return buf[::frame_skip].contiguous().flatten(0, 1).unsqueeze(0) - - -def sample_desire(buf, frame_skip): - return buf.reshape(-1, frame_skip, *buf.shape[1:]).max(1).flatten(0, 1).unsqueeze(0) - - -def make_warp(nv12, model_w, model_h): - frame_prepare = make_frame_prepare(nv12, model_w, model_h) - - def warp(tfm, big_tfm, frame, big_frame): - tfm = tfm.to(Device.DEFAULT) - big_tfm = big_tfm.to(Device.DEFAULT) - frame = frame.to(Device.DEFAULT) - big_frame = big_frame.to(Device.DEFAULT) - Tensor.realize(tfm, big_tfm, frame, big_frame) - - warped_frame = frame_prepare(frame, tfm).unsqueeze(0) - warped_big_frame = frame_prepare(big_frame, big_tfm).unsqueeze(0) - return Tensor.cat(warped_frame, warped_big_frame) - - return warp - - -def make_run_policy(model_runner, model_metadata, frame_skip): - sample_desire_fn = partial(sample_desire, frame_skip=frame_skip) - sample_skip_fn = partial(sample_skip, frame_skip=frame_skip) - npy_shapes, npy_sizes = get_policy_npy_shapes(model_metadata['input_shapes']) - model_input_dtypes = {name: spec.dtype for name, spec in model_runner.graph_inputs.items()} - - def run_policy(warped, img_q, big_img_q, feat_q, desire_q, packed_npy_inputs): - packed_npy_inputs = packed_npy_inputs.to(Device.DEFAULT) - Tensor.realize(packed_npy_inputs, warped) - - img = shift_and_sample(img_q, warped[0:1], sample_skip_fn) - big_img = shift_and_sample(big_img_q, warped[1:2], sample_skip_fn) - - desire, traffic_convention, action_t, prev_feat = (t.reshape(s) for t, s in zip(packed_npy_inputs.split(npy_sizes), npy_shapes.values(), strict=True)) - desire_buf = shift_and_sample(desire_q, desire.reshape(1, 1, -1), sample_desire_fn) - feat_buf = shift_and_sample(feat_q, prev_feat.reshape(1, 1, -1), sample_skip_fn) - - inputs = { - 'img': img, - 'big_img': big_img, - 'features_buffer': feat_buf.reshape(model_metadata['input_shapes']['features_buffer']), - 'desire_pulse': desire_buf, - 'traffic_convention': traffic_convention, - 'action_t': action_t, - } - inputs = {name: value.cast(model_input_dtypes[name]) for name, value in inputs.items()} - out = next(iter(model_runner(inputs).values())).cast('float32') - return out, - return run_policy - - -def make_run_model(warp, run_policy, model_metadata, frame_copy_size): - _, policy_sizes = get_policy_npy_shapes(model_metadata['input_shapes']) - packed_npy_size = (18 + sum(policy_sizes)) * np.dtype(np.float32).itemsize - - def run_model(img_q, big_img_q, feat_q, desire_q, packed_npy_inputs): - packed_input = packed_npy_inputs.to(Device.DEFAULT) - Tensor.realize(packed_input) - packed_npy_inputs = packed_input[:packed_npy_size].bitcast('float32') - frame = packed_input[packed_npy_size:packed_npy_size + frame_copy_size] - big_frame = packed_input[packed_npy_size + frame_copy_size:] - tfm, big_tfm, policy_inputs = packed_npy_inputs.split([9, 9, sum(policy_sizes)]) - warped = warp(tfm.reshape(3, 3), big_tfm.reshape(3, 3), frame, big_frame) - return run_policy(warped, img_q, big_img_q, feat_q, desire_q, policy_inputs) - return run_model - - -def compile_jit(jit, input_keys, make_queues, benchmark_runs): - if benchmark_runs < 1: - raise ValueError("benchmark_runs must be at least 1") - - SEED = 42 - def random_inputs_run(fn, seed, n_runs, test_val=None, test_buffers=None, expect_match=True): - input_queues, npy, frame_views = make_queues(Device.DEFAULT) - rng = np.random.default_rng(seed) - - for i in range(n_runs): - for v in npy.values(): - v[:] = rng.standard_normal(v.shape).astype(v.dtype) - for v in frame_views.values(): - v[:] = rng.integers(0, 256, size=v.shape, dtype=np.uint8) - Device.default.synchronize() - st = time.perf_counter() - outs = fn(**{k: input_queues[k] for k in input_keys}) - mt = time.perf_counter() - Device.default.synchronize() - et = time.perf_counter() - print(f" [{i+1}/{n_runs}] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") - - if i == 0: - val = [np.copy(v.numpy()) for v in outs] - buffers = [np.copy(v.numpy().copy()) for v in input_queues.values()] - - if test_val is not None: - match = all(np.array_equal(a, b) for a, b in zip(val, test_val, strict=True)) - assert match == expect_match, f"outputs {'differ from' if expect_match else 'match'} baseline (seed={seed})" - if test_buffers is not None: - match = all(np.array_equal(a, b) for a, b in zip(buffers, test_buffers, strict=True)) - assert match == expect_match, f"buffers {'differ from' if expect_match else 'match'} baseline (seed={seed})" - return val, buffers - - print('capture + replay') - test_val, test_buffers = random_inputs_run(jit, SEED, 3) - print(f'pickle round trip ({benchmark_runs} runs per seed)') - with tempfile.TemporaryFile(dir=".") as f: - dump_oob(jit, f) - f.seek(0) - loaded_jit = load_oob(f) - random_inputs_run(loaded_jit, SEED, benchmark_runs, test_val, test_buffers, expect_match=True) - random_inputs_run(loaded_jit, SEED+1, benchmark_runs, test_val, test_buffers, expect_match=False) - # Keep the original so per-resolution JITs share model weight buffers in the final pickle. - return jit - - -def _parse_size(s): - w, h = s.lower().split('x') - return int(w), int(h) - - -def read_file_chunked_to_disk(path): - from openpilot.common.file_chunker import open_file_chunked - tmp_path = f'{path}.unchunked' - with open(tmp_path, 'wb') as f, open_file_chunked(path) as src: - shutil.copyfileobj(src, f) - atexit.register(lambda: os.path.exists(tmp_path) and os.remove(tmp_path)) - return tmp_path - - -if __name__ == "__main__": - from tinygrad.nn.onnx import OnnxRunner - from openpilot.system.camerad.cameras.nv12_info import get_nv12_info - from openpilot.selfdrive.modeld.get_model_metadata import make_metadata_dict - p = argparse.ArgumentParser() - p.add_argument('--model-size', type=_parse_size, required=True, help='model input WxH') - p.add_argument('--camera-resolutions', type=_parse_size, nargs='+', required=True, - help='camera resolutions WxH (one or more)') - p.add_argument('--onnx', required=True) - p.add_argument('--output', required=True) - p.add_argument('--frame-skip', type=int, required=True) - p.add_argument('--benchmark-runs', type=int, default=1, - help='timed loaded-JIT runs for each correctness seed') - args = p.parse_args() - - model_path = read_file_chunked_to_disk(args.onnx) - model_w, model_h = args.model_size - - model_runner = OnnxRunner(model_path) - out = { - 'metadata': make_metadata_dict(model_path), - 'input_devices': {'model': Device.DEFAULT}, - 'run_model': {}, - } - - run_policy = make_run_policy(model_runner, out['metadata'], args.frame_skip) - - for cam_w, cam_h in args.camera_resolutions: - nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - frame_copy_size = nv12_copy_size(nv12.stride, nv12.y_height, nv12.uv_height) - make_model_queues = partial(make_input_queues, out['metadata']['input_shapes'], args.frame_skip, - frame_copy_size=frame_copy_size) - warp = make_warp(nv12, model_w, model_h) - run_model_jit = TinyJit(make_run_model(warp, run_policy, out['metadata'], frame_copy_size), prune=True) - out['run_model'][(cam_w,cam_h)] = compile_jit(run_model_jit, MODELD_INPUTS, make_model_queues, - args.benchmark_runs) - - with open(args.output, "wb") as f: - dump_oob(out, f) - print(f"Saved JITs to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB)") diff --git a/openpilot/selfdrive/modeld/dmonitoringmodeld.py b/openpilot/selfdrive/modeld/dmonitoringmodeld.py index 4010725b8..06d0d2e5e 100755 --- a/openpilot/selfdrive/modeld/dmonitoringmodeld.py +++ b/openpilot/selfdrive/modeld/dmonitoringmodeld.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import os -from openpilot.selfdrive.modeld.helpers import MODELS_DIR, get_tg_input_devices +import base64 +from openpilot.selfdrive.modeld.helpers import MODELS_DIR, load_oob from tinygrad.tensor import Tensor import time import pickle @@ -18,10 +19,8 @@ from openpilot.system.camerad.cameras.nv12_info import get_nv12_info from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.parse_model_outputs import sigmoid, safe_exp -PROCESS_NAME = "openpilot.selfdrive.modeld.dmonitoringmodeld" SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') MODEL_PKL_PATH = MODELS_DIR / 'dmonitoring_model_tinygrad.pkl' -METADATA_PATH = MODELS_DIR / 'dmonitoring_model_metadata.pkl' class ModelState: @@ -29,11 +28,10 @@ class ModelState: output: np.ndarray def __init__(self, cam_w: int, cam_h: int): - self.DEV = get_tg_input_devices(PROCESS_NAME, chestnut=False)['DEV'] - with open(METADATA_PATH, 'rb') as f: - model_metadata = pickle.load(f) - self.input_shapes = model_metadata['input_shapes'] - self.output_slices = model_metadata['output_slices'] + jits = load_oob(open_file_chunked(MODEL_PKL_PATH)) + self.DEV = jits['input_specs']['input_img'][2] + self.input_shapes = jits['metadata']['input_shapes'] + self.output_slices = pickle.loads(base64.b64decode(jits['metadata']['metadata']['output_slices'])) self.numpy_inputs = { 'calib': np.zeros(self.input_shapes['calib'], dtype=np.float32), @@ -42,16 +40,19 @@ class ModelState: self.warp_inputs_np = {'transform': np.zeros((3,3), dtype=np.float32)} self.warp_inputs = {k: Tensor(v, device='NPY') for k,v in self.warp_inputs_np.items()} self.frame_buf_params = get_nv12_info(cam_w, cam_h) - self.tensor_inputs = {k: Tensor(v, device='NPY').realize() for k,v in self.numpy_inputs.items()} + self.tensor_inputs = {k: Tensor(v, device=self.DEV).realize() for k,v in self.numpy_inputs.items()} + self.calib_host = Tensor(self.numpy_inputs['calib'], device='NPY')._buffer() self._blob_cache : dict[int, Tensor] = {} - self.model_run = pickle.load(open_file_chunked(str(MODEL_PKL_PATH))) + self.model_run = jits['run'] + self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in jits['output_specs'].items()} with open(MODELS_DIR / f'dm_warp_{cam_w}x{cam_h}_tinygrad.pkl', "rb") as f: - self.image_warp = pickle.load(f) + self.image_warp = pickle.load(f)['run'] def run(self, buf: VisionBuf, calib: np.ndarray, transform: np.ndarray) -> tuple[np.ndarray, float]: self.numpy_inputs['calib'][0,:] = calib t1 = time.perf_counter() + self.tensor_inputs['calib']._buffer().copy_from(self.calib_host) ptr = np.frombuffer(buf.data, dtype=np.uint8).ctypes.data # There is a ringbuffer of imgs, just cache tensors pointing to all of them @@ -59,9 +60,10 @@ class ModelState: self._blob_cache[ptr] = Tensor.from_blob(ptr, (self.frame_buf_params[3],), dtype='uint8', device=self.DEV) self.warp_inputs_np['transform'][:] = transform[:] - self.tensor_inputs['input_img'] = self.image_warp(self._blob_cache[ptr], self.warp_inputs['transform']) + self.tensor_inputs['input_img'] = self.image_warp(input_frame=self._blob_cache[ptr], M_inv=self.warp_inputs['transform']) - output = self.model_run(**self.tensor_inputs).numpy().flatten() + self.model_run(output_buffers=self.outputs, **self.tensor_inputs) + output = self.outputs['outputs'].numpy().astype(np.float32).reshape(-1) t2 = time.perf_counter() return output, t2 - t1 diff --git a/openpilot/selfdrive/modeld/get_model_metadata.py b/openpilot/selfdrive/modeld/get_model_metadata.py deleted file mode 100755 index e4c173957..000000000 --- a/openpilot/selfdrive/modeld/get_model_metadata.py +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env python3 -import sys -import pathlib -import codecs -import pickle -from typing import Any - -from tinygrad.nn.onnx import OnnxPBParser - - -class MetadataOnnxPBParser(OnnxPBParser): - def _parse_ModelProto(self) -> dict: - obj: dict[str, Any] = {"graph": {"input": [], "output": []}, "metadata_props": []} - for fid, wire_type in self._parse_message(self.reader.len): - match fid: - case 7: - obj["graph"] = self._parse_GraphProto() - case 14: - obj["metadata_props"].append(self._parse_StringStringEntryProto()) - case _: - self.reader.skip_field(wire_type) - return obj - - -def get_name_and_shape(value_info: dict[str, Any]) -> tuple[str, tuple[int, ...]]: - shape = tuple(int(dim) if isinstance(dim, int) else 0 for dim in value_info["parsed_type"].shape) - name = value_info["name"] - return name, shape - - -def get_metadata_value_by_name(model: dict[str, Any], name: str) -> str | Any: - for prop in model["metadata_props"]: - if prop["key"] == name: - return prop["value"] - return None - - -def make_metadata_dict(model_path): - model = MetadataOnnxPBParser(model_path).parse() - output_slices = get_metadata_value_by_name(model, 'output_slices') - assert output_slices is not None, 'output_slices not found in metadata' - return { - 'model_checkpoint': get_metadata_value_by_name(model, 'model_checkpoint'), - 'output_slices': pickle.loads(codecs.decode(output_slices.encode(), "base64")), - 'input_shapes': dict(get_name_and_shape(x) for x in model["graph"]["input"]), - 'output_shapes': dict(get_name_and_shape(x) for x in model["graph"]["output"]), - } - - -if __name__ == "__main__": - model_path = pathlib.Path(sys.argv[1]) - metadata_path = model_path.parent / (model_path.stem + '_metadata.pkl') - with open(metadata_path, 'wb') as f: - pickle.dump(make_metadata_dict(model_path), f) - print(f'saved metadata to {metadata_path}') diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 23eb6cbd2..5e7f413c2 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -1,49 +1,25 @@ import io -import json import pickle -import shutil import struct -import tempfile from pathlib import Path from openpilot.common.file_chunker import get_manifest_path from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH, is_chestnut_usb_id MODELS_DIR = Path(__file__).resolve().parent / 'models' -TG_INPUT_DEVICES_PATH = MODELS_DIR / 'tg_input_devices.json' -CHESTNUT_POWERED_VOLTAGE = 5000 -CHESTNUT_PCIE_READY = 0x78 -def get_tg_input_devices(process_name: str, chestnut: bool): - with open(TG_INPUT_DEVICES_PATH) as f: - return json.load(f)[process_name]['default' if not chestnut else 'chestnut'] - def modeld_pkl_path(chestnut: bool): prefix = 'big_' if chestnut else '' return MODELS_DIR / f'{prefix}driving_tinygrad.pkl' -def dump_oob(obj, f): - with tempfile.TemporaryFile(dir=".") as tmp: - def buffer_callback(pb: pickle.PickleBuffer): - m = pb.raw() - tmp.write(struct.pack(' bool: return False def chestnut_compiled() -> bool: - return Path(get_manifest_path(modeld_pkl_path(chestnut=True))).is_file() - - -def chestnut_ready(state) -> bool: - return state.supplyVoltage >= CHESTNUT_POWERED_VOLTAGE and not state.supplyFault and state.pcieLtssm == CHESTNUT_PCIE_READY + path = modeld_pkl_path(chestnut=True) + return (path.is_file() or Path(get_manifest_path(path)).is_file()) and all( + (MODELS_DIR / f'big_driving_warp_{size}_tinygrad.pkl').is_file() for size in ('1344x760', '1928x1208')) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index b98b4d0f5..57e19caac 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -1,12 +1,17 @@ #!/usr/bin/env python3 from collections.abc import Callable +import base64 import ctypes from functools import cached_property import os os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom -from tinygrad.device import Device -import usb1 -import struct +from tinygrad.device import Buffer, Device +from tinygrad.dtype import DType, dtypes +from tinygrad.tensor import Tensor +from tinygrad.helpers import round_up +from tinygrad.uop.ops import UOp +import math +import pickle import threading import time import numpy as np @@ -28,13 +33,10 @@ from openpilot.common.transformations.model import get_warp_matrix from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, should_stop, smooth_value, get_curvature_from_plan from openpilot.selfdrive.modeld.parse_model_outputs import Parser -from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, nv12_copy_size, MODELD_INPUTS from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState from openpilot.common.file_chunker import open_file_chunked -from openpilot.common.hardware.usb import CHESTNUT_USB_IDS from openpilot.selfdrive.modeld.constants import ModelConstants, Plan -from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, chestnut_ready, modeld_pkl_path, load_oob - +from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob from openpilot.sunnypilot.livedelay.helpers import get_lat_delay from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController @@ -75,45 +77,14 @@ def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log. shouldStop=bool(stop)) -class ChestnutState: - # only modeld can access chestnut +class ChestnutGpuState: + # GPU metrics require modeld's GPU context def __init__(self, pm: PubMaster, big: bool): self.pm = pm self.big = big self.valid = True self.sends = 0 self.metrics = {} - self._asm_usb = None - - def _close_asm_usb(self) -> None: - if self._asm_usb is not None: - self._asm_usb.close() - self._asm_usb = None - - def _open_asm_usb(self): - context = usb1.USBContext() - for vendor_id, product_id in CHESTNUT_USB_IDS: - if (handle := context.openByVendorIDAndProductID(vendor_id, product_id, skip_on_error=True)) is not None: - return handle - context.close() - - def _read_ina(self) -> tuple[int, int, bool]: - if "AMD" in Device._opened_devices and self._asm_usb is None: - try: - raw = Device["AMD"].iface.pci_dev.usb.usb.control_read(0xC0, 5) - return struct.unpack(' int: @@ -121,8 +92,8 @@ class ChestnutState: return smu._send_msg(smu.smu_mod.PPSMC_MSG_GetPptLimit, 0, read_back_arg=True, timeout=100) def send(self) -> None: - msg = messaging.new_message('chestnutState') - state = msg.chestnutState + msg = messaging.new_message('chestnutGpuState') + state = msg.chestnutGpuState self.sends += 1 if self.big and "AMD" in Device._opened_devices and self.sends % 100 == 1: try: @@ -148,21 +119,8 @@ class ChestnutState: for k, v in self.metrics.items(): setattr(state, k, v) - asm_valid = False - try: - # ASM runs on USB-C power, these still read without a gpu - state.supplyVoltage, state.supplyCurrent, state.supplyFault = self._read_ina() - asm_valid = True - except Exception: - pass - if "AMD" in Device._opened_devices: - try: - state.pcieLtssm = Device["AMD"].iface.pci_dev.usb.read(0xB450, 1)[0] - except Exception: - pass - - msg.valid = asm_valid and (not self.big or self.valid) - self.pm.send('chestnutState', msg) + msg.valid = not self.big or (self.valid and bool(self.metrics)) + self.pm.send('chestnutGpuState', msg) class FrameMeta: @@ -175,37 +133,65 @@ class FrameMeta: self.frame_id, self.timestamp_sof, self.timestamp_eof = vipc.frame_id, vipc.timestamp_sof, vipc.timestamp_eof +def input_view(buffer: Buffer, shape: tuple[int, ...], dtype: DType, offset: int) -> Tensor: + view = buffer.view(math.prod(shape), dtype, offset).ensure_allocated() + return Tensor(UOp.from_buffer(view)).reshape(shape) + + class ModelState(ModelStateBase): prev_desire: np.ndarray # for tracking the rising edge of the pulse def __init__(self, cam_w: int, cam_h: int, chestnut: bool): ModelStateBase.__init__(self) jits = load_oob(open_file_chunked(modeld_pkl_path(chestnut))) - input_devices = jits['input_devices'] - self.model_device = input_devices['model'] - metadata = jits['metadata'] - self.input_shapes = metadata['input_shapes'] - self.vision_input_names = [k for k in self.input_shapes if 'img' in k] - self.output_slices = metadata['output_slices'] + self.model_device = jits['input_specs']['new_img'][2] + self.input_shapes = {name: (shape, np.dtype(dtype)) for name, (shape, dtype, _) in jits['input_specs'].items()} + self.state_pairs = {name: f'next_{name}' for name in self.input_shapes if f'next_{name}' in jits['metadata']['output_shapes']} + self.vision_input_names = ('img', 'big_img') + self.output_slices = pickle.loads(base64.b64decode(jits['metadata']['metadata']['output_slices'])) self.prev_desire = np.zeros(ModelConstants.DESIRE_LEN, dtype=np.float32) self.chestnut = chestnut - self.frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ - self.frame_copy_size = nv12_copy_size(*get_nv12_info(cam_w, cam_h)[:3]) - self.input_queues, self.npy, self.frame_views = make_input_queues( - self.input_shapes, self.frame_skip, device=self.model_device, frame_copy_size=self.frame_copy_size) + stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) + self.frame_copy_size = stride * (y_height + uv_height) + self.pack_inputs() + with open(MODELS_DIR / f'{"big_" if chestnut else ""}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl', 'rb') as f: + self.run_warp = pickle.load(f)['run'] + self.run_model = jits['run'] + self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in jits['output_specs'].items()} + for name, next_name in self.state_pairs.items(): + state = self.input_queues[name] + self.outputs[next_name] = input_view(state._buffer(), state.shape, state.dtype, 0) self.parser = Parser() - self.run_model = jits['run_model'][(cam_w,cam_h)] + + def pack_inputs(self) -> None: + # Pack host inputs into one upload to reduce USB transfer overhead for the eGPU. + self.input_queues = {name: Tensor(np.zeros(shape, dtype=dtype), device=self.model_device).realize() + for name, (shape, dtype) in self.input_shapes.items() if name in self.state_pairs} + shapes = {'tfm': (2, 3, 3)} | {name: shape for name, (shape, _) in self.input_shapes.items() + if name not in self.state_pairs and name != 'new_img'} + npy_size = sum(round_up(math.prod(shape) * 4, 128) for shape in shapes.values()) + self.packed_input = np.zeros(npy_size + 2 * self.frame_copy_size, dtype=np.uint8) + self.input_host = Tensor(self.packed_input, device='NPY')._buffer() + self.input_device = Tensor(self.packed_input, device=self.model_device)._buffer() + self.npy = {} + offset = 0 + for name, shape in shapes.items(): + self.npy[name] = np.ndarray(shape, dtype=np.float32, buffer=self.packed_input, offset=offset) + self.input_queues[name] = input_view(self.input_device, shape, dtypes.float32, offset) + offset += round_up(self.npy[name].nbytes, 128) + self.frames = self.packed_input[npy_size:].reshape(2, self.frame_copy_size) + self.warp_inputs = {'input_frame': input_view(self.input_device, self.frames.shape, dtypes.uint8, npy_size), 'M_inv': self.input_queues.pop('tfm')} def slice_outputs(self, model_outputs: np.ndarray, output_slices: dict[str, slice]) -> dict[str, np.ndarray]: - parsed_model_outputs = {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()} - return parsed_model_outputs + return {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()} def run(self, bufs: dict[str, VisionBuf], transforms: dict[str, np.ndarray], inputs: dict[str, np.ndarray], after_enqueue: Callable[[], None] | None = None) -> dict[str, np.ndarray]: - for key, buf in bufs.items(): - np.copyto(self.frame_views[key], np.frombuffer(buf.data, dtype=np.uint8, count=self.frame_copy_size)) + for i, key in enumerate(self.vision_input_names): + np.copyto(self.frames[i], np.frombuffer(bufs[key].data, dtype=np.uint8, count=self.frame_copy_size)) + self.npy['tfm'][i] = transforms[key] # Model decides when action is completed, so desire input is just a pulse triggered on rising edge inputs['desire_pulse'][0] = 0 @@ -213,17 +199,16 @@ class ModelState(ModelStateBase): self.prev_desire[:] = inputs['desire_pulse'] self.npy['traffic_convention'][:] = inputs['traffic_convention'] self.npy['action_t'][:] = inputs['action_t'] - self.npy['tfm'][:,:] = transforms['img'][:,:] - self.npy['big_tfm'][:,:] = transforms['big_img'][:,:] - outs, = self.run_model(**{k: self.input_queues[k] for k in MODELD_INPUTS}) + self.input_device.copy_from(self.input_host) + self.input_queues['new_img'] = self.run_warp(**self.warp_inputs) + self.run_model(output_buffers=self.outputs, **self.input_queues) if after_enqueue is not None: after_enqueue() - model_output = outs.numpy()[0] + model_output = self.outputs['outputs'].numpy()[0] if self.chestnut and not np.all(np.isfinite(model_output)): raise RuntimeError("model output not finite") outputs_dict = self.parser.parse_outputs(self.slice_outputs(model_output, self.output_slices)) - self.npy['prev_feat'][:] = model_output[self.output_slices['hidden_state']] if SEND_RAW_PRED: outputs_dict['raw_pred'] = model_output.copy() @@ -234,33 +219,21 @@ class ModelState(ModelStateBase): eye = np.eye(3, dtype=np.float32) dims = {'desire_pulse': ModelConstants.DESIRE_LEN, 'traffic_convention': 2, 'action_t': 2} self.run(dummy_frames, dict.fromkeys(self.vision_input_names, eye), {k: np.zeros(v, dtype=np.float32) for k, v in dims.items()}) - self.input_queues, self.npy, self.frame_views = make_input_queues( - self.input_shapes, self.frame_skip, device=self.model_device, frame_copy_size=self.frame_copy_size) + self.packed_input[:] = 0 + for key in self.state_pairs: + self.input_queues[key].assign(0).realize() self.prev_desire[:] = 0 def main(demo=False): cloudlog.warning("modeld init") - chestnut_available = chestnut_present() and chestnut_compiled() - CHESTNUT = False - if chestnut_available: - poller = messaging.Poller() - sock = messaging.sub_sock("chestnutState", poller=poller, conflate=True) - deadline = time.monotonic() + 4. / SERVICE_LIST['deviceState'].frequency - while not CHESTNUT and (remaining := deadline - time.monotonic()) > 0.: - if not poller.poll(round(remaining * 1000)): - break - msg = messaging.recv_one_or_none(sock) - CHESTNUT = msg is not None and msg.valid and chestnut_ready(msg.chestnutState) + CHESTNUT = chestnut_present() and chestnut_compiled() if CHESTNUT: os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000' params = Params() params.put_bool("ChestnutLoading", CHESTNUT) - if chestnut_available and not CHESTNUT: - params.put_bool("ChestnutActive", False) - else: - params.remove("ChestnutActive") + params.remove("ChestnutActive") config_realtime_process(7, 54) @@ -304,27 +277,23 @@ def main(demo=False): loader.start() loader.join(BIG_MODEL_TIMEOUT) model = big_model - if model is None: - params.put_bool("ChestnutModelError", True) params.put_bool("ChestnutActive", model is not None) - if model is not None: - params.remove("ChestnutModelError") small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or CHESTNUT else None if model is None: model = small_model - params.put_bool("ChestnutLoading", False) assert model is not None + params.put_bool("ChestnutLoading", False) cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") # messaging - pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutState"] if CHESTNUT else []) + pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutGpuState"] if CHESTNUT else []) pm = PubMaster(pub_socks) sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"]) publish_state = PublishState() params = Params() - chestnut_state = ChestnutState(pm, model.chestnut) if CHESTNUT else None + chestnut_state = ChestnutGpuState(pm, model.chestnut) if CHESTNUT else None # setup filter to track dropped frames frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_RUN_FREQ) @@ -435,14 +404,13 @@ def main(demo=False): mt1 = time.perf_counter() try: send_chestnut = (chestnut_state is not None and - run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0) + run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutGpuState'].frequency) == 0) model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None) except Exception: if not params.get_bool("ChestnutActive"): raise # fallback to small model cloudlog.exception("big model failed, fall back to small") - params.put_bool("ChestnutModelError", True) params.put_bool("ChestnutActive", False) assert small_model is not None model = small_model @@ -457,6 +425,7 @@ def main(demo=False): modelv2_send = messaging.new_message('modelV2') drivingdata_send = messaging.new_message('drivingModelData') posenet_send = messaging.new_message('cameraOdometry') + mdv2sp_send = messaging.new_message('modelDataV2SP') action = get_action_from_model(model_output, prev_action, lat_action_t, long_action_t, v_ego) prev_action = action @@ -469,7 +438,6 @@ def main(demo=False): l_lane_change_prob = desire_state[log.Desire.laneChangeLeft] r_lane_change_prob = desire_state[log.Desire.laneChangeRight] lane_change_prob = l_lane_change_prob + r_lane_change_prob - mdv2sp_send = messaging.new_message('modelDataV2SP') left_edge, right_edge = RELC.update_and_fill(modelv2_send.modelV2, mdv2sp_send.modelDataV2SP, v_ego) DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob, left_edge, right_edge) modelv2_send.modelV2.meta.laneChangeState = DH.lane_change_state diff --git a/openpilot/selfdrive/pandad/__init__.py b/openpilot/selfdrive/pandad/__init__.py index 0c17e886a..5f7a8fec8 100644 --- a/openpilot/selfdrive/pandad/__init__.py +++ b/openpilot/selfdrive/pandad/__init__.py @@ -1,3 +1 @@ -from openpilot.selfdrive.pandad.pandad_api_impl import can_list_to_can_capnp, can_capnp_to_list -assert can_list_to_can_capnp -assert can_capnp_to_list +from openpilot.selfdrive.pandad.pandad_api_impl import can_list_to_can_capnp as can_list_to_can_capnp, can_capnp_to_list as can_capnp_to_list diff --git a/openpilot/selfdrive/pandad/pandad.cc b/openpilot/selfdrive/pandad/pandad.cc index 23df5d6a8..d89762ba9 100644 --- a/openpilot/selfdrive/pandad/pandad.cc +++ b/openpilot/selfdrive/pandad/pandad.cc @@ -342,8 +342,8 @@ void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control, } } - // Disable IR on input timeout - if (nanos_since_boot() - last_cabin_camera_t > 1e9) { + // Disable IR on input timeout or when requested offroad. + if (nanos_since_boot() - last_cabin_camera_t > 1e9 || (!is_onroad && params.getBool("DisableDriverCameraIR"))) { ir_pwr = 0; } diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index 74c1c5e8c..961d0eb3d 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -454,11 +454,12 @@ class SelfdriveD(CruiseHelper): self.logged_comm_issue = None if not self.CP.notCar and not big_model_settling: # localization has nothing to work with during the load - if not self.sm['deviceMotion'].posenetOK: + # the defaults of a message that was never received are not a localizer failure + if self.sm.seen['deviceMotion'] and not self.sm['deviceMotion'].posenetOK: self.events.add(EventName.posenetInvalid) - if not self.sm['deviceMotion'].inputsOK: + if self.sm.seen['deviceMotion'] and not self.sm['deviceMotion'].inputsOK: self.events.add(EventName.locationdTemporaryError) - if (not self.sm['vehicleParameters'].valid and cal_status == log.ExtrinsicsCalibration.Status.calibrated and + if (self.sm.seen['vehicleParameters'] and not self.sm['vehicleParameters'].valid and cal_status == log.ExtrinsicsCalibration.Status.calibrated and not TESTING_CLOSET and (not SIMULATION or REPLAY)): self.events.add(EventName.paramsdTemporaryError) diff --git a/openpilot/selfdrive/test/chestnut.sh b/openpilot/selfdrive/test/chestnut.sh new file mode 100644 index 000000000..6fc645ee6 --- /dev/null +++ b/openpilot/selfdrive/test/chestnut.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -e + +sudo python3 openpilot/system/hardware/chestnut/flash.py + +SCONSFLAGS="-j4" ./openpilot/system/manager/build.py diff --git a/openpilot/selfdrive/test/process_replay/model_replay.py b/openpilot/selfdrive/test/process_replay/model_replay.py index 927c9b38f..fb46e83ef 100755 --- a/openpilot/selfdrive/test/process_replay/model_replay.py +++ b/openpilot/selfdrive/test/process_replay/model_replay.py @@ -20,11 +20,13 @@ from openpilot.tools.lib.framereader import FrameReader from openpilot.tools.lib.logreader import LogReader, save_log from openpilot.tools.lib.github_utils import GithubUtils -TEST_ROUTE = "8494c69d3c710e81|000001d4--2648a9a404" -SEGMENT = 4 +TEST_ROUTE = "98395b7c5b27882e|0000002b--2686b5a2d0" +SEGMENT = 1 START_FRAME = 0 END_FRAME = 60 +CHESTNUT = "--chestnut" in sys.argv + SEND_EXTRA_INPUTS = bool(int(os.getenv("SEND_EXTRA_INPUTS", "0"))) DATA_TOKEN = os.getenv("CI_ARTIFACTS_TOKEN","") @@ -39,7 +41,7 @@ EXEC_TIMINGS = [ ] def get_log_fn(test_route, ref="master"): - return f"{test_route}_model_tici_{ref}.zst" + return f"{test_route}_model_{'chestnut' if CHESTNUT else 'tici'}_{ref}.zst" def plot(proposed, master, title, tmp): proposed = list(proposed) @@ -170,6 +172,8 @@ def model_replay(lr, frs): msgs = modeld_msgs + dmonitoringmodeld_msgs chestnut = any(m.modelV2.big for m in modeld_msgs if m.which() == "modelV2") + if CHESTNUT: + assert chestnut and all(m.modelV2.big for m in modeld_msgs if m.which() == "modelV2"), "Chestnut replay must run the big model without fallback" header = ['model', 'max instant', 'max instant allowed', 'average', 'max average allowed', 'test result'] rows = [] @@ -285,7 +289,8 @@ if __name__ == "__main__": diff_short, diff_long, failed = format_diff(results, log_paths, 'master') if "CI" in os.environ: - comment_replay_report(log_msgs, cmp_log, log_msgs) + if not CHESTNUT: + comment_replay_report(log_msgs, cmp_log, log_msgs) failed = False print(diff_long) print('-------------\n'*5) diff --git a/openpilot/selfdrive/test/process_replay/process_replay.py b/openpilot/selfdrive/test/process_replay/process_replay.py index 8e5d224ea..3bad4e00a 100755 --- a/openpilot/selfdrive/test/process_replay/process_replay.py +++ b/openpilot/selfdrive/test/process_replay/process_replay.py @@ -17,7 +17,7 @@ from openpilot.common.hardware.hw import Paths import openpilot.cereal.messaging as messaging from opendbc.car.structs import car from openpilot.cereal.services import SERVICE_LIST -from msgq.visionipc import VisionIpcServer, get_endpoint_name as vipc_get_endpoint_name +from msgq.visionipc import VisionIpcClient, VisionIpcServer, get_endpoint_name as vipc_get_endpoint_name from opendbc.car.can_definitions import CanData from opendbc.car.car_helpers import get_car, interfaces from openpilot.common.params import Params @@ -210,6 +210,7 @@ class ProcessContainer: stride, y_height, _, yuv_size = get_nv12_info(frame_size[0], frame_size[1]) vipc_server.create_buffers_with_sizes(meta.stream, 2, frame_size[0], frame_size[1], yuv_size, stride, stride * y_height) vipc_server.start_listener() + VisionIpcClient.available_streams("camerad", block=True) self.vipc_server = vipc_server self.cfg.vision_pubs = [meta.camera_state for meta in streams_metas if meta.camera_state in self.cfg.vision_pubs] diff --git a/openpilot/selfdrive/test/scons_build_test.sh b/openpilot/selfdrive/test/scons_build_test.sh index 6f6eafcad..dfdaf6189 100755 --- a/openpilot/selfdrive/test/scons_build_test.sh +++ b/openpilot/selfdrive/test/scons_build_test.sh @@ -3,7 +3,7 @@ set -e SCRIPT_DIR=$(dirname "$0") BASEDIR=$(realpath "$SCRIPT_DIR/../../../") -cd $BASEDIR +cd "$BASEDIR" # tests that our build system's dependencies are configured properly, # needs a machine with lots of cores @@ -11,7 +11,7 @@ cd $BASEDIR # helpful commands: # scons -Q --tree=derived -cd $BASEDIR/opendbc_repo/ +cd "$BASEDIR/opendbc_repo/" scons --clean scons --no-cache --random if ! scons -q; then diff --git a/openpilot/selfdrive/test/setup_device_ci.sh b/openpilot/selfdrive/test/setup_device_ci.sh index 60631ce03..e3ee1fc3d 100755 --- a/openpilot/selfdrive/test/setup_device_ci.sh +++ b/openpilot/selfdrive/test/setup_device_ci.sh @@ -29,7 +29,7 @@ if [ -d /data/safe_staging/ ]; then fi CONTINUE_PATH="/data/continue.sh" -tee $CONTINUE_PATH << EOF +tee "$CONTINUE_PATH" << EOF #!/usr/bin/env bash sudo abctl --set_success @@ -54,13 +54,18 @@ done sleep infinity EOF -chmod +x $CONTINUE_PATH +chmod +x "$CONTINUE_PATH" export GIT_LFS_SKIP_SMUDGE=1 pull_lfs() { - # The big driving model is not used on these devices yet. Keep its pointer in - # the worktree, but don't download or copy the 1.8 GB LFS object. - LFS_EXCLUDE="openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" + if [ -n "${CHESTNUT:-}" ] + then + git lfs pull --exclude='' + return + fi + + # Keep the precompiled big model as a pointer on devices without Chestnut. + LFS_EXCLUDE="openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl" git config --local lfs.fetchexclude "$LFS_EXCLUDE" git lfs pull --exclude="$LFS_EXCLUDE" @@ -81,16 +86,16 @@ pull_lfs() { safe_checkout() { # completely clean TEST_DIR - cd $SOURCE_DIR + cd "$SOURCE_DIR" # cleanup orphaned locks find .git -type f -name "*.lock" -exec rm {} + git reset --hard - git fetch --no-tags --no-recurse-submodules -j4 --verbose --depth 1 origin $GIT_COMMIT + git fetch --no-tags --no-recurse-submodules -j4 --verbose --depth 1 origin "$GIT_COMMIT" find . -maxdepth 1 -not -path './.git' -not -name '.' -not -name '..' -exec rm -rf '{}' \; - git reset --hard $GIT_COMMIT - git checkout $GIT_COMMIT + git reset --hard "$GIT_COMMIT" + git checkout "$GIT_COMMIT" git clean -xdff git submodule sync git submodule foreach --recursive "git reset --hard && git clean -xdff" @@ -100,22 +105,22 @@ safe_checkout() { pull_lfs echo "git checkout done, t=$SECONDS" - du -hs $SOURCE_DIR $SOURCE_DIR/.git + du -hs "$SOURCE_DIR" "$SOURCE_DIR/.git" - rsync -a --delete $SOURCE_DIR $TEST_DIR + rsync -a --delete "$SOURCE_DIR" "$TEST_DIR" } unsafe_checkout() {( set -e # checkout directly in test dir, leave old build products - cd $TEST_DIR + cd "$TEST_DIR" # cleanup orphaned locks find .git -type f -name "*.lock" -exec rm {} + - git fetch --no-tags --no-recurse-submodules -j8 --verbose --depth 1 origin $GIT_COMMIT - git checkout --force --no-recurse-submodules $GIT_COMMIT - git reset --hard $GIT_COMMIT + git fetch --no-tags --no-recurse-submodules -j8 --verbose --depth 1 origin "$GIT_COMMIT" + git checkout --force --no-recurse-submodules "$GIT_COMMIT" + git reset --hard "$GIT_COMMIT" git clean -dff git submodule sync git submodule foreach --recursive "git reset --hard && git clean -df" @@ -129,7 +134,7 @@ export GIT_PACK_THREADS=8 # set up environment if [ ! -d "$SOURCE_DIR" ]; then - git clone https://github.com/commaai/openpilot.git $SOURCE_DIR + git clone https://github.com/commaai/openpilot.git "$SOURCE_DIR" fi if [ ! -z "$UNSAFE" ]; then @@ -146,7 +151,7 @@ else fi # submodule package symlinks for PYTHONPATH imports on device (same as launch_chffrplus.sh) -cd $TEST_DIR +cd "$TEST_DIR" ln -sfn msgq_repo/msgq msgq ln -sfn opendbc_repo/opendbc opendbc ln -sfn rednose_repo/rednose rednose diff --git a/openpilot/selfdrive/test/test_onroad.py b/openpilot/selfdrive/test/test_onroad.py index f524046ab..0b64b3fb3 100755 --- a/openpilot/selfdrive/test/test_onroad.py +++ b/openpilot/selfdrive/test/test_onroad.py @@ -20,8 +20,12 @@ from openpilot.common.basedir import BASEDIR from openpilot.common.timeout import Timeout from openpilot.common.params import Params from openpilot.selfdrive.selfdrived.events import EVENTS, ET -from openpilot.selfdrive.test.helpers import set_params_enabled, release_only +from openpilot.selfdrive.test.helpers import set_params_enabled, release_only, processes_context, log_collector +from openpilot.common.hardware import HARDWARE from openpilot.common.hardware.hw import Paths +from openpilot.common.mock import mock_messages +from opendbc.car.car_helpers import get_demo_car_params +from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled from openpilot.tools.lib.logreader import LogReader from openpilot.tools.lib.log_time_series import msgs_to_time_series @@ -457,5 +461,43 @@ class TestOnroad(OpenpilotTestCase): f"Not engageable for whole segment:\n- selfdriveState.engageable: {Counter(eng)}\n- No entry events: {no_entries}" +@unittest.skipUnless(HARDWARE.get_device_type() == "mici", "requires MICI") +class TestChestnutOnroad(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True + + @mock_messages(['deviceMotion']) + def test_camera_models(self, subtests): + assert chestnut_present() and chestnut_compiled() + Params().put("CarParams", get_demo_car_params().to_bytes(), block=True) + services = ['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState', 'modelV2', 'driverStateV2'] + sm = messaging.SubMaster(services) + pm = messaging.PubMaster(['deviceState']) + device_state = messaging.new_message('deviceState') + device_state.deviceState.deviceType = HARDWARE.get_device_type() + device_state_bytes = device_state.to_bytes() + with processes_context(['camerad', 'calibrationd', 'modeld', 'dmonitoringmodeld']): + with Timeout(60, "camera models didn't start"): + while not all(sm.seen.values()) or not sm.valid['modelV2']: + pm.send('deviceState', device_state_bytes) + sm.update(1000) + with log_collector(services) as (logs, _): + time.sleep(TEST_DURATION) + + msgs = {s: [m for m in logs if m.which() == s] for s in services} + for service, messages in msgs.items(): + with subtests.test(service=service): + expected = TEST_DURATION * SERVICE_LIST[service].frequency + assert np.isclose(len(messages), expected, rtol=0.05, atol=2), f"{service}: expected {expected}, got {len(messages)}" + assert all(m.valid for m in messages) + frame_ids = [getattr(m, service).frameId for m in messages] + assert np.all(np.diff(frame_ids) > 0), f"{service}: repeated or reordered frames" + + camera_frames = {m.narrowRoadCameraState.frameId for m in msgs['narrowRoadCameraState']} + model_frames = {m.modelV2.frameId for m in msgs['modelV2']} + assert len(camera_frames & model_frames) >= TEST_DURATION * SERVICE_LIST['modelV2'].frequency * 0.9 + assert all(m.modelV2.big for m in msgs['modelV2']), "Chestnut fell back to the small model" + assert all(np.isfinite(m.modelV2.position.x).all() for m in msgs['modelV2']) + + if __name__ == "__main__": unittest.main() diff --git a/openpilot/selfdrive/test/test_power_draw.py b/openpilot/selfdrive/test/test_power_draw.py index 1d0be24ef..d8b234fe6 100755 --- a/openpilot/selfdrive/test/test_power_draw.py +++ b/openpilot/selfdrive/test/test_power_draw.py @@ -5,6 +5,8 @@ import time import unittest import numpy as np from dataclasses import dataclass +from panda import Panda +from openpilot.common.hardware import HARDWARE from openpilot.common.test import OpenpilotTestCase from openpilot.common.utils import tabulate @@ -14,11 +16,14 @@ from opendbc.car.car_helpers import get_demo_car_params from openpilot.common.mock import mock_messages from openpilot.common.params import Params from openpilot.common.hardware.comma.power_monitor import get_power +from openpilot.selfdrive.modeld.helpers import chestnut_present from openpilot.system.manager.process_config import managed_processes from openpilot.system.manager.manager import manager_cleanup SAMPLE_TIME = 2 # seconds to sample power MAX_WARMUP_TIME = 30 # seconds to wait for SAMPLE_TIME consecutive valid samples +MICI = HARDWARE.get_device_type() == "mici" +CHESTNUT = chestnut_present() @dataclass class Proc: @@ -33,9 +38,10 @@ class Proc: return '+'.join(self.procs) +# MICI readings exclude the separately powered Chestnut GPU. PROCS = [ - Proc(['camerad'], 1.65, atol=0.4, msgs=['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']), - Proc(['modeld'], 1.5, atol=0.2, msgs=['modelV2']), + Proc(['camerad'], 0.85 if MICI else 1.65, atol=0.4, msgs=['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']), + Proc(['modeld'], 0.45 if MICI and CHESTNUT else 1.5, atol=0.2, msgs=['modelV2']), Proc(['dmonitoringmodeld'], 0.65, atol=0.35, msgs=['driverStateV2']), Proc(['encoderd'], 0.23, msgs=[]), ] @@ -46,6 +52,13 @@ class TestPowerDraw(OpenpilotTestCase): def setup_method(self): Params().put("CarParams", get_demo_car_params().to_bytes(), block=True) + self.panda = None + if MICI: + HARDWARE.reset_internal_panda() + self.addCleanup(HARDWARE.reset_internal_panda) + Panda.wait_for_panda(None, 30) + self.panda = Panda(cli=False) + self.addCleanup(self.panda.close) def teardown_method(self): manager_cleanup() @@ -78,7 +91,7 @@ class TestPowerDraw(OpenpilotTestCase): start_time = time.monotonic() while (time.monotonic() - start_time) < MAX_WARMUP_TIME: - power = get_power(1) + power = get_power(1, self.panda) iteration_msg_counts = {} for msg,sock in socks.items(): iteration_msg_counts[msg] = len(messaging.drain_sock_raw(sock)) @@ -97,7 +110,7 @@ class TestPowerDraw(OpenpilotTestCase): @mock_messages(['deviceMotion']) def test_camera_procs(self, subtests): - baseline = get_power() + baseline = get_power(panda=self.panda) prev = baseline used = {} diff --git a/openpilot/selfdrive/ui/lib/prime_state.py b/openpilot/selfdrive/ui/lib/prime_state.py index b57380ac5..1fa87e31d 100644 --- a/openpilot/selfdrive/ui/lib/prime_state.py +++ b/openpilot/selfdrive/ui/lib/prime_state.py @@ -101,6 +101,10 @@ class PrimeState: with self._lock: return bool(self.prime_type > PrimeType.NONE) + def is_full_prime(self) -> bool: + with self._lock: + return self.prime_type > PrimeType.NONE and self.prime_type != PrimeType.LITE + def is_paired(self) -> bool: with self._lock: return self.prime_type > PrimeType.UNPAIRED diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index 847cd42ed..419475a60 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime import math import time @@ -39,16 +41,13 @@ class AlertsPill(Widget): self.set_rect(rl.Rectangle(0, 0, 104, 52)) self._pill_bg_txt = gui_app.texture("icons_mici/alerts_pill.png", 104, 52) - self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", 36, 36) - self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", 36, 36) - self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 36, 36) self._alert_count_callback: Callable[[], int] | None = None - self._max_severity_callback: Callable[[], int | None] | None = None + self._alert_icon_callback: Callable[[], rl.Texture | None] | None = None def set_alert_count_callback(self, callback: Callable[[], int] | None, - severity_callback: Callable[[], int | None] | None = None): + icon_callback: Callable[[], rl.Texture | None] | None = None): self._alert_count_callback = callback - self._max_severity_callback = severity_callback + self._alert_icon_callback = icon_callback def _render(self, _): alert_count = self._alert_count_callback() if self._alert_count_callback else 0 @@ -56,17 +55,12 @@ class AlertsPill(Widget): pill_w, pill_h = self._pill_bg_txt.width, self._pill_bg_txt.height rl.draw_texture_ex(self._pill_bg_txt, rl.Vector2(self.rect.x, self.rect.y), 0.0, 1.0, rl.WHITE) - severity = self._max_severity_callback() if self._max_severity_callback else None - if severity == -1: - warning_txt = self._icon_green - elif severity is not None and severity > 0: - warning_txt = self._icon_red - else: - warning_txt = self._icon_orange - - warn_x = self.rect.x + self.ICON_OFFSET - warn_y = self.rect.y + (pill_h - warning_txt.height) / 2 - rl.draw_texture_ex(warning_txt, rl.Vector2(warn_x, warn_y), 0.0, 1.0, rl.WHITE) + warning_txt = self._alert_icon_callback() if self._alert_icon_callback else None + if warning_txt is not None: + scale = 36 / max(warning_txt.width, warning_txt.height) + warn_x = self.rect.x + self.ICON_OFFSET + warn_y = self.rect.y + (pill_h - warning_txt.height * scale) / 2 + rl.draw_texture_ex(warning_txt, rl.Vector2(warn_x, warn_y), 0.0, scale, rl.WHITE) count_rect = rl.Rectangle(self.rect.x + self.COUNT_OFFSET, self.rect.y, pill_w - self.COUNT_OFFSET, pill_h) gui_label(count_rect, str(alert_count), font_size=36, @@ -77,21 +71,21 @@ class AlertsPill(Widget): class NetworkIcon(Widget): def __init__(self): super().__init__() - self.set_rect(rl.Rectangle(0, 0, 54, 44)) # max size of all icons + self.set_rect(rl.Rectangle(0, 0, 60, 47)) # max size of all icons self._net_type = NetworkType.none self._net_strength = 0 - self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 50, 44) - self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 50, 37) - self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 50, 37) - self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 50, 37) - self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 50, 37) + self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 54, 47) + self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 54, 40) + self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 54, 40) + self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 54, 40) + self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 54, 40) - self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 54, 36) - self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 54, 36) - self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 54, 36) - self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 54, 36) - self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 54, 36) + self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 60, 40) + self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 60, 40) + self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 60, 40) + self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 60, 40) + self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 60, 40) def _update_state(self): device_state = ui_state.sm['deviceState'] @@ -141,7 +135,7 @@ class MiciHomeLayout(Widget): self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) self._usb_icon = IconWidget("icons_mici/usb.png", (62, 40)) - self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (68, 40)) + self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (54, 40)) self._chestnut_loading_icon = IconWidget("icons_mici/chestnut.png", (68, 40)) self._chestnut_failed_icon = IconWidget("icons_mici/chestnut_orange.png", (68, 40)) self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46)) @@ -187,11 +181,11 @@ class MiciHomeLayout(Widget): def set_callbacks(self, on_settings: Callable | None = None, on_alerts: Callable | None = None, alert_count_callback: Callable[[], int] | None = None, - max_severity_callback: Callable[[], int | None] | None = None): + alert_icon_callback: Callable[[], rl.Texture | None] | None = None): self._on_settings_click = on_settings self._on_alerts_click = on_alerts self._alert_count_callback = alert_count_callback - self._alerts_pill.set_alert_count_callback(alert_count_callback, max_severity_callback) + self._alerts_pill.set_alert_count_callback(alert_count_callback, alert_icon_callback) def _handle_mouse_release(self, mouse_pos: MousePos): if not self._did_long_press: diff --git a/openpilot/selfdrive/ui/mici/layouts/main.py b/openpilot/selfdrive/ui/mici/layouts/main.py index 7b9636689..dc294f318 100644 --- a/openpilot/selfdrive/ui/mici/layouts/main.py +++ b/openpilot/selfdrive/ui/mici/layouts/main.py @@ -77,7 +77,7 @@ class MiciMainLayout(Scroller): on_settings=lambda: gui_app.push_widget(self._settings_layout), on_alerts=lambda: self._scroll_to(self._alerts_layout), alert_count_callback=self._alerts_layout.active_alerts, - max_severity_callback=self._alerts_layout.max_severity, + alert_icon_callback=self._alerts_layout.highest_severity_icon, ) for layout in (self._car_onroad_layout, self._body_onroad_layout): layout.set_click_callback(lambda: self._scroll_to(self._home_layout)) diff --git a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py index de8286cae..9c9018146 100644 --- a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py +++ b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pyray as rl import re import threading @@ -29,6 +31,7 @@ class AlertData: text: str severity: int visible: bool = False + icon: str | None = None class AlertItem(Widget): @@ -56,10 +59,20 @@ class AlertItem(Widget): self._bg_big = gui_app.texture("icons_mici/offroad_alerts/big_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG) self._bg_big_pressed = gui_app.texture("icons_mici/offroad_alerts/big_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG) - # Load warning icons + # Load alert icons self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", self.ICON_SIZE, self.ICON_SIZE) self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", self.ICON_SIZE, self.ICON_SIZE) self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", self.ICON_SIZE, self.ICON_SIZE) + self._custom_icon = gui_app.texture(alert_data.icon, self.ICON_SIZE, self.ICON_SIZE) if alert_data.icon else None + + if self._custom_icon is not None: + self._icon = self._custom_icon + elif alert_data.severity == -1: + self._icon = self._icon_green + elif alert_data.severity > 0: + self._icon = self._icon_red + else: + self._icon = self._icon_orange self._title_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.SEMI_BOLD, text_color=self.TEXT_COLOR, alignment=TextAlignment.LEFT, @@ -75,6 +88,10 @@ class AlertItem(Widget): self._update_content() + @property + def icon(self) -> rl.Texture: + return self._icon + def _split_text(self, text: str) -> tuple[str, str]: """Split text into title (first sentence) and body (remaining text).""" # Find the end of the first sentence (period, exclamation, or question mark followed by space or end) @@ -176,16 +193,9 @@ class AlertItem(Widget): self._body_label.render(body_rect) # Draw warning icon on the right side - # Use green icon for update alerts (severity = -1), red for high severity, orange for low severity - if self.alert_data.severity == -1: - icon_texture = self._icon_green - elif self.alert_data.severity > 0: - icon_texture = self._icon_red - else: - icon_texture = self._icon_orange icon_x = self._rect.x + self.ALERT_WIDTH - self.ALERT_PADDING - self.ICON_SIZE icon_y = self._rect.y + self.ALERT_PADDING - rl.draw_texture_ex(icon_texture, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE) + rl.draw_texture_ex(self._icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE) class MiciOffroadAlerts(Scroller): @@ -214,8 +224,10 @@ class MiciOffroadAlerts(Scroller): def active_alerts(self) -> int: return sum(alert.visible for alert in self.sorted_alerts) - def max_severity(self) -> int | None: - return max((alert.severity for alert in self.sorted_alerts if alert.visible), default=None) + def highest_severity_icon(self) -> rl.Texture | None: + item = max((item for item in self.alert_items if item.alert_data.visible), + key=lambda item: (item.alert_data.severity, bool(item.alert_data.icon)), default=None) + return item.icon if item is not None else None def scrolling(self): return self._scroller.scroll_panel.is_touch_valid() @@ -235,7 +247,7 @@ class MiciOffroadAlerts(Scroller): # Add regular alerts sorted by severity for key, config in sorted(OFFROAD_ALERTS.items(), key=lambda x: x[1].get("severity", 0), reverse=True): severity = config.get("severity", 0) - alert_data = AlertData(key=key, text="", severity=severity) + alert_data = AlertData(key=key, text="", severity=severity, icon=config.get("icon")) self.sorted_alerts.append(alert_data) # Create alert item widget diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py index 2d7dc0899..0b52e48bf 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/developer.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/developer.py @@ -23,8 +23,6 @@ class AlphaLongConfirmPage(NavScroller): GreyBigButton("", "WARNING: alpha longitudinal control may disable Automatic Emergency Braking (AEB)"), GreyBigButton("", "On this car, openpilot defaults to the stock system's built-in ACC."), GreyBigButton("", "Enabling this will switch to openpilot longitudinal control."), - GreyBigButton("", "Using Experimental mode is recommended with openpilot longitudinal control alpha."), - GreyBigButton("", "Changing this setting will restart openpilot if the car is powered on."), accept, ]) @@ -63,28 +61,31 @@ class DeveloperLayoutMici(NavScroller): txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 56, 64) github_username = ui_state.params.get("GithubUsername") or "" - self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh) + self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh, + description="Grant SSH access to all public keys in your GitHub settings. Only enter your own username.") self._ssh_keys_btn.set_click_callback(ssh_keys_callback) # adb, ssh, ssh keys, debug mode, joystick debug mode, longitudinal maneuver mode, ip address # ******** Main Scroller ******** - self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12)) - self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12)) - self._joystick_toggle = BigToggle("joystick debug mode", - initial_state=ui_state.params.get_bool("JoystickDebugMode"), - toggle_callback=self._on_joystick_debug_mode) - self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode", - initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"), - toggle_callback=self._on_long_maneuver_mode) - self._lat_maneuver_toggle = BigToggle("lateral maneuver mode", - initial_state=ui_state.params.get_bool("LateralManeuverMode"), - toggle_callback=self._on_lat_maneuver_mode) - self._alpha_long_toggle = BigToggle("alpha longitudinal", - initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"), - toggle_callback=self._on_alpha_long_enabled) + self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12), + description="Use Android Debug Bridge (ADB) over USB or the network.", title="enable ADB") + self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12), + description="Access the device remotely using your SSH keys.", title="enable SSH") + self._joystick_toggle = BigToggle("joystick debug\nmode", initial_state=ui_state.params.get_bool("JoystickDebugMode"), + toggle_callback=self._on_joystick_debug_mode, description="Control the car with a joystick for debugging.") + self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode", initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"), + toggle_callback=self._on_long_maneuver_mode, + description="Run longitudinal maneuvers for testing gas and brake control.") + self._lat_maneuver_toggle = BigToggle("lateral maneuver mode", initial_state=ui_state.params.get_bool("LateralManeuverMode"), + toggle_callback=self._on_lat_maneuver_mode, + description="Run lateral maneuvers for testing steering control.") + self._alpha_long_toggle = BigToggle("alpha longitudinal", initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"), + toggle_callback=self._on_alpha_long_enabled, + description="Use alpha openpilot longitudinal control instead of stock ACC. This may disable Automatic Emergency " + + "Braking (AEB).") self._debug_mode_toggle = BigParamControl("ui debug mode", "ShowDebugInfo", - toggle_callback=lambda checked: (gui_app.set_show_touches(checked), - gui_app.set_show_fps(checked))) + toggle_callback=lambda checked: (gui_app.set_show_touches(checked), gui_app.set_show_fps(checked)), + description="Show touch locations and the UI frame rate.") self._scroller.add_widgets([ self._adb_toggle, diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device.py b/openpilot/selfdrive/ui/mici/layouts/settings/device.py index 6038ff7d3..c038f5fdd 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device.py @@ -1,6 +1,7 @@ import os import pyray as rl from collections.abc import Callable +from typing import Union from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params @@ -77,15 +78,16 @@ def _engaged_confirmation_click(callback: Callable, action_text: str, icon: rl.T class EngagedConfirmationCircleButton(BigCircleButton): def __init__(self, title: str, icon: rl.Texture, callback: Callable[[], None], exit_on_confirm: bool = True, - red: bool = False, icon_offset: tuple[int, int] = (0, 0)): - super().__init__(icon, red, icon_offset) + red: bool = False, icon_offset: tuple[int, int] = (0, 0), *, description: str = ""): + super().__init__(icon, red, icon_offset, description=description, title=title) self.set_click_callback(lambda: _engaged_confirmation_click(callback, title, icon, exit_on_confirm=exit_on_confirm, red=red)) class EngagedConfirmationButton(BigButton): def __init__(self, text: str, action_text: str, icon: rl.Texture, callback: Callable[[], None], - exit_on_confirm: bool = True, red: bool = False): - super().__init__(text, "", icon) + exit_on_confirm: bool = True, red: bool = False, *, description: str = "", + description_icon: Union[rl.Texture, None] = None): + super().__init__(text, "", icon, description=description, description_icon=description_icon) self.set_click_callback(lambda: _engaged_confirmation_click(callback, action_text, icon, exit_on_confirm=exit_on_confirm, red=red)) @@ -177,7 +179,9 @@ class DeviceLayoutMici(NavScroller): params.put_bool("OnroadCycleRequested", True, block=True) reset_calibration_btn = EngagedConfirmationButton("reset calibration", "reset", gui_app.texture("icons_mici/settings/device/lkas.png", 122, 64), - reset_calibration_callback) + reset_calibration_callback, + description="Mount the device within 4° left or right and 5° up or 9° down. openpilot calibrates " + + "continuously; resetting is rarely needed. Resetting clears learned calibration.") reboot_btn = EngagedConfirmationCircleButton("reboot", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70), reboot_callback, exit_on_confirm=False) @@ -189,7 +193,8 @@ class DeviceLayoutMici(NavScroller): regulatory_btn = BigButton("regulatory info", "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64)) regulatory_btn.set_click_callback(self._on_regulatory) - cabin_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64)) + cabin_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64), + description="Preview the cabin camera to check driver monitoring visibility. The vehicle must be off.") cabin_cam_btn.set_click_callback(lambda: gui_app.push_widget(CabinCameraDialog())) cabin_cam_btn.set_enabled(lambda: ui_state.is_offroad()) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py index ddbab4b47..bc297ed0e 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -1,13 +1,60 @@ import pyray as rl +from openpilot.cereal import log from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiIcon from openpilot.selfdrive.ui.mici.widgets.button import BigButton +from openpilot.common.hardware import HARDWARE from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid +NetworkStrength = log.DeviceState.NetworkStrength +NetworkType = log.DeviceState.NetworkType + + +class EsimNetworkButton(BigButton): + def __init__(self, cellular_manager: CellularManager, *, description: str = ""): + self._cellular_manager = cellular_manager + self._cell_icons = { + NetworkStrength.unknown: gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 64, 47), + NetworkStrength.poor: gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 64, 47), + NetworkStrength.moderate: gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 64, 47), + NetworkStrength.good: gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 64, 47), + NetworkStrength.great: gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 64, 47), + } + super().__init__("esim", "loading...", self._cell_icons[NetworkStrength.unknown], scroll=True, description=description) + + def _update_state(self): + super()._update_state() + self.set_enabled(self._cellular_manager.is_euicc is not False) + text, value, icon = self._compute_state() + self.set_text(text) + self.set_value(value) + self.set_icon(icon) + + def _compute_state(self): + cm = self._cellular_manager + none_icon = self._cell_icons[NetworkStrength.unknown] + ip = cm.modem_state.get("ip_address") or "connecting..." + if cm.is_euicc is False: + iccid = cm.modem_state.get("iccid") or "" + if not iccid: + return "sim", "no sim", none_icon + return f"sim (...{iccid[-4:]})", ip, self._cell_icon() + + active = cm.active_profile + if active is None: + return "esim", "loading...", none_icon + return active.display_name, ip, self._cell_icon() + + def _cell_icon(self): + # read directly from HARDWARE so it reflects modem state even when wifi is the active connection + strength = HARDWARE.get_network_strength(NetworkType.cell4G) + return self._cell_icons.get(strength, self._cell_icons[NetworkStrength.unknown]) + class WifiNetworkButton(BigButton): - def __init__(self, wifi_manager: WifiManager): + def __init__(self, wifi_manager: WifiManager, *, description: str = ""): self._wifi_manager = wifi_manager self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 28, 36) self._draw_lock = False @@ -17,7 +64,7 @@ class WifiNetworkButton(BigButton): self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 64, 47) self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 64, 47) - super().__init__("wi-fi", "not connected", self._wifi_slash_txt, scroll=True) + super().__init__("wi-fi", "not connected", self._wifi_slash_txt, scroll=True, description=description) def _update_state(self): super()._update_state() diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py new file mode 100644 index 000000000..b54edec42 --- /dev/null +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/esim_ui.py @@ -0,0 +1,412 @@ +import threading + +import numpy as np +import pyray as rl +from collections.abc import Callable +from openpilot.cereal import log +from openpilot.cereal.visionipc import VisionStreamType + +from openpilot.common import qrcode +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraView +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets.nav_widget import NavWidget + +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog, BigConfirmationDialog +from openpilot.common.esim.base import Profile +from openpilot.common.esim.lpa import parse_lpa_activation_code +from openpilot.system.ui.lib.application import DEFAULT_TEXT_COLOR, FontWeight, MousePos, TextAlignment, gui_app +from openpilot.system.ui.lib.cellular_manager import CellularManager +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label +from openpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller + + +class ProfileActionButton(Widget): + SIZE = 68 + MARGIN = 10 + HORIZONTAL_MARGIN = 4 + + def __init__(self, callback: Callable, delete: bool = False): + super().__init__() + self.set_click_callback(callback) + self._delete = delete + self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 25, 30) if delete else None + + self._bg_txt = gui_app.texture("icons_mici/buttons/button_circle.png", self.SIZE, self.SIZE) + self._bg_pressed_txt = gui_app.texture("icons_mici/buttons/button_circle_pressed.png", self.SIZE, self.SIZE) + self.set_rect(rl.Rectangle(0, 0, self.SIZE + self.HORIZONTAL_MARGIN * 2, self.SIZE + self.MARGIN * 2)) + + def _render(self, _): + bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt + rl.draw_texture_ex(bg_txt, (self._rect.x + (self._rect.width - self._bg_txt.width) / 2, + self._rect.y + (self._rect.height - self._bg_txt.height) / 2), 0, 1.0, rl.WHITE) + color = rl.Color(255, 105, 115, 255) if self._delete else DEFAULT_TEXT_COLOR + if not self.enabled: + color = rl.Color(color.r, color.g, color.b, 90) + if self._trash_txt: + rl.draw_texture_ex(self._trash_txt, (self._rect.x + (self._rect.width - self._trash_txt.width) / 2, + self._rect.y + (self._rect.height - self._trash_txt.height) / 2), 0, 1.0, color) + else: + gui_label(self._rect, "Aa", 30, color=color, alignment=TextAlignment.CENTER) + + +class QRScannerDialog(NavWidget): + SCAN_INTERVAL_S = 0.25 + INVALID_CODE_DURATION_S = 1.0 + + def __init__(self, on_qr_detected: Callable[[str], None]): + super().__init__() + self._on_qr_detected = on_qr_detected + self._camera_view = CabinCameraView("camerad", VisionStreamType.VISION_STREAM_CABIN) + self._detected = False + self._last_scan_time = 0.0 + self._invalid_code_until = 0.0 + self._scan_thread: threading.Thread | None = None + self._scan_result: str | None = None + self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + + def show_event(self): + super().show_event() + ui_state.params.put_bool("DisableDriverCameraIR", True) + ui_state.params.put_bool("IsDriverViewEnabled", True) + + def hide_event(self): + super().hide_event() + ui_state.params.put_bool("IsDriverViewEnabled", False) + ui_state.params.put_bool("DisableDriverCameraIR", False) + + def __del__(self): + self._camera_view.close() + + def _update_state(self): + super()._update_state() + + now = rl.get_time() + if self._detected or not self._camera_view.frame or now < self._invalid_code_until: + return + + if self._scan_thread is not None: + if self._scan_thread.is_alive(): + return + self._scan_thread = None + data = self._scan_result + if data is not None: + try: + parse_lpa_activation_code(data) + except ValueError: + self._invalid_code_until = now + self.INVALID_CODE_DURATION_S + self._last_scan_time = self._invalid_code_until + else: + self._detected = True + self.dismiss(lambda: self._on_qr_detected(data)) + return + + if now - self._last_scan_time < self.SCAN_INTERVAL_S: + return + self._last_scan_time = now + + frame = self._camera_view.frame + y = np.frombuffer(frame.data, dtype=np.uint8, count=frame.height * frame.stride).reshape(frame.height, frame.stride) + gray = y[:, :frame.width].copy() # the vision buffer is recycled under the scan thread + self._scan_thread = threading.Thread(target=self._scan, args=(gray,), daemon=True) + self._scan_thread.start() + + def _scan(self, gray: np.ndarray): + self._scan_result = qrcode.decode(gray) + + def _render(self, rect): + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + self._camera_view._render(rect) + + if not self._camera_view.frame: + gui_label(rect, tr("camera starting"), font_size=54, font_weight=FontWeight.BOLD, + alignment=TextAlignment.CENTER) + else: + label_y = rect.y + rect.height * 3 / 4 + label_rect = rl.Rectangle(rect.x, label_y + (rect.height - label_y) / 2 - 20, rect.width, 40) + text = "not an LPA code" if rl.get_time() < self._invalid_code_until else "hold QR code to camera" + gui_label(label_rect, text, font_size=32, font_weight=FontWeight.MEDIUM, + alignment=TextAlignment.CENTER, + color=rl.Color(255, 255, 255, int(255 * 0.9))) + + rl.end_scissor_mode() + + +class InstallingProfileDialog(BigDialog): + DOT_STEP = 0.6 + + def __init__(self): + super().__init__("installing profile", "please wait...") + self._show_time = 0.0 + + def show_event(self): + super().show_event() + self._nav_bar._alpha = 0.0 + self._show_time = rl.get_time() + + def _back_enabled(self) -> bool: + return False + + def _render(self, _): + t = (rl.get_time() - self._show_time) % (self.DOT_STEP * 2) + dots = "." * min(int(t / (self.DOT_STEP / 4)), 3) + self._card.set_value(f"please wait{dots}") + super()._render(_) + + +class EsimProfileButton(BigButton): + SUB_LABEL_DISABLED = rl.Color(255, 255, 255, int(255 * 0.585)) + CHECK_ICON_COLOR = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) + LABEL_PADDING = 98 + LABEL_WIDTH = 402 - 98 - 28 + SUB_LABEL_WIDTH = 402 - BigButton.LABEL_HORIZONTAL_PADDING * 2 + + def __init__(self, profile: Profile, cellular_manager: CellularManager, profiles_enabled: Callable[[], bool]): + self._cellular_manager = cellular_manager + self._profiles_enabled = profiles_enabled + super().__init__(profile.display_name, scroll=True) + + self._profile = profile + + self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 48, 36) + self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 48, 36) + self._check_txt = gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 32, 32) + self._comma_txt = gui_app.texture("icons_mici/settings/comma_icon.png", 36, 36) if profile.is_comma else None + + self._delete_btn = ProfileActionButton(self._on_delete, delete=True) + self._rename_btn = ProfileActionButton(self._on_rename) if not profile.is_comma else None + self._delete_btn.set_enabled(lambda: not self._locked and not self._cellular_manager.busy and self._show_delete_btn) + if self._rename_btn: + self._rename_btn.set_enabled(lambda: not self._locked and not self._cellular_manager.busy) + self.set_enabled(lambda: not self._profile.enabled and self._profiles_enabled() and not self._cellular_manager.busy) + self.update_profile(profile) + + @property + def profile(self) -> Profile: + return self._profile + + def update_profile(self, profile: Profile): + self._profile = profile + active = profile.enabled + self.set_text(profile.display_name) + self.set_value("active" if active else "switch") + + def _update_state(self): + super()._update_state() + self._sub_label.set_color(DEFAULT_TEXT_COLOR if self.enabled else self.SUB_LABEL_DISABLED) + self._sub_label.set_font_weight(FontWeight.SEMI_BOLD if self.enabled else FontWeight.ROMAN) + + @property + def _locked(self) -> bool: + return not self._profile.is_comma and not self._profiles_enabled() + + @property + def _show_delete_btn(self) -> bool: + return not self._profile.enabled and not self._profile.is_comma + + def _on_rename(self): + current = self._profile.nickname or "" + dlg = BigInputDialog("nickname", default_text=current, confirm_callback=self._on_nickname_entered, + text_validator=lambda text: bool(text.strip())) + gui_app.push_widget(dlg) + + def _on_delete(self): + icon = gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64) + gui_app.push_widget(BigConfirmationDialog("slide to delete", icon, self._delete_profile, red=True)) + + def _delete_profile(self): + if not self._locked and not self._cellular_manager.busy and self._show_delete_btn: + if ui_state.sm["deviceState"].networkType == log.DeviceState.NetworkType.none: + gui_app.push_widget(BigDialog("", tr("Ensure you're connected to the internet and try again."))) + return + self._cellular_manager.delete_profile(self._profile.iccid) + + def _on_nickname_entered(self, nickname: str): + if not self._locked and not self._cellular_manager.busy: + self._cellular_manager.nickname_profile(self._profile.iccid, nickname.strip()) + + def _handle_mouse_release(self, mouse_pos: MousePos): + if self._show_delete_btn and rl.check_collision_point_rec(mouse_pos, self._delete_btn.rect): + return + if self._rename_btn is not None and rl.check_collision_point_rec(mouse_pos, self._rename_btn.rect): + return + super()._handle_mouse_release(mouse_pos) + + def _get_label_font_size(self): + return 48 + + def _draw_content(self, btn_y: float): + self._label.set_color(self.SUB_LABEL_DISABLED if self._locked else LABEL_COLOR) + label_rect = rl.Rectangle(self._rect.x + self.LABEL_PADDING, btn_y + self.LABEL_VERTICAL_PADDING, + self.LABEL_WIDTH, self._rect.height - self.LABEL_VERTICAL_PADDING * 2) + self._label.render(label_rect) + + active = self._profile.enabled + + if self.value: + sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING + label_y = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING + action_w = self._rename_btn.rect.width if self._rename_btn is not None else 0 + action_w += self._delete_btn.rect.width if self._show_delete_btn else 0 + sub_label_w = self.SUB_LABEL_WIDTH - action_w + sub_label_height = self._sub_label.get_content_height(sub_label_w) + + if active: + check_y = int(label_y - sub_label_height + (sub_label_height - self._check_txt.height) / 2) + rl.draw_texture_ex(self._check_txt, rl.Vector2(sub_label_x, check_y), 0.0, 1.0, self.CHECK_ICON_COLOR) + sub_label_x += self._check_txt.width + 14 + + sub_label_rect = rl.Rectangle(sub_label_x, label_y - sub_label_height, sub_label_w, sub_label_height) + self._sub_label.render(sub_label_rect) + + if self._comma_txt: + rl.draw_texture_ex(self._comma_txt, (self._rect.x + 36, btn_y + 38), 0.0, 1.0, rl.WHITE) + else: + cell_icon = self._cell_full_txt if active else self._cell_none_txt + rl.draw_texture_ex(cell_icon, (self._rect.x + 30, btn_y + 38), 0.0, 1.0, rl.WHITE) + + btn_x = self._rect.x + self._rect.width - (ProfileActionButton.MARGIN - ProfileActionButton.HORIZONTAL_MARGIN) + btn_bottom = btn_y + self._rect.height + if self._show_delete_btn: + btn_x -= self._delete_btn.rect.width + self._delete_btn.render(rl.Rectangle( + btn_x, btn_bottom - self._delete_btn.rect.height, + self._delete_btn.rect.width, self._delete_btn.rect.height, + )) + if self._rename_btn is not None: + btn_x -= self._rename_btn.rect.width + self._rename_btn.render(rl.Rectangle( + btn_x, btn_bottom - self._rename_btn.rect.height, + self._rename_btn.rect.width, self._rename_btn.rect.height, + )) + + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + def action_pressed() -> bool: + return self._delete_btn.is_pressed or (self._rename_btn is not None and self._rename_btn.is_pressed) + super().set_touch_valid_callback(lambda: touch_callback() and not action_pressed()) + self._delete_btn.set_touch_valid_callback(touch_callback) + if self._rename_btn: + self._rename_btn.set_touch_valid_callback(touch_callback) + + +class EsimErrorDialog(NavRawScrollPanel): + def __init__(self, error: str): + super().__init__() + self._title = UnifiedLabel("esim error", font_size=64, font_weight=FontWeight.BOLD) + self._error = UnifiedLabel(error, font_size=36, elide=False) + + def _render(self, rect: rl.Rectangle): + width = int(rect.width - 80) + title_height = self._title.get_content_height(width) + error_height = self._error.get_content_height(width) + offset = self._scroll_panel.update(rect, title_height + error_height + 100) + y = rect.y + 40 + offset + + rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) + self._title.render(rl.Rectangle(rect.x + 40, y, width, title_height)) + self._error.render(rl.Rectangle(rect.x + 40, y + title_height + 20, width, error_height)) + rl.end_scissor_mode() + + +class EsimUI(NavScroller): + def __init__(self, cellular_manager: CellularManager, profiles_enabled: Callable[[], bool]): + super().__init__() + + self._cellular_manager = cellular_manager + self._profiles_enabled = profiles_enabled + + self._add_profile_btn = BigButton("add profile", "scan QR code") + self._add_profile_btn.set_click_callback(self._on_add_profile) + self._scroller.add_widget(self._add_profile_btn) + self._installing_dialog: InstallingProfileDialog | None = None + + self._cellular_manager.on_profiles_updated = self._on_profiles_updated + self._cellular_manager.on_operation_error = self._on_error + + def show_event(self): + super().show_event() + self._update_buttons(re_sort=True) + self._cellular_manager.refresh_profiles() + + def _on_profiles_updated(self): + if self._installing_dialog: + existing = {btn.profile.iccid for btn in self._scroller.items if isinstance(btn, EsimProfileButton)} + added = [profile for profile in self._cellular_manager.profiles if profile.iccid not in existing] + # Start the normal tap-to-activate flow once the profile list is visible again. + self._installing_dialog.dismiss(lambda: self._on_profile_clicked(added[0]) if len(added) == 1 else None) + self._installing_dialog = None + + self._update_buttons() + + def _update_buttons(self, re_sort: bool = False): + existing = {btn.profile.iccid: btn for btn in self._scroller.items if isinstance(btn, EsimProfileButton)} + buttons = [] + for profile in self._cellular_manager.profiles: + btn = existing.get(profile.iccid) + if btn is None: + btn = EsimProfileButton(profile, self._cellular_manager, self._profiles_enabled) + btn.set_click_callback(lambda btn=btn: self._on_profile_clicked(btn.profile)) + self._scroller.add_widget(btn) + else: + btn.update_profile(profile) + buttons.append(btn) + + if re_sort: + self._scroller.items[:] = sorted(buttons, key=lambda b: not b.profile.enabled) + else: + self._scroller.items[:] = [btn for btn in self._scroller.items if btn in buttons] + + self._scroller.items.append(self._add_profile_btn) + + def _move_profile_to_front(self, iccid: str | None, scroll: bool = False): + front_btn_idx = next((i for i, btn in enumerate(self._scroller.items) + if isinstance(btn, EsimProfileButton) and btn.profile.iccid == iccid), None) if iccid else None + + if front_btn_idx is not None and front_btn_idx > 0: + self._scroller.move_item(front_btn_idx, 0) + + if scroll: + self._scroller.scroll_to(self._scroller.scroll_panel.get_offset(), smooth=True) + + def _update_state(self): + super()._update_state() + + self._add_profile_btn.set_enabled(not self._cellular_manager.busy and self._profiles_enabled()) + active = self._cellular_manager.active_profile + self._move_profile_to_front(active.iccid if active else None) + + def _on_add_profile(self): + if self._cellular_manager.busy or not self._profiles_enabled(): + return + if ui_state.sm["deviceState"].networkType == log.DeviceState.NetworkType.none: + gui_app.push_widget(BigDialog("", tr("Ensure you're connected to the internet and try again."))) + return + gui_app.push_widget(QRScannerDialog(on_qr_detected=self._on_qr_scanned)) + + def _on_qr_scanned(self, lpa_code: str): + dlg = BigInputDialog("enter a nickname...", text_validator=lambda text: bool(text.strip()), + confirm_callback=lambda nickname: self._download_profile(lpa_code, nickname)) + gui_app.push_widget(dlg) + + def _download_profile(self, lpa_code: str, nickname: str): + self._installing_dialog = InstallingProfileDialog() + gui_app.push_widget(self._installing_dialog) + self._cellular_manager.download_profile(lpa_code, nickname.strip()) + + def _on_error(self, error: str): + cloudlog.error("eSIM error: %s", error) + dlg = EsimErrorDialog(error) + if self._installing_dialog: + self._installing_dialog.dismiss(lambda: gui_app.push_widget(dlg)) + self._installing_dialog = None + else: + gui_app.push_widget(dlg) + + def _on_profile_clicked(self, profile: Profile): + if self._cellular_manager.busy or not self._profiles_enabled(): + return + self._cellular_manager.switch_profile(profile.iccid) + self._move_profile_to_front(profile.iccid, scroll=True) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py index 58b3d0c77..420333646 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -1,12 +1,13 @@ -from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiNetworkButton +from openpilot.selfdrive.ui.mici.layouts.settings.network import EsimNetworkButton, WifiNetworkButton +from openpilot.selfdrive.ui.mici.layouts.settings.network.esim_ui import EsimUI from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigParamControl, BigToggle from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.selfdrive.ui.lib.prime_state import PrimeType from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.cellular_manager import CellularManager from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType +from openpilot.system.ui.widgets.scroller import NavScroller class NetworkLayoutMici(NavScroller): @@ -28,7 +29,8 @@ class NetworkLayoutMici(NavScroller): self._network_metered_btn.set_enabled(False) self._wifi_manager.set_tethering_active(checked) - self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback) + self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback, + description="Share the device’s internet connection through a Wi-Fi hotspot.") def tethering_password_callback(password: str): if password: @@ -58,26 +60,39 @@ class NetworkLayoutMici(NavScroller): # TODO: signal for current network metered type when changing networks, this is wrong until you press it once # TODO: disable when not connected - self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback) + self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback, + description="Metered prevents large uploads on this Wi-Fi connection. Default uses the network’s detected " + + "setting.") self._network_metered_btn.set_enabled(False) self._wifi_button = WifiNetworkButton(self._wifi_manager) self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui)) + # ******** eSIM ******** + self._cellular_manager = CellularManager() + self._esim_ui = EsimUI( + self._cellular_manager, + lambda: not ui_state.prime_state.is_full_prime(), + ) + self._esim_button = EsimNetworkButton(self._cellular_manager) + self._esim_button.set_click_callback(lambda: gui_app.push_widget(self._esim_ui)) + # ******** Advanced settings ******** # ******** Roaming toggle ******** - self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming") + self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming", description="Allow cellular data roaming.") # ******** APN settings ******** - self._apn_btn = BigButton("apn settings", "edit") + self._apn_btn = BigButton("apn settings", "edit", + description="Set the access point name required by your cellular carrier. Leave blank for automatic configuration.") self._apn_btn.set_click_callback(self._edit_apn) # ******** Cellular metered toggle ******** - self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered") + self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered", description="Prevent large uploads over the cellular connection.") # Main scroller ---------------------------------- self._scroller.add_widgets([ self._wifi_button, + self._esim_button, self._network_metered_btn, self._tethering_toggle_btn, self._tethering_password_btn, @@ -91,8 +106,7 @@ class NetworkLayoutMici(NavScroller): def _update_state(self): super()._update_state() - # If not using prime SIM, show GSM settings and enable IPv4 forwarding - show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE) + show_cell_settings = not ui_state.prime_state.is_full_prime() self._wifi_manager.set_ipv4_forward(show_cell_settings) self._roaming_btn.set_visible(show_cell_settings) self._apn_btn.set_visible(show_cell_settings) @@ -102,14 +116,16 @@ class NetworkLayoutMici(NavScroller): super().show_event() self._wifi_manager.set_active(True) - # Process wifi callbacks while at any point in the nav stack + # Process wifi and esim callbacks while at any point in the nav stack gui_app.add_nav_stack_tick(self._wifi_manager.process_callbacks) + gui_app.add_nav_stack_tick(self._cellular_manager.process_callbacks) def hide_event(self): super().hide_event() self._wifi_manager.set_active(False) gui_app.remove_nav_stack_tick(self._wifi_manager.process_callbacks) + gui_app.remove_nav_stack_tick(self._cellular_manager.process_callbacks) def _edit_apn(self): def update_apn(apn: str): diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py index eb7789cba..477bd7893 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py @@ -3,7 +3,7 @@ from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.network.network_layout import NetworkLayoutMici -from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton +from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.software import SoftwareLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout @@ -49,7 +49,6 @@ class SettingsLayout(NavScroller): network_btn, device_btn, software_btn, - PairBigButton(), firehose_btn, developer_btn, ]) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index 2a4ae4459..15be56d36 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -242,7 +242,8 @@ class BranchSelectPage(NavScroller): class TargetBranchButton(BigButton): def __init__(self, check_update_btn: CheckUpdateButton): - super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "") + super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "", + description="Select the software branch to download on the next update check.") self._check_update_btn = check_update_btn self.set_click_callback(self._on_click) self.set_visible(not ui_state.params.get_bool("IsTestedBranch")) @@ -276,7 +277,9 @@ class SoftwareLayoutMici(NavScroller): uninstall_openpilot_btn = EngagedConfirmationButton("uninstall sunnypilot", "uninstall", gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64), - uninstall_openpilot_callback, exit_on_confirm=False) + uninstall_openpilot_callback, exit_on_confirm=False, + description="Remove openpilot from this device.", + description_icon=gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64)) check_update_btn = CheckUpdateButton() self._scroller.add_widgets([ diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index 2dba124df..78f6085e8 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -41,15 +41,34 @@ class TogglesLayoutMici(NavScroller): def __init__(self): super().__init__() - self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"]) - self._experimental_btn = BigToggle("experimental mode", initial_state=ui_state.params.get_bool("ExperimentalMode"), - toggle_callback=self._on_experimental_mode) + self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"], + description="Standard is recommended.\n" + + "Aggressive follows closer, with firmer gas and braking.\n" + + "Relaxed leaves more space.\n" + + "Use the steering wheel distance button on supported cars.") + self._experimental_btn = BigToggle("experimental mode", description_icon=gui_app.texture("icons_mici/experimental_mode.png", 64, 64), + initial_state=ui_state.params.get_bool("ExperimentalMode"), toggle_callback=self._on_experimental_mode, + description="Let the driving model control gas and brakes.\n" + + "Includes stopping for red lights and stop signs.\n" + + "Set speed is a maximum, not a target.\n" + + "These are alpha features. Expect mistakes.\n" + + "The path colors show acceleration and braking.") is_metric_toggle = BigParamControl("use metric units", "IsMetric") - ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled") - always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM") - record_front = BigParamControl("record & upload cabin camera", "RecordFront", toggle_callback=restart_needed_callback) - record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback) - enable_openpilot = BigParamControl("enable sunnypilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback) + ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled", + description="Warn when you drift across a detected lane line.\n" + + "Only above 31 mph (50 km/h), with no turn signal.") + always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM", description="Monitor the driver even when sunnypilot is not engaged.") + record_front = BigParamControl("record & upload cabin camera", "RecordFront", + description_icon=gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64), + toggle_callback=restart_needed_callback, description="Upload cabin camera data to help improve driver monitoring.") + record_mic = BigParamControl("record & upload mic audio", "RecordAudio", description_icon=gui_app.texture("icons_mici/microphone.png", 64, 64), + toggle_callback=restart_needed_callback, + description="Record microphone audio while driving.\n" + + "Audio is included in dashcam videos in sunnylink.") + enable_openpilot = BigParamControl("enable sunnypilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback, + description="Enable to use sunnypilot driver assistance.\n" + + "Disable to use your car's stock driver assistance.") + self._scroller.add_widgets([ self._personality_toggle, diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index 59a3f9519..4f395ae9b 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -29,9 +29,41 @@ class ScrollState(Enum): POST_SCROLL = 2 -class BigCircleButton(Widget): - def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0)): +class BaseButton(Widget): + def __init__(self, description: str, title: str, icon: Union[rl.Texture, None] = None): super().__init__() + self._shake_start: float | None = None + if description: + # Dialogs also use buttons; import lazily to avoid a circular import. + from openpilot.selfdrive.ui.mici.widgets.dialog import SettingDescriptionDialog + self.set_long_press_callback(lambda: gui_app.push_widget(SettingDescriptionDialog(title, description, icon))) + else: + self.set_long_press_callback(self.trigger_shake) + + def trigger_shake(self): + self._shake_start = rl.get_time() + + @property + def _shake_offset(self) -> float: + SHAKE_DURATION = 0.5 + SHAKE_AMPLITUDE = 24.0 + SHAKE_FREQUENCY = 32.0 + if self._shake_start is None: + return 0.0 + t = rl.get_time() - self._shake_start + if t > SHAKE_DURATION: + return 0.0 + decay = 1.0 - t / SHAKE_DURATION + return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY) + + def set_position(self, x: float, y: float) -> None: + super().set_position(x + self._shake_offset, y) + +class BigCircleButton(BaseButton): + def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0), + *, description: str = "", + description_icon: Union[rl.Texture, None] = None, title: str = ""): + super().__init__(description, title, description_icon or icon) self._red = red self._icon_offset = icon_offset @@ -73,8 +105,9 @@ class BigCircleButton(Widget): class BigCircleToggle(BigCircleButton): - def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0)): - super().__init__(icon, False, icon_offset=icon_offset) + def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0), + *, description: str = "", description_icon: Union[rl.Texture, None] = None, title: str = ""): + super().__init__(icon, False, icon_offset=icon_offset, description=description, description_icon=description_icon, title=title) self._toggle_callback = toggle_callback # State @@ -103,14 +136,16 @@ class BigCircleToggle(BigCircleButton): 0, 1.0, rl.WHITE) -class BigButton(Widget): +class BigButton(BaseButton): LABEL_HORIZONTAL_PADDING = 40 LABEL_VERTICAL_PADDING = 23 # visually matches 30 in figma """A lightweight stand-in for the Qt BigButton, drawn & updated each frame.""" - def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False): - super().__init__() + def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False, + *, description: str = "", + description_icon: Union[rl.Texture, None] = None): + super().__init__(description, text, description_icon or icon or None) self.set_rect(rl.Rectangle(0, 0, 402, 180)) self.text = text self.value = value @@ -119,7 +154,6 @@ class BigButton(Widget): self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) self._click_delay = 0.075 - self._shake_start: float | None = None self._grow_animation_until: float | None = None self._rotate_icon_t: float | None = None @@ -187,28 +221,9 @@ class BigButton(Widget): def get_text(self): return self.text - def trigger_shake(self): - self._shake_start = rl.get_time() - def trigger_grow_animation(self, duration: float = 0.65): self._grow_animation_until = rl.get_time() + duration - @property - def _shake_offset(self) -> float: - SHAKE_DURATION = 0.5 - SHAKE_AMPLITUDE = 24.0 - SHAKE_FREQUENCY = 32.0 - if self._shake_start is None: - return 0.0 - t = rl.get_time() - self._shake_start - if t > SHAKE_DURATION: - return 0.0 - decay = 1.0 - t / SHAKE_DURATION - return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY) - - def set_position(self, x: float, y: float) -> None: - super().set_position(x + self._shake_offset, y) - def _handle_background(self) -> tuple[rl.Texture, float, float, float]: if self._grow_animation_until is not None: if rl.get_time() >= self._grow_animation_until: @@ -272,8 +287,10 @@ class BigButton(Widget): class BigToggle(BigButton): - def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None): - super().__init__(text, value, "") + def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None, + *, description: str = "", + description_icon: Union[rl.Texture, None] = None): + super().__init__(text, value, "", description=description, description_icon=description_icon) self._checked = initial_state self._toggle_callback = toggle_callback @@ -308,8 +325,8 @@ class BigToggle(BigButton): class BigMultiToggle(BigToggle): def __init__(self, text: str, options: list[str], toggle_callback: Callable | None = None, - select_callback: Callable | None = None): - super().__init__(text, "", toggle_callback=toggle_callback) + select_callback: Callable | None = None, *, description: str = "", description_icon: Union[rl.Texture, None] = None): + super().__init__(text, "", toggle_callback=toggle_callback, description=description, description_icon=description_icon) assert len(options) > 0 self._options = options self._select_callback = select_callback @@ -374,9 +391,9 @@ class GreyBigButton(BigButton): class BigMultiParamToggle(BigMultiToggle): def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable | None = None, - select_callback: Callable | None = None): + select_callback: Callable | None = None, *, description: str = "", description_icon: Union[rl.Texture, None] = None): assert Params is not None - super().__init__(text, options, toggle_callback, select_callback) + super().__init__(text, options, toggle_callback, select_callback, description=description, description_icon=description_icon) self._param = param self._params = Params() @@ -392,9 +409,10 @@ class BigMultiParamToggle(BigMultiToggle): class BigParamControl(BigToggle): - def __init__(self, text: str, param: str, toggle_callback: Callable | None = None): + def __init__(self, text: str, param: str, toggle_callback: Callable | None = None, *, description: str = "", + description_icon: Union[rl.Texture, None] = None): assert Params is not None - super().__init__(text, "", toggle_callback=toggle_callback) + super().__init__(text, "", toggle_callback=toggle_callback, description=description, description_icon=description_icon) self.param = param self.params = Params() self.set_checked(self.params.get_bool(self.param, False)) @@ -410,9 +428,9 @@ class BigParamControl(BigToggle): # TODO: param control base class class BigCircleParamControl(BigCircleToggle): def __init__(self, icon: rl.Texture, param: str, toggle_callback: Callable | None = None, - icon_offset: tuple[int, int] = (0, 0)): + icon_offset: tuple[int, int] = (0, 0), *, description: str = "", description_icon: Union[rl.Texture, None] = None, title: str = ""): assert Params is not None - super().__init__(icon, toggle_callback, icon_offset=icon_offset) + super().__init__(icon, toggle_callback, icon_offset=icon_offset, description=description, description_icon=description_icon, title=title) self._param = param self.params = Params() self.set_checked(self.params.get_bool(self._param, False)) diff --git a/openpilot/selfdrive/ui/mici/widgets/dialog.py b/openpilot/selfdrive/ui/mici/widgets/dialog.py index 77dfa3cb9..d5da6178f 100644 --- a/openpilot/selfdrive/ui/mici/widgets/dialog.py +++ b/openpilot/selfdrive/ui/mici/widgets/dialog.py @@ -1,9 +1,11 @@ import abc import math +import re import pyray as rl from typing import Union from collections.abc import Callable from openpilot.system.ui.widgets.nav_widget import NavWidget +from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.mici_keyboard import MiciKeyboard from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -37,6 +39,31 @@ class BigDialog(BigDialogBase): )) +class SettingDescriptionDialog(NavScroller): + def __init__(self, title: str, description: str, icon: Union[rl.Texture, None] = None): + super().__init__() + cards = [GreyBigButton(title, "scroll for details", icon or gui_app.texture("icons_mici/setup/green_info.png", 64, 64))] + # Explicit lines are authored cards; otherwise prefer sentence boundaries. + paragraphs = description.splitlines() if "\n" in description else re.split(r"(?<=[.!?])\s+", description.strip()) + # Measure each card so longer text still fits with the actual font and padding. + for sentence in paragraphs: + card = GreyBigButton("", "") + words: list[str] = [] + for word in sentence.split(): + card.set_value(" ".join([*words, word])) + height = card._sub_label.get_content_height(card._subtitle_width_hint()) + if words and height > card.rect.height - 2 * card.LABEL_VERTICAL_PADDING: + card.set_value(" ".join(words)) + cards.append(card) + card = GreyBigButton("", "") + words = [] + words.append(word) + if words: + card.set_value(" ".join(words)) + cards.append(card) + self._scroller.add_widgets(cards) + + class BigConfirmationDialog(BigDialogBase): def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None], exit_on_confirm: bool = True, red: bool = False): @@ -76,14 +103,15 @@ class BigInputDialog(BigDialogBase): default_text: str = "", minimum_length: int = 1, confirm_callback: Callable[[str], None] | None = None, - auto_return_to_letters: str = ""): + auto_return_to_letters: str = "", + text_validator: Callable[[str], bool] | None = None): super().__init__() self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)), font_weight=FontWeight.MEDIUM) self._keyboard = MiciKeyboard(auto_return_to_letters=auto_return_to_letters) self._keyboard.set_text(default_text) self._keyboard.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget - self._minimum_length = minimum_length + self._text_valid = lambda text: len(text) >= minimum_length and (text_validator is None or text_validator(text)) self._backspace_held_time: float | None = None @@ -100,7 +128,8 @@ class BigInputDialog(BigDialogBase): def confirm_callback_wrapper(): text = self._keyboard.text() - self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None) + if self._text_valid(text): + self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None) self._confirm_callback = confirm_callback_wrapper def _update_state(self): @@ -185,7 +214,7 @@ class BigInputDialog(BigDialogBase): self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height) # draw enter button - self._enter_img_alpha.update(255 if len(text) >= self._minimum_length else 0) + self._enter_img_alpha.update(255 if self._text_valid(text) else 0) color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x)) rl.draw_texture_ex(self._enter_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y), 0.0, 1.0, color) color = rl.Color(255, 255, 255, 255 - int(self._enter_img_alpha.x)) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py index aab935d4b..0c3ac340e 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/vehicle/platform_selector.py @@ -92,7 +92,7 @@ class PlatformSelector(Button): def _on_platform_selected(self, dialog, res): if res == DialogResult.CONFIRM and dialog.selection_ref: - offroad_msg = tr("This setting will take effect immediately.") if ui_state.is_offroad else \ + offroad_msg = tr("This setting will take effect immediately.") if ui_state.is_offroad() else \ tr("This setting will take effect once the device enters offroad state.") callback = partial(self._confirm_platform, dialog.selection_ref) diff --git a/openpilot/selfdrive/ui/translations/auto_translate.sh b/openpilot/selfdrive/ui/translations/auto_translate.sh index 7238426c7..858e60eb2 100755 --- a/openpilot/selfdrive/ui/translations/auto_translate.sh +++ b/openpilot/selfdrive/ui/translations/auto_translate.sh @@ -4,7 +4,7 @@ set -euo pipefail DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd)" ROOT="$DIR/../../../" -cd $DIR +cd "$DIR" ./update_translations.py command -v codex >/dev/null || { diff --git a/openpilot/sunnypilot/mapd/mapd_installer.py b/openpilot/sunnypilot/mapd/mapd_installer.py index ec3cf94e7..b4789e8c8 100755 --- a/openpilot/sunnypilot/mapd/mapd_installer.py +++ b/openpilot/sunnypilot/mapd/mapd_installer.py @@ -20,7 +20,6 @@ from openpilot.common.hardware.hw import Paths from openpilot.common.spinner import Spinner from openpilot.common.version import is_prebuilt from openpilot.sunnypilot.mapd import MAPD_PATH, MAPD_BIN_DIR -import openpilot.system.sentry as sentry VERSION = "v1.12.0" URL = f"https://github.com/pfeiferj/openpilot-mapd/releases/download/{VERSION}/mapd" @@ -136,9 +135,7 @@ class MapdInstallManager: f"Boot will continue in {5 - i}s...") time.sleep(1) - sentry.init(sentry.SentryProject.SELFDRIVE) traceback.print_exc() - sentry.capture_exception() if __name__ == "__main__": diff --git a/openpilot/sunnypilot/modeld_v2/compile_modeld.py b/openpilot/sunnypilot/modeld_v2/compile_modeld.py index 1e8876992..2b7256aeb 100755 --- a/openpilot/sunnypilot/modeld_v2/compile_modeld.py +++ b/openpilot/sunnypilot/modeld_v2/compile_modeld.py @@ -7,41 +7,112 @@ See the LICENSE.md file in the root directory for more details. """ import argparse +import atexit +import codecs +import gc import math +import multiprocessing as mp import os +import pickle +import shutil import tempfile import time from functools import partial -from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob +from typing import Any + import numpy as np -os.environ['GMMU'] = '0' -def _patch_tinygrad_fetch_fw(): - import hashlib - import pathlib - import zstandard - from tinygrad import helpers - _orig_fetch_fw = helpers.fetch_fw - def fetch_fw(path, name, sha256): - p = pathlib.Path(f"/lib/firmware/{path}/{name}.zst") - if p.is_file(): - blob = zstandard.ZstdDecompressor().stream_reader(p.read_bytes()).read() - if hashlib.sha256(blob).hexdigest() == sha256: - return blob - return _orig_fetch_fw(path, name, sha256) - helpers.fetch_fw = fetch_fw -_patch_tinygrad_fetch_fw() +from openpilot.common.file_chunker import chunk_file, get_chunk_targets, open_file_chunked +from openpilot.sunnypilot.modeld_v2.helpers import dump_oob, load_oob +import openpilot.sunnypilot.modeld_v2.stock_dependencies as stock +from openpilot.system.camerad.cameras.nv12_info import get_nv12_info -import openpilot.selfdrive.modeld.compile_modeld as stock from tinygrad import dtypes from tinygrad.device import Device from tinygrad.engine.jit import TinyJit +from tinygrad.helpers import Context +from tinygrad.nn.onnx import OnnxRunner, OnnxPBParser from tinygrad.tensor import Tensor +os.environ['GMMU'] = '0' + +def _patch_tinygrad_fetch_fw(): + try: + import hashlib + import pathlib + import zstandard + from tinygrad import helpers + _orig_fetch_fw = helpers.fetch_fw + def fetch_fw(path, name, sha256): + p = pathlib.Path(f"/lib/firmware/{path}/{name}.zst") + if p.is_file(): + blob = zstandard.ZstdDecompressor().stream_reader(p.read_bytes()).read() + if hashlib.sha256(blob).hexdigest() == sha256: + return blob + return _orig_fetch_fw(path, name, sha256) + helpers.fetch_fw = fetch_fw + except ImportError: + pass +_patch_tinygrad_fetch_fw() + +def _patch_ops_serialization(): + try: + from tinygrad.uop.ops import Ops + def __reduce_ex__(self, proto): + return (getattr, (self.__class__, self.name)) + Ops.__reduce_ex__ = __reduce_ex__ + except ImportError: + pass +_patch_ops_serialization() + MODEL_TYPES = ('vision_policy', 'supercombo', 'vision_multi_policy') WARP_INPUTS = ['tfm', 'big_tfm'] POLICY_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs'] -nv12_copy_size = stock.nv12_copy_size + +class MetadataOnnxPBParser(OnnxPBParser): + def _parse_ModelProto(self) -> dict: + obj: dict[str, Any] = {"graph": {"input": [], "output": []}, "metadata_props": []} + for fid, wire_type in self._parse_message(self.reader.len): + match fid: + case 7: + obj["graph"] = self._parse_GraphProto() + case 14: + obj["metadata_props"].append(self._parse_StringStringEntryProto()) + case _: + self.reader.skip_field(wire_type) + return obj + +def get_name_and_shape(value_info: dict[str, Any]) -> tuple[str, tuple[int, ...]]: + shape = tuple(int(dim) if isinstance(dim, int) else 0 for dim in value_info["parsed_type"].shape) + name = value_info["name"] + return name, shape + +def get_metadata_value_by_name(model: dict[str, Any], name: str) -> str | Any: + for prop in model["metadata_props"]: + if prop["key"] == name: + return prop["value"] + return None + +def make_metadata_dict(model_path): + with Context(DEV='CPU'): + model = MetadataOnnxPBParser(model_path).parse() + output_slices_raw = get_metadata_value_by_name(model, 'output_slices') + assert output_slices_raw is not None, 'output_slices not found in metadata' + + output_slices = pickle.loads(codecs.decode(output_slices_raw.encode(), "base64")) + if 'hidden_state' not in output_slices: + output_slices['hidden_state'] = slice(0, 512) + + meta_dict = { + 'model_checkpoint': get_metadata_value_by_name(model, 'model_checkpoint'), + 'output_slices': output_slices, + 'input_shapes': dict(get_name_and_shape(x) for x in model["graph"]["input"]), + 'output_shapes': dict(get_name_and_shape(x) for x in model["graph"]["output"]), + } + del model + gc.collect() + return meta_dict + def _detect_desire_key(shapes: dict) -> str | None: return next((key for key in shapes if key.startswith('desire')), None) @@ -114,7 +185,7 @@ def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = D 'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), 'desire_q': Tensor(np.zeros((frame_skip * desire_shape[1], desire_shape[0], desire_shape[2]), dtype=np.float32), device=device).contiguous().realize(), - 'packed_npy_inputs': Tensor(packed_npy_inputs, device='NPY').realize(), + 'packed_npy_inputs': Tensor(packed_npy_inputs, device=device if os.getenv('CHESTNUT') else 'NPY').realize(), } if features_buffer: @@ -123,7 +194,10 @@ def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = D queues['feat_q'] = Tensor(np.zeros((feat_q_len, features_buffer[0], feat_dim), dtype=np.float32), device=device).contiguous().realize() - queues.update({key: Tensor(value, device='NPY').realize() for key, value in npy_arrays.items() if key in ('tfm', 'big_tfm')}) + for key in ('tfm', 'big_tfm'): + if key not in npy_arrays: + npy_arrays[key] = np.eye(3, dtype=np.float32) + queues[key] = Tensor(npy_arrays[key], device='NPY').realize() return queues, npy_arrays @@ -164,8 +238,7 @@ def make_run_policy(vision_runner, policy_runners: list, features_slice: slice, is_supercombo = vision_runner is None npy_shapes, npy_sizes = get_policy_npy_shapes(input_shapes, is_supercombo=is_supercombo) - def run_policy(warped, img_q, big_img_q, feat_q, packed_npy_inputs, **kwargs): - desire_q = kwargs['desire_q'] + def run_policy(warped, img_q, big_img_q, feat_q, desire_q=None, packed_npy_inputs=None, **kwargs): packed_npy_inputs_dev = packed_npy_inputs.to(Device.DEFAULT) warped_dev = warped.to(Device.DEFAULT) Tensor.realize(packed_npy_inputs_dev, warped_dev) @@ -209,51 +282,74 @@ def make_run_policy(vision_runner, policy_runners: list, features_slice: slice, return run_policy -def compile_jit(jit, input_keys, make_queues, make_random_inputs=None, benchmark_runs: int = 1): - SEED = 42 - def random_inputs_run(fn, seed, n_runs, test_val=None, test_buffers=None, expect_match=True): - queues_res = make_queues(Device.DEFAULT) - input_queues, npy = queues_res[0], queues_res[1] - frame_views = queues_res[2] if len(queues_res) > 2 else {} - rng = np.random.default_rng(seed) - Tensor.manual_seed(seed) +def compile_jit(jit_or_fn, input_keys_or_make_inputs, make_queues=None, make_random_inputs=None, + benchmark_runs: int = 1, clear_refs=None, return_loaded: bool = True): + if callable(input_keys_or_make_inputs) and make_queues is None: + fn = jit_or_fn + make_inputs = input_keys_or_make_inputs + jit = TinyJit(fn, prune=True) - for i in range(n_runs): - for v in npy.values(): - v[:] = rng.standard_normal(v.shape).astype(v.dtype) - for v in frame_views.values(): - v[:] = rng.integers(0, 256, size=v.shape, dtype=np.uint8) - Device.default.synchronize() - random_inputs = make_random_inputs(rng=rng) if make_random_inputs is not None else {} - st = time.perf_counter() - outs = fn(**{k: input_queues[k] for k in input_keys if k in input_queues}, **random_inputs) - mt = time.perf_counter() - Device.default.synchronize() - et = time.perf_counter() - print(f" [{i+1}/{n_runs}] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") + def run_eval(f, seed, count): + args, kwargs = make_inputs(seed) + result = None + for i in range(count): + Device.default.synchronize() + st = time.perf_counter() + f(*args, **kwargs) + Device.default.synchronize() + print(f" [{i+1}/{count}] {(time.perf_counter()-st)*1e3:.2f} ms") + if i == 0: + result = [t.numpy().copy() for t in kwargs['output_buffers'].values()] + return result + else: + jit = jit_or_fn + input_keys = input_keys_or_make_inputs + assert make_queues is not None + queues_maker = make_queues - if i == 0: - val = [np.copy(v.numpy()) for v in (outs if isinstance(outs, tuple) else [outs])] if outs is not None else [] - buffers = [np.copy(v.numpy().copy()) for v in input_queues.values()] + def run_eval(f, seed, count): + queues_res = queues_maker(Device.DEFAULT) + input_queues, npy = queues_res[0], queues_res[1] + frame_views = queues_res[2] if len(queues_res) > 2 else {} + rng = np.random.default_rng(seed) + Tensor.manual_seed(seed) - if test_val is not None: - match = all(np.array_equal(a, b) for a, b in zip(val, test_val, strict=True)) - assert match == expect_match, f"outputs {'differ from' if expect_match else 'match'} baseline (seed={seed})" - if test_buffers is not None: - match = all(np.array_equal(a, b) for a, b in zip(buffers, test_buffers, strict=True)) - assert match == expect_match, f"buffers {'differ from' if expect_match else 'match'} baseline (seed={seed})" - return val, buffers + for i in range(count): + for v in npy.values(): + v[:] = rng.standard_normal(v.shape).astype(v.dtype) + for v in frame_views.values(): + v[:] = rng.integers(0, 256, size=v.shape, dtype=np.uint8) + Device.default.synchronize() + random_inputs = make_random_inputs() if make_random_inputs is not None else {} + st = time.perf_counter() + outs = f(**{k: input_queues[k] for k in input_keys if k in input_queues}, **random_inputs) + mt = time.perf_counter() + Device.default.synchronize() + et = time.perf_counter() + print(f" [{i+1}/{count}] enqueue {(mt-st)*1e3:6.2f} ms -- total {(et-st)*1e3:6.2f} ms") + + if i == 0: + val = [np.copy(v.numpy()) for v in (outs if isinstance(outs, tuple) else [outs])] if outs is not None else [] + return val print('capture + replay') - test_val, test_buffers = random_inputs_run(jit, SEED, 3) + gc.collect() + test_val = run_eval(jit, 42, 3) print(f'pickle round trip ({benchmark_runs} runs per seed)') with tempfile.TemporaryFile(dir=".") as f: dump_oob(jit, f) + if clear_refs: + clear_refs() + gc.collect() f.seek(0) loaded_jit = load_oob(f) - random_inputs_run(loaded_jit, SEED, benchmark_runs, test_val, test_buffers, expect_match=True) - random_inputs_run(loaded_jit, SEED+1, benchmark_runs, test_val, test_buffers, expect_match=False) - return jit + + for seed in (42, 43): + reference = test_val if seed == 42 else run_eval(jit_or_fn, seed, 1) + actual = run_eval(loaded_jit, seed, benchmark_runs) + for ref, val in zip(reference, actual, strict=True): + np.testing.assert_array_equal(ref, val) + return loaded_jit if return_loaded else jit def _parse_size(size_str: str) -> tuple[int, int]: @@ -264,9 +360,6 @@ def _parse_size(size_str: str) -> tuple[int, int]: def read_file_chunked_to_disk(path): if not path: return None - import atexit - import shutil - from openpilot.common.file_chunker import open_file_chunked tmp_path = f'{path}.unchunked' with open(tmp_path, 'wb') as f, open_file_chunked(path) as src: shutil.copyfileobj(src, f) @@ -283,6 +376,23 @@ def _load_policy_runners(args: argparse.Namespace) -> tuple[list, list]: return runners, keys +def _compile_warp_resolution_worker(cam_w, cam_h, model_w, model_h, benchmark_runs, result_path): + nv12 = stock.NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) + WARP_DEV = os.getenv('WARP_DEV', Device.DEFAULT) + make_random_warp_inputs = partial(make_random_images, keys=['frame', 'big_frame'], shape=nv12.size, device=WARP_DEV) + warp = TinyJit(stock.make_warp(nv12, model_w, model_h), prune=True) + + def cleanup_warp(): + nonlocal warp + warp = None + compiled_jit = compile_jit(warp, WARP_INPUTS, make_warp_queues, make_random_inputs=make_random_warp_inputs, + benchmark_runs=benchmark_runs, clear_refs=cleanup_warp) + + result_data = (compiled_jit, Device.DEFAULT) + with open(result_path, "wb") as f: + dump_oob(result_data, f) + + if __name__ == "__main__": if 'USB' in os.getenv('DEV', '') or os.getenv('CHESTNUT'): from openpilot.system.hardware.chestnut.flash import link_up @@ -293,11 +403,6 @@ if __name__ == "__main__": else: raise RuntimeError("Chestnut not ready, skipping big model build") - from openpilot.common.file_chunker import chunk_file, get_chunk_targets - from openpilot.selfdrive.modeld.get_model_metadata import make_metadata_dict - from openpilot.system.camerad.cameras.nv12_info import get_nv12_info - from tinygrad.nn.onnx import OnnxRunner - parser = argparse.ArgumentParser(description="Compile combined JIT pkl for sunnypilot modeld_v2") parser.add_argument('--model-type', choices=MODEL_TYPES, required=True) parser.add_argument('--model-size', type=_parse_size, required=True, help='model input WxH') @@ -322,30 +427,45 @@ if __name__ == "__main__": args.on_policy_onnx = read_file_chunked_to_disk(args.on_policy_onnx) args.supercombo_onnx = read_file_chunked_to_disk(args.supercombo_onnx) + is_unified_supercombo = False if args.model_type == 'supercombo': assert args.supercombo_onnx model_metadata = make_metadata_dict(args.supercombo_onnx) + derived_frame_skip = args.frame_skip or derive_frame_skip({}, model_metadata['input_shapes']) + if derived_frame_skip != 1 and os.getenv('CHESTNUT'): + is_unified_supercombo = True + + if is_unified_supercombo: output_data['metadata'] = {'model': model_metadata, **model_metadata} output_data['input_devices'] = {'model': Device.DEFAULT} - output_data['run_model'] = {} - derived_frame_skip = args.frame_skip or derive_frame_skip({}, model_metadata['input_shapes']) model_runner = OnnxRunner(args.supercombo_onnx) - run_policy = stock.make_run_policy(model_runner, model_metadata, derived_frame_skip) - for cam_w, cam_h in args.camera_resolutions: - print(f"Compiling unified run_model JIT for {cam_w}x{cam_h}...") + features_slice = model_metadata['output_slices']['hidden_state'] + run_policy = make_run_policy(None, [model_runner], features_slice, derived_frame_skip, model_metadata['input_shapes']) + for cam_w, cam_h in set(args.camera_resolutions): + print(f"Compiling unified model JIT for {cam_w}x{cam_h}") nv12 = stock.NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) frame_copy_size = stock.nv12_copy_size(nv12.stride, nv12.y_height, nv12.uv_height) make_model_queues = partial(stock.make_input_queues, model_metadata['input_shapes'], derived_frame_skip, frame_copy_size=frame_copy_size) warp = stock.make_warp(nv12, model_w, model_h) run_model_jit = TinyJit(stock.make_run_model(warp, run_policy, model_metadata, frame_copy_size), prune=True) - output_data['run_model'][(cam_w, cam_h)] = compile_jit(run_model_jit, stock.MODELD_INPUTS, make_model_queues, benchmark_runs=args.benchmark_runs) + compiled_jit = compile_jit(run_model_jit, stock.MODELD_INPUTS, make_model_queues, benchmark_runs=args.benchmark_runs, return_loaded=False) + output_data[(cam_w, cam_h)] = compiled_jit + gc.collect() + del model_runner, run_policy + gc.collect() + output_data['metadata']['warp_dev'] = Device.DEFAULT else: vision_runner = OnnxRunner(args.vision_onnx) if args.vision_onnx else None + if args.model_type == 'vision_policy': assert vision_runner and args.policy_onnx policy_runners = [OnnxRunner(args.policy_onnx)] output_data['metadata'] = {'vision': make_metadata_dict(args.vision_onnx), 'policy': make_metadata_dict(args.policy_onnx)} + elif args.model_type == 'supercombo': + assert args.supercombo_onnx + policy_runners = [OnnxRunner(args.supercombo_onnx)] + output_data['metadata'] = {'model': make_metadata_dict(args.supercombo_onnx)} elif args.model_type == 'vision_multi_policy': assert vision_runner policy_runners, policy_names = _load_policy_runners(args) @@ -359,26 +479,50 @@ if __name__ == "__main__": vision_meta = output_data['metadata'].get('vision', {}) derived_frame_skip = args.frame_skip or derive_frame_skip(vision_meta.get('input_shapes', {}), first_policy_meta.get('input_shapes', {})) - all_shapes = {key: value for meta in output_data['metadata'].values() for key, value in meta['input_shapes'].items()} - feat_meta = output_data['metadata'].get('vision') or output_data['metadata'].get('policy') - assert feat_meta is not None + all_shapes = { + key: value for meta in output_data['metadata'].values() + if isinstance(meta, dict) and 'input_shapes' in meta for key, value in meta['input_shapes'].items() + } + feat_meta = output_data['metadata'].get('vision') or output_data['metadata'].get('model') or output_data['metadata'].get('policy') + assert isinstance(feat_meta, dict) features_slice = feat_meta['output_slices']['hidden_state'] + is_supercombo = vision_runner is None print(f"Compiling run_policy JIT (model_size={model_w}x{model_h}, frame_skip={derived_frame_skip})...") run_policy_func = make_run_policy(vision_runner, policy_runners, features_slice, derived_frame_skip, all_shapes) run_policy_jit = TinyJit(run_policy_func, prune=True) - make_policy_queues = partial(generate_queues_and_npy, all_shapes, derived_frame_skip, is_supercombo=False) - make_random_model_inputs = partial(make_random_images, keys=['warped'], shape=(2, 6, model_h // 2, model_w // 2), device=Device.DEFAULT) - output_data['run_policy'] = compile_jit(run_policy_jit, POLICY_INPUTS, make_policy_queues, make_random_inputs=make_random_model_inputs) + make_policy_queues = partial(generate_queues_and_npy, all_shapes, derived_frame_skip, is_supercombo=is_supercombo) + WARP_DEV = os.getenv('WARP_DEV', Device.DEFAULT) + make_random_model_inputs = partial(make_random_images, keys=['warped'], shape=(2, 6, model_h // 2, model_w // 2), device=WARP_DEV) - for cam_w, cam_h in args.camera_resolutions: - print(f"Compiling warp JIT for {cam_w}x{cam_h}...") - nv12 = stock.NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h)) - frame_copy_size = stock.nv12_copy_size(nv12.stride, nv12.y_height, nv12.uv_height) - make_random_warp_inputs = partial(make_random_images, keys=['frame', 'big_frame'], shape=frame_copy_size, device=Device.DEFAULT) - warp = TinyJit(stock.make_warp(nv12, model_w, model_h), prune=True) - output_data[(cam_w, cam_h)] = compile_jit(warp, WARP_INPUTS, make_warp_queues, make_random_inputs=make_random_warp_inputs) + def cleanup_policy(): + global run_policy_jit, run_policy_func, vision_runner, policy_runners + run_policy_jit, run_policy_func, vision_runner, policy_runners = None, None, None, None + output_data['run_policy'] = compile_jit(run_policy_jit, POLICY_INPUTS, make_policy_queues, + make_random_inputs=make_random_model_inputs, benchmark_runs=args.benchmark_runs, clear_refs=cleanup_policy) + + ctx = mp.get_context('spawn') + output_data['input_devices'] = {} + tmp_files = [] + for cam_w, cam_h in set(args.camera_resolutions): + print(f"Compiling warp JIT for {cam_w}x{cam_h}") + tmp_res = tempfile.NamedTemporaryFile(delete=False, suffix=".pkl", dir=".") + tmp_res.close() + + p = ctx.Process(target=_compile_warp_resolution_worker, args=(cam_w, cam_h, model_w, model_h, args.benchmark_runs, tmp_res.name)) + p.start() + p.join() + if p.exitcode != 0: + raise RuntimeError(f"Warp compilation worker failed for {cam_w}x{cam_h}") + + tmp_files.append((cam_w, cam_h, tmp_res.name)) + for cam_w, cam_h, tmp_name in tmp_files: + with open(tmp_name, "rb") as f: + compiled_jit, dev_name = load_oob(f) + output_data[(cam_w, cam_h)] = compiled_jit + output_data['input_devices']['warp'] = dev_name + os.remove(tmp_name) output_data['metadata']['warp_dev'] = Device.DEFAULT with open(args.output, "wb") as file: diff --git a/openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py b/openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py new file mode 100644 index 000000000..a4bfcce31 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +import runpy +import os + +from openpilot.sunnypilot.modeld_v2.compile_modeld import _patch_ops_serialization + + +if __name__ == "__main__": + _patch_ops_serialization() + upstream_script = os.path.join(os.environ.get("TINYGRAD_PATH", "tinygrad_repo"), "examples/openpilot/compile_onnx.py") + runpy.run_path(upstream_script, run_name="__main__") diff --git a/openpilot/sunnypilot/modeld_v2/get_model_metadata.py b/openpilot/sunnypilot/modeld_v2/get_model_metadata.py index 838b1e9f4..d24f59267 100755 --- a/openpilot/sunnypilot/modeld_v2/get_model_metadata.py +++ b/openpilot/sunnypilot/modeld_v2/get_model_metadata.py @@ -1,13 +1,8 @@ -#!/usr/bin/env python3 -import sys -import pathlib +from typing import Any import codecs import pickle -from typing import Any - from tinygrad.nn.onnx import OnnxPBParser - class MetadataOnnxPBParser(OnnxPBParser): def _parse_ModelProto(self) -> dict: obj: dict[str, Any] = {"graph": {"input": [], "output": []}, "metadata_props": []} @@ -21,35 +16,33 @@ class MetadataOnnxPBParser(OnnxPBParser): self.reader.skip_field(wire_type) return obj +def _get_name_and_shape(value_info: dict[str, Any]) -> tuple[str, tuple[int, ...]]: + shape = tuple(int(dim) if isinstance(dim, int) else 1 for dim in value_info["parsed_type"].shape) + return value_info["name"], shape -def get_name_and_shape(value_info: dict[str, Any]) -> tuple[str, tuple[int, ...]]: - shape = tuple(int(dim) if isinstance(dim, int) else 0 for dim in value_info["parsed_type"].shape) - name = value_info["name"] - return name, shape - - -def get_metadata_value_by_name(model: dict[str, Any], name: str) -> str | Any: +def _get_metadata_value_by_name(model: dict[str, Any], name: str) -> str | None: for prop in model["metadata_props"]: if prop["key"] == name: return prop["value"] return None +def make_metadata_dict(onnx_path): + model = MetadataOnnxPBParser(onnx_path).parse() + output_slices_raw = _get_metadata_value_by_name(model, 'output_slices') -if __name__ == "__main__": - model_path = pathlib.Path(sys.argv[1]) - model = MetadataOnnxPBParser(model_path).parse() - output_slices = get_metadata_value_by_name(model, 'output_slices') - assert output_slices is not None, 'output_slices not found in metadata' + if output_slices_raw: + output_slices = pickle.loads(codecs.decode(output_slices_raw.encode(), "base64")) + else: + output_slices = {} - metadata = { - 'model_checkpoint': get_metadata_value_by_name(model, 'model_checkpoint'), - 'output_slices': pickle.loads(codecs.decode(output_slices.encode(), "base64")), - 'input_shapes': dict(get_name_and_shape(x) for x in model["graph"]["input"]), - 'output_shapes': dict(get_name_and_shape(x) for x in model["graph"]["output"]), + input_shapes = dict(_get_name_and_shape(x) for x in model["graph"]["input"]) + metadata = {prop["key"]: prop["value"] for prop in model["metadata_props"]} + + if 'hidden_state' not in output_slices: + output_slices['hidden_state'] = slice(0, 512) + + return { + 'input_shapes': input_shapes, + 'output_slices': output_slices, + 'metadata_props': metadata } - - metadata_path = model_path.parent / (model_path.stem + '_metadata.pkl') - with open(metadata_path, 'wb') as f: - pickle.dump(metadata, f) - - print(f'saved metadata to {metadata_path}') diff --git a/openpilot/sunnypilot/modeld_v2/model_adapters.py b/openpilot/sunnypilot/modeld_v2/model_adapters.py new file mode 100644 index 000000000..b24089518 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/model_adapters.py @@ -0,0 +1,224 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import codecs +import math +from pathlib import Path +import pickle +import numpy as np + +from openpilot.common.basedir import BASEDIR +from openpilot.sunnypilot.modeld_v2.compile_modeld import (POLICY_INPUTS, derive_frame_skip, + make_split_input_queues, make_supercombo_input_queues) +from openpilot.sunnypilot.modeld_v2.stock_dependencies import make_input_queues as stock_make_input_queues, nv12_copy_size +from openpilot.system.camerad.cameras.nv12_info import get_nv12_info +from tinygrad.device import Buffer +from tinygrad.dtype import DType, dtypes +from tinygrad.helpers import round_up +from tinygrad.tensor import Tensor +from tinygrad.uop.ops import UOp + + +def input_view(buffer: Buffer, shape: tuple[int, ...], dtype: DType, offset: int) -> Tensor: + view = buffer.view(math.prod(shape), dtype, offset).ensure_allocated() + return Tensor(UOp.from_buffer(view)).reshape(shape) + + +class BaseModelAdapter: + def __init__(self, jits, cam_w, cam_h, model_device, queue_device, warp_device, chestnut=False): + self.jits = jits + self.DEV = model_device + self.QUEUE_DEV = queue_device + self.WARP_DEV = warp_device + self.chestnut = chestnut + self.cam_w = cam_w + self.cam_h = cam_h + self._combined_model_type = 'supercombo' + self._policy_slices_list = [] + self._has_on_policy = False + self.policy_output_slices = {} + self._policy_keys = [] + self.full_frames = {} + self._blob_cache = {} + self.nv12_info = get_nv12_info(cam_w, cam_h) + self.is_native = False + + def _init_common(self): + self._desire_key = next((key for key in getattr(self, 'numpy_inputs', {}) if key.startswith('desire')), 'desire') + self._road_key = next((key for key in getattr(self, '_vision_input_names', []) if 'big' not in key), 'img') + self._wide_key = next((key for key in getattr(self, '_vision_input_names', []) if 'big' in key), 'big_img') + self.frame_buf_params = dict.fromkeys(getattr(self, '_vision_input_names', ['img', 'big_img']), self.nv12_info) + + def get_dummy_inputs(self): + dummy_size = getattr(self, 'warp_frame_size', self.frame_copy_size if self.is_native else self.frame_buf_params[self._road_key][3]) + dummy_frames = {k: np.zeros(dummy_size, dtype=np.uint8) for k in self._vision_input_names} + transforms = {k: np.eye(3, dtype=np.float32) for k in [self._road_key, self._wide_key] if k} + dummy_inputs = {k: np.zeros(v.shape, dtype=v.dtype) for k, v in self.numpy_inputs.items() if k not in ['tfm', 'big_tfm', 'prev_feat']} + return dummy_frames, transforms, dummy_inputs + + def _load_warp(self): + if (self.cam_w, self.cam_h) in self.jits: + self.warp_frame_size = self.nv12_info[3] + return self.jits[(self.cam_w, self.cam_h)] + + warp_dir = Path(BASEDIR) / "openpilot/sunnypilot/modeld_v2/models" + warp_name = f'{"big_" if self.chestnut else ""}driving_warp_{self.cam_w}x{self.cam_h}_tinygrad.pkl' + with open(warp_dir / warp_name, 'rb') as f: + warp_data = pickle.load(f) + run_warp = warp_data['run'] + if 'input_specs' in warp_data and 'input_frame' in warp_data['input_specs']: + self.warp_frame_size = warp_data['input_specs']['input_frame'][0][1] + else: + self.warp_frame_size = self.nv12_info[3] if self.chestnut else self.frame_copy_size + return run_warp + + +class LegacyModelAdapter(BaseModelAdapter): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + metadata = self.jits['metadata'] + self.frame_copy_size = nv12_copy_size(*self.nv12_info[:3]) + + if self.chestnut: + self.WARP_DEV = self.DEV + + if 'model' in metadata: + model_metadata = metadata.get('model', metadata) + self.input_shapes = model_metadata['input_shapes'] + self.vision_output_slices = model_metadata['output_slices'] + self._vision_input_names = [key for key in self.input_shapes if 'img' in key] + self.frame_skip = derive_frame_skip({}, self.input_shapes) + if self.chestnut: + self.input_queues, self.numpy_inputs, self.frame_slots = stock_make_input_queues( + self.input_shapes, self.frame_skip, device=self.QUEUE_DEV, frame_copy_size=self.frame_copy_size) + else: + self.input_queues, self.numpy_inputs = make_supercombo_input_queues(self.input_shapes, self.frame_skip, device=self.QUEUE_DEV) + self.run_policy = self.jits['run_policy'] if 'run_policy' in self.jits else self.jits[(self.cam_w, self.cam_h)] + else: + self.run_policy = self.jits['run_policy'] + vision_metadata = metadata['vision'] + policy_keys = [k for k in metadata if k not in ('vision', 'warp_dev')] + self._combined_model_type = 'split' if policy_keys == ['policy'] else 'multi_policy' + self.vision_output_slices = vision_metadata['output_slices'] + self._policy_keys = policy_keys + self._policy_slices_list = [metadata[k]['output_slices'] for k in policy_keys] + self.policy_output_slices = self._policy_slices_list[0] + self._has_on_policy = any('on' in k.lower() for k in policy_keys) + self._vision_input_names = [key for key in vision_metadata['input_shapes'] if 'img' in key] + first_policy_meta = metadata[policy_keys[0]] + self.frame_skip = derive_frame_skip(vision_metadata['input_shapes'], first_policy_meta['input_shapes']) + self.input_queues, self.numpy_inputs = make_split_input_queues(vision_metadata['input_shapes'], first_policy_meta['input_shapes'], + self.frame_skip, device=self.QUEUE_DEV) + + self.run_warp = self._load_warp() + self._init_common() + if not self.chestnut: + yuv_size = self.frame_buf_params[self._road_key][3] + frame_tensor = Tensor(np.zeros(yuv_size, dtype=np.uint8), device=self.WARP_DEV).contiguous().realize() + big_frame_tensor = Tensor(np.zeros(yuv_size, dtype=np.uint8), device=self.WARP_DEV).contiguous().realize() + self.run_warp(**{k: self.input_queues[k] for k in ('tfm', 'big_tfm')}, + frame=frame_tensor, big_frame=big_frame_tensor) + + def copy_frames(self, bufs): + if self.chestnut: + for key in self._vision_input_names: + if key in bufs: + data = bufs[key].data if hasattr(bufs[key], 'data') else bufs[key] + np.copyto(self.frame_slots[key], np.frombuffer(data, dtype=np.uint8, count=self.frame_copy_size)) + else: + for key in bufs.keys(): + data = bufs[key].data if hasattr(bufs[key], 'data') else bufs[key] + ptr = np.frombuffer(data, dtype=np.uint8).ctypes.data + yuv_size = self.frame_buf_params[key][3] + cache_key = (key, ptr) + if cache_key not in self._blob_cache: + self._blob_cache[cache_key] = Tensor.from_blob(ptr, (yuv_size,), dtype='uint8', device=self.WARP_DEV) + self.full_frames[key] = self._blob_cache[cache_key] + + def reset_warmup_buffers(self): + for v in self.numpy_inputs.values(): + v[:] = 0 + if not self.chestnut: + self.full_frames.clear() + self._blob_cache.clear() + + def run(self): + if self.chestnut: + return self.run_policy(**{k: self.input_queues[k] for k in POLICY_INPUTS if k in self.input_queues}) + + warped = self.run_warp(**{k: self.input_queues[k] for k in ('tfm', 'big_tfm')}, + frame=self.full_frames[self._road_key], big_frame=self.full_frames[self._wide_key]) + return self.run_policy(**{k: self.input_queues[k] for k in POLICY_INPUTS if k in self.input_queues}, warped=warped) + + +class NativeTinygradAdapter(BaseModelAdapter): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.is_native = True + self.input_specs = self.jits['input_specs'] + + self.model_device = self.input_specs['new_img'][2] + self.input_shapes = {name: (shape, np.dtype(dtype)) for name, (shape, dtype, _) in self.input_specs.items()} + self.state_pairs = {name: f'next_{name}' for name in self.input_shapes if f'next_{name}' in self.jits['metadata']['output_shapes']} + + stride, y_height, uv_height, _ = get_nv12_info(self.cam_w, self.cam_h) + self.frame_copy_size = stride * (y_height + uv_height) + + self.input_shapes_orig = self.jits['metadata']['input_shapes'] + self._vision_input_names = [k for k in self.input_shapes_orig if 'img' in k] + self.vision_output_slices = pickle.loads(codecs.decode(self.jits['metadata']['metadata']['output_slices'].encode(), 'base64')) + + self.run_warp = self._load_warp() + self._init_common() + self.run_model = self.jits['run'] + + warp_frame_size = getattr(self, 'warp_frame_size', self.nv12_info[3]) + self.input_queues = {name: Tensor(np.zeros(shape, dtype=dtype), device=self.model_device).realize() + for name, (shape, dtype) in self.input_shapes.items() if name in self.state_pairs} + shapes = {'tfm': (2, 3, 3)} | {name: shape for name, (shape, _) in self.input_shapes.items() + if name not in self.state_pairs and name != 'new_img'} + npy_size = sum(round_up(math.prod(shape) * 4, 128) for shape in shapes.values()) + self.packed_input = np.zeros(npy_size + 2 * warp_frame_size, dtype=np.uint8) + self.input_host = Tensor(self.packed_input, device='NPY')._buffer() + self.input_device = Tensor(self.packed_input, device=self.model_device)._buffer() + self.numpy_inputs = {} + offset = 0 + for name, shape in shapes.items(): + self.numpy_inputs[name] = np.ndarray(shape, dtype=np.float32, buffer=self.packed_input, offset=offset) + self.input_queues[name] = input_view(self.input_device, shape, dtypes.float32, offset) + offset += round_up(self.numpy_inputs[name].nbytes, 128) + self.frames = self.packed_input[npy_size:].reshape(2, warp_frame_size) + self.warp_inputs = {'input_frame': input_view(self.input_device, self.frames.shape, dtypes.uint8, npy_size), 'M_inv': self.input_queues.pop('tfm')} + + self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in self.jits['output_specs'].items()} + for name, next_name in self.state_pairs.items(): + state = self.input_queues[name] + self.outputs[next_name] = input_view(state._buffer(), state.shape, state.dtype, 0) + + def copy_frames(self, bufs): + for i, key in enumerate(self._vision_input_names): + if key in bufs: + data = bufs[key].data if hasattr(bufs[key], 'data') else bufs[key] + np.copyto(self.frames[i], np.frombuffer(data, dtype=np.uint8, count=self.warp_frame_size)) + + def reset_warmup_buffers(self) -> None: + self.packed_input[:] = 0 + for key in self.state_pairs: + self.input_queues[key].assign(0).realize() + + def run(self): + self.input_device.copy_from(self.input_host) + self.input_queues['new_img'] = self.run_warp(**self.warp_inputs) + self.run_model(output_buffers=self.outputs, **self.input_queues) + return self.outputs['outputs'] + + +def get_model_adapter(jits, cam_w, cam_h, model_device, queue_device, warp_device, chestnut=False): + if 'input_specs' in jits: + return NativeTinygradAdapter(jits, cam_w, cam_h, model_device, queue_device, warp_device, chestnut=chestnut) + else: + return LegacyModelAdapter(jits, cam_w, cam_h, model_device, queue_device, warp_device, chestnut=chestnut) diff --git a/openpilot/sunnypilot/modeld_v2/modeld.py b/openpilot/sunnypilot/modeld_v2/modeld.py index 5b5f718ae..39c8dfacf 100755 --- a/openpilot/sunnypilot/modeld_v2/modeld.py +++ b/openpilot/sunnypilot/modeld_v2/modeld.py @@ -13,7 +13,6 @@ import numpy as np import threading import time from setproctitle import setproctitle -from tinygrad.tensor import Tensor import openpilot.cereal.messaging as messaging from openpilot.common.hardware import COMMA_HARDWARE @@ -32,26 +31,17 @@ from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.realtime import config_realtime_process, DT_MDL from openpilot.common.transformations.camera import DEVICE_CAMERAS from openpilot.common.transformations.model import get_warp_matrix -from openpilot.system import sentry -from openpilot.system.camerad.cameras.nv12_info import get_nv12_info from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, smooth_value -from openpilot.selfdrive.modeld.modeld import ChestnutState - -from openpilot.selfdrive.modeld.compile_modeld import ( - MODELD_INPUTS, - make_input_queues as make_stock_input_queues, -) +from openpilot.selfdrive.modeld.modeld import ChestnutGpuState from openpilot.sunnypilot.modeld_v2.fill_model_msg import fill_model_msg, fill_pose_msg, PublishState, get_curvature_from_output from openpilot.sunnypilot.modeld_v2.parse_model_outputs import Parser from openpilot.sunnypilot.modeld_v2.constants import ModelConstants, Plan from openpilot.sunnypilot.modeld_v2.meta_helper import load_meta_constants from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper -from openpilot.sunnypilot.modeld_v2.compile_modeld import (derive_frame_skip, make_split_input_queues, - make_supercombo_input_queues, nv12_copy_size, - WARP_INPUTS, POLICY_INPUTS) from openpilot.sunnypilot.livedelay.helpers import get_lat_delay from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase +from openpilot.sunnypilot.modeld_v2.model_adapters import get_model_adapter from openpilot.sunnypilot.models.helpers import get_active_bundle from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController @@ -141,52 +131,19 @@ class ModelState(ModelStateBase): self.WARP_DEV = metadata.get('warp_dev', 'QCOM') if COMMA_HARDWARE else 'CPU' self.DEV = ('AMD' if self.chestnut else 'QCOM') if COMMA_HARDWARE else 'CPU' self.QUEUE_DEV = self.DEV - self.is_run_model = 'run_model' in jits - - nv12_info = get_nv12_info(cam_w, cam_h) - self.frame_copy_size = nv12_copy_size(*nv12_info[:3]) - self.full_frames: dict = {} - self._blob_cache: dict = {} - self.frame_buffers: dict = {} - - if self.is_run_model or 'model' in metadata: - model_metadata = metadata.get('model', metadata) - self.input_shapes = model_metadata['input_shapes'] - self.vision_output_slices = model_metadata['output_slices'] - self.policy_output_slices = {} - self._policy_slices_list = [] - self._combined_model_type = 'supercombo' - self._vision_input_names = [key for key in self.input_shapes if 'img' in key] - self.frame_skip = derive_frame_skip({}, self.input_shapes) - if self.is_run_model: - self.input_queues, self.numpy_inputs, self.frame_buffers = make_stock_input_queues( - self.input_shapes, self.frame_skip, device=self.DEV, frame_copy_size=self.frame_copy_size) - self.frame_views, self.npy = self.frame_buffers, self.numpy_inputs - self.run_model, self.run_policy, self.warp = jits['run_model'][(cam_w, cam_h)], None, None - else: - self.input_queues, self.numpy_inputs = make_supercombo_input_queues(self.input_shapes, self.frame_skip, device=self.QUEUE_DEV) - self.run_model, self.run_policy, self.warp = None, jits['run_policy'], jits[(cam_w, cam_h)] - else: - self.run_model, self.run_policy, self.warp = None, jits['run_policy'], jits[(cam_w, cam_h)] - vision_metadata = metadata['vision'] - policy_keys = [k for k in metadata if k not in ('vision', 'warp_dev')] - self._combined_model_type = 'split' if policy_keys == ['policy'] else 'multi_policy' - self.vision_output_slices = vision_metadata['output_slices'] - self._policy_keys = policy_keys - self._policy_slices_list = [metadata[k]['output_slices'] for k in policy_keys] - self.policy_output_slices = self._policy_slices_list[0] - self._has_on_policy = any('on' in k.lower() for k in policy_keys) - self._vision_input_names = [key for key in vision_metadata['input_shapes'] if 'img' in key] - first_policy_meta = metadata[policy_keys[0]] - frame_skip = derive_frame_skip(vision_metadata['input_shapes'], first_policy_meta['input_shapes']) - self.input_queues, self.numpy_inputs = make_split_input_queues(vision_metadata['input_shapes'], - first_policy_meta['input_shapes'], - frame_skip, device=self.QUEUE_DEV) - - self._desire_key = next(key for key in self.numpy_inputs if key.startswith('desire')) - self._road_key = next(key for key in self._vision_input_names if 'big' not in key) - self._wide_key = next(key for key in self._vision_input_names if 'big' in key) - self.frame_buf_params = dict.fromkeys(self._vision_input_names, nv12_info) + self.adapter = get_model_adapter(jits, cam_w, cam_h, self.DEV, self.QUEUE_DEV, self.WARP_DEV, self.chestnut) + self.vision_output_slices = self.adapter.vision_output_slices + self.policy_output_slices = self.adapter.policy_output_slices + self._policy_slices_list = self.adapter._policy_slices_list + self._combined_model_type = self.adapter._combined_model_type + self._vision_input_names = self.adapter._vision_input_names + self.numpy_inputs = self.adapter.numpy_inputs + self._policy_keys = self.adapter._policy_keys + self._has_on_policy = self.adapter._has_on_policy + self._desire_key = self.adapter._desire_key + self._road_key = self.adapter._road_key + self._wide_key = self.adapter._wide_key + self.frame_buf_params = self.adapter.frame_buf_params is_20hz = bundle.is20hz if bundle else self._combined_model_type in ('split', 'multi_policy') if is_20hz: @@ -198,26 +155,10 @@ class ModelState(ModelStateBase): self.parser = Parser() self.prev_desire = np.zeros(self.constants.DESIRE_LEN, dtype=np.float32) - if self.warp is not None: - self.full_frames = {k: Tensor(np.zeros(nv12_info[3], dtype=np.uint8), device=self.WARP_DEV).contiguous().realize() for k in self._vision_input_names} - self.warp(**{k: self.input_queues[k] for k in WARP_INPUTS}, frame=self.full_frames[self._road_key], big_frame=self.full_frames[self._wide_key]) - def warmup(self) -> None: - dummy_size = self.frame_copy_size if self.is_run_model else self.frame_buf_params[self._road_key][3] - dummy_frames = {k: np.zeros(dummy_size, dtype=np.uint8) for k in self._vision_input_names} - transforms = {k: np.eye(3, dtype=np.float32) for k in [self._road_key, self._wide_key] if k} - dummy_inputs = {k: np.zeros(v.shape, dtype=v.dtype) for k, v in self.numpy_inputs.items() if k not in ['tfm', 'big_tfm', 'prev_feat']} + dummy_frames, transforms, dummy_inputs = self.adapter.get_dummy_inputs() self.run(dummy_frames, transforms, dummy_inputs) - if self.is_run_model: - self.input_queues, self.numpy_inputs, self.frame_buffers = make_stock_input_queues( - self.input_shapes, self.frame_skip, device=self.DEV, frame_copy_size=self.frame_copy_size) - self.frame_views = self.frame_buffers - self.npy = self.numpy_inputs - else: - for v in self.numpy_inputs.values(): - v[:] = 0 - self.full_frames.clear() - self._blob_cache.clear() + self.adapter.reset_warmup_buffers() self.prev_desire[:] = 0 @property @@ -235,37 +176,27 @@ class ModelState(ModelStateBase): def run(self, bufs: dict[str, VisionBuf], transforms: dict[str, np.ndarray], inputs: dict[str, np.ndarray], after_enqueue: Callable[[], None] | None = None) -> dict[str, np.ndarray] | None: - if self.is_run_model: - for key, buf in bufs.items(): - data = buf.data if hasattr(buf, 'data') else buf - np.copyto(self.frame_buffers[key], np.frombuffer(data, dtype=np.uint8, count=self.frame_copy_size)) - else: - for key, buf in bufs.items(): - ptr = np.frombuffer(buf.data, dtype=np.uint8).ctypes.data - cache_key = (key, ptr) - if cache_key not in self._blob_cache: - self._blob_cache[cache_key] = Tensor.from_blob(ptr, (self.frame_buf_params[key][3],), dtype='uint8', device=self.WARP_DEV) - self.full_frames[key] = self._blob_cache[cache_key] + self.adapter.copy_frames(bufs) desire_key = self.desire_key inputs[desire_key][0] = 0 - self.numpy_inputs[desire_key][:] = np.where(inputs[desire_key] - self.prev_desire > .99, inputs[desire_key], 0) + (self.numpy_inputs[desire_key].flat if self.adapter.is_native else self.numpy_inputs[desire_key])[:] = \ + np.where(inputs[desire_key] - self.prev_desire > .99, inputs[desire_key], 0) self.prev_desire[:] = inputs[desire_key] for key in ('traffic_convention', 'lateral_control_params', 'action_t'): if key in self.numpy_inputs and key in inputs: self.numpy_inputs[key][:] = inputs[key] - self.numpy_inputs['tfm'][:, :] = transforms[self._road_key].reshape(3, 3) - self.numpy_inputs['big_tfm'][:, :] = transforms[self._wide_key].reshape(3, 3) - - if self.run_model is not None: - outs, = self.run_model(**{k: self.input_queues[k] for k in MODELD_INPUTS}) - raw_outputs = outs + if self.adapter.is_native: + for i, key in enumerate(self._vision_input_names): + if key in transforms: + self.numpy_inputs['tfm'][i] = transforms[key].reshape(3, 3) else: - assert self.warp is not None and self.run_policy is not None - warped = self.warp(**{k: self.input_queues[k] for k in WARP_INPUTS}, frame=self.full_frames[self._road_key], big_frame=self.full_frames[self._wide_key]) - raw_outputs = self.run_policy(**{k: self.input_queues[k] for k in POLICY_INPUTS if k in self.input_queues}, warped=warped) + self.numpy_inputs['tfm'][:, :] = transforms[self._road_key].reshape(3, 3) + self.numpy_inputs['big_tfm'][:, :] = transforms[self._wide_key].reshape(3, 3) + + raw_outputs = self.adapter.run() if after_enqueue is not None: after_enqueue() @@ -277,24 +208,25 @@ class ModelState(ModelStateBase): sliced = {k: model_output[np.newaxis, v] for k, v in self.vision_output_slices.items()} outputs = self.parser.parse_outputs(sliced) if 'prev_feat' in self.numpy_inputs and 'hidden_state' in self.vision_output_slices: - self.numpy_inputs['prev_feat'][:] = model_output[self.vision_output_slices['hidden_state']] + (self.numpy_inputs['prev_feat'].flat if self.adapter.is_native else self.numpy_inputs['prev_feat'])[:] = \ + model_output[self.vision_output_slices['hidden_state']] else: vision_output = raw_outputs[0].numpy().flatten() vision_sliced = {k: vision_output[np.newaxis, v] for k, v in self.vision_output_slices.items()} outputs = self.parser.parse_vision_outputs(vision_sliced) if 'prev_feat' in self.numpy_inputs and 'hidden_state' in self.vision_output_slices: - self.numpy_inputs['prev_feat'][:] = vision_output[self.vision_output_slices['hidden_state']] + (self.numpy_inputs['prev_feat'].flat if self.adapter.is_native else self.numpy_inputs['prev_feat'])[:] = \ + vision_output[self.vision_output_slices['hidden_state']] for i, policy_slices in enumerate(self._policy_slices_list): policy_output = raw_outputs[i + 1].numpy().flatten() policy_sliced = {k: policy_output[np.newaxis, v] for k, v in policy_slices.items()} parsed = self.parser.parse_policy_outputs(policy_sliced) - if ('off' in self._policy_keys[i] - and self._has_on_policy - and any('plan' in self._policy_slices_list[j] for j, k in enumerate(self._policy_keys) if 'on' in k.lower())): - - parsed.pop('plan', None) + if 'off' in self._policy_keys[i] and self._has_on_policy: + for key in ('plan', 'planplus', 'action'): + if any(key in self._policy_slices_list[j] for j, k in enumerate(self._policy_keys) if 'on' in k.lower()): + parsed.pop(key, None) outputs.update(parsed) @@ -337,7 +269,6 @@ class ModelState(ModelStateBase): def main(demo=False): cloudlog.warning("modeld init") - sentry.set_tag("daemon", PROCESS_NAME) cloudlog.bind(daemon=PROCESS_NAME) setproctitle(PROCESS_NAME) config_realtime_process(7, 54) @@ -391,11 +322,7 @@ def main(demo=False): loader.start() loader.join(BIG_MODEL_TIMEOUT) model = big_model - if model is None: - params.put_bool("ChestnutModelError", True) params.put_bool("ChestnutActive", model is not None) - if model is not None: - params.remove("ChestnutModelError") small_model = ModelState(cam_w=vipc_client_main.width, cam_h=vipc_client_main.height, chestnut=False) if model is None or CHESTNUT else None if model is None: @@ -405,12 +332,12 @@ def main(demo=False): cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") # messaging - pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutState"] if CHESTNUT else []) + pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutGpuState"] if CHESTNUT else []) pm = PubMaster(pub_socks) sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"]) publish_state = PublishState() - chestnut_state = ChestnutState(pm, model.chestnut) if CHESTNUT else None + chestnut_state = ChestnutGpuState(pm, model.chestnut) if CHESTNUT else None # setup filter to track dropped frames frame_dropped_filter = FirstOrderFilter(0., 10., 1. / model.constants.MODEL_FREQ) @@ -532,13 +459,12 @@ def main(demo=False): mt1 = time.perf_counter() try: send_chestnut = (chestnut_state is not None and - run_count % round(model.constants.MODEL_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0) + run_count % round(model.constants.MODEL_FREQ / SERVICE_LIST['chestnutGpuState'].frequency) == 0) model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None) except Exception: if not params.get_bool("ChestnutActive"): raise - cloudlog.exception("chestnut failed, falling back to small") - params.put_bool("ChestnutModelError", True) + cloudlog.exception("big model failed, fall back to small") params.put_bool("ChestnutActive", False) assert small_model is not None model = small_model @@ -592,5 +518,5 @@ if __name__ == "__main__": except KeyboardInterrupt: cloudlog.warning(f"child {PROCESS_NAME} got SIGINT") except Exception: - sentry.capture_exception() + cloudlog.exception("modeld exception") raise diff --git a/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1344x760_tinygrad.pkl b/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1344x760_tinygrad.pkl new file mode 100644 index 0000000000000000000000000000000000000000..5e84a9bb2d4f796b76b1996b83b064e4da7ab1f5 GIT binary patch literal 872573 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 W$Rr++jpy7jG^hQyewDj-ul)a>U~e}7 literal 0 HcmV?d00001 diff --git a/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1928x1208_tinygrad.pkl b/openpilot/sunnypilot/modeld_v2/models/big_driving_warp_1928x1208_tinygrad.pkl new file mode 100644 index 0000000000000000000000000000000000000000..6a8f18169420356524d87527d7850616acd414d3 GIT binary patch literal 872317 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

dUtfk^;as2ofN`wUCrx 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 bX3m+g#QIY4RD2E_d&du&u)?@37We-Jycmt; literal 0 HcmV?d00001 diff --git a/openpilot/sunnypilot/modeld_v2/models/driving_warp_1344x760_tinygrad.pkl b/openpilot/sunnypilot/modeld_v2/models/driving_warp_1344x760_tinygrad.pkl new file mode 100644 index 0000000000000000000000000000000000000000..049009d43ba91c130222340469d0a31c4f3eedff GIT binary patch literal 838183 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 zNBmDhG_XESN816YzafE0w}1`RM?3(izafE0SAbvVM{5GVrnUO7fBh`=YfV6V`)dy1 zEcNRh|7Z1X0nch40MxH{1ql0V4S@P-f1O)E@qe8w0P3gxbq)aK{~A{S)Gq||9RgeS z*B1cwD+1aEfNg(m1AzK10=fnO?Qav%)Bzm(>skR)ze7OJ0C3dnItHMAhk)+>uV)M3 zSRZ>W9f0~v1iq=G1C-Qj8UWN^CZN0j>(~O6t*>VVK>aQP8n%Eg^;!l1^}7k^*8;lL z>lgsk?;)UF0C?1&J*!;{SfO9LE->|b3FuY#4`N$X5Uj^he$SsJ}jes0ToO z_0ewuslPsfs8@jX)#Gf$D}aXjBklmy-++Mb_K*0VglJ%WoQ}2wP=7-Lk!}GSs*iX8 zP=7-Lk*)yK>->-a36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@ zkN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg z011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!) z36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@ zkN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg z011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!) z36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@ zkN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg z011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!) z36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@ zkN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg z011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!) z36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@kN^pg011!)36KB@ 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 zfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@ z1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14c zNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-L zfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@ z1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14c zNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-L zfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@ z1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14c zNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-L zfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@ z1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14c zNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-LfCNZ@1W14cNPq-L zfCNZ@1W3RlaF8DoAOR8}0TLhq5+DH*AOR8}0TLhq5+DH*AOR8}0TLhq5+DH*AOR8} z0TLhq5+DH*AOR8}0TLhq5+DH*AOR8}0TLhq5+DH*AOR8}0TLhq5+DH*AOR8}0TLhq z5+DH*AOR8}0TLhq5+DH*AOR8}0TLhq5+DH*AOR8}0TLhq5+DH*AOR8}0TLhq5+DH* zAOR8}0TLhq5+DH*AOR8}0TLhq5+DH*AOR8}0TLhq5+DH*AOR8}0TLjAgKzLd0wh2J zBtQZrKmsH{0wh2JBtQZrKmsH{0wh2JBtQZrKmsH{0wh2JBtQZrKmsH{0wh2JBtQZr zKmsH{0wh2JBtQZrKmsH{0wh2JBtQZrKmsH{0wh2JBtQZrKmsH{0wh2JBtQZrKmsH{ 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 L61&IQv-|yj+CJ;k literal 0 HcmV?d00001 diff --git a/openpilot/sunnypilot/modeld_v2/stock_dependencies.py b/openpilot/sunnypilot/modeld_v2/stock_dependencies.py new file mode 100644 index 000000000..5281829e5 --- /dev/null +++ b/openpilot/sunnypilot/modeld_v2/stock_dependencies.py @@ -0,0 +1,184 @@ +""" +Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors. + +This file is part of sunnypilot and is licensed under the MIT License. +See the LICENSE.md file in the root directory for more details. +""" +import math +import numpy as np +from collections import namedtuple +from tinygrad.tensor import Tensor +from tinygrad.helpers import Context +from tinygrad.device import Device + +""" +Frozen in time compile_modeld dependencies to support all models prior to transition to tinygrad compilation. +This file is not meant to be modified. +""" +NV12Frame = namedtuple("NV12Frame", ['width', 'height', 'stride', 'y_height', 'uv_height', 'size']) +MODELD_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs'] + + +def nv12_copy_size(stride: int, y_height: int, uv_height: int) -> int: + return stride * (y_height + uv_height) + + +def warp_perspective_tinygrad(src_flat, M_inv, dst_shape, src_shape, stride_pad, border_fill_val=None): + w_dst, h_dst = dst_shape + h_src, w_src = src_shape + + x = Tensor.arange(w_dst).reshape(1, w_dst).expand(h_dst, w_dst).reshape(-1) + y = Tensor.arange(h_dst).reshape(h_dst, 1).expand(h_dst, w_dst).reshape(-1) + + src_x = M_inv[0, 0] * x + M_inv[0, 1] * y + M_inv[0, 2] + src_y = M_inv[1, 0] * x + M_inv[1, 1] * y + M_inv[1, 2] + src_w = M_inv[2, 0] * x + M_inv[2, 1] * y + M_inv[2, 2] + + src_x = src_x / src_w + src_y = src_y / src_w + + x_round = Tensor.round(src_x) + y_round = Tensor.round(src_y) + x_nn_clipped = x_round.clip(0, w_src - 1).cast('int') + y_nn_clipped = y_round.clip(0, h_src - 1).cast('int') + idx = y_nn_clipped * (w_src + stride_pad) + x_nn_clipped + sampled = src_flat[idx] + + if border_fill_val is None: + return sampled + + in_bounds = ((x_round >= 0) & (x_round <= w_src - 1) & + (y_round >= 0) & (y_round <= h_src - 1)).cast(sampled.dtype) + return sampled * in_bounds + Tensor(border_fill_val, dtype=sampled.dtype) * (1 - in_bounds) + + +def frames_to_tensor(frames): + H = (frames.shape[0] * 2) // 3 + W = frames.shape[1] + in_img1 = Tensor.cat(frames[0:H:2, 0::2], + frames[1:H:2, 0::2], + frames[0:H:2, 1::2], + frames[1:H:2, 1::2], + frames[H:H+H//4].reshape((H//2, W//2)), + frames[H+H//4:H+H//2].reshape((H//2, W//2)), dim=0).reshape((6, H//2, W//2)) + return in_img1 + + +def make_frame_prepare(nv12: NV12Frame, model_w, model_h): + cam_w, cam_h, stride, y_height, uv_height, _ = nv12 + uv_offset = stride * y_height + stride_pad = stride - cam_w + + def frame_prepare_tinygrad(input_frame, M_inv): + M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=Device.DEFAULT) + uv = input_frame[uv_offset:uv_offset + uv_height * stride].reshape(uv_height, stride) + with Context(SPLIT_REDUCEOP=0): + y = warp_perspective_tinygrad(input_frame[:cam_h*stride], + M_inv, (model_w, model_h), + (cam_h, cam_w), stride_pad).realize() + u = warp_perspective_tinygrad(uv[:cam_h//2, :cam_w:2].flatten(), + M_inv_uv, (model_w//2, model_h//2), + (cam_h//2, cam_w//2), 0).realize() + v = warp_perspective_tinygrad(uv[:cam_h//2, 1:cam_w:2].flatten(), + M_inv_uv, (model_w//2, model_h//2), + (cam_h//2, cam_w//2), 0).realize() + yuv = y.cat(u).cat(v).reshape((model_h * 3 // 2, model_w)) + tensor = frames_to_tensor(yuv) + return tensor + return frame_prepare_tinygrad + + +def _detect_desire_key(shapes: dict) -> str | None: + return next((key for key in shapes if key.startswith('desire')), None) + + +def _detect_vision_keys(shapes: dict) -> tuple[str | None, str | None]: + img_keys = sorted(key for key in shapes if 'img' in key) + return (next((key for key in img_keys if 'big' not in key), None), + next((key for key in img_keys if 'big' in key), None)) + + +def get_policy_npy_shapes(input_shapes): + shapes = {} + desire_key = _detect_desire_key(input_shapes) + if desire_key: + shapes['desire'] = (input_shapes[desire_key][2],) + for key, shape in input_shapes.items(): + if key not in (desire_key, 'features_buffer') and 'img' not in key: + shapes[key] = tuple(shape) + if 'features_buffer' in input_shapes: + fb = input_shapes['features_buffer'] + shapes['prev_feat'] = (fb[0], math.prod(fb[2:])) + + sizes = [int(np.prod(size)) for size in shapes.values()] + return shapes, sizes + + +def make_input_queues(input_shapes, frame_skip, device, frame_copy_size): + road_key, _ = _detect_vision_keys(input_shapes) + img = input_shapes[road_key] + fb = input_shapes['features_buffer'] + feat_dim = math.prod(fb[2:]) + dp = input_shapes[_detect_desire_key(input_shapes)] + n_frames = img[1] // 6 + img_buf_shape = (frame_skip * (n_frames - 1) + 1, 6, img[2], img[3]) + + policy_shapes, _ = get_policy_npy_shapes(input_shapes) + shapes = {'tfm': (3, 3), 'big_tfm': (3, 3)} | policy_shapes + sizes = [math.prod(s) for s in shapes.values()] + packed_npy_size = sum(sizes) * np.dtype(np.float32).itemsize + packed_input = np.zeros(packed_npy_size + 2 * frame_copy_size, dtype=np.uint8) + packed_npy_inputs = packed_input[:packed_npy_size].view(np.float32) + frames = packed_input[packed_npy_size:] + frame_views = {'img': frames[:frame_copy_size], 'big_img': frames[frame_copy_size:]} + npy = {k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed_npy_inputs, np.cumsum(sizes[:-1])), strict=True)} + input_queues = { + 'img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), + 'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(), + 'feat_q': Tensor(np.zeros((frame_skip * fb[1], fb[0], feat_dim), dtype=np.float32), device=device).contiguous().realize(), + 'desire_q': Tensor(np.zeros((frame_skip * dp[1], dp[0], dp[2]), dtype=np.float32), device=device).contiguous().realize(), + 'packed_npy_inputs': Tensor(packed_input, device='NPY').realize(), + } + return input_queues, npy, frame_views + + +def shift_and_sample(buf, new_val, sample_fn): + buf.assign(buf[1:].cat(new_val, dim=0).contiguous()) + return sample_fn(buf) + + +def sample_skip(buf, frame_skip): + return buf[::frame_skip].contiguous().flatten(0, 1).unsqueeze(0) + + +def sample_desire(buf, frame_skip): + return buf.reshape(-1, frame_skip, *buf.shape[1:]).max(1).flatten(0, 1).unsqueeze(0) + + +def make_warp(nv12, model_w, model_h): + frame_prepare = make_frame_prepare(nv12, model_w, model_h) + def warp(tfm, big_tfm, frame, big_frame): + tfm = tfm.to(Device.DEFAULT) + big_tfm = big_tfm.to(Device.DEFAULT) + frame = frame.to(Device.DEFAULT) + big_frame = big_frame.to(Device.DEFAULT) + Tensor.realize(tfm, big_tfm, frame, big_frame) + warped_frame = frame_prepare(frame, tfm).unsqueeze(0) + warped_big_frame = frame_prepare(big_frame, big_tfm).unsqueeze(0) + return Tensor.cat(warped_frame, warped_big_frame) + return warp + + +def make_run_model(warp, run_policy, model_metadata, frame_copy_size): + _, policy_sizes = get_policy_npy_shapes(model_metadata['input_shapes']) + packed_npy_size = (18 + sum(policy_sizes)) * np.dtype(np.float32).itemsize + def run_model(img_q, big_img_q, feat_q, desire_q, packed_npy_inputs): + packed_input = packed_npy_inputs.to(Device.DEFAULT) + Tensor.realize(packed_input) + packed_npy_inputs = packed_input[:packed_npy_size].bitcast('float32') + frame = packed_input[packed_npy_size:packed_npy_size + frame_copy_size] + big_frame = packed_input[packed_npy_size + frame_copy_size:] + tfm, big_tfm, policy_inputs = packed_npy_inputs.split([9, 9, sum(policy_sizes)]) + warped = warp(tfm.reshape(3, 3), big_tfm.reshape(3, 3), frame, big_frame) + return run_policy(warped, img_q, big_img_q, feat_q, desire_q, policy_inputs) + return run_model diff --git a/openpilot/sunnypilot/modeld_v2/tests/helpers.py b/openpilot/sunnypilot/modeld_v2/tests/helpers.py index 8ec69cff7..b6f147391 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/helpers.py +++ b/openpilot/sunnypilot/modeld_v2/tests/helpers.py @@ -7,6 +7,8 @@ See the LICENSE.md file in the root directory for more details. import pathlib import tempfile +import codecs +import pickle import openpilot.sunnypilot.models.helpers as helpers import openpilot.sunnypilot.modeld_v2.modeld as modeld_module @@ -161,6 +163,23 @@ ARCHETYPES = { def make_pkl_data(archetype): + if archetype.expected_model_type == 'supercombo': + slices_b64 = codecs.encode(pickle.dumps(archetype.metadata_structure['model']['output_slices']), 'base64').decode() + return { + 'metadata': { + 'metadata': {'output_slices': slices_b64}, + 'model': { + 'input_shapes': archetype.metadata_structure['model']['input_shapes'], + 'output_slices': archetype.metadata_structure['model']['output_slices'] + }, + 'input_shapes': archetype.metadata_structure['model']['input_shapes'], + 'output_slices': archetype.metadata_structure['model']['output_slices'], + 'output_shapes': {} + }, + 'run_policy': _noop_jit, + (CAM_W, CAM_H): _noop_jit + } + return { 'metadata': archetype.metadata_structure, 'run_policy': _noop_jit, @@ -169,7 +188,7 @@ def make_pkl_data(archetype): def write_pkl(tmp_path, archetype): - from openpilot.selfdrive.modeld.helpers import dump_oob + from openpilot.sunnypilot.modeld_v2.helpers import dump_oob pkl_path = tmp_path / 'driving_test_tinygrad.pkl' with open(pkl_path, 'wb') as f: dump_oob(make_pkl_data(archetype), f) diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py b/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py index 88cee8c5b..8b4c4e32a 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py @@ -12,7 +12,7 @@ import openpilot.sunnypilot.modeld_v2.modeld as modeld_module from openpilot.sunnypilot.modeld_v2.modeld import _find_driving_pkl from openpilot.sunnypilot.modeld_v2.tests import helpers as tests_helpers from openpilot.sunnypilot.modeld_v2.tests.helpers import DummyModel, DummyBundle, ARCHETYPES, CAM_W, CAM_H, \ - SPLIT_VISION_INPUT_SHAPES, SPLIT_POLICY_INPUT_SHAPES + SPLIT_VISION_INPUT_SHAPES from openpilot.common.test import OpenpilotTestCase # resolved by name from this module when a test asks for them @@ -66,21 +66,6 @@ class TestModelStateCombinedInit(OpenpilotTestCase): class TestStockEquivalence(OpenpilotTestCase): - - def test_split_queue_keys_match_stock(self, model_state_factory): - from openpilot.selfdrive.modeld.compile_modeld import make_input_queues - from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip - - state = model_state_factory(ARCHETYPES['vision_policy_split']) - - frame_skip = derive_frame_skip(SPLIT_VISION_INPUT_SHAPES, SPLIT_POLICY_INPUT_SHAPES) - stock_shapes = {**SPLIT_VISION_INPUT_SHAPES, **SPLIT_POLICY_INPUT_SHAPES, 'action_t': (1, 2)} - stock_queues, stock_npy, _frame_views = make_input_queues(stock_shapes, frame_skip, device='NPY', frame_copy_size=49152) - - # sunnypilot split pipeline has tfm/big_tfm as queues (stock has them in npy only) - assert set(stock_queues.keys()) <= set(state.input_queues.keys()) - assert {'desire', 'traffic_convention'} <= set(state.numpy_inputs.keys()) - def test_split_queue_keys_work_with_desire_key(self, model_state_factory): from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, make_split_input_queues @@ -105,20 +90,25 @@ class TestStockEquivalence(OpenpilotTestCase): def test_unified_run_model(self, tmp_path, monkeypatch, patch_modeld): from openpilot.common.hardware import hw - from openpilot.selfdrive.modeld.helpers import dump_oob + from openpilot.sunnypilot.modeld_v2.helpers import dump_oob shapes = {'img': (1, 12, 128, 256), 'big_img': (1, 12, 128, 256), 'features_buffer': (1, 24, 32, 512), 'desire_pulse': (1, 25, 8), 'traffic_convention': (1, 2), 'action_t': (1, 2)} - pkl_data = {'metadata': {'model': {'input_shapes': shapes, 'output_slices': {}}}, - 'run_model': {(CAM_W, CAM_H): tests_helpers._noop_jit}} + import codecs + import pickle + slices_b64 = codecs.encode(pickle.dumps({}), 'base64').decode() + pkl_data = { + 'metadata': {'model': {'input_shapes': shapes, 'output_slices': {}}, 'metadata': {'output_slices': slices_b64}, + 'input_shapes': shapes, 'output_slices': {}, 'output_shapes': {}}, + (CAM_W, CAM_H): tests_helpers._noop_jit, + 'run_policy': tests_helpers._noop_jit, + } with open(tmp_path / 'driving_test_tinygrad.pkl', 'wb') as f: dump_oob(pkl_data, f) bundle = DummyBundle(models=[DummyModel('supercombo', 'driving_test_tinygrad.pkl')]) patch_modeld(bundle) monkeypatch.setattr(hw.Paths, 'model_root', staticmethod(lambda: str(tmp_path))) state = ModelState(cam_w=CAM_W, cam_h=CAM_H) - assert state.is_run_model and state.run_model is not None - assert state.run_policy is None and state.warp is None - assert 'img' in state.frame_views and 'big_img' in state.frame_views + assert state.adapter.run_policy is not None ARCHETYPE_NAMES = list(ARCHETYPES.keys()) @@ -193,7 +183,7 @@ class TestInputQueueCreation(OpenpilotTestCase): def test_queues_not_empty(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) - assert len(state.input_queues) > 0, f"{arch.name}: input_queues empty" + assert len(state.adapter.input_queues) > 0, f"{arch.name}: input_queues empty" @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_npy_contains_transforms(self, archetype_name, model_state_factory): diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py b/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py index 7f6636fb9..239c498e7 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py @@ -225,6 +225,7 @@ class Test4DFeaturesBuffer(OpenpilotTestCase): class TestStockCompileModeldEquivalence(OpenpilotTestCase): + @unittest.skip("upstream removed selfdrive/modeld/compile_modeld.py — no stock implementation to compare") def test_get_policy_npy_shapes_matches_stock(self): from openpilot.selfdrive.modeld.compile_modeld import get_policy_npy_shapes as stock_get_policy_npy_shapes from openpilot.sunnypilot.modeld_v2.compile_modeld import get_policy_npy_shapes as sunny_get_policy_npy_shapes @@ -243,6 +244,7 @@ class TestStockCompileModeldEquivalence(OpenpilotTestCase): assert sunny_sizes == stock_sizes assert sunny_shapes['prev_feat'] == (1, 512) + @unittest.skip("upstream removed selfdrive/modeld/compile_modeld.py — no stock implementation to compare") def test_make_input_queues_full_stock_equivalence(self): from openpilot.selfdrive.modeld.compile_modeld import make_input_queues as stock_make_input_queues from openpilot.sunnypilot.modeld_v2.compile_modeld import make_supercombo_input_queues as sunny_make_supercombo_input_queues @@ -280,5 +282,3 @@ class TestStockCompileModeldEquivalence(OpenpilotTestCase): assert set(sunny_npy.keys()) == set(stock_npy.keys()) == {'tfm', 'big_tfm'} for key in sunny_npy: assert sunny_npy[key].shape == stock_npy[key].shape == (3, 3) - - diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py b/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py index 9c40e5973..9e0241ec0 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_fallback.py @@ -5,7 +5,7 @@ from unittest import mock from openpilot.common.file_chunker import get_chunk_name from openpilot.common.hardware import hw from openpilot.common.test import OpenpilotTestCase -from openpilot.selfdrive.modeld.helpers import dump_oob +from openpilot.sunnypilot.modeld_v2.helpers import dump_oob import openpilot.sunnypilot.modeld_v2.modeld as modeld_module from openpilot.sunnypilot.modeld_v2.tests import helpers as tests_helpers from openpilot.sunnypilot.modeld_v2.tests.helpers import DummyModel, DummyBundle, CAM_W, CAM_H diff --git a/openpilot/sunnypilot/models/fetcher.py b/openpilot/sunnypilot/models/fetcher.py index 16fa378c4..2a3fc14cb 100644 --- a/openpilot/sunnypilot/models/fetcher.py +++ b/openpilot/sunnypilot/models/fetcher.py @@ -138,8 +138,8 @@ class ModelCache: class ModelFetcher: """Handles fetching and caching of model data from remote source""" - MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v22.json" - MODEL_URL_CHESTNUT = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_chestnut_v25.json" + MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v23.json" + MODEL_URL_CHESTNUT = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_chestnut_v27.json" MODEL_SOURCES = { "qcom": (MODEL_URL, ""), diff --git a/openpilot/sunnypilot/models/helpers.py b/openpilot/sunnypilot/models/helpers.py index 842595cec..219ea05ff 100644 --- a/openpilot/sunnypilot/models/helpers.py +++ b/openpilot/sunnypilot/models/helpers.py @@ -16,7 +16,7 @@ from openpilot.common.hardware.hw import Paths from openpilot.selfdrive.modeld.helpers import chestnut_present # SET ME TO THE EXACT JSON VERSION WE SET IN SUNNYPILOT_MODELS REPO -REQUIRED_JSON_VERSION = 19 +REQUIRED_JSON_VERSION = 20 CUSTOM_MODEL_PATH = Paths.model_root() ModelManager = custom.ModelManagerSP diff --git a/openpilot/sunnypilot/models/model_name.py b/openpilot/sunnypilot/models/model_name.py index 342039182..fdf999a68 100644 --- a/openpilot/sunnypilot/models/model_name.py +++ b/openpilot/sunnypilot/models/model_name.py @@ -1,4 +1,4 @@ DEFAULT_MODEL = "CD210" DEFAULT_MODEL_REF = "5b6436a90cf6902b8aaa71c2b6f3d7164d8ae391" -DEFAULT_BIG_MODEL = "BMRLNAP Model v4" -DEFAULT_BIG_MODEL_REF = "f877d7a0ccc3cce943c76e285214c020cd65c899" +DEFAULT_BIG_MODEL = "Cinque Terre Model v2" +DEFAULT_BIG_MODEL_REF = "37bfa1413edcdc2e8844984b83727c33f81d8f46" diff --git a/openpilot/sunnypilot/models/tests/model_hash b/openpilot/sunnypilot/models/tests/model_hash index ffe44d3bf..95a9bafd3 100644 --- a/openpilot/sunnypilot/models/tests/model_hash +++ b/openpilot/sunnypilot/models/tests/model_hash @@ -1 +1 @@ -c5be11d2fb1115be953c541f30c50f7c71a00bc4a0e128e19aa11b60689317fc \ No newline at end of file +afa673add3ffdff2607c4968912a0183c5883fd0159d56218a3c22a508a2ebd5 \ No newline at end of file diff --git a/openpilot/sunnypilot/selfdrive/car/interfaces.py b/openpilot/sunnypilot/selfdrive/car/interfaces.py index d5e3749be..edc918da5 100644 --- a/openpilot/sunnypilot/selfdrive/car/interfaces.py +++ b/openpilot/sunnypilot/selfdrive/car/interfaces.py @@ -13,18 +13,9 @@ from openpilot.common.swaglog import cloudlog from openpilot.sunnypilot.selfdrive.controls.lib.nnlc.helpers import get_nn_model_path from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.helpers import set_speed_limit_assist_availability -import openpilot.system.sentry as sentry - from openpilot.sunnypilot.sunnylink.statsd import STATSLOGSP -def log_fingerprint(CP: structs.CarParams) -> None: - if CP.carFingerprint == "MOCK": - sentry.capture_fingerprint_mock() - else: - sentry.capture_fingerprint(CP.carFingerprint, CP.brand) - - def _enforce_torque_lateral_control(CP: structs.CarParams, params: Params | None = None, enabled: bool = False) -> bool: if params is None: params = Params() diff --git a/openpilot/system/camerad/test/stress_restart.sh b/openpilot/system/camerad/test/stress_restart.sh index 0445dcba7..5f0f2dd2f 100755 --- a/openpilot/system/camerad/test/stress_restart.sh +++ b/openpilot/system/camerad/test/stress_restart.sh @@ -4,6 +4,6 @@ while :; do ./camerad & pid="$!" sleep 2 - kill -2 $pid - wait $pid + kill -2 "$pid" + wait "$pid" done diff --git a/openpilot/system/hardware/chestnut/flash.py b/openpilot/system/hardware/chestnut/flash.py index f0d828031..d2c8e1dfa 100755 --- a/openpilot/system/hardware/chestnut/flash.py +++ b/openpilot/system/hardware/chestnut/flash.py @@ -63,9 +63,9 @@ def find_chestnut(): found = [] for d in glob.glob("/sys/bus/usb/devices/*"): try: - vid_pid = (open(d + "/idVendor").read().strip(), open(d + "/idProduct").read().strip()) + vid_pid = (Path(d, "idVendor").read_text().strip(), Path(d, "idProduct").read_text().strip()) if vid_pid in VID_PIDS + ROM_VID_PIDS: - found.append((d, vid_pid, open(d + "/product").read().strip())) + found.append((d, vid_pid, Path(d, "product").read_text().strip())) except OSError: pass if len(found) > 1: @@ -101,7 +101,7 @@ def unbind_drivers(path): def open_device(path): - bus, dev = int(open(path + "/busnum").read()), int(open(path + "/devnum").read()) + bus, dev = int(Path(path, "busnum").read_text()), int(Path(path, "devnum").read_text()) return os.open(f"/dev/bus/usb/{bus:03d}/{dev:03d}", os.O_RDWR) diff --git a/openpilot/system/hardware/chestnut/monitoring.py b/openpilot/system/hardware/chestnut/monitoring.py new file mode 100644 index 000000000..3a8b21f61 --- /dev/null +++ b/openpilot/system/hardware/chestnut/monitoring.py @@ -0,0 +1,53 @@ +import struct + +import usb1 + +import openpilot.cereal.messaging as messaging +from openpilot.cereal.services import SERVICE_LIST +from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, get_usb_state, is_chestnut_usb_id + + +def read_chestnut_state(handle, gpu_state=None): + msg = messaging.new_message('chestnutState') + if gpu_state is not None: + msg.chestnutState = gpu_state + state = msg.chestnutState + try: + raw = handle.controlRead(0xC0, 0xC0, 0, 0, 5, timeout=100) + state.supplyVoltage, state.supplyCurrent, state.supplyFault = struct.unpack(' None: - system_stats = LinuxSystemStats() + system_stats = LinuxSystemStats() if sys.platform == "linux" else None pm = messaging.PubMaster(['deviceState']) sm = messaging.SubMaster(["peripheralState", "gpsLocationExternal", "selfdriveState", "pandaStates", "chestnutState"], poll="pandaStates") @@ -289,10 +290,13 @@ def hardware_thread(end_event, hw_queue) -> None: except queue.Empty: pass + memory_usage = system_stats.memory_usage_percent() if system_stats is not None else 0. + cpu_usage = system_stats.cpu_usage_percent() if system_stats is not None else [] + msg.deviceState.freeSpacePercent = get_available_percent(default=100.0) - msg.deviceState.memoryUsagePercent = int(round(system_stats.memory_usage_percent())) + msg.deviceState.memoryUsagePercent = int(round(memory_usage)) msg.deviceState.gpuUsagePercent = int(round(HARDWARE.get_gpu_usage_percent())) - online_cpu_usage = [int(round(n)) for n in system_stats.cpu_usage_percent()] + online_cpu_usage = [int(round(n)) for n in cpu_usage] offline_cpu_usage = [0., ] * (len(msg.deviceState.cpuTempC) - len(online_cpu_usage)) msg.deviceState.cpuUsagePercent = online_cpu_usage + offline_cpu_usage @@ -542,6 +546,7 @@ def main(): if COMMA_HARDWARE: threads.append(threading.Thread(target=touch_thread, args=(end_event,))) + threads.append(threading.Thread(target=chestnut_state_thread, args=(end_event,))) for t in threads: t.start() diff --git a/openpilot/system/manager/manager.py b/openpilot/system/manager/manager.py index 7befb7f4a..6907ff52d 100755 --- a/openpilot/system/manager/manager.py +++ b/openpilot/system/manager/manager.py @@ -8,7 +8,6 @@ import traceback from openpilot.cereal import log import openpilot.cereal.messaging as messaging -import openpilot.system.sentry as sentry from openpilot.common.utils import atomic_write from openpilot.common.params import Params, ParamKeyFlag from openpilot.common.text_window import TextWindow @@ -95,7 +94,6 @@ def manager_init() -> None: os.environ['CLEAN'] = '1' # init logging - sentry.init(sentry.SentryProject.SELFDRIVE) cloudlog.bind_global(dongle_id=dongle_id, version=build_metadata.openpilot.version, origin=build_metadata.openpilot.git_normalized_origin, @@ -204,7 +202,7 @@ def main() -> None: manager_thread() except Exception: traceback.print_exc() - sentry.capture_exception() + cloudlog.exception("crash") finally: manager_cleanup() diff --git a/openpilot/system/manager/process.py b/openpilot/system/manager/process.py index 43ea3f730..363f7f38e 100644 --- a/openpilot/system/manager/process.py +++ b/openpilot/system/manager/process.py @@ -12,7 +12,6 @@ from setproctitle import setproctitle from openpilot.cereal import log from opendbc.car.structs import car import openpilot.cereal.messaging as messaging -import openpilot.system.sentry as sentry from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog @@ -31,7 +30,6 @@ def launcher(proc: str, name: str) -> None: # add daemon name tag to logs cloudlog.bind(daemon=name) - sentry.set_tag("daemon", name) # exec the process mod.main() @@ -40,7 +38,7 @@ def launcher(proc: str, name: str) -> None: except Exception: # can't install the crash handler because sys.excepthook doesn't play nice # with threads, so catch it here. - sentry.capture_exception() + cloudlog.exception("crash") raise diff --git a/openpilot/system/sentry.py b/openpilot/system/sentry.py deleted file mode 100644 index dc078eb53..000000000 --- a/openpilot/system/sentry.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Install exception handler for process crash.""" -import os -import traceback -from datetime import datetime -import sentry_sdk -from enum import Enum -from sentry_sdk.integrations.threading import ThreadingIntegration - -from openpilot.common.params import Params -from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID -from openpilot.common.hardware import HARDWARE -from openpilot.common.hardware.hw import Paths -from openpilot.common.swaglog import cloudlog -from openpilot.common.version import get_build_metadata, get_version - -from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID - -CRASHES_DIR = Paths.crash_log_root() - - -class SentryProject(Enum): - # python project - SELFDRIVE = "https://186a6736b7927e5ae9b92c869ba81b6b@o1138119.ingest.us.sentry.io/4508660076052480" - # native project - SELFDRIVE_NATIVE = SELFDRIVE - - -def report_tombstone(fn: str, message: str, contents: str) -> None: - cloudlog.error({'tombstone': message}) - - with sentry_sdk.configure_scope() as scope: - set_user() - scope.set_extra("tombstone_fn", fn) - scope.set_extra("tombstone", contents) - sentry_sdk.capture_message(message=message) - sentry_sdk.flush() - - -def capture_exception(*args, **kwargs) -> None: - cloudlog.error("crash", exc_info=kwargs.get('exc_info', 1)) - - try: - save_exception(traceback.format_exc()) - - set_user() - sentry_sdk.capture_exception(*args, **kwargs) - sentry_sdk.flush() # https://github.com/getsentry/sentry-python/issues/291 - except Exception: - cloudlog.exception("sentry exception") - - -def save_exception(content: str) -> None: - try: - if not os.path.exists(CRASHES_DIR): - os.makedirs(CRASHES_DIR) - - files = [ - os.path.join(CRASHES_DIR, datetime.now().strftime("%Y-%m-%d--%H-%M-%S.log")), - os.path.join(CRASHES_DIR, "error.log") - ] - - for fn in files: - with open(fn, 'w') as f: - if fn == "error.log": - lines = content.splitlines()[-3:] - f.write("\n".join(lines)) - else: - f.write(content) - - cloudlog.error(f"logged crash to {files}") - except Exception: - cloudlog.exception("error when attempting to save exception") - - -def capture_fingerprint_mock() -> None: - try: - set_user() - message = "car doesn't match any fingerprints" - sentry_sdk.capture_message(message=message, level="error") - sentry_sdk.flush() - except Exception as e: - cloudlog.exception(f"sentry fingerprint MOCK exception: {e}") - - -def capture_fingerprint(candidate: str, car_name: str) -> None: - try: - set_user() - sentry_sdk.set_tag("carFingerprint", candidate) - sentry_sdk.set_tag("carName", car_name) - - message = f"Fingerprinted {candidate}" - sentry_sdk.capture_message(message=message, level="info") - sentry_sdk.flush() - except Exception as e: - cloudlog.exception(f"sentry fingerprint exception: {e}") - - -def set_tag(key: str, value: str) -> None: - sentry_sdk.set_tag(key, value) - - -def set_user() -> None: - dongle_id, git_username, _ = get_properties() - sentry_sdk.set_user({"id": dongle_id, "name": git_username}) - - -def get_properties() -> tuple[str, str, str]: - params = Params() - hardware_serial: str = params.get("HardwareSerial") or "" - git_username: str = params.get("GithubUsername") or "" - dongle_id: str = params.get("DongleId") or f"{UNREGISTERED_DONGLE_ID}-{hardware_serial}" - sunnylink_dongle_id: str = params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID - - return dongle_id, git_username, sunnylink_dongle_id - - -def init(project: SentryProject) -> bool: - build_metadata = get_build_metadata() - - env = build_metadata.channel_type - dongle_id, git_username, sunnylink_dongle_id = get_properties() - - integrations = [] - if project == SentryProject.SELFDRIVE: - integrations.append(ThreadingIntegration(propagate_hub=True)) - - sentry_sdk.init(project.value, - default_integrations=False, - release=get_version(), - integrations=integrations, - traces_sample_rate=1.0, - max_value_length=8192, - environment=env) - - sentry_sdk.set_user({"id": dongle_id, "name": git_username}) - sentry_sdk.set_tag("dirty", build_metadata.openpilot.is_dirty) - sentry_sdk.set_tag("origin", build_metadata.openpilot.git_origin) - sentry_sdk.set_tag("branch", build_metadata.channel) - sentry_sdk.set_tag("commit", build_metadata.openpilot.git_commit) - sentry_sdk.set_tag("device", HARDWARE.get_device_type()) - sentry_sdk.set_tag("sunnylink_dongle_id", sunnylink_dongle_id) - - return True diff --git a/openpilot/system/tombstoned.py b/openpilot/system/tombstoned.py index 7741a923c..7661ec7de 100755 --- a/openpilot/system/tombstoned.py +++ b/openpilot/system/tombstoned.py @@ -9,10 +9,11 @@ import time import glob from typing import NoReturn -import openpilot.system.sentry as sentry +from openpilot.common.hardware import PC from openpilot.common.hardware.hw import Paths from openpilot.common.swaglog import cloudlog from openpilot.common.version import get_build_metadata +from openpilot.system.athena.registration import is_registered_device MAX_SIZE = 1_000_000 * 100 # allow up to 100M MAX_TOMBSTONE_FN_LEN = 62 # 85 - 23 ("/crash/") @@ -65,22 +66,12 @@ def report_tombstone_apport(fn): return message = "" # One line description of the crash - contents = "" # Full file contents without coredump path = "" # File path relative to openpilot directory - proc_maps = False - with open(fn) as f: for line in f: if "CoreDump" in line: break - elif "ProcMaps" in line: - proc_maps = True - elif "ProcStatus" in line: - proc_maps = False - - if not proc_maps: - contents += line if "ExecutablePath" in line: path = line.strip().split(': ')[-1] @@ -112,13 +103,12 @@ def report_tombstone_apport(fn): if not found: crash_function = stacktrace_s[1] - # Remove arguments that can contain pointers to make sentry one-liner unique + # Remove arguments that can contain pointers from the crash summary crash_function = " ".join(x for x in crash_function.split(' ')[1:] if not x.startswith('0x')) crash_function = re.sub(r'\(.*?\)', '', crash_function) - contents = stacktrace + "\n\n" + contents message = message + " - " + crash_function - sentry.report_tombstone(fn, message, contents) + cloudlog.error({'tombstone': message}) # Copy crashlog to upload folder clean_path = path.replace('/', '_') @@ -141,7 +131,9 @@ def report_tombstone_apport(fn): def main() -> NoReturn: - should_report = sentry.init(sentry.SentryProject.SELFDRIVE_NATIVE) + build_metadata = get_build_metadata() + comma_remote = build_metadata.openpilot.comma_remote and "commaai" in build_metadata.openpilot.git_origin + should_report = comma_remote and is_registered_device() and not PC # Clear apport folder on start, otherwise duplicate crashes won't register clear_apport_folder() diff --git a/openpilot/system/ui/lib/cellular_manager.py b/openpilot/system/ui/lib/cellular_manager.py new file mode 100644 index 000000000..7cd19c4cb --- /dev/null +++ b/openpilot/system/ui/lib/cellular_manager.py @@ -0,0 +1,157 @@ +import time +import threading +from collections.abc import Callable +from dataclasses import replace + +from openpilot.common.hardware import HARDWARE +from openpilot.common.swaglog import cloudlog +from openpilot.common.esim.base import LPABase, Profile +from openpilot.common.esim.esim import execute_and_process_notifications + + +class CellularManager: + PROFILE_POLL_INTERVAL_S = 5.0 + + def __init__(self): + self._lpa: LPABase | None = None + self._profiles: list[Profile] = [] + self._busy: bool = False + # re-probed every poll; SIM may be swapped at runtime on tray-accessible devices + self._is_euicc: bool | None = None + self._modem_state: dict = {} + + self._lock = threading.Lock() + self._callback_lock = threading.Lock() + self._callback_queue: list[Callable] = [] + + self.on_profiles_updated: Callable[[], None] | None = None + self.on_operation_error: Callable[[str], None] | None = None + + self._last_profile_poll: float = 0.0 + self._polling: bool = False + + @property + def modem_state(self) -> dict: + return self._modem_state + + def process_callbacks(self): + with self._callback_lock: + to_run, self._callback_queue = self._callback_queue, [] + for cb in to_run: + cb() + + if not self._busy and not self._polling and time.monotonic() - self._last_profile_poll >= self.PROFILE_POLL_INTERVAL_S: + self._last_profile_poll = time.monotonic() + self._modem_state = HARDWARE.get_modem_state() + self._poll_profiles() + + @property + def profiles(self) -> list[Profile]: + return self._profiles + + @property + def active_profile(self) -> Profile | None: + return next((p for p in self._profiles if p.enabled), None) + + @property + def busy(self) -> bool: + return self._busy + + @property + def is_euicc(self) -> bool | None: + return self._is_euicc + + def _ensure_lpa(self) -> LPABase: + if self._lpa is None: + self._lpa = HARDWARE.get_sim_lpa() + return self._lpa + + def _enqueue(self, cb: Callable): + with self._callback_lock: + self._callback_queue.append(cb) + + def _stop_polling(self): + self._polling = False + + def _set_profiles(self, profiles: list[Profile]): + self._profiles = profiles + if self.on_profiles_updated: + self.on_profiles_updated() + + def _finish(self, profiles: list[Profile] | None = None, error: str | None = None): + self._busy = False + # defer the next poll a full interval; the eUICC can briefly report stale state after an operation + self._last_profile_poll = time.monotonic() + if profiles is not None: + self._set_profiles(profiles) + if error is not None: + self.refresh_profiles() + if self.on_operation_error: + self.on_operation_error(error) + + def _run_operation(self, fn: Callable[[LPABase], None], error_msg: str, relist: bool = True): + self._busy = True + + def worker(): + try: + with self._lock: + lpa = self._ensure_lpa() + fn(lpa) + profiles = lpa.list_profiles() if relist else None + self._enqueue(lambda: self._finish(profiles=profiles)) + except Exception as e: + cloudlog.exception(error_msg) + err = str(e) + self._enqueue(lambda: self._finish(error=err)) + + threading.Thread(target=worker, daemon=True).start() + + def refresh_profiles(self): + # next process_callbacks tick polls, respecting busy/polling guards + self._last_profile_poll = 0.0 + + def _poll_profiles(self): + self._polling = True + def worker(): + try: + with self._lock: + lpa = self._ensure_lpa() + is_euicc = lpa.is_euicc() + profiles = lpa.list_profiles() if is_euicc else [] + self._enqueue(lambda: self._finish_poll(is_euicc, profiles)) + except Exception: + cloudlog.exception("Failed to poll eSIM profiles") + self._enqueue(self._stop_polling) + + threading.Thread(target=worker, daemon=True).start() + + def _finish_poll(self, is_euicc: bool, profiles: list[Profile]): + self._polling = False + if self._busy: + return + if not is_euicc and self._is_euicc: + # is_euicc() is False on any AT error (e.g. SIM busy during a profile refresh); confirm on the next poll + self._is_euicc = None + return + self._is_euicc = is_euicc + self._set_profiles(profiles) + + def switch_profile(self, iccid: str): + def switch(lpa: LPABase): + execute_and_process_notifications(lpa, lambda: lpa.switch_profile(iccid)) + + # optimistic: list_profiles() can briefly return stale enabled state after a switch + self._set_profiles([replace(p, enabled=(p.iccid == iccid)) for p in self._profiles]) + self._run_operation(switch, "Failed to switch eSIM profile", relist=False) + + def delete_profile(self, iccid: str): + def delete(lpa: LPABase): + execute_and_process_notifications(lpa, lambda: lpa.delete_profile(iccid)) + + self._run_operation(delete, "Failed to delete eSIM profile") + + def download_profile(self, qr: str, nickname: str | None = None): + self._run_operation(lambda lpa: lpa.download_profile(qr, nickname), "Failed to download eSIM profile") + + def nickname_profile(self, iccid: str, nickname: str): + self._run_operation(lambda lpa: lpa.nickname_profile(iccid, nickname), "Failed to update eSIM profile nickname") diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index 5e774a84d..bb29b47ad 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -203,7 +203,8 @@ class WifiManager: self._scan_lock = threading.Lock() self._scan_thread = threading.Thread(target=self._network_scanner, daemon=True) self._state_thread = threading.Thread(target=self._monitor_state, daemon=True) - self._initialize() + if not self._exit: + self._initialize() atexit.register(self.stop) def _initialize(self): diff --git a/openpilot/system/ui/widgets/__init__.py b/openpilot/system/ui/widgets/__init__.py index 4e13920d6..e2e44c254 100644 --- a/openpilot/system/ui/widgets/__init__.py +++ b/openpilot/system/ui/widgets/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc +import time import pyray as rl from enum import IntEnum from typing import Protocol, TypeVar @@ -51,6 +52,8 @@ class Widget(abc.ABC): self._click_delay: float | None = None # seconds to hold is_pressed after release self._click_release_time: float | None = None self._click_callback: Callable[[], None] | None = None + self._long_press_callback: Callable[[], None] | None = None + self._press_started: list[float | None] = [None] * MAX_TOUCH_SLOTS self._multi_touch = False self.__was_awake = True @@ -92,6 +95,9 @@ class Widget(abc.ABC): """Set a callback to be called when the widget is clicked.""" self._click_callback = click_callback + def set_long_press_callback(self, callback: Callable[[], None]) -> None: + self._long_press_callback = callback + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: """Set a callback to determine if the widget can be clicked.""" self._touch_valid_callback = touch_callback @@ -136,6 +142,7 @@ class Widget(abc.ABC): self._process_mouse_events() else: # TODO: ideally we emit release events when going disabled + self._press_started = [None] * MAX_TOUCH_SLOTS self.__is_pressed = [False] * MAX_TOUCH_SLOTS self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS @@ -160,6 +167,7 @@ class Widget(abc.ABC): # Allows touch to leave the rect and come back in focus if mouse did not release if mouse_event.left_pressed and touch_valid: if mouse_in_rect: + self._press_started[mouse_event.slot] = mouse_event.t self._handle_mouse_press(mouse_event.pos) self.__is_pressed[mouse_event.slot] = True self.__tracking_is_pressed[mouse_event.slot] = True @@ -185,9 +193,20 @@ class Widget(abc.ABC): # Mouse/touch left our rect but may come back into focus later elif not mouse_in_rect: + self._press_started[mouse_event.slot] = None self.__is_pressed[mouse_event.slot] = False self._handle_mouse_event(mouse_event) + if self._long_press_callback is not None and touch_valid: + for slot, started in enumerate(self._press_started): + if started is not None and self.__is_pressed[slot] and time.monotonic() - started >= 0.45: + # Clear tracking before opening help so release cannot activate a toggle or action. + self._press_started[slot] = None + self.__is_pressed[slot] = False + self.__tracking_is_pressed[slot] = False + self._long_press_callback() + break + def _layout(self) -> None: """Optionally lay out child widgets separately. This is called before rendering.""" diff --git a/openpilot/system/ui/widgets/scroller.py b/openpilot/system/ui/widgets/scroller.py index 55195b30f..e79c4592f 100644 --- a/openpilot/system/ui/widgets/scroller.py +++ b/openpilot/system/ui/widgets/scroller.py @@ -146,9 +146,9 @@ class _Scroller(Widget): # preserve original touch valid callback original_touch_valid_callback = item._touch_valid_callback - item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled and not self._scrolling_to[2] - and not self.moving_items and (original_touch_valid_callback() if - original_touch_valid_callback else True)) + item.set_touch_valid_callback(lambda: self._touch_valid() and self.scroll_panel.is_touch_valid() + and self.enabled and not self._scrolling_to[2] and not self.moving_items + and (original_touch_valid_callback() if original_touch_valid_callback else True)) def add_widgets(self, items: Sequence[Widget]) -> None: for item in items: @@ -397,6 +397,10 @@ class Scroller(Widget): # pass down enabled to child widget for nav stack self._scroller.set_enabled(lambda: self.enabled) + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + super().set_touch_valid_callback(touch_callback) + self._scroller.set_touch_valid_callback(touch_callback) + def _render(self, _, /): self._scroller.render(self._rect) diff --git a/openpilot/tools/cabana/.gitignore b/openpilot/tools/cabana/.gitignore index 7f9ac0fde..c59f39386 100644 --- a/openpilot/tools/cabana/.gitignore +++ b/openpilot/tools/cabana/.gitignore @@ -1,11 +1,5 @@ -moc_* -*.moc -*.generated.qrc - -assets.cc bootstrap_icons.cc -_cabana +cabana dbc/car_fingerprint_to_dbc.json tests/test_cabana -tests/test_dbc_core diff --git a/openpilot/tools/cabana/README.md b/openpilot/tools/cabana/README.md index fbdfbb40e..6fcce59eb 100644 --- a/openpilot/tools/cabana/README.md +++ b/openpilot/tools/cabana/README.md @@ -6,29 +6,27 @@ Cabana is a tool developed to view raw CAN data. One use for this is creating an ```bash $ ./cabana -h -Usage: ./cabana [options] route +Usage: ./cabana [options] [route] + + route the drive to replay. find your drives at connect.comma.ai Options: - -h, --help Displays help on commandline options. - --help-all Displays help including Qt specific options. - --demo use a demo route instead of providing your own - --auto Auto load the route from the best available source (no video): - internal, openpilotci, comma_api, car_segments, testing_closet - --qcam load qcamera - --wide-road load wide road camera - --msgq read can messages from msgq - --panda read can messages from panda - --panda-serial read can messages from panda with given serial - --socketcan read can messages from given SocketCAN device - --zmq read can messages from zmq at the specified ip-address - messages - --data_dir local directory with routes - --no-vipc do not output video - --dbc dbc file to open - -Arguments: - route the drive to replay. find your drives at - connect.comma.ai + --help show this help + --demo use a demo route instead of providing your own + --auto Auto load the route from the best available source (no video): + internal, openpilotci, comma_api, car_segments, testing_closet + --qcam load qcamera + --wide-road load wide road camera (alias: --ecam) + --cabin load cabin camera (alias: --dcam) + --msgq read can messages from the msgq + --panda read can messages from panda + --panda-serial read can messages from panda with given serial + --socketcan read can messages from given SocketCAN device + --zmq read can messages from zmq at the specified ip-address + --data_dir

local directory with routes + --no-vipc do not output video + --no-cache turn off the local route file cache + --dbc dbc file to open ``` ## Examples diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index 387129709..0249a019b 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -1,81 +1,13 @@ -import subprocess import os -import shutil import bootstrap_icons +import imgui import libusb +from openpilot.common.basedir import BASEDIR Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib', 'ffmpeg_libs') -# Detect Qt - skip build if not available -if arch == "Darwin": - try: - brew_prefix = subprocess.check_output(['brew', '--prefix'], encoding='utf8').strip() - has_qt = os.path.isdir(os.path.join(brew_prefix, "opt/qt@5")) - except (FileNotFoundError, subprocess.CalledProcessError): - has_qt = False -else: - has_qt = shutil.which('qmake') is not None -if not has_qt: - Return() - -qt_env = env.Clone() -qt_modules = ["Widgets", "Gui", "Core"] - -qt_libs = [] -if arch == "Darwin": - qt_env['QTDIR'] = f"{brew_prefix}/opt/qt@5" - qt_dirs = [ - os.path.join(qt_env['QTDIR'], "include"), - ] - qt_dirs += [f"{qt_env['QTDIR']}/include/Qt{m}" for m in qt_modules] - qt_env["LINKFLAGS"] += ["-F" + os.path.join(qt_env['QTDIR'], "lib")] - qt_env["FRAMEWORKS"] += [f"Qt{m}" for m in qt_modules] - qt_env.AppendENVPath('PATH', os.path.join(qt_env['QTDIR'], "bin")) -else: - qt_install_prefix = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_PREFIX'], encoding='utf8').strip() - qt_install_headers = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_HEADERS'], encoding='utf8').strip() - - qt_env['QTDIR'] = qt_install_prefix - qt_dirs = [ - f"{qt_install_headers}", - ] - - qt_gui_path = os.path.join(qt_install_headers, "QtGui") - qt_gui_dirs = [d for d in os.listdir(qt_gui_path) if os.path.isdir(os.path.join(qt_gui_path, d))] - qt_dirs += [f"{qt_install_headers}/QtGui/{qt_gui_dirs[0]}/QtGui", ] if qt_gui_dirs else [] - qt_dirs += [f"{qt_install_headers}/Qt{m}" for m in qt_modules] - - qt_libs = [f"Qt5{m}" for m in qt_modules] -qt_env['QT3DIR'] = qt_env['QTDIR'] -qt_env.Tool('qt3') - -qt_env['CPPPATH'] += qt_dirs -qt_flags = [ - "-D_REENTRANT", - "-DQT_NO_DEBUG", - "-DQT_WIDGETS_LIB", - "-DQT_GUI_LIB", - "-DQT_CORE_LIB", - "-DQT_MESSAGELOGCONTEXT", -] -qt_env['CXXFLAGS'] += qt_flags -qt_env['LIBPATH'] += ['#openpilot/selfdrive/ui', ] -qt_env['LIBS'] = qt_libs - -base_frameworks = qt_env['FRAMEWORKS'] -base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] + qt_env["LIBS"] - -if arch == "Darwin": - base_frameworks += ['CoreFoundation', 'CoreVideo', 'CoreMedia', 'IOKit', 'Security', 'VideoToolbox'] - -cabana_env = qt_env.Clone() -cabana_env['CPPPATH'] += [libusb.INCLUDE_DIR] -cabana_env['LIBPATH'] += [libusb.LIB_DIR] - -cabana_libs = [cereal, messaging, visionipc, replay_lib] + ffmpeg_libs + ['usb-1.0'] + base_libs -opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("../../../opendbc_repo/opendbc/dbc").abspath) -cabana_env['CXXFLAGS'] += [opendbc_path] +opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (env.Dir("../../../opendbc_repo/opendbc/dbc").abspath) # embed the bootstrap icons SVG into the binary def build_bootstrap_icons_src(target, source, env): @@ -91,39 +23,55 @@ def build_bootstrap_icons_src(target, source, env): f.write('const size_t bootstrap_icons_svg_len = sizeof(bootstrap_icons_svg);\n') return None -bootstrap_icons_src = cabana_env.Command('assets/bootstrap_icons.cc', str(bootstrap_icons.SVG_PATH), build_bootstrap_icons_src) +bootstrap_icons_src = env.Command('assets/bootstrap_icons.cc', str(bootstrap_icons.SVG_PATH), build_bootstrap_icons_src) -# build assets -assets = "assets/assets.cc" -cabana_env.Command(assets, "assets/assets.qrc", f"rcc $SOURCES -o $TARGET") -cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, "assets/assets.o"])) - -cabana_srcs = ['mainwin.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', - 'streams/routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'dbc/dbcqt.cc', - 'utils/export.cc', 'utils/util.cc', 'utils/elidedlabel.cc', - 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', - 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc', - 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] +# sources shared by the imgui frontend and the core test +core_srcs = ['streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', + 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'utils/export.cc', 'utils/util.cc', 'utils/strings.cc', + 'commands.cc', 'settings.cc', 'routes.cc', 'panda.cc'] if arch != "Darwin": - cabana_srcs += ['streams/socketcanstream.cc'] -cabana_lib = cabana_env.Library("cabana_lib", cabana_srcs + [bootstrap_icons_src], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) -cabana_env.Program('_cabana', ['cabana.cc', cabana_lib, assets], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) + core_srcs += ['streams/socketcanstream.cc'] + +# imgui frontend (tools/cabana/ui), no Qt +ui_env = env.Clone() +ui_env['CPPPATH'] += [imgui.INCLUDE_DIR, libusb.INCLUDE_DIR] +ui_env['LIBPATH'] += [imgui.MESA_DIR, libusb.LIB_DIR] +ui_env['CXXFLAGS'] += [ + opendbc_path, + "-DGLFW_INCLUDE_NONE", + '-DCABANA_FONTS_DIR=\'"%s"\'' % os.path.join(os.path.realpath(BASEDIR), "openpilot", "selfdrive", "assets", "fonts"), + '-DBOOTSTRAP_ICONS_TTF=\'"%s"\'' % bootstrap_icons.TTF_PATH, +] +ui_objs = [ui_env.Object('ui/obj/' + src.replace('/', '_')[:-3], src) for src in core_srcs] +ui_objs += [ui_env.Object('ui/obj/bootstrap_icons', bootstrap_icons_src)] +ui_objs += ui_env.Glob('ui/*.cc') + ui_env.Glob('ui/widgets/*.cc') + ui_env.Glob('ui/dialogs/*.cc') + ui_env.Glob('ui/chart/*.cc') + ui_env.Glob('ui/tools/*.cc') +ui_libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a")] + \ + ffmpeg_libs + ['zstd', 'm', 'pthread', 'usb-1.0'] +if arch == "Darwin": + ui_env['FRAMEWORKS'] = ['OpenGL', 'Cocoa', 'IOKit', 'CoreFoundation', 'CoreVideo', 'CoreMedia', 'Security', 'VideoToolbox'] +else: + ui_libs += ['GL', 'dl'] +cabana_ui = ui_env.Program('cabana', ui_objs, LIBS=ui_libs) if GetOption('extras'): - # This target deliberately uses the base environment and links no Qt libraries. - # It prevents Qt dependencies from creeping back into the DBC core. - dbc_core_test_env = env.Clone() - dbc_core_test_env['CXXFLAGS'] += [opendbc_path] - dbc_core_test_objects = [ - dbc_core_test_env.Object('tests/dbc_core_tests', 'tests/test_cabana.cc'), - dbc_core_test_env.Object('tests/dbc_core_model', 'dbc/dbc.cc'), - dbc_core_test_env.Object('tests/dbc_core_file', 'dbc/dbcfile.cc'), - dbc_core_test_env.Object('tests/dbc_core_manager', 'dbc/dbcmanager.cc'), + cabana_core_test_env = env.Clone() + cabana_core_test_env['CXXFLAGS'] += [opendbc_path] + cabana_core_test_objects = [ + cabana_core_test_env.Object('tests/cabana_core_tests', 'tests/test_cabana.cc'), + cabana_core_test_env.Object('tests/cabana_core_model', 'dbc/dbc.cc'), + cabana_core_test_env.Object('tests/cabana_core_file', 'dbc/dbcfile.cc'), + cabana_core_test_env.Object('tests/cabana_core_manager', 'dbc/dbcmanager.cc'), + cabana_core_test_env.Object('tests/cabana_core_strings', 'utils/strings.cc'), + cabana_core_test_env.Object('tests/cabana_core_util', 'utils/util.cc'), + cabana_core_test_env.Object('tests/cabana_core_icons', bootstrap_icons_src), + cabana_core_test_env.Object('tests/cabana_core_routes', 'routes.cc'), + cabana_core_test_env.Object('tests/cabana_core_qtstate', 'ui/qtstate.cc'), ] - dbc_core_test_env.Program('tests/test_dbc_core', dbc_core_test_objects) + cabana_core_test_env.Program('tests/test_cabana', cabana_core_test_objects, LIBS=[replay_lib, common]) output_json_file = 'openpilot/tools/cabana/dbc/car_fingerprint_to_dbc.json' -generate_dbc = cabana_env.Command('#' + output_json_file, - ['dbc/generate_dbc_json.py'], - "python3 openpilot/tools/cabana/dbc/generate_dbc_json.py --out " + output_json_file) -cabana_env.Depends(generate_dbc, ["#openpilot/common", '#opendbc_repo', "#openpilot/cereal", "#msgq_repo"]) +generate_dbc = ui_env.Command('#' + output_json_file, + ['dbc/generate_dbc_json.py'], + "python3 openpilot/tools/cabana/dbc/generate_dbc_json.py --out " + output_json_file) +ui_env.Depends(generate_dbc, ["#openpilot/common", '#opendbc_repo', "#openpilot/cereal", "#msgq_repo"]) +ui_env.Depends(cabana_ui, generate_dbc) diff --git a/openpilot/tools/cabana/assets/assets.qrc b/openpilot/tools/cabana/assets/assets.qrc deleted file mode 100644 index 009d63f00..000000000 --- a/openpilot/tools/cabana/assets/assets.qrc +++ /dev/null @@ -1,5 +0,0 @@ - - - cabana-icon.png - - diff --git a/openpilot/tools/cabana/cabana b/openpilot/tools/cabana/cabana deleted file mode 100755 index db613b539..000000000 --- a/openpilot/tools/cabana/cabana +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -set -e - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" -ROOT="$(cd "$DIR/../../../" && pwd)" - -install_qt() { - if [[ "$(uname)" == "Darwin" ]]; then - brew install qt@5 - brew link qt@5 || true - else - SUDO="" - if [[ ! $(id -u) -eq 0 ]]; then - SUDO="sudo" - fi - $SUDO apt-get install -y --no-install-recommends \ - qtbase5-dev \ - qtbase5-dev-tools \ - qttools5-dev-tools \ - libqt5charts5-dev \ - libqt5svg5-dev \ - libqt5serialbus5-dev \ - libqt5x11extras5-dev \ - libqt5opengl5-dev - fi -} - -# Install Qt if not found -if ! command -v qmake &> /dev/null; then - echo "Qt not found, installing dependencies..." - install_qt -fi - -# Build _cabana -cd "$ROOT" -scons -u openpilot/tools/cabana/_cabana openpilot/cereal/messaging/bridge - -exec "$DIR/_cabana" "$@" diff --git a/openpilot/tools/cabana/chart/signalselector.h b/openpilot/tools/cabana/chart/signalselector.h deleted file mode 100644 index 5b6e37e56..000000000 --- a/openpilot/tools/cabana/chart/signalselector.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" - -class SignalSelector : public QDialog { -public: - struct ListItem : public QListWidgetItem { - ListItem(const MessageId &msg_id, const cabana::Signal *sig, QListWidget *parent) : msg_id(msg_id), sig(sig), QListWidgetItem(parent) {} - MessageId msg_id; - const cabana::Signal *sig; - }; - - SignalSelector(QString title, QWidget *parent); - std::vector seletedItems(); - inline void addSelected(const MessageId &id, const cabana::Signal *sig) { addItemToList(selected_list, id, sig, true); } - -private: - void updateAvailableList(int index); - void addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name = false); - void add(QListWidgetItem *item); - void remove(QListWidgetItem *item); - - QComboBox *msgs_combo; - QListWidget *available_list; - QListWidget *selected_list; -}; diff --git a/openpilot/tools/cabana/chart/sparkline.h b/openpilot/tools/cabana/chart/sparkline.h deleted file mode 100644 index 7f30047d0..000000000 --- a/openpilot/tools/cabana/chart/sparkline.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "tools/cabana/dbc/dbc.h" -#include "tools/cabana/streams/abstractstream.h" - -class Sparkline { -public: - void update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, QSize size); - inline double freq() const { return freq_; } - bool isEmpty() const { return pixmap.isNull(); } - - QPixmap pixmap; - double min_val = 0; - double max_val = 0; - -private: - void render(const QColor &color, int range, QSize size); - - std::vector points_; - std::vector render_points_; - double freq_ = 0; -}; diff --git a/openpilot/tools/cabana/chart/tiplabel.h b/openpilot/tools/cabana/chart/tiplabel.h deleted file mode 100644 index cc96aa986..000000000 --- a/openpilot/tools/cabana/chart/tiplabel.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once - -#include - -class TipLabel : public QLabel { - Q_OBJECT - -public: - TipLabel(QWidget *parent = nullptr); - void showText(const QPoint &pt, const QString &sec, QWidget *w, const QRect &rect); - void paintEvent(QPaintEvent *ev) override; -}; diff --git a/openpilot/tools/cabana/core/color.h b/openpilot/tools/cabana/core/color.h index c29704dd4..47e7370c5 100644 --- a/openpilot/tools/cabana/core/color.h +++ b/openpilot/tools/cabana/core/color.h @@ -60,7 +60,6 @@ struct CabanaColor { return r == other.r && g == other.g && b == other.b && a == other.a; } -private: struct Hsv { float hue; float saturation; float value; }; Hsv hsv() const { const float red = r / 255.0f, green = g / 255.0f, blue = b / 255.0f; diff --git a/openpilot/tools/cabana/core/message_id.h b/openpilot/tools/cabana/core/message_id.h index ecac27963..e7f4e3c0b 100644 --- a/openpilot/tools/cabana/core/message_id.h +++ b/openpilot/tools/cabana/core/message_id.h @@ -1,7 +1,9 @@ #pragma once +#include #include #include #include +#include #include #include @@ -11,11 +13,18 @@ struct MessageId { uint8_t source = 0; uint32_t address = 0; std::string toString() const { char b[64]; snprintf(b, sizeof(b), "%u:%X", source, address); return b; } - static MessageId fromString(const std::string &s) { - const auto p = s.find(':'); - if (p == std::string::npos) return {}; - return {.source = static_cast(std::stoul(s.substr(0, p))), .address = static_cast(std::stoul(s.substr(p + 1), nullptr, 16))}; + // strict "bus:HEX" parser + static std::optional parse(const std::string &s) { + const auto colon = s.find(':'); + if (colon == std::string::npos) return std::nullopt; + const char *begin = s.data(), *end = begin + s.size(); + uint32_t source = 0, address = 0; + auto bus = std::from_chars(begin, begin + colon, source); + auto addr = std::from_chars(begin + colon + 1, end, address, 16); + if (bus.ec != std::errc() || bus.ptr != begin + colon || source > 255 || addr.ec != std::errc() || addr.ptr != end) return std::nullopt; + return MessageId{static_cast(source), address}; } + static MessageId fromString(const std::string &s) { return parse(s).value_or(MessageId{}); } bool operator==(const MessageId &o) const { return source == o.source && address == o.address; } bool operator!=(const MessageId &o) const { return !(*this == o); } bool operator<(const MessageId &o) const { return std::tie(source, address) < std::tie(o.source, o.address); } diff --git a/openpilot/tools/cabana/core/settings.h b/openpilot/tools/cabana/core/settings.h index cad152de9..6032ee566 100644 --- a/openpilot/tools/cabana/core/settings.h +++ b/openpilot/tools/cabana/core/settings.h @@ -5,22 +5,23 @@ constexpr int LIGHT_THEME = 1; constexpr int DARK_THEME = 2; +constexpr int STREAM_UPDATE_FPS = 30; // rate the streams publish message updates to the UI struct CabanaSettingsState { enum DragDirection { MsbFirst, LsbFirst, AlwaysLE, AlwaysBE }; bool absolute_time = false; - int fps = 10; int max_cached_minutes = 30; int chart_height = 200; int chart_column_count = 1; int chart_range = 3 * 60; int chart_series_type = 0; - int theme = 0; + int theme = LIGHT_THEME; int sparkline_range = 15; bool multiple_lines_hex = false; bool log_livestream = true; bool suppress_defined_signals = false; + bool crop_video = true; std::string log_path; std::string last_dir; std::string last_route_dir; diff --git a/openpilot/tools/cabana/dbc/dbcmanager.cc b/openpilot/tools/cabana/dbc/dbcmanager.cc index 7a95a4f80..fb8c17cf7 100644 --- a/openpilot/tools/cabana/dbc/dbcmanager.cc +++ b/openpilot/tools/cabana/dbc/dbcmanager.cc @@ -141,8 +141,15 @@ std::vector DBCManager::signalNames() { } int DBCManager::nonEmptyDBCCount() { - auto files = allDBCFiles(); - return std::count_if(files.cbegin(), files.cend(), [](auto &f) { return !f->isEmpty(); }); + return nonEmptyDBCFiles().size(); +} + +std::vector DBCManager::nonEmptyDBCFiles() { + std::vector files; + for (auto f : allDBCFiles()) { + if (!f->isEmpty()) files.push_back(f); + } + return files; } DBCFile *DBCManager::findDBCFile(const uint8_t source) { diff --git a/openpilot/tools/cabana/dbc/dbcmanager.h b/openpilot/tools/cabana/dbc/dbcmanager.h index 5a09fae03..c4fe9e218 100644 --- a/openpilot/tools/cabana/dbc/dbcmanager.h +++ b/openpilot/tools/cabana/dbc/dbcmanager.h @@ -49,6 +49,7 @@ public: std::vector signalNames(); inline int dbcCount() { return allDBCFiles().size(); } int nonEmptyDBCCount(); + std::vector nonEmptyDBCFiles(); const SourceSet sources(const DBCFile *dbc_file) const; DBCFile *findDBCFile(const uint8_t source); diff --git a/openpilot/tools/cabana/deqt.md b/openpilot/tools/cabana/deqt.md deleted file mode 100644 index 3bef2d7ab..000000000 --- a/openpilot/tools/cabana/deqt.md +++ /dev/null @@ -1,53 +0,0 @@ -we're migrating cabana away from Qt and to eventually entirely use imgui - -we are doing it incrementally, in small pieces that are easy to execute and verify. -we will repeat this until we're all done. - -# Cabana Qt API inventory - -these are all still in cabana. we remove them from this list once they're gone. -each bullet is an atomic unit of work. - -our workflow is: -- pick the easiest of the bulleted items from below -- implement it and make] sure it builds -- spin up reviewer agents to review the code in a clean context and a separate one to click around in xvfb as a gui test -- then implement the fixes from the above reviewer agents - -some rules -- do not add more Qt usage ever - -- `QObject`, `QMetaObject`, `QMetaType` -- `QApplication`, `QCoreApplication`, `QGuiApplication` -- `QString`, `QStringList`, `QStringBuilder`, `QChar`, `QLatin1Char` -- `QVariant` -- `QTimer` -- `QWidget`, `QMainWindow`, `QWindow` -- `QDialog`, `QDialogButtonBox`, `QMessageBox`, `QProgressDialog` -- `QFileDialog` -- `QMenu`, `QMenuBar`, `QAction`, `QActionGroup`, `QWidgetAction` -- `QToolBar`, `QToolButton`, `QPushButton` -- `QCheckBox`, `QRadioButton`, `QButtonGroup`, `QAbstractButton` -- `QComboBox`, `QLineEdit`, `QTextEdit`, `QSpinBox`, `QSlider` -- `QLabel`, `QGroupBox`, `QFrame` -- `QTabBar`, `QTabWidget`, `QSplitter`, `QScrollArea`, `QScrollBar` -- `QDockWidget`, `QStatusBar`, `QProgressBar` -- `QFormLayout`, `QGridLayout`, `QHBoxLayout`, `QVBoxLayout` -- `QSizePolicy` -- `QAbstractItemModel`, `QAbstractTableModel`, `QModelIndex` -- `QAbstractItemView`, `QTableView`, `QTreeView` -- `QTableWidget`, `QTableWidgetItem`, `QListWidget`, `QListWidgetItem` -- `QItemSelection`, `QItemSelectionModel`, `QItemSelectionRange` -- `QHeaderView`, `QStyledItemDelegate`, `QStyleOptionViewItem` -- `QValidator`, `QIntValidator` -- `QColor`, `QRgb`, `QPalette` -- `QBrush`, `QPen` -- `QPainter`, `QPainterPath`, `QStylePainter` -- `QImage`, `QPixmap`, `QPixmapCache`, `QStaticText` -- `QFont`, `QFontDatabase`, `QFontMetrics`, `QTextDocument` -- `QStyle`, `QStyleOption`, `QStyleOptionFrame`, `QStyleOptionSlider` -- `QPoint`, `QPointF`, `QRect`, `QRectF`, `QRegion` -- `QSize`, `QSizeF` -- `QEvent`, `QPaintEvent`, `QResizeEvent`, `QShowEvent`, `QCloseEvent` -- `QMouseEvent`, `QWheelEvent`, `QNativeGestureEvent`, `QContextMenuEvent` -- `QKeySequence`, `QShortcut`, `QToolTip` diff --git a/openpilot/tools/cabana/settings.cc b/openpilot/tools/cabana/settings.cc index 0c8136b84..6fabe6c0a 100644 --- a/openpilot/tools/cabana/settings.cc +++ b/openpilot/tools/cabana/settings.cc @@ -476,7 +476,6 @@ void writeSetting(json11::Json::object &settings_json, const char *key, const st template void settingsOp(Store &s, SettingOperation op) { op(s, "absolute_time", settings.absolute_time); - op(s, "fps", settings.fps); op(s, "max_cached_minutes", settings.max_cached_minutes); op(s, "chart_height", settings.chart_height); op(s, "chart_range", settings.chart_range); @@ -488,6 +487,7 @@ void settingsOp(Store &s, SettingOperation op) { op(s, "video_splitter_state", settings.video_splitter_state); op(s, "recent_files", settings.recent_files); op(s, "message_header_state", settings.message_header_state); + op(s, "ui_state", settings.ui_state); op(s, "chart_series_type", settings.chart_series_type); op(s, "theme", settings.theme); op(s, "sparkline_range", settings.sparkline_range); @@ -496,6 +496,7 @@ void settingsOp(Store &s, SettingOperation op) { op(s, "log_path", settings.log_path); op(s, "drag_direction", (int &)settings.drag_direction); op(s, "suppress_defined_signals", settings.suppress_defined_signals); + op(s, "crop_video", settings.crop_video); op(s, "recent_dbc_file", settings.recent_dbc_file); op(s, "active_msg_id", settings.active_msg_id); op(s, "selected_msg_ids", settings.selected_msg_ids); @@ -516,7 +517,8 @@ Settings::Settings() { settingsOp(legacy_settings, [](const auto &s, const char *key, auto &value) { readLegacySetting(s, key, value); }); } } - fps = std::clamp(fps, 1, 100); + // settings written before the "Automatic" theme was dropped hold a 0 for it + if (theme != LIGHT_THEME && theme != DARK_THEME) theme = LIGHT_THEME; } // Must be called before main() returns: json11's internal statistics are constructed on first diff --git a/openpilot/tools/cabana/settings.h b/openpilot/tools/cabana/settings.h index 7357ecf4f..27138c089 100644 --- a/openpilot/tools/cabana/settings.h +++ b/openpilot/tools/cabana/settings.h @@ -1,19 +1,13 @@ #pragma once #include +#include #include -#include -#include -#include -#include -#include - +#include "tools/cabana/core/observable.h" #include "tools/cabana/core/settings.h" -class Settings : public QObject, public CabanaSettingsState { - Q_OBJECT - +class Settings : public CabanaSettingsState { public: Settings(); void save(); @@ -24,22 +18,10 @@ public: std::vector window_state; std::vector message_header_state; -signals: - void changed(); -}; + // UI layout state (dock layout, window geometry, table state), owned by the imgui frontend + std::string ui_state; -class SettingsDlg : public QDialog { -public: - SettingsDlg(QWidget *parent); - void save(); - QSpinBox *fps; - QSpinBox *cached_minutes; - QSpinBox *chart_height; - QComboBox *chart_series_type; - QComboBox *theme; - QGroupBox *log_livestream; - QLineEdit *log_path; - QComboBox *drag_direction; + Observable<> changed; }; extern Settings settings; diff --git a/openpilot/tools/cabana/streams/abstractstream.cc b/openpilot/tools/cabana/streams/abstractstream.cc index c58a98084..fd23bf19d 100644 --- a/openpilot/tools/cabana/streams/abstractstream.cc +++ b/openpilot/tools/cabana/streams/abstractstream.cc @@ -140,7 +140,7 @@ bool AbstractStream::isMessageActive(const MessageId &id) const { return delta < 1.5; } - return delta < (5.0 / m.freq) + (1.0 / settings.fps); + return delta < (5.0 / m.freq) + (1.0 / STREAM_UPDATE_FPS); } void AbstractStream::updateLastMsgsTo(double sec) { @@ -208,6 +208,10 @@ void AbstractStream::mergeEvents(const std::vector &events) { msg_events[{.source = e->src, .address = e->address}].push_back(e); } + insertEvents(events, msg_events); +} + +void AbstractStream::insertEvents(const std::vector &events, const MessageEventsMap &msg_events) { if (!events.empty()) { for (const auto &[id, new_e] : msg_events) { if (!new_e.empty()) { diff --git a/openpilot/tools/cabana/streams/abstractstream.h b/openpilot/tools/cabana/streams/abstractstream.h index 82cb89993..45a9af92f 100644 --- a/openpilot/tools/cabana/streams/abstractstream.h +++ b/openpilot/tools/cabana/streams/abstractstream.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -14,16 +15,15 @@ #include "openpilot/cereal/messaging/messaging.h" #include "tools/cabana/core/can_data.h" +#include "tools/cabana/core/observable.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/utils/util.h" #include "tools/replay/util.h" -class AbstractStream : public QObject { - Q_OBJECT - +class AbstractStream { public: - AbstractStream(QObject *parent); - virtual ~AbstractStream() {} + AbstractStream(); + virtual ~AbstractStream() = default; virtual void start() = 0; virtual bool liveStreaming() const { return true; } virtual void seekTo(double ts) {} @@ -42,7 +42,7 @@ public: inline double currentSec() const { return current_sec_; } inline uint64_t toMonoTime(double sec) const { return beginMonoTime() + std::max(sec, 0.0) * 1e9; } - inline double toSeconds(uint64_t mono_time) const { return std::max(0.0, (mono_time - beginMonoTime()) / 1e9); } + inline double toSeconds(uint64_t mono_time) const { return mono_time > beginMonoTime() ? (mono_time - beginMonoTime()) / 1e9 : 0.0; } inline const std::unordered_map &lastMessages() const { return last_msgs; } bool isMessageActive(const MessageId &id) const; @@ -56,22 +56,25 @@ public: void clearSuppressed(); void suppressDefinedSignals(bool suppress); -signals: - void paused(); - void resume(); - void seeking(double sec); - void seekedTo(double sec); - void timeRangeChanged(const std::optional> &range); - void eventsMerged(const MessageEventsMap &events_map); - void msgsReceived(const std::set *new_msgs, bool has_new_ids); - void sourcesUpdated(const SourceSet &s); - void privateUpdateLastMsgsSignal(); + // invoked on the main thread + Observable<> paused; + Observable<> resume; + Observable seeking; + Observable seekedTo; + Observable> &> timeRangeChanged; + Observable eventsMerged; + Observable *, bool> msgsReceived; + Observable error; -public: SourceSet sources; protected: + void postToMainThread(std::function fn); // dropped if the stream is destroyed first + void postToMainThreadAndWait(std::function fn); + void cancelWaits(); // call before joining threads, the main thread isn't pumping events during destruction + void requestUpdateLastMessages() { postToMainThread([this]() { updateLastMessages(); }); } void mergeEvents(const std::vector &events); + void insertEvents(const std::vector &events, const MessageEventsMap &msg_events); const CanEvent *newEvent(uint64_t mono_time, const cereal::CanData::Reader &c); void updateEvent(const MessageId &id, double sec, const uint8_t *data, uint8_t size); void waitForSeekFinshed(); @@ -87,30 +90,21 @@ private: MessageEventsMap events_; std::unordered_map last_msgs; std::unique_ptr event_buffer_; + std::shared_ptr alive_ = std::make_shared(true); + Connections connections_; // Members accessed in multiple threads. (mutex protected) std::mutex mutex_; - std::condition_variable seek_finished_cv_; + std::condition_variable wait_cv_; bool seek_finished_ = false; + bool exiting_ = false; std::set new_msgs_; std::unordered_map messages_; std::unordered_map> masks_; }; -class AbstractOpenStreamWidget : public QWidget { - Q_OBJECT -public: - AbstractOpenStreamWidget(QWidget *parent = nullptr) : QWidget(parent) {} - virtual AbstractStream *open() = 0; - -signals: - void enableOpenButton(bool); -}; - class DummyStream : public AbstractStream { - Q_OBJECT public: - DummyStream(QObject *parent) : AbstractStream(parent) {} std::string routeName() const override { return "No Stream"; } void start() override {} }; diff --git a/openpilot/tools/cabana/streams/livestream.cc b/openpilot/tools/cabana/streams/livestream.cc index 019a67e8f..081e63e24 100644 --- a/openpilot/tools/cabana/streams/livestream.cc +++ b/openpilot/tools/cabana/streams/livestream.cc @@ -9,6 +9,7 @@ #include "common/timing.h" #include "common/util.h" +#include "tools/cabana/settings.h" struct LiveStream::Logger { Logger() : start_ts(seconds_since_epoch()), segment_num(-1) {} @@ -21,12 +22,9 @@ struct LiveStream::Logger { localtime_r(&start_time, &local_time); std::ostringstream date; date << std::put_time(&local_time, "%Y-%m-%d--%H-%M-%S"); - QString dir = QString("%1/%2--%3") - .arg(QString::fromStdString(settings.log_path)) - .arg(QString::fromStdString(date.str())) - .arg(n); - util::create_directories(dir.toStdString(), 0755); - fs.reset(new std::ofstream((dir + "/rlog").toStdString(), std::ios::binary | std::ios::out)); + std::string dir = settings.log_path + "/" + date.str() + "--" + std::to_string(n); + util::create_directories(dir, 0755); + fs.reset(new std::ofstream(dir + "/rlog", std::ios::binary | std::ios::out)); } auto bytes = data.asBytes(); @@ -38,7 +36,7 @@ struct LiveStream::Logger { uint64_t start_ts; }; -LiveStream::LiveStream(QObject *parent) : AbstractStream(parent) { +LiveStream::LiveStream() { if (settings.log_livestream) { logger = std::make_unique(); } @@ -50,7 +48,6 @@ LiveStream::~LiveStream() { void LiveStream::start() { begin_date_time = std::chrono::system_clock::now(); - fps_ = settings.fps; exit_ = false; stream_thread = std::thread(&LiveStream::streamThread, this); update_thread = std::thread(&LiveStream::updateThread, this); @@ -64,10 +61,10 @@ void LiveStream::stop() { void LiveStream::updateThread() { while (!exit_) { - std::this_thread::sleep_for(std::chrono::milliseconds(1000 / fps_)); - // coalesce: skip the emit if the main thread hasn't processed the previous one yet. + std::this_thread::sleep_for(std::chrono::milliseconds(1000 / STREAM_UPDATE_FPS)); + // coalesce: skip the request if the main thread hasn't processed the previous one yet. if (!update_pending_.exchange(true)) { - emit privateUpdateLastMsgsSignal(); + requestUpdateLastMessages(); } } } @@ -89,10 +86,9 @@ void LiveStream::handleEvent(kj::ArrayPtr data) { } } -// called on the main thread by the queued privateUpdateLastMsgsSignal connection +// called on the main thread via requestUpdateLastMessages() void LiveStream::updateLastMessages() { update_pending_ = false; - fps_ = settings.fps; { // merge events received from live stream thread. std::lock_guard lk(lock); @@ -142,10 +138,10 @@ void LiveStream::seekTo(double sec) { first_update_ts = nanos_since_boot(); current_event_ts = first_event_ts = std::min(sec * 1e9 + begin_event_ts, lastest_event_ts); post_last_event = (first_event_ts == lastest_event_ts); - emit seekedTo((current_event_ts - begin_event_ts) / 1e9); + seekedTo((current_event_ts - begin_event_ts) / 1e9); } void LiveStream::pause(bool pause) { paused_ = pause; - emit(pause ? paused() : resume()); + pause ? paused() : resume(); } diff --git a/openpilot/tools/cabana/streams/livestream.h b/openpilot/tools/cabana/streams/livestream.h index 5d65b1743..55b94513b 100644 --- a/openpilot/tools/cabana/streams/livestream.h +++ b/openpilot/tools/cabana/streams/livestream.h @@ -39,7 +39,6 @@ private: std::mutex lock; std::thread stream_thread, update_thread; std::atomic update_pending_ = false; - std::atomic fps_ = 10; std::vector received_events_; std::chrono::system_clock::time_point begin_date_time; diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index 6d54369ae..c0ab46abc 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -1,28 +1,29 @@ #include "tools/cabana/streams/replaystream.h" -#include - -#include -#include -#include -#include -#include +#include #include "common/timing.h" #include "common/util.h" -#include "tools/cabana/streams/routes.h" +#include "tools/cabana/settings.h" -ReplayStream::ReplayStream(QObject *parent) : AbstractStream(parent) { +ReplayStream::ReplayStream() { unsetenv("ZMQ"); setenv("COMMA_CACHE", "/tmp/comma_download_cache", 1); op_prefix = std::make_unique(); - QObject::connect(&settings, &Settings::changed, this, [this]() { + settings_connection_ = settings.changed.connect([this]() { if (replay) replay->setSegmentCacheLimit(settings.max_cached_minutes); }); } +ReplayStream::~ReplayStream() { + cancelWaits(); + if (replay) replay->stop(); +} + +// runs on replay's merge thread: a segment of CAN data takes ~30 ms to parse and group, which dropped +// frames when it ran on the main thread. Only the sorted insert and the merged signal need the main thread. void ReplayStream::mergeSegments() { auto event_data = replay->getEventData(); for (const auto &[n, seg] : event_data->segments) { @@ -31,16 +32,19 @@ void ReplayStream::mergeSegments() { std::vector new_events; new_events.reserve(seg->log->events.size()); + MessageEventsMap msg_events; for (const Event &e : seg->log->events) { if (e.which == cereal::Event::Which::CAN) { capnp::FlatArrayMessageReader reader(e.data); auto event = reader.getRoot(); for (const auto &c : event.getCan()) { - new_events.push_back(newEvent(e.mono_time, c)); + const CanEvent *ce = newEvent(e.mono_time, c); + new_events.push_back(ce); + msg_events[{.source = ce->src, .address = ce->address}].push_back(ce); } } } - mergeEvents(new_events); + postToMainThreadAndWait([&]() { insertEvents(new_events, msg_events); }); } } } @@ -51,38 +55,36 @@ bool ReplayStream::loadRoute(const std::string &route, const std::string &data_d replay->setSegmentCacheLimit(settings.max_cached_minutes); replay->installEventFilter([this](const Event *event) { return eventFilter(event); }); - // Forward replay callbacks to corresponding Qt signals. - replay->onSeeking = [this](double sec) { emit seeking(sec); }; + // replay callbacks arrive on replay threads + replay->onSeeking = [this](double sec) { postToMainThread([this, sec]() { seeking(sec); }); }; replay->onSeekedTo = [this](double sec) { - emit seekedTo(sec); + postToMainThread([this, sec]() { seekedTo(sec); }); waitForSeekFinshed(); }; - replay->onQLogLoaded = [this](std::shared_ptr qlog) { emit qLogLoaded(qlog); }; - replay->onSegmentsMerged = [this]() { QMetaObject::invokeMethod(this, &ReplayStream::mergeSegments, Qt::BlockingQueuedConnection); }; + replay->onQLogLoaded = [this](std::shared_ptr qlog) { postToMainThread([this, qlog]() { qLogLoaded(qlog); }); }; + replay->onSegmentsMerged = [this]() { mergeSegments(); }; bool success = replay->load(); if (!success) { + std::string message; if (replay->lastRouteError() == RouteLoadError::Unauthorized) { auto auth_content = util::read_file(util::getenv("HOME") + "/.comma/auth.json"); - QString message; if (auth_content.empty()) { message = "Authentication Required. Please run the following command to authenticate:\n\n" "python3 openpilot/tools/lib/auth.py\n\n" "This will grant access to routes from your comma account."; } else { - message = tr("Access Denied. You do not have permission to access route:\n\n%1\n\n" - "This is likely a private route.").arg(QString::fromStdString(route)); + message = "Access Denied. You do not have permission to access route:\n\n" + route + "\n\n" + "This is likely a private route."; } - QMessageBox::warning(nullptr, tr("Access Denied"), message); } else if (replay->lastRouteError() == RouteLoadError::NetworkError) { - QMessageBox::warning(nullptr, tr("Network Error"), - tr("Unable to load the route:\n\n %1.\n\nPlease check your network connection and try again.").arg(QString::fromStdString(route))); + message = "Unable to load the route:\n\n " + route + ".\n\nPlease check your network connection and try again."; } else if (replay->lastRouteError() == RouteLoadError::FileNotFound) { - QMessageBox::warning(nullptr, tr("Route Not Found"), - tr("The specified route could not be found:\n\n %1.\n\nPlease check the route name and try again.").arg(QString::fromStdString(route))); + message = "The specified route could not be found:\n\n " + route + ".\n\nPlease check the route name and try again."; } else { - QMessageBox::warning(nullptr, tr("Route Load Failed"), tr("Failed to load route: '%1'").arg(QString::fromStdString(route))); + message = "Failed to load route: '" + route + "'"; } + error(message); } return success; } @@ -101,8 +103,8 @@ bool ReplayStream::eventFilter(const Event *event) { } double ts = millis_since_boot(); - if ((ts - prev_update_ts) > (1000.0 / settings.fps)) { - emit privateUpdateLastMsgsSignal(); + if ((ts - prev_update_ts) > (1000.0 / STREAM_UPDATE_FPS)) { + requestUpdateLastMessages(); prev_update_ts = ts; } return true; @@ -110,66 +112,5 @@ bool ReplayStream::eventFilter(const Event *event) { void ReplayStream::pause(bool pause) { replay->pause(pause); - emit(pause ? paused() : resume()); -} - - -// OpenReplayWidget - -OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QGridLayout *grid_layout = new QGridLayout(this); - grid_layout->addWidget(new QLabel(tr("Route")), 0, 0); - grid_layout->addWidget(route_edit = new QLineEdit(this), 0, 1); - route_edit->setPlaceholderText(tr("Enter route name or browse for local/remote route")); - auto browse_remote_btn = new QPushButton(tr("Remote route..."), this); - grid_layout->addWidget(browse_remote_btn, 0, 2); - auto browse_local_btn = new QPushButton(tr("Local route..."), this); - grid_layout->addWidget(browse_local_btn, 0, 3); - - QHBoxLayout *camera_layout = new QHBoxLayout(); - for (auto c : {tr("Road camera"), tr("Driver camera"), tr("Wide road camera")}) - camera_layout->addWidget(cameras.emplace_back(new QCheckBox(c, this))); - cameras[0]->setChecked(true); - camera_layout->addStretch(1); - grid_layout->addItem(camera_layout, 1, 1); - - setMinimumWidth(550); - QObject::connect(browse_local_btn, &QPushButton::clicked, [=]() { - QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), QString::fromStdString(settings.last_route_dir)); - if (!dir.isEmpty()) { - route_edit->setText(dir); - settings.last_route_dir = std::filesystem::absolute(dir.toStdString()).parent_path().string(); - } - }); - QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() { - RoutesDialog route_dlg(this); - if (route_dlg.exec()) { - route_edit->setText(route_dlg.route()); - } - }); -} - -AbstractStream *OpenReplayWidget::open() { - QString route = route_edit->text(); - QString data_dir; - if (int idx = route.lastIndexOf('/'); idx != -1 && util::file_exists(route.toStdString())) { - data_dir = route.mid(0, idx + 1); - route = route.mid(idx + 1); - } - - bool is_valid_format = Route::parseRoute(route.toStdString()).str.size() > 0; - if (!is_valid_format) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Invalid route format: '%1'").arg(route)); - } else { - auto replay_stream = std::make_unique(qApp); - uint32_t flags = REPLAY_FLAG_NONE; - if (cameras[1]->isChecked()) flags |= REPLAY_FLAG_CABIN_CAMERA; - if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_WIDE_ROAD; - if (flags == REPLAY_FLAG_NONE && !cameras[0]->isChecked()) flags = REPLAY_FLAG_NO_VIPC; - - if (replay_stream->loadRoute(route.toStdString(), data_dir.toStdString(), flags)) { - return replay_stream.release(); - } - } - return nullptr; + pause ? paused() : resume(); } diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 53be1b0af..268aa9a86 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -1,13 +1,37 @@ +#include +#include +#include +#include +#include #include #include +#include +#include #include "common/tests/native_test.h" #include "tools/cabana/dbc/dbcfile.h" #include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/routes.h" +#include "tools/cabana/ui/qtstate.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/chart/downsample.h" +#include "tools/cabana/utils/strings.h" const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; +void test_message_id_parsing() { + for (const auto &text : {"", "1", ":123", "1:", "-1:123", "256:1", "1:100000000", "1:1junk", "1junk:1", "1:1:1"}) { + REQUIRE(!MessageId::parse(text)); + REQUIRE(MessageId::fromString(text) == MessageId{}); + } + const MessageId expected{255, 0xffffffff}; + REQUIRE(MessageId::parse("255:FFFFFFFF") == expected); + REQUIRE(MessageId::parse("255:ffffffff") == expected); + REQUIRE(MessageId::parse(expected.toString()) == expected); + REQUIRE(MessageId::parse("0:0") == MessageId{}); +} + void test_generate_dbc() { std::string fn = std::string(OPENDBC_FILE_PATH) + "/tesla_can.dbc"; DBCFile dbc_origin(fn); @@ -164,11 +188,10 @@ void test_dbc_manager() { int files_changed = 0; int signals_added = 0; int masks_updated = 0; - manager.setCallbacks({ - .signal_added = [&](MessageId, const cabana::Signal *) { ++signals_added; }, - .file_changed = [&]() { ++files_changed; }, - .mask_updated = [&]() { ++masks_updated; }, - }); + Connections connections; + connections.push_back(manager.signalAdded.connect([&](MessageId, const cabana::Signal *) { ++signals_added; })); + connections.push_back(manager.fileChanged.connect([&]() { ++files_changed; })); + connections.push_back(manager.maskUpdated.connect([&]() { ++masks_updated; })); std::string error; REQUIRE(manager.open(SOURCE_ALL, "test", "BO_ 160 message: 8 XXX\n", &error)); @@ -186,7 +209,216 @@ void test_dbc_manager() { REQUIRE(manager.msg({.source = 0, .address = 160})->sig("speed") != nullptr); } +void test_format_seconds() { + REQUIRE(utils::formatSeconds(0) == "00:00"); + REQUIRE(utils::formatSeconds(59.4) == "00:59"); + REQUIRE(utils::formatSeconds(-1) == "00:00"); + REQUIRE(utils::formatSeconds(61.234, true) == "01:01.23"); + REQUIRE(utils::formatSeconds(3599.9) == "59:59"); + REQUIRE(utils::formatSeconds(3601) == "01:00:01"); + REQUIRE(utils::formatSeconds(3601.5, true) == "01:00:01.50"); + + const char *tz = getenv("TZ"); + const bool had_tz = tz != nullptr; + const std::string saved_tz = had_tz ? tz : ""; + setenv("TZ", "UTC", 1); + tzset(); + REQUIRE(utils::formatSeconds(0, false, true) == "1970-01-01 00:00:00"); + REQUIRE(utils::formatSeconds(1700000000.123, true, true) == "2023-11-14 22:13:20.12"); + if (had_tz) { + setenv("TZ", saved_tz.c_str(), 1); + } else { + unsetenv("TZ"); + } + tzset(); +} + +void test_to_hex() { + REQUIRE(utils::toHex({}) == ""); + REQUIRE(utils::toHex({0x00, 0x0f, 0xab, 0xff}) == "000FABFF"); + REQUIRE(utils::toHex({0x01, 0x02, 0x03}, ' ') == "01 02 03"); + + REQUIRE(utils::toHexString(0) == "0x00"); + REQUIRE(utils::toHexString(0xf) == "0x0F"); + REQUIRE(utils::toHexString(0x1ab) == "0x1AB"); + REQUIRE(utils::toHexString(0x1fffffff) == "0x1FFFFFFF"); +} + +void test_signal_tooltip() { + cabana::Signal sig{}; + sig.name = "speed"; + sig.start_bit = 3; + sig.size = 12; + sig.msb = 14; + sig.lsb = 3; + sig.is_little_endian = true; + sig.is_signed = false; + REQUIRE(utils::signalToolTip(&sig) == R"( + speed
+ Start Bit: 3 Size: 12
+ MSB: 14 LSB: 3
+ Little Endian: Y Signed: N
+ )"); +} + +void test_route_timestamps() { + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05Z") == 1704164645000); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05") == 1704164645000); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02 03:04:05") == 1704164645000); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05.123Z") == 1704164645123); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05.4Z") == 1704164645400); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05.123456Z") == 1704164645123); + REQUIRE(routes::parseIsoToUnixMs("") == 0); + REQUIRE(routes::parseIsoToUnixMs("not a timestamp") == 0); + + // formatUnixMs is local time + const char *tz = getenv("TZ"); + const std::string prev_tz = tz ? tz : ""; + setenv("TZ", "UTC", 1); + tzset(); + REQUIRE(routes::formatUnixMs(1704164645123) == "2024-01-02 03:04:05"); + if (tz) { + setenv("TZ", prev_tz.c_str(), 1); + } else { + unsetenv("TZ"); + } + tzset(); +} + +void test_route_api_response() { + REQUIRE(routes::checkApiResponse("") == std::make_pair(false, 500)); + REQUIRE(routes::checkApiResponse("not json") == std::make_pair(false, 500)); + REQUIRE(routes::checkApiResponse(R"({"error": "unauthorized"})") == std::make_pair(false, 401)); + REQUIRE(routes::checkApiResponse(R"({"error": "server error"})") == std::make_pair(false, 500)); + REQUIRE(routes::checkApiResponse("[]") == std::make_pair(true, 0)); + REQUIRE(routes::checkApiResponse(R"({"dongle_id": "aaaa"})") == std::make_pair(true, 0)); +} + +void test_route_json() { + auto devices = routes::parseDevices(R"([{"dongle_id": "aaaa"}, {"dongle_id": "bbbb"}])"); + REQUIRE(devices.size() == 2); + REQUIRE(devices[0].dongle_id == "aaaa"); + REQUIRE(devices[1].dongle_id == "bbbb"); + REQUIRE(routes::parseDevices("not json").empty()); + REQUIRE(routes::parseDevices(R"({"error": "unauthorized"})").empty()); + + auto list = routes::parseRoutes( + R"([{"fullname": "aaaa|2024-01-02--03-04-05", "start_time_utc_millis": 1704164645000, "end_time_utc_millis": 1704165245000}])", false); + REQUIRE(list.size() == 1); + REQUIRE(list[0].name == "aaaa|2024-01-02--03-04-05"); + REQUIRE(list[0].start_ms == 1704164645000); + REQUIRE(list[0].end_ms == 1704165245000); + + // preserved routes report ISO-8601 timestamps + auto preserved = routes::parseRoutes( + R"([{"fullname": "aaaa|2024-01-02--03-04-05", "start_time": "2024-01-02T03:04:05Z", "end_time": "2024-01-02T03:14:05Z"}])", true); + REQUIRE(preserved.size() == 1); + REQUIRE(preserved[0].start_ms == 1704164645000); + REQUIRE(preserved[0].end_ms == 1704165245000); + + REQUIRE(routes::parseRoutes("not json", false).empty()); +} + +static std::vector fromHex(const std::string &hex) { + std::vector out; + for (size_t i = 0; i + 1 < hex.size(); i += 2) { + out.push_back((uint8_t)std::stoul(hex.substr(i, 2), nullptr, 16)); + } + return out; +} + +void test_qt_state_blobs() { + // blobs written by the Qt frontend + auto geometry = qtstate::parseQtGeometry(fromHex( + "01d9d0cb000300000000000000000014000004ff000003330000000000000014000004ff" + "00000333000000000000000006400000000000000014000004ff00000333")); + REQUIRE(geometry.has_value()); + REQUIRE(geometry->x == 0); + REQUIRE(geometry->y == 20); + REQUIRE(geometry->w == 1280); + REQUIRE(geometry->h == 800); + REQUIRE(geometry->maximized == false); + + auto splitter = qtstate::parseQtSplitter(fromHex("000000ff0000000100000002000000960000006801ffffffff010000000200")); + REQUIRE(splitter.has_value()); + REQUIRE(std::fabs(splitter->ratio - 150.0f / 254.0f) < 1e-6f); + + auto header = qtstate::parseQtHeaderState(fromHex( + "000000ff000000000000000100000000000000000100000000000000000000000000000000000003360000000701" + "01000100000000000000000000000068ffffffff0000008400000000000000070000006800000001000000000000" + "00680000000100000000000000680000000100000000000000680000000100000000000000680000000100000000" + "000000680000000100000000000000c60000000100000002000003e800000000c6")); + REQUIRE(header.has_value()); + REQUIRE(header->sort_section == 0); + REQUIRE(header->sort_order == 0); + REQUIRE(header->sort_shown == true); + const int expected_width[] = {104, 104, 104, 104, 104, 104, 198}; + for (int i = 0; i < qtstate::kMessageColumnCount; ++i) { + REQUIRE(header->visual[i] == i); + REQUIRE(header->width[i] == expected_width[i]); + REQUIRE(header->hidden[i] == false); + } + + // empty, truncated and wrong magic blobs are rejected + REQUIRE(!qtstate::parseQtGeometry({}).has_value()); + REQUIRE(!qtstate::parseQtSplitter({}).has_value()); + REQUIRE(!qtstate::parseQtHeaderState({}).has_value()); + REQUIRE(!qtstate::parseQtGeometry(fromHex("01d9d0cb00030000000000000000")).has_value()); + REQUIRE(!qtstate::parseQtSplitter(fromHex("000000ff000000010000000200000096")).has_value()); + REQUIRE(!qtstate::parseQtHeaderState(fromHex("000000ff0000000000000001000000000000000001")).has_value()); + REQUIRE(!qtstate::parseQtGeometry(fromHex("deadbeef000300000000000000000014000004ff00000333")).has_value()); + REQUIRE(!qtstate::parseQtSplitter(fromHex("000000fe0000000100000002000000960000006801ffffffff010000000200")).has_value()); + REQUIRE(!qtstate::parseQtHeaderState(fromHex("000000fe00000000000000010000000000000000010000000000000000")).has_value()); +} + +void test_parallel_failure_joins_workers() { + for (bool fail_on_caller : {true, false}) { + std::atomic finished = 0; + const size_t chunks = std::clamp(std::thread::hardware_concurrency(), 2, 4) + 1; + bool caught = false; + try { + parallelFor(chunks, [&](size_t begin, size_t end) { + if (begin == (fail_on_caller ? 0u : 1u)) throw std::runtime_error("task failed"); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + ++finished; + }); + } catch (const std::runtime_error &) { + caught = true; + } + REQUIRE(caught); + REQUIRE(finished == chunks - 1); + } +} + +void test_pixel_envelope() { + struct Point { + double x, y; + Point(double x, double y) : x(x), y(y) {} + }; + std::vector points; + for (int i = 0; i < 1000; ++i) points.emplace_back(i * 0.001, i == 203 ? 99 : i == 201 ? -99 : 0); + const auto result = chart::pixelEnvelope(points.begin(), points.end(), 0, 1, 10); + REQUIRE(result.size() <= 40); + REQUIRE(result.front().x == points.front().x); + REQUIRE(result.back().x == points.back().x); + REQUIRE(std::is_sorted(result.begin(), result.end(), [](const auto &a, const auto &b) { return a.x < b.x; })); + REQUIRE(std::any_of(result.begin(), result.end(), [](const auto &p) { return p.x == .201 && p.y == -99; })); + REQUIRE(std::any_of(result.begin(), result.end(), [](const auto &p) { return p.x == .203 && p.y == 99; })); + const std::vector step{{-1, 0}, {0, 0}, {0, 10}, {0, -10}, {0, 0}, {2, 0}}; + const auto edge = chart::pixelEnvelope(step.begin(), step.end(), 0, 1, 2); + REQUIRE(edge.front().x == -1); + REQUIRE(edge.back().x == 2); + REQUIRE(std::any_of(edge.begin(), edge.end(), [](const auto &p) { return p.y == 10; })); + REQUIRE(std::any_of(edge.begin(), edge.end(), [](const auto &p) { return p.y == -10; })); + REQUIRE(chart::pixelEnvelope(points.begin(), points.begin(), 0, 1, 10).empty()); +} + void test_cabana_core() { + test_pixel_envelope(); + test_format_seconds(); + test_to_hex(); + test_message_id_parsing(); + test_signal_tooltip(); test_generate_dbc(); test_comment_order(); test_preserve_original_header(); @@ -194,6 +426,11 @@ void test_cabana_core() { test_parse_dbc(); test_parse_opendbc(); test_dbc_manager(); + test_route_timestamps(); + test_route_api_response(); + test_route_json(); + test_parallel_failure_joins_workers(); + test_qt_state_blobs(); } int main() { diff --git a/openpilot/tools/cabana/tests/test_cabana_ui.py b/openpilot/tools/cabana/tests/test_cabana_ui.py new file mode 100644 index 000000000..aecd7e6ed --- /dev/null +++ b/openpilot/tools/cabana/tests/test_cabana_ui.py @@ -0,0 +1,13 @@ +import subprocess +from pathlib import Path + +from openpilot.common.test import OpenpilotTestCase + +CABANA_DIR = Path(__file__).parent.parent + + +class TestCabanaUi(OpenpilotTestCase): + def test_help(self): + result = subprocess.run(["./cabana", "-h"], cwd=CABANA_DIR, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert "Usage:" in result.stderr diff --git a/openpilot/tools/cabana/tools/findsimilarbits.cc b/openpilot/tools/cabana/tools/findsimilarbits.cc deleted file mode 100644 index 8062b6119..000000000 --- a/openpilot/tools/cabana/tools/findsimilarbits.cc +++ /dev/null @@ -1,161 +0,0 @@ -#include "tools/cabana/tools/findsimilarbits.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" - -FindSimilarBitsDlg::FindSimilarBitsDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags() | Qt::Window) { - setWindowTitle(tr("Find similar bits")); - setAttribute(Qt::WA_DeleteOnClose); - - QVBoxLayout *main_layout = new QVBoxLayout(this); - - QHBoxLayout *src_layout = new QHBoxLayout(); - src_bus_combo = new QComboBox(this); - find_bus_combo = new QComboBox(this); - for (auto cb : {src_bus_combo, find_bus_combo}) { - for (uint8_t bus : can->sources) { - cb->addItem(QString::number(bus), bus); - } - } - - msg_cb = new QComboBox(this); - // TODO: update when src_bus_combo changes - for (auto &[address, msg] : dbc()->getMessages(-1)) { - msg_cb->addItem(QString::fromStdString(msg.name), address); - } - msg_cb->model()->sort(0); - msg_cb->setCurrentIndex(0); - - byte_idx_sb = new QSpinBox(this); - byte_idx_sb->setFixedWidth(50); - byte_idx_sb->setRange(0, 63); - - bit_idx_sb = new QSpinBox(this); - bit_idx_sb->setFixedWidth(50); - bit_idx_sb->setRange(0, 7); - - src_layout->addWidget(new QLabel(tr("Bus"))); - src_layout->addWidget(src_bus_combo); - src_layout->addWidget(msg_cb); - src_layout->addWidget(new QLabel(tr("Byte Index"))); - src_layout->addWidget(byte_idx_sb); - src_layout->addWidget(new QLabel(tr("Bit Index"))); - src_layout->addWidget(bit_idx_sb); - src_layout->addStretch(0); - - QHBoxLayout *find_layout = new QHBoxLayout(); - find_layout->addWidget(new QLabel(tr("Bus"))); - find_layout->addWidget(find_bus_combo); - find_layout->addWidget(new QLabel(tr("Equal"))); - equal_combo = new QComboBox(this); - equal_combo->addItems({"Yes", "No"}); - find_layout->addWidget(equal_combo); - min_msgs = new QLineEdit(this); - min_msgs->setValidator(new QIntValidator(this)); - min_msgs->setText("100"); - find_layout->addWidget(new QLabel(tr("Min msg count"))); - find_layout->addWidget(min_msgs); - search_btn = new QPushButton(tr("&Find"), this); - find_layout->addWidget(search_btn); - find_layout->addStretch(0); - - QGridLayout *grid_layout = new QGridLayout(); - grid_layout->addWidget(new QLabel("Find From:"), 0, 0); - grid_layout->addLayout(src_layout, 0, 1); - grid_layout->addWidget(new QLabel("Find In:"), 1, 0); - grid_layout->addLayout(find_layout, 1, 1); - main_layout->addLayout(grid_layout); - - table = new QTableWidget(this); - table->setSelectionBehavior(QAbstractItemView::SelectRows); - table->setSelectionMode(QAbstractItemView::SingleSelection); - table->setEditTriggers(QAbstractItemView::NoEditTriggers); - table->horizontalHeader()->setStretchLastSection(true); - main_layout->addWidget(table); - - setMinimumSize({700, 500}); - QObject::connect(search_btn, &QPushButton::clicked, this, &FindSimilarBitsDlg::find); - QObject::connect(table, &QTableWidget::doubleClicked, [this](const QModelIndex &index) { - if (index.isValid()) { - MessageId msg_id = {.source = (uint8_t)find_bus_combo->currentData().toUInt(), .address = table->item(index.row(), 0)->text().toUInt(0, 16)}; - emit openMessage(msg_id); - } - }); -} - -void FindSimilarBitsDlg::find() { - search_btn->setEnabled(false); - table->clear(); - uint32_t selected_address = msg_cb->currentData().toUInt(); - auto msg_mismatched = calcBits(src_bus_combo->currentText().toUInt(), selected_address, byte_idx_sb->value(), bit_idx_sb->value(), - find_bus_combo->currentText().toUInt(), equal_combo->currentIndex() == 0, min_msgs->text().toInt()); - table->setRowCount(msg_mismatched.size()); - table->setColumnCount(6); - table->setHorizontalHeaderLabels({"address", "byte idx", "bit idx", "mismatches", "total msgs", "% mismatched"}); - for (int i = 0; i < msg_mismatched.size(); ++i) { - auto &m = msg_mismatched[i]; - table->setItem(i, 0, new QTableWidgetItem(QString("%1").arg(m.address, 1, 16))); - table->setItem(i, 1, new QTableWidgetItem(QString::number(m.byte_idx))); - table->setItem(i, 2, new QTableWidgetItem(QString::number(m.bit_idx))); - table->setItem(i, 3, new QTableWidgetItem(QString::number(m.mismatches))); - table->setItem(i, 4, new QTableWidgetItem(QString::number(m.total))); - table->setItem(i, 5, new QTableWidgetItem(QString::number(m.perc, 'f', 2))); - } - search_btn->setEnabled(true); -} - -std::vector FindSimilarBitsDlg::calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, - int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt) { - std::unordered_map> mismatches; - std::unordered_map msg_count; - const auto &events = can->allEvents(); - int bit_to_find = -1; - for (const CanEvent *e : events) { - if (e->src == bus) { - if (e->address == selected_address && e->size > byte_idx) { - bit_to_find = ((e->dat[byte_idx] >> (7 - bit_idx)) & 1) != 0; - } - } - if (e->src == find_bus) { - ++msg_count[e->address]; - if (bit_to_find == -1) continue; - - auto &mismatched = mismatches[e->address]; - if (mismatched.size() < e->size * 8) { - mismatched.resize(e->size * 8); - } - for (int i = 0; i < e->size; ++i) { - for (int j = 0; j < 8; ++j) { - int bit = ((e->dat[i] >> (7 - j)) & 1) != 0; - mismatched[i * 8 + j] += equal ? (bit != bit_to_find) : (bit == bit_to_find); - } - } - } - } - - std::vector result; - result.reserve(mismatches.size()); - for (auto it = mismatches.begin(); it != mismatches.end(); ++it) { - if (auto cnt = msg_count[it->first]; cnt > (uint32_t)min_msgs_cnt) { - auto &mismatched = it->second; - for (int i = 0; i < (int)mismatched.size(); ++i) { - if (float perc = (mismatched[i] / (double)cnt) * 100; perc < 50) { - result.push_back({it->first, (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc}); - } - } - } - } - std::sort(result.begin(), result.end(), [](auto &l, auto &r) { return l.perc < r.perc; }); - return result; -} diff --git a/openpilot/tools/cabana/tools/findsimilarbits.h b/openpilot/tools/cabana/tools/findsimilarbits.h deleted file mode 100644 index 345136065..000000000 --- a/openpilot/tools/cabana/tools/findsimilarbits.h +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" - -class FindSimilarBitsDlg : public QDialog { - Q_OBJECT - -public: - FindSimilarBitsDlg(QWidget *parent); - -signals: - void openMessage(const MessageId &msg_id); - -private: - struct mismatched_struct { - uint32_t address, byte_idx, bit_idx, mismatches, total; - float perc; - }; - std::vector calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, int bit_idx, uint8_t find_bus, - bool equal, int min_msgs_cnt); - void find(); - - QTableWidget *table; - QComboBox *src_bus_combo, *find_bus_combo, *msg_cb, *equal_combo; - QSpinBox *byte_idx_sb, *bit_idx_sb; - QPushButton *search_btn; - QLineEdit *min_msgs; -}; diff --git a/openpilot/tools/cabana/tools/routeinfo.h b/openpilot/tools/cabana/tools/routeinfo.h deleted file mode 100644 index 36f32b4bf..000000000 --- a/openpilot/tools/cabana/tools/routeinfo.h +++ /dev/null @@ -1,8 +0,0 @@ -#pragma once -#include - -class RouteInfoDlg : public QDialog { - Q_OBJECT -public: - RouteInfoDlg(QWidget *parent = nullptr); -}; diff --git a/openpilot/tools/cabana/ui/app.cc b/openpilot/tools/cabana/ui/app.cc new file mode 100644 index 000000000..beb693b1e --- /dev/null +++ b/openpilot/tools/cabana/ui/app.cc @@ -0,0 +1,203 @@ +#include "tools/cabana/ui/app.h" + +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_impl_glfw.h" +#include "imgui_impl_opengl3.h" +#include "imgui_impl_opengl3_loader.h" +#include "implot.h" +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/inistate.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/mainwin.h" +#include "tools/cabana/utils/util.h" + +namespace { + +std::atomic g_signal_exit{false}; +std::vector g_key_events; +void keyCallback(GLFWwindow *window, int key, int scancode, int action, int mods) { + ImGui_ImplGlfw_KeyCallback(window, key, scancode, action, mods); + if (action == GLFW_PRESS) g_key_events.push_back({key, mods}); +} + +void hookViewportCallbacks() { + for (ImGuiViewport *viewport : ImGui::GetPlatformIO().Viewports) { + if (viewport->PlatformHandle == nullptr || viewport == ImGui::GetMainViewport()) continue; + glfwSetKeyCallback((GLFWwindow *)viewport->PlatformHandle, keyCallback); + } +} + +void glfwErrorCallback(int error, const char *description) { + fprintf(stderr, "GLFW error %d: %s\n", error, description); +} + +void paceFrame() { + using clock = std::chrono::steady_clock; + static clock::duration period = [] { + const GLFWvidmode *mode = glfwGetVideoMode(glfwGetPrimaryMonitor()); + int hz = (mode != nullptr && mode->refreshRate > 0) ? mode->refreshRate : 60; + return std::chrono::duration_cast(std::chrono::duration(1.0 / hz)); + }(); + static clock::time_point next = clock::now(); + next += period; + auto now = clock::now(); + if (next < now) { + next = now; // fell behind (slow frame or hidden window): don't try to catch up + return; + } + std::this_thread::sleep_until(next); +} + +void renderFrame(GLFWwindow *window, MainWindow *win) { + glfwPollEvents(); + utils::drainMainThreadQueue(); + + int fb_w = 0, fb_h = 0; + glfwGetFramebufferSize(window, &fb_w, &fb_h); + + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplGlfw_NewFrame(); + ImGui::NewFrame(); + win->draw(); + ImGui::Render(); + + const ImVec4 &bg = ImGui::GetStyle().Colors[ImGuiCol_WindowBg]; + glViewport(0, 0, fb_w, fb_h); + glClearColor(bg.x, bg.y, bg.z, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + + if (ImGui::GetIO().ConfigFlags & ImGuiConfigFlags_ViewportsEnable) { + GLFWwindow *backup_context = glfwGetCurrentContext(); + ImGui::UpdatePlatformWindows(); + hookViewportCallbacks(); + ImGui::RenderPlatformWindowsDefault(); + glfwMakeContextCurrent(backup_context); + } + glfwSwapBuffers(window); + paceFrame(); +} + +class GlfwRuntime { +public: + GlfwRuntime() { + glfwSetErrorCallback(glfwErrorCallback); +#ifdef __APPLE__ + setMacAppName("Cabana"); +#endif + if (!glfwInit()) throw std::runtime_error("glfwInit failed"); + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); +#ifdef __APPLE__ + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); +#endif + // Restore geometry and render the initial layout before mapping the window. + glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE); + window_ = glfwCreateWindow(1600, 900, "Cabana", nullptr, nullptr); + if (window_ == nullptr) { + glfwTerminate(); + throw std::runtime_error("glfwCreateWindow failed"); + } + glfwMakeContextCurrent(window_); + glfwSwapInterval(0); + } + + ~GlfwRuntime() { + if (window_ != nullptr) glfwDestroyWindow(window_); + glfwTerminate(); + } + + GlfwRuntime(const GlfwRuntime &) = delete; + GlfwRuntime &operator=(const GlfwRuntime &) = delete; + GLFWwindow *window() const { return window_; } + +private: + GLFWwindow *window_ = nullptr; +}; + +class ImGuiRuntime { +public: + explicit ImGuiRuntime(GLFWwindow *window) { + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImPlot::CreateContext(); + ImGuiIO &io = ImGui::GetIO(); + io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; + io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable; + io.ConfigViewportsNoDecoration = true; + io.ConfigDockingTransparentPayload = true; + io.IniFilename = nullptr; + io.LogFilename = nullptr; + if (!ImGui_ImplGlfw_InitForOpenGL(window, true)) { + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + throw std::runtime_error("ImGui_ImplGlfw_InitForOpenGL failed"); + } + glfwSetKeyCallback(window, keyCallback); + if (!ImGui_ImplOpenGL3_Init("#version 330")) { + ImGui_ImplGlfw_Shutdown(); + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + throw std::runtime_error("ImGui_ImplOpenGL3_Init failed"); + } + } + + ~ImGuiRuntime() { + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplGlfw_Shutdown(); + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + } + + ImGuiRuntime(const ImGuiRuntime &) = delete; + ImGuiRuntime &operator=(const ImGuiRuntime &) = delete; +}; + +} // namespace + +std::vector takeKeyEvents() { + return std::exchange(g_key_events, {}); +} + +int run(std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file) { + try { + // SIGINT/SIGTERM close all windows (which may ask about unsaved changes), then exit + UnixSignalHandler signal_handler([]() { g_signal_exit = true; }); + + GlfwRuntime glfw; + ImGuiRuntime imgui(glfw.window()); + loadFonts(); + applyTheme(settings.theme); + inistate::addSettingsHandler(); + inistate::load(); + inistate::applyWindowGeometry(glfw.window()); + + MainWindow win(glfw.window(), std::move(stream), std::move(stream_loader), dbc_file); + renderFrame(glfw.window(), &win); + glfwShowWindow(glfw.window()); + while (!win.exited()) { + if (g_signal_exit.exchange(false)) { + printf("\nexiting...\n"); + win.close(); + } else if (glfwWindowShouldClose(glfw.window())) { + glfwSetWindowShouldClose(glfw.window(), GLFW_FALSE); + win.close(); + } + renderFrame(glfw.window(), &win); + } + return 0; + } catch (const std::exception &e) { + fprintf(stderr, "%s\n", e.what()); + return 1; + } +} diff --git a/openpilot/tools/cabana/ui/app.h b/openpilot/tools/cabana/ui/app.h new file mode 100644 index 000000000..a981e57e5 --- /dev/null +++ b/openpilot/tools/cabana/ui/app.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/streams/abstractstream.h" + +// builds a stream off the main thread; nullptr means the load failed +using StreamLoader = std::function()>; + +// takes ownership of stream; a loader runs behind the window instead of before it; with neither, the +// stream selector opens +int run(std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file); + +// key presses with the modifier state at event time: imgui may apply a modifier release in the same frame as +// the key press it belongs to, which loses fast shortcut sequences. Consumed once per frame by MainWindow. +struct KeyEvent { + int key; // GLFW_KEY_* + int mods; // GLFW_MOD_* +}; +std::vector takeKeyEvents(); diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc new file mode 100644 index 000000000..1c319b4fa --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -0,0 +1,797 @@ +#define IMGUI_DEFINE_MATH_OPERATORS // ImVec2 arithmetic, must precede imgui.h +#include "tools/cabana/ui/chart/chart.h" + +#include +#include +#include +#include +#include + +#include "tools/cabana/core/settings.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/chart/downsample.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +const int X_TICK_COUNT = 5; +const double MIN_ZOOM_SECONDS = 0.01; // 10ms +const double EPSILON = 1e-6; +static inline bool xLessThan(const ImPlotPoint &p, double x) { return p.x < (x - EPSILON); } +static inline bool isNull(const ImPlotPoint &p) { return p.x == 0 && p.y == 0; } + +static std::string formatNumber(double value, int precision) { + char buf[64]; + snprintf(buf, sizeof(buf), "%.*f", precision, value); + return buf; +} + +// the decimals needed to tell tick_count ticks over range apart +static int axisPrecision(double range, int tick_count, int min_precision) { + return std::max(int(-std::floor(std::log10(range / (tick_count - 1)))), min_precision); +} + +static void addTextEllipsis(ImDrawList *dl, ImFont *font, ImU32 col, const ImVec2 &pos, float max_x, const std::string &text) { + const float size = ImGui::GetFontSize(); + ImGui::PushFont(font, 0.0f); + ImGui::RenderTextEllipsis(dl, pos, ImVec2(max_x, pos.y + size), max_x, text.c_str(), nullptr, nullptr); + ImGui::PopFont(); +} + +ChartView::ChartView(const std::pair &x_range, ChartsWidget *parent) + : x_min_(x_range.first), x_max_(x_range.second), charts_widget_(parent) { + series_type_ = (SeriesType)settings.chart_series_type; + + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { signalRemoved(sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { signalUpdated(sig); })); + connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { msgRemoved(id); })); +} + +void ChartView::drawMenuActions() { + for (int i = 0; i < (int)std::size(SERIES_TYPE_NAMES); ++i) { + if (dropdown::Item(SERIES_TYPE_NAMES[i], nullptr, i == (int)series_type_)) { + setSeriesType((SeriesType)i); + } + } + ImGui::Separator(); + if (dropdown::Item("Manage Signals")) manageSignals(); + if (dropdown::Item("Split Chart", nullptr, false, sigs_.size() > 1)) charts_widget_->splitChart(this); +} + +// the buttons and their menus are drawn every frame, at the rects updateLayout() placed them at +void ChartView::createToolButtons() { + ImGui::SetCursorScreenPos(layout_.close_btn_rect.Min); + bool close_clicked = iconButton("close_btn", icon::X_LG, "Remove Chart"); + + ImGui::SetCursorScreenPos(layout_.manage_btn_rect.Min); + if (iconButton("manage_btn", icon::THREE_DOTS_VERTICAL, "")) ImGui::OpenPopup("manage_menu"); + if (dropdown::BeginPopup("manage_menu")) { + drawMenuActions(); + dropdown::EndPopup(); + } + + if (close_clicked) charts_widget_->removeChart(this); +} + +void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) { + if (hasSignal(msg_id, sig)) return; + + sigs_.push_back({.msg_id = msg_id, .sig = sig, .color = uniqueColor(sig->color)}); + updateSeries(sig); + charts_widget_->seriesChanged(); +} + +bool ChartView::hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const { + return std::any_of(sigs_.cbegin(), sigs_.cend(), [&](auto &s) { return s.msg_id == msg_id && s.sig == sig; }); +} + +void ChartView::removeIf(std::function predicate) { + int prev_size = sigs_.size(); + sigs_.erase(std::remove_if(sigs_.begin(), sigs_.end(), predicate), sigs_.end()); + if (sigs_.empty()) { + charts_widget_->removeChart(this); + } else if (sigs_.size() != prev_size) { + charts_widget_->seriesChanged(); + updateAxisY(); + } +} + +void ChartView::signalUpdated(const cabana::Signal *sig) { + auto it = std::find_if(sigs_.begin(), sigs_.end(), [sig](auto &s) { return s.sig == sig; }); + if (it != sigs_.end()) { + if (!(it->color == sig->color)) { + it->color = uniqueColor(sig->color, sig); + } + updateSeries(sig); + } +} + +void ChartView::manageSignals() { + auto dlg = std::make_unique("Manage Chart"); + for (auto &s : sigs_) { + dlg->addSelected(s.msg_id, s.sig); + } + // runs once the dialog is accepted, dropped if the chart is removed first + charts_widget_->execSignalSelector(std::move(dlg), this, [this](SignalSelector &selector) { + const auto &items = selector.selectedItems(); + for (const auto &s : items) { + addSignal(s.msg_id, s.sig); + } + removeIf([&](auto &s) { + return std::none_of(items.cbegin(), items.cend(), [&](auto &it) { return s.msg_id == it.msg_id && s.sig == it.sig; }); + }); + }); +} + +void ChartView::updateLayout() { + const ImVec2 grip = ImGui::CalcTextSize(icon::GRIP_HORIZONTAL); + const ImGuiStyle &style = ImGui::GetStyle(); + // WindowPadding can be zero in a borderless pane or drag preview. Chart + // content always uses the shared control gap, independently of its parent. + layout_.content_rect = layout_.rect; + layout_.content_rect.Expand(-style.ItemSpacing.x); + const ImVec2 top_left = layout_.content_rect.Min; + layout_.move_icon_rect = ImRect(top_left, top_left + grip); + const ImVec2 btn_size(iconButtonWidth(), iconButtonWidth()); + const ImVec2 close_min(layout_.content_rect.Max.x - btn_size.x, top_left.y); + layout_.close_btn_rect = ImRect(close_min, close_min + btn_size); + const ImVec2 manage_min(close_min.x - btn_size.x - ImGui::GetStyle().ItemSpacing.x, top_left.y); + layout_.manage_btn_rect = ImRect(manage_min, manage_min + btn_size); + + ImFont *bold = boldFont(); + const float font_size = ImGui::GetFontSize(); + const float fm_height = ImGui::GetTextLineHeight(); + const int marker_size = markerSize(); + const int row_height = std::max(marker_size, fm_height) + fm_height + style.ItemInnerSpacing.y; // + the signal value line + const int legend_left = layout_.move_icon_rect.Max.x + style.ItemSpacing.x; + const int legend_right = std::max(layout_.manage_btn_rect.Min.x - ImGui::GetStyle().ItemSpacing.x, legend_left + 10); + + // layout legend entries left-to-right, wrapping between the move icon and the buttons + layout_.legend_rects.clear(); + int x = legend_left, y = top_left.y; + for (auto &s : sigs_) { + int w = marker_size + style.ItemInnerSpacing.x + bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x + + ImGui::CalcTextSize(msgLabel(s.msg_id).c_str()).x; + pushMonoFont(font_size); + w = std::max(w, (int)std::ceil(ImGui::CalcTextSize("-0.00000e+000").x)); + popMonoFont(); + w = std::min(w, legend_right - legend_left); // keep oversized entries clear of the header buttons + if (x + w > legend_right && x > legend_left) { + x = legend_left; + y += row_height; + } + layout_.legend_rects.emplace_back(ImVec2(x, y), ImVec2(x + w, y + std::max(marker_size, fm_height))); + x += w + style.ItemSpacing.x; + } + + // add top space for the legend and signal values + layout_.header_bottom = std::max(y + row_height, layout_.manage_btn_rect.Max.y) + ImGui::GetStyle().ItemSpacing.y; +} + +void ChartView::updatePlot(double cur, double min, double max) { + cur_sec_ = cur; + if (min != x_min_ || max != x_max_) { + x_min_ = min; + x_max_ = max; + updateAxisY(); + if (tooltip_x_ >= 0) { + showTip(secondsAtPoint({(float)tooltip_x_, 0})); + } + } +} + +void ChartView::appendCanEvents(const cabana::Signal *sig, const std::vector &events, + std::vector &vals, std::vector &step_vals) { + vals.reserve(vals.size() + events.size()); + step_vals.reserve(step_vals.size() + events.size() * 2); + + double value = 0; + for (const CanEvent *e : events) { + if (sig->getValue(e->dat, e->size, &value)) { + const double ts = can->toSeconds(e->mono_time); + vals.emplace_back(ts, value); + if (!step_vals.empty()) + step_vals.emplace_back(ts, step_vals.back().y); + step_vals.emplace_back(ts, value); + } + } +} + +void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap *msg_new_events) { + for (auto &s : sigs_) { + if (!sig || s.sig == sig) { + if (!msg_new_events) { + s.vals.clear(); + s.step_vals.clear(); + } + auto events = msg_new_events ? msg_new_events : &can->eventsMap(); + auto it = events->find(s.msg_id); + if (it == events->end() || it->second.empty()) continue; + + if (s.vals.empty() || can->toSeconds(it->second.back()->mono_time) > s.vals.back().x) { + appendCanEvents(s.sig, it->second, s.vals, s.step_vals); + } else { + std::vector vals, step_vals; + appendCanEvents(s.sig, it->second, vals, step_vals); + if (vals.empty()) continue; + s.vals.insert(std::lower_bound(s.vals.begin(), s.vals.end(), vals.front().x, xLessThan), + vals.begin(), vals.end()); + s.step_vals.insert(std::lower_bound(s.step_vals.begin(), s.step_vals.end(), step_vals.front().x, xLessThan), + step_vals.begin(), step_vals.end()); + } + + if (!can->liveStreaming()) { + s.segment_tree.build(s.vals.size(), [&vals = s.vals](int i) { return vals[i].y; }); + } + } + } + updateAxisY(); +} + +std::pair ChartView::visibleRange(const std::vector &points) const { + auto first = std::lower_bound(points.cbegin(), points.cend(), x_min_, xLessThan); + auto last = std::lower_bound(first, points.cend(), x_max_, xLessThan); + return {first, last}; +} + +const ImPlotPoint *ChartView::lastPointBefore(const SigItem &s, double sec) const { + auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), sec, [](auto &p, double x) { return p.x > x + EPSILON; }); + return it != s.vals.crend() && it->x >= x_min_ ? &*it : nullptr; +} + +void ChartView::updateAxisY() { + if (sigs_.empty()) return; + + double min = std::numeric_limits::max(); + double max = std::numeric_limits::lowest(); + std::string unit = sigs_[0].sig->unit; + + for (auto &s : sigs_) { + if (!s.visible) continue; + + // Only show unit when all signals have the same unit + if (unit != s.sig->unit) { + unit.clear(); + } + + auto [first, last] = visibleRange(s.vals); + s.min = std::numeric_limits::max(); + s.max = std::numeric_limits::lowest(); + if (first == last) continue; + if (can->liveStreaming()) { + for (auto it = first; it != last; ++it) { + if (it->y < s.min) s.min = it->y; + if (it->y > s.max) s.max = it->y; + } + } else { + std::tie(s.min, s.max) = s.segment_tree.minmax(std::distance(s.vals.cbegin(), first), std::distance(s.vals.cbegin(), last) - 1); + } + min = std::min(min, s.min); + max = std::max(max, s.max); + } + if (min == std::numeric_limits::max()) min = 0; + if (max == std::numeric_limits::lowest()) max = 0; + + y_unit_ = unit; + + const double magnitude = std::max(std::abs(min), std::abs(max)); + double delta = max - min <= magnitude * 1e-9 ? (magnitude > 0 ? magnitude * 0.05 : 1) : (max - min) * 0.05; + auto [min_y, max_y, tick_count] = getNiceAxisNumbers(min - delta, max + delta, 3); + if (min_y != y_min_ || max_y != y_max_) { + y_min_ = min_y; + y_max_ = max_y; + y_tick_count_ = tick_count; + y_precision_ = axisPrecision(max_y - min_y, tick_count, 0); + } +} + +std::tuple ChartView::getNiceAxisNumbers(double min, double max, int tick_count) { + double range = niceNumber((max - min), true); // range with ceiling + double step = niceNumber(range / (tick_count - 1), false); + min = std::floor(min / step); + max = std::ceil(max / step); + tick_count = int(max - min) + 1; + return {min * step, max * step, tick_count}; +} + +int ChartView::xAxisPrecision() const { + return axisPrecision(x_max_ - x_min_, X_TICK_COUNT, 2); +} + +// nice numbers can be expressed as form of 1*10^n, 2* 10^n or 5*10^n +double ChartView::niceNumber(double x, bool ceiling) { + double z = std::pow(10, std::floor(std::log10(x))); // the largest 10^n smaller than x + double q = x / z; // 1 <= q < 10 + if (ceiling) { + if (q <= 1.0) q = 1; + else if (q <= 2.0) q = 2; + else if (q <= 5.0) q = 5; + else q = 10; + } else { + if (q < 1.5) q = 1; + else if (q < 3.0) q = 2; + else if (q < 7.0) q = 5; + else q = 10; + } + return q * z; +} + +void ChartView::drawContextMenu() { + if (drawing_ghost_) return; + // the menu opens on right press; a right release with no menu open reaches handleMouseRelease + if (ImGui::IsMouseClicked(ImGuiMouseButton_Right) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows) && + !ImGui::IsAnyItemActive()) { + ImGui::OpenPopup("context_menu"); + } + context_menu_id_ = ImGui::GetID("context_menu"); + if (dropdown::BeginPopup("context_menu")) { + drawMenuActions(); + ImGui::Separator(); + // the zoom entries come from the toolbar, where they are only visible while zoomed + if (can->timeRange().has_value()) { + const std::string undo_text = std::string(icon::ARROW_COUNTERCLOCKWISE) + " Undo Zoom"; + const std::string redo_text = std::string(icon::ARROW_CLOCKWISE) + " Redo Zoom"; + if (dropdown::Item(undo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canUndo())) charts_widget_->zoom_undo_stack_.undo(); + if (dropdown::Item(redo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canRedo())) charts_widget_->zoom_undo_stack_.redo(); + ImGui::Separator(); + } + if (dropdown::Item("Close")) charts_widget_->removeChart(this); + dropdown::EndPopup(); + } +} + +void ChartView::handleMousePress() { + if (drawing_ghost_) return; + const ImVec2 pos = ImGui::GetMousePos(); + // a press on the close/manage buttons does not reach the widget + const bool widget_pressed = ImGui::IsMouseClicked(ImGuiMouseButton_Left) && layout_.rect.Contains(pos) && + ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && + !layout_.close_btn_rect.Contains(pos) && !layout_.manage_btn_rect.Contains(pos); + if (!widget_pressed) return; + press_pos_ = pos; + if (layout_.move_icon_rect.Contains(pos)) return; // the move icon press is handled by the grip item (startChartDrag) + + if (ImGui::GetIO().KeyShift) { + // Save current playback state when scrubbing + resume_after_scrub_ = !can->isPaused(); + if (resume_after_scrub_) { + can->pause(true); + } + mouse_mode_ = MouseMode::Scrub; + } else if (layout_.plot_area.Contains(pos)) { + mouse_mode_ = MouseMode::Rubber; + rubber_rect_ = ImRect(); + } +} + +void ChartView::handleMouseMove() { + if (drawing_ghost_) return; + const ImVec2 pos = ImGui::GetMousePos(); + const ImVec2 delta = ImGui::GetIO().MouseDelta; + // a click alone must not hide the tip + if (delta.x == 0 && delta.y == 0) return; + // only the widget under the mouse, or the one dragging, reacts to a move + if (mouse_mode_ == MouseMode::None && !layout_.rect.Contains(pos)) return; + + if (mouse_mode_ == MouseMode::Scrub && ImGui::GetIO().KeyShift) { + if (layout_.plot_area.Contains(pos)) { + can->seekTo(std::clamp(secondsAtPoint(pos), can->minSeconds(), can->maxSeconds())); + } + } + + if (mouse_mode_ == MouseMode::Rubber) { + // horizontal selection, clamped to the plot area + float left = std::clamp(std::min(press_pos_.x, pos.x), layout_.plot_area.Min.x, layout_.plot_area.Max.x); + float right = std::clamp(std::max(press_pos_.x, pos.x), layout_.plot_area.Min.x, layout_.plot_area.Max.x); + rubber_rect_ = ImRect(ImVec2(left, layout_.plot_area.Min.y), ImVec2(right, layout_.plot_area.Max.y)); + } + + clearTrackPoints(); + if (mouse_mode_ != MouseMode::Rubber && layout_.plot_area.Contains(pos) && (layout_.plot_hovered || mouse_mode_ != MouseMode::None) && + ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow)) { + charts_widget_->showValueTip(secondsAtPoint(pos)); + } else if (tip_label_.isVisible()) { + charts_widget_->showValueTip(-1); + } +} + +void ChartView::handleMouseRelease() { + if (drawing_ghost_) return; + const bool left_released = ImGui::IsMouseReleased(ImGuiMouseButton_Left); + const bool right_released = ImGui::IsMouseReleased(ImGuiMouseButton_Right) && layout_.rect.Contains(ImGui::GetMousePos()); + if (!left_released && !right_released) return; + if (left_released && mouse_mode_ == MouseMode::Rubber) { + mouse_mode_ = MouseMode::None; + // Prevent zooming/seeking past the end of the route + double min = std::clamp(secondsAtPoint(rubber_rect_.Min), can->minSeconds(), can->maxSeconds()); + double max = std::clamp(secondsAtPoint(rubber_rect_.Max), can->minSeconds(), can->maxSeconds()); + if (rubber_rect_.GetWidth() <= 10) { + // Small movements are still clicks; use the same threshold as drag-to-zoom. + can->seekTo(std::clamp(secondsAtPoint(press_pos_), can->minSeconds(), can->maxSeconds())); + } else if ((max - min) > MIN_ZOOM_SECONDS) { + charts_widget_->zoom_undo_stack_.push(new ZoomCommand({min, max})); + } + rubber_rect_ = ImRect(); + } else if (right_released && !ImGui::IsPopupOpen(context_menu_id_, ImGuiPopupFlags_None)) { + charts_widget_->zoom_undo_stack_.undo(); + } + + if (mouse_mode_ == MouseMode::Scrub) { + mouse_mode_ = MouseMode::None; + if (resume_after_scrub_) { + can->pause(false); + resume_after_scrub_ = false; + } + } +} + +void ChartView::takeSignalsFrom(ChartView *source) { + for (auto &s : source->sigs_) { + s.color = uniqueColor(s.color); + sigs_.push_back(std::move(s)); + } + source->sigs_.clear(); + updateAxisY(); + charts_widget_->removeChart(source); +} + +std::vector ChartView::takeExtraSignals() { + std::vector extra; + for (auto it = sigs_.begin() + 1; it != sigs_.end(); ++it) { + it->color = it->sig->color; + extra.push_back(std::move(*it)); + } + sigs_.resize(1); + updateAxisY(); + return extra; +} + +void ChartView::adoptSignal(SigItem s) { + sigs_.push_back(std::move(s)); + updateAxisY(); +} + +void ChartView::showTip(double sec) { + ImRect tip_area(ImVec2(layout_.rect.Min.x, layout_.plot_area.Min.y), ImVec2(layout_.rect.Max.x, layout_.plot_area.Max.y)); + ImRect visible_rect = charts_widget_->chartVisibleRect(this); + visible_rect.ClipWith(tip_area); + if (visible_rect.GetWidth() <= 0 || visible_rect.GetHeight() <= 0) { + tip_label_.hide(); + return; + } + + tooltip_x_ = xPos(sec); + float x = -1; + std::vector text_list; + for (auto &s : sigs_) { + if (s.visible) { + std::string value = "--"; + if (const ImPlotPoint *pt = lastPointBefore(s, sec)) { + value = s.sig->formatValue(pt->y, false); + s.track_pt = *pt; + x = std::max(x, xPos(pt->x)); + } + std::string name = s.sig->name; + std::string min = s.min == std::numeric_limits::max() ? "--" : utils::toString(s.min); + std::string max = s.max == std::numeric_limits::lowest() ? "--" : utils::toString(s.max); + text_list.push_back({.has_marker = true, .marker = toImU32(s.color), .name = name, .value = value, .min = min, .max = max}); + } + } + if (x < 0) { + x = tooltip_x_; + } + ImVec2 pt(x, layout_.plot_area.Min.y); + text_list.insert(text_list.begin(), TipLine{.name = formatNumber(sec, 2) + " s"}); + tip_label_.showText(pt, text_list, visible_rect); +} + +void ChartView::hideTip() { + clearTrackPoints(); + tooltip_x_ = -1; + tip_label_.hide(); +} + +void ChartView::draw(float width) { + ImGui::PushID(this); + width = std::max(width, (float)CHART_MIN_WIDTH); + layout_.plot_hovered = false; + // the tile geometry is known before the child is entered, so it stays valid when imgui culls a scrolled out chart + const ImVec2 tile_pos = ImGui::GetCursorScreenPos(); + const ImVec2 tile_size(width, (float)settings.chart_height); + layout_.rect = ImRect(tile_pos, tile_pos + tile_size); + if (ImGui::BeginChild("chart", tile_size, ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + updateLayout(); + paint(); + drawContextMenu(); + // Keep the tip above the plot, but below popup menus and other windows. + ImRect visible_rect = charts_widget_->chartVisibleRect(this); + visible_rect.ClipWith(ImRect(ImVec2(layout_.rect.Min.x, layout_.plot_area.Min.y), + ImVec2(layout_.rect.Max.x, layout_.plot_area.Max.y))); + if (!drawing_ghost_ && visible_rect.GetWidth() > 0 && visible_rect.GetHeight() > 0) tip_label_.draw(visible_rect); + } + ImGui::EndChild(); + ImGui::PopID(); +} + +void ChartView::drawGhost(float width) { + // the ghost is drawn in its own window: keep the geometry of the live tile so hit testing stays correct + drawing_ghost_ = true; + const Layout saved = layout_; + draw(width); + layout_ = saved; + drawing_ghost_ = false; +} + +void ChartView::paint() { + drawStaticLayer(); + + if (can_drop_) { + ImGui::GetWindowDrawList()->AddRect(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_Header), ImGui::GetStyle().ChildRounding, 0, 4.0f); + } +} + +void ChartView::drawStaticLayer() { + ImDrawList *painter = ImGui::GetWindowDrawList(); + painter->AddRectFilled(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_ChildBg), ImGui::GetStyle().ChildRounding); + ImGui::SetCursorScreenPos(layout_.move_icon_rect.Min); + ImGui::InvisibleButton("grip", layout_.move_icon_rect.GetSize()); + if (ImGui::IsItemActivated()) charts_widget_->startChartDrag(this, ImGui::GetMousePos()); + if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + painter->AddText(layout_.move_icon_rect.Min, ImGui::GetColorU32(ImGuiCol_Text), icon::GRIP_HORIZONTAL); + createToolButtons(); + drawLegend(); + drawSignalValue(); // drawn here because implot clips the plot frame + drawAxes(); +} + +void ChartView::drawAxes() { + ImGui::SetCursorScreenPos(ImVec2(layout_.content_rect.Min.x, layout_.header_bottom)); + const float plot_h = std::max(layout_.content_rect.Max.y - layout_.header_bottom, 10.0f); + ImPlot::PushStyleVar(ImPlotStyleVar_PlotPadding, ImVec2(0.0f, ImGui::GetStyle().ItemInnerSpacing.y)); + ImPlot::PushStyleColor(ImPlotCol_PlotBg, ImVec4(0, 0, 0, 0)); + ImPlot::PushStyleColor(ImPlotCol_FrameBg, ImVec4(0, 0, 0, 0)); + ImPlot::PushStyleColor(ImPlotCol_PlotBorder, palette().grid); + ImPlot::PushStyleColor(ImPlotCol_AxisTick, ImVec4(0, 0, 0, 0)); + ImPlot::PushStyleColor(ImPlotCol_AxisText, ImGui::GetStyleColorVec4(ImGuiCol_Text)); + ImPlot::PushStyleVar(ImPlotStyleVar_MajorTickLen, ImVec2(0, 0)); + ImPlot::PushStyleVar(ImPlotStyleVar_MajorGridSize, ImVec2(1.0f, 1.0f)); + const ImPlotFlags flags = ImPlotFlags_NoTitle | ImPlotFlags_NoLegend | ImPlotFlags_NoMenus | ImPlotFlags_NoMouseText | + ImPlotFlags_NoBoxSelect | ImPlotFlags_NoInputs | ImPlotFlags_NoFrame; + const ImPlotAxisFlags axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight | ImPlotAxisFlags_NoSideSwitch | ImPlotAxisFlags_Lock; + // reserve room for the right half of the last x tick label + const float x_label_width = ImGui::CalcTextSize(formatNumber(x_max_, xAxisPrecision()).c_str()).x + ImGui::GetStyle().ItemInnerSpacing.x; + if (ImPlot::BeginPlot("##plot", ImVec2(layout_.content_rect.GetWidth() - x_label_width / 2, plot_h), flags)) { + ImPlot::SetupAxis(ImAxis_X1, nullptr, axis_flags); + ImPlot::SetupAxis(ImAxis_Y1, y_unit_.empty() ? nullptr : y_unit_.c_str(), axis_flags); + ImPlot::SetupAxisLimits(ImAxis_X1, x_min_, x_max_, ImPlotCond_Always); + ImPlot::SetupAxisLimits(ImAxis_Y1, y_min_, y_max_, ImPlotCond_Always); + // the format must be set before the ticks are generated + ImPlot::SetupAxisFormat(ImAxis_Y1, ("%." + std::to_string(y_precision_) + "f").c_str()); + ImPlot::SetupAxisTicks(ImAxis_Y1, y_min_, y_max_, y_tick_count_); + ImPlot::SetupAxisFormat(ImAxis_X1, ("%." + std::to_string(xAxisPrecision()) + "f").c_str()); + ImPlot::SetupAxisTicks(ImAxis_X1, x_min_, x_max_, X_TICK_COUNT); + ImPlot::SetupFinish(); + + layout_.plot_area = ImRect(ImPlot::GetPlotPos(), ImPlot::GetPlotPos() + ImPlot::GetPlotSize()); + // ImPlotFlags_NoInputs disables implot's own hover tracking + // A popup is a descendant of the chart, but hovering its menu must not hover the plot underneath. + layout_.plot_hovered = layout_.plot_area.Contains(ImGui::GetMousePos()) && ImGui::IsWindowHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); + drawSeries(); + if (!drawing_ghost_) { + // Own plot clicks so custom scrubbing/zooming cannot also move the floating window. + const ImGuiID input_id = ImGui::GetID("plot_input"); + if (ImGui::ItemAdd(layout_.plot_area, input_id)) { + bool hovered, held; + ImGui::ButtonBehavior(layout_.plot_area, input_id, &hovered, &held); + } + } + handleMousePress(); + handleMouseMove(); + handleMouseRelease(); + drawForeground(); + ImPlot::EndPlot(); + } + ImPlot::PopStyleColor(5); + ImPlot::PopStyleVar(3); +} + +void ChartView::drawLegend() { + ImDrawList *painter = ImGui::GetWindowDrawList(); + const ImU32 title_color = ImGui::GetColorU32(ImGuiCol_Text); + // Draw message details in similar color, but slightly fade it to the background + const ImU32 msg_color = withAlpha(title_color, 180); + ImFont *bold = boldFont(); + ImFont *normal = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + const float marker_size = markerSize(); + + for (int i = 0; i < sigs_.size() && i < layout_.legend_rects.size(); ++i) { + const auto &s = sigs_[i]; + const ImRect &r = layout_.legend_rects[i]; + // toggle series visibility by clicking its legend entry + ImGui::PushID(i); + ImGui::SetCursorScreenPos(r.Min); + if (ImGui::InvisibleButton("legend", ImVec2(std::max(r.GetWidth(), 1.0f), std::max(r.GetHeight(), 1.0f))) && + mouse_mode_ == MouseMode::None && sigs_.size() > 1) { + sigs_[i].visible = !sigs_[i].visible; + updateAxisY(); + } + ImGui::PopID(); + + if (series_type_ == SeriesType::Scatter) { + painter->AddCircleFilled(r.Min + ImVec2(marker_size / 2.0f, 2.0f + marker_size / 2.0f), marker_size / 2.0f, toImU32(s.color)); + } else { + drawColorMarker(painter, r.Min, toImU32(s.color)); + } + + float x = r.Min.x + marker_size + ImGui::GetStyle().ItemInnerSpacing.x; + const float text_y = r.GetCenter().y - font_size / 2.0f; + addTextEllipsis(painter, bold, title_color, ImVec2(x, text_y), r.Max.x, s.sig->name); + float name_w = std::min(bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x, r.Max.x - x); + x += name_w; + const std::string msg = msgLabel(s.msg_id); + addTextEllipsis(painter, normal, msg_color, ImVec2(x, text_y), r.Max.x, msg); + if (!s.visible) { // strike out + const float y = r.GetCenter().y; + painter->AddLine(ImVec2(r.Min.x + marker_size + ImGui::GetStyle().ItemInnerSpacing.x, y), ImVec2(std::min(x + ImGui::CalcTextSize(msg.c_str()).x, r.Max.x), y), title_color); + } + } +} + +void ChartView::drawSeries() { + for (int i = 0; i < sigs_.size(); ++i) { + auto &s = sigs_[i]; + if (!s.visible) continue; + + // visible points in vals to compute point density + auto [first, last] = visibleRange(s.vals); + int num_points = std::max(last - first, 1); + double pixels_per_point = 0; + if (first != last) { + const ImPlotPoint &right_pt = last == s.vals.cend() ? s.vals.back() : *last; + pixels_per_point = (xPos(right_pt.x) - xPos(first->x)) / num_points; + } + + const std::string label = "##sig" + std::to_string(i); + ImPlotSpec spec; + spec.LineColor = toImVec4(s.color); + spec.Stride = sizeof(ImPlotPoint); + if (series_type_ == SeriesType::Scatter) { + float radius = std::clamp(pixels_per_point / 2.0, 2.0, 8.0) / 2.0; + spec.Marker = ImPlotMarker_Circle; + spec.MarkerSize = radius; + if (first != last) ImPlot::PlotScatter(label.c_str(), &first->x, &first->y, last - first, spec); + } else { + const auto &points = series_type_ == SeriesType::StepLine ? s.step_vals : s.vals; + // one sample beyond each edge so the line runs out of the plot + auto [begin, end] = visibleRange(points); + if (begin != points.cbegin()) --begin; + if (end != points.cend()) ++end; + if (begin == end) continue; + + spec.LineWeight = 2; + const int pixels = std::max(1, (int)layout_.plot_area.GetWidth()); + if (end - begin > pixels * 4) { + const auto envelope = chart::pixelEnvelope(begin, end, x_min_, x_max_, pixels); + ImPlot::PlotLine(label.c_str(), &envelope.front().x, &envelope.front().y, envelope.size(), spec); + } else { + ImPlot::PlotLine(label.c_str(), &begin->x, &begin->y, end - begin, spec); + } + + // show points when zoomed in enough + if ((num_points == 1 || pixels_per_point > 20) && first != last) { + ImPlotSpec dots; + dots.LineColor = toImVec4(s.color); + dots.Stride = sizeof(ImPlotPoint); + dots.Marker = ImPlotMarker_Circle; + dots.MarkerSize = 4; + ImPlot::PlotScatter((label + "_pts").c_str(), &first->x, &first->y, last - first, dots); + } + } + } +} + +void ChartView::drawForeground() { + drawTimeline(); + ImDrawList *painter = ImPlot::GetPlotDrawList(); + ImPlot::PushPlotClipRect(); + float track_line_x = -1; + for (auto &s : sigs_) { + if (!isNull(s.track_pt) && s.visible) { + ImVec2 pos(xPos(s.track_pt.x), yPos(s.track_pt.y)); + painter->AddCircleFilled(pos, 5.5f, toImU32(s.color.darker(125))); + track_line_x = std::max(track_line_x, pos.x); + } + } + if (track_line_x > 0) { + const ImU32 dark_gray = IM_COL32(0x80, 0x80, 0x80, 0xff); + for (float y = layout_.plot_area.Min.y; y < layout_.plot_area.Max.y; y += 8) { + painter->AddLine(ImVec2(track_line_x, y), ImVec2(track_line_x, std::min(y + 4, layout_.plot_area.Max.y)), dark_gray, 1.0f); + } + } + ImPlot::PopPlotClipRect(); + + drawRubberBandTimeRange(); +} + +void ChartView::drawRubberBandTimeRange() { + if (rubber_rect_.GetWidth() <= 1) return; + + ImDrawList *painter = ImPlot::GetPlotDrawList(); + // ImGuiCol_Header is translucent, so the 1px selection outline is drawn at full alpha + const ImU32 highlight = withAlpha(ImGui::GetColorU32(ImGuiCol_Header), 255); + painter->AddRectFilled(rubber_rect_.Min, rubber_rect_.Max, withAlpha(highlight, 50), ImGui::GetStyle().FrameRounding); + painter->AddRect(rubber_rect_.Min, rubber_rect_.Max, highlight, ImGui::GetStyle().FrameRounding); + + // time labels at the bottom corners (below the plot, so clip to the widget instead of the plot) + const ImU32 white = IM_COL32_WHITE; + const ImU32 badge = ImGui::GetColorU32(palette().badge); + painter = ImGui::GetWindowDrawList(); + painter->PushClipRect(layout_.rect.Min, layout_.rect.Max); + for (const auto &pt : {rubber_rect_.GetBL(), rubber_rect_.GetBR()}) { + std::string sec = formatNumber(secondsAtPoint(pt), 2); + ImVec2 size = ImGui::CalcTextSize(sec.c_str()) + ImVec2(12, ImGui::GetStyle().ItemInnerSpacing.y * 2); + ImVec2 top_left = pt.x == rubber_rect_.Min.x ? ImVec2(pt.x - size.x, pt.y + 2) : ImVec2(pt.x, pt.y + 2); + painter->AddRectFilled(top_left, top_left + size, badge, ImGui::GetStyle().FrameRounding); + painter->AddText(top_left + ImVec2(6, ImGui::GetStyle().ItemInnerSpacing.y), white, sec.c_str()); + } + painter->PopClipRect(); +} + +void ChartView::drawTimeline() { + ImDrawList *painter = ImPlot::GetPlotDrawList(); + float x = std::clamp(xPos(cur_sec_), layout_.plot_area.Min.x, layout_.plot_area.Max.x); + painter->AddLine(ImVec2(x, layout_.plot_area.Min.y - 1.0f), ImVec2(x, layout_.plot_area.Max.y + 1.0f), ImGui::GetColorU32(ImGuiCol_Text), 1.0f); + + std::string time_str = formatNumber(cur_sec_, 2); + ImVec2 time_str_size = ImGui::CalcTextSize(time_str.c_str()) + ImVec2(8, 2); + ImVec2 time_str_pos(x - time_str_size.x / 2.0f, layout_.plot_area.Max.y + ImGui::GetStyle().ItemInnerSpacing.y); + painter->AddRectFilled(time_str_pos, time_str_pos + time_str_size, ImGui::GetColorU32(palette().badge), ImGui::GetStyle().FrameRounding); + painter->AddText(time_str_pos + ImVec2(4, 1), IM_COL32_WHITE, time_str.c_str()); +} + +void ChartView::drawSignalValue() { + pushMonoFont(ImGui::GetFontSize()); + ImDrawList *painter = ImGui::GetWindowDrawList(); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); + for (int i = 0; i < sigs_.size() && i < layout_.legend_rects.size(); ++i) { + const auto &s = sigs_[i]; + const ImPlotPoint *pt = lastPointBefore(s, cur_sec_); + std::string value = pt ? s.sig->formatValue(pt->y) : "--"; + const ImVec2 value_min = layout_.legend_rects[i].GetBL() - ImVec2(0, 1); + ImRect value_rect(value_min, value_min + layout_.legend_rects[i].GetSize()); + float w = ImGui::CalcTextSize(value.c_str()).x; + if (w <= value_rect.GetWidth()) { + painter->AddText(value_rect.Min, color, value.c_str()); + } else { + addTextEllipsis(painter, ImGui::GetFont(), color, value_rect.Min, value_rect.Max.x, value); + } + } + popMonoFont(); +} + +CabanaColor ChartView::uniqueColor(CabanaColor color, const cabana::Signal *exclude) const { + auto separation = [&](float hue) { + float distance = 1.0f; + for (const auto &s : sigs_) { + if (exclude && s.sig == exclude) continue; + const float delta = std::abs(hue - s.color.hsv().hue); + distance = std::min(distance, std::min(delta, 1.0f - delta)); + } + return distance; + }; + const float original_hue = color.hsv().hue; + if (separation(original_hue) >= 0.1f) return color; + + float best_hue = original_hue, best_distance = -1; + for (int i = 0; i < 36; ++i) { + const float hue = std::fmod(original_hue + i / 36.0f, 1.0f); + const float distance = separation(hue); + if (distance > best_distance) { + best_hue = hue; + best_distance = distance; + } + } + return CabanaColor::fromHsv(best_hue, 0.8f, 0.9f, color.a / 255.0f); +} diff --git a/openpilot/tools/cabana/ui/chart/chart.h b/openpilot/tools/cabana/ui/chart/chart.h new file mode 100644 index 000000000..7e7152f6d --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/chart.h @@ -0,0 +1,143 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "implot.h" + +#include "tools/cabana/ui/chart/tiplabel.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/util.h" + +enum class SeriesType { + Line = 0, + StepLine, + Scatter +}; +inline constexpr const char *SERIES_TYPE_NAMES[] = {"Line", "Step Line", "Scatter"}; + +// the message part of a legend entry, drawn after the signal name +inline std::string msgLabel(const MessageId &id) { return " " + msgName(id) + " " + id.toString(); } + +class ChartsWidget; +class ChartView { +public: + struct SigItem { + MessageId msg_id; + const cabana::Signal *sig = nullptr; + CabanaColor color; + bool visible = true; + std::vector vals; + std::vector step_vals; + ImPlotPoint track_pt{}; + SegmentTree segment_tree; + double min = 0; + double max = 0; + }; + + ChartView(const std::pair &x_range, ChartsWidget *parent); + void addSignal(const MessageId &msg_id, const cabana::Signal *sig); + bool hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const; + void updateSeries(const cabana::Signal *sig = nullptr, const MessageEventsMap *msg_new_events = nullptr); + void updatePlot(double cur, double min, double max); + void setSeriesType(SeriesType type) { series_type_ = type; } + void showTip(double sec); + void hideTip(); + void draw(float width); // one chart of settings.chart_height + void drawGhost(float width); // the same tile rendered again, without handling any input + void removeIf(std::function predicate); + void takeSignalsFrom(ChartView *source); + // every signal but the first, with its original color, for a split into one chart per signal + std::vector takeExtraSignals(); + void adoptSignal(SigItem s); + void setDropHighlight(bool highlight) { can_drop_ = highlight; } + const std::vector &signals() const { return sigs_; } + const ImRect &rect() const { return layout_.rect; } // the whole chart widget, screen coordinates + bool plotHovered() const { return layout_.plot_hovered; } + double secondsAtPoint(const ImVec2 &pt) const { + return x_min_ + (pt.x - layout_.plot_area.Min.x) * (x_max_ - x_min_) / std::max(layout_.plot_area.GetWidth(), 1.0f); + } + +private: + using PointIter = std::vector::const_iterator; + + void signalUpdated(const cabana::Signal *sig); + void manageSignals(); + void msgRemoved(MessageId id) { removeIf([=](auto &s) { return s.msg_id.address == id.address && !dbc()->msg(id); }); } + void signalRemoved(const cabana::Signal *sig) { removeIf([=](auto &s) { return s.sig == sig; }); } + + void appendCanEvents(const cabana::Signal *sig, const std::vector &events, + std::vector &vals, std::vector &step_vals); + void createToolButtons(); + void drawContextMenu(); + void handleMousePress(); + void handleMouseMove(); + void handleMouseRelease(); + void updateLayout(); + void updateAxisY(); + void paint(); + void drawStaticLayer(); + void drawAxes(); + void drawLegend(); + void drawSeries(); + void drawForeground(); + void drawSignalValue(); + void drawTimeline(); + void drawRubberBandTimeRange(); + void drawMenuActions(); // the series type / manage / split entries shared by the menu button and the context menu + int xAxisPrecision() const; + std::tuple getNiceAxisNumbers(double min, double max, int tick_count); + double niceNumber(double x, bool ceiling); + CabanaColor uniqueColor(CabanaColor color, const cabana::Signal *exclude = nullptr) const; + // the last sample at or before sec, nullptr when there is none inside the visible range + const ImPlotPoint *lastPointBefore(const SigItem &s, double sec) const; + // the samples inside [x_min_, x_max_) + std::pair visibleRange(const std::vector &points) const; + inline void clearTrackPoints() { for (auto &s : sigs_) s.track_pt = {}; } + inline float xPos(double sec) const { return layout_.plot_area.Min.x + (sec - x_min_) / (x_max_ - x_min_) * layout_.plot_area.GetWidth(); } + inline float yPos(double val) const { return layout_.plot_area.Max.y - (val - y_min_) / (y_max_ - y_min_) * layout_.plot_area.GetHeight(); } + + // layout + struct Layout { + ImRect rect; // the whole chart widget, screen coordinates + ImRect content_rect; // the same inset on all four sides, including during a drag + ImRect plot_area; + ImRect move_icon_rect; + ImRect close_btn_rect; + ImRect manage_btn_rect; + std::vector legend_rects; + float header_bottom = 0; + bool plot_hovered = false; + } layout_; + // axes + double x_min_; + double x_max_; + double y_min_ = 0; + double y_max_ = 1; + int y_tick_count_ = 3; + int y_precision_ = 1; + std::string y_unit_; + // interaction + enum class MouseMode { None, Rubber, Scrub }; + MouseMode mouse_mode_ = MouseMode::None; + ImVec2 press_pos_; + ImRect rubber_rect_; + bool resume_after_scrub_ = false; + bool drawing_ghost_ = false; // drawing the drag preview: no mouse handling, no tip + ImGuiID context_menu_id_ = 0; + + TipLabel tip_label_; + std::vector sigs_; + double cur_sec_ = 0; + SeriesType series_type_ = SeriesType::Line; + bool can_drop_ = false; + double tooltip_x_ = -1; + ChartsWidget *charts_widget_; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.cc b/openpilot/tools/cabana/ui/chart/chartswidget.cc new file mode 100644 index 000000000..c89dc702d --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/chartswidget.cc @@ -0,0 +1,650 @@ +#define IMGUI_DEFINE_MATH_OPERATORS // ImVec2 arithmetic, must precede imgui.h +#include "tools/cabana/ui/chart/chartswidget.h" + +#include "tools/cabana/ui/threadpool.h" + +#include +#include +#include +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/chart/chart.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +const int MAX_COLUMN_COUNT = 4; +const int START_DRAG_DISTANCE = 10; +const float MIN_RANGE_SLIDER_WIDTH = 40.0f; + +bool LogSlider::draw(const char *label, float width) { + return fusionSliderInt(label, &pos_, min_, max_, width); +} + +ChartsWidget::ChartsWidget() { + range_slider_.setRange(1, settings.max_cached_minutes * 60); + + tabbar_.setAutoHide(true); + tabbar_.setUsesScrollButtons(true); + tabbar_.setTabsClosable(true); + + column_count_ = std::clamp(settings.chart_column_count, 1, MAX_COLUMN_COUNT); + max_chart_range_ = std::clamp(settings.chart_range, 1, settings.max_cached_minutes * 60); + display_range_ = std::make_pair(can->minSeconds(), can->minSeconds() + max_chart_range_); + range_slider_.setValue(max_chart_range_); + + connections_.push_back(dbc()->fileChanged.connect([this]() { removeAll(); })); + connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &events) { eventsMerged(events); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { updateState(); })); + connections_.push_back(can->seeking.connect([this](double) { updateState(); })); + connections_.push_back(can->timeRangeChanged.connect([this](const auto &) { updateState(); })); + connections_.push_back(settings.changed.connect([this]() { settingChanged(); })); + connections_.push_back(seriesChanged.connect([this]() { updateTabBar(); })); + connections_.push_back(tabbar_.tabCloseRequested.connect([this](int index) { removeTab(index); })); + connections_.push_back(tabbar_.tabContextMenu.connect([this](int index) { + if (dropdown::BeginPopupContextItem()) { + if (dropdown::Item("Close Other Tabs")) { + tabbar_.moveTab(index, 0); + tabbar_.setCurrentIndex(0); + while (tabbar_.count() > 1) removeTab(1); + } + dropdown::EndPopup(); + } + })); + connections_.push_back(tabbar_.currentChanged.connect([this](int index) { + if (index != -1) updateLayout(); + })); + + newTab(); +} + +ChartsWidget::~ChartsWidget() = default; + +std::string ChartsWidget::whatsThis() const { + return R"( + Chart View
+ Click: Click to seek to a corresponding time.
+ Drag: Zoom into the chart.
+ Shift + Drag: Scrub through the chart to view values.
+ Right-click: Open the context menu.
+ )"; +} + +void ChartsWidget::newTab() { + static int tab_unique_id = 0; + int idx = tabbar_.addTab(""); + tabbar_.setTabData(idx, tab_unique_id++); + tabbar_.setCurrentIndex(idx); + updateTabBar(); +} + +void ChartsWidget::removeTab(int index) { + int id = tabbar_.tabData(index); + for (auto &c : std::vector(tab_charts_[id])) { + removeChart(c); + } + tab_charts_.erase(id); + tabbar_.removeTab(index); + updateTabBar(); +} + +void ChartsWidget::updateTabBar() { + for (int i = 0; i < tabbar_.count(); ++i) { + const auto &charts_in_tab = tab_charts_[tabbar_.tabData(i)]; + tabbar_.setTabText(i, "Tab " + std::to_string(i + 1) + " (" + std::to_string((int)charts_in_tab.size()) + ")"); + } +} + +void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) { + std::vector> futures; + for (auto &c : charts_) { + futures.push_back(ThreadPool::instance().run([c = c.get(), &new_events]() { c->updateSeries(nullptr, &new_events); })); + } + for (auto &f : futures) f.get(); +} + +void ChartsWidget::zoomReset() { + can->setTimeRange(std::nullopt); + zoom_undo_stack_.clear(); +} + +ImRect ChartsWidget::chartVisibleRect(ChartView *chart) { + ImRect r = chart->rect(); + r.ClipWith(charts_scroll_viewport_); + return r; +} + +void ChartsWidget::showValueTip(double sec) { + if (chartDragActive()) sec = -1; // no value tip while a drag is in progress + showTip(sec); + if (sec < 0 && !value_tip_visible_) return; + + value_tip_visible_ = sec >= 0; + for (auto c : currentCharts()) { + value_tip_visible_ ? c->showTip(sec) : c->hideTip(); + } +} + +void ChartsWidget::updateState() { + if (charts_.empty()) return; + + const auto &time_range = can->timeRange(); + const double cur_sec = can->currentSec(); + if (!time_range.has_value()) { + double pos = (cur_sec - display_range_.first) / std::max(1.0, max_chart_range_); + if (pos < 0 || pos > 0.8) { + display_range_.first = std::max(can->minSeconds(), cur_sec - max_chart_range_ * 0.1); + } + double max_sec = std::min(display_range_.first + max_chart_range_, can->maxSeconds()); + display_range_.first = std::max(can->minSeconds(), max_sec - max_chart_range_); + display_range_.second = display_range_.first + max_chart_range_; + } + + const auto &range = time_range ? *time_range : display_range_; + for (auto &c : charts_) { + c->updatePlot(cur_sec, range.first, range.second); + } +} + +void ChartsWidget::setMaxChartRange(int value) { + max_chart_range_ = settings.chart_range = value; + updateState(); +} + +void ChartsWidget::drawToolBar() { + float slider_width = 150.0f; + const bool is_zoomed = can->timeRange().has_value(); + + // the labels are captured by reference, they outlive the draw calls below + std::vector items; + items.push_back({iconButtonWidth(), [this]() { + if (stepButton("new_plot_btn", true, "New Chart")) newChart(); + }}); + items.push_back({iconButtonWidth(), [this]() { + if (iconButton("new_tab_btn", icon::WINDOW_PLUS, "New Tab")) newTab(); + }}); + + const int type_count = (int)std::size(SERIES_TYPE_NAMES); + const std::string chart_type_text = std::string("Type: ") + SERIES_TYPE_NAMES[std::clamp(settings.chart_series_type, 0, type_count - 1)]; + auto chart_type_items = [this]() { + for (int i = 0; i < type_count; ++i) { + if (dropdown::Item(SERIES_TYPE_NAMES[i], nullptr, settings.chart_series_type == i)) { + settings.chart_series_type = i; + settingChanged(); + } + } + }; + items.push_back(toolbarMenu("chart_type", chart_type_text, "Type", chart_type_items)); + + const std::string columns_action_text = "Columns: " + std::to_string(column_count_); + if (columns_action_visible_) { + auto column_items = [this]() { + for (int i = 0; i < MAX_COLUMN_COUNT; ++i) { + if (dropdown::Item(std::to_string(i + 1).c_str(), nullptr, column_count_ == i + 1)) setColumnCount(i + 1); + } + }; + items.push_back(toolbarMenu("columns", columns_action_text, "Columns", column_items)); + } + + // the spacer right aligns the rest + const size_t spacer_index = items.size(); + size_t slider_index = (size_t)-1; + const std::string range_lb = is_zoomed ? std::string() : utils::formatSeconds(max_chart_range_); + std::string reset_zoom_text; + if (!is_zoomed) { + // the range label and the slider are one unit: drawn inline and moved to the overflow menu together + slider_index = items.size(); + const float label_width = ImGui::CalcTextSize(range_lb.c_str()).x + ImGui::GetStyle().ItemInnerSpacing.x; + items.push_back({label_width + slider_width, [this, &range_lb, &slider_width]() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(range_lb.c_str()); + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + // Restore the slider width in overflow; the toolbar may have shrunk it. + const bool in_menu = ImGui::GetCurrentWindow()->Flags & ImGuiWindowFlags_Popup; + const float width = in_menu ? std::max(ImGui::GetContentRegionAvail().x, 150.0f) : slider_width; + if (range_slider_.draw("##range_slider", width)) setMaxChartRange(range_slider_.value()); + ImGui::SetItemTooltip("Set the chart range"); + }}); + } else { + const auto &range = *can->timeRange(); + char buf[64]; + snprintf(buf, sizeof(buf), "%.2f-%.2f", range.first, range.second); + reset_zoom_text = buf; + // The undo/redo/reset buttons form one group. The reset button has a fixed width in the mono font, + // sized for the longest range the stream can show, so its neighbors do not shift as the range changes. + const int digits = std::max({1, (int)std::to_string((long long)can->maxSeconds()).size(), (int)std::to_string((long long)range.second).size()}); + const std::string widest = std::string(digits, '0') + ".00"; + pushMonoFont(ImGui::GetFontSize()); + const float reset_zoom_width = iconTextButtonWidth(icon::ZOOM_OUT, widest + "-" + widest); + popMonoFont(); + items.push_back({iconButtonWidth() * 2 + ImGui::GetStyle().ItemSpacing.x, [this]() { + ImGui::BeginDisabled(!zoom_undo_stack_.canUndo()); + if (iconButton("undo_zoom", icon::ARROW_COUNTERCLOCKWISE, "Undo Zoom")) zoom_undo_stack_.undo(); + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(!zoom_undo_stack_.canRedo()); + if (iconButton("redo_zoom", icon::ARROW_CLOCKWISE, "Redo Zoom")) zoom_undo_stack_.redo(); + ImGui::EndDisabled(); + }}); + items.push_back({reset_zoom_width, [this, &reset_zoom_text, reset_zoom_width]() { + pushMonoFont(ImGui::GetFontSize()); + const bool clicked = iconTextButton("reset_zoom_btn", icon::ZOOM_OUT, reset_zoom_text, reset_zoom_width); + popMonoFont(); + if (clicked) zoomReset(); + ImGui::SetItemTooltip("Reset Zoom"); + }}); + } + items.push_back(toolbarAction("remove_all_btn", icon::TRASH, "Remove all charts", [this]() { removeAll(); }, !charts_.empty())); + + // the slider shrinks first, the buttons stay pinned to the right edge + if (slider_index != (size_t)-1) { + const float shrink = std::min(slider_width - MIN_RANGE_SLIDER_WIDTH, toolbarWidth(items, spacer_index) - ImGui::GetContentRegionAvail().x); + if (shrink > 0.0f) { + slider_width -= shrink; + items[slider_index].width -= shrink; + } + } + drawToolbar(items, spacer_index); +} + +void ChartsWidget::settingChanged() { + if (range_slider_.maximum() != settings.max_cached_minutes * 60) { + range_slider_.setRange(1, settings.max_cached_minutes * 60); + } + for (auto &c : charts_) { + c->setSeriesType((SeriesType)settings.chart_series_type); + } +} + +ChartView *ChartsWidget::findChart(const MessageId &id, const cabana::Signal *sig) { + for (auto &c : charts_) + if (c->hasSignal(id, sig)) return c.get(); + return nullptr; +} + +ChartView *ChartsWidget::createChart(int pos) { + auto chart = std::make_unique(can->timeRange().value_or(display_range_), this); + ChartView *ptr = chart.get(); + pos = std::clamp(pos, 0, (int)charts_.size()); + charts_.insert(charts_.begin() + pos, std::move(chart)); + auto ¤t = currentCharts(); + current.insert(current.begin() + std::min(pos, (int)current.size()), ptr); + updateLayout(); + return ptr; +} + +void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { + ChartView *chart = findChart(id, sig); + if (show && !chart) { + chart = merge && currentCharts().size() > 0 ? currentCharts().front() : createChart(); + chart->addSignal(id, sig); + updateState(); + } else if (!show && chart) { + chart->removeIf([&](auto &s) { return s.msg_id == id && s.sig == sig; }); + } +} + +void ChartsWidget::splitChart(ChartView *src_chart) { + if (src_chart->signals().size() > 1) { + auto ¤t = currentCharts(); + const int pos = std::find(current.begin(), current.end(), src_chart) - current.begin() + 1; + for (auto &s : src_chart->takeExtraSignals()) { + createChart(pos)->adoptSignal(std::move(s)); + } + updateState(); + } +} + +std::vector ChartsWidget::serializeChartIds() const { + std::vector chart_ids; + for (auto &c : charts_) { + std::string ids; + for (const auto &s : c->signals()) { + if (!ids.empty()) ids += ','; + ids += s.msg_id.toString() + "|" + s.sig->name; + } + chart_ids.push_back(ids); + } + std::reverse(chart_ids.begin(), chart_ids.end()); + return chart_ids; +} + +void ChartsWidget::restoreChartsFromIds(const std::vector &chart_ids) { + for (const auto &chart_id : chart_ids) { + int index = 0; + for (const auto &part : utils::split(chart_id, ',')) { + const size_t sep = part.find('|'); + if (sep == std::string::npos) continue; + MessageId msg_id = MessageId::fromString(part.substr(0, sep)); + if (auto *msg = dbc()->msg(msg_id)) + if (auto *sig = msg->sig(part.substr(sep + 1))) + showChart(msg_id, sig, true, index++ > 0); + } + } +} + +void ChartsWidget::setColumnCount(int n) { + n = std::clamp(n, 1, MAX_COLUMN_COUNT); + if (column_count_ != n) { + column_count_ = settings.chart_column_count = n; + updateLayout(); + } +} + +void ChartsWidget::updateLayout() { + // the container has not been drawn yet (docked/floated this frame): keep the last known layout + const float container_width = charts_container_.geometry().GetWidth(); + if (container_width <= 0) return; + + int n = MAX_COLUMN_COUNT; + for (; n > 1; --n) { + if ((n * CHART_MIN_WIDTH + (n - 1) * ImGui::GetStyle().ItemSpacing.x) < container_width) break; + } + + columns_action_visible_ = n > 1; + current_column_count_ = std::min(column_count_, n); +} + +void ChartsWidget::startChartDrag(ChartView *chart, const ImVec2 &global_pos) { + stopAutoScroll(); + drag_ = {.source = chart, .press_pos = global_pos}; + showValueTip(-1); // no value tip while a drag is in progress + // the drag preview re-renders the tile at CHART_MIN_WIDTH + drag_preview_size_ = ImVec2(CHART_MIN_WIDTH, (float)settings.chart_height); +} + +void ChartsWidget::dragChartMove(const ImVec2 &global_pos) { + if (!drag_.active) { + ImVec2 d = global_pos - drag_.press_pos; + if (std::abs(d.x) + std::abs(d.y) < START_DRAG_DISTANCE) return; + drag_.active = true; + drag_preview_visible_ = true; + } + drag_preview_pos_ = global_pos + ImVec2(5, 5); + + // hovering a tab switches to it so the chart can be dropped into another tab + int tab = tabbar_.tabAt(global_pos); + if (tab >= 0 && tab != tabbar_.currentIndex()) { + tabbar_.setCurrentIndex(tab); + } + + ChartView *target = nullptr; + for (auto c : currentCharts()) { + if (c != drag_.source && c->rect().Contains(global_pos)) { + target = c; + break; + } + } + if (std::exchange(drop_target_, target) != target) { + for (auto &c : charts_) c->setDropHighlight(c.get() == target); + } + bool in_viewport = charts_scroll_viewport_.Contains(global_pos); + bool on_background = !target && in_viewport && !charts_container_.childAt(global_pos); + charts_container_.setDropIndicator(on_background ? global_pos : ImVec2()); + + if (in_viewport) { + startAutoScroll(global_pos); + } +} + +void ChartsWidget::cancelChartDrag() { + drag_ = {}; + stopAutoScroll(); + drag_preview_visible_ = false; + charts_container_.setDropIndicator({}); + if (auto target = std::exchange(drop_target_, nullptr)) target->setDropHighlight(false); +} + +void ChartsWidget::dragChartRelease(const ImVec2 &global_pos) { + ChartView *source = drag_.source; + bool active = drag_.active; + ChartView *target = drop_target_; + cancelChartDrag(); + if (!active) return; + + bool in_viewport = charts_scroll_viewport_.Contains(global_pos); + if (target) { + // merge source into target + target->takeSignalsFrom(source); + } else if (in_viewport && !charts_container_.childAt(global_pos)) { + // reorder within the current tab + auto w = charts_container_.getDropAfter(global_pos); + if (w != source) { + for (auto &[_, list] : tab_charts_) { + list.erase(std::remove(list.begin(), list.end(), source), list.end()); + } + auto &cur = currentCharts(); + int to = w ? std::find(cur.begin(), cur.end(), w) - cur.begin() + 1 : 0; + cur.insert(cur.begin() + to, source); + updateLayout(); + updateTabBar(); + } + } +} + +void ChartsWidget::drawDragPreview() { + if (!drag_preview_visible_ || !drag_.source) return; + // the drag preview is the whole tile (header + axes + plot) at 50% alpha, re-rendered into a window that + // takes no input, so the live chart keeps handling the mouse. + ImGui::SetNextWindowPos(drag_preview_pos_); + ImGui::SetNextWindowSize(drag_preview_size_); + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.5f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking; + if (ImGui::Begin("##chart_drag_ghost", nullptr, flags)) { + drag_.source->drawGhost(drag_preview_size_.x); + } + ImGui::End(); + ImGui::PopStyleVar(3); +} + +void ChartsWidget::startAutoScroll(const ImVec2 &global_pos) { + auto_scroll_pos_ = global_pos; + if (!auto_scroll_timer_active_) auto_scroll_timer_next_ = ImGui::GetTime() + 0.05; + auto_scroll_timer_active_ = true; +} + +void ChartsWidget::stopAutoScroll() { + auto_scroll_timer_active_ = false; + auto_scroll_count_ = 0; +} + +void ChartsWidget::doAutoScroll() { + if (!charts_scroll_) return; + const int page_step = charts_scroll_viewport_.GetHeight(); + if (auto_scroll_count_ < page_step) { + ++auto_scroll_count_; + } + + int value = charts_scroll_->Scroll.y; + ImVec2 pos = auto_scroll_pos_; + ImRect area = charts_scroll_viewport_; + + int new_value = value; + if (pos.y - area.Min.y < settings.chart_height / 2) { + new_value = value - auto_scroll_count_; + } else if (area.Max.y - pos.y < settings.chart_height / 2) { + new_value = value + auto_scroll_count_; + } + new_value = std::clamp(new_value, 0, charts_scroll_->ScrollMax.y); + if (new_value != value) ImGui::SetScrollY(charts_scroll_, new_value); + if (value == new_value) { + stopAutoScroll(); + } else if (chartDragActive()) { + // refresh the drop indicator/target at the new scroll position + dragChartMove(auto_scroll_pos_); + } +} + +void ChartsWidget::newChart() { + execSignalSelector(std::make_unique("New Chart"), nullptr, [this](SignalSelector &dlg) { + const auto &items = dlg.selectedItems(); + if (!items.empty()) { + auto c = createChart(); + for (const auto &it : items) { + c->addSignal(it.msg_id, it.sig); + } + updateState(); + } + }); +} + +void ChartsWidget::execSignalSelector(std::unique_ptr dlg, ChartView *owner, std::function accepted) { + signal_selector_ = std::move(dlg); + signal_selector_owner_ = owner; + signal_selector_accepted_ = std::move(accepted); + signal_selector_->open(); +} + +void ChartsWidget::removeChart(ChartView *chart) { + if (drag_.source == chart) cancelChartDrag(); + if (drop_target_ == chart) drop_target_ = nullptr; + if (signal_selector_owner_ == chart) { + signal_selector_owner_ = nullptr; + signal_selector_accepted_ = nullptr; + } + auto it = std::find_if(charts_.begin(), charts_.end(), [chart](auto &c) { return c.get() == chart; }); + if (it != charts_.end()) { + deleted_charts_.push_back(std::move(*it)); // may be called from the chart's draw; freed next frame + charts_.erase(it); + } + for (auto &[_, list] : tab_charts_) { + list.erase(std::remove(list.begin(), list.end(), chart), list.end()); + } + updateLayout(); + seriesChanged(); +} + +void ChartsWidget::removeAll() { + while (tabbar_.count() > 1) { + tabbar_.removeTab(1); + } + std::vector all; + for (auto &c : charts_) all.push_back(c.get()); + for (auto c : all) removeChart(c); + tab_charts_.clear(); + zoomReset(); +} + +void ChartsWidget::handleEvents() { + // the mouse back button undoes a zoom; there is no swipe-back gesture + if (ImGui::IsMouseClicked(3) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows)) { + zoom_undo_stack_.undo(); + } + if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow)) { + if (chartDragActive()) cancelChartDrag(); + showValueTip(-1); + } + + // route all mouse events to the chart drag, even when the source chart is hidden by a tab switch + if (chartDragActive()) { + if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + dragChartMove(ImGui::GetMousePos()); + } else { + dragChartRelease(ImGui::GetMousePos()); + } + } + + if (!value_tip_visible_) return; + + // The tip is drawn without an input item, so the mouse is never "on the tip". + const ImVec2 delta = ImGui::GetIO().MouseDelta; + if (!any_plot_hovered_ && + (delta.x != 0 || delta.y != 0 || !ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows))) { + showValueTip(-1); // the mouse moved off the plot or out of the charts window + } +} + +void ChartsWidget::draw() { + deleted_charts_.clear(); + ImGui::PushID(this); + if (auto_scroll_timer_active_ && ImGui::GetTime() >= auto_scroll_timer_next_) { + auto_scroll_timer_next_ = ImGui::GetTime() + 0.05; + doAutoScroll(); + } + // the drop target and indicator must be resolved before the charts are painted, otherwise the highlight + // lags a frame behind the target used on release and the drop lands on the wrong chart + handleEvents(); + + drawToolBar(); + tabbar_.draw(); + + any_plot_hovered_ = false; + if (ImGui::BeginChild("charts_scroll", ImVec2(0, 0), ImGuiChildFlags_None, 0)) { + charts_scroll_ = ImGui::GetCurrentWindow(); + charts_scroll_viewport_ = charts_scroll_->InnerRect; + charts_container_.draw(); + } + ImGui::EndChild(); + + drawDragPreview(); + + if (signal_selector_ && !signal_selector_->draw()) { + auto dlg = std::move(signal_selector_); + auto accepted = std::move(signal_selector_accepted_); + signal_selector_owner_ = nullptr; + if (dlg->accepted() && accepted) accepted(*dlg); + } + ImGui::PopID(); +} + +void ChartsContainer::draw() { + const ImVec2 start = ImGui::GetCursorScreenPos(); + const float width_avail = ImGui::GetContentRegionAvail().x; + geometry_ = ImRect(start, start + ImVec2(width_avail, 0)); + charts_widget_->updateLayout(); + + const int n = std::max(charts_widget_->current_column_count_, 1); + const float spacing = ImGui::GetStyle().ItemSpacing.x; + const float width = (geometry_.GetWidth() - (n - 1) * spacing) / n; + const ImVec2 origin = ImGui::GetCursorScreenPos(); + auto current_charts = charts_widget_->currentCharts(); // copy: drawing may remove charts + float bottom = origin.y; + const bool aligned = ImPlot::BeginAlignedPlots("charts_align", true); + for (int i = 0; i < current_charts.size(); ++i) { + ImVec2 pos = origin + ImVec2((i % n) * (width + spacing), (i / n) * (settings.chart_height + ImGui::GetStyle().ItemSpacing.y)); + ImGui::SetCursorScreenPos(pos); + current_charts[i]->draw(width); + bottom = std::max(bottom, pos.y + settings.chart_height); + if (current_charts[i]->plotHovered()) charts_widget_->any_plot_hovered_ = true; // the window must be hovered too + } + if (aligned) ImPlot::EndAlignedPlots(); + ImGui::SetCursorScreenPos(ImVec2(origin.x, bottom)); + ImGui::Dummy(ImVec2(geometry_.GetWidth(), ImGui::GetStyle().ItemSpacing.y)); + geometry_.Max.y = bottom + ImGui::GetStyle().ItemSpacing.y; + drawDropIndicator(); +} + +void ChartsContainer::drawDropIndicator() { + if (!(drop_indicator_pos_.x == 0 && drop_indicator_pos_.y == 0) && !childAt(drop_indicator_pos_)) { + ImRect r = geometry_; + r.Max.y = r.Min.y + ImGui::GetStyle().ItemSpacing.y; + if (auto insert_after = getDropAfter(drop_indicator_pos_)) { + float h = r.GetHeight(); + r.Min.y = insert_after->rect().Max.y; + r.Max.y = r.Min.y + h; + } + + ImGui::GetWindowDrawList()->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_Header), ImGui::GetStyle().FrameRounding); + } +} + +ChartView *ChartsContainer::getDropAfter(const ImVec2 &pos) const { + const auto &charts = charts_widget_->currentCharts(); + auto it = std::find_if(charts.crbegin(), charts.crend(), [&pos](auto c) { + const ImRect &area = c->rect(); + return pos.x >= area.Min.x && pos.x <= area.Max.x && pos.y >= area.Max.y; + }); + return it == charts.crend() ? nullptr : *it; +} + +ChartView *ChartsContainer::childAt(const ImVec2 &pos) const { + for (auto c : charts_widget_->currentCharts()) { + if (c->rect().Contains(pos)) return c; + } + return nullptr; +} diff --git a/openpilot/tools/cabana/ui/chart/chartswidget.h b/openpilot/tools/cabana/ui/chart/chartswidget.h new file mode 100644 index 000000000..16e170063 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/chartswidget.h @@ -0,0 +1,165 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/ui/chart/signalselector.h" +#include "tools/cabana/ui/widgets/tabbar.h" +#include "tools/cabana/commands.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/util.h" + +const int CHART_MIN_WIDTH = 300; + +// a slider whose value is mapped onto a log10 scale +class LogSlider { +public: + LogSlider(double factor) : scale_(factor) {} + + void setRange(double min, double max) { + scale_.setRange(min, max); + min_ = min; + max_ = max; + setValue(pos_); // the raw position is re-mapped as a value + } + int value() const { return scale_.value(pos_, minimum(), maximum()); } + void setValue(int v) { pos_ = scale_.position(v, minimum(), maximum()); } + int minimum() const { return min_; } + int maximum() const { return max_; } + bool draw(const char *label, float width); + +private: + LogScale scale_; + int min_ = 0; + int max_ = 1; + int pos_ = 0; +}; + +class ChartView; +class ChartsWidget; + +class ChartsContainer { +public: + ChartsContainer(ChartsWidget *parent) : charts_widget_(parent) {} + void setDropIndicator(const ImVec2 &pt) { drop_indicator_pos_ = pt; } + void draw(); // grid layout of the current tab's charts + ChartView *getDropAfter(const ImVec2 &pos) const; + ChartView *childAt(const ImVec2 &pos) const; + const ImRect &geometry() const { return geometry_; } // screen coordinates + +private: + void drawDropIndicator(); + + ImRect geometry_; + ChartsWidget *charts_widget_; + ImVec2 drop_indicator_pos_; +}; + +class ChartsWidget { +public: + ChartsWidget(); + ~ChartsWidget(); // out of line: the header users only see a forward declared ChartView + void draw(); // content only; MainWindow owns the dockable panel + size_t chartCount() const { return charts_.size(); } + void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); + inline bool hasSignal(const MessageId &id, const cabana::Signal *sig) { return findChart(id, sig) != nullptr; } + std::vector serializeChartIds() const; + void restoreChartsFromIds(const std::vector &chart_ids); + std::string whatsThis() const; + + void setColumnCount(int n); + void removeAll(); + + Observable<> seriesChanged; + Observable showTip; + +private: + void handleEvents(); // the back button, focus loss, the chart drag and the value tip leave + void newChart(); + ChartView *createChart(int pos = 0); + void removeChart(ChartView *chart); + void splitChart(ChartView *chart); + ImRect chartVisibleRect(ChartView *chart); + void eventsMerged(const MessageEventsMap &new_events); + void updateState(); + void zoomReset(); + void startChartDrag(ChartView *chart, const ImVec2 &global_pos); + void dragChartMove(const ImVec2 &global_pos); + void dragChartRelease(const ImVec2 &global_pos); + void cancelChartDrag(); + bool chartDragActive() const { return drag_.source != nullptr; } + void startAutoScroll(const ImVec2 &global_pos); + void stopAutoScroll(); + void doAutoScroll(); + void drawToolBar(); + void updateTabBar(); + void setMaxChartRange(int value); + void updateLayout(); + void settingChanged(); + void showValueTip(double sec); + void newTab(); + void removeTab(int index); + inline std::vector ¤tCharts() { return tab_charts_[tabbar_.tabData(tabbar_.currentIndex())]; } + ChartView *findChart(const MessageId &id, const cabana::Signal *sig); + // draws the selector until closed, then runs `accepted` (unless `owner` was removed) + void execSignalSelector(std::unique_ptr dlg, ChartView *owner, std::function accepted); + void drawDragPreview(); + + LogSlider range_slider_{1000}; + + UndoStack zoom_undo_stack_; + + std::vector> charts_; + std::unordered_map> tab_charts_; + TabBar tabbar_; + ChartsContainer charts_container_{this}; + ImGuiWindow *charts_scroll_ = nullptr; // the scroll area child window + ImRect charts_scroll_viewport_; + int max_chart_range_ = 0; + std::pair display_range_; + bool columns_action_visible_ = false; + int column_count_ = 1; + int current_column_count_ = 0; + struct ChartDrag { + ChartView *source = nullptr; + ImVec2 press_pos; // global + bool active = false; + } drag_; + // the drag preview is a 50% alpha copy of the whole chart tile, drawn in a window that takes no input + ImVec2 drag_preview_pos_; + ImVec2 drag_preview_size_; + bool drag_preview_visible_ = false; + ChartView *drop_target_ = nullptr; + int auto_scroll_count_ = 0; + ImVec2 auto_scroll_pos_; + bool auto_scroll_timer_active_ = false; + double auto_scroll_timer_next_ = 0; + bool value_tip_visible_ = false; + bool any_plot_hovered_ = false; + std::vector> deleted_charts_; // freed at the start of the next draw() + std::unique_ptr signal_selector_; + ChartView *signal_selector_owner_ = nullptr; + std::function signal_selector_accepted_; + Connections connections_; + friend class ChartView; + friend class ChartsContainer; +}; + +class ZoomCommand : public UndoCommand { +public: + ZoomCommand(std::pair range) : range(range) { + prev_range = can->timeRange(); + } + void undo() override { can->setTimeRange(prev_range); } + void redo() override { can->setTimeRange(range); } + std::optional> prev_range, range; +}; diff --git a/openpilot/tools/cabana/ui/chart/downsample.h b/openpilot/tools/cabana/ui/chart/downsample.h new file mode 100644 index 000000000..e523125b6 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/downsample.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace chart { + +// Keep endpoints and both extrema in time order in each pixel column. Only rendering +// uses this envelope; calculations, cursor values and exports retain every sample. +template +auto pixelEnvelope(Iterator begin, Iterator end, double min, double max, int pixels) { + using Point = typename std::iterator_traits::value_type; + std::vector result; + if (begin == end || pixels <= 0 || max <= min) return result; + result.reserve(std::min(end - begin, (size_t)pixels * 4)); + auto column = [&](const auto &p) { + return (int)std::clamp((p.x - min) / (max - min) * pixels, 0.0, double(pixels - 1)); + }; + while (begin != end) { + auto last = begin, low = begin, high = begin, next = begin + 1; + const int bucket = column(*begin); + while (next != end && column(*next) == bucket) { + if (next->y < low->y) low = next; + if (next->y > high->y) high = next; + last = next++; + } + std::array selected{begin, low, high, last}; + std::sort(selected.begin(), selected.end()); + auto unique_end = std::unique(selected.begin(), selected.end()); + for (auto it = selected.begin(); it != unique_end; ++it) result.push_back(**it); + begin = next; + } + return result; +} + +} // namespace chart diff --git a/openpilot/tools/cabana/ui/chart/signalselector.cc b/openpilot/tools/cabana/ui/chart/signalselector.cc new file mode 100644 index 000000000..2ea6d7012 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/signalselector.cc @@ -0,0 +1,160 @@ +#include "tools/cabana/ui/chart/signalselector.h" + +#include +#include + +#include "imgui.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/ui/chart/chart.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +SignalSelector::SignalSelector(std::string title) : title_(std::move(title)) { + std::set ids; + for (const auto &[id, _] : can->eventsMap()) ids.insert(id); + for (const auto &[id, _] : can->lastMessages()) ids.insert(id); + for (const auto &id : ids) { + if (auto m = dbc()->msg(id)) { + msgs_combo_.push_back({m->name + " (" + id.toString() + ")", id}); + } + } + std::sort(msgs_combo_.begin(), msgs_combo_.end(), [](auto &a, auto &b) { return a.text < b.text; }); +} + +bool SignalSelector::draw() { + if (!open_) return false; + const std::string popup_id = title_ + "###SignalSelector"; + if (!show_) { + ImGui::OpenPopup(popup_id.c_str()); + show_ = true; + } + setNextDialogWindow(ImVec2(700.0f, 450.0f)); + if (!ImGui::BeginPopupModal(popup_id.c_str(), nullptr, ImGuiWindowFlags_NoSavedSettings)) { + open_ = false; + return false; + } + + const float btn_w = iconButtonWidth(); + const float column_w = (ImGui::GetContentRegionAvail().x - btn_w - ImGui::GetStyle().ItemSpacing.x * 2) / 2; + // the selected list spans the combo row too; both lists end above the Ok/Cancel row + const float lists_h = ImGui::GetContentRegionAvail().y - ImGui::GetFrameHeightWithSpacing() * 3; + + ImGui::BeginGroup(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Available Signals"); + // a combo popup with a filter box + const char *preview = msgs_combo_index_ >= 0 ? msgs_combo_[msgs_combo_index_].text.c_str() : "Select a message..."; + ImGui::SetNextItemWidth(column_w); + if (dropdown::BeginCombo("##msgs_combo", preview)) { + if (ImGui::IsWindowAppearing()) { + msgs_combo_filter_.clear(); // reopen showing the full list + ImGui::SetKeyboardFocusHere(); + } + ImGui::SetNextItemWidth(-FLT_MIN); + inputText("##msgs_filter", &msgs_combo_filter_, "Select a message..."); + for (int i = 0; i < (int)msgs_combo_.size(); ++i) { + if (!msgs_combo_filter_.empty() && !utils::containsCI(msgs_combo_[i].text, msgs_combo_filter_)) continue; + if (dropdown::Item(msgs_combo_[i].text.c_str(), nullptr, i == msgs_combo_index_)) { + msgs_combo_index_ = i; + updateAvailableList(i); + ImGui::CloseCurrentPopup(); + } + } + dropdown::EndCombo(); + } + bool add_dbl = false; + drawList("##available_list", available_list_, &available_row_, false, &add_dbl, ImVec2(column_w, lists_h)); + ImGui::EndGroup(); + + ImGui::SameLine(); + ImGui::BeginGroup(); + ImGui::Dummy(ImVec2(btn_w, (lists_h + ImGui::GetFrameHeightWithSpacing() * 2) / 2 - ImGui::GetFrameHeight())); + ImGui::BeginDisabled(available_row_ == -1); + bool add_clicked = iconButton("add", icon::CHEVRON_RIGHT, "Add"); + ImGui::EndDisabled(); + ImGui::BeginDisabled(selected_row_ == -1); + bool remove_clicked = iconButton("remove", icon::CHEVRON_LEFT, "Remove"); + ImGui::EndDisabled(); + ImGui::EndGroup(); + + ImGui::SameLine(); + ImGui::BeginGroup(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Selected Signals"); + bool remove_dbl = false; + drawList("##selected_list", selected_list_, &selected_row_, true, &remove_dbl, ImVec2(column_w, lists_h + ImGui::GetFrameHeightWithSpacing())); + bool rejected = false; + dialogButtons("OK", &accepted_, &rejected); + const bool done = accepted_ || rejected; + ImGui::EndGroup(); + + if ((add_dbl || add_clicked) && available_row_ >= 0 && available_row_ < (int)available_list_.size()) { + add(available_row_); + } else if ((remove_dbl || remove_clicked) && selected_row_ >= 0 && selected_row_ < (int)selected_list_.size()) { + remove(selected_row_); + } + + if (done) { + open_ = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + return open_; +} + +void SignalSelector::drawList(const char *id, std::vector &list, int *current_row, bool show_msg_name, bool *double_clicked, const ImVec2 &size) { + if (!ImGui::BeginListBox(id, size)) return; + for (int i = 0; i < (int)list.size(); ++i) { + const auto &item = list[i]; + ImGui::PushID(i); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + if (selectable("##item", i == *current_row)) *current_row = i; + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + *current_row = i; + *double_clicked = true; + } + // label: colored square, signal name, then the message name/id in gray + ImDrawList *dl = ImGui::GetWindowDrawList(); + float x = pos.x + 5; + drawColorMarker(dl, ImVec2(x, pos.y), toImU32(item.sig->color)); + x += markerSize() + 4; + dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(i == *current_row ? palette().text_selected : palette().text), item.sig->name.c_str()); + if (show_msg_name) { + x += ImGui::CalcTextSize(item.sig->name.c_str()).x; + dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(i == *current_row ? palette().text_selected : palette().text_disabled), msgLabel(item.msg_id).c_str()); + } + ImGui::PopID(); + } + ImGui::EndListBox(); +} + +void SignalSelector::add(int row) { + const auto &item = available_list_[row]; + selected_list_.emplace_back(item.msg_id, item.sig); + available_list_.erase(available_list_.begin() + row); + available_row_ = -1; +} + +void SignalSelector::remove(int row) { + const auto &item = selected_list_[row]; + if (msgs_combo_index_ >= 0 && item.msg_id == msgs_combo_[msgs_combo_index_].id) { + available_list_.emplace_back(item.msg_id, item.sig); + } + selected_list_.erase(selected_list_.begin() + row); + selected_row_ = -1; +} + +void SignalSelector::updateAvailableList(int index) { + if (index == -1) return; + available_list_.clear(); + available_row_ = -1; + MessageId msg_id = msgs_combo_[index].id; + for (auto s : dbc()->msg(msg_id)->getSignals()) { + bool is_selected = std::any_of(selected_list_.begin(), selected_list_.end(), + [sig = s, &msg_id](auto &it) { return it.msg_id == msg_id && it.sig == sig; }); + if (!is_selected) { + available_list_.emplace_back(msg_id, s); + } + } +} diff --git a/openpilot/tools/cabana/ui/chart/signalselector.h b/openpilot/tools/cabana/ui/chart/signalselector.h new file mode 100644 index 000000000..675b6f003 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/signalselector.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbcmanager.h" + +// non-blocking: open(), draw() every frame until it returns false, then check accepted() +class SignalSelector { +public: + struct ListItem { + ListItem(const MessageId &msg_id, const cabana::Signal *sig) : msg_id(msg_id), sig(sig) {} + MessageId msg_id; + const cabana::Signal *sig; + }; + + SignalSelector(std::string title); + const std::vector &selectedItems() const { return selected_list_; } + inline void addSelected(const MessageId &id, const cabana::Signal *sig) { selected_list_.emplace_back(id, sig); } + void open() { open_ = true; show_ = false; accepted_ = false; } + bool draw(); // false once the dialog is closed + bool accepted() const { return accepted_; } + +private: + void updateAvailableList(int index); + void add(int row); + void remove(int row); + void drawList(const char *id, std::vector &list, int *current_row, bool show_msg_name, bool *double_clicked, const ImVec2 &size); + + struct ComboItem { + std::string text; + MessageId id; + }; + std::string title_; + std::vector msgs_combo_; + int msgs_combo_index_ = -1; + std::string msgs_combo_filter_; + std::vector available_list_; + std::vector selected_list_; + int available_row_ = -1; + int selected_row_ = -1; + bool accepted_ = false; + bool open_ = false; + bool show_ = false; +}; diff --git a/openpilot/tools/cabana/ui/chart/sparkline.cc b/openpilot/tools/cabana/ui/chart/sparkline.cc new file mode 100644 index 000000000..1e804f276 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/sparkline.cc @@ -0,0 +1,162 @@ +#include "tools/cabana/ui/chart/sparkline.h" + +#include +#include +#include + +#include "tools/cabana/ui/util.h" + +void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, ImVec2 sz, + double window_end) { + if (first == last || sz.x <= 0 || sz.y <= 0) { + render_points_.clear(); + size = {}; + return; + } + + points_.clear(); + min_val = std::numeric_limits::max(); + max_val = std::numeric_limits::lowest(); + points_.reserve(std::distance(first, last)); + + // x runs from the start of the time window, not from the first sample in it: the oldest sample drops + // out at its own rate, so anchoring to it slid the whole curve sideways by the sample spacing on every + // update and the sparkline jittered left and right instead of scrolling with the clock + const double window_start = window_end - range; + double value = 0.0; + for (auto it = first; it != last; ++it) { + if (sig->getValue((*it)->dat, (*it)->size, &value)) { + double x = can->toSeconds((*it)->mono_time) - window_start; + // the caller hands over a bit of data from before the window so the oldest samples walk out under + // the clip rect instead of vanishing at the left edge; they must not move the scale though + if (x >= 0.0) { + min_val = std::min(min_val, value); + max_val = std::max(max_val, value); + } + points_.push_back({x, value}); + } + } + if (min_val > max_val) { // nothing inside the window, the lead in is all there is + for (const auto &p : points_) { + min_val = std::min(min_val, p.y); + max_val = std::max(max_val, p.y); + } + } + + if (points_.empty()) { + render_points_.clear(); + size = {}; + return; + } + + freq_ = points_.size() / std::max(points_.back().x - points_.front().x, 1.0); + render(sig->color, range, sz, window_end); +} + +void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double window_end) { + bool is_flat_line = min_val == max_val; + if (is_flat_line) { + min_val -= 1.0; + max_val += 1.0; + } + + const double xscale = (sz.x - 1) / (double)range; + // Leave room for the stroke's antialiasing fringe and the 3x3 endpoint marker + // at both extrema, including the half-pixel offset applied by ImGui's strokes. + const double ypadding = std::min(3.0, sz.y / 2.0); + const double yscale = (sz.y - 2 * ypadding) / (max_val - min_val); + const double span = points_.back().x - points_.front().x; + bool draw_individual_points = (span * xscale / points_.size()) > 8.0; + + // transform or downsample the points + render_points_.reserve(points_.size()); + render_points_.clear(); + if (draw_individual_points) { + for (const auto &p : points_) { + render_points_.emplace_back(p.x * xscale, ypadding + (max_val - p.y) * yscale); + } + } else if (is_flat_line) { + double y = sz.y / 2.0; + render_points_.emplace_back(points_.front().x * xscale, y); + render_points_.emplace_back(points_.back().x * xscale, y); + } else { + double prev_y = points_.front().y; + render_points_.emplace_back(points_.front().x * xscale, ypadding + (max_val - prev_y) * yscale); + bool in_flat = false; + + for (size_t i = 1; i < points_.size(); ++i) { + const auto &p = points_[i]; + double y = p.y; + if (std::abs(y - prev_y) < 1e-6) { + in_flat = true; + } else { + if (in_flat) render_points_.emplace_back(points_[i - 1].x * xscale, ypadding + (max_val - prev_y) * yscale); + render_points_.emplace_back(p.x * xscale, ypadding + (max_val - y) * yscale); + in_flat = false; + } + prev_y = y; + } + if (in_flat) render_points_.emplace_back(points_.back().x * xscale, ypadding + (max_val - prev_y) * yscale); + } + + size = sz; + color_ = toImU32(sparklineColor(color)); + draw_individual_points_ = draw_individual_points; + window_end_ = window_end; + xscale_ = xscale; +} + +void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos, ImU32 color) const { + if (render_points_.empty()) return; + if (color == 0) color = color_; + + // update() only runs when a message of this id arrives, so a slow message would hold the sparkline + // still for many frames and then move it in one step. scroll the rendered polyline by the time that + // has passed since it was built, which keeps the motion at the frame rate whatever the message rate is + const float shift = std::clamp((float)((can->currentSec() - window_end_) * xscale_), 0.0f, size.x); + + // physical pixels: the framebuffer is 2x the logical size on hidpi displays + const float px = 1.0f / std::max(1.0f, ImGui::GetIO().DisplayFramebufferScale.x); + auto snap = [&](float x) { return std::floor(x / px) * px; }; + + // a sample sits at t * xscale + k on screen, where k moves with the clock. snapping k to whole pixels + // keeps every sample's subpixel phase fixed to its timestamp, so the line looks identical from frame + // to frame and from update to update and only ever moves by whole pixels; moving it by fractions + // shifted the antialiasing coverage every frame and the thin peaks sparkled + ImVec2 offset(pos.x - shift, pos.y); + const double k = offset.x - (window_end_ * xscale_ - (size.x - 1)); + offset.x += snap(k) - k; + auto point_at = [&](const ImVec2 &p) { return ImVec2(offset.x + p.x, offset.y + p.y); }; + + draw_list->PushClipRect(pos, ImVec2(pos.x + size.x, pos.y + size.y), true); + + // a point is a 3x3 square + auto draw_point = [&](const ImVec2 &p) { draw_list->AddRectFilled(ImVec2(p.x - 1.5f, p.y - 1.5f), ImVec2(p.x + 1.5f, p.y + 1.5f), color); }; + + // Keep ordinary curves joined so short segments don't leave antialiasing seams. + // Split at turns sharper than 120 degrees: a joined narrow spike folds back on + // itself and loses its tip. Both paths retain the actual peak and every sample. + const float thickness = 1.5f; + for (size_t i = 0; i < render_points_.size(); ++i) { + const auto &p = render_points_[i]; + draw_list->PathLineTo(point_at(p)); + if (i > 0 && i + 1 < render_points_.size()) { + const auto &prev = render_points_[i - 1]; + const auto &next = render_points_[i + 1]; + const double ax = p.x - prev.x, ay = p.y - prev.y; + const double bx = next.x - p.x, by = next.y - p.y; + const double dot = ax * bx + ay * by; + if (dot < 0 && 4 * dot * dot > (ax * ax + ay * ay) * (bx * bx + by * by)) { + draw_list->PathStroke(color, ImDrawFlags_None, thickness); + draw_list->PathLineTo(point_at(p)); + } + } + } + draw_list->PathStroke(color, ImDrawFlags_None, thickness); + if (draw_individual_points_) { + for (const auto &p : render_points_) draw_point(point_at(p)); + } else { + draw_point(point_at(render_points_.back())); + } + draw_list->PopClipRect(); +} diff --git a/openpilot/tools/cabana/ui/chart/sparkline.h b/openpilot/tools/cabana/ui/chart/sparkline.h new file mode 100644 index 000000000..991ecb8d4 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/sparkline.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbc.h" +#include "tools/cabana/streams/abstractstream.h" + +class Sparkline { +public: + void update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, ImVec2 sz, double window_end); + inline double freq() const { return freq_; } + bool isEmpty() const { return render_points_.empty(); } + // emits the rendered polyline at pos (top-left, screen coordinates) + void draw(ImDrawList *draw_list, ImVec2 pos, ImU32 color = 0) const; + + ImVec2 size = {}; // empty when isEmpty() + double min_val = 0; + double max_val = 0; + +private: + struct Point { + double x, y; + }; + void render(const CabanaColor &color, int range, ImVec2 sz, double window_end); + + std::vector points_; + std::vector render_points_; + ImU32 color_ = 0; + double window_end_ = 0; // the time the polyline was built for, so draw() can scroll it on from there + double xscale_ = 0; + bool draw_individual_points_ = false; + double freq_ = 0; +}; diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.cc b/openpilot/tools/cabana/ui/chart/tiplabel.cc new file mode 100644 index 000000000..83c693bab --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/tiplabel.cc @@ -0,0 +1,144 @@ +#define IMGUI_DEFINE_MATH_OPERATORS // ImVec2 arithmetic, must precede imgui.h +#include "tools/cabana/ui/chart/tiplabel.h" + +#include +#include + +#include "tools/cabana/ui/util.h" + +ImVec2 TipLabel::layoutLines(ImDrawList *p, const ImVec2 &origin, ImU32 fg) const { + const float font_size = ImGui::GetFontSize(); + const float line_height = std::ceil(ImGui::GetTextLineHeight() + 2); + const float marker = std::floor(markerSize()); + const float gap = 8; + const float width = column_widths_[0] + column_widths_[1] + column_widths_[2] + column_widths_[3] + gap * 3; + const ImU32 muted = ImGui::GetColorU32(ImGuiCol_TextDisabled); + float y = std::round(origin.y); + auto draw = [&](float x, const std::string &text, ImU32 color) { + if (p) p->AddText(ImVec2(std::round(x), y), color, text.c_str()); + }; + const char *heading = !text_.empty() && !text_[0].has_marker ? text_[0].name.c_str() : "Signal"; + const char *headers[] = {heading, "Value", "Min", "Max"}; + if (compact_) { + const float right = origin.x + std::max(0.0f, area_.GetWidth() - MARGIN * 2 - 1); + auto cell = [&](float left, const std::string &text, ImU32 color) { + if (p) drawElidedText(p, ImRect(ImVec2(left, y), ImVec2(right, y + font_size)), text, color); + }; + cell(origin.x, heading, muted); + y += line_height; + for (const auto &line : text_) { + if (!line.has_marker) continue; + if (p) p->AddRectFilled(ImVec2(origin.x, y + (font_size - marker) * 0.5f), + ImVec2(origin.x + marker, y + (font_size + marker) * 0.5f), line.marker); + cell(origin.x + marker + 6, line.name, fg); + y += line_height; + const std::string *values[] = {&line.value, &line.min, &line.max}; + const float label_width = ImGui::CalcTextSize("Value").x + gap; + for (int i = 0; i < 3; ++i) { + draw(origin.x, headers[i + 1], muted); + pushMonoFont(font_size); + cell(origin.x + label_width, *values[i], i == 0 ? fg : muted); + popMonoFont(); + y += line_height; + } + } + return ImVec2(right - origin.x, y - origin.y); + } + float x = origin.x; + for (int i = 0; i < 4; ++i) { + draw(i ? x + column_widths_[i] - ImGui::CalcTextSize(headers[i]).x : x, headers[i], muted); + x += column_widths_[i] + gap; + } + y += line_height; + if (p) p->AddLine(ImVec2(origin.x, y - 2), ImVec2(origin.x + width, y - 2), ImGui::GetColorU32(ImGuiCol_Border)); + for (const auto &line : text_) { + if (!line.has_marker) continue; + if (p) { + const ImVec2 marker_pos(std::round(origin.x), std::round(y + (font_size - marker) * 0.5f)); + p->AddRectFilled(marker_pos, marker_pos + ImVec2(marker, marker), line.marker); + } + if (p) drawElidedText(p, ImRect(ImVec2(origin.x + marker + 6, y), + ImVec2(origin.x + column_widths_[0], y + font_size)), line.name, fg); + x = origin.x + column_widths_[0] + gap; + pushMonoFont(font_size); + const std::string *values[] = {&line.value, &line.min, &line.max}; + for (int i = 0; i < 3; ++i) { + draw(x + column_widths_[i + 1] - ImGui::CalcTextSize(values[i]->c_str()).x, *values[i], i == 0 ? fg : muted); + x += column_widths_[i + 1] + gap; + } + popMonoFont(); + y += line_height; + } + return ImVec2(width, y - origin.y); +} + +ImVec2 TipLabel::sizeHint() const { + return layoutLines(nullptr, ImVec2(0, 0), 0) + ImVec2(MARGIN * 2, MARGIN * 2); +} + +void TipLabel::showText(const ImVec2 &pt, const std::vector &text, const ImRect &rect) { + bool same_signals = text.size() == text_.size(); + for (size_t i = 1; same_signals && i < text.size(); ++i) same_signals = text[i].name == text_[i].name; + if (!same_signals) column_widths_ = {}; + text_ = text; + anchor_ = pt; + area_ = rect; + visible_ = !text_.empty(); +} + +void TipLabel::updateLayout() { + // Playback notifications can update the text outside an ImGui window scope. + // Measure and position it only while the owning chart is being drawn. + column_widths_[0] = std::max(column_widths_[0], ImGui::CalcTextSize(text_.empty() ? "Signal" : text_[0].name.c_str()).x); + for (const auto &line : text_) { + if (line.has_marker) column_widths_[0] = std::max(column_widths_[0], std::ceil(markerSize() + 6 + ImGui::CalcTextSize(line.name.c_str()).x)); + } + pushMonoFont(ImGui::GetFontSize()); + const float number_width = std::ceil(ImGui::CalcTextSize("-0.00000").x); + for (int i = 1; i < 4; ++i) column_widths_[i] = std::max(column_widths_[i], number_width); + for (const auto &line : text_) { + const std::string *values[] = {&line.value, &line.min, &line.max}; + for (int i = 0; i < 3; ++i) column_widths_[i + 1] = std::max(column_widths_[i + 1], std::ceil(ImGui::CalcTextSize(values[i]->c_str()).x)); + } + popMonoFont(); + const ImGuiViewport *viewport = ImGui::GetWindowViewport(); + ImRect bounds(viewport->WorkPos, viewport->WorkPos + viewport->WorkSize); + bounds.ClipWith(area_); + if (bounds.GetWidth() <= 0 || bounds.GetHeight() <= 0) { + visible_ = false; + return; + } + area_ = bounds; + const float numeric_width = column_widths_[1] + column_widths_[2] + column_widths_[3] + 24 + MARGIN * 2 + 1; + compact_ = bounds.GetWidth() < numeric_width + ImGui::GetFontSize() * 4; + column_widths_[0] = std::min(column_widths_[0], std::max(40.0f, std::min(ImGui::GetFontSize() * 16, bounds.GetWidth() - numeric_width))); + if (!text_.empty()) { + ImVec2 extra(1, 1); + size_ = sizeHint() + extra; + ImVec2 tip_pos(anchor_.x + 8, area_.Min.y + 2); + if (anchor_.x >= area_.GetCenter().x) { + tip_pos.x = anchor_.x - size_.x - 8; + } + tip_pos.x = std::clamp(tip_pos.x, bounds.Min.x, std::max(bounds.Min.x, bounds.Max.x - size_.x)); + tip_pos.y = std::clamp(tip_pos.y, bounds.Min.y, std::max(bounds.Min.y, bounds.Max.y - size_.y)); + pos_ = tip_pos; + visible_ = true; + return; + } + visible_ = false; +} + +void TipLabel::draw(const ImRect &rect) { + if (!visible_) return; + area_ = rect; + updateLayout(); + if (!visible_) return; + + ImDrawList *p = ImGui::GetWindowDrawList(); + p->PushClipRect(area_.Min, area_.Max, true); + // filled panel with a 1px frame + p->AddRectFilled(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_PopupBg), ImGui::GetStyle().PopupRounding); + p->AddRect(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_Border), ImGui::GetStyle().PopupRounding); + layoutLines(p, pos_ + ImVec2(MARGIN, MARGIN), ImGui::GetColorU32(ImGuiCol_Text)); + p->PopClipRect(); +} diff --git a/openpilot/tools/cabana/ui/chart/tiplabel.h b/openpilot/tools/cabana/ui/chart/tiplabel.h new file mode 100644 index 000000000..7128a5638 --- /dev/null +++ b/openpilot/tools/cabana/ui/chart/tiplabel.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +// A signal row, or a time heading when has_marker is false. +struct TipLine { + bool has_marker = false; + ImU32 marker = 0; + std::string name; + std::string value, min, max; +}; + +class TipLabel { +public: + void showText(const ImVec2 &pt, const std::vector &text, const ImRect &rect); + void hide() { visible_ = false; } + bool isVisible() const { return visible_; } + void draw(const ImRect &rect); // call inside the owning chart window, after drawing the plot + +private: + // lays the lines out from origin, drawing them when p is given; returns the size of the text block + ImVec2 layoutLines(ImDrawList *p, const ImVec2 &origin, ImU32 fg) const; + ImVec2 sizeHint() const; + void updateLayout(); + + static constexpr float MARGIN = 6.0f; + std::vector text_; + std::array column_widths_{}; + ImVec2 anchor_; + ImRect area_; + ImVec2 pos_; + ImVec2 size_; + bool compact_ = false; + bool visible_ = false; +}; diff --git a/openpilot/tools/cabana/ui/dialogs/filedialog.cc b/openpilot/tools/cabana/ui/dialogs/filedialog.cc new file mode 100644 index 000000000..c03dcad20 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/filedialog.cc @@ -0,0 +1,224 @@ +#include "tools/cabana/ui/dialogs/filedialog.h" + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" + +namespace fs = std::filesystem; + +namespace FileDialog { + +namespace { + +// case insensitive, numeric aware collation ("mazda_3_2019" before "mazda_2017"): punctuation is ignored at +// the first level ("FORD_CADS_64" before "FORD_CADS.dbc"), digit runs compare numerically, case is ignored; +// ties fall back to a plain comparison +bool naturalLess(const std::string &a, const std::string &b) { + auto skip = [](const std::string &s, size_t &i) { + while (i < s.size() && !isalnum(static_cast(s[i]))) ++i; + }; + size_t i = 0, j = 0; + for (;;) { + skip(a, i); + skip(b, j); + if (i >= a.size() || j >= b.size()) break; + if (isdigit(static_cast(a[i])) && isdigit(static_cast(b[j]))) { + size_t ie = i, je = j; + while (ie < a.size() && isdigit(static_cast(a[ie]))) ++ie; + while (je < b.size() && isdigit(static_cast(b[je]))) ++je; + const unsigned long long na = std::stoull(a.substr(i, ie - i)), nb = std::stoull(b.substr(j, je - j)); + if (na != nb) return na < nb; + i = ie; + j = je; + } else { + const int ca = tolower(static_cast(a[i])), cb = tolower(static_cast(b[j])); + if (ca != cb) return ca < cb; + ++i; + ++j; + } + } + const bool a_done = i >= a.size(), b_done = j >= b.size(); + if (a_done != b_done) return a_done; + return a < b; +} + +enum class Mode { OpenFile, SaveFile, Directory }; + +struct State { + bool active = false; + Mode mode = Mode::OpenFile; + std::string title; + std::string extension; + fs::path dir; + std::string dir_input; + std::string filename; + std::vector entries; + Callback callback; +}; + +State g_state; +PopupOwner g_owner; + +void listDir() { + State &s = g_state; + s.entries.clear(); + std::error_code ec; + for (const auto &entry : fs::directory_iterator(s.dir, ec)) { + const std::string name = entry.path().filename().string(); + if (name.empty() || name[0] == '.') continue; + const bool is_dir = entry.is_directory(ec); + if (!is_dir && s.mode == Mode::Directory) continue; + if (!is_dir && !s.extension.empty() && entry.path().extension() != s.extension) continue; + s.entries.push_back(entry); + } + std::sort(s.entries.begin(), s.entries.end(), [](const auto &a, const auto &b) { + std::error_code sort_ec; + const bool da = a.is_directory(sort_ec), db = b.is_directory(sort_ec); + return da != db ? da : naturalLess(a.path().filename().string(), b.path().filename().string()); + }); + s.dir_input = s.dir.string(); +} + +void setDir(const fs::path &dir) { + std::error_code ec; + fs::path d = fs::is_directory(dir, ec) ? fs::absolute(dir, ec) : fs::current_path(ec); + g_state.dir = d.lexically_normal(); + listDir(); +} + +void start(Mode mode, const std::string &title, const fs::path &dir, const std::string &filename, + const std::string &extension, Callback cb) { + State &s = g_state; + s = State{}; + s.active = true; + s.mode = mode; + s.title = title; + s.extension = extension; + s.filename = filename; + s.callback = std::move(cb); + g_owner.reset(); + setDir(dir); +} + +void finish(const std::string &path) { + Callback cb = std::move(g_state.callback); + g_state = State{}; + g_owner.reset(); + if (cb) cb(path); +} + +void accept(const fs::path &path) { + if (g_state.mode == Mode::SaveFile) { + std::error_code ec; + if (fs::exists(path, ec)) { + const std::string name = path.filename().string(); + MessageBox::question(g_state.title, name + " already exists.\nDo you want to replace it?", [path](bool ok) { + if (ok) finish(path.string()); + }); + return; + } + } + finish(path.string()); +} + +} // namespace + +void getOpenFileName(const std::string &title, const std::string &dir, const std::string &extension, Callback cb) { + start(Mode::OpenFile, title, dir, "", extension, std::move(cb)); +} + +void getSaveFileName(const std::string &title, const std::string &default_path, const std::string &extension, Callback cb) { + const fs::path p(default_path); + start(Mode::SaveFile, title, p.parent_path(), p.filename().string(), extension, std::move(cb)); +} + +void getExistingDirectory(const std::string &title, const std::string &dir, Callback cb) { + start(Mode::Directory, title, dir, "", "", std::move(cb)); +} + +void draw() { + State &s = g_state; + if (!s.active) return; + const std::string popup_id = s.title + "###FileDialog"; + if (!beginDialog(popup_id.c_str(), &g_owner, ImVec2(640.0f, 480.0f), 0)) return; + + if (ImGui::Button("Up")) setDir(s.dir.parent_path()); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1.0f); + if (inputText("##dir", &s.dir_input, "", ImGuiInputTextFlags_EnterReturnsTrue)) setDir(s.dir_input); + + const float footer = ImGui::GetFrameHeightWithSpacing() * (s.mode == Mode::Directory ? 1.0f : 2.0f) + ImGui::GetStyle().ItemSpacing.y; + bool ok = false, cancel = false; + fs::path result, pending_dir; + ImGui::BeginChild("entries", ImVec2(0, -footer), ImGuiChildFlags_Borders); + std::error_code dir_ec; + for (size_t i = 0; i < s.entries.size(); ++i) { + const auto &entry = s.entries[i]; + const bool is_dir = entry.is_directory(dir_ec); + const std::string name = entry.path().filename().string(); + const std::string label = (is_dir ? std::string(icon::FOLDER) : std::string(icon::FILE_EARMARK)) + " " + name; + ImGui::PushID(static_cast(i)); + const bool selected = !is_dir && name == s.filename; + if (selectable(label.c_str(), selected, ImGuiSelectableFlags_AllowDoubleClick)) { + const bool double_clicked = ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left); + if (is_dir) { + if (double_clicked) { + pending_dir = entry.path(); + } else if (s.mode == Mode::Directory) { + s.filename = name; + } + } else { + s.filename = name; + if (double_clicked && s.mode == Mode::OpenFile) { + result = entry.path(); + ok = true; + } + } + } + ImGui::PopID(); + if (ok || !pending_dir.empty()) break; + } + ImGui::EndChild(); + if (!pending_dir.empty()) setDir(pending_dir); + + if (s.mode != Mode::Directory) { + const std::string filter = s.extension.empty() ? "*" : "*" + s.extension; + ImGui::SetNextItemWidth(-(ImGui::CalcTextSize(filter.c_str()).x + ImGui::GetStyle().ItemSpacing.x)); + if (inputText("##name", &s.filename, "File name", ImGuiInputTextFlags_EnterReturnsTrue)) ok = true; + ImGui::SameLine(); + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("%s", filter.c_str()); + } + const char *accept_label = s.mode == Mode::SaveFile ? "Save" : (s.mode == Mode::Directory ? "Choose" : "Open"); + dialogButtons(accept_label, &ok, &cancel); + + if (ok && result.empty()) { + if (s.mode == Mode::Directory) { + result = s.filename.empty() ? s.dir : s.dir / s.filename; + } else if (!s.filename.empty()) { + result = fs::path(s.filename).is_absolute() ? fs::path(s.filename) : s.dir / s.filename; + if (s.mode == Mode::SaveFile && !s.extension.empty() && result.extension().empty()) result += s.extension; + if (s.mode == Mode::OpenFile && !fs::is_regular_file(result, dir_ec)) ok = false; + } else { + ok = false; + } + } + if (ok || cancel) ImGui::CloseCurrentPopup(); + // nested so the overwrite prompt stacks on this dialog; it may finish the dialog through accept() + MessageBox::draw(); + if (!s.active) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (cancel) { + finish(""); + } else if (ok) { + accept(result); + } +} + +} // namespace FileDialog diff --git a/openpilot/tools/cabana/ui/dialogs/filedialog.h b/openpilot/tools/cabana/ui/dialogs/filedialog.h new file mode 100644 index 000000000..529f22317 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/filedialog.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include +#include + +// file browser. One dialog at a time; the callback gets an empty path on cancel. +namespace FileDialog { + +using Callback = std::function; + +void getOpenFileName(const std::string &title, const std::string &dir, const std::string &extension, Callback cb); +void getSaveFileName(const std::string &title, const std::string &default_path, const std::string &extension, Callback cb); +void getExistingDirectory(const std::string &title, const std::string &dir, Callback cb); + +void draw(); // once per frame, at the top popup level + +} // namespace FileDialog diff --git a/openpilot/tools/cabana/ui/dialogs/messagebox.cc b/openpilot/tools/cabana/ui/dialogs/messagebox.cc new file mode 100644 index 000000000..bac66037a --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/messagebox.cc @@ -0,0 +1,87 @@ +#include "tools/cabana/ui/dialogs/messagebox.h" + +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/ui/util.h" + +namespace MessageBox { + +namespace { + +struct Box { + std::string title; + std::string text; + std::string detailed_text; + bool has_cancel = false; + std::function on_result; +}; + +std::deque g_queue; +bool g_show_details = false; +PopupOwner g_owner; + +void push(Box box) { g_queue.push_back(std::move(box)); } + +std::function wrap(std::function on_close) { + if (!on_close) return nullptr; + return [on_close = std::move(on_close)](bool) { on_close(); }; +} + +} // namespace + +void information(const std::string &title, const std::string &text, std::function on_close) { + push({.title = title, .text = text, .on_result = wrap(std::move(on_close))}); +} + +void warning(const std::string &title, const std::string &text, const std::string &detailed_text, + std::function on_close) { + push({.title = title, .text = text, .detailed_text = detailed_text, .on_result = wrap(std::move(on_close))}); +} + +void question(const std::string &title, const std::string &text, std::function on_result) { + push({.title = title, .text = text, .has_cancel = true, .on_result = std::move(on_result)}); +} + +void draw() { + if (g_queue.empty()) return; + Box &box = g_queue.front(); + const std::string popup_id = box.title + "###MessageBox"; + const bool first = g_owner.popup_id == 0; + if (!g_owner.begin(popup_id.c_str())) return; + // AlwaysAutoResize sizes the popup from its contents; keep the title bar text from being clipped + const ImGuiStyle &style = ImGui::GetStyle(); + const float min_width = ImGui::CalcTextSize(box.title.c_str()).x + style.FramePadding.x * 2 + style.WindowPadding.x * 2; + ImGui::SetNextWindowSizeConstraints(ImVec2(min_width, 0.0f), ImVec2(FLT_MAX, FLT_MAX)); + setNextDialogWindow(ImVec2(0.0f, 0.0f)); + if (!ImGui::BeginPopupModal(popup_id.c_str(), nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings)) return; + if (first) g_show_details = false; + bool result = false, done = false; + ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + 480.0f); + ImGui::TextUnformatted(box.text.c_str()); + ImGui::PopTextWrapPos(); + if (g_show_details) { + ImGui::InputTextMultiline("##details", box.detailed_text.data(), box.detailed_text.size() + 1, + ImVec2(480.0f, 160.0f), ImGuiInputTextFlags_ReadOnly); + } + ImGui::Separator(); + if (!box.detailed_text.empty()) { + // the details button sits at the left of the button box + if (ImGui::Button(g_show_details ? "Hide Details" : "Show Details")) g_show_details = !g_show_details; + ImGui::SameLine(); + } + dialogButtons("OK", &result, &done, true, box.has_cancel ? "Cancel" : nullptr); + if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) result = true; + if (result) done = true; + if (done) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (done) { + g_owner.reset(); + Box finished = std::move(g_queue.front()); + g_queue.pop_front(); + if (finished.on_result) finished.on_result(result); + } +} + +} // namespace MessageBox diff --git a/openpilot/tools/cabana/ui/dialogs/messagebox.h b/openpilot/tools/cabana/ui/dialogs/messagebox.h new file mode 100644 index 000000000..1e30d33dd --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/messagebox.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +// Boxes are queued and shown one at a time as modal popups; the caller passes a continuation where it +// needs the answer. +namespace MessageBox { + +// on_close (optional) runs when the box is dismissed +void information(const std::string &title, const std::string &text, std::function on_close = nullptr); +void warning(const std::string &title, const std::string &text, const std::string &detailed_text = "", + std::function on_close = nullptr); +// Ok | Cancel; on_result(true) for Ok +void question(const std::string &title, const std::string &text, std::function on_result); + +void draw(); // once per frame, at the top popup level + +} // namespace MessageBox diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.cc b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc new file mode 100644 index 000000000..e669ac5fd --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.cc @@ -0,0 +1,274 @@ +#include "tools/cabana/ui/dialogs/routesdialog.h" + +#include +#include +#include +#include + +#include "json11/json11.hpp" +#include "tools/replay/py_downloader.h" +#include "tools/cabana/ui/theme.h" + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +namespace { +const char *PERIOD_NAMES[] = {"Last week", "Last 2 weeks", "Last month", "Last 6 months", "Preserved"}; +const int PERIOD_DAYS[] = {7, 14, 30, 180, -1}; +} // namespace + +void RoutesDialog::open(std::function on_done) { + if (alive_) return; + on_done_ = std::move(on_done); + open_ = false; + popup_.reset(); + s_ = State{}; + alive_ = std::make_shared(true); + + fetchDevices(); +} + +void RoutesDialog::fetchDevices() { + routes::fetchDevices([this, alive = std::weak_ptr(alive_)](std::vector devices, bool success, int error_code) { + utils::runOnMainThread(utils::guarded(alive.lock(), [this, devices = std::move(devices), success, error_code]() { + setDeviceList(devices, success, error_code); + })); + }); +} + +void RoutesDialog::setDeviceList(const std::vector &devices, bool success, int error_code) { + if (success) { + s_.login = false; + open_ = true; + s_.devices.clear(); + for (const auto &device : devices) s_.devices.push_back(device.dongle_id); + s_.devices_loaded = true; + s_.device_index = 0; + fetchRoutes(); + } else if (error_code == 401) { + s_.login = true; + open_ = true; + } else { + // Initial failures are shown on the calling window without opening the route browser. + MessageBox::warning("Error", "Network error", "", + utils::guarded(alive_, [this]() { finish(false); })); + } +} + +void RoutesDialog::fetchRoutes() { + if (!s_.devices_loaded || s_.devices.empty()) return; + + s_.routes.clear(); + s_.route_index = -1; + s_.empty_text = "Loading..."; + + const int request_id = ++s_.fetch_id; + auto on_routes = [this, alive = std::weak_ptr(alive_), request_id](std::vector list, bool success, int) { + utils::runOnMainThread(utils::guarded(alive.lock(), [this, list = std::move(list), success, request_id]() { + if (s_.fetch_id == request_id) setRouteList(list, success); + })); + }; + routes::fetchRoutes(s_.devices[s_.device_index], PERIOD_DAYS[s_.period_index], std::move(on_routes)); +} + +void RoutesDialog::setRouteList(const std::vector &list, bool success) { + if (success) { + for (const auto &route : list) { + const int mins = static_cast((route.end_ms - route.start_ms) / 60000); + s_.routes.push_back({routes::formatUnixMs(route.start_ms) + " " + std::to_string(mins) + " min", route.name}); + } + if (!s_.routes.empty()) s_.route_index = 0; + } else { + MessageBox::warning("Error", "Failed to fetch routes. Check your network connection.", "", + utils::guarded(alive_, [this]() { finish(false); })); + } + s_.empty_text = "No items"; +} + +void RoutesDialog::finish(bool accepted) { + if (auth_abort_) *auth_abort_ = true; + auth_abort_.reset(); + alive_.reset(); + open_ = false; + auto on_done = std::move(on_done_); + if (on_done) on_done(accepted, accepted && s_.route_index >= 0 ? s_.routes[s_.route_index].name : ""); +} + +void RoutesDialog::draw() { + if (!open_) return; + if (!beginDialog("Remote Routes", &popup_, ImVec2(480.0f, 420.0f))) return; + + if (s_.login) { + drawLogin(); + if (open_) { + MessageBox::draw(); + if (!open_) ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + return; + } + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Device"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1.0f); + if (s_.devices_loaded) { + if (comboBox("##device", &s_.device_index, s_.devices)) fetchRoutes(); + } else { + int idx = 0; + ImGui::BeginDisabled(); + comboBox("##device", &idx, {"Loading..."}); + ImGui::EndDisabled(); + } + ImGui::SetNextItemWidth(-1.0f); + if (dropdown::Combo("##period", &s_.period_index, PERIOD_NAMES, IM_ARRAYSIZE(PERIOD_NAMES))) fetchRoutes(); + + bool accepted = false, rejected = false; + const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; + ImGui::BeginChild("routes", ImVec2(0, -footer), ImGuiChildFlags_Borders); + if (s_.routes.empty()) { + const ImVec2 size = ImGui::CalcTextSize(s_.empty_text.c_str()); + const ImVec2 avail = ImGui::GetContentRegionAvail(); + ImGui::SetCursorPos(ImVec2((avail.x - size.x) * 0.5f, (avail.y - size.y) * 0.5f)); + ImGui::TextUnformatted(s_.empty_text.c_str()); + } + for (int i = 0; i < static_cast(s_.routes.size()); ++i) { + ImGui::PushID(i); + if (selectable(s_.routes[i].label.c_str(), s_.route_index == i, ImGuiSelectableFlags_AllowDoubleClick)) { + s_.route_index = i; + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) accepted = true; + } + ImGui::PopID(); + } + ImGui::EndChild(); + + dialogButtons("OK", &accepted, &rejected); + MessageBox::draw(); + if (accepted || rejected || !open_) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (accepted || rejected) finish(accepted); +} + +void RoutesDialog::signIn(const std::string &provider) { + s_.provider = provider == "google" ? "Google" : provider == "apple" ? "Apple" : "GitHub"; + s_.auth_error.clear(); + auth_abort_ = std::make_shared>(false); + std::thread([this, alive = std::weak_ptr(alive_), abort = auth_abort_, provider]() { + const std::string result = PyDownloader::authenticate(provider, abort.get()); + utils::runOnMainThread(utils::guarded(alive.lock(), [this, abort, result]() { + if (*abort) return; + auth_abort_.reset(); + std::string error; + auto status = json11::Json::parse(result, error); + if (status["success"].bool_value()) { + s_.login = false; + fetchDevices(); + } else { + s_.auth_error = status["error"].string_value(); + if (s_.auth_error.empty()) s_.auth_error = "Could not start sign-in. Please try again."; + } + })); + }).detach(); +} + +void RoutesDialog::drawLogin() { + const auto &p = palette(); + const char *providers[] = {"Google", "Apple", "GitHub"}; + const char *methods[] = {"google", "apple", "github"}; + const char *icons[] = {"\xef\x8f\xb0", "\xef\x99\x9b", "\xef\x8f\xad"}; + IconTextButtonOptions button_options{.height = 44.0f, .rounding = 8.0f, .icon_gap = 16.0f, .center_content = true}; + float button_width = iconTextButtonWidth("", "Choose another method", button_options); + for (int i = 0; i < 3; ++i) { + const std::string label = std::string("Sign in with ") + providers[i]; + button_width = std::max(button_width, iconTextButtonWidth(icons[i], label, button_options)); + button_options.label_width = std::max(button_options.label_width, ImGui::CalcTextSize(label.c_str()).x); + } + button_width += ImGui::GetStyle().FramePadding.x * 4; + // Keep the footer anchored while longer errors scroll inside the content area. + const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; + ImGui::BeginChild("login_content", ImVec2(0, -footer)); + ImGui::Indent(16); + const float gap = ImGui::GetStyle().ItemSpacing.y; + const float wrap_x = ImGui::GetWindowWidth() - 28; + const float text_width = wrap_x - ImGui::GetCursorPosX(); + const char *chooser_intro = "Use your comma account to browse recorded drives and open a route for analysis."; + const auto waiting_intro = [](const std::string &provider) { + return "Sign in with " + provider + " in your browser, then return to Cabana to choose a device and route."; + }; + const std::string intro = auth_abort_ ? waiting_intro(s_.provider) : chooser_intro; + const char *hint = "Use the comma account paired with your device."; + // Reserve the same space in every state so the heading and controls never jump. + float intro_height = ImGui::CalcTextSize(chooser_intro, nullptr, false, text_width).y; + for (const char *provider : providers) { + intro_height = std::max(intro_height, ImGui::CalcTextSize(waiting_intro(provider).c_str(), nullptr, false, text_width).y); + } + const float controls_height = 3 * (button_options.height + 2 * gap) + + ImGui::CalcTextSize(hint, nullptr, false, text_width).y; + const float content_height = 28 + 2 * gap + intro_height + 2 * gap + 12 + controls_height; + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + std::max(0.0f, (ImGui::GetContentRegionAvail().y - content_height) * 0.5f)); + ImGui::PushFont(boldFont(), 28.0f); + ImGui::TextUnformatted("Open your routes in Cabana"); + ImGui::PopFont(); + ImGui::Spacing(); + ImGui::PushTextWrapPos(wrap_x); + const float controls_y = ImGui::GetCursorPosY() + intro_height + 2 * gap + 12; + const float buttons_x = ImGui::GetCursorPosX() + (ImGui::GetContentRegionAvail().x - 16 - button_width) * 0.5f; + if (auth_abort_) { + ImGui::TextWrapped("%s", intro.c_str()); + ImGui::SetCursorPosY(controls_y); + ImGui::BeginChild("auth_status", ImVec2(-16, 76), ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground); + const char *status = "Waiting for browser sign-in"; + const char *timeout = "This request expires after 3 minutes."; + const float spinner_size = ImGui::GetFontSize(); + const float status_width = spinner_size + 8 + ImGui::CalcTextSize(status).x; + ImGui::SetCursorPos(ImVec2((ImGui::GetWindowWidth() - status_width) * 0.5f, 16)); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const float angle = std::fmod(ImGui::GetTime() * 4.0, 2.0 * IM_PI); + auto *draw_list = ImGui::GetWindowDrawList(); + draw_list->PathArcTo(ImVec2(pos.x + spinner_size * 0.5f, pos.y + spinner_size * 0.5f), + spinner_size * 0.35f, angle, angle + IM_PI * 1.5f, 24); + draw_list->PathStroke(ImGui::GetColorU32(p.accent), 0, 2.0f); + ImGui::Dummy(ImVec2(spinner_size, spinner_size)); + ImGui::SameLine(0, 8); + ImGui::TextUnformatted(status); + ImGui::SetCursorPos(ImVec2((ImGui::GetWindowWidth() - ImGui::CalcTextSize(timeout).x) * 0.5f, 40)); + ImGui::TextUnformatted(timeout); + ImGui::EndChild(); + ImGui::SetCursorPosY(controls_y + 2 * (button_options.height + 2 * gap)); + ImGui::SetCursorPosX(buttons_x); + if (iconTextButton("auth_retry", "", "Choose another method", button_width, button_options)) { + *auth_abort_ = true; + auth_abort_.reset(); + } + } else { + ImGui::TextWrapped("%s", intro.c_str()); + ImGui::SetCursorPosY(controls_y); + const char *selected_method = nullptr; + for (int i = 0; i < 3; ++i) { + ImGui::SetCursorPosX(buttons_x); + if (iconTextButton(methods[i], icons[i], std::string("Sign in with ") + providers[i], + button_width, button_options)) selected_method = methods[i]; + ImGui::Spacing(); + } + const char *message = s_.auth_error.empty() ? hint : s_.auth_error.c_str(); + const float width = ImGui::GetContentRegionAvail().x - 16; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (width - ImGui::CalcTextSize(message).x) * 0.5f)); + ImGui::TextWrapped("%s", message); + // Finish drawing this state before signIn clears its error text. + if (selected_method) signIn(selected_method); + } + ImGui::PopTextWrapPos(); + ImGui::Unindent(16); + ImGui::EndChild(); + ImGui::Separator(); + bool rejected = false; + dialogButtons("Cancel", &rejected, nullptr, true, nullptr); + if (rejected || dialogEscapePressed()) { + ImGui::CloseCurrentPopup(); + finish(false); + } +} diff --git a/openpilot/tools/cabana/ui/dialogs/routesdialog.h b/openpilot/tools/cabana/ui/dialogs/routesdialog.h new file mode 100644 index 000000000..c0f6db791 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/routesdialog.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "tools/cabana/routes.h" +#include "tools/cabana/ui/util.h" + +// "Remote routes" browser. on_done gets accepted=true with the selected route name ("" if none), accepted=false on cancel. +class RoutesDialog { +public: + ~RoutesDialog() { if (auth_abort_) *auth_abort_ = true; } + void open(std::function on_done); + void draw(); + bool isActive() const { return alive_ != nullptr; } + +private: + void fetchDevices(); + void signIn(const std::string &provider); + void drawLogin(); + void setDeviceList(const std::vector &devices, bool success, int error_code); + void setRouteList(const std::vector &list, bool success); + void fetchRoutes(); + void finish(bool accepted); + + struct RouteItem { + std::string label; + std::string name; + }; + + struct State { + bool login = false; + std::string provider; + std::string auth_error; + bool devices_loaded = false; + std::vector devices; + int device_index = 0; + int period_index = 0; + std::vector routes; + int route_index = -1; + std::string empty_text = "No items"; + int fetch_id = 0; // the reply of an older request is dropped + }; + + std::shared_ptr> auth_abort_; + bool open_ = false; + PopupOwner popup_; + State s_; + std::function on_done_; + // created by open() and reset by finish(); guards main-thread callbacks from detached worker threads + std::shared_ptr alive_; +}; diff --git a/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc new file mode 100644 index 000000000..c8dd4fb66 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/settingsdialog.cc @@ -0,0 +1,111 @@ +#include "tools/cabana/ui/dialogs/settingsdialog.h" + +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +namespace { + +const int MIN_CACHE_MINUTES = 30; +const int MAX_CACHE_MINUTES = 120; + +// the label sits in the left column, the field in the right one, all fields aligned +enum FormLabel { THEME, CACHED_MINUTES, DRAG_DIRECTION, CHART_HEIGHT, FORM_LABEL_COUNT }; +const char *FORM_LABELS[FORM_LABEL_COUNT] = {"Color Theme", "Max Cached Minutes", "Drag Direction", "Chart Height"}; + +float formLabelWidth() { + float w = 0.0f; + for (const char *label : FORM_LABELS) w = std::max(w, ImGui::CalcTextSize(label).x); + return ImGui::GetCursorPosX() + w + ImGui::GetStyle().ItemSpacing.x; +} + +void formRow(FormLabel label, float label_width) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(FORM_LABELS[label]); + ImGui::SameLine(label_width); + ImGui::SetNextItemWidth(-FLT_MIN); +} + +void settingInputInt(const char *id, int *value, int step, int step_fast, int minimum, int maximum) { + inputInt((std::string("##") + id).c_str(), value, step, step_fast); + *value = std::clamp(*value, minimum, maximum); +} + +} // namespace + +void SettingsDialog::open() { + theme_ = settings.theme; + cached_minutes_ = settings.max_cached_minutes; + drag_direction_ = settings.drag_direction; + chart_height_ = settings.chart_height; + log_livestream_ = settings.log_livestream; + log_path_ = settings.log_path; + open_ = true; + popup_.reset(); +} + +void SettingsDialog::draw() { + if (!open_) return; + if (!beginDialog("Settings", &popup_, ImVec2(400.0f, 0.0f))) return; + const float label_width = formLabelWidth(); + + ImGui::SeparatorText("General"); + static const char *themes[] = {"Light", "Dark"}; + formRow(THEME, label_width); + int theme_index = theme_ - LIGHT_THEME; + if (dropdown::Combo("##theme", &theme_index, themes, IM_ARRAYSIZE(themes))) theme_ = theme_index + LIGHT_THEME; + formRow(CACHED_MINUTES, label_width); + settingInputInt("cached_minutes", &cached_minutes_, 1, 10, MIN_CACHE_MINUTES, MAX_CACHE_MINUTES); + + ImGui::SeparatorText("New Signal Settings"); + static const char *directions[] = {"MSB First", "LSB First", "Always Little Endian", "Always Big Endian"}; + formRow(DRAG_DIRECTION, label_width); + dropdown::Combo("##drag_direction", &drag_direction_, directions, IM_ARRAYSIZE(directions)); + + ImGui::SeparatorText("Chart"); + formRow(CHART_HEIGHT, label_width); + settingInputInt("chart_height", &chart_height_, 10, 10, 100, 500); + + checkBox("Enable live stream logging", &log_livestream_); + ImGui::BeginDisabled(!log_livestream_); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Browse...") + ImGui::GetStyle().ItemSpacing.x)); + inputText("##log_path", &log_path_, "", ImGuiInputTextFlags_ReadOnly); + ImGui::SameLine(); + if (ImGui::Button("Browse...")) { + FileDialog::getExistingDirectory("Log File Location", utils::homePath(), [this](const std::string &fn) { + if (!fn.empty()) log_path_ = fn; + }); + } + ImGui::EndDisabled(); + + ImGui::Separator(); + bool accepted = false, done = false; + dialogButtons("OK", &accepted, &done); + if (accepted) { + save(); + done = true; + } + FileDialog::draw(); // nested so the directory picker stacks on this modal + if (done) { + open_ = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void SettingsDialog::save() { + if (std::exchange(settings.theme, theme_) != settings.theme) applyTheme(settings.theme); + settings.max_cached_minutes = cached_minutes_; + settings.chart_height = chart_height_; + settings.log_livestream = log_livestream_; + settings.log_path = log_path_; + settings.drag_direction = (Settings::DragDirection)drag_direction_; + settings.changed(); +} diff --git a/openpilot/tools/cabana/ui/dialogs/settingsdialog.h b/openpilot/tools/cabana/ui/dialogs/settingsdialog.h new file mode 100644 index 000000000..c9947a91a --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/settingsdialog.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +#include "tools/cabana/core/settings.h" +#include "tools/cabana/ui/util.h" + +class SettingsDialog { +public: + void open(); + void draw(); + +private: + void save(); + + bool open_ = false; + PopupOwner popup_; + int theme_ = 0; + int cached_minutes_ = 0; + int drag_direction_ = 0; + int chart_height_ = 0; + bool log_livestream_ = false; + std::string log_path_; +}; diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.cc b/openpilot/tools/cabana/ui/dialogs/streamselector.cc new file mode 100644 index 000000000..d32021610 --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.cc @@ -0,0 +1,329 @@ +#include "tools/cabana/ui/dialogs/streamselector.h" + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/streams/devicestream.h" +#include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +void OpenReplayWidget::draw() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Route"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Remote Route...") + toolbarButtonWidth("Local Route...") + + ImGui::GetStyle().ItemSpacing.x * 2)); + inputText("##route", &route_, "Enter a route name or browse for a local or remote route"); + ImGui::SameLine(); + ImGui::BeginDisabled(routes_dialog_.isActive()); + if (ImGui::Button("Remote Route...")) { + routes_dialog_.open(utils::guarded(alive_, [this](bool accepted, const std::string &route) { + if (accepted) route_ = route; + })); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button("Local Route...")) { + FileDialog::getExistingDirectory("Open Local Route", settings.last_route_dir, utils::guarded(alive_, [this](const std::string &dir) { + if (!dir.empty()) { + route_ = dir; + settings.last_route_dir = std::filesystem::absolute(dir).parent_path().string(); + } + })); + } + checkBox("Road camera", &cameras_[0]); + ImGui::SameLine(); + checkBox("Driver camera", &cameras_[1]); + ImGui::SameLine(); + checkBox("Wide road camera", &cameras_[2]); +} + +void OpenReplayWidget::drawPopups() { + routes_dialog_.draw(); +} + +std::unique_ptr OpenReplayWidget::open() { + std::string route = route_; + std::string data_dir; + if (auto idx = route.rfind('/'); idx != std::string::npos && util::file_exists(route)) { + data_dir = route.substr(0, idx + 1); + route = route.substr(idx + 1); + } + + bool is_valid_format = Route::parseRoute(route).str.size() > 0; + if (!is_valid_format) { + MessageBox::warning("Warning", "Invalid route format: '" + route + "'"); + } else { + auto replay_stream = std::make_unique(); + Connection err = replay_stream->error.connect([](const std::string &msg) { + MessageBox::warning("Error", msg); + }); + uint32_t flags = REPLAY_FLAG_NONE; + if (cameras_[1]) flags |= REPLAY_FLAG_CABIN_CAMERA; + if (cameras_[2]) flags |= REPLAY_FLAG_WIDE_ROAD; + if (flags == REPLAY_FLAG_NONE && !cameras_[0]) flags = REPLAY_FLAG_NO_VIPC; + + if (replay_stream->loadRoute(route, data_dir, flags)) { + return replay_stream; + } + } + return nullptr; +} + +namespace { +const uint32_t speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U}; +const uint32_t data_speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U, 2000U, 5000U}; +} + +OpenPandaWidget::OpenPandaWidget() { + if (can && dynamic_cast(can) != nullptr) { + already_connected_ = true; + return; + } + refreshSerials(); + buildConfigForm(); +} + +void OpenPandaWidget::refreshSerials() { + serials_ = Panda::list(); + serial_index_ = 0; +} + +void OpenPandaWidget::buildConfigForm() { + std::string serial = serial_index_ < static_cast(serials_.size()) ? serials_[serial_index_] : ""; + has_fd_ = false; + has_panda_ = !serial.empty(); + if (has_panda_) { + try { + Panda panda(serial); + has_fd_ = (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA) || (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA_V2); + } catch (const std::exception &e) { + fprintf(stderr, "failed to open panda %s\n", serial.c_str()); + has_panda_ = false; + } + } + + if (has_panda_) { + config.serial = serial; + config.bus_config.resize(3); + can_speed_index_.assign(3, 0); + data_speed_index_.assign(3, 0); + for (int i = 0; i < 3; i++) { + for (int j = 0; j < static_cast(std::size(speeds)); j++) { + if (speeds[j] == config.bus_config[i].can_speed_kbps) can_speed_index_[i] = j; + } + for (int j = 0; j < static_cast(std::size(data_speeds)); j++) { + if (data_speeds[j] == config.bus_config[i].data_speed_kbps) data_speed_index_[i] = j; + } + } + } else { + config.serial = ""; + } +} + +void OpenPandaWidget::draw() { + if (already_connected_) { + ImGui::Text("Already connected to %s.", can->routeName().c_str()); + ImGui::TextUnformatted("Select File > Close Stream before connecting to another panda."); + return; + } + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Serial"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Refresh") + ImGui::GetStyle().ItemSpacing.x)); + if (comboBox("##serial", &serial_index_, serials_)) buildConfigForm(); + ImGui::SameLine(); + if (ImGui::Button("Refresh")) { + refreshSerials(); + buildConfigForm(); + } + + if (!has_panda_) { + ImGui::TextUnformatted("No panda found"); + return; + } + for (int i = 0; i < static_cast(config.bus_config.size()); i++) { + ImGui::PushID(i); + ImGui::AlignTextToFramePadding(); + ImGui::Text("Bus %d:", i); + ImGui::SameLine(); + ImGui::TextUnformatted("CAN Speed (kbps):"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (comboBox("##can_speed", &can_speed_index_[i], speeds, (int)std::size(speeds))) { + config.bus_config[i].can_speed_kbps = speeds[can_speed_index_[i]]; + } + if (has_fd_) { + ImGui::SameLine(); + checkBox("CAN-FD", &config.bus_config[i].can_fd); + ImGui::SameLine(); + ImGui::TextUnformatted("Data Speed (kbps):"); + ImGui::SameLine(); + ImGui::BeginDisabled(!config.bus_config[i].can_fd); + ImGui::SetNextItemWidth(90.0f); + if (comboBox("##data_speed", &data_speed_index_[i], data_speeds, (int)std::size(data_speeds))) { + config.bus_config[i].data_speed_kbps = data_speeds[data_speed_index_[i]]; + } + ImGui::EndDisabled(); + } + ImGui::PopID(); + } +} + +std::unique_ptr OpenPandaWidget::open() { + try { + return std::make_unique(config); + } catch (std::exception &e) { + MessageBox::warning("Warning", std::string("Failed to connect to panda: '") + e.what() + "'"); + return nullptr; + } +} + +void OpenDeviceWidget::draw() { + ImGui::RadioButton("MSGQ", &mode_, 0); + ImGui::RadioButton("ZMQ", &mode_, 1); + // the radio buttons are the label column, the ip address is the field column + const float label_width = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemInnerSpacing.x + + std::max(ImGui::CalcTextSize("MSGQ").x, ImGui::CalcTextSize("ZMQ").x) + + ImGui::GetStyle().ItemInnerSpacing.x; + ImGui::SameLine(label_width); + ImGui::BeginDisabled(mode_ != 1); + ImGui::SetNextItemWidth(-1.0f); + validatedText("##ip", &ip_address_, validateIpAddress, "Enter device IP address", ipValidator); + ImGui::EndDisabled(); +} + +std::unique_ptr OpenDeviceWidget::open() { + std::string ip = ip_address_.empty() ? "127.0.0.1" : ip_address_; + bool msgq = mode_ == 0; + return std::make_unique(msgq ? "" : ip); +} + +#ifdef __linux__ + +OpenSocketCanWidget::OpenSocketCanWidget() { + refreshDevices(); +} + +void OpenSocketCanWidget::refreshDevices() { + devices_.clear(); + // type 280 = ARPHRD_CAN + std::error_code ec; + for (const auto &entry : std::filesystem::directory_iterator("/sys/class/net", ec)) { + std::ifstream type_file(entry.path() / "type"); + int type = 0; + if (type_file >> type && type == 280) { + devices_.push_back(entry.path().filename().string()); + } + } + device_index_ = 0; + config.device = devices_.empty() ? "" : devices_[0]; +} + +void OpenSocketCanWidget::draw() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Device"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Refresh") + ImGui::GetStyle().ItemSpacing.x)); + if (comboBox("##device", &device_index_, devices_)) config.device = devices_[device_index_]; + ImGui::SameLine(); + if (ImGui::Button("Refresh")) refreshDevices(); +} + +std::unique_ptr OpenSocketCanWidget::open() { + try { + return std::make_unique(config); + } catch (std::exception &e) { + MessageBox::warning("Warning", std::string("Failed to connect to SocketCAN device: '") + e.what() + "'"); + return nullptr; + } +} +#endif + +void StreamSelector::open(Callback on_done) { + on_done_ = std::move(on_done); + open_ = true; + popup_.reset(); + first_frame_ = true; + dbc_file_.clear(); + widgets_.clear(); + widgets_.push_back(std::make_unique()); + widgets_.push_back(std::make_unique()); +#ifdef __linux__ + if (SocketCanStream::available()) { + widgets_.push_back(std::make_unique()); + } +#endif + widgets_.push_back(std::make_unique()); +} + +void StreamSelector::draw() { + if (!open_) return; + if (!beginDialog("Open Stream", &popup_, ImVec2(768.0f, 0.0f))) return; + + AbstractOpenStreamWidget *current = nullptr; + const ImVec4 pane = ImGui::GetStyleColorVec4(ImGuiCol_WindowBg); + ImGui::PushStyleColor(ImGuiCol_ChildBg, pane); + ImGui::PushStyleColor(ImGuiCol_TabSelected, pane); + if (ImGui::BeginTabBar("streams")) { + for (auto &w : widgets_) { + // a fresh dialog every time, so the first tab is always the current one + ImGuiTabItemFlags tab_flags = (first_frame_ && w == widgets_.front()) ? ImGuiTabItemFlags_SetSelected : 0; + if (ImGui::BeginTabItem(w->title(), nullptr, tab_flags)) { + current = w.get(); + ImGui::BeginChild("tab", ImVec2(0, 130.0f), ImGuiChildFlags_Borders); + w->draw(); + ImGui::EndChild(); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } + ImGui::PopStyleColor(2); + first_frame_ = false; + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("DBC File"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-(toolbarButtonWidth("Browse...") + ImGui::GetStyle().ItemSpacing.x)); + inputText("##dbc", &dbc_file_, "Choose a DBC file to open", ImGuiInputTextFlags_ReadOnly); + ImGui::SameLine(); + if (ImGui::Button("Browse...")) { + FileDialog::getOpenFileName("Open File", settings.last_dir, ".dbc", [this](const std::string &fn) { + if (!fn.empty()) { + dbc_file_ = fn; + settings.last_dir = std::filesystem::absolute(fn).parent_path().string(); + } + }); + } + ImGui::Separator(); + + bool accepted = false, rejected = false; + std::unique_ptr stream; + bool open_clicked = false; + dialogButtons("Open", &open_clicked, &rejected, current != nullptr && current->openEnabled()); + if (open_clicked) { + if (stream = current->open(); stream) accepted = true; + } + + // nested so they stack on this modal + if (current) current->drawPopups(); + FileDialog::draw(); + MessageBox::draw(); + + if (accepted || rejected) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (accepted || rejected) { + open_ = false; + widgets_.clear(); + auto on_done = std::move(on_done_); + if (on_done) on_done(std::move(stream), dbc_file_); + } +} diff --git a/openpilot/tools/cabana/ui/dialogs/streamselector.h b/openpilot/tools/cabana/ui/dialogs/streamselector.h new file mode 100644 index 000000000..b57e602ca --- /dev/null +++ b/openpilot/tools/cabana/ui/dialogs/streamselector.h @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/streams/pandastream.h" +#ifdef __linux__ +#include "tools/cabana/streams/socketcanstream.h" +#endif +#include "tools/cabana/ui/dialogs/routesdialog.h" +#include "tools/cabana/ui/util.h" + +class AbstractOpenStreamWidget { +public: + virtual ~AbstractOpenStreamWidget() = default; + virtual const char *title() const = 0; + virtual void draw() = 0; + // nested dialogs, drawn at the modal's level: a popup opened from inside the tab's child window is + // never the top-most modal's own call site, so PopupOwner would skip it + virtual void drawPopups() {} + virtual std::unique_ptr open() = 0; + virtual bool openEnabled() const { return true; } +}; + +class OpenReplayWidget : public AbstractOpenStreamWidget { +public: + const char *title() const override { return "Replay"; } + void draw() override; + void drawPopups() override; + std::unique_ptr open() override; + +private: + std::string route_; + bool cameras_[3] = {true, false, false}; + RoutesDialog routes_dialog_; + // guards dialog continuations that outlive the stream selector + std::shared_ptr alive_ = std::make_shared(true); +}; + +class OpenPandaWidget : public AbstractOpenStreamWidget { +public: + OpenPandaWidget(); + const char *title() const override { return "Panda"; } + void draw() override; + std::unique_ptr open() override; + bool openEnabled() const override { return !already_connected_; } + +private: + void refreshSerials(); + void buildConfigForm(); + + bool already_connected_ = false; + std::vector serials_; + int serial_index_ = 0; + bool has_panda_ = false; + bool has_fd_ = false; + std::vector can_speed_index_, data_speed_index_; + PandaStreamConfig config = {}; +}; + +class OpenDeviceWidget : public AbstractOpenStreamWidget { +public: + const char *title() const override { return "Device"; } + void draw() override; + std::unique_ptr open() override; + +private: + int mode_ = 1; // 0 = MSGQ, 1 = ZMQ + std::string ip_address_; +}; + +#ifdef __linux__ +class OpenSocketCanWidget : public AbstractOpenStreamWidget { +public: + OpenSocketCanWidget(); + const char *title() const override { return "SocketCAN"; } + void draw() override; + std::unique_ptr open() override; + +private: + void refreshDevices(); + + std::vector devices_; + int device_index_ = 0; + SocketCanStreamConfig config = {}; +}; +#endif + +class StreamSelector { +public: + using Callback = std::function stream, const std::string &dbc_file)>; + // on_done gets a null stream on cancel + void open(Callback on_done); + void draw(); + +private: + bool open_ = false; + PopupOwner popup_; + bool first_frame_ = false; + std::string dbc_file_; + std::vector> widgets_; + Callback on_done_; +}; diff --git a/openpilot/tools/cabana/ui/dropdown.h b/openpilot/tools/cabana/ui/dropdown.h new file mode 100644 index 000000000..e6ce25f16 --- /dev/null +++ b/openpilot/tools/cabana/ui/dropdown.h @@ -0,0 +1,142 @@ +#pragma once + +#include +#include + +#include "tools/cabana/ui/theme.h" + +namespace dropdown { +constexpr float PADDING_X = 9.0f; +constexpr float PADDING_Y = 6.0f; +constexpr float SPACING_X = 10.0f; +constexpr float SPACING_Y = 8.0f; +constexpr float ROUNDING = 6.0f; +constexpr float BORDER = 1.0f; +constexpr int WINDOW_STYLE_VARS = 3; + +class PopupViewportScope { +public: + PopupViewportScope() : main_(ImGui::GetMainViewport()), flags_(main_->Flags) { + // Keep detached popups above their owner; reset ownership when docked. + ImGuiWindowClass window_class; + const ImGuiViewport *owner = ImGui::GetWindowViewport(); + if (owner != main_) { + // Popups ignore NoAutoMerge, so exclude the main viewport during Begin. + main_->Flags &= ~ImGuiViewportFlags_CanHostOtherWindows; + window_class.ParentViewportId = owner->ID; + } + ImGui::SetNextWindowClass(&window_class); + } + ~PopupViewportScope() { main_->Flags = flags_; } + +private: + ImGuiViewport *main_; + ImGuiViewportFlags flags_; +}; + +inline void pushStyle() { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(PADDING_X, PADDING_Y)); + ImGui::PushStyleVar(ImGuiStyleVar_PopupRounding, ROUNDING); + ImGui::PushStyleVar(ImGuiStyleVar_PopupBorderSize, BORDER); +} + +inline bool finishBegin(bool open) { + if (open) ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(SPACING_X, SPACING_Y)); + else ImGui::PopStyleVar(WINDOW_STYLE_VARS); + return open; +} + +inline void PositionBelowItem(const char *id, bool align_right = false) { + const ImRect anchor(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + char name[32]; + ImFormatString(name, IM_ARRAYSIZE(name), "##Popup_%08x", ImGui::GetID(id)); + if (ImGuiWindow *popup = ImGui::FindWindowByName(name); popup && popup->WasActive) { + // Place like a combo using the owner's current monitor bounds. + const auto *viewport = static_cast(ImGui::GetWindowViewport()); + ImRect bounds = viewport->GetMainRect(); + if ((ImGui::GetIO().ConfigFlags & ImGuiConfigFlags_ViewportsEnable) && viewport->PlatformMonitor >= 0) { + const auto &monitor = ImGui::GetPlatformIO().Monitors[viewport->PlatformMonitor]; + bounds = ImRect(monitor.WorkPos, ImVec2(monitor.WorkPos.x + monitor.WorkSize.x, monitor.WorkPos.y + monitor.WorkSize.y)); + } + bounds.Expand(ImVec2(-ImGui::GetStyle().DisplaySafeAreaPadding.x, -ImGui::GetStyle().DisplaySafeAreaPadding.y)); + ImGuiDir direction = align_right ? ImGuiDir_Left : ImGuiDir_Down; + const ImVec2 pos = ImGui::FindBestWindowPosForPopupEx(anchor.GetBL(), ImGui::CalcWindowNextAutoFitSize(popup), + &direction, bounds, anchor, ImGuiPopupPositionPolicy_ComboBox); + ImGui::SetNextWindowPos(pos); + } else { + ImGui::SetNextWindowPos(align_right ? anchor.GetBR() : anchor.GetBL(), ImGuiCond_Always, ImVec2(align_right ? 1.0f : 0.0f, 0)); + } +} + +inline bool BeginPopup(const char *id, ImGuiWindowFlags flags = 0) { + PopupViewportScope viewport_scope; + pushStyle(); + return finishBegin(ImGui::BeginPopup(id, flags)); +} +inline bool BeginPopupContextItem(const char *id = nullptr, ImGuiPopupFlags flags = ImGuiPopupFlags_MouseButtonRight) { + PopupViewportScope viewport_scope; + pushStyle(); + return finishBegin(ImGui::BeginPopupContextItem(id, flags)); +} +inline void EndPopup() { + ImGui::PopStyleVar(); + ImGui::EndPopup(); + ImGui::PopStyleVar(WINDOW_STYLE_VARS); +} +inline bool BeginMenu(const char *label, bool enabled = true) { + PopupViewportScope viewport_scope; + pushStyle(); + // An open menu keeps its selection color while the pointer is over its label. + const bool selected = ImGui::IsPopupOpen(label); + if (selected) ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImGui::GetStyleColorVec4(ImGuiCol_Header)); + const bool open = ImGui::BeginMenu(label, enabled); + if (selected) ImGui::PopStyleColor(); + return finishBegin(open); +} +inline void EndMenu() { + ImGui::PopStyleVar(); + ImGui::EndMenu(); + ImGui::PopStyleVar(WINDOW_STYLE_VARS); +} +inline bool BeginCombo(const char *label, const char *preview, ImGuiComboFlags flags = 0) { + PopupViewportScope viewport_scope; + pushStyle(); + return finishBegin(ImGui::BeginCombo(label, preview, flags)); +} +inline void EndCombo() { + ImGui::PopStyleVar(); + ImGui::EndCombo(); + ImGui::PopStyleVar(WINDOW_STYLE_VARS); +} + +inline bool Item(const char *label, const char *shortcut = nullptr, bool selected = false, bool enabled = true) { + return ImGui::MenuItem(label, shortcut, selected, enabled); +} +inline bool Item(const char *label, const char *shortcut, bool *selected, bool enabled = true) { + if (!Item(label, shortcut, selected && *selected, enabled)) return false; + if (selected) *selected = !*selected; + return true; +} + +inline bool Combo(const char *label, int *index, const char *const items[], int count) { + bool changed = false; + if (BeginCombo(label, *index >= 0 && *index < count ? items[*index] : "")) { + for (int i = 0; i < count; ++i) { + ImGui::PushID(i); + if (Item(items[i], nullptr, i == *index) && i != *index) { + *index = i; + changed = true; + } + if (i == *index) ImGui::SetItemDefaultFocus(); + ImGui::PopID(); + } + EndCombo(); + } + return changed; +} +inline bool Combo(const char *label, int *index, const char *items) { + std::vector labels; + for (const char *item = items; *item; item += std::strlen(item) + 1) labels.push_back(item); + return Combo(label, index, labels.data(), labels.size()); +} +} // namespace dropdown diff --git a/openpilot/tools/cabana/ui/helpoverlay.cc b/openpilot/tools/cabana/ui/helpoverlay.cc new file mode 100644 index 000000000..bc64f9919 --- /dev/null +++ b/openpilot/tools/cabana/ui/helpoverlay.cc @@ -0,0 +1,199 @@ +#include "tools/cabana/ui/helpoverlay.h" + +#include +#include +#include +#include +#include + +#include "tools/cabana/ui/util.h" + +namespace { +struct HelpRun { + std::string text; + bool bold = false; + bool chip = false; // background-color:lightGray + bool swatch = false; // a colored square + ImU32 color = 0; // 0 = default text color +}; + +ImU32 helpColor(const std::string &name) { + if (name == "gray") return IM_COL32(128, 128, 128, 255); + if (name == "blue") return IM_COL32(0, 0, 255, 255); + if (name == "red") return IM_COL32(255, 0, 0, 255); + unsigned rgb = 0; + if (name.size() == 7 && name[0] == '#' && sscanf(name.c_str() + 1, "%6x", &rgb) == 1) { + return IM_COL32((rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff, 255); + } + return 0; +} + +std::vector> parseHelpHtml(const std::string &raw) { + std::vector> lines(1); + HelpRun style; + std::vector span_stack; + bool prev_space = true; // html collapses whitespace; leading whitespace is dropped + std::string pending; + auto flush = [&]() { + if (!pending.empty()) { + HelpRun run = style; + run.text = pending; + lines.back().push_back(run); + pending.clear(); + } + }; + auto push_swatch = [&](ImU32 color) { + flush(); + HelpRun run = style; + run.swatch = true; + if (color) run.color = color; + lines.back().push_back(run); + prev_space = false; + }; + for (size_t i = 0; i < raw.size(); ++i) { + const char c = raw[i]; + if (c == '<') { + const size_t close = raw.find('>', i); + if (close == std::string::npos) break; + const std::string tag = raw.substr(i + 1, close - i - 1); + i = close; + if (tag.compare(0, 3, "!--") == 0) continue; + flush(); + if (tag == "b") { + style.bold = true; + } else if (tag == "/b") { + style.bold = false; + } else if (tag.compare(0, 2, "br") == 0) { + lines.emplace_back(); + prev_space = true; + } else if (tag.compare(0, 4, "span") == 0) { + span_stack.push_back(style); + const size_t st = tag.find("style=\""); + if (st != std::string::npos) { + const std::string css = tag.substr(st + 7, tag.find('"', st + 7) - st - 7); + size_t pos = 0; + while (pos < css.size()) { + const size_t semi = css.find(';', pos); + const std::string decl = css.substr(pos, semi == std::string::npos ? std::string::npos : semi - pos); + const size_t colon = decl.find(':'); + if (colon != std::string::npos) { + const std::string key = decl.substr(0, colon), value = decl.substr(colon + 1); + if (key == "color") style.color = helpColor(value); + if (key == "background-color") style.chip = true; + } + if (semi == std::string::npos) break; + pos = semi + 1; + } + } + } else if (tag == "/span") { + if (!span_stack.empty()) { + style = span_stack.back(); + span_stack.pop_back(); + } + } + } else if (c == '&') { + static const std::pair entities[] = {{" ", " "}}; + bool matched = false; + for (const auto &[name, text] : entities) { + if (raw.compare(i, strlen(name), name) == 0) { + pending += text; + prev_space = false; + i += strlen(name) - 1; + matched = true; + break; + } + } + if (!matched && raw.compare(i, 7, "■") == 0) { // the filled square of the byte color legend + push_swatch(0); + i += 6; + matched = true; + } + if (!matched) { + pending += c; + prev_space = false; + } + } else if (isspace(static_cast(c))) { + if (!prev_space) pending += ' '; + prev_space = true; + } else if (c == '#' && i + 6 < raw.size() && helpColor(raw.substr(i, 7)) != 0) { // #rrggbb legend token + push_swatch(helpColor(raw.substr(i, 7))); + i += 6; + } else { + pending += c; + prev_space = false; + } + } + flush(); + for (auto &line : lines) { // trim the collapsed whitespace at the line ends + if (!line.empty() && !line.back().text.empty() && line.back().text.back() == ' ') line.back().text.pop_back(); + if (!line.empty() && !line.front().text.empty() && line.front().text.front() == ' ') line.front().text.erase(0, 1); + } + while (!lines.empty() && lines.back().empty()) lines.pop_back(); + return lines; +} +} // namespace + +void HelpOverlay::toggle() { + visible_ = !visible_; + opened_frame_ = ImGui::GetFrameCount(); +} + +void HelpOverlay::add(const std::string &text, const ImRect &rect) { + if (visible_) texts_.push_back({text, rect, ImGui::GetWindowViewport()}); +} + +void HelpOverlay::draw() { + if (!visible_) return; + // Each panel belongs to a viewport; detached panels need their own foreground layer. + std::vector viewports{ImGui::GetMainViewport()}; + for (const auto &entry : texts_) { + if (std::find(viewports.begin(), viewports.end(), entry.viewport) == viewports.end()) viewports.push_back(entry.viewport); + } + for (auto *viewport : viewports) { + ImGui::GetForegroundDrawList(viewport)->AddRectFilled(viewport->Pos, ImVec2(viewport->Pos.x + viewport->Size.x, viewport->Pos.y + viewport->Size.y), IM_COL32(0, 0, 0, 50)); + } + ImFont *font = ImGui::GetFont(); + ImFont *bold_font = boldFont() ? boldFont() : font; + const float font_size = ImGui::GetFontSize(); + const float line_h = ImGui::GetTextLineHeightWithSpacing(); + auto run_width = [&](const HelpRun &r) { + if (r.swatch) return font_size; + return (r.bold ? bold_font : font)->CalcTextSizeA(font_size, FLT_MAX, 0.0f, r.text.c_str()).x; + }; + for (const auto &[raw, rect, viewport] : texts_) { + ImDrawList *dl = ImGui::GetForegroundDrawList(viewport); + if (raw.empty()) continue; + const auto lines = parseHelpHtml(raw); + float width = 0; + for (const auto &line : lines) { + float w = 0; + for (const auto &r : line) w += run_width(r); + width = std::max(width, w); + } + const ImVec2 size(width, lines.size() * line_h); + const ImVec2 center((rect.Min.x + rect.Max.x) * 0.5f, (rect.Min.y + rect.Max.y) * 0.5f); + const ImVec2 min(center.x - size.x * 0.5f - 8.0f, center.y - size.y * 0.5f - 8.0f); + const ImVec2 max(center.x + size.x * 0.5f + 8.0f, center.y + size.y * 0.5f + 8.0f); + dl->AddRectFilled(min, max, ImGui::GetColorU32(ImGuiCol_PopupBg), ImGui::GetStyle().PopupRounding); + dl->AddRect(min, max, ImGui::GetColorU32(ImGuiCol_Border), ImGui::GetStyle().PopupRounding); + float y = min.y + 8.0f; + for (const auto &line : lines) { + float x = min.x + 8.0f; + for (const auto &r : line) { + const float w = run_width(r); + const ImU32 color = r.color ? r.color : ImGui::GetColorU32(ImGuiCol_Text); + if (r.swatch) { + dl->AddRectFilled(ImVec2(x + 2, y + 3), ImVec2(x + font_size - 2, y + font_size - 1), color); + } else { + if (r.chip) dl->AddRectFilled(ImVec2(x, y), ImVec2(x + w, y + font_size), ImGui::GetColorU32(ImGuiCol_Button), 3.0f); + dl->AddText(r.bold ? bold_font : font, font_size, ImVec2(x, y), color, r.text.c_str()); + } + x += w; + } + y += line_h; + } + } + texts_.clear(); + // ignore the release of the click that opened the overlay + if (ImGui::IsMouseReleased(ImGuiMouseButton_Left) && ImGui::GetFrameCount() != opened_frame_) visible_ = false; +} diff --git a/openpilot/tools/cabana/ui/helpoverlay.h b/openpilot/tools/cabana/ui/helpoverlay.h new file mode 100644 index 000000000..1431a4c76 --- /dev/null +++ b/openpilot/tools/cabana/ui/helpoverlay.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +#include "imgui_internal.h" + +// Dims the window and shows each widget's whatsThis text at its center; any click closes it. +// The texts are rich text: ,
, , &entities; and #rrggbb +// tokens (the video legend) are rendered, everything else is ignored. +class HelpOverlay { +public: + void toggle(); + bool visible() const { return visible_; } + // collected while the widgets draw, consumed by draw() + void add(const std::string &text, const ImRect &rect); + void draw(); + +private: + struct Entry { + std::string text; + ImRect rect; + ImGuiViewport *viewport; + }; + std::vector texts_; + bool visible_ = false; + int opened_frame_ = -1; +}; diff --git a/openpilot/tools/cabana/ui/icons.h b/openpilot/tools/cabana/ui/icons.h new file mode 100644 index 000000000..89b830086 --- /dev/null +++ b/openpilot/tools/cabana/ui/icons.h @@ -0,0 +1,40 @@ +#pragma once + +// Bootstrap Icons codepoints; loadFonts() merges the icon font into each text font. +namespace icon { +constexpr const char ARROW_CLOCKWISE[] = "\xef\x84\x96"; +constexpr const char ARROW_COUNTERCLOCKWISE[] = "\xef\x84\x97"; +constexpr const char CHEVRON_LEFT[] = "\xef\x8a\x84"; +constexpr const char CHEVRON_RIGHT[] = "\xef\x8a\x85"; +constexpr const char ASPECT_RATIO[] = "\xef\x85\x90"; +constexpr const char ASPECT_RATIO_FILL[] = "\xef\x85\x8f"; +constexpr const char EXCLAMATION_TRIANGLE[] = "\xef\x8c\xba"; +constexpr const char FAST_FORWARD[] = "\xef\x9f\xb3"; +constexpr const char FILETYPE_CSV[] = "\xef\x9d\x83"; +constexpr const char FOLDER[] = "\xef\x8f\x91"; +constexpr const char FILE_EARMARK[] = "\xef\x8d\xa9"; +constexpr const char FILE_EARMARK_RULED[] = "\xef\x8e\x84"; +constexpr const char GRAPH_UP[] = "\xef\x8f\xb2"; +constexpr const char GRIP_HORIZONTAL[] = "\xef\x8f\xbd"; +constexpr const char INFO_CIRCLE[] = "\xef\x90\xb0"; +constexpr const char PAUSE[] = "\xef\x93\x83"; +constexpr const char PENCIL[] = "\xef\x93\x8b"; +constexpr const char PLAY[] = "\xef\x93\xb4"; +constexpr const char REPEAT[] = "\xef\xa0\x93"; +constexpr const char REPEAT_1[] = "\xef\xa0\x92"; +constexpr const char REWIND[] = "\xef\xa0\x98"; +constexpr const char SKIP_END[] = "\xef\x95\x97"; +constexpr const char STOPWATCH[] = "\xef\x96\x96"; +constexpr const char THREE_DOTS[] = "\xef\x97\x94"; +constexpr const char X_LG[] = "\xef\x99\x99"; +constexpr const char ZOOM_OUT[] = "\xef\x98\xad"; +constexpr const char PLUS_LG[] = "\xef\x99\x8d"; +constexpr const char DASH_LG[] = "\xef\x98\xbb"; +constexpr const char TRASH[] = "\xef\x9e\x8b"; +constexpr const char ARROWS_COLLAPSE[] = "\xef\x85\x8b"; +constexpr const char BOX_ARROW_UP_RIGHT[] = "\xef\x87\x85"; +constexpr const char BOX_ARROW_IN_DOWN_LEFT[] = "\xef\x86\xba"; +constexpr const char WINDOW_PLUS[] = "\xef\x9b\x90"; +constexpr const char CHEVRON_DOUBLE_RIGHT[] = "\xef\x8a\x80"; +constexpr const char THREE_DOTS_VERTICAL[] = "\xef\x97\x93"; +} // namespace icon diff --git a/openpilot/tools/cabana/ui/inistate.cc b/openpilot/tools/cabana/ui/inistate.cc new file mode 100644 index 000000000..258344126 --- /dev/null +++ b/openpilot/tools/cabana/ui/inistate.cc @@ -0,0 +1,152 @@ +#include "tools/cabana/ui/inistate.h" + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/qtstate.h" +#include "tools/cabana/ui/util.h" + +namespace inistate { + +MainWindowState main_window; + +namespace { + +void *readOpen(ImGuiContext *, ImGuiSettingsHandler *, const char *name) { + return strcmp(name, "MainWindow") == 0 ? (void *)&main_window : nullptr; +} + +void readLine(ImGuiContext *, ImGuiSettingsHandler *, void *entry, const char *line) { + auto *state = (MainWindowState *)entry; + int x = 0, y = 0, flag = 0; + float ratio = 0.0f; + if (sscanf(line, "Pos=%d,%d", &x, &y) == 2) { + state->pos[0] = x; + state->pos[1] = y; + } else if (sscanf(line, "Size=%d,%d", &x, &y) == 2) { + state->size[0] = x; + state->size[1] = y; + state->has_geometry = true; + } else if (sscanf(line, "Maximized=%d", &flag) == 1) { + state->maximized = flag != 0; + } else if (sscanf(line, "VideoSplitterRatio=%f", &ratio) == 1) { + state->video_splitter_ratio = ratio; + } else if (sscanf(line, "MessagesVisible=%d", &flag) == 1) { + state->messages_visible = flag != 0; + } else if (sscanf(line, "ChartsVisible=%d", &flag) == 1) { + state->charts_visible = flag != 0; + } else if (sscanf(line, "VideoVisible=%d", &flag) == 1) { + state->video_visible = flag != 0; + } +} + +void writeAll(ImGuiContext *, ImGuiSettingsHandler *handler, ImGuiTextBuffer *buf) { + buf->appendf("[%s][MainWindow]\n", handler->TypeName); + if (main_window.has_geometry) { + buf->appendf("Pos=%d,%d\n", main_window.pos[0], main_window.pos[1]); + buf->appendf("Size=%d,%d\n", main_window.size[0], main_window.size[1]); + } + buf->appendf("Maximized=%d\n", main_window.maximized ? 1 : 0); + buf->appendf("VideoSplitterRatio=%.4f\n", main_window.video_splitter_ratio); + buf->appendf("MessagesVisible=%d\n", main_window.messages_visible ? 1 : 0); + buf->appendf("VideoVisible=%d\n", main_window.video_visible ? 1 : 0); + buf->appendf("ChartsVisible=%d\n", main_window.charts_visible ? 1 : 0); + buf->append("\n"); +} + +std::string migrateQtHeaderState(const qtstate::QtHeaderState &header) { + // imgui restarts the hash at "###" in a window name and BeginTable seeds the table id from the window id + const ImGuiID table_id = ImHashStr("messages", 0, ImHashStr(MESSAGES_PANEL_ID, 0)); + const float cell_padding = ImGui::GetStyle().CellPadding.x; + + ImGuiTextBuffer buf; + buf.appendf("[Table][0x%08X,%d]\n", table_id, qtstate::kMessageColumnCount); + for (int i = 0; i < qtstate::kMessageColumnCount; ++i) { + buf.appendf("Column %-2d", i); + if (i == 6) { + buf.append(" Weight=1.0000"); // DATA is the stretch column + } else { + buf.appendf(" Width=%d", std::max(1, (int)(header.width[i] - 2 * cell_padding))); + } + buf.appendf(" Visible=%d Order=%d", header.hidden[i] ? 0 : 1, header.visual[i]); + if (header.sort_shown && i == header.sort_section) { + // the port feeds imgui the flipped direction so the arrow matches Qt (flipSortDirection + // in ui/widgets/messageswidget.cc): Qt ascending is imgui descending + buf.appendf(" Sort=0%c", header.sort_order == 0 ? '^' : 'v'); + } + buf.append("\n"); + } + buf.append("\n"); + return std::string(buf.c_str()); +} + +std::string migrateQtState() { + auto geometry = qtstate::parseQtGeometry(settings.geometry); + auto splitter = qtstate::parseQtSplitter(settings.video_splitter_state); + + ImGuiTextBuffer buf; + if (geometry || splitter) { + buf.append("[Cabana][MainWindow]\n"); + if (geometry) { + buf.appendf("Pos=%d,%d\n", geometry->x, geometry->y); + buf.appendf("Size=%d,%d\n", geometry->w, geometry->h); + buf.appendf("Maximized=%d\n", geometry->maximized ? 1 : 0); + } + if (splitter) buf.appendf("VideoSplitterRatio=%.4f\n", splitter->ratio); + buf.append("\n"); + } + if (auto header = qtstate::parseQtHeaderState(settings.message_header_state)) { + buf.append(migrateQtHeaderState(*header).c_str()); + } + return std::string(buf.c_str()); +} + +void migrateDockLayout() { + // Show dock tabs hidden by older layouts. + if (const auto *center = ImGui::FindWindowSettingsByID(ImHashStr("###CenterWidget"))) { + if (auto *node = ImGui::DockBuilderGetNode(center->DockId)) { + node->LocalFlags &= ~ImGuiDockNodeFlags_NoTabBar; + } + } +} + +} // namespace + +void addSettingsHandler() { + ImGuiSettingsHandler handler; + handler.TypeName = "Cabana"; + handler.TypeHash = ImHashStr("Cabana"); + handler.ReadOpenFn = readOpen; + handler.ReadLineFn = readLine; + handler.WriteAllFn = writeAll; + ImGui::AddSettingsHandler(&handler); +} + +void load() { + if (settings.ui_state.empty()) settings.ui_state = migrateQtState(); + if (!settings.ui_state.empty()) + ImGui::LoadIniSettingsFromMemory(settings.ui_state.data(), settings.ui_state.size()); + + migrateDockLayout(); +} + +void applyWindowGeometry(GLFWwindow *window) { + // Qt restoreGeometry corrects off-screen geometry, here we rely on the window manager + if (main_window.has_geometry && main_window.size[0] > 0 && main_window.size[1] > 0) { + glfwSetWindowPos(window, main_window.pos[0], main_window.pos[1]); + glfwSetWindowSize(window, main_window.size[0], main_window.size[1]); + } + if (main_window.maximized) glfwMaximizeWindow(window); +} + +std::string save() { + return std::string(ImGui::SaveIniSettingsToMemory()); +} + +} // namespace inistate diff --git a/openpilot/tools/cabana/ui/inistate.h b/openpilot/tools/cabana/ui/inistate.h new file mode 100644 index 000000000..fd17c7e98 --- /dev/null +++ b/openpilot/tools/cabana/ui/inistate.h @@ -0,0 +1,28 @@ +#pragma once +#include + +struct GLFWwindow; + +// The imgui frontend's persisted state: the imgui ini text (windows, dock layout, table +// state) plus a custom [Cabana][MainWindow] section, stored as one string in Settings. +namespace inistate { + +struct MainWindowState { + int pos[2] = {0, 0}; + int size[2] = {0, 0}; + bool maximized = false; + bool has_geometry = false; + float video_splitter_ratio = -1.0f; // < 0: video at its size hint + bool messages_visible = true; + bool video_visible = true; + bool charts_visible = true; +}; + +extern MainWindowState main_window; + +void addSettingsHandler(); // register the [Cabana] ini section +void load(); // migrate Qt state if needed, then LoadIniSettingsFromMemory +void applyWindowGeometry(GLFWwindow *window); // glfw pos/size/maximize from main_window +std::string save(); // SaveIniSettingsToMemory (caller fills main_window first) + +} // namespace inistate diff --git a/openpilot/tools/cabana/cabana.cc b/openpilot/tools/cabana/ui/main.cc similarity index 65% rename from openpilot/tools/cabana/cabana.cc rename to openpilot/tools/cabana/ui/main.cc index 8b21143fa..e111c4d9f 100644 --- a/openpilot/tools/cabana/cabana.cc +++ b/openpilot/tools/cabana/ui/main.cc @@ -1,17 +1,23 @@ #include #include +#include #include +#include #include -#include - -#include "tools/cabana/mainwin.h" #include "tools/cabana/streams/devicestream.h" #include "tools/cabana/streams/pandastream.h" #include "tools/cabana/streams/replaystream.h" #ifdef __linux__ #include "tools/cabana/streams/socketcanstream.h" #endif +#include "tools/cabana/ui/app.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/utils/util.h" + +#ifdef __GLIBC__ +#include +#endif namespace { @@ -24,6 +30,7 @@ struct CabanaArgs { bool msgq = false; bool panda = false; bool no_vipc = false; + bool no_cache = false; std::string panda_serial; std::string socketcan; std::string zmq; @@ -55,11 +62,11 @@ void printUsage(const char *argv0) { " --zmq read can messages from zmq at the specified ip-address\n" " --data_dir local directory with routes\n" " --no-vipc do not output video\n" + " --no-cache turn off the local route file cache\n" " --dbc dbc file to open\n", argv0); } -// Returns true if value was consumed from argv[i+1]. bool takeValue(int argc, char *argv[], int &i, std::string &out) { if (i + 1 >= argc) { fprintf(stderr, "error: %s requires a value\n", argv[i]); @@ -69,9 +76,8 @@ bool takeValue(int argc, char *argv[], int &i, std::string &out) { return true; } -// Returns 0 to continue, or a process exit code (0 for --help, 1 for errors). -int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { - ok = false; +// the process exit code, or nullopt to continue +std::optional parseArgs(int argc, char *argv[], CabanaArgs &args) { for (int i = 1; i < argc; ++i) { const char *a = argv[i]; if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) { @@ -96,8 +102,7 @@ int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { args.panda = true; } else if (std::strcmp(a, "--socketcan") == 0) { if (!takeValue(argc, argv, i, args.socketcan)) return 1; -#ifdef __linux__ -#else +#ifndef __linux__ fprintf(stderr, "error: --socketcan is only supported on Linux\n"); return 1; #endif @@ -107,6 +112,8 @@ int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { if (!takeValue(argc, argv, i, args.data_dir)) return 1; } else if (std::strcmp(a, "--no-vipc") == 0) { args.no_vipc = true; + } else if (std::strcmp(a, "--no-cache") == 0) { + args.no_cache = true; } else if (std::strcmp(a, "--dbc") == 0) { if (!takeValue(argc, argv, i, args.dbc)) return 1; } else if (a[0] == '-') { @@ -121,44 +128,45 @@ int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { return 1; } } - ok = true; - return 0; + return std::nullopt; } } // namespace int main(int argc, char *argv[]) { - QCoreApplication::setApplicationName("Cabana"); - initApp(argc, argv, false); - QApplication app(argc, argv); - app.setApplicationDisplayName("Cabana"); - //app.setWindowIcon(QIcon(":cabana-icon.png")); // TODO: do this in imgui - - UnixSignalHandler signalHandler; - utils::setTheme(settings.theme); +#ifdef __GLIBC__ + // Worker threads (sparklines, chart series, replay) would each get their own glibc malloc arena and the + // arenas fragment without bound (RSS grew ~3 MB/min with charts open). macOS has a single allocator zone. + mallopt(M_ARENA_MAX, 1); +#endif + // ensure the current dir matches the executable's directory + std::error_code ec; + std::filesystem::current_path(executableDir(), ec); CabanaArgs args; - bool args_ok = false; - if (const int code = parseArgs(argc, argv, args, args_ok); !args_ok) { - return code; - } + if (auto code = parseArgs(argc, argv, args)) return *code; - AbstractStream *stream = nullptr; + std::unique_ptr stream; + StreamLoader stream_loader; if (args.msgq) { - stream = new DeviceStream(&app); + stream = std::make_unique(); } else if (!args.zmq.empty()) { - stream = new DeviceStream(&app, QString::fromStdString(args.zmq)); - } else if (args.panda || !args.panda_serial.empty()) { + stream = std::make_unique(args.zmq); + } else if (args.panda) { try { - stream = new PandaStream(&app, {.serial = args.panda_serial}); + stream = std::make_unique(PandaStreamConfig{.serial = args.panda_serial}); } catch (std::exception &e) { fprintf(stderr, "%s\n", e.what()); - return 0; + return 1; } #ifdef __linux__ - } else if (SocketCanStream::available() && !args.socketcan.empty()) { - stream = new SocketCanStream(&app, {.device = args.socketcan}); + } else if (!args.socketcan.empty()) { + if (!SocketCanStream::available()) { + fprintf(stderr, "error: SocketCAN is not available on this system\n"); + return 1; + } + stream = std::make_unique(SocketCanStreamConfig{.device = args.socketcan}); #endif } else { uint32_t replay_flags = REPLAY_FLAG_NONE; @@ -166,22 +174,29 @@ int main(int argc, char *argv[]) { if (args.qcam) replay_flags |= REPLAY_FLAG_QCAMERA; if (args.cabin) replay_flags |= REPLAY_FLAG_CABIN_CAMERA; if (args.no_vipc) replay_flags |= REPLAY_FLAG_NO_VIPC; + if (args.no_cache) replay_flags |= REPLAY_FLAG_NO_FILE_CACHE; - QString route; + std::string route; if (!args.route.empty()) { - route = QString::fromStdString(args.route); + route = args.route; } else if (args.demo) { route = DEMO_ROUTE; } - if (!route.isEmpty()) { - auto replay_stream = std::make_unique(&app); - if (!replay_stream->loadRoute(route.toStdString(), args.data_dir, replay_flags, args.auto_source)) { - return 0; - } - stream = replay_stream.release(); + if (!route.empty()) { + // the route file listing hits the comma API; load behind the window instead of before it + stream_loader = [route, data_dir = args.data_dir, replay_flags, auto_source = args.auto_source]() -> std::unique_ptr { + auto replay_stream = std::make_unique(); + Connection err = replay_stream->error.connect([](const std::string &msg) { + fprintf(stderr, "%s\n", msg.c_str()); + utils::runOnMainThread([msg]() { MessageBox::warning("Error", msg); }); + }); + if (!replay_stream->loadRoute(route, data_dir, replay_flags, auto_source)) { + return nullptr; + } + return replay_stream; + }; } } - MainWindow w(stream, QString::fromStdString(args.dbc)); - return app.exec(); + return run(std::move(stream), std::move(stream_loader), args.dbc); } diff --git a/openpilot/tools/cabana/ui/mainwin.cc b/openpilot/tools/cabana/ui/mainwin.cc new file mode 100644 index 000000000..674f2c3aa --- /dev/null +++ b/openpilot/tools/cabana/ui/mainwin.cc @@ -0,0 +1,944 @@ +#include "tools/cabana/ui/mainwin.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include + +#include "json11/json11.hpp" +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/app.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/inistate.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/tools/findsignal.h" +#include "tools/cabana/ui/tools/findsimilarbits.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/export.h" +#include "tools/cabana/utils/util.h" +#include "tools/replay/py_downloader.h" +#include "tools/replay/util.h" + +namespace { +// dock window ids (the visible titles change, the part after ### is the identity) +constexpr const char *VIDEO_PANEL = "###VideoPanel"; +constexpr const char *CENTER_PANEL = "Signals###CenterWidget"; +constexpr const char *CHARTS_WINDOW = "Charts###ChartsWindow"; +} // namespace + +MainWindow::MainWindow(GLFWwindow *window, std::unique_ptr stream, StreamLoader stream_loader, + const std::string &dbc_file) : window_(window) { + can = &dummy_; + messages_visible_ = inistate::main_window.messages_visible; + video_visible_ = inistate::main_window.video_visible; + charts_visible_ = inistate::main_window.charts_visible; + loadFingerprints(); + std::error_code ec; + for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH, ec)) { + if (entry.is_regular_file() && entry.path().extension() == ".dbc") { + opendbc_names_.push_back(entry.path().filename().string()); + } + } + std::sort(opendbc_names_.begin(), opendbc_names_.end()); + + // download handlers are called from download threads + installDownloadProgressHandler([this](uint64_t cur, uint64_t total, bool success) { + utils::runOnMainThread([this, cur, total, success]() { updateDownloadProgress(cur, total, success); }); + }); + installMessageHandler([this](ReplyMsgType type, const std::string &msg) { + utils::runOnMainThread([this, msg]() { showStatusMessage(msg, 2000); }); + }); + + connections_.push_back(dbc()->fileChanged.connect([this]() { dbcFileChanged(); })); + connections_.push_back(UndoStack::instance()->cleanChanged.connect([this](bool clean) { + window_modified_ = !clean; + updateWindowTitle(); + })); + + startup_stream_ = std::move(stream); + startup_loader_ = std::move(stream_loader); + nextFrame([this, dbc_file]() { + if (startup_loader_) { + loadStartupStream(dbc_file); + } else { + startup_stream_ ? openStream(std::move(startup_stream_), dbc_file) : selectAndOpenStream(); + } + }); +} + +void MainWindow::loadFingerprints() { + std::ifstream json_file((executableDir() / "dbc/car_fingerprint_to_dbc.json")); + if (!json_file) return; + const std::string contents{std::istreambuf_iterator(json_file), std::istreambuf_iterator()}; + std::string err; + auto doc = json11::Json::parse(contents, err); + if (!err.empty() || !doc.is_object()) return; + for (const auto &kv : doc.object_items()) { + if (kv.second.is_string()) { + fingerprint_to_dbc_.emplace(kv.first, kv.second.string_value()); + } + } +} + +void MainWindow::drawFileMenu() { + const bool has_stream = hasStream(); + if (dropdown::Item("Open Stream...")) selectAndOpenStream(); + if (dropdown::Item("Close Stream", nullptr, false, has_stream)) closeStream(); + if (dropdown::Item("Export to CSV...", nullptr, false, has_stream)) exportToCSV(); + ImGui::Separator(); + + if (dropdown::Item("New DBC File", shortcut("N").c_str())) newFile(); + if (dropdown::Item("Open DBC File...", shortcut("O").c_str())) openFile(); + + if (dropdown::BeginMenu("Manage DBC Files", has_stream)) { + drawManageDBCsMenu(); + dropdown::EndMenu(); + } + if (dropdown::BeginMenu("Open Recent")) { + drawRecentFilesMenu(); + dropdown::EndMenu(); + } + + ImGui::Separator(); + if (dropdown::BeginMenu("Load DBC from commaai/opendbc")) { + for (const auto &name : opendbc_names_) { + if (dropdown::Item(name.c_str())) loadDBCFromOpendbc(name); + } + dropdown::EndMenu(); + } + if (dropdown::Item("Load DBC from Clipboard")) loadFromClipboard(); + + ImGui::Separator(); + const int cnt = dbc()->nonEmptyDBCCount(); + const std::string save_text = cnt > 1 ? "Save " + std::to_string(cnt) + " DBCs..." : "Save DBC..."; + if (dropdown::Item(save_text.c_str(), shortcut("S").c_str(), false, cnt > 0)) save(); + if (dropdown::Item("Save DBC As...", shortcut("Shift+S").c_str(), false, cnt == 1)) saveAs(); + // TODO: Support clipboard for multiple files + if (dropdown::Item("Copy DBC to Clipboard", nullptr, false, cnt == 1)) saveToClipboard(); + + ImGui::Separator(); + if (dropdown::Item("Settings...")) openSettings(); + + ImGui::Separator(); + if (dropdown::Item("Exit", shortcut("Q").c_str())) close(); +} + +void MainWindow::drawMenuBar() { + // Avoid a double border with the separator drawn below. + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + const bool open = ImGui::BeginMainMenuBar(); + ImGui::PopStyleVar(); + if (!open) return; + if (dropdown::BeginMenu("File")) { + drawFileMenu(); + dropdown::EndMenu(); + } + + if (dropdown::BeginMenu("Edit")) { + auto stack = UndoStack::instance(); + const std::string undo_text = stack->canUndo() ? "Undo " + stack->undoText() : "Undo"; + const std::string redo_text = stack->canRedo() ? "Redo " + stack->redoText() : "Redo"; + if (dropdown::Item(undo_text.c_str(), shortcut("Z").c_str(), false, stack->canUndo())) stack->undo(); + if (dropdown::Item(redo_text.c_str(), shortcut("Shift+Z").c_str(), false, stack->canRedo())) stack->redo(); + dropdown::EndMenu(); + } + + if (dropdown::BeginMenu("View")) { + if (dropdown::Item("Full Screen", shortcut("F11").c_str())) toggleFullScreen(); + ImGui::Separator(); + dropdown::Item(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); + dropdown::Item(videoPanelTitle(), nullptr, &video_visible_); + dropdown::Item("Charts", nullptr, &charts_visible_); + ImGui::Separator(); + if (dropdown::Item("Reset Window Layout")) { + messages_visible_ = video_visible_ = charts_visible_ = true; + reset_layout_ = true; + } + dropdown::EndMenu(); + } + + if (dropdown::BeginMenu("Tools", hasStream())) { + if (dropdown::Item("Find Similar Bits")) findSimilarBits(); + if (dropdown::Item("Find Signal")) findSignal(); + dropdown::EndMenu(); + } + + if (dropdown::BeginMenu("Help")) { + if (dropdown::Item("Help", "F1")) toggleHelp(); + dropdown::EndMenu(); + } + const ImVec2 min = ImGui::GetWindowPos(); + const ImVec2 max(min.x + ImGui::GetWindowWidth(), min.y + ImGui::GetWindowHeight()); + ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(min.x, max.y - 1.0f), max, ImGui::GetColorU32(ImGuiCol_Border)); + ImGui::EndMainMenuBar(); +} + +void MainWindow::createDockWidgets() { + widget_connections_.clear(); + messages_widget_ = std::make_unique(); + widget_connections_.push_back(messages_widget_->msgSelectionChanged.connect([this](const MessageId &id) { center_widget_.setMessage(id); })); + + charts_widget_ = std::make_unique(); + center_widget_.setChartsWidget(charts_widget_.get()); + video_widget_ = std::make_unique(); +} + +void MainWindow::showStatusMessage(const std::string &msg, int timeout_ms) { + status_bar_.message = msg; + status_bar_.message_until = timeout_ms > 0 ? ImGui::GetTime() + timeout_ms / 1000.0 : 0; +} + +void MainWindow::updateWindowTitle() { + std::string title; + for (auto f : dbc()->nonEmptyDBCFiles()) { + if (!title.empty()) title += " | "; + title += "(" + toString(dbc()->sources(f)) + ") " + f->name(); + } + if (window_modified_) title += "*"; + if (hasStream()) { + const std::string stream_title = can->liveStreaming() ? videoPanelTitle() : can->routeName(); + title = title.empty() ? stream_title : stream_title + " \xe2\x80\x94 " + title; + } + if (!title.empty()) title += " \xe2\x80\x94 "; // em dash separator + title += "Cabana"; + glfwSetWindowTitle(window_, title.c_str()); +} + +void MainWindow::dbcFileChanged() { + UndoStack::instance()->clear(); + updateWindowTitle(); + nextFrame([this]() { restoreSessionState(); }); +} + +void MainWindow::selectAndOpenStream() { + stream_selector_.open([this](std::unique_ptr stream, const std::string &dbc_file) { + if (stream) { + openStream(std::move(stream), dbc_file); + } else if (!stream_) { + openStream(std::make_unique()); + } + }); +} + +// the route file listing hits the comma API, so the loader runs on a worker behind the window +void MainWindow::loadStartupStream(const std::string &dbc_file) { + wait_dlg_.text = "Loading route..."; + wait_dlg_.value = 0; + wait_dlg_.open = true; + wait_dlg_.show_at = ImGui::GetTime() + 4.0; // minimum duration before the dialog shows + ThreadPool::instance().run([this, dbc_file, loader = std::move(startup_loader_)]() { + AbstractStream *loaded = nullptr; + std::string error; + try { + loaded = loader().release(); + } catch (const std::exception &e) { + // the pool swallows exceptions, so the wait dialog would spin forever + error = e.what(); + } + utils::runOnMainThread([this, dbc_file, loaded, error]() { + wait_dlg_.open = false; + std::unique_ptr stream(loaded); + if (!error.empty()) { + fprintf(stderr, "%s\n", error.c_str()); + MessageBox::warning("Failed to load route", error); + } + stream ? openStream(std::move(stream), dbc_file) : openStream(std::make_unique()); + }); + }); +} + +void MainWindow::closeStream() { + openStream(std::make_unique()); + if (dbc()->nonEmptyDBCCount() > 0) { + dbc()->fileChanged(); + } + showStatusMessage("Stream closed"); +} + +void MainWindow::exportToCSV() { + std::string dir = settings.last_dir + "/" + can->routeName() + ".csv"; + FileDialog::getSaveFileName("Export stream to CSV file", dir, ".csv", [](const std::string &fn) { + if (!fn.empty()) { + utils::exportToCSV(fn); + } + }); +} + +void MainWindow::newFile(SourceSet s) { + closeFile(s, [s]() { dbc()->open(s, std::string(""), std::string("")); }); +} + +void MainWindow::openFile(SourceSet s) { + remindSaveChanges([this, s]() { + FileDialog::getOpenFileName("Open File", settings.last_dir, ".dbc", [this, s](const std::string &fn) { + if (!fn.empty()) { + loadFile(fn, s); + } + }); + }); +} + +void MainWindow::loadFile(const std::string &fn, SourceSet s, std::function then) { + if (!fn.empty()) { + closeFile(s, [this, fn, s, then]() { + std::string error; + if (dbc()->open(s, fn, &error)) { + updateRecentFiles(fn); + showStatusMessage("DBC file " + fn + " loaded", 2000); + if (then) then(); + } else { + MessageBox::warning("Failed to load DBC file", "Failed to parse DBC file " + fn, error, then); + } + }); + } else if (then) { + then(); + } +} + +void MainWindow::loadDBCFromOpendbc(const std::string &name) { + loadFile(std::string(OPENDBC_FILE_PATH) + "/" + name); +} + +void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { + std::string text; + if (!utils::getClipboardText(&text)) { + MessageBox::warning("Load from Clipboard", "No clipboard tool found. Install xclip (X11) or wl-clipboard (Wayland)."); + return; + } + if (text.empty()) { + MessageBox::warning("Load from Clipboard", "Clipboard is empty."); + return; + } + + closeFile(s, [s, text]() { + std::string error; + bool ret = dbc()->open(s, std::string(""), text, &error); + if (ret && dbc()->nonEmptyDBCCount() > 0) { + MessageBox::information("Load from Clipboard", "DBC loaded successfully."); + } else { + MessageBox::warning("Failed to load DBC from clipboard", "Make sure the clipboard contains correctly formatted DBC text.", error); + } + }); +} + +MainWindow::~MainWindow() { + installDownloadProgressHandler(nullptr); + installMessageHandler(nullptr); + releaseStream(); + can = nullptr; +} + +// the tool dialogs are connected into the messages widget, and the video widget's RouteInfoDlg keeps a raw +// pointer to the replay, so the dialogs go first and the widgets before the stream; the stream's destructor +// joins the threads that read the global `can` +void MainWindow::releaseStream() { + tool_dialogs_.clear(); + wait_dlg_.connection.disconnect(); + wait_dlg_.open = false; + widget_connections_.clear(); + charts_widget_.reset(); + video_widget_.reset(); + center_widget_.clear(); + messages_widget_.reset(); + stream_connections_.clear(); + stream_.reset(); + can = &dummy_; +} + +void MainWindow::openStream(std::unique_ptr stream, const std::string &dbc_file) { + releaseStream(); + startStream(std::move(stream), dbc_file); +} + +void MainWindow::startStream(std::unique_ptr stream, const std::string &dbc_file) { + stream_ = std::move(stream); + can = stream_.get(); + stream_connections_.push_back(can->error.connect([](const std::string &msg) { + MessageBox::warning("Error", msg); + })); + can->start(); + updateWindowTitle(); + + loadFile(dbc_file, SOURCE_ALL, [this]() { + showStatusMessage("Stream [" + can->routeName() + "] started", 2000); + createDockWidgets(); + + // Don't overwrite already loaded DBC + if (!dbc()->nonEmptyDBCCount()) { + newFile(); + } + + stream_connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &) { eventsMerged(); })); + + if (hasStream()) { + wait_dlg_.text = can->liveStreaming() ? "Waiting for the live stream to start..." : "Loading segment data..."; + wait_dlg_.value = 0; + wait_dlg_.open = true; + wait_dlg_.show_at = ImGui::GetTime() + 4.0; // minimum duration before the dialog shows + wait_dlg_.connection = can->eventsMerged.connect([this](const MessageEventsMap &) { + wait_dlg_.open = false; + wait_dlg_.connection.disconnect(); + }); + } + }); +} + +void MainWindow::eventsMerged() { + const std::string fingerprint = can->carFingerprint(); + if (!can->liveStreaming() && std::exchange(car_fingerprint_, fingerprint) != fingerprint) { + // Don't overwrite already loaded DBC + auto it = fingerprint_to_dbc_.find(car_fingerprint_); + if (!dbc()->nonEmptyDBCCount() && it != fingerprint_to_dbc_.end()) { + nextFrame([this, dbc_name = it->second]() { loadDBCFromOpendbc(dbc_name + ".dbc"); }); + } + } +} + +void MainWindow::saveFiles(bool as, std::function then) { + const std::vector files = dbc()->nonEmptyDBCFiles(); + auto next = std::make_shared>(); + *next = [this, as, files, next, then](size_t i) { + if (i >= files.size()) { + if (then) then(); + return; + } + auto cb = [next, i]() { (*next)(i + 1); }; + as ? saveFileAs(files[i], cb) : saveFile(files[i], cb); + }; + (*next)(0); +} + +void MainWindow::save(std::function then) { + saveFiles(false, std::move(then)); +} + +void MainWindow::saveAs(std::function then) { + saveFiles(true, std::move(then)); +} + +void MainWindow::closeFile(SourceSet s, std::function then) { + remindSaveChanges([s, then]() { + if (s == SOURCE_ALL) { + dbc()->closeAll(); + } else { + dbc()->close(s); + } + if (then) then(); + }); +} + +void MainWindow::closeFile(DBCFile *dbc_file) { + assert(dbc_file != nullptr); + remindSaveChanges([this, dbc_file]() { + dbc()->close(dbc_file); + // Ensure we always have at least one file open + if (dbc()->dbcCount() == 0) { + newFile(); + } + }); +} + +void MainWindow::saveFile(DBCFile *dbc_file, std::function then) { + assert(dbc_file != nullptr); + if (!dbc_file->filename.empty()) { + dbc_file->save(); + UndoStack::instance()->setClean(); + showStatusMessage("File saved", 2000); + if (then) then(); + } else if (!dbc_file->isEmpty()) { + saveFileAs(dbc_file, then); + } else if (then) { + then(); + } +} + +void MainWindow::saveFileAs(DBCFile *dbc_file, std::function then) { + std::string title = "Save File (bus: " + toString(dbc()->sources(dbc_file)) + ")"; + std::string default_path = (std::filesystem::path(settings.last_dir) / "untitled.dbc").string(); + FileDialog::getSaveFileName(title, default_path, ".dbc", [this, dbc_file, then](const std::string &fn) { + if (!fn.empty()) { + dbc_file->saveAs(fn); + UndoStack::instance()->setClean(); + showStatusMessage("File saved as " + fn, 2000); + updateRecentFiles(fn); + } + if (then) then(); + }); +} + +void MainWindow::saveToClipboard() { + // Should not be called with more than 1 file open + for (auto dbc_file : dbc()->nonEmptyDBCFiles()) { + saveFileToClipboard(dbc_file); + } +} + +void MainWindow::saveFileToClipboard(DBCFile *dbc_file) { + assert(dbc_file != nullptr); + copyToClipboard(dbc_file->generateDBC()); +} + +void MainWindow::copyToClipboard(const std::string &text) { + if (utils::setClipboardText(text)) { + MessageBox::information("Copy to Clipboard", "DBC copied successfully."); + } else { + MessageBox::warning("Copy to Clipboard", "Failed to copy DBC to clipboard. Install xclip (X11) or wl-clipboard (Wayland)."); + } +} + +void MainWindow::drawManageDBCsMenu() { + for (int source : can->sources) { + if (source >= 64) continue; // Sent and blocked buses are handled implicitly + + SourceSet ss = {source, uint8_t(source + 128), uint8_t(source + 192)}; + + auto dbc_file = dbc()->findDBCFile(source); + const std::string title = "Bus " + std::to_string(source) + " (" + (dbc_file ? dbc_file->name() : "No DBCs loaded") + ")"; + ImGui::PushID(source); + if (dropdown::BeginMenu(title.c_str())) { + if (dropdown::Item("New DBC File")) newFile(ss); + if (dropdown::Item("Open DBC File...")) openFile(ss); + if (dropdown::Item("Load DBC from Clipboard")) loadFromClipboard(ss, false); + + // Show sub-menu for each dbc for this source. + if (dbc_file) { + ImGui::Separator(); + dropdown::Item((dbc_file->name() + " (" + toString(dbc()->sources(dbc_file)) + ")").c_str(), nullptr, false, false); + if (dropdown::Item("Save...")) saveFile(dbc_file); + if (dropdown::Item("Save As...")) saveFileAs(dbc_file); + if (dropdown::Item("Copy to Clipboard")) saveFileToClipboard(dbc_file); + if (dropdown::Item("Remove from This Bus...")) closeFile(ss, {}); + if (dropdown::Item("Remove from All Buses...")) closeFile(dbc_file); + } + dropdown::EndMenu(); + } + ImGui::PopID(); + } +} + +void MainWindow::updateRecentFiles(const std::string &fn) { + settings.recent_files.erase(std::remove(settings.recent_files.begin(), settings.recent_files.end(), fn), settings.recent_files.end()); + settings.recent_files.insert(settings.recent_files.begin(), fn); + while (settings.recent_files.size() > MAX_RECENT_FILES) { + settings.recent_files.pop_back(); + } + settings.last_dir = std::filesystem::absolute(fn).parent_path().string(); +} + +void MainWindow::drawRecentFilesMenu() { + int num_recent_files = std::min(settings.recent_files.size(), MAX_RECENT_FILES); + if (!num_recent_files) { + dropdown::Item("No Recent Files", nullptr, false, false); + return; + } + + for (int i = 0; i < num_recent_files; ++i) { + std::string text = std::to_string(i + 1) + " " + std::filesystem::path(settings.recent_files[i]).filename().string(); + ImGui::PushID(i); + if (dropdown::Item(text.c_str())) loadFile(settings.recent_files[i]); + ImGui::PopID(); + } +} + +void MainWindow::remindSaveChanges(std::function then) { + if (UndoStack::instance()->isClean()) { + UndoStack::instance()->clear(); + if (then) then(); + return; + } + std::string text = "You have unsaved changes. Select OK to save them or Cancel to discard them."; + MessageBox::question("Unsaved Changes", text, [this, then](bool ok) { + if (ok) { + save([this, then]() { remindSaveChanges(then); }); + } else { + UndoStack::instance()->clear(); + if (then) then(); + } + }); +} + +void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool success) { + const double fraction = total > 0 ? cur / (double)total : 0.0; + if (wait_dlg_.open) wait_dlg_.value = (int)(fraction * 100); + if (success && cur < total) { + status_bar_.progress_value = fraction; + status_bar_.progress_text = "Downloading " + std::to_string((int)(fraction * 100)) + "% (" + formattedDataSize(total) + ")"; + status_bar_.progress_visible = true; + } else { + status_bar_.progress_visible = false; + } +} + +void MainWindow::close() { + if (closing_) return; + closing_ = true; + remindSaveChanges([this]() { finishClose(); }); +} + +void MainWindow::finishClose() { + // save states + auto &state = inistate::main_window; + state.maximized = glfwGetWindowAttrib(window_, GLFW_MAXIMIZED); + if (full_screen_) { +#ifndef __APPLE__ + // macOS full screen is the native Cocoa toggle, keep the loaded geometry there + state.pos[0] = windowed_rect_[0]; state.pos[1] = windowed_rect_[1]; + state.size[0] = windowed_rect_[2]; state.size[1] = windowed_rect_[3]; +#endif + } else if (!state.maximized) { + glfwGetWindowPos(window_, &state.pos[0], &state.pos[1]); + glfwGetWindowSize(window_, &state.size[0], &state.size[1]); + } + state.has_geometry = state.size[0] > 0 && state.size[1] > 0; + state.messages_visible = messages_visible_; + state.video_visible = video_visible_; + state.charts_visible = charts_visible_; + settings.ui_state = inistate::save(); + + saveSessionState(); + settings.save(); + exited_ = true; +} + +void MainWindow::openSettings() { + settings_dialog_.open(); +} + +void MainWindow::findSimilarBits() { + auto dlg = std::make_unique(); + dlg->connections_.push_back(dlg->openMessage.connect([this](const MessageId &id) { messages_widget_->selectMessage(id); })); + tool_dialogs_.push_back(std::move(dlg)); +} + +void MainWindow::findSignal() { + auto dlg = std::make_unique(); + dlg->connections_.push_back(dlg->openMessage.connect([this](const MessageId &id) { messages_widget_->selectMessage(id); })); + tool_dialogs_.push_back(std::move(dlg)); +} + +void MainWindow::toggleHelp() { + help_overlay_.toggle(); +} + +void MainWindow::toggleFullScreen() { +#ifdef __APPLE__ + toggleNativeFullScreen(window_); +#else + full_screen_ = !full_screen_; + if (full_screen_) { + glfwGetWindowPos(window_, &windowed_rect_[0], &windowed_rect_[1]); + glfwGetWindowSize(window_, &windowed_rect_[2], &windowed_rect_[3]); + GLFWmonitor *monitor = glfwGetPrimaryMonitor(); + const GLFWvidmode *mode = glfwGetVideoMode(monitor); + glfwSetWindowMonitor(window_, monitor, 0, 0, mode->width, mode->height, mode->refreshRate); + } else { + glfwSetWindowMonitor(window_, nullptr, windowed_rect_[0], windowed_rect_[1], windowed_rect_[2], windowed_rect_[3], 0); + glfwMaximizeWindow(window_); + } +#endif +} + +void MainWindow::saveSessionState() { + settings.recent_dbc_file = ""; + settings.active_msg_id = ""; + settings.selected_msg_ids.clear(); + settings.active_charts.clear(); + + const auto files = dbc()->nonEmptyDBCFiles(); + if (!files.empty()) settings.recent_dbc_file = files.front()->filename; + + if (auto *detail = center_widget_.getDetailWidget()) { + auto [active_id, ids] = detail->serializeMessageIds(); + settings.active_msg_id = active_id; + settings.selected_msg_ids = ids; + } + if (charts_widget_) { + settings.active_charts = charts_widget_->serializeChartIds(); + } +} + +void MainWindow::restoreSessionState() { + if (settings.recent_dbc_file.empty() || dbc()->nonEmptyDBCCount() == 0) return; + + if (dbc()->nonEmptyDBCFiles().front()->filename != settings.recent_dbc_file) return; + + if (!settings.selected_msg_ids.empty()) { + center_widget_.ensureDetailWidget()->restoreTabs(settings.active_msg_id, settings.selected_msg_ids); + } + + if (charts_widget_ != nullptr && !settings.active_charts.empty()) { + charts_widget_->restoreChartsFromIds(settings.active_charts); + } +} + +void MainWindow::handleShortcuts() { + const ImGuiIO &io = ImGui::GetIO(); + for (const KeyEvent &e : takeKeyEvents()) { + const bool ctrl = e.mods & (GLFW_MOD_CONTROL | GLFW_MOD_SUPER); + const bool shift = e.mods & GLFW_MOD_SHIFT; + // a focused text input consumes Space but not the Ctrl/F-key sequences + if (e.key == GLFW_KEY_SPACE && !ctrl && can && !io.WantTextInput) can->pause(!can->isPaused()); + if (e.key == GLFW_KEY_F1) toggleHelp(); + if (e.key == GLFW_KEY_F11 && ctrl) toggleFullScreen(); + // an open popup or a focused text input takes Esc first + if (e.key == GLFW_KEY_ESCAPE && full_screen_ && !io.WantTextInput && + !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel)) { + toggleFullScreen(); + } + if (!ctrl) continue; + if (e.key == GLFW_KEY_N) newFile(); + if (e.key == GLFW_KEY_O) openFile(); + if (e.key == GLFW_KEY_S) { + if (shift) { + if (dbc()->nonEmptyDBCCount() == 1) saveAs(); + } else if (dbc()->nonEmptyDBCCount() > 0) { + save(); + } + } + // a focused text input swallows Ctrl+Z / Ctrl+Shift+Z + if (e.key == GLFW_KEY_Z && !io.WantTextInput) shift ? UndoStack::instance()->redo() : UndoStack::instance()->undo(); + if (e.key == GLFW_KEY_Q) close(); + } +} + +void MainWindow::drawStatusBar() { + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::GetStyle().Colors[ImGuiCol_MenuBarBg]); + ImGui::BeginChild("status_bar", ImVec2(0, ImGui::GetFrameHeight()), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar); + const ImVec2 min = ImGui::GetWindowPos(); + ImGui::GetWindowDrawList()->AddRectFilled(min, ImVec2(min.x + ImGui::GetWindowWidth(), min.y + 1.0f), ImGui::GetColorU32(ImGuiCol_Border)); + // a borderless child gets no WindowPadding, so both ends sit flush against the edge and clip. Inset by + // WindowPadding.x, which lines the text up with the content of the docked panels above (the messages table). + const float width = ImGui::GetContentRegionAvail().x; + const float pad = ImGui::GetStyle().WindowPadding.x; + pushMonoFont(ImGui::GetStyle().FontSizeBase); + const float fps_x = std::max(pad, width - pad - ImGui::CalcTextSize("999 FPS").x); + popMonoFont(); + const float progress_width = std::min(300.0f, std::max(0.0f, fps_x - pad - 20.0f)); + const float progress_x = fps_x - progress_width - 10.0f; + const float message_end = status_bar_.progress_visible ? progress_x - ImGui::GetStyle().ItemSpacing.x : fps_x - 10.0f; + ImGui::PushClipRect(min, ImVec2(min.x + std::max(pad, message_end), min.y + ImGui::GetWindowHeight()), true); + ImGui::SetCursorPosX(pad); + ImGui::AlignTextToFramePadding(); + const float text_y = ImGui::GetCursorPosY(); + // a temporary message hides the normal widgets, permanent widgets stay on the right + auto &bar = status_bar_; + if (!bar.message.empty() && (bar.message_until == 0 || ImGui::GetTime() < bar.message_until)) { + ImGui::TextUnformatted(bar.message.c_str()); + } else { + bar.message.clear(); + ImGui::TextUnformatted("For help, press F1"); + } + ImGui::PopClipRect(); + if (bar.progress_visible && progress_width > 0) { + const float progress_height = 16.0f; + ImGui::PushFont(ImGui::GetFont(), 12.0f); + const std::string percentage = std::to_string((int)(bar.progress_value * 100)) + "%"; + const char *label = bar.progress_text.c_str(); + const float text_width = progress_width - 2 * ImGui::GetStyle().FramePadding.x; + if (ImGui::CalcTextSize(label).x > text_width) label = percentage.c_str(); + if (ImGui::CalcTextSize(label).x > text_width) label = ""; + ImGui::SameLine(progress_x); + ImGui::SetCursorPosY((ImGui::GetWindowHeight() - progress_height) / 2.0f); + ImGui::ProgressBar(bar.progress_value, ImVec2(progress_width, progress_height), label); + ImGui::PopFont(); + ImGui::SetItemTooltip("%s", bar.progress_text.c_str()); + } + ImGui::SameLine(fps_x); + ImGui::SetCursorPosY(text_y); + pushMonoFont(ImGui::GetStyle().FontSizeBase); + ImGui::Text("%3.0f FPS", ImGui::GetIO().Framerate); + popMonoFont(); + ImGui::SetItemTooltip("UI rendering rate (frames per second)"); + ImGui::EndChild(); + ImGui::PopStyleColor(); +} + +void MainWindow::drawWaitDialog() { + const char *id = "###WaitDialog"; + if (wait_dlg_.open && !ImGui::IsPopupOpen(id) && ImGui::GetTime() >= wait_dlg_.show_at) ImGui::OpenPopup(id); + if (!ImGui::IsPopupOpen(id)) return; // keep submitting until CloseCurrentPopup ran, a stale modal blocks all input + ImGui::SetNextWindowSize(ImVec2(400.0f, 0.0f), ImGuiCond_Always); + setNextDialogWindow(ImVec2(0.0f, 0.0f)); + if (ImGui::BeginPopupModal(id, nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextUnformatted(wait_dlg_.text.c_str()); + // no text until the progress is set + ImGui::ProgressBar(wait_dlg_.value / 100.0f, ImVec2(-1.0f, 0.0f), wait_dlg_.value == 0 ? "" : (const char *)nullptr); + bool abort = false, rejected = false; + dialogButtons("Abort", &abort, &rejected, true, nullptr); + if (abort || rejected) { + wait_dlg_.open = false; + close(); + } + if (!wait_dlg_.open) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } +} + +void MainWindow::drawDockspace() { + const ImGuiViewport *viewport = ImGui::GetMainViewport(); + // Use the menu bar's current-frame reservation, including on the first frame. + const ImRect work_rect = static_cast(viewport)->GetBuildWorkRect(); + ImGui::SetNextWindowPos(work_rect.Min); + ImGui::SetNextWindowSize(work_rect.GetSize()); + ImGui::SetNextWindowViewport(viewport->ID); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus | + ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoBackground | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse; + ImGui::Begin("##host", nullptr, flags); + ImGui::PopStyleVar(3); + + // the status bar sits below the dockspace: reserve its height plus the item spacing between the two, + // otherwise the host window is a few pixels taller than the viewport and scrolls + const float status_height = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y; + const ImVec2 dock_size(ImGui::GetContentRegionAvail().x, ImGui::GetContentRegionAvail().y - status_height); + const ImGuiID dock_id = ImGui::GetID("cabana_dockspace"); + if (reset_layout_ || ImGui::DockBuilderGetNode(dock_id) == nullptr || + (!ImGui::FindWindowByName(CHARTS_WINDOW) && !ImGui::FindWindowSettingsByID(ImHashStr(CHARTS_WINDOW)))) { + // Messages left, route above charts on the right, details in the middle. + ImGui::DockBuilderRemoveNode(dock_id); + ImGui::DockBuilderAddNode(dock_id, ImGuiDockNodeFlags_DockSpace); + ImGui::DockBuilderSetNodePos(dock_id, ImGui::GetCursorScreenPos()); + ImGui::DockBuilderSetNodeSize(dock_id, dock_size); + ImGuiID center = dock_id, left = 0, right = 0, charts = 0; + ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.28f, &left, ¢er); + ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.4f, &right, ¢er); + ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.55f, &charts, &right); + ImGui::DockBuilderDockWindow(CHARTS_WINDOW, charts); + ImGui::DockBuilderDockWindow(MESSAGES_PANEL_ID, left); + ImGui::DockBuilderDockWindow(VIDEO_PANEL, right); + ImGui::DockBuilderDockWindow(CENTER_PANEL, center); + ImGui::DockBuilderFinish(dock_id); + reset_layout_ = false; + } + // a panel never shrinks past half the width where the signal view's tool bar squishes + const float min_panel_width = (SignalView::minimumWidth() + (ImGui::GetStyle().WindowPadding.x + ImGui::GetStyle().WindowBorderSize) * 2) * 0.5f; + ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(min_panel_width, ImGui::GetStyle().WindowMinSize.y)); + ImGui::DockSpace(dock_id, dock_size); + ImGui::PopStyleVar(); + drawStatusBar(); + ImGui::End(); +} + +namespace { +// closing a panel that floated out into its own os window brings it back into the default layout, only +// the close button of a docked panel hides it +bool floatingOut() { return ImGui::GetWindowViewport() != ImGui::GetMainViewport(); } + +// the side panels float out like the dialogs, and their dock nodes have no window menu button: its +// only entry hides the tab bar, and with it the title and the close button +void setNextPanelClass() { + ImGuiWindowClass window_class; + window_class.ViewportFlagsOverrideSet = ImGuiViewportFlags_NoAutoMerge; + window_class.DockNodeFlagsOverrideSet = ImGuiDockNodeFlags_NoWindowMenuButton; + ImGui::SetNextWindowClass(&window_class); +} + +bool beginPanel(const char *name, bool *open, ImGuiWindowFlags flags = 0) { + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + const bool visible = ImGui::Begin(name, open, flags | ImGuiWindowFlags_NoCollapse); + ImGui::PopStyleVar(); + return visible; +} +} // namespace + +void MainWindow::drawMessagesPanel() { + const std::string name = (messages_widget_ ? messages_widget_->title() : "MESSAGES") + std::string(MESSAGES_PANEL_ID); + setNextPanelClass(); + if (beginPanel(name.c_str(), &messages_visible_)) { + if (messages_widget_) { + help_overlay_.add(messages_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + messages_widget_->draw(); + } + } + const bool floating = floatingOut(); + ImGui::End(); + if (!messages_visible_ && floating) messages_visible_ = reset_layout_ = true; +} + +void MainWindow::drawVideoPanel() { + const std::string name = std::string(videoPanelTitle()) + VIDEO_PANEL; + setNextPanelClass(); + const bool video_open = beginPanel(name.c_str(), &video_visible_); + const bool floating = floatingOut(); + if (video_widget_ && !video_open) { + video_widget_->setVisible(false); // the dock is collapsed or tabbed behind another one, like hideEvent + } else if (video_widget_) { + help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + video_widget_->draw(); + } + ImGui::End(); + if (!video_visible_ && floating) video_visible_ = reset_layout_ = true; +} + +void MainWindow::draw() { +#ifdef __APPLE__ + full_screen_ = isNativeFullScreen(window_); +#endif + auto pending = std::move(next_frame_); + next_frame_.clear(); + for (auto &fn : pending) fn(); + + if (ImGui::GetTopMostPopupModal() == nullptr) { + handleShortcuts(); + } else { + takeKeyEvents(); // modal dialogs swallow the shortcuts + } + if (!full_screen_) drawMenuBar(); + drawDockspace(); + + // the central widget has no scrollbars of its own (the views inside scroll) + setNextPanelClass(); + if (beginPanel(CENTER_PANEL, nullptr, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + center_widget_.draw(); + if (auto *detail = center_widget_.getDetailWidget(); detail && help_overlay_.visible()) { + for (const auto &[text, rect] : detail->helpRects()) help_overlay_.add(text, rect); + } + } + ImGui::End(); + // Submit the same dock windows while loading, so ImGui doesn't collapse their + // nodes and then redistribute the layout when the stream's widgets arrive. + if (messages_visible_) drawMessagesPanel(); + if (video_widget_ && !video_visible_) video_widget_->setVisible(false); + if (video_visible_) drawVideoPanel(); + if (charts_visible_) { + const std::string charts_title = "Charts: " + std::to_string(charts_widget_ ? charts_widget_->chartCount() : 0) + "###ChartsWindow"; + setNextPanelClass(); + if (beginPanel(charts_title.c_str(), &charts_visible_, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + if (charts_widget_) { + help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + charts_widget_->draw(); + } + } + ImGui::End(); + } + for (auto it = tool_dialogs_.begin(); it != tool_dialogs_.end();) { + it = (*it)->draw() ? it + 1 : tool_dialogs_.erase(it); + } + + stream_selector_.draw(); + settings_dialog_.draw(); + drawWaitDialog(); + FileDialog::draw(); + MessageBox::draw(); + help_overlay_.draw(); + + // Escape closes the top-most non-modal popup (a menu or a combo list) on its own; the modal dialogs + // handled Escape themselves above when they were on top + if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + ImGuiWindow *top = topPopupWindow(); + if (top != nullptr && !(top->Flags & ImGuiWindowFlags_Modal)) ImGui::ClosePopupToLevel(GImGui->OpenPopupStack.Size - 1, true); + } +} diff --git a/openpilot/tools/cabana/ui/mainwin.h b/openpilot/tools/cabana/ui/mainwin.h new file mode 100644 index 000000000..b7afa1769 --- /dev/null +++ b/openpilot/tools/cabana/ui/mainwin.h @@ -0,0 +1,136 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/ui/app.h" +#include "tools/cabana/ui/dialogs/settingsdialog.h" +#include "tools/cabana/ui/dialogs/streamselector.h" +#include "tools/cabana/ui/helpoverlay.h" +#include "tools/cabana/ui/tools/tooldialog.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/widgets/detailwidget.h" +#include "tools/cabana/ui/widgets/messageswidget.h" +#include "tools/cabana/ui/widgets/videowidget.h" + +struct GLFWwindow; + +class MainWindow { +public: + MainWindow(GLFWwindow *window, std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file); + ~MainWindow(); + void draw(); + void close(); // remind unsaved changes, save state, exit + bool exited() const { return exited_; } + void showStatusMessage(const std::string &msg, int timeout_ms = 0); + void loadFile(const std::string &fn, SourceSet s = SOURCE_ALL, std::function then = {}); + + void selectAndOpenStream(); + void openStream(std::unique_ptr stream, const std::string &dbc_file = {}); + void closeStream(); + void exportToCSV(); + + void newFile(SourceSet s = SOURCE_ALL); + void openFile(SourceSet s = SOURCE_ALL); + void loadDBCFromOpendbc(const std::string &name); + void save(std::function then = {}); + void saveAs(std::function then = {}); + void saveToClipboard(); + +private: + bool hasStream() const { return dynamic_cast(can) == nullptr; } + const char *videoPanelTitle() const { return hasStream() && can->liveStreaming() ? "Live Stream" : "Video"; } + void releaseStream(); + void startStream(std::unique_ptr stream, const std::string &dbc_file); + void loadStartupStream(const std::string &dbc_file); + void remindSaveChanges(std::function then); + void closeFile(SourceSet s, std::function then); + void closeFile(DBCFile *dbc_file); + void saveFiles(bool as, std::function then); + void saveFile(DBCFile *dbc_file, std::function then = {}); + void saveFileAs(DBCFile *dbc_file, std::function then = {}); + void saveFileToClipboard(DBCFile *dbc_file); + void copyToClipboard(const std::string &text); + void loadFingerprints(); + void loadFromClipboard(SourceSet s = SOURCE_ALL, bool close_all = true); + void updateRecentFiles(const std::string &fn); + void dbcFileChanged(); + void updateDownloadProgress(uint64_t cur, uint64_t total, bool success); + void openSettings(); + void findSimilarBits(); + void findSignal(); + void toggleHelp(); + void toggleFullScreen(); + void updateWindowTitle(); + void eventsMerged(); + void saveSessionState(); + void restoreSessionState(); + void finishClose(); + void nextFrame(std::function fn) { next_frame_.push_back(std::move(fn)); } + void createDockWidgets(); + + void handleShortcuts(); + void drawMenuBar(); + void drawFileMenu(); + void drawManageDBCsMenu(); + void drawRecentFilesMenu(); + void drawDockspace(); + void drawMessagesPanel(); + void drawVideoPanel(); + void drawStatusBar(); + void drawWaitDialog(); + + GLFWwindow *window_; + std::unique_ptr startup_stream_; // opened on the first frame + StreamLoader startup_loader_; // run on a worker after the first frame + std::unique_ptr stream_; // `can` points here, or at dummy_ when no stream is open + DummyStream dummy_; + std::unique_ptr messages_widget_; + CenterWidget center_widget_; + std::unique_ptr video_widget_; + std::unique_ptr charts_widget_; + StreamSelector stream_selector_; + SettingsDialog settings_dialog_; + HelpOverlay help_overlay_; + std::unordered_map fingerprint_to_dbc_; + std::vector opendbc_names_; + enum { MAX_RECENT_FILES = 15 }; + std::string car_fingerprint_; + bool messages_visible_ = true; + bool video_visible_ = true; + bool charts_visible_ = true; + bool reset_layout_ = false; + bool full_screen_ = false; +#ifndef __APPLE__ + int windowed_rect_[4] = {0, 0, 1600, 900}; +#endif + std::vector> tool_dialogs_; + bool closing_ = false; + bool exited_ = false; + bool window_modified_ = false; + struct StatusBar { + std::string message; + double message_until = 0; + bool progress_visible = false; + float progress_value = 0; + std::string progress_text; + } status_bar_; + // "Loading segment data..." dialog + struct WaitDialog { + bool open = false; + double show_at = 0; + std::string text; + int value = 0; + Connection connection; + } wait_dlg_; + std::vector> next_frame_; + Connections connections_; + Connections stream_connections_; + Connections widget_connections_; +}; diff --git a/openpilot/tools/cabana/ui/qtstate.cc b/openpilot/tools/cabana/ui/qtstate.cc new file mode 100644 index 000000000..f4283420c --- /dev/null +++ b/openpilot/tools/cabana/ui/qtstate.cc @@ -0,0 +1,171 @@ +#include "tools/cabana/ui/qtstate.h" + +#include +#include + +namespace qtstate { + +namespace { + +// big-endian QDataStream reader; any read past the end latches the failed state +class Cursor { +public: + explicit Cursor(const std::vector &data) : data_(data) {} + + bool ok() const { return ok_; } + size_t remaining() const { return ok_ ? data_.size() - pos_ : 0; } + + uint8_t u8() { return read(1) ? data_[pos_ - 1] : 0; } + uint16_t u16() { + if (!read(2)) return 0; + return (uint16_t(data_[pos_ - 2]) << 8) | data_[pos_ - 1]; + } + uint32_t u32() { + if (!read(4)) return 0; + return (uint32_t(data_[pos_ - 4]) << 24) | (uint32_t(data_[pos_ - 3]) << 16) | + (uint32_t(data_[pos_ - 2]) << 8) | data_[pos_ - 1]; + } + int32_t i32() { return (int32_t)u32(); } + void skip(size_t n) { read(n); } + +private: + bool read(size_t n) { + if (!ok_ || data_.size() - pos_ < n) { + ok_ = false; + return false; + } + pos_ += n; + return true; + } + + const std::vector &data_; + size_t pos_ = 0; + bool ok_ = true; +}; + +} // namespace + +std::optional parseQtGeometry(const std::vector &data) { + Cursor c(data); + if (c.u32() != 0x01D9D0CB) return std::nullopt; + const uint16_t major = c.u16(); + c.u16(); // minor + if (!c.ok() || major > 3) return std::nullopt; + + c.skip(16); // frameGeometry + int x1 = c.i32(), y1 = c.i32(), x2 = c.i32(), y2 = c.i32(); // normalGeometry + c.i32(); // screenNumber + const bool maximized = c.u8() != 0; + c.u8(); // fullScreen + if (major >= 2) c.i32(); // screenWidth + if (major >= 3) { + // the client-area rect actually restored + x1 = c.i32(); y1 = c.i32(); x2 = c.i32(); y2 = c.i32(); + } + if (!c.ok()) return std::nullopt; + return QtGeometry{x1, y1, x2 - x1 + 1, y2 - y1 + 1, maximized}; +} + +std::optional parseQtSplitter(const std::vector &data) { + Cursor c(data); + if (c.i32() != 0xff) return std::nullopt; + const int32_t version = c.i32(); + if (!c.ok() || version > 1) return std::nullopt; + + const uint32_t count = c.u32(); + if (!c.ok() || count != 2) return std::nullopt; + const int32_t first = c.i32(); + const int32_t second = c.i32(); + if (!c.ok() || first + second <= 0) return std::nullopt; + return QtSplitter{first / float(first + second)}; +} + +std::optional parseQtHeaderState(const std::vector &data) { + constexpr int N = kMessageColumnCount; + Cursor c(data); + if (c.i32() != 0xff) return std::nullopt; + if (c.i32() != 0) return std::nullopt; // version + + c.i32(); // orientation + const int32_t sort_order = c.i32(); + const int32_t sort_section = c.i32(); + const bool sort_shown = c.u8() != 0; + if (!c.ok()) return std::nullopt; + + QtHeaderState state{}; + state.sort_section = sort_section; + state.sort_order = sort_order; + state.sort_shown = sort_shown; + for (int i = 0; i < N; ++i) state.visual[i] = i; + + // visualIndices: logical -> visual, empty means identity + const uint32_t visual_count = c.u32(); + if (!c.ok() || visual_count > c.remaining() / 4) return std::nullopt; + if (visual_count > 0) { + std::vector visual(visual_count); + for (uint32_t i = 0; i < visual_count; ++i) visual[i] = c.i32(); + if (!c.ok()) return std::nullopt; + bool valid = visual_count == (uint32_t)N; + if (valid) { + bool seen[N] = {}; + for (int v : visual) { + if (v < 0 || v >= N || seen[v]) { valid = false; break; } + seen[v] = true; + } + } + if (valid) { + for (int i = 0; i < N; ++i) state.visual[i] = visual[i]; + } + } + + // logicalIndices: visual -> logical, unused but must be consumed + const uint32_t logical_count = c.u32(); + if (!c.ok() || logical_count > c.remaining() / 4) return std::nullopt; + c.skip(logical_count * 4); + + // sectionHidden: QBitArray indexed by visual position + const uint32_t hidden_bits = c.u32(); + if (!c.ok() || hidden_bits / 8 > c.remaining()) return std::nullopt; + std::vector hidden_bytes((hidden_bits + 7) / 8); + for (auto &b : hidden_bytes) b = c.u8(); + if (!c.ok()) return std::nullopt; + + // hiddenSectionSize: logical index -> size before hiding + const uint32_t hidden_size_count = c.u32(); + if (!c.ok() || hidden_size_count > c.remaining() / 8) return std::nullopt; + std::map hidden_sizes; + for (uint32_t i = 0; i < hidden_size_count; ++i) { + const int key = c.i32(); + hidden_sizes[key] = c.i32(); + } + if (!c.ok()) return std::nullopt; + + c.i32(); // length + c.i32(); // sectionCount + c.skip(5); // movable, clickable, highlight, stretchLastSection, cascading + c.skip(24); // stretchSections, contentsSections, defaultSectionSize, minimumSectionSize, defaultAlignment, globalResizeMode + if (!c.ok()) return std::nullopt; + + // SectionItems in visual order; a Qt4 item with count != 1 stands for count sections + const uint32_t item_count = c.u32(); + if (!c.ok() || item_count > c.remaining() / 12) return std::nullopt; + std::vector sizes; + for (uint32_t i = 0; i < item_count; ++i) { + const int size = c.i32(); + const int count = c.i32(); + c.i32(); // resizeMode + if (!c.ok() || count <= 0 || (int)sizes.size() + count > N) return std::nullopt; + for (int j = 0; j < count; ++j) sizes.push_back(size / count); + } + if (!c.ok() || (int)sizes.size() != N) return std::nullopt; + + for (int i = 0; i < N; ++i) { + const int v = state.visual[i]; + state.hidden[i] = (uint32_t)v < hidden_bits && (hidden_bytes[v / 8] & (1 << (v % 8))) != 0; + auto it = hidden_sizes.find(i); + state.width[i] = (state.hidden[i] && it != hidden_sizes.end()) ? it->second : sizes[v]; + } + return state; +} + +} // namespace qtstate diff --git a/openpilot/tools/cabana/ui/qtstate.h b/openpilot/tools/cabana/ui/qtstate.h new file mode 100644 index 000000000..76dbd7400 --- /dev/null +++ b/openpilot/tools/cabana/ui/qtstate.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include + +// Parsers for the Qt frontend's persisted QByteArray blobs (QWidget::saveGeometry, +// QSplitter::saveState, QHeaderView::saveState). Used once to migrate a Qt cabana.json +// to the imgui frontend's ini state. No Qt and no imgui. +// TODO: Delete this migration (qtstate.{h,cc}, migrateQtState in inistate.cc and the Qt +// byte-array fields in Settings) after users have had time to migrate to ui_state. +namespace qtstate { + +constexpr int kMessageColumnCount = 7; + +struct QtGeometry { + int x, y, w, h; + bool maximized; +}; + +struct QtSplitter { + float ratio; +}; + +struct QtHeaderState { + int sort_section; + int sort_order; // 0 = Qt::AscendingOrder, 1 = Qt::DescendingOrder + bool sort_shown; + int visual[kMessageColumnCount]; + int width[kMessageColumnCount]; + bool hidden[kMessageColumnCount]; +}; + +std::optional parseQtGeometry(const std::vector &data); +std::optional parseQtSplitter(const std::vector &data); +std::optional parseQtHeaderState(const std::vector &data); + +} // namespace qtstate diff --git a/openpilot/tools/cabana/ui/theme.cc b/openpilot/tools/cabana/ui/theme.cc new file mode 100644 index 000000000..14d094983 --- /dev/null +++ b/openpilot/tools/cabana/ui/theme.cc @@ -0,0 +1,180 @@ +#include "tools/cabana/ui/theme.h" + +#include +#include +#include + +#include "implot.h" +#include "tools/cabana/core/settings.h" + +namespace fs = std::filesystem; + +namespace { + +constexpr Palette DARK_PALETTE = { + .text = rgb(0xbbbbbb), .text_disabled = rgb(0x777777), .text_selected = rgb(0xbbbbbb), + .window = rgb(0x353535), .surface = rgb(0x3c3f41), + .frame = rgb(0x3c3f41), .frame_hovered = rgb(0x484b4d), .frame_active = rgb(0x505355), + .button = rgb(0x484b4d), .button_hovered = rgb(0x535658), .button_active = rgb(0x3c3f41), + .header = rgb(0x2f65ca), .header_hovered = rgb(0x414e65), .header_active = rgb(0x2f65ca), + .accent = rgb(0x2f65ca), + .border = rgb(0x282828), .separator = rgb(0x353535), .scrollbar_grab = rgb(0x484b4d), + .slider_track = rgb(0x777777), + .tab = rgb(0x353535), .tab_hovered = rgb(0x484b4d), .table_header = rgb(0x484b4d), + .grid = rgb(0xbbbbbb, 50.0f / 255.0f), .badge = rgb(0x808080), + .bit_background = rgb(0xffffff, 20.0f / 255.0f), + .heatmap_signal_alpha = 70.0, .heatmap_bit_alpha = 28.0, .heatmap_gamma = 0.6, + .sparkline_saturation = 1.0f, .sparkline_value = 1.0f, +}; + +constexpr Palette LIGHT_PALETTE = { + .text = rgb(0x000000), .text_disabled = rgb(0xbebebe), .text_selected = rgb(0xffffff), + .window = rgb(0xefefef), .surface = rgb(0xffffff), + .frame = rgb(0xffffff), .frame_hovered = rgb(0xf5f9fc), .frame_active = rgb(0xe7f3fb), + .button = rgb(0xefefef), .button_hovered = rgb(0xe7f3fb), .button_active = rgb(0xd4e7f4), + .header = rgb(0x308cc6), .header_hovered = rgb(0xe7f3fb), .header_active = rgb(0x308cc6), + .accent = rgb(0x308cc6), + .border = rgb(0xb6b6b6), .separator = rgb(0xd0d0d0), .scrollbar_grab = rgb(0xb6b6b6), + .slider_track = rgb(0xd0d0d0), + .tab = rgb(0xe5e5e5), .tab_hovered = rgb(0xefefef), .table_header = rgb(0xefefef), + .grid = rgb(0x000000, 50.0f / 255.0f), .badge = rgb(0xa0a0a4), + .bit_background = rgb(0xffffff, 0.0f), + .heatmap_signal_alpha = 25.0, .heatmap_bit_alpha = 10.0, .heatmap_gamma = 1.0, + .sparkline_saturation = 2.0f, .sparkline_value = 0.7f, +}; + +const Palette *g_palette = &LIGHT_PALETTE; +ImFont *g_ui_font = nullptr; +ImFont *g_bold_font = nullptr; +ImFont *g_mono_font = nullptr; +ImFont *g_large_font = nullptr; + +void addIconFont(float size, ImFont *base) { + ImFontConfig cfg; + cfg.MergeMode = base != nullptr; + cfg.GlyphMinAdvanceX = size; + if (base != nullptr) { + ImFontBaked *baked = base->GetFontBaked(size); + const float center = baked != nullptr ? (baked->Ascent + baked->Descent) * 0.5f : size * 0.5f; + cfg.GlyphOffset.y = std::round(size * 0.5f - center); + } + static const ImWchar ranges[] = {0xF000, 0xF8FF, 0}; + ImGui::GetIO().Fonts->AddFontFromFileTTF(BOOTSTRAP_ICONS_TTF, size, &cfg, ranges); +} + +ImFont *addFont(const fs::path &path, float size) { + ImFontConfig cfg; + cfg.OversampleH = 2; + cfg.OversampleV = 2; + ImFont *font = ImGui::GetIO().Fonts->AddFontFromFileTTF(path.c_str(), size, &cfg); + if (font != nullptr) addIconFont(size, font); + return font; +} + +ImVec4 alpha(ImVec4 c, float a) { return ImVec4(c.x, c.y, c.z, a); } + +} // namespace + +void loadFonts() { + ImGuiIO &io = ImGui::GetIO(); + const fs::path fonts = fs::path(CABANA_FONTS_DIR); + g_ui_font = addFont(fonts / "Inter-Regular.ttf", UI_FONT_SIZE); + g_bold_font = addFont(fonts / "Inter-SemiBold.ttf", UI_FONT_SIZE); + g_mono_font = addFont(fonts / "JetBrainsMono-Medium.ttf", 15.0f); + g_large_font = addFont(fonts / "Inter-Bold.ttf", 50.0f); + if (g_ui_font != nullptr) io.FontDefault = g_ui_font; + if (g_bold_font == nullptr) g_bold_font = g_ui_font; + if (g_mono_font == nullptr) g_mono_font = g_ui_font; + if (g_large_font == nullptr) g_large_font = g_bold_font; +} + +void applyTheme(int theme) { + g_palette = theme == DARK_THEME ? &DARK_PALETTE : &LIGHT_PALETTE; + const Palette &p = *g_palette; + const ImVec4 none(0, 0, 0, 0); + + ImGuiStyle &style = ImGui::GetStyle(); + style = ImGuiStyle(); + style.WindowRounding = 6.0f; // dialogs and tooltips; docked panels and os windows ignore it + style.ChildRounding = 6.0f; + style.PopupRounding = 6.0f; + style.FrameRounding = 4.0f; + style.GrabRounding = 4.0f; + style.ScrollbarRounding = 4.0f; + style.TabRounding = 4.0f; + style.WindowBorderSize = 1.0f; + style.FrameBorderSize = 1.0f; + style.TabBorderSize = 1.0f; + style.WindowPadding = ImVec2(spacing::CONTROL, spacing::CONTROL); + style.FramePadding = ImVec2(spacing::CONTROL, spacing::INNER); + style.ItemSpacing = ImVec2(spacing::CONTROL, spacing::CONTROL); + style.ItemInnerSpacing = ImVec2(spacing::INNER, spacing::INNER); + style.CellPadding = ImVec2(6.0f, 4.0f); + style.ScrollbarSize = 14.0f; + style.GrabMinSize = 13.0f; + + ImVec4 *c = style.Colors; + c[ImGuiCol_Text] = p.text; + c[ImGuiCol_TextDisabled] = p.text_disabled; + c[ImGuiCol_WindowBg] = c[ImGuiCol_ScrollbarBg] = c[ImGuiCol_DockingEmptyBg] = p.window; + c[ImGuiCol_MenuBarBg] = p.window; + c[ImGuiCol_TitleBg] = c[ImGuiCol_TitleBgActive] = c[ImGuiCol_TitleBgCollapsed] = p.window; + c[ImGuiCol_ChildBg] = c[ImGuiCol_PopupBg] = p.surface; + c[ImGuiCol_Border] = c[ImGuiCol_TableBorderStrong] = p.border; + c[ImGuiCol_Separator] = c[ImGuiCol_TableBorderLight] = p.separator; + c[ImGuiCol_BorderShadow] = c[ImGuiCol_ResizeGrip] = c[ImGuiCol_TableRowBg] = none; + c[ImGuiCol_FrameBg] = p.frame; + c[ImGuiCol_FrameBgHovered] = p.frame_hovered; + c[ImGuiCol_FrameBgActive] = p.frame_active; + c[ImGuiCol_Button] = p.button; + c[ImGuiCol_ButtonHovered] = c[ImGuiCol_SliderGrab] = p.button_hovered; + c[ImGuiCol_ScrollbarGrab] = p.scrollbar_grab; + c[ImGuiCol_ScrollbarGrabHovered] = p.text_disabled; + c[ImGuiCol_ButtonActive] = p.button_active; + c[ImGuiCol_Header] = p.header; + c[ImGuiCol_HeaderHovered] = p.header_hovered; + c[ImGuiCol_HeaderActive] = p.header_active; + c[ImGuiCol_CheckMark] = c[ImGuiCol_NavCursor] = c[ImGuiCol_SliderGrabActive] = c[ImGuiCol_ScrollbarGrabActive] = p.accent; + c[ImGuiCol_SeparatorActive] = c[ImGuiCol_ResizeGripActive] = c[ImGuiCol_DragDropTarget] = p.accent; + c[ImGuiCol_TabSelectedOverline] = c[ImGuiCol_PlotHistogram] = p.accent; + c[ImGuiCol_SeparatorHovered] = c[ImGuiCol_ResizeGripHovered] = alpha(p.accent, 0.6f); + c[ImGuiCol_DockingPreview] = alpha(p.accent, 0.5f); + c[ImGuiCol_TextSelectedBg] = alpha(p.accent, 0.35f); + c[ImGuiCol_Tab] = c[ImGuiCol_TabDimmed] = p.tab; + c[ImGuiCol_TabHovered] = p.tab_hovered; + c[ImGuiCol_TabSelected] = c[ImGuiCol_TabDimmedSelected] = p.surface; + c[ImGuiCol_TabDimmedSelectedOverline] = none; + c[ImGuiCol_TableHeaderBg] = p.table_header; + c[ImGuiCol_TableRowBgAlt] = none; + c[ImGuiCol_PlotLines] = p.text; + // ImGuiStyle() seeds every slot from the dark theme: set the rest so the light theme does not keep a white caret. + c[ImGuiCol_InputTextCursor] = c[ImGuiCol_UnsavedMarker] = p.text; + c[ImGuiCol_TextLink] = c[ImGuiCol_PlotLinesHovered] = c[ImGuiCol_PlotHistogramHovered] = c[ImGuiCol_NavWindowingHighlight] = p.accent; + c[ImGuiCol_TreeLines] = p.separator; + c[ImGuiCol_DragDropTargetBg] = alpha(p.accent, 0.2f); + // Disable the modal dim fade to make dialogs appear immediately. + c[ImGuiCol_ModalWindowDimBg] = c[ImGuiCol_NavWindowingDimBg] = none; + + // ChartView::drawAxes() pushes the other plot colors it needs; the rest are auto colors derived from the ImGui style. + ImPlot::GetStyle().Colors[ImPlotCol_AxisGrid] = p.grid; +} + +const Palette &palette() { return *g_palette; } + +CabanaColor sparklineColor(const CabanaColor &color) { + const Palette &p = palette(); + auto [h, s, v] = color.hsv(); + return CabanaColor::fromHsv(h, std::min(1.0f, s * p.sparkline_saturation), v * p.sparkline_value, color.a / 255.0f); +} + +ImFont *boldFont() { return g_bold_font; } + +void pushMonoFont(float size) { + if (!g_mono_font) return; + size > 0.0f ? ImGui::PushFont(g_mono_font, size) : ImGui::PushFont(g_mono_font); +} +void popMonoFont() { if (g_mono_font) ImGui::PopFont(); } +void pushBoldFont() { if (g_bold_font) ImGui::PushFont(g_bold_font); } +void popBoldFont() { if (g_bold_font) ImGui::PopFont(); } +void pushLargeFont() { if (g_large_font) ImGui::PushFont(g_large_font); } +void popLargeFont() { if (g_large_font) ImGui::PopFont(); } diff --git a/openpilot/tools/cabana/ui/theme.h b/openpilot/tools/cabana/ui/theme.h new file mode 100644 index 000000000..9c8653cfe --- /dev/null +++ b/openpilot/tools/cabana/ui/theme.h @@ -0,0 +1,57 @@ +#pragma once + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/core/color.h" + +struct Palette { + ImVec4 text, text_disabled, text_selected; + ImVec4 window; // the background behind panels and docked windows + ImVec4 surface; // panels, popups, table bodies: what content is drawn on + ImVec4 frame, frame_hovered, frame_active; + ImVec4 button, button_hovered, button_active; + ImVec4 header, header_hovered, header_active; // selections + ImVec4 accent; + ImVec4 border, separator, scrollbar_grab; + ImVec4 slider_track; + ImVec4 tab, tab_hovered, table_header; + ImVec4 grid; + ImVec4 badge; // the fill behind the time labels drawn over a chart + ImVec4 bit_background; // overlay beneath heatmap bits without a signal + double heatmap_signal_alpha, heatmap_bit_alpha, heatmap_gamma; + float sparkline_saturation, sparkline_value; // HSV multipliers for signal colors +}; + +constexpr ImVec4 rgb(unsigned hex, float alpha = 1.0f) { + return ImVec4(((hex >> 16) & 255) / 255.0f, ((hex >> 8) & 255) / 255.0f, (hex & 255) / 255.0f, alpha); +} +inline ImVec4 colorRgb(int r, int g, int b, float alpha = 1.0f) { + return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, alpha); +} +inline ImU32 toImU32(const CabanaColor &c) { return IM_COL32(c.r, c.g, c.b, c.a); } +inline ImVec4 toImVec4(const CabanaColor &c) { return ImVec4(c.r / 255.0f, c.g / 255.0f, c.b / 255.0f, c.a / 255.0f); } +inline ImU32 withAlpha(ImU32 c, int alpha) { return (c & ~IM_COL32_A_MASK) | ((ImU32)alpha << IM_COL32_A_SHIFT); } + +// Logical pixels. External control gaps are equal on both axes; inner spacing +// is reserved for parts of one control (icon/label, checkbox/label, dropdown arrow). +namespace spacing { +constexpr float CONTROL = 8.0f; +constexpr float INNER = 4.0f; +constexpr float DIALOG_BUTTON_MIN_WIDTH = 80.0f; +} // namespace spacing + +constexpr float UI_FONT_SIZE = 16.0f; + +void loadFonts(); +void applyTheme(int theme); // Safe to call at runtime. +const Palette &palette(); +CabanaColor sparklineColor(const CabanaColor &color); + +ImFont *boldFont(); +void pushMonoFont(float size = 0.0f); +void popMonoFont(); +void pushBoldFont(); +void popBoldFont(); +void pushLargeFont(); +void popLargeFont(); diff --git a/openpilot/tools/cabana/ui/threadpool.h b/openpilot/tools/cabana/ui/threadpool.h new file mode 100644 index 000000000..3580f55c7 --- /dev/null +++ b/openpilot/tools/cabana/ui/threadpool.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Reusing the same threads matters: a std::async thread per update lands each allocation in a different +// glibc malloc arena and the process RSS grows without bound. +class ThreadPool { +public: + static ThreadPool &instance() { + static ThreadPool pool(std::clamp(std::thread::hardware_concurrency(), 2u, 4u)); + return pool; + } + + std::future run(std::function fn) { + auto task = std::make_shared>(std::move(fn)); + std::future future = task->get_future(); + { + std::lock_guard lk(mutex_); + tasks_.push([task]() { (*task)(); }); + } + cv_.notify_one(); + return future; + } + + ~ThreadPool() { + { + std::lock_guard lk(mutex_); + stop_ = true; + } + cv_.notify_all(); + for (auto &thread : threads_) thread.join(); + } + +private: + explicit ThreadPool(unsigned n) { + for (unsigned i = 0; i < n; ++i) { + threads_.emplace_back([this]() { + for (;;) { + std::function task; + { + std::unique_lock lk(mutex_); + cv_.wait(lk, [this]() { return stop_ || !tasks_.empty(); }); + if (stop_ && tasks_.empty()) return; + task = std::move(tasks_.front()); + tasks_.pop(); + } + task(); + } + }); + } + } + + std::vector threads_; + std::queue> tasks_; + std::mutex mutex_; + std::condition_variable cv_; + bool stop_ = false; +}; + +// fn(begin, end) over [0, n) split into one chunk per pool thread plus one for the caller, which also +// waits for the others. Not for use from a pool thread. +inline void parallelFor(size_t n, const std::function &fn) { + const size_t chunks = std::clamp(std::thread::hardware_concurrency(), 2, 4) + 1; + const size_t chunk = (n + chunks - 1) / chunks; + if (chunk == 0) return; + std::vector> futures; + size_t begin = chunk; + for (; begin < n; begin += chunk) { + futures.push_back(ThreadPool::instance().run([&fn, begin, end = std::min(begin + chunk, n)]() { fn(begin, end); })); + } + // All tasks must finish before captures can go out of scope, even if one fails. + std::exception_ptr error; + try { fn(0, std::min(chunk, n)); } catch (...) { error = std::current_exception(); } + for (auto &f : futures) { + try { f.get(); } catch (...) { if (!error) error = std::current_exception(); } + } + if (error) std::rethrow_exception(error); +} diff --git a/openpilot/tools/cabana/ui/tools/findsignal.cc b/openpilot/tools/cabana/ui/tools/findsignal.cc new file mode 100644 index 000000000..f977aa804 --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/findsignal.cc @@ -0,0 +1,330 @@ +#include "tools/cabana/ui/tools/findsignal.h" + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +namespace { +constexpr int MAX_ROWS = 300; +} // namespace + +void SignalSearch::search(const std::function &cmp) { + const auto prev_sigs = !histories.empty() ? histories.back() : initial_signals; + filtered_signals.clear(); + filtered_signals.reserve(prev_sigs.size()); + + std::mutex lock; + parallelFor(prev_sigs.size(), [&](size_t begin, size_t end) { + for (size_t i = begin; i < end; ++i) { + const auto &s = prev_sigs[i]; + const auto &events = can->events(s.id); + auto first = std::upper_bound(events.cbegin(), events.cend(), s.mono_time, CompareCanEvent()); + auto last = events.cend(); + if (last_time < std::numeric_limits::max()) { + last = std::upper_bound(events.cbegin(), events.cend(), last_time, CompareCanEvent()); + } + + auto it = std::find_if(first, last, [&](const CanEvent *e) { return cmp(get_raw_value(e->dat, e->size, s.sig)); }); + if (it != last) { + auto values = s.values; + char buf[64]; + snprintf(buf, sizeof(buf), "(%.3f, %g)", can->toSeconds((*it)->mono_time), get_raw_value((*it)->dat, (*it)->size, s.sig)); + values.push_back(buf); + std::lock_guard lk(lock); + filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values}); + } + } + }); + + histories.push_back(filtered_signals); +} + +void SignalSearch::undo() { + if (!histories.empty()) { + histories.pop_back(); + filtered_signals.clear(); + if (!histories.empty()) filtered_signals = histories.back(); + } +} + +void SignalSearch::reset() { + histories.clear(); + filtered_signals.clear(); + initial_signals.clear(); +} + +FindSignalDlg::FindSignalDlg() { + setTitle("Find Signal"); +} + +FindSignalDlg::~FindSignalDlg() { + if (search_future_.valid()) search_future_.wait(); +} + +bool FindSignalDlg::draw() { + if (search_future_.valid() && search_future_.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + search_future_.get(); + searched_ = true; + } + searching_ = search_future_.valid(); + if (begin(ImVec2(900, 650))) { + const ImGuiStyle &style = ImGui::GetStyle(); + const float group_w = (ImGui::GetContentRegionAvail().x - style.ItemSpacing.x) / 2; + const float group_h = ImGui::GetTextLineHeightWithSpacing() + ImGui::GetFrameHeightWithSpacing() * 4 + + style.WindowPadding.y * 2 - style.ItemSpacing.y; + ImGui::BeginChild("Messages", ImVec2(group_w, group_h), ImGuiChildFlags_Borders); + drawMessageGroup(); + ImGui::EndChild(); + ImGui::SameLine(); + ImGui::BeginChild("Signal", ImVec2(group_w, group_h), ImGuiChildFlags_Borders); + drawPropertiesGroup(); + ImGui::EndChild(); + float footer = searched_ ? ImGui::GetTextLineHeightWithSpacing() : 0; + ImGui::BeginChild("Find signal", ImVec2(0, -footer), ImGuiChildFlags_Borders); + drawFindGroup(); + ImGui::EndChild(); + if (searched_) { + ImGui::Text("%zu matches. Right-click an item to create a signal. Double-click to open the message.", + search_.filtered_signals.size()); + } + } + return end(); +} + +void FindSignalDlg::drawMessageGroup() { + ImGui::BeginDisabled(searching_ || !search_.histories.empty()); + const float field_x = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Address").x + ImGui::GetStyle().ItemSpacing.x; + ImGui::TextUnformatted("Messages"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Bus"); + ImGui::SameLine(field_x); + ImGui::SetNextItemWidth(-1); + inputText("##bus", &bus_, "Comma-separated values. Leave blank for all."); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Address"); + ImGui::SameLine(field_x); + ImGui::SetNextItemWidth(-1); + inputText("##address", &address_, "Comma-separated hex values. Leave blank for all."); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Time"); + ImGui::SameLine(field_x); + ImGui::SetNextItemWidth(70); + validatedText("##first_time", &first_time_, validateDouble); + ImGui::SameLine(); + ImGui::TextUnformatted("-"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(70); + validatedText("##last_time", &last_time_, validateDouble); + ImGui::SameLine(); + ImGui::TextUnformatted("seconds"); + ImGui::EndDisabled(); +} + +void FindSignalDlg::drawPropertiesGroup() { + ImGui::BeginDisabled(searching_ || !search_.histories.empty()); + const float field_x = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Factor").x + ImGui::GetStyle().ItemSpacing.x; + ImGui::TextUnformatted("Signal"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Size"); + ImGui::SameLine(field_x); + ImGui::SetNextItemWidth(inputIntWidth(2)); + if (inputInt("##min_size", &min_size_, 1, 10)) min_size_ = std::clamp(min_size_, 1, 64); + ImGui::SameLine(); + ImGui::TextUnformatted("-"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(inputIntWidth(2)); + if (inputInt("##max_size", &max_size_, 1, 10)) max_size_ = std::clamp(max_size_, 1, 64); + ImGui::SetCursorPosX(field_x); + checkBox("Little Endian", &little_endian_); + ImGui::SameLine(); + checkBox("Signed", &is_signed_); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Factor"); + ImGui::SameLine(field_x); + ImGui::SetNextItemWidth(100); + validatedText("##factor", &factor_, validateDouble); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Offset"); + ImGui::SameLine(field_x); + ImGui::SetNextItemWidth(100); + validatedText("##offset", &offset_, validateDouble); + ImGui::EndDisabled(); +} + +void FindSignalDlg::drawFindGroup() { + static const char *compare_items[] = {"=", ">", ">=", "!=", "<", "<=", "between"}; + const int compare_count = IM_ARRAYSIZE(compare_items); + ImGui::TextUnformatted("Find Signal"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Value"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90); + dropdown::Combo("##compare", &compare_, compare_items, compare_count); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); + validatedText("##value1", &value1_, validateDouble); + if (compare_ == compare_count - 1) { + ImGui::SameLine(); + ImGui::TextUnformatted("-"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + validatedText("##value2", &value2_, validateDouble); + } + ImGui::SameLine(); + const bool first = !searching_ && search_.histories.empty(); + ImGui::BeginDisabled(searching_ || search_.histories.size() <= 1); + if (ImGui::Button("Undo Previous Find")) { + search_.undo(); + searched_ = true; + } + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(searching_ || (search_.filtered_signals.empty() && !first)); + if (ImGui::Button(searching_ ? "Finding..." : (first ? "Find" : "Find Next"))) search(); + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(searching_ || first); + if (ImGui::Button("Reset")) { + search_.reset(); + searched_ = true; + } + ImGui::EndDisabled(); + + if (searching_) { + ImGui::BeginChild("view", ImVec2(0, 0), ImGuiChildFlags_Borders); + ImGui::EndChild(); + } else { + drawTable(); + } +} + +void FindSignalDlg::drawTable() { + static const char *titles[] = {"ID", "Start Bit, Size", "(Time, Value)"}; + const int columns = IM_ARRAYSIZE(titles); + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_NoSavedSettings; + if (!ImGui::BeginTable("view", columns + 1, flags, ImVec2(0, 0))) return; + ImGui::TableSetupScrollFreeze(0, 1); + const int rows = std::min(search_.filtered_signals.size(), MAX_ROWS); + // vertical header: row number, no width while there are no results + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed | (rows ? 0 : ImGuiTableColumnFlags_Disabled), 40.0f); + for (int c = 0; c < columns; ++c) { + auto column_flags = c == columns - 1 ? ImGuiTableColumnFlags_WidthStretch : ImGuiTableColumnFlags_WidthFixed; + ImGui::TableSetupColumn(titles[c], column_flags, c == 0 ? 80.0f : 120.0f); + } + tableHeadersRow(); + for (int row = 0; row < rows; ++row) { + const auto &s = search_.filtered_signals[row]; + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(row); + if (ImGui::Selectable(std::to_string(row + 1).c_str(), false, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) openMessage(s.id); + } + drawContextMenu(row); + ImGui::PopID(); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(s.id.toString().c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::Text("%d, %d", s.sig.start_bit, s.sig.size); + ImGui::TableSetColumnIndex(3); + std::string values; + for (size_t i = 0; i < s.values.size(); ++i) { + if (i) values += " "; + values += s.values[i]; + } + ImGui::TextUnformatted(values.c_str()); + } + ImGui::EndTable(); +} + +void FindSignalDlg::search() { + if (search_.histories.empty()) { + setInitialSignals(); + } + auto v1 = utils::toDouble(value1_); + auto v2 = utils::toDouble(value2_); + std::function cmp = nullptr; + switch (compare_) { + case 0: cmp = [v1](double v) { return v == v1;}; break; + case 1: cmp = [v1](double v) { return v > v1;}; break; + case 2: cmp = [v1](double v) { return v >= v1;}; break; + case 3: cmp = [v1](double v) { return v != v1;}; break; + case 4: cmp = [v1](double v) { return v < v1;}; break; + case 5: cmp = [v1](double v) { return v <= v1;}; break; + case 6: cmp = [v1, v2](double v) { return v >= v1 && v <= v2;}; break; + } + searched_ = false; + // a thread of its own: the search fans out over the pool, which a pool thread must not wait on + search_future_ = std::async(std::launch::async, [this, cmp = std::move(cmp)]() { search_.search(cmp); }); + searching_ = true; +} + +void FindSignalDlg::setInitialSignals() { + std::set buses; + for (auto bus : utils::split(utils::trimmed(bus_), ',')) { + bus = utils::trimmed(bus); + if (!bus.empty()) buses.insert((unsigned short)utils::toULong(bus)); + } + + std::set addresses; + for (auto addr : utils::split(utils::trimmed(address_), ',')) { + addr = utils::trimmed(addr); + if (!addr.empty()) addresses.insert(utils::toULong(addr, 16)); + } + + cabana::Signal sig{}; + sig.is_little_endian = little_endian_; + sig.is_signed = is_signed_; + sig.factor = utils::toDouble(factor_); + sig.offset = utils::toDouble(offset_); + + double first_time_val = utils::toDouble(first_time_); + double last_time_val = utils::toDouble(last_time_); + auto [first_sec, last_sec] = std::minmax(first_time_val, last_time_val); + uint64_t first_time = can->toMonoTime(first_sec); + search_.last_time = std::numeric_limits::max(); + if (last_sec > 0) { + search_.last_time = can->toMonoTime(last_sec); + } + search_.initial_signals.clear(); + + for (const auto &[id, m] : can->lastMessages()) { + if ((buses.empty() || buses.count(id.source)) && (addresses.empty() || addresses.count(id.address))) { + const auto &events = can->events(id); + auto e = std::lower_bound(events.cbegin(), events.cend(), first_time, CompareCanEvent()); + if (e != events.cend()) { + const int total_size = m.dat.size() * 8; + for (int size = min_size_; size <= max_size_; ++size) { + for (int start = 0; start <= total_size - size; ++start) { + SignalSearch::SearchSignal s{.id = id, .mono_time = first_time, .sig = sig}; + s.sig.start_bit = start; + s.sig.size = size; + updateMsbLsb(s.sig); + s.value = get_raw_value((*e)->dat, (*e)->size, s.sig); + search_.initial_signals.push_back(s); + } + } + } + } + } +} + +void FindSignalDlg::drawContextMenu(int row) { + if (dropdown::BeginPopupContextItem("menu")) { + if (dropdown::Item("Create Signal")) { + auto &s = search_.filtered_signals[row]; + UndoStack::instance()->push(new AddSigCommand(s.id, s.sig)); + openMessage(s.id); + } + dropdown::EndPopup(); + } +} diff --git a/openpilot/tools/cabana/ui/tools/findsignal.h b/openpilot/tools/cabana/ui/tools/findsignal.h new file mode 100644 index 000000000..1db9bc47e --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/findsignal.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/tools/tooldialog.h" + +struct SignalSearch { + struct SearchSignal { + MessageId id = {}; + uint64_t mono_time = 0; + cabana::Signal sig = {}; + double value = 0.; + std::vector values; + }; + + void search(const std::function &cmp); + void reset(); + void undo(); + + std::vector filtered_signals; + std::vector initial_signals; + std::vector> histories; + uint64_t last_time = std::numeric_limits::max(); +}; + +class FindSignalDlg : public ToolDialog { +public: + FindSignalDlg(); + ~FindSignalDlg() override; + bool draw() override; + + Observable openMessage; + +private: + void search(); + void setInitialSignals(); + void drawContextMenu(int row); + void drawMessageGroup(); + void drawPropertiesGroup(); + void drawFindGroup(); + void drawTable(); + + std::string value1_, value2_, factor_ = "1.0", offset_ = "0.0"; + std::string bus_, address_, first_time_ = "0", last_time_ = "MAX"; + int compare_ = 0; + int min_size_ = 8, max_size_ = 8; + bool little_endian_ = true, is_signed_ = false; + bool searched_ = false; // a search/undo/reset ran, so the stats line is shown + SignalSearch search_; + std::future search_future_; // search_ is off limits while it is valid + bool searching_ = false; +}; diff --git a/openpilot/tools/cabana/ui/tools/findsimilarbits.cc b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc new file mode 100644 index 000000000..032308a9a --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/findsimilarbits.cc @@ -0,0 +1,180 @@ +#include "tools/cabana/ui/tools/findsimilarbits.h" + +#include +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/ui/util.h" + +FindSimilarBitsDlg::FindSimilarBitsDlg() { + setTitle("Find Similar Bits"); + + for (int bus : can->sources) { + bus_items_.push_back(bus); + } + updateMessages(); +} + +void FindSimilarBitsDlg::updateMessages() { + msg_items_.clear(); + msg_names_.clear(); + for (auto &[address, msg] : dbc()->getMessages(busAt(src_bus_))) { + msg_items_.push_back({msg.name, address}); + } + std::sort(msg_items_.begin(), msg_items_.end(), [](auto &l, auto &r) { return l.first < r.first; }); + for (auto &[name, _] : msg_items_) msg_names_.push_back(name); + msg_index_ = 0; +} + +bool FindSimilarBitsDlg::draw() { + if (begin(ImVec2(700, 500))) { + const float field_x = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Find From:").x + ImGui::GetStyle().ItemSpacing.x; + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Find From:"); + ImGui::SameLine(field_x); + ImGui::TextUnformatted("Bus"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60); + if (comboBox("##src_bus", &src_bus_, bus_items_.data(), (int)bus_items_.size())) updateMessages(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(200); + comboBox("##msg", &msg_index_, msg_names_); + ImGui::SetCursorPosX(field_x); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Byte Index"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(inputIntWidth(2)); + if (inputInt("##byte_idx", &byte_idx_, 1, 10)) byte_idx_ = std::clamp(byte_idx_, 0, 63); + ImGui::SameLine(); + ImGui::TextUnformatted("Bit Index"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(inputIntWidth(1)); + if (inputInt("##bit_idx", &bit_idx_, 1, 10)) bit_idx_ = std::clamp(bit_idx_, 0, 7); + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Find In:"); + ImGui::SameLine(field_x); + ImGui::TextUnformatted("Bus"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60); + comboBox("##find_bus", &find_bus_, bus_items_.data(), (int)bus_items_.size()); + ImGui::SameLine(); + ImGui::TextUnformatted("Equal"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60); + dropdown::Combo("##equal", &equal_, "Yes\0No\0"); + ImGui::SameLine(); + ImGui::TextUnformatted("Minimum Message Count"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(inputIntWidth(4)); + if (inputInt("##min_msgs", &min_msgs_, 1, 10)) min_msgs_ = std::max(min_msgs_, 0); + ImGui::SameLine(); + if (ImGui::Button("Find")) find(); + + drawTable(); + } + return end(); +} + +void FindSimilarBitsDlg::drawTable() { + // columns are set by find(); until then the table is an empty frame + if (!table_has_columns_) { + ImGui::BeginChild("table", ImVec2(0, 0), ImGuiChildFlags_Borders); + ImGui::EndChild(); + return; + } + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_NoSavedSettings; + if (!ImGui::BeginTable("table", 7, flags, ImVec2(0, 0))) return; + ImGui::TableSetupScrollFreeze(0, 1); + static const char *headers[] = {"Address", "Byte Index", "Bit Index", "Mismatches", "Messages", "Mismatched (%)"}; + // the fixed widths are section sizes: imgui adds the cell padding on top of the column width + const float padding = ImGui::GetStyle().CellPadding.x * 2; + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 40.0f - padding); // vertical header: row number + for (int c = 0; c < 6; ++c) { + ImGui::TableSetupColumn(headers[c], c == 5 ? ImGuiTableColumnFlags_WidthStretch : ImGuiTableColumnFlags_WidthFixed, + 100.0f - padding); + } + tableHeadersRow(); + ImGuiListClipper clipper; + clipper.Begin((int)table_.size()); + while (clipper.Step()) { + for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) { + auto &m = table_[i]; + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(i); + if (ImGui::Selectable(std::to_string(i + 1).c_str(), false, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + openMessage(MessageId{.source = busAt(find_bus_), .address = m.address}); + } + } + ImGui::PopID(); + ImGui::TableSetColumnIndex(1); + ImGui::Text("%x", m.address); + ImGui::TableSetColumnIndex(2); + ImGui::Text("%u", m.byte_idx); + ImGui::TableSetColumnIndex(3); + ImGui::Text("%u", m.bit_idx); + ImGui::TableSetColumnIndex(4); + ImGui::Text("%u", m.mismatches); + ImGui::TableSetColumnIndex(5); + ImGui::Text("%u", m.total); + ImGui::TableSetColumnIndex(6); + ImGui::Text("%.2f", m.perc); + } + } + ImGui::EndTable(); +} + +void FindSimilarBitsDlg::find() { + const uint32_t selected_address = msg_index_ < (int)msg_items_.size() ? msg_items_[msg_index_].second : 0; + table_ = calcBits(busAt(src_bus_), selected_address, byte_idx_, bit_idx_, busAt(find_bus_), equal_ == 0, min_msgs_); + table_has_columns_ = true; +} + +std::vector FindSimilarBitsDlg::calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, + int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt) { + std::unordered_map> mismatches; + std::unordered_map msg_count; + const auto &events = can->allEvents(); + int bit_to_find = -1; + for (const CanEvent *e : events) { + if (e->src == bus) { + if (e->address == selected_address && e->size > byte_idx) { + bit_to_find = ((e->dat[byte_idx] >> (7 - bit_idx)) & 1) != 0; + } + } + if (e->src == find_bus) { + ++msg_count[e->address]; + if (bit_to_find == -1) continue; + + auto &mismatched = mismatches[e->address]; + if (mismatched.size() < e->size * 8) { + mismatched.resize(e->size * 8); + } + for (int i = 0; i < e->size; ++i) { + for (int j = 0; j < 8; ++j) { + int bit = ((e->dat[i] >> (7 - j)) & 1) != 0; + mismatched[i * 8 + j] += equal ? (bit != bit_to_find) : (bit == bit_to_find); + } + } + } + } + + std::vector result; + result.reserve(mismatches.size()); + for (auto it = mismatches.begin(); it != mismatches.end(); ++it) { + if (auto cnt = msg_count[it->first]; cnt > (uint32_t)min_msgs_cnt) { + auto &mismatched = it->second; + for (int i = 0; i < (int)mismatched.size(); ++i) { + if (float perc = (mismatched[i] / (double)cnt) * 100; perc < 50) { + result.push_back({it->first, (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc}); + } + } + } + } + std::sort(result.begin(), result.end(), [](auto &l, auto &r) { return l.perc < r.perc; }); + return result; +} diff --git a/openpilot/tools/cabana/ui/tools/findsimilarbits.h b/openpilot/tools/cabana/ui/tools/findsimilarbits.h new file mode 100644 index 000000000..c60bf1562 --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/findsimilarbits.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/ui/tools/tooldialog.h" + +class FindSimilarBitsDlg : public ToolDialog { +public: + FindSimilarBitsDlg(); + bool draw() override; + + Observable openMessage; + +private: + struct Mismatch { + uint32_t address, byte_idx, bit_idx, mismatches, total; + float perc; + }; + std::vector calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, int bit_idx, uint8_t find_bus, + bool equal, int min_msgs_cnt); + uint8_t busAt(int index) const { return index < (int)bus_items_.size() ? bus_items_[index] : 0; } + void updateMessages(); + void find(); + void drawTable(); + + std::vector table_; // rows, replaced by find() + bool table_has_columns_ = false; + std::vector bus_items_; // the source and the find bus combos + int src_bus_ = 0, find_bus_ = 0; + std::vector> msg_items_; // (name, address) of the source bus + std::vector msg_names_; + int msg_index_ = 0; + int equal_ = 0; + int byte_idx_ = 0, bit_idx_ = 0; + int min_msgs_ = 100; +}; diff --git a/openpilot/tools/cabana/ui/tools/routeinfo.cc b/openpilot/tools/cabana/ui/tools/routeinfo.cc new file mode 100644 index 000000000..92b77cf2a --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/routeinfo.cc @@ -0,0 +1,52 @@ +#include "tools/cabana/ui/tools/routeinfo.h" + +#include +#include + +#include "imgui.h" +#include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/ui/util.h" + +RouteInfoDlg::RouteInfoDlg() { + replay_ = dynamic_cast(can)->getReplay(); + setTitle("Route: " + replay_->route().name()); +} + +bool RouteInfoDlg::draw() { + static const char *headers[] = {"", "rlog", "narrow road", "wide road", "driver", "qlog", "qcam"}; + auto yn = [](const std::string &s) { return s.empty() ? "--" : "Yes"; }; + const auto &segments = replay_->route().segments(); + // minimum size: fingerprint, header, and min(rowCount, 13) rows + float row_h = ImGui::GetTextLineHeightWithSpacing(); + float min_h = row_h * (std::min((int)segments.size(), 13) + 2) + ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().WindowPadding.y * 2; + if (begin(ImVec2(520, min_h))) { + const std::string fingerprint = replay_->carFingerprint(); + ImGui::TextWrapped("Fingerprint: %s", fingerprint.empty() ? "Unknown" : fingerprint.c_str()); + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingFixedFit; + if (ImGui::BeginTable("table", 7, flags, ImVec2(0, 0))) { + ImGui::TableSetupScrollFreeze(0, 1); + for (int c = 0; c < 7; ++c) ImGui::TableSetupColumn(headers[c]); + tableHeadersRow(); + int row = 0; + for (const auto &[seg_num, seg] : segments) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(row); + if (ImGui::Selectable(std::to_string(seg_num).c_str(), false, ImGuiSelectableFlags_SpanAllColumns)) { + can->seekTo(row * 60.0); + } + ImGui::SetItemTooltip("Click on a row to seek to the corresponding segment."); + ImGui::PopID(); + const char *cells[] = {yn(seg.rlog), yn(seg.narrow_road_cam), yn(seg.wide_road_cam), + yn(seg.cabin_cam), yn(seg.qlog), yn(seg.qcamera)}; + for (int c = 1; c < 7; ++c) { + ImGui::TableSetColumnIndex(c); + ImGui::TextUnformatted(cells[c - 1]); + } + ++row; + } + ImGui::EndTable(); + } + } + return end(); +} diff --git a/openpilot/tools/cabana/ui/tools/routeinfo.h b/openpilot/tools/cabana/ui/tools/routeinfo.h new file mode 100644 index 000000000..e3d4e11b3 --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/routeinfo.h @@ -0,0 +1,14 @@ +#pragma once + +#include "tools/cabana/ui/tools/tooldialog.h" + +class Replay; + +class RouteInfoDlg : public ToolDialog { +public: + RouteInfoDlg(); + bool draw() override; + +private: + Replay *replay_ = nullptr; // destroyed with the stream, which destroys this dialog first +}; diff --git a/openpilot/tools/cabana/ui/tools/tooldialog.h b/openpilot/tools/cabana/ui/tools/tooldialog.h new file mode 100644 index 000000000..4a8aeab7b --- /dev/null +++ b/openpilot/tools/cabana/ui/tools/tooldialog.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "tools/cabana/core/observable.h" +#include "tools/cabana/ui/util.h" + +// non-modal dialogs drawn by MainWindow every frame until closed +class ToolDialog { +public: + virtual ~ToolDialog() = default; + virtual bool draw() = 0; // false once the dialog was closed + + Connections connections_; // dies with the dialog + +protected: + void setTitle(const std::string &name) { + char buf[32]; + snprintf(buf, sizeof(buf), "###tooldialog%p", (void *)this); + title_ = name + buf; + } + + // draw() body: `if (begin(size)) { content } return end();` + bool begin(const ImVec2 &size) { + if (!open_) return false; + ImGui::SetNextWindowSize(size, ImGuiCond_Appearing); + setNextWindowFloatsOut(); + began_ = true; + return visible_ = ImGui::Begin(title_.c_str(), &open_, ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoCollapse); + } + + bool end() { + if (!began_) return false; + // Escape closes the dialog like QDialog, but not while a popup is open above it + if (visible_ && ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && + ImGui::IsKeyPressed(ImGuiKey_Escape, false) && + !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel)) { + open_ = false; + } + ImGui::End(); + began_ = false; + return open_; + } + + std::string title_; + bool open_ = true; + +private: + bool began_ = false, visible_ = false; +}; diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc new file mode 100644 index 000000000..b11ecf28f --- /dev/null +++ b/openpilot/tools/cabana/ui/util.cc @@ -0,0 +1,674 @@ +#include "tools/cabana/ui/util.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#ifdef __APPLE__ +#include +#include +extern "C" { +struct objc_object; +struct objc_selector; +objc_object *glfwGetCocoaWindow(GLFWwindow *window); +objc_selector *sel_registerName(const char *name); +void objc_msgSend(void); +} +#endif + +#include "tools/cabana/ui/icons.h" + +namespace { +ImU32 u32(const ImVec4 &c) { return ImGui::ColorConvertFloat4ToU32(c); } +} // namespace + +int inputCallback(ImGuiInputTextCallbackData *data) { + auto *ctx = static_cast(data->UserData); + if (data->EventFlag == ImGuiInputTextFlags_CallbackCharFilter) { + return ctx->validator ? ctx->validator(data) : 0; + } + if (data->EventFlag == ImGuiInputTextFlags_CallbackEdit) { + if (ctx->validate(std::string(data->Buf, data->BufTextLen)) == ValidState::Invalid) { + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, ctx->last_valid->c_str()); + } + return 0; + } + if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) { + ctx->str->resize(data->BufTextLen); + data->Buf = ctx->str->data(); + } + return 0; +} + +bool validatedInput(const char *label, std::string *s, ImGuiInputTextCallback validator, const char *hint, + ImGuiInputTextFlags flags) { + InputContext ctx{s, validator}; + flags |= ImGuiInputTextFlags_CallbackResize; + if (validator) flags |= ImGuiInputTextFlags_CallbackCharFilter; + return ImGui::InputTextWithHint(label, hint, s->data(), s->capacity() + 1, flags, inputCallback, &ctx); +} + +bool inputTextMultiline(const char *label, std::string *s, const ImVec2 &size, ImGuiInputTextFlags flags) { + InputContext ctx{s, nullptr}; + return ImGui::InputTextMultiline(label, s->data(), s->capacity() + 1, size, flags | ImGuiInputTextFlags_CallbackResize, + inputCallback, &ctx); +} + +bool beginControlChild(const char *id, const ImVec2 &size, ImGuiWindowFlags flags) { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(CONTROL_OUTLINE_PADDING, CONTROL_OUTLINE_PADDING)); + const bool visible = ImGui::BeginChild(id, size, ImGuiChildFlags_AlwaysUseWindowPadding, flags); + ImGui::PopStyleVar(); + return visible; +} + +bool clearableInput(const char *label, std::string *s, const char *hint, ImGuiInputTextCallback validator) { + const float width = ImGui::CalcItemWidth(); + const float clear_width = iconButtonWidth() + ImGui::GetStyle().ItemSpacing.x; + const bool show_clear = !s->empty() && width >= clear_width + ImGui::GetFrameHeight(); + ImGui::SetNextItemWidth(show_clear ? width - clear_width : width); + ImGui::BeginGroup(); + bool changed = validatedInput(label, s, validator, hint); + if (show_clear) { + ImGui::SameLine(); + ImGui::PushID(label); + if (iconButton("clear", icon::X_LG)) { + s->clear(); + changed = true; + } + ImGui::PopID(); + } + ImGui::EndGroup(); + return changed; +} + +bool selectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size) { + if (selected) { + ImGui::PushStyleColor(ImGuiCol_Text, palette().text_selected); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, palette().header); + } + const bool clicked = ImGui::Selectable(label, selected, flags, size); + if (selected) ImGui::PopStyleColor(2); + return clicked; +} + +bool comboBox(const char *label, int *index, const std::vector &items) { + bool changed = false; + const int count = (int)items.size(); + if (dropdown::BeginCombo(label, *index >= 0 && *index < count ? items[*index].c_str() : "")) { + for (int i = 0; i < count; ++i) { + ImGui::PushID(i); + if (dropdown::Item(items[i].c_str(), nullptr, i == *index) && *index != i) { + *index = i; + changed = true; + } + if (i == *index) ImGui::SetItemDefaultFocus(); + ImGui::PopID(); + } + dropdown::EndCombo(); + } + return changed; +} + +bool validatedText(const char *label, std::string *s, ValidState (*validate)(const std::string &), + const char *hint, ImGuiInputTextCallback filter) { + const std::string last_valid = *s; // a refused edit never reaches *s + InputContext ctx{s, filter, validate, &last_valid}; + ImGuiInputTextFlags flags = ImGuiInputTextFlags_CallbackResize | ImGuiInputTextFlags_CallbackEdit; + if (filter) flags |= ImGuiInputTextFlags_CallbackCharFilter; + ImGui::InputTextWithHint(label, hint, s->data(), s->capacity() + 1, flags, inputCallback, &ctx); + return *s != last_valid; +} + +int nameValidator(ImGuiInputTextCallbackData *data) { + // [A-Za-z0-9_], spaces rewritten to '_' + if (data->EventChar == ' ') { + data->EventChar = '_'; + return 0; + } + return (data->EventChar < 128 && (std::isalnum((int)data->EventChar) || data->EventChar == '_')) ? 0 : 1; +} + +int nodeValidator(ImGuiInputTextCallbackData *data) { + // \w+(,\w+)* + return (data->EventChar < 128 && (std::isalnum((int)data->EventChar) || data->EventChar == '_' || data->EventChar == ',')) ? 0 : 1; +} + +int doubleValidator(ImGuiInputTextCallbackData *data) { + // C-locale floating-point + const ImWchar c = data->EventChar; + return (c < 128 && (std::isdigit((int)c) || c == '+' || c == '-' || c == '.' || c == 'e' || c == 'E')) ? 0 : 1; +} + +int ipValidator(ImGuiInputTextCallbackData *data) { + // [0-9.] + const ImWchar c = data->EventChar; + return ((c >= '0' && c <= '9') || c == '.') ? 0 : 1; +} + +int nonWhitespaceValidator(ImGuiInputTextCallbackData *data) { + // \S+ + return (data->EventChar < 128 && std::isspace((int)data->EventChar)) ? 1 : 0; +} + +float iconButtonWidth() { return ImGui::GetFrameHeight(); } + +namespace { +constexpr float ICON_BUTTON_GLYPH_SCALE = 0.8f; + +// Exclude rasterization padding when centering icons; it varies between glyphs. +struct GlyphInk { float x0, y0, x1, y1; }; +GlyphInk glyphInk(const ImFontGlyph *g) { + ImTextureData *tex = ImGui::GetIO().Fonts->TexData; + const int px0 = (int)std::lround(g->U0 * tex->Width), px1 = (int)std::lround(g->U1 * tex->Width); + const int py0 = (int)std::lround(g->V0 * tex->Height), py1 = (int)std::lround(g->V1 * tex->Height); + int ix0 = px1, iy0 = py1, ix1 = px0, iy1 = py0; + for (int y = py0; y < py1; ++y) { + for (int x = px0; x < px1; ++x) { + const unsigned char *p = (const unsigned char *)tex->GetPixelsAt(x, y); + const unsigned char alpha = tex->Format == ImTextureFormat_Alpha8 ? p[0] : p[3]; + if (alpha < 64) continue; + ix0 = std::min(ix0, x); ix1 = std::max(ix1, x + 1); + iy0 = std::min(iy0, y); iy1 = std::max(iy1, y + 1); + } + } + if (ix0 >= ix1 || py1 <= py0 || px1 <= px0) return {g->X0, g->Y0, g->X1, g->Y1}; + const float sx = (g->X1 - g->X0) / (px1 - px0), sy = (g->Y1 - g->Y0) / (py1 - py0); + return {g->X0 + (ix0 - px0) * sx, g->Y0 + (iy0 - py0) * sy, g->X0 + (ix1 - px0) * sx, g->Y0 + (iy1 - py0) * sy}; +} + +// The scan reads the atlas pixels: do it once per icon, and again when the atlas repacked the glyph. +const GlyphInk &cachedGlyphInk(const ImFontGlyph *g, float size, unsigned int codepoint) { + struct Entry { ImVec4 uv; GlyphInk ink; }; + static std::unordered_map cache; + const uint64_t key = ((uint64_t)(uint32_t)size << 32) | codepoint; + const ImVec4 uv(g->U0, g->V0, g->U1, g->V1); + auto it = cache.find(key); + if (it == cache.end() || memcmp(&it->second.uv, &uv, sizeof(uv)) != 0) it = cache.insert_or_assign(key, Entry{uv, glyphInk(g)}).first; + return it->second.ink; +} + +bool squareIconButton(const char *id, const char *icon) { + const bool clicked = ImGui::Button((std::string("###") + id).c_str(), ImVec2(iconButtonWidth(), 0.0f)); + const ImRect r(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + unsigned int codepoint = 0; + ImTextCharFromUtf8(&codepoint, icon, nullptr); + // Leave a margin even for icons that fill the glyph bounds. + const float size = std::round(ImGui::GetFontSize() * ICON_BUTTON_GLYPH_SCALE); + ImFontBaked *baked = ImGui::GetFont()->GetFontBaked(size); + if (const ImFontGlyph *g = baked->FindGlyph((ImWchar)codepoint)) { + const GlyphInk ink = cachedGlyphInk(g, size, codepoint); + // Keep the exact ink center, including half pixels when glyph and button sizes + // have different parity. Rounding the origin shifts small icons off center. + const ImVec2 pos(r.GetCenter().x - (ink.x0 + ink.x1) * 0.5f, r.GetCenter().y - (ink.y0 + ink.y1) * 0.5f); + // AddText truncates the origin, so draw the atlas glyph directly. + ImGui::GetWindowDrawList()->AddImage(ImGui::GetIO().Fonts->TexRef, ImVec2(pos.x + g->X0, pos.y + g->Y0), + ImVec2(pos.x + g->X1, pos.y + g->Y1), ImVec2(g->U0, g->V0), ImVec2(g->U1, g->V1), + ImGui::GetColorU32(ImGuiCol_Text)); + } + return clicked; +} +} // namespace + +bool iconButton(const char *id, const char *icon, const char *tooltip) { + const bool clicked = squareIconButton(id, icon); + if (tooltip && *tooltip) ImGui::SetItemTooltip("%s", tooltip); + return clicked; +} + +float iconTextButtonWidth(const char *icon, const std::string &text, const IconTextButtonOptions &options) { + const ImGuiStyle &style = ImGui::GetStyle(); + const float gap = *icon ? (options.icon_gap >= 0.0f ? options.icon_gap : style.ItemInnerSpacing.x) : 0.0f; + return ImGui::CalcTextSize(icon).x + gap + std::max(ImGui::CalcTextSize(text.c_str(), nullptr, true).x, options.label_width) + + style.FramePadding.x * 2; +} + +bool iconTextButton(const char *id, const char *icon, const std::string &text, float width, const IconTextButtonOptions &options) { + const ImGuiStyle &style = ImGui::GetStyle(); + const float icon_width = ImGui::CalcTextSize(icon).x; + const float gap = *icon ? (options.icon_gap >= 0.0f ? options.icon_gap : style.ItemInnerSpacing.x) : 0.0f; + const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x; + const float label_width = std::max(text_width, options.label_width); + if (width <= 0.0f) width = icon_width + gap + label_width + style.FramePadding.x * 2; + if (options.rounding >= 0.0f) ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, options.rounding); + const bool clicked = ImGui::Button((std::string("###") + id).c_str(), ImVec2(width, options.height)); + if (options.rounding >= 0.0f) ImGui::PopStyleVar(); + const ImVec2 min = ImGui::GetItemRectMin(), max = ImGui::GetItemRectMax(); + const float top = min.y + (max.y - min.y - ImGui::GetFontSize()) * 0.5f; + const float left = min.x + (options.center_content ? std::max(style.FramePadding.x, (max.x - min.x - icon_width - gap - label_width) * 0.5f) + : style.FramePadding.x); + const float text_left = left + icon_width + gap; + const float slack = options.center_content ? 0.0f : std::max(0.0f, (max.x - style.FramePadding.x - text_left - text_width) * 0.5f); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); + auto *draw_list = ImGui::GetWindowDrawList(); + draw_list->AddText(ImVec2(left, top), color, icon); + draw_list->AddText(ImVec2(text_left + slack, top), color, text.c_str()); + return clicked; +} + +void disabledItemTooltip(const char *text) { + if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip | ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", text); +} + +bool PopupOwner::begin(const char *id) { + ImGuiWindow *window = ImGui::GetCurrentWindowRead(); // GetCurrentWindow() would mark the fallback window as used + if (popup_id == 0) { + // a pending popup may only be opened from the call nested in the top-most modal, or from any call + // when there is no modal at all + ImGuiWindow *modal = ImGui::GetTopMostPopupModal(); + if (modal != nullptr && modal != window) return false; + ImGui::OpenPopup(id); + popup_id = window->GetID(id); + owner_id = window->ID; + } else if (owner_id != window->ID) { + return false; + } else if (!ImGui::IsPopupOpen(popup_id, ImGuiPopupFlags_AnyPopupLevel)) { + // reopen if imgui closed the popup underneath us (host window change) + ImGui::OpenPopup(id); + } + return true; +} + +ImGuiWindow *topPopupWindow() { + ImGuiContext &g = *GImGui; + return g.OpenPopupStack.Size > 0 ? g.OpenPopupStack.back().Window : nullptr; +} + +bool dialogEscapePressed() { + return ImGui::IsKeyPressed(ImGuiKey_Escape, false) && topPopupWindow() == ImGui::GetCurrentWindow(); +} + +bool dialogButtons(const char *accept_label, bool *accepted, bool *rejected, bool accept_enabled, + const char *reject_label) { + const float button_width = std::max({spacing::DIALOG_BUTTON_MIN_WIDTH, toolbarButtonWidth(accept_label), + reject_label ? toolbarButtonWidth(reject_label) : 0.0f}); + const int count = reject_label ? 2 : 1; + const float total = button_width * count + ImGui::GetStyle().ItemSpacing.x * (count - 1); + const float avail = ImGui::GetContentRegionAvail().x; + if (avail > total) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + avail - total); + bool pressed = false; + if (reject_label) { + if (ImGui::Button(reject_label, ImVec2(button_width, 0.0f))) { + if (rejected) *rejected = true; + pressed = true; + } + ImGui::SameLine(); + } + ImGui::BeginDisabled(!accept_enabled); + if (ImGui::Button(accept_label, ImVec2(button_width, 0.0f))) { + if (accepted) *accepted = true; + pressed = true; + } + ImGui::EndDisabled(); + if (rejected && dialogEscapePressed()) { + *rejected = true; + pressed = true; + } + return pressed; +} + +float inputIntWidth(int digits) { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::CalcTextSize(std::string(digits, '0').c_str()).x + style.FramePadding.x * 2 + + (ImGui::GetFrameHeight() + style.ItemSpacing.x) * 2; +} + +bool stepButton(const char *id, bool increment, const char *tooltip) { + return iconButton(id, increment ? icon::PLUS_LG : icon::DASH_LG, tooltip); +} + +bool inputInt(const char *label, int *value, int step, int step_fast, ImGuiInputTextFlags flags) { + if (step <= 0) return ImGui::InputInt(label, value, 0, 0, flags); + + const float spacing = ImGui::GetStyle().ItemSpacing.x; + const float width = ImGui::CalcItemWidth(); + ImGui::BeginGroup(); + ImGui::PushID(label); + ImGui::SetNextItemWidth(std::max(1.0f, width - 2 * (iconButtonWidth() + spacing))); + bool changed = ImGui::InputInt("##value", value, 0, 0, flags); + ImGui::BeginDisabled(flags & ImGuiInputTextFlags_ReadOnly); + ImGui::PushItemFlag(ImGuiItemFlags_ButtonRepeat, true); + for (bool increment : {false, true}) { + ImGui::SameLine(0.0f, spacing); + if (stepButton(increment ? "increment" : "decrement", increment)) { + const int amount = ImGui::GetIO().KeyCtrl && step_fast > 0 ? step_fast : step; + const int next = std::clamp(int64_t(*value) + (increment ? int64_t(amount) : -int64_t(amount)), + std::numeric_limits::min(), std::numeric_limits::max()); + if (next != *value) { + *value = next; + changed = true; + } + } + } + ImGui::PopItemFlag(); + ImGui::EndDisabled(); + const char *label_end = ImGui::FindRenderedTextEnd(label); + if (label != label_end) { + ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x); + ImGui::TextUnformatted(label, label_end); + } + ImGui::PopID(); + ImGui::EndGroup(); + if (changed) ImGui::MarkItemEdited(ImGui::GetItemID()); + return changed; +} + +int tableHeadersRow() { + int clicked = -1; + ImGui::TableNextRow(ImGuiTableRowFlags_Headers); + for (int c = 0, count = ImGui::TableGetColumnCount(); c < count; ++c) { + if (!ImGui::TableSetColumnIndex(c)) continue; + const char *name = ImGui::TableGetColumnName(c); + if (!name) name = ""; + const float offset = (ImGui::GetContentRegionAvail().x - ImGui::CalcTextSize(name).x) * 0.5f; + if (offset > 0) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + offset); + ImGui::PushID(c); + ImGui::TableHeader(name); + // same timing as TableHeader's own TableOpenContextMenu, so a menu opened by the caller is opened last + // and replaces the (disabled) table context menu in the popup stack + if (ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Right)) clicked = c; + ImGui::PopID(); + } + return clicked; +} + +bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size) { + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, selected ? ImGui::GetColorU32(ImGuiCol_Header) : IM_COL32(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImGui::GetColorU32(ImGuiCol_Header)); + const bool clicked = selectable(label, selected, flags, size); + ImGui::PopStyleColor(2); + return clicked; +} + +bool checkBox(const char *label, bool *v) { + const float box = CHECKBOX_SIZE; + ImGuiWindow *window = ImGui::GetCurrentWindow(); + if (window->SkipItems) return false; + const ImGuiStyle &style = ImGui::GetStyle(); + const ImGuiID id = window->GetID(label); + const ImVec2 label_size = ImGui::CalcTextSize(label, nullptr, true); + const float frame_h = ImGui::GetFrameHeight(); + const ImVec2 pos = window->DC.CursorPos; + const ImRect total_bb(pos, ImVec2(pos.x + box + (label_size.x > 0.0f ? style.ItemInnerSpacing.x + label_size.x : 0.0f), pos.y + frame_h)); + ImGui::ItemSize(total_bb, style.FramePadding.y); + if (!ImGui::ItemAdd(total_bb, id)) return false; + bool hovered, held; + const bool pressed = ImGui::ButtonBehavior(total_bb, id, &hovered, &held); + if (pressed) { + *v = !*v; + ImGui::MarkItemEdited(id); + } + const float y = pos.y + IM_TRUNC((frame_h - box) * 0.5f); + const ImRect check_bb(ImVec2(pos.x, y), ImVec2(pos.x + box, y + box)); + ImGui::RenderNavCursor(total_bb, id); + const ImU32 bg = ImGui::GetColorU32((held && hovered) ? ImGuiCol_FrameBgActive : hovered ? ImGuiCol_FrameBgHovered : ImGuiCol_FrameBg); + ImGui::RenderFrame(check_bb.Min, check_bb.Max, bg, true, style.FrameRounding); + if (*v) { + const float pad = ImMax(1.0f, IM_TRUNC(box / 6.0f)); + ImGui::RenderCheckMark(window->DrawList, ImVec2(check_bb.Min.x + pad, check_bb.Min.y + pad), ImGui::GetColorU32(ImGuiCol_CheckMark), box - pad * 2.0f); + } + if (label_size.x > 0.0f) ImGui::RenderText(ImVec2(check_bb.Max.x + style.ItemInnerSpacing.x, pos.y + style.FramePadding.y), label); + return pressed; +} + +void alignRight(float width) { + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, ImGui::GetContentRegionAvail().x - width)); +} + +void drawText(ImDrawList *dl, const ImRect &rect, const char *text, ImU32 col, ImFont *font, float font_size, const ImVec2 &align) { + if (font == nullptr) font = ImGui::GetFont(); + if (font_size <= 0.0f) font_size = ImGui::GetFontSize(); + const ImVec2 size = font->CalcTextSizeA(font_size, FLT_MAX, 0.0f, text); + const ImVec2 pos(rect.Min.x + (rect.GetWidth() - size.x) * align.x, rect.Min.y + (rect.GetHeight() - size.y) * align.y); + dl->AddText(font, font_size, pos, col, text); +} + +void drawElidedText(ImDrawList *dl, const ImRect &rect, const std::string &text, ImU32 col, bool align_right) { + const ImVec2 size = ImGui::CalcTextSize(text.c_str()); + const float y = rect.Min.y + std::max(0.0f, (rect.GetHeight() - size.y) * 0.5f); + if (size.x <= rect.GetWidth()) { + dl->AddText(ImVec2(align_right ? rect.Max.x - size.x : rect.Min.x, y), col, text.c_str()); + } else { + ImGui::PushStyleColor(ImGuiCol_Text, col); + ImGui::RenderTextEllipsis(dl, ImVec2(rect.Min.x, y), ImVec2(rect.Max.x, y + size.y), rect.Max.x, text.c_str(), nullptr, &size); + ImGui::PopStyleColor(); + } +} + +float markerSize() { return ImGui::GetTextLineHeight() - 4; } + +void drawColorMarker(ImDrawList *dl, const ImVec2 &pos, ImU32 col) { + const float size = markerSize(); + dl->AddRectFilled(ImVec2(pos.x, pos.y + 2), ImVec2(pos.x + size, pos.y + 2 + size), col); +} + +#ifdef __APPLE__ +void setMacAppName(const char *name) { + auto info = (CFMutableDictionaryRef)CFBundleGetInfoDictionary(CFBundleGetMainBundle()); + if (info == nullptr) return; + CFStringRef value = CFStringCreateWithCString(kCFAllocatorDefault, name, kCFStringEncodingUTF8); + CFDictionarySetValue(info, CFSTR("CFBundleName"), value); + CFRelease(value); +} + +bool isNativeFullScreen(GLFWwindow *window) { + constexpr unsigned long NS_WINDOW_STYLE_MASK_FULL_SCREEN = 1ul << 14; + objc_object *ns_window = glfwGetCocoaWindow(window); + if (ns_window == nullptr) return false; + auto styleMask = (unsigned long (*)(objc_object *, objc_selector *))objc_msgSend; + return (styleMask(ns_window, sel_registerName("styleMask")) & NS_WINDOW_STYLE_MASK_FULL_SCREEN) != 0; +} + +void toggleNativeFullScreen(GLFWwindow *window) { + auto toggle = (void (*)(objc_object *, objc_selector *, objc_object *))objc_msgSend; + toggle(glfwGetCocoaWindow(window), sel_registerName("toggleFullScreen:"), nullptr); +} +#endif + +void setNextWindowFloatsOut() { + ImGuiWindowClass window_class; + window_class.ViewportFlagsOverrideSet = ImGuiViewportFlags_NoAutoMerge; + ImGui::SetNextWindowClass(&window_class); +} + +void setNextDialogWindow(const ImVec2 &size) { + if (size.x > 0.0f || size.y > 0.0f) ImGui::SetNextWindowSize(size, ImGuiCond_Appearing); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + setNextWindowFloatsOut(); +} + +bool beginDialog(const char *id, PopupOwner *owner, const ImVec2 &size, ImGuiWindowFlags flags) { + if (!owner->begin(id)) return false; + setNextDialogWindow(size); + return ImGui::BeginPopupModal(id, nullptr, flags | ImGuiWindowFlags_NoSavedSettings); +} + +// tool bar + +ToolbarItem toolbarAction(const char *id, const char *icon, const char *label, std::function trigger, bool enabled) { + return {iconButtonWidth(), [=]() { + ImGui::BeginDisabled(!enabled); + if (iconButton(id, icon)) trigger(); + ImGui::EndDisabled(); + disabledItemTooltip(label); + }, label, trigger, enabled, true}; +} + +ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *label, std::function items, bool bold, float width) { + if (width <= 0.0f) width = menuButtonWidth(text, bold); + ToolbarItem item{width, [id, text, items, bold, width]() { + const std::string popup_id = std::string(id) + "_menu"; + menuButton(id, text, popup_id.c_str(), bold, width); + if (dropdown::BeginPopup(popup_id.c_str())) { + items(); + dropdown::EndPopup(); + } + }, label}; + item.submenu = std::move(items); + return item; +} + +float toolbarButtonWidth(const std::string &label) { + return ImGui::CalcTextSize(label.c_str(), nullptr, true).x + ImGui::GetStyle().FramePadding.x * 2; +} + +static float toolbarGroupWidth(const std::vector &items, size_t begin, size_t end) { + float w = 0; + for (size_t i = begin; i < end; ++i) w += items[i].width + (i > begin ? ImGui::GetStyle().ItemSpacing.x : 0); + return w; +} + +float toolbarWidth(const std::vector &items, size_t spacer_index) { + spacer_index = std::min(spacer_index, items.size()); + float w = toolbarGroupWidth(items, 0, spacer_index) + toolbarGroupWidth(items, spacer_index, items.size()); + if (spacer_index > 0 && spacer_index < items.size()) w += ImGui::GetStyle().ItemSpacing.x; + return w; +} + +void drawToolbar(const std::vector &items, size_t spacer_index, float width) { + const ImGuiStyle &style = ImGui::GetStyle(); + spacer_index = std::min(spacer_index, items.size()); + const float right_width = toolbarGroupWidth(items, spacer_index, items.size()); + const float start_x = ImGui::GetCursorPosX(); + const float avail = width < 0.0f ? ImGui::GetContentRegionAvail().x : width; + const float right_edge = start_x + avail; + const float extension_width = iconButtonWidth(); + + // when everything fits the spacer takes the slack, otherwise the extension button is reserved at the + // right edge and the items are packed from the left until the next one does not fit + // a caller may size a flexible item from the same available width: allow for the float error of the round trip + const bool fits = toolbarWidth(items, spacer_index) <= avail + 0.5f; + size_t visible = items.size(); + if (!fits) { + const float usable = avail - (extension_width + style.ItemSpacing.x); + float used = 0; + for (visible = 0; visible < items.size(); ++visible) { + const float w = items[visible].width + (visible ? style.ItemSpacing.x : 0); + if (used + w > usable) break; + used += w; + } + } + + for (size_t i = 0; i < visible; ++i) { + if (i == 0) ImGui::SetCursorPosX(start_x); + else if (fits && i == spacer_index) ImGui::SameLine(right_edge - right_width); + else ImGui::SameLine(0.0f, ImGui::GetStyle().ItemSpacing.x); + items[i].draw(); + } + + if (visible < items.size()) { + // the extension button sits fully inside the toolbar: its right edge is the content region right edge + const float extension_x = std::max(start_x, right_edge - extension_width); + visible == 0 ? ImGui::SetCursorPosX(extension_x) : ImGui::SameLine(extension_x); + const bool extension_open = ImGui::IsPopupOpen("toolbar_extension_menu"); + const bool extension_clicked = iconButton("toolbar_extension", icon::CHEVRON_DOUBLE_RIGHT, "More"); + if (!extension_open && (extension_clicked || + (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByPopup) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)))) { + ImGui::OpenPopup("toolbar_extension_menu"); + } + dropdown::PositionBelowItem("toolbar_extension_menu", true); + if (dropdown::BeginPopup("toolbar_extension_menu")) { + for (size_t i = visible; i < items.size(); ++i) { + if (!items[i].in_menu) continue; + if (items[i].menu_label.empty()) { + items[i].draw(); + } else if (items[i].submenu) { + if (dropdown::BeginMenu(items[i].menu_label.c_str(), items[i].enabled)) { + items[i].submenu(); + dropdown::EndMenu(); + } + } else if (dropdown::Item(items[i].menu_label.c_str(), nullptr, false, items[i].enabled)) { + items[i].trigger(); + } + } + dropdown::EndPopup(); + } + } +} + +const float MENU_ARROW_SIZE = 6.0f; // dropdown arrow on a menu button + +float menuButtonWidth(const std::string &text, bool bold) { + if (bold) pushBoldFont(); + const float w = ImGui::CalcTextSize(text.c_str(), nullptr, true).x + ImGui::GetStyle().ItemInnerSpacing.x + MENU_ARROW_SIZE + + ImGui::GetStyle().FramePadding.x * 2; + if (bold) popBoldFont(); + return w; +} + +bool menuButton(const char *id, const std::string &text, const char *popup_id, bool bold, float width) { + const ImGuiStyle &style = ImGui::GetStyle(); + const bool popup_open = ImGui::IsPopupOpen(popup_id); + if (width <= 0.0f) width = menuButtonWidth(text, bold); + // ImGui closes popups at frame end on outside clicks. Only open a closed popup so a second press toggles it off. + if (bold) pushBoldFont(); + const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x; + const float ascent = ImGui::GetFontBaked()->Ascent; + // the text and the arrow are centered as a group in the button + const float padding_x = std::max(style.FramePadding.x, (width - (text_width + ImGui::GetStyle().ItemInnerSpacing.x + MENU_ARROW_SIZE)) * 0.5f); + ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : style.Colors[ImGuiCol_Button]); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(padding_x, style.FramePadding.y)); + ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.0f, 0.5f)); + const bool pressed = ImGui::ButtonEx((text + "###" + id).c_str(), ImVec2(width, 0.0f), ImGuiButtonFlags_PressedOnClick); + const bool clicked = pressed || (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByPopup) && + ImGui::IsMouseClicked(ImGuiMouseButton_Left)); + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(); + if (bold) popBoldFont(); + // a 6 px arrow right after the text, sitting on the text baseline + const ImVec2 min = ImGui::GetItemRectMin(); + const float x = min.x + padding_x + text_width + ImGui::GetStyle().ItemInnerSpacing.x; + const float baseline = min.y + style.FramePadding.y + ascent; + ImGui::GetWindowDrawList()->AddTriangleFilled(ImVec2(x, baseline - MENU_ARROW_SIZE * 0.5f), + ImVec2(x + MENU_ARROW_SIZE, baseline - MENU_ARROW_SIZE * 0.5f), + ImVec2(x + MENU_ARROW_SIZE * 0.5f, baseline), + ImGui::GetColorU32(ImGuiCol_TextDisabled)); + if (clicked && !popup_open) ImGui::OpenPopup(popup_id); + dropdown::PositionBelowItem(popup_id); + return clicked; +} + +void drawSliderHandle(ImDrawList *p, const ImRect &r) { + const Palette &pal = palette(); + p->AddRectFilled(r.Min, r.Max, u32(pal.button_hovered), 2.0f); + p->AddRectFilled(ImVec2(r.Min.x, r.GetCenter().y), r.Max, u32(pal.button), 2.0f, ImDrawFlags_RoundCornersBottom); + p->AddRect(r.Min, r.Max, u32(pal.border), 2.0f, 0, 1.0f); +} + +bool fusionSliderInt(const char *label, int *v, int min, int max, float width) { + // Keep ImGui slider input handling, but replace its frame and grab with custom drawing. + ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_SliderGrab, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_SliderGrabActive, IM_COL32_BLACK_TRANS); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::SetNextItemWidth(width); + bool changed = ImGui::SliderInt(label, v, min, max, "", ImGuiSliderFlags_NoInput); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(5); + + const ImVec2 bb_min = ImGui::GetItemRectMin(), bb_max = ImGui::GetItemRectMax(); + const float cy = (bb_min.y + bb_max.y) * 0.5f; + const float groove_h = SLIDER_THICKNESS * 0.5f; + const float handle_h = std::min(SLIDER_THICKNESS, bb_max.y - bb_min.y); + const float x0 = bb_min.x + SLIDER_LENGTH * 0.5f, x1 = bb_max.x - SLIDER_LENGTH * 0.5f; + const float t = max > min ? (float)(*v - min) / (float)(max - min) : 0.0f; + const float hx = x0 + (x1 - x0) * t; + ImDrawList *dl = ImGui::GetWindowDrawList(); + const float groove_y0 = cy - groove_h * 0.5f, groove_y1 = cy + groove_h * 0.5f; + dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(bb_max.x, groove_y1), u32(palette().slider_track), groove_h * 0.5f); + dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(hx, groove_y1), u32(palette().accent), groove_h * 0.5f); + drawSliderHandle(dl, ImRect(ImVec2(hx - SLIDER_LENGTH * 0.5f, cy - handle_h * 0.5f), + ImVec2(hx + SLIDER_LENGTH * 0.5f, cy + handle_h * 0.5f))); + return changed; +} diff --git a/openpilot/tools/cabana/ui/util.h b/openpilot/tools/cabana/ui/util.h new file mode 100644 index 000000000..d8f11700a --- /dev/null +++ b/openpilot/tools/cabana/ui/util.h @@ -0,0 +1,204 @@ +#pragma once + +#include +#include +#include + +#include "tools/cabana/ui/dropdown.h" +#include "tools/cabana/utils/util.h" + +struct GLFWwindow; + +// the dock window identity of the messages panel (the visible title changes, the part after ### is the id) +constexpr const char *MESSAGES_PANEL_ID = "###MessagesPanel"; + +struct InputContext { + std::string *str; + ImGuiInputTextCallback validator; + ValidState (*validate)(const std::string &) = nullptr; + const std::string *last_valid = nullptr; +}; + +int inputCallback(ImGuiInputTextCallbackData *data); + +// text input with an optional validator; `s` grows through the resize callback +bool validatedInput(const char *label, std::string *s, ImGuiInputTextCallback validator, const char *hint = "", + ImGuiInputTextFlags flags = 0); + +inline bool inputText(const char *label, std::string *s, const char *hint = "", ImGuiInputTextFlags flags = 0) { + return validatedInput(label, s, nullptr, hint, flags); +} + +bool inputTextMultiline(const char *label, std::string *s, const ImVec2 &size, ImGuiInputTextFlags flags = 0); + +constexpr float CONTROL_OUTLINE_PADDING = 1.0f; +// Always pair with ImGui::EndChild(), even when false is returned. +bool beginControlChild(const char *id, const ImVec2 &size, ImGuiWindowFlags flags = 0); + +// SetNextItemWidth includes the field and clear button. Returns true when text changes. +bool clearableInput(const char *label, std::string *s, const char *hint = "", ImGuiInputTextCallback validator = nullptr); + +bool selectable(const char *label, bool selected, ImGuiSelectableFlags flags = 0, const ImVec2 &size = ImVec2(0, 0)); + +bool comboBox(const char *label, int *index, const std::vector &items); + +// numeric items (bus ids, bus speeds) are formatted as they are drawn +template +inline bool comboBox(const char *label, int *index, const T *values, int count) { + bool changed = false; + const std::string preview = *index >= 0 && *index < count ? std::to_string(values[*index]) : ""; + if (dropdown::BeginCombo(label, preview.c_str())) { + for (int i = 0; i < count; ++i) { + ImGui::PushID(i); + if (dropdown::Item(std::to_string(values[i]).c_str(), nullptr, i == *index) && *index != i) { + *index = i; + changed = true; + } + if (i == *index) ImGui::SetItemDefaultFocus(); + ImGui::PopID(); + } + dropdown::EndCombo(); + } + return changed; +} + +// an edit that makes the text Invalid is refused inside the imgui buffer, like a validated line edit +bool validatedText(const char *label, std::string *s, ValidState (*validate)(const std::string &), + const char *hint = "", ImGuiInputTextCallback filter = nullptr); + +// InputText char filters; the std::string validators in utils/util.h are run again when the edit is committed +int nameValidator(ImGuiInputTextCallbackData *data); +int nodeValidator(ImGuiInputTextCallbackData *data); +int doubleValidator(ImGuiInputTextCallbackData *data); +int ipValidator(ImGuiInputTextCallbackData *data); +int nonWhitespaceValidator(ImGuiInputTextCallbackData *data); + +#ifdef __APPLE__ +constexpr const char *MOD_KEY = "Cmd"; +#else +constexpr const char *MOD_KEY = "Ctrl"; +#endif +inline std::string shortcut(const char *keys) { return std::string(MOD_KEY) + "+" + keys; } + +// Use ItemInnerSpacing between related buttons and ItemSpacing between groups. +bool iconButton(const char *id, const char *icon, const char *tooltip = nullptr); +float iconButtonWidth(); +bool stepButton(const char *id, bool increment, const char *tooltip = nullptr); +struct IconTextButtonOptions { + float height = 0.0f; + float rounding = -1.0f; // Negative uses the theme default. + float icon_gap = -1.0f; // Negative uses the theme default. + bool center_content = false; + float label_width = 0.0f; // Shared width aligns labels in a group of centered buttons. +}; +bool iconTextButton(const char *id, const char *icon, const std::string &text, float width = 0.0f, + const IconTextButtonOptions &options = {}); +float iconTextButtonWidth(const char *icon, const std::string &text, const IconTextButtonOptions &options = {}); + +// tooltip for the last item that also shows while the item is disabled +void disabledItemTooltip(const char *text); + +// A queued modal popup submitted from whichever call site is nested in the top-most modal. draw() is called +// both nested in a modal dialog and at the root level; only the level that opened the popup may submit it +// (opening at level 0 would make imgui close the parent modal). +struct PopupOwner { + ImGuiID popup_id = 0, owner_id = 0; + + // false: this call site must skip the popup this frame + bool begin(const char *id); + + void reset() { popup_id = owner_id = 0; } +}; + +// Escape closes a dialog only when nothing is open above it: a combo drops its list first. +bool dialogEscapePressed(); + +// the window of the top-most open popup, nullptr when none is open +ImGuiWindow *topPopupWindow(); + +// [Cancel] [Accept], right aligned. reject_label = nullptr for an accept-only box. Escape rejects. +bool dialogButtons(const char *accept_label, bool *accepted, bool *rejected, bool accept_enabled = true, + const char *reject_label = "Cancel"); + +// Numeric inputs keep the same external gaps as other button rows. Width includes +// a readable value plus both step buttons; use for compact fixed-width fields. +float inputIntWidth(int digits); +bool inputInt(const char *label, int *value, int step = 1, int step_fast = 100, + ImGuiInputTextFlags flags = ImGuiInputTextFlags_None); + +// horizontal header labels are centered. Returns the column a right click was released on, or -1. +int tableHeadersRow(); + +// no hover highlight, only the selection background. Selectable() prefers HeaderHovered over Header +// whenever the row is hovered, even when it is selected, so a selected row has to keep the selection color +// as its hover color or it looks unselected. +bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size); + +// a 16px box vertically centered in the frame height so rows keep their layout; ImGui::Checkbox draws a +// frame height (22 px) square. +bool checkBox(const char *label, bool *v); +constexpr float CHECKBOX_SIZE = 16.0f; + +// the next items on the line are right aligned as a block `width` wide +void alignRight(float width); + +// text drawn inside a rect; align 0 = left/top, 0.5 = center, 1 = right/bottom. font_size 0: the current size +void drawText(ImDrawList *dl, const ImRect &rect, const char *text, ImU32 col, ImFont *font = nullptr, + float font_size = 0.0f, const ImVec2 &align = ImVec2(0.5f, 0.5f)); +// elided to the rect width, vertically centered +void drawElidedText(ImDrawList *dl, const ImRect &rect, const std::string &text, ImU32 col, bool align_right = false); +// the colored square in front of a signal name: a text line minus 4 px, drawn 2 px below `pos` +float markerSize(); +void drawColorMarker(ImDrawList *dl, const ImVec2 &pos, ImU32 col); + +// the next window is a real OS window instead of being drawn inside the main one +void setNextWindowFloatsOut(); +#ifdef __APPLE__ +// the app menu takes its name from the main bundle, and a bare binary gets an info dictionary with its +// file name in it. That dictionary is mutable, so the name is set before glfw brings up cocoa +void setMacAppName(const char *name); +// the native Cocoa full screen toggle (glfw's monitor switch is not full screen on macOS) +bool isNativeFullScreen(GLFWwindow *window); +void toggleNativeFullScreen(GLFWwindow *window); +#endif + +// centered, floating out, sized on first appearance +void setNextDialogWindow(const ImVec2 &size); +// centered modal dialog. false when the popup is not submitted this frame. +bool beginDialog(const char *id, PopupOwner *owner, const ImVec2 &size, ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize); + +const float SLIDER_LENGTH = 13.0f; +const float SLIDER_THICKNESS = 13.0f; + +// a tool bar item: `draw` submits it. Items that do not fit go into a ">>" menu, where an item with a +// menu_label becomes a MenuItem that runs `trigger`, and an item without one draws itself. +struct ToolbarItem { + float width; + std::function draw; + std::string menu_label; + std::function trigger; + bool enabled = true; + bool in_menu = true; // false: left out of the ">>" menu (a separator) + std::function submenu; // set: the ">>" entry is a submenu with these items instead of an action +}; +ToolbarItem toolbarAction(const char *id, const char *icon, const char *label, std::function trigger, + bool enabled = true); +// A drop-down button that opens `items` in a popup; in the overflow menu they become a submenu. +// width 0: sized to the text. +ToolbarItem toolbarMenu(const char *id, const std::string &text, const char *label, std::function items, + bool bold = false, float width = 0.0f); +float toolbarButtonWidth(const std::string &label); +// the width of every item plus the spacing between neighbors and the two groups +float toolbarWidth(const std::vector &items, size_t spacer_index); +// items before spacer_index sit at the left, the rest are right aligned; the overflow goes into the ">>" menu +// width < 0 uses the available content width. +void drawToolbar(const std::vector &items, size_t spacer_index, float width = -1.0f); + +// Opens `popup_id` below the button on press. width 0: +// sized to the text, otherwise the text and the arrow are centered in the button +float menuButtonWidth(const std::string &text, bool bold = false); +bool menuButton(const char *id, const std::string &text, const char *popup_id, bool bold = false, float width = 0.0f); + +void drawSliderHandle(ImDrawList *p, const ImRect &r); + +bool fusionSliderInt(const char *label, int *v, int min, int max, float width); diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc new file mode 100644 index 000000000..84f5a29fd --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -0,0 +1,550 @@ +#include "tools/cabana/ui/widgets/binaryview.h" + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +namespace { + +const int CELL_HEIGHT = 36; +const float SMALL_FONT_SIZE = 10.0f; // Inter needs 10 px for a 7 px cap height +const int VERTICAL_HEADER_WIDTH = 30; +inline int get_bit_pos(const BinaryIndex &index) { return flipBitPos(index.row * 8 + index.column); } + +inline ImU32 paletteHighlight() { return ImGui::GetColorU32(ImGuiCol_Header); } +inline ImU32 paletteBase() { return ImGui::GetColorU32(ImGuiCol_ChildBg); } +inline ImU32 paletteText(bool active) { return ImGui::GetColorU32(active ? ImGuiCol_Text : ImGuiCol_TextDisabled); } +const ImU32 DARK_GRAY = IM_COL32(128, 128, 128, 255); + +// JetBrains Mono ships no bold variant, so emulate one by drawing the glyphs again a fraction of a +// pixel to the right. Keeps the monospace advance, unlike switching to the proportional bold face. +void drawBoldText(ImDrawList *p, const ImRect &r, const char *text, ImU32 col, ImFont *font, float font_size) { + drawText(p, r, text, col, font, font_size); + drawText(p, ImRect(ImVec2(r.Min.x + 0.6f, r.Min.y), ImVec2(r.Max.x + 0.6f, r.Max.y)), text, col, font, font_size); +} + +// sparse dots +void fillDense7Pattern(ImDrawList *p, const ImRect &r, ImU32 col) { + p->PushClipRect(r.Min, r.Max, true); + for (float y = r.Min.y; y < r.Max.y; y += 4.0f) { + for (float x = r.Min.x + (static_cast((y - r.Min.y) / 4.0f) % 2) * 2.0f; x < r.Max.x; x += 4.0f) { + p->AddRectFilled(ImVec2(x, y), ImVec2(x + 1.0f, y + 1.0f), col); + } + } + p->PopClipRect(); +} + +// backward diagonal lines +void fillBDiagPattern(ImDrawList *p, const ImRect &r, ImU32 col) { + p->PushClipRect(r.Min, r.Max, true); + const float h = r.GetHeight(); + for (float x = r.Min.x - h; x < r.Max.x; x += 8.0f) { + p->AddLine(ImVec2(x, r.Max.y), ImVec2(x + h, r.Min.y), col, 1.0f); + } + p->PopClipRect(); +} + +} // namespace + +BinaryView::BinaryView() { + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); +} + +std::string BinaryView::whatsThis() const { + return R"( + Binary View
+ Shortcuts
+ Delete Signal: +  x , +  Backspace , +  Delete 
+ Change endianness:  e 
+ Change signedness:  s 
+ Open chart: +  c , +  p , +  g  + )"; +} + +void BinaryView::addShortcuts() { + const ImGuiIO &io = ImGui::GetIO(); + if (io.WantTextInput || io.KeyCtrl || io.KeySuper) return; + if (ImGui::GetTopMostPopupModal() != nullptr) return; // a modal dialog blocks the shortcuts + + if (ImGui::IsKeyPressed(ImGuiKey_X, false) || ImGui::IsKeyPressed(ImGuiKey_Backspace, false) || ImGui::IsKeyPressed(ImGuiKey_Delete, false)) { + if (hovered_sig_ != nullptr) { + UndoStack::instance()->push(new RemoveSigCommand(msg_id_, hovered_sig_)); + hovered_sig_ = nullptr; + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_E, false)) { + if (hovered_sig_ != nullptr) { + cabana::Signal s = *hovered_sig_; + s.is_little_endian = !s.is_little_endian; + editSignal(hovered_sig_, s); + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_S, false)) { + if (hovered_sig_ != nullptr) { + cabana::Signal s = *hovered_sig_; + s.is_signed = !s.is_signed; + editSignal(hovered_sig_, s); + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_P, false) || ImGui::IsKeyPressed(ImGuiKey_G, false) || ImGui::IsKeyPressed(ImGuiKey_C, false)) { + if (hovered_sig_ != nullptr) { + showChart(msg_id_, hovered_sig_, true, false); + } + } +} + +ImVec2 BinaryView::minimumSizeHint() const { + // widest fixed-font glyph plus the header margins + pushMonoFont(); + const float min_section_size = ImGui::CalcTextSize("W").x + 8.0f; + popMonoFont(); + return {(min_section_size + 1) * 9 + VERTICAL_HEADER_WIDTH + 2, + static_cast(CELL_HEIGHT * std::min(row_count_, 10) + 2)}; +} + +void BinaryView::highlight(const cabana::Signal *sig) { + if (sig != hovered_sig_) { + hovered_sig_ = sig; + signalHovered(hovered_sig_); + } +} + +void BinaryView::setSelection() { + auto index = indexAt(last_mouse_pos_); + if (!anchor_index_.isValid() || !index.isValid()) + return; + + std::set selection; + auto [start, size, is_lb] = getSelection(index); + for (int i = 0; i < size; ++i) { + int pos = is_lb ? flipBitPos(start + i) : flipBitPos(start) + i; + selection.insert({pos / 8, pos % 8}); + } + selection_ = std::move(selection); +} + +void BinaryView::handleMousePress(const ImVec2 &pos) { + resize_sig_ = nullptr; + if (auto index = indexAt(last_mouse_pos_ = pos); index.isValid() && index.column != HEX_COLUMN) { + anchor_index_ = index; + auto item = &cellAt(anchor_index_); + int bit_pos = get_bit_pos(anchor_index_); + for (auto s : item->sigs) { + if (bit_pos == s->lsb || bit_pos == s->msb) { + int idx = flipBitPos(bit_pos == s->lsb ? s->msb : s->lsb); + anchor_index_ = {idx / 8, idx % 8}; + resize_sig_ = s; + break; + } + } + } +} + +void BinaryView::highlightPosition(const ImVec2 &pos) { + if (auto index = indexAt(pos); index.isValid()) { + auto item = &cellAt(index); + const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back(); + highlight(sig); + } +} + +void BinaryView::handleMouseMove(const ImVec2 &pos) { + highlightPosition(last_mouse_pos_ = pos); + // drag selecting while the left button is down; the hex column is not selectable + if (ImGui::IsMouseDown(ImGuiMouseButton_Left) && indexAt(pos).column != HEX_COLUMN) setSelection(); +} + +void BinaryView::handleMouseRelease(const ImVec2 &pos) { + auto release_index = indexAt(pos); + if (release_index.isValid() && anchor_index_.isValid()) { + if (hasSelection()) { + auto sig = resize_sig_ ? *resize_sig_ : cabana::Signal{}; + std::tie(sig.start_bit, sig.size, sig.is_little_endian) = getSelection(release_index); + resize_sig_ ? editSignal(resize_sig_, sig) + : UndoStack::instance()->push(new AddSigCommand(msg_id_, sig)); + } else { + auto item = &cellAt(anchor_index_); + if (item->sigs.size() > 0) + signalClicked(item->sigs.back()); + } + } + selection_.clear(); + anchor_index_ = BinaryIndex(); + resize_sig_ = nullptr; +} + +void BinaryView::setMessage(const MessageId &message_id) { + msg_id_ = message_id; + scroll_to_top_ = true; + refresh(); +} + +void BinaryView::refresh() { + selection_.clear(); + anchor_index_ = BinaryIndex(); + resize_sig_ = nullptr; + hovered_sig_ = nullptr; + bit_flip_tracker_ = {}; + cells_.clear(); + if (auto dbc_msg = dbc()->msg(msg_id_)) { + row_count_ = dbc_msg->size; + cells_.resize(row_count_ * COLUMN_COUNT); + for (auto sig : dbc_msg->getSignals()) { + for (int j = 0; j < sig->size; ++j) { + int pos = sig->is_little_endian ? flipBitPos(sig->start_bit + j) : flipBitPos(sig->start_bit) + j; + int idx = COLUMN_COUNT * (pos / 8) + pos % 8; + if (idx >= cells_.size()) { + fprintf(stderr, "signal %s out of bounds.start_bit: %d size: %d\n", sig->name.c_str(), sig->start_bit, sig->size); + break; + } + if (j == 0) sig->is_little_endian ? cells_[idx].is_lsb = true : cells_[idx].is_msb = true; + if (j == sig->size - 1) sig->is_little_endian ? cells_[idx].is_msb = true : cells_[idx].is_lsb = true; + + auto &sigs = cells_[idx].sigs; + sigs.push_back(sig); + if (sigs.size() > 1) { + std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) { return l->size > r->size; }); + } + } + } + } else { + row_count_ = can->lastMessage(msg_id_).dat.size(); + cells_.resize(row_count_ * COLUMN_COUNT); + } + updateState(); + if (under_mouse_) highlightPosition(last_mouse_pos_); +} + + +std::set BinaryView::getOverlappingSignals() const { + std::set overlapping; + for (const auto &item : cells_) { + if (item.sigs.size() > 1) { + for (auto s : item.sigs) { + if (s->type == cabana::Signal::Type::Normal) overlapping.insert(s); + } + } + } + return overlapping; +} + +std::tuple BinaryView::getSelection(BinaryIndex index) { + if (index.column == HEX_COLUMN) { + index = {index.row, 7}; + } + bool is_lb = true; + if (resize_sig_) { + is_lb = resize_sig_->is_little_endian; + } else if (settings.drag_direction == Settings::DragDirection::MsbFirst) { + is_lb = index < anchor_index_; + } else if (settings.drag_direction == Settings::DragDirection::LsbFirst) { + is_lb = !(index < anchor_index_); + } else if (settings.drag_direction == Settings::DragDirection::AlwaysLE) { + is_lb = true; + } else if (settings.drag_direction == Settings::DragDirection::AlwaysBE) { + is_lb = false; + } + + int cur_bit_pos = get_bit_pos(index); + int anchor_bit_pos = get_bit_pos(anchor_index_); + int start_bit = is_lb ? std::min(cur_bit_pos, anchor_bit_pos) : get_bit_pos(std::min(index, anchor_index_)); + int size = is_lb ? std::abs(cur_bit_pos - anchor_bit_pos) + 1 : std::abs(flipBitPos(cur_bit_pos) - flipBitPos(anchor_bit_pos)) + 1; + return {start_bit, size, is_lb}; +} + +BinaryIndex BinaryView::indexAt(const ImVec2 &pos) const { + if (column_width_ <= 0 || pos.x < grid_pos_.x + VERTICAL_HEADER_WIDTH || pos.y < grid_pos_.y) return {}; + int column = static_cast((pos.x - grid_pos_.x - VERTICAL_HEADER_WIDTH) / column_width_); + int row = static_cast((pos.y - grid_pos_.y) / CELL_HEIGHT); + if (column >= COLUMN_COUNT || row >= row_count_) return {}; + return {row, column}; +} + +ImRect BinaryView::visualRect(const BinaryIndex &index) const { + // sections are integral: round the edges so neighboring cells share them exactly and the cells are + // painted edge to edge with no grid line between them + const float x0 = grid_pos_.x + VERTICAL_HEADER_WIDTH + IM_ROUND(index.column * column_width_); + const float x1 = grid_pos_.x + VERTICAL_HEADER_WIDTH + IM_ROUND((index.column + 1) * column_width_); + const float y = grid_pos_.y + index.row * CELL_HEIGHT; + return ImRect(x0, y, x1, y + CELL_HEIGHT); +} + +void BinaryView::draw() { + is_message_active_ = can->isMessageActive(msg_id_); + if (scroll_to_top_) { + ImGui::SetScrollY(0.0f); + scroll_to_top_ = false; + } + + const int rows = row_count_; + // Keep hex bytes readable in narrow panels by scrolling instead of shrinking further. + const float min_column_width = std::ceil(ImGui::CalcTextSize("FF").x) + 10.0f; + const float width = std::max(ImGui::GetContentRegionAvail().x, VERTICAL_HEADER_WIDTH + min_column_width * COLUMN_COUNT); + column_width_ = std::max(min_column_width, (width - VERTICAL_HEADER_WIDTH) / COLUMN_COUNT); + grid_pos_ = ImGui::GetCursorScreenPos(); + ImGui::InvisibleButton("##binary_view", ImVec2(std::max(width, 1.0f), std::max(static_cast(rows * CELL_HEIGHT), 1.0f))); + ImDrawList *painter = ImGui::GetWindowDrawList(); + + for (int row = 0; row < rows; ++row) { + const ImRect r(grid_pos_.x, grid_pos_.y + row * CELL_HEIGHT, grid_pos_.x + VERTICAL_HEADER_WIDTH, grid_pos_.y + (row + 1) * CELL_HEIGHT); + painter->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_WindowBg)); // plain header background + drawText(painter, r, std::to_string(row).c_str(), ImGui::GetColorU32(ImGuiCol_Text)); + } + for (int row = 0; row < rows; ++row) { + for (int column = 0; column < COLUMN_COUNT; ++column) { + const BinaryIndex index = {row, column}; + paintCell(painter, visualRect(index), index); + } + } + + const ImVec2 mouse = ImGui::GetMousePos(); + const bool hovered = ImGui::IsItemHovered(); + const bool active = ImGui::IsItemActive(); + const bool under_mouse = (hovered || active) && ImGui::IsMouseHoveringRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), false); + if (hovered || active) { + if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) handleMousePress(mouse); + const ImVec2 delta = ImGui::GetIO().MouseDelta; + if (delta.x != 0.0f || delta.y != 0.0f) { + handleMouseMove(mouse); + } else { + // imgui only reports a delta on the frames the mouse actually moves, so recompute the hovered + // signal every frame the mouse is inside the widget, or the shortcuts stay inert after a click + highlightPosition(last_mouse_pos_ = mouse); + } + } + // the mouse left the widget rect, also while dragging + if (std::exchange(under_mouse_, under_mouse) && !under_mouse) highlight(nullptr); + if (ImGui::IsItemDeactivated()) handleMouseRelease(mouse); + + if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip)) { + if (auto index = indexAt(mouse); index.isValid() && !cellAt(index).sigs.empty()) { + ImGui::SetTooltip("%s", utils::stripHtml(utils::signalToolTip(cellAt(index).sigs.back())).c_str()); + } + } + + addShortcuts(); +} + +void BinaryView::setCell(int row, int col, uint8_t val, const CabanaColor &color) { + auto &item = cells_[row * COLUMN_COUNT + col]; + item.valid = true; + item.val = val; + item.bg_color = color; +} + +void BinaryView::updateState() { + const auto &last_msg = can->lastMessage(msg_id_); + const auto &binary = last_msg.dat; + if (binary.size() > row_count_) { + row_count_ = binary.size(); + cells_.resize(row_count_ * COLUMN_COUNT); + } + + auto &bit_flips = heatmap_live_mode_ ? last_msg.bit_flip_counts : bitFlipChanges(binary.size()); + uint32_t max_bit_flip_count = 1; // 1 to avoid division by zero + for (const auto &row : bit_flips) { + for (uint32_t count : row) { + max_bit_flip_count = std::max(max_bit_flip_count, count); + } + } + + const Palette &p = palette(); + const double max_alpha = 255.0; + const double min_alpha_with_signal = p.heatmap_signal_alpha; // Base alpha for small flip counts + const double min_alpha_no_signal = p.heatmap_bit_alpha; // Base alpha for small flip counts for no signal bits + const double alpha_gamma = p.heatmap_gamma; + const double log_factor = 1.0 + 0.2; + const double log_scaler = max_alpha / log2(log_factor * max_bit_flip_count); + + for (size_t i = 0; i < binary.size(); ++i) { + for (int j = 0; j < 8; ++j) { + auto &item = cells_[i * COLUMN_COUNT + j]; + int bit_val = (binary[i] >> (7 - j)) & 1; + + double alpha = item.sigs.empty() ? 0 : min_alpha_with_signal; + uint32_t flip_count = bit_flips[i][j]; + if (flip_count > 0) { + double normalized_alpha = log2(1.0 + flip_count * log_factor) * log_scaler; + normalized_alpha = max_alpha * std::pow(std::clamp(normalized_alpha / max_alpha, 0.0, 1.0), alpha_gamma); + double min_alpha = item.sigs.empty() ? min_alpha_no_signal : min_alpha_with_signal; + alpha = std::clamp(normalized_alpha, min_alpha, max_alpha); + } + + auto color = item.bg_color; + color.a = static_cast(alpha); + setCell(i, j, bit_val, color); + } + setCell(i, HEX_COLUMN, binary[i], last_msg.colors[i]); + } +} + +const std::vector> &BinaryView::bitFlipChanges(size_t msg_size) { + auto time_range = can->timeRange(); + if (bit_flip_tracker_.time_range == time_range && !bit_flip_tracker_.flip_counts.empty()) + return bit_flip_tracker_.flip_counts; + + bit_flip_tracker_.time_range = time_range; + bit_flip_tracker_.flip_counts.assign(msg_size, std::array{}); + + auto [first, last] = can->eventsInRange(msg_id_, time_range); + if (std::distance(first, last) <= 1) return bit_flip_tracker_.flip_counts; + + std::vector prev_values((*first)->dat, (*first)->dat + (*first)->size); + for (auto it = std::next(first); it != last; ++it) { + const CanEvent *event = *it; + int size = std::min(msg_size, event->size); + for (int i = 0; i < size; ++i) { + const uint8_t diff = event->dat[i] ^ prev_values[i]; + if (!diff) continue; + + auto &bit_flips = bit_flip_tracker_.flip_counts[i]; + for (int bit = 0; bit < 8; ++bit) { + if (diff & (1u << bit)) ++bit_flips[7 - bit]; + } + prev_values[i] = event->dat[i]; + } + } + + return bit_flip_tracker_.flip_counts; +} + +bool BinaryView::hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const { + if (!index.isValid()) return false; + int idx = (index.row + dy) * COLUMN_COUNT + index.column + dx; + if (idx < 0 || idx >= (int)cells_.size()) return false; + auto &s = cells_[idx].sigs; + return std::find(s.begin(), s.end(), sig) != s.end(); +} + +void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index) const { + auto item = &cellAt(index); + ImFont *font = ImGui::GetFont(); + float font_size = ImGui::GetFontSize(); + ImU32 pen = paletteText(is_message_active_); + + if (index.column == HEX_COLUMN) { + if (item->valid) { + pushMonoFont(); + font = ImGui::GetFont(); + font_size = ImGui::GetFontSize(); + popMonoFont(); + painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); + } + } else if (isSelected(index)) { + painter->AddRectFilled(rect.Min, rect.Max, resize_sig_ ? toImU32(resize_sig_->color) : paletteHighlight()); + if (resize_sig_) pen = IM_COL32_WHITE; + } else if (!hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), resize_sig_) == item->sigs.end()) { // not resizing + if (item->sigs.size() > 0) { + for (auto &s : item->sigs) { + if (s == hovered_sig_) { + painter->AddRectFilled(rect.Min, rect.Max, toImU32(s->color.darker(125))); // 4/5x brightness + } else { + drawSignalCell(painter, rect, index, s); + } + } + } else if (item->valid) { + painter->AddRectFilled(rect.Min, rect.Max, ImGui::GetColorU32(palette().bit_background)); + if (item->bg_color.alpha() > 0) painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); + } + bool bright = std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); + pen = bright ? IM_COL32_WHITE : paletteText(is_message_active_); + } + + if (item->sigs.size() > 1) { + fillDense7Pattern(painter, rect, DARK_GRAY); + } else if (!item->valid) { + fillBDiagPattern(painter, rect, DARK_GRAY); + } + if (item->valid) { + if (index.column == HEX_COLUMN) { + drawBoldText(painter, rect, utils::hexByte(item->val), pen, font, font_size); + } else { + drawText(painter, rect, item->val ? "1" : "0", pen, font, font_size); + } + } + if (item->is_msb || item->is_lsb) { + const ImRect marker_rect(rect.Min, ImVec2(rect.Max.x - 8, rect.Max.y - 3)); + drawText(painter, marker_rect, item->is_msb ? "M" : "L", pen, nullptr, SMALL_FONT_SIZE, ImVec2(1.0f, 1.0f)); + } +} + +// Draw border on edge of signal +void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const { + bool draw_left = !hasSignal(index, -1, 0, sig); + bool draw_top = !hasSignal(index, 0, -1, sig); + bool draw_right = !hasSignal(index, 1, 0, sig); + bool draw_bottom = !hasSignal(index, 0, 1, sig); + + const int spacing = 2; + ImRect rc(rect.Min.x + draw_left * 3, rect.Min.y + draw_top * spacing, rect.Max.x - draw_right * 3, rect.Max.y - draw_bottom * spacing); + std::vector subtract; + if (!draw_top) { + if (!draw_left && !hasSignal(index, -1, -1, sig)) { + subtract.emplace_back(rc.Min.x, rc.Min.y, rc.Min.x + 3, rc.Min.y + spacing); + } else if (!draw_right && !hasSignal(index, 1, -1, sig)) { + subtract.emplace_back(rc.Max.x - 3, rc.Min.y, rc.Max.x, rc.Min.y + spacing); + } + } + if (!draw_bottom) { + if (!draw_left && !hasSignal(index, -1, 1, sig)) { + subtract.emplace_back(rc.Min.x, rc.Max.y - spacing, rc.Min.x + 3, rc.Max.y); + } else if (!draw_right && !hasSignal(index, 1, 1, sig)) { + subtract.emplace_back(rc.Max.x - 3, rc.Max.y - spacing, rc.Max.x, rc.Max.y); + } + } + // rc split into horizontal bands with the notch corners removed: at most one notch in the top band and + // one in the bottom band + const ImRect *top_notch = !subtract.empty() && subtract.front().Min.y == rc.Min.y ? &subtract.front() : nullptr; + const ImRect *bottom_notch = !subtract.empty() && subtract.back().Min.y != rc.Min.y ? &subtract.back() : nullptr; + std::vector region; + auto band = [&](const ImRect *notch, float y0, float y1) { + const float x0 = notch && notch->Min.x == rc.Min.x ? notch->Max.x : rc.Min.x; + const float x1 = notch && notch->Min.x != rc.Min.x ? notch->Min.x : rc.Max.x; + if (x1 > x0 && y1 > y0) region.emplace_back(x0, y0, x1, y1); + }; + if (top_notch) band(top_notch, rc.Min.y, rc.Min.y + spacing); + band(nullptr, rc.Min.y + (top_notch ? spacing : 0), rc.Max.y - (bottom_notch ? spacing : 0)); + if (bottom_notch) band(bottom_notch, rc.Max.y - spacing, rc.Max.y); + + auto item = &cellAt(index); + CabanaColor color = sig->color; + color.a = item->bg_color.alpha(); + const ImU32 edge = toImU32(sig->color.darker(125)); + + for (const ImRect &clip : region) { + painter->PushClipRect(clip.Min, clip.Max, true); + // mix the signal color with the background to fade it + painter->AddRectFilled(rc.Min, rc.Max, paletteBase()); + painter->AddRectFilled(rc.Min, rc.Max, toImU32(color)); + + if (draw_left) painter->AddLine(ImVec2(rc.Min.x + 0.5f, rc.Min.y), ImVec2(rc.Min.x + 0.5f, rc.Max.y), edge, 1.0f); + if (draw_right) painter->AddLine(ImVec2(rc.Max.x - 0.5f, rc.Min.y), ImVec2(rc.Max.x - 0.5f, rc.Max.y), edge, 1.0f); + if (draw_bottom) painter->AddLine(ImVec2(rc.Min.x, rc.Max.y - 0.5f), ImVec2(rc.Max.x, rc.Max.y - 0.5f), edge, 1.0f); + if (draw_top) painter->AddLine(ImVec2(rc.Min.x, rc.Min.y + 0.5f), ImVec2(rc.Max.x, rc.Min.y + 0.5f), edge, 1.0f); + + // fill gaps inside corners: the 2px stroke is clipped to the region, only the half outside the notch is painted + for (auto &r : subtract) { + painter->AddRect(r.Min, r.Max, edge, 0.0f, 0, 2.0f); + } + painter->PopClipRect(); + } +} diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.h b/openpilot/tools/cabana/ui/widgets/binaryview.h new file mode 100644 index 000000000..1ca3c17ba --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/binaryview.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/core/observable.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" + +// a (row, column) of the bit grid: 8 bit columns and the hex column +struct BinaryIndex { + int row = -1; + int column = -1; + bool isValid() const { return row >= 0 && column >= 0; } + bool operator==(const BinaryIndex &o) const { return row == o.row && column == o.column; } + bool operator<(const BinaryIndex &o) const { return std::tie(row, column) < std::tie(o.row, o.column); } +}; + +class BinaryView { +public: + static constexpr int COLUMN_COUNT = 9; + static constexpr int HEX_COLUMN = 8; + + BinaryView(); + void setMessage(const MessageId &message_id); + void highlight(const cabana::Signal *sig); + std::set getOverlappingSignals() const; + void updateState(); + // draws inline into the current (scrollable) window and handles the mouse/keyboard + void draw(); + ImVec2 minimumSizeHint() const; + void setHeatmapLiveMode(bool live) { heatmap_live_mode_ = live; updateState(); } + std::string whatsThis() const; + + Observable signalClicked; + Observable signalHovered; + Observable editSignal; + Observable showChart; + +private: + struct Cell { + CabanaColor bg_color = CabanaColor(102, 86, 169, 255); + bool is_msb = false; + bool is_lsb = false; + uint8_t val; + std::vector sigs; + bool valid = false; + }; + + void refresh(); // rebuilds the grid from the DBC message + void setCell(int row, int col, uint8_t val, const CabanaColor &color); + const std::vector> &bitFlipChanges(size_t msg_size); + Cell &cellAt(const BinaryIndex &index) { return cells_[index.row * COLUMN_COUNT + index.column]; } + const Cell &cellAt(const BinaryIndex &index) const { return cells_[index.row * COLUMN_COUNT + index.column]; } + + void addShortcuts(); // polled every frame from draw() + std::tuple getSelection(BinaryIndex index); + void setSelection(); + void handleMousePress(const ImVec2 &pos); + void handleMouseMove(const ImVec2 &pos); + void handleMouseRelease(const ImVec2 &pos); + void highlightPosition(const ImVec2 &pt); + BinaryIndex indexAt(const ImVec2 &pos) const; + ImRect visualRect(const BinaryIndex &index) const; + bool hasSelection() const { return !selection_.empty(); } + bool isSelected(const BinaryIndex &index) const { return selection_.count(index) > 0; } + + void paintCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index) const; + bool hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const; + void drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const; + + MessageId msg_id_; + std::vector cells_; + int row_count_ = 0; + bool heatmap_live_mode_ = true; + struct BitFlipTracker { + std::optional> time_range; + std::vector> flip_counts; + } bit_flip_tracker_; + + BinaryIndex anchor_index_; + ImVec2 last_mouse_pos_{-1, -1}; + bool is_message_active_ = false; + const cabana::Signal *resize_sig_ = nullptr; + const cabana::Signal *hovered_sig_ = nullptr; + std::set selection_; + ImVec2 grid_pos_; // viewport origin of the current frame + float column_width_ = 0; // stretched section size + bool under_mouse_ = false; + bool scroll_to_top_ = false; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.cc b/openpilot/tools/cabana/ui/widgets/cameraview.cc new file mode 100644 index 000000000..b771717c2 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/cameraview.cc @@ -0,0 +1,189 @@ +#include "tools/cabana/ui/widgets/cameraview.h" + +#include +#include +#include +#include + +#include +#include "imgui_impl_opengl3_loader.h" + +#include "common/yuv.h" +#include "tools/cabana/utils/util.h" +#include "tools/cabana/settings.h" + +namespace { +constexpr GLenum GL_LINEAR_MIPMAP_LINEAR_ = 0x2703; +// glGenerateMipmap is not part of the imgui GL loader +void generateMipmap() { + static auto fn = (void (*)(GLenum))glfwGetProcAddress("glGenerateMipmap"); + if (fn) fn(GL_TEXTURE_2D); +} +} // namespace + +void drawVideoFrame(ImDrawList *draw_list, ImTextureRef texture, const ImRect &rect, const VideoPlacement &placement) { + const ImVec2 size(placement.max.x - placement.min.x, placement.max.y - placement.min.y); + if (size.x <= 0 || size.y <= 0) return; + + // Round the full frame, then clip to the square video bounds so letterboxing + // doesn't introduce a second set of rounded corners around the image. + const ImVec2 uv_scale((placement.uv1.x - placement.uv0.x) / size.x, + (placement.uv1.y - placement.uv0.y) / size.y); + const ImVec2 uv0(placement.uv0.x + (rect.Min.x - placement.min.x) * uv_scale.x, + placement.uv0.y + (rect.Min.y - placement.min.y) * uv_scale.y); + const ImVec2 uv1(placement.uv1.x + (rect.Max.x - placement.max.x) * uv_scale.x, + placement.uv1.y + (rect.Max.y - placement.max.y) * uv_scale.y); + draw_list->PushClipRect(placement.min, placement.max, true); + draw_list->AddImageRounded(texture, rect.Min, rect.Max, uv0, uv1, IM_COL32_WHITE, ImGui::GetStyle().ChildRounding); + draw_list->PopClipRect(); +} + +void GlTexture::upload(const RgbImage &image) { + if (id == 0) { + glGenTextures(1, &id); + glBindTexture(GL_TEXTURE_2D, id); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, mipmap ? GL_LINEAR_MIPMAP_LINEAR_ : GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + } else { + glBindTexture(GL_TEXTURE_2D, id); + } + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + if (width != image.width || height != image.height) { + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, image.width, image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); + width = image.width; + height = image.height; + } else { + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); + } + if (mipmap) generateMipmap(); + glBindTexture(GL_TEXTURE_2D, 0); +} + +void GlTexture::destroy() { + if (id != 0) { + glDeleteTextures(1, &id); + } + id = 0; + width = height = 0; + key = 0; +} + +CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type) + : stream_name_(stream_name), active_stream_type_(type), requested_stream_type_(type) {} + +CameraWidget::~CameraWidget() { + stopVipcThread(); +} + +void CameraWidget::startVipcThread() { + if (!vipc_thread_.joinable()) { + // Preserve the last frame when restoring a collapsed video; paused replay sends no replacement. + vipc_exit_ = false; + vipc_thread_ = std::thread(&CameraWidget::vipcThread, this); + } +} + +void CameraWidget::stopVipcThread() { + vipc_exit_ = true; + if (vipc_thread_.joinable()) { + vipc_thread_.join(); + } +} + +void CameraWidget::setVisible(bool visible) { + if (visible == visible_) return; + visible_ = visible; + visible ? startVipcThread() : stopVipcThread(); +} + +void CameraWidget::draw(const ImVec2 &size) { + setVisible(true); + ImGui::InvisibleButton("##camera", ImVec2(std::max(1.0f, size.x), std::max(1.0f, size.y)), + ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight | ImGuiButtonFlags_MouseButtonMiddle); + rect_ = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + paint(); + if (ImGui::IsItemDeactivated()) clicked(); +} + +float CameraWidget::frameAspectRatio() const { + if (frame_texture_.width > 0 && frame_texture_.height > 0) { + return (float)frame_texture_.width / frame_texture_.height; + } + return DEFAULT_CAMERA_ASPECT_RATIO; // the road camera, until the first frame arrives +} + +void CameraWidget::paint() { + ImDrawList *p = ImGui::GetWindowDrawList(); + p->AddRectFilled(rect_.Min, rect_.Max, bg_, ImGui::GetStyle().ChildRounding); + + std::lock_guard lk(frame_lock_); + if (rgb_frame_.isNull()) return; + if (frame_updated_) { + frame_texture_.upload(rgb_frame_); + frame_updated_ = false; + } + + VideoPlacement placement = videoPlacement(rect_, frameAspectRatio(), settings.crop_video); + if (active_stream_type_ == VISION_STREAM_CABIN) { + // mirror cabin camera horizontally + std::swap(placement.uv0.x, placement.uv1.x); + } + drawVideoFrame(p, frame_texture_.ref(), rect_, placement); +} + +void CameraWidget::vipcThread() { + VisionStreamType cur_stream = requested_stream_type_; + std::unique_ptr vipc_client; + VisionIpcBufExtra frame_meta = {}; + bool was_connected = false; + + while (!vipc_exit_) { + if (!vipc_client || cur_stream != requested_stream_type_) { + if (cur_stream != requested_stream_type_) clearFrames(); + cur_stream = requested_stream_type_; + vipc_client.reset(new VisionIpcClient(stream_name_, cur_stream, false)); + } + active_stream_type_ = cur_stream; + + if (!vipc_client->connected) { + // the server changed (a new route): the last frame is stale. A fresh thread keeps it, see startVipcThread(). + if (std::exchange(was_connected, false)) clearFrames(); + auto streams = VisionIpcClient::getAvailableStreams(stream_name_, false); + if (streams.empty()) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; + } + utils::runOnMainThread(utils::guarded(alive_, [this, streams]() { availableStreamsUpdated(streams); })); + + if (!vipc_client->connect(false)) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; + } + was_connected = true; + } + + if (VisionBuf *buf = vipc_client->recv(&frame_meta, 100)) { + // NV12 -> RGBA once per frame on the receive thread; paint just draws the image + if (rgb_back_.width != (int)buf->width || rgb_back_.height != (int)buf->height) { + rgb_back_.resize(buf->width, buf->height); + } + yuv::nv12_to_rgba(buf->y, buf->stride, buf->uv, buf->stride, + rgb_back_.data.data(), rgb_back_.bytesPerLine(), buf->width, buf->height); + { + std::lock_guard lk(frame_lock_); + rgb_frame_.swap(rgb_back_); + frame_updated_ = true; + } + } + } +} + +void CameraWidget::clearFrames() { + std::lock_guard lk(frame_lock_); + rgb_frame_.reset(); + rgb_back_.reset(); + frame_updated_ = false; +} diff --git a/openpilot/tools/cabana/ui/widgets/cameraview.h b/openpilot/tools/cabana/ui/widgets/cameraview.h new file mode 100644 index 000000000..db4e04f63 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/cameraview.h @@ -0,0 +1,124 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "openpilot/cereal/visionstream.h" +#include "tools/cabana/core/observable.h" +#include "msgq/visionipc/visionipc_client.h" + +// OS04C10 output, also used by its 1344x760 downscaled video. +constexpr float DEFAULT_CAMERA_ASPECT_RATIO = 2688.0f / 1520.0f; + +// Center-crop the source to fill the destination without stretching. +inline ImVec2 videoFillUv(const ImVec2 &size, float aspect_ratio) { + const float ratio = size.x / size.y / aspect_ratio; + return ImVec2(0.5f * (1.0f - std::min(ratio, 1.0f)), + 0.5f * (1.0f - std::min(1.0f / ratio, 1.0f))); +} + +struct VideoPlacement { + ImVec2 min; + ImVec2 max; + ImVec2 uv0; + ImVec2 uv1; +}; + +inline VideoPlacement videoPlacement(const ImRect &rect, float source_aspect_ratio, bool crop) { + VideoPlacement placement{rect.Min, rect.Max, ImVec2(0, 0), ImVec2(1, 1)}; + if (crop) { + placement.uv0 = videoFillUv(rect.GetSize(), source_aspect_ratio); + placement.uv1 = ImVec2(1.0f - placement.uv0.x, 1.0f - placement.uv0.y); + } else { + const float widget_aspect_ratio = rect.GetWidth() / rect.GetHeight(); + const int width = std::lround(rect.GetWidth() * std::min(source_aspect_ratio / widget_aspect_ratio, 1.0f)); + const int height = std::lround(rect.GetHeight() * std::min(widget_aspect_ratio / source_aspect_ratio, 1.0f)); + placement.min = ImVec2(rect.Min.x + (int)(rect.GetWidth() - width) / 2, rect.Min.y + (int)(rect.GetHeight() - height) / 2); + placement.max = ImVec2(placement.min.x + width, placement.min.y + height); + } + return placement; +} + +void drawVideoFrame(ImDrawList *draw_list, ImTextureRef texture, const ImRect &rect, const VideoPlacement &placement); + +// tightly packed RGBA pixels +struct RgbImage { + int width = 0; + int height = 0; + std::vector data; + bool isNull() const { return data.empty(); } + void reset() { width = height = 0; data.clear(); } + void resize(int w, int h) { width = w; height = h; data.resize(size_t(w) * h * 4); } + int bytesPerLine() const { return width * 4; } + void swap(RgbImage &other) { std::swap(width, other.width); std::swap(height, other.height); data.swap(other.data); } +}; + +// GL texture holding an RgbImage. Created/uploaded/freed on the GUI thread only (the GL context is current there). +struct GlTexture { + GlTexture() = default; + GlTexture(const GlTexture &) = delete; + GlTexture &operator=(const GlTexture &) = delete; + ~GlTexture() { destroy(); } + void upload(const RgbImage &image); // (re)allocates when the size changes + void destroy(); + ImTextureRef ref() const { return ImTextureRef((ImTextureID)(uintptr_t)id); } + + unsigned int id = 0; + int width = 0; + int height = 0; + uint64_t key = 0; // caller-defined identity of the uploaded image + bool mipmap = false; // set before the first upload: a mip chain for images drawn downscaled +}; + +class CameraWidget { +public: + explicit CameraWidget(std::string stream_name, VisionStreamType stream_type); + ~CameraWidget(); + void setStreamType(VisionStreamType type) { requested_stream_type_ = type; } + void stopVipcThread(); + // draw() implies visible; the owner calls setVisible(false) when the widget is no longer drawn, which + // stops the vipc thread. + void setVisible(bool visible); + // draws an item of `size` into the current window + void draw(const ImVec2 &size); + const ImRect &rect() const { return rect_; } + float frameAspectRatio() const; + float width() const { return rect_.GetWidth(); } + float height() const { return rect_.GetHeight(); } + + Observable<> clicked; + Observable> availableStreamsUpdated; // invoked on the main thread + +private: + void paint(); + void startVipcThread(); + void vipcThread(); + void clearFrames(); + + ImU32 bg_ = IM_COL32(0, 0, 0, 255); + RgbImage rgb_frame_; // written by vipc thread, drawn by GUI thread; guarded by frame_lock_ + RgbImage rgb_back_; // vipc thread only + bool frame_updated_ = false; // rgb_frame_ changed since the last upload; guarded by frame_lock_ + GlTexture frame_texture_; // GUI thread only + ImRect rect_; + bool visible_ = false; + + std::string stream_name_; + std::atomic active_stream_type_; + std::atomic requested_stream_type_; + std::thread vipc_thread_; + std::atomic vipc_exit_ = false; + std::mutex frame_lock_; + std::shared_ptr alive_ = std::make_shared(true); +}; diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc new file mode 100644 index 000000000..830a51b88 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -0,0 +1,415 @@ +#include "tools/cabana/ui/widgets/detailwidget.h" + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/commands.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +namespace { + +bool iequals(const std::string &a, const std::string &b) { + return a.size() == b.size() && + std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) { return std::tolower((unsigned char)x) == std::tolower((unsigned char)y); }); +} + +} // namespace + +ElidedLabel::ElidedLabel(const std::string &text) : text_(utils::trimmed(text)) {} + +void ElidedLabel::draw(float width) { + ImGuiWindow *window = ImGui::GetCurrentWindow(); + const ImVec2 pos(window->DC.CursorPos.x, window->DC.CursorPos.y + window->DC.CurrLineTextBaseOffset); + const ImRect bb(pos, ImVec2(pos.x + width, pos.y + ImGui::GetTextLineHeight())); + ImGui::ItemSize(bb.GetSize(), 0.0f); + if (ImGui::ItemAdd(bb, 0)) { + ImGui::RenderTextEllipsis(window->DrawList, bb.Min, bb.Max, bb.Max.x, text_.c_str(), nullptr, nullptr); + } + if (!tooltip_.empty()) ImGui::SetItemTooltip("%s", tooltip_.c_str()); + if (ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + clicked(); + } +} + +DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { + tabbar_.setUsesScrollButtons(true); + tabbar_.setAutoHide(true); + tabbar_.setTabsClosable(true); + connections_.push_back(tabbar_.currentChanged.connect([this](int index) { + if (index >= 0) setMessage(MessageId::fromString(tabbar_.tabText(index))); + })); + connections_.push_back(tabbar_.tabCloseRequested.connect([this](int index) { tabbar_.removeTab(index); })); + connections_.push_back(tabbar_.tabContextMenu.connect([this](int index) { showTabBarContextMenu(index); })); + binary_view_ = std::make_unique(); + signal_view_ = std::make_unique(charts); + + history_log_ = std::make_unique(); + page_tabs_.addTab("Messages"); + page_tabs_.addTab("Logs"); + connections_.push_back(page_tabs_.currentChanged.connect([this](int index) { + tab_widget_index_ = index; + if (index == 1) history_log_->onShown(); + updateState(); + })); + + connections_.push_back(binary_view_->signalHovered.connect([this](const cabana::Signal *s) { signal_view_->signalHovered(s); })); + connections_.push_back(binary_view_->signalClicked.connect([this](const cabana::Signal *s) { signal_view_->selectSignal(s, true); })); + connections_.push_back(binary_view_->editSignal.connect([this](const cabana::Signal *origin_s, cabana::Signal &s) { signal_view_->saveSignal(origin_s, s); })); + connections_.push_back(binary_view_->showChart.connect([this](const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { charts_->showChart(id, sig, show, merge); })); + connections_.push_back(signal_view_->showChart.connect([this](const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { charts_->showChart(id, sig, show, merge); })); + connections_.push_back(signal_view_->highlight.connect([this](const cabana::Signal *sig) { binary_view_->highlight(sig); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); + connections_.push_back(charts->seriesChanged.connect([this]() { signal_view_->updateChartState(); })); + connections_.push_back(can->timeRangeChanged.connect([this](const std::optional> &range) { + char text[64]; + if (range) snprintf(text, sizeof(text), "%.3f - %.3f", range->first, range->second); + heatmap_all_text_ = range ? text : "All"; + const bool live = !range; + if (std::exchange(heatmap_live_, live) != live) binary_view_->setHeatmapLiveMode(live); + })); +} + +void DetailWidget::drawToolBar() { + const ImGuiStyle &style = ImGui::GetStyle(); + std::vector items; + float name_width = 0.0f; + items.push_back({0.0f, [this, &name_width]() { + ImGui::AlignTextToFramePadding(); + pushBoldFont(); + name_label_.draw(name_width); + popBoldFont(); + }}); + items.back().in_menu = false; + const size_t spacer_index = items.size(); + const std::string heatmap_text = "Heatmap: " + (!heatmap_visible_ ? std::string("Hidden") : heatmap_live_ ? std::string("Live") : heatmap_all_text_); + auto heatmap_items = [this]() { + if (dropdown::Item("Hidden", nullptr, !heatmap_visible_)) heatmap_visible_ = false; + if (dropdown::Item("Live", nullptr, heatmap_visible_ && heatmap_live_)) { + heatmap_visible_ = true; + heatmap_live_ = true; + binary_view_->setHeatmapLiveMode(true); + } + if (dropdown::Item(heatmap_all_text_.c_str(), nullptr, heatmap_visible_ && !heatmap_live_)) { + heatmap_visible_ = true; + heatmap_live_ = false; + binary_view_->setHeatmapLiveMode(false); + } + }; + items.push_back(toolbarMenu("heatmap", heatmap_text, "Heatmap", heatmap_items)); + items.push_back({1.0f, []() { ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); }}); + items.back().in_menu = false; + // Capture the panel width before the action can run inside the overflow popup. + const float panel_width = ImGui::GetWindowWidth(); + items.push_back(toolbarAction("edit_msg", icon::PENCIL, "Edit Message", [this, panel_width]() { editMsg(panel_width); })); + items.push_back(toolbarAction("remove_msg", icon::TRASH, "Remove Message", + [this]() { UndoStack::instance()->push(new RemoveMsgCommand(msg_id_)); }, action_remove_msg_enabled_)); + + const float right_width = toolbarWidth(items, spacer_index) - style.ItemSpacing.x; + name_width = std::max(ImGui::CalcTextSize("MMMMMM").x, ImGui::GetContentRegionAvail().x - right_width - style.ItemSpacing.x); + items[0].width = name_width; + drawToolbar(items, spacer_index); +} + +void DetailWidget::showTabBarContextMenu(int index) { + if (dropdown::BeginPopupContextItem()) { + if (dropdown::Item("Close Other Tabs")) { + tabbar_.moveTab(index, 0); + tabbar_.setCurrentIndex(0); + while (tabbar_.count() > 1) tabbar_.removeTab(1); + } + dropdown::EndPopup(); + } +} + +int DetailWidget::findOrAddTab(const MessageId &message_id) { + const std::string text = message_id.toString(); + int index = tabbar_.count() - 1; + for (/**/; index >= 0; --index) { + if (tabbar_.tabText(index) == text) break; + } + if (index == -1) { + index = tabbar_.addTab(text); + tabbar_.setTabToolTip(index, msgName(message_id)); + } + return index; +} + +void DetailWidget::setMessage(const MessageId &message_id) { + if (std::exchange(msg_id_, message_id) == message_id) return; + + tabbar_.setCurrentIndex(findOrAddTab(message_id)); + + signal_view_->setMessage(msg_id_); + binary_view_->setMessage(msg_id_); + history_log_->setMessage(msg_id_); + refresh(); +} + +std::pair> DetailWidget::serializeMessageIds() const { + std::vector msgs; + for (int i = 0; i < tabbar_.count(); ++i) msgs.push_back(tabbar_.tabText(i)); + return std::make_pair(msg_id_.toString(), msgs); +} + +void DetailWidget::restoreTabs(const std::string &active_msg_id, const std::vector& msg_ids) { + for (const auto& str_id : msg_ids) { + MessageId id = MessageId::fromString(str_id); + if (dbc()->msg(id) != nullptr) + findOrAddTab(id); + } + + auto active_id = MessageId::fromString(active_msg_id); + if (dbc()->msg(active_id) != nullptr) + setMessage(active_id); +} + +void DetailWidget::refresh() { + std::vector warnings; + auto msg = dbc()->msg(msg_id_); + if (msg) { + if (msg_id_.source == INVALID_SOURCE) { + warnings.push_back("No messages received."); + } else if (msg->size != can->lastMessage(msg_id_).dat.size()) { + warnings.push_back("Message size (" + std::to_string(msg->size) + ") is incorrect."); + } + for (auto s : binary_view_->getOverlappingSignals()) { + warnings.push_back(s->name + " has overlapping bits."); + } + } + std::string msg_name = msg ? msg->name + " (" + msg->transmitter + ")" : msgName(msg_id_); + name_label_.setText(msg_name); + name_label_.setToolTip(msg_name); + action_remove_msg_enabled_ = msg != nullptr; + + if (!warnings.empty()) { + warning_label_.clear(); + for (size_t i = 0; i < warnings.size(); ++i) { + if (i) warning_label_ += '\n'; + warning_label_ += warnings[i]; + } + warning_icon_ = msg ? icon::EXCLAMATION_TRIANGLE : icon::INFO_CIRCLE; + } + warning_widget_visible_ = !warnings.empty(); +} + +void DetailWidget::updateState(const std::set *msgs) { + if ((msgs && !msgs->count(msg_id_))) + return; + + if (tab_widget_index_ == 0) + binary_view_->updateState(); + else + history_log_->updateState(); +} + +void DetailWidget::editMsg(float parent_width) { + auto msg = dbc()->msg(msg_id_); + int size = msg ? msg->size : can->lastMessage(msg_id_).dat.size(); + edit_dlg_ = std::make_unique(msg_id_, msgName(msg_id_), size, parent_width); +} + +void DetailWidget::drawTabWidget() { + const ImGuiStyle &style = ImGui::GetStyle(); + page_tabs_.draw(); + if (tab_widget_index_ == 0) { + if (heatmap_visible_) { + // Keep most of a short window available for signal rows. + const float avail = ImGui::GetContentRegionAvail().y; + const float max_height = std::max(avail - style.ItemSpacing.y - SignalView::minimumHeight(), 1.0f); + const float height = std::min(binary_view_->minimumSizeHint().y, max_height); + ImGui::BeginChild("binary_view", ImVec2(0, std::max(height, 1.0f)), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); + binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); + binary_view_->draw(); + ImGui::EndChild(); + } + ImGui::BeginChild("signal_view", ImVec2(0, 0), ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + signal_view_rect_ = ImGui::GetCurrentWindow()->Rect(); + signal_view_->draw(); + ImGui::EndChild(); + } else { + history_log_->draw(); + } +} + +void DetailWidget::draw() { + tabbar_.draw(); + ImGui::BeginChild("message_content", ImVec2(0, 0), ImGuiChildFlags_AlwaysUseWindowPadding, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + drawToolBar(); + + if (warning_widget_visible_) { + ImGui::TextUnformatted(warning_icon_); + ImGui::SameLine(); + ImGui::TextUnformatted(warning_label_.c_str()); + } + + drawTabWidget(); + ImGui::EndChild(); + + if (edit_dlg_ && !edit_dlg_->draw()) { + if (edit_dlg_->accepted()) { + const auto r = edit_dlg_->result(); + UndoStack::instance()->push(new EditMsgCommand(r.msg_id, r.name, r.size, r.node, r.comment)); + } + edit_dlg_.reset(); + } +} + +// HelpOverlay: the whatsThis text and last drawn rect of the binary view and the signal view +std::vector> DetailWidget::helpRects() const { + std::vector> rects; + if (tab_widget_index_ == 0) { + if (heatmap_visible_) rects.emplace_back(binary_view_->whatsThis(), binary_view_rect_); + rects.emplace_back(signal_view_->whatsThis(), signal_view_rect_); + } + return rects; +} + +EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const std::string &title, int size, float parent_width) + : msg_id_(msg_id), original_name_(title), name_edit_(title), size_spin_(size), width_(parent_width * 0.9f) { + window_title_ = "Edit Message: " + msg_id.toString(); + + if (auto msg = dbc()->msg(msg_id)) { + node_ = msg->transmitter; + comment_edit_ = msg->comment; + } + validateName(name_edit_); +} + +EditMessageDialog::Result EditMessageDialog::result() const { + return {msg_id_, utils::trimmed(name_edit_), utils::trimmed(node_), utils::trimmed(comment_edit_), size_spin_}; +} + +bool EditMessageDialog::draw() { + if (closed_) return false; + if (!opened_) { + ImGui::OpenPopup(window_title_.c_str()); + opened_ = true; + } + // The form needs room for message names and comments even when its panel is narrow. + const float max_width = std::max(1.0f, ImGui::GetMainViewport()->WorkSize.x - ImGui::GetStyle().WindowPadding.x * 2); + const float min_width = std::min(600.0f, max_width); + ImGui::SetNextWindowSizeConstraints(ImVec2(min_width, 0.0f), ImVec2(max_width, FLT_MAX)); + setNextDialogWindow(ImVec2(std::clamp(width_, min_width, max_width), 0.0f)); + bool open = true; + if (ImGui::BeginPopupModal(window_title_.c_str(), &open)) { + const float label_width = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Comment").x + ImGui::GetStyle().ItemSpacing.x; + auto row = [&](const char *label) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label); + ImGui::SameLine(label_width); + ImGui::SetNextItemWidth(-FLT_MIN); + }; + + if (!error_label_.empty()) { + row(""); + ImGui::TextUnformatted(error_label_.c_str()); + } + row("Name"); + if (validatedInput("##name", &name_edit_, nameValidator)) { + validateName(name_edit_); + } + + row("Size"); + if (inputInt("##size", &size_spin_)) size_spin_ = std::clamp(size_spin_, 1, CAN_MAX_DATA_BYTES); + + row("Node"); + validatedInput("##node", &node_, nameValidator); + row("Comment"); + inputTextMultiline("##comment", &comment_edit_, ImVec2(-FLT_MIN, 192.0f)); + const bool comment_active = ImGui::IsItemActive(); + + bool accept = false, reject = false; + if (dialogButtons("OK", &accept, &reject, ok_enabled_)) { + accepted_ = accept; + closed_ = true; + } + // Enter triggers the default (OK) button + if (!closed_ && ok_enabled_ && !comment_active && ImGui::IsKeyPressed(ImGuiKey_Enter, false)) { + accepted_ = true; + closed_ = true; + } + if (!open) closed_ = true; + if (closed_) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } else { + closed_ = true; // closed from outside + } + return !closed_; +} + +void EditMessageDialog::validateName(const std::string &text) { + bool valid = !iequals(text, UNTITLED); + error_label_.clear(); + if (!text.empty() && valid && text != original_name_) { + valid = dbc()->msg(msg_id_.source, text) == nullptr; + if (!valid) error_label_ = "Name already exists"; + } + ok_enabled_ = valid; +} + +DetailWidget* CenterWidget::ensureDetailWidget() { + if (!detail_widget) { + detail_widget = std::make_unique(charts_); + } + return detail_widget.get(); +} + +void CenterWidget::clear() { + detail_widget.reset(); + charts_ = nullptr; // MainWindow recreates the ChartsWidget after startStream +} + +void CenterWidget::draw() { + if (detail_widget) { + detail_widget->draw(); + } else { + drawWelcomeWidget(); + } +} + +void CenterWidget::drawWelcomeWidget() { + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const ImVec2 origin = ImGui::GetCursorPos(); + auto centered = [&](const char *text, float y) { + const ImVec2 size = ImGui::CalcTextSize(text); + ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - size.x) * 0.5f, y)); + ImGui::TextUnformatted(text); + }; + ImGui::PushStyleColor(ImGuiCol_Text, colorRgb(169, 169, 169)); + float y = origin.y + avail.y * 0.5f - 90.0f; + pushLargeFont(); + centered("CABANA", y); + y += ImGui::GetTextLineHeightWithSpacing(); + popLargeFont(); + + auto newShortcutRow = [&](const char *title, const char *key) { + const float w = ImGui::CalcTextSize(title).x + ImGui::CalcTextSize(key).x + 40.0f; + ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - w) * 0.5f, y)); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(title); + ImGui::SameLine(); + ImGui::BeginDisabled(); + ImGui::SmallButton(key); + ImGui::EndDisabled(); + y += ImGui::GetFrameHeightWithSpacing(); + }; + + centered("<- Select a message to view details", y); + y += ImGui::GetTextLineHeightWithSpacing(); + newShortcutRow("Pause", "Space"); + newShortcutRow("Help", "F1"); + newShortcutRow("What's This?", "Shift+F1"); + ImGui::PopStyleColor(); +} diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.h b/openpilot/tools/cabana/ui/widgets/detailwidget.h new file mode 100644 index 000000000..4745ed5c8 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.h @@ -0,0 +1,114 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "tools/cabana/ui/widgets/binaryview.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/widgets/historylog.h" +#include "tools/cabana/ui/widgets/signalview.h" +#include "tools/cabana/ui/widgets/tabbar.h" + +// a label that elides its text to the available width +class ElidedLabel { +public: + explicit ElidedLabel(const std::string &text = {}); + void setText(const std::string &text) { text_ = text; } + void setToolTip(const std::string &tip) { tooltip_ = tip; } + void draw(float width); + + Observable<> clicked; + +private: + std::string text_, tooltip_; +}; + +// modal, non-blocking: DetailWidget::draw() polls draw() and applies the result once it returns false +class EditMessageDialog { +public: + struct Result { + MessageId msg_id; + std::string name, node, comment; // trimmed + int size; + }; + + EditMessageDialog(const MessageId &msg_id, const std::string &title, int size, float parent_width); + bool draw(); // false once closed + bool accepted() const { return accepted_; } + Result result() const; + +private: + void validateName(const std::string &text); + + MessageId msg_id_; + std::string original_name_; + std::string name_edit_; + std::string node_; + std::string comment_edit_; + std::string error_label_; // empty when the name is valid + int size_spin_; + bool ok_enabled_ = true; + std::string window_title_; + float width_; + bool opened_ = false; + bool accepted_ = false; + bool closed_ = false; +}; + +class DetailWidget { +public: + DetailWidget(ChartsWidget *charts); + void setMessage(const MessageId &message_id); + void refresh(); + void draw(); // tab bar of message ids, toolbar, warning, Messages/Logs tabs + std::pair> serializeMessageIds() const; + void restoreTabs(const std::string &active_msg_id, const std::vector &msg_ids); + std::vector> helpRects() const; // HelpOverlay: (whatsThis, rect) of the binary and signal views + +private: + void drawToolBar(); + void drawTabWidget(); + int findOrAddTab(const MessageId& message_id); + void showTabBarContextMenu(int index); + void editMsg(float parent_width); + void updateState(const std::set *msgs = nullptr); + + MessageId msg_id_; + const char *warning_icon_ = nullptr; + std::string warning_label_; + ElidedLabel name_label_; + bool warning_widget_visible_ = false; + TabBar tabbar_; + TabBar page_tabs_; + int tab_widget_index_ = 0; + bool action_remove_msg_enabled_ = false; + bool heatmap_live_ = true; + bool heatmap_visible_ = true; + std::string heatmap_all_text_ = "All"; + ImRect binary_view_rect_, signal_view_rect_; // child window rects of the last drawTabWidget + std::unique_ptr history_log_; + std::unique_ptr binary_view_; + std::unique_ptr signal_view_; + ChartsWidget *charts_; + std::unique_ptr edit_dlg_; + Connections connections_; +}; + +class CenterWidget { +public: + CenterWidget() = default; + void setChartsWidget(ChartsWidget *charts) { charts_ = charts; } + void setMessage(const MessageId &message_id) { ensureDetailWidget()->setMessage(message_id); } + DetailWidget* getDetailWidget() { return detail_widget.get(); } + DetailWidget* ensureDetailWidget(); + void clear(); + void draw(); // the welcome widget until a message is selected, then the DetailWidget + +private: + void drawWelcomeWidget(); + std::unique_ptr detail_widget; + ChartsWidget *charts_ = nullptr; +}; diff --git a/openpilot/tools/cabana/ui/widgets/historylog.cc b/openpilot/tools/cabana/ui/widgets/historylog.cc new file mode 100644 index 000000000..92ed043ea --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/historylog.cc @@ -0,0 +1,311 @@ +#include "tools/cabana/ui/widgets/historylog.h" + +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/widgets/messagebytes.h" +#include "tools/cabana/utils/export.h" +#include "tools/cabana/utils/strings.h" + +namespace { + +constexpr int BATCH_SIZE = 50; +constexpr float DISPLAY_TYPE_WIDTH = 90.0f; +constexpr float SIGNALS_WIDTH = 160.0f; +constexpr float COMPARE_WIDTH = 50.0f; + +std::string formatTime(uint64_t mono_time) { + char buf[32] = {}; + snprintf(buf, sizeof(buf), "%.2f", can->toSeconds(mono_time)); + return buf; +} + +} // namespace + +LogsWidget::LogsWidget() { + connections_.push_back(can->seekedTo.connect([this](double) { reset(); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { reset(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { reset(); })); +} + +void LogsWidget::setMessage(const MessageId &message_id) { + msg_id_ = message_id; + reset(); +} + +void LogsWidget::reset() { + sigs_.clear(); + if (auto dbc_msg = dbc()->msg(msg_id_)) sigs_ = dbc_msg->getSignals(); + messages_.clear(); + hex_colors_ = {}; + signals_cb_ = comp_box_ = 0; + value_edit_.clear(); + value_edit_modified_ = false; + export_btn_enabled_ = false; + selected_row_ = selected_col_ = -1; + setFilter(0, "", nullptr); +} + +void LogsWidget::setFilter(int sig_idx, const std::string &value, std::function cmp) { + filter_sig_idx_ = sig_idx; + filter_value_ = utils::toDouble(value); + filter_cmp_ = value.empty() ? nullptr : cmp; + load(true); +} + +void LogsWidget::load(bool clear) { + if (clear && !messages_.empty()) { + messages_.clear(); + selected_row_ = selected_col_ = -1; + } + const uint64_t current_time = can->toMonoTime(can->lastMessage(msg_id_).ts) + 1; + fetch(messages_.begin(), current_time, messages_.empty() ? 0 : messages_.front().mono_time); +} + +bool LogsWidget::canFetchMore() const { + const auto &events = can->events(msg_id_); + return !events.empty() && !messages_.empty() && messages_.back().mono_time > events.front()->mono_time; +} + +void LogsWidget::fetch(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time) { + const auto &events = can->events(msg_id_); + auto first = std::upper_bound(events.rbegin(), events.rend(), from_time, [](uint64_t ts, auto e) { return ts > e->mono_time; }); + + std::vector msgs; + std::vector values(sigs_.size()); + msgs.reserve(BATCH_SIZE); + for (; first != events.rend() && (*first)->mono_time > min_time; ++first) { + const CanEvent *e = *first; + for (int i = 0; i < sigs_.size(); ++i) { + sigs_[i]->getValue(e->dat, e->size, &values[i]); + } + if (!filter_cmp_ || filter_cmp_(values[filter_sig_idx_], filter_value_)) { + msgs.emplace_back(Message{e->mono_time, values, {e->dat, e->dat + e->size}}); + if (msgs.size() >= BATCH_SIZE && min_time == 0) break; + } + } + if (msgs.empty()) return; + + if (hexMode() && (min_time > 0 || messages_.empty())) { + const auto freq = can->lastMessage(msg_id_).freq; + const std::vector no_mask; + for (auto &m : msgs) { + hex_colors_.compute(msg_id_, m.data.data(), m.data.size(), m.mono_time / (double)1e9, can->getSpeed(), no_mask, freq); + m.colors = hex_colors_.colors; + } + } + const int pos = std::distance(messages_.begin(), insert_pos); + messages_.insert(insert_pos, std::move_iterator(msgs.begin()), std::move_iterator(msgs.end())); + export_btn_enabled_ = true; + // the selection follows the message it was made on + if (selected_row_ >= pos) selected_row_ += msgs.size(); +} + +void LogsWidget::filterChanged() { + if (value_edit_.empty() && !value_edit_modified_) return; + + std::function cmp = nullptr; + switch (comp_box_) { + case 0: cmp = std::greater{}; break; + case 1: cmp = std::equal_to{}; break; + case 2: cmp = [](double l, double r) { return l != r; }; break; + case 3: cmp = std::less{}; break; + } + setFilter(signals_cb_, value_edit_, cmp); +} + +void LogsWidget::exportToCSV() { + std::string dir = settings.last_dir + "/" + can->routeName() + "_" + msgName(msg_id_) + ".csv"; + FileDialog::getSaveFileName("Export " + msgName(msg_id_) + " to CSV file", dir, ".csv", [this](const std::string &fn) { + if (!fn.empty()) { + hexMode() ? utils::exportToCSV(fn, msg_id_) : utils::exportSignalsToCSV(fn, msg_id_); + } + }); +} + +void LogsWidget::draw() { + const ImGuiStyle &style = ImGui::GetStyle(); + + beginControlChild("toolbar", ImVec2(0, ImGui::GetFrameHeight() + CONTROL_OUTLINE_PADDING * 2), + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + + // toolbar: the export button is right aligned and never clipped, the value input shrinks first + const float export_w = iconButtonWidth(); + if (!sigs_.empty()) { + const float fixed = DISPLAY_TYPE_WIDTH + SIGNALS_WIDTH + COMPARE_WIDTH + style.ItemSpacing.x * 4 + export_w; + const float value_w = std::clamp(ImGui::GetContentRegionAvail().x - fixed, 30.0f, 120.0f); + + ImGui::SetNextItemWidth(DISPLAY_TYPE_WIDTH); + if (dropdown::Combo("##display_type", &display_type_cb_, "Signal\0Hex\0")) { + hex_mode_ = display_type_cb_; + reset(); + } + ImGui::SetItemTooltip("Display signal value or raw hex value"); + ImGui::SameLine(); + std::string sig_items; + for (auto s : sigs_) { + sig_items += s->name; + sig_items += '\0'; + } + sig_items += '\0'; + ImGui::SetNextItemWidth(SIGNALS_WIDTH); + if (dropdown::Combo("##signals", &signals_cb_, sig_items.c_str())) filterChanged(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(COMPARE_WIDTH); + if (dropdown::Combo("##comp", &comp_box_, ">\0=\0!=\0<\0")) filterChanged(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(value_w); + if (clearableInput("##value", &value_edit_, "", doubleValidator)) { + value_edit_modified_ = true; // clearing the field still counts as modified + filterChanged(); + } + } + alignRight(iconButtonWidth()); + ImGui::BeginDisabled(!export_btn_enabled_); + if (iconButton("export_csv", icon::FILETYPE_CSV)) exportToCSV(); + ImGui::EndDisabled(); + disabledItemTooltip("Export to CSV file..."); + ImGui::EndChild(); + + ImGui::Separator(); + drawTable(); +} + +std::string LogsWidget::headerText(int column) const { + if (column == 0) return "Time"; + if (hexMode()) return "Data"; + std::string text = sigs_[column - 1]->name; + if (!sigs_[column - 1]->unit.empty()) text += " (" + sigs_[column - 1]->unit + ")"; + std::replace(text.begin(), text.end(), '_', ' '); + return text; +} + +ImVec2 LogsWidget::headerSize(int column, float viewport_width) const { + const ImVec2 time_text_size = ImGui::CalcTextSize("000000.00"); + const ImVec2 time_col_size(time_text_size.x + 10, time_text_size.y + 6); + if (column == 0) return time_col_size; + const int default_size = std::max(100, (int)((viewport_width - time_col_size.x) / (columnCount() - 1))); + const ImVec2 rect = ImGui::CalcTextSize(headerText(column).c_str(), nullptr, false, default_size); + return ImVec2{std::max(rect.x + 10, (float)default_size), rect.y + 6}; +} + +void LogsWidget::drawHeaderCell(ImDrawList *dl, const ImRect &rect, int column) const { + if (column > 0 && !hexMode()) { + CabanaColor bg = sigs_[column - 1]->color; + bg.a = 128; + dl->AddRectFilled(rect.Min, rect.Max, toImU32(bg), ImGui::GetStyle().FrameRounding); + } + const std::string text = headerText(column); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); + // right aligned and word wrapped, one line at a time + const ImRect r(rect.Min.x + 5, rect.Min.y + 3, rect.Max.x - 5, rect.Max.y - 3); + ImFont *font = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + const float wrap_width = std::max(1.0f, r.GetWidth()); + const char *s = text.c_str(); + const char *end = s + text.size(); + float y = r.Min.y; + dl->PushClipRect(rect.Min, rect.Max, true); + while (s < end) { + const char *line_end = font->CalcWordWrapPosition(font_size, s, end, wrap_width); + if (line_end == s) line_end = s + 1; + const float w = ImGui::CalcTextSize(s, line_end).x; + dl->AddText(font, font_size, ImVec2(r.Max.x - w, y), color, s, line_end); + y += ImGui::GetTextLineHeight(); + s = line_end; + while (s < end && ImCharIsBlankA(*s)) s++; + if (s < end && *s == '\n') s++; + } + dl->PopClipRect(); +} + +void LogsWidget::drawTable() { + const ImGuiStyle &style = ImGui::GetStyle(); + const int cols = columnCount(); + // the header viewport excludes the table's cell padding and the vertical scrollbar (the latter is one + // frame behind, it is only known once shown) + const float header_width = ImGui::GetContentRegionAvail().x - style.CellPadding.x * 2 * cols - + (vscrollbar_visible_ ? style.ScrollbarSize : 0.0f); + + std::vector sizes(cols); + float header_height = 0; + for (int i = 0; i < cols; ++i) { + sizes[i] = headerSize(i, header_width); + header_height = std::max(header_height, sizes[i].y); + } + if (hexMode() && !messages_.empty()) { + sizes[1].x = std::max(sizes[1].x, bytesCellSize(messages_.front().data.size(), false).x); + } + const float row_height = bytesCellSize(8, false).y; + + // fixed section sizes and a horizontal scrollbar, no alternating row colors; the grid is drawn between + // rows and columns + ImGuiTableFlags flags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_ScrollX | ImGuiTableFlags_Borders | + ImGuiTableFlags_SizingFixedFit; + // an empty viewport draws no grid + if (messages_.empty()) flags &= ~ImGuiTableFlags_BordersInnerV; + // the sum of the fixed section sizes, so the columns keep their size and the table scrolls + float inner_width = 0; + for (int i = 0; i < cols; ++i) inner_width += sizes[i].x + style.CellPadding.x * 2; + + bool fetch_more = false; + if (ImGui::BeginTable("logs", cols, flags, ImVec2(0, 0), inner_width)) { + ImGui::TableSetupScrollFreeze(0, 1); + for (int i = 0; i < cols; ++i) { + ImGui::TableSetupColumn(headerText(i).c_str(), ImGuiTableColumnFlags_WidthFixed, sizes[i].x); + } + ImGuiTable *table = ImGui::GetCurrentTable(); + ImDrawList *painter = ImGui::GetWindowDrawList(); + + ImGui::TableNextRow(ImGuiTableRowFlags_Headers, header_height); + for (int i = 0; i < cols; ++i) { + if (!ImGui::TableSetColumnIndex(i)) continue; + drawHeaderCell(painter, ImGui::TableGetCellBgRect(table, i), i); + ImGui::Dummy(ImVec2(0, header_height - style.CellPadding.y * 2)); + } + + ImGuiListClipper clipper; + clipper.Begin(messages_.size(), row_height); + while (clipper.Step()) { + for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) { + const auto &m = messages_[row]; + ImGui::TableNextRow(0, row_height); + // rows are prepended while the stream plays, so the id is the message, not the row index + ImGui::PushID((void *)(uintptr_t)m.mono_time); + for (int col = 0; col < cols; ++col) { + if (!ImGui::TableSetColumnIndex(col)) continue; + // cells are selected, not rows; there is no hover highlight, only the selection background + const bool cell_selected = selected_row_ == row && selected_col_ == col; + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const ImRect rect(pos, ImVec2(pos.x + ImGui::GetContentRegionAvail().x, pos.y + row_height - style.CellPadding.y * 2)); + ImGui::PushID(col); + if (viewSelectable("##cell", cell_selected, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, rect.GetHeight()))) { + selected_row_ = row; + selected_col_ = col; + } + ImGui::PopID(); + if (col == 0) { + drawTextCell(painter, rect, formatTime(m.mono_time), cell_selected, false); + } else if (hexMode()) { + drawBytesCell(painter, rect, m.data, &m.colors, cell_selected, false, false); + } else { + drawTextCell(painter, rect, sigs_[col - 1]->formatValue(m.sig_values[col - 1], false), cell_selected, false); + } + } + ImGui::PopID(); + } + // fetch more when the last row is visible or the scrollbar is at its maximum + if (clipper.DisplayEnd >= (int)messages_.size()) fetch_more = true; + } + if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) fetch_more = true; + vscrollbar_visible_ = table->InnerWindow->ScrollbarY; + ImGui::EndTable(); + } + if (fetch_more && canFetchMore()) fetch(messages_.end(), messages_.back().mono_time, 0); +} diff --git a/openpilot/tools/cabana/ui/widgets/historylog.h b/openpilot/tools/cabana/ui/widgets/historylog.h new file mode 100644 index 000000000..d8e68a482 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/historylog.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" + +// the Logs tab: the messages of one id, newest first, as signal values or hex bytes +class LogsWidget { +public: + LogsWidget(); + void setMessage(const MessageId &message_id); + void updateState() { load(false); } // appends what arrived since the last call + void onShown() { load(true); } // reloads the log when the Logs tab becomes visible + void draw(); + +private: + struct Message { + uint64_t mono_time = 0; + std::vector sig_values; + std::vector data; + std::vector colors; + }; + + bool hexMode() const { return sigs_.empty() || hex_mode_; } + int columnCount() const { return hexMode() ? 2 : (int)sigs_.size() + 1; } + void reset(); + void setFilter(int sig_idx, const std::string &value, std::function cmp); + void load(bool clear); + bool canFetchMore() const; + void fetch(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time); + void filterChanged(); + void exportToCSV(); + void drawTable(); + std::string headerText(int column) const; + ImVec2 headerSize(int column, float viewport_width) const; + void drawHeaderCell(ImDrawList *dl, const ImRect &rect, int column) const; + + MessageId msg_id_; + std::vector sigs_; + std::deque messages_; + CanData hex_colors_; + bool hex_mode_ = false; + int filter_sig_idx_ = -1; + double filter_value_ = 0; + std::function filter_cmp_; + int signals_cb_ = 0, comp_box_ = 0, display_type_cb_ = 0; // current combo box indices + std::string value_edit_; + bool value_edit_modified_ = false; + bool export_btn_enabled_ = false; + int selected_row_ = -1, selected_col_ = -1; + bool vscrollbar_visible_ = false; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.cc b/openpilot/tools/cabana/ui/widgets/messagebytes.cc new file mode 100644 index 000000000..63e31d6f8 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.cc @@ -0,0 +1,63 @@ +#include "tools/cabana/ui/widgets/messagebytes.h" + +#include +#include + +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +ImVec2 byteCellSize() { + pushMonoFont(); + // the width of "00 " by the line height (ascent + descent + 1), not the font size + const ImFontBaked *baked = ImGui::GetFontBaked(); + const ImVec2 size(ImGui::CalcTextSize("00 ").x, std::ceil(baked->Ascent) - std::floor(baked->Descent) + 1 + 2); + popMonoFont(); + return size; +} + +ImVec2 bytesCellSize(int n, bool multiple_lines) { + const ImVec2 byte_size = byteCellSize(); + const int rows = multiple_lines ? std::max(1, (n + 7) / 8) : 1; + const int columns = multiple_lines ? std::min(n, 8) : n; + const ImVec2 margin = ImGui::GetStyle().CellPadding; // the margin is one more than the table's cell padding + return {columns * byte_size.x + (margin.x + 1) * 2, rows * byte_size.y + (margin.y + 1) * 2}; +} + +ImU32 cellTextColor(bool selected, bool inactive) { + if (selected) { + const ImU32 text = ImGui::GetColorU32(palette().text_selected); + return inactive ? withAlpha(text, 100) : text; + } + return ImGui::GetColorU32(inactive ? ImGuiCol_TextDisabled : ImGuiCol_Text); +} + +void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive) { + drawElidedText(dl, rect, text, cellTextColor(selected, inactive)); +} + +void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vector &bytes, const std::vector *colors, + bool selected, bool inactive, bool multiple_lines) { + const ImU32 text_pen = cellTextColor(selected, inactive); + const ImVec2 byte_size = byteCellSize(); + pushMonoFont(); + ImFont *font = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + for (int i = 0; i < (int)bytes.size(); ++i) { + const int row = multiple_lines ? i / 8 : 0; + const int column = multiple_lines ? i % 8 : i; + const ImVec2 min(rect.Min.x + column * byte_size.x, rect.Min.y + row * byte_size.y); + const ImRect r(min, ImVec2(min.x + byte_size.x, min.y + byte_size.y)); + + // a colored unselected byte keeps text_pen, like the other cells of the row + ImU32 pen = text_pen; + if (colors && i < (int)colors->size() && (*colors)[i].alpha() > 0) { + if (selected) { + pen = ImGui::GetColorU32(ImGuiCol_Text); + dl->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_WindowBg)); + } + dl->AddRectFilled(r.Min, r.Max, toImU32((*colors)[i])); + } + drawText(dl, r, utils::hexByte(bytes[i]), pen, font, font_size); + } + popMonoFont(); +} diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.h b/openpilot/tools/cabana/ui/widgets/messagebytes.h new file mode 100644 index 000000000..7c7aabc98 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/core/color.h" + +// the cells of the messages and history log tables. Only valid inside a frame: the sizes come from the +// mono font. + +ImVec2 byteCellSize(); // one "00 " cell +ImVec2 bytesCellSize(int n, bool multiple_lines); // a cell of n bytes, the table's cell padding included +ImU32 cellTextColor(bool selected, bool inactive); // inactive rows gray the text and fade the highlighted text + +void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive); +void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vector &bytes, const std::vector *colors, + bool selected, bool inactive, bool multiple_lines); diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc new file mode 100644 index 000000000..a3cacea68 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -0,0 +1,515 @@ +#include "tools/cabana/ui/widgets/messageswidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui_internal.h" +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/widgets/messagebytes.h" +#include "tools/cabana/utils/strings.h" + +namespace { + +const char *COLUMN_TITLES[MessageList::COLUMN_COUNT] = {"Name", "Bus", "ID", "Node", "Freq", "Count", "Bytes"}; +constexpr float DEFAULT_SECTION_SIZE = 100.0f; + +// surrounding whitespace is ignored; no sign, no 0x prefix +unsigned int toUInt(const std::string &s, bool *ok, int base) { + const char *b = s.data(), *e = b + s.size(); + while (b < e && std::isspace((unsigned char)*b)) ++b; + while (e > b && std::isspace((unsigned char)e[-1])) --e; + unsigned int v = 0; + auto [p, ec] = std::from_chars(b, e, v, base); + *ok = b < e && p == e && ec == std::errc(); + return *ok ? v : 0; +} + +bool parseRange(const std::string &filter, uint32_t value, int base = 10) { + // parse the filter string into a range: "1" -> {1, 1}, "1-3" -> {1, 3}, "1-" -> {1, inf} + unsigned int min = std::numeric_limits::min(); + unsigned int max = std::numeric_limits::max(); + auto s = utils::split(filter, '-'); + bool ok = s.size() >= 1 && s.size() <= 2; + if (ok && !s[0].empty()) min = toUInt(s[0], &ok, base); + if (ok && s.size() == 1) { + max = min; + } else if (ok && s.size() == 2 && !s[1].empty()) { + max = toUInt(s[1], &ok, base); + } + return ok && value >= min && value <= max; +} + +// imgui draws an up arrow for ImGuiSortDirection_Ascending, cabana wants a down pointing one. Feed imgui +// the opposite direction and flip it back before it reaches the list. +inline ImGuiSortDirection flipSortDirection(ImGuiSortDirection dir) { + return dir == ImGuiSortDirection_Ascending ? ImGuiSortDirection_Descending : ImGuiSortDirection_Ascending; +} + +std::string formatFreq(float freq) { + if (freq <= 0) return "--"; + char buf[32]; + snprintf(buf, sizeof(buf), freq >= 0.95 ? "%.0f" : "%.2f", freq >= 0.95 ? std::nearbyint(freq) : freq); + return buf; +} + +// the text of a cell; the DATA cell paints the bytes itself +std::string cellText(const MessageList::Item &item, int column) { + const bool seen = item.id.source != INVALID_SOURCE; + switch (column) { + case MessageList::NAME: return item.name; + case MessageList::SOURCE: return seen ? std::to_string(item.id.source) : "N/A"; + case MessageList::ADDRESS: return utils::toHexString(item.id.address); + case MessageList::NODE: return item.node; + case MessageList::FREQ: return seen ? formatFreq(can->lastMessage(item.id).freq) : "N/A"; + case MessageList::COUNT: return seen ? std::to_string(can->lastMessage(item.id).count) : "N/A"; + case MessageList::DATA: return seen ? "" : "N/A"; + } + return {}; +} + +} // namespace + +// MessageList + +MessageList::MessageList() { + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool has_new_ids) { msgsReceived(msgs, has_new_ids); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { dbcModified(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { dbcModified(); })); +} + +void MessageList::setFilters(const std::map &filters) { + filters_ = filters; + filterAndSort(); +} + +void MessageList::showInactiveMessages(bool show) { + show_inactive_messages = show; + filterAndSort(); +} + +void MessageList::dbcModified() { + dbc_messages_.clear(); + for (const auto &[_, m] : dbc()->getMessages(-1)) { + dbc_messages_.insert(MessageId{.source = INVALID_SOURCE, .address = m.address}); + } + filterAndSort(); +} + +void MessageList::sortItems(std::vector &list) { + auto compare = [this](const auto &l, const auto &r) { + switch (sort_column_) { + case NAME: return std::tie(l.name, l.id) < std::tie(r.name, r.id); + case SOURCE: return std::tie(l.id.source, l.id.address) < std::tie(r.id.source, r.id.address); + case ADDRESS: return std::tie(l.id.address, l.id.source) < std::tie(r.id.address, r.id.source); + case NODE: return std::tie(l.node, l.id) < std::tie(r.node, r.id); + case FREQ: return std::tie(can->lastMessage(l.id).freq, l.id) < std::tie(can->lastMessage(r.id).freq, r.id); + case COUNT: return std::tie(can->lastMessage(l.id).count, l.id) < std::tie(can->lastMessage(r.id).count, r.id); + default: return false; + } + }; + + if (sort_order_ == ImGuiSortDirection_Descending) + std::stable_sort(list.rbegin(), list.rend(), compare); + else + std::stable_sort(list.begin(), list.end(), compare); +} + +bool MessageList::match(const Item &item) { + if (filters_.empty()) return true; + + bool match = true; + const auto &data = can->lastMessage(item.id); + for (auto it = filters_.cbegin(); it != filters_.cend() && match; ++it) { + const std::string &txt = it->second; + switch (it->first) { + case NAME: { + match = utils::containsCI(item.name, txt); + if (!match) { + const auto m = dbc()->msg(item.id); + match = m && std::any_of(m->sigs.cbegin(), m->sigs.cend(), + [&txt](const auto &s) { return utils::containsCI(s->name, txt); }); + } + break; + } + case SOURCE: + match = parseRange(txt, item.id.source); + break; + case ADDRESS: + match = utils::containsCI(utils::toHexString(item.id.address), txt); + match = match || parseRange(txt, item.id.address, 16); + break; + case NODE: + match = utils::containsCI(item.node, txt); + break; + case FREQ: + match = parseRange(txt, data.freq); + break; + case COUNT: + match = parseRange(txt, data.count); + break; + case DATA: + match = utils::containsCI(utils::toHex(data.dat), txt); + break; + } + } + return match; +} + +bool MessageList::filterAndSort() { + // merge CAN and DBC messages + std::vector all_messages; + all_messages.reserve(can->lastMessages().size() + dbc_messages_.size()); + auto dbc_msgs = dbc_messages_; + for (const auto &[id, m] : can->lastMessages()) { + all_messages.push_back(id); + dbc_msgs.erase(MessageId{.source = INVALID_SOURCE, .address = id.address}); + } + all_messages.insert(all_messages.end(), dbc_msgs.begin(), dbc_msgs.end()); + + std::vector new_items; + new_items.reserve(all_messages.size()); + for (const auto &id : all_messages) { + if (show_inactive_messages || can->isMessageActive(id)) { + auto msg = dbc()->msg(id); + Item item = {.id = id, .name = msg ? msg->name : UNTITLED, .node = msg ? msg->transmitter : std::string()}; + if (match(item)) new_items.emplace_back(item); + } + } + sortItems(new_items); + + if (items != new_items) { + items = std::move(new_items); + changed(); + return true; + } + return false; +} + +void MessageList::msgsReceived(const std::set *new_msgs, bool has_new_ids) { + if (has_new_ids || ((filters_.count(FREQ) || filters_.count(COUNT) || filters_.count(DATA)) && + ++sort_threshold_ == STREAM_UPDATE_FPS)) { + sort_threshold_ = 0; + filterAndSort(); + } +} + +void MessageList::sort(int column, ImGuiSortDirection order) { + if (column != DATA) { + sort_column_ = column; + sort_order_ = order; + filterAndSort(); + } +} + +// MessagesWidget + +MessagesWidget::MessagesWidget() { + std::iota(display_order_.begin(), display_order_.end(), 0); + list_.sort(MessageList::NAME, ImGuiSortDirection_Ascending); + + connections_.push_back(list_.changed.connect([this]() { + current_row_ = -1; // the rows moved + if (current_msg_id_) selectMessage(*current_msg_id_); + updateBytesSectionSize(); + updateTitle(); + })); + + suppressHighlighted(); +} + +std::string MessagesWidget::whatsThis() const { + return R"( + Message View
+ Byte color
+ ■ constantly changing
+ ■ increasing
+ ■ decreasing
+ Shortcuts
+ Horizontal Scrolling:  Shift+Wheel  + )"; +} + +void MessagesWidget::drawToolBar() { + const ImGuiStyle &style = ImGui::GetStyle(); + // Reserve space for View so it remains accessible when other controls overflow. + const std::string clear_label = suppress_clear_text_ + "##suppress_clear"; + std::vector items; + items.push_back({toolbarButtonWidth("Suppress Highlighted"), [this]() { + if (ImGui::Button("Suppress Highlighted")) suppressHighlighted(true); + }, "Suppress Highlighted", [this]() { suppressHighlighted(true); }}); + items.push_back({toolbarButtonWidth(suppress_clear_text_), [this, &clear_label]() { + ImGui::BeginDisabled(!suppress_clear_enabled_); + if (ImGui::Button(clear_label.c_str())) suppressHighlighted(false); + ImGui::EndDisabled(); + disabledItemTooltip("Clear suppressed"); + }, suppress_clear_text_, [this]() { suppressHighlighted(false); }, suppress_clear_enabled_}); + const size_t spacer_index = items.size(); + items.push_back({ImGui::CalcTextSize("Suppress Signals").x + CHECKBOX_SIZE + style.ItemInnerSpacing.x, []() { + bool suppress_defined_signals = settings.suppress_defined_signals; + if (checkBox("Suppress Signals", &suppress_defined_signals)) can->suppressDefinedSignals(suppress_defined_signals); + ImGui::SetItemTooltip("Suppress defined signals"); + }}); + + const float reserved = iconButtonWidth() + style.ItemSpacing.x; + drawToolbar(items, spacer_index, std::max(0.0f, ImGui::GetContentRegionAvail().x - reserved)); + ImGui::SameLine(); + if (iconButton("view_btn", icon::THREE_DOTS, "View...")) ImGui::OpenPopup("menu"); +} + +void MessagesWidget::updateTitle() { + auto stats = std::accumulate( + list_.items.begin(), list_.items.end(), std::pair(), + [](const auto &pair, const auto &item) { + auto m = dbc()->msg(item.id); + return m ? std::make_pair(pair.first + 1, pair.second + m->sigs.size()) : pair; + }); + char buf[128]; + snprintf(buf, sizeof(buf), "%zu Messages (%zu DBC Messages, %zu Signals)", list_.items.size(), stats.first, stats.second); + title_ = buf; +} + +void MessagesWidget::selectMessage(const MessageId &msg_id) { + auto it = std::find_if(list_.items.cbegin(), list_.items.cend(), [&msg_id](auto &item) { return item.id == msg_id; }); + if (it != list_.items.cend()) setCurrentRow(std::distance(list_.items.cbegin(), it)); +} + +void MessagesWidget::setCurrentRow(int row) { + if (row < 0 || row >= (int)list_.items.size()) return; + current_row_ = row; + scroll_to_current_ = true; + const auto &id = list_.items[row].id; + if (!current_msg_id_ || id != *current_msg_id_) { + current_msg_id_ = id; + msgSelectionChanged(*current_msg_id_); + } +} + +void MessagesWidget::suppressHighlighted(bool from_suppress_add) { + int n = from_suppress_add ? can->suppressHighlighted() : (can->clearSuppressed(), 0); + suppress_clear_text_ = n > 0 ? "Clear (" + std::to_string(n) + ")" : "Clear"; + suppress_clear_enabled_ = n > 0; +} + +void MessagesWidget::drawContextMenu() { + if (!dropdown::BeginPopup("menu")) return; + for (int i = 0; i < MessageList::COLUMN_COUNT; ++i) { + const int column = display_order_[i]; + // can't hide the name column + if (dropdown::Item(COLUMN_TITLES[column], nullptr, !hidden_[column], column > 0)) { + pending_hidden_.emplace_back(column, !hidden_[column]); + } + } + ImGui::Separator(); + if (dropdown::Item("Multiline Bytes", nullptr, settings.multiple_lines_hex)) { + setMultiLineBytes(!settings.multiple_lines_hex); + } + if (dropdown::Item("Show Inactive Messages", nullptr, list_.show_inactive_messages)) { + list_.showInactiveMessages(!list_.show_inactive_messages); + } + dropdown::EndPopup(); +} + +void MessagesWidget::setMultiLineBytes(bool multi) { + settings.multiple_lines_hex = multi; + updateBytesSectionSize(); +} + +void MessagesWidget::updateBytesSectionSize() { + int max_bytes = 8; + if (!settings.multiple_lines_hex) { + for (const auto &[_, m] : can->lastMessages()) { + max_bytes = std::max(max_bytes, m.dat.size()); + } + } + bytes_section_bytes_ = max_bytes; +} + +void MessagesWidget::draw() { + drawToolBar(); + drawTable(); + if (std::exchange(header_menu_requested_, false)) ImGui::OpenPopup("menu"); // at the mouse position + drawContextMenu(); +} + +void MessagesWidget::handleKeys() { + if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows) || ImGui::IsAnyItemActive()) return; + const int last = (int)list_.items.size() - 1; + if (last < 0) return; + if (ImGui::IsKeyPressed(ImGuiKey_UpArrow) && current_row_ > 0) { + setCurrentRow(current_row_ - 1); + } else if (ImGui::IsKeyPressed(ImGuiKey_DownArrow) && current_row_ < last) { + setCurrentRow(current_row_ + 1); + } else if (ImGui::IsKeyPressed(ImGuiKey_Home)) { + setCurrentRow(0); + } else if (ImGui::IsKeyPressed(ImGuiKey_End)) { + setCurrentRow(last); + } else if (ImGui::IsKeyPressed(ImGuiKey_PageUp)) { + setCurrentRow(std::max(current_row_ - visible_rows_, 0)); + } else if (ImGui::IsKeyPressed(ImGuiKey_PageDown)) { + setCurrentRow(std::min(current_row_ + visible_rows_, last)); + } +} + +void MessagesWidget::drawTable() { + handleKeys(); + const bool multiple_lines = settings.multiple_lines_hex; + + const ImGuiTableFlags flags = ImGuiTableFlags_Sortable | ImGuiTableFlags_Resizable | ImGuiTableFlags_Reorderable | + ImGuiTableFlags_ScrollX | ImGuiTableFlags_ScrollY | ImGuiTableFlags_BordersInner | + ImGuiTableFlags_Hideable | ImGuiTableFlags_PadOuterX; + // with ScrollX a stretch column needs an explicit inner width + const float bytes_width = bytesCellSize(bytes_section_bytes_, multiple_lines).x; + const float avail_width = ImGui::GetContentRegionAvail().x - (has_scrollbar_y_ ? ImGui::GetStyle().ScrollbarSize : 0); + const float inner_width = std::max(avail_width, fixed_columns_width_ + bytes_width); + if (!ImGui::BeginTable("messages", MessageList::COLUMN_COUNT, flags, ImVec2(0, 0), inner_width)) return; + + // no frozen column: only the header and the filter row stay put + ImGui::TableSetupScrollFreeze(0, 2); + // the widget shows its own header menu; imgui's default one is never drawn, so clear the flag + // TableHeader() leaves behind (it would keep the column header highlighted forever) + ImGuiTable *table = ImGui::GetCurrentTable(); + table->DisableDefaultContextMenu = true; + table->IsContextPopupOpen = false; + for (int i = 0; i < MessageList::COLUMN_COUNT; ++i) { + // with the flipped direction the first click on a section sorts ascending + ImGuiTableColumnFlags column_flags = ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_PreferSortDescending; + float width = DEFAULT_SECTION_SIZE; + if (i == MessageList::NAME) { + column_flags |= ImGuiTableColumnFlags_DefaultSort | ImGuiTableColumnFlags_NoHide; + } else if (i == MessageList::DATA) { + column_flags = ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_NoSort | ImGuiTableColumnFlags_NoResize; + width = 0; + } + ImGui::TableSetupColumn(COLUMN_TITLES[i], column_flags, width); + } + for (const auto &[column, hide] : pending_hidden_) ImGui::TableSetColumnEnabled(column, !hide); + pending_hidden_.clear(); + + if (ImGuiTableSortSpecs *specs = ImGui::TableGetSortSpecs(); specs && specs->SpecsDirty) { + if (specs->SpecsCount > 0) list_.sort(specs->Specs[0].ColumnIndex, flipSortDirection(specs->Specs[0].SortDirection)); + specs->SpecsDirty = false; + if (current_row_ >= 0) scroll_to_current_ = true; // keep the current row visible + } + + drawHeader(); + + const int rows = list_.items.size(); + if (!multiple_lines) { + ImGuiListClipper clipper; + clipper.Begin(rows); + if (scroll_to_current_ && current_row_ >= 0) clipper.IncludeItemByIndex(current_row_); + while (clipper.Step()) { + for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) drawRow(row); + } + } else { + // non-uniform row heights: no clipper + for (int row = 0; row < rows; ++row) drawRow(row); + } + + // for the next frame: the stretch inner width and the page size of handleKeys + const ImGuiTableColumn &data_column = table->Columns[MessageList::DATA]; + fixed_columns_width_ = data_column.IsEnabled ? table->ColumnsGivenWidth - data_column.WidthGiven : 0; + has_scrollbar_y_ = table->InnerWindow->ScrollbarY; + visible_rows_ = std::max(1, (int)(table->InnerWindow->InnerRect.GetHeight() / bytesCellSize(0, multiple_lines).y) - 2); + ImGui::EndTable(); +} + +void MessagesWidget::drawHeader() { + if (tableHeadersRow() >= 0) header_menu_requested_ = true; + // record the visual order and the visibility of the sections for the menu + ImGuiTable *table = ImGui::GetCurrentTable(); + for (int i = 0; i < MessageList::COLUMN_COUNT; i++) { + display_order_[i] = table->DisplayOrderToIndex[i]; + hidden_[i] = !(ImGui::TableGetColumnFlags(i) & ImGuiTableColumnFlags_IsEnabled); + } + + // the filter editors under the header + ImGui::TableNextRow(); + for (int i = 0; i < MessageList::COLUMN_COUNT; i++) { + if (!ImGui::TableSetColumnIndex(i)) continue; + ImGui::PushID(i); + ImGui::SetNextItemWidth(-FLT_MIN); + const std::string placeholder = std::string("Filter ") + COLUMN_TITLES[i]; + if (clearableInput("##filter", &filters_[i], placeholder.c_str())) { + std::map filters; + for (int c = 0; c < MessageList::COLUMN_COUNT; ++c) { + if (!filters_[c].empty()) filters[c] = filters_[c]; + } + list_.setFilters(filters); + } + ImGui::PopID(); + } +} + +void MessagesWidget::drawRow(int row) { + const auto &item = list_.items[row]; + const bool selected = row == current_row_; + const bool inactive = !can->isMessageActive(item.id); + const auto &m = can->lastMessage(item.id); + const bool seen = item.id.source != INVALID_SOURCE; + const bool multiple_lines = settings.multiple_lines_hex; + const float row_height = bytesCellSize(seen ? m.dat.size() : 0, multiple_lines).y - ImGui::GetStyle().CellPadding.y * 2; + ImGui::TableNextRow(); + ImGui::PushID(row); + + bool row_item_submitted = false; + for (int column = 0; column < MessageList::COLUMN_COUNT; ++column) { + if (!ImGui::TableSetColumnIndex(column)) continue; + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x; + const ImRect rect(pos, ImVec2(pos.x + width, pos.y + row_height)); + + const bool row_item = !row_item_submitted; + if (row_item) { + // the row selection spans all columns; submit it in the first visible column so that it is not + // clipped away when the table is scrolled horizontally + row_item_submitted = true; + // rows select on press + if (viewSelectable("##row", selected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_SelectOnClick, ImVec2(0, row_height))) { + setCurrentRow(row); + } + if (selected && scroll_to_current_) { + // only scroll when the row is outside the viewport, and only far enough + const ImGuiWindow *inner = ImGui::GetCurrentTable()->InnerWindow; + const float view_top = inner->InnerClipRect.Min.y + inner->DecoInnerSizeY1; + const float view_bottom = inner->InnerClipRect.Max.y; + if (ImGui::GetItemRectMin().y < view_top) { + ImGui::SetScrollHereY(0.0f); + } else if (ImGui::GetItemRectMax().y > view_bottom) { + ImGui::SetScrollHereY(1.0f); + } + scroll_to_current_ = false; + } + // the tooltip belongs to the name, so only show it while the mouse is over the Name column + const ImGuiTableColumn &name_col = ImGui::GetCurrentTable()->Columns[MessageList::NAME]; + const float mouse_x = ImGui::GetIO().MousePos.x; + if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip) && mouse_x >= name_col.MinX && mouse_x < name_col.MaxX) { + auto msg = dbc()->msg(item.id); + ImGui::BeginTooltip(); + ImGui::TextUnformatted(item.name.c_str()); + if (msg && !msg->comment.empty()) ImGui::TextDisabled("%s", msg->comment.c_str()); + ImGui::EndTooltip(); + } + } + + if (column == MessageList::DATA && seen) { + drawBytesCell(ImGui::GetWindowDrawList(), rect, m.dat, &m.colors, selected, inactive, multiple_lines); + } else { + drawTextCell(ImGui::GetWindowDrawList(), rect, cellText(item, column), selected, inactive); + } + // the Selectable already sized its cell + if (!row_item) ImGui::Dummy(ImVec2(width, row_height)); + } + + ImGui::PopID(); +} diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.h b/openpilot/tools/cabana/ui/widgets/messageswidget.h new file mode 100644 index 000000000..abfd43fb5 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.h @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" + +// the rows of the messages table: every CAN message seen plus the DBC messages not seen yet, filtered +// and sorted. `changed` fires when the rows differ from the last time. +class MessageList { +public: + enum Column { NAME = 0, SOURCE, ADDRESS, NODE, FREQ, COUNT, DATA, COLUMN_COUNT }; + + struct Item { + MessageId id; + std::string name; + std::string node; + bool operator==(const Item &other) const { return id == other.id && name == other.name && node == other.node; } + }; + + MessageList(); + void sort(int column, ImGuiSortDirection order); + void setFilters(const std::map &filters); + void showInactiveMessages(bool show); + bool filterAndSort(); + + std::vector items; + bool show_inactive_messages = true; + Observable<> changed; + +private: + void msgsReceived(const std::set *new_msgs, bool has_new_ids); + void dbcModified(); + void sortItems(std::vector &list); + bool match(const Item &item); + + std::map filters_; + std::set dbc_messages_; + int sort_column_ = NAME; + ImGuiSortDirection sort_order_ = ImGuiSortDirection_Ascending; + int sort_threshold_ = 0; + Connections connections_; +}; + +class MessagesWidget { +public: + MessagesWidget(); + void draw(); // content only; MainWindow does ImGui::Begin/End with the dock title + void selectMessage(const MessageId &message_id); + void suppressHighlighted(bool from_suppress_add = false); + const std::string &title() const { return title_; } + std::string whatsThis() const; + + Observable msgSelectionChanged; + +private: + void drawToolBar(); + void drawTable(); + void drawHeader(); // the header row and the filter editors row, inside the table + void drawRow(int row); + void drawContextMenu(); + void handleKeys(); // up/down move the current row + void setCurrentRow(int row); // scrolls to the row + void updateBytesSectionSize(); + void updateTitle(); + void setMultiLineBytes(bool multi); + + MessageList list_; + std::optional current_msg_id_; + int current_row_ = -1; + bool scroll_to_current_ = false; + int bytes_section_bytes_ = 8; // the minimum width of the stretched DATA section + float fixed_columns_width_ = 0; // width of everything but the stretched DATA section + bool has_scrollbar_y_ = false; + int visible_rows_ = 1; + std::array filters_; + std::array hidden_ = {}; // mirror of the table's enabled columns, refreshed every frame + std::array display_order_; // visual index -> logical column + std::vector> pending_hidden_; // applied inside the table + bool header_menu_requested_ = false; + std::string suppress_clear_text_; + bool suppress_clear_enabled_ = false; + std::string title_ = "MESSAGES"; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc new file mode 100644 index 000000000..d1a8fc83a --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.cc @@ -0,0 +1,87 @@ +#include "tools/cabana/ui/widgets/scrollabletabbar.h" + +#include +#include +#include + +#include "imgui_internal.h" + +namespace { +float scrollButtonsWidth() { + const ImGuiStyle &style = ImGui::GetStyle(); + return (ImGui::GetFrameHeight() + style.ItemSpacing.x) * 2.0f; +} + +void drawScrollButtons(ImGuiTabBar *tab_bar) { + const ImGuiStyle &style = ImGui::GetStyle(); + const float size = ImGui::GetFrameHeight(); + const float max_scroll = std::max(0.0f, tab_bar->WidthAllTabs - tab_bar->BarRect.GetWidth()); + const float start_x = tab_bar->BarRect.Max.x + style.ItemSpacing.x; + const ImVec2 backup_pos = ImGui::GetCursorScreenPos(); + + ImGui::PushItemFlag(ImGuiItemFlags_ButtonRepeat, true); + for (int i = 0; i < 2; ++i) { + const bool left = i == 0; + ImGui::SetCursorScreenPos(ImVec2(start_x + i * (size + style.ItemSpacing.x), tab_bar->BarRect.Min.y)); + ImGui::BeginDisabled(left ? tab_bar->ScrollingTarget <= 0.0f : tab_bar->ScrollingTarget >= max_scroll); + if (ImGui::Button(left ? "###scroll_left" : "###scroll_right", ImVec2(size, size))) { + const float step = (left ? -4.0f : 4.0f) * ImGui::GetFontSize(); + tab_bar->ScrollingTarget = std::clamp(tab_bar->ScrollingTarget + step, 0.0f, max_scroll); + tab_bar->ScrollingAnim = tab_bar->ScrollingTarget; + } + // the icon font glyph sits off center in its padded advance, so the chevron is drawn in the rect + const ImVec2 c((ImGui::GetItemRectMin().x + ImGui::GetItemRectMax().x) * 0.5f, + (ImGui::GetItemRectMin().y + ImGui::GetItemRectMax().y) * 0.5f); + const float h = std::round(ImGui::GetFontSize() * 0.25f); + const float dx = left ? h * 0.5f : -h * 0.5f; + ImDrawList *painter = ImGui::GetWindowDrawList(); + painter->PathLineTo(ImVec2(c.x + dx, c.y - h)); + painter->PathLineTo(ImVec2(c.x - dx, c.y)); + painter->PathLineTo(ImVec2(c.x + dx, c.y + h)); + painter->PathStroke(ImGui::GetColorU32(ImGuiCol_Text), ImDrawFlags_None, 1.5f); + ImGui::EndDisabled(); + } + ImGui::PopItemFlag(); + ImGui::SetCursorScreenPos(backup_pos); +} + +struct ScrollableTabBar { ImGuiTabBar *tab_bar; bool overflowing; }; +std::vector scrollable_tab_bars; +} // namespace + +bool beginScrollableTabBar(const char *str_id, ImGuiTabBarFlags flags) { + // the buttons take their room from the bar when the tabs overflowed last frame + ImGuiWindow *window = ImGui::GetCurrentWindow(); + ImGuiTabBar *prev_tab_bar = ImGui::TabBarFindByID(window->GetID(str_id)); + const bool overflowing = prev_tab_bar && prev_tab_bar->WidthAllTabsIdeal > prev_tab_bar->BarRect.GetWidth() + 1.0f; + const float backup_work_max_x = window->WorkRect.Max.x; + if (overflowing) window->WorkRect.Max.x -= scrollButtonsWidth(); + const bool open = ImGui::BeginTabBar(str_id, flags | ImGuiTabBarFlags_FittingPolicyScroll | ImGuiTabBarFlags_NoTabListScrollingButtons); + window->WorkRect.Max.x = backup_work_max_x; + if (open) scrollable_tab_bars.push_back({ImGui::GetCurrentTabBar(), overflowing}); + return open; +} + +void endScrollableTabBar() { + ImGui::EndTabBar(); + const ScrollableTabBar bar = scrollable_tab_bars.back(); + scrollable_tab_bars.pop_back(); + if (!bar.overflowing) return; + drawScrollButtons(bar.tab_bar); + + // the wheel scrolls the tabs while the pointer is over them: a two finger swipe on a touchpad, or a + // mouse wheel like a window that only scrolls sideways. Owning the wheel keeps the window behind still + ImGuiTabBar *tab_bar = bar.tab_bar; + if (ImGui::IsWindowHovered() && ImGui::IsMouseHoveringRect(tab_bar->BarRect.Min, tab_bar->BarRect.Max)) { + ImGui::SetKeyOwner(ImGuiKey_MouseWheelX, tab_bar->ID); + ImGui::SetKeyOwner(ImGuiKey_MouseWheelY, tab_bar->ID); + const ImGuiIO &io = ImGui::GetIO(); + const float wheel = io.MouseWheelH + io.MouseWheel; + if (wheel != 0.0f) { + const float max_scroll = std::max(0.0f, tab_bar->WidthAllTabs - tab_bar->BarRect.GetWidth()); + const float step = std::floor(ImGui::GetFontSize() * 2.0f); + tab_bar->ScrollingTarget = std::clamp(tab_bar->ScrollingTarget - wheel * step, 0.0f, max_scroll); + tab_bar->ScrollingAnim = tab_bar->ScrollingTarget; + } + } +} diff --git a/openpilot/tools/cabana/ui/widgets/scrollabletabbar.h b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.h new file mode 100644 index 000000000..6c119a7d7 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/scrollabletabbar.h @@ -0,0 +1,8 @@ +#pragma once + +#include "imgui.h" + +// a tab bar that scrolls with a pair of chevron buttons at its right end when the tabs overflow, in place of +// imgui's small arrows. Use like BeginTabBar/EndTabBar, the fitting policy is always scroll +bool beginScrollableTabBar(const char *str_id, ImGuiTabBarFlags flags = 0); +void endScrollableTabBar(); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc new file mode 100644 index 000000000..bc8b9c4d2 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -0,0 +1,1019 @@ +#include "tools/cabana/ui/widgets/signalview.h" + +#include +#include +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" +#include "tools/cabana/ui/icons.h" + +namespace { +constexpr float INDENTATION = 20.0f; +constexpr float H_MARGIN = 3.0f; +constexpr float V_MARGIN = 2.0f; +// signal rows are taller than a frame so the sparklines have room to read +constexpr float SIGNAL_ROW_EXTRA = 5.0f; // the tool button in the row makes it 27 px tall at the 16 px font +constexpr float SIGNAL_ROW_SCALE = 1.25f; +constexpr float FILTER_WIDTH = 160.0f; +constexpr float SPARKLINE_SLIDER_WIDTH = 120.0f; +// WARNING: increasing the maximum range can result in severe performance degradation. +// 30s is a reasonable value at present. +constexpr int SPARKLINE_RANGE_MAX = 30; +constexpr float LABEL_FONT = 12.0f; // Inter needs 12 px for 8 px tall digits +constexpr float MINMAX_FONT = 10.0f; +constexpr int COLOR_LABEL_WIDTH = 18; + +std::string signalTypeToString(cabana::Signal::Type type) { + if (type == cabana::Signal::Type::Multiplexor) return "Multiplexor Signal"; + else if (type == cabana::Signal::Type::Multiplexed) return "Multiplexed Signal"; + else return "Normal Signal"; +} + +std::string multiplexIndicator(const cabana::Signal *sig) { + return sig->type == cabana::Signal::Type::Multiplexor ? std::string(" M ") : " m" + std::to_string(sig->multiplex_value) + " "; +} + +std::string nameText(const SignalModel::Item *item) { + return item->type == SignalModel::Item::Sig ? item->sig->name : item->title; +} + +float rowHeight() { + return ImGui::GetFrameHeight(); +} + +// only the top level signal rows are taller; the expanded sub-rows keep the default row height +float signalRowHeight() { + return std::floor((ImGui::GetFrameHeight() + SIGNAL_ROW_EXTRA) * SIGNAL_ROW_SCALE); +} + +// column 0 has a double validator. Returns true when the cell was clicked (row selection); the two cells +// of a row share the row's id scope, so each editor needs its own column id +bool valueDescriptionEditor(int column, std::string *text) { + ImGui::PushID(column); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + validatedInput("##edit", text, column == 0 ? doubleValidator : nullptr); + ImGui::PopStyleVar(); + const bool clicked = ImGui::IsItemActivated() || ImGui::IsItemClicked(); + ImGui::PopID(); + return clicked; +} + +} // namespace + +SignalModel::SignalModel() : root_(new Item) { + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(dbc()->msgUpdated.connect([this](MessageId id) { handleMsgChanged(id); })); + connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { handleMsgChanged(id); })); + connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { handleSignalRemoved(sig); })); +} + +void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) { + Item *parent_item = new Item{.type = Item::Sig, .parent = root_item, .sig = sig, .title = sig->name}; + root_item->children.insert(root_item->children.begin() + pos, parent_item); + std::string titles[]{"Name", "Size", "Receiver Nodes", "Little Endian", "Signed", "Offset", "Factor", "Type", + "Multiplex Value", "Extra Info", "Unit", "Comment", "Minimum Value", "Maximum Value", "Value Table"}; + for (int i = 0; i < std::size(titles); ++i) { + auto item = new Item{.type = (Item::Type)(i + Item::Name), .parent = parent_item, .sig = sig, .title = titles[i]}; + parent_item->children.push_back(item); + if (item->type == Item::ExtraInfo) { + parent_item = item; + } + } +} + +void SignalModel::setMessage(const MessageId &id) { + msg_id_ = id; + filter_str_ = ""; + refresh(); +} + +void SignalModel::setFilter(const std::string &txt) { + filter_str_ = txt; + refresh(); +} + +void SignalModel::refresh() { + root_.reset(new SignalModel::Item); + if (auto msg = dbc()->msg(msg_id_)) { + for (auto s : msg->getSignals()) { + if (filter_str_.empty() || utils::containsCI(s->name, filter_str_)) { + insertItem(root_.get(), root_->children.size(), s); + } + } + } + modelReset(); + rowsChanged(); +} + +bool SignalModel::isEnabled(const Item *item) { + return !(item->type == Item::MultiplexValue && item->sig->type != cabana::Signal::Type::Multiplexed); +} + +bool SignalModel::isCheckable(const Item *item) { + return item->type == Item::Endian || item->type == Item::Signed; +} + +bool SignalModel::isEditable(const Item *item) { + return item->children.empty() && !isCheckable(item); +} + +int SignalModel::signalRow(const cabana::Signal *sig) const { + for (int i = 0; i < root_->children.size(); ++i) { + if (root_->children[i]->sig == sig) return i; + } + return -1; +} + +std::string SignalModel::valueText(const Item *item) const { + switch (item->type) { + case Item::Sig: return item->sig_val; + case Item::Name: return item->sig->name; + case Item::Size: return std::to_string(item->sig->size); + case Item::Node: return item->sig->receiver_name; + case Item::SignalType: return signalTypeToString(item->sig->type); + case Item::MultiplexValue: return std::to_string(item->sig->multiplex_value); + case Item::Offset: return doubleToString(item->sig->offset); + case Item::Factor: return doubleToString(item->sig->factor); + case Item::Unit: return item->sig->unit; + case Item::Comment: return item->sig->comment; + case Item::Min: return doubleToString(item->sig->min); + case Item::Max: return doubleToString(item->sig->max); + case Item::Desc: { + std::string val_desc; + for (auto &[val, desc] : item->sig->val_desc) { + if (!val_desc.empty()) val_desc += " "; + val_desc += utils::toString(val) + " \"" + desc + "\""; + } + return val_desc; + } + default: return {}; + } +} + +bool SignalModel::setData(Item *item, const ItemValue &value) { + cabana::Signal s = *item->sig; + switch (item->type) { + case Item::Name: s.name = value.toString(); break; + case Item::Size: s.size = value.toInt(); break; + case Item::Node: s.receiver_name = utils::trimmed(value.toString()); break; + case Item::SignalType: s.type = (cabana::Signal::Type)value.toInt(); break; + case Item::MultiplexValue: s.multiplex_value = value.toInt(); break; + case Item::Endian: s.is_little_endian = value.toBool(); break; + case Item::Signed: s.is_signed = value.toBool(); break; + case Item::Offset: s.offset = value.toDouble(); break; + case Item::Factor: s.factor = value.toDouble(); break; + case Item::Unit: s.unit = value.toString(); break; + case Item::Comment: s.comment = value.toString(); break; + case Item::Min: s.min = value.toDouble(); break; + case Item::Max: s.max = value.toDouble(); break; + case Item::Desc: s.val_desc = value.toValueDescription(); break; + default: return false; + } + return saveSignal(item->sig, s); +} + +bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) { + auto msg = dbc()->msg(msg_id_); + if (s.name != origin_s->name && msg->sig(s.name) != nullptr) { + std::string text = "There is already a signal with the same name '" + s.name + "'"; + MessageBox::warning("Failed to save signal", text); + return false; + } + + if (s.is_little_endian != origin_s->is_little_endian) { + s.start_bit = flipBitPos(s.start_bit); + } + UndoStack::instance()->push(new EditSignalCommand(msg_id_, origin_s, s)); + return true; +} + +void SignalModel::handleMsgChanged(MessageId id) { + if (id.address == msg_id_.address) { + refresh(); + } +} + +void SignalModel::handleSignalAdded(MessageId id, const cabana::Signal *sig) { + if (id == msg_id_) { + if (filter_str_.empty()) { + int i = dbc()->msg(msg_id_)->indexOf(sig); + insertItem(root_.get(), i, sig); + rowsChanged(); + } else if (utils::containsCI(sig->name, filter_str_)) { + refresh(); + } + } +} + +void SignalModel::handleSignalUpdated(const cabana::Signal *sig) { + if (int row = signalRow(sig); row != -1) { + if (filter_str_.empty()) { + // move row when the order changes. + int to = dbc()->msg(msg_id_)->indexOf(sig); + if (to != row) { + auto item = root_->children[row]; + root_->children.erase(root_->children.begin() + row); + root_->children.insert(root_->children.begin() + to, item); + } + } + } +} + +void SignalModel::handleSignalRemoved(const cabana::Signal *sig) { + if (int row = signalRow(sig); row != -1) { + delete root_->children[row]; + root_->children.erase(root_->children.begin() + row); + rowsChanged(); + } +} + +float SignalView::textWidth(const std::string &text, float font_size) { + ImFont *font = ImGui::GetFont(); + if (!font || ImGui::GetFontSize() <= 0) return 0; // no frame rendered yet + return font->CalcTextSizeA(font_size > 0 ? font_size : ImGui::GetFontSize(), FLT_MAX, 0.0f, text.c_str()).x; +} + +float SignalView::nameColumnWidth(const SignalModel::Item *item, float widget_width, const std::string &text) const { + float spacing = INDENTATION + COLOR_LABEL_WIDTH + H_MARGIN * 4; + std::string txt = text; + if (item->type == SignalModel::Item::Sig && item->sig->type != cabana::Signal::Type::Normal) { + txt += multiplexIndicator(item->sig); + spacing += H_MARGIN * 2; + } + const float preferred = ImGui::CalcTextSize(txt.c_str()).x + spacing; + return std::min(widget_width * (compact_ ? 0.6f : 0.4f), preferred); +} + +void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const SignalModel::Item *item, int column, + bool selected, const std::string &text, float viewport_x) const { + const float h_margin = H_MARGIN; + const float v_margin = V_MARGIN; + + ImRect rect(option_rect.Min.x + h_margin, option_rect.Min.y + v_margin, option_rect.Max.x - h_margin, option_rect.Max.y - v_margin); + // selection background is painted by the row's Selectable + const ImU32 text_color = ImGui::GetColorU32(selected ? palette().text_selected : palette().text); + + if (column == 0) { + if (item->type == SignalModel::Item::Sig) { + // color label + ImRect icon_rect(rect.Min.x, rect.Min.y, rect.Min.x + COLOR_LABEL_WIDTH, rect.Max.y); + painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(item->sig->color.darker(item->highlight ? 125 : 0)), ImGui::GetStyle().FrameRounding); + drawText(painter, icon_rect, std::to_string(item->row() + 1).c_str(), item->highlight ? IM_COL32_WHITE : IM_COL32_BLACK, + nullptr, LABEL_FONT); + + rect.Min.x = icon_rect.Max.x + h_margin * 2; + // multiplexer indicator + if (item->sig->type != cabana::Signal::Type::Normal) { + const std::string indicator = multiplexIndicator(item->sig); + ImRect indicator_rect(rect.Min.x, rect.Min.y, rect.Min.x + ImGui::CalcTextSize(indicator.c_str()).x, rect.Max.y); + painter->AddRectFilled(indicator_rect.Min, indicator_rect.Max, IM_COL32(160, 160, 164, 255), ImGui::GetStyle().FrameRounding); + drawElidedText(painter, indicator_rect, indicator, IM_COL32_WHITE, false); + rect.Min.x = indicator_rect.Max.x + h_margin * 2; + } + } else { + rect.Min.x = viewport_x + INDENTATION + COLOR_LABEL_WIDTH + h_margin * 3; + } + + // name + if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, false); + } else if (column == 1) { + if (!compact_ && !item->sparkline.isEmpty()) { + const ImVec2 sparkline_size = item->sparkline.size; + item->sparkline.draw(painter, rect.Min, selected ? text_color : 0); + // min-max value + rect.Min.x += sparkline_size.x + 1; + float value_adjust = 10; + if (item->highlight || selected) { + painter->AddLine(rect.Min, ImVec2(rect.Min.x, rect.Max.y), text_color); + rect.Min.x += 5; + rect.Min.y -= v_margin; + rect.Max.y += v_margin; + std::string min = utils::toString(item->sparkline.min_val); + std::string max = utils::toString(item->sparkline.max_val); + drawText(painter, rect, max.c_str(), text_color, nullptr, MINMAX_FONT, ImVec2(0.0f, 0.0f)); + drawText(painter, rect, min.c_str(), text_color, nullptr, MINMAX_FONT, ImVec2(0.0f, 1.0f)); + value_adjust = std::max(textWidth(min, MINMAX_FONT), textWidth(max, MINMAX_FONT)) + 5; + } else if (item->sig->type == cabana::Signal::Type::Multiplexed) { + // display freq of multiplexed signal + char freq[64]; + snprintf(freq, sizeof(freq), "%.2g hz", item->sparkline.freq()); + ImRect freq_rect(rect.Min.x + 5, rect.Min.y, rect.Max.x, rect.Max.y); + drawText(painter, freq_rect, freq, text_color, nullptr, LABEL_FONT, ImVec2(0.0f, 0.5f)); + value_adjust = textWidth(freq, LABEL_FONT) + 10; + } + // signal value + rect.Min.x += value_adjust; + } + // Monospaced digits prevent the value width from changing during playback. + rect.Max.x -= button_size_.x; + pushMonoFont(ImGui::GetFontSize()); + if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, true); + popMonoFont(); + } +} + +void SignalView::drawEditor(SignalModel::Item *item) { + const bool take_focus = focus_item_ == item; + if (take_focus) focus_item_ = nullptr; + if (item->type == SignalModel::Item::Name || item->type == SignalModel::Item::Node || item->type == SignalModel::Item::Offset || + item->type == SignalModel::Item::Factor || item->type == SignalModel::Item::MultiplexValue || + item->type == SignalModel::Item::Min || item->type == SignalModel::Item::Max) { + ImGuiInputTextCallback validator = nullptr; + if (item->type == SignalModel::Item::Name) validator = nameValidator; + else if (item->type == SignalModel::Item::Node) validator = nodeValidator; + else validator = doubleValidator; + + drawLineEditor(item, validator, take_focus); + } else if (item->type == SignalModel::Item::Size) { + if (take_focus) { + edit_int_ = item->sig->size; + ImGui::SetKeyboardFocusHere(); + } + bool changed = inputInt("##editor", &edit_int_, 1, 100, ImGuiInputTextFlags_AutoSelectAll); + if (ImGui::IsItemDeactivated() && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + open_item_ = nullptr; // InputInt already reverted the value; only the commit has to be skipped + return; + } + if (ImGui::IsItemDeactivatedAfterEdit() || (changed && !ImGui::IsItemActive())) { + edit_int_ = std::clamp(edit_int_, 1, CAN_MAX_DATA_BYTES); + queueCommit(item, edit_int_); + } + // Enter, Escape and a click outside close the editor; the step buttons keep it open + if (ImGui::IsItemDeactivated() && (!ImGui::IsItemHovered() || ImGui::IsKeyPressed(ImGuiKey_Enter, false) || + ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false))) { + open_item_ = nullptr; + } + } else if (item->type == SignalModel::Item::SignalType) { + // the combo editor is closed by Enter and Escape; the cell is painted as text again next frame + if (combo_focused_ && (ImGui::IsKeyPressed(ImGuiKey_Escape, false) || ImGui::IsKeyPressed(ImGuiKey_Enter, false) || + ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false))) { + open_item_ = nullptr; + combo_focused_ = false; + return; + } + std::vector> items; + items.emplace_back(signalTypeToString(cabana::Signal::Type::Normal), (int)cabana::Signal::Type::Normal); + if (!dbc()->msg(model_.msgId())->multiplexor) { + items.emplace_back(signalTypeToString(cabana::Signal::Type::Multiplexor), (int)cabana::Signal::Type::Multiplexor); + } else if (item->sig->type != cabana::Signal::Type::Multiplexor) { + items.emplace_back(signalTypeToString(cabana::Signal::Type::Multiplexed), (int)cabana::Signal::Type::Multiplexed); + } + std::vector names; + int current = -1; // -1 when the current type is not an item (Multiplexor) + for (int i = 0; i < items.size(); ++i) { + names.push_back(items[i].first.c_str()); + if (items[i].second == (int)item->sig->type) current = i; + } + const ImGuiID popup_id = ImHashStr("##ComboPopup", 0, ImGui::GetID("##editor")); + if (take_focus) ImGui::SetKeyboardFocusHere(); + if (dropdown::Combo("##editor", ¤t, names.data(), names.size())) { + queueCommit(item, items[current].second); + open_item_ = nullptr; // commit and close the editor + } + combo_focused_ = ImGui::IsItemFocused() || ImGui::IsPopupOpen(popup_id, ImGuiPopupFlags_None); + if (!take_focus && !combo_focused_) open_item_ = nullptr; // the editor is closed when it loses the focus + } else if (item->type == SignalModel::Item::Desc) { + ImGui::PushStyleColor(ImGuiCol_Header, (ImU32)0); + const bool clicked = ImGui::Selectable("##editor", false, 0, ImVec2(0, rowHeight())); + ImGui::PopStyleColor(); + drawElidedText(ImGui::GetWindowDrawList(), ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()), model_.valueText(item), + ImGui::GetColorU32(ImGuiCol_Text), false); + if (clicked || take_focus) { + desc_dlg_ = std::make_unique(item->sig->val_desc); + desc_dlg_->title = item->sig->name; + desc_sig_ = item->sig; + } + } else { + // plain text input, no validator + drawLineEditor(item, nullptr, take_focus); + } +} + +void SignalView::commitEditor() { + SignalModel::Item *item = editing_item_; + std::string text = edit_text_; + closeEditor(); + if (item && validateEditor(item, text) == ValidState::Acceptable) { + queueCommit(item, text); + } +} + +void SignalView::closeEditor() { + editing_item_ = open_item_ = focus_item_ = nullptr; + editor_active_ = refocus_editor_ = enter_pressed_ = combo_focused_ = false; + pending_commit_ = nullptr; // the items it captured are deleted by the caller +} + +// validate the editor of `item`; mutates `text` like the name validator does (spaces -> '_') +ValidState SignalView::validateEditor(const SignalModel::Item *item, std::string &text) { + if (item->type == SignalModel::Item::Name) return validateName(text); + if (item->type == SignalModel::Item::Node) return validateNodes(text); + if (item->type == SignalModel::Item::Offset || item->type == SignalModel::Item::Factor || + item->type == SignalModel::Item::MultiplexValue || item->type == SignalModel::Item::Min || + item->type == SignalModel::Item::Max) { + return validateDouble(text); + } + return ValidState::Acceptable; // no validator +} + +// Enter and focus out only commit when the validator reports Acceptable (an Intermediate or Invalid value +// keeps the editor open with the typed text and commits nothing), Escape reverts to the value the editor +// was opened with. +void SignalView::drawLineEditor(SignalModel::Item *item, ImGuiInputTextCallback validator, bool take_focus) { + const bool editing = editing_item_ == item; + const bool was_active = editing && editor_active_; // the editor had the focus at the end of the last frame + + std::string text = editing ? edit_text_ : model_.valueText(item); + if (take_focus) ImGui::SetKeyboardFocusHere(); + if (editing && refocus_editor_) { + ImGui::SetKeyboardFocusHere(); // keep the focus when the input is not acceptable + refocus_editor_ = false; + } + validatedInput("##editor", &text, validator, "", ImGuiInputTextFlags_AutoSelectAll); + if (ImGui::IsItemActivated()) editing_item_ = item; + if (editing_item_ != item) return; + + edit_text_ = text; + editor_active_ = ImGui::IsItemActive(); + if (was_active) { + if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + // InputText already reverted the text; only the commit has to be skipped + editing_item_ = open_item_ = nullptr; + enter_pressed_ = false; + return; + } + if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) { + enter_pressed_ = true; + } + } + if (ImGui::IsItemDeactivated()) { + const bool by_enter = std::exchange(enter_pressed_, false); + if (!ImGui::IsItemDeactivatedAfterEdit()) { + editing_item_ = open_item_ = nullptr; // nothing was typed, nothing to commit + } else if (validateEditor(item, edit_text_) == ValidState::Acceptable) { + queueCommit(item, edit_text_); + editing_item_ = open_item_ = nullptr; + } else if (by_enter) { + refocus_editor_ = true; // the editor stays open with the text the user typed + } else { + editing_item_ = open_item_ = nullptr; + } + } +} + +void SignalView::queueCommit(SignalModel::Item *item, const ItemValue &value) { + pending_commit_ = [this, item, value]() { model_.setData(item, value); }; +} + +void SignalView::drawValueDescriptionDlg() { + if (!desc_dlg_) return; + if (desc_dlg_->draw()) return; + + if (desc_dlg_->accepted) { + // the dialog closed: apply to the Desc item of the edited signal + for (auto sig_item : model_.root()->children) { + if (sig_item->sig != desc_sig_) continue; + for (auto child : sig_item->children) { + if (child->type != SignalModel::Item::ExtraInfo) continue; + for (auto extra : child->children) { + if (extra->type == SignalModel::Item::Desc) queueCommit(extra, desc_dlg_->val_desc); + } + } + } + } + desc_dlg_.reset(); + desc_sig_ = nullptr; +} + +static ImVec2 indexButtonsSize(float button) { + const auto &style = ImGui::GetStyle(); + // Include the value-to-button gap; the extra padding balances the inset icon glyphs. + return ImVec2(button * 2 + style.ItemInnerSpacing.x * 2 + style.FramePadding.x, button); +} + +SignalView::SignalView(ChartsWidget *charts) : charts_(charts) { + settings.sparkline_range = std::clamp(settings.sparkline_range, 1, SPARKLINE_RANGE_MAX); + + // Reserve button space for updateState() calls before the first draw (no frame yet: derive the frame height). + button_size_ = indexButtonsSize(UI_FONT_SIZE + ImGui::GetStyle().FramePadding.y * 2.0f); + updateToolBar(); + + connections_.push_back(model_.rowsChanged.connect([this]() { rowsChanged(); })); + // a reset closes the open editors; the items they point at are deleted by refresh() + connections_.push_back(model_.modelReset.connect([this]() { + closeEditor(); + // the visible range is computed while drawing; reset it to the top so the sparklines are ready in the + // frame that paints them + if (first_visible_row_ != -1) { + last_visible_row_ = std::min(model_.rowCount() - 1, last_visible_row_ - first_visible_row_); + first_visible_row_ = 0; + } + })); + connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); + // the sig pointers die with the signal + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { + if (desc_sig_ == sig) desc_sig_ = nullptr; + if ((editing_item_ && editing_item_->sig == sig) || (open_item_ && open_item_->sig == sig) || + (focus_item_ && focus_item_->sig == sig)) closeEditor(); + handleSignalRemoved(sig); + })); + connections_.push_back(dbc()->fileChanged.connect([this]() { + desc_sig_ = nullptr; + closeEditor(); + handleSignalRemoved(nullptr); + })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); +} + +std::string SignalView::whatsThis() const { + return R"( + Signal view
+ )"; +} + +void SignalView::setMessage(const MessageId &id) { + filter_edit_.clear(); + model_.setMessage(id); +} + +void SignalView::rowsChanged() { + updateToolBar(); + updateChartState(); + updateState(); +} + +void SignalView::rowClicked(SignalModel::Item *item) { + if (item->type == SignalModel::Item::Sig || item->type == SignalModel::Item::ExtraInfo) { + item->expanded = !item->expanded; + } +} + +void SignalView::selectSignal(const cabana::Signal *sig, bool expand) { + if (int row = model_.signalRow(sig); row != -1) { + auto item = model_.root()->children[row]; + if (expand) { + item->expanded = !item->expanded; + } + scroll_to_sig_ = sig; // scroll the signal to the top + current_sig_ = sig; + current_type_ = SignalModel::Item::Sig; + } +} + +void SignalView::updateChartState() { + for (auto item : model_.root()->children) { + item->chart_opened = charts_->hasSignal(model_.msgId(), item->sig); + } +} + +void SignalView::signalHovered(const cabana::Signal *sig) { + auto &children = model_.root()->children; + for (int i = 0; i < children.size(); ++i) { + children[i]->highlight = children[i]->sig == sig; + } +} + +void SignalView::updateToolBar() { + signal_count_lb_ = "Signals: " + std::to_string(model_.rowCount()); + sparkline_label_ = utils::formatSeconds(settings.sparkline_range); +} + +void SignalView::setSparklineRange(int value) { + settings.sparkline_range = value; + updateToolBar(); + updateState(); +} + +void SignalView::handleSignalAdded(MessageId id, const cabana::Signal *sig) { + if (id.address == model_.msgId().address) { + selectSignal(sig); + } +} + +void SignalView::handleSignalUpdated(const cabana::Signal *sig) { + if (int row = model_.signalRow(sig); row != -1) + updateState(); +} + +void SignalView::handleSignalRemoved(const cabana::Signal *sig) { + if (!sig || current_sig_ == sig) { + // the current index moves to the row that took the removed one, or to the last row + current_sig_ = nullptr; + auto &children = model_.root()->children; + if (sig && !children.empty() && current_row_ >= 0) { + current_sig_ = children[std::min(current_row_, children.size() - 1)]->sig; + current_type_ = SignalModel::Item::Sig; + } + } + if (!sig || scroll_to_sig_ == sig) scroll_to_sig_ = nullptr; + if (!sig || hovered_sig_ == sig) hovered_sig_ = nullptr; +} + +float SignalView::widestValueWidth(const cabana::Signal *sig) { + const double raw_max = sig->is_signed ? std::ldexp(1.0, sig->size - 1) - 1 : std::ldexp(1.0, sig->size) - 1; + const double raw_min = sig->is_signed ? -std::ldexp(1.0, sig->size - 1) : 0.0; + float width = 0; + pushMonoFont(ImGui::GetFontSize()); + for (double raw : {raw_min, raw_max}) { + width = std::max(width, textWidth(sig->formatValue(raw * sig->factor + sig->offset))); + } + for (const auto &[_, desc] : sig->val_desc) { + width = std::max(width, textWidth(desc)); + } + popMonoFont(); + return width; +} + +void SignalView::updateState(const std::set *msgs) { + const auto &last_msg = can->lastMessage(model_.msgId()); + if (model_.rowCount() == 0 || (msgs && !msgs->count(model_.msgId())) || last_msg.dat.size() == 0) return; + + // sized for the widest value the signals can produce, not the widest one in the last message: sizing + // to the current values moved the sparklines every time a value changed length + float max_value_width = 0; + for (auto item : model_.root()->children) { + double value = 0; + if (item->sig->getValue(last_msg.dat.data(), last_msg.dat.size(), &value)) { + item->sig_val = item->sig->formatValue(value); + } + max_value_width = std::max(max_value_width, widestValueWidth(item->sig)); + } + + if (!compact_ && first_visible_row_ != -1 && last_visible_row_ != -1 && last_visible_row_ < model_.rowCount()) { + const float min_max_width = textWidth("-000.00", MINMAX_FONT) + 5; + float available_width = value_column_width_ - button_size_.x; + float value_width = std::min(max_value_width + min_max_width, available_width / 2); + ImVec2 size(std::floor(available_width - value_width), + std::floor(signalRowHeight() - V_MARGIN * 2)); + + // the window ends at the playback clock, not at the last message: its timestamp only moves when a + // message of this id arrives, so a slow message held the sparkline still for several updates and + // then jumped it, which reads as a hitch at the message rate + const double window_end = can->currentSec(); + // a little data from before the window: the sparkline clips it, so the oldest samples and their + // points slide off the left edge instead of disappearing the moment they age out + const double lead_in = settings.sparkline_range * 0.05; + // plain locals: capturing structured bindings in a lambda is C++20 + const auto range = can->eventsInRange(model_.msgId(), std::make_pair(window_end - settings.sparkline_range - lead_in, window_end)); + const CanEventIter first = range.first, last = range.second; + std::vector> futures; + for (int i = first_visible_row_; i <= last_visible_row_; ++i) { + auto item = model_.root()->children[i]; + futures.push_back(ThreadPool::instance().run([item, first, last, size, window_end]() { + item->sparkline.update(item->sig, first, last, settings.sparkline_range, size, window_end); + })); + } + for (auto &f : futures) f.get(); + } +} + +// the sparkline label, the range slider and the collapse button +float SignalView::toolBarRightWidth(const std::string &range_label) { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::CalcTextSize(range_label.c_str()).x + style.ItemSpacing.x + SPARKLINE_SLIDER_WIDTH + style.ItemSpacing.x + iconButtonWidth(); +} + +// the width at which the tool bar stops squishing: the signal count and the filter box on the left, the +// sparkline controls on the right. Padding is supplied by the message panel. +float SignalView::minimumWidth() { + const ImGuiStyle &style = ImGui::GetStyle(); + const float left_width = ImGui::CalcTextSize("Signals: 000").x + style.ItemSpacing.x + FILTER_WIDTH; + // formatSeconds is mm:ss for every value the range slider allows + return left_width + style.ItemSpacing.x + toolBarRightWidth("00:00") + style.WindowPadding.x * 2; +} + +float SignalView::minimumHeight() { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::GetFrameHeightWithSpacing() + signalRowHeight() * 4 + + (style.WindowPadding.y + style.ChildBorderSize + CONTROL_OUTLINE_PADDING) * 2; +} + +void SignalView::draw() { + ImGui::PushStyleColor(ImGuiCol_ChildBg, palette().surface); + if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + ImGui::EndChild(); + ImGui::PopStyleColor(); + return; + } + + const ImGuiStyle &style = ImGui::GetStyle(); + const float width = ImGui::GetContentRegionAvail().x; + compact_ = width < minimumWidth() - style.WindowPadding.x * 2; + const float count_width = ImGui::CalcTextSize(signal_count_lb_.c_str()).x; + // The standard overflow button keeps the range and collapse actions reachable on narrow panels. + const float filter_width = compact_ ? std::clamp(width - count_width - iconButtonWidth() - style.ItemSpacing.x * 2, + 1.0f, FILTER_WIDTH) : FILTER_WIDTH; + std::vector items; + items.push_back({count_width, [this]() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(signal_count_lb_.c_str()); + }}); + items.back().in_menu = false; + items.push_back({filter_width, [this, filter_width]() { + ImGui::SetNextItemWidth(filter_width); + if (clearableInput("##filter_edit", &filter_edit_, "Filter Signal", nonWhitespaceValidator)) { + model_.setFilter(filter_edit_); + } + }}); + items.push_back({toolBarRightWidth(sparkline_label_) - iconButtonWidth() - style.ItemSpacing.x, [this]() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(sparkline_label_.c_str()); + ImGui::SameLine(); + int range = settings.sparkline_range; + if (fusionSliderInt("##sparkline_range_slider", &range, 1, SPARKLINE_RANGE_MAX, SPARKLINE_SLIDER_WIDTH)) { + setSparklineRange(range); + } + ImGui::SetItemTooltip("Sparkline time range"); + }}); + items.push_back(toolbarAction("collapse_all", icon::ARROWS_COLLAPSE, "Collapse All", [this]() { collapseAll(); })); + drawToolbar(items, 2); + + drawTree(); + drawValueDescriptionDlg(); + // model changes run after the tree is drawn: dbc()->signalUpdated/signalRemoved reorder or delete the rows + if (pending_commit_) std::exchange(pending_commit_, nullptr)(); + if (pending_action_) std::exchange(pending_action_, nullptr)(); + current_row_ = model_.signalRow(current_sig_); // used when the row is removed + + ImGui::EndChild(); + ImGui::PopStyleColor(); +} + +void SignalView::collapseAll() { + commitEditor(); // the editor loses the focus, which commits it + for (auto item : model_.root()->children) { + item->expanded = false; + for (auto child : item->children) child->expanded = false; + } +} + +void SignalView::drawTree() { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); + // Keep the toolbar fixed; only the signal rows scroll within the remaining space. + const bool visible = beginControlChild("tree", ImVec2(0, 0)); + if (visible) { + button_size_ = indexButtonsSize(iconButtonWidth()); + DrawContext ctx{ImGui::GetWindowDrawList(), ImGui::GetCursorScreenPos().x, ImGui::GetContentRegionAvail().x, rowHeight()}; + // the press that closes an open editor is consumed by the focus change, the index widgets never see it + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) editor_open_on_press_ = open_item_ != nullptr; + + int first_visible = -1, last_visible = -1; + auto &children = model_.root()->children; + // Measure before painting so resizing never leaves the old columns for a frame. + auto measure = [&](auto &&self, SignalModel::Item *item) -> void { + ctx.name_width = std::max(ctx.name_width, nameColumnWidth(item, ctx.width, nameText(item))); + if (item->expanded) for (auto child : item->children) self(self, child); + }; + for (auto item : children) measure(measure, item); + if (ctx.name_width > 0) name_column_width_ = ctx.name_width; + for (int i = 0; i < children.size(); ++i) { + ctx.any_visible = false; + const bool header_visible = drawItem(children[i], 0, ctx); + if (header_visible && first_visible == -1) first_visible = i; + if (ctx.any_visible) last_visible = i; + } + if (first_visible == -1 && last_visible != -1) last_visible = -1; + // the rows that just became visible have no sparkline yet + bool changed = first_visible != first_visible_row_ || last_visible != last_visible_row_; + first_visible_row_ = first_visible; + last_visible_row_ = last_visible; + scroll_to_sig_ = nullptr; + + if (ctx.name_width > 0) name_column_width_ = ctx.name_width; + if (ctx.value_column_width > 0 && ctx.value_column_width != value_column_width_) { + value_column_width_ = ctx.value_column_width; + changed = true; + } + if (changed) updateState(); + + // a press on the viewport that hits no row clears the selection and the current index; rowClicked() + // does not run + if (!ctx.mouse_on_row && ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + current_sig_ = nullptr; + current_type_ = SignalModel::Item::Root; + } + + if (ctx.hovered_sig != hovered_sig_) { + hovered_sig_ = ctx.hovered_sig; + highlight(hovered_sig_); + } + } + ImGui::EndChild(); + ImGui::PopStyleVar(); +} + +bool SignalView::drawItem(SignalModel::Item *item, int depth, DrawContext &ctx) { + const bool selected = item->sig == current_sig_ && item->type == current_type_; + const float row_height = item->type == SignalModel::Item::Sig ? signalRowHeight() : ctx.row_height; + const ImVec2 row_min = ImGui::GetCursorScreenPos(); + const ImVec2 row_max(row_min.x + ctx.width, row_min.y + row_height); + const bool row_visible = ImGui::IsRectVisible(row_min, row_max); + ctx.any_visible |= row_visible; + + ImGui::PushID(item); + ImGui::BeginDisabled(!SignalModel::isEnabled(item)); + const bool row_clicked = viewSelectable("##row", selected, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, row_height)); + // a press on the branch indicator only toggles the expansion; the current index does not change and + // rowClicked() does not run + const float branch_x = row_min.x + depth * INDENTATION; + const bool on_branch = !item->children.empty() && ImGui::GetMousePos().x >= branch_x && + ImGui::GetMousePos().x < branch_x + INDENTATION; + if (row_clicked && on_branch) { + item->expanded = !item->expanded; + } else if (row_clicked) { + current_sig_ = item->sig; + current_type_ = item->type; + // the new current item opens its editor. The name column and the non-editable cells (signal rows, + // check boxes) have no editor, so a click there only makes the cell current. + closeEditor(); + if (SignalModel::isEditable(item) && ImGui::GetMousePos().x >= row_min.x + name_column_width_) { + focus_item_ = open_item_ = item; + } + rowClicked(item); + } + if (item->type == SignalModel::Item::Sig && item->sig == scroll_to_sig_) { + ImGui::SetScrollHereY(0.0f); + scroll_to_sig_ = nullptr; + } + if (ImGui::IsMouseHoveringRect(row_min, row_max)) { + ctx.mouse_on_row = true; + if (ImGui::IsWindowHovered()) ctx.hovered_sig = item->sig; + } + + if (!item->children.empty()) { + const float arrow_size = ImGui::GetFontSize() * 0.7f; + ImGui::RenderArrow(ctx.draw_list, ImVec2(row_min.x + depth * INDENTATION + 4.0f, row_min.y + (row_height - arrow_size) * 0.5f), + ImGui::GetColorU32(selected ? palette().text_selected : palette().text), item->expanded ? ImGuiDir_Down : ImGuiDir_Right, 0.7f); + } + + // every row is measured, the header sizes column 0 to the contents of the whole tree + const std::string text0 = nameText(item); + ctx.name_width = std::max(ctx.name_width, nameColumnWidth(item, ctx.width, text0)); + const ImRect rect1(ImVec2(row_min.x + name_column_width_, row_min.y), row_max); + ctx.value_column_width = rect1.GetWidth(); + + // a row outside the viewport is not painted and has no index widget, like a QTreeView row. The row that + // holds the open editor is always submitted, so scrolling it out does not drop the edit. + const bool editor_open = selected && open_item_ == item; + if (row_visible || editor_open) { + const ImRect rect0(ImVec2(row_min.x + (depth + 1) * INDENTATION, row_min.y), ImVec2(row_min.x + name_column_width_, row_max.y)); + paintCell(ctx.draw_list, rect0, item, 0, selected, text0, ctx.viewport_x); + if (item->type == SignalModel::Item::Sig && ImGui::IsMouseHoveringRect(ImVec2(row_min.x, row_min.y), rect0.Max) && + ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip) && ImGui::BeginTooltip()) { + ImGui::TextUnformatted(utils::stripHtml(utils::signalToolTip(item->sig)).c_str()); + ImGui::EndTooltip(); + } + + if (item->type == SignalModel::Item::Sig) { + paintCell(ctx.draw_list, rect1, item, 1, selected, item->sig_val, ctx.viewport_x); + drawIndexWidget(item, rect1); + } else if (SignalModel::isCheckable(item)) { + bool checked = item->type == SignalModel::Item::Endian ? item->sig->is_little_endian : item->sig->is_signed; + ImGui::SetCursorScreenPos(ImVec2(rect1.Min.x + H_MARGIN, rect1.Min.y)); + if (checkBox("##check", &checked)) queueCommit(item, checked); + } else if (SignalModel::isEditable(item) && editor_open) { + // only the current item gets an editor; the others are painted as text + ImGui::SetCursorScreenPos(rect1.Min); + ImGui::SetNextItemWidth(rect1.GetWidth()); + drawEditor(item); + } else { + paintCell(ctx.draw_list, rect1, item, 1, selected, model_.valueText(item), ctx.viewport_x); + } + } + ImGui::EndDisabled(); + ImGui::PopID(); + ImGui::SetCursorScreenPos(ImVec2(row_min.x, row_max.y)); + + if (item->expanded) { + for (auto child : item->children) drawItem(child, depth + 1, ctx); + } + return row_visible; +} + +void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { + const float spacing = ImGui::GetStyle().ItemInnerSpacing.x; + const float button = iconButtonWidth(); + // Anchor fixed-size buttons inside the viewport, which excludes the scrollbar. + ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - H_MARGIN - button * 2 - spacing, + rect.Min.y + (rect.GetHeight() - button) * 0.5f)); + + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); + const auto sig = item->sig; + const bool checked = item->chart_opened; + const bool selected = current_sig_ == sig && current_type_ == SignalModel::Item::Sig; + auto row_button = [selected](const char *id, const char *glyph) { + if (selected) { + ImGui::PushStyleColor(ImGuiCol_Text, palette().text_selected); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, palette().header_active); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, palette().header_active); + } + const bool clicked = iconButton(id, glyph); + if (selected) ImGui::PopStyleColor(3); + return clicked; + }; + if (checked) ImGui::PushStyleColor(ImGuiCol_Button, selected ? palette().header_active : ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); + if (row_button("plot", icon::GRAPH_UP) && !editor_open_on_press_) { + item->chart_opened = !checked; + showChart(model_.msgId(), sig, item->chart_opened, ImGui::GetIO().KeyShift); + } + if (checked) ImGui::PopStyleColor(); + ImGui::SetItemTooltip("%s", checked ? "Close Plot" : "Show Plot\nShift-click to add to the previously opened plot"); + ImGui::SameLine(0.0f, spacing); + if (row_button("remove", icon::X_LG) && !editor_open_on_press_) { + pending_action_ = [this, sig]() { UndoStack::instance()->push(new RemoveSigCommand(model_.msgId(), sig)); }; + } + ImGui::SetItemTooltip("Remove signal"); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + +ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions) { + for (auto &[val, desc] : descriptions) { + table_.emplace_back(utils::toString(val), desc); + } +} + +bool ValueDescriptionDlg::draw() { + const std::string popup_id = title + "###ValueDescriptionDlg"; + if (!opened_) { + ImGui::OpenPopup(popup_id.c_str()); + opened_ = true; + } + setNextDialogWindow(ImVec2(500.0f, 0.0f)); + bool open = true; + // not drawn while the dock is collapsed or another modal is on top; only closed once the popup is gone + if (!ImGui::BeginPopupModal(popup_id.c_str(), &open, ImGuiWindowFlags_NoSavedSettings)) return ImGui::IsPopupOpen(popup_id.c_str()); + + bool closing = false; + if (stepButton("add", true, "Add")) { + table_.emplace_back("", ""); + } + ImGui::SameLine(); + ImGui::BeginDisabled(current_row_ == -1); + if (stepButton("remove", false, "Remove") && current_row_ < table_.size()) { + table_.erase(table_.begin() + current_row_); + current_row_ = -1; + } + ImGui::EndDisabled(); + + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY; + if (ImGui::BeginTable("table", 3, flags, ImVec2(0.0f, 300.0f))) { + ImGui::TableSetupScrollFreeze(1, 1); + // vertical header: the 1 based row number + ImGui::TableSetupColumn("##row_number", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoHeaderLabel, + ImGui::CalcTextSize("000").x + ImGui::GetStyle().CellPadding.x * 2); + ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("Description", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + for (int row = 0; row < table_.size(); ++row) { + ImGui::PushID(row); + ImGui::TableNextRow(); + if (row == current_row_) ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::GetColorU32(ImGuiCol_Header)); + ImGui::TableSetColumnIndex(0); + ImGui::AlignTextToFramePadding(); + ImGui::TextColored(row == current_row_ ? palette().text_selected : palette().text, "%d", row + 1); + ImGui::TableSetColumnIndex(1); + ImGui::SetNextItemWidth(-FLT_MIN); + if (valueDescriptionEditor(0, &table_[row].first)) current_row_ = row; + ImGui::TableSetColumnIndex(2); + ImGui::SetNextItemWidth(-FLT_MIN); + if (valueDescriptionEditor(1, &table_[row].second)) current_row_ = row; + ImGui::PopID(); + } + ImGui::EndTable(); + } + + bool accept = false, reject = false; + if (dialogButtons("OK", &accept, &reject)) { + if (accept) save(); + closing = true; + } + if (!open) closing = true; + if (closing) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + return !closing; +} + +void ValueDescriptionDlg::save() { + for (int i = 0; i < table_.size(); ++i) { + std::string val = utils::trimmed(table_[i].first); + std::string desc = utils::trimmed(table_[i].second); + if (!val.empty() && !desc.empty()) { + val_desc.push_back({utils::toDouble(val), desc}); + } + } + accepted = true; +} diff --git a/openpilot/tools/cabana/ui/widgets/signalview.h b/openpilot/tools/cabana/ui/widgets/signalview.h new file mode 100644 index 000000000..1ef830ca5 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/signalview.h @@ -0,0 +1,204 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/core/observable.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/chart/sparkline.h" +#include "tools/cabana/utils/strings.h" + +// the value SignalModel::setData takes: text, a number, a check state or a value table +class ItemValue { +public: + ItemValue(const std::string &s) : str_(s) {} + ItemValue(int v) : str_(std::to_string(v)) {} + ItemValue(bool v) : str_(v ? "1" : "0") {} + ItemValue(const ValueDescription &v) : val_desc_(v) {} + ItemValue(const char *) = delete; // a literal would bind to ItemValue(bool) + std::string toString() const { return str_; } + int toInt() const { return utils::toInt(str_); } + bool toBool() const { return str_ == "1"; } + double toDouble() const { return utils::toDouble(str_); } + const ValueDescription &toValueDescription() const { return val_desc_; } + +private: + std::string str_; + ValueDescription val_desc_; +}; + +class SignalModel { +public: + struct Item { + enum Type {Root, Sig, Name, Size, Node, Endian, Signed, Offset, Factor, SignalType, MultiplexValue, ExtraInfo, Unit, Comment, Min, Max, Desc }; + ~Item() { for (auto c : children) delete c; } + inline int row() const { + auto it = std::find(parent->children.begin(), parent->children.end(), this); + return it != parent->children.end() ? std::distance(parent->children.begin(), it) : -1; + } + + Type type = Type::Root; + Item *parent = nullptr; + std::vector children; + + const cabana::Signal *sig = nullptr; + std::string title; + bool highlight = false; + std::string sig_val = "-"; + Sparkline sparkline; + bool expanded = false; + bool chart_opened = false; // plot_btn checked state + }; + + SignalModel(); + Item *root() const { return root_.get(); } + const MessageId &msgId() const { return msg_id_; } + int rowCount() const { return root_->children.size(); } + static bool isEnabled(const Item *item); + static bool isEditable(const Item *item); // a leaf cell in the value column with an editor + static bool isCheckable(const Item *item); // Endian and Signed + std::string valueText(const Item *item) const; + bool setData(Item *item, const ItemValue &value); + void setMessage(const MessageId &id); + void setFilter(const std::string &txt); + bool saveSignal(const cabana::Signal *origin_s, cabana::Signal &s); + int signalRow(const cabana::Signal *sig) const; + + Observable<> rowsChanged; // rows were added, removed or reset + Observable<> modelReset; // the items are gone: the view closes its editors + +private: + void insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig); + void handleSignalAdded(MessageId id, const cabana::Signal *sig); + void handleSignalUpdated(const cabana::Signal *sig); + void handleSignalRemoved(const cabana::Signal *sig); + void handleMsgChanged(MessageId id); + void refresh(); + + MessageId msg_id_; + std::string filter_str_; + std::unique_ptr root_; + Connections connections_; +}; + +// non-blocking: draw() returns false once closed, `accepted` tells whether Ok was pressed +class ValueDescriptionDlg { +public: + ValueDescriptionDlg(const ValueDescription &descriptions); + bool draw(); + ValueDescription val_desc; + std::string title; + bool accepted = false; + +private: + void save(); + std::vector> table_; // rows of {Value, Description} + int current_row_ = -1; + bool opened_ = false; +}; + +class SignalView { +public: + SignalView(ChartsWidget *charts); + void setMessage(const MessageId &id); + void draw(); + static float minimumWidth(); + static float minimumHeight(); + void signalHovered(const cabana::Signal *sig); // handler for BinaryView::signalHovered + void updateChartState(); + void selectSignal(const cabana::Signal *sig, bool expand = false); + bool saveSignal(const cabana::Signal *origin, cabana::Signal &s) { return model_.saveSignal(origin, s); } + std::string whatsThis() const; + + Observable highlight; + Observable showChart; + +private: + void rowsChanged(); + void rowClicked(SignalModel::Item *item); + static float toolBarRightWidth(const std::string &range_label); + void updateToolBar(); + void setSparklineRange(int value); + void handleSignalAdded(MessageId id, const cabana::Signal *sig); + void handleSignalUpdated(const cabana::Signal *sig); + void handleSignalRemoved(const cabana::Signal *sig); // drops the row pointers to a removed signal (nullptr: all) + void updateState(const std::set *msgs = nullptr); + + struct DrawContext { + ImDrawList *draw_list; + float viewport_x; + float width; + float row_height; + float name_width = 0; + float value_column_width = 0; + bool any_visible = false; + bool mouse_on_row = false; + const cabana::Signal *hovered_sig = nullptr; + }; + void drawTree(); + bool drawItem(SignalModel::Item *item, int depth, DrawContext &ctx); // returns whether the row is visible + void drawIndexWidget(SignalModel::Item *item, const ImRect &rect); // the [plot][remove] widget + void collapseAll(); + static float widestValueWidth(const cabana::Signal *sig); + + // viewport_x: left edge of the tree viewport + void paintCell(ImDrawList *painter, const ImRect &rect, const SignalModel::Item *item, int column, bool selected, + const std::string &text, float viewport_x) const; + float nameColumnWidth(const SignalModel::Item *item, float widget_width, const std::string &text) const; + // draws the editor for `item` at the cursor; commits through queueCommit on focus out + void drawEditor(SignalModel::Item *item); + // queues the commit in pending_commit_: EditSignalCommand fires dbc()->signalUpdated synchronously, which reorders + // the rows, so the model is only changed after the tree is drawn (see draw) + void queueCommit(SignalModel::Item *item, const ItemValue &value); + void drawValueDescriptionDlg(); // continuation of the ValueDescriptionDlg opened in drawEditor + static float textWidth(const std::string &text, float font_size = 0); + void closeEditor(); + void commitEditor(); // Qt commits an open editor on focus out + // only an Acceptable value is committed + void drawLineEditor(SignalModel::Item *item, ImGuiInputTextCallback validator, bool take_focus); + static ValidState validateEditor(const SignalModel::Item *item, std::string &text); + + float value_column_width_ = 0; + bool compact_ = false; + float name_column_width_ = 150; + bool editor_open_on_press_ = false; + // computed while drawing the tree: the first top-level row whose own row is visible (a signal whose header + // is scrolled out but whose children are visible is skipped), and the last top-level row with any visible row + int first_visible_row_ = -1; + int last_visible_row_ = -1; + const cabana::Signal *current_sig_ = nullptr; + int current_row_ = -1; // row of current_sig_ at the end of the last draw() + SignalModel::Item::Type current_type_ = SignalModel::Item::Root; + const cabana::Signal *scroll_to_sig_ = nullptr; + const cabana::Signal *hovered_sig_ = nullptr; + std::function pending_action_; // button clicks that destroy rows run after the tree is drawn + std::string sparkline_label_; + std::string filter_edit_; + ChartsWidget *charts_; + std::string signal_count_lb_; + + ImVec2 button_size_ = {}; + // the editor is created for the item that just became current and takes the focus + SignalModel::Item *focus_item_ = nullptr; + // the item whose editor is open; closeEditor() returns the cell to the painted text while the row stays current + SignalModel::Item *open_item_ = nullptr; + std::function pending_commit_; + SignalModel::Item *editing_item_ = nullptr; // the open text editor + std::string edit_text_; + int edit_int_ = 0; + bool editor_active_ = false; // editor had the keyboard focus last frame + bool refocus_editor_ = false; // reopen the editor rejected by the validator + bool enter_pressed_ = false; + bool combo_focused_ = false; // the SignalType combo had the focus last frame + std::unique_ptr desc_dlg_; + const cabana::Signal *desc_sig_ = nullptr; + SignalModel model_; + Connections connections_; +}; diff --git a/openpilot/tools/cabana/ui/widgets/tabbar.cc b/openpilot/tools/cabana/ui/widgets/tabbar.cc new file mode 100644 index 000000000..5e194b965 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/tabbar.cc @@ -0,0 +1,92 @@ +#include "tools/cabana/ui/widgets/tabbar.h" + +#include +#include + +#include "tools/cabana/ui/widgets/scrollabletabbar.h" + +int TabBar::addTab(const std::string &text) { + tabs_.push_back({text, 0, next_id_++}); + int index = count() - 1; + if (current_index_ == -1) { // the first tab is current + current_index_ = index; + select_current_ = true; + currentChanged(index); + } + return index; +} + +void TabBar::setCurrentIndex(int index) { + if (index == current_index_ || index < -1 || index >= count()) return; + current_index_ = index; + select_current_ = true; + currentChanged(index); +} + +int TabBar::tabAt(const ImVec2 &pos) const { + for (int i = 0; i < count(); ++i) { + if (tabs_[i].rect.Contains(pos)) return i; + } + return -1; +} + +void TabBar::removeTab(int index) { + tabs_.erase(tabs_.begin() + index); + if (index == current_index_) { + // select the tab that moved into this index, else the one to the left + current_index_ = count() ? std::min(index, count() - 1) : -1; + select_current_ = true; + currentChanged(current_index_); + } else if (index < current_index_) { + --current_index_; + } +} + +void TabBar::moveTab(int from, int to) { + if (from == to || from < 0 || from >= count() || to < 0 || to >= count()) return; + const int current_id = current_index_ >= 0 ? tabs_[current_index_].id : -1; + Tab tab = std::move(tabs_[from]); + tabs_.erase(tabs_.begin() + from); + tabs_.insert(tabs_.begin() + to, std::move(tab)); + for (int i = 0; i < count(); ++i) { + if (tabs_[i].id == current_id) current_index_ = i; + } + select_current_ = true; // imgui orders the tabs as submitted only when a tab is (re)selected +} + +void TabBar::draw() { + if (auto_hide_ && count() < 2) return; // auto hidden with fewer than two tabs + ImGui::PushID(this); + // no default tooltip, the tabs carry their own + if (!(scroll_buttons_ ? beginScrollableTabBar("##tabbar", ImGuiTabBarFlags_NoTooltip) : ImGui::BeginTabBar("##tabbar", ImGuiTabBarFlags_NoTooltip))) { + ImGui::PopID(); + return; + } + // every tab gets a close button, not only the hovered/selected one + ImGuiStyle &style = ImGui::GetStyle(); + const float close_button_min_width = tabs_closable_ ? std::exchange(style.TabCloseButtonMinWidthUnselected, -1.0f) : 0.0f; + // setCurrentIndex requests are applied on the next frame + const bool select_current = std::exchange(select_current_, false); + int close_index = -1; + for (int i = 0; i < count(); ++i) { + bool open = true; + const std::string label = tabs_[i].text + "###tab" + std::to_string(tabs_[i].id); + const ImGuiTabItemFlags flags = (select_current && i == current_index_) ? ImGuiTabItemFlags_SetSelected : 0; + if (ImGui::BeginTabItem(label.c_str(), tabs_closable_ ? &open : nullptr, flags)) { + // a programmatic selection takes effect on the next frame, ignore the old tab until then + if (!select_current && i != current_index_) { + current_index_ = i; + currentChanged(i); + } + ImGui::EndTabItem(); + } + tabs_[i].rect = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + if (!tabs_[i].tooltip.empty()) ImGui::SetItemTooltip("%s", tabs_[i].tooltip.c_str()); + tabContextMenu(i); + if (!open) close_index = i; + } + if (tabs_closable_) style.TabCloseButtonMinWidthUnselected = close_button_min_width; + scroll_buttons_ ? endScrollableTabBar() : ImGui::EndTabBar(); + ImGui::PopID(); + if (close_index >= 0) tabCloseRequested(close_index); +} diff --git a/openpilot/tools/cabana/ui/widgets/tabbar.h b/openpilot/tools/cabana/ui/widgets/tabbar.h new file mode 100644 index 000000000..9a8b7c783 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/tabbar.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/core/observable.h" + +// QTabBar: tabs are closable when setTabsClosable(true) +class TabBar { +public: + TabBar() = default; + int addTab(const std::string &text); + int count() const { return (int)tabs_.size(); } + void setTabText(int index, const std::string &text) { if (index >= 0 && index < count()) tabs_[index].text = text; } + const std::string &tabText(int index) const { return tabs_[index].text; } + void setTabToolTip(int index, const std::string &tip) { if (index >= 0 && index < count()) tabs_[index].tooltip = tip; } + void setTabData(int index, int data) { if (index >= 0 && index < count()) tabs_[index].data = data; } + int tabData(int index) const { return index >= 0 && index < count() ? tabs_[index].data : 0; } + int currentIndex() const { return current_index_; } + void setCurrentIndex(int index); + int tabAt(const ImVec2 &pos) const; // -1 when no tab covers pos + void removeTab(int index); + void moveTab(int from, int to); + void setAutoHide(bool hide) { auto_hide_ = hide; } + void setTabsClosable(bool closable) { tabs_closable_ = closable; } // off by default + void setUsesScrollButtons(bool use) { scroll_buttons_ = use; } + void draw(); + + Observable currentChanged; + Observable tabCloseRequested; + Observable tabContextMenu; // emitted while drawing, right after the tab: open a context popup from it + +private: + struct Tab { std::string text; int data = 0; int id = 0; std::string tooltip; ImRect rect; }; + std::vector tabs_; + int current_index_ = -1; + int next_id_ = 0; + bool select_current_ = false; // programmatic current change, applied at the next draw() + bool auto_hide_ = false; + bool tabs_closable_ = false; + bool scroll_buttons_ = false; +}; diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc new file mode 100644 index 000000000..5c8a9ede3 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -0,0 +1,608 @@ +#include "tools/cabana/ui/widgets/videowidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +} +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +const int MIN_VIDEO_HEIGHT = 100; +const int THUMBNAIL_MARGIN = 3; +const float POINT_10_FONT_SIZE = 13.0f; // 10 pt at 96 dpi +const float POINT_16_FONT_SIZE = 21.0f; // 16 pt at 96 dpi +constexpr float TOOLBAR_SEPARATOR_EXTENT = 1.0f; +const float SLIDER_HEIGHT = 15.0f; // the handle plus a 1 px margin + +// Indexed by TimelineType: None, Engaged, AlertInfo, AlertWarning, AlertCritical, UserBookmark +static const ImU32 timeline_colors[] = { + IM_COL32(111, 143, 175, 255), + IM_COL32(0, 163, 108, 255), + IM_COL32(0, 255, 0, 255), + IM_COL32(255, 195, 0, 255), + IM_COL32(199, 0, 57, 255), + IM_COL32(255, 0, 255, 255), +}; + +static const float speeds[] = {0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.8, 1., 2., 3., 5.}; +static const int NORMAL_SPEED_INDEX = std::find(std::begin(speeds), std::end(speeds), 1.0f) - std::begin(speeds); + +static Replay *getReplay() { + auto stream = dynamic_cast(can); + return stream ? stream->getReplay() : nullptr; +} + +static std::string colorName(ImU32 c) { + char buf[16]; + snprintf(buf, sizeof(buf), "#%02x%02x%02x", (c >> IM_COL32_R_SHIFT) & 0xff, (c >> IM_COL32_G_SHIFT) & 0xff, (c >> IM_COL32_B_SHIFT) & 0xff); + return buf; +} + +// the zoomed range, or the whole route +static std::pair displayedTimeRange() { + return can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds())); +} + +// decode with libavcodec, already linked for the replay video decoder +static bool decodeJpeg(const uint8_t *data, size_t size, RgbImage *out) { + const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_MJPEG); + AVCodecContext *context = codec ? avcodec_alloc_context3(codec) : nullptr; + AVFrame *frame = av_frame_alloc(); + AVPacket *packet = av_packet_alloc(); + bool ok = false; + if (context && frame && packet && size > 0 && size <= (size_t)INT32_MAX && av_new_packet(packet, (int)size) >= 0) { + std::copy(data, data + size, packet->data); + ok = avcodec_open2(context, codec, nullptr) >= 0 && avcodec_send_packet(context, packet) >= 0 && + avcodec_receive_frame(context, frame) >= 0 && frame->width > 0 && frame->height > 0; + } + int chroma_x_shift = 0, chroma_y_shift = 0; + if (ok) { + switch ((AVPixelFormat)frame->format) { + case AV_PIX_FMT_YUV420P: case AV_PIX_FMT_YUVJ420P: chroma_x_shift = chroma_y_shift = 1; break; + case AV_PIX_FMT_YUV422P: case AV_PIX_FMT_YUVJ422P: chroma_x_shift = 1; break; + case AV_PIX_FMT_YUV444P: case AV_PIX_FMT_YUVJ444P: break; + default: ok = false; break; + } + } + if (ok) { + out->resize(frame->width, frame->height); + const bool full_range = frame->color_range == AVCOL_RANGE_JPEG || frame->format == AV_PIX_FMT_YUVJ420P || + frame->format == AV_PIX_FMT_YUVJ422P || frame->format == AV_PIX_FMT_YUVJ444P; + const float y_scale = full_range ? 1.0f : 1.164383f; + const float y_offset = full_range ? 0.0f : 16.0f; + const float kr = full_range ? 1.402f : 1.596027f; + const float kgu = full_range ? 0.344136f : 0.391762f; + const float kgv = full_range ? 0.714136f : 0.812968f; + const float kb = full_range ? 1.772f : 2.017232f; + for (int y = 0; y < frame->height; ++y) { + const uint8_t *y_row = frame->data[0] + y * frame->linesize[0]; + const uint8_t *u_row = frame->data[1] + (y >> chroma_y_shift) * frame->linesize[1]; + const uint8_t *v_row = frame->data[2] + (y >> chroma_y_shift) * frame->linesize[2]; + uint8_t *dst = out->data.data() + (size_t)y * out->bytesPerLine(); + for (int x = 0; x < frame->width; ++x) { + const float luma = y_scale * ((float)y_row[x] - y_offset); + const float u = (float)u_row[x >> chroma_x_shift] - 128.0f; + const float v = (float)v_row[x >> chroma_x_shift] - 128.0f; + const float r = luma + kr * v; + const float g = luma - kgu * u - kgv * v; + const float b = luma + kb * u; + dst[x * 4 + 0] = (uint8_t)std::clamp(std::lround(r), 0L, 255L); + dst[x * 4 + 1] = (uint8_t)std::clamp(std::lround(g), 0L, 255L); + dst[x * 4 + 2] = (uint8_t)std::clamp(std::lround(b), 0L, 255L); + dst[x * 4 + 3] = 255; + } + } + } + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return ok; +} + +VideoWidget::VideoWidget() { + if (!can->liveStreaming()) + createCameraWidget(); + + createSpeedDropdown(); + + connections_.push_back(can->timeRangeChanged.connect([this](const auto &) { timeRangeChanged(); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { msgs_received_ = true; })); +} + +std::string VideoWidget::whatsThis() const { + // one
separated line per legend row, with the same entries and colors + return "Video
\n" + "Timeline color
\n" + + colorName(timeline_colors[(int)TimelineType::None]) + " Disengaged   " + + colorName(timeline_colors[(int)TimelineType::Engaged]) + " Engaged
\n" + + colorName(timeline_colors[(int)TimelineType::UserBookmark]) + " User Flag   " + + colorName(timeline_colors[(int)TimelineType::AlertInfo]) + " Info
\n" + + colorName(timeline_colors[(int)TimelineType::AlertWarning]) + " Warning   " + + colorName(timeline_colors[(int)TimelineType::AlertCritical]) + " Critical
\n" + "Shortcuts
\n" + "Pause/Resume:  space "; +} + +static float toolbarHeight() { return ImGui::GetStyle().ItemSpacing.y + ImGui::GetFrameHeight(); } + +void VideoWidget::drawPlaybackController() { + if (!can->liveStreaming()) + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + ImGui::GetStyle().ItemSpacing.y); + const float speed_width = menuButtonWidth("0.05x", true); + + const char *play_icon = can->isPaused() ? icon::PLAY : icon::PAUSE; + const char *play_tooltip = can->isPaused() ? "Play" : "Pause"; + const char *loop_icon = getReplay() && getReplay()->loop() ? icon::REPEAT : icon::REPEAT_1; + const bool timestamps_resolved = can->liveStreaming() + ? msgs_received_ + : can->beginDateTime() != std::chrono::system_clock::time_point{}; + const std::string time_text = timestamps_resolved + ? (slider_ ? formatTime(can->currentSec(), true) + " / " + formatTime(slider_->maximum() / slider_->factor) + : formatTime(can->currentSec(), true)) + : "--:--.-- / --:--.--"; + const char *time_tooltip = settings.absolute_time ? "Elapsed time" : "Absolute time"; + + std::vector items; + if (!can->liveStreaming()) { + items.push_back(toolbarAction("rewind", icon::REWIND, "Seek backward", []() { can->seekTo(can->currentSec() - 1); })); + } + items.push_back(toolbarAction("play", play_icon, play_tooltip, []() { can->pause(!can->isPaused()); })); + if (can->liveStreaming()) { + items.push_back(toolbarAction("skip-end", icon::SKIP_END, "Go live", [this]() { skipToEnd(); }, skip_to_end_enabled_)); + } else { + items.push_back(toolbarAction("fast-forward", icon::FAST_FORWARD, "Seek forward", []() { can->seekTo(can->currentSec() + 1); })); + } + if (slider_ || timestamps_resolved) { + // a mono font: with proportional digits the time changed width as it ticked and the items after it moved + pushMonoFont(ImGui::GetStyle().FontSizeBase); + const float time_width = ImGui::CalcTextSize(time_text.c_str()).x; + popMonoFont(); + items.push_back({time_width, + [&]() { + pushMonoFont(ImGui::GetStyle().FontSizeBase); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(time_text.c_str()); + popMonoFont(); + if (ImGui::IsItemClicked()) toggleTimeDisplay(); + ImGui::SetItemTooltip("%s", time_tooltip); + }, + time_tooltip, [this]() { toggleTimeDisplay(); }}); + } + // the expanding spacer: the items after it are right aligned as long as everything fits + const size_t spacer_index = items.size(); + auto separator = []() { + ToolbarItem item{TOOLBAR_SEPARATOR_EXTENT, []() { + // a 1 px separator line centered in TOOLBAR_SEPARATOR_EXTENT, inset from the top and bottom + const ImVec2 min = ImGui::GetCursorScreenPos(); + ImGui::Dummy(ImVec2(TOOLBAR_SEPARATOR_EXTENT, ImGui::GetFrameHeight())); + const float x = std::floor(min.x + TOOLBAR_SEPARATOR_EXTENT * 0.5f); + const float inset = ImGui::GetStyle().FramePadding.y; + ImGui::GetWindowDrawList()->AddLine(ImVec2(x, min.y + inset), ImVec2(x, min.y + ImGui::GetFrameHeight() - inset), + ImGui::GetColorU32(ImGuiCol_Separator)); + }}; + item.in_menu = false; + return item; + }; + const char *aspect_ratio_icon = settings.crop_video ? icon::ASPECT_RATIO_FILL : icon::ASPECT_RATIO; + if (!can->liveStreaming()) { + items.push_back(toolbarAction("crop_video", aspect_ratio_icon, "Crop to fill", [this]() { cropVideoClicked(); })); + items.push_back(separator()); + items.push_back(toolbarAction("loop", loop_icon, "Loop playback", [this]() { loopPlaybackClicked(); })); + items.push_back(toolbarMenu("speed_btn", speed_text_, "Speed", [this]() { drawSpeedMenuItems(); }, true, speed_width)); + items.push_back(separator()); + items.push_back(toolbarAction("route_info", icon::INFO_CIRCLE, "View route details", [this]() { showRouteInfo(); })); + } + + drawToolbar(items, spacer_index); +} + +void VideoWidget::skipToEnd() { + // set speed to 1.0; this only checks the menu entry, the speed and the button text are unchanged + speed_index_ = NORMAL_SPEED_INDEX; + can->pause(false); + can->seekTo(can->maxSeconds() + 1); +} + +void VideoWidget::toggleTimeDisplay() { + settings.absolute_time = !settings.absolute_time; +} + +static std::string speedText(float speed) { + char buf[32]; + snprintf(buf, sizeof(buf), "%gx", speed); + return buf; +} + +void VideoWidget::createSpeedDropdown() { + speed_index_ = NORMAL_SPEED_INDEX; + can->setSpeed(speeds[speed_index_]); + speed_text_ = speedText(speeds[speed_index_]); +} + +void VideoWidget::drawSpeedMenuItems() { + for (int i = 0; i < (int)std::size(speeds); ++i) { + const float speed = speeds[i]; + if (dropdown::Item(speedText(speed).c_str(), nullptr, speed_index_ == i)) { + speed_index_ = i; + can->setSpeed(speed); + speed_text_ = speedText(speed); + } + } +} + +void VideoWidget::createCameraWidget() { + camera_tab_ = std::make_unique(); + camera_tab_->setAutoHide(true); + + cam_widget_ = std::make_unique("camerad", VISION_STREAM_NARROW_ROAD); + + slider_ = std::make_unique(); + slider_->setTimeRange(can->minSeconds(), can->maxSeconds()); + + connections_.push_back(slider_->sliderReleased.connect([this]() { can->seekTo(slider_->currentSecond()); })); + connections_.push_back(cam_widget_->clicked.connect([]() { can->pause(!can->isPaused()); })); + connections_.push_back(cam_widget_->availableStreamsUpdated.connect([this](std::set streams) { vipcAvailableStreamsUpdated(streams); })); + connections_.push_back(camera_tab_->currentChanged.connect([this](int index) { + if (index != -1) cam_widget_->setStreamType((VisionStreamType)camera_tab_->tabData(index)); + })); + connections_.push_back(static_cast(can)->qLogLoaded.connect([this](std::shared_ptr qlog) { cam_widget_->parseQLog(qlog); })); + + if (auto *replay = getReplay(); replay && !replay->hasFlag(REPLAY_FLAG_NO_VIPC)) { + std::set streams; + for (const auto &[num, segment] : replay->route().segments()) { + if (!segment.narrow_road_cam.empty() || !segment.qcamera.empty()) streams.insert(VISION_STREAM_NARROW_ROAD); + if (replay->hasFlag(REPLAY_FLAG_CABIN_CAMERA) && !segment.cabin_cam.empty()) streams.insert(VISION_STREAM_CABIN); + if (replay->hasFlag(REPLAY_FLAG_WIDE_ROAD) && !segment.wide_road_cam.empty()) streams.insert(VISION_STREAM_WIDE_ROAD); + } + vipcAvailableStreamsUpdated(streams); + } +} + +void VideoWidget::drawCameraWidget() { + const float toolbar_height = toolbarHeight(); + // Camera tabs, video and timeline touch; restore the normal gap for the controls. + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); + camera_tab_->draw(); + + // Reserve the timeline and playback controls even when the native dock is short. + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const float cam_height = std::max(1.0f, avail.y - SLIDER_HEIGHT - toolbar_height); + cam_widget_->draw(ImVec2(avail.x, cam_height), thumbnail_display_time_); + + if (!slider_->isSliderDown()) slider_->setCurrentSecond(can->currentSec()); + slider_->draw(thumbnail_display_time_); + updateSliderThumbnail(); + ImGui::PopStyleVar(); +} + +void VideoWidget::vipcAvailableStreamsUpdated(std::set streams) { + static const std::string stream_names[] = {"Road camera", "Driver camera", "Wide road camera"}; + for (int i = 0; i < streams.size(); ++i) { + if (camera_tab_->count() <= i) { + camera_tab_->addTab(std::string()); + } + int type = *std::next(streams.begin(), i); + camera_tab_->setTabText(i, stream_names[type]); + camera_tab_->setTabData(i, type); + } + while (camera_tab_->count() > streams.size()) { + camera_tab_->removeTab(camera_tab_->count() - 1); + } +} + +void VideoWidget::loopPlaybackClicked() { + getReplay()->setLoop(!getReplay()->loop()); +} + +void VideoWidget::cropVideoClicked() { + settings.crop_video = !settings.crop_video; + settings.changed(); +} + +void VideoWidget::timeRangeChanged() { + const auto time_range = can->timeRange(); + if (can->liveStreaming()) { + skip_to_end_enabled_ = !time_range.has_value(); + return; + } + time_range ? slider_->setTimeRange(time_range->first, time_range->second) + : slider_->setTimeRange(can->minSeconds(), can->maxSeconds()); +} + +std::string VideoWidget::formatTime(double sec, bool include_milliseconds) { + if (settings.absolute_time) + sec += std::chrono::duration(can->beginDateTime().time_since_epoch()).count(); + return utils::formatSeconds(sec, include_milliseconds, settings.absolute_time); +} + +void VideoWidget::setVisible(bool visible) { + if (cam_widget_) cam_widget_->setVisible(visible); +} + +void VideoWidget::showThumbnail(double seconds) { + if (can->liveStreaming()) return; + thumbnail_display_time_ = seconds; +} + +void VideoWidget::showRouteInfo() { + // dropped from route_info_dlgs_ once draw() returns false + route_info_dlgs_.push_back(std::make_unique()); +} + +void VideoWidget::updateSliderThumbnail() { + if (slider_->underMouse()) { + auto [min_sec, max_sec] = displayedTimeRange(); + showThumbnail(min_sec + (ImGui::GetMousePos().x - slider_->rect().Min.x) * (max_sec - min_sec) / slider_->width()); + } else if (slider_->mouseLeft()) { + showThumbnail(-1); + } +} + +float VideoWidget::sizeHintHeight() const { + // the camera minimum height plus the slider and the toolbar + return MIN_VIDEO_HEIGHT + SLIDER_HEIGHT + toolbarHeight(); +} + +// Keep the pane's default proportions stable as frames arrive or cameras change. +float VideoWidget::defaultHeight(float width) const { + if (!cam_widget_) return ImGui::GetFrameHeight(); // live streams have no camera or slider + const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, width / DEFAULT_CAMERA_ASPECT_RATIO); + const float tab_height = camera_tab_->count() >= 2 ? ImGui::GetFrameHeight() : 0.0f; + return cam_height + tab_height + SLIDER_HEIGHT + toolbarHeight(); +} + +void VideoWidget::draw() { + if (!can->liveStreaming()) + drawCameraWidget(); + + drawPlaybackController(); + + for (auto it = route_info_dlgs_.begin(); it != route_info_dlgs_.end();) { + it = (*it)->draw() ? it + 1 : route_info_dlgs_.erase(it); + } +} + +void Slider::draw(double thumbnail_time) { + ImGui::InvisibleButton("##slider", ImVec2(std::max(1.0f, ImGui::GetContentRegionAvail().x), SLIDER_HEIGHT)); + rect_ = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + const bool hovered = ImGui::IsItemHovered(); + left_ = hovered_ && !hovered; + hovered_ = hovered; + + if (ImGui::IsItemActivated()) handleMousePress(); + if (slider_down_) { + if (ImGui::IsItemActive()) { + // the handle keeps its grab offset while dragging + setValue(pixelPosToRangeValue(ImGui::GetMousePos().x - click_offset_)); + } else { + slider_down_ = false; + sliderReleased(); + } + } + paint(thumbnail_time); +} + +ImRect Slider::handleRect() const { + const float handle_width = SLIDER_LENGTH; + const float handle_height = std::min(SLIDER_THICKNESS, rect_.GetHeight()); + const int range = std::max(1, maximum() - minimum()); + const float x = rect_.Min.x + (float)(value() - minimum()) / range * std::max(0.0f, width() - handle_width); + const float y = rect_.GetCenter().y - handle_height / 2; + return ImRect(ImVec2(x, y), ImVec2(x + handle_width, y + handle_height)); +} + +// handle left edge (window x) -> value over the groove minus the handle width +int Slider::pixelPosToRangeValue(float x) const { + const float handle_width = SLIDER_LENGTH; + const float span = std::max(1.0f, width() - handle_width); + return minimum() + (int)std::lround((maximum() - minimum()) * std::clamp((x - rect_.Min.x) / span, 0.0f, 1.0f)); +} + +void Slider::paint(double thumbnail_time) { + ImDrawList *p = ImGui::GetWindowDrawList(); + + ImRect handle_rect = handleRect(); + ImRect groove_rect = rect_; + + // adjust the groove height to match the handle height, rounded up to whole pixels + float handle_height = handle_rect.GetHeight(); + const float groove_height = std::ceil(handle_height * 0.5f); + const float center_y = rect_.GetCenter().y; + groove_rect.Min.y = std::floor(center_y - groove_height / 2); + groove_rect.Max.y = groove_rect.Min.y + groove_height; + + p->AddRectFilled(groove_rect.Min, groove_rect.Max, timeline_colors[(int)TimelineType::None], groove_height * 0.5f); + + double min = minimum() / factor; + double max = maximum() / factor; + const double span = std::max(max - min, 1e-9); + + auto fillRange = [&](double begin, double end, ImU32 color) { + if (begin > max || end < min) return; + + // the edges truncate to whole pixels and the right edge is inclusive, so even an event shorter than a + // pixel paints one full pixel in its color instead of an anti-aliased smear + ImRect r = groove_rect; + r.Min.x = rect_.Min.x + std::floor(((std::max(min, begin) - min) / span) * width()); + r.Max.x = rect_.Min.x + std::floor(((std::min(max, end) - min) / span) * width()) + 1.0f; + p->AddRectFilled(r.Min, r.Max, color); + }; + + if (auto replay = getReplay()) { + for (const auto &entry : *replay->getTimeline()) { + fillRange(entry.start_time, entry.end_time, timeline_colors[(int)entry.type]); + } + + ImU32 empty_color = ImGui::GetColorU32(ImGuiCol_WindowBg, 160 / 255.0f); + const auto event_data = replay->getEventData(); + for (const auto &[n, _] : replay->route().segments()) { + if (!event_data->isSegmentLoaded(n)) + fillRange(n * 60.0, (n + 1) * 60.0, empty_color); + } + } + + drawSliderHandle(p, handle_rect); + + if (thumbnail_time >= 0) { + float left = rect_.Min.x + (float)((thumbnail_time - min) * width() / span) - 1; + ImRect rc(ImVec2(left, rect_.Min.y + 1), ImVec2(left + 2, rect_.Max.y - 1)); + p->AddRectFilled(rc.Min, rc.Max, ImGui::GetColorU32(ImGuiCol_Header), 1.0f); + } +} + +void Slider::handleMousePress() { + // a press on the handle starts a drag and remembers the grab offset + const ImRect handle_rect = handleRect(); + if (handle_rect.Contains(ImGui::GetMousePos())) { + slider_down_ = true; + click_offset_ = ImGui::GetMousePos().x - handle_rect.Min.x; + return; + } + setValue(minimum() + (int)(((maximum() - minimum()) * (ImGui::GetMousePos().x - rect_.Min.x)) / width())); + sliderReleased(); +} + +StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType stream_type) + : CameraWidget(stream_name, stream_type) { + big_thumbnail_texture_.mipmap = true; // the hover thumbnail is drawn at a quarter of the stored size +} + +StreamCameraView::~StreamCameraView() { + for (auto &pending : pending_thumbnails_) pending.done.wait(); +} + +void StreamCameraView::parseQLog(std::shared_ptr qlog) { + auto thumbnails = std::make_shared>(); + auto done = ThreadPool::instance().run([qlog, thumbnails]() { + for (const Event &e : qlog->events) { + if (e.which != cereal::Event::Which::THUMBNAIL) continue; + capnp::FlatArrayMessageReader reader(e.data); + auto thumb_data = reader.getRoot().getThumbnail(); + auto image_data = thumb_data.getThumbnail(); + if (RgbImage thumb; decodeJpeg(image_data.begin(), image_data.size(), &thumb)) { + (*thumbnails)[thumb_data.getTimestampEof()] = std::move(thumb); + } + } + }); + pending_thumbnails_.push_back({std::move(done), std::move(thumbnails)}); +} + +void StreamCameraView::collectThumbnails() { + for (auto it = pending_thumbnails_.begin(); it != pending_thumbnails_.end();) { + if (it->done.wait_for(std::chrono::seconds(0)) != std::future_status::ready) { + ++it; + continue; + } + for (auto &[ts, thumb] : *it->thumbnails) big_thumbnails_[ts] = std::move(thumb); + it = pending_thumbnails_.erase(it); + } +} + +void StreamCameraView::draw(const ImVec2 &size, double thumbnail_time) { + collectThumbnails(); + CameraWidget::draw(size); + + ImDrawList *p = ImGui::GetWindowDrawList(); + bool scrubbing = false; + if (thumbnail_time >= 0) { + scrubbing = can->isPaused(); + scrubbing ? drawScrubThumbnail(p, thumbnail_time) : drawThumbnail(p, thumbnail_time); + } + if (auto alert = getReplay()->findAlertAtTime(scrubbing ? thumbnail_time : can->currentSec())) { + drawAlert(p, rect(), *alert, ImGui::GetFontSize(), ImGui::GetStyle().ChildRounding); + } + + if (can->isPaused()) { + ImFont *font = boldFont(); + const char *text = "PAUSED"; + const ImVec2 text_size = font->CalcTextSizeA(POINT_16_FONT_SIZE, FLT_MAX, 0.0f, text); + const ImVec2 center = rect().GetCenter(); + p->AddText(font, POINT_16_FONT_SIZE, ImVec2(center.x - text_size.x / 2, center.y - text_size.y / 2), + IM_COL32(200, 200, 200, static_cast(255 * 0.7f)), text); + } +} + +const RgbImage *StreamCameraView::thumbnailAt(double sec) { + auto it = big_thumbnails_.lower_bound(can->toMonoTime(sec)); + if (it == big_thumbnails_.end()) return nullptr; + if (big_thumbnail_texture_.id == 0 || big_thumbnail_texture_.key != it->first) { + big_thumbnail_texture_.upload(it->second); + big_thumbnail_texture_.key = it->first; + } + return &it->second; +} + +void StreamCameraView::drawScrubThumbnail(ImDrawList *p, double sec) { + p->AddRectFilled(rect().Min, rect().Max, IM_COL32(0, 0, 0, 255), ImGui::GetStyle().ChildRounding); + if (const RgbImage *image = thumbnailAt(sec)) { + const VideoPlacement placement = videoPlacement(rect(), (float)image->width / image->height, settings.crop_video); + drawVideoFrame(p, big_thumbnail_texture_.ref(), rect(), placement); + drawTime(p, rect(), sec); + } +} + +void StreamCameraView::drawThumbnail(ImDrawList *p, double sec) { + if (const RgbImage *image = thumbnailAt(sec)) { + // AddImage scales the stored image to the thumbnail height, keeping the aspect ratio + const int h = MIN_VIDEO_HEIGHT - THUMBNAIL_MARGIN * 2; + const int w = std::max(1, (int)std::lround((double)image->width * h / image->height)); + auto [min_sec, max_sec] = displayedTimeRange(); + int pos = (sec - min_sec) * width() / (max_sec - min_sec); + const int max_x = (int)width() - w - THUMBNAIL_MARGIN + 1; + int x = std::clamp(pos - w / 2, THUMBNAIL_MARGIN, std::max(THUMBNAIL_MARGIN, max_x)); + int y = height() - h - THUMBNAIL_MARGIN; + + ImRect thumb_rect(ImVec2(rect().Min.x + x, rect().Min.y + y), ImVec2(rect().Min.x + x + w, rect().Min.y + y + h)); + p->AddImageRounded(big_thumbnail_texture_.ref(), thumb_rect.Min, thumb_rect.Max, ImVec2(0, 0), ImVec2(1, 1), IM_COL32_WHITE, ImGui::GetStyle().FrameRounding); + p->AddRect(thumb_rect.Min, thumb_rect.Max, IM_COL32_WHITE, ImGui::GetStyle().FrameRounding, 0, 2.0f); + // look up the alert at the hovered time, the thumbnail frame itself can be seconds away + if (auto alert = getReplay()->findAlertAtTime(sec)) { + drawAlert(p, thumb_rect, *alert, POINT_10_FONT_SIZE, ImGui::GetStyle().FrameRounding); + } + drawTime(p, thumb_rect, sec); + } +} + +void StreamCameraView::drawTime(ImDrawList *p, const ImRect &rect, double seconds) { + char text[32]; + snprintf(text, sizeof(text), "%.2f", seconds); + ImFont *font = ImGui::GetFont(); + const ImVec2 text_size = font->CalcTextSizeA(POINT_10_FONT_SIZE, FLT_MAX, 0.0f, text); + // centered horizontally, above the bottom margin + p->AddText(font, POINT_10_FONT_SIZE, ImVec2(rect.GetCenter().x - text_size.x / 2, rect.Max.y - THUMBNAIL_MARGIN - text_size.y), + IM_COL32_WHITE, text); +} + +void StreamCameraView::drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size, float rounding) { + const ImU32 pen = IM_COL32_WHITE; + ImU32 color = withAlpha(timeline_colors[int(alert.type)], 128); + std::string text = alert.text1; + if (!alert.text2.empty()) text += "\n" + alert.text2; + + const ImRect &text_rect = rect; + ImFont *font = ImGui::GetFont(); + const float wrap_width = std::max(1.0f, text_rect.GetWidth()); + const ImVec2 r = font->CalcTextSizeA(font_size, FLT_MAX, wrap_width, text.c_str()); + p->AddRectFilled(ImVec2(text_rect.Min.x, text_rect.Min.y), ImVec2(text_rect.Max.x, text_rect.Min.y + r.y), color, rounding, ImDrawFlags_RoundCornersTop); + // each line is centered, wrapped continuations stay left aligned + float y = text_rect.Min.y; + for (const auto &line : utils::split(text, '\n')) { + const ImVec2 line_size = font->CalcTextSizeA(font_size, FLT_MAX, wrap_width, line.c_str()); + p->AddText(font, font_size, ImVec2(text_rect.Min.x + (text_rect.GetWidth() - line_size.x) / 2, y), pen, line.c_str(), nullptr, wrap_width); + y += line_size.y; + } +} diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.h b/openpilot/tools/cabana/ui/widgets/videowidget.h new file mode 100644 index 000000000..94b50dbf3 --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/videowidget.h @@ -0,0 +1,121 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/ui/widgets/cameraview.h" +#include "tools/cabana/ui/widgets/tabbar.h" +#include "tools/cabana/ui/tools/routeinfo.h" +#include "tools/replay/logreader.h" +#include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/ui/icons.h" + +class Slider { +public: + Slider() = default; + double currentSecond() const { return value() / factor; } + void setCurrentSecond(double sec) { setValue(sec * factor); } + void setTimeRange(double min, double max) { setRange(min * factor, max * factor); } + int value() const { return value_; } + void setValue(int v) { value_ = std::clamp(v, minimum_, maximum_); } + void setRange(int min, int max) { minimum_ = min; maximum_ = std::max(min, max); setValue(value_); } + int minimum() const { return minimum_; } + int maximum() const { return maximum_; } + bool isSliderDown() const { return slider_down_; } + float width() const { return rect_.GetWidth(); } + const ImRect &rect() const { return rect_; } + bool underMouse() const { return hovered_; } + bool mouseLeft() const { return left_; } // the mouse left the slider in the last draw() + void draw(double thumbnail_time); // thumbnail_time < 0: no thumbnail marker + static constexpr double factor = 1000.0; + + Observable<> sliderReleased; + +private: + void handleMousePress(); + void paint(double thumbnail_time); + ImRect handleRect() const; + int pixelPosToRangeValue(float x) const; + int minimum_ = 0; + int maximum_ = 99; + int value_ = 0; + bool slider_down_ = false; + float click_offset_ = 0; // where inside the handle the drag started + bool hovered_ = false; + bool left_ = false; + ImRect rect_; +}; + +class StreamCameraView : public CameraWidget { +public: + StreamCameraView(std::string stream_name, VisionStreamType stream_type); + ~StreamCameraView(); + void draw(const ImVec2 &size, double thumbnail_time); // thumbnail_time < 0: no thumbnail + void parseQLog(std::shared_ptr qlog); // decodes the thumbnails on the thread pool + +private: + struct PendingThumbnails { + std::future done; + std::shared_ptr> thumbnails; + }; + void collectThumbnails(); // moves the decoded thumbnails in once a parseQLog task is done + // the first thumbnail at or after sec, uploaded to big_thumbnail_texture_; nullptr when there is none + const RgbImage *thumbnailAt(double sec); + void drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size, float rounding); + void drawThumbnail(ImDrawList *p, double sec); + void drawScrubThumbnail(ImDrawList *p, double sec); + void drawTime(ImDrawList *p, const ImRect &rect, double seconds); + + std::map big_thumbnails_; + GlTexture big_thumbnail_texture_; // the currently shown thumbnail + std::vector pending_thumbnails_; +}; + +class VideoWidget { +public: + VideoWidget(); + void draw(); // content only; MainWindow puts it in a child region above the charts + float sizeHintHeight() const; + float defaultHeight(float width) const; + // MainWindow calls this every frame with the video dock visibility, so the camera widget gets its + // vipc thread started and stopped + void setVisible(bool visible); + std::string whatsThis() const; + +private: + void showThumbnail(double seconds); + void updateSliderThumbnail(); // the thumbnail follows the mouse over the slider + std::string formatTime(double sec, bool include_milliseconds = false); + void timeRangeChanged(); + void createCameraWidget(); + void drawCameraWidget(); + void drawPlaybackController(); + void skipToEnd(); + void toggleTimeDisplay(); + void createSpeedDropdown(); + void drawSpeedMenuItems(); + void loopPlaybackClicked(); + void cropVideoClicked(); + void vipcAvailableStreamsUpdated(std::set streams); + void showRouteInfo(); + + std::unique_ptr cam_widget_; + std::string speed_text_; + int speed_index_ = -1; // checked entry of the speed menu + bool skip_to_end_enabled_ = true; + bool msgs_received_ = false; // live-stream timestamps resolve when the first messages arrive + double thumbnail_display_time_ = -1; + std::unique_ptr slider_; + std::unique_ptr camera_tab_; + std::vector> route_info_dlgs_; + Connections connections_; // last: disconnected before the widgets its handlers dereference are destroyed +}; diff --git a/openpilot/tools/cabana/utils/elidedlabel.cc b/openpilot/tools/cabana/utils/elidedlabel.cc deleted file mode 100644 index 629a108b1..000000000 --- a/openpilot/tools/cabana/utils/elidedlabel.cc +++ /dev/null @@ -1,29 +0,0 @@ -#include "tools/cabana/utils/elidedlabel.h" -#include -#include - -ElidedLabel::ElidedLabel(QWidget *parent) : ElidedLabel({}, parent) {} - -ElidedLabel::ElidedLabel(const QString &text, QWidget *parent) : QLabel(text.trimmed(), parent) { - setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - setMinimumWidth(1); -} - -void ElidedLabel::resizeEvent(QResizeEvent* event) { - QLabel::resizeEvent(event); - lastText_ = elidedText_ = ""; -} - -void ElidedLabel::paintEvent(QPaintEvent *event) { - const QString curText = text(); - if (curText != lastText_) { - elidedText_ = fontMetrics().elidedText(curText, Qt::ElideRight, contentsRect().width()); - lastText_ = curText; - } - - QPainter painter(this); - drawFrame(&painter); - QStyleOption opt; - opt.initFrom(this); - style()->drawItemText(&painter, contentsRect(), alignment(), opt.palette, isEnabled(), elidedText_, foregroundRole()); -} diff --git a/openpilot/tools/cabana/utils/elidedlabel.h b/openpilot/tools/cabana/utils/elidedlabel.h deleted file mode 100644 index 577eea12a..000000000 --- a/openpilot/tools/cabana/utils/elidedlabel.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include -#include - -class ElidedLabel : public QLabel { - Q_OBJECT - -public: - explicit ElidedLabel(QWidget *parent = 0); - explicit ElidedLabel(const QString &text, QWidget *parent = 0); - -signals: - void clicked(); - -protected: - void paintEvent(QPaintEvent *event) override; - void resizeEvent(QResizeEvent* event) override; - void mouseReleaseEvent(QMouseEvent *event) override { - if (rect().contains(event->pos())) { - emit clicked(); - } - } - QString lastText_, elidedText_; -}; diff --git a/openpilot/tools/cabana/utils/util.cc b/openpilot/tools/cabana/utils/util.cc index 87a0f8942..34b6dfe85 100644 --- a/openpilot/tools/cabana/utils/util.cc +++ b/openpilot/tools/cabana/utils/util.cc @@ -303,7 +303,6 @@ QValidator::State DoubleValidator::validate(QString &input, int &pos) const { } namespace utils { - std::string homePath() { const char *home = ::getenv("HOME"); return home ? home : ""; diff --git a/openpilot/tools/cabana/utils/util.h b/openpilot/tools/cabana/utils/util.h index 5a3c62d11..3570320f3 100644 --- a/openpilot/tools/cabana/utils/util.h +++ b/openpilot/tools/cabana/utils/util.h @@ -1,209 +1,96 @@ #pragma once -#include +#include #include #include #include +#include +#include #include #include #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbc.h" -#include "tools/cabana/settings.h" - -inline QColor toQColor(const CabanaColor &color) { - return QColor(color.r, color.g, color.b, color.a); -} - -class LogSlider : public QSlider { - Q_OBJECT +#include "tools/cabana/core/color.h" +class SegmentTree { public: - LogSlider(double factor, Qt::Orientation orientation, QWidget *parent = nullptr) : factor(factor), QSlider(orientation, parent) {} + SegmentTree() = default; + void build(int n, const std::function &y); + inline std::pair minmax(int left, int right) const { return get_minmax(1, 0, size - 1, left, right); } +private: + std::pair get_minmax(int n, int left, int right, int range_left, int range_right) const; + void build_tree(const std::function &y, int n, int left, int right); + std::vector> tree; + int size = 0; +}; + +// maps a linear slider position onto a log10 scale +class LogScale { +public: + LogScale(double factor) : factor(factor) {} void setRange(double min, double max) { log_min = factor * std::log10(min); log_max = factor * std::log10(max); - QSlider::setRange(min, max); - setValue(QSlider::value()); } - int value() const { - double v = log_min + (log_max - log_min) * ((QSlider::value() - minimum()) / double(maximum() - minimum())); + int value(int pos, int pos_min, int pos_max) const { + double v = log_min + (log_max - log_min) * ((pos - pos_min) / double(pos_max - pos_min)); return std::lround(std::pow(10, v / factor)); } - void setValue(int v) { + int position(int v, int pos_min, int pos_max) const { double log_v = std::clamp(factor * std::log10(v), log_min, log_max); - v = minimum() + (maximum() - minimum()) * ((log_v - log_min) / (log_max - log_min)); - QSlider::setValue(v); + return pos_min + (pos_max - pos_min) * ((log_v - log_min) / (log_max - log_min)); } private: double factor, log_min = 0, log_max = 1; }; -enum { - ColorsRole = Qt::UserRole + 1, - BytesRole = Qt::UserRole + 2 -}; +enum class ValidState { Invalid, Intermediate, Acceptable }; -class SegmentTree { -public: - SegmentTree() = default; - void build(const std::vector &arr); - inline std::pair minmax(int left, int right) const { return get_minmax(1, 0, size - 1, left, right); } - -private: - std::pair get_minmax(int n, int left, int right, int range_left, int range_right) const; - void build_tree(const std::vector &arr, int n, int left, int right); - std::vector> tree; - int size = 0; -}; - -class MessageBytesDelegate : public QStyledItemDelegate { - Q_OBJECT -public: - MessageBytesDelegate(QObject *parent, bool multiple_lines = false); - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; - bool multipleLines() const { return multiple_lines; } - void setMultipleLines(bool v) { multiple_lines = v; } - QSize sizeForBytes(int n) const; - -private: - std::array hex_text_table; - QFontMetrics font_metrics; - QFont fixed_font; - QSize byte_size = {}; - bool multiple_lines = false; - int h_margin, v_margin; -}; - -// Accepts a single identifier: one or more [A-Za-z0-9_], spaces rewritten to '_'. -class NameValidator : public QValidator { - Q_OBJECT -public: - NameValidator(QObject *parent=nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; - -// Accepts comma-separated identifiers: \w+(,\w+)* -class NodeValidator : public QValidator { - Q_OBJECT -public: - NodeValidator(QObject *parent=nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; - -// Accepts one or more non-whitespace characters (\S+). -class NonWhitespaceValidator : public QValidator { - Q_OBJECT -public: - NonWhitespaceValidator(QObject *parent=nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; - -// Accepts a dotted IPv4 address (0-255 per octet). -class IpAddressValidator : public QValidator { - Q_OBJECT -public: - IpAddressValidator(QObject *parent=nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; - -// C-locale floating-point validator (matches QString::toDouble). -class DoubleValidator : public QValidator { - Q_OBJECT -public: - DoubleValidator(QObject *parent = nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; +// single identifier: one or more [A-Za-z0-9_], spaces rewritten to '_' +ValidState validateName(std::string &input); +// comma-separated identifiers: \w+(,\w+)* +ValidState validateNodes(const std::string &input); +// one or more non-whitespace characters (\S+) +ValidState validateNonWhitespace(const std::string &input); +// dotted IPv4 address (0-255 per octet) +ValidState validateIpAddress(const std::string &input); +// C-locale floating-point +ValidState validateDouble(const std::string &input); namespace utils { -QPixmap icon(const QString &id); +bool isMainThread(); +// inline on the main thread, queued until drainMainThreadQueue() otherwise +void runOnMainThread(std::function fn); +void drainMainThreadQueue(); std::string homePath(); std::filesystem::path configPath(); bool getClipboardText(std::string *text); // false if no clipboard tool is available bool setClipboardText(const std::string &text); -bool isDarkTheme(); -void setTheme(int theme); -QString formatSeconds(double sec, bool include_milliseconds = false, bool absolute_time = false); -inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) { - auto size = (r.size() - text.size()) / 2; - p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text); -} -inline QString toHex(const std::vector &dat, char separator = '\0') { - static const char digits[] = "0123456789ABCDEF"; - QString hex; - hex.reserve(dat.size() * (separator ? 3 : 2)); - for (size_t i = 0; i < dat.size(); ++i) { - if (separator && i) hex += QLatin1Char(separator); - hex += QLatin1Char(digits[dat[i] >> 4]); - hex += QLatin1Char(digits[dat[i] & 0xf]); - } - return hex; -} +std::string bootstrapSvg(const std::string &id); // empty if unknown // boundary conversions for the remaining Qt byte-array based state APIs template std::vector toBytes(const T &dat) { return {dat.begin(), dat.end()}; } -inline auto qbytes(const std::vector &dat) { - return decltype(QString().toUtf8())((const char *)dat.data(), (int)dat.size()); + +// a callback that is skipped once `alive` expired: the owner resets its token when it goes away +template +auto guarded(const std::shared_ptr &alive, F fn) { + return [alive = std::weak_ptr(alive), fn = std::move(fn)](auto &&...args) { + if (!alive.expired()) fn(std::forward(args)...); + }; } } -class ToolButton : public QToolButton { - Q_OBJECT -public: - ToolButton(const QString &icon, const QString &tooltip = {}, QWidget *parent = nullptr) : QToolButton(parent) { - setIcon(icon); - setToolTip(tooltip); - setAutoRaise(true); - const int metric = QApplication::style()->pixelMetric(QStyle::PM_SmallIconSize); - setIconSize({metric, metric}); - theme = settings.theme; - connect(&settings, &Settings::changed, this, &ToolButton::updateIcon); - } - void setIcon(const QString &icon) { - icon_str = icon; - QToolButton::setIcon(utils::icon(icon_str)); - } - -private: - void updateIcon() { if (std::exchange(theme, settings.theme) != theme) setIcon(icon_str); } - QString icon_str; - int theme; -}; - -class TabBar : public QTabBar { - Q_OBJECT - -public: - TabBar(QWidget *parent) : QTabBar(parent) {} - int addTab(const QString &text); - -private: - void closeTabClicked(); -}; - -// Watches SIGINT/SIGTERM via a self-pipe and a dedicated waiter thread -// (no Qt notifiers/timers). Exit is marshaled onto the GUI thread. +// Watches SIGINT/SIGTERM via a self-pipe and a dedicated waiter thread. +// on_signal runs on the waiter thread; the caller marshals to the GUI thread. class UnixSignalHandler { public: - UnixSignalHandler(); + UnixSignalHandler(std::function on_signal); ~UnixSignalHandler(); static void signalHandler(int s); @@ -214,7 +101,4 @@ private: }; int num_decimals(double num); -QString signalToolTip(const cabana::Signal *sig); -inline QString toHexString(int value) { return QString("0x%1").arg(QString::number(value, 16).toUpper(), 2, '0'); } -void initApp(int argc, char *argv[], bool disable_hidpi = true); -QPixmap bootstrapPixmap(const QString &id); +std::filesystem::path executableDir(); diff --git a/openpilot/tools/lib/auth.py b/openpilot/tools/lib/auth.py index 6a685e38d..02df4954a 100755 --- a/openpilot/tools/lib/auth.py +++ b/openpilot/tools/lib/auth.py @@ -22,34 +22,50 @@ Examples:: """ import argparse +import json import sys +import subprocess import pprint -import webbrowser -from http.server import BaseHTTPRequestHandler, HTTPServer +import time +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Any -from urllib.parse import parse_qs, urlencode +from urllib.parse import parse_qs, urlencode, urlsplit -from openpilot.tools.lib.api import APIError, CommaApi, UnauthorizedError +from openpilot.tools.lib.api import CommaApi, UnauthorizedError from openpilot.tools.lib.auth_config import set_token, get_token -class ClientRedirectServer(HTTPServer): - query_params: dict[str, Any] = {} +class ClientRedirectServer(ThreadingHTTPServer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.query_params: dict[str, Any] = {} + self.result_lock = threading.Lock() + + def get_request(self): + request, address = super().get_request() + request.settimeout(1) # Bound incomplete requests, including browser preconnections. + return request, address class ClientRedirectHandler(BaseHTTPRequestHandler): def do_GET(self): - if not self.path.startswith('/auth'): + if urlsplit(self.path).path not in ('/auth', '/auth/'): self.send_response(204) + self.end_headers() return - query = self.path.split('?', 1)[-1] - query_parsed = parse_qs(query, keep_blank_values=True) - self.server.query_params = query_parsed + query_parsed = parse_qs(urlsplit(self.path).query, keep_blank_values=True) + with self.server.result_lock: + if not self.server.query_params and ('code' in query_parsed or 'error' in query_parsed): + self.server.query_params = query_parsed self.send_response(200) self.send_header('Content-type', 'text/plain') self.end_headers() - self.wfile.write(b'Return to the CLI to continue') + try: + self.wfile.write(b'Sign-in received. You can close this tab and return to Cabana or your terminal.') + except ConnectionError: + pass # A closing browser tab must not discard the received callback. def log_message(self, format: str, *args: object) -> None: # noqa: A002 # stdlib override pass # this prevent http server from dumping messages to stdout @@ -94,36 +110,52 @@ def auth_redirect_link(method, port): raise NotImplementedError(f"no redirect implemented for method {method}") -def login(method): - # Let the OS select an available port to avoid colliding with other services. - web_server = ClientRedirectServer(('localhost', 0), ClientRedirectHandler) - oauth_uri = auth_redirect_link(method, web_server.server_port) - print(f'To sign in, use your browser and navigate to {oauth_uri}') - webbrowser.open(oauth_uri, new=2) - - while True: - web_server.handle_request() - if 'code' in web_server.query_params: - break - elif 'error' in web_server.query_params: - print('Authentication Error: "{}". Description: "{}" '.format( - web_server.query_params['error'], - web_server.query_params.get('error_description')), file=sys.stderr) - break - +def login(method, timeout=180): + """Sign in through a browser and save the token, returning a success/error status.""" try: - auth_resp = CommaApi().post('v2/auth/', data={'code': web_server.query_params['code'], 'provider': web_server.query_params['provider']}) - set_token(auth_resp['access_token']) - except APIError as e: - print(f'Authentication Error: {e}', file=sys.stderr) + with ClientRedirectServer(('localhost', 0), ClientRedirectHandler) as server: + url = auth_redirect_link(method, server.server_port) + print(f'To sign in, use your browser and navigate to {url}', file=sys.stderr) + browser = subprocess.Popen(['open' if sys.platform == 'darwin' else 'xdg-open', url], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + server.timeout = min(0.1, max(0, deadline - time.monotonic())) + server.handle_request() + params = server.query_params + if 'error' in params: + return {"error": "Sign-in was declined. Choose a provider to try again."} + if 'code' in params: + provider = {'google': 'g', 'apple': 'a', 'github': 'h'}[method] + if len(params['code']) != 1 or not params['code'][0].strip() or params.get('provider') != [provider]: + return {"error": "Invalid sign-in response. Please try again."} + response = CommaApi().post('v2/auth/', data={'code': params['code'], 'provider': params['provider']}, timeout=30) + token = response.get('access_token') + if not isinstance(token, str) or not token.strip(): + return {"error": "Sign-in did not return an access token. Please try again."} + CommaApi(token).get('v1/me', timeout=30) + set_token(token) + return {"success": True} + if browser.poll() not in (None, 0): + return {"error": "Could not open your browser. Check your default browser and try again."} + return {"error": "Sign-in timed out. Choose a provider to try again."} + except Exception: + return {"error": "Could not complete sign-in. Check your connection and try again."} if __name__ == '__main__': parser = argparse.ArgumentParser(description='Login to your comma account') parser.add_argument('method', default='google', const='google', nargs='?', choices=['google', 'apple', 'github', 'jwt']) parser.add_argument('jwt', nargs='?') + parser.add_argument('--json', action='store_true', help='Return browser sign-in status as JSON') args = parser.parse_args() + if args.json: + if args.method == 'jwt': + parser.error('--json requires a browser sign-in provider') + print(json.dumps(login(args.method))) + sys.exit(0) + if args.method == 'jwt': if args.jwt is None: print("method JWT selected, but no JWT was provided") @@ -131,7 +163,10 @@ if __name__ == '__main__': set_token(args.jwt) else: - login(args.method) + result = login(args.method) + if "error" in result: + print(result["error"], file=sys.stderr) + sys.exit(1) try: me = CommaApi(token=get_token()).get('/v1/me') diff --git a/openpilot/tools/lib/auth_config.py b/openpilot/tools/lib/auth_config.py index 5966bd34a..954225d4d 100644 --- a/openpilot/tools/lib/auth_config.py +++ b/openpilot/tools/lib/auth_config.py @@ -1,6 +1,7 @@ import json import os from openpilot.common.hardware.hw import Paths +from openpilot.common.utils import atomic_write class MissingAuthConfigError(Exception): @@ -18,7 +19,7 @@ def get_token(): def set_token(token): os.makedirs(Paths.config_root(), exist_ok=True) - with open(os.path.join(Paths.config_root(), 'auth.json'), 'w') as f: + with atomic_write(os.path.join(Paths.config_root(), 'auth.json'), overwrite=True) as f: json.dump({'access_token': token}, f) diff --git a/openpilot/tools/lib/file_downloader.py b/openpilot/tools/lib/file_downloader.py index efb06095b..fd517d7ad 100755 --- a/openpilot/tools/lib/file_downloader.py +++ b/openpilot/tools/lib/file_downloader.py @@ -19,16 +19,14 @@ import shutil import sys import tempfile -import zstandard as zstd - from openpilot.common.hardware.hw import Paths -from openpilot.tools.lib.api import CommaApi, UnauthorizedError, APIError -from openpilot.tools.lib.auth_config import get_token -from openpilot.tools.lib.url_file import URLFile def api_call(func): """Run an API call, outputting JSON result or error to stdout.""" + from openpilot.tools.lib.api import CommaApi, UnauthorizedError, APIError + from openpilot.tools.lib.auth_config import get_token + try: result = func(CommaApi(get_token())) json.dump(result, sys.stdout) @@ -62,6 +60,7 @@ def make_decompressor(compression): if compression == 'bz2': return bz2.BZ2Decompressor() if compression == 'zst': + import zstandard as zstd return zstd.ZstdDecompressor().decompressobj() raise ValueError(f"Unsupported compression type: {compression}") @@ -126,6 +125,7 @@ def cmd_download(args): try: # Stream the file in a single HTTP request instead of making # a separate Range request per chunk (which was very slow). + from openpilot.tools.lib.url_file import URLFile pool = URLFile.pool_manager() r = pool.request("GET", url, preload_content=False) if r.status not in (200, 206): diff --git a/openpilot/tools/lib/route.py b/openpilot/tools/lib/route.py index 98334a06c..94ed7c1fa 100644 --- a/openpilot/tools/lib/route.py +++ b/openpilot/tools/lib/route.py @@ -206,7 +206,8 @@ class Segment: class RouteName: def __init__(self, name_str: str): self._name_str = name_str - delim = next(c for c in self._name_str if c in ("|", "/")) + pipe, slash = name_str.find("|"), name_str.find("/") + delim = "|" if slash == -1 or 0 <= pipe < slash else "/" self._dongle_id, self._time_str = self._name_str.split(delim) assert len(self._dongle_id) == 16, self._name_str diff --git a/openpilot/tools/replay/py_downloader.cc b/openpilot/tools/replay/py_downloader.cc index db2b2be12..286d8a731 100644 --- a/openpilot/tools/replay/py_downloader.cc +++ b/openpilot/tools/replay/py_downloader.cc @@ -5,6 +5,10 @@ #include #include #include +#include +#ifdef __APPLE__ +#include +#endif #include #include #include @@ -14,6 +18,9 @@ namespace { +constexpr const char *AUTH_MODULE = "openpilot.tools.lib.auth"; +constexpr const char *DOWNLOADER_MODULE = "openpilot.tools.lib.file_downloader"; + static std::mutex handler_mutex; static DownloadProgressHandler progress_handler = nullptr; @@ -26,61 +33,82 @@ void reportProgress(const char *line) { // Run a Python command and capture stdout. Stderr is scanned for PROGRESS lines and otherwise passed // through to the parent's stderr. Returns stdout content. If abort is signaled, kills the child process. -std::string runPython(const std::vector &args, std::atomic *abort = nullptr) { - // Build argv for execvp +std::string runPython(const char *module, const std::vector &args, std::atomic *abort = nullptr) { + // Build argv for the Python module std::vector argv; argv.push_back("python3"); argv.push_back("-m"); - argv.push_back("openpilot.tools.lib.file_downloader"); + argv.push_back(module); for (const auto &a : args) { argv.push_back(a.c_str()); } argv.push_back(nullptr); + auto open_pipe = [](int (&fds)[2]) { +#ifdef __linux__ + return pipe2(fds, O_CLOEXEC); +#else + if (pipe(fds) != 0) return -1; + if (fcntl(fds[0], F_SETFD, FD_CLOEXEC) == 0 && fcntl(fds[1], F_SETFD, FD_CLOEXEC) == 0) return 0; + close(fds[0]); close(fds[1]); + return -1; +#endif + }; int stdout_pipe[2], stderr_pipe[2]; - if (pipe(stdout_pipe) != 0) { + if (open_pipe(stdout_pipe) != 0) { rWarning("py_downloader: pipe() failed"); return {}; } - if (pipe(stderr_pipe) != 0) { + if (open_pipe(stderr_pipe) != 0) { rWarning("py_downloader: pipe() failed"); close(stdout_pipe[0]); close(stdout_pipe[1]); return {}; } - pid_t pid = fork(); - if (pid < 0) { - rWarning("py_downloader: fork() failed"); + // Avoid copying the large replay address space and running atfork handlers on + // every segment download: both can stall rendering even from a worker thread. + std::vector environment; +#ifdef __APPLE__ + char **parent_environment = *_NSGetEnviron(); +#else + char **parent_environment = environ; +#endif + for (char **entry = parent_environment; *entry; ++entry) { + if (strncmp(*entry, "OPENPILOT_PREFIX=", 17) != 0) environment.emplace_back(*entry); + } + std::vector envp; + for (auto &entry : environment) envp.push_back(entry.data()); + envp.push_back(nullptr); + + posix_spawn_file_actions_t actions; + posix_spawnattr_t attributes; + int error = posix_spawn_file_actions_init(&actions); + const bool actions_initialized = error == 0; + if (!error) error = posix_spawnattr_init(&attributes); + const bool attributes_initialized = error == 0; + if (!error) error = posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0); + if (!error) error = posix_spawn_file_actions_adddup2(&actions, stdout_pipe[1], STDOUT_FILENO); + if (!error) error = posix_spawn_file_actions_adddup2(&actions, stderr_pipe[1], STDERR_FILENO); + for (int fd : {stdout_pipe[0], stdout_pipe[1], stderr_pipe[0], stderr_pipe[1]}) { + if (!error) error = posix_spawn_file_actions_addclose(&actions, fd); + } +#ifdef POSIX_SPAWN_SETSID + if (!error) error = posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETSID); +#else + if (!error) error = posix_spawnattr_setpgroup(&attributes, 0); + if (!error) error = posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETPGROUP); +#endif + pid_t pid = -1; + if (!error) error = posix_spawnp(&pid, "python3", &actions, &attributes, const_cast(argv.data()), envp.data()); + if (attributes_initialized) posix_spawnattr_destroy(&attributes); + if (actions_initialized) posix_spawn_file_actions_destroy(&actions); + if (error) { + rWarning("py_downloader: posix_spawnp() failed: %s", strerror(error)); close(stdout_pipe[0]); close(stdout_pipe[1]); close(stderr_pipe[0]); close(stderr_pipe[1]); return {}; } - if (pid == 0) { - // Child process — detach from controlling terminal so Python - // cannot corrupt terminal settings needed by ncurses in the parent. - setsid(); - int devnull = open("/dev/null", O_RDONLY); - if (devnull >= 0) { - dup2(devnull, STDIN_FILENO); - if (devnull > STDERR_FILENO) close(devnull); - } - - // Clear OPENPILOT_PREFIX so the Python process uses default paths - // (e.g. ~/.comma/auth.json). The prefix is only for IPC in the parent. - unsetenv("OPENPILOT_PREFIX"); - - close(stdout_pipe[0]); - dup2(stdout_pipe[1], STDOUT_FILENO); - close(stdout_pipe[1]); - close(stderr_pipe[0]); - dup2(stderr_pipe[1], STDERR_FILENO); - close(stderr_pipe[1]); - - execvp("python3", const_cast(argv.data())); - _exit(127); - } - // Parent process close(stdout_pipe[1]); close(stderr_pipe[1]); @@ -190,19 +218,23 @@ std::string download(const std::string &url, bool use_cache, std::atomic * if (!use_cache) { args.push_back("--no-cache"); } - return runPython(args, abort); + return runPython(DOWNLOADER_MODULE, args, abort); } std::string decompress(const std::string &path, std::atomic *abort) { - return runPython({"decompress", path}, abort); + return runPython(DOWNLOADER_MODULE, {"decompress", path}, abort); } std::string getRouteFiles(const std::string &route) { - return runPython({"route-files", route}); + return runPython(DOWNLOADER_MODULE, {"route-files", route}); +} + +std::string authenticate(const std::string &provider, std::atomic *abort) { + return runPython(AUTH_MODULE, {provider, "--json"}, abort); } std::string getDevices() { - return runPython({"devices"}); + return runPython(DOWNLOADER_MODULE, {"devices"}); } std::string getDeviceRoutes(const std::string &dongle_id, int64_t start_ms, int64_t end_ms, bool preserved) { @@ -219,7 +251,7 @@ std::string getDeviceRoutes(const std::string &dongle_id, int64_t start_ms, int6 args.push_back(std::to_string(end_ms)); } } - return runPython(args); + return runPython(DOWNLOADER_MODULE, args); } } // namespace PyDownloader diff --git a/openpilot/tools/replay/py_downloader.h b/openpilot/tools/replay/py_downloader.h index 80fab6ab0..fac66dabd 100644 --- a/openpilot/tools/replay/py_downloader.h +++ b/openpilot/tools/replay/py_downloader.h @@ -18,6 +18,9 @@ std::string decompress(const std::string &path, std::atomic *abort = nullp // Returns JSON string of route files (same format as /v1/route/.../files API) std::string getRouteFiles(const std::string &route); +// Browser sign-in; abort closes the local callback server. Returns a JSON status. +std::string authenticate(const std::string &provider, std::atomic *abort); + // Returns JSON string of user's devices std::string getDevices(); diff --git a/openpilot/tools/replay/replay.cc b/openpilot/tools/replay/replay.cc index 6449fc30e..26e10e7c7 100644 --- a/openpilot/tools/replay/replay.cc +++ b/openpilot/tools/replay/replay.cc @@ -40,8 +40,7 @@ void Replay::setupServices(const std::vector &allow, const std::vec auto event_schema = capnp::Schema::from().asStruct(); sockets_.resize(event_schema.getUnionFields().size(), nullptr); - std::vector active_services; - active_services.reserve(services.size()); + auto &active_services = active_services_; for (const auto &[name, _] : services) { bool is_blocked = std::find(block.begin(), block.end(), name) != block.end(); @@ -55,9 +54,6 @@ void Replay::setupServices(const std::vector &allow, const std::vec std::string services_str = join(active_services, ", "); rInfo("active services: %s", services_str.c_str()); - if (!sm_) { - pm_ = std::make_unique(active_services); - } } void Replay::setupSegmentManager(bool has_filters) { @@ -73,6 +69,15 @@ void Replay::setupSegmentManager(bool has_filters) { } Replay::~Replay() { + stop(); + camera_server_.reset(); + seg_mgr_.reset(); +} + +void Replay::stop() { + // Merge callbacks access both Replay and its owner. Join them while all objects + // and their owning pointers are still alive, before stopping the playback thread. + seg_mgr_->stop(); if (stream_thread_.joinable()) { rInfo("shutdown: in progress..."); interruptStream([this]() { @@ -82,8 +87,6 @@ Replay::~Replay() { stream_thread_.join(); rInfo("shutdown: done"); } - camera_server_.reset(); - seg_mgr_.reset(); } bool Replay::load() { @@ -235,6 +238,7 @@ void Replay::publishMessage(const Event *e) { if (event_filter_ && event_filter_(e)) return; if (!sm_) { + if (!pm_) pm_ = std::make_unique(active_services_); // consumers with an event filter never need one auto bytes = e->data.asBytes(); int ret = pm_->send(sockets_[e->which], (capnp::byte *)bytes.begin(), bytes.size()); if (ret == -1) { diff --git a/openpilot/tools/replay/replay.h b/openpilot/tools/replay/replay.h index 7cd5fbecd..59e1d67d4 100644 --- a/openpilot/tools/replay/replay.h +++ b/openpilot/tools/replay/replay.h @@ -37,6 +37,7 @@ public: Replay(const std::string &route, std::vector allow, std::vector block, SubMaster *sm = nullptr, uint32_t flags = REPLAY_FLAG_NONE, const std::string &data_dir = "", bool auto_source = false); ~Replay(); + void stop(); bool load(); RouteLoadError lastRouteError() const { return route().lastError(); } void start(int seconds = 0) { seekTo(min_seconds_ + seconds, false); } @@ -107,6 +108,7 @@ private: double min_seconds_ = 0; double max_seconds_ = 0; SubMaster *sm_ = nullptr; + std::vector active_services_; std::unique_ptr pm_; std::vector sockets_; std::unique_ptr camera_server_; diff --git a/openpilot/tools/replay/seg_mgr.cc b/openpilot/tools/replay/seg_mgr.cc index 0778cacbc..2c616bd00 100644 --- a/openpilot/tools/replay/seg_mgr.cc +++ b/openpilot/tools/replay/seg_mgr.cc @@ -3,6 +3,10 @@ #include SegmentManager::~SegmentManager() { + stop(); +} + +void SegmentManager::stop() { { std::unique_lock lock(mutex_); exit_ = true; @@ -47,10 +51,11 @@ void SegmentManager::setCurrentSegment(int seg_num) { void SegmentManager::manageSegmentCache() { while (true) { std::unique_lock lock(mutex_); - cv_.wait(lock, [this]() { return exit_ || needs_update_; }); + cv_.wait_until(lock, next_retry_, [this]() { return exit_ || needs_update_; }); if (exit_) break; needs_update_ = false; + next_retry_ = std::chrono::steady_clock::time_point::max(); auto cur = segments_.lower_bound(cur_seg_num_); if (cur == segments_.end()) continue; @@ -65,8 +70,12 @@ void SegmentManager::manageSegmentCache() { bool merged = mergeSegments(begin, end); // Free segments outside the current range - std::for_each(segments_.begin(), begin, [](auto &segment) { segment.second.reset(); }); - std::for_each(end, segments_.end(), [](auto &segment) { segment.second.reset(); }); + auto evict = [this](auto &segment) { + segment.second.reset(); + load_attempts_.erase(segment.first); + }; + std::for_each(segments_.begin(), begin, evict); + std::for_each(end, segments_.end(), evict); if (merged && onSegmentMergedCallback_) { onSegmentMergedCallback_(); // Notify listener that segments have been merged @@ -117,7 +126,25 @@ void SegmentManager::loadSegmentsInRange(SegmentMap::iterator begin, SegmentMap: auto tryLoadSegment = [this](auto first, auto last) { for (auto it = first; it != last; ++it) { auto &segment_ptr = it->second; + auto &attempt = load_attempts_[it->first]; + if (segment_ptr && segment_ptr->getState() == Segment::LoadState::Failed) { + // A failed object must not permanently occupy its cache slot. Back off + // between retries while allowing other segments to load meanwhile. + if (attempt.count >= MAX_SEGMENT_LOAD_ATTEMPTS) continue; + const auto now = std::chrono::steady_clock::now(); + if (attempt.retry_at == std::chrono::steady_clock::time_point::max()) { + attempt.retry_at = now + std::chrono::seconds(attempt.count); + } + if (now < attempt.retry_at) { + next_retry_ = std::min(next_retry_, attempt.retry_at); + continue; + } + segment_ptr.reset(); + rWarning("retrying segment %d (attempt %d/%d)", it->first, attempt.count + 1, MAX_SEGMENT_LOAD_ATTEMPTS); + } if (!segment_ptr) { + ++attempt.count; + attempt.retry_at = std::chrono::steady_clock::time_point::max(); if (onBenchmarkEvent_) { onBenchmarkEvent_(it->first, "loading"); } diff --git a/openpilot/tools/replay/seg_mgr.h b/openpilot/tools/replay/seg_mgr.h index 54e156fb6..6542335ca 100644 --- a/openpilot/tools/replay/seg_mgr.h +++ b/openpilot/tools/replay/seg_mgr.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -9,6 +10,7 @@ #include "tools/replay/route.h" constexpr int MIN_SEGMENTS_CACHE = 5; +constexpr int MAX_SEGMENT_LOAD_ATTEMPTS = 3; // Includes the initial load. using SegmentMap = std::map>; @@ -23,6 +25,7 @@ public: SegmentManager(const std::string &route_name, uint32_t flags, const std::string &data_dir = "", bool auto_source = false) : flags_(flags), route_(route_name, data_dir, auto_source), event_data_(std::make_shared()) {} ~SegmentManager(); + void stop(); bool load(); void setCurrentSegment(int seg_num); @@ -55,4 +58,11 @@ private: std::function onSegmentMergedCallback_ = nullptr; std::function onBenchmarkEvent_ = nullptr; std::set merged_segments_; + struct LoadAttempt { + int count = 0; + std::chrono::steady_clock::time_point retry_at = std::chrono::steady_clock::time_point::max(); + }; + // Accessed only by the cache management thread. + std::map load_attempts_; + std::chrono::steady_clock::time_point next_retry_ = std::chrono::steady_clock::time_point::max(); }; diff --git a/openpilot/tools/sim/launch_openpilot.sh b/openpilot/tools/sim/launch_openpilot.sh index ea3c4cb8f..1c9c3d0b7 100755 --- a/openpilot/tools/sim/launch_openpilot.sh +++ b/openpilot/tools/sim/launch_openpilot.sh @@ -18,4 +18,4 @@ SCRIPT_DIR=$(dirname "$0") OPENPILOT_DIR=$SCRIPT_DIR/../../ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" -cd $OPENPILOT_DIR/system/manager && exec ./manager.py +cd "$OPENPILOT_DIR/system/manager" && exec ./manager.py diff --git a/openpilot/tools/sim/lib/camerad.py b/openpilot/tools/sim/lib/camerad.py index 206e4fdf9..7d8782d9a 100644 --- a/openpilot/tools/sim/lib/camerad.py +++ b/openpilot/tools/sim/lib/camerad.py @@ -4,6 +4,7 @@ from openpilot.cereal.visionipc import VisionStreamType from msgq.visionipc import VisionIpcServer from openpilot.cereal import messaging +from openpilot.system.camerad.cameras.nv12_info import get_nv12_info from openpilot.tools.sim.lib.common import W, H @@ -23,17 +24,20 @@ def rgb_to_nv12(rgb): g_sub = (g[0::2, 0::2] + g[0::2, 1::2] + g[1::2, 0::2] + g[1::2, 1::2] + 2) >> 2 b_sub = (b[0::2, 0::2] + b[0::2, 1::2] + b[1::2, 0::2] + b[1::2, 1::2] + 2) >> 2 - # U and V planes + # Interleave U and V planes for NV12 format u = np.clip((b_sub * 56 - g_sub * 37 - r_sub * 19 + 0x8080) >> 8, 0, 255).astype(np.uint8) v = np.clip((r_sub * 56 - g_sub * 47 - b_sub * 9 + 0x8080) >> 8, 0, 255).astype(np.uint8) + uv = np.stack((u, v), axis=-1).reshape(h // 2, w) - # Interleave UV for NV12 format - uv = np.empty((h // 2, w), dtype=np.uint8) - uv[:, 0::2] = u - uv[:, 1::2] = v + # Copy the visible image into the aligned NV12 buffer + stride, y_height, uv_height, size = get_nv12_info(w, h) + nv12 = np.zeros(size, dtype=np.uint8) + planes = nv12[:stride * (y_height + uv_height)].reshape(-1, stride) - return np.concatenate([y.ravel(), uv.ravel()]).tobytes() + planes[:h, :w] = y + planes[y_height:y_height + h // 2, :w] = uv + return nv12.tobytes() class Camerad: """Simulates the camerad daemon""" @@ -44,9 +48,12 @@ class Camerad: self.frame_wide_id = 0 self.vipc_server = VisionIpcServer("camerad") - self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_NARROW_ROAD, 5, W, H) + stride, y_height, _, size = get_nv12_info(W, H) + buffer_args = (5, W, H, size, stride, stride * y_height) + + self.vipc_server.create_buffers_with_sizes(VisionStreamType.VISION_STREAM_NARROW_ROAD, *buffer_args) if dual_camera: - self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_WIDE_ROAD, 5, W, H) + self.vipc_server.create_buffers_with_sizes(VisionStreamType.VISION_STREAM_WIDE_ROAD, *buffer_args) self.vipc_server.start_listener() @@ -65,10 +72,9 @@ class Camerad: return rgb_to_nv12(rgb) def _send_yuv(self, yuv, frame_id, pub_type, yuv_type): - eof = int(frame_id * 0.05 * 1e9) - self.vipc_server.send(yuv_type, yuv, frame_id, eof, eof) - dat = messaging.new_message(pub_type, valid=True) + self.vipc_server.send(yuv_type, yuv, frame_id, dat.logMonoTime, dat.logMonoTime) + msg = { "frameId": frame_id, "transform": [1.0, 0.0, 0.0, diff --git a/pyproject.toml b/pyproject.toml index 6a13796d7..8571d914d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "tqdm", # cars (fw_versions.py) on start + many one-off uses # core - "scons==4.10.1", # 4.11 removed the qt3 tool still used to build Cabana + "scons", "pycapnp==2.1.0", # 2.2 introduces a memory leak due to cyclic references "numpy >=2.0", @@ -44,7 +44,6 @@ dependencies = [ # these should be removed "pyzmq", - "sentry-sdk", "setproctitle", "jeepney", "zstandard", # this can go once we're on Python 3.14+ diff --git a/scripts/apply-pr.sh b/scripts/apply-pr.sh index ad0af46b4..f4a29ec08 100755 --- a/scripts/apply-pr.sh +++ b/scripts/apply-pr.sh @@ -6,6 +6,6 @@ if [ $# -eq 0 ]; then fi BASE="https://github.com/commaai/openpilot/pull/" -PR_NUM="$(echo $1 | grep -o -E '[0-9]+')" +PR_NUM="$(echo "$1" | grep -o -E '[0-9]+')" -curl -L $BASE/$PR_NUM.patch | git apply -3 +curl -L "$BASE/$PR_NUM.patch" | git apply -3 diff --git a/scripts/checkout-pr.sh b/scripts/checkout-pr.sh index eeba816d8..5f5dc8b1e 100755 --- a/scripts/checkout-pr.sh +++ b/scripts/checkout-pr.sh @@ -7,10 +7,10 @@ if [ $# -eq 0 ]; then fi BASE="https://github.com/commaai/openpilot/pull/" -PR_NUM="$(echo $1 | grep -o -E '[0-9]+')" +PR_NUM="$(echo "$1" | grep -o -E '[0-9]+')" BRANCH=tmp-pr${PR_NUM} -git branch -D -f $BRANCH || true -git fetch -u -f origin pull/$PR_NUM/head:$BRANCH -git switch $BRANCH +git branch -D -f "$BRANCH" || true +git fetch -u -f origin "pull/$PR_NUM/head:$BRANCH" +git switch "$BRANCH" git reset --hard FETCH_HEAD diff --git a/scripts/jenkins_loop_test.sh b/scripts/jenkins_loop_test.sh index 8073f4668..6cbdafd35 100755 --- a/scripts/jenkins_loop_test.sh +++ b/scripts/jenkins_loop_test.sh @@ -11,7 +11,7 @@ BRANCH="master" RUNS="20" COOKIE_JAR=/tmp/cookies -CRUMB=$(curl -s --cookie-jar $COOKIE_JAR 'https://jenkins.comma.life/crumbIssuer/api/xml?xpath=concat(//crumbRequestField,":",//crumb)') +CRUMB=$(curl -s --cookie-jar "$COOKIE_JAR" 'https://jenkins.comma.life/crumbIssuer/api/xml?xpath=concat(//crumbRequestField,":",//crumb)') FIRST_LOOP=1 @@ -25,13 +25,13 @@ function loop() { if [[ $FIRST_LOOP ]]; then TEMP_DIR=$(mktemp -d) - GIT_LFS_SKIP_SMUDGE=1 git clone --quiet -b $BRANCH --depth=1 --no-tags git@github.com:commaai/openpilot $TEMP_DIR - git -C $TEMP_DIR checkout --quiet -b $JENKINS_BRANCH - echo "TESTING: $(date)" >> $TEMP_DIR/testing_jenkins - git -C $TEMP_DIR add testing_jenkins - git -C $TEMP_DIR commit --quiet -m "testing" - git -C $TEMP_DIR push --quiet -f origin $JENKINS_BRANCH - rm -rf $TEMP_DIR + GIT_LFS_SKIP_SMUDGE=1 git clone --quiet -b "$BRANCH" --depth=1 --no-tags git@github.com:commaai/openpilot "$TEMP_DIR" + git -C "$TEMP_DIR" checkout --quiet -b "$JENKINS_BRANCH" + echo "TESTING: $(date)" >> "$TEMP_DIR/testing_jenkins" + git -C "$TEMP_DIR" add testing_jenkins + git -C "$TEMP_DIR" commit --quiet -m "testing" + git -C "$TEMP_DIR" push --quiet -f origin "$JENKINS_BRANCH" + rm -rf "$TEMP_DIR" FIRST_BUILD=1 echo '' echo 'waiting on Jenkins...' @@ -40,15 +40,15 @@ function loop() { FIRST_LOOP="" fi - FIRST_BUILD=$(curl -s $API_ROUTE/api/json | jq .nextBuildNumber) + FIRST_BUILD=$(curl -s "$API_ROUTE/api/json" | jq .nextBuildNumber) LAST_BUILD=$((FIRST_BUILD+N-1)) - TEST_BUILDS=( $(seq $FIRST_BUILD $LAST_BUILD) ) + read -r -a TEST_BUILDS <<< "$(seq -s ' ' "$FIRST_BUILD" "$LAST_BUILD")" # Start N new builds - for i in ${TEST_BUILDS[@]}; + for i in "${TEST_BUILDS[@]}"; do echo "Starting build $i" - curl -s --output /dev/null --cookie $COOKIE_JAR -H "$CRUMB" -X POST $API_ROUTE/build?delay=0sec + curl -s --output /dev/null --cookie "$COOKIE_JAR" -H "$CRUMB" -X POST "$API_ROUTE/build?delay=0sec" sleep 5 done echo "" @@ -58,14 +58,14 @@ function loop() { sleep 30 count=0 - for i in ${TEST_BUILDS[@]}; + for i in "${TEST_BUILDS[@]}"; do - RES=$(curl -s -w "\n%{http_code}" --cookie $COOKIE_JAR -H "$CRUMB" $API_ROUTE/$i/api/json) + RES=$(curl -s -w "\n%{http_code}" --cookie "$COOKIE_JAR" -H "$CRUMB" "$API_ROUTE/$i/api/json") HTTP_CODE=$(tail -n1 <<< "$RES") JSON=$(sed '$ d' <<< "$RES") if [[ $HTTP_CODE == "200" ]]; then - STILL_RUNNING=$(echo $JSON | jq .inProgress) + STILL_RUNNING=$(echo "$JSON" | jq .inProgress) if [[ $STILL_RUNNING == "true" ]]; then echo -e "Build $i: ${YELLOW}still running${NC}" continue @@ -119,11 +119,11 @@ function _looper() { echo -e "You are about to start $RUNS Jenkins builds against the $BRANCH branch." echo -e "If you expect this to run overnight, ${UNDERLINE}${BOLD}unplug the cold reboot power switch${NC} from the testing closet before." echo "" - read -p "Press (y/Y) to confirm: " choice + read -r -p "Press (y/Y) to confirm: " choice if [[ "$choice" == "y" || "$choice" == "Y" ]]; then loop fi } -_looper $@ +_looper "$@" diff --git a/scripts/launch_corolla.sh b/scripts/launch_corolla.sh index 926569a1e..8894caaf7 100755 --- a/scripts/launch_corolla.sh +++ b/scripts/launch_corolla.sh @@ -4,4 +4,4 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" export FINGERPRINT="TOYOTA_COROLLA_TSS2" export SKIP_FW_QUERY="1" -$DIR/../launch_openpilot.sh +"$DIR/../launch_openpilot.sh" diff --git a/scripts/lint/check_dependencies.py b/scripts/lint/check_dependencies.py new file mode 100644 index 000000000..1cec6fca9 --- /dev/null +++ b/scripts/lint/check_dependencies.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +import sys +import tomllib +import importlib.metadata +from pathlib import Path + + +def main() -> int: + if sys.prefix == sys.base_prefix: + print("Dependency checks require a virtual environment. Run tools/op.sh setup first.") + return 1 + + project = tomllib.loads((Path(__file__).resolve().parents[2] / "pyproject.toml").read_text())["project"] + direct = len(project["dependencies"]) + sum(len(deps) for deps in project["optional-dependencies"].values()) + # Count each installed package once, including transitive dependencies and all extras. + packages = {dist.metadata["Name"].lower().replace("_", "-") for dist in importlib.metadata.distributions()} + # Logical file sizes avoid filesystem block-size differences. Don't follow links to the interpreter or source tree. + size = sum(path.stat().st_size for path in Path(sys.prefix).rglob("*") if not path.is_symlink() and path.is_file()) + + """ + This test prevents our depency footprint from growing. + These values are *not* intended to be increased, and we + expect to strictly drive these down over time. + """ + failed = False + for name, value, limit in ( + ("Direct dependencies (all extras)", direct, 39), + ("Total dependencies", len(packages), 84), + ("Venv size (MiB)", size / 1024**2, 570), + ): + print(f"{name}: {value:g} (limit: {limit})") + failed |= value > limit + return int(failed) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lint/check_nomerge_comments.sh b/scripts/lint/check_nomerge_comments.sh index 6737d62a2..0dc3a7bd7 100755 --- a/scripts/lint/check_nomerge_comments.sh +++ b/scripts/lint/check_nomerge_comments.sh @@ -2,9 +2,9 @@ FAIL=0 -if grep -n '\(#\|//\)\([[:space:]]*\)NOMERGE' $@; then +if grep -n '\(#\|//\)\([[:space:]]*\)NOMERGE' "$@"; then echo -e "NOMERGE comments found! Remove them before merging\n" FAIL=1 fi -exit $FAIL +exit "$FAIL" diff --git a/scripts/lint/check_shebang_format.sh b/scripts/lint/check_shebang_format.sh index 89b95d592..8757bb8a0 100755 --- a/scripts/lint/check_shebang_format.sh +++ b/scripts/lint/check_shebang_format.sh @@ -2,14 +2,14 @@ FAIL=0 -if grep '^#!.*python' $@ | grep -v '#!/usr/bin/env python3$'; then +if grep -I '^#!.*python' "$@" | grep -v '#!/usr/bin/env python3$'; then echo -e "Invalid shebang! Must use '#!/usr/bin/env python3'\n" FAIL=1 fi -if grep '^#!.*bash' $@ | grep -v '#!/usr/bin/env bash$'; then +if grep -I '^#!.*bash' "$@" | grep -v '#!/usr/bin/env bash$'; then echo -e "Invalid shebang! Must use '#!/usr/bin/env bash'" FAIL=1 fi -exit $FAIL +exit "$FAIL" diff --git a/scripts/lint/check_shell.py b/scripts/lint/check_shell.py new file mode 100644 index 000000000..d34bd6e45 --- /dev/null +++ b/scripts/lint/check_shell.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""A minimal shellcheck-like static analysis tool for shell scripts. + +Covers syntax, unquoted expansions, scalar $@ assignments, and read -r. +""" +import re +import argparse +import subprocess +from pathlib import Path + +VARIABLE = re.compile(r"\$(?:\{[^}\n]*\}|[A-Za-z_]\w*|[@*0-9])") +ASSIGNMENT = re.compile(r"[A-Za-z_]\w*(?:\[[^]]*\])?\+?=") +OPAQUE = re.compile(r"\$?\(\([^\n]*?\)\)|\[\[.*?\]\]", re.DOTALL) +HEREDOC = re.compile(r"<<(-?)\s*('[^']+'|\"[^\"]+\"|\\?[A-Za-z_]\w*)") + + +def commands(text): + def scan(i=0, end="", pattern_group=False): + start, quote, words, expansions, documents = i, False, [], [], [] + cases = [] + while i < len(text): + c = text[i] + if c == "\\": + i += 2 + continue + if c == "'" and not quote: + i = text.find("'", i + 1) + 1 or len(text) + continue + if c == '"': + quote = not quote + elif (not quote or text.startswith("$((", i)) and (match := OPAQUE.match(text, i)): + i = match.end() + continue + elif text.startswith("$(", i): + if not quote: + expansions.append(i) + i = yield from scan(i + 2, ")") + continue + elif match := VARIABLE.match(text, i): + if not quote and not match[0].startswith("${#"): + expansions.append(i) + i = match.end() + continue + elif not quote: + if text.startswith("<<<", i): + i += 3 + continue + if c == "#" and i == start: + i = text.find("\n", i) + i = len(text) if i < 0 else i + start = i + continue + if not text.startswith("<<<", i) and (match := HEREDOC.match(text, i)): + documents.append((match[2].strip("'\"").lstrip("\\"), bool(match[1]))) + i = match.end() + continue + if c in " \t\r\n;|&()": + if start < i: + words.append((text[start:i], start, expansions)) + if len(words) >= 3 and words[0][0] == "case" and words[-1][0] == "in": + cases.append(True) + words = [] + if words and words[0][0] == "esac" and cases: + cases.pop() + words = [] + pattern = bool(cases and cases[-1]) + expansions = [] + if c in "\n;|&()": + if words and not (pattern or pattern_group) and (c != ")" or end): + yield words + words = [] + if c == end and not pattern: + return i + 1 + if c == ")" and pattern: + cases[-1] = False + if cases and (terminator := re.match(r";(?:;&|;|&)", text[i:])): + cases[-1] = True + i += len(terminator[0]) - 1 + if c == "(": + if pattern and i == start: + cases[-1] = False + i = yield from scan(i + 1, ")", pattern or pattern_group) + start = i + continue + if c == "\n": + for delimiter, strip_tabs in documents: + while i < len(text): + stop = text.find("\n", i + 1) + stop = len(text) if stop < 0 else stop + line, i = text[i + 1:stop], stop + if (line.lstrip("\t") if strip_tabs else line) == delimiter: + break + documents = [] + start = i + 1 + i += 1 + if start < i: + words.append((text[start:i], start, expansions)) + if words: + yield words + return i + yield from scan() + + +def check_text(text): + for words in commands(text): + command = next((w for w, _, _ in words if not ASSIGNMENT.match(w) and w not in {"if", "then", "elif", "while", "until", "do", "!"}), "") + prefix = True + for index, (word, start, expansions) in enumerate(words): + assignment = ASSIGNMENT.match(word) and (prefix or command in {"export", "local", "declare", "readonly", "typeset"}) + prefix = prefix and (bool(assignment) or word in {"if", "then", "elif", "while", "until", "do", "!"}) + for offset in expansions: + array = word in {"$@", "$*"} or "[@]" in word or "[*]" in word + if not assignment and (command not in {"case", "for", "select"} or array) and not (index and words[index - 1][0] == "<<<"): + yield text.count("\n", 0, offset) + 1, "Quote this expansion to prevent word splitting and globbing" + if assignment and re.fullmatch(r'"[^"\n]*\$@[^"\n]*"', ASSIGNMENT.sub("", word, count=1)): + yield text.count("\n", 0, start) + 1, 'Use an array for "$@", or "$*" to join arguments' + if command == "read" and not any(re.fullmatch(r"-[A-Za-z]*r[A-Za-z0-9]*", w) for w, _, _ in words): + yield text.count("\n", 0, next(start for w, start, _ in words if w == "read")) + 1, "Use read -r to preserve backslashes" + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("filenames", nargs="+") + failed = False + for filename in parser.parse_args().filenames: + syntax = subprocess.run(["bash", "-n", "--", filename], check=False) + failed |= syntax.returncode != 0 + if syntax.returncode == 0: + for line, message in check_text(Path(filename).read_text()): + print(f"{filename}:{line}: {message}") + failed = True + raise SystemExit(failed) diff --git a/scripts/lint/lint.sh b/scripts/lint/lint.sh index 8f3aab4e5..43bd83a90 100755 --- a/scripts/lint/lint.sh +++ b/scripts/lint/lint.sh @@ -9,7 +9,7 @@ NC='\033[0m' DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" ROOT="$DIR/../../" -cd $ROOT +cd "$ROOT" FAILED=0 @@ -26,10 +26,10 @@ function run() { done shift 1; - CMD="$@" + CMD=("$@") set +e - log="$((eval "$CMD" ) 2>&1)" + log="$("${CMD[@]}" 2>&1)" if [[ $? -eq 0 ]]; then echo -e "[${GREEN}✔${NC}]" @@ -42,22 +42,21 @@ function run() { } function run_tests() { - ALL_FILES=$1 - PYTHON_FILES=$2 - run "ruff" ruff check openpilot --quiet - run "check_indentation" $DIR/check_indentation.py $PYTHON_FILES - run "check_added_large_files" $DIR/check_added_large_files.py --maxkb=120 $ALL_FILES - run "check_shebang_scripts_are_executable" $DIR/check_shebang_scripts_are_executable.py $ALL_FILES - run "check_shebang_format" $DIR/check_shebang_format.sh $ALL_FILES - run "check_nomerge_comments" $DIR/check_nomerge_comments.sh $ALL_FILES + run "check_shell" python3 "$DIR/check_shell.py" "${SHELL_FILES[@]}" + run "check_dependencies" python3 "$DIR/check_dependencies.py" + run "check_indentation" "$DIR/check_indentation.py" "${PYTHON_FILES[@]}" + run "check_added_large_files" "$DIR/check_added_large_files.py" --maxkb=120 "${ALL_FILES[@]}" + run "check_shebang_scripts_are_executable" "$DIR/check_shebang_scripts_are_executable.py" "${ALL_FILES[@]}" + run "check_shebang_format" "$DIR/check_shebang_format.sh" "${ALL_FILES[@]}" + run "check_nomerge_comments" "$DIR/check_nomerge_comments.sh" "${ALL_FILES[@]}" if [[ -z "$FAST" ]]; then run "ty" ty check openpilot - run "codespell" codespell $ALL_FILES --ignore-words=$ROOT/.codespellignore + run "codespell" codespell "${ALL_FILES[@]}" --ignore-words="$ROOT/.codespellignore" fi - return $FAILED + return "$FAILED" } function help() { @@ -67,6 +66,8 @@ function help() { echo "" echo -e "${BOLD}${UNDERLINE}Tests:${NC}" echo -e " ${BOLD}ruff${NC}" + echo -e " ${BOLD}check_shell${NC}" + echo -e " ${BOLD}check_dependencies${NC}" echo -e " ${BOLD}check_indentation${NC}" echo -e " ${BOLD}ty${NC}" echo -e " ${BOLD}codespell${NC}" @@ -101,17 +102,29 @@ while [[ $# -gt 0 ]]; do esac done -RUN=$([ -z "$RUN" ] && echo "" || echo "!($(echo $RUN | sed 's/ /|/g'))") -SKIP="@($(echo $SKIP | sed 's/ /|/g'))" +RUN=$([ -z "$RUN" ] && echo "" || echo "!($(echo "$RUN" | sed 's/ /|/g'))") +SKIP="@($(echo "$SKIP" | sed 's/ /|/g'))" -IGNORED_DIRS="^openpilot/third_party/.*" -GIT_FILES="$(git ls-files openpilot | grep -vE "$IGNORED_DIRS")" -ALL_FILES="" -for f in $GIT_FILES; do - if [[ -f $f ]]; then - ALL_FILES+="$f"$'\n' +ALL_FILES=() +PYTHON_FILES=() +while IFS= read -r -d '' f; do + if [[ $f =~ ^openpilot/third_party/.* ]]; then + continue fi -done -PYTHON_FILES=$(echo "$ALL_FILES" | grep --color=never '.py$' || true) + if [[ -f $f ]]; then + ALL_FILES+=("$f") + if [[ $f == *.py ]]; then + PYTHON_FILES+=("$f") + fi + fi +done < <(git ls-files -z openpilot) -run_tests "$ALL_FILES" "$PYTHON_FILES" +# Include tooling, launchers, and the extensionless Git hook. +SHELL_FILES=() +while IFS= read -r -d '' f; do + if [[ -f $f ]]; then + SHELL_FILES+=("$f") + fi +done < <(git ls-files -z '*.sh' '*.bash' scripts/post-commit) + +run_tests diff --git a/scripts/retry.sh b/scripts/retry.sh index 23501d755..382190fe1 100755 --- a/scripts/retry.sh +++ b/scripts/retry.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash function fail { - echo $1 >&2 + echo "$1" >&2 exit 1 } @@ -14,7 +14,7 @@ function retry { "$@" && break || { if [[ $n -lt $max ]]; then ((n++)) - sleep $delay; + sleep "$delay"; else fail "The command has failed after $n attempts." fi diff --git a/tools/op.sh b/tools/op.sh index 3d7d17a76..d1a7dcebb 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -14,9 +14,9 @@ UNDERLINE='\033[4m' BOLD='\033[1m' NC='\033[0m' -SHELL_NAME="$(basename ${SHELL})" -RC_FILE="${HOME}/.$(basename ${SHELL})rc" -if [ "$(uname)" == "Darwin" ] && [ $SHELL == "/bin/bash" ]; then +SHELL_NAME="$(basename "${SHELL}")" +RC_FILE="${HOME}/.$(basename "${SHELL}")rc" +if [ "$(uname)" == "Darwin" ] && [ "$SHELL" == "/bin/bash" ]; then RC_FILE="$HOME/.bash_profile" fi @@ -73,11 +73,11 @@ function op_get_openpilot_dir() { function op_install_post_commit() { op_get_openpilot_dir - if [[ ! -d $OPENPILOT_ROOT/.git/hooks/post-commit.d ]]; then - mkdir $OPENPILOT_ROOT/.git/hooks/post-commit.d - mv $OPENPILOT_ROOT/.git/hooks/post-commit $OPENPILOT_ROOT/.git/hooks/post-commit.d 2>/dev/null || true + if [[ ! -d "$OPENPILOT_ROOT/.git/hooks/post-commit.d" ]]; then + mkdir "$OPENPILOT_ROOT/.git/hooks/post-commit.d" + mv "$OPENPILOT_ROOT/.git/hooks/post-commit" "$OPENPILOT_ROOT/.git/hooks/post-commit.d" 2>/dev/null || true fi - cd $OPENPILOT_ROOT/.git/hooks + cd "$OPENPILOT_ROOT/.git/hooks" ln -sf ../../scripts/post-commit post-commit } @@ -103,7 +103,7 @@ function op_check_git() { fi echo "Checking for git lfs files..." - if [[ $(file -b $OPENPILOT_ROOT/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx) == "data" ]]; then + if [[ $(file -b "$OPENPILOT_ROOT/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx") == "data" ]]; then echo -e " ↳ [${GREEN}✔${NC}] git lfs files found." else echo -e " ↳ [${RED}✗${NC}] git lfs files not found! Run 'git lfs pull'" @@ -112,7 +112,7 @@ function op_check_git() { echo "Checking for git submodules..." for name in $(git config --file .gitmodules --get-regexp path | awk '{ print $2 }' | tr '\n' ' '); do - if [[ -z $(ls $OPENPILOT_ROOT/$name) ]]; then + if [[ -z $(ls "$OPENPILOT_ROOT/$name") ]]; then echo -e " ↳ [${RED}✗${NC}] git submodule $name not found! Run 'git submodule update --init --recursive'" return 1 fi @@ -123,23 +123,7 @@ function op_check_git() { function op_check_os() { echo "Checking for compatible os version..." if [[ "$OSTYPE" == "linux-gnu"* ]]; then - - if [ -f "/etc/os-release" ]; then - source /etc/os-release - case "$VERSION_CODENAME" in - "jammy" | "kinetic" | "noble" | "focal") - echo -e " ↳ [${GREEN}✔${NC}] Ubuntu $VERSION_CODENAME detected." - ;; - * ) - echo -e " ↳ [${RED}✗${NC}] Incompatible Ubuntu version $VERSION_CODENAME detected!" - return 1 - ;; - esac - else - echo -e " ↳ [${RED}✗${NC}] No /etc/os-release on your system. Make sure you're running on Ubuntu, or similar!" - return 1 - fi - + echo -e " ↳ [${GREEN}✔${NC}] Linux detected." elif [[ "$OSTYPE" == "darwin"* ]]; then echo -e " ↳ [${GREEN}✔${NC}] macOS detected." else @@ -150,10 +134,10 @@ function op_check_os() { function op_check_venv() { echo "Checking for venv..." - if [[ -f $OPENPILOT_ROOT/.venv/bin/activate ]]; then + if [[ -f "$OPENPILOT_ROOT/.venv/bin/activate" ]]; then echo -e " ↳ [${GREEN}✔${NC}] venv detected." else - echo -e " ↳ [${RED}✗${NC}] Can't activate venv in $OPENPILOT_ROOT. Assuming global env!" + echo -e " ↳ [${RED}✗${NC}] Can't activate venv in '$OPENPILOT_ROOT'. Assuming global env!" fi } @@ -163,7 +147,7 @@ function op_before_cmd() { fi op_get_openpilot_dir - cd $OPENPILOT_ROOT + cd "$OPENPILOT_ROOT" result="$((op_check_openpilot_dir ) 2>&1)" || (echo -e "$result" && return 1) result="${result}\n$(( op_check_git ) 2>&1)" || (echo -e "$result" && return 1) @@ -192,7 +176,7 @@ EOF echo -e " ↳ [${GREEN}✔${NC}] op installed successfully. Open a new shell to use it." op_get_openpilot_dir - cd $OPENPILOT_ROOT + cd "$OPENPILOT_ROOT" op_check_openpilot_dir op_check_os @@ -211,7 +195,7 @@ EOF echo "Installing dependencies..." st="$(date +%s)" SETUP_SCRIPT="tools/setup_dependencies.sh" - if ! $OPENPILOT_ROOT/$SETUP_SCRIPT; then + if ! "$OPENPILOT_ROOT/$SETUP_SCRIPT"; then echo -e " ↳ [${RED}✗${NC}] Dependencies installation failed!" return 1 fi @@ -222,6 +206,12 @@ EOF echo "Pulling git lfs files..." st="$(date +%s)" + git config --local filter.lfs.clean ".venv/bin/git-lfs clean -- %f" + git config --local filter.lfs.smudge ".venv/bin/git-lfs smudge -- %f" + git config --local filter.lfs.process ".venv/bin/git-lfs filter-process" + git config --local filter.lfs.required true + printf '#!/bin/sh\nexec .venv/bin/git-lfs pre-push "$@"\n' > "$(git rev-parse --git-path hooks)/pre-push" + chmod +x "$(git rev-parse --git-path hooks)/pre-push" if ! retry 3 git lfs pull; then echo -e " ↳ [${RED}✗${NC}] Pulling git lfs files failed!" return 1 @@ -240,7 +230,7 @@ function op_auth() { function op_activate_venv() { # bash 3.2 can't handle this without the 'set +e' set +e - source $OPENPILOT_ROOT/.venv/bin/activate &> /dev/null || true + source "$OPENPILOT_ROOT/.venv/bin/activate" &> /dev/null || true set -e # persist venv on PATH across GitHub Actions steps @@ -252,18 +242,18 @@ function op_activate_venv() { function op_venv() { op_before_cmd - if [[ ! -f $OPENPILOT_ROOT/.venv/bin/activate ]]; then - echo -e "No venv found in $OPENPILOT_ROOT" + if [[ ! -f "$OPENPILOT_ROOT/.venv/bin/activate" ]]; then + echo -e "No venv found in '$OPENPILOT_ROOT'" return 1 fi case $SHELL_NAME in "zsh") ZSHRC_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t 'tmp_zsh') - echo "source $RC_FILE; source $OPENPILOT_ROOT/.venv/bin/activate" >> $ZSHRC_DIR/.zshrc + echo "source \"$RC_FILE\"; source \"$OPENPILOT_ROOT/.venv/bin/activate\"" >> "$ZSHRC_DIR/.zshrc" ZDOTDIR=$ZSHRC_DIR zsh ;; *) - bash --rcfile <(echo "source $RC_FILE; source $OPENPILOT_ROOT/.venv/bin/activate") ;; + bash --rcfile <(echo "source \"$RC_FILE\"; source \"$OPENPILOT_ROOT/.venv/bin/activate\"") ;; esac } @@ -349,6 +339,11 @@ function op_clip() { op_run_command openpilot/tools/clip/run.py "$@" } +function op_docs() { + op_before_cmd + op_run_command python docs/serve.py "$@" +} + function op_check_agnos_update() { if [[ ! -f "/AGNOS" ]]; then return 0 @@ -410,14 +405,14 @@ function op_start() { if [[ -f "/AGNOS" ]]; then op_before_cmd op_check_agnos_update - op_run_command sudo systemctl restart comma $@ + op_run_command sudo systemctl restart comma "$@" fi } function op_stop() { if [[ -f "/AGNOS" ]]; then op_before_cmd - op_run_command sudo systemctl stop comma $@ + op_run_command sudo systemctl stop comma "$@" fi } @@ -447,6 +442,7 @@ function op_default() { echo -e " ${BOLD}replay${NC} Run Replay" echo -e " ${BOLD}cabana${NC} Run Cabana" echo -e " ${BOLD}clip${NC} Run clip (linux only)" + echo -e " ${BOLD}docs${NC} Build or serve the openpilot documentation" echo -e " ${BOLD}adb${NC} Run adb shell" echo -e " ${BOLD}ssh${NC} comma prime SSH helper" echo "" @@ -502,6 +498,7 @@ function _op() { test ) shift 1; op_test "$@" ;; replay ) shift 1; op_replay "$@" ;; clip ) shift 1; op_clip "$@" ;; + docs ) shift 1; op_docs "$@" ;; sim ) shift 1; op_sim "$@" ;; switch ) shift 1; op_switch "$@" ;; start ) shift 1; op_start "$@" ;; diff --git a/tools/release/README.md b/tools/release/README.md index 862836bf9..7e2e7e2fa 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -22,7 +22,7 @@ - [ ] fresh install with `openpilot-test.comma.ai` - [ ] drive on fresh install - [ ] no submodules or LFS - - [ ] check sentry, MTBF, etc. + - [ ] check MTBF, etc. - [ ] stress test passes in production - [ ] publish the blog post - [ ] `git reset --hard origin/release-mici-staging` diff --git a/tools/release/build_release.sh b/tools/release/build_release.sh index 21d0bba44..dd6c22e4c 100755 --- a/tools/release/build_release.sh +++ b/tools/release/build_release.sh @@ -3,7 +3,7 @@ set -e set -x DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -cd $DIR +cd "$DIR" BUILD_DIR=/data/openpilot SOURCE_DIR="$(git rev-parse --show-toplevel)" @@ -19,26 +19,26 @@ BUILD_BRANCH=release-mici-staging # set git identity -source $DIR/identity.sh +source "$DIR/identity.sh" echo "[-] Setting up repo T=$SECONDS" if ! git -C "$SOURCE_DIR" worktree remove --force "$BUILD_DIR" 2>/dev/null; then - rm -rf $BUILD_DIR + rm -rf "$BUILD_DIR" fi git -C "$SOURCE_DIR" worktree prune git -C "$SOURCE_DIR" worktree add --detach --no-checkout "$BUILD_DIR" -cd $BUILD_DIR +cd "$BUILD_DIR" git update-ref -d "refs/heads/$BUILD_BRANCH" git symbolic-ref HEAD "refs/heads/$BUILD_BRANCH" git read-tree --empty # do the files copy echo "[-] copying files T=$SECONDS" -cd $SOURCE_DIR +cd "$SOURCE_DIR" ./tools/release/release_files.py | xargs -0 cp -pR --parents -t "$BUILD_DIR" -- # in the directory -cd $BUILD_DIR +cd "$BUILD_DIR" # use the full CPU available for speeding up the build. # openpilot resets the CPU frequencies when test_onroad.py runs below. @@ -49,9 +49,6 @@ for policy in /sys/devices/system/cpu/cpufreq/policy*; do done scons -if [ -n "$INCLUDE_BIG_MODEL" ]; then - test -f openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest -fi if [ -z "$PANDA_DEBUG_BUILD" ]; then # release panda fw @@ -61,6 +58,11 @@ else scons panda/ fi +find openpilot/selfdrive/modeld/models -name '*.pkl' -size +95M -exec ./openpilot/common/file_chunker.py {} \; +if [ -n "$INCLUDE_BIG_MODEL" ]; then + test -f openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest +fi + # Ensure no submodules in release if test "$(git submodule--helper list | wc -l)" -gt "0"; then echo "submodules found:" @@ -96,8 +98,8 @@ git -c core.compression=0 add -f . git -c core.compression=0 -c gc.auto=0 commit -m "openpilot v$VERSION" # Run tests -cd $BUILD_DIR -RELEASE=1 ./openpilot/selfdrive/test/test_onroad.py +cd "$BUILD_DIR" +RELEASE=1 ./openpilot/selfdrive/test/test_onroad.py "$@" #tools/test_runner.py openpilot/selfdrive/car/tests/test_car_interfaces.py echo "[-] pushing release T=$SECONDS" diff --git a/tools/release/build_stripped.sh b/tools/release/build_stripped.sh index 6015fdc5d..5177ac259 100755 --- a/tools/release/build_stripped.sh +++ b/tools/release/build_stripped.sh @@ -3,23 +3,23 @@ set -ex DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -SOURCE_DIR="$(git -C $DIR rev-parse --show-toplevel)" +SOURCE_DIR="$(git -C "$DIR" rev-parse --show-toplevel)" if [ -z "$TARGET_DIR" ]; then TARGET_DIR="$(mktemp -d)" fi # set git identity -source $DIR/identity.sh +source "$DIR/identity.sh" echo "[-] Setting up target repo T=$SECONDS" -rm -rf $TARGET_DIR -mkdir -p $TARGET_DIR -cd $TARGET_DIR -cp -r $SOURCE_DIR/.git $TARGET_DIR +rm -rf "$TARGET_DIR" +mkdir -p "$TARGET_DIR" +cd "$TARGET_DIR" +cp -r "$SOURCE_DIR/.git" "$TARGET_DIR" echo "[-] setting up stripped branch sync T=$SECONDS" -cd $TARGET_DIR +cd "$TARGET_DIR" # tmp branch git checkout --orphan tmp @@ -32,20 +32,21 @@ find . -maxdepth 1 -not -path './.git' -not -name '.' -not -name '..' -exec rm - # do the files copy echo "[-] copying files T=$SECONDS" -cd $SOURCE_DIR +cd "$SOURCE_DIR" ./tools/release/release_files.py | xargs -0 cp -pR --parents -t "$TARGET_DIR" -- # in the directory -cd $TARGET_DIR +cd "$TARGET_DIR" rm -rf .git/modules/ -find openpilot/selfdrive/modeld/models -name '*.onnx' -size +95M -exec ./openpilot/common/file_chunker.py {} \; +find openpilot/selfdrive/modeld/models -name '*.pkl' -size +95M -exec ./openpilot/common/file_chunker.py {} \; # include source commit hash and build date in commit -GIT_HASH=$(git --git-dir=$SOURCE_DIR/.git rev-parse HEAD) -GIT_COMMIT_DATE=$(git --git-dir=$SOURCE_DIR/.git show --no-patch --format='%ct %ci' HEAD) +GIT_HASH=$(git --git-dir="$SOURCE_DIR/.git" rev-parse HEAD) +GIT_COMMIT_DATE=$(git --git-dir="$SOURCE_DIR/.git" show --no-patch --format='%ct %ci' HEAD) DATETIME=$(date '+%Y-%m-%dT%H:%M:%S') -VERSION=$(cat $SOURCE_DIR/openpilot/sunnypilot/common/version.h | awk -F\" '{print $2}') +VERSION=$(cat "$SOURCE_DIR/openpilot/sunnypilot/common/version.h" | awk -F\" '{print $2}') + echo -n "$GIT_HASH" > git_src_commit echo -n "$GIT_COMMIT_DATE" > git_src_commit_date @@ -78,8 +79,11 @@ fi if [ ! -z "$BRANCH" ]; then echo "[-] Pushing to $BRANCH T=$SECONDS" + # Reset hooks since releases exclude .venv + git config --local core.hooksPath .git/hooks + git lfs update --force # uploading the larger pack is faster than spending CPU to optimize it - git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin tmp:$BRANCH + git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin "tmp:$BRANCH" fi echo "[-] done T=$SECONDS, ready at $TARGET_DIR" diff --git a/tools/release/check-dirty.sh b/tools/release/check-dirty.sh index ac049970c..73d393f3c 100755 --- a/tools/release/check-dirty.sh +++ b/tools/release/check-dirty.sh @@ -2,7 +2,7 @@ set -e DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -cd $DIR +cd "$DIR" if [ ! -z "$(git status --porcelain)" ]; then echo "Dirty working tree after build:" diff --git a/tools/release/check-submodules.sh b/tools/release/check-submodules.sh index 7120fa15d..5c5204e6b 100755 --- a/tools/release/check-submodules.sh +++ b/tools/release/check-submodules.sh @@ -9,7 +9,7 @@ has_submodule_changes() { return 1 } -while read hash submodule ref; do +while read -r hash submodule ref; do if [ -z "$hash" ] || [ -z "$submodule" ]; then continue fi @@ -23,21 +23,21 @@ while read hash submodule ref; do if [ "$CHECK_PR_REFS" = "true" ] && has_submodule_changes "$submodule"; then echo "Checking $submodule (non-master): verifying hash $hash exists" - git -C $submodule fetch --depth 100 origin - if git -C $submodule cat-file -e $hash 2>/dev/null; then + git -C "$submodule" fetch --depth 100 origin + if git -C "$submodule" cat-file -e "$hash" 2>/dev/null; then echo "$submodule ok (hash exists)" else echo "$submodule: $hash does not exist in the repository" exit 1 fi else - git -C $submodule fetch --depth 100 origin master - git -C $submodule branch -r --contains $hash | grep "origin/master" + git -C "$submodule" fetch --depth 100 origin master + git -C "$submodule" branch -r --contains "$hash" | grep "origin/master" if [ "$?" -eq 0 ]; then echo "$submodule ok" else - echo "$submodule: $hash is not on master" + echo "$submodule: $hash not on master" exit 1 fi fi -done <<< $(git submodule status --recursive) +done <<< "$(git submodule status)" diff --git a/tools/release/release_files.py b/tools/release/release_files.py index 641dd7ff8..bcf2d148a 100755 --- a/tools/release/release_files.py +++ b/tools/release/release_files.py @@ -35,7 +35,7 @@ if __name__ == "__main__": continue rf = os.fsdecode(tracked_file) - if not os.getenv("INCLUDE_BIG_MODEL") and rf.startswith("openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx"): + if not os.getenv("INCLUDE_BIG_MODEL") and rf.startswith("openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl"): continue blacklisted = any(re.search(p, rf) for p in blacklist) whitelisted = any(re.search(p, rf) for p in whitelist) diff --git a/tools/scripts/adb_ssh.sh b/tools/scripts/adb_ssh.sh index c584a72f2..b808243d3 100755 --- a/tools/scripts/adb_ssh.sh +++ b/tools/scripts/adb_ssh.sh @@ -36,7 +36,7 @@ SSH_PORT=2222 while ss -tln | grep -q ":${SSH_PORT} "; do SSH_PORT=$((SSH_PORT + 1)) done -adb forward tcp:${SSH_PORT} tcp:22 +adb forward tcp:"${SSH_PORT}" tcp:22 # SSH! -ssh comma@localhost -p ${SSH_PORT} "$@" +ssh comma@localhost -p "${SSH_PORT}" "$@" diff --git a/tools/scripts/profiling/clpeak/.gitignore b/tools/scripts/profiling/clpeak/.gitignore deleted file mode 100644 index d575c207c..000000000 --- a/tools/scripts/profiling/clpeak/.gitignore +++ /dev/null @@ -1 +0,0 @@ -clpeak/ diff --git a/tools/scripts/profiling/clpeak/build.sh b/tools/scripts/profiling/clpeak/build.sh deleted file mode 100755 index 5206ed0c4..000000000 --- a/tools/scripts/profiling/clpeak/build.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" -cd $DIR - -if [ ! -d "$DIR/clpeak" ]; then - git clone https://github.com/krrishnarraj/clpeak.git - - cd clpeak - git fetch - git checkout ec2d3e70e1abc7738b81f9277c7af79d89b2133b - git reset --hard origin/master - git submodule update --init --recursive --remote - - git apply ../run_continuously.patch -fi - -cd clpeak -mkdir build || true -cd build -cmake .. -cmake --build . diff --git a/tools/scripts/profiling/clpeak/run_continuously.patch b/tools/scripts/profiling/clpeak/run_continuously.patch deleted file mode 100644 index 075c65bd2..000000000 --- a/tools/scripts/profiling/clpeak/run_continuously.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/src/clpeak.cpp b/src/clpeak.cpp -index 8cb192b..b6fe6f5 100644 ---- a/src/clpeak.cpp -+++ b/src/clpeak.cpp -@@ -47,7 +47,7 @@ int clPeak::runAll() - - log->xmlOpenTag("clpeak"); - log->xmlAppendAttribs("os", OS_NAME); -- for (size_t p = 0; p < platforms.size(); p++) -+ for (size_t p = 0; p < platforms.size(); (p+1 % platforms.size())) - { - if (forcePlatform && (p != specifiedPlatform)) - continue; diff --git a/tools/scripts/profiling/palanteer/.gitignore b/tools/scripts/profiling/palanteer/.gitignore deleted file mode 100644 index 158e7b075..000000000 --- a/tools/scripts/profiling/palanteer/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -palanteer/ -viewer diff --git a/tools/scripts/profiling/palanteer/setup.sh b/tools/scripts/profiling/palanteer/setup.sh deleted file mode 100755 index 6f115dc86..000000000 --- a/tools/scripts/profiling/palanteer/setup.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash - -set -e - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" -cd $DIR - -if [ ! -d palanteer ]; then - git clone https://github.com/dfeneyrou/palanteer - pip install wheel - sudo apt install libunwind-dev libdw-dev -fi - -cd palanteer -git pull - -mkdir -p build -cd build -cmake .. -DCMAKE_BUILD_TYPE=Release -make -j$(nproc) - -pip install --force-reinstall python/dist/palanteer*.whl - -cp bin/palanteer $DIR/viewer diff --git a/tools/scripts/profiling/perfetto/.gitignore b/tools/scripts/profiling/perfetto/.gitignore deleted file mode 100644 index d86c89d46..000000000 --- a/tools/scripts/profiling/perfetto/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -trace_* - -tracebox -trace_processor - -perfetto/ -configs/ diff --git a/tools/scripts/profiling/perfetto/build.sh b/tools/scripts/profiling/perfetto/build.sh deleted file mode 100755 index 24255ac3b..000000000 --- a/tools/scripts/profiling/perfetto/build.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -if [ ! -d perfetto ]; then - git clone https://android.googlesource.com/platform/external/perfetto/ -fi - -cd perfetto - -tools/install-build-deps --linux-arm -tools/gn gen --args='is_debug=false target_os="linux" target_cpu="arm64"' out/linux -tools/ninja -C out/linux tracebox traced traced_probes perfetto diff --git a/tools/scripts/profiling/perfetto/copy.sh b/tools/scripts/profiling/perfetto/copy.sh deleted file mode 100755 index b91d49a80..000000000 --- a/tools/scripts/profiling/perfetto/copy.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -DEST=tici:/data/openpilot/selfdrive/debug/profiling/perfetto - -scp -r perfetto/out/linux/tracebox $DEST -scp -r perfetto/test/configs $DEST diff --git a/tools/scripts/profiling/perfetto/record.sh b/tools/scripts/profiling/perfetto/record.sh deleted file mode 100755 index 9715a7e7e..000000000 --- a/tools/scripts/profiling/perfetto/record.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" -cd $DIR - -OUT=trace_ -sudo ./tracebox -o $OUT --txt -c configs/scheduling.cfg -sudo chown $USER:$USER $OUT diff --git a/tools/scripts/profiling/perfetto/server.sh b/tools/scripts/profiling/perfetto/server.sh deleted file mode 100755 index 103ad9bd8..000000000 --- a/tools/scripts/profiling/perfetto/server.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -curl -LO https://get.perfetto.dev/trace_processor -chmod +x ./trace_processor - -./trace_processor --httpd diff --git a/tools/scripts/profiling/perfetto/traces.sh b/tools/scripts/profiling/perfetto/traces.sh deleted file mode 100755 index 17ca89da7..000000000 --- a/tools/scripts/profiling/perfetto/traces.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash - -DEST=tici:/data/openpilot/selfdrive/debug/profiling/perfetto - -scp tici:/data/openpilot/selfdrive/debug/profiling/perfetto/trace_* . diff --git a/tools/scripts/profiling/py-spy/profile.sh b/tools/scripts/profiling/py-spy/profile.sh deleted file mode 100755 index 2fdbe8d16..000000000 --- a/tools/scripts/profiling/py-spy/profile.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -set -e - -cd "$(dirname "$0")" - -# find process with name passed in (excluding this process) -for PID in $(pgrep -f $1); do - if [ "$PID" != "$$" ]; then - ps -p $PID -o args - TRACE_PID=$PID - break - fi -done - -if [ -z "$TRACE_PID" ]; then - echo "could not find PID for $1" - exit 1 -fi - -sudo env PATH=$PATH py-spy record -d 5 -o /tmp/perf$TRACE_PID.svg -p $TRACE_PID && -google-chrome /tmp/perf$TRACE_PID.svg diff --git a/tools/scripts/profiling/snapdragon/.gitignore b/tools/scripts/profiling/snapdragon/.gitignore deleted file mode 100644 index 80078842a..000000000 --- a/tools/scripts/profiling/snapdragon/.gitignore +++ /dev/null @@ -1 +0,0 @@ -SnapdragonProfiler/ \ No newline at end of file diff --git a/tools/scripts/profiling/snapdragon/README.md b/tools/scripts/profiling/snapdragon/README.md deleted file mode 100644 index 383d83ba9..000000000 --- a/tools/scripts/profiling/snapdragon/README.md +++ /dev/null @@ -1,13 +0,0 @@ -snapdragon profiler --------- - - -* download from https://developer.qualcomm.com/software/snapdragon-profiler/tools-archive (need a qc developer account) - * choose v2021.5 (verified working with 24.04 dev environment) -* unzip to openpilot/selfdrive/debug/profiling/snapdragon/SnapdragonProfiler -* run ```./setup-profiler.sh``` -* run ```./setup-agnos.sh``` -* run ```openpilot/selfdrive/debug/adb.sh``` on device -* run the ```adb connect xxx``` command that was given to you on local pc -* cd to SnapdragonProfiler and run ```./run_sdp.sh``` -* connect to device -> choose device you just setup diff --git a/tools/scripts/profiling/snapdragon/setup-agnos.sh b/tools/scripts/profiling/snapdragon/setup-agnos.sh deleted file mode 100755 index 9a781bf3c..000000000 --- a/tools/scripts/profiling/snapdragon/setup-agnos.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -# TODO: there's probably a better way to do this - -cd SnapdragonProfiler/service -mv android real_android -ln -s agl/ android diff --git a/tools/scripts/profiling/snapdragon/setup-profiler.sh b/tools/scripts/profiling/snapdragon/setup-profiler.sh deleted file mode 100755 index b97c1ae68..000000000 --- a/tools/scripts/profiling/snapdragon/setup-profiler.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash - -# install depends -sudo apt update -sudo apt-get install libc++1 libc++abi1 default-jre android-tools-adb gtk-sharp2 - -# setup mono -sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF -sudo apt install apt-transport-https ca-certificates -echo "deb https://download.mono-project.com/repo/ubuntu stable-xenial main" | sudo tee /etc/apt/sources.list.d/mono-official-stable.list -sudo apt update -sudo apt-get install -y mono-complete - -echo "Setup successful, you should now be able to run the profiler with cd SnapdragonProfiler and ./run_sdp.sh" diff --git a/tools/setup.sh b/tools/setup.sh index ced451ab1..81eb54311 100755 --- a/tools/setup.sh +++ b/tools/setup.sh @@ -59,10 +59,10 @@ function ask_dir() { return 0 fi - read + read -r if [[ ! -z "$REPLY" ]]; then - mkdir -p $REPLY - OPENPILOT_ROOT="$(realpath $REPLY)/openpilot" + mkdir -p "$REPLY" + OPENPILOT_ROOT="$(realpath "$REPLY")/openpilot" fi } @@ -114,7 +114,7 @@ function check_git() { function git_clone() { st="$(date +%s)" echo "Cloning openpilot..." - if $(git clone --filter=blob:none https://github.com/commaai/openpilot.git "$OPENPILOT_ROOT"); then + if git clone --filter=blob:none https://github.com/commaai/openpilot.git "$OPENPILOT_ROOT"; then if [[ -f $OPENPILOT_ROOT/launch_openpilot.sh ]]; then et="$(date +%s)" echo -e " ↳ [${GREEN}✔${NC}] Successfully cloned openpilot in $((et - st)) seconds.\n" @@ -127,10 +127,10 @@ function git_clone() { } function install_with_op() { - cd $OPENPILOT_ROOT - $OPENPILOT_ROOT/tools/op.sh post-commit + cd "$OPENPILOT_ROOT" + "$OPENPILOT_ROOT/tools/op.sh" post-commit - if ! $OPENPILOT_ROOT/tools/op.sh setup; then + if ! "$OPENPILOT_ROOT/tools/op.sh" setup; then echo -e "\n[${RED}✗${NC}] failed to install openpilot!" return 1 fi @@ -147,5 +147,5 @@ check_stdin ask_dir check_dir check_git -[ -z $SKIP_GIT_CLONE ] && git_clone +[ -z "$SKIP_GIT_CLONE" ] && git_clone install_with_op diff --git a/tools/setup_dependencies.sh b/tools/setup_dependencies.sh index 5ad833a5c..c9e635836 100755 --- a/tools/setup_dependencies.sh +++ b/tools/setup_dependencies.sh @@ -20,14 +20,14 @@ function retry() { } function install_linux_deps() { - SUDO="" + SUDO=() if [[ ! $(id -u) -eq 0 ]]; then if [[ -z $(which sudo) ]]; then echo "Please install sudo or run as root" exit 1 fi - SUDO="sudo" + SUDO=(sudo) fi local missing_linux_deps=0 @@ -42,33 +42,37 @@ function install_linux_deps() { # dependencies should never be added to this list. # these are only for inflating bare docker images # to their desktop equivalents. + # + # from a real desktop OS image, we install uv + # then use that to install all the dependencies + # specified in pyproject.toml. # ------------------------------------------------ if [[ "$missing_linux_deps" -eq 0 ]]; then # the native package managers are slow, so skip if we can echo "[ ] system packages already installed t=$SECONDS" elif command -v apt-get > /dev/null 2>&1; then - $SUDO apt-get update - $SUDO apt-get install -y --no-install-recommends ca-certificates build-essential curl libcurl4-openssl-dev locales git xclip wl-clipboard + "${SUDO[@]}" apt-get update + "${SUDO[@]}" apt-get install -y --no-install-recommends ca-certificates build-essential curl libcurl4-openssl-dev locales git xclip wl-clipboard elif command -v dnf > /dev/null 2>&1; then - $SUDO dnf install -y ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-langpack-en git + "${SUDO[@]}" dnf install -y ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-langpack-en git elif command -v yum > /dev/null 2>&1; then - $SUDO yum install -y ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-langpack-en git + "${SUDO[@]}" yum install -y ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-langpack-en git elif command -v pacman > /dev/null 2>&1; then - $SUDO pacman -Syu --noconfirm --needed base-devel ca-certificates curl git + "${SUDO[@]}" pacman -Syu --noconfirm --needed base-devel ca-certificates curl git elif command -v zypper > /dev/null 2>&1; then - $SUDO zypper --non-interactive refresh - $SUDO zypper --non-interactive install ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-locale git + "${SUDO[@]}" zypper --non-interactive refresh + "${SUDO[@]}" zypper --non-interactive install ca-certificates gcc gcc-c++ make curl libcurl-devel glibc-locale git elif command -v apk > /dev/null 2>&1; then - $SUDO apk add --no-cache ca-certificates build-base curl curl-dev musl-locales git + "${SUDO[@]}" apk add --no-cache ca-certificates build-base curl curl-dev musl-locales git elif command -v xbps-install > /dev/null 2>&1; then - $SUDO xbps-install -Syu base-devel ca-certificates curl git libcurl-devel glibc-locales + "${SUDO[@]}" xbps-install -Syu base-devel ca-certificates curl git libcurl-devel glibc-locales else echo "Unsupported Linux distribution. Supported package managers: apt-get, dnf, yum, pacman, zypper, apk, xbps-install." exit 1 fi if [[ -d "/etc/udev/rules.d/" ]]; then - $SUDO tee /etc/udev/rules.d/11-openpilot.rules > /dev/null <<-EOF + "${SUDO[@]}" tee /etc/udev/rules.d/11-openpilot.rules > /dev/null <<-EOF # Panda Jungle devices SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddcf", MODE="0666" SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddef", MODE="0666" @@ -87,9 +91,9 @@ function install_linux_deps() { EOF # delete the old ones - $SUDO rm -f /etc/udev/rules.d/11-panda.rules /etc/udev/rules.d/12-panda_jungle.rules /etc/udev/rules.d/50-comma-adb.rules + "${SUDO[@]}" rm -f /etc/udev/rules.d/11-panda.rules /etc/udev/rules.d/12-panda_jungle.rules /etc/udev/rules.d/50-comma-adb.rules - $SUDO udevadm control --reload-rules && $SUDO udevadm trigger || true + "${SUDO[@]}" udevadm control --reload-rules && "${SUDO[@]}" udevadm trigger || true fi } diff --git a/tools/test_runner.py b/tools/test_runner.py index 1979ce01e..2121907e3 100755 --- a/tools/test_runner.py +++ b/tools/test_runner.py @@ -170,7 +170,7 @@ def collect(targets, keyword): cls = type(test) if cls.__name__ == "_FailedTest": continue - if getattr(cls, "__unittest_skip_why__", "") == "parameterized base class": + if cls.__dict__.get("__unittest_skip_why__", "") == "parameterized base class": continue if not keyword or keyword.lower() in test.id().lower(): tests.append(test) diff --git a/uv.lock b/uv.lock index 8f6db14fe..5d84253f8 100644 --- a/uv.lock +++ b/uv.lock @@ -7,15 +7,15 @@ overrides = [{ name = "opendbc", editable = "opendbc_repo" }] [[package]] name = "anyio" -version = "4.14.2" +version = "4.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] [[package]] @@ -93,14 +93,11 @@ wheels = [ [[package]] name = "click" -version = "8.4.2" +version = "8.5.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, ] [[package]] @@ -123,229 +120,229 @@ wheels = [ [[package]] name = "comma-deps-acados" -version = "0.2.2.post98" +version = "0.2.2.post103" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/13/1190aed06e91a9f9024b16fb44a4184842e56ac39dbaec8e6aea83cb1d7e/comma_deps_acados-0.2.2.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64c002e0d6170c7bdec300159bbbe07cf3e05fa54ae5890fe736190fc3543fe7", size = 10635996, upload-time = "2026-07-23T17:01:04.136Z" }, - { url = "https://files.pythonhosted.org/packages/d6/24/a16888f692e2a2759b656847b7e6ca2dbbca666e64e1372e3e158d25e29f/comma_deps_acados-0.2.2.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:356ce6020430eb5b53d637bf870c27b10ad8599c9ade0abd2c520c3e00143410", size = 11657744, upload-time = "2026-07-23T17:01:08.618Z" }, - { url = "https://files.pythonhosted.org/packages/73/9d/24377b731093e015a44fff043dd7ea5b77b0de62acf48b5a0e7d5a662a15/comma_deps_acados-0.2.2.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:e55ac429d848415930a0b82ab100a310e1848b48cdbaa8dda574b561b43c50d0", size = 13124767, upload-time = "2026-07-23T17:01:13.091Z" }, + { url = "https://files.pythonhosted.org/packages/12/bd/b8bbb3791b42c5aef32c5ebaeaebcf4d1d712cc9e67222523daa179c7419/comma_deps_acados-0.2.2.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1067e2404c08bab1ebcdd46b00d505f956d2edacc9f50d307b0618b35eff0421", size = 10554192, upload-time = "2026-09-11T22:31:14.412Z" }, + { url = "https://files.pythonhosted.org/packages/a3/72/31488259bb173161089393e7383ae90a7f3fb76b5d41098639a78bd9699f/comma_deps_acados-0.2.2.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2ad9fcebef1f65112a9cebe8a093977310602e1325d442dc345beaf512679211", size = 11670466, upload-time = "2026-09-11T22:31:18.634Z" }, + { url = "https://files.pythonhosted.org/packages/98/eb/83c5ab1275b812ac10988e1ddbfd541088e8a5a883e45f34bd0ee35e2138/comma_deps_acados-0.2.2.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3b451852e83d62815cead999ab31073db9be60307f772650336cdd4534f12b9e", size = 13139433, upload-time = "2026-09-11T22:31:22.891Z" }, ] [[package]] name = "comma-deps-bootstrap-icons" -version = "1.10.5.0.post98" +version = "1.10.5.0.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/69/da1a72b8b7783b0caf9a54b27c7124bad11768b8bce2c656ef3b700ab831/comma_deps_bootstrap_icons-1.10.5.0.post98-py3-none-any.whl", hash = "sha256:cabaeecea398eb867b96a6c653c6078691a437c0eff2364530194a218d94cb99", size = 385998, upload-time = "2026-07-23T17:01:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/21/17/277a473fb1f6b28f88ca0b6c477b1ddf439f68371c0d4e80fdc7925f98ea/comma_deps_bootstrap_icons-1.10.5.0.post103-py3-none-any.whl", hash = "sha256:9879fa2b3b112d20f4acf6cc83bb9732f95f0864db74d25224a31fa9827f37ae", size = 386008, upload-time = "2026-09-11T22:31:27.373Z" }, ] [[package]] name = "comma-deps-capnproto" -version = "1.0.1.post98" +version = "1.0.1.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/83/d3e6346a31491be1d378e4585f37a7979eb772018616abfa74fb27750f1e/comma_deps_capnproto-1.0.1.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9f4d08682df92411b360bec855cb6475990313cd0ecd8ed5c6ee02befb9db913", size = 2407343, upload-time = "2026-07-23T17:01:21.247Z" }, - { url = "https://files.pythonhosted.org/packages/b0/8b/6f2a29d50ed4c8741dbf0a34ab109899268d09753518cd693e881bbf1a9d/comma_deps_capnproto-1.0.1.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6cdf838a8d415ac71e1f52306624ab3ab6f27777f6ce89c0059c4129ea7b7f62", size = 2506355, upload-time = "2026-07-23T17:01:25.254Z" }, - { url = "https://files.pythonhosted.org/packages/08/24/e91f2203d62e4db9de7dae06dd0cdefb1e000d8b3ba0bde48367be7e5b63/comma_deps_capnproto-1.0.1.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3d95993c9aff0c89e39ca965e995021dd3dccdfc3d5d85152916cf4bf651b7ec", size = 2590764, upload-time = "2026-07-23T17:01:29.062Z" }, + { url = "https://files.pythonhosted.org/packages/61/ef/a108b56be5bd6a655c4acaf9c7d6a11c4397d5440bd192314dc2b4e5b997/comma_deps_capnproto-1.0.1.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cfea5b5679a530affc674717173455e1bb26bde06491283f461d8c070df33811", size = 2404560, upload-time = "2026-09-11T22:31:31.176Z" }, + { url = "https://files.pythonhosted.org/packages/c4/53/38b0e046aab3f0d371d0fa6eb3d4eb3cf787e7d270f429a485e53080416c/comma_deps_capnproto-1.0.1.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4a115a789af5835dde75371abdd0c1766df40764eaccfd912de4212522935d08", size = 2503570, upload-time = "2026-09-11T22:31:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/22/7c/fcbdd3ac0f635a3b1eb674e5bd4c3ada804c89348c1a3818ca2fc91e3d91/comma_deps_capnproto-1.0.1.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f5717a6e6bee615bca9a68e9a5d10a11a871e0f61ee2d66759681de562d82c9a", size = 2587991, upload-time = "2026-09-11T22:31:38.822Z" }, ] [[package]] name = "comma-deps-eigen" -version = "3.4.0.post98" +version = "3.4.0.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/2f/89011c71976da6e1c3d7be315afa3d86ff25deeada1ad2319ac6be0e18ea/comma_deps_eigen-3.4.0.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bd182634cf4fa537e7815238d3135e6e89be5826421a495be92258c6c388b527", size = 2275893, upload-time = "2026-07-23T17:01:44.622Z" }, - { url = "https://files.pythonhosted.org/packages/2c/61/fcd4ad536c51437ee73ac255f3b8a23fb5f21bb1f96e834d8036c3bbcf08/comma_deps_eigen-3.4.0.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:250c15f6217c37736a2f54298d01f6e32f6e977faa87cc358de67ea3d121725e", size = 2275896, upload-time = "2026-07-23T17:01:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/d3/a2/2b7633fe5a5a2914900933393c315e9bd86e8fb7bbbe328d3a220eaf2027/comma_deps_eigen-3.4.0.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:dee9eb6c6c7e58201d7a36611857b7b3f3ba70f888ee07493fef2ea41d0d2cae", size = 2275898, upload-time = "2026-07-23T17:01:52.179Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b4/bfcd0e8b1480059e94f978a85497ebffdd33d59a8272a68556e23ed7d1b8/comma_deps_eigen-3.4.0.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:196b9019947552464cf72c7c4659ade6d8bd6733e0bb063d910b168cdc0c2c75", size = 2272667, upload-time = "2026-09-11T22:31:53.434Z" }, + { url = "https://files.pythonhosted.org/packages/6f/23/02407262cc7cb6509bd95b237535b3b32e6447b24abc32200aed6e9b4745/comma_deps_eigen-3.4.0.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5f18b5ae2b007f5422ce67f1e1e805bb07404885d3a8718a0341daca748a0980", size = 2272674, upload-time = "2026-09-11T22:31:57.274Z" }, + { url = "https://files.pythonhosted.org/packages/40/43/a5001078c02a5f6c28e26e3d0458a7e53826d77e9945f3196921df620a41/comma_deps_eigen-3.4.0.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:e07754a4f6062285545f70c597321a5736e193aa5a600ebe189af9e43ad965ff", size = 2272674, upload-time = "2026-09-11T22:32:01.084Z" }, ] [[package]] name = "comma-deps-ffmpeg" -version = "7.1.0.post98" +version = "7.1.0.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/59/4899ac0fa54905f43e237fff122008d6c591918b41e36880eeb18cd6279c/comma_deps_ffmpeg-7.1.0.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7816c5adc9c6a7462209ccf1d023c42d7e38a4eee47aa2f43d45dfc8320063f8", size = 7326312, upload-time = "2026-07-23T17:01:55.975Z" }, - { url = "https://files.pythonhosted.org/packages/29/cb/6e047c19c39977c5ae322ad698b91d8d9fce43314cb86563de91bb161982/comma_deps_ffmpeg-7.1.0.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:45ad401b4058e3f7efb8d6841e1f187e8c265e942e56e7fb01db1ba9096e6b78", size = 4437675, upload-time = "2026-07-23T17:01:59.971Z" }, - { url = "https://files.pythonhosted.org/packages/76/3d/cda4b19fa5a7b26921a518143c94fd3632030a34b157cab6d6f10f2c86bc/comma_deps_ffmpeg-7.1.0.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9e7034739d45a45254c4200a555d343b22ce117429bebc2de2c1b05f050bfc8c", size = 4681499, upload-time = "2026-07-23T17:02:03.963Z" }, + { url = "https://files.pythonhosted.org/packages/90/10/1407eb09f39d036c2c4d101c3e429f69059a20c78197669a2b10023ac30e/comma_deps_ffmpeg-7.1.0.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4b5074ae64ff44f32e5d2b44ea577cafeee5279e6f1029cc05ab74429f72a64f", size = 7326617, upload-time = "2026-09-11T22:32:04.765Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3b/770c32c1a232dc73bfb43f5f5834ecb2ff8fe55ff627513e1ef458214c7c/comma_deps_ffmpeg-7.1.0.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c0da5b62987de3375d1f8fa2e68d85d1a4a11bbb19a42235ae1f907f372bc364", size = 4437978, upload-time = "2026-09-11T22:32:08.792Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c8/a17c8c293b64ed75074510d9460135b2875ac1832028f950d8f69982df53/comma_deps_ffmpeg-7.1.0.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:8756f18e5b1fd760663dfed3b0762fd6b6a5f5e5acfb695e64d06605d2d9ce68", size = 4681802, upload-time = "2026-09-11T22:32:12.583Z" }, ] [[package]] name = "comma-deps-gcc-arm-none-eabi" -version = "13.2.1.post98" +version = "13.2.1.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/e5/a4cd9faa80bf419c6a7052c99dfe565c283a5c966e90ce35b1b4040b24b8/comma_deps_gcc_arm_none_eabi-13.2.1.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d0e6991b845636ab19e46199bc5cb9dd056611bc6b93c6bca4f2bb5002783533", size = 15238810, upload-time = "2026-07-23T17:02:08.588Z" }, - { url = "https://files.pythonhosted.org/packages/5b/81/690ce48945aecf58e475cb728a8d2f6c034493afd87b5b0381a85dd324b4/comma_deps_gcc_arm_none_eabi-13.2.1.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:41fef00d033e0f6c12e1748829d95e53942826e0085f57d918351b2de69530d7", size = 17367240, upload-time = "2026-07-23T17:02:13.637Z" }, - { url = "https://files.pythonhosted.org/packages/41/a9/6af914145bd5c9ce3468a95500558bdc0a438f69700daedd37535945294e/comma_deps_gcc_arm_none_eabi-13.2.1.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:ed3630aac06b3a1db78ba5a29a33b900a51dd1c0b37f86cbfe3c1591993178f8", size = 16941137, upload-time = "2026-07-23T17:02:18.976Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3c/d106a7edbb6e832974fde089c04282da9e0d035e8de1b1b5c6dbedb9deaf/comma_deps_gcc_arm_none_eabi-13.2.1.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ae7247a36c61a7c3864ac052dadd6787235ffc04d0b724972968ac57164fde00", size = 15239169, upload-time = "2026-09-11T22:32:16.377Z" }, + { url = "https://files.pythonhosted.org/packages/b5/85/3f97e71022055862e0a5f47c960eea18518a750da62ef7f6125216ec0e9c/comma_deps_gcc_arm_none_eabi-13.2.1.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:dd8eaac31e11dd291b4ea37f14c43e53e5b2b2b059207deb44b5caa3be2c6d86", size = 17367602, upload-time = "2026-09-11T22:32:21.355Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b5/4d8294c7e081b93363df0b6773be0bb2632d53c52e4fc89e64f51ab51ea5/comma_deps_gcc_arm_none_eabi-13.2.1.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6006a1073292d56d3cc25f1a236fa4da6039028d9c251bdd66747f5d5013e020", size = 16941494, upload-time = "2026-09-11T22:32:25.886Z" }, ] [[package]] name = "comma-deps-git-lfs" -version = "3.6.1.post98" +version = "3.6.1.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/27/ecfda511eb334822d9bc464ec2d9b74d3c553784811a885baba34a27eaf6/comma_deps_git_lfs-3.6.1.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:259b1f4859bb3ab20fdcc012be0a9868a3649e1087d5cec07eaade7adea44c78", size = 4685104, upload-time = "2026-07-23T17:02:23.67Z" }, - { url = "https://files.pythonhosted.org/packages/00/a5/9631b4a676279b353f82d4e2da62eb567c70fff628133a62d7f70fbf5924/comma_deps_git_lfs-3.6.1.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:60d39254138b2c7c3f15cc512c14504c11881e79885492321e1ffc3ecb840f93", size = 4485276, upload-time = "2026-07-23T17:02:27.751Z" }, - { url = "https://files.pythonhosted.org/packages/09/5a/7ef6bc209d8ec15c40b1f988345e2e59c535a215284366916422ba0d0c30/comma_deps_git_lfs-3.6.1.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:9586057ca9c6e77e9068128f3e96f0ca03db29a757414b1b251bab4ca31ee6b8", size = 4889582, upload-time = "2026-07-23T17:02:31.454Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c3/75c2406e52cb5129d8ce6b79f80a610417d8c8729422c1e80b14b6771e69/comma_deps_git_lfs-3.6.1.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ba61d0c194725aee5644731f229ff36f56a00dfd450f8226794008ab98a93144", size = 4685121, upload-time = "2026-09-11T22:32:30.047Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4f/17fb16d2de5d858ab95fdd09fd652f25d8a698b35df3e4ce24589dfd9565/comma_deps_git_lfs-3.6.1.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2d815aab8aa153a6536f538a65872ac64aefe8c62da153f9b0e8bba23a98d5a8", size = 4485292, upload-time = "2026-09-11T22:32:33.868Z" }, + { url = "https://files.pythonhosted.org/packages/d3/30/ae0def1cf507492591fb2852066dda163fd28d0e1cf52ad76c20ce1fb358/comma_deps_git_lfs-3.6.1.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:a78ca419dec75a1b99229ac40c086c3236b86ff8585740c6b34c2ccea95b1389", size = 4889597, upload-time = "2026-09-11T22:32:38.084Z" }, ] [[package]] name = "comma-deps-imgui" -version = "1.92.7.post98" +version = "1.92.7.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/e4/b9f4b68973bfd529314c28fcd87cb2f52b5dc7d9fdeb3be2d3d15b7cea25/comma_deps_imgui-1.92.7.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6fddf76138b1e54fe33e9f5ea3cbcb650f92fef8fbcf7e5080800ea851d68b98", size = 1688011, upload-time = "2026-07-23T17:02:35.416Z" }, - { url = "https://files.pythonhosted.org/packages/7f/46/92030abf6e42e9813f144d10bcf541b39a246c5ca2d63d049478deac650b/comma_deps_imgui-1.92.7.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9f7eed0f759e59afcf289967edb3d94c48a38fe97527f2909dbbc70a850dc2cc", size = 2522785, upload-time = "2026-07-23T17:02:39.092Z" }, - { url = "https://files.pythonhosted.org/packages/7d/57/d41e76559a553565413976695fb63a768d3446d12eccc9e736a12b53e662/comma_deps_imgui-1.92.7.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:07eeb105cce73ec3b27789501c78dcd059d75e736a99f1953358ef5097e7036f", size = 2655476, upload-time = "2026-07-23T17:02:42.925Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0f/29f3dc72d8b745f8db30245502c7d5626eff5d244a4c02c4e89bbc8497ec/comma_deps_imgui-1.92.7.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bb693b2e9f3aa5900b8884527409e73d6580c8b05bb42e1ec1a2897f4f6c05fc", size = 1688025, upload-time = "2026-09-11T22:32:41.73Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4b/abf18a8cc4527c6cb1d8f8dbec10b02e28746b303850d90fe0af5ba82a9d/comma_deps_imgui-1.92.7.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:1b2737fa6f47ad00835828ff663eae7150519e0a6cc7df9e0b336a48c24032b6", size = 2522805, upload-time = "2026-09-11T22:32:45.498Z" }, + { url = "https://files.pythonhosted.org/packages/17/b8/d5fe737f156ca1dd1797f9ed664464b43fbe7712f9b61271fbe6315ed539/comma_deps_imgui-1.92.7.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6554b6ff6ba818038ac8a6c51bd71afbb285c7b3f1c0c31947298b17db8f111b", size = 2655501, upload-time = "2026-09-11T22:32:49.008Z" }, ] [[package]] name = "comma-deps-json11" -version = "20170411.0.post98" +version = "20170411.0.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/f4/50411c9134a8347831a72f90318b7b7d91ce566e63575b8a4a821be50ca4/comma_deps_json11-20170411.0.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:20d666897062487e4cd93b8e4eb9c53ecabf706864be8d8cbb60a56f0113c452", size = 34034, upload-time = "2026-07-23T17:02:46.595Z" }, - { url = "https://files.pythonhosted.org/packages/1f/54/0c87fae682ee52e6aec371336ac980921ad34cedabb69e576cc9f83c40a7/comma_deps_json11-20170411.0.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9b4a03909609b832dc99b06503fb8c77419399a99e0db982d4c3f51f1563aa73", size = 41848, upload-time = "2026-07-23T17:02:50.039Z" }, - { url = "https://files.pythonhosted.org/packages/7b/71/dd100992e13f2c7a01f68eebcd1e3cf43f1d169e4b80b0577f330e5f5c12/comma_deps_json11-20170411.0.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:1da9030908f3a6631a0a254f493c382ba061c2e8ddb280a30d64430af29f4638", size = 42602, upload-time = "2026-07-23T17:02:53.233Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5c/8bed22f3118f53ae08763c6dfc755d08826c750ac21864d60a4710ef3183/comma_deps_json11-20170411.0.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cb255b3c010e83f8361869f7cf8b1f585db5e0dabc92b41f3132af49838e981a", size = 34039, upload-time = "2026-09-11T22:32:52.45Z" }, + { url = "https://files.pythonhosted.org/packages/6c/be/51c3dd8f80ee0a968a90b4737e0362e7b621366c24c9c55613386ba0aa14/comma_deps_json11-20170411.0.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0f7764609db411d98c540d985c289a2c37f282fbf2a75e2503017f1243546653", size = 41850, upload-time = "2026-09-11T22:32:55.711Z" }, + { url = "https://files.pythonhosted.org/packages/79/73/7f893e3a65e82c2453806c39e79eb4e2a8074f358cfa20e1b66e46e9a6b3/comma_deps_json11-20170411.0.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:a309d401dd22b758455dafd3c28b72c4bd88c7482ddd70fe8653e53efc745a83", size = 42604, upload-time = "2026-09-11T22:32:58.866Z" }, ] [[package]] name = "comma-deps-libusb" -version = "1.0.29.post98" +version = "1.0.29.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/fb/f7d342a8f785fc1c0fd5d6883e1a5a7d424a1899b888f78f9091f4b98049/comma_deps_libusb-1.0.29.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a10b82a946c33c23152cee3e330ce76d398ddce1f38fea62e33773bef8f56164", size = 102339, upload-time = "2026-07-23T17:02:56.567Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/1b21692cc03078219a3946aae56086a109168a4b4dcfba3a22ce1cd01064/comma_deps_libusb-1.0.29.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:39567eeef6170ece389526780f90c3b75cbcfdf427f648f6b1539c203f7387a8", size = 94431, upload-time = "2026-07-23T17:03:00.01Z" }, - { url = "https://files.pythonhosted.org/packages/49/d2/d93aac76b94ae87f7a37ce88f2e7e1184e19e67d10c068c1aac209075450/comma_deps_libusb-1.0.29.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:5e5b86be94b4a355c6be933ed21586d01b1d7ba597298dafd33b871a1ae66416", size = 93462, upload-time = "2026-07-23T17:03:03.218Z" }, + { url = "https://files.pythonhosted.org/packages/ad/81/865cbc69c8f182a020f4fb85c70b4d4e1b2a636eedfc34c85490472c1e56/comma_deps_libusb-1.0.29.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7a8afc81bce03f3d6386613983a23a303369bed19593e84d0c744e8381087cb1", size = 102343, upload-time = "2026-09-11T22:33:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1a/d07a980d8132c58cc328974c0566c8263fcff9b60e053129030fd849dd2c/comma_deps_libusb-1.0.29.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:56661f2674fd41abb5337a435c032552ecd7318857d598f79c4fd6cdddd727d8", size = 94431, upload-time = "2026-09-11T22:33:05.488Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2f/19227534c7f7cf4850ef73df16ec5feabd80e23edebec648877a5b82eab6/comma_deps_libusb-1.0.29.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:450c9bfaad634931c336eeea8369d4ac8db78ba092b3c5d7b70c098d5634b050", size = 93460, upload-time = "2026-09-11T22:33:08.701Z" }, ] [[package]] name = "comma-deps-ncurses" -version = "6.5.post98" +version = "6.5.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/d4/03e62b2a0be92ad420653ff1cf4396de9840c4c59cdc6e000ea5614f7744/comma_deps_ncurses-6.5.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:22ade1596deb18538d4bf59708aa2747c28c2e0e7048f13f5bfce3e5588f7417", size = 264921, upload-time = "2026-07-23T17:03:06.576Z" }, - { url = "https://files.pythonhosted.org/packages/1a/ab/295b428ef473dbe0d7088ff02daf5ba17b924f3b915ac69a8a9c45a6eb2b/comma_deps_ncurses-6.5.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6724d3e8c1f2e59d2475f7588d86494bcc4001e993fd7dff4c91ecb046edc97f", size = 260844, upload-time = "2026-07-23T17:03:10.329Z" }, - { url = "https://files.pythonhosted.org/packages/cd/db/75afb33eaa86425d9bee68153f6141be2645cf5699b2cab5a7cbf7a36099/comma_deps_ncurses-6.5.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:d85e70e98f4b0a969d63a4a61a1a5b85db3c648ea58422401b55f386813f7d12", size = 248352, upload-time = "2026-07-23T17:03:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/94/43/6a0c4cec1c245ec0dafa8d2c6bfa433b596153f7efa0d58f6b80c0145a74/comma_deps_ncurses-6.5.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:aad15d3cd54e6930defdad9f3c968b3f115ea634369b75b62b1457eee4789208", size = 264908, upload-time = "2026-09-11T22:33:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ca/f59ff912be4f191f6e3416548d880d662b66237e37bc8b4026af2aad2244/comma_deps_ncurses-6.5.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a8d5822ac1a1cd5bb16e7e06ba702d6de6fb584b5727190583b6d6d983cf8530", size = 260849, upload-time = "2026-09-11T22:33:15.623Z" }, + { url = "https://files.pythonhosted.org/packages/36/f8/7406248610e7862cfca0e2c5b447baa565c504c7ba72ce19e811240e73d6/comma_deps_ncurses-6.5.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:7eeb250ed2bf34d2c69098d42ec5d63e333a2e3a802911a0e1036e855b547b5c", size = 248321, upload-time = "2026-09-11T22:33:19.032Z" }, ] [[package]] name = "comma-deps-raylib" -version = "6.0.0.1.post101" +version = "6.0.0.1.post103" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/90/e289acd1725d71c792c33399422f1052c4b0c38aeb4222a866d30c4a2cad/comma_deps_raylib-6.0.0.1.post101-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa69d5093a92d7d2bfd2714a1afccca63b94d4c55fae88e61b80e8841de6a6cd", size = 1885392, upload-time = "2026-08-27T18:24:37.25Z" }, - { url = "https://files.pythonhosted.org/packages/a1/17/12977631f6d86d1daa4f67a310cfdf2783ba288f42d444d6a89b2297a0a4/comma_deps_raylib-6.0.0.1.post101-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c93ff9b45df414620b3011280da77151764c027e2f95d384fe255615d78872cc", size = 21707644, upload-time = "2026-08-27T18:24:41.282Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ca/ef33aff790b37dfc925f94fbb3a86da19f67929c15b3725ddb18afb91e96/comma_deps_raylib-6.0.0.1.post101-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:8f2a1ffe5f60cac06170b144d6ce84925c91657e7ada3ae35bc5df6ccbe0b461", size = 20722616, upload-time = "2026-08-27T18:24:45.803Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/21e3be3ad1e2382b9ecab7d836e7ca19ea0959fe714d539a71c6b87ba510/comma_deps_raylib-6.0.0.1.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d8bef63b7e49bc8704eda9fecdefbff01d7f6c9027069cb1b1f5150cac077ed1", size = 1885397, upload-time = "2026-09-11T22:33:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4d/cceaf969d771af8870094d06bb84dca805f9268584a9a3c134dfee2307c7/comma_deps_raylib-6.0.0.1.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d9b9bebb39167442bacf627958d57ae95e452550ced1b68337cbf2a73375856a", size = 21707647, upload-time = "2026-09-11T22:33:26.533Z" }, + { url = "https://files.pythonhosted.org/packages/27/ce/e8b941bcf0b213ca9cb4d8cb69b18398b4dad77923925d80bc3281837461/comma_deps_raylib-6.0.0.1.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:942ec942ec01005f05584eede1a6add483043e8e8f764e68cfebb7c21c443b89", size = 20722617, upload-time = "2026-09-11T22:33:31.158Z" }, ] [[package]] name = "comma-deps-zeromq" -version = "4.3.5.post98" +version = "4.3.5.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b7/b0070e091dae4be2cecccfb2921167b568d0e7bb9ea600b5814603e0590f/comma_deps_zeromq-4.3.5.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0ecde97133d657024bf99ac7302130905a131dabedbc8425b6666b2058ce6acb", size = 815150, upload-time = "2026-07-23T17:03:29.517Z" }, - { url = "https://files.pythonhosted.org/packages/2f/9a/d6a381b079516eca1b8a86aa3e972e550a48ff2f069ccc722b118ab53d60/comma_deps_zeromq-4.3.5.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:110a628ea440ea75707ad29fd90341d300414b0092137b1d43e8f19d100cf2fa", size = 833389, upload-time = "2026-07-23T17:03:33.25Z" }, - { url = "https://files.pythonhosted.org/packages/af/d7/504649efc8dbe8ce4c0cbec085178d1c3298f6950bd4bbca1683a90c49ed/comma_deps_zeromq-4.3.5.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cd16a515f00f5fb679c2883970c2e7f446ad84e65d7fcf045d325952f9cc3607", size = 798894, upload-time = "2026-07-23T17:03:36.788Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a9/9013d207cca0f1d4dda65f1babf16e9e2e0b5fe86259bf994d15cc6e6024/comma_deps_zeromq-4.3.5.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:66d4cc63e45bf5864e68d6a9528ce90419f4c7210250970ff58b3919985c0929", size = 814878, upload-time = "2026-09-11T22:33:35.467Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/522bdc7b93d9c84b2600e9ed84588c94acf698a8aa3186e304c6d54b4371/comma_deps_zeromq-4.3.5.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d25d7b336707f39a6c92f0b01b40ca761d067564b39c6c56a6a7f4cca7fd512d", size = 832994, upload-time = "2026-09-11T22:33:39.03Z" }, + { url = "https://files.pythonhosted.org/packages/e4/7f/e79ec0b13146d9aea6004c06bcc37264bda973a5a71248d32dbcc7ac29b4/comma_deps_zeromq-4.3.5.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:bb05f93a91b140eb54129bf68824d00a6f93802071957062995cbbc6184f70d2", size = 798535, upload-time = "2026-09-11T22:33:42.538Z" }, ] [[package]] name = "comma-deps-zstd" -version = "1.5.6.post98" +version = "1.5.6.post103" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/66/fd1098b4514e759d85e19d604d446ecec1f67e2f452df9e280d01a2449f7/comma_deps_zstd-1.5.6.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2b6acdd50e71ec67a1426423cda5116a7cb43900dd2e4fca2cbcbb7d588be171", size = 1065140, upload-time = "2026-07-23T17:03:40.465Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ba/1d61aae97577bbf13c9c02e7e69d4c9391947d8d0d09ca4ad78f2e3d0faa/comma_deps_zstd-1.5.6.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:04825faf902754a0945ebac374d01d676b7e5f98a68e460452319de509b369f3", size = 1006145, upload-time = "2026-07-23T17:03:44.144Z" }, - { url = "https://files.pythonhosted.org/packages/a9/22/94d164407b579090eb3aceeeb63fbd8c540f6972a11e5b72fe3ca3139333/comma_deps_zstd-1.5.6.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:316200a52c9ac1aeb6b22030480ffebaa1d91756c18a3f3cf216368a5fb35bfd", size = 1030359, upload-time = "2026-07-23T17:03:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e5/17e340fd63173767d7beae9228c2e227352ba880ced754203f9b5096f223/comma_deps_zstd-1.5.6.post103-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37bd1c5b8bfac9d19095f741030a73a58663bf0aab93a99768f53db67fcd09ac", size = 1004948, upload-time = "2026-09-11T22:33:46.046Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/96a88f630732dd83f0d01adf7707b369a055eea6dc4612a30ae6edaff8fc/comma_deps_zstd-1.5.6.post103-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d9a6b6b6d4ecdfd28d4843f8dcd42dfd6f4886672cc90c0957798f9c0326571a", size = 956886, upload-time = "2026-09-11T22:33:49.654Z" }, + { url = "https://files.pythonhosted.org/packages/4b/64/23cf37ae4d915ad9f7b341b08cc0435aaeeef543185c86bb5b860c87cad9/comma_deps_zstd-1.5.6.post103-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:d7bbc77bb174475f30bde69d7451baa47ddbe12724e586ac8874d799c7368101", size = 980997, upload-time = "2026-09-11T22:33:53.384Z" }, ] [[package]] name = "contourpy" -version = "1.3.3" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, - { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, - { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, - { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, - { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, - { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, - { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, - { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/97/ba/01bde9753bdbba04a6da9d2bff3881f021bc708f654655e95fae26d3b3a3/contourpy-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:186ba929df36d61b6127da2e89cd1357e4cb79aca647381ab1a6feb0b152877b", size = 297302, upload-time = "2026-09-11T19:02:45.103Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f5/c5bf67522d49a2222f3154fe46879451d26f0e35ece41770758a64ea78cb/contourpy-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c76f3a5318164db7d9401132fc1b5364b784c613c93fa506e3b5e6d1bf353ec8", size = 284921, upload-time = "2026-09-11T19:02:48.049Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cc/cb989599eec12fda312e127eb8e04a8b21a7a6c94bf7ff1bc68273334a42/contourpy-1.4.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43c3ccbb32c6294b183dcc8e8c46dacf5ecef809497e3d48a5be298eef185ad0", size = 356332, upload-time = "2026-09-11T19:02:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/6f620d7602507817b0b4c21dd790ed68688f1f4ae9c29aacf21f08db5cf1/contourpy-1.4.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86d05cec773c9507a3950122e0e40ce77c23c75ceeb2fc189514e71893cbb34b", size = 405878, upload-time = "2026-09-11T19:02:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/693c6d6bdece679f0953775eef3a1b66d56be55478d936e7deb1f3004422/contourpy-1.4.0-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2deb580178ca19437a84bd77e4bc2cd91a8ccad212413a83c273900868b5978c", size = 408296, upload-time = "2026-09-11T19:02:56.528Z" }, + { url = "https://files.pythonhosted.org/packages/78/f9/b6831508960d559581c448532ba4df312217072975486dbf2b24fcc5b76f/contourpy-1.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:875f42444c9cf48d56f724f2637e60d0f73b3b12c9041e1484580a233edf9591", size = 383243, upload-time = "2026-09-11T19:02:58.638Z" }, + { url = "https://files.pythonhosted.org/packages/f2/21/52903825a0ae7bb625e8bd30a09816d4c3c5d6174a469c10a616166cf780/contourpy-1.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f863c6100bf926cf47d13f3cd75f9bb8ebd98aaab230eeb4468b91f98f39a6b3", size = 1357306, upload-time = "2026-09-11T19:03:01.036Z" }, + { url = "https://files.pythonhosted.org/packages/d8/79/d68b1ce8539e4071518fed9523f558398f34dcd078b8927b109c72dad2ef/contourpy-1.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3863ef2e2b13fe93f8c0ebb08ee400cb07153b8b0e91c5acb26e4537f283634f", size = 1427243, upload-time = "2026-09-11T19:03:03.866Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/b75a10e9d0616b97a30de277b6dfe83f1879880854c7330337309530eb48/contourpy-1.4.0-cp312-cp312-win32.whl", hash = "sha256:5450f091ac1be0be3ad3a2a3b3f23b5e443e78c670ced4fd347d626f92a28fd2", size = 347345, upload-time = "2026-09-11T19:03:06.192Z" }, + { url = "https://files.pythonhosted.org/packages/50/a9/dab08786bb4d77ef9a046b3c1be923be4e73c5d07d91f5a54e8ea9b09e41/contourpy-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e697d94e69f499ff6bebb899cae97a58d5d14f0e1fe9568b43a0248d2f9af8c", size = 234083, upload-time = "2026-09-11T19:03:08.122Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/3ba509755a970dbde5948e7142ac21f266be72f38017cc4087a05fdceee1/contourpy-1.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:0c7a4c2716a4e98342221954416a836cca77c996c14ddbf22b5f01d5d93ca09c", size = 559521, upload-time = "2026-09-11T19:03:10.047Z" }, ] [[package]] name = "coverage" -version = "7.15.4" +version = "7.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/f5/deb1a27aa20746c0278ac998c4179e272004699b2d33959ce020c5ac1615/coverage-7.16.0.tar.gz", hash = "sha256:077f0964087883176ff6ab9b074694cae29f8c708273b13ca62c183c6ed716cd", size = 945620, upload-time = "2026-08-28T21:54:37.74Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, - { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, - { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, - { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, - { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, - { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, - { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, - { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, - { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, - { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, - { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9c/8d2688694f53dc0b0f0e4783c7eb3c4bb1e79beaf1411879f6dabedf4607/coverage-7.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d1c77c3579ac42798f8b7eed6d3dd258debacca32c8753fc8a1f6eaf1db644f5", size = 223194, upload-time = "2026-08-28T21:51:27.767Z" }, + { url = "https://files.pythonhosted.org/packages/ca/11/f002163dd688aa3fa49ac6a424b7c2705c7fcf80fba18ec9f586d77827ca/coverage-7.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f81cb1554c3712e41649ed5dc98656b50b958e4da12f0f5adb681ce3db92831", size = 223553, upload-time = "2026-08-28T21:51:29.46Z" }, + { url = "https://files.pythonhosted.org/packages/81/65/f9d469e97c4554372a710650a109004a2434dfc56f577142e5d6057fa0cc/coverage-7.16.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e701938ec9081d3e400a0c9a9a8ae0f7ca44214741daeac4454b1c6ef6dbd19", size = 255054, upload-time = "2026-08-28T21:51:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/95/29/dd89fd39af1a3b6e9a9c3eddeaf03f6376ba517d43d6cbf8b519177e2a10/coverage-7.16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:719a3feb6220dd32ed932d4c3676d17fb8739e2643b29c0e7c3af400ff80ac44", size = 257790, upload-time = "2026-08-28T21:51:33.374Z" }, + { url = "https://files.pythonhosted.org/packages/0a/64/208d26cedc525d6b5db9c492cf9130784c42d9eb08d22badaa7b806005ad/coverage-7.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87771ecf986cff55e87413238cd5e4f54d949c2074bd6fc1657d26a56314ee24", size = 258904, upload-time = "2026-08-28T21:51:35.096Z" }, + { url = "https://files.pythonhosted.org/packages/1f/98/28e2752aa9a8baee5798edade9c95602ca200f4e7eeb503eb64df42e5921/coverage-7.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47d5e1fc0b321c8308a2aacee0497c435b08acaa629b7059798fdf6fc3006352", size = 261165, upload-time = "2026-08-28T21:51:36.744Z" }, + { url = "https://files.pythonhosted.org/packages/eb/77/fa6ae699a0ea2bc12acb38a85d96b786fea0f833c12b5756056350e0e547/coverage-7.16.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01b18b8a6c9cec8d5f45550e2501426ed982cf2c35016b0acd2ba9b5d8b2fb06", size = 255416, upload-time = "2026-08-28T21:51:38.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/c8/5ee46d1de7d34cb00ba08b5c50da1971114dbc09ca9898ccc32975ec74dd/coverage-7.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:32c56b5b47c50635081445ac404dd08c2d591b9c837c22570aa9e182c3b42cd4", size = 256825, upload-time = "2026-08-28T21:51:40.27Z" }, + { url = "https://files.pythonhosted.org/packages/15/f6/d59e1c0693ad48855fe20169fbf6ee5befefe5887a7fabf5f0bcb464a2dc/coverage-7.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6ad3bbad240ab937512156bc944fdee63ac4dd34a7558a3094548fd4c1150c02", size = 254970, upload-time = "2026-08-28T21:51:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/df/7b/b51bbe05b3a7565927fccfb1be42b8b3c1f4ab15e53d91b303e9923969aa/coverage-7.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4c1f16d5555a195295d0dc9c902612270e3dfed6a11f3bf7bc470b7b6a79ed3c", size = 259039, upload-time = "2026-08-28T21:51:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/d513f816456a8a43c1859abe88a37d01d7d2515b6c3e24ebb3c9b1dd44ec/coverage-7.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f6c9c21a8bf0d19788f3c5f3e020c90317a0a63ef60521b376003801e21250fb", size = 254539, upload-time = "2026-08-28T21:51:46.733Z" }, + { url = "https://files.pythonhosted.org/packages/dc/54/5542190ceb97e0d1333a4ce0c8f95b2ef2efe790f1ad018a4b61766f849e/coverage-7.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06f20145a9eb5bf1fd1dde3c0bc2af2e7c22135ab07ca6284d6ada7cc3904c4e", size = 256410, upload-time = "2026-08-28T21:51:48.363Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/78643f361ff6bb5b2ade90f8bfc8395fe9ca367a18c101f8991215b4c65b/coverage-7.16.0-cp312-cp312-win32.whl", hash = "sha256:916cf8d25c1ce148f7eceb1d45afc9724841200110adc4e53250391852debd91", size = 225239, upload-time = "2026-08-28T21:51:50.22Z" }, + { url = "https://files.pythonhosted.org/packages/67/61/8e76b36c36b1a033dc933dd2480db96b04ce3be975793ce3fad122e7174d/coverage-7.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:78f8b56261d608be102c62edd3a60b66bcd0b581f3f86fdcabaf8b8d95adc950", size = 225775, upload-time = "2026-08-28T21:51:51.912Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f3/bb4787a4b81c1792ca69b502f5f730dbbb609f73fed552ab074c6b92cb8b/coverage-7.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:577c2ac8c0036f6f8edd3a7783a9e67302b17771d1abf0fd2ed246e3158be51b", size = 225159, upload-time = "2026-08-28T21:51:53.667Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" }, ] [[package]] name = "cryptography" -version = "50.0.0" +version = "50.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, - { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, - { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, - { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, - { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, - { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, - { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, - { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, - { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, - { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, - { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, - { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, - { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, - { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, - { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, - { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, - { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, - { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, - { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, - { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, ] [[package]] @@ -359,59 +356,59 @@ wheels = [ [[package]] name = "cython" -version = "3.2.9" +version = "3.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/de/db48b8870e766cfea809986cc50c1e986c663a9ab7bafd0ac1a2512c4a26/cython-3.2.9.tar.gz", hash = "sha256:d249c9022ab13286b17bd66f30609e800c5f95efeecb06168990c7a66cecde6c", size = 3293493, upload-time = "2026-07-24T06:21:21.867Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/d8/4981ef716ad0e3ff0d3ef383aefc6b03c4a88dee33b272bf8e0d833001ca/cython-3.3.0.tar.gz", hash = "sha256:eed0d93fbca7087f143b42c34b05a825849bdf17f101572c2105acfa49aa88b8", size = 3727515, upload-time = "2026-08-22T05:16:39.493Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/37/c74d842306c8fe381c415b37460d5e3086a820fac72b8ff5cb48513ccfcd/cython-3.2.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:114b2dee0fa1daa48a59574d848da0ff1b6bdb725a755e9b92fad14962e1ff8d", size = 3009571, upload-time = "2026-07-24T06:21:52.534Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/f7c42b161edd585e3ae556fd62a2c72cd80a6ed527a9907f0c5c6fb060de/cython-3.2.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5cd9c5f138cb052130b40ad3b6976d2180c35348410995812678f4636bd8f94", size = 3183562, upload-time = "2026-07-24T06:21:54.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/1b/c04520ac7f3157aa12a69b632c16261170dac9fab6c48608cc004b8f1b17/cython-3.2.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23e80bc885c599e72072e18d0746df82d394b73100c1e153cda7359e6e59fe09", size = 3354811, upload-time = "2026-07-24T06:21:56.72Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/4ce33235a25b19fcd51dc639f0f403b783a3b7f9b1934eade0d993fbe029/cython-3.2.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b1fd5a9c03f72a18618668a8e90d569442ed742f910e3ad003dcc9348e9598b", size = 2778077, upload-time = "2026-07-24T06:21:58.7Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4a/342312c5fe021c8e0c386e1915d138e0902c48ae179b0374ab04773a8831/cython-3.2.9-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:944dc8747f640b3527649c566a5fc75ee0c15e80642ea2fdae4fe6378e1a9d4a", size = 2899729, upload-time = "2026-07-24T06:22:24.877Z" }, - { url = "https://files.pythonhosted.org/packages/f0/62/ea919ee426cb4d435ec8155e1ee6bcbb46b20d8f070527191b59769d4e7f/cython-3.2.9-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4b871ad97dd7fb1cbf56f6238c54423febd310afc1d9d9bc70c69c89b7ce57fc", size = 3226650, upload-time = "2026-07-24T06:22:26.947Z" }, - { url = "https://files.pythonhosted.org/packages/18/02/057b4f63e2ced8c3cf217c4e9fb544bfe48145f493347c7ca3f51607526c/cython-3.2.9-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9b6ebc6c74b4318eaa4e51e520dc8b95ebc7b262953c3ecb24131104681f14e", size = 2881919, upload-time = "2026-07-24T06:22:29.318Z" }, - { url = "https://files.pythonhosted.org/packages/64/e4/e158793ee3de7e4417ba17e7ff1015d6e2cf557cb485ad270b2446c9d1c7/cython-3.2.9-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:92989da161a7d18a7ad4baebc49289b2b77556d5a94916f90140ba26aecf6892", size = 3004702, upload-time = "2026-07-24T06:22:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c0/d5bbbd743ab4feddb24a7e823b34c3ec4ebab91ff503d16743a4e7ce106b/cython-3.2.9-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e75ec625d8f8781ced690b7a2f5c2d138067711cf24bb8fb68c872c30c2fefe5", size = 2902695, upload-time = "2026-07-24T06:22:33.525Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/80850817395985259f135baa510d9186d3a325df81cd1862060bba977029/cython-3.2.9-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:2b1756ddc3bc0cd4341a515fc420c3e25e13c249f5537159b3fb0bff8d19e55c", size = 3241554, upload-time = "2026-07-24T06:22:35.667Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ce/6be776814f6cb81751f3da737ed537385738148e1ea99f89fb4637799198/cython-3.2.9-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7d41baea51ea00f9237f75af498577827493bca5e9b45bbd4e351543727e589a", size = 3124337, upload-time = "2026-07-24T06:22:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/e2/48/27c948cbbfe6050994e67a497ae530955dcda7e79084319321c49969e0fd/cython-3.2.9-cp39-abi3-win32.whl", hash = "sha256:61d4abbf84f77c8d19361d05d9f51d65d8d95e74f736eae55fa1aed8a1430469", size = 2435609, upload-time = "2026-07-24T06:22:40.005Z" }, - { url = "https://files.pythonhosted.org/packages/17/ef/cf0e1bd7542296f1752be63b027f90271448d8c8062eac66d8e44a79b883/cython-3.2.9-cp39-abi3-win_arm64.whl", hash = "sha256:57a6a78d14f7dd7d6062d9bca694e2a8c1c14113b6ceceea076abcd1161fdc5a", size = 2458025, upload-time = "2026-07-24T06:22:41.973Z" }, - { url = "https://files.pythonhosted.org/packages/00/ec/e61deec9bcfbb0e1b36f8b5ba75cb44644419b4bfd0fdd666bffd21d9579/cython-3.2.9-py3-none-any.whl", hash = "sha256:a2b0e87f6b80790c929308ca0831d686f7a180feab684fe8cd4a4380bd96aaca", size = 1259272, upload-time = "2026-07-24T06:21:18.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/95bad838a80ac52c9e982dad00bd9a0b2bad57fb4c688e5f53ac3ef65ff0/cython-3.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03bc5333932f5dda3ba9315298ecdd21daa1b58410bb1f8ce04c78ec8337130a", size = 3143472, upload-time = "2026-08-22T05:17:07.956Z" }, + { url = "https://files.pythonhosted.org/packages/91/8b/53d4a84de853b39940a0e35a6a2a9ed5f54cb05468daee95bc0fd1c2a178/cython-3.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e321ae700995a16dc3055ada06ffb8d61e1a7434e5d0e811547a45ac1015ebd", size = 3258974, upload-time = "2026-08-22T05:17:09.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4e/6b1c5a4e6bbe1726104de007aa2fdf01a3e2e386b4ec93c7be5f5085d53f/cython-3.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:428fafed98ea26927000a287b4dfc9ef07339f56656a5329a34eaa593f79a4f8", size = 3412225, upload-time = "2026-08-22T05:17:12.281Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9b/cd724d91c500116769bdb853450a2197ba3d640dbbe3b02fc54ebdfdbd1b/cython-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:333449cc0350baedee5a6af27929eac8a71eac4ec59333c45ff476b33c6c660d", size = 2872123, upload-time = "2026-08-22T05:17:14.322Z" }, + { url = "https://files.pythonhosted.org/packages/14/59/bc1a84b434cb5bebb0cd6f50da8f239d35a5c141b20fdeafc2817fd87778/cython-3.3.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:e0d2713d2b292c826bc21dc8732bd9e47628103aa3764180c881e04b3fef95dc", size = 3063660, upload-time = "2026-08-22T05:17:40.923Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6d/542e32908fb421d88354f327ed6450e14240f9825d25393065bc65f4723f/cython-3.3.0-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:169e56fd411f4cd5bba51c82f8239421d547a846099db2b261e4aed48ba9f51f", size = 3358395, upload-time = "2026-08-22T05:17:43.036Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ddaf197bc65b581e1891657940bc4f7cb1f740e822115e828920b3a119ce/cython-3.3.0-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:29f38ebafdf23e3da2516f40c4d065da38bfe002181bf93e2b8cf1262449aba6", size = 3041760, upload-time = "2026-08-22T05:17:44.907Z" }, + { url = "https://files.pythonhosted.org/packages/19/a7/ae5ec3e34d43da846ed4c425734752d83aae0dae49feb929f09c90fc9afa/cython-3.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:75c4ae8a6d3a5ccf3cdaba8ab32e6a8d0cd38e3a476aa7ac12df8f8171a8d570", size = 3156152, upload-time = "2026-08-22T05:17:46.884Z" }, + { url = "https://files.pythonhosted.org/packages/31/44/c60b601fc43f0b08e9d6f14b94e0dd02eb0ca8d60f46e242ace7191ac1be/cython-3.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b94fb5613b9fe34c27d13ec9972dc0dcd2a2155db2902e93921cadc162610a38", size = 3046690, upload-time = "2026-08-22T05:17:48.731Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9e/d735c26ed907563d3365534006acb263651c2d3b87fee804f7a483dd1714/cython-3.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c4558ba85849ab65dc57e10fd0efb13fabd9d3c09981a2566e18dec7cf47586a", size = 3373534, upload-time = "2026-08-22T05:17:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e8/aa7b4f3a28d6e8117c76e2cf78a0df7a503486cdf7243c5b53200c9533a1/cython-3.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:311a016369adfd1e0015c4f9819168fc0e518451d7efb4435c30d65a3a26d52b", size = 3265110, upload-time = "2026-08-22T05:17:52.577Z" }, + { url = "https://files.pythonhosted.org/packages/9c/66/37892a8999d6bbd3f92d691a9701cb720c8ddd6171e16f5148eee6e8cb7f/cython-3.3.0-cp39-abi3-win32.whl", hash = "sha256:90869072e50b7c8904fe1dd7810321ae901fd5637a6eec6646ed9c57f9eb1081", size = 2587733, upload-time = "2026-08-22T05:17:54.547Z" }, + { url = "https://files.pythonhosted.org/packages/19/a2/5f4d305cbd4489d21570e5491ad5c483c478cdab032853e2125c280e3bd5/cython-3.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:dce56c26d388f00a19426371b6926bf2f77c5c03b71d5273e4556c68be98c2dd", size = 2608078, upload-time = "2026-08-22T05:17:56.386Z" }, + { url = "https://files.pythonhosted.org/packages/bf/77/67b0b24e45073a699610e50f00c18474ff9b09ea29ecc95083bdf5e60acd/cython-3.3.0-py3-none-any.whl", hash = "sha256:9b24b5c8cd536946b62086fcafee6d5509d3f549f72d553d2336af87ffbe0da1", size = 1349151, upload-time = "2026-08-22T05:16:36.741Z" }, ] [[package]] name = "deepmerge" -version = "3.0" +version = "3.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/6c/9f4577a36d5f463a3a3f8322bd65d33e1a1a6b6ba1d692a5ebc3cba19015/deepmerge-3.0.tar.gz", hash = "sha256:14ed69f063de64b7743985c732ccff5d6c34ff4560946e7fbfd99086b853b9ce", size = 22279, upload-time = "2026-08-17T05:50:53.161Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/6e/5cb3548b4d3112fea529375e55e6f3cdc52b8054e3a66f203b1f888ba885/deepmerge-3.0.1.tar.gz", hash = "sha256:35b39a4cb92cf328d6eca61cbbf65f68a37c2ceb3085f0f853cbb2e52a59fc23", size = 22328, upload-time = "2026-09-01T14:09:44.383Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/d7/7f19bedd30b90b72865aeec3a29127bed6dee6c9ef0324bb5b4d424bb0e3/deepmerge-3.0-py3-none-any.whl", hash = "sha256:c8541c3e186dc88d19a5513ad3a0b2d0b22beaa780969fc0c13b995a64265365", size = 14855, upload-time = "2026-08-17T05:50:52.218Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/600003aaad107e27553fbc9cbfc57e96fa37e0223d2ef9d7d3a0e8d8d070/deepmerge-3.0.1-py3-none-any.whl", hash = "sha256:35c96f6a68fcf90719a5b31d9f8042ecef6c00fb56836660d33455d0f5cfda65", size = 14909, upload-time = "2026-09-01T14:09:43.364Z" }, ] [[package]] name = "filelock" -version = "3.32.3" +version = "3.32.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/64/a02e6765de08964ed371eca577870593245afc9dfac16d037de7c10d18e6/filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f", size = 218135, upload-time = "2026-08-13T16:00:05.577Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/59/e19834834cb01a32febfbb0f8a23a9088088f5d45991824ff2bc3b5e8acb/filelock-3.32.7.tar.gz", hash = "sha256:37b8a3d9811b0f9aef7e5ec5c71bb320de52df51e6ca9bcd6f5ad81187660da7", size = 225154, upload-time = "2026-09-16T00:24:20.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09", size = 98901, upload-time = "2026-08-13T16:00:03.974Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/31098c5aeb4d966b553641472bd55fcf5fdfac953549894b8a765ba44e91/filelock-3.32.7-py3-none-any.whl", hash = "sha256:65ff0d0190ea42038b32bda4b77834fb05be2cad4c5b9b01aa4dfb3614536e52", size = 100157, upload-time = "2026-09-16T00:24:19.543Z" }, ] [[package]] name = "fonttools" -version = "4.63.0" +version = "4.65.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/51/d63c7e52163ac14393a35bd14bd7c0da95f8f74be5d7cc988092f9965129/fonttools-4.65.0.tar.gz", hash = "sha256:762ba5431358d0dbd4a01982484a1d494fb267e91f974cdcf20b80eab8560f6f", size = 3674467, upload-time = "2026-09-10T15:35:54.955Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, - { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, - { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, - { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, - { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, + { url = "https://files.pythonhosted.org/packages/58/db/242fa4fce7f632c5f7ab15585343393b25792510c0c32bd218ad24d59f1c/fonttools-4.65.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e844a45c9e5ced6536f184cf1a65b5d65e8f7e711993b413e10500a8223622e5", size = 3097120, upload-time = "2026-09-10T15:33:46Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b6/42fa4d373416675f74446421cf0b2badb82a4245c60745f05f424f75c649/fonttools-4.65.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b30e953de049bf43fc0a63c7d0c44d205c923e4bbf24716aae1518c0e65f977c", size = 2584658, upload-time = "2026-09-10T15:33:48.473Z" }, + { url = "https://files.pythonhosted.org/packages/75/6f/d589b9d62280a846c77a2c383d852c6dcb79ae8aa02bf0fa46c8577af145/fonttools-4.65.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09c34bdeed8915bfb53bee0c8ed2254dbd8ec69c0014b7f3702f347c049bf358", size = 5425737, upload-time = "2026-09-10T15:33:51.473Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/de2c0c20a42c18e565a2617932beb08c06697bbdd0d3f62b108262e11583/fonttools-4.65.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:05595385ae99f4b9626cebb973bf171b8fe38a8f40708e6e42abba0ed7537778", size = 5402463, upload-time = "2026-09-10T15:33:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/2e/bc0f5c9dce21821454bb5812d3b23410bca33c8bbd5468386d0327aa0cff/fonttools-4.65.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d95b34dd68fbfc0e4a1740c421597656117f979ed8dc85de66e08f9f9981806e", size = 5362168, upload-time = "2026-09-10T15:33:57.481Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0d/2116763ade7e71e0e5d421babe1785d745be9b3d605bf914792ce1c97f79/fonttools-4.65.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:924d06e6130429168318db71c40174a765ad016fc4b56ca811287e3d7373b3a6", size = 5524117, upload-time = "2026-09-10T15:34:00.021Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5e/f9600553b9f645e3068553831685ff1dab6259536a23b38d2e048de38f17/fonttools-4.65.0-cp312-cp312-win32.whl", hash = "sha256:04f73dd01005752a6e75cf4a8dc6b70dc724d1d4bc34cc89522153f4a2f07680", size = 2432089, upload-time = "2026-09-10T15:34:02.803Z" }, + { url = "https://files.pythonhosted.org/packages/3a/02/e436a6a1863b9862bab9f82d6da33055dd7aa3738017edd902a163525dc2/fonttools-4.65.0-cp312-cp312-win_amd64.whl", hash = "sha256:3b5d9ba89edf778b376e669b879ae33a198bf45cf5a23c3f6514f935cf9d0d9d", size = 2483475, upload-time = "2026-09-10T15:34:05.09Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/f894ceb867118c0261d0f69a9bd516b045a3754238f76c88a49513ac7a83/fonttools-4.65.0-py3-none-any.whl", hash = "sha256:3060b8c1fc2329fa20265b7c138614143ea7c1624e26c5c180c76aeb74deae6f", size = 1196441, upload-time = "2026-09-10T15:35:52.347Z" }, ] [[package]] @@ -478,7 +475,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.28.0" +version = "1.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -491,9 +488,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/ae/222a91937ebee7f62c0ca8f5ee0afd97577caf24c0abb927d1f5c7e9f6d2/huggingface_hub-1.28.0.tar.gz", hash = "sha256:46a2e950c09234de54093d587d1675382f0d08dbd600d9fb599b5932f5b2c6cb", size = 959609, upload-time = "2026-08-18T12:27:15.101Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/f0/61159db90b5cd275d55516fe27920828e7d3be4053fdbdb27c3f70e5f1ef/huggingface_hub-1.31.0.tar.gz", hash = "sha256:f8e9e710a210613fa5d0f26bba6da05ef4aef9fba5a0f23f508f5ac4d08b6f90", size = 968039, upload-time = "2026-09-10T10:27:22.724Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/0e/eafef18f1a75e125e68395db21131db0cf868a128ecd2fce69b4df6c584b/huggingface_hub-1.28.0-py3-none-any.whl", hash = "sha256:58a8bacb03072edfc38067065e9dc24bbb34805410fcd36a1632de0b329660bb", size = 793202, upload-time = "2026-08-18T12:27:12.719Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7f/3f886a625043b77312b80da2f2bf00b5ecbf5a73061af1aa0259cd258c9d/huggingface_hub-1.31.0-py3-none-any.whl", hash = "sha256:9dbb6a503cbe2494ea666695207e7262d410659e09134059deb83e5480864667", size = 798313, upload-time = "2026-09-10T10:27:20.798Z" }, ] [[package]] @@ -546,29 +543,28 @@ wheels = [ [[package]] name = "kiwisolver" -version = "1.5.0" +version = "1.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/07/bd78e6a8fae171ea041ef5bba3ed21a003522fa088834b069b1909981f30/kiwisolver-1.5.1.tar.gz", hash = "sha256:f1303ef2eec81262a4b708c3e858afe58d7c75ad91c1c05266eda7673369859a", size = 104395, upload-time = "2026-08-28T10:28:27.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, - { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, - { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, - { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, - { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, - { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, - { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, - { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, - { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, - { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, - { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, - { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, - { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, - { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, - { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, - { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9b/65b302742389c6f96f2956bef5decf26011309feb2fc5d79613af18adea4/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:63fb7294b768f444eb4b068965f2662f28c2fd4161e23bd60fcf3ff27b74c046", size = 123876, upload-time = "2026-08-28T10:25:27.44Z" }, + { url = "https://files.pythonhosted.org/packages/71/74/c21f339956f6f691b2ed7e31d5f3ae767304df6c460192739fc830853051/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ebdef3eae5336568147c39a55be6a2036ffde53faa9ca2d978989ae7c2da12c", size = 66487, upload-time = "2026-08-28T10:25:28.728Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/bdb34e21523e01dceda064d63713f3bdec91388af24fba1eca7ea5e85864/kiwisolver-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1798e83840c3f627246104c4d8a9639c60fa068adf9ce92b61791781fa8a68c1", size = 64660, upload-time = "2026-08-28T10:25:30.071Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f4/dadfec469313c7f428efa7e84b4aba9732f813c13ea7131a24b7b008ef57/kiwisolver-1.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34633ecf50d16187ab8e5528b7a2530f2feb4e23f300db4672538b51cfc5cd38", size = 1477929, upload-time = "2026-08-28T10:25:31.495Z" }, + { url = "https://files.pythonhosted.org/packages/6f/35/09c58daac34e6f6ea5c6dee0094b422118e5a7c265586008a95fd135ac5f/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d27c2123977cb9269c30a49ba45f03a4323017ef693e19db4ec9dbe1299a3002", size = 1278499, upload-time = "2026-08-28T10:25:33.375Z" }, + { url = "https://files.pythonhosted.org/packages/19/32/739765e24fbad29d13f83e546ea4abc215a78cea9d677ca09025b027724d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a797a1cefc8b9c93170db580337e1fe3d011ad18b1299943231279406342048", size = 1296677, upload-time = "2026-08-28T10:25:35.059Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/03304d1010e2cc45e5b3b52cef7e43fed3a2a5cd6c87a89b4a88e1d85b5d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2551cf9917af48ee7c4b29cc82320489508cf96fd26a51f6fc124de661cd44c7", size = 1346037, upload-time = "2026-08-28T10:25:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/3e/57/4c49377bfd274450dd72ecaa13eaac32ea804a03363e4d1db0c5aa999ceb/kiwisolver-1.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:38f6e0deb4d0a4615efe0c4efc5990b06ae450ab50a0b321c0b078b6d238c083", size = 988248, upload-time = "2026-08-28T10:25:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/38df144a08b6c5d75ca4504e5cc3141bb3bfef64c04f4ef48204f42711b6/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bfd1de989b3330420e29de39352f5c049905c9e3ee67233a50d550e3d652c148", size = 2228722, upload-time = "2026-08-28T10:25:40.038Z" }, + { url = "https://files.pythonhosted.org/packages/e7/11/3221838a89cd64d9b386353e000cd8a296069a20fbe3584507fdfd5bebae/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1209042a623ddfda5497e4066c7b77651dde8e1d3a9dd97599dc7e97f3b9b78c", size = 2325216, upload-time = "2026-08-28T10:25:41.699Z" }, + { url = "https://files.pythonhosted.org/packages/83/d4/075c219230697bb5db910d37262b9bacf880f92b4811a02ab81ed073a253/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:26e8268480be5061d509e29669d59103c067a26377a56491630ece11762e3858", size = 1977689, upload-time = "2026-08-28T10:25:43.559Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/1d219c3c2dd960983d0d4da623d916e9de6385df2b0bab3d1af0e9b8fccc/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d79308fa689fac89cbcfbd4dbfc80b5f95c54c5a7fd4d194be221f9d33d026e6", size = 2491443, upload-time = "2026-08-28T10:25:45.242Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d3/024208ec1079d273f1047468d1bdffbf38bb75b7b268090fd3a0301b9d9a/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b03af77d77e50edba2030fd5f7c352ff209314b09030a3cba7c14edf9a09a444", size = 2295200, upload-time = "2026-08-28T10:25:46.984Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/7b210498f9f92e1cd7855f260fa69ef056881087b199ee20c208f0e4189a/kiwisolver-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:06a6917674de9e0fe3f66f5430787f59a9f2ddb64af9b714eaec547e29ef5c19", size = 70748, upload-time = "2026-08-28T10:25:48.444Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/ef0daa157c8bb23672f7423e0d14c39db1dc6ef8ed47e6bc54c9c1bef3bf/kiwisolver-1.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:ad8b9671348d7c8716715652ae11f85ed0eb99e265a2df2ca490577d69860b2c", size = 68324, upload-time = "2026-08-28T10:25:49.81Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c4/1407df7512a5b36cc79840e01710dc575733c461b13ab866cae77eaf87f3/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:482676e5bd48d70ac99d9fc78863469845421e01184fa83f1f9366dc49f7e974", size = 134002, upload-time = "2026-08-28T10:28:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/16/45/c37a21ad5c0ab581a93c55ad544721aaa1f0ae94edb29c6a678a23d013e6/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:072bdb15a3c19a5b5dbc8f8fb1f4e1884bf4f3507eeb4cc6334401274d37a5c0", size = 194292, upload-time = "2026-08-28T10:28:11.06Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/69f00d627949580e43d57af0aa465df46868d7c29801c137a55374101294/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:a5a00665d1a0e26763a7338d7e911d4598fbc1d50dd0d6b7919b7dc6c5d6569f", size = 73362, upload-time = "2026-08-28T10:28:12.449Z" }, ] [[package]] @@ -643,7 +639,7 @@ wheels = [ [[package]] name = "matplotlib" -version = "3.11.1" +version = "3.11.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy" }, @@ -656,15 +652,15 @@ dependencies = [ { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/9aa712a0afb882649424dd8de8ad9aa6235e796e84c6052e8f6dc1598d0d/matplotlib-3.11.2.tar.gz", hash = "sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4", size = 32660610, upload-time = "2026-09-11T19:05:31.214Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/6c/7ef7ebcb2bd9739b2b66b18b076e077f44bb46fdbe28ca0506edb3c62c79/matplotlib-3.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e15ef41507f3d525f46154ac9e3ae785dacde9f20e593a25de8986267892ef74", size = 9453849, upload-time = "2026-07-18T03:38:19.593Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/6d0c312c8d9738e7d9677f09fe5c986b3239e651a7b73a2deb38b65e4a71/matplotlib-3.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21a67b961a6d597bca54fae826cd20695ba4a6e4d05424a08da6e13e3176fd6b", size = 9283113, upload-time = "2026-07-18T03:38:21.95Z" }, - { url = "https://files.pythonhosted.org/packages/c9/cf/b4ad2cc81b6672ea29ea04e64e350a9f9b493b0908ccd884c67eeff8f7b2/matplotlib-3.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba8f811b8ddfac493734d6af0b2dff96919d0c28ca0d641858dab4262777c6ea", size = 10035615, upload-time = "2026-07-18T03:38:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/88/90/4e10e033d9b66589d8ed98b84c95cdbb57033d57c1f41339d7393dbd2f2e/matplotlib-3.11.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c52f7ad20ef476806ed212380b1d54d20310c8b86bdc2c9a68b51f0024a44472", size = 10842559, upload-time = "2026-07-18T03:38:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/88/eb/799612d0f8cd3e816a10fec59329fca52cd2353264df80378dfc541ae855/matplotlib-3.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b14eb22961fe865efb0e4ff167e333e428908b00115a8d800ccb65ee108e481", size = 10927532, upload-time = "2026-07-18T03:38:28.532Z" }, - { url = "https://files.pythonhosted.org/packages/88/89/56649bbaa2fd12e20f3be03dbcc135b0c8676d88bac17977599e3eb442a0/matplotlib-3.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:88a2a27dd9691ae448dfae4b26f59036be90c3c28757edd3553a29559d00859f", size = 9333886, upload-time = "2026-07-18T03:38:30.477Z" }, - { url = "https://files.pythonhosted.org/packages/c1/11/4d124efbbad677b7b7552f6f85a3bd432d4232f95400cea98fcd2ae36ef3/matplotlib-3.11.1-cp312-cp312-win_arm64.whl", hash = "sha256:480194afceca4df2f137c2721227d3cba67121fbf4397b69cee7f83714b0a58a", size = 9007545, upload-time = "2026-07-18T03:38:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ce/1bfcc4873b121597791ad74032943b123218c0613af0b97e8dd05e916fb1/matplotlib-3.11.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef752769cd962f39ea0b6ffc82d1ea43a0012c5a6157c7a075212fa509cfcff2", size = 9476466, upload-time = "2026-09-11T19:03:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c4/7f5f3601ee69baf072c0c7d3ce60c03e0618621c5a56c34a62a460e29d11/matplotlib-3.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef31985c4dedb5f1424e1aec6849a47dd37689cb7fa3c20b1b82187f26806261", size = 9305583, upload-time = "2026-09-11T19:03:22.39Z" }, + { url = "https://files.pythonhosted.org/packages/b8/90/2b3fd67ee273163faeda6d514be70b5596eaa0fd77b60ffc294ad0b34f5f/matplotlib-3.11.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df4f7784aca81a94f254c0a2767d592ee25f407e488f5fa7203e51093fb6ca27", size = 9860353, upload-time = "2026-09-11T19:03:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/f4/84/32549e7a462dc311aed2ab62e5d2538028840b5c53a8be0195c789937c3d/matplotlib-3.11.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b9a7ad579856284135e401ecc918c5f8a017ee30539298862a109f51b971710", size = 10670348, upload-time = "2026-09-11T19:03:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/84/39/02e21b74f7439bd643d717ea846006d46e309e6d29b632b57751265311d6/matplotlib-3.11.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3aa4b8516fd26659e4363abbf317c703d9116496c5db2e9d0609a2866dd39dd2", size = 10810580, upload-time = "2026-09-11T19:03:31.402Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/0d239bde12b308262265c2d98909b2f0ecad2b5deeae96241642e822e9c7/matplotlib-3.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:c5c1c68ee401fc98271263410f0e5ce88285abacf7627132914e8adf3d70ff43", size = 9349409, upload-time = "2026-09-11T19:03:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/49/a8/06baf901c02246c8a222b655cc4540ef9c15e1549a04e07927f9cde716c3/matplotlib-3.11.2-cp312-cp312-win_arm64.whl", hash = "sha256:643ff850d8e0f5b8319337f87ed3cb59506afb3df3cc48de777d85871233be7b", size = 9028084, upload-time = "2026-09-11T19:03:37.032Z" }, ] [[package]] @@ -697,21 +693,21 @@ provides-extras = ["dev"] [[package]] name = "numpy" -version = "2.5.2" +version = "2.5.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, - { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, - { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, - { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, - { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, - { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, - { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, - { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, - { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, ] [[package]] @@ -774,7 +770,6 @@ dependencies = [ { name = "pyzmq" }, { name = "requests" }, { name = "scons" }, - { name = "sentry-sdk" }, { name = "setproctitle" }, { name = "sounddevice" }, { name = "tqdm" }, @@ -847,8 +842,7 @@ requires-dist = [ { name = "rednose", marker = "extra == 'submodules'", editable = "rednose_repo" }, { name = "requests" }, { name = "ruff", marker = "extra == 'testing'" }, - { name = "scons", specifier = "==4.10.1" }, - { name = "sentry-sdk" }, + { name = "scons" }, { name = "setproctitle" }, { name = "sounddevice" }, { name = "teleoprtc", marker = "extra == 'submodules'", editable = "teleoprtc_repo" }, @@ -886,17 +880,11 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "cffi", marker = "extra == 'dev'" }, - { name = "cppcheck", marker = "extra == 'dev'", git = "https://github.com/commaai/dependencies.git?subdirectory=cppcheck&rev=release-cppcheck" }, - { name = "flaky", marker = "extra == 'dev'" }, - { name = "gcc-arm-none-eabi", marker = "extra == 'dev'", git = "https://github.com/commaai/dependencies.git?subdirectory=gcc-arm-none-eabi&rev=release-gcc-arm-none-eabi" }, + { name = "comma-deps-cppcheck", marker = "extra == 'dev'" }, + { name = "comma-deps-gcc-arm-none-eabi", marker = "extra == 'dev'" }, { name = "libusb-package" }, { name = "libusb1" }, { name = "opendbc", git = "https://github.com/sunnypilot/opendbc.git?rev=master" }, - { name = "pytest", marker = "extra == 'dev'" }, - { name = "pytest-mock", marker = "extra == 'dev'" }, - { name = "pytest-timeout", marker = "extra == 'dev'" }, - { name = "pytest-xdist", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "scons", marker = "extra == 'dev'" }, { name = "setuptools", marker = "extra == 'dev'" }, @@ -980,11 +968,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, ] [package.optional-dependencies] @@ -994,15 +982,15 @@ crypto = [ [[package]] name = "pymdown-extensions" -version = "11.0.1" +version = "12.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/7e/53144b512818c75d459523e3a91d9bf7b65cd6a1dfb964fe690adc8b3670/pymdown_extensions-12.0.1.tar.gz", hash = "sha256:88fe2c97fb9153b9f2cc1da59f1d7c7cb74971110daf33cccb2a5514e0f7e970", size = 866904, upload-time = "2026-09-15T23:20:33.187Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, + { url = "https://files.pythonhosted.org/packages/8c/30/c866b3ca8ad5cca79a3ee1a55c12d61d3e72381e5eadbfdaceb6006ebc84/pymdown_extensions-12.0.1-py3-none-any.whl", hash = "sha256:15d629e8035892b93d788a8424ccea754d4aa3cec247a64cbfe7b15089c9ad7f", size = 276957, upload-time = "2026-09-15T23:20:31.551Z" }, ] [[package]] @@ -1046,23 +1034,24 @@ wheels = [ [[package]] name = "pyzmq" -version = "27.1.0" +version = "27.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "implementation_name == 'pypy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/8d/5b3d5631c2f4b4b8862f64cd0c9eb777b5710eeb5125b4be8dd0a200a4c0/pyzmq-27.2.0.tar.gz", hash = "sha256:54d4259d1bfae24ecdb5ca79f7acc2eac6c286a02d6a0ae617797cb45f0726d3", size = 292316, upload-time = "2026-08-20T19:08:21.19Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, - { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/153532fa53db30e116118164f3af269a1f3966b3e2ba32c89b12fe864bd8/pyzmq-27.2.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:591c8de5851c5ea372194469fe97587b97c3b641e9a70f31bb3474acbfde0241", size = 1431074, upload-time = "2026-08-20T19:06:40.601Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ef/c08b91248bb90a9efa81fa00ba81b69c157c74d0c5efbb2c319d91babb62/pyzmq-27.2.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:00e73942ef12cecbc7951c4a9104bb8ffaed742abb13af2da6833d90dd368cef", size = 973915, upload-time = "2026-08-20T19:06:42.037Z" }, + { url = "https://files.pythonhosted.org/packages/b4/78/a3a3a86c2b00fadb92ece1ca4f8f028d62b2ce9ac3526097239ab2d6fba9/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8079d0521fe94bbb401fe9407578b28f3701627c8be2c9f7e0c5b77dcb0109", size = 697722, upload-time = "2026-08-20T19:06:43.325Z" }, + { url = "https://files.pythonhosted.org/packages/62/2c/d5828306f795e8d34676d266823b74e2101e0ad3760d12083de3e02abbb2/pyzmq-27.2.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dea74fd65f1fc5f7fe167916a473ebe6ed6174e5e5d9de11ea6583661be6cf43", size = 872258, upload-time = "2026-08-20T19:06:44.627Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/51253b78fd8739293e283407eeecb14215c02c71b6519af21f6eed8e69cd/pyzmq-27.2.0-cp312-abi3-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcc99ca132b667a4ed750afd42db4ea73288f18425a9b2e3c0af095665c491f5", size = 739591, upload-time = "2026-08-20T19:06:46.214Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3e/142c85b67a4c9678629b0cf6d5125b29663d75be69bfaa57a3cac344d780/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b8d5f66e4a8246cf77f7b8f7902af64f00553368fa0373c89d99b78f0ad79394", size = 1689031, upload-time = "2026-08-20T19:06:47.612Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ee/0776fb0f98ed1eb74d77240087fef0ab045b6ad15cb09555c6c5134c98ad/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:d1526b42a2e725b84ed226f37becedc250c6347594e5ed304e4e9aff68c9aec3", size = 2059547, upload-time = "2026-08-20T19:06:49.064Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/ec77f691a4aebe29ab6329f996fb0e0270c876a3016086e3ca6ef733bcae/pyzmq-27.2.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f707bcf2c1d007d14d70531d4dd7b41060881c73efa845580bf6faaf9ea24d42", size = 1910457, upload-time = "2026-08-20T19:06:50.783Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/1f5530ff4fc271b4597048371d5af972c2baab51be132ba15874e0327a6a/pyzmq-27.2.0-cp312-abi3-win32.whl", hash = "sha256:fdaaa4ea3242f6ad298eb5177eb042aea5c73c30e76d20caee7b15af20d24ec2", size = 563450, upload-time = "2026-08-20T19:06:52.307Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl", hash = "sha256:2c218c6ab8bc447ba62054b581fd30209689d199c6ecb253f79615ca74a38e12", size = 628633, upload-time = "2026-08-20T19:06:53.809Z" }, + { url = "https://files.pythonhosted.org/packages/52/aa/3918b5ac7f9987bd9c421b065074fd7409ded88f856f2c704a24341877ec/pyzmq-27.2.0-cp312-abi3-win_arm64.whl", hash = "sha256:348d6fd3e4b81ae4580622ea8c2ea60224e84b2ac1b3be4482e6edc7de06e7a3", size = 556006, upload-time = "2026-08-20T19:06:55.242Z" }, ] [[package]] @@ -1111,49 +1100,36 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.3" +version = "0.16.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, - { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, - { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, - { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, - { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, - { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, - { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, - { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, - { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, - { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, ] [[package]] name = "scons" -version = "4.10.1" +version = "4.11.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/c9/2f430bb39e4eccba32ce8008df4a3206df651276422204e177a09e12b30b/scons-4.10.1.tar.gz", hash = "sha256:99c0e94a42a2c1182fa6859b0be697953db07ba936ecc9817ae0d218ced20b15", size = 3258403, upload-time = "2025-11-16T22:43:39.258Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/b9/b7a5c88f348a0c34594d88100872c55fa1cae863ccb222c1c438341b5503/scons-4.11.1.tar.gz", hash = "sha256:4210d1a80a62e986029208117991b6347ccaaaab37b67463a3ff31ee065dc487", size = 3268963, upload-time = "2026-08-27T04:33:18.232Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/bf/931fb9fbb87234c32b8b1b1c15fba23472a10777c12043336675633809a7/scons-4.10.1-py3-none-any.whl", hash = "sha256:bd9d1c52f908d874eba92a8c0c0a8dcf2ed9f3b88ab956d0fce1da479c4e7126", size = 4136069, upload-time = "2025-11-16T22:43:35.933Z" }, -] - -[[package]] -name = "sentry-sdk" -version = "2.68.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/94/23b7dd072acb9628907bd3f4fbf61794a7b12a9db8f33c1276f70ae5ac92/sentry_sdk-2.68.0.tar.gz", hash = "sha256:648c58e9887311a03470a41539e24bdbbf64a30ca4f5336f7e3dcc87276400b3", size = 1008854, upload-time = "2026-08-13T09:06:21.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/9b/e2421d08956d0bc4691d995393d835e563886bff499d8fb10fdefae85a8d/sentry_sdk-2.68.0-py3-none-any.whl", hash = "sha256:538e56c2d03679d42f7c0cb5f1af73a7a510b00abc7e296c13ac49b107b713a4", size = 518670, upload-time = "2026-08-13T09:06:19.735Z" }, + { url = "https://files.pythonhosted.org/packages/8e/43/d6285848e893c19682c06e92679dc1a07d37ff7ea148747b1df681ec496c/scons-4.11.1-py3-none-any.whl", hash = "sha256:454cef364348053422696e3d2ecb4fa593c96a624f955842eaaea64f95c8d11d", size = 4123659, upload-time = "2026-08-27T04:33:12.728Z" }, ] [[package]] @@ -1247,7 +1223,7 @@ provides-extras = ["dev"] [[package]] name = "tinygrad" -version = "0.13.0" +version = "0.14.0" source = { editable = "tinygrad_repo" } [package.metadata] @@ -1261,6 +1237,7 @@ requires-dist = [ { name = "hypothesis", marker = "extra == 'testing-minimal'", specifier = ">=6.148.9" }, { name = "influxdb3-python", marker = "extra == 'testing'" }, { name = "librosa", marker = "extra == 'testing'" }, + { name = "mako", marker = "extra == 'autogen'" }, { name = "markdown-callouts", marker = "extra == 'docs'" }, { name = "markdown-exec", extras = ["ansi"], marker = "extra == 'docs'" }, { name = "mkdocs", marker = "extra == 'docs'" }, @@ -1275,7 +1252,7 @@ requires-dist = [ { name = "numpy", marker = "extra == 'testing-minimal'" }, { name = "onnx", marker = "extra == 'testing'", specifier = "==1.19.0" }, { name = "onnx2torch", marker = "extra == 'testing'" }, - { name = "onnxruntime", marker = "extra == 'testing'" }, + { name = "onnxruntime", marker = "extra == 'testing'", specifier = "==1.24.1" }, { name = "openai", marker = "extra == 'testing-unit'" }, { name = "opencv-python", marker = "extra == 'testing'" }, { name = "pandas", marker = "extra == 'testing'" }, @@ -1287,6 +1264,7 @@ requires-dist = [ { name = "pytest-split", marker = "extra == 'testing-minimal'" }, { name = "pytest-timeout", marker = "extra == 'testing-minimal'" }, { name = "pytest-xdist", marker = "extra == 'testing-minimal'" }, + { name = "pyyaml", marker = "extra == 'autogen'" }, { name = "ruff", marker = "extra == 'linting'", specifier = "==0.14.10" }, { name = "safetensors", marker = "extra == 'testing-unit'" }, { name = "sentencepiece", marker = "extra == 'testing'" }, @@ -1301,7 +1279,7 @@ requires-dist = [ { name = "typing-extensions", marker = "extra == 'linting'" }, { name = "z3-solver", marker = "extra == 'testing-minimal'", specifier = "<4.15.4" }, ] -provides-extras = ["linting", "testing-minimal", "testing-unit", "testing", "docs"] +provides-extras = ["linting", "testing-minimal", "testing-unit", "testing", "docs", "autogen"] [[package]] name = "tomli" @@ -1323,39 +1301,39 @@ wheels = [ [[package]] name = "tqdm" -version = "4.70.0" +version = "4.70.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, + { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" }, ] [[package]] name = "ty" -version = "0.0.73" +version = "0.0.80" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/90/c4e1bb4cead3b644c3e258a27f9b05c7dc5eb0ec96a4f5282194edae9e0d/ty-0.0.73.tar.gz", hash = "sha256:823d4ce0d237bfc7eb6bcee70842f2c0706113813a16951077840743712f4b74", size = 6712739, upload-time = "2026-08-19T03:12:43.381Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/b0/6d1b10e0d422736a3c439e487c950ae785f401d71ff879d5be68bccb6d90/ty-0.0.80.tar.gz", hash = "sha256:fe86bc91327e45ff5e3593b7e306e7f57a44bc0608f38d647b8d97bd99c96013", size = 7183998, upload-time = "2026-09-09T21:18:47.547Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/0f/f5e1801e55cc631f2db193276675b30561b963a2403da832bffb5d100267/ty-0.0.73-py3-none-linux_armv6l.whl", hash = "sha256:90a946082bf9bc446b5e72973d9f4ff1222a240b2ca4c9e6eed61eb913e30810", size = 12715452, upload-time = "2026-08-19T03:12:06.673Z" }, - { url = "https://files.pythonhosted.org/packages/54/32/515dd05074c213b433524ab97eb003b0132ae7e358e0d75633ba7a314ed8/ty-0.0.73-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b7d6b5c6a6db7ea95fbbc16af514ef44a27a29a2fe1dc798900790364d170209", size = 12301870, upload-time = "2026-08-19T03:12:08.924Z" }, - { url = "https://files.pythonhosted.org/packages/50/4d/085b4889f0d4bbe4af8b96242d4a1cb209fff95967cfa239ea141983719b/ty-0.0.73-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dd6f657f463e01372d8688f235be164750c8db722c97da27fa4903aa8d40b203", size = 12111741, upload-time = "2026-08-19T03:12:11.067Z" }, - { url = "https://files.pythonhosted.org/packages/95/f6/d6ec277cadfecf03ad4c18551b67c4c6eb7807a0560d801db14be99d7a89/ty-0.0.73-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2de468e33fd44c9ff1c43473a7316f4289480f5cba8995a67b6d22aee39ca9", size = 12196124, upload-time = "2026-08-19T03:12:13.14Z" }, - { url = "https://files.pythonhosted.org/packages/75/b7/ce78d8707563af9cae9bbd25328bfbc4931035085bd20089adf0c418f70e/ty-0.0.73-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2942fa0ef795a66034cdc8d75a72f453442f3b58ff2f69b4da05b7b954765b55", size = 12488557, upload-time = "2026-08-19T03:12:15.252Z" }, - { url = "https://files.pythonhosted.org/packages/d8/e8/329b9851b23502758c5c98e8cc875ea2a1b4c9674b4ca3a86da56a5063d3/ty-0.0.73-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e0f1ef14f642e18ac4e7a616a2796dcf7a5d82e28cd17f9796494acc7c4aabb", size = 13215606, upload-time = "2026-08-19T03:12:17.225Z" }, - { url = "https://files.pythonhosted.org/packages/36/38/67fedfd2cb77516ef0066b1642f487dba0eb3006493cf3475b15f5b8b228/ty-0.0.73-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16981e15fdceedb37d0aff76c5ac25914595dfee2675af95335550064251ad22", size = 13665497, upload-time = "2026-08-19T03:12:19.286Z" }, - { url = "https://files.pythonhosted.org/packages/8e/b3/154f4dd48ec5eebc186ab4b822c6e62f982fc5ddfd262d6e3903c2acba44/ty-0.0.73-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644b2bec8a2e2e4957a942ae81d6cff5571c489bb5a8675e4d3886de537a694d", size = 13351231, upload-time = "2026-08-19T03:12:21.353Z" }, - { url = "https://files.pythonhosted.org/packages/35/5f/d462496903fbe453fb76363f8478be929c8e6ff21e6928c57dcd7e5fa21f/ty-0.0.73-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:338d565be3186f50ff8e9d10483685549c2d23f0754485d5ede3b54f4319188a", size = 12782586, upload-time = "2026-08-19T03:12:23.667Z" }, - { url = "https://files.pythonhosted.org/packages/87/52/ec6d24b74abe3ec324204c1c71e6d0c6c76a17ffc15fd51d603b0a302abe/ty-0.0.73-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:11c7b6d839309d2c102cb3a4c03d817176bbfab5b2fccc95a75ec5c9597421c9", size = 13247134, upload-time = "2026-08-19T03:12:25.956Z" }, - { url = "https://files.pythonhosted.org/packages/26/20/cc74650fec56a54786c6d7c89e09576fcad3092be34cf21715d39a406a9b/ty-0.0.73-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:488572db7ff97fb50ea36a76250f2d617c9727d143da6c7bf0623276eb0fc507", size = 12309344, upload-time = "2026-08-19T03:12:28.122Z" }, - { url = "https://files.pythonhosted.org/packages/89/bd/4b0a9087f4315d7fbadf77a3ce44c816cc9ffabed1ced06cc5be81fbc414/ty-0.0.73-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1b958ebceefbbf594e59eb8d3d55bbd033ce634026fcba3e4bc3179e78e45bb7", size = 12502319, upload-time = "2026-08-19T03:12:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/11/80/0a925074911fe111912ea29d9eed309bcc183f43d2fb3eef07db056a0beb/ty-0.0.73-py3-none-musllinux_1_2_i686.whl", hash = "sha256:91a32993b3c34e42c3f323ad6c0399cb596bd1c27e9b7f20db7cd64c1067b68e", size = 12753688, upload-time = "2026-08-19T03:12:32.433Z" }, - { url = "https://files.pythonhosted.org/packages/24/6b/aeccaf89efbc2e112bd415340a22e2669ec998aa397242503e747b712ca4/ty-0.0.73-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bab8a19fbf51f479bddb2a12c5fabfe52f918a5590362321ed5d89b44eb62c15", size = 13069050, upload-time = "2026-08-19T03:12:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/d7/3e/eae485fd86c1585943fd4e1746b0757b2da01e2c43136ebe8c686fe1c7f1/ty-0.0.73-py3-none-win32.whl", hash = "sha256:03347a612f0fa020b19bfd8dbd521db6ecc75d377a3e4d4f6e6c2e62871da4cc", size = 12053187, upload-time = "2026-08-19T03:12:37.565Z" }, - { url = "https://files.pythonhosted.org/packages/a7/01/9b8b983786e3ce34924e372e8b76b92b508273ab65c589fc7e88cc03ee17/ty-0.0.73-py3-none-win_amd64.whl", hash = "sha256:cedd05122ded0b5dcc55431a370e974b747f99c41c290a3d2ab8c1867f197519", size = 12693838, upload-time = "2026-08-19T03:12:39.483Z" }, - { url = "https://files.pythonhosted.org/packages/ea/88/25333bbfea6a5dc064371d2002d3d4807db90b84d5448f9106b2712b0fbc/ty-0.0.73-py3-none-win_arm64.whl", hash = "sha256:e47068f8369dea5d641a26a2ad0a947a320b02ff87099b07e95de0323245a4dc", size = 12443573, upload-time = "2026-08-19T03:12:41.449Z" }, + { url = "https://files.pythonhosted.org/packages/29/c8/3c93195eca282936ebb574d0ba98843e4b147ee324006f88464777012a92/ty-0.0.80-py3-none-linux_armv6l.whl", hash = "sha256:738d1cfca466c577aea24547c348629c00c63548cf7da2c46b497b3840f85dee", size = 13606509, upload-time = "2026-09-09T21:18:10.476Z" }, + { url = "https://files.pythonhosted.org/packages/e7/48/bbb47f7001c97262a5109bcd92a45bcc8a2fbb21e994c214a9897630bfb7/ty-0.0.80-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:56060164bb8ee43770fa367524fbdba2611fc90f8923a02cc91f80eb37d5a1b0", size = 13203812, upload-time = "2026-09-09T21:18:12.796Z" }, + { url = "https://files.pythonhosted.org/packages/db/8d/fd141160567047b742e466be8ed09a4c4ed80fa045d37f946eb4f4532fee/ty-0.0.80-py3-none-macosx_11_0_arm64.whl", hash = "sha256:da4062e0fbf3923d9b71688157c5753394f243348f00732e9edbb27498397169", size = 13021896, upload-time = "2026-09-09T21:18:15.186Z" }, + { url = "https://files.pythonhosted.org/packages/1e/10/b4177faf9e71bc37bc4d08f512042269660a1ce0ec457c48f96e51fc91d7/ty-0.0.80-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9eb94b2659f506a3a07a9ea1415d5c18aaa1e554adc1612614d617a0ed320e1", size = 13083881, upload-time = "2026-09-09T21:18:17.616Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c2/192d48b9a9acbbe030fc9a4bd73af75671a5566e731949f77e912a68ce68/ty-0.0.80-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2f5e39a87da48c1af1a3b3135f02be7a83a93da30b0d6a5f5d27806e7c747ce", size = 13354874, upload-time = "2026-09-09T21:18:19.744Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/736dfd31efd98bed9dbe018ff91676e5ea83485b1e24afb635fd0247728f/ty-0.0.80-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e00149e7779c6b98f3ba12e10a631a861bd7ca1a42bbf064e2677ada7d2c0c2", size = 14204805, upload-time = "2026-09-09T21:18:21.999Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6f/557c726cb53401998e3589bb632c963a6887a70240e0a4386024f3731c81/ty-0.0.80-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a377268f359fb6e7a2cc9026a09223c764f58d020dec8e6baeaba9caba6ff829", size = 14630014, upload-time = "2026-09-09T21:18:24Z" }, + { url = "https://files.pythonhosted.org/packages/e9/19/7a4b18fe27f6b6bd4ffa56b67c8b09ecb76bb4312d1c64b73f65417b4dbd/ty-0.0.80-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4511dbf1b266b9ce5b73e9b28d94468096c2b6da00e2fe205168c32d3b352ca7", size = 14326946, upload-time = "2026-09-09T21:18:26.679Z" }, + { url = "https://files.pythonhosted.org/packages/f8/95/16dd90805fc7e53ec54ae9fdb1a8b74753fb159a55a170f60f49b9417824/ty-0.0.80-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe95feffa7156800c6f804195acb9fb5846a39671c7192c30fff23351aaf7c31", size = 13705988, upload-time = "2026-09-09T21:18:28.681Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/4e87992c23ab8a742c7d4914ac5fe66fb9301c8464a69fd2ecbc0be3fbcd/ty-0.0.80-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ba26b39f06bc8c3c2c5acd3a147239482b36620cdbca1b9d02e915294854f2cc", size = 14233729, upload-time = "2026-09-09T21:18:30.961Z" }, + { url = "https://files.pythonhosted.org/packages/36/e9/b8c11fda8e66a1d1cc1a7160ed719a33f4dc0107b1cef6a14e2673965763/ty-0.0.80-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2e6167320888c115a6fbe69893b63fd1c848f9121a454e75e5f612674528e3f3", size = 13173523, upload-time = "2026-09-09T21:18:33.266Z" }, + { url = "https://files.pythonhosted.org/packages/73/a7/512083fa540c5be1ac8642658f03bfb5bfa6fd168ea4f55e3c0aabe04166/ty-0.0.80-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7aecb62b4de70b479eab07d1779ea66da26dd8b068f50a9330867157312f103c", size = 13373014, upload-time = "2026-09-09T21:18:35.339Z" }, + { url = "https://files.pythonhosted.org/packages/5e/63/cd2f0ca81fd9b8cafef93bbcf022bf636bc4de8ee7465c6aafeec1d4d52b/ty-0.0.80-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4fed06adacd16b7e2d722f37449021b1419598d0440769701c0f4827faddde63", size = 13667827, upload-time = "2026-09-09T21:18:37.241Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5d/ebdfdcb2dc099ae74dc4b75511eef0dd398b2fa27006c543543974b4567f/ty-0.0.80-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:09329af6303ce611ec2cfffc995dc1ddf76c9f47194fc1a4d64a984759c25f5b", size = 13963866, upload-time = "2026-09-09T21:18:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/c286d5e1b560cfd16d6e91fdab683718aacf09fc88938ca06dcc9d1c8502/ty-0.0.80-py3-none-win32.whl", hash = "sha256:a81b3b512f7b4c68e42fbd1835633de658809666e9aafd5defa52bbc5197698d", size = 12881806, upload-time = "2026-09-09T21:18:41.507Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c8/3b3a8ac16d47a23ff34bf57c0d99a491860748aea96e0e886b4bfed54f9b/ty-0.0.80-py3-none-win_amd64.whl", hash = "sha256:8043f99878a2ae434781cb881c8a3840dff70c4a5b60bdc5ae84251f35ee063f", size = 13528883, upload-time = "2026-09-09T21:18:43.687Z" }, + { url = "https://files.pythonhosted.org/packages/2e/71/a6c697930fca76596d7d17f8853120f41a298fd9ea632c901cfca8ca869b/ty-0.0.80-py3-none-win_arm64.whl", hash = "sha256:e277ef034331da5319efc839c064968d24f35715b71c70369cf97d36fe4dcbdb", size = 13370135, upload-time = "2026-09-09T21:18:45.758Z" }, ] [[package]] @@ -1378,16 +1356,16 @@ wheels = [ [[package]] name = "websocket-client" -version = "1.9.0" +version = "1.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/cb/a5abcc2891249f393827c650c6296660ce40374ac22d99ab9aea41f9d2a2/websocket_client-1.9.2.tar.gz", hash = "sha256:0fcb57545848be86992e128218fd96dd87a6769ffdb1a968dff79632b85604d0", size = 84110, upload-time = "2026-08-31T14:08:40.964Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" }, ] [[package]] name = "zensical" -version = "0.0.56" +version = "0.0.62" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1399,20 +1377,17 @@ dependencies = [ { name = "pyyaml" }, { name = "tomli" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/7d/18bb725a659352e9af0940a3d879c5edcff5d86fec3ac15ce500d484d9d3/zensical-0.0.56.tar.gz", hash = "sha256:c359163800d1c3a8c39af48f4e2869fcfc2b4fc00d28652bd2a5b0330c36530c", size = 3997416, upload-time = "2026-08-18T15:46:47.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/c1/d97c5b09e193dadeea02c93c0d627b7659617a0c274bcd29803f116761fc/zensical-0.0.62.tar.gz", hash = "sha256:2449135eac161eb053bf1138df994b18794e7ab420f73941a65ba66e43b37613", size = 4128550, upload-time = "2026-09-13T17:16:35.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/4b/6810aec6e670451f39639039b570a43d90bc1d4a3b93cf13316ccf6bad11/zensical-0.0.56-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:5135ea3aa5d1358503fc1903e866c191873b138028bc1ab170abac3a4b537ffe", size = 12874712, upload-time = "2026-08-18T15:46:18.378Z" }, - { url = "https://files.pythonhosted.org/packages/97/98/23445d8ed708088dd6d9d51674f8836b77c53aab280360d7aa7a206bea8e/zensical-0.0.56-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a22ae2329ba755c6e58e1fe5967ca429d580d346a73cf467ad5185377e2cf809", size = 12764552, upload-time = "2026-08-18T15:46:20.746Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/ab89450728b0a55e6a52a86060b38a362a52a1b9f02eda7fef68b729eb2e/zensical-0.0.56-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03c70ef328e31cce0e31739acdd6679e9272bc7a3016ca5eafaa16dcfda460c9", size = 13212920, upload-time = "2026-08-18T15:46:22.977Z" }, - { url = "https://files.pythonhosted.org/packages/bf/5a/969fd9a461204392a9266a544c185fbb223714b306534661dcbb7d290be7/zensical-0.0.56-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ffc50153a50292078357a5052e7a6b3e20a818523792ffcbeace70418b761eb", size = 13146652, upload-time = "2026-08-18T15:46:25.773Z" }, - { url = "https://files.pythonhosted.org/packages/22/b3/28a7c8dea8fe2edbba75a664efbde797b5922651ea92ffc480947ab22197/zensical-0.0.56-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88bba0e339d36647ce638a42b4ba96f2521b59ea54be74c314340be7e401f94b", size = 13530946, upload-time = "2026-08-18T15:46:28.19Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a6/d55a18c6e041b788af1d19e4d8c61ee9b8a7de5b9cc79ffa7bc9565e3a28/zensical-0.0.56-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b7a37f6ac38ac218e3e0dd311b58c468603963e38ee23f2e25672dcd6e9c17b", size = 13178741, upload-time = "2026-08-18T15:46:30.378Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4f/a07da2f761cfd6a27faf9e8d5a9475c396a9a0ca37424a0b67cab7ae4d2c/zensical-0.0.56-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f6d850bce3184422b37b9d98ef4d123047a47446844793251cabc04bd55f8f0", size = 13389836, upload-time = "2026-08-18T15:46:32.847Z" }, - { url = "https://files.pythonhosted.org/packages/34/aa/697ef9846b0e2071de4d03b0472e2658380ea9271bd01235f4ffaeef1978/zensical-0.0.56-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:d18944a4a111050b8f571e73d4e2475d7ce62ce78b64f09bcf33bb11cc346e1c", size = 13419551, upload-time = "2026-08-18T15:46:35.112Z" }, - { url = "https://files.pythonhosted.org/packages/97/6b/20e1b2443951d5182b3fc2ee54c200ea00c09bd8901a479be4147c94361b/zensical-0.0.56-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:3937029ec5091d577c2a05ebccd38fde97fab28662d165ead66d566689b2a6df", size = 13579878, upload-time = "2026-08-18T15:46:37.381Z" }, - { url = "https://files.pythonhosted.org/packages/53/c7/9c3b400b8a7d78cc169b7a78d4f74a90f9114209034a604f04051f48037c/zensical-0.0.56-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:346a1cdbad157633d185f79039a97c8b4f8c2b40be7d7efd56d72622f40247b0", size = 13526142, upload-time = "2026-08-18T15:46:39.949Z" }, - { url = "https://files.pythonhosted.org/packages/0e/f6/7a6f2a513054071e44a504d7157684f00ac5e5e5f741eadc78ab6c80642c/zensical-0.0.56-cp310-abi3-win32.whl", hash = "sha256:a06681046f74b5bdc506d22af8fcef505b2450e45538c7f01a5e028f4e50c6f7", size = 12433218, upload-time = "2026-08-18T15:46:42.329Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ed/5b497c75fb1fd5845f3ec2f0b11b5e01f18f14c297041cd2f20d30d8b6a4/zensical-0.0.56-cp310-abi3-win_amd64.whl", hash = "sha256:c557985f12d042c15dcb7d577543d81ceee9281f96d591d265310b673437a325", size = 12702823, upload-time = "2026-08-18T15:46:44.836Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a6/7e3fda49749096cf405e09c4384e348c0f6bfeab44f18d02c1051673dc2e/zensical-0.0.62-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a1efd661fa3241c10c94c8c2b280ac3924bc988b24d0dbf1b8499994b23e12fb", size = 14220149, upload-time = "2026-09-13T17:16:08.144Z" }, + { url = "https://files.pythonhosted.org/packages/fa/13/c8d22c4c946bf4e6ae29db81f1456fbbc35fcd165ad874e4b919422f7b08/zensical-0.0.62-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c468e81adad37517c03de2dda13568c33d9c36b234c34e2b3a04285846d99bd5", size = 13963505, upload-time = "2026-09-13T17:16:11.529Z" }, + { url = "https://files.pythonhosted.org/packages/19/08/cce44a83dbc1070197e2006178bbf42d59e398ccb39806d3db02c9781790/zensical-0.0.62-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dcc744b1b3f56b85cf23c393a2333dec72e555b263a2fbe3d50ac896cf85468", size = 14238419, upload-time = "2026-09-13T17:16:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/af/65/586ab984b9197ab6a7ee53dffa77ef00fdb19f8c09d520c39e3c3d7dccb8/zensical-0.0.62-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b04147ac1f109ed3b1fbc74d958323243f35481926765b939638e8933a8bdc6", size = 14262571, upload-time = "2026-09-13T17:16:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/57/ae/3a4aac8181caa1079c93ac996bbc418b749ae76ecd2cf2a67943b90ae277/zensical-0.0.62-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6755d7a2d3de424e07a77f68be080b4e54a34fb564c39434322e8427b9402ace", size = 14518577, upload-time = "2026-09-13T17:16:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/5c/03/8de2cc9aecc13b93d4b63c06dec4af95482ab9ce1b586ad8d35d5f56a916/zensical-0.0.62-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2b809aa8aa175f64a362d6a8ab89586ce7dd086b5bf23778f3c3cff68deeae6", size = 14413777, upload-time = "2026-09-13T17:16:23.641Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/c7ac1e27d2832f8ee524397f47193b73a1d7d78b78e407febd5f97d72e89/zensical-0.0.62-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:111cb32c405ff3a0e162860f81cf8e041447d795657720c36ba3e1b8ed607928", size = 14747349, upload-time = "2026-09-13T17:16:26.498Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/e933a718b52161e97e7f477e189374e375be940f6ed4ea6711e9f80f8531/zensical-0.0.62-cp310-abi3-win_amd64.whl", hash = "sha256:7c216f1e33b369aa84826e6e52ecda06059c651793936ec3d64c8b4255a91dcc", size = 14531794, upload-time = "2026-09-13T17:16:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/b4/af/0ad32fb45b1eb9b8bb10ec7ef08b1982ab8f7a5c2837cbf1d3bd2df721ad/zensical-0.0.62-cp310-abi3-win_arm64.whl", hash = "sha256:8d4e48fc44faf0b4b77e880e3ad3ec2573644e2ddd59e18c71c829412733495d", size = 14240254, upload-time = "2026-09-13T17:16:32.675Z" }, ] [[package]]