diff --git a/.gitattributes b/.gitattributes index 1cf541aa3a..5cb404146d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,4 +11,4 @@ *.wav filter=lfs diff=lfs merge=lfs -text openpilot/selfdrive/car/tests/test_models_segs.txt filter=lfs diff=lfs merge=lfs -text -openpilot/common/hardware/tici/updater filter=lfs diff=lfs merge=lfs -text +openpilot/common/hardware/comma/updater filter=lfs diff=lfs merge=lfs -text diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index b986273097..0000000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - diff --git a/.github/workflows/diff_report.yaml b/.github/workflows/diff_report.yaml index 0f706ea2ed..202bc0ec79 100644 --- a/.github/workflows/diff_report.yaml +++ b/.github/workflows/diff_report.yaml @@ -40,6 +40,7 @@ jobs: echo "run-id=$run_id" >> "$GITHUB_OUTPUT" - name: Download diff if: steps.wait.outcome == 'success' + continue-on-error: true uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -48,7 +49,7 @@ jobs: name: diff_report_${{ github.event.number }} path: . - name: Comment on PR - if: steps.wait.outcome == 'success' + if: steps.wait.outcome == 'success' && hashFiles('diff_report.txt') != '' uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b with: file-path: diff_report.txt diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 0af94ba0da..5c1a8f4dde 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -15,6 +15,11 @@ concurrency: group: docs-tests-ci-run-${{ inputs.run_number }}-${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && github.run_id || github.head_ref || github.ref }}-${{ github.workflow }}-${{ github.event_name }} cancel-in-progress: true +env: + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: lfs.fetchexclude + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx + jobs: docs: name: build docs @@ -30,8 +35,7 @@ jobs: - name: Build docs run: | git lfs pull - pip install zensical - python scripts/docs.py build + python docs/serve.py --build # Push to docs.comma.ai - uses: actions/checkout@v7 @@ -52,7 +56,7 @@ jobs: git rm -rf . # copy over docs - cp -r ../docs_site/ docs/ + cp -r ../docs/_site/ docs/ # GitHub pages config touch docs/.nojekyll diff --git a/.github/workflows/model_review.yaml b/.github/workflows/model_review.yaml deleted file mode 100644 index 82c732dc3b..0000000000 --- a/.github/workflows/model_review.yaml +++ /dev/null @@ -1,42 +0,0 @@ -name: "model review" - -on: - pull_request: - types: [opened, reopened, synchronize] - paths: - - 'openpilot/selfdrive/modeld/models/*.onnx' - workflow_dispatch: - -jobs: - comment: - permissions: - contents: read - pull-requests: write - runs-on: ubuntu-latest - if: github.repository == 'commaai/openpilot' - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - submodules: true - - name: Checkout master - uses: actions/checkout@v7 - with: - ref: master - path: base - - run: git lfs pull - - run: cd base && git lfs pull - - - name: scripts/reporter.py - id: report - run: | - echo "content<> $GITHUB_OUTPUT - echo "## Model Review" >> $GITHUB_OUTPUT - PYTHONPATH=${{ github.workspace }} MASTER_PATH=${{ github.workspace }}/base python scripts/reporter.py >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - name: Post model report comment - uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 - with: - header: model-review - message: ${{ steps.report.outputs.content }} \ No newline at end of file diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 56aa126684..2240713bcb 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -4,6 +4,11 @@ on: - cron: '0 9 * * *' workflow_dispatch: +env: + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: lfs.fetchexclude + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx + jobs: build___nightly: name: build __nightly diff --git a/.github/workflows/repo-maintenance.yaml b/.github/workflows/repo-maintenance.yaml index 87556b4011..731300512b 100644 --- a/.github/workflows/repo-maintenance.yaml +++ b/.github/workflows/repo-maintenance.yaml @@ -9,6 +9,9 @@ on: env: PYTHONPATH: ${{ github.workspace }} + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: lfs.fetchexclude + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx jobs: package_updates: @@ -81,7 +84,7 @@ jobs: labels: bot cleanup_closed_branches: - if: github.repository == 'commaai/openpilot' + if: github.repository == 'commaai/openpilot' && github.event_name == 'pull_request' runs-on: ubuntu-latest permissions: contents: write diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 621aa123c5..b2e4096960 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -20,7 +20,9 @@ concurrency: env: CI: 1 PYTHONPATH: ${{ github.workspace }} - PYTEST: pytest --continue-on-collection-errors --durations=0 -n logical + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: lfs.fetchexclude + GIT_CONFIG_VALUE_0: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx jobs: build_release: @@ -125,10 +127,7 @@ jobs: timeout-minutes: ${{ contains(runner.name, 'nsc') && 2 || 999 }} env: RAYLIB_BACKEND: headless - run: | - # Pre-compile Python bytecode so each pytest worker doesn't need to - $PYTEST --collect-only -m 'not slow' -qq - MAX_EXAMPLES=1 $PYTEST -m 'not slow' + run: tools/op.sh test process_replay: name: process replay @@ -193,12 +192,6 @@ jobs: timeout_minutes: 2 max_attempts: 3 command: cd ${{ github.workspace }}/ci-artifacts && git push origin process-replay --force - - name: Run regen - if: false - timeout-minutes: 4 - env: - ONNXCPU: 1 - run: $PYTEST openpilot/selfdrive/test/process_replay/test_regen.py simulator_driving: name: simulator driving @@ -219,7 +212,7 @@ jobs: env: # MetaDrive renders offscreen through panda3d's EGL pipe on llvmpipe EGL_PLATFORM: surfaceless - run: pytest -s openpilot/tools/sim/tests/test_metadrive_bridge.py + run: python openpilot/tools/sim/tests/test_metadrive_bridge.py create_ui_report: name: Create UI Report diff --git a/.gitignore b/.gitignore index a033f70507..9dee0af9af 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ a.out .cache/ bin/ -# created at launch for TICI PYTHONPATH (PC uses editable installs via pyproject.toml) +# created at launch for comma hardware PYTHONPATH (PC uses editable installs via pyproject.toml) /msgq /opendbc /rednose @@ -39,6 +39,8 @@ bin/ *.os-* *.so *.a +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] +*.unchunked *.clb *.class *.pyxbldc @@ -54,7 +56,7 @@ compare_runtime*.html openpilot/selfdrive/modeld/models/tg_input_devices.json # build artifacts -docs_site/ +docs/_site/ openpilot/selfdrive/pandad/pandad openpilot/cereal/services.h openpilot/cereal/gen diff --git a/Jenkinsfile b/Jenkinsfile index d57e9502aa..db18d13ddd 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -30,14 +30,14 @@ export GIT_COMMIT=${env.GIT_COMMIT} export CI_ARTIFACTS_TOKEN=${env.CI_ARTIFACTS_TOKEN} export GITHUB_COMMENTS_TOKEN=${env.GITHUB_COMMENTS_TOKEN} export AZURE_TOKEN='${env.AZURE_TOKEN}' -# only use 1 thread for tici tests since most require HIL +# only use 1 thread since most require real hardware that can't be shared export PYTEST_ADDOPTS="-n0 -s" export GIT_SSH_COMMAND="ssh -i /data/gitkey" source ~/.bash_profile -if [ -f /TICI ]; then +if [ -f /AGNOS ]; then source /etc/profile rm -rf /tmp/tmp* @@ -206,35 +206,35 @@ node { deviceStage("onroad", "tizi-needs-can", ["UNSAFE=1"], [ step("build openpilot", "cd openpilot/system/manager && ./build.py"), step("check dirty", "tools/release/check-dirty.sh"), - step("onroad tests", "pytest openpilot/selfdrive/test/test_onroad.py -s", [timeout: 60]), + step("onroad tests", "./openpilot/selfdrive/test/test_onroad.py", [timeout: 60]), ]) }, 'HW + Unit Tests': { deviceStage("tizi-hardware", "tizi-common", ["UNSAFE=1"], [ step("build", "cd openpilot/system/manager && ./build.py"), - step("test power draw", "pytest -s openpilot/selfdrive/test//test_power_draw.py"), - step("test encoder", "pytest openpilot/system/loggerd/tests/test_encoder.py", [diffPaths: ["openpilot/system/loggerd/"]]), - step("test manager", "pytest openpilot/system/manager/test/test_manager.py"), + step("test power draw", "./openpilot/selfdrive/test/test_power_draw.py"), + step("test encoder", "./openpilot/system/loggerd/tests/test_encoder.py", [diffPaths: ["openpilot/system/loggerd/"]]), + step("test manager", "./openpilot/system/manager/test/test_manager.py"), ]) }, 'camerad OX03C10': { deviceStage("OX03C10", "tizi-ox03c10", ["UNSAFE=1"], [ step("build", "cd openpilot/system/manager && ./build.py"), - step("test pandad", "pytest openpilot/selfdrive/pandad/tests/test_pandad.py"), - step("test camerad", "pytest openpilot/system/camerad/test/test_camerad.py", [timeout: 90]), + step("test pandad", "./openpilot/selfdrive/pandad/tests/test_pandad.py"), + step("test camerad", "./openpilot/system/camerad/test/test_camerad.py", [timeout: 90]), ]) }, 'camerad OS04C10': { deviceStage("OS04C10", "tici-os04c10", ["UNSAFE=1"], [ step("build", "cd openpilot/system/manager && ./build.py"), - step("test pandad", "pytest openpilot/selfdrive/pandad/tests/test_pandad.py"), - step("test camerad", "pytest openpilot/system/camerad/test/test_camerad.py", [timeout: 90]), + step("test pandad", "./openpilot/selfdrive/pandad/tests/test_pandad.py"), + step("test camerad", "./openpilot/system/camerad/test/test_camerad.py", [timeout: 90]), ]) }, 'sensord': { deviceStage("LSM + MMC", "tizi-lsmc", ["UNSAFE=1"], [ step("build", "cd openpilot/system/manager && ./build.py"), - step("test sensord", "pytest openpilot/system/sensord/tests/test_sensord.py"), + step("test sensord", "./openpilot/system/sensord/tests/test_sensord.py"), ]) }, 'replay': { @@ -246,9 +246,9 @@ node { 'tizi': { deviceStage("tizi", "tizi", ["UNSAFE=1"], [ step("build openpilot", "cd openpilot/system/manager && ./build.py"), - step("test pandad loopback", "pytest openpilot/selfdrive/pandad/tests/test_pandad_loopback.py"), - step("test pandad spi", "pytest openpilot/selfdrive/pandad/tests/test_pandad_spi.py"), - step("test amp", "pytest openpilot/common/hardware/tici/tests/test_amplifier.py"), + step("test pandad loopback", "./openpilot/selfdrive/pandad/tests/test_pandad_loopback.py"), + step("test pandad spi", "./openpilot/selfdrive/pandad/tests/test_pandad_spi.py"), + step("test amp", "./openpilot/common/hardware/comma/tests/test_amplifier.py"), ]) }, diff --git a/RELEASES.md b/RELEASES.md index 770a7c40fe..36ddadb431 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,6 +1,14 @@ -Version 0.11.2 (2026-06-15) -======================== - +Version 0.11.2 (2026-08-12) +======================= +* New driving model + * Big model with 880M parameters +* Support for big models running on an external GPU +* Live stream cameras from comma connect +* Generate dashcam clips from comma connect +* Remote comma body control from comma connect +* New alert sounds +* CUPRA Born 2021-2023 support thanks to DaHansi! +* Volkswagen ID.4 2021-2025 support thanks to DaHansi! Version 0.11.1 (2026-05-18) ======================== diff --git a/SConstruct b/SConstruct index 8cf191daab..4e9dedd947 100644 --- a/SConstruct +++ b/SConstruct @@ -10,7 +10,7 @@ import numpy as np import SCons.Errors from SCons.Defaults import _stripixes -TICI = os.path.isfile('/TICI') +COMMA_HARDWARE = os.path.isfile('/AGNOS') SCons.Warnings.warningAsException(True) @@ -24,7 +24,7 @@ release = not os.path.exists(File('#.gitattributes').abspath) # file absent on r AddOption('--minimal', action='store_false', dest='extras', - default=(not TICI and not release), + default=(not COMMA_HARDWARE and not release), help='the minimum build to run openpilot. no tests, tools, etc.') submodule_python_paths = [ @@ -46,22 +46,22 @@ if external_pythonpath := os.environ.get("PYTHONPATH"): arch = subprocess.check_output(["uname", "-m"], encoding='utf8').rstrip() if platform.system() == "Darwin": arch = "Darwin" -elif arch == "aarch64" and TICI: - arch = "larch64" +elif arch == "aarch64" and COMMA_HARDWARE: + arch = "comma_arm64" assert arch in [ - "larch64", # linux tici arm64 - "aarch64", # linux pc arm64 - "x86_64", # linux pc x64 - "Darwin", # macOS arm64 (x86 not supported) + "comma_arm64", # linux comma hardware (AGNOS) arm64 + "aarch64", # linux pc arm64 + "x86_64", # linux pc x64 + "Darwin", # macOS arm64 (x86 not supported) ] -pkg_names = ['acados', 'bzip2', 'capnproto', 'catch2', 'eigen', 'ffmpeg', 'json11', 'ncurses', 'zeromq', 'zstd'] +pkg_names = ['acados', 'capnproto', 'eigen', 'ffmpeg', 'json11', 'ncurses', 'zeromq', 'zstd'] pkgs = [importlib.import_module(name) for name in pkg_names] acados = pkgs[pkg_names.index('acados')] ffmpeg = pkgs[pkg_names.index('ffmpeg')] # Shared package ships .so/.dylib; older device venvs still have static .a only. # Keep static link deps (x264/z/va/drm) when the installed package is static so -# TICI CI works without upgrading the device venv yet. +# COMMA_HARDWARE CI works without upgrading the device venv yet. # TODO: drop the static fallback once device venvs have comma-deps-ffmpeg>=7.1.0.post94 _ffmpeg_lib_names = os.listdir(ffmpeg.LIB_DIR) if os.path.isdir(ffmpeg.LIB_DIR) else [] ffmpeg_shared = any( @@ -129,10 +129,11 @@ env = Environment( CCFLAGS=[ "-g", "-fPIC", + "-pipe", "-O2", "-Wunused", "-Werror", - "-Wshadow" if arch in ("Darwin", "larch64") else "-Wshadow=local", + "-Wshadow" if arch in ("Darwin", "comma_arm64") else "-Wshadow=local", "-Wno-unknown-warning-option", "-Wno-inconsistent-missing-override", "-Wno-c99-designator", @@ -164,24 +165,24 @@ env = Environment( COMPILATIONDB_USE_ABSPATH=True, REDNOSE_ROOT="#rednose_repo", tools=["default", "cython", "compilation_db", "rednose_filter"], - toolpath=["#site_scons/site_tools", "#rednose_repo/site_scons/site_tools"], + toolpath=["#msgq_repo/site_scons/site_tools", "#rednose_repo/site_scons/site_tools"], ) # SCons' Darwin linker tool doesn't define the variables used to expand RPATH. if arch == "Darwin": env["RPATHPREFIX"] = "-Wl,-rpath," env["RPATHSUFFIX"] = "" env["_RPATH"] = "${_concat(RPATHPREFIX, RPATH, RPATHSUFFIX, __env__)}" -if arch != "larch64": +if arch != "comma_arm64": env['_LIBFLAGS'] = _libflags # Arch-specific flags and paths -if arch == "larch64": +if arch == "comma_arm64": env["CC"] = "clang" env["CXX"] = "clang++" env.Append(LIBPATH=[ "/usr/lib/aarch64-linux-gnu", ]) - arch_flags = ["-D__TICI__", "-mcpu=cortex-a57", "-DQCOM2"] + arch_flags = ["-D__COMMA_HARDWARE__", "-mcpu=cortex-a57"] env.Append(CCFLAGS=arch_flags) env.Append(CXXFLAGS=arch_flags) elif arch == "Darwin": @@ -233,7 +234,7 @@ Export('envCython', 'np_version') Export('env', 'arch', 'acados', 'release', 'ffmpeg_libs') # Setup cache dir -default_cache_dir = '/data/scons_cache' if arch == "larch64" else '/tmp/scons_cache' +default_cache_dir = '/data/scons_cache' if arch == "comma_arm64" else '/tmp/scons_cache' cache_dir = ARGUMENTS.get('cache_dir', default_cache_dir) cache_size_limit = 4e9 if "CI" in os.environ else 2e9 CacheDir(cache_dir) @@ -280,7 +281,7 @@ SConscript([ 'openpilot/system/loggerd/SConscript', ]) -if arch == "larch64": +if arch == "comma_arm64": SConscript(['openpilot/system/camerad/SConscript']) # Build selfdrive @@ -295,7 +296,7 @@ SConscript([ SConscript(['openpilot/sunnypilot/SConscript']) # Build desktop-only tools -if GetOption('extras') and arch != "larch64": +if GetOption('extras') and arch != "comma_arm64": SConscript([ 'openpilot/tools/replay/SConscript', 'openpilot/tools/cabana/SConscript', diff --git a/conftest.py b/conftest.py deleted file mode 100644 index d7bc7a6bf1..0000000000 --- a/conftest.py +++ /dev/null @@ -1,99 +0,0 @@ -import contextlib -import gc -import os -import pytest - -from openpilot.common.prefix import OpenpilotPrefix -from openpilot.system.manager import manager -from openpilot.common.hardware import TICI, HARDWARE - -# these are heavy CI-only tests, invoked explicitly in .github/workflows/tests.yaml -collect_ignore = [ - "openpilot/selfdrive/test/process_replay/test_processes.py", - "openpilot/selfdrive/test/process_replay/test_regen.py", - - "openpilot/tools/sim/", - - # tinygrad JIT has process-global state. Other test files import modeld → tinygrad, - # which corrupts JIT captures for test_warp.py in the same process. Run separately in CI. - "openpilot/sunnypilot/modeld_v2/tests/test_warp.py", -] - - -def pytest_sessionstart(session): - # TODO: fix tests and enable test order randomization - if session.config.pluginmanager.hasplugin('randomly'): - session.config.option.randomly_reorganize = False - - -@pytest.hookimpl(hookwrapper=True, trylast=True) -def pytest_runtest_call(item): - # ensure we run as a hook after capturemanager's - if item.get_closest_marker("nocapture") is not None: - capmanager = item.config.pluginmanager.getplugin("capturemanager") - with capmanager.global_and_fixture_disabled(): - yield - else: - yield - - -@contextlib.contextmanager -def clean_env(): - starting_env = dict(os.environ) - yield - os.environ.clear() - os.environ.update(starting_env) - - -@pytest.fixture(scope="function", autouse=True) -def openpilot_function_fixture(request): - with clean_env(): - # setup a clean environment for each test - with OpenpilotPrefix(shared_download_cache=request.node.get_closest_marker("shared_download_cache") is not None) as prefix: - prefix = os.environ["OPENPILOT_PREFIX"] - - yield - - # ensure the test doesn't change the prefix - assert "OPENPILOT_PREFIX" in os.environ and prefix == os.environ["OPENPILOT_PREFIX"] - - # cleanup any started processes - manager.manager_cleanup() - - # some processes disable gc for performance, re-enable here - if not gc.isenabled(): - gc.enable() - gc.collect() - -# If you use setUpClass, the environment variables won't be cleared properly, -# so we need to hook both the function and class pytest fixtures -@pytest.fixture(scope="class", autouse=True) -def openpilot_class_fixture(): - with clean_env(): - yield - - -@pytest.fixture(scope="function") -def tici_setup_fixture(request, openpilot_function_fixture): - """Ensure a consistent state for tests on-device. Needs the openpilot function fixture to run first.""" - if 'skip_tici_setup' in request.keywords: - return - HARDWARE.initialize_hardware() - HARDWARE.set_power_save(False) - os.system("pkill -9 -f athena") - - -@pytest.hookimpl(tryfirst=True) -def pytest_collection_modifyitems(config, items): - skipper = pytest.mark.skip(reason="Skipping tici test on PC") - for item in items: - if "tici" in item.keywords: - if not TICI: - item.add_marker(skipper) - else: - item.fixturenames.append('tici_setup_fixture') - - if "xdist_group_class_property" in item.keywords: - class_property_name = item.get_closest_marker('xdist_group_class_property').args[0] - class_property_value = getattr(item.cls, class_property_name) - item.add_marker(pytest.mark.xdist_group(class_property_value)) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 0c011ee22b..dd8d569821 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -61,7 +61,7 @@ A good pull request has all of the following: * Report bugs in GitHub issues. * Report driving issues in the `#driving-feedback` Discord channel. -* Consider opting into driver camera uploads to improve the driver monitoring model. +* Consider opting into cabin camera uploads to improve the driver monitoring model. * Connect your device to Wi-Fi regularly, so that we can pull data for training better driving models. * Run the `nightly` branch and report issues. This branch is like `master` but it's built just like a release. * Annotate images in the [comma10k dataset](https://github.com/commaai/comma10k). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md deleted file mode 100644 index e803a3fb8a..0000000000 --- a/docs/DEVELOPMENT.md +++ /dev/null @@ -1,24 +0,0 @@ -# Docs development - -The `docs/` tree is the source for [docs.comma.ai](https://docs.comma.ai). -The site is updated on pushes to master by this [workflow](../.github/workflows/docs.yaml). - -Those commands must be run in the root directory of openpilot, **not /docs** - -**1. Install the docs dependencies** -``` bash -uv pip install .[docs] -``` - -**2. Build the new site** -``` bash -docs build -``` - -**3. Run the new site locally** -``` bash -docs serve -``` - -References: -* https://zensical.org/docs/ diff --git a/docs/LIMITATIONS.md b/docs/LIMITATIONS.md index 8c112659c2..5f9b7517bd 100644 --- a/docs/LIMITATIONS.md +++ b/docs/LIMITATIONS.md @@ -52,7 +52,7 @@ Many factors can impact the performance of openpilot DM, causing it to be unable * Low light conditions, such as driving at night or in dark tunnels. * Bright light (due to oncoming headlights, direct sunlight, etc.). -* The driver's face is partially or completely outside field of view of the driver facing camera. -* The driver facing camera is obstructed, covered, or damaged. +* The driver's face is partially or completely outside field of view of the cabin camera. +* The cabin camera is obstructed, covered, or damaged. The list above does not represent an exhaustive list of situations that may interfere with proper operation of openpilot components. A driver should not rely on openpilot DM to assess their level of attention. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000000..d6a0126b39 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,14 @@ +# Docs development + +The `docs/` tree is the source for [docs.comma.ai](https://docs.comma.ai). +The site is updated on pushes to master by this [workflow](../.github/workflows/docs.yaml). + +**1. Build the site** +``` bash +python docs/serve.py --build +``` + +**2. Run the site locally** (rebuilds on change) +``` bash +python docs/serve.py +``` diff --git a/docs/assets/comma-logo.png b/docs/assets/comma-logo.png index 2838d92bfb..19b67d0739 120000 --- a/docs/assets/comma-logo.png +++ b/docs/assets/comma-logo.png @@ -1 +1 @@ -../../selfdrive/assets/icons_mici/settings/comma_icon.png \ No newline at end of file +../../openpilot/selfdrive/assets/icons_mici/settings/comma_icon.png \ No newline at end of file diff --git a/docs/assets/favicon.svg b/docs/assets/favicon.svg new file mode 100644 index 0000000000..304454837a --- /dev/null +++ b/docs/assets/favicon.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e019ed5af500e8820d05934d47e0380728e1b30e01a179f786dc4edb1eccbd7 +size 349 diff --git a/docs/concepts/glossary.md b/docs/concepts/glossary.md deleted file mode 100644 index 4f4dd54756..0000000000 --- a/docs/concepts/glossary.md +++ /dev/null @@ -1,3 +0,0 @@ -# openpilot glossary - -{{GLOSSARY_DEFINITIONS}} diff --git a/docs/concepts/logs.md b/docs/concepts/logs.md index dd954d4c76..a1e655935d 100644 --- a/docs/concepts/logs.md +++ b/docs/concepts/logs.md @@ -10,13 +10,13 @@ For each segment, openpilot records the following log types: rlogs contain all the messages passed amongst openpilot's processes. See [openpilot/cereal/services.py](https://github.com/commaai/openpilot/blob/master/openpilot/cereal/services.py) for a list of all the logged services. They're a zstd archive of the serialized [Cap’n Proto](https://capnproto.org/) messages. -## {f,e,d}camera.hevc +## camera video files Each camera stream is H.265 encoded and written to its respective file. -* `fcamera.hevc` is the road camera +* `fcamera.hevc` is the narrow road camera (the main forward camera) * `ecamera.hevc` is the wide road camera -* `dcamera.hevc` is the driver camera +* `dcamera.hevc` is the cabin camera ## qlog.zst & qcamera.ts diff --git a/docs/contributing/feedback.md b/docs/contributing/feedback.md index 335d24e13a..587816d140 100644 --- a/docs/contributing/feedback.md +++ b/docs/contributing/feedback.md @@ -21,14 +21,14 @@ In general, driver monitoring feedback is very actionable, and we can fix your c To post your feedback: 1. Join the [community Discord](https://discord.comma.ai). -2. If driver camera recording is toggled off, temporarily enable driver camera recording in the settings until you reproduce the issue. -3. Using comma connect, identify the relevant segment and upload the segment's logs and driver camera. +2. If cabin camera recording is toggled off, temporarily enable cabin camera recording in the settings until you reproduce the issue. +3. Using comma connect, identify the relevant segment and upload the segment's logs and cabin camera. 4. Post the segment in the `#openpilot-experience` channel on Discord with a good description. Before posting feedback, please ensure: - **openpilot is up to date** you should be on the latest openpilot release or nightly -- **the driver camera has a clear view of the driver** ensure nothing blocks view of the driver (e.g. a cable), the lens is clean, etc. +- **the cabin camera has a clear view of the driver** ensure nothing blocks view of the driver (e.g. a cable), the lens is clean, etc. - **your device is mounted properly** your device must be mounted horizontally center and relatively high on the windshield ## Other bugs diff --git a/docs/ext/glossary.py b/docs/ext/glossary.py deleted file mode 100644 index 9bbf3c78d7..0000000000 --- a/docs/ext/glossary.py +++ /dev/null @@ -1,216 +0,0 @@ -import posixpath -import re -import tomllib -import xml.etree.ElementTree as ET -from pathlib import Path - -from markdown.extensions import Extension -from markdown.preprocessors import Preprocessor -from markdown.treeprocessors import Treeprocessor - -from zensical.extensions.links import LinksTreeprocessor - -GlossaryTerm = tuple[str, re.Pattern[str], str] - -GLOSSARY_FILE = Path(__file__).with_name("glossary.toml") -GLOSSARY_PAGE = "concepts/glossary.md" -GLOSSARY_PLACEHOLDER = "{{GLOSSARY_DEFINITIONS}}" - -SKIP_TAGS = { - "a", - "code", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "kbd", - "pre", - "script", - "style", -} - -def clean_tooltip(description: str) -> str: - text = re.sub(r"\[([^\]]+)]\([^)]+\)", r"\1", description) - text = re.sub(r"`([^`]+)`", r"\1", text) - text = re.sub(r"[*_~]", "", text) - return re.sub(r"\s+", " ", text).strip() - - -def load_glossary() -> tuple[list[GlossaryTerm], str]: - with GLOSSARY_FILE.open("rb") as f: - glossary_data = tomllib.load(f).get("glossary", {}) - - glossary: list[GlossaryTerm] = [] - rendered = [] - for key, value in glossary_data.items(): - label = str(key).strip().replace("_", " ") - description = str(value).strip() - if not description: - continue - - slug = label.replace(" ", "-").replace("_", "-").lower() - glossary.append((slug, re.compile(rf"(?**{label}**: {description}') - - return glossary, "\n".join(rendered) - - -class GlossaryPreprocessor(Preprocessor): - def __init__(self, md, glossary: str): - super().__init__(md) - self.glossary = glossary - - def run(self, lines: list[str]) -> list[str]: - markdown = "\n".join(lines) - if GLOSSARY_PLACEHOLDER not in markdown: - return lines - return markdown.replace(GLOSSARY_PLACEHOLDER, self.glossary).splitlines() - - -class GlossaryTreeprocessor(Treeprocessor): - def __init__(self, md, glossary: list[GlossaryTerm]): - super().__init__(md) - self.glossary = glossary - self.seen: set[str] = set() - - def run(self, root: ET.Element) -> None: - at = self.md.treeprocessors.get_index_for_name("zrelpath") - processor = self.md.treeprocessors[at] - if not isinstance(processor, LinksTreeprocessor): - raise TypeError("Links processor not registered") - if processor.path == GLOSSARY_PAGE: - return - - self.seen.clear() - glossary_href = f"{posixpath.relpath(GLOSSARY_PAGE, posixpath.dirname(processor.path) or '.')}#" - self._walk(root, glossary_href) - - def _walk(self, element: ET.Element, glossary_href: str) -> None: - if element.tag in SKIP_TAGS or element.attrib.get("data-glossary-skip") is not None: - return - - self._replace(element, glossary_href) - - idx = 0 - while idx < len(element): - child = element[idx] - self._walk(child, glossary_href) - idx = self._replace(element, glossary_href, idx) + 1 - - def _replace(self, parent: ET.Element, glossary_href: str, index: int | None = None) -> int: - child = None if index is None else parent[index] - text = parent.text if child is None else child.tail - pieces = self._pieces(text or "", glossary_href) - if not pieces: - return -1 if index is None else index - - if child is None: - parent.text = pieces[0] if isinstance(pieces[0], str) else "" - # Insert replacements for parent.text before the first existing child. - insert_at = -1 - else: - assert index is not None - child.tail = pieces[0] if isinstance(pieces[0], str) else "" - insert_at = index - - start = 1 if isinstance(pieces[0], str) else 0 - previous = child - - for piece in pieces[start:]: - if isinstance(piece, str): - previous.tail = (previous.tail or "") + piece - continue - - insert_at += 1 - parent.insert(insert_at, piece) - previous = piece - - return insert_at - - def _pieces(self, text: str, glossary_href: str) -> list[str | ET.Element]: - if not text.strip(): - return [] - - pieces: list[str | ET.Element] = [] - cursor = 0 - - while True: - best = None - for slug, pattern, tooltip in self.glossary: - if slug in self.seen: - continue - - found = pattern.search(text, cursor) - if found is None: - continue - - candidate = (slug, tooltip, found.start(), found.end()) - if best is None: - best = candidate - continue - - _, _, best_start, best_end = best - _, _, current_start, current_end = candidate - if current_start < best_start: - best = candidate - continue - - if current_start == best_start and current_end - current_start > best_end - best_start: - best = candidate - - if best is None: - break - - slug, tooltip, start, end = best - if start > cursor: - pieces.append(text[cursor:start]) - - link = ET.Element( - "a", - { - "class": "glossary-term", - "data-glossary-term": "", - "href": f"{glossary_href}{slug}", - }, - ) - ET.SubElement(link, "span", {"class": "glossary-term__label"}).text = text[start:end] - ET.SubElement( - link, - "span", - { - "class": "glossary-term__tooltip", - "data-search-exclude": "", - }, - ).text = tooltip - pieces.append(link) - self.seen.add(slug) - cursor = end - - if not pieces: - return [] - if cursor < len(text): - pieces.append(text[cursor:]) - return pieces - - -class GlossaryExtension(Extension): - def extendMarkdown(self, md) -> None: - md.registerExtension(self) - glossary, rendered = load_glossary() - - md.preprocessors.register( - GlossaryPreprocessor(md, rendered), - "docs-ext-glossary-preprocessor", - 27, - ) - md.treeprocessors.register( - GlossaryTreeprocessor(md, glossary), - "docs-ext-glossary-treeprocessor", - 0, - ) - - -def makeExtension(**kwargs) -> GlossaryExtension: - return GlossaryExtension(**kwargs) diff --git a/docs/ext/glossary.toml b/docs/ext/glossary.toml deleted file mode 100644 index 62408d9ddd..0000000000 --- a/docs/ext/glossary.toml +++ /dev/null @@ -1,8 +0,0 @@ -[glossary] -onroad = "openpilot's system state while ignition is on." -offroad = "openpilot's system state while ignition is off." -route = "A route is a recording of an onroad session." -segment = "Routes are split into one minute chunks called segments." -"comma connect" = "The web viewer for all your routes; check it out at [connect.comma.ai](https://connect.comma.ai)." -panda = "The secondary processor on the device that implements the functional safety and directly talks to the car over CAN. See the [panda repo](https://github.com/commaai/panda)." -"comma four" = "The latest hardware by comma.ai for running openpilot. More info at [comma.ai/shop/comma-four](https://www.comma.ai/shop/comma-four)." diff --git a/docs/serve.py b/docs/serve.py new file mode 100644 index 0000000000..5cfff33dec --- /dev/null +++ b/docs/serve.py @@ -0,0 +1,555 @@ +import argparse +import functools +import html +import http.server +import json +import posixpath +import re +import shutil +import threading +import time +import urllib.parse +from pathlib import Path + +DOCS_DIR = Path(__file__).resolve().parent +SITE_DIR = DOCS_DIR / "_site" +TEMPLATE_FILE = DOCS_DIR / "template.html" +EXCLUDE_DIRS = {"_site", "__pycache__"} + +REPO_URL = "https://github.com/commaai/openpilot/" + +# (title, target) pairs. target is a page path or an absolute URL. +# A None target marks a section header. +NAV: list[tuple[str, str | None]] = [ + ("What is openpilot?", "index.md"), + ("How-to", None), + ("Turn the speed blue", "how-to/turn-the-speed-blue.md"), + ("Connect to a comma 3X or four", "how-to/connect-to-comma.md"), + ("Add support for a car", "how-to/car-port.md"), + ("Concepts", None), + ("Logs", "concepts/logs.md"), + ("Safety", "concepts/safety.md"), + ("Glossary", "concepts/glossary.md"), + ("Contributing", None), + ("Feedback", "contributing/feedback.md"), + ("Roadmap", "contributing/roadmap.md"), + ("Contributing Guide →", "https://github.com/commaai/openpilot/blob/master/docs/CONTRIBUTING.md"), + ("Links", None), + ("Blog →", "https://blog.comma.ai"), + ("Bounties →", "https://comma.ai/bounties"), + ("GitHub →", "https://github.com/commaai"), + ("Discord →", "https://discord.comma.ai"), + ("X →", "https://x.com/comma_ai"), +] + +GLOSSARY_DESCRIPTIONS = { + "onroad": "openpilot's system state while ignition is on.", + "offroad": "openpilot's system state while ignition is off.", + "route": "A route is a recording of an onroad session.", + "segment": "Routes are split into one minute chunks called segments.", + "comma connect": "The web viewer for all your routes; check it out at [connect.comma.ai](https://connect.comma.ai).", + "panda": "The secondary processor on the device that implements the functional safety and directly talks to the car over CAN. See the [panda repo](https://github.com/commaai/panda).", + "comma four": "The latest hardware by comma.ai for running openpilot. More info at [comma.ai/shop/comma-four](https://www.comma.ai/shop/comma-four).", +} +GLOSSARY_PAGE = "concepts/glossary.md" +GLOSSARY_ROUTE = GLOSSARY_PAGE.removesuffix(".md") +GLOSSARY_SKIP = frozenset("a code h1 h2 h3 h4 h5 h6 kbd pre script style".split()) + +_ENTITY = re.compile(r"&(?:#x?[0-9a-fA-F]+|[a-zA-Z]+);") +_LIST = re.compile(r"^(\s*)([*+-]|\d+\.)\s+(.*)$") +_HEADING = re.compile(r"^(#{1,6})\s+(.*)$") +_HR = re.compile(r"^(-{3,}|\*{3,}|_{3,})$") +_ATTR_URL = re.compile(r"""(?P
\b(?:href|src)=(?P["']))(?P.*?)(?P=q)""")
+_VOID = frozenset("br img hr meta link input".split())
+_URL = re.compile(r"https?://[^\s<>\[\]\"']+")
+_ADMONITION = re.compile(r"^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$", re.I)
+
+
+def page_route(path: str) -> str:
+  path = path.removesuffix(".md")
+  return posixpath.dirname(path) or "." if posixpath.basename(path) == "index" else path
+
+
+def page_href(current: str, target: str) -> str:
+  route = posixpath.relpath(page_route(target), page_route(current))
+  return ("." if route == "." else route) + "/"
+
+
+def rewrite_relative_url(value: str, page: str) -> str | None:
+  url = urllib.parse.urlparse(value)
+  if value.startswith(("#", "/")) or url.scheme or url.netloc or not url.path:
+    return None
+  target = posixpath.normpath(posixpath.join(posixpath.dirname(page), url.path))
+  if target == ".." or target.startswith("../"):
+    return None
+  path = page_href(page, target) if target.endswith(".md") else posixpath.relpath(target, page_route(page))
+  return url._replace(path=path).geturl()
+
+
+def rewrite_html_urls(fragment: str, page: str) -> str:
+  def repl(m: re.Match[str]) -> str:
+    r = rewrite_relative_url(m.group("url"), page)
+    return m.group(0) if r is None else f'{m.group("pre")}{r}{m.group("q")}'
+
+  return _ATTR_URL.sub(repl, fragment)
+
+
+def esc(text: str, attr: bool = False) -> str:
+  held: list[str] = []
+
+  def hold(m: re.Match[str]) -> str:
+    held.append(m.group(0))
+    return f"\0{len(held) - 1}\0"
+
+  return re.sub(r"\0(\d+)\0", lambda m: held[int(m.group(1))], html.escape(_ENTITY.sub(hold, text), quote=attr))
+
+
+def clean_tooltip(description: str) -> str:
+  text = re.sub(r"\[([^\]]+)]\([^)]+\)", r"\1", description)
+  return re.sub(r"\s+", " ", re.sub(r"[*_~]", "", re.sub(r"`([^`]+)`", r"\1", text))).strip()
+
+
+def glossary_slug(label: str) -> str:
+  return label.replace(" ", "-").replace("_", "-").lower()
+
+
+GLOSSARY_TERMS = [(glossary_slug(l), re.compile(rf"(?**{l}**: {d}' for l, d in GLOSSARY_DESCRIPTIONS.items())
+
+
+def inject_glossary(body: str, page: str) -> str:
+  if page == GLOSSARY_PAGE:
+    return body
+  route = "." if page == "index.md" else page.removesuffix(".md")
+  base, seen, out, skip, depth = f"{posixpath.relpath(GLOSSARY_ROUTE, route)}/#", set(), [], None, 0
+  for part in re.split(r"(<[^>]+>)", body):
+    if not part:
+      continue
+    if part.startswith("<"):
+      out.append(part)
+      if part.startswith("") or tag in _VOID
+      if closing and skip == tag and depth:
+        depth -= 1
+        skip = None if not depth else skip
+      elif not closing and not void:
+        skip, depth = (tag, 1) if skip is None else (skip, depth + (skip == tag))
+      continue
+    if depth:
+      out.append(part)
+      continue
+    cur, text = 0, part
+    while True:
+      best = None
+      for order, (slug, pat, tip) in enumerate(GLOSSARY_TERMS):
+        if slug in seen or (found := pat.search(text, cur)) is None:
+          continue
+        cand = (found.start(), found.start() - found.end(), order, slug, tip, found.end(), found.group(0))
+        if best is None or cand[:3] < best[:3]:
+          best = cand
+      if best is None:
+        out.append(text[cur:])
+        break
+      start, _, _, slug, tip, end, matched = best
+      out.append(text[cur:start])
+      out.append(
+        f''
+        + f'{matched}'
+        + f'{esc(tip)}'
+      )
+      seen.add(slug)
+      cur = end
+  return "".join(out)
+
+
+def slugify(text: str) -> str:
+  text = html.unescape(re.sub(r"<[^>]+>", "", text)).lower()
+  return re.sub(r"[-\s]+", "-", re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE)).strip("-")
+
+
+def _parse_link(text: str, start: int) -> tuple[str, str, int] | None:
+  if start >= len(text) or text[start] != "[":
+    return None
+  depth, i = 0, start
+  while i < len(text):
+    depth += (text[i] == "[") - (text[i] == "]")
+    if text[i] == "]" and depth == 0:
+      label = text[start + 1 : i]
+      if i + 1 >= len(text) or text[i + 1] != "(":
+        return None
+      j, dp = i + 2, 1
+      while j < len(text) and dp:
+        dp += (text[j] == "(") - (text[j] == ")")
+        j += 1
+      return None if dp else (label, text[i + 2 : j - 1], j)
+    i += 1
+  return None
+
+
+def autolink_plain(text: str) -> str:
+  parts: list[str] = []
+  last = 0
+  for m in _URL.finditer(text):
+    start = m.start()
+    if start > 0 and text[start - 1].isalnum():
+      continue
+    parts.append(esc(text[last:start]))
+    url = m.group(0).rstrip(".,;:!?)]")
+    parts.append(f'{esc(url)}')
+    last = start + len(url)
+  parts.append(esc(text[last:]))
+  return "".join(parts)
+
+
+def render_inline(text: str, page: str) -> str:
+  out, i, n = [], 0, len(text)
+  while i < n:
+    if text[i] == "\n" and i >= 2 and text[i - 2 : i] == "  " and out and out[-1].endswith("  "):
+      out[-1] = out[-1][:-2]
+      out.append("
\n") + i += 1 + continue + if text[i] == "`" and (end := text.find("`", i + 1)) != -1: + out.append(f"{esc(text[i + 1 : end])}") + i = end + 1 + continue + if text[i] == "!" and i + 1 < n and text[i + 1] == "[" and (p := _parse_link(text, i + 1)): + label, url, end = p + src = rewrite_relative_url(url, page) or url + out.append(f'{esc(label, True)}') + i = end + continue + if text[i] == "[" and (p := _parse_link(text, i)): + label, url, end = p + href = rewrite_relative_url(url, page) or url + out.append(f'{render_inline(label, page)}') + i = end + continue + if text[i] == "<": + if text.startswith("", i + 4) + end = n if end < 0 else end + 3 + out.append(rewrite_html_urls(text[i:end], page)) + i = end + continue + if m := re.match(r"<[^>]+>", text[i:]): + out.append(rewrite_html_urls(m.group(0), page)) + i += len(m.group(0)) + continue + if (text.startswith("**", i) or text.startswith("__", i)) and (end := text.find(text[i : i + 2], i + 2)) != -1: + out.append(f"{render_inline(text[i + 2 : end], page)}") + i = end + 2 + continue + if text[i] in "*_" and i + 1 < n and text[i + 1] not in " \t\n" and (end := text.find(text[i], i + 1)) > i + 1: + out.append(f"{render_inline(text[i + 1 : end], page)}") + i = end + 1 + continue + j = i + 1 + while j < n and text[j] not in "`[ list[str]: + return [c.strip() for c in line.strip().removeprefix("|").removesuffix("|").split("|")] + + +def _is_sep(line: str) -> bool: + return "|" in line and all(re.fullmatch(r":?-{3,}:?", c.strip()) for c in _trow(line)) + + +def _align(sep: str) -> str: + s = sep.strip() + if s.startswith(":") and s.endswith(":"): + return ' style="text-align: center;"' + if s.endswith(":"): + return ' style="text-align: right;"' + if s.startswith(":"): + return ' style="text-align: left;"' + return "" + + +def _list_info(line: str) -> tuple[int, str, str] | None: + m = _LIST.match(line) + return None if not m else (len(m.group(1)) // 4, "ol" if m.group(2)[-1] == "." else "ul", m.group(3)) + + +def _render_blocks(text: str, page: str) -> str: + lines, out, i, n = text.splitlines(), [], 0, 0 + n = len(lines) + while i < n: + line, s = lines[i], lines[i].strip() + if not s: + i += 1 + continue + + if s.startswith("```"): + lang, body = s[3:].strip(), [] + i += 1 + while i < n and not lines[i].strip().startswith("```"): + body.append(lines[i]) + i += 1 + if i < n: + i += 1 + code = html.escape("\n".join(body) + ("\n" if body else "")) + cls = f' class="language-{html.escape(lang)}"' if lang else "" + out.append(f"
{code}
") + continue + + if m := _HEADING.match(s): + content, level = m.group(2).rstrip("#").strip(), len(m.group(1)) + sid = slugify(content) + out.append(f'{render_inline(content, page)}#') + i += 1 + continue + + if _HR.fullmatch(s): + out.append("
") + i += 1 + continue + + if "|" in line and i + 1 < n and _is_sep(lines[i + 1]): + headers, aligns = _trow(line), [_align(c) for c in _trow(lines[i + 1])] + i += 2 + rows = [] + while i < n and "|" in lines[i] and lines[i].strip(): + rows.append(_trow(lines[i])) + i += 1 + parts = ( + ["", "", ""] + + [f"{render_inline(h, page)}" for j, h in enumerate(headers)] + + ["", "", ""] + ) + for row in rows: + parts.append("") + for j in range(len(headers)): + parts.append(f"{render_inline(row[j] if j < len(row) else '', page)}") + parts.append("") + out.append("\n".join(parts + ["", "
"])) + continue + + if _list_info(line): + items: list[tuple[int, str, list[str]]] = [] + while i < n: + if not lines[i].strip(): + if i + 1 < n and _list_info(lines[i + 1]): + i += 1 + continue + break + info = _list_info(lines[i]) + if not info: + break + level, kind, body = info + chunk = [body] + i += 1 + while i < n and lines[i].strip() and _list_info(lines[i]) is None: + t = lines[i].strip() + if t.startswith(("```", ">")) or _HEADING.match(t) or _HR.fullmatch(t): + break + chunk.append(lines[i]) + i += 1 + items.append((level, kind, chunk)) + + def render_list(items: list[tuple[int, str, list[str]]], start: int, min_level: int) -> tuple[str, int]: + if start >= len(items) or items[start][0] < min_level: + return "", start + kind, chunks, idx = items[start][1], [f"<{items[start][1]}>"], start + while idx < len(items) and items[idx][0] >= min_level: + level, ikind, body_lines = items[idx] + if level > min_level: + nested, idx = render_list(items, idx, level) + chunks[-1] = (chunks[-1][:-5] + nested + "") if chunks[-1].endswith("") else chunks[-1] + nested + continue + if ikind != kind: + chunks += [f"", f"<{ikind}>"] + kind = ikind + idx += 1 + body = render_inline("\n".join(body_lines), page) + nested = "" + if idx < len(items) and items[idx][0] > min_level: + nested, idx = render_list(items, idx, min_level + 1) + chunks.append(f"
  • {body}{nested}\n
  • " if nested else f"
  • {body}
  • ") + chunks.append(f"") + return "\n".join(chunks), idx + + out.append(render_list(items, 0, items[0][0])[0]) + continue + + if s.startswith(">"): + q = [] + while i < n and lines[i].strip().startswith(">"): + q.append(re.sub(r"^>\s?", "", lines[i].strip())) + i += 1 + m = _ADMONITION.match(q[0].strip()) if q else None + if m: + kind = m.group(1).lower() + title = m.group(1).capitalize() + body = _render_blocks("\n".join(q[1:]), page) + out.append(f'
    \n

    {title}

    \n{body}\n
    ') + else: + out.append(f"
    \n

    {render_inline(chr(10).join(q), page)}

    \n
    ") + continue + + if s.startswith(" # Supported Cars A supported vehicle is one that just works when you install a comma device. All supported cars provide a better experience than any stock system. Supported vehicles reference the US market unless otherwise specified. -# {{all_car_docs | selectattr('support_type', 'eq', SupportType.UPSTREAM) | list | length}} Supported Cars - -|{{Column | map(attribute='value') | join('|') | replace(hardware_col_name, wide_hardware_col_name)}}| -|---|---|---|{% for _ in range((Column | length) - 3) %}{{':---:|'}}{% endfor +%} -{% for car_docs in all_car_docs | selectattr('support_type', 'eq', SupportType.UPSTREAM) %} -|{% for column in Column %}{{car_docs.get_column(column, star_icon, video_icon, footnote_tag)}}|{% endfor %} - -{% endfor %} +# $supported_count Supported Cars +$table_header +$table_separator +$table_rows ### Footnotes -{% for footnote in footnotes %} -{{loop.index}}{{footnote | replace('
    ', '')}}
    -{% endfor %} - +$footnotes ## Community Maintained Cars Although they're not upstream, the community has openpilot running on other makes and models. See the 'Community Supported Models' section of each make [on our wiki](https://wiki.comma.ai/). @@ -71,4 +56,3 @@ openpilot does not yet support these Toyota models due to a new message authenti * Lexus NX 2022+ * Toyota bZ4x 2023+ * Subaru Solterra 2023+ - diff --git a/openpilot/selfdrive/car/car_specific.py b/openpilot/selfdrive/car/car_events.py similarity index 99% rename from openpilot/selfdrive/car/car_specific.py rename to openpilot/selfdrive/car/car_events.py index a7fdbd0b44..21478e4d55 100644 --- a/openpilot/selfdrive/car/car_specific.py +++ b/openpilot/selfdrive/car/car_events.py @@ -13,7 +13,7 @@ EventName = log.OnroadEvent.EventName NetworkLocation = structs.CarParams.NetworkLocation -class CarSpecificEvents: +class CarEvents: def __init__(self, CP: structs.CarParams): self.CP = CP @@ -136,6 +136,8 @@ class CarSpecificEvents: events.add(EventName.parkBrake) if CS.accFaulted: events.add(EventName.accFaulted) + if CS.carNotReady: + events.add(EventName.carNotReady) if CS.steeringPressed: events.add(EventName.steerOverride) if CS.steeringDisengage and not CS_prev.steeringDisengage: diff --git a/openpilot/selfdrive/car/card.py b/openpilot/selfdrive/car/card.py index d9b7b4bb5a..e253788e5e 100755 --- a/openpilot/selfdrive/car/card.py +++ b/openpilot/selfdrive/car/card.py @@ -72,7 +72,7 @@ class Car: def __init__(self, CI=None, RI=None) -> None: self.can_sock = messaging.sub_sock('can', timeout=20) self.sm = messaging.SubMaster(['pandaStates', 'carControl', 'onroadEvents'] + ['carControlSP', 'longitudinalPlanSP']) - self.pm = messaging.PubMaster(['sendcan', 'carState', 'carParams', 'carOutput', 'liveTracks'] + ['carParamsSP', 'carStateSP']) + self.pm = messaging.PubMaster(['sendcan', 'carState', 'carParams', 'carOutput', 'radarTracks'] + ['carParamsSP', 'carStateSP']) self.can_rcv_cum_timeout_counter = 0 @@ -250,10 +250,10 @@ class Car: self.pm.send('carState', cs_send) if RD is not None: - tracks_msg = messaging.new_message('liveTracks') + tracks_msg = messaging.new_message('radarTracks') tracks_msg.valid = not any(RD.errors.to_dict().values()) - tracks_msg.liveTracks = RD - self.pm.send('liveTracks', tracks_msg) + tracks_msg.radarTracks = RD + self.pm.send('radarTracks', tracks_msg) # carParamsSP - logged every 50 seconds (> 1 per segment) if self.sm.frame % int(50. / DT_CTRL) == 0: diff --git a/openpilot/selfdrive/car/docs.py b/openpilot/selfdrive/car/docs.py index ea7a70688e..8d135d82ea 100755 --- a/openpilot/selfdrive/car/docs.py +++ b/openpilot/selfdrive/car/docs.py @@ -1,13 +1,68 @@ #!/usr/bin/env python3 import argparse import os +from string import Template from openpilot.common.basedir import BASEDIR -from opendbc.car.docs import get_all_car_docs, generate_cars_md +from opendbc.car.docs import get_all_car_docs, get_all_footnotes +from opendbc.car.docs_definitions import Column, SupportType CARS_MD_OUT = os.path.join(BASEDIR, "docs", "CARS.md") CARS_MD_TEMPLATE = os.path.join(BASEDIR, "openpilot/selfdrive", "car", "CARS_template.md") +FOOTNOTE_TAG = '[{}](#footnotes)' +STAR_ICON = '[![star](assets/icon-star-{}.svg)](##)' +VIDEO_ICON = '' +# Force hardware column wider by using a blank image with max width. +HARDWARE_COL_NAME = 'Hardware Needed' +WIDE_HARDWARE_COL_NAME = f'{HARDWARE_COL_NAME}
     ' + + +def _build_cars_table(upstream_cars) -> tuple[str, str, str]: + columns = list(Column) + header_cells = [ + WIDE_HARDWARE_COL_NAME if col.value == HARDWARE_COL_NAME else col.value + for col in columns + ] + table_header = "|" + "|".join(header_cells) + "|" + + # First three columns left-aligned (---), remaining centered (:---:) + sep_parts = ["---"] * min(3, len(columns)) + [":---:"] * max(0, len(columns) - 3) + table_separator = "|" + "|".join(sep_parts) + "|" + + rows = [] + for car_docs in upstream_cars: + cells = [car_docs.get_column(column, STAR_ICON, VIDEO_ICON, FOOTNOTE_TAG) for column in columns] + rows.append("|" + "|".join(cells) + "|") + table_rows = "\n".join(rows) + ("\n" if rows else "") + + return table_header, table_separator, table_rows + + +def generate_cars_md(all_car_docs, template_fn: str, **kwargs) -> str: + del kwargs # kept for call-site compatibility + + upstream_cars = [c for c in all_car_docs if c.support_type == SupportType.UPSTREAM] + table_header, table_separator, table_rows = _build_cars_table(upstream_cars) + + footnotes = [fn.value.text.replace('
    ', '') for fn in get_all_footnotes()] + footnotes_md = "\n".join( + f"{i}{text}
    " + for i, text in enumerate(footnotes, start=1) + ) + ("\n" if footnotes else "") + + with open(template_fn) as f: + template = Template(f.read()) + + return template.substitute( + supported_count=len(upstream_cars), + table_header=table_header, + table_separator=table_separator, + table_rows=table_rows, + footnotes=footnotes_md, + ) + + if __name__ == "__main__": parser = argparse.ArgumentParser(description="Auto generates supported cars documentation", formatter_class=argparse.ArgumentDefaultsHelpFormatter) diff --git a/openpilot/selfdrive/car/tests/big_cars_test.sh b/openpilot/selfdrive/car/tests/big_cars_test.sh deleted file mode 100755 index 456fc698c5..0000000000 --- a/openpilot/selfdrive/car/tests/big_cars_test.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -SCRIPT_DIR=$(dirname "$0") -BASEDIR=$(realpath "$SCRIPT_DIR/../../../../") -cd $BASEDIR - -export MAX_EXAMPLES=300 -export INTERNAL_SEG_CNT=300 -export INTERNAL_SEG_LIST=openpilot/selfdrive/car/tests/test_models_segs.txt - -cd openpilot/selfdrive/car/tests && pytest test_models.py test_car_interfaces.py diff --git a/openpilot/selfdrive/car/tests/test_car_interfaces.py b/openpilot/selfdrive/car/tests/test_car_interfaces.py index 3c4587cbc6..bf3e8d8ff6 100644 --- a/openpilot/selfdrive/car/tests/test_car_interfaces.py +++ b/openpilot/selfdrive/car/tests/test_car_interfaces.py @@ -1,13 +1,16 @@ import os -import hypothesis.strategies as st -from hypothesis import Phase, given, settings + +from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized +from openpilot.common.fuzzy import capnp_random_dict, fuzzy_test from openpilot.cereal import custom from opendbc.car.structs import car from opendbc.car import DT_CTRL +from opendbc.car.car_helpers import interfaces +from opendbc.car.fingerprints import FW_VERSIONS +from opendbc.car.fw_versions import FW_QUERY_CONFIGS from opendbc.car.structs import CarParams -from opendbc.car.tests.test_car_interfaces import get_fuzzy_car_interface from opendbc.car.mock.values import CAR as MOCK from opendbc.car.values import PLATFORMS from openpilot.selfdrive.car.helpers import convert_carControlSP @@ -15,28 +18,41 @@ from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque from openpilot.selfdrive.controls.lib.longcontrol import LongControl -from openpilot.selfdrive.test.fuzzy_generation import FuzzyGenerator from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces MAX_EXAMPLES = int(os.environ.get('MAX_EXAMPLES', '60')) +ALL_ECUS = tuple(sorted({ecu for ecus in FW_VERSIONS.values() for ecu in ecus} | + {ecu for config in FW_QUERY_CONFIGS.values() for ecu in config.extra_ecus})) +ALL_REQUESTS = tuple(sorted({tuple(request.request) for config in FW_QUERY_CONFIGS.values() for request in config.requests})) +DLC_TO_LEN = (0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64) -class TestCarInterfaces: - # FIXME: Due to the lists used in carParams, Phase.target is very slow and will cause - # many generated examples to overrun when max_examples > ~20, don't use it - @parameterized.expand([(car,) for car in sorted(PLATFORMS)] + [MOCK.MOCK]) - @settings(max_examples=MAX_EXAMPLES, deadline=None, - phases=(Phase.reuse, Phase.generate, Phase.shrink)) - @given(data=st.data()) - def test_car_interfaces(self, car_name, data): - car_interface = get_fuzzy_car_interface(car_name, data.draw) +class TestCarInterfaces(OpenpilotTestCase): + @parameterized.expand([(car,) for car in sorted(PLATFORMS)] + [MOCK.MOCK], ids=lambda car_name: car_name) + @fuzzy_test(max_examples=60) + def test_car_interfaces(self, car_name, fuzzy): + fingerprint = dict(fuzzy.list(lambda: (fuzzy.integer(0, 0x800), fuzzy.choice(DLC_TO_LEN)))) + fingerprints = dict.fromkeys(range(7), fingerprint) + + def generate_car_fw(): + ecu, address, sub_address = fuzzy.choice(ALL_ECUS) + return CarParams.CarFw(ecu=ecu, address=address, subAddress=sub_address or 0, request=fuzzy.choice(ALL_REQUESTS)) + + CarInterface = interfaces[car_name] + car_fw = fuzzy.list(generate_car_fw) + alpha_long = fuzzy.boolean() + car_params = CarInterface.get_params(car_name, fingerprints, car_fw, + alpha_long=alpha_long, is_release=False, docs=False) + car_params_sp = CarInterface.get_params_sp(car_params, car_name, fingerprints, car_fw, + alpha_long=alpha_long, is_release_sp=False, docs=False) + car_interface = CarInterface(car_params, car_params_sp) car_params = car_interface.CP.as_reader() car_params_sp = car_interface.CP_SP sunnypilot_interfaces.setup_interfaces(car_interface) - cc_msg = FuzzyGenerator.get_random_msg(data.draw, car.CarControl, real_floats=True) - cc_sp_msg = FuzzyGenerator.get_random_msg(data.draw, custom.CarControlSP, real_floats=True) + cc_msg = capnp_random_dict(fuzzy, car.CarControl.schema, real_floats=True) + cc_sp_msg = capnp_random_dict(fuzzy, custom.CarControlSP.schema, real_floats=True) # Run car interface now_nanos = 0 CC = car.CarControl.new_message(**cc_msg) @@ -59,8 +75,6 @@ class TestCarInterfaces: now_nanos += DT_CTRL * 1e9 # 10ms # Test controller initialization - # TODO: wait until card refactor is merged to run controller a few times, - # hypothesis also slows down significantly with just one more message draw LongControl(car_params, car_params_sp) if car_params.steerControlType == CarParams.SteerControlType.angle: LatControlAngle(car_params, car_params_sp, car_interface, DT_CTRL) diff --git a/openpilot/selfdrive/car/tests/test_cruise_speed.py b/openpilot/selfdrive/car/tests/test_cruise_speed.py index c0b6ac979f..60af346838 100644 --- a/openpilot/selfdrive/car/tests/test_cruise_speed.py +++ b/openpilot/selfdrive/car/tests/test_cruise_speed.py @@ -1,7 +1,7 @@ -import pytest import itertools import numpy as np +from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized_class from openpilot.cereal import log from openpilot.selfdrive.car.cruise import VCruiseHelper, V_CRUISE_MIN, V_CRUISE_MAX, V_CRUISE_INITIAL, IMPERIAL_INCREMENT @@ -36,18 +36,18 @@ def run_cruise_simulation(cruise, e2e, personality, t_end=20.): [True, False], # e2e log.LongitudinalPersonality.schema.enumerants, # personality [5,35])) # speed -class TestCruiseSpeed: +class TestCruiseSpeed(OpenpilotTestCase): def test_cruise_speed(self): print(f'Testing {self.speed} m/s') cruise_speed = float(self.speed) simulation_steady_state = run_cruise_simulation(cruise_speed, self.e2e, self.personality) - assert simulation_steady_state == pytest.approx(cruise_speed, abs=.01), f'Did not reach {self.speed} m/s' + self.assertAlmostEqual(simulation_steady_state, cruise_speed, delta=.01, msg=f'Did not reach {self.speed} m/s') # TODO: test pcmCruise and pcmCruiseSpeed @parameterized_class(('pcm_cruise', 'pcm_cruise_speed'), [(False, True)]) -class TestVCruiseHelper: +class TestVCruiseHelper(OpenpilotTestCase): def setup_method(self): self.CP = car.CarParams(pcmCruise=self.pcm_cruise) self.CP_SP = custom.CarParamsSP(pcmCruiseSpeed=self.pcm_cruise_speed) diff --git a/openpilot/selfdrive/car/tests/test_docs.py b/openpilot/selfdrive/car/tests/test_docs.py index 8ccbfb5a79..99438b4720 100644 --- a/openpilot/selfdrive/car/tests/test_docs.py +++ b/openpilot/selfdrive/car/tests/test_docs.py @@ -1,8 +1,9 @@ -from opendbc.car.docs import generate_cars_md, get_all_car_docs -from openpilot.selfdrive.car.docs import CARS_MD_TEMPLATE +from openpilot.common.test import OpenpilotTestCase +from openpilot.selfdrive.car.docs import CARS_MD_TEMPLATE, generate_cars_md +from opendbc.car.docs import get_all_car_docs -class TestCarDocs: +class TestCarDocs(OpenpilotTestCase): @classmethod def setup_class(cls): cls.all_cars = get_all_car_docs() diff --git a/openpilot/selfdrive/car/tests/test_models.py b/openpilot/selfdrive/car/tests/test_models.py deleted file mode 100644 index 3dc7dd8a7b..0000000000 --- a/openpilot/selfdrive/car/tests/test_models.py +++ /dev/null @@ -1,502 +0,0 @@ -import time -import copy -import os -import pytest -import random -import unittest -from collections import defaultdict, Counter -import hypothesis.strategies as st -from hypothesis import Phase, given, settings -from openpilot.common.parameterized import parameterized_class -from opendbc.car import DT_CTRL, gen_empty_fingerprint, structs -from opendbc.car.can_definitions import CanData -from opendbc.car.car_helpers import FRAME_FINGERPRINT, interfaces -from opendbc.car.fingerprints import MIGRATION -from opendbc.car.honda.values import CAR as HONDA, HondaFlags -from opendbc.car.structs import car -from opendbc.car.tests.routes import non_tested_cars, routes, CarTestRoute -from opendbc.car.values import Platform, PLATFORMS -from opendbc.safety.tests.libsafety import libsafety_py -from openpilot.common.basedir import BASEDIR -from openpilot.selfdrive.pandad import can_capnp_to_list -from openpilot.selfdrive.test.helpers import read_segment_list -from openpilot.common.hardware.hw import DEFAULT_DOWNLOAD_CACHE_ROOT -from openpilot.tools.lib.logreader import LogReader, LogsUnavailable, openpilotci_source, internal_source, comma_api_source -from openpilot.tools.lib.file_sources import Source -from openpilot.tools.lib.route import SegmentName - -from openpilot.sunnypilot.tools.lib.sunnypilot_car_segments import sunnypilot_car_segments_source - -SafetyModel = car.CarParams.SafetyModel -SteerControlType = structs.CarParams.SteerControlType - -# panda safety stores angle_meas in brand-specific CAN units (angle_deg_to_can in opendbc/safety/modes/*.h). -ANGLE_DEG_TO_CAN = { - "tesla": -10, - "toyota": 17.452007, - "nissan": 100, - "psa": 10, -} - -NUM_JOBS = int(os.environ.get("NUM_JOBS", "1")) -JOB_ID = int(os.environ.get("JOB_ID", "0")) -INTERNAL_SEG_LIST = os.environ.get("INTERNAL_SEG_LIST", "") -INTERNAL_SEG_CNT = int(os.environ.get("INTERNAL_SEG_CNT", "0")) -MAX_EXAMPLES = int(os.environ.get("MAX_EXAMPLES", "300")) -CI = os.environ.get("CI", None) is not None - - -def get_test_cases() -> list[tuple[str, CarTestRoute | None]]: - # build list of test cases - test_cases = [] - if not len(INTERNAL_SEG_LIST): - routes_by_car = defaultdict(set) - for r in routes: - routes_by_car[str(r.car_model)].add(r) - - for i, c in enumerate(sorted(PLATFORMS)): - if i % NUM_JOBS == JOB_ID: - test_cases.extend(sorted((c, r) for r in routes_by_car.get(c, (None,)))) - - else: - segment_list = read_segment_list(os.path.join(BASEDIR, INTERNAL_SEG_LIST)) - segment_list = random.sample(segment_list, INTERNAL_SEG_CNT or len(segment_list)) - for platform, segment in segment_list: - platform = MIGRATION.get(platform, platform) - segment_name = SegmentName(segment) - test_cases.append((platform, CarTestRoute(segment_name.route_name.canonical_name, platform, - segment=segment_name.segment_num))) - return test_cases - - -@pytest.mark.slow -@pytest.mark.shared_download_cache -class TestCarModelBase(unittest.TestCase): - platform: Platform | None = None - test_route: CarTestRoute | None = None - - can_msgs: list[tuple[int, list[CanData]]] - fingerprint: dict[int, dict[int, int]] - elm_frame: int | None - car_safety_mode_frame: int | None - - @classmethod - def get_testing_data_from_logreader(cls, lr): - car_fw = [] - can_msgs = [] - cls.elm_frame = None - cls.car_safety_mode_frame = None - cls.fingerprint = gen_empty_fingerprint() - alpha_long = False - for msg in lr: - if msg.which() == "can": - can = can_capnp_to_list((msg.as_builder().to_bytes(),))[0] - can_msgs.append((can[0], [CanData(*can) for can in can[1]])) - if len(can_msgs) <= FRAME_FINGERPRINT: - for m in msg.can: - if m.src < 64: - cls.fingerprint[m.src][m.address] = len(m.dat) - - elif msg.which() == "carParams": - car_fw = msg.carParams.carFw - if msg.carParams.openpilotLongitudinalControl: - alpha_long = True - if cls.platform is None: - live_fingerprint = msg.carParams.carFingerprint - cls.platform = MIGRATION.get(live_fingerprint, live_fingerprint) - - # Log which can frame the panda safety mode left ELM327, for CAN validity checks - elif msg.which() == 'pandaStates': - for ps in msg.pandaStates: - if cls.elm_frame is None and ps.safetyModel != SafetyModel.elm327: - cls.elm_frame = len(can_msgs) - if cls.car_safety_mode_frame is None and ps.safetyModel not in \ - (SafetyModel.elm327, SafetyModel.noOutput): - cls.car_safety_mode_frame = len(can_msgs) - - elif msg.which() == 'pandaStateDEPRECATED': - if cls.elm_frame is None and msg.pandaStateDEPRECATED.safetyModel != SafetyModel.elm327: - cls.elm_frame = len(can_msgs) - if cls.car_safety_mode_frame is None and msg.pandaStateDEPRECATED.safetyModel not in \ - (SafetyModel.elm327, SafetyModel.noOutput): - cls.car_safety_mode_frame = len(can_msgs) - - assert len(can_msgs) > int(50 / DT_CTRL), "no can data found" - return car_fw, can_msgs, alpha_long - - @classmethod - def get_testing_data(cls): - test_segs = (2, 1, 0) - if cls.test_route.segment is not None: - test_segs = (cls.test_route.segment,) - - for seg in test_segs: - segment_range = f"{cls.test_route.route}/{seg}" - - try: - sources: list[Source] = [internal_source] if len(INTERNAL_SEG_LIST) else [openpilotci_source, comma_api_source, sunnypilot_car_segments_source] - lr = LogReader(segment_range, sources=sources, sort_by_time=True) - return cls.get_testing_data_from_logreader(lr) - except (LogsUnavailable, AssertionError): - pass - - raise Exception(f"Route: {repr(cls.test_route.route)} with segments: {test_segs} not found or no CAN msgs found. Is it uploaded and public?") - - - @classmethod - def setUpClass(cls): - if cls.__name__ == 'TestCarModel' or cls.__name__.endswith('Base'): - raise unittest.SkipTest - - if cls.test_route is None: - if cls.platform in non_tested_cars: - print(f"Skipping tests for {cls.platform}: missing route") - raise unittest.SkipTest - raise Exception(f"missing test route for {cls.platform}") - - car_fw, cls.can_msgs, alpha_long = cls.get_testing_data() - - # if relay is expected to be open in the route - cls.openpilot_enabled = cls.car_safety_mode_frame is not None - - cls.CarInterface = interfaces[cls.platform] - cls.CP = cls.CarInterface.get_params(cls.platform, cls.fingerprint, car_fw, alpha_long, False, docs=False) - cls.CP_SP = cls.CarInterface.get_params_sp(cls.CP, cls.platform, cls.fingerprint, car_fw, alpha_long, False, docs=False) - assert cls.CP - assert cls.CP_SP - assert cls.CP.carFingerprint == cls.platform - - os.environ["COMMA_CACHE"] = DEFAULT_DOWNLOAD_CACHE_ROOT - - @classmethod - def tearDownClass(cls): - del cls.can_msgs - - def setUp(self): - self.CI = self.CarInterface(self.CP.copy(), copy.deepcopy(self.CP_SP)) - assert self.CI - - # TODO: check safetyModel is in release panda build - self.safety = libsafety_py.libsafety - - safety_param_sp = self.CP_SP.safetyParam - self.safety.set_current_safety_param_sp(safety_param_sp) - - cfg = self.CP.safetyConfigs[-1] - set_status = self.safety.set_safety_hooks(cfg.safetyModel.raw, cfg.safetyParam) - self.assertEqual(0, set_status, f"failed to set safetyModel {cfg}") - self.safety.init_tests() - - def test_car_params(self): - if self.CP.dashcamOnly: - self.skipTest("no need to check carParams for dashcamOnly") - - # make sure car params are within a valid range - self.assertGreater(self.CP.mass, 1) - - if self.CP.steerControlType not in (SteerControlType.angle, SteerControlType.curvature): - tuning = self.CP.lateralTuning.which() - if tuning == 'pid': - self.assertTrue(len(self.CP.lateralTuning.pid.kpV)) - elif tuning == 'torque': - self.assertTrue(self.CP.lateralTuning.torque.latAccelFactor > 0) - else: - raise Exception("unknown tuning") - - def test_car_interface(self): - # TODO: also check for checksum violations from can parser - can_invalid_cnt = 0 - CC = structs.CarControl().as_reader() - CC_SP = structs.CarControlSP() - - for i, msg in enumerate(self.can_msgs): - CS, _ = self.CI.update(msg) - self.CI.apply(CC, CC_SP, msg[0]) - - # wait max of 2s for low frequency msgs to be seen - if i > 250: - can_invalid_cnt += not CS.canValid - - self.assertEqual(can_invalid_cnt, 0) - - def test_radar_interface(self): - RI = self.CarInterface.RadarInterface(self.CP, self.CP_SP) - assert RI - - # Since OBD port is multiplexed to bus 1 (commonly radar bus) while fingerprinting, - # start parsing CAN messages after we've left ELM mode and can expect CAN traffic - error_cnt = 0 - for i, msg in enumerate(self.can_msgs[self.elm_frame:]): - rr: structs.RadarData | None = RI.update(msg) - if rr is not None and i > 50: - error_cnt += rr.errors.canError - self.assertEqual(error_cnt, 0) - - def test_panda_safety_rx_checks(self): - if self.CP.dashcamOnly: - self.skipTest("no need to check panda safety for dashcamOnly") - - start_ts = self.can_msgs[0][0] - - failed_addrs = Counter() - for can in self.can_msgs: - # update panda timer - t = (can[0] - start_ts) / 1e3 - self.safety.set_timer(int(t)) - - # run all msgs through the safety RX hook - for msg in can[1]: - if msg.src >= 64: - continue - - to_send = libsafety_py.make_CANPacket(msg.address, msg.src % 4, msg.dat) - if self.safety.safety_rx_hook(to_send) != 1: - failed_addrs[hex(msg.address)] += 1 - - # ensure all msgs defined in the addr checks are valid - self.safety.safety_tick_current_safety_config() - if t > 1e6: - self.assertTrue(self.safety.safety_config_valid()) - - # Don't check relay malfunction on disabled routes (relay closed), - # or before fingerprinting is done (elm327 and noOutput) - if self.car_safety_mode_frame is not None and t / 1e4 > self.car_safety_mode_frame: - self.assertFalse(self.safety.get_relay_malfunction()) - else: - self.safety.set_relay_malfunction(False) - - self.assertFalse(len(failed_addrs), f"panda safety RX check failed: {failed_addrs}") - - # ensure RX checks go invalid after small time with no traffic - self.safety.set_timer(int(t + (2*1e6))) - self.safety.safety_tick_current_safety_config() - self.assertFalse(self.safety.safety_config_valid()) - - def test_panda_safety_tx_cases(self, data=None): - """Asserts we can tx common messages""" - if self.CP.dashcamOnly: - self.skipTest("no need to check panda safety for dashcamOnly") - - if self.CP.notCar: - self.skipTest("Skipping test for notCar") - - def test_car_controller(car_control, car_control_sp): - now_nanos = 0 - msgs_sent = 0 - CI = self.CarInterface(self.CP, self.CP_SP) - for _ in range(round(10.0 / DT_CTRL)): # make sure we hit the slowest messages - CI.update([]) - _, sendcan = CI.apply(car_control, car_control_sp, now_nanos) - - now_nanos += DT_CTRL * 1e9 - msgs_sent += len(sendcan) - for addr, dat, bus in sendcan: - to_send = libsafety_py.make_CANPacket(addr, bus % 4, dat) - self.assertTrue(self.safety.safety_tx_hook(to_send), (addr, dat, bus)) - - # Make sure we attempted to send messages - self.assertGreater(msgs_sent, 50) - - # Make sure we can send all messages while inactive - CC = structs.CarControl() - CC_SP = structs.CarControlSP() - test_car_controller(CC.as_reader(), CC_SP) - - # Test cancel + general messages (controls_allowed=False & cruise_engaged=True) - self.safety.set_cruise_engaged_prev(True) - CC = structs.CarControl(cruiseControl=structs.CarControl.CruiseControl(cancel=True)) - test_car_controller(CC.as_reader(), CC_SP) - - # Test resume + general messages (controls_allowed=True & cruise_engaged=True) - self.safety.set_controls_allowed(True) - CC = structs.CarControl(cruiseControl=structs.CarControl.CruiseControl(resume=True)) - test_car_controller(CC.as_reader(), CC_SP) - - # Skip stdout/stderr capture with pytest, causes elevated memory usage - @pytest.mark.nocapture - @settings(max_examples=MAX_EXAMPLES, deadline=None, - phases=(Phase.reuse, Phase.generate, Phase.shrink)) - @given(data=st.data()) - def test_panda_safety_carstate_fuzzy(self, data): - """ - For each example, pick a random CAN message on the bus and fuzz its data, - checking for panda state mismatches. - """ - - if self.CP.dashcamOnly: - self.skipTest("no need to check panda safety for dashcamOnly") - - valid_addrs = [(addr, bus, size) for bus, addrs in self.fingerprint.items() for addr, size in addrs.items()] - address, bus, size = data.draw(st.sampled_from(valid_addrs)) - - msg_strategy = st.binary(min_size=size, max_size=size) - msgs = data.draw(st.lists(msg_strategy, min_size=20)) - - vehicle_speed_seen = self.CP.steerControlType == SteerControlType.angle and not self.CP.notCar - - for n, dat in enumerate(msgs): - # due to panda updating state selectively, only edges are expected to match - # TODO: warm up CarState with real CAN messages to check edge of both sources - # (eg. toyota's gasPressed is the inverse of a signal being set) - prev_panda_gas = self.safety.get_gas_pressed_prev() - prev_panda_brake = self.safety.get_brake_pressed_prev() - prev_panda_regen_braking = self.safety.get_regen_braking_prev() - prev_panda_steering_disengage = self.safety.get_steering_disengage_prev() - prev_panda_vehicle_moving = self.safety.get_vehicle_moving() - prev_panda_vehicle_speed_min = self.safety.get_vehicle_speed_min() - prev_panda_vehicle_speed_max = self.safety.get_vehicle_speed_max() - prev_panda_cruise_engaged = self.safety.get_cruise_engaged_prev() - prev_panda_acc_main_on = self.safety.get_acc_main_on() - - to_send = libsafety_py.make_CANPacket(address, bus, dat) - self.safety.safety_rx_hook(to_send) - - can = [(int(time.monotonic() * 1e9), [CanData(address=address, dat=dat, src=bus)])] - CS, _ = self.CI.update(can) - if n < 5: # CANParser warmup time - continue - - if self.safety.get_gas_pressed_prev() != prev_panda_gas: - self.assertEqual(CS.gasPressed, self.safety.get_gas_pressed_prev()) - - if self.safety.get_brake_pressed_prev() != prev_panda_brake: - # TODO: remove this exception once this mismatch is resolved - brake_pressed = CS.brakePressed - if CS.brakePressed and not self.safety.get_brake_pressed_prev(): - if self.CP.carFingerprint in (HONDA.HONDA_PILOT, HONDA.HONDA_RIDGELINE) and CS.brake > 0.05: - brake_pressed = False - - self.assertEqual(brake_pressed, self.safety.get_brake_pressed_prev()) - - if self.safety.get_regen_braking_prev() != prev_panda_regen_braking: - self.assertEqual(CS.regenBraking, self.safety.get_regen_braking_prev()) - - if self.safety.get_steering_disengage_prev() != prev_panda_steering_disengage: - self.assertEqual(CS.steeringDisengage, self.safety.get_steering_disengage_prev()) - - if self.safety.get_vehicle_moving() != prev_panda_vehicle_moving and not self.CP.notCar: - self.assertEqual(not CS.standstill, self.safety.get_vehicle_moving()) - - # check vehicle speed if angle control car or available - if self.safety.get_vehicle_speed_min() > 0 or self.safety.get_vehicle_speed_max() > 0: - vehicle_speed_seen = True - - if vehicle_speed_seen and (self.safety.get_vehicle_speed_min() != prev_panda_vehicle_speed_min or - self.safety.get_vehicle_speed_max() != prev_panda_vehicle_speed_max): - v_ego_raw = CS.vEgoRaw / self.CP.wheelSpeedFactor - self.assertFalse(v_ego_raw > (self.safety.get_vehicle_speed_max() + 1e-3) or - v_ego_raw < (self.safety.get_vehicle_speed_min() - 1e-3)) - - if not (self.CP.brand == "honda" and not (self.CP.flags & HondaFlags.BOSCH)): - if self.safety.get_cruise_engaged_prev() != prev_panda_cruise_engaged: - self.assertEqual(CS.cruiseState.enabled, self.safety.get_cruise_engaged_prev()) - - if self.CP.brand == "honda": - if self.safety.get_acc_main_on() != prev_panda_acc_main_on: - self.assertEqual(CS.cruiseState.available, self.safety.get_acc_main_on()) - - def test_panda_safety_carstate(self): - """ - Assert that panda safety matches openpilot's carState - """ - if self.CP.dashcamOnly: - self.skipTest("no need to check panda safety for dashcamOnly") - - # warm up pass, as initial states may be different - for can in self.can_msgs[:300]: - self.CI.update(can) - for msg in filter(lambda m: m.src < 64, can[1]): - to_send = libsafety_py.make_CANPacket(msg.address, msg.src % 4, msg.dat) - self.safety.safety_rx_hook(to_send) - - controls_allowed_prev = False - CS_prev = car.CarState.new_message() - checks = defaultdict(int) - vehicle_speed_seen = self.CP.steerControlType == SteerControlType.angle and not self.CP.notCar - for idx, can in enumerate(self.can_msgs): - CS, _ = self.CI.update(can) - CS = CS.as_reader() - for msg in filter(lambda m: m.src < 64, can[1]): - to_send = libsafety_py.make_CANPacket(msg.address, msg.src % 4, msg.dat) - ret = self.safety.safety_rx_hook(to_send) - self.assertEqual(1, ret, f"safety rx failed ({ret=}): {(msg.address, msg.src % 4)}") - - # Skip first frame so CS_prev is properly initialized - if idx == 0: - CS_prev = CS - # Button may be left pressed in warm up period - if not self.CP.pcmCruise: - self.safety.set_controls_allowed(0) - continue - - # TODO: check rest of panda's carstate (steering, ACC main on, etc.) - - checks['gasPressed'] += CS.gasPressed != self.safety.get_gas_pressed_prev() - checks['standstill'] += (CS.standstill == self.safety.get_vehicle_moving()) and not self.CP.notCar - - # check vehicle speed if angle control car or available - if self.safety.get_vehicle_speed_min() > 0 or self.safety.get_vehicle_speed_max() > 0: - vehicle_speed_seen = True - - if vehicle_speed_seen: - v_ego_raw = CS.vEgoRaw / self.CP.wheelSpeedFactor - checks['vEgoRaw'] += (v_ego_raw > (self.safety.get_vehicle_speed_max() + 1e-3) or - v_ego_raw < (self.safety.get_vehicle_speed_min() - 1e-3)) - - # check steering angle for angle control cars (panda stores angle_meas in CAN units) - # ford and VW MEB excluded since they track curvature, not steering angle - # TODO: add curvature check, standardize CAN units to rm brand specific ANGLE_DEG_TO_CAN - if self.CP.steerControlType == SteerControlType.angle and not self.CP.notCar and self.CP.brand not in ("ford", "volkswagen"): - angle_can = (CS.steeringAngleDeg + CS.steeringAngleOffsetDeg) * ANGLE_DEG_TO_CAN[self.CP.brand] - checks['steeringAngleDeg'] += (angle_can > (self.safety.get_angle_meas_max() + 1) or - angle_can < (self.safety.get_angle_meas_min() - 1)) - - # TODO: remove this exception once this mismatch is resolved - brake_pressed = CS.brakePressed - if CS.brakePressed and not self.safety.get_brake_pressed_prev(): - if self.CP.carFingerprint in (HONDA.HONDA_PILOT, HONDA.HONDA_RIDGELINE) and CS.deprecated.brake > 0.05: - brake_pressed = False - checks['brakePressed'] += brake_pressed != self.safety.get_brake_pressed_prev() - checks['regenBraking'] += CS.regenBraking != self.safety.get_regen_braking_prev() - checks['steeringDisengage'] += CS.steeringDisengage != self.safety.get_steering_disengage_prev() - - if self.CP.pcmCruise: - # On most pcmCruise cars, openpilot's state is always tied to the PCM's cruise state. - # On Honda Nidec, we always engage on the rising edge of the PCM cruise state, but - # openpilot brakes to zero even if the min ACC speed is non-zero (i.e. the PCM disengages). - if self.CP.brand == "honda" and not (self.CP.flags & HondaFlags.BOSCH): - # only the rising edges are expected to match - if CS.cruiseState.enabled and not CS_prev.cruiseState.enabled: - checks['controlsAllowed'] += not self.safety.get_controls_allowed() - else: - checks['controlsAllowed'] += not CS.cruiseState.enabled and self.safety.get_controls_allowed() - - # TODO: fix notCar mismatch - if not self.CP.notCar: - checks['cruiseState'] += CS.cruiseState.enabled != self.safety.get_cruise_engaged_prev() - else: - # Check for user button enable on rising edge of controls allowed - button_enable = CS.buttonEnable and (not CS.brakePressed or CS.standstill) - mismatch = button_enable != (self.safety.get_controls_allowed() and not controls_allowed_prev) - checks['controlsAllowed'] += mismatch - controls_allowed_prev = self.safety.get_controls_allowed() - if button_enable and not mismatch: - self.safety.set_controls_allowed(False) - - if self.CP.brand == "honda": - checks['mainOn'] += CS.cruiseState.available != self.safety.get_acc_main_on() - - CS_prev = CS - - failed_checks = {k: v for k, v in checks.items() if v > 0} - self.assertFalse(len(failed_checks), f"panda safety doesn't agree with openpilot: {failed_checks}") - - -@parameterized_class(('platform', 'test_route'), get_test_cases()) -@pytest.mark.xdist_group_class_property('test_route') -class TestCarModel(TestCarModelBase): - pass - - -if __name__ == "__main__": - unittest.main() diff --git a/openpilot/selfdrive/car/tests/test_models_segs.txt b/openpilot/selfdrive/car/tests/test_models_segs.txt deleted file mode 100644 index c983fb08e7..0000000000 --- a/openpilot/selfdrive/car/tests/test_models_segs.txt +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0810a361ec5b5f5f9a2ee73b89ffb2df62ef40e8feff7e97ecb62f80fa53f6f5 -size 124950 diff --git a/openpilot/selfdrive/controls/controlsd.py b/openpilot/selfdrive/controls/controlsd.py index d9c0b445d8..f3c8782cb0 100755 --- a/openpilot/selfdrive/controls/controlsd.py +++ b/openpilot/selfdrive/controls/controlsd.py @@ -43,9 +43,9 @@ class Controls(ControlsExt): self.CI = interfaces[self.CP.carFingerprint](self.CP, self.CP_SP) - self.sm = messaging.SubMaster(['liveDelay', 'liveParameters', 'liveTorqueParameters', 'modelV2', 'selfdriveState', - 'liveCalibration', 'livePose', 'longitudinalPlan', 'lateralManeuverPlan', 'carState', 'carOutput', - 'driverMonitoringState', 'onroadEvents', 'driverAssistance', 'liveDelay'] + self.sm_services_ext, + self.sm = messaging.SubMaster(['lateralDelay', 'vehicleParameters', 'lateralTorqueParameters', 'modelV2', 'selfdriveState', + 'extrinsicsCalibration', 'deviceMotion', 'longitudinalPlan', 'lateralManeuverPlan', 'carState', 'carOutput', + 'driverMonitoringState', 'onroadEvents', 'driverAssistance'] + self.sm_services_ext, poll='selfdriveState') self.pm = messaging.PubMaster(['carControl', 'controlsState'] + self.pm_services_ext) @@ -72,17 +72,17 @@ class Controls(ControlsExt): def update(self): self.sm.update(15) - if self.sm.updated["liveCalibration"]: - self.pose_calibrator.feed_live_calib(self.sm['liveCalibration']) - if self.sm.updated["livePose"]: - device_pose = Pose.from_live_pose(self.sm['livePose']) - self.calibrated_pose = self.pose_calibrator.build_calibrated_pose(device_pose) + if self.sm.updated["extrinsicsCalibration"]: + self.pose_calibrator.feed_extrinsics_calibration(self.sm['extrinsicsCalibration']) + if self.sm.updated["deviceMotion"]: + device_motion = Pose.from_device_motion(self.sm['deviceMotion']) + self.calibrated_pose = self.pose_calibrator.build_calibrated_pose(device_motion) def state_control(self): CS = self.sm['carState'] # Update VehicleModel - lp = self.sm['liveParameters'] + lp = self.sm['vehicleParameters'] x = max(lp.stiffnessFactor, 0.1) sr = max(lp.steerRatio, 0.1) self.VM.update_params(x, sr) @@ -92,9 +92,9 @@ class Controls(ControlsExt): # Update Torque Params if self.CP.lateralTuning.which() == 'torque': - torque_params = self.sm['liveTorqueParameters'] - if self.sm.all_checks(['liveTorqueParameters']) and torque_params.useParams: - self.LaC.update_live_torque_params(torque_params.latAccelFactorFiltered, torque_params.latAccelOffsetFiltered, + torque_params = self.sm['lateralTorqueParameters'] + if self.sm.all_checks(['lateralTorqueParameters']) and torque_params.useParams: + self.LaC.update_torque_parameters(torque_params.latAccelFactorFiltered, torque_params.latAccelOffsetFiltered, torque_params.frictionCoefficientFiltered) self.LaC.extension.update_limits() @@ -144,7 +144,7 @@ class Controls(ControlsExt): else: new_desired_curvature = model_v2.action.desiredCurvature if CC.latActive else self.curvature self.desired_curvature, curvature_limited = clip_curvature(CS.vEgo, self.desired_curvature, new_desired_curvature, lp.roll) - lat_delay = self.sm["liveDelay"].lateralDelay + LAT_SMOOTH_SECONDS + lat_delay = self.sm["lateralDelay"].lateralDelay + LAT_SMOOTH_SECONDS actuators.curvature = self.desired_curvature steer, lateral_output, lac_log = self.LaC.update(CC.latActive, CS, self.VM, lp, diff --git a/openpilot/selfdrive/controls/lib/drive_helpers.py b/openpilot/selfdrive/controls/lib/drive_helpers.py index 497f8d1bdf..35ca45e56e 100644 --- a/openpilot/selfdrive/controls/lib/drive_helpers.py +++ b/openpilot/selfdrive/controls/lib/drive_helpers.py @@ -7,7 +7,6 @@ CONTROL_N = 17 CAR_ROTATION_RADIUS = 0.0 # This is a turn radius smaller than most cars can achieve MAX_CURVATURE = 0.2 -MAX_VEL_ERR = 5.0 # m/s MIN_STABLE_DELAY = 0.3 # EU guidelines @@ -16,7 +15,7 @@ MAX_LATERAL_ACCEL_NO_ROLL = 3.0 # m/s^2 def should_stop(v_ego: float, a_target: float) -> bool: - return bool(v_ego < 0.25 and a_target < 0.1) + return bool(v_ego < 0.3 and a_target < 0.1) def clamp(val, min_val, max_val): clamped_val = float(np.clip(val, min_val, max_val)) @@ -53,10 +52,8 @@ def get_accel_from_plan(speeds, accels, t_idxs, action_t=DT_MDL): v_target = np.interp(action_t, t_idxs, speeds) a_target = 2 * (v_target - v_now) / (action_t) - a_now else: - v_now = 0.0 - v_target = 0.0 a_target = 0.0 - return a_target, should_stop(v_now, a_target) + return a_target def curv_from_psis(psi_target, psi_rate, vego, action_t): vego = np.clip(vego, MIN_SPEED, np.inf) diff --git a/openpilot/selfdrive/controls/lib/latcontrol_torque.py b/openpilot/selfdrive/controls/lib/latcontrol_torque.py index 952234de99..6a9eebe867 100644 --- a/openpilot/selfdrive/controls/lib/latcontrol_torque.py +++ b/openpilot/selfdrive/controls/lib/latcontrol_torque.py @@ -50,7 +50,7 @@ class LatControlTorque(LatControl): self.extension = LatControlTorqueExt(self, CP, CP_SP, CI) - def update_live_torque_params(self, latAccelFactor, latAccelOffset, friction): + def update_torque_parameters(self, latAccelFactor, latAccelOffset, friction): self.torque_params.latAccelFactor = latAccelFactor self.torque_params.latAccelOffset = latAccelOffset self.torque_params.friction = friction diff --git a/openpilot/selfdrive/controls/lib/ldw.py b/openpilot/selfdrive/controls/lib/ldw.py index 6db59791cc..6164111190 100644 --- a/openpilot/selfdrive/controls/lib/ldw.py +++ b/openpilot/selfdrive/controls/lib/ldw.py @@ -1,5 +1,5 @@ from openpilot.cereal import log -from openpilot.common.realtime import DT_CTRL +from openpilot.common.realtime import DT_MDL from openpilot.common.constants import CV @@ -17,7 +17,7 @@ class LaneDepartureWarning: if CS.leftBlinker or CS.rightBlinker: self.last_blinker_frame = frame - recent_blinker = (frame - self.last_blinker_frame) * DT_CTRL < 5.0 # 5s blinker cooldown + recent_blinker = (frame - self.last_blinker_frame) * DT_MDL < 5.0 # 5s blinker cooldown ldw_allowed = CS.vEgo > LDW_MIN_SPEED and not recent_blinker and not CC.latActive desire_prediction = modelV2.meta.desirePrediction diff --git a/openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py b/openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py index de1ba0c278..0a0722fbc6 100755 --- a/openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py +++ b/openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py @@ -23,7 +23,7 @@ EXPORT_DIR = os.path.join(LONG_MPC_DIR, "c_generated_code") JSON_FILE = os.path.join(LONG_MPC_DIR, "acados_ocp_long.json") LongitudinalPlanSource = log.LongitudinalPlan.LongitudinalPlanSource -MPC_SOURCES = (LongitudinalPlanSource.lead0, LongitudinalPlanSource.lead1, LongitudinalPlanSource.cruise) +MPC_SOURCES = (LongitudinalPlanSource.lead0, LongitudinalPlanSource.lead1) X_DIM = 3 U_DIM = 1 @@ -55,8 +55,6 @@ FCW_IDXS = T_IDXS < 5.0 T_DIFFS = np.diff(T_IDXS, prepend=[0.]) COMFORT_BRAKE = 2.5 STOP_DISTANCE = 6.0 -CRUISE_MIN_ACCEL = -1.2 -CRUISE_MAX_ACCEL = 1.6 MIN_X_LEAD_FACTOR = 0.5 def get_jerk_factor(personality=log.LongitudinalPersonality.standard): @@ -104,7 +102,7 @@ def gen_long_model(): a_ego_dot = SX.sym('a_ego_dot') model.xdot = vertcat(x_ego_dot, v_ego_dot, a_ego_dot) - # live parameters + # runtime parameters a_min = SX.sym('a_min') a_max = SX.sym('a_max') x_obstacle = SX.sym('x_obstacle') @@ -309,9 +307,8 @@ class LongitudinalMpc: lead_xv = self.extrapolate_lead(x_lead, v_lead, a_lead, a_lead_tau) return lead_xv - def update(self, radarstate, v_cruise, personality=log.LongitudinalPersonality.standard): + def update(self, radarstate, personality=log.LongitudinalPersonality.standard): t_follow = get_T_FOLLOW(personality) - v_ego = self.x0[1] lead_xv_0 = self.process_lead(radarstate.leadOne) lead_xv_1 = self.process_lead(radarstate.leadTwo) @@ -322,15 +319,7 @@ class LongitudinalMpc: lead_0_obstacle = lead_xv_0[:,0] + get_stopped_equivalence_factor(lead_xv_0[:,1]) lead_1_obstacle = lead_xv_1[:,0] + get_stopped_equivalence_factor(lead_xv_1[:,1]) - # Fake an obstacle for cruise, this ensures smooth acceleration to set speed - # when the leads are no factor. - v_lower = v_ego + (T_IDXS * CRUISE_MIN_ACCEL * 1.05) - # TODO does this make sense when max_a is negative? - v_upper = v_ego + (T_IDXS * CRUISE_MAX_ACCEL * 1.05) - v_cruise_clipped = np.clip(v_cruise * np.ones(N+1), v_lower, v_upper) - cruise_obstacle = np.cumsum(T_DIFFS * v_cruise_clipped) + get_safe_obstacle_distance(v_cruise_clipped, t_follow) - - x_obstacles = np.column_stack([lead_0_obstacle, lead_1_obstacle, cruise_obstacle]) + x_obstacles = np.column_stack([lead_0_obstacle, lead_1_obstacle]) self.source = MPC_SOURCES[np.argmin(x_obstacles[0])] self.yref[:,:] = 0.0 diff --git a/openpilot/selfdrive/controls/lib/longitudinal_planner.py b/openpilot/selfdrive/controls/lib/longitudinal_planner.py index 7e3446a768..12b4f9da61 100755 --- a/openpilot/selfdrive/controls/lib/longitudinal_planner.py +++ b/openpilot/selfdrive/controls/lib/longitudinal_planner.py @@ -11,7 +11,7 @@ from openpilot.selfdrive.modeld.constants import ModelConstants from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalMpc, LongitudinalPlanSource from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import T_IDXS as T_IDXS_MPC -from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N, get_accel_from_plan +from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N, get_accel_from_plan, should_stop from openpilot.selfdrive.car.cruise import V_CRUISE_MAX, V_CRUISE_UNSET from openpilot.common.swaglog import cloudlog @@ -19,6 +19,8 @@ from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import Lon A_CRUISE_MAX_VALS = [1.6, 1.2, 0.8, 0.6] A_CRUISE_MAX_BP = [0., 10.0, 25., 40.] +J_CRUISE_VALS = [1.6, 1.2, 0.8, 0.6] +A_CRUISE_MIN = -1.2 CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N] ALLOW_THROTTLE_THRESHOLD = 0.4 MIN_ALLOW_THROTTLE_SPEED = 2.5 @@ -33,18 +35,25 @@ def get_max_accel(v_ego): def get_coast_accel(pitch): return np.sin(pitch) * -5.65 - 0.3 # fitted from data using xx/projects/allow_throttle/compute_coast_accel.py -def limit_accel_in_turns(v_ego, angle_steers, a_target, CP): - """ - This function returns a limited long acceleration allowed, depending on the existing lateral acceleration - this should avoid accelerating when losing the target in turns - """ - # FIXME: This function to calculate lateral accel is incorrect and should use the VehicleModel - # The lookup table for turns should also be updated if we do this - a_total_max = np.interp(v_ego, _A_TOTAL_MAX_BP, _A_TOTAL_MAX_V) - a_y = v_ego ** 2 * angle_steers * CV.DEG_TO_RAD / (CP.steerRatio * CP.wheelbase) - a_x_allowed = math.sqrt(max(a_total_max ** 2 - a_y ** 2, 0.)) +def get_cruise_accel(e2e, v_cruise, v_ego, a_cruise_prev, angle_steers, CP, dt, accel_coast, allow_throttle): + max_accel = ACCEL_MAX if e2e else get_max_accel(v_ego) - return [a_target[0], min(a_target[1], a_x_allowed)] + if not e2e: + a_total_max = np.interp(v_ego, _A_TOTAL_MAX_BP, _A_TOTAL_MAX_V) + a_y = v_ego ** 2 * angle_steers * CV.DEG_TO_RAD / (CP.steerRatio * CP.wheelbase) + a_x_allowed = math.sqrt(max(a_total_max ** 2 - a_y ** 2, 0.)) + max_accel = min(max_accel, a_x_allowed) + if not allow_throttle: + clipped_accel_coast = max(accel_coast, ACCEL_MIN) + coast_limit = np.interp(v_ego, [MIN_ALLOW_THROTTLE_SPEED, MIN_ALLOW_THROTTLE_SPEED*2], [max_accel, clipped_accel_coast]) + max_accel = min(max_accel, coast_limit) + + target_accel = np.clip(v_cruise - v_ego, A_CRUISE_MIN, max_accel) + if not e2e: + j_cruise = np.interp(v_ego, A_CRUISE_MAX_BP, J_CRUISE_VALS) + target_accel = float(np.clip(target_accel, a_cruise_prev - j_cruise * dt, a_cruise_prev + j_cruise * dt)) + + return target_accel class LongitudinalPlanner(LongitudinalPlannerSP): @@ -58,7 +67,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP): self.a_desired = init_a self.v_desired_filter = FirstOrderFilter(init_v, 2.0, self.dt) - self.prev_accel_clip = [ACCEL_MIN, ACCEL_MAX] + self.a_cruise = 0.0 self.output_a_target = 0.0 self.output_should_stop = False @@ -77,49 +86,39 @@ class LongitudinalPlanner(LongitudinalPlannerSP): v_ego = sm['carState'].vEgo v_cruise_kph = min(sm['carState'].vCruise, V_CRUISE_MAX) v_cruise = v_cruise_kph * CV.KPH_TO_MS - v_cruise_initialized = sm['carState'].vCruise != V_CRUISE_UNSET + if sm['controlsState'].forceDecel: + v_cruise = 0.0 long_control_off = sm['controlsState'].longControlState == LongCtrlState.off - force_slow_decel = sm['controlsState'].forceDecel # Reset current state when not engaged, or user is controlling the speed reset_state = long_control_off if self.CP.openpilotLongitudinalControl else not sm['selfdriveState'].enabled # PCM cruise speed may be updated a few cycles later, check if initialized + v_cruise_initialized = sm['carState'].vCruise != V_CRUISE_UNSET reset_state = reset_state or not v_cruise_initialized + throttle_probs = sm['modelV2'].meta.disengagePredictions.gasPressProbs + throttle_prob = throttle_probs[1] if len(throttle_probs) > 1 else 1.0 + self.allow_throttle = throttle_prob > ALLOW_THROTTLE_THRESHOLD or v_ego <= MIN_ALLOW_THROTTLE_SPEED + + steer_angle_without_offset = sm['carState'].steeringAngleDeg - sm['vehicleParameters'].angleOffsetDeg + + if reset_state: + self.v_desired_filter.x = v_ego + self.a_desired = np.clip(sm['carState'].aEgo, ACCEL_MIN, ACCEL_MAX) + + # Prevent divergence, smooth in current v_ego + self.v_desired_filter.x = max(0.0, self.v_desired_filter.update(v_ego)) + # No change cost when user is controlling the speed, or when standstill prev_accel_constraint = not (reset_state or sm['carState'].standstill) - accel_clip = [ACCEL_MIN, get_max_accel(v_ego)] - steer_angle_without_offset = sm['carState'].steeringAngleDeg - sm['liveParameters'].angleOffsetDeg - accel_clip = limit_accel_in_turns(v_ego, steer_angle_without_offset, accel_clip, self.CP) - - if reset_state: - self.v_desired_filter.x = v_ego - # Clip aEgo to cruise limits to prevent large accelerations when becoming active - self.a_desired = np.clip(sm['carState'].aEgo, accel_clip[0], accel_clip[1]) - - # Prevent divergence, smooth in current v_ego - self.v_desired_filter.x = max(0.0, self.v_desired_filter.update(v_ego)) - throttle_probs = sm['modelV2'].meta.disengagePredictions.gasPressProbs - throttle_prob = throttle_probs[1] if len(throttle_probs) > 1 else 1.0 - # Don't clip at low speeds since throttle_prob doesn't account for creep - self.allow_throttle = throttle_prob > ALLOW_THROTTLE_THRESHOLD or v_ego <= MIN_ALLOW_THROTTLE_SPEED - - if not self.allow_throttle: - clipped_accel_coast = max(accel_coast, accel_clip[0]) - clipped_accel_coast_interp = np.interp(v_ego, [MIN_ALLOW_THROTTLE_SPEED, MIN_ALLOW_THROTTLE_SPEED*2], [accel_clip[1], clipped_accel_coast]) - accel_clip[1] = min(accel_clip[1], clipped_accel_coast_interp) - # Get new v_cruise and a_desired from Smart Cruise Control and Speed Limit Assist v_cruise, self.a_desired = LongitudinalPlannerSP.update_targets(self, sm, self.v_desired_filter.x, self.a_desired, v_cruise) - if force_slow_decel: - v_cruise = 0.0 - self.mpc.set_weights(prev_accel_constraint, personality=sm['selfdriveState'].personality) self.mpc.set_cur_state(self.v_desired_filter.x, self.a_desired) - self.mpc.update(sm['radarState'], v_cruise, personality=sm['selfdriveState'].personality) + self.mpc.update(sm['radarState'], personality=sm['selfdriveState'].personality) self.v_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.v_solution) self.a_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.a_solution) @@ -130,14 +129,13 @@ class LongitudinalPlanner(LongitudinalPlannerSP): if self.fcw: cloudlog.info("FCW triggered") - # Interpolate 0.05 seconds and save as starting point for next iteration + # Save starting point for next iteration a_prev = self.a_desired - self.a_desired = float(np.interp(self.dt, CONTROL_N_T_IDX, self.a_desired_trajectory)) - self.v_desired_filter.x = self.v_desired_filter.x + self.dt * (self.a_desired + a_prev) / 2.0 action_t = self.CP.longitudinalActuatorDelay + DT_MDL - output_a_target_mpc, output_should_stop_mpc = get_accel_from_plan(self.v_desired_trajectory, self.a_desired_trajectory, CONTROL_N_T_IDX, - action_t=action_t) + output_a_target_mpc = get_accel_from_plan(self.v_desired_trajectory, self.a_desired_trajectory, CONTROL_N_T_IDX, + action_t=action_t) + output_should_stop_mpc = should_stop(v_ego, output_a_target_mpc) output_a_target_e2e = sm['modelV2'].action.desiredAcceleration output_should_stop_e2e = sm['modelV2'].action.shouldStop @@ -150,10 +148,22 @@ class LongitudinalPlanner(LongitudinalPlannerSP): output_a_target = output_a_target_mpc self.output_should_stop = output_should_stop_mpc - for idx in range(2): - accel_clip[idx] = np.clip(accel_clip[idx], self.prev_accel_clip[idx] - 0.05, self.prev_accel_clip[idx] + 0.05) - self.output_a_target = np.clip(output_a_target, accel_clip[0], accel_clip[1]) - self.prev_accel_clip = accel_clip + self.a_cruise = get_cruise_accel(sm['selfdriveState'].experimentalMode, v_cruise, v_ego, + self.a_cruise, steer_angle_without_offset, self.CP, self.dt, + accel_coast, self.allow_throttle) + cruise_should_stop = should_stop(v_ego, self.a_cruise) + + candidates = [(output_a_target_mpc, self.mpc.source, output_should_stop_mpc), + (self.a_cruise, LongitudinalPlanSource.cruise, cruise_should_stop)] + if sm['selfdriveState'].experimentalMode: + candidates.append((output_a_target_e2e, LongitudinalPlanSource.e2e, output_should_stop_e2e)) + + output_a_target, self.mpc.source, _ = min(candidates, key=lambda c: c[0]) + self.output_should_stop = any(should_stop for _, _, should_stop in candidates) + self.output_a_target = np.clip(output_a_target, ACCEL_MIN, ACCEL_MAX) + + self.a_desired = float(self.output_a_target) + self.v_desired_filter.x = self.v_desired_filter.x + self.dt * (self.output_a_target + a_prev) / 2.0 def publish(self, sm, pm): plan_send = messaging.new_message('longitudinalPlan') diff --git a/openpilot/selfdrive/controls/plannerd.py b/openpilot/selfdrive/controls/plannerd.py index 0af341b121..e7f36d3240 100755 --- a/openpilot/selfdrive/controls/plannerd.py +++ b/openpilot/selfdrive/controls/plannerd.py @@ -28,7 +28,7 @@ def main(): ldw = LaneDepartureWarning() longitudinal_planner = LongitudinalPlanner(CP, CP_SP) pm = messaging.PubMaster(['longitudinalPlan', 'driverAssistance', 'longitudinalPlanSP']) - sm = messaging.SubMaster(['carControl', 'carState', 'controlsState', 'liveParameters', 'radarState', 'modelV2', 'selfdriveState', + sm = messaging.SubMaster(['carControl', 'carState', 'controlsState', 'vehicleParameters', 'radarState', 'modelV2', 'selfdriveState', 'liveMapDataSP', 'carStateSP', 'selfdriveStateSP', gps_location_service], poll='modelV2', ignore_alive=ignore_services, ignore_avg_freq=ignore_services, ignore_valid=ignore_services) diff --git a/openpilot/selfdrive/controls/radard.py b/openpilot/selfdrive/controls/radard.py index 5db5865e75..56a193f833 100755 --- a/openpilot/selfdrive/controls/radard.py +++ b/openpilot/selfdrive/controls/radard.py @@ -197,7 +197,6 @@ class RadarD: self.CP_SP = CP_SP self.current_time = 0.0 - self.tracks: dict[int, Track] = {} self.kalman_params = KalmanParams(DT_MDL) self.lead_prob_filters = [FirstOrderFilter(0.0, 0.2, DT_MDL) for _ in range(2)] @@ -213,7 +212,6 @@ class RadarD: def update(self, sm: messaging.SubMaster, rr: car.RadarData): self.ready = sm.seen['modelV2'] - self.current_time = 1e-9*max(sm.logMonoTime.values()) if sm.recv_frame['carState'] != self.last_v_ego_frame: self.v_ego = sm['carState'].vEgo @@ -287,7 +285,7 @@ def main() -> None: cloudlog.info("radard got CarParamsSP") # *** setup messaging - sm = messaging.SubMaster(['modelV2', 'carState', 'liveTracks'], poll='modelV2') + sm = messaging.SubMaster(['modelV2', 'carState', 'radarTracks'], poll='modelV2') pm = messaging.PubMaster(['radarState']) RD = RadarD(CP, CP_SP, CP.radarDelay) @@ -295,7 +293,7 @@ def main() -> None: while 1: sm.update() - RD.update(sm, sm['liveTracks']) + RD.update(sm, sm['radarTracks']) RD.publish(pm) diff --git a/openpilot/selfdrive/controls/tests/test_following_distance.py b/openpilot/selfdrive/controls/tests/test_following_distance.py index fcabce0387..1914769282 100644 --- a/openpilot/selfdrive/controls/tests/test_following_distance.py +++ b/openpilot/selfdrive/controls/tests/test_following_distance.py @@ -1,5 +1,5 @@ -import pytest import itertools +from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized_class from openpilot.cereal import log @@ -36,11 +36,12 @@ def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personalit log.LongitudinalPersonality.standard, log.LongitudinalPersonality.aggressive], [0,10,35])) # speed -class TestFollowingDistance: +class TestFollowingDistance(OpenpilotTestCase): def test_following_distance(self): v_lead = float(self.speed) simulation_steady_state = run_following_distance_simulation(v_lead, e2e=self.e2e, personality=self.personality) correct_steady_state = desired_follow_distance(v_lead, v_lead, get_T_FOLLOW(self.personality)) err_ratio = 0.2 if self.e2e else 0.1 abs_err_margin = 0.5 if v_lead > 0.0 else 1.15 - assert simulation_steady_state == pytest.approx(correct_steady_state, abs=err_ratio * correct_steady_state + abs_err_margin) + self.assertAlmostEqual(simulation_steady_state, correct_steady_state, + delta=err_ratio * correct_steady_state + abs_err_margin) diff --git a/openpilot/selfdrive/controls/tests/test_latcontrol.py b/openpilot/selfdrive/controls/tests/test_latcontrol.py index 52e6912d6d..0dc26887e3 100644 --- a/openpilot/selfdrive/controls/tests/test_latcontrol.py +++ b/openpilot/selfdrive/controls/tests/test_latcontrol.py @@ -1,3 +1,4 @@ +from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized from openpilot.cereal import log @@ -14,11 +15,11 @@ from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle from openpilot.selfdrive.locationd.helpers import Pose -from openpilot.common.mock.generators import generate_livePose +from openpilot.common.mock.generators import generate_deviceMotion from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces -class TestLatControl: +class TestLatControl(OpenpilotTestCase): @parameterized.expand([(HONDA.HONDA_CIVIC, LatControlPID), (TOYOTA.TOYOTA_RAV4, LatControlTorque), (NISSAN.NISSAN_LEAF, LatControlAngle), (GM.CHEVROLET_BOLT_EUV, LatControlTorque)]) @@ -37,10 +38,10 @@ class TestLatControl: CS.vEgo = 30 CS.steeringPressed = False - params = log.LiveParametersData.new_message() + params = log.VehicleParameters.new_message() - lp = generate_livePose() - pose = Pose.from_live_pose(lp.livePose) + lp = generate_deviceMotion() + pose = Pose.from_device_motion(lp.deviceMotion) # Saturate for curvature limited and controller limited for _ in range(1000): diff --git a/openpilot/selfdrive/controls/tests/test_latcontrol_torque_buffer.py b/openpilot/selfdrive/controls/tests/test_latcontrol_torque_buffer.py index dc634f20fd..cc6b9aef16 100644 --- a/openpilot/selfdrive/controls/tests/test_latcontrol_torque_buffer.py +++ b/openpilot/selfdrive/controls/tests/test_latcontrol_torque_buffer.py @@ -1,3 +1,4 @@ +from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized from openpilot.cereal import log @@ -10,7 +11,7 @@ from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque, from openpilot.selfdrive.car.helpers import convert_to_capnp from openpilot.selfdrive.locationd.helpers import Pose -from openpilot.common.mock.generators import generate_livePose +from openpilot.common.mock.generators import generate_deviceMotion from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces def get_controller(car_name): @@ -24,7 +25,7 @@ def get_controller(car_name): controller = LatControlTorque(CP.as_reader(), CP_SP.as_reader(), CI, DT_CTRL) return controller, VM -class TestLatControlTorqueBuffer: +class TestLatControlTorqueBuffer(OpenpilotTestCase): @parameterized.expand([(TOYOTA.TOYOTA_COROLLA_TSS2,)]) def test_request_buffer_consistency(self, car_name): @@ -34,10 +35,10 @@ class TestLatControlTorqueBuffer: CS = car.CarState.new_message() CS.vEgo = 30 CS.steeringPressed = False - params = log.LiveParametersData.new_message() + params = log.VehicleParameters.new_message() - lp = generate_livePose() - pose = Pose.from_live_pose(lp.livePose) + lp = generate_deviceMotion() + pose = Pose.from_device_motion(lp.deviceMotion) for _ in range(buffer_steps): controller.update(True, CS, VM, params, False, 0.001, pose, False, 0.2) diff --git a/openpilot/selfdrive/controls/tests/test_leads.py b/openpilot/selfdrive/controls/tests/test_leads.py index 1956bb34ec..45257c545b 100644 --- a/openpilot/selfdrive/controls/tests/test_leads.py +++ b/openpilot/selfdrive/controls/tests/test_leads.py @@ -1,10 +1,11 @@ +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from opendbc.car.toyota.values import CAR as TOYOTA from openpilot.selfdrive.test.process_replay import replay_process_with_name -class TestLeads: +class TestLeads(OpenpilotTestCase): def test_radar_fault(self): # if there's no radar-related can traffic, radard should either not respond or respond with an error # this is tightly coupled with underlying car radar_interface implementation, but it's a good sanity check @@ -25,7 +26,7 @@ class TestLeads: msgs = [m for _ in range(3) for m in single_iter_pkg()] out = replay_process_with_name("card", msgs, fingerprint=TOYOTA.TOYOTA_COROLLA_TSS2) - states = [m for m in out if m.which() == "liveTracks"] + states = [m for m in out if m.which() == "radarTracks"] failures = [not state.valid for state in states] assert len(states) == 0 or all(failures) diff --git a/openpilot/selfdrive/controls/tests/test_longcontrol.py b/openpilot/selfdrive/controls/tests/test_longcontrol.py index b2906b4574..9469545204 100644 --- a/openpilot/selfdrive/controls/tests/test_longcontrol.py +++ b/openpilot/selfdrive/controls/tests/test_longcontrol.py @@ -1,8 +1,9 @@ +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import custom from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState, long_control_state_trans -class TestLongControlStateTransition: +class TestLongControlStateTransition(OpenpilotTestCase): def test_stay_stopped(self): CP_SP = custom.CarParamsSP.new_message() @@ -25,19 +26,19 @@ class TestLongControlStateTransition: should_stop=False, brake_pressed=False, cruise_standstill=False) assert next_state == LongCtrlState.off -def test_engage(): - CP_SP = custom.CarParamsSP.new_message() - active = True - current_state = LongCtrlState.off - next_state = long_control_state_trans(CP_SP, active, current_state, + def test_engage(self): + CP_SP = custom.CarParamsSP.new_message() + active = True + current_state = LongCtrlState.off + next_state = long_control_state_trans(CP_SP, active, current_state, should_stop=True, brake_pressed=False, cruise_standstill=False) - assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP_SP, active, current_state, + assert next_state == LongCtrlState.stopping + next_state = long_control_state_trans(CP_SP, active, current_state, should_stop=False, brake_pressed=True, cruise_standstill=False) - assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP_SP, active, current_state, + assert next_state == LongCtrlState.stopping + next_state = long_control_state_trans(CP_SP, active, current_state, should_stop=False, brake_pressed=False, cruise_standstill=True) - assert next_state == LongCtrlState.stopping - next_state = long_control_state_trans(CP_SP, active, current_state, + assert next_state == LongCtrlState.stopping + next_state = long_control_state_trans(CP_SP, active, current_state, should_stop=False, brake_pressed=False, cruise_standstill=False) - assert next_state == LongCtrlState.pid + assert next_state == LongCtrlState.pid diff --git a/openpilot/selfdrive/controls/tests/test_torqued_lat_accel_offset.py b/openpilot/selfdrive/controls/tests/test_torqued_lat_accel_offset.py index 7599eb1e7b..011713dfb3 100644 --- a/openpilot/selfdrive/controls/tests/test_torqued_lat_accel_offset.py +++ b/openpilot/selfdrive/controls/tests/test_torqued_lat_accel_offset.py @@ -1,4 +1,5 @@ import numpy as np +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import messaging from opendbc.car.structs import car from opendbc.car import ACCELERATION_DUE_TO_GRAVITY @@ -40,7 +41,7 @@ def simulate_straight_road_msgs(est): carControl = messaging.new_message('carControl').carControl carOutput = messaging.new_message('carOutput').carOutput carState = messaging.new_message('carState').carState - livePose = messaging.new_message('livePose').livePose + deviceMotion = messaging.new_message('deviceMotion').deviceMotion carControl.latActive = True carState.vEgo = V_EGO carState.steeringPressed = False @@ -49,24 +50,24 @@ def simulate_straight_road_msgs(est): lat_accels = TORQUE_TUNE.latAccelFactor * steer_torques for t, steer_torque, lat_accel in zip(ts, steer_torques, lat_accels, strict=True): carOutput.actuatorsOutput.torque = float(-steer_torque) - livePose.orientationNED = {'x': float(np.deg2rad(ROLL_BIAS_DEG)), 'valid': True} - livePose.angularVelocityDevice = {'z': float(lat_accel / V_EGO), 'valid': True} - livePose.inputsOK, livePose.sensorsOK, livePose.posenetOK = True, True, True - livePose.timestamp = int(t * 1e9) - for which, msg in (('carControl', carControl), ('carOutput', carOutput), ('carState', carState), ('livePose', livePose)): + deviceMotion.orientationNED = {'x': float(np.deg2rad(ROLL_BIAS_DEG)), 'valid': True} + deviceMotion.angularVelocityDevice = {'z': float(lat_accel / V_EGO), 'valid': True} + deviceMotion.inputsOK, deviceMotion.sensorsOK, deviceMotion.posenetOK = True, True, True + deviceMotion.timestamp = int(t * 1e9) + for which, msg in (('carControl', carControl), ('carOutput', carOutput), ('carState', carState), ('deviceMotion', deviceMotion)): est.handle_log(t, which, msg) -class TestTorquedLatAccelOffset: +class TestTorquedLatAccelOffset(OpenpilotTestCase): def test_estimated_offset(self): steer_torques, lat_accels = generate_inputs(TORQUE_TUNE_BIASED, la_err_std=LA_ERR_STD, input_noise_std=INPUT_NOISE_STD) est = get_warmed_up_estimator(steer_torques, lat_accels) msg = est.get_msg() # TODO add lataccelfactor and friction check when we have more accurate estimates - assert abs(msg.liveTorqueParameters.latAccelOffsetRaw - TORQUE_TUNE_BIASED.latAccelOffset) < 0.1 + assert abs(msg.lateralTorqueParameters.latAccelOffsetRaw - TORQUE_TUNE_BIASED.latAccelOffset) < 0.1 def test_straight_road_roll_bias(self): steer_torques, lat_accels = generate_inputs(TORQUE_TUNE, la_err_std=LA_ERR_STD, input_noise_std=INPUT_NOISE_STD) est = get_warmed_up_estimator(steer_torques, lat_accels) simulate_straight_road_msgs(est) msg = est.get_msg() - assert (msg.liveTorqueParameters.latAccelOffsetRaw < -0.05) and np.isfinite(msg.liveTorqueParameters.latAccelOffsetRaw) + assert (msg.lateralTorqueParameters.latAccelOffsetRaw < -0.05) and np.isfinite(msg.lateralTorqueParameters.latAccelOffsetRaw) diff --git a/openpilot/selfdrive/locationd/calibrationd.py b/openpilot/selfdrive/locationd/calibrationd.py index b9616bf632..ec9a225634 100755 --- a/openpilot/selfdrive/locationd/calibrationd.py +++ b/openpilot/selfdrive/locationd/calibrationd.py @@ -74,15 +74,15 @@ class Calibrator: wide_from_device_euler = WIDE_FROM_DEVICE_EULER_INIT height = HEIGHT_INIT valid_blocks = 0 - self.cal_status = log.LiveCalibrationData.Status.uncalibrated + self.cal_status = log.ExtrinsicsCalibration.Status.uncalibrated if param_put and calibration_params: try: with log.Event.from_bytes(calibration_params) as msg: - rpy_init = np.array(msg.liveCalibration.rpyCalib) - valid_blocks = msg.liveCalibration.validBlocks - wide_from_device_euler = np.array(msg.liveCalibration.wideFromDeviceEuler) - height = np.array(msg.liveCalibration.height) + rpy_init = np.array(msg.extrinsicsCalibration.rpyCalib) + valid_blocks = msg.extrinsicsCalibration.validBlocks + wide_from_device_euler = np.array(msg.extrinsicsCalibration.wideFromDeviceEuler) + height = np.array(msg.extrinsicsCalibration.height) except Exception: cloudlog.exception("Error reading cached CalibrationParams") @@ -149,22 +149,22 @@ class Calibrator: self.calib_spread = np.zeros(3) if self.valid_blocks < INPUTS_NEEDED: - if self.cal_status == log.LiveCalibrationData.Status.recalibrating: - self.cal_status = log.LiveCalibrationData.Status.recalibrating + if self.cal_status == log.ExtrinsicsCalibration.Status.recalibrating: + self.cal_status = log.ExtrinsicsCalibration.Status.recalibrating else: - self.cal_status = log.LiveCalibrationData.Status.uncalibrated + self.cal_status = log.ExtrinsicsCalibration.Status.uncalibrated elif is_calibration_valid(self.rpy): - self.cal_status = log.LiveCalibrationData.Status.calibrated + self.cal_status = log.ExtrinsicsCalibration.Status.calibrated else: - self.cal_status = log.LiveCalibrationData.Status.invalid + self.cal_status = log.ExtrinsicsCalibration.Status.invalid # If spread is too high, assume mounting was changed and reset to last block. # Make the transition smooth. Abrupt transitions are not good for feedback loop through supercombo model. # TODO: add height spread check with smooth transition too spread_too_high = self.calib_spread[1] > MAX_ALLOWED_PITCH_SPREAD or self.calib_spread[2] > MAX_ALLOWED_YAW_SPREAD - if spread_too_high and self.cal_status == log.LiveCalibrationData.Status.calibrated: + if spread_too_high and self.cal_status == log.ExtrinsicsCalibration.Status.calibrated: self.reset(self.rpys[self.block_idx - 1], valid_blocks=1, smooth_from=self.rpy) - self.cal_status = log.LiveCalibrationData.Status.recalibrating + self.cal_status = log.ExtrinsicsCalibration.Status.recalibrating write_this_cycle = (self.idx == 0) and (self.block_idx % (INPUTS_WANTED//5) == 5) if self.param_put and write_this_cycle: @@ -234,35 +234,35 @@ class Calibrator: def get_msg(self, valid: bool) -> capnp.lib.capnp._DynamicStructBuilder: smooth_rpy = self.get_smooth_rpy() - msg = messaging.new_message('liveCalibration') + msg = messaging.new_message('extrinsicsCalibration') msg.valid = valid - liveCalibration = msg.liveCalibration - liveCalibration.validBlocks = self.valid_blocks - liveCalibration.calStatus = self.cal_status - liveCalibration.calPerc = min(100 * (self.valid_blocks * BLOCK_SIZE + self.idx) // (INPUTS_NEEDED * BLOCK_SIZE), 100) - liveCalibration.rpyCalib = smooth_rpy.tolist() - liveCalibration.rpyCalibSpread = self.calib_spread.tolist() - liveCalibration.wideFromDeviceEuler = self.wide_from_device_euler.tolist() - liveCalibration.height = self.height.tolist() + extrinsicsCalibration = msg.extrinsicsCalibration + extrinsicsCalibration.validBlocks = self.valid_blocks + extrinsicsCalibration.calStatus = self.cal_status + extrinsicsCalibration.calPerc = min(100 * (self.valid_blocks * BLOCK_SIZE + self.idx) // (INPUTS_NEEDED * BLOCK_SIZE), 100) + extrinsicsCalibration.rpyCalib = smooth_rpy.tolist() + extrinsicsCalibration.rpyCalibSpread = self.calib_spread.tolist() + extrinsicsCalibration.wideFromDeviceEuler = self.wide_from_device_euler.tolist() + extrinsicsCalibration.height = self.height.tolist() if self.not_car: - liveCalibration.validBlocks = INPUTS_NEEDED - liveCalibration.calStatus = log.LiveCalibrationData.Status.calibrated - liveCalibration.calPerc = 100. - liveCalibration.rpyCalib = [0, 0, 0] - liveCalibration.rpyCalibSpread = self.calib_spread.tolist() + extrinsicsCalibration.validBlocks = INPUTS_NEEDED + extrinsicsCalibration.calStatus = log.ExtrinsicsCalibration.Status.calibrated + extrinsicsCalibration.calPerc = 100. + extrinsicsCalibration.rpyCalib = [0, 0, 0] + extrinsicsCalibration.rpyCalibSpread = self.calib_spread.tolist() return msg def send_data(self, pm: messaging.PubMaster, valid: bool) -> None: - pm.send('liveCalibration', self.get_msg(valid)) + pm.send('extrinsicsCalibration', self.get_msg(valid)) def main() -> NoReturn: config_realtime_process([0, 1, 2, 3], 5) - pm = messaging.PubMaster(['liveCalibration']) + pm = messaging.PubMaster(['extrinsicsCalibration']) sm = messaging.SubMaster(['cameraOdometry', 'carState'], poll='cameraOdometry') params_reader = Params() diff --git a/openpilot/selfdrive/locationd/helpers.py b/openpilot/selfdrive/locationd/helpers.py index 5e3fa16027..48e1ee25c2 100644 --- a/openpilot/selfdrive/locationd/helpers.py +++ b/openpilot/selfdrive/locationd/helpers.py @@ -9,29 +9,29 @@ from openpilot.common.transformations.orientation import rot_from_euler, euler_f @cache def fft_next_good_size(n: int) -> int: - """ - smallest composite of 2, 3, 5, 7, 11 that is >= n - inspired by pocketfft - """ - if n <= 6: - return n - best, f2 = 2 * n, 1 - while f2 < best: - f23 = f2 - while f23 < best: - f235 = f23 - while f235 < best: - f2357 = f235 - while f2357 < best: - f235711 = f2357 - while f235711 < best: - best = f235711 if f235711 >= n else best - f235711 *= 11 - f2357 *= 7 - f235 *= 5 - f23 *= 3 - f2 *= 2 - return best + """ + smallest composite of 2, 3, 5, 7, 11 that is >= n + inspired by pocketfft + """ + if n <= 6: + return n + best, f2 = 2 * n, 1 + while f2 < best: + f23 = f2 + while f23 < best: + f235 = f23 + while f235 < best: + f2357 = f235 + while f2357 < best: + f235711 = f2357 + while f235711 < best: + best = f235711 if f235711 >= n else best + f235711 *= 11 + f2357 *= 7 + f235 *= 5 + f23 *= 3 + f2 *= 2 + return best def parabolic_peak_interp(R, max_index): @@ -130,7 +130,7 @@ class Measurement: self.xyz_std: np.ndarray = xyz_std @classmethod - def from_measurement_xyz(cls, measurement: log.LivePose.XYZMeasurement) -> 'Measurement': + def from_measurement_xyz(cls, measurement: log.DeviceMotion.XYZMeasurement) -> 'Measurement': return cls( xyz=np.array([measurement.x, measurement.y, measurement.z]), xyz_std=np.array([measurement.xStd, measurement.yStd, measurement.zStd]) @@ -145,12 +145,12 @@ class Pose: self.angular_velocity = angular_velocity @classmethod - def from_live_pose(cls, live_pose: log.LivePose) -> 'Pose': + def from_device_motion(cls, device_motion: log.DeviceMotion) -> 'Pose': return Pose( - orientation=Measurement.from_measurement_xyz(live_pose.orientationNED), - velocity=Measurement.from_measurement_xyz(live_pose.velocityDevice), - acceleration=Measurement.from_measurement_xyz(live_pose.accelerationDevice), - angular_velocity=Measurement.from_measurement_xyz(live_pose.angularVelocityDevice) + orientation=Measurement.from_measurement_xyz(device_motion.orientationNED), + velocity=Measurement.from_measurement_xyz(device_motion.velocityDevice), + acceleration=Measurement.from_measurement_xyz(device_motion.accelerationDevice), + angular_velocity=Measurement.from_measurement_xyz(device_motion.angularVelocityDevice) ) @@ -178,8 +178,8 @@ class PoseCalibrator: return Pose(ned_from_calib_euler, velocity_calib, acceleration_calib, angular_velocity_calib) - def feed_live_calib(self, live_calib: log.LiveCalibrationData): - calib_rpy = np.array(live_calib.rpyCalib) + def feed_extrinsics_calibration(self, extrinsics_calibration: log.ExtrinsicsCalibration): + calib_rpy = np.array(extrinsics_calibration.rpyCalib) device_from_calib = rot_from_euler(calib_rpy) self.calib_from_device = device_from_calib.T - self.calib_valid = live_calib.calStatus == log.LiveCalibrationData.Status.calibrated + self.calib_valid = extrinsics_calibration.calStatus == log.ExtrinsicsCalibration.Status.calibrated diff --git a/openpilot/selfdrive/locationd/lagd.py b/openpilot/selfdrive/locationd/lagd.py index da2c53fd11..c80a18db16 100755 --- a/openpilot/selfdrive/locationd/lagd.py +++ b/openpilot/selfdrive/locationd/lagd.py @@ -172,7 +172,7 @@ class BlockAverage: class LateralLagEstimator: - inputs = {"carControl", "carState", "controlsState", "liveCalibration", "livePose"} + inputs = {"carControl", "carState", "controlsState", "extrinsicsCalibration", "deviceMotion"} def __init__(self, CP: car.CarParams, dt: float, block_count: int = BLOCK_NUM, min_valid_block_count: int = BLOCK_NUM_NEEDED, block_size: int = BLOCK_SIZE, @@ -220,39 +220,39 @@ class LateralLagEstimator: self.block_avg = BlockAverage(self.block_count, self.block_size, valid_blocks, initial_lag) def get_msg(self, valid: bool, debug: bool = False) -> capnp._DynamicStructBuilder: - msg = messaging.new_message('liveDelay') + msg = messaging.new_message('lateralDelay') msg.valid = valid - liveDelay = msg.liveDelay + lateralDelay = msg.lateralDelay valid_mean_lag, valid_std, current_mean_lag, current_std = self.block_avg.get() if self.block_avg.valid_blocks >= self.min_valid_block_count and not np.isnan(valid_mean_lag) and not np.isnan(valid_std): if valid_std > MAX_LAG_STD: - liveDelay.status = log.LiveDelayData.Status.invalid + lateralDelay.status = log.LateralDelay.Status.invalid else: - liveDelay.status = log.LiveDelayData.Status.estimated + lateralDelay.status = log.LateralDelay.Status.estimated else: - liveDelay.status = log.LiveDelayData.Status.unestimated + lateralDelay.status = log.LateralDelay.Status.unestimated - if liveDelay.status == log.LiveDelayData.Status.estimated: - liveDelay.lateralDelay = min(MAX_LAG, max(MIN_LAG, valid_mean_lag)) + if lateralDelay.status == log.LateralDelay.Status.estimated: + lateralDelay.lateralDelay = min(MAX_LAG, max(MIN_LAG, valid_mean_lag)) else: - liveDelay.lateralDelay = self.initial_lag + lateralDelay.lateralDelay = self.initial_lag if not np.isnan(current_mean_lag) and not np.isnan(current_std): - liveDelay.lateralDelayEstimate = current_mean_lag - liveDelay.lateralDelayEstimateStd = current_std + lateralDelay.lateralDelayEstimate = current_mean_lag + lateralDelay.lateralDelayEstimateStd = current_std else: - liveDelay.lateralDelayEstimate = self.initial_lag - liveDelay.lateralDelayEstimateStd = 0.0 + lateralDelay.lateralDelayEstimate = self.initial_lag + lateralDelay.lateralDelayEstimateStd = 0.0 - liveDelay.validBlocks = self.block_avg.valid_blocks - liveDelay.calPerc = min(100 * (self.block_avg.valid_blocks * self.block_size + self.block_avg.idx) // + lateralDelay.validBlocks = self.block_avg.valid_blocks + lateralDelay.calPerc = min(100 * (self.block_avg.valid_blocks * self.block_size + self.block_avg.idx) // (self.min_valid_block_count * self.block_size), 100) if debug: - liveDelay.points = self.block_avg.values.flatten().tolist() - liveDelay.version = VERSION + lateralDelay.points = self.block_avg.values.flatten().tolist() + lateralDelay.version = VERSION return msg @@ -265,11 +265,11 @@ class LateralLagEstimator: elif which == "controlsState": self.steering_saturated = getattr(msg.lateralControlState, msg.lateralControlState.which()).saturated self.desired_curvature = msg.desiredCurvature - elif which == "liveCalibration": - self.calibrator.feed_live_calib(msg) - elif which == "livePose": - device_pose = Pose.from_live_pose(msg) - calibrated_pose = self.calibrator.build_calibrated_pose(device_pose) + elif which == "extrinsicsCalibration": + self.calibrator.feed_extrinsics_calibration(msg) + elif which == "deviceMotion": + device_motion = Pose.from_device_motion(msg) + calibrated_pose = self.calibrator.build_calibrated_pose(device_motion) self.yaw_rate = calibrated_pose.angular_velocity.yaw self.yaw_rate_std = calibrated_pose.angular_velocity.yaw_std self.pose_valid = msg.angularVelocityDevice.valid and msg.posenetOK and msg.inputsOK @@ -369,13 +369,13 @@ def retrieve_initial_lag(params: Params, CP: car.CarParams): if last_lag_data is not None: try: with log.Event.from_bytes(last_lag_data) as last_lag_msg, car.CarParams.from_bytes(last_carparams_data) as last_CP: - ld = last_lag_msg.liveDelay + ld = last_lag_msg.lateralDelay if last_CP.carFingerprint != CP.carFingerprint: raise Exception("Car model mismatch") lag, valid_blocks, status, version = ld.lateralDelayEstimate, ld.validBlocks, ld.status, ld.version assert valid_blocks <= BLOCK_NUM, "Invalid number of valid blocks" - assert status != log.LiveDelayData.Status.invalid, "Lag estimate is invalid" + assert status != log.LateralDelay.Status.invalid, "Lag estimate is invalid" assert version == VERSION, f"Lag estimate is from a different version (got {version}, expected {VERSION})" return lag, valid_blocks except Exception as e: @@ -390,13 +390,13 @@ def main(): DEBUG = bool(int(os.getenv("DEBUG", "0"))) - pm = messaging.PubMaster(['liveDelay']) - sm = messaging.SubMaster(['livePose', 'liveCalibration', 'carState', 'controlsState', 'carControl'], poll='livePose') + pm = messaging.PubMaster(['lateralDelay']) + sm = messaging.SubMaster(['deviceMotion', 'extrinsicsCalibration', 'carState', 'controlsState', 'carControl'], poll='deviceMotion') params = Params() CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) - lag_learner = LateralLagEstimator(CP, 1. / SERVICE_LIST['livePose'].frequency) + lag_learner = LateralLagEstimator(CP, 1. / SERVICE_LIST['deviceMotion'].frequency) if (initial_lag_params := retrieve_initial_lag(params, CP)) is not None: lag, valid_blocks = initial_lag_params lag_learner.reset(lag, valid_blocks) @@ -412,12 +412,12 @@ def main(): lag_learner.handle_log(t, which, sm[which]) lag_learner.update_points() - # 4Hz driven by livePose + # 4Hz driven by deviceMotion if sm.frame % 5 == 0: lag_learner.update_estimate() lag_msg = lag_learner.get_msg(sm.all_checks(), DEBUG) lag_msg_dat = lag_msg.to_bytes() - pm.send('liveDelay', lag_msg_dat) + pm.send('lateralDelay', lag_msg_dat) if sm.frame % 1200 == 0: # cache every 60 seconds params.put("LiveDelay", lag_msg_dat) diff --git a/openpilot/selfdrive/locationd/locationd.py b/openpilot/selfdrive/locationd/locationd.py index eb3c42fce2..9fbec991d5 100755 --- a/openpilot/selfdrive/locationd/locationd.py +++ b/openpilot/selfdrive/locationd/locationd.py @@ -148,7 +148,7 @@ class LocationEstimator: elif which == "carState": self.car_speed = abs(msg.vEgo) - elif which == "liveCalibration": + elif which == "extrinsicsCalibration": # Note that we use this message during calibration if len(msg.rpyCalib) > 0: calib = np.array(msg.rpyCalib) @@ -217,19 +217,19 @@ class LocationEstimator: angular_velocity_device, angular_velocity_device_std = state[States.ANGULAR_VELOCITY], std[States.ANGULAR_VELOCITY] acceleration_device, acceleration_device_std = state[States.ACCELERATION], std[States.ACCELERATION] - msg = messaging.new_message("livePose") + msg = messaging.new_message("deviceMotion") msg.valid = filter_valid - livePose = msg.livePose - init_xyz_measurement(livePose.orientationNED, orientation_ned, orientation_ned_std, filter_valid) - init_xyz_measurement(livePose.velocityDevice, velocity_device, velocity_device_std, filter_valid) - init_xyz_measurement(livePose.angularVelocityDevice, angular_velocity_device, angular_velocity_device_std, filter_valid) - init_xyz_measurement(livePose.accelerationDevice, acceleration_device, acceleration_device_std, filter_valid) + deviceMotion = msg.deviceMotion + init_xyz_measurement(deviceMotion.orientationNED, orientation_ned, orientation_ned_std, filter_valid) + init_xyz_measurement(deviceMotion.velocityDevice, velocity_device, velocity_device_std, filter_valid) + init_xyz_measurement(deviceMotion.angularVelocityDevice, angular_velocity_device, angular_velocity_device_std, filter_valid) + init_xyz_measurement(deviceMotion.accelerationDevice, acceleration_device, acceleration_device_std, filter_valid) if self.debug: - livePose.debugFilterState.value = state.tolist() - livePose.debugFilterState.std = std.tolist() - livePose.debugFilterState.valid = filter_valid - livePose.debugFilterState.observations = [ + deviceMotion.debugFilterState.value = state.tolist() + deviceMotion.debugFilterState.std = std.tolist() + deviceMotion.debugFilterState.valid = filter_valid + deviceMotion.debugFilterState.observations = [ {'kind': k, 'value': self.observations[k].tolist(), 'error': self.observation_errors[k].tolist()} for k in self.observations.keys() ] @@ -238,10 +238,10 @@ class LocationEstimator: new_mean = np.mean(self.posenet_stds[POSENET_STD_HIST_HALF:]) std_spike = (new_mean / old_mean) > 4.0 and new_mean > 7.0 - livePose.inputsOK = inputs_valid - livePose.posenetOK = not std_spike or self.car_speed <= 5.0 - livePose.sensorsOK = sensors_valid - livePose.timestamp = int(np.nan_to_num(self.kf.t) * 1e9) + deviceMotion.inputsOK = inputs_valid + deviceMotion.posenetOK = not std_spike or self.car_speed <= 5.0 + deviceMotion.sensorsOK = sensors_valid + deviceMotion.timestamp = int(np.nan_to_num(self.kf.t) * 1e9) return msg @@ -267,8 +267,8 @@ def main(): DEBUG = bool(int(os.getenv("DEBUG", "0"))) SIMULATION = bool(int(os.getenv("SIMULATION", "0"))) - pm = messaging.PubMaster(['livePose']) - sm = messaging.SubMaster(['carState', 'liveCalibration', 'cameraOdometry'], poll='cameraOdometry') + pm = messaging.PubMaster(['deviceMotion']) + sm = messaging.SubMaster(['carState', 'extrinsicsCalibration', 'cameraOdometry'], poll='cameraOdometry') # separate sensor sockets for efficiency sensor_sockets = [messaging.sub_sock(which, timeout=20) for which in ['accelerometer', 'gyroscope']] sensor_alive, sensor_valid, sensor_recv_time = defaultdict(bool), defaultdict(bool), defaultdict(float) @@ -288,7 +288,7 @@ def main(): initial_pose_data = params.get("LocationFilterInitialState") if initial_pose_data is not None: with log.Event.from_bytes(initial_pose_data) as lp_msg: - filter_state = lp_msg.livePose.debugFilterState + filter_state = lp_msg.deviceMotion.debugFilterState x_initial = np.array(filter_state.value, dtype=np.float64) if len(filter_state.value) != 0 else PoseKalman.initial_x P_initial = np.diag(np.array(filter_state.std, dtype=np.float64)) if len(filter_state.std) != 0 else PoseKalman.initial_P estimator.reset(None, x_initial, P_initial) @@ -333,7 +333,7 @@ def main(): sensors_valid = sensor_all_checks(acc_msgs, gyro_msgs, sensor_valid, sensor_recv_time, sensor_alive, SIMULATION) msg = estimator.get_msg(sensors_valid, inputs_valid, filter_initialized) - pm.send("livePose", msg) + pm.send("deviceMotion", msg) if __name__ == "__main__": diff --git a/openpilot/selfdrive/locationd/paramsd.py b/openpilot/selfdrive/locationd/paramsd.py index c1088a8180..760176bc5c 100755 --- a/openpilot/selfdrive/locationd/paramsd.py +++ b/openpilot/selfdrive/locationd/paramsd.py @@ -65,10 +65,10 @@ class VehicleParamsLearner: self.avg_angle_offset = self.angle_offset def handle_log(self, t: float, which: str, msg: capnp._DynamicStructReader): - if which == 'livePose': + if which == 'deviceMotion': t = msg.timestamp * 1e-9 - device_pose = Pose.from_live_pose(msg) - calibrated_pose = self.calibrator.build_calibrated_pose(device_pose) + device_motion = Pose.from_device_motion(msg) + calibrated_pose = self.calibrator.build_calibrated_pose(device_motion) yaw_rate, yaw_rate_std = calibrated_pose.angular_velocity.z, calibrated_pose.angular_velocity.z_std yaw_rate_valid = msg.angularVelocityDevice.valid @@ -79,7 +79,7 @@ class VehicleParamsLearner: yaw_rate, yaw_rate_std = 0.0, np.radians(10.0) self.observed_yaw_rate = yaw_rate - localizer_roll, localizer_roll_std = device_pose.orientation.x, device_pose.orientation.x_std + localizer_roll, localizer_roll_std = device_motion.orientation.x, device_motion.orientation.x_std localizer_roll_std = np.radians(1) if np.isnan(localizer_roll_std) else localizer_roll_std roll_valid = (localizer_roll_std < ROLL_STD_MAX) and (ROLL_MIN < localizer_roll < ROLL_MAX) and msg.sensorsOK if roll_valid: @@ -113,15 +113,15 @@ class VehicleParamsLearner: self.kf.predict_and_observe(t, ObservationKind.STIFFNESS, np.array([[stiffness]])) self.kf.predict_and_observe(t, ObservationKind.STEER_RATIO, np.array([[steer_ratio]])) - elif which == 'liveCalibration': - self.calibrator.feed_live_calib(msg) + elif which == 'extrinsicsCalibration': + self.calibrator.feed_extrinsics_calibration(msg) elif which == 'carState': steering_angle = msg.steeringAngleDeg in_linear_region = abs(steering_angle) < 45 self.observed_speed = msg.vEgo - self.active = self.observed_speed > MIN_ACTIVE_SPEED and in_linear_region + self.active = self.observed_speed > MIN_ACTIVE_SPEED and in_linear_region and msg.gearShifter != car.CarState.GearShifter.reverse if self.active: self.kf.predict_and_observe(t, ObservationKind.STEER_ANGLE, np.array([[np.radians(steering_angle)]])) @@ -136,7 +136,7 @@ class VehicleParamsLearner: x = self.kf.x P = np.sqrt(self.kf.P.diagonal()) if not np.all(np.isfinite(x)): - cloudlog.error("NaN in liveParameters estimate. Resetting to default values") + cloudlog.error("NaN in vehicleParameters estimate. Resetting to default values") self.reset(self.kf.t) x = self.kf.x @@ -156,38 +156,38 @@ class VehicleParamsLearner: self.total_offset_valid = check_valid_with_hysteresis(self.total_offset_valid, self.angle_offset, OFFSET_MAX, OFFSET_LOWERED_MAX) self.roll_valid = check_valid_with_hysteresis(self.roll_valid, self.roll, ROLL_MAX, ROLL_LOWERED_MAX) - msg = messaging.new_message('liveParameters') + msg = messaging.new_message('vehicleParameters') msg.valid = valid - liveParameters = msg.liveParameters - liveParameters.posenetValid = True - liveParameters.sensorValid = sensors_valid - liveParameters.steerRatio = float(x[States.STEER_RATIO].item()) - liveParameters.stiffnessFactor = float(x[States.STIFFNESS].item()) - liveParameters.roll = float(self.roll) - liveParameters.angleOffsetAverageDeg = float(self.avg_angle_offset) - liveParameters.angleOffsetDeg = float(self.angle_offset) - liveParameters.steerRatioValid = self.min_sr <= liveParameters.steerRatio <= self.max_sr - liveParameters.stiffnessFactorValid = 0.2 <= liveParameters.stiffnessFactor <= 5.0 - liveParameters.angleOffsetAverageValid = bool(self.avg_offset_valid) - liveParameters.angleOffsetValid = bool(self.total_offset_valid) - liveParameters.valid = all(( - liveParameters.angleOffsetAverageValid, - liveParameters.angleOffsetValid , + vehicleParameters = msg.vehicleParameters + vehicleParameters.posenetValid = True + vehicleParameters.sensorValid = sensors_valid + vehicleParameters.steerRatio = float(x[States.STEER_RATIO].item()) + vehicleParameters.stiffnessFactor = float(x[States.STIFFNESS].item()) + vehicleParameters.roll = float(self.roll) + vehicleParameters.angleOffsetAverageDeg = float(self.avg_angle_offset) + vehicleParameters.angleOffsetDeg = float(self.angle_offset) + vehicleParameters.steerRatioValid = self.min_sr <= vehicleParameters.steerRatio <= self.max_sr + vehicleParameters.stiffnessFactorValid = 0.2 <= vehicleParameters.stiffnessFactor <= 5.0 + vehicleParameters.angleOffsetAverageValid = bool(self.avg_offset_valid) + vehicleParameters.angleOffsetValid = bool(self.total_offset_valid) + vehicleParameters.valid = all(( + vehicleParameters.angleOffsetAverageValid, + vehicleParameters.angleOffsetValid , self.roll_valid, roll_std < ROLL_STD_MAX, - liveParameters.stiffnessFactorValid, - liveParameters.steerRatioValid, + vehicleParameters.stiffnessFactorValid, + vehicleParameters.steerRatioValid, )) - liveParameters.steerRatioStd = float(P[States.STEER_RATIO].item()) - liveParameters.stiffnessFactorStd = float(P[States.STIFFNESS].item()) - liveParameters.angleOffsetAverageStd = float(P[States.ANGLE_OFFSET].item()) - liveParameters.angleOffsetFastStd = float(P[States.ANGLE_OFFSET_FAST].item()) + vehicleParameters.steerRatioStd = float(P[States.STEER_RATIO].item()) + vehicleParameters.stiffnessFactorStd = float(P[States.STIFFNESS].item()) + vehicleParameters.angleOffsetAverageStd = float(P[States.ANGLE_OFFSET].item()) + vehicleParameters.angleOffsetFastStd = float(P[States.ANGLE_OFFSET_FAST].item()) if debug: - liveParameters.debugFilterState = log.LiveParametersData.FilterState.new_message() - liveParameters.debugFilterState.value = x.tolist() - liveParameters.debugFilterState.std = P.tolist() + vehicleParameters.debugFilterState = log.VehicleParameters.FilterState.new_message() + vehicleParameters.debugFilterState.value = x.tolist() + vehicleParameters.debugFilterState.std = P.tolist() return msg @@ -210,7 +210,7 @@ def retrieve_initial_vehicle_params(params: Params, CP: car.CarParams, replay: b if last_parameters_data is not None and last_carparams_data is not None: try: with log.Event.from_bytes(last_parameters_data) as last_lp_msg, car.CarParams.from_bytes(last_carparams_data) as last_CP: - lp = last_lp_msg.liveParameters + lp = last_lp_msg.vehicleParameters # Check if car model matches if last_CP.carFingerprint != CP.carFingerprint: raise Exception("Car model mismatch") @@ -248,8 +248,8 @@ def main(): DEBUG = bool(int(os.getenv("DEBUG", "0"))) REPLAY = bool(int(os.getenv("REPLAY", "0"))) - pm = messaging.PubMaster(['liveParameters']) - sm = messaging.SubMaster(['livePose', 'liveCalibration', 'carState'], poll='livePose') + pm = messaging.PubMaster(['vehicleParameters']) + sm = messaging.SubMaster(['deviceMotion', 'extrinsicsCalibration', 'carState'], poll='deviceMotion') params = Params() CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) @@ -265,14 +265,14 @@ def main(): t = sm.logMonoTime[which] * 1e-9 learner.handle_log(t, which, sm[which]) - if sm.updated['livePose']: + if sm.updated['deviceMotion']: msg = learner.get_msg(sm.all_checks(), debug=DEBUG) msg_dat = msg.to_bytes() if sm.frame % 1200 == 0: # once a minute params.put("LiveParametersV2", msg_dat) - pm.send('liveParameters', msg_dat) + pm.send('vehicleParameters', msg_dat) if __name__ == "__main__": diff --git a/openpilot/selfdrive/locationd/test/test_calibrationd.py b/openpilot/selfdrive/locationd/test/test_calibrationd.py index f862b369fe..7afd43b35b 100644 --- a/openpilot/selfdrive/locationd/test/test_calibrationd.py +++ b/openpilot/selfdrive/locationd/test/test_calibrationd.py @@ -2,6 +2,7 @@ import random import numpy as np +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from openpilot.cereal import log from openpilot.common.params import Params @@ -29,19 +30,19 @@ def process_messages(c, cam_odo_calib, cycles, [0.0, 0.0, HEIGHT_INIT.item()], [cam_odo_height_std, cam_odo_height_std, cam_odo_height_std]) -class TestCalibrationd: +class TestCalibrationd(OpenpilotTestCase): def test_read_saved_params(self): - msg = messaging.new_message('liveCalibration') - msg.liveCalibration.validBlocks = random.randint(1, 10) - msg.liveCalibration.rpyCalib = [random.random() for _ in range(3)] - msg.liveCalibration.height = [random.random() for _ in range(1)] + msg = messaging.new_message('extrinsicsCalibration') + msg.extrinsicsCalibration.validBlocks = random.randint(1, 10) + msg.extrinsicsCalibration.rpyCalib = [random.random() for _ in range(3)] + msg.extrinsicsCalibration.height = [random.random() for _ in range(1)] Params().put("CalibrationParams", msg.to_bytes(), block=True) c = Calibrator(param_put=True) - np.testing.assert_allclose(msg.liveCalibration.rpyCalib, c.rpy) - np.testing.assert_allclose(msg.liveCalibration.height, c.height) - assert msg.liveCalibration.validBlocks == c.valid_blocks + np.testing.assert_allclose(msg.extrinsicsCalibration.rpyCalib, c.rpy) + np.testing.assert_allclose(msg.extrinsicsCalibration.height, c.height) + assert msg.extrinsicsCalibration.validBlocks == c.valid_blocks def test_calibration_basics(self): @@ -91,7 +92,7 @@ class TestCalibrationd: np.testing.assert_allclose(c.rpy, [0.0, 0.0, 0.0], atol=1e-3) process_messages(c, [0.0, MAX_ALLOWED_PITCH_SPREAD*0.9, MAX_ALLOWED_YAW_SPREAD*0.9], BLOCK_SIZE + 10) assert c.valid_blocks == INPUTS_NEEDED + 1 - assert c.cal_status == log.LiveCalibrationData.Status.calibrated + assert c.cal_status == log.ExtrinsicsCalibration.Status.calibrated c = Calibrator(param_put=False) process_messages(c, [0.0, 0.0, 0.0], BLOCK_SIZE * INPUTS_NEEDED) @@ -99,7 +100,7 @@ class TestCalibrationd: np.testing.assert_allclose(c.rpy, [0.0, 0.0, 0.0]) process_messages(c, [0.0, MAX_ALLOWED_PITCH_SPREAD*1.1, 0.0], BLOCK_SIZE + 10) assert c.valid_blocks == 1 - assert c.cal_status == log.LiveCalibrationData.Status.recalibrating + assert c.cal_status == log.ExtrinsicsCalibration.Status.recalibrating np.testing.assert_allclose(c.rpy, [0.0, MAX_ALLOWED_PITCH_SPREAD*1.1, 0.0], atol=1e-2) c = Calibrator(param_put=False) @@ -108,5 +109,5 @@ class TestCalibrationd: np.testing.assert_allclose(c.rpy, [0.0, 0.0, 0.0]) process_messages(c, [0.0, 0.0, MAX_ALLOWED_YAW_SPREAD*1.1], BLOCK_SIZE + 10) assert c.valid_blocks == 1 - assert c.cal_status == log.LiveCalibrationData.Status.recalibrating + assert c.cal_status == log.ExtrinsicsCalibration.Status.recalibrating np.testing.assert_allclose(c.rpy, [0.0, 0.0, MAX_ALLOWED_YAW_SPREAD*1.1], atol=1e-2) diff --git a/openpilot/selfdrive/locationd/test/test_lagd.py b/openpilot/selfdrive/locationd/test/test_lagd.py index efaba6ead9..0acc3e646f 100644 --- a/openpilot/selfdrive/locationd/test/test_lagd.py +++ b/openpilot/selfdrive/locationd/test/test_lagd.py @@ -1,8 +1,10 @@ import random import numpy as np import time -import pytest +import unittest +from functools import cache +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import messaging, log from opendbc.car.structs import car from openpilot.selfdrive.locationd.lagd import LateralLagEstimator, retrieve_initial_lag, masked_normalized_cross_correlation, \ @@ -18,6 +20,12 @@ DT = 0.05 LAGD_MIN_LAG_FRAMES, LAGD_MAX_LAG_FRAMES = int(round(MIN_LAG / DT)), int(round(MAX_LAG / DT)) +@cache +def get_test_car_params(): + lr = migrate(LogReader(TEST_ROUTE), [migrate_carParams]) + return next(m for m in lr if m.which() == "carParams").carParams + + def process_messages(estimator, lag_frames, n_frames, vego=25.0, rejection_threshold=0.0): for i in range(n_frames): t = i * estimator.dt @@ -35,9 +43,9 @@ def process_messages(estimator, lag_frames, n_frames, vego=25.0, rejection_thres (t, "carControl", car.CarControl(latActive=not rejected)), (t, "carState", car.CarState(vEgo=vego, steeringPressed=False)), (t, "controlsState", log.ControlsState(desiredCurvature=desired_cuvature)), - (t, "livePose", log.LivePose(angularVelocityDevice=log.LivePose.XYZMeasurement(z=actual_yr, valid=True), + (t, "deviceMotion", log.DeviceMotion(angularVelocityDevice=log.DeviceMotion.XYZMeasurement(z=actual_yr, valid=True), posenetOK=True, inputsOK=True)), - (t, "liveCalibration", log.LiveCalibrationData(rpyCalib=[0, 0, 0], calStatus=log.LiveCalibrationData.Status.calibrated)), + (t, "extrinsicsCalibration", log.ExtrinsicsCalibration(rpyCalib=[0, 0, 0], calStatus=log.ExtrinsicsCalibration.Status.calibrated)), ] for t, w, m in msgs: estimator.handle_log(t, w, m) @@ -45,17 +53,16 @@ def process_messages(estimator, lag_frames, n_frames, vego=25.0, rejection_thres estimator.update_estimate() -class TestLagd: +class TestLagd(OpenpilotTestCase): def test_read_saved_params(self): params = Params() - lr = migrate(LogReader(TEST_ROUTE), [migrate_carParams]) - CP = next(m for m in lr if m.which() == "carParams").carParams + CP = get_test_car_params() - msg = messaging.new_message('liveDelay') - msg.liveDelay.lateralDelayEstimate = random.random() - msg.liveDelay.validBlocks = random.randint(1, 10) - msg.liveDelay.version = VERSION + msg = messaging.new_message('lateralDelay') + msg.lateralDelay.lateralDelayEstimate = random.random() + msg.lateralDelay.validBlocks = random.randint(1, 10) + msg.lateralDelay.version = VERSION params.put("LiveDelay", msg.to_bytes(), block=True) params.put("CarParamsPrevRoute", CP.as_builder().to_bytes(), block=True) @@ -63,19 +70,18 @@ class TestLagd: assert saved_lag_params is not None lag, valid_blocks = saved_lag_params - assert lag == msg.liveDelay.lateralDelayEstimate - assert valid_blocks == msg.liveDelay.validBlocks + assert lag == msg.lateralDelay.lateralDelayEstimate + assert valid_blocks == msg.lateralDelay.validBlocks def test_read_invalid_saved_params(self, subtests): params = Params() - lr = migrate(LogReader(TEST_ROUTE), [migrate_carParams]) - CP = next(m for m in lr if m.which() == "carParams").carParams + CP = get_test_car_params() for msg_dict in [{'version': 0}, {'status': 'invalid'}, {'validBlocks': 100}]: - with subtests.test(msg=f"liveDelay={msg_dict}"): - msg = messaging.new_message('liveDelay') - msg.liveDelay = msg_dict + with subtests.test(msg=f"lateralDelay={msg_dict}"): + msg = messaging.new_message('lateralDelay') + msg.lateralDelay = msg_dict params.put("LiveDelay", msg.to_bytes(), block=True) params.put("CarParamsPrevRoute", CP.as_builder().to_bytes(), block=True) assert retrieve_initial_lag(params, CP) is None @@ -108,11 +114,11 @@ class TestLagd: mocked_CP = car.CarParams(steerActuatorDelay=0.5) estimator = LateralLagEstimator(mocked_CP, DT) msg = estimator.get_msg(True) - assert msg.liveDelay.status == 'unestimated' - assert np.allclose(msg.liveDelay.lateralDelay, estimator.initial_lag) - assert np.allclose(msg.liveDelay.lateralDelayEstimate, estimator.initial_lag) - assert msg.liveDelay.validBlocks == 0 - assert msg.liveDelay.calPerc == 0 + assert msg.lateralDelay.status == 'unestimated' + assert np.allclose(msg.lateralDelay.lateralDelay, estimator.initial_lag) + assert np.allclose(msg.lateralDelay.lateralDelayEstimate, estimator.initial_lag) + assert msg.lateralDelay.validBlocks == 0 + assert msg.lateralDelay.calPerc == 0 def test_estimator_basics(self, subtests): for lag_frames in range(LAGD_MIN_LAG_FRAMES, LAGD_MAX_LAG_FRAMES - 1): @@ -121,23 +127,23 @@ class TestLagd: estimator = LateralLagEstimator(mocked_CP, DT, min_recovery_buffer_sec=0.0, min_yr=0.0) process_messages(estimator, lag_frames, int(MIN_OKAY_WINDOW_SEC / DT) + BLOCK_NUM_NEEDED * BLOCK_SIZE) msg = estimator.get_msg(True) - assert msg.liveDelay.status == 'estimated' - assert np.allclose(msg.liveDelay.lateralDelay, lag_frames * DT, atol=0.01) - assert np.allclose(msg.liveDelay.lateralDelayEstimate, lag_frames * DT, atol=0.01) - assert np.allclose(msg.liveDelay.lateralDelayEstimateStd, 0.0, atol=0.01) - assert msg.liveDelay.validBlocks == BLOCK_NUM_NEEDED - assert msg.liveDelay.calPerc == 100 + assert msg.lateralDelay.status == 'estimated' + assert np.allclose(msg.lateralDelay.lateralDelay, lag_frames * DT, atol=0.01) + assert np.allclose(msg.lateralDelay.lateralDelayEstimate, lag_frames * DT, atol=0.01) + assert np.allclose(msg.lateralDelay.lateralDelayEstimateStd, 0.0, atol=0.01) + assert msg.lateralDelay.validBlocks == BLOCK_NUM_NEEDED + assert msg.lateralDelay.calPerc == 100 def test_estimator_masking(self): mocked_CP, lag_frames = car.CarParams(steerActuatorDelay=0.5), random.randint(LAGD_MIN_LAG_FRAMES, LAGD_MAX_LAG_FRAMES - 1) estimator = LateralLagEstimator(mocked_CP, DT, min_recovery_buffer_sec=0.0, min_yr=0.0, min_valid_block_count=1) process_messages(estimator, lag_frames, (int(MIN_OKAY_WINDOW_SEC / DT) + BLOCK_SIZE) * 2, rejection_threshold=0.4) msg = estimator.get_msg(True) - assert np.allclose(msg.liveDelay.lateralDelayEstimate, lag_frames * DT, atol=0.01) - assert np.allclose(msg.liveDelay.lateralDelayEstimateStd, 0.0, atol=0.01) - assert msg.liveDelay.calPerc == 100 + assert np.allclose(msg.lateralDelay.lateralDelayEstimate, lag_frames * DT, atol=0.01) + assert np.allclose(msg.lateralDelay.lateralDelayEstimateStd, 0.0, atol=0.01) + assert msg.lateralDelay.calPerc == 100 - @pytest.mark.skipif(PC, reason="only on device") + @unittest.skipIf(PC, "only on device") def test_estimator_performance(self): mocked_CP = car.CarParams(steerActuatorDelay=0.5) estimator = LateralLagEstimator(mocked_CP, DT) diff --git a/openpilot/selfdrive/locationd/test/test_locationd_scenarios.py b/openpilot/selfdrive/locationd/test/test_locationd_scenarios.py index 04d2d6b55b..01fc3f1177 100644 --- a/openpilot/selfdrive/locationd/test/test_locationd_scenarios.py +++ b/openpilot/selfdrive/locationd/test/test_locationd_scenarios.py @@ -1,7 +1,11 @@ +import fcntl import numpy as np +import os +import tempfile from collections import defaultdict from enum import Enum +from openpilot.common.test import OpenpilotTestCase from openpilot.tools.lib.logreader import LogReader from openpilot.selfdrive.locationd.lagd import masked_symmetric_moving_average from openpilot.selfdrive.test.process_replay.migration import migrate_all @@ -41,7 +45,7 @@ def get_select_fields_data(logs): for key in keys: val = getattr(val, key) if isinstance(key, str) else val[key] return val - lp = [x.livePose for x in logs if x.which() == 'livePose'] + lp = [x.deviceMotion for x in logs if x.which() == 'deviceMotion'] data = defaultdict(list) for msg in lp: for key, fields in SELECT_COMPARE_FIELDS.items(): @@ -96,7 +100,7 @@ def run_scenarios(scenario, logs): return get_select_fields_data(logs), get_select_fields_data(replayed_logs) -class TestLocationdScenarios: +class TestLocationdScenarios(OpenpilotTestCase): """ Test locationd with different scenarios. In all these scenarios, we expect the following: - locationd kalman filter should never go unstable (we care mostly about yaw_rate, roll, gpsOK, inputsOK, sensorsOK) @@ -105,7 +109,20 @@ class TestLocationdScenarios: @classmethod def setup_class(cls): - cls.logs = migrate_all(LogReader(TEST_ROUTE)) + # xdist can initialize this class in several workers at once. URLFile's + # cache writes are atomic, but cache misses are not locked, so every worker + # otherwise downloads the same route concurrently. + lock_path = os.path.join(tempfile.gettempdir(), "openpilot-locationd-scenarios.lock") + ready_path = f"{lock_path}.ready" + logs = None + with open(lock_path, "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + if not os.path.exists(ready_path): + logs = list(LogReader(TEST_ROUTE)) + open(ready_path, "w").close() + if logs is None: + logs = list(LogReader(TEST_ROUTE)) + cls.logs = migrate_all(logs) def test_base(self): """ diff --git a/openpilot/selfdrive/locationd/test/test_paramsd.py b/openpilot/selfdrive/locationd/test/test_paramsd.py index 74135cb875..515c7a814a 100644 --- a/openpilot/selfdrive/locationd/test/test_paramsd.py +++ b/openpilot/selfdrive/locationd/test/test_paramsd.py @@ -1,6 +1,7 @@ import random import numpy as np +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import messaging from openpilot.selfdrive.locationd.paramsd import retrieve_initial_vehicle_params from openpilot.selfdrive.locationd.models.car_kf import CarKalman @@ -10,29 +11,29 @@ from openpilot.common.params import Params from openpilot.tools.lib.logreader import LogReader -def get_random_live_parameters(CP): - msg = messaging.new_message("liveParameters") - msg.liveParameters.steerRatio = (random.random() + 0.5) * CP.steerRatio - msg.liveParameters.stiffnessFactor = random.random() - msg.liveParameters.angleOffsetAverageDeg = random.random() - msg.liveParameters.debugFilterState.std = [random.random() for _ in range(CarKalman.P_initial.shape[0])] +def get_random_vehicle_parameters(CP): + msg = messaging.new_message("vehicleParameters") + msg.vehicleParameters.steerRatio = (random.random() + 0.5) * CP.steerRatio + msg.vehicleParameters.stiffnessFactor = random.random() + msg.vehicleParameters.angleOffsetAverageDeg = random.random() + msg.vehicleParameters.debugFilterState.std = [random.random() for _ in range(CarKalman.P_initial.shape[0])] return msg -class TestParamsd: +class TestParamsd(OpenpilotTestCase): def test_read_saved_params(self): params = Params() lr = migrate(LogReader(TEST_ROUTE), [migrate_carParams]) CP = next(m for m in lr if m.which() == "carParams").carParams - msg = get_random_live_parameters(CP) + msg = get_random_vehicle_parameters(CP) params.put("LiveParametersV2", msg.to_bytes(), block=True) params.put("CarParamsPrevRoute", CP.as_builder().to_bytes(), block=True) sr, sf, offset, p_init = retrieve_initial_vehicle_params(params, CP, replay=True, debug=True) - np.testing.assert_allclose(sr, msg.liveParameters.steerRatio) - np.testing.assert_allclose(sf, msg.liveParameters.stiffnessFactor) - np.testing.assert_allclose(offset, msg.liveParameters.angleOffsetAverageDeg) + np.testing.assert_allclose(sr, msg.vehicleParameters.steerRatio) + np.testing.assert_allclose(sf, msg.vehicleParameters.stiffnessFactor) + np.testing.assert_allclose(offset, msg.vehicleParameters.angleOffsetAverageDeg) np.testing.assert_equal(p_init.shape, CarKalman.P_initial.shape) - np.testing.assert_allclose(np.diagonal(p_init), msg.liveParameters.debugFilterState.std) + np.testing.assert_allclose(np.diagonal(p_init), msg.vehicleParameters.debugFilterState.std) diff --git a/openpilot/selfdrive/locationd/test/test_torqued.py b/openpilot/selfdrive/locationd/test/test_torqued.py index 4d337178a0..3c5cb29bfc 100644 --- a/openpilot/selfdrive/locationd/test/test_torqued.py +++ b/openpilot/selfdrive/locationd/test/test_torqued.py @@ -1,12 +1,13 @@ from opendbc.car.structs import car +from openpilot.common.test import OpenpilotTestCase from openpilot.selfdrive.locationd.torqued import TorqueEstimator -class TestTorqued: +class TestTorqued(OpenpilotTestCase): def test_cal_percent(self): est = TorqueEstimator(car.CarParams()) msg = est.get_msg() - assert msg.liveTorqueParameters.calPerc == 0 + assert msg.lateralTorqueParameters.calPerc == 0 for (low, high), min_pts in zip(est.filtered_points.buckets.keys(), est.filtered_points.buckets_min_points.values(), strict=True): @@ -15,7 +16,7 @@ class TestTorqued: # enough bucket points, but not enough total points msg = est.get_msg() - assert msg.liveTorqueParameters.calPerc == (len(est.filtered_points) / est.min_points_total * 100 + 100) / 2 + assert msg.lateralTorqueParameters.calPerc == (len(est.filtered_points) / est.min_points_total * 100 + 100) / 2 # add enough points to bucket with most capacity key = list(est.filtered_points.buckets)[0] @@ -23,4 +24,4 @@ class TestTorqued: est.filtered_points.add_point((key[0] + key[1]) / 2.0, 0.0) msg = est.get_msg() - assert msg.liveTorqueParameters.calPerc == 100 + assert msg.lateralTorqueParameters.calPerc == 100 diff --git a/openpilot/selfdrive/locationd/torqued.py b/openpilot/selfdrive/locationd/torqued.py index 1d4aa8bc35..1b5653510c 100755 --- a/openpilot/selfdrive/locationd/torqued.py +++ b/openpilot/selfdrive/locationd/torqued.py @@ -110,11 +110,11 @@ class TorqueEstimator(ParameterEstimator, TorqueEstimatorExt): if params_cache is not None and torque_cache is not None: try: with log.Event.from_bytes(torque_cache) as log_evt: - cache_ltp = log_evt.liveTorqueParameters + cache_ltp = log_evt.lateralTorqueParameters with car.CarParams.from_bytes(params_cache) as msg: cache_CP = msg if self.get_restore_key(cache_CP, cache_ltp.version) == self.get_restore_key(CP, VERSION): - if cache_ltp.liveValid: + if cache_ltp.valid: initial_params = { 'latAccelFactor': cache_ltp.latAccelFactorFiltered, 'latAccelOffset': cache_ltp.latAccelOffsetFiltered, @@ -162,7 +162,7 @@ class TorqueEstimator(ParameterEstimator, TorqueEstimatorExt): _, spread = np.matmul(points[:, [0, 2]], slope2rot(slope)).T friction_coeff = np.std(spread) * FRICTION_FACTOR except np.linalg.LinAlgError as e: - cloudlog.exception(f"Error computing live torque params: {e}") + cloudlog.exception(f"Error computing lateral torque parameters: {e}") slope = offset = friction_coeff = np.nan return slope, offset, friction_coeff @@ -184,21 +184,21 @@ class TorqueEstimator(ParameterEstimator, TorqueEstimatorExt): # TODO: check if high aEgo affects resulting lateral accel self.raw_points["vego"].append(msg.vEgo) self.raw_points["steer_override"].append(msg.steeringPressed) - elif which == "liveCalibration": - self.calibrator.feed_live_calib(msg) - elif which == "liveDelay": + elif which == "extrinsicsCalibration": + self.calibrator.feed_extrinsics_calibration(msg) + elif which == "lateralDelay": self.lag = get_lat_delay(self.params, msg.lateralDelay) # calculate lateral accel from past steering torque - elif which == "livePose": + elif which == "deviceMotion": is_valid = msg.angularVelocityDevice.valid and msg.orientationNED.valid and msg.inputsOK and msg.sensorsOK and msg.posenetOK if len(self.raw_points['steer_torque']) == self.hist_len and is_valid: t = msg.timestamp * 1e-9 - device_pose = Pose.from_live_pose(msg) - calibrated_pose = self.calibrator.build_calibrated_pose(device_pose) + device_motion = Pose.from_device_motion(msg) + calibrated_pose = self.calibrator.build_calibrated_pose(device_motion) angular_velocity_calibrated = calibrated_pose.angular_velocity yaw_rate = angular_velocity_calibrated.yaw - roll = device_pose.orientation.roll + roll = device_motion.orientation.roll # check lat active up to now (without lag compensation) lat_active = np.interp(np.arange(t - MIN_ENGAGE_BUFFER, t + self.lag, DT_MDL), self.raw_points['carControl_t'], self.raw_points['lat_active']).astype(bool) @@ -215,40 +215,40 @@ class TorqueEstimator(ParameterEstimator, TorqueEstimatorExt): self.all_torque_points.append([steer, lateral_acc]) def get_msg(self, valid=True, with_points=False): - msg = messaging.new_message('liveTorqueParameters') + msg = messaging.new_message('lateralTorqueParameters') msg.valid = valid - liveTorqueParameters = msg.liveTorqueParameters - liveTorqueParameters.version = VERSION - liveTorqueParameters.useParams = self.use_params + lateralTorqueParameters = msg.lateralTorqueParameters + lateralTorqueParameters.version = VERSION + lateralTorqueParameters.useParams = self.use_params # Calculate raw estimates when possible, only update filters when enough points are gathered if self.filtered_points.is_calculable(): latAccelFactor, latAccelOffset, frictionCoeff = self.estimate_params() - liveTorqueParameters.latAccelFactorRaw = float(latAccelFactor) - liveTorqueParameters.latAccelOffsetRaw = float(latAccelOffset) - liveTorqueParameters.frictionCoefficientRaw = float(frictionCoeff) + lateralTorqueParameters.latAccelFactorRaw = float(latAccelFactor) + lateralTorqueParameters.latAccelOffsetRaw = float(latAccelOffset) + lateralTorqueParameters.frictionCoefficientRaw = float(frictionCoeff) if self.filtered_points.is_valid(): if any(val is None or np.isnan(val) for val in [latAccelFactor, latAccelOffset, frictionCoeff]): - cloudlog.exception("Live torque parameters are invalid.") - liveTorqueParameters.liveValid = False + cloudlog.exception("Lateral torque parameters are invalid.") + lateralTorqueParameters.valid = False self.reset() else: - liveTorqueParameters.liveValid = True + lateralTorqueParameters.valid = True latAccelFactor = np.clip(latAccelFactor, self.min_lataccel_factor, self.max_lataccel_factor) frictionCoeff = np.clip(frictionCoeff, self.min_friction, self.max_friction) self.update_params({'latAccelFactor': latAccelFactor, 'latAccelOffset': latAccelOffset, 'frictionCoefficient': frictionCoeff}) if with_points: - liveTorqueParameters.points = self.filtered_points.get_points()[:, [0, 2]].tolist() + lateralTorqueParameters.points = self.filtered_points.get_points()[:, [0, 2]].tolist() - liveTorqueParameters.latAccelFactorFiltered = float(self.filtered_params['latAccelFactor'].x) - liveTorqueParameters.latAccelOffsetFiltered = float(self.filtered_params['latAccelOffset'].x) - liveTorqueParameters.frictionCoefficientFiltered = float(self.filtered_params['frictionCoefficient'].x) - liveTorqueParameters.totalBucketPoints = len(self.filtered_points) - liveTorqueParameters.calPerc = self.filtered_points.get_valid_percent() - liveTorqueParameters.decay = self.decay - liveTorqueParameters.maxResets = self.resets + lateralTorqueParameters.latAccelFactorFiltered = float(self.filtered_params['latAccelFactor'].x) + lateralTorqueParameters.latAccelOffsetFiltered = float(self.filtered_params['latAccelOffset'].x) + lateralTorqueParameters.frictionCoefficientFiltered = float(self.filtered_params['frictionCoefficient'].x) + lateralTorqueParameters.totalBucketPoints = len(self.filtered_points) + lateralTorqueParameters.calPerc = self.filtered_points.get_valid_percent() + lateralTorqueParameters.decay = self.decay + lateralTorqueParameters.maxResets = self.resets return msg @@ -257,8 +257,8 @@ def main(demo=False): DEBUG = bool(int(os.getenv("DEBUG", "0"))) - pm = messaging.PubMaster(['liveTorqueParameters']) - sm = messaging.SubMaster(['carControl', 'carOutput', 'carState', 'liveCalibration', 'livePose', 'liveDelay'], poll='livePose') + pm = messaging.PubMaster(['lateralTorqueParameters']) + sm = messaging.SubMaster(['carControl', 'carOutput', 'carState', 'extrinsicsCalibration', 'deviceMotion', 'lateralDelay'], poll='deviceMotion') params = Params() estimator = TorqueEstimator(messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams)) @@ -273,9 +273,9 @@ def main(demo=False): TorqueEstimatorExt.update_use_params(estimator) - # 4Hz driven by livePose + # 4Hz driven by deviceMotion if sm.frame % 5 == 0: - pm.send('liveTorqueParameters', estimator.get_msg(valid=sm.all_checks(), with_points=DEBUG)) + pm.send('lateralTorqueParameters', estimator.get_msg(valid=sm.all_checks(), with_points=DEBUG)) # Cache points every 60 seconds while onroad if sm.frame % 240 == 0: diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 5f6281556f..30b008078e 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -1,7 +1,7 @@ import glob import json import os -import sys, subprocess +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 @@ -26,18 +26,7 @@ tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + " def estimate_pickle_max_size(onnx_size): return 1.2 * onnx_size + 10 * 1024 * 1024 # 20% + 10MB is plenty -# get fastest TG config -# probe in subprocess so usbgpu locks gets released on process exit -def probe_devices(): - return set(subprocess.run( - [sys.executable, '-c', 'from tinygrad import Device\nprint("\\n".join(Device.get_available_devices()))'], - capture_output=True, text=True, check=True).stdout.strip().splitlines()) - -available = probe_devices() -if 'CUDA' in available: - tg_backend = 'CUDA' - tg_flags = f'DEV={tg_backend}' -elif 'QCOM' in available: +if arch == 'comma_arm64': tg_backend = 'QCOM' tg_flags = f'DEV={tg_backend} IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1' else: @@ -54,9 +43,9 @@ tg_devices = { # which device to put jit inputs to at runtime }, } -USBGPU = usbgpu_present() # or release # TODO always build big model on release +USBGPU = usbgpu_present() if USBGPU: - usbgpu_tg_flags = f'DEBUG=2 DEV=USB+AMD:LLVM WARP_DEV={tg_backend} FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0' + usbgpu_tg_flags = f'DEBUG=2 DEV=USB+AMD:LLVM WARP_DEV={tg_backend} FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2' # the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it usbgpu_lock = File("models/.usb_gpu.lock").abspath @@ -97,12 +86,26 @@ for usbgpu in [False, True] if USBGPU else [False]: 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, " [USBGPU] $TARGET") if usbgpu else [cmd, Action(do_chunk, " [CHUNK] $TARGET")] node = lenv.Command( chunk_targets, tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(chunk_targets), chunker_file], - [cmd, Action(do_chunk, " [CHUNK] $TARGET")], + actions, ) if usbgpu: lenv.SideEffect(usbgpu_lock, node) diff --git a/openpilot/selfdrive/modeld/compile_modeld.py b/openpilot/selfdrive/modeld/compile_modeld.py index ebc3f21a13..2d27a41496 100755 --- a/openpilot/selfdrive/modeld/compile_modeld.py +++ b/openpilot/selfdrive/modeld/compile_modeld.py @@ -3,7 +3,6 @@ import argparse import atexit import math import os -import pickle import tempfile import time import shutil @@ -13,7 +12,6 @@ from collections import namedtuple import numpy as np from openpilot.selfdrive.modeld.helpers import dump_oob, load_oob -from openpilot.selfdrive.modeld.usbgpu_link import wait_usbgpu_link def _patch_tinygrad_fetch_fw(): import hashlib @@ -31,22 +29,6 @@ def _patch_tinygrad_fetch_fw(): helpers.fetch_fw = fetch_fw _patch_tinygrad_fetch_fw() -def _patch_tinygrad_buffer_reduce(): - from tinygrad.device import Buffer - def __reduce_ex__(self, protocol): - buf = None - if self._base is not None: - return self.__class__, (self.device, self.size, self.dtype, None, None, None, 0, self.base, self.offset, self.is_allocated()) - if self.device == "NPY": - return self.__class__, (self.device, self.size, self.dtype, self._buf, self.options, None, self.uop_refcount) - if self.is_allocated(): - buf = bytearray(self.nbytes) - self.copyout(memoryview(buf)) - if protocol >= 5: - buf = pickle.PickleBuffer(buf) - return self.__class__, (self.device, self.size, self.dtype, None, self.options, buf, self.uop_refcount) - Buffer.__reduce_ex__ = __reduce_ex__ -_patch_tinygrad_buffer_reduce() from tinygrad.tensor import Tensor from tinygrad.helpers import Context @@ -312,9 +294,6 @@ if __name__ == "__main__": p.add_argument('--frame-skip', type=int, required=True) args = p.parse_args() - if 'USB+AMD' in os.environ.get('DEV', ''): - wait_usbgpu_link() - model_path = read_file_chunked_to_disk(args.onnx) model_w, model_h = args.model_size diff --git a/openpilot/selfdrive/modeld/dmonitoringmodeld.py b/openpilot/selfdrive/modeld/dmonitoringmodeld.py index 554407a223..67161c35bd 100755 --- a/openpilot/selfdrive/modeld/dmonitoringmodeld.py +++ b/openpilot/selfdrive/modeld/dmonitoringmodeld.py @@ -8,7 +8,8 @@ import numpy as np from openpilot.cereal import messaging from openpilot.cereal.messaging import PubMaster, SubMaster -from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcClient, VisionBuf from openpilot.common.swaglog import cloudlog from openpilot.common.realtime import config_realtime_process from openpilot.common.transformations.model import dmonitoringmodel_intrinsics @@ -109,8 +110,8 @@ def get_driverstate_packet(model_output, frame_id: int, location_ts: int, exec_t def main(): config_realtime_process(7, 5) - cloudlog.warning("connecting to driver stream") - vipc_client = VisionIpcClient("camerad", VisionStreamType.VISION_STREAM_DRIVER, True) + cloudlog.warning("connecting to cabin stream") + vipc_client = VisionIpcClient("camerad", VisionStreamType.VISION_STREAM_CABIN, True) while not vipc_client.connect(False): time.sleep(0.1) assert vipc_client.is_connected() @@ -119,7 +120,7 @@ def main(): model = ModelState(vipc_client.width, vipc_client.height) cloudlog.warning("models loaded, dmonitoringmodeld starting") - sm = SubMaster(["liveCalibration"]) + sm = SubMaster(["extrinsicsCalibration"]) pm = PubMaster(["driverStateV2"]) calib = np.zeros(model.numpy_inputs['calib'].size, dtype=np.float32) @@ -135,8 +136,8 @@ def main(): model_transform = np.linalg.inv(np.dot(dmonitoringmodel_intrinsics, np.linalg.inv(cam.intrinsics))).astype(np.float32) sm.update(0) - if sm.updated["liveCalibration"]: - calib[:] = np.array(sm["liveCalibration"].rpyCalib) + if sm.updated["extrinsicsCalibration"]: + calib[:] = np.array(sm["extrinsicsCalibration"].rpyCalib) t1 = time.perf_counter() model_output, gpu_execution_time = model.run(buf, calib, model_transform) diff --git a/openpilot/selfdrive/modeld/fill_model_msg.py b/openpilot/selfdrive/modeld/fill_model_msg.py index c4c973bb78..4d0069c878 100644 --- a/openpilot/selfdrive/modeld/fill_model_msg.py +++ b/openpilot/selfdrive/modeld/fill_model_msg.py @@ -175,8 +175,8 @@ def fill_model_msg(msg: capnp._DynamicStructBuilder, net_output_data: dict[str, modelV2.rawPredictions = net_output_data['raw_pred'].tobytes() def fill_pose_msg(msg: capnp._DynamicStructBuilder, net_output_data: dict[str, np.ndarray], - vipc_frame_id: int, vipc_dropped_frames: int, timestamp_eof: int, live_calib_seen: bool) -> None: - msg.valid = live_calib_seen & (vipc_dropped_frames < 1) + vipc_frame_id: int, vipc_dropped_frames: int, timestamp_eof: int, extrinsics_calibration_seen: bool) -> None: + msg.valid = extrinsics_calibration_seen & (vipc_dropped_frames < 1) cameraOdometry = msg.cameraOdometry cameraOdometry.frameId = vipc_frame_id diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 608bc6aa64..37ab0b26d7 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -6,10 +6,11 @@ import struct import tempfile from pathlib import Path +from openpilot.common.file_chunker import get_manifest_path +from openpilot.common.hardware.usb import CHESTNUT_FW_VERSION, CHESTNUT_USB_IDS, USB_DEVICES_PATH + MODELS_DIR = Path(__file__).resolve().parent / 'models' TG_INPUT_DEVICES_PATH = MODELS_DIR / 'tg_input_devices.json' -USBGPU_VID = 0xADD1 -USBGPU_PID = 0x0001 def get_tg_input_devices(process_name: str, usbgpu: bool): @@ -38,22 +39,22 @@ def dump_oob(obj, f): def load_oob(f): opcodes = f.read(struct.unpack(' bool: - for d in Path("/sys/bus/usb/devices").glob("*"): + for d in USB_DEVICES_PATH.glob("*"): try: - if int((d / "idVendor").read_text(), 16) == USBGPU_VID and \ - int((d / "idProduct").read_text(), 16) == USBGPU_PID: + usb_id = (int((d / "idVendor").read_text(), 16), int((d / "idProduct").read_text(), 16)) + product = (d / "product").read_text().strip() + if usb_id in CHESTNUT_USB_IDS and product == f"custom {CHESTNUT_FW_VERSION}-CLEAN": return True except Exception: pass return False + +def usbgpu_compiled() -> bool: + return Path(get_manifest_path(modeld_pkl_path(usbgpu=True))).is_file() diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 5dc8712112..790ae7b167 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -1,14 +1,20 @@ #!/usr/bin/env python3 +from functools import cached_property import os os.environ['GMMU'] = '0' # for usbgpu fast loading, noop for qcom from tinygrad.tensor import Tensor +from tinygrad.device import Device +import struct +import threading import time import numpy as np import openpilot.cereal.messaging as messaging from openpilot.cereal import log from opendbc.car.structs import car from openpilot.cereal.messaging import PubMaster, SubMaster -from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf +from openpilot.cereal.services import SERVICE_LIST +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcClient, VisionBuf from opendbc.car.car_helpers import get_demo_car_params from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params @@ -18,14 +24,13 @@ from openpilot.common.transformations.camera import DEVICE_CAMERAS from openpilot.system.camerad.cameras.nv12_info import get_nv12_info 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, smooth_value, get_curvature_from_plan +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, WARP_INPUTS, POLICY_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, get_manifest_path +from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.constants import ModelConstants, Plan -from openpilot.selfdrive.modeld.helpers import usbgpu_present, modeld_pkl_path, get_tg_input_devices, load_oob -from openpilot.selfdrive.modeld.usbgpu_link import wait_usbgpu_link +from openpilot.selfdrive.modeld.helpers import usbgpu_present, usbgpu_compiled, modeld_pkl_path, get_tg_input_devices, load_oob from openpilot.sunnypilot.livedelay.helpers import get_lat_delay from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase @@ -37,16 +42,17 @@ SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') LAT_SMOOTH_SECONDS = 0.0 LONG_SMOOTH_SECONDS = 0.3 MIN_LAT_CONTROL_SPEED = 0.3 +BIG_MODEL_TIMEOUT = 60 def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log.ModelDataV2.Action, lat_action_t: float, long_action_t: float, v_ego: float) -> log.ModelDataV2.Action: if 'action' not in model_output: plan = model_output['plan'][0] - desired_accel, should_stop = get_accel_from_plan(plan[:,Plan.VELOCITY][:,0], - plan[:,Plan.ACCELERATION][:,0], - ModelConstants.T_IDXS, - action_t=long_action_t) + desired_accel = get_accel_from_plan(plan[:,Plan.VELOCITY][:,0], + plan[:,Plan.ACCELERATION][:,0], + ModelConstants.T_IDXS, + action_t=long_action_t) desired_curvature = get_curvature_from_plan(plan[:,Plan.T_FROM_CURRENT_EULER][:,2], plan[:,Plan.ORIENTATION_RATE][:,2], ModelConstants.T_IDXS, @@ -55,7 +61,7 @@ def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log. else: desired_accel = model_output['action'][0,1] desired_curvature = model_output['action'][0,0] / (max(1.0, v_ego))**2 - should_stop = (v_ego < 0.3 and desired_accel < 0.1) + stop = should_stop(v_ego, desired_accel) desired_accel = smooth_value(desired_accel, prev_action.desiredAcceleration, LONG_SMOOTH_SECONDS) if v_ego > MIN_LAT_CONTROL_SPEED: desired_curvature = smooth_value(desired_curvature, prev_action.desiredCurvature, LAT_SMOOTH_SECONDS) @@ -64,7 +70,62 @@ def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log. return log.ModelDataV2.Action(desiredCurvature=float(desired_curvature), desiredAcceleration=float(desired_accel), - shouldStop=bool(should_stop)) + shouldStop=bool(stop)) + + +class ChestnutState: + # only modeld can access chestnut + def __init__(self, pm: PubMaster, big: bool): + self.pm = pm + self.big = big + self.valid = True + self.sends = 0 + self.metrics = {} + + @cached_property + def power_limit(self) -> int: + smu = Device["AMD"].iface.dev_impl.smu + 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 + self.sends += 1 + if self.big and "AMD" in Device._opened_devices and self.sends % 100 == 1: + try: + smu = Device["AMD"].iface.dev_impl.smu + smu._send_msg(smu.smu_mod.PPSMC_MSG_TransferTableSmu2Dram, smu.smu_mod.TABLE_SMU_METRICS, timeout=100) + metrics = smu.read_table(smu.smu_mod.SmuMetricsExternal_t, smu.smu_mod.TABLE_SMU_METRICS).SmuMetrics + self.metrics = {'tempC': metrics.AvgTemperature[smu.smu_mod.TEMP_HOTSPOT], + 'memoryTempC': metrics.AvgTemperature[smu.smu_mod.TEMP_MEM], + 'powerDrawW': metrics.AverageSocketPower, + 'powerLimitW': self.power_limit, + 'gpuUsagePercent': metrics.AverageGfxActivity, + 'gpuClockMhz': metrics.AverageGfxclkFrequencyPostDs, + 'fanSpeedRpm': metrics.AvgFanRpm} + self.valid = True + except Exception: + if self.valid: + cloudlog.exception("chestnut state read failed") + self.valid = False + self.metrics.clear() + if self.big: + for k, v in self.metrics.items(): + setattr(state, k, v) + + asm_valid = False + if "AMD" in Device._opened_devices: + try: + # ASM runs on USB-C power, these still read without a gpu + asm = Device["AMD"].iface.pci_dev.usb + state.pcieLtssm = asm.read(0xB450, 1)[0] + state.supplyVoltage, state.supplyCurrent = struct.unpack(' None: + dummy_frames = {k: np.zeros(self.frame_buf_params[k][3], dtype=np.uint8) for k in self.vision_input_names} + 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 = make_input_queues(self.input_shapes, self.frame_skip, device=self.QUEUE_DEV) + self.prev_desire[:] = 0 + self.full_frames.clear() + self._blob_cache.clear() + def main(demo=False): cloudlog.warning("modeld init") - _present = usbgpu_present() - _compiled = os.path.isfile(get_manifest_path(modeld_pkl_path(usbgpu=True))) - USBGPU = _present and _compiled + USBGPU = usbgpu_present() and usbgpu_compiled() + if USBGPU: + os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000' params = Params() - params.put_bool("UsbGpuPresent", _present) - params.put_bool("UsbGpuCompiled", _compiled) + params.put_bool("UsbGpuLoading", USBGPU) + params.remove("UsbGpuActive") config_realtime_process(7, 54) @@ -156,12 +231,12 @@ def main(demo=False): while True: available_streams = VisionIpcClient.available_streams("camerad", block=False) if available_streams: - use_extra_client = VisionStreamType.VISION_STREAM_WIDE_ROAD in available_streams and VisionStreamType.VISION_STREAM_ROAD in available_streams - main_wide_camera = VisionStreamType.VISION_STREAM_ROAD not in available_streams + use_extra_client = VisionStreamType.VISION_STREAM_WIDE_ROAD in available_streams and VisionStreamType.VISION_STREAM_NARROW_ROAD in available_streams + main_wide_camera = VisionStreamType.VISION_STREAM_NARROW_ROAD not in available_streams break time.sleep(.1) - vipc_client_main_stream = VisionStreamType.VISION_STREAM_WIDE_ROAD if main_wide_camera else VisionStreamType.VISION_STREAM_ROAD + vipc_client_main_stream = VisionStreamType.VISION_STREAM_WIDE_ROAD if main_wide_camera else VisionStreamType.VISION_STREAM_NARROW_ROAD vipc_client_main = VisionIpcClient("camerad", vipc_client_main_stream, True) vipc_client_extra = VisionIpcClient("camerad", VisionStreamType.VISION_STREAM_WIDE_ROAD, False) cloudlog.warning(f"vision stream set up, main_wide_camera: {main_wide_camera}, use_extra_client: {use_extra_client}") @@ -175,19 +250,40 @@ def main(demo=False): if use_extra_client: cloudlog.warning(f"connected extra cam with buffer size: {vipc_client_extra.buffer_len} ({vipc_client_extra.width} x {vipc_client_extra.height})") - if USBGPU: - wait_usbgpu_link() st = time.monotonic() cloudlog.warning("loading model") - model = ModelState(vipc_client_main.width, vipc_client_main.height, USBGPU) + model = None + if USBGPU: + big_model = None + def load_big(): + nonlocal big_model + try: + m = ModelState(vipc_client_main.width, vipc_client_main.height, True) + m.warmup() + big_model = m + except Exception: + cloudlog.exception("big model load failed") + loader = threading.Thread(target=load_big, daemon=True) + loader.start() + loader.join(BIG_MODEL_TIMEOUT) + model = big_model + params.put_bool("UsbGpuActive", model is not None) + + small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or USBGPU else None + if model is None: + model = small_model + params.put_bool("UsbGpuLoading", False) + assert model is not None cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") # messaging - pm = PubMaster(["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"]) - sm = SubMaster(["deviceState", "carState", "roadCameraState", "liveCalibration", "driverMonitoringState", "carControl", "liveDelay"]) + pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutState"] if USBGPU else []) + pm = PubMaster(pub_socks) + sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"]) publish_state = PublishState() params = Params() + chestnut_state = ChestnutState(pm, model.usbgpu) if USBGPU else None # setup filter to track dropped frames frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_RUN_FREQ) @@ -197,7 +293,7 @@ def main(demo=False): model_transform_main = np.zeros((3, 3), dtype=np.float32) model_transform_extra = np.zeros((3, 3), dtype=np.float32) - live_calib_seen = False + extrinsics_calibration_seen = False buf_main, buf_extra = None, None meta_main = FrameMeta() meta_extra = FrameMeta() @@ -252,18 +348,19 @@ def main(demo=False): sm.update(0) desire = DH.desire is_rhd = sm["driverMonitoringState"].isRHD - frame_id = sm["roadCameraState"].frameId + frame_id = sm["narrowRoadCameraState"].frameId v_ego = max(sm["carState"].vEgo, 0.) - if sm.frame % 60 == 0: - model.lat_delay = get_lat_delay(params, sm["liveDelay"].lateralDelay) - lat_delay = model.lat_delay + LAT_SMOOTH_SECONDS - if sm.updated["liveCalibration"] and sm.seen['roadCameraState'] and sm.seen['deviceState']: - device_from_calib_euler = np.array(sm["liveCalibration"].rpyCalib, dtype=np.float32) - dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))] - model_transform_main = get_warp_matrix(device_from_calib_euler, dc.ecam.intrinsics if main_wide_camera else dc.fcam.intrinsics, False).astype(np.float32) + model.lat_delay = get_lat_delay(params, sm["lateralDelay"].lateralDelay) + lat_delay = sm["lateralDelay"].lateralDelay + LAT_SMOOTH_SECONDS + if sm.updated["extrinsicsCalibration"] and sm.seen['narrowRoadCameraState'] and sm.seen['deviceState']: + device_from_calib_euler = np.array(sm["extrinsicsCalibration"].rpyCalib, dtype=np.float32) + dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['narrowRoadCameraState'].sensor))] + main_intrinsics = dc.wide_road.intrinsics if main_wide_camera else dc.narrow_road.intrinsics + model_transform_main = get_warp_matrix(device_from_calib_euler, main_intrinsics, False).astype(np.float32) has_wide_camera = use_extra_client or main_wide_camera - model_transform_extra = get_warp_matrix(device_from_calib_euler, dc.ecam.intrinsics if has_wide_camera else dc.fcam.intrinsics, True).astype(np.float32) - live_calib_seen = True + extra_intrinsics = dc.wide_road.intrinsics if has_wide_camera else dc.narrow_road.intrinsics + model_transform_extra = get_warp_matrix(device_from_calib_euler, extra_intrinsics, True).astype(np.float32) + extrinsics_calibration_seen = True traffic_convention = np.zeros(2) traffic_convention[int(is_rhd)] = 1 @@ -295,7 +392,20 @@ def main(demo=False): } mt1 = time.perf_counter() - model_output = model.run(bufs, transforms, inputs) + try: + model_output = model.run(bufs, transforms, inputs) + except Exception: + if not params.get_bool("UsbGpuActive"): + raise + # fallback to small model + cloudlog.exception("big model failed, fall back to small") + params.put_bool("UsbGpuActive", False) + assert small_model is not None + model = small_model + if chestnut_state is not None: + chestnut_state.big = False + run_count = 0 + model_output = None mt2 = time.perf_counter() model_execution_time = mt2 - mt1 @@ -303,32 +413,37 @@ 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 fill_model_msg(modelv2_send, model_output, action, publish_state, meta_main.frame_id, meta_extra.frame_id, frame_id, - frame_drop_ratio, meta_main.timestamp_eof, model_execution_time, live_calib_seen) + frame_drop_ratio, meta_main.timestamp_eof, model_execution_time, extrinsics_calibration_seen) + modelv2_send.modelV2.big = model.usbgpu desire_state = modelv2_send.modelV2.meta.desireState 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 - 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) + DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob) modelv2_send.modelV2.meta.laneChangeState = DH.lane_change_state modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction + + mdv2sp_send = messaging.new_message('modelDataV2SP') + left_edge, right_edge = RELC.update_and_fill(modelv2_send.modelV2, mdv2sp_send.modelDataV2SP, v_ego) mdv2sp_send.modelDataV2SP.laneTurnDirection = DH.lane_turn_direction fill_driving_model_data(drivingdata_send, modelv2_send) - fill_pose_msg(posenet_send, model_output, meta_main.frame_id, vipc_dropped_frames, meta_main.timestamp_eof, live_calib_seen) + fill_pose_msg(posenet_send, model_output, meta_main.frame_id, vipc_dropped_frames, meta_main.timestamp_eof, extrinsics_calibration_seen) pm.send('modelV2', modelv2_send) pm.send('drivingModelData', drivingdata_send) pm.send('cameraOdometry', posenet_send) pm.send('modelDataV2SP', mdv2sp_send) last_vipc_frame_id = meta_main.frame_id + if chestnut_state is not None and run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0: + chestnut_state.send() + if __name__ == "__main__": try: diff --git a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx index 4a04bd7833..a74573ca29 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx +++ b/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a501760a9d1d5fef0eab2b8c5d122d06124fc26dc8e0782e0aa94b82a208f0ff -size 1757355221 +oid sha256:10926f2c0911821ca0e72439c1c3bf3ec11f0a08789aa14b7ee8f25379b2afa4 +size 1753235978 diff --git a/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx b/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx index bd3f2c85a6..1e592e2192 100644 --- a/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx +++ b/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3e7b31dfbc0a5234f1baf196513b77fc6af12204b8a8ffe8ee0417e48352f316 -size 7494962 +oid sha256:dd299afabe7a3e0d04cbe2bd97fdb0c93bba8ad6d3cc3663a0e0ededaf243ac2 +size 7497335 diff --git a/openpilot/selfdrive/modeld/usbgpu_link.py b/openpilot/selfdrive/modeld/usbgpu_link.py deleted file mode 100644 index ef12cba939..0000000000 --- a/openpilot/selfdrive/modeld/usbgpu_link.py +++ /dev/null @@ -1,34 +0,0 @@ -import time -from pathlib import Path - -from openpilot.common.swaglog import cloudlog -from openpilot.common.hardware.usb import CHESTNUT_VENDOR_ID, CHESTNUT_PRODUCT_ID, usb_devices, controller, read_int - -STABLE_SECONDS = 2.0 -STABLE_THRESHOLD = 5.0 # link errors per second - - -def _chestnut_portli() -> Path | None: - for device in usb_devices(): - if read_int(device / "idVendor", 16) == CHESTNUT_VENDOR_ID and \ - read_int(device / "idProduct", 16) == CHESTNUT_PRODUCT_ID: - ctrl = controller(device) - if ctrl is not None and (ctrl / "portli").exists(): - return ctrl / "portli" - return None - - -def wait_usbgpu_link(timeout: float = 30.0) -> None: - portli = _chestnut_portli() - if portli is None: - return - - t0 = time.monotonic() - while time.monotonic() - t0 < timeout: - start = read_int(portli, 0) - time.sleep(STABLE_SECONDS) - rate = (read_int(portli, 0) - start) / STABLE_SECONDS - if rate <= STABLE_THRESHOLD: - return - cloudlog.warning(f"usbgpu link not stable: {rate:.0f} errors/s") - cloudlog.error("usbgpu link never stabilized") diff --git a/openpilot/selfdrive/monitoring/dmonitoringd.py b/openpilot/selfdrive/monitoring/dmonitoringd.py index 085a13fcb1..caff23baa0 100755 --- a/openpilot/selfdrive/monitoring/dmonitoringd.py +++ b/openpilot/selfdrive/monitoring/dmonitoringd.py @@ -10,7 +10,7 @@ def dmonitoringd_thread(): params = Params() pm = messaging.PubMaster(['driverMonitoringState']) - sm = messaging.SubMaster(['driverStateV2', 'liveCalibration', 'carState', 'selfdriveState', 'modelV2', + sm = messaging.SubMaster(['driverStateV2', 'extrinsicsCalibration', 'carState', 'selfdriveState', 'modelV2', 'carControl'], poll='driverStateV2') DM = DriverMonitoring(rhd_saved=params.get_bool("IsRhdDetected"), always_on=params.get_bool("AlwaysOnDM")) diff --git a/openpilot/selfdrive/monitoring/policy.py b/openpilot/selfdrive/monitoring/policy.py index 1489e9c798..bd698214ac 100644 --- a/openpilot/selfdrive/monitoring/policy.py +++ b/openpilot/selfdrive/monitoring/policy.py @@ -107,17 +107,17 @@ class DriverBlink: self.right = 0. # model output refers to center of undistorted+leveled image -ref_undistorted_cam = DEVICE_CAMERAS[("tici", "ar0231")].dcam -dcam_undistorted_FL = 598.0 -dcam_undistorted_W, dcam_undistorted_H = (ref_undistorted_cam.width, ref_undistorted_cam.height) +ref_undistorted_cam = DEVICE_CAMERAS[("tici", "ar0231")].cabin +cabin_undistorted_FL = 598.0 +cabin_undistorted_W, cabin_undistorted_H = (ref_undistorted_cam.width, ref_undistorted_cam.height) def face_orientation_from_model(orient_model, pos_model, rpy_calib): pitch_model = orient_model[0] yaw_model = orient_model[1] - face_pixel_position = ((pos_model[0]+0.5)*dcam_undistorted_W, (pos_model[1]+0.5)*dcam_undistorted_H) - yaw_focal_angle = atan2(face_pixel_position[0] - dcam_undistorted_W//2, dcam_undistorted_FL) - pitch_focal_angle = atan2(face_pixel_position[1] - dcam_undistorted_H//2, dcam_undistorted_FL) + face_pixel_position = ((pos_model[0]+0.5)*cabin_undistorted_W, (pos_model[1]+0.5)*cabin_undistorted_H) + yaw_focal_angle = atan2(face_pixel_position[0] - cabin_undistorted_W//2, cabin_undistorted_FL) + pitch_focal_angle = atan2(face_pixel_position[1] - cabin_undistorted_H//2, cabin_undistorted_FL) pitch = pitch_model + pitch_focal_angle yaw = -yaw_model + yaw_focal_angle @@ -441,7 +441,7 @@ class DriverMonitoring: driver_engaged = sm['carState'].steeringPressed or (sm['selfdriveState'].enabled and sm['carState'].gasPressed) brake_disengage_prob = sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs[0] # brake disengage prob in next 2s steering_angle_deg = sm['carState'].steeringAngleDeg - rpyCalib = sm['liveCalibration'].rpyCalib + rpyCalib = sm['extrinsicsCalibration'].rpyCalib self._set_pose_strictness( brake_disengage_prob=brake_disengage_prob, diff --git a/openpilot/selfdrive/monitoring/test_monitoring.py b/openpilot/selfdrive/monitoring/test_monitoring.py index accf496b47..d9889860f5 100644 --- a/openpilot/selfdrive/monitoring/test_monitoring.py +++ b/openpilot/selfdrive/monitoring/test_monitoring.py @@ -1,5 +1,6 @@ -import pytest +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import log from opendbc.car.structs import car from openpilot.common.realtime import DT_DMON @@ -49,7 +50,7 @@ always_distracted = [msg_DISTRACTED] * int(TEST_TIMESPAN / DT_DMON) always_true = [True] * int(TEST_TIMESPAN / DT_DMON) always_false = [False] * int(TEST_TIMESPAN / DT_DMON) -class TestMonitoring: +class TestMonitoring(OpenpilotTestCase): def _run_seq(self, msgs, interaction, engaged, lowspeed): DM = DriverMonitoring() alert_lvls = [] @@ -242,36 +243,38 @@ def _build_sm(selfdrive_enabled, lat_active, steering_pressed, gas_pressed): cc.latActive = lat_active mv2 = log.ModelDataV2.new_message() mv2.meta.disengagePredictions.brakeDisengageProbs = [0.0] - lc = log.LiveCalibrationData.new_message() + lc = log.ExtrinsicsCalibration.new_message() lc.rpyCalib = [0.0, 0.0, 0.0] return { 'carState': cs, 'selfdriveState': ss, 'carControl': cc, - 'modelV2': mv2, 'liveCalibration': lc, 'driverStateV2': make_msg(False), + 'modelV2': mv2, 'extrinsicsCalibration': lc, 'driverStateV2': make_msg(False), } -@pytest.mark.parametrize("selfdrive_enabled, lat_active, steering, gas, expected_op_engaged, expected_driver_engaged", [ - (False, False, False, False, False, False), # disabled - (True, False, False, False, True, False), # OP enabled - (False, True, False, False, True, False), # MADS lat-only - (True, True, False, False, True, False), # both active - (False, True, False, True, True, False), # MADS lat-only + gas - (True, True, False, True, True, True), # full op + gas: override - (False, True, True, False, True, True), # MADS lat-only + wheel touch: override -]) -def test_run_step_engagement(selfdrive_enabled, lat_active, steering, gas, - expected_op_engaged, expected_driver_engaged): - sm = _build_sm(selfdrive_enabled, lat_active, steering, gas) - dm = DriverMonitoring() - captured = {} - orig = dm._update_events +class TestRunStepEngagement(OpenpilotTestCase): + @parameterized.expand([ + (False, False, False, False, False, False), # disabled + (True, False, False, False, True, False), # OP enabled + (False, True, False, False, True, False), # MADS lat-only + (True, True, False, False, True, False), # both active + (False, True, False, True, True, False), # MADS lat-only + gas + (True, True, False, True, True, True), # full op + gas: override + (False, True, True, False, True, True), # MADS lat-only + wheel touch: override + ], names=["selfdrive_enabled", "lat_active", "steering", "gas", + "expected_op_engaged", "expected_driver_engaged"]) + def test_run_step_engagement(self, selfdrive_enabled, lat_active, steering, gas, + expected_op_engaged, expected_driver_engaged): + sm = _build_sm(selfdrive_enabled, lat_active, steering, gas) + dm = DriverMonitoring() + captured = {} + orig = dm._update_events - def spy(driver_engaged, op_engaged, lowspeed, wrong_gear): - captured['driver_engaged'] = driver_engaged - captured['op_engaged'] = op_engaged - return orig(driver_engaged, op_engaged, lowspeed, wrong_gear) + def spy(driver_engaged, op_engaged, lowspeed, wrong_gear): + captured['driver_engaged'] = driver_engaged + captured['op_engaged'] = op_engaged + return orig(driver_engaged, op_engaged, lowspeed, wrong_gear) - object.__setattr__(dm, '_update_events', spy) - dm.run_step(sm, demo=False) - assert captured['op_engaged'] == expected_op_engaged - assert captured['driver_engaged'] == expected_driver_engaged + object.__setattr__(dm, '_update_events', spy) + dm.run_step(sm, demo=False) + assert captured['op_engaged'] == expected_op_engaged + assert captured['driver_engaged'] == expected_driver_engaged diff --git a/openpilot/selfdrive/pandad/pandad.cc b/openpilot/selfdrive/pandad/pandad.cc index 250ad5a481..ac87804bc8 100644 --- a/openpilot/selfdrive/pandad/pandad.cc +++ b/openpilot/selfdrive/pandad/pandad.cc @@ -143,8 +143,8 @@ void fill_panda_state(cereal::PandaState::Builder &ps, cereal::PandaState::Panda ps.setSbu1Voltage(health.sbu1_voltage_mV / 1000.0f); ps.setSbu2Voltage(health.sbu2_voltage_mV / 1000.0f); ps.setSoundOutputLevel(health.sound_output_level_pkt); - ps.setControlsAllowedLateral(health.controls_allowed_lateral_pkt); - ps.setControlsAllowedLongitudinal(health.controls_allowed_longitudinal_pkt); + ps.setControlsAllowedLateral(health.controls_allowed_sp_pkt & 1); + ps.setControlsAllowedLongitudinal((health.controls_allowed_sp_pkt >> 1) & 1); } void fill_panda_can_state(cereal::PandaState::PandaCanState::Builder &cs, const can_health_t &can_health) { @@ -292,9 +292,9 @@ void process_panda_state(Panda *panda, PubMaster *pm, bool engaged, bool engaged void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control, bool is_onroad) { static Params params; - static SubMaster sm({"deviceState", "driverCameraState"}); + static SubMaster sm({"deviceState", "cabinCameraState"}); - static uint64_t last_driver_camera_t = 0; + static uint64_t last_cabin_camera_t = 0; static uint16_t prev_fan_speed = 999; static int ir_pwr = 0; static int prev_ir_pwr = 999; @@ -318,20 +318,20 @@ void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control, } } - if (sm.updated("driverCameraState")) { - auto event = sm["driverCameraState"]; - int cur_integ_lines = event.getDriverCameraState().getIntegLines(); + if (sm.updated("cabinCameraState")) { + auto event = sm["cabinCameraState"]; + int cur_integ_lines = event.getCabinCameraState().getIntegLines(); // reset the filter when camerad restarts - if (event.getDriverCameraState().getFrameId() < prev_frame_id) { + if (event.getCabinCameraState().getFrameId() < prev_frame_id) { integ_lines_filter.reset(0); integ_lines_filter_driver_view.reset(0); driver_view = params.getBool("IsDriverViewEnabled"); } - prev_frame_id = event.getDriverCameraState().getFrameId(); + prev_frame_id = event.getCabinCameraState().getFrameId(); cur_integ_lines = (driver_view ? integ_lines_filter_driver_view : integ_lines_filter).update(cur_integ_lines); - last_driver_camera_t = event.getLogMonoTime(); + last_cabin_camera_t = event.getLogMonoTime(); if (cur_integ_lines <= CUTOFF_IL) { ir_pwr = 0; @@ -343,7 +343,7 @@ void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control, } // Disable IR on input timeout - if (nanos_since_boot() - last_driver_camera_t > 1e9) { + if (nanos_since_boot() - last_cabin_camera_t > 1e9) { ir_pwr = 0; } diff --git a/openpilot/selfdrive/pandad/spi.cc b/openpilot/selfdrive/pandad/spi.cc index f54c26e506..369032a533 100644 --- a/openpilot/selfdrive/pandad/spi.cc +++ b/openpilot/selfdrive/pandad/spi.cc @@ -29,6 +29,12 @@ enum SpiError { const unsigned int SPI_ACK_TIMEOUT = 500; // milliseconds const std::string SPI_DEVICE = "/dev/spidev0.0"; +// TODO: fix SPI turnaround synchronization at the protocol level. +static uint64_t spi_last_bus_activity_ns = 0; // protected by hw_lock + +static void wait_for_spi_turnaround(uint64_t start_ns) { + while ((nanos_since_boot() - start_ns) < 400000) {} +} class LockEx { public: @@ -319,6 +325,8 @@ int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx assert(tx_len < SPI_BUF_SIZE); assert(max_rx_len < SPI_BUF_SIZE); + wait_for_spi_turnaround(spi_last_bus_activity_ns); + xfer_count++; header = { .sync = SPI_SYNC, @@ -347,6 +355,7 @@ int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx if (ret < 0) { goto fail; } + wait_for_spi_turnaround(nanos_since_boot()); // Send data if (tx_data != NULL) { @@ -389,6 +398,7 @@ int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx memcpy(rx_data, rx_buf + 3, rx_data_len); } + spi_last_bus_activity_ns = nanos_since_boot(); return rx_data_len; fail: @@ -403,6 +413,7 @@ fail: } } + spi_last_bus_activity_ns = nanos_since_boot(); if (ret >= 0) ret = -1; return ret; } diff --git a/openpilot/selfdrive/pandad/tests/test_pandad.py b/openpilot/selfdrive/pandad/tests/test_pandad.py old mode 100644 new mode 100755 index 0f8fd9fc1a..891508f2d7 --- a/openpilot/selfdrive/pandad/tests/test_pandad.py +++ b/openpilot/selfdrive/pandad/tests/test_pandad.py @@ -1,20 +1,30 @@ -import os -import pytest -import time +#!/usr/bin/env python3 +import os +import time +import unittest + +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from openpilot.cereal import log from openpilot.common.gpio import gpio_set, gpio_init from panda import Panda, PandaDFU from openpilot.system.manager.process_config import managed_processes from openpilot.common.hardware import HARDWARE -from openpilot.common.hardware.tici.pins import GPIO +from openpilot.common.hardware.comma.pins import GPIO HERE = os.path.dirname(os.path.realpath(__file__)) -@pytest.mark.tici -class TestPandad: +class TestPandad(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True + + def setUp(self): + super().setUp() + managed_processes['pandad'].stop() + if not Panda.list(): + self._run_test() + def teardown_method(self): managed_processes['pandad'].stop() @@ -79,3 +89,7 @@ class TestPandad: assert not PandaDFU.list() self._run_test() + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/selfdrive/pandad/tests/test_pandad_canprotocol.cc b/openpilot/selfdrive/pandad/tests/test_pandad_canprotocol.cc index 83339a4c1c..50bbc51f49 100644 --- a/openpilot/selfdrive/pandad/tests/test_pandad_canprotocol.cc +++ b/openpilot/selfdrive/pandad/tests/test_pandad_canprotocol.cc @@ -1,11 +1,7 @@ -#define CATCH_CONFIG_MAIN -#define CATCH_CONFIG_ENABLE_BENCHMARKING - #include -#include "catch2/catch.hpp" +#include "common/tests/native_test.h" #include "openpilot/cereal/messaging/messaging.h" -#include "common/util.h" #include "selfdrive/pandad/panda.h" struct PandaTest : public Panda { @@ -26,12 +22,9 @@ PandaTest::PandaTest(int can_list_size_, cereal::PandaState::PandaType hw_type_) int data_limit = ((hw_type == cereal::PandaState::PandaType::RED_PANDA) ? std::size(dlc_to_len) : 8); // prepare test data for (int i = 0; i < data_limit; ++i) { - std::random_device rd; - std::independent_bits_engine rbe(rd()); - int data_len = dlc_to_len[i]; std::string bytes(data_len, '\0'); - std::generate(bytes.begin(), bytes.end(), std::ref(rbe)); + for (int j = 0; j < data_len; ++j) bytes[j] = static_cast((i * 31 + j) & 0xff); test_data[data_len] = bytes; } @@ -39,16 +32,15 @@ PandaTest::PandaTest(int can_list_size_, cereal::PandaState::PandaType hw_type_) auto can_list = msg.initEvent().initSendcan(can_list_size); for (uint8_t i = 0; i < can_list_size; ++i) { auto can = can_list[i]; - uint32_t id = util::random_int(0, std::size(dlc_to_len) - 1); + uint32_t id = i % data_limit; const std::string &dat = test_data[dlc_to_len[id]]; can.setAddress(i); - can.setSrc(util::random_int(0, 2)); + can.setSrc(i % 3); can.setDat(kj::ArrayPtr((uint8_t *)dat.data(), dat.size())); total_pakets_size += sizeof(can_header) + dat.size(); } can_data_list = can_list.asReader(); - INFO("test " << can_list_size << " packets, total size " << total_pakets_size); } void PandaTest::test_can_send() { @@ -56,30 +48,29 @@ void PandaTest::test_can_send() { this->pack_can_buffer(can_data_list, [&](uint8_t *chunk, size_t size) { unpacked_data.insert(unpacked_data.end(), chunk, &chunk[size]); }); - REQUIRE(unpacked_data.size() == total_pakets_size); + CHECK(unpacked_data.size() == total_pakets_size); int cnt = 0; - INFO("test can message integrity"); for (int pos = 0, pckt_len = 0; pos < unpacked_data.size(); pos += pckt_len) { can_header header; memcpy(&header, &unpacked_data[pos], sizeof(can_header)); const uint8_t data_len = dlc_to_len[header.data_len_code]; pckt_len = sizeof(can_header) + data_len; - REQUIRE(header.addr == cnt); - REQUIRE(test_data.find(data_len) != test_data.end()); + CHECK(header.addr == cnt); + CHECK(test_data.find(data_len) != test_data.end()); const std::string &dat = test_data[data_len]; - REQUIRE(memcmp(dat.data(), &unpacked_data[pos + sizeof(can_header)], dat.size()) == 0); + CHECK(memcmp(dat.data(), &unpacked_data[pos + sizeof(can_header)], dat.size()) == 0); ++cnt; } - REQUIRE(cnt == can_list_size); + CHECK(cnt == can_list_size); } void PandaTest::test_can_recv(uint32_t rx_chunk_size) { std::vector frames; this->pack_can_buffer(can_data_list, [&](uint8_t *data, uint32_t size) { if (rx_chunk_size == 0) { - REQUIRE(this->unpack_can_buffer(data, size, frames)); + CHECK(this->unpack_can_buffer(data, size, frames)); } else { this->receive_buffer_size = 0; uint32_t pos = 0; @@ -90,46 +81,35 @@ void PandaTest::test_can_recv(uint32_t rx_chunk_size) { this->receive_buffer_size += chunk_size; pos += chunk_size; - REQUIRE(this->unpack_can_buffer(this->receive_buffer, this->receive_buffer_size, frames)); + CHECK(this->unpack_can_buffer(this->receive_buffer, this->receive_buffer_size, frames)); } } }); - REQUIRE(frames.size() == can_list_size); + CHECK(frames.size() == can_list_size); for (int i = 0; i < frames.size(); ++i) { - REQUIRE(frames[i].address == i); - REQUIRE(test_data.find(frames[i].dat.size()) != test_data.end()); + CHECK(frames[i].address == i); + CHECK(test_data.find(frames[i].dat.size()) != test_data.end()); const std::string &dat = test_data[frames[i].dat.size()]; - REQUIRE(memcmp(dat.data(), frames[i].dat.data(), dat.size()) == 0); + CHECK(memcmp(dat.data(), frames[i].dat.data(), dat.size()) == 0); } } -TEST_CASE("send/recv CAN 2.0 packets") { - auto can_list_size = GENERATE(1, 3, 5, 10, 30, 60, 100, 200); - PandaTest test(can_list_size, cereal::PandaState::PandaType::DOS); +void test_can_protocol() { + for (auto hw_type : {cereal::PandaState::PandaType::DOS, cereal::PandaState::PandaType::RED_PANDA}) { + for (int can_list_size : {1, 3, 5, 10, 30, 60, 100, 200}) { + PandaTest send_test(can_list_size, hw_type); + send_test.test_can_send(); - SECTION("can_send") { - test.test_can_send(); - } - SECTION("can_receive") { - test.test_can_recv(); - } - SECTION("chunked_can_receive") { - test.test_can_recv(0x40); + PandaTest receive_test(can_list_size, hw_type); + receive_test.test_can_recv(); + + PandaTest chunked_receive_test(can_list_size, hw_type); + chunked_receive_test.test_can_recv(0x40); + } } } -TEST_CASE("send/recv CAN FD packets") { - auto can_list_size = GENERATE(1, 3, 5, 10, 30, 60, 100, 200); - PandaTest test(can_list_size, cereal::PandaState::PandaType::RED_PANDA); - - SECTION("can_send") { - test.test_can_send(); - } - SECTION("can_receive") { - test.test_can_recv(); - } - SECTION("chunked_can_receive") { - test.test_can_recv(0x40); - } +int main() { + return run_native_test(test_can_protocol); } diff --git a/openpilot/selfdrive/pandad/tests/test_pandad_loopback.py b/openpilot/selfdrive/pandad/tests/test_pandad_loopback.py old mode 100644 new mode 100755 index 0d0aa80046..ad35018f5f --- a/openpilot/selfdrive/pandad/tests/test_pandad_loopback.py +++ b/openpilot/selfdrive/pandad/tests/test_pandad_loopback.py @@ -1,11 +1,14 @@ +#!/usr/bin/env python3 + import os import copy import random import time -import pytest +import unittest from collections import defaultdict from pprint import pprint +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from openpilot.cereal import log from opendbc.car.structs import car @@ -69,8 +72,8 @@ def send_random_can_messages(sendcan, count): return sent_msgs -@pytest.mark.tici -class TestBoarddLoopback: +class TestBoarddLoopback(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True @classmethod def setup_class(cls): os.environ['STARTED'] = '1' @@ -114,3 +117,7 @@ class TestBoarddLoopback: pprint(sm['pandaStates']) # may drop messages due to RX buffer overflow for bus in sent_loopback.keys(): assert not len(sent_loopback[bus]), f"loop {i}: bus {bus} missing {len(sent_loopback[bus])} out of {sent_total[bus]} messages" + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/selfdrive/pandad/tests/test_pandad_spi.py b/openpilot/selfdrive/pandad/tests/test_pandad_spi.py old mode 100644 new mode 100755 index 2bc302b47c..ba2e31ce82 --- a/openpilot/selfdrive/pandad/tests/test_pandad_spi.py +++ b/openpilot/selfdrive/pandad/tests/test_pandad_spi.py @@ -1,9 +1,12 @@ +#!/usr/bin/env python3 + import os import time +import unittest import numpy as np -import pytest import random +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from openpilot.cereal.services import SERVICE_LIST from openpilot.common.timeout import Timeout @@ -12,8 +15,8 @@ from openpilot.selfdrive.pandad.tests.test_pandad_loopback import setup_pandad, JUNGLE_SPAM = "JUNGLE_SPAM" in os.environ -@pytest.mark.tici -class TestBoarddSpi: +class TestBoarddSpi(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True @classmethod def setup_class(cls): os.environ['STARTED'] = '1' @@ -105,3 +108,7 @@ class TestBoarddSpi: with subtests.test(msg="CAN traffic"): print(f"Sent {total_sent_count} CAN messages, got {total_recv_count} back. {total_recv_count/(total_sent_count+1e-4):.2%} received") assert total_recv_count > 20 + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/selfdrive/selfdrived/events.py b/openpilot/selfdrive/selfdrived/events.py index d7eaaf70c4..139f416e72 100755 --- a/openpilot/selfdrive/selfdrived/events.py +++ b/openpilot/selfdrive/selfdrived/events.py @@ -9,8 +9,6 @@ from openpilot.common.constants import CV from openpilot.common.git import get_short_branch from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.locationd.calibrationd import MIN_SPEED_FILTER -from openpilot.system.micd import SAMPLE_RATE, SAMPLE_BUFFER -from openpilot.selfdrive.ui.feedback.feedbackd import FEEDBACK_MAX_DURATION from openpilot.common.hardware import HARDWARE from openpilot.sunnypilot.selfdrive.selfdrived.events_base import EventsBase, Priority, ET, Alert, \ @@ -89,9 +87,9 @@ def below_steer_speed_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.S def calibration_incomplete_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: - first_word = 'Recalibrating' if sm['liveCalibration'].calStatus == log.LiveCalibrationData.Status.recalibrating else 'Calibrating' + first_word = 'Recalibrating' if sm['extrinsicsCalibration'].calStatus == log.ExtrinsicsCalibration.Status.recalibrating else 'Calibrating' return Alert( - f"{first_word}: {sm['liveCalibration'].calPerc:.0f}%", + f"{first_word}: {sm['extrinsicsCalibration'].calPerc:.0f}%", f"Drive Above {get_display_speed(MIN_SPEED_FILTER, metric)}", AlertStatus.normal, AlertSize.mid, Priority.LOWEST, VisualAlert.none, AudibleAlert.none, .2) @@ -104,14 +102,6 @@ def too_distracted_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubM return NoEntryAlert("Pay Attention to Engage", priority=Priority.HIGH) -def audio_feedback_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: - duration = FEEDBACK_MAX_DURATION - ((sm['audioFeedback'].blockNum + 1) * SAMPLE_BUFFER / SAMPLE_RATE) - return NormalPermanentAlert( - "Recording Audio Feedback", - f"{round(duration)} second{'s' if round(duration) != 1 else ''} remaining. Press again to save early.", - priority=Priority.LOW) - - # *** debug alerts *** def out_of_space_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: @@ -139,13 +129,13 @@ def comm_issue_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaste def camera_malfunction_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: - all_cams = ('roadCameraState', 'driverCameraState', 'wideRoadCameraState') + all_cams = ('narrowRoadCameraState', 'cabinCameraState', 'wideRoadCameraState') bad_cams = [s.replace('State', '') for s in all_cams if s in sm.data.keys() and not sm.all_checks([s, ])] return NormalPermanentAlert("Camera Malfunction", ', '.join(bad_cams)) def calibration_invalid_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: - rpy = sm['liveCalibration'].rpyCalib + rpy = sm['extrinsicsCalibration'].rpyCalib yaw = math.degrees(rpy[2] if len(rpy) == 3 else math.nan) pitch = math.degrees(rpy[1] if len(rpy) == 3 else math.nan) angles = f"Remount Device (Pitch: {pitch:.1f}°, Yaw: {yaw:.1f}°)" @@ -153,16 +143,16 @@ def calibration_invalid_alert(CP: car.CarParams, CS: car.CarState, sm: messaging def paramsd_invalid_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: - if not sm['liveParameters'].angleOffsetValid: - angle_offset_deg = sm['liveParameters'].angleOffsetDeg + if not sm['vehicleParameters'].angleOffsetValid: + angle_offset_deg = sm['vehicleParameters'].angleOffsetDeg title = "Steering misalignment detected" text = f"Angle offset too high (Offset: {angle_offset_deg:.1f}°)" - elif not sm['liveParameters'].steerRatioValid: - steer_ratio = sm['liveParameters'].steerRatio + elif not sm['vehicleParameters'].steerRatioValid: + steer_ratio = sm['vehicleParameters'].steerRatio title = "Steer ratio mismatch" text = f"Steering rack geometry may be off (Ratio: {steer_ratio:.1f})" - elif not sm['liveParameters'].stiffnessFactorValid: - stiffness_factor = sm['liveParameters'].stiffnessFactor + elif not sm['vehicleParameters'].stiffnessFactorValid: + stiffness_factor = sm['vehicleParameters'].stiffnessFactor title = "Abnormal tire stiffness" text = f"Check tires, pressure, or alignment (Factor: {stiffness_factor:.1f})" else: @@ -181,11 +171,6 @@ def low_memory_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaste return NormalPermanentAlert("Low Memory", f"{sm['deviceState'].memoryUsagePercent}% used") -def high_cpu_usage_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: - x = max(sm['deviceState'].cpuUsagePercent, default=0.) - return NormalPermanentAlert("High CPU Usage", f"{x}% used") - - def modeld_lagging_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: return NormalPermanentAlert("Driving Model Lagging", f"{sm['modelV2'].frameDropPerc:.1f}% frames dropped") @@ -227,6 +212,7 @@ def invalid_lkas_setting_alert(CP: car.CarParams, CS: car.CarState, sm: messagin EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { # ********** events with no alerts ********** + EventName.noGps: {}, EventName.stockFcw: {}, EventName.actuatorsApiUnavailable: {}, @@ -243,6 +229,14 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { "Ensure road ahead is clear"), }, + EventName.bigModelLoading: { + ET.NO_ENTRY: NoEntryAlert("Big Model Loading"), + }, + + EventName.bigModelFailed: { + ET.PERMANENT: NormalPermanentAlert("Big Model Failed ", "Restart the car to retry,\nnow driving on small model", duration=20.), + }, + EventName.lateralManeuver: { ET.WARNING: longitudinal_maneuver_alert, ET.PERMANENT: NormalPermanentAlert("Lateral Maneuver Mode"), @@ -456,7 +450,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { # Thrown when the fan is driven at >50% but is not rotating EventName.fanMalfunction: { - ET.PERMANENT: NormalPermanentAlert("Fan Malfunction", "Likely Hardware Issue"), + ET.PERMANENT: NormalPermanentAlert("Fan Malfunction", "Contact comma.ai/support"), }, # Camera is not outputting frames @@ -580,6 +574,10 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { ET.NO_ENTRY: NoEntryAlert("Press Set to Engage"), }, + EventName.carNotReady: { + ET.NO_ENTRY: NoEntryAlert("Car Not Ready"), + }, + EventName.wrongCruiseMode: { ET.USER_DISABLE: EngagementAlert(AudibleAlert.disengage), ET.NO_ENTRY: NoEntryAlert("Adaptive Cruise Disabled"), @@ -607,16 +605,13 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { EventName.sensorDataInvalid: { ET.PERMANENT: Alert( "Sensor Data Invalid", - "Possible Hardware Issue", + "Contact comma.ai/support", AlertStatus.normal, AlertSize.mid, Priority.LOWER, VisualAlert.none, AudibleAlert.none, .2, creation_delay=1.), ET.NO_ENTRY: NoEntryAlert("Sensor Data Invalid"), ET.SOFT_DISABLE: soft_disable_alert("Sensor Data Invalid"), }, - EventName.noGps: { - }, - EventName.tooDistracted: { ET.NO_ENTRY: too_distracted_alert, }, @@ -675,11 +670,6 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { ET.NO_ENTRY: NoEntryAlert("Electronic Stability Control Disabled"), }, - EventName.lowBattery: { - ET.SOFT_DISABLE: soft_disable_alert("Low Battery"), - ET.NO_ENTRY: NoEntryAlert("Low Battery"), - }, - # Different openpilot services communicate between each other at a certain # interval. If communication does not follow the regular schedule this alert # is thrown. This can mean a service crashed, did not broadcast a message for @@ -733,13 +723,6 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { ET.NO_ENTRY: posenet_invalid_alert, }, - # When the localizer detects an acceleration of more than 40 m/s^2 (~4G) we - # alert the driver the device might have fallen from the windshield. - EventName.deviceFalling: { - ET.SOFT_DISABLE: soft_disable_alert("Device Fell Off Mount"), - ET.NO_ENTRY: NoEntryAlert("Device Fell Off Mount"), - }, - EventName.lowMemory: { ET.SOFT_DISABLE: soft_disable_alert("Low Memory: Reboot Your Device"), ET.PERMANENT: low_memory_alert, @@ -762,14 +745,6 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { ET.NO_ENTRY: NoEntryAlert("Controls Mismatch"), }, - # Sometimes the USB stack on the device can get into a bad state - # causing the connection to the panda to be lost - EventName.usbError: { - ET.SOFT_DISABLE: soft_disable_alert("USB Error: Reboot Your Device"), - ET.PERMANENT: NormalPermanentAlert("USB Error: Reboot Your Device"), - ET.NO_ENTRY: NoEntryAlert("USB Error: Reboot Your Device"), - }, - # This alert can be thrown for the following reasons: # - No CAN data received at all # - CAN data is received, but some message are not received at the right frequency @@ -822,7 +797,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { # and this alert is thrown. EventName.relayMalfunction: { ET.IMMEDIATE_DISABLE: ImmediateDisableAlert("Harness Relay Malfunction"), - ET.PERMANENT: NormalPermanentAlert("Harness Relay Malfunction", "Check Hardware"), + ET.PERMANENT: NormalPermanentAlert("Harness Relay Malfunction", "Contact comma.ai/support"), ET.NO_ENTRY: NoEntryAlert("Harness Relay Malfunction"), }, @@ -857,10 +832,6 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { EventName.userBookmark: { ET.PERMANENT: NormalPermanentAlert("Bookmark Saved", duration=1.5), }, - - EventName.audioFeedback: { - ET.PERMANENT: audio_feedback_alert, - }, } diff --git a/openpilot/selfdrive/selfdrived/helpers.py b/openpilot/selfdrive/selfdrived/helpers.py index 3b3c44ecaf..e7dca0f9d5 100644 --- a/openpilot/selfdrive/selfdrived/helpers.py +++ b/openpilot/selfdrive/selfdrived/helpers.py @@ -31,7 +31,7 @@ class ExcessiveActuationCheck: # lateral yaw_rate = calibrated_pose.angular_velocity.yaw - roll = sm['liveParameters'].roll + roll = sm['vehicleParameters'].roll roll_compensated_lateral_accel = (CS.vEgo * yaw_rate) - (math.sin(roll) * ACCELERATION_DUE_TO_GRAVITY) # Prevent false positives after overriding @@ -41,9 +41,9 @@ class ExcessiveActuationCheck: if abs(roll_compensated_lateral_accel) > ISO_LATERAL_ACCEL * 2: excessive_lat_actuation = True - # livePose acceleration can be noisy due to bad mounting or aliased livePose measurements - livepose_valid = abs(CS.aEgo - accel_calibrated) < 2 - self._excessive_counter = self._excessive_counter + 1 if livepose_valid and (excessive_long_actuation or excessive_lat_actuation) else 0 + # deviceMotion acceleration can be noisy due to bad mounting or aliased deviceMotion measurements + device_motion_valid = abs(CS.aEgo - accel_calibrated) < 2 + self._excessive_counter = self._excessive_counter + 1 if device_motion_valid and (excessive_long_actuation or excessive_lat_actuation) else 0 excessive_type = None if self._excessive_counter > MIN_EXCESSIVE_ACTUATION_COUNT: diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index 8035b2934e..672fee9b48 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -7,7 +7,8 @@ import openpilot.cereal.messaging as messaging from openpilot.cereal import log, custom from opendbc.car.structs import car -from msgq.visionipc import VisionIpcClient, VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcClient from openpilot.common.params import Params @@ -15,7 +16,7 @@ from openpilot.common.realtime import config_realtime_process, Priority, Ratekee from openpilot.common.swaglog import cloudlog from openpilot.common.gps import get_gps_location_service -from openpilot.selfdrive.car.car_specific import CarSpecificEvents +from openpilot.selfdrive.car.car_events import CarEvents from openpilot.selfdrive.locationd.helpers import PoseCalibrator, Pose from openpilot.selfdrive.selfdrived.events import Events, ET from openpilot.selfdrive.selfdrived.helpers import ExcessiveActuationCheck @@ -75,12 +76,16 @@ class SelfdriveD(CruiseHelper): else: self.CP_SP = CP_SP - self.car_events = CarSpecificEvents(self.CP) + self.car_events = CarEvents(self.CP) self.pose_calibrator = PoseCalibrator() self.calibrated_pose: Pose | None = None self.excessive_actuation_check = ExcessiveActuationCheck() self.excessive_actuation = self.params.get("Offroad_ExcessiveActuation") is not None + self.big_model_loading = False + self.big_model_active = False + self.big_model_failed = False + self.big_model_ready_t = 0. # Setup sockets self.pm = messaging.PubMaster(['selfdriveState', 'onroadEvents'] + ['selfdriveStateSP', 'onroadEventsSP']) @@ -88,21 +93,21 @@ class SelfdriveD(CruiseHelper): self.gps_location_service = get_gps_location_service(self.params) self.gps_packets = [self.gps_location_service] self.sensor_packets = ["accelerometer", "gyroscope"] - self.camera_packets = ["roadCameraState", "driverCameraState", "wideRoadCameraState"] + self.camera_packets = ["narrowRoadCameraState", "cabinCameraState", "wideRoadCameraState"] # TODO: de-couple selfdrived with card/conflate on carState without introducing controls mismatches self.car_state_sock = messaging.sub_sock('carState', timeout=20) ignore = self.sensor_packets + self.gps_packets + ['alertDebug', 'lateralManeuverPlan'] + ['modelDataV2SP', 'longitudinalPlanSP'] if SIMULATION: - ignore += ['driverCameraState', 'managerState'] + ignore += ['cabinCameraState', 'managerState'] if REPLAY: # no vipc in replay will make them ignored anyways - ignore += ['roadCameraState', 'wideRoadCameraState'] - self.sm = messaging.SubMaster(['deviceState', 'pandaStates', 'peripheralState', 'modelV2', 'liveCalibration', - 'carOutput', 'driverMonitoringState', 'longitudinalPlan', 'livePose', 'liveDelay', - 'managerState', 'liveParameters', 'radarState', 'liveTorqueParameters', - 'controlsState', 'carControl', 'driverAssistance', 'alertDebug', 'userBookmark', 'audioFeedback', + ignore += ['narrowRoadCameraState', 'wideRoadCameraState'] + self.sm = messaging.SubMaster(['deviceState', 'pandaStates', 'peripheralState', 'modelV2', 'extrinsicsCalibration', + 'carOutput', 'driverMonitoringState', 'longitudinalPlan', 'deviceMotion', 'lateralDelay', + 'managerState', 'vehicleParameters', 'radarState', 'lateralTorqueParameters', + 'controlsState', 'carControl', 'driverAssistance', 'alertDebug', 'userBookmark', 'lateralManeuverPlan', 'modelDataV2SP', 'longitudinalPlanSP'] + \ self.camera_packets + self.sensor_packets + self.gps_packets, ignore_alive=ignore, ignore_avg_freq=ignore, @@ -190,6 +195,27 @@ class SelfdriveD(CruiseHelper): self.events.add(EventName.joystickDebug) self.startup_event = None + loading = self.params.get_bool("UsbGpuLoading") + if self.big_model_loading and not loading: + self.big_model_ready_t = time.monotonic() + self.big_model_loading = loading + if self.big_model_loading: + self.events.add(EventName.bigModelLoading) + + big_active = self.params.get("UsbGpuActive") + usbgpu_present = self.sm['deviceState'].chestnutPresent + model_unavailable = big_active is True and self.sm.seen['modelV2'] and not self.sm.alive['modelV2'] + big_failed = big_active is False or model_unavailable or (self.big_model_active and not usbgpu_present) + if big_failed and not self.big_model_failed: + self.events.add(EventName.bigModelFailed) + self.big_model_failed = big_failed + + # soft disable if the big model fails + if big_active: + self.big_model_active = True + if not self.enabled and not model_unavailable: + self.big_model_active = False + if self.sm.recv_frame['lateralManeuverPlan'] > 0: self.events.add(EventName.lateralManeuver) self.startup_event = None @@ -207,13 +233,10 @@ class SelfdriveD(CruiseHelper): self.events.add(EventName.selfdriveInitializing) return - # Check for user bookmark press (bookmark button or end of LKAS button feedback) + # Check for user bookmark press if self.sm.updated['userBookmark']: self.events.add(EventName.userBookmark) - if self.sm.updated['audioFeedback']: - self.events.add(EventName.audioFeedback) - # Don't add any more events while in dashcam mode if self.CP.passive: return @@ -287,11 +310,11 @@ class SelfdriveD(CruiseHelper): self.last_functional_fan_frame = self.sm.frame # Handle calibration status - cal_status = self.sm['liveCalibration'].calStatus - if cal_status != log.LiveCalibrationData.Status.calibrated: - if cal_status == log.LiveCalibrationData.Status.uncalibrated: + cal_status = self.sm['extrinsicsCalibration'].calStatus + if cal_status != log.ExtrinsicsCalibration.Status.calibrated: + if cal_status == log.ExtrinsicsCalibration.Status.uncalibrated: self.events.add(EventName.calibrationIncomplete) - elif cal_status == log.LiveCalibrationData.Status.recalibrating: + elif cal_status == log.ExtrinsicsCalibration.Status.recalibrating: if not self.recalibrating_seen: set_offroad_alert("Offroad_Recalibration", True) self.recalibrating_seen = True @@ -308,11 +331,11 @@ class SelfdriveD(CruiseHelper): # NOTE: To fork maintainers. # Disabling or nerfing safety features will get you and your users banned from our servers. # We recommend that you do not change these numbers from the defaults. - if self.sm.updated['liveCalibration']: - self.pose_calibrator.feed_live_calib(self.sm['liveCalibration']) - if self.sm.updated['livePose']: - device_pose = Pose.from_live_pose(self.sm['livePose']) - self.calibrated_pose = self.pose_calibrator.build_calibrated_pose(device_pose) + if self.sm.updated['extrinsicsCalibration']: + self.pose_calibrator.feed_extrinsics_calibration(self.sm['extrinsicsCalibration']) + if self.sm.updated['deviceMotion']: + device_motion = Pose.from_device_motion(self.sm['deviceMotion']) + self.calibrated_pose = self.pose_calibrator.build_calibrated_pose(device_motion) if self.calibrated_pose is not None: excessive_actuation = self.excessive_actuation_check.update(self.sm, CS, self.calibrated_pose) @@ -396,8 +419,6 @@ class SelfdriveD(CruiseHelper): self.events.add(EventName.radarTempUnavailable) elif any(self.sm['radarState'].radarErrors.to_dict().values()): self.events.add(EventName.radarFault) - if not self.sm.valid['pandaStates']: - self.events.add(EventName.usbError) if CS.canTimeout: self.events.add(EventName.canBusMissing) elif not CS.canValid: @@ -406,7 +427,9 @@ class SelfdriveD(CruiseHelper): # generic catch-all. ideally, a more specific event should be added above instead has_disable_events = self.events.contains(ET.NO_ENTRY) and (self.events.contains(ET.SOFT_DISABLE) or self.events.contains(ET.IMMEDIATE_DISABLE)) no_system_errors = (not has_disable_events) or (len(self.events) == num_events) - if not self.sm.all_checks() and no_system_errors: + warmup_sec = 5. + big_model_settling = self.big_model_loading or time.monotonic() < self.big_model_ready_t + warmup_sec + if not self.sm.all_checks() and no_system_errors and not big_model_settling: # the load holds modelV2 and friends back on purpose if not self.sm.all_alive(): self.events.add(EventName.commIssue) elif not self.sm.all_freq_ok(): @@ -425,12 +448,13 @@ class SelfdriveD(CruiseHelper): else: self.logged_comm_issue = None - if not self.CP.notCar: - if not self.sm['livePose'].posenetOK: + 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: self.events.add(EventName.posenetInvalid) - if not self.sm['livePose'].inputsOK: + if not self.sm['deviceMotion'].inputsOK: self.events.add(EventName.locationdTemporaryError) - if not self.sm['liveParameters'].valid and cal_status == log.LiveCalibrationData.Status.calibrated and not TESTING_CLOSET and (not SIMULATION or REPLAY): + if (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) # conservative HW alert. if the data or frequency are off, locationd will throw an error @@ -469,7 +493,7 @@ class SelfdriveD(CruiseHelper): # GPS checks gps_ok = self.sm.recv_frame[self.gps_location_service] > 0 and (self.sm.frame - self.sm.recv_frame[self.gps_location_service]) * DT_CTRL < 2.0 - if not gps_ok and self.sm['livePose'].inputsOK and (self.distance_traveled > 1500): + if not gps_ok and self.sm['deviceMotion'].inputsOK and (self.distance_traveled > 1500): self.events.add(EventName.noGps) if gps_ok: self.distance_traveled = 0 @@ -508,9 +532,9 @@ class SelfdriveD(CruiseHelper): timed_out = self.sm.frame * DT_CTRL > 6. if all_valid or timed_out or (SIMULATION and not REPLAY): available_streams = VisionIpcClient.available_streams("camerad", block=False) - if VisionStreamType.VISION_STREAM_ROAD not in available_streams: - self.sm.ignore_alive.append('roadCameraState') - self.sm.ignore_valid.append('roadCameraState') + if VisionStreamType.VISION_STREAM_NARROW_ROAD not in available_streams: + self.sm.ignore_alive.append('narrowRoadCameraState') + self.sm.ignore_valid.append('narrowRoadCameraState') if VisionStreamType.VISION_STREAM_WIDE_ROAD not in available_streams: self.sm.ignore_alive.append('wideRoadCameraState') self.sm.ignore_valid.append('wideRoadCameraState') diff --git a/openpilot/selfdrive/selfdrived/tests/test_alertmanager.py b/openpilot/selfdrive/selfdrived/tests/test_alertmanager.py index 8f7c79878c..67cd8f087f 100644 --- a/openpilot/selfdrive/selfdrived/tests/test_alertmanager.py +++ b/openpilot/selfdrive/selfdrived/tests/test_alertmanager.py @@ -1,12 +1,13 @@ import random +from openpilot.common.test import OpenpilotTestCase from openpilot.selfdrive.selfdrived.events import Alert, EVENTS from openpilot.selfdrive.selfdrived.alertmanager import AlertManager from openpilot.sunnypilot.selfdrive.selfdrived.events_base import EmptyAlert -class TestAlertManager: +class TestAlertManager(OpenpilotTestCase): def test_duration(self): """ diff --git a/openpilot/selfdrive/selfdrived/tests/test_alerts.py b/openpilot/selfdrive/selfdrived/tests/test_alerts.py index 0210c138da..9737aa9de7 100644 --- a/openpilot/selfdrive/selfdrived/tests/test_alerts.py +++ b/openpilot/selfdrive/selfdrived/tests/test_alerts.py @@ -2,8 +2,8 @@ import copy import json import os import random -from PIL import Image, ImageDraw, ImageFont +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import log from opendbc.car.structs import car from openpilot.cereal.messaging import SubMaster @@ -24,7 +24,7 @@ for event_types in EVENTS.values(): ALERTS.append(alert) -class TestAlerts: +class TestAlerts(OpenpilotTestCase): @classmethod def setup_class(cls): @@ -46,41 +46,6 @@ class TestAlerts: fail_msg = f"{name} @{e} not in EVENTS" assert e in EVENTS.keys(), fail_msg - # ensure alert text doesn't exceed allowed width - def test_alert_text_length(self): - font_path = os.path.join(BASEDIR, "openpilot/selfdrive/assets/fonts") - regular_font_path = os.path.join(font_path, "Inter-SemiBold.ttf") - bold_font_path = os.path.join(font_path, "Inter-Bold.ttf") - semibold_font_path = os.path.join(font_path, "Inter-SemiBold.ttf") - - max_text_width = 2160 - 300 # full screen width is usable, minus sidebar - draw = ImageDraw.Draw(Image.new('RGB', (0, 0))) - - fonts = { - AlertSize.small: [ImageFont.truetype(semibold_font_path, 74)], - AlertSize.mid: [ImageFont.truetype(bold_font_path, 88), - ImageFont.truetype(regular_font_path, 66)], - } - - for alert in ALERTS: - if not isinstance(alert, Alert): - alert = alert(self.CP, self.CS, self.sm, False, 100, log.LongitudinalPersonality.standard) - - # for full size alerts, both text fields wrap the text, - # so it's unlikely that they would go past the max width - if alert.alert_size in (AlertSize.none, AlertSize.full): - continue - - for i, txt in enumerate([alert.alert_text_1, alert.alert_text_2]): - if i >= len(fonts[alert.alert_size]): - break - - font = fonts[alert.alert_size][i] - left, _, right, _ = draw.textbbox((0, 0), txt, font) - width = right - left - msg = f"type: {alert.alert_type} msg: {txt}" - assert width <= max_text_width, msg - def test_alert_sanity_check(self): for event_types in EVENTS.values(): for event_type, a in event_types.items(): diff --git a/openpilot/selfdrive/selfdrived/tests/test_state_machine.py b/openpilot/selfdrive/selfdrived/tests/test_state_machine.py index ec8f068039..22f664718e 100644 --- a/openpilot/selfdrive/selfdrived/tests/test_state_machine.py +++ b/openpilot/selfdrive/selfdrived/tests/test_state_machine.py @@ -1,3 +1,4 @@ +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import log from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.selfdrived.state import StateMachine, SOFT_DISABLE_TIME @@ -21,7 +22,7 @@ def make_event(event_types: list[str | None]): return 0 -class TestStateMachine: +class TestStateMachine(OpenpilotTestCase): def setup_method(self): self.events = Events() self.state_machine = StateMachine() diff --git a/openpilot/selfdrive/test/cpp_harness.py b/openpilot/selfdrive/test/cpp_harness.py deleted file mode 100755 index f9f425102b..0000000000 --- a/openpilot/selfdrive/test/cpp_harness.py +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env python3 -import subprocess -import sys - -from openpilot.common.prefix import OpenpilotPrefix - -with OpenpilotPrefix(): - ret = subprocess.call(sys.argv[1:]) - -sys.exit(ret) diff --git a/openpilot/selfdrive/test/fuzzy_generation.py b/openpilot/selfdrive/test/fuzzy_generation.py deleted file mode 100644 index c97221ae9e..0000000000 --- a/openpilot/selfdrive/test/fuzzy_generation.py +++ /dev/null @@ -1,81 +0,0 @@ -import capnp -import hypothesis.strategies as st -from typing import Any -from collections.abc import Callable -from functools import cache - -from openpilot.cereal import log - -DrawType = Callable[[st.SearchStrategy], Any] - - -class FuzzyGenerator: - def __init__(self, draw: DrawType, real_floats: bool): - self.draw = draw - self.native_type_map = FuzzyGenerator._get_native_type_map(real_floats) - - def generate_native_type(self, field: str) -> st.SearchStrategy[bool | int | float | str | bytes]: - value_func = self.native_type_map.get(field) - if value_func is not None: - return value_func - else: - raise NotImplementedError(f'Invalid type: {field}') - - def generate_field(self, field: capnp.lib.capnp._StructSchemaField) -> st.SearchStrategy: - def rec(field_type: capnp.lib.capnp._DynamicStructReader) -> st.SearchStrategy: - type_which = field_type.which() - if type_which == 'struct': - return self.generate_struct(field.schema.elementType if base_type == 'list' else field.schema) - elif type_which == 'list': - return st.lists(rec(field_type.list.elementType)) - elif type_which == 'enum': - schema = field.schema.elementType if base_type == 'list' else field.schema - return st.sampled_from(list(schema.enumerants.keys())) - else: - return self.generate_native_type(type_which) - - try: - if hasattr(field.proto, 'slot'): - slot_type = field.proto.slot.type - base_type = slot_type.which() - return rec(slot_type) - else: - return self.generate_struct(field.schema) - except capnp.lib.capnp.KjException: - return self.generate_struct(field.schema) - - def generate_struct(self, schema: capnp.lib.capnp._StructSchema, event: str | None = None) -> st.SearchStrategy[dict[str, Any]]: - single_fill: tuple[str, ...] = (event,) if event else (self.draw(st.sampled_from(schema.union_fields)),) if schema.union_fields else () - fields_to_generate = [f for f in schema.non_union_fields + single_fill if not f.endswith('DEPRECATED') and f != 'deprecated'] - return st.fixed_dictionaries({field: self.generate_field(schema.fields[field]) for field in fields_to_generate}) - - @staticmethod - @cache - def _get_native_type_map(real_floats: bool) -> dict[str, st.SearchStrategy]: - return { - 'bool': st.booleans(), - 'int8': st.integers(min_value=-2**7, max_value=2**7-1), - 'int16': st.integers(min_value=-2**15, max_value=2**15-1), - 'int32': st.integers(min_value=-2**31, max_value=2**31-1), - 'int64': st.integers(min_value=-2**63, max_value=2**63-1), - 'uint8': st.integers(min_value=0, max_value=2**8-1), - 'uint16': st.integers(min_value=0, max_value=2**16-1), - 'uint32': st.integers(min_value=0, max_value=2**32-1), - 'uint64': st.integers(min_value=0, max_value=2**64-1), - 'float32': st.floats(width=32, allow_nan=not real_floats, allow_infinity=not real_floats), - 'float64': st.floats(width=64, allow_nan=not real_floats, allow_infinity=not real_floats), - 'text': st.text(max_size=1000), - 'data': st.binary(max_size=1000), - 'anyPointer': st.text(), # Note: No need to define a separate function for anyPointer - } - - @classmethod - def get_random_msg(cls, draw: DrawType, struct: capnp.lib.capnp._StructModule, real_floats: bool = False) -> dict[str, Any]: - fg = cls(draw, real_floats=real_floats) - data: dict[str, Any] = draw(fg.generate_struct(struct.schema)) - return data - - @classmethod - def get_random_event_msg(cls, draw: DrawType, events: list[str], real_floats: bool = False) -> list[dict[str, Any]]: - fg = cls(draw, real_floats=real_floats) - return [draw(fg.generate_struct(log.Event.schema, e)) for e in sorted(events)] diff --git a/openpilot/selfdrive/test/helpers.py b/openpilot/selfdrive/test/helpers.py index 9d954216b8..71cdcee5e7 100644 --- a/openpilot/selfdrive/test/helpers.py +++ b/openpilot/selfdrive/test/helpers.py @@ -3,7 +3,6 @@ import http.server import os import threading import time -import pytest from functools import wraps @@ -23,16 +22,16 @@ def set_params_enabled(): params.put_bool("OpenpilotEnabledToggle", True, block=True) # valid calib - msg = messaging.new_message('liveCalibration') - msg.liveCalibration.validBlocks = 20 - msg.liveCalibration.rpyCalib = [0.0, 0.0, 0.0] + msg = messaging.new_message('extrinsicsCalibration') + msg.extrinsicsCalibration.validBlocks = 20 + msg.extrinsicsCalibration.rpyCalib = [0.0, 0.0, 0.0] params.put("CalibrationParams", msg.to_bytes(), block=True) def release_only(f): @wraps(f) def wrap(self, *args, **kwargs): if "RELEASE" not in os.environ: - pytest.skip("This test is only for release branches") + self.skipTest("This test is only for release branches") f(self, *args, **kwargs) return wrap diff --git a/openpilot/selfdrive/test/longitudinal_maneuvers/maneuver.py b/openpilot/selfdrive/test/longitudinal_maneuvers/maneuver.py index ba0379f2d7..99a7d8d690 100644 --- a/openpilot/selfdrive/test/longitudinal_maneuvers/maneuver.py +++ b/openpilot/selfdrive/test/longitudinal_maneuvers/maneuver.py @@ -43,6 +43,7 @@ class Maneuver: valid = True logs = [] + not_starting_t = 0.0 while plant.current_time < self.duration: speed_lead = np.interp(plant.current_time, self.breakpoints, self.speed_lead_values) prob_lead = np.interp(plant.current_time, self.breakpoints, self.prob_lead_values) @@ -68,8 +69,13 @@ class Maneuver: valid = False if self.ensure_start and log['v_rel'] > 0 and log['acceleration'] < 1e-3: - print('LongitudinalPlanner not starting!') - valid = False + if not_starting_t == 0.0: + not_starting_t = plant.current_time + elif plant.current_time - not_starting_t > 0.5: + print('LongitudinalPlanner not starting!') + valid = False + else: + not_starting_t = 0.0 if self.ensure_slowdown and log['speed'] > 5.5: print('LongitudinalPlanner not slowing down!') diff --git a/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py b/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py index abbd6be1d6..b4e8d76d6d 100755 --- a/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py +++ b/openpilot/selfdrive/test/longitudinal_maneuvers/plant.py @@ -66,7 +66,7 @@ class Plant: control = messaging.new_message('controlsState') ss = messaging.new_message('selfdriveState') car_state = messaging.new_message('carState') - lp = messaging.new_message('liveParameters') + lp = messaging.new_message('vehicleParameters') car_control = messaging.new_message('carControl') model = messaging.new_message('modelV2') car_state_sp = messaging.new_message('carStateSP') @@ -112,7 +112,7 @@ class Plant: position = log.XYZTData.new_message() position.x = [float(x) for x in (self.speed + 0.5) * np.array(ModelConstants.T_IDXS)] model.modelV2.position = position - model.modelV2.action.desiredAcceleration = float(self.acceleration + 0.1) + model.modelV2.action.desiredAcceleration = float(self.acceleration + 0.5) velocity = log.XYZTData.new_message() velocity.x = [float(x) for x in (self.speed + 0.5) * np.ones_like(ModelConstants.T_IDXS)] velocity.x[0] = float(self.speed) # always start at current speed @@ -137,7 +137,7 @@ class Plant: 'carControl': car_control.carControl, 'controlsState': control.controlsState, 'selfdriveState': ss.selfdriveState, - 'liveParameters': lp.liveParameters, + 'vehicleParameters': lp.vehicleParameters, 'modelV2': model.modelV2, 'carStateSP': car_state_sp.carStateSP, 'liveMapDataSP': live_map_data_sp.liveMapDataSP, diff --git a/openpilot/selfdrive/test/longitudinal_maneuvers/test_longitudinal.py b/openpilot/selfdrive/test/longitudinal_maneuvers/test_longitudinal.py index b8f97c5041..c8694b6ac2 100644 --- a/openpilot/selfdrive/test/longitudinal_maneuvers/test_longitudinal.py +++ b/openpilot/selfdrive/test/longitudinal_maneuvers/test_longitudinal.py @@ -1,4 +1,5 @@ import itertools +from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized_class from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import STOP_DISTANCE @@ -150,7 +151,9 @@ def create_maneuvers(kwargs): enabled=False, **kwargs, ), - Maneuver( + ] + if not kwargs['e2e']: + maneuvers.append(Maneuver( "slow to 5m/s with allow_throttle = False and pitch = +0.1", duration=30., initial_speed=20., @@ -161,7 +164,7 @@ def create_maneuvers(kwargs): breakpoints=[0.0, 2., 2.01], ensure_slowdown=True, **kwargs, - )] + )) if not kwargs['force_decel']: # controls relies on planner commanding to move for stock-ACC resume spamming maneuvers.append(Maneuver( @@ -179,7 +182,7 @@ def create_maneuvers(kwargs): @parameterized_class(("e2e", "force_decel"), itertools.product([True, False], repeat=2)) -class TestLongitudinalControl: +class TestLongitudinalControl(OpenpilotTestCase): e2e: bool force_decel: bool diff --git a/openpilot/selfdrive/test/process_replay/README.md b/openpilot/selfdrive/test/process_replay/README.md index 28f3b7cd2a..36d669dda6 100644 --- a/openpilot/selfdrive/test/process_replay/README.md +++ b/openpilot/selfdrive/test/process_replay/README.md @@ -85,7 +85,7 @@ Supported processes: * modeld * dmonitoringmodeld -Certain processes may require an initial state, which is usually supplied within `Params` and persisting from segment to segment (e.g CalibrationParams, LiveParameters). The `custom_params` is dictionary used to prepopulate `Params` with arbitrary values. The `get_custom_params_from_lr` helper is provided to fetch meaningful values from log files. +Certain processes may require an initial state, which is usually supplied within `Params` and persists from segment to segment (for example `CalibrationParams` or the learner cache keys like `LiveParametersV2`). The `custom_params` is a dictionary used to prepopulate `Params` with arbitrary values. The `get_custom_params_from_lr` helper is provided to fetch meaningful values from log files. ```py from openpilot.selfdrive.test.process_replay import get_custom_params_from_lr @@ -104,9 +104,9 @@ Replaying processes that use VisionIPC (e.g. modeld, dmonitoringmodeld) require from openpilot.tools.lib.framereader import FrameReader frs = { - 'roadCameraState': FrameReader(...), + 'narrowRoadCameraState': FrameReader(...), 'wideRoadCameraState': FrameReader(...), - 'driverCameraState': FrameReader(...), + 'cabinCameraState': FrameReader(...), } output_logs = replay_process_with_name(['modeld', 'dmonitoringmodeld'], lr, frs=frs) diff --git a/openpilot/selfdrive/test/process_replay/migration.py b/openpilot/selfdrive/test/process_replay/migration.py index 9b0fd2b91f..646728e673 100644 --- a/openpilot/selfdrive/test/process_replay/migration.py +++ b/openpilot/selfdrive/test/process_replay/migration.py @@ -15,7 +15,7 @@ from opendbc.car.gm.values import GMSafetyFlags from openpilot.selfdrive.modeld.constants import ModelConstants from openpilot.selfdrive.modeld.fill_model_msg import fill_xyz_poly, fill_lane_line_meta from openpilot.selfdrive.test.process_replay.vision_meta import meta_from_encode_index -from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N, get_accel_from_plan +from openpilot.selfdrive.controls.lib.drive_helpers import CONTROL_N, get_accel_from_plan, should_stop from openpilot.system.manager.process_config import managed_processes from openpilot.tools.lib.logreader import LogIterable @@ -40,7 +40,8 @@ def migrate_all(lr: LogIterable, manager_states: bool = False, panda_states: boo migrate_carOutput, migrate_controlsState, migrate_carState, - migrate_livePose, + migrate_liveLocationKalman, + migrate_deviceMotion, migrate_liveTracks, migrate_driverAssistance, migrate_drivingModelData, @@ -129,8 +130,10 @@ def migrate_longitudinalPlan(msgs): if msg.which() != 'longitudinalPlan': continue new_msg = msg.as_builder() - a_target, should_stop = get_accel_from_plan(msg.longitudinalPlan.speeds, msg.longitudinalPlan.accels, ModelConstants.T_IDXS[:CONTROL_N]) - new_msg.longitudinalPlan.aTarget, new_msg.longitudinalPlan.shouldStop = float(a_target), bool(should_stop) + a_target = get_accel_from_plan(msg.longitudinalPlan.speeds, msg.longitudinalPlan.accels, ModelConstants.T_IDXS[:CONTROL_N]) + v_now = msg.longitudinalPlan.speeds[0] if len(msg.longitudinalPlan.speeds) == CONTROL_N else 0.0 + stop = should_stop(v_now, a_target) + new_msg.longitudinalPlan.aTarget, new_msg.longitudinalPlan.shouldStop = float(a_target), bool(stop) ops.append((index, as_reader(new_msg))) return ops, [], [] @@ -161,11 +164,11 @@ def migrate_drivingModelData(msgs): return [], add_ops, [] -@migration(inputs=["liveTracksDEPRECATED"], product="liveTracks") +@migration(inputs=["liveTracksDEPRECATED"], product="radarTracks") def migrate_liveTracks(msgs): ops = [] for index, msg in msgs: - new_msg = messaging.new_message('liveTracks') + new_msg = messaging.new_message('radarTracks') new_msg.valid = msg.valid new_msg.logMonoTime = msg.logMonoTime @@ -179,42 +182,42 @@ def migrate_liveTracks(msgs): pt.vRel = track.vRel pts.append(pt) - new_msg.liveTracks.points = pts + new_msg.radarTracks.points = pts ops.append((index, as_reader(new_msg))) return ops, [], [] -@migration(inputs=["liveLocationKalmanDEPRECATED"], product="livePose") +@migration(inputs=["liveLocationKalmanDEPRECATED"], product="deviceMotion") def migrate_liveLocationKalman(msgs): nans = [float('nan')] * 3 ops = [] for index, msg in msgs: - m = messaging.new_message('livePose') + m = messaging.new_message('deviceMotion') m.valid = msg.valid m.logMonoTime = msg.logMonoTime - m.livePose.timestamp = msg.logMonoTime + m.deviceMotion.timestamp = msg.logMonoTime for field in ["orientationNED", "velocityDevice", "accelerationDevice", "angularVelocityDevice"]: - lp_field, llk_field = getattr(m.livePose, field), getattr(msg.liveLocationKalmanDEPRECATED, field) + lp_field, llk_field = getattr(m.deviceMotion, field), getattr(msg.liveLocationKalmanDEPRECATED, field) lp_field.x, lp_field.y, lp_field.z = llk_field.value or nans lp_field.xStd, lp_field.yStd, lp_field.zStd = llk_field.std or nans lp_field.valid = llk_field.valid for flag in ["inputsOK", "posenetOK", "sensorsOK"]: - setattr(m.livePose, flag, getattr(msg.liveLocationKalmanDEPRECATED, flag)) + setattr(m.deviceMotion, flag, getattr(msg.liveLocationKalmanDEPRECATED, flag)) ops.append((index, as_reader(m))) return ops, [], [] -@migration(inputs=["livePose"]) -def migrate_livePose(msgs): +@migration(inputs=["deviceMotion"]) +def migrate_deviceMotion(msgs): ops = [] - needs_migration = all(msg.livePose.timestamp == 0 for _, msg in msgs if msg.which() == 'livePose') + needs_migration = all(msg.deviceMotion.timestamp == 0 for _, msg in msgs if msg.which() == 'deviceMotion') if not needs_migration: return [], [], [] for index, msg in msgs: - if msg.which() == "livePose": + if msg.which() == "deviceMotion": new_msg = msg.as_builder() - new_msg.livePose.timestamp = msg.logMonoTime + new_msg.deviceMotion.timestamp = msg.logMonoTime ops.append((index, as_reader(new_msg))) return ops, [], [] @@ -313,7 +316,7 @@ def migrate_pandaStates(msgs): "CHEVROLET_BOLT_EUV": GMSafetyFlags.EV | GMSafetyFlags.HW_CAM, } # TODO: get new Ford route - safety_param_migration |= dict.fromkeys((set(FORD) - FORD.with_flags(FordFlags.CANFD)), FordSafetyFlags.LONG_CONTROL) + safety_param_migration |= dict.fromkeys({p for p in FORD if not (p.config.flags & FordFlags.CANFD)}, FordSafetyFlags.LONG_CONTROL) # Migrate safety param base on carParams CP = next((m.carParams for _, m in msgs if m.which() == 'carParams'), None) @@ -361,7 +364,7 @@ def migrate_peripheralState(msgs): return [], add_ops, [] -@migration(inputs=["roadEncodeIdx", "wideRoadEncodeIdx", "driverEncodeIdx", "roadCameraState", "wideRoadCameraState", "driverCameraState"]) +@migration(inputs=["narrowRoadEncodeIdx", "wideRoadEncodeIdx", "cabinEncodeIdx", "narrowRoadCameraState", "wideRoadCameraState", "cabinCameraState"]) def migrate_cameraStates(msgs): add_ops, del_ops = [], [] frame_to_encode_id = defaultdict(dict) @@ -369,7 +372,7 @@ def migrate_cameraStates(msgs): min_frame_id = defaultdict(lambda: float('inf')) for _, msg in msgs: - if msg.which() not in ["roadEncodeIdx", "wideRoadEncodeIdx", "driverEncodeIdx"]: + if msg.which() not in ["narrowRoadEncodeIdx", "wideRoadEncodeIdx", "cabinEncodeIdx"]: continue encode_index = getattr(msg, msg.which()) @@ -379,7 +382,7 @@ def migrate_cameraStates(msgs): frame_to_encode_id[meta.camera_state][encode_index.frameId] = encode_index.segmentId for index, msg in msgs: - if msg.which() not in ["roadCameraState", "wideRoadCameraState", "driverCameraState"]: + if msg.which() not in ["narrowRoadCameraState", "wideRoadCameraState", "cabinCameraState"]: continue camera_state = getattr(msg, msg.which()) @@ -392,7 +395,7 @@ def migrate_cameraStates(msgs): del_ops.append(index) continue - # fallback mechanism for logs without encodeIdx (e.g. logs from before 2022 with dcamera recording disabled) + # fallback mechanism for logs without encodeIdx (e.g. logs from before 2022 with driver recording disabled) # try to fake encode_id by subtracting lowest frameId encode_id = camera_state.frameId - min_frame_id[msg.which()] print(f"Faking encodeId to {encode_id} for camera feed {msg.which()} with frameId: {camera_state.frameId}") diff --git a/openpilot/selfdrive/test/process_replay/model_replay.py b/openpilot/selfdrive/test/process_replay/model_replay.py index 3e5616e702..ba610c6a2b 100755 --- a/openpilot/selfdrive/test/process_replay/model_replay.py +++ b/openpilot/selfdrive/test/process_replay/model_replay.py @@ -146,16 +146,17 @@ def trim_logs(logs, start_frame, end_frame, frs_types, include_all_types): def model_replay(lr, frs): # modeld is using frame pairs - modeld_logs = trim_logs(lr, START_FRAME, END_FRAME, {"roadCameraState", "wideRoadCameraState"}, - {"roadEncodeIdx", "wideRoadEncodeIdx", "carParams", "carState", "carControl", "can"}) - dmodeld_logs = trim_logs(lr, START_FRAME, END_FRAME, {"driverCameraState"}, {"driverEncodeIdx", "carParams", "can"}) + camera_states = {"narrowRoadCameraState", "wideRoadCameraState"} + modeld_logs = trim_logs(lr, START_FRAME, END_FRAME, camera_states, + {"narrowRoadEncodeIdx", "wideRoadEncodeIdx", "carParams", "carState", "carControl", "can"}) + dmodeld_logs = trim_logs(lr, START_FRAME, END_FRAME, {"cabinCameraState"}, {"cabinEncodeIdx", "carParams", "can"}) if not SEND_EXTRA_INPUTS: - modeld_logs = [msg for msg in modeld_logs if msg.which() != 'liveCalibration'] - dmodeld_logs = [msg for msg in dmodeld_logs if msg.which() != 'liveCalibration'] + modeld_logs = [msg for msg in modeld_logs if msg.which() != 'extrinsicsCalibration'] + dmodeld_logs = [msg for msg in dmodeld_logs if msg.which() != 'extrinsicsCalibration'] # initial setup - for s in ('liveCalibration', 'deviceState'): + for s in ('extrinsicsCalibration', 'deviceState'): msg = next(msg for msg in lr if msg.which() == s).as_builder() msg.logMonoTime = lr[0].logMonoTime modeld_logs.insert(1, msg.as_reader()) @@ -209,8 +210,8 @@ def get_frames(): print(f"Failed to load frames from cache {cache_name}: {e}") frs = { - 'roadCameraState': FrameReader(get_url(TEST_ROUTE, SEGMENT, "fcamera.hevc"), pix_fmt='nv12', cache_size=END_FRAME - START_FRAME), - 'driverCameraState': FrameReader(get_url(TEST_ROUTE, SEGMENT, "dcamera.hevc"), pix_fmt='nv12', cache_size=END_FRAME - START_FRAME), + 'narrowRoadCameraState': FrameReader(get_url(TEST_ROUTE, SEGMENT, "fcamera.hevc"), pix_fmt='nv12', cache_size=END_FRAME - START_FRAME), + 'cabinCameraState': FrameReader(get_url(TEST_ROUTE, SEGMENT, "dcamera.hevc"), pix_fmt='nv12', cache_size=END_FRAME - START_FRAME), 'wideRoadCameraState': FrameReader(get_url(TEST_ROUTE, SEGMENT, "ecamera.hevc"), pix_fmt='nv12', cache_size=END_FRAME - START_FRAME), } for fr in frs.values(): diff --git a/openpilot/selfdrive/test/process_replay/process_replay.py b/openpilot/selfdrive/test/process_replay/process_replay.py index 4834cc44e6..8e5d224ea8 100755 --- a/openpilot/selfdrive/test/process_replay/process_replay.py +++ b/openpilot/selfdrive/test/process_replay/process_replay.py @@ -400,7 +400,7 @@ class ModeldCameraSyncRcvCallback: def __call__(self, msg, cfg, frame): self.is_dual_camera = len(cfg.vision_pubs) == 2 - if msg.which() == "roadCameraState": + if msg.which() == "narrowRoadCameraState": self.road_present = True elif msg.which() == "wideRoadCameraState": self.wide_road_present = True @@ -436,11 +436,11 @@ CONFIGS = [ ProcessConfig( proc_name="selfdrived", pubs=[ - "carState", "deviceState", "pandaStates", "peripheralState", "liveCalibration", "driverMonitoringState", - "longitudinalPlan", "livePose", "liveDelay", "liveParameters", "radarState", "modelV2", - "driverCameraState", "roadCameraState", "wideRoadCameraState", "managerState", "liveTorqueParameters", + "carState", "deviceState", "pandaStates", "peripheralState", "extrinsicsCalibration", "driverMonitoringState", + "longitudinalPlan", "deviceMotion", "lateralDelay", "vehicleParameters", "radarState", "modelV2", + "cabinCameraState", "narrowRoadCameraState", "wideRoadCameraState", "managerState", "lateralTorqueParameters", "accelerometer", "gyroscope", "carOutput", "gpsLocationExternal", "gpsLocation", "controlsState", - "carControl", "driverAssistance", "alertDebug", "audioFeedback", + "carControl", "driverAssistance", "alertDebug", ], subs=["selfdriveState", "onroadEvents"], ignore=["logMonoTime"], @@ -452,8 +452,8 @@ CONFIGS = [ ), ProcessConfig( proc_name="controlsd", - pubs=["liveParameters", "liveTorqueParameters", "modelV2", "selfdriveState", - "liveCalibration", "livePose", "longitudinalPlan", "carState", "carOutput", + pubs=["vehicleParameters", "lateralTorqueParameters", "modelV2", "selfdriveState", + "extrinsicsCalibration", "deviceMotion", "longitudinalPlan", "carState", "carOutput", "driverMonitoringState", "onroadEvents", "driverAssistance"], subs=["carControl", "controlsState"], ignore=["logMonoTime", ], @@ -464,7 +464,7 @@ CONFIGS = [ ProcessConfig( proc_name="card", pubs=["pandaStates", "carControl", "onroadEvents", "can"], - subs=["sendcan", "carState", "carParams", "carOutput", "liveTracks"], + subs=["sendcan", "carState", "carParams", "carOutput", "radarTracks"], ignore=["logMonoTime", "carState.cumLagMs"], init_callback=card_fingerprint_callback, should_recv_callback=card_rcv_callback, @@ -475,7 +475,7 @@ CONFIGS = [ ), ProcessConfig( proc_name="radard", - pubs=["liveTracks", "carState", "modelV2"], + pubs=["radarTracks", "carState", "modelV2"], subs=["radarState"], ignore=["logMonoTime"], init_callback=get_car_params_callback, @@ -483,7 +483,7 @@ CONFIGS = [ ), ProcessConfig( proc_name="plannerd", - pubs=["modelV2", "carControl", "carState", "controlsState", "liveParameters", "radarState", "selfdriveState"], + pubs=["modelV2", "carControl", "carState", "controlsState", "vehicleParameters", "radarState", "selfdriveState"], subs=["longitudinalPlan", "driverAssistance"], ignore=["logMonoTime", "longitudinalPlan.processingDelay", "longitudinalPlan.solverExecutionTime"], init_callback=get_car_params_callback, @@ -493,14 +493,14 @@ CONFIGS = [ ProcessConfig( proc_name="calibrationd", pubs=["carState", "cameraOdometry"], - subs=["liveCalibration"], + subs=["extrinsicsCalibration"], ignore=["logMonoTime"], init_callback=get_car_params_callback, should_recv_callback=MessageBasedRcvCallback("cameraOdometry", True), ), ProcessConfig( proc_name="dmonitoringd", - pubs=["driverStateV2", "liveCalibration", "carState", "modelV2", "selfdriveState", "carControl"], + pubs=["driverStateV2", "extrinsicsCalibration", "carState", "modelV2", "selfdriveState", "carControl"], subs=["driverMonitoringState"], ignore=["logMonoTime"], should_recv_callback=MessageBasedRcvCallback("driverStateV2"), @@ -509,9 +509,9 @@ CONFIGS = [ ProcessConfig( proc_name="locationd", pubs=[ - "cameraOdometry", "accelerometer", "gyroscope", "liveCalibration", "carState" + "cameraOdometry", "accelerometer", "gyroscope", "extrinsicsCalibration", "carState" ], - subs=["livePose"], + subs=["deviceMotion"], ignore=["logMonoTime"], should_recv_callback=MessageBasedRcvCallback("cameraOdometry"), tolerance=NUMPY_TOLERANCE, @@ -519,21 +519,21 @@ CONFIGS = [ ), ProcessConfig( proc_name="paramsd", - pubs=["livePose", "liveCalibration", "carState"], - subs=["liveParameters"], + pubs=["deviceMotion", "extrinsicsCalibration", "carState"], + subs=["vehicleParameters"], ignore=["logMonoTime"], init_callback=get_car_params_callback, - should_recv_callback=MessageBasedRcvCallback("livePose"), + should_recv_callback=MessageBasedRcvCallback("deviceMotion"), tolerance=NUMPY_TOLERANCE, processing_time=0.004, ), ProcessConfig( proc_name="lagd", - pubs=["livePose", "liveCalibration", "carState", "carControl", "controlsState"], - subs=["liveDelay"], + pubs=["deviceMotion", "extrinsicsCalibration", "carState", "carControl", "controlsState"], + subs=["lateralDelay"], ignore=["logMonoTime"], init_callback=get_car_params_callback, - should_recv_callback=MessageBasedRcvCallback("livePose"), + should_recv_callback=MessageBasedRcvCallback("deviceMotion"), tolerance=NUMPY_TOLERANCE, ), ProcessConfig( @@ -544,37 +544,38 @@ CONFIGS = [ ), ProcessConfig( proc_name="torqued", - pubs=["livePose", "liveCalibration", "liveDelay", "carState", "carControl", "carOutput"], - subs=["liveTorqueParameters"], + pubs=["deviceMotion", "extrinsicsCalibration", "lateralDelay", "carState", "carControl", "carOutput"], + subs=["lateralTorqueParameters"], ignore=["logMonoTime"], init_callback=get_car_params_callback, - should_recv_callback=MessageBasedRcvCallback("livePose", True), + should_recv_callback=MessageBasedRcvCallback("deviceMotion", True), tolerance=NUMPY_TOLERANCE, ), ProcessConfig( proc_name="modeld", - pubs=["deviceState", "roadCameraState", "wideRoadCameraState", "liveCalibration", "liveDelay", "driverMonitoringState", "carState", "carControl"], + pubs=["deviceState", "narrowRoadCameraState", "wideRoadCameraState", "extrinsicsCalibration", "lateralDelay", + "driverMonitoringState", "carState", "carControl"], subs=["modelV2", "drivingModelData", "cameraOdometry"], ignore=["logMonoTime", "modelV2.frameDropPerc", "modelV2.modelExecutionTime", "drivingModelData.frameDropPerc", "drivingModelData.modelExecutionTime"], should_recv_callback=ModeldCameraSyncRcvCallback(), tolerance=NUMPY_TOLERANCE, processing_time=0.020, - main_pub=vipc_get_endpoint_name("camerad", meta_from_camera_state("roadCameraState").stream), - vision_pubs=["roadCameraState", "wideRoadCameraState"], + main_pub=vipc_get_endpoint_name("camerad", meta_from_camera_state("narrowRoadCameraState").stream), + vision_pubs=["narrowRoadCameraState", "wideRoadCameraState"], ignore_alive_pubs=["wideRoadCameraState"], init_callback=get_car_params_callback, ), ProcessConfig( proc_name="dmonitoringmodeld", - pubs=["liveCalibration", "driverCameraState"], + pubs=["extrinsicsCalibration", "cabinCameraState"], subs=["driverStateV2"], ignore=["logMonoTime", "driverStateV2.modelExecutionTime", "driverStateV2.gpuExecutionTime"], - should_recv_callback=MessageBasedRcvCallback("driverCameraState"), + should_recv_callback=MessageBasedRcvCallback("cabinCameraState"), tolerance=NUMPY_TOLERANCE, processing_time=0.020, - main_pub=vipc_get_endpoint_name("camerad", meta_from_camera_state("driverCameraState").stream), - vision_pubs=["driverCameraState"], - ignore_alive_pubs=["driverCameraState"], + main_pub=vipc_get_endpoint_name("camerad", meta_from_camera_state("cabinCameraState").stream), + vision_pubs=["cabinCameraState"], + ignore_alive_pubs=["cabinCameraState"], ), ] @@ -590,30 +591,30 @@ def get_custom_params_from_lr(lr: LogIterable, initial_state: str = "first") -> """ Use this to get custom params dict based on provided logs. Useful when replaying following processes: calibrationd, paramsd, torqued - The params may be based on first or last message of given type (carParams, liveCalibration, liveParameters, liveTorqueParameters) in the logs. + The params may be based on first or last message of given type (carParams, extrinsicsCalibration, vehicleParameters, lateralTorqueParameters) in the logs. """ car_params = [m for m in lr if m.which() == "carParams"] - live_calibration = [m for m in lr if m.which() == "liveCalibration"] - live_parameters = [m for m in lr if m.which() == "liveParameters"] - live_torque_parameters = [m for m in lr if m.which() == "liveTorqueParameters"] + extrinsics_calibration = [m for m in lr if m.which() == "extrinsicsCalibration"] + vehicle_parameters = [m for m in lr if m.which() == "vehicleParameters"] + torque_parameters = [m for m in lr if m.which() == "lateralTorqueParameters"] assert initial_state in ["first", "last"] msg_index = 0 if initial_state == "first" else -1 - assert len(car_params) > 0, "carParams required for initial state of liveParameters and CarParamsPrevRoute" + assert len(car_params) > 0, "carParams required for initial state of vehicleParameters and CarParamsPrevRoute" CP = car_params[msg_index].carParams custom_params = { "CarParamsPrevRoute": CP.as_builder().to_bytes() } - if len(live_calibration) > 0: - custom_params["CalibrationParams"] = live_calibration[msg_index].as_builder().to_bytes() - if len(live_parameters) > 0: - custom_params["LiveParametersV2"] = live_parameters[msg_index].as_builder().to_bytes() - if len(live_torque_parameters) > 0: - custom_params["LiveTorqueParameters"] = live_torque_parameters[msg_index].as_builder().to_bytes() + if len(extrinsics_calibration) > 0: + custom_params["CalibrationParams"] = extrinsics_calibration[msg_index].as_builder().to_bytes() + if len(vehicle_parameters) > 0: + custom_params["LiveParametersV2"] = vehicle_parameters[msg_index].as_builder().to_bytes() + if len(torque_parameters) > 0: + custom_params["LiveTorqueParameters"] = torque_parameters[msg_index].as_builder().to_bytes() return custom_params diff --git a/openpilot/selfdrive/test/process_replay/regen.py b/openpilot/selfdrive/test/process_replay/regen.py deleted file mode 100755 index c501a4b250..0000000000 --- a/openpilot/selfdrive/test/process_replay/regen.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 -import os -import argparse -import time -import capnp - -from typing import Any -from collections.abc import Iterable - -from openpilot.selfdrive.test.process_replay.process_replay import CONFIGS, FAKEDATA, ProcessConfig, replay_process, get_process_config, \ - check_openpilot_enabled, check_most_messages_valid, get_custom_params_from_lr -from openpilot.selfdrive.test.update_ci_routes import upload_route -from openpilot.tools.lib.framereader import FrameReader -from openpilot.tools.lib.logreader import LogReader, LogIterable, save_log -from openpilot.tools.lib.openpilotci import get_url - - -def regen_segment( - lr: LogIterable, frs: dict[str, Any] | None = None, - processes: Iterable[ProcessConfig] = CONFIGS, disable_tqdm: bool = False -) -> list[capnp._DynamicStructReader]: - all_msgs = sorted(lr, key=lambda m: m.logMonoTime) - custom_params = get_custom_params_from_lr(all_msgs) - - print("Replayed processes:", [p.proc_name for p in processes]) - print("\n\n", "*"*30, "\n\n", sep="") - - output_logs = replay_process(processes, all_msgs, frs, return_all_logs=True, custom_params=custom_params, disable_progress=disable_tqdm) - - return output_logs - - -def setup_data_readers( - route: str, sidx: int, needs_driver_cam: bool = True, needs_road_cam: bool = True, dummy_driver_cam: bool = False -) -> tuple[LogReader, dict[str, Any]]: - lr = LogReader(f"{route}/{sidx}/r") - frs = {} - if needs_road_cam: - frs['roadCameraState'] = FrameReader(get_url(route, str(sidx), "fcamera.hevc")) - if next((True for m in lr if m.which() == "wideRoadCameraState"), False): - frs['wideRoadCameraState'] = FrameReader(get_url(route, str(sidx), "ecamera.hevc")) - if needs_driver_cam: - if dummy_driver_cam: - frs['driverCameraState'] = FrameReader(get_url(route, str(sidx), "fcamera.hevc")) # Use fcam as dummy - else: - device_type = next(str(msg.initData.deviceType) for msg in lr if msg.which() == "initData") - assert device_type != "neo", "Driver camera not supported on neo segments. Use dummy dcamera." - frs['driverCameraState'] = FrameReader(get_url(route, str(sidx), "dcamera.hevc")) - - return lr, frs - - -def regen_and_save( - route: str, sidx: int, processes: str | Iterable[str] = "all", outdir: str = FAKEDATA, - upload: bool = False, disable_tqdm: bool = False, dummy_driver_cam: bool = False -) -> str: - if not isinstance(processes, str) and not hasattr(processes, "__iter__"): - raise ValueError("whitelist_proc must be a string or iterable") - - if processes != "all": - if isinstance(processes, str): - raise ValueError(f"Invalid value for processes: {processes}") - - replayed_processes = [] - for d in processes: - cfg = get_process_config(d) - replayed_processes.append(cfg) - else: - replayed_processes = CONFIGS - - all_vision_pubs = {pub for cfg in replayed_processes for pub in cfg.vision_pubs} - lr, frs = setup_data_readers(route, sidx, - needs_driver_cam="driverCameraState" in all_vision_pubs, - needs_road_cam="roadCameraState" in all_vision_pubs or "wideRoadCameraState" in all_vision_pubs, - dummy_driver_cam=dummy_driver_cam) - output_logs = regen_segment(lr, frs, replayed_processes, disable_tqdm=disable_tqdm) - - log_dir = os.path.join(outdir, time.strftime("%Y-%m-%d--%H-%M-%S--0", time.gmtime())) - rel_log_dir = os.path.relpath(log_dir) - rpath = os.path.join(log_dir, "rlog.zst") - - os.makedirs(log_dir) - save_log(rpath, output_logs, compress=True) - - print("\n\n", "*"*30, "\n\n", sep="") - print("New route:", rel_log_dir, "\n") - - if not check_openpilot_enabled(output_logs): - raise Exception("Route did not engage for long enough") - if not check_most_messages_valid(output_logs): - raise Exception("Route has too many invalid messages") - - if upload: - upload_route(rel_log_dir) - - return rel_log_dir - - -if __name__ == "__main__": - def comma_separated_list(string): - return string.split(",") - - all_procs = [p.proc_name for p in CONFIGS] - parser = argparse.ArgumentParser(description="Generate new segments from old ones") - parser.add_argument("--upload", action="store_true", help="Upload the new segment to the CI bucket") - parser.add_argument("--outdir", help="log output dir", default=FAKEDATA) - parser.add_argument("--dummy-dcamera", action='store_true', help="Use dummy blank driver camera") - parser.add_argument("--whitelist-procs", type=comma_separated_list, default=all_procs, - help="Comma-separated whitelist of processes to regen (e.g. controlsd,radard)") - parser.add_argument("--blacklist-procs", type=comma_separated_list, default=[], - help="Comma-separated blacklist of processes to regen (e.g. controlsd,radard)") - parser.add_argument("route", type=str, help="The source route") - parser.add_argument("seg", type=int, help="Segment in source route") - args = parser.parse_args() - - blacklist_set = set(args.blacklist_procs) - processes = [p for p in args.whitelist_procs if p not in blacklist_set] - regen_and_save(args.route, args.seg, processes=processes, upload=args.upload, outdir=args.outdir, dummy_driver_cam=args.dummy_dcamera) diff --git a/openpilot/selfdrive/test/process_replay/regen_all.py b/openpilot/selfdrive/test/process_replay/regen_all.py deleted file mode 100755 index 78a90b420c..0000000000 --- a/openpilot/selfdrive/test/process_replay/regen_all.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import concurrent.futures -import os -import random -import traceback -from tqdm import tqdm - -from openpilot.common.prefix import OpenpilotPrefix -from openpilot.selfdrive.test.process_replay.regen import regen_and_save -from openpilot.selfdrive.test.process_replay.test_processes import FAKEDATA, source_segments as segments -from openpilot.tools.lib.route import SegmentName - - -def regen_job(segment, upload, disable_tqdm): - with OpenpilotPrefix(): - sn = SegmentName(segment[1]) - fake_dongle_id = 'regen' + ''.join(random.choice('0123456789ABCDEF') for _ in range(11)) - try: - relr = regen_and_save(sn.route_name.canonical_name, sn.segment_num, upload=upload, - outdir=os.path.join(FAKEDATA, fake_dongle_id), disable_tqdm=disable_tqdm, dummy_driver_cam=True) - relr = '|'.join(relr.split('/')[-2:]) - return f' ("{segment[0]}", "{relr}"), ' - except Exception as e: - err = f" {segment} failed: {str(e)}" - err += traceback.format_exc() - err += "\n\n" - return err - - -if __name__ == "__main__": - all_cars = {car for car, _ in segments} - - parser = argparse.ArgumentParser(description="Generate new segments from old ones") - parser.add_argument("-j", "--jobs", type=int, default=1) - parser.add_argument("--no-upload", action="store_true") - parser.add_argument("--whitelist-cars", type=str, nargs="*", default=all_cars, - help="Whitelist given cars from the test (e.g. HONDA)") - parser.add_argument("--blacklist-cars", type=str, nargs="*", default=[], - help="Blacklist given cars from the test (e.g. HONDA)") - args = parser.parse_args() - - tested_cars = set(args.whitelist_cars) - set(args.blacklist_cars) - tested_cars = {c.upper() for c in tested_cars} - tested_segments = [(car, segment) for car, segment in segments if car in tested_cars] - - with concurrent.futures.ProcessPoolExecutor(max_workers=args.jobs) as pool: - p = pool.map(regen_job, tested_segments, [not args.no_upload] * len(tested_segments), [args.jobs > 1] * len(tested_segments)) - msg = "Copy these new segments into test_processes.py:" - for seg in tqdm(p, desc="Generating segments", total=len(tested_segments)): - msg += "\n" + str(seg) - print() - print() - print(msg) diff --git a/openpilot/selfdrive/test/process_replay/test_fuzzy.py b/openpilot/selfdrive/test/process_replay/test_fuzzy.py index 372b368608..3a87f3a7f8 100644 --- a/openpilot/selfdrive/test/process_replay/test_fuzzy.py +++ b/openpilot/selfdrive/test/process_replay/test_fuzzy.py @@ -1,12 +1,10 @@ import copy -import os -from hypothesis import given, HealthCheck, Phase, settings -import hypothesis.strategies as st +from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized +from openpilot.common.fuzzy import capnp_random_dict, fuzzy_test from openpilot.cereal import log from opendbc.car.toyota.values import CAR as TOYOTA -from openpilot.selfdrive.test.fuzzy_generation import FuzzyGenerator import openpilot.selfdrive.test.process_replay.process_replay as pr # These processes currently fail because of unrealistic data breaking assumptions @@ -15,17 +13,16 @@ import openpilot.selfdrive.test.process_replay.process_replay as pr NOT_TESTED = ['selfdrived', 'controlsd', 'card', 'plannerd', 'calibrationd', 'dmonitoringd', 'paramsd', 'dmonitoringmodeld', 'modeld'] TEST_CASES = [(cfg.proc_name, copy.deepcopy(cfg)) for cfg in pr.CONFIGS if cfg.proc_name not in NOT_TESTED] -MAX_EXAMPLES = int(os.environ.get("MAX_EXAMPLES", "10")) -class TestFuzzProcesses: +class TestFuzzProcesses(OpenpilotTestCase): # TODO: make this faster and increase examples @parameterized.expand(TEST_CASES) - @given(st.data()) - @settings(phases=[Phase.generate, Phase.target], max_examples=MAX_EXAMPLES, deadline=1000, - suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large]) - def test_fuzz_process(self, proc_name, cfg, data): - msgs = FuzzyGenerator.get_random_event_msg(data.draw, events=cfg.pubs, real_floats=True) + @fuzzy_test(max_examples=10) + def test_fuzz_process(self, proc_name, cfg, fuzzy): + msgs = [capnp_random_dict(fuzzy, log.Event.schema, event, real_floats=True) for event in sorted(cfg.pubs)] + for i, msg in enumerate(msgs): + msg["logMonoTime"] = i * 1_000_000_000 lr = [log.Event.new_message(**m).as_reader() for m in msgs] cfg.timeout = 5 pr.replay_process(cfg, lr, fingerprint=TOYOTA.TOYOTA_COROLLA_TSS2, disable_progress=True) diff --git a/openpilot/selfdrive/test/process_replay/test_regen.py b/openpilot/selfdrive/test/process_replay/test_regen.py deleted file mode 100644 index f4942e486c..0000000000 --- a/openpilot/selfdrive/test/process_replay/test_regen.py +++ /dev/null @@ -1,37 +0,0 @@ -from openpilot.common.parameterized import parameterized - -from openpilot.selfdrive.test.process_replay.regen import regen_segment -from openpilot.selfdrive.test.process_replay.process_replay import check_openpilot_enabled -from openpilot.tools.lib.openpilotci import get_url -from openpilot.tools.lib.logreader import LogReader -from openpilot.tools.lib.framereader import FrameReader - -TESTED_SEGMENTS = [ - ("PRIUS_C2", "0982d79ebb0de295|2021-01-04--17-13-21--13"), # TOYOTA.TOYOTA_PRIUS: NEO, pandaStateDEPRECATED, no peripheralState, sensorEventsDEPRECATED - # Enable these once regen on CI becomes faster or use them for different tests running controlsd in isolation - # ("MAZDA_C3", "bd6a637565e91581|2021-10-30--15-14-53--4"), # MAZDA.CX9_2021: TICI, incomplete managerState - # ("FORD_C3", "54827bf84c38b14f|2023-01-26--21-59-07--4"), # FORD.BRONCO_SPORT_MK1: TICI -] - - -def ci_setup_data_readers(route, sidx): - lr = LogReader(get_url(route, sidx, "rlog.bz2")) - frs = { - 'roadCameraState': FrameReader(get_url(route, sidx, "fcamera.hevc")), - 'driverCameraState': FrameReader(get_url(route, sidx, "fcamera.hevc")), - } - if next((True for m in lr if m.which() == "wideRoadCameraState"), False): - frs["wideRoadCameraState"] = FrameReader(get_url(route, sidx, "ecamera.hevc")) - - return lr, frs - - -class TestRegen: - @parameterized.expand(TESTED_SEGMENTS) - def test_engaged(self, case_name, segment): - route, sidx = segment.rsplit("--", 1) - lr, frs = ci_setup_data_readers(route, sidx) - output_logs = regen_segment(lr, frs, disable_tqdm=True) - - engaged = check_openpilot_enabled(output_logs) - assert engaged, f"openpilot not engaged in {case_name}" diff --git a/openpilot/selfdrive/test/process_replay/vision_meta.py b/openpilot/selfdrive/test/process_replay/vision_meta.py index 12deb58724..6942d88367 100644 --- a/openpilot/selfdrive/test/process_replay/vision_meta.py +++ b/openpilot/selfdrive/test/process_replay/vision_meta.py @@ -1,17 +1,17 @@ from collections import namedtuple -from msgq.visionipc import VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType from openpilot.common.realtime import DT_MDL, DT_DMON from openpilot.common.transformations.camera import DEVICE_CAMERAS VideoStreamMeta = namedtuple("VideoStreamMeta", ["camera_state", "encode_index", "stream", "dt", "frame_sizes"]) -ROAD_CAMERA_FRAME_SIZES = {k: (v.dcam.width, v.dcam.height) for k, v in DEVICE_CAMERAS.items()} -WIDE_ROAD_CAMERA_FRAME_SIZES = {k: (v.ecam.width, v.ecam.height) for k, v in DEVICE_CAMERAS.items() if v.ecam is not None} -DRIVER_CAMERA_FRAME_SIZES = {k: (v.dcam.width, v.dcam.height) for k, v in DEVICE_CAMERAS.items()} +NARROW_ROAD_CAMERA_FRAME_SIZES = {k: (v.narrow_road.width, v.narrow_road.height) for k, v in DEVICE_CAMERAS.items()} +WIDE_ROAD_CAMERA_FRAME_SIZES = {k: (v.wide_road.width, v.wide_road.height) for k, v in DEVICE_CAMERAS.items() if v.wide_road is not None} +CABIN_CAMERA_FRAME_SIZES = {k: (v.cabin.width, v.cabin.height) for k, v in DEVICE_CAMERAS.items()} VIPC_STREAM_METADATA = [ # metadata: (state_msg_type, encode_msg_type, stream_type, dt, frame_sizes) - ("roadCameraState", "roadEncodeIdx", VisionStreamType.VISION_STREAM_ROAD, DT_MDL, ROAD_CAMERA_FRAME_SIZES), + ("narrowRoadCameraState", "narrowRoadEncodeIdx", VisionStreamType.VISION_STREAM_NARROW_ROAD, DT_MDL, NARROW_ROAD_CAMERA_FRAME_SIZES), ("wideRoadCameraState", "wideRoadEncodeIdx", VisionStreamType.VISION_STREAM_WIDE_ROAD, DT_MDL, WIDE_ROAD_CAMERA_FRAME_SIZES), - ("driverCameraState", "driverEncodeIdx", VisionStreamType.VISION_STREAM_DRIVER, DT_DMON, DRIVER_CAMERA_FRAME_SIZES), + ("cabinCameraState", "cabinEncodeIdx", VisionStreamType.VISION_STREAM_CABIN, DT_DMON, CABIN_CAMERA_FRAME_SIZES), ] diff --git a/openpilot/selfdrive/test/test_onroad.py b/openpilot/selfdrive/test/test_onroad.py old mode 100644 new mode 100755 index b15d890452..6768dc1d98 --- a/openpilot/selfdrive/test/test_onroad.py +++ b/openpilot/selfdrive/test/test_onroad.py @@ -1,13 +1,16 @@ +#!/usr/bin/env python3 + import math import json import os -import pytest import shutil import subprocess import time +import unittest import numpy as np from collections import Counter, defaultdict from pathlib import Path +from openpilot.common.test import OpenpilotTestCase from openpilot.common.utils import tabulate from openpilot.cereal import log @@ -54,7 +57,6 @@ PROCS = { "openpilot.selfdrive.locationd.paramsd": 9.0, "openpilot.selfdrive.locationd.lagd": 11.0, "openpilot.selfdrive.ui.soundd": 3.0, - "openpilot.selfdrive.ui.feedback.feedbackd": 1.0, "openpilot.selfdrive.monitoring.dmonitoringd": 4.0, "openpilot.system.proclogd": 7.0, "openpilot.system.logmessaged": 1.0, @@ -67,7 +69,7 @@ PROCS = { "openpilot.system.loggerd.deleter": 1.0, "./pandad": 19.0, "openpilot.system.qcomgpsd.qcomgpsd": 1.0, - "openpilot.common.hardware.tici.modem": 10.0, + "openpilot.common.hardware.comma.modem": 10.0, } TIMINGS = { @@ -81,12 +83,12 @@ TIMINGS = { "controlsState": [2.5, 0.35], "longitudinalPlan": [2.5, 0.5], "driverAssistance": [2.5, 0.5], - "roadCameraState": [2.5, 0.35], - "driverCameraState": [2.5, 0.35], + "narrowRoadCameraState": [2.5, 0.35], + "cabinCameraState": [2.5, 0.35], "modelV2": [2.5, 0.35], "driverStateV2": [2.5, 0.40], - "livePose": [2.5, 0.35], - "liveParameters": [2.5, 0.35], + "deviceMotion": [2.5, 0.35], + "vehicleParameters": [2.5, 0.35], "wideRoadCameraState": [1.5, 0.35], } @@ -102,9 +104,8 @@ def cputime_total(ct): return ct.cpuUser + ct.cpuSystem + ct.cpuChildrenUser + ct.cpuChildrenSystem -@pytest.mark.tici -@pytest.mark.skip_tici_setup -class TestOnroad: +class TestOnroad(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True @classmethod def setup_class(cls): @@ -303,7 +304,7 @@ class TestOnroad: result += "------------------------------------------------\n" result += "----------------- SOF Timing ------------------\n" result += "------------------------------------------------\n" - for name in ['roadCameraState', 'wideRoadCameraState', 'driverCameraState']: + for name in ['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']: ts = self.ts[name]['timestampSof'] d_ms = np.diff(ts) / 1e6 d50 = np.abs(d_ms-50) @@ -316,8 +317,8 @@ class TestOnroad: print(result) def test_camera_sync(self, subtests): - cam_states = ['roadCameraState', 'wideRoadCameraState', 'driverCameraState'] - encode_cams = ['roadEncodeIdx', 'wideRoadEncodeIdx', 'driverEncodeIdx'] + cam_states = ['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState'] + encode_cams = ['narrowRoadEncodeIdx', 'wideRoadEncodeIdx', 'cabinEncodeIdx'] for cams in (cam_states, encode_cams): with subtests.test(cams=cams): # sanity checks within a single cam @@ -348,15 +349,15 @@ class TestOnroad: diff = (max(ts.values()) - min(ts.values())) assert diff < 2, f"Cameras not synced properly: frame_id={start+i}, {diff=:.1f}ms, {ts=}" - # driver camera should be staggered ~25ms from road camera + # cabin camera should be staggered ~25ms from road camera offset_ms = abs(self.ts[cams[2]]['timestampSof'][i] - self.ts[cams[0]]['timestampSof'][i]) / 1e6 - assert 20 < offset_ms < 30, f"driver camera stagger out of range at frame {start+i}: {offset_ms:.1f}ms" + assert 20 < offset_ms < 30, f"cabin camera stagger out of range at frame {start+i}: {offset_ms:.1f}ms" def test_camera_encoder_matches(self, subtests): # sanity check that the frame metadata is consistent with the encoded frames - pairs = [('roadCameraState', 'roadEncodeIdx'), + pairs = [('narrowRoadCameraState', 'narrowRoadEncodeIdx'), ('wideRoadCameraState', 'wideRoadEncodeIdx'), - ('driverCameraState', 'driverEncodeIdx')] + ('cabinCameraState', 'cabinEncodeIdx')] for cam, enc in pairs: with subtests.test(camera=cam, encoder=enc): cam_frames = {fid: (sof, eof) for fid, sof, eof in zip( @@ -442,3 +443,7 @@ class TestOnroad: eng = [m.selfdriveState.engageable for m in self.msgs['selfdriveState'][offset:]] assert all(eng), \ f"Not engageable for whole segment:\n- selfdriveState.engageable: {Counter(eng)}\n- No entry events: {no_entries}" + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/selfdrive/test/test_power_draw.py b/openpilot/selfdrive/test/test_power_draw.py old mode 100644 new mode 100755 index 9bc012ceaa..1d0be24ef0 --- a/openpilot/selfdrive/test/test_power_draw.py +++ b/openpilot/selfdrive/test/test_power_draw.py @@ -1,8 +1,11 @@ +#!/usr/bin/env python3 + from collections import defaultdict, deque -import pytest import time +import unittest import numpy as np from dataclasses import dataclass +from openpilot.common.test import OpenpilotTestCase from openpilot.common.utils import tabulate import openpilot.cereal.messaging as messaging @@ -10,7 +13,7 @@ from openpilot.cereal.services import SERVICE_LIST 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.tici.power_monitor import get_power +from openpilot.common.hardware.comma.power_monitor import get_power from openpilot.system.manager.process_config import managed_processes from openpilot.system.manager.manager import manager_cleanup @@ -31,15 +34,15 @@ class Proc: PROCS = [ - Proc(['camerad'], 1.65, atol=0.4, msgs=['roadCameraState', 'wideRoadCameraState', 'driverCameraState']), + Proc(['camerad'], 1.65, atol=0.4, msgs=['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']), Proc(['modeld'], 1.5, atol=0.2, msgs=['modelV2']), Proc(['dmonitoringmodeld'], 0.65, atol=0.35, msgs=['driverStateV2']), Proc(['encoderd'], 0.23, msgs=[]), ] -@pytest.mark.tici -class TestPowerDraw: +class TestPowerDraw(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True def setup_method(self): Params().put("CarParams", get_demo_car_params().to_bytes(), block=True) @@ -92,7 +95,7 @@ class TestPowerDraw: return now, msg_counts, time.monotonic() - start_time - SAMPLE_TIME - @mock_messages(['livePose']) + @mock_messages(['deviceMotion']) def test_camera_procs(self, subtests): baseline = get_power() @@ -123,3 +126,7 @@ class TestPowerDraw: assert self.valid_power_draw(proc, cur), f"expected {expected:.2f}W, got {cur:.2f}W" print(tabulate(tab)) print(f"Baseline {baseline:.2f}W\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/selfdrive/ui/SConscript b/openpilot/selfdrive/ui/SConscript index d187b4ca3c..b5c81a9009 100644 --- a/openpilot/selfdrive/ui/SConscript +++ b/openpilot/selfdrive/ui/SConscript @@ -1,24 +1,9 @@ -from pathlib import Path import importlib.util +from pathlib import Path Import('env', 'arch', 'common') -# build the fonts -generator = File("#openpilot/selfdrive/assets/fonts/process.py") -source_files = Glob("#openpilot/selfdrive/assets/fonts/*.ttf") + Glob("#openpilot/selfdrive/assets/fonts/*.otf") -output_files = [ - (f"#{Path(f.path).with_suffix('.fnt')}", f"#{Path(f.path).with_suffix('.png')}") - for f in source_files - if "NotoColor" not in f.name -] -env.Command( - target=output_files, - source=[generator, source_files], - action=f"python3 {generator}", -) - - -if GetOption('extras') and arch == "larch64": +if GetOption('extras') and arch == "comma_arm64": # build installers raylib_dir = Path(importlib.util.find_spec("raylib").submodule_search_locations[0]) / "install" raylib_env = env.Clone() diff --git a/openpilot/selfdrive/ui/body/animations.py b/openpilot/selfdrive/ui/body/animations.py index 302f8989bf..3aecd52e97 100644 --- a/openpilot/selfdrive/ui/body/animations.py +++ b/openpilot/selfdrive/ui/body/animations.py @@ -87,21 +87,12 @@ BROW_LOWERED = [ (2, 0) ] BROW_STRAIGHT = [(1, 0), (1, 1), (1, 2)] -BROW_DOWN = [ -(0, 1), (0, 2), - (1, 3) -] - # Mouths (centered, not mirrored) MOUTH_SMILE = [ (6, 6), (6, 9), (7, 7), (7, 8), ] MOUTH_NORMAL = [(7, 7), (7, 8)] -MOUTH_SAD = [ - (6, 7), (6, 8), -(7, 6), (7, 9) -] # --- Animations --- @@ -168,16 +159,6 @@ INQUISITIVE = Animation( repeat_interval=10 ) -WINK = Animation( - frames=[ - _make_frame(EYE_OPEN, _mirror(EYE_OPEN), BROW_HIGH, _mirror(BROW_HIGH), MOUTH_SMILE), - _make_frame(EYE_OPEN, _mirror(EYE_CLOSED), BROW_HIGH, _mirror(_shift(BROW_DOWN, (0, 2))), MOUTH_SMILE), - ], - mode=AnimationMode.ONCE_FORWARD_BACKWARD, - frame_duration=0.75, -) - - # --- Face Animator Class --- class FaceAnimator: diff --git a/openpilot/selfdrive/ui/body/layouts/onroad.py b/openpilot/selfdrive/ui/body/layouts/onroad.py index d7e9f419cc..a48e525628 100644 --- a/openpilot/selfdrive/ui/body/layouts/onroad.py +++ b/openpilot/selfdrive/ui/body/layouts/onroad.py @@ -67,8 +67,8 @@ class BodyLayout(Widget): self._animator.set_animation(ASLEEP) steer = sm['testJoystick'].axes[1] if len(sm['testJoystick'].axes) > 1 else 0 - self._turning_left = steer <= -0.05 - self._turning_right = steer >= 0.05 + self._turning_left = steer >= 0.05 + self._turning_right = steer <= -0.05 # play animation on screen tap def _handle_mouse_release(self, mouse_pos): diff --git a/openpilot/selfdrive/ui/feedback/feedbackd.py b/openpilot/selfdrive/ui/feedback/feedbackd.py deleted file mode 100755 index 8056a580c6..0000000000 --- a/openpilot/selfdrive/ui/feedback/feedbackd.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -import openpilot.cereal.messaging as messaging -from openpilot.common.params import Params -from openpilot.common.swaglog import cloudlog -from opendbc.car.structs import car -from openpilot.system.micd import SAMPLE_RATE, SAMPLE_BUFFER - -FEEDBACK_MAX_DURATION = 10.0 -ButtonType = car.CarState.ButtonEvent.Type - - -def main(): - params = Params() - pm = messaging.PubMaster(['userBookmark', 'audioFeedback']) - sm = messaging.SubMaster(['rawAudioData', 'bookmarkButton', 'carState', 'selfdriveStateSP']) - should_record_audio = False - block_num = 0 - waiting_for_release = False - early_stop_triggered = False - - while True: - sm.update() - should_send_bookmark = False - - # TODO: https://github.com/commaai/openpilot/issues/36015 - # only allow the LKAS button to record feedback when MADS is disabled - if False and sm.updated['carState'] and sm['carState'].canValid and not sm['selfdriveStateSP'].mads.available: - for be in sm['carState'].buttonEvents: - if be.type == ButtonType.lkas: - if be.pressed: - if not should_record_audio: - if params.get_bool("RecordAudioFeedback"): # Start recording on first press if toggle set - should_record_audio = True - block_num = 0 - waiting_for_release = False - early_stop_triggered = False - cloudlog.info("LKAS button pressed - starting 10-second audio feedback") - else: - should_send_bookmark = True # immediately send bookmark if toggle false - cloudlog.info("LKAS button pressed - bookmarking") - elif should_record_audio and not waiting_for_release: # Wait for release of second press to stop recording early - waiting_for_release = True - elif waiting_for_release: # Second press released - waiting_for_release = False - early_stop_triggered = True - cloudlog.info("LKAS button released - ending recording early") - - if should_record_audio and sm.updated['rawAudioData']: - raw_audio = sm['rawAudioData'] - msg = messaging.new_message('audioFeedback', valid=True) - msg.audioFeedback.audio.data = raw_audio.data - msg.audioFeedback.audio.sampleRate = raw_audio.sampleRate - msg.audioFeedback.blockNum = block_num - block_num += 1 - if (block_num * SAMPLE_BUFFER / SAMPLE_RATE) >= FEEDBACK_MAX_DURATION or early_stop_triggered: # Check for timeout or early stop - should_send_bookmark = True # send bookmark at end of audio segment - should_record_audio = False - early_stop_triggered = False - cloudlog.info("10-second recording completed or second button press - stopping audio feedback") - pm.send('audioFeedback', msg) - - if sm.updated['bookmarkButton']: - cloudlog.info("Bookmark button pressed!") - should_send_bookmark = True - - if should_send_bookmark: - msg = messaging.new_message('userBookmark', valid=True) - pm.send('userBookmark', msg) - - -if __name__ == '__main__': - main() diff --git a/openpilot/selfdrive/ui/layouts/main.py b/openpilot/selfdrive/ui/layouts/main.py index 69536912ce..c4224d6467 100644 --- a/openpilot/selfdrive/ui/layouts/main.py +++ b/openpilot/selfdrive/ui/layouts/main.py @@ -26,7 +26,7 @@ class MainLayout(Widget): def __init__(self): super().__init__() - self._pm = messaging.PubMaster(['bookmarkButton']) + self._pm = messaging.PubMaster(['bookmarkButton', 'userBookmark']) self._sidebar = Sidebar() self._current_mode = MainState.HOME @@ -115,9 +115,9 @@ class MainLayout(Widget): self.open_settings(PanelType.DEVICE) def _on_bookmark_clicked(self): - user_bookmark = messaging.new_message('bookmarkButton') - user_bookmark.valid = True - self._pm.send('bookmarkButton', user_bookmark) + for service in ('bookmarkButton', 'userBookmark'): + msg = messaging.new_message(service, valid=True) + self._pm.send(service, msg) def _on_onroad_clicked(self): self._sidebar.set_visible(not self._sidebar.is_visible) diff --git a/openpilot/selfdrive/ui/layouts/settings/device.py b/openpilot/selfdrive/ui/layouts/settings/device.py index 8844623b6e..5be78cf652 100644 --- a/openpilot/selfdrive/ui/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/layouts/settings/device.py @@ -5,7 +5,7 @@ from openpilot.cereal import messaging, log from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog -from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog +from openpilot.selfdrive.ui.onroad.cabin_camera_dialog import CabinCameraDialog from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.layouts.onboarding import TrainingGuide from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog @@ -61,7 +61,7 @@ class DeviceLayout(Widget): text_item(lambda: tr("Serial"), self._params.get("HardwareSerial") or (lambda: tr("N/A"))), self._pair_device_btn, button_item(lambda: tr("Driver Camera"), lambda: tr("PREVIEW"), lambda: tr(DESCRIPTIONS['driver_camera']), - callback=lambda: gui_app.push_widget(DriverCameraDialog()), enabled=ui_state.is_offroad), + callback=lambda: gui_app.push_widget(CabinCameraDialog()), enabled=ui_state.is_offroad), self._reset_calib_btn, button_item(lambda: tr("Review Training Guide"), lambda: tr("REVIEW"), lambda: tr(DESCRIPTIONS['review_guide']), self._on_review_training_guide, enabled=ui_state.is_offroad), @@ -119,9 +119,9 @@ class DeviceLayout(Widget): calib_bytes = self._params.get("CalibrationParams") if calib_bytes: try: - calib = messaging.log_from_bytes(calib_bytes, log.Event).liveCalibration + calib = messaging.log_from_bytes(calib_bytes, log.Event).extrinsicsCalibration - if calib.calStatus != log.LiveCalibrationData.Status.uncalibrated: + if calib.calStatus != log.ExtrinsicsCalibration.Status.uncalibrated: pitch = math.degrees(calib.rpyCalib[1]) yaw = math.degrees(calib.rpyCalib[2]) desc += tr(" Your device is pointed {:.1f}° {} and {:.1f}° {}.").format(abs(pitch), tr("down") if pitch > 0 else tr("up"), @@ -133,7 +133,7 @@ class DeviceLayout(Widget): lag_bytes = self._params.get("LiveDelay") if lag_bytes: try: - lag_perc = messaging.log_from_bytes(lag_bytes, log.Event).liveDelay.calPerc + lag_perc = messaging.log_from_bytes(lag_bytes, log.Event).lateralDelay.calPerc except Exception: cloudlog.exception("invalid LiveDelay") if lag_perc < 100: @@ -144,7 +144,7 @@ class DeviceLayout(Widget): torque_bytes = self._params.get("LiveTorqueParameters") if torque_bytes: try: - torque = messaging.log_from_bytes(torque_bytes, log.Event).liveTorqueParameters + torque = messaging.log_from_bytes(torque_bytes, log.Event).lateralTorqueParameters # don't add for non-torque cars if torque.useParams: torque_perc = torque.calPerc diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index 4c4dfac520..ff8d350e08 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -139,7 +139,7 @@ class MiciHomeLayout(Widget): self._version_text = self._get_version_text() self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) - self._egpu_icon = IconWidget("icons_mici/egpu.png", (50, 37)) + self._egpu_icon = IconWidget("icons_mici/egpu_green.png", (50, 37)) self._egpu_icon_gray = IconWidget("icons_mici/egpu_gray.png", (50, 37)) self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46)) self._body_icon = IconWidget("icons_mici/body.png", (54, 37)) @@ -174,7 +174,7 @@ class MiciHomeLayout(Widget): if self._mouse_down_t is not None: if time.monotonic() - self._mouse_down_t > 0.5: # long gating for experimental mode - only allow toggle if longitudinal control is available - if ui_state.has_longitudinal_control: + if ui_state.has_longitudinal_control and ui_state.experimental_mode_confirmed: ui_state.experimental_mode = not ui_state.experimental_mode ui_state.params.put("ExperimentalMode", ui_state.experimental_mode, block=True) self._mouse_down_t = None @@ -248,8 +248,8 @@ class MiciHomeLayout(Widget): # ***** Center-aligned bottom section icons ***** self._experimental_icon.set_visible(ui_state.experimental_mode) - self._egpu_icon.set_visible(ui_state.usbgpu and ui_state.usbgpu_compiled) - self._egpu_icon_gray.set_visible(ui_state.usbgpu and not ui_state.usbgpu_compiled) + self._egpu_icon.set_visible(ui_state.sm["deviceState"].chestnutPresent and ui_state.usbgpu_compiled) + self._egpu_icon_gray.set_visible(ui_state.sm["deviceState"].chestnutPresent and not ui_state.usbgpu_compiled) self._mic_icon.set_visible(ui_state.recording_audio) self._body_icon.set_visible(bool(ui_state.is_body)) diff --git a/openpilot/selfdrive/ui/mici/layouts/main.py b/openpilot/selfdrive/ui/mici/layouts/main.py index 0ddd2decd1..7b96366894 100644 --- a/openpilot/selfdrive/ui/mici/layouts/main.py +++ b/openpilot/selfdrive/ui/mici/layouts/main.py @@ -22,7 +22,7 @@ class MiciMainLayout(Scroller): def __init__(self): super().__init__(snap_items=True, spacing=0, pad=0, scroll_indicator=False, edge_shadows=False) - self._pm = messaging.PubMaster(['bookmarkButton']) + self._pm = messaging.PubMaster(['bookmarkButton', 'userBookmark']) self._prev_onroad = False self._prev_standstill = False @@ -64,6 +64,9 @@ class MiciMainLayout(Scroller): if not self._onboarding_window.completed: gui_app.push_widget(self._onboarding_window) + # initialize correct onroad layout + self._on_body_changed() + @property def _onroad_layout(self) -> Widget: # For scroll_to @@ -143,9 +146,9 @@ class MiciMainLayout(Scroller): self._scroll_to(self._home_layout) def _on_bookmark_clicked(self): - user_bookmark = messaging.new_message('bookmarkButton') - user_bookmark.valid = True - self._pm.send('bookmarkButton', user_bookmark) + for service in ('bookmarkButton', 'userBookmark'): + msg = messaging.new_message(service, valid=True) + self._pm.send(service, msg) def _on_body_changed(self): self._car_onroad_layout.set_visible(not ui_state.is_body) diff --git a/openpilot/selfdrive/ui/mici/layouts/onboarding.py b/openpilot/selfdrive/ui/mici/layouts/onboarding.py index 3c5ad27a17..80483d1b01 100644 --- a/openpilot/selfdrive/ui/mici/layouts/onboarding.py +++ b/openpilot/selfdrive/ui/mici/layouts/onboarding.py @@ -1,9 +1,9 @@ import math import numpy as np -import qrcode import pyray as rl from collections.abc import Callable from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.common.qrcode import make_texture from openpilot.system.ui.lib.application import FontWeight, gui_app from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import SmallCircleIconButton @@ -17,11 +17,11 @@ from openpilot.common.version import sunnylink_consent_version, sunnylink_consen from openpilot.selfdrive.ui.ui_state import ui_state, device from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationCircleButton from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer -from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import BaseDriverCameraDialog +from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import BaseCabinCameraDialog from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkConsentPage -class DriverCameraSetupDialog(BaseDriverCameraDialog): +class DriverCameraSetupDialog(BaseCabinCameraDialog): def __init__(self): super().__init__() self.driver_state_renderer = DriverStateRenderer(inset=True) @@ -282,25 +282,7 @@ class QRCodeWidget(Widget): super().__init__() self.set_rect(rl.Rectangle(0, 0, size, size)) self._size = size - self._qr_texture: rl.Texture | None = None - self._generate_qr(url) - - def _generate_qr(self, url: str): - qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=0) - qr.add_data(url) - qr.make(fit=True) - - pil_img = qr.make_image(fill_color="white", back_color="black").convert('RGBA') - img_array = np.array(pil_img, dtype=np.uint8) - - rl_image = rl.Image() - rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data) - rl_image.width = pil_img.width - rl_image.height = pil_img.height - rl_image.mipmaps = 1 - rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 - - self._qr_texture = rl.load_texture_from_image(rl_image) + self._qr_texture = make_texture(url, inverted=True) def _render(self, _): if self._qr_texture: diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device.py b/openpilot/selfdrive/ui/mici/layouts/settings/device.py index 7f4a9ab0b8..6038ff7d35 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device.py @@ -9,7 +9,7 @@ from openpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog -from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog +from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraDialog from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide, TermsPage from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.system.ui.lib.multilang import tr @@ -189,9 +189,9 @@ 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) - driver_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64)) - driver_cam_btn.set_click_callback(lambda: gui_app.push_widget(DriverCameraDialog())) - driver_cam_btn.set_enabled(lambda: ui_state.is_offroad()) + cabin_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64)) + cabin_cam_btn.set_click_callback(lambda: gui_app.push_widget(CabinCameraDialog())) + cabin_cam_btn.set_enabled(lambda: ui_state.is_offroad()) review_training_guide_btn = BigButton("review\ntraining guide", "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64)) review_training_guide_btn.set_click_callback(lambda: gui_app.push_widget(ReviewTrainingGuide(completed_callback=lambda: gui_app.pop_widgets_to(self)))) @@ -204,7 +204,7 @@ class DeviceLayoutMici(NavScroller): DeviceInfoLayoutMici(), PairBigButton(), review_training_guide_btn, - driver_cam_btn, + cabin_cam_btn, terms_btn, regulatory_btn, reset_calibration_btn, diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py index 56a953a65d..eb7789cba9 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py @@ -50,7 +50,6 @@ class SettingsLayout(NavScroller): device_btn, software_btn, PairBigButton(), - #BigDialogButton("manual", "", "icons_mici/settings/manual_icon.png", "Check out the mici user\nmanual at comma.ai/setup"), firehose_btn, developer_btn, ]) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index 52bbb65e6a..2dba124df5 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -1,7 +1,10 @@ +from collections.abc import Callable + from openpilot.cereal import log from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle +from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle, BigToggle, GreyBigButton +from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationCircleButton from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback from openpilot.selfdrive.ui.ui_state import ui_state @@ -9,16 +12,42 @@ from openpilot.selfdrive.ui.ui_state import ui_state PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants +class ExperimentalModeConfirmPage(NavScroller): + def __init__(self, on_confirm: Callable[[], None]): + super().__init__() + + accept = BigConfirmationCircleButton("enable\nexperimental mode", + gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 64, 64), + lambda: self.dismiss(on_confirm)) + + self._scroller.add_widgets([ + GreyBigButton("enabling\nexperimental mode", "scroll to continue", + gui_app.texture("icons_mici/setup/warning.png", 64, 64)), + GreyBigButton("", "openpilot defaults to driving in chill mode."), + GreyBigButton("", "Experimental mode enables alpha-level features that aren't ready for chill mode."), + GreyBigButton("End-to-End Longitudinal Control"), + GreyBigButton("", "Let the driving model control the gas and brakes."), + GreyBigButton("", "openpilot will drive as it thinks a human would, including stopping for red lights and stop signs."), + GreyBigButton("", "The set speed will only act as an upper bound."), + GreyBigButton("", "This is an alpha quality feature; mistakes should be expected."), + GreyBigButton("New Driving Visualization"), + GreyBigButton("", "The path will change colors to communicate acceleration intent."), + GreyBigButton("", "Red for braking, green for acceleration, and gray for coasting."), + accept, + ]) + + class TogglesLayoutMici(NavScroller): def __init__(self): super().__init__() self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"]) - self._experimental_btn = BigParamControl("experimental mode", "ExperimentalMode") + self._experimental_btn = BigToggle("experimental mode", initial_state=ui_state.params.get_bool("ExperimentalMode"), + toggle_callback=self._on_experimental_mode) 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 driver camera", "RecordFront", toggle_callback=restart_needed_callback) + 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) @@ -85,3 +114,17 @@ class TogglesLayoutMici(NavScroller): # Refresh toggles from params to mirror external changes for key, item in self._refresh_toggles: item.set_checked(ui_state.params.get_bool(key)) + + def _on_experimental_mode(self, state: bool): + if state and not ui_state.params.get_bool("ExperimentalModeConfirmed"): + # Don't show enabled state until confirm + self._experimental_btn.set_checked(False) + + def on_confirm(): + ui_state.params.put_bool("ExperimentalModeConfirmed", True) + ui_state.params.put_bool("ExperimentalMode", True) + self._experimental_btn.set_checked(True) + + gui_app.push_widget(ExperimentalModeConfirmPage(on_confirm)) + else: + ui_state.params.put_bool("ExperimentalMode", state) diff --git a/openpilot/selfdrive/ui/mici/onroad/alert_renderer.py b/openpilot/selfdrive/ui/mici/onroad/alert_renderer.py index 016e09a0f1..4be32459d5 100644 --- a/openpilot/selfdrive/ui/mici/onroad/alert_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/alert_renderer.py @@ -9,7 +9,7 @@ from openpilot.cereal import messaging, log from opendbc.car.structs import car from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel @@ -136,7 +136,7 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer): return ALERT_STARTUP_PENDING # 2. Lost communication with selfdriveState after receiving it - if TICI and not waiting_for_startup: + if COMMA_HARDWARE and not waiting_for_startup: ss_missing = time.monotonic() - sm.recv_time['selfdriveState'] if ss_missing > SELFDRIVE_STATE_TIMEOUT: if ss.enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < SELFDRIVE_UNRESPONSIVE_TIMEOUT: @@ -310,13 +310,10 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer): # TODO: hack alert_text1 = alert.text1.lower().replace('calibrating: ', 'calibrating:\n') - can_draw_second_line = False # TODO: there should be a common way to determine font size based on text length to maximize rect if len(alert_text1) <= 12: - can_draw_second_line = True font_size = 92 - 10 elif len(alert_text1) <= 16: - can_draw_second_line = True font_size = 70 else: font_size = 64 - 10 @@ -348,13 +345,13 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer): self._text_gen_time = time.monotonic() alert_text2 = self._alert_text2_gen or alert_text2 - if can_draw_second_line and alert_text2: + if alert_text2: last_line_h = self._alert_text1_label.rect.y + self._alert_text1_label.get_content_height(int(alert_layout.text_rect.width)) last_line_h -= 4 - if len(alert_text2) > 18: - small_font_size = 36 - elif len(alert_text2) > 24: + if len(alert_text2) > 24: small_font_size = 32 + elif len(alert_text2) > 18: + small_font_size = 36 else: small_font_size = 40 text_rect2 = rl.Rectangle( diff --git a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py index b5b9a54be8..e37e9b7ea9 100644 --- a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -2,7 +2,7 @@ import numpy as np import pyray as rl from openpilot.cereal import log from opendbc.car.structs import car -from msgq.visionipc import VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH from openpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer @@ -24,8 +24,8 @@ if gui_app.sunnypilot_ui(): from openpilot.selfdrive.ui.sunnypilot.ui_state import OnroadTimerStatus OpState = log.SelfdriveState.OpenpilotState -CALIBRATED = log.LiveCalibrationData.Status.calibrated -ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD +CALIBRATED = log.ExtrinsicsCalibration.Status.calibrated +NARROW_ROAD_CAM = VisionStreamType.VISION_STREAM_NARROW_ROAD WIDE_CAM = VisionStreamType.VISION_STREAM_WIDE_ROAD DEFAULT_DEVICE_CAMERA = DEVICE_CAMERAS["tici", "ar0231"] @@ -134,7 +134,7 @@ class BookmarkIcon(Widget): class AugmentedRoadView(CameraView): - def __init__(self, bookmark_callback=None, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD): + def __init__(self, bookmark_callback=None, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_NARROW_ROAD): super().__init__("camerad", stream_type) self._bookmark_callback = bookmark_callback self._set_placeholder_color(rl.BLACK) @@ -254,12 +254,12 @@ class AugmentedRoadView(CameraView): if v_ego < WIDE_CAM_MAX_SPEED: target = WIDE_CAM elif v_ego > ROAD_CAM_MIN_SPEED: - target = ROAD_CAM + target = NARROW_ROAD_CAM else: # Hysteresis zone - keep current stream target = self.stream_type else: - target = ROAD_CAM + target = NARROW_ROAD_CAM if self.stream_type != target: self.switch_stream(target) @@ -267,14 +267,14 @@ class AugmentedRoadView(CameraView): def _update_calibration(self): # Update device camera if not already set sm = ui_state.sm - if not self.device_camera and sm.seen['roadCameraState'] and sm.seen['deviceState']: - self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))] + if not self.device_camera and sm.seen['narrowRoadCameraState'] and sm.seen['deviceState']: + self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['narrowRoadCameraState'].sensor))] - # Check if live calibration data is available and valid - if not (sm.updated["liveCalibration"] and sm.valid['liveCalibration']): + # Check if camera calibration data is available and valid + if not (sm.updated["extrinsicsCalibration"] and sm.valid['extrinsicsCalibration']): return - calib = sm['liveCalibration'] + calib = sm['extrinsicsCalibration'] if len(calib.rpyCalib) != 3 or calib.calStatus != CALIBRATED: return @@ -289,7 +289,7 @@ class AugmentedRoadView(CameraView): def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray: cache_key = ( - ui_state.sm.recv_frame['liveCalibration'], + ui_state.sm.recv_frame['extrinsicsCalibration'], int(self._content_rect.width), int(self._content_rect.height), self.stream_type, @@ -302,7 +302,7 @@ class AugmentedRoadView(CameraView): # Get camera configuration device_camera = self.device_camera or DEFAULT_DEVICE_CAMERA is_wide_camera = self.stream_type == WIDE_CAM - intrinsic = device_camera.ecam.intrinsics if is_wide_camera else device_camera.fcam.intrinsics + intrinsic = device_camera.wide_road.intrinsics if is_wide_camera else device_camera.narrow_road.intrinsics calibration = self.view_from_wide_calib if is_wide_camera else self.view_from_calib if is_wide_camera: zoom = 0.7 * 1.5 @@ -365,14 +365,14 @@ class AugmentedRoadView(CameraView): if __name__ == "__main__": gui_app.init_window("OnRoad Camera View") - road_camera_view = AugmentedRoadView(lambda: None, stream_type=ROAD_CAM) + road_camera_view = AugmentedRoadView(lambda: None, stream_type=NARROW_ROAD_CAM) print("***press space to switch camera view***") try: for _ in gui_app.render(): ui_state.update() if rl.is_key_released(rl.KeyboardKey.KEY_SPACE): if WIDE_CAM in road_camera_view.available_streams: - stream = ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM + stream = NARROW_ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM road_camera_view.switch_stream(stream) road_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) finally: diff --git a/openpilot/selfdrive/ui/mici/onroad/driver_camera_dialog.py b/openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py similarity index 95% rename from openpilot/selfdrive/ui/mici/onroad/driver_camera_dialog.py rename to openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py index e81877b402..e86c0aa739 100644 --- a/openpilot/selfdrive/ui/mici/onroad/driver_camera_dialog.py +++ b/openpilot/selfdrive/ui/mici/onroad/cabin_camera_dialog.py @@ -1,6 +1,6 @@ import pyray as rl from openpilot.cereal import log, messaging -from msgq.visionipc import VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.ui_state import ui_state, device @@ -11,7 +11,7 @@ from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.system.ui.widgets.label import gui_label -class DriverCameraView(CameraView): +class CabinCameraView(CameraView): def _calc_frame_matrix(self, rect: rl.Rectangle): base = super()._calc_frame_matrix(rect) driver_view_ratio = 1.5 @@ -20,11 +20,11 @@ class DriverCameraView(CameraView): return base -class BaseDriverCameraDialog(Widget): +class BaseCabinCameraDialog(Widget): # Not a NavWidget so training guide can use this without back navigation def __init__(self): super().__init__() - self._camera_view = DriverCameraView("camerad", VisionStreamType.VISION_STREAM_DRIVER) + self._camera_view = CabinCameraView("camerad", VisionStreamType.VISION_STREAM_CABIN) self.driver_state_renderer = DriverStateRenderer(lines=True) self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 200, 200)) self.driver_state_renderer.load_icons() @@ -229,7 +229,7 @@ class BaseDriverCameraDialog(Widget): rl.draw_texture_v(self._glasses_texture, glasses_pos, rl.Color(70, 80, 161, int(255 * glasses_prob))) -class DriverCameraDialog(NavWidget, BaseDriverCameraDialog): +class CabinCameraDialog(NavWidget, BaseCabinCameraDialog): def __init__(self): super().__init__() # TODO: this can grow unbounded, should be given some thought @@ -237,12 +237,12 @@ class DriverCameraDialog(NavWidget, BaseDriverCameraDialog): if __name__ == "__main__": - gui_app.init_window("Driver Camera View (mici)") + gui_app.init_window("Cabin Camera View (mici)") - driver_camera_view = DriverCameraDialog() - gui_app.push_widget(driver_camera_view) + cabin_camera_view = CabinCameraDialog() + gui_app.push_widget(cabin_camera_view) try: for _ in gui_app.render(): ui_state.update() finally: - driver_camera_view.close() + cabin_camera_view.close() diff --git a/openpilot/selfdrive/ui/mici/onroad/cameraview.py b/openpilot/selfdrive/ui/mici/onroad/cameraview.py index 82e4865c76..8a3e2cbed5 100644 --- a/openpilot/selfdrive/ui/mici/onroad/cameraview.py +++ b/openpilot/selfdrive/ui/mici/onroad/cameraview.py @@ -2,9 +2,10 @@ import platform import numpy as np import pyray as rl -from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcClient, VisionBuf from openpilot.common.swaglog import cloudlog -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage from openpilot.system.ui.widgets import Widget @@ -38,7 +39,7 @@ void main() { """ # Choose fragment shader based on platform capabilities -if TICI: +if COMMA_HARDWARE: FRAME_FRAGMENT_SHADER = """ #version 300 es #extension GL_OES_EGL_image_external_essl3 : enable @@ -110,7 +111,7 @@ class CameraView(Widget): self._name = name # Primary stream self.client = VisionIpcClient(name, stream_type, conflate=True) - self._stream_type = stream_type + self._stream_type: VisionStreamType = stream_type self.available_streams: list[VisionStreamType] = [] # Target stream for switching @@ -121,11 +122,11 @@ class CameraView(Widget): self._texture_needs_update = True self.last_connection_attempt: float = 0.0 self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER) - self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not TICI else -1 + self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not COMMA_HARDWARE else -1 self._engaged_loc = rl.get_shader_location(self.shader, "engaged") self._engaged_val = rl.ffi.new("int[1]", [1]) self._enhance_driver_loc = rl.get_shader_location(self.shader, "enhance_driver") - self._enhance_driver_val = rl.ffi.new("int[1]", [1 if stream_type == VisionStreamType.VISION_STREAM_DRIVER else 0]) + self._enhance_driver_val = rl.ffi.new("int[1]", [1 if stream_type == VisionStreamType.VISION_STREAM_CABIN else 0]) self.frame: VisionBuf | None = None self.texture_y: rl.Texture | None = None @@ -137,8 +138,8 @@ class CameraView(Widget): self._placeholder_color: rl.Color | None = None - # Initialize EGL for zero-copy rendering on TICI - if TICI: + # Initialize EGL for zero-copy rendering on COMMA_HARDWARE + if COMMA_HARDWARE: if not init_egl(): raise RuntimeError("Failed to initialize EGL") @@ -185,7 +186,7 @@ class CameraView(Widget): self._clear_textures() # Clean up EGL texture - if TICI and self.egl_texture: + if COMMA_HARDWARE and self.egl_texture: rl.unload_texture(self.egl_texture) self.egl_texture = None @@ -242,8 +243,8 @@ class CameraView(Widget): transform = self._calc_frame_matrix(rect) src_rect = rl.Rectangle(0, 0, float(self.frame.width), float(self.frame.height)) - # Flip driver camera horizontally - if self._stream_type == VisionStreamType.VISION_STREAM_DRIVER: + # Flip cabin camera horizontally + if self._stream_type == VisionStreamType.VISION_STREAM_CABIN: src_rect.width = -src_rect.width # Calculate scale @@ -260,7 +261,7 @@ class CameraView(Widget): dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y) # Render with appropriate method - if TICI: + if COMMA_HARDWARE: self._render_egl(src_rect, dst_rect) else: self._render_textures(src_rect, dst_rect) @@ -370,6 +371,7 @@ class CameraView(Widget): del self.client # Switch to target + assert self._target_client is not None and self._target_stream_type is not None self.client = self._target_client self._stream_type = self._target_stream_type self._texture_needs_update = True @@ -383,12 +385,12 @@ class CameraView(Widget): self._initialize_textures() def _initialize_textures(self): - self._clear_textures() - if not TICI: - self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), - int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) - self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), - int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA)) + self._clear_textures() + if not COMMA_HARDWARE: + self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), + int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) + self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), + int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA)) def _clear_textures(self): if self.texture_y and self.texture_y.id: @@ -400,7 +402,7 @@ class CameraView(Widget): self.texture_uv = None # Clean up EGL resources - if TICI: + if COMMA_HARDWARE: for data in self.egl_images.values(): destroy_egl_image(data) self.egl_images = {} @@ -408,6 +410,6 @@ class CameraView(Widget): if __name__ == "__main__": gui_app.init_window("camera view") - road = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD) + road = CameraView("camerad", VisionStreamType.VISION_STREAM_NARROW_ROAD) for _ in gui_app.render(): road.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) diff --git a/openpilot/selfdrive/ui/mici/onroad/driver_state.py b/openpilot/selfdrive/ui/mici/onroad/driver_state.py index 85b90704ac..ad5a521518 100644 --- a/openpilot/selfdrive/ui/mici/onroad/driver_state.py +++ b/openpilot/selfdrive/ui/mici/onroad/driver_state.py @@ -169,8 +169,8 @@ class DriverStateRenderer(Widget): self._is_rhd = dm_state.isRHD self._face_detected = dm_state.visionPolicyState.faceDetected self._awareness_unfull = self.effective_active and dm_state.visionPolicyState.awarenessPercent < self.AWARENESS_UNFULL_PERCENT - self._face_pitch = dm_state.visionPolicyState.pose.pitch + math.radians(6) # calib or DM pose is not accurate, add a fake upward pitch to bias forward - self._face_yaw = -dm_state.visionPolicyState.pose.yaw # undo sign flip in face_orientation_from_model to match UI convention + self._face_pitch = dm_state.visionPolicyState.pose.pitch + math.radians(6) # calib or DM pose is not accurate, add a fake upward pitch to bias forward + self._face_yaw = dm_state.visionPolicyState.pose.yaw * (1 if self._is_rhd else -1) # undo sign flip in face_orientation_from_model to match UI convention driverstate = sm["driverStateV2"] driver_data = driverstate.rightDriverData if self._is_rhd else driverstate.leftDriverData diff --git a/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py b/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py index 67f9833021..2d1f954122 100644 --- a/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/hud_renderer.py @@ -1,3 +1,4 @@ +import math import pyray as rl from dataclasses import dataclass from openpilot.common.constants import CV @@ -106,6 +107,8 @@ class HudRenderer(Widget): self.speed: float = 0.0 self.v_ego_cluster_seen: bool = False self._engaged: bool = False + self._small_model_engaged: bool = False + self._egpu_fade_time: float = 0 self._can_draw_top_icons = True self._show_wheel_critical = False @@ -121,11 +124,17 @@ class HudRenderer(Widget): self._txt_wheel: rl.Texture = gui_app.texture('icons_mici/wheel.png', 50, 50) self._txt_wheel_critical: rl.Texture = gui_app.texture('icons_mici/wheel_critical.png', 50, 50) self._txt_exclamation_point: rl.Texture = gui_app.texture('icons_mici/exclamation_point.png', 9, 44) + self._txt_egpu: rl.Texture = gui_app.texture('icons_mici/egpu.png', 60, 44) + self._txt_egpu_green: rl.Texture = gui_app.texture('icons_mici/egpu_green.png', 60, 44) + self._txt_egpu_orange: rl.Texture = gui_app.texture('icons_mici/egpu_orange.png', 60, 44) + self._txt_egpu_crossed: rl.Texture = gui_app.texture('icons_mici/egpu_crossed.png', 60, 52) + self._egpu_icon: rl.Texture | None = None self._wheel_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps) self._wheel_y_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps) self._set_speed_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) + self._egpu_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) def set_wheel_critical_icon(self, critical: bool): """Set the wheel icon to critical or normal state.""" @@ -156,6 +165,11 @@ class HudRenderer(Widget): controls_state.deprecated.vCruise if v_cruise_cluster == 0.0 else v_cruise_cluster ) engaged = sm['selfdriveState'].enabled + if (engaged and not self._engaged and not ui_state.usbgpu_loading and ui_state.usbgpu_active is not True and + ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame): + self._small_model_engaged = True + if engaged != self._engaged: + self._egpu_fade_time = rl.get_time() if engaged else 0 if (set_speed != self.set_speed and engaged) or (engaged and not self._engaged): self._set_speed_changed_time = rl.get_time() self._engaged = engaged @@ -177,8 +191,46 @@ class HudRenderer(Widget): if self.is_cruise_set: self._draw_set_speed(rect) + if ui_state.usbgpu and ui_state.usbgpu_compiled: + self._draw_model_source(rect) + self._draw_steering_wheel(rect) + def _draw_model_source(self, rect: rl.Rectangle) -> None: + if ui_state.sm.recv_frame['selfdriveState'] < ui_state.started_frame: + return + + big_failed = (ui_state.usbgpu_active is False or not ui_state.sm['deviceState'].chestnutPresent or + (ui_state.usbgpu_active is True and ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame and + not ui_state.sm.alive['modelV2']) or + (ui_state.usbgpu_active is None and ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame)) + self._small_model_engaged &= big_failed + loading = ui_state.usbgpu_loading or (ui_state.usbgpu_active is None and not big_failed) + if loading: + pulse = 0.5 - 0.5 * math.cos(rl.get_time() * 6.0) + icon = self._txt_egpu + opacity = 0.35 + 0.65 * pulse + elif self._small_model_engaged: + icon = self._txt_egpu_crossed + opacity = 0.65 + elif big_failed: + icon = self._txt_egpu_orange + opacity = 1.0 + else: + icon = self._txt_egpu_green + opacity = 1.0 + + if icon is not self._egpu_icon: + self._egpu_fade_time = rl.get_time() + self._egpu_icon = icon + alpha = self._egpu_alpha_filter.update(loading or 0 < rl.get_time() - self._egpu_fade_time < SET_SPEED_PERSISTENCE) + if alpha < 1e-2: + return + + pos = rl.Vector2(rect.x + rect.width - 10 - icon.width, + rect.y + rect.height - 14 - (self._txt_wheel.height + icon.height) / 2) + rl.draw_texture_ex(icon, pos, 0.0, 1.0, rl.Color(255, 255, 255, int(255 * opacity * alpha))) + def _draw_steering_wheel(self, rect: rl.Rectangle) -> None: wheel_txt = self._txt_wheel_critical if self._show_wheel_critical else self._txt_wheel diff --git a/openpilot/selfdrive/ui/mici/onroad/model_renderer.py b/openpilot/selfdrive/ui/mici/onroad/model_renderer.py index 3f514b28d2..805bdcc335 100644 --- a/openpilot/selfdrive/ui/mici/onroad/model_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/model_renderer.py @@ -111,7 +111,7 @@ class ModelRenderer(Widget, ModelRendererSP): self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque) # Check if data is up-to-date - if (sm.recv_frame["liveCalibration"] < ui_state.started_frame or + if (sm.recv_frame["extrinsicsCalibration"] < ui_state.started_frame or sm.recv_frame["modelV2"] < ui_state.started_frame): return @@ -123,8 +123,8 @@ class ModelRenderer(Widget, ModelRendererSP): # Update state self._experimental_mode = sm['selfdriveState'].experimentalMode - live_calib = sm['liveCalibration'] - self._path_offset_z = live_calib.height[0] if live_calib.height else HEIGHT_INIT[0] + extrinsics_calibration = sm['extrinsicsCalibration'] + self._path_offset_z = extrinsics_calibration.height[0] if extrinsics_calibration.height else HEIGHT_INIT[0] if sm.updated['carParams']: self._longitudinal_control = sm['carParams'].openpilotLongitudinalControl diff --git a/openpilot/selfdrive/ui/mici/onroad/torque_bar.py b/openpilot/selfdrive/ui/mici/onroad/torque_bar.py index 85edd89e98..c4ccafa0a3 100644 --- a/openpilot/selfdrive/ui/mici/onroad/torque_bar.py +++ b/openpilot/selfdrive/ui/mici/onroad/torque_bar.py @@ -168,7 +168,7 @@ class TorqueBar(Widget): if ui_state.sm['controlsState'].lateralControlState.which() in ('angleState', 'curvatureState'): controls_state = ui_state.sm['controlsState'] car_state = ui_state.sm['carState'] - live_parameters = ui_state.sm['liveParameters'] + vehicle_parameters = ui_state.sm['vehicleParameters'] car_control = ui_state.sm['carControl'] # Include lateral accel error in estimated torque utilization @@ -178,7 +178,7 @@ class TorqueBar(Widget): # Include road roll in estimated torque utilization # Roll is less accurate near standstill, so reduce its effect at low speed - roll_compensation = live_parameters.roll * ACCELERATION_DUE_TO_GRAVITY * np.interp(car_state.vEgo, [5, 15], [0.0, 1.0]) + roll_compensation = vehicle_parameters.roll * ACCELERATION_DUE_TO_GRAVITY * np.interp(car_state.vEgo, [5, 15], [0.0, 1.0]) lateral_acceleration = actual_lateral_accel - roll_compensation max_lateral_acceleration = ui_state.CP.maxLateralAccel if ui_state.CP else DEFAULT_MAX_LAT_ACCEL diff --git a/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py b/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py index 68c61f4ff3..c0f1ac1510 100755 --- a/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py +++ b/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py @@ -1,16 +1,16 @@ import gc import weakref -import pytest +import unittest # FIXME: known small leaks not worth worrying about at the moment KNOWN_LEAKS = { - "openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog.DriverCameraView", + "openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog.CabinCameraView", "openpilot.selfdrive.ui.mici.layouts.onboarding.TermsPage", "openpilot.selfdrive.ui.mici.layouts.onboarding.TrainingGuide", "openpilot.selfdrive.ui.mici.layouts.onboarding.DeclinePage", "openpilot.selfdrive.ui.mici.layouts.onboarding.OnboardingWindow", "openpilot.selfdrive.ui.onroad.driver_state.DriverStateRenderer", - "openpilot.selfdrive.ui.onroad.driver_camera_dialog.DriverCameraDialog", + "openpilot.selfdrive.ui.onroad.cabin_camera_dialog.CabinCameraDialog", "openpilot.selfdrive.ui.layouts.onboarding.TermsPage", "openpilot.selfdrive.ui.layouts.onboarding.DeclinePage", "openpilot.selfdrive.ui.layouts.onboarding.OnboardingWindow", @@ -41,8 +41,9 @@ def get_child_widgets(widget) -> list: return children -class TestWidgetLeaks: - @pytest.mark.skip(reason="segfaults") +from openpilot.common.test import OpenpilotTestCase +class TestWidgetLeaks(OpenpilotTestCase): + @unittest.skip("segfaults") def test_dialogs_do_not_leak(self): import pyray as rl rl.set_config_flags(rl.ConfigFlags.FLAG_WINDOW_HIDDEN) @@ -50,13 +51,13 @@ class TestWidgetLeaks: # mici dialogs from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide as MiciTrainingGuide, OnboardingWindow as MiciOnboardingWindow - from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog as MiciDriverCameraDialog + from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraDialog as MiciCabinCameraDialog from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog as MiciPairingDialog from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigInputDialog from openpilot.selfdrive.ui.mici.layouts.settings.device import MiciFccModal # tici dialogs - from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog as TiciDriverCameraDialog + from openpilot.selfdrive.ui.onroad.cabin_camera_dialog import CabinCameraDialog as TiciCabinCameraDialog from openpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow as TiciOnboardingWindow from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog as TiciPairingDialog from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog @@ -70,7 +71,7 @@ class TestWidgetLeaks: for ctor in ( # mici - MiciDriverCameraDialog, MiciPairingDialog, + MiciCabinCameraDialog, MiciPairingDialog, lambda: MiciTrainingGuide(lambda: None), lambda: MiciOnboardingWindow(lambda: None), lambda: BigDialog("test", "test"), @@ -78,7 +79,7 @@ class TestWidgetLeaks: lambda: BigInputDialog("test"), lambda: MiciFccModal(text="test"), # tici - TiciDriverCameraDialog, TiciOnboardingWindow, TiciPairingDialog, Keyboard, + TiciCabinCameraDialog, TiciOnboardingWindow, TiciPairingDialog, Keyboard, lambda: ConfirmDialog("test", "ok"), lambda: MultiOptionDialog("test", ["a", "b"]), lambda: HtmlModal(text="test"), diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index 3dd7ad9a8a..0ecda6a0c5 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -1,6 +1,6 @@ import math import pyray as rl -from typing import Union +from typing import TYPE_CHECKING, Union from enum import Enum from collections.abc import Callable from openpilot.system.ui.widgets import Widget @@ -9,12 +9,14 @@ from openpilot.system.ui.widgets.scroller import DO_ZOOM from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.common.filter_simple import BounceFilter -try: +if TYPE_CHECKING: from openpilot.common.params import Params -except ImportError: - Params = None +else: + try: + from openpilot.common.params import Params + except (ImportError, OSError): + Params = None -SCROLLING_SPEED_PX_S = 50 COMPLICATION_SIZE = 36 LABEL_COLOR = rl.Color(255, 255, 255, int(255 * 0.9)) COMPLICATION_GREY = rl.Color(0xAA, 0xAA, 0xAA, 255) diff --git a/openpilot/selfdrive/ui/mici/widgets/dialog.py b/openpilot/selfdrive/ui/mici/widgets/dialog.py index ed1466449b..77dfa3cb97 100644 --- a/openpilot/selfdrive/ui/mici/widgets/dialog.py +++ b/openpilot/selfdrive/ui/mici/widgets/dialog.py @@ -10,7 +10,7 @@ from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.system.ui.widgets.slider import RedBigSlider, BigSlider from openpilot.common.filter_simple import FirstOrderFilter -from openpilot.selfdrive.ui.mici.widgets.button import BigCircleButton, BigButton, GreyBigButton +from openpilot.selfdrive.ui.mici.widgets.button import BigCircleButton, GreyBigButton DEBUG = False @@ -216,18 +216,6 @@ class BigInputDialog(BigDialogBase): self._confirm_callback() -class BigDialogButton(BigButton): - def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", description: str = ""): - super().__init__(text, value, icon) - self._description = description - - def _handle_mouse_release(self, mouse_pos: MousePos): - super()._handle_mouse_release(mouse_pos) - - dlg = BigDialog(self.text, self._description) - gui_app.push_widget(dlg) - - class BigConfirmationCircleButton(BigCircleButton): def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None], exit_on_confirm: bool = True, red: bool = False, icon_offset: tuple[int, int] = (0, 0)): diff --git a/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py b/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py index a18b26ec02..7e05bc2fa0 100644 --- a/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py +++ b/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py @@ -1,9 +1,8 @@ import pyray as rl -import qrcode -import numpy as np import time from openpilot.common.api import Api +from openpilot.common.qrcode import make_texture from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params from openpilot.selfdrive.ui.ui_state import ui_state @@ -37,24 +36,9 @@ class PairingDialog(NavWidget): def _generate_qr_code(self) -> None: try: - qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=0) - qr.add_data(self._get_pairing_url()) - qr.make(fit=True) - - pil_img = qr.make_image(fill_color="white", back_color="black").convert('RGBA') - img_array = np.array(pil_img, dtype=np.uint8) - if self._qr_texture and self._qr_texture.id != 0: rl.unload_texture(self._qr_texture) - - rl_image = rl.Image() - rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data) - rl_image.width = pil_img.width - rl_image.height = pil_img.height - rl_image.mipmaps = 1 - rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 - - self._qr_texture = rl.load_texture_from_image(rl_image) + self._qr_texture = make_texture(self._get_pairing_url(), inverted=True) except Exception as e: cloudlog.warning(f"QR code generation failed: {e}") self._qr_texture = None diff --git a/openpilot/selfdrive/ui/onroad/alert_renderer.py b/openpilot/selfdrive/ui/onroad/alert_renderer.py index b77b1539f4..64e9fefa9c 100644 --- a/openpilot/selfdrive/ui/onroad/alert_renderer.py +++ b/openpilot/selfdrive/ui/onroad/alert_renderer.py @@ -3,7 +3,7 @@ import pyray as rl from dataclasses import dataclass from openpilot.cereal import messaging, log from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -96,7 +96,7 @@ class AlertRenderer(Widget): return ALERT_STARTUP_PENDING # 2. Lost communication with selfdriveState after receiving it - if TICI and not waiting_for_startup: + if COMMA_HARDWARE and not waiting_for_startup: ss_missing = time.monotonic() - sm.recv_time['selfdriveState'] if ss_missing > SELFDRIVE_STATE_TIMEOUT: if ss.enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < SELFDRIVE_UNRESPONSIVE_TIMEOUT: diff --git a/openpilot/selfdrive/ui/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/onroad/augmented_road_view.py index c255804f2d..70b75d13bd 100644 --- a/openpilot/selfdrive/ui/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/onroad/augmented_road_view.py @@ -1,7 +1,7 @@ import numpy as np import pyray as rl from openpilot.cereal import log -from msgq.visionipc import VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType from openpilot.selfdrive.ui import UI_BORDER_SIZE from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.selfdrive.ui.onroad.alert_renderer import AlertRenderer @@ -21,8 +21,8 @@ if gui_app.sunnypilot_ui(): from openpilot.selfdrive.ui.sunnypilot.ui_state import OnroadTimerStatus OpState = log.SelfdriveState.OpenpilotState -CALIBRATED = log.LiveCalibrationData.Status.calibrated -ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD +CALIBRATED = log.ExtrinsicsCalibration.Status.calibrated +NARROW_ROAD_CAM = VisionStreamType.VISION_STREAM_NARROW_ROAD WIDE_CAM = VisionStreamType.VISION_STREAM_WIDE_ROAD DEFAULT_DEVICE_CAMERA = DEVICE_CAMERAS["tici", "ar0231"] @@ -39,7 +39,7 @@ INF_POINT = np.array([1000.0, 0.0, 0.0]) class AugmentedRoadView(CameraView, AugmentedRoadViewSP): - def __init__(self, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD): + def __init__(self, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_NARROW_ROAD): CameraView.__init__(self, "camerad", stream_type) AugmentedRoadViewSP.__init__(self) self._set_placeholder_color(BORDER_COLORS[UIStatus.DISENGAGED]) @@ -125,12 +125,12 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewSP): if v_ego < WIDE_CAM_MAX_SPEED: target = WIDE_CAM elif v_ego > ROAD_CAM_MIN_SPEED: - target = ROAD_CAM + target = NARROW_ROAD_CAM else: # Hysteresis zone - keep current stream target = self.stream_type else: - target = ROAD_CAM + target = NARROW_ROAD_CAM if self.stream_type != target: self.switch_stream(target) @@ -138,14 +138,14 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewSP): def _update_calibration(self): # Update device camera if not already set sm = ui_state.sm - if not self.device_camera and sm.seen['roadCameraState'] and sm.seen['deviceState']: - self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))] + if not self.device_camera and sm.seen['narrowRoadCameraState'] and sm.seen['deviceState']: + self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['narrowRoadCameraState'].sensor))] - # Check if live calibration data is available and valid - if not (sm.updated["liveCalibration"] and sm.valid['liveCalibration']): + # Check if camera calibration data is available and valid + if not (sm.updated["extrinsicsCalibration"] and sm.valid['extrinsicsCalibration']): return - calib = sm['liveCalibration'] + calib = sm['extrinsicsCalibration'] if len(calib.rpyCalib) != 3 or calib.calStatus != CALIBRATED: return @@ -161,7 +161,7 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewSP): def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray: # Check if we can use cached matrix cache_key = ( - ui_state.sm.recv_frame['liveCalibration'], + ui_state.sm.recv_frame['extrinsicsCalibration'], self._content_rect.width, self._content_rect.height, self.stream_type @@ -172,7 +172,7 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewSP): # Get camera configuration device_camera = self.device_camera or DEFAULT_DEVICE_CAMERA is_wide_camera = self.stream_type == WIDE_CAM - intrinsic = device_camera.ecam.intrinsics if is_wide_camera else device_camera.fcam.intrinsics + intrinsic = device_camera.wide_road.intrinsics if is_wide_camera else device_camera.narrow_road.intrinsics calibration = self.view_from_wide_calib if is_wide_camera else self.view_from_calib zoom = 2.0 if is_wide_camera else 1.1 @@ -231,7 +231,7 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewSP): if __name__ == "__main__": gui_app.init_window("OnRoad Camera View") - road_camera_view = AugmentedRoadView(ROAD_CAM) + road_camera_view = AugmentedRoadView(NARROW_ROAD_CAM) gui_app.push_widget(road_camera_view) print("***press space to switch camera view***") try: @@ -239,7 +239,7 @@ if __name__ == "__main__": ui_state.update() if rl.is_key_released(rl.KeyboardKey.KEY_SPACE): if WIDE_CAM in road_camera_view.available_streams: - stream = ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM + stream = NARROW_ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM road_camera_view.switch_stream(stream) finally: road_camera_view.close() diff --git a/openpilot/selfdrive/ui/onroad/driver_camera_dialog.py b/openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py similarity index 90% rename from openpilot/selfdrive/ui/onroad/driver_camera_dialog.py rename to openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py index 7e07e44210..7bb1917c35 100644 --- a/openpilot/selfdrive/ui/onroad/driver_camera_dialog.py +++ b/openpilot/selfdrive/ui/onroad/cabin_camera_dialog.py @@ -1,6 +1,6 @@ import numpy as np import pyray as rl -from msgq.visionipc import VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType from openpilot.selfdrive.ui.onroad.cameraview import CameraView from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.ui_state import ui_state, device @@ -9,9 +9,9 @@ from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets.label import gui_label -class DriverCameraDialog(CameraView): +class CabinCameraDialog(CameraView): def __init__(self): - super().__init__("camerad", VisionStreamType.VISION_STREAM_DRIVER) + super().__init__("camerad", VisionStreamType.VISION_STREAM_CABIN) self.driver_state_renderer = DriverStateRenderer() # TODO: this can grow unbounded, should be given some thought device.add_interactive_timeout_callback(gui_app.pop_widget) @@ -100,12 +100,12 @@ class DriverCameraDialog(CameraView): if __name__ == "__main__": - gui_app.init_window("Driver Camera View") + gui_app.init_window("Cabin Camera View") - driver_camera_view = DriverCameraDialog() - gui_app.push_widget(driver_camera_view) + cabin_camera_view = CabinCameraDialog() + gui_app.push_widget(cabin_camera_view) try: for _ in gui_app.render(): ui_state.update() finally: - driver_camera_view.close() + cabin_camera_view.close() diff --git a/openpilot/selfdrive/ui/onroad/cameraview.py b/openpilot/selfdrive/ui/onroad/cameraview.py index 846bf20bbc..aa4f2271dc 100644 --- a/openpilot/selfdrive/ui/onroad/cameraview.py +++ b/openpilot/selfdrive/ui/onroad/cameraview.py @@ -2,9 +2,10 @@ import platform import numpy as np import pyray as rl -from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcClient, VisionBuf from openpilot.common.swaglog import cloudlog -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage from openpilot.system.ui.widgets import Widget @@ -38,7 +39,7 @@ void main() { """ # Choose fragment shader based on platform capabilities -if TICI: +if COMMA_HARDWARE: FRAME_FRAGMENT_SHADER = """ #version 300 es #extension GL_OES_EGL_image_external_essl3 : enable @@ -71,7 +72,7 @@ class CameraView(Widget): self._name = name # Primary stream self.client = VisionIpcClient(name, stream_type, conflate=True) - self._stream_type = stream_type + self._stream_type: VisionStreamType = stream_type self.available_streams: list[VisionStreamType] = [] # Target stream for switching @@ -82,7 +83,7 @@ class CameraView(Widget): self._texture_needs_update = True self.last_connection_attempt: float = 0.0 self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER) - self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not TICI else -1 + self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not COMMA_HARDWARE else -1 self.frame: VisionBuf | None = None self.texture_y: rl.Texture | None = None @@ -94,8 +95,8 @@ class CameraView(Widget): self._placeholder_color: rl.Color | None = None - # Initialize EGL for zero-copy rendering on TICI - if TICI: + # Initialize EGL for zero-copy rendering on COMMA_HARDWARE + if COMMA_HARDWARE: if not init_egl(): raise RuntimeError("Failed to initialize EGL") @@ -146,7 +147,7 @@ class CameraView(Widget): self._clear_textures() # Clean up EGL texture - if TICI and self.egl_texture: + if COMMA_HARDWARE and self.egl_texture: rl.unload_texture(self.egl_texture) self.egl_texture = None @@ -202,8 +203,8 @@ class CameraView(Widget): transform = self._calc_frame_matrix(rect) src_rect = rl.Rectangle(0, 0, float(self.frame.width), float(self.frame.height)) - # Flip driver camera horizontally - if self._stream_type == VisionStreamType.VISION_STREAM_DRIVER: + # Flip cabin camera horizontally + if self._stream_type == VisionStreamType.VISION_STREAM_CABIN: src_rect.width = -src_rect.width # Calculate scale @@ -220,7 +221,7 @@ class CameraView(Widget): dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y) # Render with appropriate method - if TICI: + if COMMA_HARDWARE: self._render_egl(src_rect, dst_rect) else: self._render_textures(src_rect, dst_rect) @@ -323,6 +324,7 @@ class CameraView(Widget): del self.client # Switch to target + assert self._target_client is not None and self._target_stream_type is not None self.client = self._target_client self._stream_type = self._target_stream_type self._texture_needs_update = True @@ -337,7 +339,7 @@ class CameraView(Widget): def _initialize_textures(self): self._clear_textures() - if not TICI: + if not COMMA_HARDWARE: self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), @@ -353,7 +355,7 @@ class CameraView(Widget): self.texture_uv = None # Clean up EGL resources - if TICI: + if COMMA_HARDWARE: for data in self.egl_images.values(): destroy_egl_image(data) self.egl_images = {} @@ -361,6 +363,6 @@ class CameraView(Widget): if __name__ == "__main__": gui_app.init_window("camera view") - road = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD) + road = CameraView("camerad", VisionStreamType.VISION_STREAM_NARROW_ROAD) for _ in gui_app.render(): road.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) diff --git a/openpilot/selfdrive/ui/onroad/model_renderer.py b/openpilot/selfdrive/ui/onroad/model_renderer.py index a4d9b8b65b..def1c644af 100644 --- a/openpilot/selfdrive/ui/onroad/model_renderer.py +++ b/openpilot/selfdrive/ui/onroad/model_renderer.py @@ -90,7 +90,7 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP): sm = ui_state.sm # Check if data is up-to-date - if (sm.recv_frame["liveCalibration"] < ui_state.started_frame or + if (sm.recv_frame["extrinsicsCalibration"] < ui_state.started_frame or sm.recv_frame["modelV2"] < ui_state.started_frame): return @@ -102,8 +102,8 @@ class ModelRenderer(Widget, ChevronMetrics, ModelRendererSP): # Update state self._experimental_mode = sm['selfdriveState'].experimentalMode - live_calib = sm['liveCalibration'] - self._path_offset_z = live_calib.height[0] if live_calib.height else HEIGHT_INIT[0] + extrinsics_calibration = sm['extrinsicsCalibration'] + self._path_offset_z = extrinsics_calibration.height[0] if extrinsics_calibration.height else HEIGHT_INIT[0] if self._counter % 60 == 0: self._camera_offset = ui_state.params.get("CameraOffset", return_default=True) if ui_state.active_bundle else 0.0 diff --git a/openpilot/selfdrive/ui/soundd.py b/openpilot/selfdrive/ui/soundd.py index a0ffbacffe..a61e26b0b6 100644 --- a/openpilot/selfdrive/ui/soundd.py +++ b/openpilot/selfdrive/ui/soundd.py @@ -53,18 +53,12 @@ sound_list: dict[int, tuple[str, int | None, float]] = { AudibleAlert.promptDistracted: ("dm_warning.wav", None, MAX_VOLUME), AudibleAlert.preAlert: ("pre_alert.wav", 1, MAX_VOLUME), - AudibleAlert.complete: ("complete.wav", 1, MAX_VOLUME), AudibleAlert.warningSoft: ("critical.wav", None, MAX_VOLUME), AudibleAlert.warningImmediate: ("dm_critical.wav", None, MAX_VOLUME), **sound_list_sp, } -if HARDWARE.get_device_type() == "tizi": - sound_list.update({ - AudibleAlert.engage: ("engage_tizi.wav", 1, MAX_VOLUME), - AudibleAlert.disengage: ("disengage_tizi.wav", 1, MAX_VOLUME), - }) def check_selfdrive_timeout_alert(sm): ss_missing = time.monotonic() - sm.recv_time['selfdriveState'] @@ -184,6 +178,7 @@ class Soundd(QuietMode): def soundd_thread(self): # sounddevice must be imported after forking processes import sounddevice as sd + micd.patch_sounddevice(sd) sm = messaging.SubMaster(['selfdriveState', 'selfdriveStateSP', 'soundPressure']) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py index e5c956cd81..99462dfcec 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/device.py @@ -5,7 +5,7 @@ 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. """ from openpilot.selfdrive.ui.layouts.settings.device import DeviceLayout -from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog +from openpilot.selfdrive.ui.onroad.cabin_camera_dialog import CabinCameraDialog from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.common.hardware import HARDWARE from openpilot.system.ui.lib.application import gui_app @@ -82,7 +82,7 @@ class DeviceLayoutSP(DeviceLayout): left_text=lambda: tr("Quiet Mode"), right_text=lambda: tr("Driver Camera Preview"), left_callback=lambda: ui_state.params.put_bool("QuietMode", not ui_state.params.get_bool("QuietMode")), - right_callback=lambda: gui_app.push_widget(DriverCameraDialog()) + right_callback=lambda: gui_app.push_widget(CabinCameraDialog()) ) self._quiet_mode_and_dcam.action_item.right_button.set_button_style(ButtonStyle.NORMAL) diff --git a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py index be2883d718..ae4ff802f3 100644 --- a/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/openpilot/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -106,7 +106,7 @@ class ModelsLayout(Widget): desc = tr("Enable this for the car to learn and adapt its steering response time. Disable to use a fixed steering response time. " + "Keeping this on provides the stock openpilot experience.") if lagd_toggle: - desc += f"
    {tr('Live Steer Delay:')} {ui_state.sm['liveDelay'].lateralDelay:.3f} s" + desc += f"
    {tr('Live Steer Delay:')} {ui_state.sm['lateralDelay'].lateralDelay:.3f} s" elif ui_state.CP is not None: sw = float(ui_state.params.get("LagdToggleDelay", "0.2")) cp = ui_state.CP.steerActuatorDelay diff --git a/openpilot/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py b/openpilot/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py index 889e1737ee..9d6cfd15a7 100644 --- a/openpilot/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py +++ b/openpilot/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py @@ -142,7 +142,7 @@ class DeveloperUiRenderer(Widget): # Add torque-specific elements if using torque control if sm['controlsState'].lateralControlState.which() == 'torqueState': override_active = ui_state.enforce_torque_control and ui_state.custom_torque_params and ui_state.torque_override_enabled - if sm.valid['liveTorqueParameters'] or override_active: + if sm.valid['lateralTorqueParameters'] or override_active: elements.extend([ self.friction_elem.update(sm, ui_state.is_metric), self.lat_accel_factor_elem.update(sm, ui_state.is_metric), diff --git a/openpilot/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py b/openpilot/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py index 4119990f22..f89edef48b 100644 --- a/openpilot/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py +++ b/openpilot/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py @@ -160,7 +160,7 @@ class ActualLateralAccelElement(LateralControlElement): controls_state = sm['controlsState'] curvature = controls_state.curvature v_ego = sm['carState'].vEgo - roll = sm['liveParameters'].roll if sm.valid['liveParameters'] else 0.0 + roll = sm['vehicleParameters'].roll if sm.valid['vehicleParameters'] else 0.0 lat_active = sm['carControl'].latActive steer_override = sm['carState'].steeringPressed @@ -179,7 +179,7 @@ class DesiredLateralAccelElement(LateralControlElement): controls_state = sm['controlsState'] desired_curvature = controls_state.desiredCurvature v_ego = sm['carState'].vEgo - roll = sm['liveParameters'].roll if sm.valid['liveParameters'] else 0.0 + roll = sm['vehicleParameters'].roll if sm.valid['vehicleParameters'] else 0.0 lat_active = sm['carControl'].latActive steer_override = sm['carState'].steeringPressed @@ -250,7 +250,7 @@ class FrictionCoefficientElement: if ui_state.enforce_torque_control and ui_state.custom_torque_params and ui_state.torque_override_enabled: return UiElement(f"{ui_state.torque_override_friction:.3f}", "FRIC.", self.unit, rl.WHITE) - ltp = sm['liveTorqueParameters'] + ltp = sm['lateralTorqueParameters'] value = f"{ltp.frictionCoefficientFiltered:.3f}" color = rl.Color(0, 255, 0, 255) if ltp.liveValid else rl.WHITE return UiElement(value, "FRIC.", self.unit, color) @@ -264,7 +264,7 @@ class LatAccelFactorElement: if ui_state.enforce_torque_control and ui_state.custom_torque_params and ui_state.torque_override_enabled: return UiElement(f"{ui_state.torque_override_lat_accel_factor:.3f}", "L.A.F.", self.unit, rl.WHITE) - ltp = sm['liveTorqueParameters'] + ltp = sm['lateralTorqueParameters'] value = f"{ltp.latAccelFactorFiltered:.3f}" color = rl.Color(0, 255, 0, 255) if ltp.liveValid else rl.WHITE return UiElement(value, "L.A.F.", self.unit, color) diff --git a/openpilot/selfdrive/ui/sunnypilot/ui_state.py b/openpilot/selfdrive/ui/sunnypilot/ui_state.py index 3f2889de9a..602830a4db 100644 --- a/openpilot/selfdrive/ui/sunnypilot/ui_state.py +++ b/openpilot/selfdrive/ui/sunnypilot/ui_state.py @@ -34,7 +34,7 @@ class UIStateSP: self.is_sp_release: bool = self.params.get_bool("IsReleaseSpBranch") self.sm_services_ext = [ "modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP", - "gpsLocation", "liveTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "liveDelay" + "gpsLocation", "lateralTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "lateralDelay" ] self.sunnylink_state = SunnylinkState() @@ -241,19 +241,26 @@ class DeviceSP: def _set_awake(self, on: bool, _ui_state=None): self._blocked_by_screensaver = False - if _ui_state.boot_offroad_mode == 1 and not on: - _ui_state.params.put_bool("OffroadMode", True) - if not on and _ui_state.screensaver_enabled: if _ui_state.screensaver.was_dismissed: - if gui_app.get_active_widget() == _ui_state.screensaver: - gui_app.pop_widget() + self.dismiss_screensaver(_ui_state) elif _ui_state.screensaver.is_active: self._blocked_by_screensaver = True else: _ui_state.screensaver.initialize() gui_app.push_widget(_ui_state.screensaver) self._blocked_by_screensaver = True + else: + self.dismiss_screensaver(_ui_state) + + # blocked runs every frame, so write only when actually sleeping + if _ui_state.boot_offroad_mode == 1 and not on and not self._blocked_by_screensaver: + _ui_state.params.put_bool("OffroadMode", True) + + def dismiss_screensaver(self, _ui_state) -> None: + if gui_app.get_active_widget() == _ui_state.screensaver: + gui_app.pop_widget() + self._blocked_by_screensaver = False @staticmethod def set_onroad_brightness(_ui_state, awake: bool, cur_brightness: float) -> float: diff --git a/openpilot/selfdrive/ui/tests/diff/replay_script.py b/openpilot/selfdrive/ui/tests/diff/replay_script.py index 18f3141fa1..fb654c5e8b 100644 --- a/openpilot/selfdrive/ui/tests/diff/replay_script.py +++ b/openpilot/selfdrive/ui/tests/diff/replay_script.py @@ -138,21 +138,25 @@ def setup_update_available(available: bool = True) -> None: params.remove("UpdaterTargetBranch") +def set_updater_state(state: str) -> None: + Params().put("UpdaterState", state, block=True) + + def setup_calibration_params() -> None: params = Params() - # live calibration - calib = messaging.new_message('liveCalibration') - calib.liveCalibration.calStatus = log.LiveCalibrationData.Status.calibrated - calib.liveCalibration.rpyCalib = [0.0, math.radians(2.5), math.radians(-1.2)] + # camera calibration + calib = messaging.new_message('extrinsicsCalibration') + calib.extrinsicsCalibration.calStatus = log.ExtrinsicsCalibration.Status.calibrated + calib.extrinsicsCalibration.rpyCalib = [0.0, math.radians(2.5), math.radians(-1.2)] params.put("CalibrationParams", calib.to_bytes(), block=True) - # live delay - delay = messaging.new_message('liveDelay') - delay.liveDelay.calPerc = 75 + # lateral delay + delay = messaging.new_message('lateralDelay') + delay.lateralDelay.calPerc = 75 params.put("LiveDelay", delay.to_bytes(), block=True) - # live torque parameters - torque = messaging.new_message('liveTorqueParameters') - torque.liveTorqueParameters.useParams = True - torque.liveTorqueParameters.calPerc = 60 + # lateral torque parameters + torque = messaging.new_message('lateralTorqueParameters') + torque.lateralTorqueParameters.useParams = True + torque.lateralTorqueParameters.calPerc = 60 params.put("LiveTorqueParameters", torque.to_bytes(), block=True) @@ -317,7 +321,7 @@ def build_mici_script(pm: PubMaster, main_layout, script: Script) -> None: lambda: swipe_left(width * 2), click, # first page, click next lambda: swipe_left(width * 2), swipe_down # second page, go back (TODO: make driver cam preview work) ), - None, # TODO: preview driver camera; enabling this causes MultiplePublishersError later in onroad alert tests + None, # TODO: preview cabin camera; enabling this causes MultiplePublishersError later in onroad alert tests lambda: explore_setting(swipe_left), # terms & conditions (swipe to view QR code) lambda: explore_setting(lambda: swipe_up(height * 3), lambda: swipe_down(height * 3)), # regulatory info lambda: run_actions(click, lambda: swipe_left(width)), # reset calibration confirm (goes back automatically) @@ -482,6 +486,9 @@ def build_tizi_script(pm: PubMaster, main_layout, script: Script) -> None: # === Settings - Software === script.setup(lambda: setup_update_available(False), wait_after=0) # start with no update available script.click(278, 720) # software + script.setup(lambda: set_updater_state("checking...")) # updater mid-check + script.setup(lambda: set_updater_state("downloading...")) # updater mid-download + script.setup(lambda: set_updater_state("idle"), wait_after=0) for _ in range(2): script.click(720, 120) # toggle current release notes script.setup(setup_update_available) # set update available diff --git a/openpilot/selfdrive/ui/tests/profile_onroad.py b/openpilot/selfdrive/ui/tests/profile_onroad.py index ec15e71c35..98816f9ddf 100755 --- a/openpilot/selfdrive/ui/tests/profile_onroad.py +++ b/openpilot/selfdrive/ui/tests/profile_onroad.py @@ -5,7 +5,8 @@ import cProfile import pyray as rl import numpy as np -from msgq.visionipc import VisionIpcServer, VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcServer from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout from openpilot.system.ui.lib.application import gui_app @@ -89,7 +90,7 @@ if __name__ == "__main__": W, H = 2048, 1216 vipc = VisionIpcServer("camerad") - vipc.create_buffers(VisionStreamType.VISION_STREAM_ROAD, 5, W, H) + vipc.create_buffers(VisionStreamType.VISION_STREAM_NARROW_ROAD, 5, W, H) vipc.start_listener() yuv_buffer_size = W * H + (W // 2) * (H // 2) * 2 yuv_data = np.random.default_rng().integers(0, 256, yuv_buffer_size, dtype=np.uint8).tobytes() @@ -99,7 +100,7 @@ if __name__ == "__main__": break if ui_state.sm.frame % 3 == 0: eof = int((ui_state.sm.frame % 3) * 0.05 * 1e9) - vipc.send(VisionStreamType.VISION_STREAM_ROAD, yuv_data, ui_state.sm.frame % 3, eof, eof) + vipc.send(VisionStreamType.VISION_STREAM_NARROW_ROAD, yuv_data, ui_state.sm.frame % 3, eof, eof) ui_state.update() pr.dump_stats(f'{args.output}_deterministic.stats') diff --git a/openpilot/selfdrive/ui/tests/test_feedbackd.py b/openpilot/selfdrive/ui/tests/test_feedbackd.py deleted file mode 100644 index 72bd499081..0000000000 --- a/openpilot/selfdrive/ui/tests/test_feedbackd.py +++ /dev/null @@ -1,53 +0,0 @@ -import pytest -import openpilot.cereal.messaging as messaging -from opendbc.car.structs import car -from openpilot.common.params import Params -from openpilot.system.manager.process_config import managed_processes - - -@pytest.mark.skip("tmp disabled") -class TestFeedbackd: - def setup_method(self): - self.pm = messaging.PubMaster(['carState', 'rawAudioData']) - self.sm = messaging.SubMaster(['audioFeedback']) - - def _send_lkas_button(self, pressed: bool): - msg = messaging.new_message('carState') - msg.carState.canValid = True - msg.carState.buttonEvents = [{'type': car.CarState.ButtonEvent.Type.lkas, 'pressed': pressed}] - self.pm.send('carState', msg) - - def _send_audio_data(self, count: int = 5): - for _ in range(count): - audio_msg = messaging.new_message('rawAudioData') - audio_msg.rawAudioData.data = bytes(1600) # 800 samples of int16 - audio_msg.rawAudioData.sampleRate = 16000 - self.pm.send('rawAudioData', audio_msg) - self.sm.update(timeout=100) - - @pytest.mark.parametrize("record_feedback", [False, True]) - def test_audio_feedback(self, record_feedback): - Params().put_bool("RecordAudioFeedback", record_feedback, block=True) - - managed_processes["feedbackd"].start() - assert self.pm.wait_for_readers_to_update('carState', timeout=5) - assert self.pm.wait_for_readers_to_update('rawAudioData', timeout=5) - - self._send_lkas_button(pressed=True) - self._send_audio_data() - self._send_lkas_button(pressed=False) - self._send_audio_data() - - if record_feedback: - assert self.sm.updated['audioFeedback'], "audioFeedback should be published when enabled" - else: - assert not self.sm.updated['audioFeedback'], "audioFeedback should not be published when disabled" - - self._send_lkas_button(pressed=True) - self._send_audio_data() - self._send_lkas_button(pressed=False) - self._send_audio_data() - - assert not self.sm.updated['audioFeedback'], "audioFeedback should not be published after second press" - - managed_processes["feedbackd"].stop() diff --git a/openpilot/selfdrive/ui/tests/test_raylib_ui.py b/openpilot/selfdrive/ui/tests/test_raylib_ui.py index d04c940cde..88f40ae6d9 100644 --- a/openpilot/selfdrive/ui/tests/test_raylib_ui.py +++ b/openpilot/selfdrive/ui/tests/test_raylib_ui.py @@ -1,8 +1,9 @@ import time +from openpilot.common.test import OpenpilotTestCase from openpilot.selfdrive.test.helpers import with_processes -class TestRaylibUi: +class TestRaylibUi(OpenpilotTestCase): @with_processes(["ui"]) def test_raylib_ui(self): """Test initialization of the UI widgets is successful.""" diff --git a/openpilot/selfdrive/ui/tests/test_soundd.py b/openpilot/selfdrive/ui/tests/test_soundd.py index 95588a9fba..e2cd90f988 100644 --- a/openpilot/selfdrive/ui/tests/test_soundd.py +++ b/openpilot/selfdrive/ui/tests/test_soundd.py @@ -1,35 +1,35 @@ +import threading +import time + +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import log, messaging from openpilot.cereal.messaging import SubMaster, PubMaster from openpilot.selfdrive.ui.soundd import SELFDRIVE_STATE_TIMEOUT, check_selfdrive_timeout_alert -import time - AudibleAlert = log.SelfdriveState.AudibleAlert -class TestSoundd: - def test_check_selfdrive_timeout_alert(self): +class TestSoundd(OpenpilotTestCase): + def test_check_selfdrive_timeout_alert(self, mocker): sm = SubMaster(['selfdriveState', 'selfdriveStateSP']) pm = PubMaster(['selfdriveState', 'selfdriveStateSP']) - for _ in range(100): - cs = messaging.new_message('selfdriveState') - cs.selfdriveState.enabled = True + cs = messaging.new_message('selfdriveState') + cs.selfdriveState.enabled = True + threading.Timer(0.01, pm.send, args=("selfdriveState", cs)).start() + sm.update(100) + assert sm.updated['selfdriveState'] - pm.send("selfdriveState", cs) - - time.sleep(0.01) - - sm.update(0) - - assert not check_selfdrive_timeout_alert(sm) - - for _ in range(SELFDRIVE_STATE_TIMEOUT * 110): - sm.update(0) - time.sleep(0.01) + received_at = sm.recv_time['selfdriveState'] + clock = mocker.patch("openpilot.selfdrive.ui.soundd.time.monotonic", return_value=received_at + SELFDRIVE_STATE_TIMEOUT) + assert not check_selfdrive_timeout_alert(sm) + clock.return_value = received_at + SELFDRIVE_STATE_TIMEOUT + 0.1 assert check_selfdrive_timeout_alert(sm) + clock.return_value = received_at + SELFDRIVE_STATE_TIMEOUT + 10 + assert not check_selfdrive_timeout_alert(sm) + def test_check_selfdrive_timeout_alert_mads_lateral_only(self): sm = SubMaster(['selfdriveState', 'selfdriveStateSP']) pm = PubMaster(['selfdriveState', 'selfdriveStateSP']) @@ -57,4 +57,3 @@ class TestSoundd: assert check_selfdrive_timeout_alert(sm) # TODO: add test with micd for checking that soundd actually outputs sounds - diff --git a/openpilot/selfdrive/ui/tests/test_translations.py b/openpilot/selfdrive/ui/tests/test_translations.py index 9eae072f21..0d636b61c8 100644 --- a/openpilot/selfdrive/ui/tests/test_translations.py +++ b/openpilot/selfdrive/ui/tests/test_translations.py @@ -3,8 +3,9 @@ import re import string from pathlib import Path -import pytest +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.selfdrive.ui.translations.potools import parse_po from openpilot.system.ui.lib.multilang import LANGUAGES_FILE, TRANSLATIONS_DIR @@ -46,13 +47,13 @@ def load_po_text(po_path: Path) -> str: return po_path.read_text(encoding='utf-8') -class TestTranslations: - @pytest.mark.parametrize("language_code", sorted(TRANSLATION_LANGUAGES.values())) +class TestTranslations(OpenpilotTestCase): + @parameterized.expand(sorted(TRANSLATION_LANGUAGES.values())) def test_translation_file_exists(self, language_code: str): po_path = PO_DIR / f"app_{language_code}.po" assert po_path.exists(), f"missing translation file: {po_path}" - @pytest.mark.parametrize("po_path", sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) + @parameterized.expand(sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) def test_translation_placeholders_are_preserved(self, po_path: Path): _, entries = parse_po(po_path) language = po_path.stem.removeprefix("app_") @@ -89,14 +90,14 @@ class TestTranslations: ) assert translated_placeholders == source_placeholders, message - @pytest.mark.parametrize("po_path", sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) + @parameterized.expand(sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) def test_translation_refs_do_not_include_line_numbers(self, po_path: Path): for line in load_po_text(po_path).splitlines(): assert not LINE_NUMBER_REF_RE.match(line), ( f"{po_path.name}: line-number source reference found: {line}" ) - @pytest.mark.parametrize("po_path", sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) + @parameterized.expand(sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) def test_translation_entities_are_valid(self, po_path: Path): matches = BAD_ENTITY_RE.findall(load_po_text(po_path)) assert not matches, ( diff --git a/openpilot/selfdrive/ui/ui.py b/openpilot/selfdrive/ui/ui.py index ff2bc7d3ce..30bba681c3 100755 --- a/openpilot/selfdrive/ui/ui.py +++ b/openpilot/selfdrive/ui/ui.py @@ -3,7 +3,7 @@ import os import time from openpilot.cereal import messaging -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE from openpilot.common.realtime import Priority, config_realtime_process, set_core_affinity from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.layouts.main import MainLayout @@ -31,7 +31,7 @@ def main(): if should_render: # reaffine after power save offlines our core - if TICI and os.sched_getaffinity(0) != cores: + if COMMA_HARDWARE and os.sched_getaffinity(0) != cores: try: set_core_affinity(list(cores)) except OSError: diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index 67845bdc87..e0aca74ff2 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -12,6 +12,7 @@ from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.ui.lib.prime_state import PrimeState from openpilot.system.ui.lib.application import gui_app from openpilot.common.hardware import HARDWARE, PC +from openpilot.selfdrive.modeld.helpers import usbgpu_compiled from openpilot.selfdrive.ui.sunnypilot.ui_state import UIStateSP, DeviceSP @@ -44,7 +45,7 @@ class UIState(UIStateSP): "modelV2", "controlsState", "onroadEvents", - "liveCalibration", + "extrinsicsCalibration", "radarState", "deviceState", "pandaStates", @@ -52,7 +53,7 @@ class UIState(UIStateSP): "driverMonitoringState", "carState", "driverStateV2", - "roadCameraState", + "narrowRoadCameraState", "wideRoadCameraState", "managerState", "selfdriveState", @@ -60,7 +61,7 @@ class UIState(UIStateSP): "gpsLocationExternal", "carOutput", "carControl", - "liveParameters", + "vehicleParameters", "testJoystick", "rawAudioData", ] + self.sm_services_ext @@ -80,15 +81,18 @@ class UIState(UIStateSP): self.is_release = False # self.params.get_bool("IsReleaseBranch") self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM") self.experimental_mode: bool = self.params.get_bool("ExperimentalMode") - self.usbgpu: bool = self.params.get_bool("UsbGpuPresent") - self.usbgpu_compiled: bool = self.params.get_bool("UsbGpuCompiled") + self.experimental_mode_confirmed: bool = self.params.get_bool("ExperimentalModeConfirmed") + self.usbgpu: bool = False + self.usbgpu_compiled: bool = usbgpu_compiled() + self.usbgpu_active: bool | None = self.params.get("UsbGpuActive") + self.usbgpu_loading: bool = self.params.get_bool("UsbGpuLoading") self.started: bool = False self.ignition: bool = False self.recording_audio: bool = False self.panda_type: log.PandaState.PandaType = log.PandaState.PandaType.unknown self.personality: log.LongitudinalPersonality = log.LongitudinalPersonality.standard self.has_longitudinal_control: bool = False - self.is_body: bool | None = None + self.is_body: bool | None = False self.CP: car.CarParams | None = None self.light_sensor: float = -1.0 @@ -211,8 +215,13 @@ class UIState(UIStateSP): self.is_metric = self.params.get_bool("IsMetric") self.always_on_dm = self.params.get_bool("AlwaysOnDM") self.experimental_mode = self.params.get_bool("ExperimentalMode") - self.usbgpu = self.params.get_bool("UsbGpuPresent") - self.usbgpu_compiled = self.params.get_bool("UsbGpuCompiled") + self.experimental_mode_confirmed = self.params.get_bool("ExperimentalModeConfirmed") + # keep usbgpu UI active until offroad transition when gpu disappears + self.usbgpu = self.sm["deviceState"].chestnutPresent or (self.usbgpu and self.started) + if not self.usbgpu_compiled: + self.usbgpu_compiled = usbgpu_compiled() + self.usbgpu_active = self.params.get("UsbGpuActive") + self.usbgpu_loading = self.params.get_bool("UsbGpuLoading") UIStateSP.update_params(self) @@ -338,6 +347,10 @@ class Device(DeviceSP): self._set_awake(ui_state.ignition or not interaction_timeout or PC) def _set_awake(self, on: bool, _ui_state=None): + # screensaver holds _awake True, so waking is not a state change + if on and self._blocked_by_screensaver: + self.dismiss_screensaver(_ui_state or ui_state) + if on != self._awake: super()._set_awake(on, _ui_state or ui_state) if self._blocked_by_screensaver: diff --git a/openpilot/selfdrive/ui/watch3.py b/openpilot/selfdrive/ui/watch3.py index bb64cdc4d5..c601ccabe9 100755 --- a/openpilot/selfdrive/ui/watch3.py +++ b/openpilot/selfdrive/ui/watch3.py @@ -1,15 +1,15 @@ #!/usr/bin/env python3 import pyray as rl -from msgq.visionipc import VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType from openpilot.system.ui.lib.application import gui_app from openpilot.selfdrive.ui.onroad.cameraview import CameraView if __name__ == "__main__": gui_app.init_window("watch3") - road = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD) - driver = CameraView("camerad", VisionStreamType.VISION_STREAM_DRIVER) + road = CameraView("camerad", VisionStreamType.VISION_STREAM_NARROW_ROAD) + driver = CameraView("camerad", VisionStreamType.VISION_STREAM_CABIN) wide = CameraView("camerad", VisionStreamType.VISION_STREAM_WIDE_ROAD) for _ in gui_app.render(): road.render(rl.Rectangle(gui_app.width // 4, 0, gui_app.width // 2, gui_app.height // 2)) diff --git a/openpilot/selfdrive/ui/widgets/pairing_dialog.py b/openpilot/selfdrive/ui/widgets/pairing_dialog.py index 1ff550e4b6..54ccfe19be 100644 --- a/openpilot/selfdrive/ui/widgets/pairing_dialog.py +++ b/openpilot/selfdrive/ui/widgets/pairing_dialog.py @@ -1,9 +1,8 @@ import pyray as rl -import qrcode -import numpy as np import time from openpilot.common.api import Api +from openpilot.common.qrcode import make_texture from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params from openpilot.system.ui.widgets import Widget @@ -39,24 +38,9 @@ class PairingDialog(Widget): def _generate_qr_code(self) -> None: try: - qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=4) - qr.add_data(self._get_pairing_url()) - qr.make(fit=True) - - pil_img = qr.make_image(fill_color="black", back_color="white").convert('RGBA') - img_array = np.array(pil_img, dtype=np.uint8) - if self.qr_texture and self.qr_texture.id != 0: rl.unload_texture(self.qr_texture) - - rl_image = rl.Image() - rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data) - rl_image.width = pil_img.width - rl_image.height = pil_img.height - rl_image.mipmaps = 1 - rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 - - self.qr_texture = rl.load_texture_from_image(rl_image) + self.qr_texture = make_texture(self._get_pairing_url()) except Exception: cloudlog.exception("QR code generation failed") self.qr_texture = None diff --git a/openpilot/selfdrive/ui/widgets/setup.py b/openpilot/selfdrive/ui/widgets/setup.py index c9452fc535..b00d1a42d4 100644 --- a/openpilot/selfdrive/ui/widgets/setup.py +++ b/openpilot/selfdrive/ui/widgets/setup.py @@ -18,7 +18,8 @@ class SetupWidget(Widget): self._pair_device_btn = Button(lambda: tr("Pair device"), self._show_pairing, button_style=ButtonStyle.PRIMARY) self._open_settings_btn = Button(lambda: tr("Open"), lambda: self._open_settings_callback() if self._open_settings_callback else None, button_style=ButtonStyle.PRIMARY) - self._firehose_label = Label(lambda: tr("🔥 Firehose Mode 🔥"), font_weight=FontWeight.MEDIUM, font_size=64) + self._firehose_label = Label(lambda: tr("Firehose Mode"), font_weight=FontWeight.MEDIUM, font_size=64) + self._fire_icon = gui_app.texture("icons/fire.png", 64, 64) def set_open_settings_callback(self, callback): self._open_settings_callback = callback @@ -67,6 +68,8 @@ class SetupWidget(Widget): # Title with fire emojis self._firehose_label.render(rl.Rectangle(rect.x, y, rect.width, 64)) + rl.draw_texture_ex(self._fire_icon, rl.Vector2(x, y), 0.0, 1.0, rl.WHITE) + rl.draw_texture_ex(self._fire_icon, rl.Vector2(x + w - 64, y), 0.0, 1.0, rl.WHITE) y += 64 + spacing # Description diff --git a/openpilot/sunnypilot/livedelay/lagd_toggle.py b/openpilot/sunnypilot/livedelay/lagd_toggle.py index 3926cbebf6..a617aaa4c6 100644 --- a/openpilot/sunnypilot/livedelay/lagd_toggle.py +++ b/openpilot/sunnypilot/livedelay/lagd_toggle.py @@ -23,7 +23,7 @@ class LagdToggle: self.lagd_toggle = self.params.get_bool("LagdToggle") self.software_delay = self.params.get("LagdToggleDelay", return_default=True) - def update(self, lag_msg: log.LiveDelayData) -> None: + def update(self, lag_msg: log.LateralDelay) -> None: self.read_params() if not self.lagd_toggle: @@ -33,6 +33,6 @@ class LagdToggle: self.params.put("LagdValueCache", self.lag) return - lateral_delay = lag_msg.liveDelay.lateralDelay + lateral_delay = lag_msg.lateralDelay.lateralDelay self.lag = lateral_delay self.params.put("LagdValueCache", self.lag) diff --git a/openpilot/sunnypilot/mads/tests/test_mads_state_machine.py b/openpilot/sunnypilot/mads/tests/test_mads_state_machine.py index 14549119fd..0147f2f4fb 100644 --- a/openpilot/sunnypilot/mads/tests/test_mads_state_machine.py +++ b/openpilot/sunnypilot/mads/tests/test_mads_state_machine.py @@ -5,14 +5,13 @@ 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 pytest -from pytest_mock import MockerFixture from openpilot.cereal import custom from openpilot.common.realtime import DT_CTRL from openpilot.sunnypilot.mads.state import StateMachine, SOFT_DISABLE_TIME from openpilot.selfdrive.selfdrived.events import ET, NormalPermanentAlert, Events from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP, EVENTS_SP +from openpilot.common.test import OpenpilotTestCase State = custom.ModularAssistiveDrivingSystem.ModularAssistiveDrivingSystemState EventNameSP = custom.OnroadEventSP.EventName @@ -34,16 +33,16 @@ def make_event(event_types): class MockMADS: - def __init__(self, mocker: MockerFixture): + def __init__(self, mocker): self.selfdrive = mocker.MagicMock() self.selfdrive.state_machine = mocker.MagicMock() self.selfdrive.events = Events() self.selfdrive.events_sp = EventsSP() -class TestMADSStateMachine: - @pytest.fixture(autouse=True) - def setup_method(self, mocker: MockerFixture): +class TestMADSStateMachine(OpenpilotTestCase): + def setup_method(self): + mocker = self._fixture("mocker") self.mads = MockMADS(mocker) self.state_machine = StateMachine(self.mads) self.events = self.mads.selfdrive.events diff --git a/openpilot/sunnypilot/mads/tests/test_mads_steering_mode.py b/openpilot/sunnypilot/mads/tests/test_mads_steering_mode.py index 2bc6130ece..2058a8b10c 100644 --- a/openpilot/sunnypilot/mads/tests/test_mads_steering_mode.py +++ b/openpilot/sunnypilot/mads/tests/test_mads_steering_mode.py @@ -5,7 +5,7 @@ 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 pytest +from openpilot.common.parameterized import parameterized from openpilot.cereal import log, custom from opendbc.car import structs @@ -14,6 +14,7 @@ from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP from openpilot.sunnypilot.mads.helpers import MadsSteeringModeOnBrake, read_steering_mode_param from openpilot.sunnypilot.mads.mads import ModularAssistiveDrivingSystem from opendbc.sunnypilot.car.tesla.values import MadsScreenButtonType, TeslaFlagsSP +from openpilot.common.test import OpenpilotTestCase State = custom.ModularAssistiveDrivingSystem.ModularAssistiveDrivingSystemState EventName = log.OnroadEvent.EventName @@ -80,8 +81,8 @@ def run_frames(mads, sd, cs, n=1): # should_silent_lkas_enable across all modes -class TestShouldSilentLkasEnable: - @pytest.mark.parametrize("brake,regen", [(True, False), (False, True)]) +class TestShouldSilentLkasEnable(OpenpilotTestCase): + @parameterized.expand([(True, False), (False, True)], names=["brake", "regen"]) def test_pause_blocks_reenable_on_braking_at_standstill(self, mocker, brake, regen): mads, _ = make_mads(mocker, MadsSteeringModeOnBrake.PAUSE) cs = make_car_state(brake_pressed=brake, regen_braking=regen, standstill=True) @@ -105,7 +106,7 @@ class TestShouldSilentLkasEnable: # pause -class TestPauseMode: +class TestPauseMode(OpenpilotTestCase): def test_stays_paused_at_standstill_brake_held(self, mocker): mads, sd = make_mads(mocker, MadsSteeringModeOnBrake.PAUSE) mads.state_machine.state = State.enabled @@ -150,7 +151,7 @@ class TestPauseMode: # disengage -class TestDisengageMode: +class TestDisengageMode(OpenpilotTestCase): def test_brake_while_enabled_disables(self, mocker): mads, sd = make_mads(mocker, MadsSteeringModeOnBrake.DISENGAGE) mads.state_machine.state = State.enabled @@ -174,7 +175,7 @@ class TestDisengageMode: # remain active -class TestRemainActiveMode: +class TestRemainActiveMode(OpenpilotTestCase): def test_brake_does_not_pause_or_disable(self, mocker): mads, sd = make_mads(mocker, MadsSteeringModeOnBrake.REMAIN_ACTIVE) mads.state_machine.state = State.enabled @@ -188,7 +189,7 @@ class TestRemainActiveMode: # lateral mismatch counter -class TestLateralMismatchCounter: +class TestLateralMismatchCounter(OpenpilotTestCase): def test_no_accumulation_while_paused(self, mocker): mads, sd = make_mads(mocker, MadsSteeringModeOnBrake.PAUSE) mads.state_machine.state = State.paused @@ -212,7 +213,7 @@ class TestLateralMismatchCounter: # brand restrictions -class TestBrandSteeringModeRestrictions: +class TestBrandSteeringModeRestrictions(OpenpilotTestCase): def test_rivian_forced_to_disengage(self, mocker): CP = structs.CarParams() CP.brand = "rivian" @@ -229,9 +230,9 @@ class TestBrandSteeringModeRestrictions: params = mocker.MagicMock() assert read_steering_mode_param(CP, CP_SP, params) == MadsSteeringModeOnBrake.DISENGAGE - @pytest.mark.parametrize("screen_button", [MadsScreenButtonType.THREE_FINGER, + @parameterized.expand([MadsScreenButtonType.THREE_FINGER, MadsScreenButtonType.FOUR_FINGER, - MadsScreenButtonType.FIVE_FINGER]) + MadsScreenButtonType.FIVE_FINGER], names=["screen_button"]) def test_tesla_with_vehicle_bus_uses_param(self, mocker, screen_button): CP = structs.CarParams() CP.brand = "tesla" @@ -250,7 +251,7 @@ class TestBrandSteeringModeRestrictions: "MadsSteeringMode": MadsSteeringModeOnBrake.REMAIN_ACTIVE}) assert read_steering_mode_param(CP, CP_SP, params) == MadsSteeringModeOnBrake.DISENGAGE - @pytest.mark.parametrize("brand", ["hyundai", "toyota", "honda", "gm"]) + @parameterized.expand(["hyundai", "toyota", "honda", "gm"], names=["brand"]) def test_other_brands_use_param(self, mocker, brand): CP = structs.CarParams() CP.brand = brand diff --git a/openpilot/sunnypilot/mapd/live_map_data/debug.py b/openpilot/sunnypilot/mapd/live_map_data/debug.py index 00783d58df..38a696d403 100644 --- a/openpilot/sunnypilot/mapd/live_map_data/debug.py +++ b/openpilot/sunnypilot/mapd/live_map_data/debug.py @@ -37,7 +37,7 @@ def live_map_data_sp_thread(): def live_map_data_sp_thread_debug(gps_location_service): - _sub_master = messaging.SubMaster(['carState', 'livePose', 'liveMapDataSP', 'longitudinalPlanSP', 'carStateSP', gps_location_service]) + _sub_master = messaging.SubMaster(['carState', 'deviceMotion', 'liveMapDataSP', 'longitudinalPlanSP', 'carStateSP', gps_location_service]) _sub_master.update() v_ego = _sub_master['carState'].vEgo diff --git a/openpilot/sunnypilot/mapd/mapd_installer.py b/openpilot/sunnypilot/mapd/mapd_installer.py index 9f2e72720a..ec3cf94e7e 100755 --- a/openpilot/sunnypilot/mapd/mapd_installer.py +++ b/openpilot/sunnypilot/mapd/mapd_installer.py @@ -26,7 +26,7 @@ VERSION = "v1.12.0" URL = f"https://github.com/pfeiferj/openpilot-mapd/releases/download/{VERSION}/mapd" -def update_installed_version(version: str, params: Params = None) -> None: +def update_installed_version(version: str, params: Params | None = None) -> None: if params is None: params = Params() diff --git a/openpilot/sunnypilot/mapd/tests/test_mapd_version.py b/openpilot/sunnypilot/mapd/tests/test_mapd_version.py index 5619d2ec29..acbbe51c37 100644 --- a/openpilot/sunnypilot/mapd/tests/test_mapd_version.py +++ b/openpilot/sunnypilot/mapd/tests/test_mapd_version.py @@ -7,9 +7,10 @@ See the LICENSE.md file in the root directory for more details. from openpilot.sunnypilot import get_file_hash from openpilot.sunnypilot.mapd import MAPD_PATH from openpilot.sunnypilot.mapd.update_version import MAPD_HASH_PATH +from openpilot.common.test import OpenpilotTestCase -class TestMapdVersion: +class TestMapdVersion(OpenpilotTestCase): def test_compare_versions(self): mapd_hash = get_file_hash(MAPD_PATH) diff --git a/openpilot/sunnypilot/modeld_v2/camera_offset_helper.py b/openpilot/sunnypilot/modeld_v2/camera_offset_helper.py index 7502c3eeb0..648ba01086 100644 --- a/openpilot/sunnypilot/modeld_v2/camera_offset_helper.py +++ b/openpilot/sunnypilot/modeld_v2/camera_offset_helper.py @@ -28,12 +28,12 @@ class CameraOffsetHelper: def update(self, model_transform_main, model_transform_extra, sm, main_wide_camera): self.actual_camera_offset = (0.9 * self.actual_camera_offset) + (0.1 * self.camera_offset) - dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))] - height = sm["liveCalibration"].height[0] if sm['liveCalibration'].height else 1.22 + dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['narrowRoadCameraState'].sensor))] + height = sm["extrinsicsCalibration"].height[0] if sm['extrinsicsCalibration'].height else 1.22 - intrinsics_main = dc.ecam.intrinsics if main_wide_camera else dc.fcam.intrinsics + intrinsics_main = dc.wide_road.intrinsics if main_wide_camera else dc.narrow_road.intrinsics model_transform_main = self.apply_camera_offset(model_transform_main, intrinsics_main, height, self.actual_camera_offset) - intrinsics_extra = dc.ecam.intrinsics + intrinsics_extra = dc.wide_road.intrinsics model_transform_extra = self.apply_camera_offset(model_transform_extra, intrinsics_extra, height, self.actual_camera_offset) return model_transform_main, model_transform_extra diff --git a/openpilot/sunnypilot/modeld_v2/modeld.py b/openpilot/sunnypilot/modeld_v2/modeld.py index 9f3a75a662..66b560802b 100755 --- a/openpilot/sunnypilot/modeld_v2/modeld.py +++ b/openpilot/sunnypilot/modeld_v2/modeld.py @@ -8,8 +8,8 @@ See the LICENSE.md file in the root directory for more details. import os os.environ['GMMU'] = '0' -from openpilot.common.hardware import TICI -os.environ['DEV'] = 'QCOM' if TICI else 'CPU' +from openpilot.common.hardware import COMMA_HARDWARE +os.environ['DEV'] = 'QCOM' if COMMA_HARDWARE else 'CPU' USBGPU = "USBGPU" in os.environ if USBGPU: os.environ['DEV'] = 'AMD' @@ -22,7 +22,8 @@ from openpilot.cereal import log from opendbc.car.structs import car from setproctitle import setproctitle from openpilot.cereal.messaging import PubMaster, SubMaster -from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcClient, VisionBuf from opendbc.car.car_helpers import get_demo_car_params from tinygrad.tensor import Tensor @@ -311,12 +312,12 @@ def main(demo=False): while True: available_streams = VisionIpcClient.available_streams("camerad", block=False) if available_streams: - use_extra_client = VisionStreamType.VISION_STREAM_WIDE_ROAD in available_streams and VisionStreamType.VISION_STREAM_ROAD in available_streams - main_wide_camera = VisionStreamType.VISION_STREAM_ROAD not in available_streams + use_extra_client = VisionStreamType.VISION_STREAM_WIDE_ROAD in available_streams and VisionStreamType.VISION_STREAM_NARROW_ROAD in available_streams + main_wide_camera = VisionStreamType.VISION_STREAM_NARROW_ROAD not in available_streams break time.sleep(.1) - vipc_client_main_stream = VisionStreamType.VISION_STREAM_WIDE_ROAD if main_wide_camera else VisionStreamType.VISION_STREAM_ROAD + vipc_client_main_stream = VisionStreamType.VISION_STREAM_WIDE_ROAD if main_wide_camera else VisionStreamType.VISION_STREAM_NARROW_ROAD vipc_client_main = VisionIpcClient("camerad", vipc_client_main_stream, True) vipc_client_extra = VisionIpcClient("camerad", VisionStreamType.VISION_STREAM_WIDE_ROAD, False) cloudlog.warning(f"vision stream set up, main_wide_camera: {main_wide_camera}, use_extra_client: {use_extra_client}") @@ -336,7 +337,7 @@ def main(demo=False): # messaging pm = PubMaster(["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"]) - sm = SubMaster(["deviceState", "carState", "roadCameraState", "liveCalibration", "driverMonitoringState", "carControl", "liveDelay"]) + sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"]) publish_state = PublishState() params = Params() @@ -406,18 +407,19 @@ def main(demo=False): sm.update(0) desire = DH.desire is_rhd = sm["driverMonitoringState"].isRHD - frame_id = sm["roadCameraState"].frameId + frame_id = sm["narrowRoadCameraState"].frameId v_ego = max(sm["carState"].vEgo, 0.) if sm.frame % 60 == 0: - model.lat_delay = get_lat_delay(params, sm["liveDelay"].lateralDelay) + model.lat_delay = get_lat_delay(params, sm["lateralDelay"].lateralDelay) model.PLANPLUS_CONTROL = params.get("PlanplusControl", return_default=True) camera_offset_helper.set_offset(params.get("CameraOffset", return_default=True)) lat_delay = model.lat_delay + model.LAT_SMOOTH_SECONDS - if sm.updated["liveCalibration"] and sm.seen['roadCameraState'] and sm.seen['deviceState']: - device_from_calib_euler = np.array(sm["liveCalibration"].rpyCalib, dtype=np.float32) - dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))] - model_transform_main = get_warp_matrix(device_from_calib_euler, dc.ecam.intrinsics if main_wide_camera else dc.fcam.intrinsics, False).astype(np.float32) - model_transform_extra = get_warp_matrix(device_from_calib_euler, dc.ecam.intrinsics, True).astype(np.float32) + if sm.updated["extrinsicsCalibration"] and sm.seen['narrowRoadCameraState'] and sm.seen['deviceState']: + device_from_calib_euler = np.array(sm["extrinsicsCalibration"].rpyCalib, dtype=np.float32) + dc = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['narrowRoadCameraState'].sensor))] + main_intrinsics = dc.wide_road.intrinsics if main_wide_camera else dc.narrow_road.intrinsics + model_transform_main = get_warp_matrix(device_from_calib_euler, main_intrinsics, False).astype(np.float32) + model_transform_extra = get_warp_matrix(device_from_calib_euler, dc.wide_road.intrinsics, True).astype(np.float32) model_transform_main, model_transform_extra = camera_offset_helper.update(model_transform_main, model_transform_extra, sm, main_wide_camera) live_calib_seen = True diff --git a/openpilot/sunnypilot/modeld_v2/tests/conftest.py b/openpilot/sunnypilot/modeld_v2/tests/helpers.py similarity index 97% rename from openpilot/sunnypilot/modeld_v2/tests/conftest.py rename to openpilot/sunnypilot/modeld_v2/tests/helpers.py index f79cbe10b2..6925a61f08 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/conftest.py +++ b/openpilot/sunnypilot/modeld_v2/tests/helpers.py @@ -5,8 +5,9 @@ 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 pathlib import pickle -import pytest +import tempfile import openpilot.sunnypilot.models.helpers as helpers import openpilot.sunnypilot.modeld_v2.modeld as modeld_module @@ -181,16 +182,19 @@ def make_bundle(archetype): ) -@pytest.fixture +def tmp_path(): + with tempfile.TemporaryDirectory() as d: + yield pathlib.Path(d) + + def patch_modeld(monkeypatch): def _patch(bundle): - monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None: bundle, raising=False) - monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None: bundle, raising=False) + monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None: bundle) + monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None: bundle) return _patch -@pytest.fixture def model_state_factory(tmp_path, monkeypatch, patch_modeld): from openpilot.common.hardware import hw diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_camera_offset_helper.py b/openpilot/sunnypilot/modeld_v2/tests/test_camera_offset_helper.py index f25bcd0a35..5398ac0ff1 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_camera_offset_helper.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_camera_offset_helper.py @@ -9,6 +9,7 @@ import numpy as np from openpilot.common.transformations.camera import DEVICE_CAMERAS from openpilot.common.transformations.model import get_warp_matrix from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper +from openpilot.common.test import OpenpilotTestCase class MockStruct: @@ -20,7 +21,7 @@ class MockStruct: return getattr(self, item) -class TestCameraOffset: +class TestCameraOffset(OpenpilotTestCase): def setup_method(self): self.camera_offset = CameraOffsetHelper() self.dc = DEVICE_CAMERAS[('mici', 'os04c10')] @@ -30,12 +31,12 @@ class TestCameraOffset: sm = MockStruct( deviceState=MockStruct(deviceType='mici'), - roadCameraState=MockStruct(sensor='os04c10'), - liveCalibration=MockStruct(rpyCalib=[0.0, 0.0, 0.0], height=[1.22]) + narrowRoadCameraState=MockStruct(sensor='os04c10'), + extrinsicsCalibration=MockStruct(rpyCalib=[0.0, 0.0, 0.0], height=[1.22]) ) - intrinsics_main = self.dc.fcam.intrinsics - intrinsics_extra = self.dc.ecam.intrinsics + intrinsics_main = self.dc.narrow_road.intrinsics + intrinsics_extra = self.dc.wide_road.intrinsics device_from_calib_euler = np.array([0.0, 0.0, 0.0], dtype=np.float32) main_transform = get_warp_matrix(device_from_calib_euler, intrinsics_main, False).astype(np.float32) extra_transform = get_warp_matrix(device_from_calib_euler, intrinsics_extra, True).astype(np.float32) @@ -46,7 +47,7 @@ class TestCameraOffset: np.testing.assert_almost_equal(self.camera_offset.actual_camera_offset, 0.038) def test_camera_offset_(self): - intrinsics = self.dc.fcam.intrinsics + intrinsics = self.dc.narrow_road.intrinsics transform = np.eye(3, dtype=np.float32) height = 1.22 offset = 0.1 @@ -62,11 +63,11 @@ class TestCameraOffset: def test_update(self): sm = MockStruct( deviceState=MockStruct(deviceType='mici'), - roadCameraState=MockStruct(sensor='os04c10'), - liveCalibration=MockStruct(rpyCalib=[0.0, 0.0, 0.0], height=[1.22]) + narrowRoadCameraState=MockStruct(sensor='os04c10'), + extrinsicsCalibration=MockStruct(rpyCalib=[0.0, 0.0, 0.0], height=[1.22]) ) - intrinsics_main = self.dc.fcam.intrinsics - intrinsics_extra = self.dc.ecam.intrinsics + intrinsics_main = self.dc.narrow_road.intrinsics + intrinsics_extra = self.dc.wide_road.intrinsics device_from_calib_euler = np.array([0.0, 0.0, 0.0], dtype=np.float32) main_transform = get_warp_matrix(device_from_calib_euler, intrinsics_main, False).astype(np.float32) extra_transform = get_warp_matrix(device_from_calib_euler, intrinsics_extra, True).astype(np.float32) 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 3c544586f8..3396649a1d 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_combined_pkl_loader.py @@ -5,20 +5,27 @@ 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 pytest +from openpilot.common.parameterized import parameterized import openpilot.sunnypilot.models.helpers as helpers 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.conftest import DummyModel, DummyBundle, ARCHETYPES, CAM_W, CAM_H, \ +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 +from openpilot.common.test import OpenpilotTestCase + +# resolved by name from this module when a test asks for them +tmp_path = tests_helpers.tmp_path +patch_modeld = tests_helpers.patch_modeld +model_state_factory = tests_helpers.model_state_factory ModelState = modeld_module.ModelState # Pkl discovery -class TestFindDrivingPkl: +class TestFindDrivingPkl(OpenpilotTestCase): def test_returns_none_when_no_bundle(self): assert _find_driving_pkl(None) is None @@ -49,16 +56,16 @@ class TestFindDrivingPkl: # Init — assertion guard -class TestModelStateCombinedInit: +class TestModelStateCombinedInit(OpenpilotTestCase): def test_asserts_when_no_pkl(self, monkeypatch): bundle = DummyBundle(models=[], is_20hz=True) - monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None: bundle, raising=False) - monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None: bundle, raising=False) - with pytest.raises(AssertionError, match="No driving pkl found"): + monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None: bundle) + monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None: bundle) + with self.assertRaisesRegex(AssertionError, "No driving pkl found"): ModelState(cam_w=CAM_W, cam_h=CAM_H) -class TestStockEquivalence: +class TestStockEquivalence(OpenpilotTestCase): def test_split_queue_keys_match_stock(self, model_state_factory): from openpilot.selfdrive.modeld.compile_modeld import make_input_queues @@ -100,8 +107,8 @@ class TestStockEquivalence: ARCHETYPE_NAMES = list(ARCHETYPES.keys()) -class TestModelTypeDetection: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestModelTypeDetection(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_combined_model_type(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -109,8 +116,8 @@ class TestModelTypeDetection: f"{arch.name}: got {state._combined_model_type}, expected {arch.expected_model_type}" -class TestConstantsSelection: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestConstantsSelection(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_constants_class(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -118,8 +125,8 @@ class TestConstantsSelection: f"{arch.name}: got {type(state.constants).__name__}, expected {arch.expected_constants_class.__name__}" -class TestParserSelection: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestParserSelection(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_parser_module(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -128,8 +135,8 @@ class TestParserSelection: f"{arch.name}: parser from {parser_module}, expected module ending with {arch.expected_parser_module}" -class TestDesireKeyDetection: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestDesireKeyDetection(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_desire_key(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -137,8 +144,8 @@ class TestDesireKeyDetection: f"{arch.name}: got {state.desire_key}, expected {arch.expected_desire_key}" -class TestVisionInputNames: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestVisionInputNames(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_vision_names_contain_img(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -147,14 +154,14 @@ class TestVisionInputNames: assert 'img' in name, f"{arch.name}: vision input name '{name}' missing 'img'" -class TestOutputSlices: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestOutputSlices(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_vision_slices_populated(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) assert len(state.vision_output_slices) > 0, f"{arch.name}: vision_output_slices empty" - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_policy_slices_match_type(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -164,14 +171,14 @@ class TestOutputSlices: assert len(state.policy_output_slices) > 0, f"{arch.name}: split/multi should have policy slices" -class TestInputQueueCreation: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestInputQueueCreation(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) 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" - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_npy_contains_transforms(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -180,7 +187,7 @@ class TestInputQueueCreation: assert state.numpy_inputs['tfm'].shape == (3, 3) assert state.numpy_inputs['big_tfm'].shape == (3, 3) - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_npy_contains_desire(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -188,8 +195,8 @@ class TestInputQueueCreation: f"{arch.name}: '{arch.expected_desire_key}' missing from npy" -class TestFrameBufferParams: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestFrameBufferParams(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_frame_buf_params_per_vision_input(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) @@ -199,29 +206,29 @@ class TestFrameBufferParams: assert len(nv12_info) >= 4, f"{arch.name}: nv12_info for '{name}' too short" -class TestBundleOverrides: - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) +class TestBundleOverrides(OpenpilotTestCase): + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_smoothing_params_from_overrides(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) assert state.LAT_SMOOTH_SECONDS == 0.1 assert state.LONG_SMOOTH_SECONDS == 0.3 - @pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES) + @parameterized.expand(ARCHETYPE_NAMES, names=["archetype_name"]) def test_generation_from_bundle(self, archetype_name, model_state_factory): arch = ARCHETYPES[archetype_name] state = model_state_factory(arch) assert state.generation == 10 -class TestMlsimProperty: +class TestMlsimProperty(OpenpilotTestCase): def test_mlsim_false_for_gen10(self, model_state_factory): state = model_state_factory(ARCHETYPES['supercombo_non20hz']) assert state.mlsim is False def test_mlsim_true_for_gen11(self, tmp_path, monkeypatch, patch_modeld): from openpilot.common.hardware import hw - from openpilot.sunnypilot.modeld_v2.tests.conftest import write_pkl, ARCHETYPES as A + from openpilot.sunnypilot.modeld_v2.tests.helpers import write_pkl, ARCHETYPES as A arch = A['supercombo_non20hz'] write_pkl(tmp_path, arch) @@ -233,10 +240,10 @@ class TestMlsimProperty: assert state.mlsim is True -class TestCrossArchetypeMismatch: +class TestCrossArchetypeMismatch(OpenpilotTestCase): def test_wrong_is_20hz_changes_constants(self, tmp_path, monkeypatch, patch_modeld): from openpilot.common.hardware import hw - from openpilot.sunnypilot.modeld_v2.tests.conftest import write_pkl + from openpilot.sunnypilot.modeld_v2.tests.helpers import write_pkl from openpilot.sunnypilot.modeld_v2.constants import ModelConstants arch = ARCHETYPES['vision_policy_split'] diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py b/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py index c30dd46e33..885696b853 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_compile_modeld.py @@ -6,12 +6,13 @@ See the LICENSE.md file in the root directory for more details. """ import numpy as np -import pytest +from openpilot.common.parameterized import parameterized from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, _detect_desire_key +from openpilot.common.test import OpenpilotTestCase -class TestDeriveFrameSkip: +class TestDeriveFrameSkip(OpenpilotTestCase): def test_non20hz_supercombo(self): vision = {} policy = {'features_buffer': (1, 99, 512), 'desire': (1, 100, 8)} @@ -31,21 +32,21 @@ class TestDeriveFrameSkip: assert derive_frame_skip({}, {}) == 1 -class TestFrameSkipBufferLengthEquivalence: - @pytest.mark.parametrize("frame_skip,expected_buffer_length", [ +class TestFrameSkipBufferLengthEquivalence(OpenpilotTestCase): + @parameterized.expand([ (1, 2), (4, 5), - ]) + ], names=["frame_skip", "expected_buffer_length"]) def test_img_buffer_size_matches_warp_buffer_length(self, frame_skip, expected_buffer_length): n_frames = 2 img_buf_dim0 = frame_skip * (n_frames - 1) + 1 assert img_buf_dim0 == expected_buffer_length, \ f"frame_skip={frame_skip}: img_buf[0]={img_buf_dim0}, expected {expected_buffer_length}" - @pytest.mark.parametrize("is_20hz,expected_frame_skip,expected_buffer_length", [ + @parameterized.expand([ (False, 1, 2), (True, 4, 5), - ]) + ], names=["is_20hz", "expected_frame_skip", "expected_buffer_length"]) def test_is_20hz_to_frame_skip_to_buffer_length(self, is_20hz, expected_frame_skip, expected_buffer_length): if is_20hz: policy_shapes = {'features_buffer': (1, 24, 512)} @@ -59,7 +60,7 @@ class TestFrameSkipBufferLengthEquivalence: assert img_buf_dim0 == expected_buffer_length -class TestTemporalSamplingEquivalence: +class TestTemporalSamplingEquivalence(OpenpilotTestCase): def test_non20hz_desire_sampling_identity(self): buf = np.random.default_rng(0).standard_normal((100, 1, 8)).astype(np.float32) frame_skip = 1 @@ -94,12 +95,12 @@ class TestTemporalSamplingEquivalence: np.testing.assert_array_equal(sampled, buf[:, 0, :]) -class TestTemporalIdxEquivalence: - @pytest.mark.parametrize("mode,desire_shape,fb_shape,frame_skip", [ +class TestTemporalIdxEquivalence(OpenpilotTestCase): + @parameterized.expand([ ('non20hz', (1, 100, 8), (1, 99, 512), 1), ('20hz', (1, 25, 8), (1, 24, 512), 4), ('split', (1, 25, 8), (1, 25, 512), 4), - ]) + ], names=["mode", "desire_shape", "fb_shape", "frame_skip"]) def test_features_buffer_idx_equivalence(self, mode, desire_shape, fb_shape, frame_skip): history = fb_shape[1] @@ -118,11 +119,11 @@ class TestTemporalIdxEquivalence: assert len(modelstate_idxs) == fb_shape[1], \ f"{mode}: ModelState idx count {len(modelstate_idxs)} != input shape {fb_shape[1]}" - @pytest.mark.parametrize("mode,desire_shape,fb_shape,frame_skip", [ + @parameterized.expand([ ('non20hz', (1, 100, 8), (1, 99, 512), 1), ('20hz', (1, 25, 8), (1, 24, 512), 4), ('split', (1, 25, 8), (1, 25, 512), 4), - ]) + ], names=["mode", "desire_shape", "fb_shape", "frame_skip"]) def test_desire_idx_equivalence(self, mode, desire_shape, fb_shape, frame_skip): history = desire_shape[1] @@ -132,7 +133,7 @@ class TestTemporalIdxEquivalence: f"{mode}: compile desire samples {compile_sampled_count} != model input {history}" -class TestDetectDesireKey: +class TestDetectDesireKey(OpenpilotTestCase): def test_finds_desire(self): shapes = {'features_buffer': (1, 99, 512), 'desire': (1, 100, 8), 'traffic_convention': (1, 2)} assert _detect_desire_key(shapes) == 'desire' @@ -146,7 +147,7 @@ class TestDetectDesireKey: assert _detect_desire_key(shapes) is None -class TestOutputSlicePreservation: +class TestOutputSlicePreservation(OpenpilotTestCase): def test_vision_hidden_state_slice_used_for_features(self): mock_slices = {'hidden_state': slice(0, 512), 'plan': slice(512, 1024)} features_slice = mock_slices['hidden_state'] diff --git a/openpilot/sunnypilot/modeld_v2/tests/test_recovery_power.py b/openpilot/sunnypilot/modeld_v2/tests/test_recovery_power.py index d7c8563e64..85305395ea 100644 --- a/openpilot/sunnypilot/modeld_v2/tests/test_recovery_power.py +++ b/openpilot/sunnypilot/modeld_v2/tests/test_recovery_power.py @@ -7,6 +7,7 @@ from openpilot.cereal import log from openpilot.sunnypilot.modeld_v2.constants import Plan from openpilot.sunnypilot.modeld_v2.modeld import ModelState import openpilot.sunnypilot.modeld_v2.modeld as modeld +from openpilot.common.test import OpenpilotTestCase class MockStruct: @@ -15,58 +16,59 @@ class MockStruct: setattr(self, k, v) -def test_recovery_power_scaling(): - state: Any = MockStruct( - PLANPLUS_CONTROL=0.75, - LONG_SMOOTH_SECONDS=0.3, - LAT_SMOOTH_SECONDS=0.1, - MIN_LAT_CONTROL_SPEED=0.3, - mlsim=True, - generation=12, - constants=MockStruct(T_IDXS=np.arange(100), DESIRE_LEN=8) - ) - prev_action = log.ModelDataV2.Action() - recorded_vel: list = [] - recorded_curv_plans: list = [] +class TestRecoveryPower(OpenpilotTestCase): + def test_recovery_power_scaling(self): + state: Any = MockStruct( + PLANPLUS_CONTROL=0.75, + LONG_SMOOTH_SECONDS=0.3, + LAT_SMOOTH_SECONDS=0.1, + MIN_LAT_CONTROL_SPEED=0.3, + mlsim=True, + generation=12, + constants=MockStruct(T_IDXS=np.arange(100), DESIRE_LEN=8) + ) + prev_action = log.ModelDataV2.Action() + recorded_vel: list = [] + recorded_curv_plans: list = [] - def mock_accel(plan_vel, plan_accel, t_idxs, action_t=0.0): - recorded_vel.append(plan_vel.copy()) - return 0.0, False + def mock_accel(plan_vel, plan_accel, t_idxs, action_t=0.0): + recorded_vel.append(plan_vel.copy()) + return 0.0, False - def mock_curvature(output, plan, vego, lat_action_t, mlsim): - recorded_curv_plans.append(plan.copy()) - return 0.0 + def mock_curvature(output, plan, vego, lat_action_t, mlsim): + recorded_curv_plans.append(plan.copy()) + return 0.0 - modeld.get_accel_from_plan = mock_accel # ty: ignore[invalid-assignment] - modeld.get_curvature_from_output = mock_curvature # ty: ignore[invalid-assignment] - plan = np.random.default_rng(0).random((1, 100, 15)).astype(np.float32) - planplus = np.random.default_rng(1).random((1, 100, 15)).astype(np.float32) - merged_plan = plan + planplus + modeld.get_accel_from_plan = mock_accel # ty: ignore[invalid-assignment] + modeld.get_curvature_from_output = mock_curvature # ty: ignore[invalid-assignment] + plan = np.random.default_rng(0).random((1, 100, 15)).astype(np.float32) + planplus = np.random.default_rng(1).random((1, 100, 15)).astype(np.float32) + merged_plan = plan + planplus - model_output: dict = { - 'plan': merged_plan.copy(), - 'planplus': planplus.copy() - } + model_output: dict = { + 'plan': merged_plan.copy(), + 'planplus': planplus.copy() + } - test_cases: list = [ - # (control, v_ego) - (0.55, 20.0), - (1.0, 25.0), - (1.5, 25.1), - (2.0, 20.0), - (0.75, 19.0), - (0.8, 25.1), - ] + test_cases: list = [ + # (control, v_ego) + (0.55, 20.0), + (1.0, 25.0), + (1.5, 25.1), + (2.0, 20.0), + (0.75, 19.0), + (0.8, 25.1), + ] - for control, v_ego in test_cases: - state.PLANPLUS_CONTROL = control - recorded_vel.clear() - recorded_curv_plans.clear() - ModelState.get_action_from_model(state, model_output, prev_action, 0.0, 0.0, v_ego) # type: ignore[arg-type] + for control, v_ego in test_cases: + state.PLANPLUS_CONTROL = control + recorded_vel.clear() + recorded_curv_plans.clear() + ModelState.get_action_from_model(state, model_output, prev_action, 0.0, 0.0, v_ego) # type: ignore[arg-type] - expected_accel_plan_vel = plan[0, :, Plan.VELOCITY][:, 0] + planplus[0, :, Plan.VELOCITY][:, 0] - np.testing.assert_allclose(recorded_vel[0], expected_accel_plan_vel, rtol=1e-5, atol=1e-6) + expected_accel_plan_vel = plan[0, :, Plan.VELOCITY][:, 0] + planplus[0, :, Plan.VELOCITY][:, 0] + np.testing.assert_allclose(recorded_vel[0], expected_accel_plan_vel, rtol=1e-5, atol=1e-6) - # For the below, yes, I know this isn't the same slicing as fillmodlmsg. This is to show that the values are only scaled on curv - expected_curv_plan_vel = plan[0, :, Plan.VELOCITY][:, 0] + control * planplus[0, :, Plan.VELOCITY][:, 0] - np.testing.assert_allclose(recorded_curv_plans[0][:, Plan.VELOCITY][:, 0], expected_curv_plan_vel, rtol=1e-5, atol=1e-6) + # For the below, yes, I know this isn't the same slicing as fillmodlmsg. This is to show that the values are only scaled on curv + expected_curv_plan_vel = plan[0, :, Plan.VELOCITY][:, 0] + control * planplus[0, :, Plan.VELOCITY][:, 0] + np.testing.assert_allclose(recorded_curv_plans[0][:, Plan.VELOCITY][:, 0], expected_curv_plan_vel, rtol=1e-5, atol=1e-6) diff --git a/openpilot/sunnypilot/models/manager.py b/openpilot/sunnypilot/models/manager.py index d742e968a9..7ba805ff95 100644 --- a/openpilot/sunnypilot/models/manager.py +++ b/openpilot/sunnypilot/models/manager.py @@ -9,7 +9,7 @@ import asyncio import os import time -import aiohttp +import requests from openpilot.common.params import Params from openpilot.common.realtime import Ratekeeper from openpilot.common.swaglog import cloudlog @@ -19,6 +19,9 @@ from openpilot.cereal import messaging, custom from openpilot.sunnypilot.models.fetcher import ModelFetcher from openpilot.sunnypilot.models.helpers import get_active_bundle, validate_active_bundle, verify_file +# (connect, read) seconds. read is per-request inactivity, not a total cap +DOWNLOAD_TIMEOUT = (30, 30) + class ModelManagerSP: """Manages model downloads and status reporting""" @@ -63,30 +66,29 @@ class ModelManagerSP: """Downloads a file with progress tracking""" self._download_start_times[model.fileName] = time.monotonic() - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - response.raise_for_status() - total_size = int(response.headers.get("content-length", 0)) - bytes_downloaded = 0 + with requests.get(url, stream=True, timeout=DOWNLOAD_TIMEOUT) as response: # noqa: ASYNC210 + response.raise_for_status() + total_size = int(response.headers.get("content-length", 0)) + bytes_downloaded = 0 - with open(path, 'wb') as f: # noqa: ASYNC230 - async for chunk in response.content.iter_chunked(self._chunk_size): # type: bytes - f.write(chunk) - bytes_downloaded += len(chunk) + with open(path, 'wb') as f: # noqa: ASYNC230 + for chunk in response.iter_content(chunk_size=self._chunk_size): # type: bytes + f.write(chunk) + bytes_downloaded += len(chunk) - if self.params.get("ModelManager_DownloadIndex") is None: - raise Exception("Download cancelled") + if self.params.get("ModelManager_DownloadIndex") is None: + raise Exception("Download cancelled") - if total_size > 0: - progress = (bytes_downloaded / total_size) * 100 - model.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading - model.downloadProgress.progress = progress - model.downloadProgress.eta = self._calculate_eta(model.fileName, progress) - self._sync_artifact_progress(model) - self._report_status() + if total_size > 0: + progress = (bytes_downloaded / total_size) * 100 + model.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading + model.downloadProgress.progress = progress + model.downloadProgress.eta = self._calculate_eta(model.fileName, progress) + self._sync_artifact_progress(model) + self._report_status() - # Clean up start time after download completes - del self._download_start_times[model.fileName] + # Clean up start time after download completes + del self._download_start_times[model.fileName] async def _download_chunked(self, base_url: str, base_path: str, artifact) -> None: from openpilot.common.file_chunker import get_chunk_name, get_manifest_path @@ -98,16 +100,18 @@ class ModelManagerSP: manifest_path = get_manifest_path(base_path) self._download_start_times[artifact.fileName] = time.monotonic() - for i, _ in enumerate(artifact.chunks): - chunk_url = get_chunk_name(base_url, i, num_chunks) - chunk_path = get_chunk_name(base_path, i, num_chunks) - chunk_downloaded = 0 - async with aiohttp.ClientSession() as session: - async with session.get(chunk_url) as response: + # Shared connection saves a TCP+TLS handshake per chunk. + # Keep sequential: the link saturates on one stream and Session is not thread-safe. + with requests.Session() as session: + for i, _ in enumerate(artifact.chunks): + chunk_url = get_chunk_name(base_url, i, num_chunks) + chunk_path = get_chunk_name(base_path, i, num_chunks) + chunk_downloaded = 0 + with session.get(chunk_url, stream=True, timeout=DOWNLOAD_TIMEOUT) as response: response.raise_for_status() chunk_size = int(response.headers.get("content-length", 0)) with open(chunk_path, 'wb') as f: # noqa: ASYNC230 - async for data in response.content.iter_chunked(self._chunk_size): + for data in response.iter_content(chunk_size=self._chunk_size): f.write(data) chunk_downloaded += len(data) if self.params.get("ModelManager_DownloadIndex") is None: diff --git a/openpilot/sunnypilot/models/tests/test_default_model.py b/openpilot/sunnypilot/models/tests/test_default_model.py index ab51027442..450237e0e9 100644 --- a/openpilot/sunnypilot/models/tests/test_default_model.py +++ b/openpilot/sunnypilot/models/tests/test_default_model.py @@ -8,9 +8,10 @@ See the LICENSE.md file in the root directory for more details. from openpilot.sunnypilot import get_file_hash from openpilot.sunnypilot.models.default_model import MODEL_HASH_PATH, SUPERCOMBO_ONNX_PATH import hashlib +from openpilot.common.test import OpenpilotTestCase -class TestDefaultModel: +class TestDefaultModel(OpenpilotTestCase): def test_compare_onnx_hashes(self): supercombo_hash = get_file_hash(SUPERCOMBO_ONNX_PATH) diff --git a/openpilot/sunnypilot/models/tests/test_manager_download.py b/openpilot/sunnypilot/models/tests/test_manager_download.py new file mode 100644 index 0000000000..67fb9023af --- /dev/null +++ b/openpilot/sunnypilot/models/tests/test_manager_download.py @@ -0,0 +1,300 @@ +""" +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 asyncio +import hashlib +import http.server +import os +import tempfile +import threading +import unittest +from typing import Any +from unittest import mock + +import requests +from urllib3.connectionpool import HTTPConnectionPool + +from openpilot.cereal import custom +from openpilot.common.test import OpenpilotTestCase +from openpilot.common.file_chunker import get_chunk_name, get_manifest_path +from openpilot.selfdrive.test.helpers import http_server_context +from openpilot.sunnypilot.models import manager as manager_module +from openpilot.sunnypilot.models.manager import ModelManagerSP + +CHUNK_BODIES = [b'A' * 5000, b'B' * 5000, b'C' * 3000] +WHOLE_BODY = b'Z' * 9000 + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +class DownloadHandler(http.server.BaseHTTPRequestHandler): + """Serves the fixture bodies. Class attributes are reset per test.""" + request_paths: list[str] = [] + fail_paths: dict[str, int] = {} + stall_paths: set[str] = set() + stall_event: threading.Event | None = None + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + pass + + def _body_for(self, path): + if path.endswith('.whole'): + return WHOLE_BODY + for i in range(len(CHUNK_BODIES)): + if path.endswith(get_chunk_name('', i, len(CHUNK_BODIES))): + return CHUNK_BODIES[i] + return None + + def do_GET(self): + type(self).request_paths.append(self.path) + + status = type(self).fail_paths.get(self.path) + if status: + self.send_response(status) + self.end_headers() + return + + body = self._body_for(self.path) + if body is None: + self.send_response(404) + self.end_headers() + return + + self.send_response(200) + self.send_header('Content-Length', str(len(body))) + self.end_headers() + + if self.path in type(self).stall_paths: + # write a little, then wait so the test can cancel mid-transfer + self.wfile.write(body[:100]) + self.wfile.flush() + if type(self).stall_event is not None: + type(self).stall_event.wait(timeout=5) + self.wfile.write(body[100:]) + else: + self.wfile.write(body) + + +class ManagerDownloadTestBase(OpenpilotTestCase): + def setUp(self): + super().setUp() + DownloadHandler.request_paths = [] + DownloadHandler.fail_paths = {} + DownloadHandler.stall_paths = set() + DownloadHandler.stall_event = None + + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.dest = self._tmp.name + + self.reported: list[float] = [] + + self.manager = ModelManagerSP.__new__(ModelManagerSP) + self.manager.params = mock.MagicMock() + self.manager.params.get.return_value = b'0' # not cancelled + self.manager.pm = mock.MagicMock() + self.manager.pm.send.side_effect = self._record_progress + self.manager.selected_bundle = None + self.manager.active_bundle = None + self.manager.available_models = [] + self.manager._chunk_size = 1024 + self.manager._download_start_times = {} + + def _record_progress(self, *args) -> None: + """Runs on every real _report_status send.""" + artifact = getattr(self, 'artifact', None) + if artifact is not None: + self.reported.append(float(artifact.downloadProgress.progress)) + + def make_artifact(self, chunked: bool): + bundle = custom.ModelManagerSP.ModelBundle.new_message() + bundle.init('models', 1) + artifact = bundle.models[0].artifact + artifact.fileName = 'driving_test_tinygrad.pkl' + if chunked: + artifact.downloadUri.uri = self.base_url + '/driving_test_tinygrad.pkl' + artifact.downloadUri.sha256 = sha256(b''.join(CHUNK_BODIES)) + artifact.init('chunks', len(CHUNK_BODIES)) + for i, body in enumerate(CHUNK_BODIES): + artifact.chunks[i].sha256 = sha256(body) + else: + artifact.downloadUri.uri = self.base_url + '/driving_test_tinygrad.pkl.whole' + artifact.downloadUri.sha256 = sha256(WHOLE_BODY) + self._bundle = bundle + self.artifact = artifact + return artifact + + def chunk_paths(self, base_path): + return [get_chunk_name(base_path, i, len(CHUNK_BODIES)) for i in range(len(CHUNK_BODIES))] + + def assert_no_partials(self, base_path): + leftovers = [p for p in [base_path, get_manifest_path(base_path)] + self.chunk_paths(base_path) + if os.path.isfile(p)] + assert leftovers == [], f"partial files left behind: {leftovers}" + + +class TestManagerDownload(ManagerDownloadTestBase): + """Exercises the real _download_file / _download_chunked against a local server.""" + + def run_with_server(self, fn): + with http_server_context(handler=DownloadHandler) as (host, port): + self.base_url = f'http://{host}:{port}' + return fn() + + def test_download_file_writes_exact_bytes(self): + def body(): + artifact = self.make_artifact(chunked=False) + path = os.path.join(self.dest, artifact.fileName) + asyncio.run(self.manager._download_file(artifact.downloadUri.uri, path, artifact)) + with open(path, 'rb') as f: + written = f.read() + assert written == WHOLE_BODY + assert sha256(written) == artifact.downloadUri.sha256 + assert artifact.fileName not in self.manager._download_start_times + self.run_with_server(body) + + def test_download_chunked_writes_all_chunks_and_manifest(self): + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + + for i, expected in enumerate(CHUNK_BODIES): + with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'rb') as f: + assert f.read() == expected, f"chunk {i} body mismatch" + + with open(get_manifest_path(base_path)) as f: + assert f.read() == str(len(CHUNK_BODIES)) + + assert not os.path.isfile(base_path), "base file should be removed after chunking" + assert artifact.fileName not in self.manager._download_start_times + self.run_with_server(body) + + def test_progress_is_monotonic_and_bounded(self): + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + + assert self.reported, "expected progress reports" + for a, b in zip(self.reported, self.reported[1:], strict=False): + assert b >= a, f"progress went backwards: {a} -> {b}" + assert max(self.reported) <= 99.0, f"chunked progress must stay <=99 until verify, got {max(self.reported)}" + self.run_with_server(body) + + def test_session_is_reused_across_chunks(self): + """One connection pool shared across every chunk.""" + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + + pools = [] + original = HTTPConnectionPool.urlopen + + def tracked(pool_self, *args, **kwargs): + pools.append(id(pool_self)) + return original(pool_self, *args, **kwargs) + + with mock.patch.object(HTTPConnectionPool, 'urlopen', tracked): + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + + assert len(pools) == len(CHUNK_BODIES), f"expected one request per chunk, got {len(pools)}" + assert len(set(pools)) == 1, f"connection pool not reused across chunks: {len(set(pools))} pools" + self.run_with_server(body) + + def test_http_error_propagates(self): + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + failing = '/' + os.path.basename(get_chunk_name(artifact.downloadUri.uri, 1, len(CHUNK_BODIES))) + DownloadHandler.fail_paths = {failing: 404} + + with self.assertRaises(requests.exceptions.HTTPError): + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + + # chunk 1 failed, so its file and the manifest must not exist + assert not os.path.isfile(get_chunk_name(base_path, 1, len(CHUNK_BODIES))) + assert not os.path.isfile(get_manifest_path(base_path)) + self.run_with_server(body) + + def test_cancellation_mid_transfer(self): + """Cancellation is checked inside the byte loop; it must still fire after the port.""" + def body(): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + self.manager.params.get.return_value = None # cancelled + + with self.assertRaises(Exception) as ctx: + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + assert 'cancelled' in str(ctx.exception).lower() + assert not os.path.isfile(get_manifest_path(base_path)) + self.run_with_server(body) + + def test_repeat_downloads_are_stable(self): + """Back-to-back runs must produce identical bytes and leak no start-time state.""" + def body(): + for _ in range(2): + artifact = self.make_artifact(chunked=True) + base_path = os.path.join(self.dest, artifact.fileName) + asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact)) + for i, expected in enumerate(CHUNK_BODIES): + with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'rb') as f: + assert f.read() == expected + assert self.manager._download_start_times == {} + self.run_with_server(body) + + +class TestManagerImports(OpenpilotTestCase): + """Catches undeclared dependencies. aiohttp lived only in the AGNOS venv; 19.6 dropped + it and models_manager died on device while CI stayed green.""" + + def test_manager_imports(self): + assert manager_module.ModelManagerSP is not None + + def test_no_undeclared_http_client(self): + with open(manager_module.__file__) as f: + src = f.read() + assert 'import aiohttp' not in src, "aiohttp is not available on AGNOS 19.6; use requests" + + def test_download_timeout_is_explicit(self): + connect, read = manager_module.DOWNLOAD_TIMEOUT + assert connect > 0 and read > 0, "requests defaults to no timeout; downloads would hang forever" + + +@unittest.skipUnless(os.environ.get('RUN_INTEGRATION_TESTS'), 'requires external network') +class TestLiveModelManifest(OpenpilotTestCase): + """Every artifact and chunk URL in the published manifest must resolve.""" + + def test_all_manifest_urls_available(self): + from openpilot.sunnypilot.models.fetcher import ModelFetcher + + manifest = requests.get(ModelFetcher.MODEL_URL, timeout=30).json() + session = requests.Session() + dead = [] + + for bundle in manifest.get('bundles', []): + for model in bundle.get('models', []): + artifact = model['artifact'] + url = artifact['download_uri']['url'] + chunks = artifact.get('chunks', []) + urls = ([url] if not chunks + else [get_chunk_name(url, i, len(chunks)) for i in range(len(chunks))]) + for u in urls: + try: + r = session.head(u, timeout=15, allow_redirects=True) + if r.status_code != 200: + dead.append(f"{bundle.get('short_name')}: HTTP {r.status_code} {u}") + except requests.RequestException as e: + dead.append(f"{bundle.get('short_name')}: {type(e).__name__} {u}") + + assert not dead, "unreachable model URLs:\n" + "\n".join(dead) + + +if __name__ == '__main__': + unittest.main() diff --git a/openpilot/sunnypilot/models/tests/test_tinygrad_ref.py b/openpilot/sunnypilot/models/tests/test_tinygrad_ref.py index 3e60ab5308..1712c60410 100644 --- a/openpilot/sunnypilot/models/tests/test_tinygrad_ref.py +++ b/openpilot/sunnypilot/models/tests/test_tinygrad_ref.py @@ -2,6 +2,7 @@ import requests from openpilot.sunnypilot.models.tinygrad_ref import get_tinygrad_ref from openpilot.sunnypilot.models.fetcher import ModelFetcher +from openpilot.common.test import OpenpilotTestCase def fetch_tinygrad_ref(): @@ -11,13 +12,14 @@ def fetch_tinygrad_ref(): return json_data.get("tinygrad_ref") -def test_tinygrad_ref(): - current_ref = get_tinygrad_ref() - remote_ref = fetch_tinygrad_ref() - assert remote_ref == current_ref, ( - f"""tinygrad_repo ref does not match remote tinygrad_ref of current compiled driving models json. - Current: {current_ref} - Remote: {remote_ref} - Please run build-all workflow to update models.""" - ) - print("tinygrad_repo ref matches current compiled driving models json ref.") +class TestTinygradRef(OpenpilotTestCase): + def test_tinygrad_ref(self): + current_ref = get_tinygrad_ref() + remote_ref = fetch_tinygrad_ref() + assert remote_ref == current_ref, ( + f"""tinygrad_repo ref does not match remote tinygrad_ref of current compiled driving models json. + Current: {current_ref} + Remote: {remote_ref} + Please run build-all workflow to update models.""" + ) + print("tinygrad_repo ref matches current compiled driving models json ref.") diff --git a/openpilot/sunnypilot/navd/helpers.py b/openpilot/sunnypilot/navd/helpers.py index c57706d32a..63e7c8c7be 100644 --- a/openpilot/sunnypilot/navd/helpers.py +++ b/openpilot/sunnypilot/navd/helpers.py @@ -106,7 +106,7 @@ def distance_along_geometry(geometry: list[Coordinate], pos: Coordinate) -> floa return total_distance_closest -def coordinate_from_param(param: str, params: Params = None) -> Coordinate | None: +def coordinate_from_param(param: str, params: Params | None = None) -> Coordinate | None: if params is None: params = Params() diff --git a/openpilot/sunnypilot/selfdrive/car/interfaces.py b/openpilot/sunnypilot/selfdrive/car/interfaces.py index ecabc53b57..4988e863b1 100644 --- a/openpilot/sunnypilot/selfdrive/car/interfaces.py +++ b/openpilot/sunnypilot/selfdrive/car/interfaces.py @@ -25,7 +25,7 @@ def log_fingerprint(CP: structs.CarParams) -> None: sentry.capture_fingerprint(CP.carFingerprint, CP.brand) -def _enforce_torque_lateral_control(CP: structs.CarParams, params: Params = None, enabled: bool = False) -> bool: +def _enforce_torque_lateral_control(CP: structs.CarParams, params: Params | None = None, enabled: bool = False) -> bool: if params is None: params = Params() @@ -36,7 +36,7 @@ def _enforce_torque_lateral_control(CP: structs.CarParams, params: Params = None def _initialize_neural_network_lateral_control(CP: structs.CarParams, CP_SP: structs.CarParamsSP, - params: Params = None, enabled: bool = False) -> bool: + params: Params | None = None, enabled: bool = False) -> bool: if params is None: params = Params() @@ -55,7 +55,7 @@ def _initialize_neural_network_lateral_control(CP: structs.CarParams, CP_SP: str return enabled -def _initialize_intelligent_cruise_button_management(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params: Params = None) -> None: +def _initialize_intelligent_cruise_button_management(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params: Params | None = None) -> None: if params is None: params = Params() @@ -69,7 +69,7 @@ def _initialize_torque_lateral_control(CI: CarInterfaceBase, CP: structs.CarPara CI.configure_torque_tune(CP.carFingerprint, CP.lateralTuning) -def _cleanup_unsupported_params(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params: Params = None) -> None: +def _cleanup_unsupported_params(CP: structs.CarParams, CP_SP: structs.CarParamsSP, params: Params | None = None) -> None: if params is None: params = Params() @@ -98,7 +98,7 @@ def _cleanup_unsupported_params(CP: structs.CarParams, CP_SP: structs.CarParamsS set_speed_limit_assist_availability(CP, CP_SP, params) -def setup_interfaces(CI: CarInterfaceBase, params: Params = None) -> None: +def setup_interfaces(CI: CarInterfaceBase, params: Params | None = None) -> None: enforce_torque = _enforce_torque_lateral_control(CI.CP, params) nnlc_enabled = _initialize_neural_network_lateral_control(CI.CP, CI.CP_SP, params) _initialize_intelligent_cruise_button_management(CI.CP, CI.CP_SP, params) diff --git a/openpilot/sunnypilot/selfdrive/car/tests/test_cruise_mode.py b/openpilot/sunnypilot/selfdrive/car/tests/test_cruise_mode.py index 3f3701b3ae..b5cd9f6f87 100644 --- a/openpilot/sunnypilot/selfdrive/car/tests/test_cruise_mode.py +++ b/openpilot/sunnypilot/selfdrive/car/tests/test_cruise_mode.py @@ -1,5 +1,6 @@ from opendbc.car.structs import car from openpilot.common.parameterized import parameterized_class +from openpilot.common.test import OpenpilotTestCase from openpilot.selfdrive.selfdrived.events import Events from openpilot.sunnypilot.selfdrive.car.cruise_helpers import CruiseHelper, DISTANCE_LONG_PRESS @@ -8,7 +9,7 @@ ButtonType = car.CarState.ButtonEvent.Type @parameterized_class(('openpilot_longitudinal',), [(True,)]) -class TestCruiseHelper: +class TestCruiseHelper(OpenpilotTestCase): def setup_method(self): self.CP = car.CarParams(openpilotLongitudinalControl=self.openpilot_longitudinal) self.cruise_helper = CruiseHelper(self.CP) diff --git a/openpilot/sunnypilot/selfdrive/car/tests/test_custom_cruise.py b/openpilot/sunnypilot/selfdrive/car/tests/test_custom_cruise.py index ce5d7dc124..56491e3518 100644 --- a/openpilot/sunnypilot/selfdrive/car/tests/test_custom_cruise.py +++ b/openpilot/sunnypilot/selfdrive/car/tests/test_custom_cruise.py @@ -1,8 +1,6 @@ -import pytest - from opendbc.car.structs import car from openpilot.common.constants import CV -from openpilot.common.parameterized import parameterized_class +from openpilot.common.parameterized import parameterized, parameterized_class from openpilot.common.params import Params from openpilot.selfdrive.car.cruise import V_CRUISE_INITIAL from openpilot.selfdrive.car.tests.test_cruise_speed import TestVCruiseHelper @@ -15,7 +13,7 @@ ButtonType = car.CarState.ButtonEvent.Type @parameterized_class(('pcm_cruise', 'pcm_cruise_speed'), [(False, True)]) class TestCustomAccIncrements(TestVCruiseHelper): def setup_method(self): - TestVCruiseHelper.setup_method(self) + TestVCruiseHelper.openpilot_setup_method(self) self.params = Params() self.reset_custom_params() @@ -67,7 +65,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): self.press_button_short(ButtonType.accelCruise) assert self.v_cruise_helper.v_cruise_kph == initial_speed + 1 - @pytest.mark.parametrize("increment", (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)) + @parameterized.expand((1, 2, 3, 4, 5, 6, 7, 8, 9, 10)) def test_custom_short_press_increments(self, increment): """Test custom short press increments (1-10)""" self.set_custom_increments(enabled=True, short_inc=increment, long_inc=5) @@ -84,7 +82,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): assert self.v_cruise_helper.v_cruise_kph == expected_speed - @pytest.mark.parametrize("increment", (1, 5, 10)) + @parameterized.expand((1, 5, 10)) def test_custom_long_press_increments(self, increment): """Test custom long press increments (1, 5, 10)""" self.set_custom_increments(enabled=True, short_inc=1, long_inc=increment) @@ -101,7 +99,7 @@ class TestCustomAccIncrements(TestVCruiseHelper): assert self.v_cruise_helper.v_cruise_kph == expected_speed - @pytest.mark.parametrize("button_type", [ButtonType.accelCruise, ButtonType.decelCruise]) + @parameterized.expand([ButtonType.accelCruise, ButtonType.decelCruise]) def test_accel_decel_symmetry(self, button_type): """Test that acceleration and deceleration work symmetrically""" self.set_custom_increments(enabled=True, short_inc=3, long_inc=5) diff --git a/openpilot/sunnypilot/selfdrive/controls/controlsd_ext.py b/openpilot/sunnypilot/selfdrive/controls/controlsd_ext.py index 33cc9e3ad8..344ae52a93 100644 --- a/openpilot/sunnypilot/selfdrive/controls/controlsd_ext.py +++ b/openpilot/sunnypilot/selfdrive/controls/controlsd_ext.py @@ -52,7 +52,7 @@ class ControlsExt(ModelStateBase): self.blinker_pause_lateral.get_params() if self.CP.lateralTuning.which() == 'torque': - self.lat_delay = get_lat_delay(self.params, sm["liveDelay"].lateralDelay) + self.lat_delay = get_lat_delay(self.params, sm["lateralDelay"].lateralDelay) self._param_update_time = time.monotonic() diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/pytest_dynamic_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/pytest_dynamic_controller.py deleted file mode 100644 index 407ed0af3a..0000000000 --- a/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/pytest_dynamic_controller.py +++ /dev/null @@ -1,94 +0,0 @@ -import pytest - -from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController - -class MockLeadOne: - def __init__(self, status=0.0): - self.status = status - -class MockRadarState: - def __init__(self, status=0.0): - self.leadOne = MockLeadOne(status=status) - -class MockCarState: - def __init__(self, vEgo=0.0, vCruise=0.0, standstill=False): - self.vEgo = vEgo - self.vCruise = vCruise - self.standstill = standstill - -class MockModelData: - def __init__(self, valid=True): - size = 33 if valid else 10 # incomplete if invalid - self.position = type("Pos", (), {"x": [0.0] * size})() - self.orientation = type("Ori", (), {"x": [0.0] * size})() - -class MockSelfDriveState: - def __init__(self, experimentalMode=False): - self.experimentalMode = experimentalMode - -class MockParams: - def get_bool(self, name): - return True - -@pytest.fixture -def default_sm(): - sm = { - 'carState': MockCarState(vEgo=10.0, vCruise=20.0), - 'radarState': MockRadarState(status=1.0), - 'modelV2': MockModelData(valid=True), - 'selfdriveState': MockSelfDriveState(experimentalMode=True), - } - return sm - -@pytest.fixture -def mock_cp(): - class CP: - radarUnavailable = False - return CP() - -@pytest.fixture -def mock_mpc(): - class MPC: - crash_cnt = 0 - return MPC() - -# Fake Kalman Filter that always returns a given value -class FakeKalman: - def __init__(self, value=1.0): - self.value = value - def add_data(self, v): pass - def get_value(self): return self.value - def get_confidence(self): return 1.0 - def reset_data(self): pass - -def test_initial_mode_is_acc(mock_cp, mock_mpc): - controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) - assert controller.mode() == "acc" - -def test_standstill_triggers_blended(mock_cp, mock_mpc, default_sm): - controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) - default_sm['carState'].standstill = True - for _ in range(10): - controller.update(default_sm) - assert controller.mode() == "blended" - -def test_emergency_blended_on_fcw(mock_cp, mock_mpc, default_sm): - controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) - mock_mpc.crash_cnt = 1 # simulate FCW - for _ in range(2): - controller.update(default_sm) - assert controller.mode() == "blended" - -def test_radarless_slowdown_triggers_blended(mock_cp, mock_mpc, default_sm): - mock_cp.radarUnavailable = True - controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) - - # Force conditions to simulate slowdown - controller._slow_down_filter = FakeKalman(value=1.0) # ty: ignore[invalid-assignment] - controller._v_ego_kph = 35.0 - default_sm['modelV2'] = MockModelData(valid=False) # Incomplete trajectory - - for _ in range(3): - controller.update(default_sm) - - assert controller.mode() == "blended" diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dynamic_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dynamic_controller.py new file mode 100644 index 0000000000..4fec6eaa52 --- /dev/null +++ b/openpilot/sunnypilot/selfdrive/controls/lib/dec/tests/test_dynamic_controller.py @@ -0,0 +1,91 @@ +from openpilot.common.test import OpenpilotTestCase +from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController + +class MockLeadOne: + def __init__(self, present=0.0): + self.present = present + +class MockRadarState: + def __init__(self, present=0.0): + self.leadOne = MockLeadOne(present=present) + +class MockCarState: + def __init__(self, vEgo=0.0, vCruise=0.0, standstill=False): + self.vEgo = vEgo + self.vCruise = vCruise + self.standstill = standstill + +class MockModelData: + def __init__(self, valid=True): + size = 33 if valid else 10 # incomplete if invalid + self.position = type("Pos", (), {"x": [0.0] * size})() + self.orientation = type("Ori", (), {"x": [0.0] * size})() + +class MockSelfDriveState: + def __init__(self, experimentalMode=False): + self.experimentalMode = experimentalMode + +class MockParams: + def get_bool(self, name): + return True + +def default_sm(): + sm = { + 'carState': MockCarState(vEgo=10.0, vCruise=20.0), + 'radarState': MockRadarState(present=1.0), + 'modelV2': MockModelData(valid=True), + 'selfdriveState': MockSelfDriveState(experimentalMode=True), + } + return sm + +def mock_cp(): + class CP: + radarUnavailable = False + return CP() + +def mock_mpc(): + class MPC: + crash_cnt = 0 + return MPC() + +# Fake Kalman Filter that always returns a given value +class FakeKalman: + def __init__(self, value=1.0): + self.value = value + def add_data(self, v): pass + def get_value(self): return self.value + def get_confidence(self): return 1.0 + def reset_data(self): pass + +class TestDynamicExperimentalController(OpenpilotTestCase): + def test_initial_mode_is_acc(self, mock_cp, mock_mpc): + controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) + assert controller.mode() == "acc" + + def test_standstill_triggers_blended(self, mock_cp, mock_mpc, default_sm): + controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) + default_sm['carState'].standstill = True + for _ in range(10): + controller.update(default_sm) + assert controller.mode() == "blended" + + def test_emergency_blended_on_fcw(self, mock_cp, mock_mpc, default_sm): + controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) + mock_mpc.crash_cnt = 1 # simulate FCW + for _ in range(2): + controller.update(default_sm) + assert controller.mode() == "blended" + + def test_radarless_slowdown_triggers_blended(self, mock_cp, mock_mpc, default_sm): + mock_cp.radarUnavailable = True + controller = DynamicExperimentalController(mock_cp, mock_mpc, params=MockParams()) + + # Force conditions to simulate slowdown + controller._slow_down_filter = FakeKalman(value=1.0) # ty: ignore[invalid-assignment] + controller._v_ego_kph = 35.0 + default_sm['modelV2'] = MockModelData(valid=False) # Incomplete trajectory + + for _ in range(3): + controller.update(default_sm) + + assert controller.mode() == "blended" diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_v0.py b/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_v0.py index 6ddfaea231..b6ee7baabe 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_v0.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/latcontrol_torque_v0.py @@ -50,7 +50,7 @@ class LatControlTorque(LatControl): self.extension = LatControlTorqueExt(self, CP, CP_SP, CI) - def update_live_torque_params(self, latAccelFactor, latAccelOffset, friction): + def update_torque_parameters(self, latAccelFactor, latAccelOffset, friction): self.torque_params.latAccelFactor = latAccelFactor self.torque_params.latAccelOffset = latAccelOffset self.torque_params.friction = friction diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_fingerprint.py b/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_fingerprint.py index 07e7d2852d..8eaa90babd 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_fingerprint.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_fingerprint.py @@ -7,6 +7,7 @@ from opendbc.car.tesla.values import CAR as TESLA from openpilot.common.parameterized import parameterized from openpilot.common.params import Params from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces +from openpilot.common.test import OpenpilotTestCase FINGERPRINT_EXACT_MATCH = [HONDA.HONDA_CIVIC_BOSCH, TOYOTA.TOYOTA_RAV4_TSS2_2022, HYUNDAI.HYUNDAI_IONIQ_5] @@ -14,7 +15,7 @@ FINGERPRINT_FUZZY_MATCH = [HONDA.HONDA_CIVIC_BOSCH_DIESEL, HYUNDAI.GENESIS_G70_2 FINGERPRINT_ANGLE_NO_MATCH = [TOYOTA.TOYOTA_RAV4_TSS2_2023, NISSAN.NISSAN_LEAF, TESLA.TESLA_MODEL_3] -class TestNNLCFingerprintBase: +class TestNNLCFingerprintBase(OpenpilotTestCase): @staticmethod def _setup_platform(car_name): diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_load_model.py b/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_load_model.py index 2f4e0d6993..4e0da4eeed 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_load_model.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_load_model.py @@ -8,9 +8,10 @@ from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.car.helpers import convert_to_capnp from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces +from openpilot.common.test import OpenpilotTestCase -class TestNNTorqueModel: +class TestNNTorqueModel(OpenpilotTestCase): @parameterized.expand([HONDA.HONDA_CIVIC, TOYOTA.TOYOTA_RAV4, HYUNDAI.HYUNDAI_SANTA_CRUZ_1ST_GEN]) def test_load_model(self, car_name): diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_nnlc.py b/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_nnlc.py index 56fd2f9ce7..4782b0c4f3 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_nnlc.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/nnlc/tests/test_nnlc.py @@ -14,9 +14,10 @@ from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.car.helpers import convert_to_capnp from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque from openpilot.selfdrive.locationd.helpers import Pose -from openpilot.common.mock.generators import generate_livePose +from openpilot.common.mock.generators import generate_deviceMotion from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces from openpilot.selfdrive.modeld.constants import ModelConstants +from openpilot.common.test import OpenpilotTestCase def generate_modelV2(): @@ -42,7 +43,7 @@ def generate_modelV2(): return model -class TestNeuralNetworkLateralControl: +class TestNeuralNetworkLateralControl(OpenpilotTestCase): @parameterized.expand([HONDA.HONDA_CIVIC, TOYOTA.TOYOTA_RAV4, HYUNDAI.HYUNDAI_SANTA_CRUZ_1ST_GEN, GM.CHEVROLET_BOLT_EUV]) def test_saturation(self, car_name): @@ -66,10 +67,10 @@ class TestNeuralNetworkLateralControl: CS.vEgo = 30 CS.steeringPressed = False - params = log.LiveParametersData.new_message() + params = log.VehicleParameters.new_message() - lp = generate_livePose() - pose = Pose.from_live_pose(lp.livePose) + lp = generate_deviceMotion() + pose = Pose.from_device_motion(lp.deviceMotion) mdl = generate_modelV2() sm = {'modelV2': mdl.modelV2} @@ -81,7 +82,7 @@ class TestNeuralNetworkLateralControl: for _ in range(1000): controller.extension.update_model_v2(model_v2) controller.extension.update_lateral_lag(test_lag) - controller.update_live_torque_params(torque_params.latAccelFactor, torque_params.latAccelOffset, torque_params.friction) + controller.update_torque_parameters(torque_params.latAccelFactor, torque_params.latAccelOffset, torque_params.friction) controller.extension.update_limits() _, _, lac_log = controller.update(True, CS, VM, params, False, 0, pose, True, 0.2) assert lac_log.saturated @@ -89,7 +90,7 @@ class TestNeuralNetworkLateralControl: for _ in range(1000): controller.extension.update_model_v2(model_v2) controller.extension.update_lateral_lag(test_lag) - controller.update_live_torque_params(torque_params.latAccelFactor, torque_params.latAccelOffset, torque_params.friction) + controller.update_torque_parameters(torque_params.latAccelFactor, torque_params.latAccelOffset, torque_params.friction) controller.extension.update_limits() _, _, lac_log = controller.update(True, CS, VM, params, False, 0, pose, False, 0.2) assert not lac_log.saturated @@ -97,7 +98,7 @@ class TestNeuralNetworkLateralControl: for _ in range(1000): controller.extension.update_model_v2(model_v2) controller.extension.update_lateral_lag(test_lag) - controller.update_live_torque_params(torque_params.latAccelFactor, torque_params.latAccelOffset, torque_params.friction) + controller.update_torque_parameters(torque_params.latAccelFactor, torque_params.latAccelOffset, torque_params.friction) controller.extension.update_limits() _, _, lac_log = controller.update(True, CS, VM, params, False, 1, pose, False, 0.2) assert lac_log.saturated diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py index dc27447c0b..a9e5cdd992 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_map_controller.py @@ -8,18 +8,18 @@ import json import math import platform -import pytest from openpilot.cereal import custom from openpilot.common.params import Params from openpilot.common.realtime import DT_MDL from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.map_controller import R, SmartCruiseControlMap +from openpilot.common.test import OpenpilotTestCase MapState = VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.MapState -class TestSmartCruiseControlMap: +class TestSmartCruiseControlMap(OpenpilotTestCase): def setup_method(self): self.params = Params() @@ -70,6 +70,6 @@ class TestSmartCruiseControlMap: self.scc_m.update(True, False, 25.0, 0.0, 30.0) - assert self.scc_m.v_target == pytest.approx(24.0) + self.assertAlmostEqual(self.scc_m.v_target, 24.0, delta=24.0 * 1e-6) # TODO-SP: mock data from modelV2 to test other states diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_vision_controller.py b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_vision_controller.py index 98b3ffc8e7..610acd47df 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_vision_controller.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/smart_cruise_control/tests/test_vision_controller.py @@ -7,7 +7,7 @@ See the LICENSE.md file in the root directory for more details. from typing import Any import numpy as np -import pytest +from openpilot.common.parameterized import parameterized import openpilot.cereal.messaging as messaging from openpilot.cereal import custom, log @@ -17,6 +17,7 @@ from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET from openpilot.selfdrive.modeld.constants import ModelConstants from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control import MIN_V from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.vision_controller import SmartCruiseControlVision, _ENTERING_PRED_LAT_ACC_TH +from openpilot.common.test import OpenpilotTestCase VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.VisionState @@ -105,7 +106,7 @@ def generate_controlsState(): return controls_state -class TestSmartCruiseControlVision: +class TestSmartCruiseControlVision(OpenpilotTestCase): def setup_method(self): self.params = Params() @@ -145,19 +146,11 @@ class TestSmartCruiseControlVision: self.scc_v.update(self.sm, True, False, 0., 0., 0.) assert self.scc_v.state == VisionState.enabled - @pytest.mark.parametrize( - "case, should_enter", - [ + @parameterized.expand([ ("p97_just_above_threshold", True), ("single_spike_filtered", False), ("persistent_high_values", True), - ], - ids=[ - "p97>threshold_enters", - "single_spike_max_large_but_p97_below_threshold", - "high_values_persist_trigger_entering", - ], - ) + ], names=["case", "should_enter"]) def test_max_pred_lat_acc_uses_p97_and_threshold(self, case, should_enter): n = len(ModelConstants.T_IDXS) th = float(_ENTERING_PRED_LAT_ACC_TH) diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/helpers.py b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/helpers.py index 77d89251e2..848f99edc7 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/helpers.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/helpers.py @@ -23,7 +23,7 @@ def compare_cluster_target(v_cruise_cluster: float, target_set_speed: float, is_ return req_plus, req_minus -def set_speed_limit_assist_availability(CP: car.CarParams, CP_SP: custom.CarParamsSP, params: Params = None) -> bool: +def set_speed_limit_assist_availability(CP: car.CarParams, CP_SP: custom.CarParamsSP, params: Params | None = None) -> bool: if params is None: params = Params() diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_assist.py b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_assist.py index 1f23fb2299..f3d32a4d5b 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_assist.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_assist.py @@ -7,7 +7,7 @@ See the LICENSE.md file in the root directory for more details. import time -import pytest +from openpilot.common.parameterized import parameterized from openpilot.cereal import custom from opendbc.car.car_helpers import interfaces @@ -27,6 +27,7 @@ from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_assist PRE_ACTIVE_GUARD_PERIOD, ACTIVE_STATES, CRUISE_BUTTON_CONFIRM_HOLD from openpilot.sunnypilot.selfdrive.selfdrived.button_state_tracker import ButtonStateTracker from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP +from openpilot.common.test import OpenpilotTestCase ButtonEvent = car.CarState.ButtonEvent ButtonType = car.CarState.ButtonEvent.Type @@ -45,21 +46,10 @@ SPEED_LIMITS = { DEFAULT_CAR = TOYOTA.TOYOTA_RAV4_TSS2 -@pytest.fixture -def car_name(request): - return getattr(request, "param", DEFAULT_CAR) +class TestSpeedLimitAssist(OpenpilotTestCase): + car_name = DEFAULT_CAR - -@pytest.fixture(autouse=True) -def set_car_name_on_instance(request, car_name): - instance = getattr(request, "instance", None) - if instance: - instance.car_name = car_name - - -class TestSpeedLimitAssist: - - def setup_method(self, method): + def setup_method(self): self.params = Params() self.reset_custom_params() self.events_sp = EventsSP() @@ -69,7 +59,7 @@ class TestSpeedLimitAssist: self.pcm_long_max_set_speed = PCM_LONG_REQUIRED_MAX_SET_SPEED[self.sla.is_metric][1] # use 80 MPH for now self.speed_conv = CV.MS_TO_KPH if self.sla.is_metric else CV.MS_TO_MPH - def teardown_method(self, method): + def teardown_method(self): self.reset_state() def _setup_platform(self, car_name): @@ -112,13 +102,16 @@ class TestSpeedLimitAssist: assert not self.sla.is_active assert V_CRUISE_UNSET == self.sla.get_v_target_from_control() - @pytest.mark.parametrize("car_name", [RIVIAN.RIVIAN_R1, TESLA.TESLA_MODEL_Y], indirect=True) + @parameterized.expand([RIVIAN.RIVIAN_R1, TESLA.TESLA_MODEL_Y], names=["car_name"]) def test_disallowed_brands(self, car_name): """ Speed Limit Assist is disabled for the following brands and conditions: - All Tesla and is a release branch; - All Rivian """ + self.car_name = car_name + self.openpilot_setup_method() # rebuild the platform for this brand + assert not self.sla.enabled # stay disallowed even when the param may have changed from somewhere else @@ -285,9 +278,10 @@ class TestSpeedLimitAssist: assert self.sla.state in ACTIVE_STATES -class TestButtonStateTrackerSLAIntegration: +class TestButtonStateTrackerSLAIntegration(OpenpilotTestCase): + + def setup_method(self): - def setup_method(self, method): self.tracker = ButtonStateTracker() self.params = Params() self.params.put("IsReleaseSpBranch", True, block=True) diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_resolver.py b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_resolver.py index a18880c620..884749c72e 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_resolver.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_resolver.py @@ -7,26 +7,26 @@ See the LICENSE.md file in the root directory for more details. import random import time -import pytest -from pytest_mock import MockerFixture +from openpilot.common.parameterized import parameterized from openpilot.cereal import custom from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit import LIMIT_MAX_MAP_DATA_AGE from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_resolver import SpeedLimitResolver, ALL_SOURCES from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.common import Policy +from openpilot.common.test import OpenpilotTestCase SpeedLimitSource = custom.LongitudinalPlanSP.SpeedLimit.Source -def create_mock(properties, mocker: MockerFixture): +def create_mock(properties, mocker): mock = mocker.MagicMock() for _property, value in properties.items(): setattr(mock, _property, value) return mock -def setup_sm_mock(mocker: MockerFixture): +def setup_sm_mock(mocker): cruise_speed_limit = random.uniform(0, 120) live_map_data_limit = random.uniform(0, 120) @@ -58,21 +58,24 @@ def setup_sm_mock(mocker: MockerFixture): return sm_mock -parametrized_policies = pytest.mark.parametrize( - "policy, sm_key, function_key", [ +parametrized_policies = parameterized.expand( + [ (Policy.car_state_only, 'carStateSP', SpeedLimitSource.car), (Policy.car_state_priority, 'carStateSP', SpeedLimitSource.car), (Policy.map_data_only, 'liveMapDataSP', SpeedLimitSource.map), (Policy.map_data_priority, 'liveMapDataSP', SpeedLimitSource.map), ], - ids=lambda val: val.name if hasattr(val, 'name') else str(val) + names=["policy", "sm_key", "function_key"] ) -@pytest.mark.parametrize("resolver_class", [SpeedLimitResolver]) -class TestSpeedLimitResolverValidation: +def resolver_class(): + return SpeedLimitResolver - @pytest.mark.parametrize("policy", list(Policy), ids=lambda policy: policy.name) + +class TestSpeedLimitResolverValidation(OpenpilotTestCase): + + @parameterized.expand(list(Policy), names=["policy"]) def test_initial_state(self, resolver_class, policy): resolver = resolver_class() resolver.policy = policy @@ -82,7 +85,7 @@ class TestSpeedLimitResolverValidation: assert resolver.distance_solutions[source] == 0. @parametrized_policies - def test_resolver(self, resolver_class, policy, sm_key, function_key, mocker: MockerFixture): + def test_resolver(self, resolver_class, policy, sm_key, function_key, mocker): resolver = resolver_class() resolver.policy = policy sm_mock = setup_sm_mock(mocker) @@ -93,7 +96,7 @@ class TestSpeedLimitResolverValidation: assert resolver.speed_limit == source_speed_limit assert resolver.source == ALL_SOURCES[function_key] - def test_resolver_combined(self, resolver_class, mocker: MockerFixture): + def test_resolver_combined(self, resolver_class, mocker): resolver = resolver_class() resolver.policy = Policy.combined sm_mock = setup_sm_mock(mocker) @@ -108,7 +111,7 @@ class TestSpeedLimitResolverValidation: assert resolver.source == socket_to_source[minimum_key] @parametrized_policies - def test_parser(self, resolver_class, policy, sm_key, function_key, mocker: MockerFixture): + def test_parser(self, resolver_class, policy, sm_key, function_key, mocker): resolver = resolver_class() resolver.policy = policy sm_mock = setup_sm_mock(mocker) @@ -119,8 +122,8 @@ class TestSpeedLimitResolverValidation: assert resolver.limit_solutions[ALL_SOURCES[function_key]] == source_speed_limit assert resolver.distance_solutions[ALL_SOURCES[function_key]] == 0. - @pytest.mark.parametrize("policy", list(Policy), ids=lambda policy: policy.name) - def test_resolve_interaction_in_update(self, resolver_class, policy, mocker: MockerFixture): + @parameterized.expand(list(Policy), names=["policy"]) + def test_resolve_interaction_in_update(self, resolver_class, policy, mocker): v_ego = 50 resolver = resolver_class() resolver.policy = policy @@ -133,8 +136,8 @@ class TestSpeedLimitResolverValidation: assert resolver.distance is not None assert resolver.source is not None - @pytest.mark.parametrize("policy", list(Policy), ids=lambda policy: policy.name) - def test_old_map_data_ignored(self, resolver_class, policy, mocker: MockerFixture): + @parameterized.expand(list(Policy), names=["policy"]) + def test_old_map_data_ignored(self, resolver_class, policy, mocker): resolver = resolver_class() resolver.policy = policy sm_mock = mocker.MagicMock() diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_auto_lane_change.py b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_auto_lane_change.py index b4ec5041c8..0d95550412 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_auto_lane_change.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_auto_lane_change.py @@ -9,6 +9,7 @@ from openpilot.common.realtime import DT_MDL from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper, LaneChangeState, LaneChangeDirection from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLaneChangeController, AutoLaneChangeMode, \ AUTO_LANE_CHANGE_TIMER, ONE_SECOND_DELAY +from openpilot.common.test import OpenpilotTestCase AUTO_LANE_CHANGE_TIMER_COMBOS = [ (AutoLaneChangeMode.NUDGELESS, AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.NUDGELESS]), @@ -19,7 +20,7 @@ AUTO_LANE_CHANGE_TIMER_COMBOS = [ ] -class TestAutoLaneChangeController: +class TestAutoLaneChangeController(OpenpilotTestCase): def setup_method(self): self.DH = DesireHelper() self.alc = AutoLaneChangeController(self.DH) @@ -85,24 +86,24 @@ class TestAutoLaneChangeController: @parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS) def test_timers(self, timer_state, timer_delay): - self._reset_states() - self.alc.lane_change_bsm_delay = False # BSM delay off - self.alc.lane_change_set_timer = timer_state + self._reset_states() + self.alc.lane_change_bsm_delay = False # BSM delay off + self.alc.lane_change_set_timer = timer_state - # Update controller once + # Update controller once + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) + + # The timer should still be below the threshold after one update + assert not self.alc.auto_lane_change_allowed + + # Update enough times to exceed the threshold (seconds / DT_MDL) + num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold + for _ in range(num_updates): self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) - # The timer should still be below the threshold after one update - assert not self.alc.auto_lane_change_allowed - - # Update enough times to exceed the threshold (seconds / DT_MDL) - num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold - for _ in range(num_updates): - self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) - - # Now lane change should be allowed - assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay - assert self.alc.auto_lane_change_allowed + # Now lane change should be allowed + assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay + assert self.alc.auto_lane_change_allowed @parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS) def test_brake_pressed_disables_auto_lane_change(self, timer_state, timer_delay): @@ -169,32 +170,32 @@ class TestAutoLaneChangeController: @parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS) def test_disallow_continuous_auto_lane_change(self, timer_state, timer_delay): - self._reset_states() - self.alc.lane_change_bsm_delay = False # BSM delay off - self.alc.lane_change_set_timer = timer_state - num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold + self._reset_states() + self.alc.lane_change_bsm_delay = False # BSM delay off + self.alc.lane_change_set_timer = timer_state + num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold - # Update enough times to exceed the threshold (seconds / DT_MDL) - for _ in range(num_updates): - self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) + # Update enough times to exceed the threshold (seconds / DT_MDL) + for _ in range(num_updates): + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) - # Now lane change should be allowed - assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay - assert self.alc.auto_lane_change_allowed + # Now lane change should be allowed + assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay + assert self.alc.auto_lane_change_allowed - # Simulate lane change is initiated - self.DH.lane_change_state = LaneChangeState.laneChangeStarting - self.alc.update_state() + # Simulate lane change is initiated + self.DH.lane_change_state = LaneChangeState.laneChangeStarting + self.alc.update_state() - # Simulate lane change is completed, and one_blinker stays on - self.DH.lane_change_state = LaneChangeState.preLaneChange - self.alc.update_state() + # Simulate lane change is completed, and one_blinker stays on + self.DH.lane_change_state = LaneChangeState.preLaneChange + self.alc.update_state() - # Update enough times to exceed the threshold (seconds / DT_MDL) - for _ in range(num_updates): - self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) + # Update enough times to exceed the threshold (seconds / DT_MDL) + for _ in range(num_updates): + self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False) - assert not self.alc.auto_lane_change_allowed + assert not self.alc.auto_lane_change_allowed def test_auto_lane_change_mode_off_disallows_lane_change(self): """Test that OFF mode never allows auto lane change.""" diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_blinker_pause_lateral.py b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_blinker_pause_lateral.py index 7a72cfa1f2..4f0e6c03df 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_blinker_pause_lateral.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_blinker_pause_lateral.py @@ -8,9 +8,10 @@ from opendbc.car.structs import car from openpilot.common.constants import CV from openpilot.sunnypilot.selfdrive.controls.lib.blinker_pause_lateral import BlinkerPauseLateral +from openpilot.common.test import OpenpilotTestCase -class TestBlinkerPauseLateral: +class TestBlinkerPauseLateral(OpenpilotTestCase): def setup_method(self): self.blinker_pause_lateral = BlinkerPauseLateral() diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_lane_turn_desire.py b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_lane_turn_desire.py index 434b12110b..52c6cecbfc 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_lane_turn_desire.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_lane_turn_desire.py @@ -1,6 +1,7 @@ -import pytest from openpilot.cereal import log, custom from openpilot.common.params import Params +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper, LaneChangeState, LaneChangeDirection from openpilot.sunnypilot.selfdrive.controls.lib.lane_turn_desire import LaneTurnController, LANE_CHANGE_SPEED_MIN @@ -10,65 +11,63 @@ from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLan TurnDirection = custom.ModelDataV2SP.TurnDirection -@pytest.mark.parametrize("left_blinker,right_blinker,v_ego,blindspot_left,blindspot_right,expected", [ - (True, False, 5, False, False, TurnDirection.turnLeft), - (False, True, 6, False, False, TurnDirection.turnRight), - (True, False, 9, False, False, TurnDirection.none), - (True, False, 7, True, False, TurnDirection.none), - (False, True, 6, False, True, TurnDirection.none), - (False, False, 5, False, False, TurnDirection.none), - (True, True, 5, False, False, TurnDirection.none), -]) -def test_lane_turn_desire_conditions(left_blinker, right_blinker, v_ego, blindspot_left, blindspot_right, expected): - dh = DesireHelper() - controller = LaneTurnController(dh) - controller.enabled = True - controller.lane_turn_value = LANE_CHANGE_SPEED_MIN - controller.turn_direction = TurnDirection.none - controller.update_lane_turn(blindspot_left, blindspot_right, left_blinker, right_blinker, v_ego) - assert controller.get_turn_direction() == expected +class TestLaneTurnDesire(OpenpilotTestCase): + @parameterized.expand([ + (True, False, 5, False, False, TurnDirection.turnLeft), + (False, True, 6, False, False, TurnDirection.turnRight), + (True, False, 9, False, False, TurnDirection.none), + (True, False, 7, True, False, TurnDirection.none), + (False, True, 6, False, True, TurnDirection.none), + (False, False, 5, False, False, TurnDirection.none), + (True, True, 5, False, False, TurnDirection.none), + ]) + def test_lane_turn_desire_conditions(self, left_blinker, right_blinker, v_ego, blindspot_left, blindspot_right, expected): + dh = DesireHelper() + controller = LaneTurnController(dh) + controller.enabled = True + controller.lane_turn_value = LANE_CHANGE_SPEED_MIN + controller.turn_direction = TurnDirection.none + controller.update_lane_turn(blindspot_left, blindspot_right, left_blinker, right_blinker, v_ego) + assert controller.get_turn_direction() == expected + def test_lane_turn_desire_disabled(self): + dh = DesireHelper() + controller = LaneTurnController(dh) + controller.enabled = False + controller.lane_turn_value = LANE_CHANGE_SPEED_MIN + controller.turn_direction = TurnDirection.none + controller.update_lane_turn(False, False, True, False, 7) + assert controller.get_turn_direction() == TurnDirection.none -def test_lane_turn_desire_disabled(): - dh = DesireHelper() - controller = LaneTurnController(dh) - controller.enabled = False - controller.lane_turn_value = LANE_CHANGE_SPEED_MIN - controller.turn_direction = TurnDirection.none - controller.update_lane_turn(False, False, True, False, 7) - assert controller.get_turn_direction() == TurnDirection.none + def test_lane_turn_overrides_lane_change(self): + dh = DesireHelper() + controller = LaneTurnController(dh) + controller.enabled = True + controller.lane_turn_value = LANE_CHANGE_SPEED_MIN + controller.turn_direction = TurnDirection.none + # left turn desire + controller.update_lane_turn(False, False, True, False, 5) + assert controller.get_turn_direction() == TurnDirection.turnLeft + # right turn desire + controller.update_lane_turn(False, False, False, True, 6) + assert controller.get_turn_direction() == TurnDirection.turnRight + # no turn + controller.update_lane_turn(False, False, False, False, 7) + assert controller.get_turn_direction() == TurnDirection.none - -def test_lane_turn_overrides_lane_change(): - dh = DesireHelper() - controller = LaneTurnController(dh) - controller.enabled = True - controller.lane_turn_value = LANE_CHANGE_SPEED_MIN - controller.turn_direction = TurnDirection.none - # left turn desire - controller.update_lane_turn(False, False, True, False, 5) - assert controller.get_turn_direction() == TurnDirection.turnLeft - # right turn desire - controller.update_lane_turn(False, False, False, True, 6) - assert controller.get_turn_direction() == TurnDirection.turnRight - # no turn - controller.update_lane_turn(False, False, False, False, 7) - assert controller.get_turn_direction() == TurnDirection.none - - -@pytest.mark.parametrize("v_ego,expected", [ - (8.93, TurnDirection.turnLeft), # just below threshold - (8.96, TurnDirection.none), # above threshold - (8.95, TurnDirection.none), # just above threshold -]) -def test_lane_turn_desire_speed_boundary(v_ego, expected): - dh = DesireHelper() - controller = LaneTurnController(dh) - controller.enabled = True - controller.lane_turn_value = LANE_CHANGE_SPEED_MIN - controller.turn_direction = TurnDirection.none - controller.update_lane_turn(False, True, True, False, v_ego) - assert controller.get_turn_direction() == expected + @parameterized.expand([ + (8.93, TurnDirection.turnLeft), # just below threshold + (8.96, TurnDirection.none), # above threshold + (8.95, TurnDirection.none), # just above threshold + ]) + def test_lane_turn_desire_speed_boundary(self, v_ego, expected): + dh = DesireHelper() + controller = LaneTurnController(dh) + controller.enabled = True + controller.lane_turn_value = LANE_CHANGE_SPEED_MIN + controller.turn_direction = TurnDirection.none + controller.update_lane_turn(False, True, True, False, v_ego) + assert controller.get_turn_direction() == expected class DummyCarState: @@ -84,43 +83,42 @@ class DummyCarState: self.brakePressed = brakePressed -@pytest.fixture def set_lane_turn_params(): params = Params() params.put("LaneTurnDesire", True) params.put("LaneTurnValue", 20.0) -@pytest.mark.parametrize("carstate, lateral_active, lane_change_prob, expected_desire", [ - # Lane turn desire overrides lane change desire - (DummyCarState(vEgo=5, leftBlinker=True, rightBlinker=False, leftBlindspot=False, rightBlindspot=False), True, 1.0, - log.Desire.turnLeft), - (DummyCarState(vEgo=7, leftBlinker=False, rightBlinker=True, leftBlindspot=False, rightBlindspot=False), True, 1.0, - log.Desire.turnRight), - # Lane change desire only (no turn desires) - (DummyCarState(vEgo=9, leftBlinker=True, rightBlinker=False, leftBlindspot=False, rightBlindspot=False, - steeringPressed=True, steeringTorque=1), True, 1.0, log.Desire.laneChangeLeft), - (DummyCarState(vEgo=9, leftBlinker=False, rightBlinker=True, leftBlindspot=False, rightBlindspot=False, - steeringPressed=True, steeringTorque=-1), True, 1.0, log.Desire.laneChangeRight), - # No desire (inactive) - (DummyCarState(vEgo=9, leftBlinker=False, rightBlinker=False), False, 1.0, log.Desire.none), - (DummyCarState(vEgo=4, leftBlinker=False, rightBlinker=False), True, 1.0, log.Desire.none), # No blinkers? no desire! -]) -def test_desire_helper_integration(carstate, lateral_active, lane_change_prob, expected_desire, set_lane_turn_params): - dh = DesireHelper() - dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE - for _ in range(10): - dh.update(carstate, lateral_active, lane_change_prob, - left_edge_detected=False, right_edge_detected=False) - assert dh.desire == expected_desire +class TestDesireHelperIntegration(OpenpilotTestCase): + @parameterized.expand([ + # Lane turn desire overrides lane change desire + (DummyCarState(vEgo=5, leftBlinker=True, rightBlinker=False, leftBlindspot=False, rightBlindspot=False), True, 1.0, + log.Desire.turnLeft), + (DummyCarState(vEgo=7, leftBlinker=False, rightBlinker=True, leftBlindspot=False, rightBlindspot=False), True, 1.0, + log.Desire.turnRight), + # Lane change desire only (no turn desires) + (DummyCarState(vEgo=9, leftBlinker=True, rightBlinker=False, leftBlindspot=False, rightBlindspot=False, + steeringPressed=True, steeringTorque=1), True, 1.0, log.Desire.laneChangeLeft), + (DummyCarState(vEgo=9, leftBlinker=False, rightBlinker=True, leftBlindspot=False, rightBlindspot=False, + steeringPressed=True, steeringTorque=-1), True, 1.0, log.Desire.laneChangeRight), + # No desire (inactive) + (DummyCarState(vEgo=9, leftBlinker=False, rightBlinker=False), False, 1.0, log.Desire.none), + (DummyCarState(vEgo=4, leftBlinker=False, rightBlinker=False), True, 1.0, log.Desire.none), # No blinkers? no desire! + ], names=["carstate", "lateral_active", "lane_change_prob", "expected_desire"]) + def test_desire_helper_integration(self, carstate, lateral_active, lane_change_prob, expected_desire, set_lane_turn_params): + dh = DesireHelper() + dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE + for _ in range(10): + dh.update(carstate, lateral_active, lane_change_prob, + left_edge_detected=False, right_edge_detected=False) + assert dh.desire == expected_desire - -def test_edge_blocks_lane_change(set_lane_turn_params): - dh = DesireHelper() - dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE - carstate = DummyCarState(vEgo=15, leftBlinker=True, steeringPressed=True, steeringTorque=1) - for _ in range(10): - dh.update(carstate, True, 1.0, left_edge_detected=True, right_edge_detected=False) - assert dh.lane_change_state == LaneChangeState.preLaneChange - assert dh.lane_change_direction == LaneChangeDirection.left - assert dh.desire == log.Desire.none + def test_edge_blocks_lane_change(self, set_lane_turn_params): + dh = DesireHelper() + dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE + carstate = DummyCarState(vEgo=15, leftBlinker=True, steeringPressed=True, steeringTorque=1) + for _ in range(10): + dh.update(carstate, True, 1.0, left_edge_detected=True, right_edge_detected=False) + assert dh.lane_change_state == LaneChangeState.preLaneChange + assert dh.lane_change_direction == LaneChangeDirection.left + assert dh.desire == log.Desire.none diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_latcontrol_torque_ext.py b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_latcontrol_torque_ext.py index 2b47977514..b4f1137081 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_latcontrol_torque_ext.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_latcontrol_torque_ext.py @@ -16,9 +16,10 @@ from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.car.helpers import convert_to_capnp from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque from openpilot.selfdrive.locationd.helpers import Pose -from openpilot.common.mock.generators import generate_livePose +from openpilot.common.mock.generators import generate_deviceMotion from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces from openpilot.selfdrive.modeld.constants import ModelConstants +from openpilot.common.test import OpenpilotTestCase def _make_controller(enhanced=False, nnlc=False): @@ -62,16 +63,16 @@ def _run_update(controller, VM): CS = car.CarState.new_message() CS.vEgo = 30 CS.steeringPressed = False - lp = generate_livePose() - pose = Pose.from_live_pose(lp.livePose) - params = log.LiveParametersData.new_message() + lp = generate_deviceMotion() + pose = Pose.from_device_motion(lp.deviceMotion) + params = log.VehicleParameters.new_message() model_v2 = _make_model_v2().modelV2 controller.extension.update_model_v2(model_v2) controller.extension.update_lateral_lag(0.2) return controller.update(True, CS, VM, params, False, 0.5, pose, False, 0.2) -class TestLatControlTorqueExt: +class TestLatControlTorqueExt(OpenpilotTestCase): def test_init_enhanced_only(self): controller, VM, _ = _make_controller(enhanced=True, nnlc=False) assert controller.extension._jerk_aware_enabled diff --git a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_relc.py b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_relc.py index 71b153c8b7..8fea65e1a1 100644 --- a/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_relc.py +++ b/openpilot/sunnypilot/selfdrive/controls/lib/tests/test_relc.py @@ -4,7 +4,8 @@ Copyright (c) 2021-, rav4kumar, 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 pytest +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.common.realtime import DT_MDL from openpilot.sunnypilot.selfdrive.controls.lib.relc import ( @@ -29,7 +30,6 @@ CLOSE_EDGES = edges(-2.0, 1.5) FAR_EDGES = edges(-10.0, 10.0) -@pytest.fixture def relc(mocker): mocker.patch("openpilot.sunnypilot.selfdrive.controls.lib.relc.Params") controller = RoadEdgeLaneChangeController() @@ -42,128 +42,129 @@ def drive(controller, road_edge_stds, lane_line_probs, seconds, v_ego=V_HIGH, ro controller.update(road_edge_stds, lane_line_probs, v_ego, road_edges) -@pytest.mark.parametrize("road_edge_stds,lane_line_probs,attr", [ - ([0.0, 0.9], [0.0, 0.8, 0.8, 0.8], "left_edge_detected"), - ([0.9, 0.0], [0.8, 0.8, 0.8, 0.0], "right_edge_detected"), -]) -def test_edge_detection(relc, road_edge_stds, lane_line_probs, attr): - drive(relc, road_edge_stds, lane_line_probs, EDGE_REACTION_TIME + 0.1) - assert getattr(relc, attr) +class TestRELC(OpenpilotTestCase): + @parameterized.expand([ + ([0.0, 0.9], [0.0, 0.8, 0.8, 0.8], "left_edge_detected"), + ([0.9, 0.0], [0.8, 0.8, 0.8, 0.0], "right_edge_detected"), + ], names=["road_edge_stds", "lane_line_probs", "attr"]) + def test_edge_detection(self, relc, road_edge_stds, lane_line_probs, attr): + drive(relc, road_edge_stds, lane_line_probs, EDGE_REACTION_TIME + 0.1) + assert getattr(relc, attr) -def test_edge_detection_requires_time(relc): - drive(relc, [0.0, 0.9], [0.0, 0.8, 0.8, 0.8], EDGE_REACTION_TIME - 0.05) - assert not relc.left_edge_detected + def test_edge_detection_requires_time(self, relc): + drive(relc, [0.0, 0.9], [0.0, 0.8, 0.8, 0.8], EDGE_REACTION_TIME - 0.05) + assert not relc.left_edge_detected -def test_both_edges_detected(relc): - drive(relc, [0.0, 0.0], [0.0, 0.8, 0.8, 0.0], EDGE_REACTION_TIME + 0.1) - assert relc.left_edge_detected - assert relc.right_edge_detected + def test_both_edges_detected(self, relc): + drive(relc, [0.0, 0.0], [0.0, 0.8, 0.8, 0.0], EDGE_REACTION_TIME + 0.1) + assert relc.left_edge_detected + assert relc.right_edge_detected -def test_noise_doesnt_clear(relc): - edge = ([0.0, 0.9], [0.0, 0.8, 0.8, 0.8]) - clear = ([0.9, 0.9], [0.8, 0.8, 0.8, 0.8]) + def test_noise_doesnt_clear(self, relc): + edge = ([0.0, 0.9], [0.0, 0.8, 0.8, 0.8]) + clear = ([0.9, 0.9], [0.8, 0.8, 0.8, 0.8]) - drive(relc, *edge, EDGE_REACTION_TIME + 0.1) - assert relc.left_edge_detected + drive(relc, *edge, EDGE_REACTION_TIME + 0.1) + assert relc.left_edge_detected - relc.update(*clear, V_HIGH, CLOSE_EDGES) - relc.update(*edge, V_HIGH, CLOSE_EDGES) - assert relc.left_edge_detected + relc.update(*clear, V_HIGH, CLOSE_EDGES) + relc.update(*edge, V_HIGH, CLOSE_EDGES) + assert relc.left_edge_detected -def test_clears_after_window(relc): - edge = ([0.0, 0.9], [0.0, 0.8, 0.8, 0.8]) - clear = ([0.9, 0.9], [0.8, 0.8, 0.8, 0.8]) + def test_clears_after_window(self, relc): + edge = ([0.0, 0.9], [0.0, 0.8, 0.8, 0.8]) + clear = ([0.9, 0.9], [0.8, 0.8, 0.8, 0.8]) - drive(relc, *edge, EDGE_REACTION_TIME + 0.1) - assert relc.left_edge_detected + drive(relc, *edge, EDGE_REACTION_TIME + 0.1) + assert relc.left_edge_detected - drive(relc, *clear, EDGE_CLEAR_TIME + 0.05) - assert not relc.left_edge_detected - assert relc.left_edge_timer == 0.0 + drive(relc, *clear, EDGE_CLEAR_TIME + 0.05) + assert not relc.left_edge_detected + assert relc.left_edge_timer == 0.0 -def test_low_speed_skips(relc): - drive(relc, [0.0, 0.9], [0.0, 0.8, 0.8, 0.8], EDGE_REACTION_TIME + 0.1, v_ego=V_LOW) - assert not relc.left_edge_detected - assert relc.left_edge_timer == 0.0 + def test_low_speed_skips(self, relc): + drive(relc, [0.0, 0.9], [0.0, 0.8, 0.8, 0.8], EDGE_REACTION_TIME + 0.1, v_ego=V_LOW) + assert not relc.left_edge_detected + assert relc.left_edge_timer == 0.0 -def test_speed_drop_resets(relc): - drive(relc, [0.0, 0.9], [0.0, 0.8, 0.8, 0.8], EDGE_REACTION_TIME + 0.1) - assert relc.left_edge_detected + def test_speed_drop_resets(self, relc): + drive(relc, [0.0, 0.9], [0.0, 0.8, 0.8, 0.8], EDGE_REACTION_TIME + 0.1) + assert relc.left_edge_detected - relc.update([0.0, 0.9], [0.0, 0.8, 0.8, 0.8], V_LOW, CLOSE_EDGES) - assert not relc.left_edge_detected + relc.update([0.0, 0.9], [0.0, 0.8, 0.8, 0.8], V_LOW, CLOSE_EDGES) + assert not relc.left_edge_detected -def test_param_off_resets(relc): - drive(relc, [0.0, 0.9], [0.0, 0.8, 0.8, 0.8], EDGE_REACTION_TIME + 0.1) - assert relc.left_edge_detected + def test_param_off_resets(self, relc): + drive(relc, [0.0, 0.9], [0.0, 0.8, 0.8, 0.8], EDGE_REACTION_TIME + 0.1) + assert relc.left_edge_detected - relc.params.get_bool.return_value = False - relc.read_params() - relc.update([0.0, 0.9], [0.0, 0.8, 0.8, 0.8], V_HIGH, CLOSE_EDGES) - assert not relc.left_edge_detected - assert not relc.right_edge_detected + relc.params.get_bool.return_value = False + relc.read_params() + relc.update([0.0, 0.9], [0.0, 0.8, 0.8, 0.8], V_HIGH, CLOSE_EDGES) + assert not relc.left_edge_detected + assert not relc.right_edge_detected -def test_lane_line_prevents_detection(relc): - drive(relc, [0.0, 0.9], [0.8, 0.8, 0.8, 0.8], EDGE_REACTION_TIME + 0.1) - assert not relc.left_edge_detected + def test_lane_line_prevents_detection(self, relc): + drive(relc, [0.0, 0.9], [0.8, 0.8, 0.8, 0.8], EDGE_REACTION_TIME + 0.1) + assert not relc.left_edge_detected -def test_one_side_blocks_other_allows(relc): - drive(relc, [0.9, 0.0], [0.8, 0.8, 0.8, 0.0], EDGE_REACTION_TIME + 0.1) - assert relc.right_edge_detected - assert not relc.left_edge_detected + def test_one_side_blocks_other_allows(self, relc): + drive(relc, [0.9, 0.0], [0.8, 0.8, 0.8, 0.0], EDGE_REACTION_TIME + 0.1) + assert relc.right_edge_detected + assert not relc.left_edge_detected -def test_disabled_no_detection(relc): - relc.enabled = False - relc.params.get_bool.return_value = False - drive(relc, [0.0, 0.0], [0.0, 0.8, 0.8, 0.0], EDGE_REACTION_TIME + 0.1) - assert not relc.left_edge_detected - assert not relc.right_edge_detected + def test_disabled_no_detection(self, relc): + relc.enabled = False + relc.params.get_bool.return_value = False + drive(relc, [0.0, 0.0], [0.0, 0.8, 0.8, 0.0], EDGE_REACTION_TIME + 0.1) + assert not relc.left_edge_detected + assert not relc.right_edge_detected -def test_far_edge_no_block(relc): - drive(relc, [0.0, 0.9], [0.05, 0.5, 0.5, 0.08], EDGE_REACTION_TIME + 0.1, road_edges=FAR_EDGES) - assert not relc.left_edge_detected + def test_far_edge_no_block(self, relc): + drive(relc, [0.0, 0.9], [0.05, 0.5, 0.5, 0.08], EDGE_REACTION_TIME + 0.1, road_edges=FAR_EDGES) + assert not relc.left_edge_detected -def test_close_edge_blocks(relc): - drive(relc, [0.9, 0.0], [0.05, 0.8, 0.8, 0.05], EDGE_REACTION_TIME + 0.1, - road_edges=edges(-8.0, 1.5)) - assert relc.right_edge_detected - assert not relc.left_edge_detected + def test_close_edge_blocks(self, relc): + drive(relc, [0.9, 0.0], [0.05, 0.8, 0.8, 0.05], EDGE_REACTION_TIME + 0.1, + road_edges=edges(-8.0, 1.5)) + assert relc.right_edge_detected + assert not relc.left_edge_detected -def test_wide_road_no_lines_no_block(relc): - drive(relc, [0.0, 0.0], [0.05, 0.4, 0.4, 0.05], EDGE_REACTION_TIME + 0.1, - road_edges=edges(-8.0, 8.0)) - assert not relc.left_edge_detected - assert not relc.right_edge_detected + def test_wide_road_no_lines_no_block(self, relc): + drive(relc, [0.0, 0.0], [0.05, 0.4, 0.4, 0.05], EDGE_REACTION_TIME + 0.1, + road_edges=edges(-8.0, 8.0)) + assert not relc.left_edge_detected + assert not relc.right_edge_detected -def test_narrow_road_both_block(relc): - drive(relc, [0.0, 0.0], [0.02, 0.4, 0.4, 0.02], EDGE_REACTION_TIME + 0.1, - road_edges=edges(-2.5, 2.5)) - assert relc.left_edge_detected - assert relc.right_edge_detected + def test_narrow_road_both_block(self, relc): + drive(relc, [0.0, 0.0], [0.02, 0.4, 0.4, 0.02], EDGE_REACTION_TIME + 0.1, + road_edges=edges(-2.5, 2.5)) + assert relc.left_edge_detected + assert relc.right_edge_detected -def test_clearance_boundary(relc): - boundary = VEHICLE_EDGE_MARGIN + EDGE_CLEARANCE # 4.78m - drive(relc, [0.0, 0.9], [0.05, 0.5, 0.5, 0.08], EDGE_REACTION_TIME + 0.1, - road_edges=edges(-(boundary - 0.1), 10.0)) - assert relc.left_edge_detected + def test_clearance_boundary(self, relc): + boundary = VEHICLE_EDGE_MARGIN + EDGE_CLEARANCE # 4.78m + drive(relc, [0.0, 0.9], [0.05, 0.5, 0.5, 0.08], EDGE_REACTION_TIME + 0.1, + road_edges=edges(-(boundary - 0.1), 10.0)) + assert relc.left_edge_detected - relc.reset() + relc.reset() - drive(relc, [0.0, 0.9], [0.05, 0.5, 0.5, 0.08], EDGE_REACTION_TIME + 0.1, - road_edges=edges(-(boundary + 0.1), 10.0)) - assert not relc.left_edge_detected + drive(relc, [0.0, 0.9], [0.05, 0.5, 0.5, 0.08], EDGE_REACTION_TIME + 0.1, + road_edges=edges(-(boundary + 0.1), 10.0)) + assert not relc.left_edge_detected diff --git a/openpilot/sunnypilot/selfdrive/locationd/locationd.cc b/openpilot/sunnypilot/selfdrive/locationd/locationd.cc index b24daa961b..499088a340 100644 --- a/openpilot/sunnypilot/selfdrive/locationd/locationd.cc +++ b/openpilot/sunnypilot/selfdrive/locationd/locationd.cc @@ -492,7 +492,7 @@ void Localizer::handle_cam_odo(double current_time, const cereal::CameraOdometry this->camodo_yawrate_distribution = Vector2d(rot_device[2], rotate_std(this->device_from_calib, rot_calib_std)[2]); } -void Localizer::handle_live_calib(double current_time, const cereal::LiveCalibrationData::Reader& log) { +void Localizer::handle_live_calib(double current_time, const cereal::ExtrinsicsCalibration::Reader& log) { if (!this->is_timestamp_valid(current_time)) { this->observation_timings_invalid = true; return; @@ -501,15 +501,15 @@ void Localizer::handle_live_calib(double current_time, const cereal::LiveCalibra if (log.getRpyCalib().size() > 0) { auto live_calib = floatlist2vector(log.getRpyCalib()); if ((live_calib.minCoeff() < -CALIB_RPY_SANITY_CHECK) || (live_calib.maxCoeff() > CALIB_RPY_SANITY_CHECK)) { - this->observation_values_invalid["liveCalibration"] += 1.0; + this->observation_values_invalid["extrinsicsCalibration"] += 1.0; return; } this->calib = live_calib; this->device_from_calib = euler2rot(this->calib); this->calib_from_device = this->device_from_calib.transpose(); - this->calibrated = log.getCalStatus() == cereal::LiveCalibrationData::Status::CALIBRATED; - this->observation_values_invalid["liveCalibration"] *= DECAY; + this->calibrated = log.getCalStatus() == cereal::ExtrinsicsCalibration::Status::CALIBRATED; + this->observation_values_invalid["extrinsicsCalibration"] *= DECAY; } } @@ -604,8 +604,8 @@ void Localizer::handle_msg(const cereal::Event::Reader& log) { this->handle_car_state(t, log.getCarState()); } else if (log.isCameraOdometry()) { this->handle_cam_odo(t, log.getCameraOdometry()); - } else if (log.isLiveCalibration()) { - this->handle_live_calib(t, log.getLiveCalibration()); + } else if (log.isExtrinsicsCalibration()) { + this->handle_live_calib(t, log.getExtrinsicsCalibration()); } this->finite_check(); this->update_reset_tracker(); @@ -688,7 +688,7 @@ int Localizer::locationd_thread() { } this->configure_gnss_source(source); - const std::initializer_list service_list = {gps_location_socket, "cameraOdometry", "liveCalibration", + const std::initializer_list service_list = {gps_location_socket, "cameraOdometry", "extrinsicsCalibration", "carState", "accelerometer", "gyroscope"}; SubMaster sm(service_list, {}, nullptr, {gps_location_socket}); @@ -696,7 +696,7 @@ int Localizer::locationd_thread() { uint64_t cnt = 0; bool filterInitialized = false; - const std::vector critical_input_services = {"cameraOdometry", "liveCalibration", "accelerometer", "gyroscope"}; + const std::vector critical_input_services = {"cameraOdometry", "extrinsicsCalibration", "accelerometer", "gyroscope"}; for (std::string service : critical_input_services) { this->observation_values_invalid.insert({service, 0.0}); } diff --git a/openpilot/sunnypilot/selfdrive/locationd/locationd.h b/openpilot/sunnypilot/selfdrive/locationd/locationd.h index a6ce697f30..c0535e6686 100644 --- a/openpilot/sunnypilot/selfdrive/locationd/locationd.h +++ b/openpilot/sunnypilot/selfdrive/locationd/locationd.h @@ -60,7 +60,7 @@ public: void handle_gnss(double current_time, const cereal::GnssMeasurements::Reader& log); void handle_car_state(double current_time, const cereal::CarState::Reader& log); void handle_cam_odo(double current_time, const cereal::CameraOdometry::Reader& log); - void handle_live_calib(double current_time, const cereal::LiveCalibrationData::Reader& log); + void handle_live_calib(double current_time, const cereal::ExtrinsicsCalibration::Reader& log); void input_fake_gps_observations(double current_time); diff --git a/openpilot/sunnypilot/selfdrive/locationd/tests/test_locationd.py b/openpilot/sunnypilot/selfdrive/locationd/tests/test_locationd.py index f81d1349aa..ac6ab6aaea 100644 --- a/openpilot/sunnypilot/selfdrive/locationd/tests/test_locationd.py +++ b/openpilot/sunnypilot/selfdrive/locationd/tests/test_locationd.py @@ -1,5 +1,5 @@ -import pytest import platform +import unittest import json import random import time @@ -11,14 +11,12 @@ from openpilot.common.params import Params from openpilot.common.transformations.coordinates import ecef2geodetic from openpilot.system.manager.process_config import managed_processes +from openpilot.common.test import OpenpilotTestCase -if platform.system() == 'Darwin': - pytest.skip("Skipping locationd test on macOS due to unsupported msgq.", allow_module_level=True) - - -class TestLocationdProc: - LLD_MSGS = ['gpsLocationExternal', 'cameraOdometry', 'carState', 'liveCalibration', +@unittest.skipIf(platform.system() == 'Darwin', "msgq unsupported on macOS") +class TestLocationdProc(OpenpilotTestCase): + LLD_MSGS = ['gpsLocationExternal', 'cameraOdometry', 'carState', 'extrinsicsCalibration', 'accelerometer', 'gyroscope'] def setup_method(self): @@ -88,6 +86,6 @@ class TestLocationdProc: time.sleep(1) # wait for async params write lastGPS = json.loads(self.params.get('LastGPSPositionLLK')) - assert lastGPS['latitude'] == pytest.approx(self.lat, abs=0.001) - assert lastGPS['longitude'] == pytest.approx(self.lon, abs=0.001) - assert lastGPS['altitude'] == pytest.approx(self.alt, abs=0.001) + self.assertAlmostEqual(lastGPS['latitude'], self.lat, delta=0.001) + self.assertAlmostEqual(lastGPS['longitude'], self.lon, delta=0.001) + self.assertAlmostEqual(lastGPS['altitude'], self.alt, delta=0.001) diff --git a/openpilot/sunnypilot/selfdrive/selfdrived/tests/test_button_state_tracker.py b/openpilot/sunnypilot/selfdrive/selfdrived/tests/test_button_state_tracker.py index 798fab650b..5824f82531 100644 --- a/openpilot/sunnypilot/selfdrive/selfdrived/tests/test_button_state_tracker.py +++ b/openpilot/sunnypilot/selfdrive/selfdrived/tests/test_button_state_tracker.py @@ -6,12 +6,13 @@ See the LICENSE.md file in the root directory for more details. """ from opendbc.car.structs import car from openpilot.sunnypilot.selfdrive.selfdrived.button_state_tracker import ButtonStateTracker +from openpilot.common.test import OpenpilotTestCase ButtonEvent = car.CarState.ButtonEvent ButtonType = car.CarState.ButtonEvent.Type -class TestButtonStateTracker: +class TestButtonStateTracker(OpenpilotTestCase): def setup_method(self) -> None: self.tracker = ButtonStateTracker() diff --git a/openpilot/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py b/openpilot/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py index 7b81da5ad4..9540d8329d 100644 --- a/openpilot/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py +++ b/openpilot/sunnypilot/sunnylink/athena/tests/test_sunnylinkd.py @@ -5,9 +5,10 @@ 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. """ from openpilot.sunnypilot.sunnylink.athena import sunnylinkd +from openpilot.common.test import OpenpilotTestCase -class TestSunnylinkdMethods: +class TestSunnylinkdMethods(OpenpilotTestCase): def setup_method(self): self.saved_params = [] diff --git a/openpilot/sunnypilot/sunnylink/tests/test_capabilities.py b/openpilot/sunnypilot/sunnylink/tests/test_capabilities.py index 4af1462479..7e0272a7c9 100644 --- a/openpilot/sunnypilot/sunnylink/tests/test_capabilities.py +++ b/openpilot/sunnypilot/sunnylink/tests/test_capabilities.py @@ -12,7 +12,6 @@ the same commit so the bump shows up in code review. """ from __future__ import annotations -import pytest from openpilot.sunnypilot.sunnylink.capabilities import ( CAPABILITY_DEFAULTS, @@ -21,18 +20,18 @@ from openpilot.sunnypilot.sunnylink.capabilities import ( PROTOCOL_VERSION, generate_capabilities, ) +from openpilot.common.test import OpenpilotTestCase KNOWN_PROTOCOL_VERSIONS = (1,) LATEST_KNOWN = max(KNOWN_PROTOCOL_VERSIONS) -@pytest.fixture(scope="module") def caps(): return generate_capabilities() -class TestProtocolVersion: +class TestProtocolVersion(OpenpilotTestCase): def test_protocol_version_in_capability_fields(self): assert "protocol_version" in CAPABILITY_FIELDS @@ -63,7 +62,7 @@ class TestProtocolVersion: ) -class TestOpaquePerBrandFlags: +class TestOpaquePerBrandFlags(OpenpilotTestCase): def test_subaru_has_sng_field_present(self): assert "subaru_has_sng" in CAPABILITY_FIELDS @@ -77,7 +76,7 @@ class TestOpaquePerBrandFlags: assert caps["hyundai_alpha_long_available"] is False -class TestCapabilitiesShape: +class TestCapabilitiesShape(OpenpilotTestCase): def test_all_fields_present(self, caps): for field in CAPABILITY_FIELDS: assert field in caps, f"capabilities missing {field}" diff --git a/openpilot/sunnypilot/sunnylink/tests/test_compile_settings_ui.py b/openpilot/sunnypilot/sunnylink/tests/test_compile_settings_ui.py index 2fcd889ec3..797bf42558 100644 --- a/openpilot/sunnypilot/sunnylink/tests/test_compile_settings_ui.py +++ b/openpilot/sunnypilot/sunnylink/tests/test_compile_settings_ui.py @@ -19,7 +19,6 @@ import difflib import json import os -import pytest import yaml from openpilot.sunnypilot.sunnylink.tools.compile_settings_ui import ( @@ -29,20 +28,19 @@ from openpilot.sunnypilot.sunnylink.tools.compile_settings_ui import ( _resolve_refs, compile_schema, ) +from openpilot.common.test import OpenpilotTestCase -@pytest.fixture(scope="module") def compiled() -> dict: return compile_schema(DEFAULT_SRC) -@pytest.fixture(scope="module") def committed() -> dict: with open(DEFAULT_OUT) as f: return json.load(f) -class TestRoundtrip: +class TestRoundtrip(OpenpilotTestCase): def test_compiled_matches_committed(self, compiled, committed): """Compiled output must match the checked-in JSON.""" if compiled == committed: @@ -54,7 +52,7 @@ class TestRoundtrip: tofile="settings_ui.json (freshly compiled)", lineterm="", )) - pytest.fail(f"settings_ui.json schema mismatch — run compile_settings_ui.py\n\n{diff}") + self.fail(f"settings_ui.json schema mismatch — run compile_settings_ui.py\n\n{diff}") def test_committed_file_is_canonical(self): """Compiled output must byte-match the checked-in file (including trailing newline). @@ -72,10 +70,10 @@ class TestRoundtrip: tofile="settings_ui.json (freshly compiled)", lineterm="", )) - pytest.fail(f"settings_ui.json out of sync — run compile_settings_ui.py\n\n{diff}") + self.fail(f"settings_ui.json out of sync — run compile_settings_ui.py\n\n{diff}") -class TestRefResolution: +class TestRefResolution(OpenpilotTestCase): def test_list_context_splices(self): macros = {"a": [{"type": "offroad_only"}], "b": [{"type": "not_engaged"}]} out = _resolve_refs([{"$ref": "#/macros/a"}, {"$ref": "#/macros/b"}], macros) @@ -95,12 +93,12 @@ class TestRefResolution: assert out == [{"type": "offroad_only"}] def test_unknown_macro_raises(self): - with pytest.raises(CompileError, match="unknown macro"): + with self.assertRaisesRegex(CompileError, "unknown macro"): _resolve_refs([{"$ref": "#/macros/missing"}], {}) def test_cycle_raises(self): macros = {"a": [{"$ref": "#/macros/b"}], "b": [{"$ref": "#/macros/a"}]} - with pytest.raises(CompileError, match="cycle"): + with self.assertRaisesRegex(CompileError, "cycle"): _resolve_refs([{"$ref": "#/macros/a"}], macros) def test_depth_limit(self): @@ -111,20 +109,20 @@ class TestRefResolution: "l3": [{"$ref": "#/macros/l4"}], "l4": [{"type": "offroad_only"}], } - with pytest.raises(CompileError, match="depth"): + with self.assertRaisesRegex(CompileError, "depth"): _resolve_refs([{"$ref": "#/macros/l1"}], macros) def test_invalid_ref_scheme(self): - with pytest.raises(CompileError, match="unsupported"): + with self.assertRaisesRegex(CompileError, "unsupported"): _resolve_refs([{"$ref": "https://example.com/x"}], {}) def test_scalar_macro_in_list_context_raises(self): macros = {"x": {"type": "offroad_only"}} # macro is a single rule (dict), not a list - with pytest.raises(CompileError, match="must resolve to a list"): + with self.assertRaisesRegex(CompileError, "must resolve to a list"): _resolve_refs([{"$ref": "#/macros/x"}], macros) -class TestCompiledShape: +class TestCompiledShape(OpenpilotTestCase): def test_panels_present(self, compiled): assert isinstance(compiled["panels"], list) assert len(compiled["panels"]) == 9 @@ -145,7 +143,7 @@ class TestCompiledShape: def walk(node): if isinstance(node, dict): if "$ref" in node: - pytest.fail(f"unresolved $ref: {node}") + self.fail(f"unresolved $ref: {node}") for v in node.values(): walk(v) elif isinstance(node, list): @@ -154,7 +152,7 @@ class TestCompiledShape: walk(compiled) -class TestSourceTreeIntegrity: +class TestSourceTreeIntegrity(OpenpilotTestCase): def test_macros_yaml_well_formed(self): with open(os.path.join(DEFAULT_SRC, "_macros.yaml")) as f: doc = yaml.safe_load(f) diff --git a/openpilot/sunnypilot/sunnylink/tests/test_settings_changes.py b/openpilot/sunnypilot/sunnylink/tests/test_settings_changes.py index 07b05d4ac4..d9b1267a10 100644 --- a/openpilot/sunnypilot/sunnylink/tests/test_settings_changes.py +++ b/openpilot/sunnypilot/sunnylink/tests/test_settings_changes.py @@ -15,7 +15,7 @@ import json import os from typing import Any -import pytest +from openpilot.common.parameterized import parameterized from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import ( DEFINITION_PATH, @@ -24,6 +24,7 @@ from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import ( _load_torque_versions, generate_schema, ) +from openpilot.common.test import OpenpilotTestCase SCHEMA_VALIDATOR_PATH = os.path.join(os.path.dirname(DEFINITION_PATH), "settings_ui.schema.json") @@ -105,12 +106,11 @@ def _references_capability_field(rules: list[dict[str, Any]] | None, field: str) return found -@pytest.fixture(scope="module") def schema(): return generate_schema() -class TestMadsBrandGates: +class TestMadsBrandGates(OpenpilotTestCase): def test_mads_main_cruise_has_brand_gate(self, schema): """MadsMainCruiseAllowed must gate on brand and tesla_has_vehicle_bus.""" item = _find_item(schema, "MadsMainCruiseAllowed") @@ -126,7 +126,7 @@ class TestMadsBrandGates: assert _references_capability_field(item.get("enablement"), "tesla_has_vehicle_bus") -class TestTestManeuversSection: +class TestTestManeuversSection(OpenpilotTestCase): def test_lateral_maneuver_mode_in_test_maneuvers(self, schema): section = _find_section(schema, "developer", "test_maneuvers") assert section is not None, "developer.test_maneuvers section missing" @@ -153,10 +153,13 @@ class TestTestManeuversSection: "test_maneuvers must gate ShowAdvancedControls via enablement" -class TestValidator: +class TestValidator(OpenpilotTestCase): def test_validator_accepts_real_json(self): """settings_ui.json validates against settings_ui.schema.json.""" - jsonschema = pytest.importorskip("jsonschema") + try: + import jsonschema + except ImportError: + self.skipTest("jsonschema not installed") with open(DEFINITION_PATH) as f: data = json.load(f) with open(SCHEMA_VALIDATOR_PATH) as f: @@ -164,7 +167,7 @@ class TestValidator: jsonschema.validate(instance=data, schema=validator) -class TestTorqueOptionGeneration: +class TestTorqueOptionGeneration(OpenpilotTestCase): def test_torque_versions_match_generated_options(self, schema): versions = _load_torque_versions() assert versions, "latcontrol_torque_versions.json must have at least one version" @@ -179,11 +182,11 @@ class TestTorqueOptionGeneration: ) -class TestReleaseBranchGates: - @pytest.mark.parametrize("key", [ +class TestReleaseBranchGates(OpenpilotTestCase): + @parameterized.expand([ "EnableGithubRunner", "QuickBootToggle", - ]) + ], names=["key"]) def test_sp_dev_items_gate_on_is_sp_release(self, schema, key): """sunnypilot dev items must hide on sunnypilot release branches (is_sp_release gate).""" item = _find_item(schema, key) @@ -192,7 +195,7 @@ class TestReleaseBranchGates: assert _references_capability_field(rules, "is_sp_release"), f"{key} missing is_sp_release gate" -class TestSpuriousOffroadGatesDropped: +class TestSpuriousOffroadGatesDropped(OpenpilotTestCase): def test_disengage_on_accelerator_has_no_offroad_only(self, schema): item = _find_item(schema, "DisengageOnAccelerator") assert item is not None @@ -204,12 +207,12 @@ class TestSpuriousOffroadGatesDropped: assert "offroad_only" not in _flatten_rule_types(item.get("enablement")) -class TestNotEngagedReplacement: - @pytest.mark.parametrize("key", [ +class TestNotEngagedReplacement(OpenpilotTestCase): + @parameterized.expand([ "AlphaLongitudinalEnabled", "ToyotaEnforceStockLongitudinal", "ToyotaStopAndGoHack", - ]) + ], names=["key"]) def test_offroad_only_replaced_with_not_engaged(self, schema, key): """These items should use not_engaged, not offroad_only.""" item = _find_item(schema, key) diff --git a/openpilot/sunnypilot/sunnylink/tests/test_settings_schema.py b/openpilot/sunnypilot/sunnylink/tests/test_settings_schema.py index 579d72b60b..e60ac000f4 100644 --- a/openpilot/sunnypilot/sunnylink/tests/test_settings_schema.py +++ b/openpilot/sunnypilot/sunnylink/tests/test_settings_schema.py @@ -5,7 +5,6 @@ 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 json -import pytest from openpilot.common.params import Params from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import ( @@ -16,6 +15,7 @@ from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import ( collect_capability_refs, ) from openpilot.sunnypilot.sunnylink.capabilities import CAPABILITY_FIELDS +from openpilot.common.test import OpenpilotTestCase VALID_WIDGET_TYPES = {"toggle", "option", "multiple_button", "button", "info"} @@ -55,18 +55,16 @@ def _brand_items(brand_data) -> list[dict]: return [] -@pytest.fixture(scope="module") def schema(): return generate_schema() -@pytest.fixture(scope="module") def all_param_keys(): """All keys registered in the device param store.""" return {k.decode("utf-8") for k in Params().all_keys()} -class TestSchemaStructure: +class TestSchemaStructure(OpenpilotTestCase): def test_schema_is_valid_json(self): """Schema serializes to valid JSON.""" raw = generate_schema_json() @@ -141,16 +139,16 @@ class TestSchemaStructure: for item in _iter_panel_items(panel): key = item["key"] if key in seen: - pytest.fail(f"Key '{key}' appears in both panel '{seen[key]}' and '{panel['id']}'") + self.fail(f"Key '{key}' appears in both panel '{seen[key]}' and '{panel['id']}'") seen[key] = panel["id"] for sub in item.get("sub_items", []): sub_key = sub["key"] if sub_key in seen: - pytest.fail(f"Sub-item key '{sub_key}' appears in both '{seen[sub_key]}' and '{panel['id']}'") + self.fail(f"Sub-item key '{sub_key}' appears in both '{seen[sub_key]}' and '{panel['id']}'") seen[sub_key] = panel["id"] -class TestSchemaCoverage: +class TestSchemaCoverage(OpenpilotTestCase): def test_all_schema_keys_exist_in_params(self, schema, all_param_keys): """Schema keys must exist in Params().all_keys().""" schema_keys = collect_all_keys(schema) @@ -169,7 +167,7 @@ class TestSchemaCoverage: assert set(schema["capability_fields"]) == set(CAPABILITY_FIELDS) -class TestRuleWellFormedness: +class TestRuleWellFormedness(OpenpilotTestCase): def _validate_rule(self, rule: dict, context: str = ""): """Recursively validate a single rule dict.""" assert "type" in rule, f"Rule missing 'type' in {context}" @@ -232,7 +230,7 @@ class TestRuleWellFormedness: key = item.get("key") for rule in item.get(rules_field, []): if rule.get("type") == "param" and rule.get("key") == key: - pytest.fail(f"Item {key} has self-referencing {rules_field} rule") + self.fail(f"Item {key} has self-referencing {rules_field} rule") for panel in schema["panels"]: for item in _iter_panel_items(panel): @@ -245,7 +243,7 @@ class TestRuleWellFormedness: _check_self_ref(item, "enablement") -class TestKnownPanels: +class TestKnownPanels(OpenpilotTestCase): def test_expected_panels_exist(self, schema): panel_ids = {p["id"] for p in schema["panels"]} expected = {"steering", "cruise", "display", "visuals", "device", "software", "developer"} @@ -279,7 +277,7 @@ class TestKnownPanels: assert "NeuralNetworkLateralControl" in enhanced_enable_keys -class TestKnownVehicleSettings: +class TestKnownVehicleSettings(OpenpilotTestCase): def test_hyundai_has_longitudinal_tuning(self, schema): keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("hyundai"))} assert "HyundaiLongitudinalTuning" in keys @@ -299,7 +297,7 @@ class TestKnownVehicleSettings: assert "SubaruStopAndGoManualParkingBrake" in keys -class TestItemCompleteness: +class TestItemCompleteness(OpenpilotTestCase): def _collect_all_items(self, schema): """Collect all items and sub_items from panels and vehicle_settings.""" items = [] @@ -319,7 +317,7 @@ class TestItemCompleteness: """All items must have titles.""" missing = [i["key"] for i in self._collect_all_items(schema) if "title" not in i] if len(missing) > MAX_ALLOWED_MISSING_TITLES: - pytest.fail(f"Items without titles ({len(missing)}): {missing[:10]}") + self.fail(f"Items without titles ({len(missing)}): {missing[:10]}") def test_no_default_titles(self, schema): """Item titles must differ from keys.""" diff --git a/openpilot/sunnypilot/system/sensord/tests/test_sensord.py b/openpilot/sunnypilot/system/sensord/tests/test_sensord.py index 53f5d27219..39486fde76 100644 --- a/openpilot/sunnypilot/system/sensord/tests/test_sensord.py +++ b/openpilot/sunnypilot/system/sensord/tests/test_sensord.py @@ -1,6 +1,5 @@ import os import subprocess -import pytest import time import numpy as np from collections import namedtuple, defaultdict @@ -12,6 +11,7 @@ from openpilot.common.gpio import get_irqs_for_action from openpilot.common.timeout import Timeout from openpilot.common.hardware import HARDWARE from openpilot.system.manager.process_config import managed_processes +from openpilot.common.test import OpenpilotTestCase BMX = { ('bmx055', 'acceleration'), @@ -103,8 +103,9 @@ def read_sensor_events(duration_sec): return {k: v for k, v in events.items() if len(v) > 0} -@pytest.mark.tici -class TestSensord: +class TestSensord(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True + @classmethod def setup_class(cls): # enable LSM self test diff --git a/openpilot/sunnypilot/system/updated/tests/test_sp_branch_migrations.py b/openpilot/sunnypilot/system/updated/tests/test_sp_branch_migrations.py index b2742841a0..86f82fd9d2 100644 --- a/openpilot/sunnypilot/system/updated/tests/test_sp_branch_migrations.py +++ b/openpilot/sunnypilot/system/updated/tests/test_sp_branch_migrations.py @@ -4,70 +4,72 @@ 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 pytest +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.common.params import Params from openpilot.system.updated.updated import Updater -@pytest.mark.parametrize(("device_type", "branch", "expected"), [ - ("tici", "staging-c3-new", "staging-tici"), - ("tici", "dev-c3-new", "staging-tici"), - ("tici", "master", "master-tici"), - ("tici", "master-dev-c3-new", "master-tici"), - ("tizi", "staging-c3-new", "staging"), - ("tizi", "dev-c3-new", "dev"), - ("tizi", "master-dev-c3-new", "master-dev"), - ("tizi", "release3", "release-tizi"), - ("tizi", "release3-staging", "release-tizi-staging"), - ("mici", "release3", "release-mici"), - ("mici", "release3-staging", "release-mici-staging"), -]) -def test_sp_branch_migrations_from_current_branch(mocker, device_type, branch, expected): - params = Params() - params.remove("UpdaterTargetBranch") - - mocker.patch("openpilot.system.updated.updated.HARDWARE.get_device_type", return_value=device_type) - mocker.patch.object(Updater, "get_branch", return_value=branch) - - assert Updater().target_branch == expected - - -@pytest.mark.parametrize(("device_type", "branch", "expected"), [ - ("tici", "staging-c3-new", "staging-tici"), - ("tici", "dev-c3-new", "staging-tici"), - ("tici", "master", "master-tici"), - ("tici", "master-dev-c3-new", "master-tici"), - ("tizi", "staging-c3-new", "staging"), - ("tizi", "dev-c3-new", "dev"), - ("tizi", "master-dev-c3-new", "master-dev"), - ("tizi", "release3", "release-tizi"), - ("tizi", "release3-staging", "release-tizi-staging"), - ("mici", "release3", "release-mici"), - ("mici", "release3-staging", "release-mici-staging"), -]) -def test_sp_branch_migrations_from_param(mocker, device_type, branch, expected): - params = Params() - params.put("UpdaterTargetBranch", branch, block=True) - - mocker.patch("openpilot.system.updated.updated.HARDWARE.get_device_type", return_value=device_type) - - try: - assert Updater().target_branch == expected - finally: +class TestBranchMigrations(OpenpilotTestCase): + @parameterized.expand([ + ("tici", "staging-c3-new", "staging-tici"), + ("tici", "dev-c3-new", "staging-tici"), + ("tici", "master", "master-tici"), + ("tici", "master-dev-c3-new", "master-tici"), + ("tizi", "staging-c3-new", "staging"), + ("tizi", "dev-c3-new", "dev"), + ("tizi", "master-dev-c3-new", "master-dev"), + ("tizi", "release3", "release-tizi"), + ("tizi", "release3-staging", "release-tizi-staging"), + ("mici", "release3", "release-mici"), + ("mici", "release3-staging", "release-mici-staging"), + ], names=["device_type", "branch", "expected"]) + def test_sp_branch_migrations_from_current_branch(self, mocker, device_type, branch, expected): + params = Params() params.remove("UpdaterTargetBranch") + mocker.patch("openpilot.system.updated.updated.HARDWARE.get_device_type", return_value=device_type) + mocker.patch.object(Updater, "get_branch", return_value=branch) -@pytest.mark.parametrize(("device_type", "branch"), [ - ("tici", "unknown"), - ("tizi", "unknown"), - ("mici", "unknown"), -]) -def test_sp_branch_migrations_passthrough(mocker, device_type, branch): - params = Params() - params.remove("UpdaterTargetBranch") + assert Updater().target_branch == expected - mocker.patch("openpilot.system.updated.updated.HARDWARE.get_device_type", return_value=device_type) - mocker.patch.object(Updater, "get_branch", return_value=branch) - assert Updater().target_branch == branch + @parameterized.expand([ + ("tici", "staging-c3-new", "staging-tici"), + ("tici", "dev-c3-new", "staging-tici"), + ("tici", "master", "master-tici"), + ("tici", "master-dev-c3-new", "master-tici"), + ("tizi", "staging-c3-new", "staging"), + ("tizi", "dev-c3-new", "dev"), + ("tizi", "master-dev-c3-new", "master-dev"), + ("tizi", "release3", "release-tizi"), + ("tizi", "release3-staging", "release-tizi-staging"), + ("mici", "release3", "release-mici"), + ("mici", "release3-staging", "release-mici-staging"), + ], names=["device_type", "branch", "expected"]) + def test_sp_branch_migrations_from_param(self, mocker, device_type, branch, expected): + params = Params() + params.put("UpdaterTargetBranch", branch, block=True) + + mocker.patch("openpilot.system.updated.updated.HARDWARE.get_device_type", return_value=device_type) + + try: + assert Updater().target_branch == expected + finally: + params.remove("UpdaterTargetBranch") + + + @parameterized.expand([ + ("tici", "unknown"), + ("tizi", "unknown"), + ("mici", "unknown"), + ], names=["device_type", "branch"]) + def test_sp_branch_migrations_passthrough(self, mocker, device_type, branch): + params = Params() + params.remove("UpdaterTargetBranch") + + mocker.patch("openpilot.system.updated.updated.HARDWARE.get_device_type", return_value=device_type) + mocker.patch.object(Updater, "get_branch", return_value=branch) + + assert Updater().target_branch == branch diff --git a/openpilot/system/athena/athenad.py b/openpilot/system/athena/athenad.py index 1f59c8d271..89a98dd646 100755 --- a/openpilot/system/athena/athenad.py +++ b/openpilot/system/athena/athenad.py @@ -5,22 +5,26 @@ import base64 import hashlib import itertools import json +import math import os import queue import random +import re import select import socket +import subprocess import sys import tempfile import threading import time import gzip +from contextlib import suppress from dataclasses import asdict, dataclass, replace from datetime import datetime from functools import partial, total_ordering from queue import Queue from typing import cast -from collections.abc import Callable +from collections.abc import Callable, Iterable import requests from requests.adapters import HTTPAdapter, DEFAULT_POOLBLOCK @@ -32,11 +36,14 @@ from openpilot.cereal import log from opendbc.car.structs import car from openpilot.cereal.services import SERVICE_LIST from openpilot.common.api import Api, get_key_pair +from openpilot.common.basedir import BASEDIR from openpilot.common.utils import CallbackReader, get_upload_stream from openpilot.common.params import Params from openpilot.common.realtime import set_core_affinity from openpilot.common.hardware import HARDWARE, PC +from openpilot.system.loggerd.config import CAMERA_FPS, SEGMENT_LENGTH from openpilot.system.loggerd.xattr_cache import getxattr, setxattr +from openpilot.tools.lib.helpers import RE from openpilot.common.swaglog import cloudlog from openpilot.common.version import get_build_metadata from openpilot.common.hardware.hw import Paths @@ -57,6 +64,7 @@ MAX_AGE = 31 * 24 * 3600 # seconds WS_FRAME_SIZE = 4096 DEVICE_STATE_UPDATE_INTERVAL = 1.0 # in seconds DEFAULT_UPLOAD_PRIORITY = 99 # higher number = lower priority +CLIP_CHUNK_SIZE = 512 * 1024 SEND_PRIORITY_HIGH = 0 SEND_PRIORITY_LOW = 1 @@ -373,6 +381,7 @@ def getVersion() -> dict[str, str]: "remote": build_metadata.openpilot.git_normalized_origin, "branch": build_metadata.channel, "commit": build_metadata.openpilot.git_commit, + "commit_date": build_metadata.openpilot.git_commit_date.strip("'").split()[0], } @@ -420,6 +429,217 @@ def listDataDirectory(prefix='') -> list[str]: return sorted(set(internal_files + external_files)) +class VideoClips: + @dataclass + class Clip: + route: str + camera: str + source_start_time: float + source_end_time: float + bitrate: int + speedup: int + filename: str + requested_at: float + + def __init__(self): + self.clip_path = os.path.join(Paths.log_root(), "clips") + self.lock = threading.Condition() + self.clips: dict[str, VideoClips.Clip] = {} + self.transcode_proc: tuple[str, subprocess.Popen] | None = None + threading.Thread(target=self._worker, name="video_clip", daemon=True).start() + + def _encode(self, clip: Clip, inputs: Iterable[str], output_path: str, start_time: float, duration: float) -> None: + inputs = list(inputs) + metadata = json.dumps(asdict(clip), separators=(',', ':')) + if PC: + command = [ + "ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin", "-y", + "-r", str(CAMERA_FPS * clip.speedup), "-f", "concat", "-safe", "0", "-protocol_whitelist", "file,pipe", "-c:v", "hevc", + "-i", "pipe:0", "-ss", str(start_time / clip.speedup), "-t", str(duration / clip.speedup), + "-map", "0:v:0", "-an", "-r", str(CAMERA_FPS), "-c:v", "libx264", "-preset", "veryfast", + "-b:v", f"{clip.bitrate}M", "-pix_fmt", "yuv420p", "-movflags", "+faststart+use_metadata_tags", + "-metadata", f"ai.comma.clip.settings={metadata}", output_path, + ] + else: + command = [os.path.join(BASEDIR, "openpilot/system/loggerd/encoderd"), "--clip", output_path, + str(start_time), str(duration), "--bitrate", str(clip.bitrate * 1_000_000), + "--speedup", str(clip.speedup), "--metadata", metadata, "--", *inputs] + + with self.lock: + if self.clips.get(clip.filename) is not clip: + return + process = subprocess.Popen(command, stdin=subprocess.PIPE if PC else subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, text=True) + self.transcode_proc = (clip.filename, process) + try: + if PC: + if process.stdin is None: + raise RuntimeError("ffmpeg stdin is unavailable") + process.stdin.write("ffconcat version 1.0\n") + for path in inputs: + escaped_path = path.replace("'", "'\\''") + process.stdin.write(f"file 'file:{escaped_path}'\noption framerate {CAMERA_FPS}\nduration {SEGMENT_LENGTH}\n") + process.stdin.close() + process.wait() + if process.returncode != 0: + raise RuntimeError(f"clip encoder exited with code {process.returncode}") + finally: + with suppress(OSError): + if process.stdin is not None: + process.stdin.close() + if process.poll() is None: + process.terminate() + process.wait() + with self.lock: + if self.transcode_proc is not None and self.transcode_proc[0] == clip.filename: + self.transcode_proc = None + + def _worker(self) -> None: + while True: + with self.lock: + while not self.clips: + self.lock.wait() + clip = next(iter(self.clips.values())) + temporary_path = "" + try: + with self.lock: + if self.clips.get(clip.filename) is not clip: + continue + first_segment = math.floor(clip.source_start_time / SEGMENT_LENGTH) + inputs = ( + os.path.join(Paths.log_root(), f"{clip.route}--{segment}", clip.camera) + for segment in range(first_segment, math.ceil(clip.source_end_time / SEGMENT_LENGTH)) + ) + os.makedirs(self.clip_path, exist_ok=True) + temporary_path = os.path.join(self.clip_path, f".{clip.filename}") + output_path = os.path.join(self.clip_path, clip.filename) + self._encode(clip, inputs, temporary_path, clip.source_start_time - first_segment * SEGMENT_LENGTH, + clip.source_end_time - clip.source_start_time) + with self.lock: + if self.clips.get(clip.filename) is clip: + os.replace(temporary_path, output_path) + del self.clips[clip.filename] + except Exception: + with self.lock: + failed = self.clips.get(clip.filename) is clip + if failed: + del self.clips[clip.filename] + if failed: + cloudlog.exception("athena.video_clip.failed") + finally: + with suppress(OSError): + if temporary_path: + os.unlink(temporary_path) + + def _on_disk(self) -> dict[str, dict]: + clips = {} + try: + entries = os.scandir(self.clip_path) + except FileNotFoundError: + return clips + with entries: + for entry in entries: + if entry.name.startswith(".") or not entry.is_file(): + continue + probe = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format_tags=ai.comma.clip.settings", + "-of", "json", entry.path], capture_output=True, text=True) + if probe.returncode != 0: + continue + try: + metadata = json.loads(json.loads(probe.stdout)["format"]["tags"]["ai.comma.clip.settings"]) + size = entry.stat().st_size + except (FileNotFoundError, KeyError, TypeError, json.JSONDecodeError): + continue + if not isinstance(metadata, dict) or not isinstance(metadata.get("requested_at"), (int, float)): + continue + clips[entry.name] = {**metadata, "filename": entry.name, "status": "ready", + "fn": os.path.relpath(entry.path, Paths.log_root()), "size": size} + return clips + + def _available_ranges(self, route: str) -> dict: + cameras: dict[str, list[int]] = {} + try: + with os.scandir(Paths.log_root()) as entries: + for entry in entries: + entry_route, _, segment = entry.name.rpartition("--") + if entry_route != route or not segment.isdigit() or not entry.is_dir(): + continue + with os.scandir(entry.path) as files: + for camera in files: + if camera.is_file() and camera.name.endswith("camera.hevc"): + cameras.setdefault(camera.name, []).append(int(segment)) + except OSError: + return {} + + available = {} + for camera, camera_segments in cameras.items(): + ranges: list[list[int]] = [] + for segment in sorted(camera_segments): + if ranges and ranges[-1][1] == segment * SEGMENT_LENGTH: + ranges[-1][1] += SEGMENT_LENGTH + else: + ranges.append([segment * SEGMENT_LENGTH, (segment + 1) * SEGMENT_LENGTH]) + available[camera] = {"available_ranges": ranges} + return available + + def createClip(self, route: str, source_start_time: float, source_end_time: float, clip: dict): + if not PC and not Params().get_bool("IsOffroad"): + raise RuntimeError("video clips can only be created while offroad") + route_match = re.fullmatch(RE.ROUTE_NAME, route) + assert route_match is not None, "invalid route" + route_name = route_match.group("log_id") + camera = clip["camera"] + filename = clip["filename"] + assert camera == os.path.basename(camera) and camera.endswith("camera.hevc"), "invalid camera filename" + assert filename == os.path.basename(filename), "invalid filename" + with self.lock: + self.clips[filename] = self.Clip(route_name, camera, source_start_time, source_end_time, clip["bitrate"], clip["speedup"], + filename, datetime.now().timestamp()) + self.lock.notify() + + def getClipState(self, route: str | None = None) -> dict: + route_match = re.search(RE.ROUTE_NAME, route or "") + with self.lock: + transcode_filename = self.transcode_proc[0] if self.transcode_proc is not None else None + active_clips = {clip.filename: {**asdict(clip), "status": "encoding" if clip.filename == transcode_filename else "queued"} + for clip in self.clips.values()} + clips = self._on_disk() + clips.update(active_clips) + state = {"clips": sorted(clips.values(), key=lambda clip: clip["requested_at"], reverse=True)} + if route_match is not None: + route_name = route_match.group("log_id") + state.update({"route": route_name, "cameras": self._available_ranges(route_name)}) + return state + + def deleteClip(self, filename: str) -> None: + assert filename == os.path.basename(filename), "invalid filename" + with self.lock: + self.clips.pop(filename, None) + output_path = os.path.join(self.clip_path, filename) + if self.transcode_proc is not None and self.transcode_proc[0] == filename: + self.transcode_proc[1].terminate() + if os.path.exists(output_path): + os.unlink(output_path) + + def getClipChunk(self, filename: str, offset: int) -> dict: + assert filename == os.path.basename(filename) and not filename.startswith("."), "invalid filename" + assert isinstance(offset, int) and offset >= 0, "invalid offset" + path = os.path.join(self.clip_path, filename) + size = os.path.getsize(path) + assert offset <= size, "offset past end of file" + with open(path, "rb") as f: + f.seek(offset) + data = f.read(CLIP_CHUNK_SIZE) + return {"data": base64.b64encode(data).decode(), "offset": offset, "size": size} + + +video_clips = VideoClips() +dispatcher.add_method(video_clips.createClip) +dispatcher.add_method(video_clips.getClipState) +dispatcher.add_method(video_clips.deleteClip) +dispatcher.add_method(video_clips.getClipChunk) + + @dispatcher.add_method def uploadFileToUrl(fn: str, url: str, headers: dict[str, str]) -> UploadFilesToUrlResponse: # this is because mypy doesn't understand that the decorator doesn't change the return type @@ -614,7 +834,7 @@ def startStream(sdp: str, enabled: bool) -> dict: if CP.notCar: bridge_services_in.append("testJoystick") else: - raise Exception("failed to get CarParamsPersistent") + raise Exception("failed to get CarParamsPersistent") if params.get_bool("IsOffroad"): # manager owns camerad/stream_encoderd/webrtcd; flip the param and let it bring them up. @@ -623,7 +843,7 @@ def startStream(sdp: str, enabled: bool) -> dict: # wait for webrtcd end points to wake up wait_for_webrtcd() - return post_stream_request(StreamRequestBody(sdp, "wideRoad", enabled, bridge_services_in, ["carState", "deviceState"])) + return post_stream_request(StreamRequestBody(sdp, ["wideRoad"], enabled, bridge_services_in, ["carState", "deviceState"])) def get_logs_to_send_sorted(log_attr_name=LOG_ATTR_NAME) -> list[str]: diff --git a/openpilot/system/athena/tests/helpers.py b/openpilot/system/athena/tests/helpers.py index a0a9cccdc1..dbca66be10 100644 --- a/openpilot/system/athena/tests/helpers.py +++ b/openpilot/system/athena/tests/helpers.py @@ -43,11 +43,10 @@ class MockApi: class MockWebsocket: - sock = socket.socket() - def __init__(self, recv_queue, send_queue): self.recv_queue = recv_queue self.send_queue = send_queue + self.sock = socket.socket() def recv(self): data = self.recv_queue.get() @@ -59,7 +58,7 @@ class MockWebsocket: self.send_queue.put_nowait((data, opcode)) def close(self): - pass + self.sock.close() class HTTPRequestHandler(http.server.SimpleHTTPRequestHandler): diff --git a/openpilot/system/athena/tests/test_athenad.py b/openpilot/system/athena/tests/test_athenad.py index 8c090b93af..f9e4c0d334 100644 --- a/openpilot/system/athena/tests/test_athenad.py +++ b/openpilot/system/athena/tests/test_athenad.py @@ -1,4 +1,3 @@ -import pytest from functools import wraps import json import multiprocessing @@ -14,6 +13,8 @@ from datetime import datetime, timedelta from websocket import ABNF from websocket._exceptions import WebSocketConnectionClosedException +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import messaging from openpilot.common.params import Params @@ -47,16 +48,14 @@ def with_upload_handler(func): thread.join() return wrapper -@pytest.fixture def mock_create_connection(mocker): - return mocker.patch('openpilot.system.athena.athenad.create_connection') + return mocker.patch('openpilot.system.athena.athenad.create_connection') -@pytest.fixture def host(): with http_server_context(handler=HTTPRequestHandler, setup=seed_athena_server) as (host, port): yield f"http://{host}:{port}" -class TestAthenadMethods: +class TestAthenadMethods(OpenpilotTestCase): @classmethod def setup_class(cls): cls.SOCKET_PORT = 45454 @@ -104,6 +103,14 @@ class TestAthenadMethods: f.write(data) return fn + @staticmethod + def _video_clips(clip): + clips = object.__new__(athenad.VideoClips) + clips.lock = threading.Condition() + clips.clips = {clip.filename: clip} + clips.transcode_proc = None + return clips + # *** test cases *** @@ -111,7 +118,7 @@ class TestAthenadMethods: assert dispatcher["echo"]("bob") == "bob" def test_get_message(self): - with pytest.raises(TimeoutError) as _: + with self.assertRaises(TimeoutError) as _: dispatcher["getMessage"]("controlsState") end_event = multiprocessing.Event() @@ -174,6 +181,56 @@ class TestAthenadMethods: assert resp, 'list empty!' assert len(resp) == len(expected) + def test_video_clip_hardware_encoder(self, mocker): + clip = athenad.VideoClips.Clip("route", "fcamera.hevc", 10, 130, 2, 4, "clip.mp4", 123) + clips = self._video_clips(clip) + process = mocker.Mock(stdin=None, returncode=0) + process.poll.return_value = 0 + popen = mocker.patch("openpilot.system.athena.athenad.subprocess.Popen", return_value=process) + mocker.patch.object(athenad, "PC", False) + + clips._encode(clip, ["segment0", "segment1"], "output.mp4", 10, 120) + + metadata = json.dumps(asdict(clip), separators=(',', ':')) + assert popen.call_args.args[0] == [ + os.path.join(athenad.BASEDIR, "openpilot/system/loggerd/encoderd"), "--clip", "output.mp4", "10", "120", + "--bitrate", "2000000", "--speedup", "4", "--metadata", metadata, "--", "segment0", "segment1", + ] + assert popen.call_args.kwargs["stdin"] == athenad.subprocess.DEVNULL + assert clips.transcode_proc is None + + def test_video_clip_hardware_encoder_failure(self, mocker): + clip = athenad.VideoClips.Clip("route", "fcamera.hevc", 0, 60, 1, 1, "clip.mp4", 123) + clips = self._video_clips(clip) + process = mocker.Mock(stdin=None, returncode=1) + process.poll.return_value = 1 + mocker.patch("openpilot.system.athena.athenad.subprocess.Popen", return_value=process) + mocker.patch.object(athenad, "PC", False) + + with self.assertRaisesRegex(RuntimeError, "clip encoder exited with code 1"): + clips._encode(clip, ["segment"], "output.mp4", 0, 60) + assert clips.transcode_proc is None + + def test_video_clip_software_fallback(self, mocker): + clip = athenad.VideoClips.Clip("route", "fcamera.hevc", 10, 30, 3, 2, "clip.mp4", 123) + clips = self._video_clips(clip) + stdin = mocker.Mock() + process = mocker.Mock(stdin=stdin, returncode=0) + process.poll.return_value = 0 + popen = mocker.patch("openpilot.system.athena.athenad.subprocess.Popen", return_value=process) + mocker.patch.object(athenad, "PC", True) + + clips._encode(clip, ["segment'0", "segment1"], "output.mp4", 10, 20) + + command = popen.call_args.args[0] + assert ["-r", "40"] == command[command.index("-r"):command.index("-r") + 2] + assert ["-ss", "5.0"] == command[command.index("-ss"):command.index("-ss") + 2] + assert ["-t", "10.0"] == command[command.index("-t"):command.index("-t") + 2] + assert ["-b:v", "3M"] == command[command.index("-b:v"):command.index("-b:v") + 2] + writes = [call.args[0] for call in stdin.write.call_args_list] + assert "file 'file:segment'\\''0'\n" in writes[1] + assert writes[-1].startswith("file 'file:segment1'") + def test_strip_extension(self): # any requested log file with an invalid extension won't return as existing fn = self._create_file('qlog.bz2') @@ -184,14 +241,14 @@ class TestAthenadMethods: if fn.endswith('.zst'): assert athenad.strip_zst_extension(fn) == fn[:-4] - @pytest.mark.parametrize("compress", [True, False]) + @parameterized.expand([True, False], names=("compress",)) def test_do_upload(self, host, compress): # random bytes to ensure rather large object post-compression fn = self._create_file('qlog', data=os.urandom(10000 * 1024)) upload_fn = fn + ('.zst' if compress else '') item = athenad.UploadItem(path=upload_fn, url="http://localhost:1238", headers={}, created_at=int(time.time()*1000), id='') # noqa: TID251 - with pytest.raises(requests.exceptions.ConnectionError): + with self.assertRaises(requests.exceptions.ConnectionError): athenad._do_upload(item) item = athenad.UploadItem(path=upload_fn, url=f"{host}/qlog.zst", headers={}, created_at=int(time.time()*1000), id='') # noqa: TID251 @@ -236,7 +293,7 @@ class TestAthenadMethods: # TODO: also check that end_event and metered network raises AbortTransferException assert athenad.upload_queue.qsize() == 0 - @pytest.mark.parametrize("status,retry", [(500,True), (412,False)]) + @parameterized.expand([(500,True), (412,False)], names=("status", "retry")) @with_upload_handler def test_upload_handler_retry(self, mocker, host, status, retry): mock_put = mocker.patch('openpilot.system.athena.athenad.UPLOAD_SESS.put') @@ -410,7 +467,7 @@ class TestAthenadMethods: def test_get_version(self): resp = dispatcher["getVersion"]() - keys = ["version", "remote", "branch", "commit"] + keys = ["version", "remote", "branch", "commit", "commit_date"] assert list(resp.keys()) == keys for k in keys: assert isinstance(resp[k], str), f"{k} is not a string" diff --git a/openpilot/system/athena/tests/test_athenad_ping.py b/openpilot/system/athena/tests/test_athenad_ping.py index 44a6b8a56b..33876a6235 100644 --- a/openpilot/system/athena/tests/test_athenad_ping.py +++ b/openpilot/system/athena/tests/test_athenad_ping.py @@ -1,25 +1,26 @@ -import pytest +import unittest import subprocess import threading import time from typing import cast +from openpilot.common.test import OpenpilotTestCase from openpilot.common.params import Params from openpilot.common.timeout import Timeout from openpilot.system.athena import athenad -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE TIMEOUT_TOLERANCE = 20 # seconds def wifi_radio(on: bool) -> None: - if not TICI: + if not COMMA_HARDWARE: return print(f"wifi {'on' if on else 'off'}") subprocess.run(["nmcli", "radio", "wifi", "on" if on else "off"], check=True) -class TestAthenadPing: +class TestAthenadPing(OpenpilotTestCase): params: Params dongle_id: str @@ -90,12 +91,12 @@ class TestAthenadPing: time.sleep(0.1) print("ping received") - @pytest.mark.skipif(not TICI, reason="only run on desk") + @unittest.skipIf(not COMMA_HARDWARE, "only run on desk") def test_offroad(self, subtests, mocker) -> None: self.params.put_bool("IsOffroad", True, block=True) self.assertTimeout(60 + TIMEOUT_TOLERANCE, subtests, mocker) # based using TCP keepalive settings - @pytest.mark.skipif(not TICI, reason="only run on desk") + @unittest.skipIf(not COMMA_HARDWARE, "only run on desk") def test_onroad(self, subtests, mocker) -> None: self.params.put_bool("IsOffroad", False, block=True) self.assertTimeout(21 + TIMEOUT_TOLERANCE, subtests, mocker) diff --git a/openpilot/system/athena/tests/test_registration.py b/openpilot/system/athena/tests/test_registration.py index bb1523de80..3c75255c61 100644 --- a/openpilot/system/athena/tests/test_registration.py +++ b/openpilot/system/athena/tests/test_registration.py @@ -2,13 +2,14 @@ import json from Crypto.PublicKey import RSA from pathlib import Path +from openpilot.common.test import OpenpilotTestCase from openpilot.common.params import Params from openpilot.system.athena.registration import register, UNREGISTERED_DONGLE_ID from openpilot.system.athena.tests.helpers import MockResponse from openpilot.common.hardware.hw import Paths -class TestRegistration: +class TestRegistration(OpenpilotTestCase): def setup_method(self): # clear params and setup key paths diff --git a/openpilot/system/camerad/SConscript b/openpilot/system/camerad/SConscript index c28330b32c..e6bc3f2bfb 100644 --- a/openpilot/system/camerad/SConscript +++ b/openpilot/system/camerad/SConscript @@ -6,6 +6,3 @@ if arch != "Darwin": camera_obj = env.Object(['cameras/camera_qcom2.cc', 'cameras/camera_common.cc', 'cameras/spectra.cc', 'cameras/cdm.cc', 'sensors/ox03c10.cc', 'sensors/os04c10.cc']) env.Program('camerad', ['main.cc', camera_obj], LIBS=libs) - -if GetOption("extras") and arch == "x86_64": - env.Program('test/test_ae_gray', ['test/test_ae_gray.cc', camera_obj], LIBS=libs) diff --git a/openpilot/system/camerad/cameras/camera_qcom2.cc b/openpilot/system/camerad/cameras/camera_qcom2.cc index 63640920db..4d30cd1e2c 100644 --- a/openpilot/system/camerad/cameras/camera_qcom2.cc +++ b/openpilot/system/camerad/cameras/camera_qcom2.cc @@ -234,11 +234,11 @@ void CameraState::sendState() { framed.setSensor(camera.sensor->image_sensor); // Log raw frames for road camera - if (env_log_raw_frames && camera.cc.stream_type == VISION_STREAM_ROAD && meta.frame_id % 100 == 5) { // no overlap with qlog decimation + if (env_log_raw_frames && camera.cc.stream_type == VISION_STREAM_NARROW_ROAD && meta.frame_id % 100 == 5) { // no overlap with qlog decimation framed.setImage(get_raw_frame_image(&camera.buf)); } - set_camera_exposure(calculate_exposure_value(&camera.buf, ae_xywh, 2, camera.cc.stream_type != VISION_STREAM_DRIVER ? 2 : 4)); + set_camera_exposure(calculate_exposure_value(&camera.buf, ae_xywh, 2, camera.cc.stream_type != VISION_STREAM_CABIN ? 2 : 4)); // Send the message pm->send(camera.cc.publish_name, msg); diff --git a/openpilot/system/camerad/cameras/hw.h b/openpilot/system/camerad/cameras/hw.h index be0bea872d..8f9de5bede 100644 --- a/openpilot/system/camerad/cameras/hw.h +++ b/openpilot/system/camerad/cameras/hw.h @@ -2,6 +2,7 @@ #include "common/util.h" #include "openpilot/cereal/gen/cpp/log.capnp.h" +#include "openpilot/cereal/visionstream.h" #include "msgq/visionipc/visionipc_server.h" #include "media/cam_isp_ife.h" @@ -43,12 +44,12 @@ const CameraConfig WIDE_ROAD_CAMERA_CONFIG = { .staggered_sof = false, }; -const CameraConfig ROAD_CAMERA_CONFIG = { +const CameraConfig NARROW_ROAD_CAMERA_CONFIG = { .camera_num = 1, - .stream_type = VISION_STREAM_ROAD, + .stream_type = VISION_STREAM_NARROW_ROAD, .focal_len = 8.0, - .publish_name = "roadCameraState", - .init_camera_state = &cereal::Event::Builder::initRoadCameraState, + .publish_name = "narrowRoadCameraState", + .init_camera_state = &cereal::Event::Builder::initNarrowRoadCameraState, .enabled = !getenv("DISABLE_ROAD"), .phy = CAM_ISP_IFE_IN_RES_PHY_1, .vignetting_correction = true, @@ -56,12 +57,12 @@ const CameraConfig ROAD_CAMERA_CONFIG = { .staggered_sof = false, }; -const CameraConfig DRIVER_CAMERA_CONFIG = { +const CameraConfig CABIN_CAMERA_CONFIG = { .camera_num = 2, - .stream_type = VISION_STREAM_DRIVER, + .stream_type = VISION_STREAM_CABIN, .focal_len = 1.71, - .publish_name = "driverCameraState", - .init_camera_state = &cereal::Event::Builder::initDriverCameraState, + .publish_name = "cabinCameraState", + .init_camera_state = &cereal::Event::Builder::initCabinCameraState, .enabled = !getenv("DISABLE_DRIVER"), .phy = CAM_ISP_IFE_IN_RES_PHY_2, .vignetting_correction = false, @@ -69,4 +70,4 @@ const CameraConfig DRIVER_CAMERA_CONFIG = { .staggered_sof = true, }; -const CameraConfig ALL_CAMERA_CONFIGS[] = {WIDE_ROAD_CAMERA_CONFIG, ROAD_CAMERA_CONFIG, DRIVER_CAMERA_CONFIG}; +const CameraConfig ALL_CAMERA_CONFIGS[] = {WIDE_ROAD_CAMERA_CONFIG, NARROW_ROAD_CAMERA_CONFIG, CABIN_CAMERA_CONFIG}; diff --git a/openpilot/system/camerad/cameras/spectra.cc b/openpilot/system/camerad/cameras/spectra.cc index 0b5f5327f5..2cbf3594c4 100644 --- a/openpilot/system/camerad/cameras/spectra.cc +++ b/openpilot/system/camerad/cameras/spectra.cc @@ -609,7 +609,7 @@ void SpectraCamera::config_bps(int idx, int request_id) { tmp.header = CAM_ICP_CMD_GENERIC_BLOB_CLK; tmp.header |= (sizeof(cam_icp_clk_bw_request)) << 8; tmp.clk.budget_ns = 0x1fca058; - tmp.clk.frame_cycles = sensor->frame_width * sensor->frame_height; // matches striping lib pixelCount + tmp.clk.frame_cycles = 20000000; // force max BPS clock (600 MHz) tmp.clk.rt_flag = 0x0; tmp.clk.uncompressed_bw = 0x38512180; tmp.clk.compressed_bw = 0x38512180; @@ -1398,14 +1398,14 @@ bool SpectraCamera::handle_camera_event(const cam_req_mgr_message *event_data) { */ uint64_t request_id = event_data->u.frame_msg.request_id; // ID from the camera request manager - uint64_t frame_id_raw = event_data->u.frame_msg.frame_id; // raw as opposed to our re-indexed frame ID + uint64_t ife_frame_id = event_data->u.frame_msg.frame_id; // kernel counter incremented on each IFE SOF event uint64_t timestamp = event_data->u.frame_msg.timestamp; // timestamped in the kernel's SOF IRQ callback - //LOGD("handle cam %d ts %lu req id %lu frame id %lu", cc.camera_num, timestamp, request_id, frame_id_raw); + //LOGD("handle cam %d ts %lu req id %lu frame id %lu", cc.camera_num, timestamp, request_id, ife_frame_id); // if there's a lag, some more frames could have already come in before // we cleared the queue, so we'll still get them with valid (> 0) request IDs. if (timestamp < last_requeue_ts) { - LOGD("skipping frame: ts before requeue / cam %d ts %lu req id %lu frame id %lu", cc.camera_num, timestamp, request_id, frame_id_raw); + LOGD("skipping frame: ts before requeue / cam %d ts %lu req id %lu frame id %lu", cc.camera_num, timestamp, request_id, ife_frame_id); return false; } @@ -1413,39 +1413,39 @@ bool SpectraCamera::handle_camera_event(const cam_req_mgr_message *event_data) { return false; } - if (!validateEvent(request_id, frame_id_raw)) { + if (!validateEvent(request_id, ife_frame_id)) { return false; } // Update tracking variables - if (request_id == request_id_last + 1) { + if (request_id == last_valid_request_id + 1) { skip_expected = false; } - frame_id_raw_last = frame_id_raw; - request_id_last = request_id; + last_valid_ife_frame_id = ife_frame_id; + last_valid_request_id = request_id; // Wait until frame's fully read out and processed if (!waitForFrameReady(request_id)) { // Reset queue on sync failure to prevent frame tearing - LOGE("camera %d sync failure %ld %ld ", cc.camera_num, request_id, frame_id_raw); + LOGE("camera %d sync failure %ld %ld ", cc.camera_num, request_id, ife_frame_id); clearAndRequeue(request_id + 1); return false; } int buf_idx = request_id % ife_buf_depth; - bool ret = processFrame(buf_idx, request_id, frame_id_raw, timestamp); + bool ret = processFrame(buf_idx, request_id, ife_frame_id, timestamp); destroySyncObjectAt(buf_idx); enqueue_frame(request_id + ife_buf_depth); // request next frame for this slot return ret; } -bool SpectraCamera::validateEvent(uint64_t request_id, uint64_t frame_id_raw) { +bool SpectraCamera::validateEvent(uint64_t request_id, uint64_t ife_frame_id) { // check if the request ID is even valid. this happens after queued // requests are cleared. unclear if it happens any other time. if (request_id == 0) { if (invalid_request_count++ > ife_buf_depth+2) { LOGE("camera %d reset after half second of invalid requests", cc.camera_num); - clearAndRequeue(request_id_last + 1); + clearAndRequeue(last_valid_request_id + 1); invalid_request_count = 0; } return false; @@ -1454,14 +1454,14 @@ bool SpectraCamera::validateEvent(uint64_t request_id, uint64_t frame_id_raw) { // check for skips in frame_id or request_id if (!skip_expected) { - if (frame_id_raw != frame_id_raw_last + 1) { - LOGE("camera %d frame ID skipped, %lu -> %lu", cc.camera_num, frame_id_raw_last, frame_id_raw); + if (ife_frame_id != last_valid_ife_frame_id + 1) { + LOGE("camera %d frame ID skipped, %lu -> %lu", cc.camera_num, last_valid_ife_frame_id, ife_frame_id); clearAndRequeue(request_id + 1); return false; } - if (request_id != request_id_last + 1) { - LOGE("camera %d requests skipped %ld -> %ld", cc.camera_num, request_id_last, request_id); + if (request_id != last_valid_request_id + 1) { + LOGE("camera %d requests skipped %ld -> %ld", cc.camera_num, last_valid_request_id, request_id); clearAndRequeue(request_id + 1); return false; } @@ -1512,8 +1512,8 @@ bool SpectraCamera::waitForFrameReady(uint64_t request_id) { return success; } -bool SpectraCamera::processFrame(int buf_idx, uint64_t request_id, uint64_t frame_id_raw, uint64_t timestamp) { - if (!syncFirstFrame(cc.camera_num, request_id, frame_id_raw, timestamp, cc.staggered_sof)) { +bool SpectraCamera::processFrame(int buf_idx, uint64_t request_id, uint64_t ife_frame_id, uint64_t timestamp) { + if (!syncFirstFrame(cc.camera_num, request_id, ife_frame_id, timestamp, cc.staggered_sof)) { return false; } @@ -1523,7 +1523,7 @@ bool SpectraCamera::processFrame(int buf_idx, uint64_t request_id, uint64_t fram // Update buffer and frame data buf.cur_buf_idx = buf_idx; buf.cur_frame_data = { - .frame_id = (uint32_t)(frame_id_raw - camera_sync_data[cc.camera_num].frame_id_offset), + .frame_id = (uint32_t)(ife_frame_id - camera_sync_data[cc.camera_num].frame_id_offset), .request_id = (uint32_t)request_id, .timestamp_sof = timestamp, .timestamp_eof = timestamp_eof, @@ -1532,11 +1532,11 @@ bool SpectraCamera::processFrame(int buf_idx, uint64_t request_id, uint64_t fram return true; } -bool SpectraCamera::syncFirstFrame(int camera_id, uint64_t request_id, uint64_t raw_id, uint64_t timestamp, bool staggered) { +bool SpectraCamera::syncFirstFrame(int camera_id, uint64_t request_id, uint64_t ife_frame_id, uint64_t timestamp, bool staggered) { if (first_frame_synced) return true; // Store the frame data for this camera - camera_sync_data[camera_id] = SyncData{timestamp, raw_id + 1, staggered}; + camera_sync_data[camera_id] = SyncData{timestamp, ife_frame_id + 1, staggered}; // Ensure all cameras are up int enabled_camera_count = std::count_if(std::begin(ALL_CAMERA_CONFIGS), std::end(ALL_CAMERA_CONFIGS), @@ -1569,7 +1569,7 @@ bool SpectraCamera::syncFirstFrame(int camera_id, uint64_t request_id, uint64_t } // Timeout in case the timestamps never line up - if (raw_id > 40) { + if (ife_frame_id > 40) { LOGE("camera first frame sync timed out"); first_frame_synced = true; } diff --git a/openpilot/system/camerad/cameras/spectra.h b/openpilot/system/camerad/cameras/spectra.h index eb6d95af1c..acb2000313 100644 --- a/openpilot/system/camerad/cameras/spectra.h +++ b/openpilot/system/camerad/cameras/spectra.h @@ -205,9 +205,9 @@ public: int buf_handle_raw[MAX_IFE_BUFS] = {}; int sync_objs_ife[MAX_IFE_BUFS] = {}; int sync_objs_bps[MAX_IFE_BUFS] = {}; - uint64_t request_id_last = 0; + uint64_t last_valid_request_id = 0; uint64_t last_requeue_ts = 0; - uint64_t frame_id_raw_last = 0; + uint64_t last_valid_ife_frame_id = 0; int invalid_request_count = 0; bool skip_expected = true; @@ -216,10 +216,10 @@ public: private: void clearAndRequeue(uint64_t from_request_id); - bool validateEvent(uint64_t request_id, uint64_t frame_id_raw); + bool validateEvent(uint64_t request_id, uint64_t ife_frame_id); bool waitForFrameReady(uint64_t request_id); - bool processFrame(int buf_idx, uint64_t request_id, uint64_t frame_id_raw, uint64_t timestamp); - static bool syncFirstFrame(int camera_id, uint64_t request_id, uint64_t raw_id, uint64_t timestamp, bool staggered); + bool processFrame(int buf_idx, uint64_t request_id, uint64_t ife_frame_id, uint64_t timestamp); + static bool syncFirstFrame(int camera_id, uint64_t request_id, uint64_t ife_frame_id, uint64_t timestamp, bool staggered); struct SyncData { uint64_t timestamp; uint64_t frame_id_offset = 0; diff --git a/openpilot/system/camerad/snapshot.py b/openpilot/system/camerad/snapshot.py index 8383865fce..622da4faef 100755 --- a/openpilot/system/camerad/snapshot.py +++ b/openpilot/system/camerad/snapshot.py @@ -3,13 +3,14 @@ import numpy as np import openpilot.cereal.messaging as messaging -from msgq.visionipc import VisionIpcClient, VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcClient from openpilot.common.realtime import DT_MDL VISION_STREAMS = { - "roadCameraState": VisionStreamType.VISION_STREAM_ROAD, - "driverCameraState": VisionStreamType.VISION_STREAM_DRIVER, + "narrowRoadCameraState": VisionStreamType.VISION_STREAM_NARROW_ROAD, + "cabinCameraState": VisionStreamType.VISION_STREAM_CABIN, "wideRoadCameraState": VisionStreamType.VISION_STREAM_WIDE_ROAD, } @@ -44,7 +45,7 @@ def extract_image(buf): return yuv_to_rgb(y, u, v) -def get_snapshots(frame="roadCameraState", front_frame="driverCameraState"): +def get_snapshots(frame="narrowRoadCameraState", front_frame="cabinCameraState"): sockets = [s for s in (frame, front_frame) if s is not None] sm = messaging.SubMaster(sockets) vipc_clients = {s: VisionIpcClient("camerad", VISION_STREAMS[s], True) for s in sockets} diff --git a/openpilot/system/camerad/test/test_ae_gray.cc b/openpilot/system/camerad/test/test_ae_gray.cc deleted file mode 100644 index 39c3d9c4e5..0000000000 --- a/openpilot/system/camerad/test/test_ae_gray.cc +++ /dev/null @@ -1,84 +0,0 @@ -#define CATCH_CONFIG_MAIN -#include "catch2/catch.hpp" - -#include - -#include -#include - -#include "common/util.h" -#include "system/camerad/cameras/camera_common.h" - -#define W 240 -#define H 160 - - -#define TONE_SPLITS 3 - -float gts[TONE_SPLITS * TONE_SPLITS * TONE_SPLITS * TONE_SPLITS] = { - 0.917969, 0.917969, 0.375000, 0.917969, 0.375000, 0.375000, 0.187500, 0.187500, 0.187500, 0.917969, - 0.375000, 0.375000, 0.187500, 0.187500, 0.187500, 0.187500, 0.187500, 0.187500, 0.093750, 0.093750, - 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, 0.917969, 0.375000, 0.375000, - 0.187500, 0.187500, 0.187500, 0.187500, 0.187500, 0.187500, 0.093750, 0.093750, 0.093750, 0.093750, - 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, 0.093750, - 0.093750, 0.093750, 0.093750, 0.093750, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, - 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, - 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, - 0.000000}; - - -TEST_CASE("camera.test_calculate_exposure_value") { - // set up fake camerabuf - CameraBuf cb = {}; - VisionBuf vb = {}; - uint8_t * fb_y = new uint8_t[W*H]; - vb.y = fb_y; - cb.cur_yuv_buf = &vb; - cb.out_img_width = W; - cb.out_img_height = H; - Rect rect = {0, 0, W-1, H-1}; - - printf("AE test patterns %dx%d\n", cb.out_img_width, cb.out_img_height); - - // mix of 5 tones - uint8_t l[5] = {0, 24, 48, 96, 235}; // 235 is yuv max - - bool passed = true; - float rtol = 0.05; - // generate pattern and calculate EV - int cnt = 0; - for (int i_0=0; i_0 rtol*evgt) { - passed = false; - } - - // report - printf("%d/%d/%d/%d/%d: ev %f, gt %f, err %f\n", h_0, h_1, h_2, h_3, h_4, ev, evgt, fabs(ev - evgt) / (evgt != 0 ? evgt : 0.00001f)); - cnt++; - } - } - } - } - assert(passed); - - delete[] fb_y; -} diff --git a/openpilot/system/camerad/test/test_camerad.py b/openpilot/system/camerad/test/test_camerad.py old mode 100644 new mode 100755 index afc49c02bf..410ea9fdb3 --- a/openpilot/system/camerad/test/test_camerad.py +++ b/openpilot/system/camerad/test/test_camerad.py @@ -1,15 +1,19 @@ +#!/usr/bin/env python3 + import os import time -import pytest +import unittest import numpy as np +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal.services import SERVICE_LIST from openpilot.tools.lib.log_time_series import msgs_to_time_series from openpilot.system.camerad.snapshot import get_snapshots from openpilot.selfdrive.test.helpers import collect_logs, log_collector, processes_context TEST_TIMESPAN = 10 -CAMERAS = ('roadCameraState', 'driverCameraState', 'wideRoadCameraState') +CAMERAS = ('narrowRoadCameraState', 'cabinCameraState', 'wideRoadCameraState') EXPOSURE_STABLE_COUNT = 3 EXPOSURE_RANGE = (0.15, 0.35) MAX_TEST_TIME = 25 @@ -38,7 +42,6 @@ def run_and_log(procs, services, duration): with processes_context(procs): return collect_logs(services, duration) -@pytest.fixture(scope="module") def _camera_session(): """Single camerad session that collects logs and exposure data. Runs until exposure stabilizes (min TEST_TIMESPAN seconds for enough log data).""" @@ -46,7 +49,7 @@ def _camera_session(): exposure = {cam: [] for cam in CAMERAS} start = time.monotonic() while time.monotonic() - start < MAX_TEST_TIME: - rpic, dpic = get_snapshots(frame="roadCameraState", front_frame="driverCameraState") + rpic, dpic = get_snapshots(frame="narrowRoadCameraState", front_frame="cabinCameraState") wpic, _ = get_snapshots(frame="wideRoadCameraState") for cam, img in zip(CAMERAS, [rpic, dpic, wpic], strict=True): exposure[cam].append(_exposure_stats(img)) @@ -69,20 +72,18 @@ def _camera_session(): return ts, exposure -@pytest.fixture(scope="module") -def logs(_camera_session): - return _camera_session[0] +class TestCamerad(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True -@pytest.fixture(scope="module") -def exposure_data(_camera_session): - return _camera_session[1] + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.logs, cls.exposure_data = _camera_session() -@pytest.mark.tici -class TestCamerad: - @pytest.mark.parametrize("cam", CAMERAS) - def test_camera_exposure(self, exposure_data, cam): + @parameterized.expand(CAMERAS, names=("cam",)) + def test_camera_exposure(self, cam): lo, hi = EXPOSURE_RANGE - checks = exposure_data[cam] + checks = self.exposure_data[cam] assert len(checks) >= EXPOSURE_STABLE_COUNT, f"{cam}: only got {len(checks)} samples" # check that exposure converges into the valid range @@ -96,34 +97,34 @@ class TestCamerad: for i, (median, mean) in enumerate(checks): ok = _in_range(median, mean) if in_range and not ok: - pytest.fail(f"{cam}: exposure regressed on sample {i+1} " + + self.fail(f"{cam}: exposure regressed on sample {i+1} " + f"(median={median:.4f}, mean={mean:.4f}, expected: ({lo}, {hi}))") in_range = ok - def test_frame_skips(self, logs): + def test_frame_skips(self): for c in CAMERAS: - assert set(np.diff(logs[c]['frameId'])) == {1, }, f"{c} has frame skips" + assert set(np.diff(self.logs[c]['frameId'])) == {1, }, f"{c} has frame skips" - def test_frame_sync(self, logs): - SYNCED_CAMS = ('roadCameraState', 'wideRoadCameraState') - n = range(len(logs['roadCameraState']['t'][:-10])) + def test_frame_sync(self): + SYNCED_CAMS = ('narrowRoadCameraState', 'wideRoadCameraState') + n = range(len(self.logs['narrowRoadCameraState']['t'][:-10])) - frame_ids = {i: [logs[cam]['frameId'][i] for cam in CAMERAS] for i in n} + frame_ids = {i: [self.logs[cam]['frameId'][i] for cam in CAMERAS] for i in n} assert all(len(set(v)) == 1 for v in frame_ids.values()), "frame IDs not aligned" # road and wide cameras should be synced within 1.1ms - synced_times = {i: [logs[cam]['timestampSof'][i] for cam in SYNCED_CAMS] for i in n} + synced_times = {i: [self.logs[cam]['timestampSof'][i] for cam in SYNCED_CAMS] for i in n} diffs = {i: (max(ts) - min(ts))/1e6 for i, ts in synced_times.items()} laggy_frames = {k: v for k, v in diffs.items() if v > 1.1} assert len(laggy_frames) == 0, f"Frames not synced properly: {laggy_frames=}" - # driver camera should be staggered ~25ms from road camera + # cabin camera should be staggered ~25ms from road camera for i in n: - offset_ms = abs(logs['driverCameraState']['timestampSof'][i] - logs['roadCameraState']['timestampSof'][i]) / 1e6 - assert 20 < offset_ms < 30, f"driver camera stagger out of range at frame {i}: {offset_ms:.1f}ms (expected ~25ms)" + offset_ms = abs(self.logs['cabinCameraState']['timestampSof'][i] - self.logs['narrowRoadCameraState']['timestampSof'][i]) / 1e6 + assert 20 < offset_ms < 30, f"cabin camera stagger out of range at frame {i}: {offset_ms:.1f}ms (expected ~25ms)" - def test_sanity_checks(self, logs): - self._sanity_checks(logs) + def test_sanity_checks(self): + self._sanity_checks(self.logs) def _sanity_checks(self, ts): for c in CAMERAS: @@ -160,3 +161,7 @@ class TestCamerad: assert np.max([ np.max(np.diff(ts[c]['requestId'])) for c in CAMERAS ]) > 1 self._sanity_checks(ts) + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/system/camerad/webcam/README.md b/openpilot/system/camerad/webcam/README.md index 17d7ee6d80..7fa28a9919 100644 --- a/openpilot/system/camerad/webcam/README.md +++ b/openpilot/system/camerad/webcam/README.md @@ -1,7 +1,7 @@ # Run openpilot with webcam on PC ## Setup openpilot -- Follow [this readme](../README.md) to install and build the requirements +- Follow [this readme](/tools/README.md) to install and build the requirements ## Connect the hardware - Connect the camera first diff --git a/openpilot/system/camerad/webcam/camerad.py b/openpilot/system/camerad/webcam/camerad.py index c46482360d..4dfa5f3371 100755 --- a/openpilot/system/camerad/webcam/camerad.py +++ b/openpilot/system/camerad/webcam/camerad.py @@ -4,25 +4,26 @@ import os import platform from collections import namedtuple -from msgq.visionipc import VisionIpcServer, VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcServer from openpilot.cereal import messaging from openpilot.system.camerad.webcam.camera import Camera from openpilot.common.realtime import Ratekeeper -ROAD_CAM = os.getenv("ROAD_CAM", "0") +NARROW_ROAD_CAM = os.getenv("NARROW_ROAD_CAM", os.getenv("ROAD_CAM", "0")) WIDE_CAM = os.getenv("WIDE_CAM") DRIVER_CAM = os.getenv("DRIVER_CAM") CameraType = namedtuple("CameraType", ["msg_name", "stream_type", "cam_id"]) CAMERAS = [ - CameraType("roadCameraState", VisionStreamType.VISION_STREAM_ROAD, ROAD_CAM) + CameraType("narrowRoadCameraState", VisionStreamType.VISION_STREAM_NARROW_ROAD, NARROW_ROAD_CAM) ] if WIDE_CAM: CAMERAS.append(CameraType("wideRoadCameraState", VisionStreamType.VISION_STREAM_WIDE_ROAD, WIDE_CAM)) if DRIVER_CAM: - CAMERAS.append(CameraType("driverCameraState", VisionStreamType.VISION_STREAM_DRIVER, DRIVER_CAM)) + CAMERAS.append(CameraType("cabinCameraState", VisionStreamType.VISION_STREAM_CABIN, DRIVER_CAM)) class Camerad: def __init__(self): diff --git a/openpilot/system/hardware/chestnut/firmware_wrapped.bin b/openpilot/system/hardware/chestnut/firmware_wrapped.bin new file mode 100644 index 0000000000..1d738e4a75 Binary files /dev/null and b/openpilot/system/hardware/chestnut/firmware_wrapped.bin differ diff --git a/openpilot/system/hardware/chestnut/flash.py b/openpilot/system/hardware/chestnut/flash.py new file mode 100755 index 0000000000..32aa5a443a --- /dev/null +++ b/openpilot/system/hardware/chestnut/flash.py @@ -0,0 +1,576 @@ +#!/usr/bin/env python3 +"""chestnut (ASM2464) SPI flasher using data-USB EP0 control transfers.""" +import argparse +import ctypes +import errno +import fcntl +import glob +import hashlib +import os +import re +import signal +import struct +import sys +import time +import zlib +from pathlib import Path + +VID_PIDS = (("add1", "0001"), ("3801", "0001")) +ROM_VID_PIDS = (("174c", "2464"), ("174c", "2463")) +ROM_PRODUCT = "USB 3.2 PCIe TinyEnclosure" +FIRMWARE_PATH = Path(__file__).with_name("firmware_wrapped.bin") +CONFIG_DIR = "/data/chestnut_config" +PM_PATHS = ("/sys/bus/platform/devices/a600000.ssusb", "/sys/bus/usb/devices/usb4") +VBUS_PATH = "/sys/kernel/debug/regulator/smb2-vbus/enable" +IMAGE_OFFSET = 0x100 +SECTOR, PAGE = 4096, 128 +MAX_CODE_SIZE = 0x10000 +FLASH_BUDGET = 600.0 +USBDEVFS_CONTROL = 0xC0185500 +USBDEVFS_BULK = 0xC0185502 +USBDEVFS_SETINTERFACE = 0x80085504 +USBDEVFS_SETCONFIGURATION = 0x80045505 +USBDEVFS_CLAIMINTERFACE = 0x8004550F +USBDEVFS_RESET = 0x5514 +USBDEVFS_CLEAR_HALT = 0x80045515 + +_deadline = float("inf") + + +def check_budget(): + if time.monotonic() > _deadline: + raise TimeoutError(f"flash did not converge within {FLASH_BUDGET:g}s") + + +class Ctrl(ctypes.Structure): + _fields_ = [("request_type", ctypes.c_uint8), ("request", ctypes.c_uint8), + ("value", ctypes.c_uint16), ("index", ctypes.c_uint16), + ("length", ctypes.c_uint16), ("timeout", ctypes.c_uint32), + ("data", ctypes.c_void_p)] + + +class Bulk(ctypes.Structure): + _fields_ = [("ep", ctypes.c_uint), ("len", ctypes.c_uint), + ("timeout", ctypes.c_uint), ("data", ctypes.c_void_p)] + + +class RomFallback(Exception): + pass + + +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()) + if vid_pid in VID_PIDS + ROM_VID_PIDS: + found.append((d, vid_pid, open(d + "/product").read().strip())) + except OSError: + pass + if len(found) > 1: + raise RuntimeError(f"expected one chestnut, found {len(found)}") + return found[0] if found else (None, None, None) + + +def in_rom_bootloader(vid_pid, product): + # the ROM bootloader reports the config page strings, or its own when the config page is lost + return vid_pid in ROM_VID_PIDS or product == ROM_PRODUCT or (product or "").startswith("AS2462") + + +def disable_runtime_pm(path): + control = os.path.join(path, "power/control") + if not os.path.exists(control): + return + with open(control, "w") as f: + f.write("on\n") + if open(control).read().strip() != "on": + raise RuntimeError(f"could not disable USB runtime PM: {control}") + delay = os.path.join(path, "power/autosuspend_delay_ms") + if os.path.exists(delay): + with open(delay, "w") as f: + f.write("-1\n") + + +def unbind_drivers(path): + for interface in glob.glob(path + ":*"): + driver = interface + "/driver" + if os.path.islink(driver): + with open(os.path.realpath(driver) + "/unbind", "w") as f: + f.write(os.path.basename(interface)) + + +def open_device(path): + bus, dev = int(open(path + "/busnum").read()), int(open(path + "/devnum").read()) + return os.open(f"/dev/bus/usb/{bus:03d}/{dev:03d}", os.O_RDWR) + + +def link_up() -> bool: + # asm enumerates on USB-C alone, gpu is only usable once pcie link is up + try: + path, _, _ = find_chestnut() + if path is None: + return False + fd = open_device(path) + except (OSError, RuntimeError): + return False + try: + fcntl.ioctl(fd, USBDEVFS_CONTROL, Ctrl(0x40, 0xF3, 1, 0, 0, 2000, None)) + buf = (ctypes.c_ubyte * 1)() + fcntl.ioctl(fd, USBDEVFS_CONTROL, Ctrl(0xC0, 0xE4, 0xB450, 0, 1, 1000, ctypes.cast(buf, ctypes.c_void_p))) + return buf[0] == 0x78 # LTSSM L0 + except OSError: + return False + finally: + os.close(fd) + + +def claim_interface(path, setup=False): + # unbind usb-storage, which binds to the ROM bootloader + disable_runtime_pm(path) + unbind_drivers(path) + fd = open_device(path) + try: + if setup: + fcntl.ioctl(fd, USBDEVFS_SETCONFIGURATION, struct.pack("I", 1)) + fcntl.ioctl(fd, USBDEVFS_CLAIMINTERFACE, struct.pack("I", 0)) + if setup: + fcntl.ioctl(fd, USBDEVFS_SETINTERFACE, struct.pack("II", 0, 0)) + except OSError as e: + os.close(fd) + if e.errno == errno.EBUSY: + raise RuntimeError("chestnut is in use, stop modeld/GPU processes before flashing") from e + raise + return fd + + +class Flash: + def __init__(self): + self.fd = -1 + + def close(self): + if self.fd >= 0: + os.close(self.fd) + self.fd = -1 + + def connect(self, timeout=5.0): + self.close() + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + path, vid_pid, product = find_chestnut() + if in_rom_bootloader(vid_pid, product): + raise RomFallback("chestnut fell back to the ROM bootloader") + if path is not None: + self.fd = claim_interface(path) + return + time.sleep(0.1) + raise RuntimeError(f"chestnut did not enumerate within {timeout:g}s") + + def reg_write(self, addr, value): + fcntl.ioctl(self.fd, USBDEVFS_CONTROL, + Ctrl(0x40, 0xE5, addr & 0xFFFF, value & 0xFFFF, 0, 2000, None)) + + def reg_read(self, addr, length=1): + buf = (ctypes.c_ubyte * length)() + fcntl.ioctl(self.fd, USBDEVFS_CONTROL, + Ctrl(0xC0, 0xE4, addr & 0xFFFF, 0, length, 2000, ctypes.cast(buf, ctypes.c_void_p))) + return bytes(buf) + + def write_buffer(self, data): + for i, value in enumerate(data): + self.reg_write(0x7000 + i, value) + + def wait_controller(self, timeout=2.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not self.reg_read(0xC8A9)[0] & 1: + return + raise TimeoutError("flash controller timeout") + + def transaction(self, command, addr=0, length=0, addr_len=0x07, mode=0): + for reg, value in ((0xC8AD, mode), (0xC8AE, 0), (0xC8AF, 0), (0xC8AA, command), (0xC8AC, addr_len), + (0xC8A1, addr), (0xC8A2, addr >> 8), (0xC8AB, addr >> 16), (0xC8A3, length >> 8), (0xC8A4, length)): + self.reg_write(reg, value & 0xFF) + self.reg_write(0xC8A9, 1) + self.wait_controller() + for _ in range(4): + self.reg_write(0xC8AD, 0) + + def write_enable(self): + for reg, value in ((0xC8AD, 0), (0xC8AA, 0x06), (0xC8AC, 0x04), (0xC8A3, 0), (0xC8A4, 0), (0xC8A9, 1)): + self.reg_write(reg, value) + self.wait_controller() + + def status(self): + self.transaction(0x05, length=1, addr_len=0x04) + return self.reg_read(0x7000)[0] + + def wait_write_done(self, timeout=10.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not self.status() & 1: + return + time.sleep(0.005) + raise TimeoutError("SPI flash WIP timeout") + + def init(self): + self.reg_write(0xCC33, 0x04) + self.reg_write(0xCA81, self.reg_read(0xCA81)[0] | 1) + self.reg_write(0xC805, 0x02) + self.reg_write(0xC8A6, 0x04) + for _ in range(5): + self.write_enable() + self.write_buffer(bytes(4)) + self.transaction(0x01, length=1, addr_len=0x04, mode=1) + time.sleep(0.01) + if not self.status() & 0x1C: + return + raise RuntimeError("could not clear SPI block protection") + + def read(self, addr, length): + out = bytearray() + while len(out) < length: + n = min(4096, length - len(out)) + self.transaction(0x03, addr + len(out), max(4096, n)) + for off in range(0, n, 255): + out += self.reg_read(0x7000 + off, min(255, n - off)) + return bytes(out) + + def erase_sector(self, addr): + self.write_enable() + self.transaction(0x20, addr) + self.wait_write_done() + + def program(self, addr, data): + self.write_buffer(data + bytes((-len(data)) % 4)) + self.write_enable() + self.transaction(0x02, addr, len(data), mode=1) + self.wait_write_done() + + +def validate_image(data): + if len(data) < 10: + raise ValueError("wrapped firmware is too short") + body_len = int.from_bytes(data[:4], "little") + if body_len > MAX_CODE_SIZE: + raise ValueError(f"wrapped firmware body exceeds {MAX_CODE_SIZE} bytes") + if len(data) != body_len + 10 or data[4 + body_len] != 0xA5: + raise ValueError("invalid wrapped firmware length or magic") + body = data[4:4 + body_len] + if data[5 + body_len] != sum(body) & 0xFF: + raise ValueError("invalid wrapped firmware checksum") + if data[6 + body_len:] != zlib.crc32(body).to_bytes(4, "little"): + raise ValueError("invalid wrapped firmware CRC") + + +def image_product(image): + match = re.search(rb"custom [0-9a-f]{8}-CLEAN", image) + if match is None: + raise ValueError("no product string in wrapped firmware") + return match.group().decode() + + +def reconnect(flash): + attempt = 0 + while True: + attempt += 1 + check_budget() + try: + flash.connect() + flash.init() + return + except (OSError, TimeoutError, RuntimeError) as e: + print(f"waiting for chestnut (attempt {attempt}): {e}", flush=True) + time.sleep(1) + + +def with_retries(flash, label, operation): + # on any transfer error, reconnect and restart the operation + attempt = 0 + while True: + attempt += 1 + try: + return operation() + except (OSError, TimeoutError, RuntimeError) as e: + check_budget() + print(f"{label} attempt {attempt}: {e}", flush=True) + reconnect(flash) + + +def stable_read(flash, addr, length, count=2): + def read(): + reads = [flash.read(addr, length) for _ in range(count)] + if any(x != reads[0] for x in reads[1:]): + raise RuntimeError(f"unstable flash read at 0x{addr:05x}") + return reads[0] + return with_retries(flash, f"read 0x{addr:05x}", read) + + +def program_sector(flash, addr, target): + def program(): + flash.erase_sector(addr) + if flash.read(addr, SECTOR) != bytes([0xFF]) * SECTOR: + raise RuntimeError("sector erase verification failed") + for off in range(0, SECTOR, PAGE): + chunk = target[off:off + PAGE] + if chunk != bytes([0xFF]) * len(chunk): + flash.program(addr + off, chunk) + if flash.read(addr + off, len(chunk)) != chunk: + raise RuntimeError(f"page verify failed at 0x{addr + off:05x}") + if flash.read(addr, SECTOR) != target: + raise RuntimeError("sector verification failed") + with_retries(flash, f"sector 0x{addr:05x}", program) + + +def config_path(): + return os.path.join(CONFIG_DIR, f"{os.uname().nodename}.bin") + + +def saved_config(path, data): + os.makedirs(os.path.dirname(path), exist_ok=True) + try: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError as e: + backup = open(path, "rb").read() + if len(backup) != 0x100: + raise RuntimeError(f"invalid config backup: {path}") from e + if backup != data: + print(f"restoring config from {path}", flush=True) + return backup + with os.fdopen(fd, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + return data + + +def rom_write(image, config): + # the ROM bootloader implements only the BOT protocol, and requires a port reset before bulk transfers + path, _, _ = find_chestnut() + if path is None: + raise RuntimeError("chestnut disappeared before recovery") + unbind_drivers(path) + fd = open_device(path) + try: + fcntl.ioctl(fd, USBDEVFS_RESET) + finally: + os.close(fd) + time.sleep(3) + path, _, _ = find_chestnut() + if path is None: + raise RuntimeError("chestnut did not re-enumerate after reset") + fd = claim_interface(path, setup=True) + for ep in (0x02, 0x81): + fcntl.ioctl(fd, USBDEVFS_CLEAR_HALT, struct.pack("I", ep)) + tag = 0 + + def bulk(ep, payload, timeout): + buf = ctypes.create_string_buffer(bytes(payload), len(payload)) + fcntl.ioctl(fd, USBDEVFS_BULK, Bulk(ep, len(payload), timeout, ctypes.cast(buf, ctypes.c_void_p))) + return buf.raw + + def cmd(cdb, data=b"", timeout=30000): + nonlocal tag + tag += 1 + bulk(0x02, struct.pack("BBB12x", 0xE1, 0x50, 0), config[:0x80]) + cmd(struct.pack(">BBB12x", 0xE1, 0x50, 1), config[0x80:]) + cmd(struct.pack(">BBI", 0xE3, 0x50, min(len(image), 0xFF00)), image[:0xFF00]) + if len(image) > 0xFF00: + cmd(struct.pack(">BBI", 0xE3, 0xD0, len(image) - 0xFF00), image[0xFF00:]) + cmd(struct.pack(">BB13x", 0xE8, 0x51)) + finally: + os.close(fd) + print("recovery flash done", flush=True) + + +def vbus_write(value): + try: + with open(VBUS_PATH, "w") as f: + f.write(value + "\n") + except OSError: + pass + + +def vbus_cycle(): + if os.path.exists(VBUS_PATH): + vbus_write("0") + time.sleep(2) + vbus_write("1") + time.sleep(5) + + +def activate(expected_product): + if not os.path.exists(VBUS_PATH): + print("no VBUS control, firmware activates on the next chestnut power cycle", flush=True) + return + print("power-cycling chestnut VBUS", flush=True) + vbus_write("0") + disconnected = False + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + path, _, _ = find_chestnut() + if path is None: + disconnected = True + break + time.sleep(0.2) + time.sleep(1) + vbus_write("1") + if not disconnected: + print("chestnut stayed powered, firmware activates on its next power cycle", flush=True) + return + deadline = time.monotonic() + 15.0 + while time.monotonic() < deadline: + _, _, product = find_chestnut() + if product is not None: + if product == expected_product: + print(f"activated {expected_product}", flush=True) + else: + print(f"chestnut re-enumerated with {product!r}, firmware activates on its next power cycle", flush=True) + return + time.sleep(0.2) + print("chestnut did not re-enumerate, firmware activates on its next power cycle", flush=True) + + +def defer_signal(signum, _frame): + # writing from a handler must not reenter a print already in progress + os.write(1, f"signal {signum} deferred until the chestnut is powered back up\n".encode()) + + +def flash_chestnut(expected_version=None, force=False): + global _deadline + + image = FIRMWARE_PATH.read_bytes() + validate_image(image) + expected_product = image_product(image) + if expected_version is not None and expected_product != f"custom {expected_version}-CLEAN": + raise RuntimeError(f"bundled firmware is {expected_product!r}, expected version {expected_version}") + + path, vid_pid, product = find_chestnut() + if path is None: + print("no chestnut connected", flush=True) + return + if product == expected_product and not force: + print(f"chestnut firmware is up to date ({expected_product})", flush=True) + return + + _deadline = time.monotonic() + FLASH_BUDGET + for pm_path in PM_PATHS: + disable_runtime_pm(pm_path) + + previous = {sig: signal.signal(sig, defer_signal) for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)} + try: + if in_rom_bootloader(vid_pid, product): + if not recover_from_rom(image, expected_product): + return + # firmware is back, verify it against the bundled image + force, product = True, None + write_image(image, expected_product, product, force) + finally: + for sig, handler in previous.items(): + signal.signal(sig, handler) + + +def recover_from_rom(image, expected_product): + # returns whether the chestnut came back on custom firmware + backup = config_path() + if not os.path.isfile(backup): + raise RuntimeError(f"cannot recover from the ROM bootloader without a config backup at {backup}") + config = open(backup, "rb").read() + if len(config) != 0x100: + raise RuntimeError(f"invalid config backup: {backup}") + + committed = False + while True: + check_budget() + path, vid_pid, product = find_chestnut() + if path is None: + if committed: + print("chestnut is offline, recovered firmware boots on its next power cycle", flush=True) + return False + vbus_cycle() + continue + if not in_rom_bootloader(vid_pid, product): + return True + if committed: + print("chestnut stayed powered, recovered firmware boots on its next power cycle", flush=True) + return False + try: + rom_write(image, config) + committed = True + except (OSError, TimeoutError, RuntimeError) as e: + print(f"ROM recovery failed, retrying: {e}", flush=True) + vbus_cycle() + continue + activate(expected_product) + + +def write_image(image, expected_product, product, force): + if force: + print(f"forced reflash of {expected_product}", flush=True) + else: + print(f"chestnut firmware mismatch: {product!r}; expected {expected_product!r}", flush=True) + + flash = Flash() + try: + reconnect(flash) + config = stable_read(flash, 0, 0x100, 3) + config = saved_config(config_path(), config) + image_end = IMAGE_OFFSET + len(image) + first_sector = IMAGE_OFFSET & ~(SECTOR - 1) + span = (image_end + SECTOR - 1) & ~(SECTOR - 1) + current = stable_read(flash, first_sector, span - first_sector) + target = bytearray(current) + target[:len(config)] = config + target[IMAGE_OFFSET - first_sector:image_end - first_sector] = image + target = bytes(target) + print(f"target {len(image)} bytes at 0x{IMAGE_OFFSET:05x}, sha256={hashlib.sha256(image).hexdigest()}", flush=True) + + for addr in range(first_sector, span, SECTOR): + off = addr - first_sector + wanted = target[off:off + SECTOR] + if current[off:off + SECTOR] == wanted: + print(f"sector 0x{addr:05x}: unchanged", flush=True) + else: + print(f"sector 0x{addr:05x}: programming", flush=True) + program_sector(flash, addr, wanted) + + verified = stable_read(flash, first_sector, span - first_sector, 3) + if verified != target: + raise RuntimeError("final full-image verification failed") + print(f"verified sha256={hashlib.sha256(verified).hexdigest()}", flush=True) + finally: + flash.close() + + activate(expected_product) + + +def main(): + parser = argparse.ArgumentParser(description="check and flash the bundled chestnut firmware") + parser.add_argument("version", nargs="?", help="expected firmware version hash") + parser.add_argument("--force", action="store_true", help="reflash even when the version matches") + args = parser.parse_args() + if os.geteuid() != 0: + raise RuntimeError("flash.py must run as root") + flash_chestnut(expected_version=args.version, force=args.force) + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(f"FAIL: {type(e).__name__}: {e}", file=sys.stderr) + sys.exit(1) diff --git a/openpilot/system/hardware/comma/agnos.json b/openpilot/system/hardware/comma/agnos.json new file mode 120000 index 0000000000..b1465ff49d --- /dev/null +++ b/openpilot/system/hardware/comma/agnos.json @@ -0,0 +1 @@ +../../../common/hardware/comma/agnos.json \ No newline at end of file diff --git a/openpilot/system/hardware/hardwared.py b/openpilot/system/hardware/hardwared.py index e5b3d8020c..eef5db2b78 100755 --- a/openpilot/system/hardware/hardwared.py +++ b/openpilot/system/hardware/hardwared.py @@ -3,6 +3,8 @@ import fcntl import os import queue import struct +import subprocess +import sys import threading import time from collections import OrderedDict, namedtuple @@ -15,8 +17,9 @@ from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.params import Params from openpilot.common.realtime import DT_HW from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert -from openpilot.common.hardware import HARDWARE, TICI -from openpilot.common.hardware.usb import get_usb_state, set_usb_state +from openpilot.common.hardware import HARDWARE, COMMA_HARDWARE +from openpilot.common.basedir import BASEDIR +from openpilot.common.hardware.usb import CHESTNUT_FW_VERSION, CHESTNUT_ROM_USB_IDS, CHESTNUT_USB_IDS, get_usb_state, get_usb_topology, set_usb_state from openpilot.common.linux import LinuxSystemStats from openpilot.system.loggerd.config import get_available_percent from openpilot.common.swaglog import cloudlog @@ -35,6 +38,44 @@ DISCONNECT_TIMEOUT = 5. # wait 5 seconds before going offroad after disconnect PANDA_STATES_TIMEOUT = round(1000 / SERVICE_LIST['pandaStates'].frequency * 1.5) # 1.5x the expected pandaState frequency ONROAD_CYCLE_TIME = 1 # seconds to wait offroad after requesting an onroad cycle +class Chestnut: + # flash offroad, modeld ignores chestnut until the product string matches + MAX_ATTEMPTS = 3 + RETRY_INTERVAL = 20. + + def __init__(self): + self.thread: threading.Thread | None = None + self.attempts = 0 + self.last_attempt = 0. + self.flashed = False + + def flash(self) -> None: + ret = subprocess.run(["sudo", sys.executable, os.path.join(BASEDIR, "openpilot/system/hardware/chestnut/flash.py"), CHESTNUT_FW_VERSION], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=False) + cloudlog.event("chestnut flash done", returncode=ret.returncode, output=ret.stdout[-1000:], error=ret.returncode != 0) + self.flashed = ret.returncode == 0 + + def update(self, offroad: bool, usb_state: list[dict]) -> None: + mismatch = any((d["vendorId"], d["productId"]) in CHESTNUT_USB_IDS + CHESTNUT_ROM_USB_IDS and + d["product"] != f"custom {CHESTNUT_FW_VERSION}-CLEAN" for d in usb_state) + if not mismatch: + self.flashed = False + return + + if not offroad or self.flashed or self.attempts >= self.MAX_ATTEMPTS: + return + if self.thread is not None and self.thread.is_alive(): + return + if time.monotonic() - self.last_attempt < self.RETRY_INTERVAL: + return + + self.attempts += 1 + self.last_attempt = time.monotonic() + cloudlog.warning(f"chestnut firmware out of date, flashing (attempt {self.attempts})") + self.thread = threading.Thread(target=self.flash, daemon=True) + self.thread.start() + + ThermalBand = namedtuple("ThermalBand", ['min_temp', 'max_temp']) HardwareState = namedtuple("HardwareState", ['network_type', 'network_info', 'network_strength', 'network_stats', 'network_metered', 'modem_temps', 'usb_state']) @@ -106,10 +147,15 @@ def hw_state_thread(end_event, hw_queue): """Handles non critical hardware state, and sends over queue""" count = 0 prev_hw_state = None + prev_usb_topology = set() while not end_event.is_set(): - # these are expensive calls. update every 10s - if (count % int(10. / DT_HW)) == 0: + usb_topology = get_usb_topology() + usb_changed = usb_topology != prev_usb_topology + + # these are expensive calls. update every 10s or when USB devices change + if (count % int(10. / DT_HW)) == 0 or usb_changed: + prev_usb_topology = usb_topology try: network_type = HARDWARE.get_network_type() modem_temps = HARDWARE.get_modem_temperatures() @@ -191,6 +237,7 @@ def hardware_thread(end_event, hw_queue) -> None: thermal_config = HARDWARE.get_thermal_config() fan_controller = FanController(int(1./DT_HW)) + chestnut = Chestnut() while not end_event.is_set(): sm.update(PANDA_STATES_TIMEOUT) @@ -251,6 +298,7 @@ def hardware_thread(end_event, hw_queue) -> None: msg.deviceState.screenBrightnessPercent = HARDWARE.get_screen_brightness() set_usb_state(msg.deviceState, last_hw_state.usb_state) + chestnut.update(started_ts is None, last_hw_state.usb_state) # this subset is only used for offroad temp_sources = [ @@ -309,7 +357,7 @@ def hardware_thread(end_event, hw_queue) -> None: # - TIZI, or # - TICI and channel_type is "tici" build_metadata = get_build_metadata() - is_unsupported_combo = TICI and HARDWARE.get_device_type() == "tici" and build_metadata.channel_type != "tici" + is_unsupported_combo = COMMA_HARDWARE and HARDWARE.get_device_type() == "tici" and build_metadata.channel_type != "tici" startup_conditions["not_tici"] = not is_unsupported_combo onroad_conditions["not_tici"] = not is_unsupported_combo set_offroad_alert("Offroad_TiciSupport", is_unsupported_combo, extra_text=build_metadata.channel) @@ -465,7 +513,7 @@ def main(): threading.Thread(target=hardware_thread, args=(end_event, hw_queue)), ] - if TICI: + if COMMA_HARDWARE: threads.append(threading.Thread(target=touch_thread, args=(end_event,))) for t in threads: diff --git a/openpilot/system/hardware/power_monitoring.py b/openpilot/system/hardware/power_monitoring.py index bd9c334d04..8dcd0fa6dc 100644 --- a/openpilot/system/hardware/power_monitoring.py +++ b/openpilot/system/hardware/power_monitoring.py @@ -106,18 +106,10 @@ class PowerMonitoring: # Max Time Offroad def max_time_offroad_exceeded(self, offroad_time): - """ - Check if the max time offroad has been exceeded. If the value is 0, it means no limit. - :param offroad_time: Time spent offroad in seconds - :return: True if the max time offroad has been exceeded, False otherwise - """ - try: - param = self.params.get("MaxTimeOffroad") - sp_max_time_val_s = param * 60 if param is not None and param >= 0 else MAX_TIME_OFFROAD_S - except Exception: - sp_max_time_val_s = MAX_TIME_OFFROAD_S - - return 0 < sp_max_time_val_s <= offroad_time + param = self.params.get("MaxTimeOffroad") # minutes, 0 = no limit + if param is not None and param >= 0: + return 0 < param * 60 <= offroad_time + return offroad_time > MAX_TIME_OFFROAD_S # See if we need to shutdown def should_shutdown(self, ignition: bool, in_car: bool, offroad_timestamp: float | None, started_seen: bool): diff --git a/openpilot/system/hardware/tests/test_fan_controller.py b/openpilot/system/hardware/tests/test_fan_controller.py index e1aceeb081..63f9967101 100644 --- a/openpilot/system/hardware/tests/test_fan_controller.py +++ b/openpilot/system/hardware/tests/test_fan_controller.py @@ -1,10 +1,11 @@ -import pytest +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.system.hardware.fan_controller import FanController ALL_CONTROLLERS = [FanController] -class TestFanController: +class TestFanController(OpenpilotTestCase): def wind_up(self, controller, ignition=True): for _ in range(1000): controller.update(100, ignition) @@ -13,31 +14,31 @@ class TestFanController: for _ in range(1000): controller.update(10, ignition) - @pytest.mark.parametrize("controller_class", ALL_CONTROLLERS) + @parameterized.expand(ALL_CONTROLLERS) def test_hot_onroad(self, controller_class): controller = controller_class(2) self.wind_up(controller) assert controller.update(100, True) >= 70 - @pytest.mark.parametrize("controller_class", ALL_CONTROLLERS) + @parameterized.expand(ALL_CONTROLLERS) def test_offroad_limits(self, controller_class): controller = controller_class(2) self.wind_up(controller) assert controller.update(100, False) <= 30 - @pytest.mark.parametrize("controller_class", ALL_CONTROLLERS) + @parameterized.expand(ALL_CONTROLLERS) def test_no_fan_wear(self, controller_class): controller = controller_class(2) self.wind_down(controller) assert controller.update(10, False) == 0 - @pytest.mark.parametrize("controller_class", ALL_CONTROLLERS) + @parameterized.expand(ALL_CONTROLLERS) def test_limited(self, controller_class): controller = controller_class(2) self.wind_up(controller, True) assert controller.update(100, True) == 100 - @pytest.mark.parametrize("controller_class", ALL_CONTROLLERS) + @parameterized.expand(ALL_CONTROLLERS) def test_windup_speed(self, controller_class): controller = controller_class(2) self.wind_down(controller, True) diff --git a/openpilot/system/hardware/tests/test_power_monitoring.py b/openpilot/system/hardware/tests/test_power_monitoring.py index 12057ab837..6b254bbf57 100644 --- a/openpilot/system/hardware/tests/test_power_monitoring.py +++ b/openpilot/system/hardware/tests/test_power_monitoring.py @@ -1,5 +1,5 @@ -import pytest - +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.common.params import Params from openpilot.system.hardware.power_monitoring import PowerMonitoring, CAR_BATTERY_CAPACITY_uWh, \ CAR_CHARGING_RATE_W, VBATT_PAUSE_CHARGING, DELAY_SHUTDOWN_TIME_S, MAX_TIME_OFFROAD_S @@ -11,6 +11,10 @@ def mock_time_monotonic(): ssb += 1. return ssb +def set_mock_time(value): + global ssb + ssb = value + TEST_DURATION_S = 50 GOOD_VOLTAGE = 12 * 1e3 VOLTAGE_BELOW_PAUSE_CHARGING = (VBATT_PAUSE_CHARGING - 1) * 1e3 @@ -22,13 +26,9 @@ def pm_patch(mocker, name, value, constant=False): mocker.patch(f"openpilot.system.hardware.power_monitoring.{name}", return_value=value) -@pytest.fixture(autouse=True) -def mock_time(mocker): - mocker.patch("time.monotonic", mock_time_monotonic) - - -class TestPowerMonitoring: +class TestPowerMonitoring(OpenpilotTestCase): def setup_method(self): + self._fixture("mocker").patch("time.monotonic", mock_time_monotonic) self.params = Params() # Test to see that it doesn't do anything when pandaState is None @@ -110,10 +110,9 @@ class TestPowerMonitoring: pm.car_battery_capacity_uWh = CAR_BATTERY_CAPACITY_uWh start_time = ssb ignition = False - while ssb <= start_time + MOCKED_MAX_OFFROAD_TIME: - pm.calculate(GOOD_VOLTAGE, ignition) - if (ssb - start_time) % 1000 == 0 and ssb < start_time + MOCKED_MAX_OFFROAD_TIME: - assert not pm.should_shutdown(ignition, True, start_time, False) + set_mock_time(start_time + MOCKED_MAX_OFFROAD_TIME - 1) + assert not pm.should_shutdown(ignition, True, start_time, False) + set_mock_time(start_time + MOCKED_MAX_OFFROAD_TIME) assert pm.should_shutdown(ignition, True, start_time, False) def test_car_voltage(self, mocker): @@ -188,18 +187,16 @@ class TestPowerMonitoring: started_seen = True pm.calculate(VOLTAGE_BELOW_PAUSE_CHARGING, ignition) - while ssb < offroad_timestamp + DELAY_SHUTDOWN_TIME_S: - assert not pm.should_shutdown(ignition, in_car, - offroad_timestamp, - started_seen), \ - f"Should not shutdown before {DELAY_SHUTDOWN_TIME_S} seconds offroad time" + set_mock_time(offroad_timestamp + DELAY_SHUTDOWN_TIME_S - 1) + assert not pm.should_shutdown(ignition, in_car, offroad_timestamp, started_seen), \ + f"Should not shutdown before {DELAY_SHUTDOWN_TIME_S} seconds offroad time" + set_mock_time(offroad_timestamp + DELAY_SHUTDOWN_TIME_S) assert pm.should_shutdown(ignition, in_car, offroad_timestamp, started_seen), \ f"Should shutdown after {DELAY_SHUTDOWN_TIME_S} seconds offroad time" - @pytest.mark.parametrize( - "max_time_offroad, offroad_time_min, expected_result", + @parameterized.expand( [ # No max time set – fallback to default (30 hours) (None, 0, False), diff --git a/openpilot/system/hardware/tici/agnos.json b/openpilot/system/hardware/tici/agnos.json index ffee992f25..87cb197052 120000 --- a/openpilot/system/hardware/tici/agnos.json +++ b/openpilot/system/hardware/tici/agnos.json @@ -1 +1 @@ -../../../common/hardware/tici/agnos.json \ No newline at end of file +../comma/agnos.json \ No newline at end of file diff --git a/openpilot/system/loggerd/SConscript b/openpilot/system/loggerd/SConscript index 7f6d4faf0c..6890c29655 100644 --- a/openpilot/system/loggerd/SConscript +++ b/openpilot/system/loggerd/SConscript @@ -4,8 +4,8 @@ libs = [common, messaging, visionipc] + ffmpeg_libs + ['pthread', 'm', 'zstd'] frameworks = [] src = ['logger.cc', 'zstd_writer.cc', 'video_writer.cc', 'encoder/encoder.cc', 'encoder/jpeg_encoder.cc'] -if arch == "larch64": - src += ['encoder/v4l_encoder.cc'] +if arch == "comma_arm64": + src += ['clip_encoder.cc', 'encoder/v4l_encoder.cc', 'encoder/v4l_decoder.cc'] else: src += ['encoder/ffmpeg_encoder.cc'] if arch == "Darwin": @@ -17,6 +17,3 @@ libs.insert(0, logger_lib) env.Program('loggerd', ['loggerd.cc'], LIBS=libs, FRAMEWORKS=frameworks) env.Program('encoderd', ['encoderd.cc'], LIBS=libs, FRAMEWORKS=frameworks) env.Program('bootlog.cc', LIBS=libs, FRAMEWORKS=frameworks) - -if GetOption('extras'): - env.Program('tests/test_logger', ['tests/test_runner.cc', 'tests/test_logger.cc', 'tests/test_zstd_writer.cc'], LIBS=libs) diff --git a/openpilot/system/loggerd/clip_encoder.cc b/openpilot/system/loggerd/clip_encoder.cc new file mode 100644 index 0000000000..7e8f3a9026 --- /dev/null +++ b/openpilot/system/loggerd/clip_encoder.cc @@ -0,0 +1,290 @@ +#include "system/loggerd/clip_encoder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +} + +#include "common/swaglog.h" +#include "system/loggerd/encoder/v4l_decoder.h" +#include "system/loggerd/encoder/v4l_encoder.h" +#include "system/loggerd/loggerd.h" +#include "system/loggerd/video_writer.h" + +namespace { + +constexpr double SEGMENT_DURATION = 60.0; +constexpr int CLIP_FPS = 20; +constexpr double PARALLEL_CLIP_MIN_DURATION = 2 * SEGMENT_DURATION; + +const EncoderInfo clip_encoder_info = { + .publish_name = "livestreamNarrowRoadEncodeData", + .record = false, + .fps = CLIP_FPS, + .get_settings = [](int) { return EncoderSettings::StreamEncoderSettings(); }, + INIT_ENCODE_FUNCTIONS(LivestreamNarrowRoadEncode), +}; + +bool open_input(const std::string &path, AVFormatContext **ctx, int *stream_index) { + if (avformat_open_input(ctx, path.c_str(), nullptr, nullptr) < 0 || + avformat_find_stream_info(*ctx, nullptr) < 0 || + (*stream_index = av_find_best_stream(*ctx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0)) < 0) { + LOGE("failed to open clip input %s", path.c_str()); + avformat_close_input(ctx); + return false; + } + return true; +} + +void remove_file(const std::string &path) { + std::error_code error; + std::filesystem::remove(path, error); +} + +int encode_clip_worker(const std::vector &inputs, int width, int height, + double start_time, double duration, int bitrate, int speedup, + int64_t frame_offset, int64_t *encoded_frames, + V4LEncoder::PacketCallback packet_callback) try { + EncoderInfo encoder_info = clip_encoder_info; + encoder_info.get_settings = [bitrate](int) { + return EncoderSettings{.encode_type = cereal::EncodeIndex::Type::QCAMERA_H264, + .bitrate = bitrate, .gop_size = 5}; + }; + V4LDecoder decoder; + V4LEncoder::Options options = { + .packet_callback = std::move(packet_callback), + .input_format = V4L2_PIX_FMT_NV12_UBWC, + .input_done_callback = [&decoder](VisionBuf *buf) { decoder.releaseFrame(buf); }, + .max_performance = true, + }; + V4LEncoder encoder(encoder_info, width, height, std::move(options)); + encoder.encoder_open(); + if (!decoder.init(V4LDecoder::DEVICE, width, height, V4L2_PIX_FMT_HEVC, true, V4L2_PIX_FMT_NV12_UBWC)) return 1; + + const int64_t first_frame = std::floor(start_time * CLIP_FPS); + const int64_t end_frame = std::ceil((start_time + duration) * CLIP_FPS); + int64_t input_frame = 0; + int64_t output_frame = 0; + int64_t received_frames = 0; + bool failed = false; + auto pump_decoder = [&](int timeout_ms) { + V4LDecodedFrame frame; + if (!decoder.pump(frame, timeout_ms)) return false; + if (!frame.buf) return true; + ++received_frames; + const int64_t source_frame = (int64_t)frame.token - 1; + if (source_frame < first_frame) { + decoder.releaseFrame(frame.buf); + return true; + } + if ((frame_offset + source_frame - first_frame) % speedup != 0) { + decoder.releaseFrame(frame.buf); + return true; + } + + VisionIpcBufExtra extra = {}; + extra.frame_id = output_frame; + extra.timestamp_sof = output_frame * 1000000000ULL / CLIP_FPS; + extra.timestamp_eof = extra.timestamp_sof; + if (encoder.encode_frame(frame.buf, &extra) < 0) { + decoder.releaseFrame(frame.buf); + return false; + } + + ++output_frame; + return true; + }; + + for (size_t input_index = 0; input_index < inputs.size(); ++input_index) { + const std::string &input = inputs[input_index]; + const int64_t segment_start_frame = input_frame; + AVFormatContext *ctx = nullptr; + int stream_index = -1; + if (!open_input(input, &ctx, &stream_index)) { failed = true; break; } + AVPacket packet = {}; + while (input_frame < end_frame && av_read_frame(ctx, &packet) >= 0) { + if (packet.stream_index != stream_index) { + av_packet_unref(&packet); + continue; + } + if (packet.size <= 0 || (size_t)packet.size > decoder.maxPacketSize()) { + LOGE("decoder packet too large: %d > %zu", packet.size, decoder.maxPacketSize()); + av_packet_unref(&packet); + failed = true; + break; + } + + // Keep several compressed packets in flight so the firmware can sustain + // decode/encode overlap and does not downclock due to a shallow queue. + while (!decoder.queuePacket(&packet, input_frame + 1)) { + if (!pump_decoder(-1)) { + failed = true; + break; + } + } + av_packet_unref(&packet); + if (failed) break; + + ++input_frame; + } + av_packet_unref(&packet); + avformat_close_input(&ctx); + // Only the final loggerd segment may be shorter than SEGMENT_DURATION. A + // short intermediate segment would silently close a gap in the source. + if (!failed && input_frame < end_frame && input_index + 1 < inputs.size() && + input_frame - segment_start_frame < static_cast(SEGMENT_DURATION * CLIP_FPS)) { + failed = true; + } + if (failed || input_frame >= end_frame) break; + } + + if (!failed) decoder.sendEOS(); + for (int empty_polls = 0; !failed && received_frames < input_frame;) { + const int64_t before = received_frames; + failed = !pump_decoder(1000); + empty_polls = received_frames == before ? empty_polls + 1 : 0; + if (empty_polls == 5) failed = true; + } + + encoder.encoder_close(); + const int64_t source_frames = std::max(0, std::min(input_frame, end_frame) - first_frame); + const int64_t first_output_frame = (speedup - frame_offset % speedup) % speedup; + const int64_t expected_output_frames = first_output_frame < source_frames ? + 1 + (source_frames - first_output_frame - 1) / speedup : 0; + if (failed || source_frames == 0 || output_frame != expected_output_frames) { + LOGE("clip failed: input=%lld/%lld decoded=%lld encoded=%lld/%lld", + (long long)input_frame, (long long)end_frame, (long long)received_frames, + (long long)output_frame, (long long)expected_output_frames); + return 1; + } + *encoded_frames = output_frame; + return 0; +} catch (const std::exception &e) { + LOGE("clip worker failed: %s", e.what()); + return 1; +} + +struct SpoolPacket { + uint32_t size; + int64_t timestamp; + bool keyframe; +}; + +} // namespace + +int encode_clip(const std::vector &inputs, const std::string &output, + double start_time, double duration, int bitrate, int speedup, + const std::string &metadata) { + if (inputs.empty() || !std::isfinite(start_time) || !std::isfinite(duration) || + start_time < 0 || duration <= 0 || bitrate <= 0 || speedup <= 0) { + return 1; + } + + // Inputs are consecutive loggerd segments. Skip whole files before the clip + // so a late start does not spend hardware time decoding discarded minutes. + const double available_duration = inputs.size() * SEGMENT_DURATION; + if (start_time >= available_duration || duration > available_duration - start_time) return 1; + const size_t skipped_segments = start_time / SEGMENT_DURATION; + const std::vector clip_inputs(inputs.begin() + skipped_segments, inputs.end()); + const double local_start = start_time - skipped_segments * SEGMENT_DURATION; + + AVFormatContext *ctx = nullptr; + int stream = -1; + if (!open_input(clip_inputs.front(), &ctx, &stream)) return 1; + AVCodecParameters *codec = ctx->streams[stream]->codecpar; + const int width = codec->width, height = codec->height; + const bool valid_codec = codec->codec_id == AV_CODEC_ID_HEVC && width > 0 && height > 0; + avformat_close_input(&ctx); + if (!valid_codec) return 1; + + std::filesystem::path output_path(output); + const std::string output_dir = output_path.has_parent_path() ? output_path.parent_path() : "."; + auto writer = std::make_unique(output_dir.c_str(), output_path.filename().c_str(), true, + width, height, CLIP_FPS, cereal::EncodeIndex::Type::QCAMERA_H264); + if (!metadata.empty()) writer->set_metadata("ai.comma.clip.settings", metadata.c_str()); + V4LEncoder::PacketCallback write_packet = [&writer](uint8_t *data, size_t size, int64_t timestamp, + bool config, bool keyframe) { + writer->write(data, size, timestamp, config, keyframe); + }; + + if (clip_inputs.size() < 2 || duration < PARALLEL_CLIP_MIN_DURATION) { + int64_t encoded_frames = 0; + const bool success = encode_clip_worker(clip_inputs, width, height, local_start, duration, + bitrate, speedup, 0, &encoded_frames, write_packet) == 0; + if (!success) { + writer.reset(); + remove_file(output); + } + return success ? 0 : 1; + } + + const size_t split = std::clamp(std::llround((local_start + duration / 2) / SEGMENT_DURATION), + 1, clip_inputs.size() - 1); + const double split_time = split * SEGMENT_DURATION; + const std::array, 2> shard_inputs = { + std::vector(clip_inputs.begin(), clip_inputs.begin() + split), + std::vector(clip_inputs.begin() + split, clip_inputs.end()), + }; + const std::array shard_starts = {local_start, 0}; + const std::array shard_durations = { + split_time - local_start, local_start + duration - split_time, + }; + const std::string spool_path = output + ".encoderd-" + std::to_string(getpid()) + ".tmp"; + FILE *spool = fopen(spool_path.c_str(), "w+b"); + if (!spool) { + writer.reset(); + remove_file(output); + return 1; + } + remove_file(spool_path); + bool spool_ok = true; + V4LEncoder::PacketCallback spool_packet = [&](uint8_t *data, size_t size, int64_t timestamp, + bool config, bool keyframe) { + if (config) return; + const SpoolPacket packet = {(uint32_t)size, timestamp, keyframe}; + spool_ok &= fwrite(&packet, sizeof(packet), 1, spool) == 1 && fwrite(data, 1, size, spool) == size; + }; + std::array results = {1, 1}; + std::array encoded_frames = {}; + const std::array frame_offsets = { + 0, (int64_t)std::llround(split_time * CLIP_FPS) - (int64_t)std::floor(local_start * CLIP_FPS), + }; + std::array workers; + + for (size_t i = 0; i < workers.size(); ++i) { + workers[i] = std::thread([&, i]() { + results[i] = encode_clip_worker(shard_inputs[i], width, height, shard_starts[i], shard_durations[i], + bitrate, speedup, frame_offsets[i], &encoded_frames[i], + i == 0 ? write_packet : spool_packet); + }); + } + for (std::thread &worker : workers) worker.join(); + + rewind(spool); + SpoolPacket packet; + std::vector data; + const int64_t timestamp_offset = encoded_frames[0] * 1000000 / CLIP_FPS; + while (spool_ok && fread(&packet, sizeof(packet), 1, spool) == 1) { + data.resize(packet.size); + spool_ok = fread(data.data(), 1, data.size(), spool) == data.size(); + if (spool_ok) writer->write(data.data(), data.size(), packet.timestamp + timestamp_offset, false, packet.keyframe); + } + fclose(spool); + bool success = results[0] == 0 && results[1] == 0 && spool_ok; + if (!success) { + writer.reset(); + remove_file(output); + } + return success ? 0 : 1; +} diff --git a/openpilot/system/loggerd/clip_encoder.h b/openpilot/system/loggerd/clip_encoder.h new file mode 100644 index 0000000000..5bd01b0c6c --- /dev/null +++ b/openpilot/system/loggerd/clip_encoder.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include + +// inputs are consecutive 60-second loggerd HEVC segments; start_time is +// relative to the beginning of the first input. +int encode_clip(const std::vector &inputs, const std::string &output, + double start_time, double duration, int bitrate = 5'000'000, + int speedup = 1, const std::string &metadata = {}); diff --git a/openpilot/system/loggerd/deleter.py b/openpilot/system/loggerd/deleter.py index d5c12474f8..51bac4ea23 100755 --- a/openpilot/system/loggerd/deleter.py +++ b/openpilot/system/loggerd/deleter.py @@ -47,6 +47,32 @@ def get_preserved_segments(dirs_by_creation: list[str]) -> set[str]: return preserved +def deleter_step() -> tuple[bool, str | None]: + out_of_bytes = get_available_bytes(default=MIN_BYTES + 1) < MIN_BYTES + out_of_percent = get_available_percent(default=MIN_PERCENT + 1) < MIN_PERCENT + out_of_space = out_of_percent or out_of_bytes + if not out_of_space: + return False, None + + dirs = listdir_by_creation(Paths.log_root()) + preserved_dirs = get_preserved_segments(dirs) + + # remove the earliest directory we can + for delete_dir in sorted(dirs, key=lambda d: (d in DELETE_LAST, d in preserved_dirs)): + delete_path = os.path.join(Paths.log_root(), delete_dir) + + if any(name.endswith(".lock") for name in os.listdir(delete_path)): + continue + + try: + cloudlog.info(f"deleting {delete_path}") + shutil.rmtree(delete_path) + return True, delete_path + except OSError: + cloudlog.exception(f"issue deleting {delete_path}") + return True, None + + def deleter_thread(exit_event: threading.Event): while not exit_event.is_set(): out_of_bytes = get_available_bytes(default=MIN_BYTES + 1) < MIN_BYTES diff --git a/openpilot/tools/replay/qcom_decoder.cc b/openpilot/system/loggerd/encoder/v4l_decoder.cc similarity index 58% rename from openpilot/tools/replay/qcom_decoder.cc rename to openpilot/system/loggerd/encoder/v4l_decoder.cc index b7f04a063b..6db41adb82 100644 --- a/openpilot/tools/replay/qcom_decoder.cc +++ b/openpilot/system/loggerd/encoder/v4l_decoder.cc @@ -1,13 +1,19 @@ -#include "qcom_decoder.h" +#include "system/loggerd/encoder/v4l_decoder.h" #include +#include +#include #include #include +#include +#include #include "common/swaglog.h" #include "common/util.h" +constexpr int OFFLINE_CORE_PLACEMENT_RATE = 80 << 16; + // echo "0xFFFF" > /sys/kernel/debug/msm_vidc/debug_level static void copyBuffer(VisionBuf *src_buf, VisionBuf *dst_buf) { @@ -26,108 +32,131 @@ static void request_buffers(int fd, v4l2_buf_type buf_type, unsigned int count) util::safe_ioctl(fd, VIDIOC_REQBUFS, &reqbuf, "VIDIOC_REQBUFS failed"); } -MsmVidc::~MsmVidc() { +V4LDecoder::~V4LDecoder() { if (fd > 0) { close(fd); } } -bool MsmVidc::init(const char* dev, size_t width, size_t height, uint64_t codec) { +bool V4LDecoder::init(const char* dev, size_t width, size_t height, uint64_t codec, + bool direct_mode, uint32_t capture_fourcc) { LOG("Initializing msm_vidc device %s", dev); this->w = width; this->h = height; - this->fd = open(dev, O_RDWR, 0); + this->direct = direct_mode; + this->capture_format = capture_fourcc; + this->fd = open(dev, O_RDWR | O_NONBLOCK, 0); if (fd < 0) { LOGE("failed to open video device %s", dev); return false; } subscribeEvents(); v4l2_buf_type out_type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE; - setPlaneFormat(out_type, V4L2_PIX_FMT_HEVC); // Also allocates the output buffer + setPlaneFormat(out_type, codec); // Also allocates the output buffers setFPS(FPS); + if (direct) { + struct v4l2_control ctrls[] = { + // A finite real-time load lets the driver place decode and encode on separate cores. + { .id = V4L2_CID_MPEG_VIDC_VIDEO_OPERATING_RATE, .value = OFFLINE_CORE_PLACEMENT_RATE }, + { .id = V4L2_CID_MPEG_VIDC_VIDEO_PRIORITY, .value = V4L2_MPEG_VIDC_VIDEO_PRIORITY_REALTIME_ENABLE }, + }; + for (auto ctrl : ctrls) { + util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL offline decode failed"); + } + } request_buffers(fd, out_type, OUTPUT_BUFFER_COUNT); util::safe_ioctl(fd, VIDIOC_STREAMON, &out_type, "VIDIOC_STREAMON OUTPUT failed"); restartCapture(); - setupPolling(); + pfd = {fd, POLLIN | POLLOUT | POLLWRNORM | POLLRDNORM | POLLPRI, 0}; this->initialized = true; return true; } -VisionBuf* MsmVidc::decodeFrame(AVPacket *pkt, VisionBuf *buf) { - assert(initialized && (pkt != nullptr) && (buf != nullptr)); +VisionBuf* V4LDecoder::decodeFrame(AVPacket *pkt, VisionBuf *buf) { + assert(initialized && !direct && pkt != nullptr && buf != nullptr); + bool queued = false; + while (true) { + if (!queued) queued = queuePacket(pkt, 0); + V4LDecodedFrame frame; + if (!pump(frame, -1)) return nullptr; + if (!frame.buf) continue; - this->frame_ready = false; - this->current_output_buf = buf; - bool sent_packet = false; + VisionBuf *decoded = frame.buf; + copyBuffer(decoded, buf); + releaseFrame(decoded); + return buf; + } +} - while (!this->frame_ready) { - if (!sent_packet) { - int buf_index = getBufferUnlocked(); - if (buf_index >= 0) { - assert(buf_index < out_buf_cnt); - sendPacket(buf_index, pkt); - sent_packet = true; - } - } +void V4LDecoder::releaseFrame(VisionBuf *buf) { + assert(buf >= cap_bufs && buf < cap_bufs + CAPTURE_BUFFER_COUNT); + queueCaptureBuffer(buf - cap_bufs); +} - if (poll(pfd, nfds, -1) < 0) { +bool V4LDecoder::queuePacket(const AVPacket *pkt, uint64_t token) { + int buf_index = getBufferUnlocked(); + return buf_index >= 0 && sendPacket(buf_index, pkt, token); +} + +bool V4LDecoder::pump(V4LDecodedFrame &frame, int timeout_ms) { + frame = {}; + int rc; + while (true) { + rc = poll(&pfd, 1, timeout_ms); + if (rc < 0) { + if (errno == EINTR) continue; LOGE("poll() error: %d", errno); - return nullptr; - } - - if (VisionBuf* result = processEvents()) { - return result; + return false; } + break; } - return buf; + if (rc == 0) return true; + + int result; + + // Port changes must be handled before capture DQ so no old-format surface is + // handed to a client after the driver has requested a capture flush. + while ((result = handleEvent()) > 0) {} + if (result < 0) return false; + + while ((result = handleOutput()) > 0) {} + if (result < 0) return false; + + result = handleCapture(&frame); + return result >= 0; } -VisionBuf* MsmVidc::processEvents() { - for (int idx = 0; idx < nfds; idx++) { - short revents = pfd[idx].revents; - if (!revents) continue; - - if (idx == ev[EV_VIDEO]) { - if (revents & (POLLIN | POLLRDNORM)) { - VisionBuf *result = handleCapture(); - if (result == this->current_output_buf) { - this->frame_ready = true; - } - } - if (revents & (POLLOUT | POLLWRNORM)) { - handleOutput(); - } - if (revents & POLLPRI) { - handleEvent(); - } - } else { - LOGE("Unexpected event on fd %d", pfd[idx].fd); - } - } - return nullptr; -} - -VisionBuf* MsmVidc::handleCapture() { +int V4LDecoder::handleCapture(V4LDecodedFrame *frame) { struct v4l2_buffer buf = {0}; struct v4l2_plane planes[1] = {0}; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE; buf.memory = V4L2_MEMORY_USERPTR; buf.m.planes = planes; buf.length = 1; - util::safe_ioctl(this->fd, VIDIOC_DQBUF, &buf, "VIDIOC_DQBUF CAPTURE failed"); - - if (this->reconfigure_pending || buf.m.planes[0].bytesused == 0) { - return nullptr; + int err = HANDLE_EINTR(ioctl(this->fd, VIDIOC_DQBUF, &buf)); + if (err < 0 && errno == EAGAIN) return 0; + if (err < 0) { + LOGE("VIDIOC_DQBUF CAPTURE failed: %d", errno); + return -1; } - copyBuffer(&cap_bufs[buf.index], this->current_output_buf); - queueCaptureBuffer(buf.index); - return this->current_output_buf; + const bool has_payload = buf.m.planes[0].bytesused != 0; + const bool eos = (buf.flags & V4L2_QCOM_BUF_FLAG_EOS) != 0; + + frame->buf = nullptr; + if (!reconfigure_pending && has_payload) { + frame->buf = &cap_bufs[buf.index]; + frame->token = (uint64_t)buf.timestamp.tv_sec * 1000000ULL + buf.timestamp.tv_usec; + } else if (!reconfigure_pending && !eos) { + queueCaptureBuffer(buf.index); + } + + return 1; } -bool MsmVidc::subscribeEvents() { +bool V4LDecoder::subscribeEvents() { for (uint32_t event : subscriptions) { struct v4l2_event_subscription sub = { .type = event}; util::safe_ioctl(fd, VIDIOC_SUBSCRIBE_EVENT, &sub, "VIDIOC_SUBSCRIBE_EVENT failed"); @@ -135,7 +164,7 @@ bool MsmVidc::subscribeEvents() { return true; } -bool MsmVidc::setPlaneFormat(enum v4l2_buf_type type, uint32_t fourcc) { +bool V4LDecoder::setPlaneFormat(enum v4l2_buf_type type, uint32_t fourcc) { struct v4l2_format fmt = {.type = type}; struct v4l2_pix_format_mplane *pix = &fmt.fmt.pix_mp; *pix = { @@ -146,20 +175,17 @@ bool MsmVidc::setPlaneFormat(enum v4l2_buf_type type, uint32_t fourcc) { util::safe_ioctl(fd, VIDIOC_S_FMT, &fmt, "VIDIOC_S_FMT failed"); if (type == V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE) { this->out_buf_size = pix->plane_fmt[0].sizeimage; - int ion_size = this->out_buf_size * OUTPUT_BUFFER_COUNT; // Output (input) buffers are ION buffer. - this->out_buf.allocate(ion_size); // mmap rw for (int i = 0; i < OUTPUT_BUFFER_COUNT; i++) { - this->out_buf_off[i] = i * this->out_buf_size; - this->out_buf_addr[i] = (char *)this->out_buf.addr + this->out_buf_off[i]; + this->out_bufs[i].allocate(this->out_buf_size); this->out_buf_flag[i] = false; } - LOGD("Set output buffer size to %d, count %d, addr %p", this->out_buf_size, OUTPUT_BUFFER_COUNT, this->out_buf.addr); + LOGD("Set output buffer size to %d, count %d, addr %p", this->out_buf_size, OUTPUT_BUFFER_COUNT, this->out_bufs[0].addr); } else if (type == V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE) { request_buffers(this->fd, type, CAPTURE_BUFFER_COUNT); util::safe_ioctl(fd, VIDIOC_G_FMT, &fmt, "VIDIOC_G_FMT failed"); const __u32 y_size = pix->plane_fmt[0].sizeimage; const __u32 y_stride = pix->plane_fmt[0].bytesperline; - for (int i = 0; i < CAPTURE_BUFFER_COUNT; i++) { + for (size_t i = 0; i < CAPTURE_BUFFER_COUNT; i++) { size_t uv_offset = (size_t)y_stride * pix->height; size_t required = uv_offset + (y_stride * pix->height / 2); // enough for Y + UV. For linear NV12, UV plane starts at y_stride * height. size_t alloc_size = std::max(y_size, required); @@ -172,7 +198,7 @@ bool MsmVidc::setPlaneFormat(enum v4l2_buf_type type, uint32_t fourcc) { return true; } -bool MsmVidc::setFPS(uint32_t fps) { +bool V4LDecoder::setFPS(uint32_t fps) { struct v4l2_streamparm streamparam = { .type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, }; @@ -181,7 +207,7 @@ bool MsmVidc::setFPS(uint32_t fps) { return true; } -bool MsmVidc::restartCapture() { +bool V4LDecoder::restartCapture() { // stop if already initialized enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE; if (this->initialized) { @@ -191,23 +217,36 @@ bool MsmVidc::restartCapture() { util::safe_ioctl(this->fd, VIDIOC_REQBUFS, &reqbuf, "VIDIOC_REQBUFS failed"); for (size_t i = 0; i < CAPTURE_BUFFER_COUNT; ++i) { this->cap_bufs[i].free(); - this->cap_buf_flag[i] = false; // mark as not queued cap_bufs[i].~VisionBuf(); new (&cap_bufs[i]) VisionBuf(); } } // setup, start and queue capture buffers setDBP(); - setPlaneFormat(type, V4L2_PIX_FMT_NV12); + setPlaneFormat(type, capture_format); + if (direct) { + struct v4l2_control ctrl = { + .id = V4L2_CID_MPEG_VIDC_VIDEO_OPERATING_RATE, + .value = OFFLINE_CORE_PLACEMENT_RATE, + }; + util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL placement decode failed"); + } util::safe_ioctl(this->fd, VIDIOC_STREAMON, &type, "VIDIOC_STREAMON CAPTURE failed"); for (size_t i = 0; i < CAPTURE_BUFFER_COUNT; ++i) { queueCaptureBuffer(i); } + if (direct) { + struct v4l2_control ctrl = { + .id = V4L2_CID_MPEG_VIDC_VIDEO_OPERATING_RATE, + .value = INT_MAX, + }; + util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL turbo decode failed"); + } return true; } -bool MsmVidc::queueCaptureBuffer(int i) { +bool V4LDecoder::queueCaptureBuffer(int i) { struct v4l2_buffer buf = {0}; struct v4l2_plane planes[1] = {0}; @@ -224,27 +263,28 @@ bool MsmVidc::queueCaptureBuffer(int i) { planes[0].bytesused = this->cap_bufs[i].len; planes[0].data_offset = 0; util::safe_ioctl(this->fd, VIDIOC_QBUF, &buf, "VIDIOC_QBUF failed"); - this->cap_buf_flag[i] = true; // mark as queued return true; } -bool MsmVidc::queueOutputBuffer(int i, size_t size) { +bool V4LDecoder::queueOutputBuffer(int i, size_t size, uint64_t token) { struct v4l2_buffer buf = {0}; struct v4l2_plane planes[1] = {0}; buf.type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE; buf.memory = V4L2_MEMORY_USERPTR; buf.index = i; + buf.flags = V4L2_BUF_FLAG_TIMESTAMP_COPY; + buf.timestamp.tv_sec = token / 1000000ULL; + buf.timestamp.tv_usec = token % 1000000ULL; buf.m.planes = planes; buf.length = 1; // decoded frame plane - planes[0].m.userptr = (unsigned long)this->out_buf_off[i]; // check this + planes[0].m.userptr = (unsigned long)this->out_bufs[i].addr; planes[0].length = this->out_buf_size; - planes[0].reserved[0] = this->out_buf.fd; // ION fd + planes[0].reserved[0] = this->out_bufs[i].fd; // ION fd planes[0].reserved[1] = 0; planes[0].bytesused = size; planes[0].data_offset = 0; - assert((this->out_buf_off[i] & 0xfff) == 0); // must be 4 KiB aligned assert(this->out_buf_size % 4096 == 0); // ditto for size util::safe_ioctl(this->fd, VIDIOC_QBUF, &buf, "VIDIOC_QBUF failed"); @@ -252,7 +292,7 @@ bool MsmVidc::queueOutputBuffer(int i, size_t size) { return true; } -bool MsmVidc::setDBP() { +bool V4LDecoder::setDBP() { struct v4l2_ext_control control[2] = {0}; struct v4l2_ext_controls controls = {0}; control[0].id = V4L2_CID_MPEG_VIDC_VIDEO_STREAM_OUTPUT_MODE; @@ -266,27 +306,19 @@ bool MsmVidc::setDBP() { return true; } -bool MsmVidc::setupPolling() { - // Initialize poll array - pfd[EV_VIDEO] = {fd, POLLIN | POLLOUT | POLLWRNORM | POLLRDNORM | POLLPRI, 0}; - ev[EV_VIDEO] = EV_VIDEO; - nfds = 1; - return true; -} - -bool MsmVidc::sendPacket(int buf_index, AVPacket *pkt) { - assert(buf_index >= 0 && buf_index < out_buf_cnt); +bool V4LDecoder::sendPacket(int buf_index, const AVPacket *pkt, uint64_t token) { + assert(buf_index >= 0 && buf_index < OUTPUT_BUFFER_COUNT); assert(pkt != nullptr && pkt->data != nullptr && pkt->size > 0); + assert((size_t)pkt->size <= (size_t)this->out_buf_size); // Prepare output buffer - memset(this->out_buf_addr[buf_index], 0, this->out_buf_size); - uint8_t * data = (uint8_t *)this->out_buf_addr[buf_index]; + uint8_t * data = (uint8_t *)this->out_bufs[buf_index].addr; memcpy(data, pkt->data, pkt->size); - queueOutputBuffer(buf_index, pkt->size); + queueOutputBuffer(buf_index, pkt->size, token); return true; } -int MsmVidc::getBufferUnlocked() { - for (int i = 0; i < this->out_buf_cnt; i++) { +int V4LDecoder::getBufferUnlocked() { + for (int i = 0; i < OUTPUT_BUFFER_COUNT; i++) { if (!out_buf_flag[i]) { return i; } @@ -295,22 +327,32 @@ int MsmVidc::getBufferUnlocked() { } -bool MsmVidc::handleOutput() { +int V4LDecoder::handleOutput() { struct v4l2_buffer buf = {0}; struct v4l2_plane planes[1]; buf.type = V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE; buf.memory = V4L2_MEMORY_USERPTR; buf.m.planes = planes; buf.length = 1; - util::safe_ioctl(this->fd, VIDIOC_DQBUF, &buf, "VIDIOC_DQBUF OUTPUT failed"); + int err = HANDLE_EINTR(ioctl(this->fd, VIDIOC_DQBUF, &buf)); + if (err < 0 && errno == EAGAIN) return 0; + if (err < 0) { + LOGE("VIDIOC_DQBUF OUTPUT failed: %d", errno); + return -1; + } this->out_buf_flag[buf.index] = false; // mark as not queued - return true; + return 1; } -bool MsmVidc::handleEvent() { +int V4LDecoder::handleEvent() { // dequeue event struct v4l2_event event = {0}; - util::safe_ioctl(this->fd, VIDIOC_DQEVENT, &event, "VIDIOC_DQEVENT failed"); + int err = HANDLE_EINTR(ioctl(this->fd, VIDIOC_DQEVENT, &event)); + if (err < 0 && (errno == EAGAIN || errno == ENOENT)) return 0; + if (err < 0) { + LOGE("VIDIOC_DQEVENT failed: %d", errno); + return -1; + } switch (event.type) { case V4L2_EVENT_MSM_VIDC_PORT_SETTINGS_CHANGED_INSUFFICIENT: { unsigned int *ptr = (unsigned int *)event.u.data; @@ -342,5 +384,10 @@ bool MsmVidc::handleEvent() { default: break; } - return true; + return 1; +} + +void V4LDecoder::sendEOS() { + struct v4l2_decoder_cmd command = { .cmd = V4L2_DEC_CMD_STOP }; + util::safe_ioctl(fd, VIDIOC_DECODER_CMD, &command, "VIDIOC_DECODER_CMD STOP failed"); } diff --git a/openpilot/system/loggerd/encoder/v4l_decoder.h b/openpilot/system/loggerd/encoder/v4l_decoder.h new file mode 100644 index 0000000000..c7a96803ed --- /dev/null +++ b/openpilot/system/loggerd/encoder/v4l_decoder.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include + +#include "msgq/visionipc/visionbuf.h" + +extern "C" { + #include + #include +} + +#define V4L2_EVENT_MSM_VIDC_START (V4L2_EVENT_PRIVATE_START + 0x00001000) +#define V4L2_EVENT_MSM_VIDC_FLUSH_DONE (V4L2_EVENT_MSM_VIDC_START + 1) +#define V4L2_EVENT_MSM_VIDC_PORT_SETTINGS_CHANGED_INSUFFICIENT (V4L2_EVENT_MSM_VIDC_START + 3) +#ifndef V4L2_CID_MPEG_MSM_VIDC_BASE +#define V4L2_CID_MPEG_MSM_VIDC_BASE 0x00992000 +#endif +#ifndef V4L2_CID_MPEG_VIDC_VIDEO_DPB_COLOR_FORMAT +#define V4L2_CID_MPEG_VIDC_VIDEO_DPB_COLOR_FORMAT (V4L2_CID_MPEG_MSM_VIDC_BASE + 44) +#endif +#ifndef V4L2_CID_MPEG_VIDC_VIDEO_STREAM_OUTPUT_MODE +#define V4L2_CID_MPEG_VIDC_VIDEO_STREAM_OUTPUT_MODE (V4L2_CID_MPEG_MSM_VIDC_BASE + 22) +#endif +#ifndef V4L2_PIX_FMT_NV12_UBWC +#define V4L2_PIX_FMT_NV12_UBWC v4l2_fourcc('Q', '1', '2', '8') +#endif +#ifndef V4L2_CID_MPEG_VIDC_VIDEO_PRIORITY +#define V4L2_CID_MPEG_VIDC_VIDEO_PRIORITY (V4L2_CID_MPEG_MSM_VIDC_BASE + 52) +#define V4L2_MPEG_VIDC_VIDEO_PRIORITY_REALTIME_ENABLE 0 +#define V4L2_MPEG_VIDC_VIDEO_PRIORITY_REALTIME_DISABLE 1 +#endif +#ifndef V4L2_CID_MPEG_VIDC_VIDEO_OPERATING_RATE +#define V4L2_CID_MPEG_VIDC_VIDEO_OPERATING_RATE (V4L2_CID_MPEG_MSM_VIDC_BASE + 53) +#endif +#define V4L2_QCOM_CMD_FLUSH_CAPTURE (1 << 1) +#define V4L2_QCOM_CMD_FLUSH (4) +#ifndef V4L2_QCOM_BUF_FLAG_EOS +#define V4L2_QCOM_BUF_FLAG_EOS 0x02000000 +#endif + +#define OUTPUT_BUFFER_COUNT 8 +#define CAPTURE_BUFFER_COUNT 16 +#define FPS 20 + +struct V4LDecodedFrame { + VisionBuf *buf = nullptr; + uint64_t token = 0; +}; + +class V4LDecoder { +public: + static constexpr const char *DEVICE = "/dev/video32"; + + V4LDecoder() = default; + ~V4LDecoder(); + + bool init(const char* dev, size_t width, size_t height, uint64_t codec, + bool direct_mode = false, uint32_t capture_fourcc = V4L2_PIX_FMT_NV12); + VisionBuf* decodeFrame(AVPacket* pkt, VisionBuf* buf); + // queuePacket() and pump() are single-threaded. releaseFrame() may be called + // from a consumer thread after a direct capture surface is no longer needed. + bool queuePacket(const AVPacket *pkt, uint64_t token); + bool pump(V4LDecodedFrame &frame, int timeout_ms); + void releaseFrame(VisionBuf *buf); + void sendEOS(); + size_t maxPacketSize() const { return out_buf_size; } + + AVFormatContext* avctx = nullptr; + int fd = 0; + +private: + bool initialized = false; + bool reconfigure_pending = false; + bool direct = false; + uint32_t capture_format = V4L2_PIX_FMT_NV12; + + VisionBuf out_bufs[OUTPUT_BUFFER_COUNT]; // Distinct dma-buf per in-flight packet + VisionBuf cap_bufs[CAPTURE_BUFFER_COUNT]; // Capture (output) buffers + + size_t w = 0, h = 0; + int out_buf_size = 0; + + bool out_buf_flag[OUTPUT_BUFFER_COUNT] = {false}; + + const int subscriptions[2] = { + V4L2_EVENT_MSM_VIDC_FLUSH_DONE, + V4L2_EVENT_MSM_VIDC_PORT_SETTINGS_CHANGED_INSUFFICIENT + }; + + struct pollfd pfd = {}; + + bool subscribeEvents(); + bool setPlaneFormat(v4l2_buf_type type, uint32_t fourcc); + bool setFPS(uint32_t fps); + bool restartCapture(); + bool queueCaptureBuffer(int i); + bool queueOutputBuffer(int i, size_t size, uint64_t token); + bool setDBP(); + bool sendPacket(int buf_index, const AVPacket* pkt, uint64_t token); + int getBufferUnlocked(); + int handleCapture(V4LDecodedFrame *frame); + int handleOutput(); + int handleEvent(); +}; diff --git a/openpilot/system/loggerd/encoder/v4l_encoder.cc b/openpilot/system/loggerd/encoder/v4l_encoder.cc index 1b4d976b93..0d7e744568 100644 --- a/openpilot/system/loggerd/encoder/v4l_encoder.cc +++ b/openpilot/system/loggerd/encoder/v4l_encoder.cc @@ -2,6 +2,7 @@ #include #include #include +#include #include "system/loggerd/encoder/v4l_encoder.h" #include "common/util.h" @@ -77,9 +78,10 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) { uint32_t idx = -1; bool exit = false; - // POLLIN is capture, POLLOUT is frame + // POLLIN is capture, POLLOUT is frame. Qualcomm's reference client also + // requests the corresponding normal-data bits. struct pollfd pfd; - pfd.events = POLLIN | POLLOUT; + pfd.events = POLLIN | POLLRDNORM | POLLOUT | POLLWRNORM; pfd.fd = e->fd; // save the header @@ -104,7 +106,7 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) { } int frame_id = -1; - if (pfd.revents & POLLIN) { + if (pfd.revents & (POLLIN | POLLRDNORM)) { unsigned int bytesused, flags, index; struct timeval timestamp; dequeue_buffer(e->fd, V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE, &index, &bytesused, &flags, ×tamp); @@ -118,12 +120,17 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) { } else if (flags & V4L2_QCOM_BUF_FLAG_CODECCONFIG) { // save header header = kj::heapArray(buf, bytesused); + if (e->packet_callback) e->packet_callback(header.begin(), header.size(), ts, true, false); } else { VisionIpcBufExtra extra = e->extras.pop(); assert(extra.timestamp_eof/1000 == ts); // stay in sync frame_id = extra.frame_id; ++idx; - e->publisher_publish(e->segment_num, idx, extra, flags, header, kj::arrayPtr(buf, bytesused)); + if (e->packet_callback) { + e->packet_callback(buf, bytesused, ts, false, flags & V4L2_BUF_FLAG_KEYFRAME); + } else { + e->publisher_publish(e->segment_num, idx, extra, flags, header, kj::arrayPtr(buf, bytesused)); + } } if (env_debug_encoder) { @@ -135,16 +142,22 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) { queue_buffer(e->fd, V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE, index, &e->buf_out[index]); } - if (pfd.revents & POLLOUT) { + if (pfd.revents & (POLLOUT | POLLWRNORM)) { unsigned int index; dequeue_buffer(e->fd, V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, &index); + VisionBuf *input_buf = e->input_bufs[index].exchange(nullptr); + if (input_buf && e->input_done_callback) e->input_done_callback(input_buf); e->free_buf_in.push(index); } } } V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_height) - : VideoEncoder(encoder_info, in_width, in_height) { + : V4LEncoder(encoder_info, in_width, in_height, Options{}) {} + +V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_height, Options options) + : VideoEncoder(encoder_info, in_width, in_height), packet_callback(std::move(options.packet_callback)), + input_done_callback(std::move(options.input_done_callback)) { fd = HANDLE_EINTR(open("/dev/v4l/by-path/platform-aa00000.qcom_vidc-video-index1", O_RDWR|O_NONBLOCK)); assert(fd >= 0); @@ -193,7 +206,7 @@ V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_hei .pix_mp = { .width = (unsigned int)in_width, .height = (unsigned int)in_height, - .pixelformat = V4L2_PIX_FMT_NV12, + .pixelformat = options.input_format, .field = V4L2_FIELD_ANY, .colorspace = V4L2_COLORSPACE_470_SYSTEM_BG, } @@ -220,6 +233,13 @@ V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_hei util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL failed"); } } + if (options.max_performance) { + struct v4l2_control ctrl = { + .id = V4L2_CID_MPEG_VIDC_VIDEO_PRIORITY, + .value = V4L2_MPEG_VIDC_VIDEO_PRIORITY_REALTIME_ENABLE, + }; + util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL offline encode failed"); + } if (is_h265) { struct v4l2_control ctrls[] = { @@ -231,12 +251,30 @@ V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_hei util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL failed"); } } else { + if (encoder_info.is_live) { + struct v4l2_control ctrls[] = { + { .id = V4L2_CID_MPEG_VIDEO_H264_PROFILE, .value = V4L2_MPEG_VIDEO_H264_PROFILE_HIGH}, + { .id = V4L2_CID_MPEG_VIDEO_H264_LEVEL, .value = V4L2_MPEG_VIDEO_H264_LEVEL_3_1}, + { .id = V4L2_CID_MPEG_VIDEO_H264_ENTROPY_MODE, .value = V4L2_MPEG_VIDEO_H264_ENTROPY_MODE_CABAC}, + { .id = V4L2_CID_MPEG_VIDC_VIDEO_H264_CABAC_MODEL, .value = V4L2_CID_MPEG_VIDC_VIDEO_H264_CABAC_MODEL_0}, + }; + for (auto ctrl : ctrls) { + util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL failed"); + } + } else { + struct v4l2_control ctrls[] = { + { .id = V4L2_CID_MPEG_VIDEO_H264_PROFILE, .value = V4L2_MPEG_VIDEO_H264_PROFILE_HIGH}, + { .id = V4L2_CID_MPEG_VIDEO_H264_LEVEL, .value = V4L2_MPEG_VIDEO_H264_LEVEL_UNKNOWN}, + { .id = V4L2_CID_MPEG_VIDEO_H264_ENTROPY_MODE, .value = V4L2_MPEG_VIDEO_H264_ENTROPY_MODE_CABAC}, + { .id = V4L2_CID_MPEG_VIDC_VIDEO_H264_CABAC_MODEL, .value = V4L2_CID_MPEG_VIDC_VIDEO_H264_CABAC_MODEL_0}, + }; + for (auto ctrl : ctrls) { + util::safe_ioctl(fd, VIDIOC_S_CTRL, &ctrl, "VIDIOC_S_CTRL failed"); + } + } + struct v4l2_control ctrls[] = { - { .id = V4L2_CID_MPEG_VIDEO_H264_PROFILE, .value = V4L2_MPEG_VIDEO_H264_PROFILE_HIGH}, - { .id = V4L2_CID_MPEG_VIDEO_H264_LEVEL, .value = V4L2_MPEG_VIDEO_H264_LEVEL_UNKNOWN}, - { .id = V4L2_CID_MPEG_VIDEO_H264_ENTROPY_MODE, .value = V4L2_MPEG_VIDEO_H264_ENTROPY_MODE_CABAC}, - { .id = V4L2_CID_MPEG_VIDC_VIDEO_H264_CABAC_MODEL, .value = V4L2_CID_MPEG_VIDC_VIDEO_H264_CABAC_MODEL_0}, - { .id = V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_MODE, .value = 0}, + { .id = V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_MODE, .value = V4L2_MPEG_VIDEO_H264_LOOP_FILTER_MODE_ENABLED}, { .id = V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_ALPHA, .value = 0}, { .id = V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_BETA, .value = 0}, { .id = V4L2_CID_MPEG_VIDEO_MULTI_SLICE_MODE, .value = 0}, @@ -281,6 +319,7 @@ int V4LEncoder::encode_frame(VisionBuf* buf, VisionIpcBufExtra *extra) { // reserve buffer int buffer_in = free_buf_in.pop(); + input_bufs[buffer_in].store(buf); // push buffer extras.push(*extra); diff --git a/openpilot/system/loggerd/encoder/v4l_encoder.h b/openpilot/system/loggerd/encoder/v4l_encoder.h index b1a84e654f..c8399e1225 100644 --- a/openpilot/system/loggerd/encoder/v4l_encoder.h +++ b/openpilot/system/loggerd/encoder/v4l_encoder.h @@ -1,14 +1,27 @@ #pragma once +#include +#include + #include "common/queue.h" #include "system/loggerd/encoder/encoder.h" -#define BUF_IN_COUNT 7 +#define BUF_IN_COUNT 9 #define BUF_OUT_COUNT 6 class V4LEncoder : public VideoEncoder { public: + using PacketCallback = std::function; + using InputDoneCallback = std::function; + struct Options { + PacketCallback packet_callback; + uint32_t input_format = V4L2_PIX_FMT_NV12; + InputDoneCallback input_done_callback; + bool max_performance = false; + }; + V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_height); + V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_height, Options options); ~V4LEncoder(); int encode_frame(VisionBuf* buf, VisionIpcBufExtra *extra); void encoder_open(); @@ -23,12 +36,14 @@ private: int segment_num = -1; int counter = 0; int current_bitrate = -1; - SafeQueue extras; + PacketCallback packet_callback; + InputDoneCallback input_done_callback; static void dequeue_handler(V4LEncoder *e); std::thread dequeue_handler_thread; VisionBuf buf_out[BUF_OUT_COUNT]; + std::atomic input_bufs[BUF_IN_COUNT] = {}; SafeQueue free_buf_in; }; diff --git a/openpilot/system/loggerd/encoderd.cc b/openpilot/system/loggerd/encoderd.cc index 11db07671d..55f0777fbb 100644 --- a/openpilot/system/loggerd/encoderd.cc +++ b/openpilot/system/loggerd/encoderd.cc @@ -1,9 +1,16 @@ #include +#ifdef __COMMA_HARDWARE__ +#include +#include +#endif +#ifdef __COMMA_HARDWARE__ +#include "system/loggerd/clip_encoder.h" +#endif #include "system/loggerd/loggerd.h" #include "system/loggerd/encoder/jpeg_encoder.h" -#ifdef __TICI__ +#ifdef __COMMA_HARDWARE__ #include "system/loggerd/encoder/v4l_encoder.h" #define Encoder V4LEncoder #else @@ -171,6 +178,37 @@ void encoderd_thread(const LogCameraInfo (&cameras)[N]) { } int main(int argc, char* argv[]) { +#ifdef __COMMA_HARDWARE__ + if (argc > 1 && std::string(argv[1]) == "--clip") { + if (argc < 6) { + fprintf(stderr, "usage: encoderd --clip OUTPUT START DURATION [--bitrate BPS] [--speedup N] " + "[--metadata JSON] SEGMENT [SEGMENT ...]\n"); + return 2; + } + try { + int bitrate = 5'000'000; + int speedup = 1; + std::string metadata; + int input_arg = 5; + while (input_arg < argc && std::string(argv[input_arg]).rfind("--", 0) == 0) { + const std::string option = argv[input_arg++]; + if (option == "--") break; + if (input_arg == argc) throw std::invalid_argument("missing clip option value"); + if (option == "--bitrate") bitrate = std::stoi(argv[input_arg++]); + else if (option == "--speedup") speedup = std::stoi(argv[input_arg++]); + else if (option == "--metadata") metadata = argv[input_arg++]; + else throw std::invalid_argument("unknown clip option: " + option); + } + if (input_arg == argc) throw std::invalid_argument("missing clip input"); + std::vector inputs(argv + input_arg, argv + argc); + return encode_clip(inputs, argv[2], std::stod(argv[3]), std::stod(argv[4]), + bitrate, speedup, metadata); + } catch (const std::exception &e) { + fprintf(stderr, "clip encoding failed: %s\n", e.what()); + return 1; + } + } +#endif if (!Hardware::PC()) { int ret; ret = util::set_realtime_priority(52); diff --git a/openpilot/system/loggerd/loggerd.cc b/openpilot/system/loggerd/loggerd.cc index 3755929619..af4a5c0cde 100644 --- a/openpilot/system/loggerd/loggerd.cc +++ b/openpilot/system/loggerd/loggerd.cc @@ -31,7 +31,7 @@ void logger_rotate(LoggerdState *s) { void rotate_if_needed(LoggerdState *s) { // all encoders ready, trigger rotation - bool all_ready = s->ready_to_rotate == s->max_waiting; + bool all_ready = s->max_waiting > 0 && s->ready_to_rotate == s->max_waiting; // fallback logic to prevent extremely long segments in the case of camera, encoder, etc. malfunctions bool timed_out = false; @@ -125,6 +125,7 @@ int handle_encoder_msg(LoggerdState *s, Message *msg, std::string &name, struct if (!re.seen_first_packet) { re.seen_first_packet = true; re.encoderd_segment_offset = idx.getSegmentNum(); + ++s->max_waiting; // only count encoders that publish so a disabled/missing camera doesn't stall rotation LOGD("%s: has encoderd offset %d", name.c_str(), re.encoderd_segment_offset); } int offset_segment_num = idx.getSegmentNum() - re.encoderd_segment_offset; @@ -246,7 +247,7 @@ void loggerd_thread() { .counter = 0, .freq = it.decimation, .encoder = encoder, - .preserve_segment = (it.name == "userBookmark") || (it.name == "audioFeedback"), + .preserve_segment = it.name == "userBookmark", .record_audio = record_audio, }; } @@ -262,7 +263,6 @@ void loggerd_thread() { for (const auto &cam : cameras_logged) { for (const auto &encoder_info : cam.encoder_infos) { encoder_infos_dict[encoder_info.publish_name] = encoder_info; - s.max_waiting++; } } diff --git a/openpilot/system/loggerd/loggerd.h b/openpilot/system/loggerd/loggerd.h index 110dbe4fd2..beabd7d791 100644 --- a/openpilot/system/loggerd/loggerd.h +++ b/openpilot/system/loggerd/loggerd.h @@ -5,6 +5,7 @@ #include "openpilot/cereal/messaging/messaging.h" #include "openpilot/cereal/services.h" +#include "openpilot/cereal/visionstream.h" #include "msgq/visionipc/visionipc_client.h" #include "common/hardware/hw.h" #include "common/params.h" @@ -25,6 +26,22 @@ const auto MAIN_ENCODE_TYPE = Hardware::PC() ? cereal::EncodeIndex::Type::BIG_BO const bool LOGGERD_TEST = getenv("LOGGERD_TEST"); const int SEGMENT_LENGTH = LOGGERD_TEST ? atoi(getenv("LOGGERD_SEGMENT_LENGTH")) : 60; +inline int livestream_width() { + switch (Hardware::get_device_type()) { + case cereal::InitData::DeviceType::TIZI: return 1152; + case cereal::InitData::DeviceType::MICI: return 1280; + default: return -1; + } +} + +inline int livestream_height() { + switch (Hardware::get_device_type()) { + case cereal::InitData::DeviceType::TIZI: + case cereal::InitData::DeviceType::MICI: return 720; + default: return -1; + } +} + constexpr char PRESERVE_ATTR_NAME[] = "user.preserve"; constexpr char PRESERVE_ATTR_VALUE = '1'; @@ -79,11 +96,11 @@ public: }; const EncoderInfo main_road_encoder_info = { - .publish_name = "roadEncodeData", + .publish_name = "narrowRoadEncodeData", .thumbnail_name = "thumbnail", .filename = "fcamera.hevc", .get_settings = [](int in_width){return EncoderSettings::MainEncoderSettings(in_width);}, - INIT_ENCODE_FUNCTIONS(RoadEncode), + INIT_ENCODE_FUNCTIONS(NarrowRoadEncode), }; const EncoderInfo main_wide_road_encoder_info = { @@ -93,52 +110,58 @@ const EncoderInfo main_wide_road_encoder_info = { INIT_ENCODE_FUNCTIONS(WideRoadEncode), }; -const EncoderInfo main_driver_encoder_info = { - .publish_name = "driverEncodeData", +const EncoderInfo main_cabin_encoder_info = { + .publish_name = "cabinEncodeData", .filename = "dcamera.hevc", .record = Params().getBool("RecordFront"), .get_settings = [](int in_width){return EncoderSettings::MainEncoderSettings(in_width);}, - INIT_ENCODE_FUNCTIONS(DriverEncode), + INIT_ENCODE_FUNCTIONS(CabinEncode), }; const EncoderInfo stream_road_encoder_info = { - .publish_name = "livestreamRoadEncodeData", + .publish_name = "livestreamNarrowRoadEncodeData", //.thumbnail_name = "thumbnail", .record = false, .is_live = true, + .frame_width = livestream_width(), + .frame_height = livestream_height(), .get_settings = [](int){return EncoderSettings::StreamEncoderSettings();}, - INIT_ENCODE_FUNCTIONS(LivestreamRoadEncode), + INIT_ENCODE_FUNCTIONS(LivestreamNarrowRoadEncode), }; const EncoderInfo stream_wide_road_encoder_info = { .publish_name = "livestreamWideRoadEncodeData", .record = false, .is_live = true, + .frame_width = livestream_width(), + .frame_height = livestream_height(), .get_settings = [](int){return EncoderSettings::StreamEncoderSettings();}, INIT_ENCODE_FUNCTIONS(LivestreamWideRoadEncode), }; -const EncoderInfo stream_driver_encoder_info = { - .publish_name = "livestreamDriverEncodeData", +const EncoderInfo stream_cabin_encoder_info = { + .publish_name = "livestreamCabinEncodeData", .record = false, .is_live = true, + .frame_width = livestream_width(), + .frame_height = livestream_height(), .get_settings = [](int){return EncoderSettings::StreamEncoderSettings();}, - INIT_ENCODE_FUNCTIONS(LivestreamDriverEncode), + INIT_ENCODE_FUNCTIONS(LivestreamCabinEncode), }; const EncoderInfo qcam_encoder_info = { - .publish_name = "qRoadEncodeData", + .publish_name = "qNarrowRoadEncodeData", .filename = "qcamera.ts", .include_audio = Params().getBool("RecordAudio"), .frame_width = 526, .frame_height = 330, .get_settings = [](int){return EncoderSettings::QcamEncoderSettings();}, - INIT_ENCODE_FUNCTIONS(QRoadEncode), + INIT_ENCODE_FUNCTIONS(QNarrowRoadEncode), }; -const LogCameraInfo road_camera_info{ - .thread_name = "road_cam_encoder", - .stream_type = VISION_STREAM_ROAD, +const LogCameraInfo narrow_road_camera_info{ + .thread_name = "narrow_road_cam_encoder", + .stream_type = VISION_STREAM_NARROW_ROAD, .encoder_infos = {main_road_encoder_info, qcam_encoder_info} }; @@ -148,15 +171,15 @@ const LogCameraInfo wide_road_camera_info{ .encoder_infos = {main_wide_road_encoder_info} }; -const LogCameraInfo driver_camera_info{ - .thread_name = "driver_cam_encoder", - .stream_type = VISION_STREAM_DRIVER, - .encoder_infos = {main_driver_encoder_info} +const LogCameraInfo cabin_camera_info{ + .thread_name = "cabin_cam_encoder", + .stream_type = VISION_STREAM_CABIN, + .encoder_infos = {main_cabin_encoder_info} }; const LogCameraInfo stream_road_camera_info{ - .thread_name = "road_cam_encoder", - .stream_type = VISION_STREAM_ROAD, + .thread_name = "narrow_road_cam_encoder", + .stream_type = VISION_STREAM_NARROW_ROAD, .encoder_infos = {stream_road_encoder_info}, }; @@ -166,11 +189,11 @@ const LogCameraInfo stream_wide_road_camera_info{ .encoder_infos = {stream_wide_road_encoder_info}, }; -const LogCameraInfo stream_driver_camera_info{ - .thread_name = "driver_cam_encoder", - .stream_type = VISION_STREAM_DRIVER, - .encoder_infos = {stream_driver_encoder_info}, +const LogCameraInfo stream_cabin_camera_info{ + .thread_name = "cabin_cam_encoder", + .stream_type = VISION_STREAM_CABIN, + .encoder_infos = {stream_cabin_encoder_info}, }; -const LogCameraInfo cameras_logged[] = {road_camera_info, wide_road_camera_info, driver_camera_info}; -const LogCameraInfo stream_cameras_logged[] = {stream_road_camera_info, stream_wide_road_camera_info, stream_driver_camera_info}; +const LogCameraInfo cameras_logged[] = {narrow_road_camera_info, wide_road_camera_info, cabin_camera_info}; +const LogCameraInfo stream_cameras_logged[] = {stream_road_camera_info, stream_wide_road_camera_info, stream_cabin_camera_info}; diff --git a/openpilot/system/loggerd/tests/loggerd_tests_common.py b/openpilot/system/loggerd/tests/loggerd_tests_common.py index 55f5fbb816..689d2c27c0 100644 --- a/openpilot/system/loggerd/tests/loggerd_tests_common.py +++ b/openpilot/system/loggerd/tests/loggerd_tests_common.py @@ -3,6 +3,7 @@ import random from pathlib import Path +from openpilot.common.test import OpenpilotTestCase import openpilot.system.loggerd.deleter as deleter import openpilot.system.loggerd.uploader as uploader from openpilot.common.params import Params @@ -53,7 +54,7 @@ class MockApiIgnore: def get_token(self): return "fake-token" -class UploaderTestCase: +class UploaderTestCase(OpenpilotTestCase): f_type = "UNKNOWN" root: Path diff --git a/openpilot/system/loggerd/tests/test_deleter.py b/openpilot/system/loggerd/tests/test_deleter.py index 8f18c8ceee..6fe1b22579 100644 --- a/openpilot/system/loggerd/tests/test_deleter.py +++ b/openpilot/system/loggerd/tests/test_deleter.py @@ -1,66 +1,39 @@ -import time -import threading from collections import namedtuple from pathlib import Path from collections.abc import Sequence import openpilot.system.loggerd.deleter as deleter -from openpilot.common.timeout import Timeout, TimeoutException from openpilot.system.loggerd.tests.loggerd_tests_common import UploaderTestCase Stats = namedtuple("Stats", ['f_bavail', 'f_blocks', 'f_frsize']) class TestDeleter(UploaderTestCase): + # Deletion behavior is independent of file size; use smaller files to keep these tests fast. + def make_file_with_data(self, f_dir: str, fn: str, size_mb: float = .001, lock: bool = False, + upload_xattr: bytes | None = None, preserve_xattr: bytes | None = None) -> Path: + return super().make_file_with_data(f_dir, fn, size_mb, lock, upload_xattr, preserve_xattr) + def fake_statvfs(self, d): return self.fake_stats def setup_method(self): self.f_type = "fcamera.hevc" - super().setup_method() + super().openpilot_setup_method() self.fake_stats = Stats(f_bavail=0, f_blocks=10, f_frsize=4096) deleter.os.statvfs = self.fake_statvfs # ty: ignore[invalid-assignment] # test double - def start_thread(self): - self.end_event = threading.Event() - self.del_thread = threading.Thread(target=deleter.deleter_thread, args=[self.end_event]) - self.del_thread.daemon = True - self.del_thread.start() - - def join_thread(self): - self.end_event.set() - self.del_thread.join() - def test_delete(self): - f_path = self.make_file_with_data(self.seg_dir, self.f_type, 1) + f_path = self.make_file_with_data(self.seg_dir, self.f_type) + assert deleter.deleter_step() == (True, str(f_path.parent)) + assert not f_path.exists() - self.start_thread() - - try: - with Timeout(2, "Timeout waiting for file to be deleted"): - while f_path.exists(): - time.sleep(0.01) - finally: - self.join_thread() - - def assertDeleteOrder(self, f_paths: Sequence[Path], timeout: int = 5) -> None: + def assertDeleteOrder(self, f_paths: Sequence[Path]) -> None: deleted_order = [] - - self.start_thread() - try: - with Timeout(timeout, "Timeout waiting for files to be deleted"): - while True: - for f in f_paths: - if not f.exists() and f not in deleted_order: - deleted_order.append(f) - if len(deleted_order) == len(f_paths): - break - time.sleep(0.01) - except TimeoutException: - print("Not deleted:", [f for f in f_paths if f not in deleted_order]) - raise - finally: - self.join_thread() + for _ in f_paths: + out_of_space, deleted_path = deleter.deleter_step() + assert out_of_space and deleted_path is not None + deleted_order.append(next(f for f in f_paths if f.parent == Path(deleted_path))) assert deleted_order == f_paths, "Files not deleted in expected order" @@ -97,21 +70,11 @@ class TestDeleter(UploaderTestCase): available = (10 * 1024 * 1024 * 1024) / block_size # 10GB free self.fake_stats = Stats(f_bavail=available, f_blocks=10, f_frsize=block_size) - self.start_thread() - start_time = time.monotonic() - while f_path.exists() and time.monotonic() - start_time < 2: - time.sleep(0.01) - self.join_thread() - + assert deleter.deleter_step() == (False, None) assert f_path.exists(), "File deleted with available space" def test_no_delete_with_lock_file(self): f_path = self.make_file_with_data(self.seg_dir, self.f_type, lock=True) - self.start_thread() - start_time = time.monotonic() - while f_path.exists() and time.monotonic() - start_time < 2: - time.sleep(0.01) - self.join_thread() - + assert deleter.deleter_step() == (True, None) assert f_path.exists(), "File deleted when locked" diff --git a/openpilot/system/loggerd/tests/test_encoder.py b/openpilot/system/loggerd/tests/test_encoder.py old mode 100644 new mode 100755 index 05bc211cbe..10cd586f35 --- a/openpilot/system/loggerd/tests/test_encoder.py +++ b/openpilot/system/loggerd/tests/test_encoder.py @@ -1,16 +1,19 @@ +#!/usr/bin/env python3 + import math import os -import pytest import shutil import subprocess import time +import unittest from pathlib import Path from tqdm import trange +from openpilot.common.test import OpenpilotTestCase from openpilot.common.params import Params from openpilot.common.timeout import Timeout -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE from openpilot.system.manager.process_config import managed_processes from openpilot.tools.lib.logreader import LogReader from openpilot.common.hardware.hw import Paths @@ -19,8 +22,8 @@ SEGMENT_LENGTH = 2 FULL_SIZE = 2507572 def hevc_size(w): return FULL_SIZE // 2 if w <= 1344 else FULL_SIZE CAMERAS = [ - ("fcamera.hevc", 20, hevc_size, "roadEncodeIdx"), - ("dcamera.hevc", 20, hevc_size, "driverEncodeIdx"), + ("fcamera.hevc", 20, hevc_size, "narrowRoadEncodeIdx"), + ("dcamera.hevc", 20, hevc_size, "cabinEncodeIdx"), ("ecamera.hevc", 20, hevc_size, "wideRoadEncodeIdx"), ("qcamera.ts", 20, lambda x: 130000, None), ] @@ -29,8 +32,8 @@ CAMERAS = [ FILE_SIZE_TOLERANCE = 0.7 -@pytest.mark.tici # TODO: all of loggerd should work on PC -class TestEncoder: +class TestEncoder(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True def setup_method(self): self._clear_logs() @@ -83,7 +86,7 @@ class TestEncoder: # TODO: this ffprobe call is really slow # get width and check frame count cmd = f"ffprobe -v error -select_streams v:0 -count_packets -show_entries stream=nb_read_packets,width -of csv=p=0 {file_path}" - if TICI: + if COMMA_HARDWARE: cmd = "LD_LIBRARY_PATH=/usr/local/lib " + cmd expected_frames = fps * SEGMENT_LENGTH @@ -127,7 +130,7 @@ class TestEncoder: assert 1 == len(set(first_frames)) - if TICI: + if COMMA_HARDWARE: expected_frames = fps * SEGMENT_LENGTH assert min(counts) == expected_frames shutil.rmtree(f"{route_prefix_path}--{i}") @@ -144,3 +147,7 @@ class TestEncoder: managed_processes['encoderd'].stop() managed_processes['camerad'].stop() managed_processes['sensord'].stop() + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/system/loggerd/tests/test_logger.cc b/openpilot/system/loggerd/tests/test_logger.cc deleted file mode 100644 index 61509c256c..0000000000 --- a/openpilot/system/loggerd/tests/test_logger.cc +++ /dev/null @@ -1,75 +0,0 @@ -#include "catch2/catch.hpp" -#include "system/loggerd/logger.h" - -typedef cereal::Sentinel::SentinelType SentinelType; - -void verify_segment(const std::string &route_path, int segment, int max_segment, int required_event_cnt) { - const std::string segment_path = route_path + "--" + std::to_string(segment); - SentinelType begin_sentinel = segment == 0 ? SentinelType::START_OF_ROUTE : SentinelType::START_OF_SEGMENT; - SentinelType end_sentinel = segment == max_segment - 1 ? SentinelType::END_OF_ROUTE : SentinelType::END_OF_SEGMENT; - - REQUIRE(!util::file_exists(segment_path + "/rlog.lock")); - for (const char *fn : {"/rlog.zst", "/qlog.zst"}) { - const std::string log_file = segment_path + fn; - std::string log = util::read_file(log_file); - REQUIRE(!log.empty()); - std::string decompressed_log = zstd_decompress(log); - int event_cnt = 0, i = 0; - kj::ArrayPtr words((capnp::word *)decompressed_log.data(), decompressed_log.size() / sizeof(capnp::word)); - while (words.size() > 0) { - try { - capnp::FlatArrayMessageReader reader(words); - auto event = reader.getRoot(); - words = kj::arrayPtr(reader.getEnd(), words.end()); - if (i == 0) { - REQUIRE(event.which() == cereal::Event::INIT_DATA); - } else if (i == 1) { - REQUIRE(event.which() == cereal::Event::SENTINEL); - REQUIRE(event.getSentinel().getType() == begin_sentinel); - REQUIRE(event.getSentinel().getSignal() == 0); - } else if (words.size() > 0) { - REQUIRE(event.which() == cereal::Event::CLOCKS); - ++event_cnt; - } else { - // the last event must be SENTINEL - REQUIRE(event.which() == cereal::Event::SENTINEL); - REQUIRE(event.getSentinel().getType() == end_sentinel); - REQUIRE(event.getSentinel().getSignal() == (end_sentinel == SentinelType::END_OF_ROUTE ? 1 : 0)); - } - ++i; - } catch (const kj::Exception &ex) { - INFO("failed parse " << i << " exception :" << ex.getDescription()); - REQUIRE(0); - break; - } - } - REQUIRE(event_cnt == required_event_cnt); - } -} - -void write_msg(LoggerState *logger) { - MessageBuilder msg; - msg.initEvent().initClocks(); - logger->write(msg.toBytes(), true); -} - -TEST_CASE("logger") { - const int segment_cnt = 100; - const std::string log_root = "/tmp/test_logger"; - REQUIRE(system(("rm " + log_root + " -rf").c_str()) == 0); - std::string route_name; - { - LoggerState logger(log_root); - route_name = logger.routeName(); - for (int i = 0; i < segment_cnt; ++i) { - REQUIRE(logger.next()); - REQUIRE(util::file_exists(logger.segmentPath() + "/rlog.lock")); - REQUIRE(logger.segment() == i); - write_msg(&logger); - } - logger.setExitSignal(1); - } - for (int i = 0; i < segment_cnt; ++i) { - verify_segment(log_root + "/" + route_name, i, segment_cnt, 1); - } -} diff --git a/openpilot/system/loggerd/tests/test_loggerd.py b/openpilot/system/loggerd/tests/test_loggerd.py index 3678ca95be..de91cae1db 100644 --- a/openpilot/system/loggerd/tests/test_loggerd.py +++ b/openpilot/system/loggerd/tests/test_loggerd.py @@ -8,8 +8,9 @@ import time from collections.abc import Collection from collections import defaultdict from pathlib import Path -import pytest +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from openpilot.cereal import log from openpilot.cereal.services import SERVICE_LIST @@ -17,14 +18,15 @@ from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.timeout import Timeout from openpilot.common.hardware.hw import Paths -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE from openpilot.system.loggerd.xattr_cache import getxattr from openpilot.system.loggerd.deleter import PRESERVE_ATTR_NAME, PRESERVE_ATTR_VALUE from openpilot.system.manager.process_config import managed_processes from openpilot.common.version import get_version from openpilot.tools.lib.helpers import RE from openpilot.tools.lib.logreader import LogReader -from msgq.visionipc import VisionIpcServer, VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcServer SentinelType = log.Sentinel.SentinelType @@ -32,7 +34,7 @@ CEREAL_SERVICES = [f for f in log.Event.schema.union_fields if f in SERVICE_LIST and SERVICE_LIST[f].should_log and "encode" not in f.lower()] -class TestLoggerd: +class TestLoggerd(OpenpilotTestCase): def _get_latest_log_dir(self): log_dirs = sorted(Path(Paths.log_root()).iterdir(), key=lambda f: f.stat().st_mtime) return log_dirs[-1] @@ -110,12 +112,12 @@ class TestLoggerd: w, h = 320, 240 frame_spec = (w, h, w * h * 3 // 2, w, w * h) streams = [ - (VisionStreamType.VISION_STREAM_ROAD, frame_spec, "roadCameraState"), - (VisionStreamType.VISION_STREAM_DRIVER, frame_spec, "driverCameraState"), + (VisionStreamType.VISION_STREAM_NARROW_ROAD, frame_spec, "narrowRoadCameraState"), + (VisionStreamType.VISION_STREAM_CABIN, frame_spec, "cabinCameraState"), (VisionStreamType.VISION_STREAM_WIDE_ROAD, frame_spec, "wideRoadCameraState"), ] - sm = messaging.SubMaster(["roadEncodeData"]) + sm = messaging.SubMaster(["narrowRoadEncodeData"]) pm = messaging.PubMaster([s for _, _, s in streams] + ["rawAudioData"]) vipc_server = VisionIpcServer("camerad") for stream_type, frame_spec, _ in streams: @@ -126,7 +128,7 @@ class TestLoggerd: os.environ["LOGGERD_SEGMENT_LENGTH"] = str(segment_length) managed_processes["loggerd"].start() managed_processes["encoderd"].start() - assert pm.wait_for_readers_to_update("roadCameraState", timeout=5) + assert pm.wait_for_readers_to_update("narrowRoadCameraState", timeout=5) fps = 20 for n in range(1, int(num_segs * segment_length * fps) + 1): @@ -193,7 +195,6 @@ class TestLoggerd: assert getattr(initData, initData_key) == v assert logged_params[param_key].decode() == v - @pytest.mark.xdist_group("camera_encoder_tests") # setting xdist group ensures tests are run in same worker, prevents encoderd from crashing def test_rotation(self): Params().put("RecordFront", True, block=True) @@ -234,7 +235,7 @@ class TestLoggerd: assert abs(boot.wallTimeNanos - time.time_ns()) < 5*1e9 # within 5s assert boot.launchLog == launch_log - if TICI: + if COMMA_HARDWARE: for fn in ["console-ramoops", "pmsg-ramoops-0"]: path = Path(os.path.join("/sys/fs/pstore/", fn)) if path.is_file(): @@ -308,25 +309,23 @@ class TestLoggerd: assert getxattr(segment_dir, PRESERVE_ATTR_NAME) == PRESERVE_ATTR_VALUE def test_not_preserving_nonbookmarked_segments(self): - services = set(random.sample(CEREAL_SERVICES, random.randint(5, 10))) - {"userBookmark", "audioFeedback"} + services = set(random.sample(CEREAL_SERVICES, random.randint(5, 10))) - {"userBookmark"} self._publish_random_messages(services) segment_dir = self._get_latest_log_dir() assert getxattr(segment_dir, PRESERVE_ATTR_NAME) is None - @pytest.mark.xdist_group("camera_encoder_tests") # setting xdist group ensures tests are run in same worker, prevents encoderd from crashing - @pytest.mark.parametrize("record_front", [True, False]) + @parameterized.expand([True, False]) def test_record_front(self, record_front): params = Params() params.put_bool("RecordFront", record_front, block=True) self._publish_camera_and_audio_messages() - dcamera_hevc_exists = os.path.exists(os.path.join(self._get_latest_log_dir(), 'dcamera.hevc')) - assert dcamera_hevc_exists == record_front + cabin_hevc_exists = os.path.exists(os.path.join(self._get_latest_log_dir(), 'dcamera.hevc')) + assert cabin_hevc_exists == record_front - @pytest.mark.xdist_group("camera_encoder_tests") # setting xdist group ensures tests are run in same worker, prevents encoderd from crashing - @pytest.mark.parametrize("record_audio", [True, False]) + @parameterized.expand([True, False]) def test_record_audio(self, record_audio): params = Params() params.put_bool("RecordAudio", record_audio, block=True) diff --git a/openpilot/system/loggerd/tests/test_runner.cc b/openpilot/system/loggerd/tests/test_runner.cc deleted file mode 100644 index 62bf7476a1..0000000000 --- a/openpilot/system/loggerd/tests/test_runner.cc +++ /dev/null @@ -1,2 +0,0 @@ -#define CATCH_CONFIG_MAIN -#include "catch2/catch.hpp" diff --git a/openpilot/system/loggerd/tests/test_uploader.py b/openpilot/system/loggerd/tests/test_uploader.py index 3af78e2c37..b83b3569e2 100644 --- a/openpilot/system/loggerd/tests/test_uploader.py +++ b/openpilot/system/loggerd/tests/test_uploader.py @@ -1,5 +1,4 @@ import os -import time import threading import logging import json @@ -7,7 +6,7 @@ from pathlib import Path from openpilot.common.hardware.hw import Paths from openpilot.common.swaglog import cloudlog -from openpilot.system.loggerd.uploader import main, UPLOAD_ATTR_NAME, UPLOAD_ATTR_VALUE +from openpilot.system.loggerd.uploader import clear_locks, main, Uploader, UPLOAD_ATTR_NAME, UPLOAD_ATTR_VALUE from openpilot.system.loggerd.tests.loggerd_tests_common import UploaderTestCase @@ -15,29 +14,38 @@ from openpilot.system.loggerd.tests.loggerd_tests_common import UploaderTestCase class FakeLogHandler(logging.Handler): def __init__(self): logging.Handler.__init__(self) + self.condition = threading.Condition() self.reset() def reset(self): - self.upload_order = [] - self.upload_ignored = [] + with self.condition: + self.upload_order = [] + self.upload_ignored = [] def emit(self, record): try: j = json.loads(record.getMessage()) - if j["event"] == "upload_success": - self.upload_order.append(j["key"]) - if j["event"] == "upload_ignored": - self.upload_ignored.append(j["key"]) + with self.condition: + if j["event"] == "upload_success": + self.upload_order.append(j["key"]) + if j["event"] == "upload_ignored": + self.upload_ignored.append(j["key"]) + self.condition.notify_all() except Exception: pass + def wait_for_uploads(self, count: int, ignored: bool = False): + uploads = self.upload_ignored if ignored else self.upload_order + with self.condition: + assert self.condition.wait_for(lambda: len(uploads) >= count, timeout=1), "Uploader did not process all files" + log_handler = FakeLogHandler() cloudlog.addHandler(log_handler) class TestUploader(UploaderTestCase): def setup_method(self): - super().setup_method() + super().openpilot_setup_method() log_handler.reset() def start_thread(self): @@ -70,14 +78,12 @@ class TestUploader(UploaderTestCase): def test_upload(self): self.gen_files(lock=False) + exp_order = self.gen_order([self.seg_num], []) self.start_thread() - # allow enough time that files could upload twice if there is a bug in the logic - time.sleep(1) + log_handler.wait_for_uploads(len(exp_order)) self.join_thread() - exp_order = self.gen_order([self.seg_num], []) - assert len(log_handler.upload_ignored) == 0, "Some files were ignored" assert not len(log_handler.upload_order) < len(exp_order), "Some files failed to upload" assert not len(log_handler.upload_order) > len(exp_order), "Some files were uploaded twice" @@ -88,14 +94,12 @@ class TestUploader(UploaderTestCase): def test_upload_with_wrong_xattr(self): self.gen_files(lock=False, xattr=b'0') + exp_order = self.gen_order([self.seg_num], []) self.start_thread() - # allow enough time that files could upload twice if there is a bug in the logic - time.sleep(1) + log_handler.wait_for_uploads(len(exp_order)) self.join_thread() - exp_order = self.gen_order([self.seg_num], []) - assert len(log_handler.upload_ignored) == 0, "Some files were ignored" assert not len(log_handler.upload_order) < len(exp_order), "Some files failed to upload" assert not len(log_handler.upload_order) > len(exp_order), "Some files were uploaded twice" @@ -107,14 +111,12 @@ class TestUploader(UploaderTestCase): def test_upload_ignored(self): self.set_ignore() self.gen_files(lock=False) + exp_order = self.gen_order([self.seg_num], []) self.start_thread() - # allow enough time that files could upload twice if there is a bug in the logic - time.sleep(1) + log_handler.wait_for_uploads(len(exp_order), ignored=True) self.join_thread() - exp_order = self.gen_order([self.seg_num], []) - assert len(log_handler.upload_order) == 0, "Some files were not ignored" assert not len(log_handler.upload_ignored) < len(exp_order), "Some files failed to ignore" assert not len(log_handler.upload_ignored) > len(exp_order), "Some files were ignored twice" @@ -136,8 +138,7 @@ class TestUploader(UploaderTestCase): exp_order = self.gen_order(seg1_nums, seg2_nums, boot=False) self.start_thread() - # allow enough time that files could upload twice if there is a bug in the logic - time.sleep(1) + log_handler.wait_for_uploads(len(exp_order)) self.join_thread() assert len(log_handler.upload_ignored) == 0, "Some files were ignored" @@ -149,34 +150,30 @@ class TestUploader(UploaderTestCase): assert log_handler.upload_order == exp_order, "Files uploaded in wrong order" def test_no_upload_with_lock_file(self): - self.start_thread() - - time.sleep(0.25) f_paths = self.gen_files(lock=True, boot=False) - - # allow enough time that files should have been uploaded if they would be uploaded - time.sleep(1) - self.join_thread() + uploader = Uploader("0000000000000000", Paths.log_root()) for f_path in f_paths: fn = f_path.with_suffix(f_path.suffix.replace(".zst", "")) - uploaded = UPLOAD_ATTR_NAME in os.listxattr(fn) and os.getxattr(fn, UPLOAD_ATTR_NAME) == UPLOAD_ATTR_VALUE - assert not uploaded, "File upload when locked" + assert all(candidate[2] != str(fn) for candidate in uploader.list_upload_files(metered=False)), "Locked file selected for upload" def test_no_upload_with_xattr(self): - self.gen_files(lock=False, xattr=UPLOAD_ATTR_VALUE) + f_paths = self.gen_files(lock=False, xattr=UPLOAD_ATTR_VALUE) + uploader = Uploader("0000000000000000", Paths.log_root()) + upload_candidates = {candidate[2] for candidate in uploader.list_upload_files(metered=False)} + assert upload_candidates.isdisjoint(map(str, f_paths)), "Uploaded file selected again" - self.start_thread() - # allow enough time that files could upload twice if there is a bug in the logic - time.sleep(1) - self.join_thread() - - assert len(log_handler.upload_order) == 0, "File uploaded again" - - def test_clear_locks_on_startup(self): + def test_clear_locks_on_startup(self, mocker): f_paths = self.gen_files(lock=True, boot=False) + locks_cleared = threading.Event() + + def clear_locks_and_signal(root): + clear_locks(root) + locks_cleared.set() + + mocker.patch("openpilot.system.loggerd.uploader.clear_locks", side_effect=clear_locks_and_signal) self.start_thread() - time.sleep(0.25) + assert locks_cleared.wait(timeout=1), "Uploader did not clear locks on startup" self.join_thread() for f_path in f_paths: diff --git a/openpilot/system/loggerd/tests/test_zstd_writer.cc b/openpilot/system/loggerd/tests/test_zstd_writer.cc deleted file mode 100644 index 479e866a14..0000000000 --- a/openpilot/system/loggerd/tests/test_zstd_writer.cc +++ /dev/null @@ -1,44 +0,0 @@ -#include - -#include -#include -#include - -#include "common/util.h" -#include "system/loggerd/logger.h" -#include "system/loggerd/zstd_writer.h" - -TEST_CASE("ZstdFileWriter writes and compresses data correctly in loops", "[ZstdFileWriter]") { - const std::string filename = "test_zstd_file.zst"; - const int iterations = 100; - const size_t dataSize = 1024; - - std::string totalTestData; - - // Step 1: Write compressed data to file in a loop - { - ZstdFileWriter writer(filename, LOG_COMPRESSION_LEVEL); - // Write various data sizes including edge cases - std::vector testSizes = {dataSize, 1, 0, dataSize * 2}; // Normal, minimal, empty, large - for (int i = 0; i < iterations; ++i) { - size_t currentSize = testSizes[i % testSizes.size()]; - std::string testData = util::random_string(currentSize); - totalTestData.append(testData); - - writer.write((void *)testData.c_str(), testData.size()); - } - } - - // Step 2: Decompress the file and verify the data - auto compressedContent = util::read_file(filename); - REQUIRE(compressedContent.size() > 0); - REQUIRE(compressedContent.size() < totalTestData.size()); - std::string decompressedData = zstd_decompress(compressedContent); - - // Step 3: Verify that the decompressed data matches the original accumulated data - REQUIRE(decompressedData.size() == totalTestData.size()); - REQUIRE(std::memcmp(decompressedData.data(), totalTestData.c_str(), totalTestData.size()) == 0); - - // Clean up the test file - std::remove(filename.c_str()); -} diff --git a/openpilot/system/loggerd/video_writer.cc b/openpilot/system/loggerd/video_writer.cc index 1b47a8fceb..70f0199299 100644 --- a/openpilot/system/loggerd/video_writer.cc +++ b/openpilot/system/loggerd/video_writer.cc @@ -49,6 +49,11 @@ VideoWriter::VideoWriter(const char *path, const char *filename, bool remuxing, } } +void VideoWriter::set_metadata(const char *key, const char *value) { + assert(remuxing && !header_written); + av_dict_set(&ofmt_ctx->metadata, key, value, 0); +} + void VideoWriter::initialize_audio(int sample_rate) { assert(this->ofmt_ctx->oformat->audio_codec != AV_CODEC_ID_NONE); // check output format supports audio streams const AVCodec *audio_avcodec = avcodec_find_encoder(AV_CODEC_ID_AAC); @@ -106,7 +111,10 @@ void VideoWriter::write(uint8_t *data, int len, long long timestamp, bool codecc int err = avcodec_parameters_from_context(out_stream->codecpar, codec_ctx); assert(err >= 0); // if there is an audio stream, it must be initialized before this point - err = avformat_write_header(ofmt_ctx, NULL); + AVDictionary *options = nullptr; + if (ofmt_ctx->metadata) av_dict_set(&options, "movflags", "+faststart+use_metadata_tags", 0); + err = avformat_write_header(ofmt_ctx, &options); + av_dict_free(&options); assert(err >= 0); header_written = true; } else { diff --git a/openpilot/system/loggerd/video_writer.h b/openpilot/system/loggerd/video_writer.h index fdec606058..1120d94a78 100644 --- a/openpilot/system/loggerd/video_writer.h +++ b/openpilot/system/loggerd/video_writer.h @@ -13,6 +13,7 @@ extern "C" { class VideoWriter { public: VideoWriter(const char *path, const char *filename, bool remuxing, int width, int height, int fps, cereal::EncodeIndex::Type codec); + void set_metadata(const char *key, const char *value); void write(uint8_t *data, int len, long long timestamp, bool codecconfig, bool keyframe); void write_audio(uint8_t *data, int len, long long timestamp, int sample_rate); diff --git a/openpilot/system/manager/build.py b/openpilot/system/manager/build.py index 75a7dc63eb..c5fdd6d92a 100755 --- a/openpilot/system/manager/build.py +++ b/openpilot/system/manager/build.py @@ -50,6 +50,8 @@ def build() -> None: if scons.returncode == 0: break + os.sync() + if scons.returncode != 0: # Build failed log errors error_s = b"\n".join(compile_output).decode('utf8', 'replace') diff --git a/openpilot/system/manager/process.py b/openpilot/system/manager/process.py index 7f7eabd7be..43ea3f7306 100644 --- a/openpilot/system/manager/process.py +++ b/openpilot/system/manager/process.py @@ -68,16 +68,11 @@ class ManagerProcess(ABC): enabled = True name = "" shutting_down = False - restart_if_crash = False @abstractmethod def start(self) -> None: pass - def restart(self) -> None: - self.stop(sig=signal.SIGKILL) - self.start() - def stop(self, retry: bool = True, block: bool = True, sig: signal.Signals | None = None) -> int | None: if self.proc is None: return None @@ -162,14 +157,13 @@ class NativeProcess(ManagerProcess): class PythonProcess(ManagerProcess): - def __init__(self, name, module, should_run, enabled=True, sigkill=False, restart_if_crash=False): + def __init__(self, name, module, should_run, enabled=True, sigkill=False): self.name = name self.module = module self.should_run = should_run self.enabled = enabled self.sigkill = sigkill self.launcher = launcher - self.restart_if_crash = restart_if_crash def start(self) -> None: # In case we only tried a non blocking stop we need to stop it before restarting @@ -228,7 +222,7 @@ class DaemonProcess(ManagerProcess): pass -def ensure_running(procs: ValuesView[ManagerProcess], started: bool, params=None, CP: car.CarParams=None, +def ensure_running(procs: ValuesView[ManagerProcess], started: bool, params: Params, CP: car.CarParams, not_run: list[str] | None=None) -> list[ManagerProcess]: if not_run is None: not_run = [] @@ -236,9 +230,6 @@ def ensure_running(procs: ValuesView[ManagerProcess], started: bool, params=None running = [] for p in procs: if p.enabled and p.name not in not_run and p.should_run(started, params, CP): - if p.restart_if_crash and p.proc is not None and not p.proc.is_alive(): - cloudlog.error(f'Restarting {p.name} (exitcode {p.proc.exitcode})') - p.restart() running.append(p) else: p.stop(block=False) diff --git a/openpilot/system/manager/process_config.py b/openpilot/system/manager/process_config.py index 08e1aabef3..c9e939c432 100644 --- a/openpilot/system/manager/process_config.py +++ b/openpilot/system/manager/process_config.py @@ -5,7 +5,7 @@ import platform from opendbc.car.structs import car from openpilot.cereal import custom from openpilot.common.params import Params -from openpilot.common.hardware import PC, TICI +from openpilot.common.hardware import PC, COMMA_HARDWARE from openpilot.system.manager.process import PythonProcess, NativeProcess, DaemonProcess from openpilot.common.hardware.hw import Paths @@ -132,7 +132,7 @@ procs = [ PythonProcess("dmonitoringmodeld", "openpilot.selfdrive.modeld.dmonitoringmodeld", driverview, enabled=(WEBCAM or not PC)), PythonProcess("sensord", "openpilot.system.sensord.sensord", only_onroad, enabled=not PC), - PythonProcess("ui", "openpilot.selfdrive.ui.ui", always_run, restart_if_crash=True), + PythonProcess("ui", "openpilot.selfdrive.ui.ui", always_run), PythonProcess("soundd", "openpilot.selfdrive.ui.soundd", driverview), PythonProcess("locationd", "openpilot.selfdrive.locationd.locationd", only_onroad), NativeProcess("_pandad", "openpilot/selfdrive/pandad", ["./pandad"], always_run, enabled=False), @@ -144,23 +144,22 @@ procs = [ PythonProcess("card", "openpilot.selfdrive.car.card", only_onroad), PythonProcess("deleter", "openpilot.system.loggerd.deleter", always_run), PythonProcess("dmonitoringd", "openpilot.selfdrive.monitoring.dmonitoringd", driverview, enabled=(WEBCAM or not PC)), - PythonProcess("qcomgpsd", "openpilot.system.qcomgpsd.qcomgpsd", qcomgps, enabled=TICI), + PythonProcess("qcomgpsd", "openpilot.system.qcomgpsd.qcomgpsd", qcomgps, enabled=COMMA_HARDWARE), PythonProcess("pandad", "openpilot.selfdrive.pandad.pandad", always_run), PythonProcess("paramsd", "openpilot.selfdrive.locationd.paramsd", only_onroad), PythonProcess("lagd", "openpilot.selfdrive.locationd.lagd", only_onroad), - PythonProcess("ubloxd", "openpilot.system.ubloxd.ubloxd", ublox, enabled=TICI), - PythonProcess("pigeond", "openpilot.system.ubloxd.pigeond", ublox, enabled=TICI), + PythonProcess("ubloxd", "openpilot.system.ubloxd.ubloxd", ublox, enabled=COMMA_HARDWARE), + PythonProcess("pigeond", "openpilot.system.ubloxd.pigeond", ublox, enabled=COMMA_HARDWARE), PythonProcess("plannerd", "openpilot.selfdrive.controls.plannerd", not_long_maneuver), PythonProcess("maneuversd", "openpilot.tools.longitudinal_maneuvers.maneuversd", long_maneuver), PythonProcess("lateral_maneuversd", "openpilot.tools.lateral_maneuvers.lateral_maneuversd", lat_maneuver), PythonProcess("radard", "openpilot.selfdrive.controls.radard", only_onroad), PythonProcess("hardwared", "openpilot.system.hardware.hardwared", always_run), - PythonProcess("modem", "openpilot.common.hardware.tici.modem", always_run, enabled=TICI), + PythonProcess("modem", "openpilot.common.hardware.comma.modem", always_run, enabled=COMMA_HARDWARE), PythonProcess("tombstoned", "openpilot.system.tombstoned", always_run, enabled=not PC), PythonProcess("updated", "openpilot.system.updated.updated", only_offroad, enabled=not PC), PythonProcess("uploader", "openpilot.system.loggerd.uploader", uploader_ready), PythonProcess("statsd", "openpilot.sunnypilot.system.statsd", always_run), - PythonProcess("feedbackd", "openpilot.selfdrive.ui.feedback.feedbackd", only_onroad), # debug procs NativeProcess("bridge", "openpilot/cereal/messaging", ["./bridge"], notcar), diff --git a/openpilot/system/manager/test/test_manager.py b/openpilot/system/manager/test/test_manager.py old mode 100644 new mode 100755 index a3808bf29f..54bdaec6fe --- a/openpilot/system/manager/test/test_manager.py +++ b/openpilot/system/manager/test/test_manager.py @@ -1,9 +1,12 @@ +#!/usr/bin/env python3 + import os -import pytest +import unittest import signal import time from opendbc.car.structs import car +from openpilot.common.test import OpenpilotTestCase from openpilot.common.params import Params import openpilot.system.manager.manager as manager from openpilot.system.manager.process import ensure_running @@ -16,7 +19,7 @@ MAX_STARTUP_TIME = 3 BLACKLIST_PROCS = ['manage_athenad', 'pandad', 'pigeond'] -class TestManager: +class TestManager(OpenpilotTestCase): def setup_method(self): HARDWARE.set_power_save(False) @@ -47,7 +50,7 @@ class TestManager: assert params.get("OpenpilotEnabledToggle") assert params.get("RouteCount") == 0 - @pytest.mark.skip("this test is flaky the way it's currently written, should be moved to test_onroad") + @unittest.skip("this test is flaky the way it's currently written, should be moved to test_onroad") def test_clean_exit(self, subtests): """ Ensure all processes exit cleanly when stopped. @@ -75,3 +78,7 @@ class TestManager: if p.sigkill: exit_codes = [-signal.SIGKILL] assert exit_code in exit_codes, f"{p.name} died with {exit_code}" + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/system/micd.py b/openpilot/system/micd.py index a3a93c8c1a..7167aad137 100755 --- a/openpilot/system/micd.py +++ b/openpilot/system/micd.py @@ -15,6 +15,14 @@ SAMPLE_RATE = 16000 SAMPLE_BUFFER = 800 # 50ms +def patch_sounddevice(sd): + # TODO: remove once sounddevice uses np.reshape internally. + def sounddevice_array(buffer, channels, dtype): + return np.frombuffer(buffer, dtype=dtype).reshape(-1, channels) + + sd._array = sounddevice_array + + @cache def get_a_weighting_filter(): # Calculate the A-weighting filter @@ -104,6 +112,7 @@ class Mic: def micd_thread(self): # sounddevice must be imported after forking processes import sounddevice as sd + patch_sounddevice(sd) with self.get_stream(sd) as stream: cloudlog.info(f"micd stream started: {stream.samplerate=} {stream.channels=} {stream.dtype=} {stream.device=}, {stream.blocksize=}") diff --git a/openpilot/system/qcomgpsd/nmeaport.py b/openpilot/system/qcomgpsd/nmeaport.py index db1fe5552d..e270e23a3a 100644 --- a/openpilot/system/qcomgpsd/nmeaport.py +++ b/openpilot/system/qcomgpsd/nmeaport.py @@ -121,7 +121,7 @@ def process_nmea_port_messages(device:str="/dev/ttyUSB1") -> NoReturn: def main() -> NoReturn: from openpilot.common.gpio import gpio_init, gpio_set - from openpilot.common.hardware.tici.pins import GPIO + from openpilot.common.hardware.comma.pins import GPIO from openpilot.system.qcomgpsd.qcomgpsd import at_cmd try: diff --git a/openpilot/system/qcomgpsd/qcomgpsd.py b/openpilot/system/qcomgpsd/qcomgpsd.py index 4e52154419..6261b18d56 100755 --- a/openpilot/system/qcomgpsd/qcomgpsd.py +++ b/openpilot/system/qcomgpsd/qcomgpsd.py @@ -15,7 +15,7 @@ import openpilot.cereal.messaging as messaging from openpilot.common.gpio import gpio_init, gpio_set from openpilot.common.utils import retry from openpilot.common.time_helpers import system_time_valid -from openpilot.common.hardware.tici.pins import GPIO +from openpilot.common.hardware.comma.pins import GPIO from openpilot.common.serial import Serial from openpilot.common.swaglog import cloudlog from openpilot.system.qcomgpsd.modemdiag import ModemDiag, DIAG_LOG_F, setup_logs, send_recv diff --git a/openpilot/system/sensord/tests/test_sensord.py b/openpilot/system/sensord/tests/test_sensord.py old mode 100644 new mode 100755 index dc96886e4a..ddbef7895b --- a/openpilot/system/sensord/tests/test_sensord.py +++ b/openpilot/system/sensord/tests/test_sensord.py @@ -1,10 +1,13 @@ +#!/usr/bin/env python3 + import os import subprocess -import pytest import time +import unittest import numpy as np from collections import namedtuple, defaultdict +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from openpilot.cereal.services import SERVICE_LIST from openpilot.common.gpio import get_irqs_for_action @@ -54,8 +57,8 @@ def iter_measurements(events): for measurement in msgs: yield measurement, getattr(measurement, measurement.which()) -@pytest.mark.tici -class TestSensord: +class TestSensord(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True @classmethod def setup_class(cls): # enable LSM self test @@ -183,3 +186,7 @@ class TestSensord: time.sleep(1) state_two = get_irq_count(self.sensord_irq) assert state_one == state_two, "Interrupts received after sensord stop!" + + +if __name__ == "__main__": + unittest.main() diff --git a/openpilot/system/tests/test_logmessaged.py b/openpilot/system/tests/test_logmessaged.py index e2637fb0e6..247bfd8a42 100644 --- a/openpilot/system/tests/test_logmessaged.py +++ b/openpilot/system/tests/test_logmessaged.py @@ -2,13 +2,14 @@ import glob import os import time +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from openpilot.system.manager.process_config import managed_processes from openpilot.common.hardware.hw import Paths from openpilot.common.swaglog import cloudlog, ipchandler -class TestLogmessaged: +class TestLogmessaged(OpenpilotTestCase): def setup_method(self): # clear the IPC buffer in case some other tests used cloudlog and filled it ipchandler.close() @@ -52,4 +53,3 @@ class TestLogmessaged: logsize = sum([os.path.getsize(f) for f in self._get_log_files()]) assert (n*len(msg)) < logsize < (n*(len(msg)+1024)) - diff --git a/openpilot/system/timed.py b/openpilot/system/timed.py index 0413f645e3..6e5200d10e 100755 --- a/openpilot/system/timed.py +++ b/openpilot/system/timed.py @@ -12,7 +12,7 @@ from openpilot.common.gps import get_gps_location_service def set_time(new_time): - diff = datetime.datetime.now() - new_time + diff = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - new_time if abs(diff) < datetime.timedelta(seconds=10): cloudlog.debug(f"Time diff too small: {diff}") return @@ -47,7 +47,7 @@ def main() -> NoReturn: pm.send('clocks', msg) gps = sm[gps_location_service] - gps_time = datetime.datetime.fromtimestamp(gps.unixTimestampMillis / 1000.) + gps_time = datetime.datetime.fromtimestamp(gps.unixTimestampMillis / 1000., datetime.UTC).replace(tzinfo=None) if not sm.updated[gps_location_service] or (time.monotonic() - sm.logMonoTime[gps_location_service] / 1e9) > 2.0: continue if not gps.hasFix: diff --git a/openpilot/system/ubloxd/pigeond.py b/openpilot/system/ubloxd/pigeond.py index 9891285465..3da9350241 100755 --- a/openpilot/system/ubloxd/pigeond.py +++ b/openpilot/system/ubloxd/pigeond.py @@ -8,13 +8,14 @@ import urllib.parse from datetime import datetime, UTC from openpilot.cereal import messaging +from openpilot.common.api import Api from openpilot.common.time_helpers import system_time_valid from openpilot.common.params import Params from openpilot.common.serial import Serial from openpilot.common.swaglog import cloudlog -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE from openpilot.common.gpio import gpio_init, gpio_set -from openpilot.common.hardware.tici.pins import GPIO +from openpilot.common.hardware.comma.pins import GPIO UBLOX_TTY = "/dev/ttyHS0" @@ -41,15 +42,24 @@ def add_ubx_checksum(msg: bytes) -> bytes: B = (B + A) % 256 return msg + bytes([A, B]) -def get_assistnow_messages(token: str) -> list[bytes]: - # make request - # TODO: implement adding the last known location - r = requests.get("https://online-live2.services.u-blox.com/GetOnlineData.ashx", params=urllib.parse.urlencode({ - 'token': token, - 'gnss': 'gps,glo', - 'datatype': 'eph,alm,aux', - }, safe=':,'), timeout=5) - assert r.status_code == 200, "Got invalid status code" +def get_assistnow_messages() -> list[bytes]: + params = Params() + if token := params.get('AssistNowToken'): + cloudlog.warning("Downloading AssistNow data directly from u-blox") + # TODO: implement adding the last known location + r = requests.get("https://online-live2.services.u-blox.com/GetOnlineData.ashx", params=urllib.parse.urlencode({ + 'token': token, + 'gnss': 'gps,glo', + 'datatype': 'eph,alm,aux', + }, safe=':,'), timeout=5) + elif dongle_id := params.get('DongleId'): + cloudlog.warning("Downloading AssistNow data from comma's AGPS proxy") + api = Api(dongle_id) + r = api.get(f"v1/{dongle_id}/assist", access_token=api.get_token(), timeout=5) + else: + raise RuntimeError("Neither AssistNowToken nor DongleId is configured") + + r.raise_for_status() dat = r.content # split up messages @@ -230,15 +240,13 @@ def init_pigeon(pigeon: TTYPigeon) -> bool: )) pigeon.send_with_ack(msg, ack=UBLOX_ASSIST_ACK) - # try getting AssistNow if we have a token - token = Params().get('AssistNowToken') - if token is not None: - try: - for msg in get_assistnow_messages(token): - pigeon.send_with_ack(msg, ack=UBLOX_ASSIST_ACK) - cloudlog.warning("AssistNow messages sent") - except Exception: - cloudlog.warning("failed to get AssistNow messages") + # A configured u-blox token takes precedence over comma's AGPS proxy. + try: + for msg in get_assistnow_messages(): + pigeon.send_with_ack(msg, ack=UBLOX_ASSIST_ACK) + cloudlog.warning("AssistNow messages sent") + except Exception: + cloudlog.warning("failed to get AssistNow messages") cloudlog.warning("Pigeon GPS on!") break @@ -302,7 +310,7 @@ def run_receiving(duration: int = 0): def main(): - assert TICI, "unsupported hardware for pigeond" + assert COMMA_HARDWARE, "unsupported hardware for pigeond" run_receiving() if __name__ == "__main__": diff --git a/openpilot/system/ubloxd/tests/test_pigeond.py b/openpilot/system/ubloxd/tests/test_pigeond.py index b894ed718b..0d2f5b00e7 100644 --- a/openpilot/system/ubloxd/tests/test_pigeond.py +++ b/openpilot/system/ubloxd/tests/test_pigeond.py @@ -1,17 +1,17 @@ -import pytest import time +from openpilot.common.test import OpenpilotTestCase import openpilot.cereal.messaging as messaging from openpilot.cereal.services import SERVICE_LIST from openpilot.common.gpio import gpio_read from openpilot.selfdrive.test.helpers import with_processes from openpilot.system.manager.process_config import managed_processes -from openpilot.common.hardware.tici.pins import GPIO +from openpilot.common.hardware.comma.pins import GPIO # TODO: test TTFF when we have good A-GNSS -@pytest.mark.tici -class TestPigeond: +class TestPigeond(OpenpilotTestCase): + COMMA_HARDWARE_TEST = True def teardown_method(self): managed_processes['pigeond'].stop() diff --git a/openpilot/system/ui/lib/application.py b/openpilot/system/ui/lib/application.py index 58fcc15a22..0db4878d94 100644 --- a/openpilot/system/ui/lib/application.py +++ b/openpilot/system/ui/lib/application.py @@ -19,7 +19,7 @@ from typing import NamedTuple from importlib.resources import as_file, files from openpilot.common.swaglog import cloudlog from openpilot.common.hardware import HARDWARE, PC -from openpilot.system.ui.lib.multilang import multilang +from openpilot.system.ui.lib.multilang import FONT_FALLBACK_LANGUAGES, TRANSLATIONS_DIR, multilang from openpilot.common.realtime import Ratekeeper from openpilot.system.ui.sunnypilot.lib.application import GuiApplicationExt @@ -94,26 +94,34 @@ FONT_SCALE = 1.242 if BIG_UI else 1.16 ASSETS_DIR = files("openpilot.selfdrive").joinpath("assets") FONT_DIR = ASSETS_DIR.joinpath("fonts") +EXTRA_FONT_CHARS = "–‑✓×°§•X⚙✕◀▶✔⌫⇧␣○●↳çêüñ–‑✓×°§•€£¥" +NOTO_FONTS = { + "ja": "NotoSansCJKjp-Regular.otf", + "ko": "NotoSansCJKkr-Regular.otf", + "th": "NotoSansThai-Regular.ttf", + "zh-CHS": "NotoSansCJKsc-Regular.otf", + "zh-CHT": "NotoSansCJKtc-Regular.otf", +} class FontWeight(StrEnum): - NORMAL = "Inter-Regular.fnt" if BIG_UI else "Inter-Medium.fnt" - MEDIUM = "Inter-Medium.fnt" - BOLD = "Inter-Bold.fnt" - SEMI_BOLD = "Inter-SemiBold.fnt" - UNIFONT = "unifont.fnt" - AUDIOWIDE = "Audiowide-Regular.fnt" + NORMAL = "Inter-Regular.ttf" if BIG_UI else "Inter-Medium.ttf" + MEDIUM = "Inter-Medium.ttf" + BOLD = "Inter-Bold.ttf" + SEMI_BOLD = "Inter-SemiBold.ttf" + UNIFONT = "unifont.otf" + AUDIOWIDE = "Audiowide-Regular.ttf" # Small UI fonts - DISPLAY_REGULAR = "Inter-Regular.fnt" - ROMAN = "Inter-Regular.fnt" - DISPLAY = "Inter-Bold.fnt" + DISPLAY_REGULAR = "Inter-Regular.ttf" + ROMAN = "Inter-Regular.ttf" + DISPLAY = "Inter-Bold.ttf" def font_fallback(font: rl.Font) -> rl.Font: - """Fall back to unifont for languages that require it.""" - if multilang.requires_unifont(): - return gui_app.font(FontWeight.UNIFONT) + """Use a Noto fallback for languages not covered by Inter.""" + if multilang.requires_font_fallback(): + return gui_app.fallback_font() return font @@ -201,6 +209,7 @@ class GuiApplication(GuiApplicationExt): self._set_log_callback() self._fonts: dict[FontWeight, rl.Font] = {} + self._fallback_fonts: dict[str, rl.Font] = {} self._width = width if width is not None else GuiApplication._default_width() self._height = height if height is not None else GuiApplication._default_height() @@ -558,6 +567,9 @@ class GuiApplication(GuiApplicationExt): for font in self._fonts.values(): rl.unload_font(font) self._fonts = {} + for font in self._fallback_fonts.values(): + rl.unload_font(font) + self._fallback_fonts = {} if self._render_texture is not None: rl.unload_render_texture(self._render_texture) @@ -683,6 +695,21 @@ class GuiApplication(GuiApplicationExt): def font(self, font_weight: FontWeight = FontWeight.NORMAL) -> rl.Font: return self._fonts[font_weight] + def fallback_font(self) -> rl.Font: + language = multilang.language + if language not in self._fallback_fonts: + chars = set(map(chr, range(32, 127))) | set(EXTRA_FONT_CHARS) + chars.update(TRANSLATIONS_DIR.joinpath(f"app_{language}.po").read_text(encoding="utf-8")) + codepoints = sorted(map(ord, chars)) + codepoint_buffer = rl.ffi.new("int[]", codepoints) + with as_file(FONT_DIR) as fspath: + font = rl.load_font_ex((fspath / NOTO_FONTS[language]).as_posix(), 48, + rl.ffi.cast("int *", codepoint_buffer), len(codepoints)) + rl.gen_texture_mipmaps(font.texture) + rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR) + self._fallback_fonts[language] = font + return self._fallback_fonts[language] + @property def width(self): return self._width @@ -692,14 +719,26 @@ class GuiApplication(GuiApplicationExt): return self._height def _load_fonts(self): + base_chars = set(map(chr, range(32, 127))) | set(EXTRA_FONT_CHARS) + unifont_chars = set(base_chars) + for language, code in multilang.languages.items(): + unifont_chars.update(language) + if code not in FONT_FALLBACK_LANGUAGES: + base_chars.update(TRANSLATIONS_DIR.joinpath(f"app_{code}.po").read_text(encoding="utf-8")) + for font_weight_file in FontWeight: with as_file(FONT_DIR) as fspath: - fnt_path = fspath / font_weight_file - font = rl.load_font(fnt_path.as_posix()) + unifont = font_weight_file == FontWeight.UNIFONT + codepoints = sorted(map(ord, unifont_chars if unifont else base_chars)) + codepoint_buffer = rl.ffi.new("int[]", codepoints) + font = rl.load_font_ex((fspath / font_weight_file).as_posix(), 16 if unifont else 200, + rl.ffi.cast("int *", codepoint_buffer), len(codepoints)) if font_weight_file != FontWeight.UNIFONT: rl.gen_texture_mipmaps(font.texture) rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR) self._fonts[font_weight_file] = font + if multilang.requires_font_fallback(): + self.fallback_font() rl.gui_set_font(self._fonts[FontWeight.NORMAL]) def _set_styles(self): diff --git a/openpilot/system/ui/lib/emoji.py b/openpilot/system/ui/lib/emoji.py deleted file mode 100644 index ad4c272c8d..0000000000 --- a/openpilot/system/ui/lib/emoji.py +++ /dev/null @@ -1,55 +0,0 @@ -import io -import re -import functools -from importlib.resources import as_file - -from PIL import Image, ImageDraw, ImageFont -import pyray as rl - -from openpilot.system.ui.lib.application import FONT_DIR - -_cache: dict[str, rl.Texture] = {} - -EMOJI_REGEX = re.compile( -"""[\U0001F600-\U0001F64F -\U0001F300-\U0001F5FF -\U0001F680-\U0001F6FF -\U0001F1E0-\U0001F1FF -\U00002700-\U000027BF -\U0001F900-\U0001F9FF -\U00002600-\U000026FF -\U00002300-\U000023FF -\U00002B00-\U00002BFF -\U0001FA70-\U0001FAFF -\U0001F700-\U0001F77F -\u2640-\u2642 -\u2600-\u2B55 -\u200d -\u23cf -\u23e9 -\u231a -\ufe0f -\u3030 -]+""".replace("\n", ""), - flags=re.UNICODE -) - -@functools.cache -def _load_emoji_font() -> ImageFont.FreeTypeFont: - with as_file(FONT_DIR.joinpath("NotoColorEmoji.ttf")) as font_path: - return ImageFont.truetype(io.BytesIO(font_path.read_bytes()), 109) - -def find_emoji(text): - return [(m.start(), m.end(), m.group()) for m in EMOJI_REGEX.finditer(text)] - -def emoji_tex(emoji): - if emoji not in _cache: - img = Image.new("RGBA", (128, 128), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - draw.text((0, 0), emoji, font=_load_emoji_font(), embedded_color=True) - with io.BytesIO() as buffer: - img.save(buffer, format="PNG") - l = buffer.tell() - buffer.seek(0) - _cache[emoji] = rl.load_texture_from_image(rl.load_image_from_memory(".png", buffer.getvalue(), l)) - return _cache[emoji] diff --git a/openpilot/system/ui/lib/multilang.py b/openpilot/system/ui/lib/multilang.py index 8d79ec3eea..85e2621275 100644 --- a/openpilot/system/ui/lib/multilang.py +++ b/openpilot/system/ui/lib/multilang.py @@ -2,20 +2,24 @@ from importlib.resources import files import json import os import re +from typing import TYPE_CHECKING from openpilot.common.basedir import BASEDIR from openpilot.common.swaglog import cloudlog -try: +if TYPE_CHECKING: from openpilot.common.params import Params -except ImportError: - Params = None +else: + try: + from openpilot.common.params import Params + except (ImportError, OSError): + Params = None SYSTEM_UI_DIR = os.path.join(BASEDIR, "openpilot/system", "ui") UI_DIR = files("openpilot.selfdrive.ui") TRANSLATIONS_DIR = UI_DIR.joinpath("translations") LANGUAGES_FILE = TRANSLATIONS_DIR.joinpath("languages.json") -UNIFONT_LANGUAGES = [ +FONT_FALLBACK_LANGUAGES = [ "th", "zh-CHT", "zh-CHS", @@ -161,9 +165,8 @@ class Multilang: def language(self) -> str: return self._language - def requires_unifont(self) -> bool: - """Certain languages require unifont to render their glyphs.""" - return self._language in UNIFONT_LANGUAGES + def requires_font_fallback(self) -> bool: + return self._language in FONT_FALLBACK_LANGUAGES def setup(self): try: diff --git a/openpilot/system/ui/lib/scroll_panel2.py b/openpilot/system/ui/lib/scroll_panel2.py index b6193672c4..19faaa542d 100644 --- a/openpilot/system/ui/lib/scroll_panel2.py +++ b/openpilot/system/ui/lib/scroll_panel2.py @@ -5,7 +5,7 @@ from collections.abc import Callable from enum import Enum from typing import cast from openpilot.system.ui.lib.application import gui_app, MouseEvent -from openpilot.common.hardware import TICI +from openpilot.common.hardware import COMMA_HARDWARE from collections import deque MIN_VELOCITY = 10 # px/s, changes from auto scroll to steady state @@ -52,7 +52,7 @@ class GuiScrollPanel2: self._initial_click_event: MouseEvent | None = None self._previous_mouse_event: MouseEvent | None = None self._velocity = 0.0 # pixels per second - self._velocity_buffer: deque[float] = deque(maxlen=12 if TICI else 6) + self._velocity_buffer: deque[float] = deque(maxlen=12 if COMMA_HARDWARE else 6) self._enabled: bool | Callable[[], bool] = True def set_enabled(self, enabled: bool | Callable[[], bool]) -> None: diff --git a/openpilot/system/ui/lib/tests/test_handle_state_change.py b/openpilot/system/ui/lib/tests/test_handle_state_change.py index 69aae6fdf3..a7a33834cd 100644 --- a/openpilot/system/ui/lib/tests/test_handle_state_change.py +++ b/openpilot/system/ui/lib/tests/test_handle_state_change.py @@ -3,15 +3,16 @@ Tests the state machine in isolation by constructing a WifiManager with mocked DBus, then calling _handle_state_change directly with NM state transitions. """ -import pytest +import unittest from jeepney.low_level import MessageType -from pytest_mock import MockerFixture +from openpilot.common.parameterized import parameterized +from openpilot.common.test import Mocker, OpenpilotTestCase from openpilot.system.ui.lib.networkmanager import NMDeviceState, NMDeviceStateReason from openpilot.system.ui.lib.wifi_manager import WifiManager, WifiState, ConnectStatus -def _make_wm(mocker: MockerFixture, connections=None): +def _make_wm(mocker: Mocker, connections=None): """Create a WifiManager with only the fields _handle_state_change touches.""" mocker.patch.object(WifiManager, '_initialize') wm = WifiManager.__new__(WifiManager) @@ -50,7 +51,7 @@ def fire_wpa_connect(wm: WifiManager) -> None: # Basic transitions # --------------------------------------------------------------------------- -class TestDisconnected: +class TestDisconnected(OpenpilotTestCase): def test_generic_disconnect_clears_state(self, mocker): wm = _make_wm(mocker) wm._wifi_state = WifiState(ssid="Net", status=ConnectStatus.CONNECTED) @@ -92,7 +93,7 @@ class TestDisconnected: assert wm._wifi_state.status == ConnectStatus.DISCONNECTED -class TestDeactivating: +class TestDeactivating(OpenpilotTestCase): def test_deactivating_noop_for_non_connection_removed(self, mocker): """DEACTIVATING with non-CONNECTION_REMOVED reason is a no-op.""" wm = _make_wm(mocker) @@ -103,10 +104,10 @@ class TestDeactivating: assert wm._wifi_state.ssid == "Net" assert wm._wifi_state.status == ConnectStatus.CONNECTED - @pytest.mark.parametrize("status, expected_clears", [ + @parameterized.expand([ (ConnectStatus.CONNECTED, True), (ConnectStatus.CONNECTING, False), - ]) + ], names=("status", "expected_clears")) def test_deactivating_connection_removed(self, mocker, status, expected_clears): """DEACTIVATING(CONNECTION_REMOVED) clears CONNECTED but preserves CONNECTING. @@ -130,7 +131,7 @@ class TestDeactivating: assert wm._wifi_state.status == ConnectStatus.CONNECTING -class TestPrepareConfig: +class TestPrepareConfig(OpenpilotTestCase): def test_user_initiated_skips_dbus_lookup(self, mocker): """User called _set_connecting('B') — PREPARE must not overwrite via DBus. @@ -148,7 +149,7 @@ class TestPrepareConfig: assert wm._wifi_state.status == ConnectStatus.CONNECTING wm._get_active_wifi_connection.assert_not_called() - @pytest.mark.parametrize("state", [NMDeviceState.PREPARE, NMDeviceState.CONFIG]) + @parameterized.expand([NMDeviceState.PREPARE, NMDeviceState.CONFIG], names=("state",)) def test_auto_connect_looks_up_ssid(self, mocker, state): """Auto-connection (ssid=None): PREPARE and CONFIG must look up ssid from NM.""" wm = _make_wm(mocker, connections={"AutoNet": "/path/auto"}) @@ -179,7 +180,7 @@ class TestPrepareConfig: assert wm._wifi_state.status == ConnectStatus.CONNECTING -class TestNeedAuth: +class TestNeedAuth(OpenpilotTestCase): def test_wrong_password_fires_callback(self, mocker): """NEED_AUTH+SUPPLICANT_DISCONNECT from CONFIG = real wrong password.""" wm = _make_wm(mocker) @@ -272,16 +273,16 @@ class TestNeedAuth: assert len(wm._callback_queue) == 0 -class TestPassthroughStates: +class TestPassthroughStates(OpenpilotTestCase): """NEED_AUTH (generic), IP_CONFIG, IP_CHECK, SECONDARIES, FAILED (generic) are no-ops.""" - @pytest.mark.parametrize("state", [ + @parameterized.expand([ NMDeviceState.NEED_AUTH, NMDeviceState.IP_CONFIG, NMDeviceState.IP_CHECK, NMDeviceState.SECONDARIES, NMDeviceState.FAILED, - ]) + ], names=("state",)) def test_passthrough_is_noop(self, mocker, state): wm = _make_wm(mocker) wm._set_connecting("Net") @@ -293,7 +294,7 @@ class TestPassthroughStates: assert len(wm._callback_queue) == 0 -class TestActivated: +class TestActivated(OpenpilotTestCase): def test_sets_connected(self, mocker): """ACTIVATED sets status to CONNECTED and fires callback.""" wm = _make_wm(mocker, connections={"MyNet": "/path/mynet"}) @@ -344,7 +345,7 @@ class TestActivated: # guard) shrink these race windows significantly. The epoch counter closes the # remaining gaps. -class TestThreadRaces: +class TestThreadRaces(OpenpilotTestCase): def test_prepare_race_user_tap_during_dbus(self, mocker): """User taps B while PREPARE's DBus call is in flight for auto-connect. @@ -416,7 +417,7 @@ class TestThreadRaces: # Full sequences (NM signal order from real devices) # --------------------------------------------------------------------------- -class TestFullSequences: +class TestFullSequences(OpenpilotTestCase): def test_normal_connect(self, mocker): """User connects to saved network: full happy path. @@ -771,7 +772,7 @@ class TestFullSequences: wm.process_callbacks() cb.assert_called_once_with("Hotspot") - @pytest.mark.xfail(reason="TODO: FAILED(SSID_NOT_FOUND) should emit error for UI") + @unittest.expectedFailure # "TODO: FAILED(SSID_NOT_FOUND) should emit error for UI" def test_ssid_not_found(self, mocker): """Network drops off while connected — hotspot turned off. @@ -843,7 +844,7 @@ class TestFullSequences: # Verified on device: when ActivateConnection returns UnknownConnection error, # NM emits no state signals. The worker error path is the only recovery point. -class TestWorkerErrorRecovery: +class TestWorkerErrorRecovery(OpenpilotTestCase): """Worker threads re-sync with NM via _init_wifi_state on DBus errors, preserving actual NM state instead of blindly clearing to DISCONNECTED.""" diff --git a/openpilot/system/ui/lib/text_measure.py b/openpilot/system/ui/lib/text_measure.py index dee4b419ff..60945b7f05 100644 --- a/openpilot/system/ui/lib/text_measure.py +++ b/openpilot/system/ui/lib/text_measure.py @@ -1,6 +1,5 @@ import pyray as rl from openpilot.system.ui.lib.application import FONT_SCALE, font_fallback -from openpilot.system.ui.lib.emoji import find_emoji _cache: dict[int, rl.Vector2] = {} @@ -13,24 +12,7 @@ def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: float if key in _cache: return _cache[key] - # Measure normal characters without emojis, then add standard width for each found emoji - emoji = find_emoji(text) - if emoji: - non_emoji_text = "" - last_index = 0 - for start, end, _ in emoji: - non_emoji_text += text[last_index:start] - last_index = end - non_emoji_text += text[last_index:] - else: - non_emoji_text = text - - result = rl.measure_text_ex(font, non_emoji_text, font_size * FONT_SCALE, spacing) # noqa: TID251 - if emoji: - result.x += len(emoji) * font_size * FONT_SCALE - # If just emoji assume a single line height - if result.y == 0: - result.y = font_size * FONT_SCALE + result = rl.measure_text_ex(font, text, font_size * FONT_SCALE, spacing) # noqa: TID251 _cache[key] = result return result diff --git a/openpilot/system/ui/lib/wifi_manager.py b/openpilot/system/ui/lib/wifi_manager.py index dedb341cd0..5e774a84d7 100644 --- a/openpilot/system/ui/lib/wifi_manager.py +++ b/openpilot/system/ui/lib/wifi_manager.py @@ -6,7 +6,7 @@ import subprocess from collections.abc import Callable from dataclasses import dataclass, replace from enum import IntEnum -from typing import Any +from typing import TYPE_CHECKING, Any from jeepney import DBusAddress, new_method_call from jeepney.bus_messages import MatchRule, message_bus @@ -26,10 +26,13 @@ from openpilot.system.ui.lib.networkmanager import (NM, NM_WIRELESS_IFACE, NM_80 NM_DEVICE_TYPE_WIFI, NM_ACTIVE_CONNECTION_IFACE, NM_IP4_CONFIG_IFACE, NM_PROPERTIES_IFACE, NMDeviceState, NMDeviceStateReason) -try: +if TYPE_CHECKING: from openpilot.common.params import Params -except Exception: - Params = None +else: + try: + from openpilot.common.params import Params + except (ImportError, OSError): + Params = None TETHERING_IP_ADDRESS = "192.168.43.1" DEFAULT_TETHERING_PASSWORD = "swagswagcomma" @@ -512,8 +515,11 @@ class WifiManager: def _get_adapter(self, adapter_type: int) -> str | None: # Return the first NetworkManager device path matching adapter_type try: - device_paths = self._router_main.send_and_get_reply(new_method_call(self._nm, 'GetDevices')).body[0] - for device_path in device_paths: + reply = self._router_main.send_and_get_reply(new_method_call(self._nm, 'GetDevices')) + if reply.header.message_type == MessageType.error: + # NetworkManager is not available, body holds an error string instead of device paths + return None + for device_path in reply.body[0]: dev_addr = DBusAddress(device_path, bus_name=NM, interface=NM_DEVICE_IFACE) dev_type = self._router_main.send_and_get_reply(Properties(dev_addr).get('DeviceType')).body[0][1] if dev_type == adapter_type: diff --git a/openpilot/system/ui/mici_setup.py b/openpilot/system/ui/mici_setup.py index e446697cfe..efec377dd5 100755 --- a/openpilot/system/ui/mici_setup.py +++ b/openpilot/system/ui/mici_setup.py @@ -13,7 +13,7 @@ import pyray as rl from openpilot.cereal import log from openpilot.common.filter_simple import BounceFilter -from openpilot.common.hardware import HARDWARE, TICI +from openpilot.common.hardware import HARDWARE, COMMA_HARDWARE from openpilot.common.realtime import config_realtime_process, set_core_affinity from openpilot.common.swaglog import cloudlog from openpilot.common.time_helpers import system_time_valid @@ -570,7 +570,7 @@ class Setup(Widget): def main(): config_realtime_process(0, 51) # attempt to affine. AGNOS will start setup with all cores, should only fail when manually launching with screen off - if TICI: + if COMMA_HARDWARE: try: set_core_affinity([5]) except OSError: diff --git a/openpilot/system/ui/mici_updater.py b/openpilot/system/ui/mici_updater.py index a072354cf0..d9009b8259 100755 --- a/openpilot/system/ui/mici_updater.py +++ b/openpilot/system/ui/mici_updater.py @@ -5,7 +5,7 @@ import threading import pyray as rl from openpilot.common.realtime import config_realtime_process, set_core_affinity -from openpilot.common.hardware import HARDWARE, TICI +from openpilot.common.hardware import HARDWARE, COMMA_HARDWARE from openpilot.common.swaglog import cloudlog from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.widgets.nav_widget import NavWidget @@ -152,7 +152,7 @@ class Updater(Scroller): def main(): config_realtime_process(0, 51) # attempt to affine. AGNOS will start setup with all cores, should only fail when manually launching with screen off - if TICI: + if COMMA_HARDWARE: try: set_core_affinity([5]) except OSError: diff --git a/openpilot/system/ui/tici_setup.py b/openpilot/system/ui/tici_setup.py index 2b2c1c39f8..36f47ccf1b 100755 --- a/openpilot/system/ui/tici_setup.py +++ b/openpilot/system/ui/tici_setup.py @@ -107,13 +107,20 @@ class Setup(Widget): self._custom_software_warning_title_label = Label("WARNING: Custom Software", 81, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_color=rl.Color(255, 89, 79, 255), text_padding=60) - self._custom_software_warning_body_label = Label("Use caution when installing third-party software.\n\n" - + "⚠️ It has not been tested by comma.\n\n" - + "⚠️ It may not comply with relevant safety standards.\n\n" - + "⚠️ It may cause damage to your device and/or vehicle.\n\n" - + "If you'd like to proceed, use https://flash.comma.ai " - + "to restore your device to a factory state later.", - 68, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=60) + self._yellow_warning_icon = gui_app.texture("icons/yellow_warning.png", int(68 * FONT_SCALE), int(68 * FONT_SCALE)) + self._custom_software_warning_body_labels = [ + Label(text, 68, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, + text_padding=60, icon=self._yellow_warning_icon if has_icon else None) + for text, has_icon in [ + ("Use caution when installing third-party software.", False), + ("It has not been tested by comma.", True), + ("It may not comply with relevant safety standards.", True), + ("It may cause damage to your device and/or vehicle.", True), + ("If you'd like to proceed, use https://flash.comma.ai to restore your device to a factory state later.", False) + ] + ] + self._custom_software_warning_body_scroll_panel = GuiScrollPanel() self._downloading_body_label = Label("Downloading...", TITLE_FONT_SIZE, FontWeight.MEDIUM, text_padding=20) @@ -295,7 +302,7 @@ class Setup(Widget): self._download_failed_startover_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT)) def render_custom_software_warning(self, rect: rl.Rectangle): - warn_rect = rl.Rectangle(rect.x, rect.y, rect.width, 1500) + warn_rect = rl.Rectangle(rect.x, rect.y, rect.width, 1550) offset = self._custom_software_warning_body_scroll_panel.update(rect, warn_rect) button_width = (rect.width - MARGIN * 3) / 2 @@ -304,7 +311,12 @@ class Setup(Widget): rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(button_y - BODY_FONT_SIZE * FONT_SCALE)) y_offset = rect.y + offset self._custom_software_warning_title_label.render(rl.Rectangle(rect.x + 50, y_offset + 150, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE)) - self._custom_software_warning_body_label.render(rl.Rectangle(rect.x + 50, y_offset + 400, rect.width - 50, BODY_FONT_SIZE * FONT_SCALE * 3)) + + y = y_offset + 300 + for label in self._custom_software_warning_body_labels: + label.render(rl.Rectangle(rect.x + 50, y, rect.width - 50, BODY_FONT_SIZE)) + y += 160 + rl.end_scissor_mode() self._custom_software_warning_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT)) diff --git a/openpilot/system/ui/widgets/__init__.py b/openpilot/system/ui/widgets/__init__.py index f8cb04d8a7..4e13920d60 100644 --- a/openpilot/system/ui/widgets/__init__.py +++ b/openpilot/system/ui/widgets/__init__.py @@ -8,14 +8,15 @@ from collections.abc import Callable from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS, MouseEvent class DeviceLike(Protocol): - awake: bool + @property + def awake(self) -> bool: ... def _get_device() -> DeviceLike: try: from openpilot.selfdrive.ui.ui_state import device return device - except ImportError: + except (ImportError, OSError): class Device: awake = True return Device() diff --git a/openpilot/system/ui/widgets/label.py b/openpilot/system/ui/widgets/label.py index fdaf6f3148..a3e827321c 100644 --- a/openpilot/system/ui/widgets/label.py +++ b/openpilot/system/ui/widgets/label.py @@ -8,7 +8,6 @@ from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEX from openpilot.system.ui.widgets import Widget from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.utils import GuiStyleContext -from openpilot.system.ui.lib.emoji import find_emoji, emoji_tex from openpilot.system.ui.lib.wrap_text import wrap_text ICON_PADDING = 15 @@ -103,7 +102,7 @@ def gui_text_box( rl.gui_set_font(gui_app.font(FontWeight.NORMAL)) -# Non-interactive text area. Can render emojis and an optional specified icon. +# Non-interactive text area. Can render an optional specified icon. class Label(Widget): def __init__(self, text: str | Callable[[], str], @@ -145,7 +144,6 @@ class Label(Widget): self._update_text(self._text) def _update_text(self, text): - self._emojis = [] self._text_size = [] text = _resolve_value(text) @@ -175,7 +173,6 @@ class Label(Widget): self._text_wrapped = wrap_text(self._font, text, self._font_size, round(self._rect.width - (self._text_padding * 2))) for t in self._text_wrapped: - self._emojis.append(find_emoji(t)) self._text_size.append(measure_text_cached(self._font, t, self._font_size)) def _render(self, _): @@ -209,7 +206,7 @@ class Label(Widget): icon_x = self._rect.x + (self._rect.width - self._icon.width) / 2 rl.draw_texture_v(self._icon, rl.Vector2(icon_x, icon_y), rl.WHITE) - for text, text_size, emojis in zip(self._text_wrapped, self._text_size, self._emojis, strict=True): + for text, text_size in zip(self._text_wrapped, self._text_size, strict=True): line_pos = rl.Vector2(text_pos.x, text_pos.y) if self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: line_pos.x += self._text_padding @@ -218,18 +215,7 @@ class Label(Widget): elif self._text_alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: line_pos.x += self._rect.width - text_size.x - self._text_padding - prev_index = 0 - for start, end, emoji in emojis: - text_before = text[prev_index:start] - width_before = measure_text_cached(self._font, text_before, self._font_size) - rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, 0, self._text_color) - line_pos.x += width_before.x - - tex = emoji_tex(emoji) - rl.draw_texture_ex(tex, line_pos, 0.0, self._font_size / tex.height * FONT_SCALE, self._text_color) - line_pos.x += self._font_size * FONT_SCALE - prev_index = end - rl.draw_text_ex(self._font, text[prev_index:], line_pos, self._font_size, 0, self._text_color) + rl.draw_text_ex(self._font, text, line_pos, self._font_size, 0, self._text_color) text_pos.y += (text_size.y or self._font_size * FONT_SCALE) * self._line_scale @@ -238,7 +224,6 @@ class UnifiedLabel(Widget): Unified label widget that combines functionality from gui_label, gui_text_box, and Label. Supports: - - Emoji rendering - Text wrapping - Automatic eliding (single-line or multiline) - Proper multiline vertical alignment @@ -303,7 +288,6 @@ class UnifiedLabel(Widget): self._cached_text: str | None = None self._cached_wrapped_lines: list[str] = [] self._cached_line_sizes: list[rl.Vector2] = [] - self._cached_line_emojis: list[list[tuple[int, int, str]]] = [] self._cached_total_height: float | None = None self._cached_width: int = -1 @@ -430,13 +414,10 @@ class UnifiedLabel(Widget): if self._scroll: self._cached_wrapped_lines = self._cached_wrapped_lines[:1] # Only first line for scrolling - # Process each line: measure and find emojis + # Process each line: measure self._cached_line_sizes = [] - self._cached_line_emojis = [] for line in self._cached_wrapped_lines: - emojis = find_emoji(line) - self._cached_line_emojis.append(emojis) # Empty lines should still have height (use font size as line height) if not line: size = rl.Vector2(0, self._font_size * FONT_SCALE) @@ -525,14 +506,12 @@ class UnifiedLabel(Widget): # Calculate which lines fit in the available height visible_lines: list[str] = [] visible_sizes: list[rl.Vector2] = [] - visible_emojis: list[list[tuple[int, int, str]]] = [] current_height = 0.0 broke_early = False - for line, size, emojis in zip( + for line, size in zip( self._cached_wrapped_lines, self._cached_line_sizes, - self._cached_line_emojis, strict=True): # Calculate height needed for this line @@ -553,7 +532,6 @@ class UnifiedLabel(Widget): visible_lines.append(line) visible_sizes.append(size) - visible_emojis.append(emojis) current_height += line_height_needed @@ -597,7 +575,7 @@ class UnifiedLabel(Widget): # Render each line current_y = start_y - for idx, (line, size, emojis) in enumerate(zip(visible_lines, visible_sizes, visible_emojis, strict=True)): + for idx, (line, size) in enumerate(zip(visible_lines, visible_sizes, strict=True)): if self._needs_scroll: if self._scroll_state == ScrollState.STARTING: if self._scroll_pause_t is None: @@ -616,12 +594,12 @@ class UnifiedLabel(Widget): else: self.reset_scroll() - self._render_line(line, size, emojis, current_y) + self._render_line(line, size, current_y) # Draw 2nd instance for scrolling if self._needs_scroll and self._scroll_state != ScrollState.STARTING: text2_scroll_offset = size.x + self._rect.width / 3 - self._render_line(line, size, emojis, current_y, text2_scroll_offset) + self._render_line(line, size, current_y, text2_scroll_offset) # Move to next line (if not last line) if idx < len(visible_lines) - 1: @@ -660,7 +638,7 @@ class UnifiedLabel(Widget): shimmer = math.exp(-0.5 * d * d / (sigma * sigma)) return self.SHIMMER_LOW_OPACITY + (1.0 - self.SHIMMER_LOW_OPACITY) * shimmer - def _render_line(self, line, size, emojis, current_y, x_offset=0.0): + def _render_line(self, line, size, current_y, x_offset=0.0): # Calculate horizontal position if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: line_x = self._rect.x + self._text_padding @@ -675,33 +653,11 @@ class UnifiedLabel(Widget): if self._shimmer: self._render_line_shimmer(line, line_x, current_y) else: - # Render line with emojis - self._render_line_normal(line, emojis, line_x, current_y) + self._render_line_normal(line, line_x, current_y) - def _render_line_normal(self, line, emojis, line_x, current_y): + def _render_line_normal(self, line, line_x, current_y): line_pos = rl.Vector2(line_x, current_y) - prev_index = 0 - - for start, end, emoji in emojis: - # Draw text before emoji - text_before = line[prev_index:start] - if text_before: - rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, self._spacing_pixels, self._text_color) - width_before = measure_text_cached(self._font, text_before, self._font_size, self._spacing_pixels) - line_pos.x += width_before.x - - # Draw emoji - tex = emoji_tex(emoji) - emoji_scale = self._font_size / tex.height * FONT_SCALE - rl.draw_texture_ex(tex, line_pos, 0.0, emoji_scale, self._text_color) - # Emoji width is font_size * FONT_SCALE (as per measure_text_cached) - line_pos.x += self._font_size * FONT_SCALE - prev_index = end - - # Draw remaining text after last emoji - text_after = line[prev_index:] - if text_after: - rl.draw_text_ex(self._font, text_after, line_pos, self._font_size, self._spacing_pixels, self._text_color) + rl.draw_text_ex(self._font, line, line_pos, self._font_size, self._spacing_pixels, self._text_color) def _render_line_shimmer(self, line, line_x, current_y): # Shimmer range based on widest line so sweep is even across all lines diff --git a/openpilot/system/ui/widgets/list_view.py b/openpilot/system/ui/widgets/list_view.py index 08c65208a4..61c77b7600 100644 --- a/openpilot/system/ui/widgets/list_view.py +++ b/openpilot/system/ui/widgets/list_view.py @@ -1,3 +1,4 @@ +import math import os import pyray as rl from collections.abc import Callable, Sequence @@ -104,7 +105,9 @@ class ButtonAction(ItemAction): value_text = self.value if value_text: text_width = measure_text_cached(self._font, value_text, ITEM_TEXT_FONT_SIZE).x - return text_width + BUTTON_WIDTH + TEXT_PADDING + # round up so the width survives float32 storage in rl.Rectangle, otherwise the + # value label can come out a fraction of a pixel too narrow and get elided + return math.ceil(text_width) + BUTTON_WIDTH + TEXT_PADDING else: return BUTTON_WIDTH diff --git a/openpilot/system/updated/common.py b/openpilot/system/updated/common.py deleted file mode 100644 index 6bb745f6b0..0000000000 --- a/openpilot/system/updated/common.py +++ /dev/null @@ -1,16 +0,0 @@ -import os -import pathlib - - -def get_consistent_flag(path: str) -> bool: - consistent_file = pathlib.Path(os.path.join(path, ".overlay_consistent")) - return consistent_file.is_file() - -def set_consistent_flag(path: str, consistent: bool) -> None: - os.sync() - consistent_file = pathlib.Path(os.path.join(path, ".overlay_consistent")) - if consistent: - consistent_file.touch() - elif not consistent: - consistent_file.unlink(missing_ok=True) - os.sync() diff --git a/openpilot/system/updated/updated.py b/openpilot/system/updated/updated.py index 99696292f1..dd2f71f30d 100755 --- a/openpilot/system/updated/updated.py +++ b/openpilot/system/updated/updated.py @@ -193,7 +193,7 @@ def finalize_update() -> None: def handle_agnos_update() -> None: - from openpilot.common.hardware.tici.agnos import flash_agnos_update, get_target_slot_number + from openpilot.common.hardware.comma.agnos import flash_agnos_update, get_target_slot_number cur_version = HARDWARE.get_os_version() updated_version = run(["bash", "-c", r"unset AGNOS_VERSION && source launch_env.sh && \ @@ -209,7 +209,7 @@ def handle_agnos_update() -> None: cloudlog.info(f"Beginning background installation for AGNOS {updated_version}") set_offroad_alert("Offroad_NeosUpdate", True) - manifest_path = os.path.join(OVERLAY_MERGED, "openpilot/system/hardware/tici/agnos.json") + manifest_path = os.path.join(OVERLAY_MERGED, "openpilot/system/hardware/comma/agnos.json") target_slot_number = get_target_slot_number() flash_agnos_update(manifest_path, target_slot_number, cloudlog) set_offroad_alert("Offroad_NeosUpdate", False) diff --git a/openpilot/system/webrtc/device/video.py b/openpilot/system/webrtc/device/video.py index c300f76485..cb85dff73e 100644 --- a/openpilot/system/webrtc/device/video.py +++ b/openpilot/system/webrtc/device/video.py @@ -32,9 +32,9 @@ class EncodedVideoFrame: class LiveStreamVideoStreamTrack(TiciVideoStreamTrack): camera_to_sock_mapping = { - "driver": "livestreamDriverEncodeData", + "driver": "livestreamCabinEncodeData", "wideRoad": "livestreamWideRoadEncodeData", - "road": "livestreamRoadEncodeData", + "road": "livestreamNarrowRoadEncodeData", } def __init__(self, camera_type: str, video_enabled: bool = True): diff --git a/openpilot/system/webrtc/helpers.py b/openpilot/system/webrtc/helpers.py index 776b64573e..de45e1c6c9 100644 --- a/openpilot/system/webrtc/helpers.py +++ b/openpilot/system/webrtc/helpers.py @@ -8,7 +8,7 @@ WEBRTCD_PORT = 5001 @dataclass class StreamRequestBody: sdp: str - init_camera: str + cameras: list[str] enabled: bool bridge_services_in: list[str] = field(default_factory=list) bridge_services_out: list[str] = field(default_factory=list) diff --git a/openpilot/system/webrtc/tests/test_stream_session.py b/openpilot/system/webrtc/tests/test_stream_session.py index 1f7bd5747a..d64cf6aed6 100644 --- a/openpilot/system/webrtc/tests/test_stream_session.py +++ b/openpilot/system/webrtc/tests/test_stream_session.py @@ -3,6 +3,7 @@ import json import time import capnp +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import messaging, log from teleoprtc.tracks import VIDEO_CLOCK_RATE @@ -10,7 +11,7 @@ from openpilot.system.webrtc.webrtcd import CerealOutgoingMessageProxy, CerealIn from openpilot.system.webrtc.device.video import LiveStreamVideoStreamTrack -class TestStreamSession: +class TestStreamSession(OpenpilotTestCase): def setup_method(self): self.loop = asyncio.new_event_loop() @@ -64,7 +65,7 @@ class TestStreamSession: mocked_pubmaster.reset_mock() def test_livestream_track(self, mocker): - fake_msg = messaging.new_message("livestreamDriverEncodeData") + fake_msg = messaging.new_message("livestreamCabinEncodeData") config = {"receive.return_value": fake_msg.to_bytes()} mocker.patch("msgq.SubSocket", spec=True, **config) diff --git a/openpilot/system/webrtc/webrtcd.py b/openpilot/system/webrtc/webrtcd.py index 7901f9847f..8e022eda1c 100755 --- a/openpilot/system/webrtc/webrtcd.py +++ b/openpilot/system/webrtc/webrtcd.py @@ -23,6 +23,7 @@ from openpilot.system.webrtc.schema import generate_field from openpilot.common.params import Params from openpilot.cereal import messaging, log +SESSION_TIMEOUT_SECONDS = 300 # socket trick: route lookup for 8.8.8.8 (nothing is sent or actually connected to) # return the source interfaces IP which is the default interface of the device @@ -220,7 +221,7 @@ class LivestreamBitrateController(AsyncTaskRunner): class StreamSession: shared_pub_master = DynamicPubMaster([]) - def __init__(self, body: StreamRequestBody, debug_mode: bool = False): + def __init__(self, body: StreamRequestBody): from openpilot.system.webrtc.device.video import LiveStreamVideoStreamTrack from teleoprtc.builder import WebRTCAnswerBuilder @@ -229,10 +230,15 @@ class StreamSession: builder = WebRTCAnswerBuilder(body.sdp, bind_address=_default_route_ip()) self.enabled = body.enabled - self.video_track = LiveStreamVideoStreamTrack(body.init_camera, self.enabled) - builder.add_video_stream(body.init_camera, self.video_track) + self.video_tracks = [] + for camera in body.cameras: + track = LiveStreamVideoStreamTrack(camera, self.enabled) + self.video_tracks.append(track) + builder.add_video_stream(camera, track) self.stream = builder.stream() + self.is_body = "testJoystick" in body.bridge_services_in + self.incoming_bridge: CerealIncomingMessageProxy | None = None self.incoming_bridge_services = body.bridge_services_in self.outgoing_bridge: CerealOutgoingMessageProxy | None = None @@ -248,8 +254,8 @@ class StreamSession: self._cleanup_done = False self.logger = logging.getLogger("webrtcd") self.logger.info( - "New stream session (%s), init camera %s, video enabled %s, incoming services %s, outgoing services %s", - self.identifier, body.init_camera, body.enabled, body.bridge_services_in, body.bridge_services_out, + "New stream session (%s), video cameras %s, video enabled %s, incoming services %s, outgoing services %s", + self.identifier, [t.id for t in self.video_tracks], body.enabled, body.bridge_services_in, body.bridge_services_out, ) def start(self): @@ -274,14 +280,17 @@ class StreamSession: match msg_type: case "livestreamCameraSwitch": - self.video_track.switch_camera(payload["data"]["camera"]) + # only needed for 1 track stream + if len(self.video_tracks) == 1: + self.video_tracks[0].switch_camera(payload["data"]["camera"]) case "livestreamSettings": if self.bitrate_controller is not None: self.bitrate_controller.set_quality(payload["data"]["quality"]) case "livestreamVideoEnable": enabled = payload["data"]["enabled"] self.enabled = enabled - self.video_track.enable(enabled) + for track in self.video_tracks: + track.enable(enabled) if self.outgoing_bridge is not None: self.outgoing_bridge.enable(enabled) if self.bitrate_controller is not None: @@ -294,16 +303,29 @@ class StreamSession: }}) self.stream.get_messaging_channel().send(pong) case "enableTimingSei": - if hasattr(self.video_track, 'timing_sei_enabled'): - self.video_track.timing_sei_enabled = bool(payload["data"]["enabled"]) + for track in self.video_tracks: + track.timing_sei_enabled = bool(payload["data"]["enabled"]) case _: - if payload.get("type") not in self.incoming_bridge_services: + if msg_type not in self.incoming_bridge_services: return if self.incoming_bridge is not None: self.incoming_bridge.send(message) except Exception: self.logger.exception("Cereal incoming proxy failure") + async def run_normal_session(self): + try: + await asyncio.wait_for(self.stream.wait_for_disconnection(), timeout=SESSION_TIMEOUT_SECONDS) + except TimeoutError: + self.logger.warning("Stream session (%s) timed out after %d s", self.identifier, SESSION_TIMEOUT_SECONDS) + try: + self.stream.get_messaging_channel().send(json.dumps({"type": "disconnect", "data": "Session timed out"})) + except Exception: + pass + + async def run_body_session(self): + await self.stream.wait_for_disconnection() + async def run(self): try: self.params.put("LivestreamRequestKeyframe", True) @@ -320,7 +342,10 @@ class StreamSession: self.bitrate_controller.start() self.logger.info("Stream session (%s) connected", self.identifier) - await self.stream.wait_for_disconnection() + if self.is_body: + await self.run_body_session() + else: + await self.run_normal_session() self.logger.info("Stream session (%s) ended", self.identifier) except Exception: self.logger.exception("Stream session failure") @@ -337,17 +362,16 @@ class StreamSession: await self.bitrate_controller.stop() if self.outgoing_bridge is not None: await self.outgoing_bridge.stop() - if self.video_track is not None: - self.video_track.stop() - self.video_track = None + for track in self.video_tracks: + track.stop() + self.video_tracks.clear() await self.stream.stop() class ServerState: - def __init__(self, debug: bool): + def __init__(self): self.streams: dict[str, StreamSession] = {} self.stream_lock = asyncio.Lock() - self.debug = debug self.teardown: asyncio.TimerHandle | None = None @@ -372,7 +396,7 @@ def _text_response(text: str, status: int = 200) -> tuple[int, bytes, str]: async def handle_get_stream(state: ServerState, raw_body: bytes) -> tuple[int, bytes, str]: - stream_dict, debug_mode = state.streams, state.debug + stream_dict = state.streams body = StreamRequestBody(**json.loads(raw_body)) async with state.stream_lock: @@ -391,7 +415,7 @@ async def handle_get_stream(state: ServerState, raw_body: bytes) -> tuple[int, b await s.stop() stream_dict.pop(sid, None) - session = StreamSession(body, debug_mode) + session = StreamSession(body) stream_dict[session.identifier] = session try: answer = await asyncio.wait_for(session.get_answer(), timeout=30) @@ -539,23 +563,23 @@ async def _shutdown(server: WebrtcdHTTPServer, state: ServerState, loop: asyncio loop.stop() -def prewarm_stream_session_imports(debug_mode: bool = False) -> None: +def prewarm_stream_session_imports() -> None: from openpilot.system.webrtc.device.video import LiveStreamVideoStreamTrack from teleoprtc.builder import WebRTCAnswerBuilder assert LiveStreamVideoStreamTrack assert WebRTCAnswerBuilder -def webrtcd_thread(host: str, port: int, debug: bool): - logging.basicConfig(level=logging.CRITICAL, handlers=[logging.StreamHandler()]) +def webrtcd_thread(host: str, port: int): + logging.basicConfig(level=logging.INFO, handlers=[logging.StreamHandler()]) prewarm_start = time.monotonic() - prewarm_stream_session_imports(debug) + prewarm_stream_session_imports() prewarm_end = time.monotonic() logging.getLogger("webrtcd").info(f"webrtc prewarm finished in {(prewarm_end - prewarm_start) * 1000} ms") loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - state = ServerState(debug) + state = ServerState() server = WebrtcdHTTPServer((host, port), WebrtcdHandler) server.state = state @@ -589,10 +613,9 @@ def main(): parser = argparse.ArgumentParser(description="WebRTC daemon") parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to listen on") parser.add_argument("--port", type=int, default=5001, help="Port to listen on") - parser.add_argument("--debug", action="store_true", help="Enable debug mode") args = parser.parse_args() - webrtcd_thread(args.host, args.port, args.debug) + webrtcd_thread(args.host, args.port) if __name__=="__main__": diff --git a/openpilot/test_native.py b/openpilot/test_native.py new file mode 100644 index 0000000000..eed549f4e4 --- /dev/null +++ b/openpilot/test_native.py @@ -0,0 +1,22 @@ +import os +import subprocess + +from openpilot.common.basedir import BASEDIR +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase + + +NATIVE_TESTS = ( + "openpilot/common/tests/test_swaglog", + "openpilot/selfdrive/pandad/tests/test_pandad_canprotocol", + "openpilot/tools/cabana/tests/test_dbc_core", +) + + +class TestNative(OpenpilotTestCase): + @parameterized.expand(NATIVE_TESTS) + def test_native(self, executable): + path = os.path.join(BASEDIR, executable) + if not os.path.exists(path): + self.skipTest(f"optional native test was not built: {executable}") + subprocess.run([path], check=True) diff --git a/openpilot/tools/cabana/README.md b/openpilot/tools/cabana/README.md index b30c29640e..fbdfbb40e5 100644 --- a/openpilot/tools/cabana/README.md +++ b/openpilot/tools/cabana/README.md @@ -15,7 +15,7 @@ Options: --auto Auto load the route from the best available source (no video): internal, openpilotci, comma_api, car_segments, testing_closet --qcam load qcamera - --ecam load wide road camera + --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 @@ -55,7 +55,7 @@ Replace "5beb9b58bd12b691/0000010a--a51155e496" with your desired route identifi To run Cabana with multiple cameras, use the following command: ```shell -cabana "5beb9b58bd12b691/0000010a--a51155e496" --dcam --ecam +cabana "5beb9b58bd12b691/0000010a--a51155e496" --cabin --wide-road ``` ### Streaming CAN Messages from a comma Device diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index fcaa3b7930..387129709e 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -73,7 +73,7 @@ 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 + ['bz2', 'zstd', 'usb-1.0'] + base_libs +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] @@ -110,14 +110,11 @@ cabana_lib = cabana_env.Library("cabana_lib", cabana_srcs + [bootstrap_icons_src cabana_env.Program('_cabana', ['cabana.cc', cabana_lib, assets], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) if GetOption('extras'): - cabana_env.Program('tests/test_cabana', ['tests/test_runner.cc', 'tests/test_cabana.cc', cabana_lib], LIBS=[cabana_libs]) - # 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_test_runner', 'tests/test_runner.cc'), 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'), diff --git a/openpilot/tools/cabana/cabana.cc b/openpilot/tools/cabana/cabana.cc index d8e21815dc..8b21143faf 100644 --- a/openpilot/tools/cabana/cabana.cc +++ b/openpilot/tools/cabana/cabana.cc @@ -19,8 +19,8 @@ struct CabanaArgs { bool demo = false; bool auto_source = false; bool qcam = false; - bool ecam = false; - bool dcam = false; + bool wide_road = false; + bool cabin = false; bool msgq = false; bool panda = false; bool no_vipc = false; @@ -44,8 +44,8 @@ void printUsage(const char *argv0) { " --auto Auto load the route from the best available source (no video):\n" " internal, openpilotci, comma_api, car_segments, testing_closet\n" " --qcam load qcamera\n" - " --ecam load wide road camera\n" - " --dcam load driver camera\n" + " --wide-road load wide road camera (alias: --ecam)\n" + " --cabin load cabin camera (alias: --dcam)\n" " --msgq read can messages from the msgq\n" " --panda read can messages from panda\n" " --panda-serial read can messages from panda with given serial\n" @@ -83,10 +83,10 @@ int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { args.auto_source = true; } else if (std::strcmp(a, "--qcam") == 0) { args.qcam = true; - } else if (std::strcmp(a, "--ecam") == 0) { - args.ecam = true; - } else if (std::strcmp(a, "--dcam") == 0) { - args.dcam = true; + } else if (std::strcmp(a, "--wide-road") == 0 || std::strcmp(a, "--ecam") == 0) { + args.wide_road = true; + } else if (std::strcmp(a, "--cabin") == 0 || std::strcmp(a, "--dcam") == 0) { + args.cabin = true; } else if (std::strcmp(a, "--msgq") == 0) { args.msgq = true; } else if (std::strcmp(a, "--panda") == 0) { @@ -162,9 +162,9 @@ int main(int argc, char *argv[]) { #endif } else { uint32_t replay_flags = REPLAY_FLAG_NONE; - if (args.ecam) replay_flags |= REPLAY_FLAG_ECAM; + if (args.wide_road) replay_flags |= REPLAY_FLAG_WIDE_ROAD; if (args.qcam) replay_flags |= REPLAY_FLAG_QCAMERA; - if (args.dcam) replay_flags |= REPLAY_FLAG_DCAM; + if (args.cabin) replay_flags |= REPLAY_FLAG_CABIN_CAMERA; if (args.no_vipc) replay_flags |= REPLAY_FLAG_NO_VIPC; QString route; diff --git a/openpilot/tools/cabana/cameraview.cc b/openpilot/tools/cabana/cameraview.cc index a8df373dc4..9bd6b9be19 100644 --- a/openpilot/tools/cabana/cameraview.cc +++ b/openpilot/tools/cabana/cameraview.cc @@ -57,8 +57,8 @@ void CameraWidget::paintEvent(QPaintEvent *event) { QRect video_rect((width() - w) / 2, (height() - h) / 2, w, h); p.setRenderHint(QPainter::SmoothPixmapTransform); - if (active_stream_type == VISION_STREAM_DRIVER) { - // mirror driver camera horizontally + if (active_stream_type == VISION_STREAM_CABIN) { + // mirror cabin camera horizontally const qreal cx = video_rect.x() + video_rect.width() / 2.0; p.translate(cx, 0); p.scale(-1, 1); diff --git a/openpilot/tools/cabana/cameraview.h b/openpilot/tools/cabana/cameraview.h index 40d3776005..3b55dd9ed9 100644 --- a/openpilot/tools/cabana/cameraview.h +++ b/openpilot/tools/cabana/cameraview.h @@ -10,6 +10,7 @@ #include #include +#include "openpilot/cereal/visionstream.h" #include "msgq/visionipc/visionipc_client.h" class CameraWidget : public QWidget { diff --git a/openpilot/tools/cabana/signalview.h b/openpilot/tools/cabana/signalview.h index 2e44e55cbf..bccfbab0cc 100644 --- a/openpilot/tools/cabana/signalview.h +++ b/openpilot/tools/cabana/signalview.h @@ -129,11 +129,11 @@ private: // update widget geometries in QTreeView::rowsInserted QTreeView::rowsInserted(parent, start, end); } - void setModel(QAbstractItemModel *model) override { - QTreeView::setModel(model); + void setModel(QAbstractItemModel *m) override { + QTreeView::setModel(m); // Bypass the slow call to QTreeView::dataChanged. - QObject::disconnect(model, &QAbstractItemModel::dataChanged, this, nullptr); - QObject::connect(model, &QAbstractItemModel::dataChanged, this, + QObject::disconnect(m, &QAbstractItemModel::dataChanged, this, nullptr); + QObject::connect(m, &QAbstractItemModel::dataChanged, this, [this](const QModelIndex &tl, const QModelIndex &br, const auto &roles) { QAbstractItemView::dataChanged(tl, br, roles); }); } void leaveEvent(QEvent *event) override { diff --git a/openpilot/tools/cabana/streams/devicestream.cc b/openpilot/tools/cabana/streams/devicestream.cc index 91e890316e..3bd51c079d 100644 --- a/openpilot/tools/cabana/streams/devicestream.cc +++ b/openpilot/tools/cabana/streams/devicestream.cc @@ -53,7 +53,7 @@ void DeviceStream::start() { if (!zmq_address.isEmpty()) { stopBridge(); const std::string path = (std::filesystem::path(QCoreApplication::applicationDirPath().toStdString()) / - "../../openpilot/cereal/messaging/bridge").lexically_normal().string(); + "../../cereal/messaging/bridge").lexically_normal().string(); const std::string addr = zmq_address.toStdString(); const char *can_filter = "/\"can/\""; diff --git a/openpilot/tools/cabana/streams/replaystream.cc b/openpilot/tools/cabana/streams/replaystream.cc index dbd44b54e3..6d54369aef 100644 --- a/openpilot/tools/cabana/streams/replaystream.cc +++ b/openpilot/tools/cabana/streams/replaystream.cc @@ -46,7 +46,7 @@ void ReplayStream::mergeSegments() { } bool ReplayStream::loadRoute(const std::string &route, const std::string &data_dir, uint32_t replay_flags, bool auto_source) { - replay.reset(new Replay(route, {"can", "roadEncodeIdx", "driverEncodeIdx", "wideRoadEncodeIdx", "carParams"}, + replay.reset(new Replay(route, {"can", "narrowRoadEncodeIdx", "cabinEncodeIdx", "wideRoadEncodeIdx", "carParams"}, {}, nullptr, replay_flags, data_dir, auto_source)); replay->setSegmentCacheLimit(settings.max_cached_minutes); replay->installEventFilter([this](const Event *event) { return eventFilter(event); }); @@ -163,8 +163,8 @@ AbstractStream *OpenReplayWidget::open() { } else { auto replay_stream = std::make_unique(qApp); uint32_t flags = REPLAY_FLAG_NONE; - if (cameras[1]->isChecked()) flags |= REPLAY_FLAG_DCAM; - if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_ECAM; + 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)) { diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index c66f57d593..53be1b0afa 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -1,20 +1,14 @@ -#undef INFO #include #include -#include "catch2/catch.hpp" +#include "common/tests/native_test.h" #include "tools/cabana/dbc/dbcfile.h" #include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/core/settings.h" - -#ifdef QT_CORE_LIB -#include -#endif const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; -TEST_CASE("DBCFile::generateDBC") { +void test_generate_dbc() { std::string fn = std::string(OPENDBC_FILE_PATH) + "/tesla_can.dbc"; DBCFile dbc_origin(fn); DBCFile dbc_from_generated("", dbc_origin.generateDBC()); @@ -35,7 +29,7 @@ TEST_CASE("DBCFile::generateDBC") { } } -TEST_CASE("DBCFile::generateDBC - comment order") { +void test_comment_order() { // Ensure that message comments are followed by signal comments and in the correct order std::string content = R"(BO_ 160 message_1: 8 EON SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX @@ -52,7 +46,7 @@ CM_ SG_ 162 signal_2 "signal comment"; REQUIRE(dbc.generateDBC() == content); } -TEST_CASE("DBCFile::generateDBC -- preserve original header") { +void test_preserve_original_header() { std::string content = R"(VERSION "1.0" NS_ : @@ -72,7 +66,7 @@ CM_ SG_ 160 signal_1 "signal comment"; REQUIRE(dbc.generateDBC() == content); } -TEST_CASE("DBCFile::generateDBC - escaped quotes") { +void test_escaped_quotes() { std::string content = R"(BO_ 160 message_1: 8 EON SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX @@ -83,7 +77,7 @@ CM_ SG_ 160 signal_1 "signal comment with \"escaped quotes\""; REQUIRE(dbc.generateDBC() == content); } -TEST_CASE("parse_dbc") { +void test_parse_dbc() { std::string content = R"( BO_ 160 message_1: 8 EON SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX @@ -149,7 +143,7 @@ CM_ SG_ 162 signal_1 "signal comment with \"escaped quotes\""; REQUIRE(msg->sigs[0]->comment == "signal comment with \"escaped quotes\""); } -TEST_CASE("parse_opendbc") { +void test_parse_opendbc() { std::vector errors; for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH)) { if (!entry.is_regular_file() || entry.path().extension() != ".dbc") continue; @@ -161,11 +155,11 @@ TEST_CASE("parse_opendbc") { } std::ostringstream details; for (const auto &error : errors) details << error << '\n'; - INFO(details.str()); + if (!errors.empty()) std::cerr << details.str(); REQUIRE(errors.empty()); } -TEST_CASE("DBCManager core callbacks") { +void test_dbc_manager() { DBCManager manager; int files_changed = 0; int signals_added = 0; @@ -192,34 +186,16 @@ TEST_CASE("DBCManager core callbacks") { REQUIRE(manager.msg({.source = 0, .address = 160})->sig("speed") != nullptr); } -TEST_CASE("Cabana settings core defaults") { - CabanaSettingsState state; - REQUIRE(state.fps == 10); - REQUIRE(state.chart_range == 180); - REQUIRE(state.drag_direction == CabanaSettingsState::MsbFirst); - REQUIRE(state.recent_files.empty()); +void test_cabana_core() { + test_generate_dbc(); + test_comment_order(); + test_preserve_original_header(); + test_escaped_quotes(); + test_parse_dbc(); + test_parse_opendbc(); + test_dbc_manager(); } -#ifdef QT_CORE_LIB -TEST_CASE("CabanaColor preserves QColor transformations") { - const std::vector colors = { - QColor(102, 86, 169, 64), QColor(0, 187, 255, 128), QColor(255, 0, 0, 128), QColor(45, 120, 75, 255), - }; - for (const auto &qt_color : colors) { - CabanaColor color(qt_color.red(), qt_color.green(), qt_color.blue(), qt_color.alpha()); - for (int factor : {75, 100, 135, 150, 200}) { - const auto lighter = color.lighter(factor); - const auto qt_lighter = qt_color.lighter(factor); - CHECK(std::abs(lighter.red() - qt_lighter.red()) <= 1); - CHECK(std::abs(lighter.green() - qt_lighter.green()) <= 1); - CHECK(std::abs(lighter.blue() - qt_lighter.blue()) <= 1); - - const auto darker = color.darker(factor); - const auto qt_darker = qt_color.darker(factor); - CHECK(std::abs(darker.red() - qt_darker.red()) <= 1); - CHECK(std::abs(darker.green() - qt_darker.green()) <= 1); - CHECK(std::abs(darker.blue() - qt_darker.blue()) <= 1); - } - } +int main() { + return run_native_test(test_cabana_core); } -#endif diff --git a/openpilot/tools/cabana/tests/test_runner.cc b/openpilot/tools/cabana/tests/test_runner.cc deleted file mode 100644 index a76c8e16b9..0000000000 --- a/openpilot/tools/cabana/tests/test_runner.cc +++ /dev/null @@ -1,7 +0,0 @@ -#define CATCH_CONFIG_RUNNER -#include "catch2/catch.hpp" - -int main(int argc, char **argv) { - const int res = Catch::Session().run(argc, argv); - return (res < 0xff ? res : 0xff); -} diff --git a/openpilot/tools/cabana/tools/routeinfo.cc b/openpilot/tools/cabana/tools/routeinfo.cc index 77a0e065cd..dc272e3d12 100644 --- a/openpilot/tools/cabana/tools/routeinfo.cc +++ b/openpilot/tools/cabana/tools/routeinfo.cc @@ -14,7 +14,7 @@ RouteInfoDlg::RouteInfoDlg(QWidget *parent) : QDialog(parent) { table->setEditTriggers(QAbstractItemView::NoEditTriggers); table->setSelectionBehavior(QAbstractItemView::SelectRows); table->setSelectionMode(QAbstractItemView::SingleSelection); - table->setHorizontalHeaderLabels({"", "rlog", "fcam", "ecam", "dcam", "qlog", "qcam"}); + table->setHorizontalHeaderLabels({"", "rlog", "narrow road", "wide road", "driver", "qlog", "qcam"}); table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); table->verticalHeader()->setVisible(false); table->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); @@ -23,9 +23,9 @@ RouteInfoDlg::RouteInfoDlg(QWidget *parent) : QDialog(parent) { for (const auto &[seg_num, seg] : replay->route().segments()) { table->setItem(row, 0, new QTableWidgetItem(QString::number(seg_num))); table->setItem(row, 1, new QTableWidgetItem(seg.rlog.empty() ? "--" : "Yes")); - table->setItem(row, 2, new QTableWidgetItem(seg.road_cam.empty() ? "--" : "Yes")); + table->setItem(row, 2, new QTableWidgetItem(seg.narrow_road_cam.empty() ? "--" : "Yes")); table->setItem(row, 3, new QTableWidgetItem(seg.wide_road_cam.empty() ? "--" : "Yes")); - table->setItem(row, 4, new QTableWidgetItem(seg.driver_cam.empty() ? "--" : "Yes")); + table->setItem(row, 4, new QTableWidgetItem(seg.cabin_cam.empty() ? "--" : "Yes")); table->setItem(row, 5, new QTableWidgetItem(seg.qlog.empty() ? "--" : "Yes")); table->setItem(row, 6, new QTableWidgetItem(seg.qcamera.empty() ? "--" : "Yes")); ++row; diff --git a/openpilot/tools/cabana/videowidget.cc b/openpilot/tools/cabana/videowidget.cc index 67b89cb080..bd61573658 100644 --- a/openpilot/tools/cabana/videowidget.cc +++ b/openpilot/tools/cabana/videowidget.cc @@ -148,7 +148,7 @@ QWidget *VideoWidget::createCameraWidget() { camera_tab->setAutoHide(true); camera_tab->setExpanding(false); - l->addWidget(cam_widget = new StreamCameraView("camerad", VISION_STREAM_ROAD)); + l->addWidget(cam_widget = new StreamCameraView("camerad", VISION_STREAM_NARROW_ROAD)); cam_widget->setMinimumHeight(MIN_VIDEO_HEIGHT); cam_widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding); diff --git a/openpilot/tools/camerastream/compressed_vipc.py b/openpilot/tools/camerastream/compressed_vipc.py index 56ed12889f..e85c88d40c 100755 --- a/openpilot/tools/camerastream/compressed_vipc.py +++ b/openpilot/tools/camerastream/compressed_vipc.py @@ -8,7 +8,8 @@ from collections import deque import openpilot.cereal.messaging as messaging -from msgq.visionipc import VisionIpcServer, VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcServer from openpilot.tools.camerastream.ffmpeg_decoder import Decoder, FFmpegError V4L2_BUF_FLAG_KEYFRAME = 8 @@ -18,8 +19,8 @@ V4L2_BUF_FLAG_KEYFRAME = 8 # then run this "./compressed_vipc.py " ENCODE_SOCKETS = { - VisionStreamType.VISION_STREAM_ROAD: "roadEncodeData", - VisionStreamType.VISION_STREAM_DRIVER: "driverEncodeData", + VisionStreamType.VISION_STREAM_NARROW_ROAD: "narrowRoadEncodeData", + VisionStreamType.VISION_STREAM_CABIN: "cabinEncodeData", VisionStreamType.VISION_STREAM_WIDE_ROAD: "wideRoadEncodeData", } @@ -145,8 +146,8 @@ if __name__ == "__main__": args = parser.parse_args() vision_streams = [ - VisionStreamType.VISION_STREAM_ROAD, - VisionStreamType.VISION_STREAM_DRIVER, + VisionStreamType.VISION_STREAM_NARROW_ROAD, + VisionStreamType.VISION_STREAM_CABIN, VisionStreamType.VISION_STREAM_WIDE_ROAD, ] diff --git a/openpilot/tools/clip/run.py b/openpilot/tools/clip/run.py index 594383f7fd..81145e2259 100755 --- a/openpilot/tools/clip/run.py +++ b/openpilot/tools/clip/run.py @@ -22,7 +22,8 @@ from openpilot.tools.lib.framereader import FrameReader, ffprobe from openpilot.selfdrive.test.process_replay.migration import migrate_all from openpilot.common.prefix import OpenpilotPrefix from openpilot.common.utils import Timer -from msgq.visionipc import VisionIpcServer, VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcServer FRAMERATE = 20 DEMO_ROUTE, DEMO_START, DEMO_END = '5beb9b58bd12b691/0000010a--a51155e496', 90, 105 @@ -319,7 +320,7 @@ def clip(route: Route, output: str, start: int, end: int, headless: bool = True, wide_frame_queue = FrameQueue(ecamera_paths, start, end, fps=FRAMERATE) vipc = VisionIpcServer("camerad") - vipc.create_buffers(VisionStreamType.VISION_STREAM_ROAD, 4, frame_queue.frame_w, frame_queue.frame_h) + vipc.create_buffers(VisionStreamType.VISION_STREAM_NARROW_ROAD, 4, frame_queue.frame_w, frame_queue.frame_h) if wide_frame_queue: vipc.create_buffers(VisionStreamType.VISION_STREAM_WIDE_ROAD, 4, wide_frame_queue.frame_w, wide_frame_queue.frame_h) vipc.start_listener() @@ -338,7 +339,7 @@ def clip(route: Route, output: str, start: int, end: int, headless: bool = True, if frame_idx >= len(message_chunks): break _, frame_bytes = frame_queue.get() - vipc.send(VisionStreamType.VISION_STREAM_ROAD, frame_bytes, frame_idx, int(frame_idx * 5e7), int(frame_idx * 5e7)) + vipc.send(VisionStreamType.VISION_STREAM_NARROW_ROAD, frame_bytes, frame_idx, int(frame_idx * 5e7), int(frame_idx * 5e7)) if wide_frame_queue: _, wide_bytes = wide_frame_queue.get() vipc.send(VisionStreamType.VISION_STREAM_WIDE_ROAD, wide_bytes, frame_idx, int(frame_idx * 5e7), int(frame_idx * 5e7)) diff --git a/openpilot/tools/jotpluggler/SConscript b/openpilot/tools/jotpluggler/SConscript index d5ebaffb98..bfe6e90742 100644 --- a/openpilot/tools/jotpluggler/SConscript +++ b/openpilot/tools/jotpluggler/SConscript @@ -102,7 +102,7 @@ event_extractors = jot_env.Command("generated_event_extractors.h", [ ) libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a")] + \ - ffmpeg_libs + ["bz2", "zstd", "m", "pthread", "usb-1.0"] + ffmpeg_libs + ["zstd", "m", "pthread", "usb-1.0"] if arch == "Darwin": jot_env["FRAMEWORKS"] = ["OpenGL", "Cocoa", "IOKit", "CoreFoundation", "CoreVideo", "CoreMedia", "VideoToolbox"] else: diff --git a/openpilot/tools/jotpluggler/app.h b/openpilot/tools/jotpluggler/app.h index b7754f51b2..9a6777ee90 100644 --- a/openpilot/tools/jotpluggler/app.h +++ b/openpilot/tools/jotpluggler/app.h @@ -87,7 +87,7 @@ enum class PaneKind : uint8_t { enum class CameraViewKind : uint8_t { Road, - Driver, + Cabin, WideRoad, QRoad, }; @@ -322,7 +322,7 @@ struct RouteData { std::vector roots; std::vector can_messages; CameraFeedIndex road_camera; - CameraFeedIndex driver_camera; + CameraFeedIndex cabin_camera; CameraFeedIndex wide_road_camera; CameraFeedIndex qroad_camera; std::vector thumbnails; diff --git a/openpilot/tools/jotpluggler/common.h b/openpilot/tools/jotpluggler/common.h index 5c68b0ed22..7149bbe54d 100644 --- a/openpilot/tools/jotpluggler/common.h +++ b/openpilot/tools/jotpluggler/common.h @@ -23,7 +23,7 @@ struct SpecialItemSpec { inline constexpr std::array kCameraViewSpecs = {{ {CameraViewKind::Road, "Road Camera", "road", "road", "camera_road", &RouteData::road_camera}, - {CameraViewKind::Driver, "Driver Camera", "driver", "driver", "camera_driver", &RouteData::driver_camera}, + {CameraViewKind::Cabin, "Cabin Camera", "driver", "driver", "camera_driver", &RouteData::cabin_camera}, {CameraViewKind::WideRoad, "Wide Road Camera", "wide", "wide_road", "camera_wide_road", &RouteData::wide_road_camera}, {CameraViewKind::QRoad, "qRoad Camera", "qroad", "qroad", "camera_qroad", &RouteData::qroad_camera}, }}; diff --git a/openpilot/tools/jotpluggler/generate_event_extractors.py b/openpilot/tools/jotpluggler/generate_event_extractors.py index be9bd49003..a424ebc237 100644 --- a/openpilot/tools/jotpluggler/generate_event_extractors.py +++ b/openpilot/tools/jotpluggler/generate_event_extractors.py @@ -62,6 +62,8 @@ class Generator: def __init__(self, event_schema): self.event_schema = event_schema self.fixed_paths = [] + self.event_base_slots = {} + self.static_enums = [] self.tmp_index = 0 self.lines = [] self.emits_memo = {} @@ -103,9 +105,13 @@ class Generator: self.emit(indent, f"append_dynamic_scalar_point({path_expr}, tm, {double_expr}, series);") else: slot = self.add_fixed_path(path) - if kind == "Enum": - self.emit_enum_capture(indent, cxx_string(path), enum_names(schema)) - self.emit(indent, f"append_fixed_scalar_point(&series->fixed_series[{slot}], tm, {double_expr});") + names = enum_names(schema) if kind == "Enum" else [] + if names: + enum_index = len(self.static_enums) + self.static_enums.append(names) + self.emit(indent, f"append_fixed_enum_point({slot}, {enum_index}, tm, {double_expr}, series);") + else: + self.emit(indent, f"append_fixed_scalar_point(&series->fixed_series[{slot}], tm, {double_expr});") return if type_kind == "struct": @@ -144,9 +150,13 @@ class Generator: self.emit(indent, f"if ({' && '.join(conditions)}) {{") indent += 2 - value_var = self.tmp("value") - self.emit(indent, f"const auto {value_var} = {get_call};") - self.emit_node(indent, type_kind, type_proto, value_schema, value_var, field_path, field_path_expr, dynamic_path) + # Scalar getters are only consumed once. Emitting them directly avoids + # thousands of single-use locals in the generated extractor. + value_expr = get_call + if kind is None: + value_expr = self.tmp("value") + self.emit(indent, f"const auto {value_expr} = {get_call};") + self.emit_node(indent, type_kind, type_proto, value_schema, value_expr, field_path, field_path_expr, dynamic_path) if conditions: indent -= 2 @@ -247,31 +257,43 @@ class Generator: self.emit(indent + 2, "}") self.emit(indent, "}") self.emit(indent, "if (skip_raw_can) {") - self.emit(indent + 2, "return true;") + self.emit(indent + 2, "return;") self.emit(indent, "}") - def emit_event_case(self, field_name): + def emit_event_reader(self, field_name): field = self.event_schema.fields[field_name] proto = field.proto type_kind = field_type(field) type_proto = field_type_proto(field) kind = scalar_kind(type_proto) schema = field.schema if kind == "Enum" or type_kind in NESTED_TYPE_KINDS else None - self.emit(4, f"case static_cast({proto.discriminantValue}): {{") valid_slot = self.add_fixed_path(f"/{field_name}/valid") - mono_slot = self.add_fixed_path(f"/{field_name}/logMonoTime") - seconds_slot = self.add_fixed_path(f"/{field_name}/t") - self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{valid_slot}], tm, event.getValid() ? 1.0 : 0.0);") - self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{mono_slot}], tm, static_cast(event.getLogMonoTime()));") - self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{seconds_slot}], tm, tm);") - if field_name in {"can", "sendcan"}: - self.emit_can_special(6, field_name) - if self.node_emits(type_kind, type_proto, schema): + self.add_fixed_path(f"/{field_name}/logMonoTime") + self.add_fixed_path(f"/{field_name}/t") + self.event_base_slots[proto.discriminantValue] = valid_slot + + emits_payload = self.node_emits(type_kind, type_proto, schema) + if field_name not in {"can", "sendcan"} and not emits_payload: + return None + + reader_name = f"append_event_{proto.discriminantValue}" + needs_can = field_name in {"can", "sendcan"} + header_index = len(self.lines) + self.emit(0, "") + if needs_can: + self.emit_can_special(2, field_name) + if emits_payload: payload = self.tmp("payload") - self.emit(6, f"const auto {payload} = event.{accessor('get', field_name)}();") - self.emit_node(6, type_kind, type_proto, schema, payload, f"/{field_name}", None, False) - self.emit(6, "return true;") - self.emit(4, "}") + self.emit(2, f"const auto {payload} = event.{accessor('get', field_name)}();") + self.emit_node(2, type_kind, type_proto, schema, payload, f"/{field_name}", None, False) + self.emit(0, "}") + self.emit(0, "") + if needs_can: + signature = "const cereal::Event::Reader &event, const dbc::Database *can_dbc, bool skip_raw_can, double tm, SeriesAccumulator *series" + else: + signature = "const cereal::Event::Reader &event, double tm, SeriesAccumulator *series" + self.lines[header_index] = f"__attribute__((noinline)) void {reader_name}({signature}) {{" + return reader_name, needs_can def generate(self): self.lines = [] @@ -298,11 +320,66 @@ class Generator: self.emit(2, "}") self.emit(0, "}") self.emit(0, "") + self.emit(0, "__attribute__((noinline)) void append_fixed_enum_point(size_t series_slot, size_t enum_index, double tm, double value, SeriesAccumulator *series);") # noqa: E501 + self.emit(0, "") + + self.emit(0, "// Keep each event payload behind its own optimizer boundary. Combining the") + self.emit(0, "// whole schema into one function creates much more code and runs slower.") + event_readers = {} + for field_name in self.event_schema.union_fields: + event_readers[field_name] = self.emit_event_reader(field_name) + + self.emit(0, "static const std::initializer_list static_event_enum_names[] = {") + for names in self.static_enums: + names_expr = "{" + ", ".join(cxx_string(name) for name in names) + "}" + self.emit(2, f"{names_expr},") + self.emit(0, "};") + self.emit(0, "") + self.emit(0, "__attribute__((noinline)) void append_fixed_enum_point(size_t series_slot, size_t enum_index, double tm, double value, SeriesAccumulator *series) {") # noqa: E501 + self.emit(2, "RouteSeries *fixed_series = &series->fixed_series[series_slot];") + self.emit(2, "capture_static_enum_info(fixed_series->path, static_event_enum_names[enum_index], series);") + self.emit(2, "fixed_series->times.push_back(tm);") + self.emit(2, "fixed_series->values.push_back(value);") + self.emit(0, "}") + self.emit(0, "") + self.emit(0, "bool append_event_static_reader(cereal::Event::Which which, const cereal::Event::Reader &event, const dbc::Database *can_dbc, bool skip_raw_can, double time_offset, SeriesAccumulator *series) {") # noqa: E501 - self.emit(2, "const double tm = static_cast(event.getLogMonoTime()) / 1.0e9 - time_offset;") + self.emit(2, "const auto log_mono_time = event.getLogMonoTime();") + self.emit(2, "const double tm = static_cast(log_mono_time) / 1.0e9 - time_offset;") + + invalid_slot = "static_cast(-1)" + max_discriminant = max(self.event_base_slots) + base_slots = [self.event_base_slots.get(i, invalid_slot) for i in range(max_discriminant + 1)] + self.emit(2, "static constexpr size_t event_base_slots[] = {") + for slot in base_slots: + self.emit(4, f"{slot},") + self.emit(2, "};") + self.emit(2, "const size_t event_index = static_cast(which);") + self.emit(2, "if (event_index >= sizeof(event_base_slots) / sizeof(event_base_slots[0])) {") + self.emit(4, "return false;") + self.emit(2, "}") + self.emit(2, "const size_t base_slot = event_base_slots[event_index];") + self.emit(2, f"if (base_slot == {invalid_slot}) {{") + self.emit(4, "return false;") + self.emit(2, "}") + self.emit(2, "RouteSeries *base_series = &series->fixed_series[base_slot];") + self.emit(2, "base_series[0].times.push_back(tm);") + self.emit(2, "base_series[0].values.push_back(event.getValid() ? 1.0 : 0.0);") + self.emit(2, "base_series[1].times.push_back(tm);") + self.emit(2, "base_series[1].values.push_back(static_cast(log_mono_time));") + self.emit(2, "base_series[2].times.push_back(tm);") + self.emit(2, "base_series[2].values.push_back(tm);") self.emit(2, "switch (which) {") for field_name in self.event_schema.union_fields: - self.emit_event_case(field_name) + field = self.event_schema.fields[field_name] + self.emit(4, f"case static_cast({field.proto.discriminantValue}):") + reader = event_readers[field_name] + if reader is not None: + if reader[1]: + self.emit(6, f"{reader[0]}(event, can_dbc, skip_raw_can, tm, series);") + else: + self.emit(6, f"{reader[0]}(event, tm, series);") + self.emit(6, "return true;") self.emit(4, "default:") self.emit(6, "return false;") self.emit(2, "}") diff --git a/openpilot/tools/jotpluggler/layouts/camera-timings.json b/openpilot/tools/jotpluggler/layouts/camera-timings.json index 64decf15d3..643a7bc43a 100644 --- a/openpilot/tools/jotpluggler/layouts/camera-timings.json +++ b/openpilot/tools/jotpluggler/layouts/camera-timings.json @@ -1 +1 @@ -{"current_tab_index":0,"tabs":[{"name":"SOF / EOF (encodeIdx)","root":{"split":"vertical","sizes":[0.500885,0.499115],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverEncodeIdx/timestampSof","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/roadEncodeIdx/timestampSof","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadEncodeIdx/timestampSof","color":"#1ac938","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}},{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverEncodeIdx/timestampEof","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/roadEncodeIdx/timestampEof","color":"#9467bd","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadEncodeIdx/timestampEof","color":"#17becf","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}}]}},{"name":"model timings","root":{"split":"vertical","sizes":[0.5,0.5],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.016865,"bottom":0.015143},"curves":[{"name":"/modelV2/modelExecutionTime","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.1,"bottom":-0.1},"curves":[{"name":"/modelV2/frameDropPerc","color":"#f14cc1"}]}]}},{"name":"sensor info","root":{"split":"vertical","sizes":[1.0],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.1,"bottom":-0.1},"curves":[{"name":"/driverCameraState/sensor","color":"#bcbd22"},{"name":"/roadCameraState/sensor","color":"#1f77b4"},{"name":"/wideRoadCameraState/sensor","color":"#d62728"}]}]}},{"name":"SOF / EOF (cameraState)","root":{"split":"vertical","sizes":[0.500885,0.499115],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverCameraState/timestampSof","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/roadCameraState/timestampSof","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadCameraState/timestampSof","color":"#1ac938","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}},{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/driverCameraState/timestampEof","color":"#ff7f0e","transform":"derivative","derivative_dt":1.0},{"name":"/roadCameraState/timestampEof","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadCameraState/timestampEof","color":"#9467bd","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}}]}}]} +{"current_tab_index":0,"tabs":[{"name":"SOF / EOF (encodeIdx)","root":{"split":"vertical","sizes":[0.500885,0.499115],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/cabinEncodeIdx/timestampSof","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/narrowRoadEncodeIdx/timestampSof","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadEncodeIdx/timestampSof","color":"#1ac938","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}},{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/cabinEncodeIdx/timestampEof","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/narrowRoadEncodeIdx/timestampEof","color":"#9467bd","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadEncodeIdx/timestampEof","color":"#17becf","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}}]}},{"name":"model timings","root":{"split":"vertical","sizes":[0.5,0.5],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.016865,"bottom":0.015143},"curves":[{"name":"/modelV2/modelExecutionTime","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.1,"bottom":-0.1},"curves":[{"name":"/modelV2/frameDropPerc","color":"#f14cc1"}]}]}},{"name":"sensor info","root":{"split":"vertical","sizes":[1.0],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":0.1,"bottom":-0.1},"curves":[{"name":"/cabinCameraState/sensor","color":"#bcbd22"},{"name":"/narrowRoadCameraState/sensor","color":"#1f77b4"},{"name":"/wideRoadCameraState/sensor","color":"#d62728"}]}]}},{"name":"SOF / EOF (cameraState)","root":{"split":"vertical","sizes":[0.500885,0.499115],"children":[{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/cabinCameraState/timestampSof","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/narrowRoadCameraState/timestampSof","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadCameraState/timestampSof","color":"#1ac938","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}},{"title":"...","range":{"left":0.0,"right":630.006367,"top":65000000.0,"bottom":35000000.0},"curves":[{"name":"/cabinCameraState/timestampEof","color":"#ff7f0e","transform":"derivative","derivative_dt":1.0},{"name":"/narrowRoadCameraState/timestampEof","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/wideRoadCameraState/timestampEof","color":"#9467bd","transform":"derivative","derivative_dt":1.0}],"y_limits":{"min":35000000.0,"max":65000000.0}}]}}]} diff --git a/openpilot/tools/jotpluggler/layouts/cameras-and-map.json b/openpilot/tools/jotpluggler/layouts/cameras-and-map.json index 68c590f7bc..7e120fcd5c 100644 --- a/openpilot/tools/jotpluggler/layouts/cameras-and-map.json +++ b/openpilot/tools/jotpluggler/layouts/cameras-and-map.json @@ -1 +1 @@ -{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"children": [{"children": [{"curves": [], "kind": "map", "title": "Map"}, {"camera_view": "road", "curves": [], "kind": "camera", "title": "Road Camera"}], "sizes": [0.5, 0.5], "split": "horizontal"}, {"children": [{"camera_view": "wide_road", "curves": [], "kind": "camera", "title": "Wide Road Camera"}, {"camera_view": "driver", "curves": [], "kind": "camera", "title": "Driver Camera"}], "sizes": [0.5, 0.5], "split": "horizontal"}], "sizes": [0.5, 0.5], "split": "vertical"}}]} +{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"children": [{"children": [{"curves": [], "kind": "map", "title": "Map"}, {"camera_view": "road", "curves": [], "kind": "camera", "title": "Road Camera"}], "sizes": [0.5, 0.5], "split": "horizontal"}, {"children": [{"camera_view": "wide_road", "curves": [], "kind": "camera", "title": "Wide Road Camera"}, {"camera_view": "driver", "curves": [], "kind": "camera", "title": "Cabin Camera"}], "sizes": [0.5, 0.5], "split": "horizontal"}], "sizes": [0.5, 0.5], "split": "vertical"}}]} diff --git a/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json b/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json index 07a5c2fd6e..3197f40ad8 100644 --- a/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json +++ b/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json @@ -1 +1 @@ -{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"children": [{"children": [{"camera_view": "driver", "curves": [], "kind": "camera", "title": "Driver Camera"}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/alertLevel"}], "title": "..."}], "sizes": [0.5, 0.5], "split": "vertical"}, {"children": [{"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/activePolicy"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/faceDetected"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/eye"}, {"color": "#dc5234", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/phone"}, {"color": "#43a047", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/pose"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/awarenessPercent"}], "title": "..."}], "sizes": [0.25, 0.25, 0.25, 0.25], "split": "vertical"}], "sizes": [0.5, 0.5], "split": "horizontal"}}]} +{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"children": [{"children": [{"camera_view": "driver", "curves": [], "kind": "camera", "title": "Cabin Camera"}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/alertLevel"}], "title": "..."}], "sizes": [0.5, 0.5], "split": "vertical"}, {"children": [{"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/activePolicy"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/faceDetected"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/eye"}, {"color": "#dc5234", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/phone"}, {"color": "#43a047", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/pose"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/awarenessPercent"}], "title": "..."}], "sizes": [0.25, 0.25, 0.25, 0.25], "split": "vertical"}], "sizes": [0.5, 0.5], "split": "horizontal"}}]} diff --git a/openpilot/tools/jotpluggler/layouts/locationd_debug.json b/openpilot/tools/jotpluggler/layouts/locationd_debug.json index 0541427bc1..53112f31d6 100644 --- a/openpilot/tools/jotpluggler/layouts/locationd_debug.json +++ b/openpilot/tools/jotpluggler/layouts/locationd_debug.json @@ -1 +1 @@ -{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.166588,0.167062,0.166113,0.166588,0.167062,0.166588],"children":[{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1.025,"bottom":-0.025},"curves":[{"name":"/livePose/inputsOK","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":14.542814,"bottom":-5.586039},"curves":[{"name":"/accelerometer/acceleration/v/0","color":"#f14cc1"},{"name":"/accelerometer/acceleration/v/1","color":"#9467bd"},{"name":"/accelerometer/acceleration/v/2","color":"#17becf"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":0.988911,"bottom":-0.745939},"curves":[{"name":"/gyroscope/gyroUncalibrated/v/0","color":"#d62728"},{"name":"/gyroscope/gyroUncalibrated/v/1","color":"#1ac938"},{"name":"/gyroscope/gyroUncalibrated/v/2","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1.025,"bottom":-0.025},"curves":[{"name":"/accelerometer/__valid","color":"#17becf"},{"name":"/gyroscope/__valid","color":"#bcbd22"},{"name":"/carState/__valid","color":"#f14cc1"},{"name":"/liveCalibration/__valid","color":"#1ac938"},{"name":"/cameraOdometry/__valid","color":"#9467bd"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1000000000.292252,"bottom":999999999.735447},"curves":[{"name":"/gyroscope/__logMonoTime","color":"#1f77b4","transform":"derivative"},{"name":"/accelerometer/__logMonoTime","color":"#d62728","transform":"derivative"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":20790107743.93223,"bottom":-529653831.495853},"curves":[{"name":"/accelerometer/timestamp","color":"#bcbd22","transform":"derivative"},{"name":"/gyroscope/timestamp","color":"#1f77b4","transform":"derivative"}]}]}}]} +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.166588,0.167062,0.166113,0.166588,0.167062,0.166588],"children":[{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1.025,"bottom":-0.025},"curves":[{"name":"/deviceMotion/inputsOK","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":14.542814,"bottom":-5.586039},"curves":[{"name":"/accelerometer/acceleration/v/0","color":"#f14cc1"},{"name":"/accelerometer/acceleration/v/1","color":"#9467bd"},{"name":"/accelerometer/acceleration/v/2","color":"#17becf"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":0.988911,"bottom":-0.745939},"curves":[{"name":"/gyroscope/gyroUncalibrated/v/0","color":"#d62728"},{"name":"/gyroscope/gyroUncalibrated/v/1","color":"#1ac938"},{"name":"/gyroscope/gyroUncalibrated/v/2","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1.025,"bottom":-0.025},"curves":[{"name":"/accelerometer/__valid","color":"#17becf"},{"name":"/gyroscope/__valid","color":"#bcbd22"},{"name":"/carState/__valid","color":"#f14cc1"},{"name":"/extrinsicsCalibration/__valid","color":"#1ac938"},{"name":"/cameraOdometry/__valid","color":"#9467bd"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":1000000000.292252,"bottom":999999999.735447},"curves":[{"name":"/gyroscope/__logMonoTime","color":"#1f77b4","transform":"derivative"},{"name":"/accelerometer/__logMonoTime","color":"#d62728","transform":"derivative"}]},{"title":"...","range":{"left":0.0,"right":2280.128382,"top":20790107743.93223,"bottom":-529653831.495853},"curves":[{"name":"/accelerometer/timestamp","color":"#bcbd22","transform":"derivative"},{"name":"/gyroscope/timestamp","color":"#1f77b4","transform":"derivative"}]}]}}]} diff --git a/openpilot/tools/jotpluggler/layouts/max-torque-debug.json b/openpilot/tools/jotpluggler/layouts/max-torque-debug.json index 3a87fb3217..b587b695a9 100644 --- a/openpilot/tools/jotpluggler/layouts/max-torque-debug.json +++ b/openpilot/tools/jotpluggler/layouts/max-torque-debug.json @@ -1 +1 @@ -{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.249724,0.250829,0.249724,0.249724],"children":[{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":6.050533,"bottom":-7.599037},"curves":[{"name":"Actual lateral accel (roll compensated)","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/liveParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"Desired lateral accel (roll compensated)","color":"#ff7f0e","custom_python":{"linked_source":"/controlsState/desiredCurvature","additional_sources":["/carState/vEgo","/liveParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":5.384416,"bottom":-7.503945},"curves":[{"name":"roll compensated lateral acceleration","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/liveParameters/roll","/carState/steeringPressed","/carControl/latActive"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3, v4):\n if (v3 == 0 and v4 == 1):\n return (value * v1 ** 2) - (v2 * 9.81)\n return 0\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i], v3[__jotpluggler_i], v4[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":1.05,"bottom":-1.05},"curves":[{"name":"/carState/steeringPressed","color":"#0097ff"},{"name":"/carOutput/actuatorsOutput/torque","color":"#d62728"}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":80.762969,"bottom":-2.181837},"curves":[{"name":"/carState/vEgo","color":"#f14cc1","transform":"scale","scale":2.23694,"offset":0.0}]}]}}]} +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.249724,0.250829,0.249724,0.249724],"children":[{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":6.050533,"bottom":-7.599037},"curves":[{"name":"Actual lateral accel (roll compensated)","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/vehicleParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"Desired lateral accel (roll compensated)","color":"#ff7f0e","custom_python":{"linked_source":"/controlsState/desiredCurvature","additional_sources":["/carState/vEgo","/vehicleParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":5.384416,"bottom":-7.503945},"curves":[{"name":"roll compensated lateral acceleration","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/vehicleParameters/roll","/carState/steeringPressed","/carControl/latActive"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3, v4):\n if (v3 == 0 and v4 == 1):\n return (value * v1 ** 2) - (v2 * 9.81)\n return 0\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i], v3[__jotpluggler_i], v4[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":1.05,"bottom":-1.05},"curves":[{"name":"/carState/steeringPressed","color":"#0097ff"},{"name":"/carOutput/actuatorsOutput/torque","color":"#d62728"}]},{"title":"...","range":{"left":0.00045,"right":2483.624998,"top":80.762969,"bottom":-2.181837},"curves":[{"name":"/carState/vEgo","color":"#f14cc1","transform":"scale","scale":2.23694,"offset":0.0}]}]}}]} diff --git a/openpilot/tools/jotpluggler/layouts/torque-controller.json b/openpilot/tools/jotpluggler/layouts/torque-controller.json index 7e269e59e6..a794c725d6 100644 --- a/openpilot/tools/jotpluggler/layouts/torque-controller.json +++ b/openpilot/tools/jotpluggler/layouts/torque-controller.json @@ -1 +1 @@ -{"current_tab_index":0,"tabs":[{"name":"Lateral Plan Conformance","root":{"split":"vertical","sizes":[0.250949,0.249051,0.250949,0.249051],"children":[{"title":"desired vs actual lateral acceleration (closer means better conformance to plan)","range":{"left":0.000194,"right":1138.891674,"top":1.858161,"bottom":-1.823407},"curves":[{"name":"/controlsState/lateralControlState/torqueState/actualLateralAccel","color":"#1f77b4"},{"name":"/controlsState/lateralControlState/torqueState/desiredLateralAccel","color":"#d62728"}]},{"title":"desired vs actual lateral acceleration, road-roll factored out (closer means better conformance to plan)","range":{"left":0.000194,"right":1138.891674,"top":2.749816,"bottom":-3.723091},"curves":[{"name":"Actual lateral accel (roll compensated)","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/liveParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"Desired lateral accel (roll compensated)","color":"#ff7f0e","custom_python":{"linked_source":"/controlsState/desiredCurvature","additional_sources":["/carState/vEgo","/liveParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"controller feed-forward vs actuator output (closer means controller prediction is more accurate)","range":{"left":0.000194,"right":1138.891674,"top":1.978032,"bottom":-1.570956},"curves":[{"name":"/carOutput/actuatorsOutput/torque","color":"#9467bd","transform":"scale","scale":-1.0,"offset":0.0},{"name":"/controlsState/lateralControlState/torqueState/f","color":"#1f77b4"},{"name":"/carState/steeringPressed","color":"#ff000f"}]},{"title":"vehicle speed","range":{"left":0.000194,"right":1138.891674,"top":105.981304,"bottom":-2.709314},"curves":[{"name":"carState.vEgo mph","color":"#d62728","custom_python":{"linked_source":"/carState/vEgo","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return value * 2.23694\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"carState.vEgo kmh","color":"#1ac938","custom_python":{"linked_source":"/carState/vEgo","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return value * 3.6\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"/carState/vEgo","color":"#ff7f0e"}]}]}},{"name":"Vehicle Dynamics","root":{"split":"vertical","sizes":[0.334282,0.331437,0.334282],"children":[{"title":"configured-initial vs online-learned steerRatio, set configured value to match learned","range":{"left":0.0,"right":1138.816328,"top":19.665784,"bottom":19.359553},"curves":[{"name":"/carParams/steerRatio","color":"#1f77b4"},{"name":"/liveParameters/steerRatio","color":"#1ac938"}]},{"title":"configured-initial vs online-learned tireStiffnessRatio, set configured value to match learned","range":{"left":0.0,"right":1138.816328,"top":1.11221,"bottom":0.995631},"curves":[{"name":"/carParams/tireStiffnessFactor","color":"#d62728"},{"name":"/liveParameters/stiffnessFactor","color":"#ff7f0e"}]},{"title":"live steering angle offsets for straight-ahead driving, large values here may indicate alignment problems","range":{"left":0.0,"right":1138.816328,"top":-1.081041,"bottom":-4.494133},"curves":[{"name":"/liveParameters/angleOffsetAverageDeg","color":"#f14cc1"},{"name":"/liveParameters/angleOffsetDeg","color":"#9467bd"}]}]}},{"name":"Actuator Performance","root":{"split":"vertical","sizes":[0.333333,0.333333,0.333333],"children":[{"title":"offline-calculated vs online-learned lateral accel scaling factor, accel obtained from 100% actuator output","range":{"left":0.0,"right":1138.920072,"top":1.21611,"bottom":0.539474},"curves":[{"name":"/liveTorqueParameters/latAccelFactorFiltered","color":"#1f77b4"},{"name":"/liveTorqueParameters/latAccelFactorRaw","color":"#d62728"},{"name":"/carParams/lateralTuning/torque/latAccelFactor","color":"#1c9222"}]},{"title":"learned lateral accel offset, vehicle-specific compensation to obtain true zero lateral accel","range":{"left":0.0,"right":1138.920072,"top":-0.304367,"bottom":-0.418688},"curves":[{"name":"/liveTorqueParameters/latAccelOffsetFiltered","color":"#1ac938"},{"name":"/liveTorqueParameters/latAccelOffsetRaw","color":"#ff7f0e"}]},{"title":"offline-calculated vs online-learned EPS friction factor, necessary to start moving the steering wheel","range":{"left":0.0,"right":1138.920072,"top":0.226389,"bottom":0.15805},"curves":[{"name":"/liveTorqueParameters/frictionCoefficientFiltered","color":"#f14cc1"},{"name":"/liveTorqueParameters/frictionCoefficientRaw","color":"#9467bd"},{"name":"/carParams/lateralTuning/torque/friction","color":"#1c9222"}]}]}},{"name":"Actuator Delay","root":{"split":"vertical","sizes":[0.30441,0.358464,0.337127],"children":[{"title":"actuator lag learning state, 0 = learning, 1 = learned/applying, 2 = invalid","range":{"left":0.0,"right":1138.749979,"top":1.025,"bottom":-0.025},"curves":[{"name":"/liveDelay/status","color":"#ff7f0e"}]},{"title":"offline default vs online estimated steering actuator lag","range":{"left":0.0,"right":1138.749979,"top":0.419648,"bottom":0.318362},"curves":[{"name":"/liveDelay/lateralDelay","color":"#1f77b4"},{"name":"/liveDelay/lateralDelayEstimate","color":"#d62728"},{"name":"opendbc default steering lag","color":"#1ac938","custom_python":{"linked_source":"/carParams/steerActuatorDelay","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return value + 0.2\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"online estimated steering actuator lag, standard deviation","range":{"left":0.0,"right":1138.749979,"top":0.06732,"bottom":-0.001642},"curves":[{"name":"/liveDelay/lateralDelayEstimateStd","color":"#f14cc1"}]}]}},{"name":"Controls Performance","root":{"split":"vertical","sizes":[0.265655,0.251898,0.245731,0.236717],"children":[{"title":"rate-of-change limits on steering actuator (blue = original, green = rate-limited before CAN output)","range":{"left":0.000194,"right":1138.891921,"top":1.05,"bottom":-1.05},"curves":[{"name":"/carControl/actuators/torque","color":"#0c00f2"},{"name":"/carOutput/actuatorsOutput/torque","color":"#2cd63a"}]},{"title":"controller feed-forward vs actuator output (closer means controller prediction is more accurate)","range":{"left":0.000194,"right":1138.891921,"top":1.978032,"bottom":-1.570956},"curves":[{"name":"/carOutput/actuatorsOutput/torque","color":"#9467bd","transform":"scale","scale":-1.0,"offset":0.0},{"name":"/controlsState/lateralControlState/torqueState/f","color":"#1f77b4"},{"name":"/carState/steeringPressed","color":"#ff000f"}]},{"title":"proportional, integral, and feed-forward terms (actuator output = sum of PIF terms)","range":{"left":0.000194,"right":1138.891921,"top":2.099784,"bottom":-4.027542},"curves":[{"name":"/controlsState/lateralControlState/torqueState/f","color":"#0ab027"},{"name":"/controlsState/lateralControlState/torqueState/p","color":"#d62728"},{"name":"/controlsState/lateralControlState/torqueState/i","color":"#ffaf00"},{"name":"Zero","color":"#756a6a","custom_python":{"linked_source":"/carState/canValid","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return (0)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"road roll angle, from openpilot localizer","range":{"left":0.000194,"right":1138.891921,"top":0.109446,"bottom":-0.045525},"curves":[{"name":"/liveParameters/roll","color":"#f14cc1"}]}]}}]} +{"current_tab_index":0,"tabs":[{"name":"Lateral Plan Conformance","root":{"split":"vertical","sizes":[0.250949,0.249051,0.250949,0.249051],"children":[{"title":"desired vs actual lateral acceleration (closer means better conformance to plan)","range":{"left":0.000194,"right":1138.891674,"top":1.858161,"bottom":-1.823407},"curves":[{"name":"/controlsState/lateralControlState/torqueState/actualLateralAccel","color":"#1f77b4"},{"name":"/controlsState/lateralControlState/torqueState/desiredLateralAccel","color":"#d62728"}]},{"title":"desired vs actual lateral acceleration, road-roll factored out (closer means better conformance to plan)","range":{"left":0.000194,"right":1138.891674,"top":2.749816,"bottom":-3.723091},"curves":[{"name":"Actual lateral accel (roll compensated)","color":"#1ac938","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/vEgo","/vehicleParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"Desired lateral accel (roll compensated)","color":"#ff7f0e","custom_python":{"linked_source":"/controlsState/desiredCurvature","additional_sources":["/carState/vEgo","/vehicleParameters/roll"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n return (value * v1 ** 2) - (v2 * 9.81)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i], v1[__jotpluggler_i], v2[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"controller feed-forward vs actuator output (closer means controller prediction is more accurate)","range":{"left":0.000194,"right":1138.891674,"top":1.978032,"bottom":-1.570956},"curves":[{"name":"/carOutput/actuatorsOutput/torque","color":"#9467bd","transform":"scale","scale":-1.0,"offset":0.0},{"name":"/controlsState/lateralControlState/torqueState/f","color":"#1f77b4"},{"name":"/carState/steeringPressed","color":"#ff000f"}]},{"title":"vehicle speed","range":{"left":0.000194,"right":1138.891674,"top":105.981304,"bottom":-2.709314},"curves":[{"name":"carState.vEgo mph","color":"#d62728","custom_python":{"linked_source":"/carState/vEgo","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return value * 2.23694\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"carState.vEgo kmh","color":"#1ac938","custom_python":{"linked_source":"/carState/vEgo","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return value * 3.6\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}},{"name":"/carState/vEgo","color":"#ff7f0e"}]}]}},{"name":"Vehicle Dynamics","root":{"split":"vertical","sizes":[0.334282,0.331437,0.334282],"children":[{"title":"configured-initial vs online-learned steerRatio, set configured value to match learned","range":{"left":0.0,"right":1138.816328,"top":19.665784,"bottom":19.359553},"curves":[{"name":"/carParams/steerRatio","color":"#1f77b4"},{"name":"/vehicleParameters/steerRatio","color":"#1ac938"}]},{"title":"configured-initial vs online-learned tireStiffnessRatio, set configured value to match learned","range":{"left":0.0,"right":1138.816328,"top":1.11221,"bottom":0.995631},"curves":[{"name":"/carParams/tireStiffnessFactor","color":"#d62728"},{"name":"/vehicleParameters/stiffnessFactor","color":"#ff7f0e"}]},{"title":"online-learned steering angle offsets for straight-ahead driving, large values here may indicate alignment problems","range":{"left":0.0,"right":1138.816328,"top":-1.081041,"bottom":-4.494133},"curves":[{"name":"/vehicleParameters/angleOffsetAverageDeg","color":"#f14cc1"},{"name":"/vehicleParameters/angleOffsetDeg","color":"#9467bd"}]}]}},{"name":"Actuator Performance","root":{"split":"vertical","sizes":[0.333333,0.333333,0.333333],"children":[{"title":"offline-calculated vs online-learned lateral accel scaling factor, accel obtained from 100% actuator output","range":{"left":0.0,"right":1138.920072,"top":1.21611,"bottom":0.539474},"curves":[{"name":"/lateralTorqueParameters/latAccelFactorFiltered","color":"#1f77b4"},{"name":"/lateralTorqueParameters/latAccelFactorRaw","color":"#d62728"},{"name":"/carParams/lateralTuning/torque/latAccelFactor","color":"#1c9222"}]},{"title":"learned lateral accel offset, vehicle-specific compensation to obtain true zero lateral accel","range":{"left":0.0,"right":1138.920072,"top":-0.304367,"bottom":-0.418688},"curves":[{"name":"/lateralTorqueParameters/latAccelOffsetFiltered","color":"#1ac938"},{"name":"/lateralTorqueParameters/latAccelOffsetRaw","color":"#ff7f0e"}]},{"title":"offline-calculated vs online-learned EPS friction factor, necessary to start moving the steering wheel","range":{"left":0.0,"right":1138.920072,"top":0.226389,"bottom":0.15805},"curves":[{"name":"/lateralTorqueParameters/frictionCoefficientFiltered","color":"#f14cc1"},{"name":"/lateralTorqueParameters/frictionCoefficientRaw","color":"#9467bd"},{"name":"/carParams/lateralTuning/torque/friction","color":"#1c9222"}]}]}},{"name":"Actuator Delay","root":{"split":"vertical","sizes":[0.30441,0.358464,0.337127],"children":[{"title":"actuator lag learning state, 0 = learning, 1 = learned/applying, 2 = invalid","range":{"left":0.0,"right":1138.749979,"top":1.025,"bottom":-0.025},"curves":[{"name":"/lateralDelay/status","color":"#ff7f0e"}]},{"title":"offline default vs online estimated steering actuator lag","range":{"left":0.0,"right":1138.749979,"top":0.419648,"bottom":0.318362},"curves":[{"name":"/lateralDelay/lateralDelay","color":"#1f77b4"},{"name":"/lateralDelay/lateralDelayEstimate","color":"#d62728"},{"name":"opendbc default steering lag","color":"#1ac938","custom_python":{"linked_source":"/carParams/steerActuatorDelay","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return value + 0.2\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"online estimated steering actuator lag, standard deviation","range":{"left":0.0,"right":1138.749979,"top":0.06732,"bottom":-0.001642},"curves":[{"name":"/lateralDelay/lateralDelayEstimateStd","color":"#f14cc1"}]}]}},{"name":"Controls Performance","root":{"split":"vertical","sizes":[0.265655,0.251898,0.245731,0.236717],"children":[{"title":"rate-of-change limits on steering actuator (blue = original, green = rate-limited before CAN output)","range":{"left":0.000194,"right":1138.891921,"top":1.05,"bottom":-1.05},"curves":[{"name":"/carControl/actuators/torque","color":"#0c00f2"},{"name":"/carOutput/actuatorsOutput/torque","color":"#2cd63a"}]},{"title":"controller feed-forward vs actuator output (closer means controller prediction is more accurate)","range":{"left":0.000194,"right":1138.891921,"top":1.978032,"bottom":-1.570956},"curves":[{"name":"/carOutput/actuatorsOutput/torque","color":"#9467bd","transform":"scale","scale":-1.0,"offset":0.0},{"name":"/controlsState/lateralControlState/torqueState/f","color":"#1f77b4"},{"name":"/carState/steeringPressed","color":"#ff000f"}]},{"title":"proportional, integral, and feed-forward terms (actuator output = sum of PIF terms)","range":{"left":0.000194,"right":1138.891921,"top":2.099784,"bottom":-4.027542},"curves":[{"name":"/controlsState/lateralControlState/torqueState/f","color":"#0ab027"},{"name":"/controlsState/lateralControlState/torqueState/p","color":"#d62728"},{"name":"/controlsState/lateralControlState/torqueState/i","color":"#ffaf00"},{"name":"Zero","color":"#756a6a","custom_python":{"linked_source":"/carState/canValid","additional_sources":[],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value):\n return (0)\n\n__jotpluggler_result = np.empty_like(value, dtype=np.float64)\nfor __jotpluggler_i in range(len(value)):\n __jotpluggler_result[__jotpluggler_i] = __jotpluggler_eval_sample(time[__jotpluggler_i], value[__jotpluggler_i])\nreturn __jotpluggler_result"}}]},{"title":"road roll angle, from openpilot localizer","range":{"left":0.000194,"right":1138.891921,"top":0.109446,"bottom":-0.045525},"curves":[{"name":"/vehicleParameters/roll","color":"#f14cc1"}]}]}}]} diff --git a/openpilot/tools/jotpluggler/runtime.cc b/openpilot/tools/jotpluggler/runtime.cc index a1e47c7e8e..d7d0fc79c4 100644 --- a/openpilot/tools/jotpluggler/runtime.cc +++ b/openpilot/tools/jotpluggler/runtime.cc @@ -39,11 +39,11 @@ const bool kLogCameraTimings = env_flag_enabled("JOTP_CAMERA_TIMINGS"); CameraType decoder_camera_type(CameraViewKind view) { switch (view) { - case CameraViewKind::Driver: return DriverCam; + case CameraViewKind::Cabin: return CabinCam; case CameraViewKind::WideRoad: return WideRoadCam; - case CameraViewKind::QRoad: return RoadCam; + case CameraViewKind::QRoad: return NarrowRoadCam; case CameraViewKind::Road: - default: return RoadCam; + default: return NarrowRoadCam; } } @@ -59,17 +59,17 @@ bool stream_batch_has_data(const StreamExtractBatch &batch) { bool should_subscribe_stream_service(const std::string &name) { static const std::array kSkippedServices = {{ - "roadEncodeIdx", - "driverEncodeIdx", + "narrowRoadEncodeIdx", + "cabinEncodeIdx", "wideRoadEncodeIdx", - "qRoadEncodeIdx", - "roadEncodeData", - "driverEncodeData", + "qNarrowRoadEncodeIdx", + "narrowRoadEncodeData", + "cabinEncodeData", "wideRoadEncodeData", - "qRoadEncodeData", + "qNarrowRoadEncodeData", "livestreamWideRoadEncodeIdx", - "livestreamRoadEncodeIdx", - "livestreamDriverEncodeIdx", + "livestreamNarrowRoadEncodeIdx", + "livestreamCabinEncodeIdx", "thumbnail", }}; if (name == "rawAudioData") return false; diff --git a/openpilot/tools/jotpluggler/sketch_layout.cc b/openpilot/tools/jotpluggler/sketch_layout.cc index 1f7df72fed..5440d9dd8d 100644 --- a/openpilot/tools/jotpluggler/sketch_layout.cc +++ b/openpilot/tools/jotpluggler/sketch_layout.cc @@ -45,9 +45,9 @@ struct RouteSelection { struct SegmentLogs { std::string rlog; std::string qlog; - std::string fcamera; - std::string dcamera; - std::string ecamera; + std::string narrow_road; + std::string cabin; + std::string wide_road; std::string qcamera; }; @@ -262,7 +262,8 @@ RouteSelection parse_route_selection(std::string route_name) { if (separator == "/") { size_t pos = range_str.find(':'); int begin_segment = 0; - if (!parse_segment_number(range_str.substr(0, pos), &begin_segment)) { + const std::string begin_str = range_str.substr(0, pos); + if (!begin_str.empty() && !parse_segment_number(begin_str, &begin_segment)) { return {}; } route.begin_segment = begin_segment; @@ -294,11 +295,11 @@ void add_log_file_to_segments(std::map *segments, int segment_ } else if (name == "qlog.bz2" || name == "qlog.zst" || name == "qlog") { segment.qlog = file; } else if (name == "fcamera.hevc") { - segment.fcamera = file; + segment.narrow_road = file; } else if (name == "dcamera.hevc") { - segment.dcamera = file; + segment.cabin = file; } else if (name == "ecamera.hevc") { - segment.ecamera = file; + segment.wide_road = file; } else if (name == "qcamera.ts") { segment.qcamera = file; } @@ -926,7 +927,9 @@ void append_scalar_point(RouteSeries *series, series->values.push_back(value); } -void append_fixed_scalar_point(RouteSeries *series, double tm, double value) { +// This has thousands of generated call sites. Inlining it duplicates vector +// growth logic throughout the extractor and is slower both to compile and run. +__attribute__((noinline)) void append_fixed_scalar_point(RouteSeries *series, double tm, double value) { series->times.push_back(tm); series->values.push_back(value); } @@ -1883,10 +1886,10 @@ RouteData load_route_data(const std::string &route_name, metadata.car_fingerprint, resolved_dbc); route_data.route_id = make_route_identifier(route, segments); - build_camera_index(segments, route_data, &SegmentLogs::fcamera, "roadEncodeIdx", &route_data.road_camera); - build_camera_index(segments, route_data, &SegmentLogs::dcamera, "driverEncodeIdx", &route_data.driver_camera); - build_camera_index(segments, route_data, &SegmentLogs::ecamera, "wideRoadEncodeIdx", &route_data.wide_road_camera); - build_camera_index(segments, route_data, &SegmentLogs::qcamera, "qRoadEncodeIdx", &route_data.qroad_camera); + build_camera_index(segments, route_data, &SegmentLogs::narrow_road, "narrowRoadEncodeIdx", &route_data.road_camera); + build_camera_index(segments, route_data, &SegmentLogs::cabin, "cabinEncodeIdx", &route_data.cabin_camera); + build_camera_index(segments, route_data, &SegmentLogs::wide_road, "wideRoadEncodeIdx", &route_data.wide_road_camera); + build_camera_index(segments, route_data, &SegmentLogs::qcamera, "qNarrowRoadEncodeIdx", &route_data.qroad_camera); stats.load_end = LoadStats::Clock::now(); stats.publish(RouteLoadStage::Finished, segments.size(), {}); stats.print_summary(route_data.series.size()); diff --git a/openpilot/tools/jotpluggler/test_jotpluggler.py b/openpilot/tools/jotpluggler/test_jotpluggler.py index 0729e3b2e3..cbcc0a8168 100644 --- a/openpilot/tools/jotpluggler/test_jotpluggler.py +++ b/openpilot/tools/jotpluggler/test_jotpluggler.py @@ -5,7 +5,8 @@ from pathlib import Path JOTPLUGGLER_DIR = Path(__file__).parent -class TestJotpluggler: +from openpilot.common.test import OpenpilotTestCase +class TestJotpluggler(OpenpilotTestCase): def test_help(self): result = subprocess.run(["./jotpluggler", "-h"], cwd=JOTPLUGGLER_DIR, capture_output=True, text=True) assert result.returncode == 0, result.stderr diff --git a/openpilot/tools/joystick/joystickd.py b/openpilot/tools/joystick/joystickd.py index ac2f99f67e..f8a2598361 100755 --- a/openpilot/tools/joystick/joystickd.py +++ b/openpilot/tools/joystick/joystickd.py @@ -9,6 +9,7 @@ from opendbc.car.vehicle_model import VehicleModel from openpilot.common.realtime import DT_CTRL, Ratekeeper from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.controls.lib.drive_helpers import should_stop LongCtrlState = car.CarControl.Actuators.LongControlState MAX_LAT_ACCEL = 3.0 @@ -20,7 +21,7 @@ def joystickd_thread(): CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) VM = VehicleModel(CP) - sm = messaging.SubMaster(['carState', 'onroadEvents', 'liveParameters', 'selfdriveState', 'testJoystick'], frequency=1. / DT_CTRL) + sm = messaging.SubMaster(['carState', 'onroadEvents', 'vehicleParameters', 'selfdriveState', 'testJoystick'], frequency=1. / DT_CTRL) pm = messaging.PubMaster(['carControl', 'controlsState']) rk = Ratekeeper(100, print_delay_threshold=None) @@ -33,6 +34,7 @@ def joystickd_thread(): CC.enabled = sm['selfdriveState'].enabled CC.latActive = sm['selfdriveState'].active and not sm['carState'].steerFaultTemporary and not sm['carState'].steerFaultPermanent CC.longActive = CC.enabled and not any(e.overrideLongitudinal for e in sm['onroadEvents']) and CP.openpilotLongitudinalControl + CC.cruiseControl.override = CC.enabled and not CC.longActive and CP.openpilotLongitudinalControl CC.cruiseControl.cancel = sm['carState'].cruiseState.enabled and (not CC.enabled or not CP.pcmCruise) CC.hudControl.leadDistanceBars = 2 @@ -48,12 +50,12 @@ def joystickd_thread(): if CC.longActive: actuators.accel = 4.0 * float(np.clip(joystick_axes[0], -1, 1)) - actuators.longControlState = LongCtrlState.pid if sm['carState'].vEgo > 0.1 else LongCtrlState.stopping + actuators.longControlState = LongCtrlState.stopping if should_stop(sm['carState'].vEgo, actuators.accel) else LongCtrlState.pid CC.cruiseControl.resume = actuators.accel > 0.0 if CC.latActive: max_curvature = MAX_LAT_ACCEL / max(sm['carState'].vEgo ** 2, 5) - max_angle = math.degrees(VM.get_steer_from_curvature(max_curvature, sm['carState'].vEgo, sm['liveParameters'].roll)) + max_angle = math.degrees(VM.get_steer_from_curvature(max_curvature, sm['carState'].vEgo, sm['vehicleParameters'].roll)) actuators.torque = float(np.clip(joystick_axes[1], -1, 1)) actuators.steeringAngleDeg, actuators.curvature = actuators.torque * max_angle, actuators.torque * -max_curvature @@ -65,7 +67,7 @@ def joystickd_thread(): controlsState = cs_msg.controlsState controlsState.lateralControlState.init('debugState') - lp = sm['liveParameters'] + lp = sm['vehicleParameters'] steer_angle_without_offset = math.radians(sm['carState'].steeringAngleDeg - lp.angleOffsetDeg) controlsState.curvature = -VM.calc_curvature(steer_angle_without_offset, sm['carState'].vEgo, lp.roll) diff --git a/openpilot/tools/lib/auth.py b/openpilot/tools/lib/auth.py index 9139d8b42d..6a685e38d9 100755 --- a/openpilot/tools/lib/auth.py +++ b/openpilot/tools/lib/auth.py @@ -32,9 +32,6 @@ from urllib.parse import parse_qs, urlencode from openpilot.tools.lib.api import APIError, CommaApi, UnauthorizedError from openpilot.tools.lib.auth_config import set_token, get_token -PORT = 3000 - - class ClientRedirectServer(HTTPServer): query_params: dict[str, Any] = {} @@ -58,7 +55,7 @@ class ClientRedirectHandler(BaseHTTPRequestHandler): pass # this prevent http server from dumping messages to stdout -def auth_redirect_link(method): +def auth_redirect_link(method, port): provider_id = { 'google': 'g', 'apple': 'a', @@ -67,7 +64,7 @@ def auth_redirect_link(method): params = { 'redirect_uri': f"https://api.comma.ai/v2/auth/{provider_id}/redirect/", - 'state': f'service,localhost:{PORT}', + 'state': f'service,localhost:{port}', } if method == 'google': @@ -98,9 +95,9 @@ def auth_redirect_link(method): def login(method): - oauth_uri = auth_redirect_link(method) - - web_server = ClientRedirectServer(('localhost', PORT), ClientRedirectHandler) + # 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) diff --git a/openpilot/tools/lib/file_downloader.py b/openpilot/tools/lib/file_downloader.py index 68061b201e..efb06095be 100755 --- a/openpilot/tools/lib/file_downloader.py +++ b/openpilot/tools/lib/file_downloader.py @@ -5,17 +5,21 @@ Called by C++ replay/cabana via subprocess. Subcommands: route-files - Get route file URLs as JSON - download - Download URL to local cache, print local path + download - Download/decompress URL to local cache, print local path + decompress - Decompress a local log file, print temporary path devices - List user's devices as JSON device-routes - List routes for a device as JSON """ import argparse +import bz2 import hashlib import json import os +import shutil import sys import tempfile -import shutil + +import zstandard as zstd from openpilot.common.hardware.hw import Paths from openpilot.tools.lib.api import CommaApi, UnauthorizedError, APIError @@ -39,11 +43,60 @@ def api_call(func): sys.stdout.flush() -def cache_file_path(url): +def cache_file_path(url, compression=None): url_without_query = url.split("?")[0] + if compression: + url_without_query = f"decompressed-{compression}:{url_without_query}" return os.path.join(Paths.download_cache_root(), hashlib.sha256(url_without_query.encode()).hexdigest()) +def compression_type(data): + if data.startswith(b'BZh'): + return 'bz2' + if data.startswith(b'\x28\xb5\x2f\xfd'): + return 'zst' + return None + + +def make_decompressor(compression): + if compression == 'bz2': + return bz2.BZ2Decompressor() + if compression == 'zst': + return zstd.ZstdDecompressor().decompressobj() + raise ValueError(f"Unsupported compression type: {compression}") + + +def decompress_file(source, destination, compression=None): + with open(source, 'rb') as src, open(destination, 'wb') as dst: + header = src.read(4) + compression = compression or compression_type(header) + decompressor = make_decompressor(compression) + dst.write(decompressor.decompress(header)) + while data := src.read(1024 * 1024): + dst.write(decompressor.decompress(data)) + if not decompressor.eof: + raise EOFError(f"Compressed {compression} file ended before the end-of-stream marker") + + +def materialize_cached_file(source, url, compression): + local_path = cache_file_path(url, compression) + if os.path.exists(local_path): + return local_path + + tmp_fd, tmp_path = tempfile.mkstemp(dir=Paths.download_cache_root()) + os.close(tmp_fd) + try: + decompress_file(source, tmp_path, compression) + shutil.move(tmp_path, local_path) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + return local_path + + def cmd_route_files(args): api_call(lambda api: api.get(f"v1/route/{args.route}/files")) @@ -53,8 +106,19 @@ def cmd_download(args): use_cache = not args.no_cache if use_cache: + for compression in ('bz2', 'zst'): + decompressed_path = cache_file_path(url, compression) + if os.path.exists(decompressed_path): + sys.stdout.write(decompressed_path + "\n") + sys.stdout.flush() + return + local_path = cache_file_path(url) if os.path.exists(local_path): + with open(local_path, 'rb') as f: + compression = compression_type(f.read(4)) + if compression: + local_path = materialize_cached_file(local_path, url, compression) sys.stdout.write(local_path + "\n") sys.stdout.flush() return @@ -80,14 +144,30 @@ def cmd_download(args): try: downloaded = 0 chunk_size = 1024 * 1024 + compression = None + decompressor = None with os.fdopen(tmp_fd, 'wb') as f: for data in r.stream(chunk_size): - f.write(data) + if downloaded == 0: + compression = compression_type(data) + if compression: + decompressor = make_decompressor(compression) + f.write(decompressor.decompress(data) if decompressor else data) downloaded += len(data) sys.stderr.write(f"PROGRESS:{downloaded}:{total}\n") sys.stderr.flush() - if use_cache: + if decompressor and not decompressor.eof: + raise EOFError(f"Compressed {compression} file ended before the end-of-stream marker") + + if decompressor: + if use_cache: + output_path = cache_file_path(url, compression) + shutil.move(tmp_path, output_path) + else: + output_path = tmp_path + sys.stdout.write(output_path + "\n") + elif use_cache: shutil.move(tmp_path, local_path) sys.stdout.write(local_path + "\n") else: @@ -109,6 +189,24 @@ def cmd_download(args): sys.stdout.flush() +def cmd_decompress(args): + os.makedirs(Paths.download_cache_root(), exist_ok=True) + output_fd, output_path = tempfile.mkstemp(dir=Paths.download_cache_root()) + os.close(output_fd) + try: + decompress_file(args.path, output_path) + except Exception as e: + try: + os.unlink(output_path) + except OSError: + pass + sys.stderr.write(f"ERROR:{e}\n") + sys.stderr.flush() + sys.exit(1) + sys.stdout.write(output_path + "\n") + sys.stdout.flush() + + def cmd_devices(args): api_call(lambda api: api.get("v1/me/devices/")) @@ -139,6 +237,10 @@ def main(): p_dl.add_argument("--no-cache", action="store_true") p_dl.set_defaults(func=cmd_download) + p_dc = subparsers.add_parser("decompress") + p_dc.add_argument("path") + p_dc.set_defaults(func=cmd_decompress) + p_dev = subparsers.add_parser("devices") p_dev.set_defaults(func=cmd_devices) diff --git a/openpilot/tools/lib/framereader.py b/openpilot/tools/lib/framereader.py index c923651563..62c75b95f7 100644 --- a/openpilot/tools/lib/framereader.py +++ b/openpilot/tools/lib/framereader.py @@ -28,7 +28,7 @@ class LRUCache: def __setitem__(self, key, value): self._cache[key] = value if len(self._cache) > self.capacity: - self._cache.popitem(last=False) + self._cache.popitem(last=False) def __contains__(self, key): return key in self._cache diff --git a/openpilot/tools/lib/log_time_series.py b/openpilot/tools/lib/log_time_series.py index 5e8aa4f73b..eba821bc84 100644 --- a/openpilot/tools/lib/log_time_series.py +++ b/openpilot/tools/lib/log_time_series.py @@ -80,5 +80,5 @@ if __name__ == "__main__": import sys from openpilot.tools.lib.logreader import LogReader m = msgs_to_time_series(LogReader(sys.argv[1])) - print(m['driverCameraState']['t']) - print(np.diff(m['driverCameraState']['timestampSof'])) + print(m['cabinCameraState']['t']) + print(np.diff(m['cabinCameraState']['timestampSof'])) diff --git a/openpilot/tools/lib/tests/test_caching.py b/openpilot/tools/lib/tests/test_caching.py index c70d7fc81b..565bac40dd 100644 --- a/openpilot/tools/lib/tests/test_caching.py +++ b/openpilot/tools/lib/tests/test_caching.py @@ -3,8 +3,9 @@ import os import shutil import socket import tempfile -import pytest +from openpilot.common.parameterized import parameterized +from openpilot.common.test import OpenpilotTestCase from openpilot.selfdrive.test.helpers import http_server_context from openpilot.common.hardware.hw import Paths from openpilot.tools.lib.url_file import URLFile, prune_cache @@ -30,12 +31,11 @@ class CachingTestRequestHandler(http.server.BaseHTTPRequestHandler): self.end_headers() -@pytest.fixture def host(): with http_server_context(handler=CachingTestRequestHandler) as (host, port): yield f"http://{host}:{port}" -class TestFileDownload: +class TestFileDownload(OpenpilotTestCase): def test_pipeline_defaults(self, host): # TODO: parameterize the defaults so we don't rely on hard-coded values in xx @@ -59,7 +59,7 @@ class TestFileDownload: # ensure caching on by default and cache dir gets created os.environ.pop("DISABLE_FILEREADER_CACHE", None) if os.path.exists(Paths.download_cache_root()): - shutil.rmtree(Paths.download_cache_root()) + shutil.rmtree(Paths.download_cache_root(), ignore_errors=True) URLFile(f"{host}/test.txt").get_length() URLFile(f"{host}/test.txt").read() assert os.path.exists(Paths.download_cache_root()) @@ -117,7 +117,7 @@ class TestFileDownload: self.compare_loads(large_file_url, length - 100, 100) self.compare_loads(large_file_url) - @pytest.mark.parametrize("cache_enabled", [True, False]) + @parameterized.expand([True, False], names=("cache_enabled",)) def test_recover_from_missing_file(self, host, cache_enabled): if cache_enabled: os.environ.pop("DISABLE_FILEREADER_CACHE", None) @@ -135,7 +135,7 @@ class TestFileDownload: assert length == 4 -class TestCache: +class TestCache(OpenpilotTestCase): def test_prune_cache(self, monkeypatch): with tempfile.TemporaryDirectory() as tmpdir: monkeypatch.setattr(Paths, 'download_cache_root', staticmethod(lambda: tmpdir + "/")) diff --git a/openpilot/tools/lib/tests/test_comma_car_segments.py b/openpilot/tools/lib/tests/test_comma_car_segments.py index 1b0f07ee63..a678aad51d 100644 --- a/openpilot/tools/lib/tests/test_comma_car_segments.py +++ b/openpilot/tools/lib/tests/test_comma_car_segments.py @@ -1,13 +1,14 @@ -import pytest +import unittest import requests from opendbc.car.fingerprints import MIGRATION +from openpilot.common.test import OpenpilotTestCase from openpilot.tools.lib.comma_car_segments import get_comma_car_segments_database, get_url from openpilot.tools.lib.logreader import LogReader from openpilot.tools.lib.route import SegmentRange -@pytest.mark.skip(reason="huggingface is flaky, run this test manually to check for issues") -class TestCommaCarSegments: +@unittest.skip("huggingface is flaky, run this test manually to check for issues") +class TestCommaCarSegments(OpenpilotTestCase): def test_database(self): database = get_comma_car_segments_database() diff --git a/openpilot/tools/lib/tests/test_logreader.py b/openpilot/tools/lib/tests/test_logreader.py index a27a348d9e..9d5691abd8 100644 --- a/openpilot/tools/lib/tests/test_logreader.py +++ b/openpilot/tools/lib/tests/test_logreader.py @@ -4,15 +4,16 @@ import io import shutil import tempfile import os -import pytest +import unittest import requests +from openpilot.common.test import OpenpilotTestCase from openpilot.common.parameterized import parameterized from openpilot.cereal import log as capnp_log -from openpilot.tools.lib.logreader import LogsUnavailable, LogIterable, LogReader, parse_indirect, ReadMode -from openpilot.tools.lib.file_sources import comma_api_source, InternalUnavailableException -from openpilot.tools.lib.route import SegmentRange +from openpilot.tools.lib.logreader import _LogFileReader, LogsUnavailable, LogIterable, LogReader, parse_indirect, ReadMode +from openpilot.tools.lib.file_sources import InternalUnavailableException +from openpilot.tools.lib.route import FileName, SegmentRange from openpilot.tools.lib.url_file import URLFileException NUM_SEGS = 17 # number of segments in the test route @@ -47,7 +48,46 @@ def setup_source_scenario(mocker, is_internal=False): yield -class TestLogReader: +class TestLogReader(OpenpilotTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.tmpdir = tempfile.TemporaryDirectory() + cls.qlog_path = os.path.join(cls.tmpdir.name, "qlog") + cls.rlog_path = os.path.join(cls.tmpdir.name, "rlog") + cls._write_test_log(cls.qlog_path, 10, include_car_params=True) + cls._write_test_log(cls.rlog_path, 100) + + @classmethod + def tearDownClass(cls): + cls.tmpdir.cleanup() + super().tearDownClass() + + @staticmethod + def _write_test_log(path, count, include_car_params=False): + events = [] + for i in range(count): + event = capnp_log.Event.new_message() + event.logMonoTime = count - i # deliberately unsorted + if include_car_params and i == 0: + event.init("carParams") + event.carParams.carFingerprint = "SUBARU OUTBACK 6TH GEN" + else: + event.init("can", 0) + events.append(event.to_bytes()) + + with open(path, "wb") as f: + f.write(b"".join(events)) + + def local_source(self, sr, seg_idxs, fns): + path = self.qlog_path if fns == FileName.QLOG else self.rlog_path + return dict.fromkeys(seg_idxs, path) + + def local_auto_source(self, sr, seg_idxs, fns): + if fns == FileName.RLOG: + return {} + return dict.fromkeys(seg_idxs, self.qlog_path) + @parameterized.expand([ (f"{TEST_ROUTE}", ALL_SEGS), (f"{TEST_ROUTE.replace('/', '|')}", ALL_SEGS), @@ -72,7 +112,7 @@ class TestLogReader: (f"https://useradmin.comma.ai/?onebox={TEST_ROUTE.replace('/', '|')}", ALL_SEGS), (f"https://useradmin.comma.ai/?onebox={TEST_ROUTE.replace('/', '%7C')}", ALL_SEGS), ]) - @pytest.mark.skip("this got flaky. internet tests are stupid.") + @unittest.skip("this got flaky. internet tests are stupid.") def test_indirect_parsing(self, identifier, expected): parsed = parse_indirect(identifier) sr = SegmentRange(parsed) @@ -90,7 +130,7 @@ class TestLogReader: sr = SegmentRange(identifier) assert str(sr) == expected - @pytest.mark.parametrize("cache_enabled", [True, False]) + @parameterized.expand([True, False], names=("cache_enabled",)) def test_direct_parsing(self, mocker, cache_enabled): file_exists_mock = mocker.patch("openpilot.tools.lib.filereader.file_exists") if cache_enabled: @@ -107,7 +147,7 @@ class TestLogReader: l = len(list(LogReader(f))) assert l > 100 - with pytest.raises(URLFileException) if not cache_enabled else pytest.raises(AssertionError): + with self.assertRaises(URLFileException if not cache_enabled else AssertionError): l = len(list(LogReader(QLOG_FILE.replace("/3/", "/200/")))) # file_exists should not be called for direct files @@ -126,47 +166,43 @@ class TestLogReader: (f"{TEST_ROUTE}--3a",), ]) def test_bad_ranges(self, segment_range): - with pytest.raises(AssertionError): + with self.assertRaises(AssertionError): _ = SegmentRange(segment_range).seg_idxs - @pytest.mark.parametrize("segment_range, api_call", [ + @parameterized.expand([ (f"{TEST_ROUTE}/0", False), (f"{TEST_ROUTE}/:2", False), (f"{TEST_ROUTE}/0:", True), (f"{TEST_ROUTE}/-1", True), (f"{TEST_ROUTE}", True), - ]) + ], names=("segment_range", "api_call")) def test_slicing_api_call(self, mocker, segment_range, api_call): max_seg_mock = mocker.patch("openpilot.tools.lib.route.get_max_seg_number_cached") max_seg_mock.return_value = NUM_SEGS _ = SegmentRange(segment_range).seg_idxs assert api_call == max_seg_mock.called - @pytest.mark.slow def test_modes(self): - qlog_len = len(list(LogReader(f"{TEST_ROUTE}/0", ReadMode.QLOG))) - rlog_len = len(list(LogReader(f"{TEST_ROUTE}/0", ReadMode.RLOG))) + qlog_len = len(list(LogReader(f"{TEST_ROUTE}/0", ReadMode.QLOG, sources=[self.local_source]))) + rlog_len = len(list(LogReader(f"{TEST_ROUTE}/0", ReadMode.RLOG, sources=[self.local_source]))) assert qlog_len * 6 < rlog_len - @pytest.mark.slow def test_modes_from_name(self): - qlog_len = len(list(LogReader(f"{TEST_ROUTE}/0/q"))) - rlog_len = len(list(LogReader(f"{TEST_ROUTE}/0/r"))) + qlog_len = len(list(LogReader(f"{TEST_ROUTE}/0/q", sources=[self.local_source]))) + rlog_len = len(list(LogReader(f"{TEST_ROUTE}/0/r", sources=[self.local_source]))) assert qlog_len * 6 < rlog_len - @pytest.mark.slow def test_list(self): - qlog_len = len(list(LogReader(f"{TEST_ROUTE}/0/q"))) - qlog_len_2 = len(list(LogReader([f"{TEST_ROUTE}/0/q", f"{TEST_ROUTE}/0/q"]))) + qlog_len = len(list(LogReader(self.qlog_path))) + qlog_len_2 = len(list(LogReader([self.qlog_path, self.qlog_path]))) assert qlog_len * 2 == qlog_len_2 - @pytest.mark.slow def test_multiple_iterations(self, mocker): - init_mock = mocker.patch("openpilot.tools.lib.logreader._LogFileReader") - lr = LogReader(f"{TEST_ROUTE}/0/q") + init_mock = mocker.patch("openpilot.tools.lib.logreader._LogFileReader", wraps=_LogFileReader) + lr = LogReader(self.qlog_path) qlog_len1 = len(list(lr)) qlog_len2 = len(list(lr)) @@ -175,47 +211,37 @@ class TestLogReader: assert qlog_len1 == qlog_len2 - @pytest.mark.slow def test_helpers(self): - lr = LogReader(f"{TEST_ROUTE}/0/q") + lr = LogReader(self.qlog_path) assert lr.first("carParams").carFingerprint == "SUBARU OUTBACK 6TH GEN" assert 0 < len(list(lr.filter("carParams"))) < len(list(lr)) - @parameterized.expand([(True,), (False,)]) - @pytest.mark.slow - def test_run_across_segments(self, cache_enabled): - if cache_enabled: - os.environ.pop("DISABLE_FILEREADER_CACHE", None) - else: - os.environ["DISABLE_FILEREADER_CACHE"] = "1" - lr = LogReader(f"{TEST_ROUTE}/0:4") + def test_run_across_segments(self): + lr = LogReader([self.qlog_path] * 4) assert len(lr.run_across_segments(4, noop)) == len(list(lr)) - @pytest.mark.slow def test_auto_mode(self, subtests, mocker): - lr = LogReader(f"{TEST_ROUTE}/0/q") + lr = LogReader(self.qlog_path) qlog_len = len(list(lr)) - log_paths_mock = mocker.patch("openpilot.tools.lib.route.Route.log_paths") - log_paths_mock.return_value = [None] * NUM_SEGS # Should fall back to qlogs since rlogs are not available with subtests.test("interactive_yes"): mocker.patch("sys.stdin", new=io.StringIO("y\n")) - lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO_INTERACTIVE, sources=[comma_api_source]) + lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO_INTERACTIVE, sources=[self.local_auto_source]) log_len = len(list(lr)) assert qlog_len == log_len with subtests.test("interactive_no"): mocker.patch("sys.stdin", new=io.StringIO("n\n")) - with pytest.raises(LogsUnavailable): - lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO_INTERACTIVE, sources=[comma_api_source]) + with self.assertRaises(LogsUnavailable): + lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO_INTERACTIVE, sources=[self.local_auto_source]) with subtests.test("non_interactive"): - lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO, sources=[comma_api_source]) + lr = LogReader(f"{TEST_ROUTE}/0", default_mode=ReadMode.AUTO, sources=[self.local_auto_source]) log_len = len(list(lr)) assert qlog_len == log_len - @pytest.mark.parametrize("is_internal", [True, False]) + @parameterized.expand([True, False], names=("is_internal",)) def test_auto_source_scenarios(self, mocker, is_internal): lr = LogReader(QLOG_FILE) qlog_len = len(list(lr)) @@ -225,12 +251,11 @@ class TestLogReader: log_len = len(list(lr)) assert qlog_len == log_len - @pytest.mark.slow def test_sort_by_time(self): - msgs = list(LogReader(f"{TEST_ROUTE}/0/q")) + msgs = list(LogReader(self.qlog_path)) assert msgs != sorted(msgs, key=lambda m: m.logMonoTime) - msgs = list(LogReader(f"{TEST_ROUTE}/0/q", sort_by_time=True)) + msgs = list(LogReader(self.qlog_path, sort_by_time=True)) assert msgs == sorted(msgs, key=lambda m: m.logMonoTime) def test_only_union_types(self): @@ -254,7 +279,7 @@ class TestLogReader: # ensure new message is added, but is not a union type msgs = list(LogReader(qlog.name)) assert len(msgs) == num_msgs + 1 - with pytest.raises(capnp.KjException): + with self.assertRaises(capnp.KjException): [m.which() for m in msgs] # should not be added when only_union_types=True diff --git a/openpilot/tools/lib/tests/test_route_library.py b/openpilot/tools/lib/tests/test_route_library.py index 491bb81327..392484c794 100644 --- a/openpilot/tools/lib/tests/test_route_library.py +++ b/openpilot/tools/lib/tests/test_route_library.py @@ -1,8 +1,9 @@ from collections import namedtuple +from openpilot.common.test import OpenpilotTestCase from openpilot.tools.lib.route import SegmentName -class TestRouteLibrary: +class TestRouteLibrary(OpenpilotTestCase): def test_segment_name_formats(self): Case = namedtuple('Case', ['input', 'expected_route', 'expected_segment_num', 'expected_data_dir']) diff --git a/openpilot/tools/longitudinal_maneuvers/generate_report.py b/openpilot/tools/longitudinal_maneuvers/generate_report.py index dbd9f6db91..2d7f81a7f8 100755 --- a/openpilot/tools/longitudinal_maneuvers/generate_report.py +++ b/openpilot/tools/longitudinal_maneuvers/generate_report.py @@ -44,14 +44,14 @@ def report(platform, route, _description, CP, ID, maneuvers): t_carControl, carControl = zip(*[(m.logMonoTime, m.carControl) for m in msgs if m.which() == 'carControl'], strict=True) t_carOutput, carOutput = zip(*[(m.logMonoTime, m.carOutput) for m in msgs if m.which() == 'carOutput'], strict=True) t_carState, carState = zip(*[(m.logMonoTime, m.carState) for m in msgs if m.which() == 'carState'], strict=True) - t_livePose, livePose = zip(*[(m.logMonoTime, m.livePose) for m in msgs if m.which() == 'livePose'], strict=True) + t_deviceMotion, deviceMotion = zip(*[(m.logMonoTime, m.deviceMotion) for m in msgs if m.which() == 'deviceMotion'], strict=True) t_longitudinalPlan, longitudinalPlan = zip(*[(m.logMonoTime, m.longitudinalPlan) for m in msgs if m.which() == 'longitudinalPlan'], strict=True) # make time relative seconds t_carControl = [(t - t_carControl[0]) / 1e9 for t in t_carControl] t_carOutput = [(t - t_carOutput[0]) / 1e9 for t in t_carOutput] t_carState = [(t - t_carState[0]) / 1e9 for t in t_carState] - t_livePose = [(t - t_livePose[0]) / 1e9 for t in t_livePose] + t_deviceMotion = [(t - t_deviceMotion[0]) / 1e9 for t in t_deviceMotion] t_longitudinalPlan = [(t - t_longitudinalPlan[0]) / 1e9 for t in t_longitudinalPlan] # maneuver validity @@ -70,7 +70,7 @@ def report(platform, route, _description, CP, ID, maneuvers): # Localizer is noisy, require two consecutive 20Hz frames above threshold prev_crossed = False - for t, lp in zip(t_livePose, livePose, strict=True): + for t, lp in zip(t_deviceMotion, deviceMotion, strict=True): crossed = (0 < aTarget < lp.accelerationDevice.x) or (0 > aTarget > lp.accelerationDevice.x) if crossed and prev_crossed: builder.append(f', crossed in {t:.3f}s') @@ -95,7 +95,7 @@ def report(platform, route, _description, CP, ID, maneuvers): ax[0].plot(t_carOutput, [m.actuatorsOutput.accel for m in carOutput], label='carOutput.actuatorsOutput.accel', linewidth=6) ax[0].plot(t_longitudinalPlan, [m.aTarget for m in longitudinalPlan], label='longitudinalPlan.aTarget', linewidth=6) ax[0].plot(t_carState, [m.aEgo for m in carState], label='carState.aEgo', linewidth=6) - ax[0].plot(t_livePose, [m.accelerationDevice.x for m in livePose], label='livePose.accelerationDevice.x', linewidth=6) + ax[0].plot(t_deviceMotion, [m.accelerationDevice.x for m in deviceMotion], label='deviceMotion.accelerationDevice.x', linewidth=6) # TODO localizer accel ax[0].set_ylabel('Acceleration (m/s^2)') #ax[0].set_ylim(-6.5, 6.5) diff --git a/openpilot/tools/longitudinal_maneuvers/maneuversd.py b/openpilot/tools/longitudinal_maneuvers/maneuversd.py index a644e1dc32..3cba805f00 100755 --- a/openpilot/tools/longitudinal_maneuvers/maneuversd.py +++ b/openpilot/tools/longitudinal_maneuvers/maneuversd.py @@ -117,8 +117,8 @@ MANEUVERS = [ initial_speed=20. * CV.MPH_TO_MS, ), Maneuver( - "brake step response: -4m/s^2 from 20mph", - [Action([-4], [3])], + "brake step response: -3.5m/s^2 from 20mph", + [Action([-3.5], [3])], repeat=2, initial_speed=20. * CV.MPH_TO_MS, ), @@ -129,8 +129,8 @@ MANEUVERS = [ initial_speed=20. * CV.MPH_TO_MS, ), Maneuver( - "gas step response: +4m/s^2 from 20mph", - [Action([4], [3])], + "gas step response: +2m/s^2 from 20mph", + [Action([2], [3])], repeat=2, initial_speed=20. * CV.MPH_TO_MS, ), diff --git a/openpilot/tools/plotjuggler/README.md b/openpilot/tools/plotjuggler/README.md index efccbdc0bd..e87fc2c3d4 100644 --- a/openpilot/tools/plotjuggler/README.md +++ b/openpilot/tools/plotjuggler/README.md @@ -4,7 +4,7 @@ ## Installation -Once you've [set up the openpilot environment](../README.md), this command will download PlotJuggler and install our plugins: +Once you've [set up the openpilot environment](/tools/README.md), this command will download PlotJuggler and install our plugins: `cd openpilot/tools/plotjuggler && ./juggle.py --install` diff --git a/openpilot/tools/plotjuggler/layouts/camera-timings.xml b/openpilot/tools/plotjuggler/layouts/camera-timings.xml index f91cbd3f53..2abff4d508 100644 --- a/openpilot/tools/plotjuggler/layouts/camera-timings.xml +++ b/openpilot/tools/plotjuggler/layouts/camera-timings.xml @@ -8,13 +8,13 @@ - - + + - - + + @@ -29,13 +29,13 @@ - - + + - - + + @@ -76,8 +76,8 @@ - - + + @@ -91,13 +91,13 @@ - - + + - - + + @@ -112,13 +112,13 @@ - - + + - - + + @@ -145,4 +145,3 @@ - diff --git a/openpilot/tools/plotjuggler/layouts/locationd_debug.xml b/openpilot/tools/plotjuggler/layouts/locationd_debug.xml index 5377e1535c..6e1cd35039 100644 --- a/openpilot/tools/plotjuggler/layouts/locationd_debug.xml +++ b/openpilot/tools/plotjuggler/layouts/locationd_debug.xml @@ -8,7 +8,7 @@ - + @@ -36,7 +36,7 @@ - + @@ -97,4 +97,3 @@ - diff --git a/openpilot/tools/plotjuggler/layouts/max-torque-debug.xml b/openpilot/tools/plotjuggler/layouts/max-torque-debug.xml index 9a6693165e..2089b826d9 100644 --- a/openpilot/tools/plotjuggler/layouts/max-torque-debug.xml +++ b/openpilot/tools/plotjuggler/layouts/max-torque-debug.xml @@ -62,7 +62,7 @@ return 0 /controlsState/curvature /carState/vEgo - /liveParameters/roll + /vehicleParameters/roll /carState/steeringPressed /carControl/latActive @@ -73,7 +73,7 @@ return 0 /controlsState/desiredCurvature /carState/vEgo - /liveParameters/roll + /vehicleParameters/roll @@ -82,11 +82,10 @@ return 0 /controlsState/curvature /carState/vEgo - /liveParameters/roll + /vehicleParameters/roll - diff --git a/openpilot/tools/plotjuggler/layouts/torque-controller.xml b/openpilot/tools/plotjuggler/layouts/torque-controller.xml index 8e9a1a8526..671b47c355 100644 --- a/openpilot/tools/plotjuggler/layouts/torque-controller.xml +++ b/openpilot/tools/plotjuggler/layouts/torque-controller.xml @@ -53,7 +53,7 @@ - + @@ -61,15 +61,15 @@ - + - + - - + + @@ -82,8 +82,8 @@ - - + + @@ -91,16 +91,16 @@ - - + + - - + + @@ -114,15 +114,15 @@ - + - - + + @@ -130,7 +130,7 @@ - + @@ -174,7 +174,7 @@ - + @@ -221,7 +221,7 @@ /controlsState/desiredCurvature /carState/vEgo - /liveParameters/roll + /vehicleParameters/roll @@ -230,7 +230,7 @@ /controlsState/curvature /carState/vEgo - /liveParameters/roll + /vehicleParameters/roll @@ -247,4 +247,3 @@ - diff --git a/openpilot/tools/plotjuggler/test_plotjuggler.py b/openpilot/tools/plotjuggler/test_plotjuggler.py index d55aafe9be..10cd342dc8 100644 --- a/openpilot/tools/plotjuggler/test_plotjuggler.py +++ b/openpilot/tools/plotjuggler/test_plotjuggler.py @@ -5,17 +5,18 @@ import signal import subprocess import time -import pytest +import unittest +from openpilot.common.test import OpenpilotTestCase from openpilot.common.basedir import BASEDIR from openpilot.common.timeout import Timeout from openpilot.tools.plotjuggler.juggle import DEMO_ROUTE, install PJ_DIR = os.path.join(BASEDIR, "openpilot/tools/plotjuggler") -class TestPlotJuggler: +class TestPlotJuggler(OpenpilotTestCase): - @pytest.mark.skipif(not shutil.which('qmake'), reason="Qt not installed") + @unittest.skipIf(not shutil.which('qmake'), "Qt not installed") def test_demo(self): install() diff --git a/openpilot/tools/replay/README.md b/openpilot/tools/replay/README.md index 108df08935..1d500eac96 100644 --- a/openpilot/tools/replay/README.md +++ b/openpilot/tools/replay/README.md @@ -68,8 +68,8 @@ Options: internal, openpilotci, comma_api, car_segments, testing_closet --data_dir local directory with routes --prefix set OPENPILOT_PREFIX - --dcam load driver camera - --ecam load wide road camera + --cabin load cabin camera + --wide-road load wide road camera --no-loop stop at the end of the route --no-cache turn off local cache --qcam load qcamera @@ -103,11 +103,11 @@ openpilot/tools/plotjuggler/juggle.py --stream watch all three cameras simultaneously from your comma three routes with watch3 -simply replay a route using the `--dcam` and `--ecam` flags: +simply replay a route using the `--cabin` and `--wide-road` flags: ```bash # start a replay -cd openpilot/tools/replay && ./replay --demo --dcam --ecam +cd openpilot/tools/replay && ./replay --demo --cabin --wide-road # then start watch3 cd openpilot/selfdrive/ui && ./watch3.py diff --git a/openpilot/tools/replay/SConscript b/openpilot/tools/replay/SConscript index 643de97bb9..9e060bc829 100644 --- a/openpilot/tools/replay/SConscript +++ b/openpilot/tools/replay/SConscript @@ -9,11 +9,8 @@ base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] replay_lib_src = ["replay.cc", "consoleui.cc", "camera.cc", "filereader.cc", "logreader.cc", "framereader.cc", "route.cc", "util.cc", "seg_mgr.cc", "timeline.cc", "py_downloader.cc"] if arch != "Darwin": - replay_lib_src.append("qcom_decoder.cc") + replay_lib_src.append("#openpilot/system/loggerd/encoder/v4l_decoder.cc") replay_lib = replay_env.Library("replay", replay_lib_src, LIBS=base_libs, FRAMEWORKS=base_frameworks) Export('replay_lib') -replay_libs = [replay_lib] + ffmpeg_libs + ['bz2', 'zstd', 'ncurses'] + base_libs +replay_libs = [replay_lib] + ffmpeg_libs + ['ncurses'] + base_libs replay_env.Program("replay", ["main.cc"], LIBS=replay_libs, FRAMEWORKS=base_frameworks) - -if GetOption('extras'): - replay_env.Program('tests/test_replay', ['tests/test_replay.cc'], LIBS=replay_libs) diff --git a/openpilot/tools/replay/camera.h b/openpilot/tools/replay/camera.h index 9433018848..81b3bb1a81 100644 --- a/openpilot/tools/replay/camera.h +++ b/openpilot/tools/replay/camera.h @@ -5,6 +5,7 @@ #include #include +#include "openpilot/cereal/visionstream.h" #include "msgq/visionipc/visionipc_server.h" #include "common/queue.h" #include "tools/replay/framereader.h" @@ -32,8 +33,8 @@ protected: VisionBuf *getFrame(Camera &cam, FrameReader *fr, int32_t segment_id, uint32_t frame_id); Camera cameras_[MAX_CAMERAS] = { - {.type = RoadCam, .stream_type = VISION_STREAM_ROAD}, - {.type = DriverCam, .stream_type = VISION_STREAM_DRIVER}, + {.type = NarrowRoadCam, .stream_type = VISION_STREAM_NARROW_ROAD}, + {.type = CabinCam, .stream_type = VISION_STREAM_CABIN}, {.type = WideRoadCam, .stream_type = VISION_STREAM_WIDE_ROAD}, }; std::atomic publishing_ = 0; diff --git a/openpilot/tools/replay/consoleui.cc b/openpilot/tools/replay/consoleui.cc index eeb385f8a4..6dbc984b2f 100644 --- a/openpilot/tools/replay/consoleui.cc +++ b/openpilot/tools/replay/consoleui.cc @@ -62,7 +62,7 @@ ExitHandler do_exit; } // namespace -ConsoleUI::ConsoleUI(Replay *replay) : replay(replay), sm({"carState", "liveParameters"}) { +ConsoleUI::ConsoleUI(Replay *replay) : replay(replay), sm({"carState", "vehicleParameters"}) { // Initialize curses initscr(); clear(); @@ -176,7 +176,7 @@ void ConsoleUI::updateStatus() { std::string current_segment = " - " + std::to_string((int)(replay->currentSeconds() / 60)); write_item(0, 25, "TIME: ", time_string, current_segment, true); - auto p = sm["liveParameters"].getLiveParameters(); + auto p = sm["vehicleParameters"].getVehicleParameters(); write_item(1, 0, "STIFFNESS: ", util::string_format("%.2f %%", p.getStiffnessFactor() * 100), " "); write_item(1, 25, "SPEED: ", util::string_format("%.2f", sm["carState"].getCarState().getVEgo()), " m/s"); write_item(2, 0, "STEER RATIO: ", util::string_format("%.2f", p.getSteerRatio()), ""); diff --git a/openpilot/tools/replay/filereader.cc b/openpilot/tools/replay/filereader.cc index 93a6a1193f..6ad56bbfc6 100644 --- a/openpilot/tools/replay/filereader.cc +++ b/openpilot/tools/replay/filereader.cc @@ -1,5 +1,8 @@ #include "tools/replay/filereader.h" +#include +#include + #include "common/util.h" #include "tools/replay/py_downloader.h" @@ -10,5 +13,17 @@ std::string FileReader::read(const std::string &file, std::atomic *abort) if (local_path.empty()) return {}; return util::read_file(local_path); } + char header[4] = {}; + std::ifstream stream(file, std::ios::binary); + stream.read(header, sizeof(header)); + const std::string magic(header, stream.gcount()); + if (util::ends_with(file, ".bz2") || util::ends_with(file, ".zst") || + util::starts_with(magic, "BZh") || magic == "\x28\xB5\x2F\xFD") { + std::string local_path = PyDownloader::decompress(file, abort); + if (local_path.empty()) return {}; + std::string data = util::read_file(local_path); + unlink(local_path.c_str()); + return data; + } return util::read_file(file); } diff --git a/openpilot/tools/replay/framereader.cc b/openpilot/tools/replay/framereader.cc index 7c6f144148..19e5fa0d0f 100644 --- a/openpilot/tools/replay/framereader.cc +++ b/openpilot/tools/replay/framereader.cc @@ -42,7 +42,7 @@ struct DecoderManager { std::unique_ptr decoder; #ifndef __APPLE__ if (!Hardware::PC() && hw_decoder) { - decoder = std::make_unique(); + decoder = std::make_unique(); } else #endif { @@ -269,18 +269,18 @@ bool FFmpegVideoDecoder::copyBuffer(AVFrame *f, VisionBuf *buf) { } #ifndef __APPLE__ -bool QcomVideoDecoder::open(AVCodecParameters *codecpar, bool hw_decoder) { +bool V4LVideoDecoder::open(AVCodecParameters *codecpar, bool hw_decoder) { if (codecpar->codec_id != AV_CODEC_ID_HEVC) { rError("Hardware decoder only supports HEVC codec"); return false; } width = codecpar->width; height = codecpar->height; - msm_vidc.init(VIDEO_DEVICE, width, height, V4L2_PIX_FMT_HEVC); + v4l_decoder.init(V4LDecoder::DEVICE, width, height, V4L2_PIX_FMT_HEVC); return true; } -bool QcomVideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) { +bool V4LVideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) { int from_idx = idx; if (idx != reader->prev_idx + 1) { // seeking to the nearest key frame @@ -301,10 +301,10 @@ bool QcomVideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) { reader->prev_idx = idx; bool result = false; AVPacket pkt; - msm_vidc.avctx = reader->input_ctx; + v4l_decoder.avctx = reader->input_ctx; for (int i = from_idx; i <= idx; ++i) { if (av_read_frame(reader->input_ctx, &pkt) == 0) { - result = msm_vidc.decodeFrame(&pkt, buf) && (i == idx); + result = v4l_decoder.decodeFrame(&pkt, buf) && (i == idx); av_packet_unref(&pkt); } } diff --git a/openpilot/tools/replay/framereader.h b/openpilot/tools/replay/framereader.h index 3609d64f8b..65feb5b3b7 100644 --- a/openpilot/tools/replay/framereader.h +++ b/openpilot/tools/replay/framereader.h @@ -7,7 +7,7 @@ #include "tools/replay/util.h" #ifndef __APPLE__ -#include "tools/replay/qcom_decoder.h" +#include "system/loggerd/encoder/v4l_decoder.h" #endif extern "C" { @@ -67,14 +67,14 @@ private: }; #ifndef __APPLE__ -class QcomVideoDecoder : public VideoDecoder { +class V4LVideoDecoder : public VideoDecoder { public: - QcomVideoDecoder() {}; - ~QcomVideoDecoder() override {}; + V4LVideoDecoder() {}; + ~V4LVideoDecoder() override {}; bool open(AVCodecParameters *codecpar, bool hw_decoder) override; bool decode(FrameReader *reader, int idx, VisionBuf *buf) override; private: - MsmVidc msm_vidc = MsmVidc(); + V4LDecoder v4l_decoder; }; #endif diff --git a/openpilot/tools/replay/logreader.cc b/openpilot/tools/replay/logreader.cc index 54b69dc168..315aed9741 100644 --- a/openpilot/tools/replay/logreader.cc +++ b/openpilot/tools/replay/logreader.cc @@ -32,16 +32,6 @@ bool LogReader::load(const std::string &url, std::atomic *abort, bool loca } compressed_size_ = data.size(); download_seconds_ = std::chrono::duration(download_end - download_start).count(); - if (!data.empty()) { - const auto decompress_start = Clock::now(); - if (url.find(".bz2") != std::string::npos || util::starts_with(data, "BZh9")) { - data = decompressBZ2(data, abort); - } else if (url.find(".zst") != std::string::npos || util::starts_with(data, "\x28\xB5\x2F\xFD")) { - data = decompressZST(data, abort); - } - const auto decompress_end = Clock::now(); - decompress_seconds_ = std::chrono::duration(decompress_end - decompress_start).count(); - } decompressed_size_ = data.size(); bool success = !data.empty() && load(data.data(), data.size(), abort, progress); @@ -84,8 +74,8 @@ bool LogReader::load(const char *data, size_t size, std::atomic *abort, uint64_t mono_time = event.getLogMonoTime(); const Event &evt = events.emplace_back(which, mono_time, event_data); // Add encodeIdx packet again as a frame packet for the video stream - if (evt.which == cereal::Event::ROAD_ENCODE_IDX || - evt.which == cereal::Event::DRIVER_ENCODE_IDX || + if (evt.which == cereal::Event::NARROW_ROAD_ENCODE_IDX || + evt.which == cereal::Event::CABIN_ENCODE_IDX || evt.which == cereal::Event::WIDE_ROAD_ENCODE_IDX) { auto idx = capnp::AnyStruct::Reader(event).getPointerSection()[0].getAs(); if (idx.getType() == cereal::EncodeIndex::Type::FULL_H_E_V_C) { diff --git a/openpilot/tools/replay/logreader.h b/openpilot/tools/replay/logreader.h index 63f7468401..52e82c46ad 100644 --- a/openpilot/tools/replay/logreader.h +++ b/openpilot/tools/replay/logreader.h @@ -8,7 +8,7 @@ #include "openpilot/cereal/gen/cpp/log.capnp.h" #include "tools/replay/util.h" -const CameraType ALL_CAMERAS[] = {RoadCam, DriverCam, WideRoadCam}; +const CameraType ALL_CAMERAS[] = {NarrowRoadCam, CabinCam, WideRoadCam}; const int MAX_CAMERAS = std::size(ALL_CAMERAS); class Event { diff --git a/openpilot/tools/replay/main.cc b/openpilot/tools/replay/main.cc index bdb9cf4f35..50233189a1 100644 --- a/openpilot/tools/replay/main.cc +++ b/openpilot/tools/replay/main.cc @@ -26,8 +26,8 @@ Options: internal, openpilotci, comma_api, car_segments, testing_closet -d, --data_dir Local directory with routes -p, --prefix Set OPENPILOT_PREFIX - --dcam Load driver camera - --ecam Load wide road camera + --cabin Load cabin camera (alias: --dcam) + --wide-road Load wide road camera (alias: --ecam) --no-loop Stop at the end of the route --no-cache Turn off local cache --qcam Load qcamera @@ -62,8 +62,10 @@ bool parseArgs(int argc, char *argv[], ReplayConfig &config) { {"auto", no_argument, nullptr, 0}, {"data_dir", required_argument, nullptr, 'd'}, {"prefix", required_argument, nullptr, 'p'}, - {"dcam", no_argument, nullptr, 0}, - {"ecam", no_argument, nullptr, 0}, + {"cabin", no_argument, nullptr, 0}, + {"dcam", no_argument, nullptr, 0}, // deprecated alias + {"wide-road", no_argument, nullptr, 0}, + {"ecam", no_argument, nullptr, 0}, // deprecated alias {"no-loop", no_argument, nullptr, 0}, {"no-cache", no_argument, nullptr, 0}, {"qcam", no_argument, nullptr, 0}, @@ -76,8 +78,10 @@ bool parseArgs(int argc, char *argv[], ReplayConfig &config) { }; const std::map flag_map = { - {"dcam", REPLAY_FLAG_DCAM}, - {"ecam", REPLAY_FLAG_ECAM}, + {"cabin", REPLAY_FLAG_CABIN_CAMERA}, + {"dcam", REPLAY_FLAG_CABIN_CAMERA}, // deprecated alias + {"wide-road", REPLAY_FLAG_WIDE_ROAD}, + {"ecam", REPLAY_FLAG_WIDE_ROAD}, // deprecated alias {"no-loop", REPLAY_FLAG_NO_LOOP}, {"no-cache", REPLAY_FLAG_NO_FILE_CACHE}, {"qcam", REPLAY_FLAG_QCAMERA}, diff --git a/openpilot/tools/replay/py_downloader.cc b/openpilot/tools/replay/py_downloader.cc index d27a77e6ee..a265dfd6a3 100644 --- a/openpilot/tools/replay/py_downloader.cc +++ b/openpilot/tools/replay/py_downloader.cc @@ -152,6 +152,10 @@ std::string download(const std::string &url, bool use_cache, std::atomic * return runPython(args, abort); } +std::string decompress(const std::string &path, std::atomic *abort) { + return runPython({"decompress", path}, abort); +} + std::string getRouteFiles(const std::string &route) { return runPython({"route-files", route}); } diff --git a/openpilot/tools/replay/py_downloader.h b/openpilot/tools/replay/py_downloader.h index 535189784c..80fab6ab00 100644 --- a/openpilot/tools/replay/py_downloader.h +++ b/openpilot/tools/replay/py_downloader.h @@ -12,6 +12,9 @@ namespace PyDownloader { // Downloads url to local cache, returns local file path. Reports progress via installDownloadProgressHandler. std::string download(const std::string &url, bool use_cache = true, std::atomic *abort = nullptr); +// Decompresses a local log file and returns the temporary output path. +std::string decompress(const std::string &path, std::atomic *abort = nullptr); + // Returns JSON string of route files (same format as /v1/route/.../files API) std::string getRouteFiles(const std::string &route); diff --git a/openpilot/tools/replay/qcom_decoder.h b/openpilot/tools/replay/qcom_decoder.h deleted file mode 100644 index 1d7522cc70..0000000000 --- a/openpilot/tools/replay/qcom_decoder.h +++ /dev/null @@ -1,88 +0,0 @@ -#pragma once - -#include -#include - -#include "msgq/visionipc/visionbuf.h" - -extern "C" { - #include - #include -} - -#define V4L2_EVENT_MSM_VIDC_START (V4L2_EVENT_PRIVATE_START + 0x00001000) -#define V4L2_EVENT_MSM_VIDC_FLUSH_DONE (V4L2_EVENT_MSM_VIDC_START + 1) -#define V4L2_EVENT_MSM_VIDC_PORT_SETTINGS_CHANGED_INSUFFICIENT (V4L2_EVENT_MSM_VIDC_START + 3) -#define V4L2_CID_MPEG_MSM_VIDC_BASE 0x00992000 -#define V4L2_CID_MPEG_VIDC_VIDEO_DPB_COLOR_FORMAT (V4L2_CID_MPEG_MSM_VIDC_BASE + 44) -#define V4L2_CID_MPEG_VIDC_VIDEO_STREAM_OUTPUT_MODE (V4L2_CID_MPEG_MSM_VIDC_BASE + 22) -#define V4L2_QCOM_CMD_FLUSH_CAPTURE (1 << 1) -#define V4L2_QCOM_CMD_FLUSH (4) - -#define VIDEO_DEVICE "/dev/video32" -#define OUTPUT_BUFFER_COUNT 8 -#define CAPTURE_BUFFER_COUNT 8 -#define FPS 20 - - -class MsmVidc { -public: - MsmVidc() = default; - ~MsmVidc(); - - bool init(const char* dev, size_t width, size_t height, uint64_t codec); - VisionBuf* decodeFrame(AVPacket* pkt, VisionBuf* buf); - - AVFormatContext* avctx = nullptr; - int fd = 0; - -private: - bool initialized = false; - bool reconfigure_pending = false; - bool frame_ready = false; - - VisionBuf* current_output_buf = nullptr; - VisionBuf out_buf; // Single input buffer - VisionBuf cap_bufs[CAPTURE_BUFFER_COUNT]; // Capture (output) buffers - - size_t w = 1928, h = 1208; - size_t cap_height = 0, cap_width = 0; - - int cap_buf_size = 0; - int out_buf_size = 0; - - size_t cap_plane_off[CAPTURE_BUFFER_COUNT] = {0}; - size_t cap_plane_stride[CAPTURE_BUFFER_COUNT] = {0}; - bool cap_buf_flag[CAPTURE_BUFFER_COUNT] = {false}; - - size_t out_buf_off[OUTPUT_BUFFER_COUNT] = {0}; - void* out_buf_addr[OUTPUT_BUFFER_COUNT] = {0}; - bool out_buf_flag[OUTPUT_BUFFER_COUNT] = {false}; - const int out_buf_cnt = OUTPUT_BUFFER_COUNT; - - const int subscriptions[2] = { - V4L2_EVENT_MSM_VIDC_FLUSH_DONE, - V4L2_EVENT_MSM_VIDC_PORT_SETTINGS_CHANGED_INSUFFICIENT - }; - - enum { EV_VIDEO, EV_COUNT }; - struct pollfd pfd[EV_COUNT] = {0}; - int ev[EV_COUNT] = {-1}; - int nfds = 0; - - VisionBuf* processEvents(); - bool setupOutput(); - bool subscribeEvents(); - bool setPlaneFormat(v4l2_buf_type type, uint32_t fourcc); - bool setFPS(uint32_t fps); - bool restartCapture(); - bool queueCaptureBuffer(int i); - bool queueOutputBuffer(int i, size_t size); - bool setDBP(); - bool setupPolling(); - bool sendPacket(int buf_index, AVPacket* pkt); - int getBufferUnlocked(); - VisionBuf* handleCapture(); - bool handleOutput(); - bool handleEvent(); -}; diff --git a/openpilot/tools/replay/replay.cc b/openpilot/tools/replay/replay.cc index 75aecfd0c2..6449fc30ea 100644 --- a/openpilot/tools/replay/replay.cc +++ b/openpilot/tools/replay/replay.cc @@ -251,13 +251,13 @@ void Replay::publishMessage(const Event *e) { void Replay::publishFrame(const Event *e) { CameraType cam; switch (e->which) { - case cereal::Event::ROAD_ENCODE_IDX: cam = RoadCam; break; - case cereal::Event::DRIVER_ENCODE_IDX: cam = DriverCam; break; + case cereal::Event::NARROW_ROAD_ENCODE_IDX: cam = NarrowRoadCam; break; + case cereal::Event::CABIN_ENCODE_IDX: cam = CabinCam; break; case cereal::Event::WIDE_ROAD_ENCODE_IDX: cam = WideRoadCam; break; default: return; // Invalid event type } - if ((cam == DriverCam && !hasFlag(REPLAY_FLAG_DCAM)) || (cam == WideRoadCam && !hasFlag(REPLAY_FLAG_ECAM))) + if ((cam == CabinCam && !hasFlag(REPLAY_FLAG_CABIN_CAMERA)) || (cam == WideRoadCam && !hasFlag(REPLAY_FLAG_WIDE_ROAD))) return; // Camera isdisabled auto seg_it = event_data_->segments.find(e->eidx_segnum); diff --git a/openpilot/tools/replay/replay.h b/openpilot/tools/replay/replay.h index ce6d75bd6d..7cd5fbecd0 100644 --- a/openpilot/tools/replay/replay.h +++ b/openpilot/tools/replay/replay.h @@ -16,8 +16,8 @@ enum REPLAY_FLAGS { REPLAY_FLAG_NONE = 0x0000, - REPLAY_FLAG_DCAM = 0x0002, - REPLAY_FLAG_ECAM = 0x0004, + REPLAY_FLAG_CABIN_CAMERA = 0x0002, + REPLAY_FLAG_WIDE_ROAD = 0x0004, REPLAY_FLAG_NO_LOOP = 0x0010, REPLAY_FLAG_NO_FILE_CACHE = 0x0020, REPLAY_FLAG_QCAMERA = 0x0040, diff --git a/openpilot/tools/replay/route.cc b/openpilot/tools/replay/route.cc index 326d28d726..f38c2b1971 100644 --- a/openpilot/tools/replay/route.cc +++ b/openpilot/tools/replay/route.cc @@ -180,9 +180,9 @@ void Route::addFileToSegment(int n, const std::string &file) { } else if (name == "qlog.bz2" || name == "qlog.zst" || name == "qlog") { segments_[n].qlog = file; } else if (name == "fcamera.hevc") { - segments_[n].road_cam = file; + segments_[n].narrow_road_cam = file; } else if (name == "dcamera.hevc") { - segments_[n].driver_cam = file; + segments_[n].cabin_cam = file; } else if (name == "ecamera.hevc") { segments_[n].wide_road_cam = file; } else if (name == "qcamera.ts") { @@ -195,11 +195,11 @@ void Route::addFileToSegment(int n, const std::string &file) { Segment::Segment(int n, const SegmentFile &files, uint32_t flags, const std::vector &filters, std::function callback) : seg_num(n), flags(flags), filters_(filters), on_load_finished_(callback) { - // [RoadCam, DriverCam, WideRoadCam, log]. fallback to qcamera/qlog + // [NarrowRoadCam, CabinCam, WideRoadCam, log]. fallback to qcamera/qlog const std::array file_list = { - (flags & REPLAY_FLAG_QCAMERA) || files.road_cam.empty() ? files.qcamera : files.road_cam, - flags & REPLAY_FLAG_DCAM ? files.driver_cam : "", - flags & REPLAY_FLAG_ECAM ? files.wide_road_cam : "", + (flags & REPLAY_FLAG_QCAMERA) || files.narrow_road_cam.empty() ? files.qcamera : files.narrow_road_cam, + flags & REPLAY_FLAG_CABIN_CAMERA ? files.cabin_cam : "", + flags & REPLAY_FLAG_WIDE_ROAD ? files.wide_road_cam : "", files.rlog.empty() ? files.qlog : files.rlog, }; for (int i = 0; i < file_list.size(); ++i) { diff --git a/openpilot/tools/replay/route.h b/openpilot/tools/replay/route.h index 50f3eba854..7a55997969 100644 --- a/openpilot/tools/replay/route.h +++ b/openpilot/tools/replay/route.h @@ -33,8 +33,8 @@ struct RouteIdentifier { struct SegmentFile { std::string rlog; std::string qlog; - std::string road_cam; - std::string driver_cam; + std::string narrow_road_cam; + std::string cabin_cam; std::string wide_road_cam; std::string qcamera; }; diff --git a/openpilot/tools/replay/tests/test_replay.cc b/openpilot/tools/replay/tests/test_replay.cc deleted file mode 100644 index 45fcc98191..0000000000 --- a/openpilot/tools/replay/tests/test_replay.cc +++ /dev/null @@ -1,18 +0,0 @@ -#define CATCH_CONFIG_MAIN -#include "catch2/catch.hpp" -#include "tools/replay/filereader.h" -#include "tools/replay/replay.h" - -const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; - -TEST_CASE("LogReader") { - SECTION("corrupt log") { - FileReader reader(true); - std::string corrupt_content = reader.read(TEST_RLOG_URL); - corrupt_content.resize(corrupt_content.length() / 2); - corrupt_content = decompressBZ2(corrupt_content); - LogReader log; - REQUIRE(log.load(corrupt_content.data(), corrupt_content.size())); - REQUIRE(log.events.size() > 0); - } -} diff --git a/openpilot/tools/replay/ui.py b/openpilot/tools/replay/ui.py index 4fbede2be9..7f34f1303d 100755 --- a/openpilot/tools/replay/ui.py +++ b/openpilot/tools/replay/ui.py @@ -21,7 +21,7 @@ from openpilot.tools.replay.lib.ui_helpers import ( plot_lead, plot_model, ) -from msgq.visionipc import VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView os.environ['BASEDIR'] = BASEDIR @@ -57,7 +57,7 @@ def ui_thread(addr): font_path = os.path.join(BASEDIR, "openpilot/selfdrive/assets/fonts/JetBrainsMono-Medium.ttf") font = rl.load_font_ex(font_path, 32, None, 0) - camera_view = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD) + camera_view = CameraView("camerad", VisionStreamType.VISION_STREAM_NARROW_ROAD) # Overlay texture for model/lane line drawing overlay_img = np.zeros((480, 640, 4), dtype='uint8') @@ -76,13 +76,13 @@ def ui_thread(addr): 'longitudinalPlan', 'carControl', 'radarState', - 'liveCalibration', + 'extrinsicsCalibration', 'controlsState', 'selfdriveState', - 'liveTracks', + 'radarTracks', 'modelV2', - 'liveParameters', - 'roadCameraState', + 'vehicleParameters', + 'narrowRoadCameraState', ], addr=addr, ) @@ -152,13 +152,13 @@ def ui_thread(addr): sm.update(0) - camera = DEVICE_CAMERAS[("tici", str(sm['roadCameraState'].sensor))] - calib_scale = camera.fcam.width / 640.0 + camera = DEVICE_CAMERAS[("tici", str(sm['narrowRoadCameraState'].sensor))] + calib_scale = camera.narrow_road.width / 640.0 if camera_view.frame: num_px = camera_view.frame.width * camera_view.frame.height - intrinsic_matrix = camera.fcam.intrinsics + intrinsic_matrix = camera.narrow_road.intrinsics w = sm['controlsState'].lateralControlState.which() if w == 'lqrStateDEPRECATED': @@ -195,10 +195,10 @@ def ui_thread(addr): plot_lead(sm['radarState'], top_down) # draw all radar points - maybe_update_radar_points(sm['liveTracks'].points, top_down[1]) + maybe_update_radar_points(sm['radarTracks'].points, top_down[1]) - if sm.updated['liveCalibration'] and num_px: - rpyCalib = np.asarray(sm['liveCalibration'].rpyCalib) + if sm.updated['extrinsicsCalibration'] and num_px: + rpyCalib = np.asarray(sm['extrinsicsCalibration'].rpyCalib) calibration = Calibration(num_px, rpyCalib, intrinsic_matrix, calib_scale) # Update overlay texture (RGB img -> RGBA with non-black pixels visible) @@ -232,10 +232,10 @@ def ui_thread(addr): ("LONG CONTROL STATE: " + str(sm['controlsState'].longControlState), YELLOW), ("LONG MPC SOURCE: " + str(sm['longitudinalPlan'].longitudinalPlanSource), YELLOW), None, - ("ANGLE OFFSET (AVG): " + str(round(sm['liveParameters'].angleOffsetAverageDeg, 2)) + " deg", YELLOW), - ("ANGLE OFFSET (INSTANT): " + str(round(sm['liveParameters'].angleOffsetDeg, 2)) + " deg", YELLOW), - ("STIFFNESS: " + str(round(sm['liveParameters'].stiffnessFactor * 100.0, 2)) + " %", YELLOW), - ("STEER RATIO: " + str(round(sm['liveParameters'].steerRatio, 2)), YELLOW), + ("ANGLE OFFSET (AVG): " + str(round(sm['vehicleParameters'].angleOffsetAverageDeg, 2)) + " deg", YELLOW), + ("ANGLE OFFSET (INSTANT): " + str(round(sm['vehicleParameters'].angleOffsetDeg, 2)) + " deg", YELLOW), + ("STIFFNESS: " + str(round(sm['vehicleParameters'].stiffnessFactor * 100.0, 2)) + " %", YELLOW), + ("STEER RATIO: " + str(round(sm['vehicleParameters'].steerRatio, 2)), YELLOW), ] for i, line in enumerate(lines): diff --git a/openpilot/tools/replay/util.cc b/openpilot/tools/replay/util.cc index 7b308b5c3d..44e144443a 100644 --- a/openpilot/tools/replay/util.cc +++ b/openpilot/tools/replay/util.cc @@ -1,14 +1,10 @@ #include "tools/replay/util.h" -#include - #include #include #include #include #include -#include - #include "common/timing.h" #include "common/util.h" @@ -58,90 +54,6 @@ std::string getUrlWithoutQuery(const std::string &url) { return (idx == std::string::npos ? url : url.substr(0, idx)); } -std::string decompressBZ2(const std::string &in, std::atomic *abort) { - return decompressBZ2((std::byte *)in.data(), in.size(), abort); -} - -std::string decompressBZ2(const std::byte *in, size_t in_size, std::atomic *abort) { - if (in_size == 0) return {}; - - bz_stream strm = {}; - int bzerror = BZ2_bzDecompressInit(&strm, 0, 0); - assert(bzerror == BZ_OK); - - strm.next_in = (char *)in; - strm.avail_in = in_size; - std::string out(in_size * 5, '\0'); - do { - strm.next_out = (char *)(&out[strm.total_out_lo32]); - strm.avail_out = out.size() - strm.total_out_lo32; - - const char *prev_write_pos = strm.next_out; - bzerror = BZ2_bzDecompress(&strm); - if (bzerror == BZ_OK && prev_write_pos == strm.next_out) { - // content is corrupt - bzerror = BZ_STREAM_END; - rWarning("decompressBZ2 error: content is corrupt"); - break; - } - - if (bzerror == BZ_OK && strm.avail_in > 0 && strm.avail_out == 0) { - out.resize(out.size() * 2); - } - } while (bzerror == BZ_OK && !(abort && *abort)); - - BZ2_bzDecompressEnd(&strm); - if (bzerror == BZ_STREAM_END && !(abort && *abort)) { - out.resize(strm.total_out_lo32); - out.shrink_to_fit(); - return out; - } - return {}; -} - -std::string decompressZST(const std::string &in, std::atomic *abort) { - return decompressZST((std::byte *)in.data(), in.size(), abort); -} - -std::string decompressZST(const std::byte *in, size_t in_size, std::atomic *abort) { - ZSTD_DCtx *dctx = ZSTD_createDCtx(); - assert(dctx != nullptr); - - // Initialize input and output buffers - ZSTD_inBuffer input = {in, in_size, 0}; - - // Estimate and reserve memory for decompressed data - size_t estimatedDecompressedSize = ZSTD_getFrameContentSize(in, in_size); - if (estimatedDecompressedSize == ZSTD_CONTENTSIZE_ERROR || estimatedDecompressedSize == ZSTD_CONTENTSIZE_UNKNOWN) { - estimatedDecompressedSize = in_size * 2; // Use a fallback size - } - - std::string decompressedData; - decompressedData.reserve(estimatedDecompressedSize); - - const size_t bufferSize = ZSTD_DStreamOutSize(); // Recommended output buffer size - std::string outputBuffer(bufferSize, '\0'); - - while (input.pos < input.size && !(abort && *abort)) { - ZSTD_outBuffer output = {outputBuffer.data(), bufferSize, 0}; - - size_t result = ZSTD_decompressStream(dctx, &output, &input); - if (ZSTD_isError(result)) { - rWarning("decompressZST error: content is corrupt"); - break; - } - - decompressedData.append(outputBuffer.data(), output.pos); - } - - ZSTD_freeDCtx(dctx); - if (!(abort && *abort)) { - decompressedData.shrink_to_fit(); - return decompressedData; - } - return {}; -} - void precise_nano_sleep(int64_t nanoseconds, std::atomic &interrupt_requested) { struct timespec req, rem; req.tv_sec = nanoseconds / 1000000000; diff --git a/openpilot/tools/replay/util.h b/openpilot/tools/replay/util.h index 03a15787b2..5cffc11706 100644 --- a/openpilot/tools/replay/util.h +++ b/openpilot/tools/replay/util.h @@ -10,8 +10,8 @@ #include "openpilot/cereal/messaging/messaging.h" enum CameraType { - RoadCam = 0, - DriverCam, + NarrowRoadCam = 0, + CabinCam, WideRoadCam }; @@ -47,10 +47,6 @@ private: }; void precise_nano_sleep(int64_t nanoseconds, std::atomic &interrupt_requested); -std::string decompressBZ2(const std::string &in, std::atomic *abort = nullptr); -std::string decompressBZ2(const std::byte *in, size_t in_size, std::atomic *abort = nullptr); -std::string decompressZST(const std::string &in, std::atomic *abort = nullptr); -std::string decompressZST(const std::byte *in, size_t in_size, std::atomic *abort = nullptr); std::string getUrlWithoutQuery(const std::string &url); std::string formattedDataSize(size_t size); std::string extractFileName(const std::string& file); diff --git a/openpilot/tools/sim/bridge/metadrive/metadrive_process.py b/openpilot/tools/sim/bridge/metadrive/metadrive_process.py index 2486d87ff9..01d0473755 100644 --- a/openpilot/tools/sim/bridge/metadrive/metadrive_process.py +++ b/openpilot/tools/sim/bridge/metadrive/metadrive_process.py @@ -27,9 +27,9 @@ def apply_metadrive_patches(arrive_dest_done=True): # By default, metadrive won't try to use cuda images unless it's used as a sensor for vehicles, so patch that in def add_image_sensor_patched(self, name: str, cls, args): if self.global_config["image_on_cuda"]:# and name == self.global_config["vehicle_config"]["image_source"]: - sensor = cls(*args, self, cuda=True) + sensor = cls(*args, self, cuda=True) else: - sensor = cls(*args, self, cuda=False) + sensor = cls(*args, self, cuda=False) assert isinstance(sensor, ImageBuffer), "This API is for adding image sensor" self.sensors[name] = sensor diff --git a/openpilot/tools/sim/lib/camerad.py b/openpilot/tools/sim/lib/camerad.py index 8efb3e5dab..206e4fdf90 100644 --- a/openpilot/tools/sim/lib/camerad.py +++ b/openpilot/tools/sim/lib/camerad.py @@ -1,6 +1,7 @@ import numpy as np -from msgq.visionipc import VisionIpcServer, VisionStreamType +from openpilot.cereal.visionipc import VisionStreamType +from msgq.visionipc import VisionIpcServer from openpilot.cereal import messaging from openpilot.tools.sim.lib.common import W, H @@ -37,20 +38,20 @@ def rgb_to_nv12(rgb): class Camerad: """Simulates the camerad daemon""" def __init__(self, dual_camera): - self.pm = messaging.PubMaster(['roadCameraState', 'wideRoadCameraState']) + self.pm = messaging.PubMaster(['narrowRoadCameraState', 'wideRoadCameraState']) self.frame_road_id = 0 self.frame_wide_id = 0 self.vipc_server = VisionIpcServer("camerad") - self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_ROAD, 5, W, H) + self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_NARROW_ROAD, 5, W, H) if dual_camera: self.vipc_server.create_buffers(VisionStreamType.VISION_STREAM_WIDE_ROAD, 5, W, H) self.vipc_server.start_listener() def cam_send_yuv_road(self, yuv): - self._send_yuv(yuv, self.frame_road_id, 'roadCameraState', VisionStreamType.VISION_STREAM_ROAD) + self._send_yuv(yuv, self.frame_road_id, 'narrowRoadCameraState', VisionStreamType.VISION_STREAM_NARROW_ROAD) self.frame_road_id += 1 def cam_send_yuv_wide_road(self, yuv): diff --git a/openpilot/tools/sim/tests/conftest.py b/openpilot/tools/sim/tests/conftest.py deleted file mode 100644 index ddf6635276..0000000000 --- a/openpilot/tools/sim/tests/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import pytest - -def pytest_addoption(parser): - parser.addoption("--test_duration", action="store", default=60, type=int, help="Seconds to run metadrive drive") - -@pytest.fixture -def test_duration(request): - return request.config.getoption("--test_duration") diff --git a/openpilot/tools/sim/tests/test_metadrive_bridge.py b/openpilot/tools/sim/tests/test_metadrive_bridge.py index 9be640d736..4e7560907b 100644 --- a/openpilot/tools/sim/tests/test_metadrive_bridge.py +++ b/openpilot/tools/sim/tests/test_metadrive_bridge.py @@ -1,17 +1,22 @@ -import pytest import warnings +import unittest +import importlib # Since metadrive depends on pkg_resources, and pkg_resources is deprecated as an API warnings.filterwarnings("ignore", category=DeprecationWarning) -from openpilot.tools.sim.bridge.metadrive.metadrive_bridge import MetaDriveBridge +try: + MetaDriveBridge = importlib.import_module("openpilot.tools.sim.bridge.metadrive.metadrive_bridge").MetaDriveBridge +except ModuleNotFoundError: + MetaDriveBridge = None from openpilot.tools.sim.tests.test_sim_bridge import TestSimBridgeBase -@pytest.mark.slow +@unittest.skipIf(MetaDriveBridge is None, "metadrive is not installed") class TestMetaDriveBridge(TestSimBridgeBase): - @pytest.fixture(autouse=True) - def setup_create_bridge(self, test_duration): + def setup_method(self): + super().openpilot_setup_method() self.test_duration = 30 def create_bridge(self): + assert MetaDriveBridge is not None return MetaDriveBridge(False, False, self.test_duration, True) diff --git a/openpilot/tools/sim/tests/test_sim_bridge.py b/openpilot/tools/sim/tests/test_sim_bridge.py index f93cc2ef50..a2c0611937 100644 --- a/openpilot/tools/sim/tests/test_sim_bridge.py +++ b/openpilot/tools/sim/tests/test_sim_bridge.py @@ -1,25 +1,27 @@ import os import subprocess import time -import pytest +import unittest from multiprocessing import Queue +from openpilot.common.test import OpenpilotTestCase from openpilot.cereal import messaging from openpilot.common.basedir import BASEDIR from openpilot.tools.sim.bridge.common import QueueMessageType SIM_DIR = os.path.join(BASEDIR, "openpilot/tools/sim") -class TestSimBridgeBase: +class TestSimBridgeBase(OpenpilotTestCase): @classmethod def setup_class(cls): if cls is TestSimBridgeBase: - raise pytest.skip("Don't run this base class, run test_metadrive_bridge.py instead") + raise unittest.SkipTest("Don't run this base class, run test_metadrive_bridge.py instead") def setup_method(self): self.processes = [] + @unittest.skip("TODO: re-enable simulator bridge test") def test_driving(self): # Startup manager and bridge.py. Check processes are running, then engage and verify. p_manager = subprocess.Popen("./launch_openpilot.sh", cwd=SIM_DIR) diff --git a/panda b/panda index 61b050f1bd..ea5a83a956 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit 61b050f1bd36fad04d4ceccca8a72c92ee1422df +Subproject commit ea5a83a956d61c7540c1a13a8d76f08c24675d1b diff --git a/pyproject.toml b/pyproject.toml index 051eb5ffc6..dcc1024479 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,12 @@ authors = [ {name = "Vehicle Researcher", email="user@comma.ai"} ] +# ------------------------------------------ +# > this dependencies list *only* goes down. +# ------------------------------------------ +# Linux and Python is all we need, otherwise +# we write and own the lines ourselves. +# ------------------------------------------ dependencies = [ # multiple users "sounddevice", # micd + soundd @@ -17,12 +23,10 @@ dependencies = [ # core "scons", "pycapnp==2.1.0", # 2.2 introduces a memory leak due to cyclic references - "Cython", "numpy >=2.0", # vendored native dependencies "comma-deps-capnproto", - "comma-deps-catch2", "comma-deps-acados", "comma-deps-ffmpeg", "comma-deps-zstd", @@ -31,10 +35,6 @@ dependencies = [ "comma-deps-git-lfs", "comma-deps-gcc-arm-none-eabi", - # logging - "pyzmq", - "sentry-sdk", - # athena "PyJWT[crypto]", "websocket_client", @@ -43,16 +43,14 @@ dependencies = [ "inputs", # these should be removed + "pyzmq", + "sentry-sdk", "setproctitle", - - # logreader - "zstandard", + "jeepney", + "zstandard", # this can go once we're on Python 3.14+ # ui "comma-deps-raylib", - "qrcode", - "jeepney", - "pillow", ] [project.optional-dependencies] @@ -60,28 +58,21 @@ docs = [ "zensical", ] +dev = [ + "huggingface_hub", +] + testing = [ "coverage", - "hypothesis ==6.47.*", "ty", - "pytest", - "pytest-cpp", - "pytest-subtests", - # https://github.com/pytest-dev/pytest-xdist/pull/1229 - "pytest-xdist @ git+https://github.com/sshane/pytest-xdist@2b4372bd62699fb412c4fe2f95bf9f01bd2018da", - "pytest-mock", "ruff", "codespell", ] -dev = [ - "huggingface_hub", - "matplotlib", -] - tools = [ + "matplotlib", + "comma-deps-imgui", - "comma-deps-bzip2", "comma-deps-bootstrap-icons", "comma-deps-libusb", "comma-deps-ncurses", @@ -119,24 +110,6 @@ packages = [ [tool.hatch.metadata] allow-direct-references = true -[tool.pytest.ini_options] -minversion = "6.0" -addopts = "-Werror --strict-config --strict-markers --durations=10 -n auto --dist=loadgroup" -cpp_files = "test_*" -cpp_harness = "openpilot/selfdrive/test/cpp_harness.py" -python_files = "test_*.py" -markers = [ - "slow: tests that take awhile to run and can be skipped with -m 'not slow'", - "tici: tests that are only meant to run on the C3/C3X", - "skip_tici_setup: mark test to skip tici setup fixture", - "nocapture: don't capture test output", - "shared_download_cache: share download cache between tests", - "xdist_group_class_property: group tests by a property of the class that contains them", -] -testpaths = [ - "openpilot", -] - [tool.codespell] quiet-level = 3 # if you've got a short variable name that's getting flagged, add it here @@ -175,10 +148,7 @@ exclude = [ lint.flake8-implicit-str-concat.allow-multiline = false [tool.ruff.lint.flake8-tidy-imports.banned-api] -"pytest.main".msg = "pytest.main requires special handling that is easy to mess up!" -"time.time".msg = "Use time.monotonic" # time.time can skip due to its reference clock, you probably want a monotonic clock - -# raylib banned APIs +"time.time".msg = "Use time.monotonic. time.time can skip due to its reference clock, you probably want a monotonic clock" "pyray.measure_text_ex".msg = "Use openpilot.system.ui.lib.text_measure" "pyray.is_mouse_button_pressed".msg = "This can miss events. Use Widget._handle_mouse_press" "pyray.is_mouse_button_released".msg = "This can miss events. Use Widget._handle_mouse_release" diff --git a/rednose_repo b/rednose_repo index 9e19086c26..28d4a7f69e 160000 --- a/rednose_repo +++ b/rednose_repo @@ -1 +1 @@ -Subproject commit 9e19086c26ca35708870d50ebcf237d65d0b163e +Subproject commit 28d4a7f69e80e1c3e0d24ca0733d7daeaeade3d0 diff --git a/scripts/docs.py b/scripts/docs.py deleted file mode 100644 index d60bfb791f..0000000000 --- a/scripts/docs.py +++ /dev/null @@ -1,63 +0,0 @@ -""" - wrapper that materializes symlinks in docs/ before build - - we can delete this once zensical supports symlinks: - https://github.com/zensical/backlog/issues/55 -""" -import os -import shutil -import signal -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -DOCS_DIR = REPO_ROOT / "docs" -SITE_DIR = REPO_ROOT / "docs_site" -sys.path.insert(0, str(REPO_ROOT)) -# Local docs build helpers live under docs/ so they stay near the content -# source. The wrapper prunes them from docs_site/ after build. -sys.path.insert(0, str(DOCS_DIR)) - - -def _materialize(docs: Path) -> dict[Path, str]: - originals: dict[Path, str] = {} - for link in docs.rglob("*"): - if not link.is_symlink(): - continue - target = link.resolve() - if not target.is_file(): - continue - originals[link] = os.readlink(link) - link.unlink() - shutil.copy2(target, link) - return originals - - -def _restore(originals: dict[Path, str]) -> None: - for link, target in originals.items(): - link.unlink(missing_ok=True) - os.symlink(target, link) - - -def _raise_interrupt(*_): - raise KeyboardInterrupt - - -def _prune_site_output() -> None: - shutil.rmtree(SITE_DIR / "ext", ignore_errors=True) - - -def main() -> None: - signal.signal(signal.SIGTERM, _raise_interrupt) - originals = _materialize(DOCS_DIR) - try: - from zensical.main import cli - cli(standalone_mode=False) - if len(sys.argv) > 1 and sys.argv[1] == "build": - _prune_site_output() - finally: - _restore(originals) - - -if __name__ == "__main__": - main() diff --git a/scripts/lint/check_indentation.py b/scripts/lint/check_indentation.py new file mode 100755 index 0000000000..533dd2efa9 --- /dev/null +++ b/scripts/lint/check_indentation.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +import argparse +import tokenize + + +# TODO: remove this once https://github.com/astral-sh/ruff/issues/8705 is closed +def check_indentation(filename: str, indent_width: int = 2) -> bool: + failed = False + indent_stack = [0] + + with tokenize.open(filename) as f: + tokens = tokenize.generate_tokens(f.readline) + for token in tokens: + if token.type == tokenize.INDENT: + indentation = token.string + width = len(indentation) + expected = indent_stack[-1] + indent_width + + if indentation != " " * expected: + found = "indentation containing tabs" if "\t" in indentation else f"{width} spaces" + print(f"{filename}:{token.start[0]}:1: expected {expected} spaces, found {found}") + failed = True + indent_stack.append(width) + elif token.type == tokenize.DEDENT: + indent_stack.pop() + + return failed + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Check Python block indentation.") + parser.add_argument("filenames", nargs="+") + args = parser.parse_args() + + failed = False + for filename in args.filenames: + failed |= check_indentation(filename) + raise SystemExit(failed) diff --git a/scripts/lint/lint.sh b/scripts/lint/lint.sh index 69ed3951d8..8f3aab4e52 100755 --- a/scripts/lint/lint.sh +++ b/scripts/lint/lint.sh @@ -46,6 +46,7 @@ function run_tests() { 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 @@ -66,6 +67,7 @@ function help() { echo "" echo -e "${BOLD}${UNDERLINE}Tests:${NC}" echo -e " ${BOLD}ruff${NC}" + echo -e " ${BOLD}check_indentation${NC}" echo -e " ${BOLD}ty${NC}" echo -e " ${BOLD}codespell${NC}" echo -e " ${BOLD}check_added_large_files${NC}" diff --git a/scripts/reporter.py b/scripts/reporter.py deleted file mode 100755 index 5de5521835..0000000000 --- a/scripts/reporter.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python3 -import os -import glob - -from tinygrad.nn.onnx import OnnxPBParser - -BASEDIR = os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(__file__)), "../")) - -MASTER_PATH = os.getenv("MASTER_PATH", BASEDIR) -MODEL_PATH = "/openpilot/selfdrive/modeld/models/" - - -class MetadataOnnxPBParser(OnnxPBParser): - def _parse_ModelProto(self) -> dict: - obj = {"metadata_props": []} - for fid, wire_type in self._parse_message(self.reader.len): - match fid: - case 14: - obj["metadata_props"].append(self._parse_StringStringEntryProto()) - case _: - self.reader.skip_field(wire_type) - return obj - - -def get_checkpoint(f): - model = MetadataOnnxPBParser(f).parse() - metadata = {prop["key"]: prop["value"] for prop in model["metadata_props"]} - # "" or "...//"; combined models list vision then policy - parts = metadata['model_checkpoint'].split('/') - return parts[-2] if len(parts) > 1 else parts[0] - - -if __name__ == "__main__": - print("| | master | PR branch |") - print("|-| ----- | --------- |") - - for f in glob.glob(BASEDIR + MODEL_PATH + "/*.onnx"): - fn = os.path.basename(f) - master_path = MASTER_PATH + MODEL_PATH + fn - if os.path.exists(master_path): - master = get_checkpoint(master_path) - master_col = f"[{master}](https://reporter.comma.life/{master})" - else: - master_col = "N/A (new model)" - pr = get_checkpoint(BASEDIR + MODEL_PATH + fn) - print("|", fn, "|", master_col, "|", f"[{pr}](https://reporter.comma.life/{pr})", "|") diff --git a/site_scons/site_tools/cython.py b/site_scons/site_tools/cython.py deleted file mode 100644 index f11db1d71b..0000000000 --- a/site_scons/site_tools/cython.py +++ /dev/null @@ -1,75 +0,0 @@ -import re -import SCons -from SCons.Action import Action -from SCons.Scanner import Scanner -import numpy as np - -pyx_from_import_re = re.compile(r'^from\s+(\S+)\s+cimport', re.M) -pyx_import_re = re.compile(r'^cimport\s+(\S+)', re.M) -cdef_import_re = re.compile(r'^cdef extern from\s+.(\S+).:', re.M) - -np_version = SCons.Script.Value(np.__version__) - -def pyx_scan(node, env, path, arg=None): - contents = node.get_text_contents() - env.Depends(str(node).split('.')[0] + env['CYTHONCFILESUFFIX'], np_version) - - # from cimport ... - matches = pyx_from_import_re.findall(contents) - # cimport - matches += pyx_import_re.findall(contents) - - # Modules can be either .pxd or .pyx files - files = [m.replace('.', '/') + '.pxd' for m in matches] - files += [m.replace('.', '/') + '.pyx' for m in matches] - - # cdef extern from - files += cdef_import_re.findall(contents) - - # Handle relative imports - cur_dir = str(node.get_dir()) - files = [cur_dir + f if f.startswith('/') else f for f in files] - - # Filter out non-existing files (probably system imports) - files = [f for f in files if env.File(f).exists()] - return env.File(files) - - -pyxscanner = Scanner(function=pyx_scan, skeys=['.pyx', '.pxd'], recursive=True) -cythonAction = Action("$CYTHONCOM") - - -def create_builder(env): - try: - cython = env['BUILDERS']['Cython'] - except KeyError: - cython = SCons.Builder.Builder( - action=cythonAction, - emitter={}, - suffix=cython_suffix_emitter, - single_source=1 - ) - env.Append(SCANNERS=pyxscanner) - env['BUILDERS']['Cython'] = cython - return cython - -def cython_suffix_emitter(env, source): - return "$CYTHONCFILESUFFIX" - -def generate(env): - env["CYTHON"] = "cythonize" - env["CYTHONCOM"] = "$CYTHON $CYTHONFLAGS $SOURCE" - env["CYTHONCFILESUFFIX"] = ".cpp" - - c_file, _ = SCons.Tool.createCFileBuilders(env) - - c_file.suffix['.pyx'] = cython_suffix_emitter - c_file.add_action('.pyx', cythonAction) - - c_file.suffix['.py'] = cython_suffix_emitter - c_file.add_action('.py', cythonAction) - - create_builder(env) - -def exists(env): - return True diff --git a/system/hardware/tici/agnos.json b/system/hardware/tici/agnos.json new file mode 120000 index 0000000000..7bc8df1b8c --- /dev/null +++ b/system/hardware/tici/agnos.json @@ -0,0 +1 @@ +../../../openpilot/system/hardware/comma/agnos.json \ No newline at end of file diff --git a/teleoprtc_repo b/teleoprtc_repo index c0f813f1c4..31db236a9e 160000 --- a/teleoprtc_repo +++ b/teleoprtc_repo @@ -1 +1 @@ -Subproject commit c0f813f1c4f7e2d29bc0ed6a4d2a7b3268511b0f +Subproject commit 31db236a9ef820d7051ccd53488153cfbc84d3b9 diff --git a/tools/CTF.md b/tools/CTF.md index 2b877a0e25..ae7bea09c2 100644 --- a/tools/CTF.md +++ b/tools/CTF.md @@ -5,7 +5,7 @@ Welcome to the first part of the comma CTF! * there's 2 flags in each segment, with roughly increasing difficulty * everything you'll need to find the flags is in the openpilot repo * grep is also your friend - * first, [setup](https://github.com/commaai/openpilot/tree/master/openpilot/tools#setup-your-pc) your PC + * first, [setup](https://github.com/commaai/openpilot/tree/master/tools) your PC * read the docs & checkout out the tools in openpilot/tools/ * tip: once you get the replay and UI up, start by familiarizing yourself with seeking in replay diff --git a/tools/README.md b/tools/README.md index 1ea42bbe1d..ae36282828 100644 --- a/tools/README.md +++ b/tools/README.md @@ -45,6 +45,14 @@ Learn about the openpilot ecosystem and tools by playing our [CTF](/tools/CTF.md ## Directory Structure +``` +├── car_porting/ # Tools for porting new cars +├── release/ # Scripts for building openpilot releases +└── scripts/ # Miscellaneous scripts +``` + +Development tools such as cabana, plotjuggler, and replay live in [openpilot/tools/](/openpilot/tools/): + ``` ├── cabana/ # View and plot CAN messages from drives or in realtime ├── camerastream/ # Cameras stream over the network @@ -52,8 +60,5 @@ Learn about the openpilot ecosystem and tools by playing our [CTF](/tools/CTF.md ├── lib/ # Libraries to support the tools and reading openpilot logs ├── plotjuggler/ # A tool to plot openpilot logs ├── replay/ # Replay drives and mock openpilot services -├── scripts/ # Miscellaneous scripts -├── serial/ # Tools for using the comma serial -├── sim/ # Run openpilot in a simulator -└── webcam/ # Run openpilot on a PC with webcams +└── sim/ # Run openpilot in a simulator ``` diff --git a/tools/car_porting/README.md b/tools/car_porting/README.md index 07ca2d0eba..ef4c6ef80b 100644 --- a/tools/car_porting/README.md +++ b/tools/car_porting/README.md @@ -32,7 +32,7 @@ Finds common bugs for car interfaces, without even requiring a route. #### Example: Typo in signal name ```bash -> pytest openpilot/selfdrive/car/tests/test_car_interfaces.py -k subaru # replace with the brand you are working on +> tools/test_runner.py openpilot/selfdrive/car/tests/test_car_interfaces.py -k subaru # replace with the brand you are working on ===================================================================== FAILED openpilot/selfdrive/car/tests/test_car_interfaces.py::TestCarInterfaces::test_car_interfaces_165_SUBARU_LEGACY_7TH_GEN - KeyError: 'CruiseControlOOPS' @@ -52,9 +52,9 @@ FAIL: test_panda_safety_carstate (__main__.CarModelTestCase.test_panda_safety_ca Assert that panda safety matches openpilot's carState ---------------------------------------------------------------------- Traceback (most recent call last): - File "/home/batman/xx/openpilot/openpilot/selfdrive/car/tests/test_models.py", line 380, in test_panda_safety_carstate - self.assertFalse(len(failed_checks), f"panda safety doesn't agree with openpilot: {failed_checks}") -AssertionError: 1 is not false : panda safety doesn't agree with openpilot: {'gasPressed': 116} + File "/home/batman/openpilot/opendbc_repo/opendbc/car/tests/test_models.py", line 440, in test_panda_safety_carstate + self.assertFalse(failed_checks, f"panda safety doesn't agree with CarState: {failed_checks}") +AssertionError: {'gasPressed': 116} is not false : panda safety doesn't agree with CarState: {'gasPressed': 116} ``` ## Jupyter notebooks diff --git a/tools/car_porting/test_car_model.py b/tools/car_porting/test_car_model.py index 61ec2e15ed..3fb7ec9ae6 100755 --- a/tools/car_porting/test_car_model.py +++ b/tools/car_porting/test_car_model.py @@ -3,7 +3,7 @@ import argparse import sys import unittest from opendbc.car.tests.routes import CarTestRoute -from openpilot.selfdrive.car.tests.test_models import TestCarModel +from opendbc.car.tests.test_models import TestCarModelBase from openpilot.tools.lib.route import SegmentRange @@ -12,14 +12,14 @@ def create_test_models_suite(routes: list[CarTestRoute]) -> unittest.TestSuite: for test_route in routes: # create new test case and discover tests test_case_args = {"platform": test_route.car_model, "test_route": test_route} - CarModelTestCase = type("CarModelTestCase", (TestCarModel,), test_case_args) + CarModelTestCase = type("CarModelTestCase", (TestCarModelBase,), test_case_args) test_suite.addTest(unittest.TestLoader().loadTestsFromTestCase(CarModelTestCase)) return test_suite if __name__ == "__main__": parser = argparse.ArgumentParser(description="Test any route against common issues with a new car port. " + - "Uses openpilot/selfdrive/car/tests/test_models.py") + "Uses opendbc_repo/opendbc/car/tests/test_models.py") parser.add_argument("route_or_segment_name", help="Specify route to run tests on") parser.add_argument("--car", help="Specify car model for test route") args = parser.parse_args() diff --git a/tools/op.sh b/tools/op.sh index 1ee7b232b0..3d7d17a76b 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -19,18 +19,6 @@ RC_FILE="${HOME}/.$(basename ${SHELL})rc" if [ "$(uname)" == "Darwin" ] && [ $SHELL == "/bin/bash" ]; then RC_FILE="$HOME/.bash_profile" fi -function op_install() { - echo "Installing op system-wide..." - OP_SH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )/op.sh" - CMD=$(cat </dev/null || printf '\n%s\n' "$CMD" >> "$RC_FILE" - echo -e " ↳ [${GREEN}✔${NC}] op installed successfully. Open a new shell to use it." -} function retry() { local attempts=$1 @@ -99,7 +87,6 @@ function op_check_openpilot_dir() { echo -e " ↳ [${GREEN}✔${NC}] openpilot found." return 0 fi - echo -e " ↳ [${RED}✗${NC}] openpilot directory not found! Make sure that you are" echo " inside the openpilot directory or specify one with the" echo " --dir option!" @@ -193,6 +180,17 @@ function op_before_cmd() { } function op_setup() { + echo "Installing op system-wide..." + OP_SH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )/op.sh" + CMD=$(cat </dev/null || printf '\n%s\n' "$CMD" >> "$RC_FILE" + echo -e " ↳ [${GREEN}✔${NC}] op installed successfully. Open a new shell to use it." + op_get_openpilot_dir cd $OPENPILOT_ROOT @@ -327,7 +325,7 @@ function op_lint() { function op_test() { op_before_cmd - op_run_command pytest "$@" + op_run_command tools/test_runner.py "$@" } function op_replay() { @@ -351,6 +349,32 @@ function op_clip() { op_run_command openpilot/tools/clip/run.py "$@" } +function op_check_agnos_update() { + if [[ ! -f "/AGNOS" ]]; then + return 0 + fi + + local choice current_version target_version + current_version="$(< /VERSION)" + target_version="$(unset AGNOS_VERSION; source "$OPENPILOT_ROOT/launch_env.sh"; echo "$AGNOS_VERSION")" + + if [[ "$current_version" == "$target_version" ]]; then + return 0 + fi + + echo -e "${BOLD}AGNOS update available:${NC} $current_version → $target_version" + if read -r -p "Install it now? [y/N] " choice && [[ "$choice" =~ ^[Yy]$ ]]; then + op_run_command "$OPENPILOT_ROOT/openpilot/common/hardware/comma/agnos.py" --swap \ + "$OPENPILOT_ROOT/openpilot/common/hardware/comma/agnos.json" + + if read -r -p "Reboot now to apply the update? [y/N] " choice && [[ "$choice" =~ ^[Yy]$ ]]; then + op_run_command sudo reboot + else + echo "Reboot before starting openpilot to apply the AGNOS update." + fi + fi +} + function op_switch() { REMOTE="origin" if [ "$#" -gt 1 ]; then @@ -378,11 +402,14 @@ function op_switch() { # remove openpilot update flag if present rm -f .overlay_init + + op_check_agnos_update } function op_start() { if [[ -f "/AGNOS" ]]; then op_before_cmd + op_check_agnos_update op_run_command sudo systemctl restart comma $@ fi } @@ -409,9 +436,8 @@ function op_default() { echo -e " ${BOLD}check${NC} Check the development environment (git, os) to start using openpilot" echo -e " ${BOLD}esim${NC} Manage eSIM profiles on your comma device" echo -e " ${BOLD}venv${NC} Activate the python virtual environment" - echo -e " ${BOLD}setup${NC} Install openpilot dependencies" + echo -e " ${BOLD}setup${NC} Install the 'op' tool and openpilot dependencies" echo -e " ${BOLD}build${NC} Run the openpilot build system in the current working directory" - echo -e " ${BOLD}install${NC} Install the 'op' tool system wide" echo -e " ${BOLD}switch${NC} Switch to a different git branch with a clean slate (nukes any changes)" echo -e " ${BOLD}start${NC} Starts (or restarts) openpilot" echo -e " ${BOLD}stop${NC} Stops openpilot" @@ -431,7 +457,7 @@ function op_default() { echo -e " ${BOLD}sim${NC} Run openpilot in a simulator" echo -e " ${BOLD}lint${NC} Run the linter" echo -e " ${BOLD}post-commit${NC} Install the linter as a post-commit hook" - echo -e " ${BOLD}test${NC} Run all unit tests from pytest" + echo -e " ${BOLD}test${NC} Run all unit tests" echo "" echo -e "${BOLD}${UNDERLINE}Options:${NC}" echo -e " ${BOLD}-d, --dir${NC}" @@ -477,7 +503,6 @@ function _op() { replay ) shift 1; op_replay "$@" ;; clip ) shift 1; op_clip "$@" ;; sim ) shift 1; op_sim "$@" ;; - install ) shift 1; op_install "$@" ;; switch ) shift 1; op_switch "$@" ;; start ) shift 1; op_start "$@" ;; stop ) shift 1; op_stop "$@" ;; diff --git a/tools/release/build_release.sh b/tools/release/build_release.sh index 6a019a80d0..3711cd582c 100755 --- a/tools/release/build_release.sh +++ b/tools/release/build_release.sh @@ -46,8 +46,9 @@ echo "[-] committing version $VERSION T=$SECONDS" git add -f . git commit -a -m "openpilot v$VERSION release" -# Build -export PYTHONPATH="$BUILD_DIR" +# Build and test before launch_chffrplus.sh creates the on-device package +# symlinks. SConstruct uses the same package roots for build subprocesses. +export PYTHONPATH="$BUILD_DIR:$BUILD_DIR/msgq_repo:$BUILD_DIR/opendbc_repo:$BUILD_DIR/rednose_repo:$BUILD_DIR/teleoprtc_repo:$BUILD_DIR/tinygrad_repo" scons if [ -z "$PANDA_DEBUG_BUILD" ]; then @@ -93,8 +94,8 @@ git commit --amend -m "openpilot v$VERSION" # Run tests cd $BUILD_DIR -RELEASE=1 pytest -n0 -s openpilot/selfdrive/test/test_onroad.py -#pytest openpilot/selfdrive/car/tests/test_car_interfaces.py +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" REFS=() diff --git a/tools/release/release_files.py b/tools/release/release_files.py index 7f08719ea8..54e92f5ff3 100755 --- a/tools/release/release_files.py +++ b/tools/release/release_files.py @@ -12,6 +12,9 @@ blacklist = [ "matlab.*.md", + # skip big model for now + "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx", + # no LFS or submodules in release ".lfsconfig", ".gitattributes", diff --git a/tools/scripts/car/max_lat_accel.py b/tools/scripts/car/max_lat_accel.py index dc44e8ac40..b76b3302cc 100755 --- a/tools/scripts/car/max_lat_accel.py +++ b/tools/scripts/car/max_lat_accel.py @@ -62,8 +62,8 @@ def find_events(lr: LogReader, extrapolate: bool = False, qlog: bool = False) -> elif msg.which() == 'controlsState': curvature = msg.controlsState.curvature - elif msg.which() == 'liveParameters': - roll = msg.liveParameters.roll + elif msg.which() == 'vehicleParameters': + roll = msg.vehicleParameters.roll if lat_active > min_lat_active and steering_unpressed > min_steering_unpressed and requesting_max > min_requesting_max: # TODO: record max lat accel at the end of the event, need to use the past lat accel as overriding can happen before we detect it diff --git a/tools/scripts/cycle_alerts.py b/tools/scripts/cycle_alerts.py index 0bd42bcd3f..f78dfe1090 100755 --- a/tools/scripts/cycle_alerts.py +++ b/tools/scripts/cycle_alerts.py @@ -50,12 +50,12 @@ def cycle_alerts(duration=200, is_metric=False): (EventName.cameraFrameRate, ET.PERMANENT), ] - cameras = ['roadCameraState', 'wideRoadCameraState', 'driverCameraState'] + cameras = ['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState'] CS = car.CarState.new_message() CP = CarInterface.get_non_essential_params("HONDA_CIVIC") - sm = messaging.SubMaster(['deviceState', 'pandaStates', 'roadCameraState', 'modelV2', 'liveCalibration', - 'driverMonitoringState', 'longitudinalPlan', 'livePose', + sm = messaging.SubMaster(['deviceState', 'pandaStates', 'narrowRoadCameraState', 'modelV2', 'extrinsicsCalibration', + 'driverMonitoringState', 'longitudinalPlan', 'deviceMotion', 'managerState'] + cameras) pm = messaging.PubMaster(['selfdriveState', 'pandaStates', 'deviceState']) @@ -87,7 +87,7 @@ def cycle_alerts(duration=200, is_metric=False): procs[i].shouldBeRunning = True sm['managerState'].processes = procs - sm['liveCalibration'].rpyCalib = [-1 * random.random() for _ in range(random.randint(0, 3))] + sm['extrinsicsCalibration'].rpyCalib = [-1 * random.random() for _ in range(random.randint(0, 3))] for s in sm.data.keys(): prob = 0.3 if s in cameras else 0.08 diff --git a/tools/scripts/profiling/clpeak/no_print.patch b/tools/scripts/profiling/clpeak/no_print.patch deleted file mode 100644 index 44a5efd10f..0000000000 --- a/tools/scripts/profiling/clpeak/no_print.patch +++ /dev/null @@ -1,39 +0,0 @@ -diff --git a/src/logger.cpp b/src/logger.cpp -index a63c6dd..a1d9860 100644 ---- a/src/logger.cpp -+++ b/src/logger.cpp -@@ -24,34 +24,22 @@ logger::~logger() - - void logger::print(string str) - { -- cout << str; -- cout.flush(); - } - - void logger::print(double val) - { -- cout << setprecision(2) << fixed; -- cout << val; -- cout.flush(); - } - - void logger::print(float val) - { -- cout << setprecision(2) << fixed; -- cout << val; -- cout.flush(); - } - - void logger::print(int val) - { -- cout << val; -- cout.flush(); - } - - void logger::print(unsigned int val) - { -- cout << val; -- cout.flush(); - } - - void logger::xmlOpenTag(string tag) diff --git a/tools/scripts/ssh.py b/tools/scripts/ssh.py index 86e86c7eed..2d8ff46604 100755 --- a/tools/scripts/ssh.py +++ b/tools/scripts/ssh.py @@ -14,7 +14,7 @@ if __name__ == "__main__": parser.add_argument("device", help="device name or dongle id") parser.add_argument("--host", help="ssh jump server host", default="ssh.comma.ai") parser.add_argument("--port", help="ssh jump server port", default=22, type=int) - parser.add_argument("--key", help="ssh key", default=os.path.join(BASEDIR, "openpilot/common/hardware/tici/id_rsa")) + parser.add_argument("--key", help="ssh key", default=os.path.join(BASEDIR, "openpilot/common/hardware/comma/id_rsa")) parser.add_argument("--debug", help="enable debug output", action="store_true") args = parser.parse_args() diff --git a/tools/scripts/test_fw_query_on_routes.py b/tools/scripts/test_fw_query_on_routes.py index ab539e4feb..33e1aa39ba 100755 --- a/tools/scripts/test_fw_query_on_routes.py +++ b/tools/scripts/test_fw_query_on_routes.py @@ -12,7 +12,6 @@ from openpilot.tools.lib.logreader import LogReader, ReadMode from openpilot.tools.lib.route import SegmentRange -NO_API = "NO_API" in os.environ SUPPORTED_BRANDS = VERSIONS.keys() SUPPORTED_CARS = [brand for brand in SUPPORTED_BRANDS for brand in interface_names[brand]] UNKNOWN_BRAND = "unknown" @@ -178,4 +177,3 @@ if __name__ == "__main__": print(f"Correct fuzzy matches: {good_fuzzy}") print(f"Wrong fuzzy matches: {wrong_fuzzy}") print() - diff --git a/tools/setup.sh b/tools/setup.sh index dafd466ef9..ced451ab11 100755 --- a/tools/setup.sh +++ b/tools/setup.sh @@ -33,6 +33,13 @@ cat << 'EOF' EOF } +function check_platform() { + if [[ -f /AGNOS ]]; then + echo -e "[${RED}✗${NC}] This installer is for PCs only. The environment is pre-configured in AGNOS." + return 1 + fi +} + function check_stdin() { if [ -t 0 ]; then INTERACTIVE=1 @@ -121,7 +128,6 @@ function git_clone() { function install_with_op() { cd $OPENPILOT_ROOT - $OPENPILOT_ROOT/tools/op.sh install $OPENPILOT_ROOT/tools/op.sh post-commit if ! $OPENPILOT_ROOT/tools/op.sh setup; then @@ -136,6 +142,7 @@ function install_with_op() { } show_motd +check_platform check_stdin ask_dir check_dir diff --git a/tools/setup_dependencies.sh b/tools/setup_dependencies.sh index b76fdc7dde..5ad833a5ca 100755 --- a/tools/setup_dependencies.sh +++ b/tools/setup_dependencies.sh @@ -38,7 +38,11 @@ function install_linux_deps() { fi done - # normal stuff, this mostly for bare docker images + # ------------------------------------------------ + # dependencies should never be added to this list. + # these are only for inflating bare docker images + # to their desktop equivalents. + # ------------------------------------------------ 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" diff --git a/tools/test_runner.py b/tools/test_runner.py new file mode 100755 index 0000000000..1979ce01e4 --- /dev/null +++ b/tools/test_runner.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +import argparse +from collections import Counter +from concurrent.futures import as_completed, ProcessPoolExecutor +from itertools import batched +import math +import os +from pathlib import Path +import sys +import tempfile +import time +import traceback +import unittest +import warnings + +ROOT = Path(__file__).resolve().parents[1] +IGNORED = ( + ROOT / "openpilot/selfdrive/test/process_replay/test_processes.py", + ROOT / "openpilot/tools/sim", +) +FAILURES = {"failed", "error", "xpassed"} +STATUS_MARKS = { + "passed": (".", 32), + "skipped": ("s", 33), + "xfailed": ("x", 36), + "failed": ("F", 31), + "error": ("E", 31), + "xpassed": ("X", 31), +} + + +def paint(text, code): + if sys.stdout.isatty() and "NO_COLOR" not in os.environ: + return f"\033[{code}m{text}\033[0m" + return text + + +class Capture: + def __init__(self, enabled): + self.enabled = enabled + + def start(self): + if not self.enabled: + return + for stream in (sys.stdout, sys.stderr): + stream.flush() + self.files = [tempfile.TemporaryFile() for _ in range(2)] + self.saved = [os.dup(fd) for fd in (1, 2)] + for fd, file in enumerate(self.files, 1): + os.dup2(file.fileno(), fd) + + def stop(self, keep=True): + if not self.enabled: + return "", "" + for stream in (sys.stdout, sys.stderr): + stream.flush() + for fd, saved in enumerate(self.saved, 1): + os.dup2(saved, fd) + os.close(saved) + output = [] + for file in self.files: + if keep: + file.seek(0) + output.append(file.read().decode(errors="replace")) + file.close() + return output if keep else ("", "") + + +def make_record(test_id, status="passed", detail=""): + return {"id": test_id, "status": status, "detail": detail, "time": 0.0, "stdout": "", "stderr": ""} + + +class Result(unittest.TestResult): + def __init__(self, capture_output): + super().__init__() + self.records = [] + self.current = None + self.capture = Capture(capture_output) + + def startTest(self, test): + self.current = make_record(test.id()) + self.started = time.monotonic() + self.capture.start() + + def stopTest(self, test): + self.current["time"] = time.monotonic() - self.started + keep = self.current["status"] in FAILURES + stdout, stderr = self.capture.stop(keep) + if keep: + self.current["stdout"] = stdout + self.current["stderr"] = stderr + self.records.append(self.current) + self.current = None + + def mark(self, test, status, detail=""): + if self.current is None: # setUpClass/setUpModule can fail before a test starts + self.records.append(make_record(test.id(), status, detail)) + return + if self.current["status"] not in FAILURES or status == "error": + self.current["status"] = status + if detail: + self.current["detail"] += ("\n\n" if self.current["detail"] else "") + detail + + def addFailure(self, test, err): + self.mark(test, "failed", self._exc_info_to_string(err, test)) + + def addError(self, test, err): + self.mark(test, "error", self._exc_info_to_string(err, test)) + + def addSkip(self, test, reason): + self.mark(test, "skipped") + + def addExpectedFailure(self, test, err): + self.mark(test, "xfailed") + + def addUnexpectedSuccess(self, test): + self.mark(test, "xpassed", "Test was expected to fail, but passed.") + + def addSubTest(self, test, subtest, err): + if err: + status = "failed" if issubclass(err[0], test.failureException) else "error" + self.mark(test, status, f"{subtest}\n{self._exc_info_to_string(err, test)}") + + +def flatten(suite): + for test in suite: + if isinstance(test, unittest.TestSuite): + yield from flatten(test) + else: + yield test + + +def module_name(path): + return ".".join(path.resolve().relative_to(ROOT).with_suffix("").parts) + + +def collect(targets, keyword): + use_ignores = not targets + targets = targets or ["openpilot"] + loader = unittest.TestLoader() + tests = [] + errors = [] + names = [] + for target in targets: + path_text, *nodes = target.split("::") + path = Path(path_text) + try: + if path.is_dir(): + files = sorted(path.rglob("test_*.py")) + if use_ignores: + files = [f for f in files if not any(f.resolve().is_relative_to(i) for i in IGNORED)] + names.extend(module_name(file) for file in files) + elif path.is_file(): + names.append(".".join((module_name(path), *nodes))) + elif "/" in path_text or path_text.endswith(".py"): + errors.append(f"{target}: file or directory not found") + else: + names.append(target.replace("::", ".")) + except (OSError, ValueError) as e: + errors.append(str(e)) + for name in dict.fromkeys(names): + before = len(loader.errors) + try: + suite = loader.loadTestsFromName(name) + except Exception: + errors.append(f"Failed to collect {name}\n{traceback.format_exc()}") + continue + errors.extend(loader.errors[before:]) + for test in flatten(suite): + cls = type(test) + if cls.__name__ == "_FailedTest": + continue + if getattr(cls, "__unittest_skip_why__", "") == "parameterized base class": + continue + if not keyword or keyword.lower() in test.id().lower(): + tests.append(test) + return list({test.id(): test for test in tests}.values()), errors + + +def make_batches(tests, workers): + fixture_groups = {} + parallel = [] + for test in tests: + cls = type(test) + module = sys.modules[cls.__module__] + if hasattr(module, "setUpModule") or hasattr(module, "tearDownModule"): + key = cls.__module__ + elif "setUpClass" in cls.__dict__ or "tearDownClass" in cls.__dict__: + key = f"{cls.__module__}.{cls.__qualname__}" + else: + parallel.append(test.id()) + continue + fixture_groups.setdefault(key, []).append(test.id()) + size = max(1, math.ceil(len(tests) / (workers * 4))) + batches = list(fixture_groups.values()) + batches.extend(list(batch) for batch in batched(parallel, size)) + return sorted(batches, key=len, reverse=True) + + +def run_batch(test_ids, capture_output): + result = Result(capture_output) + outside = Capture(capture_output) + os.chdir(ROOT) + outside.start() + try: + unittest.TestLoader().loadTestsFromNames(test_ids).run(result) + finally: + stdout, stderr = outside.stop() + failures = [item for item in result.records if item["status"] in FAILURES] + if failures: # attach class/module fixture output to the first related failure + failures[0]["stdout"] = stdout + failures[0]["stdout"] + failures[0]["stderr"] = stderr + failures[0]["stderr"] + return result.records + + +def run_parallel(batches, workers, warning_action, capture_output): + with ProcessPoolExecutor(max_workers=workers, initializer=warnings.simplefilter, initargs=(warning_action,)) as pool: + futures = {pool.submit(run_batch, batch, capture_output): batch for batch in batches} + for future in as_completed(futures): + try: + yield future.result() + except Exception: + yield [make_record(futures[future][0], "error", traceback.format_exc())] + + +def report(records, errors, duration_count, elapsed): + width = min(100, os.get_terminal_size().columns if sys.stdout.isatty() else 80) + for index, error in enumerate(errors, 1): + print(paint(f"\n{'=' * 8} COLLECTION ERROR {index} {'=' * 8}", 31)) + print(error.rstrip()) + for item in sorted((r for r in records if r["status"] in FAILURES), key=lambda r: r["id"]): + heading = f" {item['status'].upper()} {item['id']} " + print(paint(f"\n{heading:=^{width}}", 31)) + if item["detail"]: + print(item["detail"].rstrip()) + for stream in ("stdout", "stderr"): + if item[stream]: + print(paint(f"\n--- captured {stream} ---", 33)) + print(item[stream].rstrip()) + timed = sorted((r for r in records if r["time"]), key=lambda r: r["time"], reverse=True) + if duration_count: + timed = timed[:duration_count] + if timed: + print(paint("\nslowest tests", 36)) + for item in timed: + print(f"{item['time']:8.2f}s {item['id']}") + counts = Counter(item["status"] for item in records) + parts = [f"{counts[name]} {name}" for name in STATUS_MARKS if counts[name]] + if errors: + parts.append(f"{len(errors)} collection error{'s' if len(errors) != 1 else ''}") + failed = bool(errors) or any(counts[name] for name in FAILURES) + print(paint(f"\n{', '.join(parts) or 'no tests ran'} in {elapsed:.2f}s", 31 if failed else 32)) + if failed: + return 1 + if records: + return 0 + return 5 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("targets", nargs="*", help="files, directories, dotted IDs, or path.py::Class::test") + parser.add_argument("-j", "--jobs", type=int, default=os.cpu_count() or 1, help="workers (default: available CPUs)") + parser.add_argument("-k", metavar="TEXT", help="only run test IDs containing TEXT") + parser.add_argument("-s", "--no-capture", action="store_true", help="show test output live") + parser.add_argument("-v", "--verbose", action="store_true", help="show every test") + parser.add_argument("--durations", type=int, default=10, metavar="N", help="show N slowest tests; 0 shows all") + parser.add_argument("-W", "--warnings", choices=("error", "default", "always", "ignore"), default="error") + args = parser.parse_args() + + capture_output = not args.no_capture + os.chdir(ROOT) + warnings.simplefilter(args.warnings) + started = time.monotonic() + tests, errors = collect(args.targets, args.k) + batches = make_batches(tests, args.jobs) + workers = min(args.jobs, len(batches)) + summary = f"collected {len(tests)} test{'s' if len(tests) != 1 else ''} in {time.monotonic() - started:.2f}s " + summary += f"• {workers} worker{'s' if workers != 1 else ''}" + print(summary) + records = [] + column = 0 + try: + if workers < 2: + streams = (run_batch(batch, capture_output) for batch in batches) + else: + streams = run_parallel(batches, workers, args.warnings, capture_output) + for batch in streams: + records.extend(batch) + for item in batch: + mark, code = STATUS_MARKS[item["status"]] + if args.verbose: + print(f"{paint(mark, code)} {item['id']} {item['time']:.2f}s") + else: + print(paint(mark, code), end="", flush=True) + column += 1 + if column == 80: + print() + column = 0 + except KeyboardInterrupt: + print(paint("\ninterrupted", 31)) + return 2 + if column: + print() + return report(records, errors, args.durations, time.monotonic() - started) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index b88ba23d7c..a86cd55d30 100644 --- a/uv.lock +++ b/uv.lock @@ -18,70 +18,58 @@ 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" }, ] -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, -] - [[package]] name = "certifi" -version = "2026.6.17" +version = "2026.7.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] name = "cffi" -version = "2.0.0" +version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.7" +version = "3.4.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] [[package]] @@ -98,11 +86,11 @@ wheels = [ [[package]] name = "codespell" -version = "2.4.2" +version = "2.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/19/45e941380f69c042b43423513d201e6592346f992394347f5e7174c31407/codespell-2.4.3.tar.gz", hash = "sha256:cbe085e331227b37bb86ef8bddd08dc768c704ee9a07ca869852c093fa2793e2", size = 352773, upload-time = "2026-07-15T11:51:54.159Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" }, + { url = "https://files.pythonhosted.org/packages/8b/bf/bdb951d34eb169140b546f44be9ec4525d1acefb9eb5572071f5492b19fc/codespell-2.4.3-py3-none-any.whl", hash = "sha256:af2505b335e8573dbd2d384d1c4ef498f4006f4ba2d6fceca01e55b91f52628a", size = 340736, upload-time = "2026-07-15T11:51:52.925Z" }, ] [[package]] @@ -116,164 +104,146 @@ wheels = [ [[package]] name = "comma-deps-acados" -version = "0.2.2.post95" +version = "0.2.2.post98" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/58/83/e76d3f89de07a672c5fc452d81b6f00f972721342eb75de2171d2c3a8b19/comma_deps_acados-0.2.2.post95-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fd7c583ac2a33b414540c0601173e3a9c28c5d8f825cb24736c64e3c07271f56", size = 10631724, upload-time = "2026-06-24T23:58:31.726Z" }, - { url = "https://files.pythonhosted.org/packages/64/96/4b8e50a153dcb5f34628f854dc58774588b8dbfb26b6bedd0b99acb4aa71/comma_deps_acados-0.2.2.post95-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:1f47c3a665193937c993d3ee15989be5ba75eb427c9733fe1b9e800bf33c56aa", size = 11663713, upload-time = "2026-06-24T23:58:33.725Z" }, - { url = "https://files.pythonhosted.org/packages/1b/2f/bf57b9656e86950ba19c7a88992fccc0abb89878f9c30fb1b2252d57f0a7/comma_deps_acados-0.2.2.post95-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:00b49d5b691fa97d07fa5e5b842e0d2e1a6faa6856d2a081eefcee649f93329b", size = 13167209, upload-time = "2026-06-24T23:58:35.941Z" }, + { 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" }, ] [[package]] name = "comma-deps-bootstrap-icons" -version = "1.10.5.0.post95" +version = "1.10.5.0.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/0b/bb713dd4bed94b0b2b21657b7337e264953bd785a2b2f5c1b0706cdcde29/comma_deps_bootstrap_icons-1.10.5.0.post95-py3-none-any.whl", hash = "sha256:d59fc8d3e642e00f83d7a4854164f1dee21c3d17c726d5537b1b72b149f5788b", size = 386001, upload-time = "2026-06-24T23:58:37.84Z" }, -] - -[[package]] -name = "comma-deps-bzip2" -version = "1.0.8.post95" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/ac/539d76e9bcebbc53d62274a19c47522b118dce3261fc6baea045f93c98b6/comma_deps_bzip2-1.0.8.post95-py3-none-macosx_11_0_arm64.whl", hash = "sha256:be466743b7fc56fa18c2c389a46bdeb0fc885cf3cf39b779d748ce540609ebd5", size = 42832, upload-time = "2026-06-24T23:58:39.592Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fe/5206bd6d716022cea259c4878c6a1927ab3555f4caa48a74efaa83d8f418/comma_deps_bzip2-1.0.8.post95-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7c015d720cc5462f87062b4482c54684de6a237b4edb60612c2f58721413682f", size = 38629, upload-time = "2026-06-24T23:58:40.496Z" }, - { url = "https://files.pythonhosted.org/packages/39/0e/78218f9a645ad9d27000153795d6819b3b52ca62d882aa46a413e40887be/comma_deps_bzip2-1.0.8.post95-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3188a197aaf3efbfac975193887c59600b91a3b4eb7f62cc975377133cd48834", size = 36271, upload-time = "2026-06-24T23:58:41.2Z" }, + { 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" }, ] [[package]] name = "comma-deps-capnproto" -version = "1.0.1.post93" +version = "1.0.1.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/a6/97c22a112e23f530db28f8ecf7b191ec16685d282fdbe892dff7437bdf6d/comma_deps_capnproto-1.0.1.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6717e8e34ef12116502f244b23f092984c1d15afa4e40b956450b74a0d4d2520", size = 2407330, upload-time = "2026-07-08T19:30:41.395Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f2/79a15ff5f2c97a741923a97357f6a4235e4ced928ba1d4079d01c66df4b0/comma_deps_capnproto-1.0.1.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a70349446e2ec17b281ebaaae02ca4c35495f4210043c716c55139a05a826090", size = 2506341, upload-time = "2026-07-08T19:30:45.607Z" }, - { url = "https://files.pythonhosted.org/packages/80/a9/f61fe62045c4ea867f51f2d74b4edfabd6c272c62a3281a9f1f33825a725/comma_deps_capnproto-1.0.1.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f98cdba8f8c7f08a7a0c0a4b7cc0bfcf515e29ad8f1b5cb8eda4e253bef5e6e3", size = 2590769, upload-time = "2026-07-08T19:30:49.728Z" }, -] - -[[package]] -name = "comma-deps-catch2" -version = "2.13.10.post93" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/ee/b4ef7758d04a024775d49558ca930fec19aa37cd691ad9182b7141f4d4d7/comma_deps_catch2-2.13.10.post93-py3-none-any.whl", hash = "sha256:8f23293251b5db48c08885d8816ca252b3fb11b0c1c26775bae36e8b217e865e", size = 137085, upload-time = "2026-07-08T19:30:53.44Z" }, + { 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" }, ] [[package]] name = "comma-deps-eigen" -version = "3.4.0.post93" +version = "3.4.0.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/3c/b262d6103a3b7a5cb0296b821eb4f1385b349e527bba75d7efe057cea802/comma_deps_eigen-3.4.0.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f90433cea3f59f1b9ed58bd92d0f1b55721eff8b473d579ed40fa2fcdc2c1787", size = 2275895, upload-time = "2026-07-08T19:31:08.983Z" }, - { url = "https://files.pythonhosted.org/packages/00/a1/b9bf332a096267ff26ebd546b104d9a92b905857c59bae899794897c790b/comma_deps_eigen-3.4.0.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:3169d82b825ca8accdae5f3e1a9cddd9f28f8bd5f66e4353fedae68b23f13cdb", size = 2275897, upload-time = "2026-07-08T19:31:12.66Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c6/1132467acd1257ea159cf4608c6f00ef0b3ca353fd86aaced0d76dcce62c/comma_deps_eigen-3.4.0.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:d0b042c817070a0124b8c0e78b87ff5e8b44db21a1abea740afe7115f5bd8447", size = 2275900, upload-time = "2026-07-08T19:31:16.352Z" }, + { 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" }, ] [[package]] name = "comma-deps-ffmpeg" -version = "7.1.0.post94" +version = "7.1.0.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/5c/47e10049fd96b581e8ddc9fba31c3397562732fcc99db6c062690e862300/comma_deps_ffmpeg-7.1.0.post94-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e87b4dd0fb0fc25d6992167ee266b5c9e92305aefa3d343ddd22a17219363b8", size = 7329953, upload-time = "2026-07-09T03:07:11.516Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d5/c4edfbbe488983ff45fb6cacab78342fc56cc0f28010a946e8697613ffe2/comma_deps_ffmpeg-7.1.0.post94-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7ea34368c5b564cecab0fd77d6423bc15d8f100051648f64ad79a3499f43dafb", size = 4441314, upload-time = "2026-07-09T03:07:15.679Z" }, - { url = "https://files.pythonhosted.org/packages/80/8f/76a876a26572c52d52dbcf8ed39227f5023d343201a32af4b992c4b9f13e/comma_deps_ffmpeg-7.1.0.post94-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:5b2d6cfbba59ecd673dd0c8bd8eb5a5b3472bdfa025e1063c011e6234877fe84", size = 4685134, upload-time = "2026-07-09T03:07:19.596Z" }, + { 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" }, ] [[package]] name = "comma-deps-gcc-arm-none-eabi" -version = "13.2.1.post93" +version = "13.2.1.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/9f/9a6c7f04faf18fdc4ab3e5b55ecfdbcb3998333a450fd5a1c986af571d15/comma_deps_gcc_arm_none_eabi-13.2.1.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:00de06a94ecf4379c79b150d36b3116d0f37bc341bbf1ef2ad850c8a5985eeb1", size = 15238809, upload-time = "2026-07-08T19:31:33.509Z" }, - { url = "https://files.pythonhosted.org/packages/83/94/1cdb11c170b96f2677dfd1be7cfb9a2994d3518555c154f2a131929057da/comma_deps_gcc_arm_none_eabi-13.2.1.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:de1cf3ff89ea84fac5df62c65d843af877eaa2634439d793f527f0cfeea66e59", size = 17367238, upload-time = "2026-07-08T19:31:38.137Z" }, - { url = "https://files.pythonhosted.org/packages/23/3c/4b2f60274f080b9583da2fdaad54f692f4f8719bd360558d4460d79f8fb4/comma_deps_gcc_arm_none_eabi-13.2.1.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3cf7f022ffa1b3d3e20eb32610c7e59f84932e516d9ed11236df787546d82d67", size = 16941134, upload-time = "2026-07-08T19:31:43.102Z" }, + { 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" }, ] [[package]] name = "comma-deps-git-lfs" -version = "3.6.1.post93" +version = "3.6.1.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/62/e06ea6fb98b8cee2686fb409fb28426d902f3318b077cbd78d9bbec0354b/comma_deps_git_lfs-3.6.1.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89cbffd3d6800fa8e366301b98c09c4ccbc973fb59721aa2546b36158b9b64c0", size = 4685107, upload-time = "2026-07-08T19:31:47.273Z" }, - { url = "https://files.pythonhosted.org/packages/ce/cf/f08dc359e8bc7e1b21b7f4a93167a14ac61dc78f1bfb27a0e7554c78cb09/comma_deps_git_lfs-3.6.1.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:39844960733a3885d99bbfdf4f7b414e7d0bf4b4d56f9d77a88ea10d72f549c5", size = 4485276, upload-time = "2026-07-08T19:31:51.253Z" }, - { url = "https://files.pythonhosted.org/packages/2f/af/3dfae2a56320165b45ceab0d7a2c6dea69cc5766928f408d17997ff2a525/comma_deps_git_lfs-3.6.1.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:e445cbadec41856997de775d5d415d1e707db0ba563c705df1b55ce198179bbe", size = 4889583, upload-time = "2026-07-08T19:31:55.153Z" }, + { 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" }, ] [[package]] name = "comma-deps-imgui" -version = "1.92.7.post93" +version = "1.92.7.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/e8/a5f99714d4b2ef85744bd6af9ee46b016a70cea8383c9cca3097847c6940/comma_deps_imgui-1.92.7.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ae7cbb655cd61157b785e7f388cc1bfe17bf6a2bf8c40ff9484702bd9be71074", size = 1688022, upload-time = "2026-07-08T19:31:58.849Z" }, - { url = "https://files.pythonhosted.org/packages/86/f1/0ebcbb7d55ef1ad0f516831c52f37cafdf0ed94b73e0396c531ecd89dbc1/comma_deps_imgui-1.92.7.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:efc689e8279900c10e3922e9bf188fe34e16ab28d8660878e82b7192d7d86c68", size = 2522798, upload-time = "2026-07-08T19:32:02.435Z" }, - { url = "https://files.pythonhosted.org/packages/c3/94/6f0bd31d599f5748736b57e0b71a42d15ec0782906155f268bc7056fd0ac/comma_deps_imgui-1.92.7.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:b08a763c00a1f037dc5e2e2e0322c46960fe8a2cc972ce17781d547ccce3eaa6", size = 2655457, upload-time = "2026-07-08T19:32:06.273Z" }, + { 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" }, ] [[package]] name = "comma-deps-json11" -version = "20170411.0.post93" +version = "20170411.0.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/3e/f2bb5d0e0d9e63535007c64859b2ab5e17164ca1f4cfa279fd228dbcef80/comma_deps_json11-20170411.0.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0088d66fd76ea27712d09f2ffa783596f7d70b5b152a039c36d65befcebe3863", size = 34033, upload-time = "2026-07-08T19:32:10.175Z" }, - { url = "https://files.pythonhosted.org/packages/b8/6e/9576bb1ba5d8371f32664268401fa61231ca1092aa5da45141c37984d868/comma_deps_json11-20170411.0.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:659c18d4b2634bf7d2388c6e855c6f083f384d2a70052945c1c9ddabbb0dcb21", size = 41846, upload-time = "2026-07-08T19:32:13.457Z" }, - { url = "https://files.pythonhosted.org/packages/cc/34/6d673c311c9a23e65a0db56352878b53b575aca0539e57c71121d2fe544b/comma_deps_json11-20170411.0.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4c24a9c9db71b38192ec9c515f13fd14b4495b8080bb1f687b84b193613d95be", size = 42603, upload-time = "2026-07-08T19:32:16.601Z" }, + { 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" }, ] [[package]] name = "comma-deps-libusb" -version = "1.0.29.post93" +version = "1.0.29.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/a3/4fb912d8af7af134a0a54eeb893beecc9e1cb3ed8371e055319faa74a580/comma_deps_libusb-1.0.29.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1dba5fe7832877994ea9ff3daf056851f1b5c9a47552525af08b35074c5ff65e", size = 102339, upload-time = "2026-07-08T19:32:19.785Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ad/6d36327352fa1030997409ee650de3f307cc26e3fec4f2a8ea6254430e6a/comma_deps_libusb-1.0.29.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c2a29c888668cb4c0ab549ac71206d6b53da8d2db5e581a55a113574aaabe950", size = 94439, upload-time = "2026-07-08T19:32:23.104Z" }, - { url = "https://files.pythonhosted.org/packages/84/47/f4f2da67db202bacc3fa578fdebdb916f45c97bee0c3d2c5fbe845a6f129/comma_deps_libusb-1.0.29.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f136dbbfc461d228378731361cc66a90a483e17d55f8ba43f23eeab6bcf41963", size = 93461, upload-time = "2026-07-08T19:32:26.434Z" }, + { 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" }, ] [[package]] name = "comma-deps-ncurses" -version = "6.5.post93" +version = "6.5.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/3d/f85c036037e9d72c8cc444a1613ae8ca6967c0997148fbc8c03b8cdeb21c/comma_deps_ncurses-6.5.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0fbc94bdb42577389ec9e475deb7425f027b4e6a097b7b326ee0ee3ecb4c26ff", size = 264892, upload-time = "2026-07-08T22:08:58.325Z" }, - { url = "https://files.pythonhosted.org/packages/f4/da/13881cefe9ca963665a57f066683fd9995c15349839f0268686741942cd7/comma_deps_ncurses-6.5.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a8786c9e7693fbe235044ed068292ddae1e8c25e9cc3ffba9364c233b6df9f9c", size = 260837, upload-time = "2026-07-08T22:09:02.514Z" }, - { url = "https://files.pythonhosted.org/packages/11/98/6a278e4436ca901c084ae111dc23f064e3eb5f64481dc970111aff8a91fd/comma_deps_ncurses-6.5.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:1de1011e71833d46d545a0d64150316f980385d13731eb138f9fc53642942c55", size = 248341, upload-time = "2026-07-08T22:09:06.591Z" }, + { 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" }, ] [[package]] name = "comma-deps-raylib" -version = "6.0.0.1.post93" +version = "6.0.0.1.post98" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/5a/1abdc781fc242e0db517e22c53fa676fc81b76344c5d6c86677bdd5d6a9a/comma_deps_raylib-6.0.0.1.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ef4dbc7036e4449f9ee029cec077c23f64975b55b60cce0be818b70640c1732a", size = 2004199, upload-time = "2026-07-08T22:09:10.731Z" }, - { url = "https://files.pythonhosted.org/packages/79/90/919fd98aa72c8c1d31e657abc0bc28b3933d7408f4dc8ccbb7272ef638a9/comma_deps_raylib-6.0.0.1.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a64f2a77ad55b05350ed4301f3a1a814176810d1d408c77bdbaa1bd5a3ab5135", size = 7203010, upload-time = "2026-07-08T22:09:15.323Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b6/419b387b132efe43ace20dced9852448f36195d71d1305fccfd5e0617083/comma_deps_raylib-6.0.0.1.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:11bf076441b636434bc76a00b2e349c22a825e0b83416a4fb3dd4b8217638f8a", size = 5055450, upload-time = "2026-07-08T22:09:19.844Z" }, + { url = "https://files.pythonhosted.org/packages/9c/21/59509b2758e3612c63336df5534ea3b36e472e06c18a50f0b96c32e8e3e0/comma_deps_raylib-6.0.0.1.post98-py3-none-macosx_11_0_arm64.whl", hash = "sha256:838a6115f508ee4ca0e4c84975c0892d67c10f94b11dd2bc0e023ad65138d010", size = 2004199, upload-time = "2026-07-23T17:03:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f5/84e135ac611a6dfe8b95ff989097c200770907907afba78455d4c863981e/comma_deps_raylib-6.0.0.1.post98-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9347fe18799c209dd1af746a44a94433bae69d4644de10e11dcc307aecd87fb1", size = 7203023, upload-time = "2026-07-23T17:03:21.525Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/ae0959dac622f7169de230ec98c6555870ae0ad4f61e7b8d32922fa960ed/comma_deps_raylib-6.0.0.1.post98-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f9fd393fbaf1f16d7785be0b2252ef3b14a953c93d89296ed9a67bc7063de882", size = 5055448, upload-time = "2026-07-23T17:03:25.737Z" }, ] [[package]] name = "comma-deps-zeromq" -version = "4.3.5.post93" +version = "4.3.5.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/6f/d50752a3a6f6ecd4b4289bf76090fd35c11ac8d4b9353534e91e9a640512/comma_deps_zeromq-4.3.5.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b5951b302ec3db7ad3638161af00dff6ad1d8e0272aa242a0b57d692a093bacc", size = 815166, upload-time = "2026-07-08T22:09:24.48Z" }, - { url = "https://files.pythonhosted.org/packages/57/24/0fa08db746438533a67e44886bffb1cd7e2a587c68d57fba745e84036965/comma_deps_zeromq-4.3.5.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a7d4813155ea13548158204a60e6a37e2dedfcf7ae34cbb78491474ac784ac24", size = 833412, upload-time = "2026-07-08T22:09:28.872Z" }, - { url = "https://files.pythonhosted.org/packages/c5/83/9083466d8269bf273a7ff4ff154fbcd300a8d9c79ca7754fb98215caf602/comma_deps_zeromq-4.3.5.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:378f121eea1084c7cd45ee73c3dbc437e3d07547e8333d230174711e963f958d", size = 798729, upload-time = "2026-07-08T22:09:32.859Z" }, + { 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" }, ] [[package]] name = "comma-deps-zstd" -version = "1.5.6.post93" +version = "1.5.6.post98" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/32/e62e39d77d356675b07fa0c03286d3f5620bb39fe8619285bfaf3c085f6b/comma_deps_zstd-1.5.6.post93-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9c40c10fa541c5ed975a06327f4e5cc5fe380d49c50fd2899f367c52e0d535a1", size = 1065092, upload-time = "2026-07-08T22:09:37.023Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f9/80e530ffed74b767591e50367dd51413c99e8aa10a975745086f2f10eac8/comma_deps_zstd-1.5.6.post93-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:3a1da859191a9b4c778498afdaa80a7000e5967d4851d827e422c467aa159f9a", size = 1006181, upload-time = "2026-07-08T22:09:41.332Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b1/5b544af0a50235b1efe9a9de469180ded30814feb8e6a2cd89d4064f995e/comma_deps_zstd-1.5.6.post93-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:fe85e5fd24ac81964cac30ad04327ae157b5976a69725604ec960e773f06a9a8", size = 1030331, upload-time = "2026-07-08T22:09:45.535Z" }, + { 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" }, ] [[package]] @@ -300,63 +270,63 @@ wheels = [ [[package]] name = "coverage" -version = "7.15.0" +version = "7.15.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, - { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, - { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, - { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, - { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, - { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, - { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, - { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, - { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, - { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, - { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, - { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, - { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, - { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, - { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, - { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, + { 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" }, ] [[package]] name = "cryptography" -version = "49.0.0" +version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { 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" }, ] [[package]] @@ -370,24 +340,24 @@ wheels = [ [[package]] name = "cython" -version = "3.2.8" +version = "3.2.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/6b/80101e02ebacaf9232ecf32bf6a788d36b27d820ee02434746252569ef98/cython-3.2.8.tar.gz", hash = "sha256:f4f23a56b25221a06f91817fe8f3114ab8b48a4fac73187dbb64bc2c4a87961f", size = 3290300, upload-time = "2026-06-30T07:41:57.874Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/c4/47e0bcfc15b36b1c5cbde5235c60bf88df552ab216ddb836d7f816386ae6/cython-3.2.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f2547a31fbd3b1610a8859a16edee2a141f7781691cb98a2c6fd54870c5f7541", size = 2995546, upload-time = "2026-06-30T07:42:23.618Z" }, - { url = "https://files.pythonhosted.org/packages/4a/f4/bc5830abeb57a7c7498cd9a0f2df953fd9fc7f33e3f5352c9824802b83bb/cython-3.2.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab1fe11ebd61e497a848622cdd157a4324ac06e7935b1219715408844e15dd13", size = 3179546, upload-time = "2026-06-30T07:42:25.566Z" }, - { url = "https://files.pythonhosted.org/packages/89/38/a70e879ea52debac11d2810e066a5a2cb16e71229edae303f024506bc142/cython-3.2.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3bce1f079734753649f8a3d3a95832297207feb120d0ef4fd5db4c813cc6c04", size = 3351714, upload-time = "2026-06-30T07:42:27.513Z" }, - { url = "https://files.pythonhosted.org/packages/45/f1/f071c5e7050a7924ffad9822558c74d489afe4764f7486cb68555a509219/cython-3.2.8-cp312-cp312-win_amd64.whl", hash = "sha256:8297efe129e6421c34ddbeb09ed5627ef6c0fc4868bf7f9bdf6c147f595ccaed", size = 2774196, upload-time = "2026-06-30T07:42:29.47Z" }, - { url = "https://files.pythonhosted.org/packages/92/a2/0f2eaa5076bcaef52567471a54ce02ffd70007bf8688cd054f7aab9bc3b8/cython-3.2.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:127bc4039be48c6eebe7f1d68c33d23eb3a0c5ae95e1d730bdd048837751438b", size = 2892527, upload-time = "2026-06-30T07:42:55.45Z" }, - { url = "https://files.pythonhosted.org/packages/a4/08/b5488aef44662e48ac09b42d4cb398207f591c770797036fb1d6fbeb7a52/cython-3.2.8-cp39-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e480ae9f195cd29e5ce334e3d434c83dbac0783c0cc88f2407e31ba997724192", size = 3220335, upload-time = "2026-06-30T07:42:57.403Z" }, - { url = "https://files.pythonhosted.org/packages/7c/96/d04a3621045e9fe9c7c5e406a688ee3d6e04a65f545ea7c622ead4b4afd8/cython-3.2.8-cp39-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3142407b9d63f233c766e17000e2aac782411bf0409b9adc97cb7c320aecd199", size = 2876481, upload-time = "2026-06-30T07:42:59.406Z" }, - { url = "https://files.pythonhosted.org/packages/71/9a/daa259b638c5eabb8a8c36f203b85b01b5362101ff8fc4ec6ad592d34bb3/cython-3.2.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41c118fd91d320cd72af26e29232ff3f1a0a170c47d477c9a176d766067a4718", size = 2999974, upload-time = "2026-06-30T07:43:01.29Z" }, - { url = "https://files.pythonhosted.org/packages/21/de/71cc5b4be5ee4d34c3302b6e7272189106a4072e9890d284d05239d2b645/cython-3.2.8-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6335c6e8737a39734e20d25f89f425bf3274c104fc7efc05aacc7ed1a4858c9d", size = 2897932, upload-time = "2026-06-30T07:43:03.606Z" }, - { url = "https://files.pythonhosted.org/packages/5d/0c/da68b9d3056e90b2060970b50d575cd7fcd1c778e8f23cc467f346e1d471/cython-3.2.8-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1034facc082cb882e1e5beb61e136ae8e282df2eafa11e9771e9b0a15c860801", size = 3235980, upload-time = "2026-06-30T07:43:05.486Z" }, - { url = "https://files.pythonhosted.org/packages/36/0b/d88bc50e66fd1f1160dd2677d9af18273a2fb2f102c086d21f64a5a9c78b/cython-3.2.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8896ff6b133f346ebcf22aa23706c4031e8d9d5ae184433dfc67dc1053318b69", size = 3118504, upload-time = "2026-06-30T07:43:07.485Z" }, - { url = "https://files.pythonhosted.org/packages/db/de/511c4364808b3b4036d051a0301b0b142a3ddf8a319bfdbbd474fcfdc879/cython-3.2.8-cp39-abi3-win32.whl", hash = "sha256:3fd6464433d925cba66ae31bf5780c8a469a06da1d109180cffb39ee3c88ae20", size = 2435866, upload-time = "2026-06-30T07:43:09.367Z" }, - { url = "https://files.pythonhosted.org/packages/18/4f/911b2b2a0a02be15829ccbf0c906029a318efbf53d9f8e021e438261c206/cython-3.2.8-cp39-abi3-win_arm64.whl", hash = "sha256:4e9447d9b652396a285cdfbd4f9f0721842c63c6df281720e87dcc4b9ea65af5", size = 2457829, upload-time = "2026-06-30T07:43:11.645Z" }, - { url = "https://files.pythonhosted.org/packages/c4/19/31aa63ab719b2e1eea5f200a8a54e2591dc1966a6b75e3de30ef8be9bc2c/cython-3.2.8-py3-none-any.whl", hash = "sha256:f635e113677666de13a2ec2979e9b1d5b90617cdfd1a691d3559be81e2dd6cb9", size = 1258688, upload-time = "2026-06-30T07:41:55.624Z" }, + { 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" }, ] [[package]] @@ -399,15 +369,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/25/2a75b47cb057b1e164c604fb81ab690a6cdb5e2260ce651194eae90f64a3/deepmerge-2.1.0-py3-none-any.whl", hash = "sha256:8f148339a91d680a75ecb74ade235d9e759a93df373a0b04e9d31c8666cfeb75", size = 14345, upload-time = "2026-06-22T05:46:06.742Z" }, ] -[[package]] -name = "execnet" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, -] - [[package]] name = "filelock" version = "3.32.2" @@ -516,19 +477,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/d8/95b735e183957c1f26d94c52977f09d466d55119cbbc1558ea4975e4c216/huggingface_hub-1.27.0-py3-none-any.whl", hash = "sha256:7df6827c2f956c60fbaa64646e979e566db76f619dd0a9729dfb8c5a3eb4f68d", size = 784926, upload-time = "2026-08-07T12:48:02.905Z" }, ] -[[package]] -name = "hypothesis" -version = "6.47.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "sortedcontainers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/45/f2/f77da8271b1abb630cb2090ead2f5aa4acc9639d632e8e68187f52527e4b/hypothesis-6.47.5.tar.gz", hash = "sha256:e0c1e253fc97e7ecdb9e2bbff2cf815d8739e0d1d3d093d67c3af5bb6a7211b0", size = 326641, upload-time = "2022-06-25T20:58:48.926Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/a7/389bbaade2cbbb2534cb2715986041ed01c6d792152c527e71f7f68e93b5/hypothesis-6.47.5-py3-none-any.whl", hash = "sha256:87049b781ee11ec1c7948565b889ab02e428a1e32d427ab4de8fdb3649242d06", size = 387311, upload-time = "2022-06-25T20:58:45.281Z" }, -] - [[package]] name = "idna" version = "3.18" @@ -547,15 +495,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, ] -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - [[package]] name = "inputs" version = "0.5" @@ -657,11 +596,11 @@ wheels = [ [[package]] name = "markdown" -version = "3.10.2" +version = "3.10.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, ] [[package]] @@ -685,7 +624,7 @@ wheels = [ [[package]] name = "matplotlib" -version = "3.11.0" +version = "3.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy" }, @@ -698,15 +637,15 @@ dependencies = [ { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/24/080c99d223d158d3a8902769269ab6da5b50f7a0e6e072513907e02b7a6c/matplotlib-3.11.0.tar.gz", hash = "sha256:68c0c7be01b30dcca3638934f7f591df73401235cbdbf0d1ab1c71e7db7f8b57", size = 33251176, upload-time = "2026-06-12T02:29:15.508Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/17/f5276b496c61477a6c4fc5e7401f4bfe1c2e5ef7c6cd67896f2ade3809cb/matplotlib-3.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b5872e9cf11adc8f589ded3ce11bc3e1061ad498259664fabc1f6615beb918", size = 9449976, upload-time = "2026-06-12T02:27:50.989Z" }, - { url = "https://files.pythonhosted.org/packages/82/34/bdd77418adb2178a1d59f044bd67bfebb115896e91b840b8a197eb3f4f4e/matplotlib-3.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0515d495124be3124340e59f164d901ed4484e2246a5b74cfa483cac3b80bd97", size = 9279307, upload-time = "2026-06-12T02:27:53.247Z" }, - { url = "https://files.pythonhosted.org/packages/94/95/7f522393c88313336b20d70fc849555757b2e5febc22b83b3a3f0fd4bce9/matplotlib-3.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be5f93a1d21981bfb802ded0d77a0caa92d4342a47d45754fac77e314a506344", size = 10031353, upload-time = "2026-06-12T02:27:55.215Z" }, - { url = "https://files.pythonhosted.org/packages/87/ce/8f25a0e3186aefd61913e7467d1b999465bcd0d0c03ac695c1b26ca559b7/matplotlib-3.11.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41635d7909d19e52e924a521dde6d8f670b0f53ab1d0e8c331fa831554f681d1", size = 10839232, upload-time = "2026-06-12T02:27:57.746Z" }, - { url = "https://files.pythonhosted.org/packages/85/c2/db15da2bbdf9e3ca66df7db8e2c33a1dfed67be24a24d2c878efaaff01d6/matplotlib-3.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:94f5000f67ca9faa300863ea17f8bce9175cb67b88bec4bc7780502d53dd7c9e", size = 10923899, upload-time = "2026-06-12T02:28:00.223Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2f/a58a4443a4d052a4ea77557478336aefc26c7981f6408d37adba763aa758/matplotlib-3.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6f1ef39f3d0f9e2463303013094992cdbe0f85f43bc54155bc472b2042768e", size = 9329528, upload-time = "2026-06-12T02:28:02.27Z" }, - { url = "https://files.pythonhosted.org/packages/61/0f/4b669589d47733b97ab9df4b58d6fc1e68acb5ea42a928dc7cbdd6bf5871/matplotlib-3.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:9dd11fb612ce7bc60b1de5b4fc87ff959d22317b5de42aabf392f66f97af22eb", size = 9003413, upload-time = "2026-06-12T02:28:04.49Z" }, + { 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" }, ] [[package]] @@ -725,13 +664,11 @@ source = { editable = "msgq_repo" } [package.metadata] requires-dist = [ - { name = "catch2", marker = "extra == 'dev'", git = "https://github.com/commaai/dependencies.git?subdirectory=catch2&rev=release-catch2" }, { name = "codespell", marker = "extra == 'dev'" }, { name = "cppcheck", marker = "extra == 'dev'" }, { name = "cpplint", marker = "extra == 'dev'" }, { name = "cython", marker = "extra == 'dev'" }, { name = "lefthook", marker = "extra == 'dev'" }, - { name = "parameterized", marker = "extra == 'dev'" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "scons", marker = "extra == 'dev'" }, { name = "setuptools", marker = "extra == 'dev'" }, @@ -741,21 +678,21 @@ provides-extras = ["dev"] [[package]] name = "numpy" -version = "2.5.0" +version = "2.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987, upload-time = "2026-06-21T20:56:16.657Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322, upload-time = "2026-06-21T20:56:19.813Z" }, - { url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605, upload-time = "2026-06-21T20:56:22.173Z" }, - { url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628, upload-time = "2026-06-21T20:56:23.914Z" }, - { url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691, upload-time = "2026-06-21T20:56:25.631Z" }, - { url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066, upload-time = "2026-06-21T20:56:28.009Z" }, - { url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638, upload-time = "2026-06-21T20:56:30.26Z" }, - { url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647, upload-time = "2026-06-21T20:56:32.41Z" }, - { url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688, upload-time = "2026-06-21T20:56:34.657Z" }, - { url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237, upload-time = "2026-06-21T20:56:36.24Z" }, - { url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912, upload-time = "2026-06-21T20:56:38.733Z" }, + { 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" }, ] [[package]] @@ -775,9 +712,7 @@ requires-dist = [ { name = "codespell", marker = "extra == 'testing'" }, { name = "cpplint", marker = "extra == 'testing'" }, { name = "gcovr", marker = "extra == 'testing'" }, - { name = "hypothesis", marker = "extra == 'testing'", specifier = "==6.47.*" }, { name = "inputs", marker = "extra == 'examples'" }, - { name = "jinja2", marker = "extra == 'docs'" }, { name = "lefthook", marker = "extra == 'testing'" }, { name = "numpy" }, { name = "pycapnp" }, @@ -790,7 +725,7 @@ requires-dist = [ { name = "unittest-parallel", marker = "extra == 'testing'" }, { name = "zstandard", marker = "extra == 'testing'" }, ] -provides-extras = ["testing", "docs", "examples"] +provides-extras = ["testing", "examples"] [package.metadata.requires-dev] testing = [ @@ -805,7 +740,6 @@ source = { editable = "." } dependencies = [ { name = "comma-deps-acados" }, { name = "comma-deps-capnproto" }, - { name = "comma-deps-catch2" }, { name = "comma-deps-ffmpeg" }, { name = "comma-deps-gcc-arm-none-eabi" }, { name = "comma-deps-git-lfs" }, @@ -813,15 +747,12 @@ dependencies = [ { name = "comma-deps-raylib" }, { name = "comma-deps-zeromq" }, { name = "comma-deps-zstd" }, - { name = "cython" }, { name = "inputs" }, { name = "jeepney" }, { name = "numpy" }, - { name = "pillow" }, { name = "pycapnp" }, { name = "pyjwt", extra = ["crypto"] }, { name = "pyzmq" }, - { name = "qrcode" }, { name = "requests" }, { name = "scons" }, { name = "sentry-sdk" }, @@ -835,7 +766,6 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "huggingface-hub" }, - { name = "matplotlib" }, ] docs = [ { name = "zensical" }, @@ -851,21 +781,15 @@ submodules = [ testing = [ { name = "codespell" }, { name = "coverage" }, - { name = "hypothesis" }, - { name = "pytest" }, - { name = "pytest-cpp" }, - { name = "pytest-mock" }, - { name = "pytest-subtests" }, - { name = "pytest-xdist" }, { name = "ruff" }, { name = "ty" }, ] tools = [ { name = "comma-deps-bootstrap-icons" }, - { name = "comma-deps-bzip2" }, { name = "comma-deps-imgui" }, { name = "comma-deps-libusb" }, { name = "comma-deps-ncurses" }, + { name = "matplotlib" }, ] [package.dev-dependencies] @@ -878,9 +802,7 @@ requires-dist = [ { name = "codespell", marker = "extra == 'testing'" }, { name = "comma-deps-acados" }, { name = "comma-deps-bootstrap-icons", marker = "extra == 'tools'" }, - { name = "comma-deps-bzip2", marker = "extra == 'tools'" }, { name = "comma-deps-capnproto" }, - { name = "comma-deps-catch2" }, { name = "comma-deps-ffmpeg" }, { name = "comma-deps-gcc-arm-none-eabi" }, { name = "comma-deps-git-lfs" }, @@ -892,26 +814,17 @@ requires-dist = [ { name = "comma-deps-zeromq" }, { name = "comma-deps-zstd" }, { name = "coverage", marker = "extra == 'testing'" }, - { name = "cython" }, { name = "huggingface-hub", marker = "extra == 'dev'" }, - { name = "hypothesis", marker = "extra == 'testing'", specifier = "==6.47.*" }, { name = "inputs" }, { name = "jeepney" }, - { name = "matplotlib", marker = "extra == 'dev'" }, + { name = "matplotlib", marker = "extra == 'tools'" }, { name = "msgq", marker = "extra == 'submodules'", editable = "msgq_repo" }, { name = "numpy", specifier = ">=2.0" }, { name = "opendbc", marker = "extra == 'submodules'", editable = "opendbc_repo" }, { name = "pandacan", marker = "extra == 'submodules'", editable = "panda" }, - { name = "pillow" }, { name = "pycapnp", specifier = "==2.1.0" }, { name = "pyjwt", extras = ["crypto"] }, - { name = "pytest", marker = "extra == 'testing'" }, - { name = "pytest-cpp", marker = "extra == 'testing'" }, - { name = "pytest-mock", marker = "extra == 'testing'" }, - { name = "pytest-subtests", marker = "extra == 'testing'" }, - { name = "pytest-xdist", marker = "extra == 'testing'", git = "https://github.com/sshane/pytest-xdist?rev=2b4372bd62699fb412c4fe2f95bf9f01bd2018da" }, { name = "pyzmq" }, - { name = "qrcode" }, { name = "rednose", marker = "extra == 'submodules'", editable = "rednose_repo" }, { name = "requests" }, { name = "ruff", marker = "extra == 'testing'" }, @@ -927,18 +840,18 @@ requires-dist = [ { name = "zensical", marker = "extra == 'docs'" }, { name = "zstandard" }, ] -provides-extras = ["docs", "testing", "dev", "tools", "submodules"] +provides-extras = ["docs", "dev", "testing", "tools", "submodules"] [package.metadata.requires-dev] standalone = [{ name = "openpilot", extras = ["submodules"] }] [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -961,7 +874,6 @@ requires-dist = [ { name = "libusb-package" }, { name = "libusb1" }, { name = "opendbc", git = "https://github.com/sunnypilot/opendbc.git?rev=master" }, - { name = "pycryptodome", marker = "extra == 'dev'", specifier = ">=3.9.8" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "pytest-mock", marker = "extra == 'dev'" }, { name = "pytest-timeout", marker = "extra == 'dev'" }, @@ -990,15 +902,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, ] -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - [[package]] name = "pycapnp" version = "2.1.0" @@ -1092,68 +995,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] -[[package]] -name = "pytest" -version = "9.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, -] - -[[package]] -name = "pytest-cpp" -version = "2.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cf/a1/c2679d7ff2da20a0f89c7820ae2739cde739eac9b43c192531117b31b5f4/pytest_cpp-2.6.0.tar.gz", hash = "sha256:c2f49d3c038539ac84786a94d852e4f4619c34c95979c2bc69c20b3bdf051d85", size = 465490, upload-time = "2024-09-18T00:08:08.251Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/44/dc2f5d53165264ae5831f361fe7723c45da05718a97015b2eddc452cf503/pytest_cpp-2.6.0-py3-none-any.whl", hash = "sha256:b33de94609450feea2fba9efff3558b8ac8f1fdf40a99e263b395d4798b911bb", size = 15074, upload-time = "2024-09-18T00:08:06.415Z" }, -] - -[[package]] -name = "pytest-mock" -version = "3.15.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, -] - -[[package]] -name = "pytest-subtests" -version = "0.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bb/d9/20097971a8d315e011e055d512fa120fd6be3bdb8f4b3aa3e3c6bf77bebc/pytest_subtests-0.15.0.tar.gz", hash = "sha256:cb495bde05551b784b8f0b8adfaa27edb4131469a27c339b80fd8d6ba33f887c", size = 18525, upload-time = "2025-10-20T16:26:18.358Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/64/bba465299b37448b4c1b84c7a04178399ac22d47b3dc5db1874fe55a2bd3/pytest_subtests-0.15.0-py3-none-any.whl", hash = "sha256:da2d0ce348e1f8d831d5a40d81e3aeac439fec50bd5251cbb7791402696a9493", size = 9185, upload-time = "2025-10-20T16:26:17.239Z" }, -] - -[[package]] -name = "pytest-xdist" -version = "3.7.1.dev24+g2b4372b" -source = { git = "https://github.com/sshane/pytest-xdist?rev=2b4372bd62699fb412c4fe2f95bf9f01bd2018da#2b4372bd62699fb412c4fe2f95bf9f01bd2018da" } -dependencies = [ - { name = "execnet" }, - { name = "pytest" }, -] - [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1205,18 +1046,6 @@ wheels = [ { 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" }, ] -[[package]] -name = "qrcode" -version = "8.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, -] - [[package]] name = "rednose" version = "0.0.1" @@ -1263,27 +1092,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.20" +version = "0.16.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, - { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, - { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, - { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, - { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, - { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, - { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, - { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, - { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, - { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, - { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, ] [[package]] @@ -1297,15 +1126,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.64.0" +version = "2.67.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/31/b7341f156a5f6f36f0b4845d6f1c28a2ae4799171dba7007f3a1e9b234b4/sentry_sdk-2.64.0.tar.gz", hash = "sha256:68be2c29e14ae310f8a39e1a79916b6d85c6cb41dcce789d14ff05fe293e4c55", size = 921020, upload-time = "2026-06-30T08:13:47.682Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/8a/b2eec40df8a67bf073e244d29001d04ee365d163bc4f15efdfce35f53090/sentry_sdk-2.67.1.tar.gz", hash = "sha256:f263d8c9aa4137750640de8fb0ed5404df6bb564e20e4b59cb16a6eeba18d4ed", size = 990599, upload-time = "2026-08-10T13:05:55.892Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/a8/3fb9a4319efa3b26f5be0e90e6d8918df43fa7c7e977d26390f589501d82/sentry_sdk-2.64.0-py3-none-any.whl", hash = "sha256:715ea91ca860a819e8d8a50a7bde3a80d0df3b4ed7b6660a20fb9a2d084188f1", size = 498901, upload-time = "2026-06-30T08:13:45.566Z" }, + { url = "https://files.pythonhosted.org/packages/34/e2/70692eba662037cddf93391cbbf98297159f3038612e9b9a8129e16feb7a/sentry_sdk-2.67.1-py3-none-any.whl", hash = "sha256:a66bfbce1cd8a93c51c369d642ad85b46253ea7a6f7938141315b83e2823cda5", size = 515591, upload-time = "2026-08-10T13:05:54.213Z" }, ] [[package]] @@ -1328,11 +1157,11 @@ wheels = [ [[package]] name = "setuptools" -version = "82.0.1" +version = "84.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, ] [[package]] @@ -1344,15 +1173,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, -] - [[package]] name = "sounddevice" version = "0.5.5" @@ -1484,39 +1304,39 @@ wheels = [ [[package]] name = "tqdm" -version = "4.68.3" +version = "4.70.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, + { 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" }, ] [[package]] name = "ty" -version = "0.0.56" +version = "0.0.69" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/55/07/fb29aea5235b0aa8ecfc4d1cc6ddf9fba8b863d67d96c6d345694d644c43/ty-0.0.56.tar.gz", hash = "sha256:84d114dc3796361c0fc72945016eabd74d46b9ee64f198cb0e485719704681e5", size = 6050123, upload-time = "2026-07-01T16:44:56.036Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/5b/7a618632dfe9373b7df572ecd7a08c8f799d772fbc317da82dd3aa363207/ty-0.0.69.tar.gz", hash = "sha256:b65106e9ff24fa76e25e1142fb09c85244e815c40450e3021d2bf652c231bb43", size = 6565094, upload-time = "2026-08-06T10:04:25.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/48/bce79e7ca5c1cc529d3e0d37ddd1121aea4b68a4f749974ad1cc77161871/ty-0.0.56-py3-none-linux_armv6l.whl", hash = "sha256:186d4a53e15747c947e1ec3d7eec8e345d8e40a1ca10e634c585db52497e87dd", size = 11643066, upload-time = "2026-07-01T16:44:18.374Z" }, - { url = "https://files.pythonhosted.org/packages/80/d1/22555d8a1d719661f10050f3865d877bbf497da908961c75fe22217dd18a/ty-0.0.56-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aae1a980fd9535da0469b7ba2b2e1b54a907743a5e0f442dd57eee9f5bfd034c", size = 11407487, upload-time = "2026-07-01T16:44:20.956Z" }, - { url = "https://files.pythonhosted.org/packages/cf/2d/b3b7a74ce8bc59ef48843ad80179bb0d9598bbd6cfc0d11d519bdf6b1352/ty-0.0.56-py3-none-macosx_11_0_arm64.whl", hash = "sha256:afd3058c0a6c5f241e814734f133008c93ee805f61c9cf4ce7412b8822b5d9ad", size = 10962270, upload-time = "2026-07-01T16:44:22.959Z" }, - { url = "https://files.pythonhosted.org/packages/64/ac/6c2fd7de0304a8a7218a756af74f7e62a5e8540fdb175e0a869e51042345/ty-0.0.56-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:058b52f7a823ac13aae3cae30809dd6b5145794b64d8478f9ef38c75d79b4483", size = 11471406, upload-time = "2026-07-01T16:44:25.327Z" }, - { url = "https://files.pythonhosted.org/packages/50/b6/11d861156861c03c7726b74558f9a0e0092661aff83a4fda1279df28c425/ty-0.0.56-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c66e00c1522add1f2bbdd2e45828c953b35c306b7bef03ec9169c75a63699a0", size = 11445612, upload-time = "2026-07-01T16:44:27.531Z" }, - { url = "https://files.pythonhosted.org/packages/fb/ba/09df108582090f3c0770ec4bc8675affed60248f6793a78d909be16211d9/ty-0.0.56-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40903d71c669a30691b5a5d5728056c7877a1bd6be4f233a38883a8b28cf34d7", size = 12093889, upload-time = "2026-07-01T16:44:29.548Z" }, - { url = "https://files.pythonhosted.org/packages/d7/f7/dbb4b4ccb69cd64c209ae55b1ab788ace8222c2bc1f6845be9e7cbedbf25/ty-0.0.56-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63fe3947fe0c46c69a7d950e6832ee70a9ec17321fefbff3d2e3c20baf9e5bd0", size = 12666337, upload-time = "2026-07-01T16:44:31.586Z" }, - { url = "https://files.pythonhosted.org/packages/86/e9/73f903fe4a3d9ea02f26f57c1eb07e3b1029ec92b0e8c2364718893440e3/ty-0.0.56-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71a0c1a72f9854532e710e119b6871ffe4542c8a65146f1f65dcd78fecd885b4", size = 12280247, upload-time = "2026-07-01T16:44:33.637Z" }, - { url = "https://files.pythonhosted.org/packages/d6/90/cebd222495832f1a00dcd321ba25f3cab804221a4991b992c2178bec68ee/ty-0.0.56-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70d1665596494e24d8ebd198438872b5a56ec3cae5f2bcf6c673be797acc4e3c", size = 11991107, upload-time = "2026-07-01T16:44:36.122Z" }, - { url = "https://files.pythonhosted.org/packages/b7/07/8f7337a07250f42d975cdb6decf47fc5b421e6c7da5e3e7be1e85f63a7e5/ty-0.0.56-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:778f99e51558afc1dbbe48ee38ab6aae7b31390ed8c1a1ef1499b295e9f1e82f", size = 12298970, upload-time = "2026-07-01T16:44:38.243Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b9/a52cd59034a48f5f18c6b155cc2cc36861d874b6d0af204b12c898024c3d/ty-0.0.56-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:867bc5708e0066bb4ff6c7db524bd5deea2676c62bfe71d3303138b3be850af0", size = 11425683, upload-time = "2026-07-01T16:44:40.473Z" }, - { url = "https://files.pythonhosted.org/packages/1d/2e/48e42d33357d52eefb695c0c3fcfc96879b73668a7447d1d1e0ad774fedc/ty-0.0.56-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6012f4189c928edb330a37deb9930f982380bd4aa7c4b8e0428eec9651c7551", size = 11469258, upload-time = "2026-07-01T16:44:42.513Z" }, - { url = "https://files.pythonhosted.org/packages/d5/01/ad1b4138be1e3fa97863af3925aa2134f17a593240c35dc38c3429fb5ad1/ty-0.0.56-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee83de1a7ff4cc32837ec06134ce391d441bc5b35ecd8d3cfe053f120f3e4c1", size = 11758736, upload-time = "2026-07-01T16:44:44.567Z" }, - { url = "https://files.pythonhosted.org/packages/09/34/9d81967ff240eaa57e9249728ef7b7790747cf6d3c9a98ec86b2cfdcc8ee/ty-0.0.56-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:62619b3b0e2c6248ef30d3f0e2f2217ae9893040585be07f32324242f197cd6f", size = 12100242, upload-time = "2026-07-01T16:44:46.584Z" }, - { url = "https://files.pythonhosted.org/packages/c3/36/f51d4666d2de6cf33c1f3a1fcc4bb6b70b197dd6ceaa491eef71d78fe8e8/ty-0.0.56-py3-none-win32.whl", hash = "sha256:b30687bb5cd9729d34c889a289edf32770388d9bb05243e534e723fb45e0381b", size = 11093759, upload-time = "2026-07-01T16:44:49.171Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b4/8fb5d4acfa4afb152245b20fa263069a7547bd1f8e4bfca4eda280c897d7/ty-0.0.56-py3-none-win_amd64.whl", hash = "sha256:ad4c8c47b6f4e3f9ed3fc0b1a5d650088d229e17dd8f63c1826d6bbe94cc3235", size = 12100327, upload-time = "2026-07-01T16:44:51.26Z" }, - { url = "https://files.pythonhosted.org/packages/b8/fc/6a183e71edde90d0c35c2303f23f7a45b6891d1a2c45daf7b8f869831e19/ty-0.0.56-py3-none-win_arm64.whl", hash = "sha256:57538f273d444a5f1293fa7860e967178afe3917611fc5eff16b64e1204fe0d6", size = 11538780, upload-time = "2026-07-01T16:44:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/06/60/6534092f4d2c15e2491807edd609c2e50d527c1fed957acf40b9f110b64a/ty-0.0.69-py3-none-linux_armv6l.whl", hash = "sha256:98bfd383b273540829af673e7f98b9c1c4bcc8547d12a1a3806cd0bec7f0e087", size = 12364185, upload-time = "2026-08-06T10:03:47.137Z" }, + { url = "https://files.pythonhosted.org/packages/34/2b/5c29689bd4f74c2e3394d983d85e4011b629f2ce3730c9442553b8554bf8/ty-0.0.69-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:964621ddd05771660017c51b4e74078d861d9fc863c21ef2a500db1ab62c9ccf", size = 12042510, upload-time = "2026-08-06T10:03:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/09/46/fa085bde4d23516d7ef14b24736fc5dd7dc498f60f52b3d077e59ffdea20/ty-0.0.69-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3ffea4048dd0da4c9c97393b4be0901098a9065b06fa81be2477cbde65d8a151", size = 11549397, upload-time = "2026-08-06T10:03:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/97b9efb2061dcab6fef1e94a4ad99df0bb45bd2cc15d4f5794c787ee0552/ty-0.0.69-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8684d4a70aadd1eab0f41bdba835e3288ef49db8402a8e6ca81bab52ed5d610", size = 12115567, upload-time = "2026-08-06T10:03:53.79Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c1/a5e0404965093835f3e62544e661784ec0aa8ef0b006ed50af50b19c107e/ty-0.0.69-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:afaaba240ab4122e2069a796836d10be81b4ddb053ae268b3dff962a0b4ca5c7", size = 12149770, upload-time = "2026-08-06T10:03:55.993Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8cad6b205a4abe8a044ca0c84aea71e8ccda29b07a75a5f090e310605580/ty-0.0.69-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11ea63ef07d4e33aeb1a775cf5f2c736b3ed22fa6f8b1b608591612c36795044", size = 12941278, upload-time = "2026-08-06T10:03:58.324Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8b/8766d96b732c2a060d70dc8ccafcc4d6a54109a2a95f1deb0705de88892b/ty-0.0.69-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cb3730b1268e92a2907d7aea3afe8dd1b360ae65862f0557080cf479d481b424", size = 13426509, upload-time = "2026-08-06T10:04:00.621Z" }, + { url = "https://files.pythonhosted.org/packages/02/1f/e991b2cde953ea5b94d6a9a4c45c87937bd916bc09235f764407bf471c0a/ty-0.0.69-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a544ff57a752ef186ed40b5a2f44c17402af4cdefeb74a311ca02ebd57c4fca0", size = 13106582, upload-time = "2026-08-06T10:04:02.818Z" }, + { url = "https://files.pythonhosted.org/packages/ea/bb/73538f1b99e3558fd9db87b98698426f0f60fc8666da0b1efd0e70e275eb/ty-0.0.69-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87ed2cbca20caddfdf8e3e14d213ce91b67e75feed78900f4aaf3ef884954028", size = 12708931, upload-time = "2026-08-06T10:04:05.233Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/484a5208d74c4ad1155933906295ccdce9aa81a257d8df2ab9e41bd60133/ty-0.0.69-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2684efcbce5b6fe45045faf610b377b50781b6d2aa7e61ea23ecf5b3d2bce421", size = 12985322, upload-time = "2026-08-06T10:04:07.587Z" }, + { url = "https://files.pythonhosted.org/packages/6e/81/b75003f0d4da9ab3bc8fd4f4802f836cb9921ff7e70f460604f7b769a0b5/ty-0.0.69-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:da9aeb26fdac1d2214937542b59e0d4d1ba94ec7a3f45444f33c846de1eb1d63", size = 12063910, upload-time = "2026-08-06T10:04:09.835Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/088469f547ef63dceefc4a75826aedee5014f9371dc5171cde931896a82c/ty-0.0.69-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:00e7677cd14ede381f705f71104ea7b8ea0ce217a8634e19a89781953de0e9ad", size = 12166823, upload-time = "2026-08-06T10:04:12.114Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c9/ce88a0bec0d46d8ae180b99c6ec014866fecc4cba1727b5feec8877b2765/ty-0.0.69-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d91965eb799649833d0d6042db09cd03d15289125245337cc46a2606effb7bda", size = 12483136, upload-time = "2026-08-06T10:04:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/63/9e/6fae0ff225a0012642cf72c077e20f8f448c0a80771bc3360e8178fe2f32/ty-0.0.69-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1f03359cd8e5c412aa0c181118fa9b9061a4dddaedbb61bac0a424fb0814d402", size = 12799025, upload-time = "2026-08-06T10:04:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/e4/43/78a658d18b2a4ccf35b053392f2213bf12e3c63b2abea512d3b6751d1f4c/ty-0.0.69-py3-none-win32.whl", hash = "sha256:ec460e01586b1eb91894c4a8403bee3e045a47e7a4ada943cc27ce8e348e88cf", size = 11787774, upload-time = "2026-08-06T10:04:18.622Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/88db1f674403f2b81316a853a44a81ed220621fa96f8f7ae586fb6ca7513/ty-0.0.69-py3-none-win_amd64.whl", hash = "sha256:18976ca26a4e28fc3249477f79a695d5502e670803f2e080d89ac905baef3c6e", size = 12864038, upload-time = "2026-08-06T10:04:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/4d/7b/6fc6efd00c69103d70f2bdbe824343089cd70b17b3079170057d3e5a3ac0/ty-0.0.69-py3-none-win_arm64.whl", hash = "sha256:7d4ca3bb74d91cb9947ba3f3b4cb131ad6a2b3ecc76d34040c4ec6092d2e411d", size = 12196693, upload-time = "2026-08-06T10:04:22.902Z" }, ] [[package]] @@ -1548,7 +1368,7 @@ wheels = [ [[package]] name = "zensical" -version = "0.0.46" +version = "0.0.54" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1560,20 +1380,20 @@ dependencies = [ { name = "pyyaml" }, { name = "tomli" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/57/c7bbb71f943e1e0ba5ce460f4930ec836ead7286969e7fd742f7a6c049ab/zensical-0.0.46.tar.gz", hash = "sha256:3ec21f4fb1e78cd7c0d6b07ae336b04770e27ba020dabc457b2790e5d34f1978", size = 3973968, upload-time = "2026-06-21T18:52:40.368Z" } +sdist = { url = "https://files.pythonhosted.org/packages/75/7e/343a78c0c9da1954d2f0a4d47ca778baac48bb18c6b9c0c6260e7974976e/zensical-0.0.54.tar.gz", hash = "sha256:4de205dbb323d0a443e2ebf3fef77e93e3c1493c34a58d205e7f3631dd7745af", size = 3992024, upload-time = "2026-08-13T16:04:49.297Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/bd/bbc499ee35ac9ec5459dbfec7bb7231556689e97eaa13a5eddbe1f0443b5/zensical-0.0.46-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d91af81ab058c8693dfd75f2f77b4c73bcba4125681d1d276f38624291820bd2", size = 12796482, upload-time = "2026-06-21T18:52:07.369Z" }, - { url = "https://files.pythonhosted.org/packages/88/1b/7acc273184d59b8e894d15ebe3cf1c5e81b3a822fde1792ea3e33be37a2e/zensical-0.0.46-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d9221264a9a87409900a47e29985607b0c9245dacb89077e87c8e16e31edc167", size = 12660030, upload-time = "2026-06-21T18:52:10.186Z" }, - { url = "https://files.pythonhosted.org/packages/80/df/bd0a68de98a19fc6050c58be11f36d05ea72a213b6a7ff7395d33c793747/zensical-0.0.46-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec43018d5343ca2e1d71aa352eeddd560fef504effd03025840a5a783abefa4f", size = 13057130, upload-time = "2026-06-21T18:52:12.911Z" }, - { url = "https://files.pythonhosted.org/packages/f4/db/e27635f5787a42245f900e658340698a6654e165d466f9a3b640efced2cd/zensical-0.0.46-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26e98fb8ab7ab50cdd20a73e2c7d4d9aae0b46cf2d8691e6bb22f9c261b8a60a", size = 13022345, upload-time = "2026-06-21T18:52:15.84Z" }, - { url = "https://files.pythonhosted.org/packages/e7/9d/6ce2ba11c97154870b458a8dae4637ade93b7097912f0102f5ea7fe8cf5b/zensical-0.0.46-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:46fe578f26963f8ee89567983e62737b6fadc9197d4742e1020b522e092d7baa", size = 13377445, upload-time = "2026-06-21T18:52:18.538Z" }, - { url = "https://files.pythonhosted.org/packages/68/06/9930d43cd9d2f899b648d63491007c1b4f9716cf118b0c98e867b933069c/zensical-0.0.46-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aef03fa186a5589148e10b62610500989c6b075a2c08e1554233adbf91b2a3dc", size = 13086749, upload-time = "2026-06-21T18:52:21.452Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ed/2342cf860fbb02314938b0d1f1b02344935801b04d185ff3151ef1812898/zensical-0.0.46-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bc7446cdf97a8dea390f20ed2bd6b030cddc1bd36a8ce113ea3efef6fa61c573", size = 13231120, upload-time = "2026-06-21T18:52:24.171Z" }, - { url = "https://files.pythonhosted.org/packages/de/b0/d2ece02f63cd767fcf10fd7608dc8e0a995f87dc5261209b1dbc296fd57b/zensical-0.0.46-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:bbee37801f1ed500f158dc0992c569282950f780ae353c37fe6969f99983d701", size = 13295035, upload-time = "2026-06-21T18:52:26.942Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b2/cb0048a612e63e615399fc507472a557d1c5b7c2f74065c5bf11998fd597/zensical-0.0.46-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:9487c147c9cceb50c04d0ad70b024821a6eab1629dafd70ab6d1e86ec841e623", size = 13437191, upload-time = "2026-06-21T18:52:29.69Z" }, - { url = "https://files.pythonhosted.org/packages/91/16/515f81db8055b109a510063be481e60a657c4fad1a883680b2ee4aa9a424/zensical-0.0.46-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f42a4683c762f026878d19ede4bcf7bfbb84dbecb5ad923949abb77806ed88a5", size = 13369382, upload-time = "2026-06-21T18:52:32.521Z" }, - { url = "https://files.pythonhosted.org/packages/b9/5c/da54ee65b642eb7d88dd4a3db35845d0765915638e05d5d434a10b42f1c3/zensical-0.0.46-cp310-abi3-win32.whl", hash = "sha256:85f018f2a7ee76a83915c87ddb12b58cf343fd6154081d33ac95b6751b011dd7", size = 12354298, upload-time = "2026-06-21T18:52:34.976Z" }, - { url = "https://files.pythonhosted.org/packages/73/26/fc7ef081acbdada8436825221cb728ee84a81d4d78a7bb79aa58bd150d31/zensical-0.0.46-cp310-abi3-win_amd64.whl", hash = "sha256:1543a693a160de60e86ca589592401b584670e7e12c5ae30e3c2ba76786f7ec3", size = 12599687, upload-time = "2026-06-21T18:52:37.913Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cf/13e887c303fd5c786c09f83362382a42d10292fca633a95067de6a6591a3/zensical-0.0.54-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f7177a3b6647e4ee47864ab02e5141f9e923dd57b9fa96e5dc5da4228daff505", size = 12893082, upload-time = "2026-08-13T16:04:17.337Z" }, + { url = "https://files.pythonhosted.org/packages/82/ee/7fe1418fa31bc120cf9eb0fbce9e021c40752206ce993a7bc652c011f890/zensical-0.0.54-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:7b23c3b0c720885891b220c3e562074d93eb940314823d91875c6246976bd9b2", size = 12778626, upload-time = "2026-08-13T16:04:19.906Z" }, + { url = "https://files.pythonhosted.org/packages/61/b8/0420115270c1a22a2d4a1598f89dadc4e933eb7aa85539b72f3532acc5b6/zensical-0.0.54-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c012eb0ec20fda5794e90b4906b7401c77b712e2acc7f9f65026935368539da", size = 13225462, upload-time = "2026-08-13T16:04:22.663Z" }, + { url = "https://files.pythonhosted.org/packages/8d/48/0dfdd3e00fb3b807de702383ef5f4e9cf0d2791fee0e8a56f39bd590f16b/zensical-0.0.54-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0e851d26ba4f7397db3b3532e534c0572a9826bbd451ee0a00e649c030dcb38", size = 13158184, upload-time = "2026-08-13T16:04:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/f1fb1f5387108b206f985cdb394bbdc730557cc339e63067ded9b3565263/zensical-0.0.54-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a48583da6f485e2373845d4332882c9e9d504302dbd2731c589543584df5b087", size = 13536772, upload-time = "2026-08-13T16:04:27.373Z" }, + { url = "https://files.pythonhosted.org/packages/a3/97/3224b3dd5d76cebddf9725ae871a5a6a8f2de177be59d802232d00073d8b/zensical-0.0.54-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da7781a906623fb7bc278cc874ec6f80691c8753b90fbdf90a451abb046ae204", size = 13191901, upload-time = "2026-08-13T16:04:30.295Z" }, + { url = "https://files.pythonhosted.org/packages/b2/00/b1f55530c8df4331e1f209ec605816a578142f124b5f8078a9d984514d63/zensical-0.0.54-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:75aff1c01f6104dd0e79d08c87bd595da13db3e1f6da55fc9933ebff3e94fdd5", size = 13402956, upload-time = "2026-08-13T16:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/c7/6e/58df496c2742600df3a2f273ba52b8ced1ce357340b825af9f1bb0f2042e/zensical-0.0.54-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1469c5d551a0ea2d0fcf922046a263d2afcff5f03ce3bb443e286970d04ff9f2", size = 13431462, upload-time = "2026-08-13T16:04:35.518Z" }, + { url = "https://files.pythonhosted.org/packages/5b/85/70ae775db7865be2434bc39e9b4ff1c7988978d796a7ff9d49294e7410c7/zensical-0.0.54-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:31c354f98b3374b9bcab65278a278dbec66125c997f7f1a5ee9501f413b87b9c", size = 13587289, upload-time = "2026-08-13T16:04:37.954Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/05e6b3e04323810bbf4da9240b8b710740fac82ec01659b14c86e44aa88e/zensical-0.0.54-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85ef75654e7845aa65f1392af771771566f33faac452a2aed004ba367454f812", size = 13534594, upload-time = "2026-08-13T16:04:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/14/70/be34910c13632f85911f505bd9a4d1bb53d46c8498d20ebb18570bbe4b7b/zensical-0.0.54-cp310-abi3-win32.whl", hash = "sha256:b56c80a8cd234666afb917fb1ed8467104f6857b1acabd66f60ef1b8a0daa66f", size = 12448180, upload-time = "2026-08-13T16:04:43.486Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c6/a965265946555023dc0e159a41038502190882669d177dfa59b1dd3d580b/zensical-0.0.54-cp310-abi3-win_amd64.whl", hash = "sha256:f5a602986c4123a349cfd075c8d494096a2a2db74422169d65f8092872e48dfa", size = 12712264, upload-time = "2026-08-13T16:04:46.155Z" }, ] [[package]] diff --git a/zensical.toml b/zensical.toml deleted file mode 100644 index 7e5ca2c5db..0000000000 --- a/zensical.toml +++ /dev/null @@ -1,81 +0,0 @@ -[project] -site_name = "openpilot docs" -site_url = "https://docs.comma.ai" -repo_url = "https://github.com/commaai/openpilot/" - -docs_dir = "docs" -site_dir = "docs_site/" - -extra_css = ["stylesheets/extra.css"] - -nav = [ - { "What is openpilot?" = "index.md" }, - { "How-to" = [ - { "Turn the speed blue" = "how-to/turn-the-speed-blue.md" }, - { "Connect to a comma 3X or four" = "how-to/connect-to-comma.md" }, - { "Add support for a car" = "how-to/car-port.md" }, - ] }, - { "Concepts" = [ - { "Logs" = "concepts/logs.md" }, - { "Safety" = "concepts/safety.md" }, - { "Glossary" = "concepts/glossary.md" }, - ] }, - { "Contributing" = [ - { "Feedback" = "contributing/feedback.md" }, - { "Roadmap" = "contributing/roadmap.md" }, - { "Contributing Guide →" = "https://github.com/commaai/openpilot/blob/master/docs/CONTRIBUTING.md" }, - ] }, - { "Links" = [ - { "Blog →" = "https://blog.comma.ai" }, - { "Bounties →" = "https://comma.ai/bounties" }, - { "GitHub →" = "https://github.com/commaai" }, - { "Discord →" = "https://discord.comma.ai" }, - { "X →" = "https://x.com/comma_ai" }, - ] }, -] - -[project.theme] -logo = "assets/comma-logo.png" -features = [ - "navigation.expand", - "navigation.sections", - "navigation.instant", - "navigation.instant.prefetch", - "content.code.copy", - "content.action.edit", - "content.action.view", -] - -[[project.extra.social]] -icon = "fontawesome/brands/github" -link = "https://github.com/commaai" - -[[project.extra.social]] -icon = "fontawesome/brands/discord" -link = "https://discord.comma.ai" - -[[project.extra.social]] -icon = "fontawesome/brands/x-twitter" -link = "https://x.com/comma_ai" - -[project.markdown_extensions.attr_list] - -[project.markdown_extensions.admonition] - -[project.markdown_extensions.md_in_html] - -[project.markdown_extensions.pymdownx.highlight] -anchor_linenums = true -line_spans = "__span" -pygments_lang_class = true - -[project.markdown_extensions.pymdownx.inlinehilite] - -[project.markdown_extensions.pymdownx.magiclink] - -[project.markdown_extensions.pymdownx.superfences] -custom_fences = [{ name = "mermaid", class = "mermaid" }] - -[project.markdown_extensions.pymdownx.details] - -[project.markdown_extensions."ext.glossary"]