diff --git a/.github/workflows/build-all-tinygrad-models.yaml b/.github/workflows/build-all-tinygrad-models.yaml index e901baee0c..412676e5fd 100644 --- a/.github/workflows/build-all-tinygrad-models.yaml +++ b/.github/workflows/build-all-tinygrad-models.yaml @@ -34,10 +34,10 @@ jobs: echo "tinygrad_ref=$ref" >> $GITHUB_OUTPUT echo "tinygrad_ref is $ref" - - name: Checkout docs repo (sunnypilot-docs, gh-pages) + - name: Checkout docs repo (sunnypilot-models, gh-pages) uses: actions/checkout@v4 with: - repository: sunnypilot/sunnypilot-docs + repository: sunnypilot/sunnypilot-models ref: gh-pages path: docs ssh-key: ${{ secrets.CI_SUNNYPILOT_DOCS_PRIVATE_KEY }} @@ -202,7 +202,7 @@ jobs: - name: Checkout docs repo uses: actions/checkout@v4 with: - repository: sunnypilot/sunnypilot-docs + repository: sunnypilot/sunnypilot-models ref: gh-pages path: docs ssh-key: ${{ secrets.CI_SUNNYPILOT_DOCS_PRIVATE_KEY }} diff --git a/.github/workflows/build-single-tinygrad-model.yaml b/.github/workflows/build-single-tinygrad-model.yaml index fae9d6aa01..e7e3b67b51 100644 --- a/.github/workflows/build-single-tinygrad-model.yaml +++ b/.github/workflows/build-single-tinygrad-model.yaml @@ -119,7 +119,7 @@ jobs: - name: Checkout docs repo uses: actions/checkout@v4 with: - repository: sunnypilot/sunnypilot-docs + repository: sunnypilot/sunnypilot-models ref: gh-pages path: docs ssh-key: ${{ secrets.CI_SUNNYPILOT_DOCS_PRIVATE_KEY }} diff --git a/.github/workflows/cereal_validation.yaml b/.github/workflows/cereal_validation.yaml index 3a864ebefb..e9c0e5d03e 100644 --- a/.github/workflows/cereal_validation.yaml +++ b/.github/workflows/cereal_validation.yaml @@ -33,15 +33,15 @@ jobs: - run: ./tools/op.sh setup - name: Build openpilot run: scons -j$(nproc) cereal - - name: Generate the log file + - name: Dump sunnypilot schema run: | export PYTHONPATH=${{ github.workspace }} - python3 cereal/messaging/tests/validate_sp_cereal_upstream.py -g -f schema_instances.bin + python3 cereal/messaging/tests/validate_sp_cereal_upstream.py -g -f schema.json - name: 'Prepare artifact' run: | mkdir -p "cereal/messaging/tests/cereal_validations" cp cereal/messaging/tests/validate_sp_cereal_upstream.py "cereal/messaging/tests/cereal_validations/validate_sp_cereal_upstream.py" - cp schema_instances.bin "cereal/messaging/tests/cereal_validations/schema_instances.bin" + cp schema.json "cereal/messaging/tests/cereal_validations/schema.json" - name: 'Upload Artifact' uses: actions/upload-artifact@v4 with: @@ -71,8 +71,8 @@ jobs: with: name: cereal_validations path: openpilot/cereal/messaging/tests/cereal_validations - - name: 'Run the validation' + - name: 'Validate sunnypilot schema against upstream' run: | export PYTHONPATH=${{ github.workspace }}/openpilot chmod +x openpilot/cereal/messaging/tests/cereal_validations/validate_sp_cereal_upstream.py - python3 openpilot/cereal/messaging/tests/cereal_validations/validate_sp_cereal_upstream.py -r -f openpilot/cereal/messaging/tests/cereal_validations/schema_instances.bin + python3 openpilot/cereal/messaging/tests/cereal_validations/validate_sp_cereal_upstream.py -r -f openpilot/cereal/messaging/tests/cereal_validations/schema.json diff --git a/.github/workflows/diff_report.yaml b/.github/workflows/diff_report.yaml new file mode 100644 index 0000000000..2ddb850944 --- /dev/null +++ b/.github/workflows/diff_report.yaml @@ -0,0 +1,45 @@ +name: diff report + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +jobs: + comment: + name: comment + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: write + actions: read + steps: + - name: Wait for process replay + id: wait + continue-on-error: true + uses: lewagon/wait-on-check-action@v1.3.4 + with: + ref: ${{ github.event.pull_request.head.sha }} + check-name: process replay + repo-token: ${{ secrets.GITHUB_TOKEN }} + allowed-conclusions: success,failure + wait-interval: 20 + - name: Download diff + if: steps.wait.outcome == 'success' + uses: dawidd6/action-download-artifact@v6 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + workflow: tests.yaml + workflow_conclusion: '' + pr: ${{ github.event.number }} + name: diff_report_${{ github.event.number }} + path: . + allow_forks: true + - name: Comment on PR + if: steps.wait.outcome == 'success' + uses: thollander/actions-comment-pull-request@v2 + with: + filePath: diff_report.txt + comment_tag: diff_report + pr_number: ${{ github.event.number }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 27d36f9a4e..23b20a86ff 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -29,9 +29,9 @@ jobs: # Build - name: Build docs run: | - # TODO: can we install just the "docs" dependency group without the normal deps? - pip install mkdocs - mkdocs build + git lfs pull + pip install zensical + python scripts/docs.py build # Push to docs.comma.ai - uses: actions/checkout@v6 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 908c570391..6ae5336557 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -22,7 +22,7 @@ jobs: running-workflow-name: 'build __nightly' repo-token: ${{ secrets.GITHUB_TOKEN }} check-regexp: ^((?!.*(build prebuilt|create badges).*).)*$ - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 with: submodules: true fetch-depth: 0 diff --git a/.github/workflows/repo-maintenance.yaml b/.github/workflows/repo-maintenance.yaml index e7933dc470..0db4933649 100644 --- a/.github/workflows/repo-maintenance.yaml +++ b/.github/workflows/repo-maintenance.yaml @@ -9,28 +9,6 @@ env: PYTHONPATH: ${{ github.workspace }} jobs: - update_translations: - runs-on: ubuntu-latest - if: github.repository == 'sunnypilot/sunnypilot' - steps: - - uses: actions/checkout@v6 - with: - submodules: true - - run: ./tools/op.sh setup - - name: Update translations - run: python3 selfdrive/ui/update_translations.py --vanish - - name: Create Pull Request - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 - with: - author: github-actions[bot] - commit-message: "Update translations" - title: "[bot] Update translations" - body: "Automatic PR from repo-maintenance -> update_translations" - branch: "update-translations" - base: "master" - delete-branch: true - labels: bot - package_updates: name: package_updates runs-on: ubuntu-latest @@ -72,7 +50,6 @@ jobs: git add . - name: update car docs run: | - scons -j$(nproc) --minimal opendbc_repo python selfdrive/car/docs.py git add docs/CARS.md - name: Create Pull Request diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml index 3966d1a6c9..79cb5e3d61 100644 --- a/.github/workflows/sunnypilot-build-prebuilt.yaml +++ b/.github/workflows/sunnypilot-build-prebuilt.yaml @@ -185,7 +185,7 @@ jobs: echo "Building sunnypilot's locationd..." scons -j2 cache_dir=${{env.SCONS_CACHE_DIR}} --minimal sunnypilot/selfdrive/locationd echo "Building openpilot's locationd..." - scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal selfdrive/locationd + scons -j1 cache_dir=${{env.SCONS_CACHE_DIR}} --minimal selfdrive/locationd echo "Building rest of sunnypilot" scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal touch ${BUILD_DIR}/prebuilt diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 760b16326a..834771d634 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -156,12 +156,22 @@ jobs: id: print-diff if: always() run: cat selfdrive/test/process_replay/diff.txt + - name: Print diff report + if: always() + run: cat selfdrive/test/process_replay/diff_report.txt - uses: actions/upload-artifact@v6 if: always() continue-on-error: true with: name: process_replay_diff.txt path: selfdrive/test/process_replay/diff.txt + - name: Upload diff report + uses: actions/upload-artifact@v6 + if: always() && github.event_name == 'pull_request' + continue-on-error: true + with: + name: diff_report_${{ github.event.number }} + path: selfdrive/test/process_replay/diff_report.txt - name: Checkout ci-artifacts if: github.repository == 'commaai/openpilot' && github.ref == 'refs/heads/master' uses: actions/checkout@v4 @@ -181,7 +191,7 @@ jobs: echo "${{ github.sha }}" > ref_commit git add . git commit -m "process-replay refs for ${{ github.repository }}@${{ github.sha }}" || echo "No changes to commit" - git push origin process-replay + git push origin process-replay --force - name: Run regen if: false timeout-minutes: 4 diff --git a/.gitignore b/.gitignore index cd5e64e52b..3434c7254a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,13 +13,13 @@ venv/ a.out .hypothesis .cache/ - -/docs_site/ +bin/ *.mp4 *.dylib *.DSYM *.d +*.pem *.pyc *.pyo .*.swp @@ -39,11 +39,14 @@ a.out *.mo *_pyx.cpp *.stats +*.pkl +*.pkl* config.json -clcache compile_commands.json compare_runtime*.html +# build artifacts +docs_site/ selfdrive/pandad/pandad cereal/services.h cereal/gen @@ -56,51 +59,36 @@ system/camerad/test/ae_gray_test .coverage* coverage.xml htmlcov -pandaextra - -.mypy_cache/ -flycheck_* - -cppcheck_report.txt -comma*.sh - -selfdrive/modeld/models/*.pkl* -sunnypilot/modeld*/models/*.pkl # openpilot log files *.bz2 *.zst +*.rlog build/ !**/.gitkeep -poetry.toml -Pipfile ### VisualStudioCode ### +*.vsix +.history +.ionide .vscode/* +.history/ !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json !.vscode/*.code-snippets -# Local History for Visual Studio Code -.history/ - -# Built Visual Studio Code Extensions -*.vsix - -### VisualStudioCode Patch ### -# Ignore all local history of files -.history -.ionide - +# agents .claude/ .context/ PLAN.md TASK.md +CLAUDE.md +SKILL.md ### JetBrains ### !.idea/customTargets.xml diff --git a/.python-version b/.python-version new file mode 100644 index 0000000000..28d9a01b1f --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12.13 diff --git a/.vscode/launch.json b/.vscode/launch.json index f090061c42..151b757dab 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -52,6 +52,9 @@ "type": "lldb", "request": "attach", "pid": "${command:pickMyProcess}", + "sourceMap": { + ".": "${workspaceFolder}/opendbc/safety" + }, "initCommands": [ "script import time; time.sleep(3)" ] diff --git a/Jenkinsfile b/Jenkinsfile index c5ebf6162b..39175d89e3 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -167,7 +167,7 @@ node { env.GIT_COMMIT = checkout(scm).GIT_COMMIT def excludeBranches = ['__nightly', 'devel', 'devel-staging', 'release3', 'release3-staging', - 'release-tici', 'release-tizi', 'release-tizi-staging', 'testing-closet*', 'hotfix-*'] + 'release-tici', 'release-tizi', 'release-tizi-staging', 'release-mici-staging', 'testing-closet*', 'hotfix-*'] def excludeRegex = excludeBranches.join('|').replaceAll('\\*', '.*') if (env.BRANCH_NAME != 'master' && !env.BRANCH_NAME.contains('__jenkins_loop_')) { @@ -179,7 +179,7 @@ node { try { if (env.BRANCH_NAME == 'devel-staging') { deviceStage("build release-tizi-staging", "tizi-needs-can", [], [ - step("build release-tizi-staging", "RELEASE_BRANCH=release-tizi-staging $SOURCE_DIR/release/build_release.sh"), + step("build release-tizi-staging", "RELEASE_BRANCH=release-tizi-staging $SOURCE_DIR/release/build_release.sh && git push -f origin release-tizi-staging:release-mici-staging"), ]) } @@ -218,14 +218,14 @@ node { 'camerad OX03C10': { deviceStage("OX03C10", "tizi-ox03c10", ["UNSAFE=1"], [ step("build", "cd system/manager && ./build.py"), - step("test pandad", "pytest selfdrive/pandad/tests/test_pandad.py", [diffPaths: ["panda", "selfdrive/pandad/"]]), + step("test pandad", "pytest selfdrive/pandad/tests/test_pandad.py"), step("test camerad", "pytest system/camerad/test/test_camerad.py", [timeout: 90]), ]) }, 'camerad OS04C10': { deviceStage("OS04C10", "tici-os04c10", ["UNSAFE=1"], [ step("build", "cd system/manager && ./build.py"), - step("test pandad", "pytest selfdrive/pandad/tests/test_pandad.py", [diffPaths: ["panda", "selfdrive/pandad/"]]), + step("test pandad", "pytest selfdrive/pandad/tests/test_pandad.py"), step("test camerad", "pytest system/camerad/test/test_camerad.py", [timeout: 90]), ]) }, diff --git a/RELEASES.md b/RELEASES.md index 895dcbba7a..55141710d0 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -1,8 +1,17 @@ -Version 0.10.4 (2026-02-17) +Version 0.11.1 (2026-04-22) ======================== +* New driver monitoring model +* Improved image processing pipeline for driver camera +* Rivian R1S and R1T 2025 support thanks to lukasloetkolben! + +Version 0.11.0 (2026-03-17) +======================== +* New driving model #36798 + * Fully trained using a learned simulator + * Improved longitudinal performance in Experimental mode +* Reduce comma four standby power usage by 77% to 52 mW * Kia K7 2017 support thanks to royjr! * Lexus LS 2018 support thanks to Hacheoy! -* Reduce comma four standby power usage by 77% to 52 mW Version 0.10.3 (2025-12-17) ======================== diff --git a/SConstruct b/SConstruct index 7db9f7e3d2..16c0752acf 100644 --- a/SConstruct +++ b/SConstruct @@ -4,9 +4,11 @@ import sys import sysconfig import platform import shlex +import importlib import numpy as np import SCons.Errors +from SCons.Defaults import _stripixes SCons.Warnings.warningAsException(True) @@ -14,9 +16,6 @@ Decider('MD5-timestamp') SetOption('num_jobs', max(1, int(os.cpu_count()/2))) -AddOption('--asan', action='store_true', help='turn on ASAN') -AddOption('--ubsan', action='store_true', help='turn on UBSan') -AddOption('--mutation', action='store_true', help='generate mutation-ready code') AddOption('--ccflags', action='store', type='string', default='', help='pass arbitrary flags over the command line') AddOption('--verbose', action='store_true', default=False, help='show full build commands') AddOption('--minimal', @@ -38,24 +37,47 @@ assert arch in [ "Darwin", # macOS arm64 (x86 not supported) ] -if arch != "larch64": - import bzip2 - import capnproto - import eigen - import ffmpeg as ffmpeg_pkg - import libjpeg - import libyuv - import ncurses - import openssl3 - import python3_dev - import zeromq - import zstd - pkgs = [bzip2, capnproto, eigen, ffmpeg_pkg, libjpeg, libyuv, ncurses, openssl3, zeromq, zstd] - py_include = python3_dev.INCLUDE_DIR -else: - # TODO: remove when AGNOS has our new vendor pkgs - pkgs = [] - py_include = sysconfig.get_paths()['include'] +pkg_names = ['bzip2', 'capnproto', 'eigen', 'ffmpeg', 'libjpeg', 'libyuv', 'ncurses', 'zeromq', 'zstd'] +pkgs = [importlib.import_module(name) for name in pkg_names] + + +# ***** enforce a whitelist of system libraries ***** +# this prevents silently relying on a 3rd party package, +# e.g. apt-installed libusb. all libraries should either +# be distributed with all Linux distros and macOS, or +# vendored in commaai/dependencies. +allowed_system_libs = { + "EGL", "GLESv2", "GL", + "Qt5Charts", "Qt5Core", "Qt5Gui", "Qt5Widgets", + "dl", "drm", "gbm", "m", "pthread", +} + +def _resolve_lib(env, name): + for d in env.Flatten(env.get('LIBPATH', [])): + p = Dir(str(d)).abspath + for ext in ('.a', '.so', '.dylib'): + f = File(os.path.join(p, f'lib{name}{ext}')) + if f.exists() or f.has_builder(): + return name + if name in allowed_system_libs: + return name + raise SCons.Errors.UserError(f"Unexpected non-vendored library '{name}'") + +def _libflags(target, source, env, for_signature): + libs = [] + lp = env.subst('$LIBLITERALPREFIX') + for lib in env.Flatten(env.get('LIBS', [])): + if isinstance(lib, str): + if os.sep in lib or lib.startswith('#'): + libs.append(File(lib)) + elif lib.startswith('-') or (lp and lib.startswith(lp)): + libs.append(lib) + else: + libs.append(_resolve_lib(env, lib)) + else: + libs.append(lib) + return _stripixes(env['LIBLINKPREFIX'], libs, env['LIBLINKSUFFIX'], + env['LIBPREFIXES'], env['LIBSUFFIXES'], env, env['LIBLITERALPREFIX']) env = Environment( ENV={ @@ -108,14 +130,14 @@ env = Environment( tools=["default", "cython", "compilation_db", "rednose_filter"], toolpath=["#site_scons/site_tools", "#rednose_repo/site_scons/site_tools"], ) +if arch != "larch64": + env['_LIBFLAGS'] = _libflags # Arch-specific flags and paths if arch == "larch64": env["CC"] = "clang" env["CXX"] = "clang++" env.Append(LIBPATH=[ - "/usr/local/lib", - "/system/vendor/lib64", "/usr/lib/aarch64-linux-gnu", ]) arch_flags = ["-D__TICI__", "-mcpu=cortex-a57", "-DQCOM2"] @@ -127,19 +149,6 @@ elif arch == "Darwin": ]) env.Append(CCFLAGS=["-DGL_SILENCE_DEPRECATION"]) env.Append(CXXFLAGS=["-DGL_SILENCE_DEPRECATION"]) -else: - env.Append(LIBPATH=[ - "/usr/lib", - "/usr/local/lib", - ]) - -# Sanitizers and extra CCFLAGS from CLI -if GetOption('asan'): - env.Append(CCFLAGS=["-fsanitize=address", "-fno-omit-frame-pointer"]) - env.Append(LINKFLAGS=["-fsanitize=address"]) -elif GetOption('ubsan'): - env.Append(CCFLAGS=["-fsanitize=undefined"]) - env.Append(LINKFLAGS=["-fsanitize=undefined"]) _extra_cc = shlex.split(GetOption('ccflags') or '') if _extra_cc: @@ -177,7 +186,7 @@ if os.environ.get('SCONS_PROGRESS'): # ********** Cython build environment ********** envCython = env.Clone() -envCython["CPPPATH"] += [py_include, np.get_include()] +envCython["CPPPATH"] += [sysconfig.get_paths()['include'], np.get_include()] envCython["CCFLAGS"] += ["-Wno-#warnings", "-Wno-cpp", "-Wno-shadow", "-Wno-deprecated-declarations"] envCython["CCFLAGS"].remove("-Werror") @@ -211,7 +220,6 @@ Export('common') env_swaglog = env.Clone() env_swaglog['CXXFLAGS'].append('-DSWAGLOG="\\"common/swaglog.h\\""') SConscript(['msgq_repo/SConscript'], exports={'env': env_swaglog}) -SConscript(['opendbc_repo/SConscript'], exports={'env': env_swaglog}) SConscript(['cereal/SConscript']) @@ -237,12 +245,25 @@ if arch == "larch64": # Build openpilot SConscript(['third_party/SConscript']) -SConscript(['selfdrive/SConscript']) +# Build selfdrive +SConscript([ + 'selfdrive/pandad/SConscript', + 'selfdrive/controls/lib/lateral_mpc_lib/SConscript', + 'selfdrive/controls/lib/longitudinal_mpc_lib/SConscript', + 'selfdrive/locationd/SConscript', + 'selfdrive/modeld/SConscript', + 'selfdrive/ui/SConscript', +]) SConscript(['sunnypilot/SConscript']) -if Dir('#tools/cabana/').exists() and arch != "larch64": - SConscript(['tools/cabana/SConscript']) +# Build tools +if arch != "larch64": + SConscript([ + 'tools/replay/SConscript', + 'tools/cabana/SConscript', + 'tools/jotpluggler/SConscript', + ]) env.CompilationDatabase('compile_commands.json') diff --git a/cereal/SConscript b/cereal/SConscript index 73dc61844b..de7ca0d721 100644 --- a/cereal/SConscript +++ b/cereal/SConscript @@ -4,7 +4,7 @@ cereal_dir = Dir('.') gen_dir = Dir('gen') # Build cereal -schema_files = ['log.capnp', 'car.capnp', 'legacy.capnp', 'custom.capnp'] +schema_files = ['log.capnp', 'car.capnp', 'deprecated.capnp', 'custom.capnp'] env.Command([f'gen/cpp/{s}.c++' for s in schema_files] + [f'gen/cpp/{s}.h' for s in schema_files], schema_files, f"capnpc --src-prefix={cereal_dir.path} $SOURCES -o c++:{gen_dir.path}/cpp/") diff --git a/cereal/custom.capnp b/cereal/custom.capnp index cdcef93a59..f0a1ea89bb 100644 --- a/cereal/custom.capnp +++ b/cereal/custom.capnp @@ -154,6 +154,7 @@ struct ModelManagerSP @0xaedffd8f31e7b55d { vision @2; policy @3; offPolicy @4; + onPolicy @5; } } diff --git a/cereal/legacy.capnp b/cereal/deprecated.capnp similarity index 71% rename from cereal/legacy.capnp rename to cereal/deprecated.capnp index a8fa5e4a1f..45ce25c682 100644 --- a/cereal/legacy.capnp +++ b/cereal/deprecated.capnp @@ -3,7 +3,7 @@ $Cxx.namespace("cereal"); @0x80ef1ec4889c2a63; -# legacy.capnp: a home for deprecated structs +# deprecated.capnp: a home for deprecated structs struct LogRotate @0x9811e1f38f62f2d1 { segmentNum @0 :Int32; @@ -571,4 +571,219 @@ struct LidarPts @0xe3d6685d4e9d8f7a { pkt @4 :Data; } +struct LiveTracksDEPRECATED @0xb16f60103159415a { + trackId @0 :Int32; + dRel @1 :Float32; + yRel @2 :Float32; + vRel @3 :Float32; + aRel @4 :Float32; + timeStamp @5 :Float32; + status @6 :Float32; + currentTime @7 :Float32; + stationary @8 :Bool; + oncoming @9 :Bool; +} +struct LiveMpcData @0x92a5e332a85f32a0 { + x @0 :List(Float32); + y @1 :List(Float32); + psi @2 :List(Float32); + curvature @3 :List(Float32); + qpIterations @4 :UInt32; + calculationTime @5 :UInt64; + cost @6 :Float64; +} + +struct LiveLongitudinalMpcData @0xe7e17c434f865ae2 { + xEgo @0 :List(Float32); + vEgo @1 :List(Float32); + aEgo @2 :List(Float32); + xLead @3 :List(Float32); + vLead @4 :List(Float32); + aLead @5 :List(Float32); + aLeadTau @6 :Float32; # lead accel time constant + qpIterations @7 :UInt32; + mpcId @8 :UInt32; + calculationTime @9 :UInt64; + cost @10 :Float64; +} + +struct DriverStateDEPRECATED @0xb83c6cc593ed0a00 { + frameId @0 :UInt32; + modelExecutionTime @14 :Float32; + dspExecutionTime @16 :Float32; + rawPredictions @15 :Data; + + faceOrientation @3 :List(Float32); + facePosition @4 :List(Float32); + faceProb @5 :Float32; + leftEyeProb @6 :Float32; + rightEyeProb @7 :Float32; + leftBlinkProb @8 :Float32; + rightBlinkProb @9 :Float32; + faceOrientationStd @11 :List(Float32); + facePositionStd @12 :List(Float32); + sunglassesProb @13 :Float32; + poorVision @17 :Float32; + partialFace @18 :Float32; + distractedPose @19 :Float32; + distractedEyes @20 :Float32; + eyesOnRoad @21 :Float32; + phoneUse @22 :Float32; + occludedProb @23 :Float32; + + readyProb @24 :List(Float32); + notReadyProb @25 :List(Float32); + + irPwrDEPRECATED @10 :Float32; + descriptorDEPRECATED @1 :List(Float32); + stdDEPRECATED @2 :Float32; +} + +struct NavModelData @0xac3de5c437be057a { + frameId @0 :UInt32; + locationMonoTime @6 :UInt64; + modelExecutionTime @1 :Float32; + dspExecutionTime @2 :Float32; + features @3 :List(Float32); + # predicted future position + position @4 :XYData; + desirePrediction @5 :List(Float32); + + # All SI units and in device frame + struct XYData @0xbe09e615b2507e26 { + x @0 :List(Float32); + y @1 :List(Float32); + xStd @2 :List(Float32); + yStd @3 :List(Float32); + } +} + +struct AndroidBuildInfo @0xfe2919d5c21f426c { + board @0 :Text; + bootloader @1 :Text; + brand @2 :Text; + device @3 :Text; + display @4 :Text; + fingerprint @5 :Text; + hardware @6 :Text; + host @7 :Text; + id @8 :Text; + manufacturer @9 :Text; + model @10 :Text; + product @11 :Text; + radioVersion @12 :Text; + serial @13 :Text; + supportedAbis @14 :List(Text); + tags @15 :Text; + time @16 :Int64; + type @17 :Text; + user @18 :Text; + + versionCodename @19 :Text; + versionRelease @20 :Text; + versionSdk @21 :Int32; + versionSecurityPatch @22 :Text; +} + +struct AndroidSensor @0x9b513b93a887dbcd { + id @0 :Int32; + name @1 :Text; + vendor @2 :Text; + version @3 :Int32; + handle @4 :Int32; + type @5 :Int32; + maxRange @6 :Float32; + resolution @7 :Float32; + power @8 :Float32; + minDelay @9 :Int32; + fifoReservedEventCount @10 :UInt32; + fifoMaxEventCount @11 :UInt32; + stringType @12 :Text; + maxDelay @13 :Int32; +} + +struct IosBuildInfo @0xd97e3b28239f5580 { + appVersion @0 :Text; + appBuild @1 :UInt32; + osVersion @2 :Text; + deviceModel @3 :Text; +} + +enum FrameTypeDEPRECATED @0xa37f0d8558e193fd { + unknown @0; + neo @1; + chffrAndroid @2; + front @3; +} + +struct AndroidCaptureResult @0xbcc3efbac41d2048 { + sensitivity @0 :Int32; + frameDuration @1 :Int64; + exposureTime @2 :Int64; + rollingShutterSkew @3 :UInt64; + colorCorrectionTransform @4 :List(Int32); + colorCorrectionGains @5 :List(Float32); + displayRotation @6 :Int8; +} + +enum UsbPowerModeDEPRECATED @0xa8883583b32c9877 { + none @0; + client @1; + cdp @2; + dcp @3; +} + +struct LateralINDIState @0x939463348632375e { + active @0 :Bool; + steeringAngleDeg @1 :Float32; + steeringRateDeg @2 :Float32; + steeringAccelDeg @3 :Float32; + rateSetPoint @4 :Float32; + accelSetPoint @5 :Float32; + accelError @6 :Float32; + delayedOutput @7 :Float32; + delta @8 :Float32; + output @9 :Float32; + saturated @10 :Bool; + steeringAngleDesiredDeg @11 :Float32; + steeringRateDesiredDeg @12 :Float32; +} + +struct LateralLQRState @0x9024e2d790c82ade { + active @0 :Bool; + steeringAngleDeg @1 :Float32; + i @2 :Float32; + output @3 :Float32; + lqrOutput @4 :Float32; + saturated @5 :Bool; + steeringAngleDesiredDeg @6 :Float32; +} + +struct LateralCurvatureState @0xad9d8095c06f7c61 { + active @0 :Bool; + actualCurvature @1 :Float32; + desiredCurvature @2 :Float32; + error @3 :Float32; + p @4 :Float32; + i @5 :Float32; + f @6 :Float32; + output @7 :Float32; + saturated @8 :Bool; +} + +struct LateralPlannerSolution @0x84caeca5a6b4acfe { + x @0 :List(Float32); + y @1 :List(Float32); + yaw @2 :List(Float32); + yawRate @3 :List(Float32); + xStd @4 :List(Float32); + yStd @5 :List(Float32); + yawStd @6 :List(Float32); + yawRateStd @7 :List(Float32); +} + +struct GpsTrajectory @0x8cfeb072f5301000 { + x @0 :List(Float32); + y @1 :List(Float32); +} diff --git a/cereal/log.capnp b/cereal/log.capnp index d14e149c14..cf91e017e6 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -2,7 +2,7 @@ using Cxx = import "./include/c++.capnp"; $Cxx.namespace("cereal"); using Car = import "car.capnp"; -using Legacy = import "legacy.capnp"; +using Deprecated = import "deprecated.capnp"; using Custom = import "custom.capnp"; @0xf3b1f17e25a4285b; @@ -68,12 +68,12 @@ struct OnroadEvent @0xc4fa6047f024e718 { longitudinalManeuver @30; steerTempUnavailableSilent @31; resumeRequired @32; - preDriverDistracted @33; - promptDriverDistracted @34; - driverDistracted @35; - preDriverUnresponsive @36; - promptDriverUnresponsive @37; - driverUnresponsive @38; + driverDistracted1 @33; + driverDistracted2 @34; + driverDistracted3 @35; + driverUnresponsive1 @36; + driverUnresponsive2 @37; + driverUnresponsive3 @38; belowSteerSpeed @39; lowBattery @40; accFaulted @41; @@ -88,6 +88,7 @@ struct OnroadEvent @0xc4fa6047f024e718 { lowMemory @51; stockAeb @52; stockLkas @98; + lateralManeuver @99; ldw @53; carUnrecognized @54; invalidLkasSetting @55; @@ -191,66 +192,16 @@ struct InitData { espVersion @3 :Text; } - # ***** deprecated stuff ***** - gctxDEPRECATED @1 :Text; - androidBuildInfo @5 :AndroidBuildInfo; - androidSensorsDEPRECATED @6 :List(AndroidSensor); - chffrAndroidExtraDEPRECATED @7 :ChffrAndroidExtra; - iosBuildInfoDEPRECATED @14 :IosBuildInfo; - - struct AndroidBuildInfo { - board @0 :Text; - bootloader @1 :Text; - brand @2 :Text; - device @3 :Text; - display @4 :Text; - fingerprint @5 :Text; - hardware @6 :Text; - host @7 :Text; - id @8 :Text; - manufacturer @9 :Text; - model @10 :Text; - product @11 :Text; - radioVersion @12 :Text; - serial @13 :Text; - supportedAbis @14 :List(Text); - tags @15 :Text; - time @16 :Int64; - type @17 :Text; - user @18 :Text; - - versionCodename @19 :Text; - versionRelease @20 :Text; - versionSdk @21 :Int32; - versionSecurityPatch @22 :Text; - } - - struct AndroidSensor { - id @0 :Int32; - name @1 :Text; - vendor @2 :Text; - version @3 :Int32; - handle @4 :Int32; - type @5 :Int32; - maxRange @6 :Float32; - resolution @7 :Float32; - power @8 :Float32; - minDelay @9 :Int32; - fifoReservedEventCount @10 :UInt32; - fifoMaxEventCount @11 :UInt32; - stringType @12 :Text; - maxDelay @13 :Int32; - } - struct ChffrAndroidExtra { allCameraCharacteristics @0 :Map(Text, Text); } - struct IosBuildInfo { - appVersion @0 :Text; - appBuild @1 :UInt32; - osVersion @2 :Text; - deviceModel @3 :Text; + deprecated :group { + gctx @1 :Text; + androidBuildInfo @5 :Deprecated.AndroidBuildInfo; + androidSensors @6 :List(Deprecated.AndroidSensor); + chffrAndroidExtra @7 :ChffrAndroidExtra; + iosBuildInfo @14 :Deprecated.IosBuildInfo; } } @@ -279,13 +230,6 @@ struct FrameData { temperaturesC @24 :List(Float32); - enum FrameTypeDEPRECATED { - unknown @0; - neo @1; - chffrAndroid @2; - front @3; - } - sensor @26 :ImageSensor; enum ImageSensor { unknown @0; @@ -294,26 +238,19 @@ struct FrameData { os04c10 @3; } - frameLengthDEPRECATED @3 :Int32; - globalGainDEPRECATED @5 :Int32; - frameTypeDEPRECATED @7 :FrameTypeDEPRECATED; - androidCaptureResultDEPRECATED @9 :AndroidCaptureResult; - lensPosDEPRECATED @11 :Int32; - lensSagDEPRECATED @12 :Float32; - lensErrDEPRECATED @13 :Float32; - lensTruePosDEPRECATED @14 :Float32; - focusValDEPRECATED @16 :List(Int16); - focusConfDEPRECATED @17 :List(UInt8); - sharpnessScoreDEPRECATED @18 :List(UInt16); - recoverStateDEPRECATED @19 :Int32; - struct AndroidCaptureResult { - sensitivity @0 :Int32; - frameDuration @1 :Int64; - exposureTime @2 :Int64; - rollingShutterSkew @3 :UInt64; - colorCorrectionTransform @4 :List(Int32); - colorCorrectionGains @5 :List(Float32); - displayRotation @6 :Int8; + deprecated :group { + frameLength @3 :Int32; + globalGain @5 :Int32; + frameType @7 :Deprecated.FrameTypeDEPRECATED; + androidCaptureResult @9 :Deprecated.AndroidCaptureResult; + lensPos @11 :Int32; + lensSag @12 :Float32; + lensErr @13 :Float32; + lensTruePos @14 :Float32; + focusVal @16 :List(Int16); + focusConf @17 :List(UInt8); + sharpnessScore @18 :List(UInt16); + recoverState @19 :Int32; } } @@ -342,7 +279,6 @@ struct SensorEventData { sensor @1 :Int32; type @2 :Int32; timestamp @3 :Int64; - uncalibratedDEPRECATED @10 :Bool; union { acceleration @4 :SensorVec; @@ -377,6 +313,10 @@ struct SensorEventData { lsm6ds3trc @10; mmc5603nj @11; } + + deprecated :group { + uncalibrated @10 :Bool; + } } # android struct GpsLocation @@ -462,7 +402,10 @@ struct CanData { address @0 :UInt32; dat @2 :Data; src @3 :UInt8; - busTimeDEPRECATED @1 :UInt16; + + deprecated :group { + busTime @1 :UInt16; + } } struct DeviceState @0xa4d8b5af2aa492eb { @@ -552,26 +495,27 @@ struct DeviceState @0xa4d8b5af2aa492eb { wwanRx @1 :Int64; } - # deprecated - cpu0DEPRECATED @0 :UInt16; - cpu1DEPRECATED @1 :UInt16; - cpu2DEPRECATED @2 :UInt16; - cpu3DEPRECATED @3 :UInt16; - memDEPRECATED @4 :UInt16; - gpuDEPRECATED @5 :UInt16; - batDEPRECATED @6 :UInt32; - pa0DEPRECATED @21 :UInt16; - cpuUsagePercentDEPRECATED @20 :Int8; - batteryStatusDEPRECATED @9 :Text; - batteryVoltageDEPRECATED @16 :Int32; - batteryTempCDEPRECATED @29 :Float32; - batteryPercentDEPRECATED @8 :Int16; - batteryCurrentDEPRECATED @15 :Int32; - chargingErrorDEPRECATED @17 :Bool; - chargingDisabledDEPRECATED @18 :Bool; - usbOnlineDEPRECATED @12 :Bool; - ambientTempCDEPRECATED @30 :Float32; - nvmeTempCDEPRECATED @35 :List(Float32); + deprecated :group { + cpu0 @0 :UInt16; + cpu1 @1 :UInt16; + cpu2 @2 :UInt16; + cpu3 @3 :UInt16; + mem @4 :UInt16; + gpu @5 :UInt16; + bat @6 :UInt32; + pa0 @21 :UInt16; + cpuUsagePercent @20 :Int8; + batteryStatus @9 :Text; + batteryVoltage @16 :Int32; + batteryTempC @29 :Float32; + batteryPercent @8 :Int16; + batteryCurrent @15 :Int32; + chargingError @17 :Bool; + chargingDisabled @18 :Bool; + usbOnline @12 :Bool; + ambientTempC @30 :Float32; + nvmeTempC @35 :List(Float32); + } } struct PandaState @0xa7649e2575e4591e { @@ -612,6 +556,11 @@ struct PandaState @0xa7649e2575e4591e { voltage @0 :UInt32; current @1 :UInt32; + # these fields are not used by openpilot, but they're + # reserved for forks building alternate experiences. + controlsAllowedLateral @38 :Bool; + controlsAllowedLongitudinal @39 :Bool; + enum FaultStatus { none @0; faultTemp @1; @@ -708,15 +657,17 @@ struct PandaState @0xa7649e2575e4591e { } } - gasInterceptorDetectedDEPRECATED @4 :Bool; - startedSignalDetectedDEPRECATED @5 :Bool; - hasGpsDEPRECATED @6 :Bool; - gmlanSendErrsDEPRECATED @9 :UInt32; - fanSpeedRpmDEPRECATED @11 :UInt16; - usbPowerModeDEPRECATED @12 :PeripheralState.UsbPowerModeDEPRECATED; - safetyParamDEPRECATED @20 :Int16; - safetyParam2DEPRECATED @26 :UInt32; - fanStallCountDEPRECATED @34 :UInt8; + deprecated :group { + gasInterceptorDetected @4 :Bool; + startedSignalDetected @5 :Bool; + hasGps @6 :Bool; + gmlanSendErrs @9 :UInt32; + fanSpeedRpm @11 :UInt16; + usbPowerMode @12 :Deprecated.UsbPowerModeDEPRECATED; + safetyParam @20 :Int16; + safetyParam2 @26 :UInt32; + fanStallCount @34 :UInt8; + } } struct PeripheralState { @@ -725,12 +676,8 @@ struct PeripheralState { current @2 :UInt32; fanSpeedRpm @3 :UInt16; - usbPowerModeDEPRECATED @4 :UsbPowerModeDEPRECATED; - enum UsbPowerModeDEPRECATED @0xa8883583b32c9877 { - none @0; - client @1; - cdp @2; - dcp @3; + deprecated :group { + usbPowerMode @4 :Deprecated.UsbPowerModeDEPRECATED; } } @@ -759,19 +706,22 @@ struct RadarState @0x9a185389d6fdd05f { radar @14 :Bool; radarTrackId @15 :Int32 = -1; - aLeadDEPRECATED @5 :Float32; + deprecated :group { + aLead @5 :Float32; + } } - # deprecated - ftMonoTimeDEPRECATED @7 :UInt64; - warpMatrixDEPRECATED @0 :List(Float32); - angleOffsetDEPRECATED @1 :Float32; - calStatusDEPRECATED @2 :Int8; - calCycleDEPRECATED @8 :Int32; - calPercDEPRECATED @9 :Int8; - canMonoTimesDEPRECATED @10 :List(UInt64); - cumLagMsDEPRECATED @5 :Float32; - radarErrorsDEPRECATED @12 :List(Car.RadarData.ErrorDEPRECATED); + deprecated :group { + ftMonoTime @7 :UInt64; + warpMatrix @0 :List(Float32); + angleOffset @1 :Float32; + calStatus @2 :Int8; + calCycle @8 :Int32; + calPerc @9 :Int8; + canMonoTimes @10 :List(UInt64); + cumLagMs @5 :Float32; + radarErrors @12 :List(Car.RadarData.ErrorDEPRECATED); + } } struct LiveCalibrationData { @@ -789,10 +739,6 @@ struct LiveCalibrationData { wideFromDeviceEuler @10 :List(Float32); height @12 :List(Float32); - warpMatrixDEPRECATED @0 :List(Float32); - calStatusDEPRECATED @1 :Int8; - warpMatrix2DEPRECATED @5 :List(Float32); - warpMatrixBigDEPRECATED @6 :List(Float32); enum Status { uncalibrated @0; @@ -800,19 +746,13 @@ struct LiveCalibrationData { invalid @2; recalibrating @3; } -} -struct LiveTracksDEPRECATED { - trackId @0 :Int32; - dRel @1 :Float32; - yRel @2 :Float32; - vRel @3 :Float32; - aRel @4 :Float32; - timeStamp @5 :Float32; - status @6 :Float32; - currentTime @7 :Float32; - stationary @8 :Bool; - oncoming @9 :Bool; + deprecated :group { + warpMatrix @0 :List(Float32); + calStatus @1 :Int8; + warpMatrix2 @5 :List(Float32); + warpMatrixBig @6 :List(Float32); + } } struct SelfdriveState { @@ -875,25 +815,9 @@ struct ControlsState @0x97ff69c53601abf1 { debugState @59 :LateralDebugState; torqueState @60 :LateralTorqueState; - curvatureStateDEPRECATED @65 :LateralCurvatureState; - lqrStateDEPRECATED @55 :LateralLQRState; - indiStateDEPRECATED @52 :LateralINDIState; - } - - struct LateralINDIState { - active @0 :Bool; - steeringAngleDeg @1 :Float32; - steeringRateDeg @2 :Float32; - steeringAccelDeg @3 :Float32; - rateSetPoint @4 :Float32; - accelSetPoint @5 :Float32; - accelError @6 :Float32; - delayedOutput @7 :Float32; - delta @8 :Float32; - output @9 :Float32; - saturated @10 :Bool; - steeringAngleDesiredDeg @11 :Float32; - steeringRateDesiredDeg @12 :Float32; + curvatureStateDEPRECATED @65 :Deprecated.LateralCurvatureState; + lqrStateDEPRECATED @55 :Deprecated.LateralLQRState; + indiStateDEPRECATED @52 :Deprecated.LateralINDIState; } struct LateralPIDState { @@ -925,16 +849,6 @@ struct ControlsState @0x97ff69c53601abf1 { version @12 :Int32; } - struct LateralLQRState { - active @0 :Bool; - steeringAngleDeg @1 :Float32; - i @2 :Float32; - output @3 :Float32; - lqrOutput @4 :Float32; - saturated @5 :Bool; - steeringAngleDesiredDeg @6 :Float32; - } - struct LateralAngleState { active @0 :Bool; steeringAngleDeg @1 :Float32; @@ -943,18 +857,6 @@ struct ControlsState @0x97ff69c53601abf1 { steeringAngleDesiredDeg @4 :Float32; } - struct LateralCurvatureState { - active @0 :Bool; - actualCurvature @1 :Float32; - desiredCurvature @2 :Float32; - error @3 :Float32; - p @4 :Float32; - i @5 :Float32; - f @6 :Float32; - output @7 :Float32; - saturated @8 :Bool; - } - struct LateralDebugState { active @0 :Bool; steeringAngleDeg @1 :Float32; @@ -962,58 +864,59 @@ struct ControlsState @0x97ff69c53601abf1 { saturated @3 :Bool; } - # deprecated - vEgoDEPRECATED @0 :Float32; - vEgoRawDEPRECATED @32 :Float32; - aEgoDEPRECATED @1 :Float32; - canMonoTimeDEPRECATED @16 :UInt64; - radarStateMonoTimeDEPRECATED @17 :UInt64; - mdMonoTimeDEPRECATED @18 :UInt64; - yActualDEPRECATED @6 :Float32; - yDesDEPRECATED @7 :Float32; - upSteerDEPRECATED @8 :Float32; - uiSteerDEPRECATED @9 :Float32; - ufSteerDEPRECATED @34 :Float32; - aTargetMinDEPRECATED @10 :Float32; - aTargetMaxDEPRECATED @11 :Float32; - rearViewCamDEPRECATED @23 :Bool; - driverMonitoringOnDEPRECATED @43 :Bool; - hudLeadDEPRECATED @14 :Int32; - alertSoundDEPRECATED @45 :Text; - angleModelBiasDEPRECATED @27 :Float32; - gpsPlannerActiveDEPRECATED @40 :Bool; - decelForTurnDEPRECATED @47 :Bool; - decelForModelDEPRECATED @54 :Bool; - awarenessStatusDEPRECATED @26 :Float32; - angleSteersDEPRECATED @13 :Float32; - vCurvatureDEPRECATED @46 :Float32; - mapValidDEPRECATED @49 :Bool; - jerkFactorDEPRECATED @12 :Float32; - steerOverrideDEPRECATED @20 :Bool; - steeringAngleDesiredDegDEPRECATED @29 :Float32; - canMonoTimesDEPRECATED @21 :List(UInt64); - desiredCurvatureRateDEPRECATED @62 :Float32; - canErrorCounterDEPRECATED @57 :UInt32; - vPidDEPRECATED @2 :Float32; - alertBlinkingRateDEPRECATED @42 :Float32; - alertText1DEPRECATED @24 :Text; - alertText2DEPRECATED @25 :Text; - alertStatusDEPRECATED @38 :SelfdriveState.AlertStatus; - alertSizeDEPRECATED @39 :SelfdriveState.AlertSize; - alertTypeDEPRECATED @44 :Text; - alertSound2DEPRECATED @56 :Car.CarControl.HUDControl.AudibleAlert; - engageableDEPRECATED @41 :Bool; # can OP be engaged? - stateDEPRECATED @31 :SelfdriveState.OpenpilotState; - enabledDEPRECATED @19 :Bool; - activeDEPRECATED @36 :Bool; - experimentalModeDEPRECATED @64 :Bool; - personalityDEPRECATED @66 :LongitudinalPersonality; - vCruiseDEPRECATED @22 :Float32; # actual set speed - vCruiseClusterDEPRECATED @63 :Float32; # set speed to display in the UI - startMonoTimeDEPRECATED @48 :UInt64; - cumLagMsDEPRECATED @15 :Float32; - aTargetDEPRECATED @35 :Float32; - vTargetLeadDEPRECATED @3 :Float32; + deprecated :group { + vEgo @0 :Float32; + vEgoRaw @32 :Float32; + aEgo @1 :Float32; + canMonoTime @16 :UInt64; + radarStateMonoTime @17 :UInt64; + mdMonoTime @18 :UInt64; + yActual @6 :Float32; + yDes @7 :Float32; + upSteer @8 :Float32; + uiSteer @9 :Float32; + ufSteer @34 :Float32; + aTargetMin @10 :Float32; + aTargetMax @11 :Float32; + rearViewCam @23 :Bool; + driverMonitoringOn @43 :Bool; + hudLead @14 :Int32; + alertSound @45 :Text; + angleModelBias @27 :Float32; + gpsPlannerActive @40 :Bool; + decelForTurn @47 :Bool; + decelForModel @54 :Bool; + awarenessStatus @26 :Float32; + angleSteers @13 :Float32; + vCurvature @46 :Float32; + mapValid @49 :Bool; + jerkFactor @12 :Float32; + steerOverride @20 :Bool; + steeringAngleDesiredDeg @29 :Float32; + canMonoTimes @21 :List(UInt64); + desiredCurvatureRate @62 :Float32; + canErrorCounter @57 :UInt32; + vPid @2 :Float32; + alertBlinkingRate @42 :Float32; + alertText1 @24 :Text; + alertText2 @25 :Text; + alertStatus @38 :SelfdriveState.AlertStatus; + alertSize @39 :SelfdriveState.AlertSize; + alertType @44 :Text; + alertSound2 @56 :Car.CarControl.HUDControl.AudibleAlert; + engageable @41 :Bool; # can OP be engaged? + state @31 :SelfdriveState.OpenpilotState; + enabled @19 :Bool; + active @36 :Bool; + experimentalMode @64 :Bool; + personality @66 :LongitudinalPersonality; + vCruise @22 :Float32; # actual set speed + vCruiseCluster @63 :Float32; # set speed to display in the UI + startMonoTime @48 :UInt64; + cumLagMs @15 :Float32; + aTarget @35 :Float32; + vTargetLead @3 :Float32; + } } struct DrivingModelData { @@ -1089,16 +992,10 @@ struct ModelDataV2 { meta @12 :MetaData; confidence @23: ConfidenceClass; - # Model perceived motion - temporalPoseDEPRECATED @21 :Pose; - # e2e lateral planner action @26: Action; - gpuExecutionTimeDEPRECATED @17 :Float32; - navEnabledDEPRECATED @22 :Bool; - locationMonoTimeDEPRECATED @24 :UInt64; - lateralPlannerSolutionDEPRECATED @25: LateralPlannerSolution; + lateralPlannerSolutionDEPRECATED @25: Deprecated.LateralPlannerSolution; struct LeadDataV2 { prob @0 :Float32; # probability that car is your lead at time t @@ -1140,10 +1037,11 @@ struct ModelDataV2 { laneChangeDirection @9 :LaneChangeDirection; - # deprecated - brakeDisengageProbDEPRECATED @2 :Float32; - gasDisengageProbDEPRECATED @3 :Float32; - steerOverrideProbDEPRECATED @4 :Float32; + deprecated :group { + brakeDisengageProb @2 :Float32; + gasDisengageProb @3 :Float32; + steerOverrideProb @4 :Float32; + } } enum ConfidenceClass { @@ -1171,22 +1069,18 @@ struct ModelDataV2 { rotStd @3 :List(Float32); # std rad/s in device frame } - struct LateralPlannerSolution { - x @0 :List(Float32); - y @1 :List(Float32); - yaw @2 :List(Float32); - yawRate @3 :List(Float32); - xStd @4 :List(Float32); - yStd @5 :List(Float32); - yawStd @6 :List(Float32); - yawRateStd @7 :List(Float32); - } - struct Action { desiredCurvature @0 :Float32; desiredAcceleration @1 :Float32; shouldStop @2 :Bool; } + + deprecated :group { + temporalPose @21 :Pose; + gpuExecutionTime @17 :Float32; + navEnabled @22 :Bool; + locationMonoTime @24 :UInt64; + } } struct EncodeIndex { @@ -1241,6 +1135,10 @@ struct DriverAssistance { # FCW, AEB, etc. will go here } +struct LateralManeuverPlan { + desiredCurvature @0 :Float32; # 1/m +} + struct LongitudinalPlan @0xe00b5b3eba12876c { modelMonoTime @9 :UInt64; hasLead @7 :Bool; @@ -1268,38 +1166,35 @@ struct LongitudinalPlan @0xe00b5b3eba12876c { e2e @4; } - # deprecated - vCruiseDEPRECATED @16 :Float32; - aCruiseDEPRECATED @17 :Float32; - vTargetDEPRECATED @3 :Float32; - vTargetFutureDEPRECATED @14 :Float32; - vStartDEPRECATED @26 :Float32; - aStartDEPRECATED @27 :Float32; - vMaxDEPRECATED @20 :Float32; - radarStateMonoTimeDEPRECATED @10 :UInt64; - jerkFactorDEPRECATED @6 :Float32; - hasLeftLaneDEPRECATED @23 :Bool; - hasRightLaneDEPRECATED @24 :Bool; - aTargetMinDEPRECATED @4 :Float32; - aTargetMaxDEPRECATED @5 :Float32; - lateralValidDEPRECATED @0 :Bool; - longitudinalValidDEPRECATED @2 :Bool; - dPolyDEPRECATED @1 :List(Float32); - laneWidthDEPRECATED @11 :Float32; - vCurvatureDEPRECATED @21 :Float32; - decelForTurnDEPRECATED @22 :Bool; - mapValidDEPRECATED @25 :Bool; - radarValidDEPRECATED @28 :Bool; - radarCanErrorDEPRECATED @30 :Bool; - commIssueDEPRECATED @31 :Bool; - eventsDEPRECATED @13 :List(Car.OnroadEventDEPRECATED); - gpsTrajectoryDEPRECATED @12 :GpsTrajectory; - gpsPlannerActiveDEPRECATED @19 :Bool; - personalityDEPRECATED @36 :LongitudinalPersonality; - struct GpsTrajectory { - x @0 :List(Float32); - y @1 :List(Float32); + deprecated :group { + vCruise @16 :Float32; + aCruise @17 :Float32; + vTarget @3 :Float32; + vTargetFuture @14 :Float32; + vStart @26 :Float32; + aStart @27 :Float32; + vMax @20 :Float32; + radarStateMonoTime @10 :UInt64; + jerkFactor @6 :Float32; + hasLeftLane @23 :Bool; + hasRightLane @24 :Bool; + aTargetMin @4 :Float32; + aTargetMax @5 :Float32; + lateralValid @0 :Bool; + longitudinalValid @2 :Bool; + dPoly @1 :List(Float32); + laneWidth @11 :Float32; + vCurvature @21 :Float32; + decelForTurn @22 :Bool; + mapValid @25 :Bool; + radarValid @28 :Bool; + radarCanError @30 :Bool; + commIssue @31 :Bool; + events @13 :List(Car.OnroadEventDEPRECATED); + gpsTrajectory @12 :Deprecated.GpsTrajectory; + gpsPlannerActive @19 :Bool; + personality @36 :LongitudinalPersonality; } } struct UiPlan { @@ -1310,11 +1205,7 @@ struct UiPlan { struct LateralPlan @0xe1e9318e2ae8b51e { modelMonoTime @31 :UInt64; - laneWidthDEPRECATED @0 :Float32; - lProbDEPRECATED @5 :Float32; - rProbDEPRECATED @7 :Float32; dPathPoints @20 :List(Float32); - dProbDEPRECATED @21 :Float32; mpcSolutionValid @9 :Bool; desire @17 :Desire; @@ -1336,24 +1227,29 @@ struct LateralPlan @0xe1e9318e2ae8b51e { u @1 :List(Float32); } - # deprecated - curvatureDEPRECATED @22 :Float32; - curvatureRateDEPRECATED @23 :Float32; - rawCurvatureDEPRECATED @24 :Float32; - rawCurvatureRateDEPRECATED @25 :Float32; - cProbDEPRECATED @3 :Float32; - dPolyDEPRECATED @1 :List(Float32); - cPolyDEPRECATED @2 :List(Float32); - lPolyDEPRECATED @4 :List(Float32); - rPolyDEPRECATED @6 :List(Float32); - modelValidDEPRECATED @12 :Bool; - commIssueDEPRECATED @15 :Bool; - posenetValidDEPRECATED @16 :Bool; - sensorValidDEPRECATED @14 :Bool; - paramsValidDEPRECATED @10 :Bool; - steeringAngleDegDEPRECATED @8 :Float32; # deg - steeringRateDegDEPRECATED @13 :Float32; # deg/s - angleOffsetDegDEPRECATED @11 :Float32; + deprecated :group { + laneWidth @0 :Float32; + lProb @5 :Float32; + rProb @7 :Float32; + dProb @21 :Float32; + curvature @22 :Float32; + curvatureRate @23 :Float32; + rawCurvature @24 :Float32; + rawCurvatureRate @25 :Float32; + cProb @3 :Float32; + dPoly @1 :List(Float32); + cPoly @2 :List(Float32); + lPoly @4 :List(Float32); + rPoly @6 :List(Float32); + modelValid @12 :Bool; + commIssue @15 :Bool; + posenetValid @16 :Bool; + sensorValid @14 :Bool; + paramsValid @10 :Bool; + steeringAngleDeg @8 :Float32; # deg + steeringRateDeg @13 :Float32; # deg/s + angleOffsetDeg @11 :Float32; + } } struct LiveLocationKalman { @@ -1426,6 +1322,8 @@ struct LivePose { posenetOK @5 :Bool = false; sensorsOK @6 :Bool = false; + timestamp @8 :UInt64; + debugFilterState @7 :FilterState; struct XYZMeasurement { @@ -1546,7 +1444,10 @@ struct GnssMeasurements { # Satellite position and velocity [x,y,z] satPos @7 :List(Float64); satVel @8 :List(Float64); - ephemerisSourceDEPRECATED @9 :EphemerisSourceDEPRECATED; + + deprecated :group { + ephemerisSource @9 :EphemerisSourceDEPRECATED; + } } struct EphemerisSourceDEPRECATED { @@ -1701,7 +1602,6 @@ struct UbloxGnss { iDot @26 :Float64; codesL2 @27 :Float64; - gpsWeekDEPRECATED @28 :Float64; l2 @29 :Float64; svAcc @30 :Float64; @@ -1721,6 +1621,10 @@ struct UbloxGnss { towCount @40 :UInt32; toeWeek @41 :UInt16; tocWeek @42 :UInt16; + + deprecated :group { + gpsWeek @28 :Float64; + } } struct IonoData { @@ -1799,7 +1703,6 @@ struct UbloxGnss { age @17 :UInt8; svHealth @18 :UInt8; - tkDEPRECATED @19 :UInt16; tb @20 :UInt16; tauN @21 :Float64; @@ -1811,12 +1714,16 @@ struct UbloxGnss { p3 @26 :UInt8; p4 @27 :UInt8; - freqNumDEPRECATED @28 :UInt32; n4 @29 :UInt8; nt @30 :UInt16; freqNum @31 :Int16; tkSeconds @32 :UInt32; + + deprecated :group { + tk @19 :UInt16; + freqNum @28 :UInt32; + } } } @@ -2117,34 +2024,12 @@ struct QcomGnss @0xde94674b07ae51c1 { struct Clocks { wallTimeNanos @3 :UInt64; # unix epoch time - bootTimeNanosDEPRECATED @0 :UInt64; - monotonicNanosDEPRECATED @1 :UInt64; - monotonicRawNanosDEPRECATD @2 :UInt64; - modemUptimeMillisDEPRECATED @4 :UInt64; -} - -struct LiveMpcData { - x @0 :List(Float32); - y @1 :List(Float32); - psi @2 :List(Float32); - curvature @3 :List(Float32); - qpIterations @4 :UInt32; - calculationTime @5 :UInt64; - cost @6 :Float64; -} - -struct LiveLongitudinalMpcData { - xEgo @0 :List(Float32); - vEgo @1 :List(Float32); - aEgo @2 :List(Float32); - xLead @3 :List(Float32); - vLead @4 :List(Float32); - aLead @5 :List(Float32); - aLeadTau @6 :Float32; # lead accel time constant - qpIterations @7 :UInt32; - mpcId @8 :UInt32; - calculationTime @9 :UInt64; - cost @10 :Float64; + deprecated :group { + bootTimeNanos @0 :UInt64; + monotonicNanos @1 :UInt64; + monotonicRawNanos @2 :UInt64; + modemUptimeMillis @4 :UInt64; + } } struct Joystick { @@ -2169,51 +2054,26 @@ struct DriverStateV2 { facePosition @2 :List(Float32); facePositionStd @3 :List(Float32); faceProb @4 :Float32; - leftEyeProb @5 :Float32; - rightEyeProb @6 :Float32; - leftBlinkProb @7 :Float32; - rightBlinkProb @8 :Float32; - sunglassesProb @9 :Float32; + eyesVisibleProb @14 :Float32; + eyesClosedProb @15 :Float32; phoneProb @13 :Float32; - notReadyProbDEPRECATED @12 :List(Float32); - occludedProbDEPRECATED @10 :Float32; - readyProbDEPRECATED @11 :List(Float32); + + deprecated :group { + leftEyeProb @5 :Float32; + rightEyeProb @6 :Float32; + leftBlinkProb @7 :Float32; + rightBlinkProb @8 :Float32; + sunglassesProb @9 :Float32; + notReadyProb @12 :List(Float32); + occludedProb @10 :Float32; + readyProb @11 :List(Float32); + } } - dspExecutionTimeDEPRECATED @2 :Float32; - poorVisionProbDEPRECATED @4 :Float32; -} - -struct DriverStateDEPRECATED @0xb83c6cc593ed0a00 { - frameId @0 :UInt32; - modelExecutionTime @14 :Float32; - dspExecutionTime @16 :Float32; - rawPredictions @15 :Data; - - faceOrientation @3 :List(Float32); - facePosition @4 :List(Float32); - faceProb @5 :Float32; - leftEyeProb @6 :Float32; - rightEyeProb @7 :Float32; - leftBlinkProb @8 :Float32; - rightBlinkProb @9 :Float32; - faceOrientationStd @11 :List(Float32); - facePositionStd @12 :List(Float32); - sunglassesProb @13 :Float32; - poorVision @17 :Float32; - partialFace @18 :Float32; - distractedPose @19 :Float32; - distractedEyes @20 :Float32; - eyesOnRoad @21 :Float32; - phoneUse @22 :Float32; - occludedProb @23 :Float32; - - readyProb @24 :List(Float32); - notReadyProb @25 :List(Float32); - - irPwrDEPRECATED @10 :Float32; - descriptorDEPRECATED @1 :List(Float32); - stdDEPRECATED @2 :Float32; + deprecated :group { + dspExecutionTime @2 :Float32; + poorVisionProb @4 :Float32; + } } struct DriverMonitoringState @0xb83cda094a1da284 { @@ -2235,11 +2095,13 @@ struct DriverMonitoringState @0xb83cda094a1da284 { isRHD @4 :Bool; uncertainCount @19 :UInt32; - phoneProbOffsetDEPRECATED @20 :Float32; - phoneProbValidCountDEPRECATED @21 :UInt32; - isPreviewDEPRECATED @15 :Bool; - rhdCheckedDEPRECATED @5 :Bool; - eventsDEPRECATED @0 :List(Car.OnroadEventDEPRECATED); + deprecated :group { + phoneProbOffset @20 :Float32; + phoneProbValidCount @21 :UInt32; + isPreview @15 :Bool; + rhdChecked @5 :Bool; + events @0 :List(Car.OnroadEventDEPRECATED); + } } struct Boot { @@ -2248,8 +2110,10 @@ struct Boot { commands @5 :Map(Text, Data); launchLog @3 :Text; - lastKmsgDEPRECATED @1 :Data; - lastPmsgDEPRECATED @2 :Data; + deprecated :group { + lastKmsg @1 :Data; + lastPmsg @2 :Data; + } } struct LiveParametersData { @@ -2274,13 +2138,16 @@ struct LiveParametersData { steerRatioValid @19 :Bool = true; stiffnessFactorValid @20 :Bool = true; - yawRateDEPRECATED @7 :Float32; - filterStateDEPRECATED @15 :LiveLocationKalman.Measurement; struct FilterState { value @0 : List(Float64); std @1 : List(Float64); } + + deprecated :group { + yawRate @7 :Float32; + filterState @15 :LiveLocationKalman.Measurement; + } } struct LiveTorqueParametersData { @@ -2450,25 +2317,6 @@ struct MapRenderState { frameId @2: UInt32; } -struct NavModelData { - frameId @0 :UInt32; - locationMonoTime @6 :UInt64; - modelExecutionTime @1 :Float32; - dspExecutionTime @2 :Float32; - features @3 :List(Float32); - # predicted future position - position @4 :XYData; - desirePrediction @5 :List(Float32); - - # All SI units and in device frame - struct XYData { - x @0 :List(Float32); - y @1 :List(Float32); - xStd @2 :List(Float32); - yStd @3 :List(Float32); - } -} - struct EncodeData { idx @0 :EncodeIndex; data @1 :Data; @@ -2493,7 +2341,9 @@ struct SoundPressure @0xdc24138990726023 { soundPressureWeighted @3 :Float32; soundPressureWeightedDb @1 :Float32; - filteredSoundPressureWeightedDbDEPRECATED @2 :Float32; + deprecated :group { + filteredSoundPressureWeightedDb @2 :Float32; + } } struct AudioData { @@ -2610,6 +2460,8 @@ struct Event { bookmarkButton @148 :UserBookmark; audioFeedback @149 :AudioFeedback; + lateralManeuverPlan @150 :LateralManeuverPlan; + # *********** debug *********** testJoystick @52 :Joystick; roadEncodeData @86 :EncodeData; @@ -2655,48 +2507,48 @@ struct Event { customReserved19 @145 :Custom.CustomReserved19; # *********** legacy + deprecated *********** - model @9 :Legacy.ModelData; # TODO: rename modelV2 and mark this as deprecated - liveMpcDEPRECATED @36 :LiveMpcData; - liveLongitudinalMpcDEPRECATED @37 :LiveLongitudinalMpcData; - liveLocationKalmanLegacyDEPRECATED @51 :Legacy.LiveLocationData; - orbslamCorrectionDEPRECATED @45 :Legacy.OrbslamCorrection; - liveUIDEPRECATED @14 :Legacy.LiveUI; + model @9 :Deprecated.ModelData; # TODO: rename modelV2 and mark this as deprecated + liveMpcDEPRECATED @36 :Deprecated.LiveMpcData; + liveLongitudinalMpcDEPRECATED @37 :Deprecated.LiveLongitudinalMpcData; + liveLocationKalmanDeprecatedDEPRECATED @51 :Deprecated.LiveLocationData; + orbslamCorrectionDEPRECATED @45 :Deprecated.OrbslamCorrection; + liveUIDEPRECATED @14 :Deprecated.LiveUI; sensorEventDEPRECATED @4 :SensorEventData; - liveEventDEPRECATED @8 :List(Legacy.LiveEventData); - liveLocationDEPRECATED @25 :Legacy.LiveLocationData; - ethernetDataDEPRECATED @26 :List(Legacy.EthernetPacket); - cellInfoDEPRECATED @28 :List(Legacy.CellInfo); - wifiScanDEPRECATED @29 :List(Legacy.WifiScan); - uiNavigationEventDEPRECATED @50 :Legacy.UiNavigationEvent; + liveEventDEPRECATED @8 :List(Deprecated.LiveEventData); + liveLocationDEPRECATED @25 :Deprecated.LiveLocationData; + ethernetDataDEPRECATED @26 :List(Deprecated.EthernetPacket); + cellInfoDEPRECATED @28 :List(Deprecated.CellInfo); + wifiScanDEPRECATED @29 :List(Deprecated.WifiScan); + uiNavigationEventDEPRECATED @50 :Deprecated.UiNavigationEvent; liveMapDataDEPRECATED @62 :LiveMapDataDEPRECATED; - gpsPlannerPointsDEPRECATED @40 :Legacy.GPSPlannerPoints; - gpsPlannerPlanDEPRECATED @41 :Legacy.GPSPlannerPlan; + gpsPlannerPointsDEPRECATED @40 :Deprecated.GPSPlannerPoints; + gpsPlannerPlanDEPRECATED @41 :Deprecated.GPSPlannerPlan; applanixRawDEPRECATED @42 :Data; - androidGnssDEPRECATED @30 :Legacy.AndroidGnss; - lidarPtsDEPRECATED @32 :Legacy.LidarPts; - navStatusDEPRECATED @38 :Legacy.NavStatus; - trafficEventsDEPRECATED @43 :List(Legacy.TrafficEvent); - liveLocationTimingDEPRECATED @44 :Legacy.LiveLocationData; - liveLocationCorrectedDEPRECATED @46 :Legacy.LiveLocationData; - navUpdateDEPRECATED @27 :Legacy.NavUpdate; - orbObservationDEPRECATED @47 :List(Legacy.OrbObservation); - locationDEPRECATED @49 :Legacy.LiveLocationData; - orbOdometryDEPRECATED @53 :Legacy.OrbOdometry; - orbFeaturesDEPRECATED @54 :Legacy.OrbFeatures; - applanixLocationDEPRECATED @55 :Legacy.LiveLocationData; - orbKeyFrameDEPRECATED @56 :Legacy.OrbKeyFrame; - orbFeaturesSummaryDEPRECATED @58 :Legacy.OrbFeaturesSummary; - featuresDEPRECATED @10 :Legacy.CalibrationFeatures; - kalmanOdometryDEPRECATED @65 :Legacy.KalmanOdometry; - uiLayoutStateDEPRECATED @57 :Legacy.UiLayoutState; + androidGnssDEPRECATED @30 :Deprecated.AndroidGnss; + lidarPtsDEPRECATED @32 :Deprecated.LidarPts; + navStatusDEPRECATED @38 :Deprecated.NavStatus; + trafficEventsDEPRECATED @43 :List(Deprecated.TrafficEvent); + liveLocationTimingDEPRECATED @44 :Deprecated.LiveLocationData; + liveLocationCorrectedDEPRECATED @46 :Deprecated.LiveLocationData; + navUpdateDEPRECATED @27 :Deprecated.NavUpdate; + orbObservationDEPRECATED @47 :List(Deprecated.OrbObservation); + locationDEPRECATED @49 :Deprecated.LiveLocationData; + orbOdometryDEPRECATED @53 :Deprecated.OrbOdometry; + orbFeaturesDEPRECATED @54 :Deprecated.OrbFeatures; + applanixLocationDEPRECATED @55 :Deprecated.LiveLocationData; + orbKeyFrameDEPRECATED @56 :Deprecated.OrbKeyFrame; + orbFeaturesSummaryDEPRECATED @58 :Deprecated.OrbFeaturesSummary; + featuresDEPRECATED @10 :Deprecated.CalibrationFeatures; + kalmanOdometryDEPRECATED @65 :Deprecated.KalmanOdometry; + uiLayoutStateDEPRECATED @57 :Deprecated.UiLayoutState; pandaStateDEPRECATED @12 :PandaState; - driverStateDEPRECATED @59 :DriverStateDEPRECATED; + driverStateDEPRECATED @59 :Deprecated.DriverStateDEPRECATED; sensorEventsDEPRECATED @11 :List(SensorEventData); lateralPlanDEPRECATED @64 :LateralPlan; - navModelDEPRECATED @104 :NavModelData; + navModelDEPRECATED @104 :Deprecated.NavModelData; uiPlanDEPRECATED @106 :UiPlan; liveLocationKalman @72 :LiveLocationKalman; - liveTracksDEPRECATED @16 :List(LiveTracksDEPRECATED); + liveTracksDEPRECATED @16 :List(Deprecated.LiveTracksDEPRECATED); onroadEventsDEPRECATED @68: List(Car.OnroadEventDEPRECATED); gyroscope2DEPRECATED @100 :SensorEventData; accelerometer2DEPRECATED @101 :SensorEventData; diff --git a/cereal/messaging/tests/validate_sp_cereal_upstream.py b/cereal/messaging/tests/validate_sp_cereal_upstream.py index 9ccd6533ce..d673f7db0b 100755 --- a/cereal/messaging/tests/validate_sp_cereal_upstream.py +++ b/cereal/messaging/tests/validate_sp_cereal_upstream.py @@ -1,222 +1,251 @@ #!/usr/bin/env python3 +"""Schema-level cereal compat check between sunnypilot and upstream openpilot. + +Rules (per struct matched across sides by typeId): + R1 shared ordinal must reference the same type. + R2 sunnypilot-only ordinal in a union -> FAIL (unknown discriminant upstream). + R3 sunnypilot-only ordinal on a regular field -> OK (additive struct evolution). + R4 upstream-only ordinal -> OK. + R5 sunnypilot-only struct referenced via an upstream-shared field -> FAIL. +""" + +from __future__ import annotations + import argparse +import json import sys -from typing import Any, List, Tuple +from typing import Any -DEBUG = False +NO_DISCRIMINANT = 0xFFFF -def print_debug(string: str) -> None: - if DEBUG: - print(string) +def hex_id(value: int) -> str: + return f"0x{value:016x}" -def create_schema_instance(struct: Any, prop: Tuple[str, Any]) -> Any: - """ - Create a new instance of a schema type, handling different field types. - - Args: - struct: The Cap'n Proto schema structure - prop: A tuple containing the field name and field metadata - - Returns: - A new initialized schema instance - """ - struct_instance = struct.new_message() - field_name, field_metadata = prop - - try: - field_type = field_metadata.proto.slot.type.which() - - # Initialize different types of fields - if field_type in ('list', 'text', 'data'): - struct_instance.init(field_name, 1) - print_debug(f"Initialized list/text/data field: {field_name}") - elif field_type in ('struct', 'object'): - struct_instance.init(field_name) - print_debug(f"Initialized struct/object field: {field_name}") - - return struct_instance - - except Exception as e: - print(f"Error creating instance for {field_name}: {e}") - return None +def encode_type(type_node: Any) -> dict: + which = type_node.which() + if which == "struct": + return {"kind": "struct", "typeId": hex_id(type_node.struct.typeId)} + if which == "enum": + return {"kind": "enum", "typeId": hex_id(type_node.enum.typeId)} + if which == "interface": + return {"kind": "interface", "typeId": hex_id(type_node.interface.typeId)} + if which == "list": + return {"kind": "list", "element": encode_type(type_node.list.elementType)} + if which == "anyPointer": + return {"kind": "anyPointer"} + return {"kind": which} -def get_schema_fields(schema_struct: Any) -> List[Tuple[str, Any]]: - """ - Retrieve all fields from a given schema structure. +def encode_field(name: str, field: Any) -> dict: + proto = field.proto + ordinal = proto.ordinal.explicit if proto.ordinal.which() == "explicit" else None + discriminant = proto.discriminantValue if proto.discriminantValue != NO_DISCRIMINANT else None - Args: - schema_struct: The Cap'n Proto schema structure + if proto.which() == "group": + type_desc = {"kind": "group", "typeId": hex_id(proto.group.typeId)} + else: + type_desc = encode_type(proto.slot.type) - Returns: - A list of field names and their metadata - """ - try: - # Get all fields from the schema - schema_fields = list(schema_struct.schema.fields.items()) - - print_debug("Discovered schema fields:") - for field_name, field_metadata in schema_fields: - print_debug(f"- {field_name}") - - return schema_fields - - except Exception as e: - print(f"Error retrieving schema fields: {e}") - return [] + return { + "name": name, + "ordinal": ordinal, + "discriminant": discriminant, + "type": type_desc, + } -def generate_schema_instances(schema_struct: Any) -> List[Any]: - """ - Generate instances for all fields in a given schema. - - Args: - schema_struct: The Cap'n Proto schema structure - - Returns: - A list of schema instances - """ - schema_fields = get_schema_fields(schema_struct) - instances = [] - - for field_prop in schema_fields: - try: - instance = create_schema_instance(schema_struct, field_prop) - if instance is not None: - instances.append(instance) - except Exception as e: - print(f"Skipping field due to error: {e}") - - print(f"Generated {len(instances)} schema instances") - return instances +def encode_struct(schema: Any) -> dict: + node = schema.node + return { + "typeId": hex_id(node.id), + "displayName": node.displayName, + "hasUnion": node.struct.discriminantCount > 0, + "fields": [encode_field(name, field) for name, field in schema.fields.items()], + } -def persist_instances(instances: List[Any], filename: str) -> None: - """ - Write schema instances to a binary file. - - Args: - instances: List of schema instances - filename: Output file path - """ - try: - with open(filename, 'wb') as f: - for instance in instances: - f.write(instance.to_bytes()) - - print(f"Successfully wrote {len(instances)} instances to {filename}") - - except Exception as e: - print(f"Error persisting instances: {e}") - sys.exit(1) +def _child_struct_schema(field: Any) -> Any: + proto = field.proto + if proto.which() == "group": + return field.schema + type_node = proto.slot.type + which = type_node.which() + if which == "struct": + return field.schema + if which == "list": + container = field.schema + element_type = type_node.list.elementType + while element_type.which() == "list": + container = container.elementType + element_type = element_type.list.elementType + if element_type.which() == "struct": + return container.elementType + return None -def read_instances(filename: str, schema_type: Any) -> List[Any]: - """ - Read schema instances from a binary file. - - Args: - filename: Input file path - schema_type: The schema type to use for reading - - Returns: - A list of read schema instances - """ - try: - with open(filename, 'rb') as f: - data = f.read() - - instances = list(schema_type.read_multiple_bytes(data)) - - print(f"Read {len(instances)} instances from {filename}") - return instances - - except Exception as e: - print(f"Error reading instances: {e}") - sys.exit(1) +def collect_schema(root: Any) -> dict[str, dict]: + structs: dict[str, dict] = {} + stack = [root] + while stack: + schema = stack.pop() + type_id = hex_id(schema.node.id) + if type_id in structs: + continue + structs[type_id] = encode_struct(schema) + for _name, field in schema.fields.items(): + try: + child = _child_struct_schema(field) + except Exception: + child = None + if child is not None: + stack.append(child) + return structs -def compare_schemas(original_instances: List[Any], read_instances: List[Any]) -> bool: - """ - Compare original and read-back instances to detect potential breaking changes. +def dump_schema(path: str) -> None: + from cereal import log + payload = { + "root": hex_id(log.Event.schema.node.id), + "structs": collect_schema(log.Event.schema), + } + with open(path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + print(f"wrote schema dump with {len(payload['structs'])} structs to {path}") - Args: - original_instances: List of originally generated instances - read_instances: List of instances read back from file - Returns: - Boolean indicating whether schemas appear compatible - """ - if len(original_instances) != len(read_instances): - print("❌ Schema Compatibility Warning: Instance count mismatch") +def types_equal(a: dict, b: dict) -> bool: + if a.get("kind") != b.get("kind"): return False - - compatible = True - for struct in read_instances: - try: - getattr(struct, struct.which()) # Attempting to access the field to validate readability - except Exception as e: - print(f"❌ Structural change detected: {struct.which()} is not readable.\nFull error: {e}") - compatible = False - - return compatible + kind = a["kind"] + if kind in ("struct", "enum", "interface", "group"): + return a.get("typeId") == b.get("typeId") + if kind == "list": + return types_equal(a["element"], b["element"]) + return True -def main(): - """ - CLI entry point for schema compatibility testing. - """ - # Setup argument parser +def type_repr(t: dict) -> str: + kind = t.get("kind", "?") + if kind in ("struct", "enum", "interface", "group"): + return f"{kind}({t.get('typeId')})" + if kind == "list": + return f"list<{type_repr(t['element'])}>" + return kind + + +def field_is_union_variant(field: dict) -> bool: + return field.get("discriminant") is not None + + +def index_fields_by_ordinal(struct: dict) -> dict[int, dict]: + indexed: dict[int, dict] = {} + for field in struct["fields"]: + ordinal = field.get("ordinal") + if ordinal is None: + continue + indexed[ordinal] = field + return indexed + + +def compare(sunnypilot_dump: dict, upstream_dump: dict) -> list[str]: + violations: list[str] = [] + sunnypilot_structs: dict[str, dict] = sunnypilot_dump["structs"] + upstream_structs: dict[str, dict] = upstream_dump["structs"] + + sunnypilot_struct_referenced_from_shared: set[str] = set() + + for type_id, sunnypilot_struct in sunnypilot_structs.items(): + upstream_struct = upstream_structs.get(type_id) + if upstream_struct is None: + continue + + sunnypilot_fields = index_fields_by_ordinal(sunnypilot_struct) + upstream_fields = index_fields_by_ordinal(upstream_struct) + display = sunnypilot_struct["displayName"] + + for ordinal, sunnypilot_field in sunnypilot_fields.items(): + upstream_field = upstream_fields.get(ordinal) + if upstream_field is None: + if field_is_union_variant(sunnypilot_field): + violations.append( + f"[R2] {display} @{ordinal} ('{sunnypilot_field['name']}', {type_repr(sunnypilot_field['type'])}): " + f"union variant not present upstream. upstream cannot parse this discriminant." + ) + continue + + if not types_equal(sunnypilot_field["type"], upstream_field["type"]): + violations.append( + f"[R1] {display} @{ordinal}: type mismatch. " + f"sunnypilot='{sunnypilot_field['name']}' {type_repr(sunnypilot_field['type'])} vs " + f"upstream='{upstream_field['name']}' {type_repr(upstream_field['type'])}." + ) + continue + + cursor = sunnypilot_field["type"] + while cursor.get("kind") == "list": + cursor = cursor["element"] + if cursor.get("kind") in ("struct", "group", "interface") and cursor.get("typeId"): + sunnypilot_struct_referenced_from_shared.add(cursor["typeId"]) + + for type_id, sunnypilot_struct in sunnypilot_structs.items(): + if type_id in upstream_structs: + continue + if type_id in sunnypilot_struct_referenced_from_shared: + violations.append( + f"[R5] struct {sunnypilot_struct['displayName']} ({type_id}) exists only on sunnypilot " + f"but is referenced from an upstream-shared field. upstream cannot resolve this type." + ) + + return violations + + +def load_peer(path: str) -> dict: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def run_read(peer_path: str) -> int: + from cereal import log + peer_dump = load_peer(peer_path) + local_dump = { + "root": hex_id(log.Event.schema.node.id), + "structs": collect_schema(log.Event.schema), + } + violations = compare(sunnypilot_dump=peer_dump, upstream_dump=local_dump) + + if not violations: + print("cereal compat OK: upstream openpilot can parse sunnypilot routes " + "(no leaked structs, no ordinal collisions).") + return 0 + + print(f"cereal compat FAIL: upstream openpilot would misparse sunnypilot routes " + f"({len(violations)} violation(s)):") + for v in violations: + print(f" {v}") + return 1 + + +def main() -> int: parser = argparse.ArgumentParser( - description='Cap\'n Proto Schema Compatibility Testing Tool', - epilog='Test schema compatibility by generating and reading back instances.' + description="sunnypilot <-> upstream cereal compatibility validator (schema-level)." ) - - # Add mutually exclusive group for generation or reading mode - mode_group = parser.add_mutually_exclusive_group(required=True) - mode_group.add_argument('-g', '--generate', action='store_true', - help='Generate schema instances') - mode_group.add_argument('-r', '--read', action='store_true', - help='Read and validate schema instances') - - # Common arguments - parser.add_argument('-f', '--file', - default='schema_instances.bin', - help='Output/input binary file (default: schema_instances.bin)') - - # Parse arguments + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("-g", "--generate", action="store_true", help="dump local schema to JSON") + mode.add_argument("-r", "--read", action="store_true", help="load peer JSON and diff against local") + parser.add_argument("-f", "--file", default="schema.json", help="JSON file path (default: schema.json)") args = parser.parse_args() - # Import the schema dynamically try: - from cereal import log - schema_type = log.Event - except ImportError: - print("Error: Unable to import schema. Ensure 'cereal' is installed.") - sys.exit(1) - - # Execute based on mode - if args.generate: - print("🔧 Generating Schema Instances") - instances = generate_schema_instances(schema_type) - persist_instances(instances, args.file) - print("✅ Instance generation complete") - - elif args.read: - print("🔍 Reading and Validating Schema Instances") - generated_instances = generate_schema_instances(schema_type) - read_back_instances = read_instances(args.file, schema_type) - - # Compare schemas - if compare_schemas(generated_instances, read_back_instances): - print("✅ Schema Compatibility: No breaking changes detected") - sys.exit(0) - else: - print("❌ Potential Schema Breaking Changes Detected") - sys.exit(1) + if args.generate: + dump_schema(args.file) + return 0 + return run_read(args.file) + except ImportError as exc: + print(f"error: cannot import cereal ({exc}). did scons build cereal?") + return 2 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/cereal/services.py b/cereal/services.py index a2fd5f7dce..9d64f67f2f 100755 --- a/cereal/services.py +++ b/cereal/services.py @@ -39,8 +39,8 @@ _services: dict[str, tuple] = { "roadEncodeIdx": (False, 20., 1), "liveTracks": (True, 20.), "sendcan": (True, 100., 139, QueueSize.MEDIUM), - "logMessage": (True, 0.), - "errorLogMessage": (True, 0., 1), + "logMessage": (True, 0., None, QueueSize.BIG), + "errorLogMessage": (True, 0., 1, QueueSize.BIG), "liveCalibration": (True, 4., 4), "liveTorqueParameters": (True, 4., 1), "liveDelay": (True, 4., 1), @@ -49,6 +49,7 @@ _services: dict[str, tuple] = { "carControl": (True, 100., 10), "carOutput": (True, 100., 10), "longitudinalPlan": (True, 20., 10), + "lateralManeuverPlan": (True, 20.), "driverAssistance": (True, 20., 20), "procLog": (True, 0.5, 15, QueueSize.BIG), "gpsLocationExternal": (True, 10., 10), diff --git a/common/.gitignore b/common/.gitignore deleted file mode 100644 index ce1da4c53c..0000000000 --- a/common/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.cpp diff --git a/common/SConscript b/common/SConscript index 15a0e5eff1..c9bd1c72d1 100644 --- a/common/SConscript +++ b/common/SConscript @@ -1,4 +1,4 @@ -Import('env', 'envCython', 'arch') +Import('env', 'envCython') common_libs = [ 'params.cc', diff --git a/common/filter_simple.py b/common/filter_simple.py index 212e1a8f40..b28c3d68f5 100644 --- a/common/filter_simple.py +++ b/common/filter_simple.py @@ -28,7 +28,7 @@ class BounceFilter(FirstOrderFilter): scale = self.dt / (1.0 / 60.0) # tuned at 60 fps self.velocity.x += (x - self.x) * self.bounce * scale * self.dt self.velocity.update(0.0) - if abs(self.velocity.x) < 1e-5: + if abs(self.velocity.x) < 1e-3: self.velocity.x = 0.0 self.x += self.velocity.x return self.x diff --git a/common/model.h b/common/model.h index 444727be93..d134ebd15e 100644 --- a/common/model.h +++ b/common/model.h @@ -1 +1 @@ -#define DEFAULT_MODEL "CD210 (Default)" +#define DEFAULT_MODEL "POP model (Default)" diff --git a/common/params_keys.h b/common/params_keys.h index 3164fe5365..35a7bc3bc4 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -82,6 +82,7 @@ inline static std::unordered_map keys = { {"LiveParametersV2", {PERSISTENT, BYTES}}, {"LiveTorqueParameters", {PERSISTENT | DONT_LOG, BYTES}}, {"LocationFilterInitialState", {PERSISTENT, BYTES}}, + {"LateralManeuverMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, {"LongitudinalManeuverMode", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, {"LongitudinalPersonality", {PERSISTENT | BACKUP, INT, std::to_string(static_cast(cereal::LongitudinalPersonality::STANDARD))}}, {"NetworkMetered", {PERSISTENT | BACKUP, BOOL}}, @@ -172,6 +173,7 @@ inline static std::unordered_map keys = { {"OnroadScreenOffBrightness", {PERSISTENT | BACKUP, INT, "0"}}, {"OnroadScreenOffBrightnessMigrated", {PERSISTENT | BACKUP, STRING, "0.0"}}, {"OnroadScreenOffTimer", {PERSISTENT | BACKUP, INT, "15"}}, + {"OnroadScreenOffTimerMigrated", {PERSISTENT | BACKUP, STRING, "0.0"}}, {"OnroadUploads", {PERSISTENT | BACKUP, BOOL, "1"}}, {"QuickBootToggle", {PERSISTENT | BACKUP, BOOL, "0"}}, {"QuietMode", {PERSISTENT | BACKUP, BOOL, "0"}}, @@ -270,7 +272,7 @@ inline static std::unordered_map keys = { {"EnforceTorqueControl", {PERSISTENT | BACKUP, BOOL}}, {"LiveTorqueParamsToggle", {PERSISTENT | BACKUP , BOOL}}, {"LiveTorqueParamsRelaxedToggle", {PERSISTENT | BACKUP , BOOL}}, - {"TorqueControlTune", {PERSISTENT | BACKUP, FLOAT}}, + {"TorqueControlTune", {PERSISTENT | BACKUP, FLOAT, "0.0"}}, {"TorqueParamsOverrideEnabled", {PERSISTENT | BACKUP, BOOL, "0"}}, {"TorqueParamsOverrideFriction", {PERSISTENT | BACKUP, FLOAT, "0.1"}}, {"TorqueParamsOverrideLatAccelFactor", {PERSISTENT | BACKUP, FLOAT, "2.5"}}, diff --git a/common/time_helpers.py b/common/time_helpers.py index 8564e270c2..c709182d45 100644 --- a/common/time_helpers.py +++ b/common/time_helpers.py @@ -2,6 +2,7 @@ import datetime from pathlib import Path MIN_DATE = datetime.datetime(year=2025, month=2, day=21) +MAX_DATE = datetime.datetime(year=2035, month=1, day=1) def min_date(): # on systemd systems, the default time is the systemd build time @@ -12,4 +13,4 @@ def min_date(): return MIN_DATE def system_time_valid(): - return datetime.datetime.now() > min_date() + return min_date() < datetime.datetime.now() < MAX_DATE diff --git a/common/transformations/.gitignore b/common/transformations/.gitignore deleted file mode 100644 index a67290f09a..0000000000 --- a/common/transformations/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -transformations -transformations.cpp diff --git a/common/utils.py b/common/utils.py index faaa96ecbc..28b9274d82 100644 --- a/common/utils.py +++ b/common/utils.py @@ -131,11 +131,11 @@ def get_upload_stream(filepath: str, should_compress: bool) -> tuple[io.Buffered return compressed_stream, compressed_size -# remove all keys that end in DEPRECATED +# remove all keys that end in DEPRECATED, plus any "deprecated" group def strip_deprecated_keys(d): for k in list(d.keys()): if isinstance(k, str): - if k.endswith('DEPRECATED'): + if k.endswith('DEPRECATED') or k == 'deprecated': d.pop(k) elif isinstance(d[k], dict): strip_deprecated_keys(d[k]) diff --git a/common/version.h b/common/version.h index 7e78d64b22..41440556c5 100644 --- a/common/version.h +++ b/common/version.h @@ -1 +1 @@ -#define COMMA_VERSION "0.10.4" +#define COMMA_VERSION "0.11.1" diff --git a/conftest.py b/conftest.py index 0bd638b87d..2f2db08d5d 100644 --- a/conftest.py +++ b/conftest.py @@ -10,7 +10,6 @@ from openpilot.system.hardware import TICI, HARDWARE # TODO: pytest-cpp doesn't support FAIL, and we need to create test translations in sessionstart # pending https://github.com/pytest-dev/pytest-cpp/pull/147 collect_ignore = [ - "selfdrive/ui/tests/test_translations", "selfdrive/test/process_replay/test_processes.py", "selfdrive/test/process_replay/test_regen.py", ] diff --git a/docs/CARS.md b/docs/CARS.md index 31f6f2d32b..288d5b7a47 100644 --- a/docs/CARS.md +++ b/docs/CARS.md @@ -4,24 +4,25 @@ 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. -# 336 Supported Cars +# 340 Supported Cars |Make|Model|Supported Package|ACC|No ACC accel below|No ALC below|Steering Torque|Resume from stop|Hardware Needed
 |Video|Setup Video| |---|---|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| |Acura|ILX 2016-18|Technology Plus Package or AcuraWatch Plus|openpilot|26 mph|25 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Acura|ILX 2019|All|openpilot|26 mph|25 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Acura|MDX 2022-24|All|openpilot available[1](#footnotes)|0 mph|43 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Acura|MDX 2025-26|All except Type S|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Acura|RDX 2016-18|AcuraWatch Plus or Advance Package|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Acura|RDX 2019-21|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Acura|TLX 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Acura|TLX 2021-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Acura|TLX 2025|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Audi|A3 2014-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Audi|A3 Sportback e-tron 2017-18|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Audi|Q2 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Audi|Q3 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Audi|RS3 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Audi|S3 2015-17|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Chevrolet|Bolt EUV 2022-23|Premier or Premier Redline Trim, without Super Cruise Package|openpilot available[1](#footnotes)|3 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here
||| +|Audi[11](#footnotes)|A3 2014-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Audi[11](#footnotes)|A3 Sportback e-tron 2017-18|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Audi[11](#footnotes)|Q2 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Audi[11](#footnotes)|Q3 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Audi[11](#footnotes)|RS3 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Audi[11](#footnotes)|S3 2015-17|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Chevrolet|Bolt EUV 2022-23|Premier or Premier Redline Trim, without Super Cruise Package|openpilot available[1](#footnotes)|3 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here
||| |Chevrolet|Bolt EV 2022-23|2LT Trim with Adaptive Cruise Control Package|openpilot available[1](#footnotes)|3 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here
||| |Chevrolet|Equinox 2019-22|Adaptive Cruise Control (ACC)|openpilot available[1](#footnotes)|3 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here
||| |Chevrolet|Silverado 1500 2020-21|Safety Package II|openpilot available[1](#footnotes)|0 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here
||| @@ -31,34 +32,34 @@ A supported vehicle is one that just works when you install a comma device. All |Chrysler|Pacifica 2021-23|All|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Chrysler|Pacifica Hybrid 2017-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Chrysler|Pacifica Hybrid 2019-25|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|comma|body|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|None||| -|CUPRA|Ateca 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|comma|body|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|None||| +|CUPRA[11](#footnotes)|Ateca 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Dodge|Durango 2020-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Bronco Sport 2021-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Escape 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Ford|Escape 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|Escape 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Ford|Escape Hybrid 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Ford|Escape Hybrid 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|Escape Hybrid 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Ford|Escape Plug-in Hybrid 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Ford|Escape Plug-in Hybrid 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Ford|Expedition 2022-24|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|Escape Plug-in Hybrid 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|Expedition 2022-24|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Ford|Explorer 2020-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Explorer Hybrid 2020-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Ford|F-150 2021-23|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Ford|F-150 Hybrid 2021-23|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|F-150 2021-23|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|F-150 Hybrid 2021-23|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Ford|Focus 2018[2](#footnotes)|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Focus Hybrid 2018[2](#footnotes)|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Kuga 2020-23|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Kuga Hybrid 2020-23|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Ford|Kuga Hybrid 2024|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|Kuga Hybrid 2024|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Ford|Kuga Plug-in Hybrid 2020-23|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Ford|Kuga Plug-in Hybrid 2024|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|Kuga Plug-in Hybrid 2024|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Ford|Maverick 2022|LARIAT Luxury|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Maverick 2023-24|Co-Pilot360 Assist|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Maverick Hybrid 2022|LARIAT Luxury|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Maverick Hybrid 2023-24|Co-Pilot360 Assist|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Ford|Mustang Mach-E 2021-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Ford|Ranger 2024|Adaptive Cruise Control with Lane Centering|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|Mustang Mach-E 2021-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Ford|Ranger 2024|Adaptive Cruise Control with Lane Centering|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Genesis|G70 2018|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai F connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Genesis|G70 2019-21|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai F connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Genesis|G70 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -73,18 +74,18 @@ A supported vehicle is one that just works when you install a comma device. All |Genesis|GV70 Electrified (Australia Only) 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai Q connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Genesis|GV70 Electrified (with HDA II) 2023-24|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai Q connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Genesis|GV80 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai M connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|GMC|Sierra 1500 2020-21|Driver Alert Package II|openpilot available[1](#footnotes)|0 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here
||| -|Honda|Accord 2018-22|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|GMC|Sierra 1500 2020-21|Driver Alert Package II|openpilot available[1](#footnotes)|0 mph|6 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Accord 2018-22|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Accord 2023-25|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Accord Hybrid 2018-22|All|openpilot available[1](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Accord Hybrid 2023-25|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|City (Brazil only) 2023|All|openpilot available[1](#footnotes)|0 mph|14 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Honda|Civic 2016-18|Honda Sensing|openpilot|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Honda|Civic 2019-21|All|openpilot available[1](#footnotes)|0 mph|2 mph[4](#footnotes)|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Honda|Civic 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Civic 2016-18|Honda Sensing|openpilot|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Civic 2019-21|All|openpilot available[1](#footnotes)|0 mph|2 mph[4](#footnotes)|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Civic 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Civic Hatchback 2017-18|Honda Sensing|openpilot available[1](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Civic Hatchback 2019-21|All|openpilot available[1](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Honda|Civic Hatchback 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Civic Hatchback 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Civic Hatchback Hybrid 2025-26|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Civic Hatchback Hybrid (Europe only) 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Civic Hybrid 2025-26|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -103,6 +104,7 @@ A supported vehicle is one that just works when you install a comma device. All |Honda|N-Box 2018|All|openpilot available[1](#footnotes)|0 mph|11 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Odyssey 2018-20|Honda Sensing|openpilot|26 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Odyssey 2021-26|All|openpilot available[1](#footnotes)|0 mph|43 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Odyssey (Singapore) 2021|Honda Sensing|openpilot|19 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Odyssey (Taiwan) 2018-19|Honda Sensing|openpilot|19 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Passport 2019-25|All|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Passport 2026|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -115,9 +117,9 @@ A supported vehicle is one that just works when you install a comma device. All |Hyundai|Custin 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Elantra 2017-18|Smart Cruise Control (SCC)|Stock|19 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Elantra 2019|Smart Cruise Control (SCC)|Stock|19 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Hyundai|Elantra 2021-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Elantra 2021-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Elantra GT 2017-20|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Hyundai|Elantra Hybrid 2021-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Elantra Hybrid 2021-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Genesis 2015-16|Smart Cruise Control (SCC)|Stock|19 mph|37 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai J connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|i30 2017-19|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Ioniq 5 (Southeast Asia and Europe only) 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai Q connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -134,17 +136,17 @@ A supported vehicle is one that just works when you install a comma device. All |Hyundai|Kona 2022-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai O connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Kona Electric 2018-21|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Kona Electric 2022-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai O connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Hyundai|Kona Electric (with HDA II, Korea only) 2023|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai R connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Kona Electric (with HDA II, Korea only) 2023|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai R connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Kona Hybrid 2020|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai I connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Nexo 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Hyundai|Palisade 2020-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Palisade 2020-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Santa Cruz 2022-24|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Hyundai|Santa Fe 2019-20|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai D connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Hyundai|Santa Fe 2021-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Santa Fe 2019-20|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai D connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Santa Fe 2021-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Santa Fe Hybrid 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Santa Fe Plug-in Hybrid 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Sonata 2018-19|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Hyundai|Sonata 2020-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Hyundai|Sonata 2020-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Sonata Hybrid 2020-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Staria 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Tucson 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -154,8 +156,8 @@ A supported vehicle is one that just works when you install a comma device. All |Hyundai|Tucson Hybrid 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Tucson Plug-in Hybrid 2024|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Veloster 2019-20|Smart Cruise Control (SCC)|Stock|5 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Jeep|Grand Cherokee 2016-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Jeep|Grand Cherokee 2019-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Jeep|Grand Cherokee 2016-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Jeep|Grand Cherokee 2019-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Carnival 2022-24|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Carnival (China only) 2023|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Ceed 2019-21|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -168,16 +170,16 @@ A supported vehicle is one that just works when you install a comma device. All |Kia|K5 Hybrid 2020-22|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|K7 2017|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|K8 Hybrid (with HDA II) 2023|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai Q connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Kia|Niro EV 2019|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Kia|Niro EV 2020|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai F connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Kia|Niro EV 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Kia|Niro EV 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Kia|Niro EV (with HDA II) 2025|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai R connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro EV 2019|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro EV 2020|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai F connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro EV 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro EV 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro EV (with HDA II) 2024-25|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai R connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Niro EV (without HDA II) 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Niro Hybrid 2018|Smart Cruise Control (SCC)|Stock|10 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Niro Hybrid 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai D connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Niro Hybrid 2022|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai F connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Kia|Niro Hybrid 2023|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Niro Hybrid 2023-24|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Niro Plug-in Hybrid 2018-19|All|Stock|10 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Niro Plug-in Hybrid 2020|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai D connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Niro Plug-in Hybrid 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai D connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -186,21 +188,21 @@ A supported vehicle is one that just works when you install a comma device. All |Kia|Optima 2019-20|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Optima Hybrid 2019|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Seltos 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Kia|Sorento 2018|Advanced Smart Cruise Control & LKAS|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Kia|Sorento 2019|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Sorento 2018|Advanced Smart Cruise Control & LKAS|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Sorento 2019|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Sorento 2021-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Sorento Hybrid 2021-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Sorento Plug-in Hybrid 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Sportage 2023-24|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Sportage Hybrid 2023|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Kia|Stinger 2018-20|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Kia|Stinger 2018-20|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Stinger 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|Telluride 2020-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Lexus|CT Hybrid 2017-18|Lexus Safety System+|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Lexus|ES 2017-18|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Lexus|ES 2019-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Lexus|ES Hybrid 2017-18|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Lexus|ES Hybrid 2019-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Lexus|ES Hybrid 2019-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Lexus|GS F 2016|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Lexus|IS 2017-19|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Lexus|IS 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -221,23 +223,25 @@ A supported vehicle is one that just works when you install a comma device. All |Lexus|UX Hybrid 2019-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Lincoln|Aviator 2020-24|Co-Pilot360 Plus|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Lincoln|Aviator Plug-in Hybrid 2020-24|Co-Pilot360 Plus|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|MAN|eTGE 2020-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|MAN|TGE 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|MAN[11](#footnotes)|eTGE 2020-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|MAN[11](#footnotes)|TGE 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Mazda|CX-5 2022-25|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Mazda connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Mazda|CX-9 2021-23|All|Stock|0 mph|28 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Mazda connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Mazda|CX-9 2021-23|All|Stock|0 mph|28 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Mazda connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Nissan[5](#footnotes)|Altima 2019-20, 2024|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Nissan[5](#footnotes)|Leaf 2018-23|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Nissan[5](#footnotes)|Leaf 2018-23|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Nissan[5](#footnotes)|Rogue 2018-20|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Nissan[5](#footnotes)|X-Trail 2017|ProPILOT Assist|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Ram|1500 2019-24|Adaptive Cruise Control (ACC)|Stock|32 mph|1 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Ram connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ram|2500 2020-24|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Ram connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ram|3500 2019-22|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Ram connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Rivian|R1S 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Rivian|R1T 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|SEAT|Ateca 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|SEAT|Leon 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Rivian|R1S 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Rivian|R1S 2025|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian B connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Rivian|R1T 2022-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Rivian|R1T 2025|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Rivian B connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|SEAT[11](#footnotes)|Ateca 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|SEAT[11](#footnotes)|Leon 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Subaru|Ascent 2019-21|All[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|Crosstrek 2018-19|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|Crosstrek 2018-19|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| |Subaru|Crosstrek 2020-23|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| |Subaru|Forester 2017-18|EyeSight Driver Assistance[6](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| |Subaru|Forester 2019-21|All[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| @@ -248,17 +252,17 @@ A supported vehicle is one that just works when you install a comma device. All |Subaru|Outback 2015-17|EyeSight Driver Assistance[6](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| |Subaru|Outback 2018-19|EyeSight Driver Assistance[6](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| |Subaru|Outback 2020-22|All[6](#footnotes)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru B connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Subaru|XV 2018-19|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| +|Subaru|XV 2018-19|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| |Subaru|XV 2020-21|EyeSight Driver Assistance[6](#footnotes)|openpilot available[1,7](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
Tools- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep)
||| -|Škoda|Fabia 2022-23[13](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[15](#footnotes)||| -|Škoda|Kamiq 2021-23[11,13](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[15](#footnotes)||| -|Škoda|Karoq 2019-23[13](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Škoda|Kodiaq 2017-23[13](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Škoda|Octavia 2015-19[13](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Škoda|Octavia RS 2016[13](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Škoda|Octavia Scout 2017-19[13](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Škoda|Scala 2020-23[13](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[15](#footnotes)||| -|Škoda|Superb 2015-22[13](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Škoda|Fabia 2022-23[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[16](#footnotes)||| +|Škoda|Kamiq 2021-23[12,14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[16](#footnotes)||| +|Škoda[11](#footnotes)|Karoq 2019-23[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Škoda[11](#footnotes)|Kodiaq 2017-23[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Škoda[11](#footnotes)|Octavia 2015-19[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Škoda[11](#footnotes)|Octavia RS 2016[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Škoda[11](#footnotes)|Octavia Scout 2017-19[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Škoda|Scala 2020-23[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[16](#footnotes)||| +|Škoda[11](#footnotes)|Superb 2015-22[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Tesla[9](#footnotes)|Model 3 (with HW3) 2019-23[8](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Tesla[9](#footnotes)|Model 3 (with HW4) 2024-25[8](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Tesla[9](#footnotes)|Model Y (with HW3) 2020-23[8](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| @@ -275,75 +279,75 @@ A supported vehicle is one that just works when you install a comma device. All |Toyota|C-HR 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|C-HR Hybrid 2017-20|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|C-HR Hybrid 2021-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Toyota|Camry 2018-20|All|Stock|0 mph[10](#footnotes)|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Camry 2018-20|All|Stock|0 mph[10](#footnotes)|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Camry 2021-24|All|openpilot|0 mph[10](#footnotes)|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Toyota|Camry Hybrid 2018-20|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Camry Hybrid 2018-20|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Camry Hybrid 2021-24|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Corolla 2017-19|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Toyota|Corolla 2020-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Corolla 2020-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Corolla Cross (Non-US only) 2020-23|All|openpilot|17 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Corolla Cross Hybrid (Non-US only) 2020-22|All|openpilot|17 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Toyota|Corolla Hatchback 2019-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Corolla Hatchback 2019-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Corolla Hybrid 2020-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Corolla Hybrid (South America only) 2020-23|All|openpilot|17 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Toyota|Highlander 2017-19|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Highlander 2017-19|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Highlander 2020-23|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Highlander Hybrid 2017-19|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Highlander Hybrid 2020-23|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Mirai 2021|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Toyota|Prius 2016|Toyota Safety Sense P|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Toyota|Prius 2017-20|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Toyota|Prius 2021-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Toyota|Prius Prime 2017-20|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Toyota|Prius Prime 2021-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Prius 2016|Toyota Safety Sense P|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Prius 2017-20|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Prius 2021-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Prius Prime 2017-20|All|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Prius Prime 2021-22|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Prius v 2017|Toyota Safety Sense P|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|RAV4 2016|Toyota Safety Sense P|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|RAV4 2017-18|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Toyota|RAV4 2019-21|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|RAV4 2019-21|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|RAV4 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|RAV4 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Toyota|RAV4 Hybrid 2016|Toyota Safety Sense P|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Toyota|RAV4 Hybrid 2017-18|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|RAV4 Hybrid 2016|Toyota Safety Sense P|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|RAV4 Hybrid 2017-18|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|RAV4 Hybrid 2019-21|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Toyota|RAV4 Hybrid 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Toyota|RAV4 Hybrid 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Toyota|Sienna 2018-20|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| -|Volkswagen|Arteon 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Arteon eHybrid 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Arteon R 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Arteon Shooting Brake 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Atlas 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Atlas Cross Sport 2020-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|California 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Caravelle 2020|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|CC 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Crafter 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|e-Crafter 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|e-Golf 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Golf 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Golf Alltrack 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Golf GTD 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Golf GTE 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Golf GTI 2015-21|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Golf R 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Golf SportsVan 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Grand California 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Jetta 2019-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Jetta GLI 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Passat 2015-22[12](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Passat Alltrack 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Passat GTE 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Polo 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[15](#footnotes)||| -|Volkswagen|Polo GTI 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[15](#footnotes)||| -|Volkswagen|T-Cross 2021|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[15](#footnotes)||| -|Volkswagen|T-Roc 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Taos 2022-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Teramont 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Teramont Cross Sport 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Teramont X 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Tiguan 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Tiguan eHybrid 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Touran 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,14](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Toyota|RAV4 Hybrid 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|RAV4 Hybrid 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Toyota|Sienna 2018-20|All|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Arteon 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Arteon eHybrid 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Arteon R 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Arteon Shooting Brake 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Atlas 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Atlas Cross Sport 2020-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|California 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Caravelle 2020|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|CC 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Crafter 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|e-Crafter 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|e-Golf 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Golf 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Golf Alltrack 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Golf GTD 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Golf GTE 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Golf GTI 2015-21|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Golf R 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Golf SportsVan 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Grand California 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Jetta 2019-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Jetta GLI 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Passat 2015-22[13](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Passat Alltrack 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Passat GTE 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|Polo 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[16](#footnotes)||| +|Volkswagen|Polo GTI 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[16](#footnotes)||| +|Volkswagen|T-Cross 2021|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[16](#footnotes)||| +|Volkswagen[11](#footnotes)|T-Roc 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Taos 2022-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Teramont 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Teramont Cross Sport 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Teramont X 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Tiguan 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Tiguan eHybrid 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[11](#footnotes)|Touran 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| ### Footnotes 1openpilot Longitudinal Control (Alpha) is available behind a toggle; the toggle is only available in non-release branches such as `nightly-dev`.
@@ -356,11 +360,12 @@ A supported vehicle is one that just works when you install a comma device. All 8Some 2023 model years have HW4. To check which hardware type your vehicle has, look for Autopilot computer under Software -> Additional Vehicle Information on your vehicle's touchscreen. See this page for more information.
9See more setup details for Tesla.
10openpilot operates above 28mph for Camry 4CYL L, 4CYL LE and 4CYL SE which don't have Full-Speed Range Dynamic Radar Cruise Control.
-11Not including the China market Kamiq, which is based on the (currently) unsupported PQ34 platform.
-12Refers only to the MQB-based European B8 Passat, not the NMS Passat in the USA/China/Mideast markets.
-13Some Škoda vehicles are equipped with heated windshields, which are known to block GPS signal needed for some comma four functionality.
-14Only available for vehicles using a gateway (J533) harness. At this time, vehicles using a camera harness are limited to using stock ACC.
-15Model-years 2022 and beyond may have a combined CAN gateway and BCM, which is supported by openpilot in software, but doesn't yet have a harness available from the comma store.
+11The J533 harness plugs in at the CAN gateway under the dashboard, just above the steering column. More information can be found at this guide.
+12Not including the China market Kamiq, which is based on the (currently) unsupported PQ34 platform.
+13Refers only to the MQB-based European B8 Passat, not the NMS Passat in the USA/China/Mideast markets.
+14Some Škoda vehicles are equipped with heated windshields, which are known to block GPS signal needed for some comma four functionality.
+15Only available for vehicles using a gateway (J533) harness. At this time, vehicles using a camera harness are limited to using stock ACC.
+16Model-years 2022 and beyond may have a combined CAN gateway and BCM, which is supported by openpilot in software, but doesn't yet have a harness available from the comma store.
## 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/). diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 62468c7448..3d39420c01 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -39,7 +39,7 @@ All of these are examples of good PRs: ### First contribution [Projects / openpilot bounties](https://github.com/orgs/commaai/projects/26/views/1?pane=info) is the best place to get started and goes in-depth on what's expected when working on a bounty. -There are a lot of bounties that don't require a comma 3X or a car. +There are a lot of bounties that don't require a comma four or a car. ## Pull Requests diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000000..e803a3fb8a --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,24 @@ +# 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/README.md b/docs/README.md deleted file mode 100644 index 12d0b6f5dd..0000000000 --- a/docs/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# openpilot docs - -This 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). - -## Development -NOTE: 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 -mkdocs build -``` - -**3. Run the new site locally** -``` bash -mkdocs serve -``` - -References: -* https://www.mkdocs.org/getting-started/ -* https://github.com/ntno/mkdocs-terminal diff --git a/docs/assets/comma-logo.png b/docs/assets/comma-logo.png new file mode 120000 index 0000000000..2838d92bfb --- /dev/null +++ b/docs/assets/comma-logo.png @@ -0,0 +1 @@ +../../selfdrive/assets/icons_mici/settings/comma_icon.png \ No newline at end of file diff --git a/docs/car-porting/brand-port.md b/docs/car-porting/brand-port.md deleted file mode 100644 index a3daa7a848..0000000000 --- a/docs/car-porting/brand-port.md +++ /dev/null @@ -1,5 +0,0 @@ -# Developing a car brand port - -A brand port is a port of openpilot to a substantially new car brand or platform within a brand. - -Here's an example of one: https://github.com/commaai/openpilot/pull/23331. diff --git a/docs/car-porting/car-state-signals.md b/docs/car-porting/car-state-signals.md deleted file mode 100644 index 669bd0ee23..0000000000 --- a/docs/car-porting/car-state-signals.md +++ /dev/null @@ -1,65 +0,0 @@ -# CarState signals - -## Required for basic lateral control - -* `brakePressed` -* `cruiseState` -* `doorOpen` -* `espDisabled` -* `gasPressed` -* `gearShifter` -* `leftBlinker` / `rightBlinker` -* `seatbeltUnlatched` -* `standstill` -* `steeringAngleDeg` -* `steeringPressed` -* `steeringTorque` -* `steerFaultPermanent` -* `steerFaultTemporary` -* `vCruise` -* `wheelSpeeds.[fl|fr|rl|rr]`: Speed of each of the car's four wheels, in m/s. The car's CAN bus often broadcasts the -speed in kph, so the helper function `parse_wheel_speeds` performs this conversion by default. - -## Recommended / Required for openpilot longitudinal control - -* `accFaulted` -* `espActive` -* `parkingBrake` - -## Application Dependent - -* `blockPcmEnable` -* `buttonEnable` -* `brakeHoldActive` -* `carFaultedNonCritical` -* `invalidLkasSetting` -* `lowSpeedAlert` -* `regenBraking` -* `steeringAngleOffsetDeg` -* `steeringDisengage` -* `steeringTorqueEps` -* `stockLkas` -* `vCruiseCluster` -* `vEgoCluster` -* `vehicleSensorsInvalid` - -## Automatically populated - -* `buttonEvents` - -These values are populated automatically by `parse_wheel_speeds`: - -* `aEgo`: Acceleration of the ego vehicle, Kalman filtered derivative of `vEgo`. -* `vEgo`: Speed of the ego vehicle, Kalman filtered from `vEgoRaw`. -* `vEgoRaw`: Speed of the ego vehicle, based on the average of all four wheel speeds, unfiltered. - -## Optional - -* `brake` -* `charging` -* `fuelGauge` -* `leftBlindspot` / `rightBlindspot` -* `steeringRateDeg` -* `stockAeb` -* `stockFcw` -* `yawRate` diff --git a/docs/car-porting/model-port.md b/docs/car-porting/model-port.md deleted file mode 100644 index e148a40ecb..0000000000 --- a/docs/car-porting/model-port.md +++ /dev/null @@ -1,5 +0,0 @@ -# Developing a car model port - -A model port is a port of openpilot to a new car model within an already supported brand. Model ports are easier than brand ports because the car's existing APIs are already known. - -Here's an example of one: https://github.com/commaai/openpilot/pull/30672/. diff --git a/docs/car-porting/reverse-engineering.md b/docs/car-porting/reverse-engineering.md deleted file mode 100644 index 128ec8e776..0000000000 --- a/docs/car-porting/reverse-engineering.md +++ /dev/null @@ -1,85 +0,0 @@ -# Stimulus-Response Tests - -These are example test drives that can help identify the CAN bus messaging necessary for ADAS control. Each scripted -test should be done in a separate route (ignition cycle). These tests are a guide, not necessarily exhaustive. - -While testing, constant power to the comma device is highly recommended, using [comma power](https://comma.ai/shop/comma-power) if -necessary to make sure all test activity is fully captured and for ease of uploading. If constant power isn't -available, keep the ignition on for at least one minute after your test to make sure power loss doesn't result -in loss of the last minute of testing data. - -## Stationary ignition-only tests, part 1 - -1. Ignition on, but don't start engine, remain in Park -2. Open and close each door in a defined order: driver, passenger, rear left, rear right -3. Re-enter the vehicle, close the driver's door, and fasten the driver's seatbelt -4. Slowly press and release the accelerator pedal 3 times -5. Slowly press and release the brake pedal 3 times -6. Hold the brake and move the gearshift to reverse, then neutral, then drive, then sport/eco/etc if applicable -7. Return to Park, ignition off - -Brake-pressed information may show up in several messages and signals, both as on/off states and as a percentage or -pressure. It may reflect a switch on the driver's brake pedal, or a pressure-threshold state, or signals to turn on -the rear brake lights. Start by identifying all the potential signals, and confirm while driving with ACC later. - -Locate signals for all four door states if possible, but some cars only expose the driver's door state on the ADAS bus. -Driver/passenger door signals may or may not change positions for LHD vs RHD cars. For cars where only the driver's -door signal is available, the same signal may follow the driver. - -## Stationary ignition-only tests, part 2 - -1. Ignition on, but don't start engine, remain in Park -2. Press each ACC button in a defined order: main switch on/off, set, resume, cancel, accel, decel, gap adjust -3. Set the left turn signal for about five seconds -4. Operate the left turn signal one time in its touch-to-pass mode -5. Set the right turn signal for about five seconds -6. Operate the right turn signal one time in its touch-to-pass mode -7. Set the hazard / emergency indicator switch for about five seconds -8. Ignition off - -Your vehicle may have a momentary-press main ACC switch or a physical toggle that remains set. Actual ACC engagement -isn't necessary for purposes of detecting the ACC button presses. - -## Steering angle and steering torque tests - -Power steering should be available. On ICE cars, engine RPM may be present. - -1. Ignition on, start engine if applicable, remain in Park -2. Rotate the steering wheel as follows, with a few seconds pause between each step - * Start as close to exact center as possible - * Turn to 45 degrees right and hold - * Turn to 90 degrees right and hold - * Turn to 180 degrees right and hold - * Turn to full lock right and hold, with firm pressure against lock - * Release the wheel and allow it to bounce back slightly from lock - * Turn to 180 degrees left and hold - * Return to center and release -3. Ignition off - -Performing the full test to the right, followed by an abbreviated test to the left, helps give additional confirmation -of signal scale, and sign/direction for both the steering wheel angle and driver input torque signals. - -## Low speed / parking lot driving tests - -Before this test, drive to a place like an empty parking lot where you are free to drive in a series of curves. - -1. Ignition on, start engine if applicable, prepare to drive -2. Slowly (10-20mph at most) drive a figure-8 if possible, or at least one sharp left and one sharp right. -3. Come to a complete stop -4. When and where safe, drive in reverse for a short distance (10-15 feet) -5. Park the car in a safe place, ignition off - -## High speed / highway driving tests - -Select a place and time where you can safely set cruise control at normal travel speeds with little interference from -traffic ahead, and safely test the response of your factory lane guidance system. - -1. Ignition on, start engine if applicable, prepare to drive -2. When safely able, engage adaptive cruise control below 50 mph -3. When safely able, use the ACC buttons to accelerate to 50mph, then 55mph, then 60mph -4. Disengage adaptive cruise -5. When safely able, allow your factory lane guidance to prevent lane departures, 2-3 times on both the left and right - -The series of setpoints can be adjusted to local traffic regulations, and of course metric units. The specific cruise -setpoints are useful for locating the ACC HUD signals later, and confirming their precise scaling. When the car reaches -and holds the setpoint, that can also provide additional confirmation of wheel speed scaling. diff --git a/docs/concepts/glossary.md b/docs/concepts/glossary.md index a09b0f0785..4f4dd54756 100644 --- a/docs/concepts/glossary.md +++ b/docs/concepts/glossary.md @@ -1,9 +1,3 @@ # openpilot 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**: this is 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 3X**: the latest hardware by comma.ai for running openpilot. more info at [comma.ai/shop](https://comma.ai/shop). +{{GLOSSARY_DEFINITIONS}} diff --git a/docs/concepts/logs.md b/docs/concepts/logs.md index 46ab2897df..e533d36297 100644 --- a/docs/concepts/logs.md +++ b/docs/concepts/logs.md @@ -6,9 +6,9 @@ Check out our [Python library](https://github.com/commaai/openpilot/blob/master/ For each segment, openpilot records the following log types: -## rlog.bz2 +## rlog.zst -rlogs contain all the messages passed amongst openpilot's processes. See [cereal/services.py](https://github.com/commaai/cereal/blob/master/services.py) for a list of all the logged services. They're a bzip2 archive of the serialized capnproto messages. +rlogs contain all the messages passed amongst openpilot's processes. See [cereal/services.py](https://github.com/commaai/openpilot/blob/master/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 @@ -18,12 +18,10 @@ Each camera stream is H.265 encoded and written to its respective file. * `ecamera.hevc` is the wide road camera * `dcamera.hevc` is the driver camera -## qlog.bz2 & qcamera.ts +## qlog.zst & qcamera.ts qlogs are a decimated subset of the rlogs. Check out [cereal/services.py](https://github.com/commaai/cereal/blob/master/services.py) for the decimation. - qcameras are H.264 encoded, lower res versions of the fcamera.hevc. The video shown in [comma connect](https://connect.comma.ai/) is from the qcameras. - -qlogs and qcameras are designed to be small enough to upload instantly on slow internet and store forever, yet useful enough for most analysis and debugging. +qlogs and qcameras are designed to be small enough to upload instantly on slow internet, yet useful enough for most analysis and debugging. diff --git a/docs/contributing/feedback.md b/docs/contributing/feedback.md new file mode 100644 index 0000000000..335d24e13a --- /dev/null +++ b/docs/contributing/feedback.md @@ -0,0 +1,36 @@ +# How to Give Feedback + +Feedback is one of the highest leverage ways to contribute to openpilot as a user. + +## Driving + +Got feedback about how your car drives? +Join the community Discord, then use the form in `#submit-feedback`. + +Before posting feedback, please ensure: + +- **openpilot is up to date** you should be on the latest openpilot release or nightly +- **both road-facing cameras have a clear view** your windshield is clean, lenses are clean, etc. +- **your device is mounted properly** your device must be mounted horizontally center and relatively high on the windshield + +## Driver Monitoring + +If you find DM annoying while being perfectly attentive, these are likely false positives and we want to fix them! +In general, driver monitoring feedback is very actionable, and we can fix your complaint within a release cycle. + +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. +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. +- **your device is mounted properly** your device must be mounted horizontally center and relatively high on the windshield + +## Other bugs + +Got an issue with something else? Open an issue on our [GitHub issue tracker](https://github.com/commaai/openpilot/issues/new/choose). diff --git a/docs/contributing/roadmap.md b/docs/contributing/roadmap.md index 1262017a0b..ae27a5461c 100644 --- a/docs/contributing/roadmap.md +++ b/docs/contributing/roadmap.md @@ -7,25 +7,11 @@ This is the roadmap for the next major openpilot releases. Also check out * [Bounties](https://comma.ai/bounties) for paid individual issues * [#current-projects](https://discord.com/channels/469524606043160576/1249579909739708446) in Discord for discussion on work-in-progress projects -## openpilot 0.10 - -openpilot 0.10 will be the first release with a driving policy trained in -a [learned simulator](https://youtu.be/EqQNZXqzFSI). - -* Driving model trained in a learned simulator -* Always-on driver monitoring (behind a toggle) -* GPS removed from the driving stack -* 100KB qlogs -* `nightly` pushed after 1000 hours of hardware-in-the-loop testing -* Car interface code moved into [opendbc](https://github.com/commaai/opendbc) -* openpilot on PC for Linux x86, Linux arm64, and Mac (Apple Silicon) - ## openpilot 1.0 openpilot 1.0 will feature a fully end-to-end driving policy. * End-to-end longitudinal control in Chill mode -* Automatic Emergency Braking (AEB) * Driver monitoring with sleep detection * Rolling updates/releases pushed out by CI * [panda safety 1.0](https://github.com/orgs/commaai/projects/27) diff --git a/docs/css/tooltip.css b/docs/css/tooltip.css deleted file mode 100644 index b9a54f793f..0000000000 --- a/docs/css/tooltip.css +++ /dev/null @@ -1,44 +0,0 @@ -[data-tooltip] { - position: relative; - display: inline-block; - border-bottom: 1px dotted black; -} - -[data-tooltip] .tooltip-content { - width: max-content; - max-width: 25em; - position: absolute; - top: 100%; - left: 50%; - transform: translateX(-50%); - background-color: white; - color: #404040; - box-shadow: 0 4px 14px 0 rgba(0,0,0,.2), 0 0 0 1px rgba(0,0,0,.05); - padding: 10px; - font: 14px/1.5 Lato, proxima-nova, Helvetica Neue, Arial, sans-serif; - text-decoration: none; - opacity: 0; - visibility: hidden; - transition: opacity 0.1s, visibility 0s; - z-index: 1000; - pointer-events: none; /* Prevent accidental interaction */ -} - -[data-tooltip]:hover .tooltip-content { - opacity: 1; - visibility: visible; - pointer-events: auto; /* Allow interaction when visible */ -} - -.tooltip-content .tooltip-glossary-link { - display: inline-block; - margin-top: 8px; - font-size: 12px; - color: #007bff; - text-decoration: none; -} - -.tooltip-content .tooltip-glossary-link:hover { - color: #0056b3; - text-decoration: underline; -} diff --git a/docs/ext/glossary.py b/docs/ext/glossary.py new file mode 100644 index 0000000000..35c92add10 --- /dev/null +++ b/docs/ext/glossary.py @@ -0,0 +1,216 @@ +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 LinksProcessor + +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, LinksProcessor): + 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 new file mode 100644 index 0000000000..62408d9ddd --- /dev/null +++ b/docs/ext/glossary.toml @@ -0,0 +1,8 @@ +[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/getting-started/what-is-openpilot.md b/docs/getting-started/what-is-openpilot.md deleted file mode 100644 index b3c56c8410..0000000000 --- a/docs/getting-started/what-is-openpilot.md +++ /dev/null @@ -1,12 +0,0 @@ -# What is openpilot? - -[openpilot](http://github.com/commaai/openpilot) is an open source driver assistance system. Currently, openpilot performs the functions of Adaptive Cruise Control (ACC), Automated Lane Centering (ALC), Forward Collision Warning (FCW), and Lane Departure Warning (LDW) for a growing variety of [supported car makes, models, and model years](https://github.com/commaai/openpilot/blob/master/docs/CARS.md). In addition, while openpilot is engaged, a camera-based Driver Monitoring (DM) feature alerts distracted and asleep drivers. See more about [the vehicle integration](https://github.com/commaai/openpilot/blob/master/docs/INTEGRATION.md) and [limitations](https://github.com/commaai/openpilot/blob/master/docs/LIMITATIONS.md). - - -## How do I use it? - -openpilot is designed to be used on the comma 3X. - -## How does it work? - -In short, openpilot uses the car's existing APIs for the built-in [ADAS](https://en.wikipedia.org/wiki/Advanced_driver-assistance_system) system and simply provides better acceleration, braking, and steering inputs than the stock system. diff --git a/docs/hooks/glossary.py b/docs/hooks/glossary.py deleted file mode 100644 index e2fa3d51e0..0000000000 --- a/docs/hooks/glossary.py +++ /dev/null @@ -1,68 +0,0 @@ -import re -import tomllib - -def load_glossary(file_path="docs/glossary.toml"): - with open(file_path, "rb") as f: - glossary_data = tomllib.load(f) - return glossary_data.get("glossary", {}) - -def generate_anchor_id(name): - return name.replace(" ", "-").replace("_", "-").lower() - -def format_markdown_term(name, definition): - anchor_id = generate_anchor_id(name) - markdown = f"* [**{name.replace('_', ' ').title()}**](#{anchor_id})" - if definition.get("abbreviation"): - markdown += f" *({definition['abbreviation']})*" - if definition.get("description"): - markdown += f": {definition['description']}\n" - return markdown - -def glossary_markdown(vocabulary): - markdown = "" - for category, terms in vocabulary.items(): - markdown += f"## {category.replace('_', ' ').title()}\n\n" - for name, definition in terms.items(): - markdown += format_markdown_term(name, definition) - return markdown - -def format_tooltip_html(term_key, definition, html): - display_term = term_key.replace("_", " ").title() - clean_description = re.sub(r"\[(.+)]\(.+\)", r"\1", definition["description"]) - glossary_link = ( - f"Glossary🔗" - ) - return re.sub( - re.escape(display_term), - lambda - match: f"{match.group(0)}{clean_description} {glossary_link}", - html, - flags=re.IGNORECASE, - ) - -def apply_tooltip(_term_key, _definition, pattern, html): - return re.sub( - pattern, - lambda match: format_tooltip_html(_term_key, _definition, match.group(0)), - html, - flags=re.IGNORECASE, - ) - -def tooltip_html(vocabulary, html): - for _category, terms in vocabulary.items(): - for term_key, definition in terms.items(): - if definition.get("description"): - pattern = rf"(?)(?!\([^)]*\))" - html = apply_tooltip(term_key, definition, pattern, html) - return html - -# Page Hooks -def on_page_markdown(markdown, **kwargs): - glossary = load_glossary() - return markdown.replace("{{GLOSSARY_DEFINITIONS}}", glossary_markdown(glossary)) - -def on_page_content(html, **kwargs): - if kwargs.get("page").title == "Glossary": - return html - glossary = load_glossary() - return tooltip_html(glossary, html) diff --git a/docs/car-porting/what-is-a-car-port.md b/docs/how-to/car-port.md similarity index 65% rename from docs/car-porting/what-is-a-car-port.md rename to docs/how-to/car-port.md index 3480e4e5d5..ca565e53f6 100644 --- a/docs/car-porting/what-is-a-car-port.md +++ b/docs/how-to/car-port.md @@ -8,7 +8,7 @@ A car port enables openpilot support on a particular car. Each car model openpil # Structure of a car port -Virtually all car-specific code is contained in two other repositories: [opendbc](https://github.com/commaai/opendbc) and [panda](https://github.com/commaai/panda). +All car-specific code is contained in the [opendbc](https://github.com/commaai/opendbc) project. ## opendbc @@ -23,8 +23,8 @@ Each car brand is supported by a standard interface structure in `opendbc/car/[b ## safety -* `opendbc_repo/opendbc/safety/modes/[brand].h`: Brand-specific safety logic -* `opendbc_repo/opendbc/safety/tests/test_[brand].py`: Brand-specific safety CI tests +* `opendbc/safety/modes/[brand].h`: Brand-specific safety logic +* `opendbc/safety/tests/test_[brand].py`: Brand-specific safety CI tests ## openpilot @@ -32,8 +32,20 @@ For historical reasons, openpilot still contains a small amount of car-specific * `selfdrive/car/car_specific.py`: Brand-specific event logic -# Overview +# How do I port car? [Jason Young](https://github.com/jyoung8607) gave a talk at COMMA_CON with an overview of the car porting process. The talk is available on YouTube: https://www.youtube.com/watch?v=XxPS5TpTUnI + +## Brand Port + +A brand port is a port of openpilot to a substantially new car brand or platform within a brand. + +Here's an example of one: https://github.com/commaai/openpilot/pull/23331. + +## Model Port + +A model port is a port of openpilot to a new car model within an already supported brand. Model ports are easier than brand ports because the car's existing APIs are already known. + +Here's an example of one: https://github.com/commaai/openpilot/pull/30672/. diff --git a/docs/how-to/connect-to-comma.md b/docs/how-to/connect-to-comma.md index 5f02e11599..e4e322f111 100644 --- a/docs/how-to/connect-to-comma.md +++ b/docs/how-to/connect-to-comma.md @@ -1,15 +1,15 @@ -# connect to a comma 3X +# connect to a comma 3X or comma four -A comma 3X is a normal [Linux](https://github.com/commaai/agnos-builder) computer that exposes [SSH](https://wiki.archlinux.org/title/Secure_Shell) and a [serial console](https://wiki.archlinux.org/title/Working_with_the_serial_console). +A comma device is a normal [Linux](https://github.com/commaai/agnos-builder) computer that exposes [SSH](https://wiki.archlinux.org/title/Secure_Shell) and a [serial console](https://wiki.archlinux.org/title/Working_with_the_serial_console). ## Serial Console -On both the comma three and 3X, the serial console is accessible from the main OBD-C port. -Connect the comma 3X to your computer with a normal USB C cable, or use a [comma serial](https://comma.ai/shop/comma-serial) for steady 12V power. +On the comma 3X, the serial console is accessible from the main OBD-C port, forwarded through the panda. +Access it using `panda/scripts/som_debug.sh`. -On the comma three, the serial console is exposed through a UART-to-USB chip, and `tools/scripts/serial.sh` can be used to connect. +comma four also exposes a serial console, albeit through an internal debug connector. Dedicated debug hardware coming soon to the comma shop. -On the comma 3X, the serial console is accessible through the [panda](https://github.com/commaai/panda) using the `panda/tests/som_debug.sh` script. +Login to the default user with: * Username: `comma` * Password: `comma` @@ -25,7 +25,7 @@ In order to SSH into your device, you'll need a GitHub account with SSH keys. Se * Port: `22` Here's an example command for connecting to your device using its tethered connection:
-`ssh comma@192.168.43.1` +`ssh comma@192.168.43.1 -i ~/.ssh/my_github_key` For doing development work on device, it's recommended to use [SSH agent forwarding](https://docs.github.com/en/developers/overview/using-ssh-agent-forwarding). @@ -45,7 +45,7 @@ In order to use ADB on your device, you'll need to perform the following steps u * Here's an example command for connecting to your device using its tethered connection: `adb connect 192.168.43.1:5555` > [!NOTE] -> The default port for ADB is 5555 on the comma 3X. +> The default port for ADB is 5555. For more info on ADB, see the [Android Debug Bridge (ADB) documentation](https://developer.android.com/tools/adb). @@ -55,7 +55,7 @@ The public keys are only fetched from your GitHub account once. In order to upda The `id_rsa` key in this directory only works while your device is in the setup state with no software installed. After installation, that default key will be removed. -#### ssh.comma.ai proxy +## ssh.comma.ai proxy With a [comma prime subscription](https://comma.ai/connect), you can SSH into your comma device from anywhere. @@ -79,6 +79,7 @@ Host ssh.comma.ai ``` ssh -i ~/.ssh/my_github_key -o ProxyCommand="ssh -i ~/.ssh/my_github_key -W %h:%p -p %p %h@ssh.comma.ai" comma@ffffffffffffffff ``` + (Replace `ffffffffffffffff` with your dongle_id) ### ssh.comma.ai host key fingerprint diff --git a/docs/how-to/replay-a-drive.md b/docs/how-to/replay-a-drive.md index b0db36a46f..a11b29dcc4 100644 --- a/docs/how-to/replay-a-drive.md +++ b/docs/how-to/replay-a-drive.md @@ -8,7 +8,7 @@ Replaying is a critical tool for openpilot development and debugging. Just run `tools/replay/replay --demo`. ## Replaying CAN data -*Hardware required: jungle and comma 3X* +*Hardware required: jungle and comma four* 1. Connect your PC to a jungle. 2. diff --git a/docs/how-to/turn-the-speed-blue.md b/docs/how-to/turn-the-speed-blue.md index 644c35e0ab..bc1d634012 100644 --- a/docs/how-to/turn-the-speed-blue.md +++ b/docs/how-to/turn-the-speed-blue.md @@ -3,7 +3,7 @@ In 30 minutes, we'll get an openpilot development environment set up on your computer and make some changes to openpilot's UI. -And if you have a comma 3X, we'll deploy the change to your device for testing. +And if you have a comma four, we'll deploy the change to your device for testing. ## 1. Set up your development environment diff --git a/docs/index.md b/docs/index.md deleted file mode 120000 index 74ea27aeeb..0000000000 --- a/docs/index.md +++ /dev/null @@ -1 +0,0 @@ -getting-started/what-is-openpilot.md \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000000..6fab2b979b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,12 @@ +# What is openpilot? + +[openpilot](http://github.com/commaai/openpilot) is an open source driver assistance system. Currently, openpilot performs the functions of Adaptive Cruise Control (ACC), Automated Lane Centering (ALC), Forward Collision Warning (FCW), and Lane Departure Warning (LDW) for a growing variety of [supported car makes, models, and model years](https://github.com/commaai/openpilot/blob/master/docs/CARS.md). In addition, while openpilot is engaged, a camera-based Driver Monitoring (DM) feature alerts distracted and asleep drivers. See more about [the vehicle integration](https://github.com/commaai/openpilot/blob/master/docs/INTEGRATION.md) and [limitations](https://github.com/commaai/openpilot/blob/master/docs/LIMITATIONS.md). + + +## How do I use it? + +openpilot is designed to be used on the comma four. + +## How does it work? + +In short, openpilot uses the car's existing APIs for the built-in [ADAS](https://en.wikipedia.org/wiki/Advanced_driver-assistance_system) system and simply provides better acceleration, braking, and steering inputs than the stock system. diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000000..36ce354af1 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,42 @@ +.md-logo img { + filter: invert(1); +} + +.glossary-term { + position: relative; + color: inherit; + text-decoration: none; +} + +.glossary-term__label { + border-bottom: 1px dotted currentColor; +} + +.glossary-term__tooltip { + position: absolute; + top: calc(100% + 0.4rem); + left: 50%; + width: max-content; + max-width: min(30rem, 80vw); + padding: 0.65rem 0.8rem; + border-radius: 0.6rem; + background: rgb(26 26 26 / 96%); + color: white; + box-shadow: 0 0.6rem 1.8rem rgb(0 0 0 / 22%); + font-size: 0.85rem; + line-height: 1.45; + opacity: 0; + pointer-events: none; + transform: translateX(-50%) translateY(-0.15rem); + transition: opacity 120ms ease, transform 120ms ease; + visibility: hidden; + z-index: 20; +} + +.glossary-term:hover .glossary-term__tooltip, +.glossary-term:focus-visible .glossary-term__tooltip, +.glossary-term:focus-within .glossary-term__tooltip { + opacity: 1; + transform: translateX(-50%) translateY(0); + visibility: visible; +} diff --git a/launch_chffrplus.sh b/launch_chffrplus.sh index d4689aae53..5e7b4fa0db 100755 --- a/launch_chffrplus.sh +++ b/launch_chffrplus.sh @@ -7,6 +7,7 @@ source "$DIR/launch_env.sh" function agnos_init { # TODO: move this to agnos sudo rm -f /data/etc/NetworkManager/system-connections/*.nmmeta + rm -f /data/scons_cache/config.lock # set success flag for current boot slot sudo abctl --set_success diff --git a/launch_env.sh b/launch_env.sh index 314366f429..e409a80dd4 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -16,7 +16,7 @@ export VECLIB_MAXIMUM_THREADS=1 export QCOM_PRIORITY=12 if [ -z "$AGNOS_VERSION" ]; then - export AGNOS_VERSION="16" + export AGNOS_VERSION="17.2" fi export STAGING_ROOT="/data/safe_staging" diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 550f807aca..0000000000 --- a/mkdocs.yml +++ /dev/null @@ -1,44 +0,0 @@ -site_name: openpilot docs -repo_url: https://github.com/commaai/openpilot/ -site_url: https://docs.comma.ai - -exclude_docs: README.md - -strict: true -docs_dir: docs -site_dir: docs_site/ - -hooks: - - docs/hooks/glossary.py -extra_css: - - css/tooltip.css -theme: - name: readthedocs - navigation_depth: 3 - -nav: - - Getting Started: - - What is openpilot?: getting-started/what-is-openpilot.md - - How-to: - - Turn the speed blue: how-to/turn-the-speed-blue.md - - Connect to a comma 3X: how-to/connect-to-comma.md - # - Make your first pull request: how-to/make-first-pr.md - #- Replay a drive: how-to/replay-a-drive.md - - Concepts: - - Logs: concepts/logs.md - - Safety: concepts/safety.md - - Glossary: concepts/glossary.md - - Car Porting: - - What is a car port?: car-porting/what-is-a-car-port.md - - Porting a car brand: car-porting/brand-port.md - - Porting a car model: car-porting/model-port.md - - Contributing: - - Roadmap: contributing/roadmap.md - #- Architecture: contributing/architecture.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 diff --git a/msgq_repo b/msgq_repo index ed2777747d..b7688b9bd7 160000 --- a/msgq_repo +++ b/msgq_repo @@ -1 +1 @@ -Subproject commit ed2777747d60de5a399b74ef1d4be4c1fb406ae1 +Subproject commit b7688b9bd731dea4520adf248bf1eb49b6dde776 diff --git a/opendbc_repo b/opendbc_repo index 9918ec656f..df807f8be3 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit 9918ec656ffa0d1a576f8ae159390408adcaf4cd +Subproject commit df807f8be394231b2366a1ecd9c9265ab51d2400 diff --git a/panda b/panda index f5f296c65c..5a90799dac 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit f5f296c65c756a9b81af845cd874ee054d9c598c +Subproject commit 5a90799dac68a8549981cc18efe3a60ebbded053 diff --git a/pyproject.toml b/pyproject.toml index 56664b16b7..fd72c128fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,18 +26,18 @@ dependencies = [ "numpy >=2.0", # vendored native dependencies - "bzip2 @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=bzip2", - "capnproto @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=capnproto", - "eigen @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=eigen", - "ffmpeg @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=ffmpeg", - "libjpeg @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=libjpeg", - "libyuv @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=libyuv", - "openssl3 @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=openssl3", - "python3-dev @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=python3-dev", - "zstd @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=zstd", - "ncurses @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=ncurses", - "zeromq @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=zeromq", - "git-lfs @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=git-lfs", + "bzip2 @ git+https://github.com/commaai/dependencies.git@release-bzip2#subdirectory=bzip2", + "capnproto @ git+https://github.com/commaai/dependencies.git@release-capnproto#subdirectory=capnproto", + "eigen @ git+https://github.com/commaai/dependencies.git@release-eigen#subdirectory=eigen", + "ffmpeg @ git+https://github.com/commaai/dependencies.git@release-ffmpeg#subdirectory=ffmpeg", + "libjpeg @ git+https://github.com/commaai/dependencies.git@release-libjpeg#subdirectory=libjpeg", + "libyuv @ git+https://github.com/commaai/dependencies.git@release-libyuv#subdirectory=libyuv", + "zstd @ git+https://github.com/commaai/dependencies.git@release-zstd#subdirectory=zstd", + "ncurses @ git+https://github.com/commaai/dependencies.git@release-ncurses#subdirectory=ncurses", + "zeromq @ git+https://github.com/commaai/dependencies.git@release-zeromq#subdirectory=zeromq", + "libusb @ git+https://github.com/commaai/dependencies.git@release-libusb#subdirectory=libusb", + "git-lfs @ git+https://github.com/commaai/dependencies.git@release-git-lfs#subdirectory=git-lfs", + "gcc-arm-none-eabi @ git+https://github.com/commaai/dependencies.git@release-gcc-arm-none-eabi#subdirectory=gcc-arm-none-eabi", # body / webrtcd "av", @@ -76,12 +76,13 @@ dependencies = [ "raylib > 5.5.0.3", "qrcode", "jeepney", + "pillow", ] [project.optional-dependencies] docs = [ "Jinja2", - "mkdocs", + "zensical", ] testing = [ @@ -103,12 +104,11 @@ testing = [ dev = [ "matplotlib", "opencv-python-headless", - "gcc-arm-none-eabi @ git+https://github.com/commaai/dependencies.git@releases#subdirectory=gcc-arm-none-eabi", ] tools = [ + "imgui @ git+https://github.com/commaai/dependencies.git@release-imgui#subdirectory=imgui", "metadrive-simulator @ git+https://github.com/commaai/metadrive.git@minimal ; (platform_machine != 'aarch64')", - "dearpygui>=2.1.0; (sys_platform != 'linux' or platform_machine != 'aarch64')", # not vended for linux aarch64 ] [project.urls] @@ -150,7 +150,7 @@ quiet-level = 3 # if you've got a short variable name that's getting flagged, add it here ignore-words-list = "bu,ro,te,ue,alo,hda,ois,nam,nams,ned,som,parm,setts,inout,warmup,bumb,nd,sie,preints,whit,indexIn,ws,uint,grey,deque,stdio,amin,BA,LITE,atEnd,UIs,errorString,arange,FocusIn,od,tim,relA,hist,copyable,jupyter,thead,TGE,abl,lite,ser" builtin = "clear,rare,informal,code,names,en-GB_to_en-US" -skip = "./third_party/*, ./tinygrad/*, ./tinygrad_repo/*, ./msgq/*, ./panda/*, ./opendbc/*, ./opendbc_repo/*, ./rednose/*, ./rednose_repo/*, ./teleoprtc/*, ./teleoprtc_repo/*, *.po, uv.lock, *.onnx, ./cereal/gen/*, */c_generated_code/*, docs/assets/*, tools/plotjuggler/layouts/*, selfdrive/assets/offroad/mici_fcc.html" +skip = "./third_party/*, ./tinygrad/*, ./tinygrad_repo/*, ./msgq/*, ./panda/*, ./opendbc/*, ./opendbc_repo/*, ./rednose/*, ./rednose_repo/*, ./teleoprtc/*, ./teleoprtc_repo/*, *.po, uv.lock, *.onnx, *.pem, ./cereal/gen/*, */c_generated_code/*, docs/assets/*, tools/plotjuggler/layouts/*, selfdrive/assets/offroad/mici_fcc.html" # https://docs.astral.sh/ruff/configuration/#using-pyprojecttoml [tool.ruff] @@ -207,6 +207,7 @@ lint.flake8-implicit-str-concat.allow-multiline = false "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" "pyray.draw_text".msg = "Use a function (such as rl.draw_font_ex) that takes font as an argument" +"pyray.draw_texture".msg = "Use rl.draw_texture_ex for float position support" [tool.ruff.format] quote-style = "preserve" @@ -250,3 +251,6 @@ unsupported-operator = "ignore" # Ignore not-subscriptable - false positives from dynamic types not-subscriptable = "ignore" # not-iterable errors are now fixed + +[tool.uv] +python-preference = "only-managed" diff --git a/rednose_repo b/rednose_repo index 6ccb8d0556..7ffefa3d88 160000 --- a/rednose_repo +++ b/rednose_repo @@ -1 +1 @@ -Subproject commit 6ccb8d055652cd9769b5e418edf116272fde4e09 +Subproject commit 7ffefa3d8811a842f8ec97d311103ce3a45dfae0 diff --git a/release/ci/model_generator.py b/release/ci/model_generator.py index da6b933030..feeb80095b 100755 --- a/release/ci/model_generator.py +++ b/release/ci/model_generator.py @@ -104,7 +104,7 @@ def generate_metadata(model_path: Path, output_dir: Path, short_name: str): metadata_file = metadata_file.rename(output_path / f"{base}_{short_name.lower()}_metadata.pkl") # Build the metadata structure - model_type = "offPolicy" if "off_policy" in base else base.split("_")[-1] + model_type = "offPolicy" if "off_policy" in base else "onPolicy" if "on_policy" in base else base.split("_")[-1] model_metadata = { "type": model_type, diff --git a/release/pack.py b/release/pack.py index 92ff68fe76..8831a0b34d 100755 --- a/release/pack.py +++ b/release/pack.py @@ -12,12 +12,13 @@ from openpilot.common.basedir import BASEDIR DIRS = ['cereal', 'openpilot'] -EXTS = ['.png', '.py', '.ttf', '.capnp', '.json', '.fnt', '.mo'] +EXTS = ['.png', '.py', '.ttf', '.capnp', '.json', '.fnt', '.mo', '.po'] +EXCLUDE = ['selfdrive/assets/training', 'third_party/raylib/raylib_repo/examples'] INTERPRETER = '/usr/bin/env python3' def copy(src, dest): - if any(src.endswith(ext) for ext in EXTS): + if any(src.endswith(ext) for ext in EXTS) and not any(exc in src for exc in EXCLUDE): shutil.copy2(src, dest, follow_symlinks=True) @@ -28,6 +29,8 @@ if __name__ == '__main__': parser.add_argument('module', help="the module to target, e.g. 'openpilot.system.ui.spinner'") args = parser.parse_args() + print('WARNING: copying all files! make sure to run scons and git tree is clean') + if not args.output: args.output = args.module diff --git a/scripts/docs.py b/scripts/docs.py new file mode 100644 index 0000000000..d60bfb791f --- /dev/null +++ b/scripts/docs.py @@ -0,0 +1,63 @@ +""" + 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/reporter.py b/scripts/reporter.py index d894b8af48..93b71761a9 100755 --- a/scripts/reporter.py +++ b/scripts/reporter.py @@ -33,11 +33,7 @@ if __name__ == "__main__": print("|-| ----- | --------- |") for f in glob.glob(BASEDIR + MODEL_PATH + "/*.onnx"): - # TODO: add checkpoint to DM - if "dmonitoring" in f: - continue - fn = os.path.basename(f) master = get_checkpoint(MASTER_PATH + MODEL_PATH + fn) pr = get_checkpoint(BASEDIR + MODEL_PATH + fn) - print("|", fn, "|", f"[{master}](https://reporter.comma.life/experiment/{master})", "|", f"[{pr}](https://reporter.comma.life/experiment/{pr})", "|") + print("|", fn, "|", f"[{master}](https://reporterv2.comma.life/{master})", "|", f"[{pr}](https://reporterv2.comma.life/{pr})", "|") diff --git a/selfdrive/SConscript b/selfdrive/SConscript deleted file mode 100644 index 55f347c44e..0000000000 --- a/selfdrive/SConscript +++ /dev/null @@ -1,6 +0,0 @@ -SConscript(['pandad/SConscript']) -SConscript(['controls/lib/lateral_mpc_lib/SConscript']) -SConscript(['controls/lib/longitudinal_mpc_lib/SConscript']) -SConscript(['locationd/SConscript']) -SConscript(['modeld/SConscript']) -SConscript(['ui/SConscript']) diff --git a/selfdrive/assets/.gitignore b/selfdrive/assets/.gitignore index fffd4b4ed9..2d97f8b111 100644 --- a/selfdrive/assets/.gitignore +++ b/selfdrive/assets/.gitignore @@ -1,4 +1,2 @@ -*.cc fonts/*.fnt fonts/*.png -translations_assets.qrc diff --git a/selfdrive/assets/icons_mici/alerts_bell.png b/selfdrive/assets/icons_mici/alerts_bell.png new file mode 100644 index 0000000000..5d775425eb --- /dev/null +++ b/selfdrive/assets/icons_mici/alerts_bell.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ce1d357acadd798939b398cce1761ceb05564b44f2a5bc6865c7842e60e79f2 +size 1474 diff --git a/selfdrive/assets/icons_mici/alerts_pill.png b/selfdrive/assets/icons_mici/alerts_pill.png new file mode 100644 index 0000000000..29ab2ad5b3 --- /dev/null +++ b/selfdrive/assets/icons_mici/alerts_pill.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b3fe73cd1a24c05346a9b4a02e4f900a314c83a422beb38b0f88f91389582cd4 +size 3960 diff --git a/selfdrive/assets/icons_mici/onroad/bookmark_fill.png b/selfdrive/assets/icons_mici/onroad/bookmark_fill.png deleted file mode 100644 index 531d5db1cf..0000000000 --- a/selfdrive/assets/icons_mici/onroad/bookmark_fill.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f3f57346a1cf9a66f9fd746f87bcebb23b7a403e9d6e4fd7701b126abcdd47ea -size 18476 diff --git a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_center.png b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_center.png deleted file mode 100644 index a8a68b372c..0000000000 --- a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_center.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b5aee9f6cec03f1967014cd2ea2a23982b262e7d86dadca602ecfa8875b38101 -size 5875 diff --git a/selfdrive/assets/icons_mici/onroad/glasses.png b/selfdrive/assets/icons_mici/onroad/glasses.png deleted file mode 100644 index 006972fd39..0000000000 --- a/selfdrive/assets/icons_mici/onroad/glasses.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:56de402482b5987ed9a0ff3f793a1c89f857304b34fbb8a3deb5b5d4a332be1c -size 3688 diff --git a/selfdrive/assets/icons_mici/settings/device/language.png b/selfdrive/assets/icons_mici/settings/device/language.png deleted file mode 100644 index d2ef27de36..0000000000 --- a/selfdrive/assets/icons_mici/settings/device/language.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f646263b26de46f79cac836ef6865b0f25ddc91e386b99311723b68bd06693c9 -size 3304 diff --git a/selfdrive/assets/icons_mici/setup/back_new.png b/selfdrive/assets/icons_mici/setup/back_new.png deleted file mode 100644 index 20e7fe3b88..0000000000 --- a/selfdrive/assets/icons_mici/setup/back_new.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d29a9c295b33b3164c37a68ad77795595e6ac877a5b308d28112b0315ecd498f -size 1687 diff --git a/selfdrive/assets/icons_mici/setup/cancel.png b/selfdrive/assets/icons_mici/setup/cancel.png new file mode 100644 index 0000000000..f50cc9ef3f --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/cancel.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a6892bd4d9b14b587fa491a6d608562e38819b4c618b1d7a3e8c384f05d52a2b +size 1245 diff --git a/selfdrive/assets/icons_mici/setup/continue.png b/selfdrive/assets/icons_mici/setup/continue.png new file mode 100644 index 0000000000..7a67bb0c96 --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/continue.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3428d8fcf2ecf9542c524706124f82b7fc809453c63418c9234ac9df5d85bd24 +size 10074 diff --git a/selfdrive/assets/icons_mici/setup/continue_disabled.png b/selfdrive/assets/icons_mici/setup/continue_disabled.png new file mode 100644 index 0000000000..8a2bcc2ffe --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/continue_disabled.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2810add4943dd4f20a984ed6011b520925919a58d5c0dd0d846fc4d7f8a1d02 +size 7109 diff --git a/selfdrive/assets/icons_mici/setup/continue_pressed.png b/selfdrive/assets/icons_mici/setup/continue_pressed.png new file mode 100644 index 0000000000..3eaee7bf1c --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/continue_pressed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3a3a87454a3d2f1ebb327211062c52480de945673dcfd137c5da3df8fa98d731 +size 22400 diff --git a/selfdrive/assets/icons_mici/setup/factory_reset.png b/selfdrive/assets/icons_mici/setup/factory_reset.png new file mode 100644 index 0000000000..bcb3ea92cb --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/factory_reset.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:122a614d1aa26187507951f932160eebfddfebcb4293e78f8d23e350fc97bc0f +size 11489 diff --git a/selfdrive/assets/icons_mici/setup/medium_button_bg.png b/selfdrive/assets/icons_mici/setup/medium_button_bg.png deleted file mode 100644 index e79dc2eb58..0000000000 --- a/selfdrive/assets/icons_mici/setup/medium_button_bg.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9e363a79dc35ca4c4e9efaa6a843d37ad219efa5299d3e538d8249affa230096 -size 7935 diff --git a/selfdrive/assets/icons_mici/setup/medium_button_pressed_bg.png b/selfdrive/assets/icons_mici/setup/medium_button_pressed_bg.png deleted file mode 100644 index e52fb0c17d..0000000000 --- a/selfdrive/assets/icons_mici/setup/medium_button_pressed_bg.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cc6fb48520143b6fa1f060d8212e6d929917ab616ce943b5fab5a60665f00da5 -size 18225 diff --git a/selfdrive/assets/icons_mici/setup/reset/small_button.png b/selfdrive/assets/icons_mici/setup/reset/small_button.png deleted file mode 100644 index e3f58b1078..0000000000 --- a/selfdrive/assets/icons_mici/setup/reset/small_button.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7a198f13f30b3dbc09f30d7fd8033a0bc07a0da9b010b7ca6ed2678430c9e5b4 -size 6949 diff --git a/selfdrive/assets/icons_mici/setup/reset/small_button_pressed.png b/selfdrive/assets/icons_mici/setup/reset/small_button_pressed.png deleted file mode 100644 index 5b502e00aa..0000000000 --- a/selfdrive/assets/icons_mici/setup/reset/small_button_pressed.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:75289d004709def2a2d6101a0330ec867895068ec3807aefc2a26d423d907a13 -size 13437 diff --git a/selfdrive/assets/icons_mici/setup/reset/wide_button.png b/selfdrive/assets/icons_mici/setup/reset/wide_button.png deleted file mode 100644 index 3892f6eb8c..0000000000 --- a/selfdrive/assets/icons_mici/setup/reset/wide_button.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2452aaf59da18be1b74b475851d66e5c73c50aa49820419a288b1fdb7b42dee1 -size 9071 diff --git a/selfdrive/assets/icons_mici/setup/reset/wide_button_pressed.png b/selfdrive/assets/icons_mici/setup/reset/wide_button_pressed.png deleted file mode 100644 index 3a34af8846..0000000000 --- a/selfdrive/assets/icons_mici/setup/reset/wide_button_pressed.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6478f7c1c5ef2013e94fc4218ab370889883c5c12231ba3e0975874cb0b6fec9 -size 21893 diff --git a/selfdrive/assets/icons_mici/setup/reset_failed.png b/selfdrive/assets/icons_mici/setup/reset_failed.png new file mode 100644 index 0000000000..680df97cbc --- /dev/null +++ b/selfdrive/assets/icons_mici/setup/reset_failed.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d5b8f76e5f47e77e5af3016ebdbe548ad3bc9af83a1111b3214bf4017c95a28 +size 11792 diff --git a/selfdrive/assets/icons_mici/setup/restore.png b/selfdrive/assets/icons_mici/setup/restore.png index 5eff924040..5c62086f64 100644 --- a/selfdrive/assets/icons_mici/setup/restore.png +++ b/selfdrive/assets/icons_mici/setup/restore.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1f5ee67cd334d259ac33f932281db36533877009b5769c92d9cff3054fd5627c -size 2942 +oid sha256:63c1499106621a4d927c21b2b04c87235a927216d9f513a0205f0fe03b8c799b +size 12320 diff --git a/selfdrive/assets/icons_mici/setup/scroll_down_indicator.png b/selfdrive/assets/icons_mici/setup/scroll_down_indicator.png deleted file mode 100644 index 3cd26e5181..0000000000 --- a/selfdrive/assets/icons_mici/setup/scroll_down_indicator.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a733c425113a7f6ff5ec3dc50ef94b5481c0f2d306e33d1485be8ee6b2798532 -size 1136 diff --git a/selfdrive/assets/icons_mici/setup/small_red_pill.png b/selfdrive/assets/icons_mici/setup/small_red_pill.png deleted file mode 100644 index 4a7db930a0..0000000000 --- a/selfdrive/assets/icons_mici/setup/small_red_pill.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b3a336afddad80dc91caca91d54bd29897ce491f180374edf9a5ba517cbc00e9 -size 8765 diff --git a/selfdrive/assets/icons_mici/setup/small_red_pill_pressed.png b/selfdrive/assets/icons_mici/setup/small_red_pill_pressed.png deleted file mode 100644 index a8d51960c4..0000000000 --- a/selfdrive/assets/icons_mici/setup/small_red_pill_pressed.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8eee9f10ca80a4e6100c00c02bb46aa5f253b14b086ab9982cfa85ee94eec162 -size 22512 diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_bg.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_bg.png deleted file mode 100644 index 43c10a54ad..0000000000 --- a/selfdrive/assets/icons_mici/setup/small_slider/slider_bg.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:94a86fac6ffe8a8179812cf55350ab9ca6935f36244c6f679c1cf521a842316b -size 5723 diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle.png deleted file mode 100644 index 541433be76..0000000000 --- a/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6ccb5f2298389ae36df87de84d85440ee5a82c50e803c9bd362c9b89ea45aa69 -size 6611 diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle_pressed.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle_pressed.png deleted file mode 100644 index eea6eded86..0000000000 --- a/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle_pressed.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a804da77b268f0a625f93949642ae74cdfe5b5caa5baea1c52c4605ae25c80e4 -size 12916 diff --git a/selfdrive/assets/icons_mici/setup/smaller_button.png b/selfdrive/assets/icons_mici/setup/smaller_button.png deleted file mode 100644 index 9b4851c568..0000000000 --- a/selfdrive/assets/icons_mici/setup/smaller_button.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:89ca7e6bb01dfa78300126ce828cb2a64e7a2e68e1e9152de242f57a36d0e57a -size 8604 diff --git a/selfdrive/assets/icons_mici/setup/smaller_button_disabled.png b/selfdrive/assets/icons_mici/setup/smaller_button_disabled.png deleted file mode 100644 index 6514791de7..0000000000 --- a/selfdrive/assets/icons_mici/setup/smaller_button_disabled.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b3242a411b559f1d0308f189fe0d25b81d6c7d964ca418a0c599a1bab4bffcbb -size 5341 diff --git a/selfdrive/assets/icons_mici/setup/smaller_button_pressed.png b/selfdrive/assets/icons_mici/setup/smaller_button_pressed.png deleted file mode 100644 index 64235b3a2f..0000000000 --- a/selfdrive/assets/icons_mici/setup/smaller_button_pressed.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d354651c0c8107dcc5f599777d260f53ef1901123315785ed8190466166cdce8 -size 17554 diff --git a/selfdrive/assets/icons_mici/setup/widish_button.png b/selfdrive/assets/icons_mici/setup/widish_button.png deleted file mode 100644 index 529b7c80cc..0000000000 --- a/selfdrive/assets/icons_mici/setup/widish_button.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:74fc21132b1e761ea54ce64617730c6ee79d01668244ab555b3b89870cfea181 -size 7112 diff --git a/selfdrive/assets/icons_mici/setup/widish_button_disabled.png b/selfdrive/assets/icons_mici/setup/widish_button_disabled.png deleted file mode 100644 index 5028a8cd21..0000000000 --- a/selfdrive/assets/icons_mici/setup/widish_button_disabled.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9728423bd5e3197ef02d62e4bae415e6694aab875ca8630ffc9f188c38e18e5f -size 4141 diff --git a/selfdrive/assets/icons_mici/setup/widish_button_pressed.png b/selfdrive/assets/icons_mici/setup/widish_button_pressed.png deleted file mode 100644 index 1095d4fc23..0000000000 --- a/selfdrive/assets/icons_mici/setup/widish_button_pressed.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0ff179f93f421edcb503ca5c22a12b37e3a2aaabc414bf90f57e20ff5255dd75 -size 15572 diff --git a/selfdrive/car/CARS_template.md b/selfdrive/car/CARS_template.md index cd352b2ede..bc335b6bd3 100644 --- a/selfdrive/car/CARS_template.md +++ b/selfdrive/car/CARS_template.md @@ -1,6 +1,6 @@ {% set footnote_tag = '[{}](#footnotes)' %} {% set star_icon = '[![star](assets/icon-star-{}.svg)](##)' %} -{% set video_icon = '' %} +{% set video_icon = '' %} {# Force hardware column wider by using a blank image with max width. #} {% set width_tag = '%s
 ' %} {% set hardware_col_name = 'Hardware Needed' %} diff --git a/selfdrive/car/card.py b/selfdrive/car/card.py index f9ddbde213..ca6ca89d7f 100755 --- a/selfdrive/car/card.py +++ b/selfdrive/car/card.py @@ -98,7 +98,6 @@ class Car: break alpha_long_allowed = self.params.get_bool("AlphaLongitudinalEnabled") - num_pandas = len(messaging.recv_one_retry(self.sm.sock['pandaStates']).pandaStates) cached_params = None cached_params_raw = self.params.get("CarParamsCache") @@ -109,7 +108,7 @@ class Car: fixed_fingerprint = (self.params.get("CarPlatformBundle") or {}).get("platform", None) init_params_list_sp = sunnypilot_interfaces.initialize_params(self.params) - self.CI = get_car(*self.can_callbacks, obd_callback(self.params), alpha_long_allowed, is_release, num_pandas, cached_params, + self.CI = get_car(*self.can_callbacks, obd_callback(self.params), alpha_long_allowed, is_release, cached_params, fixed_fingerprint, init_params_list_sp, is_release_sp) sunnypilot_interfaces.setup_interfaces(self.CI, self.params) self.RI = interfaces[self.CI.CP.carFingerprint].RadarInterface(self.CI.CP, self.CI.CP_SP) diff --git a/selfdrive/car/tests/.gitignore b/selfdrive/car/tests/.gitignore deleted file mode 100644 index 192fb0945e..0000000000 --- a/selfdrive/car/tests/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.bz2 diff --git a/selfdrive/controls/.gitignore b/selfdrive/controls/.gitignore deleted file mode 100644 index 22a371d8ff..0000000000 --- a/selfdrive/controls/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -calibration_param -traces diff --git a/selfdrive/controls/controlsd.py b/selfdrive/controls/controlsd.py index 1c8e9f76b1..b53185ca2b 100755 --- a/selfdrive/controls/controlsd.py +++ b/selfdrive/controls/controlsd.py @@ -42,7 +42,7 @@ 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', 'carState', 'carOutput', + 'liveCalibration', 'livePose', 'longitudinalPlan', 'lateralManeuverPlan', 'carState', 'carOutput', 'driverMonitoringState', 'onroadEvents', 'driverAssistance', 'liveDelay'] + self.sm_services_ext, poll='selfdriveState') self.pm = messaging.PubMaster(['carControl', 'controlsState'] + self.pm_services_ext) @@ -135,7 +135,10 @@ class Controls(ControlsExt): # Steering PID loop and lateral MPC # Reset desired curvature to current to avoid violating the limits on engage - new_desired_curvature = model_v2.action.desiredCurvature if CC.latActive else self.curvature + if self.sm.valid['lateralManeuverPlan']: + new_desired_curvature = self.sm['lateralManeuverPlan'].desiredCurvature if CC.latActive else self.curvature + 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 diff --git a/selfdrive/controls/lib/drive_helpers.py b/selfdrive/controls/lib/drive_helpers.py index bf6dd04f60..1e2fb27b51 100644 --- a/selfdrive/controls/lib/drive_helpers.py +++ b/selfdrive/controls/lib/drive_helpers.py @@ -39,19 +39,17 @@ def clip_curvature(v_ego, prev_curvature, new_curvature, roll) -> tuple[float, b return float(new_curvature), limited_accel or limited_max_curv -def get_accel_from_plan(speeds, accels, t_idxs, action_t=DT_MDL, vEgoStopping=0.05): +def get_accel_from_plan(speeds, accels, t_idxs, action_t=DT_MDL, vEgoStopping=0.3): if len(speeds) == len(t_idxs): v_now = speeds[0] a_now = accels[0] v_target = np.interp(action_t, t_idxs, speeds) a_target = 2 * (v_target - v_now) / (action_t) - a_now - v_target_1sec = np.interp(action_t + 1.0, t_idxs, speeds) else: + v_now = 0.0 v_target = 0.0 - v_target_1sec = 0.0 a_target = 0.0 - should_stop = (v_target < vEgoStopping and - v_target_1sec < vEgoStopping) + should_stop = (v_now < vEgoStopping and a_target < 0.1) return a_target, should_stop def curv_from_psis(psi_target, psi_rate, vego, action_t): diff --git a/selfdrive/controls/lib/latcontrol_angle.py b/selfdrive/controls/lib/latcontrol_angle.py index 116d62f85c..9aa5b3cd01 100644 --- a/selfdrive/controls/lib/latcontrol_angle.py +++ b/selfdrive/controls/lib/latcontrol_angle.py @@ -11,7 +11,7 @@ class LatControlAngle(LatControl): def __init__(self, CP, CP_SP, CI, dt): super().__init__(CP, CP_SP, CI, dt) self.sat_check_min_speed = 5. - self.use_steer_limited_by_safety = CP.brand == "tesla" + self.use_steer_limited_by_safety = CP.brand in ("tesla", "hyundai") def update(self, active, CS, VM, params, steer_limited_by_safety, desired_curvature, calibrated_pose, curvature_limited, lat_delay): angle_log = log.ControlsState.LateralAngleState.new_message() diff --git a/selfdrive/controls/tests/test_torqued_lat_accel_offset.py b/selfdrive/controls/tests/test_torqued_lat_accel_offset.py index 84389856b6..2f95d7c14f 100644 --- a/selfdrive/controls/tests/test_torqued_lat_accel_offset.py +++ b/selfdrive/controls/tests/test_torqued_lat_accel_offset.py @@ -50,8 +50,10 @@ 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)) - livePose.angularVelocityDevice.z = float(lat_accel / V_EGO) + 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)): est.handle_log(t, which, msg) diff --git a/selfdrive/debug/car/fw_versions.py b/selfdrive/debug/car/fw_versions.py index 6ae10d2fb2..5fb65e6972 100755 --- a/selfdrive/debug/car/fw_versions.py +++ b/selfdrive/debug/car/fw_versions.py @@ -45,8 +45,6 @@ if __name__ == "__main__": extra[(Ecu.unknown, 0x750, i)] = [] extra = {"any": {"debug": extra}} - num_pandas = len(messaging.recv_one_retry(pandaStates_sock).pandaStates) - t = time.monotonic() print("Getting vin...") set_obd_multiplexing(True) @@ -56,7 +54,7 @@ if __name__ == "__main__": print() t = time.monotonic() - fw_vers = get_fw_versions(*can_callbacks, set_obd_multiplexing, query_brand=args.brand, extra=extra, num_pandas=num_pandas, progress=True) + fw_vers = get_fw_versions(*can_callbacks, set_obd_multiplexing, query_brand=args.brand, extra=extra, progress=True) _, candidates = match_fw_to_car(fw_vers, vin) print() diff --git a/selfdrive/debug/cycle_alerts.py b/selfdrive/debug/cycle_alerts.py index 00fa33ac63..4b1def4fc6 100755 --- a/selfdrive/debug/cycle_alerts.py +++ b/selfdrive/debug/cycle_alerts.py @@ -30,9 +30,9 @@ def cycle_alerts(duration=200, is_metric=False): (EventName.accFaulted, ET.IMMEDIATE_DISABLE), # DM sequence - (EventName.preDriverDistracted, ET.WARNING), - (EventName.promptDriverDistracted, ET.WARNING), - (EventName.driverDistracted, ET.WARNING), + (EventName.driverDistracted1, ET.WARNING), + (EventName.driverDistracted2, ET.WARNING), + (EventName.driverDistracted3, ET.WARNING), ] # debug alerts diff --git a/selfdrive/debug/debug_fw_fingerprinting_offline.py b/selfdrive/debug/debug_fw_fingerprinting_offline.py index d841e91053..d36b350bbc 100755 --- a/selfdrive/debug/debug_fw_fingerprinting_offline.py +++ b/selfdrive/debug/debug_fw_fingerprinting_offline.py @@ -44,7 +44,7 @@ if __name__ == "__main__": parser = argparse.ArgumentParser(description='View back and forth ISO-TP communication between various ECUs given an address') parser.add_argument('route', nargs='?', help='Route name, live if not specified') parser.add_argument('--addrs', nargs='*', default=[], help='List of tx address to view (0x7e0 for engine)') - parser.add_argument('--rxoffset', default='') + parser.add_argument('--rxoffset', default='0x8') args = parser.parse_args() addrs = [int(addr, base=16) if addr.startswith('0x') else int(addr) for addr in args.addrs] diff --git a/selfdrive/debug/print_docs_diff.py b/selfdrive/debug/print_docs_diff.py index 388acf3af5..c7850939f0 100755 --- a/selfdrive/debug/print_docs_diff.py +++ b/selfdrive/debug/print_docs_diff.py @@ -11,7 +11,7 @@ FOOTNOTE_TAG = "{}" STAR_ICON = '' VIDEO_ICON = '' + \ - '' + '' COLUMNS = "|" + "|".join([column.value for column in Column]) + "|" COLUMN_HEADER = "|---|---|---|{}|".format("|".join([":---:"] * (len(Column) - 3))) ARROW_SYMBOL = "➡️" diff --git a/selfdrive/locationd/.gitignore b/selfdrive/locationd/.gitignore deleted file mode 100644 index 1a8c72388a..0000000000 --- a/selfdrive/locationd/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -params_learner -paramsd diff --git a/selfdrive/locationd/lagd.py b/selfdrive/locationd/lagd.py index f432eb88bb..d037af613a 100755 --- a/selfdrive/locationd/lagd.py +++ b/selfdrive/locationd/lagd.py @@ -29,11 +29,26 @@ MIN_LAG = 0.15 MAX_LAG_STD = 0.1 MAX_LAT_ACCEL = 2.0 MAX_LAT_ACCEL_DIFF = 0.6 +MIN_LAT_ACCEL_RANGE = 0.5 MIN_CONFIDENCE = 0.7 CORR_BORDER_OFFSET = 5 LAG_CANDIDATE_CORR_THRESHOLD = 0.9 +SMOOTH_K = 5 +SMOOTH_SIGMA = 1.0 +def masked_symmetric_moving_average(x: np.ndarray, mask: np.ndarray, k: int, sigma: float) -> np.ndarray: + assert k >= 1 and k % 2 == 1, "k must be positive and odd" + pad = k // 2 + i = np.arange(k) - pad + w = np.exp(-0.5 * (i / sigma) ** 2) + w /= w.sum() + xp = np.pad(x * mask, pad, mode="edge") + mp = np.pad(mask, pad, mode="edge") + num = np.convolve(xp, w, mode="valid") + den = np.convolve(mp, w, mode="valid") + return np.divide(num, den, out=np.full_like(num, np.nan, dtype=np.float64), where=den != 0) + def masked_normalized_cross_correlation(expected_sig: np.ndarray, actual_sig: np.ndarray, mask: np.ndarray, n: int): """ References: @@ -295,11 +310,14 @@ class LateralLagEstimator: times, desired, actual, okay = self.points.get() # check if there are any new valid data points since the last update - is_valid = self.points_valid() + is_valid = self.points_valid() and (actual.max() - actual.min() >= MIN_LAT_ACCEL_RANGE) if self.last_estimate_t != 0 and times[0] <= self.last_estimate_t: new_values_start_idx = next(-i for i, t in enumerate(reversed(times)) if t <= self.last_estimate_t) is_valid = is_valid and not (new_values_start_idx == 0 or not np.any(okay[new_values_start_idx:])) + desired = masked_symmetric_moving_average(desired, okay, SMOOTH_K, SMOOTH_SIGMA) + actual = masked_symmetric_moving_average(actual, okay, SMOOTH_K, SMOOTH_SIGMA) + delay, corr, confidence = self.actuator_delay(desired, actual, okay, self.dt, MIN_LAG, MAX_LAG) if corr < self.min_ncc or confidence < self.min_confidence or not is_valid: return @@ -311,16 +329,16 @@ class LateralLagEstimator: def actuator_delay(expected_sig: np.ndarray, actual_sig: np.ndarray, mask: np.ndarray, dt: float, min_lag: float, max_lag: float) -> tuple[float, float, float]: assert len(expected_sig) == len(actual_sig) - min_lag_samples, max_lag_samples = int(round(min_lag / dt)), int(round(max_lag / dt)) - padded_size = fft_next_good_size(len(expected_sig) + max_lag_samples) + min_lag_samples, max_lag_samples, one_sec_samples = int(round(min_lag / dt)), int(round(max_lag / dt)), int(round(1.0 / dt)) + padded_size = fft_next_good_size(len(expected_sig) + max(max_lag_samples, one_sec_samples)) ncc = masked_normalized_cross_correlation(expected_sig, actual_sig, mask, padded_size) - # only consider lags from min_lag to max_lag - roi = np.s_[len(expected_sig) - 1 + min_lag_samples: len(expected_sig) - 1 + max_lag_samples] - extended_roi = np.s_[roi.start - CORR_BORDER_OFFSET: roi.stop + CORR_BORDER_OFFSET] - roi_ncc = ncc[roi] - extended_roi_ncc = ncc[extended_roi] + # only consider lags from ranges: + roi = np.s_[len(expected_sig) - 1 + min_lag_samples: len(expected_sig) - 1 + max_lag_samples] # min_lag - max_lag range + threshold_roi = np.s_[len(expected_sig) - 1: len(expected_sig) - 1 + one_sec_samples] # 0 - 1 second range + confidence_roi = np.s_[threshold_roi.start - CORR_BORDER_OFFSET: threshold_roi.stop + CORR_BORDER_OFFSET] # threshold range +/- border + roi_ncc, confidence_roi_ncc, threshold_roi_ncc = ncc[roi], ncc[confidence_roi], ncc[threshold_roi] max_corr_index = np.argmax(roi_ncc) corr = roi_ncc[max_corr_index] @@ -328,8 +346,8 @@ class LateralLagEstimator: # to estimate lag confidence, gather all high-correlation candidates and see how spread they are # if e.g. 0.8 and 0.4 are both viable, this is an ambiguous case - ncc_thresh = (roi_ncc.max() - roi_ncc.min()) * LAG_CANDIDATE_CORR_THRESHOLD + roi_ncc.min() - good_lag_candidate_mask = extended_roi_ncc >= ncc_thresh + ncc_thresh = (threshold_roi_ncc.max() - threshold_roi_ncc.min()) * LAG_CANDIDATE_CORR_THRESHOLD + threshold_roi_ncc.min() + good_lag_candidate_mask = confidence_roi_ncc >= ncc_thresh good_lag_candidate_edges = np.diff(good_lag_candidate_mask.astype(int), prepend=0, append=0) starts, ends = np.where(good_lag_candidate_edges == 1)[0], np.where(good_lag_candidate_edges == -1)[0] - 1 run_idx = np.searchsorted(starts, max_corr_index + CORR_BORDER_OFFSET, side='right') - 1 diff --git a/selfdrive/locationd/locationd.py b/selfdrive/locationd/locationd.py index f6a0935ed9..57aecb22e7 100755 --- a/selfdrive/locationd/locationd.py +++ b/selfdrive/locationd/locationd.py @@ -28,6 +28,9 @@ INPUT_INVALID_LIMIT = 2.0 # 1 (camodo) / 9 (sensor) bad input[s] ignored INPUT_INVALID_RECOVERY = 10.0 # ~10 secs to resume after exceeding allowed bad inputs by one POSENET_STD_INITIAL_VALUE = 10.0 POSENET_STD_HIST_HALF = 20 +CAM_ODO_POSE_DELAY = 0.1 # dependent on the vision model context frames and temporal frequency (current model is 5 fps with 2 context frames) +CAM_ODO_ROT_STD_MULT = 10 +CAM_ODO_TRANS_STD_MULT = 4 def calculate_invalid_input_decay(invalid_limit, recovery_time, frequency): @@ -155,6 +158,8 @@ class LocationEstimator: self.device_from_calib = rot_from_euler(calib) elif which == "cameraOdometry": + # camera odometry is delayed depending on the model context frames and temporal frequency + t = msg.timestampEof * 1e-9 - CAM_ODO_POSE_DELAY if not self._validate_timestamp(t): return HandleLogResult.TIMING_INVALID @@ -177,8 +182,8 @@ class LocationEstimator: self.posenet_stds[-1] = trans_calib_std[0] # Multiply by N to avoid to high certainty in kalman filter because of temporally correlated noise - rot_calib_std *= 10 - trans_calib_std *= 2 + rot_calib_std *= CAM_ODO_ROT_STD_MULT + trans_calib_std *= CAM_ODO_TRANS_STD_MULT rot_device_std = rotate_std(self.device_from_calib, rot_calib_std) trans_device_std = rotate_std(self.device_from_calib, trans_calib_std) @@ -234,6 +239,7 @@ class LocationEstimator: 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) return msg diff --git a/selfdrive/locationd/models/pose_kf.py b/selfdrive/locationd/models/pose_kf.py index 020e51ad6e..a8ff80c713 100755 --- a/selfdrive/locationd/models/pose_kf.py +++ b/selfdrive/locationd/models/pose_kf.py @@ -47,13 +47,13 @@ class PoseKalman(KalmanFilter): # process noise Q = np.diag([0.001**2, 0.001**2, 0.001**2, 0.01**2, 0.01**2, 0.01**2, - 0.1**2, 0.1**2, 0.1**2, + 0.085**2, 0.085**2, 0.085**2, (0.005 / 100)**2, (0.005 / 100)**2, (0.005 / 100)**2, 3**2, 3**2, 3**2, 0.005**2, 0.005**2, 0.005**2]) obs_noise = {ObservationKind.PHONE_GYRO: np.diag([0.025**2, 0.025**2, 0.025**2]), - ObservationKind.PHONE_ACCEL: np.diag([.5**2, .5**2, .5**2]), + ObservationKind.PHONE_ACCEL: np.diag([0.75**2, 0.75**2, 0.75**2]), ObservationKind.CAMERA_ODO_TRANSLATION: np.diag([0.5**2, 0.5**2, 0.5**2]), ObservationKind.CAMERA_ODO_ROTATION: np.diag([0.05**2, 0.05**2, 0.05**2])} diff --git a/selfdrive/locationd/paramsd.py b/selfdrive/locationd/paramsd.py index fd03d3d093..0489ae4174 100755 --- a/selfdrive/locationd/paramsd.py +++ b/selfdrive/locationd/paramsd.py @@ -65,6 +65,7 @@ class VehicleParamsLearner: def handle_log(self, t: float, which: str, msg: capnp._DynamicStructReader): if which == 'livePose': + t = msg.timestamp * 1e-9 device_pose = Pose.from_live_pose(msg) calibrated_pose = self.calibrator.build_calibrated_pose(device_pose) diff --git a/selfdrive/locationd/test/.gitignore b/selfdrive/locationd/test/.gitignore deleted file mode 100644 index 89f9ac04aa..0000000000 --- a/selfdrive/locationd/test/.gitignore +++ /dev/null @@ -1 +0,0 @@ -out/ diff --git a/selfdrive/locationd/test/test_lagd.py b/selfdrive/locationd/test/test_lagd.py index 4728413d9d..6249e6b04b 100644 --- a/selfdrive/locationd/test/test_lagd.py +++ b/selfdrive/locationd/test/test_lagd.py @@ -19,8 +19,8 @@ DT = 0.05 def process_messages(estimator, lag_frames, n_frames, vego=20.0, rejection_threshold=0.0): for i in range(n_frames): t = i * estimator.dt - desired_la = np.cos(10 * t) * 0.1 - actual_la = np.cos(10 * (t - lag_frames * estimator.dt)) * 0.1 + desired_la = np.cos(10 * t) * 0.3 + actual_la = np.cos(10 * (t - lag_frames * estimator.dt)) * 0.3 # if sample is masked out, set it to desired value (no lag) rejected = random.uniform(0, 1) < rejection_threshold diff --git a/selfdrive/locationd/test/test_locationd_scenarios.py b/selfdrive/locationd/test/test_locationd_scenarios.py index 0ea7ac183f..69f2ca2821 100644 --- a/selfdrive/locationd/test/test_locationd_scenarios.py +++ b/selfdrive/locationd/test/test_locationd_scenarios.py @@ -3,6 +3,7 @@ from collections import defaultdict from enum import Enum 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 from openpilot.selfdrive.test.process_replay.process_replay import replay_process_with_name @@ -15,6 +16,7 @@ SELECT_COMPARE_FIELDS = { 'inputs_flag': ['inputsOK'], 'sensors_flag': ['sensorsOK'], } +SMOOTH_FIELDS = ['yaw_rate', 'roll'] JUNK_IDX = 100 CONSISTENT_SPIKES_COUNT = 10 @@ -32,6 +34,8 @@ class Scenario(Enum): def get_select_fields_data(logs): + def sig_smooth(signal): + return masked_symmetric_moving_average(signal, np.ones_like(signal), 5, 1.0) def get_nested_keys(msg, keys): val = None for key in keys: @@ -44,6 +48,8 @@ def get_select_fields_data(logs): data[key].append(get_nested_keys(msg, fields)) for key in data: data[key] = np.array(data[key][JUNK_IDX:], dtype=float) + if key in SMOOTH_FIELDS: + data[key] = sig_smooth(data[key]) return data @@ -110,7 +116,7 @@ class TestLocationdScenarios: """ orig_data, replayed_data = run_scenarios(Scenario.BASE, self.logs) assert np.allclose(orig_data['yaw_rate'], replayed_data['yaw_rate'], atol=np.radians(0.35)) - assert np.allclose(orig_data['roll'], replayed_data['roll'], atol=np.radians(0.55)) + assert np.allclose(orig_data['roll'], replayed_data['roll'], atol=np.radians(0.35)) def test_gyro_off(self): """ @@ -135,7 +141,7 @@ class TestLocationdScenarios: """ orig_data, replayed_data = run_scenarios(Scenario.GYRO_SPIKE_MIDWAY, self.logs) assert np.allclose(orig_data['yaw_rate'], replayed_data['yaw_rate'], atol=np.radians(0.35)) - assert np.allclose(orig_data['roll'], replayed_data['roll'], atol=np.radians(0.55)) + assert np.allclose(orig_data['roll'], replayed_data['roll'], atol=np.radians(0.35)) assert np.all(replayed_data['inputs_flag'] == orig_data['inputs_flag']) assert np.all(replayed_data['sensors_flag'] == orig_data['sensors_flag']) @@ -169,7 +175,7 @@ class TestLocationdScenarios: """ orig_data, replayed_data = run_scenarios(Scenario.ACCEL_SPIKE_MIDWAY, self.logs) assert np.allclose(orig_data['yaw_rate'], replayed_data['yaw_rate'], atol=np.radians(0.35)) - assert np.allclose(orig_data['roll'], replayed_data['roll'], atol=np.radians(0.55)) + assert np.allclose(orig_data['roll'], replayed_data['roll'], atol=np.radians(0.35)) def test_single_timing_spike(self): """ diff --git a/selfdrive/locationd/torqued.py b/selfdrive/locationd/torqued.py index 2140ee603c..28731d2ad7 100755 --- a/selfdrive/locationd/torqued.py +++ b/selfdrive/locationd/torqued.py @@ -188,7 +188,9 @@ class TorqueEstimator(ParameterEstimator, TorqueEstimatorExt): self.lag = get_lat_delay(self.params, msg.lateralDelay) # calculate lateral accel from past steering torque elif which == "livePose": - if len(self.raw_points['steer_torque']) == self.hist_len: + 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) angular_velocity_calibrated = calibrated_pose.angular_velocity diff --git a/selfdrive/modeld/SConscript b/selfdrive/modeld/SConscript index ff63b644d5..f02e667228 100644 --- a/selfdrive/modeld/SConscript +++ b/selfdrive/modeld/SConscript @@ -17,8 +17,8 @@ def estimate_pickle_max_size(onnx_size): # THREADS=0 is need to prevent bug: https://github.com/tinygrad/tinygrad/issues/14689 tg_flags = { 'larch64': 'DEV=QCOM FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0', - 'Darwin': f'DEV=CPU THREADS=0 HOME={os.path.expanduser("~")} IMAGE=0', # tinygrad calls brew which needs a $HOME in the env -}.get(arch, 'DEV=CPU CPU_LLVM=1 THREADS=0 IMAGE=0') + 'Darwin': f'DEV=CPU THREADS=0 HOME={os.path.expanduser("~")}', # tinygrad calls brew which needs a $HOME in the env +}.get(arch, 'DEV=CPU CPU_LLVM=1 THREADS=0') # Get model metadata for model_name in ['driving_vision', 'driving_policy', 'dmonitoring_model']: @@ -45,29 +45,20 @@ def tg_compile(flags, model_name): pkl = fn + "_tinygrad.pkl" onnx_path = fn + ".onnx" chunk_targets = get_chunk_paths(pkl, estimate_pickle_max_size(os.path.getsize(onnx_path))) + compile_node = lenv.Command( + pkl, + [onnx_path] + tinygrad_files + [chunker_file], + f'{pythonpath_string} {flags} {image_flag} python3 {Dir("#tinygrad_repo").abspath}/examples/openpilot/compile3.py {fn}.onnx {pkl}', + ) def do_chunk(target, source, env): chunk_file(pkl, chunk_targets) return lenv.Command( chunk_targets, - [onnx_path] + tinygrad_files + [chunker_file], - [f'{pythonpath_string} {flags} {image_flag} python3 {Dir("#tinygrad_repo").abspath}/examples/openpilot/compile3.py {fn}.onnx {pkl}', - do_chunk] + compile_node, + do_chunk, ) # Compile small models for model_name in ['driving_vision', 'driving_policy', 'dmonitoring_model']: tg_compile(tg_flags, model_name) -# Compile BIG model if USB GPU is available -if "USBGPU" in os.environ: - import subprocess - # because tg doesn't support multi-process - devs = subprocess.check_output('python3 -c "from tinygrad import Device; print(list(Device.get_available_devices()))"', shell=True, cwd=env.Dir('#').abspath) - if b"AMD" in devs: - print("USB GPU detected... building") - flags = "DEV=AMD AMD_IFACE=USB AMD_LLVM=1 NOLOCALS=0 IMAGE=0" - bp = tg_compile(flags, "big_driving_policy") - bv = tg_compile(flags, "big_driving_vision") - lenv.SideEffect('lock', [bp, bv]) # tg doesn't support multi-process so build serially - else: - print("USB GPU not detected... skipping") diff --git a/selfdrive/modeld/dmonitoringmodeld.py b/selfdrive/modeld/dmonitoringmodeld.py index 28190db3e6..efd8214b9f 100755 --- a/selfdrive/modeld/dmonitoringmodeld.py +++ b/selfdrive/modeld/dmonitoringmodeld.py @@ -80,7 +80,7 @@ def parse_model_output(model_output): face_descs = model_output[f'face_descs_{ds_suffix}'] parsed[f'face_descs_{ds_suffix}'] = face_descs[:, :-6] parsed[f'face_descs_{ds_suffix}_std'] = safe_exp(face_descs[:, -6:]) - for key in ['face_prob', 'left_eye_prob', 'right_eye_prob','left_blink_prob', 'right_blink_prob', 'sunglasses_prob', 'using_phone_prob']: + for key in ['face_prob', 'eyes_visible_prob', 'eyes_closed_prob', 'using_phone_prob']: parsed[f'{key}_{ds_suffix}'] = sigmoid(model_output[f'{key}_{ds_suffix}']) return parsed @@ -90,11 +90,8 @@ def fill_driver_data(msg, model_output, ds_suffix): msg.facePosition = model_output[f'face_descs_{ds_suffix}'][0, 3:5].tolist() msg.facePositionStd = model_output[f'face_descs_{ds_suffix}_std'][0, 3:5].tolist() msg.faceProb = model_output[f'face_prob_{ds_suffix}'][0, 0].item() - msg.leftEyeProb = model_output[f'left_eye_prob_{ds_suffix}'][0, 0].item() - msg.rightEyeProb = model_output[f'right_eye_prob_{ds_suffix}'][0, 0].item() - msg.leftBlinkProb = model_output[f'left_blink_prob_{ds_suffix}'][0, 0].item() - msg.rightBlinkProb = model_output[f'right_blink_prob_{ds_suffix}'][0, 0].item() - msg.sunglassesProb = model_output[f'sunglasses_prob_{ds_suffix}'][0, 0].item() + msg.eyesVisibleProb = model_output[f'eyes_visible_prob_{ds_suffix}'][0, 0].item() + msg.eyesClosedProb = model_output[f'eyes_closed_prob_{ds_suffix}'][0, 0].item() msg.phoneProb = model_output[f'using_phone_prob_{ds_suffix}'][0, 0].item() def get_driverstate_packet(model_output, frame_id: int, location_ts: int, exec_time: float, gpu_exec_time: float): diff --git a/selfdrive/modeld/modeld.py b/selfdrive/modeld/modeld.py index 494feb99c1..d774e475d7 100755 --- a/selfdrive/modeld/modeld.py +++ b/selfdrive/modeld/modeld.py @@ -37,11 +37,11 @@ from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase PROCESS_NAME = "selfdrive.modeld.modeld" SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') -VISION_PKL_PATH = Path(__file__).parent / 'models/driving_vision_tinygrad.pkl' -POLICY_PKL_PATH = Path(__file__).parent / 'models/driving_policy_tinygrad.pkl' -VISION_METADATA_PATH = Path(__file__).parent / 'models/driving_vision_metadata.pkl' -POLICY_METADATA_PATH = Path(__file__).parent / 'models/driving_policy_metadata.pkl' MODELS_DIR = Path(__file__).parent / 'models' +VISION_PKL_PATH = MODELS_DIR / 'driving_vision_tinygrad.pkl' +VISION_METADATA_PATH = MODELS_DIR / 'driving_vision_metadata.pkl' +POLICY_PKL_PATH = MODELS_DIR / 'driving_policy_tinygrad.pkl' +POLICY_METADATA_PATH = MODELS_DIR / 'driving_policy_metadata.pkl' LAT_SMOOTH_SECONDS = 0.0 LONG_SMOOTH_SECONDS = 0.3 @@ -396,7 +396,9 @@ def main(demo=False): posenet_send = messaging.new_message('cameraOdometry') mdv2sp_send = messaging.new_message('modelDataV2SP') - action = get_action_from_model(model_output, prev_action, lat_delay + DT_MDL, long_delay + DT_MDL, v_ego) + frame_delay = DT_MDL # compensate for time passed since the frame was captured: current_time - timestamp_eof is 50ms on average + action_delay = DT_MDL / 2 # middle of the interval between model output (current state) and next frame (expected state) + action = get_action_from_model(model_output, prev_action, lat_delay + frame_delay + action_delay, long_delay + frame_delay + action_delay, v_ego) prev_action = action fill_model_msg(drivingdata_send, modelv2_send, model_output, action, publish_state, meta_main.frame_id, meta_extra.frame_id, frame_id, diff --git a/selfdrive/modeld/models/dmonitoring_model.onnx b/selfdrive/modeld/models/dmonitoring_model.onnx index dc621bed03..628f385796 100644 --- a/selfdrive/modeld/models/dmonitoring_model.onnx +++ b/selfdrive/modeld/models/dmonitoring_model.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7aff7ff1dc08bbaf562a8f77380ab5e5914f8557dba2f760d87e4d953f5604b0 -size 7307246 +oid sha256:2fd471febb6e973313ac0d0c6755f6410c1937ba92230b58a433761e8c883072 +size 7364290 diff --git a/selfdrive/modeld/models/driving_policy.onnx b/selfdrive/modeld/models/driving_policy.onnx index 611ae9fe85..7c71bc9471 100644 --- a/selfdrive/modeld/models/driving_policy.onnx +++ b/selfdrive/modeld/models/driving_policy.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:78477124cbf3ffe30fa951ebada8410b43c4242c6054584d656f1d329b067e15 -size 14060847 +oid sha256:853c6634746ff439a848349d00e4d5581cd941f13f7c1862c31b72a31cc24858 +size 14061595 diff --git a/selfdrive/modeld/models/driving_vision.onnx b/selfdrive/modeld/models/driving_vision.onnx index 6c9fc4c84d..afd617667c 100644 --- a/selfdrive/modeld/models/driving_vision.onnx +++ b/selfdrive/modeld/models/driving_vision.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ee29ee5bce84d1ce23e9ff381280de9b4e4d96d2934cd751740354884e112c66 -size 46877473 +oid sha256:940e9006a25f27f0b6e85da798e6a8fd1f6dd492dd7d0b9ff1a9436460f46129 +size 46887794 diff --git a/selfdrive/modeld/parse_model_outputs.py b/selfdrive/modeld/parse_model_outputs.py index 5c11e8ca18..a0b45d2a98 100644 --- a/selfdrive/modeld/parse_model_outputs.py +++ b/selfdrive/modeld/parse_model_outputs.py @@ -110,7 +110,7 @@ class Parser: return outs def parse_policy_outputs(self, outs: dict[str, np.ndarray]) -> dict[str, np.ndarray]: - plan_mhp = self.is_mhp(outs, 'plan', ModelConstants.IDX_N * ModelConstants.PLAN_WIDTH) + plan_mhp = self.is_mhp(outs, 'plan', ModelConstants.IDX_N * ModelConstants.PLAN_WIDTH) plan_in_N, plan_out_N = (ModelConstants.PLAN_MHP_N, ModelConstants.PLAN_MHP_SELECTION) if plan_mhp else (0, 0) self.parse_mdn('plan', outs, in_N=plan_in_N, out_N=plan_out_N, out_shape=(ModelConstants.IDX_N, ModelConstants.PLAN_WIDTH)) if 'planplus' in outs: diff --git a/selfdrive/monitoring/helpers.py b/selfdrive/monitoring/helpers.py index d6d4566884..6c81fac7f0 100644 --- a/selfdrive/monitoring/helpers.py +++ b/selfdrive/monitoring/helpers.py @@ -1,4 +1,4 @@ -from math import atan2 +from math import atan2, radians import numpy as np from cereal import car, log @@ -32,9 +32,8 @@ class DRIVER_MONITOR_SETTINGS: self._DISTRACTED_PROMPT_TIME_TILL_TERMINAL = 6. self._FACE_THRESHOLD = 0.7 - self._EYE_THRESHOLD = 0.65 - self._SG_THRESHOLD = 0.9 - self._BLINK_THRESHOLD = 0.865 + self._EYE_THRESHOLD = 0.5 + self._BLINK_THRESHOLD = 0.5 self._PHONE_THRESH = 0.5 self._POSE_PITCH_THRESHOLD = 0.3133 @@ -43,6 +42,9 @@ class DRIVER_MONITOR_SETTINGS: self._POSE_YAW_THRESHOLD = 0.4020 self._POSE_YAW_THRESHOLD_SLACK = 0.5042 self._POSE_YAW_THRESHOLD_STRICT = self._POSE_YAW_THRESHOLD + self._POSE_YAW_MIN_STEER_DEG = 30 + self._POSE_YAW_STEER_FACTOR = 0.15 + self._POSE_YAW_STEER_MAX_OFFSET = 0.3927 self._PITCH_NATURAL_OFFSET = 0.011 # initial value before offset is learned self._PITCH_NATURAL_THRESHOLD = 0.449 self._YAW_NATURAL_OFFSET = 0.075 # initial value before offset is learned @@ -59,7 +61,6 @@ class DRIVER_MONITOR_SETTINGS: self._POSESTD_THRESHOLD = 0.3 self._HI_STD_FALLBACK_TIME = int(10 / self._DT_DMON) # fall back to wheel touch if model is uncertain for 10s self._DISTRACTED_FILTER_TS = 0.25 # 0.6Hz - self._ALWAYS_ON_ALERT_MIN_SPEED = 11 self._POSE_CALIB_MIN_SPEED = 13 # 30 mph self._POSE_OFFSET_MIN_COUNT = int(60 / self._DT_DMON) # valid data counts before calibration completes, 1min cumulative @@ -101,6 +102,7 @@ class DriverPose: self.low_std = True self.cfactor_pitch = 1. self.cfactor_yaw = 1. + self.steer_yaw_offset = 0. class DriverProb: def __init__(self, raw_priors, max_trackable): @@ -108,11 +110,6 @@ class DriverProb: self.prob_offseter = RunningStatFilter(raw_priors=raw_priors, max_trackable=max_trackable) self.prob_calibrated = False -class DriverBlink: - def __init__(self): - self.left = 0. - self.right = 0. - # model output refers to center of undistorted+leveled image EFL = 598.0 # focal length in K @@ -147,7 +144,7 @@ class DriverMonitoring: wheelpos_filter_raw_priors = (self.settings._WHEELPOS_DATA_AVG, self.settings._WHEELPOS_DATA_VAR, 2) self.wheelpos = DriverProb(raw_priors=wheelpos_filter_raw_priors, max_trackable=self.settings._WHEELPOS_MAX_COUNT) self.pose = DriverPose(settings=self.settings) - self.blink = DriverBlink() + self.blink_prob = 0. self.phone_prob = 0. self.always_on = always_on @@ -238,7 +235,11 @@ class DriverMonitoring: yaw_error = self.pose.yaw - min(max(self.pose.yaw_offseter.filtered_stat.mean(), self.settings._YAW_MIN_OFFSET), self.settings._YAW_MAX_OFFSET) pitch_error = 0 if pitch_error > 0 else abs(pitch_error) # no positive pitch limit - yaw_error = abs(yaw_error) + + if yaw_error * self.pose.steer_yaw_offset > 0: # unidirectional + yaw_error = max(abs(yaw_error) - min(abs(self.pose.steer_yaw_offset), self.settings._POSE_YAW_STEER_MAX_OFFSET), 0.) + else: + yaw_error = abs(yaw_error) pitch_threshold = self.settings._POSE_PITCH_THRESHOLD * self.pose.cfactor_pitch if self.pose.calibrated else self.settings._PITCH_NATURAL_THRESHOLD yaw_threshold = self.settings._POSE_YAW_THRESHOLD * self.pose.cfactor_yaw @@ -246,7 +247,7 @@ class DriverMonitoring: if pitch_error > pitch_threshold or yaw_error > yaw_threshold: distracted_types.append(DistractedType.DISTRACTED_POSE) - if (self.blink.left + self.blink.right)*0.5 > self.settings._BLINK_THRESHOLD: + if self.blink_prob > self.settings._BLINK_THRESHOLD: distracted_types.append(DistractedType.DISTRACTED_BLINK) if self.phone_prob > self.settings._PHONE_THRESH: @@ -254,7 +255,7 @@ class DriverMonitoring: return distracted_types - def _update_states(self, driver_state, cal_rpy, car_speed, op_engaged, standstill, demo_mode=False): + def _update_states(self, driver_state, cal_rpy, car_speed, op_engaged, standstill, demo_mode=False, steering_angle_deg=0.): rhd_pred = driver_state.wheelOnRightProb # calibrates only when there's movement and either face detected if car_speed > self.settings._WHEELPOS_CALIB_MIN_SPEED and (driver_state.leftDriverData.faceProb > self.settings._FACE_THRESHOLD or @@ -277,17 +278,17 @@ class DriverMonitoring: self.face_detected = driver_data.faceProb > self.settings._FACE_THRESHOLD self.pose.roll, self.pose.pitch, self.pose.yaw = face_orientation_from_net(driver_data.faceOrientation, driver_data.facePosition, cal_rpy) + steer_d = max(abs(steering_angle_deg) - self.settings._POSE_YAW_MIN_STEER_DEG, 0.) + self.pose.steer_yaw_offset = radians(steer_d) * -np.sign(steering_angle_deg) * self.settings._POSE_YAW_STEER_FACTOR if self.wheel_on_right: self.pose.yaw *= -1 + self.pose.steer_yaw_offset *= -1 self.wheel_on_right_last = self.wheel_on_right self.pose.pitch_std = driver_data.faceOrientationStd[0] self.pose.yaw_std = driver_data.faceOrientationStd[1] model_std_max = max(self.pose.pitch_std, self.pose.yaw_std) self.pose.low_std = model_std_max < self.settings._POSESTD_THRESHOLD - self.blink.left = driver_data.leftBlinkProb * (driver_data.leftEyeProb > self.settings._EYE_THRESHOLD) \ - * (driver_data.sunglassesProb < self.settings._SG_THRESHOLD) - self.blink.right = driver_data.rightBlinkProb * (driver_data.rightEyeProb > self.settings._EYE_THRESHOLD) \ - * (driver_data.sunglassesProb < self.settings._SG_THRESHOLD) + self.blink_prob = driver_data.eyesClosedProb * (driver_data.eyesVisibleProb > self.settings._EYE_THRESHOLD) self.phone_prob = driver_data.phoneProb self.distracted_types = self._get_distracted_types() @@ -344,10 +345,14 @@ class DriverMonitoring: self._reset_awareness() return - driver_attentive = self.driver_distraction_filter.x < 0.37 awareness_prev = self.awareness + _reaching_pre = self.awareness - self.step_change <= self.threshold_pre + _reaching_terminal = self.awareness - self.step_change <= 0 + standstill_orange_exemption = standstill and _reaching_pre + always_on_red_exemption = always_on_valid and not op_engaged and _reaching_terminal - if (driver_attentive and self.face_detected and self.pose.low_std and self.awareness > 0): + if self.awareness > 0 and \ + ((self.driver_distraction_filter.x < 0.37 and self.face_detected and self.pose.low_std) or standstill_orange_exemption): if driver_engaged: self._reset_awareness() return @@ -360,34 +365,28 @@ class DriverMonitoring: if self.awareness > self.threshold_prompt: return - _reaching_audible = self.awareness - self.step_change <= self.threshold_prompt - _reaching_terminal = self.awareness - self.step_change <= 0 - standstill_orange_exemption = standstill and _reaching_audible - always_on_red_exemption = always_on_valid and not op_engaged and _reaching_terminal - always_on_lowspeed_exemption = always_on_valid and not op_engaged and car_speed < self.settings._ALWAYS_ON_ALERT_MIN_SPEED - certainly_distracted = self.driver_distraction_filter.x > 0.63 and self.driver_distracted and self.face_detected maybe_distracted = self.hi_stds > self.settings._HI_STD_FALLBACK_TIME or not self.face_detected if certainly_distracted or maybe_distracted: - # should always be counting if distracted unless at standstill (lowspeed for always-on) and reaching orange + # should always be counting if distracted unless at standstill and reaching green # also will not be reaching 0 if DM is active when not engaged - if not (standstill_orange_exemption or always_on_red_exemption or (always_on_lowspeed_exemption and _reaching_audible)): + if not (standstill_orange_exemption or always_on_red_exemption): self.awareness = max(self.awareness - self.step_change, -0.1) alert = None if self.awareness <= 0.: # terminal red alert: disengagement required - alert = EventName.driverDistracted if self.active_monitoring_mode else EventName.driverUnresponsive + alert = EventName.driverDistracted3 if self.active_monitoring_mode else EventName.driverUnresponsive3 self.terminal_time += 1 if awareness_prev > 0.: self.terminal_alert_cnt += 1 elif self.awareness <= self.threshold_prompt: # prompt orange alert - alert = EventName.promptDriverDistracted if self.active_monitoring_mode else EventName.promptDriverUnresponsive - elif self.awareness <= self.threshold_pre and not always_on_lowspeed_exemption: + alert = EventName.driverDistracted2 if self.active_monitoring_mode else EventName.driverUnresponsive2 + elif self.awareness <= self.threshold_pre: # pre green alert - alert = EventName.preDriverDistracted if self.active_monitoring_mode else EventName.preDriverUnresponsive + alert = EventName.driverDistracted1 if self.active_monitoring_mode else EventName.driverUnresponsive1 if alert is not None: self.current_events.add(alert) @@ -451,6 +450,7 @@ class DriverMonitoring: op_engaged=enabled, standstill=standstill, demo_mode=demo, + steering_angle_deg=sm['carState'].steeringAngleDeg, ) # Update distraction events diff --git a/selfdrive/monitoring/test_monitoring.py b/selfdrive/monitoring/test_monitoring.py index 75adb6a2c8..ee6028d609 100644 --- a/selfdrive/monitoring/test_monitoring.py +++ b/selfdrive/monitoring/test_monitoring.py @@ -20,10 +20,8 @@ def make_msg(face_detected, distracted=False, model_uncertain=False): ds.leftDriverData.faceOrientation = [0., 0., 0.] ds.leftDriverData.facePosition = [0., 0.] ds.leftDriverData.faceProb = 1. * face_detected - ds.leftDriverData.leftEyeProb = 1. - ds.leftDriverData.rightEyeProb = 1. - ds.leftDriverData.leftBlinkProb = 1. * distracted - ds.leftDriverData.rightBlinkProb = 1. * distracted + ds.leftDriverData.eyesVisibleProb = 1. + ds.leftDriverData.eyesClosedProb = 1. * distracted ds.leftDriverData.faceOrientationStd = [1.*model_uncertain, 1.*model_uncertain, 1.*model_uncertain] ds.leftDriverData.facePositionStd = [1.*model_uncertain, 1.*model_uncertain] # TODO: test both separately when e2e is used @@ -78,11 +76,11 @@ class TestMonitoring: assert len(events[int((d_status.settings._DISTRACTED_TIME-d_status.settings._DISTRACTED_PRE_TIME_TILL_TERMINAL)/2/DT_DMON)]) == 0 assert events[int((d_status.settings._DISTRACTED_TIME-d_status.settings._DISTRACTED_PRE_TIME_TILL_TERMINAL + \ ((d_status.settings._DISTRACTED_PRE_TIME_TILL_TERMINAL-d_status.settings._DISTRACTED_PROMPT_TIME_TILL_TERMINAL)/2))/DT_DMON)].names[0] == \ - EventName.preDriverDistracted + EventName.driverDistracted1 assert events[int((d_status.settings._DISTRACTED_TIME-d_status.settings._DISTRACTED_PROMPT_TIME_TILL_TERMINAL + \ - ((d_status.settings._DISTRACTED_PROMPT_TIME_TILL_TERMINAL)/2))/DT_DMON)].names[0] == EventName.promptDriverDistracted + ((d_status.settings._DISTRACTED_PROMPT_TIME_TILL_TERMINAL)/2))/DT_DMON)].names[0] == EventName.driverDistracted2 assert events[int((d_status.settings._DISTRACTED_TIME + \ - ((TEST_TIMESPAN-10-d_status.settings._DISTRACTED_TIME)/2))/DT_DMON)].names[0] == EventName.driverDistracted + ((TEST_TIMESPAN-10-d_status.settings._DISTRACTED_TIME)/2))/DT_DMON)].names[0] == EventName.driverDistracted3 assert isinstance(d_status.awareness, float) # engaged, no face detected the whole time, no action @@ -91,11 +89,11 @@ class TestMonitoring: assert len(events[int((d_status.settings._AWARENESS_TIME-d_status.settings._AWARENESS_PRE_TIME_TILL_TERMINAL)/2/DT_DMON)]) == 0 assert events[int((d_status.settings._AWARENESS_TIME-d_status.settings._AWARENESS_PRE_TIME_TILL_TERMINAL + \ ((d_status.settings._AWARENESS_PRE_TIME_TILL_TERMINAL-d_status.settings._AWARENESS_PROMPT_TIME_TILL_TERMINAL)/2))/DT_DMON)].names[0] == \ - EventName.preDriverUnresponsive + EventName.driverUnresponsive1 assert events[int((d_status.settings._AWARENESS_TIME-d_status.settings._AWARENESS_PROMPT_TIME_TILL_TERMINAL + \ - ((d_status.settings._AWARENESS_PROMPT_TIME_TILL_TERMINAL)/2))/DT_DMON)].names[0] == EventName.promptDriverUnresponsive + ((d_status.settings._AWARENESS_PROMPT_TIME_TILL_TERMINAL)/2))/DT_DMON)].names[0] == EventName.driverUnresponsive2 assert events[int((d_status.settings._AWARENESS_TIME + \ - ((TEST_TIMESPAN-10-d_status.settings._AWARENESS_TIME)/2))/DT_DMON)].names[0] == EventName.driverUnresponsive + ((TEST_TIMESPAN-10-d_status.settings._AWARENESS_TIME)/2))/DT_DMON)].names[0] == EventName.driverUnresponsive3 # engaged, down to orange, driver pays attention, back to normal; then down to orange, driver touches wheel # - should have short orange recovery time and no green afterwards; wheel touch only recovers when paying attention @@ -108,10 +106,10 @@ class TestMonitoring: [car_interaction_DETECTED] * (int(TEST_TIMESPAN/DT_DMON)-int(DISTRACTED_SECONDS_TO_ORANGE*3/DT_DMON)) events, _ = self._run_seq(ds_vector, interaction_vector, always_true, always_false) assert len(events[int(DISTRACTED_SECONDS_TO_ORANGE*0.5/DT_DMON)]) == 0 - assert events[int((DISTRACTED_SECONDS_TO_ORANGE-0.1)/DT_DMON)].names[0] == EventName.promptDriverDistracted + assert events[int((DISTRACTED_SECONDS_TO_ORANGE-0.1)/DT_DMON)].names[0] == EventName.driverDistracted2 assert len(events[int(DISTRACTED_SECONDS_TO_ORANGE*1.5/DT_DMON)]) == 0 - assert events[int((DISTRACTED_SECONDS_TO_ORANGE*3-0.1)/DT_DMON)].names[0] == EventName.promptDriverDistracted - assert events[int((DISTRACTED_SECONDS_TO_ORANGE*3+0.1)/DT_DMON)].names[0] == EventName.promptDriverDistracted + assert events[int((DISTRACTED_SECONDS_TO_ORANGE*3-0.1)/DT_DMON)].names[0] == EventName.driverDistracted2 + assert events[int((DISTRACTED_SECONDS_TO_ORANGE*3+0.1)/DT_DMON)].names[0] == EventName.driverDistracted2 assert len(events[int((DISTRACTED_SECONDS_TO_ORANGE*3+2.5)/DT_DMON)]) == 0 # engaged, down to orange, driver dodges camera, then comes back still distracted, down to red, \ @@ -131,9 +129,9 @@ class TestMonitoring: op_vector[int((DISTRACTED_SECONDS_TO_RED+2*_invisible_time+2.5)/DT_DMON):int((DISTRACTED_SECONDS_TO_RED+2*_invisible_time+3)/DT_DMON)] \ = [False] * int(0.5/DT_DMON) events, _ = self._run_seq(ds_vector, interaction_vector, op_vector, always_false) - assert events[int((DISTRACTED_SECONDS_TO_ORANGE+0.5*_invisible_time)/DT_DMON)].names[0] == EventName.promptDriverDistracted - assert events[int((DISTRACTED_SECONDS_TO_RED+1.5*_invisible_time)/DT_DMON)].names[0] == EventName.driverDistracted - assert events[int((DISTRACTED_SECONDS_TO_RED+2*_invisible_time+1.5)/DT_DMON)].names[0] == EventName.driverDistracted + assert events[int((DISTRACTED_SECONDS_TO_ORANGE+0.5*_invisible_time)/DT_DMON)].names[0] == EventName.driverDistracted2 + assert events[int((DISTRACTED_SECONDS_TO_RED+1.5*_invisible_time)/DT_DMON)].names[0] == EventName.driverDistracted3 + assert events[int((DISTRACTED_SECONDS_TO_RED+2*_invisible_time+1.5)/DT_DMON)].names[0] == EventName.driverDistracted3 assert len(events[int((DISTRACTED_SECONDS_TO_RED+2*_invisible_time+3.5)/DT_DMON)]) == 0 # engaged, invisible driver, down to orange, driver touches wheel; then down to orange again, driver appears @@ -147,13 +145,13 @@ class TestMonitoring: interaction_vector[int((INVISIBLE_SECONDS_TO_ORANGE)/DT_DMON):int((INVISIBLE_SECONDS_TO_ORANGE+1)/DT_DMON)] = [True] * int(1/DT_DMON) events, _ = self._run_seq(ds_vector, interaction_vector, 2*always_true, 2*always_false) assert len(events[int(INVISIBLE_SECONDS_TO_ORANGE*0.5/DT_DMON)]) == 0 - assert events[int((INVISIBLE_SECONDS_TO_ORANGE-0.1)/DT_DMON)].names[0] == EventName.promptDriverUnresponsive + assert events[int((INVISIBLE_SECONDS_TO_ORANGE-0.1)/DT_DMON)].names[0] == EventName.driverUnresponsive2 assert len(events[int((INVISIBLE_SECONDS_TO_ORANGE+0.1)/DT_DMON)]) == 0 if _visible_time == 0.5: - assert events[int((INVISIBLE_SECONDS_TO_ORANGE*2+1-0.1)/DT_DMON)].names[0] == EventName.promptDriverUnresponsive - assert events[int((INVISIBLE_SECONDS_TO_ORANGE*2+1+0.1+_visible_time)/DT_DMON)].names[0] == EventName.preDriverUnresponsive + assert events[int((INVISIBLE_SECONDS_TO_ORANGE*2+1-0.1)/DT_DMON)].names[0] == EventName.driverUnresponsive2 + assert events[int((INVISIBLE_SECONDS_TO_ORANGE*2+1+0.1+_visible_time)/DT_DMON)].names[0] == EventName.driverUnresponsive1 elif _visible_time == 10: - assert events[int((INVISIBLE_SECONDS_TO_ORANGE*2+1-0.1)/DT_DMON)].names[0] == EventName.promptDriverUnresponsive + assert events[int((INVISIBLE_SECONDS_TO_ORANGE*2+1-0.1)/DT_DMON)].names[0] == EventName.driverUnresponsive2 assert len(events[int((INVISIBLE_SECONDS_TO_ORANGE*2+1+0.1+_visible_time)/DT_DMON)]) == 0 # engaged, invisible driver, down to red, driver appears and then touches wheel, then disengages/reengages @@ -168,10 +166,10 @@ class TestMonitoring: op_vector[int((INVISIBLE_SECONDS_TO_RED+_visible_time+1)/DT_DMON):int((INVISIBLE_SECONDS_TO_RED+_visible_time+0.5)/DT_DMON)] = [False] * int(0.5/DT_DMON) events, _ = self._run_seq(ds_vector, interaction_vector, op_vector, always_false) assert len(events[int(INVISIBLE_SECONDS_TO_ORANGE*0.5/DT_DMON)]) == 0 - assert events[int((INVISIBLE_SECONDS_TO_ORANGE-0.1)/DT_DMON)].names[0] == EventName.promptDriverUnresponsive - assert events[int((INVISIBLE_SECONDS_TO_RED-0.1)/DT_DMON)].names[0] == EventName.driverUnresponsive - assert events[int((INVISIBLE_SECONDS_TO_RED+0.5*_visible_time)/DT_DMON)].names[0] == EventName.driverUnresponsive - assert events[int((INVISIBLE_SECONDS_TO_RED+_visible_time+0.5)/DT_DMON)].names[0] == EventName.driverUnresponsive + assert events[int((INVISIBLE_SECONDS_TO_ORANGE-0.1)/DT_DMON)].names[0] == EventName.driverUnresponsive2 + assert events[int((INVISIBLE_SECONDS_TO_RED-0.1)/DT_DMON)].names[0] == EventName.driverUnresponsive3 + assert events[int((INVISIBLE_SECONDS_TO_RED+0.5*_visible_time)/DT_DMON)].names[0] == EventName.driverUnresponsive3 + assert events[int((INVISIBLE_SECONDS_TO_RED+_visible_time+0.5)/DT_DMON)].names[0] == EventName.driverUnresponsive3 assert len(events[int((INVISIBLE_SECONDS_TO_RED+_visible_time+1+0.1)/DT_DMON)]) == 0 # disengaged, always distracted driver @@ -187,10 +185,21 @@ class TestMonitoring: standstill_vector = always_true[:] standstill_vector[int(_redlight_time/DT_DMON):] = [False] * int((TEST_TIMESPAN-_redlight_time)/DT_DMON) events, d_status = self._run_seq(always_distracted, always_false, always_true, standstill_vector) - assert events[int((d_status.settings._DISTRACTED_TIME-d_status.settings._DISTRACTED_PRE_TIME_TILL_TERMINAL+1)/DT_DMON)].names[0] == \ - EventName.preDriverDistracted - assert events[int((_redlight_time-0.1)/DT_DMON)].names[0] == EventName.preDriverDistracted - assert events[int((_redlight_time+0.5)/DT_DMON)].names[0] == EventName.promptDriverDistracted + assert len(events[int((_redlight_time-0.1)/DT_DMON)]) == 0 + _pre_to_prompt = d_status.settings._DISTRACTED_PRE_TIME_TILL_TERMINAL - d_status.settings._DISTRACTED_PROMPT_TIME_TILL_TERMINAL + assert events[int((_redlight_time+0.5)/DT_DMON)].names[0] == EventName.driverDistracted1 + assert events[int((_redlight_time+_pre_to_prompt+0.5)/DT_DMON)].names[0] == EventName.driverDistracted2 + + # engaged, distracted while moving, then car stops after reaching orange + # - should reset timer to pre green at standstill + def test_distracted_then_stops(self): + _stop_time = DISTRACTED_SECONDS_TO_ORANGE + 1 # stop 1 second after reaching orange + standstill_vector = always_false[:] + standstill_vector[int(_stop_time/DT_DMON):] = [True] * int((TEST_TIMESPAN-_stop_time)/DT_DMON) + events, _ = self._run_seq(always_distracted, always_false, always_true, standstill_vector) + # just before and briefly after stopping: orange alert; goes away quickly after stopped + assert events[int((_stop_time+0.1)/DT_DMON)].names[0] == EventName.driverDistracted2 + assert len(events[int((_stop_time+0.5)/DT_DMON)]) == 0 # engaged, model is somehow uncertain and driver is distracted # - should fall back to wheel touch after uncertain alert @@ -198,11 +207,11 @@ class TestMonitoring: ds_vector = [msg_DISTRACTED_BUT_SOMEHOW_UNCERTAIN] * int(TEST_TIMESPAN/DT_DMON) interaction_vector = always_false[:] events, d_status = self._run_seq(ds_vector, interaction_vector, always_true, always_false) - assert EventName.preDriverUnresponsive in \ + assert EventName.driverUnresponsive1 in \ events[int((INVISIBLE_SECONDS_TO_ORANGE-1+DT_DMON*d_status.settings._HI_STD_FALLBACK_TIME-0.1)/DT_DMON)].names - assert EventName.promptDriverUnresponsive in \ + assert EventName.driverUnresponsive2 in \ events[int((INVISIBLE_SECONDS_TO_ORANGE-1+DT_DMON*d_status.settings._HI_STD_FALLBACK_TIME+0.1)/DT_DMON)].names - assert EventName.driverUnresponsive in \ + assert EventName.driverUnresponsive3 in \ events[int((INVISIBLE_SECONDS_TO_RED-1+DT_DMON*d_status.settings._HI_STD_FALLBACK_TIME+0.1)/DT_DMON)].names @@ -267,4 +276,3 @@ def test_enabled_states(enabled_state, lat_active_state, expected): actual_enabled = captured_args[0] assert actual_enabled == expected, f"Expected op_engaged={expected}, but got {actual_enabled}" - diff --git a/selfdrive/pandad/pandad.cc b/selfdrive/pandad/pandad.cc index 3d0a551d80..cb60917008 100644 --- a/selfdrive/pandad/pandad.cc +++ b/selfdrive/pandad/pandad.cc @@ -21,8 +21,6 @@ #define CUTOFF_IL 400 #define SATURATE_IL 1000 -#define ALT_EXP_MADS_DISENGAGE_LATERAL_ON_BRAKE 2048 - ExitHandler do_exit; bool check_connected(Panda *panda) { @@ -34,15 +32,8 @@ bool check_connected(Panda *panda) { } bool process_mads_heartbeat(SubMaster *sm) { - const int &alt_exp = (*sm)["carParams"].getCarParams().getAlternativeExperience(); - const bool disengage_lateral_on_brake = (alt_exp & ALT_EXP_MADS_DISENGAGE_LATERAL_ON_BRAKE) != 0; - const auto &mads = (*sm)["selfdriveStateSP"].getSelfdriveStateSP().getMads(); - const bool heartbeat_type = disengage_lateral_on_brake ? mads.getActive() : mads.getEnabled(); - - const bool engaged = sm->allAliveAndValid({"selfdriveStateSP"}) && heartbeat_type; - - return engaged; + return sm->allAliveAndValid({"selfdriveStateSP"}) && mads.getEnabled(); } Panda *connect(std::string serial) { @@ -152,6 +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); } void fill_panda_can_state(cereal::PandaState::PandaCanState::Builder &cs, const can_health_t &can_health) { @@ -306,7 +299,7 @@ void process_panda_state(Panda *panda, PubMaster *pm, bool engaged, bool engaged panda->send_heartbeat(engaged, engaged_mads); } -void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control) { +void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control, bool is_onroad) { static Params params; static SubMaster sm({"deviceState", "driverCameraState"}); @@ -316,6 +309,8 @@ void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control) static int prev_ir_pwr = 999; static uint32_t prev_frame_id = UINT32_MAX; static bool driver_view = false; + static bool not_car = false; + static bool not_car_checked = false; // TODO: can we merge these? static FirstOrderFilter integ_lines_filter(0, 30.0, 0.05); @@ -361,6 +356,21 @@ void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control) ir_pwr = 0; } + // turn off IR leds if body + if (!not_car_checked && is_onroad) { + std::string cp_bytes = params.get("CarParams"); + if (cp_bytes.size() > 0) { + AlignedBuffer aligned_buf; + capnp::FlatArrayMessageReader cmsg(aligned_buf.align(cp_bytes.data(), cp_bytes.size())); + cereal::CarParams::Reader CP = cmsg.getRoot(); + not_car = CP.getNotCar(); + not_car_checked = true; + } + } + if (not_car) { + ir_pwr = 0; + } + if (ir_pwr != prev_ir_pwr || sm.frame % 100 == 0) { int16_t ir_panda = util::map_val(ir_pwr, 0, 100, 0, MAX_IR_PANDA_VAL); panda->set_ir_pwr(ir_panda); @@ -380,7 +390,7 @@ void pandad_run(Panda *panda) { Params params; RateKeeper rk("pandad", 100); - SubMaster sm({"selfdriveState", "selfdriveStateSP", "carParams"}); + SubMaster sm({"selfdriveState", "selfdriveStateSP"}); PubMaster pm({"can", "pandaStates", "peripheralState"}); PandaSafety panda_safety(panda); bool engaged = false; @@ -394,7 +404,7 @@ void pandad_run(Panda *panda) { // Process peripheral state at 20 Hz if (rk.frame() % 5 == 0) { - process_peripheral_state(panda, &pm, no_fan_control); + process_peripheral_state(panda, &pm, no_fan_control, is_onroad); } // Process panda state at 10 Hz diff --git a/selfdrive/pandad/pandad.py b/selfdrive/pandad/pandad.py index a0bca3f239..f65c64259f 100755 --- a/selfdrive/pandad/pandad.py +++ b/selfdrive/pandad/pandad.py @@ -32,8 +32,7 @@ def flash_panda(panda_serial: str) -> Panda: raise # skip flashing if the detected panda is not supported - supported_panda = check_panda_support(panda) - if not supported_panda: + if panda.get_type() not in Panda.SUPPORTED_DEVICES: cloudlog.warning(f"Panda {panda_serial} is not supported (hw_type: {panda.get_type()}), skipping flash...") return panda @@ -69,12 +68,20 @@ def flash_panda(panda_serial: str) -> Panda: return panda -def check_panda_support(panda) -> bool: - hw_type = panda.get_type() - if hw_type in Panda.SUPPORTED_DEVICES: - return True +def check_panda_support(panda_serials: list[str]) -> list[str]: + spi_serials = set(Panda.spi_list()) + for serial in panda_serials: + if serial in spi_serials: + return [serial] - return False + for serial in panda_serials: + panda = Panda(serial) + is_internal = panda.is_internal() + panda.close() + if is_internal: + return [serial] + + return [] def main() -> None: @@ -126,13 +133,18 @@ def main() -> None: cloudlog.info(f"{len(panda_serials)} panda(s) found, connecting - {panda_serials}") + # custom flasher for xnor's Rivian Longitudinal Upgrade Kit + flash_rivian_long(panda_serials) + + # find the internal supported panda (e.g. skip external Black Panda) + panda_serials = check_panda_support(panda_serials) + if len(panda_serials) == 0: + continue + # Flash the first panda panda_serial = panda_serials[0] panda = flash_panda(panda_serial) - # flash Rivian longitudinal upgrade panda - flash_rivian_long(panda) - # Ensure internal panda is present if expected if HARDWARE.has_internal_panda() and not panda.is_internal(): cloudlog.error("Internal panda is missing, trying again") @@ -143,12 +155,6 @@ def main() -> None: # log panda fw version params.put("PandaSignatures", panda.get_signature()) - # skip health check if the detected panda is not supported - supported_panda = check_panda_support(panda) - if not supported_panda: - cloudlog.warning(f"Panda {panda.get_usb_serial()} is not supported (hw_type: {panda.get_type()}), skipping health check...") - continue - # check health for lost heartbeat health = panda.health() if health["heartbeat_lost"]: diff --git a/selfdrive/pandad/tests/test_pandad.py b/selfdrive/pandad/tests/test_pandad.py index 88d3939a6a..6a5840d487 100644 --- a/selfdrive/pandad/tests/test_pandad.py +++ b/selfdrive/pandad/tests/test_pandad.py @@ -78,22 +78,6 @@ class TestPandad: assert any(Panda(s).is_internal() for s in Panda.list()) - def test_best_case_startup_time(self): - # run once so we're up to date - self._run_test(60) - - ts = [] - for _ in range(10): - # should be nearly instant this time - dt = self._run_test(5) - ts.append(dt) - - # 5s for USB (due to enumeration) - # - 0.2s pandad -> pandad - # - plus some buffer - print("startup times", ts, sum(ts) / len(ts)) - assert 0.1 < (sum(ts)/len(ts)) < 0.7 - def test_old_spi_protocol(self): # flash firmware with old SPI protocol self._flash_bootstub(os.path.join(HERE, "bootstub.panda_h7_spiv0.bin")) diff --git a/selfdrive/selfdrived/events.py b/selfdrive/selfdrived/events.py index 3e20a28023..c9b281436c 100755 --- a/selfdrive/selfdrived/events.py +++ b/selfdrive/selfdrived/events.py @@ -235,6 +235,11 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { "Ensure road ahead is clear"), }, + EventName.lateralManeuver: { + ET.WARNING: longitudinal_maneuver_alert, + ET.PERMANENT: NormalPermanentAlert("Lateral Maneuver Mode"), + }, + EventName.selfdriveInitializing: { ET.NO_ENTRY: NoEntryAlert("System Initializing"), }, @@ -333,7 +338,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { Priority.LOW, VisualAlert.steerRequired, AudibleAlert.prompt, 1.8), }, - EventName.preDriverDistracted: { + EventName.driverDistracted1: { ET.PERMANENT: Alert( "Pay Attention", "", @@ -341,7 +346,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { Priority.LOW, VisualAlert.none, AudibleAlert.none, .1), }, - EventName.promptDriverDistracted: { + EventName.driverDistracted2: { ET.PERMANENT: Alert( "Pay Attention", "Driver Distracted", @@ -349,7 +354,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { Priority.MID, VisualAlert.steerRequired, AudibleAlert.promptDistracted, .1), }, - EventName.driverDistracted: { + EventName.driverDistracted3: { ET.PERMANENT: Alert( "DISENGAGE IMMEDIATELY", "Driver Distracted", @@ -357,7 +362,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { Priority.HIGH, VisualAlert.steerRequired, AudibleAlert.warningImmediate, .1), }, - EventName.preDriverUnresponsive: { + EventName.driverUnresponsive1: { ET.PERMANENT: Alert( "Touch Steering Wheel: No Face Detected", "", @@ -365,7 +370,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { Priority.LOW, VisualAlert.steerRequired, AudibleAlert.none, .1), }, - EventName.promptDriverUnresponsive: { + EventName.driverUnresponsive2: { ET.PERMANENT: Alert( "Touch Steering Wheel", "Driver Unresponsive", @@ -373,7 +378,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { Priority.MID, VisualAlert.steerRequired, AudibleAlert.promptDistracted, .1), }, - EventName.driverUnresponsive: { + EventName.driverUnresponsive3: { ET.PERMANENT: Alert( "DISENGAGE IMMEDIATELY", "Driver Unresponsive", @@ -853,14 +858,14 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { if HARDWARE.get_device_type() == 'mici': EVENTS.update({ - EventName.preDriverDistracted: { + EventName.driverDistracted1: { ET.PERMANENT: Alert( "Pay Attention", "", AlertStatus.normal, AlertSize.small, Priority.LOW, VisualAlert.none, AudibleAlert.none, 2), }, - EventName.promptDriverDistracted: { + EventName.driverDistracted2: { ET.PERMANENT: Alert( "Pay Attention", "Driver Distracted", diff --git a/selfdrive/selfdrived/selfdrived.py b/selfdrive/selfdrived/selfdrived.py index 7b546f3780..1a10d91cd7 100755 --- a/selfdrive/selfdrived/selfdrived.py +++ b/selfdrive/selfdrived/selfdrived.py @@ -89,7 +89,7 @@ class SelfdriveD(CruiseHelper): # 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'] + ['modelDataV2SP'] + ignore = self.sensor_packets + self.gps_packets + ['alertDebug', 'lateralManeuverPlan'] + ['modelDataV2SP'] if SIMULATION: ignore += ['driverCameraState', 'managerState'] if REPLAY: @@ -99,7 +99,7 @@ class SelfdriveD(CruiseHelper): 'carOutput', 'driverMonitoringState', 'longitudinalPlan', 'livePose', 'liveDelay', 'managerState', 'liveParameters', 'radarState', 'liveTorqueParameters', 'controlsState', 'carControl', 'driverAssistance', 'alertDebug', 'userBookmark', 'audioFeedback', - 'modelDataV2SP', 'longitudinalPlanSP'] + \ + 'lateralManeuverPlan', 'modelDataV2SP', 'longitudinalPlanSP'] + \ self.camera_packets + self.sensor_packets + self.gps_packets, ignore_alive=ignore, ignore_avg_freq=ignore, ignore_valid=ignore, frequency=int(1/DT_CTRL)) @@ -183,7 +183,10 @@ class SelfdriveD(CruiseHelper): self.events.add(EventName.joystickDebug) self.startup_event = None - if self.sm.recv_frame['alertDebug'] > 0: + if self.sm.recv_frame['lateralManeuverPlan'] > 0: + self.events.add(EventName.lateralManeuver) + self.startup_event = None + elif self.sm.recv_frame['alertDebug'] > 0: self.events.add(EventName.longitudinalManeuver) self.startup_event = None @@ -227,7 +230,7 @@ class SelfdriveD(CruiseHelper): if self.CP.notCar: # wait for everything to init first - if self.sm.frame > int(5. / DT_CTRL) and self.initialized: + if self.sm.frame > int(2. / DT_CTRL) and self.initialized: # body always wants to enable self.events.add(EventName.pcmEnable) diff --git a/selfdrive/test/.gitignore b/selfdrive/test/.gitignore index 5801faadf4..b8c6bebd95 100644 --- a/selfdrive/test/.gitignore +++ b/selfdrive/test/.gitignore @@ -3,7 +3,7 @@ docker_out/ process_replay/diff.txt process_replay/model_diff.txt +process_replay/fakedata/ valgrind_logs.txt -*.bz2 *.hevc diff --git a/selfdrive/test/fuzzy_generation.py b/selfdrive/test/fuzzy_generation.py index 131dab47b2..9f028a8fc8 100644 --- a/selfdrive/test/fuzzy_generation.py +++ b/selfdrive/test/fuzzy_generation.py @@ -46,8 +46,8 @@ class FuzzyGenerator: 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 = schema.non_union_fields + single_fill - return st.fixed_dictionaries({field: self.generate_field(schema.fields[field]) for field in fields_to_generate if not field.endswith('DEPRECATED')}) + 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 diff --git a/selfdrive/test/process_replay/.gitignore b/selfdrive/test/process_replay/.gitignore deleted file mode 100644 index a35cd58d41..0000000000 --- a/selfdrive/test/process_replay/.gitignore +++ /dev/null @@ -1 +0,0 @@ -fakedata/ diff --git a/selfdrive/test/process_replay/compare_logs.py b/selfdrive/test/process_replay/compare_logs.py index e2d912a833..4c522c9150 100755 --- a/selfdrive/test/process_replay/compare_logs.py +++ b/selfdrive/test/process_replay/compare_logs.py @@ -76,7 +76,7 @@ def _diff_capnp_values(v1, v2, path, tolerance): for i in range(n): yield from _diff_capnp_values(v1[i], v2[i], path + (str(i),), tolerance) if n2 > n: - yield 'add', dot, list(enumerate(v2[n:], n)) + yield 'add', dot, [(i, v2[i]) for i in range(n, n2)] if n1 > n: yield 'remove', dot, list(reversed([(i, v1[i]) for i in range(n, n1)])) diff --git a/selfdrive/test/process_replay/diff_report.py b/selfdrive/test/process_replay/diff_report.py new file mode 100644 index 0000000000..5da78657f4 --- /dev/null +++ b/selfdrive/test/process_replay/diff_report.py @@ -0,0 +1,94 @@ +import os +from collections import defaultdict + +from opendbc.car.tests.car_diff import format_diff, format_numeric_diffs +from openpilot.selfdrive.test.process_replay.compare_logs import compare_logs +from openpilot.selfdrive.test.process_replay.process_replay import PROC_REPLAY_DIR + + +class MsgWrap: + """Adapter so to_dict() includes defaults""" + def __init__(self, msg): + self._msg = msg + def to_dict(self) -> dict: + return self._msg.to_dict(verbose=True) + + +def diff_process(cfg, ref_msgs, new_msgs) -> tuple | None: + ref = defaultdict(list) + new = defaultdict(list) + for m in ref_msgs: + if m.which() in cfg.subs: + ref[m.which()].append(m) + for m in new_msgs: + if m.which() in cfg.subs: + new[m.which()].append(m) + + diffs = [] + for sub in cfg.subs: + if len(ref[sub]) != len(new[sub]): + diffs.append((f"{sub} (message count)", 0, (len(ref[sub]), len(new[sub])), 0)) + for i, (r, n) in enumerate(zip(ref[sub], new[sub], strict=False)): + for d in compare_logs([r], [n], cfg.ignore, tolerance=cfg.tolerance): + if d[0] == "change": + a, b = d[2] + if a != a and b != b: + continue + diffs.append((d[1], i, d[2], r.logMonoTime)) + elif d[0] in ("add", "remove"): + for item in d[2]: + if item[1] != item[1]: + continue + diffs.append((f"{d[1]}.{item[0]}", i, (d[0], item[1]), r.logMonoTime)) + return (diffs, ref, new) if diffs else None + + +def diff_format(diffs, ref, new, field) -> list[str]: + if any(part.isdigit() for part in field.split(".")): + return format_numeric_diffs(diffs) + msg_type = field.split(".")[0] + ref_ts = [(m.logMonoTime, MsgWrap(m)) for m in ref.get(msg_type, [])] + new_wrapped = [MsgWrap(m) for m in new.get(msg_type, [])] + if not ref_ts or not new_wrapped: + return format_numeric_diffs(diffs) + return format_diff(diffs, ref_ts, new_wrapped, field) + + +def diff_report(replay_diffs, segments) -> None: + seg_to_plat = {seg: plat for plat, seg in segments} + + with_diffs, errors, n_passed = [], [], 0 + for seg, proc, data in replay_diffs: + plat = seg_to_plat.get(seg, "UNKNOWN") + if data is None: + n_passed += 1 + elif isinstance(data, str): + errors.append((plat, seg, proc, data)) + else: + with_diffs.append((plat, seg, proc, data)) + + icon = "⚠️" if with_diffs else "✅" + lines = [ + "## Process replay diff report", + "Replays driving segments through this PR and compares the behavior to master.", + "Please review any changes carefully to ensure they are expected.\n", + f"{icon} {len(with_diffs)} changed, {n_passed} passed, {len(errors)} errors", + ] + + for plat, seg, proc, err in errors: + lines.append(f"\nERROR {plat} - {seg} [{proc}]: {err}") + + if with_diffs: + lines.append("
Show changes\n\n```") + for plat, seg, proc, (diffs, ref, new) in with_diffs: + lines.append(f"\n{plat} - {seg} [{proc}]") + by_field = defaultdict(list) + for d in diffs: + by_field[d[0]].append(d) + for field, fd in sorted(by_field.items()): + lines.append(f"\n {field} ({len(fd)} diffs)") + lines.extend(diff_format(fd, ref, new, field)) + lines.append("```\n
") + + with open(os.path.join(PROC_REPLAY_DIR, "diff_report.txt"), "w") as f: + f.write("\n".join(lines)) diff --git a/selfdrive/test/process_replay/migration.py b/selfdrive/test/process_replay/migration.py index 9dc1392a86..278c366609 100644 --- a/selfdrive/test/process_replay/migration.py +++ b/selfdrive/test/process_replay/migration.py @@ -39,6 +39,7 @@ def migrate_all(lr: LogIterable, manager_states: bool = False, panda_states: boo migrate_carOutput, migrate_controlsState, migrate_carState, + migrate_livePose, migrate_liveTracks, migrate_driverAssistance, migrate_drivingModelData, @@ -99,6 +100,17 @@ def migration(inputs: list[str], product: str|None=None): return decorator +def migrate_onroad_event(event: capnp.lib.capnp._DynamicStructReader): + event_dict = event.to_dict() + try: + return log.OnroadEvent(**event_dict) + except capnp.lib.capnp.KjException as e: + # Ignore legacy events the current schema no longer defines. + if "enum has no such enumerant" in str(e): + return None + raise + + @migration(inputs=["longitudinalPlan", "carParams"]) def migrate_longitudinalPlan(msgs): ops = [] @@ -177,6 +189,7 @@ def migrate_liveLocationKalman(msgs): m = messaging.new_message('livePose') m.valid = msg.valid m.logMonoTime = msg.logMonoTime + m.livePose.timestamp = msg.logMonoTime for field in ["orientationNED", "velocityDevice", "accelerationDevice", "angularVelocityDevice"]: lp_field, llk_field = getattr(m.livePose, field), getattr(msg.liveLocationKalmanDEPRECATED, field) lp_field.x, lp_field.y, lp_field.z = llk_field.value or nans @@ -188,6 +201,21 @@ def migrate_liveLocationKalman(msgs): return ops, [], [] +@migration(inputs=["livePose"]) +def migrate_livePose(msgs): + ops = [] + needs_migration = all(msg.livePose.timestamp == 0 for _, msg in msgs if msg.which() == 'livePose') + if not needs_migration: + return [], [], [] + + for index, msg in msgs: + if msg.which() == "livePose": + new_msg = msg.as_builder() + new_msg.livePose.timestamp = msg.logMonoTime + ops.append((index, new_msg.as_reader())) + return ops, [], [] + + @migration(inputs=["controlsState"], product="selfdriveState") def migrate_controlsState(msgs): add_ops = [] @@ -199,7 +227,7 @@ def migrate_controlsState(msgs): for field in ("enabled", "active", "state", "engageable", "alertText1", "alertText2", "alertStatus", "alertSize", "alertType", "experimentalMode", "personality"): - setattr(ss, field, getattr(msg.controlsState, field+"DEPRECATED")) + setattr(ss, field, getattr(msg.controlsState.deprecated, field)) add_ops.append(m.as_reader()) return [], add_ops, [] @@ -212,10 +240,10 @@ def migrate_carState(msgs): if msg.which() == 'controlsState': last_cs = msg elif msg.which() == 'carState' and last_cs is not None: - if last_cs.controlsState.vCruiseDEPRECATED - msg.carState.vCruise > 0.1: + if last_cs.controlsState.deprecated.vCruise - msg.carState.vCruise > 0.1: msg = msg.as_builder() - msg.carState.vCruise = last_cs.controlsState.vCruiseDEPRECATED - msg.carState.vCruiseCluster = last_cs.controlsState.vCruiseClusterDEPRECATED + msg.carState.vCruise = last_cs.controlsState.deprecated.vCruise + msg.carState.vCruiseCluster = last_cs.controlsState.deprecated.vCruiseCluster ops.append((index, msg.as_reader())) return ops, [], [] @@ -277,7 +305,7 @@ def migrate_pandaStates(msgs): safety_param_migration = { "TOYOTA_PRIUS": EPS_SCALE["TOYOTA_PRIUS"] | ToyotaSafetyFlags.STOCK_LONGITUDINAL, "TOYOTA_RAV4": EPS_SCALE["TOYOTA_RAV4"] | ToyotaSafetyFlags.ALT_BRAKE, - "KIA_EV6": HyundaiSafetyFlags.EV_GAS | HyundaiSafetyFlags.CANFD_LKA_STEERING, + "KIA_EV6": HyundaiSafetyFlags.EV_GAS | HyundaiSafetyFlags.CANFD_LKA_STEER_MSG, "CHEVROLET_VOLT": GMSafetyFlags.EV, "CHEVROLET_BOLT_EUV": GMSafetyFlags.EV | GMSafetyFlags.HW_CAM, } @@ -441,12 +469,13 @@ def migrate_onroadEvents(msgs): for event in msg.onroadEventsDEPRECATED: try: if not str(event.name).endswith('DEPRECATED'): - # dict converts name enum into string representation - onroadEvents.append(log.OnroadEvent(**event.to_dict())) + migrated_event = migrate_onroad_event(event) + if migrated_event is not None: + onroadEvents.append(migrated_event) except RuntimeError: # Member was null traceback.print_exc() - new_msg = messaging.new_message('onroadEvents', len(msg.onroadEventsDEPRECATED)) + new_msg = messaging.new_message('onroadEvents', len(onroadEvents)) new_msg.valid = msg.valid new_msg.logMonoTime = msg.logMonoTime new_msg.onroadEvents = onroadEvents @@ -461,11 +490,12 @@ def migrate_driverMonitoringState(msgs): for index, msg in msgs: msg = msg.as_builder() events = [] - for event in msg.driverMonitoringState.eventsDEPRECATED: + for event in msg.driverMonitoringState.deprecated.events: try: if not str(event.name).endswith('DEPRECATED'): - # dict converts name enum into string representation - events.append(log.OnroadEvent(**event.to_dict())) + migrated_event = migrate_onroad_event(event) + if migrated_event is not None: + events.append(migrated_event) except RuntimeError: # Member was null traceback.print_exc() diff --git a/selfdrive/test/process_replay/model_replay.py b/selfdrive/test/process_replay/model_replay.py index a6ccaa1047..eb7cdbe34a 100755 --- a/selfdrive/test/process_replay/model_replay.py +++ b/selfdrive/test/process_replay/model_replay.py @@ -76,7 +76,7 @@ def generate_report(proposed, master, tmp, commit): (lambda x: get_idx_if_non_empty(x.wheelOnRightProb), "wheelOnRightProb"), (lambda x: get_idx_if_non_empty(x.leftDriverData.faceProb), "leftDriverData.faceProb"), (lambda x: get_idx_if_non_empty(x.leftDriverData.faceOrientation, 0), "leftDriverData.faceOrientation0"), - (lambda x: get_idx_if_non_empty(x.leftDriverData.leftBlinkProb), "leftDriverData.leftBlinkProb"), + (lambda x: get_idx_if_non_empty(x.leftDriverData.eyesClosedProb), "leftDriverData.eyesClosedProb"), (lambda x: get_idx_if_non_empty(x.leftDriverData.phoneProb), "leftDriverData.phoneProb"), (lambda x: get_idx_if_non_empty(x.rightDriverData.faceProb), "rightDriverData.faceProb"), ], "driverStateV2") diff --git a/selfdrive/test/process_replay/process_replay.py b/selfdrive/test/process_replay/process_replay.py index c2d0bbc921..65587d44b1 100755 --- a/selfdrive/test/process_replay/process_replay.py +++ b/selfdrive/test/process_replay/process_replay.py @@ -146,6 +146,7 @@ class ProcessContainer: self.cfg = copy.deepcopy(cfg) self.process = copy.deepcopy(managed_processes[cfg.proc_name]) self.msg_queue: list[capnp._DynamicStructReader] = [] + self.last_input_log_mono_time: int = -1 self.cnt = 0 self.pm: messaging.PubMaster | None = None self.sockets: list[messaging.SubSocket] | None = None @@ -268,6 +269,7 @@ class ProcessContainer: ms = messaging.drain_sock(socket) for m in ms: m = m.as_builder() + assert start_time > 0, "start_time must be positive" m.logMonoTime = start_time + int(self.cfg.processing_time * 1e9) output_msgs.append(m.as_reader()) return output_msgs @@ -294,10 +296,11 @@ class ProcessContainer: trigger_empty_recv = any(m.which() == self.cfg.main_pub for m in self.msg_queue) # get output msgs from previous inputs - output_msgs = self.get_output_msgs(msg.logMonoTime) + output_msgs = self.get_output_msgs(self.last_input_log_mono_time) for m in self.msg_queue: self.pm.send(m.which(), m.as_builder()) + self.last_input_log_mono_time = max(self.last_input_log_mono_time, m.logMonoTime) # send frames if needed if self.vipc_server is not None and m.which() in self.cfg.vision_pubs: camera_state = getattr(m, m.which()) @@ -513,6 +516,7 @@ CONFIGS = [ ignore=["logMonoTime"], should_recv_callback=MessageBasedRcvCallback("cameraOdometry"), tolerance=NUMPY_TOLERANCE, + processing_time=0.01, ), ProcessConfig( proc_name="paramsd", @@ -716,7 +720,7 @@ def _replay_multi_process( # flush last set of messages from each process for container in containers: - last_time = log_msgs[-1].logMonoTime if len(log_msgs) > 0 else int(time.monotonic() * 1e9) + last_time = container.last_input_log_mono_time if container.last_input_log_mono_time > 0 else int(time.monotonic() * 1e9) log_msgs.extend(container.get_output_msgs(last_time)) finally: for container in containers: diff --git a/selfdrive/test/process_replay/test_processes.py b/selfdrive/test/process_replay/test_processes.py index fbe300a7c9..bc0085534c 100755 --- a/selfdrive/test/process_replay/test_processes.py +++ b/selfdrive/test/process_replay/test_processes.py @@ -3,6 +3,7 @@ import argparse import concurrent.futures import os import sys +import traceback from collections import defaultdict from tqdm import tqdm from typing import Any @@ -11,6 +12,7 @@ from opendbc.car.car_helpers import interface_names from openpilot.common.git import get_commit from openpilot.tools.lib.openpilotci import get_url from openpilot.selfdrive.test.process_replay.compare_logs import compare_logs, format_diff +from openpilot.selfdrive.test.process_replay.diff_report import diff_process, diff_report from openpilot.selfdrive.test.process_replay.process_replay import CONFIGS, PROC_REPLAY_DIR, FAKEDATA, replay_process, \ check_most_messages_valid from openpilot.tools.lib.filereader import FileReader @@ -72,11 +74,16 @@ EXCLUDED_PROCS = {"modeld", "dmonitoringmodeld"} def run_test_process(data): segment, cfg, args, cur_log_fn, ref_log_path, lr_dat = data + ref_log_msgs = list(LogReader(ref_log_path)) lr = LogReader.from_bytes(lr_dat) - res, log_msgs = test_process(cfg, lr, segment, ref_log_path, cur_log_fn, args.ignore_fields, args.ignore_msgs) + res, log_msgs = test_process(cfg, lr, segment, ref_log_msgs, cur_log_fn, args.ignore_fields, args.ignore_msgs) # save logs so we can update refs save_log(cur_log_fn, log_msgs) - return (segment, cfg.proc_name, res) + try: + diff_data = diff_process(cfg, ref_log_msgs, log_msgs) + except Exception: + diff_data = traceback.format_exc() + return (segment, cfg.proc_name, res, diff_data) def get_log_data(segment): @@ -85,14 +92,12 @@ def get_log_data(segment): return (segment, f.read()) -def test_process(cfg, lr, segment, ref_log_path, new_log_path, ignore_fields=None, ignore_msgs=None): +def test_process(cfg, lr, segment, ref_log_msgs, new_log_path, ignore_fields=None, ignore_msgs=None): if ignore_fields is None: ignore_fields = [] if ignore_msgs is None: ignore_msgs = [] - ref_log_msgs = list(LogReader(ref_log_path)) - try: log_msgs = replay_process(cfg, lr, disable_progress=True) except Exception as e: @@ -201,9 +206,11 @@ if __name__ == "__main__": log_paths[segment][cfg.proc_name]['new'] = cur_log_fn results: Any = defaultdict(dict) + diffs: list = [] p2 = pool.map(run_test_process, pool_args) - for (segment, proc, result) in tqdm(p2, desc="Running Tests", total=len(pool_args)): + for (segment, proc, result, diff_data) in tqdm(p2, desc="Running Tests", total=len(pool_args)): results[segment][proc] = result + diffs.append((segment, proc, diff_data)) diff_short, diff_long, failed = format_diff(results, log_paths, ref_commit) if not args.update_refs: @@ -211,6 +218,11 @@ if __name__ == "__main__": f.write(diff_long) print(diff_short) + try: + diff_report(diffs, segments) + except Exception: + print(f"failed to generate diff report:\n{traceback.format_exc()}") + if failed: print("TEST FAILED") else: diff --git a/selfdrive/test/test_onroad.py b/selfdrive/test/test_onroad.py index 008b8ebe7f..1129a1a2ff 100644 --- a/selfdrive/test/test_onroad.py +++ b/selfdrive/test/test_onroad.py @@ -342,10 +342,15 @@ class TestOnroad: start, end = min(first_fid), min(last_fid) for i in range(end-start): - ts = {c: round(self.ts[c]['timestampSof'][i]/1e6, 1) for c in cams} + # road and wide cameras (first two) should be synced within 2ms + ts = {c: round(self.ts[c]['timestampSof'][i]/1e6, 1) for c in cams[:2]} 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 + 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" + def test_camera_encoder_matches(self, subtests): # sanity check that the frame metadata is consistent with the encoded frames pairs = [('roadCameraState', 'roadEncodeIdx'), diff --git a/selfdrive/ui/.gitignore b/selfdrive/ui/.gitignore index 945928f617..30ae77d885 100644 --- a/selfdrive/ui/.gitignore +++ b/selfdrive/ui/.gitignore @@ -1 +1,4 @@ installer/installers/* + +tests/diff/report +.coverage diff --git a/selfdrive/ui/SConscript b/selfdrive/ui/SConscript index 4d7448c62f..1a662e6b24 100644 --- a/selfdrive/ui/SConscript +++ b/selfdrive/ui/SConscript @@ -1,4 +1,3 @@ -import re from pathlib import Path Import('env', 'arch', 'common') @@ -19,39 +18,38 @@ env.Command( if GetOption('extras') and arch == "larch64": # build installers - if arch != "Darwin": - raylib_env = env.Clone() - raylib_env['LIBPATH'] += [f'#third_party/raylib/{arch}/'] - raylib_env['LINKFLAGS'].append('-Wl,-strip-debug') + raylib_env = env.Clone() + raylib_env['LIBPATH'] += [f'#third_party/raylib/{arch}/'] + raylib_env['LINKFLAGS'].append('-Wl,-strip-debug') - raylib_libs = common + ["raylib"] - if arch == "larch64": - raylib_libs += ["GLESv2", "EGL", "gbm", "drm"] - else: - raylib_libs += ["GL"] + raylib_libs = common + ["raylib"] + if arch == "larch64": + raylib_libs += ["GLESv2", "EGL", "gbm", "drm"] + else: + raylib_libs += ["GL"] - release = "release3" - installers = [ - ("openpilot", release), - ("openpilot_test", f"{release}-staging"), - ("openpilot_nightly", "nightly"), - ("openpilot_internal", "nightly-dev"), - ] + release = "release3" + installers = [ + ("openpilot", release), + ("openpilot_test", f"{release}-staging"), + ("openpilot_nightly", "nightly"), + ("openpilot_internal", "nightly-dev"), + ] - cont = raylib_env.Command("installer/continue_openpilot.o", "installer/continue_openpilot.sh", + cont = raylib_env.Command("installer/continue_openpilot.o", "installer/continue_openpilot.sh", + "ld -r -b binary -o $TARGET $SOURCE") + inter = raylib_env.Command("installer/inter_ttf.o", "installer/inter-ascii.ttf", + "ld -r -b binary -o $TARGET $SOURCE") + inter_bold = raylib_env.Command("installer/inter_bold.o", "../assets/fonts/Inter-Bold.ttf", "ld -r -b binary -o $TARGET $SOURCE") - inter = raylib_env.Command("installer/inter_ttf.o", "installer/inter-ascii.ttf", - "ld -r -b binary -o $TARGET $SOURCE") - inter_bold = raylib_env.Command("installer/inter_bold.o", "../assets/fonts/Inter-Bold.ttf", - "ld -r -b binary -o $TARGET $SOURCE") - inter_light = raylib_env.Command("installer/inter_light.o", "../assets/fonts/Inter-Light.ttf", - "ld -r -b binary -o $TARGET $SOURCE") - for name, branch in installers: - d = {'BRANCH': f"'\"{branch}\"'"} - if "internal" in name: - d['INTERNAL'] = "1" + inter_light = raylib_env.Command("installer/inter_light.o", "../assets/fonts/Inter-Light.ttf", + "ld -r -b binary -o $TARGET $SOURCE") + for name, branch in installers: + d = {'BRANCH': f"'\"{branch}\"'"} + if "internal" in name: + d['INTERNAL'] = "1" - obj = raylib_env.Object(f"installer/installers/installer_{name}.o", ["installer/installer.cc"], CPPDEFINES=d) - f = raylib_env.Program(f"installer/installers/installer_{name}", [obj, cont, inter, inter_bold, inter_light], LIBS=raylib_libs) - # keep installers small - assert f[0].get_size() < 2500*1e3, f[0].get_size() + obj = raylib_env.Object(f"installer/installers/installer_{name}.o", ["installer/installer.cc"], CPPDEFINES=d) + f = raylib_env.Program(f"installer/installers/installer_{name}", [obj, cont, inter, inter_bold, inter_light], LIBS=raylib_libs) + # keep installers small + assert f[0].get_size() < 2500*1e3, f[0].get_size() diff --git a/selfdrive/ui/installer/installer.cc b/selfdrive/ui/installer/installer.cc index 7599454194..0832fbb628 100644 --- a/selfdrive/ui/installer/installer.cc +++ b/selfdrive/ui/installer/installer.cc @@ -48,7 +48,7 @@ Font font_display; const bool tici_device = Hardware::get_device_type() == cereal::InitData::DeviceType::TICI || Hardware::get_device_type() == cereal::InitData::DeviceType::TIZI; -std::vector tici_prebuilt_branches = {"release3", "release-tizi", "release3-staging", "nightly", "nightly-dev"}; +std::vector tici_prebuilt_branches = {"release3", "release-tici", "release3-staging", "nightly", "nightly-dev"}; std::string migrated_branch; void branchMigration() { @@ -144,6 +144,7 @@ int cachedFetch(const std::string &cache) { LOGD("Fetching with cache: %s", cache.c_str()); run(util::string_format("cp -rp %s %s", cache.c_str(), TMP_INSTALL_PATH).c_str()); + run(util::string_format("cd %s && git remote set-url origin %s", TMP_INSTALL_PATH, GIT_URL.c_str()).c_str()); run(util::string_format("cd %s && git remote set-branches --add origin %s", TMP_INSTALL_PATH, migrated_branch.c_str()).c_str()); renderProgress(10); diff --git a/selfdrive/ui/layouts/home.py b/selfdrive/ui/layouts/home.py index bb4b868f2d..be231dcd4b 100644 --- a/selfdrive/ui/layouts/home.py +++ b/selfdrive/ui/layouts/home.py @@ -62,6 +62,7 @@ class HomeLayout(Widget): self._setup_callbacks() def show_event(self): + super().show_event() self._exp_mode_button.show_event() self.last_refresh = time.monotonic() self._refresh() diff --git a/selfdrive/ui/layouts/onboarding.py b/selfdrive/ui/layouts/onboarding.py index c53db2231a..452ed53c08 100644 --- a/selfdrive/ui/layouts/onboarding.py +++ b/selfdrive/ui/layouts/onboarding.py @@ -94,7 +94,7 @@ class TrainingGuide(Widget): def _render(self, _): # Safeguard against fast tapping step = min(self._step, len(self._textures) - 1) - rl.draw_texture(self._textures[step], 0, 0, rl.WHITE) + rl.draw_texture_ex(self._textures[step], rl.Vector2(0, 0), 0.0, 1.0, rl.WHITE) # progress bar if 0 < step < len(STEP_RECTS) - 1: diff --git a/selfdrive/ui/layouts/settings/developer.py b/selfdrive/ui/layouts/settings/developer.py index 56c2951d0d..b632e4e0d2 100644 --- a/selfdrive/ui/layouts/settings/developer.py +++ b/selfdrive/ui/layouts/settings/developer.py @@ -71,6 +71,13 @@ class DeveloperLayout(Widget): callback=self._on_long_maneuver_mode, ) + self._lat_maneuver_toggle = toggle_item( + lambda: tr("Lateral Maneuver Mode"), + description="", + initial_state=self._params.get_bool("LateralManeuverMode"), + callback=self._on_lat_maneuver_mode, + ) + self._alpha_long_toggle = toggle_item( lambda: tr("sunnypilot Longitudinal Control (Alpha)"), description=lambda: tr(DESCRIPTIONS["alpha_longitudinal"]), @@ -93,6 +100,7 @@ class DeveloperLayout(Widget): self._ssh_keys, self._joystick_toggle, self._long_maneuver_toggle, + self._lat_maneuver_toggle, self._alpha_long_toggle, self._ui_debug_toggle, ], line_separator=True, spacing=0) @@ -104,6 +112,7 @@ class DeveloperLayout(Widget): self._scroller.render(rect) def show_event(self): + super().show_event() self._scroller.show_event() self._update_toggles() @@ -112,7 +121,7 @@ class DeveloperLayout(Widget): # Hide non-release toggles on release builds # TODO: we can do an onroad cycle, but alpha long toggle requires a deinit function to re-enable radar and not fault - for item in (self._joystick_toggle, self._long_maneuver_toggle, self._alpha_long_toggle): + for item in (self._joystick_toggle, self._long_maneuver_toggle, self._lat_maneuver_toggle, self._alpha_long_toggle): item.set_visible(not self._is_release) # CP gating @@ -129,8 +138,12 @@ class DeveloperLayout(Widget): if not long_man_enabled: self._long_maneuver_toggle.action_item.set_state(False) self._params.put_bool("LongitudinalManeuverMode", False) + + lat_man_enabled = ui_state.is_offroad() + self._lat_maneuver_toggle.action_item.set_enabled(lat_man_enabled) else: self._long_maneuver_toggle.action_item.set_enabled(False) + self._lat_maneuver_toggle.action_item.set_enabled(False) self._alpha_long_toggle.set_visible(False) # TODO: make a param control list item so we don't need to manage internal state as much here @@ -140,6 +153,7 @@ class DeveloperLayout(Widget): ("SshEnabled", self._ssh_toggle), ("JoystickDebugMode", self._joystick_toggle), ("LongitudinalManeuverMode", self._long_maneuver_toggle), + ("LateralManeuverMode", self._lat_maneuver_toggle), ("AlphaLongitudinalEnabled", self._alpha_long_toggle), ("ShowDebugInfo", self._ui_debug_toggle), ): @@ -161,11 +175,23 @@ class DeveloperLayout(Widget): self._params.put_bool("JoystickDebugMode", state) self._params.put_bool("LongitudinalManeuverMode", False) self._long_maneuver_toggle.action_item.set_state(False) + self._params.put_bool("LateralManeuverMode", False) + self._lat_maneuver_toggle.action_item.set_state(False) def _on_long_maneuver_mode(self, state: bool): self._params.put_bool("LongitudinalManeuverMode", state) self._params.put_bool("JoystickDebugMode", False) self._joystick_toggle.action_item.set_state(False) + self._params.put_bool("LateralManeuverMode", False) + self._lat_maneuver_toggle.action_item.set_state(False) + + def _on_lat_maneuver_mode(self, state: bool): + self._params.put_bool("LateralManeuverMode", state) + self._params.put_bool("ExperimentalMode", False) + self._params.put_bool("JoystickDebugMode", False) + self._joystick_toggle.action_item.set_state(False) + self._params.put_bool("LongitudinalManeuverMode", False) + self._long_maneuver_toggle.action_item.set_state(False) def _on_alpha_long_enabled(self, state: bool): if state: diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py index 45589af1f0..126ad22a3a 100644 --- a/selfdrive/ui/layouts/settings/device.py +++ b/selfdrive/ui/layouts/settings/device.py @@ -75,6 +75,7 @@ class DeviceLayout(Widget): self._power_off_btn.action_item.right_button.set_visible(ui_state.is_offroad()) def show_event(self): + super().show_event() self._scroller.show_event() def _render(self, rect): diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py index f7424b974d..f42682e2f7 100644 --- a/selfdrive/ui/layouts/settings/software.py +++ b/selfdrive/ui/layouts/settings/software.py @@ -69,7 +69,6 @@ class SoftwareLayout(Widget): # Branch switcher self._branch_btn = button_item(lambda: tr("Target Branch"), lambda: tr("SELECT"), callback=self._on_select_branch) - self._branch_btn.set_visible(not ui_state.params.get_bool("IsTestedBranch")) self._branch_btn.action_item.set_value(ui_state.params.get("UpdaterTargetBranch") or "") self._branch_dialog: MultiOptionDialog | None = None @@ -83,6 +82,7 @@ class SoftwareLayout(Widget): ], line_separator=True, spacing=0) def show_event(self): + super().show_event() self._scroller.show_event() def _render(self, rect): diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py index 9f704b1fb4..9923f3a356 100644 --- a/selfdrive/ui/layouts/settings/toggles.py +++ b/selfdrive/ui/layouts/settings/toggles.py @@ -152,6 +152,7 @@ class TogglesLayout(Widget): ui_state.personality = personality def show_event(self): + super().show_event() self._scroller.show_event() self._update_toggles() diff --git a/selfdrive/ui/layouts/sidebar.py b/selfdrive/ui/layouts/sidebar.py index bfa60c88ed..1dad597ca3 100644 --- a/selfdrive/ui/layouts/sidebar.py +++ b/selfdrive/ui/layouts/sidebar.py @@ -165,14 +165,14 @@ class Sidebar(Widget, SidebarSP): # Settings button settings_down = mouse_down and rl.check_collision_point_rec(mouse_pos, SETTINGS_BTN) tint = Colors.BUTTON_PRESSED if settings_down else Colors.BUTTON_NORMAL - rl.draw_texture(self._settings_img, int(SETTINGS_BTN.x), int(SETTINGS_BTN.y), tint) + rl.draw_texture_ex(self._settings_img, rl.Vector2(SETTINGS_BTN.x, SETTINGS_BTN.y), 0.0, 1.0, tint) # Home/Flag button flag_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, HOME_BTN) button_img = self._flag_img if ui_state.started else self._home_img tint = Colors.BUTTON_PRESSED if (ui_state.started and flag_pressed) else Colors.BUTTON_NORMAL - rl.draw_texture(button_img, int(HOME_BTN.x), int(HOME_BTN.y), tint) + rl.draw_texture_ex(button_img, rl.Vector2(HOME_BTN.x, HOME_BTN.y), 0.0, 1.0, tint) # Microphone button if self._recording_audio: @@ -182,8 +182,8 @@ class Sidebar(Widget, SidebarSP): bg_color = rl.Color(Colors.DANGER.r, Colors.DANGER.g, Colors.DANGER.b, int(255 * 0.65)) if mic_pressed else Colors.DANGER rl.draw_rectangle_rounded(self._mic_indicator_rect, 1, 10, bg_color) - rl.draw_texture(self._mic_img, int(self._mic_indicator_rect.x + (self._mic_indicator_rect.width - self._mic_img.width) / 2), - int(self._mic_indicator_rect.y + (self._mic_indicator_rect.height - self._mic_img.height) / 2), Colors.WHITE) + rl.draw_texture_ex(self._mic_img, rl.Vector2(self._mic_indicator_rect.x + (self._mic_indicator_rect.width - self._mic_img.width) / 2, + self._mic_indicator_rect.y + (self._mic_indicator_rect.height - self._mic_img.height) / 2), 0.0, 1.0, Colors.WHITE) def _draw_network_indicator(self, rect: rl.Rectangle): # Signal strength dots diff --git a/selfdrive/ui/mici/layouts/home.py b/selfdrive/ui/mici/layouts/home.py index 1d6d8dad2a..cc424fa62b 100644 --- a/selfdrive/ui/mici/layouts/home.py +++ b/selfdrive/ui/mici/layouts/home.py @@ -7,13 +7,15 @@ from collections.abc import Callable from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.layouts import HBoxLayout from openpilot.system.ui.widgets.icon_widget import IconWidget -from openpilot.system.ui.widgets.label import MiciLabel, UnifiedLabel +from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.version import RELEASE_BRANCHES HEAD_BUTTON_FONT_SIZE = 40 HOME_PADDING = 8 +SETTINGS_ZONE_WIDTH = 280 +ALERTS_ZONE_WIDTH = 180 NetworkType = log.DeviceState.NetworkType @@ -28,6 +30,37 @@ NETWORK_TYPES = { } +class AlertsPill(Widget): + ICON_OFFSET = 12 + COUNT_OFFSET = 40 + + def __init__(self): + super().__init__() + self.set_rect(rl.Rectangle(0, 0, 104, 52)) + + self._pill_bg_txt = gui_app.texture("icons_mici/alerts_pill.png", 104, 52) + self._warning_txt = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", 36, 36) + self._alert_count_callback: Callable[[], int] | None = None + + def set_alert_count_callback(self, callback: Callable[[], int] | None): + self._alert_count_callback = callback + + def _render(self, _): + alert_count = self._alert_count_callback() if self._alert_count_callback else 0 + if alert_count > 0: + pill_w, pill_h = self._pill_bg_txt.width, self._pill_bg_txt.height + rl.draw_texture_ex(self._pill_bg_txt, rl.Vector2(self.rect.x, self.rect.y), 0.0, 1.0, rl.WHITE) + + warn_x = self.rect.x + self.ICON_OFFSET + warn_y = self.rect.y + (pill_h - self._warning_txt.height) / 2 + rl.draw_texture_ex(self._warning_txt, rl.Vector2(warn_x, warn_y), 0.0, 1.0, rl.WHITE) + + count_rect = rl.Rectangle(self.rect.x + self.COUNT_OFFSET, self.rect.y, pill_w - self.COUNT_OFFSET, pill_h) + gui_label(count_rect, str(alert_count), font_size=36, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + class NetworkIcon(Widget): def __init__(self): super().__init__() @@ -77,13 +110,15 @@ class NetworkIcon(Widget): # Offset by difference in height between slashless and slash icons to make center align match draw_y -= (self._wifi_slash_txt.height - self._wifi_none_txt.height) / 2 - rl.draw_texture(draw_net_txt, int(draw_x), int(draw_y), rl.Color(255, 255, 255, int(255 * 0.9))) + rl.draw_texture_ex(draw_net_txt, rl.Vector2(draw_x, draw_y), 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.9))) class MiciHomeLayout(Widget): def __init__(self): super().__init__() self._on_settings_click: Callable | None = None + self._on_alerts_click: Callable | None = None + self._alert_count_callback: Callable[[], int] | None = None self._last_refresh = 0 self._mouse_down_t: None | float = None @@ -96,6 +131,8 @@ class MiciHomeLayout(Widget): self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46)) + self._alerts_pill = AlertsPill() + self._status_bar_layout = HBoxLayout([ IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9), NetworkIcon(), @@ -103,14 +140,15 @@ class MiciHomeLayout(Widget): self._mic_icon, ], spacing=18) - self._openpilot_label = MiciLabel("sunnypilot", font_size=90, color=rl.Color(255, 255, 255, int(255 * 0.9)), font_weight=FontWeight.AUDIOWIDE) - self._version_label = MiciLabel("", font_size=36, font_weight=FontWeight.ROMAN) - self._large_version_label = MiciLabel("", font_size=64, color=rl.GRAY, font_weight=FontWeight.ROMAN) - self._date_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN) + self._openpilot_label = UnifiedLabel("sunnypilot", font_size=96, font_weight=FontWeight.DISPLAY, max_width=480, wrap_text=False) + self._version_label = UnifiedLabel("", font_size=36, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) + self._large_version_label = UnifiedLabel("", font_size=64, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) + self._date_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) self._branch_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, scroll=True) - self._version_commit_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN) + self._version_commit_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) def show_event(self): + super().show_event() self._version_text = self._get_version_text() self._update_params() @@ -140,13 +178,23 @@ class MiciHomeLayout(Widget): self._last_refresh = rl.get_time() self._update_params() - def set_callbacks(self, on_settings: Callable | None = None): + def set_callbacks(self, on_settings: Callable | None = None, on_alerts: Callable | None = None, + alert_count_callback: Callable[[], int] | None = None): self._on_settings_click = on_settings + self._on_alerts_click = on_alerts + self._alert_count_callback = alert_count_callback + self._alerts_pill.set_alert_count_callback(alert_count_callback) def _handle_mouse_release(self, mouse_pos: MousePos): if not self._did_long_press: - if self._on_settings_click: - self._on_settings_click() + relative_x = mouse_pos.x - self.rect.x + has_alerts = self._alert_count_callback and self._alert_count_callback() > 0 + if relative_x < SETTINGS_ZONE_WIDTH: + if self._on_settings_click: + self._on_settings_click() + elif has_alerts and relative_x > self.rect.width - ALERTS_ZONE_WIDTH: + if self._on_alerts_click: + self._on_alerts_click() self._did_long_press = False def _get_version_text(self) -> tuple[str, str, str, str] | None: @@ -182,12 +230,12 @@ class MiciHomeLayout(Widget): self._version_label.render() self._date_label.set_text(" " + self._version_text[3]) - self._date_label.set_position(version_pos.x + self._version_label.rect.width + 10, version_pos.y) + self._date_label.set_position(version_pos.x + self._version_label.text_width + 10, version_pos.y) self._date_label.render() - self._branch_label.set_max_width(gui_app.width - self._version_label.rect.width - self._date_label.rect.width - 32) + self._branch_label.set_max_width(gui_app.width - self._version_label.text_width - self._date_label.text_width - 32) self._branch_label.set_text(" " + ("release" if release_branch else self._version_text[1])) - self._branch_label.set_position(version_pos.x + self._version_label.rect.width + self._date_label.rect.width + 20, version_pos.y) + self._branch_label.set_position(version_pos.x + self._version_label.text_width + self._date_label.text_width + 20, version_pos.y) self._branch_label.render() if not release_branch: @@ -202,3 +250,8 @@ class MiciHomeLayout(Widget): footer_rect = rl.Rectangle(self.rect.x + HOME_PADDING, self.rect.y + self.rect.height - 48, self.rect.width - HOME_PADDING, 48) self._status_bar_layout.render(footer_rect) + + # TODO: add alignment to hboxlayout and add to there + self._alerts_pill.set_position(self.rect.x + self.rect.width - self._alerts_pill.rect.width - HOME_PADDING, + self.rect.y + self.rect.height - self._alerts_pill.rect.height) + self._alerts_pill.render() diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index 860030a24e..0c762e098d 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -13,7 +13,6 @@ from openpilot.system.ui.lib.application import gui_app if gui_app.sunnypilot_ui(): from openpilot.selfdrive.ui.sunnypilot.mici.layouts.settings import SettingsLayoutSP as SettingsLayout - ONROAD_DELAY = 2.5 # seconds @@ -56,12 +55,16 @@ class MiciMainLayout(Scroller): gui_app.push_widget(self) # Start onboarding if terms or training not completed, make sure to push after self - self._onboarding_window = OnboardingWindow() + self._onboarding_window = OnboardingWindow(lambda: gui_app.pop_widgets_to(self)) if not self._onboarding_window.completed: gui_app.push_widget(self._onboarding_window) def _setup_callbacks(self): - self._home_layout.set_callbacks(on_settings=lambda: gui_app.push_widget(self._settings_layout)) + self._home_layout.set_callbacks( + on_settings=lambda: gui_app.push_widget(self._settings_layout), + on_alerts=lambda: self._scroll_to(self._alerts_layout), + alert_count_callback=self._alerts_layout.active_alerts, + ) self._onroad_layout.set_click_callback(lambda: self._scroll_to(self._home_layout)) device.add_interactive_timeout_callback(self._on_interactive_timeout) @@ -69,6 +72,11 @@ class MiciMainLayout(Scroller): layout_x = int(layout.rect.x) self._scroller.scroll_to(layout_x, smooth=True) + def _update_state(self): + super()._update_state() + # TODO: Hack to run alert updates while not in view. Add a nav stack tick? + self._alerts_layout._update_state() + def _render(self, _): if not self._setup: if self._alerts_layout.active_alerts() > 0: @@ -82,7 +90,7 @@ class MiciMainLayout(Scroller): def _handle_transitions(self): # Don't pop if onboarding - if gui_app.get_active_widget() == self._onboarding_window: + if gui_app.widget_in_stack(self._onboarding_window): return if ui_state.started != self._prev_onroad: @@ -108,7 +116,7 @@ class MiciMainLayout(Scroller): def _on_interactive_timeout(self): # Don't pop if onboarding - if gui_app.get_active_widget() == self._onboarding_window: + if gui_app.widget_in_stack(self._onboarding_window): return if ui_state.started: diff --git a/selfdrive/ui/mici/layouts/offroad_alerts.py b/selfdrive/ui/mici/layouts/offroad_alerts.py index 3aec5bbfed..57ddfdbc48 100644 --- a/selfdrive/ui/mici/layouts/offroad_alerts.py +++ b/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -144,7 +144,7 @@ class AlertItem(Widget): bg_texture = self._bg_small_pressed if self.is_pressed else self._bg_small # Draw background - rl.draw_texture(bg_texture, int(self._rect.x), int(self._rect.y), rl.WHITE) + rl.draw_texture_ex(bg_texture, rl.Vector2(self._rect.x, self._rect.y), 0.0, 1.0, rl.WHITE) # Calculate text area (left side, avoiding icon on right) title_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) - self.ICON_SIZE - self.ICON_MARGIN @@ -183,7 +183,7 @@ class AlertItem(Widget): icon_texture = self._icon_orange icon_x = self._rect.x + self.ALERT_WIDTH - self.ALERT_PADDING - self.ICON_SIZE icon_y = self._rect.y + self.ALERT_PADDING - rl.draw_texture(icon_texture, int(icon_x), int(icon_y), rl.WHITE) + rl.draw_texture_ex(icon_texture, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE) class MiciOffroadAlerts(Scroller): diff --git a/selfdrive/ui/mici/layouts/onboarding.py b/selfdrive/ui/mici/layouts/onboarding.py index 7340360575..d6d3f70330 100644 --- a/selfdrive/ui/mici/layouts/onboarding.py +++ b/selfdrive/ui/mici/layouts/onboarding.py @@ -1,32 +1,24 @@ -from enum import IntEnum - -import weakref 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.system.hardware import HARDWARE from openpilot.system.ui.lib.application import FontWeight, gui_app from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.button import SmallButton, SmallCircleIconButton -from openpilot.system.ui.widgets.label import UnifiedLabel -from openpilot.system.ui.widgets.slider import SmallSlider -from openpilot.system.ui.mici_setup import TermsHeader, TermsPage as SetupTermsPage -from openpilot.selfdrive.ui.ui_state import ui_state, device -from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer -from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import BaseDriverCameraDialog +from openpilot.system.ui.widgets.button import SmallCircleIconButton +from openpilot.system.ui.widgets.scroller import NavScroller, Scroller +from openpilot.system.ui.widgets.nav_widget import NavWidget +from openpilot.system.ui.mici_setup import GreyBigButton, BigPillButton from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.lib.multilang import tr from openpilot.system.version import terms_version, training_version, terms_version_sp - -from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkOnboarding - - -class OnboardingState(IntEnum): - TERMS = 0 - ONBOARDING = 1 - DECLINE = 2 - SUNNYLINK_CONSENT = 3 +from openpilot.system.version import sunnylink_consent_version, sunnylink_consent_declined +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.sunnypilot.mici.layouts.onboarding import SunnylinkConsentPage class DriverCameraSetupDialog(BaseDriverCameraDialog): @@ -60,91 +52,62 @@ class DriverCameraSetupDialog(BaseDriverCameraDialog): rl.end_scissor_mode() -class TrainingGuidePreDMTutorial(SetupTermsPage): - def __init__(self, continue_callback): - super().__init__(continue_callback, continue_text="continue") - self._title_header = TermsHeader("driver monitoring setup", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60)) +class TrainingGuidePreDMTutorial(NavScroller): + def __init__(self, continue_callback: Callable[[], None]): + super().__init__() - self._dm_label = UnifiedLabel("Next, we'll ensure comma four is mounted properly.\n\nIf it does not have a clear view of the driver, " + - "unplug and remount before continuing.", 42, - FontWeight.ROMAN) + continue_button = BigPillButton("next") + continue_button.set_click_callback(continue_callback) + + self._scroller.add_widgets([ + GreyBigButton("driver monitoring\ncheck", "scroll to continue", + gui_app.texture("icons_mici/setup/green_dm.png", 64, 64)), + GreyBigButton("", "Next, we'll check if comma four can detect the driver properly."), + GreyBigButton("", "sunnypilot uses the cabin camera to check if the driver is distracted."), + GreyBigButton("", "If it does not have a clear view of the driver, unplug and remount before continuing."), + continue_button, + ]) def show_event(self): super().show_event() # Get driver monitoring model ready for next step - ui_state.params.put_bool("IsDriverViewEnabled", True) - - @property - def _content_height(self): - return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset() - - def _render_content(self, scroll_offset): - self._title_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16 + scroll_offset, - self._title_header.rect.width, - self._title_header.rect.height, - )) - - self._dm_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + 16, - self._rect.width - 32, - self._dm_label.get_content_height(int(self._rect.width - 32)), - )) + ui_state.params.put_bool_nonblocking("IsDriverViewEnabled", True) -class DMBadFaceDetected(SetupTermsPage): - def __init__(self, continue_callback, back_callback): - super().__init__(continue_callback, back_callback, continue_text="power off") - self._title_header = TermsHeader("make sure comma four can see your face", gui_app.texture("icons_mici/setup/orange_dm.png", 60, 60)) - self._dm_label = UnifiedLabel("Re-mount if your face is occluded or driver monitoring has difficulty tracking your face.", 42, FontWeight.ROMAN) +class DMBadFaceDetected(NavScroller): + def __init__(self): + super().__init__() - @property - def _content_height(self): - return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset() + back_button = BigPillButton("back") + back_button.set_click_callback(self.dismiss) - def _render_content(self, scroll_offset): - self._title_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16 + scroll_offset, - self._title_header.rect.width, - self._title_header.rect.height, - )) - - self._dm_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + 16, - self._rect.width - 32, - self._dm_label.get_content_height(int(self._rect.width - 32)), - )) + self._scroller.add_widgets([ + GreyBigButton("looking for driver", "make sure comma\nfour can see your face", + gui_app.texture("icons_mici/setup/orange_dm.png", 64, 64)), + GreyBigButton("", "Remount if your face is blocked, or driver monitoring has difficulty tracking your face."), + back_button, + ]) -class TrainingGuideDMTutorial(Widget): +class TrainingGuideDMTutorial(NavWidget): PROGRESS_DURATION = 4 LOOKING_THRESHOLD_DEG = 30.0 - def __init__(self, continue_callback): + def __init__(self, continue_callback: Callable[[], None]): super().__init__() - self_ref = weakref.ref(self) - self._back_button = SmallCircleIconButton(gui_app.texture("icons_mici/setup/driver_monitoring/dm_question.png", 28, 48)) - self._back_button.set_click_callback(lambda: self_ref() and self_ref()._show_bad_face_page()) + self._back_button.set_click_callback(lambda: gui_app.push_widget(self._bad_face_page)) + self._back_button.set_touch_valid_callback(lambda: self.enabled and not self.is_dismissing) # for nav stack self._good_button = SmallCircleIconButton(gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 42, 42)) + self._good_button.set_touch_valid_callback(lambda: self.enabled and not self.is_dismissing) # for nav stack - # Wrap the continue callback to restore settings - def wrapped_continue_callback(): - device.set_offroad_brightness(None) - continue_callback() - - self._good_button.set_click_callback(wrapped_continue_callback) + self._good_button.set_click_callback(continue_callback) self._good_button.set_enabled(False) self._progress = FirstOrderFilter(0.0, 0.5, 1 / gui_app.target_fps) self._dialog = DriverCameraSetupDialog() - self._bad_face_page = DMBadFaceDetected(HARDWARE.shutdown, lambda: self_ref() and self_ref()._hide_bad_face_page()) - self._should_show_bad_face_page = False + self._bad_face_page = DMBadFaceDetected() # Disable driver monitoring model when device times out for inactivity def inactivity_callback(): @@ -152,23 +115,11 @@ class TrainingGuideDMTutorial(Widget): device.add_interactive_timeout_callback(inactivity_callback) - def _show_bad_face_page(self): - self._bad_face_page.show_event() - self.hide_event() - self._should_show_bad_face_page = True - - def _hide_bad_face_page(self): - self._bad_face_page.hide_event() - self.show_event() - self._should_show_bad_face_page = False - def show_event(self): super().show_event() self._dialog.show_event() self._progress.x = 0.0 - device.set_offroad_brightness(100) - def _update_state(self): super()._update_state() if device.awake and not ui_state.params.get_bool("IsDriverViewEnabled"): @@ -188,7 +139,8 @@ class TrainingGuideDMTutorial(Widget): looking_center = False # stay at 100% once reached - if (dm_state.faceDetected and looking_center) or self._progress.x > 0.99: + in_bad_face = gui_app.get_active_widget() == self._bad_face_page + if ((dm_state.faceDetected and looking_center) or self._progress.x > 0.99) and not in_bad_face: slow = self._progress.x < 0.25 duration = self.PROGRESS_DURATION * 2 if slow else self.PROGRESS_DURATION self._progress.x += 1.0 / (duration * gui_app.target_fps) @@ -199,13 +151,12 @@ class TrainingGuideDMTutorial(Widget): self._good_button.set_enabled(self._progress.x >= 0.999) def _render(self, _): - if self._should_show_bad_face_page: - return self._bad_face_page.render(self._rect) - self._dialog.render(self._rect) - rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height - 80), - int(self._rect.width), 80, rl.BLANK, rl.BLACK) + gradient_y = int(self._rect.y + self._rect.height - 80) + gradient_h = int(self._rect.y) + int(self._rect.height) - gradient_y + rl.draw_rectangle_gradient_v(int(self._rect.x), gradient_y, + int(self._rect.width), gradient_h, rl.BLANK, rl.BLACK) # draw white ring around dm icon to indicate progress ring_thickness = 8 @@ -258,266 +209,229 @@ class TrainingGuideDMTutorial(Widget): )) # rounded border + rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y), int(self._rect.width), int(self._rect.height)) rl.draw_rectangle_rounded_lines_ex(self._rect, 0.2 * 1.02, 10, 50, rl.BLACK) + rl.end_scissor_mode() -class TrainingGuideRecordFront(SetupTermsPage): - def __init__(self, continue_callback): - def on_back(): - ui_state.params.put_bool("RecordFront", False) - continue_callback() - - def on_continue(): - ui_state.params.put_bool("RecordFront", True) - continue_callback() - - super().__init__(on_continue, back_callback=on_back, back_text="no", continue_text="yes") - self._title_header = TermsHeader("improve driver monitoring", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60)) - - self._dm_label = UnifiedLabel("Do you want to upload driver camera data?", 42, - FontWeight.ROMAN) - - def show_event(self): - super().show_event() - # Disable driver monitoring model after last step - ui_state.params.put_bool("IsDriverViewEnabled", False) - - @property - def _content_height(self): - return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset() - - def _render_content(self, scroll_offset): - self._title_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16 + scroll_offset, - self._title_header.rect.width, - self._title_header.rect.height, - )) - - self._dm_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + 16, - self._rect.width - 32, - self._dm_label.get_content_height(int(self._rect.width - 32)), - )) - - -class TrainingGuideAttentionNotice(SetupTermsPage): - def __init__(self, continue_callback): - super().__init__(continue_callback, continue_text="continue") - self._title_header = TermsHeader("driver assistance", gui_app.texture("icons_mici/setup/warning.png", 60, 60)) - self._warning_label = UnifiedLabel("1. sunnypilot is a driver assistance system.\n\n" + - "2. You must pay attention at all times.\n\n" + - "3. You must be ready to take over at any time.\n\n" + - "4. You are fully responsible for driving the car.", 42, - FontWeight.ROMAN) - - @property - def _content_height(self): - return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset() - - def _render_content(self, scroll_offset): - self._title_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16 + scroll_offset, - self._title_header.rect.width, - self._title_header.rect.height, - )) - - self._warning_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + 16, - self._rect.width - 32, - self._warning_label.get_content_height(int(self._rect.width - 32)), - )) - - -class TrainingGuide(Widget): - def __init__(self, completed_callback=None): +class TrainingGuideRecordFront(NavScroller): + def __init__(self, continue_callback: Callable[[], None]): super().__init__() - self._completed_callback = completed_callback - self._step = 0 - self_ref = weakref.ref(self) + def on_accept(): + ui_state.params.put_bool_nonblocking("RecordFront", True) + continue_callback() - def on_continue(): - if obj := self_ref(): - obj._advance_step() + def on_decline(): + ui_state.params.put_bool_nonblocking("RecordFront", False) + continue_callback() + + self._accept_button = BigConfirmationCircleButton("allow data uploading", gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 64, 64), + on_accept, exit_on_confirm=False) + + self._decline_button = BigConfirmationCircleButton("no, don't upload", gui_app.texture("icons_mici/setup/cancel.png", 64, 64), on_decline, + exit_on_confirm=False) + + self._scroller.add_widgets([ + GreyBigButton("driver camera data", "do you want to share video data for training?", + gui_app.texture("icons_mici/setup/green_dm.png", 64, 64)), + GreyBigButton("", "Sharing your data with comma helps improve openpilot and sunnypilot for everyone."), + self._accept_button, + self._decline_button, + ]) + + +class TrainingGuideAttentionNotice(Scroller): + def __init__(self, continue_callback: Callable[[], None]): + super().__init__() + + continue_button = BigPillButton("next") + continue_button.set_click_callback(continue_callback) + + self._scroller.add_widgets([ + GreyBigButton("what is sunnypilot?", "scroll to continue", + gui_app.texture("icons_mici/setup/green_info.png", 64, 64)), + GreyBigButton("", "1. sunnypilot is a driver assistance system."), + GreyBigButton("", "2. You must pay attention at all times."), + GreyBigButton("", "3. You must be ready to take over at any time."), + GreyBigButton("", "4. You are fully responsible for driving the car."), + continue_button, + ]) + + +class TrainingGuide(NavWidget): + def __init__(self, completed_callback: Callable[[], None]): + super().__init__() self._steps = [ - TrainingGuideAttentionNotice(continue_callback=on_continue), - TrainingGuidePreDMTutorial(continue_callback=on_continue), - TrainingGuideDMTutorial(continue_callback=on_continue), - TrainingGuideRecordFront(continue_callback=on_continue), + TrainingGuideAttentionNotice(continue_callback=lambda: gui_app.push_widget(self._steps[1])), + TrainingGuidePreDMTutorial(continue_callback=lambda: gui_app.push_widget(self._steps[2])), + TrainingGuideDMTutorial(continue_callback=lambda: gui_app.push_widget(self._steps[3])), + TrainingGuideRecordFront(continue_callback=completed_callback), ] - def show_event(self): - super().show_event() - device.set_override_interactive_timeout(300) + self._child(self._steps[0]) + self._steps[0].set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack - def hide_event(self): - super().hide_event() - device.set_override_interactive_timeout(None) + def _render(self, _): + self._steps[0].render(self._rect) - def _advance_step(self): - if self._step < len(self._steps) - 1: - self._step += 1 - self._steps[self._step].show_event() - else: - self._step = 0 - if self._completed_callback: - self._completed_callback() + +class QRCodeWidget(Widget): + def __init__(self, url: str, size: int = 170): + 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) + + def _render(self, _): + if self._qr_texture: + scale = self._size / self._qr_texture.height + rl.draw_texture_ex(self._qr_texture, rl.Vector2(round(self._rect.x), round(self._rect.y)), 0.0, scale, rl.WHITE) + + def __del__(self): + if self._qr_texture and self._qr_texture.id != 0: + rl.unload_texture(self._qr_texture) + + +class TermsPage(Scroller): + def __init__(self, on_accept, on_decline): + super().__init__() + + self._accept_button = BigConfirmationCircleButton("accept\nterms", gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 64, 64), on_accept) + self._decline_button = BigConfirmationCircleButton("decline &\nuninstall", gui_app.texture("icons_mici/setup/cancel.png", 64, 64), on_decline, + red=True, exit_on_confirm=False) + + self._terms_header = GreyBigButton("terms of\nservice", "scroll to continue", + gui_app.texture("icons_mici/setup/green_info.png", 64, 64)) + self._must_accept_card = GreyBigButton("", "You must accept the Terms of Service to use sunnypilot.") + + self._scroller.add_widgets([ + self._terms_header, + GreyBigButton("swipe for QR code", "or go to https://sunnypilot.ai/terms", + gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 56, flip_x=True)), + QRCodeWidget("https://sunnypilot.ai/terms"), + self._must_accept_card, + self._accept_button, + self._decline_button, + ]) def _render(self, _): rl.draw_rectangle_rec(self._rect, rl.BLACK) - if self._step < len(self._steps): - self._steps[self._step].render(self._rect) - - -class DeclinePage(Widget): - def __init__(self, back_callback=None): - super().__init__() - self._uninstall_slider = SmallSlider("uninstall sunnypilot", self._on_uninstall) - - self._back_button = SmallButton("back") - self._back_button.set_click_callback(back_callback) - - self._warning_header = TermsHeader("you must accept the\nterms to use sunnypilot", - gui_app.texture("icons_mici/setup/red_warning.png", 66, 60)) - - def _on_uninstall(self): - ui_state.params.put_bool("DoUninstall", True) - gui_app.request_close() - - def _render(self, _): - self._warning_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16, - self._warning_header.rect.width, - self._warning_header.rect.height, - )) - - self._back_button.set_opacity(1 - self._uninstall_slider.slider_percentage) - self._back_button.render(rl.Rectangle( - self._rect.x + 8, - self._rect.y + self._rect.height - self._back_button.rect.height, - self._back_button.rect.width, - self._back_button.rect.height, - )) - - self._uninstall_slider.render(rl.Rectangle( - self._rect.x + self._rect.width - self._uninstall_slider.rect.width, - self._rect.y + self._rect.height - self._uninstall_slider.rect.height, - self._uninstall_slider.rect.width, - self._uninstall_slider.rect.height, - )) - - -class TermsPage(SetupTermsPage): - def __init__(self, on_accept=None, on_decline=None): - super().__init__(on_accept, on_decline, "decline") - - info_txt = gui_app.texture("icons_mici/setup/green_info.png", 60, 60) - self._title_header = TermsHeader("terms of service", info_txt) - - self._terms_label = UnifiedLabel("You must accept the Terms of Service to use sunnypilot. " + - "Read the latest terms at https://sunnypilot.ai/terms before continuing.", 36, - FontWeight.ROMAN) - - @property - def _content_height(self): - return self._terms_label.rect.y + self._terms_label.rect.height - self._scroll_panel.get_offset() - - def _render_content(self, scroll_offset): - self._title_header.set_position(self._rect.x + 16, self._rect.y + 12 + scroll_offset) - self._title_header.render() - - self._terms_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING, - self._rect.width - 100, - self._terms_label.get_content_height(int(self._rect.width - 100)), - )) + super()._render(_) class OnboardingWindow(Widget): - def __init__(self): + def __init__(self, completed_callback: Callable[[], None]): super().__init__() - self._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == terms_version + self._completed_callback = completed_callback + self._accepted_terms: bool = (ui_state.params.get("HasAcceptedTerms") == terms_version and + ui_state.params.get("HasAcceptedTermsSP") == terms_version_sp) self._training_done: bool = ui_state.params.get("CompletedTrainingVersion") == training_version + self._sunnylink_consent_done: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") in { + sunnylink_consent_version, sunnylink_consent_declined + } - self._state = OnboardingState.TERMS if not self._accepted_terms else OnboardingState.ONBOARDING + self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) - self.set_rect(rl.Rectangle(0, 0, 458, gui_app.height)) + # Windows — all pushed onto nav stack, _terms is always rendered as base layer + self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_uninstall) + self._terms.set_enabled(lambda: self.enabled) # for nav stack + + self._sunnylink_consent = SunnylinkConsentPage( + on_accept=self._on_sunnylink_accepted, + on_decline=self._on_sunnylink_declined, + ) - # Windows - self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_terms_declined) self._training_guide = TrainingGuide(completed_callback=self._on_completed_training) - self._decline_page = DeclinePage(back_callback=self._on_decline_back) + self._training_guide.set_enabled(lambda: self.enabled) # for nav stack - # sunnylink consent pages - self._accepted_terms = self._accepted_terms and ui_state.params.get("HasAcceptedTermsSP") == terms_version_sp - self._sunnylink = SunnylinkOnboarding() - if not self._accepted_terms: - self._state = OnboardingState.TERMS - elif not self._sunnylink.completed: - self._state = OnboardingState.SUNNYLINK_CONSENT - elif not self._training_done: - self._state = OnboardingState.ONBOARDING - else: - self._state = OnboardingState.ONBOARDING + self._needs_initial_push = False + + def _on_uninstall(self): + ui_state.params.put_bool("DoUninstall", True) def show_event(self): super().show_event() device.set_override_interactive_timeout(300) + device.set_offroad_brightness(100) + self._needs_initial_push = True def hide_event(self): super().hide_event() + # FIXME: when nav stack sends hide event to widget 2 below on push, this needs to be moved device.set_override_interactive_timeout(None) + device.set_offroad_brightness(None) @property def completed(self) -> bool: - return self._accepted_terms and self._sunnylink.completed and self._training_done - - def _on_terms_declined(self): - self._state = OnboardingState.DECLINE - - def _on_decline_back(self): - self._state = OnboardingState.TERMS + return self._accepted_terms and self._sunnylink_consent_done and self._training_done def close(self): - ui_state.params.put_bool("IsDriverViewEnabled", False) - gui_app.pop_widget() + ui_state.params.put_bool_nonblocking("IsDriverViewEnabled", False) + self._completed_callback() def _on_terms_accepted(self): ui_state.params.put("HasAcceptedTerms", terms_version) ui_state.params.put("HasAcceptedTermsSP", terms_version_sp) - if not self._sunnylink.completed: - self._state = OnboardingState.SUNNYLINK_CONSENT + self._accepted_terms = True + if not self._sunnylink_consent_done: + gui_app.push_widget(self._sunnylink_consent) elif not self._training_done: - self._state = OnboardingState.ONBOARDING + gui_app.push_widget(self._training_guide) + else: + self.close() + + def _on_sunnylink_accepted(self): + ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version) + ui_state.params.put_bool("SunnylinkEnabled", True) + self._sunnylink_consent_done = True + if not self._training_done: + gui_app.push_widget(self._training_guide) + else: + self.close() + + def _on_sunnylink_declined(self): + ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_declined) + ui_state.params.put_bool("SunnylinkEnabled", False) + self._sunnylink_consent_done = True + if not self._training_done: + gui_app.push_widget(self._training_guide) else: self.close() def _on_completed_training(self): ui_state.params.put("CompletedTrainingVersion", training_version) + self._training_done = True self.close() def _render(self, _): rl.draw_rectangle_rec(self._rect, rl.BLACK) - if self._state == OnboardingState.TERMS: - self._terms.render(self._rect) - elif self._state == OnboardingState.SUNNYLINK_CONSENT: - self._sunnylink.render(self._rect) - if self._sunnylink.completed: - if not self._training_done: - self._state = OnboardingState.ONBOARDING - else: - self.close() - elif self._state == OnboardingState.ONBOARDING: - if not self._training_done: - self._training_guide.render(self._rect) - else: - self.close() - elif self._state == OnboardingState.DECLINE: - self._decline_page.render(self._rect) + + # Deferred from show_event to avoid nested push_widget re-enable bug + if self._needs_initial_push: + self._needs_initial_push = False + if self._accepted_terms and not self._sunnylink_consent_done: + gui_app.push_widget(self._sunnylink_consent) + elif self._accepted_terms and self._sunnylink_consent_done and not self._training_done: + gui_app.push_widget(self._training_guide) + + self._terms.render(self._rect) diff --git a/selfdrive/ui/mici/layouts/settings/developer.py b/selfdrive/ui/mici/layouts/settings/developer.py index 4e7796814e..f47614c073 100644 --- a/selfdrive/ui/mici/layouts/settings/developer.py +++ b/selfdrive/ui/mici/layouts/settings/developer.py @@ -5,32 +5,37 @@ from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog 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 -from openpilot.selfdrive.ui.widgets.ssh_key import SshKeyAction +from openpilot.selfdrive.ui.widgets.ssh_key import SshKeyFetcher class DeveloperLayoutMici(NavScroller): def __init__(self): super().__init__() + self._ssh_fetcher = SshKeyFetcher(ui_state.params) def github_username_callback(username: str): if username: - ssh_keys = SshKeyAction() - ssh_keys._fetch_ssh_key(username) - if not ssh_keys._error_message: - self._ssh_keys_btn.set_value(username) - else: - dlg = BigDialog("", ssh_keys._error_message) - gui_app.push_widget(dlg) + self._ssh_keys_btn.set_value("Loading...") + self._ssh_keys_btn.set_enabled(False) + + def on_response(error): + self._ssh_keys_btn.set_enabled(True) + if error is None: + self._ssh_keys_btn.set_value(username) + else: + self._ssh_keys_btn.set_value("Not set") + gui_app.push_widget(BigDialog("", error)) + + self._ssh_fetcher.fetch(username, on_response) else: - ui_state.params.remove("GithubUsername") - ui_state.params.remove("GithubSshKeys") + self._ssh_fetcher.clear() self._ssh_keys_btn.set_value("Not set") def ssh_keys_callback(): github_username = ui_state.params.get("GithubUsername") or "" dlg = BigInputDialog("enter GitHub username...", github_username, minimum_length=0, confirm_callback=github_username_callback) if not system_time_valid(): - dlg = BigDialog("Please connect to Wi-Fi to fetch your key", "") + dlg = BigDialog("", "Please connect to Wi-Fi to fetch your key.") gui_app.push_widget(dlg) return gui_app.push_widget(dlg) @@ -42,14 +47,17 @@ class DeveloperLayoutMici(NavScroller): # adb, ssh, ssh keys, debug mode, joystick debug mode, longitudinal maneuver mode, ip address # ******** Main Scroller ******** - self._adb_toggle = BigCircleParamControl("icons_mici/adb_short.png", "AdbEnabled", icon_size=(82, 82), icon_offset=(0, 12)) - self._ssh_toggle = BigCircleParamControl("icons_mici/ssh_short.png", "SshEnabled", icon_size=(82, 82), icon_offset=(0, 12)) + self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12)) + self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12)) self._joystick_toggle = BigToggle("joystick debug mode", initial_state=ui_state.params.get_bool("JoystickDebugMode"), toggle_callback=self._on_joystick_debug_mode) self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode", initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"), toggle_callback=self._on_long_maneuver_mode) + self._lat_maneuver_toggle = BigToggle("lateral maneuver mode", + initial_state=ui_state.params.get_bool("LateralManeuverMode"), + toggle_callback=self._on_lat_maneuver_mode) self._alpha_long_toggle = BigToggle("alpha longitudinal", initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"), toggle_callback=self._on_alpha_long_enabled) @@ -63,6 +71,7 @@ class DeveloperLayoutMici(NavScroller): self._ssh_keys_btn, self._joystick_toggle, self._long_maneuver_toggle, + self._lat_maneuver_toggle, self._alpha_long_toggle, self._debug_mode_toggle, ]) @@ -73,12 +82,13 @@ class DeveloperLayoutMici(NavScroller): ("SshEnabled", self._ssh_toggle), ("JoystickDebugMode", self._joystick_toggle), ("LongitudinalManeuverMode", self._long_maneuver_toggle), + ("LateralManeuverMode", self._lat_maneuver_toggle), ("AlphaLongitudinalEnabled", self._alpha_long_toggle), ("ShowDebugInfo", self._debug_mode_toggle), ) onroad_blocked_toggles = (self._adb_toggle, self._joystick_toggle) - release_blocked_toggles = (self._joystick_toggle, self._long_maneuver_toggle, self._alpha_long_toggle) - engaged_blocked_toggles = (self._long_maneuver_toggle, self._alpha_long_toggle) + release_blocked_toggles = (self._joystick_toggle, self._long_maneuver_toggle, self._lat_maneuver_toggle, self._alpha_long_toggle) + engaged_blocked_toggles = (self._long_maneuver_toggle, self._lat_maneuver_toggle, self._alpha_long_toggle) # Hide non-release toggles on release builds for item in release_blocked_toggles: @@ -99,6 +109,10 @@ class DeveloperLayoutMici(NavScroller): ui_state.add_offroad_transition_callback(self._update_toggles) + def _update_state(self): + super()._update_state() + self._ssh_fetcher.update() + def show_event(self): super().show_event() self._update_toggles() @@ -120,8 +134,12 @@ class DeveloperLayoutMici(NavScroller): if not long_man_enabled: self._long_maneuver_toggle.set_checked(False) ui_state.params.put_bool("LongitudinalManeuverMode", False) + + lat_man_enabled = ui_state.is_offroad() + self._lat_maneuver_toggle.set_enabled(lat_man_enabled) else: self._long_maneuver_toggle.set_enabled(False) + self._lat_maneuver_toggle.set_enabled(False) self._alpha_long_toggle.set_visible(False) # Refresh toggles from params to mirror external changes @@ -132,11 +150,24 @@ class DeveloperLayoutMici(NavScroller): ui_state.params.put_bool("JoystickDebugMode", state) ui_state.params.put_bool("LongitudinalManeuverMode", False) self._long_maneuver_toggle.set_checked(False) + ui_state.params.put_bool("LateralManeuverMode", False) + self._lat_maneuver_toggle.set_checked(False) def _on_long_maneuver_mode(self, state: bool): ui_state.params.put_bool("LongitudinalManeuverMode", state) ui_state.params.put_bool("JoystickDebugMode", False) self._joystick_toggle.set_checked(False) + ui_state.params.put_bool("LateralManeuverMode", False) + self._lat_maneuver_toggle.set_checked(False) + restart_needed_callback(state) + + def _on_lat_maneuver_mode(self, state: bool): + ui_state.params.put_bool("LateralManeuverMode", state) + ui_state.params.put_bool("ExperimentalMode", False) + ui_state.params.put_bool("JoystickDebugMode", False) + self._joystick_toggle.set_checked(False) + ui_state.params.put_bool("LongitudinalManeuverMode", False) + self._long_maneuver_toggle.set_checked(False) restart_needed_callback(state) def _on_alpha_long_enabled(self, state: bool): diff --git a/selfdrive/ui/mici/layouts/settings/device.py b/selfdrive/ui/mici/layouts/settings/device.py index 0d253cb26f..e0d89a5419 100644 --- a/selfdrive/ui/mici/layouts/settings/device.py +++ b/selfdrive/ui/mici/layouts/settings/device.py @@ -9,19 +9,40 @@ from openpilot.common.params import Params from openpilot.common.time_helpers import system_time_valid 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, BigConfirmationDialogV2 +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.layouts.onboarding import TrainingGuide, TermsPage from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget -from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.ui.widgets.label import MiciLabel +from openpilot.selfdrive.ui.ui_state import device, ui_state +from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.html_render import HtmlModal, HtmlRenderer from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID +class ReviewTermsPage(TermsPage, NavScroller): + """TermsPage with NavWidget swipe-to-dismiss for reviewing in device settings.""" + def __init__(self): + super().__init__(on_accept=self.dismiss, on_decline=self.dismiss) + self._terms_header.set_visible(False) + self._must_accept_card.set_visible(False) + self._accept_button.set_visible(False) + self._decline_button.set_visible(False) + + +class ReviewTrainingGuide(TrainingGuide): + def show_event(self): + super().show_event() + device.set_override_interactive_timeout(300) + + def hide_event(self): + super().hide_event() + device.set_override_interactive_timeout(None) + ui_state.params.put_bool_nonblocking("IsDriverViewEnabled", False) + + class MiciFccModal(NavRawScrollPanel): def __init__(self, file_path: str | None = None, text: str | None = None): super().__init__() @@ -43,34 +64,31 @@ class MiciFccModal(NavRawScrollPanel): rl.draw_texture_ex(self._fcc_logo, fcc_pos, 0.0, 1.0, rl.WHITE) -def _engaged_confirmation_callback(callback: Callable, action_text: str): +def _engaged_confirmation_click(callback: Callable, action_text: str, icon: rl.Texture, exit_on_confirm: bool = True, red: bool = False): if not ui_state.engaged: def confirm_callback(): # Check engaged again in case it changed while the dialog was open + # TODO: if true, we stay on the dialog if not exit_on_confirm until normal onroad timeout if not ui_state.engaged: callback() - red = False - if action_text == "power off": - icon = "icons_mici/settings/device/power.png" - red = True - elif action_text == "reboot": - icon = "icons_mici/settings/device/reboot.png" - elif action_text == "reset": - icon = "icons_mici/settings/device/lkas.png" - elif action_text == "uninstall": - icon = "icons_mici/settings/device/uninstall.png" - else: - # TODO: check - icon = "icons_mici/settings/comma_icon.png" - - dlg: BigConfirmationDialogV2 | BigDialog = BigConfirmationDialogV2(f"slide to\n{action_text.lower()}", icon, red=red, - exit_on_confirm=action_text == "reset", - confirm_callback=confirm_callback) - gui_app.push_widget(dlg) + gui_app.push_widget(BigConfirmationDialog(f"slide to\n{action_text.lower()}", icon, confirm_callback, exit_on_confirm=exit_on_confirm, red=red)) else: - dlg = BigDialog(f"Disengage to {action_text}", "") - gui_app.push_widget(dlg) + gui_app.push_widget(BigDialog("", f"Disengage to {action_text}")) + + +class EngagedConfirmationCircleButton(BigCircleButton): + def __init__(self, title: str, icon: rl.Texture, callback: Callable[[], None], exit_on_confirm: bool = True, + red: bool = False, icon_offset: tuple[int, int] = (0, 0)): + super().__init__(icon, red, icon_offset) + self.set_click_callback(lambda: _engaged_confirmation_click(callback, title, icon, exit_on_confirm=exit_on_confirm, red=red)) + + +class EngagedConfirmationButton(BigButton): + def __init__(self, text: str, action_text: str, icon: rl.Texture, callback: Callable[[], None], + exit_on_confirm: bool = True, red: bool = False): + super().__init__(text, "", icon) + self.set_click_callback(lambda: _engaged_confirmation_click(callback, action_text, icon, exit_on_confirm=exit_on_confirm, red=red)) class DeviceInfoLayoutMici(Widget): @@ -80,14 +98,15 @@ class DeviceInfoLayoutMici(Widget): self.set_rect(rl.Rectangle(0, 0, 360, 180)) params = Params() - header_color = rl.Color(255, 255, 255, int(255 * 0.9)) subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) max_width = int(self._rect.width - 20) - self._dongle_id_label = MiciLabel("device ID", 48, width=max_width, color=header_color, font_weight=FontWeight.DISPLAY) - self._dongle_id_text_label = MiciLabel(params.get("DongleId") or 'N/A', 32, width=max_width, color=subheader_color, font_weight=FontWeight.ROMAN) + self._dongle_id_label = UnifiedLabel("device ID", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) + self._dongle_id_text_label = UnifiedLabel(params.get("DongleId") or 'N/A', 32, max_width=max_width, text_color=subheader_color, + font_weight=FontWeight.ROMAN, wrap_text=False) - self._serial_number_label = MiciLabel("serial", 48, color=header_color, font_weight=FontWeight.DISPLAY) - self._serial_number_text_label = MiciLabel(params.get("HardwareSerial") or 'N/A', 32, width=max_width, color=subheader_color, font_weight=FontWeight.ROMAN) + self._serial_number_label = UnifiedLabel("serial", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) + self._serial_number_text_label = UnifiedLabel(params.get("HardwareSerial") or 'N/A', 32, max_width=max_width, text_color=subheader_color, + font_weight=FontWeight.ROMAN, wrap_text=False) def _render(self, _): self._dongle_id_label.set_position(self._rect.x + 20, self._rect.y - 10) @@ -111,7 +130,7 @@ class UpdaterState(IntEnum): class PairBigButton(BigButton): def __init__(self): - super().__init__("pair", "connect.comma.ai", "icons_mici/settings/comma_icon.png", icon_size=(33, 60)) + super().__init__("pair", "connect.comma.ai", gui_app.texture("icons_mici/settings/comma_icon.png", 33, 60)) def _get_label_font_size(self): return 64 @@ -137,9 +156,9 @@ class PairBigButton(BigButton): return dlg: BigDialog | PairingDialog if not system_time_valid(): - dlg = BigDialog(tr("Please connect to Wi-Fi to complete initial pairing"), "") + dlg = BigDialog("", tr("Please connect to Wi-Fi to complete initial pairing.")) elif UNREGISTERED_DONGLE_ID == (ui_state.params.get("DongleId") or UNREGISTERED_DONGLE_ID): - dlg = BigDialog(tr("Device must be registered with the comma.ai backend to pair"), "") + dlg = BigDialog("", tr("Device must be registered with the comma.ai backend to pair.")) else: dlg = PairingDialog() gui_app.push_widget(dlg) @@ -169,7 +188,7 @@ class UpdateOpenpilotBigButton(BigButton): super()._handle_mouse_release(mouse_pos) if not system_time_valid(): - dlg = BigDialog(tr("Please connect to Wi-Fi to update"), "") + dlg = BigDialog("", tr("Please connect to Wi-Fi to update.")) gui_app.push_widget(dlg) return @@ -290,33 +309,33 @@ class DeviceLayoutMici(NavScroller): def uninstall_openpilot_callback(): ui_state.params.put_bool("DoUninstall", True) - reset_calibration_btn = BigButton("reset calibration", "", "icons_mici/settings/device/lkas.png", icon_size=(114, 60)) - reset_calibration_btn.set_click_callback(lambda: _engaged_confirmation_callback(reset_calibration_callback, "reset")) + reset_calibration_btn = EngagedConfirmationButton("reset calibration", "reset", gui_app.texture("icons_mici/settings/device/lkas.png", 122, 64), + reset_calibration_callback) - uninstall_openpilot_btn = BigButton("uninstall sunnypilot", "", "icons_mici/settings/device/uninstall.png") - uninstall_openpilot_btn.set_click_callback(lambda: _engaged_confirmation_callback(uninstall_openpilot_callback, "uninstall")) + uninstall_openpilot_btn = EngagedConfirmationButton("uninstall sunnypilot", "uninstall", + gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64), + uninstall_openpilot_callback, exit_on_confirm=False) - reboot_btn = BigCircleButton("icons_mici/settings/device/reboot.png", red=False, icon_size=(64, 70)) - reboot_btn.set_click_callback(lambda: _engaged_confirmation_callback(reboot_callback, "reboot")) + reboot_btn = EngagedConfirmationCircleButton("reboot", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70), + reboot_callback, exit_on_confirm=False) - self._power_off_btn = BigCircleButton("icons_mici/settings/device/power.png", red=True, icon_size=(64, 66)) - self._power_off_btn.set_click_callback(lambda: _engaged_confirmation_callback(power_off_callback, "power off")) + self._power_off_btn = EngagedConfirmationCircleButton("power off", gui_app.texture("icons_mici/settings/device/power.png", 64, 66), + power_off_callback, exit_on_confirm=False, red=True) self._power_off_btn.set_visible(lambda: not ui_state.ignition) - regulatory_btn = BigButton("regulatory info", "", "icons_mici/settings/device/info.png") + 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", "", "icons_mici/settings/device/cameras.png") + 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()) - review_training_guide_btn = BigButton("review\ntraining guide", "", "icons_mici/settings/device/info.png") - review_training_guide_btn.set_click_callback(lambda: gui_app.push_widget(TrainingGuide(completed_callback=gui_app.pop_widget))) + 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)))) review_training_guide_btn.set_enabled(lambda: ui_state.is_offroad()) - terms_btn = BigButton("terms &\nconditions", "", "icons_mici/settings/device/info.png") - terms_btn.set_click_callback(lambda: gui_app.push_widget(TermsPage(on_accept=gui_app.pop_widget))) - terms_btn.set_enabled(lambda: ui_state.is_offroad()) + terms_btn = BigButton("terms &\nconditions", "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64)) + terms_btn.set_click_callback(lambda: gui_app.push_widget(ReviewTermsPage())) self._scroller.add_widgets([ DeviceInfoLayoutMici(), diff --git a/selfdrive/ui/mici/layouts/settings/firehose.py b/selfdrive/ui/mici/layouts/settings/firehose.py index 741ea9655a..5bf7426c77 100644 --- a/selfdrive/ui/mici/layouts/settings/firehose.py +++ b/selfdrive/ui/mici/layouts/settings/firehose.py @@ -81,12 +81,12 @@ class FirehoseLayoutBase(Widget): def _render(self, rect: rl.Rectangle): # compute total content height for scrolling content_height = self._measure_content_height(rect) - scroll_offset = round(self._scroll_panel.update(rect, content_height)) + scroll_offset = self._scroll_panel.update(rect, content_height) # start drawing with offset - x = int(rect.x + 40) - y = int(rect.y + 40 + scroll_offset) - w = int(rect.width - 80) + x = rect.x + 40 + y = rect.y + 40 + scroll_offset + w = rect.width - 80 # Title title_text = tr(TITLE) @@ -100,7 +100,7 @@ class FirehoseLayoutBase(Widget): y += 20 # Separator - rl.draw_rectangle(x, y, w, 2, self.GRAY) + rl.draw_rectangle_rec(rl.Rectangle(x, y, w, 2), self.GRAY) y += 20 # Status @@ -116,7 +116,7 @@ class FirehoseLayoutBase(Widget): y += 20 # Separator - rl.draw_rectangle(x, y, w, 2, self.GRAY) + rl.draw_rectangle_rec(rl.Rectangle(x, y, w, 2), self.GRAY) y += 20 # Instructions intro diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index 553a74fc60..ddbab4b478 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -1,13 +1,9 @@ import pyray as rl -from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici, WifiIcon -from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigParamControl, BigToggle -from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog -from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.selfdrive.ui.lib.prime_state import PrimeType +from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiIcon +from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.system.ui.lib.application import gui_app -from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType, ConnectStatus, SecurityType, normalize_ssid +from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid class WifiNetworkButton(BigButton): @@ -62,148 +58,3 @@ class WifiNetworkButton(BigButton): lock_x = icon_x + self._txt_icon.width - self._lock_txt.width + 7 lock_y = icon_y + self._txt_icon.height - self._lock_txt.height + 8 rl.draw_texture_ex(self._lock_txt, (lock_x, lock_y), 0.0, 1.0, rl.WHITE) - - -class NetworkLayoutMici(NavScroller): - def __init__(self): - super().__init__() - - self._wifi_manager = WifiManager() - self._wifi_manager.set_active(False) - self._wifi_ui = WifiUIMici(self._wifi_manager) - - self._wifi_manager.add_callbacks( - networks_updated=self._on_network_updated, - ) - - # ******** Tethering ******** - def tethering_toggle_callback(checked: bool): - self._tethering_toggle_btn.set_enabled(False) - self._tethering_password_btn.set_enabled(False) - self._network_metered_btn.set_enabled(False) - self._wifi_manager.set_tethering_active(checked) - - self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback) - - def tethering_password_callback(password: str): - if password: - self._tethering_toggle_btn.set_enabled(False) - self._tethering_password_btn.set_enabled(False) - self._wifi_manager.set_tethering_password(password) - - def tethering_password_clicked(): - tethering_password = self._wifi_manager.tethering_password - dlg = BigInputDialog("enter password...", tethering_password, minimum_length=8, - confirm_callback=tethering_password_callback) - gui_app.push_widget(dlg) - - txt_tethering = gui_app.texture("icons_mici/settings/network/tethering.png", 64, 54) - self._tethering_password_btn = BigButton("tethering password", "", txt_tethering) - self._tethering_password_btn.set_click_callback(tethering_password_clicked) - - # ******** Network Metered ******** - def network_metered_callback(value: str): - self._network_metered_btn.set_enabled(False) - metered = { - 'default': MeteredType.UNKNOWN, - 'metered': MeteredType.YES, - 'unmetered': MeteredType.NO - }.get(value, MeteredType.UNKNOWN) - self._wifi_manager.set_current_network_metered(metered) - - # TODO: signal for current network metered type when changing networks, this is wrong until you press it once - # TODO: disable when not connected - self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback) - self._network_metered_btn.set_enabled(False) - - self._wifi_button = WifiNetworkButton(self._wifi_manager) - self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui)) - - # ******** Advanced settings ******** - # ******** Roaming toggle ******** - self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming", toggle_callback=self._toggle_roaming) - - # ******** APN settings ******** - self._apn_btn = BigButton("apn settings", "edit") - self._apn_btn.set_click_callback(self._edit_apn) - - # ******** Cellular metered toggle ******** - self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered", toggle_callback=self._toggle_cellular_metered) - - # Main scroller ---------------------------------- - self._scroller.add_widgets([ - self._wifi_button, - self._network_metered_btn, - self._tethering_toggle_btn, - self._tethering_password_btn, - # /* Advanced settings - self._roaming_btn, - self._apn_btn, - self._cellular_metered_btn, - # */ - ]) - - # Set initial config - roaming_enabled = ui_state.params.get_bool("GsmRoaming") - metered = ui_state.params.get_bool("GsmMetered") - self._wifi_manager.update_gsm_settings(roaming_enabled, ui_state.params.get("GsmApn") or "", metered) - - def _update_state(self): - super()._update_state() - - # If not using prime SIM, show GSM settings and enable IPv4 forwarding - show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE) - self._wifi_manager.set_ipv4_forward(show_cell_settings) - self._roaming_btn.set_visible(show_cell_settings) - self._apn_btn.set_visible(show_cell_settings) - self._cellular_metered_btn.set_visible(show_cell_settings) - - def show_event(self): - super().show_event() - self._wifi_manager.set_active(True) - - # Process wifi callbacks while at any point in the nav stack - gui_app.add_nav_stack_tick(self._wifi_manager.process_callbacks) - - def hide_event(self): - super().hide_event() - self._wifi_manager.set_active(False) - - gui_app.remove_nav_stack_tick(self._wifi_manager.process_callbacks) - - def _toggle_roaming(self, checked: bool): - self._wifi_manager.update_gsm_settings(checked, ui_state.params.get("GsmApn") or "", ui_state.params.get_bool("GsmMetered")) - - def _edit_apn(self): - def update_apn(apn: str): - apn = apn.strip() - if apn == "": - ui_state.params.remove("GsmApn") - else: - ui_state.params.put("GsmApn", apn) - - self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), apn, ui_state.params.get_bool("GsmMetered")) - - current_apn = ui_state.params.get("GsmApn") or "" - dlg = BigInputDialog("enter APN...", current_apn, minimum_length=0, confirm_callback=update_apn) - gui_app.push_widget(dlg) - - def _toggle_cellular_metered(self, checked: bool): - self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), ui_state.params.get("GsmApn") or "", checked) - - def _on_network_updated(self, networks: list[Network]): - # Update tethering state - tethering_active = self._wifi_manager.is_tethering_active() - # TODO: use real signals (like activated/settings changed, etc.) to speed up re-enabling buttons - self._tethering_toggle_btn.set_enabled(True) - self._tethering_password_btn.set_enabled(True) - self._network_metered_btn.set_enabled(lambda: not tethering_active and bool(self._wifi_manager.ipv4_address)) - self._tethering_toggle_btn.set_checked(tethering_active) - - # Update network metered - self._network_metered_btn.set_value( - { - MeteredType.UNKNOWN: 'default', - MeteredType.YES: 'metered', - MeteredType.NO: 'unmetered' - }.get(self._wifi_manager.current_network_metered, 'default')) diff --git a/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/selfdrive/ui/mici/layouts/settings/network/network_layout.py new file mode 100644 index 0000000000..9f6fae4b5f --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -0,0 +1,154 @@ +from openpilot.system.ui.widgets.scroller import NavScroller +from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiNetworkButton +from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigParamControl, BigToggle +from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.lib.prime_state import PrimeType +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType + + +class NetworkLayoutMici(NavScroller): + def __init__(self): + super().__init__() + + self._wifi_manager = WifiManager() + self._wifi_manager.set_active(False) + self._wifi_ui = WifiUIMici(self._wifi_manager) + + self._wifi_manager.add_callbacks( + networks_updated=self._on_network_updated, + ) + + # ******** Tethering ******** + def tethering_toggle_callback(checked: bool): + self._tethering_toggle_btn.set_enabled(False) + self._tethering_password_btn.set_enabled(False) + self._network_metered_btn.set_enabled(False) + self._wifi_manager.set_tethering_active(checked) + + self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback) + + def tethering_password_callback(password: str): + if password: + self._tethering_toggle_btn.set_enabled(False) + self._tethering_password_btn.set_enabled(False) + self._wifi_manager.set_tethering_password(password) + + def tethering_password_clicked(): + tethering_password = self._wifi_manager.tethering_password + dlg = BigInputDialog("enter password...", tethering_password, minimum_length=8, + confirm_callback=tethering_password_callback) + gui_app.push_widget(dlg) + + txt_tethering = gui_app.texture("icons_mici/settings/network/tethering.png", 64, 54) + self._tethering_password_btn = BigButton("tethering password", "", txt_tethering) + self._tethering_password_btn.set_click_callback(tethering_password_clicked) + + # ******** Network Metered ******** + def network_metered_callback(value: str): + self._network_metered_btn.set_enabled(False) + metered = { + 'default': MeteredType.UNKNOWN, + 'metered': MeteredType.YES, + 'unmetered': MeteredType.NO + }.get(value, MeteredType.UNKNOWN) + self._wifi_manager.set_current_network_metered(metered) + + # TODO: signal for current network metered type when changing networks, this is wrong until you press it once + # TODO: disable when not connected + self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback) + self._network_metered_btn.set_enabled(False) + + self._wifi_button = WifiNetworkButton(self._wifi_manager) + self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui)) + + # ******** Advanced settings ******** + # ******** Roaming toggle ******** + self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming", toggle_callback=self._toggle_roaming) + + # ******** APN settings ******** + self._apn_btn = BigButton("apn settings", "edit") + self._apn_btn.set_click_callback(self._edit_apn) + + # ******** Cellular metered toggle ******** + self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered", toggle_callback=self._toggle_cellular_metered) + + # Main scroller ---------------------------------- + self._scroller.add_widgets([ + self._wifi_button, + self._network_metered_btn, + self._tethering_toggle_btn, + self._tethering_password_btn, + # /* Advanced settings + self._roaming_btn, + self._apn_btn, + self._cellular_metered_btn, + # */ + ]) + + # Set initial config + roaming_enabled = ui_state.params.get_bool("GsmRoaming") + metered = ui_state.params.get_bool("GsmMetered") + self._wifi_manager.update_gsm_settings(roaming_enabled, ui_state.params.get("GsmApn") or "", metered) + + def _update_state(self): + super()._update_state() + + # If not using prime SIM, show GSM settings and enable IPv4 forwarding + show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE) + self._wifi_manager.set_ipv4_forward(show_cell_settings) + self._roaming_btn.set_visible(show_cell_settings) + self._apn_btn.set_visible(show_cell_settings) + self._cellular_metered_btn.set_visible(show_cell_settings) + + def show_event(self): + super().show_event() + self._wifi_manager.set_active(True) + + # Process wifi callbacks while at any point in the nav stack + gui_app.add_nav_stack_tick(self._wifi_manager.process_callbacks) + + def hide_event(self): + super().hide_event() + self._wifi_manager.set_active(False) + + gui_app.remove_nav_stack_tick(self._wifi_manager.process_callbacks) + + def _toggle_roaming(self, checked: bool): + self._wifi_manager.update_gsm_settings(checked, ui_state.params.get("GsmApn") or "", ui_state.params.get_bool("GsmMetered")) + + def _edit_apn(self): + def update_apn(apn: str): + apn = apn.strip() + if apn == "": + ui_state.params.remove("GsmApn") + else: + ui_state.params.put("GsmApn", apn) + + self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), apn, ui_state.params.get_bool("GsmMetered")) + + current_apn = ui_state.params.get("GsmApn") or "" + dlg = BigInputDialog("enter APN...", current_apn, minimum_length=0, confirm_callback=update_apn) + gui_app.push_widget(dlg) + + def _toggle_cellular_metered(self, checked: bool): + self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), ui_state.params.get("GsmApn") or "", checked) + + def _on_network_updated(self, networks: list[Network]): + # Update tethering state + tethering_active = self._wifi_manager.is_tethering_active() + # TODO: use real signals (like activated/settings changed, etc.) to speed up re-enabling buttons + self._tethering_toggle_btn.set_enabled(True) + self._tethering_password_btn.set_enabled(True) + self._network_metered_btn.set_enabled(lambda: not tethering_active and bool(self._wifi_manager.ipv4_address)) + self._tethering_toggle_btn.set_checked(tethering_active) + + # Update network metered + self._network_metered_btn.set_value( + { + MeteredType.UNKNOWN: 'default', + MeteredType.YES: 'metered', + MeteredType.NO: 'unmetered' + }.get(self._wifi_manager.current_network_metered, 'default')) diff --git a/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py b/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py index 22d3d1d0da..006027e258 100644 --- a/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py @@ -3,9 +3,8 @@ import numpy as np import pyray as rl from collections.abc import Callable -from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.swaglog import cloudlog -from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog, BigConfirmationDialogV2 +from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog, BigConfirmationDialog from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight from openpilot.system.ui.widgets import Widget @@ -14,39 +13,26 @@ from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityT class LoadingAnimation(Widget): - HIDE_TIME = 4 + RADIUS = 8 + SPACING = 24 # center-to-center: diameter (16) + gap (8) + Y_MAG = 11.2 def __init__(self): super().__init__() - self._opacity_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) - self._opacity_target = 1.0 - self._hide_time = 0.0 - - def show_event(self): - self._opacity_target = 1.0 - self._hide_time = rl.get_time() + w = self.SPACING * 2 + self.RADIUS * 2 + h = self.RADIUS * 2 + int(self.Y_MAG) + self.set_rect(rl.Rectangle(0, 0, w, h)) def _render(self, _): - if rl.get_time() - self._hide_time > self.HIDE_TIME: - self._opacity_target = 0.0 - - self._opacity_filter.update(self._opacity_target) - - if self._opacity_filter.x < 0.01: - return - - cx = int(self._rect.x + self._rect.width / 2) - cy = int(self._rect.y + self._rect.height / 2) - - y_mag = 7 - anim_scale = 4 - spacing = 14 + # Balls rest at bottom center; bounce upward + base_x = int(self._rect.x + self._rect.width / 2) + base_y = int(self._rect.y + self._rect.height - self.RADIUS) for i in range(3): - x = cx - spacing + i * spacing - y = int(cy + min(math.sin((rl.get_time() - i * 0.2) * anim_scale) * y_mag, 0)) - alpha = int(np.interp(cy - y, [0, y_mag], [255 * 0.45, 255 * 0.9]) * self._opacity_filter.x) - rl.draw_circle(x, y, 5, rl.Color(255, 255, 255, alpha)) + x = base_x + (i - 1) * self.SPACING + y = int(base_y + min(math.sin((rl.get_time() - i * 0.2) * 4) * self.Y_MAG, 0)) + alpha = int(np.interp(base_y - y, [0, self.Y_MAG], [255 * 0.45, 255 * 0.9])) + rl.draw_circle(x, y, self.RADIUS, rl.Color(255, 255, 255, alpha)) class WifiIcon(Widget): @@ -124,6 +110,10 @@ class WifiButton(BigButton): if self._is_connected or self._is_connecting: self._wrong_password = False + @property + def network_forgetting(self) -> bool: + return self._network_forgetting + def _forget_network(self): if self._network_forgetting: return @@ -175,7 +165,7 @@ class WifiButton(BigButton): if self._is_connected and not self._network_forgetting: check_y = int(label_y - sub_label_height + (sub_label_height - self._check_txt.height) / 2) - rl.draw_texture(self._check_txt, int(sub_label_x), check_y, rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))) + rl.draw_texture_ex(self._check_txt, rl.Vector2(sub_label_x, check_y), 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))) sub_label_x += self._check_txt.width + 14 sub_label_rect = rl.Rectangle(sub_label_x, label_y - sub_label_height, sub_label_w, sub_label_height) @@ -256,8 +246,7 @@ class ForgetButton(Widget): def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) - dlg = BigConfirmationDialogV2("slide to forget", "icons_mici/settings/network/new/trash.png", red=True, - confirm_callback=self._forget_network) + dlg = BigConfirmationDialog("slide to forget", gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64), self._forget_network, red=True) gui_app.push_widget(dlg) def _render(self, _): @@ -270,11 +259,26 @@ class ForgetButton(Widget): rl.draw_texture_ex(self._trash_txt, (trash_x, trash_y), 0, 1.0, rl.WHITE) +class ScanningButton(BigButton): + def __init__(self): + super().__init__("", "searching for networks") + self.set_enabled(False) + self._loading_animation = LoadingAnimation() + + def _draw_content(self, btn_y: float): + super()._draw_content(btn_y) + anim = self._loading_animation + x = self._rect.x + self._rect.width - anim.rect.width - 40 + y = btn_y + self._rect.height - anim.rect.height - 30 + anim.set_position(x, y) + anim.render() + + class WifiUIMici(NavScroller): def __init__(self, wifi_manager: WifiManager): super().__init__() - self._loading_animation = LoadingAnimation() + self._scanning_btn = ScanningButton() self._wifi_manager = wifi_manager self._networks: dict[str, Network] = {} @@ -285,20 +289,23 @@ class WifiUIMici(NavScroller): networks_updated=self._on_network_updated, ) + @property + def any_network_forgetting(self) -> bool: + # TODO: deactivate before forget and add DISCONNECTING state + return any(btn.network_forgetting for btn in self._scroller.items if isinstance(btn, WifiButton)) + def show_event(self): - # Clear scroller items and update from latest scan results + # Re-sort scroller items and update from latest scan results super().show_event() - self._loading_animation.show_event() self._wifi_manager.set_active(True) - self._scroller.items.clear() - # trigger button update on latest sorted networks - self._on_network_updated(self._wifi_manager.networks) + self._networks = {n.ssid: n for n in self._wifi_manager.networks} + self._update_buttons(re_sort=True) def _on_network_updated(self, networks: list[Network]): self._networks = {network.ssid: network for network in networks} self._update_buttons() - def _update_buttons(self): + def _update_buttons(self, re_sort: bool = False): # Update existing buttons, add new ones to the end existing = {btn.network.ssid: btn for btn in self._scroller.items if isinstance(btn, WifiButton)} @@ -310,10 +317,22 @@ class WifiUIMici(NavScroller): btn.set_click_callback(lambda ssid=network.ssid: self._connect_to_network(ssid)) self._scroller.add_widget(btn) - # Mark networks no longer in scan results (display handled by _update_state) - for btn in self._scroller.items: - if isinstance(btn, WifiButton) and btn.network.ssid not in self._networks: - btn.set_network_missing(True) + if re_sort: + # Remove stale buttons and sort to match scan order, preserving eager state + btn_map = {btn.network.ssid: btn for btn in self._scroller.items if isinstance(btn, WifiButton)} + self._scroller.items[:] = [btn_map[ssid] for ssid in self._networks if ssid in btn_map] + else: + # Mark networks no longer in scan results (display handled by _update_state) + for btn in self._scroller.items: + if isinstance(btn, WifiButton) and btn.network.ssid not in self._networks: + btn.set_network_missing(True) + + # Keep scanning button at the end + items = self._scroller.items + if self._scanning_btn in items: + items.append(items.pop(items.index(self._scanning_btn))) + else: + self._scroller.add_widget(self._scanning_btn) def _connect_with_password(self, ssid: str, password: str): self._wifi_manager.connect_to_network(ssid, password) @@ -370,17 +389,3 @@ class WifiUIMici(NavScroller): super()._update_state() self._move_network_to_front(self._wifi_manager.wifi_state.ssid) - - # Show loading animation near end - max_scroll = max(self._scroller.content_size - self._scroller.rect.width, 1) - progress = -self._scroller.scroll_panel.get_offset() / max_scroll - if progress > 0.8 or len(self._scroller.items) <= 1: - self._loading_animation.show_event() - - def _render(self, _): - super()._render(self._rect) - - anim_w = 90 - anim_x = self._rect.x + self._rect.width - anim_w - anim_y = self._rect.y + self._rect.height - 25 + 2 - self._loading_animation.render(rl.Rectangle(anim_x, anim_y, anim_w, 20)) diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py index c7fb3201f5..4ccc5ba139 100644 --- a/selfdrive/ui/mici/layouts/settings/settings.py +++ b/selfdrive/ui/mici/layouts/settings/settings.py @@ -2,7 +2,7 @@ from openpilot.common.params import Params from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici -from openpilot.selfdrive.ui.mici.layouts.settings.network import NetworkLayoutMici +from openpilot.selfdrive.ui.mici.layouts.settings.network.network_layout import NetworkLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout @@ -20,23 +20,23 @@ class SettingsLayout(NavScroller): self._params = Params() toggles_panel = TogglesLayoutMici() - toggles_btn = SettingsBigButton("toggles", "", "icons_mici/settings.png") + toggles_btn = SettingsBigButton("toggles", "", gui_app.texture("icons_mici/settings.png", 64, 64)) toggles_btn.set_click_callback(lambda: gui_app.push_widget(toggles_panel)) network_panel = NetworkLayoutMici() - network_btn = SettingsBigButton("network", "", "icons_mici/settings/network/wifi_strength_full.png", icon_size=(76, 56)) + network_btn = SettingsBigButton("network", "", gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 76, 56)) network_btn.set_click_callback(lambda: gui_app.push_widget(network_panel)) device_panel = DeviceLayoutMici() - device_btn = SettingsBigButton("device", "", "icons_mici/settings/device_icon.png", icon_size=(74, 60)) + device_btn = SettingsBigButton("device", "", gui_app.texture("icons_mici/settings/device_icon.png", 72, 58)) device_btn.set_click_callback(lambda: gui_app.push_widget(device_panel)) developer_panel = DeveloperLayoutMici() - developer_btn = SettingsBigButton("developer", "", "icons_mici/settings/developer_icon.png", icon_size=(64, 60)) + developer_btn = SettingsBigButton("developer", "", gui_app.texture("icons_mici/settings/developer_icon.png", 64, 60)) developer_btn.set_click_callback(lambda: gui_app.push_widget(developer_panel)) firehose_panel = FirehoseLayout() - firehose_btn = SettingsBigButton("firehose", "", "icons_mici/settings/firehose.png", icon_size=(52, 62)) + firehose_btn = SettingsBigButton("firehose", "", gui_app.texture("icons_mici/settings/firehose.png", 52, 62)) firehose_btn.set_click_callback(lambda: gui_app.push_widget(firehose_panel)) self._scroller.add_widgets([ diff --git a/selfdrive/ui/mici/onroad/alert_renderer.py b/selfdrive/ui/mici/onroad/alert_renderer.py index 68b2aef7a5..5b550030de 100644 --- a/selfdrive/ui/mici/onroad/alert_renderer.py +++ b/selfdrive/ui/mici/onroad/alert_renderer.py @@ -231,7 +231,7 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer): self._alpha_filter.update(0 if alert is None else 1) if gui_app.sunnypilot_ui(): - ui_state.onroad_brightness_handle_alerts(ui_state.started, alert) + ui_state.onroad_brightness_handle_alerts(ui_state, alert) if alert is None: # If still animating out, keep the previous alert @@ -272,8 +272,8 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer): else: icon_alpha = int(min(self._turn_signal_alpha_filter.x, 255)) - rl.draw_texture(alert_layout.icon.texture, pos_x, int(self._rect.y + alert_layout.icon.margin_y), - rl.Color(255, 255, 255, int(icon_alpha * self._alpha_filter.x))) + rl.draw_texture_ex(alert_layout.icon.texture, rl.Vector2(pos_x, self._rect.y + alert_layout.icon.margin_y), 0.0, 1.0, + rl.Color(255, 255, 255, int(icon_alpha * self._alpha_filter.x))) def _draw_background(self, alert: Alert) -> None: # draw top gradient for alert text at top diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py index 70cc249d2a..9910c955ec 100644 --- a/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -130,7 +130,7 @@ class BookmarkIcon(Widget): if self._offset_filter.x > 0: icon_x = self.rect.x + self.rect.width - round(self._offset_filter.x) icon_y = self.rect.y + (self.rect.height - self._icon.height) / 2 # Vertically centered - rl.draw_texture(self._icon, int(icon_x), int(icon_y), rl.WHITE) + rl.draw_texture_ex(self._icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE) class AugmentedRoadView(CameraView): @@ -178,6 +178,8 @@ class AugmentedRoadView(CameraView): # update offroad label if ui_state.panda_type == log.PandaState.PandaType.unknown: self._offroad_label.set_text("system booting") + elif ui_state.ignition and not ui_state.started: + self._offroad_label.set_text("openpilot can't start\ncheck alerts") else: self._offroad_label.set_text("start the car to\nuse sunnypilot") @@ -251,7 +253,7 @@ class AugmentedRoadView(CameraView): # Draw darkened background and text if not onroad if not ui_state.started: rl.draw_rectangle(int(self.rect.x), int(self.rect.y), int(self.rect.width), int(self.rect.height), rl.Color(0, 0, 0, 175)) - self._offroad_label.render(self._content_rect) + self._offroad_label.render(self._rect) # publish uiDebug msg = messaging.new_message('uiDebug') diff --git a/selfdrive/ui/mici/onroad/cameraview.py b/selfdrive/ui/mici/onroad/cameraview.py index 89a4926ce9..62fcfd0654 100644 --- a/selfdrive/ui/mici/onroad/cameraview.py +++ b/selfdrive/ui/mici/onroad/cameraview.py @@ -155,11 +155,11 @@ class CameraView(Widget): # Prevent old frames from showing when going onroad. Qt has a separate thread # which drains the VisionIpcClient SubSocket for us. Re-connecting is not enough # and only clears internal buffers, not the message queue. - self.frame = None self.available_streams.clear() if self.client: del self.client self.client = VisionIpcClient(self._name, self._stream_type, conflate=True) + self.frame = None def _set_placeholder_color(self, color: rl.Color): """Set a placeholder color to be drawn when no frame is available.""" diff --git a/selfdrive/ui/mici/onroad/driver_camera_dialog.py b/selfdrive/ui/mici/onroad/driver_camera_dialog.py index e8321b099c..e8b8abb7f5 100644 --- a/selfdrive/ui/mici/onroad/driver_camera_dialog.py +++ b/selfdrive/ui/mici/onroad/driver_camera_dialog.py @@ -39,8 +39,6 @@ class BaseDriverCameraDialog(Widget): self._eye_fill_texture = None self._eye_orange_texture = None self._eye_size = 74 - self._glasses_texture = None - self._glasses_size = 171 self._load_eye_textures() @@ -154,8 +152,6 @@ class BaseDriverCameraDialog(Widget): self._eye_fill_texture = gui_app.texture("icons_mici/onroad/eye_fill.png", self._eye_size, self._eye_size) if self._eye_orange_texture is None: self._eye_orange_texture = gui_app.texture("icons_mici/onroad/eye_orange.png", self._eye_size, self._eye_size) - if self._glasses_texture is None: - self._glasses_texture = gui_app.texture("icons_mici/onroad/glasses.png", self._glasses_size, self._glasses_size) def _draw_face_detection(self, rect: rl.Rectangle): dm_state = ui_state.sm["driverMonitoringState"] @@ -202,31 +198,21 @@ class BaseDriverCameraDialog(Widget): eye_offset_x = 10 eye_offset_y = 10 eye_spacing = self._eye_size + 15 + eyes_prob = driver_data.eyesVisibleProb left_eye_x = rect.x + eye_offset_x left_eye_y = rect.y + eye_offset_y - left_eye_prob = driver_data.leftEyeProb right_eye_x = rect.x + eye_offset_x + eye_spacing right_eye_y = rect.y + eye_offset_y - right_eye_prob = driver_data.rightEyeProb # Draw eyes with opacity based on probability - for eye_x, eye_y, eye_prob in [(left_eye_x, left_eye_y, left_eye_prob), (right_eye_x, right_eye_y, right_eye_prob)]: - fill_opacity = eye_prob - orange_opacity = 1.0 - eye_prob - + fill_opacity = eyes_prob + orange_opacity = 1.0 - eyes_prob + for eye_x, eye_y in [(left_eye_x, left_eye_y), (right_eye_x, right_eye_y)]: rl.draw_texture_v(self._eye_orange_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * orange_opacity))) rl.draw_texture_v(self._eye_fill_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * fill_opacity))) - # Draw sunglasses indicator based on sunglasses probability - # Position glasses centered between the two eyes at top left - glasses_x = rect.x + eye_offset_x - 4 - glasses_y = rect.y - glasses_pos = rl.Vector2(glasses_x, glasses_y) - glasses_prob = driver_data.sunglassesProb - rl.draw_texture_v(self._glasses_texture, glasses_pos, rl.Color(70, 80, 161, int(255 * glasses_prob))) - class DriverCameraDialog(NavWidget, BaseDriverCameraDialog): def __init__(self): diff --git a/selfdrive/ui/mici/onroad/driver_state.py b/selfdrive/ui/mici/onroad/driver_state.py index 356d7ac832..b2be5a8e34 100644 --- a/selfdrive/ui/mici/onroad/driver_state.py +++ b/selfdrive/ui/mici/onroad/driver_state.py @@ -6,11 +6,13 @@ from openpilot.common.filter_simple import FirstOrderFilter from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.widgets import Widget from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.monitoring.helpers import face_orientation_from_net AlertSize = log.SelfdriveState.AlertSize DEBUG = False +# TODO: Only left for DM preview, remove LOOKING_CENTER_THRESHOLD_UPPER = math.radians(6) LOOKING_CENTER_THRESHOLD_LOWER = math.radians(3) @@ -59,9 +61,7 @@ class DriverStateRenderer(Widget): self._dm_person = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_person.png", cone_and_person_size, cone_and_person_size) self._dm_cone = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_cone.png", cone_and_person_size, cone_and_person_size) - center_size = round(36 / self.BASE_SIZE * self._rect.width) - self._dm_center = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_center.png", center_size, center_size) - self._dm_background = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_background.png", self._rect.width, self._rect.height) + self._dm_background = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_background.png", int(self._rect.width), int(self._rect.height)) def set_should_draw(self, should_draw: bool): self._should_draw = should_draw @@ -88,15 +88,14 @@ class DriverStateRenderer(Widget): if DEBUG: rl.draw_rectangle_lines_ex(self._rect, 1, rl.RED) - rl.draw_texture(self._dm_background, - int(self._rect.x), - int(self._rect.y), - rl.Color(255, 255, 255, int(255 * self._fade_filter.x))) + rl.draw_texture_ex(self._dm_background, + rl.Vector2(self._rect.x, self._rect.y), 0.0, 1.0, + rl.Color(255, 255, 255, int(255 * self._fade_filter.x))) - rl.draw_texture(self._dm_person, - int(self._rect.x + (self._rect.width - self._dm_person.width) / 2), - int(self._rect.y + (self._rect.height - self._dm_person.height) / 2), - rl.Color(255, 255, 255, int(255 * 0.9 * self._fade_filter.x))) + rl.draw_texture_ex(self._dm_person, + rl.Vector2(self._rect.x + (self._rect.width - self._dm_person.width) / 2, + self._rect.y + (self._rect.height - self._dm_person.height) / 2), 0.0, 1.0, + rl.Color(255, 255, 255, int(255 * 0.9 * self._fade_filter.x))) if self.effective_active: source_rect = rl.Rectangle(0, 0, self._dm_cone.width, self._dm_cone.height) @@ -114,16 +113,7 @@ class DriverStateRenderer(Widget): dest_rect, rl.Vector2(dest_rect.width / 2, dest_rect.height / 2), self._rotation_filter.x - 90, - rl.Color(255, 255, 255, int(255 * self._fade_filter.x * (1 - self._looking_center_filter.x))), - ) - - rl.draw_texture_ex( - self._dm_center, - (int(self._rect.x + (self._rect.width - self._dm_center.width) / 2), - int(self._rect.y + (self._rect.height - self._dm_center.height) / 2)), - 0, - 1.0, - rl.Color(255, 255, 255, int(255 * self._fade_filter.x * self._looking_center_filter.x)), + rl.Color(255, 255, 255, int(255 * self._fade_filter.x)), ) else: @@ -175,11 +165,22 @@ class DriverStateRenderer(Widget): # Get monitoring state driver_data = self.get_driver_data() driver_orient = driver_data.faceOrientation + driver_position = driver_data.facePosition if len(driver_orient) != 3: return - pitch, yaw, roll = driver_orient + # Calibrate orientation so looking straight ahead at road (instead of at device) is (0, 0, 0) + sm = ui_state.sm + if sm.valid['liveCalibration'] and len(sm['liveCalibration'].rpyCalib) == 3: + cal_rpy = sm['liveCalibration'].rpyCalib + else: + cal_rpy = [0.0, 0.0, 0.0] + + _, pitch, yaw = face_orientation_from_net(driver_orient, driver_position, cal_rpy) + pitch += math.radians(6) # calib or DM pose is not accurate, add a fake upward pitch to bias forward + yaw = -yaw # undo sign flip in face_orientation_from_net to match UI convention + pitch = self._pitch_filter.update(pitch) yaw = self._yaw_filter.update(yaw) @@ -193,7 +194,6 @@ class DriverStateRenderer(Widget): if DEBUG: pitchd = math.degrees(pitch) yawd = math.degrees(yaw) - rolld = math.degrees(roll) rl.draw_line_ex((0, 100), (200, 100), 3, rl.RED) rl.draw_line_ex((0, 120), (200, 120), 3, rl.RED) @@ -201,13 +201,11 @@ class DriverStateRenderer(Widget): pitch_x = 100 + pitchd yaw_x = 100 + yawd - roll_x = 100 + rolld rl.draw_circle(int(pitch_x), 100, 5, rl.GREEN) rl.draw_circle(int(yaw_x), 120, 5, rl.GREEN) - rl.draw_circle(int(roll_x), 140, 5, rl.GREEN) # filter head rotation, handling wrap-around - rotation = math.degrees(math.atan2(pitch, yaw)) + rotation = math.degrees(math.atan2(pitch * 2, yaw)) # reduce yaw sensitivity angle_diff = rotation - self._rotation_filter.x angle_diff = ((angle_diff + 180) % 360) - 180 self._rotation_filter.update(self._rotation_filter.x + angle_diff) diff --git a/selfdrive/ui/mici/onroad/hud_renderer.py b/selfdrive/ui/mici/onroad/hud_renderer.py index ccf32023ed..76724244d0 100644 --- a/selfdrive/ui/mici/onroad/hud_renderer.py +++ b/selfdrive/ui/mici/onroad/hud_renderer.py @@ -120,7 +120,7 @@ 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', 44, 44) + self._txt_exclamation_point: rl.Texture = gui_app.texture('icons_mici/exclamation_point.png', 9, 44) 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) @@ -153,7 +153,7 @@ class HudRenderer(Widget): v_cruise_cluster = car_state.vCruiseCluster set_speed = ( - controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster + controls_state.deprecated.vCruise if v_cruise_cluster == 0.0 else v_cruise_cluster ) engaged = sm['selfdriveState'].enabled if (set_speed != self.set_speed and engaged) or (engaged and not self._engaged): @@ -172,8 +172,7 @@ class HudRenderer(Widget): def _render(self, rect: rl.Rectangle) -> None: """Render HUD elements to the screen.""" - if ui_state.sm['controlsState'].lateralControlState.which() != 'angleState': - self._torque_bar.render(rect) + self._torque_bar.render(rect) if self.is_cruise_set: self._draw_set_speed(rect) @@ -222,7 +221,7 @@ class HudRenderer(Widget): EXCLAMATION_POINT_SPACING = 10 exclamation_pos_x = pos_x - self._txt_exclamation_point.width / 2 + wheel_txt.width / 2 + EXCLAMATION_POINT_SPACING exclamation_pos_y = pos_y - self._txt_exclamation_point.height / 2 - rl.draw_texture(self._txt_exclamation_point, int(exclamation_pos_x), int(exclamation_pos_y), rl.WHITE) + rl.draw_texture_ex(self._txt_exclamation_point, rl.Vector2(exclamation_pos_x, exclamation_pos_y), 0.0, 1.0, rl.WHITE) def _draw_set_speed(self, rect: rl.Rectangle) -> None: """Draw the MAX speed indicator box.""" diff --git a/selfdrive/ui/mici/onroad/torque_bar.py b/selfdrive/ui/mici/onroad/torque_bar.py index c1de694633..f0690c0abf 100644 --- a/selfdrive/ui/mici/onroad/torque_bar.py +++ b/selfdrive/ui/mici/onroad/torque_bar.py @@ -145,6 +145,9 @@ def arc_bar_pts(cx: float, cy: float, return pts +DEFAULT_MAX_LAT_ACCEL = 3.0 # m/s^2 + + class TorqueBar(Widget): def __init__(self, demo: bool = False, scale: float = 1.0, always: bool = False): super().__init__() @@ -167,16 +170,23 @@ class TorqueBar(Widget): controls_state = ui_state.sm['controlsState'] car_state = ui_state.sm['carState'] live_parameters = ui_state.sm['liveParameters'] - lateral_acceleration = controls_state.curvature * car_state.vEgo ** 2 - live_parameters.roll * ACCELERATION_DUE_TO_GRAVITY - # TODO: pull from carparams - max_lateral_acceleration = 3 + car_control = ui_state.sm['carControl'] - # from selfdrived + # Include lateral accel error in estimated torque utilization actual_lateral_accel = controls_state.curvature * car_state.vEgo ** 2 desired_lateral_accel = controls_state.desiredCurvature * car_state.vEgo ** 2 accel_diff = (desired_lateral_accel - actual_lateral_accel) - self._torque_filter.update(min(max(lateral_acceleration / max_lateral_acceleration + accel_diff, -1), 1)) + # 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]) + lateral_acceleration = actual_lateral_accel - roll_compensation + max_lateral_acceleration = ui_state.CP.maxLateralAccel if ui_state.CP else DEFAULT_MAX_LAT_ACCEL + + if not car_control.latActive: + self._torque_filter.update(0.0) + else: + self._torque_filter.update(np.clip((lateral_acceleration + accel_diff) / max_lateral_acceleration, -1, 1)) else: self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque) diff --git a/selfdrive/ui/mici/tests/test_widget_leaks.py b/selfdrive/ui/mici/tests/test_widget_leaks.py index be12839cd7..e35cb44776 100755 --- a/selfdrive/ui/mici/tests/test_widget_leaks.py +++ b/selfdrive/ui/mici/tests/test_widget_leaks.py @@ -10,7 +10,7 @@ from openpilot.system.ui.widgets import Widget 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.widgets.pairing_dialog import PairingDialog as MiciPairingDialog -from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialogV2, BigInputDialog +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigInputDialog from openpilot.selfdrive.ui.mici.layouts.settings.device import MiciFccModal # tici dialogs @@ -44,7 +44,7 @@ KNOWN_LEAKS = { "openpilot.system.ui.widgets.scroller_tici.Scroller", "openpilot.system.ui.widgets.label.UnifiedLabel", "openpilot.system.ui.widgets.mici_keyboard.MiciKeyboard", - "openpilot.selfdrive.ui.mici.widgets.dialog.BigConfirmationDialogV2", + "openpilot.selfdrive.ui.mici.widgets.dialog.BigConfirmationDialog", "openpilot.system.ui.widgets.keyboard.Keyboard", "openpilot.system.ui.widgets.slider.BigSlider", "openpilot.selfdrive.ui.mici.widgets.dialog.BigInputDialog", @@ -68,9 +68,11 @@ def test_dialogs_do_not_leak(): for ctor in ( # mici - MiciDriverCameraDialog, MiciTrainingGuide, MiciOnboardingWindow, MiciPairingDialog, + MiciDriverCameraDialog, MiciPairingDialog, + lambda: MiciTrainingGuide(lambda: None), + lambda: MiciOnboardingWindow(lambda: None), lambda: BigDialog("test", "test"), - lambda: BigConfirmationDialogV2("test", "icons_mici/settings/network/new/trash.png"), + lambda: BigConfirmationDialog("test", gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64), lambda: None), lambda: BigInputDialog("test"), lambda: MiciFccModal(text="test"), # tici diff --git a/selfdrive/ui/mici/widgets/button.py b/selfdrive/ui/mici/widgets/button.py index b5bd65e2de..058c351fb6 100644 --- a/selfdrive/ui/mici/widgets/button.py +++ b/selfdrive/ui/mici/widgets/button.py @@ -28,7 +28,7 @@ class ScrollState(Enum): class BigCircleButton(Widget): - def __init__(self, icon: str, red: bool = False, icon_size: tuple[int, int] = (64, 53), icon_offset: tuple[int, int] = (0, 0)): + def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0)): super().__init__() self._red = red self._icon_offset = icon_offset @@ -39,7 +39,7 @@ class BigCircleButton(Widget): self._click_delay = 0.075 # Icons - self._txt_icon = gui_app.texture(icon, *icon_size) + self._txt_icon = icon self._txt_btn_disabled_bg = gui_app.texture("icons_mici/buttons/button_circle_disabled.png", 180, 180) self._txt_btn_bg = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180) @@ -71,8 +71,8 @@ class BigCircleButton(Widget): class BigCircleToggle(BigCircleButton): - def __init__(self, icon: str, toggle_callback: Callable | None = None, icon_size: tuple[int, int] = (64, 53), icon_offset: tuple[int, int] = (0, 0)): - super().__init__(icon, False, icon_size=icon_size, icon_offset=icon_offset) + def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0)): + super().__init__(icon, False, icon_offset=icon_offset) self._toggle_callback = toggle_callback # State @@ -107,19 +107,18 @@ class BigButton(Widget): """A lightweight stand-in for the Qt BigButton, drawn & updated each frame.""" - def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", icon_size: tuple[int, int] = (64, 64), - scroll: bool = False): + def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False): super().__init__() self.set_rect(rl.Rectangle(0, 0, 402, 180)) self.text = text self.value = value - self._icon_size = icon_size + self._txt_icon = icon self._scroll = scroll - self.set_icon(icon) self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) self._click_delay = 0.075 self._shake_start: float | None = None + self._grow_animation_until: float | None = None self._rotate_icon_t: float | None = None @@ -132,8 +131,8 @@ class BigButton(Widget): self._load_images() - def set_icon(self, icon: Union[str, rl.Texture]): - self._txt_icon = gui_app.texture(icon, *self._icon_size) if isinstance(icon, str) and len(icon) else icon + def set_icon(self, icon: Union[rl.Texture, None]): + self._txt_icon = icon def set_rotate_icon(self, rotate: bool): if rotate and self._rotate_icon_t is not None: @@ -145,9 +144,12 @@ class BigButton(Widget): self._txt_pressed_bg = gui_app.texture("icons_mici/buttons/button_rectangle_pressed.png", 402, 180) self._txt_disabled_bg = gui_app.texture("icons_mici/buttons/button_rectangle_disabled.png", 402, 180) + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + super().set_touch_valid_callback(lambda: touch_callback() and self._grow_animation_until is None) + def _width_hint(self) -> int: # Single line if scrolling, so hide behind icon if exists - icon_size = self._icon_size[0] if self._txt_icon and self._scroll and self.value else 0 + icon_size = self._txt_icon.width if self._txt_icon and self._scroll and self.value else 0 return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - icon_size) def _get_label_font_size(self): @@ -182,12 +184,17 @@ class BigButton(Widget): def trigger_shake(self): self._shake_start = rl.get_time() + def trigger_grow_animation(self, duration: float = 0.65): + self._grow_animation_until = rl.get_time() + duration + @property def _shake_offset(self) -> float: SHAKE_DURATION = 0.5 SHAKE_AMPLITUDE = 24.0 SHAKE_FREQUENCY = 32.0 - t = rl.get_time() - (self._shake_start or 0.0) + if self._shake_start is None: + return 0.0 + t = rl.get_time() - self._shake_start if t > SHAKE_DURATION: return 0.0 decay = 1.0 - t / SHAKE_DURATION @@ -197,6 +204,10 @@ class BigButton(Widget): super().set_position(x + self._shake_offset, y) def _handle_background(self) -> tuple[rl.Texture, float, float, float]: + if self._grow_animation_until is not None: + if rl.get_time() >= self._grow_animation_until: + self._grow_animation_until = None + # draw _txt_default_bg txt_bg = self._txt_default_bg if not self.enabled: @@ -204,7 +215,7 @@ class BigButton(Widget): elif self.is_pressed: txt_bg = self._txt_pressed_bg - scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed else 1.0) + scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed or self._grow_animation_until is not None else 1.0) btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2 btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2 return txt_bg, btn_x, btn_y, scale @@ -324,6 +335,43 @@ class BigMultiToggle(BigToggle): y += 35 +class GreyBigButton(BigButton): + """Users should manage newlines with this class themselves""" + + LABEL_HORIZONTAL_PADDING = 30 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.set_touch_valid_callback(lambda: False) + + self._rect.width = 476 + + self._label.set_font_size(36) + self._label.set_font_weight(FontWeight.BOLD) + self._label.set_line_height(1.0) + + self._sub_label.set_font_size(36) + self._sub_label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9))) + self._sub_label.set_font_weight(FontWeight.DISPLAY_REGULAR) + self._sub_label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE if not self._label.text else + rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) + self._sub_label.set_line_height(0.95) + + @property + def LABEL_VERTICAL_PADDING(self): + return BigButton.LABEL_VERTICAL_PADDING if self._label.text else 18 + + def _width_hint(self) -> int: + return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2) + + def _get_label_font_size(self): + return 36 + + def _render(self, _): + rl.draw_rectangle_rounded(self._rect, 0.4, 10, rl.Color(255, 255, 255, int(255 * 0.15))) + self._draw_content(self._rect.y) + + class BigMultiParamToggle(BigMultiToggle): def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable | None = None, select_callback: Callable | None = None): @@ -359,9 +407,9 @@ class BigParamControl(BigToggle): # TODO: param control base class class BigCircleParamControl(BigCircleToggle): - def __init__(self, icon: str, param: str, toggle_callback: Callable | None = None, icon_size: tuple[int, int] = (64, 53), + def __init__(self, icon: rl.Texture, param: str, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0)): - super().__init__(icon, toggle_callback, icon_size=icon_size, icon_offset=icon_offset) + super().__init__(icon, toggle_callback, icon_offset=icon_offset) self._param = param self.params = Params() self.set_checked(self.params.get_bool(self._param, False)) diff --git a/selfdrive/ui/mici/widgets/dialog.py b/selfdrive/ui/mici/widgets/dialog.py index 619c1ca28f..ed1466449b 100644 --- a/selfdrive/ui/mici/widgets/dialog.py +++ b/selfdrive/ui/mici/widgets/dialog.py @@ -4,14 +4,13 @@ import pyray as rl from typing import Union from collections.abc import Callable from openpilot.system.ui.widgets.nav_widget import NavWidget -from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label +from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.mici_keyboard import MiciKeyboard from openpilot.system.ui.lib.text_measure import measure_text_cached -from openpilot.system.ui.lib.wrap_text import wrap_text 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 BigButton +from openpilot.selfdrive.ui.mici.widgets.button import BigCircleButton, BigButton, GreyBigButton DEBUG = False @@ -25,58 +24,31 @@ class BigDialogBase(NavWidget, abc.ABC): class BigDialog(BigDialogBase): - def __init__(self, - title: str, - description: str): + def __init__(self, title: str, description: str, icon: Union[rl.Texture, None] = None): super().__init__() - self._title = title - self._description = description + self._card = GreyBigButton(title, description, icon) def _render(self, _): - super()._render(_) - - # draw title - # TODO: we desperately need layouts - # TODO: coming up with these numbers manually is a pain and not scalable - # TODO: no clue what any of these numbers mean. VBox and HBox would remove all of this shite - max_width = self._rect.width - PADDING * 2 - - title_wrapped = '\n'.join(wrap_text(gui_app.font(FontWeight.BOLD), self._title, 50, int(max_width))) - title_size = measure_text_cached(gui_app.font(FontWeight.BOLD), title_wrapped, 50) - text_x_offset = 0 - title_rect = rl.Rectangle(int(self._rect.x + text_x_offset + PADDING), - int(self._rect.y + PADDING), - int(max_width), - int(title_size.y)) - gui_label(title_rect, title_wrapped, 50, font_weight=FontWeight.BOLD, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) - - # draw description - desc_wrapped = '\n'.join(wrap_text(gui_app.font(FontWeight.MEDIUM), self._description, 30, int(max_width))) - desc_size = measure_text_cached(gui_app.font(FontWeight.MEDIUM), desc_wrapped, 30) - desc_rect = rl.Rectangle(int(self._rect.x + text_x_offset + PADDING), - int(self._rect.y + self._rect.height / 3), - int(max_width), - int(desc_size.y)) - # TODO: text align doesn't seem to work properly with newlines - gui_label(desc_rect, desc_wrapped, 30, font_weight=FontWeight.MEDIUM, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + self._card.render(rl.Rectangle( + self._rect.x + self._rect.width / 2 - self._card.rect.width / 2, + self._rect.y + self._rect.height / 2 - self._card.rect.height / 2, + self._card.rect.width, + self._card.rect.height, + )) -class BigConfirmationDialogV2(BigDialogBase): - def __init__(self, title: str, icon: str, red: bool = False, - exit_on_confirm: bool = True, - confirm_callback: Callable | None = None): +class BigConfirmationDialog(BigDialogBase): + def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None], + exit_on_confirm: bool = True, red: bool = False): super().__init__() self._confirm_callback = confirm_callback self._exit_on_confirm = exit_on_confirm - icon_txt = gui_app.texture(icon, 64, 53) self._slider: BigSlider | RedBigSlider if red: - self._slider = RedBigSlider(title, icon_txt, confirm_callback=self._on_confirm) + self._slider = self._child(RedBigSlider(title, icon, confirm_callback=self._on_confirm)) else: - self._slider = BigSlider(title, icon_txt, confirm_callback=self._on_confirm) + self._slider = self._child(BigSlider(title, icon, confirm_callback=self._on_confirm)) self._slider.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget def _on_confirm(self): @@ -103,11 +75,12 @@ class BigInputDialog(BigDialogBase): hint: str, default_text: str = "", minimum_length: int = 1, - confirm_callback: Callable[[str], None] | None = None): + confirm_callback: Callable[[str], None] | None = None, + auto_return_to_letters: str = ""): super().__init__() self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)), font_weight=FontWeight.MEDIUM) - self._keyboard = MiciKeyboard() + self._keyboard = MiciKeyboard(auto_return_to_letters=auto_return_to_letters) self._keyboard.set_text(default_text) self._keyboard.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget self._minimum_length = minimum_length @@ -157,9 +130,9 @@ class BigInputDialog(BigDialogBase): bg_block_margin = 5 text_x = PADDING / 2 + self._enter_img.width + PADDING - text_field_rect = rl.Rectangle(text_x, int(self._rect.y + PADDING) - bg_block_margin, - int(self._rect.width - text_x * 2), - int(text_size.y)) + text_field_rect = rl.Rectangle(text_x, self._rect.y + PADDING - bg_block_margin, + self._rect.width - text_x * 2, + text_size.y) # draw text input # push text left with a gradient on left side if too long @@ -180,8 +153,8 @@ class BigInputDialog(BigDialogBase): # draw gradient on left side to indicate more text if text_size.x > text_field_rect.width: - rl.draw_rectangle_gradient_h(int(text_field_rect.x), int(text_field_rect.y), 80, int(text_field_rect.height), - rl.BLACK, rl.BLANK) + rl.draw_rectangle_gradient_ex(rl.Rectangle(text_field_rect.x, text_field_rect.y, 80, text_field_rect.height), + rl.BLACK, rl.BLANK, rl.BLANK, rl.BLACK) # draw cursor blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2 @@ -189,14 +162,14 @@ class BigInputDialog(BigDialogBase): cursor_x = min(text_x + text_size.x + 3, text_field_rect.x + text_field_rect.width) else: cursor_x = text_field_rect.x - 6 - rl.draw_rectangle_rounded(rl.Rectangle(int(cursor_x), int(text_field_rect.y), 4, int(text_size.y)), + rl.draw_rectangle_rounded(rl.Rectangle(cursor_x, text_field_rect.y, 4, text_size.y), 1, 4, rl.Color(255, 255, 255, int(255 * blink_alpha))) # draw backspace icon with nice fade self._backspace_img_alpha.update(255 * bool(text)) if self._backspace_img_alpha.x > 1: color = rl.Color(255, 255, 255, int(self._backspace_img_alpha.x)) - rl.draw_texture(self._backspace_img, int(self._rect.width - self._backspace_img.width - 27), int(self._rect.y + 14), color) + rl.draw_texture_ex(self._backspace_img, rl.Vector2(self._rect.width - self._backspace_img.width - 27, self._rect.y + 14), 0.0, 1.0, color) if not text and self._hint_label.text and not candidate_char: # draw description if no text entered yet and not drawing candidate char @@ -214,9 +187,9 @@ class BigInputDialog(BigDialogBase): # draw enter button self._enter_img_alpha.update(255 if len(text) >= self._minimum_length else 0) color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x)) - rl.draw_texture(self._enter_img, int(self._rect.x + PADDING / 2), int(self._rect.y), color) + rl.draw_texture_ex(self._enter_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y), 0.0, 1.0, color) color = rl.Color(255, 255, 255, 255 - int(self._enter_img_alpha.x)) - rl.draw_texture(self._enter_disabled_img, int(self._rect.x + PADDING / 2), int(self._rect.y), color) + rl.draw_texture_ex(self._enter_disabled_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y), 0.0, 1.0, color) # keyboard goes over everything self._keyboard.render(self._rect) @@ -253,3 +226,15 @@ class BigDialogButton(BigButton): 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)): + super().__init__(icon, red, icon_offset) + + def show_confirm_dialog(): + gui_app.push_widget(BigConfirmationDialog(title, icon, confirm_callback, + exit_on_confirm=exit_on_confirm, red=red)) + + self.set_click_callback(show_confirm_dialog) diff --git a/selfdrive/ui/mici/widgets/pairing_dialog.py b/selfdrive/ui/mici/widgets/pairing_dialog.py index 991cb05a8c..a18b26ec02 100644 --- a/selfdrive/ui/mici/widgets/pairing_dialog.py +++ b/selfdrive/ui/mici/widgets/pairing_dialog.py @@ -9,7 +9,7 @@ from openpilot.common.params import Params from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.system.ui.lib.application import FontWeight, gui_app -from openpilot.system.ui.widgets.label import MiciLabel +from openpilot.system.ui.widgets.label import UnifiedLabel class PairingDialog(NavWidget): @@ -24,8 +24,7 @@ class PairingDialog(NavWidget): self._last_qr_generation = float("-inf") self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 33, 60) - self._pair_label = MiciLabel("pair with comma connect", 48, font_weight=FontWeight.BOLD, - color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=40, wrap_text=True) + self._pair_label = UnifiedLabel("pair with comma connect", font_size=48, font_weight=FontWeight.BOLD, line_height=0.8) def _get_pairing_url(self) -> str: try: @@ -77,7 +76,7 @@ class PairingDialog(NavWidget): self._render_qr_code() label_x = self._rect.x + 8 + self._rect.height + 24 - self._pair_label.set_width(int(self._rect.width - label_x)) + self._pair_label.set_max_width(int(self._rect.width - label_x)) self._pair_label.set_position(label_x, self._rect.y + 16) self._pair_label.render() @@ -93,7 +92,7 @@ class PairingDialog(NavWidget): return scale = self._rect.height / self._qr_texture.height - pos = rl.Vector2(self._rect.x + 8, self._rect.y) + pos = rl.Vector2(round(self._rect.x + 8), round(self._rect.y)) rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE) def __del__(self): diff --git a/selfdrive/ui/onroad/alert_renderer.py b/selfdrive/ui/onroad/alert_renderer.py index 2c21b4006e..6e79d23253 100644 --- a/selfdrive/ui/onroad/alert_renderer.py +++ b/selfdrive/ui/onroad/alert_renderer.py @@ -118,7 +118,7 @@ class AlertRenderer(Widget): alert = self.get_alert(ui_state.sm) if gui_app.sunnypilot_ui(): - ui_state.onroad_brightness_handle_alerts(ui_state.started, alert) + ui_state.onroad_brightness_handle_alerts(ui_state, alert) if not alert: return diff --git a/selfdrive/ui/onroad/exp_button.py b/selfdrive/ui/onroad/exp_button.py index e5d8171413..9a92ebc3c3 100644 --- a/selfdrive/ui/onroad/exp_button.py +++ b/selfdrive/ui/onroad/exp_button.py @@ -50,7 +50,7 @@ class ExpButton(Widget): texture = self._txt_exp if self._held_or_actual_mode() else self._txt_wheel rl.draw_circle(center_x, center_y, self._rect.width / 2, self._black_bg) - rl.draw_texture(texture, center_x - texture.width // 2, center_y - texture.height // 2, self._white_color) + rl.draw_texture_ex(texture, rl.Vector2(center_x - texture.width / 2, center_y - texture.height / 2), 0.0, 1.0, self._white_color) def _held_or_actual_mode(self): now = time.monotonic() diff --git a/selfdrive/ui/onroad/hud_renderer.py b/selfdrive/ui/onroad/hud_renderer.py index 79f150deea..73df8b3961 100644 --- a/selfdrive/ui/onroad/hud_renderer.py +++ b/selfdrive/ui/onroad/hud_renderer.py @@ -86,7 +86,7 @@ class HudRenderer(Widget): v_cruise_cluster = car_state.vCruiseCluster self.set_speed = ( - controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster + controls_state.deprecated.vCruise if v_cruise_cluster == 0.0 else v_cruise_cluster ) self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA self.is_cruise_available = self.set_speed != -1 diff --git a/selfdrive/ui/soundd.py b/selfdrive/ui/soundd.py index cea22655b1..6b64289766 100644 --- a/selfdrive/ui/soundd.py +++ b/selfdrive/ui/soundd.py @@ -20,6 +20,7 @@ SAMPLE_RATE = 48000 SAMPLE_BUFFER = 4096 # (approx 100ms) MAX_VOLUME = 1.0 MIN_VOLUME = 0.1 +ALERT_RAMP_TIME = 4 # seconds to ramp to max volume for warningImmediate SELFDRIVE_STATE_TIMEOUT = 5 # 5 seconds FILTER_DT = 1. / (micd.SAMPLE_RATE / micd.FFT_SAMPLES) @@ -82,6 +83,9 @@ class Soundd(QuietMode): self.current_volume = MIN_VOLUME self.current_sound_frame = 0 + self.ramp_start_volume = MIN_VOLUME + self.ramp_start_time = 0. + self.selfdrive_timeout_alert = False self.spl_filter_weighted = FirstOrderFilter(0, 2.5, FILTER_DT, initialized=False) @@ -130,6 +134,9 @@ class Soundd(QuietMode): def update_alert(self, new_alert): current_alert_played_once = self.current_alert == AudibleAlert.none or self.current_sound_frame > len(self.loaded_sounds[self.current_alert]) if self.current_alert != new_alert and (new_alert != AudibleAlert.none or current_alert_played_once): + if new_alert == AudibleAlert.warningImmediate: + self.ramp_start_volume = self.current_volume + self.ramp_start_time = time.monotonic() self.current_alert = new_alert self.current_sound_frame = 0 @@ -170,12 +177,19 @@ class Soundd(QuietMode): self.load_param() - if sm.updated['soundPressure'] and self.current_alert == AudibleAlert.none: # only update volume filter when not playing alert + # Always update volume, even when alert is playing + if sm.updated['soundPressure']: self.spl_filter_weighted.update(sm["soundPressure"].soundPressureWeightedDb) self.current_volume = self.calculate_volume(float(self.spl_filter_weighted.x)) self.get_audible_alert(sm) + # Ramp up immediate warning sound over 4s + if self.current_alert == AudibleAlert.warningImmediate: + elapsed = time.monotonic() - self.ramp_start_time + ramp_vol = float(np.interp(elapsed, [0, ALERT_RAMP_TIME], [self.ramp_start_volume, MAX_VOLUME])) + self.current_volume = max(self.current_volume, ramp_vol) + rk.keep_time() assert stream.active diff --git a/selfdrive/ui/sunnypilot/layouts/onboarding.py b/selfdrive/ui/sunnypilot/layouts/onboarding.py index eed3cfd6b3..7e532678b0 100644 --- a/selfdrive/ui/sunnypilot/layouts/onboarding.py +++ b/selfdrive/ui/sunnypilot/layouts/onboarding.py @@ -20,7 +20,7 @@ class SunnylinkConsentPage(Widget): self._done_callback = done_callback self._step = 0 - self._title = Label(tr("sunnylink"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) + self._title = self._child(Label(tr("sunnylink"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)) self._content = [ { @@ -40,9 +40,10 @@ class SunnylinkConsentPage(Widget): } ] - self._primary_btn = Button("", button_style=ButtonStyle.PRIMARY, click_callback=lambda: self._handle_choice("enable")) - self._secondary_btn = Button("", button_style=ButtonStyle.NORMAL, click_callback=lambda: self._handle_choice("secondary")) - self._danger_btn = Button("", button_style=ButtonStyle.DANGER, click_callback=lambda: self._handle_choice("disable")) + self._primary_btn = self._child(Button("", button_style=ButtonStyle.PRIMARY, click_callback=lambda: self._handle_choice("enable"))) + self._secondary_btn = self._child(Button("", button_style=ButtonStyle.NORMAL, click_callback=lambda: self._handle_choice("secondary"))) + self._danger_btn = self._child(Button("", button_style=ButtonStyle.DANGER, click_callback=lambda: self._handle_choice("disable"))) + self._desc = self._child(Label("", font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)) def _handle_choice(self, choice): if choice == "enable": @@ -73,8 +74,8 @@ class SunnylinkConsentPage(Widget): desc_y = welcome_y + 120 desc_rect = rl.Rectangle(desc_x, desc_y, self._rect.width - desc_x, self._rect.height - desc_y - 250) - desc_label = Label(step_data["text"], font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) - desc_label.render(desc_rect) + self._desc.set_text(step_data["text"]) + self._desc.render(desc_rect) btn_y = self._rect.y + self._rect.height - 160 - 45 diff --git a/selfdrive/ui/sunnypilot/layouts/settings/display.py b/selfdrive/ui/sunnypilot/layouts/settings/display.py index d44118d8f4..8ba5663662 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/display.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/display.py @@ -6,14 +6,11 @@ See the LICENSE.md file in the root directory for more details. """ from enum import IntEnum -from openpilot.common.params import Params -from openpilot.system.ui.sunnypilot.widgets.option_control import OptionControlSP from openpilot.system.ui.widgets import Widget from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets.scroller_tici import Scroller -from openpilot.system.ui.sunnypilot.widgets.list_view import option_item_sp, ToggleActionSP - -ONROAD_BRIGHTNESS_TIMER_VALUES = {0: 15, 1: 30, **{i: (i - 1) * 60 for i in range(2, 12)}} +from openpilot.system.ui.sunnypilot.widgets.list_view import option_item_sp +from openpilot.sunnypilot.system.params_migration import ONROAD_BRIGHTNESS_TIMER_VALUES class OnroadBrightness(IntEnum): @@ -26,7 +23,6 @@ class DisplayLayout(Widget): def __init__(self): super().__init__() - self._params = Params() items = self._initialize_items() self._scroller = Scroller(items, line_separator=True, spacing=0) @@ -46,7 +42,7 @@ class DisplayLayout(Widget): title=lambda: tr("Onroad Brightness Delay"), description="", min_value=0, - max_value=11, + max_value=15, value_change_step=1, value_map=ONROAD_BRIGHTNESS_TIMER_VALUES, label_callback=lambda value: f"{value} s" if value < 60 else f"{int(value/60)} m", @@ -88,13 +84,7 @@ class DisplayLayout(Widget): def _update_state(self): super()._update_state() - for _item in self._scroller._items: - if isinstance(_item.action_item, ToggleActionSP) and _item.action_item.toggle.param_key is not None: - _item.action_item.set_state(self._params.get_bool(_item.action_item.toggle.param_key)) - elif isinstance(_item.action_item, OptionControlSP) and _item.action_item.param_key is not None: - _item.action_item.set_value(self._params.get(_item.action_item.param_key, return_default=True)) - - brightness_val = self._params.get("OnroadScreenOffBrightness", return_default=True) + brightness_val = self._onroad_brightness.action_item.current_value self._onroad_brightness_timer.action_item.set_enabled(brightness_val not in (OnroadBrightness.AUTO, OnroadBrightness.AUTO_DARK)) def _render(self, rect): diff --git a/selfdrive/ui/sunnypilot/layouts/settings/models.py b/selfdrive/ui/sunnypilot/layouts/settings/models.py index bf6af56941..adbd99f1f3 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -41,7 +41,7 @@ class ModelsLayout(Widget): self._initialize_items() - self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB") + self.clear_cache_item.action_item.set_value(f"{self.calculate_cache_size():.2f} MB") for ctrl, key in [(self.lane_turn_value_control, "LaneTurnValue"), (self.delay_control, "LagdToggleDelay")]: ctrl.action_item.set_value(int(float(ui_state.params.get(key, return_default=True)) * 100)) @@ -58,6 +58,8 @@ class ModelsLayout(Widget): self.supercombo_label = progress_item(tr("Driving Model")) self.vision_label = progress_item(tr("Vision Model")) self.policy_label = progress_item(tr("Policy Model")) + self.off_policy_label = progress_item(tr("Off-Policy Model")) + self.on_policy_label = progress_item(tr("On-Policy Model")) self.refresh_item = button_item(tr("Refresh Model List"), tr("REFRESH"), "", lambda: (ui_state.params.put("ModelManager_LastSyncTime", 0), @@ -91,7 +93,7 @@ class ModelsLayout(Widget): self.lagd_toggle = toggle_item_sp(tr("Live Learning Steer Delay"), "", param="LagdToggle") self.items = [self.current_model_item, self.cancel_download_item, self.supercombo_label, self.vision_label, - self.policy_label, self.refresh_item, self.clear_cache_item, self.lane_turn_desire_toggle, + self.policy_label, self.off_policy_label, self.on_policy_label, self.refresh_item, self.clear_cache_item, self.lane_turn_desire_toggle, self.lane_turn_value_control, self.lagd_toggle, self.delay_control] def _update_lagd_description(self, lagd_toggle: bool): @@ -110,7 +112,7 @@ class ModelsLayout(Widget): self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading) @staticmethod - def _calculate_cache_size(): + def calculate_cache_size(): cache_size = 0.0 if os.path.exists(CUSTOM_MODEL_PATH): cache_size = sum(os.path.getsize(os.path.join(CUSTOM_MODEL_PATH, file)) for file in os.listdir(CUSTOM_MODEL_PATH)) / (1024**2) @@ -120,7 +122,7 @@ class ModelsLayout(Widget): def _callback(response): if response == DialogResult.CONFIRM: ui_state.params.put_bool("ModelManager_ClearCache", True) - self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB") + self.clear_cache_item.action_item.set_value(f"{self.calculate_cache_size():.2f} MB") dialog = ConfirmDialog(tr("This will delete ALL downloaded models from the cache except the currently active model. Are you sure?"), tr("Clear Cache"), callback=_callback) @@ -129,7 +131,9 @@ class ModelsLayout(Widget): def _handle_bundle_download_progress(self): labels = {custom.ModelManagerSP.Model.Type.supercombo: self.supercombo_label, custom.ModelManagerSP.Model.Type.vision: self.vision_label, - custom.ModelManagerSP.Model.Type.policy: self.policy_label} + custom.ModelManagerSP.Model.Type.policy: self.policy_label, + custom.ModelManagerSP.Model.Type.offPolicy: self.off_policy_label, + custom.ModelManagerSP.Model.Type.onPolicy: self.on_policy_label} for label in labels.values(): label.set_visible(False) self.cancel_download_item.set_visible(False) @@ -151,7 +155,7 @@ class ModelsLayout(Widget): if (current_time := time.monotonic()) - self.last_cache_calc_time > 0.5: self.last_cache_calc_time = current_time - self.clear_cache_item.action_item.set_value(f"{self._calculate_cache_size():.2f} MB") + self.clear_cache_item.action_item.set_value(f"{self.calculate_cache_size():.2f} MB") if self.download_status == custom.ModelManagerSP.DownloadStatus.downloading: device._reset_interactive_timeout() diff --git a/selfdrive/ui/sunnypilot/layouts/settings/settings.py b/selfdrive/ui/sunnypilot/layouts/settings/settings.py index e379a77104..4917c9a157 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/settings.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/settings.py @@ -82,8 +82,7 @@ class NavButton(Widget): if self.panel_info.icon: icon_texture = gui_app.texture(self.panel_info.icon, ICON_SIZE, ICON_SIZE, keep_aspect_ratio=True) - rl.draw_texture(icon_texture, int(content_x), int(rect.y + (OP.NAV_BTN_HEIGHT - icon_texture.height) / 2), - rl.WHITE) + rl.draw_texture_ex(icon_texture, rl.Vector2(content_x, rect.y + (OP.NAV_BTN_HEIGHT - icon_texture.height) / 2), 0.0, 1.0, rl.WHITE) content_x += ICON_SIZE + 20 # Draw button text (right-aligned) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py index 5edd2e5b89..1d9b99d5fd 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/sunnylink.py @@ -41,7 +41,7 @@ class SunnylinkHeader(Widget): self._description = UnifiedLabel( text=tr("For secure backup, restore, and remote configuration"), font_size=40, - font_weight=FontWeight.LIGHT, + font_weight=FontWeight.NORMAL, text_color=rl.Color(0, 255, 0, 255), # Green alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, @@ -53,7 +53,7 @@ class SunnylinkHeader(Widget): text=tr("Sponsorship isn't required for basic backup/restore") + "\n" + tr("Click the Sponsor button for more details"), font_size=35, - font_weight=FontWeight.LIGHT, + font_weight=FontWeight.NORMAL, text_color=rl.Color(255, 165, 0, 255), # Orange alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, @@ -107,7 +107,7 @@ class SunnylinkDescriptionItem(Widget): self._description = UnifiedLabel( text="", font_size=40, - font_weight=FontWeight.LIGHT, + font_weight=FontWeight.NORMAL, text_color=rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, diff --git a/selfdrive/ui/sunnypilot/layouts/settings/trips.py b/selfdrive/ui/sunnypilot/layouts/settings/trips.py index da9eb42d29..066f52507f 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/trips.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/trips.py @@ -93,7 +93,7 @@ class TripsLayout(Widget): # Values number_font = gui_app.font(FontWeight.BOLD) - unit_font = gui_app.font(FontWeight.LIGHT) + unit_font = gui_app.font(FontWeight.NORMAL) number_base_size = 92 unit_base_size = 55 number_size = number_base_size * FONT_SCALE @@ -112,9 +112,9 @@ class TripsLayout(Widget): center_x = col_x + (col_width / 2) # Icon - icon_x = int(center_x - (icon.width / 2)) - icon_y = int(content_y + 60) - rl.draw_texture(icon, icon_x, icon_y, rl.WHITE) + icon_x = center_x - (icon.width / 2) + icon_y = content_y + 60 + rl.draw_texture_ex(icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE) # Value val_size = measure_text_cached(number_font, value, number_base_size) diff --git a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py index 351dacbbc2..20c9903a63 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/vehicle/brands/hyundai.py @@ -8,7 +8,7 @@ from openpilot.selfdrive.ui.sunnypilot.layouts.settings.vehicle.brands.base impo from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.sunnypilot.widgets.list_view import multiple_button_item_sp -from opendbc.car.hyundai.values import CAR, CANFD_UNSUPPORTED_LONGITUDINAL_CAR, UNSUPPORTED_LONGITUDINAL_CAR +from opendbc.car.hyundai.values import CAR, UNSUPPORTED_LONGITUDINAL_CAR class HyundaiSettings(BrandSettings): @@ -31,7 +31,7 @@ class HyundaiSettings(BrandSettings): bundle = ui_state.params.get("CarPlatformBundle") if bundle: platform = bundle.get("platform") - self.alpha_long_available = CAR[platform] not in (UNSUPPORTED_LONGITUDINAL_CAR | CANFD_UNSUPPORTED_LONGITUDINAL_CAR) + self.alpha_long_available = CAR[platform] not in set().union(*UNSUPPORTED_LONGITUDINAL_CAR.values()) elif ui_state.CP is not None: self.alpha_long_available = ui_state.CP.alphaLongitudinalAvailable diff --git a/selfdrive/ui/sunnypilot/mici/layouts/models.py b/selfdrive/ui/sunnypilot/mici/layouts/models.py index 5d8de4381a..331743416f 100644 --- a/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -5,13 +5,45 @@ 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 collections.abc import Callable +import pyray as rl from cereal import custom from openpilot.selfdrive.ui.mici.widgets.button import BigButton -from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.sunnypilot.layouts.settings.models import ModelsLayout +from openpilot.selfdrive.ui.ui_state import ui_state, device +from openpilot.system.ui.lib.application import FontWeight, gui_app from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.scroller import NavScroller +class CurrentModelInfo(Widget): + def __init__(self): + super().__init__() + + self.set_rect(rl.Rectangle(0, 0, 360, 180)) + + header_color = rl.Color(255, 255, 255, int(255 * 0.9)) + subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) + max_width = int(self._rect.width - 20) + self.current_model_header = UnifiedLabel(tr("active model"), 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY) + self.current_model_text = UnifiedLabel(tr("default model"), 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, scroll=True) + + self.info_header = UnifiedLabel("cache size", 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY) + self.info_text = UnifiedLabel("0 mb", 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN) + + def _render(self, _): + self.current_model_header.set_position(self._rect.x + 20, self._rect.y - 10) + self.current_model_header.render() + + self.current_model_text.set_position(self._rect.x + 20, self._rect.y + 68 - 25) + self.current_model_text.render() + + self.info_header.set_position(self._rect.x + 20, self._rect.y + 114 - 30) + self.info_header.render() + + self.info_text.set_position(self._rect.x + 20, self._rect.y + 161 - 25) + self.info_text.render() class ModelsLayoutMici(NavScroller): def __init__(self, back_callback: Callable): @@ -20,25 +52,35 @@ class ModelsLayoutMici(NavScroller): self.original_back_callback = back_callback self.focused_widget = None - self.current_model_btn = BigButton(tr("current model"), "", "") - self.current_model_btn.set_click_callback(self._show_folders) + self.current_model_info = CurrentModelInfo() + self._download_progress = "." + self._download_frame = 0 + self._was_downloading = False - self.cancel_download_btn = BigButton(tr("cancel download"), "", "") + self.select_model_btn = BigButton(tr("select model")) + self.select_model_btn.set_click_callback(self._show_folders) + + self.cancel_download_btn = BigButton(tr("cancel download")) self.cancel_download_btn.set_click_callback(lambda: ui_state.params.remove("ModelManager_DownloadIndex")) - self.main_items = [self.current_model_btn, self.cancel_download_btn] + self.main_items = [self.current_model_info, self.select_model_btn, self.cancel_download_btn] self._scroller.add_widgets(self.main_items) @property def model_manager(self): return ui_state.sm["modelManagerSP"] - def _get_grouped_bundles(self): + def _get_grouped_bundles(self, favorites = None): bundles = self.model_manager.availableBundles folders = {} for bundle in bundles: folder = next((override.value for override in bundle.overrides if override.key == "folder"), "") folders.setdefault(folder, []).append(bundle) + + if favorites: + for fav_bundle in [bundle for bundle in bundles if bundle.ref in favorites]: + folders.setdefault("favorites", []).append(fav_bundle) + return folders def _show_selection_view(self, items, back_callback: Callable): @@ -49,18 +91,25 @@ class ModelsLayoutMici(NavScroller): self.set_back_callback(back_callback) def _show_folders(self): - self.focused_widget = self.current_model_btn - folders = self._get_grouped_bundles() + self.focused_widget = self.select_model_btn + + favs = ui_state.params.get("ModelManager_Favs") + favorites = set(favs.split(';')) if favs else set() + + folders = self._get_grouped_bundles(favorites) folder_buttons = [] - default_btn = BigButton(tr("default model"), "", "") + default_btn = BigButton(tr("default model")) default_btn.set_click_callback(self._select_default) folder_buttons.append(default_btn) for folder in sorted(folders.keys(), key=lambda f: max((bundle.index for bundle in folders[f]), default=-1), reverse=True): - if folder.lower() in ["release models", "master models"]: - btn = BigButton(folder.lower(), "", "") + if folder.lower() in ["release models", "master models", "favorites"]: + btn = BigButton(folder.lower()) btn.set_click_callback(lambda f=folder: self._select_folder(f)) - folder_buttons.append(btn) + if folder.lower() == "favorites": + folder_buttons.insert(0, btn) + else: + folder_buttons.append(btn) self._show_selection_view(folder_buttons, self._reset_main_view) def _select_model(self, bundle): @@ -72,13 +121,16 @@ class ModelsLayoutMici(NavScroller): self._reset_main_view() def _select_folder(self, folder_name): - folders = self._get_grouped_bundles() + favs = ui_state.params.get("ModelManager_Favs") + favorites = set(favs.split(';')) if favs else set() + + folders = self._get_grouped_bundles(favorites) bundles = sorted(folders.get(folder_name, []), key=lambda b: b.index, reverse=True) btns = [] for bundle in bundles: txt = bundle.displayName.lower() - btn = BigButton(txt, "", "") + btn = BigButton(txt) btn.set_click_callback(lambda b=bundle: self._select_model(b)) btns.append(btn) self._show_selection_view(btns, self._show_folders) @@ -86,29 +138,62 @@ class ModelsLayoutMici(NavScroller): def _reset_main_view(self): self._scroller._items = self.main_items self.set_back_callback(self.original_back_callback) - if self.focused_widget and self.focused_widget in self.main_items: - x = self._scroller._pad - for item in self.main_items: - if not item.is_visible: - continue - if item == self.focused_widget: - break - x += item.rect.width + self._scroller._spacing - self._scroller.scroll_panel.set_offset(0) - self._scroller.scroll_to(x) - self.focused_widget = None - else: - self._scroller.scroll_panel.set_offset(0) + self._scroller.scroll_panel.set_offset(0) + self._scroller.scroll_to(0) + + def hide_event(self): + super().hide_event() + if self._was_downloading: + device.set_override_interactive_timeout(None) + self._was_downloading = False def _update_state(self): super()._update_state() + self.select_model_btn.set_enabled(ui_state.is_offroad()) + self.cancel_download_btn.set_visible(False) + self.current_model_info.current_model_header._shimmer = False + self.current_model_info.info_header._shimmer = False + manager = self.model_manager - if manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading: - self.current_model_btn.set_value("downloading...") + self._download_frame += 1 + should_update = self._download_frame % (gui_app.target_fps / 2) == 0 + if should_update: + self._download_progress = self._download_progress + "." if len(self._download_progress) < 3 else "" + + is_downloading = (manager.selectedBundle + and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading) + if self._was_downloading and not is_downloading: + device.set_override_interactive_timeout(None) + self._was_downloading = is_downloading + + self.current_model_info.current_model_header.set_text(tr("active model")) + self.current_model_info.current_model_text.set_text(manager.activeBundle.displayName.lower() if manager.activeBundle.index > 0 else tr("default model")) + self.current_model_info.info_header.set_text(tr("cache size")) + self.current_model_info.info_text.set_text(f"{ModelsLayout.calculate_cache_size():.2f} MB") + + if manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.failed: + self.current_model_info.info_header.set_text(tr("error") + self._download_progress) + self.current_model_info.info_text.set_text(tr("download failed")) + + elif manager.selectedBundle and manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading: self.cancel_download_btn.set_visible(True) - else: - self.current_model_btn.set_value(manager.activeBundle.internalName.lower() if manager.activeBundle else tr("default model")) - self.cancel_download_btn.set_visible(False) - self.current_model_btn.set_enabled(ui_state.is_offroad()) - self.current_model_btn.set_text(tr("current model")) + device.set_override_interactive_timeout(5) + progress = 0.0 + count = 0 + for model in manager.selectedBundle.models: + count += 1 + p = model.artifact.downloadProgress + if p.status == custom.ModelManagerSP.DownloadStatus.downloading: + progress += p.progress + elif p.status in (custom.ModelManagerSP.DownloadStatus.downloaded, + custom.ModelManagerSP.DownloadStatus.cached): + progress += 100.0 + + self.current_model_info.current_model_header.set_text(tr("downloading")) + self.current_model_info.current_model_header._shimmer = True + self.current_model_info.current_model_text.set_text(f"{manager.selectedBundle.internalName.lower()}") + self.current_model_info.info_header.set_text(tr("progress") + self._download_progress) + self.current_model_info.info_header._shimmer = True + self.current_model_info.info_text.set_text(f"{progress/count:.2f}%") + diff --git a/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py b/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py index 03729f0f2f..a98f5a2e2e 100644 --- a/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py +++ b/selfdrive/ui/sunnypilot/mici/layouts/onboarding.py @@ -4,90 +4,28 @@ 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 pyray as rl -from openpilot.system.ui.lib.application import FontWeight, gui_app -from openpilot.system.ui.widgets.label import UnifiedLabel -from openpilot.system.ui.widgets.slider import SmallSlider -from openpilot.system.ui.mici_setup import TermsHeader, TermsPage as SetupTermsPage -from openpilot.system.version import sunnylink_consent_version, sunnylink_consent_declined -from openpilot.selfdrive.ui.ui_state import ui_state +from collections.abc import Callable + +from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationCircleButton +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.mici_setup import GreyBigButton +from openpilot.system.ui.widgets.scroller import NavScroller -class SunnylinkConsentPage(SetupTermsPage): - def __init__(self, on_accept=None, on_decline=None, left_text: str = "disable", right_text: str = "enable"): - super().__init__(on_accept, on_decline, left_text, continue_text=right_text) +class SunnylinkConsentPage(NavScroller): + def __init__(self, on_accept: Callable | None = None, on_decline: Callable | None = None): + super().__init__() - self._title_header = TermsHeader("sunnylink", - gui_app.texture("../../sunnypilot/selfdrive/assets/logo.png", 66, 60)) + self._accept_button = BigConfirmationCircleButton("enable\nsunnylink", gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 64, 64), + on_accept, exit_on_confirm=False) - self._terms_label = UnifiedLabel("sunnylink enables secured remote access to your comma device from anywhere, " + - "including settings management, remote monitoring, real-time dashboard, etc.", - 36, FontWeight.ROMAN) + self._decline_button = BigConfirmationCircleButton("disable\nsunnylink", gui_app.texture("icons_mici/setup/cancel.png", 64, 64), + on_decline, red=True, exit_on_confirm=False) - @property - def _content_height(self): - return self._terms_label.rect.y + self._terms_label.rect.height - self._scroll_panel.get_offset() - - def _render_content(self, scroll_offset): - self._title_header.set_position(self._rect.x + 16, self._rect.y + 12 + scroll_offset) - self._title_header.render() - - self._terms_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING, - self._rect.width - 100, - self._terms_label.get_content_height(int(self._rect.width - 100)), - )) - - -class SunnylinkConsentDisableConfirmPage(SunnylinkConsentPage): - def __init__(self, on_accept=None, on_decline=None): - super().__init__(on_accept=on_decline, on_decline=on_accept, left_text="enable", right_text="disable") - - # we flip the continue & disable buttons to use slider for disable - self._continue_slider = True - self._continue_button = SmallSlider("disable", confirm_callback=on_decline) - self._scroll_panel.set_enabled(lambda: not self._continue_button.is_pressed) - - self._title_header = TermsHeader("disable sunnylink?", - gui_app.texture("icons_mici/setup/red_warning.png", 66, 60)) - - self._terms_label = UnifiedLabel("sunnylink is designed to be enabled as part of sunnypilot's core functionality. " + - "If sunnylink is disabled, features such as settings management, " + - "remote monitoring, real-time dashboards will be unavailable.", - 36, FontWeight.ROMAN) - - -class SunnylinkOnboarding: - def __init__(self): - self.consent_done: bool = ui_state.params.get("CompletedSunnylinkConsentVersion") in {sunnylink_consent_version, sunnylink_consent_declined} - self.disable_confirm = False - - self.consent_page = SunnylinkConsentPage(on_decline=self._on_decline, on_accept=self._on_accept) - self.confirm_page = SunnylinkConsentDisableConfirmPage(on_decline=self._on_confirm_decline, on_accept=self._on_accept) - - @property - def completed(self) -> bool: - return self.consent_done - - def _on_accept(self): - ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version) - ui_state.params.put_bool("SunnylinkEnabled", True) - self.consent_done = True - - def _on_decline(self): - self.disable_confirm = True - - def _on_confirm_decline(self): - ui_state.params.put_bool("SunnylinkEnabled", False) - ui_state.params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_declined) - self.consent_done = True - - def render(self, rect): - if self.consent_done: - return - - if self.disable_confirm: - self.confirm_page.render(rect) - else: - self.consent_page.render(rect) + self._scroller.add_widgets([ + GreyBigButton("sunnylink", "scroll to continue", + gui_app.texture("../../sunnypilot/selfdrive/assets/logo.png", 64, 64)), + GreyBigButton("", "sunnylink enables secured remote access to your comma device from anywhere."), + self._accept_button, + self._decline_button, + ]) diff --git a/selfdrive/ui/sunnypilot/mici/layouts/settings.py b/selfdrive/ui/sunnypilot/mici/layouts/settings.py index 43d0bd2cef..85a782ecb7 100644 --- a/selfdrive/ui/sunnypilot/mici/layouts/settings.py +++ b/selfdrive/ui/sunnypilot/mici/layouts/settings.py @@ -5,30 +5,87 @@ 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.mici.layouts.settings import settings as OP -from openpilot.selfdrive.ui.mici.widgets.button import BigButton +from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton +from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici from openpilot.selfdrive.ui.sunnypilot.mici.layouts.models import ModelsLayoutMici +from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.multilang import tr ICON_SIZE = 70 +BIG_ICON_SIZE = 110 class SettingsLayoutSP(OP.SettingsLayout): def __init__(self): OP.SettingsLayout.__init__(self) + device_panel = DeviceLayoutMici() + self._scroller._items[2].set_click_callback(lambda: gui_app.push_widget(device_panel)) + + self.icon_offroad_enable = gui_app.texture("../../sunnypilot/selfdrive/assets/icons_mici/always_offroad.png", BIG_ICON_SIZE, + BIG_ICON_SIZE) + self.icon_offroad_disable = gui_app.texture("../../sunnypilot/selfdrive/assets/icons_mici/disable_offroad.png", BIG_ICON_SIZE, + BIG_ICON_SIZE) + self.icon_offroad_slider = gui_app.texture("icons_mici/settings/device/lkas.png", BIG_ICON_SIZE, BIG_ICON_SIZE) + sunnylink_panel = SunnylinkLayoutMici(back_callback=gui_app.pop_widget) - sunnylink_btn = BigButton("sunnylink", "", "icons_mici/settings/developer/ssh.png") + sunnylink_btn = BigButton("sunnylink", "", gui_app.texture("icons_mici/settings/developer/ssh.png", ICON_SIZE, ICON_SIZE)) sunnylink_btn.set_click_callback(lambda: gui_app.push_widget(sunnylink_panel)) models_panel = ModelsLayoutMici(back_callback=gui_app.pop_widget) - models_btn = BigButton("models", "", "../../sunnypilot/selfdrive/assets/offroad/icon_models.png") + models_btn = BigButton("models", "", gui_app.texture("../../sunnypilot/selfdrive/assets/offroad/icon_models.png", ICON_SIZE, ICON_SIZE)) models_btn.set_click_callback(lambda: gui_app.push_widget(models_panel)) + # onroad: enable button sits at the front (left of toggles) + self._enable_offroad_btn_onroad = BigCircleButton(self.icon_offroad_enable, red=True) + self._enable_offroad_btn_onroad.set_click_callback(lambda: self._handle_always_offroad(True)) + self._enable_offroad_btn_onroad.set_visible(lambda: ui_state.started and not ui_state.always_offroad) + + # offroad: enable button sits at the end (right of developer) + self._enable_offroad_btn_offroad = BigCircleButton(self.icon_offroad_enable, red=True) + self._enable_offroad_btn_offroad.set_click_callback(lambda: self._handle_always_offroad(True)) + self._enable_offroad_btn_offroad.set_visible(lambda: not ui_state.started and not ui_state.always_offroad) + + self._disable_offroad_btn = BigCircleButton(self.icon_offroad_disable, red=False) + self._disable_offroad_btn.set_click_callback(lambda: self._handle_always_offroad(False)) + self._disable_offroad_btn.set_visible(lambda: ui_state.always_offroad) + items = self._scroller._items.copy() items.insert(1, sunnylink_btn) items.insert(2, models_btn) + + # front slots (only one ever visible at a time): exit-always-offroad, then enable-onroad + items.insert(0, self._enable_offroad_btn_onroad) + items.insert(0, self._disable_offroad_btn) + # end slot: enable-offroad (right of developer) + items.append(self._enable_offroad_btn_offroad) + self._scroller._items.clear() for item in items: self._scroller.add_widget(item) + + def _update_state(self): + super()._update_state() + + def _handle_always_offroad(self, enable: bool): + + def _set_offroad_status(status: bool): + if not ui_state.engaged: + ui_state.params.put_bool("OffroadMode", status) + ui_state.always_offroad = status + + if not enable: + dlg = BigConfirmationDialog(tr("slide to exit always offroad"), self.icon_offroad_slider, red=False, + confirm_callback=lambda: _set_offroad_status(False)) + else: + if ui_state.engaged: + gui_app.push_widget(BigDialog(tr("disengage to enable always offroad"), "", )) + return + + dlg = BigConfirmationDialog(tr("slide to force offroad"), self.icon_offroad_slider, red=True, + confirm_callback=lambda: _set_offroad_status(True)) + gui_app.push_widget(dlg) diff --git a/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py b/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py index c892db9960..85ebf55f46 100644 --- a/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py +++ b/selfdrive/ui/sunnypilot/mici/layouts/sunnylink.py @@ -4,20 +4,54 @@ 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 pyray as rl + from collections.abc import Callable from cereal import custom from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle -from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialogV2 +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onboarding import SunnylinkConsentPage from openpilot.selfdrive.ui.sunnypilot.mici.widgets.sunnylink_pairing_dialog import SunnylinkPairingDialog from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.sunnypilot.sunnylink.api import UNREGISTERED_SUNNYLINK_DONGLE_ID -from openpilot.system.ui.lib.application import gui_app, MousePos +from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight from openpilot.system.ui.lib.multilang import tr +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.system.version import sunnylink_consent_version, sunnylink_consent_declined +class SunnylinkInfo(Widget): + def __init__(self): + super().__init__() + + self.set_rect(rl.Rectangle(0, 0, 360, 180)) + + header_color = rl.Color(255, 255, 255, int(255 * 0.9)) + subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) + max_width = int(self._rect.width - 20) + self.device_id_header = UnifiedLabel(tr("device id"), 48, max_width=max_width, text_color=header_color, + font_weight=FontWeight.DISPLAY, shimmer=True) + self.device_id_text = UnifiedLabel(UNREGISTERED_SUNNYLINK_DONGLE_ID, 32, max_width=max_width, text_color=subheader_color, + font_weight=FontWeight.ROMAN, scroll=True) + + self.sponsor_header = UnifiedLabel(tr("sponsor tier"), 48, max_width=max_width, text_color=header_color, + font_weight=FontWeight.DISPLAY, shimmer=True) + self.sponsor_text = UnifiedLabel("N/A", 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN) + + def _render(self, _): + self.device_id_header.set_position(self._rect.x + 20, self._rect.y - 10) + self.device_id_header.render() + + self.device_id_text.set_position(self._rect.x + 20, self._rect.y + 68 - 25) + self.device_id_text.render() + + self.sponsor_header.set_position(self._rect.x + 20, self._rect.y + 114 - 30) + self.sponsor_header.render() + + self.sponsor_text.set_position(self._rect.x + 20, self._rect.y + 161 - 25) + self.sponsor_text.render() class SunnylinkLayoutMici(NavScroller): def __init__(self, back_callback: Callable): @@ -27,19 +61,22 @@ class SunnylinkLayoutMici(NavScroller): self._backup_in_progress = False self._sunnylink_enabled = ui_state.params.get("SunnylinkEnabled") + self._sunnylink_info = SunnylinkInfo() + self._sunnylink_toggle = BigToggle(text=tr("enable sunnylink"), initial_state=self._sunnylink_enabled, toggle_callback=self._sunnylink_toggle_callback) self._sunnylink_sponsor_button = SunnylinkPairBigButton(sponsor_pairing=False) self._sunnylink_pair_button = SunnylinkPairBigButton(sponsor_pairing=True) - self._backup_btn = BigButton(tr("backup settings"), "", "") + self._backup_btn = BigButton(tr("backup settings"), "") self._backup_btn.set_click_callback(lambda: self._handle_backup_restore_btn(restore=False)) - self._restore_btn = BigButton(tr("restore settings"), "", "") + self._restore_btn = BigButton(tr("restore settings"), "") self._restore_btn.set_click_callback(lambda: self._handle_backup_restore_btn(restore=True)) self._sunnylink_uploader_toggle = BigToggle(text=tr("sunnylink uploader"), initial_state=False, toggle_callback=self._sunnylink_uploader_callback) self._scroller.add_widgets([ + self._sunnylink_info, self._sunnylink_toggle, self._sunnylink_sponsor_button, self._sunnylink_pair_button, @@ -59,6 +96,10 @@ class SunnylinkLayoutMici(NavScroller): self._sunnylink_uploader_toggle.set_visible(self._sunnylink_enabled) self.handle_backup_restore_progress() + self._sunnylink_info.device_id_text.set_text(ui_state.params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID) + self._sunnylink_info.sponsor_text.set_text(ui_state.sunnylink_state.get_sponsor_tier().name.lower() or "N/A") + self._sunnylink_info.set_visible(self._sunnylink_enabled) + if ui_state.sunnylink_state.is_sponsor(): self._sunnylink_sponsor_button.set_text(tr("thanks")) self._sunnylink_sponsor_button.set_value(ui_state.sunnylink_state.get_sponsor_tier().name.lower()) @@ -75,6 +116,11 @@ class SunnylinkLayoutMici(NavScroller): def show_event(self): super().show_event() ui_state.update_params() + ui_state.sunnylink_state.set_settings_open(True) + + def hide_event(self): + super().hide_event() + ui_state.sunnylink_state.set_settings_open(False) @staticmethod def _sunnylink_toggle_callback(state: bool): @@ -105,8 +151,8 @@ class SunnylinkLayoutMici(NavScroller): def _handle_backup_restore_btn(self, restore: bool = False): lbl = tr("slide to restore") if restore else tr("slide to backup") - icon = "icons_mici/settings/device/update.png" - dlg = BigConfirmationDialogV2(lbl, icon, confirm_callback=self._restore_handler if restore else self._backup_handler) + icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64) + dlg = BigConfirmationDialog(lbl, icon, confirm_callback=self._restore_handler if restore else self._backup_handler) gui_app.push_widget(dlg) def _backup_handler(self): @@ -169,8 +215,8 @@ class SunnylinkLayoutMici(NavScroller): elif (restore_status == custom.BackupManagerSP.Status.completed or (restore_status == custom.BackupManagerSP.Status.idle and restore_progress == 100.0)): self._restore_in_progress = False - gui_app.push_widget(BigConfirmationDialogV2( - title="slide to restart", icon="icons_mici/settings/device/reboot.png", + gui_app.push_widget(BigConfirmationDialog( + title="slide to restart", icon=gui_app.texture("icons_mici/settings/device/reboot.png", 64, 64), confirm_callback=lambda: gui_app.request_close())) else: @@ -186,7 +232,7 @@ class SunnylinkLayoutMici(NavScroller): class SunnylinkPairBigButton(BigButton): def __init__(self, sponsor_pairing: bool = False): self.sponsor_pairing = sponsor_pairing - super().__init__("", "", "") + super().__init__("") def _update_state(self): super()._update_state() @@ -194,9 +240,14 @@ class SunnylinkPairBigButton(BigButton): def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) + network_type = ui_state.sm["deviceState"].networkType + dlg: BigDialog | SunnylinkPairingDialog | None = None - if UNREGISTERED_SUNNYLINK_DONGLE_ID == (ui_state.params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID): - dlg = BigDialog(tr("sunnylink Dongle ID not found. Please reboot & try again."), "") + + if network_type == 0: + dlg = BigDialog(tr("no internet"), tr("please connect to WiFi & try again")) + elif UNREGISTERED_SUNNYLINK_DONGLE_ID == (ui_state.params.get("SunnylinkDongleId") or UNREGISTERED_SUNNYLINK_DONGLE_ID): + dlg = BigDialog(tr("sunnylink dongle id not found"), tr("please reboot & try again")) elif self.sponsor_pairing: dlg = SunnylinkPairingDialog(sponsor_pairing=True) elif not self.sponsor_pairing: diff --git a/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py b/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py index eb3c2bddfe..c727e4fc1c 100644 --- a/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py +++ b/selfdrive/ui/sunnypilot/mici/widgets/sunnylink_pairing_dialog.py @@ -13,7 +13,7 @@ from openpilot.sunnypilot.sunnylink.api import SunnylinkApi, UNREGISTERED_SUNNYL from openpilot.system.ui.lib.application import FontWeight, gui_app from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets.nav_widget import NavWidget -from openpilot.system.ui.widgets.label import MiciLabel +from openpilot.system.ui.widgets.label import UnifiedLabel class SunnylinkPairingDialog(PairingDialog): @@ -23,8 +23,8 @@ class SunnylinkPairingDialog(PairingDialog): PairingDialog.__init__(self) self._sponsor_pairing = sponsor_pairing label_text = tr("pair with sunnylink") if sponsor_pairing else tr("become a sunnypilot sponsor") - self._pair_label = MiciLabel(label_text, 48, font_weight=FontWeight.BOLD, - color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=40, wrap_text=True) + self._pair_label = UnifiedLabel(label_text, font_size=48, font_weight=FontWeight.BOLD, + text_color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=0.8) def _get_pairing_url(self) -> str: qr_string = "https://github.com/sponsors/sunnyhaibin" diff --git a/selfdrive/ui/sunnypilot/onroad/augmented_road_view.py b/selfdrive/ui/sunnypilot/onroad/augmented_road_view.py index 64d7a3d946..c7dedee540 100644 --- a/selfdrive/ui/sunnypilot/onroad/augmented_road_view.py +++ b/selfdrive/ui/sunnypilot/onroad/augmented_road_view.py @@ -23,7 +23,7 @@ class AugmentedRoadViewSP: def update_fade_out_bottom_overlay(self, _content_rect): # Fade out bottom of overlays for looks (only when engaged) fade_alpha = self._fade_alpha_filter.update(ui_state.status != UIStatus.DISENGAGED) - if ui_state.torque_bar and ui_state.sm['controlsState'].lateralControlState.which() != 'angleState' and fade_alpha > 1e-2: + if ui_state.torque_bar and fade_alpha > 1e-2: # Scale the fade texture to the content rect rl.draw_texture_pro(self._fade_texture, rl.Rectangle(0, 0, self._fade_texture.width, self._fade_texture.height), diff --git a/selfdrive/ui/sunnypilot/onroad/blind_spot_indicators.py b/selfdrive/ui/sunnypilot/onroad/blind_spot_indicators.py index 4e1d748117..2efda17a2b 100644 --- a/selfdrive/ui/sunnypilot/onroad/blind_spot_indicators.py +++ b/selfdrive/ui/sunnypilot/onroad/blind_spot_indicators.py @@ -42,11 +42,11 @@ class BlindSpotIndicators: pos_y = int(rect.y + BLIND_SPOT_Y_OFFSET) alpha = int(255 * self._blind_spot_left_alpha_filter.x) color = rl.Color(255, 255, 255, alpha) - rl.draw_texture(self._txt_blind_spot_left, pos_x, pos_y, color) + rl.draw_texture_ex(self._txt_blind_spot_left, rl.Vector2(pos_x, pos_y), 0.0, 1.0, color) if self._blind_spot_right_alpha_filter.x > 0.01: pos_x = int(rect.x + rect.width - BLIND_SPOT_MARGIN_X - self._txt_blind_spot_right.width) pos_y = int(rect.y + BLIND_SPOT_Y_OFFSET) alpha = int(255 * self._blind_spot_right_alpha_filter.x) color = rl.Color(255, 255, 255, alpha) - rl.draw_texture(self._txt_blind_spot_right, pos_x, pos_y, color) + rl.draw_texture_ex(self._txt_blind_spot_right, rl.Vector2(pos_x, pos_y), 0.0, 1.0, color) diff --git a/selfdrive/ui/sunnypilot/onroad/circular_alerts.py b/selfdrive/ui/sunnypilot/onroad/circular_alerts.py index 9c9ab7ac84..f90fc81914 100644 --- a/selfdrive/ui/sunnypilot/onroad/circular_alerts.py +++ b/selfdrive/ui/sunnypilot/onroad/circular_alerts.py @@ -101,9 +101,9 @@ class CircularAlertsRenderer: # Draw Image if self._alert_img and self._e2e_alert_display_timer > 0: - img_x = int(center.x - self._alert_img.width / 2) - img_y = int(center.y - self._alert_img.height / 2) - rl.draw_texture(self._alert_img, img_x, img_y, rl.WHITE) + img_x = center.x - self._alert_img.width / 2 + img_y = center.y - self._alert_img.height / 2 + rl.draw_texture_ex(self._alert_img, rl.Vector2(img_x, img_y), 0.0, 1.0, rl.WHITE) # Draw Text txt_color = rl.Color(255, 255, 255, 255) if is_pulsing else rl.Color(0, 255, 0, 190) diff --git a/selfdrive/ui/sunnypilot/onroad/hud_renderer.py b/selfdrive/ui/sunnypilot/onroad/hud_renderer.py index 2a66b3664b..f8e4257733 100644 --- a/selfdrive/ui/sunnypilot/onroad/hud_renderer.py +++ b/selfdrive/ui/sunnypilot/onroad/hud_renderer.py @@ -131,7 +131,7 @@ class HudRendererSP(HudRenderer): def _render(self, rect: rl.Rectangle) -> None: super()._render(rect) - if ui_state.torque_bar and ui_state.sm['controlsState'].lateralControlState.which() != 'angleState': + if ui_state.torque_bar: torque_rect = rect if ui_state.developer_ui in (DeveloperUiState.BOTTOM, DeveloperUiState.BOTH): torque_rect = rl.Rectangle(rect.x, rect.y, rect.width, rect.height - get_bottom_dev_ui_offset()) diff --git a/selfdrive/ui/sunnypilot/onroad/speed_limit.py b/selfdrive/ui/sunnypilot/onroad/speed_limit.py index 1b03e9a204..98f5b29087 100644 --- a/selfdrive/ui/sunnypilot/onroad/speed_limit.py +++ b/selfdrive/ui/sunnypilot/onroad/speed_limit.py @@ -227,7 +227,7 @@ class SpeedLimitRenderer(Widget, SpeedLimitAlertRenderer): arrow_x = sign_rect.x + sign_rect.width + arrow_spacing arrow_y = sign_rect.y + (sign_rect.height - txt_icon.height) / 2 color = rl.Color(255, 255, 255, int(icon_alpha)) - rl.draw_texture(txt_icon, int(arrow_x), int(arrow_y), color) + rl.draw_texture_ex(txt_icon, rl.Vector2(arrow_x, arrow_y), 0.0, 1.0, color) def _render_vienna(self, rect, val, sub, color, has_limit, alpha=1.0): center = rl.Vector2(rect.x + rect.width / 2, rect.y + rect.height / 2) diff --git a/selfdrive/ui/sunnypilot/onroad/turn_signal.py b/selfdrive/ui/sunnypilot/onroad/turn_signal.py index e285009364..fc6f7eb915 100644 --- a/selfdrive/ui/sunnypilot/onroad/turn_signal.py +++ b/selfdrive/ui/sunnypilot/onroad/turn_signal.py @@ -55,10 +55,10 @@ class TurnSignalWidget(Widget): self._texture = self._blind_spot_texture if self._type == 'blind_spot' else self._signal_texture if self._texture: - pos_x = int(self._rect.x + (self._rect.width - self._texture.width) / 2) - pos_y = int(self._rect.y + (self._rect.height - self._texture.height) / 2) + pos_x = self._rect.x + (self._rect.width - self._texture.width) / 2 + pos_y = self._rect.y + (self._rect.height - self._texture.height) / 2 color = rl.Color(255, 255, 255, icon_alpha) - rl.draw_texture(self._texture, pos_x, pos_y, color) + rl.draw_texture_ex(self._texture, rl.Vector2(pos_x, pos_y), 0.0, 1.0, color) def activate(self, _type: str = 'signal'): if not self._active or self._type != _type: diff --git a/selfdrive/ui/sunnypilot/ui_state.py b/selfdrive/ui/sunnypilot/ui_state.py index 050df6d26d..2a48e9eeef 100644 --- a/selfdrive/ui/sunnypilot/ui_state.py +++ b/selfdrive/ui/sunnypilot/ui_state.py @@ -49,8 +49,11 @@ class UIStateSP: else: self.sunnylink_state.stop() - def onroad_brightness_handle_alerts(self, started: bool, alert): - has_alert = started and self.onroad_brightness != OnroadBrightness.AUTO and alert is not None + def onroad_brightness_handle_alerts(self, _ui_state, alert): + if _ui_state.sm.recv_frame["carState"] < _ui_state.started_frame: + return + + has_alert = _ui_state.started and self.onroad_brightness != OnroadBrightness.AUTO and alert is not None self.update_onroad_brightness(has_alert) if has_alert: @@ -143,6 +146,7 @@ class UIStateSP: self.true_v_ego_ui = self.params.get_bool("TrueVEgoUI") self.turn_signals = self.params.get_bool("ShowTurnSignals") self.boot_offroad_mode = self.params.get("DeviceBootMode", return_default=True) + self.always_offroad = self.params.get_bool("OffroadMode") class DeviceSP: diff --git a/selfdrive/ui/tests/.gitignore b/selfdrive/ui/tests/.gitignore deleted file mode 100644 index 74ab2675db..0000000000 --- a/selfdrive/ui/tests/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -test -test_translations diff --git a/selfdrive/ui/tests/diff/.gitignore b/selfdrive/ui/tests/diff/.gitignore deleted file mode 100644 index e21a8d896e..0000000000 --- a/selfdrive/ui/tests/diff/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -report -.coverage diff --git a/selfdrive/ui/tests/diff/replay.py b/selfdrive/ui/tests/diff/replay.py index 08c45b7b7f..fd82e325a3 100755 --- a/selfdrive/ui/tests/diff/replay.py +++ b/selfdrive/ui/tests/diff/replay.py @@ -4,12 +4,16 @@ import argparse import coverage import pyray as rl +from tqdm import tqdm from typing import Literal from collections.abc import Callable from cereal.messaging import PubMaster +from openpilot.common.api import Api +from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.prefix import OpenpilotPrefix from openpilot.selfdrive.ui.tests.diff.diff import DIFF_OUT_DIR +from openpilot.system.updated.updated import parse_release_notes from openpilot.system.version import terms_version, training_version, terms_version_sp, sunnylink_consent_version LayoutVariant = Literal["mici", "tizi"] @@ -25,6 +29,7 @@ def setup_state(): params.put("DongleId", "test123456789") # Combined description for layouts that still use it (BIG home, settings/software) params.put("UpdaterCurrentDescription", "0.10.1 / test-branch / abc1234 / Nov 30") + params.put("UpdaterCurrentReleaseNotes", parse_release_notes(BASEDIR)) params.put("HasAcceptedTermsSP", terms_version_sp) params.put("CompletedSunnylinkConsentVersion", sunnylink_consent_version) @@ -34,6 +39,9 @@ def setup_state(): params.put("GitCommit", "abc12340ff9131237ba23a1d0fbd8edf9c80e87") params.put("GitCommitDate", "'1732924800 2024-11-30 00:00:00 +0000'") + # Patch Api.get_token to return a static token so the pairing QR code is deterministic across runs + Api.get_token = lambda self, payload_extra=None, expiry_hours=0: "test_token" + def run_replay(variant: LayoutVariant) -> None: if HEADLESS: @@ -43,7 +51,7 @@ def run_replay(variant: LayoutVariant) -> None: setup_state() os.makedirs(DIFF_OUT_DIR, exist_ok=True) - from openpilot.selfdrive.ui.ui_state import ui_state # Import within OpenpilotPrefix context so param values are setup correctly + from openpilot.selfdrive.ui.ui_state import ui_state, device # Import within OpenpilotPrefix context so param values are setup correctly from openpilot.system.ui.lib.application import gui_app # Import here for accurate coverage from openpilot.selfdrive.ui.tests.diff.replay_script import build_script @@ -56,6 +64,10 @@ def run_replay(variant: LayoutVariant) -> None: from openpilot.selfdrive.ui.layouts.main import MainLayout main_layout = MainLayout() + # Disable interactive timeout — replay clicks use left_down=False so they never reset the timer, + # and after 30s of real wall-clock time the settings panel would close automatically. + device.set_override_interactive_timeout(99999) + pm = PubMaster(["deviceState", "pandaStates", "driverStateV2", "selfdriveState"]) script = build_script(pm, main_layout, variant) script_index = 0 @@ -67,33 +79,35 @@ def run_replay(variant: LayoutVariant) -> None: rl.get_time = lambda: frame / FPS # Main loop to replay events and render frames - for _ in gui_app.render(): - # Handle all events for the current frame - while script_index < len(script) and script[script_index][0] == frame: - _, event = script[script_index] - # Call setup function, if any - if event.setup: - event.setup() - # Send mouse events to the application - if event.mouse_events: - with gui_app._mouse._lock: - gui_app._mouse._events.extend(event.mouse_events) - # Update persistent send function - if event.send_fn is not None: - send_fn = event.send_fn - # Move to next script event - script_index += 1 + with tqdm(total=script[-1][0] + 1, desc="Replaying", unit="frame", disable=bool(os.getenv("CI"))) as pbar: + for _ in gui_app.render(): + # Handle all events for the current frame + while script_index < len(script) and script[script_index][0] == frame: + _, event = script[script_index] + # Call setup function, if any + if event.setup: + event.setup() + # Send mouse events to the application + if event.mouse_events: + with gui_app._mouse._lock: + gui_app._mouse._events.extend(event.mouse_events) + # Update persistent send function + if event.send_fn is not None: + send_fn = event.send_fn + # Move to next script event + script_index += 1 - # Keep sending cereal messages for persistent states (onroad, alerts) - if send_fn: - send_fn() + # Keep sending cereal messages for persistent states (onroad, alerts) + if send_fn: + send_fn() - ui_state.update() + ui_state.update() - frame += 1 + frame += 1 + pbar.update(1) - if script_index >= len(script): - break + if script_index >= len(script): + break gui_app.close() diff --git a/selfdrive/ui/tests/diff/replay_script.py b/selfdrive/ui/tests/diff/replay_script.py index 9f2104ec49..c53d2f116b 100644 --- a/selfdrive/ui/tests/diff/replay_script.py +++ b/selfdrive/ui/tests/diff/replay_script.py @@ -3,15 +3,27 @@ from typing import TYPE_CHECKING from collections.abc import Callable from dataclasses import dataclass +import math + from cereal import car, log, messaging from cereal.messaging import PubMaster from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert +from openpilot.selfdrive.ui.lib.prime_state import PrimeType from openpilot.selfdrive.ui.tests.diff.replay import FPS, LayoutVariant from openpilot.system.updated.updated import parse_release_notes -WAIT = int(FPS * 0.5) # Default frames to wait after events +# Default frames to wait after events +WAIT_LONG = FPS +WAIT_SHORT = FPS // 2 +FAST_CLICK = FPS // 6 + +# Direction vectors for drag gestures +DIR_LEFT = (-1, 0) +DIR_RIGHT = (1, 0) +DIR_UP = (0, -1) +DIR_DOWN = (0, 1) AlertSize = log.SelfdriveState.AlertSize AlertStatus = log.SelfdriveState.AlertStatus @@ -56,49 +68,92 @@ class Script: """Add a delay for the given number of frames followed by an empty event.""" self.add(ScriptEvent(), before=frames) - def setup(self, fn: Callable, wait_after: int = WAIT) -> None: + def setup(self, fn: Callable, wait_after: int = WAIT_SHORT) -> None: """Add a setup function to be called immediately followed by a delay of the given number of frames.""" self.add(ScriptEvent(setup=fn), after=wait_after) - def set_send(self, fn: Callable, wait_after: int = WAIT) -> None: + def set_send(self, fn: Callable, wait_after: int = WAIT_SHORT) -> None: """Set a new persistent send function to be called every frame.""" self.add(ScriptEvent(send_fn=fn), after=wait_after) - # TODO: Also add more complex gestures, like swipe or drag - def click(self, x: int, y: int, wait_after: int = WAIT, wait_between: int = 2) -> None: + def click(self, x: int, y: int, wait_after: int = WAIT_SHORT, wait_between: int = 2) -> None: """Add a click event to the script for the given position and specify frames to wait between mouse events or after the click.""" # NOTE: By default we wait a couple frames between mouse events so pressed states will be rendered from openpilot.system.ui.lib.application import MouseEvent, MousePos - # TODO: Add support for long press (left_down=True) mouse_down = MouseEvent(pos=MousePos(x, y), slot=0, left_pressed=True, left_released=False, left_down=False, t=self.get_frame_time()) self.add(ScriptEvent(mouse_events=[mouse_down]), after=wait_between) mouse_up = MouseEvent(pos=MousePos(x, y), slot=0, left_pressed=False, left_released=True, left_down=False, t=self.get_frame_time()) self.add(ScriptEvent(mouse_events=[mouse_up]), after=wait_after) + def drag(self, start_x: int, start_y: int, direction: tuple[int, int], distance: int, duration_frames: int, wait_after: int = WAIT_LONG) -> None: + """Add a drag gesture to the script from start position in the specified direction by the given distance over the given number of frames.""" + from openpilot.system.ui.lib.application import MouseEvent, MousePos + + # Calculate delta and end position based on direction and distance + delta_x, delta_y = direction[0] * distance, direction[1] * distance + end_x, end_y = start_x + delta_x, start_y + delta_y + + # Mouse down at start + mouse_down = MouseEvent(pos=MousePos(start_x, start_y), slot=0, left_pressed=True, left_released=False, left_down=True, t=self.get_frame_time()) + self.add(ScriptEvent(mouse_events=[mouse_down]), after=1) + + # Interpolate positions over duration_frames + for i in range(1, duration_frames): + t = i / duration_frames + x, y = int(start_x + delta_x * t), int(start_y + delta_y * t) + mouse_move = MouseEvent(pos=MousePos(x, y), slot=0, left_pressed=False, left_released=False, left_down=True, t=self.get_frame_time()) + self.add(ScriptEvent(mouse_events=[mouse_move]), after=1) + + # Mouse up at end + mouse_up = MouseEvent(pos=MousePos(end_x, end_y), slot=0, left_pressed=False, left_released=True, left_down=False, t=self.get_frame_time()) + self.add(ScriptEvent(mouse_events=[mouse_up]), after=wait_after) + # --- Setup functions --- -def put_update_params(params: Params | None = None) -> None: - if params is None: - params = Params() - params.put("UpdaterCurrentReleaseNotes", parse_release_notes(BASEDIR)) - params.put("UpdaterNewReleaseNotes", parse_release_notes(BASEDIR)) - params.put("UpdaterTargetBranch", BRANCH_NAME) + +def set_prime_state(prime_type: PrimeType) -> None: + from openpilot.selfdrive.ui.ui_state import ui_state + ui_state.prime_state.set_type(prime_type) def setup_offroad_alerts() -> None: - put_update_params(Params()) set_offroad_alert("Offroad_TemperatureTooHigh", True, extra_text='99C') set_offroad_alert("Offroad_ExcessiveActuation", True, extra_text='longitudinal') set_offroad_alert("Offroad_IsTakingSnapshot", True) -def setup_update_available() -> None: +def setup_update_available(available: bool = True) -> None: params = Params() - params.put_bool("UpdateAvailable", True) - params.put("UpdaterNewDescription", f"0.10.2 / {BRANCH_NAME} / 0a1b2c3 / Jan 01") - put_update_params(params) + params.put_bool("UpdateAvailable", available) + params.put("UpdaterAvailableBranches", ",".join(["test-branch", "test-branch-2", BRANCH_NAME])) + if available: + params.put("UpdaterNewDescription", f"0.10.2 / {BRANCH_NAME} / 0a1b2c3 / Jan 01") + params.put("UpdaterNewReleaseNotes", parse_release_notes(BASEDIR)) + params.put("UpdaterTargetBranch", BRANCH_NAME) + else: + params.remove("UpdaterNewDescription") + params.remove("UpdaterNewReleaseNotes") + params.remove("UpdaterTargetBranch") + + +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)] + params.put("CalibrationParams", calib.to_bytes()) + # live delay + delay = messaging.new_message('liveDelay') + delay.liveDelay.calPerc = 75 + params.put("LiveDelay", delay.to_bytes()) + # live torque parameters + torque = messaging.new_message('liveTorqueParameters') + torque.liveTorqueParameters.useParams = True + torque.liveTorqueParameters.calPerc = 60 + params.put("LiveTorqueParameters", torque.to_bytes()) def setup_developer_params() -> None: @@ -132,7 +187,6 @@ def make_network_state_setup(pm: PubMaster, network_type) -> Callable: def make_alert_setup(pm: PubMaster, size, text1, text2, status) -> Callable: def _send() -> None: - send_onroad(pm) alert = messaging.new_message('selfdriveState') ss = alert.selfdriveState ss.alertSize = size @@ -143,18 +197,181 @@ def make_alert_setup(pm: PubMaster, size, text1, text2, status) -> Callable: return _send +def test_onroad_alerts(script: Script, pm: PubMaster) -> None: + """Go through various alert types and sizes and add them to the script to test alert rendering. + Each alert is sent as a separate event with a delay in between.""" + # Small alert (normal) + script.set_send(make_alert_setup(pm, AlertSize.small, "Small Alert", "This is a small alert", AlertStatus.normal)) + # Medium alert (userPrompt) + script.set_send(make_alert_setup(pm, AlertSize.mid, "Medium Alert", "This is a medium alert", AlertStatus.userPrompt)) + # Full alert (critical) + script.set_send(make_alert_setup(pm, AlertSize.full, "DISENGAGE IMMEDIATELY", "Driver Distracted", AlertStatus.critical)) + # Full alert multiline + script.set_send(make_alert_setup(pm, AlertSize.full, "Reverse\nGear", "", AlertStatus.normal)) + # Full alert long text + script.set_send(make_alert_setup(pm, AlertSize.full, "TAKE CONTROL IMMEDIATELY", "Calibration Invalid: Remount Device & Recalibrate", AlertStatus.userPrompt)) + + # --- Script builders --- def build_mici_script(pm: PubMaster, main_layout, script: Script) -> None: """Build the replay script for the mici layout.""" from openpilot.system.ui.lib.application import gui_app - center = (gui_app.width // 2, gui_app.height // 2) + width, height = gui_app.width, gui_app.height + center = (width // 2, height // 2) + right = (width * 4 // 5, height // 2) + left = (width // 5, height // 2) + top = (width // 2, height // 10) + bottom = (width // 2, height * 9 // 10) + + DURATION = 5 + SWIPE_WAIT = FPS * 3 // 4 + + def click(times: int = 1, wait_after: int = WAIT_SHORT) -> None: + """Click at the center of the screen the given number of times with optional delay after.""" + for _ in range(times): + script.click(*center, wait_after=wait_after) + + def press(x: int, y: int, duration_frames: int = DURATION, wait_after: int = WAIT_SHORT) -> None: + """Perform a drag with no movement to simulate a left_down mouse event at the given position for the specified duration and delay after.""" + script.drag(x, y, (0, 0), 0, duration_frames, wait_after=wait_after) + + def swipe_left(distance: int = right[0] - left[0], duration_frames: int = DURATION, wait_after: int = SWIPE_WAIT) -> None: + """Drag from right edge to left (scroll right / slide confirmation).""" + script.drag(*right, DIR_LEFT, distance, duration_frames, wait_after) + + def swipe_right(distance: int = right[0] - left[0], duration_frames: int = DURATION, wait_after: int = SWIPE_WAIT) -> None: + """Drag from left edge to right (scroll left).""" + script.drag(*left, DIR_RIGHT, distance, duration_frames, wait_after) + + def swipe_down(distance: int = bottom[1] - top[1], duration_frames: int = DURATION, wait_after: int = SWIPE_WAIT) -> None: + """Drag from top edge to bottom (scroll up / go back).""" + script.drag(*top, DIR_DOWN, distance, duration_frames, wait_after) + + def swipe_up(distance: int = bottom[1] - top[1], duration_frames: int = DURATION, wait_after: int = SWIPE_WAIT) -> None: + """Drag from bottom edge to top (scroll down).""" + script.drag(*bottom, DIR_UP, distance, duration_frames, wait_after) + + ActionFn = Callable[[], None] | None + Cases = list[ActionFn] + + def run_actions(*actions: ActionFn, after_each: ActionFn = None) -> None: + """Helper function to run a sequence of actions in order for interaction tests, calling after_each callback after each action if provided.""" + for action in actions: + if action is not None: + action() + if after_each is not None: + after_each() + + def explore_setting(*actions: ActionFn) -> None: + """Helper function to open a settings item, run the given actions, and go back.""" + run_actions(click, *actions, swipe_down) # open, interact, go back + + def scroll_through_cases(cases: Cases) -> None: + """Helper function to explore a panel by calling the interaction callbacks for each item/page before swiping to the next one.""" + run_actions(*cases, after_each=lambda: swipe_left(210, 10)) # swipe to roughly the center of the next toggle after each case + + def interact_keyboard() -> None: + """Interact with the keyboard in various ways to test different actions and states. + Assumes it's a password keyboard with 8 characters required. Closes by pressing confirm at the end.""" + KEY = (250, 160) # key in the middle of the keyboard ('G') + SHIFT = (50, 210) + NUMBERS = (480, 210) + SPACE = (500, 160) + BACKSPACE = (490, 30) + CONFIRM = (50, 30) + # Begin interactions + press(*CONFIRM, wait_after=FAST_CLICK) # confirm while disabled should do nothing + swipe_left(duration_frames=FPS // 2) # swipe to type + swipe_up(duration_frames=FPS // 2) # swipe out of keyboard (nothing typed) + # press various keys to test different states: + for key in [ + SHIFT, KEY, KEY, SHIFT, SHIFT, KEY, KEY, # test casing (upper, lower, caps lock) + SPACE, SPACE, BACKSPACE, BACKSPACE, # test multiple space and backspace + NUMBERS, KEY, center, SHIFT, KEY # test numbers and symbols + ]: + press(*key, wait_after=FAST_CLICK) + # press confirm to close + script.wait(WAIT_SHORT) # wait for confirm to enable + press(*CONFIRM) + + toggle_cases: Cases = [ + lambda: click(times=3, wait_after=FAST_CLICK), # first toggle is personality, which has 3 states + None, None, None, None, None, None, # skip other toggles to save time + lambda: click(times=2, wait_after=FAST_CLICK), # test final toggle (enable openpilot) + ] + + network_cases: Cases = [ + explore_setting, # select wifi (just open and close) + None, None, + lambda: run_actions(click, interact_keyboard), # tether password keyboard + ] + + device_cases: Cases = [ + None, + click, # update + explore_setting, # pairing (just open and close) + lambda: explore_setting( + # training guide + 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 + 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) + lambda: explore_setting(lambda: swipe_left(width)), # uninstall + lambda: run_actions( + lambda: explore_setting(lambda: swipe_left(width)), # reboot + lambda: script.click(430, 120), lambda: swipe_left(width), swipe_down, # shutdown + ), + ] + + developer_cases: Cases = [ + lambda: click(times=2, wait_after=FAST_CLICK), # toggle ssh mode + explore_setting, # SSH keys keyboard (just open and close) + None, # joystick mode + lambda: click(wait_after=FAST_CLICK), # longitudinal maneuver mode (disabled; should do nothing) + lambda: click(times=2, wait_after=FAST_CLICK), # toggle UI debug mode + ] + + settings_cases: Cases = [ + lambda: scroll_through_cases(toggle_cases), + lambda: scroll_through_cases(network_cases), + lambda: scroll_through_cases(device_cases), + lambda: script.wait(WAIT_SHORT), # pairing + lambda: run_actions(lambda: swipe_up(height * 3), lambda: swipe_down(height * 3)), # firehose (scroll down and back up) + lambda: scroll_through_cases(developer_cases), + ] + + # === Homescreen === # + script.wait(WAIT_SHORT) + swipe_left(width, wait_after=WAIT_SHORT) # onroad screen + swipe_right(width, wait_after=WAIT_SHORT) # back to home + + # === Offroad Alerts === + def setup_offroad_alerts_and_refresh() -> None: + """Setup function to trigger offroad alerts and force a refresh on the alerts layout.""" + setup_offroad_alerts() + main_layout._alerts_layout.refresh() + + swipe_right(width, wait_after=WAIT_SHORT) # open alerts + script.setup(setup_offroad_alerts_and_refresh) # show alerts + swipe_up(height) # scroll alerts + swipe_left(width, wait_after=WAIT_SHORT) # close alerts + + # === Settings === # + click() # open settings + scroll_through_cases([lambda case=case: explore_setting(case) for case in settings_cases]) # explore settings + swipe_down() # back to home + + # === Onroad === + script.set_send(lambda: send_onroad(pm)) + swipe_left(width, wait_after=WAIT_SHORT) # onroad screen + test_onroad_alerts(script, pm) + swipe_right(width) # back to home - # TODO: Explore more - script.wait(FPS) - script.click(*center, FPS) # Open settings - script.click(*center, FPS) # Open toggles script.end() @@ -171,34 +388,104 @@ def build_tizi_script(pm: PubMaster, main_layout, script: Script) -> None: return setup + def add_prime_state_setup(prime_type: PrimeType) -> None: + script.set_send(lambda: set_prime_state(prime_type)) + + def do_onboarding() -> None: + """Click through the training guide and close.""" + from openpilot.selfdrive.ui.layouts.onboarding import STEP_RECTS + step = 0 + for step_rect in STEP_RECTS: + if step < len(STEP_RECTS) - 1: + script.click(int(step_rect.x), int(step_rect.y), wait_after=FAST_CLICK) + else: + script.click(950, 900) # On the last step, click Finish instead of restart + step += 1 + + def type_keyboard() -> None: + """Types 8 characters using the big keyboard to test different layouts and interactions.""" + KEY = (150, 430) # e.g. 'Q' key + SHIFT = (150, 750) # also symbols key in number mode + NUMBERS = (150, 950) + SPACE = (1060, 950) + BACKSPACE = (2000, 780) + for key in [ + SHIFT, KEY, KEY, SHIFT, SHIFT, KEY, KEY, # test casing (upper, lower, caps lock) + SPACE, SPACE, BACKSPACE, BACKSPACE, # test multiple space and backspace + NUMBERS, KEY, KEY, SHIFT, KEY, KEY # test numbers and symbols + ]: + script.click(*key, wait_after=FAST_CLICK) + # TODO: Better way of organizing the events # === Homescreen === script.set_send(make_network_state_setup(pm, log.DeviceState.NetworkType.wifi)) - - # === Offroad Alerts (auto-transitions via HomeLayout refresh) === - script.setup(make_home_refresh_setup(setup_offroad_alerts)) + # Go through different prime state layouts + add_prime_state_setup(PrimeType.LITE) + add_prime_state_setup(PrimeType.NONE) + add_prime_state_setup(PrimeType.UNPAIRED) # === Update Available (auto-transitions via HomeLayout refresh) === script.setup(make_home_refresh_setup(setup_update_available)) - # === Settings - Device (click sidebar settings button) === + # === Offroad Alerts (auto-transitions via HomeLayout refresh, overrides update) === + script.setup(make_home_refresh_setup(setup_offroad_alerts)) + script.click(620, 950) # close alerts + + # === Settings (click sidebar settings button) === script.click(150, 90) - script.click(1985, 790) # reset calibration confirmation - script.click(650, 750) # cancel + + # === Settings - Device === + # pair device + script.click(2000, 450) # pair device + script.click(110, 110) # close pairing dialog + add_prime_state_setup(PrimeType.NONE) # changed from unpaired to hide pair device button + # calibration + script.setup(setup_calibration_params, wait_after=0) + script.click(1000, 620) # expand calibration description + script.click(2000, 620) # reset calibration confirmation + script.click(1500, 750) # confirm reset + script.click(1000, 620) # collapse calibration description + # training guide + script.click(2000, 800) # open training guide + do_onboarding() + # regulatory info + script.click(2000, 970) # regulatory button + script.click(2000, 970) # OK # === Settings - Network === script.click(278, 450) + # TODO: mock networks script.click(1880, 100) # advanced network settings - script.click(630, 80) # back + + # Keyboard (tethering password) + script.click(2000, 420, wait_after=FAST_CLICK) # open tether password keyboard + script.click(2000, 950, wait_after=FAST_CLICK) # click confirm (disabled, should not close) + script.click(2000, 115) # cancel (close without typing) + script.click(2000, 420, wait_after=FAST_CLICK) # open keyboard again + type_keyboard() # test various keyboard layouts and interactions + script.click(2050, 250, wait_after=FAST_CLICK) # toggle show/hide password + script.click(2000, 950) # confirm (close keyboard) + + script.click(630, 80) # back from advanced network # === Settings - Toggles === script.click(278, 600) - script.click(1200, 280) # experimental mode description + script.click(1200, 280) # expand experimental mode description # === Settings - Software === - script.setup(put_update_params, wait_after=0) - script.click(278, 720) + script.setup(lambda: setup_update_available(False), wait_after=0) # start with no update available + script.click(278, 720) # software + for _ in range(2): + script.click(720, 120) # toggle current release notes + script.setup(setup_update_available) # set update available + for _ in range(2): + script.click(720, 450) # toggle new release notes + script.click(2000, 630) # open select branch dialog + script.click(1000, 300) # select 1st option + script.click(1600, 900) # confirm selection + script.click(2000, 800) # uninstall + script.click(650, 750) # cancel uninstall # === Settings - Firehose === script.click(278, 845) @@ -206,31 +493,18 @@ def build_tizi_script(pm: PubMaster, main_layout, script: Script) -> None: # === Settings - Developer (set CarParamsPersistent first) === script.setup(setup_developer_params, wait_after=0) script.click(278, 950) + script.click(1930, 470) # SSH keys (keyboard) + script.click(1930, 115) # click cancel on keyboard script.click(2000, 960) # toggle alpha long script.click(1500, 875) # confirm - # === Keyboard modal (SSH keys button in developer panel) === - script.click(1930, 470) # click SSH keys - script.click(1930, 115) # click cancel on keyboard - # === Close settings === script.click(250, 160) # === Onroad === script.set_send(lambda: send_onroad(pm)) script.click(1000, 500) # click onroad to toggle sidebar - - # === Onroad alerts === - # Small alert (normal) - script.set_send(make_alert_setup(pm, AlertSize.small, "Small Alert", "This is a small alert", AlertStatus.normal)) - # Medium alert (userPrompt) - script.set_send(make_alert_setup(pm, AlertSize.mid, "Medium Alert", "This is a medium alert", AlertStatus.userPrompt)) - # Full alert (critical) - script.set_send(make_alert_setup(pm, AlertSize.full, "DISENGAGE IMMEDIATELY", "Driver Distracted", AlertStatus.critical)) - # Full alert multiline - script.set_send(make_alert_setup(pm, AlertSize.full, "Reverse\nGear", "", AlertStatus.normal)) - # Full alert long text - script.set_send(make_alert_setup(pm, AlertSize.full, "TAKE CONTROL IMMEDIATELY", "Calibration Invalid: Remount Device & Recalibrate", AlertStatus.userPrompt)) + test_onroad_alerts(script, pm) # End script.end() diff --git a/selfdrive/ui/tests/test_translations.py b/selfdrive/ui/tests/test_translations.py index 599c99013c..fba595acad 100644 --- a/selfdrive/ui/tests/test_translations.py +++ b/selfdrive/ui/tests/test_translations.py @@ -1,124 +1,106 @@ -import pytest import json -import os import re -import xml.etree.ElementTree as ET import string -import requests -from openpilot.common.parameterized import parameterized_class -from openpilot.system.ui.lib.multilang import TRANSLATIONS_DIR, LANGUAGES_FILE +from pathlib import Path -with open(str(LANGUAGES_FILE)) as f: - translation_files = json.load(f) +import pytest -UNFINISHED_TRANSLATION_TAG = " list[str]: + placeholders = PERCENT_PLACEHOLDER_RE.findall(text) - @staticmethod - def _read_translation_file(path, file): - tr_file = os.path.join(path, f"{file}.ts") - with open(tr_file) as f: - return f.read() + try: + parsed = list(FORMATTER.parse(text)) + except ValueError as e: + raise AssertionError(f"invalid brace formatting in {text!r}: {e}") from e - def test_missing_translation_files(self): - assert os.path.exists(os.path.join(str(TRANSLATIONS_DIR), f"{self.file}.ts")), \ - f"{self.name} has no XML translation file, run selfdrive/ui/update_translations.py" + for _, field_name, format_spec, conversion in parsed: + if field_name is None: + continue - @pytest.mark.skip("Only test unfinished translations before going to release") - def test_unfinished_translations(self): - cur_translations = self._read_translation_file(TRANSLATIONS_DIR, self.file) - assert UNFINISHED_TRANSLATION_TAG not in cur_translations, \ - f"{self.file} ({self.name}) translation file has unfinished translations. Finish translations or mark them as completed in Qt Linguist" + token = "{" + token += field_name + if conversion: + token += f"!{conversion}" + if format_spec: + token += f":{format_spec}" + token += "}" + placeholders.append(token) - def test_vanished_translations(self): - cur_translations = self._read_translation_file(TRANSLATIONS_DIR, self.file) - assert "" not in cur_translations, \ - f"{self.file} ({self.name}) translation file has obsolete translations. Run selfdrive/ui/update_translations.py --vanish to remove them" + return sorted(placeholders) - def test_finished_translations(self): - """ - Tests ran on each translation marked "finished" - Plural: - - that any numerus (plural) translations have all plural forms non-empty - - that the correct format specifier is used (%n) - Non-plural: - - that translation is not empty - - that translation format arguments are consistent - """ - tr_xml = ET.parse(os.path.join(TRANSLATIONS_DIR, f"{self.file}.ts")) - for context in tr_xml.getroot(): - for message in context.iterfind("message"): - translation = message.find("translation") - source_text = message.find("source").text +def load_po_text(po_path: Path) -> str: + return po_path.read_text(encoding='utf-8') - # Do not test unfinished translations - if translation.get("type") == "unfinished": - continue - if message.get("numerus") == "yes": - numerusform = [t.text for t in translation.findall("numerusform")] +@pytest.mark.parametrize("language_code", sorted(TRANSLATION_LANGUAGES.values())) +def test_translation_file_exists(language_code: str): + po_path = PO_DIR / f"app_{language_code}.po" + assert po_path.exists(), f"missing translation file: {po_path}" - for nf in numerusform: - assert nf is not None, f"Ensure all plural translation forms are completed: {source_text}" - assert "%n" in nf, "Ensure numerus argument (%n) exists in translation." - assert FORMAT_ARG.search(nf) is None, f"Plural translations must use %n, not %1, %2, etc.: {numerusform}" - else: - assert translation.text is not None, f"Ensure translation is completed: {source_text}" +@pytest.mark.parametrize("po_path", sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) +def test_translation_placeholders_are_preserved(po_path: Path): + _, entries = parse_po(po_path) + language = po_path.stem.removeprefix("app_") - source_args = FORMAT_ARG.findall(source_text) - translation_args = FORMAT_ARG.findall(translation.text) - assert sorted(source_args) == sorted(translation_args), \ - f"Ensure format arguments are consistent: `{source_text}` vs. `{translation.text}`" + for entry in entries: + source_placeholders = extract_placeholders(entry.msgid) - def test_no_locations(self): - for line in self._read_translation_file(TRANSLATIONS_DIR, self.file).splitlines(): - assert not line.strip().startswith(LOCATION_TAG), \ - f"Line contains location tag: {line.strip()}, remove all line numbers." - - def test_entities_error(self): - cur_translations = self._read_translation_file(TRANSLATIONS_DIR, self.file) - matches = re.findall(r'@(\w+);', cur_translations) - assert len(matches) == 0, f"The string(s) {matches} were found with '@' instead of '&'" - - def test_bad_language(self): - IGNORED_WORDS = {'pédale'} - - match = re.search(r'([a-zA-Z]{2,3})', self.file) - assert match, f"{self.name} - could not parse language" - - try: - response = requests.get( - f"https://raw.githubusercontent.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words/master/{match.group(1)}" + if entry.is_plural: + plural_placeholders = extract_placeholders(entry.msgid_plural) + message = ( + f"{language}: source plural placeholders do not match singular for " + + f"{entry.msgid!r}: {source_placeholders} vs {plural_placeholders}" ) - response.raise_for_status() - except requests.exceptions.HTTPError as e: - if e.response is not None and e.response.status_code == 429: - pytest.skip("word list rate limited") - raise + assert plural_placeholders == source_placeholders, message - banned_words = {line.strip() for line in response.text.splitlines()} - - for context in ET.parse(os.path.join(TRANSLATIONS_DIR, f"{self.file}.ts")).getroot(): - for message in context.iterfind("message"): - translation = message.find("translation") - if translation.get("type") == "unfinished": + for idx, msgstr in sorted(entry.msgstr_plural.items()): + if not msgstr: continue - translation_text = " ".join([t.text for t in translation.findall("numerusform")]) if message.get("numerus") == "yes" else translation.text + translated_placeholders = extract_placeholders(msgstr) + message = ( + f"{language}: plural form {idx} changes placeholders for {entry.msgid!r}: " + + f"expected {source_placeholders}, got {translated_placeholders}" + ) + assert translated_placeholders == source_placeholders, message + else: + if not entry.msgstr: + continue - if not translation_text: - continue + translated_placeholders = extract_placeholders(entry.msgstr) + message = ( + f"{language}: translation changes placeholders for {entry.msgid!r}: " + + f"expected {source_placeholders}, got {translated_placeholders}" + ) + assert translated_placeholders == source_placeholders, message - words = set(translation_text.translate(str.maketrans('', '', string.punctuation + '%n')).lower().split()) - bad_words_found = words & (banned_words - IGNORED_WORDS) - assert not bad_words_found, f"Bad language found in {self.name}: '{translation_text}'. Bad word(s): {', '.join(bad_words_found)}" + +@pytest.mark.parametrize("po_path", sorted(PO_DIR.glob("app_*.po")), ids=lambda p: p.name) +def test_translation_refs_do_not_include_line_numbers(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) +def test_translation_entities_are_valid(po_path: Path): + matches = BAD_ENTITY_RE.findall(load_po_text(po_path)) + assert not matches, ( + f"{po_path.name}: found '@...;' entity typo(s): {', '.join(sorted(set(matches)))}" + ) diff --git a/selfdrive/ui/translations/README.md b/selfdrive/ui/translations/README.md deleted file mode 100644 index 433eb7d64a..0000000000 --- a/selfdrive/ui/translations/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Multilanguage - -[![languages](https://raw.githubusercontent.com/commaai/openpilot/badges/translation_badge.svg)](#) diff --git a/selfdrive/ui/translations/app.pot b/selfdrive/ui/translations/app.pot index abb6940a54..37a80ff223 100644 --- a/selfdrive/ui/translations/app.pot +++ b/selfdrive/ui/translations/app.pot @@ -1,1130 +1,871 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. -# -#, fuzzy msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 00:51-0700\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"Language: \n" -"MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" -#: system/ui/widgets/html_render.py:263 system/ui/widgets/confirm_dialog.py:93 -#: selfdrive/ui/layouts/sidebar.py:127 -#, python-format -msgid "OK" +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" msgstr "" -#: system/ui/widgets/confirm_dialog.py:23 system/ui/widgets/option_dialog.py:35 -#: system/ui/widgets/keyboard.py:81 system/ui/widgets/network.py:318 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/keyboard.py +#: system/ui/widgets/network.py +#: system/ui/widgets/option_dialog.py msgid "Cancel" msgstr "" -#: system/ui/widgets/option_dialog.py:36 -#, python-format -msgid "Select" -msgstr "" - -#: system/ui/widgets/network.py:74 system/ui/widgets/network.py:95 -#, python-format +#: system/ui/widgets/network.py msgid "Advanced" msgstr "" -#: system/ui/widgets/network.py:99 selfdrive/ui/layouts/onboarding.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +#: system/ui/widgets/network.py msgid "Back" msgstr "" -#: system/ui/widgets/network.py:120 -#, python-format -msgid "Enable Tethering" -msgstr "" - -#: system/ui/widgets/network.py:123 system/ui/widgets/network.py:139 -#, python-format -msgid "EDIT" -msgstr "" - -#: system/ui/widgets/network.py:124 -#, python-format -msgid "Tethering Password" -msgstr "" - -#: system/ui/widgets/network.py:129 -#, python-format -msgid "Enable Roaming" -msgstr "" - -#: system/ui/widgets/network.py:134 -#, python-format -msgid "Cellular Metered" -msgstr "" - -#: system/ui/widgets/network.py:135 -#, python-format -msgid "Prevent large data uploads when on a metered cellular connection" -msgstr "" - -#: system/ui/widgets/network.py:139 -#, python-format -msgid "APN Setting" -msgstr "" - -#: system/ui/widgets/network.py:142 -#, python-format -msgid "default" -msgstr "" - -#: system/ui/widgets/network.py:142 -#, python-format -msgid "metered" -msgstr "" - -#: system/ui/widgets/network.py:142 -#, python-format -msgid "unmetered" -msgstr "" - -#: system/ui/widgets/network.py:144 -#, python-format -msgid "Wi-Fi Network Metered" -msgstr "" - -#: system/ui/widgets/network.py:144 -#, python-format -msgid "Prevent large data uploads when on a metered Wi-Fi connection" -msgstr "" - -#: system/ui/widgets/network.py:150 -#, python-format -msgid "IP Address" -msgstr "" - -#: system/ui/widgets/network.py:155 -#, python-format -msgid "Hidden Network" -msgstr "" - -#: system/ui/widgets/network.py:155 selfdrive/ui/layouts/sidebar.py:73 -#: selfdrive/ui/layouts/sidebar.py:134 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:138 -#, python-format -msgid "CONNECT" -msgstr "" - -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "Enter APN" msgstr "" -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "leave blank for automatic configuration" msgstr "" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format -msgid "Enter password" -msgstr "" - -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format -msgid "for \"{}\"" -msgstr "" - -#: system/ui/widgets/network.py:241 -#, python-format +#: system/ui/widgets/network.py msgid "Enter SSID" msgstr "" -#: system/ui/widgets/network.py:254 -#, python-format +#: system/ui/widgets/network.py msgid "Enter new tethering password" msgstr "" -#: system/ui/widgets/network.py:310 -#, python-format +#: system/ui/widgets/network.py +msgid "Enable Tethering" +msgstr "" + +#: system/ui/widgets/network.py +msgid "EDIT" +msgstr "" + +#: system/ui/widgets/network.py +msgid "Tethering Password" +msgstr "" + +#: system/ui/widgets/network.py +msgid "Enable Roaming" +msgstr "" + +#: system/ui/widgets/network.py +msgid "Cellular Metered" +msgstr "" + +#: system/ui/widgets/network.py +msgid "APN Setting" +msgstr "" + +#: system/ui/widgets/network.py +msgid "Wi-Fi Network Metered" +msgstr "" + +#: system/ui/widgets/network.py +msgid "Enter password" +msgstr "" + +#: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "" -#: system/ui/widgets/network.py:314 -#, python-format -msgid "Wrong password" -msgstr "" - -#: system/ui/widgets/network.py:318 system/ui/widgets/network.py:451 -#, python-format -msgid "Forget" -msgstr "" - -#: system/ui/widgets/network.py:319 -#, python-format -msgid "Forget Wi-Fi Network \"{}\"?" -msgstr "" - -#: system/ui/widgets/network.py:369 -#, python-format +#: system/ui/widgets/network.py msgid "CONNECTING..." msgstr "" -#: system/ui/widgets/network.py:373 -#, python-format +#: system/ui/widgets/network.py +msgid "Forget" +msgstr "" + +#: system/ui/widgets/network.py +msgid "Prevent large data uploads when on a metered cellular connection" +msgstr "" + +#: system/ui/widgets/network.py +msgid "default" +msgstr "" + +#: system/ui/widgets/network.py +msgid "metered" +msgstr "" + +#: system/ui/widgets/network.py +msgid "unmetered" +msgstr "" + +#: system/ui/widgets/network.py +msgid "Prevent large data uploads when on a metered Wi-Fi connection" +msgstr "" + +#: system/ui/widgets/network.py +msgid "IP Address" +msgstr "" + +#: system/ui/widgets/network.py +msgid "Hidden Network" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/network.py +msgid "CONNECT" +msgstr "" + +#: system/ui/widgets/network.py +msgid "Wrong password" +msgstr "" + +#: system/ui/widgets/network.py msgid "FORGETTING..." msgstr "" -#: system/ui/widgets/list_view.py:123 system/ui/widgets/list_view.py:160 -#, python-format +#: system/ui/widgets/network.py +msgid "for \"{}\"" +msgstr "" + +#: system/ui/widgets/network.py +msgid "Forget Wi-Fi Network \"{}\"?" +msgstr "" + +#: system/ui/widgets/list_view.py msgid "Error" msgstr "" -#: selfdrive/ui/widgets/pairing_dialog.py:103 -#, python-format -msgid "Pair your device to your comma account" +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/html_render.py +msgid "OK" msgstr "" -#: selfdrive/ui/widgets/pairing_dialog.py:128 -#, python-format -msgid "Go to https://connect.comma.ai on your phone" +#: system/ui/widgets/option_dialog.py +msgid "Select" msgstr "" -#: selfdrive/ui/widgets/pairing_dialog.py:129 -#, python-format -msgid "Click \"add new device\" and scan the QR code on the right" -msgstr "" - -#: selfdrive/ui/widgets/pairing_dialog.py:130 -#, python-format -msgid "Bookmark connect.comma.ai to your home screen to use it like an app" -msgstr "" - -#: selfdrive/ui/widgets/pairing_dialog.py:161 -#, python-format -msgid "QR Code Error" -msgstr "" - -#: selfdrive/ui/widgets/ssh_key.py:29 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "LOADING" msgstr "" -#: selfdrive/ui/widgets/ssh_key.py:30 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "ADD" msgstr "" -#: selfdrive/ui/widgets/ssh_key.py:31 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "REMOVE" msgstr "" -#: selfdrive/ui/widgets/ssh_key.py:89 -#, python-format -msgid "Enter your GitHub username" -msgstr "" - -#: selfdrive/ui/widgets/ssh_key.py:114 -#, python-format -msgid "No SSH keys found" -msgstr "" - -#: selfdrive/ui/widgets/ssh_key.py:123 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Request timed out" msgstr "" -#: selfdrive/ui/widgets/ssh_key.py:126 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py +msgid "Enter your GitHub username" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "No SSH keys found for user '{}'" msgstr "" -#: selfdrive/ui/widgets/prime.py:33 -#, python-format -msgid "Upgrade Now" -msgstr "" - -#: selfdrive/ui/widgets/prime.py:38 -#, python-format -msgid "Become a comma prime member at connect.comma.ai" -msgstr "" - -#: selfdrive/ui/widgets/prime.py:44 -#, python-format -msgid "PRIME FEATURES:" -msgstr "" - -#: selfdrive/ui/widgets/prime.py:47 -#, python-format -msgid "Remote access" -msgstr "" - -#: selfdrive/ui/widgets/prime.py:47 -#, python-format -msgid "24/7 LTE connectivity" -msgstr "" - -#: selfdrive/ui/widgets/prime.py:47 -#, python-format -msgid "1 year of drive storage" -msgstr "" - -#: selfdrive/ui/widgets/prime.py:47 -#, python-format -msgid "Remote snapshots" -msgstr "" - -#: selfdrive/ui/widgets/prime.py:62 -#, python-format -msgid "✓ SUBSCRIBED" -msgstr "" - -#: selfdrive/ui/widgets/prime.py:63 -#, python-format -msgid "comma prime" -msgstr "" - -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format -msgid "EXPERIMENTAL MODE ON" -msgstr "" - -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format -msgid "CHILL MODE ON" -msgstr "" - -#: selfdrive/ui/widgets/offroad_alerts.py:104 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Close" msgstr "" -#: selfdrive/ui/widgets/offroad_alerts.py:106 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Snooze Update" msgstr "" -#: selfdrive/ui/widgets/offroad_alerts.py:109 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Acknowledge Excessive Actuation" msgstr "" -#: selfdrive/ui/widgets/offroad_alerts.py:112 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Reboot and Update" msgstr "" -#: selfdrive/ui/widgets/offroad_alerts.py:320 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "No release notes available." msgstr "" -#: selfdrive/ui/widgets/setup.py:19 -#, python-format -msgid "Pair device" +#: openpilot/selfdrive/ui/layouts/settings/device.py +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." msgstr "" -#: selfdrive/ui/widgets/setup.py:20 -#, python-format -msgid "Open" +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Maximize your training data uploads to improve openpilot's driving models." msgstr "" -#: selfdrive/ui/widgets/setup.py:22 -#, python-format -msgid "🔥 Firehose Mode 🔥" -msgstr "" - -#: selfdrive/ui/widgets/setup.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Finish Setup" msgstr "" -#: selfdrive/ui/widgets/setup.py:48 selfdrive/ui/layouts/settings/device.py:24 -#, python-format -msgid "" -"Pair your device with comma connect (connect.comma.ai) and claim your comma " -"prime offer." +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair device" msgstr "" -#: selfdrive/ui/widgets/setup.py:75 -#, python-format -msgid "" -"Maximize your training data uploads to improve openpilot's driving models." +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Open" msgstr "" -#: selfdrive/ui/widgets/setup.py:91 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "🔥 Firehose Mode 🔥" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Please connect to Wi-Fi to complete initial pairing" msgstr "" -#: selfdrive/ui/layouts/home.py:155 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py +msgid "EXPERIMENTAL MODE ON" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py +msgid "CHILL MODE ON" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/prime.py +msgid "Upgrade Now" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/prime.py +msgid "PRIME FEATURES:" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/prime.py +msgid "Remote access" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/prime.py +msgid "24/7 LTE connectivity" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/prime.py +msgid "1 year of drive storage" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/prime.py +msgid "Remote snapshots" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/prime.py +msgid "✓ SUBSCRIBED" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/prime.py +msgid "comma prime" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/prime.py +msgid "Become a comma prime member at connect.comma.ai" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "Pair your device to your comma account" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "Go to https://connect.comma.ai on your phone" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "Click \"add new device\" and scan the QR code on the right" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "Bookmark connect.comma.ai to your home screen to use it like an app" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py +msgid "QR Code Error" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/home.py msgid "UPDATE" msgstr "" -#: selfdrive/ui/layouts/home.py:169 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "{} ALERT" msgid_plural "{} ALERTS" msgstr[0] "" msgstr[1] "" -#: selfdrive/ui/layouts/sidebar.py:43 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "--" msgstr "" -#: selfdrive/ui/layouts/sidebar.py:44 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" msgstr "" -#: selfdrive/ui/layouts/sidebar.py:45 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ETH" msgstr "" -#: selfdrive/ui/layouts/sidebar.py:46 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "2G" msgstr "" -#: selfdrive/ui/layouts/sidebar.py:47 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "3G" msgstr "" -#: selfdrive/ui/layouts/sidebar.py:48 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "LTE" msgstr "" -#: selfdrive/ui/layouts/sidebar.py:49 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "5G" msgstr "" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 -#: selfdrive/ui/layouts/sidebar.py:127 selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "TEMP" msgstr "" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "GOOD" msgstr "" -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "VEHICLE" msgstr "" -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ONLINE" msgstr "" -#: selfdrive/ui/layouts/sidebar.py:73 selfdrive/ui/layouts/sidebar.py:134 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "OFFLINE" msgstr "" -#: selfdrive/ui/layouts/sidebar.py:117 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Unknown" msgstr "" -#: selfdrive/ui/layouts/sidebar.py:129 -msgid "HIGH" -msgstr "" - -#: selfdrive/ui/layouts/sidebar.py:138 -msgid "ERROR" -msgstr "" - -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "NO" msgstr "" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "PANDA" msgstr "" -#: selfdrive/ui/layouts/onboarding.py:111 -#, python-format -msgid "Welcome to openpilot" +#: openpilot/selfdrive/ui/layouts/sidebar.py +msgid "HIGH" msgstr "" -#: selfdrive/ui/layouts/onboarding.py:112 -#, python-format -msgid "" -"You must accept the Terms and Conditions to use openpilot. Read the latest " -"terms at https://comma.ai/terms before continuing." +#: openpilot/selfdrive/ui/layouts/sidebar.py +msgid "ERROR" msgstr "" -#: selfdrive/ui/layouts/onboarding.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Welcome to sunnypilot" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Decline" msgstr "" -#: selfdrive/ui/layouts/onboarding.py:116 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Agree" msgstr "" -#: selfdrive/ui/layouts/onboarding.py:145 -#, python-format -msgid "You must accept the Terms and Conditions in order to use openpilot." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service in order to use sunnypilot." msgstr "" -#: selfdrive/ui/layouts/onboarding.py:148 -#, python-format -msgid "Decline, uninstall openpilot" +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline, uninstall sunnypilot" msgstr "" -#: selfdrive/ui/layouts/settings/firehose.py:18 +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Device" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Network" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Toggles" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Software" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Firehose" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/settings.py +msgid "Developer" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Select a language" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Are you sure you want to reset calibration?" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Reset" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "

Steering lag calibration is complete." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Are you sure you want to reboot?" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Reboot" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Are you sure you want to power off?" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Power Off" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Pair Device" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "PAIR" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Reset Calibration" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "RESET" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Dongle ID" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Serial" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Driver Camera" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "PREVIEW" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Review Training Guide" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "REVIEW" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Regulatory" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "VIEW" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Change Language" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "CHANGE" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Disengage to Reset Calibration" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "

Steering lag calibration is {}% complete." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Disengage to Reboot" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Disengage to Power Off" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "N/A" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid " Steering torque response calibration is complete." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid " Your device is pointed {:.1f}° {} and {:.1f}° {}." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "down" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "up" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "left" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "right" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid " Steering torque response calibration is {}% complete." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Enable ADB" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Enable SSH" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "SSH Keys" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Joystick Debug Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Longitudinal Maneuver Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "UI Debug Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "Firehose Mode" msgstr "" -#: selfdrive/ui/layouts/settings/firehose.py:20 -msgid "" -"openpilot learns to drive by watching humans, like you, drive.\n" -"\n" -"Firehose Mode allows you to maximize your training data uploads to improve " -"openpilot's driving models. More data means bigger models, which means " -"better Experimental Mode." -msgstr "" - -#: selfdrive/ui/layouts/settings/firehose.py:25 -msgid "" -"For maximum effectiveness, bring your device inside and connect to a good " -"USB-C adapter and Wi-Fi weekly.\n" -"\n" -"Firehose Mode can also work while you're driving if connected to a hotspot " -"or unlimited SIM card.\n" -"\n" -"\n" -"Frequently Asked Questions\n" -"\n" -"Does it matter how or where I drive? Nope, just drive as you normally " -"would.\n" -"\n" -"Do all of my segments get pulled in Firehose Mode? No, we selectively pull a " -"subset of your segments.\n" -"\n" -"What's a good USB-C adapter? Any fast phone or laptop charger should be " -"fine.\n" -"\n" -"Does it matter which software I run? Yes, only upstream openpilot (and " -"particular forks) are able to be used for training." -msgstr "" - -#: selfdrive/ui/layouts/settings/firehose.py:111 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "{} segment of your driving is in the training dataset so far." msgid_plural "{} segments of your driving is in the training dataset so far." msgstr[0] "" msgstr[1] "" -#: selfdrive/ui/layouts/settings/firehose.py:138 -#, python-format -msgid "ACTIVE" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." msgstr "" -#: selfdrive/ui/layouts/settings/firehose.py:140 -#, python-format -msgid "INACTIVE: connect to an unmetered network" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable driver monitoring even when sunnypilot is not engaged." msgstr "" -#: selfdrive/ui/layouts/settings/developer.py:15 -msgid "" -"ADB (Android Debug Bridge) allows connecting to your device over USB or over " -"the network. See https://docs.comma.ai/how-to/connect-to-comma for more info." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." msgstr "" -#: selfdrive/ui/layouts/settings/developer.py:19 -msgid "" -"Warning: This grants SSH access to all public keys in your GitHub settings. " -"Never enter a GitHub username other than your own. A comma employee will " -"NEVER ask you to add their GitHub username." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Display speed in km/h instead of mph." msgstr "" -#: selfdrive/ui/layouts/settings/developer.py:23 -msgid "" -"WARNING: openpilot longitudinal control is in alpha for this car and will " -"disable Automatic Emergency Braking (AEB).

On this car, openpilot " -"defaults to the car's built-in ACC instead of openpilot's longitudinal " -"control. Enable this to switch to openpilot longitudinal control. Enabling " -"Experimental mode is recommended when enabling openpilot longitudinal " -"control alpha. Changing this setting will restart openpilot if the car is " -"powered on." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect." msgstr "" -#: selfdrive/ui/layouts/settings/developer.py:39 -#, python-format -msgid "Enable ADB" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Driving Personality" msgstr "" -#: selfdrive/ui/layouts/settings/developer.py:48 -#, python-format -msgid "Enable SSH" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Changing this setting will restart sunnypilot if the car is powered on." msgstr "" -#: selfdrive/ui/layouts/settings/developer.py:53 -#, python-format -msgid "SSH Keys" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." msgstr "" -#: selfdrive/ui/layouts/settings/developer.py:56 -#, python-format -msgid "Joystick Debug Mode" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable sunnypilot" msgstr "" -#: selfdrive/ui/layouts/settings/developer.py:64 -#, python-format -msgid "Longitudinal Maneuver Mode" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Experimental Mode" msgstr "" -#: selfdrive/ui/layouts/settings/developer.py:71 -#, python-format -msgid "openpilot Longitudinal Control (Alpha)" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Disengage on Accelerator Pedal" msgstr "" -#: selfdrive/ui/layouts/settings/developer.py:166 -#: selfdrive/ui/layouts/settings/toggles.py:228 -#, python-format -msgid "Enable" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable Lane Departure Warnings" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:20 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Always-On Driver Monitoring" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and Upload Driver Camera" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and Upload Microphone Audio" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Use Metric System" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Aggressive" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Standard" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Relaxed" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "checking..." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "downloading..." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "finalizing update..." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "never" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:31 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "now" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "CHECK" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Are you sure you want to uninstall?" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Uninstall" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "Select a branch" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} minute ago" msgid_plural "{} minutes ago" msgstr[0] "" msgstr[1] "" -#: selfdrive/ui/layouts/settings/software.py:37 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "" msgstr[1] "" -#: selfdrive/ui/layouts/settings/software.py:40 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "" msgstr[1] "" -#: selfdrive/ui/layouts/settings/software.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Updates are only downloaded while the car is off." msgstr "" -#: selfdrive/ui/layouts/settings/software.py:49 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Current Version" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:50 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Download" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:50 -#: selfdrive/ui/layouts/settings/software.py:107 -#: selfdrive/ui/layouts/settings/software.py:118 -#: selfdrive/ui/layouts/settings/software.py:147 -#, python-format -msgid "CHECK" -msgstr "" - -#: selfdrive/ui/layouts/settings/software.py:53 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:53 -#: selfdrive/ui/layouts/settings/software.py:136 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "INSTALL" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Target Branch" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:72 -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format -msgid "Uninstall" -msgstr "" - -#: selfdrive/ui/layouts/settings/software.py:72 -#, python-format -msgid "UNINSTALL" -msgstr "" - -#: selfdrive/ui/layouts/settings/software.py:106 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "failed to check for update" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:109 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "UNINSTALL" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "update available" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:110 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "DOWNLOAD" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:115 -#, python-format -msgid "up to date, last checked {}" -msgstr "" - -#: selfdrive/ui/layouts/settings/software.py:117 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked never" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format -msgid "Are you sure you want to uninstall?" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "up to date, last checked {}" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:183 -#, python-format -msgid "Select a branch" +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" msgstr "" -#: selfdrive/ui/layouts/settings/device.py:25 -msgid "" -"Preview the driver facing camera to ensure that driver monitoring has good " -"visibility. (vehicle must be off)" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:26 -msgid "" -"openpilot requires the device to be mounted within 4° left or right and " -"within 5° up or 9° down." -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:27 -msgid "Review the rules, features, and limitations of openpilot" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format -msgid "Pair Device" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format -msgid "PAIR" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format -msgid "Reset Calibration" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format -msgid "RESET" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format -msgid "Reboot" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format -msgid "Power Off" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:59 -#, python-format -msgid "Dongle ID" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:59 -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format -msgid "N/A" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format -msgid "Serial" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format -msgid "Driver Camera" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format -msgid "PREVIEW" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format -msgid "Review Training Guide" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format -msgid "REVIEW" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format -msgid "Regulatory" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format -msgid "VIEW" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format -msgid "Change Language" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format -msgid "CHANGE" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:91 -#, python-format -msgid "Select a language" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:103 -#, python-format -msgid "Disengage to Reset Calibration" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format -msgid "Are you sure you want to reset calibration?" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format -msgid "Reset" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format -msgid " Your device is pointed {:.1f}° {} and {:.1f}° {}." -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format -msgid "down" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format -msgid "up" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format -msgid "left" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format -msgid "right" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:146 -#, python-format -msgid "

Steering lag calibration is {}% complete." -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:148 -#, python-format -msgid "

Steering lag calibration is complete." -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:158 -#, python-format -msgid " Steering torque response calibration is {}% complete." -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:160 -#, python-format -msgid " Steering torque response calibration is complete." -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:165 -#, python-format -msgid "" -"openpilot is continuously calibrating, resetting is rarely required. " -"Resetting calibration will restart openpilot if the car is powered on." -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:172 -#, python-format -msgid "Disengage to Reboot" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format -msgid "Are you sure you want to reboot?" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:184 -#, python-format -msgid "Disengage to Power Off" -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format -msgid "Are you sure you want to power off?" -msgstr "" - -#: selfdrive/ui/layouts/settings/settings.py:62 -msgid "Device" -msgstr "" - -#: selfdrive/ui/layouts/settings/settings.py:63 -msgid "Network" -msgstr "" - -#: selfdrive/ui/layouts/settings/settings.py:64 -msgid "Toggles" -msgstr "" - -#: selfdrive/ui/layouts/settings/settings.py:65 -msgid "Software" -msgstr "" - -#: selfdrive/ui/layouts/settings/settings.py:66 -msgid "Firehose" -msgstr "" - -#: selfdrive/ui/layouts/settings/settings.py:67 -msgid "Developer" -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:17 -msgid "" -"Use the openpilot system for adaptive cruise control and lane keep driver " -"assistance. Your attention is required at all times to use this feature." -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:20 -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:22 -msgid "" -"Standard is recommended. In aggressive mode, openpilot will follow lead cars " -"closer and be more aggressive with the gas and brake. In relaxed mode " -"openpilot will stay further away from lead cars. On supported cars, you can " -"cycle through these personalities with your steering wheel distance button." -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:27 -msgid "" -"Receive alerts to steer back into the lane when your vehicle drifts over a " -"detected lane line without a turn signal activated while driving over 31 mph " -"(50 km/h)." -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:30 -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:31 -msgid "" -"Upload data from the driver facing camera and help improve the driver " -"monitoring algorithm." -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:32 -msgid "Display speed in km/h instead of mph." -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:33 -msgid "" -"Record and store microphone audio while driving. The audio will be included " -"in the dashcam video in comma connect." -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:46 -#, python-format -msgid "Enable openpilot" -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:52 -#, python-format -msgid "Experimental Mode" -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:58 -#, python-format -msgid "Disengage on Accelerator Pedal" -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:64 -#, python-format -msgid "Enable Lane Departure Warnings" -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:70 -#, python-format -msgid "Always-On Driver Monitoring" -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:76 -#, python-format -msgid "Record and Upload Driver Camera" -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:82 -#, python-format -msgid "Record and Upload Microphone Audio" -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:88 -#, python-format -msgid "Use Metric System" -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:96 -#, python-format -msgid "Driving Personality" -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format -msgid "Aggressive" -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format -msgid "Standard" -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format -msgid "Relaxed" -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:125 -#, python-format -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:158 -#, python-format -msgid "" -"openpilot defaults to driving in chill mode. Experimental mode enables alpha-" -"level features that aren't ready for chill mode. Experimental features are " -"listed below:

End-to-End Longitudinal Control


Let the driving " -"model control the gas and brakes. openpilot will drive as it thinks a human " -"would, including stopping for red lights and stop signs. Since the driving " -"model decides the speed to drive, the set speed will only act as an upper " -"bound. This is an alpha quality feature; mistakes should be expected." -"

New Driving Visualization


The driving visualization will " -"transition to the road-facing wide-angle camera at low speeds to better show " -"some turns. The Experimental mode logo will also be shown in the top right " -"corner." -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:181 -#, python-format -msgid "" -"Experimental mode is currently unavailable on this car since the car's stock " -"ACC is used for longitudinal control." -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:183 -#, python-format -msgid "openpilot longitudinal control may come in a future update." -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:186 -#, python-format -msgid "" -"An alpha version of openpilot longitudinal control can be tested, along with " -"Experimental mode, on non-release branches." -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:189 -#, python-format -msgid "" -"Enable the openpilot longitudinal control (alpha) toggle to allow " -"Experimental mode." -msgstr "" - -#: selfdrive/ui/onroad/hud_renderer.py:148 -#, python-format -msgid "MAX" -msgstr "" - -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format -msgid "km/h" -msgstr "" - -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format -msgid "mph" -msgstr "" - -#: selfdrive/ui/onroad/driver_camera_dialog.py:34 -#, python-format -msgid "camera starting" -msgstr "" - -#: selfdrive/ui/onroad/alert_renderer.py:51 -#, python-format -msgid "openpilot Unavailable" -msgstr "" - -#: selfdrive/ui/onroad/alert_renderer.py:52 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Waiting to start" msgstr "" -#: selfdrive/ui/onroad/alert_renderer.py:58 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "TAKE CONTROL IMMEDIATELY" msgstr "" -#: selfdrive/ui/onroad/alert_renderer.py:59 -#: selfdrive/ui/onroad/alert_renderer.py:65 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "System Unresponsive" msgstr "" -#: selfdrive/ui/onroad/alert_renderer.py:66 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Reboot Device" msgstr "" + +#: openpilot/selfdrive/ui/onroad/hud_renderer.py +msgid "MAX" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/hud_renderer.py +msgid "km/h" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/hud_renderer.py +msgid "mph" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py +msgid "camera starting" +msgstr "" + diff --git a/selfdrive/ui/translations/app_de.po b/selfdrive/ui/translations/app_de.po index f32c27a9ef..b4af3fcc1d 100644 --- a/selfdrive/ui/translations/app_de.po +++ b/selfdrive/ui/translations/app_de.po @@ -1,1221 +1,873 @@ -# German translations for PACKAGE package. -# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# Automatically generated, 2025. -# msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 00:50-0700\n" -"PO-Revision-Date: 2025-10-20 16:35-0700\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" -"Language: de\n" -"MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" +"Language: de\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: selfdrive/ui/layouts/settings/device.py:160 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is complete." msgstr " Die Lenkmoment-Reaktionskalibrierung ist abgeschlossen." -#: selfdrive/ui/layouts/settings/device.py:158 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is {}% complete." msgstr " Die Lenkmoment-Reaktionskalibrierung ist zu {}% abgeschlossen." -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Your device is pointed {:.1f}° {} and {:.1f}° {}." msgstr " Ihr Gerät ist um {:.1f}° {} und {:.1f}° {} ausgerichtet." -#: selfdrive/ui/layouts/sidebar.py:43 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "--" msgstr "--" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "1 year of drive storage" msgstr "1 Jahr Fahrtdatenspeicherung" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "24/7 LTE connectivity" msgstr "24/7 LTE‑Verbindung" -#: selfdrive/ui/layouts/sidebar.py:46 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "2G" msgstr "2G" -#: selfdrive/ui/layouts/sidebar.py:47 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "3G" msgstr "3G" -#: selfdrive/ui/layouts/sidebar.py:49 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "5G" msgstr "5G" -#: selfdrive/ui/layouts/settings/developer.py:23 -msgid "" -"WARNING: openpilot longitudinal control is in alpha for this car and will " -"disable Automatic Emergency Braking (AEB).

On this car, openpilot " -"defaults to the car's built-in ACC instead of openpilot's longitudinal " -"control. Enable this to switch to openpilot longitudinal control. Enabling " -"Experimental mode is recommended when enabling openpilot longitudinal " -"control alpha. Changing this setting will restart openpilot if the car is " -"powered on." -msgstr "" -"WARNUNG: Die Längsregelung von openpilot befindet sich für dieses " -"Fahrzeug in der Alpha-Phase und deaktiviert das automatische Notbremssystem " -"(AEB).

Auf diesem Fahrzeug verwendet openpilot standardmäßig den " -"integrierten ACC statt der openpilot-Längsregelung. Aktivieren Sie dies, um " -"auf die openpilot-Längsregelung umzuschalten. Das Aktivieren des " -"Experimentalmodus wird empfohlen, wenn Sie die openpilot-Längsregelung " -"(Alpha) aktivieren." - -#: selfdrive/ui/layouts/settings/device.py:148 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is complete." msgstr "

Kalibrierung der Lenkverzögerung abgeschlossen." -#: selfdrive/ui/layouts/settings/device.py:146 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is {}% complete." msgstr "

Kalibrierung der Lenkverzögerung zu {}% abgeschlossen." -#: selfdrive/ui/layouts/settings/firehose.py:138 -#, python-format -msgid "ACTIVE" -msgstr "AKTIV" - -#: selfdrive/ui/layouts/settings/developer.py:15 -msgid "" -"ADB (Android Debug Bridge) allows connecting to your device over USB or over " -"the network. See https://docs.comma.ai/how-to/connect-to-comma for more info." -msgstr "" -"ADB (Android Debug Bridge) ermöglicht die Verbindung mit Ihrem Gerät über " -"USB oder über das Netzwerk. Siehe https://docs.comma.ai/how-to/connect-to-" -"comma für weitere Informationen." - -#: selfdrive/ui/widgets/ssh_key.py:30 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "ADD" msgstr "HINZUFÜGEN" -#: system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "APN Setting" msgstr "APN‑Einstellung" -#: selfdrive/ui/widgets/offroad_alerts.py:109 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Acknowledge Excessive Actuation" msgstr "Übermäßige Betätigung bestätigen" -#: system/ui/widgets/network.py:74 system/ui/widgets/network.py:95 -#, python-format +#: system/ui/widgets/network.py msgid "Advanced" msgstr "Erweitert" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Aggressive" msgstr "Aggressiv" -#: selfdrive/ui/layouts/onboarding.py:116 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Agree" msgstr "Zustimmen" -#: selfdrive/ui/layouts/settings/toggles.py:70 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Always-On Driver Monitoring" msgstr "Immer aktive Fahrerüberwachung" -#: selfdrive/ui/layouts/settings/toggles.py:186 -#, python-format -msgid "" -"An alpha version of openpilot longitudinal control can be tested, along with " -"Experimental mode, on non-release branches." -msgstr "" -"Eine Alpha-Version der openpilot-Längsregelung kann zusammen mit dem " -"Experimentalmodus auf Nicht-Release-Zweigen getestet werden." - -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to power off?" msgstr "Sind Sie sicher, dass Sie ausschalten möchten?" -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reboot?" msgstr "Sind Sie sicher, dass Sie neu starten möchten?" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reset calibration?" msgstr "Sind Sie sicher, dass Sie die Kalibrierung zurücksetzen möchten?" -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Are you sure you want to uninstall?" msgstr "Sind Sie sicher, dass Sie deinstallieren möchten?" -#: system/ui/widgets/network.py:99 selfdrive/ui/layouts/onboarding.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +#: system/ui/widgets/network.py msgid "Back" msgstr "Zurück" -#: selfdrive/ui/widgets/prime.py:38 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Become a comma prime member at connect.comma.ai" msgstr "Werden Sie comma prime Mitglied auf connect.comma.ai" -#: selfdrive/ui/widgets/pairing_dialog.py:130 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Bookmark connect.comma.ai to your home screen to use it like an app" -msgstr "" -"Fügen Sie connect.comma.ai Ihrem Startbildschirm hinzu, um es wie eine App " -"zu verwenden" +msgstr "Fügen Sie connect.comma.ai Ihrem Startbildschirm hinzu, um es wie eine App zu verwenden" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "CHANGE" msgstr "ÄNDERN" -#: selfdrive/ui/layouts/settings/software.py:50 -#: selfdrive/ui/layouts/settings/software.py:107 -#: selfdrive/ui/layouts/settings/software.py:118 -#: selfdrive/ui/layouts/settings/software.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "CHECK" msgstr "PRÜFEN" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "CHILL MODE ON" msgstr "CHILL‑MODUS AKTIV" -#: system/ui/widgets/network.py:155 selfdrive/ui/layouts/sidebar.py:73 -#: selfdrive/ui/layouts/sidebar.py:134 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:138 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/network.py msgid "CONNECT" msgstr "VERBINDUNG" -#: system/ui/widgets/network.py:369 -#, python-format +#: system/ui/widgets/network.py msgid "CONNECTING..." msgstr "VERBINDUNG" -#: system/ui/widgets/confirm_dialog.py:23 system/ui/widgets/option_dialog.py:35 -#: system/ui/widgets/keyboard.py:81 system/ui/widgets/network.py:318 -#, python-format +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/keyboard.py +#: system/ui/widgets/network.py +#: system/ui/widgets/option_dialog.py msgid "Cancel" msgstr "Abbrechen" -#: system/ui/widgets/network.py:134 -#, python-format +#: system/ui/widgets/network.py msgid "Cellular Metered" msgstr "Getaktete Mobilfunkverbindung" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Change Language" msgstr "Sprache ändern" -#: selfdrive/ui/layouts/settings/toggles.py:125 -#, python-format -msgid "Changing this setting will restart openpilot if the car is powered on." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Changing this setting will restart sunnypilot if the car is powered on." msgstr "" -" Durch Ändern dieser Einstellung wird openpilot neu gestartet, wenn das Auto " -"eingeschaltet ist." -#: selfdrive/ui/widgets/pairing_dialog.py:129 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" msgstr "Klicken Sie auf \"add new device\" und scannen Sie den QR‑Code rechts" -#: selfdrive/ui/widgets/offroad_alerts.py:104 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Close" msgstr "Schließen" -#: selfdrive/ui/layouts/settings/software.py:49 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Current Version" msgstr "Aktuelle Version" -#: selfdrive/ui/layouts/settings/software.py:110 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "DOWNLOAD" msgstr "HERUNTERLADEN" -#: selfdrive/ui/layouts/onboarding.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Decline" msgstr "Ablehnen" -#: selfdrive/ui/layouts/onboarding.py:148 -#, python-format -msgid "Decline, uninstall openpilot" -msgstr "Ablehnen, openpilot deinstallieren" +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline, uninstall sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/settings.py:67 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" msgstr "Entwickler" -#: selfdrive/ui/layouts/settings/settings.py:62 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Device" msgstr "Gerät" -#: selfdrive/ui/layouts/settings/toggles.py:58 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Disengage on Accelerator Pedal" msgstr "Beim Gaspedal deaktivieren" -#: selfdrive/ui/layouts/settings/device.py:184 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Power Off" msgstr "Zum Ausschalten deaktivieren" -#: selfdrive/ui/layouts/settings/device.py:172 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reboot" msgstr "Zum Neustart deaktivieren" -#: selfdrive/ui/layouts/settings/device.py:103 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reset Calibration" msgstr "Zum Zurücksetzen der Kalibrierung deaktivieren" -#: selfdrive/ui/layouts/settings/toggles.py:32 +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Display speed in km/h instead of mph." msgstr "Geschwindigkeit in km/h statt mph anzeigen." -#: selfdrive/ui/layouts/settings/device.py:59 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Dongle ID" msgstr "Dongle-ID" -#: selfdrive/ui/layouts/settings/software.py:50 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Download" msgstr "Herunterladen" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Driver Camera" msgstr "Fahrerkamera" -#: selfdrive/ui/layouts/settings/toggles.py:96 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Driving Personality" msgstr "Fahrstil" -#: system/ui/widgets/network.py:123 system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "EDIT" msgstr "BEARBEITEN" -#: selfdrive/ui/layouts/sidebar.py:138 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ERROR" msgstr "FEHLER" -#: selfdrive/ui/layouts/sidebar.py:45 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ETH" msgstr "ETH" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "EXPERIMENTAL MODE ON" msgstr "EXPERIMENTALMODUS AKTIV" -#: selfdrive/ui/layouts/settings/developer.py:166 -#: selfdrive/ui/layouts/settings/toggles.py:228 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Aktivieren" -#: selfdrive/ui/layouts/settings/developer.py:39 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable ADB" msgstr "ADB aktivieren" -#: selfdrive/ui/layouts/settings/toggles.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable Lane Departure Warnings" msgstr "Spurverlassenswarnungen aktivieren" -#: system/ui/widgets/network.py:129 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Roaming" -msgstr "openpilot aktivieren" +msgstr "Roaming aktivieren" -#: selfdrive/ui/layouts/settings/developer.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable SSH" msgstr "SSH aktivieren" -#: system/ui/widgets/network.py:120 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Tethering" -msgstr "Spurverlassenswarnungen aktivieren" +msgstr "Tethering aktivieren" -#: selfdrive/ui/layouts/settings/toggles.py:30 -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "Fahrerüberwachung auch aktivieren, wenn openpilot nicht aktiv ist." - -#: selfdrive/ui/layouts/settings/toggles.py:46 -#, python-format -msgid "Enable openpilot" -msgstr "openpilot aktivieren" - -#: selfdrive/ui/layouts/settings/toggles.py:189 -#, python-format -msgid "" -"Enable the openpilot longitudinal control (alpha) toggle to allow " -"Experimental mode." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable driver monitoring even when sunnypilot is not engaged." msgstr "" -"Den Schalter für die openpilot-Längsregelung (Alpha) aktivieren, um den " -"Experimentalmodus zu erlauben." -#: system/ui/widgets/network.py:204 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable sunnypilot" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" + +#: system/ui/widgets/network.py msgid "Enter APN" msgstr "APN eingeben" -#: system/ui/widgets/network.py:241 -#, python-format +#: system/ui/widgets/network.py msgid "Enter SSID" msgstr "SSID eingeben" -#: system/ui/widgets/network.py:254 -#, python-format +#: system/ui/widgets/network.py msgid "Enter new tethering password" msgstr "Neues Tethering‑Passwort eingeben" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Enter password" msgstr "Passwort eingeben" -#: selfdrive/ui/widgets/ssh_key.py:89 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "Geben Sie Ihren GitHub‑Benutzernamen ein" -#: system/ui/widgets/list_view.py:123 system/ui/widgets/list_view.py:160 -#, python-format +#: system/ui/widgets/list_view.py msgid "Error" msgstr "Fehler" -#: selfdrive/ui/layouts/settings/toggles.py:52 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Experimental Mode" msgstr "Experimentalmodus" -#: selfdrive/ui/layouts/settings/toggles.py:181 -#, python-format -msgid "" -"Experimental mode is currently unavailable on this car since the car's stock " -"ACC is used for longitudinal control." -msgstr "" -"Der Experimentalmodus ist derzeit auf diesem Fahrzeug nicht verfügbar, da " -"der serienmäßige ACC für die Längsregelung verwendet wird." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." +msgstr "Der Experimentalmodus ist derzeit auf diesem Fahrzeug nicht verfügbar, da der serienmäßige ACC für die Längsregelung verwendet wird." -#: system/ui/widgets/network.py:373 -#, python-format +#: system/ui/widgets/network.py msgid "FORGETTING..." msgstr "WIRD VERGESSEN..." -#: selfdrive/ui/widgets/setup.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Finish Setup" msgstr "Einrichtung abschließen" -#: selfdrive/ui/layouts/settings/settings.py:66 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Firehose" -msgstr "Firehose" +msgstr "Datenstrom" -#: selfdrive/ui/layouts/settings/firehose.py:18 +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "Firehose Mode" msgstr "Firehose‑Modus" -#: selfdrive/ui/layouts/settings/firehose.py:25 -msgid "" -"For maximum effectiveness, bring your device inside and connect to a good " -"USB-C adapter and Wi-Fi weekly.\n" -"\n" -"Firehose Mode can also work while you're driving if connected to a hotspot " -"or unlimited SIM card.\n" -"\n" -"\n" -"Frequently Asked Questions\n" -"\n" -"Does it matter how or where I drive? Nope, just drive as you normally " -"would.\n" -"\n" -"Do all of my segments get pulled in Firehose Mode? No, we selectively pull a " -"subset of your segments.\n" -"\n" -"What's a good USB-C adapter? Any fast phone or laptop charger should be " -"fine.\n" -"\n" -"Does it matter which software I run? Yes, only upstream openpilot (and " -"particular forks) are able to be used for training." +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" msgstr "" -"Für maximale Wirksamkeit bringen Sie Ihr Gerät regelmäßig ins Haus und " -"verbinden es wöchentlich mit einem guten USB‑C‑Adapter und WLAN.\n" -"\n" -"Der Firehose‑Modus kann auch während der Fahrt funktionieren, wenn eine " -"Verbindung zu einem Hotspot oder einer unbegrenzten SIM besteht.\n" -"\n" -"\n" -"Häufig gestellte Fragen\n" -"\n" -"Spielt es eine Rolle, wie oder wo ich fahre? Nein, fahren Sie einfach wie " -"gewöhnlich.\n" -"\n" -"Werden alle meine Segmente im Firehose‑Modus abgeholt? Nein, wir ziehen " -"selektiv eine Teilmenge Ihrer Segmente.\n" -"\n" -"Was ist ein guter USB‑C‑Adapter? Jeder schnelle Telefon‑ oder Laptoplader " -"sollte ausreichen.\n" -"\n" -"Spielt es eine Rolle, welche Software ich verwende? Ja, nur " -"Upstream‑openpilot (und bestimmte Forks) können für das Training verwendet " -"werden." -#: system/ui/widgets/network.py:318 system/ui/widgets/network.py:451 -#, python-format +#: system/ui/widgets/network.py msgid "Forget" msgstr "Vergessen" -#: system/ui/widgets/network.py:319 -#, python-format +#: system/ui/widgets/network.py msgid "Forget Wi-Fi Network \"{}\"?" msgstr "WLAN‑Netz „{}“ vergessen?" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "GOOD" msgstr "GUT" -#: selfdrive/ui/widgets/pairing_dialog.py:128 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Go to https://connect.comma.ai on your phone" msgstr "Gehen Sie auf Ihrem Telefon zu https://connect.comma.ai" -#: selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "HIGH" msgstr "HOCH" -#: system/ui/widgets/network.py:155 -#, python-format +#: system/ui/widgets/network.py msgid "Hidden Network" -msgstr "Netzwerk" +msgstr "Verstecktes Netzwerk" -#: selfdrive/ui/layouts/settings/firehose.py:140 -#, python-format -msgid "INACTIVE: connect to an unmetered network" -msgstr "INAKTIV: Mit einem unlimitierten Netzwerk verbinden" - -#: selfdrive/ui/layouts/settings/software.py:53 -#: selfdrive/ui/layouts/settings/software.py:136 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "INSTALL" msgstr "INSTALLIEREN" -#: system/ui/widgets/network.py:150 -#, python-format +#: system/ui/widgets/network.py msgid "IP Address" msgstr "IP‑Adresse" -#: selfdrive/ui/layouts/settings/software.py:53 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Update installieren" -#: selfdrive/ui/layouts/settings/developer.py:56 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "Joystick‑Debugmodus" -#: selfdrive/ui/widgets/ssh_key.py:29 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "LOADING" msgstr "LADEN" -#: selfdrive/ui/layouts/sidebar.py:48 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "LTE" msgstr "LTE" -#: selfdrive/ui/layouts/settings/developer.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "Längsmanövermodus" -#: selfdrive/ui/onroad/hud_renderer.py:148 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "MAX" -msgstr "MAX" +msgstr "MAX." -#: selfdrive/ui/widgets/setup.py:75 -#, python-format -msgid "" -"Maximize your training data uploads to improve openpilot's driving models." -msgstr "" -"Maximieren Sie Ihre Trainingsdaten‑Uploads, um die Fahrmodelle von openpilot " -"zu verbessern." +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Maximize your training data uploads to improve openpilot's driving models." +msgstr "Maximieren Sie Ihre Trainingsdaten‑Uploads, um die Fahrmodelle von openpilot zu verbessern." -#: selfdrive/ui/layouts/settings/device.py:59 -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "N/A" msgstr "k. A." -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "NO" msgstr "KEIN" -#: selfdrive/ui/layouts/settings/settings.py:63 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Network" msgstr "Netzwerk" -#: selfdrive/ui/widgets/ssh_key.py:114 -#, python-format -msgid "No SSH keys found" -msgstr "Keine SSH‑Schlüssel gefunden" - -#: selfdrive/ui/widgets/ssh_key.py:126 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "No SSH keys found for user '{}'" -msgstr "Keine SSH‑Schlüssel für Benutzer '{username}' gefunden" +msgstr "Keine SSH‑Schlüssel für Benutzer '{}' gefunden" -#: selfdrive/ui/widgets/offroad_alerts.py:320 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "No release notes available." msgstr "Keine Versionshinweise verfügbar." -#: selfdrive/ui/layouts/sidebar.py:73 selfdrive/ui/layouts/sidebar.py:134 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "OFFLINE" -msgstr "OFFLINE" +msgstr "GETRENNT" -#: system/ui/widgets/html_render.py:263 system/ui/widgets/confirm_dialog.py:93 -#: selfdrive/ui/layouts/sidebar.py:127 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/html_render.py msgid "OK" msgstr "OK" -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ONLINE" -msgstr "ONLINE" +msgstr "VERBUNDEN" -#: selfdrive/ui/widgets/setup.py:20 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Open" msgstr "Öffnen" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PAIR" msgstr "KOPPELN" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "PANDA" msgstr "PANDA" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PREVIEW" msgstr "VORSCHAU" -#: selfdrive/ui/widgets/prime.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "PRIME FEATURES:" msgstr "PRIME‑FUNKTIONEN:" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Pair Device" msgstr "Gerät koppeln" -#: selfdrive/ui/widgets/setup.py:19 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Pair device" msgstr "Gerät koppeln" -#: selfdrive/ui/widgets/pairing_dialog.py:103 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "Koppeln Sie Ihr Gerät mit Ihrem comma‑Konto" -#: selfdrive/ui/widgets/setup.py:48 selfdrive/ui/layouts/settings/device.py:24 -#, python-format -msgid "" -"Pair your device with comma connect (connect.comma.ai) and claim your comma " -"prime offer." -msgstr "" -"Koppeln Sie Ihr Gerät mit comma connect (connect.comma.ai) und lösen Sie Ihr " -"comma‑prime‑Angebot ein." +#: openpilot/selfdrive/ui/layouts/settings/device.py +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." +msgstr "Koppeln Sie Ihr Gerät mit comma connect (connect.comma.ai) und lösen Sie Ihr comma‑prime‑Angebot ein." -#: selfdrive/ui/widgets/setup.py:91 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Please connect to Wi-Fi to complete initial pairing" msgstr "Bitte mit WLAN verbinden, um das erste Koppeln abzuschließen" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Power Off" msgstr "Ausschalten" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered Wi-Fi connection" -msgstr "" +msgstr "Verhindern Sie das Hochladen großer Datenmengen bei einer getakteten WLAN-Verbindung" -#: system/ui/widgets/network.py:135 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered cellular connection" -msgstr "" +msgstr "Verhindern Sie das Hochladen großer Datenmengen bei einer getakteten Mobilfunkverbindung" -#: selfdrive/ui/layouts/settings/device.py:25 -msgid "" -"Preview the driver facing camera to ensure that driver monitoring has good " -"visibility. (vehicle must be off)" -msgstr "" -"Vorschau der Fahrer‑Kamera, um sicherzustellen, dass die Fahrerüberwachung " -"gute Sicht hat. (Fahrzeug muss ausgeschaltet sein)" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)" +msgstr "Vorschau der Fahrer‑Kamera, um sicherzustellen, dass die Fahrerüberwachung gute Sicht hat. (Fahrzeug muss ausgeschaltet sein)" -#: selfdrive/ui/widgets/pairing_dialog.py:161 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "QR Code Error" msgstr "QR‑Code‑Fehler" -#: selfdrive/ui/widgets/ssh_key.py:31 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "REMOVE" msgstr "ENTFERNEN" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "RESET" msgstr "ZURÜCKSETZEN" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "REVIEW" msgstr "ANSEHEN" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "Neustart" -#: selfdrive/ui/onroad/alert_renderer.py:66 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Reboot Device" msgstr "Gerät neu starten" -#: selfdrive/ui/widgets/offroad_alerts.py:112 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Reboot and Update" msgstr "Neustarten und aktualisieren" -#: selfdrive/ui/layouts/settings/toggles.py:27 -msgid "" -"Receive alerts to steer back into the lane when your vehicle drifts over a " -"detected lane line without a turn signal activated while driving over 31 mph " -"(50 km/h)." -msgstr "" -"Erhalten Sie Warnungen, um zurück in die Spur zu lenken, wenn Ihr Fahrzeug " -"ohne Blinker über eine erkannte Spurlinie driftet und über 31 mph (50 km/h) " -"fährt." - -#: selfdrive/ui/layouts/settings/toggles.py:76 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Driver Camera" msgstr "Fahrerkamera aufzeichnen und hochladen" -#: selfdrive/ui/layouts/settings/toggles.py:82 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Microphone Audio" msgstr "Mikrofonton aufzeichnen und hochladen" -#: selfdrive/ui/layouts/settings/toggles.py:33 -msgid "" -"Record and store microphone audio while driving. The audio will be included " -"in the dashcam video in comma connect." -msgstr "" -"Mikrofonton während der Fahrt aufzeichnen und speichern. Die Audiospur wird " -"im Dashcam‑Video in comma connect enthalten sein." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect." +msgstr "Mikrofonton während der Fahrt aufzeichnen und speichern. Die Audiospur wird im Dashcam‑Video in comma connect enthalten sein." -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Regulatory" msgstr "Vorschriften" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Relaxed" msgstr "Entspannt" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote access" msgstr "Fernzugriff" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote snapshots" msgstr "Remote‑Schnappschüsse" -#: selfdrive/ui/widgets/ssh_key.py:123 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Request timed out" msgstr "Zeitüberschreitung bei der Anfrage" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset" msgstr "Zurücksetzen" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset Calibration" msgstr "Kalibrierung zurücksetzen" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Review Training Guide" msgstr "Trainingsanleitung ansehen" -#: selfdrive/ui/layouts/settings/device.py:27 -msgid "Review the rules, features, and limitations of openpilot" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Review the rules, features, and limitations of sunnypilot" msgstr "" -"Überprüfen Sie die Regeln, Funktionen und Einschränkungen von openpilot" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" -msgstr "" +msgstr "WÄHLEN" -#: selfdrive/ui/layouts/settings/developer.py:53 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "SSH Keys" msgstr "SSH‑Schlüssel" -#: system/ui/widgets/network.py:310 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + +#: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "WLAN‑Netzwerke werden gesucht..." -#: system/ui/widgets/option_dialog.py:36 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + +#: system/ui/widgets/option_dialog.py msgid "Select" msgstr "Auswählen" -#: selfdrive/ui/layouts/settings/software.py:183 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Select a branch" -msgstr "" +msgstr "Zweig auswählen" -#: selfdrive/ui/layouts/settings/device.py:91 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Select a language" msgstr "Sprache auswählen" -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Serial" msgstr "Seriennummer" -#: selfdrive/ui/widgets/offroad_alerts.py:106 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Snooze Update" msgstr "Update verschieben" -#: selfdrive/ui/layouts/settings/settings.py:65 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Software" -msgstr "Software" +msgstr "Softwarebereich" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Standard" -msgstr "Standard" +msgstr "Standardmodus" -#: selfdrive/ui/layouts/settings/toggles.py:22 -msgid "" -"Standard is recommended. In aggressive mode, openpilot will follow lead cars " -"closer and be more aggressive with the gas and brake. In relaxed mode " -"openpilot will stay further away from lead cars. On supported cars, you can " -"cycle through these personalities with your steering wheel distance button." -msgstr "" -"Standard wird empfohlen. Im aggressiven Modus folgt openpilot " -"vorausfahrenden Fahrzeugen näher und ist beim Gasgeben und Bremsen " -"aggressiver. Im entspannten Modus bleibt openpilot weiter entfernt. Bei " -"unterstützten Fahrzeugen können Sie mit der Abstandstaste am Lenkrad " -"zwischen diesen Profilen wechseln." - -#: selfdrive/ui/onroad/alert_renderer.py:59 -#: selfdrive/ui/onroad/alert_renderer.py:65 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "System Unresponsive" msgstr "System reagiert nicht" -#: selfdrive/ui/onroad/alert_renderer.py:58 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "TAKE CONTROL IMMEDIATELY" msgstr "SOFORT DIE KONTROLLE ÜBERNEHMEN" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 -#: selfdrive/ui/layouts/sidebar.py:127 selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "TEMP" -msgstr "TEMP" +msgstr "TEMP." -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Target Branch" -msgstr "" +msgstr "Zielzweig" -#: system/ui/widgets/network.py:124 -#, python-format +#: system/ui/widgets/network.py msgid "Tethering Password" msgstr "Tethering‑Passwort" -#: selfdrive/ui/layouts/settings/settings.py:64 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Toggles" msgstr "Schalter" -#: selfdrive/ui/layouts/settings/software.py:72 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "UI Debug Mode" +msgstr "UI-Debug-Modus" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "UNINSTALL" msgstr "DEINSTALLIEREN" -#: selfdrive/ui/layouts/home.py:155 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "UPDATE" -msgstr "UPDATE" +msgstr "AKTUALISIEREN" -#: selfdrive/ui/layouts/settings/software.py:72 -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Uninstall" msgstr "Deinstallieren" -#: selfdrive/ui/layouts/sidebar.py:117 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Unknown" msgstr "Unbekannt" -#: selfdrive/ui/layouts/settings/software.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Updates are only downloaded while the car is off." msgstr "Updates werden nur heruntergeladen, wenn das Auto aus ist." -#: selfdrive/ui/widgets/prime.py:33 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Upgrade Now" msgstr "Jetzt abonnieren" -#: selfdrive/ui/layouts/settings/toggles.py:31 -msgid "" -"Upload data from the driver facing camera and help improve the driver " -"monitoring algorithm." -msgstr "" -"Daten von der Fahrer‑Kamera hochladen und den Fahrerüberwachungs‑Algorithmus " -"verbessern." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." +msgstr "Daten von der Fahrer‑Kamera hochladen und den Fahrerüberwachungs‑Algorithmus verbessern." -#: selfdrive/ui/layouts/settings/toggles.py:88 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Use Metric System" msgstr "Metersystem verwenden" -#: selfdrive/ui/layouts/settings/toggles.py:17 -msgid "" -"Use the openpilot system for adaptive cruise control and lane keep driver " -"assistance. Your attention is required at all times to use this feature." -msgstr "" -"Verwenden Sie openpilot für adaptive Geschwindigkeitsregelung und " -"Spurhalteassistenz. Ihre Aufmerksamkeit ist jederzeit erforderlich, um diese " -"Funktion zu nutzen." - -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "VEHICLE" msgstr "FAHRZEUG" -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "VIEW" msgstr "ANSEHEN" -#: selfdrive/ui/onroad/alert_renderer.py:52 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Waiting to start" msgstr "Warten auf Start" -#: selfdrive/ui/layouts/settings/developer.py:19 -msgid "" -"Warning: This grants SSH access to all public keys in your GitHub settings. " -"Never enter a GitHub username other than your own. A comma employee will " -"NEVER ask you to add their GitHub username." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Welcome to sunnypilot" msgstr "" -"Warnung: Dies gewährt SSH‑Zugriff auf alle öffentlichen Schlüssel in Ihren " -"GitHub‑Einstellungen. Geben Sie niemals einen anderen GitHub‑Benutzernamen " -"als Ihren eigenen ein. Ein comma‑Mitarbeiter wird Sie NIEMALS bitten, seinen " -"GitHub‑Benutzernamen hinzuzufügen." -#: selfdrive/ui/layouts/onboarding.py:111 -#, python-format -msgid "Welcome to openpilot" -msgstr "Willkommen bei openpilot" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:20 -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "Wenn aktiviert, deaktiviert das Drücken des Gaspedals openpilot." - -#: selfdrive/ui/layouts/sidebar.py:44 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" msgstr "WLAN" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Wi-Fi Network Metered" msgstr "Getaktetes WLAN‑Netzwerk" -#: system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Wrong password" msgstr "Falsches Passwort" -#: selfdrive/ui/layouts/onboarding.py:145 -#, python-format -msgid "You must accept the Terms and Conditions in order to use openpilot." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service in order to use sunnypilot." msgstr "" -"Sie müssen die Nutzungsbedingungen akzeptieren, um openpilot zu verwenden." -#: selfdrive/ui/layouts/onboarding.py:112 -#, python-format -msgid "" -"You must accept the Terms and Conditions to use openpilot. Read the latest " -"terms at https://comma.ai/terms before continuing." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." msgstr "" -"Sie müssen die Nutzungsbedingungen akzeptieren, um openpilot zu verwenden. " -"Lesen Sie die aktuellen Bedingungen unter https://comma.ai/terms, bevor Sie " -"fortfahren." -#: selfdrive/ui/onroad/driver_camera_dialog.py:34 -#, python-format +#: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" msgstr "Kamera startet" -#: selfdrive/ui/widgets/prime.py:63 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "checking..." +msgstr "Überprüfung..." + +#: openpilot/selfdrive/ui/widgets/prime.py msgid "comma prime" msgstr "comma prime" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "default" msgstr "Standard" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "down" msgstr "unten" -#: selfdrive/ui/layouts/settings/software.py:106 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "downloading..." +msgstr "Herunterladen..." + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "failed to check for update" msgstr "Überprüfung auf Updates fehlgeschlagen" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "finalizing update..." +msgstr "Update wird finalisiert..." + +#: system/ui/widgets/network.py msgid "for \"{}\"" msgstr "für „{}“" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "leave blank for automatic configuration" msgstr "für automatische Konfiguration leer lassen" -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "left" msgstr "links" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "metered" msgstr "getaktet" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" -#: selfdrive/ui/layouts/settings/software.py:20 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "never" msgstr "nie" -#: selfdrive/ui/layouts/settings/software.py:31 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "now" msgstr "jetzt" -#: selfdrive/ui/layouts/settings/developer.py:71 -#, python-format -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "openpilot Längsregelung (Alpha)" - -#: selfdrive/ui/onroad/alert_renderer.py:51 -#, python-format -msgid "openpilot Unavailable" -msgstr "openpilot nicht verfügbar" - -#: selfdrive/ui/layouts/settings/toggles.py:158 -#, python-format -msgid "" -"openpilot defaults to driving in chill mode. Experimental mode enables alpha-" -"level features that aren't ready for chill mode. Experimental features are " -"listed below:

End-to-End Longitudinal Control


Let the driving " -"model control the gas and brakes. openpilot will drive as it thinks a human " -"would, including stopping for red lights and stop signs. Since the driving " -"model decides the speed to drive, the set speed will only act as an upper " -"bound. This is an alpha quality feature; mistakes should be expected." -"

New Driving Visualization


The driving visualization will " -"transition to the road-facing wide-angle camera at low speeds to better show " -"some turns. The Experimental mode logo will also be shown in the top right " -"corner." -msgstr "" -"openpilot fährt standardmäßig im Chill‑Modus. Der Experimentalmodus " -"aktiviert Funktionen im Alpha‑Status, die für den Chill‑Modus noch nicht " -"bereit sind. Die experimentellen Funktionen sind unten aufgeführt:" -"

End-to‑End‑Längsregelung


Das Fahrmodell steuert Gas und " -"Bremse. openpilot fährt so, wie es einen Menschen einschätzt, einschließlich " -"Anhalten an roten Ampeln und Stoppschildern. Da das Modell die " -"Geschwindigkeit bestimmt, dient die eingestellte Geschwindigkeit nur als " -"Obergrenze. Dies ist eine Alpha‑Funktion; Fehler sind zu erwarten." -"

Neue Fahrvisualisierung


Die Visualisierung wechselt bei " -"niedriger Geschwindigkeit auf die nach vorn gerichtete Weitwinkelkamera, um " -"manche Kurven besser zu zeigen. Das Experimentalmodus‑Logo wird außerdem " -"oben rechts angezeigt." - -#: selfdrive/ui/layouts/settings/device.py:165 -#, python-format -msgid "" -"openpilot is continuously calibrating, resetting is rarely required. " -"Resetting calibration will restart openpilot if the car is powered on." -msgstr "" -" Durch Ändern dieser Einstellung wird openpilot neu gestartet, wenn das Auto " -"eingeschaltet ist." - -#: selfdrive/ui/layouts/settings/firehose.py:20 -msgid "" -"openpilot learns to drive by watching humans, like you, drive.\n" -"\n" -"Firehose Mode allows you to maximize your training data uploads to improve " -"openpilot's driving models. More data means bigger models, which means " -"better Experimental Mode." -msgstr "" -"openpilot lernt das Fahren, indem es Menschen wie Sie beobachtet.\n" -"\n" -"Der Firehose‑Modus ermöglicht es Ihnen, Ihre Trainingsdaten‑Uploads zu " -"maximieren, um die Fahrmodelle von openpilot zu verbessern. Mehr Daten " -"bedeuten größere Modelle – und damit einen besseren Experimentalmodus." - -#: selfdrive/ui/layouts/settings/toggles.py:183 -#, python-format -msgid "openpilot longitudinal control may come in a future update." -msgstr "Die openpilot‑Längsregelung könnte in einem zukünftigen Update kommen." - -#: selfdrive/ui/layouts/settings/device.py:26 -msgid "" -"openpilot requires the device to be mounted within 4° left or right and " -"within 5° up or 9° down." -msgstr "" -"openpilot erfordert, dass das Gerät innerhalb von 4° nach links oder rechts " -"und innerhalb von 5° nach oben oder 9° nach unten montiert ist." - -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "rechts" -#: system/ui/widgets/network.py:142 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + +#: system/ui/widgets/network.py msgid "unmetered" msgstr "unbegrenzt" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "up" msgstr "oben" -#: selfdrive/ui/layouts/settings/software.py:117 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked never" msgstr "Aktuell, zuletzt geprüft: nie" -#: selfdrive/ui/layouts/settings/software.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked {}" msgstr "Aktuell, zuletzt geprüft: {}" -#: selfdrive/ui/layouts/settings/software.py:109 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "update available" msgstr "Update verfügbar" -#: selfdrive/ui/layouts/home.py:169 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "{} ALERT" msgid_plural "{} ALERTS" msgstr[0] "{} WARNUNG" msgstr[1] "{} WARNUNGEN" -#: selfdrive/ui/layouts/settings/software.py:40 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "vor {} Tag" msgstr[1] "vor {} Tagen" -#: selfdrive/ui/layouts/settings/software.py:37 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "vor {} Stunde" msgstr[1] "vor {} Stunden" -#: selfdrive/ui/layouts/settings/software.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} minute ago" msgid_plural "{} minutes ago" msgstr[0] "vor {} Minute" msgstr[1] "vor {} Minuten" -#: selfdrive/ui/layouts/settings/firehose.py:111 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "{} segment of your driving is in the training dataset so far." msgid_plural "{} segments of your driving is in the training dataset so far." msgstr[0] "{} Segment Ihrer Fahrten ist bisher im Trainingsdatensatz." msgstr[1] "{} Segmente Ihrer Fahrten sind bisher im Trainingsdatensatz." -#: selfdrive/ui/widgets/prime.py:62 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "✓ SUBSCRIBED" msgstr "✓ ABONNIERT" -#: selfdrive/ui/widgets/setup.py:22 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "🔥 Firehose Mode 🔥" msgstr "🔥 Firehose‑Modus 🔥" + diff --git a/selfdrive/ui/translations/app_en.po b/selfdrive/ui/translations/app_en.po index 6fbb537aff..ad2ed1241f 100644 --- a/selfdrive/ui/translations/app_en.po +++ b/selfdrive/ui/translations/app_en.po @@ -1,1207 +1,873 @@ -# English translations for PACKAGE package. -# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# Automatically generated, 2025. -# msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 00:50-0700\n" -"PO-Revision-Date: 2025-10-21 18:18-0700\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" -"Language: en\n" -"MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" +"Language: en\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: selfdrive/ui/layouts/settings/device.py:160 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is complete." msgstr " Steering torque response calibration is complete." -#: selfdrive/ui/layouts/settings/device.py:158 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is {}% complete." msgstr " Steering torque response calibration is {}% complete." -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Your device is pointed {:.1f}° {} and {:.1f}° {}." msgstr " Your device is pointed {:.1f}° {} and {:.1f}° {}." -#: selfdrive/ui/layouts/sidebar.py:43 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "--" msgstr "--" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "1 year of drive storage" msgstr "1 year of drive storage" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "24/7 LTE connectivity" msgstr "24/7 LTE connectivity" -#: selfdrive/ui/layouts/sidebar.py:46 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "2G" msgstr "2G" -#: selfdrive/ui/layouts/sidebar.py:47 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "3G" msgstr "3G" -#: selfdrive/ui/layouts/sidebar.py:49 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "5G" msgstr "5G" -#: selfdrive/ui/layouts/settings/developer.py:23 -msgid "" -"WARNING: openpilot longitudinal control is in alpha for this car and will " -"disable Automatic Emergency Braking (AEB).

On this car, openpilot " -"defaults to the car's built-in ACC instead of openpilot's longitudinal " -"control. Enable this to switch to openpilot longitudinal control. Enabling " -"Experimental mode is recommended when enabling openpilot longitudinal " -"control alpha. Changing this setting will restart openpilot if the car is " -"powered on." -msgstr "" -"WARNING: openpilot longitudinal control is in alpha for this car and will " -"disable Automatic Emergency Braking (AEB).

On this car, openpilot " -"defaults to the car's built-in ACC instead of openpilot's longitudinal " -"control. Enable this to switch to openpilot longitudinal control. Enabling " -"Experimental mode is recommended when enabling openpilot longitudinal " -"control alpha. Changing this setting will restart openpilot if the car is " -"powered on." - -#: selfdrive/ui/layouts/settings/device.py:148 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is complete." msgstr "

Steering lag calibration is complete." -#: selfdrive/ui/layouts/settings/device.py:146 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is {}% complete." msgstr "

Steering lag calibration is {}% complete." -#: selfdrive/ui/layouts/settings/firehose.py:138 -#, python-format -msgid "ACTIVE" -msgstr "ACTIVE" - -#: selfdrive/ui/layouts/settings/developer.py:15 -msgid "" -"ADB (Android Debug Bridge) allows connecting to your device over USB or over " -"the network. See https://docs.comma.ai/how-to/connect-to-comma for more info." -msgstr "" -"ADB (Android Debug Bridge) allows connecting to your device over USB or over " -"the network. See https://docs.comma.ai/how-to/connect-to-comma for more info." - -#: selfdrive/ui/widgets/ssh_key.py:30 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "ADD" msgstr "ADD" -#: system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "APN Setting" msgstr "APN Setting" -#: selfdrive/ui/widgets/offroad_alerts.py:109 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Acknowledge Excessive Actuation" msgstr "Acknowledge Excessive Actuation" -#: system/ui/widgets/network.py:74 system/ui/widgets/network.py:95 -#, python-format +#: system/ui/widgets/network.py msgid "Advanced" msgstr "Advanced" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Aggressive" msgstr "Aggressive" -#: selfdrive/ui/layouts/onboarding.py:116 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Agree" msgstr "Agree" -#: selfdrive/ui/layouts/settings/toggles.py:70 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Always-On Driver Monitoring" msgstr "Always-On Driver Monitoring" -#: selfdrive/ui/layouts/settings/toggles.py:186 -#, python-format -msgid "" -"An alpha version of openpilot longitudinal control can be tested, along with " -"Experimental mode, on non-release branches." -msgstr "" -"An alpha version of openpilot longitudinal control can be tested, along with " -"Experimental mode, on non-release branches." - -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to power off?" msgstr "Are you sure you want to power off?" -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reboot?" msgstr "Are you sure you want to reboot?" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reset calibration?" msgstr "Are you sure you want to reset calibration?" -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Are you sure you want to uninstall?" msgstr "Are you sure you want to uninstall?" -#: system/ui/widgets/network.py:99 selfdrive/ui/layouts/onboarding.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +#: system/ui/widgets/network.py msgid "Back" msgstr "Back" -#: selfdrive/ui/widgets/prime.py:38 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Become a comma prime member at connect.comma.ai" msgstr "Become a comma prime member at connect.comma.ai" -#: selfdrive/ui/widgets/pairing_dialog.py:130 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Bookmark connect.comma.ai to your home screen to use it like an app" msgstr "Bookmark connect.comma.ai to your home screen to use it like an app" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "CHANGE" msgstr "CHANGE" -#: selfdrive/ui/layouts/settings/software.py:50 -#: selfdrive/ui/layouts/settings/software.py:107 -#: selfdrive/ui/layouts/settings/software.py:118 -#: selfdrive/ui/layouts/settings/software.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "CHECK" msgstr "CHECK" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "CHILL MODE ON" msgstr "CHILL MODE ON" -#: system/ui/widgets/network.py:155 selfdrive/ui/layouts/sidebar.py:73 -#: selfdrive/ui/layouts/sidebar.py:134 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:138 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/network.py msgid "CONNECT" msgstr "CONNECT" -#: system/ui/widgets/network.py:369 -#, python-format +#: system/ui/widgets/network.py msgid "CONNECTING..." msgstr "CONNECTING..." -#: system/ui/widgets/confirm_dialog.py:23 system/ui/widgets/option_dialog.py:35 -#: system/ui/widgets/keyboard.py:81 system/ui/widgets/network.py:318 -#, python-format +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/keyboard.py +#: system/ui/widgets/network.py +#: system/ui/widgets/option_dialog.py msgid "Cancel" msgstr "Cancel" -#: system/ui/widgets/network.py:134 -#, python-format +#: system/ui/widgets/network.py msgid "Cellular Metered" msgstr "Cellular Metered" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Change Language" msgstr "Change Language" -#: selfdrive/ui/layouts/settings/toggles.py:125 -#, python-format -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "Changing this setting will restart openpilot if the car is powered on." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" -#: selfdrive/ui/widgets/pairing_dialog.py:129 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" msgstr "Click \"add new device\" and scan the QR code on the right" -#: selfdrive/ui/widgets/offroad_alerts.py:104 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Close" msgstr "Close" -#: selfdrive/ui/layouts/settings/software.py:49 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Current Version" msgstr "Current Version" -#: selfdrive/ui/layouts/settings/software.py:110 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "DOWNLOAD" msgstr "DOWNLOAD" -#: selfdrive/ui/layouts/onboarding.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Decline" msgstr "Decline" -#: selfdrive/ui/layouts/onboarding.py:148 -#, python-format -msgid "Decline, uninstall openpilot" -msgstr "Decline, uninstall openpilot" +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline, uninstall sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/settings.py:67 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" msgstr "Developer" -#: selfdrive/ui/layouts/settings/settings.py:62 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Device" msgstr "Device" -#: selfdrive/ui/layouts/settings/toggles.py:58 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Disengage on Accelerator Pedal" msgstr "Disengage on Accelerator Pedal" -#: selfdrive/ui/layouts/settings/device.py:184 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Power Off" msgstr "Disengage to Power Off" -#: selfdrive/ui/layouts/settings/device.py:172 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reboot" msgstr "Disengage to Reboot" -#: selfdrive/ui/layouts/settings/device.py:103 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reset Calibration" msgstr "Disengage to Reset Calibration" -#: selfdrive/ui/layouts/settings/toggles.py:32 +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Display speed in km/h instead of mph." msgstr "Display speed in km/h instead of mph." -#: selfdrive/ui/layouts/settings/device.py:59 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Dongle ID" msgstr "Dongle ID" -#: selfdrive/ui/layouts/settings/software.py:50 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Download" msgstr "Download" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Driver Camera" msgstr "Driver Camera" -#: selfdrive/ui/layouts/settings/toggles.py:96 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Driving Personality" msgstr "Driving Personality" -#: system/ui/widgets/network.py:123 system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "EDIT" msgstr "EDIT" -#: selfdrive/ui/layouts/sidebar.py:138 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ERROR" msgstr "ERROR" -#: selfdrive/ui/layouts/sidebar.py:45 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ETH" msgstr "ETH" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "EXPERIMENTAL MODE ON" msgstr "EXPERIMENTAL MODE ON" -#: selfdrive/ui/layouts/settings/developer.py:166 -#: selfdrive/ui/layouts/settings/toggles.py:228 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Enable" -#: selfdrive/ui/layouts/settings/developer.py:39 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable ADB" msgstr "Enable ADB" -#: selfdrive/ui/layouts/settings/toggles.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable Lane Departure Warnings" msgstr "Enable Lane Departure Warnings" -#: system/ui/widgets/network.py:129 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Roaming" msgstr "Enable Roaming" -#: selfdrive/ui/layouts/settings/developer.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable SSH" msgstr "Enable SSH" -#: system/ui/widgets/network.py:120 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Tethering" msgstr "Enable Tethering" -#: selfdrive/ui/layouts/settings/toggles.py:30 -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "Enable driver monitoring even when openpilot is not engaged." - -#: selfdrive/ui/layouts/settings/toggles.py:46 -#, python-format -msgid "Enable openpilot" -msgstr "Enable openpilot" - -#: selfdrive/ui/layouts/settings/toggles.py:189 -#, python-format -msgid "" -"Enable the openpilot longitudinal control (alpha) toggle to allow " -"Experimental mode." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable driver monitoring even when sunnypilot is not engaged." msgstr "" -"Enable the openpilot longitudinal control (alpha) toggle to allow " -"Experimental mode." -#: system/ui/widgets/network.py:204 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable sunnypilot" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" + +#: system/ui/widgets/network.py msgid "Enter APN" msgstr "Enter APN" -#: system/ui/widgets/network.py:241 -#, python-format +#: system/ui/widgets/network.py msgid "Enter SSID" msgstr "Enter SSID" -#: system/ui/widgets/network.py:254 -#, python-format +#: system/ui/widgets/network.py msgid "Enter new tethering password" msgstr "Enter new tethering password" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Enter password" msgstr "Enter password" -#: selfdrive/ui/widgets/ssh_key.py:89 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "Enter your GitHub username" -#: system/ui/widgets/list_view.py:123 system/ui/widgets/list_view.py:160 -#, python-format +#: system/ui/widgets/list_view.py msgid "Error" msgstr "Error" -#: selfdrive/ui/layouts/settings/toggles.py:52 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Experimental Mode" msgstr "Experimental Mode" -#: selfdrive/ui/layouts/settings/toggles.py:181 -#, python-format -msgid "" -"Experimental mode is currently unavailable on this car since the car's stock " -"ACC is used for longitudinal control." -msgstr "" -"Experimental mode is currently unavailable on this car since the car's stock " -"ACC is used for longitudinal control." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." +msgstr "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." -#: system/ui/widgets/network.py:373 -#, python-format +#: system/ui/widgets/network.py msgid "FORGETTING..." msgstr "FORGETTING..." -#: selfdrive/ui/widgets/setup.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Finish Setup" msgstr "Finish Setup" -#: selfdrive/ui/layouts/settings/settings.py:66 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Firehose" msgstr "Firehose" -#: selfdrive/ui/layouts/settings/firehose.py:18 +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "Firehose Mode" msgstr "Firehose Mode" -#: selfdrive/ui/layouts/settings/firehose.py:25 -msgid "" -"For maximum effectiveness, bring your device inside and connect to a good " -"USB-C adapter and Wi-Fi weekly.\n" -"\n" -"Firehose Mode can also work while you're driving if connected to a hotspot " -"or unlimited SIM card.\n" -"\n" -"\n" -"Frequently Asked Questions\n" -"\n" -"Does it matter how or where I drive? Nope, just drive as you normally " -"would.\n" -"\n" -"Do all of my segments get pulled in Firehose Mode? No, we selectively pull a " -"subset of your segments.\n" -"\n" -"What's a good USB-C adapter? Any fast phone or laptop charger should be " -"fine.\n" -"\n" -"Does it matter which software I run? Yes, only upstream openpilot (and " -"particular forks) are able to be used for training." +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" msgstr "" -"For maximum effectiveness, bring your device inside and connect to a good " -"USB-C adapter and Wi-Fi weekly.\n" -"\n" -"Firehose Mode can also work while you're driving if connected to a hotspot " -"or unlimited SIM card.\n" -"\n" -"\n" -"Frequently Asked Questions\n" -"\n" -"Does it matter how or where I drive? Nope, just drive as you normally " -"would.\n" -"\n" -"Do all of my segments get pulled in Firehose Mode? No, we selectively pull a " -"subset of your segments.\n" -"\n" -"What's a good USB-C adapter? Any fast phone or laptop charger should be " -"fine.\n" -"\n" -"Does it matter which software I run? Yes, only upstream openpilot (and " -"particular forks) are able to be used for training." -#: system/ui/widgets/network.py:318 system/ui/widgets/network.py:451 -#, python-format +#: system/ui/widgets/network.py msgid "Forget" msgstr "Forget" -#: system/ui/widgets/network.py:319 -#, python-format +#: system/ui/widgets/network.py msgid "Forget Wi-Fi Network \"{}\"?" msgstr "Forget Wi-Fi Network \"{}\"?" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "GOOD" msgstr "GOOD" -#: selfdrive/ui/widgets/pairing_dialog.py:128 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Go to https://connect.comma.ai on your phone" msgstr "Go to https://connect.comma.ai on your phone" -#: selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "HIGH" msgstr "HIGH" -#: system/ui/widgets/network.py:155 -#, python-format +#: system/ui/widgets/network.py msgid "Hidden Network" msgstr "Hidden Network" -#: selfdrive/ui/layouts/settings/firehose.py:140 -#, python-format -msgid "INACTIVE: connect to an unmetered network" -msgstr "INACTIVE: connect to an unmetered network" - -#: selfdrive/ui/layouts/settings/software.py:53 -#: selfdrive/ui/layouts/settings/software.py:136 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "INSTALL" msgstr "INSTALL" -#: system/ui/widgets/network.py:150 -#, python-format +#: system/ui/widgets/network.py msgid "IP Address" msgstr "IP Address" -#: selfdrive/ui/layouts/settings/software.py:53 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Install Update" -#: selfdrive/ui/layouts/settings/developer.py:56 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "Joystick Debug Mode" -#: selfdrive/ui/widgets/ssh_key.py:29 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "LOADING" msgstr "LOADING" -#: selfdrive/ui/layouts/sidebar.py:48 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "LTE" msgstr "LTE" -#: selfdrive/ui/layouts/settings/developer.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "Longitudinal Maneuver Mode" -#: selfdrive/ui/onroad/hud_renderer.py:148 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "MAX" msgstr "MAX" -#: selfdrive/ui/widgets/setup.py:75 -#, python-format -msgid "" -"Maximize your training data uploads to improve openpilot's driving models." -msgstr "" -"Maximize your training data uploads to improve openpilot's driving models." +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Maximize your training data uploads to improve openpilot's driving models." +msgstr "Maximize your training data uploads to improve openpilot's driving models." -#: selfdrive/ui/layouts/settings/device.py:59 -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "N/A" msgstr "N/A" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "NO" msgstr "NO" -#: selfdrive/ui/layouts/settings/settings.py:63 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Network" msgstr "Network" -#: selfdrive/ui/widgets/ssh_key.py:114 -#, python-format -msgid "No SSH keys found" -msgstr "No SSH keys found" - -#: selfdrive/ui/widgets/ssh_key.py:126 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "No SSH keys found for user '{}'" msgstr "No SSH keys found for user '{}'" -#: selfdrive/ui/widgets/offroad_alerts.py:320 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "No release notes available." msgstr "No release notes available." -#: selfdrive/ui/layouts/sidebar.py:73 selfdrive/ui/layouts/sidebar.py:134 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "OFFLINE" msgstr "OFFLINE" -#: system/ui/widgets/html_render.py:263 system/ui/widgets/confirm_dialog.py:93 -#: selfdrive/ui/layouts/sidebar.py:127 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/html_render.py msgid "OK" msgstr "OK" -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ONLINE" msgstr "ONLINE" -#: selfdrive/ui/widgets/setup.py:20 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Open" msgstr "Open" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PAIR" msgstr "PAIR" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "PANDA" msgstr "PANDA" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PREVIEW" msgstr "PREVIEW" -#: selfdrive/ui/widgets/prime.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "PRIME FEATURES:" msgstr "PRIME FEATURES:" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Pair Device" msgstr "Pair Device" -#: selfdrive/ui/widgets/setup.py:19 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Pair device" msgstr "Pair device" -#: selfdrive/ui/widgets/pairing_dialog.py:103 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "Pair your device to your comma account" -#: selfdrive/ui/widgets/setup.py:48 selfdrive/ui/layouts/settings/device.py:24 -#, python-format -msgid "" -"Pair your device with comma connect (connect.comma.ai) and claim your comma " -"prime offer." -msgstr "" -"Pair your device with comma connect (connect.comma.ai) and claim your comma " -"prime offer." +#: openpilot/selfdrive/ui/layouts/settings/device.py +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." +msgstr "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." -#: selfdrive/ui/widgets/setup.py:91 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Please connect to Wi-Fi to complete initial pairing" msgstr "Please connect to Wi-Fi to complete initial pairing" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Power Off" msgstr "Power Off" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered Wi-Fi connection" msgstr "Prevent large data uploads when on a metered Wi-Fi connection" -#: system/ui/widgets/network.py:135 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered cellular connection" msgstr "Prevent large data uploads when on a metered cellular connection" -#: selfdrive/ui/layouts/settings/device.py:25 -msgid "" -"Preview the driver facing camera to ensure that driver monitoring has good " -"visibility. (vehicle must be off)" -msgstr "" -"Preview the driver facing camera to ensure that driver monitoring has good " -"visibility. (vehicle must be off)" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)" +msgstr "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)" -#: selfdrive/ui/widgets/pairing_dialog.py:161 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "QR Code Error" msgstr "QR Code Error" -#: selfdrive/ui/widgets/ssh_key.py:31 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "REMOVE" msgstr "REMOVE" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "RESET" msgstr "RESET" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "REVIEW" msgstr "REVIEW" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "Reboot" -#: selfdrive/ui/onroad/alert_renderer.py:66 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Reboot Device" msgstr "Reboot Device" -#: selfdrive/ui/widgets/offroad_alerts.py:112 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Reboot and Update" msgstr "Reboot and Update" -#: selfdrive/ui/layouts/settings/toggles.py:27 -msgid "" -"Receive alerts to steer back into the lane when your vehicle drifts over a " -"detected lane line without a turn signal activated while driving over 31 mph " -"(50 km/h)." -msgstr "" -"Receive alerts to steer back into the lane when your vehicle drifts over a " -"detected lane line without a turn signal activated while driving over 31 mph " -"(50 km/h)." - -#: selfdrive/ui/layouts/settings/toggles.py:76 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Driver Camera" msgstr "Record and Upload Driver Camera" -#: selfdrive/ui/layouts/settings/toggles.py:82 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Microphone Audio" msgstr "Record and Upload Microphone Audio" -#: selfdrive/ui/layouts/settings/toggles.py:33 -msgid "" -"Record and store microphone audio while driving. The audio will be included " -"in the dashcam video in comma connect." -msgstr "" -"Record and store microphone audio while driving. The audio will be included " -"in the dashcam video in comma connect." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect." +msgstr "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect." -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Regulatory" msgstr "Regulatory" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Relaxed" msgstr "Relaxed" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote access" msgstr "Remote access" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote snapshots" msgstr "Remote snapshots" -#: selfdrive/ui/widgets/ssh_key.py:123 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Request timed out" msgstr "Request timed out" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset" msgstr "Reset" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset Calibration" msgstr "Reset Calibration" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Review Training Guide" msgstr "Review Training Guide" -#: selfdrive/ui/layouts/settings/device.py:27 -msgid "Review the rules, features, and limitations of openpilot" -msgstr "Review the rules, features, and limitations of openpilot" - -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format -msgid "SELECT" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Review the rules, features, and limitations of sunnypilot" msgstr "" -#: selfdrive/ui/layouts/settings/developer.py:53 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "SELECT" +msgstr "SELECT" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "SSH Keys" msgstr "SSH Keys" -#: system/ui/widgets/network.py:310 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + +#: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "Scanning Wi-Fi networks..." -#: system/ui/widgets/option_dialog.py:36 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + +#: system/ui/widgets/option_dialog.py msgid "Select" msgstr "Select" -#: selfdrive/ui/layouts/settings/software.py:183 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Select a branch" -msgstr "" +msgstr "Select a branch" -#: selfdrive/ui/layouts/settings/device.py:91 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Select a language" msgstr "Select a language" -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Serial" msgstr "Serial" -#: selfdrive/ui/widgets/offroad_alerts.py:106 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Snooze Update" msgstr "Snooze Update" -#: selfdrive/ui/layouts/settings/settings.py:65 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Software" msgstr "Software" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Standard" msgstr "Standard" -#: selfdrive/ui/layouts/settings/toggles.py:22 -msgid "" -"Standard is recommended. In aggressive mode, openpilot will follow lead cars " -"closer and be more aggressive with the gas and brake. In relaxed mode " -"openpilot will stay further away from lead cars. On supported cars, you can " -"cycle through these personalities with your steering wheel distance button." -msgstr "" -"Standard is recommended. In aggressive mode, openpilot will follow lead cars " -"closer and be more aggressive with the gas and brake. In relaxed mode " -"openpilot will stay further away from lead cars. On supported cars, you can " -"cycle through these personalities with your steering wheel distance button." - -#: selfdrive/ui/onroad/alert_renderer.py:59 -#: selfdrive/ui/onroad/alert_renderer.py:65 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "System Unresponsive" msgstr "System Unresponsive" -#: selfdrive/ui/onroad/alert_renderer.py:58 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "TAKE CONTROL IMMEDIATELY" msgstr "TAKE CONTROL IMMEDIATELY" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 -#: selfdrive/ui/layouts/sidebar.py:127 selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "TEMP" msgstr "TEMP" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Target Branch" -msgstr "" +msgstr "Target Branch" -#: system/ui/widgets/network.py:124 -#, python-format +#: system/ui/widgets/network.py msgid "Tethering Password" msgstr "Tethering Password" -#: selfdrive/ui/layouts/settings/settings.py:64 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Toggles" msgstr "Toggles" -#: selfdrive/ui/layouts/settings/software.py:72 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "UI Debug Mode" +msgstr "UI Debug Mode" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "UNINSTALL" msgstr "UNINSTALL" -#: selfdrive/ui/layouts/home.py:155 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "UPDATE" msgstr "UPDATE" -#: selfdrive/ui/layouts/settings/software.py:72 -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Uninstall" msgstr "Uninstall" -#: selfdrive/ui/layouts/sidebar.py:117 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Unknown" msgstr "Unknown" -#: selfdrive/ui/layouts/settings/software.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Updates are only downloaded while the car is off." msgstr "Updates are only downloaded while the car is off." -#: selfdrive/ui/widgets/prime.py:33 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Upgrade Now" msgstr "Upgrade Now" -#: selfdrive/ui/layouts/settings/toggles.py:31 -msgid "" -"Upload data from the driver facing camera and help improve the driver " -"monitoring algorithm." -msgstr "" -"Upload data from the driver facing camera and help improve the driver " -"monitoring algorithm." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." +msgstr "Upload data from the driver facing camera and help improve the driver monitoring algorithm." -#: selfdrive/ui/layouts/settings/toggles.py:88 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Use Metric System" msgstr "Use Metric System" -#: selfdrive/ui/layouts/settings/toggles.py:17 -msgid "" -"Use the openpilot system for adaptive cruise control and lane keep driver " -"assistance. Your attention is required at all times to use this feature." -msgstr "" -"Use the openpilot system for adaptive cruise control and lane keep driver " -"assistance. Your attention is required at all times to use this feature." - -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "VEHICLE" msgstr "VEHICLE" -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "VIEW" msgstr "VIEW" -#: selfdrive/ui/onroad/alert_renderer.py:52 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Waiting to start" msgstr "Waiting to start" -#: selfdrive/ui/layouts/settings/developer.py:19 -msgid "" -"Warning: This grants SSH access to all public keys in your GitHub settings. " -"Never enter a GitHub username other than your own. A comma employee will " -"NEVER ask you to add their GitHub username." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Welcome to sunnypilot" msgstr "" -"Warning: This grants SSH access to all public keys in your GitHub settings. " -"Never enter a GitHub username other than your own. A comma employee will " -"NEVER ask you to add their GitHub username." -#: selfdrive/ui/layouts/onboarding.py:111 -#, python-format -msgid "Welcome to openpilot" -msgstr "Welcome to openpilot" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:20 -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "When enabled, pressing the accelerator pedal will disengage openpilot." - -#: selfdrive/ui/layouts/sidebar.py:44 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" msgstr "Wi-Fi" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Wi-Fi Network Metered" msgstr "Wi-Fi Network Metered" -#: system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Wrong password" msgstr "Wrong password" -#: selfdrive/ui/layouts/onboarding.py:145 -#, python-format -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "You must accept the Terms and Conditions in order to use openpilot." - -#: selfdrive/ui/layouts/onboarding.py:112 -#, python-format -msgid "" -"You must accept the Terms and Conditions to use openpilot. Read the latest " -"terms at https://comma.ai/terms before continuing." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service in order to use sunnypilot." msgstr "" -"You must accept the Terms and Conditions to use openpilot. Read the latest " -"terms at https://comma.ai/terms before continuing." -#: selfdrive/ui/onroad/driver_camera_dialog.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" + +#: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" msgstr "camera starting" -#: selfdrive/ui/widgets/prime.py:63 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "checking..." +msgstr "checking..." + +#: openpilot/selfdrive/ui/widgets/prime.py msgid "comma prime" msgstr "comma prime" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "default" msgstr "default" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "down" msgstr "down" -#: selfdrive/ui/layouts/settings/software.py:106 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "downloading..." +msgstr "downloading..." + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "failed to check for update" msgstr "failed to check for update" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "finalizing update..." +msgstr "finalizing update..." + +#: system/ui/widgets/network.py msgid "for \"{}\"" msgstr "for \"{}\"" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "leave blank for automatic configuration" msgstr "leave blank for automatic configuration" -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "left" msgstr "left" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "metered" msgstr "metered" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" -#: selfdrive/ui/layouts/settings/software.py:20 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "never" msgstr "never" -#: selfdrive/ui/layouts/settings/software.py:31 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "now" msgstr "now" -#: selfdrive/ui/layouts/settings/developer.py:71 -#, python-format -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "openpilot Longitudinal Control (Alpha)" - -#: selfdrive/ui/onroad/alert_renderer.py:51 -#, python-format -msgid "openpilot Unavailable" -msgstr "openpilot Unavailable" - -#: selfdrive/ui/layouts/settings/toggles.py:158 -#, python-format -msgid "" -"openpilot defaults to driving in chill mode. Experimental mode enables alpha-" -"level features that aren't ready for chill mode. Experimental features are " -"listed below:

End-to-End Longitudinal Control


Let the driving " -"model control the gas and brakes. openpilot will drive as it thinks a human " -"would, including stopping for red lights and stop signs. Since the driving " -"model decides the speed to drive, the set speed will only act as an upper " -"bound. This is an alpha quality feature; mistakes should be expected." -"

New Driving Visualization


The driving visualization will " -"transition to the road-facing wide-angle camera at low speeds to better show " -"some turns. The Experimental mode logo will also be shown in the top right " -"corner." -msgstr "" -"openpilot defaults to driving in chill mode. Experimental mode enables alpha-" -"level features that aren't ready for chill mode. Experimental features are " -"listed below:

End-to-End Longitudinal Control


Let the driving " -"model control the gas and brakes. openpilot will drive as it thinks a human " -"would, including stopping for red lights and stop signs. Since the driving " -"model decides the speed to drive, the set speed will only act as an upper " -"bound. This is an alpha quality feature; mistakes should be expected." -"

New Driving Visualization


The driving visualization will " -"transition to the road-facing wide-angle camera at low speeds to better show " -"some turns. The Experimental mode logo will also be shown in the top right " -"corner." - -#: selfdrive/ui/layouts/settings/device.py:165 -#, python-format -msgid "" -"openpilot is continuously calibrating, resetting is rarely required. " -"Resetting calibration will restart openpilot if the car is powered on." -msgstr "" -"openpilot is continuously calibrating, resetting is rarely required. " -"Resetting calibration will restart openpilot if the car is powered on." - -#: selfdrive/ui/layouts/settings/firehose.py:20 -msgid "" -"openpilot learns to drive by watching humans, like you, drive.\n" -"\n" -"Firehose Mode allows you to maximize your training data uploads to improve " -"openpilot's driving models. More data means bigger models, which means " -"better Experimental Mode." -msgstr "" -"openpilot learns to drive by watching humans, like you, drive.\n" -"\n" -"Firehose Mode allows you to maximize your training data uploads to improve " -"openpilot's driving models. More data means bigger models, which means " -"better Experimental Mode." - -#: selfdrive/ui/layouts/settings/toggles.py:183 -#, python-format -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilot longitudinal control may come in a future update." - -#: selfdrive/ui/layouts/settings/device.py:26 -msgid "" -"openpilot requires the device to be mounted within 4° left or right and " -"within 5° up or 9° down." -msgstr "" -"openpilot requires the device to be mounted within 4° left or right and " -"within 5° up or 9° down." - -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "right" -#: system/ui/widgets/network.py:142 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + +#: system/ui/widgets/network.py msgid "unmetered" msgstr "unmetered" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "up" msgstr "up" -#: selfdrive/ui/layouts/settings/software.py:117 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked never" msgstr "up to date, last checked never" -#: selfdrive/ui/layouts/settings/software.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked {}" msgstr "up to date, last checked {}" -#: selfdrive/ui/layouts/settings/software.py:109 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "update available" msgstr "update available" -#: selfdrive/ui/layouts/home.py:169 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "{} ALERT" msgid_plural "{} ALERTS" msgstr[0] "{} ALERT" msgstr[1] "{} ALERTS" -#: selfdrive/ui/layouts/settings/software.py:40 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "{} day ago" msgstr[1] "{} days ago" -#: selfdrive/ui/layouts/settings/software.py:37 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "{} hour ago" msgstr[1] "{} hours ago" -#: selfdrive/ui/layouts/settings/software.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} minute ago" msgid_plural "{} minutes ago" msgstr[0] "{} minute ago" msgstr[1] "{} minutes ago" -#: selfdrive/ui/layouts/settings/firehose.py:111 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "{} segment of your driving is in the training dataset so far." msgid_plural "{} segments of your driving is in the training dataset so far." msgstr[0] "{} segment of your driving is in the training dataset so far." msgstr[1] "{} segments of your driving is in the training dataset so far." -#: selfdrive/ui/widgets/prime.py:62 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "✓ SUBSCRIBED" msgstr "✓ SUBSCRIBED" -#: selfdrive/ui/widgets/setup.py:22 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "🔥 Firehose Mode 🔥" msgstr "🔥 Firehose Mode 🔥" + diff --git a/selfdrive/ui/translations/app_es.po b/selfdrive/ui/translations/app_es.po index 59b9e6dfdb..2298fdb5a1 100644 --- a/selfdrive/ui/translations/app_es.po +++ b/selfdrive/ui/translations/app_es.po @@ -1,1225 +1,873 @@ -# Spanish translations for PACKAGE package. -# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# Automatically generated, 2025. -# msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 00:50-0700\n" -"PO-Revision-Date: 2025-10-20 16:35-0700\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" -"Language: es\n" -"MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" +"Language: es\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: selfdrive/ui/layouts/settings/device.py:160 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is complete." msgstr " La calibración de respuesta de par de dirección está completa." -#: selfdrive/ui/layouts/settings/device.py:158 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is {}% complete." msgstr " La calibración de respuesta de par de dirección está {}% completa." -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Your device is pointed {:.1f}° {} and {:.1f}° {}." msgstr " Tu dispositivo está orientado {:.1f}° {} y {:.1f}° {}." -#: selfdrive/ui/layouts/sidebar.py:43 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "--" msgstr "--" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "1 year of drive storage" msgstr "1 año de almacenamiento de conducción" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "24/7 LTE connectivity" msgstr "Conectividad LTE 24/7" -#: selfdrive/ui/layouts/sidebar.py:46 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "2G" msgstr "2G" -#: selfdrive/ui/layouts/sidebar.py:47 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "3G" msgstr "3G" -#: selfdrive/ui/layouts/sidebar.py:49 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "5G" msgstr "5G" -#: selfdrive/ui/layouts/settings/developer.py:23 -msgid "" -"WARNING: openpilot longitudinal control is in alpha for this car and will " -"disable Automatic Emergency Braking (AEB).

On this car, openpilot " -"defaults to the car's built-in ACC instead of openpilot's longitudinal " -"control. Enable this to switch to openpilot longitudinal control. Enabling " -"Experimental mode is recommended when enabling openpilot longitudinal " -"control alpha. Changing this setting will restart openpilot if the car is " -"powered on." -msgstr "" -"ADVERTENCIA: el control longitudinal de openpilot está en alpha para este " -"coche y deshabilitará el Frenado Automático de Emergencia (AEB).

En este coche, openpilot usa por defecto el ACC integrado del " -"coche en lugar del control longitudinal de openpilot. Activa esto para " -"cambiar al control longitudinal de openpilot. Se recomienda activar el modo " -"Experimental al habilitar el control longitudinal de openpilot (alpha)." - -#: selfdrive/ui/layouts/settings/device.py:148 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is complete." -msgstr "" +msgstr "

La calibración del retraso de dirección está completa." -#: selfdrive/ui/layouts/settings/device.py:146 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is {}% complete." -msgstr "" +msgstr "

La calibración del retraso de dirección está completa en un {}%." -#: selfdrive/ui/layouts/settings/firehose.py:138 -#, python-format -msgid "ACTIVE" -msgstr "ACTIVO" - -#: selfdrive/ui/layouts/settings/developer.py:15 -msgid "" -"ADB (Android Debug Bridge) allows connecting to your device over USB or over " -"the network. See https://docs.comma.ai/how-to/connect-to-comma for more info." -msgstr "" -"ADB (Android Debug Bridge) permite conectar tu dispositivo por USB o por la " -"red. Consulta https://docs.comma.ai/how-to/connect-to-comma para más " -"información." - -#: selfdrive/ui/widgets/ssh_key.py:30 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "ADD" msgstr "AÑADIR" -#: system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "APN Setting" -msgstr "" +msgstr "Configuración de APN" -#: selfdrive/ui/widgets/offroad_alerts.py:109 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Acknowledge Excessive Actuation" msgstr "Reconocer actuación excesiva" -#: system/ui/widgets/network.py:74 system/ui/widgets/network.py:95 -#, python-format +#: system/ui/widgets/network.py msgid "Advanced" -msgstr "" +msgstr "Avanzado" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Aggressive" msgstr "Agresivo" -#: selfdrive/ui/layouts/onboarding.py:116 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Agree" msgstr "Aceptar" -#: selfdrive/ui/layouts/settings/toggles.py:70 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Always-On Driver Monitoring" msgstr "Supervisión del conductor siempre activa" -#: selfdrive/ui/layouts/settings/toggles.py:186 -#, python-format -msgid "" -"An alpha version of openpilot longitudinal control can be tested, along with " -"Experimental mode, on non-release branches." -msgstr "" -"Se puede probar una versión alpha del control longitudinal de openpilot, " -"junto con el modo Experimental, en ramas que no son de lanzamiento." - -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to power off?" msgstr "¿Seguro que quieres apagar?" -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reboot?" msgstr "¿Seguro que quieres reiniciar?" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reset calibration?" msgstr "¿Seguro que quieres restablecer la calibración?" -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Are you sure you want to uninstall?" msgstr "¿Seguro que quieres desinstalar?" -#: system/ui/widgets/network.py:99 selfdrive/ui/layouts/onboarding.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +#: system/ui/widgets/network.py msgid "Back" msgstr "Atrás" -#: selfdrive/ui/widgets/prime.py:38 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Become a comma prime member at connect.comma.ai" msgstr "Hazte miembro de comma prime en connect.comma.ai" -#: selfdrive/ui/widgets/pairing_dialog.py:130 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Bookmark connect.comma.ai to your home screen to use it like an app" -msgstr "" -"Añade connect.comma.ai a tu pantalla de inicio para usarlo como una app" +msgstr "Añade connect.comma.ai a tu pantalla de inicio para usarlo como una app" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "CHANGE" msgstr "CAMBIAR" -#: selfdrive/ui/layouts/settings/software.py:50 -#: selfdrive/ui/layouts/settings/software.py:107 -#: selfdrive/ui/layouts/settings/software.py:118 -#: selfdrive/ui/layouts/settings/software.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "CHECK" msgstr "COMPROBAR" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "CHILL MODE ON" msgstr "MODO CHILL ACTIVADO" -#: system/ui/widgets/network.py:155 selfdrive/ui/layouts/sidebar.py:73 -#: selfdrive/ui/layouts/sidebar.py:134 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:138 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/network.py msgid "CONNECT" msgstr "CONECTAR" -#: system/ui/widgets/network.py:369 -#, python-format +#: system/ui/widgets/network.py msgid "CONNECTING..." msgstr "CONECTAR" -#: system/ui/widgets/confirm_dialog.py:23 system/ui/widgets/option_dialog.py:35 -#: system/ui/widgets/keyboard.py:81 system/ui/widgets/network.py:318 -#, python-format +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/keyboard.py +#: system/ui/widgets/network.py +#: system/ui/widgets/option_dialog.py msgid "Cancel" -msgstr "" +msgstr "Cancelar" -#: system/ui/widgets/network.py:134 -#, python-format +#: system/ui/widgets/network.py msgid "Cellular Metered" -msgstr "" +msgstr "Medición celular" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Change Language" msgstr "Cambiar idioma" -#: selfdrive/ui/layouts/settings/toggles.py:125 -#, python-format -msgid "Changing this setting will restart openpilot if the car is powered on." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Changing this setting will restart sunnypilot if the car is powered on." msgstr "" -" Cambiar esta configuración reiniciará openpilot si el coche está encendido." -#: selfdrive/ui/widgets/pairing_dialog.py:129 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" -msgstr "" -"Haz clic en \"añadir nuevo dispositivo\" y escanea el código QR de la derecha" +msgstr "Haz clic en \"añadir nuevo dispositivo\" y escanea el código QR de la derecha" -#: selfdrive/ui/widgets/offroad_alerts.py:104 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Close" msgstr "Cerrar" -#: selfdrive/ui/layouts/settings/software.py:49 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Current Version" msgstr "Versión actual" -#: selfdrive/ui/layouts/settings/software.py:110 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "DOWNLOAD" msgstr "DESCARGAR" -#: selfdrive/ui/layouts/onboarding.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Decline" msgstr "Rechazar" -#: selfdrive/ui/layouts/onboarding.py:148 -#, python-format -msgid "Decline, uninstall openpilot" -msgstr "Rechazar, desinstalar openpilot" +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline, uninstall sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/settings.py:67 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" msgstr "Desarrollador" -#: selfdrive/ui/layouts/settings/settings.py:62 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Device" msgstr "Dispositivo" -#: selfdrive/ui/layouts/settings/toggles.py:58 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Disengage on Accelerator Pedal" msgstr "Desactivar con el pedal del acelerador" -#: selfdrive/ui/layouts/settings/device.py:184 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Power Off" msgstr "Desactivar para apagar" -#: selfdrive/ui/layouts/settings/device.py:172 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reboot" msgstr "Desactivar para reiniciar" -#: selfdrive/ui/layouts/settings/device.py:103 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reset Calibration" msgstr "Desactivar para restablecer la calibración" -#: selfdrive/ui/layouts/settings/toggles.py:32 +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Display speed in km/h instead of mph." msgstr "Mostrar la velocidad en km/h en lugar de mph." -#: selfdrive/ui/layouts/settings/device.py:59 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Dongle ID" msgstr "ID del dongle" -#: selfdrive/ui/layouts/settings/software.py:50 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Download" msgstr "Descargar" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Driver Camera" msgstr "Cámara del conductor" -#: selfdrive/ui/layouts/settings/toggles.py:96 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Driving Personality" msgstr "Estilo de conducción" -#: system/ui/widgets/network.py:123 system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "EDIT" -msgstr "" +msgstr "EDITAR" -#: selfdrive/ui/layouts/sidebar.py:138 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ERROR" -msgstr "ERROR" +msgstr "FALLO" -#: selfdrive/ui/layouts/sidebar.py:45 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ETH" msgstr "ETH" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "EXPERIMENTAL MODE ON" msgstr "MODO EXPERIMENTAL ACTIVADO" -#: selfdrive/ui/layouts/settings/developer.py:166 -#: selfdrive/ui/layouts/settings/toggles.py:228 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Activar" -#: selfdrive/ui/layouts/settings/developer.py:39 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable ADB" msgstr "Activar ADB" -#: selfdrive/ui/layouts/settings/toggles.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable Lane Departure Warnings" msgstr "Activar advertencias de salida de carril" -#: system/ui/widgets/network.py:129 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Roaming" -msgstr "Activar openpilot" +msgstr "Activar roaming" -#: selfdrive/ui/layouts/settings/developer.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable SSH" msgstr "Activar SSH" -#: system/ui/widgets/network.py:120 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Tethering" -msgstr "Activar advertencias de salida de carril" +msgstr "Activar anclaje" -#: selfdrive/ui/layouts/settings/toggles.py:30 -msgid "Enable driver monitoring even when openpilot is not engaged." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable driver monitoring even when sunnypilot is not engaged." msgstr "" -"Activar la supervisión del conductor incluso cuando openpilot no esté " -"activado." -#: selfdrive/ui/layouts/settings/toggles.py:46 -#, python-format -msgid "Enable openpilot" -msgstr "Activar openpilot" - -#: selfdrive/ui/layouts/settings/toggles.py:189 -#, python-format -msgid "" -"Enable the openpilot longitudinal control (alpha) toggle to allow " -"Experimental mode." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable sunnypilot" msgstr "" -"Activa el interruptor de control longitudinal de openpilot (alpha) para " -"permitir el modo Experimental." -#: system/ui/widgets/network.py:204 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" + +#: system/ui/widgets/network.py msgid "Enter APN" -msgstr "" +msgstr "Introduce APN" -#: system/ui/widgets/network.py:241 -#, python-format +#: system/ui/widgets/network.py msgid "Enter SSID" -msgstr "" +msgstr "Introduzca SSID" -#: system/ui/widgets/network.py:254 -#, python-format +#: system/ui/widgets/network.py msgid "Enter new tethering password" -msgstr "" +msgstr "Ingrese una nueva contraseña de anclaje a red" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Enter password" +msgstr "Introduce la contraseña" + +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" msgstr "" -#: selfdrive/ui/widgets/ssh_key.py:89 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "Introduce tu nombre de usuario de GitHub" -#: system/ui/widgets/list_view.py:123 system/ui/widgets/list_view.py:160 -#, python-format +#: system/ui/widgets/list_view.py msgid "Error" -msgstr "" +msgstr "Fallo" -#: selfdrive/ui/layouts/settings/toggles.py:52 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Experimental Mode" msgstr "Modo experimental" -#: selfdrive/ui/layouts/settings/toggles.py:181 -#, python-format -msgid "" -"Experimental mode is currently unavailable on this car since the car's stock " -"ACC is used for longitudinal control." -msgstr "" -"El modo experimental no está disponible actualmente en este coche, ya que se " -"usa el ACC de fábrica para el control longitudinal." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." +msgstr "El modo experimental no está disponible actualmente en este coche, ya que se usa el ACC de fábrica para el control longitudinal." -#: system/ui/widgets/network.py:373 -#, python-format +#: system/ui/widgets/network.py msgid "FORGETTING..." -msgstr "" +msgstr "OLVIDAR..." -#: selfdrive/ui/widgets/setup.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Finish Setup" msgstr "Finalizar configuración" -#: selfdrive/ui/layouts/settings/settings.py:66 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Firehose" -msgstr "Firehose" +msgstr "Flujo masivo" -#: selfdrive/ui/layouts/settings/firehose.py:18 +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "Firehose Mode" msgstr "Modo Firehose" -#: selfdrive/ui/layouts/settings/firehose.py:25 -msgid "" -"For maximum effectiveness, bring your device inside and connect to a good " -"USB-C adapter and Wi-Fi weekly.\n" -"\n" -"Firehose Mode can also work while you're driving if connected to a hotspot " -"or unlimited SIM card.\n" -"\n" -"\n" -"Frequently Asked Questions\n" -"\n" -"Does it matter how or where I drive? Nope, just drive as you normally " -"would.\n" -"\n" -"Do all of my segments get pulled in Firehose Mode? No, we selectively pull a " -"subset of your segments.\n" -"\n" -"What's a good USB-C adapter? Any fast phone or laptop charger should be " -"fine.\n" -"\n" -"Does it matter which software I run? Yes, only upstream openpilot (and " -"particular forks) are able to be used for training." +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" msgstr "" -"Para la máxima efectividad, lleva tu dispositivo al interior y conéctalo " -"semanalmente a un buen adaptador USB‑C y Wi‑Fi.\n" -"\n" -"El Modo Firehose también puede funcionar mientras conduces si está conectado " -"a un hotspot o a una SIM ilimitada.\n" -"\n" -"\n" -"Preguntas frecuentes\n" -"\n" -"¿Importa cómo o dónde conduzco? No, conduce como normalmente lo harías.\n" -"\n" -"¿Se suben todos mis segmentos en el Modo Firehose? No, seleccionamos un " -"subconjunto de tus segmentos.\n" -"\n" -"¿Qué es un buen adaptador USB‑C? Cualquier cargador rápido de teléfono o " -"laptop sirve.\n" -"\n" -"¿Importa qué software ejecuto? Sí, solo openpilot upstream (y forks " -"particulares) pueden usarse para entrenamiento." -#: system/ui/widgets/network.py:318 system/ui/widgets/network.py:451 -#, python-format +#: system/ui/widgets/network.py msgid "Forget" -msgstr "" +msgstr "Olvidar" -#: system/ui/widgets/network.py:319 -#, python-format +#: system/ui/widgets/network.py msgid "Forget Wi-Fi Network \"{}\"?" -msgstr "" +msgstr "¿Olvidaste la red Wi-Fi \"{}\"?" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "GOOD" msgstr "BUENO" -#: selfdrive/ui/widgets/pairing_dialog.py:128 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Go to https://connect.comma.ai on your phone" msgstr "Ve a https://connect.comma.ai en tu teléfono" -#: selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "HIGH" msgstr "ALTO" -#: system/ui/widgets/network.py:155 -#, python-format +#: system/ui/widgets/network.py msgid "Hidden Network" -msgstr "Red" +msgstr "Red oculta" -#: selfdrive/ui/layouts/settings/firehose.py:140 -#, python-format -msgid "INACTIVE: connect to an unmetered network" -msgstr "INACTIVO: conéctate a una red sin límites" - -#: selfdrive/ui/layouts/settings/software.py:53 -#: selfdrive/ui/layouts/settings/software.py:136 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "INSTALL" msgstr "INSTALAR" -#: system/ui/widgets/network.py:150 -#, python-format +#: system/ui/widgets/network.py msgid "IP Address" +msgstr "Dirección IP" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:53 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Instalar actualización" -#: selfdrive/ui/layouts/settings/developer.py:56 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "Modo de depuración de joystick" -#: selfdrive/ui/widgets/ssh_key.py:29 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "LOADING" msgstr "CARGANDO" -#: selfdrive/ui/layouts/sidebar.py:48 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "LTE" msgstr "LTE" -#: selfdrive/ui/layouts/settings/developer.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "Modo de maniobra longitudinal" -#: selfdrive/ui/onroad/hud_renderer.py:148 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "MAX" msgstr "MÁX" -#: selfdrive/ui/widgets/setup.py:75 -#, python-format -msgid "" -"Maximize your training data uploads to improve openpilot's driving models." -msgstr "" -"Maximiza tus cargas de datos de entrenamiento para mejorar los modelos de " -"conducción de openpilot." +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Maximize your training data uploads to improve openpilot's driving models." +msgstr "Maximiza tus cargas de datos de entrenamiento para mejorar los modelos de conducción de openpilot." -#: selfdrive/ui/layouts/settings/device.py:59 -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "N/A" -msgstr "" +msgstr "N / A" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "NO" -msgstr "NO" +msgstr "SIN" -#: selfdrive/ui/layouts/settings/settings.py:63 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Network" msgstr "Red" -#: selfdrive/ui/widgets/ssh_key.py:114 -#, python-format -msgid "No SSH keys found" -msgstr "No se encontraron claves SSH" - -#: selfdrive/ui/widgets/ssh_key.py:126 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "No SSH keys found for user '{}'" -msgstr "No se encontraron claves SSH para el usuario '{username}'" +msgstr "No se encontraron claves SSH para el usuario '{}'" -#: selfdrive/ui/widgets/offroad_alerts.py:320 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "No release notes available." msgstr "No hay notas de versión disponibles." -#: selfdrive/ui/layouts/sidebar.py:73 selfdrive/ui/layouts/sidebar.py:134 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "OFFLINE" msgstr "SIN CONEXIÓN" -#: system/ui/widgets/html_render.py:263 system/ui/widgets/confirm_dialog.py:93 -#: selfdrive/ui/layouts/sidebar.py:127 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/html_render.py msgid "OK" msgstr "OK" -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ONLINE" msgstr "EN LÍNEA" -#: selfdrive/ui/widgets/setup.py:20 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Open" msgstr "Abrir" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PAIR" msgstr "EMPAREJAR" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "PANDA" msgstr "PANDA" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PREVIEW" msgstr "VISTA PREVIA" -#: selfdrive/ui/widgets/prime.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "PRIME FEATURES:" msgstr "FUNCIONES PRIME:" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Pair Device" msgstr "Emparejar dispositivo" -#: selfdrive/ui/widgets/setup.py:19 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Pair device" msgstr "Emparejar dispositivo" -#: selfdrive/ui/widgets/pairing_dialog.py:103 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "Empareja tu dispositivo con tu cuenta de comma" -#: selfdrive/ui/widgets/setup.py:48 selfdrive/ui/layouts/settings/device.py:24 -#, python-format -msgid "" -"Pair your device with comma connect (connect.comma.ai) and claim your comma " -"prime offer." -msgstr "" -"Empareja tu dispositivo con comma connect (connect.comma.ai) y reclama tu " -"oferta de comma prime." +#: openpilot/selfdrive/ui/layouts/settings/device.py +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." +msgstr "Empareja tu dispositivo con comma connect (connect.comma.ai) y reclama tu oferta de comma prime." -#: selfdrive/ui/widgets/setup.py:91 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Please connect to Wi-Fi to complete initial pairing" msgstr "Conéctate a Wi‑Fi para completar el emparejamiento inicial" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Power Off" msgstr "Apagar" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered Wi-Fi connection" -msgstr "" +msgstr "Evite grandes cargas de datos cuando esté en una conexión Wi-Fi medida" -#: system/ui/widgets/network.py:135 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered cellular connection" -msgstr "" +msgstr "Evite grandes cargas de datos cuando esté en una conexión celular medida" -#: selfdrive/ui/layouts/settings/device.py:25 -msgid "" -"Preview the driver facing camera to ensure that driver monitoring has good " -"visibility. (vehicle must be off)" -msgstr "" -"Previsualiza la cámara hacia el conductor para asegurarte de que la " -"supervisión del conductor tenga buena visibilidad. (el vehículo debe estar " -"apagado)" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)" +msgstr "Previsualiza la cámara hacia el conductor para asegurarte de que la supervisión del conductor tenga buena visibilidad. (el vehículo debe estar apagado)" -#: selfdrive/ui/widgets/pairing_dialog.py:161 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "QR Code Error" msgstr "Error de código QR" -#: selfdrive/ui/widgets/ssh_key.py:31 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "REMOVE" msgstr "ELIMINAR" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "RESET" msgstr "RESTABLECER" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "REVIEW" msgstr "REVISAR" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "Reiniciar" -#: selfdrive/ui/onroad/alert_renderer.py:66 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Reboot Device" msgstr "Reiniciar dispositivo" -#: selfdrive/ui/widgets/offroad_alerts.py:112 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Reboot and Update" msgstr "Reiniciar y actualizar" -#: selfdrive/ui/layouts/settings/toggles.py:27 -msgid "" -"Receive alerts to steer back into the lane when your vehicle drifts over a " -"detected lane line without a turn signal activated while driving over 31 mph " -"(50 km/h)." -msgstr "" -"Recibe alertas para volver al carril cuando tu vehículo se desvíe sobre una " -"línea de carril detectada sin la direccional activada mientras conduces a " -"más de 31 mph (50 km/h)." - -#: selfdrive/ui/layouts/settings/toggles.py:76 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Driver Camera" msgstr "Grabar y subir cámara del conductor" -#: selfdrive/ui/layouts/settings/toggles.py:82 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Microphone Audio" msgstr "Grabar y subir audio del micrófono" -#: selfdrive/ui/layouts/settings/toggles.py:33 -msgid "" -"Record and store microphone audio while driving. The audio will be included " -"in the dashcam video in comma connect." -msgstr "" -"Grabar y almacenar audio del micrófono mientras conduces. El audio se " -"incluirá en el video de la dashcam en comma connect." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect." +msgstr "Grabar y almacenar audio del micrófono mientras conduces. El audio se incluirá en el video de la dashcam en comma connect." -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Regulatory" msgstr "Reglamentario" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Relaxed" msgstr "Relajado" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote access" msgstr "Acceso remoto" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote snapshots" msgstr "Capturas remotas" -#: selfdrive/ui/widgets/ssh_key.py:123 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Request timed out" msgstr "Se agotó el tiempo de espera de la solicitud" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset" msgstr "Restablecer" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset Calibration" msgstr "Restablecer calibración" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Review Training Guide" msgstr "Revisar guía de entrenamiento" -#: selfdrive/ui/layouts/settings/device.py:27 -msgid "Review the rules, features, and limitations of openpilot" -msgstr "Revisa las reglas, funciones y limitaciones de openpilot" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" -msgstr "" +msgstr "SELECCIONAR" -#: selfdrive/ui/layouts/settings/developer.py:53 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "SSH Keys" +msgstr "Claves SSH" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" msgstr "" -#: system/ui/widgets/network.py:310 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + +#: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." +msgstr "Escaneando redes Wi-Fi..." + +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" msgstr "" -#: system/ui/widgets/option_dialog.py:36 -#, python-format +#: system/ui/widgets/option_dialog.py msgid "Select" -msgstr "" +msgstr "Seleccionar" -#: selfdrive/ui/layouts/settings/software.py:183 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Select a branch" -msgstr "" +msgstr "Selecciona una rama" -#: selfdrive/ui/layouts/settings/device.py:91 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Select a language" msgstr "Selecciona un idioma" -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Serial" msgstr "Número de serie" -#: selfdrive/ui/widgets/offroad_alerts.py:106 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Snooze Update" msgstr "Posponer actualización" -#: selfdrive/ui/layouts/settings/settings.py:65 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Software" -msgstr "Software" +msgstr "Sistema" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Standard" msgstr "Estándar" -#: selfdrive/ui/layouts/settings/toggles.py:22 -msgid "" -"Standard is recommended. In aggressive mode, openpilot will follow lead cars " -"closer and be more aggressive with the gas and brake. In relaxed mode " -"openpilot will stay further away from lead cars. On supported cars, you can " -"cycle through these personalities with your steering wheel distance button." -msgstr "" -"Se recomienda Estándar. En modo agresivo, openpilot seguirá más de cerca a " -"los coches delanteros y será más agresivo con el acelerador y el freno. En " -"modo relajado, openpilot se mantendrá más lejos de los coches delanteros. En " -"coches compatibles, puedes cambiar entre estas personalidades con el botón " -"de distancia del volante." - -#: selfdrive/ui/onroad/alert_renderer.py:59 -#: selfdrive/ui/onroad/alert_renderer.py:65 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "System Unresponsive" msgstr "Sistema sin respuesta" -#: selfdrive/ui/onroad/alert_renderer.py:58 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "TAKE CONTROL IMMEDIATELY" msgstr "TOME EL CONTROL INMEDIATAMENTE" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 -#: selfdrive/ui/layouts/sidebar.py:127 selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "TEMP" -msgstr "TEMP" +msgstr "TEMP." -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Target Branch" -msgstr "" +msgstr "Rama objetivo" -#: system/ui/widgets/network.py:124 -#, python-format +#: system/ui/widgets/network.py msgid "Tethering Password" -msgstr "" +msgstr "Contraseña de anclaje" -#: selfdrive/ui/layouts/settings/settings.py:64 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Toggles" msgstr "Interruptores" -#: selfdrive/ui/layouts/settings/software.py:72 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "UI Debug Mode" +msgstr "Modo de depuración de la interfaz de usuario" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "UNINSTALL" msgstr "DESINSTALAR" -#: selfdrive/ui/layouts/home.py:155 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "UPDATE" msgstr "ACTUALIZAR" -#: selfdrive/ui/layouts/settings/software.py:72 -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Uninstall" msgstr "Desinstalar" -#: selfdrive/ui/layouts/sidebar.py:117 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Unknown" msgstr "Desconocido" -#: selfdrive/ui/layouts/settings/software.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Updates are only downloaded while the car is off." msgstr "Las actualizaciones solo se descargan cuando el coche está apagado." -#: selfdrive/ui/widgets/prime.py:33 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Upgrade Now" msgstr "Mejorar ahora" -#: selfdrive/ui/layouts/settings/toggles.py:31 -msgid "" -"Upload data from the driver facing camera and help improve the driver " -"monitoring algorithm." -msgstr "" -"Sube datos de la cámara orientada al conductor y ayuda a mejorar el " -"algoritmo de supervisión del conductor." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." +msgstr "Sube datos de la cámara orientada al conductor y ayuda a mejorar el algoritmo de supervisión del conductor." -#: selfdrive/ui/layouts/settings/toggles.py:88 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Use Metric System" msgstr "Usar sistema métrico" -#: selfdrive/ui/layouts/settings/toggles.py:17 -msgid "" -"Use the openpilot system for adaptive cruise control and lane keep driver " -"assistance. Your attention is required at all times to use this feature." -msgstr "" -"Usa el sistema openpilot para control de crucero adaptativo y asistencia de " -"mantenimiento de carril. Tu atención se requiere en todo momento para usar " -"esta función." - -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "VEHICLE" msgstr "VEHÍCULO" -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "VIEW" msgstr "VER" -#: selfdrive/ui/onroad/alert_renderer.py:52 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Waiting to start" msgstr "Esperando para iniciar" -#: selfdrive/ui/layouts/settings/developer.py:19 -msgid "" -"Warning: This grants SSH access to all public keys in your GitHub settings. " -"Never enter a GitHub username other than your own. A comma employee will " -"NEVER ask you to add their GitHub username." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Welcome to sunnypilot" msgstr "" -"Advertencia: Esto otorga acceso SSH a todas las claves públicas en tu " -"configuración de GitHub. Nunca introduzcas un nombre de usuario de GitHub " -"que no sea el tuyo. Un empleado de comma NUNCA te pedirá que agregues su " -"nombre de usuario de GitHub." -#: selfdrive/ui/layouts/onboarding.py:111 -#, python-format -msgid "Welcome to openpilot" -msgstr "Bienvenido a openpilot" - -#: selfdrive/ui/layouts/settings/toggles.py:20 -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." msgstr "" -"Cuando está activado, al presionar el pedal del acelerador se desactivará " -"openpilot." -#: selfdrive/ui/layouts/sidebar.py:44 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" msgstr "Wi‑Fi" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Wi-Fi Network Metered" -msgstr "" +msgstr "Red Wi-Fi medida" -#: system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Wrong password" +msgstr "Contraseña incorrecta" + +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service in order to use sunnypilot." msgstr "" -#: selfdrive/ui/layouts/onboarding.py:145 -#, python-format -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "Debes aceptar los Términos y Condiciones para poder usar openpilot." - -#: selfdrive/ui/layouts/onboarding.py:112 -#, python-format -msgid "" -"You must accept the Terms and Conditions to use openpilot. Read the latest " -"terms at https://comma.ai/terms before continuing." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." msgstr "" -"Debes aceptar los Términos y Condiciones para usar openpilot. Lee los " -"términos más recientes en https://comma.ai/terms antes de continuar." -#: selfdrive/ui/onroad/driver_camera_dialog.py:34 -#, python-format +#: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" msgstr "iniciando cámara" -#: selfdrive/ui/widgets/prime.py:63 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "checking..." +msgstr "de cheques..." + +#: openpilot/selfdrive/ui/widgets/prime.py msgid "comma prime" msgstr "comma prime" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "default" -msgstr "" +msgstr "por defecto" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "down" msgstr "abajo" -#: selfdrive/ui/layouts/settings/software.py:106 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "downloading..." +msgstr "descargando..." + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "failed to check for update" msgstr "Error al buscar actualizaciones" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format -msgid "for \"{}\"" -msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "finalizing update..." +msgstr "finalizando actualización..." -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: system/ui/widgets/network.py +msgid "for \"{}\"" +msgstr "para \"{}\"" + +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "leave blank for automatic configuration" -msgstr "" +msgstr "dejar en blanco para configuración automática" -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "left" msgstr "izquierda" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "metered" -msgstr "" +msgstr "medido" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" -#: selfdrive/ui/layouts/settings/software.py:20 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "never" msgstr "nunca" -#: selfdrive/ui/layouts/settings/software.py:31 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "now" msgstr "ahora" -#: selfdrive/ui/layouts/settings/developer.py:71 -#, python-format -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "Control longitudinal de openpilot (Alpha)" - -#: selfdrive/ui/onroad/alert_renderer.py:51 -#, python-format -msgid "openpilot Unavailable" -msgstr "openpilot no disponible" - -#: selfdrive/ui/layouts/settings/toggles.py:158 -#, python-format -msgid "" -"openpilot defaults to driving in chill mode. Experimental mode enables alpha-" -"level features that aren't ready for chill mode. Experimental features are " -"listed below:

End-to-End Longitudinal Control


Let the driving " -"model control the gas and brakes. openpilot will drive as it thinks a human " -"would, including stopping for red lights and stop signs. Since the driving " -"model decides the speed to drive, the set speed will only act as an upper " -"bound. This is an alpha quality feature; mistakes should be expected." -"

New Driving Visualization


The driving visualization will " -"transition to the road-facing wide-angle camera at low speeds to better show " -"some turns. The Experimental mode logo will also be shown in the top right " -"corner." -msgstr "" -"openpilot conduce por defecto en modo chill. El modo Experimental habilita " -"funciones de nivel alpha que no están listas para el modo chill. Las " -"funciones experimentales se enumeran a continuación:

Control " -"longitudinal de extremo a extremo


Deja que el modelo de conducción " -"controle el acelerador y los frenos. openpilot conducirá como piensa que lo " -"haría un humano, incluyendo detenerse en luces rojas y señales de alto. Dado " -"que el modelo decide la velocidad a la que conducir, la velocidad " -"establecida solo actuará como límite superior. Esta es una función de " -"calidad alpha; se deben esperar errores.

Nueva visualización de " -"conducción


La visualización de conducción hará la transición a la " -"cámara gran angular orientada a la carretera a bajas velocidades para " -"mostrar mejor algunos giros. El logotipo del modo Experimental también se " -"mostrará en la esquina superior derecha." - -#: selfdrive/ui/layouts/settings/device.py:165 -#, python-format -msgid "" -"openpilot is continuously calibrating, resetting is rarely required. " -"Resetting calibration will restart openpilot if the car is powered on." -msgstr "" -" Cambiar esta configuración reiniciará openpilot si el coche está encendido." - -#: selfdrive/ui/layouts/settings/firehose.py:20 -msgid "" -"openpilot learns to drive by watching humans, like you, drive.\n" -"\n" -"Firehose Mode allows you to maximize your training data uploads to improve " -"openpilot's driving models. More data means bigger models, which means " -"better Experimental Mode." -msgstr "" -"openpilot aprende a conducir observando a humanos, como tú, conducir.\n" -"\n" -"El Modo Firehose te permite maximizar tus cargas de datos de entrenamiento " -"para mejorar los modelos de conducción de openpilot. Más datos significan " -"modelos más grandes, lo que significa un mejor Modo Experimental." - -#: selfdrive/ui/layouts/settings/toggles.py:183 -#, python-format -msgid "openpilot longitudinal control may come in a future update." -msgstr "" -"El control longitudinal de openpilot podría llegar en una actualización " -"futura." - -#: selfdrive/ui/layouts/settings/device.py:26 -msgid "" -"openpilot requires the device to be mounted within 4° left or right and " -"within 5° up or 9° down." -msgstr "" -"openpilot requiere que el dispositivo esté montado dentro de 4° a izquierda " -"o derecha y dentro de 5° hacia arriba o 9° hacia abajo." - -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "derecha" -#: system/ui/widgets/network.py:142 -#, python-format -msgid "unmetered" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" msgstr "" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + +#: system/ui/widgets/network.py +msgid "unmetered" +msgstr "sin medir" + +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "up" msgstr "arriba" -#: selfdrive/ui/layouts/settings/software.py:117 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked never" msgstr "actualizado, última comprobación: nunca" -#: selfdrive/ui/layouts/settings/software.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked {}" msgstr "actualizado, última comprobación: {}" -#: selfdrive/ui/layouts/settings/software.py:109 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "update available" msgstr "actualización disponible" -#: selfdrive/ui/layouts/home.py:169 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "{} ALERT" msgid_plural "{} ALERTS" msgstr[0] "{} ALERTA" msgstr[1] "{} ALERTAS" -#: selfdrive/ui/layouts/settings/software.py:40 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "hace {} día" msgstr[1] "hace {} días" -#: selfdrive/ui/layouts/settings/software.py:37 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "hace {} hora" msgstr[1] "hace {} horas" -#: selfdrive/ui/layouts/settings/software.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} minute ago" msgid_plural "{} minutes ago" msgstr[0] "hace {} minuto" msgstr[1] "hace {} minutos" -#: selfdrive/ui/layouts/settings/firehose.py:111 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "{} segment of your driving is in the training dataset so far." msgid_plural "{} segments of your driving is in the training dataset so far." -msgstr[0] "" -"{} segmento de tu conducción está en el conjunto de entrenamiento hasta " -"ahora." -msgstr[1] "" -"{} segmentos de tu conducción están en el conjunto de entrenamiento hasta " -"ahora." +msgstr[0] "{} segmento de tu conducción está en el conjunto de entrenamiento hasta ahora." +msgstr[1] "{} segmentos de tu conducción están en el conjunto de entrenamiento hasta ahora." -#: selfdrive/ui/widgets/prime.py:62 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "✓ SUBSCRIBED" msgstr "✓ SUSCRITO" -#: selfdrive/ui/widgets/setup.py:22 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "🔥 Firehose Mode 🔥" msgstr "🔥 Modo Firehose 🔥" + diff --git a/selfdrive/ui/translations/app_fr.po b/selfdrive/ui/translations/app_fr.po index ed9900a6ba..ac5b4deea5 100644 --- a/selfdrive/ui/translations/app_fr.po +++ b/selfdrive/ui/translations/app_fr.po @@ -1,2777 +1,873 @@ -# French translations for PACKAGE package. -# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# Automatically generated, 2025. -# msgid "" msgstr "" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 00:50-0700\n" -"PO-Revision-Date: 2026-01-24 12:37+0100\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" -"Language: fr\n" -"MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" +"Language: fr\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -"X-Generator: Poedit 3.8\n" -#: selfdrive/ui/layouts/settings/device.py:160 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is complete." msgstr " Calibration de la réponse du couple de direction terminée." -#: selfdrive/ui/layouts/settings/device.py:158 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is {}% complete." msgstr " Calibration du couple de direction : {}% effectué." -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Your device is pointed {:.1f}° {} and {:.1f}° {}." msgstr " Votre appareil est orienté {:.1f}° {} et {:.1f}° {}." -#: selfdrive/ui/layouts/sidebar.py:43 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "--" msgstr "--" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "1 year of drive storage" msgstr "1 an de stockage de trajets" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "24/7 LTE connectivity" msgstr "Connexion LTE 24/7" -#: selfdrive/ui/layouts/sidebar.py:46 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "2G" msgstr "2G" -#: selfdrive/ui/layouts/sidebar.py:47 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "3G" msgstr "3G" -#: selfdrive/ui/layouts/sidebar.py:49 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "5G" msgstr "5G" -#: selfdrive/ui/layouts/settings/developer.py:23 -msgid "" -"WARNING: openpilot longitudinal control is in alpha for this car and will" -" disable Automatic Emergency Braking (AEB).

On this car, " -"openpilot defaults to the car's built-in ACC instead of openpilot's " -"longitudinal control. Enable this to switch to openpilot longitudinal " -"control. Enabling Experimental mode is recommended when enabling openpilot " -"longitudinal control alpha. Changing this setting will restart openpilot if " -"the car is powered on." -msgstr "" -"ATTENTION : le contrôle longitudinal openpilot est en alpha pour cette " -"voiture et désactivera le freinage d'urgence automatique " -"(AEB).

Sur cette voiture, openpilot utilise par défaut le " -"régulateur de vitesse adaptatif intégré au véhicule plutôt que le contrôle " -"longitudinal d'openpilot. Activez ceci pour passer au contrôle longitudinal " -"openpilot. Il est recommandé d'activer le mode expérimental lors de " -"l'activation du contrôle longitudinal openpilot alpha." - -#: selfdrive/ui/layouts/settings/device.py:148 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is complete." msgstr "

La calibration du délai de direction est terminée." -#: selfdrive/ui/layouts/settings/device.py:146 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is {}% complete." msgstr "

Calibration du délai de direction : {}% effectué." -#: selfdrive/ui/layouts/settings/firehose.py:138 -#, python-format -msgid "ACTIVE" -msgstr "ACTIF" - -#: selfdrive/ui/layouts/settings/developer.py:15 -msgid "" -"ADB (Android Debug Bridge) allows connecting to your device over USB or over" -" the network. See https://docs.comma.ai/how-to/connect-to-comma for more " -"info." -msgstr "" -"ADB (Android Debug Bridge) permet de connecter votre appareil via USB ou via" -" le réseau. Voir https://docs.comma.ai/how-to/connect-to-comma pour plus " -"d'informations." - -#: selfdrive/ui/widgets/ssh_key.py:30 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "ADD" msgstr "AJOUTER" -#: system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "APN Setting" msgstr "Paramètre APN" -#: selfdrive/ui/widgets/offroad_alerts.py:109 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Acknowledge Excessive Actuation" msgstr "Accuser réception d'actionnement excessif" -#: system/ui/widgets/network.py:74 system/ui/widgets/network.py:95 -#, python-format +#: system/ui/widgets/network.py msgid "Advanced" msgstr "Avancé" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Aggressive" msgstr "Agressif" -#: selfdrive/ui/layouts/onboarding.py:116 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Agree" msgstr "Accepter" -#: selfdrive/ui/layouts/settings/toggles.py:70 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Always-On Driver Monitoring" msgstr "Surveillance continue du conducteur" -#: selfdrive/ui/layouts/settings/toggles.py:186 -#, python-format -msgid "" -"An alpha version of openpilot longitudinal control can be tested, along with" -" Experimental mode, on non-release branches." -msgstr "" -"Une version alpha du contrôle longitudinal openpilot peut être testée, avec " -"le mode expérimental, sur des branches non publiées." - -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to power off?" msgstr "Êtes-vous sûr de vouloir éteindre ?" -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reboot?" msgstr "Êtes-vous sûr de vouloir redémarrer ?" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reset calibration?" msgstr "Êtes-vous sûr de vouloir réinitialiser la calibration ?" -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Are you sure you want to uninstall?" msgstr "Êtes-vous sûr de vouloir désinstaller ?" -#: system/ui/widgets/network.py:99 selfdrive/ui/layouts/onboarding.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +#: system/ui/widgets/network.py msgid "Back" msgstr "Retour" -#: selfdrive/ui/widgets/prime.py:38 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Become a comma prime member at connect.comma.ai" msgstr "Devenez membre comma prime sur connect.comma.ai" -#: selfdrive/ui/widgets/pairing_dialog.py:130 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Bookmark connect.comma.ai to your home screen to use it like an app" -msgstr "" -"Ajoutez connect.comma.ai à votre écran d'accueil pour l'utiliser comme une " -"application" +msgstr "Ajoutez connect.comma.ai à votre écran d'accueil pour l'utiliser comme une application" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "CHANGE" msgstr "CHANGER" -#: selfdrive/ui/layouts/settings/software.py:50 -#: selfdrive/ui/layouts/settings/software.py:107 -#: selfdrive/ui/layouts/settings/software.py:118 -#: selfdrive/ui/layouts/settings/software.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "CHECK" msgstr "VÉRIFIER" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "CHILL MODE ON" msgstr "MODE CHILL ACTIVÉ" -#: system/ui/widgets/network.py:155 selfdrive/ui/layouts/sidebar.py:73 -#: selfdrive/ui/layouts/sidebar.py:134 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:138 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/network.py msgid "CONNECT" msgstr "CONNECTER" -#: system/ui/widgets/network.py:369 -#, python-format +#: system/ui/widgets/network.py msgid "CONNECTING..." msgstr "CONNEXION..." -#: system/ui/widgets/confirm_dialog.py:23 -#: system/ui/widgets/option_dialog.py:35 system/ui/widgets/keyboard.py:81 -#: system/ui/widgets/network.py:318 -#, python-format +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/keyboard.py +#: system/ui/widgets/network.py +#: system/ui/widgets/option_dialog.py msgid "Cancel" msgstr "Annuler" -#: system/ui/widgets/network.py:134 -#, python-format +#: system/ui/widgets/network.py msgid "Cellular Metered" msgstr "Données cellulaires limitées" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Change Language" msgstr "Changer la langue" -#: selfdrive/ui/layouts/settings/toggles.py:125 -#, python-format -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "" -"La modification de ce réglage redémarrera openpilot si la voiture est sous " -"tension." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "La modification de ce réglage redémarrera sunnypilot si la voiture est sous tension." -#: selfdrive/ui/widgets/pairing_dialog.py:129 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "Choisissez votre niveau de parrainage et confirmez votre soutien" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" msgstr "Cliquez sur \"add new device\" et scannez le code QR à droite" -#: selfdrive/ui/widgets/offroad_alerts.py:104 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Close" msgstr "Fermer" -#: selfdrive/ui/layouts/settings/software.py:49 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Current Version" msgstr "Version actuelle" -#: selfdrive/ui/layouts/settings/software.py:110 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "DOWNLOAD" msgstr "TÉLÉCHARGER" -#: selfdrive/ui/layouts/onboarding.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Decline" msgstr "Refuser" -#: selfdrive/ui/layouts/onboarding.py:148 -#, python-format -msgid "Decline, uninstall openpilot" -msgstr "Refuser, désinstaller openpilot" +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline, uninstall sunnypilot" +msgstr "Refuser, désinstaller sunnypilot" -#: selfdrive/ui/layouts/settings/settings.py:67 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" msgstr "Développeur" -#: selfdrive/ui/layouts/settings/settings.py:62 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Device" msgstr "Appareil" -#: selfdrive/ui/layouts/settings/toggles.py:58 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Disengage on Accelerator Pedal" msgstr "Désengager à l'appui sur l'accélérateur" -#: selfdrive/ui/layouts/settings/device.py:184 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Power Off" msgstr "Désengager pour éteindre" -#: selfdrive/ui/layouts/settings/device.py:172 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reboot" msgstr "Désengager pour redémarrer" -#: selfdrive/ui/layouts/settings/device.py:103 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reset Calibration" msgstr "Désengager pour réinitialiser la calibration" -#: selfdrive/ui/layouts/settings/toggles.py:32 +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Display speed in km/h instead of mph." msgstr "Afficher la vitesse en km/h au lieu de mph." -#: selfdrive/ui/layouts/settings/device.py:59 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Dongle ID" msgstr "ID du dongle" -#: selfdrive/ui/layouts/settings/software.py:50 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Download" msgstr "Télécharger" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Driver Camera" msgstr "Caméra conducteur" -#: selfdrive/ui/layouts/settings/toggles.py:96 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Driving Personality" msgstr "Personnalité de conduite" -#: system/ui/widgets/network.py:123 system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "EDIT" msgstr "MODIFIER" -#: selfdrive/ui/layouts/sidebar.py:138 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ERROR" msgstr "ERREUR" -#: selfdrive/ui/layouts/sidebar.py:45 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ETH" msgstr "ETH" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "EXPERIMENTAL MODE ON" msgstr "MODE EXPÉRIMENTAL ACTIVÉ" -#: selfdrive/ui/layouts/settings/developer.py:166 -#: selfdrive/ui/layouts/settings/toggles.py:228 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "Accès anticipé : Devenez parrain sunnypilot" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Activer" -#: selfdrive/ui/layouts/settings/developer.py:39 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable ADB" msgstr "Activer ADB" -#: selfdrive/ui/layouts/settings/toggles.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable Lane Departure Warnings" msgstr "Activer les alertes de sortie de voie" -#: system/ui/widgets/network.py:129 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Roaming" msgstr "Activer l'itinérance" -#: selfdrive/ui/layouts/settings/developer.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable SSH" msgstr "Activer SSH" -#: system/ui/widgets/network.py:120 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Tethering" msgstr "Activer le partage de connexion" -#: selfdrive/ui/layouts/settings/toggles.py:30 -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "" -"Activer la surveillance du conducteur même lorsque openpilot n'est pas " -"engagé." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "Activer la surveillance du conducteur même lorsque sunnypilot n'est pas engagé." -#: selfdrive/ui/layouts/settings/toggles.py:46 -#, python-format -msgid "Enable openpilot" -msgstr "Activer openpilot" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable sunnypilot" +msgstr "Activer sunnypilot" -#: selfdrive/ui/layouts/settings/toggles.py:189 -#, python-format -msgid "" -"Enable the openpilot longitudinal control (alpha) toggle to allow " -"Experimental mode." -msgstr "" -"Activez l'option de contrôle longitudinal openpilot (alpha) pour autoriser " -"le mode expérimental." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "Activez l'option de contrôle longitudinal sunnypilot (alpha) pour autoriser le mode expérimental." -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "Enter APN" msgstr "Entrez l'APN" -#: system/ui/widgets/network.py:241 -#, python-format +#: system/ui/widgets/network.py msgid "Enter SSID" msgstr "Entrez le SSID" -#: system/ui/widgets/network.py:254 -#, python-format +#: system/ui/widgets/network.py msgid "Enter new tethering password" msgstr "Entrez un nouveau mot de passe de partage" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Enter password" msgstr "Entrez le mot de passe" -#: selfdrive/ui/widgets/ssh_key.py:89 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "Entrez votre recherche" + +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "Entrez votre nom d'utilisateur GitHub" -#: system/ui/widgets/list_view.py:123 system/ui/widgets/list_view.py:160 -#, python-format +#: system/ui/widgets/list_view.py msgid "Error" msgstr "Erreur" -#: selfdrive/ui/layouts/settings/toggles.py:52 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Experimental Mode" msgstr "Mode expérimental" -#: selfdrive/ui/layouts/settings/toggles.py:181 -#, python-format -msgid "" -"Experimental mode is currently unavailable on this car since the car's stock" -" ACC is used for longitudinal control." -msgstr "" -"Le mode expérimental est actuellement indisponible sur cette voiture car " -"l'ACC d'origine est utilisé pour le contrôle longitudinal." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." +msgstr "Le mode expérimental est actuellement indisponible sur cette voiture car l'ACC d'origine est utilisé pour le contrôle longitudinal." -#: system/ui/widgets/network.py:373 -#, python-format +#: system/ui/widgets/network.py msgid "FORGETTING..." msgstr "SUPPRESSION..." -#: selfdrive/ui/widgets/setup.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Finish Setup" msgstr "Finir la config." -#: selfdrive/ui/layouts/settings/settings.py:66 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Firehose" msgstr "Firehose" -#: selfdrive/ui/layouts/settings/firehose.py:18 +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "Firehose Mode" msgstr "Mode Firehose" -#: selfdrive/ui/layouts/settings/firehose.py:25 -msgid "" -"For maximum effectiveness, bring your device inside and connect to a good USB-C adapter and Wi-Fi weekly.\n" -"\n" -"Firehose Mode can also work while you're driving if connected to a hotspot or unlimited SIM card.\n" -"\n" -"\n" -"Frequently Asked Questions\n" -"\n" -"Does it matter how or where I drive? Nope, just drive as you normally would.\n" -"\n" -"Do all of my segments get pulled in Firehose Mode? No, we selectively pull a subset of your segments.\n" -"\n" -"What's a good USB-C adapter? Any fast phone or laptop charger should be fine.\n" -"\n" -"Does it matter which software I run? Yes, only upstream openpilot (and particular forks) are able to be used for training." -msgstr "" -"Pour une efficacité maximale, rentrez votre appareil et connectez-le chaque semaine à un bon adaptateur USB-C et au Wi‑Fi.\n" -"\n" -"Le Mode Firehose peut aussi fonctionner pendant que vous conduisez si vous êtes connecté à un hotspot ou à une carte SIM illimitée.\n" -"\n" -"\n" -"Foire aux questions\n" -"\n" -"Est-ce que la manière ou l'endroit où je conduis compte ? Non, conduisez normalement.\n" -"\n" -"Tous mes segments sont-ils récupérés en Mode Firehose ? Non, nous récupérons de façon sélective un sous-ensemble de vos segments.\n" -"\n" -"Quel est un bon adaptateur USB-C ? Tout chargeur rapide de téléphone ou d'ordinateur portable convient.\n" -"\n" -"Le logiciel utilisé importe-t-il ? Oui, seul openpilot amont (et certains forks) peut être utilisé pour l'entraînement." +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" +msgstr "Suivez les instructions pour terminer le processus d'association" -#: system/ui/widgets/network.py:318 system/ui/widgets/network.py:451 -#, python-format +#: system/ui/widgets/network.py msgid "Forget" msgstr "Oublier" -#: system/ui/widgets/network.py:319 -#, python-format +#: system/ui/widgets/network.py msgid "Forget Wi-Fi Network \"{}\"?" msgstr "Oublier le réseau Wi‑Fi \"{}\" ?" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "GOOD" msgstr "BONNE" -#: selfdrive/ui/widgets/pairing_dialog.py:128 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Go to https://connect.comma.ai on your phone" msgstr "Allez sur https://connect.comma.ai sur votre téléphone" -#: selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "HIGH" msgstr "ÉLEVÉ" -#: system/ui/widgets/network.py:155 -#, python-format +#: system/ui/widgets/network.py msgid "Hidden Network" msgstr "Réseau masqué" -#: selfdrive/ui/layouts/settings/firehose.py:140 -#, python-format -msgid "INACTIVE: connect to an unmetered network" -msgstr "INACTIF : connectez-vous à un réseau non limité" - -#: selfdrive/ui/layouts/settings/software.py:53 -#: selfdrive/ui/layouts/settings/software.py:136 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "INSTALL" msgstr "INSTALLER" -#: system/ui/widgets/network.py:150 -#, python-format +#: system/ui/widgets/network.py msgid "IP Address" msgstr "Adresse IP" -#: selfdrive/ui/layouts/settings/software.py:53 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "Si le statut de parrainage n'a pas été mis à jour, veuillez contacter un modérateur sur le forum communautaire à https://community.sunnypilot.ai" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Installer la mise à jour" -#: selfdrive/ui/layouts/settings/developer.py:56 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "Rejoignez notre forum communautaire à https://community.sunnypilot.ai et contactez un modérateur si vous avez des problèmes" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "Mode débogage joystick" -#: selfdrive/ui/widgets/ssh_key.py:29 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "LOADING" msgstr "CHARGEMENT" -#: selfdrive/ui/layouts/sidebar.py:48 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "LTE" msgstr "LTE" -#: selfdrive/ui/layouts/settings/developer.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "Mode de manœuvre longitudinale" -#: selfdrive/ui/onroad/hud_renderer.py:148 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "MAX" msgstr "MAX" -#: selfdrive/ui/widgets/setup.py:75 -#, python-format -msgid "" -"Maximize your training data uploads to improve openpilot's driving models." -msgstr "" -"Maximisez vos envois de données d'entraînement pour améliorer les modèles de" -" conduite d'openpilot." +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Maximize your training data uploads to improve openpilot's driving models." +msgstr "Maximisez vos envois de données d'entraînement pour améliorer les modèles de conduite d'openpilot." -#: selfdrive/ui/layouts/settings/device.py:59 -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "N/A" msgstr "N/A" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "NO" msgstr "NON" -#: selfdrive/ui/layouts/settings/settings.py:63 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Network" msgstr "Réseau" -#: selfdrive/ui/widgets/ssh_key.py:114 -#, python-format -msgid "No SSH keys found" -msgstr "Aucune clé SSH trouvée" - -#: selfdrive/ui/widgets/ssh_key.py:126 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "No SSH keys found for user '{}'" msgstr "Aucune clé SSH trouvée pour l'utilisateur '{}'" -#: selfdrive/ui/widgets/offroad_alerts.py:320 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "No release notes available." msgstr "Aucune note de version disponible." -#: selfdrive/ui/layouts/sidebar.py:73 selfdrive/ui/layouts/sidebar.py:134 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "OFFLINE" msgstr "HORS LIGNE" -#: system/ui/widgets/html_render.py:263 system/ui/widgets/confirm_dialog.py:93 -#: selfdrive/ui/layouts/sidebar.py:127 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/html_render.py msgid "OK" msgstr "OK" -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ONLINE" msgstr "EN LIGNE" -#: selfdrive/ui/widgets/setup.py:20 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Open" msgstr "Ouvrir" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PAIR" msgstr "ASSOCIER" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "PANDA" msgstr "PANDA" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PREVIEW" msgstr "APERÇU" -#: selfdrive/ui/widgets/prime.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "PRIME FEATURES:" msgstr "FONCTIONNALITÉS PRIME :" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Pair Device" msgstr "Associer l'appareil" -#: selfdrive/ui/widgets/setup.py:19 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Pair device" msgstr "Associer l'appareil" -#: selfdrive/ui/widgets/pairing_dialog.py:103 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "Associer votre compte GitHub" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "Associez votre appareil à votre compte comma" -#: selfdrive/ui/widgets/setup.py:48 selfdrive/ui/layouts/settings/device.py:24 -#, python-format -msgid "" -"Pair your device with comma connect (connect.comma.ai) and claim your comma " -"prime offer." -msgstr "" -"Associez votre appareil à comma connect (connect.comma.ai) et réclamez votre" -" offre comma prime." +#: openpilot/selfdrive/ui/layouts/settings/device.py +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." +msgstr "Associez votre appareil à comma connect (connect.comma.ai) et réclamez votre offre comma prime." -#: selfdrive/ui/widgets/setup.py:91 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Please connect to Wi-Fi to complete initial pairing" msgstr "Veuillez vous connecter au Wi‑Fi pour terminer l'association initiale" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Power Off" msgstr "Éteindre" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered Wi-Fi connection" -msgstr "" -"Empêcher les téléversements volumineux sur une connexion Wi‑Fi limitée" +msgstr "Empêcher les téléversements volumineux sur une connexion Wi‑Fi limitée" -#: system/ui/widgets/network.py:135 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered cellular connection" -msgstr "" -"Empêcher les téléversements volumineux sur une connexion cellulaire limitée" +msgstr "Empêcher les téléversements volumineux sur une connexion cellulaire limitée" -#: selfdrive/ui/layouts/settings/device.py:25 -msgid "" -"Preview the driver facing camera to ensure that driver monitoring has good " -"visibility. (vehicle must be off)" -msgstr "" -"Prévisualisez la caméra orientée conducteur pour vous assurer que la " -"surveillance du conducteur a une bonne visibilité. (le véhicule doit être " -"éteint)" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)" +msgstr "Prévisualisez la caméra orientée conducteur pour vous assurer que la surveillance du conducteur a une bonne visibilité. (le véhicule doit être éteint)" -#: selfdrive/ui/widgets/pairing_dialog.py:161 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "QR Code Error" msgstr "Erreur de code QR" -#: selfdrive/ui/widgets/ssh_key.py:31 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "REMOVE" msgstr "SUPPRIMER" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "RESET" msgstr "RÉINITIALISER" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "REVIEW" msgstr "CONSULTER" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "Retournez dans le panneau \"sunnylink\" pour vérifier le statut de parrainage" + +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "Redémarrer" -#: selfdrive/ui/onroad/alert_renderer.py:66 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Reboot Device" msgstr "Redémarrer l'appareil" -#: selfdrive/ui/widgets/offroad_alerts.py:112 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Reboot and Update" msgstr "Redémarrer et mettre à jour" -#: selfdrive/ui/layouts/settings/toggles.py:27 -msgid "" -"Receive alerts to steer back into the lane when your vehicle drifts over a " -"detected lane line without a turn signal activated while driving over 31 mph" -" (50 km/h)." -msgstr "" -"Recevez des alertes pour revenir dans la voie lorsque votre véhicule dépasse" -" une ligne de voie détectée sans clignotant activé en roulant au-delà de 31 " -"mph (50 km/h)." - -#: selfdrive/ui/layouts/settings/toggles.py:76 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Driver Camera" msgstr "Enregistrer et téléverser la caméra conducteur" -#: selfdrive/ui/layouts/settings/toggles.py:82 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Microphone Audio" msgstr "Enregistrer et téléverser l'audio du microphone" -#: selfdrive/ui/layouts/settings/toggles.py:33 -msgid "" -"Record and store microphone audio while driving. The audio will be included " -"in the dashcam video in comma connect." -msgstr "" -"Enregistrer et stocker l'audio du microphone pendant la conduite. L'audio " -"sera inclus dans la vidéo dashcam dans comma connect." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect." +msgstr "Enregistrer et stocker l'audio du microphone pendant la conduite. L'audio sera inclus dans la vidéo dashcam dans comma connect." -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Regulatory" msgstr "Réglementaire" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Relaxed" msgstr "Détendu" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote access" msgstr "Accès à distance" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote snapshots" msgstr "Captures à distance" -#: selfdrive/ui/widgets/ssh_key.py:123 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Request timed out" msgstr "Délai de la requête dépassé" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset" msgstr "Réinitialiser" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset Calibration" msgstr "Réinitialiser la calibration" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Review Training Guide" msgstr "Consulter le guide d'entraînement" -#: selfdrive/ui/layouts/settings/device.py:27 -msgid "Review the rules, features, and limitations of openpilot" -msgstr "Consultez les règles, fonctionnalités et limitations d'openpilot" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "Consultez les règles, fonctionnalités et limitations de sunnypilot" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" msgstr "CHOISIR" -#: selfdrive/ui/layouts/settings/developer.py:53 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "SSH Keys" msgstr "Clés SSH" -#: system/ui/widgets/network.py:310 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "Scannez le code QR pour vous connecter à votre compte GitHub" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "Scannez le code QR pour visiter la page GitHub Sponsors de sunnyhaibin" + +#: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "Recherche des réseaux Wi‑Fi..." -#: system/ui/widgets/option_dialog.py:36 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "Rechercher" + +#: system/ui/widgets/option_dialog.py msgid "Select" msgstr "Sélectionner" -#: selfdrive/ui/layouts/settings/software.py:183 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Select a branch" msgstr "Sélectionner une branche" -#: selfdrive/ui/layouts/settings/device.py:91 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Select a language" msgstr "Sélectionner une langue" -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Serial" msgstr "Numéro de série" -#: selfdrive/ui/widgets/offroad_alerts.py:106 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Snooze Update" msgstr "Reporter la mise à jour" -#: selfdrive/ui/layouts/settings/settings.py:65 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Software" msgstr "Logiciel" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Standard" msgstr "Standard" -#: selfdrive/ui/layouts/settings/toggles.py:22 -msgid "" -"Standard is recommended. In aggressive mode, openpilot will follow lead cars" -" closer and be more aggressive with the gas and brake. In relaxed mode " -"openpilot will stay further away from lead cars. On supported cars, you can " -"cycle through these personalities with your steering wheel distance button." -msgstr "" -"Le mode standard est recommandé. En mode agressif, openpilot suivra les " -"véhicules de tête de plus près et sera plus agressif avec l'accélérateur et " -"le frein. En mode détendu, openpilot restera plus éloigné des véhicules de " -"tête. Sur les voitures compatibles, vous pouvez parcourir ces personnalités " -"avec le bouton de distance du volant." - -#: selfdrive/ui/onroad/alert_renderer.py:59 -#: selfdrive/ui/onroad/alert_renderer.py:65 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "System Unresponsive" msgstr "Système non réactif" -#: selfdrive/ui/onroad/alert_renderer.py:58 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "TAKE CONTROL IMMEDIATELY" msgstr "REPRENEZ IMMÉDIATEMENT LE CONTRÔLE" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 -#: selfdrive/ui/layouts/sidebar.py:127 selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "TEMP" msgstr "TEMP." -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Target Branch" msgstr "Branche cible" -#: system/ui/widgets/network.py:124 -#, python-format +#: system/ui/widgets/network.py msgid "Tethering Password" msgstr "Mot de passe de partage" -#: selfdrive/ui/layouts/settings/settings.py:64 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Toggles" msgstr "Options" -#: selfdrive/ui/layouts/settings/software.py:72 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "UI Debug Mode" +msgstr "Mode débogage UI" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "UNINSTALL" msgstr "SUPPRIMER" -#: selfdrive/ui/layouts/home.py:155 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "UPDATE" msgstr "METTRE À JOUR" -#: selfdrive/ui/layouts/settings/software.py:72 -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Uninstall" msgstr "Désinstaller" -#: selfdrive/ui/layouts/sidebar.py:117 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Unknown" msgstr "Inconnu" -#: selfdrive/ui/layouts/settings/software.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Updates are only downloaded while the car is off." -msgstr "" -"Les mises à jour ne sont téléchargées que lorsque la voiture est éteinte." +msgstr "Les mises à jour ne sont téléchargées que lorsque la voiture est éteinte." -#: selfdrive/ui/widgets/prime.py:33 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Upgrade Now" msgstr "Mettre à jour" -#: selfdrive/ui/layouts/settings/toggles.py:31 -msgid "" -"Upload data from the driver facing camera and help improve the driver " -"monitoring algorithm." -msgstr "" -"Téléverser les données de la caméra orientée conducteur et aider à améliorer" -" l'algorithme de surveillance du conducteur." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." +msgstr "Téléverser les données de la caméra orientée conducteur et aider à améliorer l'algorithme de surveillance du conducteur." -#: selfdrive/ui/layouts/settings/toggles.py:88 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Use Metric System" msgstr "Utiliser le système métrique" -#: selfdrive/ui/layouts/settings/toggles.py:17 -msgid "" -"Use the openpilot system for adaptive cruise control and lane keep driver " -"assistance. Your attention is required at all times to use this feature." -msgstr "" -"Utilisez le système openpilot pour l'ACC et l'assistance au maintien de " -"voie. Votre attention est requise en permanence pour utiliser cette " -"fonctionnalité." - -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "VEHICLE" msgstr "VÉHICULE" -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "VIEW" msgstr "VOIR" -#: selfdrive/ui/onroad/alert_renderer.py:52 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Waiting to start" msgstr "En attente de démarrage" -#: selfdrive/ui/layouts/settings/developer.py:19 -msgid "" -"Warning: This grants SSH access to all public keys in your GitHub settings. " -"Never enter a GitHub username other than your own. A comma employee will " -"NEVER ask you to add their GitHub username." -msgstr "" -"Avertissement : Ceci accorde un accès SSH à toutes les clés publiques dans " -"vos paramètres GitHub. N'entrez jamais un nom d'utilisateur GitHub autre que" -" le vôtre. Un employé comma ne vous demandera JAMAIS d'ajouter son nom " -"d'utilisateur GitHub." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Welcome to sunnypilot" +msgstr "Bienvenue sur sunnypilot" -#: selfdrive/ui/layouts/onboarding.py:111 -#, python-format -msgid "Welcome to openpilot" -msgstr "Bienvenue sur openpilot" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "Lorsqu'activé, appuyer sur la pédale d'accélérateur désengagera sunnypilot." -#: selfdrive/ui/layouts/settings/toggles.py:20 -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "" -"Lorsque activé, appuyer sur la pédale d'accélérateur désengagera openpilot." - -#: selfdrive/ui/layouts/sidebar.py:44 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" msgstr "Wi‑Fi" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Wi-Fi Network Metered" msgstr "Réseau Wi‑Fi limité" -#: system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Wrong password" msgstr "Mot de passe incorrect" -#: selfdrive/ui/layouts/onboarding.py:145 -#, python-format -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "Vous devez accepter les conditions générales pour utiliser openpilot." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service in order to use sunnypilot." +msgstr "Vous devez accepter les conditions d'utilisation pour utiliser sunnypilot." -#: selfdrive/ui/layouts/onboarding.py:112 -#, python-format -msgid "" -"You must accept the Terms and Conditions to use openpilot. Read the latest " -"terms at https://comma.ai/terms before continuing." -msgstr "" -"Vous devez accepter les conditions générales pour utiliser openpilot. Lisez " -"les dernières conditions sur https://comma.ai/terms avant de continuer." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "Vous devez accepter les conditions d'utilisation pour utiliser sunnypilot. Consultez les dernières conditions sur https://sunnypilot.ai/terms avant de continuer." -#: selfdrive/ui/onroad/driver_camera_dialog.py:34 -#, python-format +#: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" msgstr "démarrage de la caméra" -#: selfdrive/ui/widgets/prime.py:63 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "checking..." +msgstr "vérification..." + +#: openpilot/selfdrive/ui/widgets/prime.py msgid "comma prime" msgstr "comma prime" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "default" msgstr "par défaut" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "down" msgstr "bas" -#: selfdrive/ui/layouts/settings/software.py:106 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "downloading..." +msgstr "téléchargement..." + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "failed to check for update" msgstr "échec de la vérification de mise à jour" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "finalizing update..." +msgstr "finalisation de la mise à jour..." + +#: system/ui/widgets/network.py msgid "for \"{}\"" msgstr "pour \"{}\"" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "leave blank for automatic configuration" msgstr "laisser vide pour configuration automatique" -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "left" msgstr "gauche" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "metered" msgstr "limité" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" -#: selfdrive/ui/layouts/settings/software.py:20 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "never" msgstr "jamais" -#: selfdrive/ui/layouts/settings/software.py:31 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "now" msgstr "maintenant" -#: selfdrive/ui/layouts/settings/developer.py:71 -#, python-format -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "Contrôle longitudinal openpilot (Alpha)" - -#: selfdrive/ui/onroad/alert_renderer.py:51 -#, python-format -msgid "openpilot Unavailable" -msgstr "openpilot indisponible" - -#: selfdrive/ui/layouts/settings/toggles.py:158 -#, python-format -msgid "" -"openpilot defaults to driving in chill mode. Experimental mode enables " -"alpha-level features that aren't ready for chill mode. Experimental features" -" are listed below:

End-to-End Longitudinal Control


Let the " -"driving model control the gas and brakes. openpilot will drive as it thinks " -"a human would, including stopping for red lights and stop signs. Since the " -"driving model decides the speed to drive, the set speed will only act as an " -"upper bound. This is an alpha quality feature; mistakes should be " -"expected.

New Driving Visualization


The driving visualization" -" will transition to the road-facing wide-angle camera at low speeds to " -"better show some turns. The Experimental mode logo will also be shown in the" -" top right corner." -msgstr "" -"openpilot roule par défaut en mode chill. Le mode expérimental active des " -"fonctionnalités de niveau alpha qui ne sont pas prêtes pour le mode chill. " -"Les fonctionnalités expérimentales sont listées ci‑dessous:

Contrôle " -"longitudinal de bout en bout


Laissez le modèle de conduite contrôler" -" l'accélérateur et les freins. openpilot conduira comme il pense qu'un " -"humain le ferait, y compris s'arrêter aux feux rouges et aux panneaux stop. " -"Comme le modèle décide de la vitesse à adopter, la vitesse réglée n'agira " -"que comme une limite supérieure. C'est une fonctionnalité de qualité alpha ;" -" des erreurs sont à prévoir.

Nouvelle visualisation de " -"conduite


La visualisation passera à la caméra grand angle orientée " -"route à basse vitesse pour mieux montrer certains virages. Le logo du mode " -"expérimental sera également affiché en haut à droite." - -#: selfdrive/ui/layouts/settings/device.py:165 -#, python-format -msgid "" -"openpilot is continuously calibrating, resetting is rarely required. " -"Resetting calibration will restart openpilot if the car is powered on." -msgstr "" -" La modification de ce réglage redémarrera openpilot si la voiture est sous " -"tension." - -#: selfdrive/ui/layouts/settings/firehose.py:20 -msgid "" -"openpilot learns to drive by watching humans, like you, drive.\n" -"\n" -"Firehose Mode allows you to maximize your training data uploads to improve openpilot's driving models. More data means bigger models, which means better Experimental Mode." -msgstr "" -"openpilot apprend à conduire en regardant des humains, comme vous, conduire.\n" -"\n" -"Le Mode Firehose vous permet de maximiser vos envois de données d'entraînement pour améliorer les modèles de conduite d'openpilot. Plus de données signifie des modèles plus grands, ce qui signifie un meilleur Mode expérimental." - -#: selfdrive/ui/layouts/settings/toggles.py:183 -#, python-format -msgid "openpilot longitudinal control may come in a future update." -msgstr "" -"Le contrôle longitudinal openpilot pourra arriver dans une future mise à " -"jour." - -#: selfdrive/ui/layouts/settings/device.py:26 -msgid "" -"openpilot requires the device to be mounted within 4° left or right and " -"within 5° up or 9° down." -msgstr "" -"openpilot exige que l'appareil soit monté à moins de 4° à gauche ou à droite" -" et à moins de 5° vers le haut ou 9° vers le bas." - -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "droite" -#: system/ui/widgets/network.py:142 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "Contrôle longitudinal sunnypilot (Alpha)" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "sunnypilot indisponible" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "Le contrôle longitudinal sunnypilot pourrait arriver dans une future mise à jour." + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + +#: system/ui/widgets/network.py msgid "unmetered" msgstr "illimité" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "up" msgstr "haut" -#: selfdrive/ui/layouts/settings/software.py:117 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked never" msgstr "à jour, dernière vérification jamais" -#: selfdrive/ui/layouts/settings/software.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked {}" msgstr "à jour, dernière vérification {}" -#: selfdrive/ui/layouts/settings/software.py:109 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "update available" msgstr "mise à jour disponible" -#: selfdrive/ui/layouts/home.py:169 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "{} ALERT" msgid_plural "{} ALERTS" msgstr[0] "{} ALERTE" msgstr[1] "{} ALERTES" -#: selfdrive/ui/layouts/settings/software.py:40 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "il y a {} jour" msgstr[1] "il y a {} jours" -#: selfdrive/ui/layouts/settings/software.py:37 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "il y a {} heure" msgstr[1] "il y a {} heures" -#: selfdrive/ui/layouts/settings/software.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} minute ago" msgid_plural "{} minutes ago" msgstr[0] "il y a {} minute" msgstr[1] "il y a {} minutes" -#: selfdrive/ui/layouts/settings/firehose.py:111 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "{} segment of your driving is in the training dataset so far." msgid_plural "{} segments of your driving is in the training dataset so far." -msgstr[0] "" -"{} segment de votre conduite est dans l'ensemble d'entraînement jusqu'à " -"présent." -msgstr[1] "" -"{} segments de votre conduite sont dans l'ensemble d'entraînement jusqu'à " -"présent." +msgstr[0] "{} segment de votre conduite est dans l'ensemble d'entraînement jusqu'à présent." +msgstr[1] "{} segments de votre conduite sont dans l'ensemble d'entraînement jusqu'à présent." -#: selfdrive/ui/widgets/prime.py:62 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "✓ SUBSCRIBED" msgstr "✓ ABONNÉ" -#: selfdrive/ui/widgets/setup.py:22 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "🔥 Firehose Mode 🔥" msgstr "🔥 Mode Firehose 🔥" -msgid " (Default)" -msgstr " (Par défaut)" - -msgid "%" -msgstr "%" - -msgid "" -"(Only for highest tiers, and does NOT bring ANY benefit to you yet. We are " -"just testing data volume.)" -msgstr "" -"(Réservé aux niveaux les plus élevés, et ne vous apporte AUCUN avantage pour" -" l'instant. Nous testons juste le volume de données.)" - -msgid ", but we'll be here when you're ready to come back." -msgstr ", mais nous serons là quand vous serez prêt à revenir." - -msgid "" -"WARNING: sunnypilot longitudinal control is in alpha for this car and " -"will disable Automatic Emergency Braking (AEB).

On this car, " -"sunnypilot defaults to the car's built-in ACC instead of sunnypilot's " -"longitudinal control. Enable this to switch to sunnypilot longitudinal " -"control. Enabling Experimental mode is recommended when enabling sunnypilot " -"longitudinal control alpha. Changing this setting will restart sunnypilot if" -" the car is powered on." -msgstr "" -"ATTENTION : le contrôle longitudinal sunnypilot est en alpha pour cette " -"voiture et désactivera le freinage d'urgence automatique " -"(AEB).

Sur cette voiture, sunnypilot utilise par défaut le " -"régulateur de vitesse adaptatif intégré au véhicule plutôt que le contrôle " -"longitudinal de sunnypilot. Activez ceci pour passer au contrôle " -"longitudinal sunnypilot. Il est recommandé d'activer le mode expérimental " -"lors de l'activation du contrôle longitudinal sunnypilot alpha. La " -"modification de ce réglage redémarrera sunnypilot si la voiture est sous " -"tension." - -msgid "" -"A beautiful rainbow effect on the path the model wants to take. It does not " -"affect driving in any way." -msgstr "" -"Un magnifique effet arc-en-ciel sur le trajet que le modèle souhaite " -"emprunter. Cela n'affecte en rien la conduite." - -msgid "" -"A chime and on-screen alert will play when the traffic light you are waiting" -" for turns green and you have no vehicle in front of you.
Note: This " -"chime is only designed as a notification. It is the driver's responsibility " -"to observe their environment and make decisions accordingly." -msgstr "" -"Un carillon et une alerte à l'écran se déclencheront lorsque le feu de " -"circulation que vous attendez passera au vert et qu'il n'y a pas de véhicule" -" devant vous.
Note : Ce carillon est uniquement conçu comme une " -"notification. Il incombe au conducteur d'observer son environnement et de " -"prendre les décisions en conséquence." - -msgid "" -"A chime and on-screen alert will play when you are stopped, and the vehicle " -"in front of you start moving.
Note: This chime is only designed as a " -"notification. It is the driver's responsibility to observe their environment" -" and make decisions accordingly." -msgstr "" -"Un carillon et une alerte à l'écran se déclencheront lorsque vous êtes à " -"l'arrêt et que le véhicule devant vous commence à avancer.
Note : Ce " -"carillon est uniquement conçu comme une notification. Il incombe au " -"conducteur d'observer son environnement et de prendre les décisions en " -"conséquence." - -msgid "ALL TIME" -msgstr "TOUT TEMPS" - -msgid "Actuator Delay:" -msgstr "Délai actionneur :" - -msgid "Adjust Lane Turn Speed" -msgstr "Ajuster la vitesse de virage de voie" - -msgid "Adjust Software Delay" -msgstr "Ajuster le délai logiciel" - -msgid "" -"Adjust the software delay when Live Learning Steer Delay is toggled off. The" -" default software delay value is 0.2" -msgstr "" -"Ajuster le délai logiciel lorsque l'apprentissage en direct du délai de " -"direction est désactivé. La valeur par défaut du délai logiciel est 0.2" - -msgid "All" -msgstr "Tout" - -msgid "All states (~6.0 GB)" -msgstr "Toutes les régions (~6.0 Go)" - -msgid "" -"Allows the driver to provide limited steering input while openpilot is " -"engaged." -msgstr "" -"Permet au conducteur de fournir une entrée de direction limitée pendant " -"qu'openpilot est engagé." - -msgid "Always On" -msgstr "Toujours allumé" - -msgid "" -"An alpha version of sunnypilot longitudinal control can be tested, along " -"with Experimental mode, on non-release branches." -msgstr "" -"Une version alpha du contrôle longitudinal sunnypilot peut être testée, avec" -" le mode expérimental, sur des branches non publiées." - -msgid "" -"Apply a custom timeout for settings UI.
This is the time after which " -"settings UI closes automatically if user is not interacting with the screen." -msgstr "" -"Appliquer un délai d'inactivité personnalisé pour l'interface des " -"paramètres.
C'est le temps après lequel l'interface des paramètres se " -"ferme automatiquement si l'utilisateur n'interagit pas avec l'écran." - -msgid "Are you sure you want to backup your current sunnypilot settings?" -msgstr "" -"Êtes-vous sûr de vouloir sauvegarder vos paramètres sunnypilot actuels ?" - -msgid "Are you sure you want to enter Always Offroad mode?" -msgstr "Êtes-vous sûr de vouloir entrer en mode toujours hors route ?" - -msgid "Are you sure you want to exit Always Offroad mode?" -msgstr "Êtes-vous sûr de vouloir quitter le mode toujours hors route ?" - -msgid "" -"Are you sure you want to reset all sunnypilot settings to default? Once the " -"settings are reset, there is no going back." -msgstr "" -"Êtes-vous sûr de vouloir réinitialiser tous les paramètres sunnypilot par " -"défaut ? Une fois les paramètres réinitialisés, il n'y a pas de retour en " -"arrière." - -msgid "" -"Are you sure you want to restore the last backed up sunnypilot settings?" -msgstr "" -"Êtes-vous sûr de vouloir restaurer la dernière sauvegarde des paramètres " -"sunnypilot ?" - -msgid "Assist" -msgstr "Assistance" - -msgid "" -"Assist: Adjusts the vehicle's cruise speed based on the current road's speed" -" limit when operating the +/- buttons." -msgstr "" -"Assistance : Ajuste la vitesse de croisière du véhicule en fonction de la " -"limite de vitesse de la route actuelle lors de l'utilisation des boutons " -"+/-." - -msgid "Auto (Dark)" -msgstr "Auto (Sombre)" - -msgid "Auto (Default)" -msgstr "Auto (Par défaut)" - -msgid "Auto Lane Change by Blinker" -msgstr "Changement de voie auto par clignotant" - -msgid "Auto Lane Change: Delay with Blind Spot" -msgstr "Changement de voie auto : Délai avec angle mort" - -msgid "Backup Failed" -msgstr "Échec de la sauvegarde" - -msgid "Backup Settings" -msgstr "Sauvegarder les paramètres" - -msgid "" -"Become a sponsor of sunnypilot to get early access to sunnylink features " -"when they become available." -msgstr "" -"Devenez parrain de sunnypilot pour obtenir un accès anticipé aux " -"fonctionnalités sunnylink dès leur disponibilité." - -msgid "Bottom" -msgstr "Bas" - -msgid "CLEAR" -msgstr "VIDER" - -msgid "Cancel Download" -msgstr "Annuler le téléchargement" - -msgid "Car First" -msgstr "Voiture d'abord" - -msgid "" -"Car First: Use Speed Limit data from Car if available, else use from " -"OpenStreetMaps" -msgstr "" -"Voiture d'abord : Utiliser les données de limite de vitesse de la voiture si" -" disponibles, sinon utiliser OpenStreetMaps" - -msgid "Car Only" -msgstr "Voiture uniquement" - -msgid "Car Only: Use Speed Limit data only from Car" -msgstr "" -"Voiture uniquement : Utiliser les données de limite de vitesse uniquement de" -" la voiture" - -msgid "" -"Changing this setting will restart sunnypilot if the car is powered on." -msgstr "" -"La modification de ce réglage redémarrera sunnypilot si la voiture est sous " -"tension." - -msgid "" -"Choose how Automatic Lane Centering (ALC) behaves after the brake pedal is " -"manually pressed in sunnypilot." -msgstr "" -"Choisissez comment le centrage automatique de voie (ALC) se comporte après " -"un appui manuel sur la pédale de frein dans sunnypilot." - -msgid "Choose your sponsorship tier and confirm your support" -msgstr "Choisissez votre niveau de parrainage et confirmez votre soutien" - -msgid "Clear Cache" -msgstr "Vider le cache" - -msgid "Clear Model Cache" -msgstr "Vider le cache des modèles" - -msgid "Click the Sponsor button for more details" -msgstr "Cliquez sur le bouton Sponsor pour plus de détails" - -msgid "Colors represent vehicle fingerprint status:" -msgstr "Les couleurs représentent le statut d'empreinte du véhicule :" - -msgid "Combined" -msgstr "Combiné" - -msgid "Combined: Use combined Speed Limit data from Car & OpenStreetMaps" -msgstr "" -"Combiné : Utiliser les données combinées de limite de vitesse de la voiture " -"et d'OpenStreetMaps" - -msgid "Confirm" -msgstr "Confirmer" - -msgid "Controls state of the device after boot/sleep." -msgstr "Contrôle l'état de l'appareil après démarrage/veille." - -msgid "Cooperative Steering (Beta)" -msgstr "Direction coopérative (Bêta)" - -msgid "Country" -msgstr "Pays" - -msgid "Cruise" -msgstr "Régulateur" - -msgid "Current Model" -msgstr "Modèle actuel" - -msgid "Custom ACC Speed Increments" -msgstr "Incréments de vitesse ACC personnalisés" - -msgid "Custom Longitudinal Tuning" -msgstr "Réglage longitudinal personnalisé" - -msgid "Customize Lane Change" -msgstr "Personnaliser le changement de voie" - -msgid "Customize MADS" -msgstr "Personnaliser MADS" - -msgid "Customize Source" -msgstr "Personnaliser la source" - -msgid "Customize Torque Params" -msgstr "Personnaliser les paramètres de couple" - -msgid "DELETE" -msgstr "SUPPRIMER" - -msgid "DISABLED" -msgstr "DÉSACTIVÉ" - -msgid "Database Update" -msgstr "Mise à jour de la base de données" - -msgid "Decline, uninstall sunnypilot" -msgstr "Refuser, désinstaller sunnypilot" - -msgid "Default" -msgstr "Par défaut" - -msgid "Default Model" -msgstr "Modèle par défaut" - -msgid "Default: Device will boot/wake-up normally & will be ready to engage." -msgstr "Par défaut : L'appareil démarrera normalement et sera prêt à engager." - -msgid "Delay before lateral control resumes after the turn signal ends." -msgstr "" -"Délai avant la reprise du contrôle latéral après la fin du clignotant." - -msgid "Developer UI" -msgstr "Interface développeur" - -msgid "" -"Device will automatically shutdown after set time once the engine is turned off.\n" -"(30h is the default)" -msgstr "" -"L'appareil s'éteindra automatiquement après le temps défini une fois le moteur coupé.\n" -"(30h par défaut)" - -msgid "Disable" -msgstr "Désactiver" - -msgid "Disable Updates" -msgstr "Désactiver les mises à jour" - -msgid "" -"Disable the sunnypilot Longitudinal Control (alpha) toggle to allow " -"Intelligent Cruise Button Management." -msgstr "" -"Désactivez l'option contrôle longitudinal sunnypilot (alpha) pour permettre " -"la gestion intelligente des boutons de régulateur." - -msgid "Disengage" -msgstr "Désengager" - -msgid "Disengage to Enter Always Offroad Mode" -msgstr "Désengager pour entrer en mode toujours hors route" - -msgid "Disengage: ALC will disengage when the brake pedal is pressed." -msgstr "" -"Désengager : ALC se désengagera lorsque la pédale de frein est enfoncée." - -msgid "Display" -msgstr "Affichage" - -msgid "Display Metrics Below Chevron" -msgstr "Afficher les métriques sous le chevron" - -msgid "Display Road Name" -msgstr "Afficher le nom de la route" - -msgid "Display Turn Signals" -msgstr "Afficher les clignotants" - -msgid "Display real-time parameters and metrics from various sources." -msgstr "" -"Afficher les paramètres et métriques en temps réel provenant de diverses " -"sources." - -msgid "" -"Display steering arc on the driving screen when lateral control is enabled." -msgstr "" -"Afficher l'arc de direction sur l'écran de conduite lorsque le contrôle " -"latéral est activé." - -msgid "" -"Display useful metrics below the chevron that tracks the lead car only " -"applicable to cars with sunnypilot longitudinal control." -msgstr "" -"Afficher des métriques utiles sous le chevron qui suit le véhicule en tête, " -"uniquement applicable aux voitures avec le contrôle longitudinal sunnypilot." - -msgid "" -"Displays the name of the road the car is traveling on.
The OpenStreetMap " -"database of the location must be downloaded from the OSM panel to fetch the " -"road name." -msgstr "" -"Affiche le nom de la route sur laquelle la voiture circule.
La base de " -"données OpenStreetMap de la localisation doit être téléchargée depuis le " -"panneau OSM pour récupérer le nom de la route." - -msgid "Distance" -msgstr "Distance" - -msgid "Downloaded Maps" -msgstr "Cartes téléchargées" - -msgid "Downloading Map" -msgstr "Téléchargement de la carte" - -msgid "Downloading Maps..." -msgstr "Téléchargement des cartes..." - -msgid "Driver Camera Preview" -msgstr "Aperçu caméra conducteur" - -msgid "Drives" -msgstr "Trajets" - -msgid "Driving Model" -msgstr "Modèle de conduite" - -msgid "Dynamic" -msgstr "Dynamique" - -msgid "Early Access: Become a sunnypilot Sponsor" -msgstr "Accès anticipé : Devenez parrain sunnypilot" - -msgid "Enable \"Always Offroad\" in Device panel, or turn vehicle off to toggle." -msgstr "" -"Activez \"Toujours hors route\" dans le panneau Appareil, ou éteignez le " -"véhicule pour basculer." - -msgid "Enable Always Offroad" -msgstr "Activer le mode toujours hors route" - -msgid "Enable Custom Tuning" -msgstr "Activer le réglage personnalisé" - -msgid "Enable Dynamic Experimental Control" -msgstr "Activer le contrôle expérimental dynamique" - -msgid "Enable Standstill Timer" -msgstr "Activer le chronomètre à l'arrêt" - -msgid "Enable Tesla Rainbow Mode" -msgstr "Activer le mode arc-en-ciel Tesla" - -msgid "" -"Enable custom Short & Long press increments for cruise speed " -"increase/decrease." -msgstr "" -"Activer les incréments personnalisés d'appui court et long pour " -"augmenter/diminuer la vitesse de croisière." - -msgid "Enable driver monitoring even when sunnypilot is not engaged." -msgstr "" -"Activer la surveillance du conducteur même lorsque sunnypilot n'est pas " -"engagé." - -msgid "Enable sunnylink" -msgstr "Activer sunnylink" - -msgid "Enable sunnylink uploader (infrastructure test)" -msgstr "Activer le téléversement sunnylink" - -msgid "" -"Enable sunnylink uploader to allow sunnypilot to upload your driving data to" -" sunnypilot servers. " -msgstr "" -"Activer le téléversement sunnylink pour permettre à sunnypilot de téléverser" -" vos données de conduite vers les serveurs sunnypilot. " - -msgid "Enable sunnypilot" -msgstr "Activer sunnypilot" - -msgid "" -"Enable the beloved MADS feature. Disable toggle to revert back to stock " -"sunnypilot engagement/disengagement." -msgstr "" -"Activer la fonctionnalité MADS. Désactivez l'option pour revenir au " -"comportement d'engagement/désengagement sunnypilot d'origine." - -msgid "" -"Enable the sunnypilot longitudinal control (alpha) toggle to allow " -"Experimental mode." -msgstr "" -"Activez l'option de contrôle longitudinal sunnypilot (alpha) pour autoriser " -"le mode expérimental." - -msgid "" -"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." -msgstr "" -"Activez pour que la voiture apprenne et adapte son temps de réponse de " -"direction. Désactivez pour utiliser un temps de réponse fixe. Garder activé " -"offre l'expérience openpilot d'origine." - -msgid "" -"Enable this to enforce sunnypilot to steer with Torque lateral control." -msgstr "" -"Activez ceci pour forcer sunnypilot à diriger avec le contrôle latéral par " -"couple." - -msgid "" -"Enable toggle to allow the model to determine when to use sunnypilot ACC or " -"sunnypilot End to End Longitudinal." -msgstr "" -"Activez l'option pour permettre au modèle de déterminer quand utiliser le " -"ACC sunnypilot ou le contrôle longitudinal de bout en bout sunnypilot." - -msgid "" -"Enables custom tuning for Torque lateral control. Modifying Lateral " -"Acceleration Factor and Friction below will override the offline values " -"indicated in the YAML files within \"opendbc/car/torque_data\". The values " -"will also be used live when \"Manual Real-Time Tuning\" toggle is enabled." -msgstr "" -"Active le réglage personnalisé pour le contrôle latéral par couple. La " -"modification du facteur d'accélération latérale et de la friction ci-dessous" -" remplacera les valeurs hors ligne indiquées dans les fichiers YAML du " -"dossier \"opendbc/car/torque_data\". Les valeurs seront également utilisées " -"en direct lorsque l'option \"Réglage manuel en temps réel\" est activée." - -msgid "Enables or disables the GitHub runner service." -msgstr "Active ou désactive le service GitHub runner." - -msgid "" -"Enables self-tune for Torque lateral control for platforms that do not use " -"Torque lateral control by default." -msgstr "" -"Active l'auto-réglage pour le contrôle latéral par couple pour les " -"plateformes qui n'utilisent pas le contrôle latéral par couple par défaut." - -msgid "" -"Enabling this will display warnings when a vehicle is detected in your blind" -" spot as long as your car has BSM supported." -msgstr "" -"L'activation affichera des avertissements lorsqu'un véhicule est détecté " -"dans votre angle mort, à condition que votre voiture supporte le BSM." - -msgid "Enforce Factory Longitudinal Control" -msgstr "Forcer le contrôle longitudinal d'usine" - -msgid "Enforce Torque Lateral Control" -msgstr "Forcer le contrôle latéral par couple" - -msgid "" -"Enforces the torque lateral controller to use the fixed values instead of " -"the learned values from Self-Tune. Enabling this toggle overrides Self-Tune " -"values." -msgstr "" -"Force le contrôleur latéral par couple à utiliser les valeurs fixes au lieu " -"des valeurs apprises par l'auto-réglage. L'activation de cette option " -"remplace les valeurs de l'auto-réglage." - -msgid "" -"Engage lateral and longitudinal control with cruise control engagement." -msgstr "" -"Engager le contrôle latéral et longitudinal avec l'activation du régulateur " -"de vitesse." - -msgid "Enter model year (e.g., 2021) and model (Toyota Corolla):" -msgstr "Entrez l'année (ex. 2021) et le modèle (Toyota Corolla) :" - -msgid "Enter search query" -msgstr "Entrez votre recherche" - -msgid "Error Log" -msgstr "Journal d'erreurs" - -msgid "Error: Invalid download. Retry." -msgstr "Erreur : Téléchargement invalide. Réessayez." - -msgid "Exit Always Offroad" -msgstr "Quitter le mode toujours hors route" - -msgid "" -"Experimental feature to enable auto-resume during stop-and-go for certain " -"supported Subaru platforms." -msgstr "" -"Fonctionnalité expérimentale pour activer la reprise automatique en stop-" -"and-go pour certaines plateformes Subaru supportées." - -msgid "" -"Experimental feature to enable stop and go for Subaru Global models with " -"manual handbrake. Models with electric parking brake should keep this " -"disabled. Thanks to martinl for this implementation!" -msgstr "" -"Fonctionnalité expérimentale pour activer le stop and go pour les modèles " -"Subaru Global avec frein à main manuel. Les modèles avec frein de " -"stationnement électrique doivent garder ceci désactivé. Merci à martinl pour" -" cette implémentation !" - -msgid "FAULT" -msgstr "DÉFAUT" - -msgid "FETCHING..." -msgstr "RÉCUPÉRATION..." - -msgid "Fetching Latest Models" -msgstr "Récupération des derniers modèles" - -msgid "Fingerprinted automatically" -msgstr "Empreinte automatique" - -msgid "Fixed" -msgstr "Fixe" - -msgid "Fixed: Adds a fixed offset [Speed Limit + Offset]" -msgstr "Fixe : Ajoute un décalage fixe [Limite de vitesse + Décalage]" - -msgid "Follow the prompts to complete the pairing process" -msgstr "Suivez les instructions pour terminer le processus d'association" - -msgid "" -"For applicable vehicles, always display the true vehicle current speed from " -"wheel speed sensors." -msgstr "" -"Pour les véhicules compatibles, toujours afficher la vitesse réelle actuelle" -" du véhicule à partir des capteurs de vitesse de roue." - -msgid "For secure backup, restore, and remote configuration" -msgstr "" -"Pour la sauvegarde sécurisée, la restauration et la configuration à distance" - -msgid "Friction" -msgstr "Friction" - -msgid "GitHub Runner Service" -msgstr "Service GitHub Runner" - -msgid "Green Traffic Light Alert (Beta)" -msgstr "Alerte feu vert (Bêta)" - -msgid "Hours" -msgstr "Heures" - -msgid "" -"If sponsorship status was not updated, please contact a moderator on the " -"community forum at https://community.sunnypilot.ai" -msgstr "" -"Si le statut de parrainage n'a pas été mis à jour, veuillez contacter un " -"modérateur sur le forum communautaire à https://community.sunnypilot.ai" - -msgid "" -"If you're driving at 20 mph (32 km/h) or below and have your blinker on, the" -" car will plan a turn in that direction at the nearest drivable path. This " -"prevents situations (like at red lights) where the car might plan the wrong " -"turn direction." -msgstr "" -"Si vous roulez à 32 km/h ou moins avec le clignotant activé, la voiture " -"planifiera un virage dans cette direction vers le chemin praticable le plus " -"proche. Cela évite les situations (comme aux feux rouges) où la voiture " -"pourrait planifier la mauvaise direction de virage." - -msgid "Info" -msgstr "Info" - -msgid "Information: Displays the current road's speed limit." -msgstr "Information : Affiche la limite de vitesse de la route actuelle." - -msgid "Intelligent Cruise Button Management (ICBM) (Alpha)" -msgstr "Gestion intelligente des boutons de régulateur (ICBM) (Alpha)" - -msgid "" -"Intelligent Cruise Button Management is currently unavailable on this " -"platform." -msgstr "" -"La gestion intelligente des boutons de régulateur est actuellement " -"indisponible sur cette plateforme." - -msgid "Interactivity Timeout" -msgstr "Délai d'inactivité" - -msgid "" -"Join our Community Forum at https://community.sunnypilot.ai and reach out to" -" a moderator if you have issues" -msgstr "" -"Rejoignez notre forum communautaire à https://community.sunnypilot.ai et " -"contactez un modérateur si vous avez des problèmes" - -msgid "KM" -msgstr "KM" - -msgid "Last checked {}" -msgstr "Dernière vérification {}" - -msgid "Lateral Acceleration Factor" -msgstr "Facteur d'accélération latérale" - -msgid "Lead Departure Alert (Beta)" -msgstr "Alerte de départ du véhicule en tête (Bêta)" - -msgid "Less Restrict Settings for Self-Tune (Beta)" -msgstr "Paramètres moins restrictifs pour l'auto-réglage (Bêta)" - -msgid "" -"Less strict settings when using Self-Tune. This allows torqued to be more " -"forgiving when learning values." -msgstr "" -"Paramètres moins stricts lors de l'utilisation de l'auto-réglage. Cela " -"permet à torqued d'être plus tolérant lors de l'apprentissage des valeurs." - -msgid "Live Learning Steer Delay" -msgstr "Apprentissage en direct du délai de direction" - -msgid "Live Steer Delay:" -msgstr "Délai direction en direct :" - -msgid "Long Press Increment" -msgstr "Incrément appui long" - -msgid "Manual Real-Time Tuning" -msgstr "Réglage manuel en temps réel" - -msgid "Manually selected fingerprint" -msgstr "Empreinte sélectionnée manuellement" - -msgid "Map First" -msgstr "Carte d'abord" - -msgid "" -"Map First: Use Speed Limit data from OpenStreetMaps if available, else use " -"from Car" -msgstr "" -"Carte d'abord : Utiliser les données de limite de vitesse d'OpenStreetMaps " -"si disponibles, sinon utiliser celles de la voiture" - -msgid "Map Only" -msgstr "Carte uniquement" - -msgid "Map Only: Use Speed Limit data only from OpenStreetMaps" -msgstr "" -"Carte uniquement : Utiliser les données de limite de vitesse uniquement " -"d'OpenStreetMaps" - -msgid "Mapd Version" -msgstr "Version Mapd" - -msgid "Max Time Offroad" -msgstr "Durée max hors route" - -msgid "Miles" -msgstr "Km" - -msgid "Minimum Speed to Pause Lateral Control" -msgstr "Vitesse minimum pour suspendre le contrôle latéral" - -msgid "" -"Model download has started in the background. We suggest resetting " -"calibration. Would you like to do that now?" -msgstr "" -"Le téléchargement du modèle a démarré en arrière-plan. Nous suggérons de " -"réinitialiser la calibration. Voulez-vous le faire maintenant ?" - -msgid "Models" -msgstr "Modèles" - -msgid "Modular Assistive Driving System (MADS)" -msgstr "Système d'aide à la conduite modulaire (MADS)" - -msgid "Near" -msgstr "Proche" - -msgid "Neural Network Lateral Control (NNLC)" -msgstr "Contrôle latéral par réseau neuronal (NNLC)" - -msgid "No" -msgstr "Non" - -msgid "No vehicle selected" -msgstr "Aucun véhicule sélectionné" - -msgid "None" -msgstr "Aucun" - -msgid "None: No Offset" -msgstr "Aucun : Pas de décalage" - -msgid "Not Paired" -msgstr "Non associé" - -msgid "Not Sponsor" -msgstr "Non parrain" - -msgid "Not fingerprinted or manually selected" -msgstr "Pas d'empreinte ou sélection manuelle" - -msgid "Not going to lie, it's sad to see you disabled sunnylink" -msgstr "Honnêtement, c'est triste de voir que vous avez désactivé sunnylink" - -msgid "" -"Note: For vehicles without LFA/LKAS button, disabling this will prevent " -"lateral control engagement." -msgstr "" -"Note : Pour les véhicules sans bouton LFA/LKAS, désactiver ceci empêchera " -"l'engagement du contrôle latéral." - -msgid "" -"Note: Once lateral control is engaged via UEM, it will remain engaged until " -"it is manually disabled via the MADS button or car shut off." -msgstr "" -"Note : Une fois le contrôle latéral engagé via UEM, il restera engagé " -"jusqu'à ce qu'il soit désactivé manuellement via le bouton MADS ou " -"l'extinction de la voiture." - -msgid "Nudge" -msgstr "Impulsion" - -msgid "Nudgeless" -msgstr "Sans impulsion" - -msgid "OSM" -msgstr "OSM" - -msgid "Off" -msgstr "Désactivé" - -msgid "Off: Disables the Speed Limit functions." -msgstr "Désactivé : Désactive les fonctions de limite de vitesse." - -msgid "Offline Only" -msgstr "Hors ligne uniquement" - -msgid "Offroad" -msgstr "Hors route" - -msgid "Offroad: Device will be in Always Offroad mode after boot/wake-up." -msgstr "" -"Hors route : L'appareil sera en mode toujours hors route après démarrage." - -msgid "Only available when vehicle is off, or always offroad mode is on" -msgstr "" -"Disponible uniquement lorsque le véhicule est éteint ou en mode toujours " -"hors route" - -msgid "Onroad Brightness" -msgstr "Luminosité en conduite" - -msgid "Onroad Brightness Delay" -msgstr "Délai de luminosité en conduite" - -msgid "Onroad Uploads" -msgstr "Téléversements en conduite" - -msgid "PAST WEEK" -msgstr "SEMAINE PASSÉE" - -msgid "Pair GitHub Account" -msgstr "Associer un compte GitHub" - -msgid "Pair your GitHub account" -msgstr "Associer votre compte GitHub" - -msgid "" -"Pair your GitHub account to grant your device sponsor benefits, including " -"API access on sunnylink." -msgstr "" -"Associez votre compte GitHub pour accorder à votre appareil les avantages de" -" parrain, y compris l'accès API sur sunnylink." - -msgid "Paired" -msgstr "Associé" - -msgid "Pause" -msgstr "Pause" - -msgid "Pause Lateral Control with Blinker" -msgstr "Suspendre le contrôle latéral avec le clignotant" - -msgid "" -"Pause lateral control with blinker when traveling below the desired speed " -"selected." -msgstr "" -"Suspendre le contrôle latéral avec le clignotant lorsque la vitesse est " -"inférieure à la vitesse souhaitée sélectionnée." - -msgid "Pause: ALC will pause when the brake pedal is pressed." -msgstr "" -"Pause : ALC se mettra en pause lorsque la pédale de frein est enfoncée." - -msgid "Percent: Adds a percent offset [Speed Limit + (Offset % Speed Limit)]" -msgstr "" -"Pourcentage : Ajoute un décalage en pourcentage [Limite de vitesse + " -"(Décalage % Limite de vitesse)]" - -msgid "" -"Please enable \"Always Offroad\" mode or turn off the vehicle to adjust " -"these toggles." -msgstr "" -"Veuillez activer le mode \"Toujours hors route\" ou éteindre le véhicule " -"pour ajuster ces options." - -msgid "Please reboot and try again." -msgstr "Veuillez redémarrer et réessayer." - -msgid "Policy Model" -msgstr "Modèle de politique" - -msgid "Post-Blinker Delay" -msgstr "Délai post-clignotant" - -msgid "Predictive" -msgstr "Prédictif" - -msgid "Quickboot Mode" -msgstr "Mode démarrage rapide" - -msgid "" -"Quickboot mode requires updates to be disabled.
Enable 'Disable Updates' " -"in the Software panel first." -msgstr "" -"Le mode démarrage rapide nécessite que les mises à jour soient " -"désactivées.
Activez d'abord 'Désactiver les mises à jour' dans le " -"panneau Logiciel." - -msgid "Quiet Mode" -msgstr "Mode silencieux" - -msgid "REFRESH" -msgstr "ACTUALISER" - -msgid "REGIST..." -msgstr "ENREG..." - -msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" -msgstr "" -"Retournez dans le panneau \"sunnylink\" pour vérifier le statut de " -"parrainage" - -msgid "Real-Time & Offline" -msgstr "Temps réel et hors ligne" - -msgid "Real-time Acceleration Bar" -msgstr "Barre d'accélération en temps réel" - -msgid "Refresh Model List" -msgstr "Actualiser la liste des modèles" - -msgid "Remain Active" -msgstr "Rester actif" - -msgid "Remain Active: ALC will remain active when the brake pedal is pressed." -msgstr "" -"Rester actif : ALC restera actif lorsque la pédale de frein est enfoncée." - -msgid "Reset Settings" -msgstr "Réinitialiser les paramètres" - -msgid "Restore Failed" -msgstr "Échec de la restauration" - -msgid "Restore Settings" -msgstr "Restaurer les paramètres" - -msgid "Review the rules, features, and limitations of sunnypilot" -msgstr "Consultez les règles, fonctionnalités et limitations de sunnypilot" - -msgid "Right" -msgstr "Droite" - -msgid "Right & Bottom" -msgstr "Droite et bas" - -msgid "SPONSOR" -msgstr "PARRAINER" - -msgid "SUNNYLINK" -msgstr "SUNNYLINK" - -msgid "Scan" -msgstr "Analyser" - -msgid "Scan the QR code to login to your GitHub account" -msgstr "Scannez le code QR pour vous connecter à votre compte GitHub" - -msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" -msgstr "" -"Scannez le code QR pour visiter la page GitHub Sponsors de sunnyhaibin" - -msgid "Scanning..." -msgstr "Analyse en cours..." - -msgid "Search" -msgstr "Rechercher" - -msgid "Search your vehicle" -msgstr "Rechercher votre véhicule" - -msgid "Select a Model" -msgstr "Sélectionner un modèle" - -msgid "Select a vehicle" -msgstr "Sélectionner un véhicule" - -msgid "Select vehicle to force fingerprint manually." -msgstr "Sélectionnez le véhicule pour forcer l'empreinte manuellement." - -msgid "Self-Tune" -msgstr "Auto-réglage" - -msgid "" -"Set a timer to delay the auto lane change operation when the blinker is " -"used. No nudge on the steering wheel is required to auto lane change if a " -"timer is set. Default is Nudge.
Please use caution when using this " -"feature. Only use the blinker when traffic and road conditions permit." -msgstr "" -"Définir un minuteur pour retarder le changement de voie automatique lorsque " -"le clignotant est utilisé. Aucune impulsion sur le volant n'est nécessaire " -"pour le changement de voie automatique si un minuteur est défini. Par défaut" -" : Impulsion.
Veuillez utiliser cette fonctionnalité avec prudence. " -"N'utilisez le clignotant que lorsque la circulation et les conditions " -"routières le permettent." - -msgid "Set the maximum speed for lane turn desires. Default is 19 mph." -msgstr "" -"Définir la vitesse max. pour les changements de voie assistés. Par défaut : " -"30 km/h." - -msgid "Settings backup completed." -msgstr "Sauvegarde des paramètres terminée." - -msgid "Settings restored. Confirm to restart the interface." -msgstr "Paramètres restaurés. Confirmez pour redémarrer l'interface." - -msgid "Short Press Increment" -msgstr "Incrément appui court" - -msgid "Show Advanced Controls" -msgstr "Afficher les contrôles avancés" - -msgid "Show Blind Spot Warnings" -msgstr "Afficher les alertes d'angle mort" - -msgid "Show a timer on the HUD when the car is at a standstill." -msgstr "Afficher un chronomètre sur le HUD lorsque la voiture est à l'arrêt." - -msgid "" -"Show an indicator on the left side of the screen to display real-time " -"vehicle acceleration and deceleration. This displays what the car is " -"currently doing, not what the planner is requesting." -msgstr "" -"Afficher un indicateur à gauche de l'écran pour visualiser l'accélération et" -" la décélération du véhicule en temps réel. Cela affiche ce que la voiture " -"fait actuellement, pas ce que le planificateur demande." - -msgid "Smart Cruise Control - Map" -msgstr "Régulateur intelligent - Carte" - -msgid "Smart Cruise Control - Vision" -msgstr "Régulateur intelligent - Vision" - -msgid "Software Delay:" -msgstr "Délai logiciel :" - -msgid "Speed" -msgstr "Vitesse" - -msgid "Speed Limit" -msgstr "Limite de vitesse" - -msgid "Speed Limit Offset" -msgstr "Décalage de limite de vitesse" - -msgid "Speed Limit Source" -msgstr "Source de limite de vitesse" - -msgid "Speedometer: Always Display True Speed" -msgstr "Compteur : Toujours afficher la vitesse réelle" - -msgid "Speedometer: Hide from Onroad Screen" -msgstr "Compteur : Masquer de l'écran de conduite" - -msgid "Sponsor Status" -msgstr "Statut de parrainage" - -msgid "Sponsorship isn't required for basic backup/restore" -msgstr "" -"Le parrainage n'est pas requis pour la sauvegarde/restauration de base" - -msgid "" -"Standard is recommended. In aggressive mode, sunnypilot will follow lead " -"cars closer and be more aggressive with the gas and brake. In relaxed mode " -"sunnypilot will stay further away from lead cars. On supported cars, you can" -" cycle through these personalities with your steering wheel distance button." -msgstr "" -"Le mode standard est recommandé. En mode agressif, sunnypilot suivra les " -"véhicules en tête de plus près et sera plus agressif avec l'accélérateur et " -"le frein. En mode détendu, sunnypilot restera plus éloigné des véhicules en " -"tête. Sur les voitures compatibles, vous pouvez parcourir ces personnalités " -"avec le bouton de distance sur le volant." - -msgid "Start Download" -msgstr "Démarrer le téléchargement" - -msgid "Start the vehicle to check vehicle compatibility." -msgstr "Démarrez le véhicule pour vérifier la compatibilité du véhicule." - -msgid "State" -msgstr "Région" - -msgid "Steering" -msgstr "Direction" - -msgid "Steering Arc" -msgstr "Arc de direction" - -msgid "Steering Mode on Brake Pedal" -msgstr "Mode direction au freinage" - -msgid "Stop and Go (Beta)" -msgstr "Stop and Go (Bêta)" - -msgid "Stop and Go for Manual Parking Brake (Beta)" -msgstr "Stop and Go pour frein à main manuel (Bêta)" - -msgid "System reboot required for changes to take effect. Reboot now?" -msgstr "" -"Redémarrage système nécessaire pour appliquer les changements. Redémarrer " -"maintenant ?" - -msgid "THANKS ♥" -msgstr "MERCI ♥" - -msgid "The reset cannot be undone. You have been warned." -msgstr "La réinitialisation ne peut pas être annulée. Vous êtes prévenu." - -msgid "" -"This feature can only be used with sunnypilot longitudinal control enabled." -msgstr "" -"Cette fonctionnalité ne peut être utilisée qu'avec le contrôle longitudinal " -"sunnypilot activé." - -msgid "" -"This feature defaults to OFF, and does not allow selection due to vehicle " -"limitations." -msgstr "" -"Cette fonctionnalité est désactivée par défaut et ne permet pas la sélection" -" en raison des limitations du véhicule." - -msgid "" -"This feature defaults to ON, and does not allow selection due to vehicle " -"limitations." -msgstr "" -"Cette fonctionnalité est activée par défaut et ne permet pas la sélection en" -" raison des limitations du véhicule." - -msgid "This feature is currently not available on this platform." -msgstr "" -"Cette fonctionnalité n'est actuellement pas disponible sur cette plateforme." - -msgid "" -"This feature is not supported on this platform due to vehicle limitations." -msgstr "" -"Cette fonctionnalité n'est pas prise en charge sur cette plateforme en " -"raison des limitations du véhicule." - -msgid "" -"This feature is unavailable because sunnypilot Longitudinal Control (Alpha) " -"is not enabled." -msgstr "" -"Cette fonctionnalité est indisponible car le contrôle longitudinal " -"sunnypilot (Alpha) n'est pas activé." - -msgid "This feature is unavailable while the car is onroad." -msgstr "" -"Cette fonctionnalité est indisponible lorsque la voiture est sur la route." - -msgid "This feature requires sunnypilot longitudinal control to be available." -msgstr "" -"Cette fonctionnalité nécessite que le contrôle longitudinal sunnypilot soit " -"disponible." - -msgid "" -"This is the master switch, it will allow you to cutoff any sunnylink " -"requests should you want to do that." -msgstr "" -"C'est l'interrupteur principal, il vous permettra de couper toutes les " -"requêtes sunnylink si vous le souhaitez." - -msgid "" -"This may be due to weak internet connection or sunnylink registration issue." -" " -msgstr "" -"Cela peut être dû à une connexion internet faible ou un problème " -"d'enregistrement sunnylink. " - -msgid "This platform only supports Disengage mode due to vehicle limitations." -msgstr "" -"Cette plateforme ne supporte que le mode Désengager en raison des " -"limitations du véhicule." - -msgid "This platform supports all MADS settings." -msgstr "Cette plateforme supporte tous les paramètres MADS." - -msgid "This platform supports limited MADS settings." -msgstr "Cette plateforme supporte des paramètres MADS limités." - -msgid "This setting will take effect immediately." -msgstr "Ce paramètre prendra effet immédiatement." - -msgid "This setting will take effect once the device enters offroad state." -msgstr "Ce paramètre prendra effet une fois l'appareil en mode hors route." - -msgid "" -"This will delete ALL downloaded maps\n" -"\n" -"Are you sure you want to delete all maps?" -msgstr "" -"Cela supprimera TOUTES les cartes téléchargées\n" -"\n" -"Êtes-vous sûr de vouloir supprimer toutes les cartes ?" - -msgid "" -"This will delete ALL downloaded models from the cache except the currently " -"active model. Are you sure?" -msgstr "" -"Cela supprimera TOUS les modèles téléchargés du cache sauf le modèle " -"actuellement actif. Êtes-vous sûr ?" - -msgid "" -"This will start the download process and it might take a while to complete." -msgstr "Cela démarrera le téléchargement et peut prendre un certain temps." - -msgid "Time" -msgstr "Temps" - -msgid "" -"Toggle to enable a delay timer for seamless lane changes when blind spot " -"monitoring (BSM) detects a obstructing vehicle, ensuring safe maneuvering." -msgstr "" -"Activer un minuteur de délai pour des changements de voie fluides lorsque la" -" surveillance d'angle mort (BSM) détecte un véhicule gênant, assurant une " -"manœuvre en toute sécurité." - -msgid "" -"Toggle visibility of advanced sunnypilot controls.
This only changes the " -"visibility of the toggles; it does not change the actual enabled/disabled " -"state." -msgstr "" -"Basculer la visibilité des contrôles avancés sunnypilot.
Cela ne change " -"que la visibilité des options, pas leur état activé/désactivé réel." - -msgid "Toggle with Main Cruise" -msgstr "Basculer avec le régulateur principal" - -msgid "Total Delay:" -msgstr "Délai total :" - -msgid "Training Guide" -msgstr "Guide d'entraînement" - -msgid "Trips" -msgstr "Trajets" - -msgid "UI Debug Mode" -msgstr "Mode débogage UI" - -msgid "Unable to restore the settings, try again later." -msgstr "Impossible de restaurer les paramètres, réessayez plus tard." - -msgid "Unified Engagement Mode (UEM)" -msgstr "Mode d'engagement unifié (UEM)" - -msgid "Unrecognized Vehicle" -msgstr "Véhicule non reconnu" - -msgid "Use Lane Turn Desires" -msgstr "Changements de voie assistés" - -msgid "" -"Use map data to estimate the appropriate speed to drive through turns ahead." -msgstr "" -"Utiliser les données cartographiques pour estimer la vitesse appropriée pour" -" aborder les virages à venir." - -msgid "" -"Use the sunnypilot system for adaptive cruise control and lane keep driver " -"assistance. Your attention is required at all times to use this feature." -msgstr "" -"Utiliser le système sunnypilot pour le régulateur de vitesse adaptatif et " -"l'aide au maintien de voie. Votre attention est requise en permanence pour " -"utiliser cette fonctionnalité." - -msgid "" -"Use vision path predictions to estimate the appropriate speed to drive " -"through turns ahead." -msgstr "" -"Utiliser les prédictions de trajectoire visuelle pour estimer la vitesse " -"appropriée pour aborder les virages à venir." - -msgid "Vehicle" -msgstr "Véhicule" - -msgid "View the error log for sunnypilot crashes." -msgstr "Voir le journal d'erreurs pour les plantages sunnypilot." - -msgid "Vision Model" -msgstr "Modèle de vision" - -msgid "Visuals" -msgstr "Visuels" - -msgid "Wake Up Behavior" -msgstr "Comportement au réveil" - -msgid "Warning" -msgstr "Avertissement" - -msgid "" -"Warning: Provides a warning when exceeding the current road's speed limit." -msgstr "" -"Avertissement : Fournit un avertissement lorsque la limite de vitesse de la " -"route actuelle est dépassée." - -msgid "Welcome back!! We're excited to see you've enabled sunnylink again!" -msgstr "" -"Bon retour !! Nous sommes ravis de voir que vous avez réactivé sunnylink !" - -msgid "Welcome to sunnypilot" -msgstr "Bienvenue sur sunnypilot" - -msgid "" -"When enabled, automatic software updates will be off.
This requires a " -"reboot to take effect." -msgstr "" -"Lorsqu'activé, les mises à jour automatiques seront désactivées.
Cela " -"nécessite un redémarrage pour prendre effet." - -msgid "" -"When enabled, pressing the accelerator pedal will disengage sunnypilot." -msgstr "" -"Lorsqu'activé, appuyer sur la pédale d'accélérateur désengagera sunnypilot." - -msgid "" -"When enabled, sunnypilot will attempt to manage the built-in cruise control " -"buttons by emulating button presses for limited longitudinal control." -msgstr "" -"Lorsqu'activé, sunnypilot tentera de gérer les boutons de régulateur de " -"vitesse intégrés en émulant des appuis de bouton pour un contrôle " -"longitudinal limité." - -msgid "When enabled, the speedometer on the onroad screen is not displayed." -msgstr "" -"Lorsqu'activé, le compteur de vitesse sur l'écran de conduite n'est pas " -"affiché." - -msgid "When enabled, visual turn indicators are drawn on the HUD." -msgstr "" -"Lorsqu'activé, les indicateurs visuels de virage sont affichés sur le HUD." - -msgid "" -"When toggled on, this creates a prebuilt file to allow accelerated boot " -"times. When toggled off, it removes the prebuilt file so compilation of " -"locally edited cpp files can be made." -msgstr "" -"Lorsqu'activé, cela crée un fichier précompilé pour accélérer le temps de " -"démarrage. Lorsque désactivé, le fichier précompilé est supprimé pour " -"permettre la compilation des fichiers cpp modifiés localement." - -msgid "Would you like to delete this log?" -msgstr "Voulez-vous supprimer ce journal ?" - -msgid "Yes" -msgstr "Oui" - -msgid "Yes, delete all maps" -msgstr "Oui, supprimer toutes les cartes" - -msgid "You must accept the Terms of Service in order to use sunnypilot." -msgstr "" -"Vous devez accepter les conditions d'utilisation pour utiliser sunnypilot." - -msgid "" -"You must accept the Terms of Service to use sunnypilot. Read the latest " -"terms at https://sunnypilot.ai/terms before continuing." -msgstr "" -"Vous devez accepter les conditions d'utilisation pour utiliser sunnypilot. " -"Consultez les dernières conditions sur https://sunnypilot.ai/terms avant de " -"continuer." - -msgid "Your vehicle will use the Default longitudinal tuning." -msgstr "Votre véhicule utilisera le réglage longitudinal par défaut." - -msgid "Your vehicle will use the Dynamic longitudinal tuning." -msgstr "Votre véhicule utilisera le réglage longitudinal dynamique." - -msgid "Your vehicle will use the Predictive longitudinal tuning." -msgstr "Votre véhicule utilisera le réglage longitudinal prédictif." - -msgid "backing up" -msgstr "sauvegarde en cours" - -msgid "backup" -msgstr "sauvegarde" - -msgid "backup settings" -msgstr "sauvegarder les paramètres" - -msgid "become a sunnypilot sponsor" -msgstr "devenir sponsor sunnypilot" - -msgid "cancel download" -msgstr "annuler le téléchargement" - -msgid "checking..." -msgstr "vérification..." - -msgid "copyparty Service" -msgstr "Service copyparty" - -msgid "" -"copyparty is a very capable file server, you can use it to download your " -"routes, view your logs and even make some edits on some files from your " -"browser. Requires you to connect to your comma locally via its IP address." -msgstr "" -"copyparty est un serveur de fichiers très performant, vous pouvez l'utiliser" -" pour télécharger vos trajets, consulter vos logs et même modifier certains " -"fichiers depuis votre navigateur. Nécessite de vous connecter à votre comma " -"localement via son adresse IP." - -msgid "current model" -msgstr "modèle actuel" - -msgid "default model" -msgstr "modèle par défaut" - -msgid "downloading..." -msgstr "téléchargement..." - -msgid "enable sunnylink" -msgstr "activer sunnylink" - -msgid "failed" -msgstr "échoué" - -msgid "finalizing update..." -msgstr "finalisation de la mise à jour..." - -msgid "from cache" -msgstr "depuis le cache" - -msgid "h" -msgstr "h" - -msgid "m" -msgstr "min" - -msgid "pair" -msgstr "associer" - -msgid "pair with sunnylink" -msgstr "associer avec sunnylink" - -msgid "paired" -msgstr "associé" - -msgid "ready" -msgstr "prêt" - -msgid "recommend disabling this feature if you experience these." -msgstr "" -"nous recommandons de désactiver cette fonctionnalité si vous rencontrez ces " -"problèmes." - -msgid "restore" -msgstr "restaurer" - -msgid "restore settings" -msgstr "restaurer les paramètres" - -msgid "restoring" -msgstr "restauration en cours" - -msgid "s" -msgstr "s" - -msgid "settings backed up" -msgstr "paramètres sauvegardés" - -msgid "slide to backup" -msgstr "glisser pour sauvegarder" - -msgid "slide to restore" -msgstr "glisser pour restaurer" - -msgid "sponsor" -msgstr "sponsor" - -msgid "sunnylink" -msgstr "sunnylink" - -msgid "sunnylink Dongle ID not found. " -msgstr "ID Dongle sunnylink introuvable. " - -msgid "sunnylink Dongle ID not found. Please reboot & try again." -msgstr "ID Dongle sunnylink introuvable. Veuillez redémarrer et réessayer." - -msgid "" -"sunnylink enables secured remote access to your comma device from anywhere, " -"including settings management, remote monitoring, real-time dashboard, etc." -msgstr "" -"sunnylink permet un accès à distance sécurisé à votre appareil comma depuis " -"n'importe où, y compris la gestion des paramètres, la surveillance à " -"distance, le tableau de bord en temps réel, etc." - -msgid "" -"sunnylink is designed to be enabled as part of sunnypilot's core " -"functionality. If sunnylink is disabled, features such as settings " -"management, remote monitoring, real-time dashboards will be unavailable." -msgstr "" -"sunnylink est conçu pour être activé dans le cadre des fonctionnalités de " -"base de sunnypilot. Si sunnylink est désactivé, des fonctionnalités telles " -"que la gestion des paramètres, la surveillance à distance et les tableaux de" -" bord en temps réel seront indisponibles." - -msgid "sunnylink uploader" -msgstr "téléverseur sunnylink" - -msgid "sunnypilot Longitudinal Control (Alpha)" -msgstr "Contrôle longitudinal sunnypilot (Alpha)" - -msgid "" -"sunnypilot Longitudinal Control is the default longitudinal control for this" -" platform." -msgstr "" -"Le contrôle longitudinal sunnypilot est le contrôle longitudinal par défaut " -"pour cette plateforme." - -msgid "sunnypilot Unavailable" -msgstr "sunnypilot indisponible" - -msgid "" -"sunnypilot defaults to driving in chill mode. Experimental mode enables " -"alpha-level features that aren't ready for chill mode. Experimental features" -" are listed below:

End-to-End Longitudinal Control


Let the " -"driving model control the gas and brakes. sunnypilot will drive as it thinks" -" a human would, including stopping for red lights and stop signs. Since the " -"driving model decides the speed to drive, the set speed will only act as an " -"upper bound. This is an alpha quality feature; mistakes should be " -"expected.

New Driving Visualization


The driving visualization" -" will transition to the road-facing wide-angle camera at low speeds to " -"better show some turns. The Experimental mode logo will also be shown in the" -" top right corner." -msgstr "" -"sunnypilot conduit par défaut en mode chill. Le mode expérimental active des" -" fonctionnalités de niveau alpha qui ne sont pas prêtes pour le mode chill. " -"Les fonctionnalités expérimentales sont listées ci-dessous :

Contrôle" -" longitudinal de bout en bout


Laissez le modèle de conduite " -"contrôler l'accélérateur et les freins. sunnypilot conduira comme il pense " -"qu'un humain le ferait, y compris s'arrêter aux feux rouges et aux panneaux " -"stop. Puisque le modèle de conduite décide de la vitesse, la vitesse définie" -" ne servira que de limite supérieure. Il s'agit d'une fonctionnalité de " -"qualité alpha ; des erreurs sont à prévoir.

Nouvelle visualisation de" -" conduite


La visualisation de conduite passera à la caméra grand " -"angle orientée route à basse vitesse pour mieux montrer certains virages. Le" -" logo du mode expérimental sera également affiché dans le coin supérieur " -"droit." - -msgid "" -"sunnypilot is continuously calibrating, resetting is rarely required. " -"Resetting calibration will restart sunnypilot if the car is powered on." -msgstr "" -"sunnypilot se calibre en permanence, la réinitialisation est rarement " -"nécessaire. La réinitialisation de la calibration redémarrera sunnypilot si " -"la voiture est sous tension." - -msgid "" -"sunnypilot learns to drive by watching humans, like you, drive.\n" -"\n" -"Firehose Mode allows you to maximize your training data uploads to improve openpilot's driving models. More data means bigger models, which means better Experimental Mode." -msgstr "" -"sunnypilot apprend à conduire en regardant des humains, comme vous, conduire.\n" -"\n" -"Le mode Firehose vous permet de maximiser vos envois de données d'entraînement pour améliorer les modèles de conduite d'openpilot. Plus de données signifie de plus grands modèles, ce qui signifie un meilleur mode expérimental." - -msgid "sunnypilot longitudinal control may come in a future update." -msgstr "" -"Le contrôle longitudinal sunnypilot pourrait arriver dans une future mise à " -"jour." - -msgid "" -"sunnypilot will not take over control of gas and brakes. Factory Toyota " -"longitudinal control will be used." -msgstr "" -"sunnypilot ne prendra pas le contrôle de l'accélérateur et des freins. Le " -"contrôle longitudinal Toyota d'origine sera utilisé." diff --git a/selfdrive/ui/translations/app_ja.po b/selfdrive/ui/translations/app_ja.po index ca8aac1515..1d701bd8b4 100644 --- a/selfdrive/ui/translations/app_ja.po +++ b/selfdrive/ui/translations/app_ja.po @@ -1,1197 +1,868 @@ -# Japanese translations for PACKAGE package. -# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# Automatically generated, 2025. -# msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 00:50-0700\n" -"PO-Revision-Date: 2025-10-22 16:32-0700\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" -"Language: ja\n" -"MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" +"Language: ja\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: selfdrive/ui/layouts/settings/device.py:160 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is complete." msgstr " ステアリングトルク応答のキャリブレーションが完了しました。" -#: selfdrive/ui/layouts/settings/device.py:158 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is {}% complete." msgstr " ステアリングトルク応答のキャリブレーションは{}%完了しました。" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Your device is pointed {:.1f}° {} and {:.1f}° {}." msgstr " デバイスは{:.1f}°{}、{:.1f}°{}の向きです。" -#: selfdrive/ui/layouts/sidebar.py:43 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "--" msgstr "--" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "1 year of drive storage" msgstr "走行データを1年間保存" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "24/7 LTE connectivity" msgstr "24時間365日のLTE接続" -#: selfdrive/ui/layouts/sidebar.py:46 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "2G" msgstr "2G" -#: selfdrive/ui/layouts/sidebar.py:47 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "3G" msgstr "3G" -#: selfdrive/ui/layouts/sidebar.py:49 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "5G" msgstr "5G" -#: selfdrive/ui/layouts/settings/developer.py:23 -msgid "" -"WARNING: openpilot longitudinal control is in alpha for this car and will " -"disable Automatic Emergency Braking (AEB).

On this car, openpilot " -"defaults to the car's built-in ACC instead of openpilot's longitudinal " -"control. Enable this to switch to openpilot longitudinal control. Enabling " -"Experimental mode is recommended when enabling openpilot longitudinal " -"control alpha. Changing this setting will restart openpilot if the car is " -"powered on." -msgstr "" -"警告: この車におけるopenpilotの縦制御はアルファ版であり、自動緊急ブレーキ" -"(AEB)を無効にします。

この車では、openpilotは縦制御として" -"openpilotではなく車両の内蔵ACCを既定で使用します。openpilotの縦制御に切り替え" -"るにはこの設定を有効にしてください。openpilot縦制御アルファを有効にする場合は" -"実験モードの有効化を推奨します。この設定を変更すると、車が起動中の場合は" -"openpilotが再起動します。" - -#: selfdrive/ui/layouts/settings/device.py:148 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is complete." msgstr "

ステアリング遅延のキャリブレーションが完了しました。" -#: selfdrive/ui/layouts/settings/device.py:146 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is {}% complete." msgstr "

ステアリング遅延のキャリブレーションは{}%完了しました。" -#: selfdrive/ui/layouts/settings/firehose.py:138 -#, python-format -msgid "ACTIVE" -msgstr "アクティブ" - -#: selfdrive/ui/layouts/settings/developer.py:15 -msgid "" -"ADB (Android Debug Bridge) allows connecting to your device over USB or over " -"the network. See https://docs.comma.ai/how-to/connect-to-comma for more info." -msgstr "" -"ADB(Android Debug Bridge)を使用すると、USBまたはネットワーク経由でデバイス" -"に接続できます。詳しくは https://docs.comma.ai/how-to/connect-to-comma を参照" -"してください。" - -#: selfdrive/ui/widgets/ssh_key.py:30 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "ADD" msgstr "追加" -#: system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "APN Setting" msgstr "APN設定" -#: selfdrive/ui/widgets/offroad_alerts.py:109 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Acknowledge Excessive Actuation" msgstr "過度な作動を承認" -#: system/ui/widgets/network.py:74 system/ui/widgets/network.py:95 -#, python-format +#: system/ui/widgets/network.py msgid "Advanced" msgstr "詳細設定" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Aggressive" msgstr "アグレッシブ" -#: selfdrive/ui/layouts/onboarding.py:116 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Agree" msgstr "同意する" -#: selfdrive/ui/layouts/settings/toggles.py:70 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Always-On Driver Monitoring" msgstr "常時ドライバーモニタリング" -#: selfdrive/ui/layouts/settings/toggles.py:186 -#, python-format -msgid "" -"An alpha version of openpilot longitudinal control can be tested, along with " -"Experimental mode, on non-release branches." -msgstr "" -"openpilotの縦制御アルファ版は、実験モードと併せて非リリースブランチでテストで" -"きます。" - -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to power off?" msgstr "本当に電源をオフにしますか?" -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reboot?" msgstr "本当に再起動しますか?" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reset calibration?" msgstr "本当にキャリブレーションをリセットしますか?" -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Are you sure you want to uninstall?" msgstr "本当にアンインストールしますか?" -#: system/ui/widgets/network.py:99 selfdrive/ui/layouts/onboarding.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +#: system/ui/widgets/network.py msgid "Back" msgstr "戻る" -#: selfdrive/ui/widgets/prime.py:38 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Become a comma prime member at connect.comma.ai" msgstr "connect.comma.aiで comma prime に加入" -#: selfdrive/ui/widgets/pairing_dialog.py:130 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Bookmark connect.comma.ai to your home screen to use it like an app" msgstr "connect.comma.aiをホーム画面に追加してアプリのように使いましょう" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "CHANGE" msgstr "変更" -#: selfdrive/ui/layouts/settings/software.py:50 -#: selfdrive/ui/layouts/settings/software.py:107 -#: selfdrive/ui/layouts/settings/software.py:118 -#: selfdrive/ui/layouts/settings/software.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "CHECK" msgstr "確認" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "CHILL MODE ON" msgstr "チルモードON" -#: system/ui/widgets/network.py:155 selfdrive/ui/layouts/sidebar.py:73 -#: selfdrive/ui/layouts/sidebar.py:134 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:138 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/network.py msgid "CONNECT" msgstr "接続" -#: system/ui/widgets/network.py:369 -#, python-format +#: system/ui/widgets/network.py msgid "CONNECTING..." msgstr "接続中..." -#: system/ui/widgets/confirm_dialog.py:23 system/ui/widgets/option_dialog.py:35 -#: system/ui/widgets/keyboard.py:81 system/ui/widgets/network.py:318 -#, python-format +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/keyboard.py +#: system/ui/widgets/network.py +#: system/ui/widgets/option_dialog.py msgid "Cancel" msgstr "キャンセル" -#: system/ui/widgets/network.py:134 -#, python-format +#: system/ui/widgets/network.py msgid "Cellular Metered" msgstr "従量課金の携帯回線" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Change Language" msgstr "言語を変更" -#: selfdrive/ui/layouts/settings/toggles.py:125 -#, python-format -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "車が起動中の場合、この設定を変更するとopenpilotが再起動します。" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" -#: selfdrive/ui/widgets/pairing_dialog.py:129 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" msgstr "\"add new device\"を押して右側のQRコードをスキャン" -#: selfdrive/ui/widgets/offroad_alerts.py:104 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Close" msgstr "閉じる" -#: selfdrive/ui/layouts/settings/software.py:49 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Current Version" msgstr "現在のバージョン" -#: selfdrive/ui/layouts/settings/software.py:110 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "DOWNLOAD" msgstr "ダウンロード" -#: selfdrive/ui/layouts/onboarding.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Decline" msgstr "拒否する" -#: selfdrive/ui/layouts/onboarding.py:148 -#, python-format -msgid "Decline, uninstall openpilot" -msgstr "拒否してopenpilotをアンインストール" +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline, uninstall sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/settings.py:67 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" msgstr "開発者" -#: selfdrive/ui/layouts/settings/settings.py:62 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Device" msgstr "デバイス" -#: selfdrive/ui/layouts/settings/toggles.py:58 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Disengage on Accelerator Pedal" msgstr "アクセルで解除" -#: selfdrive/ui/layouts/settings/device.py:184 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Power Off" msgstr "解除して電源オフ" -#: selfdrive/ui/layouts/settings/device.py:172 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reboot" msgstr "解除して再起動" -#: selfdrive/ui/layouts/settings/device.py:103 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reset Calibration" msgstr "解除してキャリブレーションをリセット" -#: selfdrive/ui/layouts/settings/toggles.py:32 +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Display speed in km/h instead of mph." msgstr "速度をmphではなくkm/hで表示します。" -#: selfdrive/ui/layouts/settings/device.py:59 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Dongle ID" msgstr "ドングルID" -#: selfdrive/ui/layouts/settings/software.py:50 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Download" msgstr "ダウンロード" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Driver Camera" msgstr "ドライバーカメラ" -#: selfdrive/ui/layouts/settings/toggles.py:96 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Driving Personality" msgstr "走行性格" -#: system/ui/widgets/network.py:123 system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "EDIT" msgstr "編集" -#: selfdrive/ui/layouts/sidebar.py:138 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ERROR" msgstr "エラー" -#: selfdrive/ui/layouts/sidebar.py:45 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ETH" msgstr "ETH" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "EXPERIMENTAL MODE ON" msgstr "実験モードON" -#: selfdrive/ui/layouts/settings/developer.py:166 -#: selfdrive/ui/layouts/settings/toggles.py:228 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "有効化" -#: selfdrive/ui/layouts/settings/developer.py:39 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable ADB" msgstr "ADBを有効化" -#: selfdrive/ui/layouts/settings/toggles.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable Lane Departure Warnings" msgstr "車線逸脱警報を有効化" -#: system/ui/widgets/network.py:129 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Roaming" msgstr "ローミングを有効化" -#: selfdrive/ui/layouts/settings/developer.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable SSH" msgstr "SSHを有効化" -#: system/ui/widgets/network.py:120 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Tethering" msgstr "テザリングを有効化" -#: selfdrive/ui/layouts/settings/toggles.py:30 -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "openpilotが未作動でもドライバーモニタリングを有効にします。" - -#: selfdrive/ui/layouts/settings/toggles.py:46 -#, python-format -msgid "Enable openpilot" -msgstr "openpilotを有効化" - -#: selfdrive/ui/layouts/settings/toggles.py:189 -#, python-format -msgid "" -"Enable the openpilot longitudinal control (alpha) toggle to allow " -"Experimental mode." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable driver monitoring even when sunnypilot is not engaged." msgstr "" -"openpilot縦制御(アルファ)のトグルを有効にすると実験モードが使用できます。" -#: system/ui/widgets/network.py:204 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable sunnypilot" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" + +#: system/ui/widgets/network.py msgid "Enter APN" msgstr "APNを入力" -#: system/ui/widgets/network.py:241 -#, python-format +#: system/ui/widgets/network.py msgid "Enter SSID" msgstr "SSIDを入力" -#: system/ui/widgets/network.py:254 -#, python-format +#: system/ui/widgets/network.py msgid "Enter new tethering password" msgstr "新しいテザリングのパスワードを入力" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Enter password" msgstr "パスワードを入力" -#: selfdrive/ui/widgets/ssh_key.py:89 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "GitHubユーザー名を入力" -#: system/ui/widgets/list_view.py:123 system/ui/widgets/list_view.py:160 -#, python-format +#: system/ui/widgets/list_view.py msgid "Error" msgstr "エラー" -#: selfdrive/ui/layouts/settings/toggles.py:52 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Experimental Mode" msgstr "実験モード" -#: selfdrive/ui/layouts/settings/toggles.py:181 -#, python-format -msgid "" -"Experimental mode is currently unavailable on this car since the car's stock " -"ACC is used for longitudinal control." -msgstr "" -"この車では縦制御に純正ACCを使用するため、現在実験モードは利用できません。" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." +msgstr "この車では縦制御に純正ACCを使用するため、現在実験モードは利用できません。" -#: system/ui/widgets/network.py:373 -#, python-format +#: system/ui/widgets/network.py msgid "FORGETTING..." msgstr "削除中..." -#: selfdrive/ui/widgets/setup.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Finish Setup" msgstr "セットアップを完了" -#: selfdrive/ui/layouts/settings/settings.py:66 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Firehose" -msgstr "Firehose" +msgstr "大量配信" -#: selfdrive/ui/layouts/settings/firehose.py:18 +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "Firehose Mode" msgstr "Firehoseモード" -#: selfdrive/ui/layouts/settings/firehose.py:25 -msgid "" -"For maximum effectiveness, bring your device inside and connect to a good " -"USB-C adapter and Wi-Fi weekly.\n" -"\n" -"Firehose Mode can also work while you're driving if connected to a hotspot " -"or unlimited SIM card.\n" -"\n" -"\n" -"Frequently Asked Questions\n" -"\n" -"Does it matter how or where I drive? Nope, just drive as you normally " -"would.\n" -"\n" -"Do all of my segments get pulled in Firehose Mode? No, we selectively pull a " -"subset of your segments.\n" -"\n" -"What's a good USB-C adapter? Any fast phone or laptop charger should be " -"fine.\n" -"\n" -"Does it matter which software I run? Yes, only upstream openpilot (and " -"particular forks) are able to be used for training." +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" msgstr "" -"最大限の効果を得るため、デバイスを屋内に持ち込み、週に一度は品質の良いUSB-Cア" -"ダプターとWi‑Fiに接続してください。\n" -"\n" -"Firehoseモードは、ホットスポットや無制限SIMに接続していれば走行中でも動作しま" -"す。\n" -"\n" -"\n" -"よくある質問\n" -"\n" -"運転の仕方や場所は関係ありますか? いいえ。普段どおりに運転してください。\n" -"\n" -"Firehoseモードではすべてのセグメントが取得されますか? いいえ。セグメントの一" -"部を選択的に取得します。\n" -"\n" -"良いUSB‑Cアダプターとは? 高速なスマホまたはノートPC用充電器で問題ありませ" -"ん。\n" -"\n" -"どのソフトウェアを使うかは重要ですか? はい。学習に使えるのは上流のopenpilot" -"(および特定のフォーク)のみです。" -#: system/ui/widgets/network.py:318 system/ui/widgets/network.py:451 -#, python-format +#: system/ui/widgets/network.py msgid "Forget" msgstr "削除" -#: system/ui/widgets/network.py:319 -#, python-format +#: system/ui/widgets/network.py msgid "Forget Wi-Fi Network \"{}\"?" msgstr "Wi‑Fiネットワーク「{}」を削除しますか?" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "GOOD" msgstr "良好" -#: selfdrive/ui/widgets/pairing_dialog.py:128 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Go to https://connect.comma.ai on your phone" msgstr "スマートフォンで https://connect.comma.ai にアクセス" -#: selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "HIGH" msgstr "高温" -#: system/ui/widgets/network.py:155 -#, python-format +#: system/ui/widgets/network.py msgid "Hidden Network" msgstr "非公開ネットワーク" -#: selfdrive/ui/layouts/settings/firehose.py:140 -#, python-format -msgid "INACTIVE: connect to an unmetered network" -msgstr "非アクティブ:非従量のネットワークに接続してください" - -#: selfdrive/ui/layouts/settings/software.py:53 -#: selfdrive/ui/layouts/settings/software.py:136 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "INSTALL" msgstr "インストール" -#: system/ui/widgets/network.py:150 -#, python-format +#: system/ui/widgets/network.py msgid "IP Address" msgstr "IPアドレス" -#: selfdrive/ui/layouts/settings/software.py:53 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "アップデートをインストール" -#: selfdrive/ui/layouts/settings/developer.py:56 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "ジョイスティックデバッグモード" -#: selfdrive/ui/widgets/ssh_key.py:29 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "LOADING" msgstr "読み込み中" -#: selfdrive/ui/layouts/sidebar.py:48 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "LTE" msgstr "LTE" -#: selfdrive/ui/layouts/settings/developer.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "縦制御マヌーバーモード" -#: selfdrive/ui/onroad/hud_renderer.py:148 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "MAX" msgstr "最大" -#: selfdrive/ui/widgets/setup.py:75 -#, python-format -msgid "" -"Maximize your training data uploads to improve openpilot's driving models." -msgstr "" -"学習データのアップロードを最大化してopenpilotの運転モデルを改善しましょう。" +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Maximize your training data uploads to improve openpilot's driving models." +msgstr "学習データのアップロードを最大化してopenpilotの運転モデルを改善しましょう。" -#: selfdrive/ui/layouts/settings/device.py:59 -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "N/A" msgstr "該当なし" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "NO" msgstr "いいえ" -#: selfdrive/ui/layouts/settings/settings.py:63 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Network" msgstr "ネットワーク" -#: selfdrive/ui/widgets/ssh_key.py:114 -#, python-format -msgid "No SSH keys found" -msgstr "SSH鍵が見つかりません" - -#: selfdrive/ui/widgets/ssh_key.py:126 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "No SSH keys found for user '{}'" msgstr "ユーザー'{}'のSSH鍵が見つかりません" -#: selfdrive/ui/widgets/offroad_alerts.py:320 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "No release notes available." msgstr "リリースノートはありません。" -#: selfdrive/ui/layouts/sidebar.py:73 selfdrive/ui/layouts/sidebar.py:134 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "OFFLINE" msgstr "オフライン" -#: system/ui/widgets/html_render.py:263 system/ui/widgets/confirm_dialog.py:93 -#: selfdrive/ui/layouts/sidebar.py:127 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/html_render.py msgid "OK" msgstr "OK" -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ONLINE" msgstr "オンライン" -#: selfdrive/ui/widgets/setup.py:20 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Open" msgstr "開く" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PAIR" msgstr "ペアリング" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "PANDA" msgstr "PANDA" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PREVIEW" msgstr "プレビュー" -#: selfdrive/ui/widgets/prime.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "PRIME FEATURES:" msgstr "prime の特典:" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Pair Device" msgstr "デバイスをペアリング" -#: selfdrive/ui/widgets/setup.py:19 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Pair device" msgstr "デバイスをペアリング" -#: selfdrive/ui/widgets/pairing_dialog.py:103 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "デバイスをあなたの comma アカウントにペアリング" -#: selfdrive/ui/widgets/setup.py:48 selfdrive/ui/layouts/settings/device.py:24 -#, python-format -msgid "" -"Pair your device with comma connect (connect.comma.ai) and claim your comma " -"prime offer." -msgstr "" -"デバイスを comma connect(connect.comma.ai)とペアリングして、comma prime 特" -"典を受け取りましょう。" +#: openpilot/selfdrive/ui/layouts/settings/device.py +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." +msgstr "デバイスを comma connect(connect.comma.ai)とペアリングして、comma prime 特典を受け取りましょう。" -#: selfdrive/ui/widgets/setup.py:91 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Please connect to Wi-Fi to complete initial pairing" msgstr "初回ペアリングを完了するにはWi‑Fiに接続してください" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Power Off" msgstr "電源オフ" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered Wi-Fi connection" msgstr "従量課金のWi‑Fi接続時は大きなデータのアップロードを抑制" -#: system/ui/widgets/network.py:135 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered cellular connection" msgstr "従量課金の携帯回線接続時は大きなデータのアップロードを抑制" -#: selfdrive/ui/layouts/settings/device.py:25 -msgid "" -"Preview the driver facing camera to ensure that driver monitoring has good " -"visibility. (vehicle must be off)" -msgstr "" -"ドライバー向きカメラのプレビューでモニタリングの視界を確認します。(車両は停" -"止状態である必要があります)" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)" +msgstr "ドライバー向きカメラのプレビューでモニタリングの視界を確認します。(車両は停止状態である必要があります)" -#: selfdrive/ui/widgets/pairing_dialog.py:161 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "QR Code Error" msgstr "QRコードエラー" -#: selfdrive/ui/widgets/ssh_key.py:31 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "REMOVE" msgstr "削除" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "RESET" msgstr "リセット" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "REVIEW" msgstr "確認" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "再起動" -#: selfdrive/ui/onroad/alert_renderer.py:66 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Reboot Device" msgstr "デバイスを再起動" -#: selfdrive/ui/widgets/offroad_alerts.py:112 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Reboot and Update" msgstr "再起動して更新" -#: selfdrive/ui/layouts/settings/toggles.py:27 -msgid "" -"Receive alerts to steer back into the lane when your vehicle drifts over a " -"detected lane line without a turn signal activated while driving over 31 mph " -"(50 km/h)." -msgstr "" -"時速31mph(50km/h)を超えて走行中にウインカーを出さず検出された車線を外れた場" -"合、車線内に戻るよう警告を受け取ります。" - -#: selfdrive/ui/layouts/settings/toggles.py:76 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Driver Camera" msgstr "ドライバーカメラを記録してアップロード" -#: selfdrive/ui/layouts/settings/toggles.py:82 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Microphone Audio" msgstr "マイク音声を記録してアップロード" -#: selfdrive/ui/layouts/settings/toggles.py:33 -msgid "" -"Record and store microphone audio while driving. The audio will be included " -"in the dashcam video in comma connect." -msgstr "" -"走行中にマイク音声を記録・保存します。音声は comma connect のドライブレコー" -"ダー動画に含まれます。" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect." +msgstr "走行中にマイク音声を記録・保存します。音声は comma connect のドライブレコーダー動画に含まれます。" -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Regulatory" msgstr "規制情報" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Relaxed" msgstr "リラックス" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote access" msgstr "リモートアクセス" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote snapshots" msgstr "リモートスナップショット" -#: selfdrive/ui/widgets/ssh_key.py:123 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Request timed out" msgstr "リクエストがタイムアウトしました" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset" msgstr "リセット" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset Calibration" msgstr "キャリブレーションをリセット" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Review Training Guide" msgstr "トレーニングガイドを確認" -#: selfdrive/ui/layouts/settings/device.py:27 -msgid "Review the rules, features, and limitations of openpilot" -msgstr "openpilotのルール、機能、制限を確認" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" msgstr "選択" -#: selfdrive/ui/layouts/settings/developer.py:53 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "SSH Keys" msgstr "SSH鍵" -#: system/ui/widgets/network.py:310 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + +#: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "Wi‑Fiネットワークを検索中..." -#: system/ui/widgets/option_dialog.py:36 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + +#: system/ui/widgets/option_dialog.py msgid "Select" msgstr "選択" -#: selfdrive/ui/layouts/settings/software.py:183 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Select a branch" msgstr "ブランチを選択" -#: selfdrive/ui/layouts/settings/device.py:91 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Select a language" msgstr "言語を選択" -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Serial" msgstr "シリアル" -#: selfdrive/ui/widgets/offroad_alerts.py:106 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Snooze Update" msgstr "更新を後で通知" -#: selfdrive/ui/layouts/settings/settings.py:65 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Software" msgstr "ソフトウェア" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Standard" msgstr "スタンダード" -#: selfdrive/ui/layouts/settings/toggles.py:22 -msgid "" -"Standard is recommended. In aggressive mode, openpilot will follow lead cars " -"closer and be more aggressive with the gas and brake. In relaxed mode " -"openpilot will stay further away from lead cars. On supported cars, you can " -"cycle through these personalities with your steering wheel distance button." -msgstr "" -"標準を推奨します。アグレッシブでは前走車に近づき、加減速も積極的になります。" -"リラックスでは前走車との距離を保ちます。対応車種ではステアリングの車間ボタン" -"でこれらの性格を切り替えられます。" - -#: selfdrive/ui/onroad/alert_renderer.py:59 -#: selfdrive/ui/onroad/alert_renderer.py:65 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "System Unresponsive" msgstr "システムが応答しません" -#: selfdrive/ui/onroad/alert_renderer.py:58 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "TAKE CONTROL IMMEDIATELY" msgstr "すぐに手動介入してください" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 -#: selfdrive/ui/layouts/sidebar.py:127 selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "TEMP" msgstr "温度" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Target Branch" msgstr "対象ブランチ" -#: system/ui/widgets/network.py:124 -#, python-format +#: system/ui/widgets/network.py msgid "Tethering Password" msgstr "テザリングのパスワード" -#: selfdrive/ui/layouts/settings/settings.py:64 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Toggles" msgstr "トグル" -#: selfdrive/ui/layouts/settings/software.py:72 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "UI Debug Mode" +msgstr "UIデバッグモード" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "UNINSTALL" msgstr "アンインストール" -#: selfdrive/ui/layouts/home.py:155 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "UPDATE" msgstr "更新" -#: selfdrive/ui/layouts/settings/software.py:72 -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Uninstall" msgstr "アンインストール" -#: selfdrive/ui/layouts/sidebar.py:117 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Unknown" msgstr "不明" -#: selfdrive/ui/layouts/settings/software.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Updates are only downloaded while the car is off." msgstr "アップデートは車両の電源が切れている間のみダウンロードされます。" -#: selfdrive/ui/widgets/prime.py:33 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Upgrade Now" msgstr "今すぐアップグレード" -#: selfdrive/ui/layouts/settings/toggles.py:31 -msgid "" -"Upload data from the driver facing camera and help improve the driver " -"monitoring algorithm." -msgstr "" -"ドライバー向きカメラのデータをアップロードしてモニタリングアルゴリズムの改善" -"に協力してください。" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." +msgstr "ドライバー向きカメラのデータをアップロードしてモニタリングアルゴリズムの改善に協力してください。" -#: selfdrive/ui/layouts/settings/toggles.py:88 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Use Metric System" msgstr "メートル法を使用" -#: selfdrive/ui/layouts/settings/toggles.py:17 -msgid "" -"Use the openpilot system for adaptive cruise control and lane keep driver " -"assistance. Your attention is required at all times to use this feature." -msgstr "" -"ACCと車線維持支援にopenpilotを使用します。本機能の使用中は常に注意が必要で" -"す。" - -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "VEHICLE" msgstr "車両" -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "VIEW" msgstr "表示" -#: selfdrive/ui/onroad/alert_renderer.py:52 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Waiting to start" msgstr "開始待機中" -#: selfdrive/ui/layouts/settings/developer.py:19 -msgid "" -"Warning: This grants SSH access to all public keys in your GitHub settings. " -"Never enter a GitHub username other than your own. A comma employee will " -"NEVER ask you to add their GitHub username." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Welcome to sunnypilot" msgstr "" -"警告: これはGitHub設定内のすべての公開鍵にSSHアクセスを与えます。自分以外の" -"GitHubユーザー名を絶対に入力しないでください。comma の従業員が自分のGitHub" -"ユーザー名を追加するよう求めることは決してありません。" -#: selfdrive/ui/layouts/onboarding.py:111 -#, python-format -msgid "Welcome to openpilot" -msgstr "openpilotへようこそ" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:20 -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "有効にすると、アクセルを踏むとopenpilotが解除されます。" - -#: selfdrive/ui/layouts/sidebar.py:44 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" msgstr "Wi‑Fi" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Wi-Fi Network Metered" msgstr "Wi‑Fiネットワーク(従量課金)" -#: system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Wrong password" msgstr "パスワードが違います" -#: selfdrive/ui/layouts/onboarding.py:145 -#, python-format -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "openpilotを使用するには、利用規約に同意する必要があります。" - -#: selfdrive/ui/layouts/onboarding.py:112 -#, python-format -msgid "" -"You must accept the Terms and Conditions to use openpilot. Read the latest " -"terms at https://comma.ai/terms before continuing." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service in order to use sunnypilot." msgstr "" -"openpilotを使用するには利用規約に同意する必要があります。続行する前に " -"https://comma.ai/terms の最新の規約をお読みください。" -#: selfdrive/ui/onroad/driver_camera_dialog.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" + +#: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" msgstr "カメラを起動中" -#: selfdrive/ui/widgets/prime.py:63 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "checking..." +msgstr "チェック中..." + +#: openpilot/selfdrive/ui/widgets/prime.py msgid "comma prime" msgstr "comma prime" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "default" msgstr "既定" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "down" msgstr "下" -#: selfdrive/ui/layouts/settings/software.py:106 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "downloading..." +msgstr "ダウンロード中..." + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "failed to check for update" msgstr "アップデートの確認に失敗しました" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "finalizing update..." +msgstr "アップデートを終了しています..." + +#: system/ui/widgets/network.py msgid "for \"{}\"" msgstr "「{}」向け" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "leave blank for automatic configuration" msgstr "自動設定の場合は空欄のままにしてください" -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "left" msgstr "左" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "metered" msgstr "従量" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" -#: selfdrive/ui/layouts/settings/software.py:20 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "never" msgstr "なし" -#: selfdrive/ui/layouts/settings/software.py:31 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "now" msgstr "今" -#: selfdrive/ui/layouts/settings/developer.py:71 -#, python-format -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "openpilot 縦制御(アルファ)" - -#: selfdrive/ui/onroad/alert_renderer.py:51 -#, python-format -msgid "openpilot Unavailable" -msgstr "openpilotは利用できません" - -#: selfdrive/ui/layouts/settings/toggles.py:158 -#, python-format -msgid "" -"openpilot defaults to driving in chill mode. Experimental mode enables alpha-" -"level features that aren't ready for chill mode. Experimental features are " -"listed below:

End-to-End Longitudinal Control


Let the driving " -"model control the gas and brakes. openpilot will drive as it thinks a human " -"would, including stopping for red lights and stop signs. Since the driving " -"model decides the speed to drive, the set speed will only act as an upper " -"bound. This is an alpha quality feature; mistakes should be expected." -"

New Driving Visualization


The driving visualization will " -"transition to the road-facing wide-angle camera at low speeds to better show " -"some turns. The Experimental mode logo will also be shown in the top right " -"corner." -msgstr "" -"openpilotは既定でチルモードで走行します。実験モードでは、チルモードにはまだ準" -"備ができていないアルファレベルの機能が有効になります。実験的な機能は以下のと" -"おりです:

エンドツーエンド縦制御


運転モデルがアクセルとブレー" -"キを制御します。openpilotは人間のように走行し、赤信号や一時停止でも停止しま" -"す。走行速度は運転モデルが決めるため、設定速度は上限としてのみ機能します。こ" -"れはアルファ品質の機能であり、誤動作が発生する可能性があります。

新し" -"い運転ビジュアライゼーション


低速時には道路向きの広角カメラに切り替わ" -"り、一部の曲がりをより良く表示します。画面右上には実験モードのロゴも表示され" -"ます。" - -#: selfdrive/ui/layouts/settings/device.py:165 -#, python-format -msgid "" -"openpilot is continuously calibrating, resetting is rarely required. " -"Resetting calibration will restart openpilot if the car is powered on." -msgstr "" -"openpilotは継続的にキャリブレーションを行っており、リセットが必要になることは" -"稀です。車が起動中にキャリブレーションをリセットするとopenpilotが再起動しま" -"す。" - -#: selfdrive/ui/layouts/settings/firehose.py:20 -msgid "" -"openpilot learns to drive by watching humans, like you, drive.\n" -"\n" -"Firehose Mode allows you to maximize your training data uploads to improve " -"openpilot's driving models. More data means bigger models, which means " -"better Experimental Mode." -msgstr "" -"openpilotは、あなたのような人間の運転を見て運転を学習します。\n" -"\n" -"Firehoseモードを使うと、学習データのアップロードを最大化してopenpilotの運転モ" -"デルを改善できます。データが増えるほどモデルが大きくなり、実験モードがより良" -"くなります。" - -#: selfdrive/ui/layouts/settings/toggles.py:183 -#, python-format -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilotの縦制御は将来のアップデートで提供される可能性があります。" - -#: selfdrive/ui/layouts/settings/device.py:26 -msgid "" -"openpilot requires the device to be mounted within 4° left or right and " -"within 5° up or 9° down." -msgstr "" -"openpilotでは、デバイスの取り付け角度が左右±4°、上方向5°以内、下方向9°以内で" -"ある必要があります。" - -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "右" -#: system/ui/widgets/network.py:142 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + +#: system/ui/widgets/network.py msgid "unmetered" msgstr "非従量" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "up" msgstr "上" -#: selfdrive/ui/layouts/settings/software.py:117 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked never" msgstr "最新です。最終確認: なし" -#: selfdrive/ui/layouts/settings/software.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked {}" msgstr "最新です。最終確認: {}" -#: selfdrive/ui/layouts/settings/software.py:109 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "update available" msgstr "更新があります" -#: selfdrive/ui/layouts/home.py:169 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "{} ALERT" msgid_plural "{} ALERTS" msgstr[0] "{}件のアラート" -#: selfdrive/ui/layouts/settings/software.py:40 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "{}日前" -#: selfdrive/ui/layouts/settings/software.py:37 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "{}時間前" -#: selfdrive/ui/layouts/settings/software.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} minute ago" msgid_plural "{} minutes ago" msgstr[0] "{}分前" -#: selfdrive/ui/layouts/settings/firehose.py:111 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "{} segment of your driving is in the training dataset so far." msgid_plural "{} segments of your driving is in the training dataset so far." -msgstr[0] "" -"これまでにあなたの走行の{}セグメントが学習データセットに含まれています。" +msgstr[0] "これまでにあなたの走行の{}セグメントが学習データセットに含まれています。" -#: selfdrive/ui/widgets/prime.py:62 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "✓ SUBSCRIBED" msgstr "✓ 登録済み" -#: selfdrive/ui/widgets/setup.py:22 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "🔥 Firehose Mode 🔥" msgstr "🔥 Firehoseモード 🔥" + diff --git a/selfdrive/ui/translations/app_ko.po b/selfdrive/ui/translations/app_ko.po index f12aebaeb3..d5c5eb34a2 100644 --- a/selfdrive/ui/translations/app_ko.po +++ b/selfdrive/ui/translations/app_ko.po @@ -1,1190 +1,868 @@ -# Korean translations for PACKAGE package. -# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# Automatically generated, 2025. -# msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 00:50-0700\n" -"PO-Revision-Date: 2025-10-22 16:32-0700\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" -"Language: ko\n" -"MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" +"Language: ko\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: selfdrive/ui/layouts/settings/device.py:160 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is complete." msgstr " 스티어링 토크 응답 보정이 완료되었습니다." -#: selfdrive/ui/layouts/settings/device.py:158 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is {}% complete." msgstr " 스티어링 토크 응답 보정이 {}% 완료되었습니다." -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Your device is pointed {:.1f}° {} and {:.1f}° {}." msgstr " 장치는 {:.1f}° {} 및 {:.1f}° {} 방향을 가리키고 있습니다." -#: selfdrive/ui/layouts/sidebar.py:43 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "--" msgstr "--" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "1 year of drive storage" msgstr "주행 데이터 1년 보관" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "24/7 LTE connectivity" msgstr "연중무휴 LTE 연결" -#: selfdrive/ui/layouts/sidebar.py:46 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "2G" msgstr "2G" -#: selfdrive/ui/layouts/sidebar.py:47 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "3G" msgstr "3G" -#: selfdrive/ui/layouts/sidebar.py:49 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "5G" msgstr "5G" -#: selfdrive/ui/layouts/settings/developer.py:23 -msgid "" -"WARNING: openpilot longitudinal control is in alpha for this car and will " -"disable Automatic Emergency Braking (AEB).

On this car, openpilot " -"defaults to the car's built-in ACC instead of openpilot's longitudinal " -"control. Enable this to switch to openpilot longitudinal control. Enabling " -"Experimental mode is recommended when enabling openpilot longitudinal " -"control alpha. Changing this setting will restart openpilot if the car is " -"powered on." -msgstr "" -"경고: 이 차량에서 openpilot의 롱컨 제어는 알파 버전이며 자동 긴급 제동" -"(AEB)을 비활성화합니다.

이 차량에서는 openpilot 롱컨 제어 대신 " -"차량 내장 ACC가 기본으로 사용됩니다. openpilot 롱컨 제어로 전환하려면 이 설" -"정을 켜세요. 롱컨 제어 알파를 켤 때는 실험 모드 사용을 권장합니다. 차량 전" -"원이 켜져 있는 경우 이 설정을 변경하면 openpilot이 재시작됩니다." - -#: selfdrive/ui/layouts/settings/device.py:148 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is complete." msgstr "

스티어링 지연 보정이 완료되었습니다." -#: selfdrive/ui/layouts/settings/device.py:146 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is {}% complete." msgstr "

스티어링 지연 보정이 {}% 완료되었습니다." -#: selfdrive/ui/layouts/settings/firehose.py:138 -#, python-format -msgid "ACTIVE" -msgstr "활성" - -#: selfdrive/ui/layouts/settings/developer.py:15 -msgid "" -"ADB (Android Debug Bridge) allows connecting to your device over USB or over " -"the network. See https://docs.comma.ai/how-to/connect-to-comma for more info." -msgstr "" -"ADB(Android Debug Bridge)를 사용하면 USB 또는 네트워크로 장치에 연결할 수 있" -"습니다. 자세한 내용은 https://docs.comma.ai/how-to/connect-to-comma 를 참고하" -"세요." - -#: selfdrive/ui/widgets/ssh_key.py:30 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "ADD" msgstr "추가" -#: system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "APN Setting" msgstr "APN 설정" -#: selfdrive/ui/widgets/offroad_alerts.py:109 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Acknowledge Excessive Actuation" msgstr "과도한 작동을 확인" -#: system/ui/widgets/network.py:74 system/ui/widgets/network.py:95 -#, python-format +#: system/ui/widgets/network.py msgid "Advanced" msgstr "고급" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Aggressive" msgstr "공격적" -#: selfdrive/ui/layouts/onboarding.py:116 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Agree" msgstr "동의" -#: selfdrive/ui/layouts/settings/toggles.py:70 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Always-On Driver Monitoring" msgstr "운전자 모니터링 항상 켜짐" -#: selfdrive/ui/layouts/settings/toggles.py:186 -#, python-format -msgid "" -"An alpha version of openpilot longitudinal control can be tested, along with " -"Experimental mode, on non-release branches." -msgstr "" -"openpilot 롱컨 제어 알파 버전은 실험 모드와 함께 비릴리스 브랜치에서 테스트" -"할 수 있습니다." - -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to power off?" msgstr "정말 전원을 끄시겠습니까?" -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reboot?" msgstr "정말 재시작하시겠습니까?" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reset calibration?" msgstr "정말 보정을 재설정하시겠습니까?" -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Are you sure you want to uninstall?" msgstr "정말 제거하시겠습니까?" -#: system/ui/widgets/network.py:99 selfdrive/ui/layouts/onboarding.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +#: system/ui/widgets/network.py msgid "Back" msgstr "뒤로" -#: selfdrive/ui/widgets/prime.py:38 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Become a comma prime member at connect.comma.ai" msgstr "connect.comma.ai에서 comma prime 회원이 되세요" -#: selfdrive/ui/widgets/pairing_dialog.py:130 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Bookmark connect.comma.ai to your home screen to use it like an app" msgstr "connect.comma.ai를 홈 화면에 추가하여 앱처럼 사용하세요" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "CHANGE" msgstr "변경" -#: selfdrive/ui/layouts/settings/software.py:50 -#: selfdrive/ui/layouts/settings/software.py:107 -#: selfdrive/ui/layouts/settings/software.py:118 -#: selfdrive/ui/layouts/settings/software.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "CHECK" msgstr "확인" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "CHILL MODE ON" msgstr "안정적 모드 켜짐" -#: system/ui/widgets/network.py:155 selfdrive/ui/layouts/sidebar.py:73 -#: selfdrive/ui/layouts/sidebar.py:134 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:138 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/network.py msgid "CONNECT" msgstr "연결" -#: system/ui/widgets/network.py:369 -#, python-format +#: system/ui/widgets/network.py msgid "CONNECTING..." msgstr "연결 중..." -#: system/ui/widgets/confirm_dialog.py:23 system/ui/widgets/option_dialog.py:35 -#: system/ui/widgets/keyboard.py:81 system/ui/widgets/network.py:318 -#, python-format +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/keyboard.py +#: system/ui/widgets/network.py +#: system/ui/widgets/option_dialog.py msgid "Cancel" msgstr "취소" -#: system/ui/widgets/network.py:134 -#, python-format +#: system/ui/widgets/network.py msgid "Cellular Metered" msgstr "종량제 셀룰러" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Change Language" msgstr "언어 변경" -#: selfdrive/ui/layouts/settings/toggles.py:125 -#, python-format -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "차량 전원이 켜져 있으면 이 설정을 변경할 때 openpilot이 재시작됩니다." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" -#: selfdrive/ui/widgets/pairing_dialog.py:129 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" msgstr "\"add new device\"를 눌러 오른쪽의 QR 코드를 스캔하세요" -#: selfdrive/ui/widgets/offroad_alerts.py:104 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Close" msgstr "닫기" -#: selfdrive/ui/layouts/settings/software.py:49 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Current Version" msgstr "현재 버전" -#: selfdrive/ui/layouts/settings/software.py:110 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "DOWNLOAD" msgstr "다운로드" -#: selfdrive/ui/layouts/onboarding.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Decline" msgstr "거부" -#: selfdrive/ui/layouts/onboarding.py:148 -#, python-format -msgid "Decline, uninstall openpilot" -msgstr "거부하고 openpilot 제거" +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline, uninstall sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/settings.py:67 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" msgstr "개발자" -#: selfdrive/ui/layouts/settings/settings.py:62 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Device" msgstr "장치" -#: selfdrive/ui/layouts/settings/toggles.py:58 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Disengage on Accelerator Pedal" msgstr "가속 페달로 해제" -#: selfdrive/ui/layouts/settings/device.py:184 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Power Off" msgstr "해제 후 전원 끄기" -#: selfdrive/ui/layouts/settings/device.py:172 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reboot" msgstr "해제 후 재시작" -#: selfdrive/ui/layouts/settings/device.py:103 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reset Calibration" msgstr "해제 후 캘리브레이션 재설정" -#: selfdrive/ui/layouts/settings/toggles.py:32 +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Display speed in km/h instead of mph." msgstr "속도를 mph 대신 km/h로 표시합니다." -#: selfdrive/ui/layouts/settings/device.py:59 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Dongle ID" msgstr "동글 ID" -#: selfdrive/ui/layouts/settings/software.py:50 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Download" msgstr "다운로드" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Driver Camera" msgstr "운전자 카메라" -#: selfdrive/ui/layouts/settings/toggles.py:96 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Driving Personality" msgstr "주행 성향" -#: system/ui/widgets/network.py:123 system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "EDIT" msgstr "편집" -#: selfdrive/ui/layouts/sidebar.py:138 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ERROR" msgstr "오류" -#: selfdrive/ui/layouts/sidebar.py:45 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ETH" msgstr "ETH" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "EXPERIMENTAL MODE ON" msgstr "실험 모드 켜짐" -#: selfdrive/ui/layouts/settings/developer.py:166 -#: selfdrive/ui/layouts/settings/toggles.py:228 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "사용" -#: selfdrive/ui/layouts/settings/developer.py:39 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable ADB" msgstr "ADB 사용" -#: selfdrive/ui/layouts/settings/toggles.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable Lane Departure Warnings" msgstr "차선 이탈 경고 사용" -#: system/ui/widgets/network.py:129 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Roaming" msgstr "로밍 사용" -#: selfdrive/ui/layouts/settings/developer.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable SSH" msgstr "SSH 사용" -#: system/ui/widgets/network.py:120 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Tethering" msgstr "테더링 사용" -#: selfdrive/ui/layouts/settings/toggles.py:30 -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "openpilot이 작동 중이 아닐 때도 운전자 모니터링을 사용합니다." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:46 -#, python-format -msgid "Enable openpilot" -msgstr "openpilot 사용" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:189 -#, python-format -msgid "" -"Enable the openpilot longitudinal control (alpha) toggle to allow " -"Experimental mode." -msgstr "실험 모드를 사용하려면 openpilot 롱컨 제어(알파) 토글을 켜세요." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "Enter APN" msgstr "APN 입력" -#: system/ui/widgets/network.py:241 -#, python-format +#: system/ui/widgets/network.py msgid "Enter SSID" msgstr "SSID 입력" -#: system/ui/widgets/network.py:254 -#, python-format +#: system/ui/widgets/network.py msgid "Enter new tethering password" msgstr "새 테더링 비밀번호 입력" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Enter password" msgstr "비밀번호 입력" -#: selfdrive/ui/widgets/ssh_key.py:89 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "GitHub 사용자 이름 입력" -#: system/ui/widgets/list_view.py:123 system/ui/widgets/list_view.py:160 -#, python-format +#: system/ui/widgets/list_view.py msgid "Error" msgstr "오류" -#: selfdrive/ui/layouts/settings/toggles.py:52 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Experimental Mode" msgstr "실험 모드" -#: selfdrive/ui/layouts/settings/toggles.py:181 -#, python-format -msgid "" -"Experimental mode is currently unavailable on this car since the car's stock " -"ACC is used for longitudinal control." -msgstr "" -"이 차량은 롱컨 제어에 순정 ACC를 사용하므로 현재 실험 모드를 사용할 수 없습" -"니다." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." +msgstr "이 차량은 롱컨 제어에 순정 ACC를 사용하므로 현재 실험 모드를 사용할 수 없습니다." -#: system/ui/widgets/network.py:373 -#, python-format +#: system/ui/widgets/network.py msgid "FORGETTING..." msgstr "삭제 중..." -#: selfdrive/ui/widgets/setup.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Finish Setup" msgstr "설정 완료" -#: selfdrive/ui/layouts/settings/settings.py:66 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Firehose" msgstr "파이어호스" -#: selfdrive/ui/layouts/settings/firehose.py:18 +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "Firehose Mode" msgstr "파이어호스 모드" -#: selfdrive/ui/layouts/settings/firehose.py:25 -msgid "" -"For maximum effectiveness, bring your device inside and connect to a good " -"USB-C adapter and Wi-Fi weekly.\n" -"\n" -"Firehose Mode can also work while you're driving if connected to a hotspot " -"or unlimited SIM card.\n" -"\n" -"\n" -"Frequently Asked Questions\n" -"\n" -"Does it matter how or where I drive? Nope, just drive as you normally " -"would.\n" -"\n" -"Do all of my segments get pulled in Firehose Mode? No, we selectively pull a " -"subset of your segments.\n" -"\n" -"What's a good USB-C adapter? Any fast phone or laptop charger should be " -"fine.\n" -"\n" -"Does it matter which software I run? Yes, only upstream openpilot (and " -"particular forks) are able to be used for training." +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" msgstr "" -"최대의 효과를 위해 주 1회는 장치를 실내로 가져와 품질 좋은 USB‑C 어댑터와 " -"Wi‑Fi에 연결하세요.\n" -"\n" -"핫스팟이나 무제한 SIM에 연결되어 있다면 주행 중에도 파이어호스 모드가 동작합니" -"다.\n" -"\n" -"\n" -"자주 묻는 질문\n" -"\n" -"어떻게, 어디서 운전하는지가 중요한가요? 아니요. 평소처럼 운전하세요.\n" -"\n" -"파이어호스 모드에서 모든 구간을 가져가지나요? 아니요. 일부 구간만 선택" -"적으로 가져갑니다.\n" -"\n" -"좋은 USB‑C 어댑터는 무엇인가요? 빠른 휴대폰 또는 노트북 충전기면 충분합니" -"다.\n" -"\n" -"어떤 소프트웨어를 실행하는지가 중요한가요? 예. 학습에는 업스트림 " -"openpilot(및 일부 포크)만 사용할 수 있습니다." -#: system/ui/widgets/network.py:318 system/ui/widgets/network.py:451 -#, python-format +#: system/ui/widgets/network.py msgid "Forget" msgstr "삭제" -#: system/ui/widgets/network.py:319 -#, python-format +#: system/ui/widgets/network.py msgid "Forget Wi-Fi Network \"{}\"?" msgstr "Wi‑Fi 네트워크 \"{}\"를 삭제하시겠습니까?" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "GOOD" msgstr "양호" -#: selfdrive/ui/widgets/pairing_dialog.py:128 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Go to https://connect.comma.ai on your phone" msgstr "휴대폰에서 https://connect.comma.ai 에 접속하세요" -#: selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "HIGH" msgstr "높음" -#: system/ui/widgets/network.py:155 -#, python-format +#: system/ui/widgets/network.py msgid "Hidden Network" msgstr "숨겨진 네트워크" -#: selfdrive/ui/layouts/settings/firehose.py:140 -#, python-format -msgid "INACTIVE: connect to an unmetered network" -msgstr "비활성: 비종량제 네트워크에 연결하세요" - -#: selfdrive/ui/layouts/settings/software.py:53 -#: selfdrive/ui/layouts/settings/software.py:136 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "INSTALL" msgstr "설치" -#: system/ui/widgets/network.py:150 -#, python-format +#: system/ui/widgets/network.py msgid "IP Address" msgstr "IP 주소" -#: selfdrive/ui/layouts/settings/software.py:53 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "업데이트 설치" -#: selfdrive/ui/layouts/settings/developer.py:56 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "조이스틱 디버그 모드" -#: selfdrive/ui/widgets/ssh_key.py:29 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "LOADING" msgstr "로딩 중" -#: selfdrive/ui/layouts/sidebar.py:48 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "LTE" msgstr "LTE" -#: selfdrive/ui/layouts/settings/developer.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "롱컨 기동 모드" -#: selfdrive/ui/onroad/hud_renderer.py:148 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "MAX" msgstr "최대" -#: selfdrive/ui/widgets/setup.py:75 -#, python-format -msgid "" -"Maximize your training data uploads to improve openpilot's driving models." +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Maximize your training data uploads to improve openpilot's driving models." msgstr "학습 데이터 업로드를 최대화하여 openpilot의 주행 모델을 개선하세요." -#: selfdrive/ui/layouts/settings/device.py:59 -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "N/A" msgstr "해당 없음" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "NO" msgstr "아니오" -#: selfdrive/ui/layouts/settings/settings.py:63 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Network" msgstr "네트워크" -#: selfdrive/ui/widgets/ssh_key.py:114 -#, python-format -msgid "No SSH keys found" -msgstr "SSH 키를 찾을 수 없습니다" - -#: selfdrive/ui/widgets/ssh_key.py:126 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "No SSH keys found for user '{}'" msgstr "사용자 '{}'의 SSH 키를 찾을 수 없습니다" -#: selfdrive/ui/widgets/offroad_alerts.py:320 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "No release notes available." msgstr "릴리스 노트가 없습니다." -#: selfdrive/ui/layouts/sidebar.py:73 selfdrive/ui/layouts/sidebar.py:134 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "OFFLINE" msgstr "오프라인" -#: system/ui/widgets/html_render.py:263 system/ui/widgets/confirm_dialog.py:93 -#: selfdrive/ui/layouts/sidebar.py:127 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/html_render.py msgid "OK" msgstr "확인" -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ONLINE" msgstr "온라인" -#: selfdrive/ui/widgets/setup.py:20 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Open" msgstr "열기" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PAIR" msgstr "페어링" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "PANDA" msgstr "PANDA" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PREVIEW" msgstr "미리보기" -#: selfdrive/ui/widgets/prime.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "PRIME FEATURES:" msgstr "프라임 기능:" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Pair Device" msgstr "장치 페어링" -#: selfdrive/ui/widgets/setup.py:19 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Pair device" msgstr "장치 페어링" -#: selfdrive/ui/widgets/pairing_dialog.py:103 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "장치를 귀하의 comma 계정에 페어링하세요" -#: selfdrive/ui/widgets/setup.py:48 selfdrive/ui/layouts/settings/device.py:24 -#, python-format -msgid "" -"Pair your device with comma connect (connect.comma.ai) and claim your comma " -"prime offer." -msgstr "" -"장치를 comma connect(connect.comma.ai)와 페어링하고 comma 프라임 혜택을 받으세" -"요." +#: openpilot/selfdrive/ui/layouts/settings/device.py +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." +msgstr "장치를 comma connect(connect.comma.ai)와 페어링하고 comma 프라임 혜택을 받으세요." -#: selfdrive/ui/widgets/setup.py:91 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Please connect to Wi-Fi to complete initial pairing" msgstr "초기 페어링을 완료하려면 Wi‑Fi에 연결하세요" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Power Off" msgstr "전원 끄기" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered Wi-Fi connection" msgstr "종량제 Wi‑Fi 연결 시 대용량 업로드 방지" -#: system/ui/widgets/network.py:135 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered cellular connection" msgstr "종량제 셀룰러 연결 시 대용량 업로드 방지" -#: selfdrive/ui/layouts/settings/device.py:25 -msgid "" -"Preview the driver facing camera to ensure that driver monitoring has good " -"visibility. (vehicle must be off)" -msgstr "" -"운전자 모니터링의 가시성을 확인하기 위해 운전자 카메라를 미리 봅니다. (차량" -"은 꺼져 있어야 합니다)" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)" +msgstr "운전자 모니터링의 가시성을 확인하기 위해 운전자 카메라를 미리 봅니다. (차량은 꺼져 있어야 합니다)" -#: selfdrive/ui/widgets/pairing_dialog.py:161 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "QR Code Error" msgstr "QR 코드 오류" -#: selfdrive/ui/widgets/ssh_key.py:31 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "REMOVE" msgstr "제거" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "RESET" msgstr "재설정" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "REVIEW" msgstr "검토" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "재시작" -#: selfdrive/ui/onroad/alert_renderer.py:66 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Reboot Device" msgstr "장치 재시작" -#: selfdrive/ui/widgets/offroad_alerts.py:112 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Reboot and Update" msgstr "재시작 및 업데이트" -#: selfdrive/ui/layouts/settings/toggles.py:27 -msgid "" -"Receive alerts to steer back into the lane when your vehicle drifts over a " -"detected lane line without a turn signal activated while driving over 31 mph " -"(50 km/h)." -msgstr "" -"시속 31mph(50km/h) 이상에서 방향지시등 없이 감지된 차선 밖으로 벗어나면 차선" -"으로 복귀하라는 경고를 받습니다." - -#: selfdrive/ui/layouts/settings/toggles.py:76 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Driver Camera" msgstr "운전자 카메라 기록 및 업로드" -#: selfdrive/ui/layouts/settings/toggles.py:82 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Microphone Audio" msgstr "마이크 오디오 기록 및 업로드" -#: selfdrive/ui/layouts/settings/toggles.py:33 -msgid "" -"Record and store microphone audio while driving. The audio will be included " -"in the dashcam video in comma connect." -msgstr "" -"주행 중 마이크 오디오를 기록하고 저장합니다. 오디오는 comma connect의 대시캠 " -"영상에 포함됩니다." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect." +msgstr "주행 중 마이크 오디오를 기록하고 저장합니다. 오디오는 comma connect의 대시캠 영상에 포함됩니다." -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Regulatory" msgstr "규제 정보" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Relaxed" msgstr "편안한" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote access" msgstr "원격 액세스" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote snapshots" msgstr "원격 스냅샷" -#: selfdrive/ui/widgets/ssh_key.py:123 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Request timed out" msgstr "요청 시간이 초과되었습니다" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset" msgstr "재설정" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset Calibration" msgstr "캘리브레이션 재설정" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Review Training Guide" msgstr "학습 가이드 검토" -#: selfdrive/ui/layouts/settings/device.py:27 -msgid "Review the rules, features, and limitations of openpilot" -msgstr "openpilot의 규칙, 기능 및 제한을 검토" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" msgstr "선택" -#: selfdrive/ui/layouts/settings/developer.py:53 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "SSH Keys" msgstr "SSH 키" -#: system/ui/widgets/network.py:310 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + +#: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "Wi‑Fi 네트워크 검색 중..." -#: system/ui/widgets/option_dialog.py:36 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + +#: system/ui/widgets/option_dialog.py msgid "Select" msgstr "선택" -#: selfdrive/ui/layouts/settings/software.py:183 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Select a branch" msgstr "브랜치 선택" -#: selfdrive/ui/layouts/settings/device.py:91 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Select a language" msgstr "언어 선택" -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Serial" msgstr "시리얼" -#: selfdrive/ui/widgets/offroad_alerts.py:106 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Snooze Update" msgstr "업데이트 나중에 알림" -#: selfdrive/ui/layouts/settings/settings.py:65 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Software" msgstr "소프트웨어" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Standard" msgstr "표준" -#: selfdrive/ui/layouts/settings/toggles.py:22 -msgid "" -"Standard is recommended. In aggressive mode, openpilot will follow lead cars " -"closer and be more aggressive with the gas and brake. In relaxed mode " -"openpilot will stay further away from lead cars. On supported cars, you can " -"cycle through these personalities with your steering wheel distance button." -msgstr "" -"표준을 권장합니다. 공격적 모드에서는 앞차를 더 가깝게 따라가고 가감속이 더 적" -"극적입니다. 편안한 모드에서는 앞차와 거리를 더 둡니다. 지원 차량에서는 스티어" -"링의 차간 버튼으로 이 성향들을 전환할 수 있습니다." - -#: selfdrive/ui/onroad/alert_renderer.py:59 -#: selfdrive/ui/onroad/alert_renderer.py:65 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "System Unresponsive" msgstr "시스템 응답 없음" -#: selfdrive/ui/onroad/alert_renderer.py:58 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "TAKE CONTROL IMMEDIATELY" msgstr "즉시 수동 조작하세요" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 -#: selfdrive/ui/layouts/sidebar.py:127 selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "TEMP" msgstr "온도" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Target Branch" msgstr "대상 브랜치" -#: system/ui/widgets/network.py:124 -#, python-format +#: system/ui/widgets/network.py msgid "Tethering Password" msgstr "테더링 비밀번호" -#: selfdrive/ui/layouts/settings/settings.py:64 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Toggles" msgstr "토글" -#: selfdrive/ui/layouts/settings/software.py:72 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "UI Debug Mode" +msgstr "UI 디버그 모드" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "UNINSTALL" msgstr "제거" -#: selfdrive/ui/layouts/home.py:155 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "UPDATE" msgstr "업데이트" -#: selfdrive/ui/layouts/settings/software.py:72 -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Uninstall" msgstr "제거" -#: selfdrive/ui/layouts/sidebar.py:117 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Unknown" msgstr "알수없음" -#: selfdrive/ui/layouts/settings/software.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Updates are only downloaded while the car is off." msgstr "업데이트는 차량 전원이 꺼져 있을 때만 다운로드됩니다." -#: selfdrive/ui/widgets/prime.py:33 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Upgrade Now" msgstr "지금 업그레이드" -#: selfdrive/ui/layouts/settings/toggles.py:31 -msgid "" -"Upload data from the driver facing camera and help improve the driver " -"monitoring algorithm." -msgstr "" -"운전자 방향 카메라 데이터를 업로드하여 운전자 모니터링 알고리즘 개선에 도움" -"을 주세요." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." +msgstr "운전자 방향 카메라 데이터를 업로드하여 운전자 모니터링 알고리즘 개선에 도움을 주세요." -#: selfdrive/ui/layouts/settings/toggles.py:88 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Use Metric System" msgstr "미터법 사용" -#: selfdrive/ui/layouts/settings/toggles.py:17 -msgid "" -"Use the openpilot system for adaptive cruise control and lane keep driver " -"assistance. Your attention is required at all times to use this feature." -msgstr "" -"ACC 및 차선 유지 보조에 openpilot을 사용합니다. 이 기능을 사용할 때는 항상 주" -"의가 필요합니다." - -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "VEHICLE" msgstr "차량" -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "VIEW" msgstr "보기" -#: selfdrive/ui/onroad/alert_renderer.py:52 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Waiting to start" msgstr "시작 대기 중" -#: selfdrive/ui/layouts/settings/developer.py:19 -msgid "" -"Warning: This grants SSH access to all public keys in your GitHub settings. " -"Never enter a GitHub username other than your own. A comma employee will " -"NEVER ask you to add their GitHub username." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Welcome to sunnypilot" msgstr "" -"경고: 이는 GitHub 설정의 모든 공개 키에 SSH 액세스를 부여합니다. 자신의 것이 " -"아닌 GitHub 사용자 이름을 절대 입력하지 마세요. comma 직원이 본인의 GitHub 사" -"용자 이름 추가를 요구하는 일은 결코 없습니다." -#: selfdrive/ui/layouts/onboarding.py:111 -#, python-format -msgid "Welcome to openpilot" -msgstr "openpilot에 오신 것을 환영합니다" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:20 -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "이 옵션을 켜면 가속 페달을 밟을 때 openpilot이 해제됩니다." - -#: selfdrive/ui/layouts/sidebar.py:44 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" msgstr "Wi‑Fi" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Wi-Fi Network Metered" msgstr "Wi‑Fi 네트워크 종량제" -#: system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Wrong password" msgstr "비밀번호가 올바르지 않습니다" -#: selfdrive/ui/layouts/onboarding.py:145 -#, python-format -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "openpilot을 사용하려면 약관에 동의해야 합니다." - -#: selfdrive/ui/layouts/onboarding.py:112 -#, python-format -msgid "" -"You must accept the Terms and Conditions to use openpilot. Read the latest " -"terms at https://comma.ai/terms before continuing." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service in order to use sunnypilot." msgstr "" -"openpilot을 사용하려면 약관에 동의해야 합니다. 계속하기 전에 https://comma." -"ai/terms 에서 최신 약관을 읽어주세요." -#: selfdrive/ui/onroad/driver_camera_dialog.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" + +#: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" msgstr "카메라 시작 중" -#: selfdrive/ui/widgets/prime.py:63 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "checking..." +msgstr "확인 중..." + +#: openpilot/selfdrive/ui/widgets/prime.py msgid "comma prime" msgstr "comma 프라임" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "default" msgstr "기본값" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "down" msgstr "아래" -#: selfdrive/ui/layouts/settings/software.py:106 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "downloading..." +msgstr "다운로드 중..." + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "failed to check for update" msgstr "업데이트 확인 실패" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "finalizing update..." +msgstr "업데이트 마무리 중..." + +#: system/ui/widgets/network.py msgid "for \"{}\"" msgstr "\"{}\"용" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "leave blank for automatic configuration" msgstr "자동 구성을 사용하려면 비워 두세요" -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "left" msgstr "왼쪽" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "metered" msgstr "종량제" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" -#: selfdrive/ui/layouts/settings/software.py:20 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "never" msgstr "없음" -#: selfdrive/ui/layouts/settings/software.py:31 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "now" msgstr "지금" -#: selfdrive/ui/layouts/settings/developer.py:71 -#, python-format -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "openpilot 롱컨 제어(알파)" - -#: selfdrive/ui/onroad/alert_renderer.py:51 -#, python-format -msgid "openpilot Unavailable" -msgstr "openpilot 사용 불가" - -#: selfdrive/ui/layouts/settings/toggles.py:158 -#, python-format -msgid "" -"openpilot defaults to driving in chill mode. Experimental mode enables alpha-" -"level features that aren't ready for chill mode. Experimental features are " -"listed below:

End-to-End Longitudinal Control


Let the driving " -"model control the gas and brakes. openpilot will drive as it thinks a human " -"would, including stopping for red lights and stop signs. Since the driving " -"model decides the speed to drive, the set speed will only act as an upper " -"bound. This is an alpha quality feature; mistakes should be expected." -"

New Driving Visualization


The driving visualization will " -"transition to the road-facing wide-angle camera at low speeds to better show " -"some turns. The Experimental mode logo will also be shown in the top right " -"corner." -msgstr "" -"openpilot은 기본적으로 안정적 모드로 주행합니다. 실험 모드를 사용하면 안정적 모드에 " -"아직 준비되지 않은 알파 수준의 기능이 활성화됩니다. 실험 기능은 아래와 같습니" -"다:

엔드투엔드 롱컨 제어


주행 모델이 가속과 제동을 제어합니" -"다. openpilot은 빨간 신호 및 정지 표지에서의 정지를 포함해 사람이 운전한다고 " -"판단하는 방식으로 주행합니다. 주행 속도는 모델이 결정하므로 설정 속도는 상한" -"으로만 동작합니다. 알파 품질 기능이므로 오작동이 발생할 수 있습니다.

" -"새로운 주행 시각화


저속에서는 도로 방향의 광각 카메라로 전환되어 일" -"부 회전을 더 잘 보여줍니다. 화면 오른쪽 위에는 실험 모드 로고도 표시됩니다." - -#: selfdrive/ui/layouts/settings/device.py:165 -#, python-format -msgid "" -"openpilot is continuously calibrating, resetting is rarely required. " -"Resetting calibration will restart openpilot if the car is powered on." -msgstr "" -"openpilot은 지속적으로 보정을 진행하므로 재설정이 필요한 경우는 드뭅니다. 차" -"량 전원이 켜져 있을 때 보정을 재설정하면 openpilot이 재시작됩니다." - -#: selfdrive/ui/layouts/settings/firehose.py:20 -msgid "" -"openpilot learns to drive by watching humans, like you, drive.\n" -"\n" -"Firehose Mode allows you to maximize your training data uploads to improve " -"openpilot's driving models. More data means bigger models, which means " -"better Experimental Mode." -msgstr "" -"openpilot은 당신과 같은 사람의 운전을 보며 운전을 학습합니다.\n" -"\n" -"Firehose 모드는 학습 데이터 업로드를 최대화하여 openpilot의 주행 모델을 개선" -"할 수 있게 해줍니다. 데이터가 많을수록 모델은 커지고, 실험 모드는 더 좋아집니" -"다." - -#: selfdrive/ui/layouts/settings/toggles.py:183 -#, python-format -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilot 롱컨 제어는 향후 업데이트에서 제공될 수 있습니다." - -#: selfdrive/ui/layouts/settings/device.py:26 -msgid "" -"openpilot requires the device to be mounted within 4° left or right and " -"within 5° up or 9° down." -msgstr "openpilot은 장치를 좌우 4°, 위쪽 5°, 아래쪽 9° 이내로 장착해야 합니다." - -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "오른쪽" -#: system/ui/widgets/network.py:142 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + +#: system/ui/widgets/network.py msgid "unmetered" msgstr "비종량제" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "up" msgstr "위" -#: selfdrive/ui/layouts/settings/software.py:117 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked never" msgstr "최신입니다. 마지막 확인: 없음" -#: selfdrive/ui/layouts/settings/software.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked {}" msgstr "최신입니다. 마지막 확인: {}" -#: selfdrive/ui/layouts/settings/software.py:109 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "update available" msgstr "업데이트 가능" -#: selfdrive/ui/layouts/home.py:169 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "{} ALERT" msgid_plural "{} ALERTS" msgstr[0] "{}건의 알림" -#: selfdrive/ui/layouts/settings/software.py:40 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "{}일 전" -#: selfdrive/ui/layouts/settings/software.py:37 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "{}시간 전" -#: selfdrive/ui/layouts/settings/software.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} minute ago" msgid_plural "{} minutes ago" msgstr[0] "{}분 전" -#: selfdrive/ui/layouts/settings/firehose.py:111 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "{} segment of your driving is in the training dataset so far." msgid_plural "{} segments of your driving is in the training dataset so far." msgstr[0] "현재까지 귀하의 주행 {}구간이 학습 데이터셋에 포함되었습니다." -#: selfdrive/ui/widgets/prime.py:62 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "✓ SUBSCRIBED" msgstr "✓ 구독됨" -#: selfdrive/ui/widgets/setup.py:22 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "🔥 Firehose Mode 🔥" msgstr "🔥 파이어호스 모드 🔥" + diff --git a/selfdrive/ui/translations/app_pt-BR.po b/selfdrive/ui/translations/app_pt-BR.po index 84b53c6e8d..5244d9f63f 100644 --- a/selfdrive/ui/translations/app_pt-BR.po +++ b/selfdrive/ui/translations/app_pt-BR.po @@ -1,1220 +1,873 @@ -# Language pt-BR translations for PACKAGE package. -# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# Automatically generated, 2025. -# msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 00:50-0700\n" -"PO-Revision-Date: 2025-10-21 00:00-0700\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" -"Language: pt-BR\n" -"MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" +"Language: pt-BR\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -"X-Language: pt_BR\n" -"X-Source-Language: C\n" -#: selfdrive/ui/layouts/settings/device.py:160 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is complete." msgstr " A calibração da resposta de torque da direção foi concluída." -#: selfdrive/ui/layouts/settings/device.py:158 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is {}% complete." msgstr " A calibração da resposta de torque da direção está {}% concluída." -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Your device is pointed {:.1f}° {} and {:.1f}° {}." msgstr " Seu dispositivo está apontado {:.1f}° {} e {:.1f}° {}." -#: selfdrive/ui/layouts/sidebar.py:43 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "--" msgstr "--" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "1 year of drive storage" msgstr "1 ano de armazenamento de condução" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "24/7 LTE connectivity" msgstr "Conectividade LTE 24/7" -#: selfdrive/ui/layouts/sidebar.py:46 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "2G" msgstr "2G" -#: selfdrive/ui/layouts/sidebar.py:47 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "3G" msgstr "3G" -#: selfdrive/ui/layouts/sidebar.py:49 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "5G" msgstr "5G" -#: selfdrive/ui/layouts/settings/developer.py:23 -msgid "" -"WARNING: openpilot longitudinal control is in alpha for this car and will " -"disable Automatic Emergency Braking (AEB).

On this car, openpilot " -"defaults to the car's built-in ACC instead of openpilot's longitudinal " -"control. Enable this to switch to openpilot longitudinal control. Enabling " -"Experimental mode is recommended when enabling openpilot longitudinal " -"control alpha. Changing this setting will restart openpilot if the car is " -"powered on." -msgstr "" -"AVISO: o controle longitudinal do openpilot está em alpha para este carro " -"e desativará a Frenagem Automática de Emergência (AEB).

Neste " -"carro, o openpilot usa por padrão o ACC integrado do carro em vez do " -"controle longitudinal do openpilot. Ative isto para alternar para o controle " -"longitudinal do openpilot. Recomenda-se ativar o Modo Experimental ao ativar " -"o controle longitudinal do openpilot em alpha." - -#: selfdrive/ui/layouts/settings/device.py:148 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is complete." msgstr "

A calibração da latência da direção está concluída." -#: selfdrive/ui/layouts/settings/device.py:146 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is {}% complete." msgstr "

A calibração da latência da direção está {}% concluída." -#: selfdrive/ui/layouts/settings/firehose.py:138 -#, python-format -msgid "ACTIVE" -msgstr "ATIVO" - -#: selfdrive/ui/layouts/settings/developer.py:15 -msgid "" -"ADB (Android Debug Bridge) allows connecting to your device over USB or over " -"the network. See https://docs.comma.ai/how-to/connect-to-comma for more info." -msgstr "" -"ADB (Android Debug Bridge) permite conectar ao seu dispositivo via USB ou " -"pela rede. Veja https://docs.comma.ai/how-to/connect-to-comma para mais " -"informações." - -#: selfdrive/ui/widgets/ssh_key.py:30 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "ADD" msgstr "ADICIONAR" -#: system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "APN Setting" msgstr "Configuração de APN" -#: selfdrive/ui/widgets/offroad_alerts.py:109 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Acknowledge Excessive Actuation" msgstr "Reconhecer Atuação Excessiva" -#: system/ui/widgets/network.py:74 system/ui/widgets/network.py:95 -#, python-format +#: system/ui/widgets/network.py msgid "Advanced" msgstr "Avançado" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Aggressive" msgstr "Agressivo" -#: selfdrive/ui/layouts/onboarding.py:116 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Agree" msgstr "Concordo" -#: selfdrive/ui/layouts/settings/toggles.py:70 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Always-On Driver Monitoring" msgstr "Monitoramento de Motorista Sempre Ativo" -#: selfdrive/ui/layouts/settings/toggles.py:186 -#, python-format -msgid "" -"An alpha version of openpilot longitudinal control can be tested, along with " -"Experimental mode, on non-release branches." -msgstr "" -"Uma versão alpha do controle longitudinal do openpilot pode ser testada, " -"junto com o Modo Experimental, em ramificações fora de release." - -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to power off?" msgstr "Tem certeza de que deseja desligar?" -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reboot?" msgstr "Tem certeza de que deseja reiniciar?" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reset calibration?" msgstr "Tem certeza de que deseja redefinir a calibração?" -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Are you sure you want to uninstall?" msgstr "Tem certeza de que deseja desinstalar?" -#: system/ui/widgets/network.py:99 selfdrive/ui/layouts/onboarding.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +#: system/ui/widgets/network.py msgid "Back" msgstr "Voltar" -#: selfdrive/ui/widgets/prime.py:38 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Become a comma prime member at connect.comma.ai" msgstr "Torne-se membro comma prime em connect.comma.ai" -#: selfdrive/ui/widgets/pairing_dialog.py:130 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Bookmark connect.comma.ai to your home screen to use it like an app" msgstr "Adicione connect.comma.ai à tela inicial para usá-lo como um app" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "CHANGE" msgstr "ALTERAR" -#: selfdrive/ui/layouts/settings/software.py:50 -#: selfdrive/ui/layouts/settings/software.py:107 -#: selfdrive/ui/layouts/settings/software.py:118 -#: selfdrive/ui/layouts/settings/software.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "CHECK" msgstr "VERIFICAR" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "CHILL MODE ON" msgstr "MODO CHILL ATIVO" -#: system/ui/widgets/network.py:155 selfdrive/ui/layouts/sidebar.py:73 -#: selfdrive/ui/layouts/sidebar.py:134 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:138 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/network.py msgid "CONNECT" msgstr "CONECTAR" -#: system/ui/widgets/network.py:369 -#, python-format +#: system/ui/widgets/network.py msgid "CONNECTING..." msgstr "CONECTANDO..." -#: system/ui/widgets/confirm_dialog.py:23 -#: system/ui/widgets/option_dialog.py:35 system/ui/widgets/keyboard.py:81 -#: system/ui/widgets/network.py:318 -#, python-format +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/keyboard.py +#: system/ui/widgets/network.py +#: system/ui/widgets/option_dialog.py msgid "Cancel" msgstr "Cancelar" -#: system/ui/widgets/network.py:134 -#, python-format +#: system/ui/widgets/network.py msgid "Cellular Metered" msgstr "Dados móveis limitados" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Change Language" msgstr "Alterar Idioma" -#: selfdrive/ui/layouts/settings/toggles.py:125 -#, python-format -msgid "Changing this setting will restart openpilot if the car is powered on." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Changing this setting will restart sunnypilot if the car is powered on." msgstr "" -"Alterar esta configuração reiniciará o openpilot se o carro estiver ligado." -#: selfdrive/ui/widgets/pairing_dialog.py:129 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" msgstr "Toque em \"adicionar novo dispositivo\" e escaneie o QR code à direita" -#: selfdrive/ui/widgets/offroad_alerts.py:104 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Close" msgstr "Fechar" -#: selfdrive/ui/layouts/settings/software.py:49 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Current Version" msgstr "Versão Atual" -#: selfdrive/ui/layouts/settings/software.py:110 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "DOWNLOAD" msgstr "BAIXAR" -#: selfdrive/ui/layouts/onboarding.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Decline" msgstr "Recusar" -#: selfdrive/ui/layouts/onboarding.py:148 -#, python-format -msgid "Decline, uninstall openpilot" -msgstr "Recusar, desinstalar o openpilot" +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline, uninstall sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/settings.py:67 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" msgstr "Desenvolv" -#: selfdrive/ui/layouts/settings/settings.py:62 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Device" msgstr "Dispositivo" -#: selfdrive/ui/layouts/settings/toggles.py:58 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Disengage on Accelerator Pedal" msgstr "Desativar ao pressionar o acelerador" -#: selfdrive/ui/layouts/settings/device.py:184 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Power Off" msgstr "Desativar para Desligar" -#: selfdrive/ui/layouts/settings/device.py:172 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reboot" msgstr "Desativar para Reiniciar" -#: selfdrive/ui/layouts/settings/device.py:103 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reset Calibration" msgstr "Desativar para Redefinir Calibração" -#: selfdrive/ui/layouts/settings/toggles.py:32 +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Display speed in km/h instead of mph." msgstr "Exibir velocidade em km/h em vez de mph." -#: selfdrive/ui/layouts/settings/device.py:59 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Dongle ID" msgstr "ID do Dongle" -#: selfdrive/ui/layouts/settings/software.py:50 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Download" msgstr "Baixar" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Driver Camera" msgstr "Câmera do Motorista" -#: selfdrive/ui/layouts/settings/toggles.py:96 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Driving Personality" msgstr "Personalidade" -#: system/ui/widgets/network.py:123 system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "EDIT" msgstr "EDITAR" -#: selfdrive/ui/layouts/sidebar.py:138 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ERROR" msgstr "ERRO" -#: selfdrive/ui/layouts/sidebar.py:45 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ETH" msgstr "ETH" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "EXPERIMENTAL MODE ON" msgstr "MODO EXPERIMENTAL ATIVO" -#: selfdrive/ui/layouts/settings/developer.py:166 -#: selfdrive/ui/layouts/settings/toggles.py:228 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Ativar" -#: selfdrive/ui/layouts/settings/developer.py:39 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable ADB" msgstr "Ativar ADB" -#: selfdrive/ui/layouts/settings/toggles.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable Lane Departure Warnings" msgstr "Ativar alertas de saída de faixa" -#: system/ui/widgets/network.py:129 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Roaming" -msgstr "Ativar openpilot" +msgstr "Ativar roaming" -#: selfdrive/ui/layouts/settings/developer.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable SSH" msgstr "Ativar SSH" -#: system/ui/widgets/network.py:120 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Tethering" -msgstr "Ativar alertas de saída de faixa" +msgstr "Ativar compartilhamento" -#: selfdrive/ui/layouts/settings/toggles.py:30 -msgid "Enable driver monitoring even when openpilot is not engaged." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable driver monitoring even when sunnypilot is not engaged." msgstr "" -"Ativar monitoramento do motorista mesmo quando o openpilot não está engajado." -#: selfdrive/ui/layouts/settings/toggles.py:46 -#, python-format -msgid "Enable openpilot" -msgstr "Ativar openpilot" - -#: selfdrive/ui/layouts/settings/toggles.py:189 -#, python-format -msgid "" -"Enable the openpilot longitudinal control (alpha) toggle to allow " -"Experimental mode." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable sunnypilot" msgstr "" -"Ative a opção de controle longitudinal do openpilot (alpha) para permitir o " -"Modo Experimental." -#: system/ui/widgets/network.py:204 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" + +#: system/ui/widgets/network.py msgid "Enter APN" msgstr "Digite APN" -#: system/ui/widgets/network.py:241 -#, python-format +#: system/ui/widgets/network.py msgid "Enter SSID" msgstr "Digite SSID" -#: system/ui/widgets/network.py:254 -#, python-format +#: system/ui/widgets/network.py msgid "Enter new tethering password" msgstr "Digite nova senha tethering" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Enter password" msgstr "Digite a senha" -#: selfdrive/ui/widgets/ssh_key.py:89 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "Digite seu nome de usuário do GitHub" -#: system/ui/widgets/list_view.py:123 system/ui/widgets/list_view.py:160 -#, python-format +#: system/ui/widgets/list_view.py msgid "Error" msgstr "Erro" -#: selfdrive/ui/layouts/settings/toggles.py:52 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Experimental Mode" msgstr "Modo Experimental" -#: selfdrive/ui/layouts/settings/toggles.py:181 -#, python-format -msgid "" -"Experimental mode is currently unavailable on this car since the car's stock " -"ACC is used for longitudinal control." -msgstr "" -"O Modo Experimental está indisponível neste carro pois o ACC original do " -"carro é usado para controle longitudinal." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." +msgstr "O Modo Experimental está indisponível neste carro pois o ACC original do carro é usado para controle longitudinal." -#: system/ui/widgets/network.py:373 -#, python-format +#: system/ui/widgets/network.py msgid "FORGETTING..." msgstr "ESQUECENDO..." -#: selfdrive/ui/widgets/setup.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Finish Setup" msgstr "Configure" -#: selfdrive/ui/layouts/settings/settings.py:66 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Firehose" -msgstr "Firehose" +msgstr "Fluxo contínuo" -#: selfdrive/ui/layouts/settings/firehose.py:18 +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "Firehose Mode" msgstr "Modo Firehose" -#: selfdrive/ui/layouts/settings/firehose.py:25 -msgid "" -"For maximum effectiveness, bring your device inside and connect to a good " -"USB-C adapter and Wi-Fi weekly.\n" -"\n" -"Firehose Mode can also work while you're driving if connected to a hotspot " -"or unlimited SIM card.\n" -"\n" -"\n" -"Frequently Asked Questions\n" -"\n" -"Does it matter how or where I drive? Nope, just drive as you normally " -"would.\n" -"\n" -"Do all of my segments get pulled in Firehose Mode? No, we selectively pull a " -"subset of your segments.\n" -"\n" -"What's a good USB-C adapter? Any fast phone or laptop charger should be " -"fine.\n" -"\n" -"Does it matter which software I run? Yes, only upstream openpilot (and " -"particular forks) are able to be used for training." +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" msgstr "" -"Para máxima efetividade, leve seu dispositivo para dentro e conecte a um bom " -"adaptador USB-C e Wi‑Fi semanalmente.\n" -"\n" -"O Modo Firehose também pode funcionar enquanto você dirige se estiver " -"conectado a um hotspot ou a um SIM ilimitado.\n" -"\n" -"\n" -"Perguntas Frequentes\n" -"\n" -"Importa como ou onde eu dirijo? Não, apenas dirija como normalmente.\n" -"\n" -"Todos os meus segmentos são puxados no Modo Firehose? Não, puxamos " -"seletivamente um subconjunto dos seus segmentos.\n" -"\n" -"Qual é um bom adaptador USB‑C? Qualquer carregador rápido de telefone ou " -"laptop serve.\n" -"\n" -"Importa qual software eu executo? Sim, apenas o openpilot upstream (e forks " -"específicos) podem ser usados para treinamento." -#: system/ui/widgets/network.py:318 system/ui/widgets/network.py:451 -#, python-format +#: system/ui/widgets/network.py msgid "Forget" msgstr "Esquecer" -#: system/ui/widgets/network.py:319 -#, python-format +#: system/ui/widgets/network.py msgid "Forget Wi-Fi Network \"{}\"?" msgstr "Esquecer rede Wi-Fi \"{}\"?" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "GOOD" msgstr "BOM" -#: selfdrive/ui/widgets/pairing_dialog.py:128 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Go to https://connect.comma.ai on your phone" msgstr "Acesse https://connect.comma.ai no seu telefone" -#: selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "HIGH" msgstr "ALTO" -#: system/ui/widgets/network.py:155 -#, python-format +#: system/ui/widgets/network.py msgid "Hidden Network" -msgstr "Rede" +msgstr "Rede oculta" -#: selfdrive/ui/layouts/settings/firehose.py:140 -#, python-format -msgid "INACTIVE: connect to an unmetered network" -msgstr "INATIVO: conecte a uma rede sem franquia" - -#: selfdrive/ui/layouts/settings/software.py:53 -#: selfdrive/ui/layouts/settings/software.py:136 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "INSTALL" msgstr "INSTALAR" -#: system/ui/widgets/network.py:150 -#, python-format +#: system/ui/widgets/network.py msgid "IP Address" msgstr "Endereço IP" -#: selfdrive/ui/layouts/settings/software.py:53 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Instalar Atualização" -#: selfdrive/ui/layouts/settings/developer.py:56 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "Modo de Depuração do Joystick" -#: selfdrive/ui/widgets/ssh_key.py:29 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "LOADING" msgstr "CARREGANDO" -#: selfdrive/ui/layouts/sidebar.py:48 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "LTE" msgstr "LTE" -#: selfdrive/ui/layouts/settings/developer.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "Modo de Manobra Longitudinal" -#: selfdrive/ui/onroad/hud_renderer.py:148 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "MAX" msgstr "MÁX" -#: selfdrive/ui/widgets/setup.py:75 -#, python-format -msgid "" -"Maximize your training data uploads to improve openpilot's driving models." -msgstr "" -"Maximize seus envios de dados de treinamento para melhorar os modelos de " -"condução do openpilot." +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Maximize your training data uploads to improve openpilot's driving models." +msgstr "Maximize seus envios de dados de treinamento para melhorar os modelos de condução do openpilot." -#: selfdrive/ui/layouts/settings/device.py:59 -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "N/A" -msgstr "N/A" +msgstr "Indisp." -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "NO" msgstr "NÃO" -#: selfdrive/ui/layouts/settings/settings.py:63 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Network" msgstr "Rede" -#: selfdrive/ui/widgets/ssh_key.py:114 -#, python-format -msgid "No SSH keys found" -msgstr "Nenhuma chave SSH encontrada" - -#: selfdrive/ui/widgets/ssh_key.py:126 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "No SSH keys found for user '{}'" -msgstr "Nenhuma chave SSH encontrada para o usuário '{username}'" +msgstr "Nenhuma chave SSH encontrada para o usuário '{}'" -#: selfdrive/ui/widgets/offroad_alerts.py:320 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "No release notes available." msgstr "Sem notas de versão disponíveis." -#: selfdrive/ui/layouts/sidebar.py:73 selfdrive/ui/layouts/sidebar.py:134 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "OFFLINE" -msgstr "OFFLINE" +msgstr "OFF-LINE" -#: system/ui/widgets/html_render.py:263 system/ui/widgets/confirm_dialog.py:93 -#: selfdrive/ui/layouts/sidebar.py:127 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/html_render.py msgid "OK" msgstr "OK" -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ONLINE" -msgstr "ONLINE" +msgstr "ON-LINE" -#: selfdrive/ui/widgets/setup.py:20 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Open" msgstr "Abrir" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PAIR" msgstr "EMPARELHAR" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "PANDA" msgstr "PANDA" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PREVIEW" msgstr "PRÉVIA" -#: selfdrive/ui/widgets/prime.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "PRIME FEATURES:" msgstr "RECURSOS PRIME:" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Pair Device" msgstr "Emparelhar Dispositivo" -#: selfdrive/ui/widgets/setup.py:19 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Pair device" msgstr "Emparelhar dispositivo" -#: selfdrive/ui/widgets/pairing_dialog.py:103 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "Emparelhe seu dispositivo à sua conta comma" -#: selfdrive/ui/widgets/setup.py:48 selfdrive/ui/layouts/settings/device.py:24 -#, python-format -msgid "" -"Pair your device with comma connect (connect.comma.ai) and claim your comma " -"prime offer." -msgstr "" -"Emparelhe seu dispositivo com o comma connect (connect.comma.ai) e resgate " -"sua oferta comma prime." +#: openpilot/selfdrive/ui/layouts/settings/device.py +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." +msgstr "Emparelhe seu dispositivo com o comma connect (connect.comma.ai) e resgate sua oferta comma prime." -#: selfdrive/ui/widgets/setup.py:91 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Please connect to Wi-Fi to complete initial pairing" msgstr "Conecte-se ao Wi‑Fi para concluir o emparelhamento inicial" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Power Off" msgstr "Desligar" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered Wi-Fi connection" msgstr "Evitar uploads grandes de dados em conexões Wi-Fi limitadas" -#: system/ui/widgets/network.py:135 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered cellular connection" msgstr "Evitar uploads grandes de dados em conexões móveis limitadas" -#: selfdrive/ui/layouts/settings/device.py:25 -msgid "" -"Preview the driver facing camera to ensure that driver monitoring has good " -"visibility. (vehicle must be off)" -msgstr "" -"Pré-visualize a câmera voltada para o motorista para garantir que o " -"monitoramento do motorista tenha boa visibilidade. (veículo deve estar " -"desligado)" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)" +msgstr "Pré-visualize a câmera voltada para o motorista para garantir que o monitoramento do motorista tenha boa visibilidade. (veículo deve estar desligado)" -#: selfdrive/ui/widgets/pairing_dialog.py:161 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "QR Code Error" msgstr "Erro no QR Code" -#: selfdrive/ui/widgets/ssh_key.py:31 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "REMOVE" msgstr "REMOVER" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "RESET" msgstr "REDEFINIR" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "REVIEW" msgstr "REVISAR" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "Reiniciar" -#: selfdrive/ui/onroad/alert_renderer.py:66 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Reboot Device" msgstr "Reiniciar Dispositivo" -#: selfdrive/ui/widgets/offroad_alerts.py:112 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Reboot and Update" msgstr "Reiniciar e Atualizar" -#: selfdrive/ui/layouts/settings/toggles.py:27 -msgid "" -"Receive alerts to steer back into the lane when your vehicle drifts over a " -"detected lane line without a turn signal activated while driving over 31 mph " -"(50 km/h)." -msgstr "" -"Receba alertas para voltar à faixa quando seu veículo cruzar uma linha de " -"faixa detectada sem seta ativada ao dirigir acima de 31 mph (50 km/h)." - -#: selfdrive/ui/layouts/settings/toggles.py:76 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Driver Camera" msgstr "Gravar e Enviar Câmera do Motorista" -#: selfdrive/ui/layouts/settings/toggles.py:82 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Microphone Audio" msgstr "Gravar e Enviar Áudio do Microfone" -#: selfdrive/ui/layouts/settings/toggles.py:33 -msgid "" -"Record and store microphone audio while driving. The audio will be included " -"in the dashcam video in comma connect." -msgstr "" -"Grave e armazene o áudio do microfone enquanto dirige. O áudio será incluído " -"no vídeo da dashcam no comma connect." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect." +msgstr "Grave e armazene o áudio do microfone enquanto dirige. O áudio será incluído no vídeo da dashcam no comma connect." -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Regulatory" msgstr "Regulatório" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Relaxed" msgstr "Relaxado" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote access" msgstr "Acesso remoto" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote snapshots" msgstr "Capturas remotas" -#: selfdrive/ui/widgets/ssh_key.py:123 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Request timed out" msgstr "Tempo da solicitação esgotado" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset" msgstr "Redefinir" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset Calibration" msgstr "Redefinir Calibração" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Review Training Guide" msgstr "Revisar Guia de Treinamento" -#: selfdrive/ui/layouts/settings/device.py:27 -msgid "Review the rules, features, and limitations of openpilot" -msgstr "Revise as regras, recursos e limitações do openpilot" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" msgstr "SELECIONAR" -#: selfdrive/ui/layouts/settings/developer.py:53 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "SSH Keys" msgstr "Chaves SSH" -#: system/ui/widgets/network.py:310 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + +#: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "Procurando redes Wi-Fi..." -#: system/ui/widgets/option_dialog.py:36 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + +#: system/ui/widgets/option_dialog.py msgid "Select" msgstr "Selecione" -#: selfdrive/ui/layouts/settings/software.py:183 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Select a branch" -msgstr "Selecione uma branch" +msgstr "Selecione uma ramificação" -#: selfdrive/ui/layouts/settings/device.py:91 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Select a language" msgstr "Selecione um idioma" -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Serial" -msgstr "Serial" +msgstr "Número de série" -#: selfdrive/ui/widgets/offroad_alerts.py:106 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Snooze Update" msgstr "Adiar Atualização" -#: selfdrive/ui/layouts/settings/settings.py:65 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Software" -msgstr "Software" +msgstr "Sistema" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Standard" msgstr "Padrão" -#: selfdrive/ui/layouts/settings/toggles.py:22 -msgid "" -"Standard is recommended. In aggressive mode, openpilot will follow lead cars " -"closer and be more aggressive with the gas and brake. In relaxed mode " -"openpilot will stay further away from lead cars. On supported cars, you can " -"cycle through these personalities with your steering wheel distance button." -msgstr "" -"Padrão é recomendado. No modo agressivo, o openpilot seguirá veículos à " -"frente mais de perto e será mais agressivo com acelerador e freio. No modo " -"relaxado, o openpilot ficará mais longe dos veículos à frente. Em carros " -"compatíveis, você pode alternar essas personalidades com o botão de " -"distância do volante." - -#: selfdrive/ui/onroad/alert_renderer.py:59 -#: selfdrive/ui/onroad/alert_renderer.py:65 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "System Unresponsive" msgstr "Sistema sem resposta" -#: selfdrive/ui/onroad/alert_renderer.py:58 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "TAKE CONTROL IMMEDIATELY" msgstr "ASSUMA O CONTROLE IMEDIATAMENTE" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 -#: selfdrive/ui/layouts/sidebar.py:127 selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "TEMP" -msgstr "TEMP" +msgstr "TEMP." -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Target Branch" msgstr "Branch Alvo" -#: system/ui/widgets/network.py:124 -#, python-format +#: system/ui/widgets/network.py msgid "Tethering Password" msgstr "Senha Tethering" -#: selfdrive/ui/layouts/settings/settings.py:64 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Toggles" -msgstr "Toggles" +msgstr "Alternativas" -#: selfdrive/ui/layouts/settings/software.py:72 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "UI Debug Mode" +msgstr "Modo de depuração da interface do usuário" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "UNINSTALL" msgstr "DESINSTALAR" -#: selfdrive/ui/layouts/home.py:155 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "UPDATE" msgstr "ATUALIZAR" -#: selfdrive/ui/layouts/settings/software.py:72 -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Uninstall" msgstr "Desinstalar" -#: selfdrive/ui/layouts/sidebar.py:117 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Unknown" msgstr "Desconhecido" -#: selfdrive/ui/layouts/settings/software.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Updates are only downloaded while the car is off." msgstr "Atualizações são baixadas apenas com o carro desligado." -#: selfdrive/ui/widgets/prime.py:33 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Upgrade Now" msgstr "Atualizar Agora" -#: selfdrive/ui/layouts/settings/toggles.py:31 -msgid "" -"Upload data from the driver facing camera and help improve the driver " -"monitoring algorithm." -msgstr "" -"Envie dados da câmera voltada para o motorista e ajude a melhorar o " -"algoritmo de monitoramento do motorista." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." +msgstr "Envie dados da câmera voltada para o motorista e ajude a melhorar o algoritmo de monitoramento do motorista." -#: selfdrive/ui/layouts/settings/toggles.py:88 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Use Metric System" msgstr "Usar Sistema Métrico" -#: selfdrive/ui/layouts/settings/toggles.py:17 -msgid "" -"Use the openpilot system for adaptive cruise control and lane keep driver " -"assistance. Your attention is required at all times to use this feature." -msgstr "" -"Use o sistema openpilot para controle de cruzeiro adaptativo e assistência " -"de permanência em faixa. Sua atenção é necessária o tempo todo para usar " -"este recurso." - -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "VEHICLE" msgstr "VEÍCULO" -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "VIEW" msgstr "VER" -#: selfdrive/ui/onroad/alert_renderer.py:52 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Waiting to start" msgstr "Aguardando para iniciar" -#: selfdrive/ui/layouts/settings/developer.py:19 -msgid "" -"Warning: This grants SSH access to all public keys in your GitHub settings. " -"Never enter a GitHub username other than your own. A comma employee will " -"NEVER ask you to add their GitHub username." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Welcome to sunnypilot" msgstr "" -"Aviso: Isso concede acesso SSH a todas as chaves públicas nas suas " -"configurações do GitHub. Nunca informe um nome de usuário do GitHub que não " -"seja o seu. Um funcionário da comma NUNCA pedirá para você adicionar o nome " -"de usuário do GitHub dele." -#: selfdrive/ui/layouts/onboarding.py:111 -#, python-format -msgid "Welcome to openpilot" -msgstr "Bem-vindo ao openpilot" - -#: selfdrive/ui/layouts/settings/toggles.py:20 -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." msgstr "" -"Quando ativado, pressionar o pedal do acelerador desengajará o openpilot." -#: selfdrive/ui/layouts/sidebar.py:44 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" msgstr "Wi‑Fi" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Wi-Fi Network Metered" msgstr "Rede Wi-Fi limitada" -#: system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Wrong password" msgstr "Senha errada" -#: selfdrive/ui/layouts/onboarding.py:145 -#, python-format -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "Você deve aceitar os Termos e Condições para usar o openpilot." - -#: selfdrive/ui/layouts/onboarding.py:112 -#, python-format -msgid "" -"You must accept the Terms and Conditions to use openpilot. Read the latest " -"terms at https://comma.ai/terms before continuing." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service in order to use sunnypilot." msgstr "" -"Você deve aceitar os Termos e Condições para usar o openpilot. Leia os " -"termos mais recentes em https://comma.ai/terms antes de continuar." -#: selfdrive/ui/onroad/driver_camera_dialog.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" + +#: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" msgstr "câmera iniciando" -#: selfdrive/ui/widgets/prime.py:63 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "checking..." +msgstr "verificando..." + +#: openpilot/selfdrive/ui/widgets/prime.py msgid "comma prime" msgstr "comma prime" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "default" -msgstr "default" +msgstr "padrão" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "down" msgstr "para baixo" -#: selfdrive/ui/layouts/settings/software.py:106 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "downloading..." +msgstr "baixando..." + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "failed to check for update" msgstr "falha ao verificar atualização" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "finalizing update..." +msgstr "finalizando atualização..." + +#: system/ui/widgets/network.py msgid "for \"{}\"" msgstr "para \"{}\"" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "leave blank for automatic configuration" msgstr "deixe em branco para configuração automática" -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "left" msgstr "à esquerda" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "metered" msgstr "limitados" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" -#: selfdrive/ui/layouts/settings/software.py:20 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "never" msgstr "nunca" -#: selfdrive/ui/layouts/settings/software.py:31 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "now" msgstr "agora" -#: selfdrive/ui/layouts/settings/developer.py:71 -#, python-format -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "Controle Longitudinal do openpilot (Alpha)" - -#: selfdrive/ui/onroad/alert_renderer.py:51 -#, python-format -msgid "openpilot Unavailable" -msgstr "openpilot Indisponível" - -#: selfdrive/ui/layouts/settings/toggles.py:158 -#, python-format -msgid "" -"openpilot defaults to driving in chill mode. Experimental mode enables " -"alpha-level features that aren't ready for chill mode. Experimental features " -"are listed below:

End-to-End Longitudinal Control


Let the " -"driving model control the gas and brakes. openpilot will drive as it thinks " -"a human would, including stopping for red lights and stop signs. Since the " -"driving model decides the speed to drive, the set speed will only act as an " -"upper bound. This is an alpha quality feature; mistakes should be " -"expected.

New Driving Visualization


The driving visualization " -"will transition to the road-facing wide-angle camera at low speeds to better " -"show some turns. The Experimental mode logo will also be shown in the top " -"right corner." -msgstr "" -"o openpilot dirige por padrão no modo chill. O Modo Experimental habilita " -"recursos em nível alpha que não estão prontos para o modo chill. Os recursos " -"experimentais são listados abaixo:

Controle Longitudinal " -"End-to-End


Permita que o modelo de condução controle o acelerador e " -"os freios. O openpilot dirigirá como acha que um humano faria, incluindo " -"parar em sinais e semáforos vermelhos. Como o modelo decide a velocidade, a " -"velocidade definida atuará apenas como limite superior. Este é um recurso de " -"qualidade alpha; erros devem ser esperados.

Nova Visualização de " -"Condução


A visualização de condução mudará para a câmera " -"grande-angular voltada para a estrada em baixas velocidades para mostrar " -"melhor algumas curvas. O logotipo do Modo Experimental também será exibido " -"no canto superior direito." - -#: selfdrive/ui/layouts/settings/device.py:165 -#, python-format -msgid "" -"openpilot is continuously calibrating, resetting is rarely required. " -"Resetting calibration will restart openpilot if the car is powered on." -msgstr "" -"O openpilot está continuamente calibrando, resetar é raramente solicitado. " -"Alterar esta configuração reiniciará o openpilot se o carro estiver ligado." - -#: selfdrive/ui/layouts/settings/firehose.py:20 -msgid "" -"openpilot learns to drive by watching humans, like you, drive.\n" -"\n" -"Firehose Mode allows you to maximize your training data uploads to improve " -"openpilot's driving models. More data means bigger models, which means " -"better Experimental Mode." -msgstr "" -"o openpilot aprende a dirigir observando humanos, como você, dirigirem.\n" -"\n" -"O Modo Firehose permite maximizar seus envios de dados de treinamento para " -"melhorar os modelos de condução do openpilot. Mais dados significam modelos " -"maiores, o que significa um Modo Experimental melhor." - -#: selfdrive/ui/layouts/settings/toggles.py:183 -#, python-format -msgid "openpilot longitudinal control may come in a future update." -msgstr "" -"o controle longitudinal do openpilot pode vir em uma atualização futura." - -#: selfdrive/ui/layouts/settings/device.py:26 -msgid "" -"openpilot requires the device to be mounted within 4° left or right and " -"within 5° up or 9° down." -msgstr "" -"o openpilot requer que o dispositivo seja montado dentro de 4° para a " -"esquerda ou direita e dentro de 5° para cima ou 9° para baixo." - -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "à direita" -#: system/ui/widgets/network.py:142 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + +#: system/ui/widgets/network.py msgid "unmetered" msgstr "ilimitados" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "up" msgstr "para cima" -#: selfdrive/ui/layouts/settings/software.py:117 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked never" msgstr "atualizado, última verificação: nunca" -#: selfdrive/ui/layouts/settings/software.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked {}" msgstr "atualizado, última verificação: {}" -#: selfdrive/ui/layouts/settings/software.py:109 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "update available" msgstr "atualização disponível" -#: selfdrive/ui/layouts/home.py:169 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "{} ALERT" msgid_plural "{} ALERTS" msgstr[0] "{} ALERTA" msgstr[1] "{} ALERTAS" -#: selfdrive/ui/layouts/settings/software.py:40 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "{} dia atrás" msgstr[1] "{} dias atrás" -#: selfdrive/ui/layouts/settings/software.py:37 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "{} hora atrás" msgstr[1] "{} horas atrás" -#: selfdrive/ui/layouts/settings/software.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} minute ago" msgid_plural "{} minutes ago" msgstr[0] "{} minuto atrás" msgstr[1] "{} minutos atrás" -#: selfdrive/ui/layouts/settings/firehose.py:111 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "{} segment of your driving is in the training dataset so far." msgid_plural "{} segments of your driving is in the training dataset so far." -msgstr[0] "" -"{} segmento da sua condução está no conjunto de treinamento até agora." -msgstr[1] "" -"{} segmentos da sua condução estão no conjunto de treinamento até agora." +msgstr[0] "{} segmento da sua condução está no conjunto de treinamento até agora." +msgstr[1] "{} segmentos da sua condução estão no conjunto de treinamento até agora." -#: selfdrive/ui/widgets/prime.py:62 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "✓ SUBSCRIBED" msgstr "✓ ASSINADO" -#: selfdrive/ui/widgets/setup.py:22 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "🔥 Firehose Mode 🔥" msgstr "🔥 Modo Firehose 🔥" + diff --git a/selfdrive/ui/translations/app_th.po b/selfdrive/ui/translations/app_th.po index f2e56f2882..4897809528 100644 --- a/selfdrive/ui/translations/app_th.po +++ b/selfdrive/ui/translations/app_th.po @@ -1,1129 +1,868 @@ -# Thai translations for PACKAGE package. -# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# Automatically generated, 2025. -# msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 00:50-0700\n" -"PO-Revision-Date: 2025-10-22 16:32-0700\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" -"Language: th\n" -"MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" +"Language: th\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: selfdrive/ui/layouts/settings/device.py:160 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is complete." -msgstr "" +msgstr "การสอบเทียบการตอบสนองแรงบิดของพวงมาลัยเสร็จสมบูรณ์" -#: selfdrive/ui/layouts/settings/device.py:158 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is {}% complete." -msgstr "" +msgstr "การสอบเทียบการตอบสนองแรงบิดของพวงมาลัยเสร็จสมบูรณ์ {}%" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Your device is pointed {:.1f}° {} and {:.1f}° {}." -msgstr "" +msgstr "อุปกรณ์ของคุณชี้ไปที่ {:.1f}° {} และ {:.1f}° {}" -#: selfdrive/ui/layouts/sidebar.py:43 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "--" -msgstr "" +msgstr "--" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "1 year of drive storage" -msgstr "" +msgstr "พื้นที่เก็บข้อมูลไดรฟ์ 1 ปี" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "24/7 LTE connectivity" -msgstr "" +msgstr "การเชื่อมต่อ LTE ตลอด 24 ชั่วโมงทุกวัน" -#: selfdrive/ui/layouts/sidebar.py:46 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "2G" -msgstr "" +msgstr "2จี" -#: selfdrive/ui/layouts/sidebar.py:47 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "3G" -msgstr "" +msgstr "3จี" -#: selfdrive/ui/layouts/sidebar.py:49 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "5G" -msgstr "" +msgstr "5จี" -#: selfdrive/ui/layouts/settings/developer.py:23 -msgid "" -"WARNING: openpilot longitudinal control is in alpha for this car and will " -"disable Automatic Emergency Braking (AEB).

On this car, openpilot " -"defaults to the car's built-in ACC instead of openpilot's longitudinal " -"control. Enable this to switch to openpilot longitudinal control. Enabling " -"Experimental mode is recommended when enabling openpilot longitudinal " -"control alpha. Changing this setting will restart openpilot if the car is " -"powered on." -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:148 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is complete." -msgstr "" +msgstr "

การปรับเทียบความล่าช้าของพวงมาลัยเสร็จสมบูรณ์" -#: selfdrive/ui/layouts/settings/device.py:146 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is {}% complete." -msgstr "" +msgstr "

การปรับเทียบความล่าช้าของพวงมาลัย {}% เสร็จสมบูรณ์" -#: selfdrive/ui/layouts/settings/firehose.py:138 -#, python-format -msgid "ACTIVE" -msgstr "" - -#: selfdrive/ui/layouts/settings/developer.py:15 -msgid "" -"ADB (Android Debug Bridge) allows connecting to your device over USB or over " -"the network. See https://docs.comma.ai/how-to/connect-to-comma for more info." -msgstr "" - -#: selfdrive/ui/widgets/ssh_key.py:30 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "ADD" -msgstr "" +msgstr "เพิ่ม" -#: system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "APN Setting" -msgstr "" +msgstr "การตั้งค่า APN" -#: selfdrive/ui/widgets/offroad_alerts.py:109 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Acknowledge Excessive Actuation" -msgstr "" +msgstr "รับทราบการดำเนินการที่มากเกินไป" -#: system/ui/widgets/network.py:74 system/ui/widgets/network.py:95 -#, python-format +#: system/ui/widgets/network.py msgid "Advanced" -msgstr "" +msgstr "ขั้นสูง" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Aggressive" -msgstr "" +msgstr "ก้าวร้าว" -#: selfdrive/ui/layouts/onboarding.py:116 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Agree" -msgstr "" +msgstr "เห็นด้วย" -#: selfdrive/ui/layouts/settings/toggles.py:70 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Always-On Driver Monitoring" -msgstr "" +msgstr "การตรวจสอบไดรเวอร์ตลอดเวลา" -#: selfdrive/ui/layouts/settings/toggles.py:186 -#, python-format -msgid "" -"An alpha version of openpilot longitudinal control can be tested, along with " -"Experimental mode, on non-release branches." -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to power off?" -msgstr "" +msgstr "คุณแน่ใจหรือไม่ว่าต้องการปิดเครื่อง?" -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reboot?" -msgstr "" +msgstr "คุณแน่ใจหรือไม่ว่าต้องการรีบูต?" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reset calibration?" -msgstr "" +msgstr "คุณแน่ใจหรือไม่ว่าต้องการรีเซ็ตการปรับเทียบ" -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Are you sure you want to uninstall?" -msgstr "" +msgstr "คุณแน่ใจหรือไม่ว่าต้องการถอนการติดตั้ง?" -#: system/ui/widgets/network.py:99 selfdrive/ui/layouts/onboarding.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +#: system/ui/widgets/network.py msgid "Back" -msgstr "" +msgstr "กลับ" -#: selfdrive/ui/widgets/prime.py:38 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Become a comma prime member at connect.comma.ai" -msgstr "" +msgstr "เป็นสมาชิกจุลภาคไพรม์ที่ Connect.comma.ai" -#: selfdrive/ui/widgets/pairing_dialog.py:130 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Bookmark connect.comma.ai to your home screen to use it like an app" -msgstr "" +msgstr "คั่นหน้า Connect.comma.ai ไปที่หน้าจอหลักของคุณเพื่อใช้เหมือนแอป" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "CHANGE" -msgstr "" +msgstr "เปลี่ยน" -#: selfdrive/ui/layouts/settings/software.py:50 -#: selfdrive/ui/layouts/settings/software.py:107 -#: selfdrive/ui/layouts/settings/software.py:118 -#: selfdrive/ui/layouts/settings/software.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "CHECK" -msgstr "" +msgstr "ตรวจสอบ" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "CHILL MODE ON" -msgstr "" +msgstr "เปิดโหมด Chill" -#: system/ui/widgets/network.py:155 selfdrive/ui/layouts/sidebar.py:73 -#: selfdrive/ui/layouts/sidebar.py:134 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:138 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/network.py msgid "CONNECT" -msgstr "" +msgstr "เชื่อมต่อ" -#: system/ui/widgets/network.py:369 -#, python-format +#: system/ui/widgets/network.py msgid "CONNECTING..." -msgstr "" +msgstr "กำลังเชื่อมต่อ..." -#: system/ui/widgets/confirm_dialog.py:23 system/ui/widgets/option_dialog.py:35 -#: system/ui/widgets/keyboard.py:81 system/ui/widgets/network.py:318 -#, python-format +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/keyboard.py +#: system/ui/widgets/network.py +#: system/ui/widgets/option_dialog.py msgid "Cancel" -msgstr "" +msgstr "ยกเลิก" -#: system/ui/widgets/network.py:134 -#, python-format +#: system/ui/widgets/network.py msgid "Cellular Metered" -msgstr "" +msgstr "เซลล์วัดแสง" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Change Language" +msgstr "เปลี่ยนภาษา" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Changing this setting will restart sunnypilot if the car is powered on." msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:125 -#, python-format -msgid "Changing this setting will restart openpilot if the car is powered on." +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" msgstr "" -#: selfdrive/ui/widgets/pairing_dialog.py:129 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" -msgstr "" +msgstr "คลิก \"เพิ่มอุปกรณ์ใหม่\" และสแกนโค้ด QR ทางด้านขวา" -#: selfdrive/ui/widgets/offroad_alerts.py:104 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Close" -msgstr "" +msgstr "ปิด" -#: selfdrive/ui/layouts/settings/software.py:49 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Current Version" -msgstr "" +msgstr "เวอร์ชันปัจจุบัน" -#: selfdrive/ui/layouts/settings/software.py:110 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "DOWNLOAD" -msgstr "" +msgstr "ดาวน์โหลด" -#: selfdrive/ui/layouts/onboarding.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Decline" +msgstr "ปฏิเสธ" + +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline, uninstall sunnypilot" msgstr "" -#: selfdrive/ui/layouts/onboarding.py:148 -#, python-format -msgid "Decline, uninstall openpilot" -msgstr "" - -#: selfdrive/ui/layouts/settings/settings.py:67 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" -msgstr "" +msgstr "นักพัฒนา" -#: selfdrive/ui/layouts/settings/settings.py:62 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Device" -msgstr "" +msgstr "อุปกรณ์" -#: selfdrive/ui/layouts/settings/toggles.py:58 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Disengage on Accelerator Pedal" -msgstr "" +msgstr "ปลดเมื่อเหยียบคันเร่ง" -#: selfdrive/ui/layouts/settings/device.py:184 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Power Off" -msgstr "" +msgstr "ปลดเพื่อปิดเครื่อง" -#: selfdrive/ui/layouts/settings/device.py:172 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reboot" -msgstr "" +msgstr "ยกเลิกการเชื่อมต่อเพื่อรีบูต" -#: selfdrive/ui/layouts/settings/device.py:103 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reset Calibration" -msgstr "" +msgstr "ปลดเพื่อรีเซ็ตการปรับเทียบ" -#: selfdrive/ui/layouts/settings/toggles.py:32 +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Display speed in km/h instead of mph." -msgstr "" +msgstr "แสดงความเร็วเป็น km/h แทน mph" -#: selfdrive/ui/layouts/settings/device.py:59 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Dongle ID" -msgstr "" +msgstr "รหัสดองเกิล" -#: selfdrive/ui/layouts/settings/software.py:50 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Download" -msgstr "" +msgstr "ดาวน์โหลด" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Driver Camera" -msgstr "" +msgstr "กล้องไดร์เวอร์" -#: selfdrive/ui/layouts/settings/toggles.py:96 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Driving Personality" -msgstr "" +msgstr "บุคลิกภาพในการขับขี่" -#: system/ui/widgets/network.py:123 system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "EDIT" -msgstr "" +msgstr "แก้ไข" -#: selfdrive/ui/layouts/sidebar.py:138 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ERROR" -msgstr "" +msgstr "ข้อผิดพลาด" -#: selfdrive/ui/layouts/sidebar.py:45 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ETH" -msgstr "" +msgstr "ผลประโยชน์ทับซ้อน" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "EXPERIMENTAL MODE ON" +msgstr "โหมดทดลองเปิดอยู่" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" msgstr "" -#: selfdrive/ui/layouts/settings/developer.py:166 -#: selfdrive/ui/layouts/settings/toggles.py:228 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" -msgstr "" +msgstr "เปิดใช้งาน" -#: selfdrive/ui/layouts/settings/developer.py:39 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable ADB" -msgstr "" +msgstr "เปิดใช้งาน ADB" -#: selfdrive/ui/layouts/settings/toggles.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable Lane Departure Warnings" -msgstr "" +msgstr "เปิดใช้งานคำเตือนการออกนอกเลน" -#: system/ui/widgets/network.py:129 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Roaming" -msgstr "" +msgstr "เปิดใช้งานโรมมิ่ง" -#: selfdrive/ui/layouts/settings/developer.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable SSH" -msgstr "" +msgstr "เปิดใช้งาน SSH" -#: system/ui/widgets/network.py:120 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Tethering" +msgstr "เปิดใช้งานการปล่อยสัญญาณ" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable driver monitoring even when sunnypilot is not engaged." msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:30 -msgid "Enable driver monitoring even when openpilot is not engaged." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable sunnypilot" msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:46 -#, python-format -msgid "Enable openpilot" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:189 -#, python-format -msgid "" -"Enable the openpilot longitudinal control (alpha) toggle to allow " -"Experimental mode." -msgstr "" - -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "Enter APN" -msgstr "" +msgstr "ป้อน APN" -#: system/ui/widgets/network.py:241 -#, python-format +#: system/ui/widgets/network.py msgid "Enter SSID" -msgstr "" +msgstr "ป้อน SSID" -#: system/ui/widgets/network.py:254 -#, python-format +#: system/ui/widgets/network.py msgid "Enter new tethering password" -msgstr "" +msgstr "ป้อนรหัสผ่านการปล่อยสัญญาณใหม่" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Enter password" +msgstr "ใส่รหัสผ่าน" + +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" msgstr "" -#: selfdrive/ui/widgets/ssh_key.py:89 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" -msgstr "" +msgstr "ป้อนชื่อผู้ใช้ GitHub ของคุณ" -#: system/ui/widgets/list_view.py:123 system/ui/widgets/list_view.py:160 -#, python-format +#: system/ui/widgets/list_view.py msgid "Error" -msgstr "" +msgstr "ข้อผิดพลาด" -#: selfdrive/ui/layouts/settings/toggles.py:52 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Experimental Mode" -msgstr "" +msgstr "โหมดทดลอง" -#: selfdrive/ui/layouts/settings/toggles.py:181 -#, python-format -msgid "" -"Experimental mode is currently unavailable on this car since the car's stock " -"ACC is used for longitudinal control." -msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." +msgstr "ขณะนี้โหมดทดลองไม่สามารถใช้งานได้บนรถคันนี้ เนื่องจาก ACC ในสต็อกของรถใช้สำหรับการควบคุมตามยาว" -#: system/ui/widgets/network.py:373 -#, python-format +#: system/ui/widgets/network.py msgid "FORGETTING..." -msgstr "" +msgstr "กำลังลืม..." -#: selfdrive/ui/widgets/setup.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Finish Setup" -msgstr "" +msgstr "เสร็จสิ้นการตั้งค่า" -#: selfdrive/ui/layouts/settings/settings.py:66 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Firehose" -msgstr "" +msgstr "สายดับเพลิง" -#: selfdrive/ui/layouts/settings/firehose.py:18 +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "Firehose Mode" +msgstr "โหมดสายดับเพลิง" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" msgstr "" -#: selfdrive/ui/layouts/settings/firehose.py:25 -msgid "" -"For maximum effectiveness, bring your device inside and connect to a good " -"USB-C adapter and Wi-Fi weekly.\n" -"\n" -"Firehose Mode can also work while you're driving if connected to a hotspot " -"or unlimited SIM card.\n" -"\n" -"\n" -"Frequently Asked Questions\n" -"\n" -"Does it matter how or where I drive? Nope, just drive as you normally " -"would.\n" -"\n" -"Do all of my segments get pulled in Firehose Mode? No, we selectively pull a " -"subset of your segments.\n" -"\n" -"What's a good USB-C adapter? Any fast phone or laptop charger should be " -"fine.\n" -"\n" -"Does it matter which software I run? Yes, only upstream openpilot (and " -"particular forks) are able to be used for training." -msgstr "" - -#: system/ui/widgets/network.py:318 system/ui/widgets/network.py:451 -#, python-format +#: system/ui/widgets/network.py msgid "Forget" -msgstr "" +msgstr "ลืม" -#: system/ui/widgets/network.py:319 -#, python-format +#: system/ui/widgets/network.py msgid "Forget Wi-Fi Network \"{}\"?" -msgstr "" +msgstr "ลืมเครือข่าย Wi-Fi \"{}\" หรือไม่" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "GOOD" -msgstr "" +msgstr "ดี" -#: selfdrive/ui/widgets/pairing_dialog.py:128 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Go to https://connect.comma.ai on your phone" -msgstr "" +msgstr "ไปที่ https://connect.comma.ai บนโทรศัพท์ของคุณ" -#: selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "HIGH" -msgstr "" +msgstr "สูง" -#: system/ui/widgets/network.py:155 -#, python-format +#: system/ui/widgets/network.py msgid "Hidden Network" -msgstr "" +msgstr "เครือข่ายที่ซ่อนอยู่" -#: selfdrive/ui/layouts/settings/firehose.py:140 -#, python-format -msgid "INACTIVE: connect to an unmetered network" -msgstr "" - -#: selfdrive/ui/layouts/settings/software.py:53 -#: selfdrive/ui/layouts/settings/software.py:136 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "INSTALL" -msgstr "" +msgstr "ติดตั้ง" -#: system/ui/widgets/network.py:150 -#, python-format +#: system/ui/widgets/network.py msgid "IP Address" +msgstr "ที่อยู่ IP" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:53 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" +msgstr "ติดตั้งอัปเดต" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" msgstr "" -#: selfdrive/ui/layouts/settings/developer.py:56 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" -msgstr "" +msgstr "โหมดดีบักจอยสติ๊ก" -#: selfdrive/ui/widgets/ssh_key.py:29 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "LOADING" -msgstr "" +msgstr "กำลังโหลด" -#: selfdrive/ui/layouts/sidebar.py:48 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "LTE" +msgstr "แอลทีที" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" msgstr "" -#: selfdrive/ui/layouts/settings/developer.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" -msgstr "" +msgstr "โหมดการซ้อมรบตามยาว" -#: selfdrive/ui/onroad/hud_renderer.py:148 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "MAX" -msgstr "" +msgstr "สูงสุด" -#: selfdrive/ui/widgets/setup.py:75 -#, python-format -msgid "" -"Maximize your training data uploads to improve openpilot's driving models." -msgstr "" +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Maximize your training data uploads to improve openpilot's driving models." +msgstr "เพิ่มการอัปโหลดข้อมูลการฝึกของคุณให้สูงสุดเพื่อปรับปรุงโมเดลการขับขี่ของ Openpilot" -#: selfdrive/ui/layouts/settings/device.py:59 -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "N/A" -msgstr "" +msgstr "ไม่มี" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "NO" -msgstr "" +msgstr "เลขที่" -#: selfdrive/ui/layouts/settings/settings.py:63 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Network" -msgstr "" +msgstr "เครือข่าย" -#: selfdrive/ui/widgets/ssh_key.py:114 -#, python-format -msgid "No SSH keys found" -msgstr "" - -#: selfdrive/ui/widgets/ssh_key.py:126 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "No SSH keys found for user '{}'" -msgstr "" +msgstr "ไม่พบคีย์ SSH สำหรับผู้ใช้ '{}'" -#: selfdrive/ui/widgets/offroad_alerts.py:320 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "No release notes available." -msgstr "" +msgstr "ไม่มีบันทึกประจำรุ่น" -#: selfdrive/ui/layouts/sidebar.py:73 selfdrive/ui/layouts/sidebar.py:134 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "OFFLINE" -msgstr "" +msgstr "ออฟไลน์" -#: system/ui/widgets/html_render.py:263 system/ui/widgets/confirm_dialog.py:93 -#: selfdrive/ui/layouts/sidebar.py:127 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/html_render.py msgid "OK" -msgstr "" +msgstr "ตกลง" -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ONLINE" -msgstr "" +msgstr "ออนไลน์" -#: selfdrive/ui/widgets/setup.py:20 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Open" -msgstr "" +msgstr "เปิด" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PAIR" -msgstr "" +msgstr "คู่" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "PANDA" -msgstr "" +msgstr "แพนด้า" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PREVIEW" -msgstr "" +msgstr "ดูตัวอย่าง" -#: selfdrive/ui/widgets/prime.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "PRIME FEATURES:" -msgstr "" +msgstr "คุณสมบัติเด่น:" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Pair Device" -msgstr "" +msgstr "จับคู่อุปกรณ์" -#: selfdrive/ui/widgets/setup.py:19 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Pair device" +msgstr "จับคู่อุปกรณ์" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" msgstr "" -#: selfdrive/ui/widgets/pairing_dialog.py:103 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" -msgstr "" +msgstr "จับคู่อุปกรณ์ของคุณกับบัญชีลูกน้ำของคุณ" -#: selfdrive/ui/widgets/setup.py:48 selfdrive/ui/layouts/settings/device.py:24 -#, python-format -msgid "" -"Pair your device with comma connect (connect.comma.ai) and claim your comma " -"prime offer." -msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/device.py +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." +msgstr "จับคู่อุปกรณ์ของคุณกับการเชื่อมต่อด้วยเครื่องหมายจุลภาค (connect.comma.ai) และรับข้อเสนอจุลภาคเฉพาะของคุณ" -#: selfdrive/ui/widgets/setup.py:91 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Please connect to Wi-Fi to complete initial pairing" -msgstr "" +msgstr "โปรดเชื่อมต่อ Wi-Fi เพื่อทำการจับคู่ครั้งแรกให้เสร็จสิ้น" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Power Off" -msgstr "" +msgstr "ปิดเครื่อง" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered Wi-Fi connection" -msgstr "" +msgstr "ป้องกันการอัปโหลดข้อมูลขนาดใหญ่เมื่อใช้การเชื่อมต่อ Wi-Fi แบบมิเตอร์" -#: system/ui/widgets/network.py:135 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered cellular connection" -msgstr "" +msgstr "ป้องกันการอัพโหลดข้อมูลขนาดใหญ่เมื่อใช้การเชื่อมต่อมือถือแบบคิดค่าบริการตามปริมาณข้อมูล" -#: selfdrive/ui/layouts/settings/device.py:25 -msgid "" -"Preview the driver facing camera to ensure that driver monitoring has good " -"visibility. (vehicle must be off)" -msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)" +msgstr "ดูตัวอย่างกล้องที่หันหน้าไปทางคนขับเพื่อให้แน่ใจว่าการตรวจสอบผู้ขับขี่มีทัศนวิสัยที่ดี (รถจะต้องถูกปิด)" -#: selfdrive/ui/widgets/pairing_dialog.py:161 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "QR Code Error" -msgstr "" +msgstr "ข้อผิดพลาดรหัส QR" -#: selfdrive/ui/widgets/ssh_key.py:31 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "REMOVE" -msgstr "" +msgstr "ลบ" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "RESET" -msgstr "" +msgstr "รีเซ็ต" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "REVIEW" +msgstr "ทบทวน" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" msgstr "" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" -msgstr "" +msgstr "รีบูต" -#: selfdrive/ui/onroad/alert_renderer.py:66 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Reboot Device" -msgstr "" +msgstr "รีบูตอุปกรณ์" -#: selfdrive/ui/widgets/offroad_alerts.py:112 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Reboot and Update" -msgstr "" +msgstr "รีบูตและอัปเดต" -#: selfdrive/ui/layouts/settings/toggles.py:27 -msgid "" -"Receive alerts to steer back into the lane when your vehicle drifts over a " -"detected lane line without a turn signal activated while driving over 31 mph " -"(50 km/h)." -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:76 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Driver Camera" -msgstr "" +msgstr "บันทึกและอัพโหลดกล้องไดร์เวอร์" -#: selfdrive/ui/layouts/settings/toggles.py:82 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Microphone Audio" -msgstr "" +msgstr "บันทึกและอัปโหลดเสียงไมโครโฟน" -#: selfdrive/ui/layouts/settings/toggles.py:33 -msgid "" -"Record and store microphone audio while driving. The audio will be included " -"in the dashcam video in comma connect." -msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect." +msgstr "บันทึกและจัดเก็บเสียงไมโครโฟนขณะขับรถ เสียงจะรวมอยู่ในวิดีโอ dashcam ด้วยการเชื่อมต่อด้วยเครื่องหมายจุลภาค" -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Regulatory" -msgstr "" +msgstr "กฎระเบียบ" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Relaxed" -msgstr "" +msgstr "ผ่อนคลาย" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote access" -msgstr "" +msgstr "การเข้าถึงระยะไกล" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote snapshots" -msgstr "" +msgstr "สแนปชอตระยะไกล" -#: selfdrive/ui/widgets/ssh_key.py:123 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Request timed out" -msgstr "" +msgstr "คำขอหมดเวลา" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset" -msgstr "" +msgstr "รีเซ็ต" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset Calibration" -msgstr "" +msgstr "รีเซ็ตการปรับเทียบ" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Review Training Guide" +msgstr "ทบทวนคู่มือการฝึกอบรม" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Review the rules, features, and limitations of sunnypilot" msgstr "" -#: selfdrive/ui/layouts/settings/device.py:27 -msgid "Review the rules, features, and limitations of openpilot" -msgstr "" - -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" -msgstr "" +msgstr "เลือก" -#: selfdrive/ui/layouts/settings/developer.py:53 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "SSH Keys" +msgstr "คีย์ SSH" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" msgstr "" -#: system/ui/widgets/network.py:310 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + +#: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." +msgstr "กำลังสแกนเครือข่าย Wi-Fi..." + +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" msgstr "" -#: system/ui/widgets/option_dialog.py:36 -#, python-format +#: system/ui/widgets/option_dialog.py msgid "Select" -msgstr "" +msgstr "เลือก" -#: selfdrive/ui/layouts/settings/software.py:183 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Select a branch" -msgstr "" +msgstr "เลือกสาขา" -#: selfdrive/ui/layouts/settings/device.py:91 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Select a language" -msgstr "" +msgstr "เลือกภาษา" -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Serial" -msgstr "" +msgstr "อนุกรม" -#: selfdrive/ui/widgets/offroad_alerts.py:106 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Snooze Update" -msgstr "" +msgstr "เลื่อนการอัปเดต" -#: selfdrive/ui/layouts/settings/settings.py:65 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Software" -msgstr "" +msgstr "ซอฟต์แวร์" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Standard" -msgstr "" +msgstr "มาตรฐาน" -#: selfdrive/ui/layouts/settings/toggles.py:22 -msgid "" -"Standard is recommended. In aggressive mode, openpilot will follow lead cars " -"closer and be more aggressive with the gas and brake. In relaxed mode " -"openpilot will stay further away from lead cars. On supported cars, you can " -"cycle through these personalities with your steering wheel distance button." -msgstr "" - -#: selfdrive/ui/onroad/alert_renderer.py:59 -#: selfdrive/ui/onroad/alert_renderer.py:65 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "System Unresponsive" -msgstr "" +msgstr "ระบบไม่ตอบสนอง" -#: selfdrive/ui/onroad/alert_renderer.py:58 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "TAKE CONTROL IMMEDIATELY" -msgstr "" +msgstr "เข้าควบคุมทันที" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 -#: selfdrive/ui/layouts/sidebar.py:127 selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "TEMP" -msgstr "" +msgstr "อุณหภูมิ" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Target Branch" -msgstr "" +msgstr "สาขาเป้าหมาย" -#: system/ui/widgets/network.py:124 -#, python-format +#: system/ui/widgets/network.py msgid "Tethering Password" -msgstr "" +msgstr "รหัสผ่านการแชร์อินเทอร์เน็ต" -#: selfdrive/ui/layouts/settings/settings.py:64 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Toggles" -msgstr "" +msgstr "สลับ" -#: selfdrive/ui/layouts/settings/software.py:72 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "UI Debug Mode" +msgstr "โหมดดีบัก UI" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "UNINSTALL" -msgstr "" +msgstr "ถอนการติดตั้ง" -#: selfdrive/ui/layouts/home.py:155 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "UPDATE" -msgstr "" +msgstr "อัปเดต" -#: selfdrive/ui/layouts/settings/software.py:72 -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Uninstall" -msgstr "" +msgstr "ถอนการติดตั้ง" -#: selfdrive/ui/layouts/sidebar.py:117 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Unknown" -msgstr "" +msgstr "ไม่ทราบ" -#: selfdrive/ui/layouts/settings/software.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Updates are only downloaded while the car is off." -msgstr "" +msgstr "การอัพเดตจะถูกดาวน์โหลดในขณะที่รถดับอยู่เท่านั้น" -#: selfdrive/ui/widgets/prime.py:33 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Upgrade Now" -msgstr "" +msgstr "อัพเกรดทันที" -#: selfdrive/ui/layouts/settings/toggles.py:31 -msgid "" -"Upload data from the driver facing camera and help improve the driver " -"monitoring algorithm." -msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." +msgstr "อัปโหลดข้อมูลจากกล้องที่หันเข้าหาคนขับและช่วยปรับปรุงอัลกอริธึมการตรวจสอบผู้ขับขี่" -#: selfdrive/ui/layouts/settings/toggles.py:88 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Use Metric System" -msgstr "" +msgstr "ใช้ระบบเมตริก" -#: selfdrive/ui/layouts/settings/toggles.py:17 -msgid "" -"Use the openpilot system for adaptive cruise control and lane keep driver " -"assistance. Your attention is required at all times to use this feature." -msgstr "" - -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "VEHICLE" -msgstr "" +msgstr "ยานพาหนะ" -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "VIEW" -msgstr "" +msgstr "ดู" -#: selfdrive/ui/onroad/alert_renderer.py:52 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Waiting to start" +msgstr "กำลังรอที่จะเริ่ม" + +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Welcome to sunnypilot" msgstr "" -#: selfdrive/ui/layouts/settings/developer.py:19 -msgid "" -"Warning: This grants SSH access to all public keys in your GitHub settings. " -"Never enter a GitHub username other than your own. A comma employee will " -"NEVER ask you to add their GitHub username." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." msgstr "" -#: selfdrive/ui/layouts/onboarding.py:111 -#, python-format -msgid "Welcome to openpilot" -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:20 -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "" - -#: selfdrive/ui/layouts/sidebar.py:44 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" -msgstr "" +msgstr "อินเตอร์เน็ตไร้สาย" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Wi-Fi Network Metered" -msgstr "" +msgstr "เครือข่าย Wi-Fi มีการตรวจวัด" -#: system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Wrong password" +msgstr "รหัสผ่านผิด" + +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service in order to use sunnypilot." msgstr "" -#: selfdrive/ui/layouts/onboarding.py:145 -#, python-format -msgid "You must accept the Terms and Conditions in order to use openpilot." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." msgstr "" -#: selfdrive/ui/layouts/onboarding.py:112 -#, python-format -msgid "" -"You must accept the Terms and Conditions to use openpilot. Read the latest " -"terms at https://comma.ai/terms before continuing." -msgstr "" - -#: selfdrive/ui/onroad/driver_camera_dialog.py:34 -#, python-format +#: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" -msgstr "" +msgstr "กำลังเริ่มกล้อง" -#: selfdrive/ui/widgets/prime.py:63 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "checking..." +msgstr "กำลังตรวจสอบ..." + +#: openpilot/selfdrive/ui/widgets/prime.py msgid "comma prime" -msgstr "" +msgstr "เครื่องหมายลูกน้ำเฉพาะ" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "default" -msgstr "" +msgstr "ค่าเริ่มต้น" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "down" -msgstr "" +msgstr "ลง" -#: selfdrive/ui/layouts/settings/software.py:106 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "downloading..." +msgstr "กำลังดาวน์โหลด..." + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "failed to check for update" -msgstr "" +msgstr "ไม่สามารถตรวจสอบการอัปเดตได้" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "finalizing update..." +msgstr "กำลังสรุปการอัปเดต..." + +#: system/ui/widgets/network.py msgid "for \"{}\"" -msgstr "" +msgstr "สำหรับ \"{}\"" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" -msgstr "" +msgstr "กม./ชม" -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "leave blank for automatic configuration" -msgstr "" +msgstr "เว้นว่างไว้เพื่อกำหนดค่าอัตโนมัติ" -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "left" -msgstr "" +msgstr "ซ้าย" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "metered" -msgstr "" +msgstr "คิดค่าบริการตามปริมาณ" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" -msgstr "" +msgstr "ไมล์/ชม." -#: selfdrive/ui/layouts/settings/software.py:20 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "never" -msgstr "" +msgstr "ไม่เคย" -#: selfdrive/ui/layouts/settings/software.py:31 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "now" -msgstr "" +msgstr "ตอนนี้" -#: selfdrive/ui/layouts/settings/developer.py:71 -#, python-format -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "" - -#: selfdrive/ui/onroad/alert_renderer.py:51 -#, python-format -msgid "openpilot Unavailable" -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:158 -#, python-format -msgid "" -"openpilot defaults to driving in chill mode. Experimental mode enables alpha-" -"level features that aren't ready for chill mode. Experimental features are " -"listed below:

End-to-End Longitudinal Control


Let the driving " -"model control the gas and brakes. openpilot will drive as it thinks a human " -"would, including stopping for red lights and stop signs. Since the driving " -"model decides the speed to drive, the set speed will only act as an upper " -"bound. This is an alpha quality feature; mistakes should be expected." -"

New Driving Visualization


The driving visualization will " -"transition to the road-facing wide-angle camera at low speeds to better show " -"some turns. The Experimental mode logo will also be shown in the top right " -"corner." -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:165 -#, python-format -msgid "" -"openpilot is continuously calibrating, resetting is rarely required. " -"Resetting calibration will restart openpilot if the car is powered on." -msgstr "" - -#: selfdrive/ui/layouts/settings/firehose.py:20 -msgid "" -"openpilot learns to drive by watching humans, like you, drive.\n" -"\n" -"Firehose Mode allows you to maximize your training data uploads to improve " -"openpilot's driving models. More data means bigger models, which means " -"better Experimental Mode." -msgstr "" - -#: selfdrive/ui/layouts/settings/toggles.py:183 -#, python-format -msgid "openpilot longitudinal control may come in a future update." -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:26 -msgid "" -"openpilot requires the device to be mounted within 4° left or right and " -"within 5° up or 9° down." -msgstr "" - -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" +msgstr "ขวา" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" msgstr "" -#: system/ui/widgets/network.py:142 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + +#: system/ui/widgets/network.py msgid "unmetered" -msgstr "" +msgstr "ไม่จำกัดปริมาณ" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "up" -msgstr "" +msgstr "ขึ้น" -#: selfdrive/ui/layouts/settings/software.py:117 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked never" -msgstr "" +msgstr "เป็นเวอร์ชันล่าสุด ตรวจสอบครั้งล่าสุด: ไม่เคย" -#: selfdrive/ui/layouts/settings/software.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked {}" -msgstr "" +msgstr "เป็นเวอร์ชันล่าสุด ตรวจสอบครั้งล่าสุดเมื่อ {}" -#: selfdrive/ui/layouts/settings/software.py:109 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "update available" -msgstr "" +msgstr "มีอัปเดตใหม่" -#: selfdrive/ui/layouts/home.py:169 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "{} ALERT" msgid_plural "{} ALERTS" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "การแจ้งเตือน {} รายการ" -#: selfdrive/ui/layouts/settings/software.py:40 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} day ago" msgid_plural "{} days ago" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "{} วันที่แล้ว" -#: selfdrive/ui/layouts/settings/software.py:37 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} hour ago" msgid_plural "{} hours ago" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "{} ชั่วโมงที่แล้ว" -#: selfdrive/ui/layouts/settings/software.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} minute ago" msgid_plural "{} minutes ago" -msgstr[0] "" -msgstr[1] "" +msgstr[0] "{} นาทีที่แล้ว" -#: selfdrive/ui/layouts/settings/firehose.py:111 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "{} segment of your driving is in the training dataset so far." msgid_plural "{} segments of your driving is in the training dataset so far." -msgstr[0] "" -msgstr[1] "" +msgstr[0] "ขณะนี้มีช่วงการขับขี่ของคุณ {} ช่วงอยู่ในชุดข้อมูลฝึกสอนแล้ว" -#: selfdrive/ui/widgets/prime.py:62 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "✓ SUBSCRIBED" -msgstr "" +msgstr "✓ สมัครแล้ว" -#: selfdrive/ui/widgets/setup.py:22 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "🔥 Firehose Mode 🔥" -msgstr "" +msgstr "🔥 โหมด Firehose 🔥" + diff --git a/selfdrive/ui/translations/app_tr.po b/selfdrive/ui/translations/app_tr.po index 10191234a1..a6da586bbd 100644 --- a/selfdrive/ui/translations/app_tr.po +++ b/selfdrive/ui/translations/app_tr.po @@ -1,1210 +1,873 @@ -# Turkish translations for PACKAGE package. -# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# Automatically generated, 2025. -# msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 00:50-0700\n" -"PO-Revision-Date: 2025-10-20 18:19-0700\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" -"Language: tr\n" -"MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" +"Language: tr\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: selfdrive/ui/layouts/settings/device.py:160 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is complete." msgstr " Direksiyon tork tepkisi kalibrasyonu tamamlandı." -#: selfdrive/ui/layouts/settings/device.py:158 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is {}% complete." msgstr " Direksiyon tork tepkisi kalibrasyonu {}% tamamlandı." -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Your device is pointed {:.1f}° {} and {:.1f}° {}." msgstr " Cihazınız {:.1f}° {} ve {:.1f}° {} yönünde konumlandırılmış." -#: selfdrive/ui/layouts/sidebar.py:43 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "--" msgstr "--" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "1 year of drive storage" msgstr "1 yıl sürüş depolaması" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "24/7 LTE connectivity" msgstr "7/24 LTE bağlantısı" -#: selfdrive/ui/layouts/sidebar.py:46 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "2G" msgstr "2G" -#: selfdrive/ui/layouts/sidebar.py:47 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "3G" msgstr "3G" -#: selfdrive/ui/layouts/sidebar.py:49 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "5G" msgstr "5G" -#: selfdrive/ui/layouts/settings/developer.py:23 -msgid "" -"WARNING: openpilot longitudinal control is in alpha for this car and will " -"disable Automatic Emergency Braking (AEB).

On this car, openpilot " -"defaults to the car's built-in ACC instead of openpilot's longitudinal " -"control. Enable this to switch to openpilot longitudinal control. Enabling " -"Experimental mode is recommended when enabling openpilot longitudinal " -"control alpha. Changing this setting will restart openpilot if the car is " -"powered on." -msgstr "" -"UYARI: Bu araç için openpilot boylamsal kontrolü alfa aşamasındadır ve " -"Otomatik Acil Frenlemeyi (AEB) devre dışı bırakacaktır.

Bu araçta " -"openpilot, openpilot'un boylamsal kontrolü yerine aracın yerleşik ACC'sini " -"varsayılan olarak kullanır. openpilot boylamsal kontrolüne geçmek için bunu " -"etkinleştirin. openpilot boylamsal kontrol alfayı etkinleştirirken Deneysel " -"modu etkinleştirmeniz önerilir." - -#: selfdrive/ui/layouts/settings/device.py:148 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is complete." -msgstr "" +msgstr "

Direksiyon gecikmesi kalibrasyonu tamamlandı." -#: selfdrive/ui/layouts/settings/device.py:146 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is {}% complete." -msgstr "" +msgstr "

Direksiyon gecikmesi kalibrasyonu %{} tamamlandı." -#: selfdrive/ui/layouts/settings/firehose.py:138 -#, python-format -msgid "ACTIVE" -msgstr "AKTİF" - -#: selfdrive/ui/layouts/settings/developer.py:15 -msgid "" -"ADB (Android Debug Bridge) allows connecting to your device over USB or over " -"the network. See https://docs.comma.ai/how-to/connect-to-comma for more info." -msgstr "" -"ADB (Android Debug Bridge), cihazınıza USB veya ağ üzerinden bağlanmayı " -"sağlar. Daha fazla bilgi için https://docs.comma.ai/how-to/connect-to-comma " -"adresine bakın." - -#: selfdrive/ui/widgets/ssh_key.py:30 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "ADD" msgstr "EKLE" -#: system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "APN Setting" -msgstr "" +msgstr "APN Ayarı" -#: selfdrive/ui/widgets/offroad_alerts.py:109 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Acknowledge Excessive Actuation" msgstr "Aşırı Müdahaleyi Onayla" -#: system/ui/widgets/network.py:74 system/ui/widgets/network.py:95 -#, python-format +#: system/ui/widgets/network.py msgid "Advanced" -msgstr "" +msgstr "Gelişmiş" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Aggressive" msgstr "Agresif" -#: selfdrive/ui/layouts/onboarding.py:116 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Agree" msgstr "Kabul et" -#: selfdrive/ui/layouts/settings/toggles.py:70 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Always-On Driver Monitoring" msgstr "Sürekli Sürücü İzleme" -#: selfdrive/ui/layouts/settings/toggles.py:186 -#, python-format -msgid "" -"An alpha version of openpilot longitudinal control can be tested, along with " -"Experimental mode, on non-release branches." -msgstr "" -"openpilot boylamsal kontrolünün alfa sürümü, Deneysel mod ile birlikte, " -"yayın dışı dallarda test edilebilir." - -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to power off?" msgstr "Kapatmak istediğinizden emin misiniz?" -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reboot?" msgstr "Yeniden başlatmak istediğinizden emin misiniz?" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reset calibration?" msgstr "Kalibrasyonu sıfırlamak istediğinizden emin misiniz?" -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Are you sure you want to uninstall?" msgstr "Kaldırmak istediğinizden emin misiniz?" -#: system/ui/widgets/network.py:99 selfdrive/ui/layouts/onboarding.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +#: system/ui/widgets/network.py msgid "Back" msgstr "Geri" -#: selfdrive/ui/widgets/prime.py:38 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Become a comma prime member at connect.comma.ai" msgstr "connect.comma.ai adresinde comma prime üyesi olun" -#: selfdrive/ui/widgets/pairing_dialog.py:130 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Bookmark connect.comma.ai to your home screen to use it like an app" -msgstr "" -"connect.comma.ai'yi ana ekranınıza ekleyerek bir uygulama gibi kullanın" +msgstr "connect.comma.ai'yi ana ekranınıza ekleyerek bir uygulama gibi kullanın" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "CHANGE" msgstr "DEĞİŞTİR" -#: selfdrive/ui/layouts/settings/software.py:50 -#: selfdrive/ui/layouts/settings/software.py:107 -#: selfdrive/ui/layouts/settings/software.py:118 -#: selfdrive/ui/layouts/settings/software.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "CHECK" msgstr "KONTROL ET" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "CHILL MODE ON" msgstr "CHILL MODU AÇIK" -#: system/ui/widgets/network.py:155 selfdrive/ui/layouts/sidebar.py:73 -#: selfdrive/ui/layouts/sidebar.py:134 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:138 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/network.py msgid "CONNECT" msgstr "BAĞLAN" -#: system/ui/widgets/network.py:369 -#, python-format +#: system/ui/widgets/network.py msgid "CONNECTING..." msgstr "BAĞLAN" -#: system/ui/widgets/confirm_dialog.py:23 system/ui/widgets/option_dialog.py:35 -#: system/ui/widgets/keyboard.py:81 system/ui/widgets/network.py:318 -#, python-format +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/keyboard.py +#: system/ui/widgets/network.py +#: system/ui/widgets/option_dialog.py msgid "Cancel" -msgstr "" +msgstr "İptal" -#: system/ui/widgets/network.py:134 -#, python-format +#: system/ui/widgets/network.py msgid "Cellular Metered" -msgstr "" +msgstr "Ölçülü Hücresel" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Change Language" msgstr "Dili Değiştir" -#: selfdrive/ui/layouts/settings/toggles.py:125 -#, python-format -msgid "Changing this setting will restart openpilot if the car is powered on." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Changing this setting will restart sunnypilot if the car is powered on." msgstr "" -" Bu ayarı değiştirmek, araç çalışıyorsa openpilot'u yeniden başlatacaktır." -#: selfdrive/ui/widgets/pairing_dialog.py:129 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" msgstr "\"yeni cihaz ekle\"ye tıklayın ve sağdaki QR kodunu tarayın" -#: selfdrive/ui/widgets/offroad_alerts.py:104 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Close" msgstr "Kapat" -#: selfdrive/ui/layouts/settings/software.py:49 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Current Version" msgstr "Geçerli Sürüm" -#: selfdrive/ui/layouts/settings/software.py:110 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "DOWNLOAD" msgstr "İNDİR" -#: selfdrive/ui/layouts/onboarding.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Decline" msgstr "Reddet" -#: selfdrive/ui/layouts/onboarding.py:148 -#, python-format -msgid "Decline, uninstall openpilot" -msgstr "Reddet, openpilot'u kaldır" +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline, uninstall sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/settings.py:67 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" msgstr "Geliştirici" -#: selfdrive/ui/layouts/settings/settings.py:62 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Device" msgstr "Cihaz" -#: selfdrive/ui/layouts/settings/toggles.py:58 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Disengage on Accelerator Pedal" msgstr "Gaz Pedalında Devreden Çık" -#: selfdrive/ui/layouts/settings/device.py:184 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Power Off" msgstr "Kapatmak için Devreden Çıkın" -#: selfdrive/ui/layouts/settings/device.py:172 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reboot" msgstr "Yeniden Başlatmak için Devreden Çıkın" -#: selfdrive/ui/layouts/settings/device.py:103 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reset Calibration" msgstr "Kalibrasyonu Sıfırlamak için Devreden Çıkın" -#: selfdrive/ui/layouts/settings/toggles.py:32 +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Display speed in km/h instead of mph." msgstr "Hızı mph yerine km/h olarak göster." -#: selfdrive/ui/layouts/settings/device.py:59 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Dongle ID" -msgstr "Dongle ID" +msgstr "Dongle kimliği" -#: selfdrive/ui/layouts/settings/software.py:50 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Download" msgstr "İndir" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Driver Camera" msgstr "Sürücü Kamerası" -#: selfdrive/ui/layouts/settings/toggles.py:96 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Driving Personality" msgstr "Sürüş Kişiliği" -#: system/ui/widgets/network.py:123 system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "EDIT" -msgstr "" +msgstr "DÜZENLE" -#: selfdrive/ui/layouts/sidebar.py:138 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ERROR" msgstr "HATA" -#: selfdrive/ui/layouts/sidebar.py:45 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ETH" msgstr "ETH" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "EXPERIMENTAL MODE ON" msgstr "DENEYSEL MOD AÇIK" -#: selfdrive/ui/layouts/settings/developer.py:166 -#: selfdrive/ui/layouts/settings/toggles.py:228 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Etkinleştir" -#: selfdrive/ui/layouts/settings/developer.py:39 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable ADB" msgstr "ADB'yi Etkinleştir" -#: selfdrive/ui/layouts/settings/toggles.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable Lane Departure Warnings" msgstr "Şerit Terk Uyarılarını Etkinleştir" -#: system/ui/widgets/network.py:129 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Roaming" -msgstr "openpilot'u etkinleştir" +msgstr "Dolaşımı Etkinleştir" -#: selfdrive/ui/layouts/settings/developer.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable SSH" msgstr "SSH'yi Etkinleştir" -#: system/ui/widgets/network.py:120 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Tethering" -msgstr "Şerit Terk Uyarılarını Etkinleştir" +msgstr "İnternet Paylaşımını Etkinleştir" -#: selfdrive/ui/layouts/settings/toggles.py:30 -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "openpilot devrede değilken bile sürücü izlemesini etkinleştir." - -#: selfdrive/ui/layouts/settings/toggles.py:46 -#, python-format -msgid "Enable openpilot" -msgstr "openpilot'u etkinleştir" - -#: selfdrive/ui/layouts/settings/toggles.py:189 -#, python-format -msgid "" -"Enable the openpilot longitudinal control (alpha) toggle to allow " -"Experimental mode." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable driver monitoring even when sunnypilot is not engaged." msgstr "" -"Deneysel modu etkinleştirmek için openpilot boylamsal kontrolünü (alfa) açın." -#: system/ui/widgets/network.py:204 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable sunnypilot" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" + +#: system/ui/widgets/network.py msgid "Enter APN" -msgstr "" +msgstr "APN girin" -#: system/ui/widgets/network.py:241 -#, python-format +#: system/ui/widgets/network.py msgid "Enter SSID" -msgstr "" +msgstr "SSID girin" -#: system/ui/widgets/network.py:254 -#, python-format +#: system/ui/widgets/network.py msgid "Enter new tethering password" -msgstr "" +msgstr "Yeni internet paylaşımı şifresini girin" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Enter password" +msgstr "Şifre girin" + +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" msgstr "" -#: selfdrive/ui/widgets/ssh_key.py:89 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "GitHub kullanıcı adınızı girin" -#: system/ui/widgets/list_view.py:123 system/ui/widgets/list_view.py:160 -#, python-format +#: system/ui/widgets/list_view.py msgid "Error" -msgstr "" +msgstr "Hata" -#: selfdrive/ui/layouts/settings/toggles.py:52 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Experimental Mode" msgstr "Deneysel Mod" -#: selfdrive/ui/layouts/settings/toggles.py:181 -#, python-format -msgid "" -"Experimental mode is currently unavailable on this car since the car's stock " -"ACC is used for longitudinal control." -msgstr "" -"Bu araçta boylamsal kontrol için stok ACC kullanıldığından şu anda Deneysel " -"mod kullanılamıyor." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." +msgstr "Bu araçta boylamsal kontrol için stok ACC kullanıldığından şu anda Deneysel mod kullanılamıyor." -#: system/ui/widgets/network.py:373 -#, python-format +#: system/ui/widgets/network.py msgid "FORGETTING..." -msgstr "" +msgstr "UNUTULUYOR..." -#: selfdrive/ui/widgets/setup.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Finish Setup" msgstr "Kurulumu Bitir" -#: selfdrive/ui/layouts/settings/settings.py:66 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Firehose" -msgstr "Firehose" +msgstr "Yoğun veri akışı" -#: selfdrive/ui/layouts/settings/firehose.py:18 +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "Firehose Mode" msgstr "Firehose Modu" -#: selfdrive/ui/layouts/settings/firehose.py:25 -msgid "" -"For maximum effectiveness, bring your device inside and connect to a good " -"USB-C adapter and Wi-Fi weekly.\n" -"\n" -"Firehose Mode can also work while you're driving if connected to a hotspot " -"or unlimited SIM card.\n" -"\n" -"\n" -"Frequently Asked Questions\n" -"\n" -"Does it matter how or where I drive? Nope, just drive as you normally " -"would.\n" -"\n" -"Do all of my segments get pulled in Firehose Mode? No, we selectively pull a " -"subset of your segments.\n" -"\n" -"What's a good USB-C adapter? Any fast phone or laptop charger should be " -"fine.\n" -"\n" -"Does it matter which software I run? Yes, only upstream openpilot (and " -"particular forks) are able to be used for training." +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" msgstr "" -"Maksimum verim için cihazınızı içeri alın ve haftalık olarak iyi bir USB-C " -"adaptörüne ve Wi‑Fi'a bağlayın.\n" -"\n" -"Firehose Modu, bir hotspot'a veya sınırsız SIM karta bağlıyken sürüş " -"sırasında da çalışabilir.\n" -"\n" -"\n" -"Sıkça Sorulan Sorular\n" -"\n" -"Nasıl veya nerede sürdüğüm önemli mi? Hayır, normalde nasıl sürüyorsanız " -"öyle sürün.\n" -"\n" -"Firehose Modu'nda tüm segmentlerim çekiliyor mu? Hayır, segmentlerinizin bir " -"alt kümesini seçerek çekiyoruz.\n" -"\n" -"İyi bir USB‑C adaptörü nedir? Hızlı bir telefon veya dizüstü şarj cihazı " -"uygundur.\n" -"\n" -"Hangi yazılımı çalıştırdığım önemli mi? Evet, yalnızca upstream openpilot " -"(ve bazı fork'lar) eğitim için kullanılabilir." -#: system/ui/widgets/network.py:318 system/ui/widgets/network.py:451 -#, python-format +#: system/ui/widgets/network.py msgid "Forget" -msgstr "" +msgstr "Unut" -#: system/ui/widgets/network.py:319 -#, python-format +#: system/ui/widgets/network.py msgid "Forget Wi-Fi Network \"{}\"?" -msgstr "" +msgstr "\"{}\" Wi‑Fi ağı unutulsun mu?" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "GOOD" msgstr "İYİ" -#: selfdrive/ui/widgets/pairing_dialog.py:128 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Go to https://connect.comma.ai on your phone" msgstr "Telefonunuzda https://connect.comma.ai adresine gidin" -#: selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "HIGH" msgstr "YÜKSEK" -#: system/ui/widgets/network.py:155 -#, python-format +#: system/ui/widgets/network.py msgid "Hidden Network" -msgstr "Ağ" +msgstr "Gizli Ağ" -#: selfdrive/ui/layouts/settings/firehose.py:140 -#, python-format -msgid "INACTIVE: connect to an unmetered network" -msgstr "PASİF: sınırsız bir ağa bağlanın" - -#: selfdrive/ui/layouts/settings/software.py:53 -#: selfdrive/ui/layouts/settings/software.py:136 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "INSTALL" msgstr "YÜKLE" -#: system/ui/widgets/network.py:150 -#, python-format +#: system/ui/widgets/network.py msgid "IP Address" +msgstr "IP Adresi" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" msgstr "" -#: selfdrive/ui/layouts/settings/software.py:53 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Güncellemeyi Yükle" -#: selfdrive/ui/layouts/settings/developer.py:56 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "Joystick Hata Ayıklama Modu" -#: selfdrive/ui/widgets/ssh_key.py:29 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "LOADING" msgstr "YÜKLENİYOR" -#: selfdrive/ui/layouts/sidebar.py:48 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "LTE" msgstr "LTE" -#: selfdrive/ui/layouts/settings/developer.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "Boylamsal Manevra Modu" -#: selfdrive/ui/onroad/hud_renderer.py:148 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "MAX" msgstr "MAKS" -#: selfdrive/ui/widgets/setup.py:75 -#, python-format -msgid "" -"Maximize your training data uploads to improve openpilot's driving models." -msgstr "" -"openpilot'un sürüş modellerini iyileştirmek için eğitim veri yüklemelerinizi " -"en üst düzeye çıkarın." +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Maximize your training data uploads to improve openpilot's driving models." +msgstr "openpilot'un sürüş modellerini iyileştirmek için eğitim veri yüklemelerinizi en üst düzeye çıkarın." -#: selfdrive/ui/layouts/settings/device.py:59 -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "N/A" -msgstr "" +msgstr "Yok" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "NO" msgstr "HAYIR" -#: selfdrive/ui/layouts/settings/settings.py:63 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Network" msgstr "Ağ" -#: selfdrive/ui/widgets/ssh_key.py:114 -#, python-format -msgid "No SSH keys found" -msgstr "SSH anahtarı bulunamadı" - -#: selfdrive/ui/widgets/ssh_key.py:126 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "No SSH keys found for user '{}'" -msgstr "'{username}' için SSH anahtarı bulunamadı" +msgstr "'{}' kullanıcısı için SSH anahtarı bulunamadı" -#: selfdrive/ui/widgets/offroad_alerts.py:320 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "No release notes available." msgstr "Sürüm notu mevcut değil." -#: selfdrive/ui/layouts/sidebar.py:73 selfdrive/ui/layouts/sidebar.py:134 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "OFFLINE" msgstr "ÇEVRİMDIŞI" -#: system/ui/widgets/html_render.py:263 system/ui/widgets/confirm_dialog.py:93 -#: selfdrive/ui/layouts/sidebar.py:127 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/html_render.py msgid "OK" msgstr "OK" -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ONLINE" msgstr "ÇEVRİMİÇİ" -#: selfdrive/ui/widgets/setup.py:20 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Open" msgstr "Aç" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PAIR" msgstr "EŞLE" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "PANDA" msgstr "PANDA" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PREVIEW" msgstr "ÖNİZLEME" -#: selfdrive/ui/widgets/prime.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "PRIME FEATURES:" msgstr "PRIME ÖZELLİKLERİ:" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Pair Device" msgstr "Cihazı Eşle" -#: selfdrive/ui/widgets/setup.py:19 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Pair device" msgstr "Cihazı eşle" -#: selfdrive/ui/widgets/pairing_dialog.py:103 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "Cihazınızı comma hesabınızla eşleştirin" -#: selfdrive/ui/widgets/setup.py:48 selfdrive/ui/layouts/settings/device.py:24 -#, python-format -msgid "" -"Pair your device with comma connect (connect.comma.ai) and claim your comma " -"prime offer." -msgstr "" -"Cihazınızı comma connect (connect.comma.ai) ile eşleştirin ve comma prime " -"teklifinizi alın." +#: openpilot/selfdrive/ui/layouts/settings/device.py +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." +msgstr "Cihazınızı comma connect (connect.comma.ai) ile eşleştirin ve comma prime teklifinizi alın." -#: selfdrive/ui/widgets/setup.py:91 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Please connect to Wi-Fi to complete initial pairing" msgstr "İlk eşleştirmeyi tamamlamak için lütfen Wi‑Fi'a bağlanın" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Power Off" msgstr "Kapat" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered Wi-Fi connection" -msgstr "" +msgstr "Ölçülü bir Wi‑Fi bağlantısındayken büyük veri yüklemelerini engelle" -#: system/ui/widgets/network.py:135 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered cellular connection" -msgstr "" +msgstr "Ölçülü bir hücresel bağlantıdayken büyük veri yüklemelerini engelle" -#: selfdrive/ui/layouts/settings/device.py:25 -msgid "" -"Preview the driver facing camera to ensure that driver monitoring has good " -"visibility. (vehicle must be off)" -msgstr "" -"Sürücü izleme görünürlüğünün iyi olduğundan emin olmak için sürücüye bakan " -"kamerayı önizleyin. (araç kapalı olmalıdır)" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)" +msgstr "Sürücü izleme görünürlüğünün iyi olduğundan emin olmak için sürücüye bakan kamerayı önizleyin. (araç kapalı olmalıdır)" -#: selfdrive/ui/widgets/pairing_dialog.py:161 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "QR Code Error" msgstr "QR Kod Hatası" -#: selfdrive/ui/widgets/ssh_key.py:31 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "REMOVE" msgstr "KALDIR" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "RESET" msgstr "SIFIRLA" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "REVIEW" msgstr "GÖZDEN GEÇİR" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "Yeniden Başlat" -#: selfdrive/ui/onroad/alert_renderer.py:66 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Reboot Device" msgstr "Cihazı Yeniden Başlat" -#: selfdrive/ui/widgets/offroad_alerts.py:112 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Reboot and Update" msgstr "Yeniden Başlat ve Güncelle" -#: selfdrive/ui/layouts/settings/toggles.py:27 -msgid "" -"Receive alerts to steer back into the lane when your vehicle drifts over a " -"detected lane line without a turn signal activated while driving over 31 mph " -"(50 km/h)." -msgstr "" -"Araç 31 mph (50 km/h) üzerindeyken sinyal verilmeden algılanan şerit " -"çizgisini aştığınızda şeride geri dönmeniz için uyarılar alın." - -#: selfdrive/ui/layouts/settings/toggles.py:76 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Driver Camera" msgstr "Sürücü Kamerasını Kaydet ve Yükle" -#: selfdrive/ui/layouts/settings/toggles.py:82 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Microphone Audio" msgstr "Mikrofon Sesini Kaydet ve Yükle" -#: selfdrive/ui/layouts/settings/toggles.py:33 -msgid "" -"Record and store microphone audio while driving. The audio will be included " -"in the dashcam video in comma connect." -msgstr "" -"Sürüş sırasında mikrofon sesini kaydedip saklayın. Ses, comma connect'teki " -"ön kamera videosuna dahil edilecektir." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect." +msgstr "Sürüş sırasında mikrofon sesini kaydedip saklayın. Ses, comma connect'teki ön kamera videosuna dahil edilecektir." -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Regulatory" msgstr "Mevzuat" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Relaxed" msgstr "Rahat" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote access" msgstr "Uzaktan erişim" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote snapshots" msgstr "Uzaktan anlık görüntüler" -#: selfdrive/ui/widgets/ssh_key.py:123 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Request timed out" msgstr "İstek zaman aşımına uğradı" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset" msgstr "Sıfırla" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset Calibration" msgstr "Kalibrasyonu Sıfırla" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Review Training Guide" msgstr "Eğitim Kılavuzunu İncele" -#: selfdrive/ui/layouts/settings/device.py:27 -msgid "Review the rules, features, and limitations of openpilot" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Review the rules, features, and limitations of sunnypilot" msgstr "" -"openpilot'un kurallarını, özelliklerini ve sınırlamalarını gözden geçirin" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" -msgstr "" +msgstr "SEÇ" -#: selfdrive/ui/layouts/settings/developer.py:53 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "SSH Keys" +msgstr "SSH Anahtarları" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" msgstr "" -#: system/ui/widgets/network.py:310 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + +#: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." +msgstr "Wi‑Fi ağları taranıyor..." + +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" msgstr "" -#: system/ui/widgets/option_dialog.py:36 -#, python-format +#: system/ui/widgets/option_dialog.py msgid "Select" -msgstr "" +msgstr "Seç" -#: selfdrive/ui/layouts/settings/software.py:183 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Select a branch" -msgstr "" +msgstr "Bir dal seçin" -#: selfdrive/ui/layouts/settings/device.py:91 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Select a language" msgstr "Bir dil seçin" -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Serial" msgstr "Seri" -#: selfdrive/ui/widgets/offroad_alerts.py:106 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Snooze Update" msgstr "Güncellemeyi Ertele" -#: selfdrive/ui/layouts/settings/settings.py:65 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Software" msgstr "Yazılım" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Standard" msgstr "Standart" -#: selfdrive/ui/layouts/settings/toggles.py:22 -msgid "" -"Standard is recommended. In aggressive mode, openpilot will follow lead cars " -"closer and be more aggressive with the gas and brake. In relaxed mode " -"openpilot will stay further away from lead cars. On supported cars, you can " -"cycle through these personalities with your steering wheel distance button." -msgstr "" -"Standart önerilir. Agresif modda openpilot öndeki aracı daha yakından takip " -"eder ve gaz/fren kullanımında daha ataktır. Rahat modda openpilot öndeki " -"araçlardan daha uzak durur. Desteklenen araçlarda bu kişilikler arasında " -"direksiyon mesafe düğmesiyle geçiş yapabilirsiniz." - -#: selfdrive/ui/onroad/alert_renderer.py:59 -#: selfdrive/ui/onroad/alert_renderer.py:65 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "System Unresponsive" msgstr "Sistem Yanıt Vermiyor" -#: selfdrive/ui/onroad/alert_renderer.py:58 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "TAKE CONTROL IMMEDIATELY" msgstr "HEMEN KONTROLÜ DEVRALIN" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 -#: selfdrive/ui/layouts/sidebar.py:127 selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "TEMP" -msgstr "TEMP" +msgstr "SIC." -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Target Branch" -msgstr "" +msgstr "Hedef Dal" -#: system/ui/widgets/network.py:124 -#, python-format +#: system/ui/widgets/network.py msgid "Tethering Password" -msgstr "" +msgstr "İnternet Paylaşımı Şifresi" -#: selfdrive/ui/layouts/settings/settings.py:64 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Toggles" msgstr "Seçenekler" -#: selfdrive/ui/layouts/settings/software.py:72 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "UI Debug Mode" +msgstr "Arayüz Hata Ayıklama Modu" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "UNINSTALL" msgstr "KALDIR" -#: selfdrive/ui/layouts/home.py:155 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "UPDATE" msgstr "GÜNCELLE" -#: selfdrive/ui/layouts/settings/software.py:72 -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Uninstall" msgstr "Kaldır" -#: selfdrive/ui/layouts/sidebar.py:117 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Unknown" msgstr "Bilinmiyor" -#: selfdrive/ui/layouts/settings/software.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Updates are only downloaded while the car is off." msgstr "Güncellemeler yalnızca araç kapalıyken indirilir." -#: selfdrive/ui/widgets/prime.py:33 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Upgrade Now" msgstr "Şimdi Yükselt" -#: selfdrive/ui/layouts/settings/toggles.py:31 -msgid "" -"Upload data from the driver facing camera and help improve the driver " -"monitoring algorithm." -msgstr "" -"Sürücüye bakan kameradan veri yükleyin ve sürücü izleme algoritmasını " -"geliştirmeye yardımcı olun." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." +msgstr "Sürücüye bakan kameradan veri yükleyin ve sürücü izleme algoritmasını geliştirmeye yardımcı olun." -#: selfdrive/ui/layouts/settings/toggles.py:88 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Use Metric System" msgstr "Metrik Sistemi Kullan" -#: selfdrive/ui/layouts/settings/toggles.py:17 -msgid "" -"Use the openpilot system for adaptive cruise control and lane keep driver " -"assistance. Your attention is required at all times to use this feature." -msgstr "" -"Uyarlanabilir hız sabitleyici ve şerit koruma sürücü yardımında openpilot " -"sistemini kullanın. Bu özelliği kullanırken her zaman dikkatli olmanız " -"gerekir." - -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "VEHICLE" msgstr "ARAÇ" -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "VIEW" msgstr "GÖRÜNTÜLE" -#: selfdrive/ui/onroad/alert_renderer.py:52 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Waiting to start" msgstr "Başlatma bekleniyor" -#: selfdrive/ui/layouts/settings/developer.py:19 -msgid "" -"Warning: This grants SSH access to all public keys in your GitHub settings. " -"Never enter a GitHub username other than your own. A comma employee will " -"NEVER ask you to add their GitHub username." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Welcome to sunnypilot" msgstr "" -"Uyarı: Bu, GitHub ayarlarınızdaki tüm açık anahtarlara SSH erişimi verir. " -"Kendi adınız dışında asla bir GitHub kullanıcı adı girmeyin. Bir comma " -"çalışanı sizden asla GitHub kullanıcı adlarını eklemenizi İSTEMEZ." -#: selfdrive/ui/layouts/onboarding.py:111 -#, python-format -msgid "Welcome to openpilot" -msgstr "openpilot'a hoş geldiniz" - -#: selfdrive/ui/layouts/settings/toggles.py:20 -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." msgstr "" -"Etkinleştirildiğinde, gaz pedalına basmak openpilot'u devreden çıkarır." -#: selfdrive/ui/layouts/sidebar.py:44 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" msgstr "Wi‑Fi" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Wi-Fi Network Metered" -msgstr "" +msgstr "Ölçülü Wi‑Fi Ağı" -#: system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Wrong password" +msgstr "Yanlış şifre" + +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service in order to use sunnypilot." msgstr "" -#: selfdrive/ui/layouts/onboarding.py:145 -#, python-format -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "openpilot'u kullanmak için Şartlar ve Koşulları kabul etmelisiniz." - -#: selfdrive/ui/layouts/onboarding.py:112 -#, python-format -msgid "" -"You must accept the Terms and Conditions to use openpilot. Read the latest " -"terms at https://comma.ai/terms before continuing." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." msgstr "" -"openpilot'u kullanmak için Şartlar ve Koşulları kabul etmelisiniz. Devam " -"etmeden önce en güncel şartları https://comma.ai/terms adresinde okuyun." -#: selfdrive/ui/onroad/driver_camera_dialog.py:34 -#, python-format +#: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" msgstr "kamera başlatılıyor" -#: selfdrive/ui/widgets/prime.py:63 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "checking..." +msgstr "kontrol ediliyor..." + +#: openpilot/selfdrive/ui/widgets/prime.py msgid "comma prime" msgstr "comma prime" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "default" -msgstr "" +msgstr "varsayılan" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "down" msgstr "aşağı" -#: selfdrive/ui/layouts/settings/software.py:106 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "downloading..." +msgstr "indiriliyor..." + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "failed to check for update" msgstr "güncelleme kontrolü başarısız" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format -msgid "for \"{}\"" -msgstr "" +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "finalizing update..." +msgstr "güncelleme tamamlanıyor..." -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: system/ui/widgets/network.py +msgid "for \"{}\"" +msgstr "\"{}\" için" + +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "leave blank for automatic configuration" -msgstr "" +msgstr "otomatik yapılandırma için boş bırakın" -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "left" msgstr "sol" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "metered" -msgstr "" +msgstr "ölçülü" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" -#: selfdrive/ui/layouts/settings/software.py:20 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "never" msgstr "asla" -#: selfdrive/ui/layouts/settings/software.py:31 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "now" msgstr "şimdi" -#: selfdrive/ui/layouts/settings/developer.py:71 -#, python-format -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "openpilot Boylamsal Kontrol (Alfa)" - -#: selfdrive/ui/onroad/alert_renderer.py:51 -#, python-format -msgid "openpilot Unavailable" -msgstr "openpilot Kullanılamıyor" - -#: selfdrive/ui/layouts/settings/toggles.py:158 -#, python-format -msgid "" -"openpilot defaults to driving in chill mode. Experimental mode enables alpha-" -"level features that aren't ready for chill mode. Experimental features are " -"listed below:

End-to-End Longitudinal Control


Let the driving " -"model control the gas and brakes. openpilot will drive as it thinks a human " -"would, including stopping for red lights and stop signs. Since the driving " -"model decides the speed to drive, the set speed will only act as an upper " -"bound. This is an alpha quality feature; mistakes should be expected." -"

New Driving Visualization


The driving visualization will " -"transition to the road-facing wide-angle camera at low speeds to better show " -"some turns. The Experimental mode logo will also be shown in the top right " -"corner." -msgstr "" -"openpilot varsayılan olarak chill modunda sürer. Deneysel mod, chill moduna " -"hazır olmayan alfa seviyesindeki özellikleri etkinleştirir. Deneysel " -"özellikler aşağıda listelenmiştir:

Uçtan Uca Boylamsal Kontrol
Sürüş modelinin gaz ve frenleri kontrol etmesine izin verin. " -"openpilot, kırmızı ışıklarda ve dur işaretlerinde durmak dahil, bir insan " -"nasıl sürer diye düşündüğüne göre sürer. Hızı sürüş modeli belirlediğinden, " -"ayarlanan hız yalnızca üst sınır olarak işlev görür. Bu bir alfa kalitesinde " -"özelliktir; hatalar beklenmelidir.

Yeni Sürüş Görselleştirmesi
Sürüş görselleştirmesi, düşük hızlarda bazı dönüşleri daha iyi " -"göstermek için yola bakan geniş açılı kameraya geçer. Deneysel mod logosu " -"sağ üst köşede de gösterilecektir." - -#: selfdrive/ui/layouts/settings/device.py:165 -#, python-format -msgid "" -"openpilot is continuously calibrating, resetting is rarely required. " -"Resetting calibration will restart openpilot if the car is powered on." -msgstr "" -" Bu ayarı değiştirmek, araç çalışıyorsa openpilot'u yeniden başlatacaktır." - -#: selfdrive/ui/layouts/settings/firehose.py:20 -msgid "" -"openpilot learns to drive by watching humans, like you, drive.\n" -"\n" -"Firehose Mode allows you to maximize your training data uploads to improve " -"openpilot's driving models. More data means bigger models, which means " -"better Experimental Mode." -msgstr "" -"openpilot, sizin gibi insanların nasıl sürdüğünü izleyerek sürmeyi öğrenir.\n" -"\n" -"Firehose Modu, openpilot'un sürüş modellerini geliştirmek için eğitim veri " -"yüklemelerinizi en üst düzeye çıkarmanıza olanak tanır. Daha fazla veri, " -"daha büyük modeller demektir; bu da daha iyi Deneysel Mod anlamına gelir." - -#: selfdrive/ui/layouts/settings/toggles.py:183 -#, python-format -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilot boylamsal kontrolü gelecekteki bir güncellemede gelebilir." - -#: selfdrive/ui/layouts/settings/device.py:26 -msgid "" -"openpilot requires the device to be mounted within 4° left or right and " -"within 5° up or 9° down." -msgstr "" -"openpilot, cihazın sağa/sola 4° ve yukarı 5° veya aşağı 9° içinde monte " -"edilmesini gerektirir." - -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "sağ" -#: system/ui/widgets/network.py:142 -#, python-format -msgid "unmetered" +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" msgstr "" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + +#: system/ui/widgets/network.py +msgid "unmetered" +msgstr "ölçüsüz" + +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "up" msgstr "yukarı" -#: selfdrive/ui/layouts/settings/software.py:117 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked never" msgstr "güncel, son kontrol asla" -#: selfdrive/ui/layouts/settings/software.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked {}" msgstr "güncel, son kontrol {}" -#: selfdrive/ui/layouts/settings/software.py:109 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "update available" msgstr "güncelleme mevcut" -#: selfdrive/ui/layouts/home.py:169 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "{} ALERT" msgid_plural "{} ALERTS" msgstr[0] "{} UYARI" msgstr[1] "{} UYARILAR" -#: selfdrive/ui/layouts/settings/software.py:40 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "{} gün önce" msgstr[1] "{} gün önce" -#: selfdrive/ui/layouts/settings/software.py:37 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "{} saat önce" msgstr[1] "{} saat önce" -#: selfdrive/ui/layouts/settings/software.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} minute ago" msgid_plural "{} minutes ago" msgstr[0] "{} dakika önce" msgstr[1] "{} dakika önce" -#: selfdrive/ui/layouts/settings/firehose.py:111 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "{} segment of your driving is in the training dataset so far." msgid_plural "{} segments of your driving is in the training dataset so far." msgstr[0] "{} segment sürüşünüz eğitim veri setinde." msgstr[1] "{} segment sürüşünüz eğitim veri setinde." -#: selfdrive/ui/widgets/prime.py:62 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "✓ SUBSCRIBED" msgstr "✓ ABONE" -#: selfdrive/ui/widgets/setup.py:22 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "🔥 Firehose Mode 🔥" msgstr "🔥 Firehose Modu 🔥" + diff --git a/selfdrive/ui/translations/app_uk.po b/selfdrive/ui/translations/app_uk.po index cf78fb5a33..43d78fad1c 100644 --- a/selfdrive/ui/translations/app_uk.po +++ b/selfdrive/ui/translations/app_uk.po @@ -1,1258 +1,878 @@ -# Ukrainian translations for PACKAGE package. -# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# Automatically generated, 2025. -# msgid "" msgstr "" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-19 12:21+0200\n" -"PO-Revision-Date: 2025-11-19 13:27+0200\n" -"Last-Translator: KeeFeeRe \n" -"Language-Team: none\n" -"Language: uk\n" -"MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " -"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" -"X-Generator: Poedit 3.8\n" +"Language: uk\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" -#: selfdrive/ui/layouts/settings/device.py:160 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is complete." msgstr " Калібрування реакції крутного моменту керма завершено." -#: selfdrive/ui/layouts/settings/device.py:158 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is {}% complete." msgstr "Калібрування реакції крутного моменту керма завершено на {}%." -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Your device is pointed {:.1f}° {} and {:.1f}° {}." msgstr " Ваш пристрій нахилено на {:.1f}° {} та {:.1f}° {}." -#: selfdrive/ui/layouts/sidebar.py:43 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "--" msgstr "--" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "1 year of drive storage" msgstr "1 рік зберігання поїздок" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "24/7 LTE connectivity" msgstr "Підключення LTE 24/7" -#: selfdrive/ui/layouts/sidebar.py:46 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "2G" msgstr "2G" -#: selfdrive/ui/layouts/sidebar.py:47 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "3G" msgstr "3G" -#: selfdrive/ui/layouts/sidebar.py:49 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "5G" msgstr "5G" -#: selfdrive/ui/layouts/settings/developer.py:23 -msgid "" -"WARNING: openpilot longitudinal control is in alpha for this car and will " -"disable Automatic Emergency Braking (AEB).

On this car, openpilot " -"defaults to the car's built-in ACC instead of openpilot's longitudinal " -"control. Enable this to switch to openpilot longitudinal control. Enabling " -"Experimental mode is recommended when enabling openpilot longitudinal " -"control alpha. Changing this setting will restart openpilot if the car is " -"powered on." -msgstr "" -"ПОПЕРЕДЖЕННЯ: поздовжнє керування openpilot для цього автомобіля знаходиться " -"в стадії альфа-тестування і вимкне автоматичне екстрене гальмування (AEB)." - -#: selfdrive/ui/layouts/settings/device.py:148 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is complete." msgstr "

Калібрування затримки кермування завершено." -#: selfdrive/ui/layouts/settings/device.py:146 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is {}% complete." msgstr "

Калібрування затримки кермування завершено на {}%." -#: selfdrive/ui/layouts/settings/firehose.py:138 -#, python-format -msgid "ACTIVE" -msgstr "АКТИВНИЙ" - -#: selfdrive/ui/layouts/settings/developer.py:15 -msgid "" -"ADB (Android Debug Bridge) allows connecting to your device over USB or over " -"the network. See https://docs.comma.ai/how-to/connect-to-comma for more info." -msgstr "" -"ADB (Android Debug Bridge) дозволяє підключатися до вашого пристрою через " -"USB або мережу. Дивіться https://docs.comma.ai/how-to/connect-to-comma для " -"отримання додаткової інформації." - -#: selfdrive/ui/widgets/ssh_key.py:30 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "ADD" msgstr "ДОДАТИ" -#: system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "APN Setting" msgstr "Налаштування APN" -#: selfdrive/ui/widgets/offroad_alerts.py:109 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Acknowledge Excessive Actuation" msgstr "Визнайте надмірне спрацьовування" -#: system/ui/widgets/network.py:74 system/ui/widgets/network.py:95 -#, python-format +#: system/ui/widgets/network.py msgid "Advanced" msgstr "Розширені" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Aggressive" msgstr "Агресивн." -#: selfdrive/ui/layouts/onboarding.py:116 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Agree" msgstr "Погодитися" -#: selfdrive/ui/layouts/settings/toggles.py:70 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Always-On Driver Monitoring" msgstr "Постійний моніторинг водія" -#: selfdrive/ui/layouts/settings/toggles.py:186 -#, python-format -msgid "" -"An alpha version of openpilot longitudinal control can be tested, along with " -"Experimental mode, on non-release branches." -msgstr "" -"Альфа-версію поздовжнього керування openpilot можна протестувати разом з " -"експериментальним режимом на нерелізних гілках." - -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to power off?" msgstr "Ви впевнені, що хочете вимкнути?" -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reboot?" msgstr "Ви впевнені, що хочете перезавантажити?" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reset calibration?" msgstr "Ви впевнені, що хочете скинути калібрування?" -#: selfdrive/ui/layouts/settings/software.py:171 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Are you sure you want to uninstall?" msgstr "Ви впевнені, що хочете видалити?" -#: system/ui/widgets/network.py:99 -#: selfdrive/ui/layouts/onboarding.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +#: system/ui/widgets/network.py msgid "Back" msgstr "Назад" -#: selfdrive/ui/widgets/prime.py:38 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Become a comma prime member at connect.comma.ai" msgstr "Станьте членом comma prime на connect.comma.ai" -#: selfdrive/ui/widgets/pairing_dialog.py:119 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Bookmark connect.comma.ai to your home screen to use it like an app" -msgstr "" -"Додайте connect.comma.ai до головного екрану, щоб використовувати його як " -"додаток." +msgstr "Додайте connect.comma.ai до головного екрану, щоб використовувати його як додаток." -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "CHANGE" msgstr "ЗМІНИТИ" -#: selfdrive/ui/layouts/settings/software.py:50 -#: selfdrive/ui/layouts/settings/software.py:115 -#: selfdrive/ui/layouts/settings/software.py:126 -#: selfdrive/ui/layouts/settings/software.py:155 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "CHECK" msgstr "ПЕРЕВІРИТИ" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "CHILL MODE ON" msgstr "СПОКІЙНИЙ РЕЖИМ" -#: system/ui/widgets/network.py:155 -#: selfdrive/ui/layouts/sidebar.py:73 -#: selfdrive/ui/layouts/sidebar.py:134 -#: selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:138 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/network.py msgid "CONNECT" -msgstr "CONNECT" +msgstr "ПІДКЛЮЧИТИ" -#: system/ui/widgets/network.py:369 -#, python-format +#: system/ui/widgets/network.py msgid "CONNECTING..." msgstr "ПІДКЛЮЧА..." -#: system/ui/widgets/confirm_dialog.py:23 system/ui/widgets/option_dialog.py:35 -#: system/ui/widgets/network.py:318 system/ui/widgets/keyboard.py:81 -#, python-format +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/keyboard.py +#: system/ui/widgets/network.py +#: system/ui/widgets/option_dialog.py msgid "Cancel" msgstr "Скасувати" -#: system/ui/widgets/network.py:134 -#, python-format +#: system/ui/widgets/network.py msgid "Cellular Metered" msgstr "Лімітне стільникове з'єднання" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Change Language" msgstr "Змінити мову" -#: selfdrive/ui/layouts/settings/toggles.py:125 -#, python-format -msgid "Changing this setting will restart openpilot if the car is powered on." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Changing this setting will restart sunnypilot if the car is powered on." msgstr "" -"Зміна цього параметра призведе до перезапуску openpilot, якщо автомобіль " -"увімкнено." -#: selfdrive/ui/widgets/pairing_dialog.py:118 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" msgstr "Натисніть «додати новий пристрій» і відскануйте QR-код праворуч." -#: selfdrive/ui/widgets/offroad_alerts.py:104 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Close" msgstr "Закрити" -#: selfdrive/ui/layouts/settings/software.py:49 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Current Version" msgstr "Поточна версія" -#: selfdrive/ui/layouts/settings/software.py:118 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "DOWNLOAD" msgstr "ВАНТАЖ" -#: selfdrive/ui/layouts/onboarding.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Decline" msgstr "Відхилити" -#: selfdrive/ui/layouts/onboarding.py:148 -#, python-format -msgid "Decline, uninstall openpilot" -msgstr "Відхилити, видалити openpilot" +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline, uninstall sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/settings.py:64 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" msgstr "Розробник" -#: selfdrive/ui/layouts/settings/settings.py:59 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Device" msgstr "Пристрій" -#: selfdrive/ui/layouts/settings/toggles.py:58 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Disengage on Accelerator Pedal" msgstr "Вимкнення при натисканні на педаль газу" -#: selfdrive/ui/layouts/settings/device.py:184 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Power Off" msgstr "Вимкніть openpilot, щоб вимкнути пристрій" -#: selfdrive/ui/layouts/settings/device.py:172 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reboot" msgstr "Вимкніть openpilot, щоб перезавантажити" -#: selfdrive/ui/layouts/settings/device.py:103 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reset Calibration" msgstr "Деактивуйте для скидання калібрування" -#: selfdrive/ui/layouts/settings/toggles.py:32 +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Display speed in km/h instead of mph." msgstr "Відображати швидкість у км/год замість миль/год." -#: selfdrive/ui/layouts/settings/device.py:59 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Dongle ID" msgstr "ID ключа" -#: selfdrive/ui/layouts/settings/software.py:50 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Download" msgstr "Завантажити" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Driver Camera" msgstr "Камера водія" -#: selfdrive/ui/layouts/settings/toggles.py:96 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Driving Personality" msgstr "Стиль водіння" -#: system/ui/widgets/network.py:123 system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "EDIT" msgstr "РЕДАГ." -#: selfdrive/ui/layouts/sidebar.py:138 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ERROR" msgstr "ПОМИЛКА" -#: selfdrive/ui/layouts/sidebar.py:45 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ETH" msgstr "ETH" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "EXPERIMENTAL MODE ON" msgstr "ЕКСПЕРИМЕНТ. РЕЖИМ" -#: selfdrive/ui/layouts/settings/toggles.py:228 -#: selfdrive/ui/layouts/settings/developer.py:166 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "Увімкнути" -#: selfdrive/ui/layouts/settings/developer.py:39 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable ADB" msgstr "Увімкнути ADB" -#: selfdrive/ui/layouts/settings/toggles.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable Lane Departure Warnings" msgstr "Увімкнути попередження про виїзд зі смуги" -#: system/ui/widgets/network.py:129 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Roaming" msgstr "Увімкнути роумінг" -#: selfdrive/ui/layouts/settings/developer.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable SSH" msgstr "Увімкнути SSH" -#: system/ui/widgets/network.py:120 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Tethering" msgstr "Увімкнути точку доступу" -#: selfdrive/ui/layouts/settings/toggles.py:30 -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "Увімкнути моніторинг водія, навіть коли openpilot не ввімкнено." - -#: selfdrive/ui/layouts/settings/toggles.py:46 -#, python-format -msgid "Enable openpilot" -msgstr "Увімкнути openpilot" - -#: selfdrive/ui/layouts/settings/toggles.py:189 -#, python-format -msgid "" -"Enable the openpilot longitudinal control (alpha) toggle to allow " -"Experimental mode." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable driver monitoring even when sunnypilot is not engaged." msgstr "" -"Увімкніть перемикач поздовжнього керування openpilot (альфа), щоб увімкнути " -"експериментальний режим." -#: system/ui/widgets/network.py:204 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable sunnypilot" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" + +#: system/ui/widgets/network.py msgid "Enter APN" msgstr "Введіть APN" -#: system/ui/widgets/network.py:241 -#, python-format +#: system/ui/widgets/network.py msgid "Enter SSID" msgstr "Введіть SSID" -#: system/ui/widgets/network.py:254 -#, python-format +#: system/ui/widgets/network.py msgid "Enter new tethering password" msgstr "Введіть новий пароль для модему" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Enter password" msgstr "Введіть пароль" -#: selfdrive/ui/widgets/ssh_key.py:89 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "Введіть ваш логін GitHub" -#: system/ui/widgets/list_view.py:123 system/ui/widgets/list_view.py:160 -#, python-format +#: system/ui/widgets/list_view.py msgid "Error" msgstr "Помилка" -#: selfdrive/ui/layouts/settings/toggles.py:52 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Experimental Mode" msgstr "Експериментальний режим" -#: selfdrive/ui/layouts/settings/toggles.py:181 -#, python-format -msgid "" -"Experimental mode is currently unavailable on this car since the car's stock " -"ACC is used for longitudinal control." -msgstr "" -"Експериментальний режим наразі недоступний для цього автомобіля, оскільки " -"для поздовжнього керування використовується штатний адаптивний круїз-" -"контроль (ACC)." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." +msgstr "Експериментальний режим наразі недоступний для цього автомобіля, оскільки для поздовжнього керування використовується штатний адаптивний круїз-контроль (ACC)." -#: system/ui/widgets/network.py:373 -#, python-format +#: system/ui/widgets/network.py msgid "FORGETTING..." msgstr "ЗАБУВАЮ..." -#: selfdrive/ui/widgets/setup.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Finish Setup" msgstr "Завершити налаштування" -#: selfdrive/ui/layouts/settings/settings.py:63 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Firehose" msgstr "Злива" -#: selfdrive/ui/layouts/settings/firehose.py:18 +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "Firehose Mode" msgstr "Режим зливи" -#: selfdrive/ui/layouts/settings/firehose.py:25 -msgid "" -"For maximum effectiveness, bring your device inside and connect to a good " -"USB-C adapter and Wi-Fi weekly.\n" -"\n" -"Firehose Mode can also work while you're driving if connected to a hotspot " -"or unlimited SIM card.\n" -"\n" -"\n" -"Frequently Asked Questions\n" -"\n" -"Does it matter how or where I drive? Nope, just drive as you normally " -"would.\n" -"\n" -"Do all of my segments get pulled in Firehose Mode? No, we selectively pull a " -"subset of your segments.\n" -"\n" -"What's a good USB-C adapter? Any fast phone or laptop charger should be " -"fine.\n" -"\n" -"Does it matter which software I run? Yes, only upstream openpilot (and " -"particular forks) are able to be used for training." +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" msgstr "" -"Для максимальної ефективності щотижня заносьте пристрій у приміщення та " -"підключайте його до якісного адаптера USB-C і Wi-Fi.\n" -"\n" -"Режим Зливи також може працювати під час руху, якщо пристрій підключено до " -"точки доступу або SIM-картки з необмеженим трафіком.\n" -"\n" -"\n" -"Поширені запитання\n" -"\n" -"Чи має значення, як і де я їду? Ні, просто їдьте, як зазвичай.\n" -"\n" -"Чи всі мої сегменти потрапляють у режим Зливи? Ні, ми вибірково вибираємо " -"підмножину ваших сегментів.\n" -"\n" -"Що таке хороший адаптер USB-C? Будь-який швидкий зарядний пристрій для " -"телефону або ноутбука підійде.\n" -"\n" -"Чи має значення, яке програмне забезпечення я використовую? Так, для " -"навчання можна використовувати тільки upstream openpilot (і певні його " -"форки)." -#: system/ui/widgets/network.py:318 system/ui/widgets/network.py:451 -#, python-format +#: system/ui/widgets/network.py msgid "Forget" -msgstr "Заб-и" +msgstr "Забути" -#: system/ui/widgets/network.py:319 -#, python-format +#: system/ui/widgets/network.py msgid "Forget Wi-Fi Network \"{}\"?" msgstr "Забути мережу Wi-Fi \"{}\"?" -#: selfdrive/ui/layouts/sidebar.py:71 -#: selfdrive/ui/layouts/sidebar.py:125 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "GOOD" msgstr "ДОБРА" -#: selfdrive/ui/widgets/pairing_dialog.py:117 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Go to https://connect.comma.ai on your phone" msgstr "Перейдіть на сайт https://connect.comma.ai на своєму телефоні." -#: selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "HIGH" msgstr "ВИСОКА" -#: system/ui/widgets/network.py:155 -#, python-format +#: system/ui/widgets/network.py msgid "Hidden Network" msgstr "Прихована мережа" -#: selfdrive/ui/layouts/settings/firehose.py:140 -#, python-format -msgid "INACTIVE: connect to an unmetered network" -msgstr "НЕАКТИВНО: підключення до мережі без ліміту трафіку" - -#: selfdrive/ui/layouts/settings/software.py:53 -#: selfdrive/ui/layouts/settings/software.py:144 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "INSTALL" msgstr "ВСТАНОВ." -#: system/ui/widgets/network.py:150 -#, python-format +#: system/ui/widgets/network.py msgid "IP Address" msgstr "IP-адреса" -#: selfdrive/ui/layouts/settings/software.py:53 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "Встановити оновлення" -#: selfdrive/ui/layouts/settings/developer.py:56 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "Режим зневадження джойстика" -#: selfdrive/ui/widgets/ssh_key.py:29 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "LOADING" msgstr "ЗАВАНТАЖЕННЯ" -#: selfdrive/ui/layouts/sidebar.py:48 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "LTE" msgstr "LTE" -#: selfdrive/ui/layouts/settings/developer.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "Режим поздовжнього маневрування" -#: selfdrive/ui/onroad/hud_renderer.py:148 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "MAX" msgstr "МАКС" -#: selfdrive/ui/widgets/setup.py:75 -#, python-format -msgid "" -"Maximize your training data uploads to improve openpilot's driving models." -msgstr "" -"Максимізуйте завантаження навчальних даних, щоб поліпшити моделі openpilot." +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Maximize your training data uploads to improve openpilot's driving models." +msgstr "Максимізуйте завантаження навчальних даних, щоб поліпшити моделі openpilot." -#: selfdrive/ui/layouts/settings/device.py:59 -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "N/A" msgstr "Н/Д" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "NO" msgstr "НЕМАЄ" -#: selfdrive/ui/layouts/settings/settings.py:60 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Network" msgstr "Мережа" -#: selfdrive/ui/widgets/ssh_key.py:114 -#, python-format -msgid "No SSH keys found" -msgstr "Не знайдено ключів SSH" - -#: selfdrive/ui/widgets/ssh_key.py:126 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "No SSH keys found for user '{}'" msgstr "Користувач '{}' не має ключів на GitHub" -#: selfdrive/ui/widgets/offroad_alerts.py:320 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "No release notes available." msgstr "Інформація про випуск відсутня." -#: selfdrive/ui/layouts/sidebar.py:73 -#: selfdrive/ui/layouts/sidebar.py:134 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "OFFLINE" msgstr "ОФЛАЙН" -#: system/ui/widgets/confirm_dialog.py:93 system/ui/widgets/html_render.py:263 -#: selfdrive/ui/layouts/sidebar.py:127 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/html_render.py msgid "OK" msgstr "OK" -#: selfdrive/ui/layouts/sidebar.py:72 -#: selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ONLINE" msgstr "ОНЛАЙН" -#: selfdrive/ui/widgets/setup.py:20 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Open" msgstr "ВІДКРИТИ" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PAIR" msgstr "ПІДКЛЮЧИТИ" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "PANDA" msgstr "PANDA" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PREVIEW" msgstr "ПОКАЖИ" -#: selfdrive/ui/widgets/prime.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "PRIME FEATURES:" msgstr "XАРАКТЕРИСТИКИ PRIME:" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Pair Device" msgstr "Підключити пристрій" -#: selfdrive/ui/widgets/setup.py:19 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Pair device" msgstr "Підключити пристрій" -#: selfdrive/ui/widgets/pairing_dialog.py:92 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "Підключіть свій пристрій до обліковки comma connect" -#: selfdrive/ui/widgets/setup.py:48 -#: selfdrive/ui/layouts/settings/device.py:24 -#, python-format -msgid "" -"Pair your device with comma connect (connect.comma.ai) and claim your comma " -"prime offer." -msgstr "" -"Підключіть свій пристрій до comma connect (connect.comma.ai) і отримайте " -"свою пропозицію comma prime." +#: openpilot/selfdrive/ui/layouts/settings/device.py +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." +msgstr "Підключіть свій пристрій до comma connect (connect.comma.ai) і отримайте свою пропозицію comma prime." -#: selfdrive/ui/widgets/setup.py:91 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Please connect to Wi-Fi to complete initial pairing" msgstr "Будь ласка, підключіться до Wi-Fi, щоб завершити початкове сполучення." -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Power Off" msgstr "Вимкнути" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered Wi-Fi connection" -msgstr "" -"Запобігайте завантаженню великих обсягів даних під час використання Wi-Fi-" -"з'єднання з обмеженим трафіком" +msgstr "Запобігайте завантаженню великих обсягів даних під час використання Wi-Fi-з'єднання з обмеженим трафіком" -#: system/ui/widgets/network.py:135 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered cellular connection" -msgstr "" -"Запобігати великим завантаженням даних під час лімітного стільникового " -"з'єднання" +msgstr "Запобігати великим завантаженням даних під час лімітного стільникового з'єднання" -#: selfdrive/ui/layouts/settings/device.py:25 -msgid "" -"Preview the driver facing camera to ensure that driver monitoring has good " -"visibility. (vehicle must be off)" -msgstr "" -"Попередньо перегляньте камеру, спрямовану на водія, щоб переконатися, що " -"система моніторингу водія має добру видимість. (автомобіль повинен бути " -"вимкнений)" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)" +msgstr "Попередньо перегляньте камеру, спрямовану на водія, щоб переконатися, що система моніторингу водія має добру видимість. (автомобіль повинен бути вимкнений)" -#: selfdrive/ui/widgets/pairing_dialog.py:150 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "QR Code Error" msgstr "Помилка QR-коду" -#: selfdrive/ui/widgets/ssh_key.py:31 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "REMOVE" msgstr "ВИДАЛИТИ" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "RESET" msgstr "Скинути" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "REVIEW" msgstr "ДИВИТИСЬ" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "Перезавантажити" -#: selfdrive/ui/onroad/alert_renderer.py:66 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Reboot Device" msgstr "Перезавантажте пристрій" -#: selfdrive/ui/widgets/offroad_alerts.py:112 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Reboot and Update" msgstr "Перезавантажити та оновити" -#: selfdrive/ui/layouts/settings/toggles.py:27 -msgid "" -"Receive alerts to steer back into the lane when your vehicle drifts over a " -"detected lane line without a turn signal activated while driving over 31 mph " -"(50 km/h)." -msgstr "" -"Отримувати попередження про необхідність повернутися в смугу, коли ваш " -"автомобіль перетинає виявлену лінію розмітки без увімкненого сигналу " -"повороту під час руху зі швидкістю понад 31 миль/год (50 км/год)." - -#: selfdrive/ui/layouts/settings/toggles.py:76 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Driver Camera" msgstr "Писати та вантажити відео з камери водія" -#: selfdrive/ui/layouts/settings/toggles.py:82 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Microphone Audio" msgstr "Запис та завантаження аудіо з мікрофона" -#: selfdrive/ui/layouts/settings/toggles.py:33 -msgid "" -"Record and store microphone audio while driving. The audio will be included " -"in the dashcam video in comma connect." -msgstr "" -"Записуйте та зберігайте аудіо з мікрофона під час руху. Аудіо буде включено " -"до відео з відеореєстратора в comma connect." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect." +msgstr "Записуйте та зберігайте аудіо з мікрофона під час руху. Аудіо буде включено до відео з відеореєстратора в comma connect." -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Regulatory" msgstr "Нормативні документи" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Relaxed" msgstr "Спокійний" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote access" msgstr "Віддалений доступ" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote snapshots" msgstr "Віддалені знімки" -#: selfdrive/ui/widgets/ssh_key.py:123 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Request timed out" msgstr "Час запиту вичерпано" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset" msgstr "Скинути" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset Calibration" msgstr "Скинути калібрування" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Review Training Guide" msgstr "Переглянути посібник з навчання" -#: selfdrive/ui/layouts/settings/device.py:27 -msgid "Review the rules, features, and limitations of openpilot" -msgstr "Перегляньте правила, функції та обмеження openpilot" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" msgstr "ВИБРАТИ" -#: selfdrive/ui/layouts/settings/developer.py:53 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "SSH Keys" msgstr "SSH ключі" -#: system/ui/widgets/network.py:310 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + +#: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "Пошук мереж..." -#: system/ui/widgets/option_dialog.py:36 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + +#: system/ui/widgets/option_dialog.py msgid "Select" msgstr "Вибрати" -#: selfdrive/ui/layouts/settings/software.py:191 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Select a branch" msgstr "Виберіть гілку" -#: selfdrive/ui/layouts/settings/device.py:91 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Select a language" msgstr "Виберіть мову" -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Serial" msgstr "Серійний номер" -#: selfdrive/ui/widgets/offroad_alerts.py:106 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Snooze Update" msgstr "Відкласти оновлення" -#: selfdrive/ui/layouts/settings/settings.py:62 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Software" msgstr "Програма" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Standard" msgstr "Стандарт" -#: selfdrive/ui/layouts/settings/toggles.py:22 -msgid "" -"Standard is recommended. In aggressive mode, openpilot will follow lead cars " -"closer and be more aggressive with the gas and brake. In relaxed mode " -"openpilot will stay further away from lead cars. On supported cars, you can " -"cycle through these personalities with your steering wheel distance button." -msgstr "" -"Рекомендується стандартний режим. В агресивному режимі openpilot буде " -"триматися ближче до автомобілів попереду і більш агресивно використовувати " -"газ і гальма. У спокійному режимі openpilot буде триматися на більшій " -"відстані від автомобілів попереду. На підтримуваних автомобілях ви можете " -"перемикатися між цими режимами за допомогою кнопки дистанції на кермі." - -#: selfdrive/ui/onroad/alert_renderer.py:59 -#: selfdrive/ui/onroad/alert_renderer.py:65 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "System Unresponsive" msgstr "Система не реагує" -#: selfdrive/ui/onroad/alert_renderer.py:58 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "TAKE CONTROL IMMEDIATELY" msgstr "КЕРМУЙТЕ НЕГАЙНО" -#: selfdrive/ui/layouts/sidebar.py:71 -#: selfdrive/ui/layouts/sidebar.py:125 -#: selfdrive/ui/layouts/sidebar.py:127 -#: selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "TEMP" msgstr "ТЕМП" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Target Branch" msgstr "Цільова гілка" -#: system/ui/widgets/network.py:124 -#, python-format +#: system/ui/widgets/network.py msgid "Tethering Password" msgstr "Пароль для точки доступу" -#: selfdrive/ui/layouts/settings/settings.py:61 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Toggles" msgstr "Перемикачі" -#: selfdrive/ui/layouts/settings/software.py:72 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "UI Debug Mode" +msgstr "Режим налагодження інтерфейсу" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "UNINSTALL" msgstr "ВИДАЛИТИ" -#: selfdrive/ui/layouts/home.py:155 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "UPDATE" msgstr "ОНОВИТИ" -#: selfdrive/ui/layouts/settings/software.py:72 -#: selfdrive/ui/layouts/settings/software.py:171 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Uninstall" msgstr "Видалити" -#: selfdrive/ui/layouts/sidebar.py:117 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Unknown" msgstr "Невідомо" -#: selfdrive/ui/layouts/settings/software.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Updates are only downloaded while the car is off." msgstr "Оновлення завантажуються лише тоді, коли автомобіль вимкнено." -#: selfdrive/ui/widgets/prime.py:33 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Upgrade Now" msgstr "Оновити зараз" -#: selfdrive/ui/layouts/settings/toggles.py:31 -msgid "" -"Upload data from the driver facing camera and help improve the driver " -"monitoring algorithm." -msgstr "" -"Завантажуйте дані з камери, спрямованої на водія, та допоможіть покращити " -"алгоритм моніторингу водія." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." +msgstr "Завантажуйте дані з камери, спрямованої на водія, та допоможіть покращити алгоритм моніторингу водія." -#: selfdrive/ui/layouts/settings/toggles.py:88 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Use Metric System" msgstr "Використовувати метричну систему" -#: selfdrive/ui/layouts/settings/toggles.py:17 -msgid "" -"Use the openpilot system for adaptive cruise control and lane keep driver " -"assistance. Your attention is required at all times to use this feature." -msgstr "" -"Використовуйте систему openpilot для адаптивного круїз-контролю та допомоги " -"в утриманні смуги руху. Ваша увага потрібна постійно при використанні цієї " -"функції. Зміна цього налаштування набуває чинності після вимкнення живлення " -"автомобіля." - -#: selfdrive/ui/layouts/sidebar.py:72 -#: selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "VEHICLE" msgstr "АВТО" -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "VIEW" msgstr "ДИВИСЬ" -#: selfdrive/ui/onroad/alert_renderer.py:52 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Waiting to start" msgstr "Очікування початку" -#: selfdrive/ui/layouts/settings/developer.py:19 -msgid "" -"Warning: This grants SSH access to all public keys in your GitHub settings. " -"Never enter a GitHub username other than your own. A comma employee will " -"NEVER ask you to add their GitHub username." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Welcome to sunnypilot" msgstr "" -"Попередження: це надає доступ по SSH до всіх публічних ключів у ваших " -"налаштуваннях GitHub. Ніколи не вводьте ім'я користувача GitHub, окрім " -"вашого власного. Співробітник comma НІКОЛИ не попросить вас додати його ім'я " -"користувача GitHub." -#: selfdrive/ui/layouts/onboarding.py:111 -#, python-format -msgid "Welcome to openpilot" -msgstr "Ласкаво просимо до openpilot" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:20 -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "Якщо увімкнено, натискання на педаль акселератора вимкне openpilot." - -#: selfdrive/ui/layouts/sidebar.py:44 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" msgstr "Wi-Fi" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Wi-Fi Network Metered" msgstr "Трафік Wi-Fi" -#: system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Wrong password" msgstr "Невірний пароль" -#: selfdrive/ui/layouts/onboarding.py:145 -#, python-format -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "Ви повинні прийняти Умови та положення, щоб користуватися openpilot." - -#: selfdrive/ui/layouts/onboarding.py:112 -#, python-format -msgid "" -"You must accept the Terms and Conditions to use openpilot. Read the latest " -"terms at https://comma.ai/terms before continuing." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service in order to use sunnypilot." msgstr "" -"Ви повинні прийняти Умови використання, щоб користуватися openpilot. Перед " -"тим, як продовжити, ознайомтеся з останніми умовами на сайті https://" -"comma.ai/terms." -#: selfdrive/ui/onroad/driver_camera_dialog.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" + +#: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" msgstr "запуск камери" -#: selfdrive/ui/layouts/settings/software.py:105 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "checking..." msgstr "перевіряю..." -#: selfdrive/ui/widgets/prime.py:63 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "comma prime" msgstr "comma prime" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "default" msgstr "замовч." -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "down" msgstr "вниз" -#: selfdrive/ui/layouts/settings/software.py:106 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "downloading..." msgstr "завантажую..." -#: selfdrive/ui/layouts/settings/software.py:114 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "failed to check for update" msgstr "не вдалося перевірити оновлення" -#: selfdrive/ui/layouts/settings/software.py:107 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "finalizing update..." msgstr "завершую..." -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "for \"{}\"" msgstr "для \"{}\"" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "км/год" -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "leave blank for automatic configuration" msgstr "залиште порожнім для автоматичного налаштування" -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "left" msgstr "вліво" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "metered" msgstr "обмеж." -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "миль/год" -#: selfdrive/ui/layouts/settings/software.py:20 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "never" msgstr "ніколи" -#: selfdrive/ui/layouts/settings/software.py:31 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "now" msgstr "зараз" -#: selfdrive/ui/layouts/settings/developer.py:71 -#, python-format -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "Поздовжнє керування openpilot (Альфа)" - -#: selfdrive/ui/onroad/alert_renderer.py:51 -#, python-format -msgid "openpilot Unavailable" -msgstr "openpilot Недоступний" - -#: selfdrive/ui/layouts/settings/toggles.py:158 -#, python-format -msgid "" -"openpilot defaults to driving in chill mode. Experimental mode enables alpha-" -"level features that aren't ready for chill mode. Experimental features are " -"listed below:

End-to-End Longitudinal Control


Let the driving " -"model control the gas and brakes. openpilot will drive as it thinks a human " -"would, including stopping for red lights and stop signs. Since the driving " -"model decides the speed to drive, the set speed will only act as an upper " -"bound. This is an alpha quality feature; mistakes should be expected." -"

New Driving Visualization


The driving visualization will " -"transition to the road-facing wide-angle camera at low speeds to better show " -"some turns. The Experimental mode logo will also be shown in the top right " -"corner." -msgstr "" -"openpilot за замовчуванням працює в режимі спокій. Експериментальний режим " -"увімкне функції альфа-рівня, які ще не готові для режиму спокій. " -"Експериментальні функції перелічені нижче:

Кінцевий поздовжній " -"контроль


Дозвольте моделі водіння контролювати газ і гальма. " -"openpilot буде керувати автомобілем так, як це робив би людина, включаючи " -"зупинку на червоне світло і знаки зупинки. Оскільки модель водіння визначає " -"швидкість руху, задана швидкість буде діяти лише як верхня межа. Це функція " -"альфа-рівня; слід очікувати помилок.

Нова візуалізація водіння
Візуалізація водіння перейде на ширококутну камеру, спрямовану на " -"дорогу, при низьких швидкостях, щоб краще показувати деякі повороти. Логотип " -"експериментального режиму також буде показаний у верхньому правому куті." - -#: selfdrive/ui/layouts/settings/device.py:165 -#, python-format -msgid "" -"openpilot is continuously calibrating, resetting is rarely required. " -"Resetting calibration will restart openpilot if the car is powered on." -msgstr "" -"openpilot постійно калібрується, скидання рідко потрібне. Скидання " -"калібрування призведе до перезапуску openpilot, якщо автомобіль увімкнено." - -#: selfdrive/ui/layouts/settings/firehose.py:20 -msgid "" -"openpilot learns to drive by watching humans, like you, drive.\n" -"\n" -"Firehose Mode allows you to maximize your training data uploads to improve " -"openpilot's driving models. More data means bigger models, which means " -"better Experimental Mode." -msgstr "" -"openpilot вчиться керувати автомобілем, спостерігаючи за тим, як це роблять " -"люди, такі як ви.\n" -"\n" -"Режим зливи дозволяє максимально збільшити обсяг завантажуваних навчальних " -"даних, щоб поліпшити моделі керування автомобілем openpilot. Більше даних " -"означає більші моделі, а це означає кращий експериментальний режим." - -#: selfdrive/ui/layouts/settings/toggles.py:183 -#, python-format -msgid "openpilot longitudinal control may come in a future update." -msgstr "Поздовжнє керування openpilot може з'явитися в майбутньому оновленні." - -#: selfdrive/ui/layouts/settings/device.py:26 -msgid "" -"openpilot requires the device to be mounted within 4° left or right and " -"within 5° up or 9° down." -msgstr "" -"Для роботи openpilot потрібно, щоб пристрій був встановлений з нахилом не " -"більше 4° вліво або вправо та не більше 5° вгору або 9° вниз. openpilot " -"постійно калібрується, тому скидання калібрування потрібне рідко." - -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "вправо" -#: system/ui/widgets/network.py:142 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + +#: system/ui/widgets/network.py msgid "unmetered" msgstr "необмеж." -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "up" msgstr "вгору" -#: selfdrive/ui/layouts/settings/software.py:125 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked never" msgstr "оновлено, ніколи не перевірялось" -#: selfdrive/ui/layouts/settings/software.py:123 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked {}" msgstr "оновлено, перевірив {}" -#: selfdrive/ui/layouts/settings/software.py:117 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "update available" msgstr "доступне оновлення" -#: selfdrive/ui/layouts/home.py:169 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "{} ALERT" msgid_plural "{} ALERTS" msgstr[0] "{} СПОВІЩЕННЯ" msgstr[1] "{} СПОВІЩЕННЯ" msgstr[2] "{} СПОВІЩЕНЬ" -#: selfdrive/ui/layouts/settings/software.py:40 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "{} день тому" msgstr[1] "{} дні тому" msgstr[2] "{} днів тому" -#: selfdrive/ui/layouts/settings/software.py:37 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "{} година тому" msgstr[1] "{} години тому" msgstr[2] "{} годин тому" -#: selfdrive/ui/layouts/settings/software.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} minute ago" msgid_plural "{} minutes ago" msgstr[0] "{} хвилина тому" msgstr[1] "{} хвилини тому" msgstr[2] "{} хвилин тому" -#: selfdrive/ui/layouts/settings/firehose.py:111 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "{} segment of your driving is in the training dataset so far." msgid_plural "{} segments of your driving is in the training dataset so far." -msgstr[0] "" -"{} сегмент вашого водіння на даний момент містяться в тренувальному наборі " -"даних." -msgstr[1] "" -"{} сегменти вашого водіння на даний момент містяться в тренувальному наборі " -"даних." -msgstr[2] "" -"{} сегментів вашого водіння на даний момент містяться в тренувальному наборі " -"даних." +msgstr[0] "{} сегмент вашого водіння на даний момент містяться в тренувальному наборі даних." +msgstr[1] "{} сегменти вашого водіння на даний момент містяться в тренувальному наборі даних." +msgstr[2] "{} сегментів вашого водіння на даний момент містяться в тренувальному наборі даних." -#: selfdrive/ui/widgets/prime.py:62 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "✓ SUBSCRIBED" msgstr "✓ ПІДПИСАНО" -#: selfdrive/ui/widgets/setup.py:22 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "🔥 Firehose Mode 🔥" msgstr "🌧️ Режим зливи 🌧️" + diff --git a/selfdrive/ui/translations/app_zh-CHS.po b/selfdrive/ui/translations/app_zh-CHS.po index 2400b6f44a..b01ba69392 100644 --- a/selfdrive/ui/translations/app_zh-CHS.po +++ b/selfdrive/ui/translations/app_zh-CHS.po @@ -1,1174 +1,873 @@ -# Language zh-CHS translations for PACKAGE package. -# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# Automatically generated, 2025. -# msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 00:50-0700\n" -"PO-Revision-Date: 2025-10-22 16:32-0700\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" -"Language: zh-CHS\n" -"MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" +"Language: zh-CHS\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: selfdrive/ui/layouts/settings/device.py:160 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is complete." msgstr " 转向扭矩响应校准完成。" -#: selfdrive/ui/layouts/settings/device.py:158 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is {}% complete." msgstr " 转向扭矩响应校准已完成 {}%。" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Your device is pointed {:.1f}° {} and {:.1f}° {}." msgstr " 您的设备朝向 {:.1f}° {} 与 {:.1f}° {}。" -#: selfdrive/ui/layouts/sidebar.py:43 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "--" msgstr "--" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "1 year of drive storage" msgstr "1 年行驶数据存储" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "24/7 LTE connectivity" msgstr "全天候 LTE 连接" -#: selfdrive/ui/layouts/sidebar.py:46 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "2G" msgstr "2G" -#: selfdrive/ui/layouts/sidebar.py:47 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "3G" msgstr "3G" -#: selfdrive/ui/layouts/sidebar.py:49 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "5G" msgstr "5G" -#: selfdrive/ui/layouts/settings/developer.py:23 -msgid "" -"WARNING: openpilot longitudinal control is in alpha for this car and will " -"disable Automatic Emergency Braking (AEB).

On this car, openpilot " -"defaults to the car's built-in ACC instead of openpilot's longitudinal " -"control. Enable this to switch to openpilot longitudinal control. Enabling " -"Experimental mode is recommended when enabling openpilot longitudinal " -"control alpha. Changing this setting will restart openpilot if the car is " -"powered on." -msgstr "" -"警告:此车型的 openpilot 纵向控制仍为 alpha,将会停用自动紧急制动 (AEB)。" -"

在此车型上,openpilot 默认使用车载 ACC,而非 openpilot 的纵向控" -"制。启用此选项可切换为 openpilot 纵向控制。建议同时启用实验模式。若车辆通电," -"更改此设置将会重启 openpilot。" - -#: selfdrive/ui/layouts/settings/device.py:148 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is complete." msgstr "

转向延迟校准完成。" -#: selfdrive/ui/layouts/settings/device.py:146 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is {}% complete." msgstr "

转向延迟校准已完成 {}%。" -#: selfdrive/ui/layouts/settings/firehose.py:138 -#, python-format -msgid "ACTIVE" -msgstr "已启用" - -#: selfdrive/ui/layouts/settings/developer.py:15 -msgid "" -"ADB (Android Debug Bridge) allows connecting to your device over USB or over " -"the network. See https://docs.comma.ai/how-to/connect-to-comma for more info." -msgstr "" -"ADB(Android 调试桥)可通过 USB 或网络连接到您的设备。详见 https://docs." -"comma.ai/how-to/connect-to-comma。" - -#: selfdrive/ui/widgets/ssh_key.py:30 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "ADD" msgstr "添加" -#: system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "APN Setting" msgstr "APN 设置" -#: selfdrive/ui/widgets/offroad_alerts.py:109 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Acknowledge Excessive Actuation" msgstr "确认过度作动" -#: system/ui/widgets/network.py:74 system/ui/widgets/network.py:95 -#, python-format +#: system/ui/widgets/network.py msgid "Advanced" msgstr "高级" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Aggressive" msgstr "激进" -#: selfdrive/ui/layouts/onboarding.py:116 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Agree" msgstr "同意" -#: selfdrive/ui/layouts/settings/toggles.py:70 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Always-On Driver Monitoring" msgstr "始终启用驾驶员监控" -#: selfdrive/ui/layouts/settings/toggles.py:186 -#, python-format -msgid "" -"An alpha version of openpilot longitudinal control can be tested, along with " -"Experimental mode, on non-release branches." -msgstr "openpilot 纵向控制的 alpha 版本可在非发布分支搭配实验模式进行测试。" - -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to power off?" msgstr "确定要关机吗?" -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reboot?" msgstr "确定要重启吗?" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reset calibration?" msgstr "确定要重置校准吗?" -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Are you sure you want to uninstall?" msgstr "确定要卸载吗?" -#: system/ui/widgets/network.py:99 selfdrive/ui/layouts/onboarding.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +#: system/ui/widgets/network.py msgid "Back" msgstr "返回" -#: selfdrive/ui/widgets/prime.py:38 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Become a comma prime member at connect.comma.ai" msgstr "前往 connect.comma.ai 成为 comma prime 会员" -#: selfdrive/ui/widgets/pairing_dialog.py:130 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Bookmark connect.comma.ai to your home screen to use it like an app" msgstr "将 connect.comma.ai 添加到主屏幕,像应用一样使用" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "CHANGE" msgstr "更改" -#: selfdrive/ui/layouts/settings/software.py:50 -#: selfdrive/ui/layouts/settings/software.py:107 -#: selfdrive/ui/layouts/settings/software.py:118 -#: selfdrive/ui/layouts/settings/software.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "CHECK" msgstr "检查" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "CHILL MODE ON" msgstr "安稳模式已开启" -#: system/ui/widgets/network.py:155 selfdrive/ui/layouts/sidebar.py:73 -#: selfdrive/ui/layouts/sidebar.py:134 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:138 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/network.py msgid "CONNECT" -msgstr "CONNECT" +msgstr "连接" -#: system/ui/widgets/network.py:369 -#, python-format +#: system/ui/widgets/network.py msgid "CONNECTING..." msgstr "连接中..." -#: system/ui/widgets/confirm_dialog.py:23 system/ui/widgets/option_dialog.py:35 -#: system/ui/widgets/keyboard.py:81 system/ui/widgets/network.py:318 -#, python-format +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/keyboard.py +#: system/ui/widgets/network.py +#: system/ui/widgets/option_dialog.py msgid "Cancel" msgstr "取消" -#: system/ui/widgets/network.py:134 -#, python-format +#: system/ui/widgets/network.py msgid "Cellular Metered" msgstr "蜂窝计量" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Change Language" msgstr "更改语言" -#: selfdrive/ui/layouts/settings/toggles.py:125 -#, python-format -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "若车辆通电,更改此设置将重启 openpilot。" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" -#: selfdrive/ui/widgets/pairing_dialog.py:129 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" msgstr "点击“添加新设备”,扫描右侧二维码" -#: selfdrive/ui/widgets/offroad_alerts.py:104 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Close" msgstr "关闭" -#: selfdrive/ui/layouts/settings/software.py:49 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Current Version" msgstr "当前版本" -#: selfdrive/ui/layouts/settings/software.py:110 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "DOWNLOAD" msgstr "下载" -#: selfdrive/ui/layouts/onboarding.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Decline" msgstr "拒绝" -#: selfdrive/ui/layouts/onboarding.py:148 -#, python-format -msgid "Decline, uninstall openpilot" -msgstr "拒绝并卸载 openpilot" +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline, uninstall sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/settings.py:67 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" msgstr "开发者" -#: selfdrive/ui/layouts/settings/settings.py:62 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Device" msgstr "设备" -#: selfdrive/ui/layouts/settings/toggles.py:58 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Disengage on Accelerator Pedal" msgstr "踩下加速踏板时脱离" -#: selfdrive/ui/layouts/settings/device.py:184 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Power Off" msgstr "脱离以关机" -#: selfdrive/ui/layouts/settings/device.py:172 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reboot" msgstr "脱离以重启" -#: selfdrive/ui/layouts/settings/device.py:103 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reset Calibration" msgstr "脱离以重置校准" -#: selfdrive/ui/layouts/settings/toggles.py:32 +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Display speed in km/h instead of mph." msgstr "以 km/h 显示速度(非 mph)。" -#: selfdrive/ui/layouts/settings/device.py:59 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Dongle ID" -msgstr "Dongle ID" +msgstr "设备 ID" -#: selfdrive/ui/layouts/settings/software.py:50 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Download" msgstr "下载" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Driver Camera" msgstr "车内摄像头" -#: selfdrive/ui/layouts/settings/toggles.py:96 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Driving Personality" msgstr "驾驶风格" -#: system/ui/widgets/network.py:123 system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "EDIT" msgstr "编辑" -#: selfdrive/ui/layouts/sidebar.py:138 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ERROR" msgstr "错误" -#: selfdrive/ui/layouts/sidebar.py:45 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ETH" msgstr "ETH" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "EXPERIMENTAL MODE ON" msgstr "实验模式已开启" -#: selfdrive/ui/layouts/settings/developer.py:166 -#: selfdrive/ui/layouts/settings/toggles.py:228 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "启用" -#: selfdrive/ui/layouts/settings/developer.py:39 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable ADB" msgstr "启用 ADB" -#: selfdrive/ui/layouts/settings/toggles.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable Lane Departure Warnings" msgstr "启用车道偏离警示" -#: system/ui/widgets/network.py:129 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Roaming" msgstr "启用漫游" -#: selfdrive/ui/layouts/settings/developer.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable SSH" msgstr "启用 SSH" -#: system/ui/widgets/network.py:120 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Tethering" msgstr "启用网络共享" -#: selfdrive/ui/layouts/settings/toggles.py:30 -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "即使未启用 openpilot 也启用驾驶员监控。" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:46 -#, python-format -msgid "Enable openpilot" -msgstr "启用 openpilot" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:189 -#, python-format -msgid "" -"Enable the openpilot longitudinal control (alpha) toggle to allow " -"Experimental mode." -msgstr "启用 openpilot 纵向控制(alpha)开关,以使用实验模式。" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "Enter APN" msgstr "输入 APN" -#: system/ui/widgets/network.py:241 -#, python-format +#: system/ui/widgets/network.py msgid "Enter SSID" msgstr "输入 SSID" -#: system/ui/widgets/network.py:254 -#, python-format +#: system/ui/widgets/network.py msgid "Enter new tethering password" msgstr "输入新的网络共享密码" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Enter password" msgstr "输入密码" -#: selfdrive/ui/widgets/ssh_key.py:89 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "输入您的 GitHub 用户名" -#: system/ui/widgets/list_view.py:123 system/ui/widgets/list_view.py:160 -#, python-format +#: system/ui/widgets/list_view.py msgid "Error" msgstr "错误" -#: selfdrive/ui/layouts/settings/toggles.py:52 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Experimental Mode" msgstr "实验模式" -#: selfdrive/ui/layouts/settings/toggles.py:181 -#, python-format -msgid "" -"Experimental mode is currently unavailable on this car since the car's stock " -"ACC is used for longitudinal control." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." msgstr "此车型当前无法使用实验模式,因为纵向控制使用的是原厂 ACC。" -#: system/ui/widgets/network.py:373 -#, python-format +#: system/ui/widgets/network.py msgid "FORGETTING..." msgstr "正在遗忘..." -#: selfdrive/ui/widgets/setup.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Finish Setup" msgstr "完成设置" -#: selfdrive/ui/layouts/settings/settings.py:66 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Firehose" -msgstr "Firehose" +msgstr "数据洪流" -#: selfdrive/ui/layouts/settings/firehose.py:18 +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "Firehose Mode" msgstr "Firehose 模式" -#: selfdrive/ui/layouts/settings/firehose.py:25 -msgid "" -"For maximum effectiveness, bring your device inside and connect to a good " -"USB-C adapter and Wi-Fi weekly.\n" -"\n" -"Firehose Mode can also work while you're driving if connected to a hotspot " -"or unlimited SIM card.\n" -"\n" -"\n" -"Frequently Asked Questions\n" -"\n" -"Does it matter how or where I drive? Nope, just drive as you normally " -"would.\n" -"\n" -"Do all of my segments get pulled in Firehose Mode? No, we selectively pull a " -"subset of your segments.\n" -"\n" -"What's a good USB-C adapter? Any fast phone or laptop charger should be " -"fine.\n" -"\n" -"Does it matter which software I run? Yes, only upstream openpilot (and " -"particular forks) are able to be used for training." +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" msgstr "" -"为达到最佳效果,请将设备带到室内,并每周连接优质 USB‑C 充电器与 Wi‑Fi。\n" -"\n" -"若连接热点或不限流量卡,行车中也可使用 Firehose 模式。\n" -"\n" -"\n" -"常见问题\n" -"\n" -"我怎么开、在哪开有区别吗?没有,平常怎么开就怎么开。\n" -"\n" -"Firehose 模式会拉取我所有片段吗?不会,我们会选择性拉取部分片段。\n" -"\n" -"什么是好的 USB‑C 充电器?任何快速的手机或笔电充电器都可以。\n" -"\n" -"我跑什么软件有区别吗?有,只有上游 openpilot(及特定分支)可用于训练。" -#: system/ui/widgets/network.py:318 system/ui/widgets/network.py:451 -#, python-format +#: system/ui/widgets/network.py msgid "Forget" msgstr "忘记" -#: system/ui/widgets/network.py:319 -#, python-format +#: system/ui/widgets/network.py msgid "Forget Wi-Fi Network \"{}\"?" msgstr "要忘记 Wi‑Fi 网络“{}”吗?" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "GOOD" msgstr "良好" -#: selfdrive/ui/widgets/pairing_dialog.py:128 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Go to https://connect.comma.ai on your phone" msgstr "在手机上前往 https://connect.comma.ai" -#: selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "HIGH" msgstr "高" -#: system/ui/widgets/network.py:155 -#, python-format +#: system/ui/widgets/network.py msgid "Hidden Network" msgstr "隐藏网络" -#: selfdrive/ui/layouts/settings/firehose.py:140 -#, python-format -msgid "INACTIVE: connect to an unmetered network" -msgstr "未启用:请连接不限流量网络" - -#: selfdrive/ui/layouts/settings/software.py:53 -#: selfdrive/ui/layouts/settings/software.py:136 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "INSTALL" msgstr "安装" -#: system/ui/widgets/network.py:150 -#, python-format +#: system/ui/widgets/network.py msgid "IP Address" msgstr "IP 地址" -#: selfdrive/ui/layouts/settings/software.py:53 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "安装更新" -#: selfdrive/ui/layouts/settings/developer.py:56 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "摇杆调试模式" -#: selfdrive/ui/widgets/ssh_key.py:29 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "LOADING" msgstr "加载中" -#: selfdrive/ui/layouts/sidebar.py:48 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "LTE" msgstr "LTE" -#: selfdrive/ui/layouts/settings/developer.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "纵向操作模式" -#: selfdrive/ui/onroad/hud_renderer.py:148 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "MAX" msgstr "最大" -#: selfdrive/ui/widgets/setup.py:75 -#, python-format -msgid "" -"Maximize your training data uploads to improve openpilot's driving models." +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Maximize your training data uploads to improve openpilot's driving models." msgstr "最大化上传训练数据,以改进 openpilot 的驾驶模型。" -#: selfdrive/ui/layouts/settings/device.py:59 -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "N/A" msgstr "无" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "NO" msgstr "否" -#: selfdrive/ui/layouts/settings/settings.py:63 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Network" msgstr "网络" -#: selfdrive/ui/widgets/ssh_key.py:114 -#, python-format -msgid "No SSH keys found" -msgstr "未找到 SSH 密钥" - -#: selfdrive/ui/widgets/ssh_key.py:126 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "No SSH keys found for user '{}'" msgstr "未找到用户“{}”的 SSH 密钥" -#: selfdrive/ui/widgets/offroad_alerts.py:320 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "No release notes available." msgstr "暂无发行说明。" -#: selfdrive/ui/layouts/sidebar.py:73 selfdrive/ui/layouts/sidebar.py:134 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "OFFLINE" msgstr "离线" -#: system/ui/widgets/html_render.py:263 system/ui/widgets/confirm_dialog.py:93 -#: selfdrive/ui/layouts/sidebar.py:127 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/html_render.py msgid "OK" msgstr "确定" -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ONLINE" msgstr "在线" -#: selfdrive/ui/widgets/setup.py:20 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Open" msgstr "打开" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PAIR" msgstr "配对" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "PANDA" msgstr "PANDA" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PREVIEW" msgstr "预览" -#: selfdrive/ui/widgets/prime.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "PRIME FEATURES:" msgstr "PRIME 功能:" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Pair Device" msgstr "配对设备" -#: selfdrive/ui/widgets/setup.py:19 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Pair device" msgstr "配对设备" -#: selfdrive/ui/widgets/pairing_dialog.py:103 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "将设备配对到您的 comma 账号" -#: selfdrive/ui/widgets/setup.py:48 selfdrive/ui/layouts/settings/device.py:24 -#, python-format -msgid "" -"Pair your device with comma connect (connect.comma.ai) and claim your comma " -"prime offer." -msgstr "" -"将设备与 comma connect(connect.comma.ai)配对,领取您的 comma prime 优惠。" +#: openpilot/selfdrive/ui/layouts/settings/device.py +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." +msgstr "将设备与 comma connect(connect.comma.ai)配对,领取您的 comma prime 优惠。" -#: selfdrive/ui/widgets/setup.py:91 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Please connect to Wi-Fi to complete initial pairing" msgstr "请连接 Wi‑Fi 以完成初始配对" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Power Off" msgstr "关机" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered Wi-Fi connection" msgstr "在计量制 Wi‑Fi 连接时避免大量上传" -#: system/ui/widgets/network.py:135 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered cellular connection" msgstr "在计量制蜂窝网络时避免大量上传" -#: selfdrive/ui/layouts/settings/device.py:25 -msgid "" -"Preview the driver facing camera to ensure that driver monitoring has good " -"visibility. (vehicle must be off)" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)" msgstr "预览车内摄像头以确保驾驶员监控视野良好。(车辆必须熄火)" -#: selfdrive/ui/widgets/pairing_dialog.py:161 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "QR Code Error" msgstr "二维码错误" -#: selfdrive/ui/widgets/ssh_key.py:31 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "REMOVE" msgstr "移除" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "RESET" msgstr "重置" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "REVIEW" msgstr "查看" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "重启" -#: selfdrive/ui/onroad/alert_renderer.py:66 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Reboot Device" msgstr "重启设备" -#: selfdrive/ui/widgets/offroad_alerts.py:112 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Reboot and Update" msgstr "重启并更新" -#: selfdrive/ui/layouts/settings/toggles.py:27 -msgid "" -"Receive alerts to steer back into the lane when your vehicle drifts over a " -"detected lane line without a turn signal activated while driving over 31 mph " -"(50 km/h)." -msgstr "" -"当车辆以超过 31 mph(50 km/h)行驶且未打转向灯越过检测到的车道线时,接收引导" -"回车道的警报。" - -#: selfdrive/ui/layouts/settings/toggles.py:76 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Driver Camera" msgstr "录制并上传车内摄像头" -#: selfdrive/ui/layouts/settings/toggles.py:82 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Microphone Audio" msgstr "录制并上传麦克风音频" -#: selfdrive/ui/layouts/settings/toggles.py:33 -msgid "" -"Record and store microphone audio while driving. The audio will be included " -"in the dashcam video in comma connect." -msgstr "" -"行驶时录制并保存麦克风音频。音频将包含在 comma connect 的行车记录视频中。" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect." +msgstr "行驶时录制并保存麦克风音频。音频将包含在 comma connect 的行车记录视频中。" -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Regulatory" msgstr "法规" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Relaxed" msgstr "从容" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote access" msgstr "远程访问" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote snapshots" msgstr "远程快照" -#: selfdrive/ui/widgets/ssh_key.py:123 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Request timed out" msgstr "请求超时" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset" msgstr "重置" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset Calibration" msgstr "重置校准" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Review Training Guide" msgstr "查看训练指南" -#: selfdrive/ui/layouts/settings/device.py:27 -msgid "Review the rules, features, and limitations of openpilot" -msgstr "查看 openpilot 的规则、功能与限制" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" msgstr "选择" -#: selfdrive/ui/layouts/settings/developer.py:53 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "SSH Keys" msgstr "SSH 密钥" -#: system/ui/widgets/network.py:310 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + +#: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "正在扫描 Wi‑Fi 网络…" -#: system/ui/widgets/option_dialog.py:36 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + +#: system/ui/widgets/option_dialog.py msgid "Select" msgstr "选择" -#: selfdrive/ui/layouts/settings/software.py:183 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Select a branch" msgstr "选择分支" -#: selfdrive/ui/layouts/settings/device.py:91 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Select a language" msgstr "选择语言" -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Serial" msgstr "序列号" -#: selfdrive/ui/widgets/offroad_alerts.py:106 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Snooze Update" msgstr "延后更新" -#: selfdrive/ui/layouts/settings/settings.py:65 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Software" msgstr "软件" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Standard" msgstr "标准" -#: selfdrive/ui/layouts/settings/toggles.py:22 -msgid "" -"Standard is recommended. In aggressive mode, openpilot will follow lead cars " -"closer and be more aggressive with the gas and brake. In relaxed mode " -"openpilot will stay further away from lead cars. On supported cars, you can " -"cycle through these personalities with your steering wheel distance button." -msgstr "" -"建议使用标准模式。激进模式下,openpilot 会更贴近前车,油门与刹车更为激进;从" -"容模式下,会与前车保持更远距离。在支持的车型上,可用方向盘距离按钮切换这些风" -"格。" - -#: selfdrive/ui/onroad/alert_renderer.py:59 -#: selfdrive/ui/onroad/alert_renderer.py:65 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "System Unresponsive" msgstr "系统无响应" -#: selfdrive/ui/onroad/alert_renderer.py:58 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "TAKE CONTROL IMMEDIATELY" msgstr "请立即接管控制" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 -#: selfdrive/ui/layouts/sidebar.py:127 selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "TEMP" msgstr "温度" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Target Branch" msgstr "目标分支" -#: system/ui/widgets/network.py:124 -#, python-format +#: system/ui/widgets/network.py msgid "Tethering Password" msgstr "网络共享密码" -#: selfdrive/ui/layouts/settings/settings.py:64 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Toggles" msgstr "切换" -#: selfdrive/ui/layouts/settings/software.py:72 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "UI Debug Mode" +msgstr "界面调试模式" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "UNINSTALL" msgstr "卸载" -#: selfdrive/ui/layouts/home.py:155 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "UPDATE" msgstr "更新" -#: selfdrive/ui/layouts/settings/software.py:72 -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Uninstall" msgstr "卸载" -#: selfdrive/ui/layouts/sidebar.py:117 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Unknown" msgstr "未知" -#: selfdrive/ui/layouts/settings/software.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Updates are only downloaded while the car is off." msgstr "仅在车辆熄火时下载更新。" -#: selfdrive/ui/widgets/prime.py:33 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Upgrade Now" msgstr "立即升级" -#: selfdrive/ui/layouts/settings/toggles.py:31 -msgid "" -"Upload data from the driver facing camera and help improve the driver " -"monitoring algorithm." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." msgstr "上传车内摄像头数据,帮助改进驾驶员监控算法。" -#: selfdrive/ui/layouts/settings/toggles.py:88 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Use Metric System" msgstr "使用公制" -#: selfdrive/ui/layouts/settings/toggles.py:17 -msgid "" -"Use the openpilot system for adaptive cruise control and lane keep driver " -"assistance. Your attention is required at all times to use this feature." -msgstr "" -"使用 openpilot 进行自适应巡航与车道保持辅助。使用此功能时,您必须始终保持专" -"注。" - -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "VEHICLE" msgstr "车辆" -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "VIEW" msgstr "查看" -#: selfdrive/ui/onroad/alert_renderer.py:52 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Waiting to start" msgstr "等待开始" -#: selfdrive/ui/layouts/settings/developer.py:19 -msgid "" -"Warning: This grants SSH access to all public keys in your GitHub settings. " -"Never enter a GitHub username other than your own. A comma employee will " -"NEVER ask you to add their GitHub username." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Welcome to sunnypilot" msgstr "" -"警告:这将授予对您 GitHub 设置中所有公钥的 SSH 访问权限。请勿输入非您本人的 " -"GitHub 用户名。comma 员工绝不会要求您添加他们的用户名。" -#: selfdrive/ui/layouts/onboarding.py:111 -#, python-format -msgid "Welcome to openpilot" -msgstr "欢迎使用 openpilot" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:20 -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "启用后,踩下加速踏板将会脱离 openpilot。" - -#: selfdrive/ui/layouts/sidebar.py:44 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" msgstr "Wi‑Fi" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Wi-Fi Network Metered" msgstr "Wi‑Fi 计量网络" -#: system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Wrong password" msgstr "密码错误" -#: selfdrive/ui/layouts/onboarding.py:145 -#, python-format -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "您必须接受条款与条件才能使用 openpilot。" - -#: selfdrive/ui/layouts/onboarding.py:112 -#, python-format -msgid "" -"You must accept the Terms and Conditions to use openpilot. Read the latest " -"terms at https://comma.ai/terms before continuing." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service in order to use sunnypilot." msgstr "" -"您必须接受条款与条件才能使用 openpilot。继续前请阅读 https://comma.ai/terms " -"上的最新条款。" -#: selfdrive/ui/onroad/driver_camera_dialog.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" + +#: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" msgstr "相机启动中" -#: selfdrive/ui/widgets/prime.py:63 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "checking..." +msgstr "检查中..." + +#: openpilot/selfdrive/ui/widgets/prime.py msgid "comma prime" msgstr "comma prime" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "default" msgstr "默认" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "down" msgstr "下" -#: selfdrive/ui/layouts/settings/software.py:106 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "downloading..." +msgstr "下载中..." + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "failed to check for update" msgstr "检查更新失败" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "finalizing update..." +msgstr "正在完成更新..." + +#: system/ui/widgets/network.py msgid "for \"{}\"" msgstr "用于“{}”" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "leave blank for automatic configuration" msgstr "留空以自动配置" -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "left" msgstr "左" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "metered" msgstr "计量" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" -#: selfdrive/ui/layouts/settings/software.py:20 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "never" msgstr "从不" -#: selfdrive/ui/layouts/settings/software.py:31 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "now" msgstr "现在" -#: selfdrive/ui/layouts/settings/developer.py:71 -#, python-format -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "openpilot 纵向控制(Alpha)" - -#: selfdrive/ui/onroad/alert_renderer.py:51 -#, python-format -msgid "openpilot Unavailable" -msgstr "openpilot 无法使用" - -#: selfdrive/ui/layouts/settings/toggles.py:158 -#, python-format -msgid "" -"openpilot defaults to driving in chill mode. Experimental mode enables alpha-" -"level features that aren't ready for chill mode. Experimental features are " -"listed below:

End-to-End Longitudinal Control


Let the driving " -"model control the gas and brakes. openpilot will drive as it thinks a human " -"would, including stopping for red lights and stop signs. Since the driving " -"model decides the speed to drive, the set speed will only act as an upper " -"bound. This is an alpha quality feature; mistakes should be expected." -"

New Driving Visualization


The driving visualization will " -"transition to the road-facing wide-angle camera at low speeds to better show " -"some turns. The Experimental mode logo will also be shown in the top right " -"corner." -msgstr "" -"openpilot 默认以安稳模式行驶。实验模式会启用尚未准备好用于安稳模式的 Alpha 级" -"功能。实验功能如下:

端到端纵向控制


让驾驶模型控制油门与刹车。" -"openpilot 会像人类一样驾驶,包括在红灯与停牌前停车。由于驾驶模型决定行驶速" -"度,设定速度仅作为上限。这是 Alpha 质量功能;预期会有错误。

全新驾驶可" -"视化


在低速时,驾驶可视化将切换至面向道路的广角摄像头以更好显示部分转" -"弯。右上角也会显示实验模式图标。" - -#: selfdrive/ui/layouts/settings/device.py:165 -#, python-format -msgid "" -"openpilot is continuously calibrating, resetting is rarely required. " -"Resetting calibration will restart openpilot if the car is powered on." -msgstr "" -"openpilot 持续进行校准,通常无需重置。若车辆通电,重置校准将会重启 " -"openpilot。" - -#: selfdrive/ui/layouts/settings/firehose.py:20 -msgid "" -"openpilot learns to drive by watching humans, like you, drive.\n" -"\n" -"Firehose Mode allows you to maximize your training data uploads to improve " -"openpilot's driving models. More data means bigger models, which means " -"better Experimental Mode." -msgstr "" -"openpilot 通过观察人类(例如您)的驾驶来学习。\n" -"\n" -"Firehose 模式可让您最大化上传训练数据,以改进 openpilot 的驾驶模型。更多数据" -"意味着更大的模型,也意味着更好的实验模式。" - -#: selfdrive/ui/layouts/settings/toggles.py:183 -#, python-format -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilot 纵向控制可能会在未来更新中提供。" - -#: selfdrive/ui/layouts/settings/device.py:26 -msgid "" -"openpilot requires the device to be mounted within 4° left or right and " -"within 5° up or 9° down." -msgstr "openpilot 要求设备安装在左右 4°、上 5° 或下 9° 以内。" - -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "右" -#: system/ui/widgets/network.py:142 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + +#: system/ui/widgets/network.py msgid "unmetered" msgstr "不限流量" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "up" msgstr "上" -#: selfdrive/ui/layouts/settings/software.py:117 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked never" msgstr "已是最新,最后检查:从未" -#: selfdrive/ui/layouts/settings/software.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked {}" msgstr "已是最新,最后检查:{}" -#: selfdrive/ui/layouts/settings/software.py:109 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "update available" msgstr "有可用更新" -#: selfdrive/ui/layouts/home.py:169 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "{} ALERT" msgid_plural "{} ALERTS" msgstr[0] "{} 条警报" msgstr[1] "{} 条警报" -#: selfdrive/ui/layouts/settings/software.py:40 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "{} 天前" msgstr[1] "{} 天前" -#: selfdrive/ui/layouts/settings/software.py:37 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "{} 小时前" msgstr[1] "{} 小时前" -#: selfdrive/ui/layouts/settings/software.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} minute ago" msgid_plural "{} minutes ago" msgstr[0] "{} 分钟前" msgstr[1] "{} 分钟前" -#: selfdrive/ui/layouts/settings/firehose.py:111 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "{} segment of your driving is in the training dataset so far." msgid_plural "{} segments of your driving is in the training dataset so far." msgstr[0] "目前已有 {} 个您的驾驶片段被纳入训练数据集。" msgstr[1] "目前已有 {} 个您的驾驶片段被纳入训练数据集。" -#: selfdrive/ui/widgets/prime.py:62 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "✓ SUBSCRIBED" msgstr "✓ 已订阅" -#: selfdrive/ui/widgets/setup.py:22 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "🔥 Firehose Mode 🔥" msgstr "🔥 Firehose 模式 🔥" + diff --git a/selfdrive/ui/translations/app_zh-CHT.po b/selfdrive/ui/translations/app_zh-CHT.po index f4d5e0a4ed..3cdcfe4e20 100644 --- a/selfdrive/ui/translations/app_zh-CHT.po +++ b/selfdrive/ui/translations/app_zh-CHT.po @@ -1,1173 +1,873 @@ -# Language zh-CHT translations for PACKAGE package. -# Copyright (C) 2025 THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# Automatically generated, 2025. -# msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-10-23 00:50-0700\n" -"PO-Revision-Date: 2025-10-22 16:32-0700\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" -"Language: zh-CHT\n" -"MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" +"Language: zh-CHT\n" "Plural-Forms: nplurals=1; plural=0;\n" -#: selfdrive/ui/layouts/settings/device.py:160 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is complete." msgstr " 轉向扭矩回應校正完成。" -#: selfdrive/ui/layouts/settings/device.py:158 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Steering torque response calibration is {}% complete." msgstr " 轉向扭矩回應校正已完成 {}%。" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid " Your device is pointed {:.1f}° {} and {:.1f}° {}." msgstr " 您的裝置朝向 {:.1f}° {} 與 {:.1f}° {}。" -#: selfdrive/ui/layouts/sidebar.py:43 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "--" msgstr "--" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "1 year of drive storage" msgstr "1 年行駛資料儲存" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "24/7 LTE connectivity" msgstr "全年無休 LTE 連線" -#: selfdrive/ui/layouts/sidebar.py:46 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "2G" msgstr "2G" -#: selfdrive/ui/layouts/sidebar.py:47 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "3G" msgstr "3G" -#: selfdrive/ui/layouts/sidebar.py:49 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "5G" msgstr "5G" -#: selfdrive/ui/layouts/settings/developer.py:23 -msgid "" -"WARNING: openpilot longitudinal control is in alpha for this car and will " -"disable Automatic Emergency Braking (AEB).

On this car, openpilot " -"defaults to the car's built-in ACC instead of openpilot's longitudinal " -"control. Enable this to switch to openpilot longitudinal control. Enabling " -"Experimental mode is recommended when enabling openpilot longitudinal " -"control alpha. Changing this setting will restart openpilot if the car is " -"powered on." -msgstr "" -"警告:此車款的 openpilot 縱向控制仍為 alpha,將會停用自動緊急煞車 (AEB)。" -"

在此車款上,openpilot 預設使用車載 ACC,而非 openpilot 的縱向控" -"制。啟用此選項可切換為 openpilot 縱向控制。建議同時啟用實驗模式。若車輛通電," -"變更此設定將會重新啟動 openpilot。" - -#: selfdrive/ui/layouts/settings/device.py:148 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is complete." msgstr "

轉向延遲校正完成。" -#: selfdrive/ui/layouts/settings/device.py:146 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "

Steering lag calibration is {}% complete." msgstr "

轉向延遲校正已完成 {}%。" -#: selfdrive/ui/layouts/settings/firehose.py:138 -#, python-format -msgid "ACTIVE" -msgstr "啟用" - -#: selfdrive/ui/layouts/settings/developer.py:15 -msgid "" -"ADB (Android Debug Bridge) allows connecting to your device over USB or over " -"the network. See https://docs.comma.ai/how-to/connect-to-comma for more info." -msgstr "" -"ADB (Android Debug Bridge) 可透過 USB 或網路連線至您的裝置。詳見 https://" -"docs.comma.ai/how-to/connect-to-comma。" - -#: selfdrive/ui/widgets/ssh_key.py:30 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "ADD" msgstr "新增" -#: system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "APN Setting" msgstr "APN 設定" -#: selfdrive/ui/widgets/offroad_alerts.py:109 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Acknowledge Excessive Actuation" msgstr "確認過度作動" -#: system/ui/widgets/network.py:74 system/ui/widgets/network.py:95 -#, python-format +#: system/ui/widgets/network.py msgid "Advanced" msgstr "進階" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Aggressive" msgstr "積極" -#: selfdrive/ui/layouts/onboarding.py:116 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Agree" msgstr "同意" -#: selfdrive/ui/layouts/settings/toggles.py:70 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Always-On Driver Monitoring" msgstr "持續啟用駕駛監控" -#: selfdrive/ui/layouts/settings/toggles.py:186 -#, python-format -msgid "" -"An alpha version of openpilot longitudinal control can be tested, along with " -"Experimental mode, on non-release branches." -msgstr "openpilot 縱向控制的 alpha 版本可於非發行分支搭配實驗模式進行測試。" - -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to power off?" msgstr "確定要關機嗎?" -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reboot?" msgstr "確定要重新啟動嗎?" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Are you sure you want to reset calibration?" msgstr "確定要重設校正嗎?" -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Are you sure you want to uninstall?" msgstr "確定要解除安裝嗎?" -#: system/ui/widgets/network.py:99 selfdrive/ui/layouts/onboarding.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +#: system/ui/widgets/network.py msgid "Back" msgstr "返回" -#: selfdrive/ui/widgets/prime.py:38 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Become a comma prime member at connect.comma.ai" msgstr "前往 connect.comma.ai 成為 comma prime 會員" -#: selfdrive/ui/widgets/pairing_dialog.py:130 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Bookmark connect.comma.ai to your home screen to use it like an app" msgstr "將 connect.comma.ai 加到主畫面,像 App 一樣使用" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "CHANGE" msgstr "變更" -#: selfdrive/ui/layouts/settings/software.py:50 -#: selfdrive/ui/layouts/settings/software.py:107 -#: selfdrive/ui/layouts/settings/software.py:118 -#: selfdrive/ui/layouts/settings/software.py:147 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "CHECK" msgstr "檢查" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "CHILL MODE ON" msgstr "安穩模式已開啟" -#: system/ui/widgets/network.py:155 selfdrive/ui/layouts/sidebar.py:73 -#: selfdrive/ui/layouts/sidebar.py:134 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:138 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/network.py msgid "CONNECT" -msgstr "CONNECT" +msgstr "連線" -#: system/ui/widgets/network.py:369 -#, python-format +#: system/ui/widgets/network.py msgid "CONNECTING..." msgstr "連線中..." -#: system/ui/widgets/confirm_dialog.py:23 system/ui/widgets/option_dialog.py:35 -#: system/ui/widgets/keyboard.py:81 system/ui/widgets/network.py:318 -#, python-format +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/keyboard.py +#: system/ui/widgets/network.py +#: system/ui/widgets/option_dialog.py msgid "Cancel" msgstr "取消" -#: system/ui/widgets/network.py:134 -#, python-format +#: system/ui/widgets/network.py msgid "Cellular Metered" msgstr "行動網路計量" -#: selfdrive/ui/layouts/settings/device.py:68 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Change Language" msgstr "變更語言" -#: selfdrive/ui/layouts/settings/toggles.py:125 -#, python-format -msgid "Changing this setting will restart openpilot if the car is powered on." -msgstr "若車輛通電,變更此設定將重新啟動 openpilot。" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Changing this setting will restart sunnypilot if the car is powered on." +msgstr "" -#: selfdrive/ui/widgets/pairing_dialog.py:129 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Choose your sponsorship tier and confirm your support" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Click \"add new device\" and scan the QR code on the right" msgstr "點選「新增裝置」,掃描右側 QR 碼" -#: selfdrive/ui/widgets/offroad_alerts.py:104 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Close" msgstr "關閉" -#: selfdrive/ui/layouts/settings/software.py:49 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Current Version" msgstr "目前版本" -#: selfdrive/ui/layouts/settings/software.py:110 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "DOWNLOAD" msgstr "下載" -#: selfdrive/ui/layouts/onboarding.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py msgid "Decline" msgstr "拒絕" -#: selfdrive/ui/layouts/onboarding.py:148 -#, python-format -msgid "Decline, uninstall openpilot" -msgstr "拒絕並解除安裝 openpilot" +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Decline, uninstall sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/settings.py:67 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Developer" msgstr "開發人員" -#: selfdrive/ui/layouts/settings/settings.py:62 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Device" msgstr "裝置" -#: selfdrive/ui/layouts/settings/toggles.py:58 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Disengage on Accelerator Pedal" msgstr "踩下加速踏板時脫離" -#: selfdrive/ui/layouts/settings/device.py:184 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Power Off" msgstr "脫離以關機" -#: selfdrive/ui/layouts/settings/device.py:172 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reboot" msgstr "脫離以重新啟動" -#: selfdrive/ui/layouts/settings/device.py:103 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Disengage to Reset Calibration" msgstr "脫離以重設校正" -#: selfdrive/ui/layouts/settings/toggles.py:32 +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Display speed in km/h instead of mph." msgstr "以 km/h 顯示速度(非 mph)。" -#: selfdrive/ui/layouts/settings/device.py:59 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Dongle ID" -msgstr "Dongle ID" +msgstr "裝置 ID" -#: selfdrive/ui/layouts/settings/software.py:50 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Download" msgstr "下載" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Driver Camera" msgstr "車內鏡頭" -#: selfdrive/ui/layouts/settings/toggles.py:96 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Driving Personality" msgstr "駕駛風格" -#: system/ui/widgets/network.py:123 system/ui/widgets/network.py:139 -#, python-format +#: system/ui/widgets/network.py msgid "EDIT" msgstr "編輯" -#: selfdrive/ui/layouts/sidebar.py:138 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ERROR" msgstr "錯誤" -#: selfdrive/ui/layouts/sidebar.py:45 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ETH" msgstr "ETH" -#: selfdrive/ui/widgets/exp_mode_button.py:50 -#, python-format +#: openpilot/selfdrive/ui/widgets/exp_mode_button.py msgid "EXPERIMENTAL MODE ON" msgstr "實驗模式已開啟" -#: selfdrive/ui/layouts/settings/developer.py:166 -#: selfdrive/ui/layouts/settings/toggles.py:228 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Early Access: Become a sunnypilot Sponsor" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable" msgstr "啟用" -#: selfdrive/ui/layouts/settings/developer.py:39 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable ADB" msgstr "啟用 ADB" -#: selfdrive/ui/layouts/settings/toggles.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Enable Lane Departure Warnings" msgstr "啟用偏離車道警示" -#: system/ui/widgets/network.py:129 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Roaming" msgstr "啟用漫遊" -#: selfdrive/ui/layouts/settings/developer.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Enable SSH" msgstr "啟用 SSH" -#: system/ui/widgets/network.py:120 -#, python-format +#: system/ui/widgets/network.py msgid "Enable Tethering" msgstr "啟用網路共享" -#: selfdrive/ui/layouts/settings/toggles.py:30 -msgid "Enable driver monitoring even when openpilot is not engaged." -msgstr "即使未啟動 openpilot 亦啟用駕駛監控。" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable driver monitoring even when sunnypilot is not engaged." +msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:46 -#, python-format -msgid "Enable openpilot" -msgstr "啟用 openpilot" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:189 -#, python-format -msgid "" -"Enable the openpilot longitudinal control (alpha) toggle to allow " -"Experimental mode." -msgstr "啟用 openpilot 縱向控制(alpha)切換,以使用實驗模式。" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Enable the sunnypilot longitudinal control (alpha) toggle to allow Experimental mode." +msgstr "" -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "Enter APN" msgstr "輸入 APN" -#: system/ui/widgets/network.py:241 -#, python-format +#: system/ui/widgets/network.py msgid "Enter SSID" msgstr "輸入 SSID" -#: system/ui/widgets/network.py:254 -#, python-format +#: system/ui/widgets/network.py msgid "Enter new tethering password" msgstr "輸入新的網路共享密碼" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Enter password" msgstr "輸入密碼" -#: selfdrive/ui/widgets/ssh_key.py:89 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Enter search query" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Enter your GitHub username" msgstr "輸入您的 GitHub 使用者名稱" -#: system/ui/widgets/list_view.py:123 system/ui/widgets/list_view.py:160 -#, python-format +#: system/ui/widgets/list_view.py msgid "Error" msgstr "錯誤" -#: selfdrive/ui/layouts/settings/toggles.py:52 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Experimental Mode" msgstr "實驗模式" -#: selfdrive/ui/layouts/settings/toggles.py:181 -#, python-format -msgid "" -"Experimental mode is currently unavailable on this car since the car's stock " -"ACC is used for longitudinal control." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control." msgstr "此車款目前無法使用實驗模式,因為縱向控制使用的是原廠 ACC。" -#: system/ui/widgets/network.py:373 -#, python-format +#: system/ui/widgets/network.py msgid "FORGETTING..." msgstr "正在遺忘..." -#: selfdrive/ui/widgets/setup.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Finish Setup" msgstr "完成設定" -#: selfdrive/ui/layouts/settings/settings.py:66 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Firehose" -msgstr "Firehose" +msgstr "資料洪流" -#: selfdrive/ui/layouts/settings/firehose.py:18 +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "Firehose Mode" msgstr "Firehose 模式" -#: selfdrive/ui/layouts/settings/firehose.py:25 -msgid "" -"For maximum effectiveness, bring your device inside and connect to a good " -"USB-C adapter and Wi-Fi weekly.\n" -"\n" -"Firehose Mode can also work while you're driving if connected to a hotspot " -"or unlimited SIM card.\n" -"\n" -"\n" -"Frequently Asked Questions\n" -"\n" -"Does it matter how or where I drive? Nope, just drive as you normally " -"would.\n" -"\n" -"Do all of my segments get pulled in Firehose Mode? No, we selectively pull a " -"subset of your segments.\n" -"\n" -"What's a good USB-C adapter? Any fast phone or laptop charger should be " -"fine.\n" -"\n" -"Does it matter which software I run? Yes, only upstream openpilot (and " -"particular forks) are able to be used for training." +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Follow the prompts to complete the pairing process" msgstr "" -"為達最佳效果,請將裝置帶到室內,並每週連接優質 USB‑C 充電器與 Wi‑Fi。\n" -"\n" -"若連上熱點或吃到飽門號,行車中也可使用 Firehose 模式。\n" -"\n" -"\n" -"常見問題\n" -"\n" -"我怎麼開、在哪裡開有差嗎?沒有,平常怎麼開就怎麼開。\n" -"\n" -"Firehose 模式會拉取我所有片段嗎?不會,我們會選擇性拉取部分片段。\n" -"\n" -"什麼是好的 USB‑C 充電器?任何快速的手機或筆電充電器都可以。\n" -"\n" -"我跑什麼軟體有差嗎?有,只有上游 openpilot(及特定分支)可用於訓練。" -#: system/ui/widgets/network.py:318 system/ui/widgets/network.py:451 -#, python-format +#: system/ui/widgets/network.py msgid "Forget" msgstr "忘記" -#: system/ui/widgets/network.py:319 -#, python-format +#: system/ui/widgets/network.py msgid "Forget Wi-Fi Network \"{}\"?" msgstr "要忘記 Wi‑Fi 網路「{}」嗎?" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "GOOD" msgstr "良好" -#: selfdrive/ui/widgets/pairing_dialog.py:128 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Go to https://connect.comma.ai on your phone" msgstr "在手機上前往 https://connect.comma.ai" -#: selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "HIGH" msgstr "高" -#: system/ui/widgets/network.py:155 -#, python-format +#: system/ui/widgets/network.py msgid "Hidden Network" msgstr "隱藏網路" -#: selfdrive/ui/layouts/settings/firehose.py:140 -#, python-format -msgid "INACTIVE: connect to an unmetered network" -msgstr "未啟用:請連接不限流量網路" - -#: selfdrive/ui/layouts/settings/software.py:53 -#: selfdrive/ui/layouts/settings/software.py:136 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "INSTALL" msgstr "安裝" -#: system/ui/widgets/network.py:150 -#, python-format +#: system/ui/widgets/network.py msgid "IP Address" msgstr "IP 位址" -#: selfdrive/ui/layouts/settings/software.py:53 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "If sponsorship status was not updated, please contact a moderator on the community forum at https://community.sunnypilot.ai" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Install Update" msgstr "安裝更新" -#: selfdrive/ui/layouts/settings/developer.py:56 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Join our Community Forum at https://community.sunnypilot.ai and reach out to a moderator if you have issues" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Joystick Debug Mode" msgstr "搖桿除錯模式" -#: selfdrive/ui/widgets/ssh_key.py:29 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "LOADING" msgstr "載入中" -#: selfdrive/ui/layouts/sidebar.py:48 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "LTE" msgstr "LTE" -#: selfdrive/ui/layouts/settings/developer.py:64 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "Lateral Maneuver Mode" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "Longitudinal Maneuver Mode" msgstr "縱向操作模式" -#: selfdrive/ui/onroad/hud_renderer.py:148 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "MAX" msgstr "最大" -#: selfdrive/ui/widgets/setup.py:75 -#, python-format -msgid "" -"Maximize your training data uploads to improve openpilot's driving models." +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Maximize your training data uploads to improve openpilot's driving models." msgstr "最大化上傳訓練資料,以改進 openpilot 的駕駛模型。" -#: selfdrive/ui/layouts/settings/device.py:59 -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "N/A" msgstr "無" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "NO" msgstr "否" -#: selfdrive/ui/layouts/settings/settings.py:63 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Network" msgstr "網路" -#: selfdrive/ui/widgets/ssh_key.py:114 -#, python-format -msgid "No SSH keys found" -msgstr "找不到 SSH 金鑰" - -#: selfdrive/ui/widgets/ssh_key.py:126 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "No SSH keys found for user '{}'" msgstr "找不到使用者 '{}' 的 SSH 金鑰" -#: selfdrive/ui/widgets/offroad_alerts.py:320 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "No release notes available." msgstr "無可用發行說明。" -#: selfdrive/ui/layouts/sidebar.py:73 selfdrive/ui/layouts/sidebar.py:134 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "OFFLINE" msgstr "離線" -#: system/ui/widgets/html_render.py:263 system/ui/widgets/confirm_dialog.py:93 -#: selfdrive/ui/layouts/sidebar.py:127 -#, python-format +#: openpilot/selfdrive/ui/layouts/sidebar.py +#: system/ui/widgets/confirm_dialog.py +#: system/ui/widgets/html_render.py msgid "OK" msgstr "確定" -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:136 -#: selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "ONLINE" msgstr "線上" -#: selfdrive/ui/widgets/setup.py:20 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Open" msgstr "開啟" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PAIR" msgstr "配對" -#: selfdrive/ui/layouts/sidebar.py:142 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "PANDA" msgstr "PANDA" -#: selfdrive/ui/layouts/settings/device.py:62 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "PREVIEW" msgstr "預覽" -#: selfdrive/ui/widgets/prime.py:44 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "PRIME FEATURES:" msgstr "PRIME 功能:" -#: selfdrive/ui/layouts/settings/device.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Pair Device" msgstr "配對裝置" -#: selfdrive/ui/widgets/setup.py:19 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Pair device" msgstr "配對裝置" -#: selfdrive/ui/widgets/pairing_dialog.py:103 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Pair your GitHub account" +msgstr "" + +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "Pair your device to your comma account" msgstr "將裝置配對至您的 comma 帳號" -#: selfdrive/ui/widgets/setup.py:48 selfdrive/ui/layouts/settings/device.py:24 -#, python-format -msgid "" -"Pair your device with comma connect (connect.comma.ai) and claim your comma " -"prime offer." -msgstr "" -"將裝置與 comma connect(connect.comma.ai)配對,領取您的 comma prime 優惠。" +#: openpilot/selfdrive/ui/layouts/settings/device.py +#: openpilot/selfdrive/ui/widgets/setup.py +msgid "Pair your device with comma connect (connect.comma.ai) and claim your comma prime offer." +msgstr "將裝置與 comma connect(connect.comma.ai)配對,領取您的 comma prime 優惠。" -#: selfdrive/ui/widgets/setup.py:91 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "Please connect to Wi-Fi to complete initial pairing" msgstr "請連線至 Wi‑Fi 以完成初始化配對" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:187 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Power Off" msgstr "關機" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered Wi-Fi connection" msgstr "在計量制 Wi‑Fi 連線時避免大量上傳" -#: system/ui/widgets/network.py:135 -#, python-format +#: system/ui/widgets/network.py msgid "Prevent large data uploads when on a metered cellular connection" msgstr "在計量制行動網路時避免大量上傳" -#: selfdrive/ui/layouts/settings/device.py:25 -msgid "" -"Preview the driver facing camera to ensure that driver monitoring has good " -"visibility. (vehicle must be off)" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Preview the driver facing camera to ensure that driver monitoring has good visibility. (vehicle must be off)" msgstr "預覽車內鏡頭以確保駕駛監控視野良好。(車輛須熄火)" -#: selfdrive/ui/widgets/pairing_dialog.py:161 -#, python-format +#: openpilot/selfdrive/ui/widgets/pairing_dialog.py msgid "QR Code Error" msgstr "QR 碼錯誤" -#: selfdrive/ui/widgets/ssh_key.py:31 +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "REMOVE" msgstr "移除" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "RESET" msgstr "重設" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "REVIEW" msgstr "檢視" -#: selfdrive/ui/layouts/settings/device.py:55 -#: selfdrive/ui/layouts/settings/device.py:175 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Re-enter the \"sunnylink\" panel to verify sponsorship status" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reboot" msgstr "重新啟動" -#: selfdrive/ui/onroad/alert_renderer.py:66 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Reboot Device" msgstr "重新啟動裝置" -#: selfdrive/ui/widgets/offroad_alerts.py:112 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Reboot and Update" msgstr "重新啟動並更新" -#: selfdrive/ui/layouts/settings/toggles.py:27 -msgid "" -"Receive alerts to steer back into the lane when your vehicle drifts over a " -"detected lane line without a turn signal activated while driving over 31 mph " -"(50 km/h)." -msgstr "" -"當車輛以超過 31 mph(50 km/h)行駛且未打方向燈越過偵測到的車道線時,接收轉向" -"回車道的警示。" - -#: selfdrive/ui/layouts/settings/toggles.py:76 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Driver Camera" msgstr "錄製並上傳車內鏡頭" -#: selfdrive/ui/layouts/settings/toggles.py:82 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Record and Upload Microphone Audio" msgstr "錄製並上傳麥克風音訊" -#: selfdrive/ui/layouts/settings/toggles.py:33 -msgid "" -"Record and store microphone audio while driving. The audio will be included " -"in the dashcam video in comma connect." -msgstr "" -"行車時錄製並儲存麥克風音訊。音訊將包含在 comma connect 的行車紀錄影片中。" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Record and store microphone audio while driving. The audio will be included in the dashcam video in comma connect." +msgstr "行車時錄製並儲存麥克風音訊。音訊將包含在 comma connect 的行車紀錄影片中。" -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Regulatory" msgstr "法規" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Relaxed" msgstr "從容" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote access" msgstr "遠端存取" -#: selfdrive/ui/widgets/prime.py:47 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Remote snapshots" msgstr "遠端擷圖" -#: selfdrive/ui/widgets/ssh_key.py:123 -#, python-format +#: openpilot/selfdrive/ui/widgets/ssh_key.py msgid "Request timed out" msgstr "要求逾時" -#: selfdrive/ui/layouts/settings/device.py:119 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset" msgstr "重設" -#: selfdrive/ui/layouts/settings/device.py:51 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Reset Calibration" msgstr "重設校正" -#: selfdrive/ui/layouts/settings/device.py:65 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Review Training Guide" msgstr "檢視訓練指南" -#: selfdrive/ui/layouts/settings/device.py:27 -msgid "Review the rules, features, and limitations of openpilot" -msgstr "檢視 openpilot 的規則、功能與限制" +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "Review the rules, features, and limitations of sunnypilot" +msgstr "" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "SELECT" msgstr "選取" -#: selfdrive/ui/layouts/settings/developer.py:53 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py msgid "SSH Keys" msgstr "SSH 金鑰" -#: system/ui/widgets/network.py:310 -#, python-format +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to login to your GitHub account" +msgstr "" + +#: system/ui/sunnypilot/widgets/sunnylink_pairing_dialog.py +msgid "Scan the QR code to visit sunnyhaibin's GitHub Sponsors page" +msgstr "" + +#: system/ui/widgets/network.py msgid "Scanning Wi-Fi networks..." msgstr "正在掃描 Wi‑Fi 網路…" -#: system/ui/widgets/option_dialog.py:36 -#, python-format +#: system/ui/sunnypilot/widgets/tree_dialog.py +msgid "Search" +msgstr "" + +#: system/ui/widgets/option_dialog.py msgid "Select" msgstr "選取" -#: selfdrive/ui/layouts/settings/software.py:183 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Select a branch" msgstr "選取分支" -#: selfdrive/ui/layouts/settings/device.py:91 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Select a language" msgstr "選取語言" -#: selfdrive/ui/layouts/settings/device.py:60 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "Serial" msgstr "序號" -#: selfdrive/ui/widgets/offroad_alerts.py:106 -#, python-format +#: openpilot/selfdrive/ui/widgets/offroad_alerts.py msgid "Snooze Update" msgstr "延後更新" -#: selfdrive/ui/layouts/settings/settings.py:65 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Software" msgstr "軟體" -#: selfdrive/ui/layouts/settings/toggles.py:98 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Standard" msgstr "標準" -#: selfdrive/ui/layouts/settings/toggles.py:22 -msgid "" -"Standard is recommended. In aggressive mode, openpilot will follow lead cars " -"closer and be more aggressive with the gas and brake. In relaxed mode " -"openpilot will stay further away from lead cars. On supported cars, you can " -"cycle through these personalities with your steering wheel distance button." -msgstr "" -"建議使用標準模式。積極模式下,openpilot 會更貼近前車,油門與煞車反應更積極;" -"從容模式下,會與前車保持更遠距離。於支援車款,可用方向盤距離按鈕切換這些風" -"格。" - -#: selfdrive/ui/onroad/alert_renderer.py:59 -#: selfdrive/ui/onroad/alert_renderer.py:65 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "System Unresponsive" msgstr "系統無回應" -#: selfdrive/ui/onroad/alert_renderer.py:58 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "TAKE CONTROL IMMEDIATELY" msgstr "請立刻接手控制" -#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125 -#: selfdrive/ui/layouts/sidebar.py:127 selfdrive/ui/layouts/sidebar.py:129 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "TEMP" msgstr "溫度" -#: selfdrive/ui/layouts/settings/software.py:61 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Target Branch" msgstr "目標分支" -#: system/ui/widgets/network.py:124 -#, python-format +#: system/ui/widgets/network.py msgid "Tethering Password" msgstr "網路共享密碼" -#: selfdrive/ui/layouts/settings/settings.py:64 +#: openpilot/selfdrive/ui/layouts/settings/settings.py msgid "Toggles" msgstr "切換" -#: selfdrive/ui/layouts/settings/software.py:72 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "UI Debug Mode" +msgstr "介面除錯模式" + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "UNINSTALL" msgstr "解除安裝" -#: selfdrive/ui/layouts/home.py:155 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "UPDATE" msgstr "更新" -#: selfdrive/ui/layouts/settings/software.py:72 -#: selfdrive/ui/layouts/settings/software.py:163 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Uninstall" msgstr "解除安裝" -#: selfdrive/ui/layouts/sidebar.py:117 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Unknown" msgstr "未知" -#: selfdrive/ui/layouts/settings/software.py:48 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "Updates are only downloaded while the car is off." msgstr "僅在車輛熄火時下載更新。" -#: selfdrive/ui/widgets/prime.py:33 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "Upgrade Now" msgstr "立即升級" -#: selfdrive/ui/layouts/settings/toggles.py:31 -msgid "" -"Upload data from the driver facing camera and help improve the driver " -"monitoring algorithm." +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "Upload data from the driver facing camera and help improve the driver monitoring algorithm." msgstr "上傳車內鏡頭資料,協助改善駕駛監控演算法。" -#: selfdrive/ui/layouts/settings/toggles.py:88 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/toggles.py msgid "Use Metric System" msgstr "使用公制" -#: selfdrive/ui/layouts/settings/toggles.py:17 -msgid "" -"Use the openpilot system for adaptive cruise control and lane keep driver " -"assistance. Your attention is required at all times to use this feature." -msgstr "" -"使用 openpilot 進行 ACC 與車道維持輔助。使用此功能時,您必須始終保持專注。" - -#: selfdrive/ui/layouts/sidebar.py:72 selfdrive/ui/layouts/sidebar.py:144 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "VEHICLE" msgstr "車輛" -#: selfdrive/ui/layouts/settings/device.py:67 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "VIEW" msgstr "檢視" -#: selfdrive/ui/onroad/alert_renderer.py:52 -#, python-format +#: openpilot/selfdrive/ui/onroad/alert_renderer.py msgid "Waiting to start" msgstr "等待開始" -#: selfdrive/ui/layouts/settings/developer.py:19 -msgid "" -"Warning: This grants SSH access to all public keys in your GitHub settings. " -"Never enter a GitHub username other than your own. A comma employee will " -"NEVER ask you to add their GitHub username." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "Welcome to sunnypilot" msgstr "" -"警告:這將授予對您 GitHub 設定中所有公開金鑰的 SSH 存取權。請勿輸入非您本人" -"的 GitHub 帳號。comma 員工絕不會要求您新增他們的帳號。" -#: selfdrive/ui/layouts/onboarding.py:111 -#, python-format -msgid "Welcome to openpilot" -msgstr "歡迎使用 openpilot" +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "When enabled, pressing the accelerator pedal will disengage sunnypilot." +msgstr "" -#: selfdrive/ui/layouts/settings/toggles.py:20 -msgid "When enabled, pressing the accelerator pedal will disengage openpilot." -msgstr "啟用後,踩下加速踏板將會脫離 openpilot。" - -#: selfdrive/ui/layouts/sidebar.py:44 +#: openpilot/selfdrive/ui/layouts/sidebar.py msgid "Wi-Fi" msgstr "Wi‑Fi" -#: system/ui/widgets/network.py:144 -#, python-format +#: system/ui/widgets/network.py msgid "Wi-Fi Network Metered" msgstr "Wi‑Fi 計量網路" -#: system/ui/widgets/network.py:314 -#, python-format +#: system/ui/widgets/network.py msgid "Wrong password" msgstr "密碼錯誤" -#: selfdrive/ui/layouts/onboarding.py:145 -#, python-format -msgid "You must accept the Terms and Conditions in order to use openpilot." -msgstr "您必須接受條款與細則才能使用 openpilot。" - -#: selfdrive/ui/layouts/onboarding.py:112 -#, python-format -msgid "" -"You must accept the Terms and Conditions to use openpilot. Read the latest " -"terms at https://comma.ai/terms before continuing." +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service in order to use sunnypilot." msgstr "" -"您必須接受條款與細則才能使用 openpilot。繼續前請閱讀 https://comma.ai/terms " -"上的最新條款。" -#: selfdrive/ui/onroad/driver_camera_dialog.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/onboarding.py +msgid "You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing." +msgstr "" + +#: openpilot/selfdrive/ui/onroad/driver_camera_dialog.py msgid "camera starting" msgstr "相機啟動中" -#: selfdrive/ui/widgets/prime.py:63 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "checking..." +msgstr "檢查中..." + +#: openpilot/selfdrive/ui/widgets/prime.py msgid "comma prime" msgstr "comma prime" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "default" msgstr "預設" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "down" msgstr "下" -#: selfdrive/ui/layouts/settings/software.py:106 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "downloading..." +msgstr "下載中..." + +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "failed to check for update" msgstr "檢查更新失敗" -#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py +msgid "finalizing update..." +msgstr "正在完成更新..." + +#: system/ui/widgets/network.py msgid "for \"{}\"" msgstr "適用於「{}」" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "km/h" msgstr "km/h" -#: system/ui/widgets/network.py:204 -#, python-format +#: system/ui/widgets/network.py msgid "leave blank for automatic configuration" msgstr "留空以自動設定" -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "left" msgstr "左" -#: system/ui/widgets/network.py:142 -#, python-format +#: system/ui/widgets/network.py msgid "metered" msgstr "計量" -#: selfdrive/ui/onroad/hud_renderer.py:177 -#, python-format +#: openpilot/selfdrive/ui/onroad/hud_renderer.py msgid "mph" msgstr "mph" -#: selfdrive/ui/layouts/settings/software.py:20 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "never" msgstr "從不" -#: selfdrive/ui/layouts/settings/software.py:31 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "now" msgstr "現在" -#: selfdrive/ui/layouts/settings/developer.py:71 -#, python-format -msgid "openpilot Longitudinal Control (Alpha)" -msgstr "openpilot 縱向控制(Alpha)" - -#: selfdrive/ui/onroad/alert_renderer.py:51 -#, python-format -msgid "openpilot Unavailable" -msgstr "openpilot 無法使用" - -#: selfdrive/ui/layouts/settings/toggles.py:158 -#, python-format -msgid "" -"openpilot defaults to driving in chill mode. Experimental mode enables alpha-" -"level features that aren't ready for chill mode. Experimental features are " -"listed below:

End-to-End Longitudinal Control


Let the driving " -"model control the gas and brakes. openpilot will drive as it thinks a human " -"would, including stopping for red lights and stop signs. Since the driving " -"model decides the speed to drive, the set speed will only act as an upper " -"bound. This is an alpha quality feature; mistakes should be expected." -"

New Driving Visualization


The driving visualization will " -"transition to the road-facing wide-angle camera at low speeds to better show " -"some turns. The Experimental mode logo will also be shown in the top right " -"corner." -msgstr "" -"openpilot 預設以安穩模式行駛。實驗模式啟用尚未準備好進入安穩模式的 Alpha 等級" -"功能。實驗功能如下:

端到端縱向控制


讓駕駛模型控制油門與煞車。" -"openpilot 會如同人類駕駛般行駛,包括在紅燈與停車標誌前停車。由於駕駛模型決定" -"行駛速度,設定速度僅作為上限。此為 Alpha 品質功能;預期會有失誤。

全新" -"駕駛視覺化


在低速時,駕駛視覺化將切換至面向道路的廣角鏡頭以更好呈現部" -"分轉彎。右上角亦會顯示實驗模式圖示。" - -#: selfdrive/ui/layouts/settings/device.py:165 -#, python-format -msgid "" -"openpilot is continuously calibrating, resetting is rarely required. " -"Resetting calibration will restart openpilot if the car is powered on." -msgstr "" -"openpilot 會持續校正,通常不需重設。若車輛通電,重設校正將重新啟動 " -"openpilot。" - -#: selfdrive/ui/layouts/settings/firehose.py:20 -msgid "" -"openpilot learns to drive by watching humans, like you, drive.\n" -"\n" -"Firehose Mode allows you to maximize your training data uploads to improve " -"openpilot's driving models. More data means bigger models, which means " -"better Experimental Mode." -msgstr "" -"openpilot 透過觀察人類(也就是您)的駕駛方式來學習。\n" -"\n" -"Firehose 模式可讓您最大化上傳訓練資料,以改進 openpilot 的駕駛模型。更多資料" -"代表更大的模型,也就代表更好的實驗模式。" - -#: selfdrive/ui/layouts/settings/toggles.py:183 -#, python-format -msgid "openpilot longitudinal control may come in a future update." -msgstr "openpilot 縱向控制可能於未來更新提供。" - -#: selfdrive/ui/layouts/settings/device.py:26 -msgid "" -"openpilot requires the device to be mounted within 4° left or right and " -"within 5° up or 9° down." -msgstr "openpilot 要求裝置安裝在左右 4°、上 5° 或下 9° 以內。" - -#: selfdrive/ui/layouts/settings/device.py:134 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "right" msgstr "右" -#: system/ui/widgets/network.py:142 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/developer.py +msgid "sunnypilot Longitudinal Control (Alpha)" +msgstr "" + +#: openpilot/selfdrive/ui/onroad/alert_renderer.py +msgid "sunnypilot Unavailable" +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/toggles.py +msgid "sunnypilot longitudinal control may come in a future update." +msgstr "" + +#: openpilot/selfdrive/ui/layouts/settings/device.py +msgid "sunnypilot requires the device to be mounted within 4° left or right and within 5° up or 9° down." +msgstr "" + +#: system/ui/widgets/network.py msgid "unmetered" msgstr "不限流量" -#: selfdrive/ui/layouts/settings/device.py:133 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/device.py msgid "up" msgstr "上" -#: selfdrive/ui/layouts/settings/software.py:117 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked never" msgstr "已為最新,最後檢查:從未" -#: selfdrive/ui/layouts/settings/software.py:115 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "up to date, last checked {}" msgstr "已為最新,最後檢查:{}" -#: selfdrive/ui/layouts/settings/software.py:109 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "update available" msgstr "有可用更新" -#: selfdrive/ui/layouts/home.py:169 -#, python-format +#: openpilot/selfdrive/ui/layouts/home.py msgid "{} ALERT" msgid_plural "{} ALERTS" msgstr[0] "{} 則警示" msgstr[1] "{} 則警示" -#: selfdrive/ui/layouts/settings/software.py:40 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} day ago" msgid_plural "{} days ago" msgstr[0] "{} 天前" msgstr[1] "{} 天前" -#: selfdrive/ui/layouts/settings/software.py:37 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} hour ago" msgid_plural "{} hours ago" msgstr[0] "{} 小時前" msgstr[1] "{} 小時前" -#: selfdrive/ui/layouts/settings/software.py:34 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/software.py msgid "{} minute ago" msgid_plural "{} minutes ago" msgstr[0] "{} 分鐘前" msgstr[1] "{} 分鐘前" -#: selfdrive/ui/layouts/settings/firehose.py:111 -#, python-format +#: openpilot/selfdrive/ui/layouts/settings/firehose.py msgid "{} segment of your driving is in the training dataset so far." msgid_plural "{} segments of your driving is in the training dataset so far." msgstr[0] "目前已有 {} 個您的駕駛片段納入訓練資料集。" msgstr[1] "目前已有 {} 個您的駕駛片段納入訓練資料集。" -#: selfdrive/ui/widgets/prime.py:62 -#, python-format +#: openpilot/selfdrive/ui/widgets/prime.py msgid "✓ SUBSCRIBED" msgstr "✓ 已訂閱" -#: selfdrive/ui/widgets/setup.py:22 -#, python-format +#: openpilot/selfdrive/ui/widgets/setup.py msgid "🔥 Firehose Mode 🔥" msgstr "🔥 Firehose 模式 🔥" + diff --git a/selfdrive/ui/translations/auto_translate.py b/selfdrive/ui/translations/auto_translate.py deleted file mode 100755 index 9354790f94..0000000000 --- a/selfdrive/ui/translations/auto_translate.py +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import json -import os -import pathlib -import xml.etree.ElementTree as ET -from typing import cast - -import requests - -TRANSLATIONS_DIR = pathlib.Path(__file__).resolve().parent -TRANSLATIONS_LANGUAGES = TRANSLATIONS_DIR / "languages.json" - -OPENAI_MODEL = "gpt-4" -OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY") -OPENAI_PROMPT = "You are a professional translator from English to {language} (ISO 639 language code). " + \ - "The following sentence or word is in the GUI of a software called openpilot, translate it accordingly." - - -def get_language_files(languages: list[str] | None = None) -> dict[str, pathlib.Path]: - files = {} - - with open(TRANSLATIONS_LANGUAGES) as fp: - language_dict = json.load(fp) - - for filename in language_dict.values(): - path = TRANSLATIONS_DIR / f"{filename}.ts" - language = path.stem - - if languages is None or language in languages: - files[language] = path - - return files - - -def translate_phrase(text: str, language: str) -> str: - response = requests.post( - "https://api.openai.com/v1/chat/completions", - json={ - "model": OPENAI_MODEL, - "messages": [ - { - "role": "system", - "content": OPENAI_PROMPT.format(language=language), - }, - { - "role": "user", - "content": text, - }, - ], - "temperature": 0.8, - "max_tokens": 1024, - "top_p": 1, - }, - headers={ - "Authorization": f"Bearer {OPENAI_API_KEY}", - "Content-Type": "application/json", - }, - ) - - if 400 <= response.status_code < 600: - raise requests.HTTPError(f'Error {response.status_code}: {response.json()}', response=response) - - data = response.json() - - return cast(str, data["choices"][0]["message"]["content"]) - - -def translate_file(path: pathlib.Path, language: str, all_: bool) -> None: - tree = ET.parse(path) - - root = tree.getroot() - - for context in root.findall("./context"): - name = context.find("name") - if name is None: - raise ValueError("name not found") - - print(f"Context: {name.text}") - - for message in context.findall("./message"): - source = message.find("source") - translation = message.find("translation") - - if source is None or translation is None: - raise ValueError("source or translation not found") - - if not all_ and translation.attrib.get("type") != "unfinished": - continue - - llm_translation = translate_phrase(cast(str, source.text), language) - - print(f"Source: {source.text}\n" + - f"Current translation: {translation.text}\n" + - f"LLM translation: {llm_translation}") - - translation.text = llm_translation - - with path.open("w", encoding="utf-8") as fp: - fp.write('\n' + - '\n' + - ET.tostring(root, encoding="utf-8").decode()) - - -def main(): - arg_parser = argparse.ArgumentParser("Auto translate") - - group = arg_parser.add_mutually_exclusive_group(required=True) - group.add_argument("-a", "--all-files", action="store_true", help="Translate all files") - group.add_argument("-f", "--file", nargs="+", help="Translate the selected files. (Example: -f fr de)") - - arg_parser.add_argument("-t", "--all-translations", action="store_true", default=False, help="Translate all sections. (Default: only unfinished)") - - args = arg_parser.parse_args() - - if OPENAI_API_KEY is None: - print("OpenAI API key is missing. (Hint: use `export OPENAI_API_KEY=YOUR-KEY` before you run the script).\n" + - "If you don't have one go to: https://beta.openai.com/account/api-keys.") - exit(1) - - files = get_language_files(None if args.all_files else args.file) - - if args.file: - missing_files = set(args.file) - set(files) - if len(missing_files): - print(f"No language files found: {missing_files}") - exit(1) - - print(f"Translation mode: {'all' if args.all_translations else 'only unfinished'}. Files: {list(files)}") - - for lang, path in files.items(): - print(f"Translate {lang} ({path})") - translate_file(path, lang, args.all_translations) - - -if __name__ == "__main__": - main() diff --git a/selfdrive/ui/translations/auto_translate.sh b/selfdrive/ui/translations/auto_translate.sh new file mode 100755 index 0000000000..03a207ca3c --- /dev/null +++ b/selfdrive/ui/translations/auto_translate.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd)" +ROOT="$DIR/../../../" + +cd $DIR +./update_translations.py + +command -v codex >/dev/null || { + echo "Install codex CLI to continue:" + echo "-> https://developers.openai.com/codex/cli" + echo + exit 1 +} + +codex exec --cd "$ROOT" -c 'model_reasoning_effort="low"' --dangerously-bypass-approvals-and-sandbox "$(cat < 2: - has_content = True - break - # End of entry - if stripped.startswith(('msgid', '#')) or not stripped: - break - - if not has_content: - unfinished_translations += 1 - - return (total_translations, unfinished_translations) - -if __name__ == "__main__": - with open(LANGUAGES_FILE) as f: - translation_files = json.load(f) - - badge_svg = [] - max_badge_width = 0 # keep track of max width to set parent element - for idx, (name, file) in enumerate(translation_files.items()): - po_file_path = os.path.join(str(TRANSLATIONS_DIR), f"app_{file}.po") - - total_translations, unfinished_translations = parse_po_file(po_file_path) - - percent_finished = int(100 - (unfinished_translations / total_translations * 100.)) if total_translations > 0 else 0 - color = f"rgb{(94, 188, 0) if percent_finished == 100 else (248, 255, 50) if percent_finished > 90 else (204, 55, 27)}" - - # Download badge - badge_label = f"LANGUAGE {name}" - badge_message = f"{percent_finished}% complete" - if unfinished_translations != 0: - badge_message += f" ({unfinished_translations} unfinished)" - - r = requests.get(f"{SHIELDS_URL}/{badge_label}-{badge_message}-{color}", timeout=10) - assert r.status_code == 200, "Error downloading badge" - content_svg = r.content.decode("utf-8") - - xml = ET.fromstring(content_svg) - assert "width" in xml.attrib - max_badge_width = max(max_badge_width, int(xml.attrib["width"])) - - # Make tag ids in each badge unique to combine them into one svg - for tag in ("r", "s"): - content_svg = content_svg.replace(f'id="{tag}"', f'id="{tag}{idx}"') - content_svg = content_svg.replace(f'"url(#{tag})"', f'"url(#{tag}{idx})"') - - badge_svg.extend([f'', content_svg, ""]) - - badge_svg.insert(0, '') - badge_svg.append("") - - with open(os.path.join(BASEDIR, "translation_badge.svg"), "w") as badge_f: - badge_f.write("\n".join(badge_svg)) diff --git a/selfdrive/ui/translations/potools.py b/selfdrive/ui/translations/potools.py index 7571cccdd6..ac4dafb988 100644 --- a/selfdrive/ui/translations/potools.py +++ b/selfdrive/ui/translations/potools.py @@ -8,7 +8,6 @@ import ast import os import re from dataclasses import dataclass, field -from datetime import UTC, datetime from pathlib import Path @@ -165,18 +164,18 @@ def write_po(path: str | Path, header: POEntry | None, entries: list[POEntry]) - if header: for c in header.comments: f.write(c + '\n') - if header.flags: - f.write('#, ' + ', '.join(header.flags) + '\n') f.write(f'msgid {_quote("")}\n') f.write(f'msgstr {_quote(header.msgstr)}\n\n') for entry in entries: for c in entry.comments: f.write(c + '\n') - for ref in entry.source_refs: + # Keep file-level context for translators, but drop line numbers to + # avoid churning PO diffs on unrelated code edits. + source_files = sorted({ref.rsplit(':', 1)[0] for ref in entry.source_refs}) + for ref in source_files: f.write(f'#: {ref}\n') - if entry.flags: - f.write('#, ' + ', '.join(entry.flags) + '\n') + # Runtime loading ignores gettext flags; omit them to reduce noise. f.write(f'msgid {_quote(entry.msgid)}\n') if entry.is_plural: f.write(f'msgid_plural {_quote(entry.msgid_plural)}\n') @@ -256,31 +255,24 @@ def extract_strings(files: list[str], basedir: str) -> list[POEntry]: # ──── POT generation ──── +def _build_pot_header() -> POEntry: + return POEntry( + msgstr='Content-Type: text/plain; charset=UTF-8\n', + ) + + +def _build_po_header(language: str) -> POEntry: + plural_forms = PLURAL_FORMS.get(language, 'nplurals=2; plural=(n != 1);') + return POEntry( + msgstr='Content-Type: text/plain; charset=UTF-8\n' + + f'Language: {language}\n' + + f'Plural-Forms: {plural_forms}\n', + ) + + def generate_pot(entries: list[POEntry], pot_path: str | Path) -> None: """Generate a .pot template file from extracted entries.""" - now = datetime.now(UTC).strftime('%Y-%m-%d %H:%M%z') - header = POEntry( - comments=[ - '# SOME DESCRIPTIVE TITLE.', - "# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER", - '# This file is distributed under the same license as the PACKAGE package.', - '# FIRST AUTHOR , YEAR.', - '#', - ], - flags=['fuzzy'], - msgstr='Project-Id-Version: PACKAGE VERSION\n' + - 'Report-Msgid-Bugs-To: \n' + - f'POT-Creation-Date: {now}\n' + - 'PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n' + - 'Last-Translator: FULL NAME \n' + - 'Language-Team: LANGUAGE \n' + - 'Language: \n' + - 'MIME-Version: 1.0\n' + - 'Content-Type: text/plain; charset=UTF-8\n' + - 'Content-Transfer-Encoding: 8bit\n' + - 'Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n', - ) - write_po(pot_path, header, entries) + write_po(pot_path, _build_pot_header(), entries) # ──── PO init (replaces msginit) ──── @@ -305,43 +297,22 @@ def init_po(pot_path: str | Path, po_path: str | Path, language: str) -> None: """Create a new .po file from a .pot template (replaces msginit).""" _, entries = parse_po(pot_path) plural_forms = PLURAL_FORMS.get(language, 'nplurals=2; plural=(n != 1);') - now = datetime.now(UTC).strftime('%Y-%m-%d %H:%M%z') - - header = POEntry( - comments=[ - f'# {language} translations for PACKAGE package.', - "# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER", - '# This file is distributed under the same license as the PACKAGE package.', - '# Automatically generated.', - '#', - ], - msgstr='Project-Id-Version: PACKAGE VERSION\n' + - 'Report-Msgid-Bugs-To: \n' + - f'POT-Creation-Date: {now}\n' + - f'PO-Revision-Date: {now}\n' + - 'Last-Translator: Automatically generated\n' + - 'Language-Team: none\n' + - f'Language: {language}\n' + - 'MIME-Version: 1.0\n' + - 'Content-Type: text/plain; charset=UTF-8\n' + - 'Content-Transfer-Encoding: 8bit\n' + - f'Plural-Forms: {plural_forms}\n', - ) nplurals = int(re.search(r'nplurals=(\d+)', plural_forms).group(1)) for e in entries: if e.is_plural: e.msgstr_plural = dict.fromkeys(range(nplurals), '') - write_po(po_path, header, entries) + write_po(po_path, _build_po_header(language), entries) # ──── PO merge (replaces msgmerge) ──── def merge_po(po_path: str | Path, pot_path: str | Path) -> None: """Update a .po file with entries from a .pot template (replaces msgmerge --update).""" - po_header, po_entries = parse_po(po_path) + _, po_entries = parse_po(po_path) _, pot_entries = parse_po(pot_path) + language = Path(po_path).stem.removeprefix("app_") existing = {e.msgid: e for e in po_entries} merged = [] @@ -359,4 +330,4 @@ def merge_po(po_path: str | Path, pot_path: str | Path) -> None: merged.append(pot_e) merged.sort(key=lambda e: e.msgid) - write_po(po_path, po_header, merged) + write_po(po_path, _build_po_header(language), merged) diff --git a/selfdrive/ui/update_translations.py b/selfdrive/ui/translations/update_translations.py similarity index 100% rename from selfdrive/ui/update_translations.py rename to selfdrive/ui/translations/update_translations.py diff --git a/selfdrive/ui/widgets/exp_mode_button.py b/selfdrive/ui/widgets/exp_mode_button.py index faa3bf877f..0b5bff7da4 100644 --- a/selfdrive/ui/widgets/exp_mode_button.py +++ b/selfdrive/ui/widgets/exp_mode_button.py @@ -20,6 +20,7 @@ class ExperimentalModeButton(Widget): self.experimental_pixmap = gui_app.texture("icons/experimental_grey.png", self.img_width, self.img_width) def show_event(self): + super().show_event() self.experimental_mode = self.params.get_bool("ExperimentalMode") def _get_gradient_colors(self): diff --git a/selfdrive/ui/widgets/offroad_alerts.py b/selfdrive/ui/widgets/offroad_alerts.py index a87727e27f..110ca714a9 100644 --- a/selfdrive/ui/widgets/offroad_alerts.py +++ b/selfdrive/ui/widgets/offroad_alerts.py @@ -118,6 +118,7 @@ class AbstractAlert(Widget, ABC): self.scroll_panel = GuiScrollPanel() def show_event(self): + super().show_event() self.scroll_panel.set_offset(0) def set_dismiss_callback(self, callback: Callable): diff --git a/selfdrive/ui/widgets/ssh_key.py b/selfdrive/ui/widgets/ssh_key.py index b31a9eb3bd..b3ff5c1711 100644 --- a/selfdrive/ui/widgets/ssh_key.py +++ b/selfdrive/ui/widgets/ssh_key.py @@ -1,7 +1,6 @@ import pyray as rl import requests import threading -import copy from collections.abc import Callable from enum import Enum @@ -25,6 +24,51 @@ from openpilot.system.ui.widgets.list_view import ( VALUE_FONT_SIZE = 48 +class SshKeyFetcher: + HTTP_TIMEOUT = 15 # seconds + + def __init__(self, params: Params): + self._params = params + self._on_response: Callable[[str | None], None] | None = None + self._done: bool = False + self._error: str | None = None + + def fetch(self, username: str, on_response: Callable[[str | None], None]): + self._error = None + self._on_response = on_response + threading.Thread(target=self._fetch_thread, args=(username,), daemon=True).start() + + def update(self): + if not self._done: + return + self._done = False + if self._error is not None: + self.clear() + if self._on_response: + self._on_response(self._error) + + def clear(self): + self._params.remove("GithubUsername") + self._params.remove("GithubSshKeys") + + def _fetch_thread(self, username: str): + try: + response = requests.get(f"https://github.com/{username}.keys", timeout=self.HTTP_TIMEOUT) + response.raise_for_status() + keys = response.text.strip() + if not keys: + raise requests.exceptions.HTTPError("No SSH keys found") + + self._params.put("GithubUsername", username) + self._params.put("GithubSshKeys", keys) + except requests.exceptions.Timeout: + self._error = tr("Request timed out") + except Exception: + self._error = tr("No SSH keys found for user '{}'").format(username) + finally: + self._done = True + + class SshKeyActionState(Enum): LOADING = tr_noop("LOADING") ADD = tr_noop("ADD") @@ -32,7 +76,6 @@ class SshKeyActionState(Enum): class SshKeyAction(ItemAction): - HTTP_TIMEOUT = 15 # seconds MAX_WIDTH = 500 def __init__(self): @@ -40,7 +83,7 @@ class SshKeyAction(ItemAction): self._keyboard = Keyboard(min_text_size=1) self._params = Params() - self._error_message: str = "" + self._fetcher = SshKeyFetcher(self._params) self._text_font = gui_app.font(FontWeight.NORMAL) self._button = Button("", click_callback=self._handle_button_click, button_style=ButtonStyle.LIST_ACTION, border_radius=BUTTON_BORDER_RADIUS, font_size=BUTTON_FONT_SIZE) @@ -55,14 +98,11 @@ class SshKeyAction(ItemAction): self._username = self._params.get("GithubUsername") self._state = SshKeyActionState.REMOVE if self._params.get("GithubSshKeys") else SshKeyActionState.ADD - def _render(self, rect: rl.Rectangle) -> bool: - # Show error dialog if there's an error - if self._error_message: - message = copy.copy(self._error_message) - gui_app.push_widget(alert_dialog(message)) - self._username = "" - self._error_message = "" + def _update_state(self): + super()._update_state() + self._fetcher.update() + def _render(self, rect: rl.Rectangle) -> bool: # Draw username if exists if self._username: text_size = measure_text_cached(self._text_font, self._username, VALUE_FONT_SIZE) @@ -90,8 +130,7 @@ class SshKeyAction(ItemAction): self._keyboard.set_callback(self._on_username_submit) gui_app.push_widget(self._keyboard) elif self._state == SshKeyActionState.REMOVE: - self._params.remove("GithubUsername") - self._params.remove("GithubSshKeys") + self._fetcher.clear() self._refresh_state() def _on_username_submit(self, result: DialogResult): @@ -103,29 +142,16 @@ class SshKeyAction(ItemAction): return self._state = SshKeyActionState.LOADING - threading.Thread(target=lambda: self._fetch_ssh_key(username), daemon=True).start() + self._fetcher.fetch(username, self._on_fetch_response) - def _fetch_ssh_key(self, username: str): - try: - url = f"https://github.com/{username}.keys" - response = requests.get(url, timeout=self.HTTP_TIMEOUT) - response.raise_for_status() - keys = response.text.strip() - if not keys: - raise requests.exceptions.HTTPError(tr("No SSH keys found")) - - # Success - save keys - self._params.put("GithubUsername", username) - self._params.put("GithubSshKeys", keys) + def _on_fetch_response(self, error: str | None): + if error is None: self._state = SshKeyActionState.REMOVE - self._username = username - - except requests.exceptions.Timeout: - self._error_message = tr("Request timed out") - self._state = SshKeyActionState.ADD - except Exception: - self._error_message = tr("No SSH keys found for user '{}'").format(username) + self._username = self._params.get("GithubUsername") + else: self._state = SshKeyActionState.ADD + self._username = "" + gui_app.push_widget(alert_dialog(error)) def ssh_key_item(title: str | Callable[[], str], description: str | Callable[[], str]) -> ListItem: diff --git a/sunnypilot/mads/mads.py b/sunnypilot/mads/mads.py index 7eab55e6ea..0c87da4a2a 100644 --- a/sunnypilot/mads/mads.py +++ b/sunnypilot/mads/mads.py @@ -33,6 +33,7 @@ class ModularAssistiveDrivingSystem: self.enabled = False self.active = False self.available = False + self.lateral_mismatch_counter = 0 self.allow_always = False self.no_main_cruise = False self.selfdrive = selfdrive @@ -104,6 +105,17 @@ class ModularAssistiveDrivingSystem: self.events.remove(old_event) self.events_sp.add(new_event) + def data_sample(self): + # When the safety and selfdrived do not agree on controls_allowed_lateral + # we want to disengage sunnypilot. However the status from the panda goes through + # another socket other than the CAN messages and one can arrive earlier than the other. + # Therefore we allow a mismatch for two samples, then we trigger the disengagement. + if not self.active or self.selfdrive.enabled: + self.lateral_mismatch_counter = 0 + elif any(not ps.controlsAllowedLateral for ps in self.selfdrive.sm['pandaStates'] + if ps.safetyModel not in IGNORED_SAFETY_MODES): + self.lateral_mismatch_counter += 1 + def update_events(self, CS: structs.CarState): if not self.selfdrive.enabled and self.enabled: if CS.standstill: @@ -186,6 +198,9 @@ class ModularAssistiveDrivingSystem: if self.state_machine.state == State.paused: self.events_sp.add(EventNameSP.silentLkasEnable) + if self.lateral_mismatch_counter >= 200: + self.events_sp.add(EventNameSP.controlsMismatchLateral) + self.events.remove(EventName.pcmDisable) self.events.remove(EventName.buttonCancel) self.events.remove(EventName.pedalPressed) @@ -195,6 +210,8 @@ class ModularAssistiveDrivingSystem: if not self.enabled_toggle: return + self.data_sample() + self.update_events(CS) if not self.CP.passive and self.selfdrive.initialized: diff --git a/sunnypilot/modeld_v2/SConscript b/sunnypilot/modeld_v2/SConscript index ddf889c0c0..8526fd2729 100644 --- a/sunnypilot/modeld_v2/SConscript +++ b/sunnypilot/modeld_v2/SConscript @@ -13,7 +13,7 @@ if PC: model_dir = Dir("models").abspath cmd = f'python3 {Dir("#sunnypilot/modeld_v2").abspath}/install_models_pc.py {model_dir}' - for model_name in ['supercombo', 'driving_vision', 'driving_off_policy', 'driving_policy']: + for model_name in ['supercombo', 'driving_vision', 'driving_off_policy', 'driving_on_policy', 'driving_policy']: if File(f"models/{model_name}.onnx").exists(): inputs.append(File(f"models/{model_name}.onnx")) inputs.append(File(f"models/{model_name}_tinygrad.pkl")) @@ -42,7 +42,7 @@ def tg_compile(flags, model_name): ) # Compile models -for model_name in ['supercombo', 'driving_vision', 'driving_off_policy', 'driving_policy']: +for model_name in ['supercombo', 'driving_vision', 'driving_off_policy', 'driving_on_policy', 'driving_policy']: if File(f"models/{model_name}.onnx").exists(): tg_compile(tg_flags, model_name) diff --git a/sunnypilot/modeld_v2/fill_model_msg.py b/sunnypilot/modeld_v2/fill_model_msg.py index 57e968d02f..1f281258ba 100644 --- a/sunnypilot/modeld_v2/fill_model_msg.py +++ b/sunnypilot/modeld_v2/fill_model_msg.py @@ -100,7 +100,7 @@ def fill_model_msg(base_msg: capnp._DynamicStructBuilder, extended_msg: capnp._D fill_xyzt(modelV2.orientationRate, ModelConstants.T_IDXS, *net_output_data['plan'][0,:,Plan.ORIENTATION_RATE].T) # temporal pose - temporal_pose = modelV2.temporalPoseDEPRECATED + temporal_pose = modelV2.deprecated.temporalPose if 'sim_pose' in net_output_data: temporal_pose.trans = net_output_data['sim_pose'][0,:ModelConstants.POSE_WIDTH//2].tolist() temporal_pose.transStd = net_output_data['sim_pose_stds'][0,:ModelConstants.POSE_WIDTH//2].tolist() diff --git a/sunnypilot/modeld_v2/install_models_pc.py b/sunnypilot/modeld_v2/install_models_pc.py index d203de3487..1bba001abd 100755 --- a/sunnypilot/modeld_v2/install_models_pc.py +++ b/sunnypilot/modeld_v2/install_models_pc.py @@ -30,7 +30,7 @@ def generate_metadata_pkl(model_path, output_path): def install_models(model_dir): model_dir = Path(model_dir) - models = ["driving_off_policy", "driving_policy", "driving_vision"] + models = ["driving_off_policy", "driving_on_policy", "driving_vision"] found_models = [] for model in models: diff --git a/sunnypilot/models/fetcher.py b/sunnypilot/models/fetcher.py index 5990ee2e41..0b6853da8a 100644 --- a/sunnypilot/models/fetcher.py +++ b/sunnypilot/models/fetcher.py @@ -116,7 +116,7 @@ class ModelCache: class ModelFetcher: """Handles fetching and caching of model data from remote source""" - MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-docs/refs/heads/gh-pages/docs/driving_models_v15.json" + MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v16.json" def __init__(self, params: Params): self.params = params diff --git a/sunnypilot/models/runners/helpers.py b/sunnypilot/models/runners/helpers.py index 8f9d8fc2f5..b34a62132b 100644 --- a/sunnypilot/models/runners/helpers.py +++ b/sunnypilot/models/runners/helpers.py @@ -16,8 +16,9 @@ def get_model_runner() -> ModelRunner: bundle = get_active_bundle() if bundle and bundle.models: model_types = {m.type.raw for m in bundle.models} - # Check if the bundle uses separate vision and policy models - if ModelType.vision in model_types or ModelType.policy in model_types: + # Check if the bundle uses separate vision and policy models (legacy or new split format) + split_types = {ModelType.vision, ModelType.policy, ModelType.offPolicy, ModelType.onPolicy} + if model_types & split_types: return TinygradSplitRunner() # Otherwise, assume a single model (likely supercombo) if bundle.models: diff --git a/sunnypilot/models/runners/tinygrad/model_types.py b/sunnypilot/models/runners/tinygrad/model_types.py index 11f0965828..015adc035f 100644 --- a/sunnypilot/models/runners/tinygrad/model_types.py +++ b/sunnypilot/models/runners/tinygrad/model_types.py @@ -29,6 +29,22 @@ class OffPolicyTinygrad(ModularRunner, ABC): return result +class OnPolicyTinygrad(ModularRunner, ABC): + """ + A TinygradRunner specialized for on-policy models. + + Uses a SplitParser to handle outputs specific to the on-policy part of a split model setup. + """ + def __init__(self): + self._on_policy_parser = SplitParser() + self.parser_method_dict[ModelType.onPolicy] = self._parse_on_policy_outputs + + def _parse_on_policy_outputs(self, model_outputs: np.ndarray) -> NumpyDict: + """Parses on-policy model outputs using SplitParser.""" + result: NumpyDict = self._on_policy_parser.parse_policy_outputs(self._slice_outputs(model_outputs)) + return result + + class PolicyTinygrad(ModularRunner, ABC): """ A TinygradRunner specialized for policy-only models. diff --git a/sunnypilot/models/runners/tinygrad/tinygrad_runner.py b/sunnypilot/models/runners/tinygrad/tinygrad_runner.py index 9033c892eb..4e17bd5ead 100644 --- a/sunnypilot/models/runners/tinygrad/tinygrad_runner.py +++ b/sunnypilot/models/runners/tinygrad/tinygrad_runner.py @@ -3,14 +3,14 @@ import pickle import numpy as np from openpilot.sunnypilot.models.runners.constants import NumpyDict, ModelType, ShapeDict, CUSTOM_MODEL_PATH, SliceDict from openpilot.sunnypilot.models.runners.model_runner import ModelRunner -from openpilot.sunnypilot.models.runners.tinygrad.model_types import PolicyTinygrad, VisionTinygrad, SupercomboTinygrad, OffPolicyTinygrad +from openpilot.sunnypilot.models.runners.tinygrad.model_types import PolicyTinygrad, VisionTinygrad, SupercomboTinygrad, OffPolicyTinygrad, OnPolicyTinygrad from openpilot.sunnypilot.models.split_model_constants import SplitModelConstants from openpilot.sunnypilot.modeld_v2.constants import ModelConstants from tinygrad.tensor import Tensor -class TinygradRunner(ModelRunner, SupercomboTinygrad, PolicyTinygrad, VisionTinygrad, OffPolicyTinygrad): +class TinygradRunner(ModelRunner, SupercomboTinygrad, PolicyTinygrad, VisionTinygrad, OffPolicyTinygrad, OnPolicyTinygrad): """ A ModelRunner implementation for executing Tinygrad models. @@ -26,6 +26,7 @@ class TinygradRunner(ModelRunner, SupercomboTinygrad, PolicyTinygrad, VisionTiny PolicyTinygrad.__init__(self) VisionTinygrad.__init__(self) OffPolicyTinygrad.__init__(self) + OnPolicyTinygrad.__init__(self) self._constants = ModelConstants self._model_data = self.models.get(model_type) if not self._model_data or not self._model_data.model: @@ -98,20 +99,30 @@ class TinygradSplitRunner(ModelRunner): super().__init__() self.is_20hz_3d = True self.vision_runner = TinygradRunner(ModelType.vision) - self.policy_runner = TinygradRunner(ModelType.policy) + self.policy_runner = TinygradRunner(ModelType.policy) if self.models.get(ModelType.policy) else None self.off_policy_runner = TinygradRunner(ModelType.offPolicy) if self.models.get(ModelType.offPolicy) else None + self.on_policy_runner = TinygradRunner(ModelType.onPolicy) if self.models.get(ModelType.onPolicy) else None self._constants = SplitModelConstants def _run_model(self) -> NumpyDict: """Runs both vision and policy models and merges their parsed outputs.""" - policy_output = self.policy_runner.run_model() vision_output = self.vision_runner.run_model() - outputs = {**policy_output, **vision_output} + outputs = {**vision_output} + + if self.policy_runner: + policy_output = self.policy_runner.run_model() + outputs.update(policy_output) if self.off_policy_runner: off_policy_output = self.off_policy_runner.run_model() + if self.on_policy_runner: + off_policy_output.pop('plan', None) outputs.update(off_policy_output) + if self.on_policy_runner: + on_policy_output = self.on_policy_runner.run_model() + outputs.update(on_policy_output) + if 'planplus' in outputs and 'plan' in outputs: outputs['plan'] = outputs['plan'] + outputs['planplus'] @@ -125,31 +136,44 @@ class TinygradSplitRunner(ModelRunner): @property def input_shapes(self) -> ShapeDict: """Returns the combined input shapes from both vision and policy models.""" - shapes = {**self.policy_runner.input_shapes, **self.vision_runner.input_shapes} + shapes = {**self.vision_runner.input_shapes} + if self.policy_runner: + shapes.update(self.policy_runner.input_shapes) if self.off_policy_runner: shapes.update(self.off_policy_runner.input_shapes) + if self.on_policy_runner: + shapes.update(self.on_policy_runner.input_shapes) return shapes @property def output_slices(self) -> SliceDict: """Returns the combined output slices from both vision and policy models.""" - slices = {**self.policy_runner.output_slices, **self.vision_runner.output_slices} + slices = {**self.vision_runner.output_slices} + if self.policy_runner: + slices.update(self.policy_runner.output_slices) if self.off_policy_runner: slices.update(self.off_policy_runner.output_slices) + if self.on_policy_runner: + slices.update(self.on_policy_runner.output_slices) return slices def prepare_inputs(self, numpy_inputs: NumpyDict) -> dict: """Prepares inputs for both vision and policy models.""" - # Policy inputs only depend on numpy_inputs - self.policy_runner.prepare_policy_inputs(numpy_inputs) + if self.policy_runner: + self.policy_runner.prepare_policy_inputs(numpy_inputs) for key in self.vision_input_names: if key in self.inputs: self.vision_runner.inputs[key] = self.inputs[key].cast(self.vision_runner.input_to_dtype[key]) - inputs = {**self.policy_runner.inputs, **self.vision_runner.inputs} + inputs = {**self.vision_runner.inputs} + if self.policy_runner: + inputs.update(self.policy_runner.inputs) if self.off_policy_runner: self.off_policy_runner.prepare_policy_inputs(numpy_inputs) inputs.update(self.off_policy_runner.inputs) + if self.on_policy_runner: + self.on_policy_runner.prepare_policy_inputs(numpy_inputs) + inputs.update(self.on_policy_runner.inputs) return inputs diff --git a/sunnypilot/models/tests/model_hash b/sunnypilot/models/tests/model_hash index f363f8309a..eaf923358c 100644 --- a/sunnypilot/models/tests/model_hash +++ b/sunnypilot/models/tests/model_hash @@ -1 +1 @@ -32f57bdc91f910df1f48ddae7c59aaf6e751f9df6756da481a210577dbce8bcf \ No newline at end of file +5d4d21f1899de21137f69d74a4602c44cc5a6b04cf4e4aa9d0ec9206f8c30350 \ No newline at end of file diff --git a/sunnypilot/selfdrive/assets/icons_mici/always_offroad.png b/sunnypilot/selfdrive/assets/icons_mici/always_offroad.png new file mode 100644 index 0000000000..56f35669c6 --- /dev/null +++ b/sunnypilot/selfdrive/assets/icons_mici/always_offroad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e459241d896824f5e8207d568847acab3dedd41caae7af59d4c17e043663b0c9 +size 4035 diff --git a/sunnypilot/selfdrive/assets/icons_mici/disable_offroad.png b/sunnypilot/selfdrive/assets/icons_mici/disable_offroad.png new file mode 100644 index 0000000000..146734aafa --- /dev/null +++ b/sunnypilot/selfdrive/assets/icons_mici/disable_offroad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:27a0fca872d4586f578d246890b83674cdb7ecb03f58b2b0379b4b64a5816053 +size 3908 diff --git a/sunnypilot/selfdrive/controls/controlsd_ext.py b/sunnypilot/selfdrive/controls/controlsd_ext.py index 4b6c92ae2c..dfda5549f2 100644 --- a/sunnypilot/selfdrive/controls/controlsd_ext.py +++ b/sunnypilot/selfdrive/controls/controlsd_ext.py @@ -38,6 +38,8 @@ class ControlsExt(ModelStateBase): enforce_torque_control = self.params.get_bool("EnforceTorqueControl") torque_versions = self.params.get("TorqueControlTune") if not enforce_torque_control: + if self.CP.lateralTuning.which() == 'torque': + return LatControlTorqueV0(self.CP, self.CP_SP, CI, dt) # FIXME-SP: revert when upstream fixes tuning issues with v1 return lac if torque_versions == 0.0: # v0 diff --git a/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py index c6658bdc78..df773889a9 100644 --- a/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py +++ b/sunnypilot/selfdrive/controls/lib/latcontrol_torque_ext_base.py @@ -14,11 +14,8 @@ from openpilot.selfdrive.modeld.constants import ModelConstants LAT_PLAN_MIN_IDX = 5 LATERAL_LAG_MOD = 0.0 # seconds, modifies how far in the future we look ahead for the lateral plan -# from selfdrive/controls/lib/latcontrol_torque.py -KP = 0.8 -KI = 0.15 -INTERP_SPEEDS = [1, 1.5, 2.0, 3.0, 5, 7.5, 10, 15, 30] -KP_INTERP = [250, 120, 65, 30, 11.5, 5.5, 3.5, 2.0, KP] +KP = 1.0 +KI = 0.3 def get_predicted_lateral_jerk(lat_accels, t_diffs): @@ -61,9 +58,10 @@ class LatControlTorqueExtBase: self.lookahead_lateral_jerk: float = 0.0 self.torque_from_lateral_accel_in_torque_space = CI.torque_from_lateral_accel_in_torque_space() + self.torque_params = lac_torque.torque_params self._ff = 0.0 - self._pid = PIDController([INTERP_SPEEDS, KP_INTERP], KI) + self._pid = PIDController(KP, KI) self._pid_log = None self._setpoint = 0.0 self._measurement = 0.0 diff --git a/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py b/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py index 2db88299ca..1738a11e49 100644 --- a/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py +++ b/sunnypilot/selfdrive/controls/lib/nnlc/nnlc.py @@ -75,14 +75,14 @@ class NeuralNetworkLateralControl(LatControlTorqueExtBase): def update_feedforward_torque_space(self, CS): torque_from_setpoint = self.torque_from_lateral_accel_in_torque_space(LatControlInputs(self._setpoint, self._roll_compensation, CS.vEgo, CS.aEgo), - self.lac_torque.torque_params, gravity_adjusted=False) + self.torque_params, gravity_adjusted=False) torque_from_measurement = self.torque_from_lateral_accel_in_torque_space(LatControlInputs(self._measurement, self._roll_compensation, CS.vEgo, CS.aEgo), - self.lac_torque.torque_params, gravity_adjusted=False) + self.torque_params, gravity_adjusted=False) self._pid_log.error = float(torque_from_setpoint - torque_from_measurement) self._ff = self.torque_from_lateral_accel_in_torque_space(LatControlInputs(self._gravity_adjusted_lateral_accel, self._roll_compensation, - CS.vEgo, CS.aEgo), self.lac_torque.torque_params, gravity_adjusted=True) + CS.vEgo, CS.aEgo), self.torque_params, gravity_adjusted=True) self._ff += get_friction_in_torque_space(self._desired_lateral_accel - self._actual_lateral_accel, self._lateral_accel_deadzone, - FRICTION_THRESHOLD, self.lac_torque.torque_params) + FRICTION_THRESHOLD, self.torque_params) def update_output_torque(self, CS): freeze_integrator = self._steer_limited_by_safety or CS.steeringPressed or CS.vEgo < 5 @@ -159,6 +159,6 @@ class NeuralNetworkLateralControl(LatControlTorqueExtBase): # apply friction override for cars with low NN friction response if self.model.friction_override: - self._pid_log.error += get_friction(friction_input, self._lateral_accel_deadzone, FRICTION_THRESHOLD, self.lac_torque.torque_params) + self._pid_log.error += get_friction(friction_input, self._lateral_accel_deadzone, FRICTION_THRESHOLD, self.torque_params) self.update_output_torque(CS) diff --git a/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_assist.py b/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_assist.py index aa6650adda..f857966a73 100644 --- a/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_assist.py +++ b/sunnypilot/selfdrive/controls/lib/speed_limit/tests/test_speed_limit_assist.py @@ -105,7 +105,7 @@ 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_GEN1, TESLA.TESLA_MODEL_Y], indirect=True) + @pytest.mark.parametrize("car_name", [RIVIAN.RIVIAN_R1, TESLA.TESLA_MODEL_Y], indirect=True) def test_disallowed_brands(self, car_name): """ Speed Limit Assist is disabled for the following brands and conditions: diff --git a/sunnypilot/selfdrive/locationd/SConscript b/sunnypilot/selfdrive/locationd/SConscript index 27cd4d5b40..3e71f36782 100644 --- a/sunnypilot/selfdrive/locationd/SConscript +++ b/sunnypilot/selfdrive/locationd/SConscript @@ -14,13 +14,6 @@ live_ekf = env.RednoseCompileFilter( extra_gen_artifacts=['live_kf_constants.h'], gen_script_deps=rednose_gen_deps, ) -car_ekf = env.RednoseCompileFilter( - target='car', - filter_gen_script='models/car_kf.py', - output_dir=rednose_gen_dir, - extra_gen_artifacts=[], - gen_script_deps=rednose_gen_deps, -) # locationd build locationd_sources = ["locationd.cc", "models/live_kf.cc"] diff --git a/sunnypilot/selfdrive/pandad/rivian_long_flasher.py b/sunnypilot/selfdrive/pandad/rivian_long_flasher.py index 24191a73a2..305b994c78 100755 --- a/sunnypilot/selfdrive/pandad/rivian_long_flasher.py +++ b/sunnypilot/selfdrive/pandad/rivian_long_flasher.py @@ -72,9 +72,10 @@ def _flash_panda(panda: Panda) -> None: _flash_static(panda._handle, code) panda.reconnect() + cloudlog.info(f"Successfully flashed xnor's Rivian Longitudinal Upgrade Kit: {panda.get_usb_serial()}") -def flash_rivian_long(panda: Panda) -> None: +def flash_rivian_long(panda_serials: list[str]) -> None: if not os.path.isfile(FW_PATH): cloudlog.error(f"Rivian longitudinal upgrade firmware not found at {FW_PATH}") return @@ -83,13 +84,22 @@ def flash_rivian_long(panda: Panda) -> None: cloudlog.info("Not a Rivian, skipping longitudinal upgrade...") return - # only flash external black pandas (HW_TYPE_BLACK = 0x03) - if panda.get_type() == b'\x03' and not panda.is_internal(): - try: - _flash_panda(panda) - except Exception: - cloudlog.exception(f"Failed to flash F4 panda {panda.get_usb_serial()}") + # only check USB connected pandas, internal panda uses SPI and is never an external panda + usb_serials = set(Panda.usb_list()) + for serial in panda_serials: + if serial not in usb_serials: + continue + panda = Panda(serial) + # only flash external black pandas (HW_TYPE_BLACK = 0x03) + if panda.get_type() == b'\x03' and not panda.is_internal(): + try: + _flash_panda(panda) + except Exception: + cloudlog.exception(f"Failed to flash xnor's Rivian Longitudinal Upgrade Kit: {serial}") + panda.close() + + return if __name__ == '__main__': - flash_rivian_long(Panda()) + flash_rivian_long(Panda.list()) diff --git a/sunnypilot/sunnylink/athena/sunnylinkd.py b/sunnypilot/sunnylink/athena/sunnylinkd.py index e8066dca49..39fe5679ce 100755 --- a/sunnypilot/sunnylink/athena/sunnylinkd.py +++ b/sunnypilot/sunnylink/athena/sunnylinkd.py @@ -229,6 +229,41 @@ def getParamsAllKeysV1() -> dict[str, str]: raise +@dispatcher.add_method +def getParamsMetadata() -> str: + """Compressed equivalent of getParamsAllKeysV1 — same struct, gzipped + base64.""" + try: + with open(METADATA_PATH) as f: + metadata = json.load(f) + except Exception: + cloudlog.exception("sunnylinkd.getParamsMetadata.exception") + metadata = {} + + try: + available_keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()] + + params_list: list[dict] = [] + for key in available_keys: + value = get_param_as_byte(key, get_default=True) + + param_entry: dict = { + "key": key, + "type": int(params.get_type(key).value), + "default_value": base64.b64encode(value).decode('utf-8') if value else None, + } + + if key in metadata: + param_entry["_extra"] = metadata[key] + + params_list.append(param_entry) + + raw = json.dumps(params_list, separators=(',', ':')).encode('utf-8') + return base64.b64encode(gzip.compress(raw)).decode('utf-8') + except Exception: + cloudlog.exception("sunnylinkd.getParamsMetadata.exception") + raise + + @dispatcher.add_method def getParams(params_keys: list[str], compression: bool = False) -> str | dict[str, str]: params = Params() diff --git a/sunnypilot/sunnylink/params_metadata.json b/sunnypilot/sunnylink/params_metadata.json index 79c2b5caf9..41725883a8 100644 --- a/sunnypilot/sunnylink/params_metadata.json +++ b/sunnypilot/sunnylink/params_metadata.json @@ -588,6 +588,10 @@ "title": "Last Update Uptime Onroad", "description": "" }, + "LateralManeuverMode": { + "title": "Lateral Maneuver Mode", + "description": "" + }, "LeadDepartAlert": { "title": "Lead Departure Alert (Beta)", "description": "A chime and on-screen alert (TIZI/TICI only) will play when you are stopped, and the vehicle in front of you start moving.
Note: This chime is only designed as a notification. It is the driver's responsibility to observe their environment and make decisions accordingly." @@ -941,6 +945,22 @@ "title": "Onroad Brightness Delay", "description": "", "options": [ + { + "value": 3, + "label": "3s" + }, + { + "value": 5, + "label": "5s" + }, + { + "value": 7, + "label": "7s" + }, + { + "value": 10, + "label": "10s" + }, { "value": 15, "label": "15s" @@ -991,6 +1011,10 @@ } ] }, + "OnroadScreenOffTimerMigrated": { + "title": "Onroad Brightness Delay Migration Version", + "description": "This param is to track whether OnroadScreenOffTimer needs to be migrated." + }, "OnroadUploads": { "title": "Onroad Uploads", "description": "" @@ -1290,7 +1314,7 @@ "min": 0.1, "max": 5.0, "step": 0.1, - "unit": "m/s²" + "unit": "m/s\u00b2" }, "ToyotaEnforceStockLongitudinal": { "title": "Toyota: Enforce Factory Longitudinal Control", diff --git a/sunnypilot/sunnylink/tools/update_params_metadata.py b/sunnypilot/sunnylink/tools/update_params_metadata.py index 013544005b..ea3765420d 100755 --- a/sunnypilot/sunnylink/tools/update_params_metadata.py +++ b/sunnypilot/sunnylink/tools/update_params_metadata.py @@ -10,6 +10,7 @@ import os from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params +from openpilot.sunnypilot.system.params_migration import ONROAD_BRIGHTNESS_TIMER_VALUES METADATA_PATH = os.path.join(os.path.dirname(__file__), "../params_metadata.json") TORQUE_VERSIONS_JSON = os.path.join(BASEDIR, "sunnypilot", "selfdrive", "controls", "lib", "latcontrol_torque_versions.json") @@ -56,6 +57,9 @@ def main(): # update onroad screen brightness params update_onroad_brightness_param() + # update onroad screen brightness timer params + update_onroad_brightness_timer_param() + # update torque versions param update_torque_versions_param() @@ -81,6 +85,24 @@ def update_onroad_brightness_param(): print(f"Failed to update OnroadScreenOffBrightness versions in params_metadata.json: {e}") +def update_onroad_brightness_timer_param(): + try: + with open(METADATA_PATH) as f: + params_metadata = json.load(f) + if "OnroadScreenOffTimer" in params_metadata: + options = [] + for _index, seconds in sorted(ONROAD_BRIGHTNESS_TIMER_VALUES.items()): + label = f"{seconds}s" if seconds < 60 else f"{seconds // 60}m" + options.append({"value": seconds, "label": label}) + params_metadata["OnroadScreenOffTimer"]["options"] = options + with open(METADATA_PATH, 'w') as f: + json.dump(params_metadata, f, indent=2) + f.write('\n') + print(f"Updated OnroadScreenOffTimer options in params_metadata.json with {len(options)} options.") + except Exception as e: + print(f"Failed to update OnroadScreenOffTimer options in params_metadata.json: {e}") + + def update_torque_versions_param(): with open(TORQUE_VERSIONS_JSON) as f: current_versions = json.load(f) diff --git a/sunnypilot/system/params_migration.py b/sunnypilot/system/params_migration.py index 6a82f1866d..5e524de06e 100644 --- a/sunnypilot/system/params_migration.py +++ b/sunnypilot/system/params_migration.py @@ -7,13 +7,18 @@ See the LICENSE.md file in the root directory for more details. from openpilot.common.swaglog import cloudlog ONROAD_BRIGHTNESS_MIGRATION_VERSION: str = "1.0" +ONROAD_BRIGHTNESS_TIMER_MIGRATION_VERSION: str = "1.0" + +# index → seconds mapping for OnroadScreenOffTimer (SSoT) +ONROAD_BRIGHTNESS_TIMER_VALUES = {0: 3, 1: 5, 2: 7, 3: 10, 4: 15, 5: 30, **{i: (i - 5) * 60 for i in range(6, 16)}} +VALID_TIMER_VALUES = set(ONROAD_BRIGHTNESS_TIMER_VALUES.values()) def run_migration(_params): # migrate OnroadScreenOffBrightness if _params.get("OnroadScreenOffBrightnessMigrated") != ONROAD_BRIGHTNESS_MIGRATION_VERSION: try: - val = _params.get("OnroadScreenOffBrightness") + val = _params.get("OnroadScreenOffBrightness", return_default=True) if val >= 2: # old: 5%, new: Screen Off new_val = val + 1 _params.put("OnroadScreenOffBrightness", new_val) @@ -25,3 +30,18 @@ def run_migration(_params): cloudlog.info(log_str + f" Setting OnroadScreenOffBrightnessMigrated to {ONROAD_BRIGHTNESS_MIGRATION_VERSION}") except Exception as e: cloudlog.exception(f"Error migrating OnroadScreenOffBrightness: {e}") + + # migrate OnroadScreenOffTimer + if _params.get("OnroadScreenOffTimerMigrated") != ONROAD_BRIGHTNESS_TIMER_MIGRATION_VERSION: + try: + val = _params.get("OnroadScreenOffTimer", return_default=True) + if val not in VALID_TIMER_VALUES: + _params.put("OnroadScreenOffTimer", 15) + log_str = f"Successfully migrated OnroadScreenOffTimer from {val} to 15 (default)." + else: + log_str = "Migration not required for OnroadScreenOffTimer." + + _params.put("OnroadScreenOffTimerMigrated", ONROAD_BRIGHTNESS_TIMER_MIGRATION_VERSION) + cloudlog.info(log_str + f" Setting OnroadScreenOffTimerMigrated to {ONROAD_BRIGHTNESS_TIMER_MIGRATION_VERSION}") + except Exception as e: + cloudlog.exception(f"Error migrating OnroadScreenOffTimer: {e}") diff --git a/sunnypilot/system/updated/tests/test_sp_branch_migrations.py b/sunnypilot/system/updated/tests/test_sp_branch_migrations.py new file mode 100644 index 0000000000..661fa19840 --- /dev/null +++ b/sunnypilot/system/updated/tests/test_sp_branch_migrations.py @@ -0,0 +1,73 @@ +""" +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.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) + + mocker.patch("openpilot.system.updated.updated.HARDWARE.get_device_type", return_value=device_type) + + try: + assert Updater().target_branch == expected + finally: + params.remove("UpdaterTargetBranch") + + +@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") + + 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/docs/glossary.toml b/sunnypilot/tools/__init__.py similarity index 100% rename from docs/glossary.toml rename to sunnypilot/tools/__init__.py diff --git a/sunnypilot/tools/memory_profiler/__init__.py b/sunnypilot/tools/memory_profiler/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sunnypilot/tools/memory_profiler/mem_usage.py b/sunnypilot/tools/memory_profiler/mem_usage.py new file mode 100644 index 0000000000..20b4bb2d0f --- /dev/null +++ b/sunnypilot/tools/memory_profiler/mem_usage.py @@ -0,0 +1,164 @@ +""" +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 matplotlib.pyplot as plt +import os +import sys +import argparse +import numpy as np +import base64 +import io + +from openpilot.tools.lib.logreader import LogReader, ReadMode + + +def extract_mem_cpu_data(lr): + times, mems, cpus = [], [], [] + start_time = None + + for msg in lr: + if msg.which() == 'procLog': + if start_time is None: + start_time = msg.logMonoTime + mem = msg.procLog.mem + mem_usage = (mem.total - mem.available) / mem.total * 100 + cpu_usages = [(total - cpu.idle) / total * 100 for cpu in msg.procLog.cpuTimes + if (total := cpu.idle + cpu.user + cpu.system + cpu.nice + cpu.iowait + cpu.irq + cpu.softirq) > 0] + avg_cpu = sum(cpu_usages) / len(cpu_usages) if cpu_usages else 0 + times.append((msg.logMonoTime - start_time) / 1e9) + mems.append(mem_usage) + cpus.append(avg_cpu) + return times, mems, cpus + + +def process_segment(lr): + return [extract_mem_cpu_data(lr)] + + +def calculate_r_squared(y_true, y_pred): + ss_res = np.sum((y_true - y_pred) ** 2) + ss_tot = np.sum((y_true - np.mean(y_true)) ** 2) + return 1 - (ss_res / ss_tot) if ss_tot != 0 else 0 + + +def plot_results(segments, segment_data, route_name): + valid_data = [d for d in segment_data if d and d[0]] + if not valid_data: + print("No valid data to plot") + return + + avg_mems = [np.mean(d[1]) for d in valid_data] + avg_cpus = [np.mean(d[2]) for d in valid_data] + valid_segments = [segments[i] for i, d in enumerate(segment_data) if d and d[0]] + + height = max(10, 5 + len(valid_segments) * 0.4) + fig1, ax1 = plt.subplots(1, 1, figsize=(12, height), dpi=150) + + y_pos = range(len(valid_segments)) + ax1.barh([y - 0.2 for y in y_pos], avg_mems, height=0.4, color="dodgerblue", alpha=0.8, label="Avg Mem %") + ax1.barh([y + 0.2 for y in y_pos], avg_cpus, height=0.4, color="green", alpha=0.8, label="Avg CPU %") + + for i, (mem, cpu) in enumerate(zip(avg_mems, avg_cpus, strict=True)): + ax1.text(mem, i - 0.2, f"{mem:.1f}%", va="center", fontsize=8, color="#005a9e", fontweight="bold") + ax1.text(cpu, i + 0.2, f"{cpu:.1f}%", va="center", fontsize=8, color="#005a9e", fontweight="bold") + + ax1.set_yticks(y_pos) + ax1.set_yticklabels([f"Seg {s}" for s in valid_segments]) + ax1.set_xlabel("Usage (%)") + ax1.set_title("Average Memory and CPU Usage by Segment") + ax1.legend() + ax1.grid(axis="x", linestyle="--", alpha=0.5) + ax1.invert_yaxis() + + fig2, ax2 = plt.subplots(1, 1, figsize=(12, 8), dpi=150) + combined_times, combined_mems, combined_cpus = [], [], [] + time_offset = 0.0 + for times, mems, cpus in valid_data: + if times: + combined_times.extend([t + time_offset for t in times]) + combined_mems.extend(mems) + combined_cpus.extend(cpus) + time_offset += max(times) + + ax2.plot(combined_times, combined_mems, color="red", label="Memory Usage", alpha=0.6) + ax2.plot(combined_times, combined_cpus, color="blue", label="CPU Usage", alpha=0.6) + + warmup_sec = 60 + if len(combined_times) > 1 and combined_times[-1] > warmup_sec: + mask = np.array(combined_times) > warmup_sec + x_reg = np.array(combined_times)[mask] + + y_mem_reg = np.array(combined_mems)[mask] + slope_mem, intercept_mem = np.polyfit(x_reg, y_mem_reg, 1) + trend_mem = slope_mem * x_reg + intercept_mem + r2_mem = calculate_r_squared(y_mem_reg, trend_mem) + ax2.plot(x_reg, trend_mem, color="darkred", linestyle="--", linewidth=2.5, + label=f"Mem Trend (Slope: {slope_mem:.4f} %/s, R²: {r2_mem:.2f})") + + y_cpu_reg = np.array(combined_cpus)[mask] + slope_cpu, intercept_cpu = np.polyfit(x_reg, y_cpu_reg, 1) + trend_cpu = slope_cpu * x_reg + intercept_cpu + r2_cpu = calculate_r_squared(y_cpu_reg, trend_cpu) + ax2.plot(x_reg, trend_cpu, color="navy", linestyle="--", linewidth=2.5, + label=f"CPU Trend (Slope: {slope_cpu:.4f} %/s, R²: {r2_cpu:.2f})") + + ax2.set_xlabel("Time (s)") + ax2.set_ylabel("Usage (%)") + ax2.set_title("Memory and CPU Usage Over Time") + ax2.legend(loc='lower left', fontsize='small', framealpha=0.9) + ax2.grid(True, linestyle="--", alpha=0.5) + + buffer1 = io.BytesIO() + fig1.savefig(buffer1, format='webp', bbox_inches='tight', pad_inches=1.0) + buffer1.seek(0) + img1 = base64.b64encode(buffer1.getvalue()).decode() + + buffer2 = io.BytesIO() + fig2.savefig(buffer2, format='webp', bbox_inches='tight', pad_inches=1.0) + buffer2.seek(0) + img2 = base64.b64encode(buffer2.getvalue()).decode() + + filename = f"memory_usage_{route_name}.html" + save_path = os.path.join(os.path.dirname(__file__), "plots", filename) + os.makedirs(os.path.dirname(save_path), exist_ok=True) + + html_template = ( + "" + + f"

Memory Profile Report

Route: {route_name.replace('_', '/')}

" + + f"" + + f"" + ) + + plt.close(fig1) + plt.close(fig2) + + with open(save_path, "w") as f: + f.write(html_template) + + print(f"Report saved to {save_path}") + + +def main(): + parser = argparse.ArgumentParser(description='Extract memory usage from route logs.') + parser.add_argument('route_or_segment_name', help='Route or segment name from comma connect') + args = parser.parse_args() + + try: + print(f"Fetching logs for {args.route_or_segment_name}") + lr = LogReader(args.route_or_segment_name, default_mode=ReadMode.QLOG) + segment_data = lr.run_across_segments(24, process_segment) + segments = list(range(len(segment_data))) + route_name = args.route_or_segment_name.replace('/', '_') + plot_results(segments, segment_data, route_name) + except Exception as e: + print(f"Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/sunnypilot/tools/pull_footage.py b/sunnypilot/tools/pull_footage.py new file mode 100755 index 0000000000..9964ef5769 --- /dev/null +++ b/sunnypilot/tools/pull_footage.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +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 argparse +import os +import shutil +import subprocess +import sys +import requests +from openpilot.tools.lib.route import Route + + +def get_segments(source, route_id, camera, seg_range): + if "@" in source or "comma-" in source or "sunny-" in source: # SSH + if not route_id: + raise ValueError("route_id required for SSH") + cmd = ["ssh", source, f"ls -d /data/media/0/realdata/{route_id.split('--')[0]}--*"] + output = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode("utf-8").strip() + return [{ + "type": "ssh", + "host": source, + "src": os.path.join(path, camera), + "num": int(path.split("--")[-1]) + } for path in sorted(output.split("\n"), key=lambda x: int(x.split("--")[-1])) if path] + else: # URL + route = Route(route_id) + cameras = [camera] + if camera == "fcamera.hevc": + cameras.extend([c for c in ["ecamera.hevc", "qcamera.ts"] if c != camera]) + + for cam in cameras: + attr_name = "camera_paths" if cam == "fcamera.hevc" else f"{cam.split('.')[0]}_paths" + paths = getattr(route, attr_name)() + if any(paths): + return [{"type": "url", "src": url, "num": idx, "cam": cam} for idx, url in enumerate(paths) if url] + + raise ValueError(f"No footage found for {route_id}") + + +def download(job, out_dir): + destination = os.path.join(out_dir, f"{job['num']}_{os.path.basename(job.get('cam', job.get('src')))}") + if os.path.exists(destination) and os.path.getsize(destination) > 0: + return destination + + print(f"Downloading segment {job['num']}") + if job["type"] == "ssh": + subprocess.check_call(["scp", f"{job['host']}:{job['src']}", destination]) + else: + with requests.get(job["src"], stream=True) as r: + r.raise_for_status() + with open(destination, 'wb') as f: + shutil.copyfileobj(r.raw, f) + return destination + + +def mux(files, output_file, codec): + list_filename = f"{output_file}.list.txt" + with open(list_filename, 'w') as f: + f.write('\n'.join([f"file '{os.path.abspath(name)}'" for name in files])) + + try: + cmd = [ + "ffmpeg", "-y", "-probesize", "100M", "-analyzeduration", "100M", "-f", "concat", + "-safe", "0", "-r", "20", "-i", list_filename, "-c", "copy", "-tag:v", codec, output_file + ] + subprocess.check_call(cmd) + print(f"Saved: {output_file} ({os.path.getsize(output_file) / 1048576:.2f} MB)") + if sys.platform == "darwin": + subprocess.run(["open", "-R", output_file]) + finally: + if os.path.exists(list_filename): + os.remove(list_filename) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("source") + parser.add_argument("route_id", nargs='?') + parser.add_argument("--output", "-o", default="output.mp4") + parser.add_argument("--camera", "-c", default="fcamera.hevc") + parser.add_argument("--keep-segments", action="store_true") + args = parser.parse_args() + + try: + route_id_str = args.route_id or args.source + segment_range = None + if "/" in route_id_str: + route_id_str, range_str = route_id_str.rsplit("/", 1) + if ":" in range_str or range_str.isdigit(): + segment_range = range_str + + is_ssh = "@" in args.source or "comma-" in args.source or "sunny-" in args.source + if not is_ssh and len(route_id_str.split("--")) > 2: + route_id_str = "--".join(route_id_str.split("--")[:2]) + + segments = get_segments(args.source, route_id_str, args.camera, segment_range) + if segment_range: + if ":" in segment_range: + parts = segment_range.split(":") + start_idx = int(parts[0]) if parts[0] else None + end_idx = int(parts[1]) if parts[1] else None + else: + start_idx = int(segment_range) + end_idx = start_idx + 1 + + segments = [ + segment for segment in segments + if (start_idx is None or segment['num'] >= start_idx) and (end_idx is None or segment['num'] < end_idx) + ] + + download_dir = f"{route_id_str}_segments" + os.makedirs(download_dir, exist_ok=True) + + downloaded_files = sorted( + [download(segment, download_dir) for segment in segments], + key=lambda x: int(os.path.basename(x).split("_")[0]) + ) + + camera_name = segments[0].get('cam', args.camera) + codec = "hvc1" if camera_name.endswith("hevc") else "avc1" + mux(downloaded_files, f"{route_id_str}--{args.output}", codec) + + if not args.keep_segments: + shutil.rmtree(download_dir) + + except Exception as e: + print(f"Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/system/camerad/cameras/hw.h b/system/camerad/cameras/hw.h index f20a1b3ade..defe878e89 100644 --- a/system/camerad/cameras/hw.h +++ b/system/camerad/cameras/hw.h @@ -25,6 +25,7 @@ struct CameraConfig { uint32_t phy; bool vignetting_correction; SpectraOutputType output_type; + bool staggered_sof; // SOF is staggered (half-period offset) from other cameras }; // NOTE: to be able to disable road and wide road, we still have to configure the sensor over i2c @@ -39,6 +40,7 @@ const CameraConfig WIDE_ROAD_CAMERA_CONFIG = { .phy = CAM_ISP_IFE_IN_RES_PHY_0, .vignetting_correction = false, .output_type = ISP_IFE_PROCESSED, + .staggered_sof = false, }; const CameraConfig ROAD_CAMERA_CONFIG = { @@ -51,6 +53,7 @@ const CameraConfig ROAD_CAMERA_CONFIG = { .phy = CAM_ISP_IFE_IN_RES_PHY_1, .vignetting_correction = true, .output_type = ISP_IFE_PROCESSED, + .staggered_sof = false, }; const CameraConfig DRIVER_CAMERA_CONFIG = { @@ -63,6 +66,7 @@ const CameraConfig DRIVER_CAMERA_CONFIG = { .phy = CAM_ISP_IFE_IN_RES_PHY_2, .vignetting_correction = false, .output_type = ISP_BPS_PROCESSED, + .staggered_sof = true, }; const CameraConfig ALL_CAMERA_CONFIGS[] = {WIDE_ROAD_CAMERA_CONFIG, ROAD_CAMERA_CONFIG, DRIVER_CAMERA_CONFIG}; diff --git a/system/camerad/cameras/spectra.cc b/system/camerad/cameras/spectra.cc index 73e0a78da3..ab9d8e069a 100644 --- a/system/camerad/cameras/spectra.cc +++ b/system/camerad/cameras/spectra.cc @@ -1436,7 +1436,7 @@ bool SpectraCamera::waitForFrameReady(uint64_t request_id) { } 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)) { + if (!syncFirstFrame(cc.camera_num, request_id, frame_id_raw, timestamp, cc.staggered_sof)) { return false; } @@ -1455,23 +1455,31 @@ 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 SpectraCamera::syncFirstFrame(int camera_id, uint64_t request_id, uint64_t raw_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}; + camera_sync_data[camera_id] = SyncData{timestamp, raw_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), [](const auto &config) { return config.enabled; }); bool all_cams_up = camera_sync_data.size() == enabled_camera_count; - // Wait until the timestamps line up + // Check that camera timestamps are properly aligned: + // - non-staggered cameras should be within 0.2ms of each other + // - staggered cameras should be within 0.2ms of a 25ms offset from non-staggered cameras + const uint64_t half_period_ns = 25 * 1000000ULL; // 25ms + const uint64_t tolerance_ns = 200000ULL; // 0.2ms bool all_cams_synced = true; - for (const auto &[_, sync_data] : camera_sync_data) { + for (const auto &[cam, sync_data] : camera_sync_data) { + if (cam == camera_id) continue; uint64_t diff = std::max(timestamp, sync_data.timestamp) - std::min(timestamp, sync_data.timestamp); - if (diff > 0.2*1e6) { // milliseconds + bool pair_staggered = staggered != sync_data.staggered; + uint64_t expected_offset = pair_staggered ? half_period_ns : 0; + uint64_t error = (diff > expected_offset) ? diff - expected_offset : expected_offset - diff; + if (error > tolerance_ns) { all_cams_synced = false; } } diff --git a/system/camerad/cameras/spectra.h b/system/camerad/cameras/spectra.h index a02b8a6cac..7dd1135254 100644 --- a/system/camerad/cameras/spectra.h +++ b/system/camerad/cameras/spectra.h @@ -194,10 +194,11 @@ private: bool validateEvent(uint64_t request_id, uint64_t frame_id_raw); 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); + static bool syncFirstFrame(int camera_id, uint64_t request_id, uint64_t raw_id, uint64_t timestamp, bool staggered); struct SyncData { uint64_t timestamp; uint64_t frame_id_offset = 0; + bool staggered = false; }; inline static std::map camera_sync_data; inline static bool first_frame_synced = false; diff --git a/system/camerad/test/test_camerad.py b/system/camerad/test/test_camerad.py index 5f8de86899..3abe4db654 100644 --- a/system/camerad/test/test_camerad.py +++ b/system/camerad/test/test_camerad.py @@ -105,17 +105,23 @@ class TestCamerad: assert set(np.diff(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])) frame_ids = {i: [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" - frame_times = {i: [logs[cam]['timestampSof'][i] for cam in CAMERAS] for i in n} - diffs = {i: (max(ts) - min(ts))/1e6 for i, ts in frame_times.items()} - + # 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} + 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 + 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)" + def test_sanity_checks(self, logs): self._sanity_checks(logs) diff --git a/system/hardware/base.py b/system/hardware/base.py index 1a19f908c6..ef2b146043 100644 --- a/system/hardware/base.py +++ b/system/hardware/base.py @@ -91,6 +91,9 @@ class LPABase(ABC): def switch_profile(self, iccid: str) -> None: pass + def process_notifications(self) -> None: + pass + def is_comma_profile(self, iccid: str) -> bool: return any(iccid.startswith(prefix) for prefix in ('8985235',)) diff --git a/system/hardware/esim.py b/system/hardware/esim.py index 9b7d4f9ec0..40600d26b5 100755 --- a/system/hardware/esim.py +++ b/system/hardware/esim.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import argparse -import time from openpilot.system.hardware import HARDWARE @@ -13,16 +12,13 @@ if __name__ == '__main__': parser.add_argument('--nickname', nargs=2, metavar=('iccid', 'name'), help='update the nickname for a profile') args = parser.parse_args() - mutated = False lpa = HARDWARE.get_sim_lpa() if args.switch: lpa.switch_profile(args.switch) - mutated = True elif args.delete: confirm = input('are you sure you want to delete this profile? (y/N) ') if confirm == 'y': lpa.delete_profile(args.delete) - mutated = True else: print('cancelled') exit(0) @@ -33,11 +29,6 @@ if __name__ == '__main__': else: parser.print_help() - if mutated: - HARDWARE.reboot_modem() - # eUICC needs a small delay post-reboot before querying profiles - time.sleep(.5) - profiles = lpa.list_profiles() print(f'\n{len(profiles)} profile{"s" if len(profiles) > 1 else ""}:') for p in profiles: diff --git a/system/hardware/hardwared.py b/system/hardware/hardwared.py index 60721b6144..d4d28418a3 100755 --- a/system/hardware/hardwared.py +++ b/system/hardware/hardwared.py @@ -338,6 +338,9 @@ def hardware_thread(end_event, hw_queue) -> None: show_alert = (not onroad_conditions["device_temp_good"] or not startup_conditions["device_temp_engageable"]) and onroad_conditions["ignition"] set_offroad_alert_if_changed("Offroad_TemperatureTooHigh", show_alert, extra_text=extra_text) + if show_alert: + msg.deviceState.fanSpeedPercentDesired = 100 + # Handle offroad/onroad transition should_start = all(onroad_conditions.values()) if started_ts is None: @@ -435,9 +438,10 @@ def hardware_thread(end_event, hw_queue) -> None: statlog.gauge("fan_speed_percent_desired", msg.deviceState.fanSpeedPercentDesired) statlog.gauge("screen_brightness_percent", msg.deviceState.screenBrightnessPercent) - # report to server once every 10 minutes + # report to server once every 10 minutes, or every 1s when thermally blocked rising_edge_started = should_start and not should_start_prev - if rising_edge_started or (count % int(600. / DT_HW)) == 0: + status_packet_interval = 1. if show_alert else 600. + if rising_edge_started or (count % int(status_packet_interval / DT_HW)) == 0: dat = { 'count': count, 'pandaStates': [strip_deprecated_keys(p.to_dict()) for p in pandaStates], diff --git a/system/hardware/tici/agnos.json b/system/hardware/tici/agnos.json index e33a26bb2e..295b0279d9 100644 --- a/system/hardware/tici/agnos.json +++ b/system/hardware/tici/agnos.json @@ -56,28 +56,28 @@ }, { "name": "boot", - "url": "https://commadist.azureedge.net/agnosupdate/boot-a0185fa5ffc860de2179e4d0fec703fef6d560eacd730f79f60891ca79c72756.img.xz", - "hash": "a0185fa5ffc860de2179e4d0fec703fef6d560eacd730f79f60891ca79c72756", - "hash_raw": "a0185fa5ffc860de2179e4d0fec703fef6d560eacd730f79f60891ca79c72756", - "size": 17496064, + "url": "https://commadist.azureedge.net/agnosupdate/boot-d726315cf98a43e1090e5b49297404cf3d084cfbd42ad8bb7d8afb68136b9f51.img.xz", + "hash": "d726315cf98a43e1090e5b49297404cf3d084cfbd42ad8bb7d8afb68136b9f51", + "hash_raw": "d726315cf98a43e1090e5b49297404cf3d084cfbd42ad8bb7d8afb68136b9f51", + "size": 17500160, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "0ee1ab104bb46d0f72e7d0b7d3e94629a7644a368896c6d4c558554fb955a08a" + "ondevice_hash": "2454108de1161289bc4a75449ad6421f1772b13b3e5cba68a84fca7530557699" }, { "name": "system", - "url": "https://commadist.azureedge.net/agnosupdate/system-0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd.img.xz", - "hash": "7c58308be461126677ba02e9c9739556520ee02958934733867d86ecfe2e58e9", - "hash_raw": "0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd", + "url": "https://commadist.azureedge.net/agnosupdate/system-dcdea6bd675d0276a63c25151727829620794baf42ada2e5e19a3f77b3f583a5.img.xz", + "hash": "5f319030ad05942267b77f1a4686c4ca24cc09b2c2a4688e57342ffc9720fd49", + "hash_raw": "dcdea6bd675d0276a63c25151727829620794baf42ada2e5e19a3f77b3f583a5", "size": 4718592000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "826790516410c325aa30265846946d06a556f0a7b23c957f65fd11c055a663da", + "ondevice_hash": "c12f1b7d790a418aea17424accf4cd59c575e5745cad82bdc9452f384483648c", "alt": { - "hash": "0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd", - "url": "https://commadist.azureedge.net/agnosupdate/system-0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd.img", + "hash": "dcdea6bd675d0276a63c25151727829620794baf42ada2e5e19a3f77b3f583a5", + "url": "https://commadist.azureedge.net/agnosupdate/system-dcdea6bd675d0276a63c25151727829620794baf42ada2e5e19a3f77b3f583a5.img", "size": 4718592000 } } diff --git a/system/hardware/tici/all-partitions.json b/system/hardware/tici/all-partitions.json index b6718fe97d..0801907a2d 100644 --- a/system/hardware/tici/all-partitions.json +++ b/system/hardware/tici/all-partitions.json @@ -339,62 +339,51 @@ }, { "name": "boot", - "url": "https://commadist.azureedge.net/agnosupdate/boot-a0185fa5ffc860de2179e4d0fec703fef6d560eacd730f79f60891ca79c72756.img.xz", - "hash": "a0185fa5ffc860de2179e4d0fec703fef6d560eacd730f79f60891ca79c72756", - "hash_raw": "a0185fa5ffc860de2179e4d0fec703fef6d560eacd730f79f60891ca79c72756", - "size": 17496064, + "url": "https://commadist.azureedge.net/agnosupdate/boot-d726315cf98a43e1090e5b49297404cf3d084cfbd42ad8bb7d8afb68136b9f51.img.xz", + "hash": "d726315cf98a43e1090e5b49297404cf3d084cfbd42ad8bb7d8afb68136b9f51", + "hash_raw": "d726315cf98a43e1090e5b49297404cf3d084cfbd42ad8bb7d8afb68136b9f51", + "size": 17500160, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "0ee1ab104bb46d0f72e7d0b7d3e94629a7644a368896c6d4c558554fb955a08a" + "ondevice_hash": "2454108de1161289bc4a75449ad6421f1772b13b3e5cba68a84fca7530557699" }, { "name": "system", - "url": "https://commadist.azureedge.net/agnosupdate/system-0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd.img.xz", - "hash": "7c58308be461126677ba02e9c9739556520ee02958934733867d86ecfe2e58e9", - "hash_raw": "0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd", + "url": "https://commadist.azureedge.net/agnosupdate/system-dcdea6bd675d0276a63c25151727829620794baf42ada2e5e19a3f77b3f583a5.img.xz", + "hash": "5f319030ad05942267b77f1a4686c4ca24cc09b2c2a4688e57342ffc9720fd49", + "hash_raw": "dcdea6bd675d0276a63c25151727829620794baf42ada2e5e19a3f77b3f583a5", "size": 4718592000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "826790516410c325aa30265846946d06a556f0a7b23c957f65fd11c055a663da", + "ondevice_hash": "c12f1b7d790a418aea17424accf4cd59c575e5745cad82bdc9452f384483648c", "alt": { - "hash": "0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd", - "url": "https://commadist.azureedge.net/agnosupdate/system-0cf8cb01e40d05d6d325afe68b934a6c0dda3a56703b2ef3e3de637d754ae5dd.img", + "hash": "dcdea6bd675d0276a63c25151727829620794baf42ada2e5e19a3f77b3f583a5", + "url": "https://commadist.azureedge.net/agnosupdate/system-dcdea6bd675d0276a63c25151727829620794baf42ada2e5e19a3f77b3f583a5.img", "size": 4718592000 } }, { "name": "userdata_90", - "url": "https://commadist.azureedge.net/agnosupdate/userdata_90-ec31b8116125a95755adb32853c401c462a14a74f538535532bf2c34d72c60eb.img.xz", - "hash": "aa0f0fe32187493e6135aee9e984d3f9705fc58560d537b34687bb6b51a38428", - "hash_raw": "ec31b8116125a95755adb32853c401c462a14a74f538535532bf2c34d72c60eb", + "url": "https://commadist.azureedge.net/agnosupdate/userdata_90-a7b25ea29255f4fd3a2da99e037f40b4ca10bd4afd57dd96563353b8dfb0f634.img.xz", + "hash": "7ea9d7d4685ec36bbfdf06afe0b51650d567416c3092fef96bd97158ed322742", + "hash_raw": "a7b25ea29255f4fd3a2da99e037f40b4ca10bd4afd57dd96563353b8dfb0f634", "size": 96636764160, "sparse": true, "full_check": true, "has_ab": false, - "ondevice_hash": "9c916b7d05543d4608b0401bc867639f44ce9671639a1a6da83b6d58b4eaa1b4" + "ondevice_hash": "79ed653c1679d84b13ee23083a511b0e668454e4af9b0db99a3279072ed041c1" }, { "name": "userdata_89", - "url": "https://commadist.azureedge.net/agnosupdate/userdata_89-7f092cc841124c10300e43574e90e3367e983bfbe4faa0969024e79e5ce90b11.img.xz", - "hash": "fa83d4b7096857136820b0b0a8785c90677256b054c5c14039cd7b9b1065a90b", - "hash_raw": "7f092cc841124c10300e43574e90e3367e983bfbe4faa0969024e79e5ce90b11", + "url": "https://commadist.azureedge.net/agnosupdate/userdata_89-8e428632c967aa609cac184bff938a90240e53ffd3b4fca40bc94c33c81202ba.img.xz", + "hash": "7104cdb0384e4ecb1ebfa6136a2330251bc8aa829b9ec48c4b740f656252d382", + "hash_raw": "8e428632c967aa609cac184bff938a90240e53ffd3b4fca40bc94c33c81202ba", "size": 95563022336, "sparse": true, "full_check": true, "has_ab": false, - "ondevice_hash": "1699e38de769eb32c21dfa6a5ac21eb3ad620a362c7b8abf1a2c0afe0f717530" - }, - { - "name": "userdata_30", - "url": "https://commadist.azureedge.net/agnosupdate/userdata_30-3df2dcd5e1f426c90b090fdbcd1a95b035d96a4bdaf88d5517245db5ee84f5ed.img.xz", - "hash": "890910f20b1ad88a728ee822a47b1234eb3d70cab28ca8a935679c8c2d33cbe9", - "hash_raw": "3df2dcd5e1f426c90b090fdbcd1a95b035d96a4bdaf88d5517245db5ee84f5ed", - "size": 32212254720, - "sparse": true, - "full_check": true, - "has_ab": false, - "ondevice_hash": "8e7cb392dd6e49c7d59fa850be7d1f44901314c86ba9c88be5bb27a0cd1123c9" + "ondevice_hash": "fbede3b0831dbc4a4edd336e5f547f4978902b9421fb1484e86c416192c59165" } ] \ No newline at end of file diff --git a/system/hardware/tici/gsma_ci_bundle.pem b/system/hardware/tici/gsma_ci_bundle.pem new file mode 100644 index 0000000000..3ee7fd1252 --- /dev/null +++ b/system/hardware/tici/gsma_ci_bundle.pem @@ -0,0 +1,133 @@ +# GSMA Certificate Issuer (CI) bundle for eSIM RSP +# Source: https://euicc-manual.osmocom.org/docs/pki/ci/bundle.pem + +issuer= + countryName = CH + organizationName = OISTE Foundation + commonName = OISTE GSMA CI G1 +notBefore=2024-01-16 23:17:39Z +notAfter=2059-01-07 23:17:38Z +-----BEGIN CERTIFICATE----- +MIIB9zCCAZ2gAwIBAgIUSpBSCCDYPOEG/IFHUCKpZ2pIAQMwCgYIKoZIzj0EAwIw +QzELMAkGA1UEBhMCQ0gxGTAXBgNVBAoMEE9JU1RFIEZvdW5kYXRpb24xGTAXBgNV +BAMMEE9JU1RFIEdTTUEgQ0kgRzEwIBcNMjQwMTE2MjMxNzM5WhgPMjA1OTAxMDcy +MzE3MzhaMEMxCzAJBgNVBAYTAkNIMRkwFwYDVQQKDBBPSVNURSBGb3VuZGF0aW9u +MRkwFwYDVQQDDBBPSVNURSBHU01BIENJIEcxMFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEvZ3s3PFC4NgrCcCMmHJ6DJ66uzAHuLcvjJnOn+TtBNThS7YHLDyHCa2v +7D+zTP+XTtgqgcLoB56Gha9EQQQ4xKNtMGswDwYDVR0TAQH/BAUwAwEB/zAQBgNV +HREECTAHiAVghXQFDjAXBgNVHSABAf8EDTALMAkGB2eBEgECAQAwHQYDVR0OBBYE +FEwnlnrSDBSzkelgHkHmBK1XwCIvMA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQD +AgNIADBFAiBVcywTj017jKpAQ+gwy4MqK2hQvzve6lkvQkgSP6ykHwIhAI0KFwCD +jnPbmcJsG41hUrWNlf+IcrMvFuYii0DasBNi +-----END CERTIFICATE----- +issuer= + organizationName = GSM Association + commonName = GSM Association - RSP2 Root CI1 +notBefore=2017-02-22 00:00:00Z +notAfter=2052-02-21 23:59:59Z +-----BEGIN CERTIFICATE----- +MIICSTCCAe+gAwIBAgIQbmhWeneg7nyF7hg5Y9+qejAKBggqhkjOPQQDAjBEMRgw +FgYDVQQKEw9HU00gQXNzb2NpYXRpb24xKDAmBgNVBAMTH0dTTSBBc3NvY2lhdGlv +biAtIFJTUDIgUm9vdCBDSTEwIBcNMTcwMjIyMDAwMDAwWhgPMjA1MjAyMjEyMzU5 +NTlaMEQxGDAWBgNVBAoTD0dTTSBBc3NvY2lhdGlvbjEoMCYGA1UEAxMfR1NNIEFz +c29jaWF0aW9uIC0gUlNQMiBSb290IENJMTBZMBMGByqGSM49AgEGCCqGSM49AwEH +A0IABJ1qutL0HCMX52GJ6/jeibsAqZfULWj/X10p/Min6seZN+hf5llovbCNuB2n +unLz+O8UD0SUCBUVo8e6n9X1TuajgcAwgb0wDgYDVR0PAQH/BAQDAgEGMA8GA1Ud +EwEB/wQFMAMBAf8wEwYDVR0RBAwwCogIKwYBBAGC6WAwFwYDVR0gAQH/BA0wCzAJ +BgdngRIBAgEAME0GA1UdHwRGMEQwQqBAoD6GPGh0dHA6Ly9nc21hLWNybC5zeW1h +dXRoLmNvbS9vZmZsaW5lY2EvZ3NtYS1yc3AyLXJvb3QtY2kxLmNybDAdBgNVHQ4E +FgQUgTcPUSXQsdQI1MOyMubSXnlb6/swCgYIKoZIzj0EAwIDSAAwRQIgIJdYsOMF +WziPK7l8nh5mu0qiRiVf25oa9ullG/OIASwCIQDqCmDrYf+GziHXBOiwJwnBaeBO +aFsiLzIEOaUuZwdNUw== +-----END CERTIFICATE----- +issuer= + countryName = US + organizationName = Entrust, Inc. + organizationalUnitName = See www.entrust.net/legal-terms + organizationalUnitName = (c) 2016 Entrust, Inc. - for authorized use only + commonName = Entrust eSIM Certification Authority +notBefore=2016-11-16 16:04:02Z +notAfter=2051-10-16 16:34:02Z +-----BEGIN CERTIFICATE----- +MIIC6DCCAo2gAwIBAgIRAIy4GT7M5nHsAAAAAFgsinowCgYIKoZIzj0EAwIwgbkx +CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9T +ZWUgd3d3LmVudHJ1c3QubmV0L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykgMjAx +NiBFbnRydXN0LCBJbmMuIC0gZm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxLTArBgNV +BAMTJEVudHJ1c3QgZVNJTSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAgFw0xNjEx +MTYxNjA0MDJaGA8yMDUxMTAxNjE2MzQwMlowgbkxCzAJBgNVBAYTAlVTMRYwFAYD +VQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1c3QubmV0 +L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxNiBFbnRydXN0LCBJbmMuIC0g +Zm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxLTArBgNVBAMTJEVudHJ1c3QgZVNJTSBD +ZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IA +BAdzwGHeQ1Wb2f4DmHTByR5/IWL3JugQ1U3908a++bHdlt+TTA7K4c5cYZ+51Yz/ +hg/bacxguPDh9uQUK6Wg3a6jcjBwMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/ +BAQDAgEGMBcGA1UdIAEB/wQNMAswCQYHZ4ESAQIBADAVBgNVHREEDjAMiApghkgB +hvpsFAoAMB0GA1UdDgQWBBQWcEt/NR42B/GMS3AAXDoAPf1BSjAKBggqhkjOPQQD +AgNJADBGAiEAspjXMvaBZyAg86Z0AAtT0yBRAi1EyaAfNz9kDJeAE04CIQC3efj8 +ATL7/tDBOhANy3cK8PS/1NIlu9vqMLCZsZvJ0Q== +-----END CERTIFICATE----- +issuer= + countryName = FR + organizationName = OBERTHUR TECHNOLOGIES + organizationalUnitName = TELECOM + commonName = MC4 OT ROOT CI v1 +notBefore=2016-11-15 00:00:01Z +notAfter=2046-11-08 23:59:59Z +-----BEGIN CERTIFICATE----- +MIICOjCCAeGgAwIBAgIBATAKBggqhkjOPQQDAjBbMQswCQYDVQQGEwJGUjEeMBwG +A1UEChMVT0JFUlRIVVIgVEVDSE5PTE9HSUVTMRAwDgYDVQQLEwdURUxFQ09NMRow +GAYDVQQDExFNQzQgT1QgUk9PVCBDSSB2MTAeFw0xNjExMTUwMDAwMDFaFw00NjEx +MDgyMzU5NTlaMFsxCzAJBgNVBAYTAkZSMR4wHAYDVQQKExVPQkVSVEhVUiBURUNI +Tk9MT0dJRVMxEDAOBgNVBAsTB1RFTEVDT00xGjAYBgNVBAMTEU1DNCBPVCBST09U +IENJIHYxMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEHb/Gajt3OZxuaDSklBQE +D4lOd6PGPLSvtfkM952ubdyy45tJwAeA0eEii0CLrFT6tcfXkW+H/5mQyMRXaAUk +T6OBlTCBkjAfBgNVHSMEGDAWgBTNbmC3LXoGPLyEYluR6A/jBAbhPjAdBgNVHQ4E +FgQUzW5gty16Bjy8hGJbkegP4wQG4T4wDgYDVR0PAQH/BAQDAgAGMBcGA1UdIAEB +/wQNMAswCQYHZ4ESAQIBADAWBgNVHREEDzANiAsrBgEEAYHvb7OITTAPBgNVHRMB +Af8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIEw4Nc7f2fDtoH+6ON/bknfDQxmT +ikThXjhpLtSrSKN2AiAxHxgC87L0FDnH8dJNlkdGX9c0JIx6oLheIplfS6k+jg== +-----END CERTIFICATE----- +issuer= + commonName = SubMan V4.2 CI Google Pixel + organizationName = Giesecke and Devrient GmbH + organizationalUnitName = Mobile Security + countryName = DE +notBefore=2017-05-10 00:00:00Z +notAfter=2027-05-10 00:00:00Z +-----BEGIN CERTIFICATE----- +MIICaTCCAg6gAwIBAgICASwwCgYIKoZIzj0EAwIwczElMCMGA1UEAxMcIFN1Yk1h +biBWNC4yIENJIEdvb2dsZSBQaXhlbDEjMCEGA1UEChMaR2llc2Vja2UgYW5kIERl +dnJpZW50IEdtYkgxGDAWBgNVBAsTD01vYmlsZSBTZWN1cml0eTELMAkGA1UEBhMC +REUwHhcNMTcwNTEwMDAwMDAwWhcNMjcwNTEwMDAwMDAwWjBzMSUwIwYDVQQDExwg +U3ViTWFuIFY0LjIgQ0kgR29vZ2xlIFBpeGVsMSMwIQYDVQQKExpHaWVzZWNrZSBh +bmQgRGV2cmllbnQgR21iSDEYMBYGA1UECxMPTW9iaWxlIFNlY3VyaXR5MQswCQYD +VQQGEwJERTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABHNorfaJsGzqWNawyAhl +IAv9QL2/+b9RsUoso06t/dKX1MRr5CUJ51acvv5TAFhQKIml+dwLbFnV5aO+8W6Z +wxajgZEwgY4wHwYDVR0jBBgwFoAUtg8LiX/WMLiM/tYWH46oCMU4KsMwHQYDVR0O +BBYEFLYPC4l/1jC4jP7WFh+OqAjFOCrDMA4GA1UdDwEB/wQEAwIBBjAXBgNVHSAB +Af8EDTALMAkGB2eBEgECAQAwDwYDVR0TAQH/BAUwAwEB/zASBgNVHREECzAJiAcr +BgEEAdwPMAoGCCqGSM49BAMCA0kAMEYCIQDpoZcuAQrjATW8U+AWqMUJ0dY6nWW1 +R1QmFzVZ1yMXSwIhALCvRqkCtgiavdeFeSgsSNbY5Fhd+QoCltuSh1U4TE7A +-----END CERTIFICATE----- +issuer= + countryName = DE + commonName = SubMan V4.2 CI + organizationName = Giesecke and Devrient + organizationalUnitName = Mobile Security +notBefore=2016-08-12 13:51:48Z +notAfter=2026-08-12 13:51:48Z +-----BEGIN CERTIFICATE----- +MIICUjCCAfigAwIBAgIDQgAAMAoGCCqGSM49BAMCMGAxCzAJBgNVBAYTAkRFMRcw +FQYDVQQDEw5TdWJNYW4gVjQuMiBDSTEeMBwGA1UEChMVR2llc2Vja2UgYW5kIERl +dnJpZW50MRgwFgYDVQQLEw9Nb2JpbGUgU2VjdXJpdHkwHhcNMTYwODEyMTM1MTQ4 +WhcNMjYwODEyMTM1MTQ4WjBgMQswCQYDVQQGEwJERTEXMBUGA1UEAxMOU3ViTWFu +IFY0LjIgQ0kxHjAcBgNVBAoTFUdpZXNlY2tlIGFuZCBEZXZyaWVudDEYMBYGA1UE +CxMPTW9iaWxlIFNlY3VyaXR5MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYIgl +VQr9wbXOlwPp8qMg5Df08Cli9Mc+lpr3Lwa9PlVA3QWlLeX4GfD4H3phLBqVIa17 +yHttmtheTxi0KoEqhKOBoDCBnTAdBgNVHQ4EFgQU6lOt7zMpuVCa/XVf1Ei4LcG8 +7P8wDgYDVR0PAQH/BAQDAgEGMBcGA1UdIAEB/wQNMAswCQYHZ4ESAQIBADAPBgNV +HRMBAf8EBTADAQH/MBIGA1UdEQQLMAmIBysGAQQB3A8wLgYDVR0fBCcwJTAjoCGg +H4YdaHR0cDovL2dpLWRlLmNvbS90ZXN0LmNybC5wZW0wCgYIKoZIzj0EAwIDSAAw +RQIhAMMx2L/VHDiOW+Fl/OuFmhCdizYM17Yn9zAVieKO2T0iAiANWtCMmY+DzkqK +yHxBFX0U2tBd682zP4DpgRt8j3Ylew== +-----END CERTIFICATE----- diff --git a/system/hardware/tici/lpa.py b/system/hardware/tici/lpa.py index 2e7e6a0ba9..e46ac005b8 100644 --- a/system/hardware/tici/lpa.py +++ b/system/hardware/tici/lpa.py @@ -2,17 +2,29 @@ import atexit import base64 +import fcntl +import hashlib import math import os +import requests import serial +import subprocess import sys +import termios +import time -from collections.abc import Generator +from collections.abc import Callable, Generator +from contextlib import contextmanager +from typing import Any -from openpilot.system.hardware.base import LPABase, Profile +from pathlib import Path +from openpilot.common.time_helpers import system_time_valid +from openpilot.system.hardware.base import LPABase, LPAError, Profile -DEFAULT_DEVICE = "/dev/ttyUSB2" +GSMA_CI_BUNDLE = str(Path(__file__).parent / "gsma_ci_bundle.pem") + +DEFAULT_DEVICE = "/dev/modem_at0" DEFAULT_BAUD = 9600 DEFAULT_TIMEOUT = 5.0 # https://euicc-manual.osmocom.org/docs/lpa/applet-id/ @@ -20,44 +32,133 @@ ISDR_AID = "A0000005591010FFFFFFFF8900000100" MM = "org.freedesktop.ModemManager1" MM_MODEM = MM + ".Modem" ES10X_MSS = 120 +HTTP_TIMEOUT = 30 +OPEN_ISDR_RETRIES = 10 +OPEN_ISDR_RETRY_DELAY_S = 0.25 +OPEN_ISDR_RESET_ATTEMPT = 5 +SEND_APDU_RETRIES = 3 +LOCK_FILE = '/dev/shm/modem_lpa.lock' DEBUG = os.environ.get("DEBUG") == "1" + # TLV Tags TAG_ICCID = 0x5A +TAG_STATUS = 0x80 +TAG_EUICC_INFO = 0xBF20 +TAG_PREPARE_DOWNLOAD = 0xBF21 +TAG_BPP_COMMAND = 0xBF23 +TAG_PROFILE_METADATA = 0xBF25 +TAG_INSTALL_RESULT_DATA = 0xBF27 +TAG_LIST_NOTIFICATION = 0xBF28 +TAG_SET_NICKNAME = 0xBF29 +TAG_RETRIEVE_NOTIFICATION = 0xBF2B TAG_PROFILE_INFO_LIST = 0xBF2D +TAG_EUICC_CHALLENGE = 0xBF2E +TAG_NOTIFICATION_METADATA = 0xBF2F +TAG_NOTIFICATION_SENT = 0xBF30 +TAG_ENABLE_PROFILE = 0xBF31 +TAG_DELETE_PROFILE = 0xBF33 +TAG_BPP = 0xBF36 +TAG_PROFILE_INSTALL_RESULT = 0xBF37 +TAG_AUTH_SERVER = 0xBF38 +TAG_CANCEL_SESSION = 0xBF41 +TAG_OK = 0xA0 + +PROFILE_OK = 0x00 +PROFILE_NOT_IN_DISABLED_STATE = 0x02 +PROFILE_CAT_BUSY = 0x05 + +PROFILE_ERROR_CODES = { + 0x01: "iccidOrAidNotFound", PROFILE_NOT_IN_DISABLED_STATE: "profileNotInDisabledState", + 0x03: "disallowedByPolicy", 0x04: "wrongProfileReenabling", + PROFILE_CAT_BUSY: "catBusy", 0x06: "undefinedError", +} +AUTH_SERVER_ERROR_CODES = { + 0x01: "eUICCVerificationFailed", 0x02: "eUICCCertificateExpired", + 0x03: "eUICCCertificateRevoked", 0x05: "invalidServerSignature", + 0x06: "euiccCiPKUnknown", 0x0A: "matchingIdRefused", + 0x10: "insufficientMemory", +} +BPP_COMMAND_NAMES = { + 0: "initialiseSecureChannel", 1: "configureISDP", 2: "storeMetadata", + 3: "storeMetadata2", 4: "replaceSessionKeys", 5: "loadProfileElements", +} +BPP_ERROR_REASONS = { + 1: "incorrectInputValues", 2: "invalidSignature", 3: "invalidTransactionId", + 4: "unsupportedCrtValues", 5: "unsupportedRemoteOperationType", + 6: "unsupportedProfileClass", 7: "scp03tStructureError", 8: "scp03tSecurityError", + 9: "iccidAlreadyExistsOnEuicc", 10: "insufficientMemoryForProfile", + 11: "installInterrupted", 12: "peProcessingError", 13: "dataMismatch", + 14: "invalidNAA", +} +BPP_ERROR_MESSAGES = { + 9: "This eSIM profile is already installed on this device.", + 10: "Not enough memory on the eUICC to install this profile.", + 12: "Profile installation failed. The QR code may have already been used.", +} + +# SGP.22 §5.2.6 — SM-DP+ reason/subject codes mapped to user-friendly messages +ES9P_ERROR_MESSAGES: dict[tuple[str, str], str] = { + ('3.8', '8.2.6'): "This eSIM profile is already installed on another device. Please use a new QR code.", + ('3.8', '8.2.1'): "This eSIM profile has expired. Please request a new QR code.", + ('3.8', '8.1'): "The SM-DP+ server refused this request.", + ('3.1', '8.2.6'): "This eSIM profile has been revoked by the carrier.", + ('3.9', '8.2.6'): "This eSIM profile download has already been completed.", + ('2.1', '8.8'): "The device is not compatible with this eSIM profile.", + ('1.2', '8.1'): "The SM-DP+ server is temporarily unavailable. Try again later.", +} + +NOTIFICATION_OPERATIONS = {0x80: "install", 0x40: "enable", 0x20: "disable", 0x10: "delete"} STATE_LABELS = {0: "disabled", 1: "enabled", 255: "unknown"} ICON_LABELS = {0: "jpeg", 1: "png", 255: "unknown"} CLASS_LABELS = {0: "test", 1: "provisioning", 2: "operational", 255: "unknown"} +# TLV tag -> (field_name, decoder) +FieldMap = dict[int, tuple[str, Callable[[bytes], Any]]] + def b64e(data: bytes) -> str: return base64.b64encode(data).decode("ascii") +def base64_trim(s: str) -> str: + return "".join(c for c in s if c not in "\n\r \t") + + +def b64d(s: str) -> bytes: + return base64.b64decode(base64_trim(s)) + + class AtClient: - def __init__(self, device: str, baud: int, timeout: float, debug: bool) -> None: - self.debug = debug + def __init__(self, device: str, baud: int, timeout: float) -> None: self.channel: str | None = None + self._device = device + self._baud = baud self._timeout = timeout self._serial: serial.Serial | None = None - try: - self._serial = serial.Serial(device, baudrate=baud, timeout=timeout) - self._serial.reset_input_buffer() - except (serial.SerialException, PermissionError, OSError): - pass + self._use_dbus = not os.path.exists(device) + + def send_raw(self, data: bytes) -> None: + self._ensure_serial() + self._serial.reset_input_buffer() + self._serial.write(data) + self._serial.flush() def close(self) -> None: try: if self.channel: - self.query(f"AT+CCHC={self.channel}") + try: + self.query(f"AT+CCHC={self.channel}") + except (RuntimeError, TimeoutError): + pass self.channel = None finally: if self._serial: self._serial.close() def _send(self, cmd: str) -> None: - if self.debug: + if DEBUG: print(f"SER >> {cmd}", file=sys.stderr) self._serial.write((cmd + "\r").encode("ascii")) @@ -70,7 +171,7 @@ class AtClient: line = raw.decode(errors="ignore").strip() if not line: continue - if self.debug: + if DEBUG: print(f"SER << {line}", file=sys.stderr) if line == "OK": return lines @@ -78,6 +179,18 @@ class AtClient: raise RuntimeError(f"AT command failed: {line}") lines.append(line) + def _ensure_serial(self, reconnect: bool = False) -> None: + if reconnect: + self.channel = None + try: + if self._serial: + self._serial.close() + except Exception: + pass + self._serial = None + if self._serial is None: + self._serial = serial.Serial(self._device, baudrate=self._baud, timeout=self._timeout) + def _get_modem(self): import dbus bus = dbus.SystemBus() @@ -87,48 +200,88 @@ class AtClient: return bus.get_object(MM, modem_path) def _dbus_query(self, cmd: str) -> list[str]: - if self.debug: + if DEBUG: print(f"DBUS >> {cmd}", file=sys.stderr) try: result = str(self._get_modem().Command(cmd, math.ceil(self._timeout), dbus_interface=MM_MODEM, timeout=self._timeout)) except Exception as e: raise RuntimeError(f"AT command failed: {e}") from e lines = [line.strip() for line in result.splitlines() if line.strip()] - if self.debug: + if DEBUG: for line in lines: print(f"DBUS << {line}", file=sys.stderr) return lines def query(self, cmd: str) -> list[str]: - if self._serial: + if self._use_dbus: + return self._dbus_query(cmd) + self._ensure_serial() + try: + self._send(cmd) + return self._expect() + except serial.SerialException: + self._ensure_serial(reconnect=True) self._send(cmd) return self._expect() - return self._dbus_query(cmd) - def open_isdr(self) -> None: - # close any stale logical channel from a previous crashed session - try: - self.query("AT+CCHC=1") - except RuntimeError: - pass + def _open_isdr_once(self) -> None: + if self.channel: + try: + self.query(f"AT+CCHC={self.channel}") + except RuntimeError: + pass + self.channel = None + # drain any unsolicited responses before opening + if self._serial and not self._use_dbus: + try: + self._serial.reset_input_buffer() + except (OSError, serial.SerialException, termios.error): + self._ensure_serial(reconnect=True) for line in self.query(f'AT+CCHO="{ISDR_AID}"'): if line.startswith("+CCHO:") and (ch := line.split(":", 1)[1].strip()): self.channel = ch return raise RuntimeError("Failed to open ISD-R application") + def _reset_modem(self) -> None: + if self._serial: + try: + self._serial.close() + except Exception: + pass + self._serial = None + subprocess.run(['/usr/comma/lte/lte.sh', 'start'], capture_output=True) + + def open_isdr(self) -> None: + for attempt in range(OPEN_ISDR_RETRIES): + try: + self._open_isdr_once() + return + except (RuntimeError, TimeoutError, termios.error, serial.SerialException): + time.sleep(OPEN_ISDR_RETRY_DELAY_S) + if attempt == OPEN_ISDR_RESET_ATTEMPT: + self._reset_modem() + raise RuntimeError("Failed to open ISD-R after retries") + def send_apdu(self, apdu: bytes) -> tuple[bytes, int, int]: - if not self.channel: - raise RuntimeError("Logical channel is not open") - hex_payload = apdu.hex().upper() - for line in self.query(f'AT+CGLA={self.channel},{len(hex_payload)},"{hex_payload}"'): - if line.startswith("+CGLA:"): - parts = line.split(":", 1)[1].split(",", 1) - if len(parts) == 2: - data = bytes.fromhex(parts[1].strip().strip('"')) - if len(data) >= 2: - return data[:-2], data[-2], data[-1] - raise RuntimeError("Missing +CGLA response") + for attempt in range(SEND_APDU_RETRIES): + try: + if not self.channel: + self.open_isdr() + hex_payload = apdu.hex().upper() + for line in self.query(f'AT+CGLA={self.channel},{len(hex_payload)},"{hex_payload}"'): + if line.startswith("+CGLA:"): + parts = line.split(":", 1)[1].split(",", 1) + if len(parts) == 2: + data = bytes.fromhex(parts[1].strip().strip('"')) + if len(data) >= 2: + return data[:-2], data[-2], data[-1] + raise RuntimeError("Missing +CGLA response") + except (RuntimeError, ValueError): + self.channel = None + if attempt == SEND_APDU_RETRIES - 1: + raise + raise RuntimeError("send_apdu failed") # --- TLV utilities --- @@ -170,12 +323,37 @@ def find_tag(data: bytes, target: int) -> bytes | None: return next((v for t, v in iter_tlv(data) if t == target), None) +def require_tag(data: bytes, target: int, label: str = "") -> bytes: + v = find_tag(data, target) + if v is None: + raise RuntimeError(f"Missing {label or f'tag 0x{target:X}'}") + return v + + def tbcd_to_string(raw: bytes) -> str: return "".join(str(n) for b in raw for n in (b & 0x0F, b >> 4) if n <= 9) -# Profile field decoders: TLV tag -> (field_name, decoder) -_PROFILE_FIELDS = { +def string_to_tbcd(s: str) -> bytes: + digits = [int(c) for c in s if c.isdigit()] + return bytes(digits[i] | ((digits[i + 1] if i + 1 < len(digits) else 0xF) << 4) for i in range(0, len(digits), 2)) + + +def encode_tlv(tag: int, value: bytes) -> bytes: + tag_bytes = bytes([(tag >> 8) & 0xFF, tag & 0xFF]) if tag > 255 else bytes([tag]) + vlen = len(value) + if vlen <= 127: + return tag_bytes + bytes([vlen]) + value + length_bytes = vlen.to_bytes((vlen.bit_length() + 7) // 8, "big") + return tag_bytes + bytes([0x80 | len(length_bytes)]) + length_bytes + value + + +def int_bytes(n: int) -> bytes: + """Encode a positive integer as minimal big-endian bytes (at least 1 byte).""" + return n.to_bytes((n.bit_length() + 7) // 8 or 1, "big") + + +PROFILE: FieldMap = { TAG_ICCID: ("iccid", tbcd_to_string), 0x4F: ("isdpAid", lambda v: v.hex().upper()), 0x9F70: ("profileState", lambda v: STATE_LABELS.get(v[0], "unknown")), @@ -188,11 +366,11 @@ _PROFILE_FIELDS = { } -def _decode_profile_fields(data: bytes) -> dict: - """Parse known profile metadata TLV fields into a dict.""" - result = {} +def decode_struct(data: bytes, field_map: FieldMap) -> dict[str, Any]: + """Parse TLV data using a {tag: (field_name, decoder)} map into a dict.""" + result: dict[str, Any] = {name: None for name, _ in field_map.values()} for tag, value in iter_tlv(data): - if (field := _PROFILE_FIELDS.get(tag)): + if (field := field_map.get(tag)): result[field[0]] = field[1](value) return result @@ -224,58 +402,393 @@ def es10x_command(client: AtClient, data: bytes) -> bytes: # --- Profile operations --- +NOTIFICATION: FieldMap = { + TAG_STATUS: ("seqNumber", lambda v: int.from_bytes(v, "big")), + 0x81: ("profileManagementOperation", + lambda v: NOTIFICATION_OPERATIONS.get(next((m for m in NOTIFICATION_OPERATIONS if len(v) >= 2 and v[1] & m), 0), "unknown")), + 0x0C: ("notificationAddress", lambda v: v.decode("utf-8", errors="ignore")), + TAG_ICCID: ("iccid", tbcd_to_string), +} + + def decode_profiles(blob: bytes) -> list[dict]: - root = find_tag(blob, TAG_PROFILE_INFO_LIST) - if root is None: - raise RuntimeError("Missing ProfileInfoList") - list_ok = find_tag(root, 0xA0) + root = require_tag(blob, TAG_PROFILE_INFO_LIST, "ProfileInfoList") + list_ok = find_tag(root, TAG_OK) if list_ok is None: return [] - defaults = {name: None for name, _ in _PROFILE_FIELDS.values()} - return [{**defaults, **_decode_profile_fields(value)} for tag, value in iter_tlv(list_ok) if tag == 0xE3] + return [decode_struct(value, PROFILE) for tag, value in iter_tlv(list_ok) if tag == 0xE3] def list_profiles(client: AtClient) -> list[dict]: return decode_profiles(es10x_command(client, TAG_PROFILE_INFO_LIST.to_bytes(2, "big") + b"\x00")) +def set_profile_nickname(client: AtClient, iccid: str, nickname: str) -> None: + nickname_bytes = nickname.encode("utf-8") + if len(nickname_bytes) > 64: + raise ValueError("Profile nickname must be 64 bytes or less") + content = encode_tlv(TAG_ICCID, string_to_tbcd(iccid)) + encode_tlv(0x90, nickname_bytes) + response = es10x_command(client, encode_tlv(TAG_SET_NICKNAME, content)) + code = require_tag(require_tag(response, TAG_SET_NICKNAME, "SetNicknameResponse"), TAG_STATUS, "SetNickname status")[0] + if code == 0x01: + raise LPAError(f"profile {iccid} not found") + if code != 0x00: + raise RuntimeError(f"SetNickname failed with status 0x{code:02X}") + + +# --- ES9P HTTP --- + +def es9p_request(smdp_address: str, endpoint: str, payload: dict, error_prefix: str = "Request", session: requests.Session | None = None) -> dict: + url = f"https://{smdp_address}/gsma/rsp2/es9plus/{endpoint}" + headers = {"User-Agent": "gsma-rsp-lpad", "X-Admin-Protocol": "gsma/rsp/v2.3.0", "Content-Type": "application/json"} + http = session or requests + resp = http.post(url, json=payload, headers=headers, timeout=HTTP_TIMEOUT, verify=GSMA_CI_BUNDLE) + resp.raise_for_status() + if not resp.content: + return {} + data = resp.json() + if "header" in data and "functionExecutionStatus" in data["header"]: + status = data["header"]["functionExecutionStatus"] + if status.get("status") == "Failed": + sd = status.get("statusCodeData", {}) + reason = sd.get("reasonCode", "unknown") + subject = sd.get("subjectCode", "unknown") + msg = ES9P_ERROR_MESSAGES.get((reason, subject), + f"{error_prefix} failed: {reason}/{subject} - {sd.get('message', 'unknown')}") + raise RuntimeError(msg) + return data + + +# --- Notifications --- + +def list_notifications(client: AtClient) -> list[dict]: + response = es10x_command(client, encode_tlv(TAG_LIST_NOTIFICATION, b"")) + root = require_tag(response, TAG_LIST_NOTIFICATION, "ListNotificationResponse") + metadata_list = find_tag(root, TAG_OK) + if metadata_list is None: + return [] + return [decode_struct(value, NOTIFICATION) for tag, value in iter_tlv(metadata_list) if tag == TAG_NOTIFICATION_METADATA] + + +def process_notifications(client: AtClient) -> None: + for notification in list_notifications(client): + seq_number, smdp_address = notification["seqNumber"], notification["notificationAddress"] + try: + request = encode_tlv(TAG_RETRIEVE_NOTIFICATION, encode_tlv(TAG_OK, encode_tlv(TAG_STATUS, int_bytes(seq_number)))) + response = es10x_command(client, request) + content = require_tag(require_tag(response, TAG_RETRIEVE_NOTIFICATION, "RetrieveNotificationsListResponse"), + TAG_OK, "RetrieveNotificationsListResponse") + pending_notif = next((v for t, v in iter_tlv(content) if t in (TAG_PROFILE_INSTALL_RESULT, 0x30)), None) + if pending_notif is None: + raise RuntimeError("Missing PendingNotification") + + es9p_request(smdp_address, "handleNotification", {"pendingNotification": b64e(pending_notif)}, "HandleNotification") + + response = es10x_command(client, encode_tlv(TAG_NOTIFICATION_SENT, encode_tlv(TAG_STATUS, int_bytes(seq_number)))) + root = require_tag(response, TAG_NOTIFICATION_SENT, "NotificationSentResponse") + if int.from_bytes(require_tag(root, TAG_STATUS, "RemoveNotificationFromList status"), "big") != 0: + raise RuntimeError("RemoveNotificationFromList failed") + except Exception as e: + print(f"notification {seq_number} failed: {e}", file=sys.stderr) + + +# --- Authentication & Download --- + +def get_challenge_and_info(client: AtClient) -> tuple[bytes, bytes]: + challenge_resp = es10x_command(client, encode_tlv(TAG_EUICC_CHALLENGE, b"")) + challenge = require_tag(require_tag(challenge_resp, TAG_EUICC_CHALLENGE, "GetEuiccDataResponse"), + TAG_STATUS, "challenge in response") + info_resp = es10x_command(client, encode_tlv(TAG_EUICC_INFO, b"")) + require_tag(info_resp, TAG_EUICC_INFO, "GetEuiccInfo1Response") + return challenge, info_resp + + +def authenticate_server(client: AtClient, b64_signed1: str, b64_sig1: str, b64_pk_id: str, b64_cert: str, matching_id: str) -> str: + tac = bytes([0x35, 0x29, 0x06, 0x11]) + device_info = encode_tlv(TAG_STATUS, tac) + encode_tlv(0xA1, b"") + ctx_inner = encode_tlv(TAG_STATUS, matching_id.encode("utf-8")) + encode_tlv(0xA1, device_info) + content = b64d(b64_signed1) + b64d(b64_sig1) + b64d(b64_pk_id) + b64d(b64_cert) + encode_tlv(0xA0, ctx_inner) + response = es10x_command(client, encode_tlv(TAG_AUTH_SERVER, content)) + root = require_tag(response, TAG_AUTH_SERVER, "AuthenticateServerResponse") + error_tag = find_tag(root, 0xA1) + if error_tag is not None: + code = int.from_bytes(error_tag, "big") if error_tag else 0 + raise RuntimeError(f"AuthenticateServer rejected by eUICC: {AUTH_SERVER_ERROR_CODES.get(code, 'unknown')} (0x{code:02X})") + return b64e(response) + + +def prepare_download(client: AtClient, b64_signed2: str, b64_sig2: str, b64_cert: str, cc: str | None = None) -> str: + smdp_signed2 = b64d(b64_signed2) + smdp_signature2 = b64d(b64_sig2) + smdp_certificate = b64d(b64_cert) + smdp_signed2_root = find_tag(smdp_signed2, 0x30) + if smdp_signed2_root is None: + raise RuntimeError("Invalid smdpSigned2") + transaction_id = find_tag(smdp_signed2_root, TAG_STATUS) + cc_required_flag = find_tag(smdp_signed2_root, 0x01) + if transaction_id is None or cc_required_flag is None: + raise RuntimeError("Invalid smdpSigned2") + content = smdp_signed2 + smdp_signature2 + if int.from_bytes(cc_required_flag, "big") != 0: + if not cc: + raise RuntimeError("Confirmation code required but not provided") + content += encode_tlv(0x04, hashlib.sha256(hashlib.sha256(cc.encode("utf-8")).digest() + transaction_id).digest()) + content += smdp_certificate + response = es10x_command(client, encode_tlv(TAG_PREPARE_DOWNLOAD, content)) + require_tag(response, TAG_PREPARE_DOWNLOAD, "PrepareDownloadResponse") + return b64e(response) + + +def _parse_tlv_header_len(data: bytes) -> int: + tag_len = 2 if data[0] & 0x1F == 0x1F else 1 + length_byte = data[tag_len] + return tag_len + (1 + (length_byte & 0x7F) if length_byte & 0x80 else 1) + + +def _split_bpp(bpp: bytes) -> list[bytes]: + """Split a BoundProfilePackage into APDU chunks per SGP.22 §5.7.6.""" + root_value = None + for tag, value, start, end in iter_tlv(bpp, with_positions=True): + if tag == TAG_BPP: + root_value = value + val_start = start + _parse_tlv_header_len(bpp[start:end]) + break + if root_value is None: + raise RuntimeError("Invalid BoundProfilePackage") + + chunks: list[bytes] = [] + for tag, value, start, end in iter_tlv(root_value, with_positions=True): + if tag == TAG_BPP_COMMAND: + chunks.append(bpp[0 : val_start + end]) + elif tag in (0xA0, 0xA2): + chunks.append(bpp[val_start + start : val_start + end]) + elif tag in (0xA1, 0xA3): + hdr_len = _parse_tlv_header_len(root_value[start:end]) + chunks.append(bpp[val_start + start : val_start + start + hdr_len]) + for _, _, cs, ce in iter_tlv(value, with_positions=True): + chunks.append(value[cs:ce]) + return chunks + + +def _parse_install_result(response: bytes) -> dict[str, Any] | None: + """Parse a ProfileInstallResult from an APDU response, or None if not present.""" + root = find_tag(response, TAG_PROFILE_INSTALL_RESULT) + if not root: + return None + result_data = find_tag(root, TAG_INSTALL_RESULT_DATA) + if not result_data: + return None + result: dict[str, Any] = {"seqNumber": 0, "success": False, "bppCommandId": None, "errorReason": None} + notif_meta = find_tag(result_data, TAG_NOTIFICATION_METADATA) + if notif_meta: + seq_num = find_tag(notif_meta, TAG_STATUS) + if seq_num: + result["seqNumber"] = int.from_bytes(seq_num, "big") + final_result = find_tag(result_data, 0xA2) + if final_result: + for tag, value in iter_tlv(final_result): + if tag == 0xA0: + result["success"] = True + elif tag == 0xA1: + bpp_cmd = find_tag(value, TAG_STATUS) + if bpp_cmd: + result["bppCommandId"] = int.from_bytes(bpp_cmd, "big") + err = find_tag(value, 0x81) + if err: + result["errorReason"] = int.from_bytes(err, "big") + return result + + +def load_bpp(client: AtClient, b64_bpp: str) -> dict: + bpp = b64d(b64_bpp) + result = None + for chunk in _split_bpp(bpp): + response = es10x_command(client, chunk) + if response: + result = _parse_install_result(response) or result + + if result is None: + raise RuntimeError("Profile installation failed: no result from eUICC") + if not result["success"] and result["errorReason"] is not None: + msg = BPP_ERROR_MESSAGES.get(result["errorReason"]) + if not msg: + cmd_name = BPP_COMMAND_NAMES.get(result["bppCommandId"], f"unknown({result['bppCommandId']})") + err_name = BPP_ERROR_REASONS.get(result["errorReason"], f"unknown({result['errorReason']})") + msg = f"Profile installation failed at {cmd_name}: {err_name}" + raise RuntimeError(msg) + if not result["success"]: + raise RuntimeError("Profile installation failed: no result from eUICC") + return result + + +def parse_metadata(b64_metadata: str) -> dict: + root = find_tag(b64d(b64_metadata), TAG_PROFILE_METADATA) + if root is None: + raise RuntimeError("Invalid profileMetadata") + return decode_struct(root, PROFILE) + + +def cancel_session(client: AtClient, transaction_id: bytes, reason: int = 127) -> str: + content = encode_tlv(0x80, transaction_id) + encode_tlv(0x81, bytes([reason])) + response = es10x_command(client, encode_tlv(TAG_CANCEL_SESSION, content)) + return b64e(response) + + +def parse_lpa_activation_code(activation_code: str) -> tuple[str, str]: + """Parse 'LPA:1$smdp.example.com$MATCHING-ID' into (smdp_address, matching_id).""" + if not activation_code.startswith("LPA:"): + raise ValueError("Invalid activation code format") + parts = activation_code[4:].split("$") + if len(parts) != 3: + raise ValueError("Invalid activation code format") + return parts[1], parts[2] + + +def _b64_field(data: dict, key: str) -> str: + return base64_trim(data[key]) + + +def _cancel_session_safe(client: AtClient, smdp: str, tx_id: str, session: requests.Session) -> None: + b64_cancel = "" + try: + b64_cancel = cancel_session(client, b64d(tx_id)) + except Exception: + pass + try: + es9p_request(smdp, "cancelSession", {"transactionId": tx_id, "cancelSessionResponse": b64_cancel}, "CancelSession", session=session) + except Exception: + pass + + +def download_profile(client: AtClient, activation_code: str) -> str: + """Download and install an eSIM profile. Returns the ICCID of the installed profile.""" + if not system_time_valid(): + raise RuntimeError("System time is not set; TLS certificate validation requires a valid clock") + smdp, matching_id = parse_lpa_activation_code(activation_code) + challenge, euicc_info = get_challenge_and_info(client) + session = requests.Session() + tx_id = None + + try: + # step 1: initiate authentication + auth = es9p_request(smdp, "initiateAuthentication", { + "smdpAddress": smdp, "euiccChallenge": b64e(challenge), + "euiccInfo1": b64e(euicc_info), "matchingId": matching_id, + }, "Authentication", session=session) + tx_id = _b64_field(auth, "transactionId") + + # step 2: authenticate server + b64_auth = authenticate_server(client, + _b64_field(auth, "serverSigned1"), _b64_field(auth, "serverSignature1"), + _b64_field(auth, "euiccCiPKIdToBeUsed"), _b64_field(auth, "serverCertificate"), + matching_id) + + # step 3: authenticate client + get metadata + cli = es9p_request(smdp, "authenticateClient", { + "transactionId": tx_id, "authenticateServerResponse": b64_auth, + }, "Authentication", session=session) + iccid = parse_metadata(_b64_field(cli, "profileMetadata"))["iccid"] + + # step 4: prepare download + b64_prep = prepare_download(client, + _b64_field(cli, "smdpSigned2"), _b64_field(cli, "smdpSignature2"), + _b64_field(cli, "smdpCertificate")) + + # step 5: get and install bound profile package + bpp = es9p_request(smdp, "getBoundProfilePackage", { + "transactionId": tx_id, "prepareDownloadResponse": b64_prep, + }, "GetBoundProfilePackage", session=session) + load_bpp(client, _b64_field(bpp, "boundProfilePackage")) + return iccid + except Exception: + if tx_id: + _cancel_session_safe(client, smdp, tx_id, session) + raise + finally: + session.close() + + class TiciLPA(LPABase): - _instance = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - def __init__(self): if hasattr(self, '_client'): return - self._client = AtClient(DEFAULT_DEVICE, DEFAULT_BAUD, DEFAULT_TIMEOUT, debug=DEBUG) - self._client.open_isdr() + self._client = AtClient(DEFAULT_DEVICE, DEFAULT_BAUD, DEFAULT_TIMEOUT) atexit.register(self._client.close) + @contextmanager + def _acquire_channel(self): + fd = os.open(LOCK_FILE, os.O_CREAT | os.O_RDWR) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + self._client.open_isdr() + yield + finally: + if self._client.channel: + try: + self._client.query(f"AT+CCHC={self._client.channel}") + except (RuntimeError, TimeoutError): + pass + self._client.channel = None + fcntl.flock(fd, fcntl.LOCK_UN) + os.close(fd) + def list_profiles(self) -> list[Profile]: - return [ - Profile( - iccid=p.get("iccid", ""), - nickname=p.get("profileNickname") or "", - enabled=p.get("profileState") == "enabled", - provider=p.get("serviceProviderName") or "", - ) - for p in list_profiles(self._client) - ] + with self._acquire_channel(): + return [ + Profile( + iccid=p.get("iccid", ""), + nickname=p.get("profileNickname") or "", + enabled=p.get("profileState") == "enabled", + provider=p.get("serviceProviderName") or "", + ) + for p in list_profiles(self._client) + ] def get_active_profile(self) -> Profile | None: return None + def process_notifications(self) -> None: + if not system_time_valid(): + raise RuntimeError("System time is not set; TLS certificate validation requires a valid clock") + with self._acquire_channel(): + process_notifications(self._client) + def delete_profile(self, iccid: str) -> None: - return None + if self.is_comma_profile(iccid): + raise LPAError("refusing to delete a comma profile") + with self._acquire_channel(): + request = encode_tlv(TAG_DELETE_PROFILE, encode_tlv(TAG_ICCID, string_to_tbcd(iccid))) + response = es10x_command(self._client, request) + code = require_tag(require_tag(response, TAG_DELETE_PROFILE, "DeleteProfileResponse"), TAG_STATUS, "DeleteProfile status")[0] + if code != PROFILE_OK: + raise LPAError(f"DeleteProfile failed: {PROFILE_ERROR_CODES.get(code, 'unknown')} (0x{code:02X})") def download_profile(self, qr: str, nickname: str | None = None) -> None: - return None + with self._acquire_channel(): + iccid = download_profile(self._client, qr) + if nickname and iccid: + set_profile_nickname(self._client, iccid, nickname) def nickname_profile(self, iccid: str, nickname: str) -> None: - return None + with self._acquire_channel(): + set_profile_nickname(self._client, iccid, nickname) + + def _enable_profile(self, iccid: str) -> int: + inner = encode_tlv(TAG_OK, encode_tlv(TAG_ICCID, string_to_tbcd(iccid))) + inner += b'\x01\x01\x01' # refreshFlag=1 + response = es10x_command(self._client, encode_tlv(TAG_ENABLE_PROFILE, inner)) + return require_tag(require_tag(response, TAG_ENABLE_PROFILE, "EnableProfileResponse"), TAG_STATUS, "EnableProfile status")[0] def switch_profile(self, iccid: str) -> None: - return None + with self._acquire_channel(): + code = self._enable_profile(iccid) + if code == PROFILE_CAT_BUSY: # stale eUICC transaction, reset and retry + self._client._reset_modem() + self._client.open_isdr() + code = self._enable_profile(iccid) + if code not in (PROFILE_OK, PROFILE_NOT_IN_DISABLED_STATE): + raise LPAError(f"EnableProfile failed: {PROFILE_ERROR_CODES.get(code, 'unknown')} (0x{code:02X})") + from openpilot.system.hardware import HARDWARE + if HARDWARE.get_device_type() == "mici": + self._client.send_raw(b'AT+CFUN=0\rAT+CFUN=1\r') # mici has no SIM presence pin; raw because CFUN=0 drops serial + self._client._ensure_serial(reconnect=True) diff --git a/system/hardware/tici/updater_magic b/system/hardware/tici/updater_magic index ec586dbcb3..44b82d0c54 100755 --- a/system/hardware/tici/updater_magic +++ b/system/hardware/tici/updater_magic @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c44fb88b3b1643b6b44ae8ac9880348bd0257ff90f4084cbe889de91d71653fe -size 25111329 +oid sha256:3a94ab8395f20d20a9d5a2a2bacca0694f072df8421cf13adca6250d28065bdc +size 24709205 diff --git a/system/loggerd/SConscript b/system/loggerd/SConscript index b02c409240..45e8b25d20 100644 --- a/system/loggerd/SConscript +++ b/system/loggerd/SConscript @@ -3,11 +3,16 @@ Import('env', 'arch', 'messaging', 'common', 'visionipc') libs = [common, messaging, visionipc, 'avformat', 'avcodec', 'swresample', 'avutil', 'x264', 'pthread', 'z', 'm', 'zstd'] +frameworks = [] src = ['logger.cc', 'zstd_writer.cc', 'video_writer.cc', 'encoder/encoder.cc', 'encoder/v4l_encoder.cc', 'encoder/jpeg_encoder.cc'] if arch != "larch64": src += ['encoder/ffmpeg_encoder.cc'] libs += ['yuv'] + if arch == "Darwin": + frameworks += ['VideoToolbox', 'CoreMedia', 'CoreFoundation', 'CoreVideo'] + else: + libs += ['va', 'va-drm', 'drm'] if arch == "Darwin": # exclude v4l @@ -16,9 +21,9 @@ if arch == "Darwin": logger_lib = env.Library('logger', src) libs.insert(0, logger_lib) -env.Program('loggerd', ['loggerd.cc'], LIBS=libs) -env.Program('encoderd', ['encoderd.cc'], LIBS=libs + ["jpeg"]) -env.Program('bootlog.cc', LIBS=libs) +env.Program('loggerd', ['loggerd.cc'], LIBS=libs, FRAMEWORKS=frameworks) +env.Program('encoderd', ['encoderd.cc'], LIBS=libs + ["jpeg"], 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/system/manager/manager.py b/system/manager/manager.py index 8c219909e4..3ec554f904 100755 --- a/system/manager/manager.py +++ b/system/manager/manager.py @@ -51,7 +51,8 @@ def manager_init() -> None: if params.get_bool("RecordFrontLock"): params.put_bool("RecordFront", True) - run_migration(params) + if not PC: + run_migration(params) # set unset params to their default value for k in params.all_keys(): diff --git a/system/manager/process_config.py b/system/manager/process_config.py index 2e43dfada6..a9ecf302f2 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -46,6 +46,9 @@ def not_joystick(started: bool, params: Params, CP: car.CarParams) -> bool: def long_maneuver(started: bool, params: Params, CP: car.CarParams) -> bool: return started and params.get_bool("LongitudinalManeuverMode") +def lat_maneuver(started: bool, params: Params, CP: car.CarParams) -> bool: + return started and params.get_bool("LateralManeuverMode") + def not_long_maneuver(started: bool, params: Params, CP: car.CarParams) -> bool: return started and not params.get_bool("LongitudinalManeuverMode") @@ -142,6 +145,7 @@ procs = [ PythonProcess("pigeond", "system.ubloxd.pigeond", ublox, enabled=TICI), PythonProcess("plannerd", "selfdrive.controls.plannerd", not_long_maneuver), PythonProcess("maneuversd", "tools.longitudinal_maneuvers.maneuversd", long_maneuver), + PythonProcess("lateral_maneuversd", "tools.lateral_maneuvers.lateral_maneuversd", lat_maneuver), PythonProcess("radard", "selfdrive.controls.radard", only_onroad), PythonProcess("hardwared", "system.hardware.hardwared", always_run), PythonProcess("tombstoned", "system.tombstoned", always_run, enabled=not PC), diff --git a/system/qcomgpsd/qcomgpsd.py b/system/qcomgpsd/qcomgpsd.py index 59f5ac0b50..47d64ff4e9 100755 --- a/system/qcomgpsd/qcomgpsd.py +++ b/system/qcomgpsd/qcomgpsd.py @@ -7,7 +7,7 @@ import math import time import requests import shutil -import subprocess +from serial import Serial import datetime from multiprocessing import Process, Event from typing import NoReturn @@ -90,9 +90,24 @@ measurementStatusGlonassFields = { def try_setup_logs(diag, logs): return setup_logs(diag, logs) -@retry(attempts=3, delay=1.0) -def at_cmd(cmd: str) -> str | None: - return subprocess.check_output(f"mmcli -m any --timeout 30 --command='{cmd}'", shell=True, encoding='utf8') +AT_PORT = "/dev/modem_at0" + +@retry(attempts=5, delay=1.0) +def at_cmd(cmd: str) -> str: + with Serial(AT_PORT, baudrate=115200, timeout=5) as ser: + ser.reset_input_buffer() + ser.write(f"{cmd}\r".encode()) + lines = [] + while True: + line = ser.readline() + if not line: + raise RuntimeError(f"AT command timeout: {cmd}") + line = line.decode('utf-8', errors='replace').strip() + if line in ("OK", "ERROR") or line.startswith("+CME ERROR"): + break + if line and line != cmd: + lines.append(line) + return '\n'.join(lines) def gps_enabled() -> bool: return "QGPS: 1" in at_cmd("AT+QGPS?") @@ -131,6 +146,7 @@ def downloader_loop(event): @retry(attempts=5, delay=0.2, ignore_failure=True) def inject_assistance(): + import subprocess cmd = f"mmcli -m any --timeout 30 --location-inject-assistance-data={ASSIST_DATA_FILE}" subprocess.check_output(cmd, stderr=subprocess.PIPE, shell=True) cloudlog.info("successfully loaded assistance data") @@ -207,13 +223,19 @@ def teardown_quectel(diag): try_setup_logs(diag, []) -def wait_for_modem(cmd="AT+QGPS?"): +def wait_for_modem(): cloudlog.warning("waiting for modem to come up") + while not os.path.exists(AT_PORT): + time.sleep(0.5) + # wait until the modem GNSS subsystem responds while True: - ret = subprocess.call(f"mmcli -m any --timeout 10 --command=\"{cmd}\"", stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, shell=True) - if ret == 0: - return - time.sleep(0.1) + try: + resp = at_cmd("AT+QGPS?") + if "+QGPS:" in resp: + return + except Exception: + pass + time.sleep(0.5) def main() -> NoReturn: @@ -335,6 +357,9 @@ def main() -> NoReturn: report = unpack_position(log_payload) if report["u_PosSource"] != 2: continue + # uint16_t max is an invalid sentinel value from the modem + if report['w_GpsWeekNumber'] >= 0xFFFF: + continue vNED = [report["q_FltVelEnuMps[1]"], report["q_FltVelEnuMps[0]"], -report["q_FltVelEnuMps[2]"]] vNEDsigma = [report["q_FltVelSigmaMps[1]"], report["q_FltVelSigmaMps[0]"], -report["q_FltVelSigmaMps[2]"]] diff --git a/system/qcomgpsd/tests/test_qcomgpsd.py b/system/qcomgpsd/tests/test_qcomgpsd.py index 2fc6205ea4..75e8707591 100644 --- a/system/qcomgpsd/tests/test_qcomgpsd.py +++ b/system/qcomgpsd/tests/test_qcomgpsd.py @@ -1,9 +1,7 @@ import os import pytest -import json import time import datetime -import subprocess import cereal.messaging as messaging from openpilot.system.qcomgpsd.qcomgpsd import at_cmd, wait_for_modem @@ -44,15 +42,13 @@ class TestRawgpsd: return self.sm.updated['qcomGnss'] def test_no_crash_double_command(self): + wait_for_modem() at_cmd("AT+QGPSDEL=0") at_cmd("AT+QGPSDEL=0") def test_wait_for_modem(self): os.system("sudo systemctl stop ModemManager") managed_processes['qcomgpsd'].start() - assert not self._wait_for_output(5) - - os.system("sudo systemctl restart ModemManager") assert self._wait_for_output(30) def test_startup_time(self, subtests): @@ -61,7 +57,7 @@ class TestRawgpsd: os.system("sudo systemctl stop systemd-resolved") with subtests.test(internet=internet): managed_processes['qcomgpsd'].start() - assert self._wait_for_output(7) + assert self._wait_for_output(30) managed_processes['qcomgpsd'].stop() def test_turns_off_gnss(self, subtests): @@ -71,14 +67,15 @@ class TestRawgpsd: time.sleep(s) managed_processes['qcomgpsd'].stop() - ls = subprocess.check_output("mmcli -m any --location-status --output-json", shell=True, encoding='utf-8') - loc_status = json.loads(ls) - assert set(loc_status['modem']['location']['enabled']) <= {'3gpp-lac-ci'} + wait_for_modem() + resp = at_cmd("AT+QGPS?") + assert "+QGPS: 0" in resp def check_assistance(self, should_be_loaded): # after QGPSDEL: '+QGPSXTRADATA: 0,"1980/01/05,19:00:00"' # after loading: '+QGPSXTRADATA: 10080,"2023/06/24,19:00:00"' + wait_for_modem() out = at_cmd("AT+QGPSXTRADATA?") out = out.split("+QGPSXTRADATA:")[1].split("'")[0].strip() valid_duration, injected_time_str = out.split(",", 1) @@ -92,20 +89,23 @@ class TestRawgpsd: assert injected_time_str[:] == '1980/01/05,19:00:00'[:] assert valid_duration == '0' + @pytest.mark.skip(reason="XTRA injection via QMI needs debugging on AGNOS 17") def test_assistance_loading(self): managed_processes['qcomgpsd'].start() - assert self._wait_for_output(10) + assert self._wait_for_output(30) managed_processes['qcomgpsd'].stop() self.check_assistance(True) + @pytest.mark.skip(reason="XTRA injection via QMI needs debugging on AGNOS 17") def test_no_assistance_loading(self): os.system("sudo systemctl stop systemd-resolved") managed_processes['qcomgpsd'].start() - assert self._wait_for_output(10) + assert self._wait_for_output(30) managed_processes['qcomgpsd'].stop() self.check_assistance(False) + @pytest.mark.skip(reason="XTRA injection via QMI needs debugging on AGNOS 17") def test_late_assistance_loading(self): os.system("sudo systemctl stop systemd-resolved") diff --git a/system/timed.py b/system/timed.py index b7131b04c0..c74ba51da5 100755 --- a/system/timed.py +++ b/system/timed.py @@ -5,7 +5,7 @@ import time from typing import NoReturn import cereal.messaging as messaging -from openpilot.common.time_helpers import min_date, system_time_valid +from openpilot.common.time_helpers import min_date, MAX_DATE, system_time_valid from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params from openpilot.common.gps import get_gps_location_service @@ -52,7 +52,7 @@ def main() -> NoReturn: continue if not gps.hasFix: continue - if gps_time < min_date(): + if gps_time < min_date() or gps_time > MAX_DATE: continue set_time(gps_time) diff --git a/system/ubloxd/ubloxd.py b/system/ubloxd/ubloxd.py index e55cadcf78..78429a847b 100755 --- a/system/ubloxd/ubloxd.py +++ b/system/ubloxd/ubloxd.py @@ -365,7 +365,7 @@ class UbloxMsgParser: assert isinstance(s1, Glonass.String1) eph.p1 = int(s1.p1) tk = int(s1.t_k) - eph.tkDEPRECATED = tk + eph.deprecated.tk = tk eph.xVel = float(s1.x_vel) * math.pow(2, -20) eph.xAccel = float(s1.x_accel) * math.pow(2, -30) eph.x = float(s1.x) * math.pow(2, -11) diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index df4153067c..a9e2982c95 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -1,5 +1,6 @@ import atexit import cffi +import math import os import queue import time @@ -96,7 +97,6 @@ FONT_DIR = ASSETS_DIR.joinpath("fonts") class FontWeight(StrEnum): - LIGHT = "Inter-Light.fnt" NORMAL = "Inter-Regular.fnt" if BIG_UI else "Inter-Medium.fnt" MEDIUM = "Inter-Medium.fnt" BOLD = "Inter-Bold.fnt" @@ -172,6 +172,10 @@ class MouseState: self._rk.keep_time() def _handle_mouse_event(self): + # TODO: read touch events from evdev directly to get real kernel timestamps. + # Polling at 140Hz with time.monotonic() causes timing jitter that makes scroll + # velocity oscillate (alternating high/low). Real timestamps would also let us + # detect swipe-stop-lift via event gaps instead of the fragile decel heuristic. for slot in range(MAX_TOUCH_SLOTS): mouse_pos = rl.get_touch_position(slot) x = mouse_pos.x / self._scale if self._scale != 1.0 else mouse_pos.x @@ -283,7 +287,7 @@ class GuiApplication(GuiApplicationExt): if self._scale != 1.0: rl.set_mouse_scale(1 / self._scale, 1 / self._scale) if needs_render_texture: - self._render_texture = rl.load_render_texture(self._width, self._height) + self._render_texture = rl.load_render_texture(self._scaled_width, self._scaled_height) rl.set_texture_filter(self._render_texture.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) if RECORD: @@ -294,13 +298,13 @@ class GuiApplication(GuiApplicationExt): '-nostats', # Suppress encoding progress '-f', 'rawvideo', # Input format '-pix_fmt', 'rgba', # Input pixel format - '-s', f'{self._width}x{self._height}', # Input resolution + '-s', f'{self._scaled_width}x{self._scaled_height}', # Input resolution '-r', str(fps), # Input frame rate '-i', 'pipe:0', # Input from stdin '-vf', 'vflip,format=yuv420p', # Flip vertically and convert to yuv420p '-r', str(output_fps), # Output frame rate (for speed multiplier) '-c:v', 'libx264', - '-preset', 'ultrafast', + '-preset', 'veryfast', '-crf', str(RECORD_QUALITY) ] if RECORD_BITRATE: @@ -324,6 +328,7 @@ class GuiApplication(GuiApplicationExt): self._set_styles() self._load_fonts() self._patch_text_functions() + self._patch_scissor_mode() if BURN_IN_MODE and self._burn_in_shader is None: self._burn_in_shader = rl.load_shader_from_memory(BURN_IN_VERTEX_SHADER, BURN_IN_FRAGMENT_SHADER) @@ -436,6 +441,9 @@ class GuiApplication(GuiApplicationExt): return self._nav_stack[-1] return None + def widget_in_stack(self, widget: object) -> bool: + return widget in self._nav_stack + def add_nav_stack_tick(self, tick_function: Callable[[], None]): if tick_function not in self._nav_stack_ticks: self._nav_stack_ticks.append(tick_function) @@ -449,6 +457,11 @@ class GuiApplication(GuiApplicationExt): def texture(self, asset_path: str, width: int | None = None, height: int | None = None, alpha_premultiply=False, keep_aspect_ratio=True, flip_x: bool = False) -> rl.Texture: + if width is not None: + width = round(width) + if height is not None: + height = round(height) + cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}_{keep_aspect_ratio}_{flip_x}" if cache_key in self._textures: return self._textures[cache_key] @@ -456,6 +469,12 @@ class GuiApplication(GuiApplicationExt): with as_file(ASSETS_DIR.joinpath(asset_path)) as fspath: image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio, flip_x) texture_obj = self._load_texture_from_image(image_obj) + + # Set logical size so widget layout math stays at 1x coordinates + if self._scale != 1.0 and width is not None and height is not None: + texture_obj.width = width + texture_obj.height = height + self._textures[cache_key] = texture_obj return texture_obj @@ -467,6 +486,11 @@ class GuiApplication(GuiApplicationExt): if alpha_premultiply: rl.image_alpha_premultiply(image) + # Scale up load size for sharper rendering, capped at source resolution + if self._scale != 1.0 and width is not None and height is not None: + width = min(int(width * self._scale), image.width) + height = min(int(height * self._scale), image.height) + if width is not None and height is not None: same_dimensions = image.width == width and image.height == height @@ -590,6 +614,10 @@ class GuiApplication(GuiApplicationExt): rl.begin_drawing() rl.clear_background(rl.BLACK) + if self._scale != 1.0: + rl.rl_push_matrix() + rl.rl_scalef(self._scale, self._scale, 1.0) + # Allow a Widget to still run a function regardless of the stack depth for tick in self._nav_stack_ticks: tick() @@ -600,11 +628,14 @@ class GuiApplication(GuiApplicationExt): yield True + if self._scale != 1.0: + rl.rl_pop_matrix() + if self._render_texture: rl.end_texture_mode() rl.begin_drawing() rl.clear_background(rl.BLACK) - src_rect = rl.Rectangle(0, 0, float(self._width), -float(self._height)) + src_rect = rl.Rectangle(0, 0, float(self._scaled_width), -float(self._scaled_height)) dst_rect = rl.Rectangle(0, 0, float(self._scaled_width), float(self._scaled_height)) texture = self._render_texture.texture if texture: @@ -661,7 +692,8 @@ class GuiApplication(GuiApplicationExt): fnt_path = fspath / font_weight_file font = rl.load_font(fnt_path.as_posix()) if font_weight_file != FontWeight.UNIFONT: - rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) + rl.gen_texture_mipmaps(font.texture) + rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR) self._fonts[font_weight_file] = font rl.gui_set_font(self._fonts[FontWeight.NORMAL]) @@ -683,6 +715,20 @@ class GuiApplication(GuiApplicationExt): rl.draw_text_ex = _draw_text_ex_scaled + def _patch_scissor_mode(self): + if self._scale == 1.0: + return + + if not hasattr(rl, "_orig_begin_scissor_mode"): + rl._orig_begin_scissor_mode = rl.begin_scissor_mode + + def _begin_scissor_mode_scaled(x, y, width, height): + return rl._orig_begin_scissor_mode( + int(x * self._scale), int(y * self._scale), + int(math.ceil(width * self._scale)), int(math.ceil(height * self._scale))) + + rl.begin_scissor_mode = _begin_scissor_mode_scaled + def _set_log_callback(self): ffi_libc = cffi.FFI() ffi_libc.cdef(""" diff --git a/system/ui/lib/emoji.py b/system/ui/lib/emoji.py index 37228e2d45..ad4c272c8d 100644 --- a/system/ui/lib/emoji.py +++ b/system/ui/lib/emoji.py @@ -1,12 +1,13 @@ 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 -_emoji_font: ImageFont.FreeTypeFont | None = None _cache: dict[str, rl.Texture] = {} EMOJI_REGEX = re.compile( @@ -33,11 +34,10 @@ EMOJI_REGEX = re.compile( flags=re.UNICODE ) -def _load_emoji_font() -> ImageFont.FreeTypeFont | None: - global _emoji_font - if _emoji_font is None: - _emoji_font = ImageFont.truetype(str(FONT_DIR.joinpath("NotoColorEmoji.ttf")), 109) - return _emoji_font +@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)] diff --git a/system/ui/lib/multilang.py b/system/ui/lib/multilang.py index 506fc13b0a..9d25427867 100644 --- a/system/ui/lib/multilang.py +++ b/system/ui/lib/multilang.py @@ -74,7 +74,7 @@ def load_translations(path) -> tuple[dict[str, str], dict[str, list[str]]]: translations: msgid -> msgstr plurals: msgid -> [msgstr[0], msgstr[1], ...] """ - with open(str(path), encoding='utf-8') as f: + with path.open(encoding='utf-8') as f: lines = f.readlines() translations: dict[str, str] = {} diff --git a/system/ui/lib/scroll_panel2.py b/system/ui/lib/scroll_panel2.py index e2a548ba26..18fd8a9a67 100644 --- a/system/ui/lib/scroll_panel2.py +++ b/system/ui/lib/scroll_panel2.py @@ -20,6 +20,21 @@ MAX_SPEED = 10000.0 # px/s DEBUG = os.getenv("DEBUG_SCROLL", "0") == "1" +# Weights older (steadier) velocity samples more heavily on release. +# Finger-lift samples are noisy; trusting earlier samples gives consistent fling velocity. +# Reverse-engineered from iOS UIScrollView (tuned at 120Hz touch) by Flutter team: +# https://github.com/flutter/flutter/pull/60501 +# 3 samples ≈ 25ms at 120Hz (iOS) / ~21ms at 140Hz (comma). Scale if touch rate changes. +def weighted_velocity(buffer: deque) -> float: + if len(buffer) >= 3: + return buffer[-3] * 0.6 + buffer[-2] * 0.35 + buffer[-1] * 0.05 + elif len(buffer) == 2: + return buffer[-2] * 0.7 + buffer[-1] * 0.3 + elif len(buffer) == 1: + return buffer[-1] + return 0.0 + + # from https://ariya.io/2011/10/flick-list-with-its-momentum-scrolling-and-deceleration class ScrollState(Enum): STEADY = 0 @@ -151,7 +166,13 @@ class GuiScrollPanel2: # Touch rejection: when releasing finger after swiping and stopping, panel # reports a few erroneous touch events with high velocity, try to ignore. - # If velocity decelerates very quickly, assume user doesn't intend to auto scroll + # If velocity decelerates very quickly, assume user doesn't intend to auto scroll. + # Catches two cases: 1) swipe, stop finger, then lift (stale high velocity in buffer) + # 2) dirty finger lift where finger rotates/slides producing spurious velocity spike. + # TODO: this heuristic false-positives on fast swipes because 140Hz touch polling + # jitter causes velocity to oscillate (not real deceleration). Better approaches: + # - Use evdev kernel timestamps to eliminate velocity oscillation at the source + # - Replace with a time-since-last-event check (40ms timeout) for swipe-stop-lift high_decel = False if len(self._velocity_buffer) > 2: # We limit max to first half since final few velocities can surpass first few @@ -166,6 +187,8 @@ class GuiScrollPanel2: print('deceleration too high, going to STEADY') high_decel = True + self._velocity = weighted_velocity(self._velocity_buffer) + # If final velocity is below some threshold, switch to steady state too low_speed = abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING * 1.5 # plus some margin diff --git a/system/ui/lib/wifi_manager.py b/system/ui/lib/wifi_manager.py index 1084e9e533..b83f7bacc9 100644 --- a/system/ui/lib/wifi_manager.py +++ b/system/ui/lib/wifi_manager.py @@ -854,8 +854,12 @@ class WifiManager: # NOTE: AccessPoints property may exclude hidden APs (use GetAllAccessPoints method if needed) wifi_addr = DBusAddress(self._wifi_device, NM, interface=NM_WIRELESS_IFACE) - wifi_props = self._router_main.send_and_get_reply(Properties(wifi_addr).get_all()).body[0] - ap_paths = wifi_props.get('AccessPoints', ('ao', []))[1] + wifi_props_reply = self._router_main.send_and_get_reply(Properties(wifi_addr).get_all()) + if wifi_props_reply.header.message_type == MessageType.error: + cloudlog.warning(f"Failed to get WiFi properties: {wifi_props_reply}") + return + + ap_paths = wifi_props_reply.body[0].get('AccessPoints', ('ao', []))[1] aps: dict[str, list[AccessPoint]] = {} diff --git a/system/ui/mici_reset.py b/system/ui/mici_reset.py index a459927eeb..9cc6e7f3f8 100755 --- a/system/ui/mici_reset.py +++ b/system/ui/mici_reset.py @@ -1,18 +1,17 @@ #!/usr/bin/env python3 import os import sys -import threading import time +import threading from enum import IntEnum import pyray as rl -from openpilot.system.hardware import PC -from openpilot.system.ui.lib.application import gui_app, FontWeight -from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.slider import SmallSlider -from openpilot.system.ui.widgets.button import SmallButton, FullRoundedButton -from openpilot.system.ui.widgets.label import gui_label, gui_text_box +from openpilot.system.hardware import HARDWARE, PC +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.mici_setup import GreyBigButton, FailedPage +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationCircleButton USERDATA = "/dev/disk/by-partlabel/userdata" TIMEOUT = 3*60 @@ -21,35 +20,85 @@ TIMEOUT = 3*60 class ResetMode(IntEnum): USER_RESET = 0 # user initiated a factory reset from openpilot RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover - FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata + TAP_RESET = 2 # user initiated a factory reset by tapping the screen during boot -class ResetState(IntEnum): - NONE = 0 - RESETTING = 1 - FAILED = 2 +class ResetFailedPage(FailedPage): + def __init__(self): + super().__init__(None, "reset failed", "reboot to try again", icon="icons_mici/setup/reset_failed.png") + + def show_event(self): + super().show_event() + self._nav_bar._alpha = 0.0 # not dismissable + + def _back_enabled(self) -> bool: + return False -class Reset(Widget): +class ResettingPage(BigDialog): + DOT_STEP = 0.6 + + def __init__(self): + super().__init__("resetting device", "this may take up to\na minute...", + gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64)) + self._show_time = 0.0 + + def show_event(self): + super().show_event() + self._nav_bar._alpha = 0.0 # not dismissable + self._show_time = rl.get_time() + + def _back_enabled(self) -> bool: + return False + + def _render(self, _): + t = (rl.get_time() - self._show_time) % (self.DOT_STEP * 2) + dots = "." * min(int(t / (self.DOT_STEP / 4)), 3) + self._card.set_value(f"this may take up to\na minute{dots}") + super()._render(_) + + +class Reset(Scroller): def __init__(self, mode): super().__init__() self._mode = mode - self._previous_reset_state = None - self._reset_state = ResetState.NONE + self._previous_active_widget = None + self._reset_failed = False + self._timeout_st = time.monotonic() - self._cancel_button = SmallButton("cancel") - self._cancel_button.set_click_callback(gui_app.request_close) + self._resetting_page = ResettingPage() + self._reset_failed_page = ResetFailedPage() - self._reboot_button = FullRoundedButton("reboot") - self._reboot_button.set_click_callback(self._do_reboot) + self._reset_button = BigConfirmationCircleButton("reset &\nerase", gui_app.texture("icons_mici/settings/device/uninstall.png", 70, 70), + self._start_reset, exit_on_confirm=False, red=True) + self._cancel_button = BigConfirmationCircleButton("cancel", gui_app.texture("icons_mici/setup/cancel.png", 64, 64), + gui_app.request_close, exit_on_confirm=False) + self._reboot_button = BigConfirmationCircleButton("reboot\ndevice", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70), + HARDWARE.reboot, exit_on_confirm=False) - self._confirm_slider = SmallSlider("reset", self._confirm) + # show reboot button if in recover mode + self._cancel_button.set_visible(mode != ResetMode.RECOVER) + self._reboot_button.set_visible(mode == ResetMode.RECOVER) - def _do_reboot(self): - if PC: - return + main_card = GreyBigButton("factory reset", "resetting erases\nall user content & data", + gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64)) + self._scroller.add_widget(main_card) - os.system("sudo reboot") + if mode != ResetMode.USER_RESET: + self._scroller.add_widget(GreyBigButton("", "Resetting erases all user content & data.")) + if mode == ResetMode.RECOVER: + main_card.set_value("user data partition\ncould not be mounted") + elif mode == ResetMode.TAP_RESET: + main_card.set_value("reset triggered by\ntapping the screen") + + self._scroller.add_widgets([ + GreyBigButton("", "For a deeper reset, go to\nhttps://flash.comma.ai"), + self._cancel_button, + self._reboot_button, + self._reset_button, + ]) + + gui_app.add_nav_stack_tick(self._nav_stack_tick) def _do_erase(self): if PC: @@ -63,86 +112,38 @@ class Reset(Widget): if rm == 0 or fmt == 0: os.system("sudo reboot") else: - self._reset_state = ResetState.FAILED + self._reset_failed = True - def start_reset(self): - self._reset_state = ResetState.RESETTING - threading.Timer(0.1, self._do_erase).start() + def _start_reset(self): + def do_erase_thread(): + threading.Thread(target=self._do_erase, daemon=True).start() - def _update_state(self): - if self._reset_state != self._previous_reset_state: - self._previous_reset_state = self._reset_state + self._resetting_page.set_shown_callback(do_erase_thread) + gui_app.push_widget(self._resetting_page) + + def _nav_stack_tick(self): + if self._reset_failed: + self._reset_failed = False + gui_app.pop_widgets_to(self, lambda: gui_app.push_widget(self._reset_failed_page)) + + active_widget = gui_app.get_active_widget() + if active_widget != self._previous_active_widget: + self._previous_active_widget = active_widget self._timeout_st = time.monotonic() - elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT: + elif self._mode != ResetMode.RECOVER and active_widget != self._resetting_page and (time.monotonic() - self._timeout_st) > TIMEOUT: exit(0) - def _render(self, rect: rl.Rectangle): - label_rect = rl.Rectangle(rect.x + 8, rect.y + 8, rect.width, 50) - gui_label(label_rect, "factory reset", 48, font_weight=FontWeight.BOLD, - color=rl.Color(255, 255, 255, int(255 * 0.9))) - - text_rect = rl.Rectangle(rect.x + 8, rect.y + 56, rect.width - 8 * 2, rect.height - 80) - gui_text_box(text_rect, self._get_body_text(), 36, font_weight=FontWeight.ROMAN, line_scale=0.9) - - if self._reset_state != ResetState.RESETTING: - # fade out cancel button as slider is moved, set visible to prevent pressing invisible cancel - self._cancel_button.set_opacity(1.0 - self._confirm_slider.slider_percentage) - self._cancel_button.set_visible(self._confirm_slider.slider_percentage < 0.8) - - if self._mode == ResetMode.RECOVER: - self._cancel_button.set_text("reboot") - self._cancel_button.render(rl.Rectangle( - rect.x + 8, - rect.y + rect.height - self._cancel_button.rect.height, - self._cancel_button.rect.width, - self._cancel_button.rect.height)) - elif self._mode == ResetMode.USER_RESET and self._reset_state != ResetState.FAILED: - self._cancel_button.render(rl.Rectangle( - rect.x + 8, - rect.y + rect.height - self._cancel_button.rect.height, - self._cancel_button.rect.width, - self._cancel_button.rect.height)) - - if self._reset_state != ResetState.FAILED: - self._confirm_slider.render(rl.Rectangle( - rect.x + rect.width - self._confirm_slider.rect.width, - rect.y + rect.height - self._confirm_slider.rect.height, - self._confirm_slider.rect.width, - self._confirm_slider.rect.height)) - else: - self._reboot_button.render(rl.Rectangle( - rect.x + 8, - rect.y + rect.height - self._reboot_button.rect.height, - self._reboot_button.rect.width, - self._reboot_button.rect.height)) - - def _confirm(self): - self.start_reset() - - def _get_body_text(self): - if self._reset_state == ResetState.RESETTING: - return "Resetting device... This may take up to a minute." - if self._reset_state == ResetState.FAILED: - return "Reset failed. Reboot to try again." - if self._mode == ResetMode.RECOVER: - return "Unable to mount data partition. It may be corrupted." - return "All content and settings will be erased." - def main(): mode = ResetMode.USER_RESET if len(sys.argv) > 1: if sys.argv[1] == '--recover': mode = ResetMode.RECOVER - elif sys.argv[1] == "--format": - mode = ResetMode.FORMAT + elif sys.argv[1] == '--tap-reset': + mode = ResetMode.TAP_RESET gui_app.init_window("System Reset") reset = Reset(mode) - - if mode == ResetMode.FORMAT: - reset.start_reset() - gui_app.push_widget(reset) for _ in gui_app.render(): diff --git a/system/ui/mici_setup.py b/system/ui/mici_setup.py index 76fdfd8c68..d04f08ca37 100755 --- a/system/ui/mici_setup.py +++ b/system/ui/mici_setup.py @@ -1,61 +1,52 @@ #!/usr/bin/env python3 -from abc import abstractmethod import os import re +import ssl import threading import time import urllib.request import urllib.error from urllib.parse import urlparse -import shutil from collections.abc import Callable import pyray as rl from cereal import log -from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.common.filter_simple import BounceFilter +from openpilot.system.hardware import HARDWARE, TICI 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 from openpilot.common.utils import run_cmd -from openpilot.system.hardware import HARDWARE, TICI from openpilot.system.ui.lib.application import gui_app, FontWeight -from openpilot.system.ui.lib.wifi_manager import WifiManager -from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 +from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.nav_widget import NavWidget -from openpilot.system.ui.widgets.button import (IconButton, SmallButton, WideRoundedButton, SmallerRoundedButton, - SmallCircleIconButton, WidishRoundedButton, FullRoundedButton) from openpilot.system.ui.widgets.label import UnifiedLabel -from openpilot.system.ui.widgets.slider import LargerSlider, SmallSlider -from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiUIMici -from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog +from openpilot.system.ui.widgets.scroller import Scroller, NavScroller, ITEM_SPACING +from openpilot.system.ui.widgets.slider import LargerSlider +from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiNetworkButton +from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici +from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog, BigConfirmationCircleButton +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, GreyBigButton NetworkType = log.DeviceState.NetworkType OPENPILOT_URL = "https://openpilot.comma.ai" USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}" -CONTINUE_PATH = "/data/continue.sh" -TMP_CONTINUE_PATH = "/data/continue.sh.new" -INSTALL_PATH = "/data/openpilot" -VALID_CACHE_PATH = "/data/.openpilot_cache" -INSTALLER_SOURCE_PATH = "/usr/comma/installer" INSTALLER_DESTINATION_PATH = "/tmp/installer" INSTALLER_URL_PATH = "/tmp/installer_url" -CONTINUE = """#!/usr/bin/env bash - -cd /data/openpilot -exec ./launch_openpilot.sh -""" - class NetworkConnectivityMonitor: def __init__(self, should_check: Callable[[], bool] | None = None): self.network_connected = threading.Event() self.wifi_connected = threading.Event() + self.recheck_event = threading.Event() self._should_check = should_check or (lambda: True) self._stop_event = threading.Event() + self._last_timesyncd_restart = 0.0 self._thread: threading.Thread | None = None def start(self): @@ -74,15 +65,32 @@ class NetworkConnectivityMonitor: self.network_connected.clear() self.wifi_connected.clear() + def invalidate(self): + self.recheck_event.set() + self.reset() + def _run(self): while not self._stop_event.is_set(): if self._should_check(): try: request = urllib.request.Request(OPENPILOT_URL, method="HEAD") urllib.request.urlopen(request, timeout=2.0) + + # Discard stale result if invalidated during request + if self.recheck_event.is_set(): + self.recheck_event.clear() + continue + self.network_connected.set() if HARDWARE.get_network_type() == NetworkType.wifi: self.wifi_connected.set() + except urllib.error.URLError as e: + if (isinstance(e.reason, ssl.SSLCertVerificationError) and + not system_time_valid() and + time.monotonic() - self._last_timesyncd_restart > 5): + self._last_timesyncd_restart = time.monotonic() + run_cmd(["sudo", "systemctl", "restart", "systemd-timesyncd"]) + self.reset() except Exception: self.reset() else: @@ -102,7 +110,7 @@ class StartPage(Widget): self._start_bg_txt = gui_app.texture("icons_mici/setup/start_button.png", 500, 224, keep_aspect_ratio=False) self._start_bg_pressed_txt = gui_app.texture("icons_mici/setup/start_button_pressed.png", 500, 224, keep_aspect_ratio=False) - self._scale_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps) + self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) self._click_delay = 0.075 def _render(self, rect: rl.Rectangle): @@ -122,9 +130,9 @@ class SoftwareSelectionPage(NavWidget): use_custom_software_callback: Callable): super().__init__() - self._openpilot_slider = LargerSlider("slide to use\nopenpilot", use_openpilot_callback) + self._openpilot_slider = self._child(LargerSlider("slide to install\nopenpilot", use_openpilot_callback)) self._openpilot_slider.set_enabled(lambda: self.enabled and not self.is_dismissing) - self._custom_software_slider = LargerSlider("slide to use\ncustom software", use_custom_software_callback, green=False) + self._custom_software_slider = self._child(LargerSlider("slide to install\ncustom software", use_custom_software_callback, green=False, shimmer_offset=0.4)) self._custom_software_slider.set_enabled(lambda: self.enabled and not self.is_dismissing) def show_event(self): @@ -161,202 +169,28 @@ class SoftwareSelectionPage(NavWidget): self._custom_software_slider.render(custom_software_rect) -class TermsHeader(Widget): - def __init__(self, text: str, icon_texture: rl.Texture): - super().__init__() - - self._title = UnifiedLabel(text, 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - font_weight=FontWeight.BOLD, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, - line_height=0.8) - self._icon_texture = icon_texture - - self.set_rect(rl.Rectangle(0, 0, gui_app.width - 16 * 2, self._icon_texture.height)) - - def set_title(self, text: str): - self._title.set_text(text) - - def set_icon(self, icon_texture: rl.Texture): - self._icon_texture = icon_texture - - def _render(self, _): - rl.draw_texture_ex(self._icon_texture, rl.Vector2(self._rect.x, self._rect.y), - 0.0, 1.0, rl.WHITE) - - # May expand outside parent rect - title_content_height = self._title.get_content_height(int(self._rect.width - self._icon_texture.width - 16)) - title_rect = rl.Rectangle( - self._rect.x + self._icon_texture.width + 16, - self._rect.y + (self._rect.height - title_content_height) / 2, - self._rect.width - self._icon_texture.width - 16, - title_content_height, - ) - self._title.render(title_rect) - - -class TermsPage(Widget): - ITEM_SPACING = 20 - - def __init__(self, continue_callback: Callable, back_callback: Callable | None = None, - back_text: str = "back", continue_text: str = "accept"): - super().__init__() - - # TODO: use Scroller - self._scroll_panel = GuiScrollPanel2(horizontal=False) - - self._continue_text = continue_text - self._continue_slider: bool = continue_text in ("reboot", "power off") - self._continue_button: WideRoundedButton | FullRoundedButton | SmallSlider - if self._continue_slider: - self._continue_button = SmallSlider(continue_text, confirm_callback=continue_callback) - self._scroll_panel.set_enabled(lambda: not self._continue_button.is_pressed) - elif back_callback is not None: - self._continue_button = WideRoundedButton(continue_text) - else: - self._continue_button = FullRoundedButton(continue_text) - self._continue_button.set_enabled(False) - self._continue_button.set_opacity(0.0) - self._continue_button.set_touch_valid_callback(self._scroll_panel.is_touch_valid) - if not self._continue_slider: - self._continue_button.set_click_callback(continue_callback) - - self._enable_back = back_callback is not None - self._back_button = SmallButton(back_text) - self._back_button.set_opacity(0.0) - self._back_button.set_touch_valid_callback(self._scroll_panel.is_touch_valid) - self._back_button.set_click_callback(back_callback) - - self._scroll_down_indicator = IconButton(gui_app.texture("icons_mici/setup/scroll_down_indicator.png", 64, 78)) - self._scroll_down_indicator.set_enabled(False) - - def reset(self): - self._scroll_panel.set_offset(0) - self._continue_button.set_enabled(False) - self._continue_button.set_opacity(0.0) - self._back_button.set_enabled(False) - self._back_button.set_opacity(0.0) - self._scroll_down_indicator.set_opacity(1.0) - - def show_event(self): - super().show_event() - self.reset() - - @property - @abstractmethod - def _content_height(self): - pass - - @property - def _scrolled_down_offset(self): - return -self._content_height + (self._continue_button.rect.height + 16 + 30) - - @abstractmethod - def _render_content(self, scroll_offset): - pass - - def _render(self, _): - rl.draw_rectangle_rec(self._rect, rl.BLACK) - scroll_offset = round(self._scroll_panel.update(self._rect, self._content_height + self._continue_button.rect.height + 16)) - - if scroll_offset <= self._scrolled_down_offset: - # don't show back if not enabled - if self._enable_back: - self._back_button.set_enabled(True) - self._back_button.set_opacity(1.0, smooth=True) - self._continue_button.set_enabled(True) - self._continue_button.set_opacity(1.0, smooth=True) - self._scroll_down_indicator.set_opacity(0.0, smooth=True) - else: - self._back_button.set_enabled(False) - self._back_button.set_opacity(0.0, smooth=True) - self._continue_button.set_enabled(False) - self._continue_button.set_opacity(0.0, smooth=True) - self._scroll_down_indicator.set_opacity(1.0, smooth=True) - - # Render content - self._render_content(scroll_offset) - - # black gradient at top and bottom for scrolling content - rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y), - int(self._rect.width), 20, rl.BLACK, rl.BLANK) - rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height - 20), - int(self._rect.width), 20, rl.BLANK, rl.BLACK) - - # fade out back button as slider is moved - if self._continue_slider and scroll_offset <= self._scrolled_down_offset: - self._back_button.set_opacity(1.0 - self._continue_button.slider_percentage) - self._back_button.set_visible(self._continue_button.slider_percentage < 0.99) - - self._back_button.render(rl.Rectangle( - self._rect.x + 8, - self._rect.y + self._rect.height - self._back_button.rect.height, - self._back_button.rect.width, - self._back_button.rect.height, - )) - - continue_x = self._rect.x + 8 - if self._enable_back: - continue_x = self._rect.x + self._rect.width - self._continue_button.rect.width - 8 - if self._continue_slider: - continue_x += 8 - self._continue_button.render(rl.Rectangle( - continue_x, - self._rect.y + self._rect.height - self._continue_button.rect.height, - self._continue_button.rect.width, - self._continue_button.rect.height, - )) - - self._scroll_down_indicator.render(rl.Rectangle( - self._rect.x + self._rect.width - self._scroll_down_indicator.rect.width - 8, - self._rect.y + self._rect.height - self._scroll_down_indicator.rect.height - 8, - self._scroll_down_indicator.rect.width, - self._scroll_down_indicator.rect.height, - )) - - -class CustomSoftwareWarningPage(TermsPage): +class CustomSoftwareWarningPage(NavScroller): def __init__(self, continue_callback: Callable, back_callback: Callable): - super().__init__(continue_callback, back_callback) + super().__init__() + self.set_back_callback(back_callback) - self._title_header = TermsHeader("use caution installing\n3rd party software", - gui_app.texture("icons_mici/setup/warning.png", 66, 60)) - self._body = UnifiedLabel("• It has not been tested by comma.\n" + - "• It may not comply with relevant safety standards.\n" + - "• It may cause damage to your device and/or vehicle.\n", 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - font_weight=FontWeight.ROMAN) + self._continue_button = BigPillButton("next") + self._continue_button.set_click_callback(continue_callback) - self._restore_header = TermsHeader("how to backup &\nrestore", gui_app.texture("icons_mici/setup/restore.png", 60, 60)) - self._restore_body = UnifiedLabel("To restore your device to a factory state later, use https://flash.comma.ai", - 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - font_weight=FontWeight.ROMAN) - - @property - def _content_height(self): - return self._restore_body.rect.y + self._restore_body.rect.height - self._scroll_panel.get_offset() - - def _render_content(self, scroll_offset): - self._title_header.set_position(self._rect.x + 16, self._rect.y + 8 + scroll_offset) - self._title_header.render() - - body_rect = rl.Rectangle( - self._rect.x + 8, - self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING, - self._rect.width - 50, - self._body.get_content_height(int(self._rect.width - 50)), - ) - self._body.render(body_rect) - - self._restore_header.set_position(self._rect.x + 16, self._body.rect.y + self._body.rect.height + self.ITEM_SPACING) - self._restore_header.render() - - self._restore_body.render(rl.Rectangle( - self._rect.x + 8, - self._restore_header.rect.y + self._restore_header.rect.height + self.ITEM_SPACING, - self._rect.width - 50, - self._restore_body.get_content_height(int(self._rect.width - 50)), - )) + self._scroller.add_widgets([ + GreyBigButton("caution: installing\n3rd party software", "swipe down to go back", + gui_app.texture("icons_mici/setup/warning.png", 64, 58)), + GreyBigButton("", "• It has not been tested by comma."), + GreyBigButton("", "• It may not comply with safety standards."), + GreyBigButton("", "• It may damage your device and/or vehicle."), + GreyBigButton("how to restore to a\nfactory state later", "https://flash.comma.ai", + gui_app.texture("icons_mici/setup/restore.png", 64, 64)), + self._continue_button, + ]) -class DownloadingPage(Widget): +# TODO: unifi with updater's progress page +class DownloadingPage(NavWidget): def __init__(self): super().__init__() @@ -366,8 +200,12 @@ class DownloadingPage(Widget): font_weight=FontWeight.ROMAN, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) self._progress = 0 + def _back_enabled(self) -> bool: + return False + def show_event(self): super().show_event() + self._nav_bar._alpha = 0.0 # not dismissable self.set_progress(0) def set_progress(self, progress: int): @@ -391,151 +229,204 @@ class DownloadingPage(Widget): )) -class FailedPage(NavWidget): - def __init__(self, reboot_callback: Callable, retry_callback: Callable, title: str = "download failed"): +class FailedPage(NavScroller): + def __init__(self, retry_callback: Callable | None, title: str = "download failed", + description: str | None = None, icon: str = "icons_mici/setup/warning.png"): super().__init__() self.set_back_callback(retry_callback) - self._title_label = UnifiedLabel(title, 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - font_weight=FontWeight.DISPLAY) - self._reason_label = UnifiedLabel("", 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)), - font_weight=FontWeight.ROMAN) + self._reason_card = GreyBigButton("", "") + self._reason_card.set_visible(False) - self._reboot_slider = SmallSlider("reboot", reboot_callback) - self._reboot_slider.set_enabled(lambda: self.enabled) # for nav stack - - self._retry_button = SmallButton("retry") - self._retry_button.set_click_callback(retry_callback) - self._retry_button.set_enabled(lambda: self.enabled) # for nav stack + self._scroller.add_widgets([ + GreyBigButton(title, description or "swipe down to go\nback and try again", + gui_app.texture(icon, 64, 58)), + self._reason_card, + BigConfirmationCircleButton("reboot\ndevice", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70), + HARDWARE.reboot, exit_on_confirm=False), + ]) def set_reason(self, reason: str): - self._reason_label.set_text(reason) - - def show_event(self): - super().show_event() - self._reboot_slider.reset() - - def _render(self, rect: rl.Rectangle): - self._title_label.render(rl.Rectangle( - rect.x + 8, - rect.y + 10, - rect.width, - 64, - )) - - self._reason_label.render(rl.Rectangle( - rect.x + 8, - rect.y + 10 + 64, - rect.width, - 36, - )) - - self._retry_button.set_opacity(1 - self._reboot_slider.slider_percentage) - self._retry_button.render(rl.Rectangle( - self._rect.x + 8, - self._rect.y + self._rect.height - self._retry_button.rect.height, - self._retry_button.rect.width, - self._retry_button.rect.height, - )) - - self._reboot_slider.render(rl.Rectangle( - self._rect.x + self._rect.width - self._reboot_slider.rect.width, - self._rect.y + self._rect.height - self._reboot_slider.rect.height, - self._reboot_slider.rect.width, - self._reboot_slider.rect.height, - )) + if reason: + self._reason_card.set_value(reason) + self._reason_card.set_visible(True) + else: + self._reason_card.set_visible(False) -class NetworkSetupPage(NavWidget): +class BigPillButton(BigButton): + def __init__(self, *args, green: bool = False, disabled_background: bool = False, **kwargs): + self._green = green + self._disabled_background = disabled_background + super().__init__(*args, **kwargs) + + self._label.set_font_size(48) + self._label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + def _load_images(self): + if self._green: + self._txt_default_bg = gui_app.texture("icons_mici/setup/start_button.png", 402, 180) + self._txt_pressed_bg = gui_app.texture("icons_mici/setup/start_button_pressed.png", 402, 180) + else: + self._txt_default_bg = gui_app.texture("icons_mici/setup/continue.png", 402, 180) + self._txt_pressed_bg = gui_app.texture("icons_mici/setup/continue_pressed.png", 402, 180) + self._txt_disabled_bg = gui_app.texture("icons_mici/setup/continue_disabled.png", 402, 180) + + def set_green(self, green: bool): + if self._green != green: + self._green = green + self._load_images() + + def _update_label_layout(self): + # Don't change label text size + pass + + def _handle_background(self) -> tuple[rl.Texture, float, float, float]: + txt_bg, btn_x, btn_y, scale = super()._handle_background() + + if self._disabled_background: + txt_bg = self._txt_disabled_bg + return txt_bg, btn_x, btn_y, scale + + +class NetworkSetupPageBase(Scroller): def __init__(self, network_monitor: NetworkConnectivityMonitor, continue_callback: Callable[[bool], None], - back_callback: Callable[[], None] | None): + disable_connect_hint: bool = False): super().__init__() - self.set_back_callback(back_callback) self._wifi_manager = WifiManager() self._wifi_manager.set_active(True) self._network_monitor = network_monitor self._custom_software = False - self._prev_has_internet = False self._wifi_ui = WifiUIMici(self._wifi_manager) - self._no_wifi_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 58, 50) - self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 58, 50) - self._waiting_text = "waiting for internet..." - self._network_header = TermsHeader(self._waiting_text, self._no_wifi_txt) + self._connect_button = GreyBigButton("connect to\ninternet", "swipe down to go back", + gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 56, flip_x=True)) + self._connect_button.set_visible(not disable_connect_hint) - back_txt = gui_app.texture("icons_mici/setup/back_new.png", 37, 32) - self._back_button = SmallCircleIconButton(back_txt) - self._back_button.set_click_callback(back_callback) - self._back_button.set_enabled(lambda: self.enabled) # for nav stack - - self._wifi_button = SmallerRoundedButton("wifi") + self._wifi_button = WifiNetworkButton(self._wifi_manager) self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui)) - self._wifi_button.set_enabled(lambda: self.enabled) - self._continue_button = WidishRoundedButton("continue") - self._continue_button.set_enabled(False) + self._prev_has_internet = False + self._prev_wifi_connected = False + self._pending_has_internet_scroll: float | None = None # stores time to use as delay + self._pending_continue_grow_animation = False + self._pending_wifi_grow_animation = False + + def on_waiting_click(): + offset = (self._wifi_button.rect.x + self._wifi_button.rect.width / 2) - (self._rect.x + self._rect.width / 2) + self._scroller.scroll_to(offset, smooth=True, block_interaction=True) + # trigger grow when wifi button in view + self._pending_wifi_grow_animation = True + + self._waiting_button = BigPillButton("connect to\ncontinue", disabled_background=True) + self._waiting_button.set_click_callback(on_waiting_click) + self._continue_button = BigPillButton("install openpilot", green=True) self._continue_button.set_click_callback(lambda: continue_callback(self._custom_software)) + self._scroller.add_widgets([ + self._connect_button, + self._wifi_button, + self._continue_button, + self._waiting_button, + ]) + gui_app.add_nav_stack_tick(self._nav_stack_tick) def show_event(self): super().show_event() - self._prev_has_internet = False - self._network_monitor.reset() - self._set_has_internet(False) + # make sure we populate strength and ip immediately if already have wifi + self._wifi_manager.set_active(True) + self._prev_has_internet = self._has_internet + self._prev_wifi_connected = self._wifi_manager.wifi_state.status == ConnectStatus.CONNECTED + self._pending_has_internet_scroll = None + self._pending_continue_grow_animation = False + self._pending_wifi_grow_animation = False + + if self._prev_has_internet or self._prev_wifi_connected: + self.set_shown_callback(lambda: self._scroll_to_end_and_grow()) + + @property + def _has_internet(self) -> bool: + network_changing = self._wifi_ui.any_network_forgetting or self._wifi_manager.wifi_state.status == ConnectStatus.CONNECTING + if network_changing: + self._network_monitor.invalidate() + + has_internet = (self._network_monitor.network_connected.is_set() and + not network_changing and + not self._network_monitor.recheck_event.is_set()) + return has_internet def _nav_stack_tick(self): + # Only run tick when this page or its WiFi UI is on the stack + if gui_app.get_active_widget() is not self and not gui_app.widget_in_stack(self._wifi_ui): + self._wifi_manager.process_callbacks() + return + + # Check network state before processing callbacks so forgetting flag + # is still set on the frame the forgotten callback fires + has_internet = self._has_internet + wifi_connected = self._wifi_manager.wifi_state.status == ConnectStatus.CONNECTED + + self._continue_button.set_visible(has_internet) + self._waiting_button.set_visible(not has_internet) + + # TODO: fire show/hide events on visibility changes + if not has_internet: + self._pending_continue_grow_animation = False + self._waiting_button.set_text("waiting for\ninternet..." if wifi_connected else "connect to\ncontinue") + self._wifi_manager.process_callbacks() - has_internet = self._network_monitor.network_connected.is_set() - if has_internet != self._prev_has_internet: - self._set_has_internet(has_internet) - if has_internet: - gui_app.pop_widgets_to(self) - self._prev_has_internet = has_internet + # Dismiss WiFi UI and scroll on WiFi connect or internet gain + if (has_internet and not self._prev_has_internet) or (wifi_connected and not self._prev_wifi_connected): + # TODO: cancel if connect is transient + self._pending_has_internet_scroll = rl.get_time() - def _set_has_internet(self, has_internet: bool): - if has_internet: - self._network_header.set_title("connected to internet") - self._network_header.set_icon(self._wifi_full_txt) - self._continue_button.set_enabled(lambda: self.enabled) - else: - self._network_header.set_title(self._waiting_text) - self._network_header.set_icon(self._no_wifi_txt) - self._continue_button.set_enabled(False) + self._prev_has_internet = has_internet + self._prev_wifi_connected = wifi_connected + + if self._pending_has_internet_scroll is not None: + # Scrolls over to continue button, then grows once in view + elapsed = rl.get_time() - self._pending_has_internet_scroll + if elapsed > 0.7 or gui_app.get_active_widget() is self: # instant scroll + grow if not popping + # Animate WifiUi down first before scroll + self._pending_has_internet_scroll = None + gui_app.pop_widgets_to(self, self._scroll_to_end_and_grow) + + def _scroll_to_end_and_grow(self): + self._scroller._layout() + end_offset = -(self._scroller.content_size - self._rect.width) + remaining = self._scroller.scroll_panel.get_offset() - end_offset + self._scroller.scroll_to(remaining, smooth=True, block_interaction=True) + self._pending_continue_grow_animation = True def set_custom_software(self, custom_software: bool): self._custom_software = custom_software + self._continue_button.set_text("install openpilot" if not custom_software else "choose software") + self._continue_button.set_green(not custom_software) - def _render(self, _): - self._network_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16, - self._rect.width - 32, - self._network_header.rect.height, - )) + def _update_state(self): + super()._update_state() - self._back_button.render(rl.Rectangle( - self._rect.x + 8, - self._rect.y + self._rect.height - self._back_button.rect.height, - self._back_button.rect.width, - self._back_button.rect.height, - )) + if self._pending_continue_grow_animation: + btn_right = self._continue_button.rect.x + self._continue_button.rect.width + visible_right = self._rect.x + self._rect.width + if btn_right < visible_right + 50: + self._pending_continue_grow_animation = False + self._continue_button.trigger_grow_animation() - self._wifi_button.render(rl.Rectangle( - self._rect.x + 8 + self._back_button.rect.width + 10, - self._rect.y + self._rect.height - self._wifi_button.rect.height, - self._wifi_button.rect.width, - self._wifi_button.rect.height, - )) + if self._pending_wifi_grow_animation and abs(self._wifi_button.rect.x - ITEM_SPACING) < 50: + self._pending_wifi_grow_animation = False + self._wifi_button.trigger_grow_animation() - self._continue_button.render(rl.Rectangle( - self._rect.x + self._rect.width - self._continue_button.rect.width - 8, - self._rect.y + self._rect.height - self._continue_button.rect.height, - self._continue_button.rect.width, - self._continue_button.rect.height, - )) + +class NetworkSetupPage(NetworkSetupPageBase, NavScroller): + def __init__(self, network_monitor: NetworkConnectivityMonitor, continue_callback: Callable[[bool], None], + back_callback: Callable[[], None] | None): + super().__init__(network_monitor, continue_callback) + self.set_back_callback(back_callback) class Setup(Widget): @@ -550,20 +441,19 @@ class Setup(Widget): self._network_monitor.start() def getting_started_button_callback(): - self._software_selection_page.reset() gui_app.push_widget(self._software_selection_page) self._start_page = StartPage() self._start_page.set_click_callback(getting_started_button_callback) self._start_page.set_enabled(lambda: self.enabled) # for nav stack - self._network_setup_page = NetworkSetupPage(self._network_monitor, self._network_setup_continue_button_callback, - self._pop_to_software_selection) - self._software_selection_page = SoftwareSelectionPage(self._use_openpilot, lambda: gui_app.push_widget(self._custom_software_warning_page)) + self._network_setup_page = NetworkSetupPage(self._network_monitor, self._network_setup_continue_callback, self._pop_to_software_selection) - self._download_failed_page = FailedPage(HARDWARE.reboot, self._pop_to_software_selection) + self._software_selection_page = SoftwareSelectionPage(self._push_network_setup, lambda: gui_app.push_widget(self._custom_software_warning_page)) - self._custom_software_warning_page = CustomSoftwareWarningPage(self._software_selection_custom_software_continue, self._pop_to_software_selection) + self._download_failed_page = FailedPage(self._pop_to_software_selection, icon="icons_mici/setup/red_warning.png") + + self._custom_software_warning_page = CustomSoftwareWarningPage(lambda: self._push_network_setup(True), self._pop_to_software_selection) self._downloading_page = DownloadingPage() @@ -576,8 +466,7 @@ class Setup(Widget): reason = self._download_failed_reason self._download_failed_reason = None self._download_failed_page.set_reason(reason) - gui_app.pop_widgets_to(self._software_selection_page, instant=True) # don't reset sliders - gui_app.push_widget(self._download_failed_page) + gui_app.pop_widgets_to(self._software_selection_page, lambda: gui_app.push_widget(self._download_failed_page)) def _render(self, rect: rl.Rectangle): self._start_page.render(rect) @@ -589,41 +478,21 @@ class Setup(Widget): # reset sliders after dismiss completes gui_app.pop_widgets_to(self._software_selection_page, self._software_selection_page.reset) - def _use_openpilot(self): - if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH): - os.remove(VALID_CACHE_PATH) - with open(TMP_CONTINUE_PATH, "w") as f: - f.write(CONTINUE) - run_cmd(["chmod", "+x", TMP_CONTINUE_PATH]) - shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH) - shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH) - - # give time for installer UI to take over - time.sleep(0.1) - gui_app.request_close() - else: - self._push_network_setup(custom_software=False) - - def _push_network_setup(self, custom_software: bool): + def _push_network_setup(self, custom_software: bool = False): + # to fire the correct continue callback later self._network_setup_page.set_custom_software(custom_software) gui_app.push_widget(self._network_setup_page) - def _software_selection_custom_software_continue(self): - gui_app.pop_widgets_to(self._software_selection_page, instant=True) # don't reset sliders - self._push_network_setup(custom_software=True) - - def _network_setup_continue_button_callback(self, custom_software): + def _network_setup_continue_callback(self, custom_software: bool): if not custom_software: - gui_app.pop_widgets_to(self._software_selection_page, instant=True) # don't reset sliders self._download(OPENPILOT_URL) else: def handle_keyboard_result(text): url = text.strip() if url: - gui_app.pop_widgets_to(self._software_selection_page, instant=True) # don't reset sliders self._download(url) - keyboard = BigInputDialog("custom software URL", confirm_callback=handle_keyboard_result) + keyboard = BigInputDialog("custom software URL...", confirm_callback=handle_keyboard_result, auto_return_to_letters="./") gui_app.push_widget(keyboard) def _download(self, url: str): @@ -635,10 +504,12 @@ class Setup(Widget): self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl() self.download_progress = 0 - gui_app.push_widget(self._downloading_page) + def start_download(): + self.download_thread = threading.Thread(target=self._download_thread, daemon=True) + self.download_thread.start() - self.download_thread = threading.Thread(target=self._download_thread, daemon=True) - self.download_thread.start() + self._downloading_page.set_shown_callback(start_download) + gui_app.push_widget(self._downloading_page) def _download_thread(self): try: @@ -673,26 +544,27 @@ class Setup(Widget): is_elf = header == b'\x7fELF' if not is_elf: - self._download_failed_reason = "No custom software found at this URL." + self._download_failed_reason = "No custom software found at this URL: " + self.download_url.replace("https://", "", 1) return + # NOTE: currently unused, for future logging + with open(INSTALLER_URL_PATH, "w") as f: + f.write(self.download_url) + # AGNOS might try to execute the installer before this process exits. # Therefore, important to close the fd before renaming the installer. os.close(fd) os.rename(tmpfile, INSTALLER_DESTINATION_PATH) - with open(INSTALLER_URL_PATH, "w") as f: - f.write(self.download_url) - # give time for installer UI to take over time.sleep(0.1) gui_app.request_close() except urllib.error.HTTPError as e: if e.code == 409: - self._download_failed_reason = "Incompatible sunnypilot version" + self._download_failed_reason = "Incompatible sunnypilot version." except Exception: - self._download_failed_reason = "Invalid URL" + self._download_failed_reason = "Invalid URL: " + self.download_url.replace("https://", "", 1) def main(): diff --git a/system/ui/mici_updater.py b/system/ui/mici_updater.py index c98b310709..8437e6fa60 100755 --- a/system/ui/mici_updater.py +++ b/system/ui/mici_updater.py @@ -3,57 +3,28 @@ import sys import subprocess import threading import pyray as rl -from enum import IntEnum -from openpilot.system.hardware import HARDWARE +from openpilot.common.realtime import config_realtime_process, set_core_affinity +from openpilot.system.hardware import HARDWARE, TICI +from openpilot.common.swaglog import cloudlog from openpilot.system.ui.lib.application import gui_app, FontWeight -from openpilot.system.ui.lib.wifi_manager import WifiManager -from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.nav_widget import NavWidget +from openpilot.system.ui.widgets.scroller import Scroller from openpilot.system.ui.widgets.label import UnifiedLabel -from openpilot.system.ui.widgets.button import FullRoundedButton -from openpilot.system.ui.mici_setup import NetworkSetupPage, FailedPage, NetworkConnectivityMonitor +from openpilot.system.ui.mici_setup import (NetworkSetupPage, FailedPage, NetworkConnectivityMonitor, + GreyBigButton, BigPillButton) -class Screen(IntEnum): - PROMPT = 0 - WIFI = 1 - PROGRESS = 2 - FAILED = 3 +class UpdaterNetworkSetupPage(NetworkSetupPage): + def __init__(self, network_monitor, continue_callback): + super().__init__(network_monitor, continue_callback, back_callback=None) + self._continue_button.set_text("download\n& install") + self._continue_button.set_green(False) -class Updater(Widget): - def __init__(self, updater_path, manifest_path): +class ProgressPage(NavWidget): + def __init__(self): super().__init__() - self.updater = updater_path - self.manifest = manifest_path - self.current_screen = Screen.PROMPT - - self.progress_value = 0 - self.progress_text = "loading" - self.process = None - self.update_thread = None - self._wifi_manager = WifiManager() - self._wifi_manager.set_active(True) - - self._network_setup_page = NetworkSetupPage(self._wifi_manager, self._network_setup_continue_callback, - self._network_setup_back_callback) - self._network_setup_page.set_enabled(lambda: self.enabled) # for nav stack - - self._network_monitor = NetworkConnectivityMonitor() - self._network_monitor.start() - - # Buttons - self._continue_button = FullRoundedButton("continue") - self._continue_button.set_click_callback(lambda: self.set_current_screen(Screen.WIFI)) - - self._title_label = UnifiedLabel("update required", 48, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - font_weight=FontWeight.DISPLAY) - self._subtitle_label = UnifiedLabel("The download size is approximately 1GB.", 36, - text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - font_weight=FontWeight.ROMAN) - - self._update_failed_page = FailedPage(HARDWARE.reboot, self._update_failed_retry_callback, - title="update failed") self._progress_title_label = UnifiedLabel("", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), font_weight=FontWeight.DISPLAY, line_height=0.8) @@ -61,47 +32,102 @@ class Updater(Widget): font_weight=FontWeight.ROMAN, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) - def _network_setup_back_callback(self): - self.set_current_screen(Screen.PROMPT) + def _back_enabled(self) -> bool: + return False - def _network_setup_continue_callback(self): + def set_progress(self, text: str, value: int): + self._progress_title_label.set_text(text.replace("_", "_\n") + "...") + self._progress_percent_label.set_text(f"{value}%") + + def show_event(self): + super().show_event() + self._nav_bar._alpha = 0.0 # not dismissable + self.set_progress("downloading", 0) + + def _render(self, rect: rl.Rectangle): + rl.draw_rectangle_rec(rect, rl.BLACK) + self._progress_title_label.render(rl.Rectangle( + rect.x + 12, + rect.y + 2, + rect.width, + self._progress_title_label.get_content_height(int(rect.width - 20)), + )) + + self._progress_percent_label.render(rl.Rectangle( + rect.x + 12, + rect.y + 18, + rect.width, + rect.height, + )) + + +class Updater(Scroller): + def __init__(self, updater_path, manifest_path): + super().__init__() + self.updater = updater_path + self.manifest = manifest_path + + self.progress_value = 0 + self.progress_text = "loading" + self.process = None + self.update_thread = None + self._update_failed = False + + self._network_monitor = NetworkConnectivityMonitor() + self._network_monitor.start() + + self._network_setup_page = UpdaterNetworkSetupPage(self._network_monitor, self._network_setup_continue_callback) + + self._progress_page = ProgressPage() + + self._failed_page = FailedPage(self._retry, title="update failed") + + self._continue_button = BigPillButton("next") + self._continue_button.set_click_callback(lambda: gui_app.push_widget(self._network_setup_page)) + + self._scroller.add_widgets([ + GreyBigButton("update required", "the download size\nis approximately 1 GB", + gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 64, 64)), + self._continue_button, + ]) + + gui_app.add_nav_stack_tick(self._nav_stack_tick) + + def _network_setup_continue_callback(self, _): self.install_update() - def _update_failed_retry_callback(self): - self.set_current_screen(Screen.PROMPT) + def _retry(self): + gui_app.pop_widgets_to(self) - def set_current_screen(self, screen: Screen): - if self.current_screen != screen: - if screen == Screen.PROGRESS: - if self._network_setup_page: - self._network_setup_page.hide_event() - elif screen == Screen.WIFI: - if self._network_setup_page: - self._network_setup_page.show_event() - elif screen == Screen.PROMPT: - if self._network_setup_page: - self._network_setup_page.hide_event() - elif screen == Screen.FAILED: - if self._network_setup_page: - self._network_setup_page.hide_event() + def _nav_stack_tick(self): + self._progress_page.set_progress(self.progress_text, self.progress_value) - self.current_screen = screen + if self._update_failed: + self._update_failed = False + self.show_event() + gui_app.pop_widgets_to(self, lambda: gui_app.push_widget(self._failed_page)) def install_update(self): - self.set_current_screen(Screen.PROGRESS) self.progress_value = 0 self.progress_text = "downloading" - # Start the update process in a separate thread - self.update_thread = threading.Thread(target=self._run_update_process) - self.update_thread.daemon = True - self.update_thread.start() + def start_update(): + self.update_thread = threading.Thread(target=self._run_update_process, daemon=True) + self.update_thread.start() + + # Start the update process in a separate thread *after* show animation completes + self._progress_page.set_shown_callback(start_update) + gui_app.push_widget(self._progress_page) def _run_update_process(self): # TODO: just import it and run in a thread without a subprocess - cmd = [self.updater, "--swap", self.manifest] - self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, bufsize=1, universal_newlines=True) + try: + cmd = [self.updater, "--swap", self.manifest] + self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, + text=True, bufsize=1, universal_newlines=True) + except Exception: + self._update_failed = True + return if self.process.stdout is not None: for line in self.process.stdout: @@ -117,68 +143,21 @@ class Updater(Widget): if exit_code == 0: HARDWARE.reboot() else: - self.set_current_screen(Screen.FAILED) - - def render_prompt_screen(self, rect: rl.Rectangle): - self._title_label.render(rl.Rectangle( - rect.x + 8, - rect.y - 5, - rect.width, - 48, - )) - - subtitle_width = rect.width - 16 - subtitle_height = self._subtitle_label.get_content_height(int(subtitle_width)) - self._subtitle_label.render(rl.Rectangle( - rect.x + 8, - rect.y + 48, - subtitle_width, - subtitle_height, - )) - - self._continue_button.render(rl.Rectangle( - rect.x + 8, - rect.y + rect.height - self._continue_button.rect.height, - self._continue_button.rect.width, - self._continue_button.rect.height, - )) - - def render_progress_screen(self, rect: rl.Rectangle): - self._progress_title_label.set_text(self.progress_text.replace("_", "_\n") + "...") - self._progress_title_label.render(rl.Rectangle( - rect.x + 12, - rect.y + 2, - rect.width, - self._progress_title_label.get_content_height(int(rect.width - 20)), - )) - - self._progress_percent_label.set_text(f"{self.progress_value}%") - self._progress_percent_label.render(rl.Rectangle( - rect.x + 12, - rect.y + 18, - rect.width, - rect.height, - )) - - def _update_state(self): - self._wifi_manager.process_callbacks() - - def _render(self, rect: rl.Rectangle): - if self.current_screen == Screen.PROMPT: - self.render_prompt_screen(rect) - elif self.current_screen == Screen.WIFI: - self._network_setup_page.set_has_internet(self._network_monitor.network_connected.is_set()) - self._network_setup_page.render(rect) - elif self.current_screen == Screen.PROGRESS: - self.render_progress_screen(rect) - elif self.current_screen == Screen.FAILED: - self._update_failed_page.render(rect) + self._update_failed = True def close(self): self._network_monitor.stop() 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: + try: + set_core_affinity([5]) + except OSError: + cloudlog.exception("Failed to set core affinity for updater process") + if len(sys.argv) < 3: print("Usage: updater.py ") sys.exit(1) diff --git a/system/ui/sunnypilot/widgets/option_control.py b/system/ui/sunnypilot/widgets/option_control.py index 291d8f6ff0..82126417d5 100644 --- a/system/ui/sunnypilot/widgets/option_control.py +++ b/system/ui/sunnypilot/widgets/option_control.py @@ -60,17 +60,19 @@ class OptionControlSP(ItemAction): def set_value(self, value: int): """Set the control to a specific value""" - if self.min_value <= value <= self.max_value: - self.current_value = value - if self.value_map: - self.params.put(self.param_key, self.value_map[value]) - else: - if self.use_float_scaling: - self.params.put(self.param_key, value / 100.0) - else: - self.params.put(self.param_key, value) - if self.on_value_changed: - self.on_value_changed(value) + if not (self.min_value <= value <= self.max_value): + return + if value == self.current_value: + return + self.current_value = value + if self.value_map: + self.params.put(self.param_key, self.value_map[value]) + elif self.use_float_scaling: + self.params.put(self.param_key, value / 100.0) + else: + self.params.put(self.param_key, value) + if self.on_value_changed: + self.on_value_changed(value) def get_displayed_value(self) -> str: """Get the displayed value, handling value mapping if present""" @@ -157,10 +159,10 @@ class OptionControlSP(ItemAction): def _handle_mouse_release(self, mouse_pos: MousePos): if self._minus_enabled and rl.check_collision_point_rec(mouse_pos, self.minus_btn_rect): - self.current_value -= self.value_change_step - self.current_value = max(self.min_value, self.current_value) + new_value = self.current_value - self.value_change_step + new_value = max(self.min_value, new_value) + self.set_value(new_value) elif self._plus_enabled and rl.check_collision_point_rec(mouse_pos, self.plus_btn_rect): - self.current_value += self.value_change_step - self.current_value = min(self.max_value, self.current_value) - - self.set_value(self.current_value) + new_value = self.current_value + self.value_change_step + new_value = min(self.max_value, new_value) + self.set_value(new_value) diff --git a/system/ui/sunnypilot/widgets/tree_dialog.py b/system/ui/sunnypilot/widgets/tree_dialog.py index c34db092e8..69233b803d 100644 --- a/system/ui/sunnypilot/widgets/tree_dialog.py +++ b/system/ui/sunnypilot/widgets/tree_dialog.py @@ -198,7 +198,10 @@ class TreeOptionDialog(MultiOptionDialog): self.option_buttons = self.visible_items self.options = [item.text for item in self.visible_items] - self.scroller._items = self.visible_items + # Rebuild scroller items to ensure proper setup of touch callbacks + self.scroller._items.clear() + for item in self.option_buttons: + self.scroller.add_widget(item) if reset_scroll: self.scroller.scroll_panel.set_offset(0) diff --git a/system/ui/tici_reset.py b/system/ui/tici_reset.py index 23f6b344ec..a6603d547e 100755 --- a/system/ui/tici_reset.py +++ b/system/ui/tici_reset.py @@ -20,7 +20,6 @@ TIMEOUT = 3*60 class ResetMode(IntEnum): USER_RESET = 0 # user initiated a factory reset from openpilot RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover - FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata class ResetState(IntEnum): @@ -54,7 +53,7 @@ class Reset(Widget): else: self._reset_state = ResetState.FAILED - def start_reset(self): + def _start_reset(self): self._reset_state = ResetState.RESETTING threading.Timer(0.1, self._do_erase).start() @@ -92,7 +91,7 @@ class Reset(Widget): def _confirm(self): if self._reset_state == ResetState.CONFIRM: - self.start_reset() + self._start_reset() else: self._reset_state = ResetState.CONFIRM @@ -113,15 +112,10 @@ def main(): if len(sys.argv) > 1: if sys.argv[1] == '--recover': mode = ResetMode.RECOVER - elif sys.argv[1] == "--format": - mode = ResetMode.FORMAT gui_app.init_window("System Reset", 20) reset = Reset(mode) - if mode == ResetMode.FORMAT: - reset.start_reset() - gui_app.push_widget(reset) for _ in gui_app.render(): diff --git a/system/ui/tici_setup.py b/system/ui/tici_setup.py index 8098e9ea27..9eefb6af53 100755 --- a/system/ui/tici_setup.py +++ b/system/ui/tici_setup.py @@ -7,12 +7,10 @@ import urllib.request import urllib.error from urllib.parse import urlparse from enum import IntEnum -import shutil import pyray as rl from cereal import log -from openpilot.common.utils import run_cmd from openpilot.system.hardware import HARDWARE from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE @@ -35,20 +33,9 @@ BUTTON_SPACING = 50 OPENPILOT_URL = "https://openpilot.comma.ai" USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}" -CONTINUE_PATH = "/data/continue.sh" -TMP_CONTINUE_PATH = "/data/continue.sh.new" -INSTALL_PATH = "/data/openpilot" -VALID_CACHE_PATH = "/data/.openpilot_cache" -INSTALLER_SOURCE_PATH = "/usr/comma/installer" INSTALLER_DESTINATION_PATH = "/tmp/installer" INSTALLER_URL_PATH = "/tmp/installer_url" -CONTINUE = """#!/usr/bin/env bash - -cd /data/openpilot -exec ./launch_openpilot.sh -""" - class SetupState(IntEnum): LOW_VOLTAGE = 0 @@ -176,7 +163,9 @@ class Setup(Widget): def _software_selection_continue_button_callback(self): if self._software_selection_openpilot_button.selected: - self.use_openpilot() + self.state = SetupState.NETWORK_SETUP + self.stop_network_check_thread.clear() + self.start_network_check() else: self.state = SetupState.CUSTOM_SOFTWARE_WARNING @@ -194,7 +183,7 @@ class Setup(Widget): self.state = SetupState.CUSTOM_SOFTWARE def render_low_voltage(self, rect: rl.Rectangle): - rl.draw_texture(self.warning, int(rect.x + 150), int(rect.y + 110), rl.WHITE) + rl.draw_texture_ex(self.warning, rl.Vector2(rect.x + 150, rect.y + 110), 0.0, 1.0, rl.WHITE) self._low_voltage_title_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 100, rect.width - 500 - 150, TITLE_FONT_SIZE * FONT_SCALE)) self._low_voltage_body_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 150, rect.width - 500, BODY_FONT_SIZE * FONT_SCALE * 3)) @@ -342,23 +331,6 @@ class Setup(Widget): self.keyboard.set_callback(handle_keyboard_result) gui_app.push_widget(self.keyboard) - def use_openpilot(self): - if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH): - os.remove(VALID_CACHE_PATH) - with open(TMP_CONTINUE_PATH, "w") as f: - f.write(CONTINUE) - run_cmd(["chmod", "+x", TMP_CONTINUE_PATH]) - shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH) - shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH) - - # give time for installer UI to take over - time.sleep(0.1) - gui_app.request_close() - else: - self.state = SetupState.NETWORK_SETUP - self.stop_network_check_thread.clear() - self.start_network_check() - def download(self, url: str): # autocomplete incomplete URLs if re.match("^([^/.]+)/([^/]+)$", url): diff --git a/system/ui/tici_updater.py b/system/ui/tici_updater.py index 9824638cd0..3a3b0987d0 100755 --- a/system/ui/tici_updater.py +++ b/system/ui/tici_updater.py @@ -67,9 +67,14 @@ class Updater(Widget): def _run_update_process(self): # TODO: just import it and run in a thread without a subprocess - cmd = [self.updater, "--swap", self.manifest] - self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, bufsize=1, universal_newlines=True) + try: + cmd = [self.updater, "--swap", self.manifest] + self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, + text=True, bufsize=1, universal_newlines=True) + except Exception: + self.progress_text = "Update failed" + self.show_reboot_button = True + return if self.process.stdout is not None: for line in self.process.stdout: diff --git a/system/ui/widgets/__init__.py b/system/ui/widgets/__init__.py index 568f58b985..4ce1c1b694 100644 --- a/system/ui/widgets/__init__.py +++ b/system/ui/widgets/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import abc import pyray as rl from enum import IntEnum +from typing import TypeVar from collections.abc import Callable from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS, MouseEvent @@ -13,6 +14,10 @@ except ImportError: awake = True device = Device() +W = TypeVar('W', bound='Widget') + +DEBUG = False + class DialogResult(IntEnum): CANCEL = 0 @@ -24,11 +29,14 @@ class Widget(abc.ABC): def __init__(self): self._rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0) self._parent_rect: rl.Rectangle | None = None + self._children: list[Widget] = [] + + self._enabled: bool | Callable[[], bool] = True + self._is_visible: bool | Callable[[], bool] = True + self.__is_pressed = [False] * MAX_TOUCH_SLOTS # if current mouse/touch down started within the widget's rectangle self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS - self._enabled: bool | Callable[[], bool] = True - self._is_visible: bool | Callable[[], bool] = True self._touch_valid_callback: Callable[[], bool] | None = None self._click_delay: float | None = None # seconds to hold is_pressed after release self._click_release_time: float | None = None @@ -197,12 +205,37 @@ class Widget(abc.ABC): """Optionally handle mouse events. This is called before rendering.""" # Default implementation does nothing, can be overridden by subclasses + def _child(self, widget: W) -> W: + """ + Register a widget as a child. Lifecycle events (show/hide) propagate to registered children. + - If the widget is pushed onto the nav stack, do NOT register it (gui_app manages its lifecycle). + - If the widget is rendered inline in _render(), register it. + """ + assert widget not in self._children, f"{type(widget).__name__} already a child of {type(self).__name__}" + self._children.append(widget) + return widget + + _show_hide_depth = 0 + def show_event(self): - """Optionally handle show event. Parent must manually call this""" - # TODO: iterate through all child objects, check for subclassing from Widget/Layout (Scroller) + """Called when widget becomes visible. Propagates to registered children.""" + if DEBUG: + print(f"{' ' * Widget._show_hide_depth}show_event: {type(self).__name__}") + Widget._show_hide_depth += 1 + for child in self._children: + child.show_event() + if DEBUG: + Widget._show_hide_depth -= 1 def hide_event(self): - """Optionally handle hide event. Parent must manually call this""" + """Called when widget is hidden. Propagates to registered children.""" + if DEBUG: + print(f"{' ' * Widget._show_hide_depth}hide_event: {type(self).__name__}") + Widget._show_hide_depth += 1 + for child in self._children: + child.hide_event() + if DEBUG: + Widget._show_hide_depth -= 1 def dismiss(self, callback: Callable[[], None] | None = None): """Immediately dismiss the widget, firing the callback after.""" diff --git a/system/ui/widgets/button.py b/system/ui/widgets/button.py index 67125d7091..36ef3bedab 100644 --- a/system/ui/widgets/button.py +++ b/system/ui/widgets/button.py @@ -5,7 +5,7 @@ import pyray as rl from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.label import Label, UnifiedLabel +from openpilot.system.ui.widgets.label import Label from openpilot.common.filter_simple import FirstOrderFilter @@ -191,7 +191,7 @@ class IconButton(Widget): color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.35 * self._opacity_filter.x)) draw_x = rect.x + (rect.width - self._texture.width) / 2 draw_y = rect.y + (rect.height - self._texture.height) / 2 - rl.draw_texture(self._texture, int(draw_x), int(draw_y), color) + rl.draw_texture_ex(self._texture, rl.Vector2(draw_x, draw_y), 0.0, 1.0, color) class SmallCircleIconButton(Widget): @@ -219,85 +219,7 @@ class SmallCircleIconButton(Widget): bg_txt = self._icon_bg_pressed_txt if self.is_pressed else self._icon_bg_txt icon_white = white - rl.draw_texture(bg_txt, int(self.rect.x), int(self.rect.y), white) + rl.draw_texture_ex(bg_txt, rl.Vector2(self.rect.x, self.rect.y), 0.0, 1.0, white) icon_x = self.rect.x + (self.rect.width - self._icon_txt.width) / 2 icon_y = self.rect.y + (self.rect.height - self._icon_txt.height) / 2 - rl.draw_texture(self._icon_txt, int(icon_x), int(icon_y), icon_white) - - -class SmallButton(Widget): - def __init__(self, text: str): - super().__init__() - self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps) - - self._load_assets() - - self._label = UnifiedLabel(text, 36, font_weight=FontWeight.SEMI_BOLD, - text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) - - self._bg_disabled_txt = None - - def _load_assets(self): - self.set_rect(rl.Rectangle(0, 0, 194, 100)) - self._bg_txt = gui_app.texture("icons_mici/setup/reset/small_button.png", 194, 100) - self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/small_button_pressed.png", 194, 100) - - def set_text(self, text: str): - self._label.set_text(text) - - def set_opacity(self, opacity: float, smooth: bool = False): - if smooth: - self._opacity_filter.update(opacity) - else: - self._opacity_filter.x = opacity - - def _render(self, _): - if not self.enabled and self._bg_disabled_txt is not None: - rl.draw_texture(self._bg_disabled_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))) - elif self.is_pressed: - rl.draw_texture(self._bg_pressed_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))) - else: - rl.draw_texture(self._bg_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))) - - opacity = 0.9 if self.enabled else 0.35 - self._label.set_color(rl.Color(255, 255, 255, int(255 * opacity * self._opacity_filter.x))) - self._label.render(self._rect) - - -class SmallRedPillButton(SmallButton): - def _load_assets(self): - self.set_rect(rl.Rectangle(0, 0, 194, 100)) - self._bg_txt = gui_app.texture("icons_mici/setup/small_red_pill.png", 194, 100) - self._bg_pressed_txt = gui_app.texture("icons_mici/setup/small_red_pill_pressed.png", 194, 100) - - -class SmallerRoundedButton(SmallButton): - def _load_assets(self): - self.set_rect(rl.Rectangle(0, 0, 150, 100)) - self._bg_txt = gui_app.texture("icons_mici/setup/smaller_button.png", 150, 100) - self._bg_disabled_txt = gui_app.texture("icons_mici/setup/smaller_button_disabled.png", 150, 100) - self._bg_pressed_txt = gui_app.texture("icons_mici/setup/smaller_button_pressed.png", 150, 100) - - -class WideRoundedButton(SmallButton): - def _load_assets(self): - self.set_rect(rl.Rectangle(0, 0, 316, 100)) - self._bg_txt = gui_app.texture("icons_mici/setup/medium_button_bg.png", 316, 100) - self._bg_pressed_txt = gui_app.texture("icons_mici/setup/medium_button_pressed_bg.png", 316, 100) - - -class WidishRoundedButton(SmallButton): - def _load_assets(self): - self.set_rect(rl.Rectangle(0, 0, 250, 100)) - self._bg_txt = gui_app.texture("icons_mici/setup/widish_button.png", 250, 100) - self._bg_pressed_txt = gui_app.texture("icons_mici/setup/widish_button_pressed.png", 250, 100) - self._bg_disabled_txt = gui_app.texture("icons_mici/setup/widish_button_disabled.png", 250, 100) - - -class FullRoundedButton(SmallButton): - def _load_assets(self): - self.set_rect(rl.Rectangle(0, 0, 520, 100)) - self._bg_txt = gui_app.texture("icons_mici/setup/reset/wide_button.png", 520, 100) - self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/wide_button_pressed.png", 520, 100) + rl.draw_texture_ex(self._icon_txt, rl.Vector2(icon_x, icon_y), 0.0, 1.0, icon_white) diff --git a/system/ui/widgets/label.py b/system/ui/widgets/label.py index 8ed9ec62f5..7fe25ab51d 100644 --- a/system/ui/widgets/label.py +++ b/system/ui/widgets/label.py @@ -1,3 +1,4 @@ +import math from enum import IntEnum from collections.abc import Callable from itertools import zip_longest @@ -26,166 +27,6 @@ class ScrollState(IntEnum): SCROLLING = 1 -# TODO: merge anything new here to master -class MiciLabel(Widget): - def __init__(self, - text: str, - font_size: int = DEFAULT_TEXT_SIZE, - width: int | None = None, - color: rl.Color = DEFAULT_TEXT_COLOR, - font_weight: FontWeight = FontWeight.NORMAL, - alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, - spacing: int = 0, - line_height: int | None = None, - elide_right: bool = True, - wrap_text: bool = False, - scroll: bool = False): - super().__init__() - self.text = text - self.wrapped_text: list[str] = [] - self.font_size = font_size - self.width = width - self.color = color - self.font_weight = font_weight - self.alignment = alignment - self.alignment_vertical = alignment_vertical - self.spacing = spacing - self.line_height = line_height if line_height is not None else font_size - self.elide_right = elide_right - self.wrap_text = wrap_text - self._height = 0 - - # Scroll state - self.scroll = scroll - self._needs_scroll = False - self._scroll_offset = 0 - self._scroll_pause_t: float | None = None - self._scroll_state: ScrollState = ScrollState.STARTING - - assert not (self.scroll and self.wrap_text), "Cannot enable both scroll and wrap_text" - assert not (self.scroll and self.elide_right), "Cannot enable both scroll and elide_right" - - self.set_text(text) - - @property - def text_height(self): - return self._height - - def set_font_size(self, font_size: int): - self.font_size = font_size - self.set_text(self.text) - - def set_width(self, width: int): - self.width = width - self._rect.width = width - self.set_text(self.text) - - def set_text(self, txt: str): - self.text = txt - text_size = measure_text_cached(gui_app.font(self.font_weight), self.text, self.font_size, self.spacing) - if self.width is not None: - self._rect.width = self.width - else: - self._rect.width = text_size.x - - if self.wrap_text: - self.wrapped_text = wrap_text(gui_app.font(self.font_weight), self.text, self.font_size, int(self._rect.width)) - self._height = len(self.wrapped_text) * self.line_height - elif self.scroll: - self._needs_scroll = self.scroll and text_size.x > self._rect.width - self._rect.height = text_size.y - - def set_color(self, color: rl.Color): - self.color = color - - def set_font_weight(self, font_weight: FontWeight): - self.font_weight = font_weight - self.set_text(self.text) - - def _render(self, rect: rl.Rectangle): - # Only scissor when we know there is a single scrolling line - if self._needs_scroll: - rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) - - font = gui_app.font(self.font_weight) - - text_y_offset = 0 - # Draw the text in the specified rectangle - lines = self.wrapped_text or [self.text] - if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: - lines = lines[::-1] - - for display_text in lines: - text_size = measure_text_cached(font, display_text, self.font_size, self.spacing) - - # Elide text to fit within the rectangle - if self.elide_right and text_size.x > rect.width: - ellipsis = "..." - left, right = 0, len(display_text) - while left < right: - mid = (left + right) // 2 - candidate = display_text[:mid] + ellipsis - candidate_size = measure_text_cached(font, candidate, self.font_size, self.spacing) - if candidate_size.x <= rect.width: - left = mid + 1 - else: - right = mid - display_text = display_text[: left - 1] + ellipsis if left > 0 else ellipsis - text_size = measure_text_cached(font, display_text, self.font_size, self.spacing) - - # Handle scroll state - elif self.scroll and self._needs_scroll: - if self._scroll_state == ScrollState.STARTING: - if self._scroll_pause_t is None: - self._scroll_pause_t = rl.get_time() + 2.0 - if rl.get_time() >= self._scroll_pause_t: - self._scroll_state = ScrollState.SCROLLING - self._scroll_pause_t = None - - elif self._scroll_state == ScrollState.SCROLLING: - self._scroll_offset -= 0.8 / 60. * gui_app.target_fps - # don't fully hide - if self._scroll_offset <= -text_size.x - self._rect.width / 3: - self._scroll_offset = 0 - self._scroll_state = ScrollState.STARTING - self._scroll_pause_t = None - - # Calculate horizontal position based on alignment - text_x = rect.x + { - rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0, - rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2, - rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x, - }.get(self.alignment, 0) + self._scroll_offset - - # Calculate vertical position based on alignment - text_y = rect.y + { - rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0, - rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2, - rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y, - }.get(self.alignment_vertical, 0) - text_y += text_y_offset - - rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x), text_y), self.font_size, self.spacing, self.color) - # Draw 2nd instance for scrolling - if self._needs_scroll and self._scroll_state != ScrollState.STARTING: - text2_scroll_offset = text_size.x + self._rect.width / 3 - rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x + text2_scroll_offset), text_y), self.font_size, self.spacing, self.color) - if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: - text_y_offset -= self.line_height - else: - text_y_offset += self.line_height - - if self._needs_scroll: - # draw black fade on left and right - fade_width = 20 - rl.draw_rectangle_gradient_h(int(rect.x + rect.width - fade_width), int(rect.y), fade_width, int(rect.height), rl.BLANK, rl.BLACK) - if self._scroll_state != ScrollState.STARTING: - rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), fade_width, int(rect.height), rl.BLACK, rl.BLANK) - - rl.end_scissor_mode() - - # TODO: This should be a Widget class def gui_label( rect: rl.Rectangle, @@ -392,7 +233,7 @@ class Label(Widget): class UnifiedLabel(Widget): """ - Unified label widget that combines functionality from gui_label, gui_text_box, Label, and MiciLabel. + Unified label widget that combines functionality from gui_label, gui_text_box, and Label. Supports: - Emoji rendering @@ -401,6 +242,13 @@ class UnifiedLabel(Widget): - Proper multiline vertical alignment - Height calculation for layout purposes """ + # Shimmer constants + SHIMMER_BAND_WIDTH = 0.3 # shimmer width as fraction of text width + SHIMMER_BLUR_RADIUS = 0.12 # gaussian blur as fraction of text width + SHIMMER_CYCLE_PERIOD = 2.5 # seconds per full shimmer cycle + SHIMMER_SWEEP_FRACTION = 0.9 # fraction of cycle spent sweeping (rest is pause) + SHIMMER_LOW_OPACITY = 0.65 # text opacity at rest, shimmer brings to 1.0 + def __init__(self, text: str | Callable[[], str], font_size: int = DEFAULT_TEXT_SIZE, @@ -414,7 +262,8 @@ class UnifiedLabel(Widget): wrap_text: bool = True, scroll: bool = False, line_height: float = 1.0, - letter_spacing: float = 0.0): + letter_spacing: float = 0.0, + shimmer: bool = False): super().__init__() self._text = text self._font_size = font_size @@ -432,6 +281,10 @@ class UnifiedLabel(Widget): self._letter_spacing = letter_spacing # 0.1 = 10% self._spacing_pixels = font_size * letter_spacing + # Shimmer state + self._shimmer = shimmer + self._shimmer_start_time = 0.0 + # Scroll state self._scroll = scroll self._needs_scroll = False @@ -467,6 +320,14 @@ class UnifiedLabel(Widget): """Get the current text content.""" return str(_resolve_value(self._text)) + @property + def font_size(self) -> int: + return self._font_size + + @property + def text_width(self) -> float: + return max((s.x for s in self._cached_line_sizes), default=0.0) + def set_text_color(self, color: rl.Color): """Update the text color.""" self._text_color = color @@ -517,6 +378,15 @@ class UnifiedLabel(Widget): self._scroll_pause_t = None self._scroll_state = ScrollState.STARTING + def show_event(self): + super().show_event() + if self._shimmer: + self.reset_shimmer() + + def reset_shimmer(self, offset: float = 0.0): + """Reset shimmer animation timing.""" + self._shimmer_start_time = rl.get_time() + offset + def set_max_width(self, max_width: int | None): """Set the maximum width constraint for wrapping/eliding.""" if self._max_width != max_width: @@ -770,6 +640,24 @@ class UnifiedLabel(Widget): rl.end_scissor_mode() + def _shimmer_alpha(self, char_x: float, shimmer_left: float, shimmer_width: float) -> float: + """Compute shimmer opacity multiplier for a character at the given x position.""" + sigma = shimmer_width * self.SHIMMER_BLUR_RADIUS + if sigma <= 0: + return self.SHIMMER_LOW_OPACITY + + elapsed = rl.get_time() - self._shimmer_start_time + t_raw = (elapsed % self.SHIMMER_CYCLE_PERIOD) / self.SHIMMER_CYCLE_PERIOD + t_clamped = max(0.0, min(t_raw / self.SHIMMER_SWEEP_FRACTION, 1.0)) + t = t_clamped * t_clamped * (3.0 - 2.0 * t_clamped) # smoothstep + + margin = shimmer_width * self.SHIMMER_BAND_WIDTH + center = shimmer_left + shimmer_width + margin - t * (shimmer_width + 2.0 * margin) + + d = char_x - center + 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): # Calculate horizontal position if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: @@ -782,7 +670,13 @@ class UnifiedLabel(Widget): line_x = self._rect.x + self._text_padding line_x += self._scroll_offset + x_offset - # Render line with emojis + 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) + + def _render_line_normal(self, line, emojis, line_x, current_y): line_pos = rl.Vector2(line_x, current_y) prev_index = 0 @@ -806,3 +700,23 @@ class UnifiedLabel(Widget): 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) + + def _render_line_shimmer(self, line, line_x, current_y): + # Shimmer range based on widest line so sweep is even across all lines + max_width = self.text_width + if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: + shimmer_left = self._rect.x + self._rect.width - self._text_padding - max_width + elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER: + shimmer_left = self._rect.x + (self._rect.width - max_width) / 2 + else: + shimmer_left = self._rect.x + self._text_padding + + base_a = self._text_color.a / 255.0 + cursor_x = line_x + for ch in line: + char_width = measure_text_cached(self._font, ch, self._font_size, self._spacing_pixels).x + char_center_x = cursor_x + char_width / 2.0 + alpha = int(255 * self._shimmer_alpha(char_center_x, shimmer_left, max_width) * base_a) + color = rl.Color(self._text_color.r, self._text_color.g, self._text_color.b, alpha) + rl.draw_text_ex(self._font, ch, rl.Vector2(cursor_x, current_y), self._font_size, 0, color) + cursor_x += char_width + self._spacing_pixels diff --git a/system/ui/widgets/layouts.py b/system/ui/widgets/layouts.py index 6f97fe5ed8..6bbc49e927 100644 --- a/system/ui/widgets/layouts.py +++ b/system/ui/widgets/layouts.py @@ -21,7 +21,6 @@ class HBoxLayout(Widget): def __init__(self, widgets: list[Widget] | None = None, spacing: int = 0, alignment: Alignment = Alignment.LEFT | Alignment.V_CENTER): super().__init__() - self._widgets: list[Widget] = [] self._spacing = spacing self._alignment = alignment @@ -31,13 +30,13 @@ class HBoxLayout(Widget): @property def widgets(self) -> list[Widget]: - return self._widgets + return self._children def add_widget(self, widget: Widget) -> None: - self._widgets.append(widget) + self._child(widget) def _render(self, _): - visible_widgets = [w for w in self._widgets if w.is_visible] + visible_widgets = [w for w in self._children if w.is_visible] cur_offset_x = 0 @@ -55,6 +54,6 @@ class HBoxLayout(Widget): y = self._rect.y + (self._rect.height - widget.rect.height) / 2 # Update widget position and render - widget.set_position(round(x), round(y)) + widget.set_position(x, y) widget.set_parent_rect(self._rect) widget.render() diff --git a/system/ui/widgets/list_view.py b/system/ui/widgets/list_view.py index 32bf01cfc8..82613c37c8 100644 --- a/system/ui/widgets/list_view.py +++ b/system/ui/widgets/list_view.py @@ -293,6 +293,7 @@ class ListItem(Widget): self._prev_description: str | None = self.description def show_event(self): + super().show_event() self._set_description_visible(False) def set_description_opened_callback(self, callback: Callable) -> None: @@ -354,7 +355,7 @@ class ListItem(Widget): if self.title: # Draw icon if present if self.icon: - rl.draw_texture(self._icon_texture, int(content_x), int(self._rect.y + (ITEM_BASE_HEIGHT - self._icon_texture.height) // 2), rl.WHITE) + rl.draw_texture_ex(self._icon_texture, rl.Vector2(content_x, self._rect.y + (ITEM_BASE_HEIGHT - self._icon_texture.height) / 2), 0.0, 1.0, rl.WHITE) text_x += ICON_SIZE + ITEM_PADDING # Draw main text diff --git a/system/ui/widgets/mici_keyboard.py b/system/ui/widgets/mici_keyboard.py index 18384fd905..75a3c29e6b 100644 --- a/system/ui/widgets/mici_keyboard.py +++ b/system/ui/widgets/mici_keyboard.py @@ -95,7 +95,7 @@ class Key(Widget): self._size_filter.update(size) def _get_font_size(self) -> int: - return int(round(self._size_filter.x)) + return round(self._size_filter.x) class SmallKey(Key): @@ -146,8 +146,9 @@ class CapsState(IntEnum): class MiciKeyboard(Widget): - def __init__(self): + def __init__(self, auto_return_to_letters: str = ""): super().__init__() + self._auto_return_to_letters = auto_return_to_letters lower_chars = [ "qwertyuiop", @@ -305,6 +306,10 @@ class MiciKeyboard(Widget): if self._caps_state == CapsState.UPPER: self._set_uppercase(False) + # Switch back to letters after common URL delimiters + if self._closest_key[0].char in self._auto_return_to_letters and self._current_keys in (self._special_keys, self._super_special_keys): + self._set_uppercase(False) + # ensure minimum selected animation time key_selected_dt = rl.get_time() - (self._selected_key_t or 0) cur_t = rl.get_time() diff --git a/system/ui/widgets/nav_widget.py b/system/ui/widgets/nav_widget.py index 67203d53f4..11770bbe5d 100644 --- a/system/ui/widgets/nav_widget.py +++ b/system/ui/widgets/nav_widget.py @@ -63,11 +63,13 @@ class NavWidget(Widget, abc.ABC): self._playing_dismiss_animation = False # released and animating away self._y_pos_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps, bounce=1) - self._back_callback: Callable[[], None] | None = None # persistent callback for any back navigation + self._back_callback: Callable[[], None] | None = None # persistent callback for user-initiated back navigation self._dismiss_callback: Callable[[], None] | None = None # transient callback for programmatic dismiss + # TODO: add this functionality to push_widget + self._shown_callback: Callable[[], None] | None = None # transient callback fired after show animation completes # TODO: move this state into NavBar - self._nav_bar = NavBar() + self._nav_bar = self._child(NavBar()) self._nav_bar_show_time = 0.0 self._nav_bar_y_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) @@ -79,6 +81,9 @@ class NavWidget(Widget, abc.ABC): def set_back_callback(self, callback: Callable[[], None]) -> None: self._back_callback = callback + def set_shown_callback(self, callback: Callable[[], None] | None) -> None: + self._shown_callback = callback + def _handle_mouse_event(self, mouse_event: MouseEvent) -> None: super()._handle_mouse_event(mouse_event) @@ -143,19 +148,24 @@ class NavWidget(Widget, abc.ABC): if self._playing_dismiss_animation: new_y = self._rect.height + DISMISS_PUSH_OFFSET - new_y = round(self._y_pos_filter.update(new_y)) - if abs(new_y) < 1 and self._y_pos_filter.velocity.x == 0.0: + new_y = self._y_pos_filter.update(new_y) + if abs(new_y) < 1 and abs(self._y_pos_filter.velocity.x) < 0.5: new_y = self._y_pos_filter.x = 0.0 + self._y_pos_filter.velocity.x = 0.0 + + if self._shown_callback is not None: + self._shown_callback() + self._shown_callback = None if new_y > self._rect.height + DISMISS_PUSH_OFFSET - 10: gui_app.pop_widget() - if self._back_callback is not None: - self._back_callback() - + # Only one callback should ever be fired if self._dismiss_callback is not None: self._dismiss_callback() self._dismiss_callback = None + elif self._back_callback is not None: + self._back_callback() self._playing_dismiss_animation = False self._drag_start_pos = None @@ -166,10 +176,10 @@ class NavWidget(Widget, abc.ABC): def _layout(self): # Dim whatever is behind this widget, fading with position (runs after _update_state so position is correct) overlay_alpha = int(200 * max(0.0, min(1.0, 1.0 - self._rect.y / self._rect.height))) if self._rect.height > 0 else 0 - rl.draw_rectangle(0, 0, int(self._rect.width), int(self._rect.height), rl.Color(0, 0, 0, overlay_alpha)) + rl.draw_rectangle_rec(rl.Rectangle(0, 0, self._rect.width, self._rect.height), rl.Color(0, 0, 0, overlay_alpha)) bounce_height = 20 - rl.draw_rectangle(int(self._rect.x), int(self._rect.y), int(self._rect.width), int(self._rect.height + bounce_height), rl.BLACK) + rl.draw_rectangle_rec(rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, self._rect.height + bounce_height), rl.BLACK) def render(self, rect: rl.Rectangle | None = None) -> bool | int | None: ret = super().render(rect) @@ -186,7 +196,7 @@ class NavWidget(Widget, abc.ABC): else: self._nav_bar_y_filter.update(NAV_BAR_MARGIN) - self._nav_bar.set_position(bar_x, round(self._nav_bar_y_filter.x)) + self._nav_bar.set_position(bar_x, self._nav_bar_y_filter.x) self._nav_bar.render() return ret @@ -204,7 +214,6 @@ class NavWidget(Widget, abc.ABC): def show_event(self): super().show_event() - self._nav_bar.show_event() # Reset state self._drag_start_pos = None @@ -214,6 +223,7 @@ class NavWidget(Widget, abc.ABC): # Start NavWidget off-screen, no matter how tall it is self._y_pos_filter.update_alpha(0.1) self._y_pos_filter.x = gui_app.height + self._y_pos_filter.velocity.x = 0.0 self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT self._nav_bar_show_time = rl.get_time() diff --git a/system/ui/widgets/network.py b/system/ui/widgets/network.py index 6427a2f203..69be42f502 100644 --- a/system/ui/widgets/network.py +++ b/system/ui/widgets/network.py @@ -75,17 +75,14 @@ class NetworkUI(Widget): super().__init__() self._wifi_manager = wifi_manager self._current_panel: PanelType = PanelType.WIFI - self._wifi_panel = WifiManagerUI(wifi_manager) - self._advanced_panel = AdvancedNetworkSettings(wifi_manager) - self._nav_button = NavButton(tr("Advanced")) + self._wifi_panel = self._child(WifiManagerUI(wifi_manager)) + self._advanced_panel = self._child(AdvancedNetworkSettings(wifi_manager)) + self._nav_button = self._child(NavButton(tr("Advanced"))) self._nav_button.set_click_callback(self._cycle_panel) def show_event(self): + super().show_event() self._set_current_panel(PanelType.WIFI) - self._wifi_panel.show_event() - - def hide_event(self): - self._wifi_panel.hide_event() def _cycle_panel(self): if self._current_panel == PanelType.WIFI: @@ -305,10 +302,12 @@ class WifiManagerUI(Widget): disconnected=self._on_disconnected) def show_event(self): + super().show_event() # start/stop scanning when widget is visible self._wifi_manager.set_active(True) def hide_event(self): + super().hide_event() self._wifi_manager.set_active(False) def _load_icons(self): diff --git a/system/ui/widgets/scroller.py b/system/ui/widgets/scroller.py index c48be6b80b..a3a0d2b38f 100644 --- a/system/ui/widgets/scroller.py +++ b/system/ui/widgets/scroller.py @@ -47,7 +47,7 @@ class ScrollIndicator(Widget): # position based on scroll ratio slide_range = self._viewport.width - indicator_w max_scroll = self._content_size - self._viewport.width - scroll_ratio = -self._scroll_offset / max_scroll + scroll_ratio = (-self._scroll_offset / abs(max_scroll)) if abs(max_scroll) > 1e-3 else 0.0 x = self._viewport.x + scroll_ratio * slide_range # don't bounce up when NavWidget shows y = max(self._viewport.y, 0) + self._viewport.height - self._txt_scroll_indicator.height / 2 @@ -190,7 +190,7 @@ class _Scroller(Widget): self.scroll_panel.set_enabled(scroll_enabled and self.enabled and not self._scrolling_to[1]) self.scroll_panel.update(self._rect, content_size) if not self._snap_items: - return round(self.scroll_panel.get_offset()) + return self.scroll_panel.get_offset() # Snap closest item to center center_pos = self._rect.x + self._rect.width / 2 if self._horizontal else self._rect.y + self._rect.height / 2 @@ -341,7 +341,7 @@ class _Scroller(Widget): x, y = self._do_move_animation(item, x, y) # Update item state - item.set_position(round(x), round(y)) # round to prevent jumping when settling + item.set_position(x, y) item.set_parent_rect(self._rect) def _render_item(self, item: Widget): @@ -422,18 +422,10 @@ class Scroller(Widget): """Wrapper for _Scroller so that children do not need to call events or pass down enabled for nav stack.""" def __init__(self, **kwargs): super().__init__() - self._scroller = _Scroller([], **kwargs) + self._scroller = self._child(_Scroller([], **kwargs)) # pass down enabled to child widget for nav stack self._scroller.set_enabled(lambda: self.enabled) - def show_event(self): - super().show_event() - self._scroller.show_event() - - def hide_event(self): - super().hide_event() - self._scroller.hide_event() - def _render(self, _): self._scroller.render(self._rect) diff --git a/system/ui/widgets/slider.py b/system/ui/widgets/slider.py index 8f4bbfc011..bf965954f2 100644 --- a/system/ui/widgets/slider.py +++ b/system/ui/widgets/slider.py @@ -1,3 +1,4 @@ +import abc from collections.abc import Callable import pyray as rl @@ -5,19 +6,23 @@ import pyray as rl 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 -from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter -class SmallSlider(Widget): +class SliderBase(Widget, abc.ABC): HORIZONTAL_PADDING = 8 CONFIRM_DELAY = 0.2 + PRESSED_SCALE = 1.07 - def __init__(self, title: str, confirm_callback: Callable | None = None): - # TODO: unify this with BigConfirmationDialogV2 + _bg_txt: rl.Texture + _circle_bg_txt: rl.Texture + _circle_bg_pressed_txt: rl.Texture + _circle_arrow_txt: rl.Texture + + def __init__(self, title: str, confirm_callback: Callable | None = None, shimmer_offset: float = 0.0): super().__init__() self._confirm_callback = confirm_callback - - self._font = gui_app.font(FontWeight.DISPLAY) + self._shimmer_offset = shimmer_offset self._load_assets() @@ -30,30 +35,34 @@ class SmallSlider(Widget): self._start_x_circle = 0.0 self._scroll_x_circle = 0.0 self._scroll_x_circle_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps) + self._circle_scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) + self._circle_press_time: float | None = None self._is_dragging_circle = False - self._label = UnifiedLabel(title, font_size=36, font_weight=FontWeight.SEMI_BOLD, text_color=rl.Color(255, 255, 255, int(255 * 0.65)), - alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9) + self._label = self._child(UnifiedLabel(title, font_size=36, font_weight=FontWeight.SEMI_BOLD, text_color=rl.WHITE, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9, shimmer=True)) + @abc.abstractmethod def _load_assets(self): - self.set_rect(rl.Rectangle(0, 0, 316 + self.HORIZONTAL_PADDING * 2, 100)) - - self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg.png", 316, 100) - self._circle_bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_red_circle.png", 100, 100) - self._circle_bg_pressed_txt = gui_app.texture("icons_mici/setup/small_slider/slider_red_circle_pressed.png", 100, 100) - self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 37, 32) + ... @property def confirmed(self) -> bool: return self._confirmed_time > 0.0 + def show_event(self): + super().show_event() + self.reset() + def reset(self): # reset all slider state self._is_dragging_circle = False + self._circle_press_time = None self._confirmed_time = 0.0 self._confirm_callback_called = False + self._label.reset_shimmer(self._shimmer_offset) def set_opacity(self, opacity: float, smooth: bool = False): if smooth: @@ -84,6 +93,7 @@ class SmallSlider(Widget): if rl.check_collision_point_rec(mouse_event.pos, circle_button_rect): self._start_x_circle = mouse_event.pos.x self._is_dragging_circle = True + self._circle_press_time = rl.get_time() elif mouse_event.left_released: # swiped to left @@ -101,7 +111,7 @@ class SmallSlider(Widget): activated_pos = int(-self._bg_txt.width + self._circle_bg_txt.width) self._scroll_x_circle = max(min(self._scroll_x_circle, 0), activated_pos) - if self._confirmed_time > 0: + if self.confirmed: # swiped left to confirm self._scroll_x_circle_filter.update(activated_pos) @@ -119,8 +129,6 @@ class SmallSlider(Widget): self._scroll_x_circle_filter.x = self._scroll_x_circle def _render(self, _): - # TODO: iOS text shimmering animation - white = rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)) bg_txt_x = self._rect.x + (self._rect.width - self._bg_txt.width) / 2 @@ -130,8 +138,9 @@ class SmallSlider(Widget): btn_x = bg_txt_x + self._bg_txt.width - self._circle_bg_txt.width + self._scroll_x_circle_filter.x btn_y = self._rect.y + (self._rect.height - self._circle_bg_txt.height) / 2 - if self._confirmed_time == 0.0 or self._scroll_x_circle > 0: - self._label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.65 * (1.0 - self.slider_percentage) * self._opacity_filter.x))) + label_alpha = int(255 * (1.0 - self.slider_percentage) * self._opacity_filter.x) + if label_alpha > 0: + self._label.set_text_color(rl.Color(255, 255, 255, label_alpha)) label_rect = rl.Rectangle( self._rect.x + 20, self._rect.y, @@ -140,19 +149,23 @@ class SmallSlider(Widget): ) self._label.render(label_rect) - # circle and arrow - circle_bg_txt = self._circle_bg_pressed_txt if self._is_dragging_circle or self._confirmed_time > 0 else self._circle_bg_txt - rl.draw_texture_ex(circle_bg_txt, rl.Vector2(btn_x, btn_y), 0.0, 1.0, white) + # circle and arrow with grow animation + circle_pressed = self._is_dragging_circle or self.confirmed or (self._circle_press_time is not None and rl.get_time() - self._circle_press_time < 0.075) + circle_bg_txt = self._circle_bg_pressed_txt if circle_pressed else self._circle_bg_txt + scale = self._circle_scale_filter.update(self.PRESSED_SCALE if circle_pressed else 1.0) + scaled_btn_x = btn_x + (self._circle_bg_txt.width * (1 - scale)) / 2 + scaled_btn_y = btn_y + (self._circle_bg_txt.height * (1 - scale)) / 2 + rl.draw_texture_ex(circle_bg_txt, rl.Vector2(scaled_btn_x, scaled_btn_y), 0.0, scale, white) arrow_x = btn_x + (self._circle_bg_txt.width - self._circle_arrow_txt.width) / 2 - arrow_y = btn_y + (self._circle_bg_txt.height - self._circle_arrow_txt.height) / 2 + arrow_y = scaled_btn_y + (self._circle_bg_txt.height - self._circle_arrow_txt.height) / 2 rl.draw_texture_ex(self._circle_arrow_txt, rl.Vector2(arrow_x, arrow_y), 0.0, 1.0, white) -class LargerSlider(SmallSlider): - def __init__(self, title: str, confirm_callback: Callable | None = None, green: bool = True): +class LargerSlider(SliderBase): + def __init__(self, title: str, confirm_callback: Callable | None = None, green: bool = True, shimmer_offset: float = 0.0): self._green = green - super().__init__(title, confirm_callback=confirm_callback) + super().__init__(title, confirm_callback=confirm_callback, shimmer_offset=shimmer_offset) def _load_assets(self): self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 115)) @@ -164,13 +177,13 @@ class LargerSlider(SmallSlider): self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 55) -class BigSlider(SmallSlider): +class BigSlider(SliderBase): def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable | None = None): self._icon = icon super().__init__(title, confirm_callback=confirm_callback) - self._label = UnifiedLabel(title, font_size=48, font_weight=FontWeight.DISPLAY, text_color=rl.Color(255, 255, 255, int(255 * 0.65)), - alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, - line_height=0.875) + self._label.set_font_size(48) + self._label.set_font_weight(FontWeight.DISPLAY) + self._label.set_line_height(0.875) def _load_assets(self): self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 180)) diff --git a/system/updated/tests/test_updated.py b/system/updated/tests/test_updated.py new file mode 100644 index 0000000000..d36d4dd4e1 --- /dev/null +++ b/system/updated/tests/test_updated.py @@ -0,0 +1,38 @@ +import pytest + +from openpilot.common.params import Params +from openpilot.system.updated.updated import Updater + + +@pytest.mark.parametrize(("device_type", "branch", "expected"), [ + ("tizi", "release3", "release-tizi"), + ("tizi", "release3-staging", "release-tizi-staging"), + ("mici", "release3", "release-mici"), + ("mici", "release3-staging", "release-mici-staging"), +]) +def test_target_branch_migration_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"), [ + ("tizi", "release3", "release-tizi"), + ("tizi", "release3-staging", "release-tizi-staging"), + ("mici", "release3", "release-mici"), + ("mici", "release3-staging", "release-mici-staging"), +]) +def test_target_branch_migration_from_param(mocker, device_type, branch, expected): + params = Params() + params.put("UpdaterTargetBranch", branch) + + mocker.patch("openpilot.system.updated.updated.HARDWARE.get_device_type", return_value=device_type) + + try: + assert Updater().target_branch == expected + finally: + params.remove("UpdaterTargetBranch") diff --git a/system/version.py b/system/version.py index ae6ac1b13a..2d4fc8f765 100755 --- a/system/version.py +++ b/system/version.py @@ -24,6 +24,10 @@ SP_BRANCH_MIGRATIONS = { ("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", } BUILD_METADATA_FILENAME = "build.json" diff --git a/system/webrtc/schema.py b/system/webrtc/schema.py index d80986ebf2..4876198eb0 100644 --- a/system/webrtc/schema.py +++ b/system/webrtc/schema.py @@ -16,7 +16,7 @@ def generate_type(type_walker, schema_walker) -> str | list[Any] | dict[str, Any def generate_struct(schema: capnp.lib.capnp._StructSchema) -> dict[str, Any]: - return {field: generate_field(schema.fields[field]) for field in schema.fields if not field.endswith("DEPRECATED")} + return {field: generate_field(schema.fields[field]) for field in schema.fields if not field.endswith("DEPRECATED") and field != "deprecated"} def generate_field(field: capnp.lib.capnp._StructSchemaField) -> str | list[Any] | dict[str, Any]: diff --git a/third_party/.gitignore b/third_party/.gitignore deleted file mode 100644 index 0d20b6487c..0000000000 --- a/third_party/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.pyc diff --git a/third_party/bootstrap/bootstrap-icons.ttf b/third_party/bootstrap/bootstrap-icons.ttf new file mode 100644 index 0000000000..49c8ea699a --- /dev/null +++ b/third_party/bootstrap/bootstrap-icons.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57e798d421bb56bb058ed9b0c83dd97fe1e411cde3a2bd6eb4a8705234f69027 +size 453096 diff --git a/third_party/bootstrap/pull.sh b/third_party/bootstrap/pull.sh index 0b03b4db9e..5c4c955c04 100755 --- a/third_party/bootstrap/pull.sh +++ b/third_party/bootstrap/pull.sh @@ -12,3 +12,13 @@ cd icons git fetch --all git checkout d5aa187483a1b0b186f87adcfa8576350d970d98 cp bootstrap-icons.svg ../ + +# Convert WOFF → TTF for imgui (imgui only reads TTF/OTF) +python3 -c " +from fontTools.ttLib import TTFont +import io +f = TTFont('font/fonts/bootstrap-icons.woff') +f.flavor = None +f.save('../bootstrap-icons.ttf') +print('bootstrap-icons.ttf written') +" diff --git a/third_party/json11/json11.cpp b/third_party/json11/json11.cpp index bc4045f07d..3bd4fde2f2 100644 --- a/third_party/json11/json11.cpp +++ b/third_party/json11/json11.cpp @@ -25,6 +25,7 @@ #include #include #include +#include namespace json11 { diff --git a/tools/README.md b/tools/README.md index d52c8f4522..90696ab4e6 100644 --- a/tools/README.md +++ b/tools/README.md @@ -38,7 +38,7 @@ scons -u -j$(nproc) Follow [these instructions](https://docs.microsoft.com/en-us/windows/wsl/install) to setup the WSL and install the `Ubuntu-24.04` distribution. Once your Ubuntu WSL environment is setup, follow the Linux setup instructions to finish setting up your environment. See [these instructions](https://learn.microsoft.com/en-us/windows/wsl/tutorials/gui-apps) for running GUI apps. -**NOTE**: If you are running WSL and any GUIs are failing (segfaulting or other strange issues) even after following the steps above, you may need to enable software rendering with `LIBGL_ALWAYS_SOFTWARE=1`, e.g. `LIBGL_ALWAYS_SOFTWARE=1 selfdrive/ui/ui`. +**NOTE**: If you are running WSL 2 and experiencing performance issues with the UI or simulator, you may need to explicitly enable hardware acceleration by setting `GALLIUM_DRIVER=d3d12` before commands. Add `export GALLIUM_DRIVER=d3d12` to your `~/.bashrc` file to make it automatic for future sessions. ## CTF Learn about the openpilot ecosystem and tools by playing our [CTF](/tools/CTF.md). diff --git a/tools/bodyteleop/.gitignore b/tools/bodyteleop/.gitignore deleted file mode 100644 index adeab99a95..0000000000 --- a/tools/bodyteleop/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -av -av-10.0.0/* -key.pem -cert.pem \ No newline at end of file diff --git a/tools/bodyteleop/static/index.html b/tools/bodyteleop/static/index.html index 3654769756..48672dbbf0 100644 --- a/tools/bodyteleop/static/index.html +++ b/tools/bodyteleop/static/index.html @@ -15,29 +15,23 @@

comma body

- -
-
-
-
-
- - - -
-

body

+
+
+
+
+
W
-
-
- - -
-

you

+
+
A
+
S
+
D
-
+
+
+
@@ -53,43 +47,6 @@
- -
-
-
-
-
-
W
-
0,0x,y
-
-
-
A
-
S
-
D
-
-
-
- - - - -
-
-
-
-
-
-

Play Sounds

-
-
- - - -
-
diff --git a/tools/bodyteleop/static/js/controls.js b/tools/bodyteleop/static/js/controls.js index b1e0e7ee70..3a11f78b9e 100644 --- a/tools/bodyteleop/static/js/controls.js +++ b/tools/bodyteleop/static/js/controls.js @@ -18,37 +18,3 @@ export const handleKeyX = (key, setValue) => { $("#pos-vals").text(x+","+y); } }; - -export async function executePlan() { - let plan = $("#plan-text").val(); - const planList = []; - plan.split("\n").forEach(function(e){ - let line = e.split(",").map(k=>parseInt(k)); - if (line.length != 5 || line.slice(0, 4).map(e=>[1, 0].includes(e)).includes(false) || line[4] < 0 || line[4] > 10){ - console.log("invalid plan"); - } - else{ - planList.push(line) - } - }); - - async function execute() { - for (var i = 0; i < planList.length; i++) { - let [w, a, s, d, t] = planList[i]; - while(t > 0){ - console.log(w, a, s, d, t); - if(w==1){$("#key-w").mousedown();} - if(a==1){$("#key-a").mousedown();} - if(s==1){$("#key-s").mousedown();} - if(d==1){$("#key-d").mousedown();} - await sleep(50); - $("#key-w").mouseup(); - $("#key-a").mouseup(); - $("#key-s").mouseup(); - $("#key-d").mouseup(); - t = t - 0.05; - } - } - } - execute(); -} \ No newline at end of file diff --git a/tools/bodyteleop/static/js/jsmain.js b/tools/bodyteleop/static/js/jsmain.js index 83205a876b..0db1dcd9b3 100644 --- a/tools/bodyteleop/static/js/jsmain.js +++ b/tools/bodyteleop/static/js/jsmain.js @@ -1,5 +1,5 @@ -import { handleKeyX, executePlan } from "./controls.js"; -import { start, stop, lastChannelMessageTime, playSoundRequest } from "./webrtc.js"; +import { handleKeyX } from "./controls.js"; +import { start, stop, lastChannelMessageTime } from "./webrtc.js"; export var pc = null; export var dc = null; @@ -8,12 +8,6 @@ document.addEventListener('keydown', (e)=>(handleKeyX(e.key.toLowerCase(), 1))); document.addEventListener('keyup', (e)=>(handleKeyX(e.key.toLowerCase(), 0))); $(".keys").bind("mousedown touchstart", (e)=>handleKeyX($(e.target).attr('id').replace('key-', ''), 1)); $(".keys").bind("mouseup touchend", (e)=>handleKeyX($(e.target).attr('id').replace('key-', ''), 0)); -$("#plan-button").click(executePlan); -$(".sound").click((e)=>{ - const sound = $(e.target).attr('id').replace('sound-', '') - return playSoundRequest(sound); -}); - setInterval( () => { const dt = new Date().getTime(); if ((dt - lastChannelMessageTime) > 1000) { diff --git a/tools/bodyteleop/static/js/webrtc.js b/tools/bodyteleop/static/js/webrtc.js index 165a2ce6c4..28bea238e6 100644 --- a/tools/bodyteleop/static/js/webrtc.js +++ b/tools/bodyteleop/static/js/webrtc.js @@ -15,15 +15,6 @@ export function offerRtcRequest(sdp, type) { } -export function playSoundRequest(sound) { - return fetch('/sound', { - body: JSON.stringify({sound}), - headers: {'Content-Type': 'application/json'}, - method: 'POST' - }); -} - - export function pingHeadRequest() { return fetch('/', { method: 'HEAD' @@ -38,20 +29,18 @@ export function createPeerConnection(pc) { pc = new RTCPeerConnection(config); - // connect audio / video + // connect video pc.addEventListener('track', function(evt) { console.log("Adding Tracks!") if (evt.track.kind == 'video') document.getElementById('video').srcObject = evt.streams[0]; - else - document.getElementById('audio').srcObject = evt.streams[0]; }); return pc; } export function negotiate(pc) { - return pc.createOffer({offerToReceiveAudio:true, offerToReceiveVideo:true}).then(function(offer) { + return pc.createOffer({offerToReceiveVideo:true}).then(function(offer) { return pc.setLocalDescription(offer); }).then(function() { return new Promise(function(resolve) { @@ -90,14 +79,6 @@ function isMobile() { export const constraints = { - audio: { - autoGainControl: false, - sampleRate: 48000, - sampleSize: 16, - echoCancellation: true, - noiseSuppression: true, - channelCount: 1 - }, video: isMobile() }; @@ -105,23 +86,8 @@ export const constraints = { export function start(pc, dc) { pc = createPeerConnection(pc); - // add audio track - navigator.mediaDevices.enumerateDevices() - .then(function(devices) { - const hasAudioInput = devices.find((device) => device.kind === "audioinput"); - var modifiedConstraints = {}; - modifiedConstraints.video = constraints.video; - modifiedConstraints.audio = hasAudioInput ? constraints.audio : false; - - return Promise.resolve(modifiedConstraints); - }) - .then(function(constraints) { - if (constraints.audio || constraints.video) { - return navigator.mediaDevices.getUserMedia(constraints); - } else{ - return Promise.resolve(null); - } - }) + // add a local video track on mobile + (constraints.video ? navigator.mediaDevices.getUserMedia(constraints) : Promise.resolve(null)) .then(function(stream) { if (stream) { stream.getTracks().forEach(function(track) { diff --git a/tools/bodyteleop/static/main.css b/tools/bodyteleop/static/main.css index 1bfb5982b4..79fe8052ff 100644 --- a/tools/bodyteleop/static/main.css +++ b/tools/bodyteleop/static/main.css @@ -172,13 +172,6 @@ video { display: none; } -.plan-form { - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: center; -} - .details { display: flex; padding: 0px 10px 0px 10px; diff --git a/tools/cabana/.gitignore b/tools/cabana/.gitignore index 3d64f83204..1ee6c92236 100644 --- a/tools/cabana/.gitignore +++ b/tools/cabana/.gitignore @@ -1,6 +1,8 @@ moc_* *.moc +assets.cc + _cabana dbc/car_fingerprint_to_dbc.json tests/test_cabana diff --git a/tools/cabana/SConscript b/tools/cabana/SConscript index cecb5ed8d9..f5ef0f4393 100644 --- a/tools/cabana/SConscript +++ b/tools/cabana/SConscript @@ -2,7 +2,9 @@ import subprocess import os import shutil -Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal') +import libusb + +Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib') # Detect Qt - skip build if not available if arch == "Darwin": @@ -13,15 +15,11 @@ if arch == "Darwin": has_qt = False else: has_qt = shutil.which('qmake') is not None - if not has_qt: Return() -SConscript(['#tools/replay/SConscript']) -Import('replay_lib') - qt_env = env.Clone() -qt_modules = ["Widgets", "Gui", "Core", "Network", "Concurrent", "DBus", "Xml"] +qt_modules = ["Widgets", "Gui", "Core"] qt_libs = [] if arch == "Darwin": @@ -51,7 +49,7 @@ else: qt_env['QT3DIR'] = qt_env['QTDIR'] qt_env.Tool('qt3') -qt_env['CPPPATH'] += qt_dirs + ["#third_party/qrcode"] +qt_env['CPPPATH'] += qt_dirs qt_flags = [ "-D_REENTRANT", "-DQT_NO_DEBUG", @@ -65,23 +63,20 @@ qt_env['LIBPATH'] += ['#selfdrive/ui', ] qt_env['LIBS'] = qt_libs base_frameworks = qt_env['FRAMEWORKS'] -base_libs = [common, messaging, cereal, visionipc, 'm', 'ssl', 'crypto', 'pthread'] + qt_env["LIBS"] +base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] + qt_env["LIBS"] if arch == "Darwin": - base_frameworks.append('QtCharts') - base_frameworks.append('QtSerialBus') + base_frameworks += ['QtCharts', 'CoreFoundation', 'CoreVideo', 'CoreMedia', 'IOKit', 'Security', 'VideoToolbox'] else: base_libs.append('Qt5Charts') - base_libs.append('Qt5SerialBus') - -qt_libs = base_libs cabana_env = qt_env.Clone() -if arch == "Darwin": - cabana_env['CPPPATH'] += [f"{brew_prefix}/include"] - cabana_env['LIBPATH'] += [f"{brew_prefix}/lib"] +cabana_env['CPPPATH'] += [libusb.INCLUDE_DIR] +cabana_env['LIBPATH'] += [libusb.LIB_DIR] -cabana_libs = [cereal, messaging, visionipc, replay_lib, 'avformat', 'avcodec', 'swresample', 'avutil', 'x264', 'z', 'bz2', 'zstd', 'yuv', 'usb-1.0'] + qt_libs +cabana_libs = [cereal, messaging, visionipc, replay_lib, 'avformat', 'avcodec', 'swresample', 'avutil', 'x264', 'z', 'bz2', 'zstd', 'yuv', 'usb-1.0'] + base_libs +if arch != "Darwin": + cabana_libs += ['va', 'va-drm', 'drm'] opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("../../opendbc/dbc").abspath) cabana_env['CXXFLAGS'] += [opendbc_path] @@ -91,12 +86,15 @@ assets_src = "assets/assets.qrc" cabana_env.Command(assets, assets_src, f"rcc $SOURCES -o $TARGET") cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, assets_src, "assets/assets.o"])) -cabana_lib = cabana_env.Library("cabana_lib", ['mainwin.cc', 'streams/socketcanstream.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', - 'streams/routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', - 'utils/export.cc', 'utils/util.cc', 'utils/elidedlabel.cc', - 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', - 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc', - 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) +cabana_srcs = ['mainwin.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', + 'streams/routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', + 'utils/export.cc', 'utils/util.cc', 'utils/elidedlabel.cc', + 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', + 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc', + 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] +if arch != "Darwin": + cabana_srcs += ['streams/socketcanstream.cc'] +cabana_lib = cabana_env.Library("cabana_lib", cabana_srcs, LIBS=cabana_libs, FRAMEWORKS=base_frameworks) cabana_env.Program('_cabana', ['cabana.cc', cabana_lib, assets], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) if GetOption('extras'): diff --git a/tools/cabana/assets/.gitignore b/tools/cabana/assets/.gitignore deleted file mode 100644 index 283034ca8b..0000000000 --- a/tools/cabana/assets/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.cc diff --git a/tools/cabana/binaryview.cc b/tools/cabana/binaryview.cc index b5a68c6b26..0be28f06b7 100644 --- a/tools/cabana/binaryview.cc +++ b/tools/cabana/binaryview.cc @@ -111,7 +111,8 @@ void BinaryView::highlight(const cabana::Signal *sig) { if (sig != hovered_sig) { for (int i = 0; i < model->items.size(); ++i) { auto &item_sigs = model->items[i].sigs; - if ((sig && item_sigs.contains(sig)) || (hovered_sig && item_sigs.contains(hovered_sig))) { + auto has = [](const auto &v, auto p) { return std::find(v.begin(), v.end(), p) != v.end(); }; + if ((sig && has(item_sigs, sig)) || (hovered_sig && has(item_sigs, hovered_sig))) { auto index = model->index(i / model->columnCount(), i % model->columnCount()); emit model->dataChanged(index, index, {Qt::DisplayRole}); } @@ -157,7 +158,7 @@ void BinaryView::mousePressEvent(QMouseEvent *event) { void BinaryView::highlightPosition(const QPoint &pos) { if (auto index = indexAt(viewport()->mapFromGlobal(pos)); index.isValid()) { auto item = (BinaryViewModel::Item *)index.internalPointer(); - const cabana::Signal *sig = item->sigs.isEmpty() ? nullptr : item->sigs.back(); + const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back(); highlight(sig); } } @@ -208,12 +209,12 @@ void BinaryView::refresh() { highlightPosition(QCursor::pos()); } -QSet BinaryView::getOverlappingSignals() const { - QSet overlapping; +std::set BinaryView::getOverlappingSignals() const { + std::set overlapping; for (const auto &item : model->items) { if (item.sigs.size() > 1) { for (auto s : item.sigs) { - if (s->type == cabana::Signal::Type::Normal) overlapping += s; + if (s->type == cabana::Signal::Type::Normal) overlapping.insert(s); } } } @@ -258,7 +259,7 @@ void BinaryViewModel::refresh() { int pos = sig->is_little_endian ? flipBitPos(sig->start_bit + j) : flipBitPos(sig->start_bit) + j; int idx = column_count * (pos / 8) + pos % 8; if (idx >= items.size()) { - qWarning() << "signal " << sig->name << "out of bounds.start_bit:" << sig->start_bit << "size:" << sig->size; + qWarning() << "signal " << sig->name.c_str() << "out of bounds.start_bit:" << sig->start_bit << "size:" << sig->size; break; } if (j == 0) sig->is_little_endian ? items[idx].is_lsb = true : items[idx].is_msb = true; @@ -404,7 +405,9 @@ bool BinaryItemDelegate::hasSignal(const QModelIndex &index, int dx, int dy, con if (!index.isValid()) return false; auto model = (const BinaryViewModel*)(index.model()); int idx = (index.row() + dy) * model->columnCount() + index.column() + dx; - return (idx >=0 && idx < model->items.size()) ? model->items[idx].sigs.contains(sig) : false; + if (idx < 0 || idx >= (int)model->items.size()) return false; + auto &s = model->items[idx].sigs; + return std::find(s.begin(), s.end(), sig) != s.end(); } void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { @@ -421,7 +424,7 @@ void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &op auto color = bin_view->resize_sig ? bin_view->resize_sig->color : option.palette.color(QPalette::Active, QPalette::Highlight); painter->fillRect(option.rect, color); painter->setPen(option.palette.color(QPalette::BrightText)); - } else if (!bin_view->selectionModel()->hasSelection() || !item->sigs.contains(bin_view->resize_sig)) { // not resizing + } else if (!bin_view->selectionModel()->hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), bin_view->resize_sig) == item->sigs.end()) { // not resizing if (item->sigs.size() > 0) { for (auto &s : item->sigs) { if (s == bin_view->hovered_sig) { @@ -433,7 +436,7 @@ void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &op } else if (item->valid && item->bg_color.alpha() > 0) { painter->fillRect(option.rect, item->bg_color); } - auto color_role = item->sigs.contains(bin_view->hovered_sig) ? QPalette::BrightText : QPalette::Text; + auto color_role = (std::find(item->sigs.begin(), item->sigs.end(), bin_view->hovered_sig) != item->sigs.end()) ? QPalette::BrightText : QPalette::Text; painter->setPen(option.palette.color(bin_view->is_message_active ? QPalette::Normal : QPalette::Disabled, color_role)); } diff --git a/tools/cabana/binaryview.h b/tools/cabana/binaryview.h index 920deb0018..e568228b37 100644 --- a/tools/cabana/binaryview.h +++ b/tools/cabana/binaryview.h @@ -1,10 +1,9 @@ #pragma once +#include #include #include -#include -#include #include #include @@ -51,7 +50,7 @@ public: bool is_msb = false; bool is_lsb = false; uint8_t val; - QList sigs; + std::vector sigs; bool valid = false; }; std::vector items; @@ -68,7 +67,7 @@ public: BinaryView(QWidget *parent = nullptr); void setMessage(const MessageId &message_id); void highlight(const cabana::Signal *sig); - QSet getOverlappingSignals() const; + std::set getOverlappingSignals() const; void updateState() { model->updateState(); } void paintEvent(QPaintEvent *event) override { is_message_active = can->isMessageActive(model->msg_id); diff --git a/tools/cabana/cabana b/tools/cabana/cabana index 00709734a5..cd9bf1dd79 100755 --- a/tools/cabana/cabana +++ b/tools/cabana/cabana @@ -33,6 +33,6 @@ fi # Build _cabana cd "$ROOT" -scons -j"$(nproc)" tools/cabana/_cabana +scons -j4 tools/cabana/_cabana cereal/messaging/bridge exec "$DIR/_cabana" "$@" diff --git a/tools/cabana/cabana.cc b/tools/cabana/cabana.cc index bc50afc03a..db26b4067a 100644 --- a/tools/cabana/cabana.cc +++ b/tools/cabana/cabana.cc @@ -5,7 +5,9 @@ #include "tools/cabana/streams/devicestream.h" #include "tools/cabana/streams/pandastream.h" #include "tools/cabana/streams/replaystream.h" +#ifdef __linux__ #include "tools/cabana/streams/socketcanstream.h" +#endif int main(int argc, char *argv[]) { QCoreApplication::setApplicationName("Cabana"); @@ -29,9 +31,11 @@ int main(int argc, char *argv[]) { cmd_parser.addOption({"msgq", "read can messages from the msgq"}); cmd_parser.addOption({"panda", "read can messages from panda"}); cmd_parser.addOption({"panda-serial", "read can messages from panda with given serial", "panda-serial"}); +#ifdef __linux__ if (SocketCanStream::available()) { cmd_parser.addOption({"socketcan", "read can messages from given SocketCAN device", "socketcan"}); } +#endif cmd_parser.addOption({"zmq", "read can messages from zmq at the specified ip-address", "ip-address"}); cmd_parser.addOption({"data_dir", "local directory with routes", "data_dir"}); cmd_parser.addOption({"no-vipc", "do not output video"}); @@ -46,13 +50,15 @@ int main(int argc, char *argv[]) { stream = new DeviceStream(&app, cmd_parser.value("zmq")); } else if (cmd_parser.isSet("panda") || cmd_parser.isSet("panda-serial")) { try { - stream = new PandaStream(&app, {.serial = cmd_parser.value("panda-serial")}); + stream = new PandaStream(&app, {.serial = cmd_parser.value("panda-serial").toStdString()}); } catch (std::exception &e) { qWarning() << e.what(); return 0; } +#ifdef __linux__ } else if (SocketCanStream::available() && cmd_parser.isSet("socketcan")) { - stream = new SocketCanStream(&app, {.device = cmd_parser.value("socketcan")}); + stream = new SocketCanStream(&app, {.device = cmd_parser.value("socketcan").toStdString()}); +#endif } else { uint32_t replay_flags = REPLAY_FLAG_NONE; if (cmd_parser.isSet("ecam")) replay_flags |= REPLAY_FLAG_ECAM; @@ -70,7 +76,7 @@ int main(int argc, char *argv[]) { if (!route.isEmpty()) { auto replay_stream = std::make_unique(&app); bool auto_source = cmd_parser.isSet("auto"); - if (!replay_stream->loadRoute(route, cmd_parser.value("data_dir"), replay_flags, auto_source)) { + if (!replay_stream->loadRoute(route.toStdString(), cmd_parser.value("data_dir").toStdString(), replay_flags, auto_source)) { return 0; } stream = replay_stream.release(); diff --git a/tools/cabana/chart/chart.cc b/tools/cabana/chart/chart.cc index 14491b1eff..9dfdc595f0 100644 --- a/tools/cabana/chart/chart.cc +++ b/tools/cabana/chart/chart.cc @@ -237,8 +237,8 @@ void ChartView::updateTitle() { for (auto &s : sigs) { auto decoration = s.series->isVisible() ? "none" : "line-through"; s.series->setName(QString("%3 %5 %6") - .arg(decoration, titleColorCss, s.sig->name, - msgColorCss, msgName(s.msg_id), s.msg_id.toString())); + .arg(decoration, titleColorCss, QString::fromStdString(s.sig->name), + msgColorCss, QString::fromStdString(msgName(s.msg_id)), QString::fromStdString(s.msg_id.toString()))); } split_chart_act->setEnabled(sigs.size() > 1); resetChartCache(); @@ -339,13 +339,13 @@ void ChartView::updateAxisY() { double min = std::numeric_limits::max(); double max = std::numeric_limits::lowest(); - QString unit = sigs[0].sig->unit; + QString unit = QString::fromStdString(sigs[0].sig->unit); for (auto &s : sigs) { if (!s.series->isVisible()) continue; // Only show unit when all signals have the same unit - if (unit != s.sig->unit) { + if (unit != QString::fromStdString(s.sig->unit)) { unit.clear(); } @@ -573,11 +573,11 @@ void ChartView::showTip(double sec) { // use reverse iterator to find last item <= sec. auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), sec, [](auto &p, double v) { return p.x() > v; }); if (it != s.vals.crend() && it->x() >= axis_x->min()) { - value = s.sig->formatValue(it->y(), false); + value = QString::fromStdString(s.sig->formatValue(it->y(), false)); s.track_pt = *it; x = std::max(x, chart()->mapToPosition(*it).x()); } - QString name = sigs.size() > 1 ? s.sig->name + ": " : ""; + QString name = sigs.size() > 1 ? QString::fromStdString(s.sig->name) + ": " : ""; QString min = s.min == std::numeric_limits::max() ? "--" : QString::number(s.min); QString max = s.max == std::numeric_limits::lowest() ? "--" : QString::number(s.max); text_list << QString("%2%3 (%4, %5)") @@ -766,7 +766,7 @@ void ChartView::drawSignalValue(QPainter *painter) { for (auto &s : sigs) { auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), cur_sec, [](auto &p, double x) { return p.x() > x + EPSILON; }); - QString value = (it != s.vals.crend() && it->x() >= axis_x->min()) ? s.sig->formatValue(it->y()) : "--"; + QString value = (it != s.vals.crend() && it->x() >= axis_x->min()) ? QString::fromStdString(s.sig->formatValue(it->y())) : "--"; QRectF marker_rect = legend_markers[i++]->sceneBoundingRect(); QRectF value_rect(marker_rect.bottomLeft() - QPoint(0, 1), marker_rect.size()); QString elided_val = painter->fontMetrics().elidedText(value, Qt::ElideRight, value_rect.width()); diff --git a/tools/cabana/chart/chartswidget.cc b/tools/cabana/chart/chartswidget.cc index aba25dcf83..44dca42152 100644 --- a/tools/cabana/chart/chartswidget.cc +++ b/tools/cabana/chart/chartswidget.cc @@ -1,13 +1,13 @@ #include "tools/cabana/chart/chartswidget.h" #include +#include #include -#include #include +#include #include #include -#include #include "tools/cabana/chart/chart.h" @@ -166,15 +166,16 @@ void ChartsWidget::removeTab(int index) { void ChartsWidget::updateTabBar() { for (int i = 0; i < tabbar->count(); ++i) { const auto &charts_in_tab = tab_charts[tabbar->tabData(i).toInt()]; - tabbar->setTabText(i, QString("Tab %1 (%2)").arg(i + 1).arg(charts_in_tab.count())); + tabbar->setTabText(i, QString("Tab %1 (%2)").arg(i + 1).arg((int)charts_in_tab.size())); } } void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) { - QFutureSynchronizer future_synchronizer; + std::vector> futures; for (auto c : charts) { - future_synchronizer.addFuture(QtConcurrent::run(c, &ChartView::updateSeries, nullptr, &new_events)); + futures.push_back(std::async(std::launch::async, &ChartView::updateSeries, c, nullptr, &new_events)); } + for (auto &f : futures) f.get(); } void ChartsWidget::timeRangeChanged(const std::optional> &time_range) { @@ -203,7 +204,7 @@ void ChartsWidget::showValueTip(double sec) { } void ChartsWidget::updateState() { - if (charts.isEmpty()) return; + if (charts.empty()) return; const auto &time_range = can->timeRange(); const double cur_sec = can->currentSec(); @@ -247,7 +248,7 @@ void ChartsWidget::updateToolBar() { redo_zoom_action->setVisible(is_zoomed); reset_zoom_action->setVisible(is_zoomed); reset_zoom_btn->setText(is_zoomed ? tr("%1-%2").arg(can->timeRange()->first, 0, 'f', 2).arg(can->timeRange()->second, 0, 'f', 2) : ""); - remove_all_btn->setEnabled(!charts.isEmpty()); + remove_all_btn->setEnabled(!charts.empty()); } void ChartsWidget::settingChanged() { @@ -281,9 +282,9 @@ ChartView *ChartsWidget::createChart(int pos) { chart->setMinimumWidth(CHART_MIN_WIDTH); chart->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Fixed); QObject::connect(chart, &ChartView::axisYLabelWidthChanged, align_timer, qOverload<>(&QTimer::start)); - pos = std::clamp(pos, 0, charts.size()); - charts.insert(pos, chart); - currentCharts().insert(pos, chart); + pos = std::clamp(pos, 0, (int)charts.size()); + charts.insert(charts.begin() + pos, chart); + currentCharts().insert(currentCharts().begin() + pos, chart); updateLayout(true); updateToolBar(); return chart; @@ -302,7 +303,7 @@ void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, boo void ChartsWidget::splitChart(ChartView *src_chart) { if (src_chart->sigs.size() > 1) { - int pos = charts.indexOf(src_chart) + 1; + int pos = std::find(charts.begin(), charts.end(), src_chart) - charts.begin() + 1; for (auto it = src_chart->sigs.begin() + 1; it != src_chart->sigs.end(); /**/) { auto c = createChart(pos); src_chart->chart()->removeSeries(it->series); @@ -327,7 +328,7 @@ QStringList ChartsWidget::serializeChartIds() const { for (auto c : charts) { QStringList ids; for (const auto& s : c->sigs) - ids += QString("%1|%2").arg(s.msg_id.toString(), s.sig->name); + ids += QString("%1|%2").arg(QString::fromStdString(s.msg_id.toString()), QString::fromStdString(s.sig->name)); chart_ids += ids.join(','); } std::reverse(chart_ids.begin(), chart_ids.end()); @@ -340,9 +341,9 @@ void ChartsWidget::restoreChartsFromIds(const QStringList& chart_ids) { for (const auto& part : chart_id.split(',')) { const auto sig_parts = part.split('|'); if (sig_parts.size() != 2) continue; - MessageId msg_id = MessageId::fromString(sig_parts[0]); + MessageId msg_id = MessageId::fromString(sig_parts[0].toStdString()); if (auto* msg = dbc()->msg(msg_id)) - if (auto* sig = msg->sig(sig_parts[1])) + if (auto* sig = msg->sig(sig_parts[1].toStdString())) showChart(msg_id, sig, true, index++ > 0); } } @@ -426,14 +427,14 @@ void ChartsWidget::doAutoScroll() { } QSize ChartsWidget::minimumSizeHint() const { - return QSize(CHART_MIN_WIDTH * 1.5 * qApp->devicePixelRatio(), QWidget::minimumSizeHint().height()); + return QSize(CHART_MIN_WIDTH * 1.5, QWidget::minimumSizeHint().height()); } void ChartsWidget::newChart() { SignalSelector dlg(tr("New Chart"), this); if (dlg.exec() == QDialog::Accepted) { auto items = dlg.seletedItems(); - if (!items.isEmpty()) { + if (!items.empty()) { auto c = createChart(); for (auto it : items) { c->addSignal(it->msg_id, it->sig); @@ -443,10 +444,10 @@ void ChartsWidget::newChart() { } void ChartsWidget::removeChart(ChartView *chart) { - charts.removeOne(chart); + charts.erase(std::remove(charts.begin(), charts.end(), chart), charts.end()); chart->deleteLater(); for (auto &[_, list] : tab_charts) { - list.removeOne(chart); + list.erase(std::remove(list.begin(), list.end(), chart), list.end()); } updateToolBar(); updateLayout(true); @@ -460,7 +461,7 @@ void ChartsWidget::removeAll() { } tab_charts.clear(); - if (!charts.isEmpty()) { + if (!charts.empty()) { for (auto c : charts) { delete c; } @@ -560,10 +561,11 @@ void ChartsContainer::dropEvent(QDropEvent *event) { auto chart = qobject_cast(event->source()); if (w != chart) { for (auto &[_, list] : charts_widget->tab_charts) { - list.removeOne(chart); + list.erase(std::remove(list.begin(), list.end(), chart), list.end()); } - int to = w ? charts_widget->currentCharts().indexOf(w) + 1 : 0; - charts_widget->currentCharts().insert(to, chart); + auto &cur = charts_widget->currentCharts(); + int to = w ? std::find(cur.begin(), cur.end(), w) - cur.begin() + 1 : 0; + cur.insert(cur.begin() + to, chart); charts_widget->updateLayout(true); charts_widget->updateTabBar(); event->acceptProposedAction(); diff --git a/tools/cabana/chart/chartswidget.h b/tools/cabana/chart/chartswidget.h index f87b1276c5..ef3fbc471a 100644 --- a/tools/cabana/chart/chartswidget.h +++ b/tools/cabana/chart/chartswidget.h @@ -81,7 +81,7 @@ private: bool eventFilter(QObject *obj, QEvent *event) override; void newTab(); void removeTab(int index); - inline QList ¤tCharts() { return tab_charts[tabbar->tabData(tabbar->currentIndex()).toInt()]; } + inline std::vector ¤tCharts() { return tab_charts[tabbar->tabData(tabbar->currentIndex()).toInt()]; } ChartView *findChart(const MessageId &id, const cabana::Signal *sig); QLabel *title_label; @@ -100,8 +100,8 @@ private: QUndoStack *zoom_undo_stack; ToolButton *remove_all_btn; - QList charts; - std::unordered_map> tab_charts; + std::vector charts; + std::unordered_map> tab_charts; TabBar *tabbar; ChartsContainer *charts_container; QScrollArea *charts_scroll; diff --git a/tools/cabana/chart/signalselector.cc b/tools/cabana/chart/signalselector.cc index 63f3a7d575..6f2fd8de46 100644 --- a/tools/cabana/chart/signalselector.cc +++ b/tools/cabana/chart/signalselector.cc @@ -46,7 +46,7 @@ SignalSelector::SignalSelector(QString title, QWidget *parent) : QDialog(parent) for (const auto &[id, _] : can->lastMessages()) { if (auto m = dbc()->msg(id)) { - msgs_combo->addItem(QString("%1 (%2)").arg(m->name).arg(id.toString()), QVariant::fromValue(id)); + msgs_combo->addItem(QString("%1 (%2)").arg(QString::fromStdString(m->name)).arg(QString::fromStdString(id.toString())), QVariant::fromValue(id)); } } msgs_combo->model()->sort(0); @@ -92,8 +92,8 @@ void SignalSelector::updateAvailableList(int index) { } void SignalSelector::addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name) { - QString text = QString(" %1").arg(sig->color.name(), sig->name); - if (show_msg_name) text += QString(" %0 %1").arg(msgName(id), id.toString()); + QString text = QString(" %1").arg(sig->color.name(), QString::fromStdString(sig->name)); + if (show_msg_name) text += QString(" %0 %1").arg(QString::fromStdString(msgName(id)), QString::fromStdString(id.toString())); QLabel *label = new QLabel(text); label->setContentsMargins(5, 0, 5, 0); @@ -102,8 +102,8 @@ void SignalSelector::addItemToList(QListWidget *parent, const MessageId id, cons parent->setItemWidget(new_item, label); } -QList SignalSelector::seletedItems() { - QList ret; +std::vector SignalSelector::seletedItems() { + std::vector ret; for (int i = 0; i < selected_list->count(); ++i) ret.push_back((ListItem *)selected_list->item(i)); return ret; } diff --git a/tools/cabana/chart/signalselector.h b/tools/cabana/chart/signalselector.h index f46779f044..5b6e37e56a 100644 --- a/tools/cabana/chart/signalselector.h +++ b/tools/cabana/chart/signalselector.h @@ -15,7 +15,7 @@ public: }; SignalSelector(QString title, QWidget *parent); - QList seletedItems(); + std::vector seletedItems(); inline void addSelected(const MessageId &id, const cabana::Signal *sig) { addItemToList(selected_list, id, sig, true); } private: diff --git a/tools/cabana/commands.cc b/tools/cabana/commands.cc index 52861723f4..f158528b51 100644 --- a/tools/cabana/commands.cc +++ b/tools/cabana/commands.cc @@ -4,22 +4,22 @@ // EditMsgCommand -EditMsgCommand::EditMsgCommand(const MessageId &id, const QString &name, int size, - const QString &node, const QString &comment, QUndoCommand *parent) +EditMsgCommand::EditMsgCommand(const MessageId &id, const std::string &name, int size, + const std::string &node, const std::string &comment, QUndoCommand *parent) : id(id), new_name(name), new_size(size), new_node(node), new_comment(comment), QUndoCommand(parent) { if (auto msg = dbc()->msg(id)) { old_name = msg->name; old_size = msg->size; old_node = msg->transmitter; old_comment = msg->comment; - setText(QObject::tr("edit message %1:%2").arg(name).arg(id.address)); + setText(QObject::tr("edit message %1:%2").arg(QString::fromStdString(name)).arg(id.address)); } else { - setText(QObject::tr("new message %1:%2").arg(name).arg(id.address)); + setText(QObject::tr("new message %1:%2").arg(QString::fromStdString(name)).arg(id.address)); } } void EditMsgCommand::undo() { - if (old_name.isEmpty()) + if (old_name.empty()) dbc()->removeMsg(id); else dbc()->updateMsg(id, old_name, old_size, old_node, old_comment); @@ -34,12 +34,12 @@ void EditMsgCommand::redo() { RemoveMsgCommand::RemoveMsgCommand(const MessageId &id, QUndoCommand *parent) : id(id), QUndoCommand(parent) { if (auto msg = dbc()->msg(id)) { message = *msg; - setText(QObject::tr("remove message %1:%2").arg(message.name).arg(id.address)); + setText(QObject::tr("remove message %1:%2").arg(QString::fromStdString(message.name)).arg(id.address)); } } void RemoveMsgCommand::undo() { - if (!message.name.isEmpty()) { + if (!message.name.empty()) { dbc()->updateMsg(id, message.name, message.size, message.transmitter, message.comment); for (auto s : message.getSignals()) dbc()->addSignal(id, *s); @@ -47,7 +47,7 @@ void RemoveMsgCommand::undo() { } void RemoveMsgCommand::redo() { - if (!message.name.isEmpty()) + if (!message.name.empty()) dbc()->removeMsg(id); } @@ -55,7 +55,7 @@ void RemoveMsgCommand::redo() { AddSigCommand::AddSigCommand(const MessageId &id, const cabana::Signal &sig, QUndoCommand *parent) : id(id), signal(sig), QUndoCommand(parent) { - setText(QObject::tr("add signal %1 to %2:%3").arg(sig.name).arg(msgName(id)).arg(id.address)); + setText(QObject::tr("add signal %1 to %2:%3").arg(QString::fromStdString(sig.name)).arg(QString::fromStdString(msgName(id))).arg(id.address)); } void AddSigCommand::undo() { @@ -85,7 +85,7 @@ RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *si } } } - setText(QObject::tr("remove signal %1 from %2:%3").arg(sig->name).arg(msgName(id)).arg(id.address)); + setText(QObject::tr("remove signal %1 from %2:%3").arg(QString::fromStdString(sig->name)).arg(QString::fromStdString(msgName(id))).arg(id.address)); } void RemoveSigCommand::undo() { for (const auto &s : sigs) dbc()->addSignal(id, s); } @@ -108,7 +108,7 @@ EditSignalCommand::EditSignalCommand(const MessageId &id, const cabana::Signal * } } } - setText(QObject::tr("edit signal %1 in %2:%3").arg(sig->name).arg(msgName(id)).arg(id.address)); + setText(QObject::tr("edit signal %1 in %2:%3").arg(QString::fromStdString(sig->name)).arg(QString::fromStdString(msgName(id))).arg(id.address)); } void EditSignalCommand::undo() { for (const auto &s : sigs) dbc()->updateSignal(id, s.second.name, s.first); } diff --git a/tools/cabana/commands.h b/tools/cabana/commands.h index 0736d9b83f..4081f86985 100644 --- a/tools/cabana/commands.h +++ b/tools/cabana/commands.h @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include @@ -10,14 +12,14 @@ class EditMsgCommand : public QUndoCommand { public: - EditMsgCommand(const MessageId &id, const QString &name, int size, const QString &node, - const QString &comment, QUndoCommand *parent = nullptr); + EditMsgCommand(const MessageId &id, const std::string &name, int size, const std::string &node, + const std::string &comment, QUndoCommand *parent = nullptr); void undo() override; void redo() override; private: const MessageId id; - QString old_name, new_name, old_comment, new_comment, old_node, new_node; + std::string old_name, new_name, old_comment, new_comment, old_node, new_node; int old_size = 0, new_size = 0; }; @@ -52,7 +54,7 @@ public: private: const MessageId id; - QList sigs; + std::vector sigs; }; class EditSignalCommand : public QUndoCommand { @@ -63,7 +65,7 @@ public: private: const MessageId id; - QList> sigs; // QList<{old_sig, new_sig}> + std::vector> sigs; // {old_sig, new_sig} }; namespace UndoStack { diff --git a/tools/cabana/dbc/dbc.cc b/tools/cabana/dbc/dbc.cc index 9b0de92218..8e41cf54e3 100644 --- a/tools/cabana/dbc/dbc.cc +++ b/tools/cabana/dbc/dbc.cc @@ -4,10 +4,6 @@ #include "tools/cabana/utils/util.h" -uint qHash(const MessageId &item) { - return qHash(item.source) ^ qHash(item.address); -} - // cabana::Msg cabana::Msg::~Msg() { @@ -22,7 +18,7 @@ cabana::Signal *cabana::Msg::addSignal(const cabana::Signal &sig) { return s; } -cabana::Signal *cabana::Msg::updateSignal(const QString &sig_name, const cabana::Signal &new_sig) { +cabana::Signal *cabana::Msg::updateSignal(const std::string &sig_name, const cabana::Signal &new_sig) { auto s = sig(sig_name); if (s) { *s = new_sig; @@ -31,7 +27,7 @@ cabana::Signal *cabana::Msg::updateSignal(const QString &sig_name, const cabana: return s; } -void cabana::Msg::removeSignal(const QString &sig_name) { +void cabana::Msg::removeSignal(const std::string &sig_name) { auto it = std::find_if(sigs.begin(), sigs.end(), [&](auto &s) { return s->name == sig_name; }); if (it != sigs.end()) { delete *it; @@ -57,7 +53,7 @@ cabana::Msg &cabana::Msg::operator=(const cabana::Msg &other) { return *this; } -cabana::Signal *cabana::Msg::sig(const QString &sig_name) const { +cabana::Signal *cabana::Msg::sig(const std::string &sig_name) const { auto it = std::find_if(sigs.begin(), sigs.end(), [&](auto &s) { return s->name == sig_name; }); return it != sigs.end() ? *it : nullptr; } @@ -69,17 +65,17 @@ int cabana::Msg::indexOf(const cabana::Signal *sig) const { return -1; } -QString cabana::Msg::newSignalName() { - QString new_name; +std::string cabana::Msg::newSignalName() { + std::string new_name; for (int i = 1; /**/; ++i) { - new_name = QString("NEW_SIGNAL_%1").arg(i); + new_name = "NEW_SIGNAL_" + std::to_string(i); if (sig(new_name) == nullptr) break; } return new_name; } void cabana::Msg::update() { - if (transmitter.isEmpty()) { + if (transmitter.empty()) { transmitter = DEFAULT_NODE_NAME; } mask.assign(size, 0x00); @@ -129,13 +125,13 @@ void cabana::Msg::update() { void cabana::Signal::update() { updateMsbLsb(*this); - if (receiver_name.isEmpty()) { + if (receiver_name.empty()) { receiver_name = DEFAULT_NODE_NAME; } float h = 19 * (float)lsb / 64.0; h = fmod(h, 1.0); - size_t hash = qHash(name); + size_t hash = std::hash{}(name); float s = 0.25 + 0.25 * (float)(hash & 0xff) / 255.0; float v = 0.75 + 0.25 * (float)((hash >> 8) & 0xff) / 255.0; @@ -143,7 +139,7 @@ void cabana::Signal::update() { precision = std::max(num_decimals(factor), num_decimals(offset)); } -QString cabana::Signal::formatValue(double value, bool with_unit) const { +std::string cabana::Signal::formatValue(double value, bool with_unit) const { // Show enum string int64_t raw_value = round((value - offset) / factor); for (const auto &[val, desc] : val_desc) { @@ -152,8 +148,10 @@ QString cabana::Signal::formatValue(double value, bool with_unit) const { } } - QString val_str = QString::number(value, 'f', precision); - if (with_unit && !unit.isEmpty()) { + char buf[64]; + snprintf(buf, sizeof(buf), "%.*f", precision, value); + std::string val_str(buf); + if (with_unit && !unit.empty()) { val_str += " " + unit; } return val_str; diff --git a/tools/cabana/dbc/dbc.h b/tools/cabana/dbc/dbc.h index 134d88a919..a10e7871fe 100644 --- a/tools/cabana/dbc/dbc.h +++ b/tools/cabana/dbc/dbc.h @@ -1,29 +1,35 @@ #pragma once +#include +#include +#include #include +#include #include #include #include #include -#include -const QString UNTITLED = "untitled"; -const QString DEFAULT_NODE_NAME = "XXX"; +const std::string UNTITLED = "untitled"; +const std::string DEFAULT_NODE_NAME = "XXX"; constexpr int CAN_MAX_DATA_BYTES = 64; struct MessageId { uint8_t source = 0; uint32_t address = 0; - QString toString() const { - return QString("%1:%2").arg(source).arg(QString::number(address, 16).toUpper()); + std::string toString() const { + char buf[64]; + snprintf(buf, sizeof(buf), "%u:%X", source, address); + return buf; } - inline static MessageId fromString(const QString &str) { - auto parts = str.split(':'); - if (parts.size() != 2) return {}; - return MessageId{.source = uint8_t(parts[0].toUInt()), .address = parts[1].toUInt(nullptr, 16)}; + inline static MessageId fromString(const std::string &str) { + auto pos = str.find(':'); + if (pos == std::string::npos) return {}; + return MessageId{.source = uint8_t(std::stoul(str.substr(0, pos))), + .address = uint32_t(std::stoul(str.substr(pos + 1), nullptr, 16))}; } bool operator==(const MessageId &other) const { @@ -43,15 +49,17 @@ struct MessageId { } }; -uint qHash(const MessageId &item); Q_DECLARE_METATYPE(MessageId); template <> struct std::hash { - std::size_t operator()(const MessageId &k) const noexcept { return qHash(k); } + std::size_t operator()(const MessageId &k) const noexcept { + return std::hash{}(k.source) ^ (std::hash{}(k.address) << 1); + } }; -typedef std::vector> ValueDescription; +typedef std::vector> ValueDescription; +Q_DECLARE_METATYPE(ValueDescription); namespace cabana { @@ -61,7 +69,7 @@ public: Signal(const Signal &other) = default; void update(); bool getValue(const uint8_t *data, size_t data_size, double *val) const; - QString formatValue(double value, bool with_unit = true) const; + std::string formatValue(double value, bool with_unit = true) const; bool operator==(const cabana::Signal &other) const; inline bool operator!=(const cabana::Signal &other) const { return !(*this == other); } @@ -72,16 +80,16 @@ public: }; Type type = Type::Normal; - QString name; + std::string name; int start_bit, msb, lsb, size; double factor = 1.0; double offset = 0; bool is_signed; bool is_little_endian; double min, max; - QString unit; - QString comment; - QString receiver_name; + std::string unit; + std::string comment; + std::string receiver_name; ValueDescription val_desc; int precision = 0; QColor color; @@ -97,20 +105,20 @@ public: Msg(const Msg &other) { *this = other; } ~Msg(); cabana::Signal *addSignal(const cabana::Signal &sig); - cabana::Signal *updateSignal(const QString &sig_name, const cabana::Signal &sig); - void removeSignal(const QString &sig_name); + cabana::Signal *updateSignal(const std::string &sig_name, const cabana::Signal &sig); + void removeSignal(const std::string &sig_name); Msg &operator=(const Msg &other); int indexOf(const cabana::Signal *sig) const; - cabana::Signal *sig(const QString &sig_name) const; - QString newSignalName(); + cabana::Signal *sig(const std::string &sig_name) const; + std::string newSignalName(); void update(); inline const std::vector &getSignals() const { return sigs; } uint32_t address; - QString name; + std::string name; uint32_t size; - QString comment; - QString transmitter; + std::string comment; + std::string transmitter; std::vector sigs; std::vector mask; @@ -123,4 +131,8 @@ public: double get_raw_value(const uint8_t *data, size_t data_size, const cabana::Signal &sig); void updateMsbLsb(cabana::Signal &s); inline int flipBitPos(int start_bit) { return 8 * (start_bit / 8) + 7 - start_bit % 8; } -inline QString doubleToString(double value) { return QString::number(value, 'g', std::numeric_limits::digits10); } +inline std::string doubleToString(double value) { + char buf[64]; + snprintf(buf, sizeof(buf), "%.*g", std::numeric_limits::digits10, value); + return buf; +} diff --git a/tools/cabana/dbc/dbcfile.cc b/tools/cabana/dbc/dbcfile.cc index 1c03c8a0aa..d9c129ee81 100644 --- a/tools/cabana/dbc/dbcfile.cc +++ b/tools/cabana/dbc/dbcfile.cc @@ -3,11 +3,12 @@ #include #include #include +#include -DBCFile::DBCFile(const QString &dbc_file_name) { - QFile file(dbc_file_name); +DBCFile::DBCFile(const std::string &dbc_file_name) { + QFile file(QString::fromStdString(dbc_file_name)); if (file.open(QIODevice::ReadOnly)) { - name_ = QFileInfo(dbc_file_name).baseName(); + name_ = QFileInfo(QString::fromStdString(dbc_file_name)).baseName().toStdString(); filename = dbc_file_name; parse(file.readAll()); } else { @@ -15,34 +16,35 @@ DBCFile::DBCFile(const QString &dbc_file_name) { } } -DBCFile::DBCFile(const QString &name, const QString &content) : name_(name), filename("") { - parse(content); +DBCFile::DBCFile(const std::string &name, const std::string &content) : name_(name), filename("") { + parse(QString::fromStdString(content)); } bool DBCFile::save() { - assert(!filename.isEmpty()); + assert(!filename.empty()); return writeContents(filename); } -bool DBCFile::saveAs(const QString &new_filename) { +bool DBCFile::saveAs(const std::string &new_filename) { filename = new_filename; return save(); } -bool DBCFile::writeContents(const QString &fn) { - QFile file(fn); +bool DBCFile::writeContents(const std::string &fn) { + QFile file(QString::fromStdString(fn)); if (file.open(QIODevice::WriteOnly)) { - return file.write(generateDBC().toUtf8()) >= 0; + std::string content = generateDBC(); + return file.write(content.c_str(), content.size()) >= 0; } return false; } -void DBCFile::updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment) { +void DBCFile::updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment) { auto &m = msgs[id.address]; m.address = id.address; m.name = name; m.size = size; - m.transmitter = node.isEmpty() ? DEFAULT_NODE_NAME : node; + m.transmitter = node.empty() ? DEFAULT_NODE_NAME : node; m.comment = comment; } @@ -51,12 +53,12 @@ cabana::Msg *DBCFile::msg(uint32_t address) { return it != msgs.end() ? &it->second : nullptr; } -cabana::Msg *DBCFile::msg(const QString &name) { +cabana::Msg *DBCFile::msg(const std::string &name) { auto it = std::find_if(msgs.begin(), msgs.end(), [&name](auto &m) { return m.second.name == name; }); return it != msgs.end() ? &(it->second) : nullptr; } -cabana::Signal *DBCFile::signal(uint32_t address, const QString &name) { +cabana::Signal *DBCFile::signal(uint32_t address, const std::string &name) { auto m = msg(address); return m ? (cabana::Signal *)m->sig(name) : nullptr; } @@ -93,13 +95,13 @@ void DBCFile::parse(const QString &content) { seen = false; } } catch (std::exception &e) { - throw std::runtime_error(QString("[%1:%2]%3: %4").arg(filename).arg(line_num).arg(e.what()).arg(line).toStdString()); + throw std::runtime_error(QString("[%1:%2]%3: %4").arg(QString::fromStdString(filename)).arg(line_num).arg(e.what()).arg(line).toStdString()); } if (seen) { seen_first = true; } else if (!seen_first) { - header += raw_line + "\n"; + header += raw_line.toStdString() + "\n"; } } @@ -122,9 +124,9 @@ cabana::Msg *DBCFile::parseBO(const QString &line) { // Create a new message object cabana::Msg *msg = &msgs[address]; msg->address = address; - msg->name = match.captured("name"); + msg->name = match.captured("name").toStdString(); msg->size = match.captured("size").toULong(); - msg->transmitter = match.captured("transmitter").trimmed(); + msg->transmitter = match.captured("transmitter").trimmed().toStdString(); return msg; } @@ -141,7 +143,7 @@ void DBCFile::parseCM_BO(const QString &line, const QString &content, const QStr throw std::runtime_error("Invalid message comment format"); if (auto m = (cabana::Msg *)msg(match.captured("address").toUInt())) - m->comment = match.captured("comment").trimmed().replace("\\\"", "\""); + m->comment = match.captured("comment").trimmed().replace("\\\"", "\"").toStdString(); } void DBCFile::parseSG(const QString &line, cabana::Msg *current_msg, int &multiplexor_cnt) { @@ -160,7 +162,7 @@ void DBCFile::parseSG(const QString &line, cabana::Msg *current_msg, int &multip if (!match.hasMatch()) throw std::runtime_error("Invalid SG_ line format"); - QString name = match.captured(1); + std::string name = match.captured(1).toStdString(); if (current_msg->sig(name) != nullptr) throw std::runtime_error("Duplicate signal name"); @@ -188,8 +190,8 @@ void DBCFile::parseSG(const QString &line, cabana::Msg *current_msg, int &multip s.offset = match.captured(offset + 7).toDouble(); s.min = match.captured(8 + offset).toDouble(); s.max = match.captured(9 + offset).toDouble(); - s.unit = match.captured(10 + offset); - s.receiver_name = match.captured(11 + offset).trimmed(); + s.unit = match.captured(10 + offset).toStdString(); + s.receiver_name = match.captured(11 + offset).trimmed().toStdString(); current_msg->sigs.push_back(new cabana::Signal(s)); } @@ -205,8 +207,8 @@ void DBCFile::parseCM_SG(const QString &line, const QString &content, const QStr if (!match.hasMatch()) throw std::runtime_error("Invalid CM_ SG_ line format"); - if (auto s = signal(match.captured(1).toUInt(), match.captured(2))) { - s->comment = match.captured(3).trimmed().replace("\\\"", "\""); + if (auto s = signal(match.captured(1).toUInt(), match.captured(2).toStdString())) { + s->comment = match.captured(3).trimmed().replace("\\\"", "\"").toStdString(); } } @@ -217,55 +219,60 @@ void DBCFile::parseVAL(const QString &line) { if (!match.hasMatch()) throw std::runtime_error("invalid VAL_ line format"); - if (auto s = signal(match.captured(1).toUInt(), match.captured(2))) { + if (auto s = signal(match.captured(1).toUInt(), match.captured(2).toStdString())) { QStringList desc_list = match.captured(3).trimmed().split('"'); for (int i = 0; i < desc_list.size(); i += 2) { auto val = desc_list[i].trimmed(); if (!val.isEmpty() && (i + 1) < desc_list.size()) { auto desc = desc_list[i + 1].trimmed(); - s->val_desc.push_back({val.toDouble(), desc}); + s->val_desc.push_back({val.toDouble(), desc.toStdString()}); } } } } -QString DBCFile::generateDBC() { - QString dbc_string, comment, val_desc; +std::string DBCFile::generateDBC() { + std::string dbc_string, comment, val_desc; for (const auto &[address, m] : msgs) { - const QString transmitter = m.transmitter.isEmpty() ? DEFAULT_NODE_NAME : m.transmitter; - dbc_string += QString("BO_ %1 %2: %3 %4\n").arg(address).arg(m.name).arg(m.size).arg(transmitter); - if (!m.comment.isEmpty()) { - comment += QString("CM_ BO_ %1 \"%2\";\n").arg(address).arg(QString(m.comment).replace("\"", "\\\"")); + const std::string &transmitter = m.transmitter.empty() ? DEFAULT_NODE_NAME : m.transmitter; + dbc_string += "BO_ " + std::to_string(address) + " " + m.name + ": " + std::to_string(m.size) + " " + transmitter + "\n"; + if (!m.comment.empty()) { + std::string escaped_comment = m.comment; + // Replace " with \" + for (size_t pos = 0; (pos = escaped_comment.find('"', pos)) != std::string::npos; pos += 2) + escaped_comment.replace(pos, 1, "\\\""); + comment += "CM_ BO_ " + std::to_string(address) + " \"" + escaped_comment + "\";\n"; } for (auto sig : m.getSignals()) { - QString multiplexer_indicator; + std::string multiplexer_indicator; if (sig->type == cabana::Signal::Type::Multiplexor) { multiplexer_indicator = "M "; } else if (sig->type == cabana::Signal::Type::Multiplexed) { - multiplexer_indicator = QString("m%1 ").arg(sig->multiplex_value); + multiplexer_indicator = "m" + std::to_string(sig->multiplex_value) + " "; } - dbc_string += QString(" SG_ %1 %2: %3|%4@%5%6 (%7,%8) [%9|%10] \"%11\" %12\n") - .arg(sig->name) - .arg(multiplexer_indicator) - .arg(sig->start_bit) - .arg(sig->size) - .arg(sig->is_little_endian ? '1' : '0') - .arg(sig->is_signed ? '-' : '+') - .arg(doubleToString(sig->factor)) - .arg(doubleToString(sig->offset)) - .arg(doubleToString(sig->min)) - .arg(doubleToString(sig->max)) - .arg(sig->unit) - .arg(sig->receiver_name.isEmpty() ? DEFAULT_NODE_NAME : sig->receiver_name); - if (!sig->comment.isEmpty()) { - comment += QString("CM_ SG_ %1 %2 \"%3\";\n").arg(address).arg(sig->name).arg(QString(sig->comment).replace("\"", "\\\"")); + const std::string &recv = sig->receiver_name.empty() ? DEFAULT_NODE_NAME : sig->receiver_name; + dbc_string += " SG_ " + sig->name + " " + multiplexer_indicator + ": " + + std::to_string(sig->start_bit) + "|" + std::to_string(sig->size) + "@" + + std::string(1, sig->is_little_endian ? '1' : '0') + + std::string(1, sig->is_signed ? '-' : '+') + + " (" + doubleToString(sig->factor) + "," + doubleToString(sig->offset) + ")" + + " [" + doubleToString(sig->min) + "|" + doubleToString(sig->max) + "]" + + " \"" + sig->unit + "\" " + recv + "\n"; + if (!sig->comment.empty()) { + std::string escaped_comment = sig->comment; + for (size_t pos = 0; (pos = escaped_comment.find('"', pos)) != std::string::npos; pos += 2) + escaped_comment.replace(pos, 1, "\\\""); + comment += "CM_ SG_ " + std::to_string(address) + " " + sig->name + " \"" + escaped_comment + "\";\n"; } if (!sig->val_desc.empty()) { - QStringList text; + std::string text; for (auto &[val, desc] : sig->val_desc) { - text << QString("%1 \"%2\"").arg(val).arg(desc); + if (!text.empty()) text += " "; + char val_buf[64]; + snprintf(val_buf, sizeof(val_buf), "%g", val); + text += std::string(val_buf) + " \"" + desc + "\""; } - val_desc += QString("VAL_ %1 %2 %3;\n").arg(address).arg(sig->name).arg(text.join(" ")); + val_desc += "VAL_ " + std::to_string(address) + " " + sig->name + " " + text + ";\n"; } } dbc_string += "\n"; diff --git a/tools/cabana/dbc/dbcfile.h b/tools/cabana/dbc/dbcfile.h index bd267898f9..decb566abd 100644 --- a/tools/cabana/dbc/dbcfile.h +++ b/tools/cabana/dbc/dbcfile.h @@ -1,34 +1,35 @@ #pragma once #include +#include #include #include "tools/cabana/dbc/dbc.h" class DBCFile { public: - DBCFile(const QString &dbc_file_name); - DBCFile(const QString &name, const QString &content); + DBCFile(const std::string &dbc_file_name); + DBCFile(const std::string &name, const std::string &content); ~DBCFile() {} bool save(); - bool saveAs(const QString &new_filename); - bool writeContents(const QString &fn); - QString generateDBC(); + bool saveAs(const std::string &new_filename); + bool writeContents(const std::string &fn); + std::string generateDBC(); - void updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment); + void updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment); inline void removeMsg(const MessageId &id) { msgs.erase(id.address); } inline const std::map &getMessages() const { return msgs; } cabana::Msg *msg(uint32_t address); - cabana::Msg *msg(const QString &name); + cabana::Msg *msg(const std::string &name); inline cabana::Msg *msg(const MessageId &id) { return msg(id.address); } - cabana::Signal *signal(uint32_t address, const QString &name); + cabana::Signal *signal(uint32_t address, const std::string &name); - inline QString name() const { return name_.isEmpty() ? "untitled" : name_; } - inline bool isEmpty() const { return msgs.empty() && name_.isEmpty(); } + inline std::string name() const { return name_.empty() ? "untitled" : name_; } + inline bool isEmpty() const { return msgs.empty() && name_.empty(); } - QString filename; + std::string filename; private: void parse(const QString &content); @@ -38,7 +39,7 @@ private: void parseCM_SG(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream); void parseVAL(const QString &line); - QString header; + std::string header; std::map msgs; - QString name_; + std::string name_; }; diff --git a/tools/cabana/dbc/dbcmanager.cc b/tools/cabana/dbc/dbcmanager.cc index 8e98d95322..2236a93da1 100644 --- a/tools/cabana/dbc/dbcmanager.cc +++ b/tools/cabana/dbc/dbcmanager.cc @@ -1,10 +1,9 @@ #include "tools/cabana/dbc/dbcmanager.h" -#include #include -#include +#include -bool DBCManager::open(const SourceSet &sources, const QString &dbc_file_name, QString *error) { +bool DBCManager::open(const SourceSet &sources, const std::string &dbc_file_name, QString *error) { try { auto it = std::find_if(dbc_files.begin(), dbc_files.end(), [&](auto &f) { return f.second && f.second->filename == dbc_file_name; }); @@ -21,7 +20,7 @@ bool DBCManager::open(const SourceSet &sources, const QString &dbc_file_name, QS return true; } -bool DBCManager::open(const SourceSet &sources, const QString &name, const QString &content, QString *error) { +bool DBCManager::open(const SourceSet &sources, const std::string &name, const std::string &content, QString *error) { try { auto file = std::make_shared(name, content); for (auto s : sources) { @@ -64,7 +63,7 @@ void DBCManager::addSignal(const MessageId &id, const cabana::Signal &sig) { } } -void DBCManager::updateSignal(const MessageId &id, const QString &sig_name, const cabana::Signal &sig) { +void DBCManager::updateSignal(const MessageId &id, const std::string &sig_name, const cabana::Signal &sig) { if (auto m = msg(id)) { if (auto s = m->updateSignal(sig_name, sig)) { emit signalUpdated(s); @@ -73,7 +72,7 @@ void DBCManager::updateSignal(const MessageId &id, const QString &sig_name, cons } } -void DBCManager::removeSignal(const MessageId &id, const QString &sig_name) { +void DBCManager::removeSignal(const MessageId &id, const std::string &sig_name) { if (auto m = msg(id)) { if (auto s = m->sig(sig_name)) { emit signalRemoved(s); @@ -83,7 +82,7 @@ void DBCManager::removeSignal(const MessageId &id, const QString &sig_name) { } } -void DBCManager::updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment) { +void DBCManager::updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment) { auto dbc_file = findDBCFile(id); assert(dbc_file); // This should be impossible dbc_file->updateMsg(id, name, size, node, comment); @@ -98,11 +97,13 @@ void DBCManager::removeMsg(const MessageId &id) { emit maskUpdated(); } -QString DBCManager::newMsgName(const MessageId &id) { - return QString("NEW_MSG_") + QString::number(id.address, 16).toUpper(); +std::string DBCManager::newMsgName(const MessageId &id) { + char buf[64]; + snprintf(buf, sizeof(buf), "NEW_MSG_%X", id.address); + return buf; } -QString DBCManager::newSignalName(const MessageId &id) { +std::string DBCManager::newSignalName(const MessageId &id) { auto m = msg(id); return m ? m->newSignalName() : ""; } @@ -118,14 +119,14 @@ cabana::Msg *DBCManager::msg(const MessageId &id) { return dbc_file ? dbc_file->msg(id) : nullptr; } -cabana::Msg *DBCManager::msg(uint8_t source, const QString &name) { +cabana::Msg *DBCManager::msg(uint8_t source, const std::string &name) { auto dbc_file = findDBCFile(source); return dbc_file ? dbc_file->msg(name) : nullptr; } -QStringList DBCManager::signalNames() { +std::vector DBCManager::signalNames() { // Used for autocompletion - QSet names; + std::set names; for (auto &f : allDBCFiles()) { for (auto &[_, m] : f->getMessages()) { for (auto sig : m.getSignals()) { @@ -133,8 +134,8 @@ QStringList DBCManager::signalNames() { } } } - QStringList ret = names.values(); - ret.sort(); + std::vector ret(names.begin(), names.end()); + std::sort(ret.begin(), ret.end()); return ret; } @@ -165,11 +166,13 @@ const SourceSet DBCManager::sources(const DBCFile *dbc_file) const { return sources; } -QString toString(const SourceSet &ss) { - return std::accumulate(ss.cbegin(), ss.cend(), QString(), [](QString str, int source) { - if (!str.isEmpty()) str += ", "; - return str + (source == -1 ? QStringLiteral("all") : QString::number(source)); - }); +std::string toString(const SourceSet &ss) { + std::string result; + for (int source : ss) { + if (!result.empty()) result += ", "; + result += (source == -1) ? "all" : std::to_string(source); + } + return result; } DBCManager *dbc() { diff --git a/tools/cabana/dbc/dbcmanager.h b/tools/cabana/dbc/dbcmanager.h index 5f183752d2..4a122073ea 100644 --- a/tools/cabana/dbc/dbcmanager.h +++ b/tools/cabana/dbc/dbcmanager.h @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include "tools/cabana/dbc/dbcfile.h" @@ -18,27 +20,27 @@ class DBCManager : public QObject { public: DBCManager(QObject *parent) : QObject(parent) {} ~DBCManager() {} - bool open(const SourceSet &sources, const QString &dbc_file_name, QString *error = nullptr); - bool open(const SourceSet &sources, const QString &name, const QString &content, QString *error = nullptr); + bool open(const SourceSet &sources, const std::string &dbc_file_name, QString *error = nullptr); + bool open(const SourceSet &sources, const std::string &name, const std::string &content, QString *error = nullptr); void close(const SourceSet &sources); void close(DBCFile *dbc_file); void closeAll(); void addSignal(const MessageId &id, const cabana::Signal &sig); - void updateSignal(const MessageId &id, const QString &sig_name, const cabana::Signal &sig); - void removeSignal(const MessageId &id, const QString &sig_name); + void updateSignal(const MessageId &id, const std::string &sig_name, const cabana::Signal &sig); + void removeSignal(const MessageId &id, const std::string &sig_name); - void updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment); + void updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment); void removeMsg(const MessageId &id); - QString newMsgName(const MessageId &id); - QString newSignalName(const MessageId &id); + std::string newMsgName(const MessageId &id); + std::string newSignalName(const MessageId &id); const std::map &getMessages(uint8_t source); cabana::Msg *msg(const MessageId &id); - cabana::Msg* msg(uint8_t source, const QString &name); + cabana::Msg* msg(uint8_t source, const std::string &name); - QStringList signalNames(); + std::vector signalNames(); inline int dbcCount() { return allDBCFiles().size(); } int nonEmptyDBCCount(); @@ -62,8 +64,8 @@ private: DBCManager *dbc(); -QString toString(const SourceSet &ss); -inline QString msgName(const MessageId &id) { +std::string toString(const SourceSet &ss); +inline std::string msgName(const MessageId &id) { auto msg = dbc()->msg(id); return msg ? msg->name : UNTITLED; } diff --git a/tools/cabana/detailwidget.cc b/tools/cabana/detailwidget.cc index 35492c8efa..148b059e5b 100644 --- a/tools/cabana/detailwidget.cc +++ b/tools/cabana/detailwidget.cc @@ -124,9 +124,9 @@ int DetailWidget::findOrAddTab(const MessageId& message_id) { if (tabbar->tabData(index).value() == message_id) break; } if (index == -1) { - index = tabbar->addTab(message_id.toString()); + index = tabbar->addTab(QString::fromStdString(message_id.toString())); tabbar->setTabData(index, QVariant::fromValue(message_id)); - tabbar->setTabToolTip(index, msgName(message_id)); + tabbar->setTabToolTip(index, QString::fromStdString(msgName(message_id))); } return index; } @@ -151,21 +151,21 @@ std::pair DetailWidget::serializeMessageIds() const { QStringList msgs; for (int i = 0; i < tabbar->count(); ++i) { MessageId id = tabbar->tabData(i).value(); - msgs.append(id.toString()); + msgs.append(QString::fromStdString(id.toString())); } - return std::make_pair(msg_id.toString(), msgs); + return std::make_pair(QString::fromStdString(msg_id.toString()), msgs); } void DetailWidget::restoreTabs(const QString active_msg_id, const QStringList& msg_ids) { tabbar->blockSignals(true); for (const auto& str_id : msg_ids) { - MessageId id = MessageId::fromString(str_id); + MessageId id = MessageId::fromString(str_id.toStdString()); if (dbc()->msg(id) != nullptr) findOrAddTab(id); } tabbar->blockSignals(false); - auto active_id = MessageId::fromString(active_msg_id); + auto active_id = MessageId::fromString(active_msg_id.toStdString()); if (dbc()->msg(active_id) != nullptr) setMessage(active_id); } @@ -180,10 +180,10 @@ void DetailWidget::refresh() { warnings.push_back(tr("Message size (%1) is incorrect.").arg(msg->size)); } for (auto s : binary_view->getOverlappingSignals()) { - warnings.push_back(tr("%1 has overlapping bits.").arg(s->name)); + warnings.push_back(tr("%1 has overlapping bits.").arg(QString::fromStdString(s->name))); } } - QString msg_name = msg ? QString("%1 (%2)").arg(msg->name, msg->transmitter) : msgName(msg_id); + QString msg_name = msg ? QString("%1 (%2)").arg(QString::fromStdString(msg->name), QString::fromStdString(msg->transmitter)) : QString::fromStdString(msgName(msg_id)); name_label->setText(msg_name); name_label->setToolTip(msg_name); action_remove_msg->setEnabled(msg != nullptr); @@ -208,10 +208,10 @@ void DetailWidget::updateState(const std::set *msgs) { void DetailWidget::editMsg() { auto msg = dbc()->msg(msg_id); int size = msg ? msg->size : can->lastMessage(msg_id).dat.size(); - EditMessageDialog dlg(msg_id, msgName(msg_id), size, this); + EditMessageDialog dlg(msg_id, QString::fromStdString(msgName(msg_id)), size, this); if (dlg.exec()) { - UndoStack::push(new EditMsgCommand(msg_id, dlg.name_edit->text().trimmed(), dlg.size_spin->value(), - dlg.node->text().trimmed(), dlg.comment_edit->toPlainText().trimmed())); + UndoStack::push(new EditMsgCommand(msg_id, dlg.name_edit->text().trimmed().toStdString(), dlg.size_spin->value(), + dlg.node->text().trimmed().toStdString(), dlg.comment_edit->toPlainText().trimmed().toStdString())); } } @@ -223,7 +223,7 @@ void DetailWidget::removeMsg() { EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const QString &title, int size, QWidget *parent) : original_name(title), msg_id(msg_id), QDialog(parent) { - setWindowTitle(tr("Edit message: %1").arg(msg_id.toString())); + setWindowTitle(tr("Edit message: %1").arg(QString::fromStdString(msg_id.toString()))); QFormLayout *form_layout = new QFormLayout(this); form_layout->addRow("", error_label = new QLabel); @@ -241,8 +241,8 @@ EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const QString &tit form_layout->addRow(btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel)); if (auto msg = dbc()->msg(msg_id)) { - node->setText(msg->transmitter); - comment_edit->setText(msg->comment); + node->setText(QString::fromStdString(msg->transmitter)); + comment_edit->setText(QString::fromStdString(msg->comment)); } validateName(name_edit->text()); setFixedWidth(parent->width() * 0.9); @@ -252,10 +252,10 @@ EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const QString &tit } void EditMessageDialog::validateName(const QString &text) { - bool valid = text.compare(UNTITLED, Qt::CaseInsensitive) != 0; + bool valid = text.compare(QString::fromStdString(UNTITLED), Qt::CaseInsensitive) != 0; error_label->setVisible(false); if (!text.isEmpty() && valid && text != original_name) { - valid = dbc()->msg(msg_id.source, text) == nullptr; + valid = dbc()->msg(msg_id.source, text.toStdString()) == nullptr; if (!valid) { error_label->setText(tr("Name already exists")); error_label->setVisible(true); diff --git a/tools/cabana/historylog.cc b/tools/cabana/historylog.cc index 3dbdf5a7cd..fb79ff9cea 100644 --- a/tools/cabana/historylog.cc +++ b/tools/cabana/historylog.cc @@ -14,7 +14,7 @@ QVariant HistoryLogModel::data(const QModelIndex &index, int role) const { const int col = index.column(); if (role == Qt::DisplayRole) { if (col == 0) return QString::number(can->toSeconds(m.mono_time), 'f', 3); - if (!isHexMode()) return sigs[col - 1]->formatValue(m.sig_values[col - 1], false); + if (!isHexMode()) return QString::fromStdString(sigs[col - 1]->formatValue(m.sig_values[col - 1], false)); } else if (role == Qt::TextAlignmentRole) { return (uint32_t)(Qt::AlignRight | Qt::AlignVCenter); } @@ -49,8 +49,8 @@ QVariant HistoryLogModel::headerData(int section, Qt::Orientation orientation, i if (section == 0) return "Time"; if (isHexMode()) return "Data"; - QString name = sigs[section - 1]->name; - QString unit = sigs[section - 1]->unit; + QString name = QString::fromStdString(sigs[section - 1]->name); + QString unit = QString::fromStdString(sigs[section - 1]->unit); return unit.isEmpty() ? name : QString("%1 (%2)").arg(name, unit); } else if (role == Qt::BackgroundRole && section > 0 && !isHexMode()) { // Alpha-blend the signal color with the background to ensure contrast @@ -216,7 +216,7 @@ LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) { void LogsWidget::modelReset() { signals_cb->clear(); for (auto s : model->sigs) { - signals_cb->addItem(s->name); + signals_cb->addItem(QString::fromStdString(s->name)); } export_btn->setEnabled(false); value_edit->clear(); @@ -238,8 +238,8 @@ void LogsWidget::filterChanged() { } void LogsWidget::exportToCSV() { - QString dir = QString("%1/%2_%3.csv").arg(settings.last_dir).arg(can->routeName()).arg(msgName(model->msg_id)); - QString fn = QFileDialog::getSaveFileName(this, QString("Export %1 to CSV file").arg(msgName(model->msg_id)), + QString dir = QString("%1/%2_%3.csv").arg(settings.last_dir).arg(QString::fromStdString(can->routeName())).arg(QString::fromStdString(msgName(model->msg_id))); + QString fn = QFileDialog::getSaveFileName(this, QString("Export %1 to CSV file").arg(QString::fromStdString(msgName(model->msg_id))), dir, tr("csv (*.csv)")); if (!fn.isEmpty()) { model->isHexMode() ? utils::exportToCSV(fn, model->msg_id) diff --git a/tools/cabana/mainwin.cc b/tools/cabana/mainwin.cc index a7040f891e..39fb979c79 100644 --- a/tools/cabana/mainwin.cc +++ b/tools/cabana/mainwin.cc @@ -234,7 +234,7 @@ void MainWindow::DBCFileChanged() { QStringList title; for (auto f : dbc()->allDBCFiles()) { - title.push_back(tr("(%1) %2").arg(toString(dbc()->sources(f)), f->name())); + title.push_back(tr("(%1) %2").arg(QString::fromStdString(toString(dbc()->sources(f))), QString::fromStdString(f->name()))); } setWindowFilePath(title.join(" | ")); @@ -259,7 +259,7 @@ void MainWindow::closeStream() { } void MainWindow::exportToCSV() { - QString dir = QString("%1/%2.csv").arg(settings.last_dir).arg(can->routeName()); + QString dir = QString("%1/%2.csv").arg(settings.last_dir).arg(QString::fromStdString(can->routeName())); QString fn = QFileDialog::getSaveFileName(this, "Export stream to CSV file", dir, tr("csv (*.csv)")); if (!fn.isEmpty()) { utils::exportToCSV(fn); @@ -268,7 +268,7 @@ void MainWindow::exportToCSV() { void MainWindow::newFile(SourceSet s) { closeFile(s); - dbc()->open(s, "", ""); + dbc()->open(s, std::string(""), std::string("")); } void MainWindow::openFile(SourceSet s) { @@ -284,7 +284,7 @@ void MainWindow::loadFile(const QString &fn, SourceSet s) { closeFile(s); QString error; - if (dbc()->open(s, fn, &error)) { + if (dbc()->open(s, fn.toStdString(), &error)) { updateRecentFiles(fn); statusBar()->showMessage(tr("DBC File %1 loaded").arg(fn), 2000); } else { @@ -304,7 +304,7 @@ void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { QString dbc_str = QGuiApplication::clipboard()->text(); QString error; - bool ret = dbc()->open(s, "", dbc_str, &error); + bool ret = dbc()->open(s, std::string(""), dbc_str.toStdString(), &error); if (ret && dbc()->nonEmptyDBCCount() > 0) { QMessageBox::information(this, tr("Load From Clipboard"), tr("DBC Successfully Loaded!")); } else { @@ -333,7 +333,7 @@ void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { can->start(); loadFile(dbc_file); - statusBar()->showMessage(tr("Stream [%1] started").arg(can->routeName()), 2000); + statusBar()->showMessage(tr("Stream [%1] started").arg(QString::fromStdString(can->routeName())), 2000); bool has_stream = dynamic_cast(can) == nullptr; close_stream_act->setEnabled(has_stream); @@ -341,7 +341,7 @@ void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { tools_menu->setEnabled(has_stream); createDockWidgets(); - video_dock->setWindowTitle(can->routeName()); + video_dock->setWindowTitle(QString::fromStdString(can->routeName())); if (can->liveStreaming() || video_splitter->sizes()[0] == 0) { // display video at minimum size. video_splitter->setSizes({1, 1}); @@ -368,9 +368,9 @@ void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { } void MainWindow::eventsMerged() { - if (!can->liveStreaming() && std::exchange(car_fingerprint, can->carFingerprint()) != car_fingerprint) { + if (!can->liveStreaming() && std::exchange(car_fingerprint, QString::fromStdString(can->carFingerprint())) != car_fingerprint) { video_dock->setWindowTitle(tr("ROUTE: %1 FINGERPRINT: %2") - .arg(can->routeName()) + .arg(QString::fromStdString(can->routeName())) .arg(car_fingerprint.isEmpty() ? tr("Unknown Car") : car_fingerprint)); // Don't overwrite already loaded DBC if (!dbc()->nonEmptyDBCCount() && fingerprint_to_dbc.object().contains(car_fingerprint)) { @@ -416,7 +416,7 @@ void MainWindow::closeFile(DBCFile *dbc_file) { void MainWindow::saveFile(DBCFile *dbc_file) { assert(dbc_file != nullptr); - if (!dbc_file->filename.isEmpty()) { + if (!dbc_file->filename.empty()) { dbc_file->save(); UndoStack::instance()->setClean(); statusBar()->showMessage(tr("File saved"), 2000); @@ -426,10 +426,10 @@ void MainWindow::saveFile(DBCFile *dbc_file) { } void MainWindow::saveFileAs(DBCFile *dbc_file) { - QString title = tr("Save File (bus: %1)").arg(toString(dbc()->sources(dbc_file))); + QString title = tr("Save File (bus: %1)").arg(QString::fromStdString(toString(dbc()->sources(dbc_file)))); QString fn = QFileDialog::getSaveFileName(this, title, QDir::cleanPath(settings.last_dir + "/untitled.dbc"), tr("DBC (*.dbc)")); if (!fn.isEmpty()) { - dbc_file->saveAs(fn); + dbc_file->saveAs(fn.toStdString()); UndoStack::instance()->setClean(); statusBar()->showMessage(tr("File saved as %1").arg(fn), 2000); updateRecentFiles(fn); @@ -446,7 +446,7 @@ void MainWindow::saveToClipboard() { void MainWindow::saveFileToClipboard(DBCFile *dbc_file) { assert(dbc_file != nullptr); - QGuiApplication::clipboard()->setText(dbc_file->generateDBC()); + QGuiApplication::clipboard()->setText(QString::fromStdString(dbc_file->generateDBC())); QMessageBox::information(this, tr("Copy To Clipboard"), tr("DBC Successfully copied!")); } @@ -467,14 +467,14 @@ void MainWindow::updateLoadSaveMenus() { auto dbc_file = dbc()->findDBCFile(source); if (dbc_file) { bus_menu->addSeparator(); - bus_menu->addAction(dbc_file->name() + " (" + toString(dbc()->sources(dbc_file)) + ")")->setEnabled(false); + bus_menu->addAction(QString::fromStdString(dbc_file->name()) + " (" + QString::fromStdString(toString(dbc()->sources(dbc_file))) + ")")->setEnabled(false); bus_menu->addAction(tr("Save..."), [=]() { saveFile(dbc_file); }); bus_menu->addAction(tr("Save As..."), [=]() { saveFileAs(dbc_file); }); bus_menu->addAction(tr("Copy to Clipboard..."), [=]() { saveFileToClipboard(dbc_file); }); bus_menu->addAction(tr("Remove from this bus..."), [=]() { closeFile(ss); }); bus_menu->addAction(tr("Remove from all buses..."), [=]() { closeFile(dbc_file); }); } - bus_menu->setTitle(tr("Bus %1 (%2)").arg(source).arg(dbc_file ? dbc_file->name() : "No DBCs loaded")); + bus_menu->setTitle(tr("Bus %1 (%2)").arg(source).arg(dbc_file ? QString::fromStdString(dbc_file->name()) : "No DBCs loaded")); manage_dbcs_menu->addMenu(bus_menu); } @@ -627,7 +627,7 @@ void MainWindow::saveSessionState() { settings.active_charts.clear(); for (auto &f : dbc()->allDBCFiles()) - if (!f->isEmpty()) { settings.recent_dbc_file = f->filename; break; } + if (!f->isEmpty()) { settings.recent_dbc_file = QString::fromStdString(f->filename); break; } if (auto *detail = center_widget->getDetailWidget()) { auto [active_id, ids] = detail->serializeMessageIds(); @@ -643,7 +643,7 @@ void MainWindow::restoreSessionState() { QString dbc_file; for (auto& f : dbc()->allDBCFiles()) - if (!f->isEmpty()) { dbc_file = f->filename; break; } + if (!f->isEmpty()) { dbc_file = QString::fromStdString(f->filename); break; } if (dbc_file != settings.recent_dbc_file) return; if (!settings.selected_msg_ids.isEmpty()) diff --git a/tools/cabana/messageswidget.cc b/tools/cabana/messageswidget.cc index ed9aeaf311..ec8c82dd98 100644 --- a/tools/cabana/messageswidget.cc +++ b/tools/cabana/messageswidget.cc @@ -205,7 +205,7 @@ QVariant MessageListModel::data(const QModelIndex &index, int role) const { } else if (role == Qt::ToolTipRole && index.column() == Column::NAME) { auto msg = dbc()->msg(item.id); auto tooltip = item.name; - if (msg && !msg->comment.isEmpty()) tooltip += "
" + msg->comment + ""; + if (msg && !msg->comment.empty()) tooltip += "
" + QString::fromStdString(msg->comment) + ""; return tooltip; } return {}; @@ -277,7 +277,7 @@ bool MessageListModel::match(const MessageListModel::Item &item) { if (!match) { const auto m = dbc()->msg(item.id); match = m && std::any_of(m->sigs.cbegin(), m->sigs.cend(), - [&txt](const auto &s) { return s->name.contains(txt, Qt::CaseInsensitive); }); + [&txt](const auto &s) { return QString::fromStdString(s->name).contains(txt, Qt::CaseInsensitive); }); } break; } @@ -323,8 +323,8 @@ bool MessageListModel::filterAndSort() { if (show_inactive_messages || can->isMessageActive(id)) { auto msg = dbc()->msg(id); Item item = {.id = id, - .name = msg ? msg->name : UNTITLED, - .node = msg ? msg->transmitter : QString()}; + .name = msg ? QString::fromStdString(msg->name) : QString::fromStdString(UNTITLED), + .node = msg ? QString::fromStdString(msg->transmitter) : QString()}; if (match(item)) items.emplace_back(item); } diff --git a/tools/cabana/panda.cc b/tools/cabana/panda.cc index 0612d67746..cf5354a507 100644 --- a/tools/cabana/panda.cc +++ b/tools/cabana/panda.cc @@ -106,8 +106,8 @@ cereal::PandaState::PandaType Panda::get_hw_type() { -void Panda::send_heartbeat(bool engaged) { - control_write(0xf3, engaged, 0); +void Panda::send_heartbeat(bool engaged, bool engaged_mads) { + control_write(0xf3, engaged, engaged_mads); } void Panda::set_can_speed_kbps(uint16_t bus, uint16_t speed) { diff --git a/tools/cabana/panda.h b/tools/cabana/panda.h index d318c33f4d..8b861a2476 100644 --- a/tools/cabana/panda.h +++ b/tools/cabana/panda.h @@ -64,7 +64,7 @@ public: // Panda functionality cereal::PandaState::PandaType get_hw_type(); void set_safety_model(cereal::CarParams::SafetyModel safety_model, uint16_t safety_param=0U); - void send_heartbeat(bool engaged); + void send_heartbeat(bool engaged, bool engaged_mads = false); void set_can_speed_kbps(uint16_t bus, uint16_t speed); void set_data_speed_kbps(uint16_t bus, uint16_t speed); bool can_receive(std::vector& out_vec); diff --git a/tools/cabana/signalview.cc b/tools/cabana/signalview.cc index a9ceacd806..15baabe512 100644 --- a/tools/cabana/signalview.cc +++ b/tools/cabana/signalview.cc @@ -1,6 +1,7 @@ #include "tools/cabana/signalview.h" #include +#include #include #include @@ -11,7 +12,6 @@ #include #include #include -#include #include #include "tools/cabana/commands.h" @@ -34,8 +34,8 @@ SignalModel::SignalModel(QObject *parent) : root(new Item), QAbstractItemModel(p } void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) { - Item *parent_item = new Item{.type = Item::Sig, .parent = root_item, .sig = sig, .title = sig->name}; - root_item->children.insert(pos, parent_item); + Item *parent_item = new Item{.type = Item::Sig, .parent = root_item, .sig = sig, .title = QString::fromStdString(sig->name)}; + root_item->children.insert(root_item->children.begin() + pos, parent_item); QString titles[]{"Name", "Size", "Receiver Nodes", "Little Endian", "Signed", "Offset", "Factor", "Type", "Multiplex Value", "Extra Info", "Unit", "Comment", "Minimum Value", "Maximum Value", "Value Table"}; for (int i = 0; i < std::size(titles); ++i) { @@ -63,7 +63,7 @@ void SignalModel::refresh() { root.reset(new SignalModel::Item); if (auto msg = dbc()->msg(msg_id)) { for (auto s : msg->getSignals()) { - if (filter_str.isEmpty() || s->name.contains(filter_str, Qt::CaseInsensitive)) { + if (filter_str.isEmpty() || QString::fromStdString(s->name).contains(filter_str, Qt::CaseInsensitive)) { insertItem(root.get(), root->children.size(), s); } } @@ -124,25 +124,25 @@ QVariant SignalModel::data(const QModelIndex &index, int role) const { const Item *item = getItem(index); if (role == Qt::DisplayRole || role == Qt::EditRole) { if (index.column() == 0) { - return item->type == Item::Sig ? item->sig->name : item->title; + return item->type == Item::Sig ? QString::fromStdString(item->sig->name) : item->title; } else { switch (item->type) { case Item::Sig: return item->sig_val; - case Item::Name: return item->sig->name; + case Item::Name: return QString::fromStdString(item->sig->name); case Item::Size: return item->sig->size; - case Item::Node: return item->sig->receiver_name; + case Item::Node: return QString::fromStdString(item->sig->receiver_name); case Item::SignalType: return signalTypeToString(item->sig->type); case Item::MultiplexValue: return item->sig->multiplex_value; - case Item::Offset: return doubleToString(item->sig->offset); - case Item::Factor: return doubleToString(item->sig->factor); - case Item::Unit: return item->sig->unit; - case Item::Comment: return item->sig->comment; - case Item::Min: return doubleToString(item->sig->min); - case Item::Max: return doubleToString(item->sig->max); + case Item::Offset: return QString::fromStdString(doubleToString(item->sig->offset)); + case Item::Factor: return QString::fromStdString(doubleToString(item->sig->factor)); + case Item::Unit: return QString::fromStdString(item->sig->unit); + case Item::Comment: return QString::fromStdString(item->sig->comment); + case Item::Min: return QString::fromStdString(doubleToString(item->sig->min)); + case Item::Max: return QString::fromStdString(doubleToString(item->sig->max)); case Item::Desc: { QStringList val_desc; for (auto &[val, desc] : item->sig->val_desc) { - val_desc << QString("%1 \"%2\"").arg(val).arg(desc); + val_desc << QString("%1 \"%2\"").arg(val).arg(QString::fromStdString(desc)); } return val_desc.join(" "); } @@ -165,17 +165,17 @@ bool SignalModel::setData(const QModelIndex &index, const QVariant &value, int r Item *item = getItem(index); cabana::Signal s = *item->sig; switch (item->type) { - case Item::Name: s.name = value.toString(); break; + case Item::Name: s.name = value.toString().toStdString(); break; case Item::Size: s.size = value.toInt(); break; - case Item::Node: s.receiver_name = value.toString().trimmed(); break; + case Item::Node: s.receiver_name = value.toString().trimmed().toStdString(); break; case Item::SignalType: s.type = (cabana::Signal::Type)value.toInt(); break; case Item::MultiplexValue: s.multiplex_value = value.toInt(); break; case Item::Endian: s.is_little_endian = value.toBool(); break; case Item::Signed: s.is_signed = value.toBool(); break; case Item::Offset: s.offset = value.toDouble(); break; case Item::Factor: s.factor = value.toDouble(); break; - case Item::Unit: s.unit = value.toString(); break; - case Item::Comment: s.comment = value.toString(); break; + case Item::Unit: s.unit = value.toString().toStdString(); break; + case Item::Comment: s.comment = value.toString().toStdString(); break; case Item::Min: s.min = value.toDouble(); break; case Item::Max: s.max = value.toDouble(); break; case Item::Desc: s.val_desc = value.value(); break; @@ -189,7 +189,7 @@ bool SignalModel::setData(const QModelIndex &index, const QVariant &value, int r bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) { auto msg = dbc()->msg(msg_id); if (s.name != origin_s->name && msg->sig(s.name) != nullptr) { - QString text = tr("There is already a signal with the same name '%1'").arg(s.name); + QString text = tr("There is already a signal with the same name '%1'").arg(QString::fromStdString(s.name)); QMessageBox::warning(nullptr, tr("Failed to save signal"), text); return false; } @@ -214,7 +214,7 @@ void SignalModel::handleSignalAdded(MessageId id, const cabana::Signal *sig) { beginInsertRows({}, i, i); insertItem(root.get(), i, sig); endInsertRows(); - } else if (sig->name.contains(filter_str, Qt::CaseInsensitive)) { + } else if (QString::fromStdString(sig->name).contains(filter_str, Qt::CaseInsensitive)) { refresh(); } } @@ -229,7 +229,9 @@ void SignalModel::handleSignalUpdated(const cabana::Signal *sig) { int to = dbc()->msg(msg_id)->indexOf(sig); if (to != row) { beginMoveRows({}, row, row, {}, to > row ? to + 1 : to); - root->children.move(row, to); + auto item = root->children[row]; + root->children.erase(root->children.begin() + row); + root->children.insert(root->children.begin() + to, item); endMoveRows(); } } @@ -239,7 +241,8 @@ void SignalModel::handleSignalUpdated(const cabana::Signal *sig) { void SignalModel::handleSignalRemoved(const cabana::Signal *sig) { if (int row = signalRow(sig); row != -1) { beginRemoveRows({}, row, row); - delete root->children.takeAt(row); + delete root->children[row]; + root->children.erase(root->children.begin() + row); endRemoveRows(); } } @@ -373,7 +376,10 @@ QWidget *SignalItemDelegate::createEditor(QWidget *parent, const QStyleOptionVie else e->setValidator(double_validator); if (item->type == SignalModel::Item::Name) { - QCompleter *completer = new QCompleter(dbc()->signalNames(), e); + auto names = dbc()->signalNames(); + QStringList qnames; + for (const auto &n : names) qnames.push_back(QString::fromStdString(n)); + QCompleter *completer = new QCompleter(qnames, e); completer->setCaseSensitivity(Qt::CaseInsensitive); completer->setFilterMode(Qt::MatchContains); e->setCompleter(completer); @@ -395,7 +401,7 @@ QWidget *SignalItemDelegate::createEditor(QWidget *parent, const QStyleOptionVie return c; } else if (item->type == SignalModel::Item::Desc) { ValueDescriptionDlg dlg(item->sig->val_desc, parent); - dlg.setWindowTitle(item->sig->name); + dlg.setWindowTitle(QString::fromStdString(item->sig->name)); if (dlg.exec()) { ((QAbstractItemModel *)index.model())->setData(index, QVariant::fromValue(dlg.val_desc)); } @@ -621,7 +627,7 @@ void SignalView::updateState(const std::set *msgs) { for (auto item : model->root->children) { double value = 0; if (item->sig->getValue(last_msg.dat.data(), last_msg.dat.size(), &value)) { - item->sig_val = item->sig->formatValue(value); + item->sig_val = QString::fromStdString(item->sig->formatValue(value)); max_value_width = std::max(max_value_width, fontMetrics().horizontalAdvance(item->sig_val)); } } @@ -635,13 +641,13 @@ void SignalView::updateState(const std::set *msgs) { delegate->button_size.height() - style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2); auto [first, last] = can->eventsInRange(model->msg_id, std::make_pair(last_msg.ts -settings.sparkline_range, last_msg.ts)); - QFutureSynchronizer synchronizer; + std::vector> futures; for (int i = first_visible.row(); i <= last_visible.row(); ++i) { auto item = model->getItem(model->index(i, 1)); - synchronizer.addFuture(QtConcurrent::run( - &item->sparkline, &Sparkline::update, item->sig, first, last, settings.sparkline_range, size)); + futures.push_back(std::async(std::launch::async, + &Sparkline::update, &item->sparkline, item->sig, first, last, settings.sparkline_range, size)); } - synchronizer.waitForFinished(); + for (auto &f : futures) f.get(); } for (int i = 0; i < model->rowCount(); ++i) { @@ -677,7 +683,7 @@ ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions, Q int row = 0; for (auto &[val, desc] : descriptions) { table->setItem(row, 0, new QTableWidgetItem(QString::number(val))); - table->setItem(row, 1, new QTableWidgetItem(desc)); + table->setItem(row, 1, new QTableWidgetItem(QString::fromStdString(desc))); ++row; } @@ -706,7 +712,7 @@ void ValueDescriptionDlg::save() { QString val = table->item(i, 0)->text().trimmed(); QString desc = table->item(i, 1)->text().trimmed(); if (!val.isEmpty() && !desc.isEmpty()) { - val_desc.push_back({val.toDouble(), desc}); + val_desc.push_back({val.toDouble(), desc.toStdString()}); } } QDialog::accept(); diff --git a/tools/cabana/signalview.h b/tools/cabana/signalview.h index 4e746ea105..42db830df7 100644 --- a/tools/cabana/signalview.h +++ b/tools/cabana/signalview.h @@ -20,12 +20,15 @@ class SignalModel : public QAbstractItemModel { public: struct Item { enum Type {Root, Sig, Name, Size, Node, Endian, Signed, Offset, Factor, SignalType, MultiplexValue, ExtraInfo, Unit, Comment, Min, Max, Desc }; - ~Item() { qDeleteAll(children); } - inline int row() { return parent->children.indexOf(this); } + ~Item() { for (auto c : children) delete c; } + inline int row() { + auto it = std::find(parent->children.begin(), parent->children.end(), this); + return it != parent->children.end() ? std::distance(parent->children.begin(), it) : -1; + } Type type = Type::Root; Item *parent = nullptr; - QList children; + std::vector children; const cabana::Signal *sig = nullptr; QString title; diff --git a/tools/cabana/streams/abstractstream.h b/tools/cabana/streams/abstractstream.h index f35b19d34f..7d66a420dc 100644 --- a/tools/cabana/streams/abstractstream.h +++ b/tools/cabana/streams/abstractstream.h @@ -65,8 +65,8 @@ public: virtual void start() = 0; virtual bool liveStreaming() const { return true; } virtual void seekTo(double ts) {} - virtual QString routeName() const = 0; - virtual QString carFingerprint() const { return ""; } + virtual std::string routeName() const = 0; + virtual std::string carFingerprint() const { return ""; } virtual QDateTime beginDateTime() const { return {}; } virtual uint64_t beginMonoTime() const { return 0; } virtual double minSeconds() const { return 0; } @@ -149,7 +149,7 @@ class DummyStream : public AbstractStream { Q_OBJECT public: DummyStream(QObject *parent) : AbstractStream(parent) {} - QString routeName() const override { return tr("No Stream"); } + std::string routeName() const override { return "No Stream"; } void start() override {} }; diff --git a/tools/cabana/streams/devicestream.h b/tools/cabana/streams/devicestream.h index 6beb300d7a..4bcdb5351d 100644 --- a/tools/cabana/streams/devicestream.h +++ b/tools/cabana/streams/devicestream.h @@ -9,8 +9,8 @@ class DeviceStream : public LiveStream { public: DeviceStream(QObject *parent, QString address = {}); ~DeviceStream(); - inline QString routeName() const override { - return QString("Live Streaming From %1").arg(zmq_address.isEmpty() ? "127.0.0.1" : zmq_address); + inline std::string routeName() const override { + return "Live Streaming From " + (zmq_address.isEmpty() ? std::string("127.0.0.1") : zmq_address.toStdString()); } protected: diff --git a/tools/cabana/streams/pandastream.cc b/tools/cabana/streams/pandastream.cc index a2430c665f..3692f71a11 100644 --- a/tools/cabana/streams/pandastream.cc +++ b/tools/cabana/streams/pandastream.cc @@ -16,8 +16,8 @@ PandaStream::PandaStream(QObject *parent, PandaStreamConfig config_) : config(co bool PandaStream::connect() { try { - qDebug() << "Connecting to panda " << config.serial; - panda.reset(new Panda(config.serial.toStdString())); + qDebug() << "Connecting to panda " << config.serial.c_str(); + panda.reset(new Panda(config.serial)); config.bus_config.resize(3); qDebug() << "Connected"; } catch (const std::exception& e) { @@ -81,7 +81,7 @@ void PandaStream::streamThread() { OpenPandaWidget::OpenPandaWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { form_layout = new QFormLayout(this); if (can && dynamic_cast(can) != nullptr) { - form_layout->addWidget(new QLabel(tr("Already connected to %1.").arg(can->routeName()))); + form_layout->addWidget(new QLabel(tr("Already connected to %1.").arg(QString::fromStdString(can->routeName())))); form_layout->addWidget(new QLabel("Close the current connection via [File menu -> Close Stream] before connecting to another Panda.")); QTimer::singleShot(0, [this]() { emit enableOpenButton(false); }); return; @@ -129,7 +129,7 @@ void OpenPandaWidget::buildConfigForm() { } if (has_panda) { - config.serial = serial; + config.serial = serial.toStdString(); config.bus_config.resize(3); for (int i = 0; i < config.bus_config.size(); i++) { QHBoxLayout *bus_layout = new QHBoxLayout; diff --git a/tools/cabana/streams/pandastream.h b/tools/cabana/streams/pandastream.h index e17ad887fc..f8847f65e5 100644 --- a/tools/cabana/streams/pandastream.h +++ b/tools/cabana/streams/pandastream.h @@ -19,7 +19,7 @@ struct BusConfig { }; struct PandaStreamConfig { - QString serial = ""; + std::string serial = ""; std::vector bus_config; }; @@ -28,8 +28,8 @@ class PandaStream : public LiveStream { public: PandaStream(QObject *parent, PandaStreamConfig config_ = {}); ~PandaStream() { stop(); } - inline QString routeName() const override { - return QString("Panda: %1").arg(config.serial); + inline std::string routeName() const override { + return "Panda: " + config.serial; } protected: diff --git a/tools/cabana/streams/replaystream.cc b/tools/cabana/streams/replaystream.cc index b8cf1be299..f42bf2601c 100644 --- a/tools/cabana/streams/replaystream.cc +++ b/tools/cabana/streams/replaystream.cc @@ -46,9 +46,9 @@ void ReplayStream::mergeSegments() { } } -bool ReplayStream::loadRoute(const QString &route, const QString &data_dir, uint32_t replay_flags, bool auto_source) { - replay.reset(new Replay(route.toStdString(), {"can", "roadEncodeIdx", "driverEncodeIdx", "wideRoadEncodeIdx", "carParams"}, - {}, nullptr, replay_flags, data_dir.toStdString(), auto_source)); +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"}, + {}, nullptr, replay_flags, data_dir, auto_source)); replay->setSegmentCacheLimit(settings.max_cached_minutes); replay->installEventFilter([this](const Event *event) { return eventFilter(event); }); @@ -72,17 +72,17 @@ bool ReplayStream::loadRoute(const QString &route, const QString &data_dir, uint "This will grant access to routes from your comma account."; } else { message = tr("Access Denied. You do not have permission to access route:\n\n%1\n\n" - "This is likely a private route.").arg(route); + "This is likely a private route.").arg(QString::fromStdString(route)); } QMessageBox::warning(nullptr, tr("Access Denied"), message); } else if (replay->lastRouteError() == RouteLoadError::NetworkError) { QMessageBox::warning(nullptr, tr("Network Error"), - tr("Unable to load the route:\n\n %1.\n\nPlease check your network connection and try again.").arg(route)); + tr("Unable to load the route:\n\n %1.\n\nPlease check your network connection and try again.").arg(QString::fromStdString(route))); } else if (replay->lastRouteError() == RouteLoadError::FileNotFound) { QMessageBox::warning(nullptr, tr("Route Not Found"), - tr("The specified route could not be found:\n\n %1.\n\nPlease check the route name and try again.").arg(route)); + tr("The specified route could not be found:\n\n %1.\n\nPlease check the route name and try again.").arg(QString::fromStdString(route))); } else { - QMessageBox::warning(nullptr, tr("Route Load Failed"), tr("Failed to load route: '%1'").arg(route)); + QMessageBox::warning(nullptr, tr("Route Load Failed"), tr("Failed to load route: '%1'").arg(QString::fromStdString(route))); } } return success; @@ -168,7 +168,7 @@ AbstractStream *OpenReplayWidget::open() { if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_ECAM; if (flags == REPLAY_FLAG_NONE && !cameras[0]->isChecked()) flags = REPLAY_FLAG_NO_VIPC; - if (replay_stream->loadRoute(route, data_dir, flags)) { + if (replay_stream->loadRoute(route.toStdString(), data_dir.toStdString(), flags)) { return replay_stream.release(); } } diff --git a/tools/cabana/streams/replaystream.h b/tools/cabana/streams/replaystream.h index d429ed1f95..40f8ec8cfb 100644 --- a/tools/cabana/streams/replaystream.h +++ b/tools/cabana/streams/replaystream.h @@ -18,12 +18,12 @@ class ReplayStream : public AbstractStream { public: ReplayStream(QObject *parent); void start() override { replay->start(); } - bool loadRoute(const QString &route, const QString &data_dir, uint32_t replay_flags = REPLAY_FLAG_NONE, bool auto_source = false); + bool loadRoute(const std::string &route, const std::string &data_dir, uint32_t replay_flags = REPLAY_FLAG_NONE, bool auto_source = false); bool eventFilter(const Event *event); void seekTo(double ts) override { replay->seekTo(std::max(double(0), ts), false); } bool liveStreaming() const override { return false; } - inline QString routeName() const override { return QString::fromStdString(replay->route().name()); } - inline QString carFingerprint() const override { return replay->carFingerprint().c_str(); } + inline std::string routeName() const override { return replay->route().name(); } + inline std::string carFingerprint() const override { return replay->carFingerprint(); } double minSeconds() const override { return replay->minSeconds(); } double maxSeconds() const { return replay->maxSeconds(); } inline QDateTime beginDateTime() const { return QDateTime::fromSecsSinceEpoch(replay->routeDateTime()); } diff --git a/tools/cabana/streams/routes.cc b/tools/cabana/streams/routes.cc index 8539a00b5b..1e69a45cea 100644 --- a/tools/cabana/streams/routes.cc +++ b/tools/cabana/streams/routes.cc @@ -11,7 +11,7 @@ #include #include #include -#include +#include #include "tools/replay/py_downloader.h" @@ -72,13 +72,12 @@ RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { // Fetch devices QPointer self = this; - QtConcurrent::run([self]() { + std::thread([self]() { std::string result = PyDownloader::getDevices(); - auto [success, error_code] = checkApiResponse(result); - QMetaObject::invokeMethod(qApp, [self, r = QString::fromStdString(result), success, error_code]() { - if (self) self->parseDeviceList(r, success, error_code); + QMetaObject::invokeMethod(qApp, [self, r = QString::fromStdString(result), response = checkApiResponse(result)]() { + if (self) self->parseDeviceList(r, response.first, response.second); }, Qt::QueuedConnection); - }); + }).detach(); } void RoutesDialog::parseDeviceList(const QString &json, bool success, int error_code) { @@ -114,14 +113,13 @@ void RoutesDialog::fetchRoutes() { int request_id = ++fetch_id_; QPointer self = this; - QtConcurrent::run([self, did, start_ms, end_ms, preserved, request_id]() { + std::thread([self, did, start_ms, end_ms, preserved, request_id]() { std::string result = PyDownloader::getDeviceRoutes(did, start_ms, end_ms, preserved); if (!self || self->fetch_id_ != request_id) return; - auto [success, error_code] = checkApiResponse(result); - QMetaObject::invokeMethod(qApp, [self, r = QString::fromStdString(result), success, error_code, request_id]() { - if (self && self->fetch_id_ == request_id) self->parseRouteList(r, success, error_code); + QMetaObject::invokeMethod(qApp, [self, r = QString::fromStdString(result), response = checkApiResponse(result), request_id]() { + if (self && self->fetch_id_ == request_id) self->parseRouteList(r, response.first, response.second); }, Qt::QueuedConnection); - }); + }).detach(); } void RoutesDialog::parseRouteList(const QString &json, bool success, int error_code) { diff --git a/tools/cabana/streams/socketcanstream.cc b/tools/cabana/streams/socketcanstream.cc index e4801df9c0..768465d5a3 100644 --- a/tools/cabana/streams/socketcanstream.cc +++ b/tools/cabana/streams/socketcanstream.cc @@ -1,6 +1,14 @@ #include "tools/cabana/streams/socketcanstream.h" +#include +#include +#include +#include +#include +#include + #include +#include #include #include #include @@ -9,59 +17,82 @@ SocketCanStream::SocketCanStream(QObject *parent, SocketCanStreamConfig config_) : config(config_), LiveStream(parent) { if (!available()) { - throw std::runtime_error("SocketCAN plugin not available"); + throw std::runtime_error("SocketCAN not available"); } - qDebug() << "Connecting to SocketCAN device" << config.device; + qDebug() << "Connecting to SocketCAN device" << config.device.c_str(); if (!connect()) { throw std::runtime_error("Failed to connect to SocketCAN device"); } } +SocketCanStream::~SocketCanStream() { + stop(); + if (sock_fd >= 0) { + ::close(sock_fd); + sock_fd = -1; + } +} + bool SocketCanStream::available() { - return QCanBus::instance()->plugins().contains("socketcan"); + int fd = socket(PF_CAN, SOCK_RAW, CAN_RAW); + if (fd < 0) return false; + ::close(fd); + return true; } bool SocketCanStream::connect() { - // Connecting might generate some warnings about missing socketcan/libsocketcan libraries - // These are expected and can be ignored, we don't need the advanced features of libsocketcan - QString errorString; - device.reset(QCanBus::instance()->createDevice("socketcan", config.device, &errorString)); - device->setConfigurationParameter(QCanBusDevice::CanFdKey, true); - - if (!device) { - qDebug() << "Failed to create SocketCAN device" << errorString; + sock_fd = socket(PF_CAN, SOCK_RAW, CAN_RAW); + if (sock_fd < 0) { + qDebug() << "Failed to create CAN socket"; return false; } - if (!device->connectDevice()) { - qDebug() << "Failed to connect to device"; + // Enable CAN-FD + int fd_enable = 1; + setsockopt(sock_fd, SOL_CAN_RAW, CAN_RAW_FD_FRAMES, &fd_enable, sizeof(fd_enable)); + + struct ifreq ifr = {}; + strncpy(ifr.ifr_name, config.device.c_str(), IFNAMSIZ - 1); + if (ioctl(sock_fd, SIOCGIFINDEX, &ifr) < 0) { + qDebug() << "Failed to get interface index for" << config.device.c_str(); + ::close(sock_fd); + sock_fd = -1; return false; } + struct sockaddr_can addr = {}; + addr.can_family = AF_CAN; + addr.can_ifindex = ifr.ifr_ifindex; + if (bind(sock_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + qDebug() << "Failed to bind CAN socket"; + ::close(sock_fd); + sock_fd = -1; + return false; + } + + // Set read timeout so the thread can check for interruption + struct timeval tv = {.tv_sec = 0, .tv_usec = 100000}; // 100ms + setsockopt(sock_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + return true; } void SocketCanStream::streamThread() { - while (!QThread::currentThread()->isInterruptionRequested()) { - QThread::msleep(1); + struct canfd_frame frame; - auto frames = device->readAllFrames(); - if (frames.size() == 0) continue; + while (!QThread::currentThread()->isInterruptionRequested()) { + ssize_t nbytes = read(sock_fd, &frame, sizeof(frame)); + if (nbytes <= 0) continue; + + uint8_t len = (nbytes == CAN_MTU) ? frame.len : frame.len; // works for both CAN and CAN-FD MessageBuilder msg; auto evt = msg.initEvent(); - auto canData = evt.initCan(frames.size()); - - for (uint i = 0; i < frames.size(); i++) { - if (!frames[i].isValid()) continue; - - canData[i].setAddress(frames[i].frameId()); - canData[i].setSrc(0); - - auto payload = frames[i].payload(); - canData[i].setDat(kj::arrayPtr((uint8_t*)payload.data(), payload.size())); - } + auto canData = evt.initCan(1); + canData[0].setAddress(frame.can_id & CAN_EFF_MASK); + canData[0].setSrc(0); + canData[0].setDat(kj::arrayPtr(frame.data, len)); handleEvent(capnp::messageToFlatArray(msg)); } @@ -87,7 +118,7 @@ OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWi main_layout->addStretch(1); QObject::connect(refresh, &QPushButton::clicked, this, &OpenSocketCanWidget::refreshDevices); - QObject::connect(device_edit, &QComboBox::currentTextChanged, this, [=]{ config.device = device_edit->currentText(); }); + QObject::connect(device_edit, &QComboBox::currentTextChanged, this, [=]{ config.device = device_edit->currentText().toStdString(); }); // Populate devices refreshDevices(); @@ -95,12 +126,19 @@ OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWi void OpenSocketCanWidget::refreshDevices() { device_edit->clear(); - for (auto device : QCanBus::instance()->availableDevices(QStringLiteral("socketcan"))) { - device_edit->addItem(device.name()); + // Scan /sys/class/net/ for CAN interfaces (type 280 = ARPHRD_CAN) + QDir net_dir("/sys/class/net"); + for (const auto &iface : net_dir.entryList(QDir::Dirs | QDir::NoDotAndDotDot)) { + QFile type_file(net_dir.filePath(iface) + "/type"); + if (type_file.open(QIODevice::ReadOnly)) { + int type = type_file.readAll().trimmed().toInt(); + if (type == 280) { + device_edit->addItem(iface); + } + } } } - AbstractStream *OpenSocketCanWidget::open() { try { return new SocketCanStream(qApp, config); diff --git a/tools/cabana/streams/socketcanstream.h b/tools/cabana/streams/socketcanstream.h index 8083b687e9..3c5cd184f7 100644 --- a/tools/cabana/streams/socketcanstream.h +++ b/tools/cabana/streams/socketcanstream.h @@ -1,27 +1,22 @@ #pragma once -#include - -#include -#include -#include #include #include "tools/cabana/streams/livestream.h" struct SocketCanStreamConfig { - QString device = ""; // TODO: support multiple devices/buses at once + std::string device = ""; // TODO: support multiple devices/buses at once }; class SocketCanStream : public LiveStream { Q_OBJECT public: SocketCanStream(QObject *parent, SocketCanStreamConfig config_ = {}); - ~SocketCanStream() { stop(); } + ~SocketCanStream(); static bool available(); - inline QString routeName() const override { - return QString("Live Streaming From Socket CAN %1").arg(config.device); + inline std::string routeName() const override { + return "Live Streaming From Socket CAN " + config.device; } protected: @@ -29,7 +24,7 @@ protected: bool connect(); SocketCanStreamConfig config = {}; - std::unique_ptr device; + int sock_fd = -1; }; class OpenSocketCanWidget : public AbstractOpenStreamWidget { diff --git a/tools/cabana/streamselector.cc b/tools/cabana/streamselector.cc index efd00d3985..4ad552d4b4 100644 --- a/tools/cabana/streamselector.cc +++ b/tools/cabana/streamselector.cc @@ -4,11 +4,12 @@ #include #include -#include "streams/socketcanstream.h" #include "tools/cabana/streams/devicestream.h" #include "tools/cabana/streams/pandastream.h" #include "tools/cabana/streams/replaystream.h" +#ifdef __linux__ #include "tools/cabana/streams/socketcanstream.h" +#endif StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { setWindowTitle(tr("Open stream")); @@ -35,9 +36,11 @@ StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { addStreamWidget(new OpenReplayWidget, tr("&Replay")); addStreamWidget(new OpenPandaWidget, tr("&Panda")); +#ifdef __linux__ if (SocketCanStream::available()) { addStreamWidget(new OpenSocketCanWidget, tr("&SocketCAN")); } +#endif addStreamWidget(new OpenDeviceWidget, tr("&Device")); QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject); diff --git a/tools/cabana/tests/test_cabana.cc b/tools/cabana/tests/test_cabana.cc index d9fcae6f21..833cfbe4b5 100644 --- a/tools/cabana/tests/test_cabana.cc +++ b/tools/cabana/tests/test_cabana.cc @@ -8,7 +8,7 @@ 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") { - QString fn = QString("%1/%2.dbc").arg(OPENDBC_FILE_PATH, "tesla_can"); + std::string fn = std::string(OPENDBC_FILE_PATH) + "/tesla_can.dbc"; DBCFile dbc_origin(fn); DBCFile dbc_from_generated("", dbc_origin.generateDBC()); @@ -30,7 +30,7 @@ TEST_CASE("DBCFile::generateDBC") { TEST_CASE("DBCFile::generateDBC - comment order") { // Ensure that message comments are followed by signal comments and in the correct order - auto content = R"(BO_ 160 message_1: 8 EON + std::string content = R"(BO_ 160 message_1: 8 EON SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX BO_ 162 message_2: 8 EON @@ -46,7 +46,7 @@ CM_ SG_ 162 signal_2 "signal comment"; } TEST_CASE("DBCFile::generateDBC -- preserve original header") { - QString content = R"(VERSION "1.0" + std::string content = R"(VERSION "1.0" NS_ : CM_ @@ -66,7 +66,7 @@ CM_ SG_ 160 signal_1 "signal comment"; } TEST_CASE("DBCFile::generateDBC - escaped quotes") { - QString content = R"(BO_ 160 message_1: 8 EON + std::string content = R"(BO_ 160 message_1: 8 EON SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX CM_ BO_ 160 "message comment with \"escaped quotes\""; @@ -77,7 +77,7 @@ CM_ SG_ 160 signal_1 "signal comment with \"escaped quotes\""; } TEST_CASE("parse_dbc") { - QString content = R"( + std::string content = R"( BO_ 160 message_1: 8 EON SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX SG_ signal_2 : 12|1@1+ (1.0,0.0) [0.0|1] "" XXX @@ -119,9 +119,9 @@ CM_ SG_ 162 signal_1 "signal comment with \"escaped quotes\""; REQUIRE(sig_1->comment == "signal comment"); REQUIRE(sig_1->receiver_name == "XXX"); REQUIRE(sig_1->val_desc.size() == 3); - REQUIRE(sig_1->val_desc[0] == std::pair{0, "disabled"}); - REQUIRE(sig_1->val_desc[1] == std::pair{1.2, "initializing"}); - REQUIRE(sig_1->val_desc[2] == std::pair{2, "fault"}); + REQUIRE(sig_1->val_desc[0] == std::pair{0, "disabled"}); + REQUIRE(sig_1->val_desc[1] == std::pair{1.2, "initializing"}); + REQUIRE(sig_1->val_desc[2] == std::pair{2, "fault"}); auto &sig_2 = msg->sigs[1]; REQUIRE(sig_2->comment == "multiple line comment \n1\n2"); @@ -147,7 +147,7 @@ TEST_CASE("parse_opendbc") { QStringList errors; for (auto fn : dir.entryList({"*.dbc"}, QDir::Files, QDir::Name)) { try { - auto dbc = DBCFile(dir.filePath(fn)); + auto dbc = DBCFile(dir.filePath(fn).toStdString()); } catch (std::exception &e) { errors.push_back(e.what()); } diff --git a/tools/cabana/tools/findsignal.cc b/tools/cabana/tools/findsignal.cc index ec56fcaac0..b131893942 100644 --- a/tools/cabana/tools/findsignal.cc +++ b/tools/cabana/tools/findsignal.cc @@ -1,10 +1,11 @@ #include "tools/cabana/tools/findsignal.h" +#include + #include #include #include #include -#include #include #include @@ -20,7 +21,7 @@ QVariant FindSignalModel::data(const QModelIndex &index, int role) const { if (role == Qt::DisplayRole) { const auto &s = filtered_signals[index.row()]; switch (index.column()) { - case 0: return s.id.toString(); + case 0: return QString::fromStdString(s.id.toString()); case 1: return QString("%1, %2").arg(s.sig.start_bit).arg(s.sig.size); case 2: return s.values.join(" "); } @@ -32,36 +33,49 @@ void FindSignalModel::search(std::function cmp) { beginResetModel(); std::mutex lock; - const auto prev_sigs = !histories.isEmpty() ? histories.back() : initial_signals; + const auto prev_sigs = !histories.empty() ? histories.back() : initial_signals; filtered_signals.clear(); filtered_signals.reserve(prev_sigs.size()); - QtConcurrent::blockingMap(prev_sigs, [&](auto &s) { - const auto &events = can->events(s.id); - auto first = std::upper_bound(events.cbegin(), events.cend(), s.mono_time, CompareCanEvent()); - auto last = events.cend(); - if (last_time < std::numeric_limits::max()) { - last = std::upper_bound(events.cbegin(), events.cend(), last_time, CompareCanEvent()); - } - auto it = std::find_if(first, last, [&](const CanEvent *e) { return cmp(get_raw_value(e->dat, e->size, s.sig)); }); - if (it != last) { - auto values = s.values; - values += QString("(%1, %2)").arg(can->toSeconds((*it)->mono_time), 0, 'f', 3).arg(get_raw_value((*it)->dat, (*it)->size, s.sig)); - std::lock_guard lk(lock); - filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values}); - } - }); + unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency()); + size_t chunk = (prev_sigs.size() + num_threads - 1) / num_threads; + std::vector threads; + for (unsigned int t = 0; t < num_threads && t * chunk < (size_t)prev_sigs.size(); ++t) { + size_t start = t * chunk; + size_t end = std::min(start + chunk, (size_t)prev_sigs.size()); + threads.emplace_back([&, start, end]() { + for (size_t i = start; i < end; ++i) { + const auto &s = prev_sigs[i]; + const auto &events = can->events(s.id); + auto first = std::upper_bound(events.cbegin(), events.cend(), s.mono_time, CompareCanEvent()); + auto last = events.cend(); + if (last_time < std::numeric_limits::max()) { + last = std::upper_bound(events.cbegin(), events.cend(), last_time, CompareCanEvent()); + } + + auto it = std::find_if(first, last, [&](const CanEvent *e) { return cmp(get_raw_value(e->dat, e->size, s.sig)); }); + if (it != last) { + auto values = s.values; + values += QString("(%1, %2)").arg(can->toSeconds((*it)->mono_time), 0, 'f', 3).arg(get_raw_value((*it)->dat, (*it)->size, s.sig)); + std::lock_guard lk(lock); + filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values}); + } + } + }); + } + for (auto &th : threads) th.join(); + histories.push_back(filtered_signals); endResetModel(); } void FindSignalModel::undo() { - if (!histories.isEmpty()) { + if (!histories.empty()) { beginResetModel(); histories.pop_back(); filtered_signals.clear(); - if (!histories.isEmpty()) filtered_signals = histories.back(); + if (!histories.empty()) filtered_signals = histories.back(); endResetModel(); } } @@ -172,7 +186,7 @@ FindSignalDlg::FindSignalDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags( } void FindSignalDlg::search() { - if (model->histories.isEmpty()) { + if (model->histories.empty()) { setInitialSignals(); } auto v1 = value1->text().toDouble(); @@ -246,12 +260,12 @@ void FindSignalDlg::setInitialSignals() { } void FindSignalDlg::modelReset() { - properties_group->setEnabled(model->histories.isEmpty()); - message_group->setEnabled(model->histories.isEmpty()); - search_btn->setText(model->histories.isEmpty() ? tr("Find") : tr("Find Next")); - reset_btn->setEnabled(!model->histories.isEmpty()); + properties_group->setEnabled(model->histories.empty()); + message_group->setEnabled(model->histories.empty()); + search_btn->setText(model->histories.empty() ? tr("Find") : tr("Find Next")); + reset_btn->setEnabled(!model->histories.empty()); undo_btn->setEnabled(model->histories.size() > 1); - search_btn->setEnabled(model->rowCount() > 0 || model->histories.isEmpty()); + search_btn->setEnabled(model->rowCount() > 0 || model->histories.empty()); stats_label->setVisible(true); stats_label->setText(tr("%1 matches. right click on an item to create signal. double click to open message").arg(model->filtered_signals.size())); } diff --git a/tools/cabana/tools/findsignal.h b/tools/cabana/tools/findsignal.h index 5ef7461fee..239a08c9c4 100644 --- a/tools/cabana/tools/findsignal.h +++ b/tools/cabana/tools/findsignal.h @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include @@ -26,14 +28,14 @@ public: QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; int columnCount(const QModelIndex &parent = QModelIndex()) const override { return 3; } - int rowCount(const QModelIndex &parent = QModelIndex()) const override { return std::min(filtered_signals.size(), 300); } + int rowCount(const QModelIndex &parent = QModelIndex()) const override { return std::min((int)filtered_signals.size(), 300); } void search(std::function cmp); void reset(); void undo(); - QList filtered_signals; - QList initial_signals; - QList> histories; + std::vector filtered_signals; + std::vector initial_signals; + std::vector> histories; uint64_t last_time = std::numeric_limits::max(); }; diff --git a/tools/cabana/tools/findsimilarbits.cc b/tools/cabana/tools/findsimilarbits.cc index c3c659791a..8062b61199 100644 --- a/tools/cabana/tools/findsimilarbits.cc +++ b/tools/cabana/tools/findsimilarbits.cc @@ -1,6 +1,7 @@ #include "tools/cabana/tools/findsimilarbits.h" #include +#include #include #include @@ -31,7 +32,7 @@ FindSimilarBitsDlg::FindSimilarBitsDlg(QWidget *parent) : QDialog(parent, Qt::Wi msg_cb = new QComboBox(this); // TODO: update when src_bus_combo changes for (auto &[address, msg] : dbc()->getMessages(-1)) { - msg_cb->addItem(msg.name, address); + msg_cb->addItem(QString::fromStdString(msg.name), address); } msg_cb->model()->sort(0); msg_cb->setCurrentIndex(0); @@ -114,10 +115,10 @@ void FindSimilarBitsDlg::find() { search_btn->setEnabled(true); } -QList FindSimilarBitsDlg::calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, - int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt) { - QHash> mismatches; - QHash msg_count; +std::vector FindSimilarBitsDlg::calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, + int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt) { + std::unordered_map> mismatches; + std::unordered_map msg_count; const auto &events = can->allEvents(); int bit_to_find = -1; for (const CanEvent *e : events) { @@ -143,14 +144,14 @@ QList FindSimilarBitsDlg::calcBits(uint8_ } } - QList result; + std::vector result; result.reserve(mismatches.size()); for (auto it = mismatches.begin(); it != mismatches.end(); ++it) { - if (auto cnt = msg_count[it.key()]; cnt > min_msgs_cnt) { - auto &mismatched = it.value(); - for (int i = 0; i < mismatched.size(); ++i) { + if (auto cnt = msg_count[it->first]; cnt > (uint32_t)min_msgs_cnt) { + auto &mismatched = it->second; + for (int i = 0; i < (int)mismatched.size(); ++i) { if (float perc = (mismatched[i] / (double)cnt) * 100; perc < 50) { - result.push_back({it.key(), (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc}); + result.push_back({it->first, (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc}); } } } diff --git a/tools/cabana/tools/findsimilarbits.h b/tools/cabana/tools/findsimilarbits.h index 77bfac19ca..3451360654 100644 --- a/tools/cabana/tools/findsimilarbits.h +++ b/tools/cabana/tools/findsimilarbits.h @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -22,7 +24,7 @@ private: uint32_t address, byte_idx, bit_idx, mismatches, total; float perc; }; - QList calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, int bit_idx, uint8_t find_bus, + std::vector calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt); void find(); diff --git a/tools/cabana/utils/export.cc b/tools/cabana/utils/export.cc index 79ca97ba8f..a7f910193f 100644 --- a/tools/cabana/utils/export.cc +++ b/tools/cabana/utils/export.cc @@ -26,7 +26,7 @@ void exportSignalsToCSV(const QString &file_name, const MessageId &msg_id) { QTextStream stream(&file); stream << "time,addr,bus"; for (auto s : msg->sigs) - stream << "," << s->name; + stream << "," << s->name.c_str(); stream << "\n"; for (auto e : can->events(msg_id)) { diff --git a/tools/cabana/utils/util.cc b/tools/cabana/utils/util.cc index 61e4ee0514..50ab764423 100644 --- a/tools/cabana/utils/util.cc +++ b/tools/cabana/utils/util.cc @@ -17,8 +17,7 @@ #include #include #include -#include -#include +#include #include "common/util.h" // SegmentTree @@ -278,7 +277,7 @@ QString signalToolTip(const cabana::Signal *sig) { Start Bit: %2 Size: %3
MSB: %4 LSB: %5
Little Endian: %6 Signed: %7 - )").arg(sig->name).arg(sig->start_bit).arg(sig->size).arg(sig->msb).arg(sig->lsb) + )").arg(QString::fromStdString(sig->name)).arg(sig->start_bit).arg(sig->size).arg(sig->msb).arg(sig->lsb) .arg(sig->is_little_endian ? "Y" : "N").arg(sig->is_signed ? "Y" : "N"); } @@ -325,36 +324,49 @@ void initApp(int argc, char *argv[], bool disable_hidpi) { setSurfaceFormat(); } -static QHash load_bootstrap_icons() { - QHash icons; +static std::unordered_map load_bootstrap_icons() { + std::unordered_map icons; QFile f(":/bootstrap-icons.svg"); if (f.open(QIODevice::ReadOnly | QIODevice::Text)) { - QDomDocument xml; - xml.setContent(&f); - QDomNode n = xml.documentElement().firstChild(); - while (!n.isNull()) { - QDomElement e = n.toElement(); - if (!e.isNull() && e.hasAttribute("id")) { - QString svg_str; - QTextStream stream(&svg_str); - n.save(stream, 0); - svg_str.replace("", ""); - icons[e.attribute("id")] = svg_str.toUtf8(); + std::string content = f.readAll().toStdString(); + const std::string sym_open = " with + svg_str.replace(0, 7, " ""); // "" (9) -> "" (6) + icons[id] = std::move(svg_str); + } } - n = n.nextSibling(); + pos = end; } } return icons; } QPixmap bootstrapPixmap(const QString &id) { - static QHash icons = load_bootstrap_icons(); + static auto icons = load_bootstrap_icons(); QPixmap pixmap; - if (auto it = icons.find(id); it != icons.end()) { - pixmap.loadFromData(it.value(), "svg"); + auto it = icons.find(id.toStdString()); + if (it != icons.end()) { + pixmap.loadFromData((const uchar *)it->second.data(), it->second.size(), "svg"); } return pixmap; } diff --git a/tools/cabana/videowidget.cc b/tools/cabana/videowidget.cc index a05bf24b4a..f203ec663e 100644 --- a/tools/cabana/videowidget.cc +++ b/tools/cabana/videowidget.cc @@ -1,6 +1,7 @@ #include "tools/cabana/videowidget.h" #include +#include #include #include @@ -9,7 +10,6 @@ #include #include #include -#include #include "tools/cabana/tools/routeinfo.h" @@ -334,19 +334,31 @@ StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType str void StreamCameraView::parseQLog(std::shared_ptr qlog) { std::mutex mutex; - QtConcurrent::blockingMap(qlog->events.cbegin(), qlog->events.cend(), [this, &mutex](const Event &e) { - if (e.which == cereal::Event::Which::THUMBNAIL) { - capnp::FlatArrayMessageReader reader(e.data); - auto thumb_data = reader.getRoot().getThumbnail(); - auto image_data = thumb_data.getThumbnail(); - if (QPixmap thumb; thumb.loadFromData(image_data.begin(), image_data.size(), "jpeg")) { - QPixmap generated_thumb = generateThumbnail(thumb, can->toSeconds(thumb_data.getTimestampEof())); - std::lock_guard lock(mutex); - thumbnails[thumb_data.getTimestampEof()] = generated_thumb; - big_thumbnails[thumb_data.getTimestampEof()] = thumb; + const auto &events = qlog->events; + unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency()); + size_t chunk = (events.size() + num_threads - 1) / num_threads; + std::vector threads; + for (unsigned int t = 0; t < num_threads && t * chunk < events.size(); ++t) { + size_t start = t * chunk; + size_t end = std::min(start + chunk, events.size()); + threads.emplace_back([this, &mutex, &events, start, end]() { + for (size_t i = start; i < end; ++i) { + const Event &e = events[i]; + if (e.which == cereal::Event::Which::THUMBNAIL) { + capnp::FlatArrayMessageReader reader(e.data); + auto thumb_data = reader.getRoot().getThumbnail(); + auto image_data = thumb_data.getThumbnail(); + if (QPixmap thumb; thumb.loadFromData(image_data.begin(), image_data.size(), "jpeg")) { + QPixmap generated_thumb = generateThumbnail(thumb, can->toSeconds(thumb_data.getTimestampEof())); + std::lock_guard lock(mutex); + thumbnails[thumb_data.getTimestampEof()] = generated_thumb; + big_thumbnails[thumb_data.getTimestampEof()] = thumb; + } + } } - } - }); + }); + } + for (auto &th : threads) th.join(); update(); } @@ -384,9 +396,9 @@ QPixmap StreamCameraView::generateThumbnail(QPixmap thumb, double seconds) { void StreamCameraView::drawScrubThumbnail(QPainter &p) { p.fillRect(rect(), Qt::black); - auto it = big_thumbnails.lowerBound(can->toMonoTime(thumbnail_dispaly_time)); + auto it = big_thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time)); if (it != big_thumbnails.end()) { - QPixmap scaled_thumb = it.value().scaled(rect().size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); + QPixmap scaled_thumb = it->second.scaled(rect().size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); QRect thumb_rect(rect().center() - scaled_thumb.rect().center(), scaled_thumb.size()); p.drawPixmap(thumb_rect.topLeft(), scaled_thumb); drawTime(p, thumb_rect, thumbnail_dispaly_time); @@ -394,9 +406,9 @@ void StreamCameraView::drawScrubThumbnail(QPainter &p) { } void StreamCameraView::drawThumbnail(QPainter &p) { - auto it = thumbnails.lowerBound(can->toMonoTime(thumbnail_dispaly_time)); + auto it = thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time)); if (it != thumbnails.end()) { - const QPixmap &thumb = it.value(); + const QPixmap &thumb = it->second; auto [min_sec, max_sec] = can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds())); int pos = (thumbnail_dispaly_time - min_sec) * width() / (max_sec - min_sec); int x = std::clamp(pos - thumb.width() / 2, THUMBNAIL_MARGIN, width() - thumb.width() - THUMBNAIL_MARGIN + 1); diff --git a/tools/cabana/videowidget.h b/tools/cabana/videowidget.h index 6da0023123..e52e92ebd1 100644 --- a/tools/cabana/videowidget.h +++ b/tools/cabana/videowidget.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -47,8 +48,8 @@ private: void drawTime(QPainter &p, const QRect &rect, double seconds); QPropertyAnimation *fade_animation; - QMap big_thumbnails; - QMap thumbnails; + std::map big_thumbnails; + std::map thumbnails; double thumbnail_dispaly_time = -1; friend class VideoWidget; }; diff --git a/tools/tuning/measure_steering_accuracy.py b/tools/car_porting/measure_steering_accuracy.py similarity index 100% rename from tools/tuning/measure_steering_accuracy.py rename to tools/car_porting/measure_steering_accuracy.py diff --git a/tools/clip/run.py b/tools/clip/run.py index a5587f239b..ed2a5075b4 100755 --- a/tools/clip/run.py +++ b/tools/clip/run.py @@ -63,8 +63,10 @@ def parse_args(): return args -def setup_env(output_path: str, big: bool = False, speed: int = 1, target_mb: float = 0, duration: int = 0): - os.environ.update({"RECORD": "1", "OFFSCREEN": "1", "RECORD_OUTPUT": str(Path(output_path).with_suffix(".mp4"))}) +def setup_env(output_path: str, big: bool = False, speed: int = 1, target_mb: float = 0, duration: int = 0, headless: bool = True): + os.environ.update({"RECORD": "1", "RECORD_OUTPUT": str(Path(output_path).with_suffix(".mp4"))}) + if headless: + os.environ["OFFSCREEN"] = "1" if speed > 1: os.environ["RECORD_SPEED"] = str(speed) if target_mb > 0 and duration > 0: @@ -302,11 +304,11 @@ def clip(route: Route, output: str, start: int, end: int, headless: bool = True, logger.error("No messages to render") sys.exit(1) - metadata = load_route_metadata(route) if show_metadata else None if headless: rl.set_config_flags(rl.ConfigFlags.FLAG_WINDOW_HIDDEN) with OpenpilotPrefix(shared_download_cache=True): + metadata = load_route_metadata(route) if show_metadata else None camera_paths = route.qcamera_paths() if use_qcam else route.camera_paths() frame_queue = FrameQueue(camera_paths, start, end, fps=FRAMERATE, use_qcam=use_qcam) @@ -349,8 +351,9 @@ def main(): logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s\t%(message)s") args = parse_args() - setup_env(args.output, big=args.big, speed=args.speed, target_mb=args.file_size, duration=args.end - args.start) - clip(Route(args.route, data_dir=args.data_dir), args.output, args.start, args.end, not args.windowed, + headless = not args.windowed + setup_env(args.output, big=args.big, speed=args.speed, target_mb=args.file_size, duration=args.end - args.start, headless=headless) + clip(Route(args.route, data_dir=args.data_dir), args.output, args.start, args.end, headless, args.big, args.title, not args.no_metadata, not args.no_time_overlay, args.qcam) diff --git a/tools/jotpluggler/.gitignore b/tools/jotpluggler/.gitignore new file mode 100644 index 0000000000..7cb98300fe --- /dev/null +++ b/tools/jotpluggler/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +jot_*.o +*.o +jotpluggler +car_fingerprint_to_dbc.h +generated_dbcs/.stamp +generated_dbcs/*.dbc +layouts/.jotpluggler_autosave/ +reports/ diff --git a/tools/jotpluggler/README.md b/tools/jotpluggler/README.md deleted file mode 100644 index d5e4b8ab0f..0000000000 --- a/tools/jotpluggler/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# JotPluggler - -JotPluggler is a tool to quickly visualize openpilot logs. - -## Usage - -``` -$ ./jotpluggler/pluggle.py -h -usage: pluggle.py [-h] [--demo] [--layout LAYOUT] [route] - -A tool for visualizing openpilot logs. - -positional arguments: - route Optional route name to load on startup. - -options: - -h, --help show this help message and exit - --demo Use the demo route instead of providing one - --layout LAYOUT Path to YAML layout file to load on startup -``` - -Example using route name: - -`./pluggle.py "5beb9b58bd12b691/0000010a--a51155e496"` - -Examples using segment: - -`./pluggle.py "5beb9b58bd12b691/0000010a--a51155e496/1"` - -`./pluggle.py "5beb9b58bd12b691/0000010a--a51155e496/1/q" # use qlogs` - -Example using segment range: - -`./pluggle.py "5beb9b58bd12b691/0000010a--a51155e496/0:1"` - -## Demo - -For a quick demo, run this command: - -`./pluggle.py --demo --layout=layouts/torque-controller.yaml` - - -## Basic Usage/Features: -- The text box to load a route is a the top left of the page, accepts standard openpilot format routes (e.g. `5beb9b58bd12b691/0000010a--a51155e496/0:1`, `https://connect.comma.ai/5beb9b58bd12b691/0000010a--a51155e496/`) -- The Play/Pause button is at the bottom of the screen, you can drag the bottom slider to seek. The timeline in timeseries plots are synced with the slider. -- The Timeseries List sidebar has several dropdowns, the fields each show the field name and value, synced with the timeline (will show N/A until the time of the first message in that field is reached). -- There is a search bar for the timeseries list, you can search for structs or fields, or both by separating with a "/" -- You can drag and drop any numeric/boolean field from the timeseries list into a timeseries panel. -- You can create more panels with the split buttons (buttons with two rectangles, either horizontal or vertical). You can resize the panels by dragging the grip in between any panel. -- You can load and save layouts with the corresponding buttons. Layouts will save all tabs, panels, titles, timeseries, etc. - -## Layouts - -If you create a layout that's useful for others, consider upstreaming it. - -## Plot Interaction Controls - -- **Left click and drag within the plot area** to pan X - - Left click and drag on an axis to pan an individual axis (disabled for Y-axis) -- **Scroll in the plot area** to zoom in X axes, Y-axis is autofit - - Scroll on an axis to zoom an individual axis -- **Right click and drag** to select data and zoom into the selected data - - Left click while box selecting to cancel the selection -- **Double left click** to fit all visible data - - Double left click on an axis to fit the individual axis (disabled for Y-axis, always autofit) -- **Double right click** to open the plot context menu -- **Click legend label icons** to show/hide plot items diff --git a/tools/jotpluggler/SConscript b/tools/jotpluggler/SConscript new file mode 100644 index 0000000000..122d502341 --- /dev/null +++ b/tools/jotpluggler/SConscript @@ -0,0 +1,92 @@ +import os +import imgui +import libusb +from opendbc import get_generated_dbcs +from opendbc.car import Bus +from opendbc.car.fingerprints import MIGRATION +from opendbc.car.values import PLATFORMS +from openpilot.common.basedir import BASEDIR + +Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib') + +jot_env = env.Clone() +jot_env["LIBPATH"] += [imgui.MESA_DIR, libusb.LIB_DIR] +jot_env["CPPPATH"] += [imgui.INCLUDE_DIR, libusb.INCLUDE_DIR] +jot_env["CXXFLAGS"] += [ + "-DGLFW_INCLUDE_NONE", + '-DJOTP_REPO_ROOT=\'"%s"\'' % os.path.realpath(BASEDIR), +] + +def materialize_generated_dbcs(target, source, env): + out_dir = os.path.dirname(str(target[0])) + os.makedirs(out_dir, exist_ok=True) + + for name in os.listdir(out_dir): + if name.endswith('.dbc'): + os.unlink(os.path.join(out_dir, name)) + + for name, content in sorted(get_generated_dbcs().items()): + with open(os.path.join(out_dir, f"{name}.dbc"), "w") as f: + f.write(content) + + with open(str(target[0]), "w") as f: + f.write("ok\n") + + return None + +def write_car_fingerprint_to_dbc_header(target, source, env): + pairs = {} + + for name, platform in sorted(PLATFORMS.items()): + dbc = platform.config.dbc_dict.get(Bus.pt, "") + if not dbc and name.startswith("TESLA_"): + dbc = platform.config.dbc_dict.get(Bus.party, "") + if not dbc and name == "COMMA_BODY": + dbc = "comma_body" + if dbc and name != "MOCK": + pairs[name] = dbc + + for fingerprint, car in sorted(MIGRATION.items()): + dbc = pairs.get(str(car), "") + if dbc: + pairs[fingerprint] = dbc + + lines = [ + "#pragma once", + "", + "#include ", + "#include ", + "", + "inline constexpr std::pair kCarFingerprintToDbc[] = {", + ] + lines.extend(f' {{"{fingerprint}", "{dbc}"}},' for fingerprint, dbc in sorted(pairs.items())) + lines.extend([ + "};", + "", + "inline std::string_view dbc_for_car_fingerprint(std::string_view fingerprint) {", + " for (const auto &[car_fingerprint, dbc] : kCarFingerprintToDbc) {", + " if (car_fingerprint == fingerprint) return dbc;", + " }", + " return {};", + "}", + "", + ]) + + with open(str(target[0]), "w") as f: + f.write("\n".join(lines)) + + return None + +generated_dbc_stamp = jot_env.Command(f"generated_dbcs/.stamp", [], materialize_generated_dbcs) +car_fingerprint_to_dbc = jot_env.Command("car_fingerprint_to_dbc.h", [], write_car_fingerprint_to_dbc_header) + +libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a"), + "avformat", "avcodec", "avutil", "x264", "yuv", "z", "bz2", "zstd", "m", "pthread", "usb-1.0"] +if arch == "Darwin": + jot_env["FRAMEWORKS"] = ["OpenGL", "Cocoa", "IOKit", "CoreFoundation", "CoreVideo", "CoreMedia", "VideoToolbox"] +else: + libs += ["GL", "dl", "va", "va-drm", "drm"] + +program = jot_env.Program("jotpluggler", jot_env.Glob("*.cc"), LIBS=libs) +jot_env.Depends(program, generated_dbc_stamp) +jot_env.Depends(program, car_fingerprint_to_dbc) diff --git a/tools/jotpluggler/app.cc b/tools/jotpluggler/app.cc new file mode 100644 index 0000000000..4b56299ead --- /dev/null +++ b/tools/jotpluggler/app.cc @@ -0,0 +1,1914 @@ +#include "tools/jotpluggler/app.h" +#include "tools/jotpluggler/camera.h" +#include "tools/jotpluggler/common.h" +#include "tools/jotpluggler/internal.h" +#include "tools/jotpluggler/map.h" +#include "system/hardware/hw.h" +#include "imgui_impl_glfw.h" + +#include "imgui_internal.h" +#include "imgui_impl_opengl3.h" +#include "imgui_impl_opengl3_loader.h" +#include "implot.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "third_party/json11/json11.hpp" + +namespace fs = std::filesystem; + +constexpr const char *UNTITLED_PANE_TITLE = "..."; +ImFont *g_ui_font = nullptr; +ImFont *g_ui_bold_font = nullptr; +ImFont *g_mono_font = nullptr; + +std::string layout_name_from_arg(const std::string &layout_arg) { + const fs::path raw(layout_arg); + if (raw.extension() == ".xml" || raw.extension() == ".json") { + return raw.stem().string(); + } + if (raw.filename() != raw) { + return raw.filename().replace_extension("").string(); + } + fs::path stem_path = raw; + return stem_path.replace_extension("").string(); +} + +fs::path layouts_dir() { + return repo_root() / "tools" / "jotpluggler" / "layouts"; +} + +std::string sanitize_layout_stem(std::string_view name) { + std::string out; + out.reserve(name.size()); + bool last_was_dash = false; + for (const char raw : name) { + const unsigned char c = static_cast(raw); + if (std::isalnum(c) != 0) { + out.push_back(static_cast(std::tolower(c))); + last_was_dash = false; + } else if (raw == '-' || raw == '_') { + out.push_back(raw); + last_was_dash = false; + } else if (!last_was_dash && !out.empty()) { + out.push_back('-'); + last_was_dash = true; + } + } + while (!out.empty() && out.back() == '-') { + out.pop_back(); + } + return out.empty() ? "untitled" : out; +} + +fs::path autosave_dir() { + return layouts_dir() / ".jotpluggler_autosave"; +} + +fs::path resolve_layout_path(const std::string &layout_arg) { + const fs::path direct(layout_arg); + if (fs::exists(direct)) { + if (direct.extension() == ".json") return fs::absolute(direct); + const fs::path sibling_json = direct.parent_path() / (direct.stem().string() + ".json"); + if (direct.extension() == ".xml" && fs::exists(sibling_json)) { + return fs::absolute(sibling_json); + } + } + const fs::path candidate = layouts_dir() / (layout_name_from_arg(layout_arg) + ".json"); + if (!fs::exists(candidate)) throw std::runtime_error("Unknown layout: " + layout_arg); + return candidate; +} + +fs::path autosave_path_for_layout(const fs::path &layout_path) { + const std::string stem = layout_path.empty() ? "untitled" : layout_path.stem().string(); + return autosave_dir() / (sanitize_layout_stem(stem) + ".json"); +} + +std::vector available_layout_names() { + std::vector names; + const fs::path root = layouts_dir(); + if (!fs::exists(root) || !fs::is_directory(root)) { + return names; + } + for (const auto &entry : fs::directory_iterator(root)) { + if (!entry.is_regular_file() || entry.path().extension() != ".json") { + continue; + } + names.push_back(entry.path().stem().string()); + } + std::sort(names.begin(), names.end()); + return names; +} + +void refresh_replaced_layout_ui(AppSession *session, UiState *state, bool mark_docks) { + state->tabs.clear(); + cancel_rename_tab(state); + sync_ui_state(state, session->layout); + sync_layout_buffers(state, *session); + if (mark_docks) { + mark_all_docks_dirty(state); + } +} + +void start_new_layout(AppSession *session, UiState *state, const std::string &status_text) { + session->layout = make_empty_layout(); + session->layout_path.clear(); + session->autosave_path.clear(); + state->undo.reset(session->layout); + state->layout_dirty = false; + state->status_text = status_text; + refresh_replaced_layout_ui(session, state, true); + reset_shared_range(state, *session); +} + +bool is_decoded_can_series_path(std::string_view path) { + const std::string value(path); + return util::starts_with(value, "/can/") || util::starts_with(value, "/sendcan/"); +} + +bool apply_route_can_decode_update(AppSession *session, UiState *state); + +void rebuild_series_lookup_preserving_formats(AppSession *session, + std::string_view updated_prefix, + bool refresh_updated_formats_only) { + const std::string prefix(updated_prefix); + if (!updated_prefix.empty()) { + for (auto it = session->route_data.series_formats.begin(); it != session->route_data.series_formats.end();) { + if (util::starts_with(it->first, prefix)) { + it = session->route_data.series_formats.erase(it); + } else { + ++it; + } + } + } + session->series_by_path.clear(); + session->series_by_path.reserve(session->route_data.series.size()); + for (RouteSeries &series : session->route_data.series) { + session->series_by_path.emplace(series.path, &series); + if (refresh_updated_formats_only) { + if (!updated_prefix.empty() && util::starts_with(series.path, prefix)) { + const bool enum_like = session->route_data.enum_info.find(series.path) != session->route_data.enum_info.end(); + session->route_data.series_formats[series.path] = compute_series_format(series.values, enum_like); + } + } else { + const bool enum_like = session->route_data.enum_info.find(series.path) != session->route_data.enum_info.end(); + session->route_data.series_formats[series.path] = compute_series_format(series.values, enum_like); + } + } +} + +bool apply_route_can_decode_update(AppSession *session, UiState *state) { + const std::string active_dbc_name = !session->dbc_override.empty() ? session->dbc_override : session->route_data.dbc_name; + if (!active_dbc_name.empty() && !load_dbc_by_name(active_dbc_name).has_value()) { + state->error_text = "DBC not found: " + active_dbc_name; + state->open_error_popup = true; + return false; + } + std::unordered_map can_enum_info; + std::vector can_series = decode_can_messages(session->route_data.can_messages, active_dbc_name, &can_enum_info); + + std::vector updated_series; + updated_series.reserve(session->route_data.series.size() + can_series.size()); + for (RouteSeries &series : session->route_data.series) { + if (!is_decoded_can_series_path(series.path)) { + updated_series.push_back(std::move(series)); + } + } + for (RouteSeries &series : can_series) { + updated_series.push_back(std::move(series)); + } + std::sort(updated_series.begin(), updated_series.end(), [](const RouteSeries &a, const RouteSeries &b) { + return a.path < b.path; + }); + + std::unordered_map updated_enum_info; + updated_enum_info.reserve(session->route_data.enum_info.size() + can_enum_info.size()); + for (auto &[path, info] : session->route_data.enum_info) { + if (!is_decoded_can_series_path(path)) { + updated_enum_info.emplace(path, std::move(info)); + } + } + for (auto &[path, info] : can_enum_info) { + updated_enum_info[path] = std::move(info); + } + + session->route_data.series = std::move(updated_series); + session->route_data.enum_info = std::move(updated_enum_info); + session->route_data.paths.clear(); + session->route_data.paths.reserve(session->route_data.series.size()); + for (const RouteSeries &series : session->route_data.series) { + session->route_data.paths.push_back(series.path); + } + std::sort(session->route_data.paths.begin(), session->route_data.paths.end()); + session->route_data.roots = collect_route_roots_for_paths(session->route_data.paths); + + rebuild_route_index(session); + rebuild_browser_nodes(session, state); + refresh_all_custom_curves(session, state); + sync_camera_feeds(session); + return true; +} + +void apply_dbc_override_change(AppSession *session, UiState *state, const std::string &dbc_override) { + session->dbc_override = dbc_override; + if (session->data_mode == SessionDataMode::Stream) { + start_stream_session(session, state, session->stream_source, session->stream_buffer_seconds, false); + } else if (!session->route_name.empty()) { + const bool ok = apply_route_can_decode_update(session, state); + if (ok) { + state->status_text = dbc_override.empty() ? "DBC auto-detect enabled" : "DBC set to " + dbc_override; + } else { + state->status_text = "Failed to apply DBC"; + } + } else if (dbc_override.empty()) { + state->status_text = "DBC auto-detect enabled"; + } else { + state->status_text = "DBC set to " + dbc_override; + } +} + +void configure_style() { + ImGui::StyleColorsLight(); + ImPlot::StyleColorsLight(); + + ImGuiIO &io = ImGui::GetIO(); + g_ui_font = nullptr; + g_ui_bold_font = nullptr; + g_mono_font = nullptr; + const fs::path fonts_dir = repo_root() / "selfdrive" / "assets" / "fonts"; + ImFontConfig font_cfg; + font_cfg.OversampleH = 2; + font_cfg.OversampleV = 2; + font_cfg.RasterizerDensity = 1.0f; + icon_add_font(16.0f); + const auto add_font_with_icons = [&](const fs::path &path, float size) -> ImFont * { + ImFont *font = io.Fonts->AddFontFromFileTTF(path.c_str(), size, &font_cfg); + if (font != nullptr) { + icon_add_font(size, true, font); + } + return font; + }; + if (ImFont *font = add_font_with_icons(fonts_dir / "Inter-Regular.ttf", 16.0f); font != nullptr) { + g_ui_font = font; + io.FontDefault = font; + } + g_ui_bold_font = add_font_with_icons(fonts_dir / "Inter-SemiBold.ttf", 16.75f); + if (g_ui_font == nullptr) { + if (ImFont *font = add_font_with_icons(fonts_dir / "JetBrainsMono-Medium.ttf", 15.75f); font != nullptr) { + g_mono_font = font; + io.FontDefault = font; + } + } + if (g_mono_font == nullptr) { + g_mono_font = add_font_with_icons(fonts_dir / "JetBrainsMono-Medium.ttf", 15.75f); + } + if (g_ui_bold_font == nullptr) { + g_ui_bold_font = g_ui_font; + } + + ImGuiStyle &style = ImGui::GetStyle(); + style.WindowRounding = 0.0f; + style.ChildRounding = 0.0f; + style.PopupRounding = 0.0f; + style.FrameRounding = 2.0f; + style.ScrollbarRounding = 2.0f; + style.GrabRounding = 2.0f; + style.TabRounding = 0.0f; + style.WindowBorderSize = 1.0f; + style.ChildBorderSize = 1.0f; + style.FrameBorderSize = 1.0f; + style.WindowPadding = ImVec2(8.0f, 7.0f); + style.FramePadding = ImVec2(6.0f, 3.0f); + style.ItemSpacing = ImVec2(8.0f, 5.0f); + style.ItemInnerSpacing = ImVec2(6.0f, 3.0f); + struct ColorDef { ImGuiCol idx; int r, g, b; }; + constexpr ColorDef COLORS[] = { + {ImGuiCol_WindowBg, 250, 250, 251}, {ImGuiCol_ChildBg, 255, 255, 255}, + {ImGuiCol_Border, 194, 198, 204}, {ImGuiCol_TitleBg, 252, 252, 253}, + {ImGuiCol_TitleBgActive, 252, 252, 253}, {ImGuiCol_TitleBgCollapsed, 252, 252, 253}, + {ImGuiCol_Text, 74, 80, 88}, {ImGuiCol_TextDisabled, 108, 118, 128}, + {ImGuiCol_Button, 255, 255, 255}, {ImGuiCol_ButtonHovered, 246, 248, 250}, + {ImGuiCol_ButtonActive, 238, 240, 244}, {ImGuiCol_FrameBg, 255, 255, 255}, + {ImGuiCol_FrameBgHovered, 248, 249, 251}, {ImGuiCol_FrameBgActive, 241, 244, 248}, + {ImGuiCol_Header, 243, 245, 248}, {ImGuiCol_HeaderHovered, 237, 240, 244}, + {ImGuiCol_HeaderActive, 232, 236, 240}, {ImGuiCol_PopupBg, 248, 249, 251}, + {ImGuiCol_MenuBarBg, 232, 236, 241}, {ImGuiCol_Separator, 194, 198, 204}, + {ImGuiCol_ScrollbarBg, 240, 242, 245}, {ImGuiCol_ScrollbarGrab, 202, 207, 214}, + {ImGuiCol_ScrollbarGrabHovered, 180, 186, 194}, {ImGuiCol_ScrollbarGrabActive, 164, 171, 180}, + {ImGuiCol_Tab, 219, 224, 230}, {ImGuiCol_TabHovered, 232, 236, 241}, + {ImGuiCol_TabSelected, 250, 251, 253}, {ImGuiCol_TabSelectedOverline, 92, 109, 136}, + {ImGuiCol_TabDimmed, 213, 219, 226}, {ImGuiCol_TabDimmedSelected, 244, 247, 249}, + {ImGuiCol_TabDimmedSelectedOverline, 92, 109, 136}, {ImGuiCol_DockingEmptyBg, 244, 246, 248}, + }; + for (const auto &c : COLORS) { style.Colors[c.idx] = color_rgb(c.r, c.g, c.b); } + style.Colors[ImGuiCol_DockingPreview] = color_rgb(69, 115, 184, 0.22f); + + ImPlotStyle &plot_style = ImPlot::GetStyle(); + plot_style.PlotBorderSize = 1.0f; + plot_style.MinorAlpha = 0.65f; + plot_style.LegendPadding = ImVec2(6.0f, 5.0f); + plot_style.LegendInnerPadding = ImVec2(6.0f, 3.0f); + plot_style.LegendSpacing = ImVec2(7.0f, 2.0f); + plot_style.PlotPadding = ImVec2(4.0f, 8.0f); + plot_style.FitPadding = ImVec2(0.02f, 0.4f); + + ImPlot::MapInputDefault(); + ImPlotInputMap &input_map = ImPlot::GetInputMap(); + input_map.Pan = ImGuiMouseButton_Right; + input_map.PanMod = ImGuiMod_None; + input_map.Select = ImGuiMouseButton_Left; + input_map.SelectCancel = ImGuiMouseButton_Right; + input_map.SelectMod = ImGuiMod_None; +} + +void app_push_mono_font() { + if (g_mono_font != nullptr) { + ImGui::PushFont(g_mono_font); + } +} + +void app_pop_mono_font() { + if (g_mono_font != nullptr) { + ImGui::PopFont(); + } +} + +void app_push_bold_font() { + if (g_ui_bold_font != nullptr) { + ImGui::PushFont(g_ui_bold_font); + } +} + +void app_pop_bold_font() { + if (g_ui_bold_font != nullptr) { + ImGui::PopFont(); + } +} + +UiMetrics compute_ui_metrics(const ImVec2 &size, float top_offset, float sidebar_width) { + UiMetrics ui; + ui.width = size.x; + ui.height = size.y; + ui.top_offset = top_offset; + ui.sidebar_width = sidebar_width <= 0.0f + ? 0.0f + : std::clamp(sidebar_width, SIDEBAR_MIN_WIDTH, std::min(SIDEBAR_MAX_WIDTH, size.x * 0.6f)); + ui.content_x = ui.sidebar_width; + ui.content_y = top_offset; + ui.content_w = std::max(1.0f, size.x - ui.content_x); + ui.content_h = std::max(1.0f, size.y - ui.content_y - STATUS_BAR_HEIGHT); + ui.status_bar_y = std::max(0.0f, size.y - STATUS_BAR_HEIGHT); + return ui; +} + +void sync_ui_state(UiState *state, const SketchLayout &layout) { + const bool initializing = state->tabs.empty(); + state->tabs.resize(layout.tabs.size()); + if (layout.tabs.empty()) { + state->active_tab_index = 0; + state->requested_tab_index = -1; + return; + } + if (initializing) { + state->active_tab_index = std::clamp(layout.current_tab_index, 0, static_cast(layout.tabs.size()) - 1); + state->requested_tab_index = state->active_tab_index; + } + state->active_tab_index = std::clamp(state->active_tab_index, 0, static_cast(layout.tabs.size()) - 1); + for (size_t i = 0; i < layout.tabs.size(); ++i) { + if (state->tabs[i].runtime_id == 0) { + state->tabs[i].runtime_id = state->next_tab_runtime_id++; + } + const int pane_count = static_cast(layout.tabs[i].panes.size()); + state->tabs[i].map_panes.resize(static_cast(std::max(0, pane_count))); + state->tabs[i].camera_panes.resize(static_cast(std::max(0, pane_count))); + state->tabs[i].active_pane_index = pane_count <= 0 + ? 0 + : std::clamp(state->tabs[i].active_pane_index, 0, pane_count - 1); + } +} + +void resize_tab_pane_state(TabUiState *tab_state, size_t pane_count) { + if (tab_state == nullptr) return; + tab_state->map_panes.resize(pane_count); + tab_state->camera_panes.resize(pane_count); +} + +void sync_route_buffers(UiState *state, const AppSession &session) { + state->route_buffer = session.route_name; + state->data_dir_buffer = session.data_dir; +} + +void sync_stream_buffers(UiState *state, const AppSession &session) { + state->stream_address_buffer = session.stream_source.address; + state->stream_source_kind = session.stream_source.kind; + state->stream_buffer_seconds = session.stream_buffer_seconds; +} + +fs::path default_layout_save_path(const AppSession &session) { + return session.layout_path.empty() ? layouts_dir() / "new-layout.json" : session.layout_path; +} + +void sync_layout_buffers(UiState *state, const AppSession &session) { + state->load_layout_buffer = session.layout_path.empty() ? std::string() : session.layout_path.string(); + state->save_layout_buffer = default_layout_save_path(session).string(); +} + +const WorkspaceTab *app_active_tab(const SketchLayout &layout, const UiState &state) { + if (layout.tabs.empty()) return nullptr; + const int index = std::clamp(state.active_tab_index, 0, static_cast(layout.tabs.size()) - 1); + return &layout.tabs[static_cast(index)]; +} + +WorkspaceTab *app_active_tab(SketchLayout *layout, const UiState &state) { + if (layout->tabs.empty()) return nullptr; + const int index = std::clamp(state.active_tab_index, 0, static_cast(layout->tabs.size()) - 1); + return &layout->tabs[static_cast(index)]; +} + +TabUiState *app_active_tab_state(UiState *state) { + if (state->tabs.empty()) return nullptr; + const int index = std::clamp(state->active_tab_index, 0, static_cast(state->tabs.size()) - 1); + return &state->tabs[static_cast(index)]; +} + +std::string pane_window_name(int tab_runtime_id, int pane_index, const Pane &pane) { + const char *title = pane.title.empty() ? UNTITLED_PANE_TITLE : pane.title.c_str(); + return util::string_format("%s###tab%d_pane%d", title, tab_runtime_id, pane_index); +} + +std::string tab_item_label(const WorkspaceTab &tab, int tab_runtime_id) { + return util::string_format("%s##workspace_tab_%d", tab.tab_name.c_str(), tab_runtime_id); +} + +void request_tab_selection(UiState *state, int tab_index) { + state->active_tab_index = tab_index; + state->requested_tab_index = tab_index; +} + +void begin_rename_tab(const SketchLayout &layout, UiState *state, int tab_index) { + if (tab_index < 0 || tab_index >= static_cast(layout.tabs.size())) { + return; + } + state->rename_tab_buffer = layout.tabs[static_cast(tab_index)].tab_name; + state->rename_tab_index = tab_index; + state->focus_rename_tab_input = true; + request_tab_selection(state, tab_index); +} + +void cancel_rename_tab(UiState *state) { + state->rename_tab_index = -1; + state->focus_rename_tab_input = false; +} + +ImGuiID dockspace_id_for_tab(int tab_runtime_id) { + return ImHashStr(util::string_format("jotpluggler_dockspace_%d", tab_runtime_id).c_str()); +} + +bool curve_has_local_samples(const Curve &curve) { + return curve.xs.size() > 1 && curve.xs.size() == curve.ys.size(); +} + +void mark_all_docks_dirty(UiState *state) { + for (TabUiState &tab_state : state->tabs) { + tab_state.dock_needs_build = true; + } +} + +void mark_tab_dock_dirty(UiState *state, int tab_index) { + if (tab_index >= 0 && tab_index < static_cast(state->tabs.size())) { + state->tabs[static_cast(tab_index)].dock_needs_build = true; + } +} + +void normalize_split_node(WorkspaceNode *node) { + if (node->is_pane) { + return; + } + for (WorkspaceNode &child : node->children) { + normalize_split_node(&child); + } + if (node->children.empty()) { + return; + } + if (node->children.size() == 1) { + *node = node->children.front(); + return; + } + if (node->sizes.size() != node->children.size()) { + node->sizes.assign(node->children.size(), 1.0f / static_cast(node->children.size())); + return; + } + float total = 0.0f; + for (float &size : node->sizes) { + size = std::max(size, 0.0f); + total += size; + } + if (total <= 0.0f) { + node->sizes.assign(node->children.size(), 1.0f / static_cast(node->children.size())); + return; + } + for (float &size : node->sizes) { + size /= total; + } +} + +void decrement_pane_indices(WorkspaceNode *node, int removed_index) { + if (node->is_pane) { + if (node->pane_index > removed_index) { + node->pane_index -= 1; + } + return; + } + for (WorkspaceNode &child : node->children) { + decrement_pane_indices(&child, removed_index); + } +} + +bool remove_pane_node(WorkspaceNode *node, int pane_index) { + if (node->is_pane) return node->pane_index == pane_index; + + for (size_t i = 0; i < node->children.size();) { + if (remove_pane_node(&node->children[i], pane_index)) { + node->children.erase(node->children.begin() + static_cast(i)); + if (i < node->sizes.size()) { + node->sizes.erase(node->sizes.begin() + static_cast(i)); + } + } else { + ++i; + } + } + + normalize_split_node(node); + return !node->is_pane && node->children.empty(); +} + +bool split_pane_node(WorkspaceNode *node, int target_pane_index, SplitOrientation orientation, + bool new_before, int new_pane_index) { + if (node->is_pane) { + if (node->pane_index != target_pane_index) return false; + WorkspaceNode existing_pane; + existing_pane.is_pane = true; + existing_pane.pane_index = target_pane_index; + + WorkspaceNode new_pane; + new_pane.is_pane = true; + new_pane.pane_index = new_pane_index; + + node->is_pane = false; + node->pane_index = -1; + node->orientation = orientation; + node->sizes = {0.5f, 0.5f}; + node->children.clear(); + if (new_before) { + node->children.push_back(std::move(new_pane)); + node->children.push_back(std::move(existing_pane)); + } else { + node->children.push_back(std::move(existing_pane)); + node->children.push_back(std::move(new_pane)); + } + return true; + } + + if (node->orientation == orientation) { + for (size_t i = 0; i < node->children.size(); ++i) { + WorkspaceNode &child = node->children[i]; + if (!child.is_pane || child.pane_index != target_pane_index) { + continue; + } + + WorkspaceNode new_pane; + new_pane.is_pane = true; + new_pane.pane_index = new_pane_index; + + const auto insert_it = node->children.begin() + static_cast(new_before ? i : i + 1); + node->children.insert(insert_it, std::move(new_pane)); + node->sizes.assign(node->children.size(), 1.0f / static_cast(node->children.size())); + return true; + } + } + + for (WorkspaceNode &child : node->children) { + if (split_pane_node(&child, target_pane_index, orientation, new_before, new_pane_index)) return true; + } + return false; +} + +Pane make_empty_pane(const std::string &title = UNTITLED_PANE_TITLE) { + Pane pane; + pane.title = title; + return pane; +} + +WorkspaceTab make_empty_tab(const std::string &tab_name) { + WorkspaceTab tab; + tab.tab_name = tab_name; + tab.panes.push_back(make_empty_pane()); + tab.root.is_pane = true; + tab.root.pane_index = 0; + return tab; +} + +SketchLayout make_empty_layout() { + SketchLayout layout; + layout.tabs.push_back(make_empty_tab("tab1")); + layout.current_tab_index = 0; + layout.roots.push_back("layout"); + return layout; +} + +bool tab_name_exists(const SketchLayout &layout, const std::string &name) { + return std::any_of(layout.tabs.begin(), layout.tabs.end(), [&](const WorkspaceTab &tab) { + return tab.tab_name == name; + }); +} + +std::string next_tab_name(const SketchLayout &layout, const std::string &base_name) { + if (base_name == "tab" || base_name == "tab1") { + int max_suffix = 0; + for (const WorkspaceTab &tab : layout.tabs) { + if (tab.tab_name.size() > 3 && util::starts_with(tab.tab_name, "tab")) { + const std::string suffix = tab.tab_name.substr(3); + if (!suffix.empty() && std::all_of(suffix.begin(), suffix.end(), ::isdigit)) { + max_suffix = std::max(max_suffix, std::stoi(suffix)); + } + } + } + return "tab" + std::to_string(std::max(1, max_suffix + 1)); + } + std::string base = base_name.empty() ? "tab" : base_name; + if (!tab_name_exists(layout, base)) return base; + for (int i = 2; i < 1000; ++i) { + const std::string candidate = base + " " + std::to_string(i); + if (!tab_name_exists(layout, candidate)) return candidate; + } + return base + " copy"; +} + +void clear_layout_autosave(const AppSession &session) { + if (!session.autosave_path.empty() && fs::exists(session.autosave_path)) { + fs::remove(session.autosave_path); + } +} + +bool autosave_layout(AppSession *session, UiState *state) { + try { + if (session->autosave_path.empty()) { + session->autosave_path = autosave_path_for_layout(session->layout_path); + } + session->layout.current_tab_index = state->active_tab_index; + save_layout_json(session->layout, session->autosave_path); + state->layout_dirty = true; + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + state->status_text = "Failed to save layout draft"; + return false; + } +} + +bool mark_layout_dirty(AppSession *session, UiState *state) { + return autosave_layout(session, state); +} + +bool active_tab_has_map_pane(const SketchLayout &layout) { + if (layout.tabs.empty()) { + return false; + } + const int tab_index = std::clamp(layout.current_tab_index, 0, static_cast(layout.tabs.size()) - 1); + const WorkspaceTab &tab = layout.tabs[static_cast(tab_index)]; + return std::any_of(tab.panes.begin(), tab.panes.end(), [](const Pane &pane) { + return pane_kind_is_special(pane.kind); + }); +} + +void draw_browser_special_item(const char *item_id, const char *label) { + const ImGuiStyle &style = ImGui::GetStyle(); + const ImVec2 row_size(std::max(1.0f, ImGui::GetContentRegionAvail().x), ImGui::GetFrameHeight()); + ImGui::PushID(item_id); + ImGui::InvisibleButton("##special_data_row", row_size); + const bool hovered = ImGui::IsItemHovered(); + const bool held = ImGui::IsItemActive(); + const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + if (hovered) { + const ImU32 bg = ImGui::GetColorU32(held ? ImGuiCol_HeaderActive : ImGuiCol_HeaderHovered); + draw_list->AddRectFilled(rect.Min, rect.Max, bg, 0.0f); + } + ImGui::RenderTextEllipsis(draw_list, + ImVec2(rect.Min.x + style.FramePadding.x, rect.Min.y + style.FramePadding.y), + ImVec2(rect.Max.x - style.FramePadding.x, rect.Max.y), + rect.Max.x - style.FramePadding.x, + label, + nullptr, + nullptr); + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { + ImGui::SetDragDropPayload("JOTP_SPECIAL_ITEM", item_id, std::strlen(item_id) + 1); + ImGui::TextUnformatted(label); + ImGui::EndDragDropSource(); + } + ImGui::PopID(); +} + +std::array app_next_curve_color(const Pane &pane) { + static constexpr std::array, 10> PALETTE = {{ + {35, 107, 180}, + {220, 82, 52}, + {67, 160, 71}, + {243, 156, 18}, + {123, 97, 255}, + {0, 150, 136}, + {214, 48, 49}, + {52, 73, 94}, + {197, 90, 17}, + {96, 125, 139}, + }}; + return PALETTE[pane.curves.size() % PALETTE.size()]; +} + +void draw_sidebar(AppSession *session, const UiMetrics &ui, UiState *state, bool show_camera_feed) { + ImGui::SetNextWindowPos(ImVec2(0.0f, ui.top_offset)); + ImGui::SetNextWindowSize(ImVec2(ui.sidebar_width, std::max(1.0f, ui.height - ui.top_offset))); + ImGui::PushStyleColor(ImGuiCol_WindowBg, color_rgb(238, 240, 244)); + ImGui::PushStyleColor(ImGuiCol_Border, color_rgb(190, 197, 205)); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoSavedSettings; + if (ImGui::Begin("##sidebar", nullptr, flags)) { + const RouteLoadSnapshot load = session->route_loader ? session->route_loader->snapshot() : RouteLoadSnapshot{}; + const bool show_load_progress = session->route_loader && (load.active || load.total_segments > 0); + const bool streaming = session->data_mode == SessionDataMode::Stream; + CameraFeedView *sidebar_camera = session->pane_camera_feeds[static_cast(sidebar_preview_camera_view(*session))].get(); + if (show_camera_feed && sidebar_camera != nullptr) { + sidebar_camera->draw(ImGui::GetContentRegionAvail().x, load.active); + } else if (streaming) { + ImGui::SeparatorText("Camera"); + ImGui::TextDisabled("Camera not available during live stream."); + ImGui::Spacing(); + } + + ImGui::SeparatorText(streaming ? "Stream" : "Route"); + if (streaming) { + const StreamPollSnapshot stream = session->stream_poller ? session->stream_poller->snapshot() : StreamPollSnapshot{}; + const bool paused = stream.paused || session->stream_paused; + const bool live = stream.connected && !paused; + const ImVec4 status_color = live ? color_rgb(38, 135, 67) : (paused ? color_rgb(168, 119, 34) : color_rgb(155, 63, 63)); + ImGui::TextColored(status_color, "%s %s", live ? "●" : "○", stream.source_label.c_str()); + ImGui::TextDisabled("%s%s", stream_source_kind_label(stream.source_kind), paused ? " paused" : ""); + const double span = session->route_data.has_time_range ? (session->route_data.x_max - session->route_data.x_min) : 0.0; + const float fill = stream.buffer_seconds <= 0.0 + ? 0.0f + : std::clamp(static_cast(span / stream.buffer_seconds), 0.0f, 1.0f); + ImGui::ProgressBar(fill, ImVec2(-FLT_MIN, 0.0f), nullptr); + ImGui::TextDisabled("%.0fs buffer | %zu series", session->stream_buffer_seconds, session->route_data.series.size()); + const char *button_label = paused ? "Resume" : "Pause"; + if (ImGui::Button(button_label, ImVec2(std::max(1.0f, ImGui::GetContentRegionAvail().x), 0.0f))) { + if (paused) { + start_stream_session(session, state, session->stream_source, session->stream_buffer_seconds, true); + } else { + stop_stream_session(session, state); + state->status_text = "Paused stream " + stream_source_target_label(session->stream_source); + } + } + } else if (session->route_name.empty()) { + ImGui::TextDisabled("No route loaded"); + } + if (!session->route_data.car_fingerprint.empty()) { + ImGui::TextWrapped("Car: %s", session->route_data.car_fingerprint.c_str()); + } + const std::vector dbc_names = available_dbc_names(); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::BeginCombo("##dbc_combo", dbc_combo_label(*session).c_str())) { + const bool auto_selected = session->dbc_override.empty(); + if (ImGui::Selectable("Auto", auto_selected)) { + apply_dbc_override_change(session, state, {}); + } + if (auto_selected) { + ImGui::SetItemDefaultFocus(); + } + ImGui::Separator(); + for (const std::string &dbc_name : dbc_names) { + const bool selected = session->dbc_override == dbc_name; + if (ImGui::Selectable(dbc_name.c_str(), selected) && !selected) { + apply_dbc_override_change(session, state, dbc_name); + } + if (selected) { + ImGui::SetItemDefaultFocus(); + } + } + ImGui::EndCombo(); + } + ImGui::SeparatorText("Layout"); + ImGui::SetNextItemWidth(-FLT_MIN); + const std::string layout_combo_label = [&] { + const std::string base = session->layout_path.empty() ? std::string("untitled") : session->layout_path.stem().string(); + return state->layout_dirty ? base + " *" : base; + }(); + if (ImGui::BeginCombo("##layout_combo", layout_combo_label.c_str())) { + if (ImGui::Selectable("New Layout")) { + start_new_layout(session, state); + } + ImGui::Separator(); + const std::vector layouts = available_layout_names(); + const std::string current_layout = session->layout_path.empty() ? std::string("untitled") : session->layout_path.stem().string(); + for (const std::string &layout_name : layouts) { + const bool selected = layout_name == current_layout; + if (ImGui::Selectable(layout_name.c_str(), selected) && !selected) { + reload_layout(session, state, layout_name); + } + if (selected) { + ImGui::SetItemDefaultFocus(); + } + } + ImGui::EndCombo(); + } + const float layout_button_gap = ImGui::GetStyle().ItemSpacing.x; + const float layout_row_width = std::max(1.0f, ImGui::GetContentRegionAvail().x); + const float layout_button_width = std::max(1.0f, (layout_row_width - 2.0f * layout_button_gap) / 3.0f); + if (ImGui::Button("New", ImVec2(layout_button_width, 0.0f))) { + start_new_layout(session, state); + } + ImGui::SameLine(0.0f, layout_button_gap); + if (ImGui::Button("Save", ImVec2(layout_button_width, 0.0f))) { + state->request_save_layout = true; + } + ImGui::SameLine(0.0f, layout_button_gap); + ImGui::BeginDisabled(!state->layout_dirty); + if (ImGui::Button("Reset", ImVec2(layout_button_width, 0.0f))) { + state->request_reset_layout = true; + } + ImGui::EndDisabled(); + ImGui::Spacing(); + + ImGui::SeparatorText("Data Sources"); + ImGui::SetNextItemWidth(-FLT_MIN); + input_text_with_hint_string("##browser_filter", "Search...", &state->browser_filter); + const float footer_height = ImGui::GetFrameHeightWithSpacing() + + ImGui::GetTextLineHeightWithSpacing() + + 16.0f + + (show_load_progress ? (ImGui::GetFrameHeightWithSpacing() + 12.0f) : 0.0f); + const float browser_height = std::max(1.0f, ImGui::GetContentRegionAvail().y - footer_height); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(6.0f, 2.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8.0f, 3.0f)); + if (ImGui::BeginChild("##timeseries_browser", ImVec2(0.0f, browser_height), true)) { + const std::string filter = lowercase_copy(state->browser_filter); + std::vector visible_paths; + for (const BrowserNode &node : session->browser_nodes) { + collect_visible_leaf_paths(node, filter, &visible_paths); + } + for (const SpecialItemSpec &spec : kSpecialItemSpecs) { + draw_browser_special_item(spec.id, spec.label); + } + ImGui::Dummy(ImVec2(0.0f, 2.0f)); + ImGui::Separator(); + ImGui::Dummy(ImVec2(0.0f, 2.0f)); + for (const BrowserNode &node : session->browser_nodes) { + draw_browser_node(session, node, state, filter, visible_paths); + } + } + ImGui::EndChild(); + ImGui::PopStyleVar(2); + + ImGui::SeparatorText("Custom Series"); + if (ImGui::Button("Create...", ImVec2(std::max(1.0f, ImGui::GetContentRegionAvail().x), 0.0f))) { + open_custom_series_editor(state, state->selected_browser_path); + } + if (show_load_progress) { + const float total = static_cast(std::max(1, load.total_segments)); + const bool finalizing = load.active + && load.total_segments > 0 + && load.segments_downloaded >= load.total_segments + && load.segments_parsed >= load.total_segments; + const float progress = load.total_segments == 0 + ? 0.0f + : (finalizing + ? 0.99f + : std::clamp(static_cast(load.segments_downloaded + load.segments_parsed) / (2.0f * total), 0.0f, 0.99f)); + ImGui::Dummy(ImVec2(0.0f, 8.0f)); + ImGui::ProgressBar(progress, ImVec2(-FLT_MIN, 0.0f), finalizing ? "Finalizing..." : nullptr); + } + } + ImGui::End(); + ImGui::PopStyleColor(2); +} + +std::string app_curve_display_name(const Curve &curve) { + if (!curve.label.empty()) return curve.label; + if (!curve.name.empty()) return curve.name; + return "curve"; +} + +Curve make_curve_for_path(const Pane &pane, const std::string &path) { + Curve curve; + curve.name = path; + curve.label = path; + curve.color = app_next_curve_color(pane); + return curve; +} + +bool add_curve_to_pane(WorkspaceTab *tab, int pane_index, Curve curve) { + if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { + return false; + } + Pane &pane = tab->panes[static_cast(pane_index)]; + if (pane.kind != PaneKind::Plot) { + pane.kind = PaneKind::Plot; + if (is_default_special_title(pane.title)) { + pane.title = UNTITLED_PANE_TITLE; + } + } + for (Curve &existing : pane.curves) { + const bool same_named_curve = !curve.name.empty() && existing.name == curve.name; + const bool same_unnamed_curve = curve.name.empty() && existing.name.empty() && existing.label == curve.label; + if (same_named_curve || same_unnamed_curve) { + existing.visible = true; + return false; + } + } + pane.curves.push_back(std::move(curve)); + return true; +} + +bool add_path_curve_to_pane(AppSession *session, UiState *state, int pane_index, const std::string &path) { + if (app_find_route_series(*session, path) == nullptr) { + state->status_text = "Path not found in route"; + return false; + } + WorkspaceTab *tab = app_active_tab(&session->layout, *state); + if (tab == nullptr || pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { + state->status_text = "No active pane"; + return false; + } + const SketchLayout before_layout = session->layout; + const bool inserted = add_curve_to_pane(tab, pane_index, make_curve_for_path(tab->panes[static_cast(pane_index)], path)); + bool autosave_ok = true; + if (inserted) { + state->undo.push(before_layout); + autosave_ok = mark_layout_dirty(session, state); + } + if (autosave_ok) { + state->status_text = inserted ? "Added " + path : "Curve already present"; + } + return true; +} + +int add_path_curves_to_pane(AppSession *session, UiState *state, int pane_index, const std::vector &paths) { + WorkspaceTab *tab = app_active_tab(&session->layout, *state); + if (tab == nullptr || pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { + state->status_text = "No active pane"; + return 0; + } + + int inserted_count = 0; + int duplicate_count = 0; + const SketchLayout before_layout = session->layout; + for (const std::string &path : paths) { + if (app_find_route_series(*session, path) == nullptr) continue; + if (add_curve_to_pane(tab, pane_index, make_curve_for_path(tab->panes[static_cast(pane_index)], path))) { + ++inserted_count; + } else { + ++duplicate_count; + } + } + + if (inserted_count > 0) { + state->undo.push(before_layout); + if (mark_layout_dirty(session, state)) { + state->status_text = inserted_count == 1 + ? "Added " + paths.front() + : "Added " + std::to_string(inserted_count) + " curves"; + } + return inserted_count; + } + + if (duplicate_count > 0) { + state->status_text = duplicate_count == 1 ? "Curve already present" : "Curves already present"; + } else { + state->status_text = "No matching series found"; + } + return 0; +} + +bool app_add_curve_to_active_pane(AppSession *session, UiState *state, const std::string &path) { + const TabUiState *tab_state = app_active_tab_state(state); + if (tab_state == nullptr) { + state->status_text = "No active pane"; + return false; + } + return add_path_curve_to_pane(session, state, tab_state->active_pane_index, path); +} + +bool apply_special_item_to_pane(WorkspaceTab *tab, TabUiState *tab_state, int pane_index, std::string_view item_id) { + if (tab == nullptr || tab_state == nullptr) return false; + if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) return false; + const SpecialItemSpec *spec = special_item_spec(item_id); + if (spec == nullptr) return false; + Pane &pane = tab->panes[static_cast(pane_index)]; + if (!((pane.kind == PaneKind::Plot && pane.curves.empty()) || pane_kind_is_special(pane.kind))) { + return false; + } + if (pane.kind == spec->kind && (spec->kind != PaneKind::Camera || pane.camera_view == spec->camera_view)) { + tab_state->active_pane_index = pane_index; + return false; + } + const PaneKind previous_kind = pane.kind; + pane.kind = spec->kind; + pane.camera_view = spec->camera_view; + if (spec->kind == PaneKind::Map) { + if (pane.title == UNTITLED_PANE_TITLE || previous_kind != PaneKind::Plot) { + pane.title = spec->label; + } + } else { + pane.title = spec->label; + resize_tab_pane_state(tab_state, tab->panes.size()); + tab_state->camera_panes[static_cast(pane_index)].fit_to_pane = true; + } + tab_state->active_pane_index = pane_index; + return true; +} + +bool split_pane(WorkspaceTab *tab, int pane_index, PaneDropZone zone, std::optional curve = std::nullopt) { + if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { + return false; + } + if (zone == PaneDropZone::Center) return false; + + const int new_pane_index = static_cast(tab->panes.size()); + Pane new_pane = make_empty_pane(); + if (curve.has_value()) { + new_pane.curves.push_back(*curve); + } + tab->panes.push_back(std::move(new_pane)); + + const bool vertical = zone == PaneDropZone::Top || zone == PaneDropZone::Bottom; + const bool new_before = zone == PaneDropZone::Left || zone == PaneDropZone::Top; + return split_pane_node(&tab->root, pane_index, + vertical ? SplitOrientation::Vertical : SplitOrientation::Horizontal, + new_before, new_pane_index); +} + +bool close_pane(WorkspaceTab *tab, int pane_index) { + if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { + return false; + } + if (tab->panes.size() <= 1) { + tab->panes[static_cast(pane_index)] = make_empty_pane(); + return true; + } + if (remove_pane_node(&tab->root, pane_index)) return false; + tab->panes.erase(tab->panes.begin() + static_cast(pane_index)); + decrement_pane_indices(&tab->root, pane_index); + normalize_split_node(&tab->root); + return true; +} + +void clear_pane(WorkspaceTab *tab, int pane_index) { + if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { + return; + } + Pane &pane = tab->panes[static_cast(pane_index)]; + pane.curves.clear(); + pane.title = UNTITLED_PANE_TITLE; +} + +void create_runtime_tab(SketchLayout *layout, UiState *state) { + const std::string tab_name = next_tab_name(*layout, "tab1"); + layout->tabs.push_back(make_empty_tab(tab_name)); + state->tabs.push_back(TabUiState{.dock_needs_build = true, .active_pane_index = 0, .runtime_id = state->next_tab_runtime_id++}); + request_tab_selection(state, static_cast(layout->tabs.size()) - 1); + state->status_text = "Created " + tab_name; +} + +void duplicate_runtime_tab(SketchLayout *layout, UiState *state) { + if (layout->tabs.empty()) { + return; + } + const int source_index = std::clamp(state->active_tab_index, 0, static_cast(layout->tabs.size()) - 1); + WorkspaceTab copy = layout->tabs[static_cast(source_index)]; + copy.tab_name = next_tab_name(*layout, copy.tab_name + " copy"); + layout->tabs.push_back(std::move(copy)); + const int active_pane_index = source_index < static_cast(state->tabs.size()) ? state->tabs[static_cast(source_index)].active_pane_index : 0; + state->tabs.push_back(TabUiState{.dock_needs_build = true, .active_pane_index = active_pane_index, .runtime_id = state->next_tab_runtime_id++}); + request_tab_selection(state, static_cast(layout->tabs.size()) - 1); + state->status_text = "Duplicated tab"; +} + +void close_runtime_tab(SketchLayout *layout, UiState *state) { + if (layout->tabs.empty()) { + return; + } + const int tab_index = std::clamp(state->active_tab_index, 0, static_cast(layout->tabs.size()) - 1); + if (layout->tabs.size() == 1) { + layout->tabs[0] = make_empty_tab(layout->tabs[0].tab_name.empty() ? "tab1" : layout->tabs[0].tab_name); + if (state->tabs.empty()) { + state->tabs.push_back(TabUiState{.dock_needs_build = true, .active_pane_index = 0}); + } else { + state->tabs.resize(1); + state->tabs[0] = TabUiState{ + .dock_needs_build = true, + .active_pane_index = 0, + .runtime_id = state->tabs[0].runtime_id == 0 ? state->next_tab_runtime_id++ : state->tabs[0].runtime_id, + }; + } + state->active_tab_index = 0; + state->requested_tab_index = 0; + layout->current_tab_index = 0; + cancel_rename_tab(state); + state->status_text = "Closed tab"; + return; + } + layout->tabs.erase(layout->tabs.begin() + static_cast(tab_index)); + if (tab_index < static_cast(state->tabs.size())) { + state->tabs.erase(state->tabs.begin() + static_cast(tab_index)); + } + if (state->active_tab_index >= static_cast(layout->tabs.size())) { + state->active_tab_index = static_cast(layout->tabs.size()) - 1; + } + sync_ui_state(state, *layout); + state->requested_tab_index = state->active_tab_index; + state->status_text = "Closed tab"; +} + +void rename_runtime_tab(SketchLayout *layout, UiState *state) { + if (state->rename_tab_index < 0 || state->rename_tab_index >= static_cast(layout->tabs.size())) { + return; + } + layout->tabs[static_cast(state->rename_tab_index)].tab_name = state->rename_tab_buffer; + state->status_text = "Renamed tab"; + layout->current_tab_index = state->rename_tab_index; + cancel_rename_tab(state); +} + +void draw_inline_tab_editor(AppSession *session, UiState *state, const ImRect &tab_rect) { + const int rename_tab_index = state->rename_tab_index; + if (rename_tab_index < 0 || rename_tab_index >= static_cast(session->layout.tabs.size())) { + return; + } + + const float width = std::max(48.0f, tab_rect.Max.x - tab_rect.Min.x - 10.0f); + const ImVec2 pos = ImVec2(tab_rect.Min.x + 5.0f, tab_rect.Min.y + 2.0f); + ImGui::SetCursorScreenPos(pos); + ImGui::PushItemWidth(width); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4.0f, 2.0f)); + if (state->focus_rename_tab_input) { + ImGui::SetKeyboardFocusHere(); + state->focus_rename_tab_input = false; + } + const bool submitted = input_text_string("##rename_tab_inline", + &state->rename_tab_buffer, + ImGuiInputTextFlags_AutoSelectAll | ImGuiInputTextFlags_EnterReturnsTrue); + const bool active = ImGui::IsItemActive(); + const bool escape = active && ImGui::IsKeyPressed(ImGuiKey_Escape); + const bool deactivated = ImGui::IsItemDeactivated(); + ImGui::PopStyleVar(); + ImGui::PopItemWidth(); + + if (escape) { + cancel_rename_tab(state); + } else if (submitted || deactivated) { + const SketchLayout before_layout = session->layout; + rename_runtime_tab(&session->layout, state); + state->undo.push(before_layout); + mark_layout_dirty(session, state); + } +} + + +std::optional draw_pane_drop_target(int tab_index, int pane_index, const Pane &target_pane) { + if (ImGui::GetDragDropPayload() == nullptr) return std::nullopt; + + const ImVec2 window_pos = ImGui::GetWindowPos(); + const ImVec2 content_min = ImGui::GetWindowContentRegionMin(); + const ImVec2 content_max = ImGui::GetWindowContentRegionMax(); + ImRect content_rect(ImVec2(window_pos.x + content_min.x, window_pos.y + content_min.y), + ImVec2(window_pos.x + content_max.x, window_pos.y + content_max.y)); + content_rect.Expand(ImVec2(-6.0f, -6.0f)); + if (content_rect.GetWidth() < 60.0f || content_rect.GetHeight() < 60.0f) { + return std::nullopt; + } + + const float edge_w = std::min(90.0f, content_rect.GetWidth() * 0.24f); + const float edge_h = std::min(72.0f, content_rect.GetHeight() * 0.24f); + struct ZoneRect { + PaneDropZone zone; + ImRect rect; + }; + const std::array zones = {{ + {PaneDropZone::Left, ImRect(content_rect.Min, ImVec2(content_rect.Min.x + edge_w, content_rect.Max.y))}, + {PaneDropZone::Right, ImRect(ImVec2(content_rect.Max.x - edge_w, content_rect.Min.y), content_rect.Max)}, + {PaneDropZone::Top, ImRect(content_rect.Min, ImVec2(content_rect.Max.x, content_rect.Min.y + edge_h))}, + {PaneDropZone::Bottom, ImRect(ImVec2(content_rect.Min.x, content_rect.Max.y - edge_h), content_rect.Max)}, + {PaneDropZone::Center, ImRect(ImVec2(content_rect.Min.x + edge_w, content_rect.Min.y + edge_h), + ImVec2(content_rect.Max.x - edge_w, content_rect.Max.y - edge_h))}, + }}; + + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + for (const ZoneRect &zone : zones) { + if (zone.rect.GetWidth() <= 0.0f || zone.rect.GetHeight() <= 0.0f) { + continue; + } + + ImGui::PushID(static_cast(zone.zone) * 1000 + pane_index + tab_index * 100); + ImGui::SetCursorScreenPos(zone.rect.Min); + ImGui::InvisibleButton("##drop_zone", zone.rect.GetSize()); + if (ImGui::BeginDragDropTarget()) { + auto try_accept = [&](const char *type) -> const ImGuiPayload * { + const ImGuiPayload *p = ImGui::AcceptDragDropPayload(type, ImGuiDragDropFlags_AcceptBeforeDelivery); + if (p && p->Preview) { + draw_list->AddRectFilled(zone.rect.Min, zone.rect.Max, IM_COL32(70, 130, 220, 55)); + draw_list->AddRect(zone.rect.Min, zone.rect.Max, IM_COL32(45, 95, 175, 220), 0.0f, 0, 2.0f); + } + return p; + }; + auto deliver = [&](PaneDropAction action) -> std::optional { + action.zone = zone.zone; + action.target_pane_index = pane_index; + ImGui::EndDragDropTarget(); + ImGui::PopID(); + return action; + }; + if (const ImGuiPayload *p = try_accept("JOTP_BROWSER_PATHS"); p && p->Delivery) { + if (zone.zone != PaneDropZone::Center || target_pane.kind == PaneKind::Plot) { + PaneDropAction action; + action.from_browser = true; + action.browser_paths = decode_browser_drag_payload(static_cast(p->Data)); + return deliver(std::move(action)); + } + } + if (zone.zone != PaneDropZone::Center || (target_pane.kind == PaneKind::Plot && target_pane.curves.empty()) || pane_kind_is_special(target_pane.kind)) { + if (const ImGuiPayload *p = try_accept("JOTP_SPECIAL_ITEM"); p && p->Delivery) { + PaneDropAction action; + action.special_item_id = static_cast(p->Data); + return deliver(std::move(action)); + } + } + if (const ImGuiPayload *p = try_accept("JOTP_PANE_CURVE"); p && p->Delivery) { + if (zone.zone != PaneDropZone::Center || target_pane.kind == PaneKind::Plot) { + PaneDropAction action; + action.curve_ref = *static_cast(p->Data); + return deliver(std::move(action)); + } + } + ImGui::EndDragDropTarget(); + } + ImGui::PopID(); + } + return std::nullopt; +} + +bool commit_tab_layout_change(AppSession *session, + UiState *state, + WorkspaceTab *tab, + TabUiState *tab_state, + const SketchLayout &before_layout, + std::string_view status_text, + bool dock_changed) { + if (dock_changed) { + mark_tab_dock_dirty(state, state->active_tab_index); + } + resize_tab_pane_state(tab_state, tab->panes.size()); + state->undo.push(before_layout); + if (mark_layout_dirty(session, state)) { + state->status_text = std::string(status_text); + } + return true; +} + +bool apply_pane_menu_action(AppSession *session, UiState *state, int pane_index, + const PaneMenuAction &action) { + WorkspaceTab *tab = app_active_tab(&session->layout, *state); + TabUiState *tab_state = app_active_tab_state(state); + if (tab == nullptr || tab_state == nullptr) return false; + + const int original_pane_count = static_cast(tab->panes.size()); + const SketchLayout before_layout = session->layout; + bool dock_changed = false; + bool layout_changed = false; + std::string_view success_status = "Workspace updated"; + switch (action.kind) { + case PaneMenuActionKind::OpenAxisLimits: + tab_state->active_pane_index = pane_index; + open_axis_limits_editor(*session, state, pane_index); + state->status_text = "Axis limits editor opened"; + return true; + case PaneMenuActionKind::OpenCustomSeries: + tab_state->active_pane_index = pane_index; + open_custom_series_editor(state, preferred_custom_series_source(tab->panes[static_cast(pane_index)])); + state->status_text = "Custom series editor opened"; + return true; + case PaneMenuActionKind::SplitLeft: + case PaneMenuActionKind::SplitRight: + case PaneMenuActionKind::SplitTop: + case PaneMenuActionKind::SplitBottom: { + constexpr PaneDropZone kZones[] = {PaneDropZone::Left, PaneDropZone::Right, PaneDropZone::Top, PaneDropZone::Bottom}; + const auto zone = kZones[static_cast(action.kind) - static_cast(PaneMenuActionKind::SplitLeft)]; + if (split_pane(tab, pane_index, zone)) { + tab_state->active_pane_index = static_cast(tab->panes.size()) - 1; + dock_changed = true; + layout_changed = true; + } + break; + } + case PaneMenuActionKind::ResetView: + reset_shared_range(state, *session); + state->follow_latest = session->data_mode == SessionDataMode::Stream; + state->suppress_range_side_effects = true; + clamp_shared_range(state, *session); + persist_shared_range_to_tab(tab, *state); + clear_pane_vertical_limits(&tab->panes[static_cast(pane_index)]); + layout_changed = true; + success_status = "Plot view reset"; + break; + case PaneMenuActionKind::ResetHorizontal: + reset_shared_range(state, *session); + state->follow_latest = session->data_mode == SessionDataMode::Stream; + state->suppress_range_side_effects = true; + clamp_shared_range(state, *session); + persist_shared_range_to_tab(tab, *state); + layout_changed = true; + success_status = "Horizontal zoom reset"; + break; + case PaneMenuActionKind::ResetVertical: + clear_pane_vertical_limits(&tab->panes[static_cast(pane_index)]); + layout_changed = true; + success_status = "Vertical zoom reset"; + break; + case PaneMenuActionKind::Clear: + clear_pane(tab, pane_index); + tab_state->active_pane_index = pane_index; + layout_changed = true; + break; + case PaneMenuActionKind::Close: + if (close_pane(tab, pane_index)) { + tab_state->active_pane_index = std::clamp(pane_index, 0, static_cast(tab->panes.size()) - 1); + layout_changed = true; + dock_changed = static_cast(tab->panes.size()) != original_pane_count; + } + break; + case PaneMenuActionKind::None: + return false; + } + + if (!layout_changed) { + return false; + } + return commit_tab_layout_change(session, state, tab, tab_state, before_layout, success_status, dock_changed); +} + +bool apply_pane_drop_action(AppSession *session, UiState *state, const PaneDropAction &action) { + WorkspaceTab *tab = app_active_tab(&session->layout, *state); + TabUiState *tab_state = app_active_tab_state(state); + if (tab == nullptr || tab_state == nullptr) return false; + + if (!action.special_item_id.empty()) { + const SpecialItemSpec *spec = special_item_spec(action.special_item_id); + if (spec == nullptr) { + return false; + } + if (action.zone == PaneDropZone::Center) { + if (action.target_pane_index < 0 || action.target_pane_index >= static_cast(tab->panes.size())) { + return false; + } + if (!((tab->panes[static_cast(action.target_pane_index)].kind == PaneKind::Plot + && tab->panes[static_cast(action.target_pane_index)].curves.empty()) + || pane_kind_is_special(tab->panes[static_cast(action.target_pane_index)].kind))) { + state->status_text = std::string(special_item_label(action.special_item_id)) + " can only replace another special pane or use an empty pane"; + return false; + } + const SketchLayout before_layout = session->layout; + const bool changed = apply_special_item_to_pane(tab, tab_state, action.target_pane_index, spec->id); + if (!changed) { + state->status_text = std::string(special_item_label(action.special_item_id)) + " already shown in pane"; + return false; + } + return commit_tab_layout_change(session, state, tab, tab_state, before_layout, + std::string(special_item_label(action.special_item_id)) + " added to pane", + false); + } + const SketchLayout before_layout = session->layout; + if (split_pane(tab, action.target_pane_index, action.zone)) { + tab_state->active_pane_index = static_cast(tab->panes.size()) - 1; + const bool changed = apply_special_item_to_pane(tab, tab_state, tab_state->active_pane_index, spec->id); + if (!changed) { + return false; + } + return commit_tab_layout_change(session, state, tab, tab_state, before_layout, + "Split pane and added " + std::string(special_item_label(action.special_item_id)), + true); + } + return false; + } + + if (action.from_browser) { + if (action.browser_paths.empty()) return false; + if (action.zone == PaneDropZone::Center) { + const int inserted_count = add_path_curves_to_pane(session, state, action.target_pane_index, action.browser_paths); + if (inserted_count > 0) { + tab_state->active_pane_index = action.target_pane_index; + } + return inserted_count > 0; + } + const SketchLayout before_layout = session->layout; + if (split_pane(tab, action.target_pane_index, action.zone)) { + tab_state->active_pane_index = static_cast(tab->panes.size()) - 1; + int inserted_count = 0; + for (const std::string &path : action.browser_paths) { + if (app_find_route_series(*session, path) == nullptr) continue; + if (add_curve_to_pane(tab, tab_state->active_pane_index, + make_curve_for_path(tab->panes[static_cast(tab_state->active_pane_index)], path))) { + ++inserted_count; + } + } + if (inserted_count > 0) { + return commit_tab_layout_change(session, state, tab, tab_state, before_layout, + inserted_count == 1 + ? "Split pane and added " + action.browser_paths.front() + : "Split pane and added " + std::to_string(inserted_count) + " curves", + true); + } + return false; + } + return false; + } + + if (action.curve_ref.tab_index < 0 + || action.curve_ref.tab_index >= static_cast(session->layout.tabs.size())) { + return false; + } + WorkspaceTab &source_tab = session->layout.tabs[static_cast(action.curve_ref.tab_index)]; + if (action.curve_ref.pane_index < 0 + || action.curve_ref.pane_index >= static_cast(source_tab.panes.size())) { + return false; + } + const Pane &source_pane = source_tab.panes[static_cast(action.curve_ref.pane_index)]; + if (action.curve_ref.curve_index < 0 + || action.curve_ref.curve_index >= static_cast(source_pane.curves.size())) { + return false; + } + const Curve curve = source_pane.curves[static_cast(action.curve_ref.curve_index)]; + + if (action.zone == PaneDropZone::Center) { + const SketchLayout before_layout = session->layout; + const bool inserted = add_curve_to_pane(tab, action.target_pane_index, curve); + tab_state->active_pane_index = action.target_pane_index; + if (inserted) { + state->undo.push(before_layout); + if (mark_layout_dirty(session, state)) { + state->status_text = "Added " + app_curve_display_name(curve); + } + } else { + state->status_text = "Curve already present"; + } + return true; + } + const SketchLayout before_layout = session->layout; + if (split_pane(tab, action.target_pane_index, action.zone, curve)) { + tab_state->active_pane_index = static_cast(tab->panes.size()) - 1; + return commit_tab_layout_change(session, state, tab, tab_state, before_layout, + "Split pane and added " + app_curve_display_name(curve), + true); + } + return false; +} + +ImGuiDir dock_direction(SplitOrientation orientation) { + return orientation == SplitOrientation::Horizontal ? ImGuiDir_Left : ImGuiDir_Up; +} + +void build_dock_tree(const WorkspaceNode &node, const WorkspaceTab &tab, int tab_runtime_id, ImGuiID dock_id) { + if (node.is_pane) { + if (node.pane_index >= 0 && node.pane_index < static_cast(tab.panes.size())) { + ImGui::DockBuilderDockWindow( + pane_window_name(tab_runtime_id, node.pane_index, tab.panes[static_cast(node.pane_index)]).c_str(), + dock_id); + if (ImGuiDockNode *dock_node = ImGui::DockBuilderGetNode(dock_id); dock_node != nullptr) { + dock_node->LocalFlags |= ImGuiDockNodeFlags_AutoHideTabBar | + ImGuiDockNodeFlags_NoWindowMenuButton | + ImGuiDockNodeFlags_NoCloseButton; + } + } + return; + } + if (node.children.empty()) { + return; + } + if (node.children.size() == 1) { + build_dock_tree(node.children.front(), tab, tab_runtime_id, dock_id); + return; + } + + float remaining = 1.0f; + ImGuiID current = dock_id; + for (size_t i = 0; i + 1 < node.children.size(); ++i) { + const float child_size = i < node.sizes.size() ? node.sizes[i] : 0.0f; + const float ratio = remaining <= 0.0f ? 0.5f : std::clamp(child_size / remaining, 0.05f, 0.95f); + ImGuiID child_id = 0; + ImGuiID remainder_id = 0; + ImGui::DockBuilderSplitNode(current, dock_direction(node.orientation), ratio, &child_id, &remainder_id); + build_dock_tree(node.children[i], tab, tab_runtime_id, child_id); + current = remainder_id; + remaining = std::max(0.0f, remaining - child_size); + } + build_dock_tree(node.children.back(), tab, tab_runtime_id, current); +} + +void ensure_dockspace(const WorkspaceTab &tab, TabUiState *tab_state, ImVec2 dockspace_size) { + if (dockspace_size.x <= 0.0f || dockspace_size.y <= 0.0f || tab_state == nullptr) { + return; + } + const bool size_changed = std::abs(tab_state->last_dockspace_size.x - dockspace_size.x) > 1.0f + || std::abs(tab_state->last_dockspace_size.y - dockspace_size.y) > 1.0f; + if (!tab_state->dock_needs_build && !size_changed) { + return; + } + + const ImGuiID dockspace_id = dockspace_id_for_tab(tab_state->runtime_id); + ImGui::DockBuilderRemoveNode(dockspace_id); + ImGui::DockBuilderAddNode(dockspace_id, ImGuiDockNodeFlags_DockSpace | ImGuiDockNodeFlags_AutoHideTabBar); + ImGui::DockBuilderSetNodeSize(dockspace_id, dockspace_size); + build_dock_tree(tab.root, tab, tab_state->runtime_id, dockspace_id); + ImGui::DockBuilderFinish(dockspace_id); + tab_state->dock_needs_build = false; + tab_state->last_dockspace_size = dockspace_size; +} + +void draw_pane_windows(AppSession *session, UiState *state) { + WorkspaceTab *tab = app_active_tab(&session->layout, *state); + TabUiState *tab_state = app_active_tab_state(state); + if (tab == nullptr || tab_state == nullptr) { + return; + } + + std::optional> pending_menu_action; + std::optional pending_close_pane; + std::optional pending_drop_action; + + for (size_t i = 0; i < tab->panes.size(); ++i) { + Pane &pane = tab->panes[i]; + std::optional menu_action; + std::optional drop_action; + bool close_pane_requested = false; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(2.0f, 2.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, color_rgb(250, 250, 251)); + ImGui::PushStyleColor(ImGuiCol_Border, color_rgb(194, 198, 204)); + ImGui::PushStyleColor(ImGuiCol_TitleBg, color_rgb(252, 252, 253)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, color_rgb(252, 252, 253)); + ImGui::PushStyleColor(ImGuiCol_TitleBgCollapsed, color_rgb(252, 252, 253)); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse; + const std::string window_name = pane_window_name(tab_state->runtime_id, static_cast(i), pane); + const bool opened = ImGui::Begin(window_name.c_str(), nullptr, flags); + if (opened) { + if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) + || (ImGui::IsWindowHovered(ImGuiHoveredFlags_RootAndChildWindows) && ImGui::IsMouseClicked(0))) { + tab_state->active_pane_index = static_cast(i); + } + if (pane.kind == PaneKind::Map) { + draw_map_pane(session, state, &pane, static_cast(i)); + } else if (pane.kind == PaneKind::Camera) { + draw_camera_pane(session, state, tab_state, static_cast(i), pane); + } else { + draw_plot(*session, &pane, state); + } + draw_pane_frame_overlay(); + close_pane_requested = draw_pane_close_button_overlay(); + menu_action = draw_pane_context_menu(*tab, static_cast(i)); + drop_action = draw_pane_drop_target(state->active_tab_index, static_cast(i), pane); + } + ImGui::End(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(5); + if (!pending_menu_action.has_value() && menu_action.has_value()) { + pending_menu_action = std::make_pair(static_cast(i), *menu_action); + } + if (!pending_menu_action.has_value() && !pending_close_pane.has_value() && close_pane_requested) { + pending_close_pane = static_cast(i); + } + if (!pending_menu_action.has_value() && !pending_close_pane.has_value() + && !pending_drop_action.has_value() && drop_action.has_value()) { + pending_drop_action = *drop_action; + } + } + + if (pending_menu_action.has_value()) { + apply_pane_menu_action(session, state, pending_menu_action->first, pending_menu_action->second); + return; + } + if (pending_close_pane.has_value()) { + PaneMenuAction action; + action.kind = PaneMenuActionKind::Close; + action.pane_index = *pending_close_pane; + apply_pane_menu_action(session, state, *pending_close_pane, action); + return; + } + if (pending_drop_action.has_value()) { + apply_pane_drop_action(session, state, *pending_drop_action); + } +} + +void draw_workspace(AppSession *session, const UiMetrics &ui, UiState *state) { + state->custom_series.selected = false; + state->logs.selected = false; + ImGui::SetNextWindowPos(ImVec2(ui.content_x, ui.content_y)); + ImGui::SetNextWindowSize(ImVec2(ui.content_w, ui.content_h)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, color_rgb(244, 246, 248)); + ImGui::PushStyleColor(ImGuiCol_Border, color_rgb(186, 191, 198)); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse; + if (ImGui::Begin("##workspace_host", nullptr, flags)) { + const int selection_request = state->requested_tab_index; + std::optional rename_tab_rect; + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 6.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemInnerSpacing, ImVec2(8.0f, 4.0f)); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + if (ImGui::BeginTabBar("##layout_tabs", ImGuiTabBarFlags_FittingPolicyScroll)) { + enum class TabActionKind { + None, + New, + Rename, + Duplicate, + Close, + }; + TabActionKind pending_action = TabActionKind::None; + int pending_tab_index = -1; + bool custom_series_tab_open = state->custom_series.open; + bool suppress_aux_tabs_this_frame = state->request_close_tab && session->layout.tabs.size() == 1; + for (size_t i = 0; i < session->layout.tabs.size(); ++i) { + const WorkspaceTab &tab = session->layout.tabs[i]; + const TabUiState &tab_ui = state->tabs[i]; + ImGuiTabItemFlags tab_flags = ImGuiTabItemFlags_None; + if (static_cast(i) == selection_request) { + tab_flags |= ImGuiTabItemFlags_SetSelected; + } + bool tab_open = true; + const bool opened = ImGui::BeginTabItem(tab_item_label(tab, tab_ui.runtime_id).c_str(), &tab_open, tab_flags); + if (state->rename_tab_index == static_cast(i)) { + rename_tab_rect = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + } + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + pending_action = TabActionKind::Rename; + pending_tab_index = static_cast(i); + } + if (!tab_open) { + pending_action = TabActionKind::Close; + pending_tab_index = static_cast(i); + if (session->layout.tabs.size() == 1) { + suppress_aux_tabs_this_frame = true; + } + } + if (ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem("New Tab")) { + pending_action = TabActionKind::New; + } + if (ImGui::MenuItem("Rename Tab...")) { + pending_action = TabActionKind::Rename; + pending_tab_index = static_cast(i); + } + if (ImGui::MenuItem("Duplicate Tab")) { + pending_action = TabActionKind::Duplicate; + pending_tab_index = static_cast(i); + } + if (ImGui::MenuItem("Close Tab")) { + pending_action = TabActionKind::Close; + pending_tab_index = static_cast(i); + } + ImGui::EndPopup(); + } + if (opened) { + state->active_tab_index = static_cast(i); + session->layout.current_tab_index = state->active_tab_index; + if (i < state->tabs.size()) { + ensure_dockspace(tab, &state->tabs[i], ImGui::GetContentRegionAvail()); + } + ImGui::DockSpace(dockspace_id_for_tab(tab_ui.runtime_id), + ImVec2(0.0f, 0.0f), + ImGuiDockNodeFlags_AutoHideTabBar | + ImGuiDockNodeFlags_NoWindowMenuButton | + ImGuiDockNodeFlags_NoCloseButton); + ImGui::EndTabItem(); + } + } + if (!suppress_aux_tabs_this_frame) { + ImGuiTabItemFlags logs_flags = ImGuiTabItemFlags_None; + if (state->logs.request_select) { + logs_flags |= ImGuiTabItemFlags_SetSelected; + } + if (ImGui::BeginTabItem("Logs##workspace_logs", nullptr, logs_flags)) { + state->logs.request_select = false; + state->logs.selected = true; + draw_logs_tab(session, state); + ImGui::EndTabItem(); + } + if (custom_series_tab_open) { + ImGuiTabItemFlags custom_flags = ImGuiTabItemFlags_None; + if (state->custom_series.request_select) { + custom_flags |= ImGuiTabItemFlags_SetSelected; + } + if (ImGui::BeginTabItem("Custom Series##workspace_custom_series", &custom_series_tab_open, custom_flags)) { + state->custom_series.request_select = false; + state->custom_series.selected = true; + draw_custom_series_editor(session, state); + ImGui::EndTabItem(); + } + } + } + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(12.0f, 5.0f)); + ImGui::PushStyleColor(ImGuiCol_Tab, color_rgb(210, 217, 225)); + ImGui::PushStyleColor(ImGuiCol_TabHovered, color_rgb(224, 230, 237)); + ImGui::PushStyleColor(ImGuiCol_TabSelected, color_rgb(242, 245, 248)); + if (ImGui::TabItemButton(" ##new_tab_button", ImGuiTabItemFlags_Trailing)) { + pending_action = TabActionKind::New; + } + { + const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + const ImU32 color = ImGui::GetColorU32(color_rgb(72, 79, 88)); + const ImVec2 center((rect.Min.x + rect.Max.x) * 0.5f, (rect.Min.y + rect.Max.y) * 0.5f); + constexpr float half_extent = 6.25f; + constexpr float thickness = 2.0f; + draw_list->AddLine(ImVec2(center.x - half_extent, center.y), + ImVec2(center.x + half_extent, center.y), + color, + thickness); + draw_list->AddLine(ImVec2(center.x, center.y - half_extent), + ImVec2(center.x, center.y + half_extent), + color, + thickness); + } + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) { + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 6.0f)); + ImGui::BeginTooltip(); + ImGui::TextUnformatted("New Tab"); + ImGui::EndTooltip(); + ImGui::PopStyleVar(); + } + ImGui::PopStyleColor(3); + ImGui::PopStyleVar(); + ImGui::EndTabBar(); + + if (!custom_series_tab_open) { + state->custom_series.open = false; + state->custom_series.request_select = false; + } + + if (rename_tab_rect.has_value()) { + draw_inline_tab_editor(session, state, *rename_tab_rect); + } + + if (state->request_new_tab || pending_action == TabActionKind::New) { + const SketchLayout before_layout = session->layout; + create_runtime_tab(&session->layout, state); + state->undo.push(before_layout); + mark_layout_dirty(session, state); + state->request_new_tab = false; + } else if (pending_action == TabActionKind::Rename) { + begin_rename_tab(session->layout, state, pending_tab_index); + } else if (state->request_duplicate_tab || pending_action == TabActionKind::Duplicate) { + if (pending_tab_index >= 0) { + request_tab_selection(state, pending_tab_index); + } + const SketchLayout before_layout = session->layout; + duplicate_runtime_tab(&session->layout, state); + state->undo.push(before_layout); + mark_layout_dirty(session, state); + state->request_duplicate_tab = false; + } else if (state->request_close_tab || pending_action == TabActionKind::Close) { + if (pending_tab_index >= 0) { + request_tab_selection(state, pending_tab_index); + } + const SketchLayout before_layout = session->layout; + close_runtime_tab(&session->layout, state); + state->undo.push(before_layout); + mark_layout_dirty(session, state); + state->request_close_tab = false; + } + if (state->requested_tab_index == selection_request) { + state->requested_tab_index = -1; + } + } + ImGui::PopStyleColor(3); + ImGui::PopStyleVar(2); + } + ImGui::End(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(2); +} + +int run(const Options &options) { + try { + const fs::path layout_path = options.layout.empty() ? fs::path() : resolve_layout_path(options.layout); + AppSession session = { + .layout_path = layout_path, + .autosave_path = layout_path.empty() ? fs::path() : autosave_path_for_layout(layout_path), + .route_name = options.route_name, + .data_dir = options.data_dir, + .dbc_override = {}, + .stream_source = StreamSourceConfig{.kind = is_local_stream_address(options.stream_address) + ? StreamSourceKind::CerealLocal + : StreamSourceKind::CerealRemote, + .address = options.stream_address}, + .stream_buffer_seconds = options.stream_buffer_seconds, + .data_mode = options.stream ? SessionDataMode::Stream : SessionDataMode::Route, + .route_id = options.stream ? RouteIdentifier{} : parse_route_identifier(options.route_name), + .layout = options.layout.empty() ? make_empty_layout() : load_sketch_layout(layout_path), + }; + UiState ui_state; + if (!layout_path.empty() && !session.autosave_path.empty() && fs::exists(session.autosave_path)) { + session.layout = load_sketch_layout(session.autosave_path); + ui_state.layout_dirty = true; + } + ui_state.undo.reset(session.layout); + sync_ui_state(&ui_state, session.layout); + sync_route_buffers(&ui_state, session); + sync_stream_buffers(&ui_state, session); + sync_layout_buffers(&ui_state, session); + + session.async_route_loading = session.data_mode == SessionDataMode::Route + && options.show && options.output_path.empty() && !options.sync_load; + if (session.data_mode == SessionDataMode::Route && !session.async_route_loading) { + TerminalRouteProgress route_progress(::isatty(STDERR_FILENO) != 0); + rebuild_session_route_data(&session, &ui_state, [&](const RouteLoadProgress &update) { + route_progress.update(update); + }); + route_progress.finish(); + } + + GlfwRuntime glfw_runtime(options); + ImGuiRuntime imgui_runtime(glfw_runtime.window()); + configure_style(); + session.map_data = std::make_unique(); + for (std::unique_ptr &feed : session.pane_camera_feeds) { + feed = std::make_unique(); + } + sync_camera_feeds(&session); + + if (session.async_route_loading) { + session.route_loader = std::make_unique(::isatty(STDERR_FILENO) != 0); + start_async_route_load(&session, &ui_state); + } else if (session.data_mode == SessionDataMode::Stream) { + session.stream_poller = std::make_unique(); + start_stream_session(&session, &ui_state, session.stream_source, session.stream_buffer_seconds); + } + + const bool should_capture = !options.output_path.empty(); + const fs::path output_path = should_capture ? fs::path(options.output_path) : fs::path(); + const bool capture_has_map = should_capture && active_tab_has_map_pane(session.layout); + if (options.show) { + bool captured = false; + const auto capture_ready_at = std::chrono::steady_clock::now() + (capture_has_map ? std::chrono::milliseconds(1800) + : std::chrono::milliseconds(0)); + while (!glfwWindowShouldClose(glfw_runtime.window())) { + const bool capture_ready = std::chrono::steady_clock::now() >= capture_ready_at; + const fs::path *capture_path = (!captured && should_capture && capture_ready) ? &output_path : nullptr; + render_frame(glfw_runtime.window(), &session, &ui_state, capture_path); + captured = captured || capture_path != nullptr; + } + } else { + render_frame(glfw_runtime.window(), &session, &ui_state, nullptr); + if (should_capture) { + for (int i = 0; i < 3; ++i) { + render_frame(glfw_runtime.window(), &session, &ui_state, nullptr); + } + if (capture_has_map) { + for (int i = 0; i < 18; ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + render_frame(glfw_runtime.window(), &session, &ui_state, nullptr); + } + } + render_frame(glfw_runtime.window(), &session, &ui_state, &output_path); + } + } + if (session.stream_poller) { + session.stream_poller->stop(); + } + session.map_data.reset(); + for (std::unique_ptr &feed : session.pane_camera_feeds) { + feed.reset(); + } + return 0; + } catch (const std::exception &err) { + std::cerr << err.what() << "\n"; + return 1; + } +} diff --git a/tools/jotpluggler/app.h b/tools/jotpluggler/app.h new file mode 100644 index 0000000000..872a6973d7 --- /dev/null +++ b/tools/jotpluggler/app.h @@ -0,0 +1,887 @@ +#pragma once + +#include "cereal/gen/cpp/log.capnp.h" +#include "imgui.h" +#include "tools/jotpluggler/dbc.h" +#include "tools/jotpluggler/util.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ***** +// app options & entry point +// ***** + +struct Options { + std::string layout; + std::string route_name; + std::string data_dir; + std::string output_path; + std::string stream_address = "127.0.0.1"; + int width = 1600; + int height = 900; + bool show = false; + bool sync_load = false; + bool stream = false; + double stream_buffer_seconds = 30.0; +}; + +int run(const Options &options); + +// ***** +// sketch layout & route data +// ***** + +struct PlotRange { + bool valid = false; + double left = 0.0; + double right = 0.0; + double bottom = 0.0; + double top = 1.0; + bool has_y_limit_min = false; + bool has_y_limit_max = false; + double y_limit_min = 0.0; + double y_limit_max = 1.0; +}; + +struct CustomPythonSeries { + std::string linked_source; + std::vector additional_sources; + std::string globals_code; + std::string function_code; +}; + +struct Curve { + std::string name; + std::string label; + std::array color = {160, 170, 180}; + bool visible = true; + bool derivative = false; + double derivative_dt = 0.0; + double value_scale = 1.0; + double value_offset = 0.0; + bool runtime_only = false; + std::optional custom_python; + std::string runtime_error_message; + std::vector xs; + std::vector ys; +}; + +enum class PaneKind : uint8_t { + Plot, + Map, + Camera, +}; + +enum class CameraViewKind : uint8_t { + Road, + Driver, + WideRoad, + QRoad, +}; + +struct Pane { + PaneKind kind = PaneKind::Plot; + CameraViewKind camera_view = CameraViewKind::Road; + std::string title; + PlotRange range; + std::vector curves; +}; + +enum class SplitOrientation { + Horizontal, + Vertical, +}; + +struct WorkspaceNode { + bool is_pane = false; + int pane_index = -1; + SplitOrientation orientation = SplitOrientation::Horizontal; + std::vector sizes; + std::vector children; +}; + +struct WorkspaceTab { + std::string tab_name; + WorkspaceNode root; + std::vector panes; +}; + +struct RouteSeries { + std::string path; + std::vector times; + std::vector values; +}; + +struct CameraSegmentFile { + int segment = -1; + std::string path; +}; + +struct CameraFrameIndexEntry { + double timestamp = 0.0; + int segment = -1; + int decode_index = -1; + uint32_t frame_id = 0; +}; + +struct CameraFeedIndex { + std::vector segment_files; + std::vector entries; +}; + +enum class LogOrigin : uint8_t { + Log, + Android, + Alert, +}; + +struct LogEntry { + double mono_time = 0.0; + double boot_time = 0.0; + double wall_time = 0.0; + uint8_t level = 20; + std::string source; + std::string func; + std::string message; + std::string context; + LogOrigin origin = LogOrigin::Log; +}; + +struct EnumInfo { + std::vector names; +}; + +struct SeriesFormat { + int decimals = 3; + bool integer_like = false; + bool has_negative = false; + int digits_before = 1; + int total_width = 0; + char fmt[16] = "%7.3f"; +}; + +enum class CanServiceKind : uint8_t { + Can, + Sendcan, +}; + +struct CanMessageId { + CanServiceKind service = CanServiceKind::Can; + uint8_t bus = 0; + uint32_t address = 0; + + bool operator==(const CanMessageId &other) const { + return service == other.service && bus == other.bus && address == other.address; + } +}; + +struct CanMessageIdHash { + size_t operator()(const CanMessageId &id) const { + return (static_cast(id.service) << 40) + ^ (static_cast(id.bus) << 32) + ^ static_cast(id.address); + } +}; + +struct CanFrameSample { + double mono_time = 0.0; + uint16_t bus_time = 0; + std::string data; +}; + +struct LiveCanFrame { + double mono_time = 0.0; + uint8_t bus = 0; + uint32_t address = 0; + uint16_t bus_time = 0; + std::string data; +}; + +struct CanMessageData { + CanMessageId id; + std::vector samples; +}; + +struct TimelineEntry { + enum class Type : uint8_t { + None, + Engaged, + AlertInfo, + AlertWarning, + AlertCritical, + }; + + double start_time = 0.0; + double end_time = 0.0; + Type type = Type::None; +}; + +struct GpsPoint { + double time = 0.0; + double lat = 0.0; + double lon = 0.0; + float bearing = 0.0f; + TimelineEntry::Type type = TimelineEntry::Type::None; +}; + +struct GpsTrace { + std::vector points; + double min_lat = 0.0; + double max_lat = 0.0; + double min_lon = 0.0; + double max_lon = 0.0; +}; + +enum class LogSelector : uint8_t { + Auto, + RLog, + QLog, +}; + +struct RouteIdentifier { + std::string dongle_id; + std::string log_id; + int slice_begin = 0; + int slice_end = -1; + bool slice_explicit = false; + LogSelector selector = LogSelector::Auto; + bool selector_explicit = false; + int available_begin = 0; + int available_end = 0; + + bool empty() const { + return dongle_id.empty() || log_id.empty(); + } + + std::string canonical() const { + return empty() ? std::string() : dongle_id + "/" + log_id; + } + + std::string onebox() const { + return empty() ? std::string() : dongle_id + "|" + log_id; + } + + std::string display_slice() const { + const int begin = slice_explicit ? slice_begin : available_begin; + const int end = slice_explicit ? slice_end : available_end; + if (end < 0) { + return std::to_string(begin) + ":"; + } + if (end == begin) { + return std::to_string(begin); + } + return std::to_string(begin) + ":" + std::to_string(end); + } + + char selector_char() const { + switch (selector) { + case LogSelector::RLog: return 'r'; + case LogSelector::QLog: return 'q'; + case LogSelector::Auto: + default: return 'a'; + } + } + + std::string full_spec() const { + if (empty()) return {}; + std::string spec = dongle_id + "/" + log_id; + if (slice_explicit) { + spec += "/"; + spec += display_slice(); + } + if (selector_explicit) { + spec += "/"; + spec.push_back(selector_char()); + } + return spec; + } +}; + +struct RouteData { + std::vector series; + std::vector paths; + std::vector roots; + std::vector can_messages; + CameraFeedIndex road_camera; + CameraFeedIndex driver_camera; + CameraFeedIndex wide_road_camera; + CameraFeedIndex qroad_camera; + GpsTrace gps_trace; + std::vector logs; + std::vector timeline; + std::unordered_map enum_info; + std::unordered_map series_formats; + std::string car_fingerprint; + std::string dbc_name; + RouteIdentifier route_id; + bool has_time_range = false; + double x_min = 0.0; + double x_max = 1.0; +}; + +struct StreamExtractBatch { + std::vector series; + std::vector can_messages; + std::vector logs; + std::vector timeline; + std::unordered_map enum_info; + std::string car_fingerprint; + std::string dbc_name; + bool has_time_offset = false; + double time_offset = 0.0; +}; + +struct SketchLayout { + std::vector tabs; + std::vector roots; + int current_tab_index = 0; +}; + +enum class RouteLoadStage { + Resolving, + DownloadingSegment, + ParsingSegment, + Finished, +}; + +struct RouteLoadProgress { + RouteLoadStage stage = RouteLoadStage::Resolving; + size_t segment_index = 0; + size_t segment_count = 0; + uint64_t current = 0; + uint64_t total = 0; + size_t segments_downloaded = 0; + size_t segments_parsed = 0; + size_t total_segments = 0; + uint64_t bytes_downloaded = 0; + int num_workers = 1; + std::string segment_name; +}; + +using RouteLoadProgressCallback = std::function; + +class StreamAccumulator { +public: + explicit StreamAccumulator(const std::string &dbc_name = {}, std::optional time_offset = std::nullopt); + ~StreamAccumulator(); + + StreamAccumulator(const StreamAccumulator &) = delete; + StreamAccumulator &operator=(const StreamAccumulator &) = delete; + + void setDbcName(const std::string &dbc_name); + void appendEvent(kj::ArrayPtr data); + void appendCanFrames(CanServiceKind service, const std::vector &frames); + StreamExtractBatch takeBatch(); + const std::string &carFingerprint() const; + const std::string &dbc_name() const; + std::optional timeOffset() const; + +private: + struct Impl; + std::unique_ptr impl_; +}; + +SketchLayout load_sketch_layout(const std::filesystem::path &layout_path); +std::vector available_dbc_names(); +std::vector collect_route_roots_for_paths(const std::vector &paths); +std::optional load_dbc_by_name(const std::string &dbc_name); +std::vector decode_can_messages(const std::vector &can_messages, + const std::string &dbc_name, + std::unordered_map *enum_info = nullptr); +RouteData load_route_data(const std::string &route_name, + const std::string &data_dir = {}, + const std::string &dbc_name = {}, + const RouteLoadProgressCallback &progress = {}); +RouteIdentifier parse_route_identifier(std::string_view route_name); +void rebuild_gps_trace(RouteData *route_data); + +// ***** +// icons +// ***** + +namespace icon { +constexpr const char ARROW_DOWN_UP[] = "\xef\x84\xa7"; +constexpr const char ARROW_LEFT_RIGHT[] = "\xef\x84\xab"; +constexpr const char BAR_CHART[] = "\xef\x85\xbe"; +constexpr const char BOX_ARROW_UP_RIGHT[] = "\xef\x87\x85"; +constexpr const char CLIPBOARD[] = "\xef\x8a\x90"; +constexpr const char CLIPBOARD2[] = "\xef\x9c\xb3"; +constexpr const char DISTRIBUTE_HORIZONTAL[] = "\xef\x8c\x83"; +constexpr const char DISTRIBUTE_VERTICAL[] = "\xef\x8c\x84"; +constexpr const char FILE_EARMARK_IMAGE[] = "\xef\x8d\xad"; +constexpr const char FILES[] = "\xef\x8f\x82"; +constexpr const char INFO_CIRCLE[] = "\xef\x90\xb1"; +constexpr const char PALETTE[] = "\xef\x92\xb1"; +constexpr const char PLUS_SLASH_MINUS[] = "\xef\x9a\xaa"; +constexpr const char SAVE[] = "\xef\x94\xa5"; +constexpr const char SLIDERS[] = "\xef\x95\xab"; +constexpr const char TRASH[] = "\xef\x97\x9e"; +constexpr const char X_SQUARE[] = "\xef\x98\xa9"; +constexpr const char ZOOM_OUT[] = "\xef\x98\xad"; +} // namespace icon + +void icon_add_font(float size, bool merge = false, const ImFont *base_font = nullptr); +bool icon_menu_item(const char *glyph, + const char *label, + const char *shortcut = nullptr, + bool selected = false, + bool enabled = true); + +// ***** +// app session, UI state, & internal API +// ***** + +class AsyncRouteLoader; +class CameraFeedView; +class StreamPoller; +class MapDataManager; + +enum class SessionDataMode : uint8_t { + Route, + Stream, +}; + +enum class StreamSourceKind : uint8_t { + CerealLocal, + CerealRemote, +}; + +struct StreamSourceConfig { + StreamSourceKind kind = StreamSourceKind::CerealLocal; + std::string address = "127.0.0.1"; +}; + +struct BrowserNode { + std::string label; + std::string full_path; + std::vector children; +}; + +struct AppSession { + std::filesystem::path layout_path; + std::filesystem::path autosave_path; + std::string route_name; + std::string data_dir; + std::string dbc_override; + StreamSourceConfig stream_source; + double stream_buffer_seconds = 30.0; + SessionDataMode data_mode = SessionDataMode::Route; + RouteIdentifier route_id; + SketchLayout layout; + RouteData route_data; + std::unordered_map series_by_path; + std::vector browser_nodes; + std::unique_ptr route_loader; + std::unique_ptr stream_poller; + std::array, 4> pane_camera_feeds; + std::unique_ptr map_data; + bool async_route_loading = false; + double next_stream_custom_refresh_time = 0.0; + bool stream_paused = false; + std::optional stream_time_offset; +}; + +struct TabUiState { + struct MapPaneState { + bool initialized = false; + bool follow = false; + float zoom = 1.0f; + double center_lat = 0.0; + double center_lon = 0.0; + }; + + struct CameraPaneState { + bool fit_to_pane = true; + }; + + bool dock_needs_build = true; + int active_pane_index = 0; + int runtime_id = 0; + ImVec2 last_dockspace_size = ImVec2(0.0f, 0.0f); + std::vector map_panes; + std::vector camera_panes; +}; + +struct CustomSeriesEditorState { + bool open = false; + bool open_help = false; + bool request_select = false; + bool selected = false; + bool focus_name = false; + int selected_template = 0; + int selected_additional_source = -1; + std::string name; + std::string linked_source; + std::vector additional_sources; + std::string globals_code; + std::string function_code = "return value"; + std::string preview_label; + std::vector preview_xs; + std::vector preview_ys; + bool preview_is_result = false; +}; + +enum class LogTimeMode : uint8_t { + Route, + Boot, + WallClock, +}; + +struct LogsUiState { + bool selected = false; + bool request_select = false; + bool all_sources = true; + uint32_t enabled_levels_mask = 0b11110; + int expanded_index = -1; + std::string search; + std::vector selected_sources; + double last_auto_scroll_time = -1.0; + LogTimeMode time_mode = LogTimeMode::Route; +}; + +struct AxisLimitsEditorState { + bool open = false; + int pane_index = -1; + double x_min = 0.0; + double x_max = 1.0; + bool y_min_enabled = false; + bool y_max_enabled = false; + double y_min = 0.0; + double y_max = 1.0; +}; + +struct DbcEditorState { + bool open = false; + bool loaded = false; + std::string source_name; + std::filesystem::path source_path; + enum class SourceKind : uint8_t { + None, + Generated, + Opendbc, + }; + SourceKind source_kind = SourceKind::None; + std::string save_name; + std::string text; +}; + +enum class TimelineDragMode : uint8_t { + None, + ScrubCursor, + PanViewport, + ResizeLeft, + ResizeRight, +}; + +struct UndoStack { + static constexpr size_t kMaxHistory = 50; + + std::vector history; + int position = -1; + + void reset(const SketchLayout &layout) { + history.clear(); + history.push_back(layout); + position = 0; + } + + void push(const SketchLayout &layout) { + if (position < 0) { + reset(layout); + return; + } + if (position + 1 < static_cast(history.size())) { + history.resize(static_cast(position + 1)); + } + history.push_back(layout); + if (history.size() > kMaxHistory) { + history.erase(history.begin()); + } + position = static_cast(history.size()) - 1; + } + + bool can_undo() const { + return position > 0; + } + + bool can_redo() const { + return position >= 0 && position + 1 < static_cast(history.size()); + } + + const SketchLayout &undo() { + return history[static_cast(--position)]; + } + + const SketchLayout &redo() { + return history[static_cast(++position)]; + } +}; + +struct UiState { + bool open_open_route = false; + bool open_stream = false; + bool open_load_layout = false; + bool open_save_layout = false; + bool open_preferences = false; + bool open_find_signal = false; + bool request_close = false; + bool request_reset_layout = false; + bool request_save_layout = false; + bool request_new_tab = false; + bool request_duplicate_tab = false; + bool request_close_tab = false; + bool follow_latest = false; + bool has_shared_range = false; + bool has_tracker_time = false; + bool layout_dirty = false; + bool playback_loop = false; + bool playback_playing = false; + bool show_deprecated_fields = false; + bool show_fps_overlay = false; + bool fps_overlay_initialized = false; + bool suppress_range_side_effects = false; + bool browser_nodes_dirty = false; + int active_tab_index = 0; + int next_tab_runtime_id = 1; + int requested_tab_index = -1; + int rename_tab_index = -1; + bool focus_rename_tab_input = false; + std::vector tabs; + std::string route_buffer; + std::string stream_address_buffer; + std::string rename_tab_buffer; + std::string browser_filter; + std::string data_dir_buffer; + std::string load_layout_buffer; + std::string save_layout_buffer; + std::string find_signal_buffer; + std::string selected_browser_path; + std::vector selected_browser_paths; + std::string browser_selection_anchor; + std::string route_slice_buffer; + std::string error_text; + bool open_error_popup = false; + std::string status_text = "Ready"; + std::string route_copy_feedback_text; + double route_copy_feedback_until = 0.0; + bool editing_route_slice = false; + bool focus_route_slice_input = false; + StreamSourceKind stream_source_kind = StreamSourceKind::CerealLocal; + float sidebar_width = 320.0f; + double route_x_min = 0.0; + double route_x_max = 1.0; + double x_view_min = 0.0; + double x_view_max = 1.0; + double tracker_time = 0.0; + double playback_rate = 1.0; + double playback_step = 0.1; + double stream_buffer_seconds = 30.0; + TimelineDragMode timeline_drag_mode = TimelineDragMode::None; + double timeline_drag_anchor_time = 0.0; + double timeline_drag_anchor_x_min = 0.0; + double timeline_drag_anchor_x_max = 0.0; + AxisLimitsEditorState axis_limits; + DbcEditorState dbc_editor; + CustomSeriesEditorState custom_series; + LogsUiState logs; + UndoStack undo; +}; + +// app.cc public API + +const WorkspaceTab *app_active_tab(const SketchLayout &layout, const UiState &state); +WorkspaceTab *app_active_tab(SketchLayout *layout, const UiState &state); +TabUiState *app_active_tab_state(UiState *state); + +void app_push_mono_font(); +void app_pop_mono_font(); +bool app_add_curve_to_active_pane(AppSession *session, UiState *state, const std::string &path); + +std::string app_curve_display_name(const Curve &curve); +std::array app_next_curve_color(const Pane &pane); +const RouteSeries *app_find_route_series(const AppSession &session, const std::string &path); +void app_decimate_samples(const std::vector &xs_in, + const std::vector &ys_in, + int max_points, + std::vector *xs_out, + std::vector *ys_out); +std::optional app_sample_xy_value_at_time(const std::vector &xs, + const std::vector &ys, + bool stairs, + double tm); +void save_layout_json(const SketchLayout &layout, const std::filesystem::path &path); + +// ***** +// browser +// ***** + +void rebuild_route_index(AppSession *session); +void rebuild_browser_nodes(AppSession *session, UiState *state); +SeriesFormat compute_series_format(const std::vector &values, bool enum_like = false); +std::string format_display_value(double display_value, + const SeriesFormat &format, + const EnumInfo *enum_info); +std::vector decode_browser_drag_payload(std::string_view payload); +void collect_visible_leaf_paths(const BrowserNode &node, + const std::string &filter, + std::vector *out); +void draw_browser_node(AppSession *session, + const BrowserNode &node, + UiState *state, + const std::string &filter, + const std::vector &visible_paths); + +// ***** +// custom series +// ***** + +void open_custom_series_editor(UiState *state, const std::string &preferred_source = {}); +std::string preferred_custom_series_source(const Pane &pane); +void refresh_all_custom_curves(AppSession *session, UiState *state); +void draw_custom_series_editor(AppSession *session, UiState *state); + +// ***** +// logs +// ***** + +void draw_logs_tab(AppSession *session, UiState *state); + +// ***** +// map +// ***** + +void draw_map_pane(AppSession *session, UiState *state, Pane *pane, int pane_index); + +// ***** +// runtime (GLFW, async loaders, streaming, camera) +// ***** + +struct GLFWwindow; + +struct RouteLoadSnapshot { + bool active = false; + size_t total_segments = 0; + size_t segments_downloaded = 0; + size_t segments_parsed = 0; +}; + +struct StreamPollSnapshot { + bool active = false; + bool connected = false; + bool paused = false; + StreamSourceKind source_kind = StreamSourceKind::CerealLocal; + std::string source_label; + std::string dbc_name; + std::string car_fingerprint; + double buffer_seconds = 30.0; + uint64_t received_messages = 0; +}; + +class GlfwRuntime { +public: + explicit GlfwRuntime(const Options &options); + ~GlfwRuntime(); + + GlfwRuntime(const GlfwRuntime &) = delete; + GlfwRuntime &operator=(const GlfwRuntime &) = delete; + + GLFWwindow *window() const; + +private: + GLFWwindow *window_ = nullptr; +}; + +class ImGuiRuntime { +public: + explicit ImGuiRuntime(GLFWwindow *window); + ~ImGuiRuntime(); + + ImGuiRuntime(const ImGuiRuntime &) = delete; + ImGuiRuntime &operator=(const ImGuiRuntime &) = delete; +}; + +class TerminalRouteProgress { +public: + explicit TerminalRouteProgress(bool enabled); + ~TerminalRouteProgress(); + + TerminalRouteProgress(const TerminalRouteProgress &) = delete; + TerminalRouteProgress &operator=(const TerminalRouteProgress &) = delete; + + void update(const RouteLoadProgress &progress); + void finish(); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +class AsyncRouteLoader { +public: + explicit AsyncRouteLoader(bool enable_terminal_progress); + ~AsyncRouteLoader(); + + AsyncRouteLoader(const AsyncRouteLoader &) = delete; + AsyncRouteLoader &operator=(const AsyncRouteLoader &) = delete; + + void start(const std::string &route_name, const std::string &data_dir, const std::string &dbc_name); + RouteLoadSnapshot snapshot() const; + bool consume(RouteData *route_data, std::string *error_text); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +class StreamPoller { +public: + StreamPoller(); + ~StreamPoller(); + + StreamPoller(const StreamPoller &) = delete; + StreamPoller &operator=(const StreamPoller &) = delete; + + void start(const StreamSourceConfig &source, + double buffer_seconds, + const std::string &dbc_name, + std::optional time_offset = std::nullopt); + void setPaused(bool paused); + void stop(); + StreamPollSnapshot snapshot() const; + bool consume(StreamExtractBatch *batch, std::string *error_text); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +class CameraFeedView { +public: + CameraFeedView(); + ~CameraFeedView(); + + CameraFeedView(const CameraFeedView &) = delete; + CameraFeedView &operator=(const CameraFeedView &) = delete; + + void setRouteData(const RouteData &route_data); + void setCameraIndex(const CameraFeedIndex &camera_index, CameraViewKind view); + void update(double tracker_time); + void draw(float width, bool loading); + void drawSized(ImVec2 size, bool loading, bool fit_to_pane = false); + +private: + struct Impl; + std::unique_ptr impl_; +}; diff --git a/tools/jotpluggler/assets/pause.png b/tools/jotpluggler/assets/pause.png deleted file mode 100644 index 8040099831..0000000000 --- a/tools/jotpluggler/assets/pause.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3ea96d8193eb9067a5efdc5d88a3099730ecafa40efcd09d7402bb3efd723603 -size 2305 diff --git a/tools/jotpluggler/assets/play.png b/tools/jotpluggler/assets/play.png deleted file mode 100644 index b1556cf0ab..0000000000 --- a/tools/jotpluggler/assets/play.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:53097ac5403b725ff1841dfa186ea770b4bb3714205824bde36ec3c2a0fb5dba -size 2758 diff --git a/tools/jotpluggler/assets/plus.png b/tools/jotpluggler/assets/plus.png deleted file mode 100644 index 6f8388b24d..0000000000 --- a/tools/jotpluggler/assets/plus.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:248b71eafd1b42b0861da92114da3d625221cd88121fff01e0514bf3d79ff3b1 -size 1364 diff --git a/tools/jotpluggler/assets/split_h.png b/tools/jotpluggler/assets/split_h.png deleted file mode 100644 index 4fd88806e1..0000000000 --- a/tools/jotpluggler/assets/split_h.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:54dd035ff898d881509fa686c402a61af8ef5fb408b92414722da01f773b0d33 -size 2900 diff --git a/tools/jotpluggler/assets/split_v.png b/tools/jotpluggler/assets/split_v.png deleted file mode 100644 index 752e62a4ae..0000000000 --- a/tools/jotpluggler/assets/split_v.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:adbd4e5df1f58694dca9dde46d1d95b4e7471684e42e6bca9f41ea5d346e67c5 -size 3669 diff --git a/tools/jotpluggler/assets/x.png b/tools/jotpluggler/assets/x.png deleted file mode 100644 index 3b2eabd447..0000000000 --- a/tools/jotpluggler/assets/x.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a6d9c90cb0dd906e0b15e1f7f3fd9f0dfad3c3b0b34eeed7a7882768dc5f3961 -size 2053 diff --git a/tools/jotpluggler/browser.cc b/tools/jotpluggler/browser.cc new file mode 100644 index 0000000000..27378b4b6b --- /dev/null +++ b/tools/jotpluggler/browser.cc @@ -0,0 +1,465 @@ +#include "tools/jotpluggler/app.h" + +#include "imgui_internal.h" + +#include +#include +#include + +namespace { + +constexpr float BROWSER_VALUE_WIDTH = 88.0f; + +bool path_matches_filter(const std::string &path, const std::string &lower_filter) { + if (lower_filter.empty()) return true; + return lowercase_copy(path).find(lower_filter) != std::string::npos; +} + +void insert_browser_path(std::vector *nodes, const std::string &path) { + size_t start = 0; + while (start < path.size() && path[start] == '/') { + ++start; + } + std::vector parts; + while (start < path.size()) { + const size_t end = path.find('/', start); + parts.push_back(path.substr(start, end == std::string::npos ? std::string::npos : end - start)); + if (end == std::string::npos) break; + start = end + 1; + } + if (parts.empty()) { + return; + } + + std::vector *current_nodes = nodes; + std::string current_path; + for (size_t i = 0; i < parts.size(); ++i) { + if (!current_path.empty()) { + current_path += "/"; + } + current_path += parts[i]; + auto it = std::find_if(current_nodes->begin(), current_nodes->end(), + [&](const BrowserNode &node) { return node.label == parts[i]; }); + if (it == current_nodes->end()) { + current_nodes->push_back(BrowserNode{.label = parts[i]}); + it = std::prev(current_nodes->end()); + } + if (i + 1 == parts.size()) { + it->full_path = "/" + current_path; + } + current_nodes = &it->children; + } +} + +void sort_browser_nodes(std::vector *nodes) { + std::sort(nodes->begin(), nodes->end(), [](const BrowserNode &a, const BrowserNode &b) { + if (a.children.empty() != b.children.empty()) { + return !a.children.empty(); + } + return a.label < b.label; + }); + for (BrowserNode &node : *nodes) { + sort_browser_nodes(&node.children); + } +} + +std::vector build_browser_tree(const std::vector &paths) { + std::vector nodes; + for (const std::string &path : paths) { + insert_browser_path(&nodes, path); + } + sort_browser_nodes(&nodes); + return nodes; +} + +bool is_deprecated_browser_path(const std::string &path) { + return path.find("DEPRECATED") != std::string::npos || path.find("/deprecated/") != std::string::npos; +} + +std::vector visible_browser_paths(const RouteData &route_data, bool show_deprecated_fields) { + if (show_deprecated_fields) return route_data.paths; + std::vector filtered; + filtered.reserve(route_data.paths.size()); + for (const std::string &path : route_data.paths) { + if (!is_deprecated_browser_path(path)) { + filtered.push_back(path); + } + } + return filtered; +} + +bool browser_selection_contains(const UiState &state, std::string_view path) { + return std::find(state.selected_browser_paths.begin(), state.selected_browser_paths.end(), path) + != state.selected_browser_paths.end(); +} + +std::vector browser_drag_paths(const UiState &state, const std::string &dragged_path) { + if (browser_selection_contains(state, dragged_path) && !state.selected_browser_paths.empty()) { + return state.selected_browser_paths; + } + return {dragged_path}; +} + +std::string encode_browser_drag_payload(const std::vector &paths) { + std::string payload; + for (size_t i = 0; i < paths.size(); ++i) { + if (i != 0) { + payload.push_back('\n'); + } + payload += paths[i]; + } + return payload; +} + +void set_browser_selection_single(UiState *state, const std::string &path) { + state->selected_browser_paths = {path}; + state->selected_browser_path = path; + state->browser_selection_anchor = path; +} + +void toggle_browser_selection(UiState *state, const std::string &path) { + auto it = std::find(state->selected_browser_paths.begin(), state->selected_browser_paths.end(), path); + if (it == state->selected_browser_paths.end()) { + state->selected_browser_paths.push_back(path); + } else { + state->selected_browser_paths.erase(it); + } + state->selected_browser_path = path; + state->browser_selection_anchor = path; + if (state->selected_browser_paths.empty()) { + state->selected_browser_path.clear(); + } +} + +void select_browser_range(UiState *state, const std::vector &visible_paths, const std::string &clicked_path) { + if (visible_paths.empty()) { + set_browser_selection_single(state, clicked_path); + return; + } + + const std::string anchor = state->browser_selection_anchor.empty() ? clicked_path : state->browser_selection_anchor; + const auto anchor_it = std::find(visible_paths.begin(), visible_paths.end(), anchor); + const auto clicked_it = std::find(visible_paths.begin(), visible_paths.end(), clicked_path); + if (clicked_it == visible_paths.end()) { + return; + } + if (anchor_it == visible_paths.end()) { + set_browser_selection_single(state, clicked_path); + return; + } + + const auto [begin_it, end_it] = std::minmax(anchor_it, clicked_it); + std::vector selected; + selected.reserve(static_cast(std::distance(begin_it, end_it)) + 1); + for (auto it = begin_it; it != end_it + 1; ++it) { + selected.push_back(*it); + } + state->selected_browser_paths = std::move(selected); + state->selected_browser_path = clicked_path; +} + +void prune_browser_selection(UiState *state, const std::vector &visible_paths) { + const std::unordered_set visible_set(visible_paths.begin(), visible_paths.end()); + auto is_visible = [&](const std::string &path) { + return visible_set.count(path) > 0; + }; + + state->selected_browser_paths.erase( + std::remove_if(state->selected_browser_paths.begin(), state->selected_browser_paths.end(), + [&](const std::string &path) { return !is_visible(path); }), + state->selected_browser_paths.end()); + + if (!state->selected_browser_path.empty() && !is_visible(state->selected_browser_path)) { + state->selected_browser_path.clear(); + } + if (!state->browser_selection_anchor.empty() && !is_visible(state->browser_selection_anchor)) { + state->browser_selection_anchor.clear(); + } + if (state->selected_browser_paths.empty()) { + state->selected_browser_path.clear(); + } else if (state->selected_browser_path.empty()) { + state->selected_browser_path = state->selected_browser_paths.back(); + } +} + +std::optional sample_route_series_value(const RouteSeries &series, double tm, bool stairs) { + return app_sample_xy_value_at_time(series.times, series.values, stairs, tm); +} + +std::string browser_series_value_text(const AppSession &session, const UiState &state, std::string_view path) { + auto it = session.series_by_path.find(std::string(path)); + if (it == session.series_by_path.end() || it->second == nullptr) return {}; + + const RouteSeries &series = *it->second; + if (series.values.empty()) return {}; + + const auto enum_it = session.route_data.enum_info.find(series.path); + const EnumInfo *enum_info = enum_it == session.route_data.enum_info.end() ? nullptr : &enum_it->second; + const bool stairs = enum_info != nullptr; + + std::optional value; + if (state.has_tracker_time) { + value = sample_route_series_value(series, state.tracker_time, stairs); + } else { + value = series.values.back(); + } + if (!value.has_value()) return {}; + + const auto display_it = session.route_data.series_formats.find(series.path); + const SeriesFormat display_info = display_it == session.route_data.series_formats.end() + ? compute_series_format(series.values, enum_info != nullptr) + : display_it->second; + + return format_display_value(*value, display_info, enum_info); +} + +bool browser_node_matches(const BrowserNode &node, const std::string &filter) { + if (filter.empty()) return true; + if (!node.full_path.empty() && path_matches_filter(node.full_path, filter)) { + return true; + } + for (const BrowserNode &child : node.children) { + if (browser_node_matches(child, filter)) return true; + } + return false; +} + +} // namespace + +namespace { + +int decimals_needed(double value) { + const double abs_value = std::abs(value); + if (abs_value < 1.0e-12) return 0; + for (int decimals = 0; decimals <= 6; ++decimals) { + const double scale = std::pow(10.0, decimals); + if (std::abs(abs_value * scale - std::round(abs_value * scale)) < 1.0e-6) { + return decimals; + } + } + return 6; +} + +void finalize_series_format(SeriesFormat *format) { + format->digits_before = std::max(format->digits_before, 1); + format->decimals = std::clamp(format->decimals, 0, 6); + format->integer_like = format->decimals == 0; + const int sign_width = format->has_negative ? 1 : 0; + const int dot_width = format->decimals > 0 ? 1 : 0; + format->total_width = sign_width + format->digits_before + dot_width + format->decimals; + std::snprintf(format->fmt, sizeof(format->fmt), "%%%d.%df", format->total_width, format->decimals); +} + +} // namespace + +SeriesFormat compute_series_format(const std::vector &values, bool enum_like) { + SeriesFormat format; + if (values.empty()) return format; + + const size_t step = std::max(1, values.size() / 256); + bool saw_finite = false; + bool all_integer = enum_like; + double min_value = 0.0; + double max_value = 0.0; + int max_needed_decimals = 0; + + for (size_t i = 0; i < values.size(); i += step) { + const double value = values[i]; + if (!std::isfinite(value)) continue; + if (!saw_finite) { + min_value = value; + max_value = value; + saw_finite = true; + } else { + min_value = std::min(min_value, value); + max_value = std::max(max_value, value); + } + if (std::abs(value - std::round(value)) > 1.0e-9) { + all_integer = false; + } + if (!all_integer) { + max_needed_decimals = std::max(max_needed_decimals, decimals_needed(value)); + } + } + + if (!saw_finite) return format; + + format.has_negative = min_value < 0.0; + const double peak = std::max(std::abs(min_value), std::abs(max_value)); + format.digits_before = peak < 1.0 ? 1 : static_cast(std::floor(std::log10(peak))) + 1; + + if (enum_like || all_integer) { + format.decimals = 0; + } else if (peak >= 1000.0) { + format.decimals = std::min(max_needed_decimals, 1); + } else if (peak >= 100.0) { + format.decimals = std::min(max_needed_decimals, 2); + } else { + format.decimals = std::min(max_needed_decimals, 4); + } + + finalize_series_format(&format); + return format; +} + +std::string format_display_value(double display_value, + const SeriesFormat &display_info, + const EnumInfo *enum_info) { + if (!std::isfinite(display_value)) return "---"; + if (enum_info != nullptr) { + const int idx = static_cast(std::llround(display_value)); + if (idx >= 0 && std::abs(display_value - static_cast(idx)) < 0.01 + && static_cast(idx) < enum_info->names.size() + && !enum_info->names[static_cast(idx)].empty()) { + return enum_info->names[static_cast(idx)]; + } + } + char buf[64] = {}; + std::snprintf(buf, sizeof(buf), display_info.fmt, display_value); + return buf; +} + +std::vector decode_browser_drag_payload(std::string_view payload) { + std::vector out; + size_t begin = 0; + while (begin <= payload.size()) { + const size_t end = payload.find('\n', begin); + const size_t length = (end == std::string_view::npos ? payload.size() : end) - begin; + if (length > 0) { + out.emplace_back(payload.substr(begin, length)); + } + if (end == std::string_view::npos) break; + begin = end + 1; + } + return out; +} + +void collect_visible_leaf_paths(const BrowserNode &node, + const std::string &filter, + std::vector *out) { + if (!browser_node_matches(node, filter)) { + return; + } + if (node.children.empty()) { + if (!node.full_path.empty()) { + out->push_back(node.full_path); + } + return; + } + for (const BrowserNode &child : node.children) { + collect_visible_leaf_paths(child, filter, out); + } +} + +void rebuild_browser_nodes(AppSession *session, UiState *state) { + const std::vector paths = visible_browser_paths(session->route_data, state->show_deprecated_fields); + session->browser_nodes = build_browser_tree(paths); + prune_browser_selection(state, paths); +} + +void rebuild_route_index(AppSession *session) { + session->series_by_path.clear(); + session->route_data.series_formats.clear(); + for (RouteSeries &series : session->route_data.series) { + session->series_by_path.emplace(series.path, &series); + const bool enum_like = session->route_data.enum_info.find(series.path) != session->route_data.enum_info.end(); + session->route_data.series_formats.emplace(series.path, compute_series_format(series.values, enum_like)); + } +} + +void draw_browser_node(AppSession *session, + const BrowserNode &node, + UiState *state, + const std::string &filter, + const std::vector &visible_paths) { + if (!browser_node_matches(node, filter)) { + return; + } + + if (node.children.empty()) { + const bool selected = browser_selection_contains(*state, node.full_path); + const std::string value_text = browser_series_value_text(*session, *state, node.full_path); + const ImGuiStyle &style = ImGui::GetStyle(); + const ImVec2 row_size(std::max(1.0f, ImGui::GetContentRegionAvail().x), ImGui::GetFrameHeight()); + ImGui::PushID(node.full_path.c_str()); + const bool clicked = ImGui::InvisibleButton("##browser_leaf", row_size); + const bool hovered = ImGui::IsItemHovered(); + const bool held = ImGui::IsItemActive(); + const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + if (selected || hovered) { + const ImU32 bg = ImGui::GetColorU32(selected + ? (held ? ImGuiCol_HeaderActive : ImGuiCol_Header) + : ImGuiCol_HeaderHovered); + draw_list->AddRectFilled(rect.Min, rect.Max, bg, 0.0f); + } + + const float value_right = rect.Max.x - style.FramePadding.x; + const float value_left = value_right - (value_text.empty() ? 0.0f : BROWSER_VALUE_WIDTH); + const float label_left = rect.Min.x + style.FramePadding.x; + const float label_right = value_text.empty() + ? rect.Max.x - style.FramePadding.x + : std::max(label_left + 40.0f, value_left - 10.0f); + ImGui::RenderTextEllipsis(draw_list, + ImVec2(label_left, rect.Min.y + style.FramePadding.y), + ImVec2(label_right, rect.Max.y), + label_right, + node.label.c_str(), + nullptr, + nullptr); + if (!value_text.empty()) { + app_push_mono_font(); + ImGui::PushStyleColor(ImGuiCol_Text, selected ? color_rgb(70, 77, 86) : color_rgb(116, 124, 133)); + ImGui::RenderTextClipped(ImVec2(value_left, rect.Min.y + style.FramePadding.y), + ImVec2(value_right, rect.Max.y), + value_text.c_str(), + nullptr, + nullptr, + ImVec2(1.0f, 0.0f)); + ImGui::PopStyleColor(); + app_pop_mono_font(); + } + + if (clicked) { + const bool shift_down = ImGui::GetIO().KeyShift; + const bool ctrl_down = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeySuper; + if (shift_down) { + select_browser_range(state, visible_paths, node.full_path); + } else if (ctrl_down) { + toggle_browser_selection(state, node.full_path); + } else { + set_browser_selection_single(state, node.full_path); + } + } + if (hovered && ImGui::IsMouseDoubleClicked(0)) { + set_browser_selection_single(state, node.full_path); + app_add_curve_to_active_pane(session, state, node.full_path); + } + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) { + const std::vector drag_paths = browser_drag_paths(*state, node.full_path); + const std::string payload = encode_browser_drag_payload(drag_paths); + ImGui::SetDragDropPayload("JOTP_BROWSER_PATHS", payload.c_str(), payload.size() + 1); + if (drag_paths.size() == 1) { + ImGui::TextUnformatted(drag_paths.front().c_str()); + } else { + ImGui::Text("%zu timeseries", drag_paths.size()); + ImGui::TextUnformatted(drag_paths.front().c_str()); + } + ImGui::EndDragDropSource(); + } + ImGui::PopID(); + return; + } + + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_SpanAvailWidth; + if (!filter.empty()) { + flags |= ImGuiTreeNodeFlags_DefaultOpen; + } + const bool open = ImGui::TreeNodeEx(node.label.c_str(), flags); + if (open) { + for (const BrowserNode &child : node.children) { + draw_browser_node(session, child, state, filter, visible_paths); + } + ImGui::TreePop(); + } +} diff --git a/tools/jotpluggler/camera.cc b/tools/jotpluggler/camera.cc new file mode 100644 index 0000000000..24a35d8794 --- /dev/null +++ b/tools/jotpluggler/camera.cc @@ -0,0 +1,54 @@ +#include "tools/jotpluggler/camera.h" + +#include "imgui.h" +#include "imgui_internal.h" + +namespace { + +bool draw_camera_fit_toggle_overlay(bool fit_to_pane) { + const ImVec2 window_pos = ImGui::GetWindowPos(); + const ImVec2 content_min = ImGui::GetWindowContentRegionMin(); + const ImRect rect(ImVec2(window_pos.x + content_min.x + 8.0f, window_pos.y + content_min.y + 8.0f), + ImVec2(window_pos.x + content_min.x + 58.0f, window_pos.y + content_min.y + 28.0f)); + const bool hovered = ImGui::IsMouseHoveringRect(rect.Min, rect.Max, false); + const bool held = hovered && ImGui::IsMouseDown(ImGuiMouseButton_Left); + if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + draw_list->AddRectFilled(rect.Min, rect.Max, hovered ? IM_COL32(255, 255, 255, 234) : IM_COL32(255, 255, 255, 214), 4.0f); + draw_list->AddRect(rect.Min, rect.Max, IM_COL32(184, 189, 196, 255), 4.0f, 0, 1.0f); + const ImRect box(ImVec2(rect.Min.x + 6.0f, rect.Min.y + 4.0f), ImVec2(rect.Min.x + 18.0f, rect.Min.y + 16.0f)); + draw_list->AddRect(box.Min, box.Max, IM_COL32(112, 120, 129, 255), 2.0f, 0, 1.0f); + if (fit_to_pane) { + draw_list->AddLine(ImVec2(box.Min.x + 2.5f, box.Min.y + 6.5f), ImVec2(box.Min.x + 5.5f, box.Max.y - 2.5f), IM_COL32(60, 111, 202, 255), 1.8f); + draw_list->AddLine(ImVec2(box.Min.x + 5.5f, box.Max.y - 2.5f), ImVec2(box.Max.x - 2.5f, box.Min.y + 2.5f), IM_COL32(60, 111, 202, 255), 1.8f); + } + draw_list->AddText(ImVec2(box.Max.x + 6.0f, rect.Min.y + 3.0f), IM_COL32(72, 79, 88, 255), "Fit"); + return hovered && !held && ImGui::IsMouseReleased(ImGuiMouseButton_Left); +} + +} // namespace + +void draw_camera_pane(AppSession *session, UiState *state, TabUiState *tab_state, int pane_index, const Pane &pane) { + CameraFeedView *feed = session->pane_camera_feeds[static_cast(pane.camera_view)].get(); + if (feed == nullptr) { + ImGui::TextDisabled("Camera unavailable"); + return; + } + + const bool fit_to_pane = tab_state != nullptr + && pane_index >= 0 + && pane_index < static_cast(tab_state->camera_panes.size()) + ? tab_state->camera_panes[static_cast(pane_index)].fit_to_pane + : true; + if (state->has_tracker_time) { + feed->update(state->tracker_time); + } + feed->drawSized(ImGui::GetContentRegionAvail(), session->async_route_loading, fit_to_pane); + if (tab_state != nullptr + && pane_index >= 0 + && pane_index < static_cast(tab_state->camera_panes.size()) + && draw_camera_fit_toggle_overlay(fit_to_pane)) { + tab_state->camera_panes[static_cast(pane_index)].fit_to_pane = !fit_to_pane; + } +} diff --git a/tools/jotpluggler/camera.h b/tools/jotpluggler/camera.h new file mode 100644 index 0000000000..666e335af8 --- /dev/null +++ b/tools/jotpluggler/camera.h @@ -0,0 +1,5 @@ +#pragma once + +#include "tools/jotpluggler/app.h" + +void draw_camera_pane(AppSession *session, UiState *state, TabUiState *tab_state, int pane_index, const Pane &pane); diff --git a/tools/jotpluggler/common.cc b/tools/jotpluggler/common.cc new file mode 100644 index 0000000000..9bd6c18cea --- /dev/null +++ b/tools/jotpluggler/common.cc @@ -0,0 +1,179 @@ +#include "tools/jotpluggler/common.h" + +#include +#include +#include + +namespace { + +std::string format_coord(const GpsPoint &point) { + return util::string_format("%.5f,%.5f", point.lat, point.lon); +} + +} // namespace + +const CameraViewSpec &camera_view_spec(CameraViewKind view) { + auto it = std::find_if(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) { + return spec.view == view; + }); + return it != kCameraViewSpecs.end() ? *it : kCameraViewSpecs.front(); +} + +const CameraViewSpec *camera_view_spec_from_special_item(std::string_view item_id) { + auto it = std::find_if(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) { + return item_id == spec.special_item_id; + }); + return it != kCameraViewSpecs.end() ? &*it : nullptr; +} + +const CameraViewSpec *camera_view_spec_from_layout_name(std::string_view layout_name) { + auto it = std::find_if(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) { + return layout_name == spec.layout_name; + }); + return it != kCameraViewSpecs.end() ? &*it : nullptr; +} + +const SpecialItemSpec *special_item_spec(std::string_view item_id) { + auto it = std::find_if(kSpecialItemSpecs.begin(), kSpecialItemSpecs.end(), [&](const SpecialItemSpec &spec) { + return item_id == spec.id; + }); + return it != kSpecialItemSpecs.end() ? &*it : nullptr; +} + +const char *special_item_label(std::string_view item_id) { + const SpecialItemSpec *spec = special_item_spec(item_id); + return spec != nullptr ? spec->label : "Item"; +} + +bool pane_kind_is_special(PaneKind kind) { + return kind == PaneKind::Map || kind == PaneKind::Camera; +} + +bool is_default_special_title(std::string_view title) { + if (title == "Map") return true; + return std::any_of(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) { + return title == spec.label; + }); +} + +CameraViewKind sidebar_preview_camera_view(const AppSession &session) { + return session.route_data.road_camera.entries.empty() && !session.route_data.qroad_camera.entries.empty() + ? CameraViewKind::QRoad + : CameraViewKind::Road; +} + +const std::filesystem::path &repo_root() { + static const std::filesystem::path root(JOTP_REPO_ROOT); + return root; +} + +ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha) { + return timeline_entry_color(type, alpha, {111, 143, 175}); +} + +ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha, std::array none_color) { + switch (type) { + case TimelineEntry::Type::Engaged: + return ImGui::GetColorU32(color_rgb(0, 163, 108, alpha)); + case TimelineEntry::Type::AlertInfo: + return ImGui::GetColorU32(color_rgb(255, 195, 0, alpha)); + case TimelineEntry::Type::AlertWarning: + case TimelineEntry::Type::AlertCritical: + return ImGui::GetColorU32(color_rgb(199, 0, 57, alpha)); + case TimelineEntry::Type::None: + default: + return ImGui::GetColorU32(color_rgb(none_color, alpha)); + } +} + +const char *timeline_entry_label(TimelineEntry::Type type) { + static constexpr const char *kLabels[] = { + "disengaged", + "engaged", + "alert info", + "alert warning", + "alert critical", + }; + const size_t index = static_cast(type); + return index < std::size(kLabels) ? kLabels[index] : kLabels[0]; +} + +TimelineEntry::Type timeline_type_at_time(const std::vector &timeline, double time_value) { + for (const TimelineEntry &entry : timeline) { + if (time_value >= entry.start_time && time_value <= entry.end_time) { + return entry.type; + } + } + return TimelineEntry::Type::None; +} + +std::string normalize_stream_address(std::string address) { + return is_local_stream_address(address) ? "127.0.0.1" : address; +} + +const char *stream_source_kind_label(StreamSourceKind kind) { + static constexpr const char *kLabels[] = { + "Local (MSGQ)", + "Remote (ZMQ)", + }; + const size_t index = static_cast(kind); + return index < std::size(kLabels) ? kLabels[index] : kLabels[0]; +} + +std::string stream_source_target_label(const StreamSourceConfig &source) { + switch (source.kind) { + case StreamSourceKind::CerealRemote: + return normalize_stream_address(source.address); + case StreamSourceKind::CerealLocal: + default: + return "127.0.0.1"; + } +} + +bool env_flag_enabled(const char *name, bool default_value) { + const char *raw = std::getenv(name); + if (raw == nullptr || raw[0] == '\0') { + return default_value; + } + const std::string value = lowercase_copy(util::strip(raw)); + return !(value == "0" || value == "false" || value == "no" || value == "off"); +} + +void open_external_url(std::string_view url) { +#ifdef __APPLE__ + const std::string command = "open " + shell_quote(url) + " &"; +#else + const std::string command = "xdg-open " + shell_quote(url) + " >/dev/null 2>&1 &"; +#endif + util::check_system(command); +} + +std::string route_useradmin_url(const RouteIdentifier &route_id) { + return route_id.empty() ? std::string() + : "https://useradmin.comma.ai/?onebox=" + route_id.dongle_id + "%7C" + route_id.log_id; +} + +std::string route_connect_url(const RouteIdentifier &route_id) { + return route_id.empty() ? std::string() + : "https://connect.comma.ai/" + route_id.canonical(); +} + +std::string route_google_maps_url(const GpsTrace &trace) { + if (trace.points.size() < 2) { + return {}; + } + + const std::string prefix = "https://www.google.com/maps/dir/?api=1&travelmode=driving&origin=" + + format_coord(trace.points.front()) + "&destination=" + format_coord(trace.points.back()); + for (size_t n = std::min(9, trace.points.size() > 2 ? trace.points.size() - 2 : 0); ; --n) { + std::string url = prefix; + if (n > 0) { + url += "&waypoints="; + for (size_t i = 0; i < n; ++i) { + if (i) url += "%7C"; + url += format_coord(trace.points[1 + ((trace.points.size() - 2) * (i + 1)) / (n + 1)]); + } + } + if (url.size() <= 1900 || n == 0) return url; + } +} diff --git a/tools/jotpluggler/common.h b/tools/jotpluggler/common.h new file mode 100644 index 0000000000..25b1f91e89 --- /dev/null +++ b/tools/jotpluggler/common.h @@ -0,0 +1,63 @@ +#pragma once + +#include "tools/jotpluggler/app.h" + +#include +#include + +struct CameraViewSpec { + CameraViewKind view = CameraViewKind::Road; + const char *label = ""; + const char *runtime_name = ""; + const char *layout_name = ""; + const char *special_item_id = ""; + CameraFeedIndex RouteData::*route_member = nullptr; +}; + +struct SpecialItemSpec { + const char *id = ""; + const char *label = ""; + PaneKind kind = PaneKind::Plot; + CameraViewKind camera_view = CameraViewKind::Road; +}; + +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::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}, +}}; + +inline constexpr std::array kSpecialItemSpecs = {{ + {"map", "Map", PaneKind::Map, CameraViewKind::Road}, + {kCameraViewSpecs[0].special_item_id, kCameraViewSpecs[0].label, PaneKind::Camera, kCameraViewSpecs[0].view}, + {kCameraViewSpecs[1].special_item_id, kCameraViewSpecs[1].label, PaneKind::Camera, kCameraViewSpecs[1].view}, + {kCameraViewSpecs[2].special_item_id, kCameraViewSpecs[2].label, PaneKind::Camera, kCameraViewSpecs[2].view}, + {kCameraViewSpecs[3].special_item_id, kCameraViewSpecs[3].label, PaneKind::Camera, kCameraViewSpecs[3].view}, +}}; + +const CameraViewSpec &camera_view_spec(CameraViewKind view); +const CameraViewSpec *camera_view_spec_from_special_item(std::string_view item_id); +const CameraViewSpec *camera_view_spec_from_layout_name(std::string_view layout_name); + +const SpecialItemSpec *special_item_spec(std::string_view item_id); +const char *special_item_label(std::string_view item_id); + +bool pane_kind_is_special(PaneKind kind); +bool is_default_special_title(std::string_view title); +CameraViewKind sidebar_preview_camera_view(const AppSession &session); +const std::filesystem::path &repo_root(); + +ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha = 1.0f); +ImU32 timeline_entry_color(TimelineEntry::Type type, float alpha, std::array none_color); +const char *timeline_entry_label(TimelineEntry::Type type); +TimelineEntry::Type timeline_type_at_time(const std::vector &timeline, double time_value); +std::string normalize_stream_address(std::string address); +const char *stream_source_kind_label(StreamSourceKind kind); +std::string stream_source_target_label(const StreamSourceConfig &source); + +bool env_flag_enabled(const char *name, bool default_value = false); +void open_external_url(std::string_view url); +std::string route_useradmin_url(const RouteIdentifier &route_id); +std::string route_connect_url(const RouteIdentifier &route_id); +std::string route_google_maps_url(const GpsTrace &trace); diff --git a/tools/jotpluggler/custom_series.cc b/tools/jotpluggler/custom_series.cc new file mode 100644 index 0000000000..bd2a3f36d1 --- /dev/null +++ b/tools/jotpluggler/custom_series.cc @@ -0,0 +1,750 @@ +#include "tools/jotpluggler/app.h" +#include "tools/jotpluggler/common.h" + +#include "implot.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "third_party/json11/json11.hpp" + +namespace fs = std::filesystem; + +namespace { + +struct PythonEvalResult { + std::vector xs; + std::vector ys; +}; + +struct CustomSeriesTemplate { + const char *name; + const char *globals_code; + const char *function_code; + const char *preview_text; + int required_additional_sources; + const char *requirement_text; +}; + +void write_binary_vector(const fs::path &path, const std::vector &values) { + write_file_or_throw(path, values.data(), values.size() * sizeof(double)); +} + +std::vector read_binary_vector(const fs::path &path) { + const std::string raw = read_file_or_throw(path); + if (raw.size() % sizeof(double) != 0) { + throw std::runtime_error("Invalid binary series file: " + path.string()); + } + std::vector values(raw.size() / sizeof(double)); + if (!values.empty()) { + std::memcpy(values.data(), raw.data(), raw.size()); + } + return values; +} + +void write_text_file(const fs::path &path, std::string_view text) { + write_file_or_throw(path, text); +} + +fs::path create_custom_series_temp_dir() { + const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count(); + const fs::path dir = fs::temp_directory_path() / ("jotpluggler_math_" + std::to_string(::getpid()) + "_" + std::to_string(stamp)); + fs::create_directories(dir); + return dir; +} + +void reset_custom_series_editor(CustomSeriesEditorState *editor) { + *editor = CustomSeriesEditorState{}; +} + +bool add_additional_source(CustomSeriesEditorState *editor, const std::string &path) { + if (path.empty() || path == editor->linked_source) return false; + if (std::find(editor->additional_sources.begin(), editor->additional_sources.end(), path) != editor->additional_sources.end()) { + return false; + } + editor->additional_sources.push_back(path); + return true; +} + +std::string next_custom_curve_name(const Pane &pane) { + std::set used; + for (const Curve &curve : pane.curves) { + if (!curve.label.empty()) { + used.insert(curve.label); + } + if (!curve.name.empty()) { + used.insert(curve.name); + } + } + for (int i = 1; i < 1000; ++i) { + const std::string candidate = "series" + std::to_string(i); + if (used.find(candidate) == used.end()) { + return candidate; + } + } + return "series"; +} + +Curve make_custom_curve(const Pane &pane, + const std::string &name, + const CustomPythonSeries &spec, + PythonEvalResult result) { + Curve curve; + curve.name = name; + curve.label = name; + curve.color = app_next_curve_color(pane); + curve.runtime_only = true; + curve.custom_python = spec; + curve.xs = std::move(result.xs); + curve.ys = std::move(result.ys); + return curve; +} + +bool upsert_custom_curve_in_pane(WorkspaceTab *tab, int pane_index, Curve curve) { + if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { + return false; + } + Pane &pane = tab->panes[static_cast(pane_index)]; + for (Curve &existing : pane.curves) { + if (existing.runtime_only && existing.name == curve.name) { + existing.visible = true; + existing.label = curve.label; + existing.custom_python = curve.custom_python; + existing.xs = std::move(curve.xs); + existing.ys = std::move(curve.ys); + return false; + } + } + pane.curves.push_back(std::move(curve)); + return true; +} + +std::set collect_custom_series_paths(const CustomPythonSeries &spec, + std::string_view globals_code, + std::string_view function_code) { + std::set paths; + if (!spec.linked_source.empty()) { + paths.insert(spec.linked_source); + } + paths.insert(spec.additional_sources.begin(), spec.additional_sources.end()); + + static const std::regex kPathRegex(R"([tv]\(\s*["']([^"']+)["']\s*\))"); + const auto collect_from = [&](std::string_view code) { + std::string owned(code); + for (std::sregex_iterator it(owned.begin(), owned.end(), kPathRegex), end; it != end; ++it) { + paths.insert((*it)[1].str()); + } + }; + collect_from(globals_code); + collect_from(function_code); + return paths; +} + +PythonEvalResult evaluate_custom_python_series(const AppSession &session, + const CustomPythonSeries &spec) { + const std::set referenced_paths = + collect_custom_series_paths(spec, spec.globals_code, spec.function_code); + if (referenced_paths.empty()) throw std::runtime_error("No input series referenced. Set an input timeseries or reference route paths in code."); + + const fs::path temp_dir = create_custom_series_temp_dir(); + try { + const fs::path globals_path = temp_dir / "globals.py"; + const fs::path code_path = temp_dir / "code.py"; + const fs::path manifest_path = temp_dir / "manifest.json"; + const fs::path out_t_path = temp_dir / "result.t.bin"; + const fs::path out_v_path = temp_dir / "result.v.bin"; + + write_text_file(globals_path, spec.globals_code); + write_text_file(code_path, spec.function_code); + + json11::Json::array paths_json(session.route_data.paths.begin(), session.route_data.paths.end()); + json11::Json::array additional_json(spec.additional_sources.begin(), spec.additional_sources.end()); + json11::Json::array series_json; + size_t series_index = 0; + for (const std::string &path : referenced_paths) { + const RouteSeries *series = app_find_route_series(session, path); + if (series == nullptr || series->times.size() < 2 || series->times.size() != series->values.size()) { + throw std::runtime_error("Missing route series " + path); + } + const std::string prefix = "series_" + std::to_string(series_index++); + const fs::path time_path = temp_dir / (prefix + ".t.bin"); + const fs::path value_path = temp_dir / (prefix + ".v.bin"); + write_binary_vector(time_path, series->times); + write_binary_vector(value_path, series->values); + series_json.push_back(json11::Json::object{ + {"path", path}, {"t", time_path.string()}, {"v", value_path.string()}}); + } + const json11::Json manifest_json = json11::Json::object{ + {"paths", std::move(paths_json)}, + {"linked_source", spec.linked_source}, + {"additional_sources", std::move(additional_json)}, + {"series", std::move(series_json)}, + }; + write_text_file(manifest_path, manifest_json.dump()); + + const CommandResult process = run_process_capture_output({ + "python3", + (repo_root() / "tools" / "jotpluggler" / "math_eval.py").string(), + manifest_path.string(), + globals_path.string(), + code_path.string(), + out_t_path.string(), + out_v_path.string(), + }); + if (process.exit_code != 0) { + const std::string error_text = util::strip(process.output); + throw std::runtime_error(error_text.empty() ? "Python evaluation failed" : error_text); + } + + PythonEvalResult result; + result.xs = read_binary_vector(out_t_path); + result.ys = read_binary_vector(out_v_path); + if (result.xs.size() < 2 || result.xs.size() != result.ys.size()) { + throw std::runtime_error("Custom series returned invalid output"); + } + fs::remove_all(temp_dir); + return result; + } catch (...) { + std::error_code ignore_error; + fs::remove_all(temp_dir, ignore_error); + throw; + } +} + +void refresh_custom_curve_samples(AppSession *session, UiState *state, Curve *curve) { + if (!curve->custom_python.has_value()) { + return; + } + if (!session->route_data.has_time_range || session->route_data.series.empty()) { + curve->runtime_error_message.clear(); + curve->xs.clear(); + curve->ys.clear(); + return; + } + try { + PythonEvalResult result = evaluate_custom_python_series(*session, *curve->custom_python); + curve->runtime_error_message.clear(); + curve->xs = std::move(result.xs); + curve->ys = std::move(result.ys); + } catch (const std::exception &err) { + curve->xs.clear(); + curve->ys.clear(); + const std::string err_text = err.what(); + if (session->data_mode == SessionDataMode::Stream && util::starts_with(err_text, "Missing route series ")) { + curve->runtime_error_message = err_text; + return; + } + const std::string error_message = std::string("Failed to evaluate custom series \"") + + app_curve_display_name(*curve) + "\":\n\n" + err_text; + if (curve->runtime_error_message != error_message) { + curve->runtime_error_message = error_message; + state->error_text = error_message; + state->open_error_popup = true; + } + } +} + +const std::array &custom_series_templates() { + static constexpr std::array kTemplates = {{ + { + .name = "Derivative", + .globals_code = "", + .function_code = "return np.gradient(value, time)", + .preview_text = "return np.gradient(value, time)", + .required_additional_sources = 0, + .requirement_text = "", + }, + { + .name = "Difference", + .globals_code = "", + .function_code = "return value - v1", + .preview_text = "Requires one additional source timeseries.\n\nreturn value - v1", + .required_additional_sources = 1, + .requirement_text = "Difference requires one additional source timeseries for v1.", + }, + { + .name = "Smoothing", + .globals_code = "window = 20\nweights = np.ones(window) / window", + .function_code = "return np.convolve(value, weights, mode='same')", + .preview_text = "window = 20\nweights = np.ones(window) / window\n\nreturn np.convolve(value, weights, mode='same')", + .required_additional_sources = 0, + .requirement_text = "", + }, + { + .name = "Integral", + .globals_code = "", + .function_code = "dt = np.mean(np.diff(time))\nreturn np.cumsum(value) * dt", + .preview_text = "dt = np.mean(np.diff(time))\nreturn np.cumsum(value) * dt", + .required_additional_sources = 0, + .requirement_text = "", + }, + }}; + return kTemplates; +} + +void draw_custom_series_help_popup(CustomSeriesEditorState *editor) { + if (editor->open_help) { + ImGui::OpenPopup("Custom Series Help"); + editor->open_help = false; + } + if (!ImGui::BeginPopupModal("Custom Series Help", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + ImGui::TextUnformatted("Available variables"); + ImGui::Separator(); + ImGui::BulletText("np: numpy"); + ImGui::BulletText("t(path), v(path): timestamps and values for a route series"); + ImGui::BulletText("paths: all available route series paths"); + ImGui::BulletText("time, value: linked input timeseries"); + ImGui::BulletText("t1, v1, t2, v2, ...: additional source timeseries"); + ImGui::Spacing(); + ImGui::TextWrapped("Write either a single expression like \"return np.gradient(value, time)\" " + "or a multi-line Python body that returns an array or a (times, values) tuple."); + ImGui::Spacing(); + if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_custom_series_preview(const AppSession &session, CustomSeriesEditorState *editor) { + std::vector preview_xs; + std::vector preview_ys; + std::string preview_label = editor->preview_label; + if (editor->preview_is_result && editor->preview_xs.size() > 1 && editor->preview_xs.size() == editor->preview_ys.size()) { + preview_xs = editor->preview_xs; + preview_ys = editor->preview_ys; + if (preview_label.empty()) { + preview_label = "Result preview"; + } + } else if (!editor->linked_source.empty()) { + if (const RouteSeries *series = app_find_route_series(session, editor->linked_source); series != nullptr + && series->times.size() > 1 && series->times.size() == series->values.size()) { + preview_xs = series->times; + preview_ys = series->values; + preview_label = "Input preview (not result)"; + } + } + + if (!preview_xs.empty() && preview_xs.size() == preview_ys.size()) { + std::vector plot_xs; + std::vector plot_ys; + app_decimate_samples(preview_xs, preview_ys, 1200, &plot_xs, &plot_ys); + const double preview_x_min = preview_xs.front(); + const double preview_x_max = preview_xs.back() > preview_xs.front() + ? preview_xs.back() + : preview_xs.front() + 1e-6; + std::string plot_id = "##custom_series_preview"; + if (editor->preview_is_result) { + plot_id += "_result_"; + plot_id += editor->name.empty() ? preview_label : editor->name; + } else if (!editor->linked_source.empty()) { + plot_id += "_input_"; + plot_id += editor->linked_source; + } + ImGui::TextUnformatted(preview_label.c_str()); + if (!editor->linked_source.empty() && !editor->preview_is_result) { + ImGui::SameLine(); + ImGui::TextDisabled("%s", editor->linked_source.c_str()); + } + if (ImPlot::BeginPlot(plot_id.c_str(), + ImVec2(-1.0f, std::max(180.0f, ImGui::GetContentRegionAvail().y - 6.0f)), + ImPlotFlags_NoTitle | ImPlotFlags_NoMenus | ImPlotFlags_NoLegend)) { + ImPlot::SetupAxes(nullptr, nullptr, ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight, + ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight | ImPlotAxisFlags_AutoFit | ImPlotAxisFlags_RangeFit); + ImPlot::SetupAxisLimitsConstraints(ImAxis_X1, preview_x_min, preview_x_max); + ImPlot::SetupAxisLimits(ImAxis_X1, preview_x_min, preview_x_max, ImPlotCond_Once); + ImPlot::SetupAxisFormat(ImAxis_X1, "%.1f"); + ImPlot::SetupAxisFormat(ImAxis_Y1, "%.6g"); + ImPlotSpec spec; + spec.LineColor = color_rgb(35, 107, 180); + spec.LineWeight = 2.0f; + ImPlot::PlotLine("##custom_preview_line", plot_xs.data(), plot_ys.data(), static_cast(plot_xs.size()), spec); + ImPlot::EndPlot(); + } + } else { + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 72.0f); + ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(116, 124, 133)); + ImGui::TextWrapped("Choose an input timeseries or click Preview to evaluate the custom result."); + ImGui::PopStyleColor(); + } +} + +std::string custom_series_name_status(const Pane &pane, std::string_view name) { + const std::string trimmed = util::strip(std::string(name)); + if (trimmed.empty()) return "name required"; + if (!trimmed.empty() && trimmed.front() == '/') { + return "cannot start with /"; + } + for (const Curve &curve : pane.curves) { + if (curve.runtime_only && curve.name == trimmed) return "updates existing curve"; + } + return "new curve"; +} + +const CustomSeriesTemplate &selected_custom_series_template(const CustomSeriesEditorState &editor) { + const auto &templates = custom_series_templates(); + return templates[static_cast(std::clamp(editor.selected_template, 0, static_cast(templates.size()) - 1))]; +} + +bool custom_series_template_ready(const CustomSeriesEditorState &editor) { + const CustomSeriesTemplate &templ = selected_custom_series_template(editor); + return !editor.linked_source.empty() + && static_cast(editor.additional_sources.size()) >= templ.required_additional_sources; +} + +bool prepare_custom_series_spec(CustomSeriesEditorState *editor, + UiState *state, + bool require_name, + CustomPythonSeries *out_spec) { + editor->name = util::strip(editor->name); + editor->linked_source = util::strip(editor->linked_source); + for (std::string &path : editor->additional_sources) { + path = util::strip(path); + } + editor->additional_sources.erase( + std::remove_if(editor->additional_sources.begin(), editor->additional_sources.end(), + [&](const std::string &path) { return path.empty() || path == editor->linked_source; }), + editor->additional_sources.end()); + + if (require_name && editor->name.empty()) { + state->error_text = "Custom series name is required."; + state->open_error_popup = true; + return false; + } + if (require_name && !editor->name.empty() && editor->name.front() == '/') { + state->error_text = "Custom series names may not start with '/'."; + state->open_error_popup = true; + return false; + } + + *out_spec = CustomPythonSeries{ + .linked_source = editor->linked_source, + .additional_sources = editor->additional_sources, + .globals_code = editor->globals_code, + .function_code = editor->function_code, + }; + return true; +} + +bool preview_custom_series_editor(AppSession *session, UiState *state) { + CustomSeriesEditorState &editor = state->custom_series; + const CustomSeriesTemplate &templ = selected_custom_series_template(editor); + if (editor.linked_source.empty()) { + state->error_text = "Choose an input timeseries before previewing."; + state->open_error_popup = true; + state->status_text = "Custom series preview failed"; + return false; + } + if (static_cast(editor.additional_sources.size()) < templ.required_additional_sources) { + state->error_text = templ.requirement_text; + state->open_error_popup = true; + state->status_text = "Custom series preview failed"; + return false; + } + CustomPythonSeries spec; + if (!prepare_custom_series_spec(&editor, state, false, &spec)) return false; + + try { + PythonEvalResult result = evaluate_custom_python_series(*session, spec); + editor.preview_label = editor.name.empty() ? "Result preview" : editor.name; + editor.preview_xs = std::move(result.xs); + editor.preview_ys = std::move(result.ys); + editor.preview_is_result = true; + state->status_text = "Previewed custom series"; + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + state->status_text = "Custom series preview failed"; + return false; + } +} + +bool apply_custom_series_editor(AppSession *session, UiState *state) { + WorkspaceTab *tab = app_active_tab(&session->layout, *state); + TabUiState *tab_state = app_active_tab_state(state); + if (tab == nullptr || tab_state == nullptr) { + state->status_text = "No active pane"; + return false; + } + if (tab_state->active_pane_index < 0 || tab_state->active_pane_index >= static_cast(tab->panes.size())) { + state->status_text = "No active pane"; + return false; + } + + CustomSeriesEditorState &editor = state->custom_series; + CustomPythonSeries spec; + if (!prepare_custom_series_spec(&editor, state, true, &spec)) return false; + + try { + PythonEvalResult result = evaluate_custom_python_series(*session, spec); + const SketchLayout before_layout = session->layout; + Pane &pane = tab->panes[static_cast(tab_state->active_pane_index)]; + editor.preview_label = editor.name; + editor.preview_xs = result.xs; + editor.preview_ys = result.ys; + editor.preview_is_result = true; + const bool inserted = upsert_custom_curve_in_pane(tab, + tab_state->active_pane_index, + make_custom_curve(pane, editor.name, spec, std::move(result))); + state->undo.push(before_layout); + state->status_text = inserted ? "Created custom series " + editor.name + : "Updated custom series " + editor.name; + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + state->status_text = "Custom series failed"; + return false; + } +} + +} // namespace + +void open_custom_series_editor(UiState *state, const std::string &preferred_source) { + CustomSeriesEditorState &editor = state->custom_series; + if (!editor.open && editor.name.empty() && editor.linked_source.empty() && editor.function_code == "return value") { + editor.focus_name = true; + } + if (editor.linked_source.empty() && !preferred_source.empty()) { + editor.linked_source = preferred_source; + } + editor.open = true; + editor.request_select = true; +} + +std::string preferred_custom_series_source(const Pane &pane) { + for (const Curve &curve : pane.curves) { + if (!curve.name.empty() && curve.name.front() == '/') { + return curve.name; + } + if (curve.custom_python.has_value() && !curve.custom_python->linked_source.empty()) { + return curve.custom_python->linked_source; + } + } + return {}; +} + +void refresh_all_custom_curves(AppSession *session, UiState *state) { + for (WorkspaceTab &tab : session->layout.tabs) { + for (Pane &pane : tab.panes) { + for (Curve &curve : pane.curves) { + refresh_custom_curve_samples(session, state, &curve); + } + } + } +} + +void draw_editor_source_panel(UiState *state, CustomSeriesEditorState &editor) { + ImGui::TextWrapped("Input timeseries. Provides arguments time and value:"); + ImGui::SetNextItemWidth(-FLT_MIN); + input_text_string("##custom_linked_source", &editor.linked_source, ImGuiInputTextFlags_ReadOnly); + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload *payload = ImGui::AcceptDragDropPayload("JOTP_BROWSER_PATH")) { + editor.linked_source = static_cast(payload->Data); + editor.additional_sources.erase( + std::remove(editor.additional_sources.begin(), editor.additional_sources.end(), editor.linked_source), + editor.additional_sources.end()); + editor.preview_is_result = false; + } + ImGui::EndDragDropTarget(); + } + if (ImGui::Button("Use Selected", ImVec2(120.0f, 0.0f)) && !state->selected_browser_path.empty()) { + editor.linked_source = state->selected_browser_path; + editor.additional_sources.erase( + std::remove(editor.additional_sources.begin(), editor.additional_sources.end(), editor.linked_source), + editor.additional_sources.end()); + editor.preview_is_result = false; + } + ImGui::SameLine(); + if (ImGui::Button("Clear", ImVec2(120.0f, 0.0f))) { + editor.linked_source.clear(); + editor.preview_is_result = false; + } + + ImGui::Spacing(); + ImGui::TextUnformatted("Additional source timeseries:"); + ImGui::SameLine(); + const CustomSeriesTemplate &tmpl = selected_custom_series_template(editor); + if (tmpl.required_additional_sources > 0) { + const bool ready = static_cast(editor.additional_sources.size()) >= tmpl.required_additional_sources; + ImGui::TextColored(ready ? color_rgb(58, 126, 73) : color_rgb(180, 122, 44), "%s", tmpl.requirement_text); + } + ImGui::SameLine(); + ImGui::BeginDisabled(editor.selected_additional_source < 0 + || editor.selected_additional_source >= static_cast(editor.additional_sources.size())); + if (ImGui::Button("Remove Selected", ImVec2(140.0f, 0.0f)) + && editor.selected_additional_source >= 0 + && editor.selected_additional_source < static_cast(editor.additional_sources.size())) { + editor.additional_sources.erase(editor.additional_sources.begin() + + static_cast(editor.selected_additional_source)); + editor.selected_additional_source = editor.additional_sources.empty() + ? -1 : std::clamp(editor.selected_additional_source, 0, static_cast(editor.additional_sources.size()) - 1); + editor.preview_is_result = false; + } + ImGui::EndDisabled(); + + if (ImGui::BeginChild("##custom_additional_sources", ImVec2(0.0f, 156.0f), true)) { + if (ImGui::BeginTable("##custom_additional_table", 2, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { + ImGui::TableSetupColumn("id", ImGuiTableColumnFlags_WidthFixed, 42.0f); + ImGui::TableSetupColumn("path", ImGuiTableColumnFlags_WidthStretch); + for (size_t i = 0; i < editor.additional_sources.size(); ++i) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::Text("v%zu", i + 1); + ImGui::TableNextColumn(); + if (ImGui::Selectable(editor.additional_sources[i].c_str(), + editor.selected_additional_source == static_cast(i), + ImGuiSelectableFlags_SpanAllColumns)) { + editor.selected_additional_source = static_cast(i); + } + } + ImGui::EndTable(); + } + if (ImGui::BeginDragDropTarget()) { + if (const ImGuiPayload *payload = ImGui::AcceptDragDropPayload("JOTP_BROWSER_PATH")) { + if (add_additional_source(&editor, static_cast(payload->Data))) + editor.preview_is_result = false; + } + ImGui::EndDragDropTarget(); + } + } + ImGui::EndChild(); + if (ImGui::Button("Add Selected", ImVec2(120.0f, 0.0f))) { + for (const std::string &path : state->selected_browser_paths) { + if (add_additional_source(&editor, path)) editor.preview_is_result = false; + } + } + + ImGui::Spacing(); + ImGui::SeparatorText("Function library"); + const auto &templates = custom_series_templates(); + if (ImGui::BeginChild("##custom_series_template_list", ImVec2(0.0f, 132.0f), true)) { + for (size_t i = 0; i < templates.size(); ++i) { + if (ImGui::Selectable(templates[i].name, editor.selected_template == static_cast(i), + ImGuiSelectableFlags_AllowDoubleClick)) { + editor.selected_template = static_cast(i); + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + editor.globals_code = templates[i].globals_code; + editor.function_code = templates[i].function_code; + editor.preview_is_result = false; + } + } + } + } + ImGui::EndChild(); + if (ImGui::Button("Use Selected Example")) { + const auto &sel = selected_custom_series_template(editor); + editor.globals_code = sel.globals_code; + editor.function_code = sel.function_code; + editor.preview_is_result = false; + } + ImGui::Spacing(); + ImGui::TextDisabled("Preview"); + ImGui::BeginChild("##custom_series_template_preview", ImVec2(0.0f, 0.0f), true); + ImGui::TextUnformatted(selected_custom_series_template(editor).preview_text); + ImGui::EndChild(); +} + +void draw_editor_code_panel(CustomSeriesEditorState &editor, const Pane *active_pane) { + const std::string name_status = active_pane != nullptr ? custom_series_name_status(*active_pane, editor.name) : "no active pane"; + ImGui::TextUnformatted("New name:"); + ImGui::SameLine(); + const bool name_error = name_status == "name required" || name_status == "cannot start with /"; + ImGui::TextColored(name_error ? color_rgb(200, 72, 64) : color_rgb(58, 126, 73), "%s", name_status.c_str()); + if (editor.focus_name) { ImGui::SetKeyboardFocusHere(); editor.focus_name = false; } + ImGui::SetNextItemWidth(-FLT_MIN); + input_text_string("##custom_series_name", &editor.name, ImGuiInputTextFlags_AutoSelectAll); + + ImGui::Spacing(); + ImGui::SeparatorText("Global variables"); + ImGui::SameLine(); + if (ImGui::SmallButton("Help")) editor.open_help = true; + const float globals_h = std::max(96.0f, ImGui::GetContentRegionAvail().y * 0.28f); + if (input_text_multiline_string("##custom_series_globals", &editor.globals_code, + ImVec2(-FLT_MIN, globals_h), ImGuiInputTextFlags_AllowTabInput)) + editor.preview_is_result = false; + + ImGui::Spacing(); + ImGui::TextUnformatted("def calc(time, value):"); + const float func_h = std::max(180.0f, ImGui::GetContentRegionAvail().y - 16.0f); + if (input_text_multiline_string("##custom_series_function", &editor.function_code, + ImVec2(-FLT_MIN, func_h), ImGuiInputTextFlags_AllowTabInput)) + editor.preview_is_result = false; +} + +void draw_custom_series_editor(AppSession *session, UiState *state) { + CustomSeriesEditorState &editor = state->custom_series; + if (!editor.open) return; + + WorkspaceTab *tab = app_active_tab(&session->layout, *state); + TabUiState *tab_state = app_active_tab_state(state); + Pane *active_pane = (tab && tab_state && tab_state->active_pane_index >= 0 + && tab_state->active_pane_index < static_cast(tab->panes.size())) + ? &tab->panes[static_cast(tab_state->active_pane_index)] : nullptr; + if (editor.focus_name && active_pane && editor.name.empty()) + editor.name = next_custom_curve_name(*active_pane); + + draw_custom_series_help_popup(&editor); + + if (ImGui::BeginTabBar("##custom_series_tabs")) { + if (ImGui::BeginTabItem("Single Function")) { + const float footer_height = ImGui::GetFrameHeightWithSpacing() * 2.0f + 10.0f; + if (ImGui::BeginChild("##custom_series_body", + ImVec2(0.0f, std::max(1.0f, ImGui::GetContentRegionAvail().y - footer_height)), false)) { + if (ImGui::BeginChild("##custom_series_preview_child", + ImVec2(0.0f, std::max(200.0f, ImGui::GetContentRegionAvail().y * 0.28f)), true)) + draw_custom_series_preview(*session, &editor); + ImGui::EndChild(); + ImGui::Spacing(); + + if (ImGui::BeginTable("##custom_series_editor_table", 2, + ImGuiTableFlags_Resizable | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingStretchProp, + ImVec2(0.0f, std::max(1.0f, ImGui::GetContentRegionAvail().y)))) { + ImGui::TableSetupColumn("left", ImGuiTableColumnFlags_WidthFixed, 320.0f); + ImGui::TableSetupColumn("right", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableNextColumn(); + if (ImGui::BeginChild("##custom_series_left", ImVec2(0.0f, 0.0f), false)) + draw_editor_source_panel(state, editor); + ImGui::EndChild(); + ImGui::TableNextColumn(); + if (ImGui::BeginChild("##custom_series_right", ImVec2(0.0f, 0.0f), false)) + draw_editor_code_panel(editor, active_pane); + ImGui::EndChild(); + ImGui::EndTable(); + } + } + ImGui::EndChild(); + + ImGui::Spacing(); + if (ImGui::Button("New", ImVec2(120.0f, 0.0f))) { + reset_custom_series_editor(&editor); + if (!state->selected_browser_path.empty()) editor.linked_source = state->selected_browser_path; + editor.open = true; + editor.focus_name = true; + } + ImGui::SameLine(); + ImGui::BeginDisabled(!custom_series_template_ready(editor)); + if (ImGui::Button("Preview Result", ImVec2(120.0f, 0.0f))) + preview_custom_series_editor(session, state); + ImGui::EndDisabled(); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled) && !custom_series_template_ready(editor)) { + if (editor.linked_source.empty()) ImGui::SetTooltip("Choose an input timeseries first."); + else ImGui::SetTooltip("%s", selected_custom_series_template(editor).requirement_text); + } + ImGui::SameLine(); + if (ImGui::Button("Apply", ImVec2(120.0f, 0.0f))) apply_custom_series_editor(session, state); + ImGui::SameLine(); + if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { editor.open = false; editor.request_select = false; } + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } +} diff --git a/tools/jotpluggler/data.py b/tools/jotpluggler/data.py deleted file mode 100644 index 756b87bd20..0000000000 --- a/tools/jotpluggler/data.py +++ /dev/null @@ -1,360 +0,0 @@ -import numpy as np -import threading -import multiprocessing -import bisect -from collections import defaultdict -from tqdm import tqdm -from openpilot.common.swaglog import cloudlog -from openpilot.selfdrive.test.process_replay.migration import migrate_all -from openpilot.tools.lib.logreader import _LogFileReader, LogReader - - -def flatten_dict(d: dict, sep: str = "/", prefix: str | None = None) -> dict: - result = {} - stack: list[tuple] = [(d, prefix)] - - while stack: - obj, current_prefix = stack.pop() - - if isinstance(obj, dict): - for key, val in obj.items(): - new_prefix = key if current_prefix is None else f"{current_prefix}{sep}{key}" - if isinstance(val, (dict, list)): - stack.append((val, new_prefix)) - else: - result[new_prefix] = val - elif isinstance(obj, list): - for i, item in enumerate(obj): - new_prefix = f"{current_prefix}{sep}{i}" - if isinstance(item, (dict, list)): - stack.append((item, new_prefix)) - else: - result[new_prefix] = item - else: - if current_prefix is not None: - result[current_prefix] = obj - return result - - -def extract_field_types(schema, prefix, field_types_dict): - stack = [(schema, prefix)] - - while stack: - current_schema, current_prefix = stack.pop() - - for field in current_schema.fields_list: - field_name = field.proto.name - field_path = f"{current_prefix}/{field_name}" - field_proto = field.proto - field_which = field_proto.which() - - field_type = field_proto.slot.type.which() if field_which == 'slot' else field_which - field_types_dict[field_path] = field_type - - if field_which == 'slot': - slot_type = field_proto.slot.type - type_which = slot_type.which() - - if type_which == 'list': - element_type = slot_type.list.elementType.which() - list_path = f"{field_path}/*" - field_types_dict[list_path] = element_type - - if element_type == 'struct': - stack.append((field.schema.elementType, list_path)) - - elif type_which == 'struct': - stack.append((field.schema, field_path)) - - elif field_which == 'group': - stack.append((field.schema, field_path)) - - -def _convert_to_optimal_dtype(values_list, capnp_type): - dtype_mapping = { - 'bool': np.bool_, 'int8': np.int8, 'int16': np.int16, 'int32': np.int32, 'int64': np.int64, - 'uint8': np.uint8, 'uint16': np.uint16, 'uint32': np.uint32, 'uint64': np.uint64, - 'float32': np.float32, 'float64': np.float64, 'text': object, 'data': object, - 'enum': object, 'anyPointer': object, - } - - target_dtype = dtype_mapping.get(capnp_type, object) - return np.array(values_list, dtype=target_dtype) - - -def _match_field_type(field_path, field_types): - if field_path in field_types: - return field_types[field_path] - - path_parts = field_path.split('/') - template_parts = [p if not p.isdigit() else '*' for p in path_parts] - template_path = '/'.join(template_parts) - return field_types.get(template_path) - - -def _get_field_times_values(segment, field_name): - if field_name not in segment: - return None, None - - field_data = segment[field_name] - segment_times = segment['t'] - - if field_data['sparse']: - if len(field_data['t_index']) == 0: - return None, None - return segment_times[field_data['t_index']], field_data['values'] - else: - return segment_times, field_data['values'] - - -def msgs_to_time_series(msgs): - """Extract scalar fields and return (time_series_data, start_time, end_time).""" - collected_data = defaultdict(lambda: {'timestamps': [], 'columns': defaultdict(list), 'sparse_fields': set()}) - field_types = {} - extracted_schemas = set() - min_time = max_time = None - - for msg in msgs: - typ = msg.which() - timestamp = msg.logMonoTime * 1e-9 - if typ != 'initData': - if min_time is None: - min_time = timestamp - max_time = timestamp - - sub_msg = getattr(msg, typ) - if not hasattr(sub_msg, 'to_dict'): - continue - - if hasattr(sub_msg, 'schema') and typ not in extracted_schemas: - extract_field_types(sub_msg.schema, typ, field_types) - extracted_schemas.add(typ) - - try: - msg_dict = sub_msg.to_dict(verbose=True) - except Exception as e: - cloudlog.warning(f"Failed to convert sub_msg.to_dict() for message of type: {typ}: {e}") - continue - - flat_dict = flatten_dict(msg_dict) - flat_dict['_valid'] = msg.valid - field_types[f"{typ}/_valid"] = 'bool' - - type_data = collected_data[typ] - columns, sparse_fields = type_data['columns'], type_data['sparse_fields'] - known_fields = set(columns.keys()) - missing_fields = known_fields - flat_dict.keys() - - for field, value in flat_dict.items(): - if field not in known_fields and type_data['timestamps']: - sparse_fields.add(field) - columns[field].append(value) - if value is None: - sparse_fields.add(field) - - for field in missing_fields: - columns[field].append(None) - sparse_fields.add(field) - - type_data['timestamps'].append(timestamp) - - final_result = {} - for typ, data in collected_data.items(): - if not data['timestamps']: - continue - - typ_result = {'t': np.array(data['timestamps'], dtype=np.float64)} - sparse_fields = data['sparse_fields'] - - for field_name, values in data['columns'].items(): - if len(values) < len(data['timestamps']): - values = [None] * (len(data['timestamps']) - len(values)) + values - sparse_fields.add(field_name) - - capnp_type = _match_field_type(f"{typ}/{field_name}", field_types) - - if field_name in sparse_fields: # extract non-None values and their indices - non_none_indices = [] - non_none_values = [] - for i, value in enumerate(values): - if value is not None: - non_none_indices.append(i) - non_none_values.append(value) - - if non_none_values: # check if indices > uint16 max, currently would require a 1000+ Hz signal since indices are within segments - assert max(non_none_indices) <= 65535, f"Sparse field {typ}/{field_name} has timestamp indices exceeding uint16 max. Max: {max(non_none_indices)}" - - typ_result[field_name] = { - 'values': _convert_to_optimal_dtype(non_none_values, capnp_type), - 'sparse': True, - 't_index': np.array(non_none_indices, dtype=np.uint16), - } - else: # dense representation - typ_result[field_name] = {'values': _convert_to_optimal_dtype(values, capnp_type), 'sparse': False} - - final_result[typ] = typ_result - - return final_result, min_time or 0.0, max_time or 0.0 - - -def _process_segment(segment_identifier: str): - try: - lr = _LogFileReader(segment_identifier, sort_by_time=True) - migrated_msgs = migrate_all(lr) - return msgs_to_time_series(migrated_msgs) - except Exception as e: - cloudlog.warning(f"Warning: Failed to process segment {segment_identifier}: {e}") - return {}, 0.0, 0.0 - - -class DataManager: - def __init__(self): - self._segments = [] - self._segment_starts = [] - self._start_time = 0.0 - self._duration = 0.0 - self._paths = set() - self._observers = [] - self._loading = False - self._lock = threading.RLock() - - def load_route(self, route: str) -> None: - if self._loading: - return - self._reset() - threading.Thread(target=self._load_async, args=(route,), daemon=True).start() - - def get_timeseries(self, path: str): - with self._lock: - msg_type, field = path.split('/', 1) - times, values = [], [] - - for segment in self._segments: - if msg_type in segment: - field_times, field_values = _get_field_times_values(segment[msg_type], field) - if field_times is not None: - times.append(field_times) - values.append(field_values) - - if not times: - return np.array([]), np.array([]) - - combined_times = np.concatenate(times) - self._start_time - - if len(values) > 1: - first_dtype = values[0].dtype - if all(arr.dtype == first_dtype for arr in values): # check if all arrays have compatible dtypes - combined_values = np.concatenate(values) - else: - combined_values = np.concatenate([arr.astype(object) for arr in values]) - else: - combined_values = values[0] if values else np.array([]) - - return combined_times, combined_values - - def get_value_at(self, path: str, time: float): - with self._lock: - MAX_LOOKBACK = 5.0 # seconds - absolute_time = self._start_time + time - message_type, field = path.split('/', 1) - current_index = bisect.bisect_right(self._segment_starts, absolute_time) - 1 - for index in (current_index, current_index - 1): - if not 0 <= index < len(self._segments): - continue - segment = self._segments[index].get(message_type) - if not segment: - continue - times, values = _get_field_times_values(segment, field) - if times is None or len(times) == 0 or (index != current_index and absolute_time - times[-1] > MAX_LOOKBACK): - continue - position = np.searchsorted(times, absolute_time, 'right') - 1 - if position >= 0 and absolute_time - times[position] <= MAX_LOOKBACK: - return values[position] - return None - - def get_all_paths(self): - with self._lock: - return sorted(self._paths) - - def get_duration(self): - with self._lock: - return self._duration - - def is_plottable(self, path: str): - _, values = self.get_timeseries(path) - if len(values) == 0: - return False - return np.issubdtype(values.dtype, np.number) or np.issubdtype(values.dtype, np.bool_) - - def add_observer(self, callback): - with self._lock: - self._observers.append(callback) - - def remove_observer(self, callback): - with self._lock: - if callback in self._observers: - self._observers.remove(callback) - - def _reset(self): - with self._lock: - self._loading = True - self._segments.clear() - self._segment_starts.clear() - self._paths.clear() - self._start_time = self._duration = 0.0 - observers = self._observers.copy() - - for callback in observers: - callback({'reset': True}) - - def _load_async(self, route: str): - try: - lr = LogReader(route, sort_by_time=True) - if not lr.logreader_identifiers: - cloudlog.warning(f"Warning: No log segments found for route: {route}") - return - - total_segments = len(lr.logreader_identifiers) - with self._lock: - observers = self._observers.copy() - for callback in observers: - callback({'metadata_loaded': True, 'total_segments': total_segments}) - - num_processes = max(1, multiprocessing.cpu_count() // 2) - with multiprocessing.Pool(processes=num_processes) as pool, tqdm(total=len(lr.logreader_identifiers), desc="Processing Segments") as pbar: - for segment_result, start_time, end_time in pool.imap(_process_segment, lr.logreader_identifiers): - pbar.update(1) - if segment_result: - self._add_segment(segment_result, start_time, end_time) - except Exception: - cloudlog.exception(f"Error loading route {route}:") - finally: - self._finalize_loading() - - def _add_segment(self, segment_data: dict, start_time: float, end_time: float): - with self._lock: - self._segments.append(segment_data) - self._segment_starts.append(start_time) - - if len(self._segments) == 1: - self._start_time = start_time - self._duration = end_time - self._start_time - - for msg_type, data in segment_data.items(): - for field_name in data.keys(): - if field_name != 't': - self._paths.add(f"{msg_type}/{field_name}") - - observers = self._observers.copy() - - for callback in observers: - callback({'segment_added': True, 'duration': self._duration, 'segment_count': len(self._segments)}) - - def _finalize_loading(self): - with self._lock: - self._loading = False - observers = self._observers.copy() - duration = self._duration - - for callback in observers: - callback({'loading_complete': True, 'duration': duration}) diff --git a/tools/jotpluggler/datatree.py b/tools/jotpluggler/datatree.py deleted file mode 100644 index 4f3219dc1b..0000000000 --- a/tools/jotpluggler/datatree.py +++ /dev/null @@ -1,315 +0,0 @@ -import os -import re -import threading -import numpy as np -import dearpygui.dearpygui as dpg - - -class DataTreeNode: - def __init__(self, name: str, full_path: str = "", parent=None): - self.name = name - self.full_path = full_path - self.parent = parent - self.children: dict[str, DataTreeNode] = {} - self.filtered_children: dict[str, DataTreeNode] = {} - self.created_children: dict[str, DataTreeNode] = {} - self.is_leaf = False - self.is_plottable: bool | None = None - self.ui_created = False - self.children_ui_created = False - self.ui_tag: str | None = None - - -class DataTree: - MAX_NODES_PER_FRAME = 50 - - def __init__(self, data_manager, playback_manager): - self.data_manager = data_manager - self.playback_manager = playback_manager - self.current_search = "" - self.data_tree = DataTreeNode(name="root") - self._build_queue: dict[str, tuple[DataTreeNode, DataTreeNode, str | int]] = {} # full_path -> (node, parent, before_tag) - self._current_created_paths: set[str] = set() - self._current_filtered_paths: set[str] = set() - self._path_to_node: dict[str, DataTreeNode] = {} # full_path -> node - self._expanded_tags: set[str] = set() - self._item_handlers: dict[str, str] = {} # ui_tag -> handler_tag - self._char_width = None - self._queued_search = None - self._new_data = False - self._ui_lock = threading.RLock() - self._handlers_to_delete = [] - self.data_manager.add_observer(self._on_data_loaded) - - def create_ui(self, parent_tag: str): - with dpg.child_window(parent=parent_tag, border=False, width=-1, height=-1): - dpg.add_text("Timeseries List") - dpg.add_separator() - dpg.add_input_text(tag="search_input", width=-1, hint="Search fields...", callback=self.search_data) - dpg.add_separator() - with dpg.child_window(border=False, width=-1, height=-1): - with dpg.group(tag="data_tree_container"): - pass - - def _on_data_loaded(self, data: dict): - with self._ui_lock: - if data.get('segment_added') or data.get('reset'): - self._new_data = True - - def update_frame(self, font): - if self._handlers_to_delete: # we need to do everything in main thread, frame callbacks are flaky - dpg.render_dearpygui_frame() # wait a frame to ensure queued callbacks are done - with self._ui_lock: - for handler in self._handlers_to_delete: - dpg.delete_item(handler) - self._handlers_to_delete.clear() - - with self._ui_lock: - if self._char_width is None: - if size := dpg.get_text_size(" ", font=font): - self._char_width = size[0] / 2 # we scale font 2x and downscale to fix hidpi bug - - if self._new_data: - self._process_path_change() - self._new_data = False - return - - if self._queued_search is not None: - self.current_search = self._queued_search - self._process_path_change() - self._queued_search = None - return - - nodes_processed = 0 - while self._build_queue and nodes_processed < self.MAX_NODES_PER_FRAME: - child_node, parent, before_tag = self._build_queue.pop(next(iter(self._build_queue))) - parent_tag = "data_tree_container" if parent.name == "root" else parent.ui_tag - if not child_node.ui_created: - if child_node.is_leaf: - self._create_leaf_ui(child_node, parent_tag, before_tag) - else: - self._create_tree_node_ui(child_node, parent_tag, before_tag) - parent.created_children[child_node.name] = parent.children[child_node.name] - self._current_created_paths.add(child_node.full_path) - nodes_processed += 1 - - def _process_path_change(self): - self._build_queue.clear() - search_term = self.current_search.strip().lower() - all_paths = set(self.data_manager.get_all_paths()) - new_filtered_leafs = {path for path in all_paths if self._should_show_path(path, search_term)} - new_filtered_paths = set(new_filtered_leafs) - for path in new_filtered_leafs: - parts = path.split('/') - for i in range(1, len(parts)): - prefix = '/'.join(parts[:i]) - new_filtered_paths.add(prefix) - created_paths_to_remove = self._current_created_paths - new_filtered_paths - filtered_paths_to_remove = self._current_filtered_paths - new_filtered_leafs - - if created_paths_to_remove or filtered_paths_to_remove: - self._remove_paths_from_tree(created_paths_to_remove, filtered_paths_to_remove) - self._apply_expansion_to_tree(self.data_tree, search_term) - - paths_to_add = new_filtered_leafs - self._current_created_paths - if paths_to_add: - self._add_paths_to_tree(paths_to_add) - self._apply_expansion_to_tree(self.data_tree, search_term) - self._current_filtered_paths = new_filtered_paths - - def _remove_paths_from_tree(self, created_paths_to_remove, filtered_paths_to_remove): - for path in sorted(created_paths_to_remove, reverse=True): - current_node = self._path_to_node[path] - - if len(current_node.created_children) == 0: - self._current_created_paths.remove(current_node.full_path) - if item_handler_tag := self._item_handlers.get(current_node.ui_tag): - dpg.configure_item(item_handler_tag, show=False) - self._handlers_to_delete.append(item_handler_tag) - del self._item_handlers[current_node.ui_tag] - dpg.delete_item(current_node.ui_tag) - current_node.ui_created = False - current_node.ui_tag = None - current_node.children_ui_created = False - del current_node.parent.created_children[current_node.name] - del current_node.parent.filtered_children[current_node.name] - - for path in filtered_paths_to_remove: - parts = path.split('/') - current_node = self._path_to_node[path] - - part_array_index = -1 - while len(current_node.filtered_children) == 0 and part_array_index >= -len(parts): - current_node = current_node.parent - if parts[part_array_index] in current_node.filtered_children: - del current_node.filtered_children[parts[part_array_index]] - part_array_index -= 1 - - def _add_paths_to_tree(self, paths): - parent_nodes_to_recheck = set() - for path in sorted(paths): - parts = path.split('/') - current_node = self.data_tree - current_path_prefix = "" - - for i, part in enumerate(parts): - current_path_prefix = f"{current_path_prefix}/{part}" if current_path_prefix else part - if i < len(parts): - parent_nodes_to_recheck.add(current_node) # for incremental changes from new data - if part not in current_node.children: - current_node.children[part] = DataTreeNode(name=part, full_path=current_path_prefix, parent=current_node) - self._path_to_node[current_path_prefix] = current_node.children[part] - current_node.filtered_children[part] = current_node.children[part] - current_node = current_node.children[part] - - if not current_node.is_leaf: - current_node.is_leaf = True - - for p_node in parent_nodes_to_recheck: - p_node.children_ui_created = False - self._request_children_build(p_node) - - def _get_node_label_and_expand(self, node: DataTreeNode, search_term: str): - label = f"{node.name} ({len(node.filtered_children)} fields)" - expand = len(search_term) > 0 and any(search_term in path for path in self._get_descendant_paths(node)) - if expand and node.parent and len(node.parent.filtered_children) > 100 and len(node.filtered_children) > 2: - label += " (+)" # symbol for large lists which aren't fully expanded for performance (only affects procLog rn) - expand = False - return label, expand - - def _apply_expansion_to_tree(self, node: DataTreeNode, search_term: str): - if node.ui_created and not node.is_leaf and node.ui_tag and dpg.does_item_exist(node.ui_tag): - label, expand = self._get_node_label_and_expand(node, search_term) - if expand: - self._expanded_tags.add(node.ui_tag) - dpg.set_value(node.ui_tag, expand) - elif node.ui_tag in self._expanded_tags: # not expanded and was expanded - self._expanded_tags.remove(node.ui_tag) - dpg.set_value(node.ui_tag, expand) - dpg.delete_item(node.ui_tag, children_only=True) # delete children (not visible since collapsed) - self._reset_ui_state_recursive(node) - node.children_ui_created = False - dpg.set_item_label(node.ui_tag, label) - for child in node.created_children.values(): - self._apply_expansion_to_tree(child, search_term) - - def _reset_ui_state_recursive(self, node: DataTreeNode): - for child in node.created_children.values(): - if child.ui_tag is not None: - if item_handler_tag := self._item_handlers.get(child.ui_tag): - self._handlers_to_delete.append(item_handler_tag) - dpg.configure_item(item_handler_tag, show=False) - del self._item_handlers[child.ui_tag] - self._reset_ui_state_recursive(child) - child.ui_created = False - child.ui_tag = None - child.children_ui_created = False - self._current_created_paths.remove(child.full_path) - node.created_children.clear() - - def search_data(self): - with self._ui_lock: - self._queued_search = dpg.get_value("search_input") - - def _create_tree_node_ui(self, node: DataTreeNode, parent_tag: str, before: str | int): - node.ui_tag = f"tree_{node.full_path}" - search_term = self.current_search.strip().lower() - label, expand = self._get_node_label_and_expand(node, search_term) - if expand: - self._expanded_tags.add(node.ui_tag) - elif node.ui_tag in self._expanded_tags: - self._expanded_tags.remove(node.ui_tag) - - with dpg.tree_node( - label=label, parent=parent_tag, tag=node.ui_tag, default_open=expand, open_on_arrow=True, open_on_double_click=True, before=before, delay_search=True - ): - with dpg.item_handler_registry() as handler_tag: - dpg.add_item_toggled_open_handler(callback=lambda s, a, u: self._request_children_build(node)) - dpg.add_item_visible_handler(callback=lambda s, a, u: self._request_children_build(node)) - dpg.bind_item_handler_registry(node.ui_tag, handler_tag) - self._item_handlers[node.ui_tag] = handler_tag - node.ui_created = True - - def _create_leaf_ui(self, node: DataTreeNode, parent_tag: str, before: str | int): - node.ui_tag = f"leaf_{node.full_path}" - with dpg.group(parent=parent_tag, tag=node.ui_tag, before=before, delay_search=True): - with dpg.table(header_row=False, policy=dpg.mvTable_SizingStretchProp, delay_search=True): - dpg.add_table_column(init_width_or_weight=0.5) - dpg.add_table_column(init_width_or_weight=0.5) - with dpg.table_row(): - dpg.add_text(node.name) - dpg.add_text("N/A", tag=f"value_{node.full_path}") - - if node.is_plottable is None: - node.is_plottable = self.data_manager.is_plottable(node.full_path) - if node.is_plottable: - with dpg.drag_payload(parent=node.ui_tag, drag_data=node.full_path, payload_type="TIMESERIES_PAYLOAD"): - dpg.add_text(f"Plot: {node.full_path}") - - with dpg.item_handler_registry() as handler_tag: - dpg.add_item_visible_handler(callback=self._on_item_visible, user_data=node.full_path) - dpg.bind_item_handler_registry(node.ui_tag, handler_tag) - self._item_handlers[node.ui_tag] = handler_tag - node.ui_created = True - - def _on_item_visible(self, sender, app_data, user_data): - with self._ui_lock: - path = user_data - value_tag = f"value_{path}" - if not dpg.does_item_exist(value_tag): - return - value_column_width = dpg.get_item_rect_size(f"leaf_{path}")[0] // 2 - value = self.data_manager.get_value_at(path, self.playback_manager.current_time_s) - if value is not None: - formatted_value = self.format_and_truncate(value, value_column_width, self._char_width) - dpg.set_value(value_tag, formatted_value) - else: - dpg.set_value(value_tag, "N/A") - - def _request_children_build(self, node: DataTreeNode): - with self._ui_lock: - if not node.children_ui_created and (node.name == "root" or (node.ui_tag is not None and dpg.get_value(node.ui_tag))): # check root or node expanded - sorted_children = sorted(node.filtered_children.values(), key=self._natural_sort_key) - next_existing: list[int | str] = [0] * len(sorted_children) - current_before_tag: int | str = 0 - - for i in range(len(sorted_children) - 1, -1, -1): # calculate "before_tag" for correct ordering when incrementally building tree - child = sorted_children[i] - next_existing[i] = current_before_tag - if child.ui_created: - candidate_tag = f"leaf_{child.full_path}" if child.is_leaf else f"tree_{child.full_path}" - if dpg.does_item_exist(candidate_tag): - current_before_tag = candidate_tag - - for i, child_node in enumerate(sorted_children): - if not child_node.ui_created: - before_tag = next_existing[i] - self._build_queue[child_node.full_path] = (child_node, node, before_tag) - node.children_ui_created = True - - def _should_show_path(self, path: str, search_term: str) -> bool: - if 'DEPRECATED' in path and not os.environ.get('SHOW_DEPRECATED'): - return False - return not search_term or search_term in path.lower() - - def _natural_sort_key(self, node: DataTreeNode): - node_type_key = node.is_leaf - parts = [int(p) if p.isdigit() else p.lower() for p in re.split(r'(\d+)', node.name) if p] - return (node_type_key, parts) - - def _get_descendant_paths(self, node: DataTreeNode): - for child_name, child_node in node.filtered_children.items(): - child_name_lower = child_name.lower() - if child_node.is_leaf: - yield child_name_lower - else: - for path in self._get_descendant_paths(child_node): - yield f"{child_name_lower}/{path}" - - @staticmethod - def format_and_truncate(value, available_width: float, char_width: float) -> str: - s = f"{value:.5f}" if np.issubdtype(type(value), np.floating) else str(value) - max_chars = int(available_width / char_width) - if len(s) > max_chars: - return s[: max(0, max_chars - 3)] + "..." - return s diff --git a/tools/jotpluggler/dbc.h b/tools/jotpluggler/dbc.h new file mode 100644 index 0000000000..d7c5461502 --- /dev/null +++ b/tools/jotpluggler/dbc.h @@ -0,0 +1,400 @@ +#pragma once + +#include "common/util.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dbc { + + +struct ValueDescriptionEntry { + double value = 0.0; + std::string text; +}; + +struct Signal { + enum class Type { + Normal = 0, + Multiplexed, + Multiplexor, + }; + + Type type = Type::Normal; + std::string name; + int start_bit = 0; + int msb = 0; + int lsb = 0; + int size = 0; + double factor = 1.0; + double offset = 0.0; + double min = 0.0; + double max = 0.0; + bool is_signed = false; + bool is_little_endian = false; + std::string unit; + std::string comment; + std::string receiver_name; + int multiplex_value = 0; + int multiplexor_index = -1; + std::vector value_descriptions; +}; + +struct Message { + uint32_t address = 0; + std::string name; + uint32_t size = 0; + std::string comment; + std::string transmitter; + std::vector signals; + int multiplexor_index = -1; + + const std::vector &getSignals() const { return signals; } +}; + +class Database { +public: + Database() = default; + explicit Database(const std::filesystem::path &path); + static Database fromContent(const std::string &content, const std::string &filename = ""); + + const Message *message(uint32_t address) const; + const std::unordered_map &messages() const { return messages_; } + std::vector enumNames(const Signal &signal) const; + +private: + void parse(const std::string &content, const std::string &filename); + void parseBo(const std::string &line, int line_number, Message **current_message); + void parseSg(const std::string &line, int line_number, Message *current_message); + void parseVal(const std::string &line, int line_number); + void parseCmBo(const std::string &line, int line_number); + void parseCmSg(const std::string &line, int line_number); + void finalize(); + + std::string filename_; + std::unordered_map messages_; +}; + +void updateMsbLsb(Signal *signal); +double rawSignalValue(const Signal &signal, const uint8_t *data, size_t data_size); +std::optional signalValue(const Signal &signal, const Message &message, const uint8_t *data, size_t data_size); + +namespace { + +std::string unescape_dbc_string(std::string text) { + size_t pos = 0; + while ((pos = text.find("\\\"", pos)) != std::string::npos) { + text.replace(pos, 2, "\""); + ++pos; + } + return text; +} + +int flip_bit_pos(int start_bit) { + return 8 * (start_bit / 8) + 7 - start_bit % 8; +} + +std::string read_multiline_statement(std::istream &stream, std::string statement, int *line_number) { + static const std::regex statement_end(R"(\"\s*;\s*$)"); + while (true) { + const std::string trimmed = util::strip(statement); + if (std::regex_search(trimmed, statement_end)) { + return trimmed; + } + + std::string next_line; + if (!std::getline(stream, next_line)) { + return trimmed; + } + statement += "\n"; + statement += next_line; + ++(*line_number); + } +} + +} // namespace + +inline void updateMsbLsb(Signal *signal) { + if (signal->is_little_endian) { + signal->lsb = signal->start_bit; + signal->msb = signal->start_bit + signal->size - 1; + } else { + signal->lsb = flip_bit_pos(flip_bit_pos(signal->start_bit) + signal->size - 1); + signal->msb = signal->start_bit; + } +} + +inline double rawSignalValue(const Signal &signal, const uint8_t *data, size_t data_size) { + const int msb_byte = signal.msb / 8; + if (msb_byte >= static_cast(data_size)) return 0.0; + + const int lsb_byte = signal.lsb / 8; + uint64_t val = 0; + if (msb_byte == lsb_byte) { + val = (data[msb_byte] >> (signal.lsb & 7)) & ((1ULL << signal.size) - 1); + } else { + int bits = signal.size; + int i = msb_byte; + const int step = signal.is_little_endian ? -1 : 1; + while (i >= 0 && i < static_cast(data_size) && bits > 0) { + const int msb = (i == msb_byte) ? signal.msb & 7 : 7; + const int lsb = (i == lsb_byte) ? signal.lsb & 7 : 0; + const int nbits = msb - lsb + 1; + val = (val << nbits) | ((data[i] >> lsb) & ((1ULL << nbits) - 1)); + bits -= nbits; + i += step; + } + } + + if (signal.is_signed && (val & (1ULL << (signal.size - 1)))) { + val |= ~((1ULL << signal.size) - 1); + } + + return static_cast(val) * signal.factor + signal.offset; +} + +[[noreturn]] inline void parse_error(const std::string &filename, int line_number, const std::string &message, const std::string &line) { + std::ostringstream out; + out << "[" << filename << ":" << line_number << "] " << message << ": " << line; + throw std::runtime_error(out.str()); +} + +inline Database::Database(const std::filesystem::path &path) { + const std::string content = util::read_file(path.string()); + if (content.empty() && !std::filesystem::exists(path)) { + throw std::runtime_error("Failed to open DBC " + path.string()); + } + parse(content, path.filename().string()); +} + +inline Database Database::fromContent(const std::string &content, const std::string &filename) { + Database db; + db.parse(content, filename); + return db; +} + +inline const Message *Database::message(uint32_t address) const { + auto it = messages_.find(address); + return it == messages_.end() ? nullptr : &it->second; +} + +inline std::vector Database::enumNames(const Signal &signal) const { + if (signal.value_descriptions.empty()) return {}; + int max_index = -1; + for (const auto &entry : signal.value_descriptions) { + const double rounded = std::round(entry.value); + if (std::abs(entry.value - rounded) > 1e-6 || rounded < 0.0 || rounded > 512.0) return {}; + max_index = std::max(max_index, static_cast(rounded)); + } + if (max_index < 0) return {}; + std::vector names(static_cast(max_index + 1)); + for (const auto &entry : signal.value_descriptions) { + names[static_cast(std::llround(entry.value))] = entry.text; + } + return names; +} + +inline void Database::parse(const std::string &content, const std::string &filename) { + filename_ = filename; + messages_.clear(); + std::istringstream stream(content); + std::string raw_line; + Message *current_message = nullptr; + int line_number = 0; + while (std::getline(stream, raw_line)) { + ++line_number; + std::string line = util::strip(raw_line); + if (line.empty()) continue; + if (util::starts_with(line, "BO_ ")) { + parseBo(line, line_number, ¤t_message); + } else if (util::starts_with(line, "SG_ ")) { + if (current_message == nullptr) { + parse_error(filename, line_number, "Signal without current message", line); + } + parseSg(line, line_number, current_message); + } else if (util::starts_with(line, "VAL_ ")) { + parseVal(line, line_number); + } else if (util::starts_with(line, "CM_ BO_")) { + parseCmBo(read_multiline_statement(stream, raw_line, &line_number), line_number); + } else if (util::starts_with(line, "CM_ SG_")) { + parseCmSg(read_multiline_statement(stream, raw_line, &line_number), line_number); + } + } + finalize(); +} + +inline void Database::parseBo(const std::string &line, int line_number, Message **current_message) { + static const std::regex pattern(R"(^BO_\s+(\w+)\s+(\w+)\s*:\s*(\w+)\s+(\w+)\s*$)"); + std::smatch match; + if (!std::regex_match(line, match, pattern)) { + parse_error("", line_number, "Invalid BO_ line format", line); + } + uint32_t address = static_cast(std::stoul(match[1].str(), nullptr, 0)); + if (messages_.find(address) != messages_.end()) { + parse_error(filename_, line_number, "Duplicate message address", line); + } + Message &message = messages_[address]; + message.address = address; + message.name = match[2].str(); + message.size = static_cast(std::stoul(match[3].str(), nullptr, 0)); + message.transmitter = match[4].str(); + message.signals.clear(); + message.multiplexor_index = -1; + *current_message = &message; +} + +inline void Database::parseSg(const std::string &line, int line_number, Message *current_message) { + static const std::regex multiplex_pattern(R"(^SG_\s+(\w+)\s+(\w+)\s*:\s*(\d+)\|(\d+)@(\d)([+-])\s+\(([0-9.+\-eE]+),([0-9.+\-eE]+)\)\s+\[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\]\s+\"(.*)\"\s+(.*)$)"); + static const std::regex normal_pattern(R"(^SG_\s+(\w+)\s*:\s*(\d+)\|(\d+)@(\d)([+-])\s+\(([0-9.+\-eE]+),([0-9.+\-eE]+)\)\s+\[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\]\s+\"(.*)\"\s+(.*)$)"); + + std::smatch match; + Signal signal; + int offset = 0; + if (std::regex_match(line, match, normal_pattern)) { + offset = 0; + } else if (std::regex_match(line, match, multiplex_pattern)) { + offset = 1; + const std::string indicator = match[2].str(); + if (indicator == "M") { + if (std::any_of(current_message->signals.begin(), current_message->signals.end(), [](const Signal &existing) { + return existing.type == Signal::Type::Multiplexor; + })) { + parse_error(filename_, line_number, "Multiple multiplexor", line); + } + signal.type = Signal::Type::Multiplexor; + } else if (!indicator.empty() && indicator.front() == 'm') { + signal.type = Signal::Type::Multiplexed; + signal.multiplex_value = std::stoi(indicator.substr(1)); + } else { + parse_error("", line_number, "Invalid multiplex indicator", line); + } + } else { + parse_error("", line_number, "Invalid SG_ line format", line); + } + + signal.name = match[1].str(); + if (std::any_of(current_message->signals.begin(), current_message->signals.end(), [&](const Signal &existing) { + return existing.name == signal.name; + })) { + parse_error(filename_, line_number, "Duplicate signal name", line); + } + signal.start_bit = std::stoi(match[2 + offset].str()); + signal.size = std::stoi(match[3 + offset].str()); + signal.is_little_endian = match[4 + offset].str() == "1"; + signal.is_signed = match[5 + offset].str() == "-"; + signal.factor = std::stod(match[6 + offset].str()); + signal.offset = std::stod(match[7 + offset].str()); + signal.min = std::stod(match[8 + offset].str()); + signal.max = std::stod(match[9 + offset].str()); + signal.unit = match[10 + offset].str(); + signal.receiver_name = util::strip(match[11 + offset].str()); + updateMsbLsb(&signal); + current_message->signals.push_back(std::move(signal)); +} + +inline void Database::parseVal(const std::string &line, int line_number) { + static const std::regex prefix(R"(^VAL_\s+(\w+)\s+(\w+)\s+(.*);$)"); + std::smatch match; + if (!std::regex_match(line, match, prefix)) { + parse_error("", line_number, "Invalid VAL_ line format", line); + } + + const uint32_t address = static_cast(std::stoul(match[1].str(), nullptr, 0)); + auto msg_it = messages_.find(address); + if (msg_it == messages_.end()) { + return; + } + auto sig_it = std::find_if(msg_it->second.signals.begin(), msg_it->second.signals.end(), [&](const Signal &signal) { + return signal.name == match[2].str(); + }); + if (sig_it == msg_it->second.signals.end()) { + return; + } + + static const std::regex entry_pattern(R"(([+-]?\d+(?:\.\d+)?)\s+\"((?:[^\"\\]|\\.)*)\")"); + const std::string defs = match[3].str(); + for (std::sregex_iterator it(defs.begin(), defs.end(), entry_pattern), end; it != end; ++it) { + sig_it->value_descriptions.push_back(ValueDescriptionEntry{ + .value = std::stod((*it)[1].str()), + .text = (*it)[2].str(), + }); + } +} + +inline void Database::parseCmBo(const std::string &line, int line_number) { + static const std::regex pattern(R"(^CM_\s+BO_\s*(\w+)\s*\"((?:[^\"\\]|\\.|[\r\n])*)\"\s*;\s*$)"); + std::smatch match; + if (!std::regex_match(line, match, pattern)) { + parse_error(filename_, line_number, "Invalid message comment format", line); + } + const uint32_t address = static_cast(std::stoul(match[1].str(), nullptr, 0)); + auto it = messages_.find(address); + if (it != messages_.end()) { + it->second.comment = unescape_dbc_string(match[2].str()); + } +} + +inline void Database::parseCmSg(const std::string &line, int line_number) { + static const std::regex pattern(R"(^CM_\s+SG_\s*(\w+)\s*(\w+)\s*\"((?:[^\"\\]|\\.|[\r\n])*)\"\s*;\s*$)"); + std::smatch match; + if (!std::regex_match(line, match, pattern)) { + parse_error(filename_, line_number, "Invalid signal comment format", line); + } + + const uint32_t address = static_cast(std::stoul(match[1].str(), nullptr, 0)); + auto msg_it = messages_.find(address); + if (msg_it == messages_.end()) return; + + auto sig_it = std::find_if(msg_it->second.signals.begin(), msg_it->second.signals.end(), [&](const Signal &signal) { + return signal.name == match[2].str(); + }); + if (sig_it != msg_it->second.signals.end()) { + sig_it->comment = unescape_dbc_string(match[3].str()); + } +} + +inline void Database::finalize() { + for (auto &[_, message] : messages_) { + std::sort(message.signals.begin(), message.signals.end(), [](const Signal &left, const Signal &right) { + return std::tie(right.type, left.multiplex_value, left.start_bit, left.name) + < std::tie(left.type, right.multiplex_value, right.start_bit, right.name); + }); + message.multiplexor_index = -1; + for (size_t i = 0; i < message.signals.size(); ++i) { + if (message.signals[i].type == Signal::Type::Multiplexor) { + message.multiplexor_index = static_cast(i); + break; + } + } + for (Signal &signal : message.signals) { + signal.multiplexor_index = signal.type == Signal::Type::Multiplexed ? message.multiplexor_index : -1; + if (signal.type == Signal::Type::Multiplexed && signal.multiplexor_index < 0) { + signal.type = Signal::Type::Normal; + signal.multiplex_value = 0; + } + } + } +} + +inline std::optional signalValue(const Signal &signal, const Message &message, const uint8_t *data, size_t data_size) { + if (signal.multiplexor_index >= 0) { + const Signal &multiplexor = message.signals[static_cast(signal.multiplexor_index)]; + const double mux_value = rawSignalValue(multiplexor, data, data_size); + if (std::llround(mux_value) != signal.multiplex_value) return std::nullopt; + } + return rawSignalValue(signal, data, data_size); +} + +} // namespace dbc diff --git a/tools/jotpluggler/generated_dbcs/.gitignore b/tools/jotpluggler/generated_dbcs/.gitignore new file mode 100644 index 0000000000..d6b7ef32c8 --- /dev/null +++ b/tools/jotpluggler/generated_dbcs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tools/jotpluggler/icons.cc b/tools/jotpluggler/icons.cc new file mode 100644 index 0000000000..9507090e03 --- /dev/null +++ b/tools/jotpluggler/icons.cc @@ -0,0 +1,24 @@ +#include "tools/jotpluggler/app.h" +#include "tools/jotpluggler/common.h" + +#include + +void icon_add_font(float size, bool merge, const ImFont *base_font) { + const std::filesystem::path ttf = repo_root() / "third_party" / "bootstrap" / "bootstrap-icons.ttf"; + ImGuiIO &io = ImGui::GetIO(); + ImFontConfig config; + config.MergeMode = merge; + config.GlyphMinAdvanceX = size; + if (base_font != nullptr) { + ImFontBaked *baked = const_cast(base_font)->GetFontBaked(size); + const float base_center = baked != nullptr ? (baked->Ascent + baked->Descent) * 0.5f : size * 0.5f; + config.GlyphOffset.y = std::round(size * 0.5f - base_center); + } + static const ImWchar ranges[] = {0xF000, 0xF8FF, 0}; + io.Fonts->AddFontFromFileTTF(ttf.c_str(), size, &config, ranges); +} + +bool icon_menu_item(const char *glyph, const char *label, const char *shortcut, bool selected, bool enabled) { + assert(glyph != nullptr && glyph[0] != '\0'); + return ImGui::MenuItem(util::string_format("%s %s", glyph, label).c_str(), shortcut, selected, enabled); +} diff --git a/tools/jotpluggler/internal.h b/tools/jotpluggler/internal.h new file mode 100644 index 0000000000..22a5c1dd95 --- /dev/null +++ b/tools/jotpluggler/internal.h @@ -0,0 +1,166 @@ +#pragma once + +#include "tools/jotpluggler/common.h" +#include "tools/jotpluggler/map.h" + +#include +#include +#include + +struct GLFWwindow; + +enum class PaneDropZone { + Center, + Left, + Right, + Top, + Bottom, +}; + +enum class PaneMenuActionKind { + None, + OpenAxisLimits, + OpenCustomSeries, + SplitLeft, + SplitRight, + SplitTop, + SplitBottom, + ResetView, + ResetHorizontal, + ResetVertical, + Clear, + Close, +}; + +struct PaneMenuAction { + PaneMenuActionKind kind = PaneMenuActionKind::None; + int pane_index = -1; +}; + +struct PaneCurveDragPayload { + int tab_index = -1; + int pane_index = -1; + int curve_index = -1; +}; + +struct PaneDropAction { + PaneDropZone zone = PaneDropZone::Center; + int target_pane_index = -1; + bool from_browser = false; + std::vector browser_paths; + std::string special_item_id; + PaneCurveDragPayload curve_ref; +}; + +inline constexpr float SIDEBAR_WIDTH = 320.0f; +inline constexpr float SIDEBAR_MIN_WIDTH = 220.0f; +inline constexpr float SIDEBAR_MAX_WIDTH = 520.0f; +inline constexpr float TIMELINE_BAR_HEIGHT = 14.0f; +inline constexpr float STATUS_BAR_HEIGHT = 52.0f; +inline constexpr double MIN_HORIZONTAL_ZOOM_SECONDS = 2.0; + +struct UiMetrics { + float width = 0.0f; + float height = 0.0f; + float top_offset = 0.0f; + float sidebar_width = SIDEBAR_WIDTH; + float content_x = 0.0f; + float content_y = 0.0f; + float content_w = 0.0f; + float content_h = 0.0f; + float status_bar_y = 0.0f; +}; + +std::filesystem::path resolve_layout_path(const std::string &layout_arg); +std::filesystem::path autosave_path_for_layout(const std::filesystem::path &layout_path); +std::vector available_layout_names(); + +SketchLayout make_empty_layout(); +void cancel_rename_tab(UiState *state); +void sync_ui_state(UiState *state, const SketchLayout &layout); +void sync_route_buffers(UiState *state, const AppSession &session); +void sync_stream_buffers(UiState *state, const AppSession &session); +void sync_layout_buffers(UiState *state, const AppSession &session); +void mark_all_docks_dirty(UiState *state); +void clear_layout_autosave(const AppSession &session); +bool autosave_layout(AppSession *session, UiState *state); +bool apply_axis_limits_editor(AppSession *session, UiState *state); +void open_axis_limits_editor(const AppSession &session, UiState *state, int pane_index); +void persist_shared_range_to_tab(WorkspaceTab *tab, const UiState &state); +void clear_pane_vertical_limits(Pane *pane); + +void refresh_replaced_layout_ui(AppSession *session, UiState *state, bool mark_docks); +void start_new_layout(AppSession *session, UiState *state, const std::string &status_text = "New untitled layout"); +void apply_dbc_override_change(AppSession *session, UiState *state, const std::string &dbc_override); + +void app_push_bold_font(); +void app_pop_bold_font(); +void draw_vertical_splitter(const char *id, float height, float min_left, float max_left, float *left_width); +void draw_right_splitter(const char *id, float height, float min_right, float max_right, float *right_width); +bool draw_horizontal_splitter(const char *id, float width, float min_top, float max_top, float *top_height); +void draw_payload_bytes(std::string_view data, const std::string *prev_data = nullptr); +void draw_payload_preview_boxes(const char *id, std::string_view data, const std::string *prev_data, float max_width); +void draw_signal_sparkline(const AppSession &session, + const UiState &state, + std::string_view signal_path, + bool selected, + ImVec2 size = ImVec2(0.0f, 24.0f)); +ImU32 mix_color(ImU32 a, ImU32 b, float t); +void draw_empty_panel(const char *title, const char *message); + +UiMetrics compute_ui_metrics(const ImVec2 &size, float top_offset, float sidebar_width); +void draw_sidebar(AppSession *session, const UiMetrics &ui, UiState *state, bool show_camera_feed); +void draw_workspace(AppSession *session, const UiMetrics &ui, UiState *state); +void draw_pane_windows(AppSession *session, UiState *state); + +// plot.cc +void draw_plot(const AppSession &session, Pane *pane, UiState *state); +bool draw_pane_close_button_overlay(); +void draw_pane_frame_overlay(); +std::optional draw_pane_context_menu(const WorkspaceTab &tab, int pane_index); +bool curve_has_samples(const AppSession &session, const Curve &curve); +bool curve_has_local_samples(const Curve &curve); +std::string app_curve_display_name(const Curve &curve); +bool mark_layout_dirty(AppSession *session, UiState *state); + +const RouteSeries *app_find_route_series(const AppSession &session, const std::string &path); +void sync_camera_feeds(AppSession *session); +void apply_route_data(AppSession *session, UiState *state, RouteData route_data); +bool apply_undo(AppSession *session, UiState *state); +bool apply_redo(AppSession *session, UiState *state); +bool infer_stream_follow_state(const UiState &state, const AppSession &session); +void ensure_shared_range(UiState *state, const AppSession &session); +void clamp_shared_range(UiState *state, const AppSession &session); +void reset_shared_range(UiState *state, const AppSession &session); +void update_follow_range(UiState *state, const AppSession &session); +void advance_playback(UiState *state, const AppSession &session); +void step_tracker(UiState *state, double direction); +std::string dbc_combo_label(const AppSession &session); +const char *log_selector_name(LogSelector selector); +const char *log_selector_description(LogSelector selector); +std::string format_cache_bytes(uint64_t bytes); +MapCacheStats directory_cache_stats(const std::filesystem::path &root); +float draw_main_menu_bar(AppSession *session, UiState *state); + +bool reset_layout(AppSession *session, UiState *state); +bool reload_layout(AppSession *session, UiState *state, const std::string &layout_arg); +bool save_layout(AppSession *session, UiState *state, const std::string &layout_path); +void rebuild_session_route_data(AppSession *session, UiState *state, + const RouteLoadProgressCallback &progress = {}); +void stop_stream_session(AppSession *session, UiState *state, bool preserve_data = true); +bool start_stream_session(AppSession *session, + UiState *state, + const StreamSourceConfig &source, + double buffer_seconds, + bool preserve_existing_data = false); +void start_async_route_load(AppSession *session, UiState *state); +void poll_async_route_load(AppSession *session, UiState *state); +bool reload_session(AppSession *session, UiState *state, const std::string &route_name, const std::string &data_dir); +void draw_popups(AppSession *session, UiState *state); + +void draw_status_bar(const AppSession &session, const UiMetrics &ui, UiState *state); +void draw_sidebar_resizer(const UiMetrics &ui, UiState *state); + +void apply_stream_batch(AppSession *session, UiState *state, StreamExtractBatch batch); + +void render_frame(GLFWwindow *window, AppSession *session, UiState *state, const std::filesystem::path *capture_path); diff --git a/tools/jotpluggler/layout.cc b/tools/jotpluggler/layout.cc new file mode 100644 index 0000000000..8a58ef7cd6 --- /dev/null +++ b/tools/jotpluggler/layout.cc @@ -0,0 +1,704 @@ +#include "tools/jotpluggler/internal.h" +#include "system/hardware/hw.h" + +#include + +namespace fs = std::filesystem; + +namespace { + +enum class ModalAction { + None, + Primary, + Secondary, +}; + +struct FindSignalMatch { + const std::string *path = nullptr; + int score = 0; +}; + +struct DbcEditorSource { + fs::path path; + DbcEditorState::SourceKind kind = DbcEditorState::SourceKind::None; +}; + +StreamSourceConfig stream_source_config_from_ui(const UiState &state) { + StreamSourceConfig source; + source.kind = state.stream_source_kind; + source.address = util::strip(state.stream_address_buffer); + if (source.kind == StreamSourceKind::CerealLocal) { + source.address = "127.0.0.1"; + } else { + source.address = normalize_stream_address(std::move(source.address)); + } + return source; +} + +void open_queued_popup(bool &flag, const char *name) { + if (flag) { + ImGui::OpenPopup(name); + flag = false; + } +} + +ModalAction draw_modal_action_row(const char *primary_label, + const char *secondary_label = "Cancel", + float width = 120.0f) { + if (ImGui::Button(primary_label, ImVec2(width, 0.0f))) { + return ModalAction::Primary; + } + ImGui::SameLine(); + if (ImGui::Button(secondary_label, ImVec2(width, 0.0f))) { + return ModalAction::Secondary; + } + return ModalAction::None; +} + +std::vector find_signal_matches(const AppSession &session, std::string_view query) { + std::vector matches; + if (query.empty()) { + return matches; + } + const std::string needle = lowercase_copy(query); + for (const std::string &path : session.route_data.paths) { + const std::string hay = lowercase_copy(path); + const size_t pos = hay.find(needle); + if (pos == std::string::npos) { + continue; + } + const size_t slash = path.find_last_of('/'); + const std::string_view label = slash == std::string::npos ? std::string_view(path) : std::string_view(path).substr(slash + 1); + int score = static_cast(pos * 8 + path.size()); + if (lowercase_copy(label) == needle) score -= 60; + if (util::starts_with(hay, needle)) score -= 30; + matches.push_back({.path = &path, .score = score}); + } + std::sort(matches.begin(), matches.end(), [](const FindSignalMatch &a, const FindSignalMatch &b) { + return std::tie(a.score, *a.path) < std::tie(b.score, *b.path); + }); + if (matches.size() > 200) { + matches.resize(200); + } + return matches; +} + +bool open_find_signal_result(UiState *state, const std::string &path) { + state->selected_browser_paths = {path}; + state->selected_browser_path = path; + state->browser_selection_anchor = path; + state->status_text = "Selected signal " + path; + return true; +} + +void draw_open_route_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("Open Route", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + ImGui::TextUnformatted("Load a route into the current layout."); + ImGui::Separator(); + input_text_string("Route", &state->route_buffer); + input_text_string("Data Dir", &state->data_dir_buffer); + ImGui::Spacing(); + switch (draw_modal_action_row("Load")) { + case ModalAction::Primary: + reload_session(session, state, state->route_buffer, state->data_dir_buffer); + ImGui::CloseCurrentPopup(); + break; + case ModalAction::Secondary: + sync_route_buffers(state, *session); + ImGui::CloseCurrentPopup(); + break; + case ModalAction::None: + break; + } + ImGui::EndPopup(); +} + +void draw_stream_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("Live Stream", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + + ImGui::TextUnformatted("Connect to a live source."); + ImGui::Separator(); + if (ImGui::RadioButton("Local (MSGQ)", state->stream_source_kind == StreamSourceKind::CerealLocal)) { + state->stream_source_kind = StreamSourceKind::CerealLocal; + } + if (ImGui::RadioButton("Remote (ZMQ)", state->stream_source_kind == StreamSourceKind::CerealRemote)) { + state->stream_source_kind = StreamSourceKind::CerealRemote; + } + + if (state->stream_source_kind == StreamSourceKind::CerealRemote) { + input_text_string("Address", &state->stream_address_buffer); + } + ImGui::InputDouble("Buffer (seconds)", &state->stream_buffer_seconds, 0.0, 0.0, "%.0f"); + ImGui::Spacing(); + switch (draw_modal_action_row("Connect")) { + case ModalAction::Primary: { + const StreamSourceConfig source = stream_source_config_from_ui(*state); + if (start_stream_session(session, state, source, state->stream_buffer_seconds, false)) { + ImGui::CloseCurrentPopup(); + } + break; + } + case ModalAction::Secondary: + sync_stream_buffers(state, *session); + ImGui::CloseCurrentPopup(); + break; + case ModalAction::None: + break; + } + ImGui::EndPopup(); +} + +void draw_load_layout_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("Load Layout", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + ImGui::TextUnformatted("Load a JotPlugger JSON layout."); + ImGui::Separator(); + input_text_string("Layout", &state->load_layout_buffer); + ImGui::Spacing(); + switch (draw_modal_action_row("Load")) { + case ModalAction::Primary: + if (reload_layout(session, state, state->load_layout_buffer)) { + ImGui::CloseCurrentPopup(); + } + break; + case ModalAction::Secondary: + sync_layout_buffers(state, *session); + ImGui::CloseCurrentPopup(); + break; + case ModalAction::None: + break; + } + ImGui::EndPopup(); +} + +void draw_save_layout_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("Save Layout", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + ImGui::TextUnformatted("Save the current workspace as a JotPlugger JSON layout."); + ImGui::Separator(); + input_text_string("Layout", &state->save_layout_buffer); + ImGui::Spacing(); + switch (draw_modal_action_row("Save")) { + case ModalAction::Primary: + if (save_layout(session, state, state->save_layout_buffer)) { + ImGui::CloseCurrentPopup(); + } + break; + case ModalAction::Secondary: + sync_layout_buffers(state, *session); + ImGui::CloseCurrentPopup(); + break; + case ModalAction::None: + break; + } + ImGui::EndPopup(); +} + +void draw_preferences_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("Preferences", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + if (session->map_data) { + const MapCacheStats map_cache = session->map_data->cacheStats(); + const MapCacheStats download_cache = directory_cache_stats(Path::download_cache_root()); + ImGui::TextUnformatted("Map"); + ImGui::Separator(); + ImGui::Text("Map cache: %s in %zu file%s", + format_cache_bytes(map_cache.bytes).c_str(), + map_cache.files, + map_cache.files == 1 ? "" : "s"); + if (ImGui::Button("Clear Map Cache", ImVec2(120.0f, 0.0f))) { + session->map_data->clearCache(); + state->status_text = "Cleared map cache"; + } + ImGui::Spacing(); + ImGui::TextUnformatted("comma Download Cache"); + ImGui::Separator(); + ImGui::Text("Download cache: %s in %zu file%s", + format_cache_bytes(download_cache.bytes).c_str(), + download_cache.files, + download_cache.files == 1 ? "" : "s"); + ImGui::TextDisabled("%s", Path::download_cache_root().c_str()); + ImGui::Spacing(); + } + if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_find_signal_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("Find Signal", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + ImGui::TextUnformatted("Search decoded signals across the loaded route."); + ImGui::Separator(); + ImGui::SetNextItemWidth(560.0f); + input_text_with_hint_string("##find_signal_query", "Search signal path or name...", &state->find_signal_buffer); + if (ImGui::IsWindowAppearing()) { + ImGui::SetKeyboardFocusHere(-1); + } + const std::vector matches = find_signal_matches(*session, state->find_signal_buffer); + ImGui::Spacing(); + ImGui::TextDisabled("%zu match%s", matches.size(), matches.size() == 1 ? "" : "es"); + if (ImGui::BeginChild("##find_signal_results", ImVec2(760.0f, 360.0f), true)) { + for (const FindSignalMatch &match : matches) { + const std::string &path = *match.path; + const size_t slash = path.find_last_of('/'); + const std::string_view label = slash == std::string::npos ? std::string_view(path) : std::string_view(path).substr(slash + 1); + if (ImGui::Selectable((std::string(label) + "##" + path).c_str(), false, ImGuiSelectableFlags_SpanAllColumns)) { + if (open_find_signal_result(state, path)) { + ImGui::CloseCurrentPopup(); + } + } + ImGui::SameLine(280.0f); + ImGui::TextDisabled("%s", path.c_str()); + } + } + ImGui::EndChild(); + ImGui::Spacing(); + if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +std::string default_dbc_template() { + return "VERSION \"\"\n\nNS_ :\nBS_:\nBU_: XXX\n"; +} + +DbcEditorSource resolve_dbc_editor_source(const std::string &dbc_name) { + const fs::path generated_dbc_dir = repo_root() / "tools" / "jotpluggler" / "generated_dbcs"; + const std::array candidates = {{ + {.path = repo_root() / "opendbc" / "dbc" / (dbc_name + ".dbc"), .kind = DbcEditorState::SourceKind::Opendbc}, + {.path = generated_dbc_dir / (dbc_name + ".dbc"), .kind = DbcEditorState::SourceKind::Generated}, + }}; + for (const DbcEditorSource &candidate : candidates) { + if (fs::exists(candidate.path)) { + return candidate; + } + } + return {}; +} + +void load_dbc_editor_state(const AppSession &session, UiState *state) { + DbcEditorState &editor = state->dbc_editor; + const std::string dbc_name = !session.dbc_override.empty() ? session.dbc_override : session.route_data.dbc_name; + editor.source_name = dbc_name.empty() ? "untitled" : dbc_name; + editor.source_path.clear(); + editor.source_kind = DbcEditorState::SourceKind::None; + if (dbc_name.empty()) { + editor.save_name = "custom_can"; + editor.text = default_dbc_template(); + } else { + const DbcEditorSource source = resolve_dbc_editor_source(dbc_name); + editor.source_kind = source.kind; + editor.source_path = source.path; + editor.text = source.path.empty() ? default_dbc_template() : read_file_or_throw(source.path); + editor.save_name = source.kind == DbcEditorState::SourceKind::Generated ? dbc_name : dbc_name + "_edited"; + } + editor.loaded = true; +} + +bool ensure_dbc_editor_loaded(const AppSession &session, UiState *state) { + if (!state->dbc_editor.loaded) { + try { + load_dbc_editor_state(session, state); + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + return false; + } + } + return true; +} + +bool save_dbc_editor_contents(AppSession *session, UiState *state) { + DbcEditorState &editor = state->dbc_editor; + editor.save_name = util::strip(editor.save_name); + if (editor.save_name.empty()) { + state->error_text = "DBC name cannot be empty"; + state->open_error_popup = true; + return false; + } + if (editor.source_kind == DbcEditorState::SourceKind::Opendbc && editor.save_name == editor.source_name) { + state->error_text = "Save edited opendbc files under a new name"; + state->open_error_popup = true; + return false; + } + try { + dbc::Database::fromContent(editor.text, editor.save_name + ".dbc"); + const fs::path generated_dbc_dir = repo_root() / "tools" / "jotpluggler" / "generated_dbcs"; + fs::create_directories(generated_dbc_dir); + const fs::path output = generated_dbc_dir / (editor.save_name + ".dbc"); + write_file_or_throw(output, editor.text); + apply_dbc_override_change(session, state, editor.save_name); + editor.source_name = editor.save_name; + editor.source_path = output; + editor.source_kind = DbcEditorState::SourceKind::Generated; + editor.loaded = false; + state->status_text = "Saved DBC " + editor.save_name; + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + return false; + } +} + +void draw_dbc_editor_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("DBC Editor", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + DbcEditorState &editor = state->dbc_editor; + if (!ensure_dbc_editor_loaded(*session, state)) { + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + return; + } + ImGui::TextUnformatted("Edit DBC text and save it into generated_dbcs."); + ImGui::Separator(); + ImGui::SetNextItemWidth(260.0f); + input_text_string("DBC Name", &editor.save_name, ImGuiInputTextFlags_AutoSelectAll); + if (!editor.source_path.empty()) { + ImGui::TextDisabled("%s", editor.source_path.string().c_str()); + } else { + ImGui::TextDisabled("New in-memory DBC"); + } + ImGui::Spacing(); + input_text_multiline_string("##dbc_editor_text", &editor.text, ImVec2(920.0f, 520.0f), ImGuiInputTextFlags_AllowTabInput); + ImGui::Spacing(); + if (ImGui::Button("Apply + Save", ImVec2(140.0f, 0.0f))) { + if (save_dbc_editor_contents(session, state)) { + ImGui::CloseCurrentPopup(); + } + } + ImGui::SameLine(); + if (ImGui::Button("Reload Source", ImVec2(140.0f, 0.0f))) { + editor.loaded = false; + } + ImGui::SameLine(); + if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { + editor.loaded = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void draw_axis_limits_popup(AppSession *session, UiState *state) { + if (!ImGui::BeginPopupModal("Edit Axis Limits", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + const WorkspaceTab *tab = app_active_tab(session->layout, *state); + const bool valid_pane = tab != nullptr + && state->axis_limits.pane_index >= 0 + && state->axis_limits.pane_index < static_cast(tab->panes.size()); + if (!valid_pane) { + ImGui::TextWrapped("The selected pane is no longer available."); + ImGui::Spacing(); + if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { + state->axis_limits.pane_index = -1; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + return; + } + + ImGui::TextUnformatted("X range applies to the active tab. Y limits apply to the selected pane."); + ImGui::Separator(); + ImGui::TextUnformatted("Horizontal"); + ImGui::SetNextItemWidth(180.0f); + ImGui::InputDouble("X Min", &state->axis_limits.x_min, 0.0, 0.0, "%.3f"); + ImGui::SetNextItemWidth(180.0f); + ImGui::InputDouble("X Max", &state->axis_limits.x_max, 0.0, 0.0, "%.3f"); + ImGui::Spacing(); + ImGui::TextUnformatted("Vertical"); + ImGui::Checkbox("Use Y Min", &state->axis_limits.y_min_enabled); + ImGui::BeginDisabled(!state->axis_limits.y_min_enabled); + ImGui::SetNextItemWidth(180.0f); + ImGui::InputDouble("Y Min", &state->axis_limits.y_min, 0.0, 0.0, "%.6g"); + ImGui::EndDisabled(); + ImGui::Checkbox("Use Y Max", &state->axis_limits.y_max_enabled); + ImGui::BeginDisabled(!state->axis_limits.y_max_enabled); + ImGui::SetNextItemWidth(180.0f); + ImGui::InputDouble("Y Max", &state->axis_limits.y_max, 0.0, 0.0, "%.6g"); + ImGui::EndDisabled(); + ImGui::Spacing(); + switch (draw_modal_action_row("Apply")) { + case ModalAction::Primary: + if (apply_axis_limits_editor(session, state)) { + state->axis_limits.pane_index = -1; + ImGui::CloseCurrentPopup(); + } + break; + case ModalAction::Secondary: + state->axis_limits.pane_index = -1; + ImGui::CloseCurrentPopup(); + break; + case ModalAction::None: + break; + } + ImGui::EndPopup(); +} + +void draw_error_popup(UiState *state) { + if (state->open_error_popup) { + ImGui::OpenPopup("Error"); + state->open_error_popup = false; + } + if (!ImGui::BeginPopupModal("Error", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + return; + } + ImGui::TextWrapped("%s", state->error_text.c_str()); + ImGui::Spacing(); + if (ImGui::Button("Close", ImVec2(120.0f, 0.0f))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +} // namespace + +bool reset_layout(AppSession *session, UiState *state) { + try { + if (session->layout_path.empty()) { + start_new_layout(session, state, "Reset layout"); + return true; + } + clear_layout_autosave(*session); + session->layout = load_sketch_layout(session->layout_path); + state->layout_dirty = false; + session->autosave_path = autosave_path_for_layout(session->layout_path); + state->undo.reset(session->layout); + refresh_replaced_layout_ui(session, state, false); + reset_shared_range(state, *session); + state->status_text = "Reset layout"; + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + state->status_text = "Failed to reset layout"; + return false; + } +} + +bool reload_layout(AppSession *session, UiState *state, const std::string &layout_arg) { + try { + const bool preserve_shared_range = session->route_data.has_time_range && state->has_shared_range; + const double preserved_x_min = state->x_view_min; + const double preserved_x_max = state->x_view_max; + const fs::path layout_path = resolve_layout_path(layout_arg); + session->autosave_path = autosave_path_for_layout(layout_path); + const bool load_draft = fs::exists(session->autosave_path); + session->layout = load_sketch_layout(load_draft ? session->autosave_path : layout_path); + session->layout_path = layout_path; + state->layout_dirty = load_draft; + state->undo.reset(session->layout); + refresh_replaced_layout_ui(session, state, true); + if (preserve_shared_range) { + state->has_shared_range = true; + state->x_view_min = preserved_x_min; + state->x_view_max = preserved_x_max; + clamp_shared_range(state, *session); + } else { + reset_shared_range(state, *session); + } + state->status_text = std::string(load_draft ? "Loaded layout draft " : "Loaded layout ") + + layout_path.filename().string(); + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + state->status_text = "Failed to load layout"; + return false; + } +} + +bool save_layout(AppSession *session, UiState *state, const std::string &layout_path) { + try { + if (layout_path.empty()) throw std::runtime_error("Layout path is empty"); + session->layout.current_tab_index = state->active_tab_index; + const fs::path previous_autosave = session->autosave_path; + const fs::path output = fs::absolute(fs::path(layout_path)); + save_layout_json(session->layout, output); + session->layout_path = output; + session->autosave_path = autosave_path_for_layout(output); + if (!previous_autosave.empty() && previous_autosave != session->autosave_path && fs::exists(previous_autosave)) { + fs::remove(previous_autosave); + } + clear_layout_autosave(*session); + state->layout_dirty = false; + sync_layout_buffers(state, *session); + state->status_text = "Saved layout " + output.filename().string(); + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + state->status_text = "Failed to save layout"; + return false; + } +} + +void rebuild_session_route_data(AppSession *session, UiState *state, + const RouteLoadProgressCallback &progress) { + apply_route_data(session, state, load_route_data(session->route_name, session->data_dir, session->dbc_override, progress)); +} + +void stop_stream_session(AppSession *session, UiState *state, bool preserve_data) { + if (preserve_data && session->stream_poller && session->data_mode == SessionDataMode::Stream) { + session->stream_poller->setPaused(true); + } else if (session->stream_poller) { + session->stream_poller->stop(); + } + session->stream_paused = preserve_data && session->data_mode == SessionDataMode::Stream; + if (!preserve_data) { + session->stream_time_offset.reset(); + apply_route_data(session, state, RouteData{}); + } + sync_stream_buffers(state, *session); +} + +bool start_stream_session(AppSession *session, + UiState *state, + const StreamSourceConfig &source, + double buffer_seconds, + bool preserve_existing_data) { + try { + if (session->route_loader) { + session->route_loader.reset(); + } + session->data_mode = SessionDataMode::Stream; + session->route_id = {}; + session->route_name.clear(); + session->data_dir.clear(); + session->stream_source = source; + if (session->stream_source.kind == StreamSourceKind::CerealLocal) { + session->stream_source.address = "127.0.0.1"; + } + session->stream_buffer_seconds = std::max(1.0, buffer_seconds); + session->next_stream_custom_refresh_time = 0.0; + session->stream_paused = false; + if (preserve_existing_data && session->stream_poller) { + StreamPollSnapshot snapshot = session->stream_poller->snapshot(); + if (snapshot.active) { + session->stream_poller->setPaused(false); + sync_route_buffers(state, *session); + sync_stream_buffers(state, *session); + state->follow_latest = true; + state->playback_playing = false; + state->status_text = "Resumed stream " + stream_source_target_label(session->stream_source); + return true; + } + } + if (!preserve_existing_data) { + session->stream_time_offset.reset(); + apply_route_data(session, state, RouteData{}); + } + if (!session->stream_poller) { + session->stream_poller = std::make_unique(); + } + session->stream_poller->start(session->stream_source, + session->stream_buffer_seconds, + session->dbc_override, + session->stream_time_offset); + sync_route_buffers(state, *session); + sync_stream_buffers(state, *session); + state->follow_latest = true; + state->playback_playing = false; + state->status_text = preserve_existing_data ? "Resumed stream " + stream_source_target_label(session->stream_source) + : "Streaming from " + stream_source_target_label(session->stream_source); + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + state->status_text = "Failed to start stream"; + return false; + } +} + +void start_async_route_load(AppSession *session, UiState *state) { + if (!session->route_loader) { + return; + } + apply_route_data(session, state, RouteData{}); + session->route_loader->start(session->route_name, session->data_dir, session->dbc_override); + state->status_text = session->route_name.empty() ? "Ready" : "Loading route " + session->route_name; +} + +void poll_async_route_load(AppSession *session, UiState *state) { + if (!session->route_loader) { + return; + } + RouteData loaded_route; + std::string error_text; + if (!session->route_loader->consume(&loaded_route, &error_text)) { + return; + } + if (!error_text.empty()) { + state->error_text = error_text; + state->open_error_popup = true; + state->status_text = "Failed to load route"; + return; + } + apply_route_data(session, state, std::move(loaded_route)); + state->status_text = session->route_name.empty() ? "Ready" : "Loaded route " + session->route_name; +} + +bool reload_session(AppSession *session, UiState *state, const std::string &route_name, const std::string &data_dir) { + try { + stop_stream_session(session, state, false); + session->data_mode = SessionDataMode::Route; + session->route_name = route_name; + session->route_id = parse_route_identifier(route_name); + session->data_dir = data_dir; + if (session->async_route_loading) { + if (!session->route_loader) { + session->route_loader = std::make_unique(::isatty(STDERR_FILENO) != 0); + } + start_async_route_load(session, state); + } else { + rebuild_session_route_data(session, state); + state->status_text = "Loaded route " + route_name; + } + sync_route_buffers(state, *session); + return true; + } catch (const std::exception &err) { + state->error_text = err.what(); + state->open_error_popup = true; + state->status_text = "Failed to load route"; + return false; + } +} + +void draw_popups(AppSession *session, UiState *state) { + open_queued_popup(state->open_open_route, "Open Route"); + if (state->open_stream) { + sync_stream_buffers(state, *session); + } + open_queued_popup(state->open_stream, "Live Stream"); + if (state->open_load_layout || state->open_save_layout) { + sync_layout_buffers(state, *session); + } + open_queued_popup(state->open_load_layout, "Load Layout"); + open_queued_popup(state->open_save_layout, "Save Layout"); + open_queued_popup(state->open_preferences, "Preferences"); + open_queued_popup(state->dbc_editor.open, "DBC Editor"); + open_queued_popup(state->open_find_signal, "Find Signal"); + open_queued_popup(state->axis_limits.open, "Edit Axis Limits"); + + draw_open_route_popup(session, state); + draw_stream_popup(session, state); + draw_load_layout_popup(session, state); + draw_save_layout_popup(session, state); + draw_preferences_popup(session, state); + draw_dbc_editor_popup(session, state); + draw_find_signal_popup(session, state); + draw_axis_limits_popup(session, state); + draw_error_popup(state); +} diff --git a/tools/jotpluggler/layout.py b/tools/jotpluggler/layout.py deleted file mode 100644 index 13fbee54e2..0000000000 --- a/tools/jotpluggler/layout.py +++ /dev/null @@ -1,477 +0,0 @@ -import dearpygui.dearpygui as dpg -from openpilot.tools.jotpluggler.data import DataManager -from openpilot.tools.jotpluggler.views import TimeSeriesPanel - -GRIP_SIZE = 4 -MIN_PANE_SIZE = 60 - -class LayoutManager: - def __init__(self, data_manager, playback_manager, worker_manager, scale: float = 1.0): - self.data_manager = data_manager - self.playback_manager = playback_manager - self.worker_manager = worker_manager - self.scale = scale - self.container_tag = "plot_layout_container" - self.tab_bar_tag = "tab_bar_container" - self.tab_content_tag = "tab_content_area" - - self.active_tab = 0 - initial_panel_layout = PanelLayoutManager(data_manager, playback_manager, worker_manager, scale) - self.tabs: dict = {0: {"name": "Tab 1", "panel_layout": initial_panel_layout}} - self._next_tab_id = self.active_tab + 1 - - def to_dict(self) -> dict: - return { - "tabs": { - str(tab_id): { - "name": tab_data["name"], - "panel_layout": tab_data["panel_layout"].to_dict() - } - for tab_id, tab_data in self.tabs.items() - } - } - - def clear_and_load_from_dict(self, data: dict): - tab_ids_to_close = list(self.tabs.keys()) - for tab_id in tab_ids_to_close: - self.close_tab(tab_id, force=True) - - for tab_id_str, tab_data in data["tabs"].items(): - tab_id = int(tab_id_str) - panel_layout = PanelLayoutManager.load_from_dict( - tab_data["panel_layout"], self.data_manager, self.playback_manager, - self.worker_manager, self.scale - ) - self.tabs[tab_id] = { - "name": tab_data["name"], - "panel_layout": panel_layout - } - - self.active_tab = min(self.tabs.keys()) if self.tabs else 0 - self._next_tab_id = max(self.tabs.keys()) + 1 if self.tabs else 1 - - def create_ui(self, parent_tag: str): - if dpg.does_item_exist(self.container_tag): - dpg.delete_item(self.container_tag) - - with dpg.child_window(tag=self.container_tag, parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True, no_scroll_with_mouse=True): - self._create_tab_bar() - self._create_tab_content() - dpg.bind_item_theme(self.tab_bar_tag, "tab_bar_theme") - - def _create_tab_bar(self): - text_size = int(13 * self.scale) - with dpg.child_window(tag=self.tab_bar_tag, parent=self.container_tag, height=(text_size + 8), border=False, horizontal_scrollbar=True): - with dpg.group(horizontal=True, tag="tab_bar_group"): - for tab_id, tab_data in self.tabs.items(): - self._create_tab_ui(tab_id, tab_data["name"]) - dpg.add_image_button(texture_tag="plus_texture", callback=self.add_tab, width=text_size, height=text_size, tag="add_tab_button") - dpg.bind_item_theme("add_tab_button", "inactive_tab_theme") - - def _create_tab_ui(self, tab_id: int, tab_name: str): - text_size = int(13 * self.scale) - tab_width = int(140 * self.scale) - with dpg.child_window(width=tab_width, height=-1, border=False, no_scrollbar=True, tag=f"tab_window_{tab_id}", parent="tab_bar_group"): - with dpg.group(horizontal=True, tag=f"tab_group_{tab_id}"): - dpg.add_input_text( - default_value=tab_name, width=tab_width - text_size - 16, callback=lambda s, v, u: self.rename_tab(u, v), user_data=tab_id, tag=f"tab_input_{tab_id}" - ) - dpg.add_image_button( - texture_tag="x_texture", callback=lambda s, a, u: self.close_tab(u), user_data=tab_id, width=text_size, height=text_size, tag=f"tab_close_{tab_id}" - ) - with dpg.item_handler_registry(tag=f"tab_handler_{tab_id}"): - dpg.add_item_clicked_handler(callback=lambda s, a, u: self.switch_tab(u), user_data=tab_id) - dpg.bind_item_handler_registry(f"tab_group_{tab_id}", f"tab_handler_{tab_id}") - - theme_tag = "active_tab_theme" if tab_id == self.active_tab else "inactive_tab_theme" - dpg.bind_item_theme(f"tab_window_{tab_id}", theme_tag) - - def _create_tab_content(self): - with dpg.child_window(tag=self.tab_content_tag, parent=self.container_tag, border=False, width=-1, height=-1, no_scrollbar=True, no_scroll_with_mouse=True): - if self.active_tab in self.tabs: - active_panel_layout = self.tabs[self.active_tab]["panel_layout"] - active_panel_layout.create_ui() - - def add_tab(self): - new_panel_layout = PanelLayoutManager(self.data_manager, self.playback_manager, self.worker_manager, self.scale) - new_tab = {"name": f"Tab {self._next_tab_id + 1}", "panel_layout": new_panel_layout} - self.tabs[self._next_tab_id] = new_tab - self._create_tab_ui(self._next_tab_id, new_tab["name"]) - dpg.move_item("add_tab_button", parent="tab_bar_group") # move plus button to end - self.switch_tab(self._next_tab_id) - self._next_tab_id += 1 - - def close_tab(self, tab_id: int, force = False): - if len(self.tabs) <= 1 and not force: - return # don't allow closing the last tab - - tab_to_close = self.tabs[tab_id] - tab_to_close["panel_layout"].destroy_ui() - for suffix in ["window", "group", "input", "close", "handler"]: - tag = f"tab_{suffix}_{tab_id}" - if dpg.does_item_exist(tag): - dpg.delete_item(tag) - del self.tabs[tab_id] - - if self.active_tab == tab_id and self.tabs: # switch to another tab if we closed the active one - self.active_tab = next(iter(self.tabs.keys())) - self._switch_tab_content() - dpg.bind_item_theme(f"tab_window_{self.active_tab}", "active_tab_theme") - - def switch_tab(self, tab_id: int): - if tab_id == self.active_tab or tab_id not in self.tabs: - return - - current_panel_layout = self.tabs[self.active_tab]["panel_layout"] - current_panel_layout.destroy_ui() - dpg.bind_item_theme(f"tab_window_{self.active_tab}", "inactive_tab_theme") # deactivate old tab - self.active_tab = tab_id - dpg.bind_item_theme(f"tab_window_{tab_id}", "active_tab_theme") # activate new tab - self._switch_tab_content() - - def _switch_tab_content(self): - dpg.delete_item(self.tab_content_tag, children_only=True) - active_panel_layout = self.tabs[self.active_tab]["panel_layout"] - active_panel_layout.create_ui() - active_panel_layout.update_all_panels() - - def rename_tab(self, tab_id: int, new_name: str): - if tab_id in self.tabs: - self.tabs[tab_id]["name"] = new_name - - def update_all_panels(self): - self.tabs[self.active_tab]["panel_layout"].update_all_panels() - - def on_viewport_resize(self): - self.tabs[self.active_tab]["panel_layout"].on_viewport_resize() - -class PanelLayoutManager: - def __init__(self, data_manager: DataManager, playback_manager, worker_manager, scale: float = 1.0): - self.data_manager = data_manager - self.playback_manager = playback_manager - self.worker_manager = worker_manager - self.scale = scale - self.active_panels: list = [] - self.parent_tag = "tab_content_area" - self._queue_resize = False - self._created_handler_tags: set[str] = set() - - self.grip_size = int(GRIP_SIZE * self.scale) - self.min_pane_size = int(MIN_PANE_SIZE * self.scale) - - initial_panel = TimeSeriesPanel(data_manager, playback_manager, worker_manager) - self.layout: dict = {"type": "panel", "panel": initial_panel} - - def to_dict(self) -> dict: - return self._layout_to_dict(self.layout) - - def _layout_to_dict(self, layout: dict) -> dict: - if layout["type"] == "panel": - return { - "type": "panel", - "panel": layout["panel"].to_dict() - } - else: # split - return { - "type": "split", - "orientation": layout["orientation"], - "proportions": layout["proportions"], - "children": [self._layout_to_dict(child) for child in layout["children"]] - } - - @classmethod - def load_from_dict(cls, data: dict, data_manager, playback_manager, worker_manager, scale: float = 1.0): - manager = cls(data_manager, playback_manager, worker_manager, scale) - manager.layout = manager._dict_to_layout(data) - return manager - - def _dict_to_layout(self, data: dict) -> dict: - if data["type"] == "panel": - panel_data = data["panel"] - if panel_data["type"] == "timeseries": - panel = TimeSeriesPanel.load_from_dict( - panel_data, self.data_manager, self.playback_manager, self.worker_manager - ) - return {"type": "panel", "panel": panel} - else: - # Handle future panel types here or make a general mapping - raise ValueError(f"Unknown panel type: {panel_data['type']}") - else: # split - return { - "type": "split", - "orientation": data["orientation"], - "proportions": data["proportions"], - "children": [self._dict_to_layout(child) for child in data["children"]] - } - - def create_ui(self): - self.active_panels.clear() - if dpg.does_item_exist(self.parent_tag): - dpg.delete_item(self.parent_tag, children_only=True) - self._cleanup_all_handlers() - - container_width, container_height = dpg.get_item_rect_size(self.parent_tag) - if container_width == 0 and container_height == 0: - self._queue_resize = True - self._create_ui_recursive(self.layout, self.parent_tag, [], container_width, container_height) - - def destroy_ui(self): - self._cleanup_ui_recursive(self.layout, []) - self._cleanup_all_handlers() - self.active_panels.clear() - - def _cleanup_all_handlers(self): - for handler_tag in list(self._created_handler_tags): - if dpg.does_item_exist(handler_tag): - dpg.delete_item(handler_tag) - self._created_handler_tags.clear() - - def _create_ui_recursive(self, layout: dict, parent_tag: str, path: list[int], width: int, height: int): - if layout["type"] == "panel": - self._create_panel_ui(layout, parent_tag, path, width, height) - else: - self._create_split_ui(layout, parent_tag, path, width, height) - - def _create_panel_ui(self, layout: dict, parent_tag: str, path: list[int], width: int, height: int): - panel_tag = self._path_to_tag(path, "panel") - panel = layout["panel"] - self.active_panels.append(panel) - text_size = int(13 * self.scale) - bar_height = (text_size + 24) if width < int(329 * self.scale + 64) else (text_size + 8) # adjust height to allow for scrollbar - - with dpg.child_window(parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True): - with dpg.group(horizontal=True): - with dpg.child_window(tag=panel_tag, width=-(text_size + 16), height=bar_height, horizontal_scrollbar=True, no_scroll_with_mouse=True, border=False): - with dpg.group(horizontal=True): - # if you change the widths make sure to change the sum of widths (currently 329 * scale) - dpg.add_input_text(default_value=panel.title, width=int(150 * self.scale), callback=lambda s, v: setattr(panel, "title", v)) - dpg.add_combo(items=["Time Series"], default_value="Time Series", width=int(100 * self.scale)) - dpg.add_button(label="Clear", callback=lambda: self.clear_panel(panel), width=int(40 * self.scale)) - dpg.add_image_button(texture_tag="split_h_texture", callback=lambda: self.split_panel(path, 0), width=text_size, height=text_size) - dpg.add_image_button(texture_tag="split_v_texture", callback=lambda: self.split_panel(path, 1), width=text_size, height=text_size) - dpg.add_image_button(texture_tag="x_texture", callback=lambda: self.delete_panel(path), width=text_size, height=text_size) - - dpg.add_separator() - - content_tag = self._path_to_tag(path, "content") - with dpg.child_window(tag=content_tag, border=False, height=-1, width=-1, no_scrollbar=True): - panel.create_ui(content_tag) - - def _create_split_ui(self, layout: dict, parent_tag: str, path: list[int], width: int, height: int): - split_tag = self._path_to_tag(path, "split") - orientation, _, pane_sizes = self._get_split_geometry(layout, (width, height)) - - with dpg.group(tag=split_tag, parent=parent_tag, horizontal=orientation == 0): - for i, child_layout in enumerate(layout["children"]): - child_path = path + [i] - container_tag = self._path_to_tag(child_path, "container") - pane_width, pane_height = [(pane_sizes[i], -1), (-1, pane_sizes[i])][orientation] # fill 2nd dim up to the border - with dpg.child_window(tag=container_tag, width=pane_width, height=pane_height, border=False, no_scrollbar=True): - child_width, child_height = [(pane_sizes[i], height), (width, pane_sizes[i])][orientation] - self._create_ui_recursive(child_layout, container_tag, child_path, child_width, child_height) - if i < len(layout["children"]) - 1: - self._create_grip(split_tag, path, i, orientation) - - def clear_panel(self, panel): - panel.clear() - - def delete_panel(self, panel_path: list[int]): - if not panel_path: # Root deletion - old_panel = self.layout["panel"] - old_panel.destroy_ui() - self.active_panels.remove(old_panel) - new_panel = TimeSeriesPanel(self.data_manager, self.playback_manager, self.worker_manager) - self.layout = {"type": "panel", "panel": new_panel} - self._rebuild_ui_at_path([]) - return - - parent, child_index = self._get_parent_and_index(panel_path) - layout_to_delete = parent["children"][child_index] - self._cleanup_ui_recursive(layout_to_delete, panel_path) - - parent["children"].pop(child_index) - parent["proportions"].pop(child_index) - - if len(parent["children"]) == 1: # remove parent and collapse - remaining_child = parent["children"][0] - if len(panel_path) == 1: # parent is at root level - promote remaining child to root - self.layout = remaining_child - self._rebuild_ui_at_path([]) - else: # replace parent with remaining child in grandparent - grandparent_path = panel_path[:-2] - parent_index = panel_path[-2] - self._replace_layout_at_path(grandparent_path + [parent_index], remaining_child) - self._rebuild_ui_at_path(grandparent_path + [parent_index]) - else: # redistribute proportions - equal_prop = 1.0 / len(parent["children"]) - parent["proportions"] = [equal_prop] * len(parent["children"]) - self._rebuild_ui_at_path(panel_path[:-1]) - - def split_panel(self, panel_path: list[int], orientation: int): - current_layout = self._get_layout_at_path(panel_path) - existing_panel = current_layout["panel"] - new_panel = TimeSeriesPanel(self.data_manager, self.playback_manager, self.worker_manager) - parent, child_index = self._get_parent_and_index(panel_path) - - if parent is None: # Root split - self.layout = { - "type": "split", - "orientation": orientation, - "children": [{"type": "panel", "panel": existing_panel}, {"type": "panel", "panel": new_panel}], - "proportions": [0.5, 0.5], - } - self._rebuild_ui_at_path([]) - elif parent["type"] == "split" and parent["orientation"] == orientation: # Same orientation - insert into existing split - parent["children"].insert(child_index + 1, {"type": "panel", "panel": new_panel}) - parent["proportions"] = [1.0 / len(parent["children"])] * len(parent["children"]) - self._rebuild_ui_at_path(panel_path[:-1]) - else: # Different orientation - create new split level - new_split = {"type": "split", "orientation": orientation, "children": [current_layout, {"type": "panel", "panel": new_panel}], "proportions": [0.5, 0.5]} - self._replace_layout_at_path(panel_path, new_split) - self._rebuild_ui_at_path(panel_path) - - def _rebuild_ui_at_path(self, path: list[int]): - layout = self._get_layout_at_path(path) - if path: - container_tag = self._path_to_tag(path, "container") - else: # Root update - container_tag = self.parent_tag - - self._cleanup_ui_recursive(layout, path) - dpg.delete_item(container_tag, children_only=True) - width, height = dpg.get_item_rect_size(container_tag) - self._create_ui_recursive(layout, container_tag, path, width, height) - - def _cleanup_ui_recursive(self, layout: dict, path: list[int]): - if layout["type"] == "panel": - panel = layout["panel"] - panel.destroy_ui() - if panel in self.active_panels: - self.active_panels.remove(panel) - else: - for i in range(len(layout["children"]) - 1): - handler_tag = f"{self._path_to_tag(path, f'grip_{i}')}_handler" - if dpg.does_item_exist(handler_tag): - dpg.delete_item(handler_tag) - self._created_handler_tags.discard(handler_tag) - - for i, child in enumerate(layout["children"]): - self._cleanup_ui_recursive(child, path + [i]) - - def update_all_panels(self): - if self._queue_resize: - if (size := dpg.get_item_rect_size(self.parent_tag)) != [0, 0]: - self._queue_resize = False - self._resize_splits_recursive(self.layout, [], *size) - for panel in self.active_panels: - panel.update() - - def on_viewport_resize(self): - self._resize_splits_recursive(self.layout, []) - - def _resize_splits_recursive(self, layout: dict, path: list[int], width: int | None = None, height: int | None = None): - if layout["type"] == "split": - split_tag = self._path_to_tag(path, "split") - if dpg.does_item_exist(split_tag): - available_sizes = (width, height) if width and height else dpg.get_item_rect_size(dpg.get_item_parent(split_tag)) - orientation, _, pane_sizes = self._get_split_geometry(layout, available_sizes) - size_properties = ("width", "height") - - for i, child_layout in enumerate(layout["children"]): - child_path = path + [i] - container_tag = self._path_to_tag(child_path, "container") - if dpg.does_item_exist(container_tag): - dpg.configure_item(container_tag, **{size_properties[orientation]: pane_sizes[i]}) - child_width, child_height = [(pane_sizes[i], available_sizes[1]), (available_sizes[0], pane_sizes[i])][orientation] - self._resize_splits_recursive(child_layout, child_path, child_width, child_height) - else: # leaf node/panel - adjust bar height to allow for scrollbar - panel_tag = self._path_to_tag(path, "panel") - if width is not None and width < int(329 * self.scale + 64): # scaled widths of the elements in top bar + fixed 8 padding on left and right of each item - dpg.configure_item(panel_tag, height=(int(13 * self.scale) + 24)) - else: - dpg.configure_item(panel_tag, height=(int(13 * self.scale) + 8)) - - def _get_split_geometry(self, layout: dict, available_size: tuple[int, int]) -> tuple[int, int, list[int]]: - orientation = layout["orientation"] - num_grips = len(layout["children"]) - 1 - usable_size = max(self.min_pane_size, available_size[orientation] - (num_grips * (self.grip_size + 8 * (2 - orientation)))) # approximate, scaling is weird - pane_sizes = [max(self.min_pane_size, int(usable_size * prop)) for prop in layout["proportions"]] - return orientation, usable_size, pane_sizes - - def _get_layout_at_path(self, path: list[int]) -> dict: - current = self.layout - for index in path: - current = current["children"][index] - return current - - def _get_parent_and_index(self, path: list[int]) -> tuple: - return (None, -1) if not path else (self._get_layout_at_path(path[:-1]), path[-1]) - - def _replace_layout_at_path(self, path: list[int], new_layout: dict): - if not path: - self.layout = new_layout - else: - parent, index = self._get_parent_and_index(path) - parent["children"][index] = new_layout - - def _path_to_tag(self, path: list[int], prefix: str = "") -> str: - path_str = "_".join(map(str, path)) if path else "root" - return f"{prefix}_{path_str}" if prefix else path_str - - def _create_grip(self, parent_tag: str, path: list[int], grip_index: int, orientation: int): - grip_tag = self._path_to_tag(path, f"grip_{grip_index}") - handler_tag = f"{grip_tag}_handler" - width, height = [(self.grip_size, -1), (-1, self.grip_size)][orientation] - - with dpg.child_window(tag=grip_tag, parent=parent_tag, width=width, height=height, no_scrollbar=True, border=False): - button_tag = dpg.add_button(label="", width=-1, height=-1) - - with dpg.item_handler_registry(tag=handler_tag): - user_data = (path, grip_index, orientation) - dpg.add_item_active_handler(callback=self._on_grip_drag, user_data=user_data) - dpg.add_item_deactivated_handler(callback=self._on_grip_end, user_data=user_data) - dpg.bind_item_handler_registry(button_tag, handler_tag) - self._created_handler_tags.add(handler_tag) - - def _on_grip_drag(self, sender, app_data, user_data): - path, grip_index, orientation = user_data - layout = self._get_layout_at_path(path) - - if "_drag_data" not in layout: - layout["_drag_data"] = {"initial_proportions": layout["proportions"][:], "start_mouse": dpg.get_mouse_pos(local=False)[orientation]} - return - - drag_data = layout["_drag_data"] - split_tag = self._path_to_tag(path, "split") - if not dpg.does_item_exist(split_tag): - return - - _, usable_size, _ = self._get_split_geometry(layout, dpg.get_item_rect_size(split_tag)) - current_coord = dpg.get_mouse_pos(local=False)[orientation] - delta = current_coord - drag_data["start_mouse"] - delta_prop = delta / usable_size - - left_idx = grip_index - right_idx = left_idx + 1 - initial = drag_data["initial_proportions"] - min_prop = self.min_pane_size / usable_size - - new_left = max(min_prop, initial[left_idx] + delta_prop) - new_right = max(min_prop, initial[right_idx] - delta_prop) - - total_available = initial[left_idx] + initial[right_idx] - if new_left + new_right > total_available: - if new_left > new_right: - new_left = total_available - new_right - else: - new_right = total_available - new_left - - layout["proportions"] = initial[:] - layout["proportions"][left_idx] = new_left - layout["proportions"][right_idx] = new_right - - self._resize_splits_recursive(layout, path) - - def _on_grip_end(self, sender, app_data, user_data): - path, _, _ = user_data - self._get_layout_at_path(path).pop("_drag_data", None) diff --git a/tools/jotpluggler/layout_io.cc b/tools/jotpluggler/layout_io.cc new file mode 100644 index 0000000000..f984c0f0e8 --- /dev/null +++ b/tools/jotpluggler/layout_io.cc @@ -0,0 +1,128 @@ +#include "tools/jotpluggler/app.h" +#include "tools/jotpluggler/common.h" + +#include +#include +#include +#include + +#include "third_party/json11/json11.hpp" + +namespace fs = std::filesystem; + +namespace { + +std::string curve_color_hex(const std::array &color) { + std::ostringstream hex; + hex << "#" << std::hex << std::setfill('0') + << std::setw(2) << static_cast(color[0]) + << std::setw(2) << static_cast(color[1]) + << std::setw(2) << static_cast(color[2]); + return hex.str(); +} + +json11::Json curve_to_json(const Curve &curve) { + json11::Json::object obj = { + {"name", curve.name}, + {"color", curve_color_hex(curve.color)}, + }; + if (curve.derivative) { + obj["transform"] = "derivative"; + if (curve.derivative_dt > 0.0) { + obj["derivative_dt"] = curve.derivative_dt; + } + } else if (std::abs(curve.value_scale - 1.0) > 1.0e-9 || std::abs(curve.value_offset) > 1.0e-9) { + obj["transform"] = "scale"; + obj["scale"] = curve.value_scale; + obj["offset"] = curve.value_offset; + } + if (curve.custom_python.has_value()) { + json11::Json::array additional_sources; + for (const std::string &path : curve.custom_python->additional_sources) { + additional_sources.push_back(path); + } + obj["custom_python"] = json11::Json::object{ + {"linked_source", curve.custom_python->linked_source}, + {"additional_sources", additional_sources}, + {"globals_code", curve.custom_python->globals_code}, + {"function_code", curve.custom_python->function_code}, + }; + } + return obj; +} + +json11::Json workspace_node_to_json(const WorkspaceNode &node, const WorkspaceTab &tab) { + if (node.is_pane) { + if (node.pane_index < 0 || node.pane_index >= static_cast(tab.panes.size())) { + return nullptr; + } + const Pane &pane = tab.panes[static_cast(node.pane_index)]; + json11::Json::object obj = { + {"title", pane.title.empty() ? std::string("...") : pane.title}, + }; + if (pane.kind == PaneKind::Map) { + obj["kind"] = "map"; + } else if (pane.kind == PaneKind::Camera) { + obj["kind"] = "camera"; + obj["camera_view"] = camera_view_spec(pane.camera_view).layout_name; + } + if (pane.range.valid) { + obj["range"] = json11::Json::object{ + {"left", pane.range.left}, {"right", pane.range.right}, + {"top", pane.range.top}, {"bottom", pane.range.bottom}, + }; + } + if (pane.range.has_y_limit_min || pane.range.has_y_limit_max) { + json11::Json::object limits; + if (pane.range.has_y_limit_min) { + limits["min"] = pane.range.y_limit_min; + } + if (pane.range.has_y_limit_max) { + limits["max"] = pane.range.y_limit_max; + } + obj["y_limits"] = limits; + } + json11::Json::array curves; + for (const Curve &curve : pane.curves) { + if (!curve.runtime_only) { + curves.push_back(curve_to_json(curve)); + } + } + obj["curves"] = curves; + return obj; + } + + if (node.children.empty()) return nullptr; + json11::Json::array sizes; + for (size_t i = 0; i < node.children.size(); ++i) { + sizes.push_back(i < node.sizes.size() ? static_cast(node.sizes[i]) + : 1.0 / static_cast(node.children.size())); + } + json11::Json::array children; + for (const WorkspaceNode &child : node.children) { + children.push_back(workspace_node_to_json(child, tab)); + } + return json11::Json::object{ + {"split", node.orientation == SplitOrientation::Horizontal ? "horizontal" : "vertical"}, + {"sizes", sizes}, + {"children", children}, + }; +} + +} // namespace + +void save_layout_json(const SketchLayout &layout, const fs::path &path) { + ensure_parent_dir(path); + json11::Json::array tabs; + for (const WorkspaceTab &tab : layout.tabs) { + tabs.push_back(json11::Json::object{ + {"name", tab.tab_name}, + {"root", workspace_node_to_json(tab.root, tab)}, + }); + } + const json11::Json root = json11::Json::object{ + {"current_tab_index", std::clamp(layout.current_tab_index, 0, std::max(0, static_cast(layout.tabs.size()) - 1))}, + {"tabs", tabs}, + }; + write_file_or_throw(path, root.dump() + "\n"); +} diff --git a/tools/jotpluggler/layouts/.gitignore b/tools/jotpluggler/layouts/.gitignore new file mode 100644 index 0000000000..a965bb777d --- /dev/null +++ b/tools/jotpluggler/layouts/.gitignore @@ -0,0 +1 @@ +.jotpluggler_autosave/ diff --git a/tools/jotpluggler/layouts/CAN-bus-debug.json b/tools/jotpluggler/layouts/CAN-bus-debug.json new file mode 100644 index 0000000000..496993a1fd --- /dev/null +++ b/tools/jotpluggler/layouts/CAN-bus-debug.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.33362,0.33276,0.33362],"children":[{"title":"CAN RX","range":{"left":0.0,"right":60.526742,"top":1101.875,"bottom":-26.875},"curves":[{"name":"/pandaStates/0/canState0/totalRxCnt","color":"#f14cc1","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState1/totalRxCnt","color":"#9467bd","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState2/totalRxCnt","color":"#ff7f0e","transform":"derivative","derivative_dt":1.0}]},{"title":"CAN TX","range":{"left":0.0,"right":60.526742,"top":455.1,"bottom":-11.1},"curves":[{"name":"/pandaStates/0/canState0/totalTxCnt","color":"#17becf","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState1/totalTxCnt","color":"#bcbd22","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState2/totalTxCnt","color":"#1f77b4","transform":"derivative","derivative_dt":1.0}]},{"title":"CAN errors","range":{"left":0.0,"right":60.526742,"top":2515.35,"bottom":-61.35},"curves":[{"name":"/pandaStates/0/canState0/totalErrorCnt","color":"#1f77b4","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState1/totalErrorCnt","color":"#d62728","transform":"derivative","derivative_dt":1.0},{"name":"/pandaStates/0/canState2/totalErrorCnt","color":"#1ac938","transform":"derivative","derivative_dt":1.0}]}]}}]} diff --git a/tools/jotpluggler/layouts/camera-timings.json b/tools/jotpluggler/layouts/camera-timings.json new file mode 100644 index 0000000000..64decf15d3 --- /dev/null +++ b/tools/jotpluggler/layouts/camera-timings.json @@ -0,0 +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}}]}}]} diff --git a/tools/jotpluggler/layouts/cameras-and-map.json b/tools/jotpluggler/layouts/cameras-and-map.json new file mode 100644 index 0000000000..68c590f7bc --- /dev/null +++ b/tools/jotpluggler/layouts/cameras-and-map.json @@ -0,0 +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"}}]} diff --git a/tools/jotpluggler/layouts/can-states.json b/tools/jotpluggler/layouts/can-states.json new file mode 100644 index 0000000000..6f04940a33 --- /dev/null +++ b/tools/jotpluggler/layouts/can-states.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.500381,0.499619],"children":[{"split":"horizontal","sizes":[0.5,0.5],"children":[{"title":"...","range":{"left":0.0,"right":632.799721,"top":771630.925,"bottom":-17755.925},"curves":[{"name":"/pandaStates/0/canState0/totalRxCnt","color":"#1f77b4"},{"name":"/pandaStates/0/canState1/totalRxCnt","color":"#d62728"},{"name":"/pandaStates/0/canState2/totalRxCnt","color":"#1ac938"}]},{"title":"...","range":{"left":0.0,"right":632.799721,"top":760365.5,"bottom":-18545.5},"curves":[{"name":"/pandaStates/0/canState0/totalTxCnt","color":"#ff7f0e"},{"name":"/pandaStates/0/canState1/totalTxCnt","color":"#f14cc1"},{"name":"/pandaStates/0/canState2/totalTxCnt","color":"#9467bd"}]}]},{"split":"horizontal","sizes":[0.333333,0.333333,0.333333],"children":[{"title":"...","range":{"left":0.0,"right":632.799721,"top":55.35,"bottom":-1.35},"curves":[{"name":"/pandaStates/0/canState0/totalRxLostCnt","color":"#ff7f0e"},{"name":"/pandaStates/0/canState1/totalRxLostCnt","color":"#f14cc1"},{"name":"/pandaStates/0/canState2/totalRxLostCnt","color":"#9467bd"}]},{"title":"...","range":{"left":0.0,"right":632.799721,"top":2.05,"bottom":-0.05},"curves":[{"name":"/pandaStates/0/canState0/totalTxLostCnt","color":"#17becf"},{"name":"/pandaStates/0/canState1/totalTxLostCnt","color":"#bcbd22"},{"name":"/pandaStates/0/canState2/totalTxLostCnt","color":"#1f77b4"}]},{"title":"...","range":{"left":0.0,"right":632.799721,"top":0.1,"bottom":-0.1},"curves":[{"name":"/pandaStates/0/canState0/busOffCnt","color":"#17becf"},{"name":"/pandaStates/0/canState1/busOffCnt","color":"#1ac938"},{"name":"/pandaStates/0/canState2/busOffCnt","color":"#bcbd22"}]}]}]}}]} diff --git a/tools/jotpluggler/layouts/controls_mismatch_debug.json b/tools/jotpluggler/layouts/controls_mismatch_debug.json new file mode 100644 index 0000000000..16912cd684 --- /dev/null +++ b/tools/jotpluggler/layouts/controls_mismatch_debug.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.2,0.2,0.2,0.2,0.2],"children":[{"title":"...","range":{"left":0.018309,"right":59.674401,"top":1.025,"bottom":-0.025},"curves":[{"name":"/carControl/enabled","color":"#1f77b4"},{"name":"/pandaStates/0/controlsAllowed","color":"#d62728"}]},{"title":"...","range":{"left":0.018309,"right":59.674401,"top":27.087398,"bottom":-0.905168},"curves":[{"name":"/carState/cumLagMs","color":"#9467bd"}]},{"title":"...","range":{"left":0.018309,"right":59.674401,"top":1.025,"bottom":-0.025},"curves":[{"name":"/pandaStates/0/safetyRxInvalid","color":"#1f77b4"},{"name":"/pandaStates/0/safetyRxChecksInvalid","color":"#e801ce"}]},{"title":"...","range":{"left":0.018309,"right":59.674401,"top":158.85,"bottom":-2.85},"curves":[{"name":"/pandaStates/0/safetyTxBlocked","color":"#d62728"}]},{"title":"...","range":{"left":0.018309,"right":59.674401,"top":1.025,"bottom":-0.025},"curves":[{"name":"/carState/gasPressed","color":"#1ac938"},{"name":"/carState/brakePressed","color":"#ff7f0e"}]}]}}]} diff --git a/tools/jotpluggler/layouts/gps.json b/tools/jotpluggler/layouts/gps.json new file mode 100644 index 0000000000..fdabbfd381 --- /dev/null +++ b/tools/jotpluggler/layouts/gps.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.24977,0.250689,0.24977,0.24977],"children":[{"title":"...","range":{"left":0.0,"right":1678.753571,"top":1.025,"bottom":-0.025},"curves":[{"name":"/gpsLocationExternal/hasFix","color":"#1f77b4"}]},{"title":"...","range":{"left":0.0,"right":1678.753571,"top":17.425,"bottom":-0.425},"curves":[{"name":"/gpsLocationExternal/satelliteCount","color":"#d62728"}]},{"title":"...","range":{"left":0.0,"right":1678.753571,"top":3.0,"bottom":0.0},"curves":[{"name":"/gpsLocationExternal/horizontalAccuracy","color":"#1ac938"}],"y_limits":{"min":0.0,"max":3.0}},{"title":"...","range":{"left":0.0,"right":1678.753571,"top":766.374004,"bottom":-17.262},"curves":[{"name":"/gpsLocationExternal/horizontalAccuracy","color":"#1ac938"}]}]}}]} diff --git a/tools/jotpluggler/layouts/gps_vs_llk.json b/tools/jotpluggler/layouts/gps_vs_llk.json new file mode 100644 index 0000000000..878e0a57a8 --- /dev/null +++ b/tools/jotpluggler/layouts/gps_vs_llk.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.333805,0.33239,0.333805],"children":[{"title":"...","range":{"left":76.646983,"right":196.811937,"top":32.070386,"bottom":0.368228},"curves":[{"name":"haversine distance [m]","color":"#1f77b4","custom_python":{"linked_source":"/gpsLocationExternal/latitude","additional_sources":["/gpsLocationExternal/longitude","/liveLocationKalmanDEPRECATED/positionGeodetic/value/0","/liveLocationKalmanDEPRECATED/positionGeodetic/value/1"],"globals_code":"R = 6378.137 # Radius of earth in KM","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3):\n global R\n # Compute the Haversine distance between\n # two points defined by latitude and longitude.\n # Return the distance in meters\n lat1, lon1 = value, v1\n lat2, lon2 = v2, v3\n dLat = (lat2 - lat1) * np.pi / 180\n dLon = (lon2 - lon1) * np.pi / 180\n a = np.sin(dLat/2) * np.sin(dLat/2) +\n np.cos(lat1 * np.pi / 180) * np.cos(lat2 * np.pi / 180) *\n np.sin(dLon/2) * np.sin(dLon/2)\n c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1-a))\n d = R * c\n distance = d * 1000 # meters\n return distance\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])\nreturn __jotpluggler_result"}}]},{"title":"...","range":{"left":76.646983,"right":196.811937,"top":12.637299,"bottom":-0.259115},"curves":[{"name":"/carState/vEgo","color":"#17becf"},{"name":"/gpsLocationExternal/speed","color":"#bcbd22"}]},{"split":"horizontal","sizes":[0.500516,0.499484],"children":[{"title":"...","range":{"left":76.646983,"right":196.811937,"top":0.1,"bottom":-0.1},"curves":[{"name":"/liveLocationKalmanDEPRECATED/positionGeodetic/std/0","color":"#d62728"},{"name":"/liveLocationKalmanDEPRECATED/positionGeodetic/std/1","color":"#1ac938"}]},{"title":"...","range":{"left":76.646983,"right":196.811937,"top":7.160833,"bottom":-0.449385},"curves":[{"name":"/gpsLocationExternal/horizontalAccuracy","color":"#ff7f0e"},{"name":"/gpsLocationExternal/verticalAccuracy","color":"#f14cc1"},{"name":"/gpsLocationExternal/speedAccuracy","color":"#9467bd"}]}]}]}}]} diff --git a/tools/jotpluggler/layouts/locationd_debug.json b/tools/jotpluggler/layouts/locationd_debug.json new file mode 100644 index 0000000000..0541427bc1 --- /dev/null +++ b/tools/jotpluggler/layouts/locationd_debug.json @@ -0,0 +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"}]}]}}]} diff --git a/tools/jotpluggler/layouts/longitudinal.json b/tools/jotpluggler/layouts/longitudinal.json new file mode 100644 index 0000000000..27f43eb357 --- /dev/null +++ b/tools/jotpluggler/layouts/longitudinal.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.250401,0.249599,0.250401,0.249599],"children":[{"title":"...","range":{"left":104.907277,"right":126.285782,"top":1.391623,"bottom":-2.563614},"curves":[{"name":"/carState/aEgo","color":"#f14cc1"},{"name":"/longitudinalPlan/accels/0","color":"#9467bd"},{"name":"/carControl/actuators/accel","color":"#17becf"},{"name":"/carOutput/actuatorsOutput/accel","color":"#d62728"}]},{"title":"...","range":{"left":104.907277,"right":126.285782,"top":1.18496,"bottom":-1.811222},"curves":[{"name":"/controlsState/upAccelCmd","color":"#1f77b4"},{"name":"/controlsState/uiAccelCmd","color":"#d62728"},{"name":"/controlsState/ufAccelCmd","color":"#1ac938"}]},{"title":"...","range":{"left":104.907277,"right":126.285782,"top":15.862889,"bottom":-0.568809},"curves":[{"name":"/carState/vEgo","color":"#1ac938"},{"name":"/longitudinalPlan/speeds/0","color":"#ff7f0e"}]},{"title":"...","range":{"left":104.907277,"right":126.285782,"top":1.025,"bottom":-0.025},"curves":[{"name":"/carControl/longActive","color":"#1f77b4"},{"name":"/carState/gasPressed","color":"#d62728"}]}]}}]} diff --git a/tools/jotpluggler/layouts/max-torque-debug.json b/tools/jotpluggler/layouts/max-torque-debug.json new file mode 100644 index 0000000000..3a87fb3217 --- /dev/null +++ b/tools/jotpluggler/layouts/max-torque-debug.json @@ -0,0 +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}]}]}}]} diff --git a/tools/jotpluggler/layouts/new-layout.json b/tools/jotpluggler/layouts/new-layout.json new file mode 100644 index 0000000000..bffb62d7c7 --- /dev/null +++ b/tools/jotpluggler/layouts/new-layout.json @@ -0,0 +1 @@ +{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"curves": [], "title": "..."}}]} diff --git a/tools/jotpluggler/layouts/system_lag_debug.json b/tools/jotpluggler/layouts/system_lag_debug.json new file mode 100644 index 0000000000..281de440fa --- /dev/null +++ b/tools/jotpluggler/layouts/system_lag_debug.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.249729,0.250814,0.249729,0.249729],"children":[{"title":"...","range":{"left":0.0,"right":59.992103,"top":102.5,"bottom":-2.5},"curves":[{"name":"/deviceState/cpuUsagePercent/0","color":"#1f77b4"},{"name":"/deviceState/cpuUsagePercent/1","color":"#d62728"},{"name":"/deviceState/cpuUsagePercent/2","color":"#1ac938"},{"name":"/deviceState/cpuUsagePercent/3","color":"#ff7f0e"},{"name":"/deviceState/cpuUsagePercent/4","color":"#f14cc1"},{"name":"/deviceState/cpuUsagePercent/5","color":"#9467bd"},{"name":"/deviceState/cpuUsagePercent/6","color":"#17becf"},{"name":"/deviceState/cpuUsagePercent/7","color":"#bcbd22"}]},{"title":"...","range":{"left":0.0,"right":59.992103,"top":64.005001,"bottom":51.195},"curves":[{"name":"/deviceState/cpuTempC/0","color":"#d62728"},{"name":"/deviceState/cpuTempC/1","color":"#1ac938"},{"name":"/deviceState/cpuTempC/2","color":"#ff7f0e"},{"name":"/deviceState/cpuTempC/3","color":"#f14cc1"},{"name":"/deviceState/cpuTempC/4","color":"#9467bd"},{"name":"/deviceState/cpuTempC/5","color":"#17becf"},{"name":"/deviceState/cpuTempC/6","color":"#bcbd22"},{"name":"/deviceState/cpuTempC/7","color":"#1f77b4"},{"name":"/deviceState/gpuTempC/0","color":"#d62728"},{"name":"/deviceState/gpuTempC/1","color":"#1ac938"}]},{"title":"...","range":{"left":0.0,"right":59.992103,"top":37.371108,"bottom":-0.91149},"curves":[{"name":"/modelV2/frameDropPerc","color":"#f14cc1"}]},{"title":"...","range":{"left":0.0,"right":59.992103,"top":-3.593455,"bottom":-12.190956},"curves":[{"name":"/carState/cumLagMs","color":"#9467bd"}]}]}}]} diff --git a/tools/jotpluggler/layouts/thermal_debug.json b/tools/jotpluggler/layouts/thermal_debug.json new file mode 100644 index 0000000000..3a7ce454cf --- /dev/null +++ b/tools/jotpluggler/layouts/thermal_debug.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.166785,0.166785,0.166075,0.166785,0.166785,0.166785],"children":[{"title":"...","range":{"left":0.006955,"right":301.842654,"top":87.987497,"bottom":75.912497},"curves":[{"name":"/deviceState/cpuTempC/0","color":"#1f77b4"},{"name":"/deviceState/cpuTempC/1","color":"#d62728"},{"name":"/deviceState/cpuTempC/2","color":"#1ac938"},{"name":"/deviceState/cpuTempC/3","color":"#ff7f0e"},{"name":"/deviceState/cpuTempC/4","color":"#f14cc1"},{"name":"/deviceState/cpuTempC/5","color":"#9467bd"},{"name":"/deviceState/cpuTempC/6","color":"#17becf"},{"name":"/deviceState/cpuTempC/7","color":"#bcbd22"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":85.861052,"bottom":66.49695},"curves":[{"name":"/deviceState/pmicTempC/0","color":"#1f77b4"},{"name":"/deviceState/gpuTempC/0","color":"#d62728"},{"name":"/deviceState/gpuTempC/1","color":"#1ac938"},{"name":"/deviceState/memoryTempC","color":"#f14cc1"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":86.207876,"bottom":70.665918},"curves":[{"name":"/deviceState/maxTempC","color":"#1f77b4"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":1.025,"bottom":-0.025},"curves":[{"name":"/deviceState/thermalStatus","color":"#1f77b4"}]},{"split":"horizontal","sizes":[0.333124,0.333752,0.333124],"children":[{"title":"...","range":{"left":0.006955,"right":301.842654,"top":12.057358,"bottom":4.843517},"curves":[{"name":"/deviceState/powerDrawW","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":100.0,"bottom":0.0},"curves":[{"name":"/deviceState/fanSpeedPercentDesired","color":"#9467bd"},{"name":"/pandaStates/0/fanPower","color":"#1f77b4"}],"y_limits":{"min":0.0,"max":100.0}},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":5018.4,"bottom":255.6},"curves":[{"name":"/peripheralState/fanSpeedRpm","color":"#1f77b4"}]}]},{"split":"horizontal","sizes":[0.502513,0.497487],"children":[{"title":"...","range":{"left":0.006955,"right":301.842654,"top":100.025,"bottom":14.975},"curves":[{"name":"/deviceState/cpuUsagePercent/0","color":"#1f77b4"},{"name":"/deviceState/cpuUsagePercent/1","color":"#d62728"},{"name":"/deviceState/cpuUsagePercent/2","color":"#1ac938"},{"name":"/deviceState/cpuUsagePercent/3","color":"#ff7f0e"}]},{"title":"...","range":{"left":0.006955,"right":301.842654,"top":102.5,"bottom":-2.5},"curves":[{"name":"/deviceState/cpuUsagePercent/4","color":"#f14cc1"},{"name":"/deviceState/cpuUsagePercent/5","color":"#9467bd"},{"name":"/deviceState/cpuUsagePercent/6","color":"#17becf"},{"name":"/deviceState/cpuUsagePercent/7","color":"#bcbd22"}]}]}]}}]} diff --git a/tools/jotpluggler/layouts/torque-controller.json b/tools/jotpluggler/layouts/torque-controller.json new file mode 100644 index 0000000000..7e269e59e6 --- /dev/null +++ b/tools/jotpluggler/layouts/torque-controller.json @@ -0,0 +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"}]}]}}]} diff --git a/tools/jotpluggler/layouts/torque-controller.yaml b/tools/jotpluggler/layouts/torque-controller.yaml deleted file mode 100644 index 5503be9e64..0000000000 --- a/tools/jotpluggler/layouts/torque-controller.yaml +++ /dev/null @@ -1,128 +0,0 @@ -tabs: - '0': - name: Lateral Plan Conformance - panel_layout: - type: split - orientation: 1 - proportions: - - 0.3333333333333333 - - 0.3333333333333333 - - 0.3333333333333333 - children: - - type: panel - panel: - type: timeseries - title: desired vs actual - series_paths: - - controlsState/lateralControlState/torqueState/desiredLateralAccel - - controlsState/lateralControlState/torqueState/actualLateralAccel - - type: panel - panel: - type: timeseries - title: ff vs output - series_paths: - - controlsState/lateralControlState/torqueState/f - - carState/steeringPressed - - carControl/actuators/torque - - type: panel - panel: - type: timeseries - title: vehicle speed - series_paths: - - carState/vEgo - '1': - name: Actuator Performance - panel_layout: - type: split - orientation: 1 - proportions: - - 0.3333333333333333 - - 0.3333333333333333 - - 0.3333333333333333 - children: - - type: panel - panel: - type: timeseries - title: calc vs learned latAccelFactor - series_paths: - - liveTorqueParameters/latAccelFactorFiltered - - liveTorqueParameters/latAccelFactorRaw - - carParams/lateralTuning/torque/latAccelFactor - - type: panel - panel: - type: timeseries - title: learned latAccelOffset - series_paths: - - liveTorqueParameters/latAccelOffsetRaw - - liveTorqueParameters/latAccelOffsetFiltered - - type: panel - panel: - type: timeseries - title: calc vs learned friction - series_paths: - - liveTorqueParameters/frictionCoefficientFiltered - - liveTorqueParameters/frictionCoefficientRaw - - carParams/lateralTuning/torque/friction - '2': - name: Vehicle Dynamics - panel_layout: - type: split - orientation: 1 - proportions: - - 0.3333333333333333 - - 0.3333333333333333 - - 0.3333333333333333 - children: - - type: panel - panel: - type: timeseries - title: initial vs learned steerRatio - series_paths: - - carParams/steerRatio - - liveParameters/steerRatio - - type: panel - panel: - type: timeseries - title: initial vs learned tireStiffnessFactor - series_paths: - - carParams/tireStiffnessFactor - - liveParameters/stiffnessFactor - - type: panel - panel: - type: timeseries - title: live steering angle offsets - series_paths: - - liveParameters/angleOffsetDeg - - liveParameters/angleOffsetAverageDeg - '3': - name: Controller PIF Terms - panel_layout: - type: split - orientation: 1 - proportions: - - 0.3333333333333333 - - 0.3333333333333333 - - 0.3333333333333333 - children: - - type: panel - panel: - type: timeseries - title: ff vs output - series_paths: - - carControl/actuators/torque - - controlsState/lateralControlState/torqueState/f - - carState/steeringPressed - - type: panel - panel: - type: timeseries - title: PIF terms - series_paths: - - controlsState/lateralControlState/torqueState/f - - controlsState/lateralControlState/torqueState/p - - controlsState/lateralControlState/torqueState/i - - type: panel - panel: - type: timeseries - title: road roll angle - series_paths: - - liveParameters/roll diff --git a/tools/jotpluggler/layouts/tuning.json b/tools/jotpluggler/layouts/tuning.json new file mode 100644 index 0000000000..0a8e81743e --- /dev/null +++ b/tools/jotpluggler/layouts/tuning.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"Lateral","root":{"split":"vertical","sizes":[0.200458,0.199313,0.200458,0.199313,0.200458],"children":[{"title":"Velocity [m/s]","range":{"left":1.253354,"right":631.055584,"top":29.954036,"bottom":-0.841715},"curves":[{"name":"/carState/vEgo","color":"#0072b2"}]},{"title":"Curvature [1/m] True [blue] Vehicle Model [purple] Plan [green]","range":{"left":0.0,"right":631.055209,"top":0.006648,"bottom":-0.00315},"curves":[{"name":"engaged curvature plan","color":"#009e73","custom_python":{"linked_source":"/modelV2/action/desiredCurvature","additional_sources":["/carState/steeringPressed","/carControl/enabled"],"globals_code":"engage_delay = 5\nlast_bad_time = -engage_delay","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n global engage_delay, last_bad_time\n curvature = value\n pressed = v1\n enabled = v2\n if (pressed == 1 or enabled == 0):\n last_bad_time = time\n if (time > last_bad_time + engage_delay):\n return value\n else:\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])\nreturn __jotpluggler_result"}},{"name":"engaged curvature vehicle model","color":"#785ef0","custom_python":{"linked_source":"/controlsState/curvature","additional_sources":["/carState/steeringPressed","/carControl/enabled"],"globals_code":"engage_delay = 5\nlast_bad_time = -engage_delay","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2):\n global engage_delay, last_bad_time\n curvature = value\n pressed = v1\n enabled = v2\n if (pressed == 1 or enabled == 0):\n last_bad_time = time\n if (time > last_bad_time + engage_delay):\n return value\n else:\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])\nreturn __jotpluggler_result"}},{"name":"engaged curvature yaw","color":"#0072b2","custom_python":{"linked_source":"/carControl/angularVelocity/2","additional_sources":["/carState/steeringPressed","/carControl/enabled","/carState/vEgo"],"globals_code":"engage_delay = 5\nlast_bad_time = -engage_delay","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3):\n global engage_delay, last_bad_time\n curvature = value / v3\n pressed = v1\n enabled = v2\n if (pressed == 1 or enabled == 0):\n last_bad_time = time\n if (time > last_bad_time + engage_delay):\n return curvature\n else:\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])\nreturn __jotpluggler_result"}}]},{"title":"Roll [rad]","range":{"left":0.0,"right":631.038276,"top":0.166067,"bottom":-1.598381},"curves":[{"name":"/carControl/orientationNED/0","color":"#ffb000"}]},{"title":"Engaged [green] Steering Pressed [blue]","range":{"left":1.252984,"right":631.055584,"top":1.025,"bottom":-0.025},"curves":[{"name":"/selfdriveState/enabled","color":"#009e73"},{"name":"/carState/steeringPressed","color":"#0072b2"}]},{"title":"Steering Limited: Rate [orange] Saturated [magenta]","range":{"left":1.253354,"right":631.055584,"top":1.025,"bottom":-0.025},"curves":[{"name":"steering rate limited","color":"#ffb000","custom_python":{"linked_source":"/carControl/actuators/torque","additional_sources":["/carOutput/actuatorsOutput/torque","/carControl/actuators/steeringAngleDeg","/carOutput/actuatorsOutput/steeringAngleDeg"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3):\n return (np.abs(value - v1) > 0.001 or np.abs(v2 - v3) > 0.05) and 1 or 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])\nreturn __jotpluggler_result"}},{"name":"/controlsState/lateralControlState/pidState/saturated","color":"#dc267f"}]}]}},{"name":"Longitudinal","root":{"split":"vertical","sizes":[0.1875,0.1875,0.1875,0.1875,0.25],"children":[{"title":"Velocity [m/s] True [blue] Plan [green] Cruise [magenta]","range":{"left":0.0,"right":631.055584,"top":42.713492,"bottom":-1.041792},"curves":[{"name":"/carState/cruiseState/speed","color":"#dc267f"},{"name":"/longitudinalPlan/speeds/0","color":"#009e73"},{"name":"/carState/vEgo","color":"#0072b2"}]},{"title":"Acceleration [m/s^2] True [blue] Actuator [purple] Plan [green]","range":{"left":1.253354,"right":631.055759,"top":0.808303,"bottom":-1.213305},"curves":[{"name":"engaged_accel_plan","color":"#009e73","custom_python":{"linked_source":"/longitudinalPlan/accels/0","additional_sources":["/carState/brakePressed","/carState/gasPressed","/carControl/enabled"],"globals_code":"engage_delay = 5\nlast_bad_time = -engage_delay","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3):\n global engage_delay, last_bad_time\n accel = value\n brake = v1\n gas = v2\n enabled = v3\n if (brake != 0 or gas != 0 or enabled == 0):\n last_bad_time = time\n if (time > last_bad_time + engage_delay):\n return value\n else:\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])\nreturn __jotpluggler_result"}},{"name":"engaged_accel_actuator","color":"#785ef0","custom_python":{"linked_source":"/carControl/actuators/accel","additional_sources":["/carState/brakePressed","/carState/gasPressed","/carControl/enabled"],"globals_code":"engage_delay = 5\nlast_bad_time = -engage_delay","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3):\n global engage_delay, last_bad_time\n accel = value\n brake = v1\n gas = v2\n enabled = v3\n if (brake != 0 or gas != 0 or enabled == 0):\n last_bad_time = time\n if (time > last_bad_time + engage_delay):\n return value\n else:\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])\nreturn __jotpluggler_result"}},{"name":"engaged_accel_actual","color":"#0072b2","custom_python":{"linked_source":"/carState/aEgo","additional_sources":["/carState/brakePressed","/carState/gasPressed","/carControl/enabled"],"globals_code":"engage_delay = 5\nlast_bad_time = -engage_delay","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3):\n global engage_delay, last_bad_time\n accel = value\n brake = v1\n gas = v2\n enabled = v3\n if (brake != 0 or gas != 0 or enabled == 0):\n last_bad_time = time\n if (time > last_bad_time + engage_delay):\n return value\n else:\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])\nreturn __jotpluggler_result"}}]},{"title":"Pitch [rad]","range":{"left":0.0,"right":631.038276,"top":0.158854,"bottom":-0.594843},"curves":[{"name":"/carControl/orientationNED/1","color":"#ffb000"}]},{"title":"Engaged [green] Gas [orange] Brake [magenta]","range":{"left":1.253354,"right":631.055759,"top":1.025,"bottom":-0.025},"curves":[{"name":"/carControl/enabled","color":"#009e73"},{"name":"/carState/gasPressed","color":"#ffb000"},{"name":"/carState/brakePressed","color":"#dc267f"}]},{"title":"State [blue: off,pid,stop,start] Source [green: cruise,lead0,lead1,lead2,e2e]","range":{"left":1.25362,"right":631.055759,"top":5.125,"bottom":-0.125},"curves":[{"name":"/carControl/actuators/longControlState","color":"#0072b2"},{"name":"/longitudinalPlan/longitudinalPlanSource","color":"#009e73"}]}]}},{"name":"Lateral Debug","root":{"split":"vertical","sizes":[0.25,0.25,0.25,0.25],"children":[{"title":"Controller F [magenta] P [purple] I [blue]","range":{"left":0.0,"right":1.0,"top":1.0,"bottom":0.0},"curves":[{"name":"/controlsState/lateralControlState/pidState/f","color":"#f14cc1"},{"name":"/controlsState/lateralControlState/pidState/p","color":"#9467bd"},{"name":"/controlsState/lateralControlState/pidState/i","color":"#17becf"}]},{"title":"Driver Torque [blue] EPS Torque [green]","range":{"left":1.253354,"right":631.055584,"top":2690.99903,"bottom":-3450.198981},"curves":[{"name":"/carState/steeringTorqueEps","color":"#009e73"},{"name":"/carState/steeringTorque","color":"#0072b2"}]},{"title":"Engaged [green] Steering Pressed [blue]","range":{"left":1.253354,"right":631.055759,"top":1.025,"bottom":-0.025},"curves":[{"name":"/carControl/enabled","color":"#009e73"},{"name":"/carState/steeringPressed","color":"#0072b2"}]},{"title":"Steering Limited: Rate [orange] Saturated [magenta]","range":{"left":1.253354,"right":631.055584,"top":1.025,"bottom":-0.025},"curves":[{"name":"steering rate limited","color":"#ffb000","custom_python":{"linked_source":"/carControl/actuators/torque","additional_sources":["/carOutput/actuatorsOutput/torque","/carControl/actuators/steeringAngleDeg","/carOutput/actuatorsOutput/steeringAngleDeg"],"globals_code":"","function_code":"def __jotpluggler_eval_sample(time, value, v1, v2, v3):\n return (np.abs(value - v1) > 0.001 or np.abs(v2 - v3) > 0.05) and 1 or 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])\nreturn __jotpluggler_result"}},{"name":"/controlsState/lateralControlState/pidState/saturated","color":"#dc267f"}]}]}}]} diff --git a/tools/jotpluggler/layouts/ublox-debug.json b/tools/jotpluggler/layouts/ublox-debug.json new file mode 100644 index 0000000000..4509a192df --- /dev/null +++ b/tools/jotpluggler/layouts/ublox-debug.json @@ -0,0 +1 @@ +{"current_tab_index":0,"tabs":[{"name":"tab1","root":{"split":"vertical","sizes":[0.333333,0.333333,0.333333],"children":[{"title":"...","range":{"left":0.0,"right":134.825489,"top":4402341.574525,"bottom":-107369.555525},"curves":[{"name":"/gpsLocationExternal/horizontalAccuracy","color":"#1f77b4"}]},{"title":"...","range":{"left":0.0,"right":134.825489,"top":1.025,"bottom":-0.025},"curves":[{"name":"/gpsLocationExternal/flags","color":"#d62728"}]},{"title":"...","range":{"left":0.0,"right":134.825489,"top":6.15,"bottom":-0.15},"curves":[{"name":"/ubloxGnss/measurementReport/numMeas","color":"#1ac938"}]}]}}]} diff --git a/tools/jotpluggler/logs.cc b/tools/jotpluggler/logs.cc new file mode 100644 index 0000000000..4da1cbf501 --- /dev/null +++ b/tools/jotpluggler/logs.cc @@ -0,0 +1,419 @@ +#include "tools/jotpluggler/app.h" + +#include +#include + +namespace { + +struct LevelOption { + const char *label; + int value; +}; + +constexpr std::array LEVEL_OPTIONS = {{ + {"DEBUG", 10}, + {"INFO", 20}, + {"WARNING", 30}, + {"ERROR", 40}, + {"CRITICAL", 50}, +}}; +constexpr uint32_t ALL_LEVEL_MASK = (1u << LEVEL_OPTIONS.size()) - 1u; + +bool log_matches_search(const LogEntry &entry, std::string_view query) { + if (query.empty()) return true; + const std::string needle = lowercase_copy(query); + const auto contains = [&](std::string_view haystack) { + return lowercase_copy(haystack).find(needle) != std::string::npos; + }; + return contains(entry.message) || contains(entry.source) || contains(entry.func); +} + +std::vector collect_log_sources(const std::vector &logs) { + std::vector sources; + for (const LogEntry &entry : logs) { + if (entry.source.empty()) continue; + if (std::find(sources.begin(), sources.end(), entry.source) == sources.end()) { + sources.push_back(entry.source); + } + } + std::sort(sources.begin(), sources.end()); + return sources; +} + +std::vector filter_log_indices(const RouteData &route_data, const LogsUiState &logs_state) { + std::vector indices; + indices.reserve(route_data.logs.size()); + for (size_t i = 0; i < route_data.logs.size(); ++i) { + const LogEntry &entry = route_data.logs[i]; + int level_index = 0; + if (entry.level >= 50) { + level_index = 4; + } else if (entry.level >= 40) { + level_index = 3; + } else if (entry.level >= 30) { + level_index = 2; + } else if (entry.level >= 20) { + level_index = 1; + } + if ((logs_state.enabled_levels_mask & (1u << level_index)) == 0) { + continue; + } + if (!logs_state.all_sources) { + const auto it = std::find(logs_state.selected_sources.begin(), + logs_state.selected_sources.end(), + entry.source); + if (it == logs_state.selected_sources.end()) continue; + } + if (!log_matches_search(entry, logs_state.search)) continue; + indices.push_back(static_cast(i)); + } + return indices; +} + +int find_active_log_position(const RouteData &route_data, + const std::vector &filtered_indices, + double tracker_time) { + if (filtered_indices.empty()) return -1; + auto it = std::lower_bound(filtered_indices.begin(), filtered_indices.end(), tracker_time, + [&](int log_index, double tm) { + return route_data.logs[static_cast(log_index)].mono_time < tm; + }); + if (it == filtered_indices.begin()) return static_cast(std::distance(filtered_indices.begin(), it)); + if (it == filtered_indices.end()) return static_cast(filtered_indices.size()) - 1; + if (route_data.logs[static_cast(*it)].mono_time > tracker_time) { + --it; + } + return static_cast(std::distance(filtered_indices.begin(), it)); +} + +std::string format_route_time(double seconds) { + if (seconds < 0.0) { + seconds = 0.0; + } + const int minutes = static_cast(seconds / 60.0); + const double remaining = seconds - static_cast(minutes) * 60.0; + return util::string_format("%d:%06.3f", minutes, remaining); +} + +std::string format_boot_time(double seconds) { + return util::string_format("%.3f", seconds); +} + +std::string format_wall_time(double seconds) { + if (seconds <= 0.0) return "--"; + const time_t wall_seconds = static_cast(seconds); + std::tm wall_tm = {}; + localtime_r(&wall_seconds, &wall_tm); + const int millis = static_cast(std::llround((seconds - std::floor(seconds)) * 1000.0)); + return util::string_format("%02d:%02d:%02d.%03d", + wall_tm.tm_hour, wall_tm.tm_min, wall_tm.tm_sec, millis); +} + +std::string format_log_time(const LogEntry &entry, LogTimeMode mode) { + switch (mode) { + case LogTimeMode::Route: + return format_route_time(entry.mono_time); + case LogTimeMode::Boot: + return format_boot_time(entry.boot_time); + case LogTimeMode::WallClock: + return format_wall_time(entry.wall_time); + } + return format_route_time(entry.mono_time); +} + +const char *time_mode_label(LogTimeMode mode) { + switch (mode) { + case LogTimeMode::Route: return "Route"; + case LogTimeMode::Boot: return "Boot"; + case LogTimeMode::WallClock: return "Wall clock"; + } + return "Route"; +} + +std::string level_filter_label(uint32_t mask) { + if (mask == ALL_LEVEL_MASK) return "All levels"; + if (mask == 0b11110) return "INFO+"; + if (mask == 0b11100) return "WARNING+"; + if (mask == 0b11000) return "ERROR+"; + if (mask == 0b10000) return "CRITICAL"; + + int enabled_count = 0; + const char *last_label = "None"; + for (size_t i = 0; i < LEVEL_OPTIONS.size(); ++i) { + if ((mask & (1u << i)) == 0) { + continue; + } + ++enabled_count; + last_label = LEVEL_OPTIONS[i].label; + } + if (enabled_count == 0) return "None"; + if (enabled_count == 1) return last_label; + return "Custom"; +} + +std::string source_filter_label(const LogsUiState &logs_state, const std::vector &sources) { + if (logs_state.all_sources || logs_state.selected_sources.size() == sources.size()) { + return "All sources"; + } + if (logs_state.selected_sources.empty()) return "No sources"; + if (logs_state.selected_sources.size() == 1) return logs_state.selected_sources.front(); + return std::to_string(logs_state.selected_sources.size()) + " sources"; +} + +const char *level_label(const LogEntry &entry) { + if (entry.origin == LogOrigin::Alert) return "ALRT"; + if (entry.level >= 50) return "CRIT"; + if (entry.level >= 40) return "ERR"; + if (entry.level >= 30) return "WARN"; + if (entry.level >= 20) return "INFO"; + return "DBG"; +} + +ImVec4 level_text_color(const LogEntry &entry, bool active) { + if (active) return color_rgb(46, 54, 63); + if (entry.origin == LogOrigin::Alert) return color_rgb(50, 100, 200); + if (entry.level >= 50) return color_rgb(176, 26, 18); + if (entry.level >= 40) return color_rgb(200, 50, 40); + if (entry.level >= 30) return color_rgb(200, 130, 0); + if (entry.level >= 20) return color_rgb(80, 86, 94); + return color_rgb(126, 133, 141); +} + +ImU32 row_bg_color(const LogEntry &entry, bool active) { + if (active) return IM_COL32(80, 140, 210, 38); + return 0; +} + +void set_tracker_to_log(UiState *state, const LogEntry &entry) { + state->tracker_time = entry.mono_time; + state->has_tracker_time = true; + state->logs.last_auto_scroll_time = entry.mono_time; +} + +void draw_log_expansion_row(const LogEntry &entry) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(""); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(""); + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(entry.func.empty() ? "" : entry.func.c_str()); + ImGui::TableSetColumnIndex(3); + ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(96, 104, 113)); + ImGui::TextWrapped("%s", entry.message.c_str()); + if (!entry.func.empty()) { + ImGui::TextWrapped("func: %s", entry.func.c_str()); + } + if (!entry.context.empty()) { + ImGui::TextWrapped("ctx: %s", entry.context.c_str()); + } + ImGui::PopStyleColor(); +} + +void draw_log_row(const LogEntry &entry, + int log_index, + bool active, + UiState *state) { + ImGui::PushID(log_index); + const ImU32 bg = row_bg_color(entry, active); + ImGui::TableNextRow(); + if (bg != 0) { + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, bg); + } + + const std::string time_text = std::string(active ? "\xE2\x96\xB6 " : " ") + format_log_time(entry, state->logs.time_mode); + const auto clickable_text = [&](const char *id, const std::string &text, ImVec4 color = color_rgb(74, 80, 88)) { + ImGui::PushID(id); + ImGui::PushStyleColor(ImGuiCol_Text, color); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(0, 0, 0, 0)); + const bool clicked = ImGui::Selectable(text.c_str(), false, ImGuiSelectableFlags_AllowDoubleClick); + ImGui::PopStyleColor(4); + ImGui::PopID(); + return clicked; + }; + + bool clicked = false; + ImGui::TableSetColumnIndex(0); + app_push_mono_font(); + clicked = clickable_text("time", time_text); + app_pop_mono_font(); + + ImGui::TableSetColumnIndex(1); + clicked = clickable_text("level", level_label(entry), level_text_color(entry, active)) || clicked; + + ImGui::TableSetColumnIndex(2); + clicked = clickable_text("source", entry.source) || clicked; + + ImGui::TableSetColumnIndex(3); + clicked = clickable_text("message", entry.message) || clicked; + + if (clicked) { + set_tracker_to_log(state, entry); + state->logs.expanded_index = state->logs.expanded_index == log_index ? -1 : log_index; + } + ImGui::PopID(); +} + +} // namespace + +void draw_logs_tab(AppSession *session, UiState *state) { + LogsUiState &logs_state = state->logs; + const RouteData &route_data = session->route_data; + const RouteLoadSnapshot load = session->route_loader ? session->route_loader->snapshot() : RouteLoadSnapshot{}; + const bool loading_logs = load.active && route_data.logs.empty(); + const std::vector sources = collect_log_sources(route_data.logs); + + if (!logs_state.all_sources) { + logs_state.selected_sources.erase( + std::remove_if(logs_state.selected_sources.begin(), + logs_state.selected_sources.end(), + [&](const std::string &source) { + return std::find(sources.begin(), sources.end(), source) == sources.end(); + }), + logs_state.selected_sources.end()); + } + + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(6.0f, 3.0f)); + ImGui::SetNextItemWidth(110.0f); + const std::string levels_label = level_filter_label(logs_state.enabled_levels_mask); + if (ImGui::BeginCombo("##logs_level", levels_label.c_str())) { + bool all_levels = logs_state.enabled_levels_mask == ALL_LEVEL_MASK; + if (ImGui::Checkbox("All levels", &all_levels)) { + logs_state.enabled_levels_mask = all_levels ? ALL_LEVEL_MASK : 0u; + } + ImGui::Separator(); + for (size_t i = 0; i < LEVEL_OPTIONS.size(); ++i) { + bool enabled = (logs_state.enabled_levels_mask & (1u << i)) != 0; + if (ImGui::Checkbox(LEVEL_OPTIONS[i].label, &enabled)) { + if (enabled) { + logs_state.enabled_levels_mask |= (1u << i); + } else { + logs_state.enabled_levels_mask &= ~(1u << i); + } + } + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + + ImGui::SetNextItemWidth(150.0f); + input_text_with_hint_string("##logs_search", "Search...", &logs_state.search); + ImGui::SameLine(); + + const std::string sources_label = source_filter_label(logs_state, sources); + ImGui::SetNextItemWidth(180.0f); + if (ImGui::BeginCombo("##logs_source", sources_label.c_str())) { + bool all_sources = logs_state.all_sources; + if (ImGui::Checkbox("All sources", &all_sources)) { + logs_state.all_sources = all_sources; + if (logs_state.all_sources) { + logs_state.selected_sources.clear(); + } else { + logs_state.selected_sources = sources; + } + } + ImGui::Separator(); + for (const std::string &source : sources) { + bool enabled = logs_state.all_sources + || std::find(logs_state.selected_sources.begin(), logs_state.selected_sources.end(), source) != logs_state.selected_sources.end(); + if (ImGui::Checkbox(source.c_str(), &enabled)) { + if (logs_state.all_sources) { + logs_state.all_sources = false; + logs_state.selected_sources = sources; + } + auto it = std::find(logs_state.selected_sources.begin(), logs_state.selected_sources.end(), source); + if (enabled) { + if (it == logs_state.selected_sources.end()) { + logs_state.selected_sources.push_back(source); + } + } else if (it != logs_state.selected_sources.end()) { + logs_state.selected_sources.erase(it); + } + if (logs_state.selected_sources.size() == sources.size()) { + logs_state.all_sources = true; + logs_state.selected_sources.clear(); + } + } + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + + ImGui::SetNextItemWidth(110.0f); + if (ImGui::BeginCombo("##logs_time_mode", time_mode_label(logs_state.time_mode))) { + for (LogTimeMode mode : {LogTimeMode::Route, LogTimeMode::Boot, LogTimeMode::WallClock}) { + const bool selected = logs_state.time_mode == mode; + if (ImGui::Selectable(time_mode_label(mode), selected)) { + logs_state.time_mode = mode; + } + } + ImGui::EndCombo(); + } + + const std::vector filtered_indices = filter_log_indices(route_data, logs_state); + const bool have_tracker = state->has_tracker_time && !filtered_indices.empty(); + const int active_pos = have_tracker ? find_active_log_position(route_data, filtered_indices, state->tracker_time) : -1; + + ImGui::SameLine(); + ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowContentRegionMax().x - 110.0f)); + ImGui::Text("%zu / %zu", filtered_indices.size(), route_data.logs.size()); + ImGui::PopStyleVar(); + + if (route_data.logs.empty()) { + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(116, 124, 133)); + ImGui::TextWrapped("%s", loading_logs ? "Loading logs..." : "No text logs available for this route."); + ImGui::PopStyleColor(); + return; + } + + if (ImGui::BeginChild("##logs_table_child", ImVec2(0.0f, 0.0f), false)) { + if (have_tracker && std::abs(logs_state.last_auto_scroll_time - state->tracker_time) > 1.0e-6) { + const float row_height = ImGui::GetTextLineHeightWithSpacing() + 6.0f; + const float visible_h = std::max(1.0f, ImGui::GetWindowHeight()); + const float target = std::max(0.0f, static_cast(active_pos) * row_height - visible_h * 0.45f); + ImGui::SetScrollY(target); + logs_state.last_auto_scroll_time = state->tracker_time; + } + + if (ImGui::BeginTable("##logs_table", + 4, + ImGuiTableFlags_BordersInnerV | + ImGuiTableFlags_RowBg | + ImGuiTableFlags_Resizable | + ImGuiTableFlags_SizingStretchProp)) { + ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 72.0f); + ImGui::TableSetupColumn("Source", ImGuiTableColumnFlags_WidthFixed, 180.0f); + ImGui::TableSetupColumn("Message", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + + const bool use_clipper = logs_state.expanded_index < 0; + if (use_clipper) { + ImGuiListClipper clipper; + clipper.Begin(static_cast(filtered_indices.size())); + while (clipper.Step()) { + for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) { + const int log_index = filtered_indices[static_cast(i)]; + const LogEntry &entry = route_data.logs[static_cast(log_index)]; + draw_log_row(entry, log_index, i == active_pos, state); + } + } + } else { + for (int i = 0; i < static_cast(filtered_indices.size()); ++i) { + const int log_index = filtered_indices[static_cast(i)]; + const LogEntry &entry = route_data.logs[static_cast(log_index)]; + draw_log_row(entry, log_index, i == active_pos, state); + if (logs_state.expanded_index == log_index) { + draw_log_expansion_row(entry); + } + } + } + + ImGui::EndTable(); + } + } + ImGui::EndChild(); +} diff --git a/tools/jotpluggler/main.cc b/tools/jotpluggler/main.cc new file mode 100644 index 0000000000..22bc29664c --- /dev/null +++ b/tools/jotpluggler/main.cc @@ -0,0 +1,126 @@ +#include +#include + +#include "tools/jotpluggler/app.h" + +namespace { + +constexpr const char *DEMO_ROUTE = "5beb9b58bd12b691/0000010a--a51155e496"; + +void print_usage(const char *argv0) { + std::cerr + << "Usage: " << argv0 << " [--layout ] [options] [route]\n" + << "\n" + << "Options:\n" + << " --demo\n" + << " --data-dir \n" + << " --stream\n" + << " --address \n" + << " --buffer-seconds \n" + << " --width \n" + << " --height \n" + << " --output \n" + << " --show\n" + << " --sync-load\n" + << "\n" + << "Examples:\n" + << " " << argv0 << "\n" + << " " << argv0 << " --demo\n" + << " " << argv0 << " --layout longitudinal --demo\n" + << " " << argv0 << " --layout longitudinal --demo --output /tmp/longitudinal.png\n" + << " " << argv0 << " --stream --show\n" + << " " << argv0 << " --stream --address 192.168.60.52 --buffer-seconds 45 --show\n"; +} + +bool parse_int(const char *value, int *out) { + char *end = nullptr; + const long parsed = std::strtol(value, &end, 10); + if (end == nullptr || *end != '\0') return false; + *out = static_cast(parsed); + return true; +} + +bool parse_double(const char *value, double *out) { + char *end = nullptr; + const double parsed = std::strtod(value, &end); + if (end == nullptr || *end != '\0') return false; + *out = parsed; + return true; +} + +} // namespace + +int main(int argc, char *argv[]) { + Options options; + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + const auto require_value = [&](const char *flag) -> const char * { + if (i + 1 >= argc) { + std::cerr << "Missing value for " << flag << "\n"; + print_usage(argv[0]); + std::exit(2); + } + return argv[++i]; + }; + + if (arg == "--layout") { + options.layout = require_value("--layout"); + } else if (arg == "--demo") { + options.route_name = DEMO_ROUTE; + } else if (arg == "--data-dir") { + options.data_dir = require_value("--data-dir"); + } else if (arg == "--stream") { + options.stream = true; + } else if (arg == "--address") { + options.stream_address = require_value("--address"); + } else if (arg == "--buffer-seconds") { + if (!parse_double(require_value("--buffer-seconds"), &options.stream_buffer_seconds)) { + std::cerr << "Invalid buffer seconds\n"; + return 2; + } + } else if (arg == "--output") { + options.output_path = require_value("--output"); + } else if (arg == "--width") { + if (!parse_int(require_value("--width"), &options.width)) { + std::cerr << "Invalid width\n"; + return 2; + } + } else if (arg == "--height") { + if (!parse_int(require_value("--height"), &options.height)) { + std::cerr << "Invalid height\n"; + return 2; + } + } else if (arg == "--show") { + options.show = true; + } else if (arg == "--sync-load") { + options.sync_load = true; + } else if (arg == "--help" || arg == "-h") { + print_usage(argv[0]); + return 0; + } else if (!arg.empty() && arg[0] != '-' && options.route_name.empty()) { + options.route_name = arg; + } else { + std::cerr << "Unknown argument: " << arg << "\n"; + print_usage(argv[0]); + return 2; + } + } + + if (options.output_path.empty() && !options.show) { + options.show = true; + } + if (options.width <= 0 || options.height <= 0) { + std::cerr << "Width and height must be positive\n"; + return 2; + } + if (options.stream && !options.route_name.empty()) { + std::cerr << "Route/file mode and --stream are mutually exclusive\n"; + return 2; + } + if (options.stream_buffer_seconds <= 0.0) { + std::cerr << "Buffer seconds must be positive\n"; + return 2; + } + + return run(options); +} diff --git a/tools/jotpluggler/map.cc b/tools/jotpluggler/map.cc new file mode 100644 index 0000000000..8725908ea0 --- /dev/null +++ b/tools/jotpluggler/map.cc @@ -0,0 +1,1328 @@ +#include "tools/jotpluggler/app.h" +#include "tools/jotpluggler/common.h" +#include "tools/jotpluggler/map.h" + +#include + +extern "C" { +#include +} + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/util.h" +#include "third_party/json11/json11.hpp" + +namespace fs = std::filesystem; + +namespace { + +constexpr int MAP_MIN_ZOOM = 1; +constexpr int MAP_MAX_ZOOM = 18; +constexpr int MAP_SINGLE_POINT_MIN_ZOOM = 14; +constexpr float MAP_WHEEL_ZOOM_STEP = 0.25f; +constexpr double MAP_TRACE_PAD_FRAC = 0.45; +constexpr double MAP_TRACE_MIN_LAT_PAD = 0.01; +constexpr double MAP_BOUNDS_GRID = 0.005; +constexpr double MAP_CORRIDOR_LAT_PAD = 0.010; +constexpr double MAP_CORRIDOR_MIN_STEP_S = 1.5; +constexpr size_t MAP_CORRIDOR_MAX_BOXES = 36; +constexpr float MAP_INITIAL_FIT_FILL = 0.88f; +constexpr float MAP_MIN_ZOOM_FILL = 0.98f; +constexpr float MAP_EDGE_FADE_FRAC = 0.28f; +constexpr const char *MAP_QUERY_ENDPOINTS[] = { + "https://overpass-api.de/api/interpreter", + "https://overpass.private.coffee/api/interpreter", +}; +struct GeoPoint { + double lat = 0.0; + double lon = 0.0; +}; + +struct ProjectedPoint { + float x = 0.0f; + float y = 0.0f; +}; + +struct ProjectedBounds { + float min_x = 0.0f; + float min_y = 0.0f; + float max_x = 0.0f; + float max_y = 0.0f; + + bool valid() const { + return max_x >= min_x && max_y >= min_y; + } +}; + +enum class RoadClass : uint8_t { + Motorway, + Primary, + Secondary, + Local, +}; + +struct RoadFeature { + RoadClass road_class = RoadClass::Local; + ProjectedBounds bounds; + std::vector points; +}; + +struct WaterLineFeature { + ProjectedBounds bounds; + std::vector points; +}; + +struct WaterPolygonFeature { + ProjectedBounds bounds; + std::vector ring; +}; + +} // namespace + +struct RouteBasemap { + std::string key; + GeoBounds bounds; + ProjectedBounds projected_bounds; + std::vector roads; + std::vector water_lines; + std::vector water_polygons; +}; + +struct MapRequestSpec { + std::string key; + GeoBounds bounds; + std::string query; +}; + +namespace { + +double lon_to_world_x(double lon, double zoom) { + return (lon + 180.0) / 360.0 * 256.0 * std::exp2(zoom); +} + +double lat_to_world_y(double lat, double zoom) { + const double lat_rad = lat * M_PI / 180.0; + return (1.0 - std::log(std::tan(lat_rad) + 1.0 / std::cos(lat_rad)) / M_PI) / 2.0 * 256.0 * std::exp2(zoom); +} + +double world_x_to_lon(double x, double zoom) { + return x / std::exp2(zoom) / 256.0 * 360.0 - 180.0; +} + +double world_y_to_lat(double y, double zoom) { + const double n = M_PI - (2.0 * M_PI * (y / std::exp2(zoom))) / 256.0; + return 180.0 / M_PI * std::atan(std::sinh(n)); +} + +double map_trace_center_lat(const GpsTrace &trace) { + return (trace.min_lat + trace.max_lat) * 0.5; +} + +double map_trace_center_lon(const GpsTrace &trace) { + return (trace.min_lon + trace.max_lon) * 0.5; +} + +double clamp_lat(double lat) { + return std::clamp(lat, -85.0, 85.0); +} + +double clamp_lon(double lon) { + return std::clamp(lon, -179.999, 179.999); +} + +float project_lon0(double lon) { + return static_cast((lon + 180.0) / 360.0 * 256.0); +} + +float project_lat0(double lat) { + const double lat_rad = lat * M_PI / 180.0; + return static_cast((1.0 - std::log(std::tan(lat_rad) + 1.0 / std::cos(lat_rad)) / M_PI) / 2.0 * 256.0); +} + +double cos_lat_scale(double lat) { + return std::max(0.2, std::cos(lat * M_PI / 180.0)); +} + +double quantize_down(double value, double step) { + return std::floor(value / step) * step; +} + +double quantize_up(double value, double step) { + return std::ceil(value / step) * step; +} + +ProjectedBounds compute_projected_bounds(const std::vector &points) { + ProjectedBounds bounds; + if (points.empty()) { + return bounds; + } + bounds.min_x = bounds.max_x = points.front().x; + bounds.min_y = bounds.max_y = points.front().y; + for (const ProjectedPoint &point : points) { + bounds.min_x = std::min(bounds.min_x, point.x); + bounds.max_x = std::max(bounds.max_x, point.x); + bounds.min_y = std::min(bounds.min_y, point.y); + bounds.max_y = std::max(bounds.max_y, point.y); + } + return bounds; +} + +ProjectedBounds project_bounds0(const GeoBounds &bounds) { + if (!bounds.valid()) { + return {}; + } + return ProjectedBounds{ + .min_x = project_lon0(bounds.west), + .min_y = project_lat0(bounds.north), + .max_x = project_lon0(bounds.east), + .max_y = project_lat0(bounds.south), + }; +} + +bool feature_intersects_view(const ProjectedBounds &feature, const ProjectedBounds &view, float zoom_scale) { + const float min_x = feature.min_x * zoom_scale; + const float max_x = feature.max_x * zoom_scale; + const float min_y = feature.min_y * zoom_scale; + const float max_y = feature.max_y * zoom_scale; + return !(max_x < view.min_x || min_x > view.max_x + || max_y < view.min_y || min_y > view.max_y); +} + +GeoBounds requested_bounds_for_trace(const GpsTrace &trace) { + if (trace.points.empty()) { + return {}; + } + const double center_lat = map_trace_center_lat(trace); + const double lat_span = std::max(trace.max_lat - trace.min_lat, 0.002); + const double lon_span = std::max(trace.max_lon - trace.min_lon, 0.002 / cos_lat_scale(center_lat)); + const double lat_pad = std::max(lat_span * MAP_TRACE_PAD_FRAC, MAP_TRACE_MIN_LAT_PAD); + const double lon_pad = std::max(lon_span * MAP_TRACE_PAD_FRAC, MAP_TRACE_MIN_LAT_PAD / cos_lat_scale(center_lat)); + + GeoBounds bounds; + bounds.south = clamp_lat(quantize_down(trace.min_lat - lat_pad, MAP_BOUNDS_GRID)); + bounds.north = clamp_lat(quantize_up(trace.max_lat + lat_pad, MAP_BOUNDS_GRID)); + bounds.west = clamp_lon(quantize_down(trace.min_lon - lon_pad, MAP_BOUNDS_GRID)); + bounds.east = clamp_lon(quantize_up(trace.max_lon + lon_pad, MAP_BOUNDS_GRID)); + return bounds; +} + +GeoBounds merge_bounds(const GeoBounds &a, const GeoBounds &b) { + if (!a.valid()) return b; + if (!b.valid()) return a; + return GeoBounds{ + .south = std::min(a.south, b.south), + .west = std::min(a.west, b.west), + .north = std::max(a.north, b.north), + .east = std::max(a.east, b.east), + }; +} + +bool bounds_overlap_or_touch(const GeoBounds &a, const GeoBounds &b) { + return !(a.east < b.west || b.east < a.west || a.north < b.south || b.north < a.south); +} + +std::vector corridor_boxes_for_trace(const GpsTrace &trace) { + std::vector boxes; + if (trace.points.empty()) { + return boxes; + } + + const double center_lat = map_trace_center_lat(trace); + const double lon_pad = MAP_CORRIDOR_LAT_PAD / cos_lat_scale(center_lat); + const double total_time = trace.points.back().time - trace.points.front().time; + const double target_boxes = std::min(MAP_CORRIDOR_MAX_BOXES, std::max(8.0, total_time / MAP_CORRIDOR_MIN_STEP_S)); + const size_t stride = std::max(1, static_cast(std::ceil(trace.points.size() / target_boxes))); + + auto add_box = [&](double lat, double lon) { + GeoBounds box{ + .south = clamp_lat(quantize_down(lat - MAP_CORRIDOR_LAT_PAD, MAP_BOUNDS_GRID)), + .west = clamp_lon(quantize_down(lon - lon_pad, MAP_BOUNDS_GRID)), + .north = clamp_lat(quantize_up(lat + MAP_CORRIDOR_LAT_PAD, MAP_BOUNDS_GRID)), + .east = clamp_lon(quantize_up(lon + lon_pad, MAP_BOUNDS_GRID)), + }; + if (!box.valid()) { + return; + } + for (GeoBounds &existing : boxes) { + if (bounds_overlap_or_touch(existing, box)) { + existing = merge_bounds(existing, box); + return; + } + } + boxes.push_back(box); + }; + + add_box(trace.points.front().lat, trace.points.front().lon); + for (size_t i = stride; i < trace.points.size(); i += stride) { + add_box(trace.points[i].lat, trace.points[i].lon); + } + add_box(trace.points.back().lat, trace.points.back().lon); + + bool merged = true; + while (merged) { + merged = false; + for (size_t i = 0; i < boxes.size() && !merged; ++i) { + for (size_t j = i + 1; j < boxes.size(); ++j) { + if (bounds_overlap_or_touch(boxes[i], boxes[j])) { + boxes[i] = merge_bounds(boxes[i], boxes[j]); + boxes.erase(boxes.begin() + static_cast(j)); + merged = true; + break; + } + } + } + } + return boxes; +} + +ProjectedBounds view_bounds(double top_left_x, double top_left_y, float width, float height) { + return ProjectedBounds{ + .min_x = static_cast(top_left_x), + .min_y = static_cast(top_left_y), + .max_x = static_cast(top_left_x + width), + .max_y = static_cast(top_left_y + height), + }; +} + +int fit_map_zoom_for_bounds(const GeoBounds &bounds, float width, float height, float fill_fraction) { + if (!bounds.valid()) { + return MAP_MIN_ZOOM; + } + const double max_width = std::max(1.0f, width * fill_fraction); + const double max_height = std::max(1.0f, height * fill_fraction); + for (int z = MAP_MAX_ZOOM; z >= MAP_MIN_ZOOM; --z) { + const double pixel_width = std::abs(lon_to_world_x(bounds.east, z) - lon_to_world_x(bounds.west, z)); + const double pixel_height = std::abs(lat_to_world_y(bounds.south, z) - lat_to_world_y(bounds.north, z)); + if (pixel_width <= max_width && pixel_height <= max_height) { + return z; + } + } + return MAP_MIN_ZOOM; +} + +int fit_map_zoom_for_trace(const GpsTrace &trace, float width, float height) { + return fit_map_zoom_for_bounds(requested_bounds_for_trace(trace), width, height, MAP_INITIAL_FIT_FILL); +} + +int minimum_allowed_map_zoom(const GeoBounds &bounds, const GpsTrace &trace, ImVec2 size) { + if (trace.points.size() <= 1) { + return MAP_SINGLE_POINT_MIN_ZOOM; + } + const int fit_zoom = fit_map_zoom_for_bounds(bounds.valid() ? bounds : requested_bounds_for_trace(trace), + size.x, size.y, MAP_MIN_ZOOM_FILL); + return std::clamp(fit_zoom, MAP_MIN_ZOOM, MAP_MAX_ZOOM); +} + +std::optional interpolate_gps(const GpsTrace &trace, double time_value) { + if (trace.points.empty()) { + return std::nullopt; + } + if (time_value <= trace.points.front().time) { + return trace.points.front(); + } + if (time_value >= trace.points.back().time) { + return trace.points.back(); + } + auto upper = std::lower_bound(trace.points.begin(), trace.points.end(), time_value, + [](const GpsPoint &point, double target) { + return point.time < target; + }); + if (upper == trace.points.begin()) { + return trace.points.front(); + } + const GpsPoint &p1 = *upper; + const GpsPoint &p0 = *(upper - 1); + const double dt = p1.time - p0.time; + if (dt <= 1.0e-9) { + return p0; + } + const double alpha = (time_value - p0.time) / dt; + GpsPoint out; + out.time = time_value; + out.lat = p0.lat + (p1.lat - p0.lat) * alpha; + out.lon = p0.lon + (p1.lon - p0.lon) * alpha; + out.bearing = static_cast(p0.bearing + (p1.bearing - p0.bearing) * alpha); + out.type = alpha < 0.5 ? p0.type : p1.type; + return out; +} + +ImU32 map_timeline_color(TimelineEntry::Type type, float alpha = 1.0f) { + return timeline_entry_color(type, alpha, {140, 150, 165}); +} + +ImVec2 gps_to_screen(double lat, double lon, double zoom, double top_left_x, double top_left_y, const ImVec2 &rect_min) { + return ImVec2(rect_min.x + static_cast(lon_to_world_x(lon, zoom) - top_left_x), + rect_min.y + static_cast(lat_to_world_y(lat, zoom) - top_left_y)); +} + +bool point_in_rect_with_margin(const ImVec2 &point, const ImVec2 &rect_min, const ImVec2 &rect_max, + float margin_fraction) { + const float width = rect_max.x - rect_min.x; + const float height = rect_max.y - rect_min.y; + const float margin_x = width * margin_fraction; + const float margin_y = height * margin_fraction; + return point.x >= rect_min.x + margin_x && point.x <= rect_max.x - margin_x + && point.y >= rect_min.y + margin_y && point.y <= rect_max.y - margin_y; +} + +void draw_car_marker(ImDrawList *draw_list, ImVec2 center, float bearing_deg, ImU32 color, float size) { + const float rad = bearing_deg * static_cast(M_PI / 180.0); + const ImVec2 forward(std::sin(rad), -std::cos(rad)); + const ImVec2 perp(-forward.y, forward.x); + const ImVec2 tip(center.x + forward.x * size, center.y + forward.y * size); + const ImVec2 base(center.x - forward.x * size * 0.45f, center.y - forward.y * size * 0.45f); + const ImVec2 left(base.x + perp.x * size * 0.6f, base.y + perp.y * size * 0.6f); + const ImVec2 right(base.x - perp.x * size * 0.6f, base.y - perp.y * size * 0.6f); + draw_list->AddTriangleFilled(tip, left, right, color); + draw_list->AddTriangle(tip, left, right, IM_COL32(255, 255, 255, 210), 2.0f); +} + +bool is_convex_ring(const std::vector &points) { + if (points.size() < 4) { + return false; + } + float sign = 0.0f; + const size_t n = points.size(); + for (size_t i = 0; i < n; ++i) { + const ImVec2 &a = points[i]; + const ImVec2 &b = points[(i + 1) % n]; + const ImVec2 &c = points[(i + 2) % n]; + const float cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x); + if (std::abs(cross) < 1.0e-3f) { + continue; + } + if (sign == 0.0f) { + sign = cross; + } else if ((cross > 0.0f) != (sign > 0.0f)) { + return false; + } + } + return sign != 0.0f; +} + +uint64_t fnv1a64(std::string_view text) { + uint64_t value = 1469598103934665603ULL; + for (unsigned char c : text) { + value ^= static_cast(c); + value *= 1099511628211ULL; + } + return value; +} + +fs::path basemap_cache_root() { + const char *home = std::getenv("HOME"); + fs::path root = home != nullptr ? fs::path(home) / ".comma" : fs::temp_directory_path(); + root /= "jotpluggler_vector_map"; + fs::create_directories(root); + return root; +} + +std::string bounds_key(const GeoBounds &bounds) { + return util::string_format("v2_%.5f_%.5f_%.5f_%.5f", + bounds.south, bounds.west, bounds.north, bounds.east); +} + +fs::path basemap_cache_path(const std::string &key) { + const uint64_t hash = fnv1a64(key); + return basemap_cache_root() / util::string_format("%016llx.bin.zst", static_cast(hash)); +} + +uint64_t cache_directory_size_bytes() { + uint64_t total = 0; + const fs::path root = basemap_cache_root(); + if (!fs::exists(root)) { + return 0; + } + for (const fs::directory_entry &entry : fs::directory_iterator(root)) { + if (entry.is_regular_file()) { + total += static_cast(entry.file_size()); + } + } + return total; +} + +size_t cache_directory_file_count() { + size_t count = 0; + const fs::path root = basemap_cache_root(); + if (!fs::exists(root)) { + return 0; + } + for (const fs::directory_entry &entry : fs::directory_iterator(root)) { + if (entry.is_regular_file()) { + ++count; + } + } + return count; +} + +void clear_cache_directory() { + const fs::path root = basemap_cache_root(); + if (!fs::exists(root)) { + return; + } + for (const fs::directory_entry &entry : fs::directory_iterator(root)) { + if (entry.is_regular_file()) { + std::error_code ec; + fs::remove(entry.path(), ec); + } + } +} + +std::string percent_encode(std::string_view text) { + std::string out; + out.reserve(text.size() * 3); + for (unsigned char c : text) { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '-' || c == '_' || c == '.' || c == '~') { + out.push_back(static_cast(c)); + } else { + out += util::string_format("%%%02X", static_cast(c)); + } + } + return out; +} + +std::string bbox_string(const GeoBounds &bounds) { + return util::string_format("%.6f,%.6f,%.6f,%.6f", + bounds.south, bounds.west, bounds.north, bounds.east); +} + +MapRequestSpec build_request_for_trace(const GpsTrace &trace) { + const std::vector boxes = corridor_boxes_for_trace(trace); + GeoBounds union_bounds; + std::string query = "[out:json][timeout:25];("; + for (const GeoBounds &box : boxes) { + union_bounds = merge_bounds(union_bounds, box); + const std::string bbox = bbox_string(box); + query += "way[\"highway\"][\"area\"!=\"yes\"](" + bbox + ");"; + query += "way[\"natural\"=\"water\"](" + bbox + ");"; + query += "way[\"waterway\"=\"riverbank\"](" + bbox + ");"; + query += "way[\"waterway\"~\"river|stream|canal\"](" + bbox + ");"; + } + query += ");out tags geom;"; + + std::string key = bounds_key(union_bounds); + key += ":"; + key += std::to_string(boxes.size()); + for (const GeoBounds &box : boxes) { + key += ":"; + key += bbox_string(box); + } + return MapRequestSpec{ + .key = std::move(key), + .bounds = union_bounds, + .query = std::move(query), + }; +} + +bool fetch_overpass_json(std::string_view query, std::string *out) { + const std::string body = std::string("data=") + percent_encode(query); + for (const char *endpoint : MAP_QUERY_ENDPOINTS) { + const std::string command = "curl -fsSL --compressed --connect-timeout 8 --max-time 30 " + "-A 'jotpluggler-vector-map/1.0' " + "-H 'Content-Type: application/x-www-form-urlencoded; charset=UTF-8' " + "--data-raw " + shell_quote(body) + " " + + shell_quote(endpoint); + const std::string response = util::check_output(command); + if (!response.empty() && response.front() == '{') { + *out = response; + return true; + } + } + return false; +} + +std::string load_overpass_json(std::string_view query) { + std::string response; + if (!fetch_overpass_json(query, &response)) { + return {}; + } + return response; +} + +template +void append_pod(std::string *out, const T &value) { + const size_t start = out->size(); + out->resize(start + sizeof(T)); + std::memcpy(out->data() + start, &value, sizeof(T)); +} + +template +bool read_pod(std::string_view data, size_t *offset, T *value) { + if (*offset + sizeof(T) > data.size()) { + return false; + } + std::memcpy(value, data.data() + *offset, sizeof(T)); + *offset += sizeof(T); + return true; +} + +void append_points(std::string *out, const std::vector &points) { + const uint32_t count = static_cast(points.size()); + append_pod(out, count); + for (const ProjectedPoint &point : points) { + append_pod(out, point.x); + append_pod(out, point.y); + } +} + +bool read_points(std::string_view data, size_t *offset, std::vector *points) { + uint32_t count = 0; + if (!read_pod(data, offset, &count)) { + return false; + } + points->clear(); + points->reserve(count); + for (uint32_t i = 0; i < count; ++i) { + ProjectedPoint point; + if (!read_pod(data, offset, &point.x) || !read_pod(data, offset, &point.y)) { + return false; + } + points->push_back(point); + } + return true; +} + +std::string serialize_basemap_payload(const RouteBasemap &basemap) { + std::string raw; + raw.reserve(1024 + basemap.roads.size() * 48); + raw.append("JBM2", 4); + append_pod(&raw, basemap.bounds.south); + append_pod(&raw, basemap.bounds.west); + append_pod(&raw, basemap.bounds.north); + append_pod(&raw, basemap.bounds.east); + + const uint32_t road_count = static_cast(basemap.roads.size()); + const uint32_t water_line_count = static_cast(basemap.water_lines.size()); + const uint32_t water_polygon_count = static_cast(basemap.water_polygons.size()); + append_pod(&raw, road_count); + append_pod(&raw, water_line_count); + append_pod(&raw, water_polygon_count); + + for (const RoadFeature &road : basemap.roads) { + const uint8_t kind = static_cast(road.road_class); + append_pod(&raw, kind); + append_points(&raw, road.points); + } + for (const WaterLineFeature &water : basemap.water_lines) { + append_points(&raw, water.points); + } + for (const WaterPolygonFeature &water : basemap.water_polygons) { + append_points(&raw, water.ring); + } + return raw; +} + +std::optional deserialize_basemap_payload(std::string_view raw, const std::string &key) { + if (!util::starts_with(std::string(raw), "JBM2")) { + return std::nullopt; + } + size_t offset = 4; + RouteBasemap basemap; + basemap.key = key; + if (!read_pod(raw, &offset, &basemap.bounds.south) + || !read_pod(raw, &offset, &basemap.bounds.west) + || !read_pod(raw, &offset, &basemap.bounds.north) + || !read_pod(raw, &offset, &basemap.bounds.east)) { + return std::nullopt; + } + basemap.projected_bounds = project_bounds0(basemap.bounds); + + uint32_t road_count = 0; + uint32_t water_line_count = 0; + uint32_t water_polygon_count = 0; + if (!read_pod(raw, &offset, &road_count) + || !read_pod(raw, &offset, &water_line_count) + || !read_pod(raw, &offset, &water_polygon_count)) { + return std::nullopt; + } + + basemap.roads.reserve(road_count); + for (uint32_t i = 0; i < road_count; ++i) { + uint8_t kind = 0; + std::vector points; + if (!read_pod(raw, &offset, &kind) || !read_points(raw, &offset, &points)) { + return std::nullopt; + } + basemap.roads.push_back(RoadFeature{ + .road_class = static_cast(kind), + .bounds = compute_projected_bounds(points), + .points = std::move(points), + }); + } + + basemap.water_lines.reserve(water_line_count); + for (uint32_t i = 0; i < water_line_count; ++i) { + std::vector points; + if (!read_points(raw, &offset, &points)) { + return std::nullopt; + } + basemap.water_lines.push_back(WaterLineFeature{ + .bounds = compute_projected_bounds(points), + .points = std::move(points), + }); + } + + basemap.water_polygons.reserve(water_polygon_count); + for (uint32_t i = 0; i < water_polygon_count; ++i) { + std::vector ring; + if (!read_points(raw, &offset, &ring)) { + return std::nullopt; + } + basemap.water_polygons.push_back(WaterPolygonFeature{ + .bounds = compute_projected_bounds(ring), + .ring = std::move(ring), + }); + } + return basemap; +} + +bool save_compressed_basemap(const fs::path &path, const RouteBasemap &basemap) { + const std::string raw = serialize_basemap_payload(basemap); + const size_t bound = ZSTD_compressBound(raw.size()); + std::string compressed(bound, '\0'); + const size_t size = ZSTD_compress(compressed.data(), compressed.size(), raw.data(), raw.size(), 5); + if (ZSTD_isError(size)) { + return false; + } + compressed.resize(size); + ensure_parent_dir(path); + const std::string path_string = path.string(); + return util::write_file(path_string.c_str(), compressed.data(), compressed.size(), O_WRONLY | O_CREAT | O_TRUNC) == 0; +} + +std::optional load_compressed_basemap(const fs::path &path, const std::string &key) { + const std::string compressed = util::read_file(path.string()); + if (compressed.empty()) { + return std::nullopt; + } + const unsigned long long raw_size = ZSTD_getFrameContentSize(compressed.data(), compressed.size()); + if (raw_size == ZSTD_CONTENTSIZE_ERROR || raw_size == ZSTD_CONTENTSIZE_UNKNOWN || raw_size > (1ULL << 31)) { + return std::nullopt; + } + std::string raw(static_cast(raw_size), '\0'); + const size_t actual = ZSTD_decompress(raw.data(), raw.size(), compressed.data(), compressed.size()); + if (ZSTD_isError(actual)) { + return std::nullopt; + } + raw.resize(actual); + return deserialize_basemap_payload(raw, key); +} + +std::vector geometry_points(const json11::Json &geometry_json) { + std::vector points; + const auto items = geometry_json.array_items(); + points.reserve(items.size()); + for (const json11::Json &point : items) { + if (!point["lat"].is_number() || !point["lon"].is_number()) { + continue; + } + points.push_back(ProjectedPoint{ + .x = project_lon0(point["lon"].number_value()), + .y = project_lat0(point["lat"].number_value()), + }); + } + return points; +} + +std::optional classify_road(std::string_view highway) { + if (highway == "motorway" || highway == "motorway_link" || highway == "trunk" || highway == "trunk_link") { + return RoadClass::Motorway; + } + if (highway == "primary" || highway == "primary_link") { + return RoadClass::Primary; + } + if (highway == "secondary" || highway == "secondary_link" || highway == "tertiary" || highway == "tertiary_link") { + return RoadClass::Secondary; + } + if (highway == "residential" || highway == "unclassified" || highway == "living_street" || highway == "road") { + return RoadClass::Local; + } + return std::nullopt; +} + +std::optional parse_basemap_json(const std::string &raw, const GeoBounds &bounds, const std::string &key) { + std::string parse_error; + const json11::Json root = json11::Json::parse(raw, parse_error); + if (!parse_error.empty() || !root.is_object()) { + return std::nullopt; + } + + RouteBasemap basemap; + basemap.key = key; + basemap.bounds = bounds; + basemap.projected_bounds = project_bounds0(bounds); + + for (const json11::Json &element : root["elements"].array_items()) { + if (element["type"].string_value() != "way") { + continue; + } + const json11::Json &tags = element["tags"]; + const std::vector points = geometry_points(element["geometry"]); + if (points.size() < 2) { + continue; + } + + const std::string highway = tags["highway"].string_value(); + if (!highway.empty()) { + const std::optional road_class = classify_road(highway); + if (!road_class.has_value()) { + continue; + } + basemap.roads.push_back(RoadFeature{ + .road_class = *road_class, + .bounds = compute_projected_bounds(points), + .points = points, + }); + continue; + } + + const std::string natural = tags["natural"].string_value(); + const std::string waterway = tags["waterway"].string_value(); + const bool closed = points.size() >= 4 + && std::abs(points.front().x - points.back().x) < 1.0e-6f + && std::abs(points.front().y - points.back().y) < 1.0e-6f; + if ((natural == "water" || waterway == "riverbank") && closed) { + basemap.water_polygons.push_back(WaterPolygonFeature{ + .bounds = compute_projected_bounds(points), + .ring = points, + }); + continue; + } + if (waterway == "river" || waterway == "stream" || waterway == "canal") { + basemap.water_lines.push_back(WaterLineFeature{ + .bounds = compute_projected_bounds(points), + .points = points, + }); + } + } + + return basemap; +} + +struct RoadPaint { + ImU32 casing = 0; + ImU32 fill = 0; + float casing_width = 1.0f; + float fill_width = 1.0f; +}; + +constexpr ImU32 MAP_BG_COLOR = IM_COL32(244, 243, 238, 255); +constexpr ImU32 MAP_WATER_FILL = IM_COL32(193, 216, 235, 185); +constexpr ImU32 MAP_WATER_OUTLINE = IM_COL32(143, 173, 201, 220); +constexpr ImU32 MAP_WATER_LINE = IM_COL32(156, 186, 214, 205); +constexpr ImU32 MAP_ROUTE_HALO = IM_COL32(31, 40, 50, 92); + +RoadPaint road_paint(RoadClass road_class, float zoom) { + const float scale = std::clamp(0.88f + 0.12f * (zoom - 12.0f), 0.76f, 1.95f); + switch (road_class) { + case RoadClass::Motorway: + return { + .casing = IM_COL32(163, 157, 149, 235), + .fill = IM_COL32(245, 235, 215, 255), + .casing_width = 5.6f * scale, + .fill_width = 3.7f * scale, + }; + case RoadClass::Primary: + return { + .casing = IM_COL32(171, 171, 168, 220), + .fill = IM_COL32(249, 246, 237, 248), + .casing_width = 4.6f * scale, + .fill_width = 2.95f * scale, + }; + case RoadClass::Secondary: + return { + .casing = IM_COL32(183, 186, 189, 210), + .fill = IM_COL32(252, 251, 247, 240), + .casing_width = 3.5f * scale, + .fill_width = 2.15f * scale, + }; + case RoadClass::Local: + default: + return { + .casing = IM_COL32(200, 202, 205, 195), + .fill = IM_COL32(255, 255, 254, 230), + .casing_width = 2.5f * scale, + .fill_width = 1.5f * scale, + }; + } +} + +void clamp_map_center(TabUiState::MapPaneState *map_state, const GeoBounds &bounds, const ImVec2 &size) { + if (!bounds.valid() || size.x <= 1.0f || size.y <= 1.0f) { + return; + } + const double zoom = map_state->zoom; + const double min_x = lon_to_world_x(bounds.west, zoom); + const double max_x = lon_to_world_x(bounds.east, zoom); + const double min_y = lat_to_world_y(bounds.north, zoom); + const double max_y = lat_to_world_y(bounds.south, zoom); + const double half_w = size.x * 0.5; + const double half_h = size.y * 0.5; + double center_x = lon_to_world_x(map_state->center_lon, zoom); + double center_y = lat_to_world_y(map_state->center_lat, zoom); + if (max_x - min_x <= size.x) { + center_x = (min_x + max_x) * 0.5; + } else { + center_x = std::clamp(center_x, min_x + half_w, max_x - half_w); + } + if (max_y - min_y <= size.y) { + center_y = (min_y + max_y) * 0.5; + } else { + center_y = std::clamp(center_y, min_y + half_h, max_y - half_h); + } + map_state->center_lon = world_x_to_lon(center_x, zoom); + map_state->center_lat = world_y_to_lat(center_y, zoom); +} + +void initialize_map_pane_state(TabUiState::MapPaneState *map_state, + const GpsTrace &trace, + const GeoBounds &bounds, + ImVec2 size, + SessionDataMode mode, + std::optional cursor_point) { + if (trace.points.empty()) { + return; + } + map_state->initialized = true; + map_state->follow = mode == SessionDataMode::Stream; + const int min_zoom = minimum_allowed_map_zoom(bounds, trace, size); + if (mode == SessionDataMode::Stream && cursor_point.has_value()) { + map_state->zoom = std::max(16.0f, static_cast(min_zoom)); + map_state->center_lat = cursor_point->lat; + map_state->center_lon = cursor_point->lon; + } else { + map_state->zoom = std::max(static_cast(fit_map_zoom_for_trace(trace, size.x, size.y)), + static_cast(min_zoom)); + map_state->center_lat = map_trace_center_lat(trace); + map_state->center_lon = map_trace_center_lon(trace); + } + clamp_map_center(map_state, bounds, size); +} + +void draw_feature_polyline(ImDrawList *draw_list, + const std::vector &points, + float zoom_scale, + double top_left_x, + double top_left_y, + const ImVec2 &rect_min, + ImU32 color, + float thickness, + bool closed = false) { + if (points.size() < 2) { + return; + } + std::vector screen; + screen.reserve(points.size()); + for (const ProjectedPoint &point : points) { + screen.push_back(ImVec2(rect_min.x + point.x * zoom_scale - static_cast(top_left_x), + rect_min.y + point.y * zoom_scale - static_cast(top_left_y))); + } + draw_list->AddPolyline(screen.data(), static_cast(screen.size()), color, + closed ? ImDrawFlags_Closed : ImDrawFlags_None, thickness); +} + +void draw_water_polygon(ImDrawList *draw_list, + const WaterPolygonFeature &feature, + float zoom_scale, + double top_left_x, + double top_left_y, + const ImVec2 &rect_min) { + if (feature.ring.size() < 3) { + return; + } + std::vector screen; + screen.reserve(feature.ring.size()); + for (const ProjectedPoint &point : feature.ring) { + screen.push_back(ImVec2(rect_min.x + point.x * zoom_scale - static_cast(top_left_x), + rect_min.y + point.y * zoom_scale - static_cast(top_left_y))); + } + if (screen.size() >= 3 && is_convex_ring(screen)) { + draw_list->AddConvexPolyFilled(screen.data(), static_cast(screen.size()), MAP_WATER_FILL); + } + draw_list->AddPolyline(screen.data(), static_cast(screen.size()), MAP_WATER_OUTLINE, + ImDrawFlags_Closed, 1.8f); +} + +void draw_edge_fade(ImDrawList *draw_list, + const GeoBounds &bounds, + double zoom, + double top_left_x, + double top_left_y, + const ImVec2 &rect_min, + const ImVec2 &rect_max) { + if (!bounds.valid()) { + return; + } + + const float west_x = rect_min.x + static_cast(lon_to_world_x(bounds.west, zoom) - top_left_x); + const float east_x = rect_min.x + static_cast(lon_to_world_x(bounds.east, zoom) - top_left_x); + const float north_y = rect_min.y + static_cast(lat_to_world_y(bounds.north, zoom) - top_left_y); + const float south_y = rect_min.y + static_cast(lat_to_world_y(bounds.south, zoom) - top_left_y); + + const float fade_x = std::max(28.0f, (rect_max.x - rect_min.x) * MAP_EDGE_FADE_FRAC); + const float fade_y = std::max(28.0f, (rect_max.y - rect_min.y) * MAP_EDGE_FADE_FRAC); + const ImU32 solid = MAP_BG_COLOR; + const ImU32 clear = IM_COL32(244, 243, 238, 6); + + if (west_x > rect_min.x) { + const float x0 = rect_min.x; + const float x1 = std::min(rect_max.x, west_x); + const float xfade = std::max(x0, x1 - fade_x); + draw_list->AddRectFilledMultiColor(ImVec2(x0, rect_min.y), ImVec2(xfade, rect_max.y), solid, solid, solid, solid); + draw_list->AddRectFilledMultiColor(ImVec2(xfade, rect_min.y), ImVec2(x1, rect_max.y), solid, clear, clear, solid); + } + if (east_x < rect_max.x) { + const float x0 = std::max(rect_min.x, east_x); + const float x1 = rect_max.x; + const float xfade = std::min(x1, x0 + fade_x); + draw_list->AddRectFilledMultiColor(ImVec2(x0, rect_min.y), ImVec2(xfade, rect_max.y), clear, solid, solid, clear); + draw_list->AddRectFilledMultiColor(ImVec2(xfade, rect_min.y), ImVec2(x1, rect_max.y), solid, solid, solid, solid); + } + if (north_y > rect_min.y) { + const float y0 = rect_min.y; + const float y1 = std::min(rect_max.y, north_y); + const float yfade = std::max(y0, y1 - fade_y); + draw_list->AddRectFilledMultiColor(ImVec2(rect_min.x, y0), ImVec2(rect_max.x, yfade), solid, solid, solid, solid); + draw_list->AddRectFilledMultiColor(ImVec2(rect_min.x, yfade), ImVec2(rect_max.x, y1), solid, solid, clear, clear); + } + if (south_y < rect_max.y) { + const float y0 = std::max(rect_min.y, south_y); + const float y1 = rect_max.y; + const float yfade = std::min(y1, y0 + fade_y); + draw_list->AddRectFilledMultiColor(ImVec2(rect_min.x, y0), ImVec2(rect_max.x, yfade), clear, clear, solid, solid); + draw_list->AddRectFilledMultiColor(ImVec2(rect_min.x, yfade), ImVec2(rect_max.x, y1), solid, solid, solid, solid); + } +} + +} // namespace + +MapDataManager::MapDataManager() : worker_([this]() { run(); }) {} + +MapDataManager::~MapDataManager() { + { + std::lock_guard lock(mutex_); + stopping_ = true; + } + cv_.notify_all(); + if (worker_.joinable()) { + worker_.join(); + } +} + +void MapDataManager::pump() { + std::unique_ptr ready; + { + std::lock_guard lock(mutex_); + ready = std::move(completed_); + } + if (ready) { + current_ = std::move(ready); + } +} + +void MapDataManager::ensureTrace(const GpsTrace &trace) { + if (trace.points.empty()) { + return; + } + const MapRequestSpec wanted = build_request_for_trace(trace); + if (!wanted.bounds.valid()) { + return; + } + + std::lock_guard lock(mutex_); + if ((current_ && current_->key == wanted.key) || (pending_ && pending_->key == wanted.key)) { + return; + } + + if (const auto cached = load_compressed_basemap(basemap_cache_path(wanted.key), wanted.key)) { + current_ = std::make_unique(std::move(*cached)); + completed_.reset(); + pending_.reset(); + active_.reset(); + return; + } + + pending_ = std::make_unique(Request{ + .key = wanted.key, + .bounds = wanted.bounds, + .query = wanted.query, + }); + cv_.notify_one(); +} + +bool MapDataManager::loading() const { + std::lock_guard lock(mutex_); + return active_ || pending_; +} + +const RouteBasemap *MapDataManager::current() const { + return current_.get(); +} + +void MapDataManager::clearCache() { + std::lock_guard lock(mutex_); + clear_cache_directory(); +} + +MapCacheStats MapDataManager::cacheStats() const { + return MapCacheStats{ + .bytes = cache_directory_size_bytes(), + .files = cache_directory_file_count(), + }; +} + +void MapDataManager::run() { + while (true) { + Request request; + { + std::unique_lock lock(mutex_); + cv_.wait(lock, [&]() { return stopping_ || pending_ != nullptr; }); + if (stopping_) { + return; + } + request = *pending_; + active_ = std::move(pending_); + } + + std::unique_ptr parsed; + const std::string raw = load_overpass_json(request.query); + if (!raw.empty()) { + if (auto basemap = parse_basemap_json(raw, request.bounds, request.key)) { + save_compressed_basemap(basemap_cache_path(request.key), *basemap); + parsed = std::make_unique(std::move(*basemap)); + } + } + + { + std::lock_guard lock(mutex_); + if (active_ && active_->key == request.key) { + completed_ = std::move(parsed); + active_.reset(); + } + } + } +} + +void draw_map_pane(AppSession *session, UiState *state, Pane *, int pane_index) { + TabUiState *tab_state = app_active_tab_state(state); + if (tab_state == nullptr || pane_index < 0 || pane_index >= static_cast(tab_state->map_panes.size())) { + ImGui::TextUnformatted("Map unavailable"); + return; + } + if (!session->map_data) { + ImGui::TextUnformatted("Map unavailable"); + return; + } + + session->map_data->ensureTrace(session->route_data.gps_trace); + session->map_data->pump(); + + TabUiState::MapPaneState &map_state = tab_state->map_panes[static_cast(pane_index)]; + const GpsTrace &trace = session->route_data.gps_trace; + const RouteBasemap *basemap = session->map_data->current(); + const GeoBounds map_bounds = basemap != nullptr ? basemap->bounds : requested_bounds_for_trace(trace); + + const ImVec2 rect_min = ImGui::GetCursorScreenPos(); + const ImVec2 size = ImGui::GetContentRegionAvail(); + const ImVec2 input_size(std::max(1.0f, size.x - 22.0f), std::max(1.0f, size.y)); + ImGui::SetNextItemAllowOverlap(); + ImGui::InvisibleButton("##map_canvas", input_size); + const ImVec2 rect_max(rect_min.x + size.x, rect_min.y + size.y); + const float rect_width = rect_max.x - rect_min.x; + const float rect_height = rect_max.y - rect_min.y; + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + + draw_list->PushClipRect(rect_min, rect_max, true); + draw_list->AddRectFilled(rect_min, rect_max, MAP_BG_COLOR); + + if (trace.points.empty()) { + const char *label = session->async_route_loading ? "Loading map..." : "No GPS trace"; + const ImVec2 text = ImGui::CalcTextSize(label); + draw_list->AddText(ImVec2(rect_min.x + (rect_width - text.x) * 0.5f, + rect_min.y + (rect_height - text.y) * 0.5f), + IM_COL32(110, 118, 128, 255), label); + draw_list->PopClipRect(); + return; + } + + const std::optional cursor_point = state->has_tracker_time + ? interpolate_gps(trace, state->tracker_time) + : std::optional{}; + if (!map_state.initialized) { + initialize_map_pane_state(&map_state, trace, map_bounds, size, session->data_mode, cursor_point); + } + + const int min_zoom = minimum_allowed_map_zoom(map_bounds, trace, size); + if (map_state.follow && cursor_point.has_value()) { + const float follow_zoom = std::clamp(map_state.zoom, static_cast(min_zoom), static_cast(MAP_MAX_ZOOM)); + const double center_x = lon_to_world_x(map_state.center_lon, follow_zoom); + const double center_y = lat_to_world_y(map_state.center_lat, follow_zoom); + const double top_left_x = center_x - rect_width * 0.5; + const double top_left_y = center_y - rect_height * 0.5; + const ImVec2 car_screen = gps_to_screen(cursor_point->lat, cursor_point->lon, follow_zoom, top_left_x, top_left_y, rect_min); + if (!point_in_rect_with_margin(car_screen, rect_min, rect_max, 0.22f)) { + map_state.center_lat = cursor_point->lat; + map_state.center_lon = cursor_point->lon; + } + } + + map_state.zoom = std::clamp(map_state.zoom, static_cast(min_zoom), static_cast(MAP_MAX_ZOOM)); + clamp_map_center(&map_state, map_bounds, size); + + const double zoom = map_state.zoom; + const float zoom_scale = static_cast(std::exp2(zoom)); + const double center_x = lon_to_world_x(map_state.center_lon, zoom); + const double center_y = lat_to_world_y(map_state.center_lat, zoom); + const double top_left_x = center_x - rect_width * 0.5; + const double top_left_y = center_y - rect_height * 0.5; + const ProjectedBounds current_view = view_bounds(top_left_x, top_left_y, rect_width, rect_height); + + if (basemap != nullptr) { + for (const WaterPolygonFeature &water : basemap->water_polygons) { + if (feature_intersects_view(water.bounds, current_view, zoom_scale)) { + draw_water_polygon(draw_list, water, zoom_scale, top_left_x, top_left_y, rect_min); + } + } + for (const WaterLineFeature &water : basemap->water_lines) { + if (feature_intersects_view(water.bounds, current_view, zoom_scale)) { + draw_feature_polyline(draw_list, water.points, zoom_scale, top_left_x, top_left_y, rect_min, + MAP_WATER_LINE, 2.4f); + } + } + + std::array order = { + RoadClass::Local, + RoadClass::Secondary, + RoadClass::Primary, + RoadClass::Motorway, + }; + for (RoadClass road_class : order) { + const RoadPaint paint = road_paint(road_class, static_cast(zoom)); + for (const RoadFeature &road : basemap->roads) { + if (road.road_class != road_class || !feature_intersects_view(road.bounds, current_view, zoom_scale)) { + continue; + } + draw_feature_polyline(draw_list, road.points, zoom_scale, top_left_x, top_left_y, rect_min, + paint.casing, paint.casing_width); + draw_feature_polyline(draw_list, road.points, zoom_scale, top_left_x, top_left_y, rect_min, + paint.fill, paint.fill_width); + } + } + } + + if (basemap != nullptr) { + draw_edge_fade(draw_list, basemap->bounds, zoom, top_left_x, top_left_y, rect_min, rect_max); + } + + for (size_t i = 1; i < trace.points.size(); ++i) { + const GpsPoint &p0 = trace.points[i - 1]; + const GpsPoint &p1 = trace.points[i]; + const ImVec2 s0 = gps_to_screen(p0.lat, p0.lon, zoom, top_left_x, top_left_y, rect_min); + const ImVec2 s1 = gps_to_screen(p1.lat, p1.lon, zoom, top_left_x, top_left_y, rect_min); + draw_list->AddLine(s0, s1, MAP_ROUTE_HALO, 5.8f); + draw_list->AddLine(s0, s1, map_timeline_color(p1.type, 1.0f), 3.25f); + } + + if (cursor_point.has_value()) { + const ImVec2 marker = gps_to_screen(cursor_point->lat, cursor_point->lon, zoom, top_left_x, top_left_y, rect_min); + const float marker_size = std::clamp(9.0f + 1.0f * static_cast(zoom - min_zoom), 9.0f, 20.0f); + draw_car_marker(draw_list, marker, cursor_point->bearing, map_timeline_color(cursor_point->type, 1.0f), marker_size); + } + + if (session->map_data->loading()) { + const char *label = basemap != nullptr ? "Refreshing roads..." : "Loading roads..."; + const ImVec2 text = ImGui::CalcTextSize(label); + const ImVec2 pos(rect_min.x + 12.0f, rect_max.y - text.y - 12.0f); + draw_list->AddRectFilled(ImVec2(pos.x - 6.0f, pos.y - 4.0f), + ImVec2(pos.x + text.x + 6.0f, pos.y + text.y + 4.0f), + IM_COL32(255, 255, 255, 180), 4.0f); + draw_list->AddText(pos, IM_COL32(84, 93, 105, 255), label); + } + draw_list->PopClipRect(); + + const bool canvas_hovered = ImGui::IsItemHovered(); + const bool double_clicked = canvas_hovered && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left); + bool overlay_hovered = false; + if (const std::string google_maps_url = route_google_maps_url(trace); !google_maps_url.empty()) { + std::string label = std::string("Google Maps ") + icon::BOX_ARROW_UP_RIGHT; + const ImVec2 text_size = ImGui::CalcTextSize(label.c_str()); + const ImVec2 button_size(text_size.x + 20.0f, text_size.y + 10.0f); + const ImVec2 button_pos(rect_max.x - button_size.x - 28.0f, rect_min.y + 10.0f); + ImGui::SetCursorScreenPos(button_pos); + ImGui::SetNextItemAllowOverlap(); + if (ImGui::Button("##open_google_maps", button_size)) { + open_external_url(google_maps_url); + state->status_text = "Opened Google Maps"; + } + overlay_hovered = ImGui::IsItemHovered(); + draw_list->AddText(ImVec2(button_pos.x + 10.0f, button_pos.y + (button_size.y - text_size.y) * 0.5f), + ImGui::GetColorU32(ImGuiCol_Text), label.c_str()); + } + const bool hovered = canvas_hovered && !overlay_hovered; + if (hovered && ImGui::GetIO().MouseWheel != 0.0f) { + const float next_zoom = std::clamp(static_cast(zoom) + ImGui::GetIO().MouseWheel * MAP_WHEEL_ZOOM_STEP, + static_cast(min_zoom), static_cast(MAP_MAX_ZOOM)); + if (std::abs(next_zoom - zoom) > 1.0e-4f) { + const ImVec2 mouse = ImGui::GetIO().MousePos; + const double mouse_world_x = top_left_x + (mouse.x - rect_min.x); + const double mouse_world_y = top_left_y + (mouse.y - rect_min.y); + const double mouse_lon = world_x_to_lon(mouse_world_x, zoom); + const double mouse_lat = world_y_to_lat(mouse_world_y, zoom); + const double next_center_x = lon_to_world_x(mouse_lon, next_zoom) - (mouse.x - rect_min.x) + rect_width * 0.5; + const double next_center_y = lat_to_world_y(mouse_lat, next_zoom) - (mouse.y - rect_min.y) + rect_height * 0.5; + map_state.zoom = next_zoom; + map_state.center_lon = world_x_to_lon(next_center_x, next_zoom); + map_state.center_lat = world_y_to_lat(next_center_y, next_zoom); + map_state.follow = false; + clamp_map_center(&map_state, map_bounds, size); + } + } + if (hovered && ImGui::IsMouseDragging(ImGuiMouseButton_Left, 2.0f)) { + const ImVec2 delta = ImGui::GetIO().MouseDelta; + const double next_center_x = center_x - delta.x; + const double next_center_y = center_y - delta.y; + map_state.center_lon = world_x_to_lon(next_center_x, zoom); + map_state.center_lat = world_y_to_lat(next_center_y, zoom); + map_state.follow = false; + clamp_map_center(&map_state, map_bounds, size); + } else if (hovered && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + const ImVec2 drag_delta = ImGui::GetMouseDragDelta(ImGuiMouseButton_Left); + if (drag_delta.x * drag_delta.x + drag_delta.y * drag_delta.y < 16.0f) { + const ImVec2 mouse = ImGui::GetIO().MousePos; + double best_dist = std::numeric_limits::max(); + double best_time = state->tracker_time; + for (const GpsPoint &point : trace.points) { + const ImVec2 screen = gps_to_screen(point.lat, point.lon, zoom, top_left_x, top_left_y, rect_min); + const double dx = static_cast(screen.x - mouse.x); + const double dy = static_cast(screen.y - mouse.y); + const double dist = dx * dx + dy * dy; + if (dist < best_dist) { + best_dist = dist; + best_time = point.time; + } + } + state->tracker_time = best_time; + state->has_tracker_time = true; + } + ImGui::ResetMouseDragDelta(ImGuiMouseButton_Left); + } + if (double_clicked) { + map_state.initialized = false; + } +} diff --git a/tools/jotpluggler/map.h b/tools/jotpluggler/map.h new file mode 100644 index 0000000000..97473f1ba9 --- /dev/null +++ b/tools/jotpluggler/map.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +struct GpsTrace; +struct GeoBounds { + double south = 0.0; + double west = 0.0; + double north = 0.0; + double east = 0.0; + + bool valid() const { + return south < north && west < east; + } +}; + +struct RouteBasemap; +struct MapCacheStats { + uint64_t bytes = 0; + size_t files = 0; +}; + +class MapDataManager { +public: + MapDataManager(); + ~MapDataManager(); + + MapDataManager(const MapDataManager &) = delete; + MapDataManager &operator=(const MapDataManager &) = delete; + + void pump(); + void ensureTrace(const GpsTrace &trace); + void clearCache(); + bool loading() const; + const RouteBasemap *current() const; + MapCacheStats cacheStats() const; + +private: + struct Request { + std::string key; + GeoBounds bounds; + std::string query; + }; + + void run(); + + mutable std::mutex mutex_; + std::condition_variable cv_; + bool stopping_ = false; + std::unique_ptr pending_; + std::unique_ptr active_; + std::unique_ptr completed_; + std::unique_ptr current_; + std::thread worker_; +}; diff --git a/tools/jotpluggler/math_eval.py b/tools/jotpluggler/math_eval.py new file mode 100755 index 0000000000..a865c88a3a --- /dev/null +++ b/tools/jotpluggler/math_eval.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 + +import json +import sys +import textwrap +import traceback + +import numpy as np + + +def _load_manifest(path: str) -> dict: + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _load_vector(path: str) -> np.ndarray: + return np.fromfile(path, dtype=np.float64) + + +def _write_vector(path: str, values: np.ndarray) -> None: + np.asarray(values, dtype=np.float64).tofile(path) + + +def _resample_to_reference(ref_t: np.ndarray, src_t: np.ndarray, src_v: np.ndarray) -> np.ndarray: + ref_t = np.asarray(ref_t, dtype=np.float64).reshape(-1) + src_t = np.asarray(src_t, dtype=np.float64).reshape(-1) + src_v = np.asarray(src_v, dtype=np.float64).reshape(-1) + if ref_t.size == 0 or src_t.size == 0 or src_v.size == 0: + return np.empty_like(ref_t) + indices = np.searchsorted(src_t, ref_t, side="right") - 1 + indices = np.clip(indices, 0, src_v.size - 1) + return src_v[indices] + + +def _evaluate_user_code(code: str, env: dict): + stripped = code.strip() + if not stripped: + raise ValueError("Function body is empty") + + expr = stripped + if expr.startswith("return "): + expr = expr[7:].strip() + try: + return eval(expr, env, env) + except SyntaxError: + pass + + function_src = "def __jotpluggler_eval__():\n" + textwrap.indent(code, " ") + exec(function_src, env, env) + return env["__jotpluggler_eval__"]() + + +def main() -> int: + if len(sys.argv) != 6: + print("usage: math_eval.py ", file=sys.stderr) + return 2 + + manifest_path, globals_path, code_path, out_t_path, out_v_path = sys.argv[1:6] + manifest = _load_manifest(manifest_path) + + series_t = {} + series_v = {} + for entry in manifest.get("series", []): + path = entry["path"] + series_t[path] = _load_vector(entry["t"]) + series_v[path] = _load_vector(entry["v"]) + + first_path = manifest.get("linked_source") or None + + def remember(path: str) -> None: + nonlocal first_path + if first_path is None: + first_path = path + + def t(path: str) -> np.ndarray: + remember(path) + return series_t[path] + + def v(path: str) -> np.ndarray: + remember(path) + return series_v[path] + + additional_sources = list(manifest.get("additional_sources", [])) + linked_source = manifest.get("linked_source") or "" + paths = list(manifest.get("paths", [])) + + env = { + "__builtins__": __builtins__, + "np": np, + "t": t, + "v": v, + "paths": paths, + "linked_source": linked_source, + "additional_sources": additional_sources, + } + + reference_time = None + if linked_source: + reference_time = series_t[linked_source] + env["time"] = reference_time + env["value"] = series_v[linked_source] + + for i, path in enumerate(additional_sources, start=1): + if reference_time is None: + env[f"t{i}"] = series_t[path] + env[f"v{i}"] = series_v[path] + else: + env[f"t{i}"] = reference_time + env[f"v{i}"] = _resample_to_reference(reference_time, series_t[path], series_v[path]) + + with open(globals_path, encoding="utf-8") as f: + globals_code = f.read() + if globals_code.strip(): + exec(globals_code, env, env) + + with open(code_path, encoding="utf-8") as f: + user_code = f.read() + result = _evaluate_user_code(user_code, env) + + if isinstance(result, tuple) and len(result) == 2: + result_t, result_v = result + else: + if first_path is None: + raise ValueError("No reference series found. Set an input timeseries or return (times, values).") + result_t = series_t[first_path] + result_v = result + + result_t = np.asarray(result_t, dtype=np.float64).reshape(-1) + result_v = np.asarray(result_v, dtype=np.float64).reshape(-1) + if result_t.size == 0 or result_v.size == 0: + raise ValueError("Custom series returned an empty result") + if result_t.shape != result_v.shape: + raise ValueError(f"Time/value arrays must have the same shape, got {result_t.shape} and {result_v.shape}") + + _write_vector(out_t_path, result_t) + _write_vector(out_v_path, result_v) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as err: + traceback.print_exc() + raise SystemExit(1) from err diff --git a/tools/jotpluggler/plot.cc b/tools/jotpluggler/plot.cc new file mode 100644 index 0000000000..5d9d644808 --- /dev/null +++ b/tools/jotpluggler/plot.cc @@ -0,0 +1,951 @@ +#include "tools/jotpluggler/internal.h" + +#include "implot.h" +#include "imgui_internal.h" + +#include +#include +#include + +constexpr double PLOT_Y_PAD_FRACTION = 0.4; + +struct PlotBounds { + double x_min = 0.0; + double x_max = 1.0; + double y_min = 0.0; + double y_max = 1.0; +}; + +bool curve_has_samples(const AppSession &session, const Curve &curve) { + if (curve_has_local_samples(curve)) return true; + if (curve.name.empty() || curve.name.front() != '/') { + return false; + } + const RouteSeries *series = app_find_route_series(session, curve.name); + return series != nullptr && series->times.size() > 1 && series->times.size() == series->values.size(); +} + +void extend_range(const std::vector &values, bool *found, double *min_value, double *max_value) { + if (values.empty()) { + return; + } + const auto [min_it, max_it] = std::minmax_element(values.begin(), values.end()); + if (!*found) { + *min_value = *min_it; + *max_value = *max_it; + *found = true; + return; + } + *min_value = std::min(*min_value, *min_it); + *max_value = std::max(*max_value, *max_it); +} + +void ensure_non_degenerate_range(double *min_value, double *max_value, double pad_fraction, double fallback_pad) { + if (*max_value <= *min_value) { + const double pad = std::max(std::abs(*min_value) * 0.1, fallback_pad); + *min_value -= pad; + *max_value += pad; + return; + } + const double span = *max_value - *min_value; + const double pad = std::max(span * pad_fraction, fallback_pad); + *min_value -= pad; + *max_value += pad; +} + +struct PreparedCurve { + int pane_curve_index = -1; + std::string label; + std::array color = {160, 170, 180}; + float line_weight = 2.0f; + bool stairs = false; + const EnumInfo *enum_info = nullptr; + SeriesFormat display_info; + std::optional legend_value; + std::vector xs; + std::vector ys; +}; + +struct StateBlock { + double t0 = 0.0; + double t1 = 0.0; + int value = 0; + std::string label; +}; + +struct PaneValueFormatContext { + SeriesFormat format; + bool valid = false; +}; + +bool curves_are_bool_like(const std::vector &prepared_curves) { + if (prepared_curves.empty()) { + return false; + } + for (const PreparedCurve &curve : prepared_curves) { + if (!curve.display_info.integer_like || curve.ys.empty()) { + return false; + } + bool found_finite = false; + for (double value : curve.ys) { + if (!std::isfinite(value)) continue; + found_finite = true; + if (std::abs(value) > 0.01 && std::abs(value - 1.0) > 0.01) { + return false; + } + } + if (!found_finite) { + return false; + } + } + return true; +} + +ImU32 state_block_color(int value, float alpha = 1.0f) { + static constexpr std::array, 8> kPalette = {{ + {{111, 143, 175}}, + {{0, 163, 108}}, + {{255, 195, 0}}, + {{199, 0, 57}}, + {{123, 97, 255}}, + {{0, 150, 136}}, + {{214, 48, 49}}, + {{52, 73, 94}}, + }}; + const size_t index = static_cast(std::abs(value)) % kPalette.size(); + return ImGui::GetColorU32(color_rgb(kPalette[index], alpha)); +} + +std::string state_block_label(const PreparedCurve &curve, int value) { + if (curve.enum_info != nullptr && value >= 0 && static_cast(value) < curve.enum_info->names.size()) { + const std::string &name = curve.enum_info->names[static_cast(value)]; + if (!name.empty()) { + return name; + } + } + return std::to_string(value); +} + +std::vector build_state_blocks(const PreparedCurve &curve) { + std::vector blocks; + if (curve.xs.size() < 2 || curve.xs.size() != curve.ys.size()) { + return blocks; + } + + int current_value = static_cast(std::llround(curve.ys.front())); + double start_time = curve.xs.front(); + for (size_t i = 1; i < curve.xs.size(); ++i) { + const int value = static_cast(std::llround(curve.ys[i])); + if (value == current_value) { + continue; + } + const double end_time = curve.xs[i]; + if (end_time > start_time) { + blocks.push_back(StateBlock{ + .t0 = start_time, + .t1 = end_time, + .value = current_value, + .label = state_block_label(curve, current_value), + }); + } + current_value = value; + start_time = end_time; + } + + const double final_time = curve.xs.back(); + if (final_time >= start_time) { + blocks.push_back(StateBlock{ + .t0 = start_time, + .t1 = final_time, + .value = current_value, + .label = state_block_label(curve, current_value), + }); + } + return blocks; +} + +void app_decimate_samples_impl(const std::vector &xs_in, + const std::vector &ys_in, + int max_points, + std::vector *xs_out, + std::vector *ys_out) { + + const size_t bucket_count = std::max(1, static_cast(max_points / 4)); + const size_t bucket_size = std::max( + 1, + static_cast(std::ceil(static_cast(xs_in.size()) / static_cast(bucket_count)))); + xs_out->reserve(bucket_count * 4 + 2); + ys_out->reserve(bucket_count * 4 + 2); + + size_t last_index = std::numeric_limits::max(); + auto append_index = [&](size_t index) { + if (index >= xs_in.size() || index == last_index) { + return; + } + xs_out->push_back(xs_in[index]); + ys_out->push_back(ys_in[index]); + last_index = index; + }; + + for (size_t start = 0; start < xs_in.size(); start += bucket_size) { + const size_t end = std::min(xs_in.size(), start + bucket_size); + size_t min_index = start; + size_t max_index = start; + for (size_t index = start + 1; index < end; ++index) { + if (ys_in[index] < ys_in[min_index]) { + min_index = index; + } + if (ys_in[index] > ys_in[max_index]) { + max_index = index; + } + } + + std::array indices = {start, min_index, max_index, end - 1}; + std::sort(indices.begin(), indices.end()); + for (size_t index : indices) { + append_index(index); + } + } +} + +void app_decimate_samples(const std::vector &xs_in, + const std::vector &ys_in, + int max_points, + std::vector *xs_out, + std::vector *ys_out) { + xs_out->clear(); + ys_out->clear(); + if (xs_in.empty() || xs_in.size() != ys_in.size()) { + return; + } + if (max_points <= 0 || static_cast(xs_in.size()) <= max_points) { + *xs_out = xs_in; + *ys_out = ys_in; + return; + } + app_decimate_samples_impl(xs_in, ys_in, max_points, xs_out, ys_out); +} + +void app_decimate_samples(std::vector &&xs_in, + std::vector &&ys_in, + int max_points, + std::vector *xs_out, + std::vector *ys_out) { + xs_out->clear(); + ys_out->clear(); + if (xs_in.empty() || xs_in.size() != ys_in.size()) { + return; + } + if (max_points <= 0 || static_cast(xs_in.size()) <= max_points) { + *xs_out = std::move(xs_in); + *ys_out = std::move(ys_in); + return; + } + app_decimate_samples_impl(xs_in, ys_in, max_points, xs_out, ys_out); +} + +std::optional app_sample_xy_value_at_time(const std::vector &xs, + const std::vector &ys, + bool stairs, + double tm) { + if (xs.size() < 2 || xs.size() != ys.size()) { + return std::nullopt; + } + if (tm <= xs.front()) return ys.front(); + if (tm >= xs.back()) return ys.back(); + + const auto upper = std::lower_bound(xs.begin(), xs.end(), tm); + if (upper == xs.begin()) return ys.front(); + if (upper == xs.end()) return ys.back(); + + const size_t upper_index = static_cast(std::distance(xs.begin(), upper)); + const size_t lower_index = upper_index - 1; + const double x0 = xs[lower_index]; + const double x1 = xs[upper_index]; + const double y0 = ys[lower_index]; + const double y1 = ys[upper_index]; + if (std::abs(tm - x1) < 1.0e-9) return y1; + if (stairs || x1 <= x0) return y0; + const double alpha = (tm - x0) / (x1 - x0); + return y0 + (y1 - y0) * alpha; +} + +int format_numeric_axis_tick(double value, char *buf, int size, void *user_data) { + const auto *ctx = static_cast(user_data); + if (ctx == nullptr || !ctx->valid) { + return std::snprintf(buf, size, "%.6g", value); + } + if (ctx->format.integer_like) { + const double nearest_int = std::round(value); + if (std::abs(value - nearest_int) > 1.0e-6) { + int decimals = 1; + while (decimals < 4) { + const double scale = std::pow(10.0, decimals); + const double rounded = std::round(value * scale) / scale; + if (std::abs(value - rounded) <= 1.0e-6) { + break; + } + ++decimals; + } + return std::snprintf(buf, size, "%.*f", decimals, value); + } + } + return std::snprintf(buf, size, ctx->format.fmt, value); +} + +void merge_pane_value_format(PaneValueFormatContext *ctx, const SeriesFormat &format) { + if (!ctx->valid) { + ctx->format = format; + ctx->valid = true; + return; + } + ctx->format.has_negative = ctx->format.has_negative || format.has_negative; + ctx->format.digits_before = std::max(ctx->format.digits_before, format.digits_before); + ctx->format.decimals = std::max(ctx->format.decimals, format.decimals); + ctx->format.integer_like = ctx->format.decimals == 0; + const int sign_width = ctx->format.has_negative ? 1 : 0; + const int dot_width = ctx->format.decimals > 0 ? 1 : 0; + ctx->format.total_width = sign_width + ctx->format.digits_before + dot_width + ctx->format.decimals; + std::snprintf(ctx->format.fmt, sizeof(ctx->format.fmt), "%%%d.%df", + ctx->format.total_width, ctx->format.decimals); +} + +std::string curve_legend_label(const PreparedCurve &curve, bool has_cursor_time, size_t label_width) { + if (!has_cursor_time) return curve.label; + if (!curve.legend_value.has_value()) return curve.label; + const std::string value_text = format_display_value(*curve.legend_value, curve.display_info, curve.enum_info); + if (value_text.empty()) return curve.label; + const size_t padded_width = std::max(label_width, curve.label.size()); + return curve.label + std::string(padded_width - curve.label.size() + 2, ' ') + value_text; +} + +bool build_curve_series(const AppSession &session, + const Curve &curve, + const UiState &state, + int max_points, + PreparedCurve *prepared) { + std::vector xs; + std::vector ys; + if (curve_has_local_samples(curve)) { + xs = curve.xs; + ys = curve.ys; + } else { + const RouteSeries *series = app_find_route_series(session, curve.name); + if (series == nullptr || series->times.size() < 2 || series->times.size() != series->values.size()) { + return false; + } + + size_t begin_index = 0; + size_t end_index = series->times.size(); + if (state.has_shared_range && state.x_view_max > state.x_view_min) { + auto begin_it = std::lower_bound(series->times.begin(), series->times.end(), state.x_view_min); + auto end_it = std::upper_bound(series->times.begin(), series->times.end(), state.x_view_max); + begin_index = begin_it == series->times.begin() ? 0 : static_cast(std::distance(series->times.begin(), begin_it - 1)); + end_index = end_it == series->times.end() ? series->times.size() : static_cast(std::distance(series->times.begin(), end_it + 1)); + end_index = std::min(end_index, series->times.size()); + } + if (end_index <= begin_index + 1) return false; + xs.assign(series->times.begin() + begin_index, series->times.begin() + end_index); + ys.assign(series->values.begin() + begin_index, series->values.begin() + end_index); + } + + std::vector transformed_xs; + std::vector transformed_ys; + if (curve.derivative) { + if (xs.size() < 2) return false; + transformed_xs.reserve(xs.size() - 1); + transformed_ys.reserve(ys.size() - 1); + for (size_t i = 1; i < xs.size(); ++i) { + const double dt = curve.derivative_dt > 0.0 ? curve.derivative_dt : (xs[i] - xs[i - 1]); + if (dt <= 0.0) continue; + transformed_xs.push_back(xs[i]); + transformed_ys.push_back((ys[i] - ys[i - 1]) / dt); + } + } else { + transformed_xs = std::move(xs); + transformed_ys = std::move(ys); + } + + if (transformed_xs.size() < 2 || transformed_xs.size() != transformed_ys.size()) { + return false; + } + + for (double &value : transformed_ys) { + value = value * curve.value_scale + curve.value_offset; + } + + prepared->label = app_curve_display_name(curve); + prepared->color = curve.color; + prepared->line_weight = curve.derivative ? 1.8f : 2.25f; + if (!curve.derivative + && curve.value_scale == 1.0 + && curve.value_offset == 0.0 + && !curve_has_local_samples(curve) + && !curve.name.empty() + && curve.name.front() == '/') { + auto it = session.route_data.enum_info.find(curve.name); + if (it != session.route_data.enum_info.end()) { + prepared->enum_info = &it->second; + } + } + if (prepared->enum_info != nullptr) { + prepared->display_info = compute_series_format(transformed_ys, true); + } else if (!curve_has_local_samples(curve) + && !curve.derivative + && curve.value_scale == 1.0 + && curve.value_offset == 0.0 + && !curve.name.empty() + && curve.name.front() == '/') { + auto display_it = session.route_data.series_formats.find(curve.name); + if (display_it != session.route_data.series_formats.end()) { + prepared->display_info = display_it->second; + } else { + prepared->display_info = compute_series_format(transformed_ys, false); + } + } else { + prepared->display_info = compute_series_format(transformed_ys, false); + } + const bool stairs = !curve.derivative && prepared->display_info.integer_like; + if (state.has_tracker_time) { + prepared->legend_value = app_sample_xy_value_at_time(transformed_xs, transformed_ys, stairs, state.tracker_time); + } + if (stairs) { + prepared->xs = std::move(transformed_xs); + prepared->ys = std::move(transformed_ys); + } else { + app_decimate_samples(std::move(transformed_xs), std::move(transformed_ys), max_points, &prepared->xs, &prepared->ys); + } + prepared->stairs = stairs; + return prepared->xs.size() > 1 && prepared->xs.size() == prepared->ys.size(); +} + +bool draw_pane_close_button_overlay() { + const ImVec2 window_pos = ImGui::GetWindowPos(); + const ImVec2 content_min = ImGui::GetWindowContentRegionMin(); + const ImVec2 content_max = ImGui::GetWindowContentRegionMax(); + const ImRect rect(ImVec2(window_pos.x + content_max.x - 42.0f, window_pos.y + content_min.y + 4.0f), + ImVec2(window_pos.x + content_max.x - 4.0f, window_pos.y + content_min.y + 42.0f)); + const bool hovered = ImGui::IsMouseHoveringRect(rect.Min, rect.Max, false); + const bool held = hovered && ImGui::IsMouseDown(ImGuiMouseButton_Left); + if (hovered) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + } + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + const float pad = 11.0f; + const ImU32 color = hovered || held + ? ImGui::GetColorU32(color_rgb(72, 79, 88)) + : ImGui::GetColorU32(color_rgb(138, 146, 156)); + draw_list->AddLine(ImVec2(rect.Min.x + pad, rect.Min.y + pad), + ImVec2(rect.Max.x - pad, rect.Max.y - pad), + color, + 2.4f); + draw_list->AddLine(ImVec2(rect.Min.x + pad, rect.Max.y - pad), + ImVec2(rect.Max.x - pad, rect.Min.y + pad), + color, + 2.4f); + return hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left); +} + +void draw_pane_frame_overlay() { + const ImVec2 window_pos = ImGui::GetWindowPos(); + const ImVec2 content_min = ImGui::GetWindowContentRegionMin(); + const ImVec2 content_max = ImGui::GetWindowContentRegionMax(); + const ImRect frame_rect(ImVec2(window_pos.x + content_min.x, window_pos.y + content_min.y), + ImVec2(window_pos.x + content_max.x, window_pos.y + content_max.y)); + ImGui::GetWindowDrawList()->AddRect(frame_rect.Min, + frame_rect.Max, + ImGui::GetColorU32(color_rgb(186, 190, 196)), + 0.0f, + 0, + 1.0f); +} + +PlotBounds compute_plot_bounds(const Pane &pane, + const std::vector &prepared_curves, + const UiState &state) { + PlotBounds bounds; + bounds.x_min = state.has_shared_range ? state.x_view_min : 0.0; + bounds.x_max = state.has_shared_range ? state.x_view_max : 1.0; + if (bounds.x_max <= bounds.x_min) { + bounds.x_max = bounds.x_min + 1.0; + } + + bool found = false; + double min_value = 0.0; + double max_value = 1.0; + for (const PreparedCurve &curve : prepared_curves) { + extend_range(curve.ys, &found, &min_value, &max_value); + } + if (!found) { + min_value = 0.0; + max_value = 1.0; + } + if (curves_are_bool_like(prepared_curves)) { + min_value = std::min(min_value, 0.0); + max_value = std::max(max_value, 1.0); + } + ensure_non_degenerate_range(&min_value, &max_value, PLOT_Y_PAD_FRACTION, 0.1); + if (pane.range.has_y_limit_min) { + min_value = pane.range.y_limit_min; + } + if (pane.range.has_y_limit_max) { + max_value = pane.range.y_limit_max; + } + ensure_non_degenerate_range(&min_value, &max_value, 0.0, 0.1); + bounds.y_min = min_value; + bounds.y_max = max_value; + return bounds; +} + +void draw_state_blocks_pane(const std::vector &prepared_curves, UiState *state) { + if (prepared_curves.empty() || !state->has_shared_range || state->x_view_max <= state->x_view_min) { + return; + } + + ImDrawList *draw_list = ImPlot::GetPlotDrawList(); + const ImVec2 plot_min = ImPlot::GetPlotPos(); + const ImVec2 plot_size = ImPlot::GetPlotSize(); + const int curve_count = static_cast(prepared_curves.size()); + if (plot_size.x <= 2.0f || plot_size.y <= 2.0f || curve_count <= 0) { + return; + } + + float label_width = 0.0f; + if (curve_count > 1) { + for (const PreparedCurve &curve : prepared_curves) { + label_width = std::max(label_width, ImGui::CalcTextSize(curve.label.c_str()).x); + } + label_width = std::clamp(label_width + 14.0f, 72.0f, std::min(160.0f, plot_size.x * 0.35f)); + } + + const float row_height = plot_size.y / static_cast(curve_count); + const float blocks_min_x = plot_min.x + label_width; + const float blocks_max_x = plot_min.x + plot_size.x; + const float blocks_width = std::max(1.0f, blocks_max_x - blocks_min_x); + const double x_span = std::max(1.0e-9, state->x_view_max - state->x_view_min); + + struct HoveredBlock { + int curve_index = -1; + StateBlock block; + }; + std::optional hovered; + + const ImVec2 mouse_pos = ImGui::GetMousePos(); + const bool plot_hovered = ImPlot::IsPlotHovered(); + + for (int curve_index = 0; curve_index < curve_count; ++curve_index) { + const PreparedCurve &curve = prepared_curves[static_cast(curve_index)]; + const float y0 = plot_min.y + row_height * static_cast(curve_index); + const float y1 = y0 + row_height; + const std::vector blocks = build_state_blocks(curve); + + if (curve_index > 0) { + draw_list->AddLine(ImVec2(plot_min.x, y0), ImVec2(plot_min.x + plot_size.x, y0), + IM_COL32(210, 214, 220, 255), 1.0f); + } + if (curve_count > 1) { + draw_list->AddLine(ImVec2(blocks_min_x, y0), ImVec2(blocks_min_x, y1), + IM_COL32(210, 214, 220, 255), 1.0f); + const float label_left = plot_min.x + 6.0f; + const float label_right = std::max(label_left + 12.0f, blocks_min_x - 6.0f); + ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(120, 128, 138)); + ImGui::RenderTextEllipsis(draw_list, + ImVec2(label_left, y0 + 4.0f), + ImVec2(label_right, y1 - 4.0f), + label_right, + curve.label.c_str(), + nullptr, + nullptr); + ImGui::PopStyleColor(); + } + + for (const StateBlock &block : blocks) { + const double visible_t0 = std::max(block.t0, state->x_view_min); + const double visible_t1 = std::min(block.t1, state->x_view_max); + if (visible_t1 <= visible_t0) { + continue; + } + const float x0 = blocks_min_x + static_cast((visible_t0 - state->x_view_min) / x_span) * blocks_width; + const float x1 = blocks_min_x + static_cast((visible_t1 - state->x_view_min) / x_span) * blocks_width; + const ImU32 fill_color = state_block_color(block.value, 0.15f); + const ImU32 line_color = state_block_color(block.value, 0.90f); + draw_list->AddRectFilled(ImVec2(x0, y0), ImVec2(std::max(x1, x0 + 1.0f), y1), fill_color); + draw_list->AddLine(ImVec2(x0, y0), ImVec2(x0, y1), line_color, 2.0f); + + const float block_width = x1 - x0; + if (block_width > 14.0f) { + const float text_left = x0 + 6.0f; + const float text_right = x1 - 6.0f; + if (text_right > text_left) { + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(state_block_color(block.value, 0.80f))); + ImGui::RenderTextEllipsis(draw_list, + ImVec2(text_left, y0 + 4.0f), + ImVec2(text_right, y1 - 4.0f), + text_right, + block.label.c_str(), + nullptr, + nullptr); + ImGui::PopStyleColor(); + } + } + + if (plot_hovered && mouse_pos.x >= blocks_min_x && mouse_pos.x <= blocks_max_x && mouse_pos.y >= y0 && mouse_pos.y <= y1) { + const double hover_time = state->x_view_min + static_cast((mouse_pos.x - blocks_min_x) / blocks_width) * x_span; + if (hover_time >= block.t0 && hover_time <= block.t1) { + hovered = HoveredBlock{ + .curve_index = curve_index, + .block = block, + }; + } + } + } + } + + if (hovered.has_value()) { + const HoveredBlock &info = *hovered; + ImGui::BeginTooltip(); + if (curve_count > 1) { + ImGui::Text("%s: %s (%d)", prepared_curves[static_cast(info.curve_index)].label.c_str(), + info.block.label.c_str(), info.block.value); + } else { + ImGui::Text("%s (%d)", info.block.label.c_str(), info.block.value); + } + ImGui::Separator(); + ImGui::Text("%.3fs -> %.3fs", info.block.t0, info.block.t1); + ImGui::Text("duration: %.3fs", info.block.t1 - info.block.t0); + ImGui::EndTooltip(); + } +} + +void persist_shared_range_to_tab(WorkspaceTab *tab, const UiState &state) { + if (tab == nullptr || !state.has_shared_range) { + return; + } + const double x_min = state.x_view_min; + const double x_max = state.x_view_max > state.x_view_min ? state.x_view_max : state.x_view_min + 1.0; + for (Pane &pane : tab->panes) { + pane.range.valid = true; + pane.range.left = x_min; + pane.range.right = x_max; + } +} + +void clear_pane_vertical_limits(Pane *pane) { + if (pane == nullptr) { + return; + } + pane->range.has_y_limit_min = false; + pane->range.has_y_limit_max = false; +} + +PlotBounds current_plot_bounds_for_pane(const AppSession &session, const Pane &pane, const UiState &state) { + std::vector prepared_curves; + prepared_curves.reserve(pane.curves.size()); + constexpr int kAxisEditorMaxPoints = 2048; + for (size_t curve_index = 0; curve_index < pane.curves.size(); ++curve_index) { + const Curve &curve = pane.curves[curve_index]; + if (!curve.visible || !curve_has_samples(session, curve)) continue; + PreparedCurve prepared; + if (build_curve_series(session, curve, state, kAxisEditorMaxPoints, &prepared)) { + prepared.pane_curve_index = static_cast(curve_index); + prepared_curves.push_back(std::move(prepared)); + } + } + return compute_plot_bounds(pane, prepared_curves, state); +} + +void open_axis_limits_editor(const AppSession &session, UiState *state, int pane_index) { + ensure_shared_range(state, session); + clamp_shared_range(state, session); + const WorkspaceTab *tab = app_active_tab(session.layout, *state); + if (tab == nullptr || pane_index < 0 || pane_index >= static_cast(tab->panes.size())) { + return; + } + + const Pane &pane = tab->panes[static_cast(pane_index)]; + const PlotBounds bounds = current_plot_bounds_for_pane(session, pane, *state); + AxisLimitsEditorState &editor = state->axis_limits; + editor.open = true; + editor.pane_index = pane_index; + editor.x_min = state->x_view_min; + editor.x_max = state->x_view_max; + editor.y_min_enabled = pane.range.has_y_limit_min; + editor.y_max_enabled = pane.range.has_y_limit_max; + editor.y_min = pane.range.has_y_limit_min ? pane.range.y_limit_min : bounds.y_min; + editor.y_max = pane.range.has_y_limit_max ? pane.range.y_limit_max : bounds.y_max; +} + +bool apply_axis_limits_editor(AppSession *session, UiState *state) { + WorkspaceTab *tab = app_active_tab(&session->layout, *state); + if (tab == nullptr) return false; + + AxisLimitsEditorState &editor = state->axis_limits; + if (editor.pane_index < 0 || editor.pane_index >= static_cast(tab->panes.size())) { + state->error_text = "The selected pane is no longer available."; + state->open_error_popup = true; + return false; + } + if (!std::isfinite(editor.x_min) || !std::isfinite(editor.x_max)) { + state->error_text = "Axis limits must be finite numbers."; + state->open_error_popup = true; + return false; + } + if (editor.x_max <= editor.x_min) { + state->error_text = "X max must be greater than X min."; + state->open_error_popup = true; + return false; + } + if (editor.y_min_enabled && !std::isfinite(editor.y_min)) { + state->error_text = "Y min must be a finite number."; + state->open_error_popup = true; + return false; + } + if (editor.y_max_enabled && !std::isfinite(editor.y_max)) { + state->error_text = "Y max must be a finite number."; + state->open_error_popup = true; + return false; + } + if (editor.y_min_enabled && editor.y_max_enabled && editor.y_max <= editor.y_min) { + state->error_text = "Y max must be greater than Y min."; + state->open_error_popup = true; + return false; + } + + const SketchLayout before_layout = session->layout; + state->has_shared_range = true; + state->x_view_min = editor.x_min; + state->x_view_max = editor.x_max; + if (session->data_mode == SessionDataMode::Stream) { + state->follow_latest = infer_stream_follow_state(*state, *session); + } else { + state->follow_latest = false; + } + state->suppress_range_side_effects = true; + clamp_shared_range(state, *session); + persist_shared_range_to_tab(tab, *state); + + Pane &pane = tab->panes[static_cast(editor.pane_index)]; + pane.range.has_y_limit_min = editor.y_min_enabled; + pane.range.has_y_limit_max = editor.y_max_enabled; + if (editor.y_min_enabled) { + pane.range.y_limit_min = editor.y_min; + } + if (editor.y_max_enabled) { + pane.range.y_limit_max = editor.y_max; + } + + const PlotBounds bounds = current_plot_bounds_for_pane(*session, pane, *state); + pane.range.valid = true; + pane.range.left = state->x_view_min; + pane.range.right = state->x_view_max; + pane.range.bottom = bounds.y_min; + pane.range.top = bounds.y_max; + + state->undo.push(before_layout); + const bool ok = mark_layout_dirty(session, state); + if (ok) { + state->status_text = "Axis limits updated"; + } + return ok; +} + +void draw_plot(const AppSession &session, Pane *pane, UiState *state) { + std::vector prepared_curves; + prepared_curves.reserve(pane->curves.size()); + const int max_points = std::max(256, static_cast(ImGui::GetContentRegionAvail().x) * 2); + for (size_t curve_index = 0; curve_index < pane->curves.size(); ++curve_index) { + const Curve &curve = pane->curves[curve_index]; + if (!curve.visible || !curve_has_samples(session, curve)) continue; + PreparedCurve prepared; + if (build_curve_series(session, curve, *state, max_points, &prepared)) { + prepared.pane_curve_index = static_cast(curve_index); + prepared_curves.push_back(std::move(prepared)); + } + } + + const PlotBounds bounds = compute_plot_bounds(*pane, prepared_curves, *state); + PaneValueFormatContext pane_value_format; + bool state_block_mode = !prepared_curves.empty(); + size_t max_legend_label_width = 0; + for (const PreparedCurve &curve : prepared_curves) { + max_legend_label_width = std::max(max_legend_label_width, curve.label.size()); + if (curve.enum_info == nullptr) { + state_block_mode = false; + merge_pane_value_format(&pane_value_format, curve.display_info); + } + } + const int supported_count = static_cast(prepared_curves.size()); + const ImVec2 plot_size = ImGui::GetContentRegionAvail(); + const bool has_cursor_time = state->has_tracker_time; + const double cursor_time = state->tracker_time; + + ImPlot::PushStyleColor(ImPlotCol_PlotBg, color_rgb(255, 255, 255)); + ImPlot::PushStyleColor(ImPlotCol_PlotBorder, color_rgb(186, 190, 196)); + ImPlot::PushStyleColor(ImPlotCol_LegendBg, color_rgb(248, 249, 251, 0.92f)); + ImPlot::PushStyleColor(ImPlotCol_LegendBorder, color_rgb(168, 175, 184)); + ImPlot::PushStyleColor(ImPlotCol_LegendText, color_rgb(57, 62, 69)); + ImPlot::PushStyleColor(ImPlotCol_TitleText, color_rgb(57, 62, 69)); + ImPlot::PushStyleColor(ImPlotCol_InlayText, color_rgb(95, 103, 112)); + ImPlot::PushStyleColor(ImPlotCol_AxisGrid, color_rgb(188, 196, 206)); + ImPlot::PushStyleColor(ImPlotCol_AxisText, color_rgb(95, 103, 112)); + ImPlot::PushStyleColor(ImPlotCol_AxisBg, color_rgb(255, 255, 255, 0.0f)); + ImPlot::PushStyleColor(ImPlotCol_AxisBgHovered, color_rgb(214, 220, 228, 0.45f)); + ImPlot::PushStyleColor(ImPlotCol_AxisBgActive, color_rgb(199, 209, 222, 0.55f)); + ImPlot::PushStyleColor(ImPlotCol_Selection, color_rgb(252, 211, 77, 0.28f)); + ImPlot::PushStyleColor(ImPlotCol_Crosshairs, color_rgb(120, 128, 138, 0.70f)); + ImPlot::PushStyleVar(ImPlotStyleVar_LegendPadding, ImVec2(56.0f, 10.0f)); + + ImPlotFlags plot_flags = ImPlotFlags_NoTitle | ImPlotFlags_NoMenus; + if (state_block_mode) { + plot_flags |= ImPlotFlags_NoLegend | ImPlotFlags_NoMouseText; + } + if (supported_count == 0) { + plot_flags |= ImPlotFlags_NoLegend; + } + + const ImPlotAxisFlags x_axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight; + ImPlotAxisFlags y_axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight; + if (state_block_mode) { + y_axis_flags |= ImPlotAxisFlags_NoDecorations; + } + const bool explicit_y = pane->range.has_y_limit_min || pane->range.has_y_limit_max; + if (!state_block_mode && !explicit_y && supported_count > 0) { + y_axis_flags |= ImPlotAxisFlags_AutoFit | ImPlotAxisFlags_RangeFit; + } + + const double previous_x_min = state->x_view_min; + const double previous_x_max = state->x_view_max; + app_push_mono_font(); + if (ImPlot::BeginPlot("##plot", plot_size, plot_flags)) { + ImPlot::SetupAxes(nullptr, nullptr, x_axis_flags, y_axis_flags); + ImPlot::SetupAxisFormat(ImAxis_X1, "%.1f"); + if (state_block_mode) { + ImPlot::SetupAxisLimits(ImAxis_Y1, 0.0, 1.0, ImPlotCond_Always); + } else if (pane_value_format.valid) { + ImPlot::SetupAxisFormat(ImAxis_Y1, format_numeric_axis_tick, &pane_value_format); + } else { + ImPlot::SetupAxisFormat(ImAxis_Y1, "%.6g"); + } + ImPlot::SetupAxisLinks(ImAxis_X1, &state->x_view_min, &state->x_view_max); + if (state->route_x_max > state->route_x_min) { + const double x_constraint_min = session.data_mode == SessionDataMode::Stream + ? state->route_x_min - std::max(MIN_HORIZONTAL_ZOOM_SECONDS, session.stream_buffer_seconds) + : state->route_x_min; + ImPlot::SetupAxisLimitsConstraints(ImAxis_X1, x_constraint_min, state->route_x_max); + } + if (!state_block_mode) { + ImPlot::SetupMouseText(ImPlotLocation_SouthEast, ImPlotMouseTextFlags_NoAuxAxes); + } + if (!state_block_mode && (explicit_y || supported_count == 0)) { + ImPlot::SetupAxisLimits(ImAxis_Y1, bounds.y_min, bounds.y_max, ImPlotCond_Always); + } + if (!state_block_mode && supported_count > 0) { + ImPlot::SetupLegend(ImPlotLocation_NorthEast); + } + + if (state_block_mode) { + draw_state_blocks_pane(prepared_curves, state); + } else { + for (size_t i = 0; i < prepared_curves.size(); ++i) { + const PreparedCurve &curve = prepared_curves[i]; + std::string series_id = curve_legend_label(curve, has_cursor_time, max_legend_label_width) + "##curve" + std::to_string(i); + ImPlotSpec spec; + spec.LineColor = color_rgb(curve.color); + spec.LineWeight = curve.line_weight; + spec.Flags = ImPlotLineFlags_SkipNaN; + if (!curve.xs.empty() && curve.xs.size() == curve.ys.size()) { + if (curve.stairs) { + spec.Flags = ImPlotStairsFlags_PreStep; + ImPlot::PlotStairs(series_id.c_str(), curve.xs.data(), curve.ys.data(), static_cast(curve.xs.size()), spec); + } else { + ImPlot::PlotLine(series_id.c_str(), curve.xs.data(), curve.ys.data(), static_cast(curve.xs.size()), spec); + } + } + } + } + if (has_cursor_time) { + const double clamped_cursor_time = std::clamp(cursor_time, state->route_x_min, state->route_x_max); + ImPlotSpec cursor_spec; + cursor_spec.LineColor = color_rgb(108, 118, 128, 0.7f); + cursor_spec.LineWeight = 1.0f; + cursor_spec.Flags = ImPlotItemFlags_NoLegend; + ImPlot::PlotInfLines("##tracker_cursor", &clamped_cursor_time, 1, cursor_spec); + } + if (ImPlot::IsPlotHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + state->tracker_time = std::clamp(ImPlot::GetPlotMousePos().x, state->route_x_min, state->route_x_max); + state->has_tracker_time = true; + } + ImPlot::EndPlot(); + } + app_pop_mono_font(); + clamp_shared_range(state, session); + if (std::abs(state->x_view_min - previous_x_min) > 1.0e-6 + || std::abs(state->x_view_max - previous_x_max) > 1.0e-6) { + if (!state->suppress_range_side_effects) { + if (session.data_mode == SessionDataMode::Stream) { + state->follow_latest = infer_stream_follow_state(*state, session); + } else { + state->follow_latest = false; + } + } + } + ImPlot::PopStyleVar(); + ImPlot::PopStyleColor(12); +} + +std::optional draw_pane_context_menu(const WorkspaceTab &tab, int pane_index) { + if (!ImGui::BeginPopupContextWindow("##pane_context")) return std::nullopt; + + PaneMenuAction action; + action.pane_index = pane_index; + const Pane *pane = pane_index >= 0 && pane_index < static_cast(tab.panes.size()) + ? &tab.panes[static_cast(pane_index)] + : nullptr; + const bool has_curves = pane_index >= 0 + && pane_index < static_cast(tab.panes.size()) + && !tab.panes[static_cast(pane_index)].curves.empty(); + const bool is_plot = pane != nullptr && pane->kind == PaneKind::Plot; + if (icon_menu_item(icon::SLIDERS, "Edit Axis Limits...", nullptr, false, is_plot)) { + action.kind = PaneMenuActionKind::OpenAxisLimits; + } + icon_menu_item(icon::PALETTE, "Edit Curve Style...", nullptr, false, false && is_plot); + if (action.kind == PaneMenuActionKind::None + && icon_menu_item(icon::PLUS_SLASH_MINUS, "Apply filter to data...", nullptr, false, has_curves && is_plot)) { + action.kind = PaneMenuActionKind::OpenCustomSeries; + } + ImGui::Separator(); + if (action.kind == PaneMenuActionKind::None && icon_menu_item(icon::DISTRIBUTE_HORIZONTAL, "Split Left / Right")) { + action.kind = PaneMenuActionKind::SplitRight; + } else if (action.kind == PaneMenuActionKind::None + && icon_menu_item(icon::DISTRIBUTE_VERTICAL, "Split Top / Bottom")) { + action.kind = PaneMenuActionKind::SplitBottom; + } + ImGui::Separator(); + if (icon_menu_item(icon::ZOOM_OUT, "Zoom Out", nullptr, false, is_plot)) { + action.kind = PaneMenuActionKind::ResetView; + } else if (icon_menu_item(icon::ARROW_LEFT_RIGHT, "Zoom Out Horizontally", nullptr, false, is_plot)) { + action.kind = PaneMenuActionKind::ResetHorizontal; + } else if (icon_menu_item(icon::ARROW_DOWN_UP, "Zoom Out Vertically", nullptr, false, is_plot)) { + action.kind = PaneMenuActionKind::ResetVertical; + } + ImGui::Separator(); + if (icon_menu_item(icon::TRASH, "Remove ALL curves", nullptr, false, is_plot)) { + action.kind = PaneMenuActionKind::Clear; + } + ImGui::Separator(); + icon_menu_item(icon::ARROW_LEFT_RIGHT, "Flip Horizontal Axis", nullptr, false, false); + icon_menu_item(icon::ARROW_DOWN_UP, "Flip Vertical Axis", nullptr, false, false); + ImGui::Separator(); + icon_menu_item(icon::FILES, "Copy", nullptr, false, false); + icon_menu_item(icon::CLIPBOARD2, "Paste", nullptr, false, false); + icon_menu_item(icon::FILE_EARMARK_IMAGE, "Copy image to clipboard", nullptr, false, false); + icon_menu_item(icon::SAVE, "Save plot to file", nullptr, false, false); + icon_menu_item(icon::BAR_CHART, "Show data statistics", nullptr, false, false); + ImGui::Separator(); + if (icon_menu_item(icon::X_SQUARE, "Close Pane")) { + action.kind = PaneMenuActionKind::Close; + } + ImGui::EndPopup(); + if (action.kind == PaneMenuActionKind::None) return std::nullopt; + return action; +} diff --git a/tools/jotpluggler/pluggle.py b/tools/jotpluggler/pluggle.py deleted file mode 100755 index 2fb6e3e2f4..0000000000 --- a/tools/jotpluggler/pluggle.py +++ /dev/null @@ -1,368 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import os -import dearpygui.dearpygui as dpg -import multiprocessing -import uuid -import signal -import yaml -from openpilot.common.swaglog import cloudlog -from openpilot.common.basedir import BASEDIR -from openpilot.tools.jotpluggler.data import DataManager -from openpilot.tools.jotpluggler.datatree import DataTree -from openpilot.tools.jotpluggler.layout import LayoutManager - -DEMO_ROUTE = "5beb9b58bd12b691/0000010a--a51155e496" - - -class WorkerManager: - def __init__(self, max_workers=None): - self.pool = multiprocessing.Pool(max_workers or min(4, multiprocessing.cpu_count()), initializer=WorkerManager.worker_initializer) - self.active_tasks = {} - - def submit_task(self, func, args_list, callback=None, task_id=None): - task_id = task_id or str(uuid.uuid4()) - - if task_id in self.active_tasks: - try: - self.active_tasks[task_id].terminate() - except Exception: - pass - - def handle_success(result): - self.active_tasks.pop(task_id, None) - if callback: - try: - callback(result) - except Exception as e: - print(f"Callback for task {task_id} failed: {e}") - - def handle_error(error): - self.active_tasks.pop(task_id, None) - print(f"Task {task_id} failed: {error}") - - async_result = self.pool.starmap_async(func, args_list, callback=handle_success, error_callback=handle_error) - self.active_tasks[task_id] = async_result - return task_id - - @staticmethod - def worker_initializer(): - signal.signal(signal.SIGINT, signal.SIG_IGN) - - def shutdown(self): - for task in self.active_tasks.values(): - try: - task.terminate() - except Exception: - pass - self.pool.terminate() - self.pool.join() - - -class PlaybackManager: - def __init__(self): - self.is_playing = False - self.current_time_s = 0.0 - self.duration_s = 0.0 - self.num_segments = 0 - - self.x_axis_bounds = (0.0, 0.0) # (min_time, max_time) - self.x_axis_observers = [] # callbacks for x-axis changes - self._updating_x_axis = False - - def set_route_duration(self, duration: float): - self.duration_s = duration - self.seek(min(self.current_time_s, duration)) - - def toggle_play_pause(self): - if not self.is_playing and self.current_time_s >= self.duration_s: - self.seek(0.0) - self.is_playing = not self.is_playing - texture_tag = "pause_texture" if self.is_playing else "play_texture" - dpg.configure_item("play_pause_button", texture_tag=texture_tag) - - def seek(self, time_s: float): - self.current_time_s = max(0.0, min(time_s, self.duration_s)) - - def update_time(self, delta_t: float): - if self.is_playing: - self.current_time_s = min(self.current_time_s + delta_t, self.duration_s) - if self.current_time_s >= self.duration_s: - self.is_playing = False - dpg.configure_item("play_pause_button", texture_tag="play_texture") - return self.current_time_s - - def set_x_axis_bounds(self, min_time: float, max_time: float, source_panel=None): - if self._updating_x_axis: - return - - new_bounds = (min_time, max_time) - if new_bounds == self.x_axis_bounds: - return - - self.x_axis_bounds = new_bounds - self._updating_x_axis = True # prevent recursive updates - - try: - for callback in self.x_axis_observers: - try: - callback(min_time, max_time, source_panel) - except Exception as e: - print(f"Error in x-axis sync callback: {e}") - finally: - self._updating_x_axis = False - - def add_x_axis_observer(self, callback): - if callback not in self.x_axis_observers: - self.x_axis_observers.append(callback) - - def remove_x_axis_observer(self, callback): - if callback in self.x_axis_observers: - self.x_axis_observers.remove(callback) - -class MainController: - def __init__(self, scale: float = 1.0): - self.scale = scale - self.data_manager = DataManager() - self.playback_manager = PlaybackManager() - self.worker_manager = WorkerManager() - self._create_global_themes() - self.data_tree = DataTree(self.data_manager, self.playback_manager) - self.layout_manager = LayoutManager(self.data_manager, self.playback_manager, self.worker_manager, scale=self.scale) - self.data_manager.add_observer(self.on_data_loaded) - self._total_segments = 0 - - def _create_global_themes(self): - with dpg.theme(tag="line_theme"): - with dpg.theme_component(dpg.mvLineSeries): - scaled_thickness = max(1.0, self.scale) - dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots) - - with dpg.theme(tag="timeline_theme"): - with dpg.theme_component(dpg.mvInfLineSeries): - scaled_thickness = max(1.0, self.scale) - dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots) - dpg.add_theme_color(dpg.mvPlotCol_Line, (255, 0, 0, 128), category=dpg.mvThemeCat_Plots) - - for tag, color in (("active_tab_theme", (37, 37, 38, 255)), ("inactive_tab_theme", (70, 70, 75, 255))): - with dpg.theme(tag=tag): - for cmp, target in ((dpg.mvChildWindow, dpg.mvThemeCol_ChildBg), (dpg.mvInputText, dpg.mvThemeCol_FrameBg), (dpg.mvImageButton, dpg.mvThemeCol_Button)): - with dpg.theme_component(cmp): - dpg.add_theme_color(target, color) - - with dpg.theme(tag="tab_bar_theme"): - with dpg.theme_component(dpg.mvChildWindow): - dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (51, 51, 55, 255)) - - def on_data_loaded(self, data: dict): - duration = data.get('duration', 0.0) - self.playback_manager.set_route_duration(duration) - - if data.get('metadata_loaded'): - self.playback_manager.num_segments = data.get('total_segments', 0) - self._total_segments = data.get('total_segments', 0) - dpg.set_value("load_status", f"Loading... 0/{self._total_segments} segments processed") - elif data.get('reset'): - self.playback_manager.current_time_s = 0.0 - self.playback_manager.duration_s = 0.0 - self.playback_manager.is_playing = False - self._total_segments = 0 - dpg.set_value("load_status", "Loading...") - dpg.set_value("timeline_slider", 0.0) - dpg.configure_item("timeline_slider", max_value=0.0) - dpg.configure_item("play_pause_button", texture_tag="play_texture") - dpg.configure_item("load_button", enabled=True) - elif data.get('loading_complete'): - num_paths = len(self.data_manager.get_all_paths()) - dpg.set_value("load_status", f"Loaded {num_paths} data paths") - dpg.configure_item("load_button", enabled=True) - elif data.get('segment_added'): - segment_count = data.get('segment_count', 0) - dpg.set_value("load_status", f"Loading... {segment_count}/{self._total_segments} segments processed") - - dpg.configure_item("timeline_slider", max_value=duration) - - def save_layout_to_yaml(self, filepath: str): - layout_dict = self.layout_manager.to_dict() - with open(filepath, 'w') as f: - yaml.dump(layout_dict, f, default_flow_style=False, sort_keys=False) - - def load_layout_from_yaml(self, filepath: str): - with open(filepath) as f: - layout_dict = yaml.safe_load(f) - self.layout_manager.clear_and_load_from_dict(layout_dict) - self.layout_manager.create_ui("main_plot_area") - - def save_layout_dialog(self): - if dpg.does_item_exist("save_layout_dialog"): - dpg.delete_item("save_layout_dialog") - with dpg.file_dialog( - callback=self._save_layout_callback, tag="save_layout_dialog", width=int(700 * self.scale), height=int(400 * self.scale), - default_filename="layout", default_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), "layouts") - ): - dpg.add_file_extension(".yaml") - - def load_layout_dialog(self): - if dpg.does_item_exist("load_layout_dialog"): - dpg.delete_item("load_layout_dialog") - with dpg.file_dialog( - callback=self._load_layout_callback, tag="load_layout_dialog", width=int(700 * self.scale), height=int(400 * self.scale), - default_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), "layouts") - ): - dpg.add_file_extension(".yaml") - - def _save_layout_callback(self, sender, app_data): - filepath = app_data['file_path_name'] - try: - self.save_layout_to_yaml(filepath) - dpg.set_value("load_status", f"Layout saved to {os.path.basename(filepath)}") - except Exception: - dpg.set_value("load_status", "Error saving layout") - cloudlog.exception(f"Error saving layout to {filepath}") - dpg.delete_item("save_layout_dialog") - - def _load_layout_callback(self, sender, app_data): - filepath = app_data['file_path_name'] - try: - self.load_layout_from_yaml(filepath) - dpg.set_value("load_status", f"Layout loaded from {os.path.basename(filepath)}") - except Exception: - dpg.set_value("load_status", "Error loading layout") - cloudlog.exception(f"Error loading layout from {filepath}:") - dpg.delete_item("load_layout_dialog") - - def setup_ui(self): - with dpg.texture_registry(): - script_dir = os.path.dirname(os.path.realpath(__file__)) - for image in ["play", "pause", "x", "split_h", "split_v", "plus"]: - texture = dpg.load_image(os.path.join(script_dir, "assets", f"{image}.png")) - dpg.add_static_texture(width=texture[0], height=texture[1], default_value=texture[3], tag=f"{image}_texture") - - with dpg.window(tag="Primary Window"): - with dpg.group(horizontal=True): - # Left panel - Data tree - with dpg.child_window(label="Sidebar", width=int(300 * self.scale), tag="sidebar_window", border=True, resizable_x=True): - with dpg.group(horizontal=True): - dpg.add_input_text(tag="route_input", width=int(-75 * self.scale), hint="Enter route name...") - dpg.add_button(label="Load", callback=self.load_route, tag="load_button", width=-1) - dpg.add_text("Ready to load route", tag="load_status") - dpg.add_separator() - - with dpg.table(header_row=False, policy=dpg.mvTable_SizingStretchProp): - dpg.add_table_column(init_width_or_weight=0.5) - dpg.add_table_column(init_width_or_weight=0.5) - with dpg.table_row(): - dpg.add_button(label="Save Layout", callback=self.save_layout_dialog, width=-1) - dpg.add_button(label="Load Layout", callback=self.load_layout_dialog, width=-1) - dpg.add_separator() - - self.data_tree.create_ui("sidebar_window") - - # Right panel - Plots and timeline - with dpg.group(tag="right_panel"): - with dpg.child_window(label="Plot Window", border=True, height=int(-(32 + 13 * self.scale)), tag="main_plot_area"): - self.layout_manager.create_ui("main_plot_area") - - with dpg.child_window(label="Timeline", border=True): - with dpg.table(header_row=False): - btn_size = int(13 * self.scale) - dpg.add_table_column(width_fixed=True, init_width_or_weight=(btn_size + 8)) # Play button - dpg.add_table_column(width_stretch=True) # Timeline slider - dpg.add_table_column(width_fixed=True, init_width_or_weight=int(50 * self.scale)) # FPS counter - with dpg.table_row(): - dpg.add_image_button(texture_tag="play_texture", tag="play_pause_button", callback=self.toggle_play_pause, width=btn_size, height=btn_size) - dpg.add_slider_float(tag="timeline_slider", default_value=0.0, label="", width=-1, callback=self.timeline_drag) - dpg.add_text("", tag="fps_counter") - with dpg.item_handler_registry(tag="plot_resize_handler"): - dpg.add_item_resize_handler(callback=self.on_plot_resize) - dpg.bind_item_handler_registry("right_panel", "plot_resize_handler") - - dpg.set_primary_window("Primary Window", True) - - def on_plot_resize(self, sender, app_data, user_data): - self.layout_manager.on_viewport_resize() - - def load_route(self): - route_name = dpg.get_value("route_input").strip() - if route_name: - dpg.set_value("load_status", "Loading route...") - dpg.configure_item("load_button", enabled=False) - self.data_manager.load_route(route_name) - - def toggle_play_pause(self, sender): - self.playback_manager.toggle_play_pause() - - def timeline_drag(self, sender, app_data): - self.playback_manager.seek(app_data) - - def update_frame(self, font): - self.data_tree.update_frame(font) - - new_time = self.playback_manager.update_time(dpg.get_delta_time()) - if not dpg.is_item_active("timeline_slider"): - dpg.set_value("timeline_slider", new_time) - - self.layout_manager.update_all_panels() - - dpg.set_value("fps_counter", f"{dpg.get_frame_rate():.1f} FPS") - - def shutdown(self): - self.worker_manager.shutdown() - - -def main(route_to_load=None, layout_to_load=None): - dpg.create_context() - - # TODO: find better way of calculating display scaling - #try: - # w, h = next(tuple(map(int, l.split()[0].split('x'))) for l in subprocess.check_output(['xrandr']).decode().split('\n') if '*' in l) # actual resolution - # scale = pyautogui.size()[0] / w # scaled resolution - #except Exception: - # scale = 1 - scale = 1 - - with dpg.font_registry(): - default_font = dpg.add_font(os.path.join(BASEDIR, "selfdrive/assets/fonts/JetBrainsMono-Medium.ttf"), int(13 * scale * 2)) # 2x then scale for hidpi - dpg.bind_font(default_font) - dpg.set_global_font_scale(0.5) - - viewport_width, viewport_height = int(1200 * scale), int(800 * scale) - dpg.create_viewport( - title='JotPluggler', width=viewport_width, height=viewport_height, - ) - dpg.setup_dearpygui() - - controller = MainController(scale=scale) - controller.setup_ui() - - if layout_to_load: - try: - controller.load_layout_from_yaml(layout_to_load) - print(f"Loaded layout from {layout_to_load}") - except Exception as e: - print(f"Failed to load layout from {layout_to_load}: {e}") - cloudlog.exception(f"Error loading layout from {layout_to_load}") - - if route_to_load: - dpg.set_value("route_input", route_to_load) - controller.load_route() - - dpg.show_viewport() - - # Main loop - try: - while dpg.is_dearpygui_running(): - controller.update_frame(default_font) - dpg.render_dearpygui_frame() - finally: - controller.shutdown() - dpg.destroy_context() - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="A tool for visualizing openpilot logs.") - parser.add_argument("--demo", action="store_true", help="Use the demo route instead of providing one") - parser.add_argument("--layout", type=str, help="Path to YAML layout file to load on startup") - parser.add_argument("route", nargs='?', default=None, help="Optional route name to load on startup.") - args = parser.parse_args() - route = DEMO_ROUTE if args.demo else args.route - main(route_to_load=route, layout_to_load=args.layout) diff --git a/tools/jotpluggler/render.cc b/tools/jotpluggler/render.cc new file mode 100644 index 0000000000..54f0c16cc3 --- /dev/null +++ b/tools/jotpluggler/render.cc @@ -0,0 +1,173 @@ +#include "tools/jotpluggler/internal.h" + +#include "imgui_impl_glfw.h" +#include "imgui_impl_opengl3.h" +#include "imgui_impl_opengl3_loader.h" + +#include + +namespace fs = std::filesystem; + +void draw_fps_overlay(const UiState &state, float top_offset) { + if (!state.show_fps_overlay) { + return; + } + ImGuiViewport *viewport = ImGui::GetMainViewport(); + const ImGuiIO &io = ImGui::GetIO(); + const float fps = io.Framerate; + const std::string label = util::string_format("%.1f fps", fps); + + const ImVec2 padding(10.0f, 8.0f); + const ImVec2 margin(12.0f, 10.0f); + app_push_mono_font(); + ImFont *font = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + const ImVec2 text_size = ImGui::CalcTextSize(label.c_str()); + app_pop_mono_font(); + const ImVec2 size(text_size.x + padding.x * 2.0f, text_size.y + padding.y * 2.0f); + const ImVec2 pos(viewport->Pos.x + viewport->Size.x - size.x - margin.x, + viewport->Pos.y + top_offset + margin.y); + ImDrawList *draw_list = ImGui::GetForegroundDrawList(viewport); + const ImVec2 max(pos.x + size.x, pos.y + size.y); + draw_list->AddRectFilled(pos, max, ImGui::GetColorU32(color_rgb(248, 249, 251, 0.92f)), 4.0f); + draw_list->AddRect(pos, max, ImGui::GetColorU32(color_rgb(182, 188, 196, 0.95f)), 4.0f); + draw_list->AddText(font, font_size, ImVec2(pos.x + padding.x, pos.y + padding.y), + ImGui::GetColorU32(color_rgb(57, 62, 69)), label.c_str(), nullptr); +} + +void render_layout(AppSession *session, UiState *state, bool show_camera_feed) { + if (!state->fps_overlay_initialized) { + state->show_fps_overlay = false; + state->fps_overlay_initialized = true; + } + ensure_shared_range(state, *session); + if (state->follow_latest) { + update_follow_range(state, *session); + state->suppress_range_side_effects = true; + } else { + clamp_shared_range(state, *session); + } + const bool ctrl = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeySuper; + const bool shift = ImGui::GetIO().KeyShift; + if (!ImGui::GetIO().WantTextInput && ctrl && ImGui::IsKeyPressed(ImGuiKey_Z, false)) { + if (shift) { + apply_redo(session, state); + } else { + apply_undo(session, state); + } + } + if (!ImGui::GetIO().WantTextInput && ctrl && ImGui::IsKeyPressed(ImGuiKey_F, false)) { + state->open_find_signal = true; + } + if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false)) { + step_tracker(state, -1.0); + } + if (ImGui::IsKeyPressed(ImGuiKey_RightArrow, false)) { + step_tracker(state, 1.0); + } + if (!ImGui::GetIO().WantTextInput && ImGui::IsKeyPressed(ImGuiKey_Space, false)) { + state->playback_playing = !state->playback_playing; + } + advance_playback(state, *session); + CameraFeedView *sidebar_camera = session->pane_camera_feeds[static_cast(sidebar_preview_camera_view(*session))].get(); + if (show_camera_feed && sidebar_camera != nullptr && state->has_tracker_time) { + sidebar_camera->update(state->tracker_time); + } + const float menu_height = draw_main_menu_bar(session, state); + UiMetrics ui = compute_ui_metrics(ImGui::GetMainViewport()->Size, menu_height, state->sidebar_width); + if (state->browser_nodes_dirty) { + rebuild_browser_nodes(session, state); + state->browser_nodes_dirty = false; + } + state->sidebar_width = ui.sidebar_width; + draw_sidebar(session, ui, state, show_camera_feed); + draw_workspace(session, ui, state); + draw_sidebar_resizer(ui, state); + if (!state->custom_series.selected && !state->logs.selected) { + draw_pane_windows(session, state); + } + draw_status_bar(*session, ui, state); + draw_popups(session, state); + draw_fps_overlay(*state, menu_height); +} + +void save_framebuffer_png(const fs::path &output_path, int width, int height) { + ensure_parent_dir(output_path); + if (width <= 0 || height <= 0) throw std::runtime_error("Invalid framebuffer size"); + + std::vector pixels(static_cast(width) * static_cast(height) * 4U, 0); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); + + const fs::path ppm_path = output_path.parent_path() / (output_path.stem().string() + ".ppm"); + std::string ppm = util::string_format("P6\n%d %d\n255\n", width, height); + ppm.reserve(ppm.size() + static_cast(width) * static_cast(height) * 3U); + for (int y = height - 1; y >= 0; --y) { + for (int x = 0; x < width; ++x) { + const size_t index = static_cast((y * width + x) * 4); + ppm.append(reinterpret_cast(&pixels[index]), 3); + } + } + write_file_or_throw(ppm_path, ppm.data(), ppm.size()); + + const std::string command = "convert " + shell_quote(ppm_path.string()) + " " + shell_quote(output_path.string()); + run_system_or_throw(command, "image conversion"); + fs::remove(ppm_path); +} + +void render_frame(GLFWwindow *window, AppSession *session, UiState *state, const fs::path *capture_path) { + glfwPollEvents(); + + int framebuffer_width = 0; + int framebuffer_height = 0; + glfwGetFramebufferSize(window, &framebuffer_width, &framebuffer_height); + + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplGlfw_NewFrame(); + ImGui::NewFrame(); + + if (state->request_save_layout) { + if (session->layout_path.empty()) { + state->open_save_layout = true; + } else { + save_layout(session, state, session->layout_path.string()); + } + state->request_save_layout = false; + } + if (state->request_reset_layout) { + reset_layout(session, state); + state->request_reset_layout = false; + } + poll_async_route_load(session, state); + if (session->data_mode == SessionDataMode::Stream && session->stream_poller) { + StreamExtractBatch batch; + std::string error_text; + if (session->stream_poller->consume(&batch, &error_text)) { + if (!error_text.empty()) { + state->error_text = error_text; + state->open_error_popup = true; + state->status_text = "Stream disconnected"; + } else { + apply_stream_batch(session, state, std::move(batch)); + } + } + } + + const bool show_camera = capture_path == nullptr && session->data_mode != SessionDataMode::Stream; + render_layout(session, state, show_camera); + ImGui::Render(); + if (state->request_close) { + glfwSetWindowShouldClose(window, GLFW_TRUE); + state->request_close = false; + } + + glViewport(0, 0, framebuffer_width, framebuffer_height); + glClearColor(227.0f / 255.0f, 229.0f / 255.0f, 233.0f / 255.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + if (capture_path != nullptr) { + save_framebuffer_png(*capture_path, framebuffer_width, framebuffer_height); + } + glfwSwapBuffers(window); + state->suppress_range_side_effects = false; +} diff --git a/tools/jotpluggler/runtime.cc b/tools/jotpluggler/runtime.cc new file mode 100644 index 0000000000..6eb2be80e8 --- /dev/null +++ b/tools/jotpluggler/runtime.cc @@ -0,0 +1,1278 @@ +#include "tools/jotpluggler/app.h" +#include "tools/jotpluggler/common.h" + +#include "cereal/services.h" +#include "common/timing.h" +#include "imgui_impl_glfw.h" +#include "imgui_impl_opengl3.h" +#include "imgui_impl_opengl3_loader.h" +#include "implot.h" +#include "libyuv.h" +#include "msgq_repo/msgq/ipc.h" +#include "tools/replay/framereader.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "system/camerad/cameras/nv12_info.h" + +namespace { + +std::atomic g_glfw_alive{false}; +const bool kLogCameraTimings = env_flag_enabled("JOTP_CAMERA_TIMINGS"); + +CameraType decoder_camera_type(CameraViewKind view) { + switch (view) { + case CameraViewKind::Driver: return DriverCam; + case CameraViewKind::WideRoad: return WideRoadCam; + case CameraViewKind::QRoad: return RoadCam; + case CameraViewKind::Road: + default: return RoadCam; + } +} + +bool stream_batch_has_data(const StreamExtractBatch &batch) { + return !batch.series.empty() + || !batch.can_messages.empty() + || !batch.logs.empty() + || !batch.timeline.empty() + || !batch.enum_info.empty() + || !batch.car_fingerprint.empty() + || !batch.dbc_name.empty(); +} + +bool should_subscribe_stream_service(const std::string &name) { + static const std::array kSkippedServices = {{ + "roadEncodeIdx", + "driverEncodeIdx", + "wideRoadEncodeIdx", + "qRoadEncodeIdx", + "roadEncodeData", + "driverEncodeData", + "wideRoadEncodeData", + "qRoadEncodeData", + "livestreamWideRoadEncodeIdx", + "livestreamRoadEncodeIdx", + "livestreamDriverEncodeIdx", + "thumbnail", + "navThumbnail", + }}; + if (name == "rawAudioData") return false; + for (std::string_view skipped : kSkippedServices) { + if (name == skipped) return false; + } + return true; +} + +void glfw_error_callback(int error, const char *description) { + const std::string_view desc = description != nullptr ? description : "unknown"; + if (error == 65539 && desc.find("Invalid window attribute 0x0002000D") != std::string_view::npos) { + return; + } + std::cerr << "GLFW error " << error << ": " << desc << "\n"; +} + +} // namespace + +GlfwRuntime::GlfwRuntime(const Options &options) { + glfwSetErrorCallback(glfw_error_callback); + if (!glfwInit()) throw std::runtime_error("glfwInit failed"); + g_glfw_alive.store(true); + + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); +#ifdef __APPLE__ + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); +#endif + const bool fixed_size = !options.show; + glfwWindowHint(GLFW_RESIZABLE, fixed_size ? GLFW_FALSE : GLFW_TRUE); + glfwWindowHint(GLFW_VISIBLE, options.show ? GLFW_TRUE : GLFW_FALSE); + + window_ = glfwCreateWindow(options.width, options.height, "jotpluggler", nullptr, nullptr); + if (window_ == nullptr) { + glfwTerminate(); + throw std::runtime_error("glfwCreateWindow failed"); + } + + if (fixed_size) { + glfwSetWindowSizeLimits(window_, options.width, options.height, options.width, options.height); + } + glfwMakeContextCurrent(window_); + glfwSwapInterval(options.show ? 1 : 0); +} + +GlfwRuntime::~GlfwRuntime() { + if (window_ != nullptr) { + glfwDestroyWindow(window_); + } + g_glfw_alive.store(false); + glfwTerminate(); +} + +GLFWwindow *GlfwRuntime::window() const { + return window_; +} + +ImGuiRuntime::ImGuiRuntime(GLFWwindow *window) { + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImPlot::CreateContext(); + + ImGuiIO &io = ImGui::GetIO(); + io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; + io.IniFilename = nullptr; + io.LogFilename = nullptr; + + if (!ImGui_ImplGlfw_InitForOpenGL(window, true)) { + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + throw std::runtime_error("ImGui_ImplGlfw_InitForOpenGL failed"); + } + if (!ImGui_ImplOpenGL3_Init("#version 330")) { + ImGui_ImplGlfw_Shutdown(); + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + throw std::runtime_error("ImGui_ImplOpenGL3_Init failed"); + } +} + +ImGuiRuntime::~ImGuiRuntime() { + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplGlfw_Shutdown(); + ImPlot::DestroyContext(); + ImGui::DestroyContext(); +} + +struct TerminalRouteProgress::Impl { + explicit Impl(bool enabled) : enabled_(enabled) {} + + void update(const RouteLoadProgress &progress) { + std::lock_guard lock(mutex_); + if (!enabled_) { + return; + } + + double overall = 0.0; + std::string detail = "Resolving route"; + if (progress.stage == RouteLoadStage::Finished) { + overall = 1.0; + detail = "Ready"; + } else if (progress.total_segments > 0) { + const bool finalizing = progress.segments_downloaded >= progress.total_segments + && progress.segments_parsed >= progress.total_segments; + if (finalizing) { + overall = 0.99; + detail = "Finalizing route data"; + } else { + const double total_work = static_cast(progress.total_segments) * 2.0; + const double complete_work = static_cast(progress.segments_downloaded + progress.segments_parsed); + overall = total_work <= 0.0 ? 0.0 : std::clamp(complete_work / total_work, 0.0, 0.99); + std::ostringstream desc; + desc << "Downloaded " << progress.segments_downloaded << "/" << progress.total_segments + << " Parsed " << progress.segments_parsed << "/" << progress.total_segments; + detail = desc.str(); + } + } + + render(overall, detail); + } + + void finish() { + std::lock_guard lock(mutex_); + if (!enabled_ || !rendered_) { + return; + } + render(1.0, "Ready"); + std::fputc('\n', stderr); + std::fflush(stderr); + rendered_ = false; + } + + void render(double progress, const std::string &detail) { + const int percent = std::clamp(static_cast(std::round(progress * 100.0)), 0, 100); + if (percent == last_percent_ && detail == last_detail_) { + return; + } + + constexpr int kWidth = 20; + const int filled = std::clamp(static_cast(std::round(progress * kWidth)), 0, kWidth); + const std::string bar = std::string(static_cast(filled), '=') + std::string(static_cast(kWidth - filled), ' '); + std::ostringstream line; + line << "\r[" << bar << "] " << percent << "% " << detail; + + const std::string text = line.str(); + std::fprintf(stderr, "%s", text.c_str()); + if (text.size() < last_line_width_) { + std::fprintf(stderr, "%s", std::string(last_line_width_ - text.size(), ' ').c_str()); + } + std::fflush(stderr); + + rendered_ = true; + last_percent_ = percent; + last_detail_ = detail; + last_line_width_ = text.size(); + } + + bool enabled_ = false; + bool rendered_ = false; + int last_percent_ = -1; + size_t last_line_width_ = 0; + std::string last_detail_; + std::mutex mutex_; +}; + +TerminalRouteProgress::TerminalRouteProgress(bool enabled) + : impl_(std::make_unique(enabled)) {} + +TerminalRouteProgress::~TerminalRouteProgress() { + finish(); +} + +void TerminalRouteProgress::update(const RouteLoadProgress &progress) { + impl_->update(progress); +} + +void TerminalRouteProgress::finish() { + impl_->finish(); +} + +struct AsyncRouteLoader::Impl { + explicit Impl(bool enable_terminal_progress) + : terminal_progress(enable_terminal_progress) {} + + ~Impl() { + join(); + } + + void start(const std::string &route_name_value, const std::string &data_dir_value, const std::string &dbc_name_value) { + join(); + { + std::lock_guard lock(mutex); + route_name = route_name_value; + data_dir = data_dir_value; + dbc_name = dbc_name_value; + result.reset(); + error_text.clear(); + } + active.store(!route_name_value.empty()); + completed.store(route_name_value.empty()); + success.store(route_name_value.empty()); + total_segments.store(0); + segments_downloaded.store(0); + segments_parsed.store(0); + if (route_name_value.empty()) { + return; + } + + worker = std::thread([this]() { + try { + RouteData route_data = load_route_data(route_name, data_dir, dbc_name, [this](const RouteLoadProgress &progress) { + total_segments.store(progress.total_segments > 0 ? progress.total_segments : progress.segment_count); + segments_downloaded.store(progress.segments_downloaded); + segments_parsed.store(progress.segments_parsed); + terminal_progress.update(progress); + }); + { + std::lock_guard lock(mutex); + result = std::make_unique(std::move(route_data)); + error_text.clear(); + } + success.store(true); + } catch (const std::exception &err) { + std::lock_guard lock(mutex); + result.reset(); + error_text = err.what(); + success.store(false); + } + active.store(false); + completed.store(true); + terminal_progress.finish(); + }); + } + + RouteLoadSnapshot snapshot() const { + RouteLoadSnapshot snapshot; + snapshot.active = active.load(); + snapshot.total_segments = total_segments.load(); + snapshot.segments_downloaded = segments_downloaded.load(); + snapshot.segments_parsed = segments_parsed.load(); + return snapshot; + } + + bool consume(RouteData *route_data, std::string *error_text_out) { + if (!completed.load()) return false; + join(); + std::lock_guard lock(mutex); + completed.store(false); + if (result) { + *route_data = std::move(*result); + result.reset(); + if (error_text_out != nullptr) { + error_text_out->clear(); + } + return true; + } + if (error_text_out != nullptr) { + *error_text_out = error_text; + } + return true; + } + + void join() { + if (worker.joinable()) { + worker.join(); + } + } + + mutable std::mutex mutex; + std::thread worker; + std::unique_ptr result; + std::string route_name; + std::string data_dir; + std::string dbc_name; + std::string error_text; + std::atomic active{false}; + std::atomic completed{false}; + std::atomic success{false}; + std::atomic total_segments{0}; + std::atomic segments_downloaded{0}; + std::atomic segments_parsed{0}; + TerminalRouteProgress terminal_progress; +}; + +AsyncRouteLoader::AsyncRouteLoader(bool enable_terminal_progress) + : impl_(std::make_unique(enable_terminal_progress)) {} + +AsyncRouteLoader::~AsyncRouteLoader() = default; + +void AsyncRouteLoader::start(const std::string &route_name, const std::string &data_dir, const std::string &dbc_name) { + impl_->start(route_name, data_dir, dbc_name); +} + +RouteLoadSnapshot AsyncRouteLoader::snapshot() const { + return impl_->snapshot(); +} + +bool AsyncRouteLoader::consume(RouteData *route_data, std::string *error_text) { + return impl_->consume(route_data, error_text); +} + +struct StreamPoller::Impl { + ~Impl() { + stop(); + } + + void start(const StreamSourceConfig &requested_source, + double requested_buffer_seconds, + const std::string &dbc_name, + std::optional time_offset) { + stop(); + { + std::lock_guard lock(mutex); + pending = {}; + pending_series_slots.clear(); + pending_can_slots.clear(); + error_text.clear(); + source = requested_source; + if (source.kind == StreamSourceKind::CerealLocal) { + source.address = "127.0.0.1"; + } else if (source.kind == StreamSourceKind::CerealRemote) { + source.address = normalize_stream_address(source.address); + } + buffer_seconds = std::max(1.0, requested_buffer_seconds); + latest_dbc_name = dbc_name; + latest_car_fingerprint.clear(); + } + received_messages.store(0); + connected.store(false); + paused.store(false); + running.store(true); + worker = std::thread([this, dbc_name, time_offset]() { + try { + StreamAccumulator accumulator(dbc_name, time_offset); + switch (source.kind) { + case StreamSourceKind::CerealLocal: + case StreamSourceKind::CerealRemote: + run_cereal_source(&accumulator); + break; + } + } catch (const std::exception &err) { + std::lock_guard lock(mutex); + error_text = err.what(); + } + connected.store(false); + running.store(false); + }); + } + + void setPaused(bool paused_value) { + paused.store(paused_value); + if (paused_value) { + std::lock_guard lock(mutex); + pending = {}; + pending_series_slots.clear(); + pending_can_slots.clear(); + error_text.clear(); + } + } + + void set_error_text(std::string text) { + std::lock_guard lock(mutex); + error_text = std::move(text); + } + + void clear_error_text() { + std::lock_guard lock(mutex); + error_text.clear(); + } + + void stop() { + running.store(false); + paused.store(false); + if (worker.joinable()) { + worker.join(); + } + connected.store(false); + } + + StreamPollSnapshot snapshot() const { + StreamPollSnapshot out; + out.active = running.load(); + out.connected = connected.load(); + out.paused = paused.load(); + out.source_kind = source.kind; + out.source_label = stream_source_target_label(source); + out.buffer_seconds = buffer_seconds; + out.received_messages = received_messages.load(); + std::lock_guard lock(mutex); + out.dbc_name = latest_dbc_name; + out.car_fingerprint = latest_car_fingerprint; + return out; + } + + bool consume(StreamExtractBatch *batch, std::string *out_error_text) { + std::lock_guard lock(mutex); + const bool has_error = !error_text.empty(); + const bool has_batch = stream_batch_has_data(pending); + if (!has_error && !has_batch) return false; + if (batch != nullptr) { + *batch = std::move(pending); + pending = {}; + pending_series_slots.clear(); + pending_can_slots.clear(); + } + if (out_error_text != nullptr) { + *out_error_text = error_text; + error_text.clear(); + } + return true; + } + + static void merge_route_series(RouteSeries *dst, RouteSeries *src) { + if (src->times.empty()) { + return; + } + if (dst->path.empty()) { + dst->path = src->path; + } + dst->times.insert(dst->times.end(), src->times.begin(), src->times.end()); + dst->values.insert(dst->values.end(), src->values.begin(), src->values.end()); + } + + static void merge_can_message_data(CanMessageData *dst, CanMessageData *src) { + if (src->samples.empty()) { + return; + } + if (dst->samples.empty()) { + *dst = std::move(*src); + return; + } + dst->samples.insert(dst->samples.end(), + std::make_move_iterator(src->samples.begin()), + std::make_move_iterator(src->samples.end())); + } + + static void merge_batch(StreamExtractBatch *dst, + std::unordered_map *series_slots, + std::unordered_map *can_slots, + StreamExtractBatch *src) { + for (RouteSeries &series : src->series) { + auto [it, inserted] = series_slots->try_emplace(series.path, dst->series.size()); + if (inserted) { + dst->series.push_back(RouteSeries{.path = series.path}); + } + merge_route_series(&dst->series[it->second], &series); + } + for (CanMessageData &message : src->can_messages) { + auto [it, inserted] = can_slots->try_emplace(message.id, dst->can_messages.size()); + if (inserted) { + dst->can_messages.push_back(CanMessageData{.id = message.id}); + } + merge_can_message_data(&dst->can_messages[it->second], &message); + } + if (!src->logs.empty()) { + dst->logs.insert(dst->logs.end(), + std::make_move_iterator(src->logs.begin()), + std::make_move_iterator(src->logs.end())); + } + if (!src->timeline.empty()) { + dst->timeline.insert(dst->timeline.end(), + std::make_move_iterator(src->timeline.begin()), + std::make_move_iterator(src->timeline.end())); + } + for (auto &[path, info] : src->enum_info) { + dst->enum_info[path] = std::move(info); + } + if (!src->car_fingerprint.empty()) { + dst->car_fingerprint = src->car_fingerprint; + } + if (!src->dbc_name.empty()) { + dst->dbc_name = src->dbc_name; + } + } + + void publish_batch(StreamAccumulator *accumulator) { + StreamExtractBatch batch = accumulator->takeBatch(); + if (!stream_batch_has_data(batch)) { + return; + } + std::lock_guard lock(mutex); + merge_batch(&pending, &pending_series_slots, &pending_can_slots, &batch); + latest_dbc_name = pending.dbc_name; + latest_car_fingerprint = pending.car_fingerprint; + } + + void run_cereal_source(StreamAccumulator *accumulator) { + if (source.kind == StreamSourceKind::CerealRemote) { + setenv("ZMQ", "1", 1); + } else { + unsetenv("ZMQ"); + } + + std::unique_ptr context(Context::create()); + std::unique_ptr poller(Poller::create()); + std::vector> sockets; + sockets.reserve(services.size()); + for (const auto &[name, info] : services) { + if (!should_subscribe_stream_service(name)) continue; + std::unique_ptr socket( + SubSocket::create(context.get(), name.c_str(), source.address.c_str(), false, true, info.queue_size)); + if (socket == nullptr) continue; + socket->setTimeout(0); + poller->registerSocket(socket.get()); + sockets.push_back(std::move(socket)); + } + if (sockets.empty()) throw std::runtime_error("Failed to connect to any cereal service"); + connected.store(true); + + while (running.load()) { + std::vector ready = poller->poll(1); + for (SubSocket *socket : ready) { + while (running.load()) { + std::unique_ptr msg(socket->receive(true)); + if (!msg) break; + const size_t size = msg->getSize(); + if (size < sizeof(capnp::word) || (size % sizeof(capnp::word)) != 0) { + continue; + } + if (paused.load()) { + received_messages.fetch_add(1); + continue; + } + kj::ArrayPtr data(reinterpret_cast(msg->getData()), + size / sizeof(capnp::word)); + accumulator->appendEvent(data); + received_messages.fetch_add(1); + } + } + publish_batch(accumulator); + } + } + + mutable std::mutex mutex; + std::thread worker; + std::atomic running{false}; + std::atomic connected{false}; + std::atomic paused{false}; + std::atomic received_messages{0}; + StreamExtractBatch pending; + std::unordered_map pending_series_slots; + std::unordered_map pending_can_slots; + std::string error_text; + StreamSourceConfig source; + std::string latest_dbc_name; + std::string latest_car_fingerprint; + double buffer_seconds = 30.0; +}; + +StreamPoller::StreamPoller() + : impl_(std::make_unique()) {} + +StreamPoller::~StreamPoller() = default; + +void StreamPoller::start(const StreamSourceConfig &source, + double buffer_seconds, + const std::string &dbc_name, + std::optional time_offset) { + impl_->start(source, buffer_seconds, dbc_name, time_offset); +} + +void StreamPoller::setPaused(bool paused) { + impl_->setPaused(paused); +} + +void StreamPoller::stop() { + impl_->stop(); +} + +StreamPollSnapshot StreamPoller::snapshot() const { + return impl_->snapshot(); +} + +bool StreamPoller::consume(StreamExtractBatch *batch, std::string *error_text) { + return impl_->consume(batch, error_text); +} + +struct CameraFeedView::Impl { + struct RequestKey { + int segment = -1; + int decode_index = -1; + }; + + struct DecodeRequest { + RequestKey key; + std::string path; + uint64_t serial = 0; + uint64_t generation = 0; + }; + + struct PreloadTask { + int segment = -1; + std::string path; + uint64_t generation = 0; + }; + + struct DecodeResult { + RequestKey key; + bool success = false; + int width = 0; + int height = 0; + double decode_ms = 0.0; + std::vector rgba; + }; + + static constexpr float kDefaultAspect = 1208.0f / 1928.0f; + static constexpr size_t kCachedFrames = 8; + static constexpr int kPrefetchAhead = 2; + static constexpr int kImmediateNearbyFrameDistance = 8; + static constexpr int kPreloadWorkerCount = 2; + + Impl() { + demand_worker = std::thread([this]() { demand_worker_loop(); }); + for (int i = 0; i < kPreloadWorkerCount; ++i) { + preload_workers.emplace_back([this]() { preload_worker_loop(); }); + } + } + + ~Impl() { + stop.store(true); + cv.notify_all(); + if (demand_worker.joinable()) { + demand_worker.join(); + } + for (std::thread &worker : preload_workers) { + if (worker.joinable()) { + worker.join(); + } + } + destroy_texture(); + } + + void setRouteData(const RouteData &route_data) { + setCameraIndex(route_data.road_camera, CameraViewKind::Road); + } + + void setCameraIndex(const CameraFeedIndex &camera_index, CameraViewKind view) { + destroy_texture(); + { + std::lock_guard lock(mutex); + route_index = camera_index; + camera_view = view; + pending_request.reset(); + pending_result.reset(); + cached_results.clear(); + preload_queue.clear(); + preload_focus_segment = -1; + ++route_generation; + latest_request_serial = 0; + int initial_focus_segment = -1; + if (!route_index.entries.empty()) { + initial_focus_segment = route_index.entries.front().segment; + } else { + for (const CameraSegmentFile &segment_file : route_index.segment_files) { + if (!segment_file.path.empty()) { + initial_focus_segment = segment_file.segment; + break; + } + } + } + if (initial_focus_segment >= 0) { + rebuild_preload_queue_locked(initial_focus_segment); + } + } + abort_demand_work.store(true); + abort_preload_work.store(true); + active_request.reset(); + displayed_request.reset(); + failed_request.reset(); + frame_width = 0; + frame_height = 0; + cv.notify_all(); + } + + void update(double tracker_time) { + upload_pending_result(); + std::optional request = request_for_time(tracker_time); + if (!request.has_value()) { + return; + } + if (same_request(active_request, request->key) || same_request(displayed_request, request->key) || + same_request(failed_request, request->key)) { + return; + } + if (try_upload_cached_result(request->key)) { + return; + } + try_upload_nearby_cached_result(request->key); + + bool focus_changed = false; + { + std::lock_guard lock(mutex); + if (preload_focus_segment != request->key.segment) { + rebuild_preload_queue_locked(request->key.segment); + focus_changed = true; + } + request->serial = ++latest_request_serial; + request->generation = route_generation; + pending_request = request; + } + abort_demand_work.store(true); + if (focus_changed) { + abort_preload_work.store(true); + } + active_request = request->key; + failed_request.reset(); + cv.notify_all(); + } + + void draw(float width, bool loading) { + const float preview_width = std::max(1.0f, width); + const float preview_height = preview_width * preview_aspect(); + drawSized(ImVec2(preview_width, preview_height), loading, false); + ImGui::Spacing(); + } + + void drawSized(ImVec2 size, bool loading, bool fit_to_pane) { + size.x = std::max(1.0f, size.x); + size.y = std::max(1.0f, size.y); + const float aspect = preview_aspect(); + ImVec2 frame_size = size; + ImVec2 top_left = ImGui::GetCursorScreenPos(); + ImVec2 uv0(0.0f, 0.0f); + ImVec2 uv1(1.0f, 1.0f); + if (aspect > 0.0f && !fit_to_pane) { + frame_size.y = std::min(size.y, size.x * aspect); + frame_size.x = std::min(size.x, frame_size.y / aspect); + top_left = ImVec2(top_left.x + (size.x - frame_size.x) * 0.5f, + top_left.y + (size.y - frame_size.y) * 0.5f); + } else if (aspect > 0.0f && fit_to_pane) { + const float src_aspect = 1.0f / aspect; + const float dst_aspect = size.x / size.y; + if (dst_aspect > src_aspect) { + const float visible_v = std::clamp(src_aspect / dst_aspect, 0.0f, 1.0f); + const float v_pad = (1.0f - visible_v) * 0.5f; + uv0.y = v_pad; + uv1.y = 1.0f - v_pad; + } else if (dst_aspect < src_aspect) { + const float visible_u = std::clamp(dst_aspect / src_aspect, 0.0f, 1.0f); + const float u_pad = (1.0f - visible_u) * 0.5f; + uv0.x = u_pad; + uv1.x = 1.0f - u_pad; + } + } + ImGui::InvisibleButton("##camera_feed_sized", size); + if (texture != 0) { + ImGui::GetWindowDrawList()->AddImage(static_cast(texture), + top_left, + ImVec2(top_left.x + frame_size.x, top_left.y + frame_size.y), + uv0, + uv1); + } else { + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + draw_list->AddRectFilled(top_left, ImVec2(top_left.x + frame_size.x, top_left.y + frame_size.y), IM_COL32(45, 47, 50, 255)); + draw_list->AddRect(top_left, ImVec2(top_left.x + frame_size.x, top_left.y + frame_size.y), IM_COL32(95, 100, 106, 255)); + + const char *label = (loading || has_video_source()) ? "loading" : "no video"; + const ImVec2 text_size = ImGui::CalcTextSize(label); + const ImVec2 text_pos(top_left.x + (frame_size.x - text_size.x) * 0.5f, + top_left.y + (frame_size.y - text_size.y) * 0.5f); + draw_list->AddText(text_pos, IM_COL32(187, 187, 187, 255), label); + } + } + + static bool same_request(const std::optional &lhs, const RequestKey &rhs) { + return lhs.has_value() && lhs->segment == rhs.segment && lhs->decode_index == rhs.decode_index; + } + + bool has_video_source() const { + std::lock_guard lock(mutex); + return !route_index.entries.empty() && !route_index.segment_files.empty(); + } + + float preview_aspect() const { + if (frame_width > 0 && frame_height > 0) return static_cast(frame_height) / static_cast(frame_width); + return kDefaultAspect; + } + + std::optional request_for_time(double tracker_time) const { + std::lock_guard lock(mutex); + if (route_index.entries.empty()) return std::nullopt; + + auto it = std::lower_bound(route_index.entries.begin(), route_index.entries.end(), tracker_time, + [](const CameraFrameIndexEntry &entry, double tm) { + return entry.timestamp < tm; + }); + if (it == route_index.entries.end()) { + it = std::prev(route_index.entries.end()); + } else if (it != route_index.entries.begin()) { + const auto prev = std::prev(it); + if (std::abs(prev->timestamp - tracker_time) <= std::abs(it->timestamp - tracker_time)) { + it = prev; + } + } + + auto path_it = std::find_if(route_index.segment_files.begin(), route_index.segment_files.end(), + [&](const CameraSegmentFile &segment) { + return segment.segment == it->segment && !segment.path.empty(); + }); + if (path_it == route_index.segment_files.end()) return std::nullopt; + + return DecodeRequest{ + .key = RequestKey{.segment = it->segment, .decode_index = it->decode_index}, + .path = path_it->path, + }; + } + + void upload_pending_result() { + std::optional result; + { + std::lock_guard lock(mutex); + if (!pending_result.has_value()) { + return; + } + result = std::move(pending_result); + pending_result.reset(); + } + + active_request.reset(); + if (!result->success || result->rgba.empty() || result->width <= 0 || result->height <= 0) { + failed_request = result->key; + return; + } + + upload_result(*result); + } + + void upload_result(const DecodeResult &result) { + remember_cached_result(result); + + const bool new_size = texture_width != result.width || texture_height != result.height; + if (texture == 0) { + glGenTextures(1, &texture); + } + glBindTexture(GL_TEXTURE_2D, texture); + if (new_size) { + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, result.width, result.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, result.rgba.data()); + texture_width = result.width; + texture_height = result.height; + } else { + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, result.width, result.height, GL_RGBA, GL_UNSIGNED_BYTE, result.rgba.data()); + } + glBindTexture(GL_TEXTURE_2D, 0); + + frame_width = result.width; + frame_height = result.height; + displayed_request = result.key; + failed_request.reset(); + } + + bool try_upload_cached_result(const RequestKey &key) { + std::optional result; + { + std::lock_guard lock(mutex); + auto it = std::find_if(cached_results.begin(), cached_results.end(), [&](const DecodeResult &cached) { + return cached.key.segment == key.segment && cached.key.decode_index == key.decode_index; + }); + if (it == cached_results.end()) { + return false; + } + result = *it; + } + active_request.reset(); + upload_result(*result); + return true; + } + + bool try_upload_nearby_cached_result(const RequestKey &key) { + std::optional result; + int best_distance = std::numeric_limits::max(); + { + std::lock_guard lock(mutex); + for (const DecodeResult &cached : cached_results) { + if (cached.key.segment != key.segment) continue; + const int distance = std::abs(cached.key.decode_index - key.decode_index); + if (distance == 0 || distance > kImmediateNearbyFrameDistance || distance >= best_distance) continue; + best_distance = distance; + result = cached; + } + } + if (!result.has_value()) { + return false; + } + upload_result(*result); + return true; + } + + void remember_cached_result(const DecodeResult &result) { + std::lock_guard lock(mutex); + auto it = std::find_if(cached_results.begin(), cached_results.end(), [&](const DecodeResult &cached) { + return cached.key.segment == result.key.segment && cached.key.decode_index == result.key.decode_index; + }); + if (it != cached_results.end()) { + cached_results.erase(it); + } + cached_results.push_front(result); + while (cached_results.size() > kCachedFrames) { + cached_results.pop_back(); + } + } + + void destroy_texture() { + if (texture != 0 && g_glfw_alive.load() && glfwGetCurrentContext() != nullptr) { + glDeleteTextures(1, &texture); + } + texture = 0; + texture_width = 0; + texture_height = 0; + frame_width = 0; + frame_height = 0; + } + + static bool ensure_decode_buffer(FrameReader *reader, VisionBuf *buf, bool &allocated, int &w, int &h) { + if (!reader) return false; + if (allocated && w == reader->width && h == reader->height) return true; + if (allocated) { buf->free(); allocated = false; } + const auto [stride, y_height, _uv_height, size] = get_nv12_info(reader->width, reader->height); + buf->allocate(size); + buf->init_yuv(reader->width, reader->height, stride, stride * y_height); + w = reader->width; + h = reader->height; + allocated = true; + return true; + } + + void publish_result(const DecodeRequest &request, DecodeResult result) { + std::lock_guard lock(mutex); + if (!pending_request.has_value() || pending_request->serial != request.serial || + pending_request->generation != request.generation) { + return; + } + pending_result = std::move(result); + } + + bool has_newer_pending_request(uint64_t serial) const { + std::lock_guard lock(mutex); + return pending_request.has_value() && pending_request->serial != serial; + } + + void rebuild_preload_queue_locked(int focus_segment) { + std::vector ordered; + ordered.reserve(route_index.segment_files.size()); + for (const CameraSegmentFile &segment_file : route_index.segment_files) { + if (segment_file.path.empty()) continue; + if (segment_file.segment == focus_segment) continue; + ordered.push_back(PreloadTask{ + .segment = segment_file.segment, + .path = segment_file.path, + .generation = route_generation, + }); + } + std::sort(ordered.begin(), ordered.end(), [&](const PreloadTask &a, const PreloadTask &b) { + const int distance_a = std::abs(a.segment - focus_segment); + const int distance_b = std::abs(b.segment - focus_segment); + if (distance_a != distance_b) return distance_a < distance_b; + return a.segment < b.segment; + }); + preload_queue.assign(ordered.begin(), ordered.end()); + preload_focus_segment = focus_segment; + } + + std::shared_ptr find_loaded_reader_locked(int segment, uint64_t generation) { + if (readers_generation != generation) { + readers.clear(); + loading_segments.clear(); + readers_generation = generation; + } + auto it = readers.find(segment); + return it != readers.end() ? it->second : nullptr; + } + + std::shared_ptr ensure_reader_loaded(int segment, + const std::string &path, + uint64_t generation, + const char *reason, + std::atomic *abort_flag, + bool wait_for_inflight) { + while (!stop.load()) { + { + std::unique_lock lock(readers_mutex); + if (std::shared_ptr cached = find_loaded_reader_locked(segment, generation)) { + return cached; + } + if (loading_segments.find(segment) != loading_segments.end()) { + if (!wait_for_inflight) { + return nullptr; + } + readers_cv.wait(lock, [&]() { + return stop.load() + || readers_generation != generation + || loading_segments.find(segment) == loading_segments.end(); + }); + continue; + } + loading_segments.insert(segment); + } + + auto reader = std::make_shared(); + const auto load_begin = std::chrono::steady_clock::now(); + const bool loaded = reader->load(decoder_camera_type(camera_view), path, false, abort_flag, true); + + { + std::lock_guard lock(readers_mutex); + if (readers_generation != generation) { + readers.clear(); + loading_segments.clear(); + readers_generation = generation; + } + loading_segments.erase(segment); + if (loaded) { + readers[segment] = reader; + } + } + readers_cv.notify_all(); + + if (!loaded) { + return nullptr; + } + if (kLogCameraTimings) { + const double load_ms = std::chrono::duration(std::chrono::steady_clock::now() - load_begin).count(); + std::fprintf(stderr, "camera[%s] %s-load seg=%d %.1fms\n", + camera_view_spec(camera_view).runtime_name, reason, segment, load_ms); + } + return reader; + } + return nullptr; + } + + void preload_worker_loop() { + while (true) { + std::optional preload; + { + std::unique_lock lock(mutex); + cv.wait(lock, [&]() { return stop.load() || !preload_queue.empty(); }); + if (stop.load()) { + break; + } + preload = preload_queue.front(); + preload_queue.pop_front(); + } + + abort_preload_work.store(false); + { + std::lock_guard lock(readers_mutex); + if (find_loaded_reader_locked(preload->segment, preload->generation)) { + continue; + } + } + ensure_reader_loaded(preload->segment, preload->path, preload->generation, "preload", + &abort_preload_work, false); + } + } + + void demand_worker_loop() { + uint64_t handled_serial = 0; + VisionBuf decode_buffer; + bool buffer_allocated = false; + int buffer_width = 0; + int buffer_height = 0; + + while (true) { + std::optional request; + { + std::unique_lock lock(mutex); + cv.wait(lock, [&]() { + return stop.load() || (pending_request.has_value() && pending_request->serial != handled_serial); + }); + if (stop.load()) break; + request = *pending_request; + handled_serial = request->serial; + } + + abort_demand_work.store(false); + + DecodeResult result{.key = request->key}; + std::shared_ptr reader = ensure_reader_loaded(request->key.segment, request->path, + request->generation, "demand", + &abort_demand_work, true); + if (!reader) { + publish_result(*request, std::move(result)); + continue; + } + if (has_newer_pending_request(request->serial)) { + continue; + } + + const auto decode_begin = std::chrono::steady_clock::now(); + if (!ensure_decode_buffer(reader.get(), &decode_buffer, buffer_allocated, buffer_width, buffer_height) || + !reader->get(request->key.decode_index, &decode_buffer)) { + publish_result(*request, std::move(result)); + continue; + } + + result.width = reader->width; + result.height = reader->height; + result.rgba.resize(static_cast(result.width) * static_cast(result.height) * 4U, 0); + libyuv::NV12ToABGR(decode_buffer.y, + static_cast(decode_buffer.stride), + decode_buffer.uv, + static_cast(decode_buffer.stride), + result.rgba.data(), + result.width * 4, + result.width, + result.height); + result.success = true; + result.decode_ms = std::chrono::duration(std::chrono::steady_clock::now() - decode_begin).count(); + publish_result(*request, std::move(result)); + + for (int offset = 1; offset <= kPrefetchAhead; ++offset) { + if (stop.load() || has_newer_pending_request(request->serial)) { + break; + } + const int next_index = request->key.decode_index + offset; + if (next_index < 0 || next_index >= static_cast(reader->getFrameCount())) { + break; + } + if (!reader->get(next_index, &decode_buffer)) { + break; + } + DecodeResult prefetched{ + .key = RequestKey{.segment = request->key.segment, .decode_index = next_index}, + .success = true, + .width = reader->width, + .height = reader->height, + }; + prefetched.rgba.resize(static_cast(prefetched.width) * static_cast(prefetched.height) * 4U, 0); + libyuv::NV12ToABGR(decode_buffer.y, + static_cast(decode_buffer.stride), + decode_buffer.uv, + static_cast(decode_buffer.stride), + prefetched.rgba.data(), + prefetched.width * 4, + prefetched.width, + prefetched.height); + remember_cached_result(prefetched); + } + } + + if (buffer_allocated) { + decode_buffer.free(); + } + } + + mutable std::mutex mutex; + std::condition_variable cv; + std::thread demand_worker; + std::vector preload_workers; + std::atomic stop{false}; + std::atomic abort_demand_work{false}; + std::atomic abort_preload_work{false}; + CameraFeedIndex route_index; + CameraViewKind camera_view = CameraViewKind::Road; + std::optional pending_request; + std::optional pending_result; + std::deque preload_queue; + int preload_focus_segment = -1; + std::deque cached_results; + uint64_t latest_request_serial = 0; + uint64_t route_generation = 1; + std::optional active_request; + std::optional displayed_request; + std::optional failed_request; + std::mutex readers_mutex; + std::condition_variable readers_cv; + std::unordered_map> readers; + std::unordered_set loading_segments; + uint64_t readers_generation = 0; + GLuint texture = 0; + int texture_width = 0; + int texture_height = 0; + int frame_width = 0; + int frame_height = 0; +}; + +CameraFeedView::CameraFeedView() + : impl_(std::make_unique()) {} + +CameraFeedView::~CameraFeedView() = default; + +void CameraFeedView::setRouteData(const RouteData &route_data) { + impl_->setRouteData(route_data); +} + +void CameraFeedView::setCameraIndex(const CameraFeedIndex &camera_index, CameraViewKind view) { + impl_->setCameraIndex(camera_index, view); +} + +void CameraFeedView::update(double tracker_time) { + impl_->update(tracker_time); +} + +void CameraFeedView::draw(float width, bool loading) { + impl_->draw(width, loading); +} + +void CameraFeedView::drawSized(ImVec2 size, bool loading, bool fit_to_pane) { + impl_->drawSized(size, loading, fit_to_pane); +} diff --git a/tools/jotpluggler/session.cc b/tools/jotpluggler/session.cc new file mode 100644 index 0000000000..173df7bc04 --- /dev/null +++ b/tools/jotpluggler/session.cc @@ -0,0 +1,773 @@ +#include "tools/jotpluggler/internal.h" + +#include "imgui_internal.h" + +#include +#include +#include + +namespace fs = std::filesystem; + +const RouteSeries *app_find_route_series(const AppSession &session, const std::string &path) { + auto it = session.series_by_path.find(path); + return it == session.series_by_path.end() ? nullptr : it->second; +} + +void sync_camera_feeds(AppSession *session) { + for (size_t i = 0; i < kCameraViewSpecs.size(); ++i) { + if (session->pane_camera_feeds[i]) { + session->pane_camera_feeds[i]->setCameraIndex(session->route_data.*(kCameraViewSpecs[i].route_member), kCameraViewSpecs[i].view); + } + } +} + +void apply_route_data(AppSession *session, UiState *state, RouteData route_data) { + if (!route_data.route_id.empty()) { + session->route_id = route_data.route_id; + } else if (session->route_name.empty() && session->data_mode == SessionDataMode::Route) { + session->route_id = {}; + } + session->route_data = std::move(route_data); + rebuild_route_index(session); + rebuild_browser_nodes(session, state); + state->browser_nodes_dirty = false; + refresh_all_custom_curves(session, state); + sync_camera_feeds(session); + state->has_shared_range = false; + state->has_tracker_time = false; + reset_shared_range(state, *session); +} + +bool restore_undo_layout(AppSession *session, UiState *state, const SketchLayout &layout, const char *status_text) { + session->layout = layout; + cancel_rename_tab(state); + state->custom_series.request_select = false; + state->active_tab_index = std::clamp(layout.current_tab_index, 0, std::max(0, static_cast(layout.tabs.size()) - 1)); + state->requested_tab_index = state->active_tab_index; + sync_ui_state(state, session->layout); + mark_all_docks_dirty(state); + const bool autosave_ok = autosave_layout(session, state); + if (autosave_ok) { + state->status_text = status_text; + } + return autosave_ok; +} + +bool apply_undo(AppSession *session, UiState *state) { + if (!state->undo.can_undo()) { + return false; + } + return restore_undo_layout(session, state, state->undo.undo(), "Undo"); +} + +bool apply_redo(AppSession *session, UiState *state) { + if (!state->undo.can_redo()) { + return false; + } + return restore_undo_layout(session, state, state->undo.redo(), "Redo"); +} + +std::optional> tab_default_x_range(const WorkspaceTab &tab) { + bool found = false; + double min_value = 0.0; + double max_value = 1.0; + for (const Pane &pane : tab.panes) { + if (!pane.range.valid || pane.range.right <= pane.range.left) continue; + if (!found) { + min_value = pane.range.left; + max_value = pane.range.right; + found = true; + } else { + min_value = std::min(min_value, pane.range.left); + max_value = std::max(max_value, pane.range.right); + } + } + if (!found) return std::nullopt; + return std::make_pair(min_value, max_value); +} + +bool infer_stream_follow_state(const UiState &state, const AppSession &session) { + if (session.data_mode != SessionDataMode::Stream || !state.has_shared_range || !session.route_data.has_time_range) { + return false; + } + const double target_span = std::max(MIN_HORIZONTAL_ZOOM_SECONDS, session.stream_buffer_seconds); + const double current_span = std::max(0.0, state.x_view_max - state.x_view_min); + const double edge_epsilon = std::max(0.05, target_span * 0.02); + return std::abs(state.x_view_max - state.route_x_max) <= edge_epsilon + && std::abs(current_span - target_span) <= edge_epsilon; +} + +void ensure_shared_range(UiState *state, const AppSession &session) { + if (session.route_data.has_time_range) { + state->route_x_min = session.route_data.x_min; + state->route_x_max = session.route_data.x_max; + } else { + state->route_x_min = 0.0; + state->route_x_max = 1.0; + } + + if (state->has_shared_range) { + return; + } + + if (session.data_mode == SessionDataMode::Stream) { + const double target_span = std::max(MIN_HORIZONTAL_ZOOM_SECONDS, session.stream_buffer_seconds); + if (session.route_data.has_time_range) { + state->x_view_max = state->route_x_max; + state->x_view_min = state->x_view_max - target_span; + } else { + state->x_view_min = 0.0; + state->x_view_max = target_span; + } + if (state->x_view_max <= state->x_view_min) { + state->x_view_max = state->x_view_min + 1.0; + } + state->has_shared_range = true; + if (!state->has_tracker_time || state->tracker_time < state->route_x_min || state->tracker_time > state->route_x_max) { + state->tracker_time = state->route_x_max; + state->has_tracker_time = session.route_data.has_time_range; + } + return; + } + + if (const WorkspaceTab *tab = app_active_tab(session.layout, *state); tab != nullptr) { + if (std::optional> tab_range = tab_default_x_range(*tab); tab_range.has_value()) { + state->x_view_min = tab_range->first; + state->x_view_max = tab_range->second; + state->has_shared_range = true; + if (!state->has_tracker_time || state->tracker_time < state->route_x_min || state->tracker_time > state->route_x_max) { + state->tracker_time = state->route_x_min; + state->has_tracker_time = true; + } + return; + } + } + + state->x_view_min = state->route_x_min; + state->x_view_max = state->route_x_max; + if (state->x_view_max <= state->x_view_min) { + state->x_view_max = state->x_view_min + 1.0; + } + state->has_shared_range = true; + if (!state->has_tracker_time || state->tracker_time < state->route_x_min || state->tracker_time > state->route_x_max) { + state->tracker_time = state->route_x_min; + state->has_tracker_time = true; + } +} + +void clamp_shared_range(UiState *state, const AppSession &session) { + if (!state->has_shared_range) { + return; + } + const double min_span = MIN_HORIZONTAL_ZOOM_SECONDS; + double span = state->x_view_max - state->x_view_min; + if (span < min_span) { + const double center = 0.5 * (state->x_view_min + state->x_view_max); + span = min_span; + state->x_view_min = center - span * 0.5; + state->x_view_max = center + span * 0.5; + } + if (session.data_mode == SessionDataMode::Stream) { + if (session.route_data.has_time_range && state->x_view_max > state->route_x_max) { + state->x_view_min -= state->x_view_max - state->route_x_max; + state->x_view_max = state->route_x_max; + } + if (state->x_view_max <= state->x_view_min) { + state->x_view_max = state->x_view_min + min_span; + } + if (state->has_tracker_time && session.route_data.has_time_range) { + state->tracker_time = std::clamp(state->tracker_time, state->route_x_min, state->route_x_max); + } + if (session.route_data.has_time_range) { + state->follow_latest = infer_stream_follow_state(*state, session); + } + return; + } + if (state->route_x_max > state->route_x_min) { + if (state->x_view_min < state->route_x_min) { + state->x_view_max += state->route_x_min - state->x_view_min; + state->x_view_min = state->route_x_min; + } + if (state->x_view_max > state->route_x_max) { + state->x_view_min -= state->x_view_max - state->route_x_max; + state->x_view_max = state->route_x_max; + } + if (state->x_view_min < state->route_x_min) { + state->x_view_min = state->route_x_min; + } + if (state->x_view_max <= state->x_view_min) { + state->x_view_max = std::min(state->route_x_max, state->x_view_min + min_span); + } + } + if (state->has_tracker_time) { + state->tracker_time = std::clamp(state->tracker_time, state->route_x_min, state->route_x_max); + } +} + +void reset_shared_range(UiState *state, const AppSession &session) { + state->has_shared_range = false; + ensure_shared_range(state, session); + clamp_shared_range(state, session); +} + +void update_follow_range(UiState *state, const AppSession &session) { + if (!state->follow_latest || !state->has_shared_range) { + return; + } + const double span = session.data_mode == SessionDataMode::Stream + ? std::max(MIN_HORIZONTAL_ZOOM_SECONDS, session.stream_buffer_seconds) + : std::max(MIN_HORIZONTAL_ZOOM_SECONDS, state->x_view_max - state->x_view_min); + const double route_span = state->route_x_max - state->route_x_min; + if (route_span <= 0.0) { + return; + } + state->x_view_max = state->route_x_max; + state->x_view_min = state->x_view_max - span; + clamp_shared_range(state, session); +} + +void advance_playback(UiState *state, const AppSession &session) { + if (!state->playback_playing || !state->has_shared_range || state->route_x_max <= state->route_x_min) { + return; + } + + const double delta = std::max(0.0, static_cast(ImGui::GetIO().DeltaTime)) * state->playback_rate; + const double view_span = std::max(MIN_HORIZONTAL_ZOOM_SECONDS, state->x_view_max - state->x_view_min); + const double loop_min = state->playback_loop + ? std::clamp(state->x_view_min, state->route_x_min, state->route_x_max) + : state->route_x_min; + const double loop_max = state->playback_loop + ? std::clamp(state->x_view_max, state->route_x_min, state->route_x_max) + : state->route_x_max; + + state->tracker_time += delta; + if (state->tracker_time >= loop_max) { + if (state->playback_loop) { + state->tracker_time = loop_min; + } else { + state->tracker_time = state->route_x_max; + state->playback_playing = false; + } + } + + if (!state->playback_loop) { + constexpr double kScrollStartFraction = 0.70; + const double scroll_anchor = state->x_view_min + view_span * kScrollStartFraction; + if (state->tracker_time > scroll_anchor && state->x_view_max < state->route_x_max) { + state->x_view_min = state->tracker_time - view_span * kScrollStartFraction; + state->x_view_max = state->x_view_min + view_span; + clamp_shared_range(state, session); + } else if (state->tracker_time < state->x_view_min || state->tracker_time > state->x_view_max) { + state->x_view_min = state->tracker_time - view_span * 0.5; + state->x_view_max = state->x_view_min + view_span; + clamp_shared_range(state, session); + } + } +} + +void step_tracker(UiState *state, double direction) { + if (!state->has_shared_range) { + return; + } + state->tracker_time += direction * std::max(0.001, state->playback_step); + state->tracker_time = std::clamp(state->tracker_time, state->route_x_min, state->route_x_max); +} + +const char *log_selector_name(LogSelector selector) { + static constexpr const char *kLabels[] = {"a", "r", "q"}; + const size_t index = static_cast(selector); + return index < std::size(kLabels) ? kLabels[index] : kLabels[0]; +} + +const char *log_selector_description(LogSelector selector) { + static constexpr const char *kLabels[] = { + "any of rlog or qlog", + "rlog only", + "qlog only", + }; + const size_t index = static_cast(selector); + return index < std::size(kLabels) ? kLabels[index] : kLabels[0]; +} + +std::string shorten_route_part(std::string_view text, size_t keep) { + if (text.size() <= keep) { + return std::string(text); + } + return std::string(text.substr(0, keep)); +} + +bool parse_slice_spec(std::string_view text, int *begin, int *end) { + const auto parse_nonnegative = [](std::string_view value, int *out) { + if (value.empty()) return false; + char *end_ptr = nullptr; + const long parsed = std::strtol(std::string(value).c_str(), &end_ptr, 10); + if (end_ptr == nullptr || *end_ptr != '\0' || parsed < 0) { + return false; + } + *out = static_cast(parsed); + return true; + }; + const std::string trimmed = util::strip(std::string(text)); + if (trimmed.empty()) { + return false; + } + const size_t colon = trimmed.find(':'); + int parsed_begin = 0; + if (!parse_nonnegative(trimmed.substr(0, colon), &parsed_begin)) { + return false; + } + int parsed_end = parsed_begin; + if (colon != std::string::npos) { + const std::string end_text = trimmed.substr(colon + 1); + if (end_text.empty()) { + parsed_end = -1; + } else if (!parse_nonnegative(end_text, &parsed_end) || parsed_end < parsed_begin) { + return false; + } + } + *begin = parsed_begin; + *end = parsed_end; + return true; +} + +std::string format_duration_short(double seconds) { + const double clamped = std::max(0.0, seconds); + const int total_ms = static_cast(std::round(clamped * 1000.0)); + const int minutes = total_ms / 60000; + const int rem_ms = total_ms % 60000; + const int secs = rem_ms / 1000; + const int millis = rem_ms % 1000; + return util::string_format("%d:%02d.%03d", minutes, secs, millis); +} + +bool apply_route_identifier(AppSession *session, UiState *state, const RouteIdentifier &route_id, const char *status_text) { + if (route_id.empty()) { + return false; + } + if (!reload_session(session, state, route_id.full_spec(), session->data_dir)) { + return false; + } + state->status_text = status_text; + return true; +} + +bool apply_route_slice_change(AppSession *session, UiState *state, std::string_view slice_text) { + int begin = 0; + int end = 0; + if (!parse_slice_spec(slice_text, &begin, &end)) { + state->error_text = "Slice must be N, N:, or N:M."; + state->open_error_popup = true; + return false; + } + RouteIdentifier next = session->route_id; + next.slice_begin = begin; + next.slice_end = end; + next.slice_explicit = true; + return apply_route_identifier(session, state, next, "Updated route slice"); +} + +bool apply_route_selector_change(AppSession *session, UiState *state, LogSelector selector) { + RouteIdentifier next = session->route_id; + next.selector = selector; + next.selector_explicit = true; + return apply_route_identifier(session, state, next, "Updated log selector"); +} + +ImU32 route_chip_part_color(int part_index, bool explicit_part) { + constexpr std::array, 4> BASE = {{ + {70, 96, 126}, // dongle + {100, 86, 148}, // log id + {72, 112, 86}, // slice + {156, 104, 38}, // selector + }}; + const std::array &base = BASE[static_cast(std::clamp(part_index, 0, 3))]; + if (explicit_part) { + return ImGui::GetColorU32(color_rgb(base[0], base[1], base[2])); + } + const int gray = 144; + return ImGui::GetColorU32(color_rgb((base[0] + gray) / 2, (base[1] + gray) / 2, (base[2] + gray) / 2)); +} + +bool draw_route_chip_text_button(const char *id, + std::string_view text, + ImVec2 pos, + ImU32 color, + ImDrawList *draw_list, + const char *tooltip = nullptr) { + const ImVec2 size = ImGui::CalcTextSize(text.data(), text.data() + text.size()); + ImGui::SetCursorScreenPos(pos); + ImGui::InvisibleButton(id, size); + const bool hovered = ImGui::IsItemHovered(); + if (hovered) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + draw_list->AddRectFilled(ImVec2(pos.x - 5.0f, pos.y - 1.0f), + ImVec2(pos.x + size.x + 5.0f, pos.y + size.y + 2.0f), + ImGui::GetColorU32(color_rgb(225, 231, 239, 0.95f)), 0.0f); + } + draw_list->AddText(pos, color, text.data(), text.data() + text.size()); + if (tooltip != nullptr && ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) { + ImGui::BeginTooltip(); + ImGui::TextUnformatted(tooltip); + ImGui::EndTooltip(); + } + return ImGui::IsItemClicked(ImGuiMouseButton_Left); +} + +void draw_route_copy_feedback(UiState *state, ImDrawList *draw_list, ImVec2 chip_max) { + if (state->route_copy_feedback_text.empty()) { + return; + } + const double now = ImGui::GetTime(); + if (now >= state->route_copy_feedback_until) { + state->route_copy_feedback_text.clear(); + state->route_copy_feedback_until = 0.0; + return; + } + + const float alpha = static_cast(std::clamp((state->route_copy_feedback_until - now) / 1.1, 0.0, 1.0)); + const ImVec2 text_size = ImGui::CalcTextSize(state->route_copy_feedback_text.c_str()); + const ImVec2 pad(9.0f, 5.0f); + const ImVec2 bubble_min(chip_max.x - text_size.x - pad.x * 2.0f, chip_max.y + 7.0f); + const ImVec2 bubble_max(chip_max.x, bubble_min.y + text_size.y + pad.y * 2.0f); + draw_list->AddRectFilled(bubble_min, bubble_max, + ImGui::GetColorU32(color_rgb(46, 125, 80, 0.96f * alpha)), 7.0f); + draw_list->AddRect(bubble_min, bubble_max, + ImGui::GetColorU32(color_rgb(35, 96, 61, 0.9f * alpha)), 7.0f, 0, 1.0f); + draw_list->AddText(ImVec2(std::floor(bubble_min.x + pad.x), std::floor(bubble_min.y + pad.y)), + ImGui::GetColorU32(color_rgb(247, 251, 248, alpha)), + state->route_copy_feedback_text.c_str()); +} + +void draw_route_info_popup(AppSession *session, UiState *state, ImVec2 anchor) { + if (session->route_id.empty()) { + return; + } + ImGui::SetNextWindowPos(anchor, ImGuiCond_Appearing); + ImGui::SetNextWindowSizeConstraints(ImVec2(300.0f, 0.0f), ImVec2(420.0f, FLT_MAX)); + if (!ImGui::BeginPopup("##route_info_popup", + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings)) { + return; + } + + ImGui::TextUnformatted("Route Info"); + ImGui::Separator(); + app_push_mono_font(); + ImGui::TextUnformatted(session->route_id.canonical().c_str()); + app_pop_mono_font(); + + const char *copy_icon = icon::CLIPBOARD; + const char *link_icon = icon::BOX_ARROW_UP_RIGHT; + const std::string useradmin_label = std::string("Useradmin ") + link_icon; + const std::string connect_label = std::string("comma connect ") + link_icon; + if (ImGui::Button(copy_icon, ImVec2(34.0f, 26.0f))) { + ImGui::SetClipboardText(session->route_id.canonical().c_str()); + state->status_text = "Copied route to clipboard"; + state->route_copy_feedback_text = "Copied"; + state->route_copy_feedback_until = ImGui::GetTime() + 1.1; + } + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) { + ImGui::BeginTooltip(); + ImGui::TextUnformatted("Copy route"); + ImGui::EndTooltip(); + } + ImGui::SameLine(); + if (ImGui::Button(useradmin_label.c_str(), ImVec2(132.0f, 26.0f))) { + open_external_url(route_useradmin_url(session->route_id)); + state->status_text = "Opened useradmin"; + } + ImGui::SameLine(); + if (ImGui::Button(connect_label.c_str(), ImVec2(156.0f, 26.0f))) { + open_external_url(route_connect_url(session->route_id)); + state->status_text = "Opened comma connect"; + } + + ImGui::Spacing(); + const int loaded_begin = session->route_id.available_begin; + const int loaded_end = session->route_id.available_end; + const int loaded_count = loaded_end >= loaded_begin ? (loaded_end - loaded_begin + 1) : 0; + ImGui::Text("Duration %s", format_duration_short(session->route_data.x_max - session->route_data.x_min).c_str()); + ImGui::Text("Segments %s (%d)", session->route_id.display_slice().c_str(), loaded_count); + ImGui::Text("Selector %s", log_selector_description(session->route_id.selector)); + if (!session->route_data.car_fingerprint.empty()) { + ImGui::TextWrapped("Car %s", session->route_data.car_fingerprint.c_str()); + } + if (!session->route_data.dbc_name.empty()) { + ImGui::TextWrapped("DBC %s", session->route_data.dbc_name.c_str()); + } + + ImGui::EndPopup(); +} + +void draw_route_id_chip(AppSession *session, UiState *state) { + if (session->data_mode != SessionDataMode::Route || session->route_id.empty()) { + return; + } + + ImGuiWindow *window = ImGui::GetCurrentWindow(); + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + const RouteIdentifier &route_id = session->route_id; + app_push_bold_font(); + const std::string dongle_text = shorten_route_part(route_id.dongle_id, 8); + const std::string log_text = shorten_route_part(route_id.log_id, 16); + const std::string slice_text = route_id.display_slice(); + const std::string selector_text(1, route_id.selector_char()); + const std::string sep_text = " / "; + + const ImVec2 dongle_size = ImGui::CalcTextSize(dongle_text.c_str()); + const ImVec2 log_size = ImGui::CalcTextSize(log_text.c_str()); + const ImVec2 slice_size = state->editing_route_slice + ? ImVec2(68.0f, ImGui::GetFrameHeight()) + : ImGui::CalcTextSize(slice_text.c_str()); + const ImVec2 selector_size = ImGui::CalcTextSize(selector_text.c_str()); + const ImVec2 sep_size = ImGui::CalcTextSize(sep_text.c_str()); + constexpr float chip_pad_x = 12.0f; + constexpr float info_size = 18.0f; + const float chip_h = 28.0f; + const float chip_w = chip_pad_x * 2.0f + dongle_size.x + sep_size.x + log_size.x + sep_size.x + + slice_size.x + sep_size.x + selector_size.x + 10.0f + info_size; + const float menu_right = window->Pos.x + window->Size.x - 8.0f; + const float cursor_x = ImGui::GetCursorScreenPos().x + 4.0f; + const float chip_x = std::clamp(cursor_x, window->Pos.x + 48.0f, std::max(window->Pos.x + 48.0f, menu_right - chip_w)); + const float chip_y = std::floor(window->Pos.y + std::max(0.0f, (window->Size.y - chip_h) * 0.5f)); + const ImVec2 chip_min(chip_x, chip_y); + const ImVec2 chip_max(chip_x + chip_w, chip_y + chip_h); + const float text_y = std::floor(chip_y + std::max(0.0f, (chip_h - ImGui::GetTextLineHeight()) * 0.5f)); + const ImU32 chip_bg = ImGui::GetColorU32(color_rgb(247, 249, 252)); + const ImU32 chip_border = ImGui::GetColorU32(color_rgb(184, 191, 200)); + const ImU32 sep = ImGui::GetColorU32(color_rgb(162, 170, 178)); + draw_list->AddRectFilled(chip_min, chip_max, chip_bg, 0.0f); + draw_list->AddRect(chip_min, chip_max, chip_border, 0.0f, 0, 1.0f); + + float x = chip_x + chip_pad_x; + const bool dongle_click = draw_route_chip_text_button( + "##route_dongle", dongle_text, ImVec2(x, text_y), route_chip_part_color(0, true), draw_list, + "Device identifier"); + x += dongle_size.x; + draw_list->AddText(ImVec2(x, text_y), sep, sep_text.c_str()); + x += sep_size.x; + const bool log_click = draw_route_chip_text_button( + "##route_log", log_text, ImVec2(x, text_y), route_chip_part_color(1, true), draw_list, + "Route identifier"); + x += log_size.x; + draw_list->AddText(ImVec2(x, text_y), sep, sep_text.c_str()); + x += sep_size.x; + + if (state->editing_route_slice) { + ImGui::SetCursorScreenPos(ImVec2(x - 4.0f, chip_y + 1.0f)); + ImGui::SetNextItemWidth(76.0f); + if (state->focus_route_slice_input) { + ImGui::SetKeyboardFocusHere(); + state->focus_route_slice_input = false; + } + const bool applied = input_text_string("##route_slice_edit", &state->route_slice_buffer, + ImGuiInputTextFlags_EnterReturnsTrue); + const bool deactivated = ImGui::IsItemDeactivated(); + const bool clicked_elsewhere = ImGui::IsMouseClicked(ImGuiMouseButton_Left) + && !ImGui::IsItemHovered() + && !ImGui::IsItemActive(); + if (applied) { + if (apply_route_slice_change(session, state, state->route_slice_buffer)) { + state->editing_route_slice = false; + } + } else if (ImGui::IsKeyPressed(ImGuiKey_Escape)) { + state->editing_route_slice = false; + } else if (deactivated || clicked_elsewhere) { + const std::string trimmed = util::strip(state->route_slice_buffer); + if (trimmed != route_id.display_slice()) { + int begin = 0; + int end = 0; + if (parse_slice_spec(trimmed, &begin, &end)) { + apply_route_slice_change(session, state, trimmed); + } else { + state->status_text = "Canceled route slice edit"; + } + } + state->editing_route_slice = false; + } + x += slice_size.x; + } else { + const bool slice_click = draw_route_chip_text_button( + "##route_slice", slice_text, ImVec2(x, text_y), + route_chip_part_color(2, route_id.slice_explicit), draw_list, + "Segment range"); + if (slice_click) { + state->editing_route_slice = true; + state->focus_route_slice_input = true; + state->route_slice_buffer = route_id.display_slice(); + } + x += slice_size.x; + } + + draw_list->AddText(ImVec2(x, text_y), sep, sep_text.c_str()); + x += sep_size.x; + const bool selector_click = draw_route_chip_text_button( + "##route_selector", selector_text, ImVec2(x, text_y), + route_chip_part_color(3, route_id.selector_explicit), draw_list, + "Log selector"); + if (selector_click) { + ImGui::OpenPopup("##route_selector_popup"); + } + x += selector_size.x + 10.0f; + + const ImVec2 info_center(x + info_size * 0.5f, chip_y + chip_h * 0.5f); + ImGui::SetCursorScreenPos(ImVec2(x, chip_y + (chip_h - info_size) * 0.5f)); + ImGui::InvisibleButton("##route_info_button", ImVec2(info_size, info_size)); + const bool info_hovered = ImGui::IsItemHovered(); + if (info_hovered) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + } + draw_list->AddCircleFilled(info_center, info_size * 0.5f, + ImGui::GetColorU32(info_hovered ? color_rgb(220, 229, 240) : color_rgb(239, 243, 248))); + draw_list->AddCircle(info_center, info_size * 0.5f, chip_border, 20, 1.0f); + const char *info_text = icon::INFO_CIRCLE; + const ImVec2 info_text_size = ImGui::CalcTextSize(info_text); + draw_list->AddText(ImVec2(std::floor(info_center.x - info_text_size.x * 0.5f), + std::floor(info_center.y - info_text_size.y * 0.5f)), + route_chip_part_color(0, true), info_text); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_DelayShort)) { + ImGui::BeginTooltip(); + ImGui::TextUnformatted("Route details"); + ImGui::EndTooltip(); + } + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) { + ImGui::OpenPopup("##route_info_popup"); + } + + app_pop_bold_font(); + + if (dongle_click || log_click) { + ImGui::SetClipboardText(route_id.canonical().c_str()); + state->status_text = "Copied route to clipboard"; + state->route_copy_feedback_text = "Copied"; + state->route_copy_feedback_until = ImGui::GetTime() + 1.1; + } + + ImGui::SetNextWindowPos(ImVec2(chip_max.x - 60.0f, chip_max.y + 4.0f), ImGuiCond_Appearing); + if (ImGui::BeginPopup("##route_selector_popup")) { + for (LogSelector selector : {LogSelector::Auto, LogSelector::RLog, LogSelector::QLog}) { + const bool selected = route_id.selector == selector; + const std::string label = std::string(log_selector_name(selector)) + " " + log_selector_description(selector); + if (ImGui::Selectable(label.c_str(), selected) && !selected) { + apply_route_selector_change(session, state, selector); + } + if (selected) { + ImGui::SetItemDefaultFocus(); + } + } + ImGui::EndPopup(); + } + + draw_route_copy_feedback(state, draw_list, chip_max); + draw_route_info_popup(session, state, ImVec2(std::max(window->Pos.x + 16.0f, chip_max.x - 360.0f), chip_max.y + 6.0f)); +} + +std::string format_cache_bytes(uint64_t bytes) { + if (bytes >= (1ULL << 30)) { + return util::string_format("%.1f GiB", static_cast(bytes) / static_cast(1ULL << 30)); + } else if (bytes >= (1ULL << 20)) { + return util::string_format("%.1f MiB", static_cast(bytes) / static_cast(1ULL << 20)); + } else if (bytes >= (1ULL << 10)) { + return util::string_format("%.1f KiB", static_cast(bytes) / static_cast(1ULL << 10)); + } + return util::string_format("%llu B", static_cast(bytes)); +} + +MapCacheStats directory_cache_stats(const fs::path &root) { + MapCacheStats stats; + std::error_code ec; + if (!fs::exists(root, ec)) { + return stats; + } + fs::recursive_directory_iterator it(root, fs::directory_options::skip_permission_denied, ec); + for (const fs::directory_entry &entry : it) { + if (ec) { + ec.clear(); + continue; + } + const fs::file_status status = entry.symlink_status(ec); + if (ec || !fs::is_regular_file(status)) { + ec.clear(); + continue; + } + const uintmax_t size = entry.file_size(ec); + if (!ec) { + stats.bytes += static_cast(size); + ++stats.files; + } else { + ec.clear(); + } + } + return stats; +} + +float draw_main_menu_bar(AppSession *session, UiState *state) { + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(7.0f, 5.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(9.0f, 6.0f)); + float height = ImGui::GetFrameHeight(); + if (ImGui::BeginMainMenuBar()) { + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("Undo", "Ctrl+Z", false, state->undo.can_undo())) { + apply_undo(session, state); + } + if (ImGui::MenuItem("Redo", "Ctrl+Shift+Z", false, state->undo.can_redo())) { + apply_redo(session, state); + } + ImGui::Separator(); + if (ImGui::MenuItem("Open Route...")) { + state->open_open_route = true; + } + if (ImGui::MenuItem("Stream...")) { + state->open_stream = true; + } + if (ImGui::MenuItem("Find Signal...", "Ctrl+F")) { + state->open_find_signal = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("New Layout")) { + start_new_layout(session, state); + } + if (ImGui::MenuItem("Load Layout...")) { + state->open_load_layout = true; + } + if (ImGui::MenuItem("Save Layout")) { + state->request_save_layout = true; + } + if (ImGui::MenuItem("Save Layout As...")) { + state->open_save_layout = true; + } + if (ImGui::MenuItem("Reset Layout")) { + state->request_reset_layout = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Show DEPRECATED Fields", nullptr, state->show_deprecated_fields)) { + state->show_deprecated_fields = !state->show_deprecated_fields; + rebuild_browser_nodes(session, state); + } + if (ImGui::MenuItem("Show FPS", nullptr, state->show_fps_overlay)) { + state->show_fps_overlay = !state->show_fps_overlay; + } + if (ImGui::MenuItem("Preferences...")) { + state->open_preferences = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Reset Plot View")) { + reset_shared_range(state, *session); + state->follow_latest = session->data_mode == SessionDataMode::Stream; + clamp_shared_range(state, *session); + state->suppress_range_side_effects = true; + state->status_text = "Plot view reset"; + } + ImGui::Separator(); + if (ImGui::MenuItem("Close")) { + state->request_close = true; + } + ImGui::EndMenu(); + } + ImGui::SameLine(0.0f, 8.0f); + draw_route_id_chip(session, state); + height = ImGui::GetWindowSize().y; + ImGui::EndMainMenuBar(); + } + ImGui::PopStyleVar(2); + return height; +} diff --git a/tools/jotpluggler/sidebar.cc b/tools/jotpluggler/sidebar.cc new file mode 100644 index 0000000000..c120b47908 --- /dev/null +++ b/tools/jotpluggler/sidebar.cc @@ -0,0 +1,215 @@ +#include "tools/jotpluggler/internal.h" + +std::string dbc_combo_label(const AppSession &session) { + if (!session.dbc_override.empty()) return session.dbc_override; + if (!session.route_data.dbc_name.empty()) return "Auto: " + session.route_data.dbc_name; + return "Auto"; +} + +float timeline_time_to_x(double time_value, double route_min, double route_max, float x_min, float x_max) { + const double span = route_max - route_min; + if (span <= 0.0) { + return x_min; + } + const double ratio = (time_value - route_min) / span; + return x_min + static_cast(ratio * static_cast(x_max - x_min)); +} + +double timeline_x_to_time(float x, double route_min, double route_max, float x_min, float x_max) { + const float width = std::max(1.0f, x_max - x_min); + const float clamped_x = std::clamp(x, x_min, x_max); + const double ratio = static_cast((clamped_x - x_min) / width); + return route_min + ratio * (route_max - route_min); +} + +void reset_timeline_view(UiState *state, const AppSession &session) { + state->follow_latest = session.data_mode == SessionDataMode::Stream; + reset_shared_range(state, session); +} + +void draw_timeline_bar_contents(const AppSession &session, UiState *state, float width) { + if (!session.route_data.has_time_range) { + ImGui::Dummy(ImVec2(width, TIMELINE_BAR_HEIGHT)); + return; + } + + const ImVec2 cursor = ImGui::GetCursorScreenPos(); + const ImVec2 size(width, TIMELINE_BAR_HEIGHT); + const ImVec2 bar_min(cursor.x + 1.0f, cursor.y + 1.0f); + const ImVec2 bar_max(cursor.x + size.x - 1.0f, cursor.y + size.y - 1.0f); + const double route_min = state->route_x_min; + const double route_max = state->route_x_max; + const float vp_left = timeline_time_to_x(std::clamp(state->x_view_min, route_min, route_max), + route_min, route_max, bar_min.x, bar_max.x); + const float vp_right = timeline_time_to_x(std::clamp(state->x_view_max, route_min, route_max), + route_min, route_max, bar_min.x, bar_max.x); + + ImGui::InvisibleButton("##timeline_button", size); + const bool hovered = ImGui::IsItemHovered(); + const bool active = ImGui::IsItemActive(); + const bool double_clicked = hovered && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left); + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + + draw_list->AddRectFilled(bar_min, bar_max, timeline_entry_color(TimelineEntry::Type::None, 0.2f)); + if (session.route_data.timeline.empty()) { + draw_list->AddRectFilled(ImVec2(vp_left, bar_min.y), ImVec2(vp_right, bar_max.y), + timeline_entry_color(TimelineEntry::Type::None, 1.0f)); + } else { + for (const TimelineEntry &entry : session.route_data.timeline) { + float x0 = timeline_time_to_x(entry.start_time, route_min, route_max, bar_min.x, bar_max.x); + float x1 = timeline_time_to_x(entry.end_time, route_min, route_max, bar_min.x, bar_max.x); + x1 = std::max(x1, x0 + 1.0f); + draw_list->AddRectFilled(ImVec2(x0, bar_min.y), ImVec2(x1, bar_max.y), + timeline_entry_color(entry.type, 0.25f)); + } + for (const TimelineEntry &entry : session.route_data.timeline) { + float x0 = std::max(timeline_time_to_x(entry.start_time, route_min, route_max, bar_min.x, bar_max.x), vp_left); + float x1 = std::min(std::max(timeline_time_to_x(entry.end_time, route_min, route_max, bar_min.x, bar_max.x), x0 + 1.0f), vp_right); + if (x1 <= x0) { + continue; + } + draw_list->AddRectFilled(ImVec2(x0, bar_min.y), ImVec2(x1, bar_max.y), + timeline_entry_color(entry.type, 1.0f)); + } + } + + draw_list->AddLine(ImVec2(vp_left, bar_min.y), ImVec2(vp_left, bar_max.y), IM_COL32(60, 70, 80, 200), 1.0f); + draw_list->AddLine(ImVec2(vp_right, bar_min.y), ImVec2(vp_right, bar_max.y), IM_COL32(60, 70, 80, 200), 1.0f); + if (state->has_tracker_time) { + const float cx = timeline_time_to_x(std::clamp(state->tracker_time, route_min, route_max), + route_min, route_max, bar_min.x, bar_max.x); + draw_list->AddLine(ImVec2(cx, bar_min.y), ImVec2(cx, bar_max.y), IM_COL32(220, 60, 50, 255), 1.5f); + } + draw_list->AddRect(bar_min, bar_max, IM_COL32(170, 178, 186, 255), 0.0f, 0, 1.0f); + + const float edge_grab = 4.0f; + const float mouse_x = ImGui::GetIO().MousePos.x; + const double mouse_time = timeline_x_to_time(mouse_x, route_min, route_max, bar_min.x, bar_max.x); + if (double_clicked) { + reset_timeline_view(state, session); + } else if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + state->timeline_drag_anchor_time = mouse_time; + state->timeline_drag_anchor_x_min = state->x_view_min; + state->timeline_drag_anchor_x_max = state->x_view_max; + if (std::abs(mouse_x - vp_left) <= edge_grab) { + state->timeline_drag_mode = TimelineDragMode::ResizeLeft; + } else if (std::abs(mouse_x - vp_right) <= edge_grab) { + state->timeline_drag_mode = TimelineDragMode::ResizeRight; + } else if (mouse_x >= vp_left && mouse_x <= vp_right) { + state->timeline_drag_mode = TimelineDragMode::PanViewport; + } else { + state->timeline_drag_mode = TimelineDragMode::ScrubCursor; + state->tracker_time = std::clamp(mouse_time, route_min, route_max); + state->has_tracker_time = true; + } + } + + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + state->timeline_drag_mode = TimelineDragMode::None; + } else if (active || state->timeline_drag_mode != TimelineDragMode::None) { + switch (state->timeline_drag_mode) { + case TimelineDragMode::ScrubCursor: + state->tracker_time = std::clamp(mouse_time, route_min, route_max); + state->has_tracker_time = true; + break; + case TimelineDragMode::PanViewport: { + const double delta = mouse_time - state->timeline_drag_anchor_time; + state->x_view_min = state->timeline_drag_anchor_x_min + delta; + state->x_view_max = state->timeline_drag_anchor_x_max + delta; + clamp_shared_range(state, session); + break; + } + case TimelineDragMode::ResizeLeft: + state->x_view_min = std::min(mouse_time, state->x_view_max - MIN_HORIZONTAL_ZOOM_SECONDS); + clamp_shared_range(state, session); + break; + case TimelineDragMode::ResizeRight: + state->x_view_max = std::max(mouse_time, state->x_view_min + MIN_HORIZONTAL_ZOOM_SECONDS); + clamp_shared_range(state, session); + break; + case TimelineDragMode::None: + break; + } + } + + if (hovered) { + if (std::abs(mouse_x - vp_left) <= edge_grab || std::abs(mouse_x - vp_right) <= edge_grab) { + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + } else if (mouse_x >= vp_left && mouse_x <= vp_right) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + } + ImGui::BeginTooltip(); + ImGui::Text("t=%.1fs — %s", mouse_time, timeline_entry_label(timeline_type_at_time(session.route_data.timeline, mouse_time))); + ImGui::EndTooltip(); + } +} + +void draw_status_bar(const AppSession &session, const UiMetrics &ui, UiState *state) { + ImGui::SetNextWindowPos(ImVec2(ui.content_x, ui.status_bar_y)); + ImGui::SetNextWindowSize(ImVec2(ui.content_w, STATUS_BAR_HEIGHT)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, color_rgb(247, 248, 250)); + ImGui::PushStyleColor(ImGuiCol_Border, color_rgb(188, 193, 199)); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoSavedSettings; + if (ImGui::Begin("##status_bar", nullptr, flags)) { + draw_timeline_bar_contents(session, state, ui.content_w); + const float row_y = TIMELINE_BAR_HEIGHT + 8.0f; + ImGui::SetCursorPos(ImVec2(8.0f, row_y)); + ImGui::BeginDisabled(!session.route_data.has_time_range); + ImGui::Checkbox("Loop", &state->playback_loop); + ImGui::SameLine(0.0f, 10.0f); + if (ImGui::Button(state->playback_playing ? "Pause" : "Play", ImVec2(56.0f, 0.0f))) { + state->playback_playing = !state->playback_playing; + } + ImGui::SameLine(0.0f, 10.0f); + if (ImGui::Button("Reset View", ImVec2(86.0f, 0.0f))) { + reset_timeline_view(state, session); + } + const float controls_end_x = ImGui::GetItemRectMax().x - ImGui::GetWindowPos().x; + ImGui::EndDisabled(); + + const char *status_text = state->status_text.empty() ? "Ready" : state->status_text.c_str(); + const float status_x = controls_end_x + 16.0f; + ImGui::SetCursorPos(ImVec2(status_x, row_y + 2.0f)); + ImGui::PushStyleColor(ImGuiCol_Text, color_rgb(102, 110, 118)); + ImGui::TextUnformatted(status_text); + ImGui::PopStyleColor(); + + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + +void draw_sidebar_resizer(const UiMetrics &ui, UiState *state) { + constexpr float kHandleWidth = 14.0f; + ImGui::SetNextWindowPos(ImVec2(ui.sidebar_width - kHandleWidth * 0.5f, ui.top_offset)); + ImGui::SetNextWindowSize(ImVec2(kHandleWidth, std::max(1.0f, ui.height - ui.top_offset))); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoBackground; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + if (ImGui::Begin("##sidebar_resizer", nullptr, flags)) { + ImGui::InvisibleButton("##sidebar_resizer_button", ImVec2(kHandleWidth, std::max(1.0f, ui.height - ui.top_offset))); + if (ImGui::IsItemHovered() || ImGui::IsItemActive()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + } + if (ImGui::IsItemActive()) { + const float max_width = std::min(SIDEBAR_MAX_WIDTH, ui.width * 0.6f); + state->sidebar_width = std::clamp(ImGui::GetIO().MousePos.x, SIDEBAR_MIN_WIDTH, max_width); + } + + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + const ImVec2 origin = ImGui::GetWindowPos(); + draw_list->AddLine(ImVec2(origin.x + kHandleWidth * 0.5f, origin.y), + ImVec2(origin.x + kHandleWidth * 0.5f, origin.y + std::max(1.0f, ui.height - ui.top_offset)), + IM_COL32(194, 198, 204, 255)); + } + ImGui::End(); + ImGui::PopStyleVar(); +} diff --git a/tools/jotpluggler/sketch_layout.cc b/tools/jotpluggler/sketch_layout.cc new file mode 100644 index 0000000000..d9622dde6d --- /dev/null +++ b/tools/jotpluggler/sketch_layout.cc @@ -0,0 +1,2221 @@ +#include "tools/jotpluggler/app.h" +#include "tools/jotpluggler/car_fingerprint_to_dbc.h" +#include "tools/jotpluggler/common.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/util.h" +#include "third_party/json11/json11.hpp" +#include "tools/replay/logreader.h" +#include "tools/replay/py_downloader.h" + +namespace fs = std::filesystem; + +namespace { + +struct RouteSelection { + std::string dongle_id; + std::string timestamp; + int begin_segment = 0; + int end_segment = -1; + bool slice_explicit = false; + LogSelector selector = LogSelector::Auto; + bool selector_explicit = false; + std::string canonical_name; +}; + +struct SegmentLogs { + std::string rlog; + std::string qlog; + std::string fcamera; + std::string dcamera; + std::string ecamera; + std::string qcamera; +}; + +enum class ScalarKind { + None, + Bool, + Int, + UInt, + Float, + Enum, +}; + +enum class ResolvedNodeKind { + Ignore, + Scalar, + Struct, + List, +}; + +struct ResolvedNode { + ResolvedNodeKind kind = ResolvedNodeKind::Ignore; + ScalarKind scalar_kind = ScalarKind::None; + int fixed_slot = -1; + bool has_field = false; + capnp::StructSchema::Field field; + std::string segment; + std::string path; + bool skip_large_scalar_list = false; + std::vector children; + std::unique_ptr element; +}; + +struct ResolvedService { + uint16_t event_which = 0; + capnp::StructSchema::Field union_field; + std::string service_name; + int valid_slot = -1; + int log_mono_time_slot = -1; + int seconds_slot = -1; + ResolvedNode payload; +}; + +struct SchemaIndex { + std::vector> by_which; + size_t fixed_series_count = 0; + std::vector fixed_paths; + + static const SchemaIndex &instance(); +}; + +constexpr size_t INVALID_DYNAMIC_SLOT = std::numeric_limits::max(); + +struct SeriesAccumulator { + explicit SeriesAccumulator(size_t fixed_count = 0) : fixed_series(fixed_count) {} + + std::vector fixed_series; + std::vector dynamic_series; + std::vector can_messages; + std::unordered_map dynamic_slots; + std::unordered_map> list_scalar_slots; + std::unordered_map can_message_slots; + std::unordered_map enum_info; +}; + +struct LoadedRouteArtifacts { + std::vector series; + std::vector can_messages; + std::vector logs; + std::vector timeline; + std::unordered_map enum_info; +}; + +struct RouteMetadata { + std::string car_fingerprint; +}; + +struct LoadStats { + using Clock = std::chrono::steady_clock; + using TimePoint = Clock::time_point; + + struct SegmentStats { + int segment_number = -1; + std::string log_path; + double download_seconds = 0.0; + double decompress_seconds = 0.0; + double parse_seconds = 0.0; + double extract_seconds = 0.0; + size_t compressed_bytes = 0; + size_t decompressed_bytes = 0; + size_t event_count = 0; + size_t series_count = 0; + bool failed = false; + }; + + explicit LoadStats(const RouteLoadProgressCallback &callback) : progress(callback) {} + + void publish(RouteLoadStage stage, size_t segment_index, const std::string &segment_name) { + if (!progress) { + return; + } + RouteLoadProgress update; + update.stage = stage; + update.segment_index = segment_index; + update.segment_count = segment_count; + update.current = stage == RouteLoadStage::DownloadingSegment + ? segments_downloaded.load() + : segments_parsed.load(); + update.total = total_segments.load(); + update.segments_downloaded = segments_downloaded.load(); + update.segments_parsed = segments_parsed.load(); + update.total_segments = total_segments.load(); + update.bytes_downloaded = bytes_downloaded.load(); + update.num_workers = num_workers; + update.segment_name = segment_name; + std::lock_guard lock(progress_mutex); + progress(update); + } + + void print_summary(size_t final_series_count) const { + const auto secs = [](TimePoint a, TimePoint b) { return std::chrono::duration(b - a).count(); }; + const auto mb = [](size_t bytes) { return static_cast(bytes) / (1024.0 * 1024.0); }; + double dl = 0, dc = 0, pa = 0, ex = 0; + size_t ev = 0, cb = 0, db = 0; + for (const auto &s : segments) { + dl += s.download_seconds; dc += s.decompress_seconds; + pa += s.parse_seconds; ex += s.extract_seconds; + ev += s.event_count; cb += s.compressed_bytes; db += s.decompressed_bytes; + } + std::cerr << std::fixed << std::setprecision(1) + << "route loaded in " << secs(load_start, load_end) << "s (" << segment_count << " segments, " << num_workers << " workers)\n" + << " resolve: " << secs(load_start, resolve_end) << "s fetch: " << dl << "s (" << mb(cb) << " MB)" + << " decompress: " << dc << "s (" << mb(db) << " MB)\n" + << " parse: " << pa << "s (" << ev << " events) extract: " << ex << "s merge: " << secs(merge_start, merge_end) << "s" + << " series: " << final_series_count << " paths\n"; + for (const auto &s : segments) { + std::cerr << " seg " << std::setw(2) << s.segment_number << ": " + << (s.failed ? "FAILED" : std::to_string(s.download_seconds) + "s + " + std::to_string(s.parse_seconds) + + "s (" + std::to_string(s.event_count) + " ev, " + std::to_string(s.series_count) + " series)") << "\n"; + } + std::cerr.unsetf(std::ios::floatfield); + } + + TimePoint load_start; + TimePoint resolve_end; + TimePoint merge_start; + TimePoint merge_end; + TimePoint load_end; + size_t segment_count = 0; + int num_workers = 1; + std::vector segments; + std::atomic segments_downloaded{0}; + std::atomic segments_parsed{0}; + std::atomic total_segments{0}; + std::atomic bytes_downloaded{0}; + RouteLoadProgressCallback progress; + mutable std::mutex progress_mutex; +}; + +// Skip individual messages that our local Cap'n Proto schema can't project, +// such as logs recorded by a newer build. +template +void with_parseable_event(kj::ArrayPtr data, Fn &&fn) { + try { + capnp::FlatArrayMessageReader event_reader(data); + fn(event_reader.getRoot()); + } catch (const kj::Exception &) { + return; + } +} + +std::string curve_label(std::string_view series_name) { + return std::string(series_name.empty() ? std::string_view{"plot"} : series_name); +} + +bool parse_segment_number(std::string_view value, int *out) { + if (value.empty()) return false; + char *end = nullptr; + const long parsed = std::strtol(std::string(value).c_str(), &end, 10); + if (end == nullptr || *end != '\0') return false; + *out = static_cast(parsed); + return true; +} + +bool is_log_selector_char(char c) { + return c == 'a' || c == 'r' || c == 'q'; +} + +LogSelector parse_log_selector_char(char c) { + switch (c) { + case 'r': return LogSelector::RLog; + case 'q': return LogSelector::QLog; + case 'a': + default: return LogSelector::Auto; + } +} + +const std::string &selected_log_path(const SegmentLogs &segment, LogSelector selector) { + switch (selector) { + case LogSelector::RLog: + return segment.rlog; + case LogSelector::QLog: + return segment.qlog; + case LogSelector::Auto: + default: + return !segment.rlog.empty() ? segment.rlog : segment.qlog; + } +} + +RouteSelection parse_route_selection(std::string route_name) { + RouteSelection route = {}; + route_name = util::strip(route_name); + if (route_name.size() >= 2 && route_name[route_name.size() - 2] == '/' + && is_log_selector_char(static_cast(std::tolower(route_name.back())))) { + route.selector = parse_log_selector_char(static_cast(std::tolower(route_name.back()))); + route.selector_explicit = true; + route_name.resize(route_name.size() - 2); + } + static const std::regex pattern(R"(^(([a-z0-9]{16})[|_/])?(.{20})((--|/)((-?\d+(:(-?\d+)?)?)|(:-?\d+)))?$)"); + std::smatch match; + if (!std::regex_match(route_name, match, pattern)) return route; + + route.dongle_id = match[2].str(); + route.timestamp = match[3].str(); + route.canonical_name = route.dongle_id + "|" + route.timestamp; + + const std::string separator = match[5].str(); + const std::string range_str = match[6].str(); + if (!range_str.empty()) { + route.slice_explicit = true; + if (separator == "/") { + size_t pos = range_str.find(':'); + int begin_segment = 0; + if (!parse_segment_number(range_str.substr(0, pos), &begin_segment)) { + return {}; + } + route.begin_segment = begin_segment; + route.end_segment = begin_segment; + if (pos != std::string::npos) { + int end_segment = -1; + const std::string end_str = range_str.substr(pos + 1); + if (!end_str.empty() && !parse_segment_number(end_str, &end_segment)) { + return {}; + } + route.end_segment = end_str.empty() ? -1 : end_segment; + } + } else if (separator == "--") { + int begin_segment = 0; + if (!parse_segment_number(range_str, &begin_segment)) return {}; + route.begin_segment = begin_segment; + } + } + return route; +} + +void add_log_file_to_segments(std::map *segments, int segment_number, const std::string &file) { + std::string name = extractFileName(file); + const size_t pos = name.find_last_of("--"); + name = pos != std::string::npos ? name.substr(pos + 2) : name; + SegmentLogs &segment = (*segments)[segment_number]; + if (name == "rlog.bz2" || name == "rlog.zst" || name == "rlog") { + segment.rlog = file; + } else if (name == "qlog.bz2" || name == "qlog.zst" || name == "qlog") { + segment.qlog = file; + } else if (name == "fcamera.hevc") { + segment.fcamera = file; + } else if (name == "dcamera.hevc") { + segment.dcamera = file; + } else if (name == "ecamera.hevc") { + segment.ecamera = file; + } else if (name == "qcamera.ts") { + segment.qcamera = file; + } +} + +std::map trim_segments(std::map segments, const RouteSelection &route) { + if (route.begin_segment > 0) { + segments.erase(segments.begin(), segments.lower_bound(route.begin_segment)); + } + if (route.end_segment >= 0) { + segments.erase(segments.upper_bound(route.end_segment), segments.end()); + } + return segments; +} + +std::map load_segments_from_json(const json11::Json &json) { + std::map segments; + static const std::regex rx(R"(\/(\d+)\/)"); + for (const auto &value : json.object_items()) { + for (const auto &url : value.second.array_items()) { + const std::string url_str = url.string_value(); + std::smatch match; + if (!std::regex_search(url_str, match, rx)) continue; + add_log_file_to_segments(&segments, std::stoi(match[1].str()), url_str); + } + } + return segments; +} + +std::map load_segments_from_server(const RouteSelection &route) { + const std::string result = PyDownloader::getRouteFiles(route.canonical_name); + if (result.empty()) throw std::runtime_error("Failed to fetch route files for " + route.canonical_name); + + std::string parse_error; + const auto json = json11::Json::parse(result, parse_error); + if (!parse_error.empty()) throw std::runtime_error("Failed to parse route file list for " + route.canonical_name); + if (json.is_object() && json["error"].is_string()) { + throw std::runtime_error("Route API error for " + route.canonical_name + ": " + json["error"].string_value()); + } + return load_segments_from_json(json); +} + +std::map load_segments_from_local(const RouteSelection &route, const std::string &data_dir) { + std::map segments; + const std::string pattern = route.timestamp + "--"; + for (const auto &entry : fs::directory_iterator(data_dir)) { + if (!entry.is_directory()) continue; + const std::string dirname = entry.path().filename().string(); + if (dirname.find(pattern) == std::string::npos) continue; + const size_t marker = dirname.rfind("--"); + if (marker == std::string::npos) continue; + int segment_number = 0; + if (!parse_segment_number(dirname.substr(marker + 2), &segment_number)) { + continue; + } + for (const auto &file : fs::directory_iterator(entry.path())) { + if (file.is_regular_file()) { + add_log_file_to_segments(&segments, segment_number, file.path().string()); + } + } + } + return segments; +} + +RouteIdentifier make_route_identifier(const RouteSelection &route, const std::map &segments) { + RouteIdentifier route_id; + route_id.dongle_id = route.dongle_id; + route_id.log_id = route.timestamp; + route_id.slice_begin = route.begin_segment; + route_id.slice_end = route.end_segment; + route_id.slice_explicit = route.slice_explicit; + route_id.selector = route.selector; + route_id.selector_explicit = route.selector_explicit; + if (!segments.empty()) { + route_id.available_begin = segments.begin()->first; + route_id.available_end = segments.rbegin()->first; + } + return route_id; +} + +std::string detect_dbc_for_fingerprint(std::string_view car_fingerprint) { + return std::string(dbc_for_car_fingerprint(car_fingerprint)); +} + +std::vector available_dbc_names_impl() { + std::set names; + for (const fs::path &dbc_dir : { + repo_root() / "opendbc" / "dbc", + repo_root() / "tools" / "jotpluggler" / "generated_dbcs", + }) { + if (fs::exists(dbc_dir) && fs::is_directory(dbc_dir)) { + for (const auto &entry : fs::directory_iterator(dbc_dir)) { + if (!entry.is_regular_file() || entry.path().extension() != ".dbc") { + continue; + } + names.insert(entry.path().stem().string()); + } + } + } + for (const auto &[_, dbc_name] : kCarFingerprintToDbc) { + if (!dbc_name.empty()) { + names.insert(std::string(dbc_name)); + } + } + return std::vector(names.begin(), names.end()); +} + +fs::path resolve_dbc_path(const std::string &dbc_name) { + for (const fs::path &candidate : { + repo_root() / "opendbc" / "dbc" / (dbc_name + ".dbc"), + repo_root() / "tools" / "jotpluggler" / "generated_dbcs" / (dbc_name + ".dbc"), + }) { + if (fs::exists(candidate)) return candidate; + } + throw std::runtime_error("DBC not found: " + dbc_name); +} + +std::array parse_color(std::string_view color) { + if (!color.empty() && color.front() == '#') { + color.remove_prefix(1); + } + if (color.size() != 6) return {160, 170, 180}; + + std::array out = {}; + for (size_t i = 0; i < 3; ++i) { + const std::string byte(color.substr(i * 2, 2)); + char *end = nullptr; + const long parsed = std::strtol(byte.c_str(), &end, 16); + if (end == nullptr || *end != '\0' || parsed < 0 || parsed > 255) return {160, 170, 180}; + out[i] = static_cast(parsed); + } + return out; +} + +uint8_t android_priority_to_level(uint8_t priority) { + switch (priority) { + case 2: + case 3: + return 10; + case 4: + return 20; + case 5: + return 30; + case 6: + return 40; + case 7: + default: + return 50; + } +} + +uint8_t alert_status_to_level(cereal::SelfdriveState::AlertStatus status) { + switch (status) { + case cereal::SelfdriveState::AlertStatus::NORMAL: + return 20; + case cereal::SelfdriveState::AlertStatus::USER_PROMPT: + return 30; + case cereal::SelfdriveState::AlertStatus::CRITICAL: + return 40; + } + return 20; +} + +TimelineEntry::Type alert_status_to_timeline_type(cereal::SelfdriveState::AlertStatus status, bool enabled) { + if (!enabled) { + return TimelineEntry::Type::None; + } + switch (status) { + case cereal::SelfdriveState::AlertStatus::NORMAL: + return TimelineEntry::Type::Engaged; + case cereal::SelfdriveState::AlertStatus::USER_PROMPT: + return TimelineEntry::Type::AlertInfo; + case cereal::SelfdriveState::AlertStatus::CRITICAL: + return TimelineEntry::Type::AlertCritical; + } + return TimelineEntry::Type::Engaged; +} + +void append_timeline_entry(std::vector *timeline, double mono_time, TimelineEntry::Type type) { + if (timeline == nullptr) { + return; + } + if (!timeline->empty() && timeline->back().type == type) { + timeline->back().end_time = std::max(timeline->back().end_time, mono_time); + return; + } + timeline->push_back(TimelineEntry{ + .start_time = mono_time, + .end_time = mono_time, + .type = type, + }); +} + +double android_wall_time_seconds(uint64_t timestamp) { + if (timestamp == 0) return 0.0; + if (timestamp > 1000000000000ULL) return static_cast(timestamp) / 1.0e9; + if (timestamp > 1000000000ULL) return static_cast(timestamp) / 1.0e6; + return static_cast(timestamp); +} + +std::optional json_u64_value(const json11::Json &value) { + if (value.is_number()) { + const double number = value.number_value(); + if (number >= 0.0) return static_cast(number); + } + if (value.is_string()) { + try { + return static_cast(std::stoull(value.string_value())); + } catch (...) { + } + } + return std::nullopt; +} + +std::optional json_int_value(const json11::Json &value) { + if (value.is_number()) return value.int_value(); + if (value.is_string()) { + try { + return std::stoi(value.string_value()); + } catch (...) { + } + } + return std::nullopt; +} + +std::string json_value_for_log(const json11::Json &value) { + if (value.is_string()) return value.string_value(); + if (value.is_bool()) return value.bool_value() ? "true" : "false"; + return value.dump(); +} + +std::string format_journal_context(const json11::Json &parsed, int pid, int tid) { + std::vector lines; + if (pid != 0 || tid != 0) { + lines.push_back("pid=" + std::to_string(pid) + ", tid=" + std::to_string(tid)); + } + + const std::array preferred_keys = { + "_HOSTNAME", + "_TRANSPORT", + "PRIORITY", + "SYSLOG_FACILITY", + "__MONOTONIC_TIMESTAMP", + }; + for (const char *key : preferred_keys) { + const json11::Json &value = parsed[key]; + if (!value.is_null()) { + lines.push_back(std::string(key) + "=" + json_value_for_log(value)); + } + } + return join(lines, "\n"); +} + +std::string alert_message_text(const cereal::SelfdriveState::Reader &state) { + std::string text = state.getAlertText1().cStr(); + const std::string text2 = state.getAlertText2().cStr(); + if (!text2.empty()) { + text += " - " + text2; + } + return text; +} + +bool same_log_entry(const LogEntry &a, const LogEntry &b) { + return a.mono_time == b.mono_time + && a.level == b.level + && a.source == b.source + && a.func == b.func + && a.message == b.message + && a.context == b.context + && a.origin == b.origin; +} + +void append_log_event(cereal::Event::Which which, + const cereal::Event::Reader &event, + double time_offset, + std::vector *logs, + std::string *last_alert_key) { + const double boot_time = static_cast(event.getLogMonoTime()) / 1.0e9; + const double mono_time = boot_time - time_offset; + + auto make_entry = [&](LogOrigin origin, uint8_t level = 20) { + LogEntry e; + e.mono_time = mono_time; + e.boot_time = boot_time; + e.origin = origin; + e.level = level; + return e; + }; + + switch (which) { + case cereal::Event::Which::LOG_MESSAGE: + case cereal::Event::Which::ERROR_LOG_MESSAGE: { + const std::string raw = which == cereal::Event::Which::LOG_MESSAGE + ? event.getLogMessage().cStr() : event.getErrorLogMessage().cStr(); + auto entry = make_entry(LogOrigin::Log, which == cereal::Event::Which::ERROR_LOG_MESSAGE ? 40 : 20); + entry.source = "log"; + entry.message = raw; + std::string err; + if (const auto p = json11::Json::parse(raw, err); err.empty() && p.is_object()) { + entry.wall_time = p["created"].number_value(); + if (p["levelnum"].is_number()) entry.level = static_cast(p["levelnum"].int_value()); + const std::string fn = p["filename"].string_value(); + const int ln = p["lineno"].is_number() ? p["lineno"].int_value() : 0; + entry.source = fn.empty() ? "log" : fn + (ln > 0 ? ":" + std::to_string(ln) : ""); + entry.func = p["funcname"].string_value(); + if (p["msg"].is_string()) entry.message = p["msg"].string_value(); + if (!p["ctx"].is_null()) entry.context = p["ctx"].dump(); + } + logs->push_back(std::move(entry)); + break; + } + case cereal::Event::Which::ANDROID_LOG: { + const auto android = event.getAndroidLog(); + auto entry = make_entry(LogOrigin::Android, android_priority_to_level(android.getPriority())); + entry.wall_time = android_wall_time_seconds(android.getTs()); + entry.source = android.hasTag() ? android.getTag().cStr() : "android"; + entry.message = android.hasMessage() ? android.getMessage().cStr() : std::string(); + entry.context = "pid=" + std::to_string(android.getPid()) + ", tid=" + std::to_string(android.getTid()); + if (!entry.message.empty()) { + std::string err; + if (const auto p = json11::Json::parse(entry.message, err); err.empty() && p.is_object()) { + if (p["MESSAGE"].is_string()) entry.message = p["MESSAGE"].string_value(); + if (p["SYSLOG_IDENTIFIER"].is_string() && !p["SYSLOG_IDENTIFIER"].string_value().empty()) + entry.source = p["SYSLOG_IDENTIFIER"].string_value(); + if (auto pri = json_int_value(p["PRIORITY"]); pri.has_value()) + entry.level = android_priority_to_level(*pri); + if (auto ts = json_u64_value(p["__REALTIME_TIMESTAMP"]); ts.has_value()) + entry.wall_time = android_wall_time_seconds(*ts); + entry.context = format_journal_context(p, android.getPid(), android.getTid()); + } + } + logs->push_back(std::move(entry)); + break; + } + case cereal::Event::Which::SELFDRIVE_STATE: { + const auto sd = event.getSelfdriveState(); + const std::string alert_type = sd.getAlertType().cStr(); + const std::string alert_text1 = sd.getAlertText1().cStr(); + if (alert_text1.empty() && alert_type.empty()) break; + const std::string key = alert_type + "\n" + alert_text1 + "\n" + std::string(sd.getAlertText2().cStr()); + if (last_alert_key != nullptr && key == *last_alert_key) break; + if (last_alert_key != nullptr) *last_alert_key = key; + auto entry = make_entry(LogOrigin::Alert, alert_status_to_level(sd.getAlertStatus())); + entry.source = "alert"; + entry.func = alert_type; + entry.message = alert_message_text(sd); + logs->push_back(std::move(entry)); + break; + } + default: + break; + } +} + +std::vector extract_segment_timeline(const std::vector &events) { + std::vector timeline; + timeline.reserve(events.size() / 16); + + for (const Event &event_record : events) { + if (event_record.which != cereal::Event::Which::SELFDRIVE_STATE) { + continue; + } + with_parseable_event(event_record.data, [&](const cereal::Event::Reader &event) { + const auto sd = event.getSelfdriveState(); + const double mono_time = static_cast(event.getLogMonoTime()) / 1.0e9; + append_timeline_entry(&timeline, mono_time, alert_status_to_timeline_type(sd.getAlertStatus(), sd.getEnabled())); + }); + } + + return timeline; +} + +std::vector extract_segment_logs(const std::vector &events) { + std::vector logs; + logs.reserve(events.size() / 8); + std::string last_alert_key; + + for (const Event &event_record : events) { + with_parseable_event(event_record.data, [&](const cereal::Event::Reader &event) { + append_log_event(event_record.which, event, 0.0, &logs, &last_alert_key); + }); + } + + return logs; +} + +RouteMetadata extract_segment_metadata(const std::vector &events) { + RouteMetadata metadata; + for (const Event &event_record : events) { + if (event_record.which != cereal::Event::Which::CAR_PARAMS) continue; + with_parseable_event(event_record.data, [&](const cereal::Event::Reader &event) { + metadata.car_fingerprint = event.getCarParams().getCarFingerprint().cStr(); + }); + if (!metadata.car_fingerprint.empty()) break; + } + return metadata; +} + +RouteMetadata detect_route_metadata(const std::map &segments, LogSelector selector) { + for (const auto &[_, segment] : segments) { + const std::string &log_path = selector == LogSelector::Auto + ? (!segment.qlog.empty() ? segment.qlog : segment.rlog) + : selected_log_path(segment, selector); + if (log_path.empty()) { + continue; + } + LogReader reader; + if (!reader.load(log_path, nullptr, true)) continue; + RouteMetadata metadata = extract_segment_metadata(reader.events); + if (!metadata.car_fingerprint.empty()) return metadata; + } + return {}; +} + +std::vector normalize_sizes(const json11::Json &sizes_json, size_t child_count) { + std::vector parsed; + if (sizes_json.is_array()) { + for (const json11::Json &value : sizes_json.array_items()) { + if (value.is_number()) { + parsed.push_back(std::max(value.number_value(), 0.0)); + } + } + } + + if (parsed.size() != child_count || child_count == 0) return std::vector(child_count, child_count == 0 ? 0.0 : 1.0 / static_cast(child_count)); + + const double total = std::accumulate(parsed.begin(), parsed.end(), 0.0); + if (total <= 0.0) return std::vector(child_count, 1.0 / static_cast(child_count)); + for (double &value : parsed) { + value /= total; + } + return parsed; +} + +PlotRange parse_range(const json11::Json &pane_node) { + PlotRange range; + const json11::Json &range_node = pane_node["range"]; + if (range_node.is_object()) { + range.valid = true; + range.left = range_node["left"].number_value(); + range.right = range_node["right"].number_value(); + range.bottom = range_node["bottom"].number_value(); + range.top = range_node["top"].is_number() ? range_node["top"].number_value() : 1.0; + } + const json11::Json &limit_y_node = pane_node["y_limits"]; + if (limit_y_node.is_object()) { + if (limit_y_node["min"].is_number()) { + range.has_y_limit_min = true; + range.y_limit_min = limit_y_node["min"].number_value(); + } + if (limit_y_node["max"].is_number()) { + range.has_y_limit_max = true; + range.y_limit_max = limit_y_node["max"].number_value(); + } + } + return range; +} + +Curve parse_curve(const json11::Json &curve_node) { + Curve curve; + curve.name = curve_node["name"].string_value(); + curve.label = curve_label(curve.name); + curve.color = parse_color(curve_node["color"].string_value()); + + const std::string transform_name = curve_node["transform"].string_value(); + if (transform_name == "derivative") { + curve.derivative = true; + curve.derivative_dt = curve_node["derivative_dt"].is_number() ? curve_node["derivative_dt"].number_value() : 0.0; + } else if (transform_name == "scale") { + curve.value_scale = curve_node["scale"].is_number() ? curve_node["scale"].number_value() : 1.0; + curve.value_offset = curve_node["offset"].is_number() ? curve_node["offset"].number_value() : 0.0; + } + const json11::Json &custom_node = curve_node["custom_python"]; + if (custom_node.is_object()) { + CustomPythonSeries spec; + spec.linked_source = custom_node["linked_source"].string_value(); + spec.globals_code = custom_node["globals_code"].string_value(); + spec.function_code = custom_node["function_code"].string_value(); + for (const json11::Json &source : custom_node["additional_sources"].array_items()) { + if (source.is_string()) { + spec.additional_sources.push_back(source.string_value()); + } + } + curve.custom_python = std::move(spec); + } + return curve; +} + +std::string pane_title(const json11::Json &dock_area_node) { + const std::string raw = dock_area_node["title"].string_value(); + return raw.empty() ? "..." : raw; +} + +Pane parse_dock_area(const json11::Json &dock_area_node) { + Pane pane; + const std::string kind = dock_area_node["kind"].string_value(); + if (kind == "map") { + pane.kind = PaneKind::Map; + } else if (kind == "camera") { + pane.kind = PaneKind::Camera; + const std::string camera_view = dock_area_node["camera_view"].string_value(); + if (const CameraViewSpec *spec = camera_view_spec_from_layout_name(camera_view)) { + pane.camera_view = spec->view; + } else { + pane.camera_view = CameraViewKind::Road; + } + } + pane.range = parse_range(dock_area_node); + const json11::Json &curves_node = dock_area_node["curves"]; + if (curves_node.is_array()) { + for (const json11::Json &curve_node : curves_node.array_items()) { + if (curve_node.is_object()) { + pane.curves.push_back(parse_curve(curve_node)); + } + } + } + pane.title = pane_title(dock_area_node); + return pane; +} + +WorkspaceNode parse_workspace_node(const json11::Json &node, WorkspaceTab *tab) { + WorkspaceNode workspace_node; + if (!node.is_object()) return workspace_node; + + if (node["curves"].is_array()) { + workspace_node.is_pane = true; + workspace_node.pane_index = static_cast(tab->panes.size()); + tab->panes.push_back(parse_dock_area(node)); + return workspace_node; + } + + const json11::Json &children_node = node["children"]; + if (!children_node.is_array()) return workspace_node; + + const std::vector children = children_node.array_items(); + if (children.empty()) return workspace_node; + + const std::string split = node["split"].string_value(); + workspace_node.orientation = split == "vertical" ? SplitOrientation::Vertical : SplitOrientation::Horizontal; + const std::vector sizes = normalize_sizes(node["sizes"], children.size()); + workspace_node.sizes.reserve(sizes.size()); + workspace_node.children.reserve(children.size()); + for (size_t i = 0; i < children.size(); ++i) { + workspace_node.sizes.push_back(static_cast(sizes[i])); + workspace_node.children.push_back(parse_workspace_node(children[i], tab)); + } + return workspace_node; +} + +WorkspaceTab parse_tab(const json11::Json &tab, const fs::path &layout_path) { + WorkspaceTab workspace_tab; + workspace_tab.tab_name = tab["name"].string_value().empty() ? "tab1" : tab["name"].string_value(); + const json11::Json &dock_root = tab["root"]; + if (!dock_root.is_object()) throw std::runtime_error("Layout tab has no dock content: " + layout_path.string()); + workspace_tab.root = parse_workspace_node(dock_root, &workspace_tab); + return workspace_tab; +} + +SketchLayout parse_layout(const fs::path &layout_path) { + const std::string text = util::read_file(layout_path.string()); + if (text.empty()) throw std::runtime_error("Failed to read layout JSON: " + layout_path.string()); + + std::string parse_error; + const json11::Json root = json11::Json::parse(text, parse_error); + if (!parse_error.empty() || !root.is_object()) { + throw std::runtime_error("Failed to parse layout JSON: " + layout_path.string()); + } + SketchLayout layout; + for (const json11::Json &tab : root["tabs"].array_items()) { + if (tab.is_object()) { + layout.tabs.push_back(parse_tab(tab, layout_path)); + } + } + if (layout.tabs.empty()) throw std::runtime_error("Layout has no tabs: " + layout_path.string()); + const json11::Json &tab_index = root["current_tab_index"].is_number() ? root["current_tab_index"] : root["currentTabIndex"]; + layout.current_tab_index = std::clamp(tab_index.is_number() ? tab_index.int_value() : 0, + 0, + static_cast(layout.tabs.size()) - 1); + return layout; +} + +ScalarKind scalar_kind_for_type(const capnp::Type &type) { + if (type.isBool()) return ScalarKind::Bool; + if (type.isInt8() || type.isInt16() || type.isInt32() || type.isInt64()) { + return ScalarKind::Int; + } + if (type.isUInt8() || type.isUInt16() || type.isUInt32() || type.isUInt64()) { + return ScalarKind::UInt; + } + if (type.isFloat32() || type.isFloat64()) { + return ScalarKind::Float; + } + if (type.isEnum()) return ScalarKind::Enum; + return ScalarKind::None; +} + +ResolvedNode build_resolved_type(const capnp::Type &type, + bool has_field, + capnp::StructSchema::Field field, + std::string segment, + std::string path, + size_t *next_fixed_slot, + std::vector *fixed_paths, + bool dynamic_path = false) { + ResolvedNode node; + node.has_field = has_field; + node.field = field; + node.segment = std::move(segment); + node.path = std::move(path); + node.scalar_kind = scalar_kind_for_type(type); + if (node.scalar_kind != ScalarKind::None) { + node.kind = ResolvedNodeKind::Scalar; + if (!dynamic_path) { + node.fixed_slot = static_cast((*next_fixed_slot)++); + fixed_paths->push_back(node.path); + } + return node; + } + + if (type.isStruct()) { + node.kind = ResolvedNodeKind::Struct; + for (auto child : type.asStruct().getFields()) { + const std::string child_segment = child.getProto().getName().cStr(); + node.children.push_back(build_resolved_type( + child.getType(), + true, + child, + child_segment, + node.path + "/" + child_segment, + next_fixed_slot, + fixed_paths, + dynamic_path)); + } + return node; + } + + if (type.isList()) { + const capnp::Type element_type = type.asList().getElementType(); + if (element_type.isText() || element_type.isData() || element_type.isInterface() || element_type.isAnyPointer()) { + node.kind = ResolvedNodeKind::Ignore; + return node; + } + node.kind = ResolvedNodeKind::List; + node.skip_large_scalar_list = scalar_kind_for_type(element_type) != ScalarKind::None; + node.element = std::make_unique( + build_resolved_type(element_type, + false, + capnp::StructSchema::Field(), + "", + node.path, + next_fixed_slot, + fixed_paths, + true)); + return node; + } + + node.kind = ResolvedNodeKind::Ignore; + return node; +} + +int register_fixed_series_path(const std::string &path, + size_t *next_fixed_slot, + std::vector *fixed_paths) { + const int slot = static_cast((*next_fixed_slot)++); + fixed_paths->push_back(path); + return slot; +} + +const SchemaIndex &SchemaIndex::instance() { + static const SchemaIndex index = [] { + SchemaIndex out; + const auto event_schema = capnp::Schema::from().asStruct(); + uint16_t max_discriminant = 0; + for (auto union_field : event_schema.getUnionFields()) { + max_discriminant = std::max(max_discriminant, union_field.getProto().getDiscriminantValue()); + } + out.by_which.resize(static_cast(max_discriminant) + 1); + size_t next_fixed_slot = 0; + for (auto union_field : event_schema.getUnionFields()) { + ResolvedService service; + service.event_which = union_field.getProto().getDiscriminantValue(); + service.union_field = union_field; + service.service_name = union_field.getProto().getName().cStr(); + service.valid_slot = register_fixed_series_path( + "/" + service.service_name + "/valid", &next_fixed_slot, &out.fixed_paths); + service.log_mono_time_slot = register_fixed_series_path( + "/" + service.service_name + "/logMonoTime", &next_fixed_slot, &out.fixed_paths); + service.seconds_slot = register_fixed_series_path( + "/" + service.service_name + "/t", &next_fixed_slot, &out.fixed_paths); + service.payload = build_resolved_type( + union_field.getType(), + false, + capnp::StructSchema::Field(), + service.service_name, + "/" + service.service_name, + &next_fixed_slot, + &out.fixed_paths); + out.by_which[service.event_which] = std::move(service); + } + out.fixed_series_count = next_fixed_slot; + return out; + }(); + return index; +} + +bool is_absolute_curve(const std::string &name) { + return !name.empty() && name.front() == '/'; +} + +std::optional scalar_value_to_double(const capnp::DynamicValue::Reader &value, ScalarKind kind) { + switch (kind) { + case ScalarKind::Bool: + return value.as() ? 1.0 : 0.0; + case ScalarKind::Int: + return static_cast(value.as()); + case ScalarKind::UInt: + return static_cast(value.as()); + case ScalarKind::Float: + return value.as(); + case ScalarKind::Enum: + return static_cast(value.as().getRaw()); + case ScalarKind::None: + return std::nullopt; + } + return std::nullopt; +} + +void capture_enum_info(const std::string &path, + const capnp::DynamicValue::Reader &value, + SeriesAccumulator *series) { + if (series->enum_info.find(path) != series->enum_info.end()) { + return; + } + + const auto dynamic_enum = value.as(); + EnumInfo info; + for (auto enumerant : dynamic_enum.getSchema().getEnumerants()) { + const uint16_t ordinal = enumerant.getOrdinal(); + if (ordinal >= info.names.size()) { + info.names.resize(static_cast(ordinal) + 1); + } + info.names[ordinal] = enumerant.getProto().getName().cStr(); + } + if (!info.names.empty()) { + series->enum_info.emplace(path, std::move(info)); + } +} + +void append_scalar_point(RouteSeries *series, + const std::string &path, + double tm, + double value) { + if (series->path.empty()) { + series->path = path; + } + series->times.push_back(tm); + series->values.push_back(value); +} + +void append_fixed_scalar_point(RouteSeries *series, double tm, double value) { + series->times.push_back(tm); + series->values.push_back(value); +} + +CanMessageData *ensure_can_message(CanServiceKind service, uint8_t bus, uint32_t address, SeriesAccumulator *series) { + const CanMessageId id{service, bus, address}; + auto [it, inserted] = series->can_message_slots.try_emplace(id, series->can_messages.size()); + if (inserted) { + series->can_messages.push_back(CanMessageData{.id = id}); + } + return &series->can_messages[it->second]; +} + +void append_can_frame(CanServiceKind service, + uint8_t bus, + uint32_t address, + uint16_t bus_time, + capnp::Data::Reader dat, + double tm, + SeriesAccumulator *series) { + CanMessageData *message = ensure_can_message(service, bus, address, series); + message->samples.push_back(CanFrameSample{ + .mono_time = tm, + .bus_time = bus_time, + .data = std::string(reinterpret_cast(dat.begin()), dat.size()), + }); +} + +void append_dynamic_scalar_point(const std::string &path, double tm, double value, SeriesAccumulator *series); + +void decode_can_frame(const dbc::Database *can_dbc, + const std::string &service_name, + uint8_t bus, + uint32_t address, + const uint8_t *raw, + size_t data_size, + double tm, + SeriesAccumulator *series) { + if (can_dbc == nullptr) { + return; + } + const dbc::Message *message = can_dbc->message(address); + if (message == nullptr) { + return; + } + const std::string base_path = "/" + service_name + "/" + std::to_string(bus) + "/" + message->name; + for (const dbc::Signal &signal : message->signals) { + std::optional value = dbc::signalValue(signal, *message, raw, data_size); + if (!value.has_value()) continue; + const std::string path = base_path + "/" + signal.name; + append_dynamic_scalar_point(path, tm, *value, series); + if (series->enum_info.find(path) == series->enum_info.end()) { + std::vector enum_names = can_dbc->enumNames(signal); + if (!enum_names.empty()) { + series->enum_info.emplace(path, EnumInfo{.names = std::move(enum_names)}); + } + } + } +} + +void append_live_can_frame(CanServiceKind service, + const LiveCanFrame &frame, + double time_offset, + const dbc::Database *can_dbc, + SeriesAccumulator *series) { + const double tm = frame.mono_time - time_offset; + CanMessageData *message = ensure_can_message(service, frame.bus, frame.address, series); + message->samples.push_back(CanFrameSample{ + .mono_time = tm, + .bus_time = frame.bus_time, + .data = frame.data, + }); + decode_can_frame(can_dbc, + service == CanServiceKind::Can ? "can" : "sendcan", + frame.bus, + frame.address, + reinterpret_cast(frame.data.data()), + frame.data.size(), + tm, + series); +} + +SeriesAccumulator make_series_accumulator(const SchemaIndex &schema) { + SeriesAccumulator out(schema.fixed_series_count); + for (size_t i = 0; i < schema.fixed_paths.size(); ++i) { + out.fixed_series[i].path = schema.fixed_paths[i]; + } + return out; +} + +size_t ensure_dynamic_slot(const std::string &path, SeriesAccumulator *series) { + auto [it, inserted] = series->dynamic_slots.try_emplace(path, series->dynamic_series.size()); + if (inserted) { + series->dynamic_series.push_back(RouteSeries{it->first}); + } + return it->second; +} + +RouteSeries *ensure_dynamic_series(const std::string &path, SeriesAccumulator *series) { + return &series->dynamic_series[ensure_dynamic_slot(path, series)]; +} + +RouteSeries *ensure_list_scalar_series(const std::string &base_path, size_t index, SeriesAccumulator *series) { + auto [it, _] = series->list_scalar_slots.try_emplace(base_path); + std::vector &slots = it->second; + if (slots.size() <= index) { + slots.resize(index + 1, INVALID_DYNAMIC_SLOT); + } + if (slots[index] == INVALID_DYNAMIC_SLOT) { + slots[index] = ensure_dynamic_slot(base_path + "/" + std::to_string(index), series); + } + return &series->dynamic_series[slots[index]]; +} + +void append_dynamic_scalar_point(const std::string &path, double tm, double value, SeriesAccumulator *series) { + append_scalar_point(ensure_dynamic_series(path, series), path, tm, value); +} + +void append_scalar_value(const ResolvedNode &node, + const std::string *path_override, + const capnp::DynamicValue::Reader &raw_value, + double tm, + double value, + SeriesAccumulator *series) { + if (path_override == nullptr && node.fixed_slot >= 0) { + if (node.scalar_kind == ScalarKind::Enum) { + capture_enum_info(node.path, raw_value, series); + } + append_fixed_scalar_point(&series->fixed_series[static_cast(node.fixed_slot)], tm, value); + return; + } + + const std::string &path = path_override != nullptr ? *path_override : node.path; + if (node.scalar_kind == ScalarKind::Enum) { + capture_enum_info(path, raw_value, series); + } + append_dynamic_scalar_point(path, tm, value, series); +} + +void append_fast_node(const ResolvedNode &node, + const capnp::DynamicValue::Reader &value, + double tm, + SeriesAccumulator *series, + const std::string *path_override = nullptr) { + switch (node.kind) { + case ResolvedNodeKind::Scalar: { + if (std::optional scalar = scalar_value_to_double(value, node.scalar_kind); scalar.has_value()) { + append_scalar_value(node, path_override, value, tm, *scalar, series); + } + return; + } + case ResolvedNodeKind::Struct: { + const capnp::DynamicStruct::Reader reader = value.as(); + for (const ResolvedNode &child : node.children) { + if (!child.has_field || !reader.has(child.field)) continue; + if (path_override == nullptr) { + append_fast_node(child, reader.get(child.field), tm, series, nullptr); + } else { + const std::string child_path = child.segment.empty() ? *path_override : (*path_override + "/" + child.segment); + append_fast_node(child, reader.get(child.field), tm, series, &child_path); + } + } + return; + } + case ResolvedNodeKind::List: { + if (!node.element) { + return; + } + const capnp::DynamicList::Reader list = value.as(); + if (list.size() == 0) { + return; + } + if (node.skip_large_scalar_list && list.size() > 16) { + return; + } + const std::string &base_path = path_override != nullptr ? *path_override : node.path; + if (node.element->kind == ResolvedNodeKind::Scalar) { + for (uint i = 0; i < list.size(); ++i) { + if (std::optional scalar = scalar_value_to_double(list[i], node.element->scalar_kind); scalar.has_value()) { + RouteSeries *item_series = ensure_list_scalar_series(base_path, i, series); + if (node.element->scalar_kind == ScalarKind::Enum && !item_series->path.empty()) { + capture_enum_info(item_series->path, list[i], series); + } + append_fixed_scalar_point(item_series, tm, *scalar); + } + } + return; + } + for (uint i = 0; i < list.size(); ++i) { + const std::string item_path = base_path + "/" + std::to_string(i); + append_fast_node(*node.element, list[i], tm, series, &item_path); + } + return; + } + case ResolvedNodeKind::Ignore: + return; + } +} + +void append_event_fast_reader(cereal::Event::Which which, + const cereal::Event::Reader &event, + const SchemaIndex &schema, + const dbc::Database *can_dbc, + bool skip_raw_can, + double time_offset, + SeriesAccumulator *series) { + const uint16_t which_index = static_cast(which); + if (which_index >= schema.by_which.size() || !schema.by_which[which_index].has_value()) { + return; + } + const ResolvedService &service = *schema.by_which[which_index]; + const capnp::DynamicStruct::Reader dynamic_event(event); + const capnp::DynamicValue::Reader payload = dynamic_event.get(service.union_field); + const double tm = static_cast(event.getLogMonoTime()) / 1.0e9 - time_offset; + append_fixed_scalar_point(&series->fixed_series[static_cast(service.valid_slot)], + tm, + event.getValid() ? 1.0 : 0.0); + append_fixed_scalar_point(&series->fixed_series[static_cast(service.log_mono_time_slot)], + tm, + static_cast(event.getLogMonoTime())); + append_fixed_scalar_point(&series->fixed_series[static_cast(service.seconds_slot)], + tm, + tm); + if (service.service_name == "can" || service.service_name == "sendcan") { + const CanServiceKind can_service = service.service_name == "can" + ? CanServiceKind::Can + : CanServiceKind::Sendcan; + auto decode_message = [&](uint8_t bus, uint32_t address, const auto &dat_reader) { + const auto bytes = dat_reader.begin(); + decode_can_frame(can_dbc, service.service_name, bus, address, bytes, dat_reader.size(), tm, series); + }; + if (service.service_name == "can") { + for (const auto &msg : event.getCan()) { + append_can_frame(can_service, + static_cast(msg.getSrc()), + msg.getAddress(), + msg.getDeprecated().getBusTime(), + msg.getDat(), + tm, + series); + if (!skip_raw_can) continue; + decode_message(static_cast(msg.getSrc()), msg.getAddress(), msg.getDat()); + } + } else { + for (const auto &msg : event.getSendcan()) { + append_can_frame(can_service, + static_cast(msg.getSrc()), + msg.getAddress(), + msg.getDeprecated().getBusTime(), + msg.getDat(), + tm, + series); + if (!skip_raw_can) continue; + decode_message(static_cast(msg.getSrc()), msg.getAddress(), msg.getDat()); + } + } + if (skip_raw_can) { + return; + } + } + + append_fast_node(service.payload, payload, tm, series); +} + +void append_event_fast(cereal::Event::Which which, + int32_t eidx_segnum, + kj::ArrayPtr data, + const SchemaIndex &schema, + const dbc::Database *can_dbc, + bool skip_raw_can, + double time_offset, + SeriesAccumulator *series) { + if (eidx_segnum != -1) { + return; + } + with_parseable_event(data, [&](const cereal::Event::Reader &event) { + append_event_fast_reader(which, event, schema, can_dbc, skip_raw_can, time_offset, series); + }); +} + +void append_events_fast_range(const std::vector &events, + size_t begin, + size_t end, + const SchemaIndex &schema, + const dbc::Database *can_dbc, + bool skip_raw_can, + SeriesAccumulator *series) { + for (size_t i = begin; i < end; ++i) { + const Event &event_record = events[i]; + append_event_fast(event_record.which, + event_record.eidx_segnum, + event_record.data, + schema, + can_dbc, + skip_raw_can, + 0.0, + series); + } +} + +void merge_route_series(RouteSeries *dst, RouteSeries *src) { + if (src->times.empty()) { + return; + } + if (dst->times.empty()) { + *dst = std::move(*src); + return; + } + + dst->times.reserve(dst->times.size() + src->times.size()); + dst->values.reserve(dst->values.size() + src->values.size()); + dst->times.insert(dst->times.end(), src->times.begin(), src->times.end()); + dst->values.insert(dst->values.end(), src->values.begin(), src->values.end()); +} + +void merge_can_message_data(CanMessageData *dst, CanMessageData *src) { + if (src->samples.empty()) { + return; + } + if (dst->samples.empty()) { + *dst = std::move(*src); + return; + } + dst->samples.reserve(dst->samples.size() + src->samples.size()); + dst->samples.insert(dst->samples.end(), + std::make_move_iterator(src->samples.begin()), + std::make_move_iterator(src->samples.end())); +} + +void merge_series_accumulator(SeriesAccumulator *dst, SeriesAccumulator *src) { + if (dst->fixed_series.size() != src->fixed_series.size()) { + throw std::runtime_error("Fixed-series slot count mismatch during merge"); + } + + for (size_t i = 0; i < dst->fixed_series.size(); ++i) { + merge_route_series(&dst->fixed_series[i], &src->fixed_series[i]); + } + for (auto &series : src->dynamic_series) { + if (series.path.empty()) continue; + RouteSeries &dst_series = dst->dynamic_series[ensure_dynamic_slot(series.path, dst)]; + merge_route_series(&dst_series, &series); + } + for (auto &message : src->can_messages) { + CanMessageData &dst_message = *ensure_can_message(message.id.service, message.id.bus, message.id.address, dst); + merge_can_message_data(&dst_message, &message); + } + for (auto &[path, info] : src->enum_info) { + dst->enum_info.try_emplace(path, std::move(info)); + } +} + +size_t populated_series_count(const SeriesAccumulator &series) { + size_t count = 0; + for (const RouteSeries &fixed : series.fixed_series) { + count += !fixed.times.empty(); + } + for (const RouteSeries &dynamic : series.dynamic_series) { + count += !dynamic.times.empty(); + } + return count; +} + +bool series_is_sorted(const RouteSeries &series) { + for (size_t i = 1; i < series.times.size(); ++i) { + if (series.times[i] < series.times[i - 1]) return false; + } + return true; +} + +void sort_series_by_time(RouteSeries *series) { + if (series->times.size() <= 1 || series_is_sorted(*series)) { + return; + } + std::vector order(series->times.size()); + std::iota(order.begin(), order.end(), 0); + std::sort(order.begin(), order.end(), [&](size_t a, size_t b) { + return series->times[a] < series->times[b]; + }); + + std::vector sorted_times(series->times.size()); + std::vector sorted_values(series->values.size()); + for (size_t i = 0; i < order.size(); ++i) { + sorted_times[i] = series->times[order[i]]; + sorted_values[i] = series->values[order[i]]; + } + series->times = std::move(sorted_times); + series->values = std::move(sorted_values); +} + +std::vector collect_series(SeriesAccumulator &&series) { + std::vector out; + out.reserve(series.fixed_series.size() + series.dynamic_series.size()); + for (auto &fixed : series.fixed_series) { + sort_series_by_time(&fixed); + if (!fixed.times.empty()) { + out.push_back(std::move(fixed)); + } + } + for (auto &dynamic : series.dynamic_series) { + sort_series_by_time(&dynamic); + if (!dynamic.times.empty()) { + out.push_back(std::move(dynamic)); + } + } + return out; +} + +RouteData build_route_data(std::vector &&series_list, + std::vector &&can_messages, + std::vector &&logs, + std::vector &&timeline, + std::unordered_map &&enum_info, + std::string car_fingerprint, + std::string dbc_name) { + RouteData route_data; + route_data.series.reserve(series_list.size()); + route_data.paths.reserve(series_list.size()); + for (RouteSeries &series : series_list) { + if (series.times.empty()) continue; + route_data.has_time_range = true; + route_data.x_min = route_data.series.empty() ? series.times.front() : std::min(route_data.x_min, series.times.front()); + route_data.x_max = route_data.series.empty() ? series.times.back() : std::max(route_data.x_max, series.times.back()); + route_data.paths.push_back(series.path); + route_data.series.push_back(std::move(series)); + } + + std::sort(route_data.paths.begin(), route_data.paths.end()); + std::sort(route_data.series.begin(), route_data.series.end(), [](const RouteSeries &a, const RouteSeries &b) { + return a.path < b.path; + }); + std::sort(logs.begin(), logs.end(), [](const LogEntry &a, const LogEntry &b) { + return a.mono_time < b.mono_time; + }); + logs.erase(std::unique(logs.begin(), logs.end(), [](const LogEntry &a, const LogEntry &b) { + return same_log_entry(a, b); + }), + logs.end()); + + std::vector deduped_logs; + deduped_logs.reserve(logs.size()); + for (LogEntry &entry : logs) { + if (!deduped_logs.empty() + && entry.origin == LogOrigin::Alert + && deduped_logs.back().origin == LogOrigin::Alert + && deduped_logs.back().func == entry.func + && deduped_logs.back().message == entry.message) { + continue; + } + deduped_logs.push_back(std::move(entry)); + } + route_data.logs = std::move(deduped_logs); + + if (!route_data.has_time_range && !route_data.logs.empty()) { + route_data.has_time_range = true; + route_data.x_min = route_data.logs.front().mono_time; + route_data.x_max = route_data.logs.back().mono_time; + } + if (!route_data.has_time_range) { + bool initialized = false; + for (const CanMessageData &message : can_messages) { + if (message.samples.empty()) continue; + if (!initialized) { + route_data.x_min = message.samples.front().mono_time; + route_data.x_max = message.samples.back().mono_time; + initialized = true; + } else { + route_data.x_min = std::min(route_data.x_min, message.samples.front().mono_time); + route_data.x_max = std::max(route_data.x_max, message.samples.back().mono_time); + } + } + route_data.has_time_range = initialized; + } + if (!route_data.has_time_range && !timeline.empty()) { + route_data.has_time_range = true; + route_data.x_min = timeline.front().start_time; + route_data.x_max = timeline.back().end_time; + } + + if (route_data.has_time_range) { + const double time_offset = route_data.x_min; + for (RouteSeries &series : route_data.series) { + for (double &tm : series.times) { + tm -= time_offset; + } + } + for (LogEntry &entry : route_data.logs) { + entry.boot_time = entry.mono_time; + entry.mono_time -= time_offset; + } + for (CanMessageData &message : can_messages) { + for (CanFrameSample &sample : message.samples) { + sample.mono_time -= time_offset; + } + } + for (TimelineEntry &entry : timeline) { + entry.start_time -= time_offset; + entry.end_time -= time_offset; + } + route_data.x_max -= time_offset; + route_data.x_min = 0.0; + } + + std::sort(timeline.begin(), timeline.end(), [](const TimelineEntry &a, const TimelineEntry &b) { + return a.start_time < b.start_time; + }); + std::vector merged_timeline; + merged_timeline.reserve(timeline.size()); + for (TimelineEntry &entry : timeline) { + if (!merged_timeline.empty() && merged_timeline.back().type == entry.type) { + merged_timeline.back().end_time = std::max(merged_timeline.back().end_time, entry.end_time); + continue; + } + merged_timeline.push_back(std::move(entry)); + } + route_data.timeline = std::move(merged_timeline); + std::sort(can_messages.begin(), can_messages.end(), [](const CanMessageData &a, const CanMessageData &b) { + return std::make_tuple(a.id.service, a.id.bus, a.id.address) + < std::make_tuple(b.id.service, b.id.bus, b.id.address); + }); + route_data.can_messages = std::move(can_messages); + + route_data.enum_info = std::move(enum_info); + route_data.car_fingerprint = std::move(car_fingerprint); + route_data.dbc_name = std::move(dbc_name); + rebuild_gps_trace(&route_data); + route_data.roots = collect_route_roots_for_paths(route_data.paths); + return route_data; +} + +const RouteSeries *find_route_series(const RouteData &route_data, std::string_view path) { + auto it = std::lower_bound(route_data.series.begin(), route_data.series.end(), path, + [](const RouteSeries &series, std::string_view target) { + return series.path < target; + }); + if (it == route_data.series.end() || it->path != path) return nullptr; + return &(*it); +} + +std::optional sample_series_at_time(const RouteSeries &series, double tm) { + if (series.times.empty() || series.times.size() != series.values.size()) { + return std::nullopt; + } + if (tm <= series.times.front()) { + return series.values.front(); + } + if (tm >= series.times.back()) { + return series.values.back(); + } + auto upper = std::lower_bound(series.times.begin(), series.times.end(), tm); + if (upper == series.times.begin()) { + return series.values.front(); + } + if (upper == series.times.end()) { + return series.values.back(); + } + const size_t upper_index = static_cast(std::distance(series.times.begin(), upper)); + const size_t lower_index = upper_index - 1; + const double t0 = series.times[lower_index]; + const double t1 = series.times[upper_index]; + const double v0 = series.values[lower_index]; + const double v1 = series.values[upper_index]; + if (t1 <= t0) { + return v0; + } + const double alpha = (tm - t0) / (t1 - t0); + return v0 + (v1 - v0) * alpha; +} + +} // namespace + +void rebuild_gps_trace(RouteData *route_data) { + route_data->gps_trace = {}; + const RouteSeries *latitude = find_route_series(*route_data, "/gpsLocationExternal/latitude"); + const RouteSeries *longitude = find_route_series(*route_data, "/gpsLocationExternal/longitude"); + const RouteSeries *has_fix = find_route_series(*route_data, "/gpsLocationExternal/hasFix"); + if (latitude == nullptr || longitude == nullptr || has_fix == nullptr) { + return; + } + + const RouteSeries *bearing = find_route_series(*route_data, "/gpsLocationExternal/bearingDeg"); + size_t count = std::min({latitude->times.size(), latitude->values.size(), + longitude->times.size(), longitude->values.size(), + has_fix->times.size(), has_fix->values.size()}); + if (count == 0) { + return; + } + + bool found = false; + route_data->gps_trace.points.reserve(count); + for (size_t i = 0; i < count; ++i) { + if (has_fix->values[i] < 0.5) { + continue; + } + const double lat = latitude->values[i]; + const double lon = longitude->values[i]; + if (!std::isfinite(lat) || !std::isfinite(lon)) { + continue; + } + const double tm = latitude->times[i]; + const float bearing_value = bearing != nullptr + ? static_cast(sample_series_at_time(*bearing, tm).value_or(0.0)) + : 0.0f; + route_data->gps_trace.points.push_back(GpsPoint{ + .time = tm, + .lat = lat, + .lon = lon, + .bearing = bearing_value, + .type = timeline_type_at_time(route_data->timeline, tm), + }); + if (!found) { + route_data->gps_trace.min_lat = route_data->gps_trace.max_lat = lat; + route_data->gps_trace.min_lon = route_data->gps_trace.max_lon = lon; + found = true; + } else { + route_data->gps_trace.min_lat = std::min(route_data->gps_trace.min_lat, lat); + route_data->gps_trace.max_lat = std::max(route_data->gps_trace.max_lat, lat); + route_data->gps_trace.min_lon = std::min(route_data->gps_trace.min_lon, lon); + route_data->gps_trace.max_lon = std::max(route_data->gps_trace.max_lon, lon); + } + } + if (!found) { + route_data->gps_trace = {}; + } +} + +namespace { + +void build_camera_index(const std::map &segments, + const RouteData &route_data, + std::string SegmentLogs::*file_member, + std::string_view index_name, + CameraFeedIndex *out) { + *out = {}; + out->segment_files.reserve(segments.size()); + + std::unordered_set available_segments; + available_segments.reserve(segments.size()); + for (const auto &[segment_number, segment] : segments) { + const std::string &path = segment.*file_member; + if (path.empty()) continue; + out->segment_files.push_back(CameraSegmentFile{ + .segment = segment_number, + .path = path, + }); + available_segments.insert(segment_number); + } + if (out->segment_files.empty()) { + return; + } + + const std::string prefix = "/" + std::string(index_name); + const RouteSeries *segment_numbers = find_route_series(route_data, prefix + "/segmentNum"); + const RouteSeries *decode_indices = find_route_series(route_data, prefix + "/segmentId"); + if (decode_indices == nullptr) { + decode_indices = find_route_series(route_data, prefix + "/segmentIdEncode"); + } + const RouteSeries *frame_ids = find_route_series(route_data, prefix + "/frameId"); + if (segment_numbers == nullptr || decode_indices == nullptr) { + return; + } + + size_t count = std::min(segment_numbers->times.size(), segment_numbers->values.size()); + count = std::min(count, decode_indices->values.size()); + if (frame_ids != nullptr) { + count = std::min(count, frame_ids->values.size()); + } + out->entries.reserve(count); + for (size_t i = 0; i < count; ++i) { + const int segment_number = static_cast(std::llround(segment_numbers->values[i])); + if (available_segments.find(segment_number) == available_segments.end()) { + continue; + } + const int decode_index = static_cast(std::llround(decode_indices->values[i])); + const uint32_t frame_id = frame_ids != nullptr + ? static_cast(std::llround(frame_ids->values[i])) + : static_cast(std::max(0, decode_index)); + out->entries.push_back(CameraFrameIndexEntry{ + .timestamp = segment_numbers->times[i], + .segment = segment_number, + .decode_index = decode_index, + .frame_id = frame_id, + }); + } + + std::sort(out->entries.begin(), out->entries.end(), + [](const CameraFrameIndexEntry &a, const CameraFrameIndexEntry &b) { + return a.timestamp < b.timestamp; + }); +} + +size_t load_worker_budget() { + size_t worker_count = std::thread::hardware_concurrency(); + if (worker_count == 0) { + worker_count = 1; + } + if (const char *raw = std::getenv("JOTP_LOAD_WORKERS"); raw != nullptr && std::strlen(raw) > 0) { + char *end = nullptr; + const unsigned long parsed = std::strtoul(raw, &end, 10); + if (end != nullptr && *end == '\0' && parsed > 0) { + worker_count = static_cast(parsed); + } + } + return std::max(1, worker_count); +} + +size_t segment_worker_count(size_t segment_count, size_t worker_budget) { + return std::max(1, std::min(worker_budget, segment_count)); +} + +size_t extract_chunk_count(size_t event_count, size_t worker_budget, size_t segment_workers) { + if (event_count < 4096) return 1; + const size_t per_segment_budget = std::max(1, worker_budget / std::max(1, segment_workers)); + const size_t chunk_target = std::max(1, (event_count + 14999) / 15000); + return std::max(1, std::min({per_segment_budget, chunk_target, static_cast(8)})); +} + +SeriesAccumulator extract_segment_series(const std::vector &events, + const SchemaIndex &schema, + const dbc::Database *can_dbc, + bool skip_raw_can, + size_t worker_budget, + size_t segment_workers) { + const size_t chunk_count = extract_chunk_count(events.size(), worker_budget, segment_workers); + if (chunk_count <= 1 || events.empty()) { + SeriesAccumulator series = make_series_accumulator(schema); + append_events_fast_range(events, 0, events.size(), schema, can_dbc, skip_raw_can, &series); + return series; + } + + const size_t events_per_chunk = (events.size() + chunk_count - 1) / chunk_count; + std::vector chunk_results; + chunk_results.reserve(chunk_count); + for (size_t i = 0; i < chunk_count; ++i) { + chunk_results.push_back(make_series_accumulator(schema)); + } + + std::vector workers; + workers.reserve(chunk_count > 0 ? chunk_count - 1 : 0); + for (size_t chunk = 1; chunk < chunk_count; ++chunk) { + workers.emplace_back([&, chunk]() { + const size_t begin = chunk * events_per_chunk; + const size_t end = std::min(events.size(), begin + events_per_chunk); + append_events_fast_range(events, begin, end, schema, can_dbc, skip_raw_can, &chunk_results[chunk]); + }); + } + append_events_fast_range(events, 0, std::min(events.size(), events_per_chunk), schema, can_dbc, skip_raw_can, &chunk_results[0]); + for (std::thread &worker : workers) { + worker.join(); + } + + SeriesAccumulator merged = make_series_accumulator(schema); + for (SeriesAccumulator &chunk : chunk_results) { + merge_series_accumulator(&merged, &chunk); + } + return merged; +} + +LoadedRouteArtifacts load_route_series_parallel( + const std::map &segments, + const SchemaIndex &schema, + const dbc::Database *can_dbc, + LogSelector selector, + bool skip_raw_can, + LoadStats *stats) { + struct SegmentResult { + SeriesAccumulator series; + std::vector logs; + std::vector timeline; + }; + + const std::vector> segment_list(segments.begin(), segments.end()); + std::vector results; + results.reserve(segment_list.size()); + for (size_t i = 0; i < segment_list.size(); ++i) { + results.emplace_back(SegmentResult{make_series_accumulator(schema)}); + } + std::atomic next_segment{0}; + std::mutex error_mutex; + std::string first_error; + const size_t worker_budget = static_cast(stats->num_workers); + const size_t segment_workers = segment_worker_count(segment_list.size(), worker_budget); + + auto worker = [&]() { + while (true) { + const size_t index = next_segment.fetch_add(1); + if (index >= segment_list.size()) { + return; + } + + const auto &[segment_number, segment] = segment_list[index]; + const std::string &log_path = selected_log_path(segment, selector); + LoadStats::SegmentStats &segment_stats = stats->segments[index]; + segment_stats.segment_number = segment_number; + segment_stats.log_path = log_path; + if (log_path.empty()) { + segment_stats.failed = true; + std::lock_guard lock(error_mutex); + if (first_error.empty()) { + first_error = "Missing log path for segment " + std::to_string(segment_number); + } + stats->publish(RouteLoadStage::DownloadingSegment, index, std::to_string(segment_number)); + stats->publish(RouteLoadStage::ParsingSegment, index, std::to_string(segment_number)); + continue; + } + + LogReader reader; + if (!reader.load(log_path, nullptr, true)) { + segment_stats.failed = true; + std::lock_guard lock(error_mutex); + if (first_error.empty()) { + first_error = "Failed to load log segment: " + log_path; + } + stats->publish(RouteLoadStage::DownloadingSegment, index, std::to_string(segment_number)); + stats->publish(RouteLoadStage::ParsingSegment, index, std::to_string(segment_number)); + continue; + } + + segment_stats.download_seconds = reader.download_seconds(); + segment_stats.decompress_seconds = reader.decompress_seconds(); + segment_stats.parse_seconds = reader.parse_seconds(); + segment_stats.compressed_bytes = reader.compressed_size(); + segment_stats.decompressed_bytes = reader.decompressed_size(); + stats->bytes_downloaded.fetch_add(reader.compressed_size()); + stats->segments_downloaded.fetch_add(1); + stats->publish(RouteLoadStage::DownloadingSegment, index, std::to_string(segment_number)); + + const auto extract_start = LoadStats::Clock::now(); + results[index].series = extract_segment_series(reader.events, schema, can_dbc, skip_raw_can, worker_budget, segment_workers); + results[index].logs = extract_segment_logs(reader.events); + results[index].timeline = extract_segment_timeline(reader.events); + segment_stats.extract_seconds = std::chrono::duration(LoadStats::Clock::now() - extract_start).count(); + segment_stats.event_count = reader.events.size(); + segment_stats.series_count = populated_series_count(results[index].series); + stats->segments_parsed.fetch_add(1); + stats->publish(RouteLoadStage::ParsingSegment, index, std::to_string(segment_number)); + } + }; + + std::vector workers; + workers.reserve(segment_workers); + for (size_t i = 0; i < segment_workers; ++i) { + workers.emplace_back(worker); + } + for (std::thread &thread : workers) { + thread.join(); + } + + if (!first_error.empty()) throw std::runtime_error(first_error); + + stats->merge_start = LoadStats::Clock::now(); + SeriesAccumulator merged = make_series_accumulator(schema); + for (size_t i = 0; i < results.size(); ++i) { + merge_series_accumulator(&merged, &results[i].series); + } + std::vector logs; + std::vector timeline; + for (SegmentResult &result : results) { + if (!result.logs.empty()) { + logs.insert(logs.end(), + std::make_move_iterator(result.logs.begin()), + std::make_move_iterator(result.logs.end())); + } + if (!result.timeline.empty()) { + timeline.insert(timeline.end(), + std::make_move_iterator(result.timeline.begin()), + std::make_move_iterator(result.timeline.end())); + } + } + LoadedRouteArtifacts artifacts; + artifacts.series = collect_series(std::move(merged)); + artifacts.can_messages = std::move(merged.can_messages); + artifacts.logs = std::move(logs); + artifacts.timeline = std::move(timeline); + artifacts.enum_info = std::move(merged.enum_info); + stats->merge_end = LoadStats::Clock::now(); + return artifacts; +} + +std::vector collect_layout_roots(const SketchLayout &layout) { + std::vector roots; + for (const auto &tab : layout.tabs) { + for (const auto &pane : tab.panes) { + for (const auto &curve : pane.curves) { + std::string root = "custom"; + if (is_absolute_curve(curve.name)) { + const size_t slash = curve.name.find('/', 1); + root = curve.name.substr(1, slash == std::string::npos ? std::string::npos : slash - 1); + } + if (std::find(roots.begin(), roots.end(), root) == roots.end()) { + roots.push_back(root); + } + } + } + } + if (roots.empty()) { + roots.push_back("layout"); + } + return roots; +} + +} // namespace + +std::vector collect_route_roots_for_paths(const std::vector &paths) { + std::vector roots; + for (const std::string &path : paths) { + if (!is_absolute_curve(path)) continue; + const size_t slash = path.find('/', 1); + const std::string root = path.substr(1, slash == std::string::npos ? std::string::npos : slash - 1); + if (!root.empty() && std::find(roots.begin(), roots.end(), root) == roots.end()) { + roots.push_back(root); + } + } + std::sort(roots.begin(), roots.end()); + return roots; +} + +struct StreamAccumulator::Impl { + const SchemaIndex &schema = SchemaIndex::instance(); + SeriesAccumulator series = make_series_accumulator(schema); + std::vector logs; + std::vector timeline; + std::string last_alert_key; + std::string manual_dbc_name; + std::string detected_dbc_name; + std::string car_fingerprint; + std::optional can_dbc; + std::optional time_offset; + + void refresh_dbc() { + const std::string next_dbc = !manual_dbc_name.empty() ? manual_dbc_name : detect_dbc_for_fingerprint(car_fingerprint); + if (next_dbc == detected_dbc_name) { + return; + } + detected_dbc_name = next_dbc; + can_dbc = load_dbc_by_name(detected_dbc_name); + } +}; + +StreamAccumulator::StreamAccumulator(const std::string &dbc_name, std::optional time_offset) + : impl_(std::make_unique()) { + impl_->manual_dbc_name = dbc_name; + impl_->time_offset = time_offset; + impl_->refresh_dbc(); +} + +StreamAccumulator::~StreamAccumulator() = default; + +void StreamAccumulator::setDbcName(const std::string &dbc_name) { + impl_->manual_dbc_name = dbc_name; + impl_->refresh_dbc(); +} + +void StreamAccumulator::appendEvent(kj::ArrayPtr data) { + with_parseable_event(data, [&](const cereal::Event::Reader &event) { + const cereal::Event::Which which = event.which(); + const double boot_time = static_cast(event.getLogMonoTime()) / 1.0e9; + if (!impl_->time_offset.has_value()) { + impl_->time_offset = boot_time; + } + if (which == cereal::Event::Which::CAR_PARAMS) { + const std::string fingerprint = event.getCarParams().getCarFingerprint().cStr(); + if (!fingerprint.empty() && fingerprint != impl_->car_fingerprint) { + impl_->car_fingerprint = fingerprint; + impl_->refresh_dbc(); + } + } + + append_event_fast_reader(which, + event, + impl_->schema, + impl_->can_dbc ? &*impl_->can_dbc : nullptr, + impl_->can_dbc.has_value(), + *impl_->time_offset, + &impl_->series); + append_log_event(which, event, *impl_->time_offset, &impl_->logs, &impl_->last_alert_key); + if (which == cereal::Event::Which::SELFDRIVE_STATE) { + const auto sd = event.getSelfdriveState(); + append_timeline_entry(&impl_->timeline, boot_time - *impl_->time_offset, + alert_status_to_timeline_type(sd.getAlertStatus(), sd.getEnabled())); + } + }); +} + +void StreamAccumulator::appendCanFrames(CanServiceKind service, const std::vector &frames) { + if (frames.empty()) { + return; + } + if (!impl_->time_offset.has_value()) { + impl_->time_offset = frames.front().mono_time; + } + for (const LiveCanFrame &frame : frames) { + append_live_can_frame(service, + frame, + *impl_->time_offset, + impl_->can_dbc ? &*impl_->can_dbc : nullptr, + &impl_->series); + } +} + +StreamExtractBatch StreamAccumulator::takeBatch() { + StreamExtractBatch batch; + batch.car_fingerprint = impl_->car_fingerprint; + batch.dbc_name = impl_->detected_dbc_name; + if (impl_->time_offset.has_value()) { + batch.has_time_offset = true; + batch.time_offset = *impl_->time_offset; + } + if (impl_->logs.empty() && impl_->timeline.empty() + && populated_series_count(impl_->series) == 0 + && impl_->series.enum_info.empty() + && impl_->series.can_messages.empty()) { + return batch; + } + + SeriesAccumulator emitted = std::move(impl_->series); + batch.can_messages = std::move(emitted.can_messages); + batch.enum_info = std::move(emitted.enum_info); + batch.series = collect_series(std::move(emitted)); + batch.logs = std::move(impl_->logs); + batch.timeline = std::move(impl_->timeline); + impl_->series = make_series_accumulator(impl_->schema); + impl_->logs.clear(); + impl_->timeline.clear(); + return batch; +} + +const std::string &StreamAccumulator::carFingerprint() const { + return impl_->car_fingerprint; +} + +const std::string &StreamAccumulator::dbc_name() const { + return impl_->detected_dbc_name; +} + +std::optional StreamAccumulator::timeOffset() const { + return impl_->time_offset; +} + +SketchLayout load_sketch_layout(const fs::path &layout_path) { + SketchLayout layout = parse_layout(layout_path); + layout.roots = collect_layout_roots(layout); + return layout; +} + +RouteData load_route_data(const std::string &route_name, + const std::string &data_dir, + const std::string &dbc_name, + const RouteLoadProgressCallback &progress) { + if (route_name.empty()) return RouteData{}; + + const RouteSelection route = parse_route_selection(route_name); + if (route.canonical_name.empty() || (data_dir.empty() && route.dongle_id.empty())) { + throw std::runtime_error("Invalid route format: " + route_name); + } + LoadStats stats(progress); + stats.load_start = LoadStats::Clock::now(); + std::map segments = data_dir.empty() + ? load_segments_from_server(route) + : load_segments_from_local(route, data_dir); + segments = trim_segments(std::move(segments), route); + if (segments.empty()) throw std::runtime_error("No log segments found for " + route_name); + stats.resolve_end = LoadStats::Clock::now(); + stats.segment_count = segments.size(); + stats.total_segments.store(segments.size()); + stats.num_workers = static_cast(load_worker_budget()); + stats.segments.resize(segments.size()); + stats.publish(RouteLoadStage::Resolving, 0, {}); + + const RouteMetadata metadata = detect_route_metadata(segments, route.selector); + const std::string resolved_dbc = !dbc_name.empty() ? dbc_name : detect_dbc_for_fingerprint(metadata.car_fingerprint); + const std::optional can_dbc = load_dbc_by_name(resolved_dbc); + + const SchemaIndex &schema = SchemaIndex::instance(); + LoadedRouteArtifacts artifacts = load_route_series_parallel(segments, schema, can_dbc ? &*can_dbc : nullptr, + route.selector, can_dbc.has_value(), &stats); + RouteData route_data = build_route_data(std::move(artifacts.series), + std::move(artifacts.can_messages), + std::move(artifacts.logs), + std::move(artifacts.timeline), + std::move(artifacts.enum_info), + 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); + stats.load_end = LoadStats::Clock::now(); + stats.publish(RouteLoadStage::Finished, segments.size(), {}); + stats.print_summary(route_data.series.size()); + return route_data; +} + +RouteIdentifier parse_route_identifier(std::string_view route_name) { + return make_route_identifier(parse_route_selection(std::string(route_name)), {}); +} + +std::vector available_dbc_names() { + return available_dbc_names_impl(); +} + +std::optional load_dbc_by_name(const std::string &dbc_name) { + if (dbc_name.empty()) { + return std::nullopt; + } + try { + return std::optional(std::in_place, resolve_dbc_path(dbc_name)); + } catch (...) { + return std::nullopt; + } +} + +std::vector decode_can_messages(const std::vector &can_messages, + const std::string &dbc_name, + std::unordered_map *enum_info) { + if (enum_info != nullptr) { + enum_info->clear(); + } + const std::optional can_dbc = load_dbc_by_name(dbc_name); + if (!can_dbc.has_value()) { + return {}; + } + + SeriesAccumulator series; + for (const CanMessageData &message : can_messages) { + const char *service_name = message.id.service == CanServiceKind::Can ? "can" : "sendcan"; + for (const CanFrameSample &sample : message.samples) { + decode_can_frame(&*can_dbc, + service_name, + message.id.bus, + message.id.address, + reinterpret_cast(sample.data.data()), + sample.data.size(), + sample.mono_time, + &series); + } + } + if (enum_info != nullptr) { + *enum_info = std::move(series.enum_info); + } + return collect_series(std::move(series)); +} diff --git a/tools/jotpluggler/stream.cc b/tools/jotpluggler/stream.cc new file mode 100644 index 0000000000..fcfa6585bb --- /dev/null +++ b/tools/jotpluggler/stream.cc @@ -0,0 +1,207 @@ +#include "tools/jotpluggler/internal.h" + +#include + +template +std::optional stream_batch_extreme_time(const StreamExtractBatch &batch, + Cmp cmp, + SeriesAccessor series_time, + LogAccessor log_time_fn) { + std::optional result; + for (const RouteSeries &series : batch.series) { + if (!series.times.empty()) { + const double t = series_time(series); + result = result.has_value() ? cmp(*result, t) : t; + } + } + if (!batch.logs.empty()) { + const double t = log_time_fn(batch); + result = result.has_value() ? cmp(*result, t) : t; + } + if (!batch.timeline.empty()) { + const double t = cmp(batch.timeline.front().start_time, batch.timeline.back().end_time); + result = result.has_value() ? cmp(*result, t) : t; + } + for (const CanMessageData &message : batch.can_messages) { + if (!message.samples.empty()) { + const double t = cmp(message.samples.front().mono_time, message.samples.back().mono_time); + result = result.has_value() ? cmp(*result, t) : t; + } + } + return result; +} + +std::optional earliest_stream_batch_time(const StreamExtractBatch &batch) { + return stream_batch_extreme_time(batch, + [](double a, double b) { return std::min(a, b); }, + [](const RouteSeries &s) { return s.times.front(); }, + [](const StreamExtractBatch &b) { return b.logs.front().mono_time; }); +} + +std::optional latest_stream_batch_time(const StreamExtractBatch &batch) { + return stream_batch_extreme_time(batch, + [](double a, double b) { return std::max(a, b); }, + [](const RouteSeries &s) { return s.times.back(); }, + [](const StreamExtractBatch &b) { return b.logs.back().mono_time; }); +} + +bool layout_has_custom_curves(const SketchLayout &layout) { + for (const WorkspaceTab &tab : layout.tabs) { + for (const Pane &pane : tab.panes) { + for (const Curve &curve : pane.curves) { + if (curve.custom_python.has_value()) return true; + } + } + } + return false; +} + +void append_stream_timeline_entries(std::vector *timeline, std::vector entries) { + for (TimelineEntry &entry : entries) { + if (!timeline->empty() && timeline->back().type == entry.type) { + timeline->back().end_time = std::max(timeline->back().end_time, entry.end_time); + } else { + timeline->push_back(std::move(entry)); + } + } +} + +bool can_message_less(const CanMessageData &a, const CanMessageData &b) { + return std::make_tuple(a.id.service, a.id.bus, a.id.address) + < std::make_tuple(b.id.service, b.id.bus, b.id.address); +} + +void apply_stream_batch(AppSession *session, UiState *state, StreamExtractBatch batch) { + if (batch.has_time_offset) { + session->stream_time_offset = batch.time_offset; + } + if (!batch.car_fingerprint.empty()) { + session->route_data.car_fingerprint = batch.car_fingerprint; + } + if (!batch.dbc_name.empty()) { + session->route_data.dbc_name = batch.dbc_name; + } + if (!batch.enum_info.empty()) { + for (auto &[path, info] : batch.enum_info) { + session->route_data.enum_info[path] = std::move(info); + } + } + + bool new_paths = false; + std::vector new_series; + std::vector touched_paths; + touched_paths.reserve(batch.series.size()); + for (RouteSeries &incoming : batch.series) { + touched_paths.push_back(incoming.path); + auto existing_it = session->series_by_path.find(incoming.path); + if (existing_it == session->series_by_path.end()) { + new_series.push_back(std::move(incoming)); + new_paths = true; + continue; + } + RouteSeries &existing = *existing_it->second; + existing.times.insert(existing.times.end(), incoming.times.begin(), incoming.times.end()); + existing.values.insert(existing.values.end(), incoming.values.begin(), incoming.values.end()); + } + for (RouteSeries &series : new_series) { + session->route_data.paths.push_back(series.path); + session->route_data.series.push_back(std::move(series)); + } + + if (!batch.logs.empty()) { + std::sort(batch.logs.begin(), batch.logs.end(), [](const LogEntry &a, const LogEntry &b) { + return a.mono_time < b.mono_time; + }); + const size_t old_size = session->route_data.logs.size(); + session->route_data.logs.insert(session->route_data.logs.end(), + std::make_move_iterator(batch.logs.begin()), + std::make_move_iterator(batch.logs.end())); + if (old_size > 0 && session->route_data.logs.size() > old_size + && session->route_data.logs[old_size - 1].mono_time > session->route_data.logs[old_size].mono_time) { + std::inplace_merge(session->route_data.logs.begin(), + session->route_data.logs.begin() + static_cast(old_size), + session->route_data.logs.end(), + [](const LogEntry &a, const LogEntry &b) { + return a.mono_time < b.mono_time; + }); + } + } + if (!batch.timeline.empty()) { + append_stream_timeline_entries(&session->route_data.timeline, std::move(batch.timeline)); + } + + for (CanMessageData &incoming : batch.can_messages) { + auto it = std::lower_bound(session->route_data.can_messages.begin(), + session->route_data.can_messages.end(), + incoming, + can_message_less); + if (it == session->route_data.can_messages.end() + || can_message_less(incoming, *it) + || can_message_less(*it, incoming)) { + session->route_data.can_messages.insert(it, std::move(incoming)); + } else { + it->samples.insert(it->samples.end(), + std::make_move_iterator(incoming.samples.begin()), + std::make_move_iterator(incoming.samples.end())); + } + } + + if (new_paths) { + const size_t old_path_count = session->route_data.paths.size() - new_series.size(); + std::sort(session->route_data.paths.begin() + static_cast(old_path_count), session->route_data.paths.end()); + std::inplace_merge(session->route_data.paths.begin(), + session->route_data.paths.begin() + static_cast(old_path_count), + session->route_data.paths.end()); + const size_t old_series_count = session->route_data.series.size() - new_series.size(); + auto series_cmp = [](const RouteSeries &a, const RouteSeries &b) { return a.path < b.path; }; + std::sort(session->route_data.series.begin() + static_cast(old_series_count), + session->route_data.series.end(), series_cmp); + std::inplace_merge(session->route_data.series.begin(), + session->route_data.series.begin() + static_cast(old_series_count), + session->route_data.series.end(), series_cmp); + session->route_data.roots = collect_route_roots_for_paths(session->route_data.paths); + rebuild_route_index(session); + rebuild_browser_nodes(session, state); + state->browser_nodes_dirty = false; + } else { + for (const std::string &path : touched_paths) { + auto series_it = session->series_by_path.find(path); + if (series_it == session->series_by_path.end() || series_it->second == nullptr) continue; + const bool enum_like = session->route_data.enum_info.find(path) != session->route_data.enum_info.end(); + session->route_data.series_formats[path] = compute_series_format(series_it->second->values, enum_like); + } + } + const std::optional earliest_time = earliest_stream_batch_time(batch); + const std::optional latest_time = latest_stream_batch_time(batch); + if (earliest_time.has_value() && latest_time.has_value()) { + if (!session->route_data.has_time_range) { + session->route_data.x_min = *earliest_time; + session->route_data.x_max = *latest_time; + } else { + session->route_data.x_min = std::min(session->route_data.x_min, *earliest_time); + session->route_data.x_max = std::max(session->route_data.x_max, *latest_time); + } + session->route_data.has_time_range = true; + } + + if (new_paths + || std::find(touched_paths.begin(), touched_paths.end(), "/gpsLocationExternal/latitude") != touched_paths.end() + || std::find(touched_paths.begin(), touched_paths.end(), "/gpsLocationExternal/longitude") != touched_paths.end() + || std::find(touched_paths.begin(), touched_paths.end(), "/gpsLocationExternal/hasFix") != touched_paths.end() + || std::find(touched_paths.begin(), touched_paths.end(), "/gpsLocationExternal/bearingDeg") != touched_paths.end()) { + rebuild_gps_trace(&session->route_data); + } + + if (latest_time.has_value() && layout_has_custom_curves(session->layout) + && *latest_time >= session->next_stream_custom_refresh_time) { + refresh_all_custom_curves(session, state); + session->next_stream_custom_refresh_time = *latest_time + 0.1; + } + if (state->follow_latest || !state->has_tracker_time) { + state->tracker_time = session->route_data.x_max; + state->has_tracker_time = session->route_data.has_time_range; + } + if (!state->has_shared_range) { + reset_shared_range(state, *session); + } +} diff --git a/tools/jotpluggler/util.cc b/tools/jotpluggler/util.cc new file mode 100644 index 0000000000..5c20e795f6 --- /dev/null +++ b/tools/jotpluggler/util.cc @@ -0,0 +1,59 @@ +#include "tools/jotpluggler/util.h" + +#include +#include +#include +#include + +std::string read_file_or_throw(const std::filesystem::path &path) { + const std::string contents = util::read_file(path.string()); + if (!contents.empty() || std::filesystem::exists(path)) { + return contents; + } + throw std::runtime_error("Failed to read " + path.string()); +} + +void write_file_or_throw(const std::filesystem::path &path, const void *data, size_t size) { + ensure_parent_dir(path); + const std::string path_string = path.string(); + const void *bytes = size == 0 ? static_cast("") : data; + if (util::write_file(path_string.c_str(), bytes, size, O_WRONLY | O_CREAT | O_TRUNC) != 0) { + throw std::runtime_error("Failed to write " + path_string); + } +} + +void write_file_or_throw(const std::filesystem::path &path, std::string_view contents) { + write_file_or_throw(path, contents.data(), contents.size()); +} + +void run_system_or_throw(const std::string &command, std::string_view action) { + const int ret = std::system(command.c_str()); + if (ret != 0) { + throw std::runtime_error(util::string_format("%.*s failed with exit code %d", + static_cast(action.size()), action.data(), ret)); + } +} + +CommandResult run_process_capture_output(const std::vector &args) { + std::string command; + for (const std::string &arg : args) { + if (!command.empty()) command += ' '; + command += shell_quote(arg); + } + command += " 2>&1"; + + FILE *pipe = popen(command.c_str(), "r"); + if (pipe == nullptr) { + throw std::runtime_error("popen() failed"); + } + + CommandResult result; + std::array buf = {}; + while (fgets(buf.data(), static_cast(buf.size()), pipe) != nullptr) { + result.output += buf.data(); + } + + const int status = pclose(pipe); + result.exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 1; + return result; +} diff --git a/tools/jotpluggler/util.h b/tools/jotpluggler/util.h new file mode 100644 index 0000000000..ea77a236f0 --- /dev/null +++ b/tools/jotpluggler/util.h @@ -0,0 +1,103 @@ +#pragma once + +#include "common/util.h" +#include "imgui.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +inline ImVec4 color_rgb(int r, int g, int b, float alpha = 1.0f) { + return ImVec4(static_cast(r) / 255.0f, + static_cast(g) / 255.0f, + static_cast(b) / 255.0f, + alpha); +} + +inline ImVec4 color_rgb(const std::array &color, float alpha = 1.0f) { + return color_rgb(color[0], color[1], color[2], alpha); +} + +inline std::string lowercase_copy(std::string_view value) { + std::string out(value); + std::transform(out.begin(), out.end(), out.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return out; +} + +inline int imgui_resize_callback(ImGuiInputTextCallbackData *data) { + if (data->EventFlag != ImGuiInputTextFlags_CallbackResize || data->UserData == nullptr) return 0; + auto *text = static_cast(data->UserData); + text->resize(static_cast(data->BufTextLen)); + data->Buf = text->data(); + return 0; +} + +inline bool input_text_string(const char *label, + std::string *text, + ImGuiInputTextFlags flags = 0) { + flags |= ImGuiInputTextFlags_CallbackResize; + return ImGui::InputText(label, text->data(), text->capacity() + 1, + flags, imgui_resize_callback, text); +} + +inline bool input_text_with_hint_string(const char *label, + const char *hint, + std::string *text, + ImGuiInputTextFlags flags = 0) { + flags |= ImGuiInputTextFlags_CallbackResize; + return ImGui::InputTextWithHint(label, hint, text->data(), text->capacity() + 1, + flags, imgui_resize_callback, text); +} + +inline bool input_text_multiline_string(const char *label, + std::string *text, + const ImVec2 &size = ImVec2(0.0f, 0.0f), + ImGuiInputTextFlags flags = 0) { + flags |= ImGuiInputTextFlags_CallbackResize; + return ImGui::InputTextMultiline(label, text->data(), text->capacity() + 1, + size, flags, imgui_resize_callback, text); +} + +inline bool is_local_stream_address(std::string_view address) { + return address.empty() || address == "127.0.0.1" || address == "localhost"; +} + +inline void ensure_parent_dir(const std::filesystem::path &path) { + if (path.has_parent_path()) { + std::filesystem::create_directories(path.parent_path()); + } +} + +inline std::string shell_quote(std::string_view value) { + std::string quoted; + quoted.reserve(value.size() + 8); + quoted.push_back('\''); + for (char c : value) { + if (c == '\'') { + quoted += "'\\''"; + } else { + quoted.push_back(c); + } + } + quoted.push_back('\''); + return quoted; +} + +struct CommandResult { + int exit_code = 0; + std::string output; +}; + +std::string read_file_or_throw(const std::filesystem::path &path); +void write_file_or_throw(const std::filesystem::path &path, std::string_view contents); +void write_file_or_throw(const std::filesystem::path &path, const void *data, size_t size); +void run_system_or_throw(const std::string &command, std::string_view action); +CommandResult run_process_capture_output(const std::vector &args); diff --git a/tools/jotpluggler/views.py b/tools/jotpluggler/views.py deleted file mode 100644 index cd723d161e..0000000000 --- a/tools/jotpluggler/views.py +++ /dev/null @@ -1,294 +0,0 @@ -import uuid -import threading -import numpy as np -from collections import deque -import dearpygui.dearpygui as dpg -from abc import ABC, abstractmethod - - -class ViewPanel(ABC): - """Abstract base class for all view panels that can be displayed in a plot container""" - - def __init__(self, panel_id: str | None = None): - self.panel_id = panel_id or str(uuid.uuid4()) - self.title = "Untitled Panel" - - @abstractmethod - def clear(self): - pass - - @abstractmethod - def create_ui(self, parent_tag: str): - pass - - @abstractmethod - def destroy_ui(self): - pass - - @abstractmethod - def get_panel_type(self) -> str: - pass - - @abstractmethod - def update(self): - pass - - @abstractmethod - def to_dict(self) -> dict: - pass - - @classmethod - @abstractmethod - def load_from_dict(cls, data: dict, data_manager, playback_manager, worker_manager): - pass - - -class TimeSeriesPanel(ViewPanel): - def __init__(self, data_manager, playback_manager, worker_manager, panel_id: str | None = None): - super().__init__(panel_id) - self.data_manager = data_manager - self.playback_manager = playback_manager - self.worker_manager = worker_manager - self.title = "Time Series Plot" - self.plot_tag = f"plot_{self.panel_id}" - self.x_axis_tag = f"{self.plot_tag}_x_axis" - self.y_axis_tag = f"{self.plot_tag}_y_axis" - self.timeline_indicator_tag = f"{self.plot_tag}_timeline" - self._ui_created = False - self._series_data: dict[str, tuple[np.ndarray, np.ndarray]] = {} - self._last_plot_duration = 0 - self._update_lock = threading.RLock() - self._results_deque: deque[tuple[str, list, list]] = deque() - self._new_data = False - self._last_x_limits = (0.0, 0.0) - self._queued_x_sync: tuple | None = None - self._queued_reallow_x_zoom = False - self._total_segments = self.playback_manager.num_segments - - def to_dict(self) -> dict: - return { - "type": "timeseries", - "title": self.title, - "series_paths": list(self._series_data.keys()) - } - - @classmethod - def load_from_dict(cls, data: dict, data_manager, playback_manager, worker_manager): - panel = cls(data_manager, playback_manager, worker_manager) - panel.title = data.get("title", "Time Series Plot") - panel._series_data = {path: (np.array([]), np.array([])) for path in data.get("series_paths", [])} - return panel - - def create_ui(self, parent_tag: str): - self.data_manager.add_observer(self.on_data_loaded) - self.playback_manager.add_x_axis_observer(self._on_x_axis_sync) - with dpg.plot(height=-1, width=-1, tag=self.plot_tag, parent=parent_tag, drop_callback=self._on_series_drop, payload_type="TIMESERIES_PAYLOAD"): - dpg.add_plot_legend() - dpg.add_plot_axis(dpg.mvXAxis, no_label=True, tag=self.x_axis_tag) - dpg.add_plot_axis(dpg.mvYAxis, no_label=True, tag=self.y_axis_tag) - timeline_series_tag = dpg.add_inf_line_series(x=[0], label="Timeline", parent=self.y_axis_tag, tag=self.timeline_indicator_tag) - dpg.bind_item_theme(timeline_series_tag, "timeline_theme") - - self._new_data = True - self._queued_x_sync = self.playback_manager.x_axis_bounds - self._ui_created = True - - def update(self): - with self._update_lock: - if not self._ui_created: - return - - if self._queued_x_sync: - min_time, max_time = self._queued_x_sync - self._queued_x_sync = None - dpg.set_axis_limits(self.x_axis_tag, min_time, max_time) - self._last_x_limits = (min_time, max_time) - self._fit_y_axis(min_time, max_time) - self._queued_reallow_x_zoom = True # must wait a frame before allowing user changes so that axis limits take effect - return - - if self._queued_reallow_x_zoom: - self._queued_reallow_x_zoom = False - if tuple(dpg.get_axis_limits(self.x_axis_tag)) == self._last_x_limits: - dpg.set_axis_limits_auto(self.x_axis_tag) - else: - self._queued_x_sync = self._last_x_limits # retry, likely too early - return - - if self._new_data: # handle new data in main thread - self._new_data = False - if self._total_segments > 0: - dpg.set_axis_limits_constraints(self.x_axis_tag, -10, self._total_segments * 60 + 10) - self._fit_y_axis(*dpg.get_axis_limits(self.x_axis_tag)) - for series_path in list(self._series_data.keys()): - self.add_series(series_path, update=True) - - current_limits = dpg.get_axis_limits(self.x_axis_tag) - # downsample if plot zoom changed significantly - plot_duration = current_limits[1] - current_limits[0] - if plot_duration > self._last_plot_duration * 2 or plot_duration < self._last_plot_duration * 0.5: - self._downsample_all_series(plot_duration) - # sync x-axis if changed by user - if self._last_x_limits != current_limits: - self.playback_manager.set_x_axis_bounds(current_limits[0], current_limits[1], source_panel=self) - self._last_x_limits = current_limits - self._fit_y_axis(current_limits[0], current_limits[1]) - - while self._results_deque: # handle downsampled results in main thread - results = self._results_deque.popleft() - for series_path, downsampled_time, downsampled_values in results: - series_tag = f"series_{self.panel_id}_{series_path}" - if dpg.does_item_exist(series_tag): - dpg.set_value(series_tag, (downsampled_time, downsampled_values.astype(float))) - - # update timeline - current_time_s = self.playback_manager.current_time_s - dpg.set_value(self.timeline_indicator_tag, [[current_time_s], [0]]) - - # update timeseries legend label - for series_path, (time_array, value_array) in self._series_data.items(): - position = np.searchsorted(time_array, current_time_s, side='right') - 1 - if position >= 0 and (current_time_s - time_array[position]) <= 1.0: - value = value_array[position] - formatted_value = f"{value:.5f}" if np.issubdtype(type(value), np.floating) else str(value) - series_tag = f"series_{self.panel_id}_{series_path}" - if dpg.does_item_exist(series_tag): - dpg.configure_item(series_tag, label=f"{series_path}: {formatted_value}") - - def _on_x_axis_sync(self, min_time: float, max_time: float, source_panel): - with self._update_lock: - if source_panel != self: - self._queued_x_sync = (min_time, max_time) - - def _fit_y_axis(self, x_min: float, x_max: float): - if not self._series_data: - dpg.set_axis_limits(self.y_axis_tag, -1, 1) - return - - global_min = float('inf') - global_max = float('-inf') - found_data = False - - for time_array, value_array in self._series_data.values(): - if len(time_array) == 0: - continue - start_idx, end_idx = np.searchsorted(time_array, [x_min, x_max]) - end_idx = min(end_idx, len(time_array) - 1) - if start_idx <= end_idx: - y_slice = value_array[start_idx:end_idx + 1] - series_min, series_max = np.min(y_slice), np.max(y_slice) - global_min = min(global_min, series_min) - global_max = max(global_max, series_max) - found_data = True - - if not found_data: - dpg.set_axis_limits(self.y_axis_tag, -1, 1) - return - - if global_min == global_max: - padding = max(abs(global_min) * 0.1, 1.0) - y_min, y_max = global_min - padding, global_max + padding - else: - range_size = global_max - global_min - padding = range_size * 0.1 - y_min, y_max = global_min - padding, global_max + padding - - dpg.set_axis_limits(self.y_axis_tag, y_min, y_max) - - def _downsample_all_series(self, plot_duration): - plot_width = dpg.get_item_rect_size(self.plot_tag)[0] - if plot_width <= 0 or plot_duration <= 0: - return - - self._last_plot_duration = plot_duration - target_points_per_second = plot_width / plot_duration - work_items = [] - for series_path, (time_array, value_array) in self._series_data.items(): - if len(time_array) == 0: - continue - series_duration = time_array[-1] - time_array[0] if len(time_array) > 1 else 1 - points_per_second = len(time_array) / series_duration - if points_per_second > target_points_per_second * 2: - target_points = max(int(target_points_per_second * series_duration), plot_width) - work_items.append((series_path, time_array, value_array, target_points)) - elif dpg.does_item_exist(f"series_{self.panel_id}_{series_path}"): - dpg.set_value(f"series_{self.panel_id}_{series_path}", (time_array, value_array.astype(float))) - - if work_items: - self.worker_manager.submit_task( - TimeSeriesPanel._downsample_worker, work_items, callback=lambda results: self._results_deque.append(results), task_id=f"downsample_{self.panel_id}" - ) - - def add_series(self, series_path: str, update: bool = False): - with self._update_lock: - if update or series_path not in self._series_data: - self._series_data[series_path] = self.data_manager.get_timeseries(series_path) - - time_array, value_array = self._series_data[series_path] - series_tag = f"series_{self.panel_id}_{series_path}" - if dpg.does_item_exist(series_tag): - dpg.set_value(series_tag, (time_array, value_array.astype(float))) - else: - line_series_tag = dpg.add_line_series(x=time_array, y=value_array.astype(float), label=series_path, parent=self.y_axis_tag, tag=series_tag) - dpg.bind_item_theme(line_series_tag, "line_theme") - self._fit_y_axis(*dpg.get_axis_limits(self.x_axis_tag)) - plot_duration = dpg.get_axis_limits(self.x_axis_tag)[1] - dpg.get_axis_limits(self.x_axis_tag)[0] - self._downsample_all_series(plot_duration) - - def destroy_ui(self): - with self._update_lock: - self.data_manager.remove_observer(self.on_data_loaded) - self.playback_manager.remove_x_axis_observer(self._on_x_axis_sync) - if dpg.does_item_exist(self.plot_tag): - dpg.delete_item(self.plot_tag) - self._ui_created = False - - def get_panel_type(self) -> str: - return "timeseries" - - def clear(self): - with self._update_lock: - for series_path in list(self._series_data.keys()): - self.remove_series(series_path) - - def remove_series(self, series_path: str): - with self._update_lock: - if series_path in self._series_data: - if dpg.does_item_exist(f"series_{self.panel_id}_{series_path}"): - dpg.delete_item(f"series_{self.panel_id}_{series_path}") - del self._series_data[series_path] - - def on_data_loaded(self, data: dict): - with self._update_lock: - self._new_data = True - if data.get('metadata_loaded'): - self._total_segments = data.get('total_segments', 0) - limits = (-10, self._total_segments * 60 + 10) - self._queued_x_sync = limits - - def _on_series_drop(self, sender, app_data, user_data): - self.add_series(app_data) - - @staticmethod - def _downsample_worker(series_path, time_array, value_array, target_points): - if len(time_array) <= target_points: - return series_path, time_array, value_array - - step = len(time_array) / target_points - indices = [] - - for i in range(target_points): - start_idx = int(i * step) - end_idx = int((i + 1) * step) - if start_idx == end_idx: - indices.append(start_idx) - else: - bucket_values = value_array[start_idx:end_idx] - min_idx = start_idx + np.argmin(bucket_values) - max_idx = start_idx + np.argmax(bucket_values) - if min_idx != max_idx: - indices.extend([min(min_idx, max_idx), max(min_idx, max_idx)]) - else: - indices.append(min_idx) - indices = sorted(set(indices)) - return series_path, time_array[indices], value_array[indices] diff --git a/tools/lateral_maneuvers/.gitignore b/tools/lateral_maneuvers/.gitignore new file mode 100644 index 0000000000..a0b6efe6b3 --- /dev/null +++ b/tools/lateral_maneuvers/.gitignore @@ -0,0 +1 @@ +/lateral_reports/ diff --git a/tools/lateral_maneuvers/README.md b/tools/lateral_maneuvers/README.md new file mode 100644 index 0000000000..3a54bc7409 --- /dev/null +++ b/tools/lateral_maneuvers/README.md @@ -0,0 +1,42 @@ +# Lateral Maneuvers Testing Tool + +> [!WARNING] +> Use caution when using this tool. + +Test your vehicle's lateral control tuning with this tool. The tool will test the vehicle's ability to follow a few lateral maneuvers and includes a tool to generate a report from the route. + +## Instructions + +1. Check out a development branch such as `master` on your comma device. +2. The full maneuver suite runs at 20 and 30 mph. +3. Enable "Lateral Maneuver Mode" in Settings > Developer on the device while offroad. Alternatively, set the parameter manually: + + ```sh + echo -n 1 > /data/params/d/LateralManeuverMode + ``` + +4. Turn your vehicle back on. You will see "Lateral Maneuver Mode". + +5. Ensure the area ahead is clear, as openpilot will command lateral acceleration steps in this mode. Once you are ready, set ACC manually to the target speed shown on screen and let openpilot stabilize lateral. After 1 seconds of steady straight driving, the maneuver will begin automatically. openpilot lateral control stays engaged between maneuvers normally while waiting for the next maneuver's readiness conditions. The maneuver will be aborted and repeated if speed is out of range, steering is touched or openpilot disengages. + +6. When the testing is complete, you'll see an alert that says "Maneuvers Finished." Complete the route by pulling over and turning off the vehicle. + +7. Visit https://connect.comma.ai and locate the route(s). They will stand out with lots of orange intervals in their timeline. Ensure "All logs" show as "uploaded." + + ![image](https://github.com/user-attachments/assets/cfe4c6d9-752f-4b24-b421-4b90a01933dc) + +8. Gather the route ID and then run the report generator. The file will be exported to the same directory: + + ```sh + $ python tools/lateral_maneuvers/generate_report.py 98395b7c5b27882e/000001cc--5a73bde686 + + processing report for KIA_EV6 + plotting maneuver: step right 20mph, runs: 3 + plotting maneuver: step left 20mph, runs: 3 + plotting maneuver: sine 0.5Hz 20mph, runs: 3 + plotting maneuver: step right 30mph, runs: 3 + + Opening report: /home/batman/openpilot/tools/lateral_maneuvers/lateral_reports/KIA_EV6_98395b7c5b27882e_000001cc--5a73bde686.html + ``` + +You can reach out on [Discord](https://discord.comma.ai) if you have any questions about these instructions or the tool itself. diff --git a/tools/lateral_maneuvers/generate_report.py b/tools/lateral_maneuvers/generate_report.py new file mode 100755 index 0000000000..9a6fe1b979 --- /dev/null +++ b/tools/lateral_maneuvers/generate_report.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +import argparse +import base64 +import io +import math +import numpy as np +import os +import webbrowser +from collections import defaultdict +from pathlib import Path +import matplotlib.pyplot as plt +from openpilot.common.utils import tabulate + +from cereal import car +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.selfdrive.controls.lib.latcontrol_torque import LP_FILTER_CUTOFF_HZ +from openpilot.tools.lib.logreader import LogReader +from openpilot.system.hardware.hw import Paths +from openpilot.common.constants import CV +from openpilot.tools.longitudinal_maneuvers.generate_report import format_car_params + + +def lat_accel(curvature, v): + return curvature * max(v, 1.0) ** 2 + + +def report(platform, route, _description, CP, ID, maneuvers): + output_path = Path(__file__).resolve().parent / "lateral_reports" + output_fn = output_path / f"{platform}_{route.replace('/', '_')}.html" + output_path.mkdir(exist_ok=True) + target_cross_times = defaultdict(list) + + builder = [ + "\n", + "

Lateral maneuver report

\n", + f"

{platform}

\n", + f"

{route}

\n", + f"

{ID.gitCommit}, {ID.gitBranch}, {ID.gitRemote}

\n", + ] + if _description is not None: + builder.append(f"

Description: {_description}

\n") + builder.append(f"

CarParams

{format_car_params(CP)}
\n") + builder.append('{ summary }') # to be replaced below + for description, runs in maneuvers: + # filter incomplete runs + completed_runs = [msgs for msgs in runs + if any(m.alertDebug.alertText1 == 'Complete' for m in msgs if m.which() == 'alertDebug')] + print(f'plotting maneuver: {description}') + if not completed_runs: + continue + builder.append("
\n") + builder.append(f"

{description}

\n") + for run, msgs in enumerate(completed_runs): + t_carControl, carControl = zip(*[(m.logMonoTime, m.carControl) for m in msgs if m.which() == 'carControl'], strict=True) + t_carState, carState = zip(*[(m.logMonoTime, m.carState) for m in msgs if m.which() == 'carState'], strict=True) + t_controlsState, controlsState = zip(*[(m.logMonoTime, m.controlsState) for m in msgs if m.which() == 'controlsState'], strict=True) + t_lateralPlan, lateralPlan = zip(*[(m.logMonoTime, m.lateralManeuverPlan) for m in msgs if m.which() == 'lateralManeuverPlan' and m.valid], strict=True) + t_carOutput, carOutput = zip(*[(m.logMonoTime, m.carOutput) for m in msgs if m.which() == 'carOutput'], strict=True) + + # make time relative seconds + t_carControl = [(t - t_carControl[0]) / 1e9 for t in t_carControl] + t_carState = [(t - t_carState[0]) / 1e9 for t in t_carState] + t_controlsState = [(t - t_controlsState[0]) / 1e9 for t in t_controlsState] + t_lateralPlan = [(t - t_lateralPlan[0]) / 1e9 for t in t_lateralPlan] + t_carOutput = [(t - t_carOutput[0]) / 1e9 for t in t_carOutput] + + # maneuver validity + latActive = [m.latActive for m in carControl] + maneuver_valid = all(latActive) and not any(cs.steeringPressed for cs in carState) + + _open = 'open' if maneuver_valid else '' + title = f'Run #{int(run)+1}' + (' (invalid maneuver!)' if not maneuver_valid else '') + + builder.append(f"

{title}

\n") + + baseline_accel = lat_accel(controlsState[0].curvature, carState[0].vEgo) + v_ego = [m.vEgo for m in carState] + cross_markers = [] + + if description.startswith('sine'): + amplitude = max(abs(lat_accel(lp.desiredCurvature, v) - baseline_accel) + for lp, v in zip(lateralPlan, v_ego, strict=False)) + threshold = amplitude * 0.5 + builder.append('

50% peak') + for t, cs, v in zip(t_controlsState, controlsState, v_ego, strict=False): + actual = lat_accel(cs.curvature, v) - baseline_accel + if abs(actual) > threshold: + builder.append(f', crossed in {t:.3f}s') + cross_markers.append((t, actual + baseline_accel)) + if maneuver_valid: + target_cross_times[description].append(t) + break + else: + builder.append(', not crossed') + builder.append('

') + if maneuver_valid: + target_cross_times.setdefault(description, []) + else: + action_targets = [(0, lat_accel(lateralPlan[0].desiredCurvature, v_ego[0]) - baseline_accel)] + for i in range(1, min(len(lateralPlan), len(v_ego))): + if abs(lateralPlan[i].desiredCurvature - lateralPlan[i - 1].desiredCurvature) > 0.001: + desired = lat_accel(lateralPlan[i].desiredCurvature, v_ego[i]) - baseline_accel + action_targets.append((i, desired)) + + for j, (start_i, act_target) in enumerate(action_targets): + start_time = t_lateralPlan[start_i] + end_time = t_lateralPlan[action_targets[j + 1][0]] if j + 1 < len(action_targets) else t_controlsState[-1] + + builder.append(f'

aTarget: {round(act_target, 1)} m/s^2') + prev_crossed = False + for t, cs, v in zip(t_controlsState, controlsState, v_ego, strict=False): + if not (start_time <= t <= end_time): + continue + actual_accel = lat_accel(cs.curvature, v) - baseline_accel + crossed = (0 < act_target < actual_accel) or (0 > act_target > actual_accel) + if crossed and prev_crossed: + cross_time = t - start_time + builder.append(f', crossed in {cross_time:.3f}s') + cross_markers.append((t, act_target + baseline_accel)) + if maneuver_valid: + target_cross_times[description].append(cross_time) + break + prev_crossed = crossed + else: + builder.append(', not crossed') + builder.append('

') + if maneuver_valid: + target_cross_times.setdefault(description, []) + + plt.rcParams['font.size'] = 40 + fig = plt.figure(figsize=(30, 30)) + ax = fig.subplots(4, 1, sharex=True, gridspec_kw={'height_ratios': [5, 3, 3, 3]}) + + ax[0].grid(linewidth=4) + desired_lat_accel = [lat_accel(m.desiredCurvature, v) for m, v in zip(lateralPlan, v_ego, strict=False)] + if description.startswith('sine'): + ax[0].plot(t_lateralPlan[:len(desired_lat_accel)], desired_lat_accel, label='desired lat accel', linewidth=6) + else: + t_desired = [t_lateralPlan[0]] + t_lateralPlan[:len(desired_lat_accel)] + desired_lat_accel = [baseline_accel] + desired_lat_accel + ax[0].step(t_desired, desired_lat_accel, label='desired lat accel', linewidth=6, where='post') + actual_lat_accel = [lat_accel(cs.curvature, v) for cs, v in zip(controlsState, v_ego, strict=False)] + ax[0].plot(t_controlsState[:len(actual_lat_accel)], actual_lat_accel, label='actual lat accel', linewidth=6) + ax[0].set_ylabel('Lateral Accel (m/s^2)') + + for ct, cv in cross_markers: + ax[0].plot(ct, cv, marker='o', markersize=50, markeredgewidth=7, markeredgecolor='black', markerfacecolor='None') + + ax2 = ax[0].twinx() + if CP.steerControlType == car.CarParams.SteerControlType.angle: + ax2.plot(t_carOutput, [-m.actuatorsOutput.steeringAngleDeg for m in carOutput], 'C2', label='steer angle', linewidth=6) + else: + ax2.plot(t_carOutput, [-m.actuatorsOutput.torque for m in carOutput], 'C2', label='steer torque', linewidth=6) + + h1, l1 = ax[0].get_legend_handles_labels() + h2, l2 = ax2.get_legend_handles_labels() + ax[0].legend(h1 + h2, l1 + l2, prop={'size': 30}) + + ax[1].grid(linewidth=4) + ax[1].plot(t_carState, [v * CV.MS_TO_MPH for v in v_ego], label='vEgo', linewidth=6) + ax[1].set_ylabel('Velocity (mph)') + ax[1].yaxis.set_major_formatter(plt.FormatStrFormatter('%.1f')) + ax[1].legend() + + t_accel = np.array(t_controlsState[:len(actual_lat_accel)]) + raw_jerk = np.gradient(actual_lat_accel, t_accel) + dt_avg = np.mean(np.diff(t_accel)) + jerk_filter = FirstOrderFilter(0.0, 1 / (2 * np.pi * LP_FILTER_CUTOFF_HZ), dt_avg) + filtered_jerk = [jerk_filter.update(j) for j in raw_jerk] + ax[2].grid(linewidth=4) + ax[2].plot(t_accel, filtered_jerk, label='actual jerk', linewidth=6) + if CP.steerControlType == car.CarParams.SteerControlType.torque: + desired_jerk = [cs.lateralControlState.torqueState.desiredLateralJerk for cs in controlsState] + ax[2].plot(t_controlsState[:len(controlsState)], desired_jerk, label='desired jerk', linewidth=6) + ax[2].set_ylabel('Jerk (m/s^3)') + ax[2].legend() + + ax[3].grid(linewidth=4) + ax[3].plot(t_carControl, [math.degrees(m.orientationNED[0]) for m in carControl], label='roll', linewidth=6) + ax[3].set_ylabel('Roll (deg)') + ax[3].legend() + + ax[-1].set_xlabel("Time (s)") + fig.tight_layout() + + buffer = io.BytesIO() + fig.savefig(buffer, format='webp') + plt.close(fig) + buffer.seek(0) + builder.append(f"\n") + builder.append("
\n") + + summary = ["

Summary

\n"] + cols = ['maneuver', 'crossed', 'mean', 'min', 'max'] + table = [] + for description, times in target_cross_times.items(): + l = [description, len(times)] + if len(times): + l.extend([round(sum(times) / len(times), 2), round(min(times), 2), round(max(times), 2)]) + table.append(l) + summary.append(tabulate(table, headers=cols, tablefmt='html', numalign='left') + '\n') + + sum_idx = builder.index('{ summary }') + builder[sum_idx:sum_idx + 1] = summary + + with open(output_fn, "w") as f: + f.write(''.join(builder)) + + print(f"\nOpening report: {output_fn}\n") + webbrowser.open_new_tab(str(output_fn)) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Generate lateral maneuver report from route') + parser.add_argument('route', type=str, help='Route name (e.g. 00000000--5f742174be)') + parser.add_argument('description', type=str, nargs='?') + + args = parser.parse_args() + + if '/' in args.route or '|' in args.route: + lr = LogReader(args.route, only_union_types=True) + else: + segs = [seg for seg in os.listdir(Paths.log_root()) if args.route in seg] + lr = LogReader([os.path.join(Paths.log_root(), seg, 'rlog.zst') for seg in segs], only_union_types=True) + + CP = lr.first('carParams') + ID = lr.first('initData') + platform = CP.carFingerprint + print('processing report for', platform) + + maneuvers: list[tuple[str, list[list]]] = [] + active_prev = False + description_prev = None + + for msg in lr: + if msg.which() == 'alertDebug': + active = 'Active' in msg.alertDebug.alertText1 or msg.alertDebug.alertText1 == 'Complete' + if active and not active_prev: + if msg.alertDebug.alertText2 == description_prev: + maneuvers[-1][1].append([]) + else: + maneuvers.append((msg.alertDebug.alertText2, [[]])) + description_prev = maneuvers[-1][0] + active_prev = active + + if active_prev: + maneuvers[-1][1][-1].append(msg) + + report(platform, args.route, args.description, CP, ID, maneuvers) diff --git a/tools/lateral_maneuvers/lateral_maneuversd.py b/tools/lateral_maneuvers/lateral_maneuversd.py new file mode 100755 index 0000000000..d8a7185410 --- /dev/null +++ b/tools/lateral_maneuvers/lateral_maneuversd.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +import numpy as np +from dataclasses import dataclass + +from cereal import messaging, car +from openpilot.common.constants import CV +from openpilot.common.realtime import DT_MDL +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.controls.lib.drive_helpers import MIN_SPEED +from openpilot.tools.longitudinal_maneuvers.maneuversd import Action, Maneuver as _Maneuver + +# thresholds for starting maneuvers +MAX_SPEED_DEV = 0.7 # deviation in m/s +MAX_CURV = 0.002 # 500 m radius +MAX_ROLL = 0.08 # 4.56° +TIMER = 2.0 # sec stable conditions before starting maneuver + +@dataclass +class Maneuver(_Maneuver): + _baseline_curvature: float = 0.0 + + def get_accel(self, v_ego: float, lat_active: bool, curvature: float, roll: float) -> float: + self._run_completed = False + # only start maneuver on straight, flat roads + ready = abs(v_ego - self.initial_speed) < MAX_SPEED_DEV and lat_active and abs(curvature) < MAX_CURV and abs(roll) < MAX_ROLL + self._ready_cnt = (self._ready_cnt + 1) if ready else max(self._ready_cnt - 1, 0) + + if self._ready_cnt > (TIMER / DT_MDL): + if not self._active: + self._baseline_curvature = curvature + self._active = True + + if not self._active: + return 0.0 + + return self._step() + + def reset(self): + super().reset() + self._ready_cnt = 0 + + +def _sine_action(amplitude, period, duration): + t = np.linspace(0, duration, int(duration / DT_MDL) + 1) + a = amplitude * np.sin(2 * np.pi * t / period) + return Action(a.tolist(), t.tolist()) + + +MANEUVERS = [ + Maneuver( + "step right 20mph", + [Action([0.5], [1.0]), Action([-0.5], [1.5])], + repeat=2, + initial_speed=20. * CV.MPH_TO_MS, + ), + Maneuver( + "step left 20mph", + [Action([-0.5], [1.0]), Action([0.5], [1.5])], + repeat=2, + initial_speed=20. * CV.MPH_TO_MS, + ), + Maneuver( + "sine 0.5Hz 20mph", + [_sine_action(1.0, 2.0, 2.0), Action([0.0], [0.5])], + repeat=2, + initial_speed=20. * CV.MPH_TO_MS, + ), + Maneuver( + "step right 30mph", + [Action([0.5], [1.0]), Action([-0.5], [1.5])], + repeat=2, + initial_speed=30. * CV.MPH_TO_MS, + ), + Maneuver( + "step left 30mph", + [Action([-0.5], [1.0]), Action([0.5], [1.5])], + repeat=2, + initial_speed=30. * CV.MPH_TO_MS, + ), + Maneuver( + "sine 0.5Hz 30mph", + [_sine_action(1.0, 2.0, 2.0), Action([0.0], [0.5])], + repeat=2, + initial_speed=30. * CV.MPH_TO_MS, + ), +] + + +def main(): + params = Params() + cloudlog.info("lateral_maneuversd is waiting for CarParams") + messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) + + sm = messaging.SubMaster(['carState', 'carControl', 'controlsState', 'selfdriveState', 'modelV2'], poll='modelV2') + pm = messaging.PubMaster(['lateralManeuverPlan', 'alertDebug']) + + maneuvers = iter(MANEUVERS) + maneuver = None + complete_cnt = 0 + display_holdoff = 0 + prev_text = '' + + while True: + sm.update() + + if maneuver is None: + maneuver = next(maneuvers, None) + + alert_msg = messaging.new_message('alertDebug') + alert_msg.valid = True + + plan_send = messaging.new_message('lateralManeuverPlan') + + accel = 0 + v_ego = max(sm['carState'].vEgo, 0) + curvature = sm['controlsState'].desiredCurvature + + if complete_cnt > 0: + complete_cnt -= 1 + alert_msg.alertDebug.alertText1 = 'Completed' + alert_msg.alertDebug.alertText2 = maneuver.description + elif maneuver is not None: + # reset maneuver on steering override or out of range speed + if sm['carState'].steeringPressed or (maneuver.active and abs(v_ego - maneuver.initial_speed) > MAX_SPEED_DEV): + maneuver.reset() + + roll = sm['carControl'].orientationNED[0] if len(sm['carControl'].orientationNED) == 3 else 0.0 + accel = maneuver.get_accel(v_ego, sm['carControl'].latActive, curvature, roll) + + if maneuver._run_completed: + complete_cnt = int(1.0 / DT_MDL) + alert_msg.alertDebug.alertText1 = 'Complete' + alert_msg.alertDebug.alertText2 = maneuver.description + elif maneuver.active: + action_remaining = maneuver.actions[maneuver._action_index].time_bp[-1] - maneuver._action_frames * DT_MDL + if maneuver.description.startswith('sine'): + freq = maneuver.description.split()[1] + alert_msg.alertDebug.alertText1 = f'Active sine {freq} {max(action_remaining, 0):.1f}s' + else: + alert_msg.alertDebug.alertText1 = f'Active {accel:+.1f}m/s² {max(action_remaining, 0):.1f}s' + alert_msg.alertDebug.alertText2 = maneuver.description + elif not (abs(v_ego - maneuver.initial_speed) < MAX_SPEED_DEV and sm['carControl'].latActive): + alert_msg.alertDebug.alertText1 = f'Set speed to {maneuver.initial_speed * CV.MS_TO_MPH:0.0f} mph' + elif maneuver._ready_cnt > 0: + ready_time = max(TIMER - maneuver._ready_cnt * DT_MDL, 0) + alert_msg.alertDebug.alertText1 = f'Starting: {int(ready_time) + 1}' + alert_msg.alertDebug.alertText2 = maneuver.description + else: + curv_ok = abs(curvature) < MAX_CURV + reason = 'road not straight' if not curv_ok else 'road not flat' + alert_msg.alertDebug.alertText1 = f'Waiting: {reason}' + alert_msg.alertDebug.alertText2 = maneuver.description + else: + alert_msg.alertDebug.alertText1 = 'Maneuvers Finished' + + # prevent flickering text + setup = ('Set speed', 'Starting', 'Waiting') + text = alert_msg.alertDebug.alertText1 + same = text == prev_text or (text.startswith('Starting') and prev_text.startswith('Starting')) + if not same and text.startswith(setup) and prev_text.startswith(setup) and display_holdoff > 0: + alert_msg.alertDebug.alertText1 = prev_text + display_holdoff -= 1 + else: + prev_text = text + display_holdoff = int(0.5 / DT_MDL) if text.startswith(setup) else 0 + + pm.send('alertDebug', alert_msg) + + plan_send.valid = maneuver is not None and maneuver.active and complete_cnt == 0 + if plan_send.valid: + plan_send.lateralManeuverPlan.desiredCurvature = maneuver._baseline_curvature + accel / max(v_ego, MIN_SPEED) ** 2 + pm.send('lateralManeuverPlan', plan_send) + + if maneuver is not None and maneuver.finished and complete_cnt == 0: + maneuver = None + + +if __name__ == "__main__": + main() diff --git a/tools/lib/file_downloader.py b/tools/lib/file_downloader.py index c9c26bb307..5b31a5894c 100755 --- a/tools/lib/file_downloader.py +++ b/tools/lib/file_downloader.py @@ -60,8 +60,16 @@ def cmd_download(args): return try: - uf = URLFile(url, cache=False) - total = uf.get_length() + # Stream the file in a single HTTP request instead of making + # a separate Range request per chunk (which was very slow). + pool = URLFile.pool_manager() + r = pool.request("GET", url, preload_content=False) + if r.status not in (200, 206): + sys.stderr.write(f"ERROR:HTTP {r.status}\n") + sys.stderr.flush() + sys.exit(1) + + total = int(r.headers.get('content-length', 0)) if total <= 0: sys.stderr.write("ERROR:File not found or empty\n") sys.stderr.flush() @@ -73,8 +81,7 @@ def cmd_download(args): downloaded = 0 chunk_size = 1024 * 1024 with os.fdopen(tmp_fd, 'wb') as f: - while downloaded < total: - data = uf.read(min(chunk_size, total - downloaded)) + for data in r.stream(chunk_size): f.write(data) downloaded += len(data) sys.stderr.write(f"PROGRESS:{downloaded}:{total}\n") @@ -91,6 +98,8 @@ def cmd_download(args): except OSError: pass raise + finally: + r.release_conn() except Exception as e: sys.stderr.write(f"ERROR:{e}\n") diff --git a/tools/lib/github_utils.py b/tools/lib/github_utils.py index 46a0dcf3cb..6a443b4155 100644 --- a/tools/lib/github_utils.py +++ b/tools/lib/github_utils.py @@ -62,7 +62,7 @@ class GithubUtils: self.api_call(github_path, data=data, method=HTTPMethod.POST, data_call=True) def get_bucket_sha(self, bucket): - github_path = f"git/refs/heads/{bucket}" + github_path = f"git/ref/heads/{bucket}" r = self.api_call(github_path, data_call=True, raise_on_failure=False) return r.json()['object']['sha'] if r.ok else None diff --git a/tools/longitudinal_maneuvers/maneuversd.py b/tools/longitudinal_maneuvers/maneuversd.py index 9044afaa8f..48e7384cd3 100755 --- a/tools/longitudinal_maneuvers/maneuversd.py +++ b/tools/longitudinal_maneuvers/maneuversd.py @@ -27,23 +27,14 @@ class Maneuver: _active: bool = False _finished: bool = False + _run_completed: bool = False _action_index: int = 0 _action_frames: int = 0 _ready_cnt: int = 0 _repeated: int = 0 - def get_accel(self, v_ego: float, long_active: bool, standstill: bool, cruise_standstill: bool) -> float: - ready = abs(v_ego - self.initial_speed) < 0.3 and long_active and not cruise_standstill - if self.initial_speed < 0.01: - ready = ready and standstill - self._ready_cnt = (self._ready_cnt + 1) if ready else 0 - - if self._ready_cnt > (3. / DT_MDL): - self._active = True - - if not self._active: - return min(max(self.initial_speed - v_ego, -2.), 2.) - + def _step(self) -> float: + self._run_completed = False action = self.actions[self._action_index] action_accel = np.interp(self._action_frames * DT_MDL, action.time_bp, action.accel_bp) @@ -58,15 +49,34 @@ class Maneuver: # repeat maneuver elif self._repeated < self.repeat: self._repeated += 1 - self._action_index = 0 - self._action_frames = 0 - self._active = False + self._run_completed = True + self.reset() # finish maneuver else: + self._run_completed = True self._finished = True return float(action_accel) + def get_accel(self, v_ego: float, long_active: bool, standstill: bool, cruise_standstill: bool) -> float: + ready = abs(v_ego - self.initial_speed) < 0.3 and long_active and not cruise_standstill + if self.initial_speed < 0.01: + ready = ready and standstill + self._ready_cnt = (self._ready_cnt + 1) if ready else 0 + + if self._ready_cnt > (3. / DT_MDL): + self._active = True + + if not self._active: + return min(max(self.initial_speed - v_ego, -2.), 2.) + + return self._step() + + def reset(self): + self._active = False + self._action_frames = 0 + self._action_index = 0 + @property def finished(self): return self._finished diff --git a/tools/op.sh b/tools/op.sh index 7c20403a27..dccf080829 100755 --- a/tools/op.sh +++ b/tools/op.sh @@ -26,15 +26,6 @@ function op_install() { echo -e " ↳ [${GREEN}✔${NC}] op installed successfully. Open a new shell to use it." } -function loge() { - if [[ -f "$LOG_FILE" ]]; then - # error type - echo "$1" >> $LOG_FILE - # error log - echo "$2" >> $LOG_FILE - fi -} - function retry() { local attempts=$1 shift @@ -148,13 +139,11 @@ function op_check_os() { ;; * ) echo -e " ↳ [${RED}✗${NC}] Incompatible Ubuntu version $VERSION_CODENAME detected!" - loge "ERROR_INCOMPATIBLE_UBUNTU" "$VERSION_CODENAME" return 1 ;; esac else echo -e " ↳ [${RED}✗${NC}] No /etc/os-release on your system. Make sure you're running on Ubuntu, or similar!" - loge "ERROR_UNKNOWN_UBUNTU" return 1 fi @@ -162,34 +151,10 @@ function op_check_os() { echo -e " ↳ [${GREEN}✔${NC}] macOS detected." else echo -e " ↳ [${RED}✗${NC}] OS type $OSTYPE not supported!" - loge "ERROR_UNKNOWN_OS" "$OSTYPE" return 1 fi } -function op_check_python() { - echo "Checking for compatible python version..." - REQUIRED_PYTHON_VERSION=$(grep "requires-python" $OPENPILOT_ROOT/pyproject.toml) - INSTALLED_PYTHON_VERSION=$(python3 --version 2> /dev/null || true) - - if [[ -z $INSTALLED_PYTHON_VERSION ]]; then - echo -e " ↳ [${RED}✗${NC}] python3 not found on your system. You need python version satisfying $(echo $REQUIRED_PYTHON_VERSION | cut -d '=' -f2-) to continue!" - loge "ERROR_PYTHON_NOT_FOUND" - return 1 - else - LB=$(echo $REQUIRED_PYTHON_VERSION | tr -d '",' | awk '{ split($4, v, "."); printf "%d%02d%02d", v[1], v[2], v[3] }') - UB=$(echo $REQUIRED_PYTHON_VERSION | tr -d '",' | awk '{ split($6, v, "."); printf "%d%02d%02d", v[1], v[2], v[3] }') - VERSION=$(echo $INSTALLED_PYTHON_VERSION | awk '{ split($2, v, "."); printf "%d%02d%02d", v[1], v[2], v[3] }') - if [[ $VERSION -ge LB && $VERSION -lt UB ]]; then - echo -e " ↳ [${GREEN}✔${NC}] $INSTALLED_PYTHON_VERSION detected." - else - echo -e " ↳ [${RED}✗${NC}] You need a python version satisfying $(echo $REQUIRED_PYTHON_VERSION | cut -d '=' -f2-) to continue!" - loge "ERROR_PYTHON_VERSION" "$INSTALLED_PYTHON_VERSION" - return 1 - fi - fi -} - function op_check_venv() { echo "Checking for venv..." if [[ -f $OPENPILOT_ROOT/.venv/bin/activate ]]; then @@ -214,8 +179,6 @@ function op_before_cmd() { op_activate_venv - result="${result}\n$(( op_check_python ) 2>&1)" || (echo -e "$result" && return 1) - if [[ -z $VERBOSE ]]; then echo -e "${BOLD}Checking system →${NC} [${GREEN}✔${NC}]" else @@ -235,7 +198,6 @@ function op_setup() { SETUP_SCRIPT="tools/setup_dependencies.sh" if ! $OPENPILOT_ROOT/$SETUP_SCRIPT; then echo -e " ↳ [${RED}✗${NC}] Dependencies installation failed!" - loge "ERROR_DEPENDENCIES_INSTALLATION" return 1 fi et="$(date +%s)" @@ -247,7 +209,6 @@ function op_setup() { st="$(date +%s)" if ! retry 3 git submodule update --jobs 4 --init --recursive; then echo -e " ↳ [${RED}✗${NC}] Getting git submodules failed!" - loge "ERROR_GIT_SUBMODULES" return 1 fi et="$(date +%s)" @@ -257,7 +218,6 @@ function op_setup() { st="$(date +%s)" if ! retry 3 git lfs pull; then echo -e " ↳ [${RED}✗${NC}] Pulling git lfs files failed!" - loge "ERROR_GIT_LFS" return 1 fi et="$(date +%s)" @@ -408,6 +368,9 @@ function op_switch() { git submodule update --init --recursive git submodule foreach git reset --hard git submodule foreach git clean -df + + # remove openpilot update flag if present + rm -f .overlay_init } function op_start() { @@ -436,7 +399,7 @@ function op_default() { echo "" echo -e "${BOLD}${UNDERLINE}Commands [System]:${NC}" echo -e " ${BOLD}auth${NC} Authenticate yourself for API use" - echo -e " ${BOLD}check${NC} Check the development environment (git, os, python) to start using openpilot" + 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" @@ -490,7 +453,6 @@ function _op() { -d | --dir ) shift 1; OPENPILOT_ROOT="$1"; shift 1 ;; --dry ) shift 1; DRY="1" ;; -n | --no-verify ) shift 1; NO_VERIFY="1" ;; - -l | --log ) shift 1; LOG_FILE="$1" ; shift 1 ;; esac # parse Commands diff --git a/tools/plotjuggler/.gitignore b/tools/plotjuggler/.gitignore deleted file mode 100644 index 45559d0b09..0000000000 --- a/tools/plotjuggler/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -bin/ -bin -*.rlog diff --git a/tools/plotjuggler/layouts/controls_mismatch_debug.xml b/tools/plotjuggler/layouts/controls_mismatch_debug.xml index 646e12a281..cf337aa7df 100644 --- a/tools/plotjuggler/layouts/controls_mismatch_debug.xml +++ b/tools/plotjuggler/layouts/controls_mismatch_debug.xml @@ -16,7 +16,7 @@ - + @@ -58,4 +58,3 @@ - diff --git a/tools/plotjuggler/layouts/gps_vs_llk.xml b/tools/plotjuggler/layouts/gps_vs_llk.xml index 69b8f20058..2051c2bef2 100644 --- a/tools/plotjuggler/layouts/gps_vs_llk.xml +++ b/tools/plotjuggler/layouts/gps_vs_llk.xml @@ -24,8 +24,8 @@ - - + + @@ -72,12 +72,11 @@ return distance /gpsLocationExternal/latitude /gpsLocationExternal/longitude - /liveLocationKalman/positionGeodetic/value/0 - /liveLocationKalman/positionGeodetic/value/1 + /liveLocationKalmanDEPRECATED/positionGeodetic/value/0 + /liveLocationKalmanDEPRECATED/positionGeodetic/value/1 - diff --git a/tools/plotjuggler/layouts/system_lag_debug.xml b/tools/plotjuggler/layouts/system_lag_debug.xml index a90bba0e27..88511ffe09 100644 --- a/tools/plotjuggler/layouts/system_lag_debug.xml +++ b/tools/plotjuggler/layouts/system_lag_debug.xml @@ -45,7 +45,7 @@ - + @@ -64,4 +64,3 @@ - diff --git a/tools/plotjuggler/layouts/tuning.xml b/tools/plotjuggler/layouts/tuning.xml index 503e726caf..699f6ff683 100644 --- a/tools/plotjuggler/layouts/tuning.xml +++ b/tools/plotjuggler/layouts/tuning.xml @@ -24,14 +24,14 @@ - + - + @@ -39,7 +39,7 @@ - + @@ -71,7 +71,7 @@ - + @@ -126,7 +126,7 @@ - + @@ -161,11 +161,11 @@ if (time > last_bad_time + engage_delay) then else return 0 end - /liveLocationKalman/angularVelocityCalibrated/value/2 + /carControl/angularVelocity/2 /carState/steeringPressed /carControl/enabled - /liveLocationKalman/velocityCalibrated/value/0 + /carState/vEgo @@ -206,7 +206,7 @@ if (time > last_bad_time + engage_delay) then else return 0 end - /lateralPlan/curvatures/0 + /modelV2/action/desiredCurvature /carState/steeringPressed /carControl/enabled @@ -284,8 +284,17 @@ end /carControl/enabled + + + return (math.abs(value - v1) > 0.001 or math.abs(v2 - v3) > 0.05) and 1 or 0 + /carControl/actuators/torque + + /carOutput/actuatorsOutput/torque + /carControl/actuators/steeringAngleDeg + /carOutput/actuatorsOutput/steeringAngleDeg + + - diff --git a/tools/replay/.gitignore b/tools/replay/.gitignore index 83f0e99a8b..aa615770a2 100644 --- a/tools/replay/.gitignore +++ b/tools/replay/.gitignore @@ -1,5 +1,2 @@ -moc_* -*.moc - replay tests/test_replay diff --git a/tools/replay/SConscript b/tools/replay/SConscript index 3efa970b37..d047415f58 100644 --- a/tools/replay/SConscript +++ b/tools/replay/SConscript @@ -3,8 +3,8 @@ Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal') replay_env = env.Clone() replay_env['CCFLAGS'] += ['-Wno-deprecated-declarations'] -base_frameworks = [] -base_libs = [common, messaging, cereal, visionipc, 'm', 'ssl', 'crypto', 'pthread'] +base_frameworks = ['VideoToolbox', 'CoreMedia', 'CoreFoundation', 'CoreVideo'] if arch == "Darwin" else [] +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"] @@ -13,6 +13,8 @@ if arch != "Darwin": replay_lib = replay_env.Library("replay", replay_lib_src, LIBS=base_libs, FRAMEWORKS=base_frameworks) Export('replay_lib') replay_libs = [replay_lib, 'avformat', 'avcodec', 'swresample', 'avutil', 'x264', 'z', 'bz2', 'zstd', 'yuv', 'ncurses'] + base_libs +if arch != "Darwin": + replay_libs += ['va', 'va-drm', 'drm'] replay_env.Program("replay", ["main.cc"], LIBS=replay_libs, FRAMEWORKS=base_frameworks) if GetOption('extras'): diff --git a/tools/replay/lib/ui_helpers.py b/tools/replay/lib/ui_helpers.py index 7c95e9cad4..039dd4f235 100644 --- a/tools/replay/lib/ui_helpers.py +++ b/tools/replay/lib/ui_helpers.py @@ -5,6 +5,7 @@ import numpy as np import pyray as rl from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.offsetbox import AnchoredOffsetbox, HPacker, TextArea from openpilot.common.transformations.camera import get_view_frame_from_calib_frame from openpilot.selfdrive.controls.radard import RADAR_TO_CAMERA @@ -94,6 +95,7 @@ def draw_path(path, color, img, calibration, top_down, lid_color=None, z_off=0): def init_plots(arr, name_to_arr_idx, plot_xlims, plot_ylims, plot_names, plot_colors, plot_styles): color_palette = {"r": (1, 0, 0), "g": (0, 1, 0), "b": (0, 0, 1), "k": (0, 0, 0), "y": (1, 1, 0), "p": (0, 1, 1), "m": (1, 0, 1)} + label_palette = {**color_palette, "b": (43/255, 114/255, 1.0)} dpi = 90 fig = plt.figure(figsize=(575 / dpi, 600 / dpi), dpi=dpi) @@ -116,10 +118,18 @@ def init_plots(arr, name_to_arr_idx, plot_xlims, plot_ylims, plot_names, plot_co plots.append(plot) idxs.append(name_to_arr_idx[item]) plot_select.append(i) - axs[i].set_title(", ".join(f"{nm} ({cl})" for (nm, cl) in zip(pl_list, plot_colors[i], strict=False)), fontsize=10) + # Build colored title: each label colored to match its plot line + title_texts = [] + for j2, (nm, cl) in enumerate(zip(pl_list, plot_colors[i], strict=False)): + if j2 > 0: + title_texts.append(TextArea(", ", textprops=dict(color="white", fontsize=10))) + title_texts.append(TextArea(nm, textprops=dict(color=label_palette[cl], fontsize=10))) + packed = HPacker(children=title_texts, pad=0, sep=0) + ab = AnchoredOffsetbox(loc='lower center', child=packed, bbox_to_anchor=(0.5, 1.0), + bbox_transform=axs[i].transAxes, frameon=False, pad=0) + axs[i].add_artist(ab) axs[i].tick_params(axis="x", colors="white") axs[i].tick_params(axis="y", colors="white") - axs[i].title.set_color("white") if i < len(plot_ylims) - 1: axs[i].set_xticks([]) diff --git a/tools/replay/logreader.cc b/tools/replay/logreader.cc index 997a4bc00f..54b69dc168 100644 --- a/tools/replay/logreader.cc +++ b/tools/replay/logreader.cc @@ -1,31 +1,68 @@ #include "tools/replay/logreader.h" #include +#include #include #include "tools/replay/filereader.h" +#include "tools/replay/py_downloader.h" #include "tools/replay/util.h" #include "common/util.h" -bool LogReader::load(const std::string &url, std::atomic *abort, bool local_cache) { +bool LogReader::load(const std::string &url, std::atomic *abort, bool local_cache, + const ProgressCallback &progress) { + using Clock = std::chrono::steady_clock; + compressed_size_ = 0; + decompressed_size_ = 0; + download_seconds_ = 0.0; + decompress_seconds_ = 0.0; + parse_seconds_ = 0.0; + + if (progress) { + installDownloadProgressHandler([progress](uint64_t cur, uint64_t total, bool success) { + if (success) { + progress(ProgressStage::Downloading, cur, total); + } + }); + } + const auto download_start = Clock::now(); std::string data = FileReader(local_cache).read(url, abort); + const auto download_end = Clock::now(); + if (progress) { + installDownloadProgressHandler(nullptr); + } + 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); + bool success = !data.empty() && load(data.data(), data.size(), abort, progress); if (filters_.empty()) raw_ = std::move(data); return success; } -bool LogReader::load(const char *data, size_t size, std::atomic *abort) { +bool LogReader::load(const char *data, size_t size, std::atomic *abort, + const ProgressCallback &progress) { + using Clock = std::chrono::steady_clock; + const auto parse_start = Clock::now(); try { events.reserve(65000); kj::ArrayPtr words((const capnp::word *)data, size / sizeof(capnp::word)); + const uint64_t total_bytes = size; + const uint64_t report_step = std::max(1, total_bytes / 200); + uint64_t last_reported = 0; + if (progress) { + progress(ProgressStage::Parsing, 0, total_bytes); + } while (words.size() > 0 && !(abort && *abort)) { capnp::FlatArrayMessageReader reader(words); auto event = reader.getRoot(); @@ -56,15 +93,30 @@ bool LogReader::load(const char *data, size_t size, std::atomic *abort) { events.emplace_back(which, sof ? sof : mono_time, event_data, idx.getSegmentNum()); } } + + if (progress) { + const uint64_t current_bytes = + total_bytes - static_cast(words.size() * sizeof(capnp::word)); + if (current_bytes >= total_bytes || current_bytes - last_reported >= report_step) { + progress(ProgressStage::Parsing, current_bytes, total_bytes); + last_reported = current_bytes; + } + } } } catch (const kj::Exception &e) { rWarning("Failed to parse log : %s.\nRetrieved %zu events from corrupt log", e.getDescription().cStr(), events.size()); } + if (progress) { + progress(ProgressStage::Parsing, size, size); + } + if (requires_migration) { migrateOldEvents(); } + parse_seconds_ = std::chrono::duration(Clock::now() - parse_start).count(); + if (!events.empty() && !(abort && *abort)) { events.shrink_to_fit(); std::sort(events.begin(), events.end()); @@ -90,18 +142,19 @@ void LogReader::migrateOldEvents() { new_evt.setLogMonoTime(old_evt.getLogMonoTime()); auto new_state = new_evt.initSelfdriveState(); - new_state.setActive(old_state.getActiveDEPRECATED()); - new_state.setAlertSize(old_state.getAlertSizeDEPRECATED()); - new_state.setAlertSound(old_state.getAlertSound2DEPRECATED()); - new_state.setAlertStatus(old_state.getAlertStatusDEPRECATED()); - new_state.setAlertText1(old_state.getAlertText1DEPRECATED()); - new_state.setAlertText2(old_state.getAlertText2DEPRECATED()); - new_state.setAlertType(old_state.getAlertTypeDEPRECATED()); - new_state.setEnabled(old_state.getEnabledDEPRECATED()); - new_state.setEngageable(old_state.getEngageableDEPRECATED()); - new_state.setExperimentalMode(old_state.getExperimentalModeDEPRECATED()); - new_state.setPersonality(old_state.getPersonalityDEPRECATED()); - new_state.setState(old_state.getStateDEPRECATED()); + auto old_dep = old_state.getDeprecated(); + new_state.setActive(old_dep.getActive()); + new_state.setAlertSize(old_dep.getAlertSize()); + new_state.setAlertSound(old_dep.getAlertSound2()); + new_state.setAlertStatus(old_dep.getAlertStatus()); + new_state.setAlertText1(old_dep.getAlertText1()); + new_state.setAlertText2(old_dep.getAlertText2()); + new_state.setAlertType(old_dep.getAlertType()); + new_state.setEnabled(old_dep.getEnabled()); + new_state.setEngageable(old_dep.getEngageable()); + new_state.setExperimentalMode(old_dep.getExperimentalMode()); + new_state.setPersonality(old_dep.getPersonality()); + new_state.setState(old_dep.getState()); // Serialize the new event to the buffer auto buf_size = msg.getSerializedSize(); diff --git a/tools/replay/logreader.h b/tools/replay/logreader.h index 9219878ace..fe11ab0f77 100644 --- a/tools/replay/logreader.h +++ b/tools/replay/logreader.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include @@ -26,12 +28,26 @@ public: class LogReader { public: + enum class ProgressStage { + Downloading, + Parsing, + }; + + using ProgressCallback = std::function; + LogReader(const std::vector &filters = {}) { filters_ = filters; } bool load(const std::string &url, std::atomic *abort = nullptr, - bool local_cache = false); - bool load(const char *data, size_t size, std::atomic *abort = nullptr); + bool local_cache = false, const ProgressCallback &progress = {}); + bool load(const char *data, size_t size, std::atomic *abort = nullptr, + const ProgressCallback &progress = {}); std::vector events; + uint64_t compressed_size() const { return compressed_size_; } + uint64_t decompressed_size() const { return decompressed_size_; } + double download_seconds() const { return download_seconds_; } + double decompress_seconds() const { return decompress_seconds_; } + double parse_seconds() const { return parse_seconds_; } + private: void migrateOldEvents(); @@ -39,4 +55,9 @@ private: bool requires_migration = true; std::vector filters_; MonotonicBuffer buffer_{1024 * 1024}; + uint64_t compressed_size_ = 0; + uint64_t decompressed_size_ = 0; + double download_seconds_ = 0.0; + double decompress_seconds_ = 0.0; + double parse_seconds_ = 0.0; }; diff --git a/tools/replay/py_downloader.cc b/tools/replay/py_downloader.cc index efaf3c93a2..5063d6947c 100644 --- a/tools/replay/py_downloader.cc +++ b/tools/replay/py_downloader.cc @@ -149,11 +149,16 @@ std::string runPython(const std::vector &args, std::atomic *a int status; waitpid(pid, &status, 0); - bool failed = (abort && *abort) || + const bool aborted = abort && *abort; + const bool expected_sigterm = aborted && WIFSIGNALED(status) && WTERMSIG(status) == SIGTERM; + bool failed = aborted || (WIFEXITED(status) && WEXITSTATUS(status) != 0) || WIFSIGNALED(status); if (failed) { - if (WIFEXITED(status) && WEXITSTATUS(status) != 0) { + if (expected_sigterm) { + // Route/camera teardown cancels outstanding downloader subprocesses. + // Keep that expected shutdown path quiet. + } else if (WIFEXITED(status) && WEXITSTATUS(status) != 0) { rWarning("py_downloader: process exited with code %d", WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { rWarning("py_downloader: process killed by signal %d", WTERMSIG(status)); diff --git a/tools/replay/ui.py b/tools/replay/ui.py index 8707f2be99..405c79f425 100755 --- a/tools/replay/ui.py +++ b/tools/replay/ui.py @@ -3,7 +3,6 @@ import argparse import os import sys -import cv2 import numpy as np import pyray as rl @@ -22,7 +21,8 @@ from openpilot.tools.replay.lib.ui_helpers import ( plot_lead, plot_model, ) -from msgq.visionipc import VisionIpcClient, VisionStreamType +from msgq.visionipc import VisionStreamType +from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView os.environ['BASEDIR'] = BASEDIR @@ -30,8 +30,6 @@ ANGLE_SCALE = 5.0 def ui_thread(addr): - cv2.setNumThreads(1) - # Get monitor info before creating window rl.set_config_flags(rl.ConfigFlags.FLAG_MSAA_4X_HINT) rl.init_window(1, 1, "") @@ -59,14 +57,15 @@ def ui_thread(addr): font_path = os.path.join(BASEDIR, "selfdrive/assets/fonts/JetBrainsMono-Medium.ttf") font = rl.load_font_ex(font_path, 32, None, 0) - # Create textures for camera and top-down view - camera_image = rl.gen_image_color(640, 480, rl.BLACK) - camera_texture = rl.load_texture_from_image(camera_image) - rl.unload_image(camera_image) + camera_view = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD) + + # Overlay texture for model/lane line drawing + overlay_img = np.zeros((480, 640, 4), dtype='uint8') + overlay_image = rl.gen_image_color(640, 480, rl.BLANK) + overlay_texture = rl.load_texture_from_image(overlay_image) + rl.unload_image(overlay_image) # lid_overlay array is (lidar_x, lidar_y) = (384, 960) - # pygame treats first axis as width, so texture is 384 wide x 960 tall - # For raylib, we need to transpose to get (height, width) = (960, 384) for the RGBA array top_down_image = rl.gen_image_color(UP.lidar_x, UP.lidar_y, rl.BLACK) top_down_texture = rl.load_texture_from_image(top_down_image) rl.unload_image(top_down_image) @@ -89,7 +88,6 @@ def ui_thread(addr): ) img = np.zeros((480, 640, 3), dtype='uint8') - imgff = None num_px = 0 calibration = None @@ -116,7 +114,7 @@ def ui_thread(addr): plot_arr = np.zeros((100, len(name_to_arr_idx.values()))) plot_xlims = [(0, plot_arr.shape[0]), (0, plot_arr.shape[0]), (0, plot_arr.shape[0]), (0, plot_arr.shape[0])] - plot_ylims = [(-0.1, 1.1), (-ANGLE_SCALE, ANGLE_SCALE), (0.0, 75.0), (-3.0, 2.0)] + plot_ylims = [(-0.1, 1.1), (-ANGLE_SCALE, ANGLE_SCALE), (0.0, 75.0), (-3.5, 2.0)] plot_names = [ ["gas", "computer_gas", "user_brake", "computer_brake"], ["angle_steers", "angle_steers_des", "angle_steers_k", "steer_torque"], @@ -138,20 +136,16 @@ def ui_thread(addr): palette[110] = [110, 110, 110, 255] # car_color (gray) palette[255] = [255, 255, 255, 255] # WHITE - vipc_client = VisionIpcClient("camerad", VisionStreamType.VISION_STREAM_ROAD, True) while not rl.window_should_close(): - # ***** frame ***** - if not vipc_client.is_connected(): - vipc_client.connect(False) - rl.begin_drawing() rl.clear_background(rl.Color(64, 64, 64, 255)) - yuv_img_raw = vipc_client.recv() - if yuv_img_raw is None or not yuv_img_raw.data.any(): - rl.draw_text_ex(font, "waiting for frames", rl.Vector2(200, 200), 30, 0, rl.WHITE) - rl.end_drawing() - continue + # Render camera (NV12->RGB on GPU via shader) + if camera_view.frame: + cam_h = 640.0 * camera_view.frame.height / camera_view.frame.width + else: + cam_h = 480.0 + camera_view.render(rl.Rectangle(0, 0, 640, cam_h)) lid_overlay = lid_overlay_blank.copy() top_down = top_down_texture, lid_overlay @@ -159,19 +153,10 @@ def ui_thread(addr): sm.update(0) camera = DEVICE_CAMERAS[("tici", str(sm['roadCameraState'].sensor))] - - # Use received buffer dimensions (full HEVC can have stride != buffer_len/rows due to VENUS padding) - h, w, stride = yuv_img_raw.height, yuv_img_raw.width, yuv_img_raw.stride - nv12_size = h * 3 // 2 * stride - imgff = np.frombuffer(yuv_img_raw.data, dtype=np.uint8, count=nv12_size).reshape((h * 3 // 2, stride)) - num_px = w * h - rgb = cv2.cvtColor(imgff[: h * 3 // 2, : w], cv2.COLOR_YUV2RGB_NV12) - - qcam = "QCAM" in os.environ - bb_scale = (528 if qcam else camera.fcam.width) / 640.0 calib_scale = camera.fcam.width / 640.0 - zoom_matrix = np.asarray([[bb_scale, 0.0, 0.0], [0.0, bb_scale, 0.0], [0.0, 0.0, 1.0]]) - cv2.warpAffine(rgb, zoom_matrix[:2], (img.shape[1], img.shape[0]), dst=img, flags=cv2.WARP_INVERSE_MAP) + + if camera_view.frame: + num_px = camera_view.frame.width * camera_view.frame.height intrinsic_matrix = camera.fcam.intrinsics @@ -183,7 +168,8 @@ def ui_thread(addr): else: angle_steers_k = np.inf - plot_arr[:-1] = plot_arr[1:] + if sm.updated['carState']: + plot_arr[:-1] = plot_arr[1:] plot_arr[-1, name_to_arr_idx['angle_steers']] = sm['carState'].steeringAngleDeg plot_arr[-1, name_to_arr_idx['angle_steers_des']] = sm['carControl'].actuators.steeringAngleDeg plot_arr[-1, name_to_arr_idx['angle_steers_k']] = angle_steers_k @@ -198,9 +184,10 @@ def ui_thread(addr): plot_arr[-1, name_to_arr_idx['v_cruise']] = sm['carState'].cruiseState.speed plot_arr[-1, name_to_arr_idx['a_ego']] = sm['carState'].aEgo - if len(sm['longitudinalPlan'].accels): - plot_arr[-1, name_to_arr_idx['a_target']] = sm['longitudinalPlan'].accels[0] + plot_arr[-1, name_to_arr_idx['a_target']] = sm['longitudinalPlan'].aTarget + # Draw model overlays onto img, then blit as transparent overlay + img[:] = 0 if sm.recv_frame['modelV2']: plot_model(sm['modelV2'], img, calibration, top_down) @@ -214,11 +201,12 @@ def ui_thread(addr): rpyCalib = np.asarray(sm['liveCalibration'].rpyCalib) calibration = Calibration(num_px, rpyCalib, intrinsic_matrix, calib_scale) - # *** blits *** - # Update camera texture from numpy array - img_rgba = cv2.cvtColor(img, cv2.COLOR_RGB2RGBA) - rl.update_texture(camera_texture, rl.ffi.cast("void *", img_rgba.ctypes.data)) - rl.draw_texture(camera_texture, 0, 0, rl.WHITE) + # Update overlay texture (RGB img -> RGBA with non-black pixels visible) + mask = np.any(img > 0, axis=2) + overlay_img[:, :, :3] = img + overlay_img[:, :, 3] = mask * 255 + rl.update_texture(overlay_texture, rl.ffi.cast("void *", overlay_img.ctypes.data)) + rl.draw_texture(overlay_texture, 0, 0, rl.WHITE) # noqa: TID251 # display alerts rl.draw_text_ex(font, sm['selfdriveState'].alertText1, rl.Vector2(180, 150), 30, 0, rl.RED) @@ -227,15 +215,15 @@ def ui_thread(addr): # draw plots (texture is reused internally) plot_texture = draw_plots(plot_arr) if hor_mode: - rl.draw_texture(plot_texture, 640 + 384, 0, rl.WHITE) + rl.draw_texture(plot_texture, 640 + 384, 0, rl.WHITE) # noqa: TID251 else: - rl.draw_texture(plot_texture, 0, 600, rl.WHITE) + rl.draw_texture(plot_texture, 0, 600, rl.WHITE) # noqa: TID251 # Convert lid_overlay to RGBA and update top_down texture # lid_overlay is (384, 960), need to transpose to (960, 384) for row-major RGBA buffer lid_rgba = palette[lid_overlay.T] rl.update_texture(top_down_texture, rl.ffi.cast("void *", np.ascontiguousarray(lid_rgba).ctypes.data)) - rl.draw_texture(top_down_texture, 640, 0, rl.WHITE) + rl.draw_texture(top_down_texture, 640, 0, rl.WHITE) # noqa: TID251 SPACING = 25 lines = [ @@ -257,9 +245,10 @@ def ui_thread(addr): rl.end_drawing() - rl.unload_texture(camera_texture) + rl.unload_texture(overlay_texture) rl.unload_texture(top_down_texture) rl.unload_font(font) + camera_view.close() rl.close_window() diff --git a/tools/replay/util.cc b/tools/replay/util.cc index 7294de8282..7b308b5c3d 100644 --- a/tools/replay/util.cc +++ b/tools/replay/util.cc @@ -1,7 +1,6 @@ #include "tools/replay/util.h" #include -#include #include #include @@ -162,15 +161,6 @@ void precise_nano_sleep(int64_t nanoseconds, std::atomic &interrupt_reques } } -std::string sha256(const std::string &str) { - unsigned char hash[SHA256_DIGEST_LENGTH]; - SHA256_CTX sha256; - SHA256_Init(&sha256); - SHA256_Update(&sha256, str.c_str(), str.size()); - SHA256_Final(hash, &sha256); - return util::hexdump(hash, SHA256_DIGEST_LENGTH); -} - std::vector split(std::string_view source, char delimiter) { std::vector fields; size_t last = 0; diff --git a/tools/replay/util.h b/tools/replay/util.h index ee92190337..a2d0f6203a 100644 --- a/tools/replay/util.h +++ b/tools/replay/util.h @@ -46,7 +46,6 @@ private: static constexpr float growth_factor = 1.5; }; -std::string sha256(const std::string &str); 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); diff --git a/tools/scripts/adb_ssh.sh b/tools/scripts/adb_ssh.sh index 4527a0296d..b9668e7e0b 100755 --- a/tools/scripts/adb_ssh.sh +++ b/tools/scripts/adb_ssh.sh @@ -31,8 +31,12 @@ for name, port in sorted(ports): PY ) -# Forward SSH port first for interactive shell access. -adb forward tcp:2222 tcp:22 +# Forward SSH port, finding a free local port if 2222 is taken. +SSH_PORT=2222 +while ss -tln | grep -q ":${SSH_PORT} "; do + SSH_PORT=$((SSH_PORT + 1)) +done +adb forward tcp:${SSH_PORT} tcp:22 # SSH! -ssh comma@localhost -p 2222 "$@" +ssh comma@localhost -p ${SSH_PORT} "$@" diff --git a/tools/setup.sh b/tools/setup.sh index fd7efcee90..dafd466ef9 100755 --- a/tools/setup.sh +++ b/tools/setup.sh @@ -33,39 +33,6 @@ cat << 'EOF' EOF } -function sentry_send_event() { - SENTRY_KEY=dd0cba62ba0ac07ff9f388f8f1e6a7f4 - SENTRY_URL=https://sentry.io/api/4507726145781760/store/ - - EVENT=$1 - EVENT_TYPE=${2:-$EVENT} - EVENT_LOG=${3:-"NA"} - - PLATFORM=$(uname -s) - ARCH=$(uname -m) - SYSTEM=$(uname -a) - if [[ $PLATFORM == "Darwin" ]]; then - OS="macos" - elif [[ $PLATFORM == "Linux" ]]; then - OS="linux" - fi - - if [[ $ARCH == armv8* ]] || [[ $ARCH == arm64* ]] || [[ $ARCH == aarch64* ]]; then - ARCH="aarch64" - elif [[ $ARCH == "x86_64" ]] || [[ $ARCH == i686* ]]; then - ARCH="x86" - fi - - PYTHON_VERSION=$(echo $(python3 --version 2> /dev/null || echo "NA")) - BRANCH=$(echo $(git -C $OPENPILOT_ROOT rev-parse --abbrev-ref HEAD 2> /dev/null || echo "NA")) - COMMIT=$(echo $(git -C $OPENPILOT_ROOT rev-parse HEAD 2> /dev/null || echo "NA")) - - curl -s -o /dev/null -X POST -g --data "{ \"exception\": { \"values\": [{ \"type\": \"$EVENT\" }] }, \"tags\" : { \"event_type\" : \"$EVENT_TYPE\", \"event_log\" : \"$EVENT_LOG\", \"os\" : \"$OS\", \"arch\" : \"$ARCH\", \"python_version\" : \"$PYTHON_VERSION\" , \"git_branch\" : \"$BRANCH\", \"git_commit\" : \"$COMMIT\", \"system\" : \"$SYSTEM\" } }" \ - -H 'Content-Type: application/json' \ - -H "X-Sentry-Auth: Sentry sentry_version=7, sentry_key=$SENTRY_KEY, sentry_client=op_setup/0.1" \ - $SENTRY_URL 2> /dev/null -} - function check_stdin() { if [ -t 0 ]; then INTERACTIVE=1 @@ -131,7 +98,6 @@ function check_git() { echo "Checking for git..." if ! command -v "git" > /dev/null 2>&1; then echo -e " ↳ [${RED}✗${NC}] git not found on your system, can't continue!" - sentry_send_event "SETUP_FAILURE" "ERROR_GIT_NOT_FOUND" return 1 else echo -e " ↳ [${GREEN}✔${NC}] git found.\n" @@ -150,7 +116,6 @@ function git_clone() { fi echo -e " ↳ [${RED}✗${NC}] failed to clone openpilot!" - sentry_send_event "SETUP_FAILURE" "ERROR_GIT_CLONE" return 1 } @@ -159,18 +124,9 @@ function install_with_op() { $OPENPILOT_ROOT/tools/op.sh install $OPENPILOT_ROOT/tools/op.sh post-commit - LOG_FILE=$(mktemp) - - if ! $OPENPILOT_ROOT/tools/op.sh --log $LOG_FILE setup; then + if ! $OPENPILOT_ROOT/tools/op.sh setup; then echo -e "\n[${RED}✗${NC}] failed to install openpilot!" - - ERROR_TYPE="$(cat "$LOG_FILE" | sed '1p;d')" - ERROR_LOG="$(cat "$LOG_FILE" | sed '2p;d')" - sentry_send_event "SETUP_FAILURE" "$ERROR_TYPE" "$ERROR_LOG" || true - return 1 - else - sentry_send_event "SETUP_SUCCESS" || true fi echo -e "\n----------------------------------------------------------------------" diff --git a/tools/setup_dependencies.sh b/tools/setup_dependencies.sh index 0b785bf4a2..8132cd16dc 100755 --- a/tools/setup_dependencies.sh +++ b/tools/setup_dependencies.sh @@ -113,24 +113,12 @@ function install_python_deps() { source .venv/bin/activate } -function install_macos_deps() { - if ! command -v brew > /dev/null 2>&1; then - echo "homebrew not found, skipping macOS system dependency install" - return 0 - fi - - if ! command -v cmake > /dev/null 2>&1; then - brew install cmake - fi -} - # --- Main --- if [[ "$OSTYPE" == "linux-gnu"* ]]; then install_ubuntu_deps echo "[ ] installed system dependencies t=$SECONDS" elif [[ "$OSTYPE" == "darwin"* ]]; then - install_macos_deps if [[ $SHELL == "/bin/zsh" ]]; then RC_FILE="$HOME/.zshrc" elif [[ $SHELL == "/bin/bash" ]]; then diff --git a/tools/sim/bridge/metadrive/metadrive_common.py b/tools/sim/bridge/metadrive/metadrive_common.py index 42a7eb60dd..0106579b20 100644 --- a/tools/sim/bridge/metadrive/metadrive_common.py +++ b/tools/sim/bridge/metadrive/metadrive_common.py @@ -13,11 +13,9 @@ class CopyRamRGBCamera(RGBCamera): def get_rgb_array_cpu(self): origin_img = self.cpu_texture - img = np.frombuffer(origin_img.getRamImage().getData(), dtype=np.uint8) - img = img.reshape((origin_img.getYSize(), origin_img.getXSize(), -1)) - img = img[:,:,:3] # RGBA to RGB - # img = np.swapaxes(img, 1, 0) - img = img[::-1] # Flip on vertical axis + img = np.frombuffer(origin_img.getRamImageAs("RGB").getData(), dtype=np.uint8) + img = img.reshape((origin_img.getYSize(), origin_img.getXSize(), 3)) + img = img[::-1] # Flip on vertical axis return img diff --git a/tools/sim/lib/simulated_car.py b/tools/sim/lib/simulated_car.py index 68ff3050db..1567c20ef9 100644 --- a/tools/sim/lib/simulated_car.py +++ b/tools/sim/lib/simulated_car.py @@ -92,6 +92,8 @@ class SimulatedCar: 'ignitionLine': simulator_state.ignition, 'pandaType': "blackPanda", 'controlsAllowed': True, + 'controlsAllowedLateral': True, + 'controlsAllowedLongitudinal': True, 'safetyModel': 'hondaBosch', 'alternativeExperience': self.sm["carParams"].alternativeExperience, 'safetyParam': HondaSafetyFlags.RADARLESS.value | HondaSafetyFlags.BOSCH_LONG.value, diff --git a/uv.lock b/uv.lock index f5c128fcb7..f8278c1368 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -24,25 +24,25 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, ] [[package]] @@ -91,11 +91,11 @@ wheels = [ [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +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/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { 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]] @@ -116,12 +116,12 @@ wheels = [ [[package]] name = "bzip2" version = "1.0.8" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=bzip2&rev=releases#12581f30b45b570dd0bbc36055fe1532f5a8ef60" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=bzip2&rev=release-bzip2#13755b73dbcda1b186641fcccce90d55f815d6bc" } [[package]] name = "capnproto" version = "1.0.1" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=capnproto&rev=releases#12581f30b45b570dd0bbc36055fe1532f5a8ef60" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=capnproto&rev=release-capnproto#eba2fe8b8208b5408fbda1bc0104a91e4375aee3" } [[package]] name = "casadi" @@ -174,48 +174,48 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.4" +version = "3.4.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, + { 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" }, ] [[package]] name = "click" -version = "8.3.1" +version = "8.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, ] [[package]] name = "codespell" -version = "2.4.1" +version = "2.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/e0/709453393c0ea77d007d907dd436b3ee262e28b30995ea1aa36c6ffbccaf/codespell-2.4.1.tar.gz", hash = "sha256:299fcdcb09d23e81e35a671bbe746d5ad7e8385972e65dbb833a2eaac33c01e5", size = 344740, upload-time = "2025-01-28T18:52:39.411Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/01/b394922252051e97aab231d416c86da3d8a6d781eeadcdca1082867de64e/codespell-2.4.1-py3-none-any.whl", hash = "sha256:3dadafa67df7e4a3dbf51e0d7315061b80d265f9552ebd699b3dd6834b47e425", size = 344501, upload-time = "2025-01-28T18:52:37.057Z" }, + { 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" }, ] [[package]] @@ -251,26 +251,26 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.4" +version = "7.13.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, - { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, - { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, - { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, - { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, - { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, - { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, - { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, - { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] [[package]] @@ -291,41 +291,41 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, ] [[package]] @@ -360,13 +360,12 @@ wheels = [ ] [[package]] -name = "dearpygui" -version = "2.2" +name = "deepmerge" +version = "2.0" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/c8/b4afdac89c7bf458513366af3143f7383d7b09721637989c95788d93e24c/dearpygui-2.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:34ceae1ca1b65444e49012d6851312e44f08713da1b8cc0150cf41f1c207af9c", size = 1931443, upload-time = "2026-02-17T14:21:54.394Z" }, - { url = "https://files.pythonhosted.org/packages/43/93/a2d083b2e0edb095be815662cc41e40cf9ea7b65d6323e47bb30df7eb284/dearpygui-2.2-cp312-cp312-manylinux1_x86_64.whl", hash = "sha256:e1fae9ae59fec0e41773df64c80311a6ba67696219dde5506a2a4c013e8bcdfa", size = 2592645, upload-time = "2026-02-17T14:22:02.869Z" }, - { url = "https://files.pythonhosted.org/packages/80/ba/eae13acaad479f522db853e8b1ccd695a7bc8da2b9685c1d70a3b318df89/dearpygui-2.2-cp312-cp312-win_amd64.whl", hash = "sha256:7d399543b5a26ab6426ef3bbd776e55520b491b3e169647bde5e6b2de3701b35", size = 1830531, upload-time = "2026-02-17T14:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, ] [[package]] @@ -381,7 +380,7 @@ wheels = [ [[package]] name = "eigen" version = "3.4.0" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=eigen&rev=releases#12581f30b45b570dd0bbc36055fe1532f5a8ef60" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=eigen&rev=release-eigen#9157467a9e343d876e85f6187eae8c974fe3d83f" } [[package]] name = "execnet" @@ -395,23 +394,23 @@ wheels = [ [[package]] name = "ffmpeg" version = "7.1.0" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=ffmpeg&rev=releases#12581f30b45b570dd0bbc36055fe1532f5a8ef60" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=ffmpeg&rev=release-ffmpeg#4be3ad687902199df76b78cc8cf07f61e69ec266" } [[package]] name = "fonttools" -version = "4.61.1" +version = "4.62.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/16/7decaa24a1bd3a70c607b2e29f0adc6159f36a7e40eaba59846414765fd4/fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e", size = 2851593, upload-time = "2025-12-12T17:30:04.225Z" }, - { url = "https://files.pythonhosted.org/packages/94/98/3c4cb97c64713a8cf499b3245c3bf9a2b8fd16a3e375feff2aed78f96259/fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2", size = 2400231, upload-time = "2025-12-12T17:30:06.47Z" }, - { url = "https://files.pythonhosted.org/packages/b7/37/82dbef0f6342eb01f54bca073ac1498433d6ce71e50c3c3282b655733b31/fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796", size = 4954103, upload-time = "2025-12-12T17:30:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/6c/44/f3aeac0fa98e7ad527f479e161aca6c3a1e47bb6996b053d45226fe37bf2/fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d", size = 5004295, upload-time = "2025-12-12T17:30:10.56Z" }, - { url = "https://files.pythonhosted.org/packages/14/e8/7424ced75473983b964d09f6747fa09f054a6d656f60e9ac9324cf40c743/fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8", size = 4944109, upload-time = "2025-12-12T17:30:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8b/6391b257fa3d0b553d73e778f953a2f0154292a7a7a085e2374b111e5410/fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0", size = 5093598, upload-time = "2025-12-12T17:30:15.79Z" }, - { url = "https://files.pythonhosted.org/packages/d9/71/fd2ea96cdc512d92da5678a1c98c267ddd4d8c5130b76d0f7a80f9a9fde8/fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261", size = 2269060, upload-time = "2025-12-12T17:30:18.058Z" }, - { url = "https://files.pythonhosted.org/packages/80/3b/a3e81b71aed5a688e89dfe0e2694b26b78c7d7f39a5ffd8a7d75f54a12a8/fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9", size = 2319078, upload-time = "2025-12-12T17:30:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, + { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, + { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, + { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, ] [[package]] @@ -442,24 +441,12 @@ wheels = [ [[package]] name = "gcc-arm-none-eabi" version = "13.2.1" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=gcc-arm-none-eabi&rev=releases#12581f30b45b570dd0bbc36055fe1532f5a8ef60" } - -[[package]] -name = "ghp-import" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, -] +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=gcc-arm-none-eabi&rev=release-gcc-arm-none-eabi#0e1ae2548977f6cd78c51d4d0c16ebd1863241b8" } [[package]] name = "git-lfs" version = "3.6.1" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=git-lfs&rev=releases#12581f30b45b570dd0bbc36055fe1532f5a8ef60" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=git-lfs&rev=release-git-lfs#ab3064b6e7df110e32aa7748689cb43b26f07b54" } [[package]] name = "google-crc32c" @@ -505,6 +492,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, ] +[[package]] +name = "imgui" +version = "1.92.7" +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=imgui&rev=release-imgui#58d66087adacabb2bb4e56e74ebdea7d55c78e34" } + [[package]] name = "iniconfig" version = "2.3.0" @@ -555,29 +547,40 @@ wheels = [ [[package]] name = "kiwisolver" -version = "1.4.9" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, - { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, - { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, - { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, - { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, - { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, - { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, - { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, - { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, ] [[package]] name = "libjpeg" version = "3.1.0" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libjpeg&rev=releases#12581f30b45b570dd0bbc36055fe1532f5a8ef60" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libjpeg&rev=release-libjpeg#71f7a3f2aaccdc0612d93fac858b78f35bc2a565" } + +[[package]] +name = "libusb" +version = "1.0.29" +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libusb&rev=release-libusb#222120c19c857d6d0a681aff2e335c829ffcf89c" } [[package]] name = "libusb1" @@ -593,7 +596,7 @@ wheels = [ [[package]] name = "libyuv" version = "1922.0" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libyuv&rev=releases#12581f30b45b570dd0bbc36055fe1532f5a8ef60" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libyuv&rev=release-libyuv#febc42742ebf25429575caf784adecc6e516b892" } [[package]] name = "markdown" @@ -649,15 +652,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, ] -[[package]] -name = "mergedeep" -version = "1.3.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, -] - [[package]] name = "metadrive-simulator" version = "0.4.2.3" @@ -668,44 +662,6 @@ dependencies = [ { name = "panda3d-gltf" }, ] -[[package]] -name = "mkdocs" -version = "1.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "ghp-import" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mergedeep" }, - { name = "mkdocs-get-deps" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "pyyaml" }, - { name = "pyyaml-env-tag" }, - { name = "watchdog" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, -] - -[[package]] -name = "mkdocs-get-deps" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mergedeep" }, - { name = "platformdirs" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, -] - [[package]] name = "mpmath" version = "1.3.0" @@ -745,25 +701,25 @@ wheels = [ [[package]] name = "ncurses" version = "6.5" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=ncurses&rev=releases#12581f30b45b570dd0bbc36055fe1532f5a8ef60" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=ncurses&rev=release-ncurses#e78a693655261b101325aaa5b3cd9f1eb35f496b" } [[package]] name = "numpy" -version = "2.4.2" +version = "2.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, - { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, - { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, - { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, - { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, - { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, - { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, - { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, ] [[package]] @@ -800,22 +756,23 @@ dependencies = [ { name = "cython" }, { name = "eigen" }, { name = "ffmpeg" }, + { name = "gcc-arm-none-eabi" }, { name = "git-lfs" }, { name = "inputs" }, { name = "jeepney" }, { name = "json-rpc" }, { name = "libjpeg" }, + { name = "libusb" }, { name = "libusb1" }, { name = "libyuv" }, { name = "ncurses" }, { name = "numpy" }, - { name = "openssl3" }, + { name = "pillow" }, { name = "psutil" }, { name = "pycapnp" }, { name = "pycryptodome" }, { name = "pyjwt" }, { name = "pyserial" }, - { name = "python3-dev" }, { name = "pyzmq" }, { name = "qrcode" }, { name = "raylib" }, @@ -837,13 +794,12 @@ dependencies = [ [package.optional-dependencies] dev = [ - { name = "gcc-arm-none-eabi" }, { name = "matplotlib" }, { name = "opencv-python-headless" }, ] docs = [ { name = "jinja2" }, - { name = "mkdocs" }, + { name = "zensical" }, ] testing = [ { name = "codespell" }, @@ -860,7 +816,7 @@ testing = [ { name = "ty" }, ] tools = [ - { name = "dearpygui", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "imgui" }, { name = "metadrive-simulator", marker = "platform_machine != 'aarch64'" }, ] @@ -869,34 +825,34 @@ requires-dist = [ { name = "aiohttp" }, { name = "aiortc" }, { name = "av" }, - { name = "bzip2", git = "https://github.com/commaai/dependencies.git?subdirectory=bzip2&rev=releases" }, - { name = "capnproto", git = "https://github.com/commaai/dependencies.git?subdirectory=capnproto&rev=releases" }, + { name = "bzip2", git = "https://github.com/commaai/dependencies.git?subdirectory=bzip2&rev=release-bzip2" }, + { name = "capnproto", git = "https://github.com/commaai/dependencies.git?subdirectory=capnproto&rev=release-capnproto" }, { name = "casadi", specifier = ">=3.6.6" }, { name = "cffi" }, { name = "codespell", marker = "extra == 'testing'" }, { name = "coverage", marker = "extra == 'testing'" }, { name = "crcmod-plus" }, { name = "cython" }, - { name = "dearpygui", marker = "(platform_machine != 'aarch64' and extra == 'tools') or (sys_platform != 'linux' and extra == 'tools')", specifier = ">=2.1.0" }, - { name = "eigen", git = "https://github.com/commaai/dependencies.git?subdirectory=eigen&rev=releases" }, - { name = "ffmpeg", git = "https://github.com/commaai/dependencies.git?subdirectory=ffmpeg&rev=releases" }, - { name = "gcc-arm-none-eabi", marker = "extra == 'dev'", git = "https://github.com/commaai/dependencies.git?subdirectory=gcc-arm-none-eabi&rev=releases" }, - { name = "git-lfs", git = "https://github.com/commaai/dependencies.git?subdirectory=git-lfs&rev=releases" }, + { name = "eigen", git = "https://github.com/commaai/dependencies.git?subdirectory=eigen&rev=release-eigen" }, + { name = "ffmpeg", git = "https://github.com/commaai/dependencies.git?subdirectory=ffmpeg&rev=release-ffmpeg" }, + { name = "gcc-arm-none-eabi", git = "https://github.com/commaai/dependencies.git?subdirectory=gcc-arm-none-eabi&rev=release-gcc-arm-none-eabi" }, + { name = "git-lfs", git = "https://github.com/commaai/dependencies.git?subdirectory=git-lfs&rev=release-git-lfs" }, { name = "hypothesis", marker = "extra == 'testing'", specifier = "==6.47.*" }, + { name = "imgui", marker = "extra == 'tools'", git = "https://github.com/commaai/dependencies.git?subdirectory=imgui&rev=release-imgui" }, { name = "inputs" }, { name = "jeepney" }, { name = "jinja2", marker = "extra == 'docs'" }, { name = "json-rpc" }, - { name = "libjpeg", git = "https://github.com/commaai/dependencies.git?subdirectory=libjpeg&rev=releases" }, + { name = "libjpeg", git = "https://github.com/commaai/dependencies.git?subdirectory=libjpeg&rev=release-libjpeg" }, + { name = "libusb", git = "https://github.com/commaai/dependencies.git?subdirectory=libusb&rev=release-libusb" }, { name = "libusb1" }, - { name = "libyuv", git = "https://github.com/commaai/dependencies.git?subdirectory=libyuv&rev=releases" }, + { name = "libyuv", git = "https://github.com/commaai/dependencies.git?subdirectory=libyuv&rev=release-libyuv" }, { name = "matplotlib", marker = "extra == 'dev'" }, { name = "metadrive-simulator", marker = "platform_machine != 'aarch64' and extra == 'tools'", git = "https://github.com/commaai/metadrive.git?rev=minimal" }, - { name = "mkdocs", marker = "extra == 'docs'" }, - { name = "ncurses", git = "https://github.com/commaai/dependencies.git?subdirectory=ncurses&rev=releases" }, + { name = "ncurses", git = "https://github.com/commaai/dependencies.git?subdirectory=ncurses&rev=release-ncurses" }, { name = "numpy", specifier = ">=2.0" }, { name = "opencv-python-headless", marker = "extra == 'dev'" }, - { name = "openssl3", git = "https://github.com/commaai/dependencies.git?subdirectory=openssl3&rev=releases" }, + { name = "pillow" }, { name = "pre-commit-hooks", marker = "extra == 'testing'" }, { name = "psutil" }, { name = "pycapnp" }, @@ -909,7 +865,6 @@ requires-dist = [ { 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 = "python3-dev", git = "https://github.com/commaai/dependencies.git?subdirectory=python3-dev&rev=releases" }, { name = "pyzmq" }, { name = "qrcode" }, { name = "raylib", specifier = ">5.5.0.3" }, @@ -926,24 +881,20 @@ requires-dist = [ { name = "ty", marker = "extra == 'testing'" }, { name = "websocket-client" }, { name = "xattr" }, - { name = "zeromq", git = "https://github.com/commaai/dependencies.git?subdirectory=zeromq&rev=releases" }, + { name = "zensical", marker = "extra == 'docs'" }, + { name = "zeromq", git = "https://github.com/commaai/dependencies.git?subdirectory=zeromq&rev=release-zeromq" }, { name = "zstandard" }, - { name = "zstd", git = "https://github.com/commaai/dependencies.git?subdirectory=zstd&rev=releases" }, + { name = "zstd", git = "https://github.com/commaai/dependencies.git?subdirectory=zstd&rev=release-zstd" }, ] provides-extras = ["docs", "testing", "dev", "tools"] -[[package]] -name = "openssl3" -version = "3.4.1" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=openssl3&rev=releases#12581f30b45b570dd0bbc36055fe1532f5a8ef60" } - [[package]] name = "packaging" -version = "26.0" +version = "26.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, ] [[package]] @@ -985,41 +936,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/5d/3744c6550dddf933785a37cdd4a9921fe13284e6d115b5a2637fe390f158/panda3d_simplepbr-0.13.1-py3-none-any.whl", hash = "sha256:cda41cb57cff035b851646956cfbdcc408bee42511dabd4f2d7bd4fbf48c57a9", size = 2457097, upload-time = "2025-03-30T16:57:39.729Z" }, ] -[[package]] -name = "pathspec" -version = "1.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, -] - [[package]] name = "pillow" -version = "12.1.1" +version = "12.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, - { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, - { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, - { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, - { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, - { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.9.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, ] [[package]] @@ -1147,20 +1080,20 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pyjwt" -version = "2.11.0" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [[package]] @@ -1185,17 +1118,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/ec/6e02b2561d056ea5b33046e3cad21238e6a9097b97d6ccc0fbe52b50c858/pylibsrtp-1.0.0-cp310-abi3-win_arm64.whl", hash = "sha256:2696bdb2180d53ac55d0eb7b58048a2aa30cd4836dd2ca683669889137a94d2a", size = 1159246, upload-time = "2025-10-13T16:12:30.285Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, +] + [[package]] name = "pyopenssl" -version = "25.3.0" +version = "26.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" }, ] [[package]] @@ -1218,7 +1164,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1227,9 +1173,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -1303,11 +1249,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] -[[package]] -name = "python3-dev" -version = "3.12.8" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=python3-dev&rev=releases#12581f30b45b570dd0bbc36055fe1532f5a8ef60" } - [[package]] name = "pyyaml" version = "6.0.3" @@ -1326,18 +1267,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, ] -[[package]] -name = "pyyaml-env-tag" -version = "1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, -] - [[package]] name = "pyzmq" version = "27.1.0" @@ -1391,7 +1320,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1399,9 +1328,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] [[package]] @@ -1415,27 +1344,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.4" +version = "0.15.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, - { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, - { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, - { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, - { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, - { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, - { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, - { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, - { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, - { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, - { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, + { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, + { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, + { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, + { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, + { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, + { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, + { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, + { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, + { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, + { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, ] [[package]] @@ -1449,15 +1378,15 @@ wheels = [ [[package]] name = "sentry-sdk" -version = "2.53.0" +version = "2.58.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/b3/fb8291170d0e844173164709fc0fa0c221ed75a5da740c8746f2a83b4eb1/sentry_sdk-2.58.0.tar.gz", hash = "sha256:c1144d947352d54e5b7daa63596d9f848adf684989c06c4f5a659f0c85a18f6f", size = 438764, upload-time = "2026-04-13T17:23:26.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/fa/eb/d875669993b762556ae8b2efd86219943b4c0864d22204d622a9aee3052b/sentry_sdk-2.58.0-py2.py3-none-any.whl", hash = "sha256:688d1c704ddecf382ea3326f21a67453d4caa95592d722b7c780a36a9d23109e", size = 460919, upload-time = "2026-04-13T17:23:24.675Z" }, ] [[package]] @@ -1480,11 +1409,11 @@ wheels = [ [[package]] name = "setuptools" -version = "82.0.0" +version = "82.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, + { 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" }, ] [[package]] @@ -1553,26 +1482,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.19" +version = "0.0.31" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/5e/da108b9eeb392e02ff0478a34e9651490b36af295881cb56575b83f0cc3a/ty-0.0.19.tar.gz", hash = "sha256:ee3d9ed4cb586e77f6efe3d0fe5a855673ca438a3d533a27598e1d3502a2948a", size = 5220026, upload-time = "2026-02-26T12:13:15.215Z" } +sdist = { url = "https://files.pythonhosted.org/packages/31/cc/5ea5d3a72216c8c2bf77d83066dd4f3553532d0aacc03d4a8397dd9845e1/ty-0.0.31.tar.gz", hash = "sha256:4a4094292d9671caf3b510c7edf36991acd9c962bb5d97205374ffed9f541c45", size = 5516619, upload-time = "2026-04-15T15:47:59.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/31/fd8c6067abb275bea11523d21ecf64e1d870b1ce80cac529cf6636df1471/ty-0.0.19-py3-none-linux_armv6l.whl", hash = "sha256:29bed05d34c8a7597567b8e327c53c1aed4a07dcfbe6c81e6d60c7444936ad77", size = 10268470, upload-time = "2026-02-26T12:13:42.881Z" }, - { url = "https://files.pythonhosted.org/packages/15/de/16a11bbf7d98c75849fc41f5d008b89bb5d080a4b10dc8ea851ee2bd371b/ty-0.0.19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79140870c688c97ec68e723c28935ddef9d91a76d48c68e665fe7c851e628b8a", size = 10098562, upload-time = "2026-02-26T12:13:31.618Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4f/086d6ff6686eadf903913c45b53ab96694b62bbfee1d8cf3e55a9b5aa4b2/ty-0.0.19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6e9c1f9cfa6a26f7881d14d75cf963af743f6c4189e6aa3e3b4056a65f22e730", size = 9604073, upload-time = "2026-02-26T12:13:24.645Z" }, - { url = "https://files.pythonhosted.org/packages/95/13/888a6b6c7ed4a880fee91bec997f775153ce86215ee4c56b868516314734/ty-0.0.19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbca43b050edf1db2e64ae7b79add233c2aea2855b8a876081bbd032edcd0610", size = 10106295, upload-time = "2026-02-26T12:13:40.584Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e8/05a372cae8da482de73b8246fb43236bf11e24ac28c879804568108759db/ty-0.0.19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8acaa88ab1955ca6b15a0ccc274011c4961377fe65c3948e5d2b212f2517b87c", size = 10098234, upload-time = "2026-02-26T12:13:33.725Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f1/5b0958e9e9576e7662192fe689bbb3dc88e631a4e073db3047793a547d58/ty-0.0.19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a901b6a6dd9d17d5b3b2e7bafc3057294e88da3f5de507347316687d7f191a1", size = 10607218, upload-time = "2026-02-26T12:13:17.576Z" }, - { url = "https://files.pythonhosted.org/packages/fb/ab/358c78b77844f58ff5aca368550ab16c719f1ab0ec892ceb1114d7500f4e/ty-0.0.19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8deafdaaaee65fd121c66064da74a922d8501be4a2d50049c71eab521a23eff7", size = 11160593, upload-time = "2026-02-26T12:13:36.008Z" }, - { url = "https://files.pythonhosted.org/packages/95/59/827fc346d66a59fe48e9689a5ceb67dbbd5b4de2e8d4625371af39a2e8b7/ty-0.0.19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e56071af280897441018f74f921b97d53aec0856f8af85f4f949df8eda07d", size = 10822392, upload-time = "2026-02-26T12:13:29.415Z" }, - { url = "https://files.pythonhosted.org/packages/81/f9/3bbfbbe35478de9bcd63848f4bc9bffda72278dd9732dbad3efc3978432e/ty-0.0.19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abdf5885130393ce74501dba792f48ce0a515756ec81c33a4b324bdf3509df6e", size = 10707139, upload-time = "2026-02-26T12:13:20.148Z" }, - { url = "https://files.pythonhosted.org/packages/12/9e/597023b183ec4ade83a36a0cea5c103f3bffa34f70813d46386c61447fb8/ty-0.0.19-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:877e89005c8f9d1dbff5ad14cbac9f35c528406fde38926f9b44f24830de8d6a", size = 10096933, upload-time = "2026-02-26T12:13:45.266Z" }, - { url = "https://files.pythonhosted.org/packages/1e/76/d0d2f6e674db2a17c8efa5e26682b9dfa8d34774705f35902a7b45ebd3bd/ty-0.0.19-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:39bd1da051c1e4d316efaf79dbed313255633f7c6ad6e24d29f4d9c6ffaf4de6", size = 10109547, upload-time = "2026-02-26T12:13:22.17Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b0/76026c06b852a3aa4fdb5bd329fdc2175aaf3c64a3fafece9cc4df167cee/ty-0.0.19-py3-none-musllinux_1_2_i686.whl", hash = "sha256:87df8415a6c9cb27b8f1382fcdc6052e59f5b9f50f78bc14663197eb5c8d3699", size = 10289110, upload-time = "2026-02-26T12:13:38.29Z" }, - { url = "https://files.pythonhosted.org/packages/14/6c/f3b3a189816b4f079b20fe5d0d7ee38e38a472f53cc6770bb6571147e3de/ty-0.0.19-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:89b6bb23c332ed5c38dd859eb5793f887abcc936f681a40d4ea68e35eac1af33", size = 10796479, upload-time = "2026-02-26T12:13:10.992Z" }, - { url = "https://files.pythonhosted.org/packages/3d/18/caee33d1ce9dd50bd94c26cde7cda4f6971e22e474e7d72a5c86d745ad58/ty-0.0.19-py3-none-win32.whl", hash = "sha256:19b33df3aa7af7b1a9eaa4e1175c3b4dec0f5f2e140243e3492c8355c37418f3", size = 9677215, upload-time = "2026-02-26T12:13:08.519Z" }, - { url = "https://files.pythonhosted.org/packages/81/41/18fc0771d0b1da7d7cc2fc9af278d3122b754fe8b521a748734f4e16ecfd/ty-0.0.19-py3-none-win_amd64.whl", hash = "sha256:b9052c61464cdd76bc8e6796f2588c08700f25d0dcbc225bb165e390ea9d96a4", size = 10651252, upload-time = "2026-02-26T12:13:13.035Z" }, - { url = "https://files.pythonhosted.org/packages/8b/8c/26f7ce8863eb54510082747b3dfb1046ba24f16fc11de18c0e5feb36ff18/ty-0.0.19-py3-none-win_arm64.whl", hash = "sha256:9329804b66dcbae8e7af916ef4963221ed53b8ec7d09b0793591c5ae8a0f3270", size = 10093195, upload-time = "2026-02-26T12:13:26.816Z" }, + { url = "https://files.pythonhosted.org/packages/b0/10/ea805cbbd75d5d50792551a2b383de8521eeab0c44f38c73e12819ced65e/ty-0.0.31-py3-none-linux_armv6l.whl", hash = "sha256:761651dc17ad7bc0abfc1b04b3f0e84df263ed435d34f29760b3da739ab02d35", size = 10834749, upload-time = "2026-04-15T15:48:14.877Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4c/fabf951850401d24d36b21bced088a366c6827e1c37dab4523afff84c4b2/ty-0.0.31-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c529922395a07231c27488f0290651e05d27d149f7e0aa807678f1f7e9c58a5e", size = 10626012, upload-time = "2026-04-15T15:48:22.554Z" }, + { url = "https://files.pythonhosted.org/packages/04/b0/4a5aff88d2544f19514a59c8f693d63144aa7307fe2ee5df608333ab5460/ty-0.0.31-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5f345df2b87d747859e72c2cbc9be607ea1bbc8bc93dd32fa3d03ea091cb4fee", size = 10075790, upload-time = "2026-04-15T15:47:46.959Z" }, + { url = "https://files.pythonhosted.org/packages/d5/73/9d4dcad12cd4e85274014f2c0510ef93f590b2a1e5148de3a9f276098dad/ty-0.0.31-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4b207eddcfbafd376132689d3435b14efcb531289cb59cd961c6a611133bd54", size = 10590286, upload-time = "2026-04-15T15:48:06.222Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/fe40adde18692359ded174ae7ddbfac056e876eb0f43b65be74fde7f6072/ty-0.0.31-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:663778b220f357067488ce68bfc52335ccbd161549776f70dcbde6bbde82f77a", size = 10623824, upload-time = "2026-04-15T15:48:12.965Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e8/0ffa2e09b548e6daa9ebc368d68b767dc2405ca4cbeadb7ede0e2cb21059/ty-0.0.31-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3506cfe87dfade0fb2960dd4fffd4fd8089003587b3445c0a1a295c9d83764fb", size = 11156864, upload-time = "2026-04-15T15:48:08.473Z" }, + { url = "https://files.pythonhosted.org/packages/08/e9/fd44c2075115d569593ee9473d7e2a38b750fd7e783421c95eb528c15df5/ty-0.0.31-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b3f3d8492f08e81916026354c1d1599e9ddfa1241804141a74d5662fc710085", size = 11696401, upload-time = "2026-04-15T15:48:17.355Z" }, + { url = "https://files.pythonhosted.org/packages/4e/50/35aad8eadf964d23e2a4faa5b38a206aa85c78833c8ce335dddd2c34ba63/ty-0.0.31-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a97de32ee6a619393a4c495e056a1c547de7877510f3152e61345c71d774d2d0", size = 11374903, upload-time = "2026-04-15T15:47:55.893Z" }, + { url = "https://files.pythonhosted.org/packages/c8/37/01eccd25d23f5aaa7f7ff1a87b5b215469f6b202cf689a1812b71c1e7f6b/ty-0.0.31-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c906354ce441e342646582bc9b8f48a676f79f3d061e25de15ff870e015ca14e", size = 11206624, upload-time = "2026-04-15T15:47:51.778Z" }, + { url = "https://files.pythonhosted.org/packages/f4/70/baad2914cb097453f127a221f8addb2b41926098059cd773c75e6a662fc4/ty-0.0.31-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:275bb7c82afcbf89fe2dbef1b2692f2bc98451f1ee2c8eb809ddd91317822388", size = 10575089, upload-time = "2026-04-15T15:47:49.448Z" }, + { url = "https://files.pythonhosted.org/packages/83/12/bae3a7bba2e785eb72ce00f9da70eedcb8c5e8299efecbd16e6e436abd82/ty-0.0.31-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:405da247027c6efd1e264886b6ac4a86ab3a4f09200b02e33630efe85f119e53", size = 10642315, upload-time = "2026-04-15T15:48:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/93/9e/cad04d5d839bc60355cea98c7e09d724ea65f47184def0fae8b90dc54591/ty-0.0.31-py3-none-musllinux_1_2_i686.whl", hash = "sha256:54d9835608eed196853d6643f645c50ce83bcc7fe546cdb3e210c1bcf7c58c09", size = 10834473, upload-time = "2026-04-15T15:48:02.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ba/84112d280182d37690d3d2b4018b2667e42bc281585e607015635310016a/ty-0.0.31-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ee11be9b07e8c0c6b455ff075a0abe4f194de9476f57624db98eec9df618355", size = 11315785, upload-time = "2026-04-15T15:48:10.754Z" }, + { url = "https://files.pythonhosted.org/packages/50/9f/ac42dc223d7e0950e97a1854567a8b3e7fe09ad7375adbf91bfb43290482/ty-0.0.31-py3-none-win32.whl", hash = "sha256:7286587aacf3eef0956062d6492b893b02f82b0f22c5e230008e13ff0d216a8b", size = 10187657, upload-time = "2026-04-15T15:48:04.264Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/57ba7ea7ecb2f4751644ba91756e2be70e33ef5952c0c41a256a0e4c2437/ty-0.0.31-py3-none-win_amd64.whl", hash = "sha256:81134e25d2a2562ab372f24de8f9bd05034d27d30377a5d7540f259791c6234c", size = 11205258, upload-time = "2026-04-15T15:47:53.759Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/bca669095ccf0a400af941fdf741578d4c2d6719f1b7f10e6dbec10aa862/ty-0.0.31-py3-none-win_arm64.whl", hash = "sha256:e9cb15fad26545c6a608f40f227af3a5513cb376998ca6feddd47ca7d93ffafa", size = 10590392, upload-time = "2026-04-15T15:47:57.968Z" }, ] [[package]] @@ -1593,27 +1522,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] -[[package]] -name = "watchdog" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, -] - [[package]] name = "websocket-client" version = "1.9.0" @@ -1643,38 +1551,68 @@ wheels = [ [[package]] name = "yarl" -version = "1.22.0" +version = "1.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, - { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, - { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, - { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, - { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, - { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, - { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, - { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, - { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, - { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, - { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +] + +[[package]] +name = "zensical" +version = "0.0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "markdown" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/c2/dea4b86dc1ca2a7b55414017f12cfb12b5cfdf3a1ed7c77a04c271eb523b/zensical-0.0.33.tar.gz", hash = "sha256:05209cb4f80185c533e0d37c25d084ddc2050e3d5a4dd1b1812961c2ee0c3380", size = 3892278, upload-time = "2026-04-14T11:08:19.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/5f/45d5200405420a9d8ac91cf9e7826622ea12f3198e8e6ac4ffb481eb53bf/zensical-0.0.33-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f658e3c241cfbb560bd8811116a9486cff7e04d7d5aed73569dd533c74187450", size = 12416748, upload-time = "2026-04-14T11:07:43.246Z" }, + { url = "https://files.pythonhosted.org/packages/33/1e/aadaf31d6e4d20419ecedaf0b1c804e359ec23dcdb44c8d2bf6d8407080c/zensical-0.0.33-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f9813ac3256c28e2e2f1ba5c9fab1b4bca62bbe0e0f8e85ac22d33b068b1b08a", size = 12293372, upload-time = "2026-04-14T11:07:46.569Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/838be8451ea8b2aecec39fbec3971060fc705e17f5741249740d9b6a6824/zensical-0.0.33-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3bad7ac71028769c5d1f3f84f448dbb7352db28d77095d1b40a8d1b0aa34ec30", size = 12659832, upload-time = "2026-04-14T11:07:50.754Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5c/dd957d7c83efc13a70a6058d4190a3afcf29942aefb391120bca5466347d/zensical-0.0.33-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:06bb039daf044547c9400a52f9493b3cd486ba9baef3324fdcffd2e26e61105f", size = 12603847, upload-time = "2026-04-14T11:07:53.698Z" }, + { url = "https://files.pythonhosted.org/packages/b7/99/dd6ccc392ece1f34fb20ea339a01717badbbeb2fba1d4f3019a5028d0bcc/zensical-0.0.33-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:260238062b3139ece0edab93f4dbe7a12923453091f5aa580dfd73e799388076", size = 12956236, upload-time = "2026-04-14T11:07:56.728Z" }, + { url = "https://files.pythonhosted.org/packages/f4/76/e0a1b884eadf6afa7e2d56c90c268eec36836ac27e96ef250c0129e55417/zensical-0.0.33-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7dff0f4afda7b8586bc4ab2a5684bce5b282232dd4e0cad3be4c73fedd264425", size = 12701944, upload-time = "2026-04-14T11:07:59.928Z" }, + { url = "https://files.pythonhosted.org/packages/38/38/e1ff13461e406864fa2b23fc828822659a7dbac5c79398f724d17f088540/zensical-0.0.33-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:207b4d81b208d75b97dc7bd318804550b886a3e852ef67429ef0e6b9442839d1", size = 12835444, upload-time = "2026-04-14T11:08:02.998Z" }, + { url = "https://files.pythonhosted.org/packages/41/04/7d24d52d6903fc5c511633afe8b5716fef19da09685327665cc127f61648/zensical-0.0.33-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:06d2f57f7bc8cc8fd904386020ea1365eebc411e8698a871e9525c885abca574", size = 12878419, upload-time = "2026-04-14T11:08:06.054Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ec/87fc9e360c694ab006363c7834639eccafd0d26a487cd63dd609bd68f36a/zensical-0.0.33-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:c2851b82d83aa0b2ae4f8e99731cfeedeecebfa04e6b3fc4d375deca629fa240", size = 13022474, upload-time = "2026-04-14T11:08:09.007Z" }, + { url = "https://files.pythonhosted.org/packages/10/b3/0bf174ab6ceedb31d9af462073b5339c894b2084a27d42cb9f0906050d76/zensical-0.0.33-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:90daaf512b0429d7b9147ad5e6085b455d24803eff18b508aed738ca65444683", size = 12975233, upload-time = "2026-04-14T11:08:12.535Z" }, + { url = "https://files.pythonhosted.org/packages/a9/27/7cc3c2d284698647f60f3b823e0101e619c87edf158d47ee11bf4bfb6228/zensical-0.0.33-cp310-abi3-win32.whl", hash = "sha256:2701820597fe19361a12371129927c58c19633dcaa5f6986d610dce58cecd8c4", size = 12012664, upload-time = "2026-04-14T11:08:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/25/0b/6be5c2fdaf9f1600577e7ba5e235d86b72a26f6af389efb146f978f76ac3/zensical-0.0.33-cp310-abi3-win_amd64.whl", hash = "sha256:a5a0911b4247708a55951b74c459f4d5faec5daaf287d23a2e1f0d96be1e647f", size = 12206255, upload-time = "2026-04-14T11:08:17.375Z" }, ] [[package]] name = "zeromq" version = "4.3.5" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=zeromq&rev=releases#12581f30b45b570dd0bbc36055fe1532f5a8ef60" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=zeromq&rev=release-zeromq#173fe8e9a0b8cf666bac5363c3376e866a386568" } [[package]] name = "zstandard" @@ -1704,4 +1642,4 @@ wheels = [ [[package]] name = "zstd" version = "1.5.6" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=zstd&rev=releases#12581f30b45b570dd0bbc36055fe1532f5a8ef60" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=zstd&rev=release-zstd#c4b1fdec74010075965d68e2c743055c6ef18d48" } diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 0000000000..7e5ca2c5db --- /dev/null +++ b/zensical.toml @@ -0,0 +1,81 @@ +[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"]