7c9d3961-8e4f-472b-bb13-7cfed5343cfb/100

gwm-driving
Yassine Yousfi 22 hours ago
commit 91a1cea814
  1. 5
      .github/workflows/selfdrive_tests.yaml
  2. 9
      RELEASES.md
  3. 1
      SConstruct
  4. 4
      common/api.py
  5. 15
      docs/CARS.md
  6. 2
      msgq_repo
  7. 2
      opendbc_repo
  8. 2
      panda
  9. 4
      pyproject.toml
  10. 3
      selfdrive/assets/fonts/NotoColorEmoji-Regular.ttf
  11. 5
      selfdrive/locationd/torqued.py
  12. 4
      selfdrive/modeld/models/driving_policy.onnx
  13. 4
      selfdrive/modeld/models/driving_vision.onnx
  14. 2
      selfdrive/pandad/pandad.cc
  15. 27
      selfdrive/pandad/tests/test_pandad.py
  16. 6
      selfdrive/selfdrived/events.py
  17. 5
      selfdrive/ui/onroad/augmented_road_view.py
  18. 4
      selfdrive/ui/widgets/pairing_dialog.py
  19. 9
      system/hardware/base.py
  20. 20
      system/hardware/tici/hardware.py
  21. 4
      system/hardware/tici/tests/test_power_draw.py
  22. 13
      system/ubloxd/glonass_fix.patch
  23. 89
      system/ubloxd/tests/ubloxd.py
  24. 16
      system/ui/lib/shader_polygon.py
  25. 33
      system/ui/widgets/__init__.py
  26. 11
      system/ui/widgets/button.py
  27. 2
      system/updated/updated.py
  28. 7
      system/version.py
  29. 2
      tools/cabana/mainwin.cc
  30. 24
      tools/install_ubuntu_dependencies.sh
  31. 67
      tools/jotpluggler/README.md
  32. 3
      tools/jotpluggler/assets/pause.png
  33. 3
      tools/jotpluggler/assets/play.png
  34. 3
      tools/jotpluggler/assets/plus.png
  35. 3
      tools/jotpluggler/assets/split_h.png
  36. 3
      tools/jotpluggler/assets/split_v.png
  37. 3
      tools/jotpluggler/assets/x.png
  38. 109
      tools/jotpluggler/data.py
  39. 281
      tools/jotpluggler/datatree.py
  40. 253
      tools/jotpluggler/layout.py
  41. 128
      tools/jotpluggler/layouts/torque-controller.yaml
  42. 169
      tools/jotpluggler/pluggle.py
  43. 137
      tools/jotpluggler/views.py
  44. 3
      tools/mac_setup.sh
  45. 2
      tools/op.sh
  46. 255
      tools/plotjuggler/layouts/torque-controller.xml
  47. 4
      tools/replay/SConscript
  48. 7
      tools/replay/framereader.cc
  49. 5
      tools/replay/framereader.h
  50. 4
      tools/sim/lib/simulated_car.py

@ -91,7 +91,6 @@ jobs:
build_mac: build_mac:
name: build macOS name: build macOS
if: false # temp disable since homebrew install is getting stuck
runs-on: ${{ ((github.repository == 'commaai/openpilot') && ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.full_name == 'commaai/openpilot'))) && 'namespace-profile-macos-8x14' || 'macos-latest' }} runs-on: ${{ ((github.repository == 'commaai/openpilot') && ((github.event_name != 'pull_request') || (github.event.pull_request.head.repo.full_name == 'commaai/openpilot'))) && 'namespace-profile-macos-8x14' || 'macos-latest' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -109,8 +108,8 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: ./tools/mac_setup.sh run: ./tools/mac_setup.sh
env: env:
# package install has DeprecationWarnings PYTHONWARNINGS: default # package install has DeprecationWarnings
PYTHONWARNINGS: default HOMEBREW_DISPLAY_INSTALL_TIMES: 1
- run: git lfs pull - run: git lfs pull
- name: Getting scons cache - name: Getting scons cache
uses: ./.github/workflows/auto-cache uses: ./.github/workflows/auto-cache

@ -1,11 +1,14 @@
Version 0.10.1 (2025-09-08) Version 0.10.1 (2025-09-08)
======================== ========================
* New driving model * New driving model #36087
* World Model: removed global localization inputs * World Model: removed global localization inputs
* World Model: 2x the number of parameters * World Model: 2x the number of parameters
* World Model: trained on 4x the number of segments * World Model: trained on 4x the number of segments
* Record driving feedback using LKAS button * Driving Vision Model: trained on 4x the number of segments
* Honda City 2023 support thanks to drFritz! * Honda City 2023 support thanks to vanillagorillaa and drFritz!
* Honda N-Box 2018 support thanks to miettal!
* Honda Odyssey 2021-25 support thanks to csouers and MVL!
* Honda Passport 2026 support thanks to vanillagorillaa and MVL!
Version 0.10.0 (2025-08-05) Version 0.10.0 (2025-08-05)
======================== ========================

@ -116,6 +116,7 @@ else:
f"#third_party/acados/{arch}/lib", f"#third_party/acados/{arch}/lib",
f"{brew_prefix}/lib", f"{brew_prefix}/lib",
f"{brew_prefix}/opt/openssl@3.0/lib", f"{brew_prefix}/opt/openssl@3.0/lib",
f"{brew_prefix}/opt/llvm/lib/c++",
"/System/Library/Frameworks/OpenGL.framework/Libraries", "/System/Library/Frameworks/OpenGL.framework/Libraries",
] ]

@ -22,7 +22,7 @@ class Api:
def request(self, method, endpoint, timeout=None, access_token=None, **params): def request(self, method, endpoint, timeout=None, access_token=None, **params):
return api_get(endpoint, method=method, timeout=timeout, access_token=access_token, **params) return api_get(endpoint, method=method, timeout=timeout, access_token=access_token, **params)
def get_token(self, expiry_hours=1): def get_token(self, payload_extra=None, expiry_hours=1):
now = datetime.now(UTC).replace(tzinfo=None) now = datetime.now(UTC).replace(tzinfo=None)
payload = { payload = {
'identity': self.dongle_id, 'identity': self.dongle_id,
@ -30,6 +30,8 @@ class Api:
'iat': now, 'iat': now,
'exp': now + timedelta(hours=expiry_hours) 'exp': now + timedelta(hours=expiry_hours)
} }
if payload_extra is not None:
payload.update(payload_extra)
token = jwt.encode(payload, self.private_key, algorithm='RS256') token = jwt.encode(payload, self.private_key, algorithm='RS256')
if isinstance(token, bytes): if isinstance(token, bytes):
token = token.decode('utf8') token = token.decode('utf8')

@ -4,7 +4,7 @@
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. 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.
# 321 Supported Cars # 324 Supported Cars
|Make|Model|Supported Package|ACC|No ACC accel below|No ALC below|Steering Torque|Resume from stop|<a href="##"><img width=2000></a>Hardware Needed<br>&nbsp;|Video|Setup Video| |Make|Model|Supported Package|ACC|No ACC accel below|No ALC below|Steering Torque|Resume from stop|<a href="##"><img width=2000></a>Hardware Needed<br>&nbsp;|Video|Setup Video|
|---|---|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| |---|---|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
@ -83,7 +83,7 @@ A supported vehicle is one that just works when you install a comma device. All
|Honda|Civic Hatchback 2017-18|Honda Sensing|openpilot available[<sup>1</sup>](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback 2017-18">Buy Here</a></sub></details>||| |Honda|Civic Hatchback 2017-18|Honda Sensing|openpilot available[<sup>1</sup>](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback 2017-18">Buy Here</a></sub></details>|||
|Honda|Civic Hatchback 2019-21|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback 2019-21">Buy Here</a></sub></details>||| |Honda|Civic Hatchback 2019-21|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback 2019-21">Buy Here</a></sub></details>|||
|Honda|Civic Hatchback 2022-24|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback 2022-24">Buy Here</a></sub></details>|<a href="https://youtu.be/ytiOT5lcp6Q" target="_blank"><img height="18px" src="assets/icon-youtube.svg"></img></a>|| |Honda|Civic Hatchback 2022-24|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback 2022-24">Buy Here</a></sub></details>|<a href="https://youtu.be/ytiOT5lcp6Q" target="_blank"><img height="18px" src="assets/icon-youtube.svg"></img></a>||
|Honda|Civic Hatchback Hybrid 2025|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback Hybrid 2025">Buy Here</a></sub></details>||| |Honda|Civic Hatchback Hybrid 2025-26|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback Hybrid 2025-26">Buy Here</a></sub></details>|||
|Honda|Civic Hatchback Hybrid (Europe only) 2023|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback Hybrid (Europe only) 2023">Buy Here</a></sub></details>||| |Honda|Civic Hatchback Hybrid (Europe only) 2023|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hatchback Hybrid (Europe only) 2023">Buy Here</a></sub></details>|||
|Honda|Civic Hybrid 2025|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hybrid 2025">Buy Here</a></sub></details>||| |Honda|Civic Hybrid 2025|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Civic Hybrid 2025">Buy Here</a></sub></details>|||
|Honda|CR-V 2015-16|Touring Trim|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda CR-V 2015-16">Buy Here</a></sub></details>||| |Honda|CR-V 2015-16|Touring Trim|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda CR-V 2015-16">Buy Here</a></sub></details>|||
@ -98,8 +98,11 @@ A supported vehicle is one that just works when you install a comma device. All
|Honda|HR-V 2023-25|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda HR-V 2023-25">Buy Here</a></sub></details>||| |Honda|HR-V 2023-25|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch B connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda HR-V 2023-25">Buy Here</a></sub></details>|||
|Honda|Insight 2019-22|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Insight 2019-22">Buy Here</a></sub></details>||| |Honda|Insight 2019-22|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Insight 2019-22">Buy Here</a></sub></details>|||
|Honda|Inspire 2018|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Inspire 2018">Buy Here</a></sub></details>||| |Honda|Inspire 2018|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|3 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Inspire 2018">Buy Here</a></sub></details>|||
|Honda|N-Box 2018|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|11 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda N-Box 2018">Buy Here</a></sub></details>|||
|Honda|Odyssey 2018-20|Honda Sensing|openpilot|26 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Odyssey 2018-20">Buy Here</a></sub></details>||| |Honda|Odyssey 2018-20|Honda Sensing|openpilot|26 mph|0 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Odyssey 2018-20">Buy Here</a></sub></details>|||
|Honda|Odyssey 2021-25|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|43 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Odyssey 2021-25">Buy Here</a></sub></details>|||
|Honda|Passport 2019-25|All|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Passport 2019-25">Buy Here</a></sub></details>||| |Honda|Passport 2019-25|All|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Passport 2019-25">Buy Here</a></sub></details>|||
|Honda|Passport 2026|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch C connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Passport 2026">Buy Here</a></sub></details>|||
|Honda|Pilot 2016-22|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Pilot 2016-22">Buy Here</a></sub></details>||| |Honda|Pilot 2016-22|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Pilot 2016-22">Buy Here</a></sub></details>|||
|Honda|Pilot 2023-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch C connector<br>- 1 angled mount (8 degrees)<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Pilot 2023-25">Buy Here</a></sub></details>||| |Honda|Pilot 2023-25|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Bosch C connector<br>- 1 angled mount (8 degrees)<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Pilot 2023-25">Buy Here</a></sub></details>|||
|Honda|Ridgeline 2017-25|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Ridgeline 2017-25">Buy Here</a></sub></details>||| |Honda|Ridgeline 2017-25|Honda Sensing|openpilot|26 mph|12 mph|[![star](assets/icon-star-empty.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Honda Nidec connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Honda Ridgeline 2017-25">Buy Here</a></sub></details>|||
@ -244,10 +247,10 @@ A supported vehicle is one that just works when you install a comma device. All
|Škoda|Octavia Scout 2017-19[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Octavia Scout 2017-19">Buy Here</a></sub></details>||| |Škoda|Octavia Scout 2017-19[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Octavia Scout 2017-19">Buy Here</a></sub></details>|||
|Škoda|Scala 2020-23[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Scala 2020-23">Buy Here</a></sub></details>[<sup>17</sup>](#footnotes)||| |Škoda|Scala 2020-23[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Scala 2020-23">Buy Here</a></sub></details>[<sup>17</sup>](#footnotes)|||
|Škoda|Superb 2015-22[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Superb 2015-22">Buy Here</a></sub></details>||| |Škoda|Superb 2015-22[<sup>15</sup>](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[<sup>1,16</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 USB-C coupler<br>- 1 VW J533 connector<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Škoda Superb 2015-22">Buy Here</a></sub></details>|||
|Tesla[<sup>11</sup>](#footnotes)|Model 3 (with HW3) 2019-23[<sup>10</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Tesla A connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model 3 (with HW3) 2019-23">Buy Here</a></sub></details>||| |Tesla[<sup>11</sup>](#footnotes)|Model 3 (with HW3) 2019-23[<sup>10</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Tesla A connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model 3 (with HW3) 2019-23">Buy Here</a></sub></details>|||
|Tesla[<sup>11</sup>](#footnotes)|Model 3 (with HW4) 2024-25[<sup>10</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Tesla B connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model 3 (with HW4) 2024-25">Buy Here</a></sub></details>||| |Tesla[<sup>11</sup>](#footnotes)|Model 3 (with HW4) 2024-25[<sup>10</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Tesla B connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model 3 (with HW4) 2024-25">Buy Here</a></sub></details>|||
|Tesla[<sup>11</sup>](#footnotes)|Model Y (with HW3) 2020-23[<sup>10</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Tesla A connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model Y (with HW3) 2020-23">Buy Here</a></sub></details>||| |Tesla[<sup>11</sup>](#footnotes)|Model Y (with HW3) 2020-23[<sup>10</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Tesla A connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model Y (with HW3) 2020-23">Buy Here</a></sub></details>|||
|Tesla[<sup>11</sup>](#footnotes)|Model Y (with HW4) 2024[<sup>10</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Tesla B connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model Y (with HW4) 2024">Buy Here</a></sub></details>||| |Tesla[<sup>11</sup>](#footnotes)|Model Y (with HW4) 2024-25[<sup>10</sup>](#footnotes)|All|openpilot available[<sup>1</sup>](#footnotes)|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Tesla B connector<br>- 1 USB-C coupler<br>- 1 comma 3X<br>- 1 harness box<br>- 1 long OBD-C cable (9.5 ft)<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Tesla Model Y (with HW4) 2024-25">Buy Here</a></sub></details>|||
|Toyota|Alphard 2019-20|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Alphard 2019-20">Buy Here</a></sub></details>||| |Toyota|Alphard 2019-20|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Alphard 2019-20">Buy Here</a></sub></details>|||
|Toyota|Alphard Hybrid 2021|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Alphard Hybrid 2021">Buy Here</a></sub></details>||| |Toyota|Alphard Hybrid 2021|All|openpilot|0 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-full.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Alphard Hybrid 2021">Buy Here</a></sub></details>|||
|Toyota|Avalon 2016|Toyota Safety Sense P|openpilot available[<sup>2</sup>](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Avalon 2016">Buy Here</a></sub></details>||| |Toyota|Avalon 2016|Toyota Safety Sense P|openpilot available[<sup>2</sup>](#footnotes)|19 mph|0 mph|[![star](assets/icon-star-full.svg)](##)|[![star](assets/icon-star-empty.svg)](##)|<details><summary>Parts</summary><sub>- 1 Toyota A connector<br>- 1 comma 3X<br>- 1 comma power v3<br>- 1 harness box<br>- 1 mount<br>- 1 right angle OBD-C cable (1.5 ft)<br><a href="https://comma.ai/shop/comma-3x?harness=Toyota Avalon 2016">Buy Here</a></sub></details>|||

@ -1 +1 @@
Subproject commit 5483a02de303d40cb2632d59f3f3a54dabfb5965 Subproject commit 89096d90d2f0f71be63a4af0152fe3b2aa55cf9d

@ -1 +1 @@
Subproject commit 7afc25d8d4096bb31e25c0b7ae0b961ea05f5394 Subproject commit c70bd060c6a410c1083186a1e4165e43a4eda0df

@ -1 +1 @@
Subproject commit 819fa5854e2e75da7f982f7d06be69c61793d6e1 Subproject commit a2064b86f3c9908883033a953503f150cedacbc7

@ -23,7 +23,7 @@ dependencies = [
# core # core
"cffi", "cffi",
"scons", "scons",
"pycapnp", "pycapnp==2.1.0",
"Cython", "Cython",
"setuptools", "setuptools",
"numpy >=2.0", "numpy >=2.0",
@ -176,7 +176,7 @@ quiet-level = 3
# if you've got a short variable name that's getting flagged, add it here # if you've got a short variable name that's getting flagged, add it here
ignore-words-list = "bu,ro,te,ue,alo,hda,ois,nam,nams,ned,som,parm,setts,inout,warmup,bumb,nd,sie,preints,whit,indexIn,ws,uint,grey,deque,stdio,amin,BA,LITE,atEnd,UIs,errorString,arange,FocusIn,od,tim,relA,hist,copyable,jupyter,thead,TGE,abl,lite" ignore-words-list = "bu,ro,te,ue,alo,hda,ois,nam,nams,ned,som,parm,setts,inout,warmup,bumb,nd,sie,preints,whit,indexIn,ws,uint,grey,deque,stdio,amin,BA,LITE,atEnd,UIs,errorString,arange,FocusIn,od,tim,relA,hist,copyable,jupyter,thead,TGE,abl,lite"
builtin = "clear,rare,informal,code,names,en-GB_to_en-US" builtin = "clear,rare,informal,code,names,en-GB_to_en-US"
skip = "./third_party/*, ./tinygrad/*, ./tinygrad_repo/*, ./msgq/*, ./panda/*, ./opendbc/*, ./opendbc_repo/*, ./rednose/*, ./rednose_repo/*, ./teleoprtc/*, ./teleoprtc_repo/*, *.ts, uv.lock, *.onnx, ./cereal/gen/*, */c_generated_code/*, docs/assets/*" skip = "./third_party/*, ./tinygrad/*, ./tinygrad_repo/*, ./msgq/*, ./panda/*, ./opendbc/*, ./opendbc_repo/*, ./rednose/*, ./rednose_repo/*, ./teleoprtc/*, ./teleoprtc_repo/*, *.ts, uv.lock, *.onnx, ./cereal/gen/*, */c_generated_code/*, docs/assets/*, tools/plotjuggler/layouts/*"
[tool.mypy] [tool.mypy]
python_version = "3.11" python_version = "3.11"

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:69f216a4ec672bb910d652678301ffe3094c44e5d03276e794ef793d936a1f1d
size 25096376

@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os
import numpy as np import numpy as np
from collections import deque, defaultdict from collections import deque, defaultdict
@ -242,6 +243,8 @@ class TorqueEstimator(ParameterEstimator):
def main(demo=False): def main(demo=False):
config_realtime_process([0, 1, 2, 3], 5) config_realtime_process([0, 1, 2, 3], 5)
DEBUG = bool(int(os.getenv("DEBUG", "0")))
pm = messaging.PubMaster(['liveTorqueParameters']) pm = messaging.PubMaster(['liveTorqueParameters'])
sm = messaging.SubMaster(['carControl', 'carOutput', 'carState', 'liveCalibration', 'livePose', 'liveDelay'], poll='livePose') sm = messaging.SubMaster(['carControl', 'carOutput', 'carState', 'liveCalibration', 'livePose', 'liveDelay'], poll='livePose')
@ -258,7 +261,7 @@ def main(demo=False):
# 4Hz driven by livePose # 4Hz driven by livePose
if sm.frame % 5 == 0: if sm.frame % 5 == 0:
pm.send('liveTorqueParameters', estimator.get_msg(valid=sm.all_checks())) pm.send('liveTorqueParameters', estimator.get_msg(valid=sm.all_checks(), with_points=DEBUG))
# Cache points every 60 seconds while onroad # Cache points every 60 seconds while onroad
if sm.frame % 240 == 0: if sm.frame % 240 == 0:

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:74e79e50df530cea1f0033dbc4e37cf767e1128332660fedcfe67daab65b3be3 oid sha256:5b0ce3cc48eee07b1a59e47c25552528761547a98dd0c4fac65c42013fc955c5
size 12343535 size 23927774

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:befac016a247b7ad5dc5b55d339d127774ed7bd2b848f1583f72aa4caee37781 oid sha256:cf6376aa9a090f0da26c280ef69eabf9bbdd51d1faac9ed392919c3db69be916
size 46271991 size 46271942

@ -66,7 +66,7 @@ Panda *connect(std::string serial="", uint32_t index=0) {
} }
//panda->enable_deepsleep(); //panda->enable_deepsleep();
for (int i = 0; i < PANDA_BUS_CNT; i++) { for (int i = 0; i < PANDA_CAN_CNT; i++) {
panda->set_can_fd_auto(i, true); panda->set_can_fd_auto(i, true);
} }

@ -5,8 +5,7 @@ import time
import cereal.messaging as messaging import cereal.messaging as messaging
from cereal import log from cereal import log
from openpilot.common.gpio import gpio_set, gpio_init from openpilot.common.gpio import gpio_set, gpio_init
from panda import Panda, PandaDFU, PandaProtocolMismatch from panda import Panda, PandaDFU
from openpilot.common.retry import retry
from openpilot.system.manager.process_config import managed_processes from openpilot.system.manager.process_config import managed_processes
from openpilot.system.hardware import HARDWARE from openpilot.system.hardware import HARDWARE
from openpilot.system.hardware.tici.pins import GPIO from openpilot.system.hardware.tici.pins import GPIO
@ -50,8 +49,7 @@ class TestPandad:
assert not Panda.wait_for_dfu(None, 3) assert not Panda.wait_for_dfu(None, 3)
assert not Panda.wait_for_panda(None, 3) assert not Panda.wait_for_panda(None, 3)
@retry(attempts=3) def _flash_bootstub(self, fn):
def _flash_bootstub_and_test(self, fn, expect_mismatch=False):
self._go_to_dfu() self._go_to_dfu()
pd = PandaDFU(None) pd = PandaDFU(None)
if fn is None: if fn is None:
@ -61,16 +59,6 @@ class TestPandad:
pd.reset() pd.reset()
HARDWARE.reset_internal_panda() HARDWARE.reset_internal_panda()
assert Panda.wait_for_panda(None, 10)
if expect_mismatch:
with pytest.raises(PandaProtocolMismatch):
Panda()
else:
with Panda() as p:
assert p.bootstub
self._run_test(45)
def test_in_dfu(self): def test_in_dfu(self):
HARDWARE.recover_internal_panda() HARDWARE.recover_internal_panda()
self._run_test(60) self._run_test(60)
@ -106,13 +94,14 @@ class TestPandad:
print("startup times", ts, sum(ts) / len(ts)) print("startup times", ts, sum(ts) / len(ts))
assert 0.1 < (sum(ts)/len(ts)) < 0.7 assert 0.1 < (sum(ts)/len(ts)) < 0.7
def test_protocol_version_check(self): def test_old_spi_protocol(self):
# flash old fw # flash firmware with old SPI protocol
fn = os.path.join(HERE, "bootstub.panda_h7_spiv0.bin") self._flash_bootstub(os.path.join(HERE, "bootstub.panda_h7_spiv0.bin"))
self._flash_bootstub_and_test(fn, expect_mismatch=True) self._run_test(45)
def test_release_to_devel_bootstub(self): def test_release_to_devel_bootstub(self):
self._flash_bootstub_and_test(None) self._flash_bootstub(None)
self._run_test(45)
def test_recover_from_bad_bootstub(self): def test_recover_from_bad_bootstub(self):
self._go_to_dfu() self._go_to_dfu()

@ -240,7 +240,7 @@ def below_engage_speed_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.
def below_steer_speed_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert: def below_steer_speed_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert:
return Alert( return Alert(
f"Steer Unavailable Below {get_display_speed(CP.minSteerSpeed, metric)}", f"Steer Assist Unavailable Below {get_display_speed(CP.minSteerSpeed, metric)}",
"", "",
AlertStatus.userPrompt, AlertSize.small, AlertStatus.userPrompt, AlertSize.small,
Priority.LOW, VisualAlert.none, AudibleAlert.prompt, 0.4) Priority.LOW, VisualAlert.none, AudibleAlert.prompt, 0.4)
@ -489,7 +489,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = {
EventName.steerTempUnavailableSilent: { EventName.steerTempUnavailableSilent: {
ET.WARNING: Alert( ET.WARNING: Alert(
"Steering Temporarily Unavailable", "Steering Assist Temporarily Unavailable",
"", "",
AlertStatus.userPrompt, AlertSize.small, AlertStatus.userPrompt, AlertSize.small,
Priority.LOW, VisualAlert.steerRequired, AudibleAlert.prompt, 1.8), Priority.LOW, VisualAlert.steerRequired, AudibleAlert.prompt, 1.8),
@ -735,7 +735,7 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = {
}, },
EventName.steerTempUnavailable: { EventName.steerTempUnavailable: {
ET.SOFT_DISABLE: soft_disable_alert("Steering Temporarily Unavailable"), ET.SOFT_DISABLE: soft_disable_alert("Steering Assist Temporarily Unavailable"),
ET.NO_ENTRY: NoEntryAlert("Steering Temporarily Unavailable"), ET.NO_ENTRY: NoEntryAlert("Steering Temporarily Unavailable"),
}, },

@ -102,10 +102,13 @@ class AugmentedRoadView(CameraView):
# Handle click events if no HUD interaction occurred # Handle click events if no HUD interaction occurred
if not self._hud_renderer.handle_mouse_event(): if not self._hud_renderer.handle_mouse_event():
if self._click_callback and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT): if self._click_callback is not None and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
if rl.check_collision_point_rec(rl.get_mouse_position(), self._content_rect): if rl.check_collision_point_rec(rl.get_mouse_position(), self._content_rect):
self._click_callback() self._click_callback()
def _handle_mouse_release(self, _):
pass
def _draw_border(self, rect: rl.Rectangle): def _draw_border(self, rect: rl.Rectangle):
border_color = BORDER_COLORS.get(ui_state.status, BORDER_COLORS[UIStatus.DISENGAGED]) border_color = BORDER_COLORS.get(ui_state.status, BORDER_COLORS[UIStatus.DISENGAGED])
rl.draw_rectangle_lines_ex(rect, UI_BORDER_SIZE, border_color) rl.draw_rectangle_lines_ex(rect, UI_BORDER_SIZE, border_color)

@ -24,11 +24,11 @@ class PairingDialog:
def _get_pairing_url(self) -> str: def _get_pairing_url(self) -> str:
try: try:
dongle_id = self.params.get("DongleId") or "" dongle_id = self.params.get("DongleId") or ""
token = Api(dongle_id).get_token() token = Api(dongle_id).get_token({'pair': True})
except Exception as e: except Exception as e:
cloudlog.warning(f"Failed to get pairing token: {e}") cloudlog.warning(f"Failed to get pairing token: {e}")
token = "" token = ""
return f"https://connect.comma.ai/setup?token={token}" return f"https://connect.comma.ai/?pair={token}"
def _generate_qr_code(self) -> None: def _generate_qr_code(self) -> None:
try: try:

@ -232,3 +232,12 @@ class HardwareBase(ABC):
def get_modem_data_usage(self): def get_modem_data_usage(self):
return -1, -1 return -1, -1
def get_voltage(self) -> float:
return 0.
def get_current(self) -> float:
return 0.
def set_ir_power(self, percent: int):
pass

@ -116,6 +116,26 @@ class Tici(HardwareBase):
def get_serial(self): def get_serial(self):
return self.get_cmdline()['androidboot.serialno'] return self.get_cmdline()['androidboot.serialno']
def get_voltage(self):
with open("/sys/class/hwmon/hwmon1/in1_input") as f:
return int(f.read())
def get_current(self):
with open("/sys/class/hwmon/hwmon1/curr1_input") as f:
return int(f.read())
def set_ir_power(self, percent: int):
if self.get_device_type() in ("tici", "tizi"):
return
value = int((percent / 100) * 300)
with open("/sys/class/leds/led:switch_2/brightness", "w") as f:
f.write("0\n")
with open("/sys/class/leds/led:torch_2/brightness", "w") as f:
f.write(f"{value}\n")
with open("/sys/class/leds/led:switch_2/brightness", "w") as f:
f.write(f"{value}\n")
def get_network_type(self): def get_network_type(self):
try: try:
primary_connection = self.nm.Get(NM, 'PrimaryConnection', dbus_interface=DBUS_PROPS, timeout=TIMEOUT) primary_connection = self.nm.Get(NM, 'PrimaryConnection', dbus_interface=DBUS_PROPS, timeout=TIMEOUT)

@ -31,9 +31,9 @@ class Proc:
PROCS = [ PROCS = [
Proc(['camerad'], 1.75, msgs=['roadCameraState', 'wideRoadCameraState', 'driverCameraState']), Proc(['camerad'], 1.65, atol=0.4, msgs=['roadCameraState', 'wideRoadCameraState', 'driverCameraState']),
Proc(['modeld'], 1.24, atol=0.2, msgs=['modelV2']), Proc(['modeld'], 1.24, atol=0.2, msgs=['modelV2']),
Proc(['dmonitoringmodeld'], 0.7, msgs=['driverStateV2']), Proc(['dmonitoringmodeld'], 0.65, atol=0.35, msgs=['driverStateV2']),
Proc(['encoderd'], 0.23, msgs=[]), Proc(['encoderd'], 0.23, msgs=[]),
] ]

@ -1,13 +0,0 @@
diff --git a/system/ubloxd/generated/glonass.cpp b/system/ubloxd/generated/glonass.cpp
index 5b17bc327..b5c6aa610 100644
--- a/system/ubloxd/generated/glonass.cpp
+++ b/system/ubloxd/generated/glonass.cpp
@@ -17,7 +17,7 @@ glonass_t::glonass_t(kaitai::kstream* p__io, kaitai::kstruct* p__parent, glonass
void glonass_t::_read() {
m_idle_chip = m__io->read_bits_int_be(1);
m_string_number = m__io->read_bits_int_be(4);
- m__io->align_to_byte();
+ //m__io->align_to_byte();
switch (string_number()) {
case 4: {
m_data = new string_4_t(m__io, this, m__root);

@ -1,89 +0,0 @@
#!/usr/bin/env python3
# type: ignore
from openpilot.selfdrive.locationd.test import ublox
import struct
baudrate = 460800
rate = 100 # send new data every 100ms
def configure_ublox(dev):
# configure ports and solution parameters and rate
dev.configure_port(port=ublox.PORT_USB, inMask=1, outMask=1) # enable only UBX on USB
dev.configure_port(port=0, inMask=0, outMask=0) # disable DDC
payload = struct.pack('<BBHIIHHHBB', 1, 0, 0, 2240, baudrate, 1, 1, 0, 0, 0)
dev.configure_poll(ublox.CLASS_CFG, ublox.MSG_CFG_PRT, payload) # enable UART
dev.configure_port(port=4, inMask=0, outMask=0) # disable SPI
dev.configure_poll_port()
dev.configure_poll_port(ublox.PORT_SERIAL1)
dev.configure_poll_port(ublox.PORT_USB)
dev.configure_solution_rate(rate_ms=rate)
# Configure solution
payload = struct.pack('<HBBIIBB4H6BH6B', 5, 4, 3, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 0)
dev.configure_poll(ublox.CLASS_CFG, ublox.MSG_CFG_NAV5, payload)
payload = struct.pack('<B3BBB6BBB2BBB2B', 0, 0, 0, 0, 1,
3, 0, 0, 0, 0,
0, 0, 0, 0, 0,
0, 0, 0, 0, 0)
dev.configure_poll(ublox.CLASS_CFG, ublox.MSG_CFG_ODO, payload)
#bits_ITMF_config1 = '10101101011000101010110111111111'
#bits_ITMF_config2 = '00000000000000000110001100011110'
ITMF_config1 = 2908925439
ITMF_config2 = 25374
payload = struct.pack('<II', ITMF_config1, ITMF_config2)
dev.configure_poll(ublox.CLASS_CFG, ublox.MSG_CFG_ITMF, payload)
payload = struct.pack('<HHIBBBBBBBBBBH6BBB2BH4B3BB', 0, (1 << 10), 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 1, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0,
0, 0, 0, 0)
dev.configure_poll(ublox.CLASS_CFG, ublox.MSG_CFG_NAVX5, payload)
dev.configure_poll(ublox.CLASS_CFG, ublox.MSG_CFG_NAV5)
dev.configure_poll(ublox.CLASS_CFG, ublox.MSG_CFG_NAVX5)
dev.configure_poll(ublox.CLASS_CFG, ublox.MSG_CFG_ODO)
dev.configure_poll(ublox.CLASS_CFG, ublox.MSG_CFG_ITMF)
# Configure RAW, PVT and HW messages to be sent every solution cycle
dev.configure_message_rate(ublox.CLASS_NAV, ublox.MSG_NAV_PVT, 1)
dev.configure_message_rate(ublox.CLASS_RXM, ublox.MSG_RXM_RAW, 1)
dev.configure_message_rate(ublox.CLASS_RXM, ublox.MSG_RXM_SFRBX, 1)
dev.configure_message_rate(ublox.CLASS_MON, ublox.MSG_MON_HW, 1)
dev.configure_message_rate(ublox.CLASS_MON, ublox.MSG_MON_HW2, 1)
dev.configure_message_rate(ublox.CLASS_NAV, ublox.MSG_NAV_SAT, 1)
# Query the backup restore status
print("backup restore polling message (implement custom response handler!):")
dev.configure_poll(0x09, 0x14)
print("if successful, send this to clear the flash:")
dev.send_message(0x09, 0x14, b"\x01\x00\x00\x00")
print("send on stop:")
# Save on shutdown
# Controlled GNSS stop and hot start
payload = struct.pack('<HBB', 0x0000, 0x08, 0x00)
dev.send_message(ublox.CLASS_CFG, ublox.MSG_CFG_RST, payload)
# UBX-UPD-SOS backup
dev.send_message(0x09, 0x14, b"\x00\x00\x00\x00")
if __name__ == "__main__":
class Device:
def write(self, s):
d = '"{}"s'.format(''.join(f'\\x{b:02X}' for b in s))
print(f" if (!send_with_ack({d})) continue;")
dev = ublox.UBlox(Device(), baudrate=baudrate)
configure_ublox(dev)

@ -99,19 +99,13 @@ float distanceToEdge(vec2 p) {
void main() { void main() {
vec2 pixel = fragTexCoord * resolution; vec2 pixel = fragTexCoord * resolution;
// Compute pixel size for anti-aliasing
vec2 pixelGrad = vec2(dFdx(pixel.x), dFdy(pixel.y));
float pixelSize = length(pixelGrad);
float aaWidth = max(0.5, pixelSize * 1.5);
bool inside = isPointInsidePolygon(pixel); bool inside = isPointInsidePolygon(pixel);
if (inside) { float sd = (inside ? 1.0 : -1.0) * distanceToEdge(pixel);
finalColor = useGradient == 1 ? getGradientColor(pixel) : fillColor;
return; // ~1 pixel wide anti-aliasing
} float w = max(0.75, fwidth(sd));
float sd = -distanceToEdge(pixel); float alpha = smoothstep(-w, w, sd);
float alpha = smoothstep(-aaWidth, aaWidth, sd);
if (alpha > 0.0){ if (alpha > 0.0){
vec4 color = useGradient == 1 ? getGradientColor(pixel) : fillColor; vec4 color = useGradient == 1 ? getGradientColor(pixel) : fillColor;
finalColor = vec4(color.rgb, color.a * alpha); finalColor = vec4(color.rgb, color.a * alpha);

@ -15,12 +15,13 @@ class Widget(abc.ABC):
def __init__(self): def __init__(self):
self._rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0) self._rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._parent_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0) self._parent_rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0)
self._is_pressed = [False] * MAX_TOUCH_SLOTS self.__is_pressed = [False] * MAX_TOUCH_SLOTS
# if current mouse/touch down started within the widget's rectangle # if current mouse/touch down started within the widget's rectangle
self._tracking_is_pressed = [False] * MAX_TOUCH_SLOTS self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS
self._enabled: bool | Callable[[], bool] = True self._enabled: bool | Callable[[], bool] = True
self._is_visible: bool | Callable[[], bool] = True self._is_visible: bool | Callable[[], bool] = True
self._touch_valid_callback: Callable[[], bool] | None = None self._touch_valid_callback: Callable[[], bool] | None = None
self._click_callback: Callable[[], None] | None = None
self._multi_touch = False self._multi_touch = False
@property @property
@ -40,7 +41,7 @@ class Widget(abc.ABC):
@property @property
def is_pressed(self) -> bool: def is_pressed(self) -> bool:
return any(self._is_pressed) return any(self.__is_pressed)
@property @property
def enabled(self) -> bool: def enabled(self) -> bool:
@ -56,6 +57,10 @@ class Widget(abc.ABC):
def set_visible(self, visible: bool | Callable[[], bool]) -> None: def set_visible(self, visible: bool | Callable[[], bool]) -> None:
self._is_visible = visible self._is_visible = visible
def set_click_callback(self, click_callback: Callable[[], None] | None) -> None:
"""Set a callback to be called when the widget is clicked."""
self._click_callback = click_callback
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
"""Set a callback to determine if the widget can be clicked.""" """Set a callback to determine if the widget can be clicked."""
self._touch_valid_callback = touch_callback self._touch_valid_callback = touch_callback
@ -91,28 +96,28 @@ class Widget(abc.ABC):
# Allows touch to leave the rect and come back in focus if mouse did not release # Allows touch to leave the rect and come back in focus if mouse did not release
if mouse_event.left_pressed and self._touch_valid(): if mouse_event.left_pressed and self._touch_valid():
if rl.check_collision_point_rec(mouse_event.pos, self._rect): if rl.check_collision_point_rec(mouse_event.pos, self._rect):
self._is_pressed[mouse_event.slot] = True self.__is_pressed[mouse_event.slot] = True
self._tracking_is_pressed[mouse_event.slot] = True self.__tracking_is_pressed[mouse_event.slot] = True
# Callback such as scroll panel signifies user is scrolling # Callback such as scroll panel signifies user is scrolling
elif not self._touch_valid(): elif not self._touch_valid():
self._is_pressed[mouse_event.slot] = False self.__is_pressed[mouse_event.slot] = False
self._tracking_is_pressed[mouse_event.slot] = False self.__tracking_is_pressed[mouse_event.slot] = False
elif mouse_event.left_released: elif mouse_event.left_released:
if self._is_pressed[mouse_event.slot] and rl.check_collision_point_rec(mouse_event.pos, self._rect): if self.__is_pressed[mouse_event.slot] and rl.check_collision_point_rec(mouse_event.pos, self._rect):
self._handle_mouse_release(mouse_event.pos) self._handle_mouse_release(mouse_event.pos)
self._is_pressed[mouse_event.slot] = False self.__is_pressed[mouse_event.slot] = False
self._tracking_is_pressed[mouse_event.slot] = False self.__tracking_is_pressed[mouse_event.slot] = False
# Mouse/touch is still within our rect # Mouse/touch is still within our rect
elif rl.check_collision_point_rec(mouse_event.pos, self._rect): elif rl.check_collision_point_rec(mouse_event.pos, self._rect):
if self._tracking_is_pressed[mouse_event.slot]: if self.__tracking_is_pressed[mouse_event.slot]:
self._is_pressed[mouse_event.slot] = True self.__is_pressed[mouse_event.slot] = True
# Mouse/touch left our rect but may come back into focus later # Mouse/touch left our rect but may come back into focus later
elif not rl.check_collision_point_rec(mouse_event.pos, self._rect): elif not rl.check_collision_point_rec(mouse_event.pos, self._rect):
self._is_pressed[mouse_event.slot] = False self.__is_pressed[mouse_event.slot] = False
return ret return ret
@ -128,6 +133,8 @@ class Widget(abc.ABC):
def _handle_mouse_release(self, mouse_pos: MousePos) -> bool: def _handle_mouse_release(self, mouse_pos: MousePos) -> bool:
"""Optionally handle mouse release events.""" """Optionally handle mouse release events."""
if self._click_callback:
self._click_callback()
return False return False
def show_event(self): def show_event(self):

@ -165,7 +165,7 @@ def gui_button(
class Button(Widget): class Button(Widget):
def __init__(self, def __init__(self,
text: str, text: str,
click_callback: Callable[[], None] = None, click_callback: Callable[[], None] | None = None,
font_size: int = DEFAULT_BUTTON_FONT_SIZE, font_size: int = DEFAULT_BUTTON_FONT_SIZE,
font_weight: FontWeight = FontWeight.MEDIUM, font_weight: FontWeight = FontWeight.MEDIUM,
button_style: ButtonStyle = ButtonStyle.NORMAL, button_style: ButtonStyle = ButtonStyle.NORMAL,
@ -190,10 +190,6 @@ class Button(Widget):
def set_text(self, text): def set_text(self, text):
self._label.set_text(text) self._label.set_text(text)
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._click_callback and self.enabled:
self._click_callback()
def _update_state(self): def _update_state(self):
if self.enabled: if self.enabled:
self._label.set_text_color(BUTTON_TEXT_COLOR[self._button_style]) self._label.set_text_color(BUTTON_TEXT_COLOR[self._button_style])
@ -215,7 +211,7 @@ class ButtonRadio(Button):
def __init__(self, def __init__(self,
text: str, text: str,
icon, icon,
click_callback: Callable[[], None] = None, click_callback: Callable[[], None] | None = None,
font_size: int = DEFAULT_BUTTON_FONT_SIZE, font_size: int = DEFAULT_BUTTON_FONT_SIZE,
text_alignment: TextAlignment = TextAlignment.LEFT, text_alignment: TextAlignment = TextAlignment.LEFT,
border_radius: int = 10, border_radius: int = 10,
@ -230,9 +226,8 @@ class ButtonRadio(Button):
self.selected = False self.selected = False
def _handle_mouse_release(self, mouse_pos: MousePos): def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
self.selected = not self.selected self.selected = not self.selected
if self._click_callback:
self._click_callback()
def _update_state(self): def _update_state(self):
if self.selected: if self.selected:

@ -113,6 +113,7 @@ def setup_git_options(cwd: str) -> None:
("protocol.version", "2"), ("protocol.version", "2"),
("gc.auto", "0"), ("gc.auto", "0"),
("gc.autoDetach", "false"), ("gc.autoDetach", "false"),
("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"),
] ]
for option, value in git_cfg: for option, value in git_cfg:
run(["git", "config", option, value], cwd) run(["git", "config", option, value], cwd)
@ -389,6 +390,7 @@ class Updater:
cloudlog.info("git reset in progress") cloudlog.info("git reset in progress")
cmds = [ cmds = [
["git", "checkout", "--force", "--no-recurse-submodules", "-B", branch, "FETCH_HEAD"], ["git", "checkout", "--force", "--no-recurse-submodules", "-B", branch, "FETCH_HEAD"],
["git", "branch", "--set-upstream-to", f"origin/{branch}"],
["git", "reset", "--hard"], ["git", "reset", "--hard"],
["git", "clean", "-xdff"], ["git", "clean", "-xdff"],
["git", "submodule", "sync"], ["git", "submodule", "sync"],

@ -37,9 +37,7 @@ def is_prebuilt(path: str = BASEDIR) -> bool:
@cache @cache
def is_dirty(cwd: str = BASEDIR) -> bool: def is_dirty(cwd: str = BASEDIR) -> bool:
origin = get_origin() if not get_origin() or not get_short_branch():
branch = get_branch()
if not origin or not branch:
return True return True
dirty = False dirty = False
@ -52,6 +50,9 @@ def is_dirty(cwd: str = BASEDIR) -> bool:
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
pass pass
branch = get_branch()
if not branch:
return True
dirty = (subprocess.call(["git", "diff-index", "--quiet", branch, "--"], cwd=cwd)) != 0 dirty = (subprocess.call(["git", "diff-index", "--quiet", branch, "--"], cwd=cwd)) != 0
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
cloudlog.exception("git subprocess failed while checking dirty") cloudlog.exception("git subprocess failed while checking dirty")

@ -122,7 +122,7 @@ void MainWindow::createActions() {
auto undo_act = UndoStack::instance()->createUndoAction(this, tr("&Undo")); auto undo_act = UndoStack::instance()->createUndoAction(this, tr("&Undo"));
undo_act->setShortcuts(QKeySequence::Undo); undo_act->setShortcuts(QKeySequence::Undo);
edit_menu->addAction(undo_act); edit_menu->addAction(undo_act);
auto redo_act = UndoStack::instance()->createRedoAction(this, tr("&Rndo")); auto redo_act = UndoStack::instance()->createRedoAction(this, tr("&Redo"));
redo_act->setShortcuts(QKeySequence::Redo); redo_act->setShortcuts(QKeySequence::Redo);
edit_menu->addAction(redo_act); edit_menu->addAction(redo_act);
edit_menu->addSeparator(); edit_menu->addSeparator();

@ -20,18 +20,25 @@ fi
# Install common packages # Install common packages
function install_ubuntu_common_requirements() { function install_ubuntu_common_requirements() {
$SUDO apt-get update $SUDO apt-get update
# normal stuff, mostly for the bare docker image
$SUDO apt-get install -y --no-install-recommends \ $SUDO apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
clang \ clang \
build-essential \ build-essential \
gcc-arm-none-eabi \
liblzma-dev \
capnproto \
libcapnp-dev \
curl \ curl \
libssl-dev \
libcurl4-openssl-dev \ libcurl4-openssl-dev \
locales \
git \ git \
git-lfs \ git-lfs \
xvfb
# TODO: vendor the rest of these in third_party/
$SUDO apt-get install -y --no-install-recommends \
gcc-arm-none-eabi \
capnproto \
libcapnp-dev \
ffmpeg \ ffmpeg \
libavformat-dev \ libavformat-dev \
libavcodec-dev \ libavcodec-dev \
@ -41,20 +48,16 @@ function install_ubuntu_common_requirements() {
libbz2-dev \ libbz2-dev \
libeigen3-dev \ libeigen3-dev \
libffi-dev \ libffi-dev \
libglew-dev \
libgles2-mesa-dev \ libgles2-mesa-dev \
libglfw3-dev \ libglfw3-dev \
libglib2.0-0 \ libglib2.0-0 \
libjpeg-dev \ libjpeg-dev \
libqt5charts5-dev \ libqt5charts5-dev \
libncurses5-dev \ libncurses5-dev \
libssl-dev \
libusb-1.0-0-dev \ libusb-1.0-0-dev \
libzmq3-dev \ libzmq3-dev \
libzstd-dev \ libzstd-dev \
libsqlite3-dev \ libsqlite3-dev \
libsystemd-dev \
locales \
opencl-headers \ opencl-headers \
ocl-icd-libopencl1 \ ocl-icd-libopencl1 \
ocl-icd-opencl-dev \ ocl-icd-opencl-dev \
@ -63,8 +66,7 @@ function install_ubuntu_common_requirements() {
libqt5svg5-dev \ libqt5svg5-dev \
libqt5serialbus5-dev \ libqt5serialbus5-dev \
libqt5x11extras5-dev \ libqt5x11extras5-dev \
libqt5opengl5-dev \ libqt5opengl5-dev
xvfb
} }
# Install Ubuntu 24.04 LTS packages # Install Ubuntu 24.04 LTS packages
@ -74,8 +76,6 @@ function install_ubuntu_lts_latest_requirements() {
$SUDO apt-get install -y --no-install-recommends \ $SUDO apt-get install -y --no-install-recommends \
g++-12 \ g++-12 \
qtbase5-dev \ qtbase5-dev \
qtchooser \
qt5-qmake \
qtbase5-dev-tools \ qtbase5-dev-tools \
python3-dev \ python3-dev \
python3-venv python3-venv

@ -0,0 +1,67 @@
# JotPluggler
JotPluggler is a tool to quickly visualize openpilot logs.
## Usage
```
$ ./jotpluggler/pluggle.py -h
usage: pluggle.py [-h] [--demo] [--layout LAYOUT] [route]
A tool for visualizing openpilot logs.
positional arguments:
route Optional route name to load on startup.
options:
-h, --help show this help message and exit
--demo Use the demo route instead of providing one
--layout LAYOUT Path to YAML layout file to load on startup
```
Example using route name:
`./pluggle.py "a2a0ccea32023010/2023-07-27--13-01-19"`
Examples using segment:
`./pluggle.py "a2a0ccea32023010/2023-07-27--13-01-19/1"`
`./pluggle.py "a2a0ccea32023010/2023-07-27--13-01-19/1/q" # use qlogs`
Example using segment range:
`./pluggle.py "a2a0ccea32023010/2023-07-27--13-01-19/0:1"`
## Demo
For a quick demo, run this command:
`./pluggle.py --demo --layout=layouts/torque-controller.yaml`
## Basic Usage/Features:
- The text box to load a route is a the top left of the page, accepts standard openpilot format routes (e.g. `a2a0ccea32023010/2023-07-27--13-01-19/0:1`, `https://connect.comma.ai/a2a0ccea32023010/2023-07-27--13-01-19/`)
- The Play/Pause button is at the bottom of the screen, you can drag the bottom slider to seek. The timeline in timeseries plots are synced with the slider.
- The Timeseries List sidebar has several dropdowns, the fields each show the field name and value, synced with the timeline (will show N/A until the time of the first message in that field is reached).
- There is a search bar for the timeseries list, you can search for structs or fields, or both by separating with a "/"
- You can drag and drop any numeric/boolean field from the timeseries list into a timeseries panel.
- You can create more panels with the split buttons (buttons with two rectangles, either horizontal or vertical). You can resize the panels by dragging the grip in between any panel.
- You can load and save layouts with the corresponding buttons. Layouts will save all tabs, panels, titles, timeseries, etc.
## Layouts
If you create a layout that's useful for others, consider upstreaming it.
## Plot Interaction Controls
- **Left click and drag within the plot area** to pan X
- Left click and drag on an axis to pan an individual axis (disabled for Y-axis)
- **Scroll in the plot area** to zoom in X axes, Y-axis is autofit
- Scroll on an axis to zoom an individual axis
- **Right click and drag** to select data and zoom into the selected data
- Left click while box selecting to cancel the selection
- **Double left click** to fit all visible data
- Double left click on an axis to fit the individual axis (disabled for Y-axis, always autofit)
- **Double right click** to open the plot context menu
- **Click legend label icons** to show/hide plot items

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ea96d8193eb9067a5efdc5d88a3099730ecafa40efcd09d7402bb3efd723603
size 2305

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:53097ac5403b725ff1841dfa186ea770b4bb3714205824bde36ec3c2a0fb5dba
size 2758

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:248b71eafd1b42b0861da92114da3d625221cd88121fff01e0514bf3d79ff3b1
size 1364

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:54dd035ff898d881509fa686c402a61af8ef5fb408b92414722da01f773b0d33
size 2900

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:adbd4e5df1f58694dca9dde46d1d95b4e7471684e42e6bca9f41ea5d346e67c5
size 3669

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a6d9c90cb0dd906e0b15e1f7f3fd9f0dfad3c3b0b34eeed7a7882768dc5f3961
size 2053

@ -3,8 +3,9 @@ import threading
import multiprocessing import multiprocessing
import bisect import bisect
from collections import defaultdict from collections import defaultdict
import tqdm from tqdm import tqdm
from openpilot.common.swaglog import cloudlog from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.test.process_replay.migration import migrate_all
from openpilot.tools.lib.logreader import _LogFileReader, LogReader from openpilot.tools.lib.logreader import _LogFileReader, LogReader
@ -70,9 +71,6 @@ def extract_field_types(schema, prefix, field_types_dict):
def _convert_to_optimal_dtype(values_list, capnp_type): def _convert_to_optimal_dtype(values_list, capnp_type):
if not values_list:
return np.array([])
dtype_mapping = { dtype_mapping = {
'bool': np.bool_, 'int8': np.int8, 'int16': np.int16, 'int32': np.int32, 'int64': np.int64, 'bool': np.bool_, 'int8': np.int8, 'int16': np.int16, 'int32': np.int32, 'int64': np.int64,
'uint8': np.uint8, 'uint16': np.uint16, 'uint32': np.uint32, 'uint64': np.uint64, 'uint8': np.uint8, 'uint16': np.uint16, 'uint32': np.uint32, 'uint64': np.uint64,
@ -80,8 +78,8 @@ def _convert_to_optimal_dtype(values_list, capnp_type):
'enum': object, 'anyPointer': object, 'enum': object, 'anyPointer': object,
} }
target_dtype = dtype_mapping.get(capnp_type) target_dtype = dtype_mapping.get(capnp_type, object)
return np.array(values_list, dtype=target_dtype) if target_dtype else np.array(values_list) return np.array(values_list, dtype=target_dtype)
def _match_field_type(field_path, field_types): def _match_field_type(field_path, field_types):
@ -94,6 +92,21 @@ def _match_field_type(field_path, field_types):
return field_types.get(template_path) return field_types.get(template_path)
def _get_field_times_values(segment, field_name):
if field_name not in segment:
return None, None
field_data = segment[field_name]
segment_times = segment['t']
if field_data['sparse']:
if len(field_data['t_index']) == 0:
return None, None
return segment_times[field_data['t_index']], field_data['values']
else:
return segment_times, field_data['values']
def msgs_to_time_series(msgs): def msgs_to_time_series(msgs):
"""Extract scalar fields and return (time_series_data, start_time, end_time).""" """Extract scalar fields and return (time_series_data, start_time, end_time)."""
collected_data = defaultdict(lambda: {'timestamps': [], 'columns': defaultdict(list), 'sparse_fields': set()}) collected_data = defaultdict(lambda: {'timestamps': [], 'columns': defaultdict(list), 'sparse_fields': set()})
@ -110,16 +123,22 @@ def msgs_to_time_series(msgs):
max_time = timestamp max_time = timestamp
sub_msg = getattr(msg, typ) sub_msg = getattr(msg, typ)
if not hasattr(sub_msg, 'to_dict') or typ in ('qcomGnss', 'ubloxGnss'): if not hasattr(sub_msg, 'to_dict'):
continue continue
if hasattr(sub_msg, 'schema') and typ not in extracted_schemas: if hasattr(sub_msg, 'schema') and typ not in extracted_schemas:
extract_field_types(sub_msg.schema, typ, field_types) extract_field_types(sub_msg.schema, typ, field_types)
extracted_schemas.add(typ) extracted_schemas.add(typ)
try:
msg_dict = sub_msg.to_dict(verbose=True) msg_dict = sub_msg.to_dict(verbose=True)
except Exception as e:
cloudlog.warning(f"Failed to convert sub_msg.to_dict() for message of type: {typ}: {e}")
continue
flat_dict = flatten_dict(msg_dict) flat_dict = flatten_dict(msg_dict)
flat_dict['_valid'] = msg.valid flat_dict['_valid'] = msg.valid
field_types[f"{typ}/_valid"] = 'bool'
type_data = collected_data[typ] type_data = collected_data[typ]
columns, sparse_fields = type_data['columns'], type_data['sparse_fields'] columns, sparse_fields = type_data['columns'], type_data['sparse_fields']
@ -152,11 +171,26 @@ def msgs_to_time_series(msgs):
values = [None] * (len(data['timestamps']) - len(values)) + values values = [None] * (len(data['timestamps']) - len(values)) + values
sparse_fields.add(field_name) sparse_fields.add(field_name)
if field_name in sparse_fields:
typ_result[field_name] = np.array(values, dtype=object)
else:
capnp_type = _match_field_type(f"{typ}/{field_name}", field_types) capnp_type = _match_field_type(f"{typ}/{field_name}", field_types)
typ_result[field_name] = _convert_to_optimal_dtype(values, capnp_type)
if field_name in sparse_fields: # extract non-None values and their indices
non_none_indices = []
non_none_values = []
for i, value in enumerate(values):
if value is not None:
non_none_indices.append(i)
non_none_values.append(value)
if non_none_values: # check if indices > uint16 max, currently would require a 1000+ Hz signal since indices are within segments
assert max(non_none_indices) <= 65535, f"Sparse field {typ}/{field_name} has timestamp indices exceeding uint16 max. Max: {max(non_none_indices)}"
typ_result[field_name] = {
'values': _convert_to_optimal_dtype(non_none_values, capnp_type),
'sparse': True,
't_index': np.array(non_none_indices, dtype=np.uint16),
}
else: # dense representation
typ_result[field_name] = {'values': _convert_to_optimal_dtype(values, capnp_type), 'sparse': False}
final_result[typ] = typ_result final_result[typ] = typ_result
@ -166,7 +200,8 @@ def msgs_to_time_series(msgs):
def _process_segment(segment_identifier: str): def _process_segment(segment_identifier: str):
try: try:
lr = _LogFileReader(segment_identifier, sort_by_time=True) lr = _LogFileReader(segment_identifier, sort_by_time=True)
return msgs_to_time_series(lr) migrated_msgs = migrate_all(lr)
return msgs_to_time_series(migrated_msgs)
except Exception as e: except Exception as e:
cloudlog.warning(f"Warning: Failed to process segment {segment_identifier}: {e}") cloudlog.warning(f"Warning: Failed to process segment {segment_identifier}: {e}")
return {}, 0.0, 0.0 return {}, 0.0, 0.0
@ -195,18 +230,27 @@ class DataManager:
times, values = [], [] times, values = [], []
for segment in self._segments: for segment in self._segments:
if msg_type in segment and field in segment[msg_type]: if msg_type in segment:
times.append(segment[msg_type]['t']) field_times, field_values = _get_field_times_values(segment[msg_type], field)
values.append(segment[msg_type][field]) if field_times is not None:
times.append(field_times)
values.append(field_values)
if not times: if not times:
return [], [] return np.array([]), np.array([])
combined_times = np.concatenate(times) - self._start_time combined_times = np.concatenate(times) - self._start_time
if len(values) > 1 and any(arr.dtype != values[0].dtype for arr in values):
values = [arr.astype(object) for arr in values]
return combined_times, np.concatenate(values) if len(values) > 1:
first_dtype = values[0].dtype
if all(arr.dtype == first_dtype for arr in values): # check if all arrays have compatible dtypes
combined_values = np.concatenate(values)
else:
combined_values = np.concatenate([arr.astype(object) for arr in values])
else:
combined_values = values[0] if values else np.array([])
return combined_times, combined_values
def get_value_at(self, path: str, time: float): def get_value_at(self, path: str, time: float):
with self._lock: with self._lock:
@ -218,14 +262,14 @@ class DataManager:
if not 0 <= index < len(self._segments): if not 0 <= index < len(self._segments):
continue continue
segment = self._segments[index].get(message_type) segment = self._segments[index].get(message_type)
if not segment or field not in segment: if not segment:
continue continue
times = segment['t'] times, values = _get_field_times_values(segment, field)
if len(times) == 0 or (index != current_index and absolute_time - times[-1] > MAX_LOOKBACK): if times is None or len(times) == 0 or (index != current_index and absolute_time - times[-1] > MAX_LOOKBACK):
continue continue
position = np.searchsorted(times, absolute_time, 'right') - 1 position = np.searchsorted(times, absolute_time, 'right') - 1
if position >= 0 and absolute_time - times[position] <= MAX_LOOKBACK: if position >= 0 and absolute_time - times[position] <= MAX_LOOKBACK:
return segment[field][position] return values[position]
return None return None
def get_all_paths(self): def get_all_paths(self):
@ -237,10 +281,9 @@ class DataManager:
return self._duration return self._duration
def is_plottable(self, path: str): def is_plottable(self, path: str):
data = self.get_timeseries(path) _, values = self.get_timeseries(path)
if data is None: if len(values) == 0:
return False return False
_, values = data
return np.issubdtype(values.dtype, np.number) or np.issubdtype(values.dtype, np.bool_) return np.issubdtype(values.dtype, np.number) or np.issubdtype(values.dtype, np.bool_)
def add_observer(self, callback): def add_observer(self, callback):
@ -271,8 +314,14 @@ class DataManager:
cloudlog.warning(f"Warning: No log segments found for route: {route}") cloudlog.warning(f"Warning: No log segments found for route: {route}")
return return
total_segments = len(lr.logreader_identifiers)
with self._lock:
observers = self._observers.copy()
for callback in observers:
callback({'metadata_loaded': True, 'total_segments': total_segments})
num_processes = max(1, multiprocessing.cpu_count() // 2) num_processes = max(1, multiprocessing.cpu_count() // 2)
with multiprocessing.Pool(processes=num_processes) as pool, tqdm.tqdm(total=len(lr.logreader_identifiers), desc="Processing Segments") as pbar: with multiprocessing.Pool(processes=num_processes) as pool, tqdm(total=len(lr.logreader_identifiers), desc="Processing Segments") as pbar:
for segment_result, start_time, end_time in pool.imap(_process_segment, lr.logreader_identifiers): for segment_result, start_time, end_time in pool.imap(_process_segment, lr.logreader_identifiers):
pbar.update(1) pbar.update(1)
if segment_result: if segment_result:
@ -292,9 +341,9 @@ class DataManager:
self._duration = end_time - self._start_time self._duration = end_time - self._start_time
for msg_type, data in segment_data.items(): for msg_type, data in segment_data.items():
for field in data.keys(): for field_name in data.keys():
if field != 't': if field_name != 't':
self._paths.add(f"{msg_type}/{field}") self._paths.add(f"{msg_type}/{field_name}")
observers = self._observers.copy() observers = self._observers.copy()

@ -2,7 +2,6 @@ import os
import re import re
import threading import threading
import numpy as np import numpy as np
from collections import deque
import dearpygui.dearpygui as dpg import dearpygui.dearpygui as dpg
@ -12,8 +11,9 @@ class DataTreeNode:
self.full_path = full_path self.full_path = full_path
self.parent = parent self.parent = parent
self.children: dict[str, DataTreeNode] = {} self.children: dict[str, DataTreeNode] = {}
self.filtered_children: dict[str, DataTreeNode] = {}
self.created_children: dict[str, DataTreeNode] = {}
self.is_leaf = False self.is_leaf = False
self.child_count = 0
self.is_plottable: bool | None = None self.is_plottable: bool | None = None
self.ui_created = False self.ui_created = False
self.children_ui_created = False self.children_ui_created = False
@ -28,154 +28,211 @@ class DataTree:
self.playback_manager = playback_manager self.playback_manager = playback_manager
self.current_search = "" self.current_search = ""
self.data_tree = DataTreeNode(name="root") self.data_tree = DataTreeNode(name="root")
self._build_queue: deque[tuple[DataTreeNode, str | None, str | int]] = deque() self._build_queue: dict[str, tuple[DataTreeNode, DataTreeNode, str | int]] = {} # full_path -> (node, parent, before_tag)
self._all_paths_cache: set[str] = set() self._current_created_paths: set[str] = set()
self._item_handlers: set[str] = set() self._current_filtered_paths: set[str] = set()
self._avg_char_width = None self._path_to_node: dict[str, DataTreeNode] = {} # full_path -> node
self._expanded_tags: set[str] = set()
self._item_handlers: dict[str, str] = {} # ui_tag -> handler_tag
self._char_width = None
self._queued_search = None self._queued_search = None
self._new_data = False self._new_data = False
self._ui_lock = threading.RLock() self._ui_lock = threading.RLock()
self._handlers_to_delete = []
self.data_manager.add_observer(self._on_data_loaded) self.data_manager.add_observer(self._on_data_loaded)
def create_ui(self, parent_tag: str): def create_ui(self, parent_tag: str):
with dpg.child_window(parent=parent_tag, border=False, width=-1, height=-1): with dpg.child_window(parent=parent_tag, border=False, width=-1, height=-1):
dpg.add_text("Available Data") dpg.add_text("Timeseries List")
dpg.add_separator() dpg.add_separator()
dpg.add_input_text(tag="search_input", width=-1, hint="Search fields...", callback=self.search_data) dpg.add_input_text(tag="search_input", width=-1, hint="Search fields...", callback=self.search_data)
dpg.add_separator() dpg.add_separator()
with dpg.child_window(border=False, width=-1, height=-1):
with dpg.group(tag="data_tree_container"): with dpg.group(tag="data_tree_container"):
pass pass
def _on_data_loaded(self, data: dict): def _on_data_loaded(self, data: dict):
with self._ui_lock: with self._ui_lock:
if data.get('segment_added'): if data.get('segment_added') or data.get('reset'):
self._new_data = True self._new_data = True
elif data.get('reset'):
self._all_paths_cache = set()
self._new_data = True
def _populate_tree(self):
self._clear_ui()
self.data_tree = self._add_paths_to_tree(self._all_paths_cache, incremental=False)
if self.data_tree:
self._request_children_build(self.data_tree)
def _add_paths_to_tree(self, paths, incremental=False):
search_term = self.current_search.strip().lower()
filtered_paths = [path for path in paths if self._should_show_path(path, search_term)]
target_tree = self.data_tree if incremental else DataTreeNode(name="root")
if not filtered_paths:
return target_tree
parent_nodes_to_recheck = set()
for path in sorted(filtered_paths):
parts = path.split('/')
current_node = target_tree
current_path_prefix = ""
for i, part in enumerate(parts):
current_path_prefix = f"{current_path_prefix}/{part}" if current_path_prefix else part
if i < len(parts) - 1:
parent_nodes_to_recheck.add(current_node) # for incremental changes from new data
if part not in current_node.children:
current_node.children[part] = DataTreeNode(name=part, full_path=current_path_prefix, parent=current_node)
current_node = current_node.children[part]
if not current_node.is_leaf:
current_node.is_leaf = True
self._calculate_child_counts(target_tree)
if incremental:
for p_node in parent_nodes_to_recheck:
p_node.children_ui_created = False
self._request_children_build(p_node)
return target_tree
def update_frame(self, font): def update_frame(self, font):
if self._handlers_to_delete: # we need to do everything in main thread, frame callbacks are flaky
dpg.render_dearpygui_frame() # wait a frame to ensure queued callbacks are done
with self._ui_lock: with self._ui_lock:
if self._avg_char_width is None and dpg.is_dearpygui_running(): for handler in self._handlers_to_delete:
self._avg_char_width = self.calculate_avg_char_width(font) dpg.delete_item(handler)
self._handlers_to_delete.clear()
with self._ui_lock:
if self._char_width is None:
if size := dpg.get_text_size(" ", font=font):
self._char_width = size[0] / 2 # we scale font 2x and downscale to fix hidpi bug
if self._new_data: if self._new_data:
current_paths = set(self.data_manager.get_all_paths()) self._process_path_change()
new_paths = current_paths - self._all_paths_cache
all_paths_empty = not self._all_paths_cache
self._all_paths_cache = current_paths
if all_paths_empty:
self._populate_tree()
elif new_paths:
self._add_paths_to_tree(new_paths, incremental=True)
self._new_data = False self._new_data = False
return return
if self._queued_search is not None: if self._queued_search is not None:
self.current_search = self._queued_search self.current_search = self._queued_search
self._all_paths_cache = set(self.data_manager.get_all_paths()) self._process_path_change()
self._populate_tree()
self._queued_search = None self._queued_search = None
return return
nodes_processed = 0 nodes_processed = 0
while self._build_queue and nodes_processed < self.MAX_NODES_PER_FRAME: while self._build_queue and nodes_processed < self.MAX_NODES_PER_FRAME:
child_node, parent_tag, before_tag = self._build_queue.popleft() child_node, parent, before_tag = self._build_queue.pop(next(iter(self._build_queue)))
parent_tag = "data_tree_container" if parent.name == "root" else parent.ui_tag
if not child_node.ui_created: if not child_node.ui_created:
if child_node.is_leaf: if child_node.is_leaf:
self._create_leaf_ui(child_node, parent_tag, before_tag) self._create_leaf_ui(child_node, parent_tag, before_tag)
else: else:
self._create_tree_node_ui(child_node, parent_tag, before_tag) self._create_tree_node_ui(child_node, parent_tag, before_tag)
parent.created_children[child_node.name] = parent.children[child_node.name]
self._current_created_paths.add(child_node.full_path)
nodes_processed += 1 nodes_processed += 1
def search_data(self): def _process_path_change(self):
self._queued_search = dpg.get_value("search_input") self._build_queue.clear()
search_term = self.current_search.strip().lower()
all_paths = set(self.data_manager.get_all_paths())
new_filtered_leafs = {path for path in all_paths if self._should_show_path(path, search_term)}
new_filtered_paths = set(new_filtered_leafs)
for path in new_filtered_leafs:
parts = path.split('/')
for i in range(1, len(parts)):
prefix = '/'.join(parts[:i])
new_filtered_paths.add(prefix)
created_paths_to_remove = self._current_created_paths - new_filtered_paths
filtered_paths_to_remove = self._current_filtered_paths - new_filtered_leafs
if created_paths_to_remove or filtered_paths_to_remove:
self._remove_paths_from_tree(created_paths_to_remove, filtered_paths_to_remove)
self._apply_expansion_to_tree(self.data_tree, search_term)
paths_to_add = new_filtered_leafs - self._current_created_paths
if paths_to_add:
self._add_paths_to_tree(paths_to_add)
self._apply_expansion_to_tree(self.data_tree, search_term)
self._current_filtered_paths = new_filtered_paths
def _remove_paths_from_tree(self, created_paths_to_remove, filtered_paths_to_remove):
for path in sorted(created_paths_to_remove, reverse=True):
current_node = self._path_to_node[path]
if len(current_node.created_children) == 0:
self._current_created_paths.remove(current_node.full_path)
if item_handler_tag := self._item_handlers.get(current_node.ui_tag):
dpg.configure_item(item_handler_tag, show=False)
self._handlers_to_delete.append(item_handler_tag)
del self._item_handlers[current_node.ui_tag]
dpg.delete_item(current_node.ui_tag)
current_node.ui_created = False
current_node.ui_tag = None
current_node.children_ui_created = False
del current_node.parent.created_children[current_node.name]
del current_node.parent.filtered_children[current_node.name]
for path in filtered_paths_to_remove:
parts = path.split('/')
current_node = self._path_to_node[path]
def _clear_ui(self): part_array_index = -1
for handler_tag in self._item_handlers: while len(current_node.filtered_children) == 0 and part_array_index >= -len(parts):
dpg.configure_item(handler_tag, show=False) current_node = current_node.parent
dpg.set_frame_callback(dpg.get_frame_count() + 1, callback=self._delete_handlers, user_data=list(self._item_handlers)) if parts[part_array_index] in current_node.filtered_children:
self._item_handlers.clear() del current_node.filtered_children[parts[part_array_index]]
part_array_index -= 1
if dpg.does_item_exist("data_tree_container"): def _add_paths_to_tree(self, paths):
dpg.delete_item("data_tree_container", children_only=True) parent_nodes_to_recheck = set()
for path in sorted(paths):
parts = path.split('/')
current_node = self.data_tree
current_path_prefix = ""
self._build_queue.clear() for i, part in enumerate(parts):
current_path_prefix = f"{current_path_prefix}/{part}" if current_path_prefix else part
if i < len(parts):
parent_nodes_to_recheck.add(current_node) # for incremental changes from new data
if part not in current_node.children:
current_node.children[part] = DataTreeNode(name=part, full_path=current_path_prefix, parent=current_node)
self._path_to_node[current_path_prefix] = current_node.children[part]
current_node.filtered_children[part] = current_node.children[part]
current_node = current_node.children[part]
def _delete_handlers(self, sender, app_data, user_data): if not current_node.is_leaf:
for handler in user_data: current_node.is_leaf = True
dpg.delete_item(handler)
def _calculate_child_counts(self, node: DataTreeNode): for p_node in parent_nodes_to_recheck:
if node.is_leaf: p_node.children_ui_created = False
node.child_count = 0 self._request_children_build(p_node)
else:
node.child_count = len(node.children) def _get_node_label_and_expand(self, node: DataTreeNode, search_term: str):
for child in node.children.values(): label = f"{node.name} ({len(node.filtered_children)} fields)"
self._calculate_child_counts(child) expand = len(search_term) > 0 and any(search_term in path for path in self._get_descendant_paths(node))
if expand and node.parent and len(node.parent.filtered_children) > 100 and len(node.filtered_children) > 2:
label += " (+)" # symbol for large lists which aren't fully expanded for performance (only affects procLog rn)
expand = False
return label, expand
def _apply_expansion_to_tree(self, node: DataTreeNode, search_term: str):
if node.ui_created and not node.is_leaf and node.ui_tag and dpg.does_item_exist(node.ui_tag):
label, expand = self._get_node_label_and_expand(node, search_term)
if expand:
self._expanded_tags.add(node.ui_tag)
dpg.set_value(node.ui_tag, expand)
elif node.ui_tag in self._expanded_tags: # not expanded and was expanded
self._expanded_tags.remove(node.ui_tag)
dpg.set_value(node.ui_tag, expand)
dpg.delete_item(node.ui_tag, children_only=True) # delete children (not visible since collapsed)
self._reset_ui_state_recursive(node)
node.children_ui_created = False
dpg.set_item_label(node.ui_tag, label)
for child in node.created_children.values():
self._apply_expansion_to_tree(child, search_term)
def _reset_ui_state_recursive(self, node: DataTreeNode):
for child in node.created_children.values():
if child.ui_tag is not None:
if item_handler_tag := self._item_handlers.get(child.ui_tag):
self._handlers_to_delete.append(item_handler_tag)
dpg.configure_item(item_handler_tag, show=False)
del self._item_handlers[child.ui_tag]
self._reset_ui_state_recursive(child)
child.ui_created = False
child.ui_tag = None
child.children_ui_created = False
self._current_created_paths.remove(child.full_path)
node.created_children.clear()
def search_data(self):
with self._ui_lock:
self._queued_search = dpg.get_value("search_input")
def _create_tree_node_ui(self, node: DataTreeNode, parent_tag: str, before: str | int): def _create_tree_node_ui(self, node: DataTreeNode, parent_tag: str, before: str | int):
tag = f"tree_{node.full_path}" node.ui_tag = f"tree_{node.full_path}"
node.ui_tag = tag
label = f"{node.name} ({node.child_count} fields)"
search_term = self.current_search.strip().lower() search_term = self.current_search.strip().lower()
expand = bool(search_term) and len(search_term) > 1 and any(search_term in path for path in self._get_descendant_paths(node)) label, expand = self._get_node_label_and_expand(node, search_term)
if expand and node.parent and node.parent.child_count > 100 and node.child_count > 2: # don't fully autoexpand large lists (only affects procLog rn) if expand:
label += " (+)" self._expanded_tags.add(node.ui_tag)
expand = False elif node.ui_tag in self._expanded_tags:
self._expanded_tags.remove(node.ui_tag)
with dpg.tree_node( with dpg.tree_node(
label=label, parent=parent_tag, tag=tag, default_open=expand, open_on_arrow=True, open_on_double_click=True, before=before, delay_search=True label=label, parent=parent_tag, tag=node.ui_tag, default_open=expand, open_on_arrow=True, open_on_double_click=True, before=before, delay_search=True
): ):
with dpg.item_handler_registry() as handler_tag: with dpg.item_handler_registry() as handler_tag:
dpg.add_item_toggled_open_handler(callback=lambda s, a, u: self._request_children_build(node)) dpg.add_item_toggled_open_handler(callback=lambda s, a, u: self._request_children_build(node))
dpg.add_item_visible_handler(callback=lambda s, a, u: self._request_children_build(node)) dpg.add_item_visible_handler(callback=lambda s, a, u: self._request_children_build(node))
dpg.bind_item_handler_registry(tag, handler_tag) dpg.bind_item_handler_registry(node.ui_tag, handler_tag)
self._item_handlers.add(handler_tag) self._item_handlers[node.ui_tag] = handler_tag
node.ui_created = True node.ui_created = True
def _create_leaf_ui(self, node: DataTreeNode, parent_tag: str, before: str | int): def _create_leaf_ui(self, node: DataTreeNode, parent_tag: str, before: str | int):
with dpg.group(parent=parent_tag, tag=f"leaf_{node.full_path}", before=before, delay_search=True) as draggable_group: node.ui_tag = f"leaf_{node.full_path}"
with dpg.group(parent=parent_tag, tag=node.ui_tag, before=before, delay_search=True):
with dpg.table(header_row=False, policy=dpg.mvTable_SizingStretchProp, delay_search=True): with dpg.table(header_row=False, policy=dpg.mvTable_SizingStretchProp, delay_search=True):
dpg.add_table_column(init_width_or_weight=0.5) dpg.add_table_column(init_width_or_weight=0.5)
dpg.add_table_column(init_width_or_weight=0.5) dpg.add_table_column(init_width_or_weight=0.5)
@ -186,25 +243,25 @@ class DataTree:
if node.is_plottable is None: if node.is_plottable is None:
node.is_plottable = self.data_manager.is_plottable(node.full_path) node.is_plottable = self.data_manager.is_plottable(node.full_path)
if node.is_plottable: if node.is_plottable:
with dpg.drag_payload(parent=draggable_group, drag_data=node.full_path, payload_type="TIMESERIES_PAYLOAD"): with dpg.drag_payload(parent=node.ui_tag, drag_data=node.full_path, payload_type="TIMESERIES_PAYLOAD"):
dpg.add_text(f"Plot: {node.full_path}") dpg.add_text(f"Plot: {node.full_path}")
with dpg.item_handler_registry() as handler_tag: with dpg.item_handler_registry() as handler_tag:
dpg.add_item_visible_handler(callback=self._on_item_visible, user_data=node.full_path) dpg.add_item_visible_handler(callback=self._on_item_visible, user_data=node.full_path)
dpg.bind_item_handler_registry(draggable_group, handler_tag) dpg.bind_item_handler_registry(node.ui_tag, handler_tag)
self._item_handlers.add(handler_tag) self._item_handlers[node.ui_tag] = handler_tag
node.ui_created = True node.ui_created = True
node.ui_tag = f"value_{node.full_path}"
def _on_item_visible(self, sender, app_data, user_data): def _on_item_visible(self, sender, app_data, user_data):
with self._ui_lock: with self._ui_lock:
path = user_data path = user_data
value_tag = f"value_{path}" value_tag = f"value_{path}"
value_column_width = dpg.get_item_rect_size("sidebar_window")[0] // 2 if not dpg.does_item_exist(value_tag):
return
value_column_width = dpg.get_item_rect_size(f"leaf_{path}")[0] // 2
value = self.data_manager.get_value_at(path, self.playback_manager.current_time_s) value = self.data_manager.get_value_at(path, self.playback_manager.current_time_s)
if value is not None: if value is not None:
formatted_value = self.format_and_truncate(value, value_column_width, self._avg_char_width) formatted_value = self.format_and_truncate(value, value_column_width, self._char_width)
dpg.set_value(value_tag, formatted_value) dpg.set_value(value_tag, formatted_value)
else: else:
dpg.set_value(value_tag, "N/A") dpg.set_value(value_tag, "N/A")
@ -212,8 +269,7 @@ class DataTree:
def _request_children_build(self, node: DataTreeNode): def _request_children_build(self, node: DataTreeNode):
with self._ui_lock: with self._ui_lock:
if not node.children_ui_created and (node.name == "root" or (node.ui_tag is not None and dpg.get_value(node.ui_tag))): # check root or node expanded if not node.children_ui_created and (node.name == "root" or (node.ui_tag is not None and dpg.get_value(node.ui_tag))): # check root or node expanded
parent_tag = "data_tree_container" if node.name == "root" else node.ui_tag sorted_children = sorted(node.filtered_children.values(), key=self._natural_sort_key)
sorted_children = sorted(node.children.values(), key=self._natural_sort_key)
next_existing: list[int | str] = [0] * len(sorted_children) next_existing: list[int | str] = [0] * len(sorted_children)
current_before_tag: int | str = 0 current_before_tag: int | str = 0
@ -228,7 +284,7 @@ class DataTree:
for i, child_node in enumerate(sorted_children): for i, child_node in enumerate(sorted_children):
if not child_node.ui_created: if not child_node.ui_created:
before_tag = next_existing[i] before_tag = next_existing[i]
self._build_queue.append((child_node, parent_tag, before_tag)) self._build_queue[child_node.full_path] = (child_node, node, before_tag)
node.children_ui_created = True node.children_ui_created = True
def _should_show_path(self, path: str, search_term: str) -> bool: def _should_show_path(self, path: str, search_term: str) -> bool:
@ -242,7 +298,7 @@ class DataTree:
return (node_type_key, parts) return (node_type_key, parts)
def _get_descendant_paths(self, node: DataTreeNode): def _get_descendant_paths(self, node: DataTreeNode):
for child_name, child_node in node.children.items(): for child_name, child_node in node.filtered_children.items():
child_name_lower = child_name.lower() child_name_lower = child_name.lower()
if child_node.is_leaf: if child_node.is_leaf:
yield child_name_lower yield child_name_lower
@ -251,16 +307,9 @@ class DataTree:
yield f"{child_name_lower}/{path}" yield f"{child_name_lower}/{path}"
@staticmethod @staticmethod
def calculate_avg_char_width(font): def format_and_truncate(value, available_width: float, char_width: float) -> str:
sample_text = "abcdefghijklmnopqrstuvwxyz0123456789"
if size := dpg.get_text_size(sample_text, font=font):
return size[0] / len(sample_text)
return None
@staticmethod
def format_and_truncate(value, available_width: float, avg_char_width: float) -> str:
s = f"{value:.5f}" if np.issubdtype(type(value), np.floating) else str(value) s = f"{value:.5f}" if np.issubdtype(type(value), np.floating) else str(value)
max_chars = int(available_width / avg_char_width) - 3 max_chars = int(available_width / char_width)
if len(s) > max_chars: if len(s) > max_chars:
return s[: max(0, max_chars)] + "..." return s[: max(0, max_chars - 3)] + "..."
return s return s

@ -5,15 +5,156 @@ from openpilot.tools.jotpluggler.views import TimeSeriesPanel
GRIP_SIZE = 4 GRIP_SIZE = 4
MIN_PANE_SIZE = 60 MIN_PANE_SIZE = 60
class LayoutManager:
def __init__(self, data_manager, playback_manager, worker_manager, scale: float = 1.0):
self.data_manager = data_manager
self.playback_manager = playback_manager
self.worker_manager = worker_manager
self.scale = scale
self.container_tag = "plot_layout_container"
self.tab_bar_tag = "tab_bar_container"
self.tab_content_tag = "tab_content_area"
self.active_tab = 0
initial_panel_layout = PanelLayoutManager(data_manager, playback_manager, worker_manager, scale)
self.tabs: dict = {0: {"name": "Tab 1", "panel_layout": initial_panel_layout}}
self._next_tab_id = self.active_tab + 1
def to_dict(self) -> dict:
return {
"tabs": {
str(tab_id): {
"name": tab_data["name"],
"panel_layout": tab_data["panel_layout"].to_dict()
}
for tab_id, tab_data in self.tabs.items()
}
}
def clear_and_load_from_dict(self, data: dict):
tab_ids_to_close = list(self.tabs.keys())
for tab_id in tab_ids_to_close:
self.close_tab(tab_id, force=True)
for tab_id_str, tab_data in data["tabs"].items():
tab_id = int(tab_id_str)
panel_layout = PanelLayoutManager.load_from_dict(
tab_data["panel_layout"], self.data_manager, self.playback_manager,
self.worker_manager, self.scale
)
self.tabs[tab_id] = {
"name": tab_data["name"],
"panel_layout": panel_layout
}
self.active_tab = min(self.tabs.keys()) if self.tabs else 0
self._next_tab_id = max(self.tabs.keys()) + 1 if self.tabs else 1
class PlotLayoutManager: def create_ui(self, parent_tag: str):
if dpg.does_item_exist(self.container_tag):
dpg.delete_item(self.container_tag)
with dpg.child_window(tag=self.container_tag, parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True, no_scroll_with_mouse=True):
self._create_tab_bar()
self._create_tab_content()
dpg.bind_item_theme(self.tab_bar_tag, "tab_bar_theme")
def _create_tab_bar(self):
text_size = int(13 * self.scale)
with dpg.child_window(tag=self.tab_bar_tag, parent=self.container_tag, height=(text_size + 8), border=False, horizontal_scrollbar=True):
with dpg.group(horizontal=True, tag="tab_bar_group"):
for tab_id, tab_data in self.tabs.items():
self._create_tab_ui(tab_id, tab_data["name"])
dpg.add_image_button(texture_tag="plus_texture", callback=self.add_tab, width=text_size, height=text_size, tag="add_tab_button")
dpg.bind_item_theme("add_tab_button", "inactive_tab_theme")
def _create_tab_ui(self, tab_id: int, tab_name: str):
text_size = int(13 * self.scale)
tab_width = int(140 * self.scale)
with dpg.child_window(width=tab_width, height=-1, border=False, no_scrollbar=True, tag=f"tab_window_{tab_id}", parent="tab_bar_group"):
with dpg.group(horizontal=True, tag=f"tab_group_{tab_id}"):
dpg.add_input_text(
default_value=tab_name, width=tab_width - text_size - 16, callback=lambda s, v, u: self.rename_tab(u, v), user_data=tab_id, tag=f"tab_input_{tab_id}"
)
dpg.add_image_button(
texture_tag="x_texture", callback=lambda s, a, u: self.close_tab(u), user_data=tab_id, width=text_size, height=text_size, tag=f"tab_close_{tab_id}"
)
with dpg.item_handler_registry(tag=f"tab_handler_{tab_id}"):
dpg.add_item_clicked_handler(callback=lambda s, a, u: self.switch_tab(u), user_data=tab_id)
dpg.bind_item_handler_registry(f"tab_group_{tab_id}", f"tab_handler_{tab_id}")
theme_tag = "active_tab_theme" if tab_id == self.active_tab else "inactive_tab_theme"
dpg.bind_item_theme(f"tab_window_{tab_id}", theme_tag)
def _create_tab_content(self):
with dpg.child_window(tag=self.tab_content_tag, parent=self.container_tag, border=False, width=-1, height=-1, no_scrollbar=True, no_scroll_with_mouse=True):
if self.active_tab in self.tabs:
active_panel_layout = self.tabs[self.active_tab]["panel_layout"]
active_panel_layout.create_ui()
def add_tab(self):
new_panel_layout = PanelLayoutManager(self.data_manager, self.playback_manager, self.worker_manager, self.scale)
new_tab = {"name": f"Tab {self._next_tab_id + 1}", "panel_layout": new_panel_layout}
self.tabs[self._next_tab_id] = new_tab
self._create_tab_ui(self._next_tab_id, new_tab["name"])
dpg.move_item("add_tab_button", parent="tab_bar_group") # move plus button to end
self.switch_tab(self._next_tab_id)
self._next_tab_id += 1
def close_tab(self, tab_id: int, force = False):
if len(self.tabs) <= 1 and not force:
return # don't allow closing the last tab
tab_to_close = self.tabs[tab_id]
tab_to_close["panel_layout"].destroy_ui()
for suffix in ["window", "group", "input", "close", "handler"]:
tag = f"tab_{suffix}_{tab_id}"
if dpg.does_item_exist(tag):
dpg.delete_item(tag)
del self.tabs[tab_id]
if self.active_tab == tab_id and self.tabs: # switch to another tab if we closed the active one
self.active_tab = next(iter(self.tabs.keys()))
self._switch_tab_content()
dpg.bind_item_theme(f"tab_window_{self.active_tab}", "active_tab_theme")
def switch_tab(self, tab_id: int):
if tab_id == self.active_tab or tab_id not in self.tabs:
return
current_panel_layout = self.tabs[self.active_tab]["panel_layout"]
current_panel_layout.destroy_ui()
dpg.bind_item_theme(f"tab_window_{self.active_tab}", "inactive_tab_theme") # deactivate old tab
self.active_tab = tab_id
dpg.bind_item_theme(f"tab_window_{tab_id}", "active_tab_theme") # activate new tab
self._switch_tab_content()
def _switch_tab_content(self):
dpg.delete_item(self.tab_content_tag, children_only=True)
active_panel_layout = self.tabs[self.active_tab]["panel_layout"]
active_panel_layout.create_ui()
active_panel_layout.update_all_panels()
def rename_tab(self, tab_id: int, new_name: str):
if tab_id in self.tabs:
self.tabs[tab_id]["name"] = new_name
def update_all_panels(self):
self.tabs[self.active_tab]["panel_layout"].update_all_panels()
def on_viewport_resize(self):
self.tabs[self.active_tab]["panel_layout"].on_viewport_resize()
class PanelLayoutManager:
def __init__(self, data_manager: DataManager, playback_manager, worker_manager, scale: float = 1.0): def __init__(self, data_manager: DataManager, playback_manager, worker_manager, scale: float = 1.0):
self.data_manager = data_manager self.data_manager = data_manager
self.playback_manager = playback_manager self.playback_manager = playback_manager
self.worker_manager = worker_manager self.worker_manager = worker_manager
self.scale = scale self.scale = scale
self.container_tag = "plot_layout_container"
self.active_panels: list = [] self.active_panels: list = []
self.parent_tag = "tab_content_area"
self._queue_resize = False
self._created_handler_tags: set[str] = set()
self.grip_size = int(GRIP_SIZE * self.scale) self.grip_size = int(GRIP_SIZE * self.scale)
self.min_pane_size = int(MIN_PANE_SIZE * self.scale) self.min_pane_size = int(MIN_PANE_SIZE * self.scale)
@ -21,33 +162,94 @@ class PlotLayoutManager:
initial_panel = TimeSeriesPanel(data_manager, playback_manager, worker_manager) initial_panel = TimeSeriesPanel(data_manager, playback_manager, worker_manager)
self.layout: dict = {"type": "panel", "panel": initial_panel} self.layout: dict = {"type": "panel", "panel": initial_panel}
def create_ui(self, parent_tag: str): def to_dict(self) -> dict:
if dpg.does_item_exist(self.container_tag): return self._layout_to_dict(self.layout)
dpg.delete_item(self.container_tag)
def _layout_to_dict(self, layout: dict) -> dict:
if layout["type"] == "panel":
return {
"type": "panel",
"panel": layout["panel"].to_dict()
}
else: # split
return {
"type": "split",
"orientation": layout["orientation"],
"proportions": layout["proportions"],
"children": [self._layout_to_dict(child) for child in layout["children"]]
}
@classmethod
def load_from_dict(cls, data: dict, data_manager, playback_manager, worker_manager, scale: float = 1.0):
manager = cls(data_manager, playback_manager, worker_manager, scale)
manager.layout = manager._dict_to_layout(data)
return manager
def _dict_to_layout(self, data: dict) -> dict:
if data["type"] == "panel":
panel_data = data["panel"]
if panel_data["type"] == "timeseries":
panel = TimeSeriesPanel.load_from_dict(
panel_data, self.data_manager, self.playback_manager, self.worker_manager
)
return {"type": "panel", "panel": panel}
else:
# Handle future panel types here or make a general mapping
raise ValueError(f"Unknown panel type: {panel_data['type']}")
else: # split
return {
"type": "split",
"orientation": data["orientation"],
"proportions": data["proportions"],
"children": [self._dict_to_layout(child) for child in data["children"]]
}
with dpg.child_window(tag=self.container_tag, parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True): def create_ui(self):
container_width, container_height = dpg.get_item_rect_size(self.container_tag) self.active_panels.clear()
self._create_ui_recursive(self.layout, self.container_tag, [], container_width, container_height) if dpg.does_item_exist(self.parent_tag):
dpg.delete_item(self.parent_tag, children_only=True)
self._cleanup_all_handlers()
container_width, container_height = dpg.get_item_rect_size(self.parent_tag)
if container_width == 0 and container_height == 0:
self._queue_resize = True
self._create_ui_recursive(self.layout, self.parent_tag, [], container_width, container_height)
def destroy_ui(self):
self._cleanup_ui_recursive(self.layout, [])
self._cleanup_all_handlers()
self.active_panels.clear()
def _cleanup_all_handlers(self):
for handler_tag in list(self._created_handler_tags):
if dpg.does_item_exist(handler_tag):
dpg.delete_item(handler_tag)
self._created_handler_tags.clear()
def _create_ui_recursive(self, layout: dict, parent_tag: str, path: list[int], width: int, height: int): def _create_ui_recursive(self, layout: dict, parent_tag: str, path: list[int], width: int, height: int):
if layout["type"] == "panel": if layout["type"] == "panel":
self._create_panel_ui(layout, parent_tag, path) self._create_panel_ui(layout, parent_tag, path, width, height)
else: else:
self._create_split_ui(layout, parent_tag, path, width, height) self._create_split_ui(layout, parent_tag, path, width, height)
def _create_panel_ui(self, layout: dict, parent_tag: str, path: list[int]): def _create_panel_ui(self, layout: dict, parent_tag: str, path: list[int], width: int, height: int):
panel_tag = self._path_to_tag(path, "panel") panel_tag = self._path_to_tag(path, "panel")
panel = layout["panel"] panel = layout["panel"]
self.active_panels.append(panel) self.active_panels.append(panel)
text_size = int(13 * self.scale)
bar_height = (text_size + 24) if width < int(329 * self.scale + 64) else (text_size + 8) # adjust height to allow for scrollbar
with dpg.child_window(tag=panel_tag, parent=parent_tag, border=True, width=-1, height=-1, no_scrollbar=True): with dpg.child_window(parent=parent_tag, border=False, width=-1, height=-1, no_scrollbar=True):
with dpg.group(horizontal=True):
with dpg.child_window(tag=panel_tag, width=-(text_size + 16), height=bar_height, horizontal_scrollbar=True, no_scroll_with_mouse=True, border=False):
with dpg.group(horizontal=True): with dpg.group(horizontal=True):
dpg.add_input_text(default_value=panel.title, width=int(100 * self.scale), callback=lambda s, v: setattr(panel, "title", v)) # if you change the widths make sure to change the sum of widths (currently 329 * scale)
dpg.add_input_text(default_value=panel.title, width=int(150 * self.scale), callback=lambda s, v: setattr(panel, "title", v))
dpg.add_combo(items=["Time Series"], default_value="Time Series", width=int(100 * self.scale)) dpg.add_combo(items=["Time Series"], default_value="Time Series", width=int(100 * self.scale))
dpg.add_button(label="Clear", callback=lambda: self.clear_panel(panel), width=int(40 * self.scale)) dpg.add_button(label="Clear", callback=lambda: self.clear_panel(panel), width=int(40 * self.scale))
dpg.add_button(label="Delete", callback=lambda: self.delete_panel(path), width=int(40 * self.scale)) dpg.add_image_button(texture_tag="split_h_texture", callback=lambda: self.split_panel(path, 0), width=text_size, height=text_size)
dpg.add_button(label="Split H", callback=lambda: self.split_panel(path, 0), width=int(40 * self.scale)) dpg.add_image_button(texture_tag="split_v_texture", callback=lambda: self.split_panel(path, 1), width=text_size, height=text_size)
dpg.add_button(label="Split V", callback=lambda: self.split_panel(path, 1), width=int(40 * self.scale)) dpg.add_image_button(texture_tag="x_texture", callback=lambda: self.delete_panel(path), width=text_size, height=text_size)
dpg.add_separator() dpg.add_separator()
@ -133,7 +335,7 @@ class PlotLayoutManager:
if path: if path:
container_tag = self._path_to_tag(path, "container") container_tag = self._path_to_tag(path, "container")
else: # Root update else: # Root update
container_tag = self.container_tag container_tag = self.parent_tag
self._cleanup_ui_recursive(layout, path) self._cleanup_ui_recursive(layout, path)
dpg.delete_item(container_tag, children_only=True) dpg.delete_item(container_tag, children_only=True)
@ -151,11 +353,16 @@ class PlotLayoutManager:
handler_tag = f"{self._path_to_tag(path, f'grip_{i}')}_handler" handler_tag = f"{self._path_to_tag(path, f'grip_{i}')}_handler"
if dpg.does_item_exist(handler_tag): if dpg.does_item_exist(handler_tag):
dpg.delete_item(handler_tag) dpg.delete_item(handler_tag)
self._created_handler_tags.discard(handler_tag)
for i, child in enumerate(layout["children"]): for i, child in enumerate(layout["children"]):
self._cleanup_ui_recursive(child, path + [i]) self._cleanup_ui_recursive(child, path + [i])
def update_all_panels(self): def update_all_panels(self):
if self._queue_resize:
if (size := dpg.get_item_rect_size(self.parent_tag)) != [0, 0]:
self._queue_resize = False
self._resize_splits_recursive(self.layout, [], *size)
for panel in self.active_panels: for panel in self.active_panels:
panel.update() panel.update()
@ -177,11 +384,17 @@ class PlotLayoutManager:
dpg.configure_item(container_tag, **{size_properties[orientation]: pane_sizes[i]}) dpg.configure_item(container_tag, **{size_properties[orientation]: pane_sizes[i]})
child_width, child_height = [(pane_sizes[i], available_sizes[1]), (available_sizes[0], pane_sizes[i])][orientation] child_width, child_height = [(pane_sizes[i], available_sizes[1]), (available_sizes[0], pane_sizes[i])][orientation]
self._resize_splits_recursive(child_layout, child_path, child_width, child_height) self._resize_splits_recursive(child_layout, child_path, child_width, child_height)
else: # leaf node/panel - adjust bar height to allow for scrollbar
panel_tag = self._path_to_tag(path, "panel")
if width is not None and width < int(329 * self.scale + 64): # scaled widths of the elements in top bar + fixed 8 padding on left and right of each item
dpg.configure_item(panel_tag, height=(int(13 * self.scale) + 24))
else:
dpg.configure_item(panel_tag, height=(int(13 * self.scale) + 8))
def _get_split_geometry(self, layout: dict, available_size: tuple[int, int]) -> tuple[int, int, list[int]]: def _get_split_geometry(self, layout: dict, available_size: tuple[int, int]) -> tuple[int, int, list[int]]:
orientation = layout["orientation"] orientation = layout["orientation"]
num_grips = len(layout["children"]) - 1 num_grips = len(layout["children"]) - 1
usable_size = max(self.min_pane_size, available_size[orientation] - (num_grips * self.grip_size)) usable_size = max(self.min_pane_size, available_size[orientation] - (num_grips * (self.grip_size + 8 * (2 - orientation)))) # approximate, scaling is weird
pane_sizes = [max(self.min_pane_size, int(usable_size * prop)) for prop in layout["proportions"]] pane_sizes = [max(self.min_pane_size, int(usable_size * prop)) for prop in layout["proportions"]]
return orientation, usable_size, pane_sizes return orientation, usable_size, pane_sizes
@ -207,16 +420,18 @@ class PlotLayoutManager:
def _create_grip(self, parent_tag: str, path: list[int], grip_index: int, orientation: int): def _create_grip(self, parent_tag: str, path: list[int], grip_index: int, orientation: int):
grip_tag = self._path_to_tag(path, f"grip_{grip_index}") grip_tag = self._path_to_tag(path, f"grip_{grip_index}")
handler_tag = f"{grip_tag}_handler"
width, height = [(self.grip_size, -1), (-1, self.grip_size)][orientation] width, height = [(self.grip_size, -1), (-1, self.grip_size)][orientation]
with dpg.child_window(tag=grip_tag, parent=parent_tag, width=width, height=height, no_scrollbar=True, border=False): with dpg.child_window(tag=grip_tag, parent=parent_tag, width=width, height=height, no_scrollbar=True, border=False):
button_tag = dpg.add_button(label="", width=-1, height=-1) button_tag = dpg.add_button(label="", width=-1, height=-1)
with dpg.item_handler_registry(tag=f"{grip_tag}_handler"): with dpg.item_handler_registry(tag=handler_tag):
user_data = (path, grip_index, orientation) user_data = (path, grip_index, orientation)
dpg.add_item_active_handler(callback=self._on_grip_drag, user_data=user_data) dpg.add_item_active_handler(callback=self._on_grip_drag, user_data=user_data)
dpg.add_item_deactivated_handler(callback=self._on_grip_end, user_data=user_data) dpg.add_item_deactivated_handler(callback=self._on_grip_end, user_data=user_data)
dpg.bind_item_handler_registry(button_tag, f"{grip_tag}_handler") dpg.bind_item_handler_registry(button_tag, handler_tag)
self._created_handler_tags.add(handler_tag)
def _on_grip_drag(self, sender, app_data, user_data): def _on_grip_drag(self, sender, app_data, user_data):
path, grip_index, orientation = user_data path, grip_index, orientation = user_data

@ -0,0 +1,128 @@
tabs:
'0':
name: Lateral Plan Conformance
panel_layout:
type: split
orientation: 1
proportions:
- 0.3333333333333333
- 0.3333333333333333
- 0.3333333333333333
children:
- type: panel
panel:
type: timeseries
title: desired vs actual
series_paths:
- controlsState/lateralControlState/torqueState/desiredLateralAccel
- controlsState/lateralControlState/torqueState/actualLateralAccel
- type: panel
panel:
type: timeseries
title: ff vs output
series_paths:
- controlsState/lateralControlState/torqueState/f
- carState/steeringPressed
- carControl/actuators/torque
- type: panel
panel:
type: timeseries
title: vehicle speed
series_paths:
- carState/vEgo
'1':
name: Actuator Performance
panel_layout:
type: split
orientation: 1
proportions:
- 0.3333333333333333
- 0.3333333333333333
- 0.3333333333333333
children:
- type: panel
panel:
type: timeseries
title: calc vs learned latAccelFactor
series_paths:
- liveTorqueParameters/latAccelFactorFiltered
- liveTorqueParameters/latAccelFactorRaw
- carParams/lateralTuning/torque/latAccelFactor
- type: panel
panel:
type: timeseries
title: learned latAccelOffset
series_paths:
- liveTorqueParameters/latAccelOffsetRaw
- liveTorqueParameters/latAccelOffsetFiltered
- type: panel
panel:
type: timeseries
title: calc vs learned friction
series_paths:
- liveTorqueParameters/frictionCoefficientFiltered
- liveTorqueParameters/frictionCoefficientRaw
- carParams/lateralTuning/torque/friction
'2':
name: Vehicle Dynamics
panel_layout:
type: split
orientation: 1
proportions:
- 0.3333333333333333
- 0.3333333333333333
- 0.3333333333333333
children:
- type: panel
panel:
type: timeseries
title: initial vs learned steerRatio
series_paths:
- carParams/steerRatio
- liveParameters/steerRatio
- type: panel
panel:
type: timeseries
title: initial vs learned tireStiffnessFactor
series_paths:
- carParams/tireStiffnessFactor
- liveParameters/stiffnessFactor
- type: panel
panel:
type: timeseries
title: live steering angle offsets
series_paths:
- liveParameters/angleOffsetDeg
- liveParameters/angleOffsetAverageDeg
'3':
name: Controller PIF Terms
panel_layout:
type: split
orientation: 1
proportions:
- 0.3333333333333333
- 0.3333333333333333
- 0.3333333333333333
children:
- type: panel
panel:
type: timeseries
title: ff vs output
series_paths:
- carControl/actuators/torque
- controlsState/lateralControlState/torqueState/f
- carState/steeringPressed
- type: panel
panel:
type: timeseries
title: PIF terms
series_paths:
- controlsState/lateralControlState/torqueState/f
- controlsState/lateralControlState/torqueState/p
- controlsState/lateralControlState/torqueState/i
- type: panel
panel:
type: timeseries
title: road roll angle
series_paths:
- liveParameters/roll

@ -7,10 +7,12 @@ import dearpygui.dearpygui as dpg
import multiprocessing import multiprocessing
import uuid import uuid
import signal import signal
import yaml # type: ignore
from openpilot.common.swaglog import cloudlog
from openpilot.common.basedir import BASEDIR from openpilot.common.basedir import BASEDIR
from openpilot.tools.jotpluggler.data import DataManager from openpilot.tools.jotpluggler.data import DataManager
from openpilot.tools.jotpluggler.datatree import DataTree from openpilot.tools.jotpluggler.datatree import DataTree
from openpilot.tools.jotpluggler.layout import PlotLayoutManager from openpilot.tools.jotpluggler.layout import LayoutManager
DEMO_ROUTE = "a2a0ccea32023010|2023-07-27--13-01-19" DEMO_ROUTE = "a2a0ccea32023010|2023-07-27--13-01-19"
@ -64,6 +66,11 @@ class PlaybackManager:
self.is_playing = False self.is_playing = False
self.current_time_s = 0.0 self.current_time_s = 0.0
self.duration_s = 0.0 self.duration_s = 0.0
self.num_segments = 0
self.x_axis_bounds = (0.0, 0.0) # (min_time, max_time)
self.x_axis_observers = [] # callbacks for x-axis changes
self._updating_x_axis = False
def set_route_duration(self, duration: float): def set_route_duration(self, duration: float):
self.duration_s = duration self.duration_s = duration
@ -73,9 +80,10 @@ class PlaybackManager:
if not self.is_playing and self.current_time_s >= self.duration_s: if not self.is_playing and self.current_time_s >= self.duration_s:
self.seek(0.0) self.seek(0.0)
self.is_playing = not self.is_playing self.is_playing = not self.is_playing
texture_tag = "pause_texture" if self.is_playing else "play_texture"
dpg.configure_item("play_pause_button", texture_tag=texture_tag)
def seek(self, time_s: float): def seek(self, time_s: float):
self.is_playing = False
self.current_time_s = max(0.0, min(time_s, self.duration_s)) self.current_time_s = max(0.0, min(time_s, self.duration_s))
def update_time(self, delta_t: float): def update_time(self, delta_t: float):
@ -83,8 +91,36 @@ class PlaybackManager:
self.current_time_s = min(self.current_time_s + delta_t, self.duration_s) self.current_time_s = min(self.current_time_s + delta_t, self.duration_s)
if self.current_time_s >= self.duration_s: if self.current_time_s >= self.duration_s:
self.is_playing = False self.is_playing = False
dpg.configure_item("play_pause_button", texture_tag="play_texture")
return self.current_time_s return self.current_time_s
def set_x_axis_bounds(self, min_time: float, max_time: float, source_panel=None):
if self._updating_x_axis:
return
new_bounds = (min_time, max_time)
if new_bounds == self.x_axis_bounds:
return
self.x_axis_bounds = new_bounds
self._updating_x_axis = True # prevent recursive updates
try:
for callback in self.x_axis_observers:
try:
callback(min_time, max_time, source_panel)
except Exception as e:
print(f"Error in x-axis sync callback: {e}")
finally:
self._updating_x_axis = False
def add_x_axis_observer(self, callback):
if callback not in self.x_axis_observers:
self.x_axis_observers.append(callback)
def remove_x_axis_observer(self, callback):
if callback in self.x_axis_observers:
self.x_axis_observers.remove(callback)
class MainController: class MainController:
def __init__(self, scale: float = 1.0): def __init__(self, scale: float = 1.0):
@ -94,34 +130,49 @@ class MainController:
self.worker_manager = WorkerManager() self.worker_manager = WorkerManager()
self._create_global_themes() self._create_global_themes()
self.data_tree = DataTree(self.data_manager, self.playback_manager) self.data_tree = DataTree(self.data_manager, self.playback_manager)
self.plot_layout_manager = PlotLayoutManager(self.data_manager, self.playback_manager, self.worker_manager, scale=self.scale) self.layout_manager = LayoutManager(self.data_manager, self.playback_manager, self.worker_manager, scale=self.scale)
self.data_manager.add_observer(self.on_data_loaded) self.data_manager.add_observer(self.on_data_loaded)
self._total_segments = 0
def _create_global_themes(self): def _create_global_themes(self):
with dpg.theme(tag="global_line_theme"): with dpg.theme(tag="line_theme"):
with dpg.theme_component(dpg.mvLineSeries): with dpg.theme_component(dpg.mvLineSeries):
scaled_thickness = max(1.0, self.scale) scaled_thickness = max(1.0, self.scale)
dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots) dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots)
with dpg.theme(tag="global_timeline_theme"): with dpg.theme(tag="timeline_theme"):
with dpg.theme_component(dpg.mvInfLineSeries): with dpg.theme_component(dpg.mvInfLineSeries):
scaled_thickness = max(1.0, self.scale) scaled_thickness = max(1.0, self.scale)
dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots) dpg.add_theme_style(dpg.mvPlotStyleVar_LineWeight, scaled_thickness, category=dpg.mvThemeCat_Plots)
dpg.add_theme_color(dpg.mvPlotCol_Line, (255, 0, 0, 128), category=dpg.mvThemeCat_Plots) dpg.add_theme_color(dpg.mvPlotCol_Line, (255, 0, 0, 128), category=dpg.mvThemeCat_Plots)
for tag, color in (("active_tab_theme", (37, 37, 38, 255)), ("inactive_tab_theme", (70, 70, 75, 255))):
with dpg.theme(tag=tag):
for cmp, target in ((dpg.mvChildWindow, dpg.mvThemeCol_ChildBg), (dpg.mvInputText, dpg.mvThemeCol_FrameBg), (dpg.mvImageButton, dpg.mvThemeCol_Button)):
with dpg.theme_component(cmp):
dpg.add_theme_color(target, color)
with dpg.theme(tag="tab_bar_theme"):
with dpg.theme_component(dpg.mvChildWindow):
dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (51, 51, 55, 255))
def on_data_loaded(self, data: dict): def on_data_loaded(self, data: dict):
duration = data.get('duration', 0.0) duration = data.get('duration', 0.0)
self.playback_manager.set_route_duration(duration) self.playback_manager.set_route_duration(duration)
if data.get('reset'): if data.get('metadata_loaded'):
self.playback_manager.num_segments = data.get('total_segments', 0)
self._total_segments = data.get('total_segments', 0)
dpg.set_value("load_status", f"Loading... 0/{self._total_segments} segments processed")
elif data.get('reset'):
self.playback_manager.current_time_s = 0.0 self.playback_manager.current_time_s = 0.0
self.playback_manager.duration_s = 0.0 self.playback_manager.duration_s = 0.0
self.playback_manager.is_playing = False self.playback_manager.is_playing = False
self._total_segments = 0
dpg.set_value("load_status", "Loading...") dpg.set_value("load_status", "Loading...")
dpg.set_value("timeline_slider", 0.0) dpg.set_value("timeline_slider", 0.0)
dpg.configure_item("timeline_slider", max_value=0.0) dpg.configure_item("timeline_slider", max_value=0.0)
dpg.configure_item("play_pause_button", label="Play") dpg.configure_item("play_pause_button", texture_tag="play_texture")
dpg.configure_item("load_button", enabled=True) dpg.configure_item("load_button", enabled=True)
elif data.get('loading_complete'): elif data.get('loading_complete'):
num_paths = len(self.data_manager.get_all_paths()) num_paths = len(self.data_manager.get_all_paths())
@ -129,34 +180,99 @@ class MainController:
dpg.configure_item("load_button", enabled=True) dpg.configure_item("load_button", enabled=True)
elif data.get('segment_added'): elif data.get('segment_added'):
segment_count = data.get('segment_count', 0) segment_count = data.get('segment_count', 0)
dpg.set_value("load_status", f"Loading... {segment_count} segments processed") dpg.set_value("load_status", f"Loading... {segment_count}/{self._total_segments} segments processed")
dpg.configure_item("timeline_slider", max_value=duration) dpg.configure_item("timeline_slider", max_value=duration)
def save_layout_to_yaml(self, filepath: str):
layout_dict = self.layout_manager.to_dict()
with open(filepath, 'w') as f:
yaml.dump(layout_dict, f, default_flow_style=False, sort_keys=False)
def load_layout_from_yaml(self, filepath: str):
with open(filepath) as f:
layout_dict = yaml.safe_load(f)
self.layout_manager.clear_and_load_from_dict(layout_dict)
self.layout_manager.create_ui("main_plot_area")
def save_layout_dialog(self):
if dpg.does_item_exist("save_layout_dialog"):
dpg.delete_item("save_layout_dialog")
with dpg.file_dialog(
callback=self._save_layout_callback, tag="save_layout_dialog", width=int(700 * self.scale), height=int(400 * self.scale),
default_filename="layout", default_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), "layouts")
):
dpg.add_file_extension(".yaml")
def load_layout_dialog(self):
if dpg.does_item_exist("load_layout_dialog"):
dpg.delete_item("load_layout_dialog")
with dpg.file_dialog(
callback=self._load_layout_callback, tag="load_layout_dialog", width=int(700 * self.scale), height=int(400 * self.scale),
default_path=os.path.join(os.path.dirname(os.path.realpath(__file__)), "layouts")
):
dpg.add_file_extension(".yaml")
def _save_layout_callback(self, sender, app_data):
filepath = app_data['file_path_name']
try:
self.save_layout_to_yaml(filepath)
dpg.set_value("load_status", f"Layout saved to {os.path.basename(filepath)}")
except Exception:
dpg.set_value("load_status", "Error saving layout")
cloudlog.exception(f"Error saving layout to {filepath}")
dpg.delete_item("save_layout_dialog")
def _load_layout_callback(self, sender, app_data):
filepath = app_data['file_path_name']
try:
self.load_layout_from_yaml(filepath)
dpg.set_value("load_status", f"Layout loaded from {os.path.basename(filepath)}")
except Exception:
dpg.set_value("load_status", "Error loading layout")
cloudlog.exception(f"Error loading layout from {filepath}:")
dpg.delete_item("load_layout_dialog")
def setup_ui(self): def setup_ui(self):
with dpg.texture_registry():
script_dir = os.path.dirname(os.path.realpath(__file__))
for image in ["play", "pause", "x", "split_h", "split_v", "plus"]:
texture = dpg.load_image(os.path.join(script_dir, "assets", f"{image}.png"))
dpg.add_static_texture(width=texture[0], height=texture[1], default_value=texture[3], tag=f"{image}_texture")
with dpg.window(tag="Primary Window"): with dpg.window(tag="Primary Window"):
with dpg.group(horizontal=True): with dpg.group(horizontal=True):
# Left panel - Data tree # Left panel - Data tree
with dpg.child_window(label="Sidebar", width=300 * self.scale, tag="sidebar_window", border=True, resizable_x=True): with dpg.child_window(label="Sidebar", width=int(300 * self.scale), tag="sidebar_window", border=True, resizable_x=True):
with dpg.group(horizontal=True): with dpg.group(horizontal=True):
dpg.add_input_text(tag="route_input", width=-75 * self.scale, hint="Enter route name...") dpg.add_input_text(tag="route_input", width=int(-75 * self.scale), hint="Enter route name...")
dpg.add_button(label="Load", callback=self.load_route, tag="load_button", width=-1) dpg.add_button(label="Load", callback=self.load_route, tag="load_button", width=-1)
dpg.add_text("Ready to load route", tag="load_status") dpg.add_text("Ready to load route", tag="load_status")
dpg.add_separator() dpg.add_separator()
with dpg.table(header_row=False, policy=dpg.mvTable_SizingStretchProp):
dpg.add_table_column(init_width_or_weight=0.5)
dpg.add_table_column(init_width_or_weight=0.5)
with dpg.table_row():
dpg.add_button(label="Save Layout", callback=self.save_layout_dialog, width=-1)
dpg.add_button(label="Load Layout", callback=self.load_layout_dialog, width=-1)
dpg.add_separator()
self.data_tree.create_ui("sidebar_window") self.data_tree.create_ui("sidebar_window")
# Right panel - Plots and timeline # Right panel - Plots and timeline
with dpg.group(tag="right_panel"): with dpg.group(tag="right_panel"):
with dpg.child_window(label="Plot Window", border=True, height=-(30 + 13 * self.scale), tag="main_plot_area"): with dpg.child_window(label="Plot Window", border=True, height=int(-(32 + 13 * self.scale)), tag="main_plot_area"):
self.plot_layout_manager.create_ui("main_plot_area") self.layout_manager.create_ui("main_plot_area")
with dpg.child_window(label="Timeline", border=True): with dpg.child_window(label="Timeline", border=True):
with dpg.table(header_row=False, borders_innerH=False, borders_innerV=False, borders_outerH=False, borders_outerV=False): with dpg.table(header_row=False):
dpg.add_table_column(width_fixed=True, init_width_or_weight=int(50 * self.scale)) # Play button btn_size = int(13 * self.scale)
dpg.add_table_column(width_fixed=True, init_width_or_weight=(btn_size + 8)) # Play button
dpg.add_table_column(width_stretch=True) # Timeline slider dpg.add_table_column(width_stretch=True) # Timeline slider
dpg.add_table_column(width_fixed=True, init_width_or_weight=int(50 * self.scale)) # FPS counter dpg.add_table_column(width_fixed=True, init_width_or_weight=int(50 * self.scale)) # FPS counter
with dpg.table_row(): with dpg.table_row():
dpg.add_button(label="Play", tag="play_pause_button", callback=self.toggle_play_pause, width=int(50 * self.scale)) dpg.add_image_button(texture_tag="play_texture", tag="play_pause_button", callback=self.toggle_play_pause, width=btn_size, height=btn_size)
dpg.add_slider_float(tag="timeline_slider", default_value=0.0, label="", width=-1, callback=self.timeline_drag) dpg.add_slider_float(tag="timeline_slider", default_value=0.0, label="", width=-1, callback=self.timeline_drag)
dpg.add_text("", tag="fps_counter") dpg.add_text("", tag="fps_counter")
with dpg.item_handler_registry(tag="plot_resize_handler"): with dpg.item_handler_registry(tag="plot_resize_handler"):
@ -166,7 +282,7 @@ class MainController:
dpg.set_primary_window("Primary Window", True) dpg.set_primary_window("Primary Window", True)
def on_plot_resize(self, sender, app_data, user_data): def on_plot_resize(self, sender, app_data, user_data):
self.plot_layout_manager.on_viewport_resize() self.layout_manager.on_viewport_resize()
def load_route(self): def load_route(self):
route_name = dpg.get_value("route_input").strip() route_name = dpg.get_value("route_input").strip()
@ -177,12 +293,9 @@ class MainController:
def toggle_play_pause(self, sender): def toggle_play_pause(self, sender):
self.playback_manager.toggle_play_pause() self.playback_manager.toggle_play_pause()
label = "Pause" if self.playback_manager.is_playing else "Play"
dpg.configure_item(sender, label=label)
def timeline_drag(self, sender, app_data): def timeline_drag(self, sender, app_data):
self.playback_manager.seek(app_data) self.playback_manager.seek(app_data)
dpg.configure_item("play_pause_button", label="Play")
def update_frame(self, font): def update_frame(self, font):
self.data_tree.update_frame(font) self.data_tree.update_frame(font)
@ -191,7 +304,7 @@ class MainController:
if not dpg.is_item_active("timeline_slider"): if not dpg.is_item_active("timeline_slider"):
dpg.set_value("timeline_slider", new_time) dpg.set_value("timeline_slider", new_time)
self.plot_layout_manager.update_all_panels() self.layout_manager.update_all_panels()
dpg.set_value("fps_counter", f"{dpg.get_frame_rate():.1f} FPS") dpg.set_value("fps_counter", f"{dpg.get_frame_rate():.1f} FPS")
@ -199,7 +312,7 @@ class MainController:
self.worker_manager.shutdown() self.worker_manager.shutdown()
def main(route_to_load=None): def main(route_to_load=None, layout_to_load=None):
dpg.create_context() dpg.create_context()
# TODO: find better way of calculating display scaling # TODO: find better way of calculating display scaling
@ -210,8 +323,9 @@ def main(route_to_load=None):
scale = 1 scale = 1
with dpg.font_registry(): with dpg.font_registry():
default_font = dpg.add_font(os.path.join(BASEDIR, "selfdrive/assets/fonts/Inter-Regular.ttf"), int(13 * scale)) default_font = dpg.add_font(os.path.join(BASEDIR, "selfdrive/assets/fonts/JetBrainsMono-Medium.ttf"), int(13 * scale * 2)) # 2x then scale for hidpi
dpg.bind_font(default_font) dpg.bind_font(default_font)
dpg.set_global_font_scale(0.5)
viewport_width, viewport_height = int(1200 * scale), int(800 * scale) viewport_width, viewport_height = int(1200 * scale), int(800 * scale)
mouse_x, mouse_y = pyautogui.position() # TODO: find better way of creating the window where the user is (default dpg behavior annoying on multiple displays) mouse_x, mouse_y = pyautogui.position() # TODO: find better way of creating the window where the user is (default dpg behavior annoying on multiple displays)
@ -223,6 +337,14 @@ def main(route_to_load=None):
controller = MainController(scale=scale) controller = MainController(scale=scale)
controller.setup_ui() controller.setup_ui()
if layout_to_load:
try:
controller.load_layout_from_yaml(layout_to_load)
print(f"Loaded layout from {layout_to_load}")
except Exception as e:
print(f"Failed to load layout from {layout_to_load}: {e}")
cloudlog.exception(f"Error loading layout from {layout_to_load}")
if route_to_load: if route_to_load:
dpg.set_value("route_input", route_to_load) dpg.set_value("route_input", route_to_load)
controller.load_route() controller.load_route()
@ -241,7 +363,8 @@ def main(route_to_load=None):
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="A tool for visualizing openpilot logs.") parser = argparse.ArgumentParser(description="A tool for visualizing openpilot logs.")
parser.add_argument("--demo", action="store_true", help="Use the demo route instead of providing one") parser.add_argument("--demo", action="store_true", help="Use the demo route instead of providing one")
parser.add_argument("--layout", type=str, help="Path to YAML layout file to load on startup")
parser.add_argument("route", nargs='?', default=None, help="Optional route name to load on startup.") parser.add_argument("route", nargs='?', default=None, help="Optional route name to load on startup.")
args = parser.parse_args() args = parser.parse_args()
route = DEMO_ROUTE if args.demo else args.route route = DEMO_ROUTE if args.demo else args.route
main(route_to_load=route) main(route_to_load=route, layout_to_load=args.layout)

@ -33,6 +33,15 @@ class ViewPanel(ABC):
def update(self): def update(self):
pass pass
@abstractmethod
def to_dict(self) -> dict:
pass
@classmethod
@abstractmethod
def load_from_dict(cls, data: dict, data_manager, playback_manager, worker_manager):
pass
class TimeSeriesPanel(ViewPanel): class TimeSeriesPanel(ViewPanel):
def __init__(self, data_manager, playback_manager, worker_manager, panel_id: str | None = None): def __init__(self, data_manager, playback_manager, worker_manager, panel_id: str | None = None):
@ -46,23 +55,42 @@ class TimeSeriesPanel(ViewPanel):
self.y_axis_tag = f"{self.plot_tag}_y_axis" self.y_axis_tag = f"{self.plot_tag}_y_axis"
self.timeline_indicator_tag = f"{self.plot_tag}_timeline" self.timeline_indicator_tag = f"{self.plot_tag}_timeline"
self._ui_created = False self._ui_created = False
self._series_data: dict[str, tuple[list, list]] = {} self._series_data: dict[str, tuple[np.ndarray, np.ndarray]] = {}
self._last_plot_duration = 0 self._last_plot_duration = 0
self._update_lock = threading.RLock() self._update_lock = threading.RLock()
self.results_deque: deque[tuple[str, list, list]] = deque() self._results_deque: deque[tuple[str, list, list]] = deque()
self._new_data = False self._new_data = False
self._last_x_limits = (0.0, 0.0)
self._queued_x_sync: tuple | None = None
self._queued_reallow_x_zoom = False
self._total_segments = self.playback_manager.num_segments
def to_dict(self) -> dict:
return {
"type": "timeseries",
"title": self.title,
"series_paths": list(self._series_data.keys())
}
@classmethod
def load_from_dict(cls, data: dict, data_manager, playback_manager, worker_manager):
panel = cls(data_manager, playback_manager, worker_manager)
panel.title = data.get("title", "Time Series Plot")
panel._series_data = {path: (np.array([]), np.array([])) for path in data.get("series_paths", [])}
return panel
def create_ui(self, parent_tag: str): def create_ui(self, parent_tag: str):
self.data_manager.add_observer(self.on_data_loaded) self.data_manager.add_observer(self.on_data_loaded)
self.playback_manager.add_x_axis_observer(self._on_x_axis_sync)
with dpg.plot(height=-1, width=-1, tag=self.plot_tag, parent=parent_tag, drop_callback=self._on_series_drop, payload_type="TIMESERIES_PAYLOAD"): with dpg.plot(height=-1, width=-1, tag=self.plot_tag, parent=parent_tag, drop_callback=self._on_series_drop, payload_type="TIMESERIES_PAYLOAD"):
dpg.add_plot_legend() dpg.add_plot_legend()
dpg.add_plot_axis(dpg.mvXAxis, no_label=True, tag=self.x_axis_tag) dpg.add_plot_axis(dpg.mvXAxis, no_label=True, tag=self.x_axis_tag)
dpg.add_plot_axis(dpg.mvYAxis, no_label=True, tag=self.y_axis_tag) dpg.add_plot_axis(dpg.mvYAxis, no_label=True, tag=self.y_axis_tag)
timeline_series_tag = dpg.add_inf_line_series(x=[0], label="Timeline", parent=self.y_axis_tag, tag=self.timeline_indicator_tag) timeline_series_tag = dpg.add_inf_line_series(x=[0], label="Timeline", parent=self.y_axis_tag, tag=self.timeline_indicator_tag)
dpg.bind_item_theme(timeline_series_tag, "global_timeline_theme") dpg.bind_item_theme(timeline_series_tag, "timeline_theme")
for series_path in list(self._series_data.keys()): self._new_data = True
self.add_series(series_path) self._queued_x_sync = self.playback_manager.x_axis_bounds
self._ui_created = True self._ui_created = True
def update(self): def update(self):
@ -70,17 +98,48 @@ class TimeSeriesPanel(ViewPanel):
if not self._ui_created: if not self._ui_created:
return return
if self._queued_x_sync:
min_time, max_time = self._queued_x_sync
self._queued_x_sync = None
dpg.set_axis_limits(self.x_axis_tag, min_time, max_time)
self._last_x_limits = (min_time, max_time)
self._fit_y_axis(min_time, max_time)
self._queued_reallow_x_zoom = True # must wait a frame before allowing user changes so that axis limits take effect
return
if self._queued_reallow_x_zoom:
self._queued_reallow_x_zoom = False
if tuple(dpg.get_axis_limits(self.x_axis_tag)) == self._last_x_limits:
dpg.set_axis_limits_auto(self.x_axis_tag)
else:
self._queued_x_sync = self._last_x_limits # retry, likely too early
return
if self._new_data: # handle new data in main thread if self._new_data: # handle new data in main thread
self._new_data = False self._new_data = False
if self._total_segments > 0:
dpg.set_axis_limits_constraints(self.x_axis_tag, -10, self._total_segments * 60 + 10)
self._fit_y_axis(*dpg.get_axis_limits(self.x_axis_tag))
for series_path in list(self._series_data.keys()): for series_path in list(self._series_data.keys()):
self.add_series(series_path, update=True) self.add_series(series_path, update=True)
while self.results_deque: # handle downsampled results in main thread current_limits = dpg.get_axis_limits(self.x_axis_tag)
results = self.results_deque.popleft() # downsample if plot zoom changed significantly
plot_duration = current_limits[1] - current_limits[0]
if plot_duration > self._last_plot_duration * 2 or plot_duration < self._last_plot_duration * 0.5:
self._downsample_all_series(plot_duration)
# sync x-axis if changed by user
if self._last_x_limits != current_limits:
self.playback_manager.set_x_axis_bounds(current_limits[0], current_limits[1], source_panel=self)
self._last_x_limits = current_limits
self._fit_y_axis(current_limits[0], current_limits[1])
while self._results_deque: # handle downsampled results in main thread
results = self._results_deque.popleft()
for series_path, downsampled_time, downsampled_values in results: for series_path, downsampled_time, downsampled_values in results:
series_tag = f"series_{self.panel_id}_{series_path}" series_tag = f"series_{self.panel_id}_{series_path}"
if dpg.does_item_exist(series_tag): if dpg.does_item_exist(series_tag):
dpg.set_value(series_tag, [downsampled_time, downsampled_values]) dpg.set_value(series_tag, (downsampled_time, downsampled_values.astype(float)))
# update timeline # update timeline
current_time_s = self.playback_manager.current_time_s current_time_s = self.playback_manager.current_time_s
@ -96,10 +155,45 @@ class TimeSeriesPanel(ViewPanel):
if dpg.does_item_exist(series_tag): if dpg.does_item_exist(series_tag):
dpg.configure_item(series_tag, label=f"{series_path}: {formatted_value}") dpg.configure_item(series_tag, label=f"{series_path}: {formatted_value}")
# downsample if plot zoom changed significantly def _on_x_axis_sync(self, min_time: float, max_time: float, source_panel):
plot_duration = dpg.get_axis_limits(self.x_axis_tag)[1] - dpg.get_axis_limits(self.x_axis_tag)[0] with self._update_lock:
if plot_duration > self._last_plot_duration * 2 or plot_duration < self._last_plot_duration * 0.5: if source_panel != self:
self._downsample_all_series(plot_duration) self._queued_x_sync = (min_time, max_time)
def _fit_y_axis(self, x_min: float, x_max: float):
if not self._series_data:
dpg.set_axis_limits(self.y_axis_tag, -1, 1)
return
global_min = float('inf')
global_max = float('-inf')
found_data = False
for time_array, value_array in self._series_data.values():
if len(time_array) == 0:
continue
start_idx, end_idx = np.searchsorted(time_array, [x_min, x_max])
end_idx = min(end_idx, len(time_array) - 1)
if start_idx <= end_idx:
y_slice = value_array[start_idx:end_idx + 1]
series_min, series_max = np.min(y_slice), np.max(y_slice)
global_min = min(global_min, series_min)
global_max = max(global_max, series_max)
found_data = True
if not found_data:
dpg.set_axis_limits(self.y_axis_tag, -1, 1)
return
if global_min == global_max:
padding = max(abs(global_min) * 0.1, 1.0)
y_min, y_max = global_min - padding, global_max + padding
else:
range_size = global_max - global_min
padding = range_size * 0.1
y_min, y_max = global_min - padding, global_max + padding
dpg.set_axis_limits(self.y_axis_tag, y_min, y_max)
def _downsample_all_series(self, plot_duration): def _downsample_all_series(self, plot_duration):
plot_width = dpg.get_item_rect_size(self.plot_tag)[0] plot_width = dpg.get_item_rect_size(self.plot_tag)[0]
@ -118,11 +212,11 @@ class TimeSeriesPanel(ViewPanel):
target_points = max(int(target_points_per_second * series_duration), plot_width) target_points = max(int(target_points_per_second * series_duration), plot_width)
work_items.append((series_path, time_array, value_array, target_points)) work_items.append((series_path, time_array, value_array, target_points))
elif dpg.does_item_exist(f"series_{self.panel_id}_{series_path}"): elif dpg.does_item_exist(f"series_{self.panel_id}_{series_path}"):
dpg.set_value(f"series_{self.panel_id}_{series_path}", [time_array, value_array]) dpg.set_value(f"series_{self.panel_id}_{series_path}", (time_array, value_array.astype(float)))
if work_items: if work_items:
self.worker_manager.submit_task( self.worker_manager.submit_task(
TimeSeriesPanel._downsample_worker, work_items, callback=lambda results: self.results_deque.append(results), task_id=f"downsample_{self.panel_id}" TimeSeriesPanel._downsample_worker, work_items, callback=lambda results: self._results_deque.append(results), task_id=f"downsample_{self.panel_id}"
) )
def add_series(self, series_path: str, update: bool = False): def add_series(self, series_path: str, update: bool = False):
@ -133,18 +227,18 @@ class TimeSeriesPanel(ViewPanel):
time_array, value_array = self._series_data[series_path] time_array, value_array = self._series_data[series_path]
series_tag = f"series_{self.panel_id}_{series_path}" series_tag = f"series_{self.panel_id}_{series_path}"
if dpg.does_item_exist(series_tag): if dpg.does_item_exist(series_tag):
dpg.set_value(series_tag, [time_array, value_array]) dpg.set_value(series_tag, (time_array, value_array.astype(float)))
else: else:
line_series_tag = dpg.add_line_series(x=time_array, y=value_array, label=series_path, parent=self.y_axis_tag, tag=series_tag) line_series_tag = dpg.add_line_series(x=time_array, y=value_array.astype(float), label=series_path, parent=self.y_axis_tag, tag=series_tag)
dpg.bind_item_theme(line_series_tag, "global_line_theme") dpg.bind_item_theme(line_series_tag, "line_theme")
dpg.fit_axis_data(self.x_axis_tag) self._fit_y_axis(*dpg.get_axis_limits(self.x_axis_tag))
dpg.fit_axis_data(self.y_axis_tag)
plot_duration = dpg.get_axis_limits(self.x_axis_tag)[1] - dpg.get_axis_limits(self.x_axis_tag)[0] plot_duration = dpg.get_axis_limits(self.x_axis_tag)[1] - dpg.get_axis_limits(self.x_axis_tag)[0]
self._downsample_all_series(plot_duration) self._downsample_all_series(plot_duration)
def destroy_ui(self): def destroy_ui(self):
with self._update_lock: with self._update_lock:
self.data_manager.remove_observer(self.on_data_loaded) self.data_manager.remove_observer(self.on_data_loaded)
self.playback_manager.remove_x_axis_observer(self._on_x_axis_sync)
if dpg.does_item_exist(self.plot_tag): if dpg.does_item_exist(self.plot_tag):
dpg.delete_item(self.plot_tag) dpg.delete_item(self.plot_tag)
self._ui_created = False self._ui_created = False
@ -165,7 +259,12 @@ class TimeSeriesPanel(ViewPanel):
del self._series_data[series_path] del self._series_data[series_path]
def on_data_loaded(self, data: dict): def on_data_loaded(self, data: dict):
with self._update_lock:
self._new_data = True self._new_data = True
if data.get('metadata_loaded'):
self._total_segments = data.get('total_segments', 0)
limits = (-10, self._total_segments * 60 + 10)
self._queued_x_sync = limits
def _on_series_drop(self, sender, app_data, user_data): def _on_series_drop(self, sender, app_data, user_data):
self.add_series(app_data) self.add_series(app_data)

@ -34,13 +34,11 @@ fi
brew bundle --file=- <<-EOS brew bundle --file=- <<-EOS
brew "git-lfs" brew "git-lfs"
brew "zlib"
brew "capnp" brew "capnp"
brew "coreutils" brew "coreutils"
brew "eigen" brew "eigen"
brew "ffmpeg" brew "ffmpeg"
brew "glfw" brew "glfw"
brew "libarchive"
brew "libusb" brew "libusb"
brew "libtool" brew "libtool"
brew "llvm" brew "llvm"
@ -50,7 +48,6 @@ brew "zeromq"
cask "gcc-arm-embedded" cask "gcc-arm-embedded"
brew "portaudio" brew "portaudio"
brew "gcc@13" brew "gcc@13"
cask "font-noto-color-emoji"
EOS EOS
echo "[ ] finished brew install t=$SECONDS" echo "[ ] finished brew install t=$SECONDS"

@ -366,9 +366,11 @@ function op_switch() {
BRANCH="$1" BRANCH="$1"
git config --replace-all remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*" git config --replace-all remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"
git submodule deinit --all --force
git fetch "$REMOTE" "$BRANCH" git fetch "$REMOTE" "$BRANCH"
git checkout -f FETCH_HEAD git checkout -f FETCH_HEAD
git checkout -B "$BRANCH" --track "$REMOTE"/"$BRANCH" git checkout -B "$BRANCH" --track "$REMOTE"/"$BRANCH"
git submodule deinit --all --force
git reset --hard "${REMOTE}/${BRANCH}" git reset --hard "${REMOTE}/${BRANCH}"
git clean -df git clean -df
git submodule update --init --recursive git submodule update --init --recursive

@ -1,193 +1,180 @@
<?xml version='1.0' encoding='UTF-8'?> <?xml version='1.0' encoding='UTF-8'?>
<root> <root>
<tabbed_widget parent="main_window" name="Main Window"> <tabbed_widget name="Main Window" parent="main_window">
<Tab containers="1" tab_name="Lateral Plan Conformance"> <Tab containers="1" tab_name="Lateral Plan Conformance">
<Container> <Container>
<DockSplitter count="4" sizes="0.25;0.25;0.25;0.25" orientation="-"> <DockSplitter orientation="-" count="4" sizes="0.250949;0.249051;0.250949;0.249051">
<DockArea name="desired vs actual lateral acceleration (closer means better conformance to plan)"> <DockArea name="desired vs actual lateral acceleration (closer means better conformance to plan)">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="3.586831" right="269.643117" left="0.000140" bottom="-2.354077"/> <range top="1.858161" bottom="-1.823407" right="1138.891674" left="0.000194"/>
<limitY/> <limitY/>
<curve color="#1f77b4" name="/controlsState/lateralControlState/torqueState/actualLateralAccel"/> <curve name="/controlsState/lateralControlState/torqueState/actualLateralAccel" color="#1f77b4"/>
<curve color="#d62728" name="/controlsState/lateralControlState/torqueState/desiredLateralAccel"/> <curve name="/controlsState/lateralControlState/torqueState/desiredLateralAccel" color="#d62728"/>
</plot> </plot>
</DockArea> </DockArea>
<DockArea name="desired vs actual lateral acceleration, road-roll factored out (closer means better conformance to plan)"> <DockArea name="desired vs actual lateral acceleration, road-roll factored out (closer means better conformance to plan)">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="3.445087" right="269.643117" left="0.000140" bottom="-2.654874"/> <range top="2.749816" bottom="-3.723091" right="1138.891674" left="0.000194"/>
<limitY/> <limitY/>
<curve color="#1ac938" name="Actual lateral accel (roll compensated)"/> <curve name="Actual lateral accel (roll compensated)" color="#1ac938"/>
<curve color="#ff7f0e" name="Desired lateral accel (roll compensated)"/> <curve name="Desired lateral accel (roll compensated)" color="#ff7f0e"/>
</plot> </plot>
</DockArea> </DockArea>
<DockArea name="controller feed-forward vs actuator output (closer means controller prediction is more accurate)"> <DockArea name="controller feed-forward vs actuator output (closer means controller prediction is more accurate)">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="1.082031" right="269.643117" left="0.000140" bottom="-1.050781"/> <range top="1.978032" bottom="-1.570956" right="1138.891674" left="0.000194"/>
<limitY/> <limitY/>
<curve color="#9467bd" name="/carOutput/actuatorsOutput/torque"> <curve name="/carOutput/actuatorsOutput/torque" color="#9467bd">
<transform alias="/carOutput/actuatorsOutput/torque[Scale/Offset]" name="Scale/Offset"> <transform alias="/carOutput/actuatorsOutput/torque[Scale/Offset]" name="Scale/Offset">
<options value_scale="-1" time_offset="0" value_offset="0"/> <options value_offset="0" value_scale="-1" time_offset="0"/>
</transform> </transform>
</curve> </curve>
<curve color="#1f77b4" name="/controlsState/lateralControlState/torqueState/f"/> <curve name="/controlsState/lateralControlState/torqueState/f" color="#1f77b4"/>
<curve color="#ff000f" name="/carState/steeringPressed"/> <curve name="/carState/steeringPressed" color="#ff000f"/>
</plot> </plot>
</DockArea> </DockArea>
<DockArea name="vehicle speed"> <DockArea name="vehicle speed">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="101.506277" right="269.643117" left="0.000140" bottom="-2.475763"/> <range top="105.981304" bottom="-2.709314" right="1138.891674" left="0.000194"/>
<limitY/> <limitY/>
<curve color="#d62728" name="carState.vEgo mph"/> <curve name="carState.vEgo mph" color="#d62728"/>
<curve color="#1ac938" name="carState.vEgo kmh"/> <curve name="carState.vEgo kmh" color="#1ac938"/>
<curve color="#ff7f0e" name="/carState/vEgo"/> <curve name="/carState/vEgo" color="#ff7f0e"/>
</plot> </plot>
</DockArea> </DockArea>
</DockSplitter> </DockSplitter>
</Container> </Container>
</Tab> </Tab>
<Tab containers="1" tab_name="Actuator Performance"> <Tab containers="1" tab_name="Vehicle Dynamics">
<Container> <Container>
<DockSplitter count="3" sizes="0.33361;0.33278;0.33361" orientation="-"> <DockSplitter orientation="-" count="3" sizes="0.334282;0.331437;0.334282">
<DockArea name="offline-calculated vs online-learned lateral accel scaling factor, accel obtained from 100% actuator output"> <DockArea name="configured-initial vs online-learned steerRatio, set configured value to match learned">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="4.186453" right="269.490213" left="0.000000" bottom="3.175940"/> <range top="19.665784" bottom="19.359553" right="1138.816328" left="0.000000"/>
<limitY/> <limitY/>
<curve color="#1f77b4" name="/liveTorqueParameters/latAccelFactorFiltered"/> <curve name="/carParams/steerRatio" color="#1f77b4"/>
<curve color="#d62728" name="/liveTorqueParameters/latAccelFactorRaw"/> <curve name="/liveParameters/steerRatio" color="#1ac938"/>
<curve color="#1c9222" name="/carParams/lateralTuning/torque/latAccelFactor"/>
</plot> </plot>
</DockArea> </DockArea>
<DockArea name="learned lateral accel offset, vehicle-specific compensation to obtain true zero lateral accel"> <DockArea name="configured-initial vs online-learned tireStiffnessRatio, set configured value to match learned">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="0.003035" right="269.490213" left="0.000000" bottom="-0.124417"/> <range top="1.112210" bottom="0.995631" right="1138.816328" left="0.000000"/>
<limitY/> <limitY/>
<curve color="#1ac938" name="/liveTorqueParameters/latAccelOffsetFiltered"/> <curve name="/carParams/tireStiffnessFactor" color="#d62728"/>
<curve color="#ff7f0e" name="/liveTorqueParameters/latAccelOffsetRaw"/> <curve name="/liveParameters/stiffnessFactor" color="#ff7f0e"/>
</plot> </plot>
</DockArea> </DockArea>
<DockArea name="offline-calculated vs online-learned EPS friction factor, necessary to start moving the steering wheel"> <DockArea name="live steering angle offsets for straight-ahead driving, large values here may indicate alignment problems">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="0.121143" right="269.490213" left="0.000000" bottom="-0.002955"/> <range top="-1.081041" bottom="-4.494133" right="1138.816328" left="0.000000"/>
<limitY/> <limitY/>
<curve color="#f14cc1" name="/liveTorqueParameters/frictionCoefficientFiltered"/> <curve name="/liveParameters/angleOffsetAverageDeg" color="#f14cc1"/>
<curve color="#9467bd" name="/liveTorqueParameters/frictionCoefficientRaw"/> <curve name="/liveParameters/angleOffsetDeg" color="#9467bd"/>
<curve color="#1c9222" name="/carParams/lateralTuning/torque/friction"/>
</plot> </plot>
</DockArea> </DockArea>
</DockSplitter> </DockSplitter>
</Container> </Container>
</Tab> </Tab>
<Tab containers="1" tab_name="Vehicle Dynamics"> <Tab containers="1" tab_name="Actuator Performance">
<Container> <Container>
<DockSplitter count="3" sizes="0.33361;0.33278;0.33361" orientation="-"> <DockSplitter orientation="-" count="3" sizes="0.333333;0.333333;0.333333">
<DockArea name="configured-initial vs online-learned steerRatio, set configured value to match learned"> <DockArea name="offline-calculated vs online-learned lateral accel scaling factor, accel obtained from 100% actuator output">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="12.903705" right="269.638801" left="0.000000" bottom="12.748092"/> <range top="1.216110" bottom="0.539474" right="1138.920072" left="0.000000"/>
<limitY/> <limitY/>
<curve color="#1f77b4" name="/carParams/steerRatio"/> <curve name="/liveTorqueParameters/latAccelFactorFiltered" color="#1f77b4"/>
<curve color="#1ac938" name="/liveParameters/steerRatio"/> <curve name="/liveTorqueParameters/latAccelFactorRaw" color="#d62728"/>
<curve name="/carParams/lateralTuning/torque/latAccelFactor" color="#1c9222"/>
</plot> </plot>
</DockArea> </DockArea>
<DockArea name="configured-initial vs online-learned tireStiffnessRatio, set configured value to match learned"> <DockArea name="learned lateral accel offset, vehicle-specific compensation to obtain true zero lateral accel">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="1.000520" right="269.638801" left="0.000000" bottom="0.999718"/> <range top="-0.304367" bottom="-0.418688" right="1138.920072" left="0.000000"/>
<limitY/> <limitY/>
<curve color="#d62728" name="/carParams/tireStiffnessFactor"/> <curve name="/liveTorqueParameters/latAccelOffsetFiltered" color="#1ac938"/>
<curve color="#ff7f0e" name="/liveParameters/stiffnessFactor"/> <curve name="/liveTorqueParameters/latAccelOffsetRaw" color="#ff7f0e"/>
</plot> </plot>
</DockArea> </DockArea>
<DockArea name="live steering angle offsets for straight-ahead driving, large values here may indicate alignment problems"> <DockArea name="offline-calculated vs online-learned EPS friction factor, necessary to start moving the steering wheel">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="-0.332067" right="269.638801" left="0.000000" bottom="-3.149970"/> <range top="0.226389" bottom="0.158050" right="1138.920072" left="0.000000"/>
<limitY/> <limitY/>
<curve color="#f14cc1" name="/liveParameters/angleOffsetAverageDeg"/> <curve name="/liveTorqueParameters/frictionCoefficientFiltered" color="#f14cc1"/>
<curve color="#9467bd" name="/liveParameters/angleOffsetDeg"/> <curve name="/liveTorqueParameters/frictionCoefficientRaw" color="#9467bd"/>
<curve name="/carParams/lateralTuning/torque/friction" color="#1c9222"/>
</plot> </plot>
</DockArea> </DockArea>
</DockSplitter> </DockSplitter>
</Container> </Container>
</Tab> </Tab>
<Tab containers="1" tab_name="Controller PIF Terms"> <Tab containers="1" tab_name="Actuator Delay">
<Container> <Container>
<DockSplitter count="3" sizes="0.33361;0.33278;0.33361" orientation="-"> <DockSplitter orientation="-" count="3" sizes="0.30441;0.358464;0.337127">
<DockArea name="controller feed-forward vs actuator output (closer means controller prediction is more accurate)"> <DockArea name="actuator lag learning state, 0 = learning, 1 = learned/applying, 2 = invalid">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="1.082031" right="269.643117" left="0.000140" bottom="-1.050781"/> <range top="1.025000" bottom="-0.025000" right="1138.749979" left="0.000000"/>
<limitY/> <limitY/>
<curve color="#9467bd" name="/carOutput/actuatorsOutput/torque"> <curve name="/liveDelay/status" color="#ff7f0e"/>
<transform alias="/carOutput/actuatorsOutput/torque[Scale/Offset]" name="Scale/Offset">
<options value_scale="-1.0" time_offset="0" value_offset="0"/>
</transform>
</curve>
<curve color="#1f77b4" name="/controlsState/lateralControlState/torqueState/f"/>
<curve color="#ff000f" name="/carState/steeringPressed"/>
</plot> </plot>
</DockArea> </DockArea>
<DockArea name="proportional, integral, and feed-forward terms (actuator output = sum of PIF terms)"> <DockArea name="offline default vs online estimated steering actuator lag">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="1.572946" right="269.643117" left="0.000140" bottom="-3.822608"/> <range top="0.419648" bottom="0.318362" right="1138.749979" left="0.000000"/>
<limitY/> <limitY/>
<curve color="#0ab027" name="/controlsState/lateralControlState/torqueState/f"/> <curve name="/liveDelay/lateralDelay" color="#1f77b4"/>
<curve color="#d62728" name="/controlsState/lateralControlState/torqueState/p"/> <curve name="/liveDelay/lateralDelayEstimate" color="#d62728"/>
<curve color="#ffaf00" name="/controlsState/lateralControlState/torqueState/i"/> <curve name="opendbc default steering lag" color="#1ac938"/>
<curve color="#756a6a" name="Zero"/>
</plot> </plot>
</DockArea> </DockArea>
<DockArea name="road roll angle, from openpilot localizer"> <DockArea name="online estimated steering actuator lag, standard deviation">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="0.059127" right="269.643117" left="0.000140" bottom="-0.031841"/> <range top="0.067320" bottom="-0.001642" right="1138.749979" left="0.000000"/>
<limitY/> <limitY/>
<curve color="#f14cc1" name="/liveParameters/roll"/> <curve name="/liveDelay/lateralDelayEstimateStd" color="#f14cc1"/>
</plot> </plot>
</DockArea> </DockArea>
</DockSplitter> </DockSplitter>
</Container> </Container>
</Tab> </Tab>
<Tab containers="1" tab_name="Actuator Delay Estimation"> <Tab containers="1" tab_name="Controls Performance">
<Container> <Container>
<DockSplitter count="4" sizes="0.25;0.25;0.25;0.25" orientation="-"> <DockSplitter orientation="-" count="4" sizes="0.265655;0.251898;0.245731;0.236717">
<DockArea name="desired vs actual lateral acceleration (baseline)"> <DockArea name="rate-of-change limits on steering actuator (blue = original, green = rate-limited before CAN output)">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="3.586831" right="269.943117" left="0.134774" bottom="-2.354077"/> <range top="1.050000" bottom="-1.050000" right="1138.891921" left="0.000194"/>
<limitY/> <limitY/>
<curve color="#ff7f0e" name="/controlsState/lateralControlState/torqueState/desiredLateralAccel"/> <curve name="/carControl/actuators/torque" color="#0c00f2"/>
<curve color="#1ac938" name="/controlsState/lateralControlState/torqueState/actualLateralAccel"/> <curve name="/carOutput/actuatorsOutput/torque" color="#2cd63a"/>
</plot> </plot>
</DockArea> </DockArea>
<DockArea name="desired vs actual lateral acceleration (desired shifted by +0.1s)"> <DockArea name="controller feed-forward vs actuator output (closer means controller prediction is more accurate)">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="3.586831" right="269.943117" left="0.134774" bottom="-2.354077"/> <range top="1.978032" bottom="-1.570956" right="1138.891921" left="0.000194"/>
<limitY/> <limitY/>
<curve color="#ff7f0e" name="/controlsState/lateralControlState/torqueState/desiredLateralAccel"> <curve name="/carOutput/actuatorsOutput/torque" color="#9467bd">
<transform alias="/controlsState/lateralControlState/torqueState/desiredLateralAccel[Scale/Offset]" name="Scale/Offset"> <transform alias="/carOutput/actuatorsOutput/torque[Scale/Offset]" name="Scale/Offset">
<options value_scale="1.0" time_offset="0.1" value_offset="0"/> <options value_offset="0" value_scale="-1.0" time_offset="0"/>
</transform> </transform>
</curve> </curve>
<curve color="#1ac938" name="/controlsState/lateralControlState/torqueState/actualLateralAccel"/> <curve name="/controlsState/lateralControlState/torqueState/f" color="#1f77b4"/>
<curve name="/carState/steeringPressed" color="#ff000f"/>
</plot> </plot>
</DockArea> </DockArea>
<DockArea name="desired vs actual lateral acceleration (desired shifted by +0.2s)"> <DockArea name="proportional, integral, and feed-forward terms (actuator output = sum of PIF terms)">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="3.586831" right="269.943117" left="0.134774" bottom="-2.354077"/> <range top="2.099784" bottom="-4.027542" right="1138.891921" left="0.000194"/>
<limitY/> <limitY/>
<curve color="#ff7f0e" name="/controlsState/lateralControlState/torqueState/desiredLateralAccel"> <curve name="/controlsState/lateralControlState/torqueState/f" color="#0ab027"/>
<transform alias="/controlsState/lateralControlState/torqueState/desiredLateralAccel[Scale/Offset]" name="Scale/Offset"> <curve name="/controlsState/lateralControlState/torqueState/p" color="#d62728"/>
<options value_scale="1.0" time_offset="0.2" value_offset="0"/> <curve name="/controlsState/lateralControlState/torqueState/i" color="#ffaf00"/>
</transform> <curve name="Zero" color="#756a6a"/>
</curve>
<curve color="#1ac938" name="/controlsState/lateralControlState/torqueState/actualLateralAccel"/>
</plot> </plot>
</DockArea> </DockArea>
<DockArea name="desired vs actual lateral acceleration (desired shifted by +0.3s)"> <DockArea name="road roll angle, from openpilot localizer">
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries"> <plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
<range top="3.586831" right="269.943117" left="0.134774" bottom="-2.354077"/> <range top="0.109446" bottom="-0.045525" right="1138.891921" left="0.000194"/>
<limitY/> <limitY/>
<curve color="#ff7f0e" name="/controlsState/lateralControlState/torqueState/desiredLateralAccel"> <curve name="/liveParameters/roll" color="#f14cc1"/>
<transform alias="/controlsState/lateralControlState/torqueState/desiredLateralAccel[Scale/Offset]" name="Scale/Offset">
<options value_scale="1.0" time_offset="0.3" value_offset="0"/>
</transform>
</curve>
<curve color="#1ac938" name="/controlsState/lateralControlState/torqueState/actualLateralAccel"/>
</plot> </plot>
</DockArea> </DockArea>
</DockSplitter> </DockSplitter>
@ -199,44 +186,62 @@
<!-- - - - - - - - - - - - - - - --> <!-- - - - - - - - - - - - - - - -->
<!-- - - - - - - - - - - - - - - --> <!-- - - - - - - - - - - - - - - -->
<Plugins> <Plugins>
<plugin ID="DataLoad CSV">
<default delimiter="0" time_axis=""/>
</plugin>
<plugin ID="DataLoad Rlog"/> <plugin ID="DataLoad Rlog"/>
<plugin ID="DataLoad ULog"/>
<plugin ID="Cereal Subscriber"/> <plugin ID="Cereal Subscriber"/>
<plugin ID="UDP Server"/>
<plugin ID="ZMQ Subscriber"/>
<plugin ID="Fast Fourier Transform"/>
<plugin ID="Quaternion to RPY"/>
<plugin ID="Reactive Script Editor">
<library code="--[[ Helper function to create a series from arrays&#xa;&#xa; new_series: a series previously created with ScatterXY.new(name)&#xa; prefix: prefix of the timeseries, before the index of the array&#xa; suffix_X: suffix to complete the name of the series containing the X value. If [nil], use the index of the array.&#xa; suffix_Y: suffix to complete the name of the series containing the Y value&#xa; timestamp: usually the tracker_time variable&#xa; &#xa; Example:&#xa; &#xa; Assuming we have multiple series in the form:&#xa; &#xa; /trajectory/node.{X}/position/x&#xa; /trajectory/node.{X}/position/y&#xa; &#xa; where {N} is the index of the array (integer). We can create a reactive series from the array with:&#xa; &#xa; new_series = ScatterXY.new(&quot;my_trajectory&quot;) &#xa; CreateSeriesFromArray( new_series, &quot;/trajectory/node&quot;, &quot;position/x&quot;, &quot;position/y&quot;, tracker_time );&#xa;--]]&#xa;&#xa;function CreateSeriesFromArray( new_series, prefix, suffix_X, suffix_Y, timestamp )&#xa; &#xa; --- clear previous values&#xa; new_series:clear()&#xa; &#xa; --- Append points to new_series&#xa; index = 0&#xa; while(true) do&#xa;&#xa; x = index;&#xa; -- if not nil, get the X coordinate from a series&#xa; if suffix_X ~= nil then &#xa; series_x = TimeseriesView.find( string.format( &quot;%s.%d/%s&quot;, prefix, index, suffix_X) )&#xa; if series_x == nil then break end&#xa; x = series_x:atTime(timestamp)&#x9; &#xa; end&#xa; &#xa; series_y = TimeseriesView.find( string.format( &quot;%s.%d/%s&quot;, prefix, index, suffix_Y) )&#xa; if series_y == nil then break end &#xa; y = series_y:atTime(timestamp)&#xa; &#xa; new_series:push_back(x,y)&#xa; index = index+1&#xa; end&#xa;end&#xa;&#xa;--[[ Similar to the built-in function GetSeriesNames(), but select only the names with a give prefix. --]]&#xa;&#xa;function GetSeriesNamesByPrefix(prefix)&#xa; -- GetSeriesNames(9 is a built-in function&#xa; all_names = GetSeriesNames()&#xa; filtered_names = {}&#xa; for i, name in ipairs(all_names) do&#xa; -- check the prefix&#xa; if name:find(prefix, 1, #prefix) then&#xa; table.insert(filtered_names, name);&#xa; end&#xa; end&#xa; return filtered_names&#xa;end&#xa;&#xa;--[[ Modify an existing series, applying offsets to all their X and Y values&#xa;&#xa; series: an existing timeseries, obtained with TimeseriesView.find(name)&#xa; delta_x: offset to apply to each x value&#xa; delta_y: offset to apply to each y value &#xa; &#xa;--]]&#xa;&#xa;function ApplyOffsetInPlace(series, delta_x, delta_y)&#xa; -- use C++ indeces, not Lua indeces&#xa; for index=0, series:size()-1 do&#xa; x,y = series:at(index)&#xa; series:set(index, x + delta_x, y + delta_y)&#xa; end&#xa;end&#xa;"/>
<scripts/>
</plugin>
<plugin ID="CSV Exporter"/>
</Plugins> </Plugins>
<!-- - - - - - - - - - - - - - - --> <!-- - - - - - - - - - - - - - - -->
<!-- - - - - - - - - - - - - - - --> <!-- - - - - - - - - - - - - - - -->
<customMathEquations> <customMathEquations>
<snippet name="Zero"> <snippet name="carState.vEgo kmh">
<global></global> <global></global>
<function>return (0)</function> <function>return value * 3.6</function>
<linked_source>/carState/canValid</linked_source> <linked_source>/carState/vEgo</linked_source>
</snippet> </snippet>
<snippet name="Actual lateral accel (roll compensated)"> <snippet name="carState.vEgo mph">
<global></global>
<function>return value * 2.23694</function>
<linked_source>/carState/vEgo</linked_source>
</snippet>
<snippet name="Desired lateral accel (roll compensated)">
<global></global> <global></global>
<function>return (value * v1 ^ 2) - (v2 * 9.81)</function> <function>return (value * v1 ^ 2) - (v2 * 9.81)</function>
<linked_source>/controlsState/curvature</linked_source> <linked_source>/controlsState/desiredCurvature</linked_source>
<additional_sources> <additional_sources>
<v1>/carState/vEgo</v1> <v1>/carState/vEgo</v1>
<v2>/liveParameters/roll</v2> <v2>/liveParameters/roll</v2>
</additional_sources> </additional_sources>
</snippet> </snippet>
<snippet name="Desired lateral accel (roll compensated)"> <snippet name="Actual lateral accel (roll compensated)">
<global></global> <global></global>
<function>return (value * v1 ^ 2) - (v2 * 9.81)</function> <function>return (value * v1 ^ 2) - (v2 * 9.81)</function>
<linked_source>/controlsState/desiredCurvature</linked_source> <linked_source>/controlsState/curvature</linked_source>
<additional_sources> <additional_sources>
<v1>/carState/vEgo</v1> <v1>/carState/vEgo</v1>
<v2>/liveParameters/roll</v2> <v2>/liveParameters/roll</v2>
</additional_sources> </additional_sources>
</snippet> </snippet>
<snippet name="carState.vEgo mph"> <snippet name="opendbc default steering lag">
<global></global> <global></global>
<function>return value * 2.23694</function> <function>return value + 0.2</function>
<linked_source>/carState/vEgo</linked_source> <linked_source>/carParams/steerActuatorDelay</linked_source>
</snippet> </snippet>
<snippet name="carState.vEgo kmh"> <snippet name="Zero">
<global></global> <global></global>
<function>return value * 3.6</function> <function>return (0)</function>
<linked_source>/carState/vEgo</linked_source> <linked_source>/carState/canValid</linked_source>
</snippet> </snippet>
</customMathEquations> </customMathEquations>
<snippets/> <snippets/>

@ -12,7 +12,9 @@ else:
base_libs.append('OpenCL') base_libs.append('OpenCL')
replay_lib_src = ["replay.cc", "consoleui.cc", "camera.cc", "filereader.cc", "logreader.cc", "framereader.cc", replay_lib_src = ["replay.cc", "consoleui.cc", "camera.cc", "filereader.cc", "logreader.cc", "framereader.cc",
"route.cc", "util.cc", "seg_mgr.cc", "timeline.cc", "api.cc", "qcom_decoder.cc"] "route.cc", "util.cc", "seg_mgr.cc", "timeline.cc", "api.cc"]
if arch != "Darwin":
replay_lib_src.append("qcom_decoder.cc")
replay_lib = replay_env.Library("replay", replay_lib_src, LIBS=base_libs, FRAMEWORKS=base_frameworks) replay_lib = replay_env.Library("replay", replay_lib_src, LIBS=base_libs, FRAMEWORKS=base_frameworks)
Export('replay_lib') Export('replay_lib')
replay_libs = [replay_lib, 'avutil', 'avcodec', 'avformat', 'bz2', 'zstd', 'curl', 'yuv', 'ncurses'] + base_libs replay_libs = [replay_lib, 'avutil', 'avcodec', 'avformat', 'bz2', 'zstd', 'curl', 'yuv', 'ncurses'] + base_libs

@ -39,9 +39,12 @@ struct DecoderManager {
} }
std::unique_ptr<VideoDecoder> decoder; std::unique_ptr<VideoDecoder> decoder;
#ifndef __APPLE__
if (Hardware::TICI() && hw_decoder) { if (Hardware::TICI() && hw_decoder) {
decoder = std::make_unique<QcomVideoDecoder>(); decoder = std::make_unique<QcomVideoDecoder>();
} else { } else
#endif
{
decoder = std::make_unique<FFmpegVideoDecoder>(); decoder = std::make_unique<FFmpegVideoDecoder>();
} }
@ -264,6 +267,7 @@ bool FFmpegVideoDecoder::copyBuffer(AVFrame *f, VisionBuf *buf) {
return true; return true;
} }
#ifndef __APPLE__
bool QcomVideoDecoder::open(AVCodecParameters *codecpar, bool hw_decoder) { bool QcomVideoDecoder::open(AVCodecParameters *codecpar, bool hw_decoder) {
if (codecpar->codec_id != AV_CODEC_ID_HEVC) { if (codecpar->codec_id != AV_CODEC_ID_HEVC) {
rError("Hardware decoder only supports HEVC codec"); rError("Hardware decoder only supports HEVC codec");
@ -305,3 +309,4 @@ bool QcomVideoDecoder::decode(FrameReader *reader, int idx, VisionBuf *buf) {
} }
return result; return result;
} }
#endif

@ -6,7 +6,10 @@
#include "msgq/visionipc/visionbuf.h" #include "msgq/visionipc/visionbuf.h"
#include "tools/replay/filereader.h" #include "tools/replay/filereader.h"
#include "tools/replay/util.h" #include "tools/replay/util.h"
#ifndef __APPLE__
#include "tools/replay/qcom_decoder.h" #include "tools/replay/qcom_decoder.h"
#endif
extern "C" { extern "C" {
#include <libavcodec/avcodec.h> #include <libavcodec/avcodec.h>
@ -65,6 +68,7 @@ private:
AVBufferRef *hw_device_ctx = nullptr; AVBufferRef *hw_device_ctx = nullptr;
}; };
#ifndef __APPLE__
class QcomVideoDecoder : public VideoDecoder { class QcomVideoDecoder : public VideoDecoder {
public: public:
QcomVideoDecoder() {}; QcomVideoDecoder() {};
@ -75,3 +79,4 @@ public:
private: private:
MsmVidc msm_vidc = MsmVidc(); MsmVidc msm_vidc = MsmVidc();
}; };
#endif

@ -11,7 +11,7 @@ from openpilot.tools.sim.lib.common import SimulatorState
class SimulatedCar: class SimulatedCar:
"""Simulates a honda civic 2022 (panda state + can messages) to OpenPilot""" """Simulates a honda civic 2022 (panda state + can messages) to OpenPilot"""
packer = CANPacker("honda_civic_ex_2022_can_generated") packer = CANPacker("honda_bosch_radarless_generated")
def __init__(self): def __init__(self):
self.pm = messaging.PubMaster(['can', 'pandaStates']) self.pm = messaging.PubMaster(['can', 'pandaStates'])
@ -23,7 +23,7 @@ class SimulatedCar:
@staticmethod @staticmethod
def get_car_can_parser(): def get_car_can_parser():
dbc_f = 'honda_civic_ex_2022_can_generated' dbc_f = 'honda_bosch_radarless_generated'
checks = [] checks = []
return CANParser(dbc_f, checks, 0) return CANParser(dbc_f, checks, 0)

Loading…
Cancel
Save