diff --git a/.gitattributes b/.gitattributes index afdc8192f3..300ca01979 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,7 +3,7 @@ # to move existing files into LFS: # git add --renormalize . *.onnx filter=lfs diff=lfs merge=lfs -text -openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl filter=lfs diff=lfs merge=lfs -text +openpilot/selfdrive/modeld/models/big_*_tinygrad.pkl filter=lfs diff=lfs merge=lfs -text openpilot/sunnypilot/modeld_v2/models/*.pkl filter=lfs diff=lfs merge=lfs -text *.svg filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index 0a0284c421..6caf71a999 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,7 @@ st[0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z] *.stats *.pkl *.pkl* -!openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl +!openpilot/selfdrive/modeld/models/big_*_tinygrad.pkl !openpilot/sunnypilot/modeld_v2/models/*.pkl config.json compile_commands.json diff --git a/Jenkinsfile b/Jenkinsfile index 67039d0927..5facce903f 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -161,6 +161,14 @@ def step(String name, String cmd, Map args = [:]) { return [name, cmd, args] } +def chestnutStage(String name = "chestnut") { + deviceStage(name, "mici-chestnut-ci", ["UNSAFE=1", "CHESTNUT=1"], [ + step("build", "./openpilot/selfdrive/test/chestnut.sh"), + step("model replay", "openpilot/selfdrive/test/process_replay/model_replay.py --chestnut"), + step("onroad tests", "./openpilot/selfdrive/test/test_onroad.py TestChestnutOnroad", [timeout: 120]), + ]) +} + node { env.CI = "1" env.PYTHONWARNINGS = "error" @@ -171,6 +179,36 @@ node { env.GIT_BRANCH = checkout(scm).GIT_BRANCH env.GIT_COMMIT = checkout(scm).GIT_COMMIT + if (env.JOB_BASE_NAME == 'chestnut-stresstest') { + sh 'git fetch --no-tags origin nightly-chestnut' + env.GIT_COMMIT = sh(script: 'git show FETCH_HEAD:git_src_commit', returnStdout: true).trim() + if (!(env.GIT_COMMIT ==~ /[0-9a-f]{40}/)) { + error("invalid nightly-chestnut source commit") + } + def results = [] + try { + timeout(time: 12, unit: 'HOURS') { + for (int run = 1; run <= 100; run++) { + results << "run ${run}: INCOMPLETE" + try { + chestnutStage("chestnut ${run}/100") + results[run - 1] = "run ${run}: PASS" + } catch (hudson.AbortException e) { + results[run - 1] = "run ${run}: FAIL" + echo e.toString() + } + } + } + } finally { + writeFile file: 'chestnut_stress_report.txt', text: "commit: ${env.GIT_COMMIT}\ncompleted: ${results.count { !it.endsWith('INCOMPLETE') }}/100\n" + results.join('\n') + '\n' + archiveArtifacts artifacts: 'chestnut_stress_report.txt' + } + if (results.any { !it.endsWith('PASS') }) { + error("chestnut stress test failed") + } + return + } + def excludeBranches = ['__nightly', '__nightly-chestnut', 'devel', 'devel-staging', 'release-tizi', 'release-tizi-staging', 'release-mici', 'release-mici-staging', 'testing-closet*', 'hotfix-*'] def excludeRegex = excludeBranches.join('|').replaceAll('\\*', '.*') @@ -204,9 +242,18 @@ node { } if (env.BRANCH_NAME == '__nightly-chestnut') { - deviceStage("build nightly-chestnut", "mici-chestnut-ci", [], [ - step("build nightly-chestnut", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 PANDA_DEBUG_BUILD=1 RELEASE_BRANCH=nightly-chestnut $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"), - ]) + parallel ( + 'nightly-chestnut': { + deviceStage("build nightly-chestnut", "mici-chestnut-ci", ["CHESTNUT=1"], [ + step("build nightly-chestnut", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 RELEASE_BRANCH=nightly-chestnut $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"), + ]) + }, + 'nightly-chestnut-dev': { + deviceStage("build nightly-chestnut-dev", "mici-chestnut-ci", ["CHESTNUT=1"], [ + step("build nightly-chestnut-dev", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 PANDA_DEBUG_BUILD=1 RELEASE_BRANCH=nightly-chestnut-dev $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"), + ]) + }, + ) } if (!env.BRANCH_NAME.matches(excludeRegex)) { @@ -261,12 +308,7 @@ node { ]) }, 'chestnut': { - deviceStage("chestnut", "mici-chestnut-ci", ["UNSAFE=1", "CHESTNUT=1"], [ - step("build", "./openpilot/selfdrive/test/chestnut.sh"), - step("model replay", "openpilot/selfdrive/test/process_replay/model_replay.py --chestnut"), - step("onroad tests", "./openpilot/selfdrive/test/test_onroad.py TestChestnutOnroad", [timeout: 120]), - step("test power draw", "./openpilot/selfdrive/test/test_power_draw.py"), - ]) + chestnutStage() }, ) diff --git a/SConstruct b/SConstruct index 2437f99d95..dbeff51da9 100644 --- a/SConstruct +++ b/SConstruct @@ -20,7 +20,7 @@ SetOption('num_jobs', max(1, int(os.cpu_count()/(1 if "CI" in os.environ else 2) 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') -release = not os.path.exists(File('#.gitattributes').abspath) # file absent on release branch, see release_files.py +release = not os.path.exists(File('#.gitmodules').abspath) # file absent on release branch, see release_files.py AddOption('--minimal', action='store_false', dest='extras', @@ -316,8 +316,6 @@ def count_scons_nodes(nodes): if node in seen: continue seen.add(node) - if hasattr(node, 'has_builder') and node.has_builder(): - build_product_nodes.add(node) executor = node.get_executor() if executor is not None: stack += executor.get_all_prerequisites() + executor.get_all_children() @@ -326,7 +324,6 @@ def count_scons_nodes(nodes): progress_interval = 5 progress_count = 0 -build_product_nodes = set() progress_total = max(1, count_scons_nodes(env.arg2nodes(BUILD_TARGETS or [Dir('.')], env.fs.Entry))) def progress_function(node): @@ -342,13 +339,3 @@ def progress_function(node): Progress(progress_function, interval=progress_interval) AddPostAction(BUILD_TARGETS or [Dir('.')], prune_cache_dir) - -def check_build_product_size(target, source, env): - limit = 50 * 1024 * 1024 # GitHub max size - for t in target: - if str(t).endswith('.pkl'): # chunked during release packaging - continue - if hasattr(t, 'isfile') and t.isfile() and (size := os.path.getsize(t.abspath)) > limit: - raise SCons.Errors.UserError(f"{t} is {size / (1024 * 1024):.1f} MiB, exceeding the {limit / (1024 * 1024):.1f} MiB limit") -if not GetOption('extras'): - AddPostAction(list(build_product_nodes), Action(check_build_product_size, None)) diff --git a/docs/CARS.md b/docs/CARS.md index 8397e6e520..faa86f1d5d 100644 --- a/docs/CARS.md +++ b/docs/CARS.md @@ -4,10 +4,11 @@ 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. -# 345 Supported Cars +# 351 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|ADX 2025-26|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |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,5](#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
||| @@ -16,12 +17,12 @@ A supported vehicle is one that just works when you install a comma device. All |Acura|RDX 2019-21|All|openpilot available[1,5](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Acura|TLX 2021-22|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch 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[12](#footnotes)|A3 2014-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Audi[12](#footnotes)|A3 Sportback e-tron 2017-18|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Audi[12](#footnotes)|Q2 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Audi[12](#footnotes)|Q3 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Audi[12](#footnotes)|RS3 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Audi[12](#footnotes)|S3 2015-17|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Audi[13](#footnotes)|A3 2014-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|A3 Sportback e-tron 2017-18|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Q2 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Q3 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|RS3 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|S3 2015-17|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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
||| @@ -33,8 +34,8 @@ A supported vehicle is one that just works when you install a comma device. All |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[12](#footnotes)|Ateca 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|CUPRA|Born 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW MEB connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|CUPRA[13](#footnotes)|Ateca 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|CUPRA|Born 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[17](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW MEB connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Dodge|Durango 2020-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Bronco Sport 2021-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Ford|Escape 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -86,7 +87,7 @@ A supported vehicle is one that just works when you install a comma device. All |Honda|Civic 2022-26|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Civic Hatchback 2017-18|Honda Sensing|openpilot available[1,5](#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,5](#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,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Civic Hatchback 2022-26|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Civic Hatchback Hybrid 2025-26|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Civic Hatchback Hybrid (Europe only) 2023|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Civic Hybrid 2025-26|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -111,7 +112,9 @@ A supported vehicle is one that just works when you install a comma device. All |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
||| |Honda|Pilot 2016-22|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Honda|Pilot 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|Ridgeline 2017-25|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Pilot 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
||| +|Honda|Prelude 2026|All|openpilot available[1,5](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| +|Honda|Ridgeline 2017-26|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Azera 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 K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Azera Hybrid 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 C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Hyundai|Azera Hybrid 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 K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -163,6 +166,7 @@ A supported vehicle is one that just works when you install a comma device. All |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
||| +|Kia|Ceed Plug-in Hybrid 2021|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.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
||| |Kia|EV6 (Southeast Asia 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 P connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|EV6 (with HDA II) 2022-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 P connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Kia|EV6 (without HDA II) 2022-24|Highway Driving Assist|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
||| @@ -197,6 +201,7 @@ A supported vehicle is one that just works when you install a comma device. All |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|Sportage Plug-in 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 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
||| @@ -225,51 +230,52 @@ 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[12](#footnotes)|eTGE 2020-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|MAN[12](#footnotes)|TGE 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|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
||| -|Nissan[6](#footnotes)|Altima 2019-24|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[6](#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[6](#footnotes)|Leaf IC 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[6](#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[6](#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
||| +|MAN[13](#footnotes)|eTGE 2020-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|TGE 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[6](#footnotes)|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[6](#footnotes)|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[7](#footnotes)|Altima 2019-24|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[7](#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[7](#footnotes)|Leaf IC 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[7](#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[7](#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|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[12](#footnotes)|Ateca 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|SEAT[12](#footnotes)|Leon 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Subaru|Ascent 2019-21|All[7](#footnotes)|Stock|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[7](#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|Crosstrek 2020-23|EyeSight Driver Assistance[7](#footnotes)|Stock|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[7](#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[7](#footnotes)|Stock|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|Impreza 2017-19|EyeSight Driver Assistance[7](#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|Impreza 2020-22|EyeSight Driver Assistance[7](#footnotes)|Stock|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|Legacy 2015-18|EyeSight Driver Assistance[7](#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|Legacy 2020-22|All[7](#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|Outback 2015-17|EyeSight Driver Assistance[7](#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[7](#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[7](#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[7](#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|XV 2020-21|EyeSight Driver Assistance[7](#footnotes)|Stock|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[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[17](#footnotes)||| -|Škoda|Kamiq 2021-23[13,15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[17](#footnotes)||| -|Škoda[12](#footnotes)|Karoq 2019-23[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Škoda[12](#footnotes)|Kodiaq 2017-23[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Škoda[12](#footnotes)|Octavia 2015-19[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Škoda[12](#footnotes)|Octavia RS 2016[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Škoda[12](#footnotes)|Octavia Scout 2017-19[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Škoda|Scala 2020-23[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[17](#footnotes)||| -|Škoda[12](#footnotes)|Superb 2015-22[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Tesla[10](#footnotes)|Model 3 (with HW3) 2019-23[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Tesla[10](#footnotes)|Model 3 (with HW4) 2024-25[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Tesla[10](#footnotes)|Model Y (with HW3) 2020-24[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Tesla[10](#footnotes)|Model Y (with HW4) 2023-25[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Rivian|R1S 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 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 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 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 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 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 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 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[13](#footnotes)|Ateca 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Leon 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[8](#footnotes)|Stock|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[8](#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|Crosstrek 2020-23|EyeSight Driver Assistance[8](#footnotes)|Stock|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[8](#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[8](#footnotes)|Stock|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|Impreza 2017-19|EyeSight Driver Assistance[8](#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|Impreza 2020-22|EyeSight Driver Assistance[8](#footnotes)|Stock|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|Legacy 2015-18|EyeSight Driver Assistance[8](#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|Legacy 2020-22|All[8](#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|Outback 2015-17|EyeSight Driver Assistance[8](#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[8](#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[8](#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[8](#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|XV 2020-21|EyeSight Driver Assistance[8](#footnotes)|Stock|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[16](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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
[18](#footnotes)||| +|Škoda|Kamiq 2021-23[14,16](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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
[18](#footnotes)||| +|Škoda[13](#footnotes)|Karoq 2019-23[16](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Kodiaq 2017-23[16](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Octavia 2015-19[16](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Octavia RS 2016[16](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Octavia Scout 2017-19[16](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[16](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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
[18](#footnotes)||| +|Škoda[13](#footnotes)|Superb 2015-22[16](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[11](#footnotes)|Model 3 (with HW2.5) 2019[10](#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[11](#footnotes)|Model 3 (with HW3) 2019-23[10](#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[11](#footnotes)|Model 3 (with HW4) 2024-25[10](#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[11](#footnotes)|Model Y (with HW3) 2020-24[10](#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[11](#footnotes)|Model Y (with HW4) 2023-25[10](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| |Toyota|Alphard 2019-20|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Alphard Hybrid 2021|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| |Toyota|Avalon 2016|Toyota Safety Sense P|Stock|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here
||| @@ -282,8 +288,8 @@ 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[11](#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[11](#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 2018-20|All|Stock|0 mph[12](#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[12](#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 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
||| @@ -315,44 +321,44 @@ A supported vehicle is one that just works when you install a comma device. All |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[12](#footnotes)|Arteon 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Arteon eHybrid 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Arteon R 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Arteon Shooting Brake 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Atlas 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Atlas Cross Sport 2020-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|California 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Caravelle 2020|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|CC 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Crafter 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|e-Crafter 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|e-Golf 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Golf 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-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[12](#footnotes)|Golf Alltrack 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-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[12](#footnotes)|Golf GTD 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Golf GTE 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Golf GTI 2015-21|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-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[12](#footnotes)|Golf R 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Golf SportsVan 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Grand California 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|ID.4 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW MEB connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|ID.4 2024-25|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW MEB connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Jetta 2019-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Jetta GLI 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Passat 2015-22[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Passat Alltrack 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Passat GTE 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen|Polo 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[17](#footnotes)||| -|Volkswagen|Polo GTI 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[17](#footnotes)||| -|Volkswagen|T-Cross 2021|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
[17](#footnotes)||| -|Volkswagen[12](#footnotes)|T-Roc 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Taos 2022-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Teramont 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Teramont Cross Sport 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Teramont X 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Tiguan 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Tiguan eHybrid 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| -|Volkswagen[12](#footnotes)|Touran 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[13](#footnotes)|Arteon 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Arteon eHybrid 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Arteon R 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Arteon Shooting Brake 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Atlas 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Atlas Cross Sport 2020-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|California 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Caravelle 2020|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|CC 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Crafter 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|e-Crafter 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|e-Golf 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Golf 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Golf Alltrack 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Golf GTD 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Golf GTE 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Golf GTI 2015-21|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Golf R 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Golf SportsVan 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Grand California 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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|ID.4 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[17](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW MEB connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen|ID.4 2024-25|Adaptive Cruise Control (ACC) & Lane Assist|openpilot[17](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|
Parts- 1 OBD-C cable (2 ft)
- 1 VW MEB connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here
||| +|Volkswagen[13](#footnotes)|Jetta 2019-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Jetta GLI 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Passat Alltrack 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Passat GTE 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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,17](#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
[18](#footnotes)||| +|Volkswagen|Polo GTI 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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
[18](#footnotes)||| +|Volkswagen|T-Cross 2021|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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
[18](#footnotes)||| +|Volkswagen[13](#footnotes)|T-Roc 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Taos 2022-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Teramont 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Teramont Cross Sport 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Teramont X 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Tiguan 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Tiguan eHybrid 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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[13](#footnotes)|Touran 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,17](#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`.
@@ -360,18 +366,19 @@ A supported vehicle is one that just works when you install a comma device. All 3See more setup details for GM.
42019 Honda Civic 1.6L Diesel Sedan does not have ALC below 12mph.
5Enabling longitudinal control (alpha) will disable all CMBS functionality, including AEB and FCW.
-6See more setup details for Nissan.
-7In the non-US market, openpilot requires the car to come equipped with EyeSight with Lane Keep Assistance.
-8Enabling longitudinal control (alpha) will disable all EyeSight functionality, including AEB, LDW, and RAB.
-9Model years 2023 and 2024 can have either hardware type, depending on build date and factory. To check which hardware type your vehicle has, look for Autopilot computer under Software -> Additional Vehicle Information on your vehicle's touchscreen. See this page for more information.
-10See more setup details for Tesla.
-11openpilot operates above 28mph for Camry 4CYL L, 4CYL LE and 4CYL SE which don't have Full-Speed Range Dynamic Radar Cruise Control.
-12The J533 harness plugs in at the CAN gateway under the dashboard, just above the steering column. More information can be found at this guide.
-13Not including the China market Kamiq, which is based on the (currently) unsupported PQ34 platform.
-14Refers only to the MQB-based European B8 Passat, not the NMS Passat in the USA/China/Mideast markets.
-15Some Škoda vehicles are equipped with heated windshields, which are known to block GPS signal needed for some comma four functionality.
-16Only available for vehicles using a gateway (J533) harness. At this time, vehicles using a camera harness are limited to using stock ACC.
-17Model-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.
+6Mazda vehicles require comma power installation.
+7See more setup details for Nissan.
+8In the non-US market, openpilot requires the car to come equipped with EyeSight with Lane Keep Assistance.
+9Enabling longitudinal control (alpha) will disable all EyeSight functionality, including AEB, LDW, and RAB.
+10Model year 2019 can have HW2.5 or HW3, and model years 2023 and 2024 can have HW3 or HW4, depending on build date and factory. To check which hardware type your vehicle has, look for Autopilot computer under Software -> Additional Vehicle Information on your vehicle's touchscreen. See this page for more information.
+11See more setup details for Tesla.
+12openpilot operates above 28mph for Camry 4CYL L, 4CYL LE and 4CYL SE which don't have Full-Speed Range Dynamic Radar Cruise Control.
+13The J533 harness plugs in at the CAN gateway under the dashboard, just above the steering column. More information can be found at this guide.
+14Not including the China market Kamiq, which is based on the (currently) unsupported PQ34 platform.
+15Refers only to the MQB-based European B8 Passat, not the NMS Passat in the USA/China/Mideast markets.
+16Some Škoda vehicles are equipped with heated windshields, which are known to block GPS signal needed for some comma four functionality.
+17Only available for vehicles using a gateway (J533) harness. At this time, vehicles using a camera harness are limited to using stock ACC.
+18Model-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/launch_env.sh b/launch_env.sh index 4fcb243f12..0f913f287a 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="19.7" + export AGNOS_VERSION="19.9" fi export STAGING_ROOT="/data/safe_staging" diff --git a/opendbc_repo b/opendbc_repo index b2acec1dc7..a20dab9f45 160000 --- a/opendbc_repo +++ b/opendbc_repo @@ -1 +1 @@ -Subproject commit b2acec1dc7f15cfb258aa48415168485e29cc4ff +Subproject commit a20dab9f453fe251cba9683eb9d91e9e78ef0db5 diff --git a/openpilot/cereal/log.capnp b/openpilot/cereal/log.capnp index 10d5a925d8..4e9f8880be 100644 --- a/openpilot/cereal/log.capnp +++ b/openpilot/cereal/log.capnp @@ -128,6 +128,7 @@ struct OnroadEvent @0xc4fa6047f024e718 { personalityChanged @91; aeb @92; userBookmark @95; + userBookmarkNotPaired @104; excessiveActuation @96; bigModelLoading @100; bigModelFailed @102; @@ -166,12 +167,7 @@ struct InitData { gitSrcCommit @23 :Text; gitSrcCommitDate @24 :Text; - androidProperties @16 :Map(Text, Text); - - pandaInfo @8 :PandaInfo; - dirty @9 :Bool; - passive @12 :Bool; params @17 :Map(Text, Data); commands @19 :Map(Text, Data); @@ -189,6 +185,17 @@ struct InitData { mici @7; # comma four } + deprecated :group { + gctx @1 :Text; + androidBuildInfo @5 :Deprecated.AndroidBuildInfo; + androidSensors @6 :List(Deprecated.AndroidSensor); + chffrAndroidExtra @7 :ChffrAndroidExtra; + pandaInfo @8 :PandaInfo; + passive @12 :Bool; + iosBuildInfo @14 :Deprecated.IosBuildInfo; + androidProperties @16 :Map(Text, Text); + } + struct PandaInfo { hasPanda @0 :Bool; dongleId @1 :Text; @@ -199,14 +206,6 @@ struct InitData { struct ChffrAndroidExtra { allCameraCharacteristics @0 :Map(Text, Text); } - - deprecated :group { - gctx @1 :Text; - androidBuildInfo @5 :Deprecated.AndroidBuildInfo; - androidSensors @6 :List(Deprecated.AndroidSensor); - chffrAndroidExtra @7 :ChffrAndroidExtra; - iosBuildInfo @14 :Deprecated.IosBuildInfo; - } } struct FrameData { @@ -572,6 +571,9 @@ struct PandaState @0xa7649e2575e4591e { controlsAllowedLateral @38 :Bool; controlsAllowedLongitudinal @39 :Bool; + nmiReset @40 :Bool; + hardfaultReset @41 :Bool; + enum FaultStatus { none @0; faultTemp @1; @@ -694,6 +696,7 @@ struct PeripheralState { struct UsbState { devices @0 :List(Device); + connected @1 :Bool; struct Device { busnum @0 :UInt8; @@ -1237,6 +1240,7 @@ struct LongitudinalPlan @0xe00b5b3eba12876c { shouldStop @37: Bool; allowThrottle @38: Bool; allowBrake @39: Bool; + accelBoost @40 :Float32; solverExecutionTime @35 :Float32; @@ -2234,6 +2238,7 @@ struct DriverMonitoringState { pose @0: Bool; eye @1: Bool; phone @2: Bool; + sleep @3: Bool; } struct Pose { diff --git a/openpilot/common/file_chunker.py b/openpilot/common/file_chunker.py deleted file mode 100755 index 28a7e1212e..0000000000 --- a/openpilot/common/file_chunker.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python3 -import io -import sys -import math -import os -from pathlib import Path - -CHUNK_SIZE = 45 * 1024 * 1024 # 45MB, under GitHub's 50MB limit - -def get_chunk_name(name, idx, num_chunks): - return f"{name}.chunk{idx+1:02d}of{num_chunks:02d}" - -def get_manifest_path(name): - return f"{name}.chunkmanifest" - -def _chunk_paths(path, num_chunks): - return [get_manifest_path(path)] + [get_chunk_name(path, i, num_chunks) for i in range(num_chunks)] - -def get_chunk_targets(path, file_size): - num_chunks = math.ceil(file_size / CHUNK_SIZE) - return _chunk_paths(path, num_chunks) - -def chunk_file(path, targets): - manifest_path, *chunk_paths = targets - actual_num_chunks = max(1, math.ceil(os.path.getsize(path) / CHUNK_SIZE)) - assert len(chunk_paths) >= actual_num_chunks, f"Allowed {len(chunk_paths)} chunks but needs at least {actual_num_chunks}, for path {path}" - Path(manifest_path).unlink(missing_ok=True) - with open(path, 'rb') as f: - for chunk_path in chunk_paths: - with open(chunk_path, 'wb') as out: - out.write(f.read(CHUNK_SIZE)) - Path(manifest_path).write_text(str(len(chunk_paths))) - os.remove(path) - -class ChunkStream(io.RawIOBase): - def __init__(self, paths): - self._paths = iter(paths) - self._f = None - - def readable(self): - return True - - def readinto(self, b): - n = 0 - view = memoryview(b) - while n < len(b): - if self._f is None: - p = next(self._paths, None) - if p is None: - break - self._f = open(p, 'rb') - count = self._f.readinto(view[n:]) - if not count: - self._f.close() - self._f = None - continue - n += count - return n - -def open_file_chunked(path): - manifest_path = get_manifest_path(path) - if os.path.isfile(path): - paths = [path] - elif os.path.isfile(manifest_path): - num_chunks = int(Path(manifest_path).read_text().strip()) - paths = [get_chunk_name(path, i, num_chunks) for i in range(num_chunks)] - else: - raise FileNotFoundError(path) - return io.BufferedReader(ChunkStream(paths)) - - -if __name__ == "__main__": - path = sys.argv[1] - chunk_paths = get_chunk_targets(path, os.path.getsize(path)) - chunk_file(path, chunk_paths) diff --git a/openpilot/common/hardware/comma/agnos.json b/openpilot/common/hardware/comma/agnos.json index 4a48016ed2..96803bdb85 100644 --- a/openpilot/common/hardware/comma/agnos.json +++ b/openpilot/common/hardware/comma/agnos.json @@ -56,28 +56,28 @@ }, { "name": "boot", - "url": "https://commadist.azureedge.net/agnosupdate/boot-6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d.img.xz", - "hash": "6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d", - "hash_raw": "6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d", - "size": 46897152, + "url": "https://commadist.azureedge.net/agnosupdate/boot-b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615.img.xz", + "hash": "b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615", + "hash_raw": "b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615", + "size": 46962688, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "d12e1e5b9455b62a1464558716493b33e470d7a7e88da1c4105a3b21d0961808" + "ondevice_hash": "3ef4769bb1cc73bfc31f96f073b46af42dcc0bad4ab2dde32d11929745d0cd28" }, { "name": "system", - "url": "https://commadist.azureedge.net/agnosupdate/system-3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f.img.xz", - "hash": "74ffc9c551e1f29cda897ace8a69080fe644f8039977c6885f2b48362e39b744", - "hash_raw": "3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f", + "url": "https://commadist.azureedge.net/agnosupdate/system-80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7.img.xz", + "hash": "9aebca09b9d142718e71efbfe44b723010eec6e70067c043c63186067291739d", + "hash_raw": "80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7", "size": 4718592000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "6a992680183685eea9db99d915219a37935f45989330d9b619e880450257f448", + "ondevice_hash": "270b76c5c5568828e3883390b83ea445425a104dd42119d68f06093ce6b6fcef", "alt": { - "hash": "3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f", - "url": "https://commadist.azureedge.net/agnosupdate/system-3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f.img", + "hash": "80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7", + "url": "https://commadist.azureedge.net/agnosupdate/system-80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7.img", "size": 4718592000 } } diff --git a/openpilot/common/hardware/comma/power_monitor.py b/openpilot/common/hardware/comma/power_monitor.py index 9ae2c9e138..296290dae8 100755 --- a/openpilot/common/hardware/comma/power_monitor.py +++ b/openpilot/common/hardware/comma/power_monitor.py @@ -9,25 +9,22 @@ from openpilot.common.realtime import Ratekeeper from openpilot.common.filter_simple import FirstOrderFilter -def read_power(panda=None): - if panda is not None and panda.get_type() == panda.HW_TYPE_CUATRO: - health = panda.health() - return health['voltage'] * health['current'] / 1e6 +def read_power(): with open("/sys/bus/i2c/devices/0-0040/hwmon/hwmon1/power1_input") as f: return int(f.read()) / 1e6 -def sample_power(seconds=5, panda=None) -> list[float]: +def sample_power(seconds=5) -> list[float]: rate = 123 rk = Ratekeeper(rate, print_delay_threshold=None) pwrs = [] for _ in range(rate*seconds): - pwrs.append(read_power(panda)) + pwrs.append(read_power()) rk.keep_time() return pwrs -def get_power(seconds=5, panda=None): - pwrs = sample_power(seconds, panda) +def get_power(seconds=5): + pwrs = sample_power(seconds) return np.mean(pwrs) def wait_for_power(min_pwr, max_pwr, min_secs_in_range, timeout): diff --git a/openpilot/common/hardware/comma/updater b/openpilot/common/hardware/comma/updater index 35227d2d79..241d0fc168 100755 --- a/openpilot/common/hardware/comma/updater +++ b/openpilot/common/hardware/comma/updater @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6a7adb302d378dda7b1788a841b89e4f905c872550a70a76650dcde977b4ece0 -size 24709209 +oid sha256:f92c0a9b41613e063b6a1cd086729f5555d9df6482249e171ed1e276272cad7d +size 24709520 diff --git a/openpilot/common/hardware/usb.py b/openpilot/common/hardware/usb.py index c3c2050b6b..ba1d4f0cbc 100644 --- a/openpilot/common/hardware/usb.py +++ b/openpilot/common/hardware/usb.py @@ -36,6 +36,10 @@ def read_int(path: Path, base: int = 10) -> int: return 0 +def cable_connected() -> bool: + return read_int(TYPEC_CC_ORIENTATION_PATH) != 0 + + def usb_devices() -> list[Path]: try: devices = (d for d in USB_DEVICES_PATH.glob("*") if (d / "idVendor").exists()) @@ -91,3 +95,4 @@ def set_usb_state(device_state, devices: list[dict]) -> None: chestnut_present = True device_state.chestnutPresent = chestnut_present + device_state.usbState.connected = cable_connected() diff --git a/openpilot/common/params_keys.h b/openpilot/common/params_keys.h index 19c063fc26..9607d02035 100644 --- a/openpilot/common/params_keys.h +++ b/openpilot/common/params_keys.h @@ -93,16 +93,17 @@ inline static std::unordered_map keys = { {"ObdMultiplexingEnabled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}}, {"Offroad_CarUnrecognized", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_ChestnutBranch", {CLEAR_ON_MANAGER_START, JSON}}, + {"Offroad_ChestnutModelError", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_ChestnutNotDetected", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_ChestnutOverheated", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_ChestnutPcieUnavailable", {CLEAR_ON_MANAGER_START, JSON}}, - {"Offroad_ChestnutUncompiled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_ChestnutUpdateFailed", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_ChestnutUsbSlow", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_ConnectivityNeeded", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_ConnectivityNeededPrompt", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_ExcessiveActuation", {PERSISTENT, JSON}}, {"Offroad_NoFirmware", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, + {"Offroad_Pairing", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_Recalibration", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}}, {"Offroad_TemperatureTooHigh", {CLEAR_ON_MANAGER_START, JSON}}, {"Offroad_UnregisteredHardware", {CLEAR_ON_MANAGER_START, JSON}}, @@ -111,6 +112,8 @@ inline static std::unordered_map keys = { {"OnroadCycleRequested", {CLEAR_ON_MANAGER_START, BOOL}}, {"OpenpilotEnabledToggle", {PERSISTENT | BACKUP, BOOL, "1"}}, {"PandaHeartbeatLost", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, BOOL}}, + {"PairingEmail", {PERSISTENT, STRING}}, + {"PairingProvider", {PERSISTENT, STRING}}, {"PrimeType", {PERSISTENT, INT}}, {"RecordAudio", {PERSISTENT | BACKUP, BOOL}}, {"RecordFront", {PERSISTENT | BACKUP, BOOL}}, diff --git a/openpilot/common/version.py b/openpilot/common/version.py index 0456782c05..7473684d5a 100755 --- a/openpilot/common/version.py +++ b/openpilot/common/version.py @@ -13,8 +13,8 @@ from openpilot.common.git import get_commit, get_origin, get_branch, get_short_b RELEASE_SP_BRANCHES = ['release-c3', 'release', 'release-tizi', 'release-tici', 'release-tizi-staging', 'release-tici-staging'] TESTED_SP_BRANCHES = ['staging-c3', 'staging-c3-new', 'staging'] MASTER_SP_BRANCHES = ['master'] -RELEASE_BRANCHES = ['release-tizi-staging', 'release-mici-staging', 'release-tizi', 'release-mici', 'nightly'] -TESTED_BRANCHES = RELEASE_BRANCHES + ['devel-staging', 'nightly-dev'] + RELEASE_SP_BRANCHES + TESTED_SP_BRANCHES +RELEASE_BRANCHES = ['release-tizi-staging', 'release-mici-staging', 'release-tizi', 'release-mici', 'nightly', 'nightly-chestnut'] +TESTED_BRANCHES = RELEASE_BRANCHES + ['devel-staging', 'nightly-dev', 'nightly-chestnut-dev'] + RELEASE_SP_BRANCHES + TESTED_SP_BRANCHES CHESTNUT_BRANCHES = { "staging": "staging-chestnut", diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/car.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/car.png new file mode 100644 index 0000000000..221a8e1ceb --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/car.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0936a3d890a3ad68ffe021e4905b9b9e59ffc2ae19ee9c703daa3e7c92e7c78 +size 1793 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/car_green.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/car_green.png new file mode 100644 index 0000000000..82d757faf6 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/car_green.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57c7ba72642b0350ee764c924586fa66ff94915be4aba1813797e67eb4b74376 +size 33215 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1.png new file mode 100644 index 0000000000..3bccf1b0be --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71dc069bd3dc5b11ba8e1b52eb3a1025ee0ad8c82834f94b4b2269ec0d401920 +size 237 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1_green.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1_green.png new file mode 100644 index 0000000000..038d82958f --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_1_green.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73a6fe02cf19105c0051a6664a424c1bf2ede364c6cf3791f235c86ffa203f39 +size 10348 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2.png new file mode 100644 index 0000000000..c164b7b9d4 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f500f118dca3eedbd86fc6cc943ad8be67fe5d4f7b474d5b452cd757ad805c3 +size 282 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2_green.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2_green.png new file mode 100644 index 0000000000..b2b56b6c01 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_2_green.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a49996f0ce069a048abe94ba9424de655dcff53745676ab35e045204af88a3c +size 12103 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3.png new file mode 100644 index 0000000000..ef4b57207d --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8542374779f1908dc52c8a126bc041efea4c4d605afdd34e3bf8e520a7e78bf2 +size 320 diff --git a/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3_green.png b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3_green.png new file mode 100644 index 0000000000..f4688fc2d3 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/longitudinal/distance_3_green.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21e656b387c1a7e8de54d3e1b1c246f442cffe3c5e0924273d6d6d897c00b568 +size 13868 diff --git a/openpilot/selfdrive/assets/icons_mici/onroad/bookmark.png b/openpilot/selfdrive/assets/icons_mici/onroad/bookmark.png index 305561f509..75a19e7983 100644 --- a/openpilot/selfdrive/assets/icons_mici/onroad/bookmark.png +++ b/openpilot/selfdrive/assets/icons_mici/onroad/bookmark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fd91685bf656e828648acf035a4737acb2c4709e8514cf0aa0a10fa470a9bb60 -size 11580 +oid sha256:92431d37a844206570d73d48f39dd8d934539a89668fe8d5f4cccae38f7b301b +size 11530 diff --git a/openpilot/selfdrive/assets/icons_mici/onroad/bookmark_fill.png b/openpilot/selfdrive/assets/icons_mici/onroad/bookmark_fill.png new file mode 100644 index 0000000000..94cc355358 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/onroad/bookmark_fill.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3efe7d12ae82e0f5a1bd773129cf1eafe3c8c36ff5cba43a6c95f295706062b5 +size 19724 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/apple.png b/openpilot/selfdrive/assets/icons_mici/settings/device/apple.png new file mode 100644 index 0000000000..04af670a48 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/apple.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dfb645e59636687d61d20283bae65b68ddda6f7b2d4fda01a6e613a842e3b41f +size 823 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/commacare.png b/openpilot/selfdrive/assets/icons_mici/settings/device/commacare.png new file mode 100644 index 0000000000..0e484035cf --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/commacare.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df07383a6ddfe8cd7eeb2f22271cbd13068327ca0978873f8d10368860562e40 +size 111083 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/github.png b/openpilot/selfdrive/assets/icons_mici/settings/device/github.png new file mode 100644 index 0000000000..136c09aac7 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/github.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7518f3409e2ff8f7e99fb7f0c06afa8bdf0b0a506147b5cf9d4931b820a1a75 +size 1107 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/google.png b/openpilot/selfdrive/assets/icons_mici/settings/device/google.png new file mode 100644 index 0000000000..daeb574532 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/google.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a18e3de5f455b4089a824da8d5d78d5d532ef71facb05cc75c178b24d5beeb7 +size 920 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/green_cell.png b/openpilot/selfdrive/assets/icons_mici/settings/device/green_cell.png new file mode 100644 index 0000000000..99635bcc2b --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/green_cell.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:710e74b526adb5bbac15cd8915a828dd673dd8ce15b2fd694c64c182eef6f56f +size 13744 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/green_settings.png b/openpilot/selfdrive/assets/icons_mici/settings/device/green_settings.png new file mode 100644 index 0000000000..230beb85dd --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/green_settings.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25db08a9038b5faa3b200ef5625cb216d7f4b31ea3d49fb0395f9b726bfaf8e6 +size 13135 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/paired_apple.png b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_apple.png new file mode 100644 index 0000000000..75708bf51c --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_apple.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:547f466cc59d29647f3bf131d8444d7f22b63b186bcea55ceb8109c68ca74fe8 +size 2345 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/paired_github.png b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_github.png new file mode 100644 index 0000000000..8514100359 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_github.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:55050096cd98e7f4ea3e843d927c18815e458dfbd5e7d100b503db92075d061b +size 2373 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/paired_google.png b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_google.png new file mode 100644 index 0000000000..853de37d9d --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/paired_google.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f9c439ed89bc0357e86c005d5dbb6f1ad2b8d72cdc0890e3201d842d8c3928f1 +size 2443 diff --git a/openpilot/selfdrive/assets/icons_mici/settings/device/phone.png b/openpilot/selfdrive/assets/icons_mici/settings/device/phone.png new file mode 100644 index 0000000000..6114cd9124 --- /dev/null +++ b/openpilot/selfdrive/assets/icons_mici/settings/device/phone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7aeccd3e0d8517f74eb34f7d73632c0b29b0ad5df5b9686c0f509bb6b136561e +size 1728 diff --git a/openpilot/selfdrive/assets/sounds/dm_critical_max.wav b/openpilot/selfdrive/assets/sounds/dm_critical_max.wav new file mode 100644 index 0000000000..9526364f99 --- /dev/null +++ b/openpilot/selfdrive/assets/sounds/dm_critical_max.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c4699e3a456a0df2bbfac7ab1eb8972fc8bd970296133fd322e214c61884407 +size 52374 diff --git a/openpilot/selfdrive/controls/lib/accel_boost.py b/openpilot/selfdrive/controls/lib/accel_boost.py new file mode 100644 index 0000000000..6059f8dabc --- /dev/null +++ b/openpilot/selfdrive/controls/lib/accel_boost.py @@ -0,0 +1,39 @@ +import numpy as np +from openpilot.common.constants import CV +from openpilot.common.realtime import DT_MDL + +ACCEL_BOOST_MAX = 0.2 +ACCEL_BOOST_RATE = 0.025 +ACCEL_BOOST_PER_OVERRIDE = 0.05 +ACCEL_BOOST_MIN_SPEED = 10 * CV.MPH_TO_MS + + +class AccelBoost: + def __init__(self): + self.total_boost = 0.0 + self.boost_this_override = 0.0 + self.boost_eligible = False + + def update(self, sm, output_a_target_e2e, output_a_target_mpc, a_cruise): + enabled = sm['selfdriveState'].enabled + gas_pressed = sm['carState'].gasPressed + v_ego = sm['carState'].vEgo + speed_scale = np.interp(v_ego, [ACCEL_BOOST_MIN_SPEED, 2 * ACCEL_BOOST_MIN_SPEED], [0.0, 1.0]) + boost_scale = speed_scale * np.interp(output_a_target_e2e, [-1.0, -0.5, 5.0], [0.0, 1.0, 1.0], right=0.0) + model_limited = (sm['selfdriveState'].experimentalMode and + output_a_target_e2e + self.total_boost * boost_scale < min(output_a_target_mpc, a_cruise) - 0.1) + + if not enabled or not gas_pressed: + self.boost_this_override = 0.0 + # Latch eligibility before the override changes the lead plan. + self.boost_eligible = enabled and model_limited + + if not enabled: + self.total_boost = 0.0 + elif gas_pressed and self.boost_eligible: + increase = min(ACCEL_BOOST_RATE * DT_MDL * speed_scale, + ACCEL_BOOST_PER_OVERRIDE - self.boost_this_override, ACCEL_BOOST_MAX - self.total_boost) + self.total_boost += increase + self.boost_this_override += increase + + return output_a_target_e2e + self.total_boost * boost_scale diff --git a/openpilot/selfdrive/controls/lib/latcontrol_curvature.py b/openpilot/selfdrive/controls/lib/latcontrol_curvature.py index 3dfe691e10..efa0734609 100644 --- a/openpilot/selfdrive/controls/lib/latcontrol_curvature.py +++ b/openpilot/selfdrive/controls/lib/latcontrol_curvature.py @@ -1,11 +1,13 @@ import math +import numpy as np +from collections import deque from openpilot.cereal import log from openpilot.common.pid import PIDController from openpilot.selfdrive.controls.lib.latcontrol import LatControl from openpilot.selfdrive.controls.lib.drive_helpers import MAX_CURVATURE -CURVATURE_SATURATION_THRESHOLD = 1e-3 # 1/m +LAT_ACCEL_SATURATION_THRESHOLD = 0.5 # m/s^2 class LatControlCurvature(LatControl): @@ -20,6 +22,8 @@ class LatControlCurvature(LatControl): else: self.pid = None self.kf = 1. + self.desired_curvature_buffer_len = int(1. / self.dt) + self.desired_curvature_buffer = deque([0.] * self.desired_curvature_buffer_len, maxlen=self.desired_curvature_buffer_len) def reset(self): super().reset() @@ -30,6 +34,9 @@ class LatControlCurvature(LatControl): curvature_log = log.ControlsState.LateralCurvatureState.new_message() actual_curvature = -VM.calc_curvature(math.radians(CS.steeringAngleDeg - params.angleOffsetDeg), CS.vEgo, params.roll) error = desired_curvature - actual_curvature + self.desired_curvature_buffer.append(desired_curvature) + delay_frames = int(np.clip(lat_delay / self.dt + 1, 1, self.desired_curvature_buffer_len)) + expected_curvature = self.desired_curvature_buffer[-delay_frames] if not active: output_curvature = 0.0 @@ -53,6 +60,6 @@ class LatControlCurvature(LatControl): curvature_log.actualCurvature = float(actual_curvature) curvature_log.desiredCurvature = float(desired_curvature) curvature_log.output = float(output_curvature) - curvature_log.saturated = bool(self._check_saturation(abs(error) > CURVATURE_SATURATION_THRESHOLD, CS, + curvature_log.saturated = bool(self._check_saturation(abs(expected_curvature - actual_curvature) * CS.vEgo ** 2 > LAT_ACCEL_SATURATION_THRESHOLD, CS, False, curvature_limited)) return 0.0, float(output_curvature), curvature_log diff --git a/openpilot/selfdrive/controls/lib/longitudinal_planner.py b/openpilot/selfdrive/controls/lib/longitudinal_planner.py index bf91c5e1f9..e6faad7ca4 100755 --- a/openpilot/selfdrive/controls/lib/longitudinal_planner.py +++ b/openpilot/selfdrive/controls/lib/longitudinal_planner.py @@ -8,6 +8,7 @@ from openpilot.common.constants import CV from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.realtime import DT_MDL from openpilot.selfdrive.modeld.constants import ModelConstants +from openpilot.selfdrive.controls.lib.accel_boost import AccelBoost from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalMpc, LongitudinalPlanSource from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import T_IDXS as T_IDXS_MPC @@ -63,6 +64,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP): self.fcw = False self.dt = dt self.allow_throttle = True + self.accel_boost = AccelBoost() self.v_desired_filter = FirstOrderFilter(init_v, 2.0, self.dt) self.a_cruise = init_a @@ -145,6 +147,8 @@ class LongitudinalPlanner(LongitudinalPlannerSP): accel_coast, self.allow_throttle) cruise_should_stop = should_stop(v_ego, self.a_cruise) + output_a_target_e2e = self.accel_boost.update(sm, output_a_target_e2e, output_a_target_mpc, self.a_cruise) + candidates = [(output_a_target_mpc, self.mpc.source, output_should_stop_mpc), (self.a_cruise, LongitudinalPlanSource.cruise, cruise_should_stop)] if is_e2e: @@ -175,6 +179,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP): longitudinalPlan.fcw = self.fcw longitudinalPlan.aTarget = float(self.output_a_target) + longitudinalPlan.accelBoost = float(self.accel_boost.total_boost) longitudinalPlan.shouldStop = bool(self.output_should_stop) longitudinalPlan.allowBrake = True longitudinalPlan.allowThrottle = bool(self.allow_throttle) diff --git a/openpilot/selfdrive/controls/tests/test_latcontrol.py b/openpilot/selfdrive/controls/tests/test_latcontrol.py index 0dc26887e3..d45d12d957 100644 --- a/openpilot/selfdrive/controls/tests/test_latcontrol.py +++ b/openpilot/selfdrive/controls/tests/test_latcontrol.py @@ -8,12 +8,14 @@ from opendbc.car.honda.values import CAR as HONDA from opendbc.car.toyota.values import CAR as TOYOTA from opendbc.car.nissan.values import CAR as NISSAN from opendbc.car.gm.values import CAR as GM +from opendbc.car.volkswagen.values import CAR as VOLKSWAGEN from opendbc.car.vehicle_model import VehicleModel from openpilot.common.realtime import DT_CTRL from openpilot.selfdrive.car.helpers import convert_to_capnp from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle +from openpilot.selfdrive.controls.lib.latcontrol_curvature import LatControlCurvature from openpilot.selfdrive.locationd.helpers import Pose from openpilot.common.mock.generators import generate_deviceMotion from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces @@ -22,7 +24,8 @@ from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfac class TestLatControl(OpenpilotTestCase): @parameterized.expand([(HONDA.HONDA_CIVIC, LatControlPID), (TOYOTA.TOYOTA_RAV4, LatControlTorque), - (NISSAN.NISSAN_LEAF, LatControlAngle), (GM.CHEVROLET_BOLT_EUV, LatControlTorque)]) + (NISSAN.NISSAN_LEAF, LatControlAngle), (GM.CHEVROLET_BOLT_EUV, LatControlTorque), + (VOLKSWAGEN.VOLKSWAGEN_ID4_MK2, LatControlCurvature)]) def test_saturation(self, car_name, controller): CarInterface = interfaces[car_name] CP = CarInterface.get_non_essential_params(car_name) diff --git a/openpilot/selfdrive/locationd/models/pose_kf.py b/openpilot/selfdrive/locationd/models/pose_kf.py index a8ff80c713..f71503c201 100755 --- a/openpilot/selfdrive/locationd/models/pose_kf.py +++ b/openpilot/selfdrive/locationd/models/pose_kf.py @@ -40,7 +40,7 @@ class PoseKalman(KalmanFilter): initial_P = np.diag([0.01**2, 0.01**2, 0.01**2, 10**2, 10**2, 10**2, 1**2, 1**2, 1**2, - 1**2, 1**2, 1**2, + 0.1**2, 0.1**2, 0.1**2, 100**2, 100**2, 100**2, 0.01**2, 0.01**2, 0.01**2]) diff --git a/openpilot/selfdrive/modeld/SConscript b/openpilot/selfdrive/modeld/SConscript index 2aac90fe63..c8892ef912 100644 --- a/openpilot/selfdrive/modeld/SConscript +++ b/openpilot/selfdrive/modeld/SConscript @@ -1,14 +1,20 @@ +import ctypes.util import glob import os -import time from SCons.Script import Action, Value from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE -from openpilot.selfdrive.modeld.helpers import chestnut_present from openpilot.system.camerad.cameras.nv12_info import get_nv12_info Import('env', 'arch') + +# TODO: add GCC support to tinygrad? +# tinygrad's CPU:LLVM backend requires libLLVM, skip the build on PC if it's not available +if arch not in ('comma_arm64', 'Darwin') and ctypes.util.find_library('LLVM') is None: + print("\033[93mmodeld: LLVM not found, skipping tinygrad model compilation. Install LLVM to compile models.\033[0m") + Return() + lenv = env.Clone() lenv.PrependENVPath('PYTHONPATH', Dir('#tinygrad_repo').abspath) @@ -24,38 +30,18 @@ else: # JIT=2 disables graph batching, which produces incorrect outputs after buffers change. tg_flags = 'DEV=METAL JIT=2' if arch == 'Darwin' else 'DEV=CPU:LLVM' -CHESTNUT = chestnut_present() -if CHESTNUT: - chestnut_tg_flags = 'DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32' - # the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it - chestnut_lock = File("models/.chestnut.lock").abspath - # tinygrad calls brew which needs a $HOME in the env mac_brew_string = f'HOME={os.path.expanduser("~")}' if arch == 'Darwin' else '' warp_deps = [File("#openpilot/system/camerad/cameras/nv12_info.py")] compiler = Dir('#tinygrad_repo/examples/openpilot').abspath -# CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it. -taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else '' - -def chestnut_action(command): - def do_compile(target, source, env): - from openpilot.system.hardware.chestnut.flash import link_up - # chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars - for _ in range(10): - if link_up(): - break - time.sleep(1) - else: - print("Chestnut not ready, skipping warp build") - return - return env.Execute(command) - return Action(do_compile, " [CHESTNUT] $TARGET") +# build on all cores +taskset = 'taskset -c 0-7 ' if arch == 'comma_arm64' else '' def compile_model(onnx_path, pkl_path): onnx_path, target_pkl_path = File(onnx_path).abspath, File(pkl_path).abspath cmd = (f'{tg_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_onnx.py" ' - f'"{onnx_path}" "{target_pkl_path}" --device-input "*" --out-of-band --benchmark-runs 1') + f'"{onnx_path}" "{target_pkl_path}" --out-of-band --benchmark-runs 1') lenv.Command( target_pkl_path, tinygrad_files + [onnx_path, Value(cmd)], @@ -66,19 +52,14 @@ compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygra compile_model('models/driving_supercombo.onnx', 'models/driving_tinygrad.pkl') model_w, model_h = MEDMODEL_INPUT_SIZE -for chestnut in [False, True] if CHESTNUT else [False]: - file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags) - for cam_w, cam_h in camera_configs: - warp_pkl_path = File(f"models/{file_prefix}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath - stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) - cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_warp.py" ' - f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{stride * (y_height + uv_height)} ' - f'--warp-to {model_w}x{model_h} --layout yuv420 --frames 2 ' - f'--output {warp_pkl_path}') - action = chestnut_action(cmd) if chestnut else cmd - node = lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], action) - if chestnut: - lenv.SideEffect(chestnut_lock, node) +for cam_w, cam_h in camera_configs: + warp_pkl_path = File(f"models/driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath + stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h) + cmd = (f'{tg_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_warp.py" ' + f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{stride * (y_height + uv_height)} ' + f'--warp-to {model_w}x{model_h} --layout yuv420 --frames 2 ' + f'--output {warp_pkl_path}') + lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], cmd) dm_w, dm_h = DM_INPUT_SIZE for cam_w, cam_h in camera_configs: diff --git a/openpilot/selfdrive/modeld/dmonitoringmodeld.py b/openpilot/selfdrive/modeld/dmonitoringmodeld.py index 06d0d2e5e7..5a6e9216ad 100755 --- a/openpilot/selfdrive/modeld/dmonitoringmodeld.py +++ b/openpilot/selfdrive/modeld/dmonitoringmodeld.py @@ -16,7 +16,6 @@ from openpilot.common.realtime import config_realtime_process from openpilot.common.transformations.model import dmonitoringmodel_intrinsics from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye from openpilot.system.camerad.cameras.nv12_info import get_nv12_info -from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.parse_model_outputs import sigmoid, safe_exp SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') @@ -28,7 +27,7 @@ class ModelState: output: np.ndarray def __init__(self, cam_w: int, cam_h: int): - jits = load_oob(open_file_chunked(MODEL_PKL_PATH)) + jits = load_oob(MODEL_PKL_PATH) self.DEV = jits['input_specs']['input_img'][2] self.input_shapes = jits['metadata']['input_shapes'] self.output_slices = pickle.loads(base64.b64decode(jits['metadata']['metadata']['output_slices'])) diff --git a/openpilot/selfdrive/modeld/helpers.py b/openpilot/selfdrive/modeld/helpers.py index 5e7f413c26..9ac772e697 100644 --- a/openpilot/selfdrive/modeld/helpers.py +++ b/openpilot/selfdrive/modeld/helpers.py @@ -1,9 +1,8 @@ -import io -import pickle -import struct +import sys +import time from pathlib import Path -from openpilot.common.file_chunker import get_manifest_path +from openpilot.common.hardware import AGNOS from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH, is_chestnut_usb_id MODELS_DIR = Path(__file__).resolve().parent / 'models' @@ -13,15 +12,12 @@ def modeld_pkl_path(chestnut: bool): prefix = 'big_' if chestnut else '' return MODELS_DIR / f'{prefix}driving_tinygrad.pkl' -def load_oob(f): - opcodes = f.read(struct.unpack(' bool: for d in USB_DEVICES_PATH.glob("*"): @@ -34,7 +30,15 @@ def chestnut_present() -> bool: pass return False +def wait_for_chestnut(timeout: float = 10.) -> None: + # the cable is detected before chestnut enumerates + st = time.monotonic() + while not chestnut_present(): + if time.monotonic() - st > timeout: + raise TimeoutError("chestnut did not enumerate") + time.sleep(0.1) + def chestnut_compiled() -> bool: path = modeld_pkl_path(chestnut=True) - return (path.is_file() or Path(get_manifest_path(path)).is_file()) and all( + return path.is_file() and all( (MODELS_DIR / f'big_driving_warp_{size}_tinygrad.pkl').is_file() for size in ('1344x760', '1928x1208')) diff --git a/openpilot/selfdrive/modeld/modeld.py b/openpilot/selfdrive/modeld/modeld.py index 57e19caacc..d451b5a59e 100755 --- a/openpilot/selfdrive/modeld/modeld.py +++ b/openpilot/selfdrive/modeld/modeld.py @@ -2,11 +2,14 @@ from collections.abc import Callable import base64 import ctypes +import gc from functools import cached_property import os os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom +os.environ.setdefault('AM_POWER_LIMIT', '100') from tinygrad.device import Buffer, Device from tinygrad.dtype import DType, dtypes +from tinygrad.engine.realize import lower_and_compile from tinygrad.tensor import Tensor from tinygrad.helpers import round_up from tinygrad.uop.ops import UOp @@ -25,6 +28,7 @@ from msgq.visionipc import VisionIpcClient, VisionBuf from opendbc.car.car_helpers import get_demo_car_params from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params +from openpilot.common.hardware.usb import cable_connected from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.realtime import config_realtime_process, DT_MDL from openpilot.common.transformations.camera import DEVICE_CAMERAS @@ -34,9 +38,8 @@ from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, should_stop, smooth_value, get_curvature_from_plan from openpilot.selfdrive.modeld.parse_model_outputs import Parser from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState -from openpilot.common.file_chunker import open_file_chunked from openpilot.selfdrive.modeld.constants import ModelConstants, Plan -from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob +from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob, wait_for_chestnut from openpilot.sunnypilot.livedelay.helpers import get_lat_delay from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController @@ -77,6 +80,12 @@ def get_action_from_model(model_output: dict[str, np.ndarray], prev_action: log. shouldStop=bool(stop)) +def drop_chestnut() -> None: + if "AMD" in Device._opened_devices: + for d in Device._opened_devices: + Device[d].pending.pop(Device["AMD"], None) + + class ChestnutGpuState: # GPU metrics require modeld's GPU context def __init__(self, pm: PubMaster, big: bool): @@ -143,7 +152,7 @@ class ModelState(ModelStateBase): def __init__(self, cam_w: int, cam_h: int, chestnut: bool): ModelStateBase.__init__(self) - jits = load_oob(open_file_chunked(modeld_pkl_path(chestnut))) + jits = load_oob(modeld_pkl_path(chestnut), chestnut) self.model_device = jits['input_specs']['new_img'][2] self.input_shapes = {name: (shape, np.dtype(dtype)) for name, (shape, dtype, _) in jits['input_specs'].items()} self.state_pairs = {name: f'next_{name}' for name in self.input_shapes if f'next_{name}' in jits['metadata']['output_shapes']} @@ -158,7 +167,9 @@ class ModelState(ModelStateBase): self.pack_inputs() with open(MODELS_DIR / f'{"big_" if chestnut else ""}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl', 'rb') as f: self.run_warp = pickle.load(f)['run'] + self.run_warp.captured._linear = lower_and_compile(self.run_warp.captured._linear) self.run_model = jits['run'] + self.run_model.captured._linear = lower_and_compile(self.run_model.captured._linear) self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in jits['output_specs'].items()} for name, next_name in self.state_pairs.items(): state = self.input_queues[name] @@ -228,14 +239,15 @@ class ModelState(ModelStateBase): def main(demo=False): cloudlog.warning("modeld init") - CHESTNUT = chestnut_present() and chestnut_compiled() + CHESTNUT = chestnut_compiled() and (chestnut_present() or cable_connected()) if CHESTNUT: - os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000' + from tinygrad.runtime.ops_amd import AMDDevice + AMDDevice.wait_timeout_ms = 3000 params = Params() params.put_bool("ChestnutLoading", CHESTNUT) params.remove("ChestnutActive") - config_realtime_process(7, 54) + gc.disable() # visionipc clients while True: @@ -268,6 +280,7 @@ def main(demo=False): def load_big(): nonlocal big_model try: + wait_for_chestnut() m = ModelState(vipc_client_main.width, vipc_client_main.height, True) m.warmup() big_model = m @@ -278,6 +291,8 @@ def main(demo=False): loader.join(BIG_MODEL_TIMEOUT) model = big_model params.put_bool("ChestnutActive", model is not None) + if model is None: + drop_chestnut() small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or CHESTNUT else None if model is None: @@ -286,6 +301,8 @@ def main(demo=False): params.put_bool("ChestnutLoading", False) cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting") + config_realtime_process(7, 54) + # messaging pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutGpuState"] if CHESTNUT else []) pm = PubMaster(pub_socks) @@ -407,10 +424,11 @@ def main(demo=False): run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutGpuState'].frequency) == 0) model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None) except Exception: - if not params.get_bool("ChestnutActive"): + if not model.chestnut: raise # fallback to small model cloudlog.exception("big model failed, fall back to small") + drop_chestnut() params.put_bool("ChestnutActive", False) assert small_model is not None model = small_model diff --git a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl index 9a99ee2633..7925e6d828 100644 --- a/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl +++ b/openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76cc0a9bc3af7a318889483dcbe126337f8d338f5abcbe664a8988c9b18b6639 -size 776634338 +oid sha256:cc75858d5e007b725b6264e194da4321d7874a212dc631b8f40a215dfd172cd2 +size 799992286 diff --git a/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl new file mode 100644 index 0000000000..baa4f72f41 --- /dev/null +++ b/openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e80410f65706472e72150cfdcb0087595782e8aa366149286672511a6224ef54 +size 864120 diff --git a/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl b/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl new file mode 100644 index 0000000000..242cc0fb9a --- /dev/null +++ b/openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54dff4bc4a64a8615823bb4906429a9db30f4b9fca9b8a56e9c84100227608bb +size 863864 diff --git a/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx b/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx index 1e592e2192..1d6527cf2c 100644 --- a/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx +++ b/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd299afabe7a3e0d04cbe2bd97fdb0c93bba8ad6d3cc3663a0e0ededaf243ac2 -size 7497335 +oid sha256:dee5a294e8afaacc9295ac5d100e00733ecac278e79264b96331e40a3ede1b04 +size 7844499 diff --git a/openpilot/selfdrive/monitoring/policy.py b/openpilot/selfdrive/monitoring/policy.py index bd698214ac..57c83683e1 100644 --- a/openpilot/selfdrive/monitoring/policy.py +++ b/openpilot/selfdrive/monitoring/policy.py @@ -51,6 +51,7 @@ class DRIVER_MONITOR_SETTINGS: self._SG_THRESHOLD = 0.9 self._BLINK_THRESHOLD = 0.865 self._PHONE_THRESH = 0.5 + self._SLEEP_THRESH = 0.75 self._POSE_PITCH_THRESHOLD = 0.3133 self._POSE_PITCH_THRESHOLD_SLACK = 0.3237 self._POSE_PITCH_THRESHOLD_STRICT = self._POSE_PITCH_THRESHOLD @@ -138,6 +139,7 @@ class DriverMonitoring: self.pose = DriverPose(settings=self.settings) self.blink = DriverBlink() self.phone_prob = 0. + self.sleep_prob = 0. self.alert_level = AlertLevel.none self.always_on = always_on @@ -240,6 +242,7 @@ class DriverMonitoring: self.distracted_types['pose'] = bool((pitch_error > pitch_threshold) or (yaw_error > yaw_threshold)) self.distracted_types['eye'] = bool((self.blink.left + self.blink.right)*0.5 > self.settings._BLINK_THRESHOLD) self.distracted_types['phone'] = bool(self.phone_prob > self.settings._PHONE_THRESH) + self.distracted_types['sleep'] = bool(self.sleep_prob > self.settings._SLEEP_THRESH) def _update_states(self, driver_state, cal_rpy, car_speed, op_engaged, lowspeed, demo_mode=False, steering_angle_deg=0.): rhd_pred = driver_state.wheelOnRightProb @@ -277,6 +280,7 @@ class DriverMonitoring: self.blink.right = driver_data.rightBlinkProb * (driver_data.rightEyeProb > self.settings._EYE_THRESHOLD) \ * (driver_data.sunglassesProb < self.settings._SG_THRESHOLD) self.phone_prob = driver_data.phoneProb + self.sleep_prob = driver_data.sleepProb self._get_distracted_types() self.driver_distracted = any(self.distracted_types.values()) and driver_data.faceProb > self.settings._FACE_THRESHOLD and self.pose.low_std @@ -406,6 +410,7 @@ class DriverMonitoring: dm.visionPolicyState.distractedTypes.pose = self.distracted_types['pose'] dm.visionPolicyState.distractedTypes.eye = self.distracted_types['eye'] dm.visionPolicyState.distractedTypes.phone = self.distracted_types['phone'] + dm.visionPolicyState.distractedTypes.sleep = self.distracted_types['sleep'] dm.visionPolicyState.faceDetected = self.face_detected dm.visionPolicyState.pose.pitch = self.pose.pitch dm.visionPolicyState.pose.yaw = self.pose.yaw diff --git a/openpilot/selfdrive/monitoring/test_monitoring.py b/openpilot/selfdrive/monitoring/test_monitoring.py index d9889860f5..44fc0398cd 100644 --- a/openpilot/selfdrive/monitoring/test_monitoring.py +++ b/openpilot/selfdrive/monitoring/test_monitoring.py @@ -28,6 +28,7 @@ def make_msg(face_detected, distracted=False, model_uncertain=False): ds.leftDriverData.facePositionStd = [1.*model_uncertain, 1.*model_uncertain] # TODO: test both separately when e2e is used ds.leftDriverData.phoneProb = 0. + ds.leftDriverData.sleepProb = 0. return ds diff --git a/openpilot/selfdrive/pandad/pandad.cc b/openpilot/selfdrive/pandad/pandad.cc index d89762ba9b..8dc81606e8 100644 --- a/openpilot/selfdrive/pandad/pandad.cc +++ b/openpilot/selfdrive/pandad/pandad.cc @@ -139,6 +139,8 @@ void fill_panda_state(cereal::PandaState::Builder &ps, cereal::PandaState::Panda ps.setInterruptLoad(health.interrupt_load_pkt / 255.0f); ps.setFanPower(health.fan_power); ps.setSafetyRxChecksInvalid((health.flags_pkt & HEALTH_FLAG_SAFETY_RX_CHECKS_INVALID) != 0U); + ps.setNmiReset((health.flags_pkt & HEALTH_FLAG_NMI_RESET) != 0U); + ps.setHardfaultReset((health.flags_pkt & HEALTH_FLAG_HARDFAULT_RESET) != 0U); ps.setSpiErrorCount(health.spi_error_count_pkt); ps.setSbu1Voltage(health.sbu1_voltage_mV / 1000.0f); ps.setSbu2Voltage(health.sbu2_voltage_mV / 1000.0f); diff --git a/openpilot/selfdrive/pandad/spi.cc b/openpilot/selfdrive/pandad/spi.cc index 369032a533..f54c26e506 100644 --- a/openpilot/selfdrive/pandad/spi.cc +++ b/openpilot/selfdrive/pandad/spi.cc @@ -29,12 +29,6 @@ enum SpiError { const unsigned int SPI_ACK_TIMEOUT = 500; // milliseconds const std::string SPI_DEVICE = "/dev/spidev0.0"; -// TODO: fix SPI turnaround synchronization at the protocol level. -static uint64_t spi_last_bus_activity_ns = 0; // protected by hw_lock - -static void wait_for_spi_turnaround(uint64_t start_ns) { - while ((nanos_since_boot() - start_ns) < 400000) {} -} class LockEx { public: @@ -325,8 +319,6 @@ int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx assert(tx_len < SPI_BUF_SIZE); assert(max_rx_len < SPI_BUF_SIZE); - wait_for_spi_turnaround(spi_last_bus_activity_ns); - xfer_count++; header = { .sync = SPI_SYNC, @@ -355,7 +347,6 @@ int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx if (ret < 0) { goto fail; } - wait_for_spi_turnaround(nanos_since_boot()); // Send data if (tx_data != NULL) { @@ -398,7 +389,6 @@ int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx memcpy(rx_data, rx_buf + 3, rx_data_len); } - spi_last_bus_activity_ns = nanos_since_boot(); return rx_data_len; fail: @@ -413,7 +403,6 @@ fail: } } - spi_last_bus_activity_ns = nanos_since_boot(); if (ret >= 0) ret = -1; return ret; } diff --git a/openpilot/selfdrive/selfdrived/alerts_offroad.json b/openpilot/selfdrive/selfdrived/alerts_offroad.json index b3b5077823..23b661f60c 100644 --- a/openpilot/selfdrive/selfdrived/alerts_offroad.json +++ b/openpilot/selfdrive/selfdrived/alerts_offroad.json @@ -21,6 +21,10 @@ "text": "Chestnut detected! Switch to the %1 branch to use chestnut-class models.", "severity": -1 }, + "Offroad_ChestnutModelError": { + "text": "Chestnut model error. Restart the car to retry.", + "severity": 0 + }, "Offroad_ChestnutNotDetected": { "text": "Chestnut not detected. Check USB and 12V connections.", "severity": 0 @@ -33,10 +37,6 @@ "text": "%1", "severity": 0 }, - "Offroad_ChestnutUncompiled": { - "text": "Chestnut model not compiled. Keep ignition on and reboot the comma.", - "severity": 0 - }, "Offroad_ChestnutUpdateFailed": { "text": "Chestnut update failed. Check the USB cable.", "severity": 0 @@ -74,5 +74,10 @@ "text": "Unsupported branch detected - The current version of %1 branch is no longer supported on the comma three. Please go to [Device > Software] and install a supported branch with -tici in the branch name for the comma three.", "severity": 1, "_comment": "Set extra field to the current branch name." + }, + "Offroad_Pairing": { + "text": "Finish setup. Pair your device with comma connect to view recent drives and bookmarked routes.", + "severity": -2, + "icon": "icons_mici/settings/device/green_settings.png" } } diff --git a/openpilot/selfdrive/selfdrived/events.py b/openpilot/selfdrive/selfdrived/events.py index 8a8271b123..7ba1b3d8f0 100755 --- a/openpilot/selfdrive/selfdrived/events.py +++ b/openpilot/selfdrive/selfdrived/events.py @@ -833,6 +833,10 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = { EventName.userBookmark: { ET.PERMANENT: NormalPermanentAlert("Bookmark Saved", duration=1.5), }, + + EventName.userBookmarkNotPaired: { + ET.PERMANENT: NormalPermanentAlert("Bookmark Saved", "pair to connect\nto view", duration=1.5), + }, } diff --git a/openpilot/selfdrive/selfdrived/selfdrived.py b/openpilot/selfdrive/selfdrived/selfdrived.py index 2354717a5c..3d140595af 100755 --- a/openpilot/selfdrive/selfdrived/selfdrived.py +++ b/openpilot/selfdrive/selfdrived/selfdrived.py @@ -236,7 +236,9 @@ class SelfdriveD(CruiseHelper): # Check for user bookmark press if self.sm.updated['userBookmark']: - self.events.add(EventName.userBookmark) + prime_type = self.params.get("PrimeType") + paired = prime_type is not None and int(prime_type) >= 0 + self.events.add(EventName.userBookmark if paired else EventName.userBookmarkNotPaired) # Don't add any more events while in dashcam mode if self.CP.passive: diff --git a/openpilot/selfdrive/test/process_replay/model_replay.py b/openpilot/selfdrive/test/process_replay/model_replay.py index fb46e83ef0..a4f9fcfb2b 100755 --- a/openpilot/selfdrive/test/process_replay/model_replay.py +++ b/openpilot/selfdrive/test/process_replay/model_replay.py @@ -80,6 +80,7 @@ def generate_report(proposed, master, tmp, commit): (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.phoneProb), "leftDriverData.phoneProb"), + (lambda x: get_idx_if_non_empty(x.leftDriverData.sleepProb), "leftDriverData.sleepProb"), (lambda x: get_idx_if_non_empty(x.rightDriverData.faceProb), "rightDriverData.faceProb"), ], "driverStateV2") diff --git a/openpilot/selfdrive/test/process_replay/test_processes.py b/openpilot/selfdrive/test/process_replay/test_processes.py index 5b72f7b712..0de9a953f4 100755 --- a/openpilot/selfdrive/test/process_replay/test_processes.py +++ b/openpilot/selfdrive/test/process_replay/test_processes.py @@ -36,7 +36,7 @@ source_segments = [ ("MAZDA", "bd6a637565e91581|2021-10-30--15-14-53--4"), # MAZDA.MAZDA_CX9_2021 ("FORD", "54827bf84c38b14f|2023-01-26--21-59-07--4"), # FORD.FORD_BRONCO_SPORT_MK1 ("RIVIAN", "bc095dc92e101734|000000db--ee9fe46e57--1"), # RIVIAN.RIVIAN_R1_GEN1 - ("TESLA", "2c912ca5de3b1ee9|0000025d--6eb6bcbca4--4"), # TESLA.TESLA_MODEL_Y + ("TESLA", "aebd8f1d4ea16066|000002d8--5850d3ea5c--2"), # TESLA.TESLA_MODEL_Y # Enable when port is tested and dashcamOnly is no longer set #("VOLKSWAGEN2", "3cfdec54aa035f3f|2022-07-19--23-45-10--2"), # VOLKSWAGEN.VOLKSWAGEN_PASSAT_NMS @@ -60,7 +60,7 @@ segments = [ ("MAZDA", "regenACF84CCF482|2024-08-30--03-21-55--0"), ("FORD", "regen755D8CB1E1F|2025-04-08--23-13-43--0"), ("RIVIAN", "regen5FCAC896BBE|2025-04-08--23-13-35--0"), - ("TESLA", "2c912ca5de3b1ee9|0000025d--6eb6bcbca4--4"), + ("TESLA", "aebd8f1d4ea16066|000002d8--5850d3ea5c--2"), ] # dashcamOnly makes don't need to be tested until a full port is done @@ -74,11 +74,19 @@ 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)) + ref_error = None + try: + ref_log_msgs = list(LogReader(ref_log_path)) + except Exception: + # New routes have no ref yet, still replay and publish new refs. + ref_log_msgs = [] + ref_error = f"Failed to load reference {ref_log_path}:\n{traceback.format_exc()}" lr = LogReader.from_bytes(lr_dat) 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) + if ref_error is not None: + return (segment, cfg.proc_name, ref_error, ref_error) try: diff_data = diff_process(cfg, ref_log_msgs, log_msgs) except Exception: diff --git a/openpilot/selfdrive/test/test_power_draw.py b/openpilot/selfdrive/test/test_power_draw.py index d8b234fe60..1d0be24ef0 100755 --- a/openpilot/selfdrive/test/test_power_draw.py +++ b/openpilot/selfdrive/test/test_power_draw.py @@ -5,8 +5,6 @@ import time import unittest import numpy as np from dataclasses import dataclass -from panda import Panda -from openpilot.common.hardware import HARDWARE from openpilot.common.test import OpenpilotTestCase from openpilot.common.utils import tabulate @@ -16,14 +14,11 @@ from opendbc.car.car_helpers import get_demo_car_params from openpilot.common.mock import mock_messages from openpilot.common.params import Params from openpilot.common.hardware.comma.power_monitor import get_power -from openpilot.selfdrive.modeld.helpers import chestnut_present from openpilot.system.manager.process_config import managed_processes from openpilot.system.manager.manager import manager_cleanup SAMPLE_TIME = 2 # seconds to sample power MAX_WARMUP_TIME = 30 # seconds to wait for SAMPLE_TIME consecutive valid samples -MICI = HARDWARE.get_device_type() == "mici" -CHESTNUT = chestnut_present() @dataclass class Proc: @@ -38,10 +33,9 @@ class Proc: return '+'.join(self.procs) -# MICI readings exclude the separately powered Chestnut GPU. PROCS = [ - Proc(['camerad'], 0.85 if MICI else 1.65, atol=0.4, msgs=['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']), - Proc(['modeld'], 0.45 if MICI and CHESTNUT else 1.5, atol=0.2, msgs=['modelV2']), + Proc(['camerad'], 1.65, atol=0.4, msgs=['narrowRoadCameraState', 'wideRoadCameraState', 'cabinCameraState']), + Proc(['modeld'], 1.5, atol=0.2, msgs=['modelV2']), Proc(['dmonitoringmodeld'], 0.65, atol=0.35, msgs=['driverStateV2']), Proc(['encoderd'], 0.23, msgs=[]), ] @@ -52,13 +46,6 @@ class TestPowerDraw(OpenpilotTestCase): def setup_method(self): Params().put("CarParams", get_demo_car_params().to_bytes(), block=True) - self.panda = None - if MICI: - HARDWARE.reset_internal_panda() - self.addCleanup(HARDWARE.reset_internal_panda) - Panda.wait_for_panda(None, 30) - self.panda = Panda(cli=False) - self.addCleanup(self.panda.close) def teardown_method(self): manager_cleanup() @@ -91,7 +78,7 @@ class TestPowerDraw(OpenpilotTestCase): start_time = time.monotonic() while (time.monotonic() - start_time) < MAX_WARMUP_TIME: - power = get_power(1, self.panda) + power = get_power(1) iteration_msg_counts = {} for msg,sock in socks.items(): iteration_msg_counts[msg] = len(messaging.drain_sock_raw(sock)) @@ -110,7 +97,7 @@ class TestPowerDraw(OpenpilotTestCase): @mock_messages(['deviceMotion']) def test_camera_procs(self, subtests): - baseline = get_power(panda=self.panda) + baseline = get_power() prev = baseline used = {} diff --git a/openpilot/selfdrive/ui/layouts/settings/toggles.py b/openpilot/selfdrive/ui/layouts/settings/toggles.py index ee76b7e4cc..943b84bc00 100644 --- a/openpilot/selfdrive/ui/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/layouts/settings/toggles.py @@ -3,10 +3,7 @@ from openpilot.common.params import Params, UnknownKeyName from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.list_view import multiple_button_item, toggle_item from openpilot.system.ui.widgets.scroller_tici import Scroller -from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog -from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop -from openpilot.system.ui.widgets import DialogResult from openpilot.selfdrive.ui.ui_state import ui_state if gui_app.sunnypilot_ui(): @@ -216,32 +213,11 @@ class TogglesLayout(Widget): icon = "experimental.png" if self._toggles["ExperimentalMode"].action_item.get_state() else "experimental_white.png" self._toggles["ExperimentalMode"].set_icon(icon) - def _handle_experimental_mode_toggle(self, state: bool): - confirmed = self._params.get_bool("ExperimentalModeConfirmed") - if state and not confirmed: - def confirm_callback(result: DialogResult): - if result == DialogResult.CONFIRM: - self._params.put_bool("ExperimentalMode", True, block=True) - self._params.put_bool("ExperimentalModeConfirmed", True, block=True) - else: - self._toggles["ExperimentalMode"].action_item.set_state(False) - self._update_experimental_mode_icon() - - # show confirmation dialog - content = (f"

{self._toggles['ExperimentalMode'].title}


" + - f"

{self._toggles['ExperimentalMode'].description}

") - dlg = ConfirmDialog(content, tr("Enable"), rich=True, callback=confirm_callback) - gui_app.push_widget(dlg) - else: - self._update_experimental_mode_icon() - self._params.put_bool("ExperimentalMode", state, block=True) - def _toggle_callback(self, state: bool, param: str): - if param == "ExperimentalMode": - self._handle_experimental_mode_toggle(state) - return - self._params.put_bool(param, state, block=True) + if param == "ExperimentalMode": + self._update_experimental_mode_icon() + if self._toggle_defs[param][3]: self._params.put_bool("OnroadCycleRequested", True, block=True) diff --git a/openpilot/selfdrive/ui/lib/prime_state.py b/openpilot/selfdrive/ui/lib/prime_state.py index 1fa87e31df..6b37675c9b 100644 --- a/openpilot/selfdrive/ui/lib/prime_state.py +++ b/openpilot/selfdrive/ui/lib/prime_state.py @@ -10,6 +10,7 @@ from openpilot.common.realtime import drop_realtime from openpilot.common.swaglog import cloudlog from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID from openpilot.selfdrive.ui.lib.api_helpers import get_token +from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert class PrimeType(IntEnum): @@ -22,6 +23,10 @@ class PrimeType(IntEnum): MAGENTA_NEW = 4 PURPLE = 5 +class Provider(str): + GOOGLE = "google" + GITHUB = "github" + APPLE = "apple" class PrimeState: FETCH_INTERVAL = 5.0 # seconds between API calls @@ -33,6 +38,13 @@ class PrimeState: self._lock = threading.Lock() self._session = requests.Session() # reuse session to reduce SSL handshake overhead self.prime_type: PrimeType = self._load_initial_state() + self._prime_trial_available = False + pairing_provider = os.getenv("PAIRING_PROVIDER") or self._params.get("PairingProvider") + self._pairing_provider: Provider | None = Provider(pairing_provider) if pairing_provider is not None else None + self._pairing_email: str | None = self._params.get("PairingEmail") + self._commacare = False + + set_offroad_alert("Offroad_Pairing", self.prime_type <= PrimeType.UNPAIRED) self._running = False self._thread = None @@ -56,18 +68,80 @@ class PrimeState: response = api_get(f"v1.1/devices/{dongle_id}", timeout=self.API_TIMEOUT, access_token=identity_token, session=self._session) if response.status_code == 200: data = response.json() + is_paired = data.get("is_paired", False) prime_type = data.get("prime_type", 0) self.set_type(PrimeType(prime_type) if is_paired else PrimeType.UNPAIRED) + if not is_paired: + self.set_provider(None, None) + + prime_trial_available = data.get("trial_claimed") is False and data.get("eligible_features", {}).get("prime", False) + self.set_prime_trial_available(prime_trial_available) + + commacare = data.get("commacare", False) + self.set_commacare(commacare) except Exception as e: cloudlog.error(f"Failed to fetch prime status: {e}") + def _fetch_pairing_provider(self) -> None: + dongle_id = self._params.get("DongleId") + if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID: + return + + try: + identity_token = get_token(dongle_id) + response = api_get(f"v1/devices/{dongle_id}/owner", timeout=self.API_TIMEOUT, access_token=identity_token, session=self._session) + if response.status_code == 200: + data = response.json() + user_id = data.get("user_id", "") + provider = Provider(user_id.partition("_")[0]) + email = data.get("email") + self.set_provider(provider, email) + except Exception as e: + cloudlog.error(f"Failed to fetch pairing provider: {e}") + def set_type(self, prime_type: PrimeType) -> None: with self._lock: if prime_type != self.prime_type: self.prime_type = prime_type self._params.put("PrimeType", int(prime_type)) cloudlog.info(f"Prime type updated to {prime_type}") + set_offroad_alert("Offroad_Pairing", self.prime_type <= PrimeType.UNPAIRED) + + def set_provider(self, provider: Provider | None, email: str | None): + with self._lock: + if self.prime_type <= PrimeType.UNPAIRED: + provider = None + email = None + email = email or None # if data.get(email) returns "" instead of None + + if self._pairing_provider != provider: + self._pairing_provider = provider + if provider is None: + self._params.remove("PairingProvider") + else: + self._params.put("PairingProvider", str(provider)) + + if self._pairing_email != email: + self._pairing_email = email + if email is None: + self._params.remove("PairingEmail") + else: + self._params.put("PairingEmail", email) + + def set_commacare(self, has_commacare: bool): + with self._lock: + if self.prime_type <= PrimeType.UNPAIRED: + self._commacare = False + else: + self._commacare = has_commacare + + def set_prime_trial_available(self, prime_trail_available: bool): + with self._lock: + if self.prime_type <= PrimeType.UNPAIRED: + self._prime_trial_available = False + else: + self._prime_trial_available = prime_trail_available def _worker_thread(self) -> None: drop_realtime() @@ -75,6 +149,7 @@ class PrimeState: while self._running: if not ui_state.started and device._awake: self._fetch_prime_status() + self._fetch_pairing_provider() for _ in range(int(self.FETCH_INTERVAL / self.SLEEP_INTERVAL)): if not self._running: @@ -109,5 +184,25 @@ class PrimeState: with self._lock: return self.prime_type > PrimeType.UNPAIRED + def can_claim_prime_trial(self) -> bool: + with self._lock: + return self._prime_trial_available + + def has_commacare(self) -> bool: + with self._lock: + return self._commacare + + def get_pairing_provider(self) -> str | None: + with self._lock: + return self._pairing_provider + + def get_pairing_account(self) -> str: + with self._lock: + if not self._pairing_provider: + return "unknown" + elif self._pairing_provider == Provider.GITHUB or not self._pairing_email: + return f"{self._pairing_provider} account" + return self._pairing_email + def __del__(self): self.stop() diff --git a/openpilot/selfdrive/ui/mici/layouts/home.py b/openpilot/selfdrive/ui/mici/layouts/home.py index 419475a603..87d9eea9ac 100644 --- a/openpilot/selfdrive/ui/mici/layouts/home.py +++ b/openpilot/selfdrive/ui/mici/layouts/home.py @@ -173,7 +173,7 @@ class MiciHomeLayout(Widget): if self._mouse_down_t is not None: if time.monotonic() - self._mouse_down_t > 0.5: # long gating for experimental mode - only allow toggle if longitudinal control is available - if ui_state.has_longitudinal_control and ui_state.experimental_mode_confirmed: + if ui_state.has_longitudinal_control: ui_state.experimental_mode = not ui_state.experimental_mode ui_state.params.put("ExperimentalMode", ui_state.experimental_mode, block=True) self._mouse_down_t = None diff --git a/openpilot/selfdrive/ui/mici/layouts/main.py b/openpilot/selfdrive/ui/mici/layouts/main.py index dc294f3185..306feb28c5 100644 --- a/openpilot/selfdrive/ui/mici/layouts/main.py +++ b/openpilot/selfdrive/ui/mici/layouts/main.py @@ -73,6 +73,8 @@ class MiciMainLayout(Scroller): return self._body_onroad_layout if ui_state.is_body else self._car_onroad_layout def _setup_callbacks(self): + self._alerts_layout.set_enabled(lambda: self.enabled) + self._alerts_layout.set_pairing_callback(self._settings_layout.show_pairing) self._home_layout.set_callbacks( on_settings=lambda: gui_app.push_widget(self._settings_layout), on_alerts=lambda: self._scroll_to(self._alerts_layout), diff --git a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py index 9c90181469..5cdb0ebd7e 100644 --- a/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py +++ b/openpilot/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -4,6 +4,7 @@ import pyray as rl import re import threading import time +from collections.abc import Callable from dataclasses import dataclass from enum import IntEnum from openpilot.common.params import Params @@ -232,6 +233,11 @@ class MiciOffroadAlerts(Scroller): def scrolling(self): return self._scroller.scroll_panel.is_touch_valid() + def set_pairing_callback(self, callback: Callable[[], None]): + for alert_item in self.alert_items: + if alert_item.alert_data.key == "Offroad_Pairing": + alert_item.set_click_callback(callback) + def _build_alerts(self): """Build sorted list of alerts from OFFROAD_ALERTS.""" self.sorted_alerts = [] diff --git a/openpilot/selfdrive/ui/mici/layouts/onboarding.py b/openpilot/selfdrive/ui/mici/layouts/onboarding.py index d138172da6..03743a1e8f 100644 --- a/openpilot/selfdrive/ui/mici/layouts/onboarding.py +++ b/openpilot/selfdrive/ui/mici/layouts/onboarding.py @@ -3,7 +3,7 @@ import numpy as np import pyray as rl from collections.abc import Callable from openpilot.common.filter_simple import FirstOrderFilter -from openpilot.common.qrcode import make_texture +from openpilot.selfdrive.ui.mici.widgets.qr import QR from openpilot.system.ui.lib.application import FontWeight, gui_app, TextAlignment from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import SmallCircleIconButton @@ -277,23 +277,6 @@ class TrainingGuide(NavWidget): self._steps[0].render(self._rect) -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 = make_texture(url, inverted=True) - - 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__() @@ -310,7 +293,7 @@ class TermsPage(Scroller): 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"), + QR("https://sunnypilot.ai/terms"), self._must_accept_card, self._accept_button, self._decline_button, diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device/__init__.py b/openpilot/selfdrive/ui/mici/layouts/settings/device/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device.py b/openpilot/selfdrive/ui/mici/layouts/settings/device/device_layout.py similarity index 73% rename from openpilot/selfdrive/ui/mici/layouts/settings/device.py rename to openpilot/selfdrive/ui/mici/layouts/settings/device/device_layout.py index c038f5fddc..20b0523444 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/device.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device/device_layout.py @@ -6,17 +6,16 @@ from typing import Union from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.time_helpers import system_time_valid +from openpilot.selfdrive.ui.mici.layouts.settings.device.prime import PrimeScroller from openpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller +from openpilot.selfdrive.ui.mici.widgets.info import InfoLayoutMici from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog -from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraDialog from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide, TermsPage -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, MousePos from openpilot.system.ui.lib.multilang import tr -from openpilot.system.ui.widgets import Widget 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 HtmlRenderer from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID @@ -91,70 +90,56 @@ class EngagedConfirmationButton(BigButton): self.set_click_callback(lambda: _engaged_confirmation_click(callback, action_text, icon, exit_on_confirm=exit_on_confirm, red=red)) -class DeviceInfoLayoutMici(Widget): +class DeviceInfoLayoutMici(InfoLayoutMici): def __init__(self): - super().__init__() - - self.set_rect(rl.Rectangle(0, 0, 360, 180)) - params = Params() - subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) - max_width = int(self._rect.width - 20) - 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 = 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) - self._dongle_id_label.render() - - self._dongle_id_text_label.set_position(self._rect.x + 20, self._rect.y + 68 - 25) - self._dongle_id_text_label.render() - - self._serial_number_label.set_position(self._rect.x + 20, self._rect.y + 114 - 30) - self._serial_number_label.render() - - self._serial_number_text_label.set_position(self._rect.x + 20, self._rect.y + 161 - 25) - self._serial_number_text_label.render() + super().__init__("device ID", params.get("DongleId") or 'N/A', "serial", params.get("HardwareSerial") or 'N/A', width=380) class PairBigButton(BigButton): def __init__(self): - 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 + self._commacare_badge = gui_app.texture("icons_mici/settings/device/commacare.png", 27, 32) + self._show_commacare = False + self._comma_icon = gui_app.texture("icons_mici/settings/comma_icon.png", 33, 60) + self._provider_icons = {provider: gui_app.texture(f"icons_mici/settings/device/paired_{provider}.png", 64, 64) + for provider in ("github", "google", "apple")} + super().__init__("pair to connect", "connect.comma.ai", self._comma_icon) def _update_state(self): super()._update_state() + self._show_commacare = (ui_state.prime_state.is_paired() and ui_state.prime_state.is_prime() + and ui_state.prime_state.has_commacare()) if ui_state.prime_state.is_paired(): + self.set_icon(self._provider_icons.get(ui_state.prime_state.get_pairing_provider(), self._comma_icon)) self.set_text("paired") if ui_state.prime_state.is_prime(): - self.set_value("subscribed") + self.set_value("prime" if ui_state.prime_state.is_full_prime() else "prime lite") else: - self.set_value("upgrade to prime") + self.set_value("claim prime trial" if ui_state.prime_state.can_claim_prime_trial() else "upgrade to prime") else: - self.set_text("pair") + self.set_icon(self._comma_icon) + self.set_text("pair to connect") self.set_value("connect.comma.ai") + def _draw_subtitle(self, rect: rl.Rectangle): + if self._show_commacare: + y = rect.y + rect.height - self._commacare_badge.height + rl.draw_texture_v(self._commacare_badge, rl.Vector2(rect.x, y), rl.WHITE) + offset = self._commacare_badge.width + 14 + rect = rl.Rectangle(rect.x + offset, rect.y, rect.width - offset, rect.height) + super()._draw_subtitle(rect) + def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) - # TODO: show ad dialog when clicked if not prime - if ui_state.prime_state.is_paired(): - return - dlg: BigDialog | PairingDialog + dlg: BigDialog | PrimeScroller if not system_time_valid(): 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.")) else: - dlg = PairingDialog() + dlg = PrimeScroller() gui_app.push_widget(dlg) @@ -163,6 +148,8 @@ class DeviceLayoutMici(NavScroller): super().__init__() self._fcc_dialog: MiciFccModal | None = None + self._pairing_button = PairBigButton() + self._pending_pairing_grow_animation = False def power_off_callback(): ui_state.params.put_bool("DoShutdown", True, block=True) @@ -207,7 +194,7 @@ class DeviceLayoutMici(NavScroller): self._scroller.add_widgets([ DeviceInfoLayoutMici(), - PairBigButton(), + self._pairing_button, review_training_guide_btn, cabin_cam_btn, terms_btn, @@ -217,6 +204,31 @@ class DeviceLayoutMici(NavScroller): self._power_off_btn, ]) + def _update_state(self): + super()._update_state() + if self._pending_pairing_grow_animation: + btn_right = self._pairing_button.rect.x + self._pairing_button.rect.width + visible_right = self._rect.x + self._rect.width + if btn_right < visible_right + 50: + self._pending_pairing_grow_animation = False + self._pairing_button.trigger_grow_animation() + + def show_event(self): + super().show_event() + if not ui_state.prime_state.is_paired(): + self.set_shown_callback(self._pairing_button.trigger_grow_animation) + + def hide_event(self): + super().hide_event() + self.set_shown_callback(None) + self._pending_pairing_grow_animation = False + + def scroll_to_pairing(self): + self._scroller._layout() + offset = (self._pairing_button.rect.x + self._pairing_button.rect.width / 2) - (self._rect.x + self._rect.width / 2) + self._scroller.scroll_to(offset, smooth=True, block_interrupt=True, block_widget_interaction=True) + self._pending_pairing_grow_animation = True + def _on_regulatory(self): if not self._fcc_dialog: self._fcc_dialog = MiciFccModal(os.path.join(BASEDIR, "openpilot/selfdrive/assets/offroad/mici_fcc.html")) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py b/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py new file mode 100644 index 0000000000..9a914dff4b --- /dev/null +++ b/openpilot/selfdrive/ui/mici/layouts/settings/device/prime.py @@ -0,0 +1,171 @@ +import pyray as rl +import time + +from openpilot.common.api import Api +from openpilot.common.swaglog import cloudlog +from openpilot.common.params import Params +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, GreyBigButton +from openpilot.selfdrive.ui.mici.widgets.info import InfoLayoutMici +from openpilot.selfdrive.ui.mici.widgets.qr import QR +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets.scroller import NavScroller + + +class PairingInfoLayout(InfoLayoutMici): + def __init__(self): + super().__init__("account", "", "status", "") + self._commacare_badge = gui_app.texture("icons_mici/settings/device/commacare.png", 27, 32) + self._provider_icons = {provider: gui_app.texture(f"icons_mici/settings/device/{provider}.png", 32, 32) + for provider in ("github", "google", "apple")} + + def _update_state(self): + super()._update_state() + self.subtext1.set_text(ui_state.prime_state.get_pairing_account()) + self.subtext2.set_text(self._get_prime_status()) + self._provider_icon = self._provider_icons.get(ui_state.prime_state.get_pairing_provider()) + self._show_commacare = ui_state.prime_state.has_commacare() + + @staticmethod + def _get_prime_status() -> str: + if ui_state.prime_state.is_prime(): + return "prime" if ui_state.prime_state.is_full_prime() else "prime lite" + return "not subscribed" + + def _layout(self): + super()._layout() + provider_offset = self._provider_icon.width + 14 if self._provider_icon else 0 + commacare_offset = self._commacare_badge.width + 14 if self._show_commacare else 0 + self.subtext1.set_position(self.subtext1.rect.x + provider_offset, self.subtext1.rect.y) + self.subtext1.set_max_width(int(self._rect.width - provider_offset - (40 if self._provider_icon else 20))) + self.subtext2.set_position(self.subtext2.rect.x + commacare_offset, self.subtext2.rect.y) + self.subtext2.set_max_width(int(self._rect.width - commacare_offset - (40 if self._show_commacare else 20))) + + def _render(self, rect: rl.Rectangle): + super()._render(rect) + + if self._provider_icon: + icon_pos = rl.Vector2(self._rect.x + 20, self.subtext1.rect.y + (self.subtext1.rect.height - self._provider_icon.height) / 2) + rl.draw_texture_v(self._provider_icon, icon_pos, self._subheader_color) + if self._show_commacare: + icon_pos = rl.Vector2(self._rect.x + 20, self.subtext2.rect.y + (self.subtext2.rect.height - self._commacare_badge.height) / 2 + 4) + rl.draw_texture_v(self._commacare_badge, icon_pos, rl.WHITE) + + +class PrimeManagementScroller(NavScroller): + """Prime subscription management and upgrade information.""" + + def __init__(self): + super().__init__() + self._params = Params() + + can_claim_trial = ui_state.prime_state.can_claim_prime_trial() + + self._prime_icon = gui_app.texture("icons_mici/settings/device/green_cell.png", 64, 64) + self._phone_icon = gui_app.texture("icons_mici/settings/device/phone.png", 85, 64) + self._prime_adverts = [ + QR(self._get_prime_url()), + GreyBigButton( + "try prime for\n30 days" if can_claim_trial else "upgrade to prime", + "scan to claim trial" if can_claim_trial else "scan to manage\nprime status", + self._prime_icon + ), + GreyBigButton("", "prime adds 24/7 LTE to device and 1 year of cloud storage in connect.",), + GreyBigButton("", "prime lets you view live video and GPS location remotely in connect.",), + GreyBigButton("", "prime also includes commacare extended device warranty.",), + ] + self._prime_management = [ + QR(self._get_prime_url()), + GreyBigButton( + "manage prime", + "scan to open\ndevice prime settings", + self._phone_icon + ), + ] + self._scroller.add_widgets(self._prime_management if ui_state.prime_state.is_prime() else self._prime_adverts) + + def _get_prime_url(self) -> str: + if dongle_id := self._params.get("DongleId"): + return f"https://connect.comma.ai/{dongle_id}/prime" + return "https://connect.comma.ai" + + def _update_state(self): + super()._update_state() + if not ui_state.prime_state.is_paired(): + self.dismiss() + + +class PrimeScroller(NavScroller): + """Pairing flow or paired account information with access to prime management.""" + + QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds + + def __init__(self): + super().__init__() + self._params = Params() + self.initial_is_paired = ui_state.prime_state.is_paired() + self._last_pairing_qr_generation = float("-inf") + + self._manage_icon = gui_app.texture("icons_mici/settings/comma_icon.png", 33, 60) + if not ui_state.prime_state.is_prime(): + title = "claim\nprime trial" if ui_state.prime_state.can_claim_prime_trial() else "upgrade\nto prime" + self._manage_prime = BigButton(title, icon=self._manage_icon) + else: + self._manage_prime = BigButton("manage prime", icon=self._manage_icon) + self._manage_prime.set_click_callback(lambda: gui_app.push_widget(PrimeManagementScroller())) + + if self.initial_is_paired: + self._scroller.add_widgets([ + PairingInfoLayout(), + self._manage_prime, + ]) + else: + self._qr = QR(self._get_pairing_url()) + self._scroller.scroll_indicator_start_after = self._qr + self._scroller.add_widgets([ + self._qr, + GreyBigButton("finish setup", "scan QR code or visit connect.comma.ai", + gui_app.texture("icons_mici/settings/device/green_settings.png", 64, 64)), + GreyBigButton("", "connect lets you review recent driving footage and bookmark events."), + ]) + + def _get_pairing_url(self) -> str: + try: + dongle_id = self._params.get("DongleId") or "" + token = Api(dongle_id).get_token({'pair': True}) + except Exception as e: + cloudlog.warning(f"Failed to get pairing token: {e}") + token = "" + return f"https://connect.comma.ai/?pair={token}" + + def _update_layout_rects(self): + super()._update_layout_rects() + if not self.initial_is_paired: + self._qr.set_rect(rl.Rectangle(self._qr.rect.x, self._qr.rect.y, self._rect.height, self._rect.height)) + + def _render(self, rect: rl.Rectangle): + if not self.initial_is_paired: + current_time = time.monotonic() + if current_time - self._last_pairing_qr_generation >= self.QR_REFRESH_INTERVAL: + self._qr._url = self._get_pairing_url() + if self._qr._texture and self._qr._texture.id != 0: + rl.unload_texture(self._qr._texture) + self._qr._texture = self._qr._generate_qr_code() + self._last_pairing_qr_generation = current_time + super()._render(rect) + + def _update_state(self): + super()._update_state() + if self.initial_is_paired != ui_state.prime_state.is_paired() and gui_app.get_active_widget() is self: + self.dismiss() + + +if __name__ == "__main__": + gui_app.init_window("pairing device") + pairing = PrimeScroller() + gui_app.push_widget(pairing) + try: + for _ in gui_app.render(): + pass + finally: + del pairing diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py index 4203336469..d38d640bdd 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -30,7 +30,7 @@ class NetworkLayoutMici(NavScroller): self._wifi_manager.set_tethering_active(checked) self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback, - description="Share the device’s internet connection through a Wi-Fi hotspot.") + description="Share the device's internet connection through a Wi-Fi hotspot.") def tethering_password_callback(password: str): if password: @@ -61,7 +61,7 @@ class NetworkLayoutMici(NavScroller): # TODO: signal for current network metered type when changing networks, this is wrong until you press it once # TODO: disable when not connected self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback, - description="Metered prevents large uploads on this Wi-Fi connection. Default uses the network’s detected " + + description="Metered prevents large uploads on this Wi-Fi connection. Default uses the network's detected " + "setting.") self._network_metered_btn.set_enabled(False) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py index 477bd78935..c979fe67a5 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/settings.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/settings.py @@ -3,7 +3,7 @@ from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.network.network_layout import NetworkLayoutMici -from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici +from openpilot.selfdrive.ui.mici.layouts.settings.device.device_layout import DeviceLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.software import SoftwareLayoutMici from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout @@ -28,9 +28,9 @@ class SettingsLayout(NavScroller): 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", "", gui_app.texture("icons_mici/settings/device_icon.png", 72, 58)) - device_btn.set_click_callback(lambda: gui_app.push_widget(device_panel)) + self._device_panel = DeviceLayoutMici() + self._device_button = SettingsBigButton("device", "", gui_app.texture("icons_mici/settings/device_icon.png", 72, 58)) + self._device_button.set_click_callback(lambda: gui_app.push_widget(self._device_panel)) software_panel = SoftwareLayoutMici() software_btn = SettingsBigButton("software", "", gui_app.texture("icons_mici/settings/software.png", 64, 75)) @@ -47,10 +47,28 @@ class SettingsLayout(NavScroller): self._scroller.add_widgets([ toggles_btn, network_btn, - device_btn, + self._device_button, software_btn, firehose_btn, developer_btn, ]) self._font_medium = gui_app.font(FontWeight.MEDIUM) + + def show_pairing(self): + gui_app.push_widget(self) + # add settings in the back stack without showing its entrance animation. + self._y_pos_filter.x = 0.0 + self.set_visible(lambda: self.enabled or self._device_panel.is_dismissing) + self.set_shown_callback(self._scroll_to_device) + gui_app.push_widget(self._device_panel) + self._device_panel.set_shown_callback(self._on_pairing_shown) + + def _scroll_to_device(self): + self._scroller._layout() + offset = (self._device_button.rect.x + self._device_button.rect.width / 2) - (self._rect.x + self._rect.width / 2) + self._scroller.scroll_to(offset, smooth=False) + + def _on_pairing_shown(self): + self.set_visible(True) + self._device_panel.scroll_to_pairing() diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index 15be56d36e..fee5b7ddb5 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -5,14 +5,13 @@ from enum import IntEnum from collections.abc import Callable from openpilot.common.time_helpers import system_time_valid -from openpilot.selfdrive.ui.mici.layouts.settings.device import EngagedConfirmationButton +from openpilot.selfdrive.ui.mici.layouts.settings.device.device_layout import EngagedConfirmationButton +from openpilot.selfdrive.ui.mici.widgets.info import InfoLayoutMici from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos +from openpilot.system.ui.lib.application import gui_app, MousePos 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 UPDATER_TIMEOUT = 10.0 # seconds to wait for updater to respond @@ -33,44 +32,21 @@ class UpdaterState(IntEnum): UPDATER_RESPONDING = 2 -class SoftwareInfoLayoutMici(Widget): +class SoftwareInfoLayoutMici(InfoLayoutMici): def __init__(self): - super().__init__() - - self.set_rect(rl.Rectangle(0, 0, 360, 180)) - - subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) - max_width = int(self._rect.width - 20) - self._version_label = UnifiedLabel("version", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) - self._version_text_label = UnifiedLabel("", 32, max_width=max_width, text_color=subheader_color, - font_weight=FontWeight.ROMAN, wrap_text=False) - - self._branch_label = UnifiedLabel("branch", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) - self._branch_text_label = UnifiedLabel("", 32, max_width=max_width, text_color=subheader_color, - font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True) + super().__init__("version", "", "branch", "") def _update_state(self): + super()._update_state() + desc = _split_description(ui_state.params.get("UpdaterCurrentDescription") or "") if desc is not None: version, branch, commit, date = desc - self._version_text_label.set_text(f"{version} ({date})") - self._branch_text_label.set_text(f"{branch} ({commit})") + self.subtext1.set_text(f"{version} ({date})") + self.subtext2.set_text(f"{branch} ({commit})") else: - self._version_text_label.set_text(ui_state.params.get("Version") or "N/A") - self._branch_text_label.set_text(ui_state.params.get("GitBranch") or "N/A") - - def _render(self, _): - self._version_label.set_position(self._rect.x + 20, self._rect.y - 10) - self._version_label.render() - - self._version_text_label.set_position(self._rect.x + 20, self._rect.y + 68 - 25) - self._version_text_label.render() - - self._branch_label.set_position(self._rect.x + 20, self._rect.y + 114 - 30) - self._branch_label.render() - - self._branch_text_label.set_position(self._rect.x + 20, self._rect.y + 161 - 25) - self._branch_text_label.render() + self.subtext1.set_text(ui_state.params.get("Version") or "N/A") + self.subtext2.set_text(ui_state.params.get("GitBranch") or "N/A") class CheckUpdateButton(BigButton): diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py index 78f6085e87..60d5079ef3 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/toggles.py @@ -1,10 +1,7 @@ -from collections.abc import Callable - from openpilot.cereal import log from openpilot.system.ui.widgets.scroller import NavScroller -from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle, BigToggle, GreyBigButton -from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationCircleButton +from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle 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 @@ -12,31 +9,6 @@ from openpilot.selfdrive.ui.ui_state import ui_state PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants -class ExperimentalModeConfirmPage(NavScroller): - def __init__(self, on_confirm: Callable[[], None]): - super().__init__() - - accept = BigConfirmationCircleButton("enable\nexperimental mode", - gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 64, 64), - lambda: self.dismiss(on_confirm)) - - self._scroller.add_widgets([ - GreyBigButton("enabling\nexperimental mode", "scroll to continue", - gui_app.texture("icons_mici/setup/warning.png", 64, 64)), - GreyBigButton("", "openpilot defaults to driving in chill mode."), - GreyBigButton("", "Experimental mode enables alpha-level features that aren't ready for chill mode."), - GreyBigButton("End-to-End Longitudinal Control"), - GreyBigButton("", "Let the driving model control the gas and brakes."), - GreyBigButton("", "openpilot will drive as it thinks a human would, including stopping for red lights and stop signs."), - GreyBigButton("", "The set speed will only act as an upper bound."), - GreyBigButton("", "This is an alpha quality feature; mistakes should be expected."), - GreyBigButton("New Driving Visualization"), - GreyBigButton("", "The path will change colors to communicate acceleration intent."), - GreyBigButton("", "Red for braking, green for acceleration, and gray for coasting."), - accept, - ]) - - class TogglesLayoutMici(NavScroller): def __init__(self): super().__init__() @@ -46,8 +18,8 @@ class TogglesLayoutMici(NavScroller): "Aggressive follows closer, with firmer gas and braking.\n" + "Relaxed leaves more space.\n" + "Use the steering wheel distance button on supported cars.") - self._experimental_btn = BigToggle("experimental mode", description_icon=gui_app.texture("icons_mici/experimental_mode.png", 64, 64), - initial_state=ui_state.params.get_bool("ExperimentalMode"), toggle_callback=self._on_experimental_mode, + self._experimental_btn = BigParamControl("experimental mode", "ExperimentalMode", + description_icon=gui_app.texture("icons_mici/experimental_mode.png", 64, 64), description="Let the driving model control gas and brakes.\n" + "Includes stopping for red lights and stop signs.\n" + "Set speed is a maximum, not a target.\n" + @@ -133,17 +105,3 @@ class TogglesLayoutMici(NavScroller): # Refresh toggles from params to mirror external changes for key, item in self._refresh_toggles: item.set_checked(ui_state.params.get_bool(key)) - - def _on_experimental_mode(self, state: bool): - if state and not ui_state.params.get_bool("ExperimentalModeConfirmed"): - # Don't show enabled state until confirm - self._experimental_btn.set_checked(False) - - def on_confirm(): - ui_state.params.put_bool("ExperimentalModeConfirmed", True) - ui_state.params.put_bool("ExperimentalMode", True) - self._experimental_btn.set_checked(True) - - gui_app.push_widget(ExperimentalModeConfirmPage(on_confirm)) - else: - ui_state.params.put_bool("ExperimentalMode", state) diff --git a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py index 71ea7e8f8f..2ef0f8d498 100644 --- a/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/mici/onroad/augmented_road_view.py @@ -8,13 +8,14 @@ from openpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH from openpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer +from openpilot.selfdrive.ui.mici.onroad.long_indicator import LongIndicator from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent, TextAlignment, TextAlignmentVertical from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets import Widget -from openpilot.common.filter_simple import BounceFilter +from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame from openpilot.common.transformations.orientation import rot_from_euler from enum import IntEnum @@ -35,14 +36,14 @@ class BookmarkState(IntEnum): DRAGGING = 1 TRIGGERED = 2 -WIDE_CAM_MAX_SPEED = 5.0 # m/s (10 mph) -ROAD_CAM_MIN_SPEED = 10 # m/s (25 mph) +WIDE_CAM_MAX_SPEED = 5.0 # m/s (11 mph) +ROAD_CAM_MIN_SPEED = 10.0 # m/s (22 mph) CAM_Y_OFFSET = 20 class BookmarkIcon(Widget): - PEEK_THRESHOLD = 50 # If icon peeks out this much, snap it fully visible + PEEK_THRESHOLD = 150 # If icon peeks out this much, snap it fully visible FULL_VISIBLE_OFFSET = 200 # How far onscreen when fully visible HIDDEN_OFFSET = -50 # How far offscreen when hidden @@ -50,7 +51,9 @@ class BookmarkIcon(Widget): super().__init__() self._bookmark_callback = bookmark_callback self._icon = gui_app.texture("icons_mici/onroad/bookmark.png", 180, 180) + self._filled_icon = gui_app.texture("icons_mici/onroad/bookmark_fill.png", 180, 180) self._offset_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps) + self._active_alpha = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) # State self._interacting = False @@ -60,6 +63,8 @@ class BookmarkIcon(Widget): self._is_swiping = False self._is_swiping_left: bool = False self._triggered_time: float = 0.0 + self._triggered_duration = 1.5 + self._swipe_expired = False def is_swiping_left(self) -> bool: """Check if currently swiping left (for scroller to disable).""" @@ -70,18 +75,25 @@ class BookmarkIcon(Widget): return interacting def _update_state(self): + if self._state == BookmarkState.TRIGGERED and rl.get_time() - self._triggered_time >= self._triggered_duration: + self._state = BookmarkState.HIDDEN + self._swipe_expired = self._is_swiping + + swipe_offset = self._swipe_start_x - self._swipe_current_x + armed = self._state == BookmarkState.DRAGGING and swipe_offset > self.PEEK_THRESHOLD if self._state == BookmarkState.DRAGGING: - # Allow pulling past activated position with rubber band effect - swipe_offset = self._swipe_start_x - self._swipe_current_x + # Snap to the released position when armed, while allowing further dragging or cancellation. + if armed: + swipe_offset += self.FULL_VISIBLE_OFFSET - self.PEEK_THRESHOLD swipe_offset = min(swipe_offset, self.FULL_VISIBLE_OFFSET + 50) self._offset_filter.update(swipe_offset) elif self._state == BookmarkState.TRIGGERED: - # Continue animating to fully visible - self._offset_filter.update(self.FULL_VISIBLE_OFFSET) - # Stay in TRIGGERED state for 1 second - if rl.get_time() - self._triggered_time >= 1.5: - self._state = BookmarkState.HIDDEN + # Let another left swipe move the same bookmark. + offset = self.FULL_VISIBLE_OFFSET + if self._is_swiping and self._is_swiping_left: + offset += min(swipe_offset, 50) + self._offset_filter.update(offset) elif self._state == BookmarkState.HIDDEN: self._offset_filter.update(self.HIDDEN_OFFSET) @@ -89,17 +101,23 @@ class BookmarkIcon(Widget): if self._offset_filter.x < 1e-3: self._interacting = False + self._active_alpha.update(float(armed or self._state == BookmarkState.TRIGGERED)) + def _handle_mouse_event(self, mouse_event: MouseEvent): if not ui_state.started: return if mouse_event.left_pressed: + if self._state == BookmarkState.TRIGGERED: + self._triggered_time = rl.get_time() + self._triggered_duration = 0.5 + # Store relative position within widget self._swipe_start_x = mouse_event.pos.x self._swipe_current_x = mouse_event.pos.x self._is_swiping = True self._is_swiping_left = False - self._state = BookmarkState.DRAGGING + self._swipe_expired = False elif mouse_event.left_down and self._is_swiping: self._swipe_current_x = mouse_event.pos.x @@ -107,30 +125,34 @@ class BookmarkIcon(Widget): self._is_swiping_left = swipe_offset > 0 if self._is_swiping_left: self._interacting = True + if self._state == BookmarkState.HIDDEN and not self._swipe_expired: + self._state = BookmarkState.DRAGGING elif mouse_event.left_released: - if self._is_swiping: + if self._is_swiping and self._state == BookmarkState.DRAGGING: swipe_distance = self._swipe_start_x - self._swipe_current_x # If peeking past threshold, transition to animating to fully visible and bookmark if swipe_distance > self.PEEK_THRESHOLD: self._state = BookmarkState.TRIGGERED self._triggered_time = rl.get_time() + self._triggered_duration = 1.5 self._bookmark_callback() else: # Otherwise, transition back to hidden self._state = BookmarkState.HIDDEN - # Reset swipe state - self._is_swiping = False - self._is_swiping_left = False + # Reset swipe state + self._is_swiping = False + self._is_swiping_left = False def _render(self, _): """Render the bookmark icon.""" 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_ex(self._icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE) + for icon, alpha in ((self._icon, 1.0 - self._active_alpha.x), (self._filled_icon, self._active_alpha.x)): + rl.draw_texture_ex(icon, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.Color(255, 255, 255, round(255 * alpha))) class AugmentedRoadView(CameraView): @@ -153,6 +175,7 @@ class AugmentedRoadView(CameraView): self._model_renderer = ModelRenderer() self._hud_renderer = HudRenderer() + self._long_indicator = LongIndicator() self._alert_renderer = AlertRenderer() self._driver_state_renderer = DriverStateRenderer() self._confidence_ball = ConfidenceBall() @@ -229,6 +252,8 @@ class AugmentedRoadView(CameraView): self._driver_state_renderer.set_should_draw(should_draw_dmoji) self._driver_state_renderer.set_position(self._rect.x + 16, self._rect.y + 10) self._driver_state_renderer.render() + self._long_indicator.set_should_draw(not self._hud_renderer.drawing_top_icons()) + self._long_indicator.render(self._content_rect) self._hud_renderer.set_can_draw_top_icons(alert_to_render is None) self._hud_renderer.set_wheel_critical_icon(alert_to_render is not None and not not_animating_out and diff --git a/openpilot/selfdrive/ui/mici/onroad/long_indicator.py b/openpilot/selfdrive/ui/mici/onroad/long_indicator.py new file mode 100644 index 0000000000..1ff83082a2 --- /dev/null +++ b/openpilot/selfdrive/ui/mici/onroad/long_indicator.py @@ -0,0 +1,89 @@ +import pyray as rl +from openpilot.cereal import log +from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets import Widget + +HIGHLIGHT_TIME = 2.5 # seconds + + +class LongIndicator(Widget): + def __init__(self): + super().__init__() + self._txt_lead_car = (self._texture('car', 35, 27), self._texture('car_green', 50, 42)) + self._txt_distance = [(self._texture('distance_1', 32, 7), self._texture('distance_1_green', 60, 35)), + (self._texture('distance_2', 40, 9), self._texture('distance_2_green', 68, 37)), + (self._texture('distance_3', 48, 11), self._texture('distance_3_green', 76, 39))] + self._alpha_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps) + # crossfade between states, a white and a green filter per icon + self._lead_car_filters = (FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps), FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)) + self._distance_filters = [(FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps), FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)) + for _ in range(3)] + self._personality: int | None = None + self._personality_changed_time = -HIGHLIGHT_TIME + self._should_draw = False + + @staticmethod + def _texture(name: str, width: int, height: int) -> rl.Texture: + return gui_app.texture(f'icons_mici/longitudinal/{name}.png', width, height, keep_aspect_ratio=False) + + def set_should_draw(self, should_draw: bool): + self._should_draw = should_draw + + def _render(self, rect: rl.Rectangle) -> None: + sm = ui_state.sm + if sm.recv_frame['selfdriveState'] < ui_state.started_frame or not sm['selfdriveState'].enabled or not ui_state.has_longitudinal_control: + self._personality = None + self._personality_changed_time = -HIGHLIGHT_TIME + self._alpha_filter.x = 0.0 + return + + # hidden under alerts and set speed + visible = self._should_draw and sm['selfdriveState'].alertSize == log.SelfdriveState.AlertSize.none + alpha = self._alpha_filter.update(visible) + self._draw_lead_car(rect, alpha) + self._draw_distance_bars(rect, alpha, visible) + + def _draw_lead_car(self, rect: rl.Rectangle, alpha: float) -> None: + sm = ui_state.sm + plan = sm['longitudinalPlan'] + has_lead = sm.alive['longitudinalPlan'] and plan.hasLead + + e2e = has_lead and plan.longitudinalPlanSource == log.LongitudinalPlan.LongitudinalPlanSource.e2e + white_f, green_f = self._lead_car_filters + white_alpha = white_f.update(0.0 if e2e else 0.9 if has_lead else 0.35) + green_alpha = green_f.update(float(e2e)) + + white, green = self._txt_lead_car + self._draw_centered(white, rect, 100, white_alpha * alpha) + self._draw_centered(green, rect, 100, green_alpha * alpha) + + def _draw_distance_bars(self, rect: rl.Rectangle, alpha: float, visible: bool) -> None: + sm = ui_state.sm + now = rl.get_time() + personality = sm['selfdriveState'].personality.raw + if self._personality is not None and personality != self._personality: + self._personality_changed_time = now + self._personality = personality + # the personality alert covers the bars, hold the highlight until they show + if not visible and now - self._personality_changed_time < HIGHLIGHT_TIME: + self._personality_changed_time = now + highlight = now - self._personality_changed_time < HIGHLIGHT_TIME + + # blink at double the turn signal rate (2.67 Hz) while overriding the gas + overriding = any(e.name == log.OnroadEvent.EventName.gasPressedOverride for e in sm['onroadEvents']) + blink = 0.35 / 0.9 if overriding and now % 0.375 > 0.1875 else 1.0 + + count = personality + 1 + for i, ((white, green), y, (active_f, green_f)) in enumerate(zip(self._txt_distance, (122, 136, 152), self._distance_filters, strict=True)): + active = active_f.update(float(i < count)) + green_alpha = green_f.update(float(highlight and i == count - 1)) + # only lit bars blink on override + self._draw_centered(white, rect, y, (0.35 * (1 - active) + 0.9 * (active - green_alpha) * blink) * alpha) + self._draw_centered(green, rect, y, green_alpha * blink * alpha) + + @staticmethod + def _draw_centered(texture: rl.Texture, rect: rl.Rectangle, y: float, alpha: float) -> None: + pos = rl.Vector2(rect.x + 46 - texture.width / 2, rect.y + y - texture.height / 2) + rl.draw_texture_ex(texture, pos, 0.0, 1.0, rl.Color(255, 255, 255, round(255 * alpha))) diff --git a/openpilot/selfdrive/ui/mici/onroad/model_renderer.py b/openpilot/selfdrive/ui/mici/onroad/model_renderer.py index 805bdcc335..0bf74cfe8a 100644 --- a/openpilot/selfdrive/ui/mici/onroad/model_renderer.py +++ b/openpilot/selfdrive/ui/mici/onroad/model_renderer.py @@ -1,11 +1,12 @@ import colorsys import numpy as np import pyray as rl -from openpilot.cereal import messaging +from openpilot.cereal import log, messaging from opendbc.car.structs import car from dataclasses import dataclass, field from openpilot.common.params import Params from openpilot.common.filter_simple import FirstOrderFilter +from openpilot.selfdrive.controls.radard import RADAR_TO_CAMERA from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.selfdrive.ui.mici.onroad import blend_colors @@ -18,6 +19,8 @@ from openpilot.selfdrive.ui.sunnypilot.mici.onroad.model_renderer import LANE_LI CLIP_MARGIN = 500 MIN_DRAW_DISTANCE = 10.0 MAX_DRAW_DISTANCE = 100.0 +LEAD_BAR_LENGTH = 12.0 # px +LEAD_BAR_WIDTH = 1.8 # m THROTTLE_COLORS = [ rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4) @@ -45,11 +48,12 @@ class ModelPoints: projected_points: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=np.float32)) -@dataclass class LeadVehicle: - glow: list[tuple[float, float]] = field(default_factory=list) - chevron: list[tuple[float, float]] = field(default_factory=list) - fill_alpha: int = 0 + def __init__(self): + self.bar = np.empty((0, 2), dtype=np.float32) + self.d_filter = FirstOrderFilter(0.0, 0.2, 1 / gui_app.target_fps, initialized=False) + self.y_filter = FirstOrderFilter(0.0, 0.2, 1 / gui_app.target_fps, initialized=False) + self.fade_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) class ModelRenderer(Widget, ModelRendererSP): @@ -145,8 +149,6 @@ class ModelRenderer(Widget, ModelRendererSP): return self._update_model(lead_one, path_x_array) - if render_lead_indicator: - self._update_leads(radar_state, path_x_array) self._transform_dirty = False # Draw elements (hide when disengaged) @@ -154,8 +156,11 @@ class ModelRenderer(Widget, ModelRendererSP): self._draw_lane_lines() self._draw_path(sm) - # if render_lead_indicator and radar_state: - # self._draw_lead_indicator() + if render_lead_indicator: + self._update_leads(sm) + self._draw_lead_indicator() + else: + self._lead_vehicles = [LeadVehicle(), LeadVehicle()] def _update_raw_points(self, model): """Update raw 3D points from model data""" @@ -171,21 +176,45 @@ class ModelRenderer(Widget, ModelRendererSP): self._road_edge_stds = np.array(model.roadEdgeStds, dtype=np.float32) self._acceleration_x = np.array(model.acceleration.x, dtype=np.float32) - def _update_leads(self, radar_state, path_x_array): - """Update positions of lead vehicles""" - self._lead_vehicles = [LeadVehicle(), LeadVehicle()] - leads = [radar_state.leadOne, radar_state.leadTwo] + def _update_leads(self, sm): + plan = sm['longitudinalPlan'] + if plan.longitudinalPlanSource == log.LongitudinalPlan.LongitudinalPlanSource.e2e and len(sm['modelV2'].leadsV3) > 1: + leads = [(lead.prob > 0.5, lead.x[0], -lead.y[0]) for lead in list(sm['modelV2'].leadsV3)[:2]] + else: + radar = sm['radarState'] + leads = [(lead.present, lead.dRel + RADAR_TO_CAMERA, lead.yRel) for lead in (radar.leadOne, radar.leadTwo)] - for i, lead_data in enumerate(leads): - if lead_data and lead_data.present: - d_rel, y_rel, v_rel = lead_data.dRel, lead_data.yRel, lead_data.vRel - idx = self._get_path_length_idx(path_x_array, d_rel) + # both leads can be the same vehicle + if leads[0][0] and abs(leads[1][1] - leads[0][1]) < 3.0: + leads[1] = (False, 0.0, 0.0) - # Get z-coordinate from path at the lead vehicle position - z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0 - point = self._map_to_screen(d_rel, -y_rel + self._camera_offset, z + self._path_offset_z) - if point: - self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect) + ss, cs = sm['selfdriveState'], sm['carState'] + # braking disengages without making openpilot unavailable + available = ss.enabled or ss.engageable or cs.brakePressed + lane = self._path.raw_points + opacity = 0.4 if ui_state.status == UIStatus.DISENGAGED else 0.8 + for lead, (present, d_rel, y_rel) in zip(self._lead_vehicles, leads, strict=True): + visible = available and present and d_rel < MAX_DRAW_DISTANCE and len(lane) > 0 + # snap to a new vehicle instead of sliding over + if not visible or abs(y_rel - lead.y_filter.x) > 3.0: + lead.d_filter.initialized = lead.y_filter.initialized = False + lead.fade_filter.update(opacity if visible else 0.0) + if visible: + lead.bar = self._get_lead_bar(lane, lead.d_filter.update(d_rel), lead.y_filter.update(y_rel)) + + def _get_lead_bar(self, lane, d_rel, y_rel): + # bar on the road behind the lead, following the lane + x = np.array([d_rel, d_rel - min(6.0, 0.25 * d_rel)]) + y = np.interp(x, lane[:, 0], lane[:, 1]) - np.interp(d_rel, lane[:, 0], lane[:, 1]) - y_rel + z = np.interp(x, self._path.raw_points[:, 0], self._path.raw_points[:, 2]) + self._path_offset_z + corners = np.vstack((np.column_stack((x, y + LEAD_BAR_WIDTH / 2, z)), np.column_stack((x, y - LEAD_BAR_WIDTH / 2, z))[::-1])) + pts = self._car_space_transform @ corners.T + bar = (pts[:2] / pts[2]).T + + far, near = bar[[0, 3]], bar[[1, 2]] + length = np.linalg.norm(near.mean(axis=0) - far.mean(axis=0)) + bar[[1, 2]] = far + (near - far) * np.clip(length, 3.0, LEAD_BAR_LENGTH) / length + return bar.astype(np.float32) def _update_model(self, lead, path_x_array): """Update model visualization data based on model message""" @@ -270,30 +299,6 @@ class ModelRenderer(Widget, ModelRendererSP): self._exp_gradient.colors = segment_colors self._exp_gradient.stops = gradient_stops - def _update_lead_vehicle(self, d_rel, v_rel, point, rect): - speed_buff, lead_buff = 10.0, 40.0 - - # Calculate fill alpha - fill_alpha = 0 - if d_rel < lead_buff: - fill_alpha = 255 * (1.0 - (d_rel / lead_buff)) - if v_rel < 0: - fill_alpha += 255 * (-1 * (v_rel / speed_buff)) - fill_alpha = min(fill_alpha, 255) - - # Calculate size and position - sz = np.clip((25 * 30) / (d_rel / 3 + 30), 15.0, 30.0) * 1 - x = np.clip(point[0], 0.0, rect.width - sz / 2) - y = min(point[1], rect.height - sz * 0.6) - - g_xo = sz / 5 - g_yo = sz / 10 - - glow = [(x + (sz * 1.35) + g_xo, y + sz + g_yo), (x, y - g_yo), (x - (sz * 1.35) - g_xo, y + sz + g_yo)] - chevron = [(x + (sz * 1.25), y + sz), (x, y), (x - (sz * 1.25), y + sz)] - - return LeadVehicle(glow=glow, chevron=chevron, fill_alpha=int(fill_alpha)) - def _get_ll_color(self, prob: float, adjacent: bool, left: bool): alpha = np.clip(prob, 0.0, 0.7) if adjacent: @@ -375,13 +380,9 @@ class ModelRenderer(Widget, ModelRendererSP): draw_polygon(self._rect, path_pts, gradient=gradient) def _draw_lead_indicator(self): - # Draw lead vehicles if available + offset = np.array([self._rect.x, self._rect.y], dtype=np.float32) for lead in self._lead_vehicles: - if not lead.glow or not lead.chevron: - continue - - rl.draw_triangle_fan(lead.glow, len(lead.glow), rl.Color(218, 202, 37, 255)) - rl.draw_triangle_fan(lead.chevron, len(lead.chevron), rl.Color(201, 34, 49, lead.fill_alpha)) + draw_polygon(self._rect, lead.bar + offset, rl.Color(255, 255, 255, int(255 * lead.fade_filter.x))) @staticmethod def _get_path_length_idx(pos_x_array: np.ndarray, path_height: float) -> int: @@ -391,22 +392,6 @@ class ModelRenderer(Widget, ModelRendererSP): indices = np.where(pos_x_array <= path_height)[0] return indices[-1] if indices.size > 0 else 0 - def _map_to_screen(self, in_x, in_y, in_z): - """Project a point in car space to screen space""" - input_pt = np.array([in_x, in_y, in_z]) - pt = self._car_space_transform @ input_pt - - if abs(pt[2]) < 1e-6: - return None - - x, y = pt[0] / pt[2], pt[1] / pt[2] - - clip = self._clip_region - if not (clip.x <= x <= clip.x + clip.width and clip.y <= y <= clip.y + clip.height): - return None - - return (x, y) - def _map_line_to_polygon(self, line: np.ndarray, y_off: float, z_off: float, max_idx: int, allow_invert: bool = True) -> np.ndarray: """Convert 3D line to 2D polygon for rendering.""" if line.shape[0] == 0: diff --git a/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py b/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py index c0f1ac1510..71530f9fd8 100755 --- a/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py +++ b/openpilot/selfdrive/ui/mici/tests/test_widget_leaks.py @@ -19,7 +19,7 @@ KNOWN_LEAKS = { "openpilot.system.ui.widgets.button.Button", "openpilot.system.ui.widgets.html_render.HtmlRenderer", "openpilot.system.ui.widgets.nav_widget.NavBar", - "openpilot.selfdrive.ui.mici.layouts.settings.device.MiciFccModal", + "openpilot.selfdrive.ui.mici.layouts.settings.device.device_layout.MiciFccModal", "openpilot.system.ui.widgets.inputbox.InputBox", "openpilot.system.ui.widgets.scroller_tici.Scroller", "openpilot.system.ui.widgets.label.UnifiedLabel", @@ -52,9 +52,9 @@ class TestWidgetLeaks(OpenpilotTestCase): # mici dialogs from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide as MiciTrainingGuide, OnboardingWindow as MiciOnboardingWindow from openpilot.selfdrive.ui.mici.onroad.cabin_camera_dialog import CabinCameraDialog as MiciCabinCameraDialog - from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog as MiciPairingDialog + from openpilot.selfdrive.ui.mici.layouts.settings.device.prime import PrimeScroller from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigInputDialog - from openpilot.selfdrive.ui.mici.layouts.settings.device import MiciFccModal + from openpilot.selfdrive.ui.mici.layouts.settings.device.device_layout import MiciFccModal # tici dialogs from openpilot.selfdrive.ui.onroad.cabin_camera_dialog import CabinCameraDialog as TiciCabinCameraDialog @@ -71,7 +71,7 @@ class TestWidgetLeaks(OpenpilotTestCase): for ctor in ( # mici - MiciCabinCameraDialog, MiciPairingDialog, + MiciCabinCameraDialog, PrimeScroller, lambda: MiciTrainingGuide(lambda: None), lambda: MiciOnboardingWindow(lambda: None), lambda: BigDialog("test", "test"), diff --git a/openpilot/selfdrive/ui/mici/widgets/button.py b/openpilot/selfdrive/ui/mici/widgets/button.py index 4f395ae9bc..c481a7707b 100644 --- a/openpilot/selfdrive/ui/mici/widgets/button.py +++ b/openpilot/selfdrive/ui/mici/widgets/button.py @@ -241,6 +241,9 @@ class BigButton(BaseButton): btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2 return txt_bg, btn_x, btn_y, scale + def _draw_subtitle(self, rect: rl.Rectangle): + self._sub_label.render(rect) + def _draw_content(self, btn_y: float): # LABEL ------------------------------------------------------------------ label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING @@ -255,7 +258,7 @@ class BigButton(BaseButton): label_y = label_rect.y + self._label.get_content_height(int(label_rect.width)) sub_label_height = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING - label_y sub_label_rect = rl.Rectangle(label_x, label_y, self._subtitle_width_hint(), sub_label_height) - self._sub_label.render(sub_label_rect) + self._draw_subtitle(sub_label_rect) # ICON ------------------------------------------------------------------- if self._txt_icon: diff --git a/openpilot/selfdrive/ui/mici/widgets/dialog.py b/openpilot/selfdrive/ui/mici/widgets/dialog.py index d5da6178f1..80625aa451 100644 --- a/openpilot/selfdrive/ui/mici/widgets/dialog.py +++ b/openpilot/selfdrive/ui/mici/widgets/dialog.py @@ -183,7 +183,7 @@ class BigInputDialog(BigDialogBase): # draw gradient on left side to indicate more text if text_size.x > text_field_rect.width: 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) + rl.BLACK, rl.BLACK, rl.BLANK, rl.BLANK) # draw cursor blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2 diff --git a/openpilot/selfdrive/ui/mici/widgets/info.py b/openpilot/selfdrive/ui/mici/widgets/info.py new file mode 100644 index 0000000000..2d765cd6fd --- /dev/null +++ b/openpilot/selfdrive/ui/mici/widgets/info.py @@ -0,0 +1,31 @@ +import pyray as rl + +from openpilot.system.ui.lib.application import FontWeight +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import UnifiedLabel + + +class InfoLayoutMici(Widget): + def __init__(self, title1: str, subtext1: str, title2: str, subtext2: str, *, width: int = 360): + super().__init__() + self.set_rect(rl.Rectangle(0, 0, width, 180)) + + max_width = int(self._rect.width - 20) + self._subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) + + self.title1 = UnifiedLabel(title1, 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) + self.subtext1 = UnifiedLabel(subtext1, 36, max_width=max_width, text_color=self._subheader_color, + font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True) + self.title2 = UnifiedLabel(title2, 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) + self.subtext2 = UnifiedLabel(subtext2, 36, max_width=max_width, text_color=self._subheader_color, + font_weight=FontWeight.ROMAN, wrap_text=False, scroll=True) + + def _layout(self): + self.title1.set_position(self._rect.x + 20, self._rect.y - 10) + self.subtext1.set_position(self._rect.x + 20, self._rect.y + 68 - 25) + self.title2.set_position(self._rect.x + 20, self._rect.y + 114 - 30) + self.subtext2.set_position(self._rect.x + 20, self._rect.y + 161 - 25) + + def _render(self, rect: rl.Rectangle): + for widget in [self.title1, self.subtext1, self.title2, self.subtext2]: + widget.render() diff --git a/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py b/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py deleted file mode 100644 index 7e05bc2fa0..0000000000 --- a/openpilot/selfdrive/ui/mici/widgets/pairing_dialog.py +++ /dev/null @@ -1,95 +0,0 @@ -import pyray as rl -import time - -from openpilot.common.api import Api -from openpilot.common.qrcode import make_texture -from openpilot.common.swaglog import cloudlog -from openpilot.common.params import Params -from openpilot.selfdrive.ui.ui_state import ui_state -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 UnifiedLabel - - -class PairingDialog(NavWidget): - """Dialog for device pairing with QR code.""" - - QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds - - def __init__(self): - super().__init__() - self._params = Params() - self._qr_texture: rl.Texture | None = None - self._last_qr_generation = float("-inf") - - self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 33, 60) - 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: - dongle_id = self._params.get("DongleId") or "" - token = Api(dongle_id).get_token({'pair': True}) - except Exception as e: - cloudlog.warning(f"Failed to get pairing token: {e}") - token = "" - return f"https://connect.comma.ai/?pair={token}" - - def _generate_qr_code(self) -> None: - try: - if self._qr_texture and self._qr_texture.id != 0: - rl.unload_texture(self._qr_texture) - self._qr_texture = make_texture(self._get_pairing_url(), inverted=True) - except Exception as e: - cloudlog.warning(f"QR code generation failed: {e}") - self._qr_texture = None - - def _check_qr_refresh(self) -> None: - current_time = time.monotonic() - if current_time - self._last_qr_generation >= self.QR_REFRESH_INTERVAL: - self._generate_qr_code() - self._last_qr_generation = current_time - - def _update_state(self): - super()._update_state() - if ui_state.prime_state.is_paired() and not self.is_dismissing: - self.dismiss() - - def _render(self, rect: rl.Rectangle): - self._check_qr_refresh() - - self._render_qr_code() - - label_x = self._rect.x + 8 + self._rect.height + 24 - 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() - - rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16), - 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35))) - - def _render_qr_code(self) -> None: - if not self._qr_texture: - error_font = gui_app.font(FontWeight.BOLD) - rl.draw_text_ex( - error_font, "QR Code Error", rl.Vector2(self._rect.x + 20, self._rect.y + self._rect.height // 2 - 15), 30, 0.0, rl.RED - ) - return - - scale = self._rect.height / self._qr_texture.height - 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): - if self._qr_texture and self._qr_texture.id != 0: - rl.unload_texture(self._qr_texture) - - -if __name__ == "__main__": - gui_app.init_window("pairing device") - pairing = PairingDialog() - gui_app.push_widget(pairing) - try: - for _ in gui_app.render(): - pass - finally: - del pairing diff --git a/openpilot/selfdrive/ui/mici/widgets/qr.py b/openpilot/selfdrive/ui/mici/widgets/qr.py new file mode 100644 index 0000000000..f0ff0d0ed9 --- /dev/null +++ b/openpilot/selfdrive/ui/mici/widgets/qr.py @@ -0,0 +1,37 @@ +import pyray as rl + +from openpilot.common.swaglog import cloudlog +from openpilot.system.ui.lib.application import FontWeight +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.label import Label +from openpilot.common.qrcode import make_texture + + +class QR(Widget): + def __init__(self, url: str, width: int = 170): + super().__init__() + self._url = url + + self._texture = self._generate_qr_code() + + self.set_rect(rl.Rectangle(0, 0, width, width)) + self._error = Label("QR Code Error", font_size=30, font_weight=FontWeight.BOLD, text_color=rl.RED) + + def _generate_qr_code(self): + try: + return make_texture(self._url, inverted=True) + except Exception as e: + cloudlog.warning(f"QR code generation failed: {e}") + return None + + def _render(self, rect: rl.Rectangle): + if not self._texture: + self._error.render(rect) + return + scale = rect.height / self._texture.height + pos = rl.Vector2(round(rect.x), round(rect.y)) + rl.draw_texture_ex(self._texture, pos, 0.0, scale, rl.WHITE) + + def __del__(self): + if self._texture and self._texture.id != 0: + rl.unload_texture(self._texture) diff --git a/openpilot/selfdrive/ui/onroad/augmented_road_view.py b/openpilot/selfdrive/ui/onroad/augmented_road_view.py index 70b75d13bd..23a5331aa7 100644 --- a/openpilot/selfdrive/ui/onroad/augmented_road_view.py +++ b/openpilot/selfdrive/ui/onroad/augmented_road_view.py @@ -33,8 +33,8 @@ BORDER_COLORS = { **BORDER_COLORS_SP, } -WIDE_CAM_MAX_SPEED = 10.0 # m/s (22 mph) -ROAD_CAM_MIN_SPEED = 15.0 # m/s (34 mph) +WIDE_CAM_MAX_SPEED = 5.0 # m/s (11 mph) +ROAD_CAM_MIN_SPEED = 10.0 # m/s (22 mph) INF_POINT = np.array([1000.0, 0.0, 0.0]) diff --git a/openpilot/selfdrive/ui/onroad/exp_button.py b/openpilot/selfdrive/ui/onroad/exp_button.py index 9a92ebc3c3..a122394c88 100644 --- a/openpilot/selfdrive/ui/onroad/exp_button.py +++ b/openpilot/selfdrive/ui/onroad/exp_button.py @@ -34,7 +34,7 @@ class ExpButton(Widget): def _handle_mouse_release(self, _): super()._handle_mouse_release(_) - if self._is_toggle_allowed(): + if ui_state.has_longitudinal_control: new_mode = not self._experimental_mode self._params.put_bool("ExperimentalMode", new_mode) @@ -61,10 +61,3 @@ class ExpButton(Widget): self._hold_end_time = self._held_mode = None return self._experimental_mode - - def _is_toggle_allowed(self): - if not self._params.get_bool("ExperimentalModeConfirmed"): - return False - - # Mirror exp mode toggle using persistent car params - return ui_state.has_longitudinal_control diff --git a/openpilot/selfdrive/ui/soundd.py b/openpilot/selfdrive/ui/soundd.py index a61e26b0b6..fa6b780581 100644 --- a/openpilot/selfdrive/ui/soundd.py +++ b/openpilot/selfdrive/ui/soundd.py @@ -20,7 +20,8 @@ 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 +ALERT_RAMP_TIME = 4 # seconds to ramp critical alerts to max volume +ALERT_MAX_TIME = 8 # seconds before critical alerts switch to the max sound SELFDRIVE_STATE_TIMEOUT = 5 # 5 seconds FILTER_DT = 1. / (micd.SAMPLE_RATE / micd.FFT_SAMPLES) @@ -34,6 +35,7 @@ if HARDWARE.get_device_type() == "tizi": AudibleAlert = log.SelfdriveState.AudibleAlert AudibleAlertSP = custom.SelfdriveStateSP.AudibleAlert +CRITICAL_MAX = -1 # internal sound key, not an AudibleAlert sound_list_sp: dict[int, tuple[str, int | None, float]] = { @@ -58,6 +60,7 @@ sound_list: dict[int, tuple[str, int | None, float]] = { AudibleAlert.warningImmediate: ("dm_critical.wav", None, MAX_VOLUME), **sound_list_sp, + CRITICAL_MAX: ("dm_critical_max.wav", None, MAX_VOLUME), } def check_selfdrive_timeout_alert(sm): @@ -77,6 +80,7 @@ class Soundd(QuietMode): self.load_sounds() self.current_alert = AudibleAlert.none + self.current_sound = AudibleAlert.none self.current_volume = MIN_VOLUME self.current_sound_frame = 0 @@ -109,7 +113,7 @@ class Soundd(QuietMode): if self.should_play_sound(self.current_alert): num_loops = sound_list[self.current_alert][1] - sound_data = self.loaded_sounds[self.current_alert] + sound_data = self.loaded_sounds[self.current_sound] written_frames = 0 current_sound_frame = self.current_sound_frame % len(sound_data) @@ -136,7 +140,7 @@ class Soundd(QuietMode): data_out[:frames, 0] = self.get_sound_data(frames) 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]) + current_alert_played_once = self.current_alert == AudibleAlert.none or self.current_sound_frame >= len(self.loaded_sounds[self.current_sound]) # let looping sounds finish the current loop instead of cutting off mid tone if new_alert == AudibleAlert.none and self.current_alert != AudibleAlert.none and sound_list[self.current_alert][1] is None: if current_alert_played_once: @@ -147,10 +151,11 @@ class Soundd(QuietMode): return self.pending_stop = False if self.current_alert != new_alert and (new_alert != AudibleAlert.none or current_alert_played_once): - if new_alert == AudibleAlert.warningImmediate: + if new_alert in (AudibleAlert.warningSoft, AudibleAlert.warningImmediate): self.ramp_start_volume = self.current_volume self.ramp_start_time = time.monotonic() self.current_alert = new_alert + self.current_sound = new_alert self.current_sound_frame = 0 def get_audible_alert(self, sm): @@ -199,11 +204,13 @@ class Soundd(QuietMode): self.get_audible_alert(sm) - # Ramp up immediate warning sound over 4s - if self.current_alert == AudibleAlert.warningImmediate: + if self.current_alert in (AudibleAlert.warningSoft, 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) + if elapsed >= ALERT_MAX_TIME and self.current_sound != CRITICAL_MAX: + self.current_sound = CRITICAL_MAX + self.current_sound_frame = 0 rk.keep_time() diff --git a/openpilot/selfdrive/ui/ui_state.py b/openpilot/selfdrive/ui/ui_state.py index a101f93d57..f895650bc5 100644 --- a/openpilot/selfdrive/ui/ui_state.py +++ b/openpilot/selfdrive/ui/ui_state.py @@ -12,7 +12,7 @@ from openpilot.common.swaglog import cloudlog from openpilot.selfdrive.ui.lib.prime_state import PrimeState from openpilot.system.ui.lib.application import gui_app from openpilot.common.hardware import HARDWARE, PC -from openpilot.common.hardware.usb import TYPEC_CC_ORIENTATION_PATH, get_usb_state, is_chestnut_usb_id, read_int +from openpilot.common.hardware.usb import cable_connected, get_usb_state, is_chestnut_usb_id from openpilot.selfdrive.modeld.helpers import chestnut_compiled from openpilot.selfdrive.ui.sunnypilot.ui_state import UIStateSP, DeviceSP @@ -91,7 +91,6 @@ class UIState(UIStateSP): self.is_release = False # self.params.get_bool("IsReleaseBranch") self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM") self.experimental_mode: bool = self.params.get_bool("ExperimentalMode") - self.experimental_mode_confirmed: bool = self.params.get_bool("ExperimentalModeConfirmed") self.chestnut_present: bool = False self.chestnut_compiled: bool = chestnut_compiled() self.chestnut_active: bool | None = None @@ -210,7 +209,7 @@ class UIState(UIStateSP): self.status = UIStatus.DISENGAGED self.started_frame = self.sm.frame self.started_time = time.monotonic() - self.chestnut_present = self.sm["deviceState"].chestnutPresent + self.chestnut_present = self.sm["deviceState"].chestnutPresent or (self.chestnut_compiled and self.usb_connected) for callback in self._offroad_transition_callbacks: callback() @@ -230,10 +229,10 @@ class UIState(UIStateSP): self.chestnut_state = ChestnutState.DISCONNECTED elif not self.chestnut_compiled: self.chestnut_state = ChestnutState.UNCOMPILED - elif self.chestnut_state == ChestnutState.FAILED or not detected or (model_seen and (not self.sm.alive["modelV2"] or not self.sm["modelV2"].big)): - self.chestnut_state = ChestnutState.FAILED elif self.chestnut_loading or not model_seen: self.chestnut_state = ChestnutState.LOADING + elif self.chestnut_state == ChestnutState.FAILED or not detected or (model_seen and (not self.sm.alive["modelV2"] or not self.sm["modelV2"].big)): + self.chestnut_state = ChestnutState.FAILED elif self.chestnut_active is False: self.chestnut_state = ChestnutState.FAILED else: @@ -254,13 +253,10 @@ class UIState(UIStateSP): self.is_metric = self.params.get_bool("IsMetric") self.always_on_dm = self.params.get_bool("AlwaysOnDM") self.experimental_mode = self.params.get_bool("ExperimentalMode") - self.experimental_mode_confirmed = self.params.get_bool("ExperimentalModeConfirmed") - if not self.chestnut_compiled: - self.chestnut_compiled = chestnut_compiled() self.chestnut_active = self.params.get("ChestnutActive") self.chestnut_loading = self.params.get_bool("ChestnutLoading") now = time.monotonic() - if read_int(TYPEC_CC_ORIENTATION_PATH) != 0: + if cable_connected(): self.usb_disconnected_ts = None if not self.usb_connected: self.usb_connected = True diff --git a/openpilot/selfdrive/ui/widgets/offroad_alerts.py b/openpilot/selfdrive/ui/widgets/offroad_alerts.py index 88ccc5df7e..e2b62834ce 100644 --- a/openpilot/selfdrive/ui/widgets/offroad_alerts.py +++ b/openpilot/selfdrive/ui/widgets/offroad_alerts.py @@ -263,6 +263,8 @@ class OffroadAlert(AbstractAlert): def _build_alerts(self): self.sorted_alerts = [] for key, config in sorted(OFFROAD_ALERTS.items(), key=lambda x: x[1].get("severity", 0), reverse=True): + if key == "Offroad_Pairing": + continue severity = config.get("severity", 0) alert_data = AlertData(key=key, text="", severity=severity) self.sorted_alerts.append(alert_data) diff --git a/openpilot/system/athena/tests/test_athenad.py b/openpilot/system/athena/tests/test_athenad.py index f9e4c0d334..a89c8800f9 100644 --- a/openpilot/system/athena/tests/test_athenad.py +++ b/openpilot/system/athena/tests/test_athenad.py @@ -58,9 +58,7 @@ def host(): class TestAthenadMethods(OpenpilotTestCase): @classmethod def setup_class(cls): - cls.SOCKET_PORT = 45454 athenad.Api = MockApi # ty: ignore[invalid-assignment] # test double - athenad.LOCAL_PORT_WHITELIST = {cls.SOCKET_PORT} def setup_method(self): self.default_params = { @@ -434,7 +432,7 @@ class TestAthenadMethods(OpenpilotTestCase): assert athenad.upload_queue.qsize() == 1 assert asdict(athenad.upload_queue.queue[-1]) == asdict(item1) - def test_start_local_proxy(self, mock_create_connection): + def test_start_local_proxy(self, mock_create_connection, mocker): end_event = threading.Event() ws_recv = queue.Queue() @@ -442,11 +440,13 @@ class TestAthenadMethods(OpenpilotTestCase): mock_ws = MockWebsocket(ws_recv, ws_send) mock_create_connection.return_value = mock_ws - echo_socket = EchoSocket(self.SOCKET_PORT) + echo_socket = EchoSocket(0) + port = echo_socket.socket.getsockname()[1] + mocker.patch('openpilot.system.athena.athenad.LOCAL_PORT_WHITELIST', {port}) socket_thread = threading.Thread(target=echo_socket.run) socket_thread.start() - athenad.startLocalProxy(end_event, 'ws://localhost:1234', self.SOCKET_PORT) + athenad.startLocalProxy(end_event, 'ws://localhost:1234', port) ws_recv.put_nowait(b'ping') try: diff --git a/openpilot/system/hardware/chestnut/status.py b/openpilot/system/hardware/chestnut/status.py index f108c3d62b..eda46b0a5e 100644 --- a/openpilot/system/hardware/chestnut/status.py +++ b/openpilot/system/hardware/chestnut/status.py @@ -5,7 +5,7 @@ from openpilot.common.version import get_build_metadata, CHESTNUT_BRANCHES from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_compiled -CHESTNUT_RELEASE_BRANCHES = ("release-chestnut", "release-chestnut-staging", "nightly-chestnut") +CHESTNUT_RELEASE_BRANCHES = ("release-chestnut", "release-chestnut-staging", "nightly-chestnut", "nightly-chestnut-dev") CHESTNUT_POWERED_VOLTAGE = 5000 GPU_TEMP_LIMIT = 100. MEMORY_TEMP_LIMIT = 95. @@ -24,6 +24,7 @@ class ChestnutStatus: self.link_failures = 0 self.model_loading_seen = False self.model_attempted = False + self.model_failed = False self.overheated = False self.usb_seen = False self.usb_failed = False @@ -43,11 +44,13 @@ class ChestnutStatus: self.link_failures = 0 self.model_loading_seen = False self.model_attempted = False + self.model_failed = False self.usb_seen = firmware_ok self.usb_failed = False self.model_loading_seen |= model_loading self.model_attempted |= self.model_loading_seen and not model_loading and model_active is not None + self.model_failed |= not offroad and self.model_attempted and model_active is False if not offroad and self.usb_seen and not firmware_ok: self.usb_failed = True @@ -96,6 +99,6 @@ class ChestnutStatus: else: pcie_alert = "Chestnut GPU unavailable. PCIe link is not up. Check the GPU is securely seated." set_alert("Offroad_ChestnutPcieUnavailable", self.pcie_failed, pcie_alert) - set_alert("Offroad_ChestnutUncompiled", offroad and firmware_ok and not chestnut_compiled()) + set_alert("Offroad_ChestnutModelError", self.model_failed and not (missing or self.pcie_failed)) set_alert("Offroad_ChestnutUpdateFailed", offroad and firmware_failed) self.offroad = offroad diff --git a/openpilot/system/loggerd/logger.cc b/openpilot/system/loggerd/logger.cc index f8f94c4b71..76588a74a5 100644 --- a/openpilot/system/loggerd/logger.cc +++ b/openpilot/system/loggerd/logger.cc @@ -49,7 +49,6 @@ kj::Array logger_build_init_data(bool route_log) { init.setGitCommitDate(params_map["GitCommitDate"]); init.setGitBranch(params_map["GitBranch"]); init.setGitRemote(params_map["GitRemote"]); - init.setPassive(false); init.setDongleId(params_map["DongleId"]); // for prebuilt branches diff --git a/openpilot/system/ui/README.md b/openpilot/system/ui/README.md index 7c9713b39e..8afddc1228 100644 --- a/openpilot/system/ui/README.md +++ b/openpilot/system/ui/README.md @@ -12,6 +12,8 @@ Quick start: * set `MAGIC_DEBUG=1` to show every dropped frames (only on device) * set `RECORD=1` to record the screen, output defaults to `output.mp4` but can be set with `RECORD_OUTPUT` * set `SUNNYPILOT_UI=0` to run the stock UI instead of the sunnypilot UI +* set `PRIME_TYPE` to preview pairing/Prime status: `-1` unpaired, `0` paired without Prime, `2` Prime Lite, `1` Prime Full +* set `PAIRING_PROVIDER` to preview the different states based on connect auth providers `google`, `github` or `apple` * https://www.raylib.com/cheatsheet/cheatsheet.html * https://electronstudio.github.io/raylib-python-cffi/README.html#quickstart diff --git a/openpilot/system/ui/widgets/mici_keyboard.py b/openpilot/system/ui/widgets/mici_keyboard.py index bc3041aa3c..97a33e95e7 100644 --- a/openpilot/system/ui/widgets/mici_keyboard.py +++ b/openpilot/system/ui/widgets/mici_keyboard.py @@ -353,8 +353,12 @@ class MiciKeyboard(Widget): # draw black circle behind selected key circle_alpha = int(self._selected_key_filter.x * 225) - rl.draw_circle_gradient(rl.Vector2(key_x + key.rect.width / 2, key_y + key.rect.height / 2), - SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, circle_alpha), rl.BLANK) + try: + rl.draw_circle_gradient(rl.Vector2(key_x + key.rect.width / 2, key_y + key.rect.height / 2), + SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, circle_alpha), rl.BLANK) + except (TypeError, RuntimeError): # AGNOS updater compatibility for raylib 5 + rl.draw_circle_gradient(int(key_x + key.rect.width / 2), int(key_y + key.rect.height / 2), + SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, circle_alpha), rl.BLANK) else: # move other keys away from selected key a bit dx = key.original_position.x - self._closest_key[0].original_position.x diff --git a/openpilot/system/ui/widgets/scroller.py b/openpilot/system/ui/widgets/scroller.py index e79c4592fc..ebde01c428 100644 --- a/openpilot/system/ui/widgets/scroller.py +++ b/openpilot/system/ui/widgets/scroller.py @@ -97,6 +97,7 @@ class _Scroller(Widget): self._scroll_enabled: bool | Callable[[], bool] = True self._show_scroll_indicator = scroll_indicator and self._horizontal + self.scroll_indicator_start_after: Widget | None = None self._scroll_indicator = ScrollIndicator() self._edge_shadows = edge_shadows and self._horizontal @@ -364,7 +365,14 @@ class _Scroller(Widget): # Draw scroll indicator on top of edge shadows if self._show_scroll_indicator and len(self._visible_items) > 0: - self._scroll_indicator.update(self._scroll_offset, self._content_size, self._rect) + viewport = self._rect + if self.scroll_indicator_start_after is not None: + offset = min(self._rect.width, max(0.0, self.scroll_indicator_start_after.rect.x + + self.scroll_indicator_start_after.rect.width + + self._spacing - self._rect.x)) + viewport = rl.Rectangle(self._rect.x + offset, self._rect.y, self._rect.width - offset, self._rect.height) + + self._scroll_indicator.update(self._scroll_offset, self._content_size, viewport) self._scroll_indicator.render() def show_event(self): diff --git a/openpilot/tools/cabana/SConscript b/openpilot/tools/cabana/SConscript index 0249a019bb..b1ef7602a5 100644 --- a/openpilot/tools/cabana/SConscript +++ b/openpilot/tools/cabana/SConscript @@ -1,14 +1,23 @@ import os +from pathlib import Path import bootstrap_icons import imgui import libusb from openpilot.common.basedir import BASEDIR +from opendbc.dbc.generator.generator import generate_all Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib', 'ffmpeg_libs') opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (env.Dir("../../../opendbc_repo/opendbc/dbc").abspath) +dbc_dir = Path(env.Dir("#opendbc_repo/opendbc/dbc").abspath) +generated_dbcs = env.Command( + [str(dbc_dir / (name + '.dbc')) for name in sorted(generate_all())], + sorted(str(path) for path in (dbc_dir / 'generator').rglob('*') if path.suffix in ('.py', '.dbc')), + 'python3 -m opendbc.dbc.generator.generator', +) + # embed the bootstrap icons SVG into the binary def build_bootstrap_icons_src(target, source, env): data = open(str(source[0]), 'rb').read() @@ -52,6 +61,7 @@ if arch == "Darwin": else: ui_libs += ['GL', 'dl'] cabana_ui = ui_env.Program('cabana', ui_objs, LIBS=ui_libs) +ui_env.Requires(cabana_ui, generated_dbcs) if GetOption('extras'): cabana_core_test_env = env.Clone() @@ -67,7 +77,8 @@ if GetOption('extras'): cabana_core_test_env.Object('tests/cabana_core_routes', 'routes.cc'), cabana_core_test_env.Object('tests/cabana_core_qtstate', 'ui/qtstate.cc'), ] - cabana_core_test_env.Program('tests/test_cabana', cabana_core_test_objects, LIBS=[replay_lib, common]) + cabana_core_test = cabana_core_test_env.Program('tests/test_cabana', cabana_core_test_objects, LIBS=[replay_lib, common]) + cabana_core_test_env.Requires(cabana_core_test, generated_dbcs) output_json_file = 'openpilot/tools/cabana/dbc/car_fingerprint_to_dbc.json' generate_dbc = ui_env.Command('#' + output_json_file, diff --git a/openpilot/tools/cabana/core/heatmap.h b/openpilot/tools/cabana/core/heatmap.h new file mode 100644 index 0000000000..04ef64e884 --- /dev/null +++ b/openpilot/tools/cabana/core/heatmap.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include + +// Count adjacent samples, never treating a newly appearing byte as a change. +struct HeatmapCounts { + HeatmapCounts() = default; + explicit HeatmapCounts(size_t size) : bits(size), bytes(size) {} + + void add(const uint8_t *data, size_t size) { + const size_t common = std::min({size, previous.size(), bits.size()}); + for (size_t i = 0; i < common; ++i) { + const uint8_t diff = data[i] ^ previous[i]; + if (diff) ++bytes[i]; + for (int bit = 0; bit < 8; ++bit) { + if (diff & (1u << bit)) ++bits[i][7 - bit]; + } + } + previous.assign(data, data + size); + } + + std::vector> bits; + std::vector bytes; + std::vector previous; +}; diff --git a/openpilot/tools/cabana/tests/test_cabana.cc b/openpilot/tools/cabana/tests/test_cabana.cc index 268aa9a86d..5f6b8da31f 100644 --- a/openpilot/tools/cabana/tests/test_cabana.cc +++ b/openpilot/tools/cabana/tests/test_cabana.cc @@ -11,6 +11,7 @@ #include "common/tests/native_test.h" #include "tools/cabana/dbc/dbcfile.h" +#include "tools/cabana/core/heatmap.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/routes.h" #include "tools/cabana/ui/qtstate.h" @@ -413,7 +414,30 @@ void test_pixel_envelope() { REQUIRE(chart::pixelEnvelope(points.begin(), points.begin(), 0, 1, 10).empty()); } +void test_heatmap_counts() { + HeatmapCounts counts(3); + for (int value = 0; value < 256; ++value) { + const uint8_t data[] = {static_cast(value), 42, static_cast(value / 16)}; + counts.add(data, sizeof(data)); + } + REQUIRE(counts.bytes == std::vector({255, 0, 15})); + REQUIRE(counts.bits[0] == std::array({1, 3, 7, 15, 31, 63, 127, 255})); + REQUIRE(counts.bits[1] == std::array{}); + + // Every range starts with a baseline, without a transition from outside it. + HeatmapCounts range(3); + const uint8_t first[] = {255, 42, 15}; + range.add(first, 3); + REQUIRE(range.bytes == std::vector({0, 0, 0})); + const uint8_t short_data[] = {0}; + range.add(short_data, 1); + range.add(first, 3); + REQUIRE(range.bytes == std::vector({2, 0, 0})); + REQUIRE(HeatmapCounts(0).bits.empty()); +} + void test_cabana_core() { + test_heatmap_counts(); test_pixel_envelope(); test_format_seconds(); test_to_hex(); diff --git a/openpilot/tools/cabana/ui/chart/chart.cc b/openpilot/tools/cabana/ui/chart/chart.cc index 1c319b4faf..57d00a2ee2 100644 --- a/openpilot/tools/cabana/ui/chart/chart.cc +++ b/openpilot/tools/cabana/ui/chart/chart.cc @@ -475,7 +475,7 @@ void ChartView::showTip(double sec) { std::string name = s.sig->name; std::string min = s.min == std::numeric_limits::max() ? "--" : utils::toString(s.min); std::string max = s.max == std::numeric_limits::lowest() ? "--" : utils::toString(s.max); - text_list.push_back({.has_marker = true, .marker = toImU32(s.color), .name = name, .value = value, .min = min, .max = max}); + text_list.push_back({.has_marker = true, .marker = toImU32(graphicColor(s.color)), .name = name, .value = value, .min = min, .max = max}); } } if (x < 0) { @@ -620,9 +620,9 @@ void ChartView::drawLegend() { ImGui::PopID(); if (series_type_ == SeriesType::Scatter) { - painter->AddCircleFilled(r.Min + ImVec2(marker_size / 2.0f, 2.0f + marker_size / 2.0f), marker_size / 2.0f, toImU32(s.color)); + painter->AddCircleFilled(r.Min + ImVec2(marker_size / 2.0f, 2.0f + marker_size / 2.0f), marker_size / 2.0f, toImU32(graphicColor(s.color))); } else { - drawColorMarker(painter, r.Min, toImU32(s.color)); + drawColorMarker(painter, r.Min, toImU32(graphicColor(s.color))); } float x = r.Min.x + marker_size + ImGui::GetStyle().ItemInnerSpacing.x; @@ -655,7 +655,7 @@ void ChartView::drawSeries() { const std::string label = "##sig" + std::to_string(i); ImPlotSpec spec; - spec.LineColor = toImVec4(s.color); + spec.LineColor = toImVec4(graphicColor(s.color)); spec.Stride = sizeof(ImPlotPoint); if (series_type_ == SeriesType::Scatter) { float radius = std::clamp(pixels_per_point / 2.0, 2.0, 8.0) / 2.0; @@ -682,7 +682,7 @@ void ChartView::drawSeries() { // show points when zoomed in enough if ((num_points == 1 || pixels_per_point > 20) && first != last) { ImPlotSpec dots; - dots.LineColor = toImVec4(s.color); + dots.LineColor = toImVec4(graphicColor(s.color)); dots.Stride = sizeof(ImPlotPoint); dots.Marker = ImPlotMarker_Circle; dots.MarkerSize = 4; @@ -700,12 +700,12 @@ void ChartView::drawForeground() { for (auto &s : sigs_) { if (!isNull(s.track_pt) && s.visible) { ImVec2 pos(xPos(s.track_pt.x), yPos(s.track_pt.y)); - painter->AddCircleFilled(pos, 5.5f, toImU32(s.color.darker(125))); + painter->AddCircleFilled(pos, 5.5f, toImU32(graphicColor(s.color))); track_line_x = std::max(track_line_x, pos.x); } } if (track_line_x > 0) { - const ImU32 dark_gray = IM_COL32(0x80, 0x80, 0x80, 0xff); + const ImU32 dark_gray = ImGui::GetColorU32(palette().text_disabled); for (float y = layout_.plot_area.Min.y; y < layout_.plot_area.Max.y; y += 8) { painter->AddLine(ImVec2(track_line_x, y), ImVec2(track_line_x, std::min(y + 4, layout_.plot_area.Max.y)), dark_gray, 1.0f); } diff --git a/openpilot/tools/cabana/ui/chart/sparkline.cc b/openpilot/tools/cabana/ui/chart/sparkline.cc index 1e804f2765..d9e5702fb5 100644 --- a/openpilot/tools/cabana/ui/chart/sparkline.cc +++ b/openpilot/tools/cabana/ui/chart/sparkline.cc @@ -100,7 +100,7 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi } size = sz; - color_ = toImU32(sparklineColor(color)); + color_ = color; draw_individual_points_ = draw_individual_points; window_end_ = window_end; xscale_ = xscale; @@ -108,7 +108,8 @@ void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double wi void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos, ImU32 color) const { if (render_points_.empty()) return; - if (color == 0) color = color_; + // Pick up theme changes while paused. + if (color == 0) color = toImU32(sparklineColor(color_)); // update() only runs when a message of this id arrives, so a slow message would hold the sparkline // still for many frames and then move it in one step. scroll the rendered polyline by the time that diff --git a/openpilot/tools/cabana/ui/chart/sparkline.h b/openpilot/tools/cabana/ui/chart/sparkline.h index 991ecb8d4c..9d5420f4b6 100644 --- a/openpilot/tools/cabana/ui/chart/sparkline.h +++ b/openpilot/tools/cabana/ui/chart/sparkline.h @@ -26,7 +26,7 @@ private: std::vector points_; std::vector render_points_; - ImU32 color_ = 0; + CabanaColor color_; double window_end_ = 0; // the time the polyline was built for, so draw() can scroll it on from there double xscale_ = 0; bool draw_individual_points_ = false; diff --git a/openpilot/tools/cabana/ui/helpoverlay.cc b/openpilot/tools/cabana/ui/helpoverlay.cc index bc64f99196..f89e8b0b7e 100644 --- a/openpilot/tools/cabana/ui/helpoverlay.cc +++ b/openpilot/tools/cabana/ui/helpoverlay.cc @@ -181,7 +181,9 @@ void HelpOverlay::draw() { float x = min.x + 8.0f; for (const auto &r : line) { const float w = run_width(r); - const ImU32 color = r.color ? r.color : ImGui::GetColorU32(ImGuiCol_Text); + const auto background = fromImVec4(r.chip ? palette().button : palette().surface); + const auto source = r.color ? fromImVec4(ImGui::ColorConvertU32ToFloat4(r.color)) : fromImVec4(palette().text); + const ImU32 color = toImU32(contrastColor(source, background, r.swatch ? 3.0 : 4.5)); if (r.swatch) { dl->AddRectFilled(ImVec2(x + 2, y + 3), ImVec2(x + font_size - 2, y + font_size - 1), color); } else { diff --git a/openpilot/tools/cabana/ui/theme.cc b/openpilot/tools/cabana/ui/theme.cc index 14d0949835..eda301a566 100644 --- a/openpilot/tools/cabana/ui/theme.cc +++ b/openpilot/tools/cabana/ui/theme.cc @@ -12,34 +12,30 @@ namespace fs = std::filesystem; namespace { constexpr Palette DARK_PALETTE = { - .text = rgb(0xbbbbbb), .text_disabled = rgb(0x777777), .text_selected = rgb(0xbbbbbb), - .window = rgb(0x353535), .surface = rgb(0x3c3f41), - .frame = rgb(0x3c3f41), .frame_hovered = rgb(0x484b4d), .frame_active = rgb(0x505355), - .button = rgb(0x484b4d), .button_hovered = rgb(0x535658), .button_active = rgb(0x3c3f41), - .header = rgb(0x2f65ca), .header_hovered = rgb(0x414e65), .header_active = rgb(0x2f65ca), - .accent = rgb(0x2f65ca), - .border = rgb(0x282828), .separator = rgb(0x353535), .scrollbar_grab = rgb(0x484b4d), - .slider_track = rgb(0x777777), - .tab = rgb(0x353535), .tab_hovered = rgb(0x484b4d), .table_header = rgb(0x484b4d), - .grid = rgb(0xbbbbbb, 50.0f / 255.0f), .badge = rgb(0x808080), - .bit_background = rgb(0xffffff, 20.0f / 255.0f), - .heatmap_signal_alpha = 70.0, .heatmap_bit_alpha = 28.0, .heatmap_gamma = 0.6, + .text = rgb(0xffffff), .text_disabled = rgb(0xcccccc), .text_inactive = rgb(0xa4a4a4), .text_selected = rgb(0xffffff), + .window = rgb(0x353535), .surface = rgb(0x2b2c2d), + .frame = rgb(0x2b2c2d), .frame_hovered = rgb(0x363738), .frame_active = rgb(0x434445), + .button = rgb(0x3f4041), .button_hovered = rgb(0x525354), .button_active = rgb(0x303132), + .header = rgb(0x2d74d2), .header_hovered = rgb(0x323944), .header_active = rgb(0x434445), + .accent = rgb(0x90bfff), + .border = rgb(0xaaaaaa), .separator = rgb(0x353535), .scrollbar_grab = rgb(0xaaaaaa), + .slider_track = rgb(0xaaaaaa), + .tab = rgb(0x2c2c2c), .tab_hovered = rgb(0x444647), .tab_selected = rgb(0x393a3b), .table_header = rgb(0x3f4041), + .grid = rgb(0xdddddd, 50.0f / 255.0f), .badge = rgb(0x626262), .sparkline_saturation = 1.0f, .sparkline_value = 1.0f, }; constexpr Palette LIGHT_PALETTE = { - .text = rgb(0x000000), .text_disabled = rgb(0xbebebe), .text_selected = rgb(0xffffff), + .text = rgb(0x000000), .text_disabled = rgb(0x595959), .text_inactive = rgb(0x595959), .text_selected = rgb(0xffffff), .window = rgb(0xefefef), .surface = rgb(0xffffff), .frame = rgb(0xffffff), .frame_hovered = rgb(0xf5f9fc), .frame_active = rgb(0xe7f3fb), .button = rgb(0xefefef), .button_hovered = rgb(0xe7f3fb), .button_active = rgb(0xd4e7f4), - .header = rgb(0x308cc6), .header_hovered = rgb(0xe7f3fb), .header_active = rgb(0x308cc6), - .accent = rgb(0x308cc6), - .border = rgb(0xb6b6b6), .separator = rgb(0xd0d0d0), .scrollbar_grab = rgb(0xb6b6b6), - .slider_track = rgb(0xd0d0d0), - .tab = rgb(0xe5e5e5), .tab_hovered = rgb(0xefefef), .table_header = rgb(0xefefef), - .grid = rgb(0x000000, 50.0f / 255.0f), .badge = rgb(0xa0a0a4), - .bit_background = rgb(0xffffff, 0.0f), - .heatmap_signal_alpha = 25.0, .heatmap_bit_alpha = 10.0, .heatmap_gamma = 1.0, + .header = rgb(0x226a99), .header_hovered = rgb(0xe7f3fb), .header_active = rgb(0xd4e7f4), + .accent = rgb(0x226a99), + .border = rgb(0x747474), .separator = rgb(0xd0d0d0), .scrollbar_grab = rgb(0x747474), + .slider_track = rgb(0x747474), + .tab = rgb(0xe5e5e5), .tab_hovered = rgb(0xefefef), .tab_selected = rgb(0xffffff), .table_header = rgb(0xefefef), + .grid = rgb(0x000000, 50.0f / 255.0f), .badge = rgb(0x626262), .sparkline_saturation = 2.0f, .sparkline_value = 0.7f, }; @@ -71,6 +67,36 @@ ImFont *addFont(const fs::path &path, float size) { return font; } +double linearChannel(double value) { + value /= 255.0; + return value <= 0.04045 ? value / 12.92 : std::pow((value + 0.055) / 1.055, 2.4); +} + +double luminance(const CabanaColor &color) { + return 0.2126 * linearChannel(color.r) + 0.7152 * linearChannel(color.g) + 0.0722 * linearChannel(color.b); +} + +// Directed rounding preserves the luminance bound in 8-bit sRGB. +CabanaColor boundLuminance(const CabanaColor &color, double bound, bool minimum) { + const double current = luminance(color); + if (minimum ? current >= bound : current <= bound) return color; + auto channel = [&](uint8_t value) { + double c = linearChannel(value); + c = minimum ? c + (1.0 - c) * (bound - current) / (1.0 - current) : c * bound / current; + const double srgb = c <= 0.0031308 ? 12.92 * c : 1.055 * std::pow(c, 1.0 / 2.4) - 0.055; + const double scaled = std::clamp(srgb * 255.0, 0.0, 255.0); + return static_cast(minimum ? std::ceil(scaled) : std::floor(scaled)); + }; + return {channel(color.r), channel(color.g), channel(color.b), color.a}; +} + +CabanaColor composite(const CabanaColor &color, const CabanaColor &base) { + auto channel = [&](int foreground, int background) { + return static_cast(std::lround((foreground * color.a + background * (255 - color.a)) / 255.0)); + }; + return {channel(color.r, base.r), channel(color.g, base.g), channel(color.b, base.b)}; +} + ImVec4 alpha(ImVec4 c, float a) { return ImVec4(c.x, c.y, c.z, a); } } // namespace @@ -127,7 +153,8 @@ void applyTheme(int theme) { c[ImGuiCol_FrameBgHovered] = p.frame_hovered; c[ImGuiCol_FrameBgActive] = p.frame_active; c[ImGuiCol_Button] = p.button; - c[ImGuiCol_ButtonHovered] = c[ImGuiCol_SliderGrab] = p.button_hovered; + c[ImGuiCol_ButtonHovered] = p.button_hovered; + c[ImGuiCol_SliderGrab] = p.accent; c[ImGuiCol_ScrollbarGrab] = p.scrollbar_grab; c[ImGuiCol_ScrollbarGrabHovered] = p.text_disabled; c[ImGuiCol_ButtonActive] = p.button_active; @@ -142,7 +169,7 @@ void applyTheme(int theme) { c[ImGuiCol_TextSelectedBg] = alpha(p.accent, 0.35f); c[ImGuiCol_Tab] = c[ImGuiCol_TabDimmed] = p.tab; c[ImGuiCol_TabHovered] = p.tab_hovered; - c[ImGuiCol_TabSelected] = c[ImGuiCol_TabDimmedSelected] = p.surface; + c[ImGuiCol_TabSelected] = c[ImGuiCol_TabDimmedSelected] = p.tab_selected; c[ImGuiCol_TabDimmedSelectedOverline] = none; c[ImGuiCol_TableHeaderBg] = p.table_header; c[ImGuiCol_TableRowBgAlt] = none; @@ -161,10 +188,54 @@ void applyTheme(int theme) { const Palette &palette() { return *g_palette; } +CabanaColor contrastColor(CabanaColor color, const CabanaColor &background, double target) { + color.a = 255; + const double value = luminance(color), base = luminance(background); + if ((std::max(value, base) + 0.05) / (std::min(value, base) + 0.05) >= target) return color; + const bool lighter = base < 0.179; + const double bound = lighter ? target * (base + 0.05) - 0.05 : (base + 0.05) / target - 0.05; + return boundLuminance(color, std::clamp(bound, 0.0, 1.0), lighter); +} + +CabanaColor byteColor(const CabanaColor &color) { + const bool dark = palette().text.x > 0.5f; + const auto [h, s, v] = color.hsv(); + auto fill = CabanaColor::fromHsv(h, std::min(s, 0.30f), 0.86f, color.alphaF()); + if (dark) fill = boundLuminance(fill, 0.16, false); // 5:1 with white text + fill = composite(fill, fromImVec4(palette().surface)); + return dark ? fill : composite({255, 255, 255, 40}, fill); +} + +CabanaColor signalFill(const CabanaColor &color, bool defined) { + const bool dark = palette().text.x > 0.5f; + // Constrain the full-strength color before blending to preserve the activity ramp. + if (defined) return composite(boundLuminance(color, 0.18, !dark), fromImVec4(palette().surface)); + auto fill = composite(color, fromImVec4(palette().surface)); + fill = composite(dark ? CabanaColor(0, 0, 0, 115) : CabanaColor(255, 255, 255, 40), fill); + return boundLuminance(fill, 0.18, !dark); // 4.5:1 with the theme's text +} + +CabanaColor signalHighlight(CabanaColor color) { + color.a = 255; + return boundLuminance(boundLuminance(color, 0.175, true), 0.18, false); +} + +CabanaColor signalOutline(CabanaColor color, bool hovered) { + const bool dark = palette().text.x > 0.5f; + if (dark && hovered) color = color.lighter(150); + color.a = 255; + // 3:1 against normal and highlighted fills, including neighboring signals. + return dark ? boundLuminance(color, 0.67, true) : boundLuminance(color, 0.02, false); +} + +CabanaColor graphicColor(const CabanaColor &color, const ImVec4 &background) { + return contrastColor(color, fromImVec4(background), 3.0); +} + CabanaColor sparklineColor(const CabanaColor &color) { const Palette &p = palette(); auto [h, s, v] = color.hsv(); - return CabanaColor::fromHsv(h, std::min(1.0f, s * p.sparkline_saturation), v * p.sparkline_value, color.a / 255.0f); + return graphicColor(CabanaColor::fromHsv(h, std::min(1.0f, s * p.sparkline_saturation), v * p.sparkline_value)); } ImFont *boldFont() { return g_bold_font; } diff --git a/openpilot/tools/cabana/ui/theme.h b/openpilot/tools/cabana/ui/theme.h index 9c8653cfe9..4655219505 100644 --- a/openpilot/tools/cabana/ui/theme.h +++ b/openpilot/tools/cabana/ui/theme.h @@ -6,20 +6,18 @@ #include "tools/cabana/core/color.h" struct Palette { - ImVec4 text, text_disabled, text_selected; + ImVec4 text, text_disabled, text_inactive, text_selected; ImVec4 window; // the background behind panels and docked windows ImVec4 surface; // panels, popups, table bodies: what content is drawn on ImVec4 frame, frame_hovered, frame_active; ImVec4 button, button_hovered, button_active; - ImVec4 header, header_hovered, header_active; // selections + ImVec4 header, header_hovered, header_active; // selected fill; unselected hover/press fills ImVec4 accent; ImVec4 border, separator, scrollbar_grab; ImVec4 slider_track; - ImVec4 tab, tab_hovered, table_header; + ImVec4 tab, tab_hovered, tab_selected, table_header; ImVec4 grid; ImVec4 badge; // the fill behind the time labels drawn over a chart - ImVec4 bit_background; // overlay beneath heatmap bits without a signal - double heatmap_signal_alpha, heatmap_bit_alpha, heatmap_gamma; float sparkline_saturation, sparkline_value; // HSV multipliers for signal colors }; @@ -30,6 +28,10 @@ inline ImVec4 colorRgb(int r, int g, int b, float alpha = 1.0f) { return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, alpha); } inline ImU32 toImU32(const CabanaColor &c) { return IM_COL32(c.r, c.g, c.b, c.a); } +inline CabanaColor fromImVec4(const ImVec4 &c) { + return {static_cast(std::lround(c.x * 255)), static_cast(std::lround(c.y * 255)), + static_cast(std::lround(c.z * 255)), static_cast(std::lround(c.w * 255))}; +} inline ImVec4 toImVec4(const CabanaColor &c) { return ImVec4(c.r / 255.0f, c.g / 255.0f, c.b / 255.0f, c.a / 255.0f); } inline ImU32 withAlpha(ImU32 c, int alpha) { return (c & ~IM_COL32_A_MASK) | ((ImU32)alpha << IM_COL32_A_SHIFT); } @@ -46,6 +48,12 @@ constexpr float UI_FONT_SIZE = 16.0f; void loadFonts(); void applyTheme(int theme); // Safe to call at runtime. const Palette &palette(); +CabanaColor contrastColor(CabanaColor color, const CabanaColor &background, double target = 4.5); +CabanaColor byteColor(const CabanaColor &color); +CabanaColor signalFill(const CabanaColor &color, bool defined = true); +CabanaColor signalHighlight(CabanaColor color); +CabanaColor signalOutline(CabanaColor color, bool hovered = false); +CabanaColor graphicColor(const CabanaColor &color, const ImVec4 &background = palette().surface); CabanaColor sparklineColor(const CabanaColor &color); ImFont *boldFont(); diff --git a/openpilot/tools/cabana/ui/util.cc b/openpilot/tools/cabana/ui/util.cc index b11ecf28fa..2252355144 100644 --- a/openpilot/tools/cabana/ui/util.cc +++ b/openpilot/tools/cabana/ui/util.cc @@ -94,9 +94,10 @@ bool selectable(const char *label, bool selected, ImGuiSelectableFlags flags, co if (selected) { ImGui::PushStyleColor(ImGuiCol_Text, palette().text_selected); ImGui::PushStyleColor(ImGuiCol_HeaderHovered, palette().header); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, palette().header); } const bool clicked = ImGui::Selectable(label, selected, flags, size); - if (selected) ImGui::PopStyleColor(2); + if (selected) ImGui::PopStyleColor(3); return clicked; } @@ -382,7 +383,7 @@ int tableHeadersRow() { bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size) { ImGui::PushStyleColor(ImGuiCol_HeaderHovered, selected ? ImGui::GetColorU32(ImGuiCol_Header) : IM_COL32(0, 0, 0, 0)); - ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImGui::GetColorU32(ImGuiCol_Header)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, selected ? palette().header : palette().header_active); const bool clicked = selectable(label, selected, flags, size); ImGui::PopStyleColor(2); return clicked; diff --git a/openpilot/tools/cabana/ui/widgets/binarysignals.h b/openpilot/tools/cabana/ui/widgets/binarysignals.h new file mode 100644 index 0000000000..1ccdd16c1c --- /dev/null +++ b/openpilot/tools/cabana/ui/widgets/binarysignals.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include + +#include "tools/cabana/dbc/dbc.h" + +// Only the current multiplexed branch owns the payload bits. Use this same +// signal set for painting, endpoint markers, and mouse interaction. +inline std::vector binaryViewSignals(const cabana::Msg *msg, const uint8_t *data, size_t size) { + if (!msg) return {}; + std::optional multiplex_value; + if (msg->multiplexor) { + auto selector = *msg->multiplexor; + if (std::max(selector.msb, selector.lsb) / 8 < size) { + // Multiplex values are raw identifiers, independent of display scaling. + selector.factor = 1; + selector.offset = 0; + selector.is_signed = false; + multiplex_value = get_raw_value(data, size, selector); + } + } + std::vector signals; + for (auto sig : msg->getSignals()) { + if (sig->type != cabana::Signal::Type::Multiplexed || + (multiplex_value && *multiplex_value == sig->multiplex_value)) { + signals.push_back(sig); + } + } + return signals; +} diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.cc b/openpilot/tools/cabana/ui/widgets/binaryview.cc index 84f5a29fd8..818f8b42a8 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.cc +++ b/openpilot/tools/cabana/ui/widgets/binaryview.cc @@ -10,20 +10,20 @@ #include "tools/cabana/commands.h" #include "tools/cabana/settings.h" #include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/widgets/binarysignals.h" #include "tools/cabana/utils/strings.h" #include "tools/cabana/utils/util.h" namespace { const int CELL_HEIGHT = 36; +const float CELL_FONT_SIZE = UI_FONT_SIZE + 2.0f; const float SMALL_FONT_SIZE = 10.0f; // Inter needs 10 px for a 7 px cap height -const int VERTICAL_HEADER_WIDTH = 30; +const int GRID_COLUMN_COUNT = BinaryView::COLUMN_COUNT + 1; inline int get_bit_pos(const BinaryIndex &index) { return flipBitPos(index.row * 8 + index.column); } -inline ImU32 paletteHighlight() { return ImGui::GetColorU32(ImGuiCol_Header); } inline ImU32 paletteBase() { return ImGui::GetColorU32(ImGuiCol_ChildBg); } -inline ImU32 paletteText(bool active) { return ImGui::GetColorU32(active ? ImGuiCol_Text : ImGuiCol_TextDisabled); } -const ImU32 DARK_GRAY = IM_COL32(128, 128, 128, 255); +inline ImU32 paletteText() { return ImGui::GetColorU32(ImGuiCol_Text); } // JetBrains Mono ships no bold variant, so emulate one by drawing the glyphs again a fraction of a // pixel to the right. Keeps the monospace advance, unlike switching to the proportional bold face. @@ -56,6 +56,13 @@ void fillBDiagPattern(ImDrawList *p, const ImRect &r, ImU32 col) { } // namespace BinaryView::BinaryView() { + heatmap_live_mode_ = can->liveStreaming(); + connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &events) { + if (events.count(msg_id_)) { + bit_flip_tracker_.valid = false; + if (!heatmap_live_mode_) updateState(); + } + })); connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); } @@ -63,6 +70,8 @@ BinaryView::BinaryView() { std::string BinaryView::whatsThis() const { return R"( Binary View
+ All / selected range: brighter bits and bytes changed more often; uncolored cells did not change.
+ Live: accumulated bit flips and fading byte changes during playback.
Shortcuts
Delete Signal:  x , @@ -113,11 +122,11 @@ void BinaryView::addShortcuts() { } ImVec2 BinaryView::minimumSizeHint() const { - // widest fixed-font glyph plus the header margins - pushMonoFont(); - const float min_section_size = ImGui::CalcTextSize("W").x + 8.0f; + // Match the enlarged hex font when reserving space for narrow panels. + pushMonoFont(CELL_FONT_SIZE); + const float min_section_size = std::ceil(ImGui::CalcTextSize("FF").x) + 10.0f; popMonoFont(); - return {(min_section_size + 1) * 9 + VERTICAL_HEADER_WIDTH + 2, + return {min_section_size * GRID_COLUMN_COUNT + 2, static_cast(CELL_HEIGHT * std::min(row_count_, 10) + 2)}; } @@ -164,6 +173,8 @@ void BinaryView::highlightPosition(const ImVec2 &pos) { auto item = &cellAt(index); const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back(); highlight(sig); + } else { + highlight(nullptr); } } @@ -205,35 +216,46 @@ void BinaryView::refresh() { hovered_sig_ = nullptr; bit_flip_tracker_ = {}; cells_.clear(); - if (auto dbc_msg = dbc()->msg(msg_id_)) { - row_count_ = dbc_msg->size; - cells_.resize(row_count_ * COLUMN_COUNT); - for (auto sig : dbc_msg->getSignals()) { - for (int j = 0; j < sig->size; ++j) { - int pos = sig->is_little_endian ? flipBitPos(sig->start_bit + j) : flipBitPos(sig->start_bit) + j; - int idx = COLUMN_COUNT * (pos / 8) + pos % 8; - if (idx >= cells_.size()) { - fprintf(stderr, "signal %s out of bounds.start_bit: %d size: %d\n", sig->name.c_str(), sig->start_bit, sig->size); - break; - } - if (j == 0) sig->is_little_endian ? cells_[idx].is_lsb = true : cells_[idx].is_msb = true; - if (j == sig->size - 1) sig->is_little_endian ? cells_[idx].is_msb = true : cells_[idx].is_lsb = true; - - auto &sigs = cells_[idx].sigs; - sigs.push_back(sig); - if (sigs.size() > 1) { - std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) { return l->size > r->size; }); - } - } - } - } else { - row_count_ = can->lastMessage(msg_id_).dat.size(); - cells_.resize(row_count_ * COLUMN_COUNT); - } + visible_signals_.clear(); + const auto *msg = dbc()->msg(msg_id_); + row_count_ = msg ? msg->size : can->lastMessage(msg_id_).dat.size(); + cells_.resize(row_count_ * COLUMN_COUNT); updateState(); if (under_mouse_) highlightPosition(last_mouse_pos_); } +void BinaryView::updateSignals() { + const auto &data = can->lastMessage(msg_id_).dat; + auto signals = binaryViewSignals(dbc()->msg(msg_id_), data.data(), data.size()); + if (signals == visible_signals_) return; + visible_signals_ = std::move(signals); + + // A branch switch invalidates the old hover and any in-progress resize. + selection_.clear(); + anchor_index_ = {}; + resize_sig_ = nullptr; + highlight(nullptr); + for (auto &cell : cells_) { + cell.sigs.clear(); + cell.is_msb = cell.is_lsb = false; + } + for (auto sig : visible_signals_) { + for (int j = 0; j < sig->size; ++j) { + int pos = sig->is_little_endian ? flipBitPos(sig->start_bit + j) : flipBitPos(sig->start_bit) + j; + int idx = COLUMN_COUNT * (pos / 8) + pos % 8; + if (idx < 0 || idx >= cells_.size()) break; + if (j == 0) sig->is_little_endian ? cells_[idx].is_lsb = true : cells_[idx].is_msb = true; + if (j == sig->size - 1) sig->is_little_endian ? cells_[idx].is_msb = true : cells_[idx].is_lsb = true; + cells_[idx].sigs.push_back(sig); + } + } + for (auto &cell : cells_) { + std::stable_sort(cell.sigs.begin(), cell.sigs.end(), [](auto l, auto r) { return l->size > r->size; }); + } + if (under_mouse_) highlightPosition(last_mouse_pos_); + signalsChanged(); +} + std::set BinaryView::getOverlappingSignals() const { std::set overlapping; @@ -272,18 +294,18 @@ std::tuple BinaryView::getSelection(BinaryIndex index) { } BinaryIndex BinaryView::indexAt(const ImVec2 &pos) const { - if (column_width_ <= 0 || pos.x < grid_pos_.x + VERTICAL_HEADER_WIDTH || pos.y < grid_pos_.y) return {}; - int column = static_cast((pos.x - grid_pos_.x - VERTICAL_HEADER_WIDTH) / column_width_); + if (column_width_ <= 0 || pos.x < grid_pos_.x + IM_ROUND(column_width_) || pos.y < grid_pos_.y) return {}; + int column = 0; + while (column < COLUMN_COUNT && pos.x >= grid_pos_.x + IM_ROUND((column + 2) * column_width_)) ++column; int row = static_cast((pos.y - grid_pos_.y) / CELL_HEIGHT); if (column >= COLUMN_COUNT || row >= row_count_) return {}; return {row, column}; } ImRect BinaryView::visualRect(const BinaryIndex &index) const { - // sections are integral: round the edges so neighboring cells share them exactly and the cells are - // painted edge to edge with no grid line between them - const float x0 = grid_pos_.x + VERTICAL_HEADER_WIDTH + IM_ROUND(index.column * column_width_); - const float x1 = grid_pos_.x + VERTICAL_HEADER_WIDTH + IM_ROUND((index.column + 1) * column_width_); + // Round shared edges consistently across the byte index, bits, and hex column. + const float x0 = grid_pos_.x + IM_ROUND((index.column + 1) * column_width_); + const float x1 = grid_pos_.x + IM_ROUND((index.column + 2) * column_width_); const float y = grid_pos_.y + index.row * CELL_HEIGHT; return ImRect(x0, y, x1, y + CELL_HEIGHT); } @@ -297,17 +319,19 @@ void BinaryView::draw() { const int rows = row_count_; // Keep hex bytes readable in narrow panels by scrolling instead of shrinking further. + pushMonoFont(CELL_FONT_SIZE); const float min_column_width = std::ceil(ImGui::CalcTextSize("FF").x) + 10.0f; - const float width = std::max(ImGui::GetContentRegionAvail().x, VERTICAL_HEADER_WIDTH + min_column_width * COLUMN_COUNT); - column_width_ = std::max(min_column_width, (width - VERTICAL_HEADER_WIDTH) / COLUMN_COUNT); + popMonoFont(); + const float width = std::max(ImGui::GetContentRegionAvail().x, min_column_width * GRID_COLUMN_COUNT); + column_width_ = std::max(min_column_width, width / GRID_COLUMN_COUNT); grid_pos_ = ImGui::GetCursorScreenPos(); ImGui::InvisibleButton("##binary_view", ImVec2(std::max(width, 1.0f), std::max(static_cast(rows * CELL_HEIGHT), 1.0f))); ImDrawList *painter = ImGui::GetWindowDrawList(); + painter->AddRectFilled(grid_pos_, ImVec2(grid_pos_.x + width, grid_pos_.y + rows * CELL_HEIGHT), paletteBase()); for (int row = 0; row < rows; ++row) { - const ImRect r(grid_pos_.x, grid_pos_.y + row * CELL_HEIGHT, grid_pos_.x + VERTICAL_HEADER_WIDTH, grid_pos_.y + (row + 1) * CELL_HEIGHT); - painter->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_WindowBg)); // plain header background - drawText(painter, r, std::to_string(row).c_str(), ImGui::GetColorU32(ImGuiCol_Text)); + const ImRect r(grid_pos_.x, grid_pos_.y + row * CELL_HEIGHT, grid_pos_.x + IM_ROUND(column_width_), grid_pos_.y + (row + 1) * CELL_HEIGHT); + drawText(painter, r, std::to_string(row).c_str(), paletteText(), nullptr, CELL_FONT_SIZE); } for (int row = 0; row < rows; ++row) { for (int column = 0; column < COLUMN_COUNT; ++column) { @@ -316,6 +340,29 @@ void BinaryView::draw() { } } + // One consistent grid over every cell; hover never changes its geometry. + const ImU32 grid_color = ImGui::GetColorU32(ImGuiCol_Border, 0.35f); + const float right = grid_pos_.x + IM_ROUND(GRID_COLUMN_COUNT * column_width_); + const float bottom = grid_pos_.y + rows * CELL_HEIGHT; + for (int column = 0; column <= GRID_COLUMN_COUNT; ++column) { + const float x = std::clamp(grid_pos_.x + IM_ROUND(column * column_width_), grid_pos_.x + 0.5f, right - 0.5f); + painter->AddLine(ImVec2(x, grid_pos_.y), ImVec2(x, bottom), grid_color); + } + for (int row = 0; rows > 0 && row <= rows; ++row) { + const float y = std::clamp(grid_pos_.y + row * CELL_HEIGHT, grid_pos_.y + 0.5f, bottom - 0.5f); + painter->AddLine(ImVec2(grid_pos_.x, y), ImVec2(right, y), grid_color); + } + + // Signal definitions are a separate layer from heatmap activity. + for (int row = 0; row < rows; ++row) { + for (int column = 0; column < HEX_COLUMN; ++column) { + const BinaryIndex index{row, column}; + for (const auto *sig : cellAt(index).sigs) { + if (!hasSelection() || sig != resize_sig_) drawSignalOutline(painter, visualRect(index), index, sig); + } + } + } + const ImVec2 mouse = ImGui::GetMousePos(); const bool hovered = ImGui::IsItemHovered(); const bool active = ImGui::IsItemActive(); @@ -359,6 +406,9 @@ void BinaryView::updateState() { cells_.resize(row_count_ * COLUMN_COUNT); } + for (auto &cell : cells_) cell.valid = false; + updateSignals(); + auto &bit_flips = heatmap_live_mode_ ? last_msg.bit_flip_counts : bitFlipChanges(binary.size()); uint32_t max_bit_flip_count = 1; // 1 to avoid division by zero for (const auto &row : bit_flips) { @@ -367,13 +417,15 @@ void BinaryView::updateState() { } } - const Palette &p = palette(); + uint32_t max_byte_flip_count = 1; + for (auto count : bit_flip_tracker_.counts.bytes) max_byte_flip_count = std::max(max_byte_flip_count, count); + + // Use the same logarithmic intensity mapping in both themes. const double max_alpha = 255.0; - const double min_alpha_with_signal = p.heatmap_signal_alpha; // Base alpha for small flip counts - const double min_alpha_no_signal = p.heatmap_bit_alpha; // Base alpha for small flip counts for no signal bits - const double alpha_gamma = p.heatmap_gamma; + const double min_alpha_with_signal = 25.0; // Base alpha for small flip counts + const double min_alpha_no_signal = 10.0; // Base alpha for small flip counts for no signal bits const double log_factor = 1.0 + 0.2; - const double log_scaler = max_alpha / log2(log_factor * max_bit_flip_count); + const double log_scaler = max_alpha / log2(1.0 + log_factor * max_bit_flip_count); for (size_t i = 0; i < binary.size(); ++i) { for (int j = 0; j < 8; ++j) { @@ -384,7 +436,6 @@ void BinaryView::updateState() { uint32_t flip_count = bit_flips[i][j]; if (flip_count > 0) { double normalized_alpha = log2(1.0 + flip_count * log_factor) * log_scaler; - normalized_alpha = max_alpha * std::pow(std::clamp(normalized_alpha / max_alpha, 0.0, 1.0), alpha_gamma); double min_alpha = item.sigs.empty() ? min_alpha_no_signal : min_alpha_with_signal; alpha = std::clamp(normalized_alpha, min_alpha, max_alpha); } @@ -393,87 +444,68 @@ void BinaryView::updateState() { color.a = static_cast(alpha); setCell(i, j, bit_val, color); } - setCell(i, HEX_COLUMN, binary[i], last_msg.colors[i]); + auto byte_color = last_msg.colors[i]; + if (!heatmap_live_mode_) { + const auto count = bit_flip_tracker_.counts.bytes[i]; + const double intensity = std::log2(1.0 + count * log_factor) / std::log2(1.0 + max_byte_flip_count * log_factor); + byte_color = CabanaColor(102, 86, 169, static_cast(max_alpha * intensity)); + } + setCell(i, HEX_COLUMN, binary[i], byte_color); } } const std::vector> &BinaryView::bitFlipChanges(size_t msg_size) { auto time_range = can->timeRange(); - if (bit_flip_tracker_.time_range == time_range && !bit_flip_tracker_.flip_counts.empty()) - return bit_flip_tracker_.flip_counts; + if (bit_flip_tracker_.valid && bit_flip_tracker_.time_range == time_range && + bit_flip_tracker_.counts.bits.size() == msg_size) return bit_flip_tracker_.counts.bits; bit_flip_tracker_.time_range = time_range; - bit_flip_tracker_.flip_counts.assign(msg_size, std::array{}); - + bit_flip_tracker_.counts = HeatmapCounts(msg_size); auto [first, last] = can->eventsInRange(msg_id_, time_range); - if (std::distance(first, last) <= 1) return bit_flip_tracker_.flip_counts; - - std::vector prev_values((*first)->dat, (*first)->dat + (*first)->size); - for (auto it = std::next(first); it != last; ++it) { - const CanEvent *event = *it; - int size = std::min(msg_size, event->size); - for (int i = 0; i < size; ++i) { - const uint8_t diff = event->dat[i] ^ prev_values[i]; - if (!diff) continue; - - auto &bit_flips = bit_flip_tracker_.flip_counts[i]; - for (int bit = 0; bit < 8; ++bit) { - if (diff & (1u << bit)) ++bit_flips[7 - bit]; - } - prev_values[i] = event->dat[i]; - } - } - - return bit_flip_tracker_.flip_counts; -} - -bool BinaryView::hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const { - if (!index.isValid()) return false; - int idx = (index.row + dy) * COLUMN_COUNT + index.column + dx; - if (idx < 0 || idx >= (int)cells_.size()) return false; - auto &s = cells_[idx].sigs; - return std::find(s.begin(), s.end(), sig) != s.end(); + for (auto it = first; it != last; ++it) bit_flip_tracker_.counts.add((*it)->dat, (*it)->size); + bit_flip_tracker_.valid = true; + return bit_flip_tracker_.counts.bits; } void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index) const { auto item = &cellAt(index); ImFont *font = ImGui::GetFont(); - float font_size = ImGui::GetFontSize(); - ImU32 pen = paletteText(is_message_active_); + float font_size = CELL_FONT_SIZE; + ImU32 pen = paletteText(); + const bool hovered = hovered_sig_ && std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); if (index.column == HEX_COLUMN) { if (item->valid) { - pushMonoFont(); + pushMonoFont(CELL_FONT_SIZE); font = ImGui::GetFont(); font_size = ImGui::GetFontSize(); popMonoFont(); - painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); + painter->AddRectFilled(rect.Min, rect.Max, toImU32(byteColor(item->bg_color))); } } else if (isSelected(index)) { - painter->AddRectFilled(rect.Min, rect.Max, resize_sig_ ? toImU32(resize_sig_->color) : paletteHighlight()); - if (resize_sig_) pen = IM_COL32_WHITE; + painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalHighlight(resize_sig_ ? resize_sig_->color : fromImVec4(palette().header)))); + pen = IM_COL32_WHITE; } else if (!hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), resize_sig_) == item->sigs.end()) { // not resizing if (item->sigs.size() > 0) { for (auto &s : item->sigs) { - if (s == hovered_sig_) { - painter->AddRectFilled(rect.Min, rect.Max, toImU32(s->color.darker(125))); // 4/5x brightness - } else { - drawSignalCell(painter, rect, index, s); - } + drawSignalCell(painter, rect, index, s); } + // Hover covers the entire signal, including bits shared with another definition. + if (hovered) painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalHighlight(hovered_sig_->color))); } else if (item->valid) { - painter->AddRectFilled(rect.Min, rect.Max, ImGui::GetColorU32(palette().bit_background)); - if (item->bg_color.alpha() > 0) painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); + if (item->bg_color.alpha() > 0) painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalFill(item->bg_color, false))); } - bool bright = std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); - pen = bright ? IM_COL32_WHITE : paletteText(is_message_active_); + pen = hovered ? IM_COL32_WHITE : paletteText(); } + const auto text = fromImVec4(ImGui::ColorConvertU32ToFloat4(pen)); + const ImU32 pattern = toImU32(contrastColor({128, 128, 128}, text)); if (item->sigs.size() > 1) { - fillDense7Pattern(painter, rect, DARK_GRAY); + fillDense7Pattern(painter, rect, pattern); } else if (!item->valid) { - fillBDiagPattern(painter, rect, DARK_GRAY); + fillBDiagPattern(painter, rect, pattern); } + if (item->valid) { if (index.column == HEX_COLUMN) { drawBoldText(painter, rect, utils::hexByte(item->val), pen, font, font_size); @@ -487,64 +519,38 @@ void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const Binary } } -// Draw border on edge of signal -void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const { - bool draw_left = !hasSignal(index, -1, 0, sig); - bool draw_top = !hasSignal(index, 0, -1, sig); - bool draw_right = !hasSignal(index, 1, 0, sig); - bool draw_bottom = !hasSignal(index, 0, 1, sig); +bool BinaryView::hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const { + const int row = index.row + dy; + const int column = index.column + dx; + if (row < 0 || row >= row_count_ || column < 0 || column >= HEX_COLUMN) return false; + const auto &signals = cellAt({row, column}).sigs; + return std::find(signals.begin(), signals.end(), sig) != signals.end(); +} - const int spacing = 2; - ImRect rc(rect.Min.x + draw_left * 3, rect.Min.y + draw_top * spacing, rect.Max.x - draw_right * 3, rect.Max.y - draw_bottom * spacing); - std::vector subtract; - if (!draw_top) { - if (!draw_left && !hasSignal(index, -1, -1, sig)) { - subtract.emplace_back(rc.Min.x, rc.Min.y, rc.Min.x + 3, rc.Min.y + spacing); - } else if (!draw_right && !hasSignal(index, 1, -1, sig)) { - subtract.emplace_back(rc.Max.x - 3, rc.Min.y, rc.Max.x, rc.Min.y + spacing); - } - } - if (!draw_bottom) { - if (!draw_left && !hasSignal(index, -1, 1, sig)) { - subtract.emplace_back(rc.Min.x, rc.Max.y - spacing, rc.Min.x + 3, rc.Max.y); - } else if (!draw_right && !hasSignal(index, 1, 1, sig)) { - subtract.emplace_back(rc.Max.x - 3, rc.Max.y - spacing, rc.Max.x, rc.Max.y); - } - } - // rc split into horizontal bands with the notch corners removed: at most one notch in the top band and - // one in the bottom band - const ImRect *top_notch = !subtract.empty() && subtract.front().Min.y == rc.Min.y ? &subtract.front() : nullptr; - const ImRect *bottom_notch = !subtract.empty() && subtract.back().Min.y != rc.Min.y ? &subtract.back() : nullptr; - std::vector region; - auto band = [&](const ImRect *notch, float y0, float y1) { - const float x0 = notch && notch->Min.x == rc.Min.x ? notch->Max.x : rc.Min.x; - const float x1 = notch && notch->Min.x != rc.Min.x ? notch->Min.x : rc.Max.x; - if (x1 > x0 && y1 > y0) region.emplace_back(x0, y0, x1, y1); +void BinaryView::drawSignalOutline(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, + const cabana::Signal *sig) const { + // Definition boundaries remain visible even at zero activity. Keep them above the + // fills and grid, at the same fixed position before and during hover. + const ImU32 edge = toImU32(signalOutline(sig->color, sig == hovered_sig_)); + const bool left = !hasSignal(index, -1, 0, sig); + const bool right = !hasSignal(index, 1, 0, sig); + const bool top = !hasSignal(index, 0, -1, sig); + const bool bottom = !hasSignal(index, 0, 1, sig); + // Extend one pixel into adjacent cells to join inward corners. + auto offset = [&](bool boundary, int dx, int dy) { + return boundary ? 1.0f : hasSignal(index, dx, dy, sig) ? -1.0f : 0.0f; }; - if (top_notch) band(top_notch, rc.Min.y, rc.Min.y + spacing); - band(nullptr, rc.Min.y + (top_notch ? spacing : 0), rc.Max.y - (bottom_notch ? spacing : 0)); - if (bottom_notch) band(bottom_notch, rc.Max.y - spacing, rc.Max.y); + const ImVec2 min = rect.Min, max = rect.Max; + if (left) painter->AddLine(ImVec2(min.x + 1, min.y + offset(top, -1, -1)), ImVec2(min.x + 1, max.y - offset(bottom, -1, 1)), edge); + if (right) painter->AddLine(ImVec2(max.x - 1, min.y + offset(top, 1, -1)), ImVec2(max.x - 1, max.y - offset(bottom, 1, 1)), edge); + if (top) painter->AddLine(ImVec2(min.x + offset(left, -1, -1), min.y + 1), ImVec2(max.x - offset(right, 1, -1), min.y + 1), edge); + if (bottom) painter->AddLine(ImVec2(min.x + offset(left, -1, 1), max.y - 1), ImVec2(max.x - offset(right, 1, 1), max.y - 1), edge); +} - auto item = &cellAt(index); +// Signal intensity and hover share the same full-cell geometry. +void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const { CabanaColor color = sig->color; - color.a = item->bg_color.alpha(); - const ImU32 edge = toImU32(sig->color.darker(125)); - - for (const ImRect &clip : region) { - painter->PushClipRect(clip.Min, clip.Max, true); - // mix the signal color with the background to fade it - painter->AddRectFilled(rc.Min, rc.Max, paletteBase()); - painter->AddRectFilled(rc.Min, rc.Max, toImU32(color)); - - if (draw_left) painter->AddLine(ImVec2(rc.Min.x + 0.5f, rc.Min.y), ImVec2(rc.Min.x + 0.5f, rc.Max.y), edge, 1.0f); - if (draw_right) painter->AddLine(ImVec2(rc.Max.x - 0.5f, rc.Min.y), ImVec2(rc.Max.x - 0.5f, rc.Max.y), edge, 1.0f); - if (draw_bottom) painter->AddLine(ImVec2(rc.Min.x, rc.Max.y - 0.5f), ImVec2(rc.Max.x, rc.Max.y - 0.5f), edge, 1.0f); - if (draw_top) painter->AddLine(ImVec2(rc.Min.x, rc.Min.y + 0.5f), ImVec2(rc.Max.x, rc.Min.y + 0.5f), edge, 1.0f); - - // fill gaps inside corners: the 2px stroke is clipped to the region, only the half outside the notch is painted - for (auto &r : subtract) { - painter->AddRect(r.Min, r.Max, edge, 0.0f, 0, 2.0f); - } - painter->PopClipRect(); - } + color.a = 96 + cellAt(index).bg_color.alpha() * (255 - 96) / 255; + painter->AddRectFilled(rect.Min, rect.Max, paletteBase()); + painter->AddRectFilled(rect.Min, rect.Max, toImU32(signalFill(color))); } diff --git a/openpilot/tools/cabana/ui/widgets/binaryview.h b/openpilot/tools/cabana/ui/widgets/binaryview.h index 1ca3c17bab..4f1afa1032 100644 --- a/openpilot/tools/cabana/ui/widgets/binaryview.h +++ b/openpilot/tools/cabana/ui/widgets/binaryview.h @@ -9,6 +9,7 @@ #include "imgui.h" #include "imgui_internal.h" +#include "tools/cabana/core/heatmap.h" #include "tools/cabana/core/observable.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" @@ -39,6 +40,7 @@ public: std::string whatsThis() const; Observable signalClicked; + Observable<> signalsChanged; Observable signalHovered; Observable editSignal; Observable showChart; @@ -53,6 +55,7 @@ private: bool valid = false; }; + void updateSignals(); void refresh(); // rebuilds the grid from the DBC message void setCell(int row, int col, uint8_t val, const CabanaColor &color); const std::vector> &bitFlipChanges(size_t msg_size); @@ -73,15 +76,18 @@ private: void paintCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index) const; bool hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const; + void drawSignalOutline(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const; void drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const; MessageId msg_id_; std::vector cells_; + std::vector visible_signals_; int row_count_ = 0; bool heatmap_live_mode_ = true; struct BitFlipTracker { std::optional> time_range; - std::vector> flip_counts; + HeatmapCounts counts; + bool valid = false; } bit_flip_tracker_; BinaryIndex anchor_index_; diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.cc b/openpilot/tools/cabana/ui/widgets/detailwidget.cc index 830a51b889..6378d2f8ca 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.cc +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -48,6 +48,7 @@ DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { })); connections_.push_back(tabbar_.tabCloseRequested.connect([this](int index) { tabbar_.removeTab(index); })); connections_.push_back(tabbar_.tabContextMenu.connect([this](int index) { showTabBarContextMenu(index); })); + heatmap_live_ = can->liveStreaming(); binary_view_ = std::make_unique(); signal_view_ = std::make_unique(charts); @@ -60,6 +61,7 @@ DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { updateState(); })); + connections_.push_back(binary_view_->signalsChanged.connect([this]() { refresh(); })); connections_.push_back(binary_view_->signalHovered.connect([this](const cabana::Signal *s) { signal_view_->signalHovered(s); })); connections_.push_back(binary_view_->signalClicked.connect([this](const cabana::Signal *s) { signal_view_->selectSignal(s, true); })); connections_.push_back(binary_view_->editSignal.connect([this](const cabana::Signal *origin_s, cabana::Signal &s) { signal_view_->saveSignal(origin_s, s); })); @@ -74,8 +76,8 @@ DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { char text[64]; if (range) snprintf(text, sizeof(text), "%.3f - %.3f", range->first, range->second); heatmap_all_text_ = range ? text : "All"; - const bool live = !range; - if (std::exchange(heatmap_live_, live) != live) binary_view_->setHeatmapLiveMode(live); + heatmap_live_ = !range && can->liveStreaming(); + binary_view_->setHeatmapLiveMode(heatmap_live_); })); } @@ -174,23 +176,30 @@ void DetailWidget::restoreTabs(const std::string &active_msg_id, const std::vect } void DetailWidget::refresh() { + auto msg = dbc()->msg(msg_id_); + std::string msg_name = msg ? msg->name + " (" + msg->transmitter + ")" : msgName(msg_id_); + name_label_.setText(msg_name); + name_label_.setToolTip(msg_name); + action_remove_msg_enabled_ = msg != nullptr; + updateWarnings(); +} + +void DetailWidget::updateWarnings() { std::vector warnings; auto msg = dbc()->msg(msg_id_); if (msg) { - if (msg_id_.source == INVALID_SOURCE) { + // A restored tab or a seek can refer to a bus with no sample at the current time. + // lastMessage() returns empty data in that case, not a received zero-byte frame. + if (msg_id_.source == INVALID_SOURCE || !can->lastMessages().count(msg_id_)) { warnings.push_back("No messages received."); } else if (msg->size != can->lastMessage(msg_id_).dat.size()) { - warnings.push_back("Message size (" + std::to_string(msg->size) + ") is incorrect."); + warnings.push_back("Message size mismatch: DBC defines " + std::to_string(msg->size) + + " bytes, received " + std::to_string(can->lastMessage(msg_id_).dat.size()) + " bytes."); } for (auto s : binary_view_->getOverlappingSignals()) { warnings.push_back(s->name + " has overlapping bits."); } } - std::string msg_name = msg ? msg->name + " (" + msg->transmitter + ")" : msgName(msg_id_); - name_label_.setText(msg_name); - name_label_.setToolTip(msg_name); - action_remove_msg_enabled_ = msg != nullptr; - if (!warnings.empty()) { warning_label_.clear(); for (size_t i = 0; i < warnings.size(); ++i) { @@ -210,6 +219,7 @@ void DetailWidget::updateState(const std::set *msgs) { binary_view_->updateState(); else history_log_->updateState(); + updateWarnings(); } void DetailWidget::editMsg(float parent_width) { @@ -223,16 +233,29 @@ void DetailWidget::drawTabWidget() { page_tabs_.draw(); if (tab_widget_index_ == 0) { if (heatmap_visible_) { - // Keep most of a short window available for signal rows. + // Allow the heatmap to grow while reserving room for signal rows. + const float handle_height = style.DockingSeparatorSize; const float avail = ImGui::GetContentRegionAvail().y; - const float max_height = std::max(avail - style.ItemSpacing.y - SignalView::minimumHeight(), 1.0f); - const float height = std::min(binary_view_->minimumSizeHint().y, max_height); - ImGui::BeginChild("binary_view", ImVec2(0, std::max(height, 1.0f)), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); + const float max_height = std::max(avail - handle_height - style.ItemSpacing.y * 2 - SignalView::minimumHeight(), 1.0f); + const float min_height = std::min(ImGui::GetFrameHeight(), max_height); + float height = std::clamp(heatmap_height_ < 0 ? binary_view_->minimumSizeHint().y : heatmap_height_, min_height, max_height); + ImGui::BeginChild("binary_view", ImVec2(0, height), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar); binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); binary_view_->draw(); ImGui::EndChild(); + + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const ImVec2 size(std::max(ImGui::GetContentRegionAvail().x, 1.0f), handle_height); + const ImRect splitter(pos, ImVec2(pos.x + size.x, pos.y + size.y)); + float signal_height = std::max(avail - height - handle_height - style.ItemSpacing.y * 2, SignalView::minimumHeight()); + if (ImGui::SplitterBehavior(splitter, ImGui::GetID("##heatmap_resize"), ImGuiAxis_Y, + &height, &signal_height, min_height, SignalView::minimumHeight(), 4.0f)) { + heatmap_height_ = height; + } + ImGui::Dummy(size); } - ImGui::BeginChild("signal_view", ImVec2(0, 0), ImGuiChildFlags_None, + const float signal_height = std::max(ImGui::GetContentRegionAvail().y, SignalView::minimumHeight()); + ImGui::BeginChild("signal_view", ImVec2(0, signal_height), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); signal_view_rect_ = ImGui::GetCurrentWindow()->Rect(); signal_view_->draw(); @@ -244,8 +267,7 @@ void DetailWidget::drawTabWidget() { void DetailWidget::draw() { tabbar_.draw(); - ImGui::BeginChild("message_content", ImVec2(0, 0), ImGuiChildFlags_AlwaysUseWindowPadding, - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::BeginChild("message_content", ImVec2(0, 0), ImGuiChildFlags_AlwaysUseWindowPadding); drawToolBar(); if (warning_widget_visible_) { @@ -387,7 +409,7 @@ void CenterWidget::drawWelcomeWidget() { ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - size.x) * 0.5f, y)); ImGui::TextUnformatted(text); }; - ImGui::PushStyleColor(ImGuiCol_Text, colorRgb(169, 169, 169)); + ImGui::PushStyleColor(ImGuiCol_Text, palette().text_disabled); float y = origin.y + avail.y * 0.5f - 90.0f; pushLargeFont(); centered("CABANA", y); diff --git a/openpilot/tools/cabana/ui/widgets/detailwidget.h b/openpilot/tools/cabana/ui/widgets/detailwidget.h index 4745ed5c84..f752488ee1 100644 --- a/openpilot/tools/cabana/ui/widgets/detailwidget.h +++ b/openpilot/tools/cabana/ui/widgets/detailwidget.h @@ -75,6 +75,7 @@ private: void showTabBarContextMenu(int index); void editMsg(float parent_width); void updateState(const std::set *msgs = nullptr); + void updateWarnings(); MessageId msg_id_; const char *warning_icon_ = nullptr; @@ -87,6 +88,7 @@ private: bool action_remove_msg_enabled_ = false; bool heatmap_live_ = true; bool heatmap_visible_ = true; + float heatmap_height_ = -1.0f; std::string heatmap_all_text_ = "All"; ImRect binary_view_rect_, signal_view_rect_; // child window rects of the last drawTabWidget std::unique_ptr history_log_; diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.cc b/openpilot/tools/cabana/ui/widgets/messagebytes.cc index 63e31d6f83..fb66726b70 100644 --- a/openpilot/tools/cabana/ui/widgets/messagebytes.cc +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.cc @@ -26,13 +26,13 @@ ImVec2 bytesCellSize(int n, bool multiple_lines) { ImU32 cellTextColor(bool selected, bool inactive) { if (selected) { const ImU32 text = ImGui::GetColorU32(palette().text_selected); - return inactive ? withAlpha(text, 100) : text; + return text; } - return ImGui::GetColorU32(inactive ? ImGuiCol_TextDisabled : ImGuiCol_Text); + return ImGui::GetColorU32(inactive ? palette().text_inactive : palette().text); } -void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive) { - drawElidedText(dl, rect, text, cellTextColor(selected, inactive)); +void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive, bool align_right) { + drawElidedText(dl, rect, text, cellTextColor(selected, inactive), align_right); } void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vector &bytes, const std::vector *colors, @@ -48,14 +48,11 @@ void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vectorsize() && (*colors)[i].alpha() > 0) { - if (selected) { - pen = ImGui::GetColorU32(ImGuiCol_Text); - dl->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_WindowBg)); - } - dl->AddRectFilled(r.Min, r.Max, toImU32((*colors)[i])); + // Activity backgrounds use the theme's text, including stale values. + pen = ImGui::GetColorU32(ImGuiCol_Text); + dl->AddRectFilled(r.Min, r.Max, toImU32(byteColor((*colors)[i]))); } drawText(dl, r, utils::hexByte(bytes[i]), pen, font, font_size); } diff --git a/openpilot/tools/cabana/ui/widgets/messagebytes.h b/openpilot/tools/cabana/ui/widgets/messagebytes.h index 7c7aabc984..163a5805ae 100644 --- a/openpilot/tools/cabana/ui/widgets/messagebytes.h +++ b/openpilot/tools/cabana/ui/widgets/messagebytes.h @@ -15,6 +15,6 @@ ImVec2 byteCellSize(); // one "00 " cell ImVec2 bytesCellSize(int n, bool multiple_lines); // a cell of n bytes, the table's cell padding included ImU32 cellTextColor(bool selected, bool inactive); // inactive rows gray the text and fade the highlighted text -void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive); +void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive, bool align_right = false); void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vector &bytes, const std::vector *colors, bool selected, bool inactive, bool multiple_lines); diff --git a/openpilot/tools/cabana/ui/widgets/messageswidget.cc b/openpilot/tools/cabana/ui/widgets/messageswidget.cc index a3cacea680..e648113526 100644 --- a/openpilot/tools/cabana/ui/widgets/messageswidget.cc +++ b/openpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -23,11 +23,12 @@ namespace { const char *COLUMN_TITLES[MessageList::COLUMN_COUNT] = {"Name", "Bus", "ID", "Node", "Freq", "Count", "Bytes"}; constexpr float DEFAULT_SECTION_SIZE = 100.0f; -// surrounding whitespace is ignored; no sign, no 0x prefix +// surrounding whitespace is ignored; no sign, optional 0x prefix for hex unsigned int toUInt(const std::string &s, bool *ok, int base) { const char *b = s.data(), *e = b + s.size(); while (b < e && std::isspace((unsigned char)*b)) ++b; while (e > b && std::isspace((unsigned char)e[-1])) --e; + if (base == 16 && e - b >= 2 && b[0] == '0' && (b[1] == 'x' || b[1] == 'X')) b += 2; unsigned int v = 0; auto [p, ec] = std::from_chars(b, e, v, base); *ok = b < e && p == e && ec == std::errc(); @@ -505,7 +506,9 @@ void MessagesWidget::drawRow(int row) { if (column == MessageList::DATA && seen) { drawBytesCell(ImGui::GetWindowDrawList(), rect, m.dat, &m.colors, selected, inactive, multiple_lines); } else { - drawTextCell(ImGui::GetWindowDrawList(), rect, cellText(item, column), selected, inactive); + const bool align_right = seen && (column == MessageList::SOURCE || column == MessageList::COUNT || + (column == MessageList::FREQ && m.freq > 0)); + drawTextCell(ImGui::GetWindowDrawList(), rect, cellText(item, column), selected, inactive, align_right); } // the Selectable already sized its cell if (!row_item) ImGui::Dummy(ImVec2(width, row_height)); diff --git a/openpilot/tools/cabana/ui/widgets/signalview.cc b/openpilot/tools/cabana/ui/widgets/signalview.cc index bc8b9c4d23..6562903935 100644 --- a/openpilot/tools/cabana/ui/widgets/signalview.cc +++ b/openpilot/tools/cabana/ui/widgets/signalview.cc @@ -267,7 +267,7 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const if (item->type == SignalModel::Item::Sig) { // color label ImRect icon_rect(rect.Min.x, rect.Min.y, rect.Min.x + COLOR_LABEL_WIDTH, rect.Max.y); - painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(item->sig->color.darker(item->highlight ? 125 : 0)), ImGui::GetStyle().FrameRounding); + painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(item->highlight ? signalHighlight(item->sig->color) : contrastColor(item->sig->color, {0, 0, 0})), ImGui::GetStyle().FrameRounding); drawText(painter, icon_rect, std::to_string(item->row() + 1).c_str(), item->highlight ? IM_COL32_WHITE : IM_COL32_BLACK, nullptr, LABEL_FONT); @@ -276,7 +276,7 @@ void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const if (item->sig->type != cabana::Signal::Type::Normal) { const std::string indicator = multiplexIndicator(item->sig); ImRect indicator_rect(rect.Min.x, rect.Min.y, rect.Min.x + ImGui::CalcTextSize(indicator.c_str()).x, rect.Max.y); - painter->AddRectFilled(indicator_rect.Min, indicator_rect.Max, IM_COL32(160, 160, 164, 255), ImGui::GetStyle().FrameRounding); + painter->AddRectFilled(indicator_rect.Min, indicator_rect.Max, ImGui::GetColorU32(palette().badge), ImGui::GetStyle().FrameRounding); drawElidedText(painter, indicator_rect, indicator, IM_COL32_WHITE, false); rect.Min.x = indicator_rect.Max.x + h_margin * 2; } @@ -909,35 +909,36 @@ void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - H_MARGIN - button * 2 - spacing, rect.Min.y + (rect.GetHeight() - button) * 0.5f)); - ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); const auto sig = item->sig; const bool checked = item->chart_opened; const bool selected = current_sig_ == sig && current_type_ == SignalModel::Item::Sig; - auto row_button = [selected](const char *id, const char *glyph) { - if (selected) { - ImGui::PushStyleColor(ImGuiCol_Text, palette().text_selected); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, palette().header_active); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, palette().header_active); - } + auto row_button = [selected, button](const char *id, const char *glyph, bool plotted = false) { + const auto &p = palette(); + const ImVec2 button_min = ImGui::GetCursorScreenPos(); + const bool hovered = ImGui::IsWindowHovered() && + ImGui::IsMouseHoveringRect(button_min, ImVec2(button_min.x + button, button_min.y + button)); + // Both actions use neutral hover fills, including on selected rows. The plot keeps its outline. + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, plotted ? 1.0f : 0.0f); + ImGui::PushStyleColor(ImGuiCol_Button, plotted ? p.button : ImVec4(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, settings.theme == LIGHT_THEME ? ImLerp(p.button, p.text, 0.12f) : p.button_hovered); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, settings.theme == LIGHT_THEME ? ImLerp(p.button, p.text, 0.20f) : p.button_active); + ImGui::PushStyleColor(ImGuiCol_Border, ImLerp(p.button, p.text, 0.45f)); + ImGui::PushStyleColor(ImGuiCol_Text, selected && !plotted && !hovered ? p.text_selected : p.text); const bool clicked = iconButton(id, glyph); - if (selected) ImGui::PopStyleColor(3); + ImGui::PopStyleColor(5); + ImGui::PopStyleVar(); return clicked; }; - if (checked) ImGui::PushStyleColor(ImGuiCol_Button, selected ? palette().header_active : ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); - if (row_button("plot", icon::GRAPH_UP) && !editor_open_on_press_) { + if (row_button("plot", icon::GRAPH_UP, checked) && !editor_open_on_press_) { item->chart_opened = !checked; showChart(model_.msgId(), sig, item->chart_opened, ImGui::GetIO().KeyShift); } - if (checked) ImGui::PopStyleColor(); ImGui::SetItemTooltip("%s", checked ? "Close Plot" : "Show Plot\nShift-click to add to the previously opened plot"); ImGui::SameLine(0.0f, spacing); if (row_button("remove", icon::X_LG) && !editor_open_on_press_) { pending_action_ = [this, sig]() { UndoStack::instance()->push(new RemoveSigCommand(model_.msgId(), sig)); }; } ImGui::SetItemTooltip("Remove signal"); - ImGui::PopStyleColor(); - ImGui::PopStyleVar(); } ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions) { diff --git a/openpilot/tools/cabana/ui/widgets/videowidget.cc b/openpilot/tools/cabana/ui/widgets/videowidget.cc index 5c8a9ede3c..37b4348fd3 100644 --- a/openpilot/tools/cabana/ui/widgets/videowidget.cc +++ b/openpilot/tools/cabana/ui/widgets/videowidget.cc @@ -425,7 +425,7 @@ void Slider::paint(double thumbnail_time) { groove_rect.Min.y = std::floor(center_y - groove_height / 2); groove_rect.Max.y = groove_rect.Min.y + groove_height; - p->AddRectFilled(groove_rect.Min, groove_rect.Max, timeline_colors[(int)TimelineType::None], groove_height * 0.5f); + p->AddRectFilled(groove_rect.Min, groove_rect.Max, toImU32(graphicColor(fromImVec4(ImGui::ColorConvertU32ToFloat4(timeline_colors[(int)TimelineType::None])), palette().window)), groove_height * 0.5f); double min = minimum() / factor; double max = maximum() / factor; @@ -444,7 +444,7 @@ void Slider::paint(double thumbnail_time) { if (auto replay = getReplay()) { for (const auto &entry : *replay->getTimeline()) { - fillRange(entry.start_time, entry.end_time, timeline_colors[(int)entry.type]); + fillRange(entry.start_time, entry.end_time, toImU32(graphicColor(fromImVec4(ImGui::ColorConvertU32ToFloat4(timeline_colors[(int)entry.type])), palette().window))); } ImU32 empty_color = ImGui::GetColorU32(ImGuiCol_WindowBg, 160 / 255.0f); @@ -531,8 +531,9 @@ void StreamCameraView::draw(const ImVec2 &size, double thumbnail_time) { const char *text = "PAUSED"; const ImVec2 text_size = font->CalcTextSizeA(POINT_16_FONT_SIZE, FLT_MAX, 0.0f, text); const ImVec2 center = rect().GetCenter(); - p->AddText(font, POINT_16_FONT_SIZE, ImVec2(center.x - text_size.x / 2, center.y - text_size.y / 2), - IM_COL32(200, 200, 200, static_cast(255 * 0.7f)), text); + const ImVec2 pos(center.x - text_size.x / 2, center.y - text_size.y / 2); + p->AddRectFilled(ImVec2(pos.x - 4, pos.y - 2), ImVec2(pos.x + text_size.x + 4, pos.y + text_size.y + 2), ImGui::GetColorU32(palette().badge), ImGui::GetStyle().FrameRounding); + p->AddText(font, POINT_16_FONT_SIZE, pos, ImGui::GetColorU32(palette().text_selected), text); } } @@ -583,13 +584,16 @@ void StreamCameraView::drawTime(ImDrawList *p, const ImRect &rect, double second ImFont *font = ImGui::GetFont(); const ImVec2 text_size = font->CalcTextSizeA(POINT_10_FONT_SIZE, FLT_MAX, 0.0f, text); // centered horizontally, above the bottom margin - p->AddText(font, POINT_10_FONT_SIZE, ImVec2(rect.GetCenter().x - text_size.x / 2, rect.Max.y - THUMBNAIL_MARGIN - text_size.y), - IM_COL32_WHITE, text); + const ImVec2 pos(rect.GetCenter().x - text_size.x / 2, rect.Max.y - THUMBNAIL_MARGIN - text_size.y); + p->AddRectFilled(ImVec2(pos.x - 4, pos.y - 2), ImVec2(pos.x + text_size.x + 4, pos.y + text_size.y + 2), ImGui::GetColorU32(palette().badge), ImGui::GetStyle().FrameRounding); + p->AddText(font, POINT_10_FONT_SIZE, pos, ImGui::GetColorU32(palette().text_selected), text); } void StreamCameraView::drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size, float rounding) { const ImU32 pen = IM_COL32_WHITE; - ImU32 color = withAlpha(timeline_colors[int(alert.type)], 128); + // Opaque backing keeps alert text readable over every camera frame. + const auto source = fromImVec4(ImGui::ColorConvertU32ToFloat4(timeline_colors[int(alert.type)])); + const ImU32 color = toImU32(contrastColor(source, fromImVec4(palette().text_selected))); std::string text = alert.text1; if (!alert.text2.empty()) text += "\n" + alert.text2; diff --git a/openpilot/tools/jotpluggler/app.cc b/openpilot/tools/jotpluggler/app.cc index e6ba696bae..5cdcff5ed2 100644 --- a/openpilot/tools/jotpluggler/app.cc +++ b/openpilot/tools/jotpluggler/app.cc @@ -1614,6 +1614,7 @@ void draw_pane_windows(AppSession *session, UiState *state) { void draw_workspace(AppSession *session, const UiMetrics &ui, UiState *state) { state->custom_series.selected = false; state->logs.selected = false; + state->init_data_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)); @@ -1711,6 +1712,11 @@ void draw_workspace(AppSession *session, const UiMetrics &ui, UiState *state) { draw_logs_tab(session, state); ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("initData##workspace_init_data")) { + state->init_data_selected = true; + draw_init_data_tab(session, state); + ImGui::EndTabItem(); + } if (custom_series_tab_open) { ImGuiTabItemFlags custom_flags = ImGuiTabItemFlags_None; if (state->custom_series.request_select) { diff --git a/openpilot/tools/jotpluggler/app.h b/openpilot/tools/jotpluggler/app.h index 9a6777ee90..e089609247 100644 --- a/openpilot/tools/jotpluggler/app.h +++ b/openpilot/tools/jotpluggler/app.h @@ -168,8 +168,19 @@ struct LogEntry { struct EnumInfo { std::vector names; + bool is_text = false; + std::unordered_map text_indices; + + double text_value(std::string text) { + is_text = true; + auto [it, inserted] = text_indices.try_emplace(text, names.size()); + if (inserted) names.push_back(std::move(text)); + return static_cast(it->second); + } }; +void merge_text_labels(RouteSeries *incoming, const EnumInfo &labels, EnumInfo *destination); + struct SeriesFormat { int decimals = 3; bool integer_like = false; @@ -316,7 +327,19 @@ struct RouteIdentifier { } }; +struct InitDataSection { + std::string name; + std::vector> values; +}; + +struct InitDataSnapshot { + std::vector sections; +}; + +InitDataSnapshot extract_init_data(cereal::InitData::Reader reader); + struct RouteData { + std::optional init_data; std::vector series; std::vector paths; std::vector roots; @@ -340,6 +363,7 @@ struct RouteData { }; struct StreamExtractBatch { + std::optional init_data; std::vector series; std::vector can_messages; std::vector logs; @@ -706,6 +730,8 @@ struct UiState { DbcEditorState dbc_editor; CustomSeriesEditorState custom_series; LogsUiState logs; + bool init_data_selected = false; + std::string init_data_search; UndoStack undo; }; @@ -767,6 +793,7 @@ void draw_custom_series_editor(AppSession *session, UiState *state); // ***** void draw_logs_tab(AppSession *session, UiState *state); +void draw_init_data_tab(AppSession *session, UiState *state); // ***** // map diff --git a/openpilot/tools/jotpluggler/browser.cc b/openpilot/tools/jotpluggler/browser.cc index 91e4a707da..3342e82b37 100644 --- a/openpilot/tools/jotpluggler/browser.cc +++ b/openpilot/tools/jotpluggler/browser.cc @@ -310,8 +310,9 @@ std::string format_display_value(double display_value, 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)]; + && (enum_info->is_text || !enum_info->names[static_cast(idx)].empty())) { + const std::string &name = enum_info->names[static_cast(idx)]; + return name.empty() ? "(empty)" : name; } } char buf[64] = {}; @@ -416,6 +417,18 @@ void draw_browser_node(AppSession *session, app_pop_mono_font(); } + if (hovered) { + auto it = session->route_data.enum_info.find(node.full_path); + if (it != session->route_data.enum_info.end() && it->second.is_text) { + ImGui::BeginTooltip(); + ImGui::TextUnformatted(node.full_path.c_str()); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 35.0f); + ImGui::TextWrapped("%s", value_text.c_str()); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } + } + if (clicked) { const bool shift_down = ImGui::GetIO().KeyShift; const bool ctrl_down = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeySuper; diff --git a/openpilot/tools/jotpluggler/common.cc b/openpilot/tools/jotpluggler/common.cc index 50f5fc0b95..4136063607 100644 --- a/openpilot/tools/jotpluggler/common.cc +++ b/openpilot/tools/jotpluggler/common.cc @@ -185,3 +185,14 @@ std::string route_google_maps_url(const GpsTrace &trace) { if (url.size() <= 1900 || n == 0) return url; } } + +void merge_text_labels(RouteSeries *incoming, const EnumInfo &labels, EnumInfo *destination) { + std::vector remap; + remap.reserve(labels.names.size()); + for (const std::string &text : labels.names) { + remap.push_back(destination->text_value(text)); + } + for (double &value : incoming->values) { + value = remap.at(static_cast(value)); + } +} diff --git a/openpilot/tools/jotpluggler/generate_event_extractors.py b/openpilot/tools/jotpluggler/generate_event_extractors.py index a424ebc237..2dc506e4be 100644 --- a/openpilot/tools/jotpluggler/generate_event_extractors.py +++ b/openpilot/tools/jotpluggler/generate_event_extractors.py @@ -18,7 +18,7 @@ SCALAR_KINDS = { "enum": "Enum", } NESTED_TYPE_KINDS = {"struct", "list"} -IGNORED_TYPE_KINDS = {"void", "text", "data", "interface", "anyPointer"} +IGNORED_TYPE_KINDS = {"void", "data", "interface", "anyPointer"} def cxx_string(value): @@ -114,6 +114,11 @@ class Generator: self.emit(indent, f"append_fixed_scalar_point(&series->fixed_series[{slot}], tm, {double_expr});") return + if type_kind == "text": + target = f"ensure_dynamic_series({path_expr}, series)" if dynamic_path else f"&series->fixed_series[{self.add_fixed_path(path)}]" + self.emit(indent, f"append_text_point({target}, tm, {expr}, series);") + return + if type_kind == "struct": self.emit_struct(indent, schema, expr, path, path_expr, dynamic_path) return @@ -180,7 +185,7 @@ class Generator: self.emit(indent, f"const std::string {base_path_var} = {cxx_string(path)};") elem_scalar = scalar_kind(elem_type) - if elem_scalar is not None: + if elem_scalar is not None or elem_kind == "text": self.emit(indent, f"if ({list_expr}.size() <= 16) {{") index_var = self.tmp("i") self.emit(indent + 2, f"for (uint {index_var} = 0; {index_var} < {list_expr}.size(); ++{index_var}) {{") @@ -188,7 +193,10 @@ class Generator: self.emit(indent + 4, f"RouteSeries *{item_series} = ensure_list_scalar_series({base_path_var}, {index_var}, series);") if elem_scalar == "Enum": self.emit_enum_capture(indent + 4, f"{item_series}->path", enum_names(schema.elementType)) - self.emit(indent + 4, f"append_fixed_scalar_point({item_series}, tm, {self.scalar_double_expr(f'{list_expr}[{index_var}]', elem_scalar)});") + if elem_kind == "text": + self.emit(indent + 4, f"append_text_point({item_series}, tm, {list_expr}[{index_var}], series);") + else: + self.emit(indent + 4, f"append_fixed_scalar_point({item_series}, tm, {self.scalar_double_expr(f'{list_expr}[{index_var}]', elem_scalar)});") self.emit(indent + 2, "}") self.emit(indent, "}") return @@ -207,7 +215,7 @@ class Generator: self.emit(indent, "}") def node_emits(self, type_kind, type_proto, schema, seen=frozenset()): - if scalar_kind(type_proto) is not None: + if scalar_kind(type_proto) is not None or type_kind == "text": return True if type_kind == "struct": if schema is None: @@ -238,7 +246,7 @@ class Generator: elem_kind = elem_type.which() if elem_kind in IGNORED_TYPE_KINDS: return False - if scalar_kind(elem_type) is not None: + if scalar_kind(elem_type) is not None or elem_kind == "text": return True if elem_kind == "struct": return self.node_emits("struct", None, schema.elementType, seen) diff --git a/openpilot/tools/jotpluggler/init_data.cc b/openpilot/tools/jotpluggler/init_data.cc new file mode 100644 index 0000000000..d4d2e8ed05 --- /dev/null +++ b/openpilot/tools/jotpluggler/init_data.cc @@ -0,0 +1,113 @@ +#include "tools/jotpluggler/app.h" + +#include + +namespace { + +std::string format_data(capnp::Data::Reader data) { + const bool text = std::all_of(data.begin(), data.end(), [](uint8_t c) { + return (c >= 32 && c < 127) || c == '\n' || c == '\r' || c == '\t'; + }); + if (data.size() == 0) return ""; + if (text) return std::string(reinterpret_cast(data.begin()), data.size()); + constexpr char hex[] = "0123456789abcdef"; + std::string value = "hex: "; + for (uint8_t c : data) { + value += hex[c >> 4]; + value += hex[c & 15]; + } + return value; +} + +std::string format_value(capnp::DynamicValue::Reader value) { + if (value.getType() == capnp::DynamicValue::DATA) return format_data(value.as()); + if (value.getType() == capnp::DynamicValue::TEXT) { + const auto text = value.as(); + return std::string(text.begin(), text.size()); + } + return kj::str(value).cStr(); +} + +} // namespace + +InitDataSnapshot extract_init_data(cereal::InitData::Reader reader) { + InitDataSnapshot snapshot; + InitDataSection metadata{"Device & software", {}}; + auto dynamic = capnp::toDynamic(reader); + for (auto field : dynamic.getSchema().getFields()) { + const std::string name = field.getProto().getName().cStr(); + if (name == "deprecated") continue; + if (name == "params" || name == "commands") { + InitDataSection section{name, {}}; + auto entries = dynamic.get(field).as().get("entries").as(); + for (auto entry : entries) { + auto item = entry.as(); + section.values.emplace_back(format_value(item.get("key")), format_value(item.get("value"))); + } + std::sort(section.values.begin(), section.values.end()); + snapshot.sections.push_back(std::move(section)); + } else { + metadata.values.emplace_back(name, format_value(dynamic.get(field))); + } + } + snapshot.sections.insert(snapshot.sections.begin(), std::move(metadata)); + return snapshot; +} + +void draw_init_data_tab(AppSession *session, UiState *state) { + ImGui::SetNextItemWidth(280.0f); + input_text_with_hint_string("##init_data_search", "Search fields and values...", &state->init_data_search); + ImGui::SameLine(); + ImGui::TextDisabled("Right-click a value to copy"); + ImGui::Separator(); + if (!session->route_data.init_data) { + const auto load = session->route_loader ? session->route_loader->snapshot() : RouteLoadSnapshot{}; + ImGui::TextWrapped("%s", load.active ? "Loading initData..." : "No initData available for this route or stream."); + return; + } + const std::string query = lowercase_copy(state->init_data_search); + const auto matches = [&](const auto &row) { + return query.empty() || lowercase_copy(row.first).find(query) != std::string::npos + || lowercase_copy(row.second).find(query) != std::string::npos; + }; + bool found = false; + if (ImGui::BeginChild("##init_data_content", ImVec2(0, 0), false)) { + for (const auto §ion : session->route_data.init_data->sections) { + const size_t count = std::count_if(section.values.begin(), section.values.end(), matches); + if (!query.empty() && count == 0) continue; + found = found || count > 0; + ImGui::PushID(section.name.c_str()); + const std::string label = section.name + " (" + std::to_string(count) + ")"; + if (ImGui::CollapsingHeader(label.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) { + if (section.values.empty()) ImGui::TextDisabled("No entries"); + if (ImGui::BeginTable("##values", 2, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_Resizable)) { + ImGui::TableSetupColumn("Field", ImGuiTableColumnFlags_WidthFixed, 230.0f); + ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + for (size_t i = 0; i < section.values.size(); ++i) { + const auto &[key, value] = section.values[i]; + if (!matches(section.values[i])) continue; + ImGui::PushID(static_cast(i)); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(key.c_str()); + ImGui::TableSetColumnIndex(1); + app_push_mono_font(); + ImGui::TextWrapped("%s", value.empty() ? "(empty)" : value.c_str()); + app_pop_mono_font(); + if (ImGui::BeginPopupContextItem("##copy")) { + if (ImGui::MenuItem("Copy value")) ImGui::SetClipboardText(value.c_str()); + if (ImGui::MenuItem("Copy field")) ImGui::SetClipboardText(key.c_str()); + ImGui::EndPopup(); + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + } + ImGui::PopID(); + } + if (!query.empty() && !found) ImGui::TextDisabled("No matching fields or values."); + } + ImGui::EndChild(); +} diff --git a/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json b/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json index 3197f40ad8..e82edfbf96 100644 --- a/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json +++ b/openpilot/tools/jotpluggler/layouts/driver-monitoring-debug.json @@ -1 +1 @@ -{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"children": [{"children": [{"camera_view": "driver", "curves": [], "kind": "camera", "title": "Cabin Camera"}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/alertLevel"}], "title": "..."}], "sizes": [0.5, 0.5], "split": "vertical"}, {"children": [{"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/activePolicy"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/faceDetected"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/eye"}, {"color": "#dc5234", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/phone"}, {"color": "#43a047", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/pose"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/awarenessPercent"}], "title": "..."}], "sizes": [0.25, 0.25, 0.25, 0.25], "split": "vertical"}], "sizes": [0.5, 0.5], "split": "horizontal"}}]} +{"current_tab_index": 0, "tabs": [{"name": "tab1", "root": {"children": [{"children": [{"camera_view": "driver", "curves": [], "kind": "camera", "title": "Cabin Camera"}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/alertLevel"}], "title": "..."}], "sizes": [0.5, 0.5], "split": "vertical"}, {"children": [{"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/activePolicy"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/faceDetected"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/eye"}, {"color": "#dc5234", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/phone"}, {"color": "#8e44ad", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/sleep"}, {"color": "#43a047", "name": "/driverMonitoringState/visionPolicyState/distractedTypes/pose"}], "title": "..."}, {"curves": [{"color": "#236bb4", "name": "/driverMonitoringState/visionPolicyState/awarenessPercent"}], "title": "..."}], "sizes": [0.25, 0.25, 0.25, 0.25], "split": "vertical"}], "sizes": [0.5, 0.5], "split": "horizontal"}}]} diff --git a/openpilot/tools/jotpluggler/plot.cc b/openpilot/tools/jotpluggler/plot.cc index 019eff7070..b8697c6872 100644 --- a/openpilot/tools/jotpluggler/plot.cc +++ b/openpilot/tools/jotpluggler/plot.cc @@ -117,8 +117,8 @@ ImU32 state_block_color(int value, float alpha = 1.0f) { 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; + if (!name.empty() || curve.enum_info->is_text) { + return name.empty() ? "(empty)" : name; } } return std::to_string(value); diff --git a/openpilot/tools/jotpluggler/render.cc b/openpilot/tools/jotpluggler/render.cc index 54f0c16cc3..f7d991d2f9 100644 --- a/openpilot/tools/jotpluggler/render.cc +++ b/openpilot/tools/jotpluggler/render.cc @@ -83,7 +83,7 @@ void render_layout(AppSession *session, UiState *state, bool show_camera_feed) { 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) { + if (!state->custom_series.selected && !state->logs.selected && !state->init_data_selected) { draw_pane_windows(session, state); } draw_status_bar(*session, ui, state); diff --git a/openpilot/tools/jotpluggler/runtime.cc b/openpilot/tools/jotpluggler/runtime.cc index d7d0fc79c4..dfbb5474c9 100644 --- a/openpilot/tools/jotpluggler/runtime.cc +++ b/openpilot/tools/jotpluggler/runtime.cc @@ -48,7 +48,8 @@ CameraType decoder_camera_type(CameraViewKind view) { } bool stream_batch_has_data(const StreamExtractBatch &batch) { - return !batch.series.empty() + return batch.init_data.has_value() + || !batch.series.empty() || !batch.can_messages.empty() || !batch.logs.empty() || !batch.timeline.empty() @@ -526,6 +527,7 @@ struct StreamPoller::Impl { } merge_can_message_data(&dst->can_messages[it->second], &message); } + if (src->init_data) dst->init_data = std::move(src->init_data); if (!src->logs.empty()) { dst->logs.insert(dst->logs.end(), std::make_move_iterator(src->logs.begin()), diff --git a/openpilot/tools/jotpluggler/sketch_layout.cc b/openpilot/tools/jotpluggler/sketch_layout.cc index 5440d9dd8d..bca20ec20c 100644 --- a/openpilot/tools/jotpluggler/sketch_layout.cc +++ b/openpilot/tools/jotpluggler/sketch_layout.cc @@ -74,6 +74,8 @@ struct SeriesAccumulator { void append_fixed_scalar_point(RouteSeries *series, double tm, double value); void append_dynamic_scalar_point(const std::string &path, double tm, double value, SeriesAccumulator *series); +RouteSeries *ensure_dynamic_series(const std::string &path, SeriesAccumulator *series); +void append_text_point(RouteSeries *series, double tm, capnp::Text::Reader text, SeriesAccumulator *accumulator); RouteSeries *ensure_list_scalar_series(const std::string &base_path, size_t index, SeriesAccumulator *series); void append_can_frame(CanServiceKind service, uint8_t bus, @@ -94,6 +96,7 @@ void decode_can_frame(const dbc::Database *can_dbc, #include "tools/jotpluggler/generated_event_extractors.h" struct LoadedRouteArtifacts { + std::optional init_data; std::vector series; std::vector can_messages; std::vector logs; @@ -317,20 +320,18 @@ std::map trim_segments(std::map segments, co 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); - } + for (const auto &[number, files] : json.object_items()) { + segments[std::stoi(number)] = { + files["rlog"].string_value(), files["qlog"].string_value(), files["narrow_road"].string_value(), + files["cabin"].string_value(), files["wide_road"].string_value(), files["qcamera"].string_value(), + }; } return segments; } std::map load_segments_from_server(const RouteSelection &route) { - const std::string result = PyDownloader::getRouteFiles(route.canonical_name); + const std::string selector = route.selector == LogSelector::RLog ? "r" : route.selector == LogSelector::QLog ? "q" : "a"; + const std::string result = PyDownloader::resolveRouteFiles(route.canonical_name, route.begin_segment, route.end_segment, selector); if (result.empty()) throw std::runtime_error("Failed to fetch route files for " + route.canonical_name); std::string parse_error; @@ -958,7 +959,14 @@ void append_can_frame(CanServiceKind service, }); } +void append_text_point(RouteSeries *series, double tm, capnp::Text::Reader text, SeriesAccumulator *accumulator) { + const double value = accumulator->enum_info[series->path].text_value(std::string(text.begin(), text.size())); + append_fixed_scalar_point(series, tm, value); +} + void append_dynamic_scalar_point(const std::string &path, double tm, double value, SeriesAccumulator *series); +RouteSeries *ensure_dynamic_series(const std::string &path, SeriesAccumulator *series); +void append_text_point(RouteSeries *series, double tm, capnp::Text::Reader text, SeriesAccumulator *accumulator); void decode_can_frame(const dbc::Database *can_dbc, const std::string &service_name, @@ -1115,6 +1123,15 @@ void merge_series_accumulator(SeriesAccumulator *dst, SeriesAccumulator *src) { throw std::runtime_error("Fixed-series slot count mismatch during merge"); } + const auto remap_text = [&](RouteSeries &series) { + auto it = src->enum_info.find(series.path); + if (it != src->enum_info.end() && it->second.is_text) { + merge_text_labels(&series, it->second, &dst->enum_info[series.path]); + } + }; + for (RouteSeries &series : src->fixed_series) remap_text(series); + for (RouteSeries &series : src->dynamic_series) remap_text(series); + for (size_t i = 0; i < dst->fixed_series.size(); ++i) { merge_route_series(&dst->fixed_series[i], &src->fixed_series[i]); } @@ -1201,9 +1218,12 @@ RouteData build_route_data(std::vector &&series_list, 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()); + // initData is copied into every segment with its original route timestamp. + if (series.path.rfind("/initData/", 0) != 0) { + route_data.x_min = !route_data.has_time_range ? series.times.front() : std::min(route_data.x_min, series.times.front()); + route_data.x_max = !route_data.has_time_range ? series.times.back() : std::max(route_data.x_max, series.times.back()); + route_data.has_time_range = true; + } route_data.paths.push_back(series.path); route_data.series.push_back(std::move(series)); } @@ -1560,6 +1580,7 @@ LoadedRouteArtifacts load_route_series_parallel( LoadStats *stats) { struct SegmentResult { SeriesAccumulator series; + std::optional init_data; std::vector logs; std::vector timeline; std::vector thumbnails; @@ -1624,6 +1645,13 @@ LoadedRouteArtifacts load_route_series_parallel( 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); + for (const Event &record : reader.events) { + if (record.which != cereal::Event::Which::INIT_DATA) continue; + with_parseable_event(record.data, [&](const cereal::Event::Reader &event) { + results[index].init_data = extract_init_data(event.getInitData()); + }); + if (results[index].init_data) break; + } results[index].timeline = extract_segment_timeline(reader.events); results[index].thumbnails = extract_segment_thumbnails(reader.events, segment_number); segment_stats.extract_seconds = std::chrono::duration(LoadStats::Clock::now() - extract_start).count(); @@ -1671,6 +1699,12 @@ LoadedRouteArtifacts load_route_series_parallel( } } LoadedRouteArtifacts artifacts; + for (SegmentResult &result : results) { + if (result.init_data) { + artifacts.init_data = std::move(result.init_data); + break; + } + } artifacts.series = collect_series(std::move(merged)); artifacts.can_messages = std::move(merged.can_messages); artifacts.logs = std::move(logs); @@ -1720,6 +1754,7 @@ std::vector collect_route_roots_for_paths(const std::vector init_data; const SchemaIndex &schema = SchemaIndex::instance(); SeriesAccumulator series = make_series_accumulator(schema); std::vector logs; @@ -1762,6 +1797,9 @@ void StreamAccumulator::appendEvent(kj::ArrayPtr data) { if (!impl_->time_offset.has_value()) { impl_->time_offset = boot_time; } + if (which == cereal::Event::Which::INIT_DATA) { + impl_->init_data = extract_init_data(event.getInitData()); + } if (which == cereal::Event::Which::CAR_PARAMS) { const std::string fingerprint = event.getCarParams().getCarFingerprint().cStr(); if (!fingerprint.empty() && fingerprint != impl_->car_fingerprint) { @@ -1803,6 +1841,8 @@ void StreamAccumulator::appendCanFrames(CanServiceKind service, const std::vecto StreamExtractBatch StreamAccumulator::takeBatch() { StreamExtractBatch batch; + batch.init_data = std::move(impl_->init_data); + impl_->init_data.reset(); batch.car_fingerprint = impl_->car_fingerprint; batch.dbc_name = impl_->detected_dbc_name; if (impl_->time_offset.has_value()) { @@ -1885,6 +1925,7 @@ RouteData load_route_data(const std::string &route_name, std::move(artifacts.enum_info), metadata.car_fingerprint, resolved_dbc); + route_data.init_data = std::move(artifacts.init_data); route_data.route_id = make_route_identifier(route, segments); build_camera_index(segments, route_data, &SegmentLogs::narrow_road, "narrowRoadEncodeIdx", &route_data.road_camera); build_camera_index(segments, route_data, &SegmentLogs::cabin, "cabinEncodeIdx", &route_data.cabin_camera); diff --git a/openpilot/tools/jotpluggler/stream.cc b/openpilot/tools/jotpluggler/stream.cc index fcfa6585bb..35bca3d3c5 100644 --- a/openpilot/tools/jotpluggler/stream.cc +++ b/openpilot/tools/jotpluggler/stream.cc @@ -72,6 +72,7 @@ bool can_message_less(const CanMessageData &a, const CanMessageData &b) { } void apply_stream_batch(AppSession *session, UiState *state, StreamExtractBatch batch) { + if (batch.init_data) session->route_data.init_data = std::move(batch.init_data); if (batch.has_time_offset) { session->stream_time_offset = batch.time_offset; } @@ -83,7 +84,7 @@ void apply_stream_batch(AppSession *session, UiState *state, StreamExtractBatch } if (!batch.enum_info.empty()) { for (auto &[path, info] : batch.enum_info) { - session->route_data.enum_info[path] = std::move(info); + if (!info.is_text) session->route_data.enum_info[path] = std::move(info); } } @@ -92,6 +93,10 @@ void apply_stream_batch(AppSession *session, UiState *state, StreamExtractBatch std::vector touched_paths; touched_paths.reserve(batch.series.size()); for (RouteSeries &incoming : batch.series) { + auto labels = batch.enum_info.find(incoming.path); + if (labels != batch.enum_info.end() && labels->second.is_text) { + merge_text_labels(&incoming, labels->second, &session->route_data.enum_info[incoming.path]); + } touched_paths.push_back(incoming.path); auto existing_it = session->series_by_path.find(incoming.path); if (existing_it == session->series_by_path.end()) { diff --git a/openpilot/tools/lib/file_downloader.py b/openpilot/tools/lib/file_downloader.py index fd517d7ada..b473740be9 100755 --- a/openpilot/tools/lib/file_downloader.py +++ b/openpilot/tools/lib/file_downloader.py @@ -5,6 +5,7 @@ Called by C++ replay/cabana via subprocess. Subcommands: route-files - Get route file URLs as JSON + resolve-route-files - Resolve logs and cameras from API and internal storage download - Download/decompress URL to local cache, print local path decompress - Decompress a local log file, print temporary path devices - List user's devices as JSON @@ -18,6 +19,8 @@ import os import shutil import sys import tempfile +import traceback +from urllib.parse import urlparse from openpilot.common.hardware.hw import Paths @@ -100,6 +103,100 @@ def cmd_route_files(args): api_call(lambda api: api.get(f"v1/route/{args.route}/files")) +def resolve_route_files(route_name: str, begin: int = 0, end: int = -1, selector: str = "a") -> dict[int, dict[str, str]]: + from openpilot.tools.lib.filereader import DATA_ENDPOINT + from openpilot.tools.lib.file_sources import InternalUnavailableException, internal_source + from openpilot.tools.lib.route import FileName, SegmentRange + from openpilot.tools.lib.api import CommaApi + from openpilot.tools.lib.auth_config import get_token + + sr = SegmentRange(route_name) + api_error = None + try: + route_files = CommaApi(get_token()).get(f"v1/route/{sr.route_name}/files") + except Exception as e: + api_error = e + route_files = {} + fields = {"rlog": FileName.RLOG, "qlog": FileName.QLOG, + "narrow_road": FileName.FCAMERA, "cabin": FileName.DCAMERA, "wide_road": FileName.ECAMERA, "qcamera": FileName.QCAMERA} + names = {name: key for key, fns in fields.items() for name in fns} + names.update({"rlog": "rlog", "qlog": "qlog"}) + api_files: dict[int, dict[str, str]] = {} + for url in (url for urls in route_files.values() for url in urls): + _, number, name = urlparse(url).path.rsplit("/", 2) + if name in names: + api_files.setdefault(int(number), {})[names[name]] = url + # Preserve replay's inclusive segment bounds. + open_ended = end < 0 + local_internal = os.path.isdir(DATA_ENDPOINT) + if open_ended: + end = max(begin, max(api_files, default=0)) + if local_internal: + internal_dir = os.path.join(DATA_ENDPOINT, sr.dongle_id, sr.log_id) + if os.path.isdir(internal_dir): + end = max(end, max((int(name) for name in os.listdir(internal_dir) if name.isdigit()), default=0)) + indices = list(range(max(0, begin), end + 1)) + segments: dict[int, dict[str, str]] = {seg: {} for seg in indices} + internal_errors: dict[str, Exception] = {} + + def resolve(key, requested): + files = {seg: api_files[seg][key] for seg in requested if key in api_files.get(seg, {})} + missing = [seg for seg in requested if seg not in files] + if missing: + try: + files.update(internal_source(sr, missing, fields[key])) + except Exception as e: + if e is not InternalUnavailableException: + internal_errors[key] = e + for seg, path in files.items(): + segments[seg][key] = path + + log_types = ["qlog"] if selector == "q" else ["rlog"] + if selector == "a": + log_types.append("qlog") + for key in log_types: + resolve(key, [seg for seg in indices if not segments[seg]]) + if open_ended and not local_internal: + # Remote internal storage exposes individual files, not a segment listing. + # Probe the contiguous tail beyond the API's last segment until no selected log exists. + number = end + 1 + while True: + internal_errors.clear() + segments[number] = {} + for key in log_types: + if not segments[number]: + resolve(key, [number]) + if not segments[number]: + if internal_errors: + print(f"Internal discovery stopped at segment {number}; keeping resolved logs", file=sys.stderr) + traceback.print_exception(next(iter(internal_errors.values()))) + del segments[number] + break + indices.append(number) + number += 1 + available = [seg for seg in indices if segments[seg]] + if not available: + if api_error is not None: + raise api_error + raise FileNotFoundError(f"No {selector} logs found for {sr.route_name} in segments {indices} through the API or internal storage") + for key in ("narrow_road", "cabin", "wide_road", "qcamera"): + resolve(key, available) + return {seg: segments[seg] for seg in available} + + +def cmd_resolve_route_files(args): + try: + result = resolve_route_files(args.route, args.begin, args.end, args.selector) + except Exception as e: + error = f"{type(e).__name__} resolving {args.route} (begin={args.begin}, end={args.end}, selector={args.selector}): {e}" + print(error, file=sys.stderr) + traceback.print_exc() + result = {"error": error} + json.dump(result, sys.stdout) + sys.stdout.write("\n") + sys.stdout.flush() + + def cmd_download(args): url = args.url use_cache = not args.no_cache @@ -232,6 +329,13 @@ def main(): p_rf.add_argument("route") p_rf.set_defaults(func=cmd_route_files) + p_resolve = subparsers.add_parser("resolve-route-files") + p_resolve.add_argument("route") + p_resolve.add_argument("--begin", type=int, default=0) + p_resolve.add_argument("--end", type=int, default=-1, help="Inclusive last segment; negative means open-ended") + p_resolve.add_argument("--selector", choices=("a", "r", "q"), default="a") + p_resolve.set_defaults(func=cmd_resolve_route_files) + p_dl = subparsers.add_parser("download") p_dl.add_argument("url") p_dl.add_argument("--no-cache", action="store_true") diff --git a/openpilot/tools/replay/.gitignore b/openpilot/tools/replay/.gitignore index aa615770a2..720992de90 100644 --- a/openpilot/tools/replay/.gitignore +++ b/openpilot/tools/replay/.gitignore @@ -1,2 +1,3 @@ replay tests/test_replay +tests/test_route diff --git a/openpilot/tools/replay/SConscript b/openpilot/tools/replay/SConscript index 9e060bc829..f6c45b3792 100644 --- a/openpilot/tools/replay/SConscript +++ b/openpilot/tools/replay/SConscript @@ -14,3 +14,5 @@ replay_lib = replay_env.Library("replay", replay_lib_src, LIBS=base_libs, FRAMEW Export('replay_lib') replay_libs = [replay_lib] + ffmpeg_libs + ['ncurses'] + base_libs replay_env.Program("replay", ["main.cc"], LIBS=replay_libs, FRAMEWORKS=base_frameworks) +if GetOption('extras'): + replay_env.Program("tests/test_route", ["tests/test_route.cc"], LIBS=replay_libs, FRAMEWORKS=base_frameworks) diff --git a/openpilot/tools/replay/py_downloader.cc b/openpilot/tools/replay/py_downloader.cc index 286d8a7319..9d4ed04122 100644 --- a/openpilot/tools/replay/py_downloader.cc +++ b/openpilot/tools/replay/py_downloader.cc @@ -229,6 +229,11 @@ std::string getRouteFiles(const std::string &route) { return runPython(DOWNLOADER_MODULE, {"route-files", route}); } +std::string resolveRouteFiles(const std::string &route, int begin, int end, const std::string &selector) { + return runPython(DOWNLOADER_MODULE, {"resolve-route-files", route, "--begin", std::to_string(begin), + "--end", std::to_string(end), "--selector", selector}); +} + std::string authenticate(const std::string &provider, std::atomic *abort) { return runPython(AUTH_MODULE, {provider, "--json"}, abort); } diff --git a/openpilot/tools/replay/py_downloader.h b/openpilot/tools/replay/py_downloader.h index fac66dabd5..92bc62140b 100644 --- a/openpilot/tools/replay/py_downloader.h +++ b/openpilot/tools/replay/py_downloader.h @@ -18,6 +18,9 @@ std::string decompress(const std::string &path, std::atomic *abort = nullp // Returns JSON string of route files (same format as /v1/route/.../files API) std::string getRouteFiles(const std::string &route); +// Resolves API/internal files into a JSON manifest keyed by segment number. End is inclusive. +std::string resolveRouteFiles(const std::string &route, int begin, int end, const std::string &selector); + // Browser sign-in; abort closes the local callback server. Returns a JSON status. std::string authenticate(const std::string &provider, std::atomic *abort); diff --git a/openpilot/tools/replay/route.cc b/openpilot/tools/replay/route.cc index f38c2b1971..dce5661b4c 100644 --- a/openpilot/tools/replay/route.cc +++ b/openpilot/tools/replay/route.cc @@ -27,8 +27,8 @@ RouteIdentifier Route::parseRoute(const std::string &str) { const auto range_str = match[6].str(); if (!range_str.empty()) { if (separator == "/") { - int pos = range_str.find(':'); - int begin_seg = std::stoi(range_str.substr(0, pos)); + const auto pos = range_str.find(':'); + int begin_seg = pos == 0 ? 0 : std::stoi(range_str.substr(0, pos)); identifier.begin_segment = identifier.end_segment = begin_seg; if (pos != std::string::npos) { auto end_seg_str = range_str.substr(pos + 1); diff --git a/openpilot/tools/replay/tests/test_route.cc b/openpilot/tools/replay/tests/test_route.cc new file mode 100644 index 0000000000..c1eafbd4c5 --- /dev/null +++ b/openpilot/tools/replay/tests/test_route.cc @@ -0,0 +1,37 @@ +#include "common/tests/native_test.h" +#include "tools/replay/replay.h" +#include "tools/replay/route.h" + +void test_route_ranges() { + struct Range { + const char *suffix; + int begin; + int end; + }; + const Range ranges[] = { + {"", 0, -1}, + {"/:15", 0, 15}, + {"/:0", 0, 0}, + {"/0:15", 0, 15}, + {"/5:15", 5, 15}, + {"/5:", 5, -1}, + {"/5", 5, 5}, + {"--5", 5, -1}, + }; + for (const std::string route : {DEMO_ROUTE, "64e16c4237493597/00000033--332a5f9d82"}) { + const auto full = Route::parseRoute(route); + REQUIRE(!full.str.empty()); + for (const auto &range : ranges) { + const auto parsed = Route::parseRoute(route + range.suffix); + REQUIRE(parsed.str == full.str); + REQUIRE(parsed.dongle_id == full.dongle_id); + REQUIRE(parsed.timestamp == full.timestamp); + REQUIRE(parsed.begin_segment == range.begin); + REQUIRE(parsed.end_segment == range.end); + } + } +} + +int main() { + return run_native_test(test_route_ranges); +} diff --git a/panda b/panda index 4230530f5e..9b969b15bf 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit 4230530f5ec4c7f11971f868ae9930b3547346eb +Subproject commit 9b969b15bf3e822567eb266cfae679353e167088 diff --git a/tinygrad_repo b/tinygrad_repo index fe5d3169ba..387d6bdec8 160000 --- a/tinygrad_repo +++ b/tinygrad_repo @@ -1 +1 @@ -Subproject commit fe5d3169ba4f41d0947ad174925f413cbea9d056 +Subproject commit 387d6bdec8cd1c0888674216d776f541f9535ece diff --git a/tools/release/README.md b/tools/release/README.md index 7e2e7e2faf..25ae32f704 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -21,7 +21,7 @@ - [ ] update from new release -> previous release - [ ] fresh install with `openpilot-test.comma.ai` - [ ] drive on fresh install - - [ ] no submodules or LFS + - [ ] no submodules; only the Chestnut big driving model uses LFS - [ ] check MTBF, etc. - [ ] stress test passes in production - [ ] publish the blog post diff --git a/tools/release/build_release.sh b/tools/release/build_release.sh index dd6c22e4cf..d83bcc7ec1 100755 --- a/tools/release/build_release.sh +++ b/tools/release/build_release.sh @@ -39,6 +39,7 @@ cd "$SOURCE_DIR" # in the directory cd "$BUILD_DIR" +source "$SOURCE_DIR/tools/release/setup_lfs.sh" # use the full CPU available for speeding up the build. # openpilot resets the CPU frequencies when test_onroad.py runs below. @@ -58,9 +59,10 @@ else scons panda/ fi -find openpilot/selfdrive/modeld/models -name '*.pkl' -size +95M -exec ./openpilot/common/file_chunker.py {} \; if [ -n "$INCLUDE_BIG_MODEL" ]; then - test -f openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl.chunkmanifest + test -f openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl + test -f openpilot/selfdrive/modeld/models/big_driving_warp_1344x760_tinygrad.pkl + test -f openpilot/selfdrive/modeld/models/big_driving_warp_1928x1208_tinygrad.pkl fi # Ensure no submodules in release @@ -96,6 +98,7 @@ VERSION=$(cat openpilot/sunnypilot/common/version.h | awk -F[\"-] '{print $2}') # writing larger objects is faster than compressing them on-device git -c core.compression=0 add -f . git -c core.compression=0 -c gc.auto=0 commit -m "openpilot v$VERSION" +source "$SOURCE_DIR/tools/release/check_file_sizes.sh" # Run tests cd "$BUILD_DIR" @@ -108,6 +111,7 @@ for branch in ${RELEASE_BRANCH//,/ }; do REFS+=("$BUILD_BRANCH:$branch") done # uploading the larger pack is faster than spending CPU to optimize it -git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin "${REFS[@]}" +# The big model is already published to LFS by the source branch. +GIT_LFS_SKIP_PUSH=1 git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin "${REFS[@]}" echo "[-] done T=$SECONDS" diff --git a/tools/release/build_stripped.sh b/tools/release/build_stripped.sh index 5177ac2592..c854aeb18e 100755 --- a/tools/release/build_stripped.sh +++ b/tools/release/build_stripped.sh @@ -39,7 +39,7 @@ cd "$SOURCE_DIR" cd "$TARGET_DIR" rm -rf .git/modules/ -find openpilot/selfdrive/modeld/models -name '*.pkl' -size +95M -exec ./openpilot/common/file_chunker.py {} \; +source "$SOURCE_DIR/tools/release/setup_lfs.sh" # include source commit hash and build date in commit GIT_HASH=$(git --git-dir="$SOURCE_DIR/.git" rev-parse HEAD) @@ -61,29 +61,20 @@ date: $DATETIME master commit: $GIT_HASH " -# should be no submodules or LFS files +# should be no submodules or unexpected LFS files git submodule status -if [ ! -z "$(git lfs ls-files)" ]; then +if [ -z "$INCLUDE_BIG_MODEL" ] && [ -n "$(git lfs ls-files)" ]; then echo "LFS files detected!" exit 1 fi -# ensure files are within GitHub's limit -BIG_FILES="$(find . -type f -not -path './.git/*' -size +95M)" -if [ ! -z "$BIG_FILES" ]; then - printf '\n\n\n' - echo "Found files exceeding GitHub's 100MB limit:" - echo "$BIG_FILES" - exit 1 -fi +source "$SOURCE_DIR/tools/release/check_file_sizes.sh" if [ ! -z "$BRANCH" ]; then echo "[-] Pushing to $BRANCH T=$SECONDS" - # Reset hooks since releases exclude .venv - git config --local core.hooksPath .git/hooks - git lfs update --force # uploading the larger pack is faster than spending CPU to optimize it - git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin "tmp:$BRANCH" + # The big model is already published to LFS by the source branch. + GIT_LFS_SKIP_PUSH=1 git -c pack.window=0 -c pack.depth=0 -c pack.compression=0 push -f origin "tmp:$BRANCH" fi echo "[-] done T=$SECONDS, ready at $TARGET_DIR" diff --git a/tools/release/check_file_sizes.sh b/tools/release/check_file_sizes.sh new file mode 100644 index 0000000000..2563e78c2b --- /dev/null +++ b/tools/release/check_file_sizes.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +BIG_FILES="$(git ls-tree -rl HEAD | awk '$4 > 95 * 1024 * 1024 {print $5}')" +if [ -n "$BIG_FILES" ]; then + echo "Found Git blobs exceeding the 95 MiB release limit:" + echo "$BIG_FILES" + exit 1 +fi diff --git a/tools/release/release_files.py b/tools/release/release_files.py index bcf2d148a3..3f22497999 100755 --- a/tools/release/release_files.py +++ b/tools/release/release_files.py @@ -14,7 +14,7 @@ blacklist = [ "matlab.*.md", - # no LFS or submodules in release + # release scripts configure LFS for the big driving model and warps ".lfsconfig", ".gitattributes", ".git$", @@ -24,8 +24,7 @@ blacklist = [ ] # gets you through the blacklist -whitelist: list[str] = [ -] +whitelist: list[str] = [r"^\.lfsconfig$"] if os.getenv("INCLUDE_BIG_MODEL") else [] if __name__ == "__main__": @@ -35,7 +34,7 @@ if __name__ == "__main__": continue rf = os.fsdecode(tracked_file) - if not os.getenv("INCLUDE_BIG_MODEL") and rf.startswith("openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl"): + if not os.getenv("INCLUDE_BIG_MODEL") and rf.startswith("openpilot/selfdrive/modeld/models/big_"): continue blacklisted = any(re.search(p, rf) for p in blacklist) whitelisted = any(re.search(p, rf) for p in whitelist) diff --git a/tools/release/setup_lfs.sh b/tools/release/setup_lfs.sh new file mode 100644 index 0000000000..e2122a87cb --- /dev/null +++ b/tools/release/setup_lfs.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +if [ -n "$INCLUDE_BIG_MODEL" ]; then + echo 'openpilot/selfdrive/modeld/models/big_*_tinygrad.pkl filter=lfs diff=lfs merge=lfs -text' > .gitattributes +fi + +# Reset filters and hooks since releases exclude .venv. +git config --local core.hooksPath "$(git rev-parse --git-common-dir)/hooks" +git lfs install --local --force diff --git a/tools/setup_dependencies.sh b/tools/setup_dependencies.sh index c9e635836b..6f023f7b53 100755 --- a/tools/setup_dependencies.sh +++ b/tools/setup_dependencies.sh @@ -72,7 +72,8 @@ function install_linux_deps() { fi if [[ -d "/etc/udev/rules.d/" ]]; then - "${SUDO[@]}" tee /etc/udev/rules.d/11-openpilot.rules > /dev/null <<-EOF + local udev_rules + udev_rules=$(cat <<-EOF # Panda Jungle devices SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddcf", MODE="0666" SUBSYSTEM=="usb", ATTRS{idVendor}=="3801", ATTRS{idProduct}=="ddef", MODE="0666" @@ -89,11 +90,17 @@ function install_linux_deps() { # comma devices over ADB SUBSYSTEM=="usb", ATTR{idVendor}=="04d8", ATTR{idProduct}=="1234", ENV{adb_user}="yes" EOF + ) - # delete the old ones - "${SUDO[@]}" rm -f /etc/udev/rules.d/11-panda.rules /etc/udev/rules.d/12-panda_jungle.rules /etc/udev/rules.d/50-comma-adb.rules + if ! cmp -s /etc/udev/rules.d/11-openpilot.rules <<< "$udev_rules" || + [[ -e /etc/udev/rules.d/11-panda.rules || -e /etc/udev/rules.d/12-panda_jungle.rules || -e /etc/udev/rules.d/50-comma-adb.rules ]]; then + "${SUDO[@]}" tee /etc/udev/rules.d/11-openpilot.rules > /dev/null <<< "$udev_rules" - "${SUDO[@]}" udevadm control --reload-rules && "${SUDO[@]}" udevadm trigger || true + # delete the old ones + "${SUDO[@]}" rm -f /etc/udev/rules.d/11-panda.rules /etc/udev/rules.d/12-panda_jungle.rules /etc/udev/rules.d/50-comma-adb.rules + + "${SUDO[@]}" udevadm control --reload-rules && "${SUDO[@]}" udevadm trigger || true + fi fi }