diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml
index 075b617245..38bdb198d6 100644
--- a/.github/workflows/stale.yaml
+++ b/.github/workflows/stale.yaml
@@ -5,8 +5,8 @@ on:
workflow_dispatch:
env:
- DAYS_BEFORE_PR_CLOSE: 2
- DAYS_BEFORE_PR_STALE: 9
+ DAYS_BEFORE_PR_CLOSE: 7
+ DAYS_BEFORE_PR_STALE: 24
DAYS_BEFORE_PR_STALE_DRAFT: 30
jobs:
diff --git a/.github/workflows/sunnypilot-build-prebuilt.yaml b/.github/workflows/sunnypilot-build-prebuilt.yaml
index 12fb01cbd1..d3ad2d2419 100644
--- a/.github/workflows/sunnypilot-build-prebuilt.yaml
+++ b/.github/workflows/sunnypilot-build-prebuilt.yaml
@@ -22,7 +22,7 @@ on:
workflow_dispatch:
inputs:
wait_for_tests:
- description: 'Wait for selfdrive_tests to finish'
+ description: 'Wait for tests to finish'
required: false
type: boolean
default: false
@@ -99,7 +99,7 @@ jobs:
- name: Wait for Tests
uses: ./.github/workflows/wait-for-action # Path to where you place the action
with:
- workflow: selfdrive_tests.yaml # The workflow file to monitor
+ workflow: tests.yaml # The workflow file to monitor
github-token: ${{ secrets.GITHUB_TOKEN }}
should-wait-for-start: ${{ github.event_name == 'push' && 'true' || 'false' }}
diff --git a/.github/workflows/sunnypilot-master-dev-prep.yaml b/.github/workflows/sunnypilot-master-dev-prep.yaml
index e93e778aa6..e7a9663743 100644
--- a/.github/workflows/sunnypilot-master-dev-prep.yaml
+++ b/.github/workflows/sunnypilot-master-dev-prep.yaml
@@ -57,7 +57,7 @@ jobs:
|| (contains(github.event_name, 'pull_request') && ((github.event.action == 'labeled' && (github.event.label.name == vars.PREBUILT_PR_LABEL || github.event.label.name == 'trust-fork-pr') && contains(github.event.pull_request.labels.*.name, vars.PREBUILT_PR_LABEL))))
)
with:
- workflow: selfdrive_tests.yaml # The workflow file to monitor
+ workflow: tests.yaml # The workflow file to monitor
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
@@ -201,13 +201,13 @@ jobs:
if: steps.push-changes.outputs.has_changes == 'true'
run: |
echo "Triggering selfdrive tests..."
- gh workflow run selfdrive_tests.yaml --ref "${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}"
+ gh workflow run tests.yaml --ref "${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}"
echo "Sleeping for 120s to give plenty of time for the action to start and then we wait"
sleep 120
echo "Getting latest run ID..."
- RUN_ID=$(gh run list --workflow=selfdrive_tests.yaml --branch="${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}" --limit=1 --json databaseId --jq '.[0].databaseId')
+ RUN_ID=$(gh run list --workflow=tests.yaml --branch="${{ inputs.target_branch || env.DEFAULT_TARGET_BRANCH }}" --limit=1 --json databaseId --jq '.[0].databaseId')
echo "Watching run ID: $RUN_ID"
gh run watch "$RUN_ID"
diff --git a/.github/workflows/wait-for-action/action.yaml b/.github/workflows/wait-for-action/action.yaml
index 9cde4cf076..01bc614618 100644
--- a/.github/workflows/wait-for-action/action.yaml
+++ b/.github/workflows/wait-for-action/action.yaml
@@ -4,7 +4,7 @@ inputs:
workflow:
description: 'The workflow file name to monitor'
required: true
- default: 'selfdrive_tests.yaml'
+ default: 'tests.yaml'
branch:
description: 'The branch to monitor (defaults to current branch)'
required: false
diff --git a/.gitmodules b/.gitmodules
index b9f0336a0e..cd6cf2168f 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -6,7 +6,7 @@
url = https://github.com/sunnypilot/opendbc.git
[submodule "msgq"]
path = msgq_repo
- url = https://github.com/sunnypilot/msgq.git
+ url = https://github.com/commaai/msgq.git
[submodule "rednose_repo"]
path = rednose_repo
url = https://github.com/commaai/rednose.git
diff --git a/RELEASES.md b/RELEASES.md
index 9ab60e6cb7..58044dc694 100644
--- a/RELEASES.md
+++ b/RELEASES.md
@@ -1,5 +1,6 @@
-Version 0.10.2 (2025-11-23)
+Version 0.10.2 (2025-11-19)
========================
+* comma four support
Version 0.10.1 (2025-09-08)
========================
diff --git a/cereal/log.capnp b/cereal/log.capnp
index 891af58a21..c5052d6c14 100644
--- a/cereal/log.capnp
+++ b/cereal/log.capnp
@@ -2166,7 +2166,8 @@ struct DriverStateV2 {
leftBlinkProb @7 :Float32;
rightBlinkProb @8 :Float32;
sunglassesProb @9 :Float32;
- notReadyProb @12 :List(Float32);
+ phoneProb @13 :Float32;
+ notReadyProbDEPRECATED @12 :List(Float32);
occludedProbDEPRECATED @10 :Float32;
readyProbDEPRECATED @11 :List(Float32);
}
@@ -2225,6 +2226,8 @@ struct DriverMonitoringState @0xb83cda094a1da284 {
isActiveMode @16 :Bool;
isRHD @4 :Bool;
uncertainCount @19 :UInt32;
+ phoneProbOffset @20 :Float32;
+ phoneProbValidCount @21 :UInt32;
isPreviewDEPRECATED @15 :Bool;
rhdCheckedDEPRECATED @5 :Bool;
diff --git a/common/filter_simple.py b/common/filter_simple.py
index 9ea6fe3070..212e1a8f40 100644
--- a/common/filter_simple.py
+++ b/common/filter_simple.py
@@ -15,3 +15,20 @@ class FirstOrderFilter:
self.initialized = True
self.x = x
return self.x
+
+
+class BounceFilter(FirstOrderFilter):
+ def __init__(self, x0, rc, dt, initialized=True, bounce=2):
+ self.velocity = FirstOrderFilter(0.0, 0.15, dt)
+ self.bounce = bounce
+ super().__init__(x0, rc, dt, initialized)
+
+ def update(self, x):
+ super().update(x)
+ scale = self.dt / (1.0 / 60.0) # tuned at 60 fps
+ self.velocity.x += (x - self.x) * self.bounce * scale * self.dt
+ self.velocity.update(0.0)
+ if abs(self.velocity.x) < 1e-5:
+ self.velocity.x = 0.0
+ self.x += self.velocity.x
+ return self.x
diff --git a/docs/CARS.md b/docs/CARS.md
index 695c2589e3..4c1e90e18d 100644
--- a/docs/CARS.md
+++ b/docs/CARS.md
@@ -4,349 +4,351 @@
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.
-# 339 Supported Cars
+# 341 Supported Cars
|Make|Model|Supported Package|ACC|No ACC accel below|No ALC below|Steering Torque|Resume from stop|
Hardware Needed
|Video|Setup Video|
|---|---|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
-|Acura|ILX 2016-18|Technology Plus Package or AcuraWatch Plus|openpilot|26 mph|25 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Acura|ILX 2019|All|openpilot|26 mph|25 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Acura|MDX 2025|All except Type S|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Acura|RDX 2016-18|AcuraWatch Plus or Advance Package|openpilot|26 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Acura|RDX 2019-21|All|openpilot available[1](#footnotes)|0 mph|3 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Audi|A3 2014-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Audi|A3 Sportback e-tron 2017-18|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Audi|Q2 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Audi|Q3 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Audi|RS3 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Audi|S3 2015-17|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Chevrolet|Bolt EUV 2022-23|Premier or Premier Redline Trim without Super Cruise Package|openpilot available[1](#footnotes)|3 mph|6 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Chevrolet|Bolt EV 2022-23|2LT Trim with Adaptive Cruise Control Package|openpilot available[1](#footnotes)|3 mph|6 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Chevrolet|Bolt EV Non-ACC 2017|Adaptive Cruise Control (ACC)|Stock|24 mph|7 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Chevrolet|Bolt EV Non-ACC 2018-21|Adaptive Cruise Control (ACC)|Stock|24 mph|7 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Chevrolet|Equinox 2019-22|Adaptive Cruise Control (ACC)|openpilot available[1](#footnotes)|3 mph|6 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Chevrolet|Malibu Non-ACC 2016-23|Adaptive Cruise Control (ACC)|Stock|24 mph|7 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Chevrolet|Silverado 1500 2020-21|Safety Package II|openpilot available[1](#footnotes)|0 mph|6 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Chevrolet|Trailblazer 2021-22|Adaptive Cruise Control (ACC)|openpilot available[1](#footnotes)|3 mph|6 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Chrysler|Pacifica 2017-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[](##)|[](##)|Parts
- 1 FCA connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Chrysler|Pacifica 2019-20|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[](##)|[](##)|Parts
- 1 FCA connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Chrysler|Pacifica 2021-23|All|Stock|0 mph|39 mph|[](##)|[](##)|Parts
- 1 FCA connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Chrysler|Pacifica Hybrid 2017-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[](##)|[](##)|Parts
- 1 FCA connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Chrysler|Pacifica Hybrid 2019-25|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[](##)|[](##)|Parts
- 1 FCA connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
+|Acura|ILX 2016-18|Technology Plus Package or AcuraWatch Plus|openpilot|26 mph|25 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Acura|ILX 2019|All|openpilot|26 mph|25 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Acura|MDX 2025|All except Type S|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Acura|RDX 2016-18|AcuraWatch Plus or Advance Package|openpilot|26 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Acura|RDX 2019-21|All|openpilot available[1](#footnotes)|0 mph|3 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Acura|TLX 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Audi|A3 2014-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Audi|A3 Sportback e-tron 2017-18|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Audi|Q2 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Audi|Q3 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Audi|RS3 2018|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Audi|S3 2015-17|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Chevrolet|Bolt EUV 2022-23|Premier or Premier Redline Trim without Super Cruise Package|openpilot available[1](#footnotes)|3 mph|6 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here |
||
+|Chevrolet|Bolt EV 2022-23|2LT Trim with Adaptive Cruise Control Package|openpilot available[1](#footnotes)|3 mph|6 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here |||
+|Chevrolet|Bolt EV Non-ACC 2017|Adaptive Cruise Control (ACC)|Stock|24 mph|7 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here |||
+|Chevrolet|Bolt EV Non-ACC 2018-21|Adaptive Cruise Control (ACC)|Stock|24 mph|7 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here |||
+|Chevrolet|Equinox 2019-22|Adaptive Cruise Control (ACC)|openpilot available[1](#footnotes)|3 mph|6 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here |||
+|Chevrolet|Malibu Non-ACC 2016-23|Adaptive Cruise Control (ACC)|Stock|24 mph|7 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here |||
+|Chevrolet|Silverado 1500 2020-21|Safety Package II|openpilot available[1](#footnotes)|0 mph|6 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here |||
+|Chevrolet|Trailblazer 2021-22|Adaptive Cruise Control (ACC)|openpilot available[1](#footnotes)|3 mph|6 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here |||
+|Chrysler|Pacifica 2017-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[](##)|[](##)|Parts
- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Chrysler|Pacifica 2019-20|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[](##)|[](##)|Parts
- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Chrysler|Pacifica 2021-23|All|Stock|0 mph|39 mph|[](##)|[](##)|Parts
- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Chrysler|Pacifica Hybrid 2017-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[](##)|[](##)|Parts
- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Chrysler|Pacifica Hybrid 2019-25|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[](##)|[](##)|Parts
- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
|comma|body|All|openpilot|0 mph|0 mph|[](##)|[](##)|None|
||
-|CUPRA|Ateca 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Dodge|Durango 2020-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[](##)|[](##)|Parts
- 1 FCA connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ford|Bronco Sport 2021-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ford|Escape 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ford|Escape 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here ||
|
-|Ford|Escape Hybrid 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ford|Escape Hybrid 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here ||
|
-|Ford|Escape Plug-in Hybrid 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ford|Escape Plug-in Hybrid 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here ||
|
-|Ford|Expedition 2022-24|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here ||
|
-|Ford|Explorer 2020-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ford|Explorer Hybrid 2020-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ford|F-150 2021-23|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here ||
|
-|Ford|F-150 Hybrid 2021-23|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here ||
|
-|Ford|Focus 2018[3](#footnotes)|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ford|Focus Hybrid 2018[3](#footnotes)|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ford|Kuga 2020-23|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ford|Kuga Hybrid 2020-23|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ford|Kuga Hybrid 2024|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here ||
|
-|Ford|Kuga Plug-in Hybrid 2020-23|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ford|Kuga Plug-in Hybrid 2024|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here ||
|
-|Ford|Maverick 2022|LARIAT Luxury|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ford|Maverick 2023-24|Co-Pilot360 Assist|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ford|Maverick Hybrid 2022|LARIAT Luxury|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ford|Maverick Hybrid 2023-24|Co-Pilot360 Assist|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ford|Mustang Mach-E 2021-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here ||
|
-|Ford|Ranger 2024|Adaptive Cruise Control with Lane Centering|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here ||
|
-|Genesis|G70 2018|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai F connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Genesis|G70 2019-21|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai F connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Genesis|G70 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai L connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Genesis|G80 2017|All|Stock|19 mph|37 mph|[](##)|[](##)|Parts
- 1 Hyundai J connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Genesis|G80 2018-19|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Genesis|G80 (2.5T Advanced Trim, with HDA II) 2024[6](#footnotes)|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai P connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Genesis|G90 2017-20|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Genesis|GV60 (Advanced Trim) 2023[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Genesis|GV60 (Performance Trim) 2022-23[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Genesis|GV70 (2.5T Trim, without HDA II) 2022-24[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai L connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Genesis|GV70 (3.5T Trim, without HDA II) 2022-23[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai M connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Genesis|GV70 Electrified (Australia Only) 2022[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai Q connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Genesis|GV70 Electrified (with HDA II) 2023-24[6](#footnotes)|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai Q connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Genesis|GV80 2023[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai M connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|GMC|Sierra 1500 2020-21|Driver Alert Package II|openpilot available[1](#footnotes)|0 mph|6 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 comma 3X
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Honda|Accord 2018-22|All|openpilot available[1](#footnotes)|0 mph|3 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Honda|Accord 2023-25|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Accord Hybrid 2018-22|All|openpilot available[1](#footnotes)|0 mph|3 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Accord Hybrid 2023-25|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|City (Brazil only) 2023|All|openpilot available[1](#footnotes)|0 mph|14 mph|[](##)|[](##)|Parts
- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Civic 2016-18|Honda Sensing|openpilot|0 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Honda|Civic 2019-21|All|openpilot available[1](#footnotes)|0 mph|2 mph[5](#footnotes)|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Honda|Civic 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Honda|Civic Hatchback 2017-18|Honda Sensing|openpilot available[1](#footnotes)|0 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Civic Hatchback 2019-21|All|openpilot available[1](#footnotes)|0 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Civic Hatchback 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Honda|Civic Hatchback Hybrid 2025-26|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Civic Hatchback Hybrid (Europe only) 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Civic Hybrid 2025|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Clarity 2018-21|Honda Sensing|openpilot|0 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector + Honda Clarity Proxy Board
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|CR-V 2015-16|Touring Trim|openpilot|26 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|CR-V 2017-22|Honda Sensing|openpilot available[1](#footnotes)|0 mph|15 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|CR-V 2023-25|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|CR-V Hybrid 2017-22|Honda Sensing|openpilot available[1](#footnotes)|0 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|CR-V Hybrid 2023-25|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|e 2020|All|openpilot available[1](#footnotes)|0 mph|3 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Fit 2018-20|Honda Sensing|openpilot|26 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Freed 2020|Honda Sensing|openpilot|26 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|HR-V 2019-22|Honda Sensing|openpilot|26 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|HR-V 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Insight 2019-22|All|openpilot available[1](#footnotes)|0 mph|3 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Inspire 2018|All|openpilot available[1](#footnotes)|0 mph|3 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|N-Box 2018|All|openpilot available[1](#footnotes)|0 mph|11 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Odyssey 2018-20|Honda Sensing|openpilot|26 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Odyssey 2021-25|All|openpilot available[1](#footnotes)|0 mph|43 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Passport 2019-25|All|openpilot|26 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Pilot 2016-22|Honda Sensing|openpilot|26 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Pilot 2023-25|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch C connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Honda|Ridgeline 2017-25|Honda Sensing|openpilot|26 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Azera 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Azera Hybrid 2019|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Azera Hybrid 2020|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Custin 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Elantra 2017-18|Smart Cruise Control (SCC)|Stock|19 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Elantra 2019|Smart Cruise Control (SCC)|Stock|19 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai G connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Elantra 2021-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Hyundai|Elantra GT 2017-20|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai E connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Elantra Hybrid 2021-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Hyundai|Elantra Non-SCC 2022|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Genesis 2015-16|Smart Cruise Control (SCC)|Stock|19 mph|37 mph|[](##)|[](##)|Parts
- 1 Hyundai J connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|i30 2017-19|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai E connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Ioniq 5 (Southeast Asia and Europe only) 2022-24[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai Q connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Ioniq 5 (with HDA II) 2022-24[6](#footnotes)|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai Q connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Ioniq 5 (without HDA II) 2022-24[6](#footnotes)|Highway Driving Assist|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Ioniq 6 (with HDA II) 2023-24[6](#footnotes)|Highway Driving Assist II|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai P connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Ioniq Electric 2019|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Ioniq Electric 2020|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Ioniq Hybrid 2017-19|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Ioniq Hybrid 2020-22|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Ioniq Plug-in Hybrid 2019|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Ioniq Plug-in Hybrid 2020-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Kona 2020|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|6 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Kona 2022-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai O connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Kona Electric 2018-21|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai G connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Kona Electric 2022-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai O connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Kona Electric (with HDA II, Korea only) 2023[6](#footnotes)|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai R connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Hyundai|Kona Electric Non-SCC 2019|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai G connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Kona Hybrid 2020|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai I connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Kona Non-SCC 2019|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Nexo 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Palisade 2020-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Hyundai|Santa Cruz 2022-24[6](#footnotes)|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai N connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Santa Fe 2019-20|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai D connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Hyundai|Santa Fe 2021-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai L connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Hyundai|Santa Fe Hybrid 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai L connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Santa Fe Plug-in Hybrid 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai L connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Sonata 2018-19|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai E connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Sonata 2020-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Hyundai|Sonata Hybrid 2020-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Staria 2023[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Tucson 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai L connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Tucson 2022[6](#footnotes)|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai N connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Tucson 2023-24[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai N connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Tucson Diesel 2019|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai L connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Tucson Hybrid 2022-24[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai N connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Tucson Plug-in Hybrid 2024[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai N connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Hyundai|Veloster 2019-20|Smart Cruise Control (SCC)|Stock|5 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai E connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Jeep|Grand Cherokee 2016-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[](##)|[](##)|Parts
- 1 FCA connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Jeep|Grand Cherokee 2019-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[](##)|[](##)|Parts
- 1 FCA connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Kia|Carnival 2022-24[6](#footnotes)|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Carnival (China only) 2023[6](#footnotes)|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Ceed 2019-21|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai E connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Ceed Plug-in Hybrid Non-SCC 2022|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai I connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|EV6 (Southeast Asia only) 2022-24[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai P connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|EV6 (with HDA II) 2022-24[6](#footnotes)|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai P connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|EV6 (without HDA II) 2022-24[6](#footnotes)|Highway Driving Assist|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai L connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Forte 2019-21|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|6 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai G connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Forte 2022-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai E connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Forte Non-SCC 2019|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai G connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|K5 2021-24|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|K5 Hybrid 2020-22|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|K8 Hybrid (with HDA II) 2023[6](#footnotes)|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai Q connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Niro EV 2019|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Kia|Niro EV 2020|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai F connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Kia|Niro EV 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Kia|Niro EV 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Kia|Niro EV (with HDA II) 2025[6](#footnotes)|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai R connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Niro EV (without HDA II) 2023-25[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Niro Hybrid 2018|Smart Cruise Control (SCC)|Stock|10 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Niro Hybrid 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai D connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Niro Hybrid 2022|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai F connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Niro Hybrid 2023[6](#footnotes)|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Niro Plug-in Hybrid 2018-19|All|Stock|10 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Niro Plug-in Hybrid 2020|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai D connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Niro Plug-in Hybrid 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai D connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Niro Plug-in Hybrid 2022|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai F connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Optima 2017|Advanced Smart Cruise Control|Stock|0 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Optima 2019-20|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai G connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Optima Hybrid 2019|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Seltos 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Sorento 2018|Advanced Smart Cruise Control & LKAS|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai E connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Kia|Sorento 2019|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai E connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Kia|Sorento 2021-23[6](#footnotes)|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Sorento Hybrid 2021-23[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Sorento Plug-in Hybrid 2022-23[6](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Sportage 2023-24[6](#footnotes)|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai N connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Sportage Hybrid 2023[6](#footnotes)|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai N connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Stinger 2018-20|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Kia|Stinger 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Kia|Telluride 2020-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|CT Hybrid 2017-18|Lexus Safety System+|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|ES 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|ES 2019-25|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|ES Hybrid 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|ES Hybrid 2019-25|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Lexus|GS F 2016|All|Stock|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|IS 2017-19|All|Stock|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|IS 2022-24|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|LC 2024-25|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|NX 2018-19|All|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|NX 2020-21|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|NX Hybrid 2018-19|All|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|NX Hybrid 2020-21|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|RC 2018-20|All|Stock|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|RC 2023|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|RX 2016|Lexus Safety System+|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|RX 2017-19|All|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|RX 2020-22|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|RX Hybrid 2016|Lexus Safety System+|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|RX Hybrid 2017-19|All|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|RX Hybrid 2020-22|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lexus|UX Hybrid 2019-24|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lincoln|Aviator 2020-24|Co-Pilot360 Plus|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Lincoln|Aviator Plug-in Hybrid 2020-24|Co-Pilot360 Plus|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|MAN|eTGE 2020-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|MAN|TGE 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Mazda|CX-5 2022-25|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Mazda connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Mazda|CX-9 2021-23|All|Stock|0 mph|28 mph|[](##)|[](##)|Parts
- 1 Mazda connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Nissan[7](#footnotes)|Altima 2019-20|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Nissan B connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Nissan[7](#footnotes)|Leaf 2018-23|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Nissan A connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Nissan[7](#footnotes)|Rogue 2018-20|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Nissan A connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Nissan[7](#footnotes)|X-Trail 2017|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Nissan A connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ram|1500 2019-24|Adaptive Cruise Control (ACC)|Stock|32 mph|1 mph|[](##)|[](##)|Parts
- 1 Ram connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ram|2500 2020-24|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[](##)|[](##)|Parts
- 1 Ram connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Ram|3500 2019-22|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[](##)|[](##)|Parts
- 1 Ram connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Rivian|R1S 2022-24|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Rivian A connector
- 1 USB-C coupler
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here ||
|
-|Rivian|R1T 2022-24|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Rivian A connector
- 1 USB-C coupler
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here ||
|
-|SEAT|Ateca 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|SEAT|Leon 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Subaru|Ascent 2019-21|All[8](#footnotes)|openpilot available[1,9](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
-|Subaru|Crosstrek 2018-19|EyeSight Driver Assistance[8](#footnotes)|openpilot available[1,9](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |
||
-|Subaru|Crosstrek 2020-23|EyeSight Driver Assistance[8](#footnotes)|openpilot available[1,9](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
-|Subaru|Forester 2017-18|EyeSight Driver Assistance[8](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
-|Subaru|Forester 2019-21|All[8](#footnotes)|openpilot available[1,9](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
-|Subaru|Impreza 2017-19|EyeSight Driver Assistance[8](#footnotes)|openpilot available[1,9](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
-|Subaru|Impreza 2020-22|EyeSight Driver Assistance[8](#footnotes)|openpilot available[1,9](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
-|Subaru|Legacy 2015-18|EyeSight Driver Assistance[8](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
-|Subaru|Legacy 2020-22|All[8](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Subaru B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
-|Subaru|Outback 2015-17|EyeSight Driver Assistance[8](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
-|Subaru|Outback 2018-19|EyeSight Driver Assistance[8](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
-|Subaru|Outback 2020-22|All[8](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Subaru B connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
-|Subaru|XV 2018-19|EyeSight Driver Assistance[8](#footnotes)|openpilot available[1,9](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |
||
-|Subaru|XV 2020-21|EyeSight Driver Assistance[8](#footnotes)|openpilot available[1,9](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Subaru A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
-|Škoda|Fabia 2022-23[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here [17](#footnotes)|||
-|Škoda|Kamiq 2021-23[13,15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here [17](#footnotes)|||
-|Škoda|Karoq 2019-23[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Škoda|Kodiaq 2017-23[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Škoda|Octavia 2015-19[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Škoda|Octavia RS 2016[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Škoda|Octavia Scout 2017-19[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Škoda|Scala 2020-23[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here [17](#footnotes)|||
-|Škoda|Superb 2015-22[15](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Tesla[11](#footnotes)|Model 3 (with HW3) 2019-23[10](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Tesla A connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Tesla[11](#footnotes)|Model 3 (with HW4) 2024-25[10](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Tesla B connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Tesla[11](#footnotes)|Model Y (with HW3) 2020-23[10](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Tesla A connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Tesla[11](#footnotes)|Model Y (with HW4) 2024-25[10](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Tesla B connector
- 1 USB-C coupler
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Alphard 2019-20|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Alphard Hybrid 2021|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Avalon 2016|Toyota Safety Sense P|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Avalon 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Avalon 2019-21|All|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Avalon 2022|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Avalon Hybrid 2019-21|All|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Avalon Hybrid 2022|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|C-HR 2017-20|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|C-HR 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|C-HR Hybrid 2017-20|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|C-HR Hybrid 2021-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Camry 2018-20|All|Stock|0 mph[12](#footnotes)|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Toyota|Camry 2021-24|All|openpilot|0 mph[12](#footnotes)|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Camry Hybrid 2018-20|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Toyota|Camry Hybrid 2021-24|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Corolla 2017-19|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Corolla 2020-22|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Toyota|Corolla Cross (Non-US only) 2020-23|All|openpilot|17 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Corolla Cross Hybrid (Non-US only) 2020-22|All|openpilot|17 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Corolla Hatchback 2019-22|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Toyota|Corolla Hybrid 2020-22|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Corolla Hybrid (South America only) 2020-23|All|openpilot|17 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Highlander 2017-19|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Toyota|Highlander 2020-23|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Highlander Hybrid 2017-19|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Highlander Hybrid 2020-23|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Mirai 2021|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|Prius 2016|Toyota Safety Sense P|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Toyota|Prius 2017-20|All|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Toyota|Prius 2021-22|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Toyota|Prius Prime 2017-20|All|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Toyota|Prius Prime 2021-22|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Toyota|Prius v 2017|Toyota Safety Sense P|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|RAV4 2016|Toyota Safety Sense P|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|RAV4 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|RAV4 2019-21|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Toyota|RAV4 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|RAV4 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|RAV4 Hybrid 2016|Toyota Safety Sense P|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Toyota|RAV4 Hybrid 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Toyota|RAV4 Hybrid 2019-21|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Toyota|RAV4 Hybrid 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Toyota|RAV4 Hybrid 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Toyota|Sienna 2018-20|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Toyota A connector
- 1 comma 3X
- 1 comma power v3
- 1 harness box
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Volkswagen|Arteon 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Volkswagen|Arteon eHybrid 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Volkswagen|Arteon R 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Volkswagen|Arteon Shooting Brake 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Volkswagen|Atlas 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Atlas Cross Sport 2020-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|California 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Caravelle 2020|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|CC 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Volkswagen|Crafter 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Volkswagen|e-Crafter 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Volkswagen|e-Golf 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Golf 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Golf Alltrack 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Golf GTD 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Golf GTE 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Golf GTI 2015-21|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Golf R 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Golf SportsVan 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Grand California 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|31 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 angled mount (8 degrees)
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |
||
-|Volkswagen|Jetta 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Jetta GLI 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Passat 2015-22[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Passat Alltrack 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Passat GTE 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Polo 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here [17](#footnotes)|||
-|Volkswagen|Polo GTI 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here [17](#footnotes)|||
-|Volkswagen|T-Cross 2021|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here [17](#footnotes)|||
-|Volkswagen|T-Roc 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Taos 2022-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Teramont 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Teramont Cross Sport 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Teramont X 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Tiguan 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Tiguan eHybrid 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
-|Volkswagen|Touran 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,16](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma 3X
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
- 1 right angle OBD-C cable (1.5 ft)
Buy Here |||
+|CUPRA|Ateca 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Dodge|Durango 2020-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[](##)|[](##)|Parts
- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ford|Bronco Sport 2021-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ford|Escape 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ford|Escape 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here ||
|
+|Ford|Escape Hybrid 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ford|Escape Hybrid 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here ||
|
+|Ford|Escape Plug-in Hybrid 2020-22|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ford|Escape Plug-in Hybrid 2023-24|Co-Pilot360 Assist+|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here ||
|
+|Ford|Expedition 2022-24|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here ||
|
+|Ford|Explorer 2020-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ford|Explorer Hybrid 2020-24|Co-Pilot360 Assist+|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ford|F-150 2021-23|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here ||
|
+|Ford|F-150 Hybrid 2021-23|Co-Pilot360 Assist 2.0|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here ||
|
+|Ford|Focus 2018[3](#footnotes)|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ford|Focus Hybrid 2018[3](#footnotes)|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ford|Kuga 2020-23|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ford|Kuga Hybrid 2020-23|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ford|Kuga Hybrid 2024|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here ||
|
+|Ford|Kuga Plug-in Hybrid 2020-23|Adaptive Cruise Control with Lane Centering|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ford|Kuga Plug-in Hybrid 2024|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here ||
|
+|Ford|Maverick 2022|LARIAT Luxury|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ford|Maverick 2023-24|Co-Pilot360 Assist|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ford|Maverick Hybrid 2022|LARIAT Luxury|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ford|Maverick Hybrid 2023-24|Co-Pilot360 Assist|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ford|Mustang Mach-E 2021-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here ||
|
+|Ford|Ranger 2024|Adaptive Cruise Control with Lane Centering|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q4 connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here ||
|
+|Genesis|G70 2018|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai F connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Genesis|G70 2019-21|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai F connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Genesis|G70 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Genesis|G80 2017|All|Stock|19 mph|37 mph|[](##)|[](##)|Parts
- 1 Hyundai J connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Genesis|G80 2018-19|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Genesis|G80 (2.5T Advanced Trim, with HDA II) 2024|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai P connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Genesis|G90 2017-20|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Genesis|GV60 (Advanced Trim) 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Genesis|GV60 (Performance Trim) 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Genesis|GV70 (2.5T Trim, without HDA II) 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Genesis|GV70 (3.5T Trim, without HDA II) 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai M connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Genesis|GV70 Electrified (Australia Only) 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai Q connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Genesis|GV70 Electrified (with HDA II) 2023-24|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai Q connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Genesis|GV80 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai M connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|GMC|Sierra 1500 2020-21|Driver Alert Package II|openpilot available[1](#footnotes)|0 mph|6 mph|[](##)|[](##)|Parts
- 1 GM connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 harness box
- 1 mount
Buy Here |
||
+|Honda|Accord 2018-22|All|openpilot available[1](#footnotes)|0 mph|3 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Honda|Accord 2023-25|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Accord Hybrid 2018-22|All|openpilot available[1](#footnotes)|0 mph|3 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Accord Hybrid 2023-25|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|City (Brazil only) 2023|All|openpilot available[1](#footnotes)|0 mph|14 mph|[](##)|[](##)|Parts
- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Civic 2016-18|Honda Sensing|openpilot|0 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Honda|Civic 2019-21|All|openpilot available[1](#footnotes)|0 mph|2 mph[5](#footnotes)|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Honda|Civic 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Honda|Civic Hatchback 2017-18|Honda Sensing|openpilot available[1](#footnotes)|0 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Civic Hatchback 2019-21|All|openpilot available[1](#footnotes)|0 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Civic Hatchback 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Honda|Civic Hatchback Hybrid 2025-26|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Civic Hatchback Hybrid (Europe only) 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Civic Hybrid 2025-26|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Clarity 2018-21|Honda Sensing|openpilot|0 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector + Honda Clarity Proxy Board
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|CR-V 2015-16|Touring Trim|openpilot|26 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|CR-V 2017-22|Honda Sensing|openpilot available[1](#footnotes)|0 mph|15 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|CR-V 2023-26|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|CR-V Hybrid 2017-22|Honda Sensing|openpilot available[1](#footnotes)|0 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|CR-V Hybrid 2023-25|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|e 2020|All|openpilot available[1](#footnotes)|0 mph|3 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Fit 2018-20|Honda Sensing|openpilot|26 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Freed 2020|Honda Sensing|openpilot|26 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|HR-V 2019-22|Honda Sensing|openpilot|26 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|HR-V 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Insight 2019-22|All|openpilot available[1](#footnotes)|0 mph|3 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Inspire 2018|All|openpilot available[1](#footnotes)|0 mph|3 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|N-Box 2018|All|openpilot available[1](#footnotes)|0 mph|11 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Odyssey 2018-20|Honda Sensing|openpilot|26 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Odyssey 2021-26|All|openpilot available[1](#footnotes)|0 mph|43 mph|[](##)|[](##)|Parts
- 1 Honda Bosch A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Passport 2019-25|All|openpilot|26 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Passport 2026|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Pilot 2016-22|Honda Sensing|openpilot|26 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Pilot 2023-25|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Honda Bosch C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Honda|Ridgeline 2017-25|Honda Sensing|openpilot|26 mph|12 mph|[](##)|[](##)|Parts
- 1 Honda Nidec connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Azera 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Azera Hybrid 2019|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Azera Hybrid 2020|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Custin 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Elantra 2017-18|Smart Cruise Control (SCC)|Stock|19 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Elantra 2019|Smart Cruise Control (SCC)|Stock|19 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Elantra 2021-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Hyundai|Elantra GT 2017-20|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Elantra Hybrid 2021-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Hyundai|Elantra Non-SCC 2022|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Genesis 2015-16|Smart Cruise Control (SCC)|Stock|19 mph|37 mph|[](##)|[](##)|Parts
- 1 Hyundai J connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|i30 2017-19|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Ioniq 5 (Southeast Asia and Europe only) 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai Q connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Ioniq 5 (with HDA II) 2022-24|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai Q connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Ioniq 5 (without HDA II) 2022-24|Highway Driving Assist|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Ioniq 6 (with HDA II) 2023-24|Highway Driving Assist II|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai P connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Ioniq Electric 2019|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Ioniq Electric 2020|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Ioniq Hybrid 2017-19|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Ioniq Hybrid 2020-22|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Ioniq Plug-in Hybrid 2019|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Ioniq Plug-in Hybrid 2020-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Kona 2020|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|6 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Kona 2022-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai O connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Kona Electric 2018-21|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Kona Electric 2022-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai O connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Kona Electric (with HDA II, Korea only) 2023|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai R connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Hyundai|Kona Electric Non-SCC 2019|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Kona Hybrid 2020|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai I connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Kona Non-SCC 2019|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Nexo 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Palisade 2020-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Hyundai|Santa Cruz 2022-24|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Santa Fe 2019-20|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai D connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Hyundai|Santa Fe 2021-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Hyundai|Santa Fe Hybrid 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Santa Fe Plug-in Hybrid 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Sonata 2018-19|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Sonata 2020-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Hyundai|Sonata Hybrid 2020-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Staria 2023|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Tucson 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Tucson 2022|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Tucson 2023-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Tucson Diesel 2019|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Tucson Hybrid 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Tucson Plug-in Hybrid 2024|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Hyundai|Veloster 2019-20|Smart Cruise Control (SCC)|Stock|5 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Jeep|Grand Cherokee 2016-18|Adaptive Cruise Control (ACC)|Stock|0 mph|9 mph|[](##)|[](##)|Parts
- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Jeep|Grand Cherokee 2019-21|Adaptive Cruise Control (ACC)|Stock|0 mph|39 mph|[](##)|[](##)|Parts
- 1 FCA connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Kia|Carnival 2022-24|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Carnival (China only) 2023|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Ceed 2019-21|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Ceed Plug-in Hybrid Non-SCC 2022|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai I connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|EV6 (Southeast Asia only) 2022-24|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai P connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|EV6 (with HDA II) 2022-24|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai P connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|EV6 (without HDA II) 2022-24|Highway Driving Assist|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai L connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Forte 2019-21|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|6 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Forte 2022-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Forte Non-SCC 2019|No Smart Cruise Control (Non-SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|K5 2021-24|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|K5 Hybrid 2020-22|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|K8 Hybrid (with HDA II) 2023|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai Q connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Niro EV 2019|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Kia|Niro EV 2020|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai F connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Kia|Niro EV 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Kia|Niro EV 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Kia|Niro EV (with HDA II) 2025|Highway Driving Assist II|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai R connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Niro EV (without HDA II) 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Niro Hybrid 2018|Smart Cruise Control (SCC)|Stock|10 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Niro Hybrid 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai D connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Niro Hybrid 2022|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai F connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Niro Hybrid 2023|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Niro Plug-in Hybrid 2018-19|All|Stock|10 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Niro Plug-in Hybrid 2020|Smart Cruise Control (SCC)|Stock|0 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai D connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Niro Plug-in Hybrid 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai D connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Niro Plug-in Hybrid 2022|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai F connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Optima 2017|Advanced Smart Cruise Control|Stock|0 mph|32 mph|[](##)|[](##)|Parts
- 1 Hyundai B connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Optima 2019-20|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai G connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Optima Hybrid 2019|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Seltos 2021|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Sorento 2018|Advanced Smart Cruise Control & LKAS|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Kia|Sorento 2019|Smart Cruise Control (SCC)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai E connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Kia|Sorento 2021-23|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Sorento Hybrid 2021-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Sorento Plug-in Hybrid 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai A connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Sportage 2023-24|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Sportage Hybrid 2023|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai N connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Stinger 2018-20|Smart Cruise Control (SCC)|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai C connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Kia|Stinger 2022-23|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai K connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Kia|Telluride 2020-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Hyundai H connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|CT Hybrid 2017-18|Lexus Safety System+|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|ES 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|ES 2019-25|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|ES Hybrid 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|ES Hybrid 2019-25|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Lexus|GS F 2016|All|Stock|19 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|IS 2017-19|All|Stock|19 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|IS 2022-24|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|LC 2024-25|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|NX 2018-19|All|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|NX 2020-21|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|NX Hybrid 2018-19|All|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|NX Hybrid 2020-21|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|RC 2018-20|All|Stock|19 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|RC 2023|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|RX 2016|Lexus Safety System+|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|RX 2017-19|All|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|RX 2020-22|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|RX Hybrid 2016|Lexus Safety System+|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|RX Hybrid 2017-19|All|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|RX Hybrid 2020-22|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lexus|UX Hybrid 2019-24|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lincoln|Aviator 2020-24|Co-Pilot360 Plus|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Lincoln|Aviator Plug-in Hybrid 2020-24|Co-Pilot360 Plus|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Ford Q3 connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|MAN|eTGE 2020-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |
||
+|MAN|TGE 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |
||
+|Mazda|CX-5 2022-25|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Mazda connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Mazda|CX-9 2021-23|All|Stock|0 mph|28 mph|[](##)|[](##)|Parts
- 1 Mazda connector
- 1 OBD-C cable (2 ft)
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Nissan[6](#footnotes)|Altima 2019-20, 2024|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Nissan B connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Nissan[6](#footnotes)|Leaf 2018-23|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |
||
+|Nissan[6](#footnotes)|Rogue 2018-20|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Nissan[6](#footnotes)|X-Trail 2017|ProPILOT Assist|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 Nissan A connector
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Ram|1500 2019-24|Adaptive Cruise Control (ACC)|Stock|32 mph|1 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Ram connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ram|2500 2020-24|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Ram connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Ram|3500 2019-22|Adaptive Cruise Control (ACC)|Stock|0 mph|36 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Ram connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Rivian|R1S 2022-24|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Rivian A connector
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here ||
|
+|Rivian|R1T 2022-24|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Rivian A connector
- 1 USB-C coupler
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here ||
|
+|SEAT|Ateca 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|SEAT|Leon 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Subaru|Ascent 2019-21|All[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
+|Subaru|Crosstrek 2018-19|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |
||
+|Subaru|Crosstrek 2020-23|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
+|Subaru|Forester 2017-18|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
+|Subaru|Forester 2019-21|All[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
+|Subaru|Impreza 2017-19|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
+|Subaru|Impreza 2020-22|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
+|Subaru|Legacy 2015-18|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
+|Subaru|Legacy 2020-22|All[7](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Subaru B connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
+|Subaru|Outback 2015-17|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
+|Subaru|Outback 2018-19|EyeSight Driver Assistance[7](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
+|Subaru|Outback 2020-22|All[7](#footnotes)|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Subaru B connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
+|Subaru|XV 2018-19|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |
||
+|Subaru|XV 2020-21|EyeSight Driver Assistance[7](#footnotes)|openpilot available[1,8](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Subaru A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy HereTools
- 1 Pry Tool
- 1 Socket Wrench 8mm or 5/16" (deep) |||
+|Škoda|Fabia 2022-23[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here [16](#footnotes)|||
+|Škoda|Kamiq 2021-23[12,14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here [16](#footnotes)|||
+|Škoda|Karoq 2019-23[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Škoda|Kodiaq 2017-23[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Škoda|Octavia 2015-19[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Škoda|Octavia RS 2016[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Škoda|Octavia Scout 2017-19[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Škoda|Scala 2020-23[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here [16](#footnotes)|||
+|Škoda|Superb 2015-22[14](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Tesla[10](#footnotes)|Model 3 (with HW3) 2019-23[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 USB-C coupler
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Tesla[10](#footnotes)|Model 3 (with HW4) 2024-25[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 USB-C coupler
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Tesla[10](#footnotes)|Model Y (with HW3) 2020-23[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Tesla A connector
- 1 USB-C coupler
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Tesla[10](#footnotes)|Model Y (with HW4) 2024-25[9](#footnotes)|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Tesla B connector
- 1 USB-C coupler
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Toyota|Alphard 2019-20|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Alphard Hybrid 2021|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Avalon 2016|Toyota Safety Sense P|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Avalon 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Avalon 2019-21|All|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Avalon 2022|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Avalon Hybrid 2019-21|All|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Avalon Hybrid 2022|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|C-HR 2017-20|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|C-HR 2021|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|C-HR Hybrid 2017-20|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|C-HR Hybrid 2021-22|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Camry 2018-20|All|Stock|0 mph[11](#footnotes)|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Toyota|Camry 2021-24|All|openpilot|0 mph[11](#footnotes)|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Camry Hybrid 2018-20|All|Stock|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Toyota|Camry Hybrid 2021-24|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Corolla 2017-19|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Corolla 2020-22|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Toyota|Corolla Cross (Non-US only) 2020-23|All|openpilot|17 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Corolla Cross Hybrid (Non-US only) 2020-22|All|openpilot|17 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Corolla Hatchback 2019-22|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Toyota|Corolla Hybrid 2020-22|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Corolla Hybrid (South America only) 2020-23|All|openpilot|17 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Highlander 2017-19|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Toyota|Highlander 2020-23|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Highlander Hybrid 2017-19|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Highlander Hybrid 2020-23|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Mirai 2021|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|Prius 2016|Toyota Safety Sense P|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Toyota|Prius 2017-20|All|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Toyota|Prius 2021-22|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Toyota|Prius Prime 2017-20|All|openpilot available[2](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Toyota|Prius Prime 2021-22|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Toyota|Prius v 2017|Toyota Safety Sense P|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|RAV4 2016|Toyota Safety Sense P|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|RAV4 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|RAV4 2019-21|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Toyota|RAV4 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|RAV4 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|RAV4 Hybrid 2016|Toyota Safety Sense P|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Toyota|RAV4 Hybrid 2017-18|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Toyota|RAV4 Hybrid 2019-21|All|openpilot|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |||
+|Toyota|RAV4 Hybrid 2022|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Toyota|RAV4 Hybrid 2023-25|All|openpilot available[1](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Toyota|Sienna 2018-20|All|openpilot available[2](#footnotes)|19 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 Toyota A connector
- 1 comma four
- 1 comma power v3
- 1 harness box
- 1 mount
Buy Here |
||
+|Volkswagen|Arteon 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |
||
+|Volkswagen|Arteon eHybrid 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |
||
+|Volkswagen|Arteon R 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |
||
+|Volkswagen|Arteon Shooting Brake 2020-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |
||
+|Volkswagen|Atlas 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Atlas Cross Sport 2020-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|California 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Caravelle 2020|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|CC 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |
||
+|Volkswagen|Crafter 2017-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |
||
+|Volkswagen|e-Crafter 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |
||
+|Volkswagen|e-Golf 2014-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Golf 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Golf Alltrack 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Golf GTD 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Golf GTE 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Golf GTI 2015-21|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Golf R 2015-19|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Golf SportsVan 2015-20|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Grand California 2019-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|31 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |
||
+|Volkswagen|Jetta 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Jetta GLI 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Passat 2015-22[13](#footnotes)|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Passat Alltrack 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Passat GTE 2015-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Polo 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here [16](#footnotes)|||
+|Volkswagen|Polo GTI 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here [16](#footnotes)|||
+|Volkswagen|T-Cross 2021|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here [16](#footnotes)|||
+|Volkswagen|T-Roc 2018-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Taos 2022-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Teramont 2018-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Teramont Cross Sport 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Teramont X 2021-22|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Tiguan 2018-24|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Tiguan eHybrid 2021-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
+|Volkswagen|Touran 2016-23|Adaptive Cruise Control (ACC) & Lane Assist|openpilot available[1,15](#footnotes)|0 mph|0 mph|[](##)|[](##)|Parts
- 1 OBD-C cable (2 ft)
- 1 USB-C coupler
- 1 VW J533 connector
- 1 comma four
- 1 harness box
- 1 long OBD-C cable (9.5 ft)
- 1 mount
Buy Here |||
### Footnotes
1openpilot Longitudinal Control (Alpha) is available behind a toggle; the toggle is only available in non-release branches such as `devel` or `nightly-dev`.
@@ -354,18 +356,17 @@ A supported vehicle is one that just works when you install a comma device. All
3Refers only to the Focus Mk4 (C519) available in Europe/China/Taiwan/Australasia, not the Focus Mk3 (C346) in North and South America/Southeast Asia.
4See more setup details for GM.
52019 Honda Civic 1.6L Diesel Sedan does not have ALC below 12mph.
-6Requires a CAN FD panda kit if not using comma 3X for this CAN FD car.
-7See more setup details for Nissan.
-8In the non-US market, openpilot requires the car to come equipped with EyeSight with Lane Keep Assistance.
-9Enabling longitudinal control (alpha) will disable all EyeSight functionality, including AEB, LDW, and RAB.
-10Some 2023 model years have HW4. To check which hardware type your vehicle has, look for Autopilot computer under Software -> Additional Vehicle Information on your vehicle's touchscreen. See this page for more information.
-11See more setup details for Tesla.
-12openpilot operates above 28mph for Camry 4CYL L, 4CYL LE and 4CYL SE which don't have Full-Speed Range Dynamic Radar Cruise Control.
-13Not including the China market Kamiq, which is based on the (currently) unsupported PQ34 platform.
-14Refers only to the MQB-based European B8 Passat, not the NMS Passat in the USA/China/Mideast markets.
-15Some Škoda vehicles are equipped with heated windshields, which are known to block GPS signal needed for some comma 3X functionality.
-16Only available for vehicles using a gateway (J533) harness. At this time, vehicles using a camera harness are limited to using stock ACC.
-17Model-years 2022 and beyond may have a combined CAN gateway and BCM, which is supported by openpilot in software, but doesn't yet have a harness available from the comma store.
+6See more setup details for Nissan.
+7In the non-US market, openpilot requires the car to come equipped with EyeSight with Lane Keep Assistance.
+8Enabling longitudinal control (alpha) will disable all EyeSight functionality, including AEB, LDW, and RAB.
+9Some 2023 model years have HW4. To check which hardware type your vehicle has, look for Autopilot computer under Software -> Additional Vehicle Information on your vehicle's touchscreen. See this page for more information.
+10See more setup details for Tesla.
+11openpilot operates above 28mph for Camry 4CYL L, 4CYL LE and 4CYL SE which don't have Full-Speed Range Dynamic Radar Cruise Control.
+12Not including the China market Kamiq, which is based on the (currently) unsupported PQ34 platform.
+13Refers only to the MQB-based European B8 Passat, not the NMS Passat in the USA/China/Mideast markets.
+14Some Škoda vehicles are equipped with heated windshields, which are known to block GPS signal needed for some comma four functionality.
+15Only available for vehicles using a gateway (J533) harness. At this time, vehicles using a camera harness are limited to using stock ACC.
+16Model-years 2022 and beyond may have a combined CAN gateway and BCM, which is supported by openpilot in software, but doesn't yet have a harness available from the comma store.
## Community Maintained Cars
Although they're not upstream, the community has openpilot running on other makes and models. See the 'Community Supported Models' section of each make [on our wiki](https://wiki.comma.ai/).
diff --git a/launch_env.sh b/launch_env.sh
index 0fba08bb24..fcbee2ff8d 100755
--- a/launch_env.sh
+++ b/launch_env.sh
@@ -16,7 +16,7 @@ export VECLIB_MAXIMUM_THREADS=1
export QCOM_PRIORITY=12
if [ -z "$AGNOS_VERSION" ]; then
- export AGNOS_VERSION="15"
+ export AGNOS_VERSION="15.1"
fi
export STAGING_ROOT="/data/safe_staging"
diff --git a/msgq_repo b/msgq_repo
index fd7bd0df50..a16cf1f608 160000
--- a/msgq_repo
+++ b/msgq_repo
@@ -1 +1 @@
-Subproject commit fd7bd0df50a95dca3f180705721aa1fa300aef0f
+Subproject commit a16cf1f608538d14f66bd6142230d8728f2d0abc
diff --git a/opendbc_repo b/opendbc_repo
index 62f6f9e4d4..61bf5a90c5 160000
--- a/opendbc_repo
+++ b/opendbc_repo
@@ -1 +1 @@
-Subproject commit 62f6f9e4d4ff4c424586c114c6ae3dd33629e4af
+Subproject commit 61bf5a90c5c1917b657b8dd50c4d95e437413170
diff --git a/pyproject.toml b/pyproject.toml
index eed5ff41b8..c58d950496 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -178,7 +178,7 @@ quiet-level = 3
# if you've got a short variable name that's getting flagged, add it here
ignore-words-list = "bu,ro,te,ue,alo,hda,ois,nam,nams,ned,som,parm,setts,inout,warmup,bumb,nd,sie,preints,whit,indexIn,ws,uint,grey,deque,stdio,amin,BA,LITE,atEnd,UIs,errorString,arange,FocusIn,od,tim,relA,hist,copyable,jupyter,thead,TGE,abl,lite"
builtin = "clear,rare,informal,code,names,en-GB_to_en-US"
-skip = "./third_party/*, ./tinygrad/*, ./tinygrad_repo/*, ./msgq/*, ./panda/*, ./opendbc/*, ./opendbc_repo/*, ./rednose/*, ./rednose_repo/*, ./teleoprtc/*, ./teleoprtc_repo/*, *.po, uv.lock, *.onnx, ./cereal/gen/*, */c_generated_code/*, docs/assets/*, tools/plotjuggler/layouts/*"
+skip = "./third_party/*, ./tinygrad/*, ./tinygrad_repo/*, ./msgq/*, ./panda/*, ./opendbc/*, ./opendbc_repo/*, ./rednose/*, ./rednose_repo/*, ./teleoprtc/*, ./teleoprtc_repo/*, *.po, uv.lock, *.onnx, ./cereal/gen/*, */c_generated_code/*, docs/assets/*, tools/plotjuggler/layouts/*, selfdrive/assets/offroad/mici_fcc.html"
[tool.mypy]
python_version = "3.11"
@@ -236,6 +236,7 @@ lint.ignore = [
"B027",
"B024",
"NPY002", # new numpy random syntax is worse
+ "UP045", "UP007", # these don't play nice with raylib atm
]
line-length = 160
target-version ="py311"
diff --git a/selfdrive/assets/fonts/process.py b/selfdrive/assets/fonts/process.py
index a0d01af148..ddc8b3a868 100755
--- a/selfdrive/assets/fonts/process.py
+++ b/selfdrive/assets/fonts/process.py
@@ -10,7 +10,7 @@ TRANSLATIONS_DIR = SELFDRIVE_DIR / "ui" / "translations"
LANGUAGES_FILE = TRANSLATIONS_DIR / "languages.json"
GLYPH_PADDING = 6
-EXTRA_CHARS = "–‑✓×°§•€£¥"
+EXTRA_CHARS = "–‑✓×°§•X⚙✕◀▶✔⌫⇧␣○●↳çêüñ–‑✓×°§•€£¥"
UNIFONT_LANGUAGES = {"ar", "th", "zh-CHT", "zh-CHS", "ko", "ja"}
@@ -68,6 +68,10 @@ def _glyph_metrics(glyphs, rects, codepoints):
def _write_bmfont(path: Path, font_size: int, face: str, atlas_name: str, line_height: int, base: int, atlas_size, entries):
+ # TODO: why doesn't raylib calculate these metrics correctly?
+ if line_height != font_size:
+ print("using font size for line height", atlas_name)
+ line_height = font_size
lines = [
f"info face=\"{face}\" size=-{font_size} bold=0 italic=0 charset=\"\" unicode=1 stretchH=100 smooth=0 aa=1 padding=0,0,0,0 spacing=0,0 outline=0",
f"common lineHeight={line_height} base={base} scaleW={atlas_size[0]} scaleH={atlas_size[1]} pages=1 packed=0 alphaChnl=0 redChnl=4 greenChnl=4 blueChnl=4",
diff --git a/selfdrive/assets/icons/eyes_crossed.png b/selfdrive/assets/icons/eyes_crossed.png
new file mode 100644
index 0000000000..af2122cd9a
--- /dev/null
+++ b/selfdrive/assets/icons/eyes_crossed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4def42b5faffc6a8f747c210d24c3a1a8a7f82891738ff7f3317091e63326ba5
+size 1083
diff --git a/selfdrive/assets/icons/eyes_open.png b/selfdrive/assets/icons/eyes_open.png
new file mode 100644
index 0000000000..ad9afc3a3e
--- /dev/null
+++ b/selfdrive/assets/icons/eyes_open.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:52019b72834e478588114584820313af866d2d7a737591a166c413ccaab6acf5
+size 931
diff --git a/selfdrive/assets/icons_mici/buttons/button_circle.png b/selfdrive/assets/icons_mici/buttons/button_circle.png
new file mode 100644
index 0000000000..b6f4cc9d12
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_circle.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f92e5f0b7fc50c3b64bd18ecee8a8d518017b5461104de76dee6feb0f4f0d70d
+size 7496
diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_disabled.png b/selfdrive/assets/icons_mici/buttons/button_circle_disabled.png
new file mode 100644
index 0000000000..d2104df4e1
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_circle_disabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:947aa3beb7eff6afb44101daf0aeaae7b7f31961c273df00eec0ca8359233c56
+size 5175
diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_hover.png b/selfdrive/assets/icons_mici/buttons/button_circle_hover.png
new file mode 100644
index 0000000000..5cae152106
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_circle_hover.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:20024203288f144633014422e16119278477099f24fba5c155a804a1864a26b4
+size 7511
diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_red.png b/selfdrive/assets/icons_mici/buttons/button_circle_red.png
new file mode 100644
index 0000000000..68ae400b1e
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_circle_red.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b48d8a191979f27dae8a336f99d944008e2536f698c58cffa5f3dddc17429b45
+size 11451
diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_red_hover.png b/selfdrive/assets/icons_mici/buttons/button_circle_red_hover.png
new file mode 100644
index 0000000000..3696334d5e
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_circle_red_hover.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:279c1d8f95eb9f4a3058dff76b0f316ce9eef7bc8f4296936ad25fd08703ce13
+size 10380
diff --git a/selfdrive/assets/icons_mici/buttons/button_rectangle.png b/selfdrive/assets/icons_mici/buttons/button_rectangle.png
new file mode 100644
index 0000000000..230c537d6d
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_rectangle.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ffb293236f5f8f7da44b5a3c4c0b72e86c4e1fdb04f89c94507af008ff7de139
+size 8210
diff --git a/selfdrive/assets/icons_mici/buttons/button_rectangle_disabled.png b/selfdrive/assets/icons_mici/buttons/button_rectangle_disabled.png
new file mode 100644
index 0000000000..76e75d5421
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_rectangle_disabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bda53863c9a46c50a1e2920a76c2d2f1fe4df8a94b8d2e26f5d83eef3a9c3bd3
+size 3627
diff --git a/selfdrive/assets/icons_mici/buttons/button_rectangle_hover.png b/selfdrive/assets/icons_mici/buttons/button_rectangle_hover.png
new file mode 100644
index 0000000000..a9fd28cc35
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_rectangle_hover.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6b55e43c50e805ac5e8357e5943374ed02d756cefa3aaffb58c568a0b125c30b
+size 7750
diff --git a/selfdrive/assets/icons_mici/buttons/button_rectangle_pressed.png b/selfdrive/assets/icons_mici/buttons/button_rectangle_pressed.png
new file mode 100644
index 0000000000..779c219fcb
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_rectangle_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5528e9c041b824f005bf1ef6e49b2dbbc4ba10f994b0726d2a17a4fbf8c80f55
+size 21379
diff --git a/selfdrive/assets/icons_mici/buttons/button_side_back.png b/selfdrive/assets/icons_mici/buttons/button_side_back.png
new file mode 100644
index 0000000000..3d648d34f1
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_side_back.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9df44871e9f5fa910622b0b92205b92a54d137dbdc3827b92e8622d85ff2e08e
+size 5189
diff --git a/selfdrive/assets/icons_mici/buttons/button_side_back_pressed.png b/selfdrive/assets/icons_mici/buttons/button_side_back_pressed.png
new file mode 100644
index 0000000000..e431cb0c73
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_side_back_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:013b368b38b17d9b2ef6aaf0f498f672deed95888084b7287f42bdfba617cbb6
+size 10142
diff --git a/selfdrive/assets/icons_mici/buttons/button_side_check.png b/selfdrive/assets/icons_mici/buttons/button_side_check.png
new file mode 100644
index 0000000000..820b236066
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_side_check.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8fd563eec78d5ce4a8204c2f596789e1090cb3e26a35b4ffeacee4ab61968538
+size 8303
diff --git a/selfdrive/assets/icons_mici/buttons/button_side_check_pressed.png b/selfdrive/assets/icons_mici/buttons/button_side_check_pressed.png
new file mode 100644
index 0000000000..6c38508af9
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/button_side_check_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0be8d5eddcd9f87acbf1daccf446be6218522120f64aee1ee0a3c0b31560f076
+size 15761
diff --git a/selfdrive/assets/icons_mici/buttons/slider_bg.png b/selfdrive/assets/icons_mici/buttons/slider_bg.png
new file mode 100644
index 0000000000..9164f74bad
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/slider_bg.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1ca620a05e9e69351b9bbcfcf021dae11fde26be50d7f1a39257d319f6303616
+size 9779
diff --git a/selfdrive/assets/icons_mici/buttons/toggle_dot_disabled.png b/selfdrive/assets/icons_mici/buttons/toggle_dot_disabled.png
new file mode 100644
index 0000000000..0e21bc1b5a
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/toggle_dot_disabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:613af9ed79bb26c60fbd19c094214f0881736c0e293f6d000b530cde0478a273
+size 2470
diff --git a/selfdrive/assets/icons_mici/buttons/toggle_dot_enabled.png b/selfdrive/assets/icons_mici/buttons/toggle_dot_enabled.png
new file mode 100644
index 0000000000..5bb4d778f8
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/toggle_dot_enabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:532bf0e8535e3f9bc13af13029a27d6c14ae788d52224b6c65623334f62fada0
+size 6048
diff --git a/selfdrive/assets/icons_mici/buttons/toggle_pill_disabled.png b/selfdrive/assets/icons_mici/buttons/toggle_pill_disabled.png
new file mode 100644
index 0000000000..555c16e095
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/toggle_pill_disabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7891a628bd9cedc1097114e89fcec4a50a88021c7d6c63f1329d087be9e1783e
+size 3065
diff --git a/selfdrive/assets/icons_mici/buttons/toggle_pill_enabled.png b/selfdrive/assets/icons_mici/buttons/toggle_pill_enabled.png
new file mode 100644
index 0000000000..d95039da92
--- /dev/null
+++ b/selfdrive/assets/icons_mici/buttons/toggle_pill_enabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ad31da78544edd18d0ac154670f22d1cd1ac57f50576002f04701d22c59502a8
+size 8257
diff --git a/selfdrive/assets/icons_mici/exclamation_point.png b/selfdrive/assets/icons_mici/exclamation_point.png
new file mode 100644
index 0000000000..246fc015ec
--- /dev/null
+++ b/selfdrive/assets/icons_mici/exclamation_point.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b77579c099c688d1a27f356197fba9c2c8efcf4d391af580b4b29f0e70587919
+size 2086
diff --git a/selfdrive/assets/icons_mici/experimental_mode.png b/selfdrive/assets/icons_mici/experimental_mode.png
new file mode 100644
index 0000000000..e0138bfd65
--- /dev/null
+++ b/selfdrive/assets/icons_mici/experimental_mode.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:eb42b8d6259238beb26f286dc28fb2dc8d91b00fec1f7a7655296b5769439a15
+size 15690
diff --git a/selfdrive/assets/icons_mici/microphone.png b/selfdrive/assets/icons_mici/microphone.png
new file mode 100644
index 0000000000..9718a6b135
--- /dev/null
+++ b/selfdrive/assets/icons_mici/microphone.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:17b6fe530598cbad34bcf31d4f21f929b792aacedef51b3ffef1941c86017811
+size 7331
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/big_alert.png b/selfdrive/assets/icons_mici/offroad_alerts/big_alert.png
new file mode 100644
index 0000000000..142367d0e6
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/big_alert.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:aeee7f049879caff52320fab5f286cf3fd6a52c820cd8e150ff242e53a14176f
+size 14774
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/big_alert_pressed.png b/selfdrive/assets/icons_mici/offroad_alerts/big_alert_pressed.png
new file mode 100644
index 0000000000..2ff01024d9
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/big_alert_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0b59ddada9c9e0e7972ead27396ebe6c10fd2352687b18ff6b476f61f74d80bf
+size 46106
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/green_wheel.png b/selfdrive/assets/icons_mici/offroad_alerts/green_wheel.png
new file mode 100644
index 0000000000..6a8351f6ee
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/green_wheel.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:05f3626e790622a4ad90e982c4aacb612d0785a752339352a3187addf763e2e9
+size 13288
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/medium_alert.png b/selfdrive/assets/icons_mici/offroad_alerts/medium_alert.png
new file mode 100644
index 0000000000..91a7b43c5a
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/medium_alert.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:46d08a8a08b42d466ff45d8ad6d2578e345dcbb1c06c126ad361873d9d35eaec
+size 12877
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/medium_alert_pressed.png b/selfdrive/assets/icons_mici/offroad_alerts/medium_alert_pressed.png
new file mode 100644
index 0000000000..09ca2d08d5
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/medium_alert_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:42ea275e5fe0a8a0e2fddb5a4a8487806fb22850115ea3646ee8f213d5fd6bb6
+size 37202
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/orange_warning.png b/selfdrive/assets/icons_mici/offroad_alerts/orange_warning.png
new file mode 100644
index 0000000000..13af475c6d
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/orange_warning.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a877882a8dccb884bd35918f9f9b427a724a59e90a638e54f6fd5d0680ad173c
+size 12137
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/red_warning.png b/selfdrive/assets/icons_mici/offroad_alerts/red_warning.png
new file mode 100644
index 0000000000..83c3595b29
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/red_warning.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ba944b208abed9b8b9752adb8017bd29cd2e98c89fb07ee5d0a595185c7564a5
+size 11898
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/small_alert.png b/selfdrive/assets/icons_mici/offroad_alerts/small_alert.png
new file mode 100644
index 0000000000..0a50b6a1ca
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/small_alert.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cfbf38672a893fd1d8fadf942354d2511e2436814a9af0e5c188cbb427fc6c70
+size 12281
diff --git a/selfdrive/assets/icons_mici/offroad_alerts/small_alert_pressed.png b/selfdrive/assets/icons_mici/offroad_alerts/small_alert_pressed.png
new file mode 100644
index 0000000000..865355ef01
--- /dev/null
+++ b/selfdrive/assets/icons_mici/offroad_alerts/small_alert_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a7102850bcfb075a285041cecb546559374a905403ab3b9814fa6097d2d822dd
+size 34680
diff --git a/selfdrive/assets/icons_mici/onroad/blind_spot_left.png b/selfdrive/assets/icons_mici/onroad/blind_spot_left.png
new file mode 100644
index 0000000000..5d3b1e5d7b
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/blind_spot_left.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a23743d21bc8160e013625210654a55634e4ed58e60057b70e08761bac1c3680
+size 40406
diff --git a/selfdrive/assets/icons_mici/onroad/blind_spot_right.png b/selfdrive/assets/icons_mici/onroad/blind_spot_right.png
new file mode 100644
index 0000000000..67216078d9
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/blind_spot_right.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:acbfa3e38f0b9f422f5c1335ce20013852df2892b813db176a51918adc83ad58
+size 40979
diff --git a/selfdrive/assets/icons_mici/onroad/bookmark.png b/selfdrive/assets/icons_mici/onroad/bookmark.png
new file mode 100644
index 0000000000..207182276e
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/bookmark.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e0d00d743b01c49c2b739127e9916a229caf8c48346d6d168863b080ddcaa409
+size 11124
diff --git a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_background.png b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_background.png
new file mode 100644
index 0000000000..4d83ed5cd9
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_background.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f27352a18194a1c819e9eaea89cfc11d2964402df0a28efa3ba60ae2d972fe67
+size 13108
diff --git a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_center.png b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_center.png
new file mode 100644
index 0000000000..a8a68b372c
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_center.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b5aee9f6cec03f1967014cd2ea2a23982b262e7d86dadca602ecfa8875b38101
+size 5875
diff --git a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_cone.png b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_cone.png
new file mode 100644
index 0000000000..ec2f948998
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_cone.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:26b3660dbd1e60b0ba98914afa7cb3a67151bb6990d218f55c901f243e38ff3e
+size 3631
diff --git a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_person.png b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_person.png
new file mode 100644
index 0000000000..7aa7f0542a
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_person.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:25d66e42a28a3367eb40724d28652889089aa762438b475645269e0319c46009
+size 1431
diff --git a/selfdrive/assets/icons_mici/onroad/eye_fill.png b/selfdrive/assets/icons_mici/onroad/eye_fill.png
new file mode 100644
index 0000000000..8f0e8ebfb1
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/eye_fill.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:51af75afbaf30abeaae1c99c7ad3e25cf5d5c90a2d6c799aad353b3302384b0a
+size 4829
diff --git a/selfdrive/assets/icons_mici/onroad/eye_orange.png b/selfdrive/assets/icons_mici/onroad/eye_orange.png
new file mode 100644
index 0000000000..b61b9b063c
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/eye_orange.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:88b2ecf3a9834d2b156bb632ec2090d7dc112e8ab61711ba645c03489d1c457f
+size 29157
diff --git a/selfdrive/assets/icons_mici/onroad/glasses.png b/selfdrive/assets/icons_mici/onroad/glasses.png
new file mode 100644
index 0000000000..1ac4442f49
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/glasses.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:28c95c8970648d40b35b94724936a9ab7a6f4cbca367a40f01b86f9abedc70e5
+size 1587
diff --git a/selfdrive/assets/icons_mici/onroad/onroad_fade.png b/selfdrive/assets/icons_mici/onroad/onroad_fade.png
new file mode 100644
index 0000000000..bc12e57e17
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/onroad_fade.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d2a2cb4db429467783d7f721ffbed7838551e4aabf32771e73759c87b4a67bca
+size 28880
diff --git a/selfdrive/assets/icons_mici/onroad/turn_signal_left.png b/selfdrive/assets/icons_mici/onroad/turn_signal_left.png
new file mode 100644
index 0000000000..48f52ff9ce
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/turn_signal_left.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0e845a211cf5d03f781efdd6eec4f8106e8dd85799ea59b51834a9099b479141
+size 30348
diff --git a/selfdrive/assets/icons_mici/onroad/turn_signal_right.png b/selfdrive/assets/icons_mici/onroad/turn_signal_right.png
new file mode 100644
index 0000000000..87ca979fbe
--- /dev/null
+++ b/selfdrive/assets/icons_mici/onroad/turn_signal_right.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:009005539f14acc29a4f5510b4e9531d2ba3667133644f6e0069c12b08ba0fd9
+size 35370
diff --git a/selfdrive/assets/icons_mici/settings.png b/selfdrive/assets/icons_mici/settings.png
new file mode 100644
index 0000000000..e668ed1fe4
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:38a52171bdc6feb3ddfd2d9f9e59db3dabd09fa0aafbc9f81137c59bd03b7c26
+size 2321
diff --git a/selfdrive/assets/icons_mici/settings/comma_icon.png b/selfdrive/assets/icons_mici/settings/comma_icon.png
new file mode 100644
index 0000000000..72a7c8c8f9
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/comma_icon.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:10f469a6f5d25d9e2b0b1aae51b4fbd06d2c7b8417613bb321c2a30bb7298dab
+size 1392
diff --git a/selfdrive/assets/icons_mici/settings/developer/ssh.png b/selfdrive/assets/icons_mici/settings/developer/ssh.png
new file mode 100644
index 0000000000..cd86937aea
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/developer/ssh.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c655994336b7da4ca986c6f27494bcab66e77f016ec9db8df271de53ed93e517
+size 1328
diff --git a/selfdrive/assets/icons_mici/settings/developer_icon.png b/selfdrive/assets/icons_mici/settings/developer_icon.png
new file mode 100644
index 0000000000..af16c02912
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/developer_icon.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a1f058c5640bd763d2f6927432a1daff1587770ea0d06f2e351a28462e9d8335
+size 1743
diff --git a/selfdrive/assets/icons_mici/settings/device/cameras.png b/selfdrive/assets/icons_mici/settings/device/cameras.png
new file mode 100644
index 0000000000..c44c511275
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/cameras.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:77a1281979f0b50f0e109ead56a88a33b81ef5901dd1a4537eb3fa048e0d90de
+size 1345
diff --git a/selfdrive/assets/icons_mici/settings/device/fcc_logo.png b/selfdrive/assets/icons_mici/settings/device/fcc_logo.png
new file mode 100644
index 0000000000..f29b24fd09
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/fcc_logo.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3cac8546d19e75a9edcbc0721a887fd74c8a3c41bfe19e36186b2b2bcabdae98
+size 1817
diff --git a/selfdrive/assets/icons_mici/settings/device/info.png b/selfdrive/assets/icons_mici/settings/device/info.png
new file mode 100644
index 0000000000..cb16320693
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/info.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2649d36259700d32a0edef878647e76492b1bec2fe34ac8ea806d4e7e4c57855
+size 2668
diff --git a/selfdrive/assets/icons_mici/settings/device/language.png b/selfdrive/assets/icons_mici/settings/device/language.png
new file mode 100644
index 0000000000..f6d57b3134
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/language.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4b982ac1b78b45487490d1dbbffed1f68735f6a35def502e882f706c30683aff
+size 3664
diff --git a/selfdrive/assets/icons_mici/settings/device/lkas.png b/selfdrive/assets/icons_mici/settings/device/lkas.png
new file mode 100644
index 0000000000..186ea78fb9
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/lkas.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ab6aeb6cba94acf948a0ad64a485db00bf1f3de1360ae4c57212f3f083b2bd24
+size 2554
diff --git a/selfdrive/assets/icons_mici/settings/device/pair.png b/selfdrive/assets/icons_mici/settings/device/pair.png
new file mode 100644
index 0000000000..f072b2363f
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/pair.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ed671f4ad1523f0e66498af39e6075a0c19842ae05eddd00871a6e48ed3685d7
+size 1594
diff --git a/selfdrive/assets/icons_mici/settings/device/power.png b/selfdrive/assets/icons_mici/settings/device/power.png
new file mode 100644
index 0000000000..a2de14a4e8
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/power.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5b45645ad9ff27776fdb1caa27827c526cae57f8bd4e23bd1160cb0094121ff2
+size 2338
diff --git a/selfdrive/assets/icons_mici/settings/device/reboot.png b/selfdrive/assets/icons_mici/settings/device/reboot.png
new file mode 100644
index 0000000000..6c89cd9fc2
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/reboot.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f24039f82d7399d02a155022de65b6dc3b8edcf17059a73a9fd3a9209e3f5575
+size 2360
diff --git a/selfdrive/assets/icons_mici/settings/device/uninstall.png b/selfdrive/assets/icons_mici/settings/device/uninstall.png
new file mode 100644
index 0000000000..f9173711eb
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/uninstall.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:558ea538fb258079f9eb05fe048b2806c7635b9f0452af874b00cb8d79b45f9b
+size 2421
diff --git a/selfdrive/assets/icons_mici/settings/device/up_to_date.png b/selfdrive/assets/icons_mici/settings/device/up_to_date.png
new file mode 100644
index 0000000000..ee925458d3
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/up_to_date.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4510e65775c6001758ebcf4dc13e9fa561cce5159d1fd54fbb506f22d3c7bdf3
+size 3149
diff --git a/selfdrive/assets/icons_mici/settings/device/update.png b/selfdrive/assets/icons_mici/settings/device/update.png
new file mode 100644
index 0000000000..cc05931b03
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device/update.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c6137349218ea22adba44f46a096afe2efc35536b2251192ed0ea61be443a3c5
+size 2493
diff --git a/selfdrive/assets/icons_mici/settings/device_icon.png b/selfdrive/assets/icons_mici/settings/device_icon.png
new file mode 100644
index 0000000000..0caf0d07ce
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/device_icon.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:db20bea98259b204be634ce0d9a23fbfdcfc73a324fc0aac0f9ac54e1c51556d
+size 2443
diff --git a/selfdrive/assets/icons_mici/settings/keyboard/backspace.png b/selfdrive/assets/icons_mici/settings/keyboard/backspace.png
new file mode 100644
index 0000000000..342f8e28da
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/keyboard/backspace.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:116bbbd1509e6644f7b65b8dacd2402b0918785bd80207504a99ab7e13ab738f
+size 2049
diff --git a/selfdrive/assets/icons_mici/settings/keyboard/caps_lock.png b/selfdrive/assets/icons_mici/settings/keyboard/caps_lock.png
new file mode 100644
index 0000000000..d63cc56fbc
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/keyboard/caps_lock.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3e8c7fec57640de6bfa8d0ede977e40920a8e651b68ed14e3d6c1850e702f3e3
+size 1399
diff --git a/selfdrive/assets/icons_mici/settings/keyboard/caps_lower.png b/selfdrive/assets/icons_mici/settings/keyboard/caps_lower.png
new file mode 100644
index 0000000000..eb38934302
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/keyboard/caps_lower.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b7dab3af28938e9c3ad7b6c3b60526bb76498b0103c7276d90c4bff3622f07d0
+size 1157
diff --git a/selfdrive/assets/icons_mici/settings/keyboard/caps_upper.png b/selfdrive/assets/icons_mici/settings/keyboard/caps_upper.png
new file mode 100644
index 0000000000..4a2cae6c8a
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/keyboard/caps_upper.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0c5a88a0e8e810115b6d497d3e230d866bd96a715ddac632f48c78b40e1df702
+size 1059
diff --git a/selfdrive/assets/icons_mici/settings/keyboard/confirm.png b/selfdrive/assets/icons_mici/settings/keyboard/confirm.png
new file mode 100644
index 0000000000..09b180e97f
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/keyboard/confirm.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:32ce109a9fe4814bb9bed88f67d85292791f4a6d7c162e07561920221ac38b2d
+size 1411
diff --git a/selfdrive/assets/icons_mici/settings/keyboard/keyboard_background.png b/selfdrive/assets/icons_mici/settings/keyboard/keyboard_background.png
new file mode 100644
index 0000000000..8c2c068d41
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/keyboard/keyboard_background.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:399e7ff9dea6710244827c91014f1a08d8ae989dce922928d6b7f7504b15ba79
+size 11321
diff --git a/selfdrive/assets/icons_mici/settings/keyboard/space.png b/selfdrive/assets/icons_mici/settings/keyboard/space.png
new file mode 100644
index 0000000000..778d1847d7
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/keyboard/space.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9b04d17f3b0340a94210efa5c9547e0ac340dd6b6dd9ac1f81ba5eb3f89f405d
+size 619
diff --git a/selfdrive/assets/icons_mici/settings/manual_icon.png b/selfdrive/assets/icons_mici/settings/manual_icon.png
new file mode 100644
index 0000000000..100b29da45
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/manual_icon.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:957330e9fbc8c03f05dbef8097178a40efc0fc52a6faf7a9917f97046d9a5e99
+size 1559
diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_full.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_full.png
new file mode 100644
index 0000000000..4bf0cd8726
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/cell_strength_full.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6a981d5c5558859b283cb6321c84eec947f82fc2dea8dbdd19b66781e4d3f61f
+size 1060
diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_high.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_high.png
new file mode 100644
index 0000000000..df6d009335
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/cell_strength_high.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:58da16ede432cf89096c11dc0f4ea098735863fb09a1d655cb06de8a112bd263
+size 1205
diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_low.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_low.png
new file mode 100644
index 0000000000..c3323a9fea
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/cell_strength_low.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:031bbd50c34d8fd5e71bdc292ba3e50b28a13c56a48dc84117723f1b35b42f51
+size 1224
diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_medium.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_medium.png
new file mode 100644
index 0000000000..64ab947c53
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/cell_strength_medium.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ccb5f2227c72dd28e40c9f19965abe007cbd7b47cdca924907dc9fad906f5c81
+size 1219
diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_none.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_none.png
new file mode 100644
index 0000000000..6cdef706bd
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/cell_strength_none.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:92c195721fe2b4ca42176077bf4ca3484cdfc314e961f1431b2296476bcae891
+size 1178
diff --git a/selfdrive/assets/icons_mici/settings/network/new/connect_button.png b/selfdrive/assets/icons_mici/settings/network/new/connect_button.png
new file mode 100644
index 0000000000..eae5af77f0
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/connect_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:04236fa0f2759a01c6e321ac7b1c86c7a039215a7953b1a23d250ecf2ef1fa87
+size 8563
diff --git a/selfdrive/assets/icons_mici/settings/network/new/connect_button_pressed.png b/selfdrive/assets/icons_mici/settings/network/new/connect_button_pressed.png
new file mode 100644
index 0000000000..0da6c384d9
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/connect_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4337098554af30c98ebd512e17ab08207db868ff34acca5f865fcbfc940286d3
+size 21123
diff --git a/selfdrive/assets/icons_mici/settings/network/new/forget_button.png b/selfdrive/assets/icons_mici/settings/network/new/forget_button.png
new file mode 100644
index 0000000000..541433be76
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/forget_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6ccb5f2298389ae36df87de84d85440ee5a82c50e803c9bd362c9b89ea45aa69
+size 6611
diff --git a/selfdrive/assets/icons_mici/settings/network/new/forget_button_pressed.png b/selfdrive/assets/icons_mici/settings/network/new/forget_button_pressed.png
new file mode 100644
index 0000000000..26cc8b4fca
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/forget_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d9f17c82b2f349d107d27c69418f054be1f1753f970c7d3d3520c1e65de00511
+size 12894
diff --git a/selfdrive/assets/icons_mici/settings/network/new/full_connect_button.png b/selfdrive/assets/icons_mici/settings/network/new/full_connect_button.png
new file mode 100644
index 0000000000..905170fd10
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/full_connect_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ffd37d5e5d5980efa98fee1cd0e8ebbf4139149b41c099e7dc3d5bd402cffb92
+size 9072
diff --git a/selfdrive/assets/icons_mici/settings/network/new/full_connect_button_pressed.png b/selfdrive/assets/icons_mici/settings/network/new/full_connect_button_pressed.png
new file mode 100644
index 0000000000..88eb4ac2a3
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/full_connect_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1b1d58704f8808dcb5a7ce9d86bc4212477759e96ac2419475f16f9184ee6a42
+size 21892
diff --git a/selfdrive/assets/icons_mici/settings/network/new/lock.png b/selfdrive/assets/icons_mici/settings/network/new/lock.png
new file mode 100644
index 0000000000..0a0b18c7a9
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/lock.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:40dbbb3000e1137ec11fe658fbfebae7cadfc91356953317335f9bb70fcb40d3
+size 1235
diff --git a/selfdrive/assets/icons_mici/settings/network/new/trash.png b/selfdrive/assets/icons_mici/settings/network/new/trash.png
new file mode 100644
index 0000000000..99e1a2e246
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/trash.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:efabf98ed66fe4447c0f13c74aec681b084de780c551ce18258c79636d4123c5
+size 1524
diff --git a/selfdrive/assets/icons_mici/settings/network/new/wifi_selected.png b/selfdrive/assets/icons_mici/settings/network/new/wifi_selected.png
new file mode 100644
index 0000000000..2a3e837138
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/new/wifi_selected.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:160f67162e075436200d6719e614ddf96caaa2b7c0a3943f728c2afef10aa4ad
+size 2489
diff --git a/selfdrive/assets/icons_mici/settings/network/tethering.png b/selfdrive/assets/icons_mici/settings/network/tethering.png
new file mode 100644
index 0000000000..9e7b90be41
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/tethering.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2907ce46d1b6e676402f390c530955b65e76baf0b77fafc0616c50b988b3994c
+size 1609
diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_full.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_full.png
new file mode 100644
index 0000000000..1a1655fddc
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/wifi_strength_full.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f2715ea698eccb3648ab96cbddf897ea1842acbc1eb9667bc6f34aba82d0896b
+size 1976
diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_low.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_low.png
new file mode 100644
index 0000000000..4d64d8062f
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/wifi_strength_low.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:58d839402c6f002ba8d2217888190b338fc3ac13d372df0988fac7bf95b89302
+size 2111
diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_medium.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_medium.png
new file mode 100644
index 0000000000..2d53a20cef
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/wifi_strength_medium.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a9918724409dbfa1973a097a692c2f57e45cc2bc0ce71c498ef3e02aa82559d3
+size 2128
diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_none.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_none.png
new file mode 100644
index 0000000000..482a0e1042
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/wifi_strength_none.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3fcef95eb18e2db566b907ae99b8d8f450424b3b7823fdc24cdfe066ccf64378
+size 2141
diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_slash.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_slash.png
new file mode 100644
index 0000000000..38ddff84b7
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/network/wifi_strength_slash.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:73e4ae4741a039f41d79827c40be6da83f8c6eb79e9103db2dfec718ca96efb7
+size 2512
diff --git a/selfdrive/assets/icons_mici/settings/toggles_icon.png b/selfdrive/assets/icons_mici/settings/toggles_icon.png
new file mode 100644
index 0000000000..ccb343e8ed
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/toggles_icon.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0297535eb73bea71e87c363dc12385bb9163b81403797e50966b20259f725542
+size 2528
diff --git a/selfdrive/assets/icons_mici/settings/vertical_scroll_indicator.png b/selfdrive/assets/icons_mici/settings/vertical_scroll_indicator.png
new file mode 100644
index 0000000000..77d9a77d6f
--- /dev/null
+++ b/selfdrive/assets/icons_mici/settings/vertical_scroll_indicator.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:88e6c50358f627fc714c1e9883143aeed00baabeab16132e16001aa1051e5eb8
+size 1272
diff --git a/selfdrive/assets/icons_mici/setup/back_new.png b/selfdrive/assets/icons_mici/setup/back_new.png
new file mode 100644
index 0000000000..c4834a5649
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/back_new.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7198352d23952d0f2fbc128f20523ea6f2f2b7e378aa495da748a0e34f192806
+size 1641
diff --git a/selfdrive/assets/icons_mici/setup/green_button.png b/selfdrive/assets/icons_mici/setup/green_button.png
new file mode 100644
index 0000000000..9708cfe284
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/green_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:163ac31cb990bdddfe552efef9a68870404caadb1c40fa8a5042b5ae956e6b4c
+size 24687
diff --git a/selfdrive/assets/icons_mici/setup/green_button_pressed.png b/selfdrive/assets/icons_mici/setup/green_button_pressed.png
new file mode 100644
index 0000000000..030ce61d5b
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/green_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6e4614adb2d3d0e44c64a855c221ec462a7aee22fff26132ad551035141c1a53
+size 62056
diff --git a/selfdrive/assets/icons_mici/setup/green_car.png b/selfdrive/assets/icons_mici/setup/green_car.png
new file mode 100644
index 0000000000..867cadbbd6
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/green_car.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ce8a34777e0b185f457b98845aa17fe6b5192ca46101463aecd21a9e04c0f0f0
+size 13281
diff --git a/selfdrive/assets/icons_mici/setup/green_dm.png b/selfdrive/assets/icons_mici/setup/green_dm.png
new file mode 100644
index 0000000000..d41edd4c2a
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/green_dm.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:78795eaa5e0be5fa369e172c02f5bd4b06d20f44363ccb8cbd02cb181b13e529
+size 14289
diff --git a/selfdrive/assets/icons_mici/setup/green_info.png b/selfdrive/assets/icons_mici/setup/green_info.png
new file mode 100644
index 0000000000..309e56e6ee
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/green_info.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2b0b1777d5bed7149982af9f2abab3fab7b6c576e3d53cf2c459804c6ec9ca1e
+size 3957
diff --git a/selfdrive/assets/icons_mici/setup/green_pedal.png b/selfdrive/assets/icons_mici/setup/green_pedal.png
new file mode 100644
index 0000000000..2dd18f489a
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/green_pedal.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6cadcda59bc861a1e710e0a8ac67024bdcc44b5f9261abbf098ff11cefb1da51
+size 12209
diff --git a/selfdrive/assets/icons_mici/setup/medium_button_bg.png b/selfdrive/assets/icons_mici/setup/medium_button_bg.png
new file mode 100644
index 0000000000..e79dc2eb58
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/medium_button_bg.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9e363a79dc35ca4c4e9efaa6a843d37ad219efa5299d3e538d8249affa230096
+size 7935
diff --git a/selfdrive/assets/icons_mici/setup/medium_button_pressed_bg.png b/selfdrive/assets/icons_mici/setup/medium_button_pressed_bg.png
new file mode 100644
index 0000000000..e52fb0c17d
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/medium_button_pressed_bg.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cc6fb48520143b6fa1f060d8212e6d929917ab616ce943b5fab5a60665f00da5
+size 18225
diff --git a/selfdrive/assets/icons_mici/setup/red_warning.png b/selfdrive/assets/icons_mici/setup/red_warning.png
new file mode 100644
index 0000000000..ed0634079b
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/red_warning.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:448d3e7214a77b02b32020ddb440ccd8fe72e110493a51cc10901c8242e72ca8
+size 3185
diff --git a/selfdrive/assets/icons_mici/setup/reset/small_button.png b/selfdrive/assets/icons_mici/setup/reset/small_button.png
new file mode 100644
index 0000000000..e3f58b1078
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/reset/small_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7a198f13f30b3dbc09f30d7fd8033a0bc07a0da9b010b7ca6ed2678430c9e5b4
+size 6949
diff --git a/selfdrive/assets/icons_mici/setup/reset/small_button_pressed.png b/selfdrive/assets/icons_mici/setup/reset/small_button_pressed.png
new file mode 100644
index 0000000000..5b502e00aa
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/reset/small_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:75289d004709def2a2d6101a0330ec867895068ec3807aefc2a26d423d907a13
+size 13437
diff --git a/selfdrive/assets/icons_mici/setup/reset/wide_button.png b/selfdrive/assets/icons_mici/setup/reset/wide_button.png
new file mode 100644
index 0000000000..3892f6eb8c
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/reset/wide_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2452aaf59da18be1b74b475851d66e5c73c50aa49820419a288b1fdb7b42dee1
+size 9071
diff --git a/selfdrive/assets/icons_mici/setup/reset/wide_button_pressed.png b/selfdrive/assets/icons_mici/setup/reset/wide_button_pressed.png
new file mode 100644
index 0000000000..3a34af8846
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/reset/wide_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6478f7c1c5ef2013e94fc4218ab370889883c5c12231ba3e0975874cb0b6fec9
+size 21893
diff --git a/selfdrive/assets/icons_mici/setup/restore.png b/selfdrive/assets/icons_mici/setup/restore.png
new file mode 100644
index 0000000000..6aa6c6b851
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/restore.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9d6b99696163cac1867d46998af9e53e212b82641b33c93b51276671f400a5ac
+size 2962
diff --git a/selfdrive/assets/icons_mici/setup/scroll_down_indicator.png b/selfdrive/assets/icons_mici/setup/scroll_down_indicator.png
new file mode 100644
index 0000000000..4d74d86075
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/scroll_down_indicator.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:52535e34e27b0341f7690a72dc16555eeb6e032bc2c2cde0786469852fdf5987
+size 1267
diff --git a/selfdrive/assets/icons_mici/setup/small_button.png b/selfdrive/assets/icons_mici/setup/small_button.png
new file mode 100644
index 0000000000..1ee01aeac2
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:13919cf5df3137fdffdb8cc53a1215f13bf478a780ca8614234b7af0cdc0e766
+size 5409
diff --git a/selfdrive/assets/icons_mici/setup/small_button_pressed.png b/selfdrive/assets/icons_mici/setup/small_button_pressed.png
new file mode 100644
index 0000000000..6e30f47fba
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c032fd71ccfebe161827de420771e7927fe1ed799e615e24d458cfd79fead7f7
+size 7875
diff --git a/selfdrive/assets/icons_mici/setup/small_red_pill.png b/selfdrive/assets/icons_mici/setup/small_red_pill.png
new file mode 100644
index 0000000000..4a7db930a0
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_red_pill.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b3a336afddad80dc91caca91d54bd29897ce491f180374edf9a5ba517cbc00e9
+size 8765
diff --git a/selfdrive/assets/icons_mici/setup/small_red_pill_pressed.png b/selfdrive/assets/icons_mici/setup/small_red_pill_pressed.png
new file mode 100644
index 0000000000..a8d51960c4
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_red_pill_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8eee9f10ca80a4e6100c00c02bb46aa5f253b14b086ab9982cfa85ee94eec162
+size 22512
diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow.png
new file mode 100644
index 0000000000..bbf1d96254
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8425c56cb413ba757c94febe0332ce472dbf1472236b03cc4e627746fb86d701
+size 1149
diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_bg.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_bg.png
new file mode 100644
index 0000000000..43c10a54ad
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_bg.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:94a86fac6ffe8a8179812cf55350ab9ca6935f36244c6f679c1cf521a842316b
+size 5723
diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_bg_larger.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_bg_larger.png
new file mode 100644
index 0000000000..11c3ae2d3f
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_bg_larger.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:950d55fd7294fb05c10ba9944537c02637776497c159e1b7d145c73f0f9d3253
+size 7119
diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_black_rounded_rectangle.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_black_rounded_rectangle.png
new file mode 100644
index 0000000000..683587a060
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_black_rounded_rectangle.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:61281d3e3ef5ac5a8fe75405a93c2096bf235f090b27832e986444e3fb85715e
+size 7427
diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle.png
new file mode 100644
index 0000000000..9ebff76b50
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bcd08444c77b3e559876eeb88d17808f72496adc26e27c3c21c00ff410879447
+size 10966
diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle.png
new file mode 100644
index 0000000000..541433be76
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6ccb5f2298389ae36df87de84d85440ee5a82c50e803c9bd362c9b89ea45aa69
+size 6611
diff --git a/selfdrive/assets/icons_mici/setup/smaller_button.png b/selfdrive/assets/icons_mici/setup/smaller_button.png
new file mode 100644
index 0000000000..9b4851c568
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/smaller_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:89ca7e6bb01dfa78300126ce828cb2a64e7a2e68e1e9152de242f57a36d0e57a
+size 8604
diff --git a/selfdrive/assets/icons_mici/setup/smaller_button_disabled.png b/selfdrive/assets/icons_mici/setup/smaller_button_disabled.png
new file mode 100644
index 0000000000..6514791de7
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/smaller_button_disabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b3242a411b559f1d0308f189fe0d25b81d6c7d964ca418a0c599a1bab4bffcbb
+size 5341
diff --git a/selfdrive/assets/icons_mici/setup/smaller_button_pressed.png b/selfdrive/assets/icons_mici/setup/smaller_button_pressed.png
new file mode 100644
index 0000000000..64235b3a2f
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/smaller_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d354651c0c8107dcc5f599777d260f53ef1901123315785ed8190466166cdce8
+size 17554
diff --git a/selfdrive/assets/icons_mici/setup/warning.png b/selfdrive/assets/icons_mici/setup/warning.png
new file mode 100644
index 0000000000..806eea28b7
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/warning.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3bc7a85a0672183d80817f337084060465e143362037955025c11bc8ac531076
+size 3247
diff --git a/selfdrive/assets/icons_mici/setup/widish_button.png b/selfdrive/assets/icons_mici/setup/widish_button.png
new file mode 100644
index 0000000000..529b7c80cc
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/widish_button.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:74fc21132b1e761ea54ce64617730c6ee79d01668244ab555b3b89870cfea181
+size 7112
diff --git a/selfdrive/assets/icons_mici/setup/widish_button_disabled.png b/selfdrive/assets/icons_mici/setup/widish_button_disabled.png
new file mode 100644
index 0000000000..5028a8cd21
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/widish_button_disabled.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9728423bd5e3197ef02d62e4bae415e6694aab875ca8630ffc9f188c38e18e5f
+size 4141
diff --git a/selfdrive/assets/icons_mici/setup/widish_button_pressed.png b/selfdrive/assets/icons_mici/setup/widish_button_pressed.png
new file mode 100644
index 0000000000..1095d4fc23
--- /dev/null
+++ b/selfdrive/assets/icons_mici/setup/widish_button_pressed.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0ff179f93f421edcb503ca5c22a12b37e3a2aaabc414bf90f57e20ff5255dd75
+size 15572
diff --git a/selfdrive/assets/icons_mici/turn_intent_left.png b/selfdrive/assets/icons_mici/turn_intent_left.png
new file mode 100644
index 0000000000..6c2c47e882
--- /dev/null
+++ b/selfdrive/assets/icons_mici/turn_intent_left.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ead8287b7041c32456e13721c238a71933256ca3d2b7e649c8f8731585eb5de8
+size 906
diff --git a/selfdrive/assets/icons_mici/turn_intent_right.png b/selfdrive/assets/icons_mici/turn_intent_right.png
new file mode 100644
index 0000000000..03a7245e76
--- /dev/null
+++ b/selfdrive/assets/icons_mici/turn_intent_right.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6fe0532f7040aae78baa85c4cca44f5c939adb6a6f15889e2ca036f4a493f848
+size 935
diff --git a/selfdrive/assets/icons_mici/wheel.png b/selfdrive/assets/icons_mici/wheel.png
new file mode 100644
index 0000000000..f122349b82
--- /dev/null
+++ b/selfdrive/assets/icons_mici/wheel.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cc3ef0c8c3038d75f99df2c565a361107bc903944d1afe91de0cbed9f6ca062a
+size 2725
diff --git a/selfdrive/assets/icons_mici/wheel_critical.png b/selfdrive/assets/icons_mici/wheel_critical.png
new file mode 100644
index 0000000000..c0e5e8619e
--- /dev/null
+++ b/selfdrive/assets/icons_mici/wheel_critical.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:12783dc05ea6dae2647ac3a3a7c8391d520c3f0cf2f458333a357ee9633eb6c4
+size 10909
diff --git a/selfdrive/assets/offroad/mici_fcc.html b/selfdrive/assets/offroad/mici_fcc.html
new file mode 100644
index 0000000000..e6e4189128
--- /dev/null
+++ b/selfdrive/assets/offroad/mici_fcc.html
@@ -0,0 +1,16 @@
+
HVIN: comma four
+FCC ID: 2BFC6-MICI
+IC: 32232-MICI
+Contains FCC ID: XMR2023EG916QGL
+Contains IC: 10224A-023EG916QGL
+
+This device contains licence-exempt transmitter(s)/receiver(s) that comply with Innovation, Science and Economic Development
+Canada's licence-exempt RSS(s) and complies with part 15 of the FCC Rules. Operation is subject to the following two conditions:
+1. This device may not cause harmful interference.
+2. This device must accept any interference received, including interference that may cause undesired operation of the device.
+
+L'émetteur/récepteur exempt de licence contenu dans le présent appareil est conforme aux CNR d'Innovation, Sciences
+et Développement économique Canada applicables aux appareils radio exempts de licence. L'exploitation est autorisée
+aux deux conditions suivantes :
+1. L'appareil ne doit pas produire de brouillage.
+2. L'appareil doit accepter tout brouillage radioélectrique subi, même si le brouillage est susceptible d'en compromettre
diff --git a/selfdrive/assets/sounds/disengage.wav b/selfdrive/assets/sounds/disengage.wav
index f3b5f21a27..8983884b25 100644
--- a/selfdrive/assets/sounds/disengage.wav
+++ b/selfdrive/assets/sounds/disengage.wav
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:1f061777d66d8d856a5fcb17378416f959088885ed770181355195ad15de881b
-size 68628
+oid sha256:c94582be9d921146b3c356e08a7352700c309cb407877c1180542811b2d637fa
+size 48078
diff --git a/selfdrive/assets/sounds/disengage_tizi.wav b/selfdrive/assets/sounds/disengage_tizi.wav
new file mode 100644
index 0000000000..f3b5f21a27
--- /dev/null
+++ b/selfdrive/assets/sounds/disengage_tizi.wav
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1f061777d66d8d856a5fcb17378416f959088885ed770181355195ad15de881b
+size 68628
diff --git a/selfdrive/assets/sounds/engage.wav b/selfdrive/assets/sounds/engage.wav
index fc24a23c2f..39d4c749c8 100644
--- a/selfdrive/assets/sounds/engage.wav
+++ b/selfdrive/assets/sounds/engage.wav
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6b54a85cc09b8ee79fce23d48209205d2e70510eed4c5a86fa400fe3f7caebfd
-size 63120
+oid sha256:bc2b12bfe816a79307660b6b3d2de87a7643c6ccbfc9d1b33804645ad717682a
+size 48078
diff --git a/selfdrive/assets/sounds/engage_tizi.wav b/selfdrive/assets/sounds/engage_tizi.wav
new file mode 100644
index 0000000000..fc24a23c2f
--- /dev/null
+++ b/selfdrive/assets/sounds/engage_tizi.wav
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6b54a85cc09b8ee79fce23d48209205d2e70510eed4c5a86fa400fe3f7caebfd
+size 63120
diff --git a/selfdrive/modeld/SConscript b/selfdrive/modeld/SConscript
index f20855c2cb..1fb8d4f7c8 100644
--- a/selfdrive/modeld/SConscript
+++ b/selfdrive/modeld/SConscript
@@ -32,7 +32,7 @@ lenvCython.Program('models/commonmodel_pyx.so', 'models/commonmodel_pyx.pyx', LI
tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "/**", recursive=True, root_dir=env.Dir("#").abspath) if 'pycache' not in x]
# Get model metadata
-for model_name in ['driving_vision', 'driving_policy']:
+for model_name in ['driving_vision', 'driving_policy', 'dmonitoring_model']:
fn = File(f"models/{model_name}").abspath
script_files = [File(Dir("#selfdrive/modeld").File("get_model_metadata.py").abspath)]
cmd = f'python3 {Dir("#selfdrive/modeld").abspath}/get_model_metadata.py {fn}.onnx'
diff --git a/selfdrive/modeld/dmonitoringmodeld.py b/selfdrive/modeld/dmonitoringmodeld.py
index 2851a3e7da..fca762c69b 100755
--- a/selfdrive/modeld/dmonitoringmodeld.py
+++ b/selfdrive/modeld/dmonitoringmodeld.py
@@ -4,10 +4,8 @@ from openpilot.system.hardware import TICI
os.environ['DEV'] = 'QCOM' if TICI else 'CPU'
from tinygrad.tensor import Tensor
from tinygrad.dtype import dtypes
-import math
import time
import pickle
-import ctypes
import numpy as np
from pathlib import Path
@@ -16,47 +14,16 @@ from cereal.messaging import PubMaster, SubMaster
from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf
from openpilot.common.swaglog import cloudlog
from openpilot.common.realtime import config_realtime_process
-from openpilot.common.transformations.model import dmonitoringmodel_intrinsics, DM_INPUT_SIZE
+from openpilot.common.transformations.model import dmonitoringmodel_intrinsics
from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye
from openpilot.selfdrive.modeld.models.commonmodel_pyx import CLContext, MonitoringModelFrame
-from openpilot.selfdrive.modeld.parse_model_outputs import sigmoid
+from openpilot.selfdrive.modeld.parse_model_outputs import sigmoid, safe_exp
from openpilot.selfdrive.modeld.runners.tinygrad_helpers import qcom_tensor_from_opencl_address
-MODEL_WIDTH, MODEL_HEIGHT = DM_INPUT_SIZE
-CALIB_LEN = 3
-FEATURE_LEN = 512
-OUTPUT_SIZE = 83 + FEATURE_LEN
-
PROCESS_NAME = "selfdrive.modeld.dmonitoringmodeld"
SEND_RAW_PRED = os.getenv('SEND_RAW_PRED')
MODEL_PKL_PATH = Path(__file__).parent / 'models/dmonitoring_model_tinygrad.pkl'
-
-# TODO: slice from meta
-class DriverStateResult(ctypes.Structure):
- _fields_ = [
- ("face_orientation", ctypes.c_float*3),
- ("face_position", ctypes.c_float*3),
- ("face_orientation_std", ctypes.c_float*3),
- ("face_position_std", ctypes.c_float*3),
- ("face_prob", ctypes.c_float),
- ("_unused_a", ctypes.c_float*8),
- ("left_eye_prob", ctypes.c_float),
- ("_unused_b", ctypes.c_float*8),
- ("right_eye_prob", ctypes.c_float),
- ("left_blink_prob", ctypes.c_float),
- ("right_blink_prob", ctypes.c_float),
- ("sunglasses_prob", ctypes.c_float),
- ("_unused_c", ctypes.c_float),
- ("_unused_d", ctypes.c_float*4),
- ("not_ready_prob", ctypes.c_float*2)]
-
-
-class DMonitoringModelResult(ctypes.Structure):
- _fields_ = [
- ("driver_state_lhd", DriverStateResult),
- ("driver_state_rhd", DriverStateResult),
- ("wheel_on_right_prob", ctypes.c_float),
- ("features", ctypes.c_float*FEATURE_LEN)]
+METADATA_PATH = Path(__file__).parent / 'models/dmonitoring_model_metadata.pkl'
class ModelState:
@@ -64,11 +31,14 @@ class ModelState:
output: np.ndarray
def __init__(self, cl_ctx):
- assert ctypes.sizeof(DMonitoringModelResult) == OUTPUT_SIZE * ctypes.sizeof(ctypes.c_float)
+ with open(METADATA_PATH, 'rb') as f:
+ model_metadata = pickle.load(f)
+ self.input_shapes = model_metadata['input_shapes']
+ self.output_slices = model_metadata['output_slices']
self.frame = MonitoringModelFrame(cl_ctx)
self.numpy_inputs = {
- 'calib': np.zeros((1, CALIB_LEN), dtype=np.float32),
+ 'calib': np.zeros(self.input_shapes['calib'], dtype=np.float32),
}
self.tensor_inputs = {k: Tensor(v, device='NPY').realize() for k,v in self.numpy_inputs.items()}
@@ -84,9 +54,9 @@ class ModelState:
if TICI:
# The imgs tensors are backed by opencl memory, only need init once
if 'input_img' not in self.tensor_inputs:
- self.tensor_inputs['input_img'] = qcom_tensor_from_opencl_address(input_img_cl.mem_address, (1, MODEL_WIDTH*MODEL_HEIGHT), dtype=dtypes.uint8)
+ self.tensor_inputs['input_img'] = qcom_tensor_from_opencl_address(input_img_cl.mem_address, self.input_shapes['input_img'], dtype=dtypes.uint8)
else:
- self.tensor_inputs['input_img'] = Tensor(self.frame.buffer_from_cl(input_img_cl).reshape((1, MODEL_WIDTH*MODEL_HEIGHT)), dtype=dtypes.uint8).realize()
+ self.tensor_inputs['input_img'] = Tensor(self.frame.buffer_from_cl(input_img_cl).reshape(self.input_shapes['input_img']), dtype=dtypes.uint8).realize()
output = self.model_run(**self.tensor_inputs).contiguous().realize().uop.base.buffer.numpy()
@@ -94,32 +64,43 @@ class ModelState:
t2 = time.perf_counter()
return output, t2 - t1
+def slice_outputs(model_outputs, output_slices):
+ return {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()}
-def fill_driver_state(msg, ds_result: DriverStateResult):
- msg.faceOrientation = list(ds_result.face_orientation)
- msg.faceOrientationStd = [math.exp(x) for x in ds_result.face_orientation_std]
- msg.facePosition = list(ds_result.face_position[:2])
- msg.facePositionStd = [math.exp(x) for x in ds_result.face_position_std[:2]]
- msg.faceProb = float(sigmoid(ds_result.face_prob))
- msg.leftEyeProb = float(sigmoid(ds_result.left_eye_prob))
- msg.rightEyeProb = float(sigmoid(ds_result.right_eye_prob))
- msg.leftBlinkProb = float(sigmoid(ds_result.left_blink_prob))
- msg.rightBlinkProb = float(sigmoid(ds_result.right_blink_prob))
- msg.sunglassesProb = float(sigmoid(ds_result.sunglasses_prob))
- msg.notReadyProb = [float(sigmoid(x)) for x in ds_result.not_ready_prob]
+def parse_model_output(model_output):
+ parsed = {}
+ parsed['wheel_on_right'] = sigmoid(model_output['wheel_on_right'])
+ for ds_suffix in ['lhd', 'rhd']:
+ face_descs = model_output[f'face_descs_{ds_suffix}']
+ parsed[f'face_descs_{ds_suffix}'] = face_descs[:, :-6]
+ parsed[f'face_descs_{ds_suffix}_std'] = safe_exp(face_descs[:, -6:])
+ for key in ['face_prob', 'left_eye_prob', 'right_eye_prob','left_blink_prob', 'right_blink_prob', 'sunglasses_prob', 'using_phone_prob']:
+ parsed[f'{key}_{ds_suffix}'] = sigmoid(model_output[f'{key}_{ds_suffix}'])
+ return parsed
+def fill_driver_data(msg, model_output, ds_suffix):
+ msg.faceOrientation = model_output[f'face_descs_{ds_suffix}'][0, :3].tolist()
+ msg.faceOrientationStd = model_output[f'face_descs_{ds_suffix}_std'][0, :3].tolist()
+ msg.facePosition = model_output[f'face_descs_{ds_suffix}'][0, 3:5].tolist()
+ msg.facePositionStd = model_output[f'face_descs_{ds_suffix}_std'][0, 3:5].tolist()
+ msg.faceProb = model_output[f'face_prob_{ds_suffix}'][0, 0].item()
+ msg.leftEyeProb = model_output[f'left_eye_prob_{ds_suffix}'][0, 0].item()
+ msg.rightEyeProb = model_output[f'right_eye_prob_{ds_suffix}'][0, 0].item()
+ msg.leftBlinkProb = model_output[f'left_blink_prob_{ds_suffix}'][0, 0].item()
+ msg.rightBlinkProb = model_output[f'right_blink_prob_{ds_suffix}'][0, 0].item()
+ msg.sunglassesProb = model_output[f'sunglasses_prob_{ds_suffix}'][0, 0].item()
+ msg.phoneProb = model_output[f'using_phone_prob_{ds_suffix}'][0, 0].item()
-def get_driverstate_packet(model_output: np.ndarray, frame_id: int, location_ts: int, execution_time: float, gpu_execution_time: float):
- model_result = ctypes.cast(model_output.ctypes.data, ctypes.POINTER(DMonitoringModelResult)).contents
+def get_driverstate_packet(model_output, frame_id: int, location_ts: int, exec_time: float, gpu_exec_time: float):
msg = messaging.new_message('driverStateV2', valid=True)
ds = msg.driverStateV2
ds.frameId = frame_id
- ds.modelExecutionTime = execution_time
- ds.gpuExecutionTime = gpu_execution_time
- ds.wheelOnRightProb = float(sigmoid(model_result.wheel_on_right_prob))
- ds.rawPredictions = model_output.tobytes() if SEND_RAW_PRED else b''
- fill_driver_state(ds.leftDriverData, model_result.driver_state_lhd)
- fill_driver_state(ds.rightDriverData, model_result.driver_state_rhd)
+ ds.modelExecutionTime = exec_time
+ ds.gpuExecutionTime = gpu_exec_time
+ ds.rawPredictions = model_output['raw_pred']
+ ds.wheelOnRightProb = model_output['wheel_on_right'][0, 0].item()
+ fill_driver_data(ds.leftDriverData, model_output, 'lhd')
+ fill_driver_data(ds.rightDriverData, model_output, 'rhd')
return msg
@@ -140,7 +121,7 @@ def main():
sm = SubMaster(["liveCalibration"])
pm = PubMaster(["driverStateV2"])
- calib = np.zeros(CALIB_LEN, dtype=np.float32)
+ calib = np.zeros(model.numpy_inputs['calib'].size, dtype=np.float32)
model_transform = None
while True:
@@ -159,8 +140,12 @@ def main():
t1 = time.perf_counter()
model_output, gpu_execution_time = model.run(buf, calib, model_transform)
t2 = time.perf_counter()
-
- pm.send("driverStateV2", get_driverstate_packet(model_output, vipc_client.frame_id, vipc_client.timestamp_sof, t2 - t1, gpu_execution_time))
+ raw_pred = model_output.tobytes() if SEND_RAW_PRED else b''
+ model_output = slice_outputs(model_output, model.output_slices)
+ model_output = parse_model_output(model_output)
+ model_output['raw_pred'] = raw_pred
+ msg = get_driverstate_packet(model_output, vipc_client.frame_id, vipc_client.timestamp_sof, t2 - t1, gpu_execution_time)
+ pm.send("driverStateV2", msg)
if __name__ == "__main__":
diff --git a/selfdrive/modeld/models/dmonitoring_model.onnx b/selfdrive/modeld/models/dmonitoring_model.onnx
index 1b6a8c3e93..9b1c4a1834 100644
--- a/selfdrive/modeld/models/dmonitoring_model.onnx
+++ b/selfdrive/modeld/models/dmonitoring_model.onnx
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:3a53626ab84757813fb16a1441704f2ae7192bef88c331bdc2415be6981d204f
-size 7191776
+oid sha256:3446bf8b22e50e47669a25bf32460ae8baf8547037f346753e19ecbfcf6d4e59
+size 6954368
diff --git a/selfdrive/monitoring/dmonitoringd.py b/selfdrive/monitoring/dmonitoringd.py
index 71733f99fc..02e5aafa68 100755
--- a/selfdrive/monitoring/dmonitoringd.py
+++ b/selfdrive/monitoring/dmonitoringd.py
@@ -14,6 +14,7 @@ def dmonitoringd_thread():
'carControl'], poll='driverStateV2')
DM = DriverMonitoring(rhd_saved=params.get_bool("IsRhdDetected"), always_on=params.get_bool("AlwaysOnDM"))
+ demo_mode=False
# 20Hz <- dmonitoringmodeld
while True:
@@ -23,8 +24,10 @@ def dmonitoringd_thread():
continue
valid = sm.all_checks()
- if valid:
- DM.run_step(sm)
+ if demo_mode and sm.valid['driverStateV2']:
+ DM.run_step(sm, demo=demo_mode)
+ elif valid:
+ DM.run_step(sm, demo=demo_mode)
# publish
dat = DM.get_state_packet(valid=valid)
@@ -33,9 +36,10 @@ def dmonitoringd_thread():
# load live always-on toggle
if sm['driverStateV2'].frameId % 40 == 1:
DM.always_on = params.get_bool("AlwaysOnDM")
+ demo_mode = params.get_bool("IsDriverViewEnabled")
# save rhd virtual toggle every 5 mins
- if (sm['driverStateV2'].frameId % 6000 == 0 and
+ if (sm['driverStateV2'].frameId % 6000 == 0 and not demo_mode and
DM.wheelpos_learner.filtered_stat.n > DM.settings._WHEELPOS_FILTER_MIN_COUNT and
DM.wheel_on_right == (DM.wheelpos_learner.filtered_stat.M > DM.settings._WHEELPOS_THRESHOLD)):
params.put_bool_nonblocking("IsRhdDetected", DM.wheel_on_right)
diff --git a/selfdrive/monitoring/helpers.py b/selfdrive/monitoring/helpers.py
index d1630f0d5b..7697e68b98 100644
--- a/selfdrive/monitoring/helpers.py
+++ b/selfdrive/monitoring/helpers.py
@@ -21,7 +21,7 @@ EventName = log.OnroadEvent.EventName
# ******************************************************************************************
class DRIVER_MONITOR_SETTINGS:
- def __init__(self):
+ def __init__(self, device_type):
self._DT_DMON = DT_DMON
# ref (page15-16): https://eur-lex.europa.eu/legal-content/EN/TXT/PDF/?uri=CELEX:42018X1947&rid=2
self._AWARENESS_TIME = 30. # passive wheeltouch total timeout
@@ -36,13 +36,10 @@ class DRIVER_MONITOR_SETTINGS:
self._SG_THRESHOLD = 0.9
self._BLINK_THRESHOLD = 0.865
- if HARDWARE.get_device_type() == 'mici':
- self._EE_THRESH11 = 0.75
- else:
- self._EE_THRESH11 = 0.4
- self._EE_THRESH12 = 15.0
- self._EE_MAX_OFFSET1 = 0.06
- self._EE_MIN_OFFSET1 = 0.025
+ self._PHONE_THRESH = 0.75 if device_type == 'mici' else 0.4
+ self._PHONE_THRESH2 = 15.0
+ self._PHONE_MAX_OFFSET = 0.06
+ self._PHONE_MIN_OFFSET = 0.025
self._POSE_PITCH_THRESHOLD = 0.3133
self._POSE_PITCH_THRESHOLD_SLACK = 0.3237
@@ -84,7 +81,7 @@ class DistractedType:
NOT_DISTRACTED = 0
DISTRACTED_POSE = 1 << 0
DISTRACTED_BLINK = 1 << 1
- DISTRACTED_E2E = 1 << 2
+ DISTRACTED_PHONE = 1 << 2
class DriverPose:
def __init__(self, max_trackable):
@@ -101,6 +98,12 @@ class DriverPose:
self.cfactor_pitch = 1.
self.cfactor_yaw = 1.
+class DriverPhone:
+ def __init__(self, max_trackable):
+ self.prob = 0.
+ self.prob_offseter = RunningStatFilter(max_trackable=max_trackable)
+ self.prob_calibrated = False
+
class DriverBlink:
def __init__(self):
self.left = 0.
@@ -133,18 +136,14 @@ def face_orientation_from_net(angles_desc, pos_desc, rpy_calib):
class DriverMonitoring:
def __init__(self, rhd_saved=False, settings=None, always_on=False):
- if settings is None:
- settings = DRIVER_MONITOR_SETTINGS()
# init policy settings
- self.settings = settings
+ self.settings = settings if settings is not None else DRIVER_MONITOR_SETTINGS(device_type=HARDWARE.get_device_type())
# init driver status
self.wheelpos_learner = RunningStatFilter()
self.pose = DriverPose(self.settings._POSE_OFFSET_MAX_COUNT)
+ self.phone = DriverPhone(self.settings._POSE_OFFSET_MAX_COUNT)
self.blink = DriverBlink()
- self.eev1 = 0.
- self.ee1_offseter = RunningStatFilter(max_trackable=self.settings._POSE_OFFSET_MAX_COUNT)
- self.ee1_calibrated = False
self.always_on = always_on
self.distracted_types = []
@@ -211,8 +210,8 @@ class DriverMonitoring:
self.step_change = self.settings._DT_DMON / self.settings._AWARENESS_TIME
self.active_monitoring_mode = False
- def _set_policy(self, model_data, car_speed):
- bp = model_data.meta.disengagePredictions.brakeDisengageProbs[0] # brake disengage prob in next 2s
+ def _set_policy(self, brake_disengage_prob, car_speed):
+ bp = brake_disengage_prob
k1 = max(-0.00156*((car_speed-16)**2)+0.6, 0.2)
bp_normal = max(min(bp / k1, 0.5),0)
self.pose.cfactor_pitch = np.interp(bp_normal, [0, 0.5],
@@ -242,33 +241,32 @@ class DriverMonitoring:
if (self.blink.left + self.blink.right)*0.5 > self.settings._BLINK_THRESHOLD:
distracted_types.append(DistractedType.DISTRACTED_BLINK)
- if self.ee1_calibrated:
- ee1_dist = self.eev1 > max(min(self.ee1_offseter.filtered_stat.M, self.settings._EE_MAX_OFFSET1), self.settings._EE_MIN_OFFSET1) \
- * self.settings._EE_THRESH12
+ if self.phone.prob_calibrated:
+ using_phone = self.phone.prob > max(min(self.phone.prob_offseter.filtered_stat.M, self.settings._PHONE_MAX_OFFSET), self.settings._PHONE_MIN_OFFSET) \
+ * self.settings._PHONE_THRESH2
else:
- ee1_dist = self.eev1 > self.settings._EE_THRESH11
- if ee1_dist:
- distracted_types.append(DistractedType.DISTRACTED_E2E)
+ using_phone = self.phone.prob > self.settings._PHONE_THRESH
+ if using_phone:
+ distracted_types.append(DistractedType.DISTRACTED_PHONE)
return distracted_types
- def _update_states(self, driver_state, cal_rpy, car_speed, op_engaged, standstill):
+ def _update_states(self, driver_state, cal_rpy, car_speed, op_engaged, standstill, demo_mode=False):
rhd_pred = driver_state.wheelOnRightProb
# calibrates only when there's movement and either face detected
if car_speed > self.settings._WHEELPOS_CALIB_MIN_SPEED and (driver_state.leftDriverData.faceProb > self.settings._FACE_THRESHOLD or
driver_state.rightDriverData.faceProb > self.settings._FACE_THRESHOLD):
self.wheelpos_learner.push_and_update(rhd_pred)
- if self.wheelpos_learner.filtered_stat.n > self.settings._WHEELPOS_FILTER_MIN_COUNT:
+ if self.wheelpos_learner.filtered_stat.n > self.settings._WHEELPOS_FILTER_MIN_COUNT or demo_mode:
self.wheel_on_right = self.wheelpos_learner.filtered_stat.M > self.settings._WHEELPOS_THRESHOLD
else:
self.wheel_on_right = self.wheel_on_right_default # use default/saved if calibration is unfinished
# make sure no switching when engaged
- if op_engaged and self.wheel_on_right_last is not None and self.wheel_on_right_last != self.wheel_on_right:
+ if op_engaged and self.wheel_on_right_last is not None and self.wheel_on_right_last != self.wheel_on_right and not demo_mode:
self.wheel_on_right = self.wheel_on_right_last
driver_data = driver_state.rightDriverData if self.wheel_on_right else driver_state.leftDriverData
if not all(len(x) > 0 for x in (driver_data.faceOrientation, driver_data.facePosition,
- driver_data.faceOrientationStd, driver_data.facePositionStd,
- driver_data.notReadyProb)):
+ driver_data.faceOrientationStd, driver_data.facePositionStd)):
return
self.face_detected = driver_data.faceProb > self.settings._FACE_THRESHOLD
@@ -281,14 +279,15 @@ class DriverMonitoring:
model_std_max = max(self.pose.pitch_std, self.pose.yaw_std)
self.pose.low_std = model_std_max < self.settings._POSESTD_THRESHOLD
self.blink.left = driver_data.leftBlinkProb * (driver_data.leftEyeProb > self.settings._EYE_THRESHOLD) \
- * (driver_data.sunglassesProb < self.settings._SG_THRESHOLD)
+ * (driver_data.sunglassesProb < self.settings._SG_THRESHOLD)
self.blink.right = driver_data.rightBlinkProb * (driver_data.rightEyeProb > self.settings._EYE_THRESHOLD) \
- * (driver_data.sunglassesProb < self.settings._SG_THRESHOLD)
- self.eev1 = driver_data.notReadyProb[0]
+ * (driver_data.sunglassesProb < self.settings._SG_THRESHOLD)
+ self.phone.prob = driver_data.phoneProb
self.distracted_types = self._get_distracted_types()
- self.driver_distracted = (DistractedType.DISTRACTED_E2E in self.distracted_types or DistractedType.DISTRACTED_POSE in self.distracted_types
- or DistractedType.DISTRACTED_BLINK in self.distracted_types) \
+ self.driver_distracted = (DistractedType.DISTRACTED_PHONE in self.distracted_types
+ or DistractedType.DISTRACTED_POSE in self.distracted_types
+ or DistractedType.DISTRACTED_BLINK in self.distracted_types) \
and driver_data.faceProb > self.settings._FACE_THRESHOLD and self.pose.low_std
self.driver_distraction_filter.update(self.driver_distracted)
@@ -297,11 +296,11 @@ class DriverMonitoring:
if self.face_detected and car_speed > self.settings._POSE_CALIB_MIN_SPEED and self.pose.low_std and (not op_engaged or not self.driver_distracted):
self.pose.pitch_offseter.push_and_update(self.pose.pitch)
self.pose.yaw_offseter.push_and_update(self.pose.yaw)
- self.ee1_offseter.push_and_update(self.eev1)
+ self.phone.prob_offseter.push_and_update(self.phone.prob)
self.pose.calibrated = self.pose.pitch_offseter.filtered_stat.n > self.settings._POSE_OFFSET_MIN_COUNT and \
- self.pose.yaw_offseter.filtered_stat.n > self.settings._POSE_OFFSET_MIN_COUNT
- self.ee1_calibrated = self.ee1_offseter.filtered_stat.n > self.settings._POSE_OFFSET_MIN_COUNT
+ self.pose.yaw_offseter.filtered_stat.n > self.settings._POSE_OFFSET_MIN_COUNT
+ self.phone.prob_calibrated = self.phone.prob_offseter.filtered_stat.n > self.settings._POSE_OFFSET_MIN_COUNT
if self.face_detected and not self.driver_distracted:
if model_std_max > self.settings._DCAM_UNCERTAIN_ALERT_THRESHOLD:
@@ -407,6 +406,8 @@ class DriverMonitoring:
"posePitchValidCount": self.pose.pitch_offseter.filtered_stat.n,
"poseYawOffset": self.pose.yaw_offseter.filtered_stat.mean(),
"poseYawValidCount": self.pose.yaw_offseter.filtered_stat.n,
+ "phoneProbOffset": self.phone.prob_offseter.filtered_stat.mean(),
+ "phoneProbValidCount": self.phone.prob_offseter.filtered_stat.n,
"stepChange": self.step_change,
"awarenessActive": self.awareness_active,
"awarenessPassive": self.awareness_passive,
@@ -418,27 +419,43 @@ class DriverMonitoring:
}
return dat
- def run_step(self, sm):
- # Set strictness
+ def run_step(self, sm, demo=False):
+ if demo:
+ highway_speed = 30
+ enabled = True
+ wrong_gear = False
+ standstill = False
+ driver_engaged = False
+ brake_disengage_prob = 1.0
+ rpyCalib = [0., 0., 0.]
+ else:
+ highway_speed = sm['carState'].vEgo
+ enabled = sm['selfdriveState'].enabled
+ wrong_gear = sm['carState'].gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low)
+ standstill = sm['carState'].standstill
+ driver_engaged = sm['carState'].steeringPressed or sm['carState'].gasPressed
+ brake_disengage_prob = sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs[0] # brake disengage prob in next 2s
+ rpyCalib = sm['liveCalibration'].rpyCalib
self._set_policy(
- model_data=sm['modelV2'],
- car_speed=sm['carState'].vEgo
+ brake_disengage_prob=brake_disengage_prob,
+ car_speed=highway_speed,
)
# Parse data from dmonitoringmodeld
self._update_states(
driver_state=sm['driverStateV2'],
- cal_rpy=sm['liveCalibration'].rpyCalib,
- car_speed=sm['carState'].vEgo,
- op_engaged=sm['selfdriveState'].enabled or sm['carControl'].latActive,
- standstill=sm['carState'].standstill,
+ cal_rpy=rpyCalib,
+ car_speed=highway_speed,
+ op_engaged=enabled,
+ standstill=standstill,
+ demo_mode=demo,
)
# Update distraction events
self._update_events(
- driver_engaged=sm['carState'].steeringPressed or sm['carState'].gasPressed,
- op_engaged=sm['selfdriveState'].enabled or sm['carControl'].latActive,
- standstill=sm['carState'].standstill,
- wrong_gear=sm['carState'].gearShifter in [car.CarState.GearShifter.reverse, car.CarState.GearShifter.park],
- car_speed=sm['carState'].vEgo
+ driver_engaged=driver_engaged,
+ op_engaged=enabled,
+ standstill=standstill,
+ wrong_gear=wrong_gear,
+ car_speed=highway_speed
)
diff --git a/selfdrive/monitoring/test_monitoring.py b/selfdrive/monitoring/test_monitoring.py
index 1f8babe029..6ea9b80283 100644
--- a/selfdrive/monitoring/test_monitoring.py
+++ b/selfdrive/monitoring/test_monitoring.py
@@ -3,9 +3,10 @@ import numpy as np
from cereal import log
from openpilot.common.realtime import DT_DMON
from openpilot.selfdrive.monitoring.helpers import DriverMonitoring, DRIVER_MONITOR_SETTINGS
+from openpilot.system.hardware import HARDWARE
EventName = log.OnroadEvent.EventName
-dm_settings = DRIVER_MONITOR_SETTINGS()
+dm_settings = DRIVER_MONITOR_SETTINGS(device_type=HARDWARE.get_device_type())
TEST_TIMESPAN = 120 # seconds
DISTRACTED_SECONDS_TO_ORANGE = dm_settings._DISTRACTED_TIME - dm_settings._DISTRACTED_PROMPT_TIME_TILL_TERMINAL + 1
@@ -25,7 +26,7 @@ def make_msg(face_detected, distracted=False, model_uncertain=False):
ds.leftDriverData.faceOrientationStd = [1.*model_uncertain, 1.*model_uncertain, 1.*model_uncertain]
ds.leftDriverData.facePositionStd = [1.*model_uncertain, 1.*model_uncertain]
# TODO: test both separately when e2e is used
- ds.leftDriverData.notReadyProb = [0., 0.]
+ ds.leftDriverData.phoneProb = 0.
return ds
diff --git a/selfdrive/pandad/pandad.cc b/selfdrive/pandad/pandad.cc
index 55d033222b..f64f4cbc1e 100644
--- a/selfdrive/pandad/pandad.cc
+++ b/selfdrive/pandad/pandad.cc
@@ -386,14 +386,19 @@ void process_panda_state(std::vector &pandas, PubMaster *pm, bool engag
}
void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control) {
+ static Params params;
static SubMaster sm({"deviceState", "driverCameraState"});
static uint64_t last_driver_camera_t = 0;
static uint16_t prev_fan_speed = 999;
static int ir_pwr = 0;
static int prev_ir_pwr = 999;
+ static uint32_t prev_frame_id = UINT32_MAX;
+ static bool driver_view = false;
+ // TODO: can we merge these?
static FirstOrderFilter integ_lines_filter(0, 30.0, 0.05);
+ static FirstOrderFilter integ_lines_filter_driver_view(0, 5.0, 0.05);
{
sm.update(0);
@@ -410,7 +415,15 @@ void process_peripheral_state(Panda *panda, PubMaster *pm, bool no_fan_control)
auto event = sm["driverCameraState"];
int cur_integ_lines = event.getDriverCameraState().getIntegLines();
- cur_integ_lines = integ_lines_filter.update(cur_integ_lines);
+ // reset the filter when camerad restarts
+ if (event.getDriverCameraState().getFrameId() < prev_frame_id) {
+ integ_lines_filter.reset(0);
+ integ_lines_filter_driver_view.reset(0);
+ driver_view = params.getBool("IsDriverViewEnabled");
+ }
+ prev_frame_id = event.getDriverCameraState().getFrameId();
+
+ cur_integ_lines = (driver_view ? integ_lines_filter_driver_view : integ_lines_filter).update(cur_integ_lines);
last_driver_camera_t = event.getLogMonoTime();
if (cur_integ_lines <= CUTOFF_IL) {
diff --git a/selfdrive/selfdrived/alerts_offroad.json b/selfdrive/selfdrived/alerts_offroad.json
index 14a833a3ba..917b0b6ab7 100644
--- a/selfdrive/selfdrived/alerts_offroad.json
+++ b/selfdrive/selfdrived/alerts_offroad.json
@@ -26,7 +26,7 @@
"severity": 0
},
"Offroad_UnregisteredHardware": {
- "text": "Device failed to register with the comma.ai backend. It will not connect or upload to comma.ai servers, and receives no support from comma.ai. If this is a device purchased at comma.ai/shop, open a ticket at https://comma.ai/support.",
+ "text": "Failed to register with comma.ai backend. It will not connect or upload to comma.ai servers, and receives no support from comma.ai. If this is a device purchased at comma.ai/shop, open a ticket at https://comma.ai/support.",
"severity": 1
},
"Offroad_CarUnrecognized": {
@@ -42,11 +42,11 @@
"severity": 0
},
"Offroad_DriverMonitoringUncertain": {
- "text": "openpilot detected poor visibility for driver monitoring. Ensure the device has a clear view of the driver. This can be checked using Settings -> Device -> Driver Camera Preview. Extreme lighting conditions and/or unconventional mounting positions may also trigger this alert.",
+ "text": "Poor visibility detected for driver monitoring. Ensure the device has a clear view of the driver. This can be checked in the device settings. Extreme lighting conditions and/or unconventional mounting positions may also trigger this alert.",
"severity": 0
},
"Offroad_ExcessiveActuation": {
- "text": "openpilot detected excessive %1 actuation on your last drive. Please contact support at https://comma.ai/support and share your device's Dongle ID for troubleshooting.",
+ "text": "Excessive %1 actuation detected on your last drive. Please contact support at https://comma.ai/support and share your device's Dongle ID for troubleshooting.",
"severity": 1,
"_comment": "Set extra field to lateral or longitudinal."
},
diff --git a/selfdrive/selfdrived/events.py b/selfdrive/selfdrived/events.py
index 79a6aacb57..202e5843e2 100755
--- a/selfdrive/selfdrived/events.py
+++ b/selfdrive/selfdrived/events.py
@@ -10,6 +10,7 @@ from openpilot.common.realtime import DT_CTRL
from openpilot.selfdrive.locationd.calibrationd import MIN_SPEED_FILTER
from openpilot.system.micd import SAMPLE_RATE, SAMPLE_BUFFER
from openpilot.selfdrive.ui.feedback.feedbackd import FEEDBACK_MAX_DURATION
+from openpilot.system.hardware import HARDWARE
from openpilot.sunnypilot.selfdrive.selfdrived.events_base import EventsBase, Priority, ET, Alert, \
NoEntryAlert, SoftDisableAlert, UserSoftDisableAlert, ImmediateDisableAlert, EngagementAlert, NormalPermanentAlert, \
@@ -86,10 +87,19 @@ def below_steer_speed_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.S
Priority.LOW, VisualAlert.none, AudibleAlert.prompt, 0.4)
-def calibration_incomplete_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert:
- first_word = 'Recalibration' if sm['liveCalibration'].calStatus == log.LiveCalibrationData.Status.recalibrating else 'Calibration'
+def steer_saturated_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert:
+ steer_text2 = "Steer Left" if sm['carControl'].actuators.torque > 0 else "Steer Right"
return Alert(
- f"{first_word} in Progress: {sm['liveCalibration'].calPerc:.0f}%",
+ "Take Control",
+ steer_text2,
+ AlertStatus.userPrompt, AlertSize.mid,
+ Priority.LOW, VisualAlert.steerRequired, AudibleAlert.promptRepeat, 2.)
+
+
+def calibration_incomplete_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert:
+ first_word = 'Recalibrating' if sm['liveCalibration'].calStatus == log.LiveCalibrationData.Status.recalibrating else 'Calibrating'
+ return Alert(
+ f"{first_word}: {sm['liveCalibration'].calPerc:.0f}%",
f"Drive Above {get_display_speed(MIN_SPEED_FILTER, metric)}",
AlertStatus.normal, AlertSize.mid,
Priority.LOWEST, VisualAlert.none, AudibleAlert.none, .2)
@@ -846,6 +856,70 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = {
}
+if HARDWARE.get_device_type() == 'mici':
+ EVENTS.update({
+ EventName.preDriverDistracted: {
+ ET.PERMANENT: Alert(
+ "Pay Attention",
+ "",
+ AlertStatus.normal, AlertSize.small,
+ Priority.LOW, VisualAlert.none, AudibleAlert.none, 2),
+ },
+ EventName.promptDriverDistracted: {
+ ET.PERMANENT: Alert(
+ "Pay Attention",
+ "Driver Distracted",
+ AlertStatus.userPrompt, AlertSize.mid,
+ Priority.MID, VisualAlert.steerRequired, AudibleAlert.promptDistracted, 1),
+ },
+ EventName.resumeRequired: {
+ ET.WARNING: Alert(
+ "Press Resume",
+ "",
+ AlertStatus.userPrompt, AlertSize.small,
+ Priority.LOW, VisualAlert.none, AudibleAlert.none, .2),
+ },
+ EventName.preLaneChangeLeft: {
+ ET.WARNING: Alert(
+ "Steer Left",
+ "Confirm Lane Change",
+ AlertStatus.normal, AlertSize.mid,
+ Priority.LOW, VisualAlert.none, AudibleAlert.none, .1),
+ },
+ EventName.preLaneChangeRight: {
+ ET.WARNING: Alert(
+ "Steer Right",
+ "Confirm Lane Change",
+ AlertStatus.normal, AlertSize.mid,
+ Priority.LOW, VisualAlert.none, AudibleAlert.none, .1),
+ },
+ EventName.laneChangeBlocked: {
+ ET.WARNING: Alert(
+ "Car in Blindspot",
+ "",
+ AlertStatus.userPrompt, AlertSize.small,
+ Priority.LOW, VisualAlert.none, AudibleAlert.prompt, .1),
+ },
+ EventName.steerSaturated: {
+ ET.WARNING: steer_saturated_alert,
+ },
+ EventName.calibrationIncomplete: {
+ ET.PERMANENT: calibration_incomplete_alert,
+ ET.SOFT_DISABLE: soft_disable_alert("Calibration Incomplete"),
+ ET.NO_ENTRY: NoEntryAlert("Calibrating"),
+ },
+ EventName.reverseGear: {
+ ET.PERMANENT: Alert(
+ "Reverse",
+ "",
+ AlertStatus.normal, AlertSize.full,
+ Priority.LOWEST, VisualAlert.none, AudibleAlert.none, .2, creation_delay=0.5),
+ ET.USER_DISABLE: ImmediateDisableAlert("Reverse"),
+ ET.NO_ENTRY: NoEntryAlert("Reverse"),
+ },
+ })
+
+
if __name__ == '__main__':
# print all alerts by type and priority
from cereal.services import SERVICE_LIST
diff --git a/selfdrive/selfdrived/selfdrived.py b/selfdrive/selfdrived/selfdrived.py
index 3bc616fb04..7b546f3780 100755
--- a/selfdrive/selfdrived/selfdrived.py
+++ b/selfdrive/selfdrived/selfdrived.py
@@ -22,6 +22,7 @@ from openpilot.selfdrive.selfdrived.state import StateMachine
from openpilot.selfdrive.selfdrived.alertmanager import AlertManager, set_offroad_alert
from openpilot.system.version import get_build_metadata
+from openpilot.system.hardware import HARDWARE
from openpilot.sunnypilot.mads.mads import ModularAssistiveDrivingSystem
from openpilot.sunnypilot import get_sanitize_int_param
@@ -147,6 +148,8 @@ class SelfdriveD(CruiseHelper):
# Determine startup event
is_remote = build_metadata.openpilot.comma_remote or build_metadata.openpilot.sunnypilot_remote
self.startup_event = EventName.startup if is_remote and build_metadata.tested_channel else EventName.startupMaster
+ if HARDWARE.get_device_type() == 'mici':
+ self.startup_event = None
if not car_recognized:
self.startup_event = EventName.startupNoCar
elif car_recognized and self.CP.passive:
diff --git a/selfdrive/test/process_replay/model_replay.py b/selfdrive/test/process_replay/model_replay.py
index 59b8cf8250..9ba599bac9 100755
--- a/selfdrive/test/process_replay/model_replay.py
+++ b/selfdrive/test/process_replay/model_replay.py
@@ -77,7 +77,7 @@ def generate_report(proposed, master, tmp, commit):
(lambda x: get_idx_if_non_empty(x.leftDriverData.faceProb), "leftDriverData.faceProb"),
(lambda x: get_idx_if_non_empty(x.leftDriverData.faceOrientation, 0), "leftDriverData.faceOrientation0"),
(lambda x: get_idx_if_non_empty(x.leftDriverData.leftBlinkProb), "leftDriverData.leftBlinkProb"),
- (lambda x: get_idx_if_non_empty(x.leftDriverData.notReadyProb, 0), "leftDriverData.notReadyProb0"),
+ (lambda x: get_idx_if_non_empty(x.leftDriverData.phoneProb), "leftDriverData.phoneProb"),
(lambda x: get_idx_if_non_empty(x.rightDriverData.faceProb), "rightDriverData.faceProb"),
], "driverStateV2")
diff --git a/selfdrive/test/test_onroad.py b/selfdrive/test/test_onroad.py
index 69d920c1a0..27cc17624e 100644
--- a/selfdrive/test/test_onroad.py
+++ b/selfdrive/test/test_onroad.py
@@ -179,7 +179,7 @@ class TestOnroad:
def test_manager_starting_time(self):
st = self.ts['managerState']['t'][0]
- assert (st - self.manager_st) < 12.5, f"manager.py took {st - self.manager_st}s to publish the first 'managerState' msg"
+ assert (st - self.manager_st) < 15.0, f"manager.py took {st - self.manager_st}s to publish the first 'managerState' msg"
def test_cloudlog_size(self):
msgs = self.msgs['logMessage']
diff --git a/selfdrive/ui/SConscript b/selfdrive/ui/SConscript
index 8a5f5793a5..0de3e13c01 100644
--- a/selfdrive/ui/SConscript
+++ b/selfdrive/ui/SConscript
@@ -56,12 +56,16 @@ if GetOption('extras'):
"ld -r -b binary -o $TARGET $SOURCE")
inter = raylib_env.Command("installer/inter_ttf.o", "installer/inter-ascii.ttf",
"ld -r -b binary -o $TARGET $SOURCE")
+ inter_bold = raylib_env.Command("installer/inter_bold.o", "../assets/fonts/Inter-Bold.ttf",
+ "ld -r -b binary -o $TARGET $SOURCE")
+ inter_light = raylib_env.Command("installer/inter_light.o", "../assets/fonts/Inter-Light.ttf",
+ "ld -r -b binary -o $TARGET $SOURCE")
for name, branch in installers:
d = {'BRANCH': f"'\"{branch}\"'"}
if "internal" in name:
d['INTERNAL'] = "1"
obj = raylib_env.Object(f"installer/installers/installer_{name}.o", ["installer/installer.cc"], CPPDEFINES=d)
- f = raylib_env.Program(f"installer/installers/installer_{name}", [obj, cont, inter], LIBS=raylib_libs)
+ f = raylib_env.Program(f"installer/installers/installer_{name}", [obj, cont, inter, inter_bold, inter_light], LIBS=raylib_libs)
# keep installers small
assert f[0].get_size() < 1900*1e3, f[0].get_size()
diff --git a/selfdrive/ui/installer/installer.cc b/selfdrive/ui/installer/installer.cc
index 5cb0a38e0b..072fa4e24b 100644
--- a/selfdrive/ui/installer/installer.cc
+++ b/selfdrive/ui/installer/installer.cc
@@ -36,8 +36,17 @@ extern const uint8_t str_continue[] asm("_binary_selfdrive_ui_installer_continue
extern const uint8_t str_continue_end[] asm("_binary_selfdrive_ui_installer_continue_openpilot_sh_end");
extern const uint8_t inter_ttf[] asm("_binary_selfdrive_ui_installer_inter_ascii_ttf_start");
extern const uint8_t inter_ttf_end[] asm("_binary_selfdrive_ui_installer_inter_ascii_ttf_end");
+extern const uint8_t inter_light_ttf[] asm("_binary_selfdrive_assets_fonts_Inter_Light_ttf_start");
+extern const uint8_t inter_light_ttf_end[] asm("_binary_selfdrive_assets_fonts_Inter_Light_ttf_end");
+extern const uint8_t inter_bold_ttf[] asm("_binary_selfdrive_assets_fonts_Inter_Bold_ttf_start");
+extern const uint8_t inter_bold_ttf_end[] asm("_binary_selfdrive_assets_fonts_Inter_Bold_ttf_end");
-Font font;
+Font font_inter;
+Font font_roman;
+Font font_display;
+
+const bool tici_device = Hardware::get_device_type() == cereal::InitData::DeviceType::TICI ||
+ Hardware::get_device_type() == cereal::InitData::DeviceType::TIZI;
std::vector tici_prebuilt_branches = {"release3", "release-tizi", "release3-staging", "nightly", "nightly-dev"};
std::string migrated_branch;
@@ -57,6 +66,12 @@ void branchMigration() {
} else if (BRANCH_STR == "release3-staging") {
migrated_branch = "release-tizi-staging";
}
+ } else if (device_type == cereal::InitData::DeviceType::MICI) {
+ if (BRANCH_STR == "release3") {
+ migrated_branch = "release-mici";
+ } else if (BRANCH_STR == "release3-staging") {
+ migrated_branch = "release-mici-staging";
+ }
}
}
@@ -68,9 +83,13 @@ void run(const char* cmd) {
void finishInstall() {
BeginDrawing();
ClearBackground(BLACK);
- const char *m = "Finishing install...";
- int text_width = MeasureText(m, FONT_SIZE);
- DrawTextEx(font, m, (Vector2){(float)(GetScreenWidth() - text_width)/2 + FONT_SIZE, (float)(GetScreenHeight() - FONT_SIZE)/2}, FONT_SIZE, 0, WHITE);
+ if (tici_device) {
+ const char *m = "Finishing install...";
+ int text_width = MeasureText(m, FONT_SIZE);
+ DrawTextEx(font_display, m, (Vector2){(float)(GetScreenWidth() - text_width)/2 + FONT_SIZE, (float)(GetScreenHeight() - FONT_SIZE)/2}, FONT_SIZE, 0, WHITE);
+ } else {
+ DrawTextEx(font_display, "finishing setup", (Vector2){8, 10}, 82, 0, WHITE);
+ }
EndDrawing();
util::sleep_for(60 * 1000);
}
@@ -78,13 +97,21 @@ void finishInstall() {
void renderProgress(int progress) {
BeginDrawing();
ClearBackground(BLACK);
- DrawTextEx(font, "Installing...", (Vector2){150, 290}, 110, 0, WHITE);
- Rectangle bar = {150, 570, (float)GetScreenWidth() - 300, 72};
- DrawRectangleRec(bar, (Color){41, 41, 41, 255});
- progress = std::clamp(progress, 0, 100);
- bar.width *= progress / 100.0f;
- DrawRectangleRec(bar, (Color){70, 91, 234, 255});
- DrawTextEx(font, (std::to_string(progress) + "%").c_str(), (Vector2){150, 670}, 85, 0, WHITE);
+ if (tici_device) {
+ DrawTextEx(font_inter, "Installing...", (Vector2){150, 290}, 110, 0, WHITE);
+ Rectangle bar = {150, 570, (float)GetScreenWidth() - 300, 72};
+ DrawRectangleRec(bar, (Color){41, 41, 41, 255});
+ progress = std::clamp(progress, 0, 100);
+ bar.width *= progress / 100.0f;
+ DrawRectangleRec(bar, (Color){70, 91, 234, 255});
+ DrawTextEx(font_inter, (std::to_string(progress) + "%").c_str(), (Vector2){150, 670}, 85, 0, WHITE);
+ } else {
+ DrawTextEx(font_display, "installing", (Vector2){8, 10}, 82, 0, WHITE);
+ const std::string percent_str = std::to_string(progress) + "%";
+ DrawTextEx(font_roman, percent_str.c_str(), (Vector2){6, (float)(GetScreenHeight() - 128 + 18)}, 128, 0,
+ (Color){255, 255, 255, (unsigned char)(255 * 0.9 * 0.35)});
+ }
+
EndDrawing();
}
@@ -211,9 +238,18 @@ void cloneFinished(int exitCode) {
}
int main(int argc, char *argv[]) {
- InitWindow(2160, 1080, "Installer");
- font = LoadFontFromMemory(".ttf", inter_ttf, inter_ttf_end - inter_ttf, FONT_SIZE, NULL, 0);
- SetTextureFilter(font.texture, TEXTURE_FILTER_BILINEAR);
+ if (tici_device) {
+ InitWindow(2160, 1080, "Installer");
+ } else {
+ InitWindow(536, 240, "Installer");
+ }
+
+ font_inter = LoadFontFromMemory(".ttf", inter_ttf, inter_ttf_end - inter_ttf, FONT_SIZE, NULL, 0);
+ font_roman = LoadFontFromMemory(".ttf", inter_light_ttf, inter_light_ttf_end - inter_light_ttf, FONT_SIZE, NULL, 0);
+ font_display = LoadFontFromMemory(".ttf", inter_bold_ttf, inter_bold_ttf_end - inter_bold_ttf, FONT_SIZE, NULL, 0);
+ SetTextureFilter(font_inter.texture, TEXTURE_FILTER_BILINEAR);
+ SetTextureFilter(font_roman.texture, TEXTURE_FILTER_BILINEAR);
+ SetTextureFilter(font_display.texture, TEXTURE_FILTER_BILINEAR);
branchMigration();
@@ -226,6 +262,8 @@ int main(int argc, char *argv[]) {
}
CloseWindow();
- UnloadFont(font);
+ UnloadFont(font_inter);
+ UnloadFont(font_roman);
+ UnloadFont(font_display);
return 0;
}
diff --git a/selfdrive/ui/layouts/home.py b/selfdrive/ui/layouts/home.py
index 7f477d4241..cd6ae600ef 100644
--- a/selfdrive/ui/layouts/home.py
+++ b/selfdrive/ui/layouts/home.py
@@ -20,8 +20,6 @@ SPACING = 25
RIGHT_COLUMN_WIDTH = 750
REFRESH_INTERVAL = 10.0
-PRIME_BG_COLOR = rl.Color(51, 51, 51, 255)
-
class HomeLayoutState(IntEnum):
HOME = 0
diff --git a/selfdrive/ui/layouts/onboarding.py b/selfdrive/ui/layouts/onboarding.py
index df259a8fb5..5d61c1c95a 100644
--- a/selfdrive/ui/layouts/onboarding.py
+++ b/selfdrive/ui/layouts/onboarding.py
@@ -11,6 +11,7 @@ from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import Label
from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.system.version import terms_version, training_version
DEBUG = False
@@ -169,10 +170,8 @@ class DeclinePage(Widget):
class OnboardingWindow(Widget):
def __init__(self):
super().__init__()
- self._current_terms_version = ui_state.params.get("TermsVersion")
- self._current_training_version = ui_state.params.get("TrainingVersion")
- self._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == self._current_terms_version
- self._training_done: bool = ui_state.params.get("CompletedTrainingVersion") == self._current_training_version
+ self._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == terms_version
+ self._training_done: bool = ui_state.params.get("CompletedTrainingVersion") == training_version
self._state = OnboardingState.TERMS if not self._accepted_terms else OnboardingState.ONBOARDING
@@ -192,13 +191,13 @@ class OnboardingWindow(Widget):
self._state = OnboardingState.TERMS
def _on_terms_accepted(self):
- ui_state.params.put("HasAcceptedTerms", self._current_terms_version)
+ ui_state.params.put("HasAcceptedTerms", terms_version)
self._state = OnboardingState.ONBOARDING
if self._training_done:
gui_app.set_modal_overlay(None)
def _on_completed_training(self):
- ui_state.params.put("CompletedTrainingVersion", self._current_training_version)
+ ui_state.params.put("CompletedTrainingVersion", training_version)
gui_app.set_modal_overlay(None)
def _render(self, _):
diff --git a/selfdrive/ui/layouts/settings/common.py b/selfdrive/ui/layouts/settings/common.py
new file mode 100644
index 0000000000..5e87a6447a
--- /dev/null
+++ b/selfdrive/ui/layouts/settings/common.py
@@ -0,0 +1,5 @@
+from openpilot.selfdrive.ui.ui_state import ui_state
+
+
+def restart_needed_callback(_):
+ ui_state.params.put_bool("OnroadCycleRequested", True)
diff --git a/selfdrive/ui/layouts/settings/developer.py b/selfdrive/ui/layouts/settings/developer.py
index 9ea1019f54..646c817508 100644
--- a/selfdrive/ui/layouts/settings/developer.py
+++ b/selfdrive/ui/layouts/settings/developer.py
@@ -3,7 +3,7 @@ from openpilot.selfdrive.ui.widgets.ssh_key import ssh_key_item
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.list_view import toggle_item
-from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr, tr_noop
diff --git a/selfdrive/ui/layouts/settings/device.py b/selfdrive/ui/layouts/settings/device.py
index f5f37fbd3c..00ae6a188e 100644
--- a/selfdrive/ui/layouts/settings/device.py
+++ b/selfdrive/ui/layouts/settings/device.py
@@ -17,7 +17,7 @@ from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dial
from openpilot.system.ui.widgets.html_render import HtmlModal
from openpilot.system.ui.widgets.list_view import text_item, button_item, dual_button_item
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
-from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.scroller_tici import Scroller
# Description constants
DESCRIPTIONS = {
diff --git a/selfdrive/ui/layouts/settings/software.py b/selfdrive/ui/layouts/settings/software.py
index 8166a8a9e4..4b8b7015f8 100644
--- a/selfdrive/ui/layouts/settings/software.py
+++ b/selfdrive/ui/layouts/settings/software.py
@@ -9,7 +9,7 @@ from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.widgets.list_view import button_item, text_item, ListItem
from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog
-from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.scroller_tici import Scroller
# TODO: remove this. updater fails to respond on startup if time is not correct
UPDATED_TIMEOUT = 10 # seconds to wait for updated to respond
diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py
index 3a1265e0fe..7fae2dfd24 100644
--- a/selfdrive/ui/layouts/settings/toggles.py
+++ b/selfdrive/ui/layouts/settings/toggles.py
@@ -2,7 +2,7 @@ from cereal import log
from openpilot.common.params import Params, UnknownKeyName
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.list_view import multiple_button_item, toggle_item
-from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.multilang import tr, tr_noop
diff --git a/selfdrive/ui/layouts/sidebar.py b/selfdrive/ui/layouts/sidebar.py
index d468442b15..050cd795bf 100644
--- a/selfdrive/ui/layouts/sidebar.py
+++ b/selfdrive/ui/layouts/sidebar.py
@@ -116,7 +116,7 @@ class Sidebar(Widget):
def _update_network_status(self, device_state):
self._net_type = NETWORK_TYPES.get(device_state.networkType.raw, tr_noop("Unknown"))
strength = device_state.networkStrength
- self._net_strength = max(0, min(5, strength.raw + 1)) if strength > 0 else 0
+ self._net_strength = max(0, min(5, strength.raw + 1)) if strength.raw > 0 else 0
def _update_temperature_status(self, device_state):
thermal_status = device_state.thermalStatus
diff --git a/selfdrive/ui/mici/layouts/__init__.py b/selfdrive/ui/mici/layouts/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/selfdrive/ui/mici/layouts/home.py b/selfdrive/ui/mici/layouts/home.py
new file mode 100644
index 0000000000..014fc0c45f
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/home.py
@@ -0,0 +1,272 @@
+import time
+
+from cereal import log
+import pyray as rl
+from collections.abc import Callable
+from openpilot.system.ui.widgets.label import gui_label, MiciLabel
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_COLOR, MousePos
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.system.ui.text import wrap_text
+from openpilot.system.version import training_version
+
+HEAD_BUTTON_FONT_SIZE = 40
+HOME_PADDING = 8
+
+RELEASE_BRANCH = "release3"
+
+NetworkType = log.DeviceState.NetworkType
+
+NETWORK_TYPES = {
+ NetworkType.none: "Offline",
+ NetworkType.wifi: "WiFi",
+ NetworkType.cell2G: "2G",
+ NetworkType.cell3G: "3G",
+ NetworkType.cell4G: "LTE",
+ NetworkType.cell5G: "5G",
+ NetworkType.ethernet: "Ethernet",
+}
+
+
+class DeviceStatus(Widget):
+ def __init__(self):
+ super().__init__()
+ self.set_rect(rl.Rectangle(0, 0, 300, 175))
+ self._update_state()
+ self._version_text = self._get_version_text()
+
+ self._do_welcome()
+
+ def _do_welcome(self):
+ ui_state.params.put("CompletedTrainingVersion", training_version)
+
+ def refresh(self):
+ self._update_state()
+ self._version_text = self._get_version_text()
+
+ def _get_version_text(self) -> str:
+ brand = "openpilot"
+ description = ui_state.params.get("UpdaterCurrentDescription")
+ return f"{brand} {description}" if description else brand
+
+ def _update_state(self):
+ # TODO: refresh function that can be called periodically, not at 60 fps, so we can update version
+ # update system status
+ self._system_status = "SYSTEM READY ✓" if ui_state.panda_type != log.PandaState.PandaType.unknown else "BOOTING UP..."
+
+ # update network status
+ strength = ui_state.sm['deviceState'].networkStrength.raw
+ strength_text = "● " * strength + "○ " * (4 - strength) # ◌ also works
+ network_type = NETWORK_TYPES[ui_state.sm['deviceState'].networkType.raw]
+ self._network_status = f"{network_type} {strength_text}"
+
+ def _render(self, _):
+ # draw status
+ status_rect = rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, 40)
+ gui_label(status_rect, self._system_status, font_size=HEAD_BUTTON_FONT_SIZE, color=DEFAULT_TEXT_COLOR,
+ font_weight=FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
+
+ # draw network status
+ network_rect = rl.Rectangle(self._rect.x, self._rect.y + 60, self._rect.width, 40)
+ gui_label(network_rect, self._network_status, font_size=40, color=DEFAULT_TEXT_COLOR,
+ font_weight=FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
+
+ # draw version
+ version_font_size = 30
+ version_rect = rl.Rectangle(self._rect.x, self._rect.y + 140, self._rect.width + 20, 40)
+ wrapped_text = '\n'.join(wrap_text(self._version_text, version_font_size, version_rect.width))
+ gui_label(version_rect, wrapped_text, font_size=version_font_size, color=DEFAULT_TEXT_COLOR,
+ font_weight=FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
+
+
+class MiciHomeLayout(Widget):
+ def __init__(self):
+ super().__init__()
+ self._on_settings_click: Callable | None = None
+
+ self._last_refresh = 0
+ self._mouse_down_t: None | float = None
+ self._did_long_press = False
+ self._is_pressed_prev = False
+
+ self._version_text = None
+ self._experimental_mode = False
+
+ self._settings_txt = gui_app.texture("icons_mici/settings.png", 48, 48)
+ self._experimental_txt = gui_app.texture("icons_mici/experimental_mode.png", 48, 48)
+ self._mic_txt = gui_app.texture("icons_mici/microphone.png", 48, 48)
+
+ self._net_type = NETWORK_TYPES.get(NetworkType.none)
+ self._net_strength = 0
+
+ self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 50, 44)
+ self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 50, 44)
+ self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 50, 44)
+ self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 50, 44)
+ self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 50, 44)
+
+ self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 55, 35)
+ self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 55, 35)
+ self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 55, 35)
+ self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 55, 35)
+ self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 55, 35)
+
+ self._openpilot_label = MiciLabel("openpilot", font_size=96, color=rl.Color(255, 255, 255, int(255 * 0.9)), font_weight=FontWeight.DISPLAY)
+ self._version_label = MiciLabel("", font_size=36, font_weight=FontWeight.ROMAN)
+ self._large_version_label = MiciLabel("", font_size=64, color=rl.GRAY, font_weight=FontWeight.ROMAN)
+ self._date_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN)
+ self._branch_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN, elide_right=False, scroll=True)
+ self._version_commit_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN)
+
+ def show_event(self):
+ self._version_text = self._get_version_text()
+ self._update_network_status(ui_state.sm['deviceState'])
+ self._update_params()
+
+ def _update_params(self):
+ self._experimental_mode = ui_state.params.get_bool("ExperimentalMode")
+
+ def _update_state(self):
+ if self.is_pressed and not self._is_pressed_prev:
+ self._mouse_down_t = time.monotonic()
+ elif not self.is_pressed and self._is_pressed_prev:
+ self._mouse_down_t = None
+ self._did_long_press = False
+ self._is_pressed_prev = self.is_pressed
+
+ if self._mouse_down_t is not None:
+ if time.monotonic() - self._mouse_down_t > 0.5:
+ # long gating for experimental mode - only allow toggle if longitudinal control is available
+ if ui_state.has_longitudinal_control:
+ self._experimental_mode = not self._experimental_mode
+ ui_state.params.put("ExperimentalMode", self._experimental_mode)
+ self._mouse_down_t = None
+ self._did_long_press = True
+
+ if rl.get_time() - self._last_refresh > 5.0:
+ device_state = ui_state.sm['deviceState']
+ self._update_network_status(device_state)
+
+ # Update version text
+ self._version_text = self._get_version_text()
+ self._last_refresh = rl.get_time()
+ self._update_params()
+
+ def _update_network_status(self, device_state):
+ self._net_type = device_state.networkType
+ strength = device_state.networkStrength
+ self._net_strength = max(0, min(5, strength.raw + 1)) if strength.raw > 0 else 0
+
+ def set_callbacks(self, on_settings: Callable | None = None):
+ self._on_settings_click = on_settings
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ if not self._did_long_press:
+ if self._on_settings_click:
+ self._on_settings_click()
+ self._did_long_press = False
+
+ def _get_version_text(self) -> tuple[str, str, str, str] | None:
+ description = ui_state.params.get("UpdaterCurrentDescription")
+
+ if description is not None and len(description) > 0:
+ # Expect "version / branch / commit / date"; be tolerant of other formats
+ try:
+ version, branch, commit, date = description.split(" / ")
+ return version, branch, commit, date
+ except Exception:
+ return None
+
+ return None
+
+ def _render(self, _):
+ # TODO: why is there extra space here to get it to be flush?
+ text_pos = rl.Vector2(self.rect.x - 2 + HOME_PADDING, self.rect.y - 16)
+ self._openpilot_label.set_position(text_pos.x, text_pos.y)
+ self._openpilot_label.render()
+
+ if self._version_text is not None:
+ # release branch
+ if self._version_text[0] == RELEASE_BRANCH:
+ version_pos = rl.Vector2(text_pos.x, text_pos.y + self._openpilot_label.font_size + 16)
+ self._large_version_label.set_text(self._version_text[0])
+ self._large_version_label.set_position(version_pos.x, version_pos.y)
+ self._large_version_label.render()
+
+ else:
+ version_pos = rl.Rectangle(text_pos.x, text_pos.y + self._openpilot_label.font_size + 16, 100, 44)
+ self._version_label.set_text(self._version_text[0])
+ self._version_label.set_position(version_pos.x, version_pos.y)
+ self._version_label.render()
+
+ self._date_label.set_text(" " + self._version_text[3])
+ self._date_label.set_position(version_pos.x + self._version_label.rect.width + 10, version_pos.y)
+ self._date_label.render()
+
+ self._branch_label.set_width(gui_app.width - self._version_label.rect.width - self._date_label.rect.width - 32)
+ self._branch_label.set_text(" " + self._version_text[1])
+ self._branch_label.set_position(version_pos.x + self._version_label.rect.width + self._date_label.rect.width + 20, version_pos.y)
+ self._branch_label.render()
+
+ # 2nd line
+ self._version_commit_label.set_text(self._version_text[2])
+ self._version_commit_label.set_position(version_pos.x, version_pos.y + self._date_label.font_size + 7)
+ self._version_commit_label.render()
+
+ self._render_bottom_status_bar()
+
+ def _render_bottom_status_bar(self):
+ # ***** Center-aligned bottom section icons *****
+
+ # TODO: refactor repeated icon drawing into a small loop
+ ITEM_SPACING = 18
+ Y_CENTER = 24
+
+ last_x = self.rect.x + HOME_PADDING
+
+ # Draw settings icon in bottom left corner
+ rl.draw_texture(self._settings_txt, int(last_x), int(self._rect.y + self.rect.height - self._settings_txt.height / 2 - Y_CENTER),
+ rl.Color(255, 255, 255, int(255 * 0.9)))
+ last_x = last_x + self._settings_txt.width + ITEM_SPACING
+
+ # draw network
+ if self._net_type == NetworkType.wifi:
+ # There is no 1
+ draw_net_txt = {0: self._wifi_none_txt,
+ 2: self._wifi_low_txt,
+ 3: self._wifi_medium_txt,
+ 4: self._wifi_full_txt,
+ 5: self._wifi_full_txt}.get(self._net_strength, self._wifi_low_txt)
+ rl.draw_texture(draw_net_txt, int(last_x),
+ int(self._rect.y + self.rect.height - draw_net_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, int(255 * 0.9)))
+ last_x += draw_net_txt.width + ITEM_SPACING
+
+ elif self._net_type in (NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G):
+ draw_net_txt = {0: self._cell_none_txt,
+ 2: self._cell_low_txt,
+ 3: self._cell_medium_txt,
+ 4: self._cell_high_txt,
+ 5: self._cell_full_txt}.get(self._net_strength, self._cell_none_txt)
+ rl.draw_texture(draw_net_txt, int(last_x),
+ int(self._rect.y + self.rect.height - draw_net_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, int(255 * 0.9)))
+ last_x += draw_net_txt.width + ITEM_SPACING
+
+ else:
+ # No network
+ # Offset by difference in height between slashless and slash icons to make center align match
+ rl.draw_texture(self._wifi_slash_txt, int(last_x), int(self._rect.y + self.rect.height - self._wifi_slash_txt.height / 2 -
+ (self._wifi_slash_txt.height - self._wifi_none_txt.height) / 2 - Y_CENTER),
+ rl.Color(255, 255, 255, 255))
+ last_x += self._wifi_slash_txt.width + ITEM_SPACING
+
+ # draw experimental icon
+ if self._experimental_mode:
+ rl.draw_texture(self._experimental_txt, int(last_x),
+ int(self._rect.y + self.rect.height - self._experimental_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, 255))
+ last_x += self._experimental_txt.width + ITEM_SPACING
+
+ # draw microphone icon when recording audio is enabled
+ if ui_state.recording_audio:
+ rl.draw_texture(self._mic_txt, int(last_x),
+ int(self._rect.y + self.rect.height - self._mic_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, 255))
+ last_x += self._mic_txt.width + ITEM_SPACING
diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py
new file mode 100644
index 0000000000..b52f9ed39a
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/main.py
@@ -0,0 +1,149 @@
+import pyray as rl
+from enum import IntEnum
+import cereal.messaging as messaging
+from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout
+from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsLayout
+from openpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts
+from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView
+from openpilot.selfdrive.ui.ui_state import device, ui_state
+from openpilot.selfdrive.ui.mici.layouts.onboarding import OnboardingWindow
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.lib.application import gui_app
+
+
+ONROAD_DELAY = 2.5 # seconds
+
+
+class MainState(IntEnum):
+ MAIN = 0
+ SETTINGS = 1
+
+
+class MiciMainLayout(Widget):
+ def __init__(self):
+ super().__init__()
+
+ self._pm = messaging.PubMaster(['bookmarkButton'])
+
+ self._current_mode: MainState | None = None
+ self._prev_onroad = False
+ self._prev_standstill = False
+ self._onroad_time_delay: float | None = None
+ self._setup = False
+
+ # Initialize widgets
+ self._home_layout = MiciHomeLayout()
+ self._alerts_layout = MiciOffroadAlerts()
+ self._settings_layout = SettingsLayout()
+ self._onroad_layout = AugmentedRoadView(bookmark_callback=self._on_bookmark_clicked)
+
+ # Initialize widget rects
+ for widget in (self._home_layout, self._settings_layout, self._alerts_layout, self._onroad_layout):
+ # TODO: set parent rect and use it if never passed rect from render (like in Scroller)
+ widget.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+
+ self._scroller = Scroller([
+ self._alerts_layout,
+ self._home_layout,
+ self._onroad_layout,
+ ], spacing=0, pad_start=0, pad_end=0)
+ self._scroller.set_reset_scroll_at_show(False)
+
+ # Disable scrolling when onroad is interacting with bookmark
+ self._scroller.set_scrolling_enabled(lambda: not self._onroad_layout.is_swiping_left())
+
+ self._layouts = {
+ MainState.MAIN: self._scroller,
+ MainState.SETTINGS: self._settings_layout,
+ }
+
+ # Set callbacks
+ self._setup_callbacks()
+
+ # Start onboarding if terms or training not completed
+ self._onboarding_window = OnboardingWindow()
+ if not self._onboarding_window.completed:
+ gui_app.set_modal_overlay(self._onboarding_window)
+
+ def _setup_callbacks(self):
+ self._home_layout.set_callbacks(on_settings=self._on_settings_clicked)
+ self._settings_layout.set_callbacks(on_close=self._on_settings_closed)
+ self._onroad_layout.set_click_callback(lambda: self._scroll_to(self._home_layout))
+ device.add_interactive_timeout_callback(self._set_mode_for_started)
+
+ def _scroll_to(self, layout: Widget):
+ layout_x = int(layout.rect.x)
+ self._scroller.scroll_to(layout_x, smooth=True)
+
+ def _render(self, _):
+ # Initial show event
+ if self._current_mode is None:
+ self._set_mode(MainState.MAIN)
+
+ if not self._setup:
+ if self._alerts_layout.active_alerts() > 0:
+ self._scroller.scroll_to(self._alerts_layout.rect.x)
+ else:
+ self._scroller.scroll_to(self._rect.width)
+ self._setup = True
+
+ # Render
+ if self._current_mode == MainState.MAIN:
+ self._scroller.render(self._rect)
+
+ elif self._current_mode == MainState.SETTINGS:
+ self._settings_layout.render(self._rect)
+
+ self._handle_transitions()
+
+ def _set_mode(self, mode: MainState):
+ if mode != self._current_mode:
+ if self._current_mode is not None:
+ self._layouts[self._current_mode].hide_event()
+ self._layouts[mode].show_event()
+ self._current_mode = mode
+
+ def _handle_transitions(self):
+ if ui_state.started != self._prev_onroad:
+ self._prev_onroad = ui_state.started
+
+ if ui_state.started:
+ self._onroad_time_delay = rl.get_time()
+ else:
+ self._set_mode_for_started(True)
+
+ # delay so we show home for a bit after starting
+ if self._onroad_time_delay is not None and rl.get_time() - self._onroad_time_delay >= ONROAD_DELAY:
+ self._set_mode_for_started(True)
+ self._onroad_time_delay = None
+
+ CS = ui_state.sm["carState"]
+ if not CS.standstill and self._prev_standstill:
+ self._set_mode(MainState.MAIN)
+ self._scroll_to(self._onroad_layout)
+ self._prev_standstill = CS.standstill
+
+ def _set_mode_for_started(self, onroad_transition: bool = False):
+ if ui_state.started:
+ CS = ui_state.sm["carState"]
+ # Only go onroad if car starts or is not at a standstill
+ if not CS.standstill or onroad_transition:
+ self._set_mode(MainState.MAIN)
+ self._scroll_to(self._onroad_layout)
+ else:
+ # Stay in settings if car turns off while in settings
+ if not onroad_transition or self._current_mode != MainState.SETTINGS:
+ self._set_mode(MainState.MAIN)
+ self._scroll_to(self._home_layout)
+
+ def _on_settings_clicked(self):
+ self._set_mode(MainState.SETTINGS)
+
+ def _on_settings_closed(self):
+ self._set_mode(MainState.MAIN)
+
+ def _on_bookmark_clicked(self):
+ user_bookmark = messaging.new_message('bookmarkButton')
+ user_bookmark.valid = True
+ self._pm.send('bookmarkButton', user_bookmark)
diff --git a/selfdrive/ui/mici/layouts/offroad_alerts.py b/selfdrive/ui/mici/layouts/offroad_alerts.py
new file mode 100644
index 0000000000..2e9a8bee3c
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/offroad_alerts.py
@@ -0,0 +1,307 @@
+import pyray as rl
+import re
+import time
+from dataclasses import dataclass
+from enum import IntEnum
+from openpilot.common.params import Params
+from openpilot.selfdrive.selfdrived.alertmanager import OFFROAD_ALERTS
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.label import UnifiedLabel
+from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.lib.multilang import tr
+
+REFRESH_INTERVAL = 5.0 # seconds
+
+
+class AlertSize(IntEnum):
+ SMALL = 0
+ MEDIUM = 1
+ BIG = 2
+
+
+@dataclass
+class AlertData:
+ key: str
+ text: str
+ severity: int
+ visible: bool = False
+
+
+class AlertItem(Widget):
+ # TODO: click should always go somewhere: home or specific settings pane
+ """Individual alert item widget with background image and text."""
+ ALERT_WIDTH = 520
+ ALERT_HEIGHT_SMALL = 212
+ ALERT_HEIGHT_MED = 240
+ ALERT_HEIGHT_BIG = 324
+ ALERT_PADDING = 28
+ ICON_SIZE = 64
+ ICON_MARGIN = 12
+ TEXT_COLOR = rl.Color(255, 255, 255, int(255 * 0.9))
+ TITLE_BODY_SPACING = 24
+
+ def __init__(self, alert_data: AlertData):
+ super().__init__()
+ self.alert_data = alert_data
+
+ # Load background textures
+ self._bg_small = gui_app.texture("icons_mici/offroad_alerts/small_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_SMALL)
+ self._bg_small_pressed = gui_app.texture("icons_mici/offroad_alerts/small_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_SMALL)
+ self._bg_medium = gui_app.texture("icons_mici/offroad_alerts/medium_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_MED)
+ self._bg_medium_pressed = gui_app.texture("icons_mici/offroad_alerts/medium_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_MED)
+ self._bg_big = gui_app.texture("icons_mici/offroad_alerts/big_alert.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG)
+ self._bg_big_pressed = gui_app.texture("icons_mici/offroad_alerts/big_alert_pressed.png", self.ALERT_WIDTH, self.ALERT_HEIGHT_BIG)
+
+ # Load warning icons
+ self._icon_orange = gui_app.texture("icons_mici/offroad_alerts/orange_warning.png", self.ICON_SIZE, self.ICON_SIZE)
+ self._icon_red = gui_app.texture("icons_mici/offroad_alerts/red_warning.png", self.ICON_SIZE, self.ICON_SIZE)
+ self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", self.ICON_SIZE, self.ICON_SIZE)
+
+ self._title_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.SEMI_BOLD, text_color=self.TEXT_COLOR,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, line_height=0.95)
+
+ self._body_label = UnifiedLabel(text="", font_size=28, font_weight=FontWeight.ROMAN, text_color=self.TEXT_COLOR,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, line_height=0.95)
+
+ self._title_text = ""
+ self._body_text = ""
+ self._alert_size = AlertSize.SMALL
+
+ self._update_content()
+
+ def _split_text(self, text: str) -> tuple[str, str]:
+ """Split text into title (first sentence) and body (remaining text)."""
+ # Find the end of the first sentence (period, exclamation, or question mark followed by space or end)
+ match = re.search(r'[.!?](?:\s+|$)', text)
+ if match:
+ # Found a sentence boundary - split at the end of the sentence
+ title = text[:match.start()].strip()
+ body = text[match.end():].strip()
+ return title, body
+ else:
+ # No sentence boundary found, return full text as title
+ return "", text
+
+ def _update_content(self):
+ """Update text and calculate height."""
+ if not self.alert_data.visible or not self.alert_data.text:
+ self.set_visible(False)
+ return
+
+ self.set_visible(True)
+
+ # Split text into title and body
+ self._title_text, self._body_text = self._split_text(self.alert_data.text)
+
+ # Calculate text width (alert width minus padding and icon space on right)
+ title_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) - self.ICON_SIZE - self.ICON_MARGIN
+ body_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2)
+
+ # Update labels
+ self._title_label.set_text(self._title_text)
+ self._body_label.set_text(self._body_text)
+
+ # Calculate content height
+ title_height = self._title_label.get_content_height(title_width) if self._title_text else 0
+ body_height = self._body_label.get_content_height(body_width) if self._body_text else 0
+ spacing = self.TITLE_BODY_SPACING if (self._title_text and self._body_text) else 0
+ total_text_height = title_height + spacing + body_height
+
+ # Determine which background size to use based on content height
+ min_height_with_padding = total_text_height + (self.ALERT_PADDING * 2)
+ if min_height_with_padding > self.ALERT_HEIGHT_MED:
+ self._alert_size = AlertSize.BIG
+ height = self.ALERT_HEIGHT_BIG
+ elif min_height_with_padding > self.ALERT_HEIGHT_SMALL:
+ self._alert_size = AlertSize.MEDIUM
+ height = self.ALERT_HEIGHT_MED
+ else:
+ self._alert_size = AlertSize.SMALL
+ height = self.ALERT_HEIGHT_SMALL
+
+ # Set rect size
+ self.set_rect(rl.Rectangle(0, 0, self.ALERT_WIDTH, height))
+
+ def update_alert_data(self, alert_data: AlertData):
+ """Update alert data and refresh display."""
+ self.alert_data = alert_data
+ self._update_content()
+
+ def _render(self, _):
+ if not self.alert_data.visible or not self.alert_data.text:
+ return
+
+ # Choose background based on size
+ if self._alert_size == AlertSize.BIG:
+ bg_texture = self._bg_big_pressed if self.is_pressed else self._bg_big
+ elif self._alert_size == AlertSize.MEDIUM:
+ bg_texture = self._bg_medium_pressed if self.is_pressed else self._bg_medium
+ else: # AlertSize.SMALL
+ bg_texture = self._bg_small_pressed if self.is_pressed else self._bg_small
+
+ # Draw background
+ rl.draw_texture(bg_texture, int(self._rect.x), int(self._rect.y), rl.WHITE)
+
+ # Calculate text area (left side, avoiding icon on right)
+ title_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) - self.ICON_SIZE - self.ICON_MARGIN
+ body_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2)
+ text_x = self._rect.x + self.ALERT_PADDING
+ text_y = self._rect.y + self.ALERT_PADDING
+
+ # Draw title label
+ if self._title_text:
+ title_rect = rl.Rectangle(
+ text_x,
+ text_y,
+ title_width,
+ self._title_label.get_content_height(title_width),
+ )
+ self._title_label.render(title_rect)
+ text_y += title_rect.height + self.TITLE_BODY_SPACING
+
+ # Draw body label
+ if self._body_text:
+ body_rect = rl.Rectangle(
+ text_x,
+ text_y,
+ body_width,
+ self._rect.height - text_y + self._rect.y - self.ALERT_PADDING,
+ )
+ self._body_label.render(body_rect)
+
+ # Draw warning icon on the right side
+ # Use green icon for update alerts (severity = -1), red for high severity, orange for low severity
+ if self.alert_data.severity == -1:
+ icon_texture = self._icon_green
+ elif self.alert_data.severity > 0:
+ icon_texture = self._icon_red
+ else:
+ icon_texture = self._icon_orange
+ icon_x = self._rect.x + self.ALERT_WIDTH - self.ALERT_PADDING - self.ICON_SIZE
+ icon_y = self._rect.y + self.ALERT_PADDING
+ rl.draw_texture(icon_texture, int(icon_x), int(icon_y), rl.WHITE)
+
+
+class MiciOffroadAlerts(Widget):
+ """Offroad alerts layout with vertical scrolling."""
+
+ def __init__(self):
+ super().__init__()
+ self.params = Params()
+ self.sorted_alerts: list[AlertData] = []
+ self.alert_items: list[AlertItem] = []
+ self._last_refresh = 0.0
+
+ # Create vertical scroller
+ self._scroller = Scroller([], horizontal=False, spacing=12, pad_start=0, pad_end=0, snap_items=False)
+
+ # Create empty state label
+ self._empty_label = UnifiedLabel(tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
+
+ # Build initial alert list
+ self._build_alerts()
+
+ def active_alerts(self) -> int:
+ return sum(alert.visible for alert in self.sorted_alerts)
+
+ def scrolling(self):
+ return self._scroller.scroll_panel.is_touch_valid()
+
+ def _build_alerts(self):
+ """Build sorted list of alerts from OFFROAD_ALERTS."""
+ self.sorted_alerts = []
+
+ # Add UpdateAvailable alert at the top (severity = -1 to indicate special handling)
+ update_alert_data = AlertData(key="UpdateAvailable", text="", severity=-1)
+ self.sorted_alerts.append(update_alert_data)
+ update_alert_item = AlertItem(update_alert_data)
+ self.alert_items.append(update_alert_item)
+ self._scroller.add_widget(update_alert_item)
+
+ # Add regular alerts sorted by severity
+ for key, config in sorted(OFFROAD_ALERTS.items(), key=lambda x: x[1].get("severity", 0), reverse=True):
+ severity = config.get("severity", 0)
+ alert_data = AlertData(key=key, text="", severity=severity)
+ self.sorted_alerts.append(alert_data)
+
+ # Create alert item widget
+ alert_item = AlertItem(alert_data)
+ self.alert_items.append(alert_item)
+ self._scroller.add_widget(alert_item)
+
+ def refresh(self) -> int:
+ """Refresh alerts from params and return active count."""
+ active_count = 0
+
+ # Handle UpdateAvailable alert specially
+ update_available = self.params.get_bool("UpdateAvailable")
+ update_alert_data = next((alert_data for alert_data in self.sorted_alerts if alert_data.key == "UpdateAvailable"), None)
+
+ if update_alert_data:
+ if update_available:
+ # Default text
+ update_alert_data.text = "update available. go to comma.ai/blog to read the release notes."
+
+ # Get new version description and parse version and date
+ new_desc = self.params.get("UpdaterNewDescription") or ""
+ if new_desc:
+ # Parse description (format: "version / branch / commit / date")
+ parts = new_desc.split(" / ")
+ if len(parts) > 3:
+ version, date = parts[0], parts[3]
+ update_alert_data.text = f"update available\n openpilot {version}, {date}. go to comma.ai/blog to read the release notes."
+
+ update_alert_data.visible = True
+ active_count += 1
+ else:
+ update_alert_data.text = ""
+ update_alert_data.visible = False
+
+ # Handle regular alerts
+ for alert_data in self.sorted_alerts:
+ if alert_data.key == "UpdateAvailable":
+ continue # Skip, already handled above
+
+ text = ""
+ alert_json = self.params.get(alert_data.key)
+
+ if alert_json:
+ text = alert_json.get("text", "").replace("%1", alert_json.get("extra", ""))
+
+ alert_data.text = text
+ alert_data.visible = bool(text)
+
+ if alert_data.visible:
+ active_count += 1
+
+ # Update alert items (they reference the same alert_data objects)
+ for alert_item in self.alert_items:
+ alert_item.update_alert_data(alert_item.alert_data)
+
+ return active_count
+
+ def show_event(self):
+ """Reset scroll position when shown and refresh alerts."""
+ self._scroller.show_event()
+ self._last_refresh = time.monotonic()
+ self.refresh()
+
+ def _update_state(self):
+ """Periodically refresh alerts."""
+ # Refresh alerts periodically, not every frame
+ current_time = time.monotonic()
+ if current_time - self._last_refresh >= REFRESH_INTERVAL:
+ self.refresh()
+ self._last_refresh = current_time
+
+ def _render(self, rect: rl.Rectangle):
+ """Render the alerts scroller or empty state."""
+ if self.active_alerts() == 0:
+ self._empty_label.render(rect)
+ else:
+ self._scroller.render(rect)
diff --git a/selfdrive/ui/mici/layouts/onboarding.py b/selfdrive/ui/mici/layouts/onboarding.py
new file mode 100644
index 0000000000..afc7bfce17
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/onboarding.py
@@ -0,0 +1,349 @@
+from enum import IntEnum
+from collections.abc import Callable
+
+import pyray as rl
+from openpilot.system.ui.lib.application import FontWeight, gui_app
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.button import SmallButton
+from openpilot.system.ui.widgets.label import UnifiedLabel
+from openpilot.system.ui.widgets.slider import SmallSlider
+from openpilot.system.ui.mici_setup import TermsHeader, TermsPage as SetupTermsPage
+from openpilot.selfdrive.ui.ui_state import ui_state, device
+from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
+from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog
+from openpilot.system.ui.widgets.label import gui_label
+from openpilot.system.ui.lib.multilang import tr
+from openpilot.system.version import terms_version, training_version
+
+
+class OnboardingState(IntEnum):
+ TERMS = 0
+ ONBOARDING = 1
+ DECLINE = 2
+
+
+class DriverCameraSetupDialog(DriverCameraDialog):
+ def __init__(self, confirm_callback: Callable):
+ super().__init__(no_escape=True)
+ self.driver_state_renderer = DriverStateRenderer(confirm_mode=True, confirm_callback=confirm_callback)
+ self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 200, 200))
+ self.driver_state_renderer.load_icons()
+
+ def _render(self, rect):
+ rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
+ self._camera_view._render(rect)
+
+ if not self._camera_view.frame:
+ gui_label(rect, tr("camera starting"), font_size=64, font_weight=FontWeight.BOLD,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
+ rl.end_scissor_mode()
+ return -1
+
+ # Position dmoji on opposite side from driver
+ # TODO: we don't have design for RHD yet
+ is_rhd = False
+ driver_state_rect = (
+ rect.x if is_rhd else rect.x + rect.width - self.driver_state_renderer.rect.width,
+ rect.y + (rect.height - self.driver_state_renderer.rect.height) / 2,
+ )
+ self.driver_state_renderer.set_position(*driver_state_rect)
+ self.driver_state_renderer.render()
+
+ rl.end_scissor_mode()
+ return -1
+
+
+class TrainingGuidePreDMTutorial(SetupTermsPage):
+ def __init__(self, continue_callback):
+ super().__init__(continue_callback, continue_text="continue")
+ self._title_header = TermsHeader("driver monitoring setup", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60))
+
+ self._dm_label = UnifiedLabel("Next, we'll ensure comma four is mounted properly.\n\nIf it does not have a clear view of the driver, " +
+ "unplug and remount before continuing.", 42,
+ FontWeight.ROMAN)
+
+ def show_event(self):
+ super().show_event()
+ # Get driver monitoring model ready for next step
+ ui_state.params.put_bool("IsDriverViewEnabled", True)
+
+ @property
+ def _content_height(self):
+ return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset()
+
+ def _render_content(self, scroll_offset):
+ self._title_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + 16 + scroll_offset,
+ self._title_header.rect.width,
+ self._title_header.rect.height,
+ ))
+
+ self._dm_label.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._title_header.rect.y + self._title_header.rect.height + 16,
+ self._rect.width - 32,
+ self._dm_label.get_content_height(int(self._rect.width - 32)),
+ ))
+
+
+class TrainingGuideDMTutorial(Widget):
+ def __init__(self, continue_callback):
+ super().__init__()
+ self._title_header = TermsHeader("fill the circle to continue", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60))
+
+ self._original_continue_callback = continue_callback
+
+ # Wrap the continue callback to restore settings
+ def wrapped_continue_callback():
+ self._restore_settings()
+ continue_callback()
+
+ self._dialog = DriverCameraSetupDialog(wrapped_continue_callback)
+
+ # Disable driver monitoring model when device times out for inactivity
+ def inactivity_callback():
+ ui_state.params.put_bool("IsDriverViewEnabled", False)
+
+ device.add_interactive_timeout_callback(inactivity_callback)
+
+ def show_event(self):
+ super().show_event()
+ self._dialog.show_event()
+
+ device.set_offroad_brightness(100)
+ device.reset_interactive_timeout(300) # 5 minutes
+
+ def _restore_settings(self):
+ device.set_offroad_brightness(None)
+ device.reset_interactive_timeout()
+
+ def _update_state(self):
+ super()._update_state()
+ if device.awake:
+ ui_state.params.put_bool("IsDriverViewEnabled", True)
+
+ def _render(self, _):
+ self._dialog.render(self._rect)
+
+ rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height - self._title_header.rect.height * 1.5 - 32),
+ int(self._rect.width), int(self._title_header.rect.height * 1.5 + 32),
+ rl.BLANK, rl.Color(0, 0, 0, 150))
+ self._title_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + self._rect.height - self._title_header.rect.height - 16,
+ self._title_header.rect.width,
+ self._title_header.rect.height,
+ ))
+
+
+class TrainingGuideRecordFront(SetupTermsPage):
+ def __init__(self, continue_callback):
+ def on_back():
+ ui_state.params.put_bool("RecordFront", False)
+ continue_callback()
+
+ def on_continue():
+ ui_state.params.put_bool("RecordFront", True)
+ continue_callback()
+
+ super().__init__(on_continue, back_callback=on_back, back_text="no", continue_text="yes")
+ self._title_header = TermsHeader("improve driver monitoring", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60))
+
+ self._dm_label = UnifiedLabel("Do you want to upload driver camera data to improve driver monitoring?", 42,
+ FontWeight.ROMAN)
+
+ def show_event(self):
+ super().show_event()
+ # Disable driver monitoring model after last step
+ ui_state.params.put_bool("IsDriverViewEnabled", False)
+
+ @property
+ def _content_height(self):
+ return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset()
+
+ def _render_content(self, scroll_offset):
+ self._title_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + 16 + scroll_offset,
+ self._title_header.rect.width,
+ self._title_header.rect.height,
+ ))
+
+ self._dm_label.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._title_header.rect.y + self._title_header.rect.height + 16,
+ self._rect.width - 32,
+ self._dm_label.get_content_height(int(self._rect.width - 32)),
+ ))
+
+
+class TrainingGuideAttentionNotice(SetupTermsPage):
+ def __init__(self, continue_callback):
+ super().__init__(continue_callback, continue_text="continue")
+ self._title_header = TermsHeader("driver assistance", gui_app.texture("icons_mici/setup/warning.png", 60, 60))
+ self._warning_label = UnifiedLabel("1. openpilot is a driver assistance system.\n\n" +
+ "2. You must pay attention at all times.\n\n" +
+ "3. You must be ready to take over at any time.\n\n" +
+ "4. You are fully responsible for driving the car.", 42,
+ FontWeight.ROMAN)
+
+ @property
+ def _content_height(self):
+ return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset()
+
+ def _render_content(self, scroll_offset):
+ self._title_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + 16 + scroll_offset,
+ self._title_header.rect.width,
+ self._title_header.rect.height,
+ ))
+
+ self._warning_label.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._title_header.rect.y + self._title_header.rect.height + 16,
+ self._rect.width - 32,
+ self._warning_label.get_content_height(int(self._rect.width - 32)),
+ ))
+
+
+class TrainingGuide(Widget):
+ def __init__(self, completed_callback=None):
+ super().__init__()
+ self._completed_callback = completed_callback
+ self._step = 0
+
+ self._steps = [
+ TrainingGuideAttentionNotice(continue_callback=self._advance_step),
+ TrainingGuidePreDMTutorial(continue_callback=self._advance_step),
+ TrainingGuideDMTutorial(continue_callback=self._advance_step),
+ TrainingGuideRecordFront(continue_callback=self._advance_step),
+ ]
+
+ def _advance_step(self):
+ if self._step < len(self._steps) - 1:
+ self._step += 1
+ self._steps[self._step].show_event()
+ else:
+ self._step = 0
+ if self._completed_callback:
+ self._completed_callback()
+
+ def _render(self, _):
+ if self._step < len(self._steps):
+ self._steps[self._step].render(self._rect)
+ return -1
+
+
+class DeclinePage(Widget):
+ def __init__(self, back_callback=None):
+ super().__init__()
+ self._uninstall_slider = SmallSlider("uninstall openpilot", self._on_uninstall)
+
+ self._back_button = SmallButton("back")
+ self._back_button.set_click_callback(back_callback)
+
+ self._warning_header = TermsHeader("you must accept the\nterms to use openpilot",
+ gui_app.texture("icons_mici/setup/red_warning.png", 66, 60))
+
+ def _on_uninstall(self):
+ ui_state.params.put_bool("DoUninstall", True)
+ gui_app.request_close()
+
+ def _render(self, _):
+ self._warning_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + 16,
+ self._warning_header.rect.width,
+ self._warning_header.rect.height,
+ ))
+
+ self._back_button.set_opacity(1 - self._uninstall_slider.slider_percentage)
+ self._back_button.render(rl.Rectangle(
+ self._rect.x + 8,
+ self._rect.y + self._rect.height - self._back_button.rect.height,
+ self._back_button.rect.width,
+ self._back_button.rect.height,
+ ))
+
+ self._uninstall_slider.render(rl.Rectangle(
+ self._rect.x + self._rect.width - self._uninstall_slider.rect.width,
+ self._rect.y + self._rect.height - self._uninstall_slider.rect.height,
+ self._uninstall_slider.rect.width,
+ self._uninstall_slider.rect.height,
+ ))
+
+
+class TermsPage(SetupTermsPage):
+ def __init__(self, on_accept=None, on_decline=None):
+ super().__init__(on_accept, on_decline, "decline")
+
+ info_txt = gui_app.texture("icons_mici/setup/green_info.png", 60, 60)
+ self._title_header = TermsHeader("terms & conditions", info_txt)
+
+ self._terms_label = UnifiedLabel("You must accept the Terms and Conditions to use openpilot. " +
+ "Read the latest terms at https://comma.ai/terms before continuing.", 36,
+ FontWeight.ROMAN)
+
+ @property
+ def _content_height(self):
+ return self._terms_label.rect.y + self._terms_label.rect.height - self._scroll_panel.get_offset()
+
+ def _render_content(self, scroll_offset):
+ self._title_header.set_position(self._rect.x + 16, self._rect.y + 12 + scroll_offset)
+ self._title_header.render()
+
+ self._terms_label.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING,
+ self._rect.width - 100,
+ self._terms_label.get_content_height(int(self._rect.width - 100)),
+ ))
+
+
+class OnboardingWindow(Widget):
+ def __init__(self):
+ super().__init__()
+ self._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == terms_version
+ self._training_done: bool = ui_state.params.get("CompletedTrainingVersion") == training_version
+
+ self._state = OnboardingState.TERMS if not self._accepted_terms else OnboardingState.ONBOARDING
+
+ self.set_rect(rl.Rectangle(0, 0, 458, gui_app.height))
+
+ # Windows
+ self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_terms_declined)
+ self._training_guide = TrainingGuide(completed_callback=self._on_completed_training)
+ self._decline_page = DeclinePage(back_callback=self._on_decline_back)
+
+ @property
+ def completed(self) -> bool:
+ return self._accepted_terms and self._training_done
+
+ def _on_terms_declined(self):
+ self._state = OnboardingState.DECLINE
+
+ def _on_decline_back(self):
+ self._state = OnboardingState.TERMS
+
+ def close(self):
+ ui_state.params.put_bool("IsDriverViewEnabled", False)
+ gui_app.set_modal_overlay(None)
+
+ def _on_terms_accepted(self):
+ ui_state.params.put("HasAcceptedTerms", terms_version)
+ self._state = OnboardingState.ONBOARDING
+
+ def _on_completed_training(self):
+ ui_state.params.put("CompletedTrainingVersion", training_version)
+ self.close()
+
+ def _render(self, _):
+ if self._state == OnboardingState.TERMS:
+ self._terms.render(self._rect)
+ elif self._state == OnboardingState.ONBOARDING:
+ self._training_guide.render(self._rect)
+ elif self._state == OnboardingState.DECLINE:
+ self._decline_page.render(self._rect)
+ return -1
diff --git a/selfdrive/ui/mici/layouts/settings/developer.py b/selfdrive/ui/mici/layouts/settings/developer.py
new file mode 100644
index 0000000000..8fc63e8963
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/settings/developer.py
@@ -0,0 +1,151 @@
+import pyray as rl
+from collections.abc import Callable
+
+from openpilot.common.time_helpers import system_time_valid
+from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle, BigParamControl
+from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.widgets import NavWidget
+from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.selfdrive.ui.widgets.ssh_key import SshKeyAction
+
+
+class DeveloperLayoutMici(NavWidget):
+ def __init__(self, back_callback: Callable):
+ super().__init__()
+ self.set_back_callback(back_callback)
+
+ def github_username_callback(username: str):
+ if username:
+ ssh_keys = SshKeyAction()
+ ssh_keys._fetch_ssh_key(username)
+ if not ssh_keys._error_message:
+ self._ssh_keys_btn.set_value(username)
+ else:
+ dlg = BigDialog("", ssh_keys._error_message)
+ gui_app.set_modal_overlay(dlg)
+
+ def ssh_keys_callback():
+ github_username = ui_state.params.get("GithubUsername") or ""
+ dlg = BigInputDialog("enter GitHub username", github_username, confirm_callback=github_username_callback)
+ if not system_time_valid():
+ dlg = BigDialog("Please connect to Wi-Fi to fetch your key", "")
+ gui_app.set_modal_overlay(dlg)
+ return
+ gui_app.set_modal_overlay(dlg)
+
+ txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 77, 44)
+ github_username = ui_state.params.get("GithubUsername") or ""
+ self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh)
+ self._ssh_keys_btn.set_click_callback(ssh_keys_callback)
+
+ # adb, ssh, ssh keys, debug mode, joystick debug mode, longitudinal maneuver mode, ip address
+ # ******** Main Scroller ********
+ self._adb_toggle = BigParamControl("enable ADB", "AdbEnabled")
+ self._ssh_toggle = BigParamControl("enable SSH", "SshEnabled")
+ self._joystick_toggle = BigToggle("joystick debug mode",
+ initial_state=ui_state.params.get_bool("JoystickDebugMode"),
+ toggle_callback=self._on_joystick_debug_mode)
+ self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode",
+ initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"),
+ toggle_callback=self._on_long_maneuver_mode)
+ self._alpha_long_toggle = BigToggle("alpha longitudinal",
+ initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"),
+ toggle_callback=self._on_alpha_long_enabled)
+ self._debug_mode_toggle = BigParamControl("ui debug mode", "ShowDebugInfo",
+ toggle_callback=lambda checked: (gui_app.set_show_touches(checked),
+ gui_app.set_show_fps(checked)))
+
+ self._scroller = Scroller([
+ self._adb_toggle,
+ self._ssh_toggle,
+ self._ssh_keys_btn,
+ self._joystick_toggle,
+ self._long_maneuver_toggle,
+ self._alpha_long_toggle,
+ self._debug_mode_toggle,
+ ], snap_items=False)
+
+ # Toggle lists
+ self._refresh_toggles = (
+ ("AdbEnabled", self._adb_toggle),
+ ("SshEnabled", self._ssh_toggle),
+ ("JoystickDebugMode", self._joystick_toggle),
+ ("LongitudinalManeuverMode", self._long_maneuver_toggle),
+ ("AlphaLongitudinalEnabled", self._alpha_long_toggle),
+ ("ShowDebugInfo", self._debug_mode_toggle),
+ )
+ onroad_blocked_toggles = (self._adb_toggle, self._joystick_toggle)
+ release_blocked_toggles = (self._joystick_toggle, self._long_maneuver_toggle, self._alpha_long_toggle)
+ engaged_blocked_toggles = (self._long_maneuver_toggle, self._alpha_long_toggle)
+
+ # Hide non-release toggles on release builds
+ for item in release_blocked_toggles:
+ item.set_visible(not ui_state.is_release)
+
+ # Disable toggles that require offroad
+ for item in onroad_blocked_toggles:
+ item.set_enabled(lambda: ui_state.is_offroad())
+
+ # Disable toggles that require not engaged
+ for item in engaged_blocked_toggles:
+ item.set_enabled(lambda: not ui_state.engaged)
+
+ # Set initial state
+ if ui_state.params.get_bool("ShowDebugInfo"):
+ gui_app.set_show_touches(True)
+ gui_app.set_show_fps(True)
+
+ ui_state.add_offroad_transition_callback(self._update_toggles)
+
+ def show_event(self):
+ super().show_event()
+ self._scroller.show_event()
+ self._update_toggles()
+
+ def _render(self, rect: rl.Rectangle):
+ self._scroller.render(rect)
+
+ def _update_toggles(self):
+ ui_state.update_params()
+
+ # CP gating
+ if ui_state.CP is not None:
+ alpha_avail = ui_state.CP.alphaLongitudinalAvailable
+ if not alpha_avail or ui_state.is_release:
+ self._alpha_long_toggle.set_visible(False)
+ ui_state.params.remove("AlphaLongitudinalEnabled")
+ else:
+ self._alpha_long_toggle.set_visible(True)
+
+ long_man_enabled = ui_state.has_longitudinal_control and ui_state.is_offroad()
+ self._long_maneuver_toggle.set_enabled(long_man_enabled)
+ if not long_man_enabled:
+ self._long_maneuver_toggle.set_checked(False)
+ ui_state.params.put_bool("LongitudinalManeuverMode", False)
+ else:
+ self._long_maneuver_toggle.set_enabled(False)
+ self._alpha_long_toggle.set_visible(False)
+
+ # Refresh toggles from params to mirror external changes
+ for key, item in self._refresh_toggles:
+ item.set_checked(ui_state.params.get_bool(key))
+
+ def _on_joystick_debug_mode(self, state: bool):
+ ui_state.params.put_bool("JoystickDebugMode", state)
+ ui_state.params.put_bool("LongitudinalManeuverMode", False)
+ self._long_maneuver_toggle.set_checked(False)
+
+ def _on_long_maneuver_mode(self, state: bool):
+ ui_state.params.put_bool("LongitudinalManeuverMode", state)
+ ui_state.params.put_bool("JoystickDebugMode", False)
+ self._joystick_toggle.set_checked(False)
+ restart_needed_callback(state)
+
+ def _on_alpha_long_enabled(self, state: bool):
+ # TODO: show confirmation dialog before enabling
+ ui_state.params.put_bool("AlphaLongitudinalEnabled", state)
+ restart_needed_callback(state)
+ self._update_toggles()
diff --git a/selfdrive/ui/mici/layouts/settings/device.py b/selfdrive/ui/mici/layouts/settings/device.py
new file mode 100644
index 0000000000..b5e0ea838d
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/settings/device.py
@@ -0,0 +1,383 @@
+import os
+import threading
+import json
+import pyray as rl
+from enum import IntEnum
+from collections.abc import Callable
+
+from openpilot.common.basedir import BASEDIR
+from openpilot.common.params import Params
+from openpilot.common.time_helpers import system_time_valid
+from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
+from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton
+from openpilot.selfdrive.ui.mici.widgets.dialog import BigMultiOptionDialog, BigDialog, BigConfirmationDialogV2
+from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog
+from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog
+from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide
+from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
+from openpilot.system.ui.lib.multilang import tr
+from openpilot.system.ui.widgets import Widget, NavWidget
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.system.ui.widgets.label import MiciLabel
+from openpilot.system.ui.widgets.html_render import HtmlModal, HtmlRenderer
+from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID
+
+
+class MiciFccModal(NavWidget):
+ BACK_TOUCH_AREA_PERCENTAGE = 0.1
+
+ def __init__(self, file_path: str | None = None, text: str | None = None):
+ super().__init__()
+ self.set_back_callback(lambda: gui_app.set_modal_overlay(None))
+ self._content = HtmlRenderer(file_path=file_path, text=text)
+ self._scroll_panel = GuiScrollPanel2(horizontal=False)
+ self._fcc_logo = gui_app.texture("icons_mici/settings/device/fcc_logo.png", 76, 64)
+
+ def _render(self, rect: rl.Rectangle):
+ content_height = self._content.get_total_height(int(rect.width))
+ content_height += self._fcc_logo.height + 20
+
+ scroll_content_rect = rl.Rectangle(rect.x, rect.y, rect.width, content_height)
+ scroll_offset = self._scroll_panel.update(rect, scroll_content_rect.height)
+
+ fcc_pos = rl.Vector2(rect.x + 20, rect.y + 20 + scroll_offset)
+
+ scroll_content_rect.y += scroll_offset + self._fcc_logo.height + 20
+ self._content.render(scroll_content_rect)
+
+ rl.draw_texture_ex(self._fcc_logo, fcc_pos, 0.0, 1.0, rl.WHITE)
+
+ return -1
+
+
+def _engaged_confirmation_callback(callback: Callable, action_text: str):
+ if not ui_state.engaged:
+ def confirm_callback():
+ # Check engaged again in case it changed while the dialog was open
+ if not ui_state.engaged:
+ callback()
+
+ red = False
+ if action_text == "power off":
+ icon = "icons_mici/settings/device/power.png"
+ red = True
+ elif action_text == "reboot":
+ icon = "icons_mici/settings/device/reboot.png"
+ elif action_text == "reset":
+ icon = "icons_mici/settings/device/lkas.png"
+ elif action_text == "uninstall":
+ icon = "icons_mici/settings/device/uninstall.png"
+ else:
+ # TODO: check
+ icon = "icons_mici/settings/comma_icon.png"
+
+ dlg: BigConfirmationDialogV2 | BigDialog = BigConfirmationDialogV2(f"slide to\n{action_text.lower()}", icon, red=red,
+ exit_on_confirm=action_text == "reset",
+ confirm_callback=confirm_callback)
+ gui_app.set_modal_overlay(dlg)
+ else:
+ dlg = BigDialog(f"Disengage to {action_text}", "")
+ gui_app.set_modal_overlay(dlg)
+
+
+class DeviceInfoLayoutMici(Widget):
+ def __init__(self):
+ super().__init__()
+
+ self.set_rect(rl.Rectangle(0, 0, 360, 180))
+
+ params = Params()
+ header_color = rl.Color(255, 255, 255, int(255 * 0.9))
+ subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))
+ max_width = int(self._rect.width - 20)
+ self._dongle_id_label = MiciLabel("device ID", 48, width=max_width, color=header_color, font_weight=FontWeight.DISPLAY)
+ self._dongle_id_text_label = MiciLabel(params.get("DongleId") or 'N/A', 32, width=max_width, color=subheader_color, font_weight=FontWeight.ROMAN)
+
+ self._serial_number_label = MiciLabel("serial", 48, color=header_color, font_weight=FontWeight.DISPLAY)
+ self._serial_number_text_label = MiciLabel(params.get("HardwareSerial") or 'N/A', 32, width=max_width, color=subheader_color, font_weight=FontWeight.ROMAN)
+
+ def _render(self, _):
+ self._dongle_id_label.set_position(self._rect.x + 20, self._rect.y - 10)
+ self._dongle_id_label.render()
+
+ self._dongle_id_text_label.set_position(self._rect.x + 20, self._rect.y + 68 - 25)
+ self._dongle_id_text_label.render()
+
+ self._serial_number_label.set_position(self._rect.x + 20, self._rect.y + 114 - 30)
+ self._serial_number_label.render()
+
+ self._serial_number_text_label.set_position(self._rect.x + 20, self._rect.y + 161 - 25)
+ self._serial_number_text_label.render()
+
+
+class UpdaterState(IntEnum):
+ IDLE = 0
+ WAITING_FOR_UPDATER = 1
+ UPDATER_RESPONDING = 2
+
+
+class PairBigButton(BigButton):
+ def __init__(self):
+ super().__init__("pair", "connect.comma.ai", "icons_mici/settings/comma_icon.png")
+
+ def _update_state(self):
+ if ui_state.prime_state.is_paired():
+ self.set_text("paired")
+ if ui_state.prime_state.is_prime():
+ self.set_value("subscribed")
+ else:
+ self.set_value("upgrade to prime")
+ else:
+ self.set_text("pair")
+ self.set_value("connect.comma.ai")
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+
+ # TODO: show ad dialog when clicked if not prime
+ if ui_state.prime_state.is_paired():
+ return
+ dlg: BigDialog | PairingDialog
+ if not system_time_valid():
+ dlg = BigDialog(tr("Please connect to Wi-Fi to complete initial pairing"), "")
+ elif UNREGISTERED_DONGLE_ID == (ui_state.params.get("DongleId") or UNREGISTERED_DONGLE_ID):
+ dlg = BigDialog(tr("Device must be registered with the comma.ai backend to pair"), "")
+ else:
+ dlg = PairingDialog()
+ gui_app.set_modal_overlay(dlg)
+
+
+UPDATER_TIMEOUT = 10.0 # seconds to wait for updater to respond
+
+
+class UpdateOpenpilotBigButton(BigButton):
+ def __init__(self):
+ self._txt_update_icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64)
+ self._txt_reboot_icon = gui_app.texture("icons_mici/settings/device/reboot.png", 64, 64)
+ self._txt_up_to_date_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64)
+ super().__init__("update openpilot", "", self._txt_update_icon)
+
+ self._waiting_for_updater_t: float | None = None
+ self._hide_value_t: float | None = None
+ self._state: UpdaterState = UpdaterState.IDLE
+
+ ui_state.add_offroad_transition_callback(self.offroad_transition)
+
+ def offroad_transition(self):
+ if ui_state.is_offroad():
+ self.set_enabled(True)
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ if not system_time_valid():
+ dlg = BigDialog(tr("Please connect to Wi-Fi to update"), "")
+ gui_app.set_modal_overlay(dlg)
+ return
+
+ self.set_enabled(False)
+ self._state = UpdaterState.WAITING_FOR_UPDATER
+ self.set_icon(self._txt_update_icon)
+
+ def run():
+ if self.get_value() == "download update":
+ os.system("pkill -SIGHUP -f system.updated.updated")
+ elif self.get_value() == "update now":
+ ui_state.params.put_bool("DoReboot", True)
+ else:
+ os.system("pkill -SIGUSR1 -f system.updated.updated")
+
+ threading.Thread(target=run, daemon=True).start()
+
+ def set_value(self, value: str):
+ super().set_value(value)
+ if value:
+ self.set_text("")
+ else:
+ self.set_text("update openpilot")
+
+ def _update_state(self):
+ if ui_state.started:
+ self.set_enabled(False)
+ return
+
+ updater_state = ui_state.params.get("UpdaterState") or ""
+ failed_count = ui_state.params.get("UpdateFailedCount")
+ failed = False if failed_count is None else int(failed_count) > 0
+
+ if ui_state.params.get_bool("UpdateAvailable"):
+ self.set_rotate_icon(False)
+ self.set_enabled(True)
+ if self.get_value() != "update now":
+ self.set_value("update now")
+ self.set_icon(self._txt_reboot_icon)
+
+ elif self._state == UpdaterState.WAITING_FOR_UPDATER:
+ self.set_rotate_icon(True)
+ if updater_state != "idle":
+ self._state = UpdaterState.UPDATER_RESPONDING
+
+ # Recover from updater not responding (time invalid shortly after boot)
+ if self._waiting_for_updater_t is None:
+ self._waiting_for_updater_t = rl.get_time()
+
+ if self._waiting_for_updater_t is not None and rl.get_time() - self._waiting_for_updater_t > UPDATER_TIMEOUT:
+ self.set_rotate_icon(False)
+ self.set_value("updater failed to respond")
+ self._state = UpdaterState.IDLE
+ self._hide_value_t = rl.get_time()
+
+ elif self._state == UpdaterState.UPDATER_RESPONDING:
+ if updater_state == "idle":
+ self.set_rotate_icon(False)
+ self._state = UpdaterState.IDLE
+ self._hide_value_t = rl.get_time()
+ else:
+ if self.get_value() != updater_state:
+ self.set_value(updater_state)
+
+ elif self._state == UpdaterState.IDLE:
+ self.set_rotate_icon(False)
+ if failed:
+ if self.get_value() != "failed to update":
+ self.set_value("failed to update")
+
+ elif ui_state.params.get_bool("UpdaterFetchAvailable"):
+ self.set_enabled(True)
+ if self.get_value() != "download update":
+ self.set_value("download update")
+
+ elif self._hide_value_t is not None:
+ self.set_enabled(True)
+ if self.get_value() == "checking...":
+ self.set_value("up to date")
+ self.set_icon(self._txt_up_to_date_icon)
+
+ # Hide previous text after short amount of time (up to date or failed)
+ if rl.get_time() - self._hide_value_t > 3.0:
+ self._hide_value_t = None
+ self.set_value("")
+ self.set_icon(self._txt_update_icon)
+ else:
+ if self.get_value() != "":
+ self.set_value("")
+
+ if self._state != UpdaterState.WAITING_FOR_UPDATER:
+ self._waiting_for_updater_t = None
+
+
+class DeviceLayoutMici(NavWidget):
+ def __init__(self, back_callback: Callable):
+ super().__init__()
+
+ self._fcc_dialog: HtmlModal | None = None
+ self._driver_camera: DriverCameraDialog | None = None
+ self._training_guide: TrainingGuide | None = None
+
+ def power_off_callback():
+ ui_state.params.put_bool("DoShutdown", True)
+
+ def reboot_callback():
+ ui_state.params.put_bool("DoReboot", True)
+
+ def reset_calibration_callback():
+ params = ui_state.params
+ params.remove("CalibrationParams")
+ params.remove("LiveTorqueParameters")
+ params.remove("LiveParameters")
+ params.remove("LiveParametersV2")
+ params.remove("LiveDelay")
+ params.put_bool("OnroadCycleRequested", True)
+
+ def uninstall_openpilot_callback():
+ ui_state.params.put_bool("DoUninstall", True)
+
+ reset_calibration_btn = BigButton("reset calibration", "", "icons_mici/settings/device/lkas.png")
+ reset_calibration_btn.set_click_callback(lambda: _engaged_confirmation_callback(reset_calibration_callback, "reset"))
+
+ uninstall_openpilot_btn = BigButton("uninstall openpilot", "", "icons_mici/settings/device/uninstall.png")
+ uninstall_openpilot_btn.set_click_callback(lambda: _engaged_confirmation_callback(uninstall_openpilot_callback, "uninstall"))
+
+ reboot_btn = BigCircleButton("icons_mici/settings/device/reboot.png", red=False)
+ reboot_btn.set_click_callback(lambda: _engaged_confirmation_callback(reboot_callback, "reboot"))
+
+ self._power_off_btn = BigCircleButton("icons_mici/settings/device/power.png", red=True)
+ self._power_off_btn.set_click_callback(lambda: _engaged_confirmation_callback(power_off_callback, "power off"))
+
+ self._load_languages()
+
+ def language_callback():
+ def selected_language_callback():
+ selected_language = dlg.get_selected_option()
+ ui_state.params.put("LanguageSetting", self._languages[selected_language])
+
+ current_language_name = ui_state.params.get("LanguageSetting")
+ current_language = next(name for name, lang in self._languages.items() if lang == current_language_name)
+
+ dlg = BigMultiOptionDialog(list(self._languages), default=current_language, right_btn_callback=selected_language_callback)
+ gui_app.set_modal_overlay(dlg)
+
+ # lang_button = BigButton("change language", "", "icons_mici/settings/device/language.png")
+ # lang_button.set_click_callback(language_callback)
+
+ regulatory_btn = BigButton("regulatory info", "", "icons_mici/settings/device/info.png")
+ regulatory_btn.set_click_callback(self._on_regulatory)
+
+ driver_cam_btn = BigButton("driver camera preview", "", "icons_mici/settings/device/cameras.png")
+ driver_cam_btn.set_click_callback(self._show_driver_camera)
+ driver_cam_btn.set_enabled(lambda: ui_state.is_offroad())
+
+ review_training_guide_btn = BigButton("review training guide", "", "icons_mici/settings/device/info.png")
+ review_training_guide_btn.set_click_callback(self._on_review_training_guide)
+ review_training_guide_btn.set_enabled(lambda: ui_state.is_offroad())
+
+ self._scroller = Scroller([
+ DeviceInfoLayoutMici(),
+ UpdateOpenpilotBigButton(),
+ PairBigButton(),
+ review_training_guide_btn,
+ driver_cam_btn,
+ # lang_button,
+ reset_calibration_btn,
+ uninstall_openpilot_btn,
+ regulatory_btn,
+ reboot_btn,
+ self._power_off_btn,
+ ], snap_items=False)
+
+ # Set up back navigation
+ self.set_back_callback(back_callback)
+
+ # Hide power off button when onroad
+ ui_state.add_offroad_transition_callback(self._offroad_transition)
+
+ def _on_regulatory(self):
+ if not self._fcc_dialog:
+ self._fcc_dialog = MiciFccModal(os.path.join(BASEDIR, "selfdrive/assets/offroad/mici_fcc.html"))
+ gui_app.set_modal_overlay(self._fcc_dialog, callback=setattr(self, '_fcc_dialog', None))
+
+ def _offroad_transition(self):
+ self._power_off_btn.set_visible(ui_state.is_offroad())
+
+ def _show_driver_camera(self):
+ if not self._driver_camera:
+ self._driver_camera = DriverCameraDialog()
+ gui_app.set_modal_overlay(self._driver_camera, callback=lambda result: setattr(self, '_driver_camera', None))
+
+ def _on_review_training_guide(self):
+ if not self._training_guide:
+ def completed_callback():
+ gui_app.set_modal_overlay(None)
+
+ self._training_guide = TrainingGuide(completed_callback=completed_callback)
+ gui_app.set_modal_overlay(self._training_guide, callback=lambda result: setattr(self, '_training_guide', None))
+
+ def _load_languages(self):
+ with open(os.path.join(BASEDIR, "selfdrive/ui/translations/languages.json")) as f:
+ self._languages = json.load(f)
+
+ def show_event(self):
+ super().show_event()
+ self._scroller.show_event()
+
+ def _render(self, rect: rl.Rectangle):
+ self._scroller.render(rect)
diff --git a/selfdrive/ui/mici/layouts/settings/firehose.py b/selfdrive/ui/mici/layouts/settings/firehose.py
new file mode 100644
index 0000000000..303d976b07
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/settings/firehose.py
@@ -0,0 +1,223 @@
+import threading
+import time
+import pyray as rl
+
+from openpilot.common.api import api_get
+from openpilot.common.params import Params
+from openpilot.common.swaglog import cloudlog
+from openpilot.selfdrive.ui.lib.api_helpers import get_token
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID
+from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
+from openpilot.system.ui.lib.wrap_text import wrap_text
+from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
+from openpilot.system.ui.lib.multilang import tr, trn, tr_noop
+from openpilot.system.ui.widgets import NavWidget
+
+
+TITLE = tr_noop("Firehose Mode")
+DESCRIPTION = tr_noop(
+ "openpilot learns to drive by watching humans, like you, drive.\n\n"
+ + "Firehose Mode allows you to maximize your training data uploads to improve "
+ + "openpilot's driving models. More data means bigger models, which means better Experimental Mode."
+)
+INSTRUCTIONS_INTRO = tr_noop(
+ "For maximum effectiveness, bring your device inside and connect to a good USB-C adapter and Wi-Fi weekly.\n\n"
+ + "Firehose Mode can also work while you're driving if connected to a hotspot or unlimited SIM card."
+)
+FAQ_HEADER = tr_noop("Frequently Asked Questions")
+FAQ_ITEMS = [
+ (tr_noop("Does it matter how or where I drive?"), tr_noop("Nope, just drive as you normally would.")),
+ (tr_noop("Do all of my segments get pulled in Firehose Mode?"), tr_noop("No, we selectively pull a subset of your segments.")),
+ (tr_noop("What's a good USB-C adapter?"), tr_noop("Any fast phone or laptop charger should be fine.")),
+ (tr_noop("Does it matter which software I run?"), tr_noop("Yes, only upstream openpilot (and particular forks) are able to be used for training.")),
+]
+
+
+class FirehoseLayoutMici(NavWidget):
+ BACK_TOUCH_AREA_PERCENTAGE = 0.1
+
+ PARAM_KEY = "ApiCache_FirehoseStats"
+ GREEN = rl.Color(46, 204, 113, 255)
+ RED = rl.Color(231, 76, 60, 255)
+ GRAY = rl.Color(68, 68, 68, 255)
+ LIGHT_GRAY = rl.Color(228, 228, 228, 255)
+ UPDATE_INTERVAL = 30 # seconds
+
+ def __init__(self, back_callback):
+ super().__init__()
+ self.set_back_callback(back_callback)
+
+ self.params = Params()
+ self.segment_count = self._get_segment_count()
+
+ self._scroll_panel = GuiScrollPanel2(horizontal=False)
+ self._content_height = 0
+
+ self._running = True
+ self._update_thread = threading.Thread(target=self._update_loop, daemon=True)
+ self._update_thread.start()
+
+ def __del__(self):
+ self._running = False
+ try:
+ if self._update_thread and self._update_thread.is_alive():
+ self._update_thread.join(timeout=1.0)
+ except Exception:
+ pass
+
+ def show_event(self):
+ super().show_event()
+ self._scroll_panel.set_offset(0)
+
+ def _get_segment_count(self) -> int:
+ stats = self.params.get(self.PARAM_KEY)
+ if not stats:
+ return 0
+ try:
+ return int(stats.get("firehose", 0))
+ except Exception:
+ cloudlog.exception(f"Failed to decode firehose stats: {stats}")
+ return 0
+
+ def _render(self, rect: rl.Rectangle):
+ # compute total content height for scrolling
+ content_height = self._measure_content_height(rect)
+ scroll_offset = self._scroll_panel.update(rect, content_height)
+
+ # start drawing with offset
+ x = int(rect.x + 40)
+ y = int(rect.y + 40 + scroll_offset)
+ w = int(rect.width - 80)
+
+ # Title
+ title_text = tr(TITLE)
+ title_font = gui_app.font(FontWeight.BOLD)
+ title_size = 64
+ rl.draw_text_ex(title_font, title_text, rl.Vector2(x, y), title_size, 0, rl.WHITE)
+ y += int(title_size * FONT_SCALE) + 20
+
+ # Description
+ y = self._draw_wrapped_text(x, y, w, tr(DESCRIPTION), gui_app.font(FontWeight.ROMAN), 36, rl.WHITE)
+ y += 20
+
+ # Separator
+ rl.draw_rectangle(x, y, w, 2, self.GRAY)
+ y += 20
+
+ # Status
+ status_text, status_color = self._get_status()
+ y = self._draw_wrapped_text(x, y, w, status_text, gui_app.font(FontWeight.BOLD), 48, status_color)
+ y += 20
+
+ # Contribution count (if available)
+ if self.segment_count > 0:
+ contrib_text = trn("{} segment of your driving is in the training dataset so far.",
+ "{} segments of your driving is in the training dataset so far.", self.segment_count).format(self.segment_count)
+ y = self._draw_wrapped_text(x, y, w, contrib_text, gui_app.font(FontWeight.BOLD), 42, rl.WHITE)
+ y += 20
+
+ # Separator
+ rl.draw_rectangle(x, y, w, 2, self.GRAY)
+ y += 20
+
+ # Instructions intro
+ y = self._draw_wrapped_text(x, y, w, tr(INSTRUCTIONS_INTRO), gui_app.font(FontWeight.ROMAN), 32, self.LIGHT_GRAY)
+ y += 20
+
+ # FAQ Header
+ y = self._draw_wrapped_text(x, y, w, tr(FAQ_HEADER), gui_app.font(FontWeight.BOLD), 44, rl.WHITE)
+ y += 20
+
+ # FAQ Items
+ for question, answer in FAQ_ITEMS:
+ y = self._draw_wrapped_text(x, y, w, tr(question), gui_app.font(FontWeight.BOLD), 32, self.LIGHT_GRAY)
+ y = self._draw_wrapped_text(x, y, w, tr(answer), gui_app.font(FontWeight.ROMAN), 32, self.LIGHT_GRAY)
+ y += 20
+
+ # return value not used by NavWidget
+ return -1
+
+ def _draw_wrapped_text(self, x, y, width, text, font, font_size, color):
+ wrapped = wrap_text(font, text, font_size, width)
+ for line in wrapped:
+ rl.draw_text_ex(font, line, rl.Vector2(x, y), font_size, 0, color)
+ y += int(font_size * FONT_SCALE)
+ return y
+
+ def _measure_content_height(self, rect: rl.Rectangle) -> int:
+ # Rough measurement using the same wrapping as rendering
+ w = int(rect.width - 80)
+ y = 40
+
+ # Title
+ title_size = 72
+ y += int(title_size * FONT_SCALE) + 20
+
+ # Description
+ desc_lines = wrap_text(gui_app.font(FontWeight.ROMAN), tr(DESCRIPTION), 36, w)
+ y += int(len(desc_lines) * 36 * FONT_SCALE) + 20
+
+ # Separator + Status
+ y += 2 + 20
+ status_text, _ = self._get_status()
+ status_lines = wrap_text(gui_app.font(FontWeight.BOLD), status_text, 48, w)
+ y += int(len(status_lines) * 48 * FONT_SCALE) + 20
+
+ # Contribution count
+ if self.segment_count > 0:
+ contrib_text = trn("{} segment of your driving is in the training dataset so far.",
+ "{} segments of your driving is in the training dataset so far.", self.segment_count).format(self.segment_count)
+ contrib_lines = wrap_text(gui_app.font(FontWeight.BOLD), contrib_text, 42, w)
+ y += int(len(contrib_lines) * 42 * FONT_SCALE) + 20
+
+ # Separator + Instructions
+ y += 2 + 20
+
+ # Instructions intro
+ intro_lines = wrap_text(gui_app.font(FontWeight.ROMAN), tr(INSTRUCTIONS_INTRO), 32, w)
+ y += int(len(intro_lines) * 32 * FONT_SCALE) + 20
+
+ # FAQ Header
+ faq_header_lines = wrap_text(gui_app.font(FontWeight.BOLD), tr(FAQ_HEADER), 44, w)
+ y += int(len(faq_header_lines) * 44 * FONT_SCALE) + 20
+
+ # FAQ Items
+ for question, answer in FAQ_ITEMS:
+ q_lines = wrap_text(gui_app.font(FontWeight.BOLD), tr(question), 32, w)
+ y += int(len(q_lines) * 32 * FONT_SCALE)
+ a_lines = wrap_text(gui_app.font(FontWeight.ROMAN), tr(answer), 32, w)
+ y += int(len(a_lines) * 32 * FONT_SCALE) + 20
+
+ # bottom padding
+ y += 40
+ return y
+
+ def _get_status(self) -> tuple[str, rl.Color]:
+ network_type = ui_state.sm["deviceState"].networkType
+ network_metered = ui_state.sm["deviceState"].networkMetered
+
+ if not network_metered and network_type != 0: # Not metered and connected
+ return tr("ACTIVE"), self.GREEN
+ else:
+ return tr("INACTIVE: connect to an unmetered network"), self.RED
+
+ def _fetch_firehose_stats(self):
+ try:
+ dongle_id = self.params.get("DongleId")
+ if not dongle_id or dongle_id == UNREGISTERED_DONGLE_ID:
+ return
+ identity_token = get_token(dongle_id)
+ response = api_get(f"v1/devices/{dongle_id}/firehose_stats", access_token=identity_token)
+ if response.status_code == 200:
+ data = response.json()
+ self.segment_count = data.get("firehose", 0)
+ self.params.put(self.PARAM_KEY, data)
+ except Exception as e:
+ cloudlog.error(f"Failed to fetch firehose stats: {e}")
+
+ def _update_loop(self):
+ while self._running:
+ if not ui_state.started:
+ self._fetch_firehose_stats()
+ time.sleep(self.UPDATE_INTERVAL)
diff --git a/selfdrive/ui/mici/layouts/settings/network.py b/selfdrive/ui/mici/layouts/settings/network.py
new file mode 100644
index 0000000000..a62c1d153a
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/settings/network.py
@@ -0,0 +1,558 @@
+import math
+import numpy as np
+import pyray as rl
+from enum import IntEnum
+from collections.abc import Callable
+
+from openpilot.common.swaglog import cloudlog
+from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.label import UnifiedLabel
+from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigToggle
+from openpilot.selfdrive.ui.mici.widgets.dialog import BigMultiOptionDialog, BigInputDialog, BigDialogOptionButton, BigConfirmationDialogV2
+from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight
+from openpilot.system.ui.widgets import Widget, NavWidget
+from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType, MeteredType
+
+
+def normalize_ssid(ssid: str) -> str:
+ return ssid.replace("’", "'") # for iPhone hotspots
+
+
+class NetworkPanelType(IntEnum):
+ NONE = 0
+ WIFI = 1
+
+
+class LoadingAnimation(Widget):
+ def _render(self, _):
+ cx = int(self._rect.x + 70)
+ cy = int(self._rect.y + self._rect.height / 2 - 50)
+
+ y_mag = 20
+ anim_scale = 5
+ spacing = 28
+
+ for i in range(3):
+ x = cx - spacing + i * spacing
+ y = int(cy + min(math.sin((rl.get_time() - i * 0.2) * anim_scale) * y_mag, 0))
+ alpha = int(np.interp(cy - y, [0, y_mag], [255 * 0.45, 255 * 0.9]))
+ rl.draw_circle(x, y, 10, rl.Color(255, 255, 255, alpha))
+
+
+class WifiIcon(Widget):
+ def __init__(self):
+ super().__init__()
+ self.set_rect(rl.Rectangle(0, 0, 89, 64))
+
+ self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 89, 64)
+ self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 89, 64)
+ self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 89, 64)
+ self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 89, 64)
+ self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 89, 64)
+ self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 23, 32)
+
+ self._network: Network | None = None
+ self._scale = 1.0
+
+ def set_current_network(self, network: Network):
+ self._network = network
+
+ def set_scale(self, scale: float):
+ self._scale = scale
+
+ def _render(self, _):
+ if self._network is None:
+ return
+
+ # Determine which wifi strength icon to use
+ strength = round(self._network.strength / 100 * 4)
+ if strength == 4:
+ strength_icon = self._wifi_full_txt
+ elif strength == 3:
+ strength_icon = self._wifi_medium_txt
+ elif strength == 2:
+ strength_icon = self._wifi_low_txt
+ elif self._network.strength < 0:
+ strength_icon = self._wifi_slash_txt
+ else:
+ strength_icon = self._wifi_none_txt
+
+ icon_x = int(self._rect.x + (self._rect.width - strength_icon.width * self._scale) // 2)
+ icon_y = int(self._rect.y + (self._rect.height - strength_icon.height * self._scale) // 2)
+ rl.draw_texture_ex(strength_icon, (icon_x, icon_y), 0.0, self._scale, rl.WHITE)
+
+ # Render lock icon at lower right of wifi icon if secured
+ if self._network.security_type not in (SecurityType.OPEN, SecurityType.UNSUPPORTED):
+ lock_scale = self._scale * 1.1
+ lock_x = int(icon_x + 1 + strength_icon.width * self._scale - self._lock_txt.width * lock_scale / 2)
+ lock_y = int(icon_y + 1 + strength_icon.height * self._scale - self._lock_txt.height * lock_scale / 2)
+ rl.draw_texture_ex(self._lock_txt, (lock_x, lock_y), 0.0, lock_scale, rl.WHITE)
+
+
+class WifiItem(BigDialogOptionButton):
+ LEFT_MARGIN = 20
+
+ def __init__(self, network: Network):
+ super().__init__(network.ssid)
+
+ self.set_rect(rl.Rectangle(0, 0, gui_app.width, 64))
+
+ self._selected_txt = gui_app.texture("icons_mici/settings/network/new/wifi_selected.png", 48, 96)
+
+ self._network = network
+ self._wifi_icon = WifiIcon()
+ self._wifi_icon.set_current_network(network)
+
+ def set_current_network(self, network: Network):
+ self._network = network
+ self._wifi_icon.set_current_network(network)
+
+ def _render(self, _):
+ if self._network.is_connected:
+ selected_x = int(self._rect.x - self._selected_txt.width / 2)
+ selected_y = int(self._rect.y + (self._rect.height - self._selected_txt.height) / 2)
+ rl.draw_texture(self._selected_txt, selected_x, selected_y, rl.WHITE)
+
+ self._wifi_icon.set_scale((1.0 if self._selected else 0.65) * 0.7)
+ self._wifi_icon.render(rl.Rectangle(
+ self._rect.x + self.LEFT_MARGIN,
+ self._rect.y,
+ self._rect.height,
+ self._rect.height
+ ))
+
+ if self._selected:
+ self._label.set_font_size(74)
+ self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.9)))
+ self._label.set_font_weight(FontWeight.DISPLAY)
+ else:
+ self._label.set_font_size(70)
+ self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.58)))
+ self._label.set_font_weight(FontWeight.DISPLAY_REGULAR)
+
+ label_offset = self.LEFT_MARGIN + self._wifi_icon.rect.width + 20
+ label_rect = rl.Rectangle(self._rect.x + label_offset, self._rect.y, self._rect.width - label_offset, self._rect.height)
+ self._label.set_text(normalize_ssid(self._network.ssid))
+ self._label.render(label_rect)
+
+
+class ConnectButton(Widget):
+ def __init__(self):
+ super().__init__()
+ self._bg_txt = gui_app.texture("icons_mici/settings/network/new/connect_button.png", 410, 100)
+ self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/connect_button_pressed.png", 410, 100)
+ self._bg_full_txt = gui_app.texture("icons_mici/settings/network/new/full_connect_button.png", 520, 100)
+ self._bg_full_pressed_txt = gui_app.texture("icons_mici/settings/network/new/full_connect_button_pressed.png", 520, 100)
+
+ self._full: bool = False
+
+ self._label = UnifiedLabel("", 36, FontWeight.MEDIUM, rl.Color(255, 255, 255, int(255 * 0.9)),
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
+
+ @property
+ def full(self) -> bool:
+ return self._full
+
+ def set_full(self, full: bool):
+ self._full = full
+ self.set_rect(rl.Rectangle(0, 0, 520 if self._full else 410, 100))
+
+ def set_label(self, text: str):
+ self._label.set_text(text)
+
+ def _render(self, _):
+ if self._full:
+ bg_txt = self._bg_full_pressed_txt if self.is_pressed and self.enabled else self._bg_full_txt
+ else:
+ bg_txt = self._bg_pressed_txt if self.is_pressed and self.enabled else self._bg_txt
+
+ rl.draw_texture(bg_txt, int(self._rect.x), int(self._rect.y), rl.WHITE)
+
+ self._label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9) if self.enabled else int(255 * 0.9 * 0.65)))
+ self._label.render(self._rect)
+
+
+class ForgetButton(Widget):
+ HORIZONTAL_MARGIN = 8
+
+ def __init__(self, forget_network: Callable, open_network_manage_page):
+ super().__init__()
+ self._forget_network = forget_network
+ self._open_network_manage_page = open_network_manage_page
+
+ self._bg_txt = gui_app.texture("icons_mici/settings/network/new/forget_button.png", 100, 100)
+ self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/forget_button_pressed.png", 100, 100)
+ self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 32, 36)
+ self.set_rect(rl.Rectangle(0, 0, 100 + self.HORIZONTAL_MARGIN * 2, 100))
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+ dlg = BigConfirmationDialogV2("slide to forget", "icons_mici/settings/network/new/trash.png", red=True,
+ confirm_callback=self._forget_network)
+ gui_app.set_modal_overlay(dlg, callback=self._open_network_manage_page)
+
+ def _render(self, _):
+ bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt
+ rl.draw_texture(bg_txt, int(self._rect.x + self.HORIZONTAL_MARGIN), int(self._rect.y), rl.WHITE)
+
+ trash_x = int(self._rect.x + (self._rect.width - self._trash_txt.width) // 2)
+ trash_y = int(self._rect.y + (self._rect.height - self._trash_txt.height) // 2)
+ rl.draw_texture(self._trash_txt, trash_x, trash_y, rl.WHITE)
+
+
+class NetworkInfoPage(NavWidget):
+ def __init__(self, wifi_manager, connect_callback: Callable, forget_callback: Callable, open_network_manage_page: Callable):
+ super().__init__()
+ self._wifi_manager = wifi_manager
+
+ self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+
+ self._wifi_icon = WifiIcon()
+ self._forget_btn = ForgetButton(lambda: forget_callback(self._network.ssid) if self._network is not None else None,
+ open_network_manage_page)
+ self._connect_btn = ConnectButton()
+ self._connect_btn.set_click_callback(lambda: connect_callback(self._network.ssid) if self._network is not None else None)
+
+ self._title = UnifiedLabel("", 64, FontWeight.DISPLAY, rl.Color(255, 255, 255, int(255 * 0.9)),
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
+ self._subtitle = UnifiedLabel("", 36, FontWeight.ROMAN, rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)),
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
+
+ self.set_back_callback(lambda: gui_app.set_modal_overlay(None))
+
+ # State
+ self._network: Network | None = None
+ self._connecting: Callable[[], str | None] | None = None
+
+ def update_networks(self, networks: dict[str, Network]):
+ # update current network from latest scan results
+ for ssid, network in networks.items():
+ if self._network is not None and ssid == self._network.ssid:
+ self.set_current_network(network)
+ break
+ else:
+ # network disappeared, close page
+ gui_app.set_modal_overlay(None)
+
+ def _update_state(self):
+ super()._update_state()
+ # Modal overlays stop main UI rendering, so we need to call here
+ self._wifi_manager.process_callbacks()
+
+ if self._network is None:
+ return
+
+ self._connect_btn.set_full(not self._network.is_saved and not self._is_connecting)
+ if self._is_connecting:
+ self._connect_btn.set_label("connecting...")
+ self._connect_btn.set_enabled(False)
+ elif self._network.is_connected:
+ self._connect_btn.set_label("connected")
+ self._connect_btn.set_enabled(False)
+ elif self._network.security_type == SecurityType.UNSUPPORTED:
+ self._connect_btn.set_label("connect")
+ self._connect_btn.set_enabled(False)
+ else: # saved or unknown
+ self._connect_btn.set_label("connect")
+ self._connect_btn.set_enabled(True)
+
+ self._title.set_text(normalize_ssid(self._network.ssid))
+ if self._network.security_type == SecurityType.OPEN:
+ self._subtitle.set_text("open")
+ elif self._network.security_type == SecurityType.UNSUPPORTED:
+ self._subtitle.set_text("unsupported")
+ else:
+ self._subtitle.set_text("secured")
+
+ def set_current_network(self, network: Network):
+ self._network = network
+ self._wifi_icon.set_current_network(network)
+
+ def set_connecting(self, is_connecting: Callable[[], str | None]):
+ self._connecting = is_connecting
+
+ @property
+ def _is_connecting(self):
+ if self._connecting is None or self._network is None:
+ return False
+ is_connecting = self._connecting() == self._network.ssid
+ return is_connecting
+
+ def _render(self, _):
+ self._wifi_icon.render(rl.Rectangle(
+ self._rect.x + 32,
+ self._rect.y + (self._rect.height - self._connect_btn.rect.height - self._wifi_icon.rect.height) / 2,
+ self._wifi_icon.rect.width,
+ self._wifi_icon.rect.height,
+ ))
+
+ self._title.render(rl.Rectangle(
+ self._rect.x + self._wifi_icon.rect.width + 32 + 32,
+ self._rect.y + 32 - 16,
+ self._rect.width - (self._wifi_icon.rect.width + 32 + 32),
+ 64,
+ ))
+
+ self._subtitle.render(rl.Rectangle(
+ self._rect.x + self._wifi_icon.rect.width + 32 + 32,
+ self._rect.y + 32 + 64 - 16,
+ self._rect.width - (self._wifi_icon.rect.width + 32 + 32),
+ 48,
+ ))
+
+ self._connect_btn.render(rl.Rectangle(
+ self._rect.x + 8,
+ self._rect.y + self._rect.height - self._connect_btn.rect.height,
+ self._connect_btn.rect.width,
+ self._connect_btn.rect.height,
+ ))
+
+ if not self._connect_btn.full:
+ self._forget_btn.render(rl.Rectangle(
+ self._rect.x + self._rect.width - self._forget_btn.rect.width,
+ self._rect.y + self._rect.height - self._forget_btn.rect.height,
+ self._forget_btn.rect.width,
+ self._forget_btn.rect.height,
+ ))
+
+ return -1
+
+
+class WifiUIMici(BigMultiOptionDialog):
+ def __init__(self, wifi_manager: WifiManager, back_callback: Callable):
+ super().__init__([], None, None, right_btn_callback=None)
+
+ # Set up back navigation
+ self.set_back_callback(back_callback)
+
+ self._network_info_page = NetworkInfoPage(wifi_manager, self._connect_to_network, self._forget_network, self._open_network_manage_page)
+ self._network_info_page.set_connecting(lambda: self._connecting)
+ self._should_open_network_info_page = False # wait for scroll_to animation
+
+ self._loading_animation = LoadingAnimation()
+
+ self._wifi_manager = wifi_manager
+ self._connecting: str | None = None
+ self._networks: dict[str, Network] = {}
+
+ self._wifi_manager.add_callbacks(
+ need_auth=self._on_need_auth,
+ activated=self._on_activated,
+ forgotten=self._on_forgotten,
+ networks_updated=self._on_network_updated,
+ disconnected=self._on_disconnected,
+ )
+
+ def show_event(self):
+ # Call super to prepare scroller; selection scroll is handled dynamically
+ super().show_event()
+ self._wifi_manager.set_active(True)
+ self._scroller.show_event()
+
+ def hide_event(self):
+ super().hide_event()
+ self._wifi_manager.set_active(False)
+
+ def _update_state(self):
+ super()._update_state()
+ if self._should_open_network_info_page:
+ self._should_open_network_info_page = False
+ self._open_network_manage_page()
+
+ def _open_network_manage_page(self, result=None):
+ self._network_info_page.update_networks(self._networks)
+ gui_app.set_modal_overlay(self._network_info_page)
+
+ def _forget_network(self, ssid: str):
+ network = self._networks.get(ssid)
+ if network is None:
+ cloudlog.warning(f"Trying to forget unknown network: {ssid}")
+ return
+
+ self._wifi_manager.forget_connection(network.ssid)
+
+ def _on_network_updated(self, networks: list[Network]):
+ self._networks = {network.ssid: network for network in networks}
+ self._update_buttons()
+ self._network_info_page.update_networks(self._networks)
+
+ def _update_buttons(self):
+ for network in self._networks.values():
+ # pop and re-insert to eliminate stuttering on update (prevents position lost for a frame)
+ network_button_idx = next((i for i, btn in enumerate(self._scroller._items) if btn.option == network.ssid), None)
+ if network_button_idx is not None:
+ network_button = self._scroller._items.pop(network_button_idx)
+ # Update network on existing button
+ network_button.set_current_network(network)
+ else:
+ network_button = WifiItem(network)
+
+ def show_network_info_page(_network):
+ self._network_info_page.set_current_network(_network)
+ self._should_open_network_info_page = True
+
+ network_button.set_click_callback(lambda _net=network,_button=network_button: _button._selected and show_network_info_page(_net))
+
+ self.add_button(network_button)
+
+ # remove networks no longer present
+ self._scroller._items[:] = [btn for btn in self._scroller._items if btn.option in self._networks]
+
+ def _connect_with_password(self, ssid: str, password: str):
+ if password:
+ self._connecting = ssid
+ self._wifi_manager.connect_to_network(ssid, password)
+ self._update_buttons()
+
+ def _connect_to_network(self, ssid: str):
+ network = self._networks.get(ssid)
+ if network is None:
+ cloudlog.warning(f"Trying to connect to unknown network: {ssid}")
+ return
+
+ if network.is_saved:
+ self._connecting = network.ssid
+ self._wifi_manager.activate_connection(network.ssid)
+ self._update_buttons()
+ elif network.security_type == SecurityType.OPEN:
+ self._connecting = network.ssid
+ self._wifi_manager.connect_to_network(network.ssid, "")
+ self._update_buttons()
+ else:
+ self._on_need_auth(network.ssid, False)
+
+ def _on_need_auth(self, ssid, incorrect_password=True):
+ hint = "incorrect password..." if incorrect_password else "enter password..."
+ dlg = BigInputDialog(hint, "", minimum_length=8,
+ confirm_callback=lambda _password: self._connect_with_password(ssid, _password))
+ # go back to the manage network page
+ gui_app.set_modal_overlay(dlg, self._open_network_manage_page)
+
+ def _on_activated(self):
+ self._connecting = None
+
+ def _on_forgotten(self):
+ self._connecting = None
+
+ def _on_disconnected(self):
+ self._connecting = None
+
+ def _render(self, _):
+ super()._render(_)
+
+ if not self._networks:
+ self._loading_animation.render(self._rect)
+
+
+class NetworkLayoutMici(NavWidget):
+ def __init__(self, back_callback: Callable):
+ super().__init__()
+
+ self._current_panel = NetworkPanelType.WIFI
+ self.set_back_enabled(lambda: self._current_panel == NetworkPanelType.NONE)
+
+ self._wifi_manager = WifiManager()
+ self._wifi_manager.set_active(False)
+ self._wifi_ui = WifiUIMici(self._wifi_manager, back_callback=lambda: self._switch_to_panel(NetworkPanelType.NONE))
+
+ self._wifi_manager.add_callbacks(
+ networks_updated=self._on_network_updated,
+ )
+
+ _tethering_icon = "icons_mici/settings/network/tethering.png"
+
+ # ******** Tethering ********
+ def tethering_toggle_callback(checked: bool):
+ self._tethering_toggle_btn.set_enabled(False)
+ self._network_metered_btn.set_enabled(False)
+ self._wifi_manager.set_tethering_active(checked)
+
+ self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback)
+
+ def tethering_password_callback(password: str):
+ if password:
+ self._wifi_manager.set_tethering_password(password)
+
+ def tethering_password_clicked():
+ tethering_password = self._wifi_manager.tethering_password
+ dlg = BigInputDialog("enter password...", tethering_password, minimum_length=8,
+ confirm_callback=tethering_password_callback)
+ gui_app.set_modal_overlay(dlg)
+
+ txt_tethering = gui_app.texture(_tethering_icon, 64, 53)
+ self._tethering_password_btn = BigButton("tethering password", "", txt_tethering)
+ self._tethering_password_btn.set_click_callback(tethering_password_clicked)
+
+ # ******** IP Address ********
+ self._ip_address_btn = BigButton("IP Address", "Not connected")
+
+ # ******** Network Metered ********
+ def network_metered_callback(value: str):
+ self._network_metered_btn.set_enabled(False)
+ metered = {
+ 'default': MeteredType.UNKNOWN,
+ 'metered': MeteredType.YES,
+ 'unmetered': MeteredType.NO
+ }.get(value, MeteredType.UNKNOWN)
+ self._wifi_manager.set_current_network_metered(metered)
+
+ # TODO: signal for current network metered type when changing networks, this is wrong until you press it once
+ # TODO: disable when not connected
+ self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback)
+ self._network_metered_btn.set_enabled(False)
+
+ wifi_button = BigButton("wi-fi")
+ wifi_button.set_click_callback(lambda: self._switch_to_panel(NetworkPanelType.WIFI))
+
+ # Main scroller ----------------------------------
+ self._scroller = Scroller([
+ wifi_button,
+ self._network_metered_btn,
+ self._tethering_toggle_btn,
+ self._tethering_password_btn,
+ self._ip_address_btn,
+ ], snap_items=False)
+
+ # Set up back navigation
+ self.set_back_callback(back_callback)
+
+ def show_event(self):
+ super().show_event()
+ self._current_panel = NetworkPanelType.NONE
+ self._wifi_ui.show_event()
+ self._scroller.show_event()
+
+ def hide_event(self):
+ super().hide_event()
+ self._wifi_ui.hide_event()
+
+ def _on_network_updated(self, networks: list[Network]):
+ # Update tethering state
+ tethering_active = self._wifi_manager.is_tethering_active()
+ # TODO: use real signals (like activated/settings changed, etc.) to speed up re-enabling buttons
+ self._tethering_toggle_btn.set_enabled(True)
+ self._network_metered_btn.set_enabled(lambda: not tethering_active and bool(self._wifi_manager.ipv4_address))
+ self._tethering_toggle_btn.set_checked(tethering_active)
+
+ # Update IP address
+ self._ip_address_btn.set_value(self._wifi_manager.ipv4_address or "Not connected")
+
+ # Update network metered
+ self._network_metered_btn.set_value(
+ {
+ MeteredType.UNKNOWN: 'default',
+ MeteredType.YES: 'metered',
+ MeteredType.NO: 'unmetered'
+ }.get(self._wifi_manager.current_network_metered, 'default'))
+
+ def _switch_to_panel(self, panel_type: NetworkPanelType):
+ self._current_panel = panel_type
+
+ def _render(self, rect: rl.Rectangle):
+ self._wifi_manager.process_callbacks()
+
+ if self._current_panel == NetworkPanelType.WIFI:
+ self._wifi_ui.render(rect)
+ else:
+ self._scroller.render(rect)
diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py
new file mode 100644
index 0000000000..75238d581a
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/settings/settings.py
@@ -0,0 +1,113 @@
+import pyray as rl
+from dataclasses import dataclass
+from enum import IntEnum
+from collections.abc import Callable
+
+from openpilot.common.params import Params
+from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.selfdrive.ui.mici.widgets.button import BigButton
+from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici
+from openpilot.selfdrive.ui.mici.layouts.settings.network import NetworkLayoutMici
+from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton
+from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici
+from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayoutMici
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.widgets import Widget, NavWidget
+
+
+class PanelType(IntEnum):
+ TOGGLES = 0
+ NETWORK = 1
+ DEVICE = 2
+ DEVELOPER = 3
+ USER_MANUAL = 4
+ FIREHOSE = 5
+
+
+@dataclass
+class PanelInfo:
+ name: str
+ instance: Widget
+
+
+class SettingsLayout(NavWidget):
+ def __init__(self):
+ super().__init__()
+ self._params = Params()
+ self._current_panel = None # PanelType.DEVICE
+
+ toggles_btn = BigButton("toggles", "", "icons_mici/settings/toggles_icon.png")
+ toggles_btn.set_click_callback(lambda: self._set_current_panel(PanelType.TOGGLES))
+ network_btn = BigButton("network", "", "icons_mici/settings/network/wifi_strength_full.png")
+ network_btn.set_click_callback(lambda: self._set_current_panel(PanelType.NETWORK))
+ device_btn = BigButton("device", "", "icons_mici/settings/device_icon.png")
+ device_btn.set_click_callback(lambda: self._set_current_panel(PanelType.DEVICE))
+ developer_btn = BigButton("developer", "", "icons_mici/settings/developer_icon.png")
+ developer_btn.set_click_callback(lambda: self._set_current_panel(PanelType.DEVELOPER))
+
+ firehose_btn = BigButton("firehose", "", "icons_mici/settings/comma_icon.png")
+ firehose_btn.set_click_callback(lambda: self._set_current_panel(PanelType.FIREHOSE))
+
+ self._scroller = Scroller([
+ toggles_btn,
+ network_btn,
+ device_btn,
+ PairBigButton(),
+ #BigDialogButton("manual", "", "icons_mici/settings/manual_icon.png", "Check out the mici user\nmanual at comma.ai/setup"),
+ firehose_btn,
+ developer_btn,
+ ], snap_items=False)
+
+ # Set up back navigation
+ self.set_back_callback(self.close_settings)
+ self.set_back_enabled(lambda: self._current_panel is None)
+
+ self._panels = {
+ PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayoutMici(back_callback=lambda: self._set_current_panel(None))),
+ PanelType.NETWORK: PanelInfo("Network", NetworkLayoutMici(back_callback=lambda: self._set_current_panel(None))),
+ PanelType.DEVICE: PanelInfo("Device", DeviceLayoutMici(back_callback=lambda: self._set_current_panel(None))),
+ PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayoutMici(back_callback=lambda: self._set_current_panel(None))),
+ PanelType.FIREHOSE: PanelInfo("Firehose", FirehoseLayoutMici(back_callback=lambda: self._set_current_panel(None))),
+ }
+
+ self._font_medium = gui_app.font(FontWeight.MEDIUM)
+
+ # Callbacks
+ self._close_callback: Callable | None = None
+
+ def show_event(self):
+ super().show_event()
+ self._set_current_panel(None)
+ self._scroller.show_event()
+ if self._current_panel is not None:
+ self._panels[self._current_panel].instance.show_event()
+
+ def hide_event(self):
+ super().hide_event()
+ if self._current_panel is not None:
+ self._panels[self._current_panel].instance.hide_event()
+
+ def set_callbacks(self, on_close: Callable):
+ self._close_callback = on_close
+
+ def _render(self, rect: rl.Rectangle):
+ if self._current_panel is not None:
+ self._draw_current_panel()
+ else:
+ self._scroller.render(rect)
+
+ def _draw_current_panel(self):
+ panel = self._panels[self._current_panel]
+ panel.instance.render(self._rect)
+
+ def _set_current_panel(self, panel_type: PanelType | None):
+ if panel_type != self._current_panel:
+ if self._current_panel is not None:
+ self._panels[self._current_panel].instance.hide_event()
+ self._current_panel = panel_type
+ if self._current_panel is not None:
+ self._panels[self._current_panel].instance.show_event()
+
+ def close_settings(self):
+ if self._close_callback:
+ self._close_callback()
diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py
new file mode 100644
index 0000000000..8efb516a42
--- /dev/null
+++ b/selfdrive/ui/mici/layouts/settings/toggles.py
@@ -0,0 +1,95 @@
+import pyray as rl
+from collections.abc import Callable
+from cereal import log
+
+from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.widgets import NavWidget
+from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback
+from openpilot.selfdrive.ui.ui_state import ui_state
+
+PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants
+
+
+class TogglesLayoutMici(NavWidget):
+ def __init__(self, back_callback: Callable):
+ super().__init__()
+ self.set_back_callback(back_callback)
+
+ self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"])
+ self._experimental_btn = BigParamControl("experimental mode", "ExperimentalMode")
+ is_metric_toggle = BigParamControl("use metric units", "IsMetric")
+ ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled")
+ always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM")
+ record_front = BigParamControl("record & upload driver camera", "RecordFront", toggle_callback=restart_needed_callback)
+ record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback)
+ enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback)
+
+ self._scroller = Scroller([
+ self._personality_toggle,
+ self._experimental_btn,
+ is_metric_toggle,
+ ldw_toggle,
+ always_on_dm_toggle,
+ record_front,
+ record_mic,
+ enable_openpilot,
+ ], snap_items=False)
+
+ # Toggle lists
+ self._refresh_toggles = (
+ ("ExperimentalMode", self._experimental_btn),
+ ("IsMetric", is_metric_toggle),
+ ("IsLdwEnabled", ldw_toggle),
+ ("AlwaysOnDM", always_on_dm_toggle),
+ ("RecordFront", record_front),
+ ("RecordAudio", record_mic),
+ ("OpenpilotEnabledToggle", enable_openpilot),
+ )
+
+ enable_openpilot.set_enabled(lambda: not ui_state.engaged)
+ record_front.set_enabled(False if ui_state.params.get_bool("RecordFrontLock") else (lambda: not ui_state.engaged))
+ record_mic.set_enabled(lambda: not ui_state.engaged)
+
+ if ui_state.params.get_bool("ShowDebugInfo"):
+ gui_app.set_show_touches(True)
+ gui_app.set_show_fps(True)
+
+ ui_state.add_engaged_transition_callback(self._update_toggles)
+
+ def _update_state(self):
+ super()._update_state()
+
+ if ui_state.sm.updated["selfdriveState"]:
+ personality = PERSONALITY_TO_INT[ui_state.sm["selfdriveState"].personality]
+ if personality != ui_state.personality and ui_state.started:
+ self._personality_toggle.set_value(self._personality_toggle._options[personality])
+ ui_state.personality = personality
+
+ def show_event(self):
+ super().show_event()
+ self._scroller.show_event()
+ self._update_toggles()
+
+ def _update_toggles(self):
+ ui_state.update_params()
+
+ # CP gating for experimental mode
+ if ui_state.CP is not None:
+ if ui_state.has_longitudinal_control:
+ self._experimental_btn.set_enabled(True)
+ self._personality_toggle.set_enabled(True)
+ else:
+ # no long for now
+ self._experimental_btn.set_enabled(False)
+ self._experimental_btn.set_checked(False)
+ self._personality_toggle.set_enabled(False)
+ ui_state.params.remove("ExperimentalMode")
+
+ # Refresh toggles from params to mirror external changes
+ for key, item in self._refresh_toggles:
+ item.set_checked(ui_state.params.get_bool(key))
+
+ def _render(self, rect: rl.Rectangle):
+ self._scroller.render(rect)
diff --git a/selfdrive/ui/mici/onroad/__init__.py b/selfdrive/ui/mici/onroad/__init__.py
new file mode 100644
index 0000000000..bb45117b94
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/__init__.py
@@ -0,0 +1,12 @@
+import pyray as rl
+
+SIDE_PANEL_WIDTH = 60
+
+
+def blend_colors(a: rl.Color, b: rl.Color, f: float) -> rl.Color:
+ h0, s0, v0 = (hsv0 := rl.color_to_hsv(a)).x, hsv0.y, hsv0.z
+ h1, s1, v1 = (hsv1 := rl.color_to_hsv(b)).x, hsv1.y, hsv1.z
+ dh = ((h1 - h0 + 180) % 360) - 180 # shortest hue delta
+ return rl.color_from_hsv((h0 + f * dh) % 360,
+ s0 + f * (s1 - s0),
+ v0 + f * (v1 - v0))
diff --git a/selfdrive/ui/mici/onroad/alert_renderer.py b/selfdrive/ui/mici/onroad/alert_renderer.py
new file mode 100644
index 0000000000..eb5555660a
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/alert_renderer.py
@@ -0,0 +1,361 @@
+import time
+from enum import StrEnum
+from typing import NamedTuple
+import pyray as rl
+import random
+import string
+from dataclasses import dataclass
+from cereal import messaging, log, car
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter
+from openpilot.system.hardware import TICI
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.label import UnifiedLabel
+
+AlertSize = log.SelfdriveState.AlertSize
+AlertStatus = log.SelfdriveState.AlertStatus
+
+ALERT_MARGIN = 18
+
+ALERT_FONT_SMALL = 66 - 50
+ALERT_FONT_BIG = 88 - 40
+
+SELFDRIVE_STATE_TIMEOUT = 5 # Seconds
+SELFDRIVE_UNRESPONSIVE_TIMEOUT = 10 # Seconds
+
+# Constants
+ALERT_COLORS = {
+ AlertStatus.normal: rl.Color(0, 0, 0, 255),
+ AlertStatus.userPrompt: rl.Color(255, 115, 0, 255),
+ AlertStatus.critical: rl.Color(255, 0, 21, 255),
+}
+
+TURN_SIGNAL_BLINK_PERIOD = 1 / (80 / 60) # Mazda heartbeat turn signal BPM
+
+DEBUG = False
+
+
+class IconSide(StrEnum):
+ left = 'left'
+ right = 'right'
+
+
+class IconLayout(NamedTuple):
+ texture: rl.Texture
+ side: IconSide
+ margin_x: int
+ margin_y: int
+
+
+class AlertLayout(NamedTuple):
+ text_rect: rl.Rectangle
+ icon: IconLayout | None
+
+
+@dataclass
+class Alert:
+ text1: str = ""
+ text2: str = ""
+ size: int = 0
+ status: int = 0
+ visual_alert: int = car.CarControl.HUDControl.VisualAlert.none
+ alert_type: str = ""
+
+
+# Pre-defined alert instances
+ALERT_STARTUP_PENDING = Alert(
+ text1="openpilot Unavailable",
+ text2="Waiting to start",
+ size=AlertSize.mid,
+ status=AlertStatus.normal,
+)
+
+ALERT_CRITICAL_TIMEOUT = Alert(
+ text1="TAKE CONTROL IMMEDIATELY",
+ text2="System Unresponsive",
+ size=AlertSize.full,
+ status=AlertStatus.critical,
+)
+
+ALERT_CRITICAL_REBOOT = Alert(
+ text1="System Unresponsive",
+ text2="Reboot Device",
+ size=AlertSize.full,
+ status=AlertStatus.critical,
+)
+
+
+class AlertRenderer(Widget):
+ def __init__(self):
+ super().__init__()
+ self.font_regular: rl.Font = gui_app.font(FontWeight.MEDIUM)
+ self.font_roman: rl.Font = gui_app.font(FontWeight.ROMAN)
+ self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
+ self.font_display: rl.Font = gui_app.font(FontWeight.DISPLAY)
+
+ self._alert_text1_label = UnifiedLabel(text="", font_size=ALERT_FONT_BIG, font_weight=FontWeight.DISPLAY, line_height=0.86,
+ letter_spacing=-0.02)
+ self._alert_text2_label = UnifiedLabel(text="", font_size=ALERT_FONT_SMALL, font_weight=FontWeight.ROMAN, line_height=0.86,
+ letter_spacing=0.025)
+
+ self._prev_alert: Alert | None = None
+ self._text_gen_time = 0
+ self._alert_text2_gen = ''
+
+ # animation filters
+ # TODO: use 0.1 but with proper alert height calculation
+ self._alert_y_filter = BounceFilter(0, 0.1, 1 / gui_app.target_fps)
+ self._alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
+
+ self._turn_signal_timer = 0.0
+ self._turn_signal_alpha_filter = FirstOrderFilter(0.0, 0.3, 1 / gui_app.target_fps)
+ self._last_icon_side: IconSide | None = None
+
+ self._load_icons()
+
+ def _load_icons(self):
+ self._txt_turn_signal_left = gui_app.texture('icons_mici/onroad/turn_signal_left.png', 100, 91)
+ self._txt_turn_signal_right = gui_app.texture('icons_mici/onroad/turn_signal_right.png', 100, 91)
+ self._txt_blind_spot_left = gui_app.texture('icons_mici/onroad/blind_spot_left.png', 108, 128)
+ self._txt_blind_spot_right = gui_app.texture('icons_mici/onroad/blind_spot_right.png', 108, 128)
+
+ def get_alert(self, sm: messaging.SubMaster) -> Alert | None:
+ """Generate the current alert based on selfdrive state."""
+ ss = sm['selfdriveState']
+
+ # Check if selfdriveState messages have stopped arriving
+ if not sm.updated['selfdriveState']:
+ recv_frame = sm.recv_frame['selfdriveState']
+ time_since_onroad = time.monotonic() - ui_state.started_time
+
+ # 1. Never received selfdriveState since going onroad
+ waiting_for_startup = recv_frame < ui_state.started_frame
+ if waiting_for_startup and time_since_onroad > 5:
+ return ALERT_STARTUP_PENDING
+
+ # 2. Lost communication with selfdriveState after receiving it
+ if TICI and not waiting_for_startup:
+ ss_missing = time.monotonic() - sm.recv_time['selfdriveState']
+ if ss_missing > SELFDRIVE_STATE_TIMEOUT:
+ if ss.enabled and (ss_missing - SELFDRIVE_STATE_TIMEOUT) < SELFDRIVE_UNRESPONSIVE_TIMEOUT:
+ return ALERT_CRITICAL_TIMEOUT
+ return ALERT_CRITICAL_REBOOT
+
+ # No alert if size is none
+ if ss.alertSize == 0:
+ return None
+
+ # Return current alert
+ ret = Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw,
+ visual_alert=ss.alertHudVisual, alert_type=ss.alertType)
+ self._prev_alert = ret
+ return ret
+
+ def will_render(self) -> tuple[Alert | None, bool]:
+ alert = self.get_alert(ui_state.sm)
+ return alert or self._prev_alert, alert is None
+
+ def _icon_helper(self, alert: Alert) -> AlertLayout:
+ icon_side = None
+ txt_icon = None
+ icon_margin_x = 20
+ icon_margin_y = 18
+
+ # alert_type format is "EventName/eventType" (e.g., "preLaneChangeLeft/warning")
+ event_name = alert.alert_type.split('/')[0] if alert.alert_type else ''
+
+ if event_name == 'preLaneChangeLeft':
+ icon_side = IconSide.left
+ txt_icon = self._txt_turn_signal_left
+ icon_margin_x = 2
+ icon_margin_y = 5
+
+ elif event_name == 'preLaneChangeRight':
+ icon_side = IconSide.right
+ txt_icon = self._txt_turn_signal_right
+ icon_margin_x = 2
+ icon_margin_y = 5
+
+ elif event_name == 'laneChange':
+ icon_side = self._last_icon_side
+ txt_icon = self._txt_turn_signal_left if self._last_icon_side == 'left' else self._txt_turn_signal_right
+ icon_margin_x = 2
+ icon_margin_y = 5
+
+ elif event_name == 'laneChangeBlocked':
+ CS = ui_state.sm['carState']
+ if CS.leftBlinker:
+ icon_side = IconSide.left
+ elif CS.rightBlinker:
+ icon_side = IconSide.right
+ else:
+ icon_side = self._last_icon_side
+ txt_icon = self._txt_blind_spot_left if icon_side == 'left' else self._txt_blind_spot_right
+ icon_margin_x = 8
+ icon_margin_y = 0
+
+ else:
+ self._turn_signal_timer = 0.0
+
+ self._last_icon_side = icon_side
+
+ # create text rect based on icon presence
+ text_x = self._rect.x + ALERT_MARGIN
+ text_width = self._rect.width - ALERT_MARGIN
+ if icon_side == 'left':
+ text_x = self._rect.x + self._txt_turn_signal_right.width + 20 * 2
+ text_width = self._rect.width - ALERT_MARGIN - self._txt_turn_signal_right.width - 20 * 2
+ elif icon_side == 'right':
+ text_x = self._rect.x + ALERT_MARGIN
+ text_width = self._rect.width - ALERT_MARGIN - self._txt_turn_signal_right.width - 20 * 2
+
+ text_rect = rl.Rectangle(
+ text_x,
+ self._alert_y_filter.x,
+ text_width,
+ self._rect.height,
+ )
+ icon_layout = IconLayout(txt_icon, icon_side, icon_margin_x, icon_margin_y) if txt_icon is not None and icon_side is not None else None
+ return AlertLayout(text_rect, icon_layout)
+
+ def _render(self, rect: rl.Rectangle) -> bool:
+ alert = self.get_alert(ui_state.sm)
+
+ # Animate fade and slide in/out
+ self._alert_y_filter.update(self._rect.y - 50 if alert is None else self._rect.y)
+ self._alpha_filter.update(0 if alert is None else 1)
+
+ if alert is None:
+ # If still animating out, keep the previous alert
+ if self._alpha_filter.x > 0.01 and self._prev_alert is not None:
+ alert = self._prev_alert
+ else:
+ self._prev_alert = None
+ return False
+
+ self._draw_background(alert)
+
+ alert_layout = self._icon_helper(alert)
+ self._draw_text(alert, alert_layout)
+ self._draw_icons(alert_layout)
+
+ return True
+
+ def _draw_icons(self, alert_layout: AlertLayout) -> None:
+ if alert_layout.icon is None:
+ return
+
+ if time.monotonic() - self._turn_signal_timer > TURN_SIGNAL_BLINK_PERIOD:
+ self._turn_signal_timer = time.monotonic()
+ self._turn_signal_alpha_filter.x = 255 * 2
+ else:
+ self._turn_signal_alpha_filter.update(255 * 0.2)
+
+ if alert_layout.icon.side == 'left':
+ pos_x = int(self._rect.x + alert_layout.icon.margin_x)
+ else:
+ pos_x = int(self._rect.x + self._rect.width - alert_layout.icon.margin_x - alert_layout.icon.texture.width)
+
+ if alert_layout.icon.texture not in (self._txt_turn_signal_left, self._txt_turn_signal_right):
+ icon_alpha = 255
+ else:
+ icon_alpha = int(min(self._turn_signal_alpha_filter.x, 255))
+
+ rl.draw_texture(alert_layout.icon.texture, pos_x, int(self._rect.y + alert_layout.icon.margin_y),
+ rl.Color(255, 255, 255, int(icon_alpha * self._alpha_filter.x)))
+
+ def _draw_background(self, alert: Alert) -> None:
+ # draw top gradient for alert text at top
+ color = ALERT_COLORS.get(alert.status, ALERT_COLORS[AlertStatus.normal])
+ color = rl.Color(color.r, color.g, color.b, int(255 * 0.90 * self._alpha_filter.x))
+ translucent_color = rl.Color(color.r, color.g, color.b, int(0 * self._alpha_filter.x))
+
+ small_alert_height = round(self._rect.height * 0.583) # 140px at mici height
+ medium_alert_height = round(self._rect.height * 0.833) # 200px at mici height
+
+ # alert_type format is "EventName/eventType" (e.g., "preLaneChangeLeft/warning")
+ event_name = alert.alert_type.split('/')[0] if alert.alert_type else ''
+
+ if event_name == 'preLaneChangeLeft':
+ bg_height = small_alert_height
+ elif event_name == 'preLaneChangeRight':
+ bg_height = small_alert_height
+ elif event_name == 'laneChange':
+ bg_height = small_alert_height
+ elif event_name == 'laneChangeBlocked':
+ bg_height = medium_alert_height
+ else:
+ bg_height = int(self._rect.height)
+
+ solid_height = round(bg_height * 0.2)
+ rl.draw_rectangle(int(self._rect.x), int(self._rect.y), int(self._rect.width), solid_height, color)
+ rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + solid_height), int(self._rect.width),
+ int(bg_height - solid_height),
+ color, translucent_color)
+
+ def _draw_text(self, alert: Alert, alert_layout: AlertLayout) -> None:
+ icon_side = alert_layout.icon.side if alert_layout.icon is not None else None
+
+ # TODO: hack
+ alert_text1 = alert.text1.lower().replace('calibrating: ', 'calibrating:\n')
+ can_draw_second_line = False
+ # TODO: there should be a common way to determine font size based on text length to maximize rect
+ if len(alert_text1) <= 12:
+ can_draw_second_line = True
+ font_size = 92 - 10
+ elif len(alert_text1) <= 16:
+ can_draw_second_line = True
+ font_size = 70
+ else:
+ font_size = 64 - 10
+
+ if icon_side is not None:
+ font_size -= 10
+
+ color = rl.Color(255, 255, 255, int(255 * 0.9 * self._alpha_filter.x))
+
+ text1_y_offset = 11 if font_size >= 70 else 4
+ text_rect1 = rl.Rectangle(
+ alert_layout.text_rect.x,
+ alert_layout.text_rect.y - text1_y_offset,
+ alert_layout.text_rect.width,
+ alert_layout.text_rect.height,
+ )
+ self._alert_text1_label.set_text(alert_text1)
+ self._alert_text1_label.set_text_color(color)
+ self._alert_text1_label.set_font_size(font_size)
+ self._alert_text1_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
+ self._alert_text1_label.render(text_rect1)
+
+ alert_text2 = alert.text2.lower()
+
+ # randomize chars and length for testing
+ if DEBUG:
+ if time.monotonic() - self._text_gen_time > 0.5:
+ self._alert_text2_gen = ''.join(random.choices(string.ascii_lowercase + ' ', k=random.randint(0, 40)))
+ self._text_gen_time = time.monotonic()
+ alert_text2 = self._alert_text2_gen or alert_text2
+
+ if can_draw_second_line and alert_text2:
+ last_line_h = self._alert_text1_label.rect.y + self._alert_text1_label.get_content_height(int(alert_layout.text_rect.width))
+ last_line_h -= 4
+ if len(alert_text2) > 18:
+ small_font_size = 36
+ elif len(alert_text2) > 24:
+ small_font_size = 32
+ else:
+ small_font_size = 40
+ text_rect2 = rl.Rectangle(
+ alert_layout.text_rect.x,
+ last_line_h,
+ alert_layout.text_rect.width,
+ alert_layout.text_rect.height - last_line_h
+ )
+ color = rl.Color(255, 255, 255, int(255 * 0.65 * self._alpha_filter.x))
+
+ self._alert_text2_label.set_text(alert_text2)
+ self._alert_text2_label.set_text_color(color)
+ self._alert_text2_label.set_font_size(small_font_size)
+ self._alert_text2_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
+ self._alert_text2_label.render(text_rect2)
diff --git a/selfdrive/ui/mici/onroad/augmented_road_view.py b/selfdrive/ui/mici/onroad/augmented_road_view.py
new file mode 100644
index 0000000000..ab55f392f7
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/augmented_road_view.py
@@ -0,0 +1,358 @@
+import numpy as np
+import pyray as rl
+from cereal import car, log
+from msgq.visionipc import VisionStreamType
+from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
+from openpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH
+from openpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer
+from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
+from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer
+from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer
+from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall
+from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
+from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent
+from openpilot.system.ui.widgets.label import UnifiedLabel
+from openpilot.system.ui.widgets import Widget
+from openpilot.common.filter_simple import BounceFilter
+from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCameraConfig, view_frame_from_device_frame
+from openpilot.common.transformations.orientation import rot_from_euler
+from enum import IntEnum
+
+OpState = log.SelfdriveState.OpenpilotState
+CALIBRATED = log.LiveCalibrationData.Status.calibrated
+ROAD_CAM = VisionStreamType.VISION_STREAM_ROAD
+WIDE_CAM = VisionStreamType.VISION_STREAM_WIDE_ROAD
+DEFAULT_DEVICE_CAMERA = DEVICE_CAMERAS["tici", "ar0231"]
+
+
+class BookmarkState(IntEnum):
+ HIDDEN = 0
+ DRAGGING = 1
+ TRIGGERED = 2
+
+WIDE_CAM_MAX_SPEED = 5.0 # m/s (10 mph)
+ROAD_CAM_MIN_SPEED = 10 # m/s (25 mph)
+
+CAM_Y_OFFSET = 20
+
+
+class BookmarkIcon(Widget):
+ PEEK_THRESHOLD = 50 # If icon peeks out this much, snap it fully visible
+ FULL_VISIBLE_OFFSET = 200 # How far onscreen when fully visible
+ HIDDEN_OFFSET = -50 # How far offscreen when hidden
+
+ def __init__(self, bookmark_callback):
+ super().__init__()
+ self._bookmark_callback = bookmark_callback
+ self._icon = gui_app.texture("icons_mici/onroad/bookmark.png", 180, 180)
+ self._offset_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps)
+
+ # State
+ self._interacting = False
+ self._state = BookmarkState.HIDDEN
+ self._swipe_start_x = 0.0
+ self._swipe_current_x = 0.0
+ self._is_swiping = False
+ self._is_swiping_left: bool = False
+ self._triggered_time: float = 0.0
+
+ def is_swiping_left(self) -> bool:
+ """Check if currently swiping left (for scroller to disable)."""
+ return self._is_swiping_left
+
+ def interacting(self):
+ interacting, self._interacting = self._interacting, False
+ return interacting
+
+ def _update_state(self):
+ if self._state == BookmarkState.DRAGGING:
+ # Allow pulling past activated position with rubber band effect
+ swipe_offset = self._swipe_start_x - self._swipe_current_x
+ swipe_offset = min(swipe_offset, self.FULL_VISIBLE_OFFSET + 50)
+ self._offset_filter.update(swipe_offset)
+
+ elif self._state == BookmarkState.TRIGGERED:
+ # Continue animating to fully visible
+ self._offset_filter.update(self.FULL_VISIBLE_OFFSET)
+ # Stay in TRIGGERED state for 1 second
+ if rl.get_time() - self._triggered_time >= 1.5:
+ self._state = BookmarkState.HIDDEN
+
+ elif self._state == BookmarkState.HIDDEN:
+ self._offset_filter.update(self.HIDDEN_OFFSET)
+
+ if self._offset_filter.x < 1e-3:
+ self._interacting = False
+
+ def _handle_mouse_event(self, mouse_event: MouseEvent):
+ if not ui_state.started:
+ return
+
+ if mouse_event.left_pressed:
+ # Store relative position within widget
+ self._swipe_start_x = mouse_event.pos.x
+ self._swipe_current_x = mouse_event.pos.x
+ self._is_swiping = True
+ self._is_swiping_left = False
+ self._state = BookmarkState.DRAGGING
+
+ elif mouse_event.left_down and self._is_swiping:
+ self._swipe_current_x = mouse_event.pos.x
+ swipe_offset = self._swipe_start_x - self._swipe_current_x
+ self._is_swiping_left = swipe_offset > 0
+ if self._is_swiping_left:
+ self._interacting = True
+
+ elif mouse_event.left_released:
+ if self._is_swiping:
+ swipe_distance = self._swipe_start_x - self._swipe_current_x
+
+ # If peeking past threshold, transition to animating to fully visible and bookmark
+ if swipe_distance > self.PEEK_THRESHOLD:
+ self._state = BookmarkState.TRIGGERED
+ self._triggered_time = rl.get_time()
+ self._bookmark_callback()
+ else:
+ # Otherwise, transition back to hidden
+ self._state = BookmarkState.HIDDEN
+
+ # Reset swipe state
+ self._is_swiping = False
+ self._is_swiping_left = False
+
+ def _render(self, _):
+ """Render the bookmark icon."""
+ if self._offset_filter.x > 0:
+ icon_x = self.rect.x + self.rect.width - round(self._offset_filter.x)
+ icon_y = self.rect.y + (self.rect.height - self._icon.height) / 2 # Vertically centered
+ rl.draw_texture(self._icon, int(icon_x), int(icon_y), rl.WHITE)
+
+
+class AugmentedRoadView(CameraView):
+ def __init__(self, bookmark_callback=None, stream_type: VisionStreamType = VisionStreamType.VISION_STREAM_ROAD):
+ super().__init__("camerad", stream_type)
+ self._bookmark_callback = bookmark_callback
+ self._set_placeholder_color(rl.BLACK)
+
+ self.device_camera: DeviceCameraConfig | None = None
+ self.view_from_calib = view_frame_from_device_frame.copy()
+ self.view_from_wide_calib = view_frame_from_device_frame.copy()
+
+ self._last_calib_time: float = 0
+ self._last_rect_dims = (0.0, 0.0)
+ self._last_stream_type = stream_type
+ self._cached_matrix: np.ndarray | None = None
+ self._content_rect = rl.Rectangle()
+ self._last_click_time = 0.0
+
+ # Bookmark icon with swipe gesture
+ self._bookmark_icon = BookmarkIcon(bookmark_callback)
+
+ self._model_renderer = ModelRenderer()
+ self._hud_renderer = HudRenderer()
+ self._alert_renderer = AlertRenderer()
+ self._driver_state_renderer = DriverStateRenderer()
+ self._confidence_ball = ConfidenceBall()
+ self._offroad_label = UnifiedLabel("start the car to\nuse openpilot", 54, FontWeight.DISPLAY,
+ text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
+
+ self._fade_texture = gui_app.texture("icons_mici/onroad/onroad_fade.png")
+
+ def is_swiping_left(self) -> bool:
+ """Check if currently swiping left (for scroller to disable)."""
+ return self._bookmark_icon.is_swiping_left()
+
+ def _update_state(self):
+ super()._update_state()
+
+ # update offroad label
+ if ui_state.panda_type == log.PandaState.PandaType.unknown:
+ self._offroad_label.set_text("system booting")
+ else:
+ self._offroad_label.set_text("start the car to\nuse openpilot")
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ # Don't trigger click callback if bookmark was triggered
+ if not self._bookmark_icon.interacting():
+ super()._handle_mouse_release(mouse_pos)
+
+ def _render(self, _):
+ self._switch_stream_if_needed(ui_state.sm)
+
+ # Update calibration before rendering
+ self._update_calibration()
+
+ # Create inner content area with border padding
+ self._content_rect = rl.Rectangle(
+ self.rect.x,
+ self.rect.y,
+ self.rect.width - SIDE_PANEL_WIDTH,
+ self.rect.height,
+ )
+
+ # Enable scissor mode to clip all rendering within content rectangle boundaries
+ # This creates a rendering viewport that prevents graphics from drawing outside the border
+ rl.begin_scissor_mode(
+ int(self._content_rect.x),
+ int(self._content_rect.y),
+ int(self._content_rect.width),
+ int(self._content_rect.height)
+ )
+
+ # Render the base camera view
+ super()._render(self._content_rect)
+
+ # Draw all UI overlays
+ self._model_renderer.render(self._content_rect)
+
+ # Fade out bottom of overlays for looks
+ rl.draw_texture_ex(self._fade_texture, rl.Vector2(self._content_rect.x, self._content_rect.y), 0.0, 1.0, rl.WHITE)
+
+ alert_to_render, not_animating_out = self._alert_renderer.will_render()
+
+ # Hide DMoji when disengaged unless AlwaysOnDM is enabled
+ should_draw_dmoji = (not self._hud_renderer.drawing_top_icons() and ui_state.is_onroad() and
+ (ui_state.status != UIStatus.DISENGAGED or ui_state.always_on_dm))
+ self._driver_state_renderer.set_should_draw(should_draw_dmoji)
+ self._driver_state_renderer.set_position(self._rect.x + 16, self._rect.y + 10)
+ self._driver_state_renderer.render()
+
+ self._hud_renderer.set_can_draw_top_icons(alert_to_render is None)
+ self._hud_renderer.set_wheel_critical_icon(alert_to_render is not None and not not_animating_out and
+ alert_to_render.visual_alert == car.CarControl.HUDControl.VisualAlert.steerRequired)
+ # TODO: have alert renderer draw offroad mici label below
+ if ui_state.started:
+ self._alert_renderer.render(self._content_rect)
+ self._hud_renderer.render(self._content_rect)
+
+ # Draw fake rounded border
+ rl.draw_rectangle_rounded_lines_ex(self._content_rect, 0.2 * 1.02, 10, 50, rl.BLACK)
+
+ # End clipping region
+ rl.end_scissor_mode()
+
+ # Custom UI extension point - add custom overlays here
+ # Use self._content_rect for positioning within camera bounds
+ self._confidence_ball.render(self.rect)
+
+ self._bookmark_icon.render(self.rect)
+
+ # Draw darkened background and text if not onroad
+ if not ui_state.started:
+ rl.draw_rectangle(int(self.rect.x), int(self.rect.y), int(self.rect.width), int(self.rect.height), rl.Color(0, 0, 0, 175))
+ self._offroad_label.render(self._content_rect)
+
+ def _switch_stream_if_needed(self, sm):
+ if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams:
+ v_ego = sm['carState'].vEgo
+ if v_ego < WIDE_CAM_MAX_SPEED:
+ target = WIDE_CAM
+ elif v_ego > ROAD_CAM_MIN_SPEED:
+ target = ROAD_CAM
+ else:
+ # Hysteresis zone - keep current stream
+ target = self.stream_type
+ else:
+ target = ROAD_CAM
+
+ if self.stream_type != target:
+ self.switch_stream(target)
+
+ def _update_calibration(self):
+ # Update device camera if not already set
+ sm = ui_state.sm
+ if not self.device_camera and sm.seen['roadCameraState'] and sm.seen['deviceState']:
+ self.device_camera = DEVICE_CAMERAS[(str(sm['deviceState'].deviceType), str(sm['roadCameraState'].sensor))]
+
+ # Check if live calibration data is available and valid
+ if not (sm.updated["liveCalibration"] and sm.valid['liveCalibration']):
+ return
+
+ calib = sm['liveCalibration']
+ if len(calib.rpyCalib) != 3 or calib.calStatus != CALIBRATED:
+ return
+
+ # Update view_from_calib matrix
+ device_from_calib = rot_from_euler(calib.rpyCalib)
+ self.view_from_calib = view_frame_from_device_frame @ device_from_calib
+
+ # Update wide calibration if available
+ if hasattr(calib, 'wideFromDeviceEuler') and len(calib.wideFromDeviceEuler) == 3:
+ wide_from_device = rot_from_euler(calib.wideFromDeviceEuler)
+ self.view_from_wide_calib = view_frame_from_device_frame @ wide_from_device @ device_from_calib
+
+ def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
+ # Get camera configuration
+ # TODO: cache with vEgo?
+ calib_time = ui_state.sm.recv_frame['liveCalibration']
+ current_dims = (self._content_rect.width, self._content_rect.height)
+ device_camera = self.device_camera or DEFAULT_DEVICE_CAMERA
+ is_wide_camera = self.stream_type == WIDE_CAM
+ intrinsic = device_camera.ecam.intrinsics if is_wide_camera else device_camera.fcam.intrinsics
+ calibration = self.view_from_wide_calib if is_wide_camera else self.view_from_calib
+ if is_wide_camera:
+ zoom = 0.7 * 1.5
+ else:
+ zoom = np.interp(ui_state.sm['carState'].vEgo, [10, 30], [0.8, 1.0])
+
+ # Calculate transforms for vanishing point
+ inf_point = np.array([1000.0, 0.0, 0.0])
+ calib_transform = intrinsic @ calibration
+ kep = calib_transform @ inf_point
+
+ # Calculate center points and dimensions
+ x, y = self._content_rect.x, self._content_rect.y
+ w, h = self._content_rect.width, self._content_rect.height
+ cx, cy = intrinsic[0, 2], intrinsic[1, 2]
+
+ # Calculate max allowed offsets with margins
+ margin = 5
+ max_x_offset = cx * zoom - w / 2 - margin
+ max_y_offset = cy * zoom - h / 2 - margin
+
+ # Calculate and clamp offsets to prevent out-of-bounds issues
+ try:
+ if abs(kep[2]) > 1e-6:
+ x_offset = np.clip((kep[0] / kep[2] - cx) * zoom, -max_x_offset, max_x_offset)
+ y_offset = np.clip((kep[1] / kep[2] - cy) * zoom + CAM_Y_OFFSET, -max_y_offset, max_y_offset)
+ else:
+ x_offset, y_offset = 0, 0
+ except (ZeroDivisionError, OverflowError):
+ x_offset, y_offset = 0, 0
+
+ # Cache the computed transformation matrix to avoid recalculations
+ self._last_calib_time = calib_time
+ self._last_rect_dims = current_dims
+ self._last_stream_type = self.stream_type
+ self._cached_matrix = np.array([
+ [zoom * 2 * cx / w, 0, -x_offset / w * 2],
+ [0, zoom * 2 * cy / h, -y_offset / h * 2],
+ [0, 0, 1.0]
+ ])
+
+ video_transform = np.array([
+ [zoom, 0.0, (w / 2 + x - x_offset) - (cx * zoom)],
+ [0.0, zoom, (h / 2 + y - y_offset) - (cy * zoom)],
+ [0.0, 0.0, 1.0]
+ ])
+ self._model_renderer.set_transform(video_transform @ calib_transform)
+
+ return self._cached_matrix
+
+
+if __name__ == "__main__":
+ gui_app.init_window("OnRoad Camera View")
+ road_camera_view = AugmentedRoadView(ROAD_CAM)
+ print("***press space to switch camera view***")
+ try:
+ for _ in gui_app.render():
+ ui_state.update()
+ if rl.is_key_released(rl.KeyboardKey.KEY_SPACE):
+ if WIDE_CAM in road_camera_view.available_streams:
+ stream = ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM
+ road_camera_view.switch_stream(stream)
+ road_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+ finally:
+ road_camera_view.close()
diff --git a/selfdrive/ui/mici/onroad/cameraview.py b/selfdrive/ui/mici/onroad/cameraview.py
new file mode 100644
index 0000000000..0f425b10da
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/cameraview.py
@@ -0,0 +1,412 @@
+import platform
+import numpy as np
+import pyray as rl
+
+from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf
+from openpilot.common.swaglog import cloudlog
+from openpilot.system.hardware import TICI
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage
+from openpilot.system.ui.widgets import Widget
+from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
+
+CONNECTION_RETRY_INTERVAL = 0.2 # seconds between connection attempts
+
+VERSION = """
+#version 300 es
+precision mediump float;
+"""
+if platform.system() == "Darwin":
+ VERSION = """
+ #version 330 core
+ """
+
+
+VERTEX_SHADER = VERSION + """
+in vec3 vertexPosition;
+in vec2 vertexTexCoord;
+in vec3 vertexNormal;
+in vec4 vertexColor;
+uniform mat4 mvp;
+out vec2 fragTexCoord;
+out vec4 fragColor;
+void main() {
+ fragTexCoord = vertexTexCoord;
+ fragColor = vertexColor;
+ gl_Position = mvp * vec4(vertexPosition, 1.0);
+}
+"""
+
+# Choose fragment shader based on platform capabilities
+if TICI:
+ FRAME_FRAGMENT_SHADER = """
+ #version 300 es
+ #extension GL_OES_EGL_image_external_essl3 : enable
+ precision mediump float;
+ in vec2 fragTexCoord;
+ uniform samplerExternalOES texture0;
+ out vec4 fragColor;
+ uniform int engaged;
+ uniform int enhance_driver;
+
+ void main() {
+ vec4 color = texture(texture0, fragTexCoord);
+ if (engaged == 1) {
+ float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); // Luma
+ color.rgb = mix(vec3(gray), color.rgb, 0.2); // 20% saturation
+ color.rgb = clamp((color.rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast
+ color.rgb = pow(color.rgb, vec3(1.0/1.28));
+ fragColor = vec4(color.rgb, color.a);
+ } else {
+ color.rgb *= 0.85; // 85% opacity
+ }
+ if (enhance_driver == 1) {
+ float brightness = 1.1;
+ color.rgb = color.rgb + 0.15;
+ color.rgb = clamp((color.rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0);
+ color.rgb = color.rgb * color.rgb * (3.0 - 2.0 * color.rgb);
+ color.rgb = pow(color.rgb, vec3(0.8));
+ }
+ fragColor = vec4(color.rgb, color.a);
+ }
+ """
+else:
+ FRAME_FRAGMENT_SHADER = VERSION + """
+ in vec2 fragTexCoord;
+ uniform sampler2D texture0;
+ uniform sampler2D texture1;
+ out vec4 fragColor;
+ uniform int engaged;
+ uniform int enhance_driver;
+
+ void main() {
+ float y = texture(texture0, fragTexCoord).r;
+ vec2 uv = texture(texture1, fragTexCoord).ra - 0.5;
+ vec3 rgb = vec3(y + 1.402*uv.y, y - 0.344*uv.x - 0.714*uv.y, y + 1.772*uv.x);
+ if (engaged == 1) {
+ float gray = dot(rgb, vec3(0.299, 0.587, 0.114));
+ rgb = mix(vec3(gray), rgb, 0.2); // 20% saturation
+ rgb = clamp((rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast
+ } else {
+ rgb *= 0.85; // 85% opacity
+ }
+ // TODO: the images out of camerad need some more correction and
+ // the ui should apply a gamma curve for the device display
+ if (enhance_driver == 1) {
+ float brightness = 1.1;
+ rgb = rgb + 0.15;
+ rgb = clamp((rgb - 0.5) * (brightness * 0.8) + 0.5, 0.0, 1.0);
+ rgb = rgb * rgb * (3.0 - 2.0 * rgb);
+ rgb = pow(rgb, vec3(0.8));
+ }
+ fragColor = vec4(rgb, 1.0);
+ }
+ """
+
+
+class CameraView(Widget):
+ def __init__(self, name: str, stream_type: VisionStreamType):
+ super().__init__()
+ # TODO: implement a receiver and connect thread
+ self._name = name
+ # Primary stream
+ self.client = VisionIpcClient(name, stream_type, conflate=True)
+ self._stream_type = stream_type
+ self.available_streams: list[VisionStreamType] = []
+
+ # Target stream for switching
+ self._target_client: VisionIpcClient | None = None
+ self._target_stream_type: VisionStreamType | None = None
+ self._switching: bool = False
+
+ self._texture_needs_update = True
+ self.last_connection_attempt: float = 0.0
+ self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER)
+ self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not TICI else -1
+ self._engaged_loc = rl.get_shader_location(self.shader, "engaged")
+ self._engaged_val = rl.ffi.new("int[1]", [1])
+ self._enhance_driver_loc = rl.get_shader_location(self.shader, "enhance_driver")
+ self._enhance_driver_val = rl.ffi.new("int[1]", [1 if stream_type == VisionStreamType.VISION_STREAM_DRIVER else 0])
+
+ self.frame: VisionBuf | None = None
+ self.texture_y: rl.Texture | None = None
+ self.texture_uv: rl.Texture | None = None
+
+ # EGL resources
+ self.egl_images: dict[int, EGLImage] = {}
+ self.egl_texture: rl.Texture | None = None
+
+ self._placeholder_color: rl.Color | None = None
+
+ # Initialize EGL for zero-copy rendering on TICI
+ if TICI:
+ if not init_egl():
+ raise RuntimeError("Failed to initialize EGL")
+
+ # Create a 1x1 pixel placeholder texture for EGL image binding
+ temp_image = rl.gen_image_color(1, 1, rl.BLACK)
+ self.egl_texture = rl.load_texture_from_image(temp_image)
+ rl.unload_image(temp_image)
+
+ ui_state.add_offroad_transition_callback(self._offroad_transition)
+
+ def _offroad_transition(self):
+ # Reconnect if not first time going onroad
+ if ui_state.is_onroad() and self.frame is not None:
+ # Prevent old frames from showing when going onroad. Qt has a separate thread
+ # which drains the VisionIpcClient SubSocket for us. Re-connecting is not enough
+ # and only clears internal buffers, not the message queue.
+ self.frame = None
+ self.available_streams.clear()
+ if self.client:
+ del self.client
+ self.client = VisionIpcClient(self._name, self._stream_type, conflate=True)
+
+ def _set_placeholder_color(self, color: rl.Color):
+ """Set a placeholder color to be drawn when no frame is available."""
+ self._placeholder_color = color
+
+ def switch_stream(self, stream_type: VisionStreamType) -> None:
+ if self._stream_type == stream_type:
+ return
+
+ if self._switching and self._target_stream_type == stream_type:
+ return
+
+ cloudlog.debug(f'Preparing switch from {self._stream_type} to {stream_type}')
+
+ if self._target_client:
+ del self._target_client
+
+ self._target_stream_type = stream_type
+ self._target_client = VisionIpcClient(self._name, stream_type, conflate=True)
+ self._switching = True
+
+ @property
+ def stream_type(self) -> VisionStreamType:
+ return self._stream_type
+
+ def close(self) -> None:
+ self._clear_textures()
+
+ # Clean up EGL texture
+ if TICI and self.egl_texture:
+ rl.unload_texture(self.egl_texture)
+ self.egl_texture = None
+
+ # Clean up shader
+ if self.shader and self.shader.id:
+ rl.unload_shader(self.shader)
+
+ self.client = None
+
+ def __del__(self):
+ self.close()
+
+ def _calc_frame_matrix(self, rect: rl.Rectangle) -> np.ndarray:
+ if not self.frame:
+ return np.eye(3)
+
+ # Calculate aspect ratios
+ widget_aspect_ratio = rect.width / rect.height
+ frame_aspect_ratio = self.frame.width / self.frame.height
+
+ # Calculate scaling factors to maintain aspect ratio
+ zx = min(frame_aspect_ratio / widget_aspect_ratio, 1.0)
+ zy = min(widget_aspect_ratio / frame_aspect_ratio, 1.0)
+
+ return np.array([
+ [zx, 0.0, 0.0],
+ [0.0, zy, 0.0],
+ [0.0, 0.0, 1.0]
+ ])
+
+ def _render(self, rect: rl.Rectangle):
+ if self._switching:
+ self._handle_switch()
+
+ if not self._ensure_connection():
+ self._draw_placeholder(rect)
+ return
+
+ # Try to get a new buffer without blocking
+ buffer = self.client.recv(timeout_ms=0)
+ if buffer:
+ self._texture_needs_update = True
+ self.frame = buffer
+
+ if not self.frame:
+ self._draw_placeholder(rect)
+ return
+
+ transform = self._calc_frame_matrix(rect)
+ src_rect = rl.Rectangle(0, 0, float(self.frame.width), float(self.frame.height))
+ # Flip driver camera horizontally
+ if self._stream_type == VisionStreamType.VISION_STREAM_DRIVER:
+ src_rect.width = -src_rect.width
+
+ # Calculate scale
+ scale_x = rect.width * transform[0, 0] # zx
+ scale_y = rect.height * transform[1, 1] # zy
+
+ # Calculate base position (centered)
+ x_offset = rect.x + (rect.width - scale_x) / 2
+ y_offset = rect.y + (rect.height - scale_y) / 2
+
+ x_offset += transform[0, 2] * rect.width / 2
+ y_offset += transform[1, 2] * rect.height / 2
+
+ dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y)
+
+ # Render with appropriate method
+ if TICI:
+ self._render_egl(src_rect, dst_rect)
+ else:
+ self._render_textures(src_rect, dst_rect)
+
+ def _draw_placeholder(self, rect: rl.Rectangle):
+ if self._placeholder_color:
+ rl.draw_rectangle_rec(rect, self._placeholder_color)
+
+ def _render_egl(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
+ """Render using EGL for direct buffer access"""
+ if self.frame is None or self.egl_texture is None:
+ return
+
+ idx = self.frame.idx
+ egl_image = self.egl_images.get(idx)
+
+ # Create EGL image if needed
+ if egl_image is None:
+ egl_image = create_egl_image(self.frame.width, self.frame.height, self.frame.stride, self.frame.fd, self.frame.uv_offset)
+ if egl_image:
+ self.egl_images[idx] = egl_image
+ else:
+ return
+
+ # Update texture dimensions to match current frame
+ self.egl_texture.width = self.frame.width
+ self.egl_texture.height = self.frame.height
+
+ # Bind the EGL image to our texture
+ bind_egl_image_to_texture(self.egl_texture.id, egl_image)
+
+ # Render with shader
+ rl.begin_shader_mode(self.shader)
+ self._update_texture_color_filtering()
+ rl.draw_texture_pro(self.egl_texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
+ rl.end_shader_mode()
+
+ def _render_textures(self, src_rect: rl.Rectangle, dst_rect: rl.Rectangle) -> None:
+ """Render using texture copies"""
+ if not self.texture_y or not self.texture_uv or self.frame is None:
+ return
+
+ # Update textures with new frame data
+ if self._texture_needs_update:
+ y_data = self.frame.data[: self.frame.uv_offset]
+ uv_data = self.frame.data[self.frame.uv_offset:]
+
+ rl.update_texture(self.texture_y, rl.ffi.cast("void *", y_data.ctypes.data))
+ rl.update_texture(self.texture_uv, rl.ffi.cast("void *", uv_data.ctypes.data))
+ self._texture_needs_update = False
+
+ # Render with shader
+ rl.begin_shader_mode(self.shader)
+ self._update_texture_color_filtering()
+ rl.set_shader_value_texture(self.shader, self._texture1_loc, self.texture_uv)
+ rl.draw_texture_pro(self.texture_y, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
+ rl.end_shader_mode()
+
+ def _update_texture_color_filtering(self):
+ self._engaged_val[0] = 1 if ui_state.status != UIStatus.DISENGAGED else 0
+ rl.set_shader_value(self.shader, self._engaged_loc, self._engaged_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
+ rl.set_shader_value(self.shader, self._enhance_driver_loc, self._enhance_driver_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT)
+
+ def _ensure_connection(self) -> bool:
+ if not self.client.is_connected():
+ self.frame = None
+ self.available_streams.clear()
+
+ # Throttle connection attempts
+ current_time = rl.get_time()
+ if current_time - self.last_connection_attempt < CONNECTION_RETRY_INTERVAL:
+ return False
+ self.last_connection_attempt = current_time
+
+ if not self.client.connect(False) or not self.client.num_buffers:
+ return False
+
+ cloudlog.debug(f"Connected to {self._name} stream: {self._stream_type}, buffers: {self.client.num_buffers}")
+ self._initialize_textures()
+ self.available_streams = self.client.available_streams(self._name, block=False)
+
+ return True
+
+ def _handle_switch(self) -> None:
+ """Check if target stream is ready and switch immediately."""
+ if not self._target_client or not self._switching:
+ return
+
+ # Try to connect target if needed
+ if not self._target_client.is_connected():
+ if not self._target_client.connect(False) or not self._target_client.num_buffers:
+ return
+
+ cloudlog.debug(f"Target stream connected: {self._target_stream_type}")
+
+ # Check if target has frames ready
+ target_frame = self._target_client.recv(timeout_ms=0)
+ if target_frame:
+ self.frame = target_frame # Update current frame to target frame
+ self._complete_switch()
+
+ def _complete_switch(self) -> None:
+ """Instantly switch to target stream."""
+ cloudlog.debug(f"Switching to {self._target_stream_type}")
+ # Clean up current resources
+ if self.client:
+ del self.client
+
+ # Switch to target
+ self.client = self._target_client
+ self._stream_type = self._target_stream_type
+ self._texture_needs_update = True
+
+ # Reset state
+ self._target_client = None
+ self._target_stream_type = None
+ self._switching = False
+
+ # Initialize textures for new stream
+ self._initialize_textures()
+
+ def _initialize_textures(self):
+ self._clear_textures()
+ if not TICI:
+ self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride),
+ int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE))
+ self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2),
+ int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA))
+
+ def _clear_textures(self):
+ if self.texture_y and self.texture_y.id:
+ rl.unload_texture(self.texture_y)
+ self.texture_y = None
+
+ if self.texture_uv and self.texture_uv.id:
+ rl.unload_texture(self.texture_uv)
+ self.texture_uv = None
+
+ # Clean up EGL resources
+ if TICI:
+ for data in self.egl_images.values():
+ destroy_egl_image(data)
+ self.egl_images = {}
+
+
+if __name__ == "__main__":
+ gui_app.init_window("camera view")
+ road = CameraView("camerad", VisionStreamType.VISION_STREAM_ROAD)
+ for _ in gui_app.render():
+ road.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
diff --git a/selfdrive/ui/mici/onroad/confidence_ball.py b/selfdrive/ui/mici/onroad/confidence_ball.py
new file mode 100644
index 0000000000..a5c95470f5
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/confidence_ball.py
@@ -0,0 +1,78 @@
+import math
+import pyray as rl
+from openpilot.selfdrive.ui.mici.onroad import SIDE_PANEL_WIDTH
+from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.common.filter_simple import FirstOrderFilter
+
+
+def draw_circle_gradient(center_x: float, center_y: float, radius: int,
+ top: rl.Color, bottom: rl.Color) -> None:
+ # Draw a square with the gradient
+ rl.draw_rectangle_gradient_v(int(center_x - radius), int(center_y - radius),
+ radius * 2, radius * 2,
+ top, bottom)
+
+ # Paint over square with a ring
+ outer_radius = math.ceil(radius * math.sqrt(2)) + 1
+ rl.draw_ring(rl.Vector2(int(center_x), int(center_y)), radius, outer_radius,
+ 0.0, 360.0,
+ 20, rl.BLACK)
+
+
+class ConfidenceBall(Widget):
+ def __init__(self, demo: bool = False):
+ super().__init__()
+ self._demo = demo
+ self._confidence_filter = FirstOrderFilter(-0.5, 0.5, 1 / gui_app.target_fps)
+
+ def update_filter(self, value: float):
+ self._confidence_filter.update(value)
+
+ def _update_state(self):
+ if self._demo:
+ return
+
+ # animate status dot in from bottom
+ if ui_state.status == UIStatus.DISENGAGED:
+ self._confidence_filter.update(-0.5)
+ else:
+ self._confidence_filter.update((1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs or [1])) *
+ (1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.steerOverrideProbs or [1])))
+
+ def _render(self, _):
+ content_rect = rl.Rectangle(
+ self.rect.x + self.rect.width - SIDE_PANEL_WIDTH,
+ self.rect.y,
+ SIDE_PANEL_WIDTH,
+ self.rect.height,
+ )
+
+ status_dot_radius = 24
+ dot_height = (1 - self._confidence_filter.x) * (content_rect.height - 2 * status_dot_radius) + status_dot_radius
+ dot_height = self._rect.y + dot_height
+
+ # confidence zones
+ if ui_state.status == UIStatus.ENGAGED or self._demo:
+ if self._confidence_filter.x > 0.5:
+ top_dot_color = rl.Color(0, 255, 204, 255)
+ bottom_dot_color = rl.Color(0, 255, 38, 255)
+ elif self._confidence_filter.x > 0.2:
+ top_dot_color = rl.Color(255, 200, 0, 255)
+ bottom_dot_color = rl.Color(255, 115, 0, 255)
+ else:
+ top_dot_color = rl.Color(255, 0, 21, 255)
+ bottom_dot_color = rl.Color(255, 0, 89, 255)
+
+ elif ui_state.status == UIStatus.OVERRIDE:
+ top_dot_color = rl.Color(255, 255, 255, 255)
+ bottom_dot_color = rl.Color(82, 82, 82, 255)
+
+ else:
+ top_dot_color = rl.Color(50, 50, 50, 255)
+ bottom_dot_color = rl.Color(13, 13, 13, 255)
+
+ draw_circle_gradient(content_rect.x + content_rect.width - status_dot_radius,
+ dot_height, status_dot_radius,
+ top_dot_color, bottom_dot_color)
diff --git a/selfdrive/ui/mici/onroad/driver_camera_dialog.py b/selfdrive/ui/mici/onroad/driver_camera_dialog.py
new file mode 100644
index 0000000000..f2fa5e8fe8
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/driver_camera_dialog.py
@@ -0,0 +1,241 @@
+import pyray as rl
+from cereal import log, messaging
+from msgq.visionipc import VisionStreamType
+from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
+from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
+from openpilot.selfdrive.ui.ui_state import ui_state, device
+from openpilot.selfdrive.selfdrived.events import EVENTS, ET
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.lib.multilang import tr
+from openpilot.system.ui.widgets import NavWidget
+from openpilot.system.ui.widgets.label import gui_label
+
+EventName = log.OnroadEvent.EventName
+
+EVENT_TO_INT = EventName.schema.enumerants
+
+
+class DriverCameraDialog(NavWidget):
+ def __init__(self, no_escape=False):
+ super().__init__()
+ self._camera_view = CameraView("camerad", VisionStreamType.VISION_STREAM_DRIVER)
+ self._original_calc_frame_matrix = self._camera_view._calc_frame_matrix
+ self._camera_view._calc_frame_matrix = self._calc_driver_frame_matrix
+ self.driver_state_renderer = DriverStateRenderer(lines=True)
+ self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 200, 200))
+ self.driver_state_renderer.load_icons()
+ self._pm = messaging.PubMaster(['selfdriveState'])
+ if not no_escape:
+ # TODO: this can grow unbounded, should be given some thought
+ device.add_interactive_timeout_callback(self.stop_dmonitoringmodeld)
+ self.set_back_callback(self._dismiss)
+ self.set_back_enabled(not no_escape)
+
+ # Load eye icons
+ self._eye_fill_texture = None
+ self._eye_orange_texture = None
+ self._eye_size = 74
+ self._glasses_texture = None
+ self._glasses_size = 171
+
+ self._load_eye_textures()
+
+ def stop_dmonitoringmodeld(self):
+ ui_state.params.put_bool("IsDriverViewEnabled", False)
+ gui_app.set_modal_overlay(None)
+
+ def show_event(self):
+ super().show_event()
+ ui_state.params.put_bool("IsDriverViewEnabled", True)
+ self._publish_alert_sound(None)
+ device.reset_interactive_timeout(300)
+ ui_state.params.remove("DriverTooDistracted")
+
+ def hide_event(self):
+ super().hide_event()
+ device.reset_interactive_timeout()
+
+ def _handle_mouse_release(self, _):
+ ui_state.params.remove("DriverTooDistracted")
+
+ def _dismiss(self):
+ self.stop_dmonitoringmodeld()
+
+ def close(self):
+ if self._camera_view:
+ self._camera_view.close()
+
+ def _update_state(self):
+ if self._camera_view:
+ self._camera_view._update_state()
+ # Enable driver state renderer to show Dmoji in preview
+ self.driver_state_renderer.set_should_draw(True)
+ self.driver_state_renderer.set_force_active(True)
+ super()._update_state()
+
+ def _render(self, rect):
+ rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
+ self._camera_view._render(rect)
+
+ if not self._camera_view.frame:
+ gui_label(rect, tr("camera starting"), font_size=54, font_weight=FontWeight.BOLD,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
+ rl.end_scissor_mode()
+ self._publish_alert_sound(None)
+ return -1
+
+ self._draw_face_detection(rect)
+
+ # Position dmoji on opposite side from driver
+ dm_state = ui_state.sm["driverMonitoringState"]
+ driver_state_rect = (
+ rect.x if dm_state.isRHD else rect.x + rect.width - self.driver_state_renderer.rect.width,
+ rect.y + (rect.height - self.driver_state_renderer.rect.height) / 2,
+ )
+ self.driver_state_renderer.set_position(*driver_state_rect)
+ self.driver_state_renderer.render()
+
+ # Render driver monitoring alerts
+ self._render_dm_alerts(rect)
+
+ rl.end_scissor_mode()
+ return -1
+
+ def _publish_alert_sound(self, dm_state):
+ """Publish selfdriveState with only alertSound field set"""
+ msg = messaging.new_message('selfdriveState')
+ if dm_state is not None and len(dm_state.events):
+ event_name = EVENT_TO_INT[dm_state.events[0].name]
+ if event_name is not None and event_name in EVENTS and ET.PERMANENT in EVENTS[event_name]:
+ msg.selfdriveState.alertSound = EVENTS[event_name][ET.PERMANENT].audible_alert
+ self._pm.send('selfdriveState', msg)
+
+ def _render_dm_alerts(self, rect: rl.Rectangle):
+ """Render driver monitoring event names"""
+ dm_state = ui_state.sm["driverMonitoringState"]
+ self._publish_alert_sound(dm_state)
+
+ gui_label(rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height),
+ f"Awareness: {dm_state.awarenessStatus * 100:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
+ color=rl.Color(0, 0, 0, 180))
+ gui_label(rect, f"Awareness: {dm_state.awarenessStatus * 100:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
+ color=rl.Color(255, 255, 255, int(255 * 0.9)))
+
+ if not dm_state.events:
+ return
+
+ # Show first event (only one should be active at a time)
+ event_name_str = str(dm_state.events[0].name).split('.')[-1]
+ alignment = rl.GuiTextAlignment.TEXT_ALIGN_RIGHT if dm_state.isRHD else rl.GuiTextAlignment.TEXT_ALIGN_LEFT
+
+ shadow_rect = rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height)
+ gui_label(shadow_rect, event_name_str, font_size=40, font_weight=FontWeight.BOLD,
+ alignment=alignment,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM,
+ color=rl.Color(0, 0, 0, 180))
+ gui_label(rect, event_name_str, font_size=40, font_weight=FontWeight.BOLD,
+ alignment=alignment,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM,
+ color=rl.Color(255, 255, 255, int(255 * 0.9)))
+
+ def _load_eye_textures(self):
+ """Lazy load eye textures"""
+ if self._eye_fill_texture is None:
+ self._eye_fill_texture = gui_app.texture("icons_mici/onroad/eye_fill.png", self._eye_size, self._eye_size)
+ if self._eye_orange_texture is None:
+ self._eye_orange_texture = gui_app.texture("icons_mici/onroad/eye_orange.png", self._eye_size, self._eye_size)
+ if self._glasses_texture is None:
+ self._glasses_texture = gui_app.texture("icons_mici/onroad/glasses.png", self._glasses_size, self._glasses_size)
+
+ def _draw_face_detection(self, rect: rl.Rectangle) -> None:
+ driver_state = ui_state.sm["driverStateV2"]
+ is_rhd = driver_state.wheelOnRightProb > 0.5
+ driver_data = driver_state.rightDriverData if is_rhd else driver_state.leftDriverData
+ face_detect = driver_data.faceProb > 0.7
+ if not face_detect:
+ return
+
+ # Get face position and orientation
+ face_x, face_y = driver_data.facePosition
+ face_std = max(driver_data.faceOrientationStd[0], driver_data.faceOrientationStd[1])
+ alpha = 0.7
+ if face_std > 0.15:
+ alpha = max(0.7 - (face_std - 0.15) * 3.5, 0.0)
+
+ # use approx instead of distort_points
+ # TODO: replace with distort_points
+ tici_x = 1080.0 - 1714.0 * face_x
+ tici_y = -135.0 + (504.0 + abs(face_x) * 112.0) + (1205.0 - abs(face_x) * 724.0) * face_y
+
+ # Tici coords are relative to center, scale offset
+ offset_x = (tici_x - 1080.0) * 1.25
+ offset_y = (tici_y - 540.0) * 1.25
+
+ # Map to mici screen (scale from 2160x1080 to rect dimensions)
+ scale_x = rect.width / 2160.0
+ scale_y = rect.height / 1080.0
+ fbox_x = rect.x + rect.width / 2 + offset_x * scale_x
+ fbox_y = rect.y + rect.height / 2 + offset_y * scale_y
+ box_size = 50
+ line_thickness = 3
+
+ line_color = rl.Color(255, 255, 255, int(alpha * 255))
+ rl.draw_rectangle_rounded_lines_ex(
+ rl.Rectangle(fbox_x - box_size / 2, fbox_y - box_size / 2, box_size, box_size),
+ 35.0 / box_size / 2,
+ line_thickness,
+ line_thickness,
+ line_color,
+ )
+
+ # Draw eye indicators based on eye probabilities
+ eye_offset_x = 10
+ eye_offset_y = 10
+ eye_spacing = self._eye_size + 15
+
+ left_eye_x = rect.x + eye_offset_x
+ left_eye_y = rect.y + eye_offset_y
+ left_eye_prob = driver_data.leftEyeProb
+
+ right_eye_x = rect.x + eye_offset_x + eye_spacing
+ right_eye_y = rect.y + eye_offset_y
+ right_eye_prob = driver_data.rightEyeProb
+
+ # Draw eyes with opacity based on probability
+ for eye_x, eye_y, eye_prob in [(left_eye_x, left_eye_y, left_eye_prob), (right_eye_x, right_eye_y, right_eye_prob)]:
+ fill_opacity = eye_prob
+ orange_opacity = 1.0 - eye_prob
+
+ rl.draw_texture_v(self._eye_orange_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * orange_opacity)))
+ rl.draw_texture_v(self._eye_fill_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * fill_opacity)))
+
+ # Draw sunglasses indicator based on sunglasses probability
+ # Position glasses centered between the two eyes at top left
+ glasses_x = rect.x + eye_offset_x - 4
+ glasses_y = rect.y
+ glasses_pos = rl.Vector2(glasses_x, glasses_y)
+ glasses_prob = driver_data.sunglassesProb
+ rl.draw_texture_v(self._glasses_texture, glasses_pos, rl.Color(70, 80, 161, int(255 * glasses_prob)))
+
+ def _calc_driver_frame_matrix(self, rect: rl.Rectangle):
+ base = self._original_calc_frame_matrix(rect)
+ driver_view_ratio = 1.5
+ base[0, 0] *= driver_view_ratio
+ base[1, 1] *= driver_view_ratio
+ return base
+
+
+if __name__ == "__main__":
+ gui_app.init_window("Driver Camera View (mici)")
+
+ driver_camera_view = DriverCameraDialog()
+ try:
+ for _ in gui_app.render():
+ ui_state.update()
+ driver_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+ finally:
+ driver_camera_view.close()
diff --git a/selfdrive/ui/mici/onroad/driver_state.py b/selfdrive/ui/mici/onroad/driver_state.py
new file mode 100644
index 0000000000..369055846e
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/driver_state.py
@@ -0,0 +1,227 @@
+import pyray as rl
+from collections.abc import Callable
+import numpy as np
+import math
+from cereal import log
+from openpilot.common.filter_simple import FirstOrderFilter
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.widgets import Widget
+from openpilot.selfdrive.ui.ui_state import ui_state
+
+AlertSize = log.SelfdriveState.AlertSize
+
+DEBUG = False
+
+LOOKING_CENTER_THRESHOLD_UPPER = math.radians(6)
+LOOKING_CENTER_THRESHOLD_LOWER = math.radians(3)
+
+
+class DriverStateRenderer(Widget):
+ BASE_SIZE = 60
+ LINES_ANGLE_INCREMENT = 5
+ LINES_STALE_ANGLES = 3.0 # seconds
+
+ def __init__(self, lines: bool = False, confirm_mode: bool = False, confirm_callback: Callable | None = None):
+ super().__init__()
+ self.set_rect(rl.Rectangle(0, 0, self.BASE_SIZE, self.BASE_SIZE))
+ self._lines = lines or confirm_mode
+
+ # In confirm mode, user must fill out the circle to confirm some action in the UI
+ self._confirm_mode = confirm_mode
+ self._confirm_callback = confirm_callback
+ self._confirm_angles: dict[int, float] = {} # angle: timestamp
+
+ # In line mode, track smoothed angles
+ assert 360 % self.LINES_ANGLE_INCREMENT == 0
+ self._head_angles = {i * self.LINES_ANGLE_INCREMENT: FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) for i in range(360 // self.LINES_ANGLE_INCREMENT)}
+
+ self._is_active = False
+ self._is_rhd = False
+ self._face_detected = False
+ self._should_draw = False
+ self._force_active = False
+ self._looking_center = False
+
+ self._fade_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
+ self._pitch_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps, initialized=False)
+ self._yaw_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps, initialized=False)
+ self._rotation_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps, initialized=False)
+ self._looking_center_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
+
+ # Load the driver face icons
+ self.load_icons()
+
+ def load_icons(self):
+ """Load or reload the driver face icon texture"""
+ self._dm_person = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_person.png", self._rect.width, self._rect.height)
+ self._dm_cone = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_cone.png", self._rect.width, self._rect.height)
+ center_size = round(36 / self.BASE_SIZE * self._rect.width)
+ self._dm_center = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_center.png", center_size, center_size)
+ background_size = round(52 / self.BASE_SIZE * self._rect.width)
+ self._dm_background = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_background.png", background_size, background_size)
+
+ def set_should_draw(self, should_draw: bool):
+ self._should_draw = should_draw
+
+ @property
+ def should_draw(self):
+ return (self._should_draw and ui_state.sm["selfdriveState"].alertSize == AlertSize.none and
+ ui_state.sm.recv_frame["driverStateV2"] > ui_state.started_frame)
+
+ def set_force_active(self, force_active: bool):
+ """Force the dmoji to always appear active (green) regardless of actual state"""
+ self._force_active = force_active
+
+ @property
+ def effective_active(self) -> bool:
+ """Returns True if dmoji should appear active (either actually active or forced)"""
+ return bool(self._force_active or self._is_active)
+
+ def _render(self, _):
+ if DEBUG:
+ rl.draw_rectangle_lines_ex(self._rect, 1, rl.RED)
+
+ rl.draw_texture(self._dm_background,
+ int(self._rect.x + (self._rect.width - self._dm_background.width) / 2),
+ int(self._rect.y + (self._rect.height - self._dm_background.height) / 2),
+ rl.Color(255, 255, 255, int(255 * self._fade_filter.x)))
+
+ rl.draw_texture(self._dm_person, int(self._rect.x), int(self._rect.y),
+ rl.Color(255, 255, 255, int(255 * 0.9 * self._fade_filter.x)))
+
+ if self.effective_active:
+ source_rect = rl.Rectangle(0, 0, self._dm_cone.width, self._dm_cone.height)
+ dest_rect = rl.Rectangle(
+ self._rect.x + self._rect.width / 2,
+ self._rect.y + self._rect.height / 2,
+ self._dm_cone.width,
+ self._dm_cone.height,
+ )
+
+ if not self._lines:
+ rl.draw_texture_pro(
+ self._dm_cone,
+ source_rect,
+ dest_rect,
+ rl.Vector2(dest_rect.width / 2, dest_rect.height / 2),
+ self._rotation_filter.x - 90,
+ rl.Color(255, 255, 255, int(255 * self._fade_filter.x * (1 - self._looking_center_filter.x))),
+ )
+
+ rl.draw_texture_ex(
+ self._dm_center,
+ (int(self._rect.x + (self._rect.width - self._dm_center.width) / 2),
+ int(self._rect.y + (self._rect.height - self._dm_center.height) / 2)),
+ 0,
+ 1.0,
+ rl.Color(255, 255, 255, int(255 * self._fade_filter.x * self._looking_center_filter.x)),
+ )
+
+ else:
+ # remove old angles
+ now = rl.get_time()
+ self._confirm_angles = {angle: t for angle, t in self._confirm_angles.items() if now - t < self.LINES_STALE_ANGLES}
+
+ looking_center = self._looking_center_filter.x > 0.2
+ for angle, f in self._head_angles.items():
+ dst_from_current = ((angle - self._rotation_filter.x) % 360) - 180
+ target = 1.0 if abs(dst_from_current) <= self.LINES_ANGLE_INCREMENT * 5 else 0.0
+ if not self._face_detected:
+ target = 0.0
+
+ if self._confirm_mode:
+ # Extra careful to not add angles when looking near center
+ if target > 0 and not looking_center:
+ self._confirm_angles[angle] = now
+
+ # User is looking at area already confirmed, reduce target to indicate where they are
+ if angle in self._confirm_angles and target == 0:
+ target = 0.65
+
+ # Reduce all line lengths when looking center
+ if self._looking_center:
+ target = np.interp(self._looking_center_filter.x, [0.0, 1.0], [target, 0.45])
+
+ f.update(target)
+ self._draw_line(angle, f, self._looking_center and angle not in self._confirm_angles)
+
+ # if all lines placed, reset for next time and call callback
+ if self._confirm_mode:
+ if len(self._confirm_angles) >= 360 // self.LINES_ANGLE_INCREMENT:
+ self._confirm_angles = {}
+ if self._confirm_callback is not None:
+ self._confirm_callback()
+
+ def _draw_line(self, angle: int, f: FirstOrderFilter, grey: bool):
+ line_length = self._rect.width / 6
+ line_length = round(np.interp(f.x, [0.0, 1.0], [0, line_length]))
+ line_offset = self._rect.width / 2 - line_length * 2 # ensure line ends within rect
+ center_x = self._rect.x + self._rect.width / 2
+ center_y = self._rect.y + self._rect.height / 2
+ start_x = center_x + (line_offset + line_length) * math.cos(math.radians(angle))
+ start_y = center_y + (line_offset + line_length) * math.sin(math.radians(angle))
+ end_x = start_x + line_length * math.cos(math.radians(angle))
+ end_y = start_y + line_length * math.sin(math.radians(angle))
+ color = rl.Color(0, 255, 64, 255)
+
+ if grey:
+ color = rl.Color(166, 166, 166, 255)
+
+ if f.x > 0.01:
+ rl.draw_line_ex((start_x, start_y), (end_x, end_y), 12, color)
+
+ def _update_state(self):
+ sm = ui_state.sm
+
+ # Get monitoring state
+ dm_state = sm["driverMonitoringState"]
+ self._is_active = dm_state.isActiveMode
+ self._is_rhd = dm_state.isRHD
+ self._face_detected = dm_state.faceDetected
+
+ driverstate = sm["driverStateV2"]
+ driver_data = driverstate.rightDriverData if self._is_rhd else driverstate.leftDriverData
+ driver_orient = driver_data.faceOrientation
+
+ if len(driver_orient) != 3:
+ return
+
+ pitch, yaw, roll = driver_orient
+ pitch = self._pitch_filter.update(pitch)
+ yaw = self._yaw_filter.update(yaw)
+
+ # hysteresis on looking center
+ if abs(pitch) < LOOKING_CENTER_THRESHOLD_LOWER and abs(yaw) < LOOKING_CENTER_THRESHOLD_LOWER:
+ self._looking_center = True
+ elif abs(pitch) > LOOKING_CENTER_THRESHOLD_UPPER or abs(yaw) > LOOKING_CENTER_THRESHOLD_UPPER:
+ self._looking_center = False
+ self._looking_center_filter.update(1 if self._looking_center else 0)
+
+ if DEBUG:
+ pitchd = math.degrees(pitch)
+ yawd = math.degrees(yaw)
+ rolld = math.degrees(roll)
+
+ rl.draw_line_ex((0, 100), (200, 100), 3, rl.RED)
+ rl.draw_line_ex((0, 120), (200, 120), 3, rl.RED)
+ rl.draw_line_ex((0, 140), (200, 140), 3, rl.RED)
+
+ pitch_x = 100 + pitchd
+ yaw_x = 100 + yawd
+ roll_x = 100 + rolld
+ rl.draw_circle(int(pitch_x), 100, 5, rl.GREEN)
+ rl.draw_circle(int(yaw_x), 120, 5, rl.GREEN)
+ rl.draw_circle(int(roll_x), 140, 5, rl.GREEN)
+
+ # filter head rotation, handling wrap-around
+ rotation = math.degrees(math.atan2(pitch, yaw))
+ angle_diff = rotation - self._rotation_filter.x
+ angle_diff = ((angle_diff + 180) % 360) - 180
+ self._rotation_filter.update(self._rotation_filter.x + angle_diff)
+
+ if not self.should_draw:
+ self._fade_filter.update(0.0)
+ elif not self.effective_active:
+ self._fade_filter.update(0.35)
+ else:
+ self._fade_filter.update(1.0)
diff --git a/selfdrive/ui/mici/onroad/hud_renderer.py b/selfdrive/ui/mici/onroad/hud_renderer.py
new file mode 100644
index 0000000000..bb5171d6e3
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/hud_renderer.py
@@ -0,0 +1,287 @@
+import pyray as rl
+from dataclasses import dataclass
+from openpilot.common.constants import CV
+from openpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar
+from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.lib.multilang import tr
+from openpilot.system.ui.lib.text_measure import measure_text_cached
+from openpilot.system.ui.widgets import Widget
+from openpilot.common.filter_simple import FirstOrderFilter
+from cereal import log
+
+EventName = log.OnroadEvent.EventName
+
+# Constants
+SET_SPEED_NA = 255
+KM_TO_MILE = 0.621371
+CRUISE_DISABLED_CHAR = '–'
+
+SET_SPEED_PERSISTENCE = 2.5 # seconds
+
+
+@dataclass(frozen=True)
+class FontSizes:
+ current_speed: int = 176
+ speed_unit: int = 66
+ max_speed: int = 36
+ set_speed: int = 112
+
+
+@dataclass(frozen=True)
+class Colors:
+ white: rl.Color = rl.WHITE
+ disengaged: rl.Color = rl.Color(145, 155, 149, 255)
+ override: rl.Color = rl.Color(145, 155, 149, 255) # Added
+ engaged: rl.Color = rl.Color(128, 216, 166, 255)
+ disengaged_bg: rl.Color = rl.Color(0, 0, 0, 153)
+ override_bg: rl.Color = rl.Color(145, 155, 149, 204)
+ engaged_bg: rl.Color = rl.Color(128, 216, 166, 204)
+ grey: rl.Color = rl.Color(166, 166, 166, 255)
+ dark_grey: rl.Color = rl.Color(114, 114, 114, 255)
+ black_translucent: rl.Color = rl.Color(0, 0, 0, 166)
+ white_translucent: rl.Color = rl.Color(255, 255, 255, 200)
+ border_translucent: rl.Color = rl.Color(255, 255, 255, 75)
+ header_gradient_start: rl.Color = rl.Color(0, 0, 0, 114)
+ header_gradient_end: rl.Color = rl.BLANK
+
+
+FONT_SIZES = FontSizes()
+COLORS = Colors()
+
+
+class TurnIntent(Widget):
+ FADE_IN_ANGLE = 30 # degrees
+
+ def __init__(self):
+ super().__init__()
+ self._pre = False
+ self._turn_intent_direction: int = 0
+
+ self._turn_intent_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
+ self._turn_intent_rotation_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
+
+ self._txt_turn_intent_left: rl.Texture = gui_app.texture('icons_mici/turn_intent_left.png', 50, 19)
+ self._txt_turn_intent_right: rl.Texture = gui_app.texture('icons_mici/turn_intent_right.png', 50, 19)
+
+ def _render(self, _):
+ if self._turn_intent_alpha_filter.x > 1e-2:
+ turn_intent_texture = self._txt_turn_intent_right if self._turn_intent_direction == 1 else self._txt_turn_intent_left
+ src_rect = rl.Rectangle(0, 0, turn_intent_texture.width, turn_intent_texture.height)
+ dest_rect = rl.Rectangle(self._rect.x + self._rect.width / 2, self._rect.y + self._rect.height / 2,
+ turn_intent_texture.width, turn_intent_texture.height)
+
+ origin = (turn_intent_texture.width / 2, self._rect.height / 2)
+ color = rl.Color(255, 255, 255, int(255 * self._turn_intent_alpha_filter.x))
+ rl.draw_texture_pro(turn_intent_texture, src_rect, dest_rect, origin, self._turn_intent_rotation_filter.x, color)
+
+ def _update_state(self) -> None:
+ sm = ui_state.sm
+
+ left = any(e.name == EventName.preLaneChangeLeft for e in sm['onroadEvents'])
+ right = any(e.name == EventName.preLaneChangeRight for e in sm['onroadEvents'])
+ if left or right:
+ # pre lane change
+ if not self._pre:
+ self._turn_intent_rotation_filter.x = self.FADE_IN_ANGLE if left else -self.FADE_IN_ANGLE
+
+ self._pre = True
+ self._turn_intent_direction = -1 if left else 1
+ self._turn_intent_alpha_filter.update(1)
+ self._turn_intent_rotation_filter.update(0)
+ elif any(e.name == EventName.laneChange for e in sm['onroadEvents']):
+ # fade out and rotate away
+ self._pre = False
+ self._turn_intent_alpha_filter.update(0)
+
+ if self._turn_intent_direction == 0:
+ # unknown. missed pre frame?
+ self._turn_intent_rotation_filter.update(0)
+ else:
+ self._turn_intent_rotation_filter.update(self._turn_intent_direction * self.FADE_IN_ANGLE)
+ else:
+ # didn't complete lane change, just hide
+ self._pre = False
+ self._turn_intent_direction = 0
+ self._turn_intent_alpha_filter.update(0)
+ self._turn_intent_rotation_filter.update(0)
+
+
+class HudRenderer(Widget):
+ def __init__(self):
+ super().__init__()
+ """Initialize the HUD renderer."""
+ self.is_cruise_set: bool = False
+ self.is_cruise_available: bool = True
+ self.set_speed: float = SET_SPEED_NA
+ self._set_speed_changed_time: float = 0
+ self.speed: float = 0.0
+ self.v_ego_cluster_seen: bool = False
+ self._engaged: bool = False
+
+ self._can_draw_top_icons = True
+ self._show_wheel_critical = False
+
+ self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
+ self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM)
+ self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD)
+ self._font_display: rl.Font = gui_app.font(FontWeight.DISPLAY)
+
+ self._turn_intent = TurnIntent()
+ self._torque_bar = TorqueBar()
+
+ self._txt_wheel: rl.Texture = gui_app.texture('icons_mici/wheel.png', 50, 50)
+ self._txt_wheel_critical: rl.Texture = gui_app.texture('icons_mici/wheel_critical.png', 50, 50)
+ self._txt_exclamation_point: rl.Texture = gui_app.texture('icons_mici/exclamation_point.png', 44, 44)
+
+ self._wheel_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
+ self._wheel_y_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
+
+ self._set_speed_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
+
+ def set_wheel_critical_icon(self, critical: bool):
+ """Set the wheel icon to critical or normal state."""
+ self._show_wheel_critical = critical
+
+ def set_can_draw_top_icons(self, can_draw_top_icons: bool):
+ """Set whether to draw the top part of the HUD."""
+ self._can_draw_top_icons = can_draw_top_icons
+
+ def drawing_top_icons(self) -> bool:
+ # whether we're drawing any top icons currently
+ return bool(self._set_speed_alpha_filter.x > 1e-2)
+
+ def _update_state(self) -> None:
+ """Update HUD state based on car state and controls state."""
+ sm = ui_state.sm
+ if sm.recv_frame["carState"] < ui_state.started_frame:
+ self.is_cruise_set = False
+ self.set_speed = SET_SPEED_NA
+ self.speed = 0.0
+ return
+
+ controls_state = sm['controlsState']
+ car_state = sm['carState']
+
+ v_cruise_cluster = car_state.vCruiseCluster
+ set_speed = (
+ controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster
+ )
+ engaged = sm['selfdriveState'].enabled
+ if (set_speed != self.set_speed and engaged) or (engaged and not self._engaged):
+ self._set_speed_changed_time = rl.get_time()
+ self._engaged = engaged
+ self.set_speed = set_speed
+ self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA
+ self.is_cruise_available = self.set_speed != -1
+
+ v_ego_cluster = car_state.vEgoCluster
+ self.v_ego_cluster_seen = self.v_ego_cluster_seen or v_ego_cluster != 0.0
+ v_ego = v_ego_cluster if self.v_ego_cluster_seen else car_state.vEgo
+ speed_conversion = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
+ self.speed = max(0.0, v_ego * speed_conversion)
+
+ def _render(self, rect: rl.Rectangle) -> None:
+ """Render HUD elements to the screen."""
+
+ if ui_state.sm['controlsState'].lateralControlState.which() != 'angleState':
+ self._torque_bar.render(rect)
+
+ if self.is_cruise_set:
+ self._draw_set_speed(rect)
+
+ self._draw_steering_wheel(rect)
+
+ def _draw_steering_wheel(self, rect: rl.Rectangle) -> None:
+ wheel_txt = self._txt_wheel_critical if self._show_wheel_critical else self._txt_wheel
+
+ if self._show_wheel_critical:
+ self._wheel_alpha_filter.update(255)
+ self._wheel_y_filter.update(0)
+ else:
+ if ui_state.status == UIStatus.DISENGAGED:
+ self._wheel_alpha_filter.update(0)
+ self._wheel_y_filter.update(wheel_txt.height / 2)
+ else:
+ self._wheel_alpha_filter.update(255 * 0.9)
+ self._wheel_y_filter.update(0)
+
+ # pos
+ pos_x = int(rect.x + 21 + wheel_txt.width / 2)
+ pos_y = int(rect.y + rect.height - 14 - wheel_txt.height / 2 + self._wheel_y_filter.x)
+ rotation = -ui_state.sm['carState'].steeringAngleDeg
+
+ turn_intent_margin = 25
+ self._turn_intent.render(rl.Rectangle(
+ pos_x - wheel_txt.width / 2 - turn_intent_margin,
+ pos_y - wheel_txt.height / 2 - turn_intent_margin,
+ wheel_txt.width + turn_intent_margin * 2,
+ wheel_txt.height + turn_intent_margin * 2,
+ ))
+
+ src_rect = rl.Rectangle(0, 0, wheel_txt.width, wheel_txt.height)
+ dest_rect = rl.Rectangle(pos_x, pos_y, wheel_txt.width, wheel_txt.height)
+ origin = (wheel_txt.width / 2, wheel_txt.height / 2)
+
+ # color and draw
+ color = rl.Color(255, 255, 255, int(self._wheel_alpha_filter.x))
+ rl.draw_texture_pro(wheel_txt, src_rect, dest_rect, origin, rotation, color)
+
+ if self._show_wheel_critical:
+ # Draw exclamation point icon
+ EXCLAMATION_POINT_SPACING = 10
+ exclamation_pos_x = pos_x - self._txt_exclamation_point.width / 2 + wheel_txt.width / 2 + EXCLAMATION_POINT_SPACING
+ exclamation_pos_y = pos_y - self._txt_exclamation_point.height / 2
+ rl.draw_texture(self._txt_exclamation_point, int(exclamation_pos_x), int(exclamation_pos_y), rl.WHITE)
+
+ def _draw_set_speed(self, rect: rl.Rectangle) -> None:
+ """Draw the MAX speed indicator box."""
+ x = rect.x
+ y = rect.y
+
+ alpha = self._set_speed_alpha_filter.update(0 < rl.get_time() - self._set_speed_changed_time < SET_SPEED_PERSISTENCE and
+ self._can_draw_top_icons and self._engaged)
+
+ # draw drop shadow
+ circle_radius = 162 // 2
+ rl.draw_circle_gradient(int(x + circle_radius), int(y + circle_radius), circle_radius,
+ rl.Color(0, 0, 0, int(255 / 2 * alpha)), rl.Color(0, 0, 0, 0))
+
+ set_speed_color = rl.Color(255, 255, 255, int(255 * 0.9 * alpha))
+ max_color = rl.Color(255, 255, 255, int(255 * 0.9 * alpha))
+
+ set_speed = self.set_speed
+ if self.is_cruise_set and not ui_state.is_metric:
+ set_speed *= KM_TO_MILE
+
+ set_speed_text = CRUISE_DISABLED_CHAR if not self.is_cruise_set else str(round(set_speed))
+ rl.draw_text_ex(
+ self._font_display,
+ set_speed_text,
+ rl.Vector2(x + 13 + 4, y + 3 - 8 - 3 + 4),
+ FONT_SIZES.set_speed,
+ 0,
+ set_speed_color,
+ )
+
+ max_text = tr("MAX")
+ rl.draw_text_ex(
+ self._font_semi_bold,
+ max_text,
+ rl.Vector2(x + 25, y + FONT_SIZES.set_speed - 7 + 4),
+ FONT_SIZES.max_speed,
+ 0,
+ max_color,
+ )
+
+ def _draw_current_speed(self, rect: rl.Rectangle) -> None:
+ """Draw the current vehicle speed and unit."""
+ speed_text = str(round(self.speed))
+ speed_text_size = measure_text_cached(self._font_bold, speed_text, FONT_SIZES.current_speed)
+ speed_pos = rl.Vector2(rect.x + rect.width / 2 - speed_text_size.x / 2, 180 - speed_text_size.y / 2)
+ rl.draw_text_ex(self._font_bold, speed_text, speed_pos, FONT_SIZES.current_speed, 0, COLORS.white)
+
+ unit_text = tr("km/h") if ui_state.is_metric else tr("mph")
+ unit_text_size = measure_text_cached(self._font_medium, unit_text, FONT_SIZES.speed_unit)
+ unit_pos = rl.Vector2(rect.x + rect.width / 2 - unit_text_size.x / 2, 290 - unit_text_size.y / 2)
+ rl.draw_text_ex(self._font_medium, unit_text, unit_pos, FONT_SIZES.speed_unit, 0, COLORS.white_translucent)
diff --git a/selfdrive/ui/mici/onroad/model_renderer.py b/selfdrive/ui/mici/onroad/model_renderer.py
new file mode 100644
index 0000000000..3f1badfe84
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/model_renderer.py
@@ -0,0 +1,479 @@
+import colorsys
+import numpy as np
+import pyray as rl
+from cereal import messaging, car
+from dataclasses import dataclass, field
+from openpilot.common.params import Params
+from openpilot.common.filter_simple import FirstOrderFilter
+from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT
+from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
+from openpilot.selfdrive.ui.mici.onroad import blend_colors
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
+from openpilot.system.ui.widgets import Widget
+
+CLIP_MARGIN = 500
+MIN_DRAW_DISTANCE = 10.0
+MAX_DRAW_DISTANCE = 100.0
+
+THROTTLE_COLORS = [
+ rl.Color(13, 248, 122, 102), # HSLF(148/360, 0.94, 0.51, 0.4)
+ rl.Color(114, 255, 92, 89), # HSLF(112/360, 1.0, 0.68, 0.35)
+ rl.Color(114, 255, 92, 0), # HSLF(112/360, 1.0, 0.68, 0.0)
+]
+
+NO_THROTTLE_COLORS = [
+ rl.Color(242, 242, 242, 102), # HSLF(148/360, 0.0, 0.95, 0.4)
+ rl.Color(242, 242, 242, 89), # HSLF(112/360, 0.0, 0.95, 0.35)
+ rl.Color(242, 242, 242, 0), # HSLF(112/360, 0.0, 0.95, 0.0)
+]
+
+LANE_LINE_COLORS = {
+ UIStatus.DISENGAGED: rl.Color(200, 200, 200, 255),
+ UIStatus.OVERRIDE: rl.Color(255, 255, 255, 255),
+ UIStatus.ENGAGED: rl.Color(0, 255, 64, 255),
+}
+
+
+@dataclass
+class ModelPoints:
+ raw_points: np.ndarray = field(default_factory=lambda: np.empty((0, 3), dtype=np.float32))
+ projected_points: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=np.float32))
+
+
+@dataclass
+class LeadVehicle:
+ glow: list[float] = field(default_factory=list)
+ chevron: list[float] = field(default_factory=list)
+ fill_alpha: int = 0
+
+
+class ModelRenderer(Widget):
+ def __init__(self):
+ super().__init__()
+ self._longitudinal_control = False
+ self._experimental_mode = False
+ self._blend_filter = FirstOrderFilter(1.0, 0.25, 1 / gui_app.target_fps)
+ self._prev_allow_throttle = True
+ self._lane_line_probs = np.zeros(4, dtype=np.float32)
+ self._road_edge_stds = np.zeros(2, dtype=np.float32)
+ self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
+ self._path_offset_z = HEIGHT_INIT[0]
+
+ # Initialize ModelPoints objects
+ self._path = ModelPoints()
+ self._lane_lines = [ModelPoints() for _ in range(4)]
+ self._road_edges = [ModelPoints() for _ in range(2)]
+ self._acceleration_x = np.empty((0,), dtype=np.float32)
+
+ self._acceleration_x_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
+ self._acceleration_x_filter2 = FirstOrderFilter(0.0, 1, 1 / gui_app.target_fps)
+
+ self._torque_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
+ self._ll_color_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
+
+ # Transform matrix (3x3 for car space to screen space)
+ self._car_space_transform = np.zeros((3, 3), dtype=np.float32)
+ self._transform_dirty = True
+ self._clip_region = None
+
+ self._exp_gradient = Gradient(
+ start=(0.0, 1.0), # Bottom of path
+ end=(0.0, 0.0), # Top of path
+ colors=[],
+ stops=[],
+ )
+
+ # Get longitudinal control setting from car parameters
+ if car_params := Params().get("CarParams"):
+ cp = messaging.log_from_bytes(car_params, car.CarParams)
+ self._longitudinal_control = cp.openpilotLongitudinalControl
+
+ def set_transform(self, transform: np.ndarray):
+ self._car_space_transform = transform.astype(np.float32)
+ self._transform_dirty = True
+
+ def _render(self, rect: rl.Rectangle):
+ sm = ui_state.sm
+
+ self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque)
+
+ # Check if data is up-to-date
+ if (sm.recv_frame["liveCalibration"] < ui_state.started_frame or
+ sm.recv_frame["modelV2"] < ui_state.started_frame):
+ return
+
+ # Set up clipping region
+ self._clip_region = rl.Rectangle(
+ rect.x - CLIP_MARGIN, rect.y - CLIP_MARGIN, rect.width + 2 * CLIP_MARGIN, rect.height + 2 * CLIP_MARGIN
+ )
+
+ # Update state
+ self._experimental_mode = sm['selfdriveState'].experimentalMode
+
+ live_calib = sm['liveCalibration']
+ self._path_offset_z = live_calib.height[0] if live_calib.height else HEIGHT_INIT[0]
+
+ if sm.updated['carParams']:
+ self._longitudinal_control = sm['carParams'].openpilotLongitudinalControl
+
+ model = sm['modelV2']
+ radar_state = sm['radarState'] if sm.valid['radarState'] else None
+ lead_one = radar_state.leadOne if radar_state else None
+ render_lead_indicator = self._longitudinal_control and radar_state is not None
+
+ # Update model data when needed
+ model_updated = sm.updated['modelV2']
+ if model_updated or sm.updated['radarState'] or self._transform_dirty:
+ if model_updated:
+ self._update_raw_points(model)
+
+ path_x_array = self._path.raw_points[:, 0]
+ if path_x_array.size == 0:
+ return
+
+ self._update_model(lead_one, path_x_array)
+ if render_lead_indicator:
+ self._update_leads(radar_state, path_x_array)
+ self._transform_dirty = False
+
+ # Draw elements (hide when disengaged)
+ if ui_state.status != UIStatus.DISENGAGED:
+ self._draw_lane_lines()
+ self._draw_path(sm)
+
+ # if render_lead_indicator and radar_state:
+ # self._draw_lead_indicator()
+
+ def _update_raw_points(self, model):
+ """Update raw 3D points from model data"""
+ self._path.raw_points = np.array([model.position.x, model.position.y, model.position.z], dtype=np.float32).T
+
+ for i, lane_line in enumerate(model.laneLines):
+ self._lane_lines[i].raw_points = np.array([lane_line.x, lane_line.y, lane_line.z], dtype=np.float32).T
+
+ for i, road_edge in enumerate(model.roadEdges):
+ self._road_edges[i].raw_points = np.array([road_edge.x, road_edge.y, road_edge.z], dtype=np.float32).T
+
+ self._lane_line_probs = np.array(model.laneLineProbs, dtype=np.float32)
+ self._road_edge_stds = np.array(model.roadEdgeStds, dtype=np.float32)
+ self._acceleration_x = np.array(model.acceleration.x, dtype=np.float32)
+
+ def _update_leads(self, radar_state, path_x_array):
+ """Update positions of lead vehicles"""
+ self._lead_vehicles = [LeadVehicle(), LeadVehicle()]
+ leads = [radar_state.leadOne, radar_state.leadTwo]
+
+ for i, lead_data in enumerate(leads):
+ if lead_data and lead_data.status:
+ d_rel, y_rel, v_rel = lead_data.dRel, lead_data.yRel, lead_data.vRel
+ idx = self._get_path_length_idx(path_x_array, d_rel)
+
+ # Get z-coordinate from path at the lead vehicle position
+ z = self._path.raw_points[idx, 2] if idx < len(self._path.raw_points) else 0.0
+ point = self._map_to_screen(d_rel, -y_rel, z + self._path_offset_z)
+ if point:
+ self._lead_vehicles[i] = self._update_lead_vehicle(d_rel, v_rel, point, self._rect)
+
+ def _update_model(self, lead, path_x_array):
+ """Update model visualization data based on model message"""
+ max_distance = np.clip(path_x_array[-1], MIN_DRAW_DISTANCE, MAX_DRAW_DISTANCE)
+ max_idx = self._get_path_length_idx(self._lane_lines[0].raw_points[:, 0], max_distance)
+
+ # Update lane lines using raw points
+ line_width_factor = 0.12
+ for i, lane_line in enumerate(self._lane_lines):
+ if i in (1, 2):
+ line_width_factor = 0.16
+ lane_line.projected_points = self._map_line_to_polygon(
+ lane_line.raw_points, line_width_factor * self._lane_line_probs[i], 0.0, max_idx
+ )
+
+ # Update road edges using raw points
+ for road_edge in self._road_edges:
+ road_edge.projected_points = self._map_line_to_polygon(road_edge.raw_points, line_width_factor, 0.0, max_idx)
+
+ # Update path using raw points
+ if lead and lead.status:
+ lead_d = lead.dRel * 2.0
+ max_distance = np.clip(lead_d - min(lead_d * 0.35, 10.0), 0.0, max_distance)
+
+ soon_acceleration = self._acceleration_x[len(self._acceleration_x) // 4] if len(self._acceleration_x) > 0 else 0
+ self._acceleration_x_filter.update(soon_acceleration)
+ self._acceleration_x_filter2.update(soon_acceleration)
+
+ # make path width wider/thinner when initially braking/accelerating
+ if self._experimental_mode and False:
+ high_pass_acceleration = self._acceleration_x_filter.x - self._acceleration_x_filter2.x
+ y_off = np.interp(high_pass_acceleration, [-1, 0, 1], [0.9 * 2, 0.9, 0.9 / 2])
+ else:
+ y_off = 0.9
+
+ max_idx = self._get_path_length_idx(path_x_array, max_distance)
+ self._path.projected_points = self._map_line_to_polygon(
+ self._path.raw_points, y_off, self._path_offset_z, max_idx, allow_invert=False
+ )
+
+ self._update_experimental_gradient()
+
+ def _update_experimental_gradient(self):
+ """Pre-calculate experimental mode gradient colors"""
+ if not self._experimental_mode:
+ return
+
+ max_len = min(len(self._path.projected_points) // 2, len(self._acceleration_x))
+
+ segment_colors = []
+ gradient_stops = []
+
+ i = 0
+ while i < max_len:
+ # Some points (screen space) are out of frame (rect space)
+ track_y = self._path.projected_points[i][1]
+ if track_y < self._rect.y or track_y > (self._rect.y + self._rect.height):
+ i += 1
+ continue
+
+ # Calculate color based on acceleration (0 is bottom, 1 is top)
+ lin_grad_point = 1 - (track_y - self._rect.y) / self._rect.height
+
+ # speed up: 120, slow down: 0
+ path_hue = np.clip(60 + self._acceleration_x[i] * 35, 0, 120)
+
+ saturation = min(abs(self._acceleration_x[i] * 1.5), 1)
+ lightness = np.interp(saturation, [0.0, 1.0], [0.95, 0.62])
+ alpha = np.interp(lin_grad_point, [0.75 / 2.0, 0.75], [0.4, 0.0])
+
+ # Use HSL to RGB conversion
+ color = self._hsla_to_color(path_hue / 360.0, saturation, lightness, alpha)
+
+ gradient_stops.append(lin_grad_point)
+ segment_colors.append(color)
+
+ # Skip a point, unless next is last
+ i += 1 + (1 if (i + 2) < max_len else 0)
+
+ # Store the gradient in the path object
+ self._exp_gradient.colors = segment_colors
+ self._exp_gradient.stops = gradient_stops
+
+ def _update_lead_vehicle(self, d_rel, v_rel, point, rect):
+ speed_buff, lead_buff = 10.0, 40.0
+
+ # Calculate fill alpha
+ fill_alpha = 0
+ if d_rel < lead_buff:
+ fill_alpha = 255 * (1.0 - (d_rel / lead_buff))
+ if v_rel < 0:
+ fill_alpha += 255 * (-1 * (v_rel / speed_buff))
+ fill_alpha = min(fill_alpha, 255)
+
+ # Calculate size and position
+ sz = np.clip((25 * 30) / (d_rel / 3 + 30), 15.0, 30.0) * 1
+ x = np.clip(point[0], 0.0, rect.width - sz / 2)
+ y = min(point[1], rect.height - sz * 0.6)
+
+ g_xo = sz / 5
+ g_yo = sz / 10
+
+ glow = [(x + (sz * 1.35) + g_xo, y + sz + g_yo), (x, y - g_yo), (x - (sz * 1.35) - g_xo, y + sz + g_yo)]
+ chevron = [(x + (sz * 1.25), y + sz), (x, y), (x - (sz * 1.25), y + sz)]
+
+ return LeadVehicle(glow=glow, chevron=chevron, fill_alpha=int(fill_alpha))
+
+ def _get_ll_color(self, prob: float, adjacent: bool, left: bool):
+ alpha = np.clip(prob, 0.0, 0.7)
+ if adjacent:
+ _base_color = LANE_LINE_COLORS.get(ui_state.status, LANE_LINE_COLORS[UIStatus.DISENGAGED])
+ color = rl.Color(_base_color.r, _base_color.g, _base_color.b, int(alpha * 255))
+
+ # turn adjacent lls orange if torque is high
+ torque = self._torque_filter.x
+ high_torque = abs(torque) > 0.6
+ if high_torque and (left == (torque > 0)):
+ color = blend_colors(
+ color,
+ rl.Color(255, 115, 0, int(alpha * 255)), # orange
+ np.interp(abs(torque), [0.6, 0.8], [0.0, 1.0])
+ )
+ else:
+ color = rl.Color(255, 255, 255, int(alpha * 255))
+
+ if ui_state.status == UIStatus.DISENGAGED:
+ color = rl.Color(0, 0, 0, int(alpha * 255))
+
+ return color
+
+ def _draw_lane_lines(self):
+ """Draw lane lines and road edges"""
+ """Two closest lines should be green (lane line or road edges)"""
+ for i, lane_line in enumerate(self._lane_lines):
+ if lane_line.projected_points.size == 0:
+ continue
+
+ color = self._get_ll_color(float(self._lane_line_probs[i]), i in (1, 2), i in (0, 1))
+ draw_polygon(self._rect, lane_line.projected_points, color)
+
+ for i, road_edge in enumerate(self._road_edges):
+ if road_edge.projected_points.size == 0:
+ continue
+
+ # if closest lane lines are not confident, make road edges green
+ color = self._get_ll_color(float(1.0 - self._road_edge_stds[i]), float(self._lane_line_probs[i + 1]) < 0.25, i == 0)
+ draw_polygon(self._rect, road_edge.projected_points, color)
+
+ def _draw_path(self, sm):
+ """Draw path with dynamic coloring based on mode and throttle state."""
+ if not self._path.projected_points.size:
+ return
+
+ allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control
+ self._blend_filter.update(int(allow_throttle))
+
+ if self._experimental_mode:
+ # Draw with acceleration coloring
+ if ui_state.status == UIStatus.DISENGAGED:
+ draw_polygon(self._rect, self._path.projected_points, rl.Color(0, 0, 0, 90))
+ elif len(self._exp_gradient.colors) > 1:
+ draw_polygon(self._rect, self._path.projected_points, gradient=self._exp_gradient)
+ else:
+ draw_polygon(self._rect, self._path.projected_points, rl.Color(255, 255, 255, 30))
+ else:
+ # Blend throttle/no throttle colors based on transition
+ blend_factor = round(self._blend_filter.x * 100) / 100
+ blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor)
+ gradient = Gradient(
+ start=(0.0, 1.0), # Bottom of path
+ end=(0.0, 0.0), # Top of path
+ colors=blended_colors,
+ stops=[0.0, 0.5, 1.0],
+ )
+
+ if ui_state.status == UIStatus.DISENGAGED:
+ draw_polygon(self._rect, self._path.projected_points, rl.Color(0, 0, 0, 90))
+ else:
+ draw_polygon(self._rect, self._path.projected_points, gradient=gradient)
+
+ def _draw_lead_indicator(self):
+ # Draw lead vehicles if available
+ for lead in self._lead_vehicles:
+ if not lead.glow or not lead.chevron:
+ continue
+
+ rl.draw_triangle_fan(lead.glow, len(lead.glow), rl.Color(218, 202, 37, 255))
+ rl.draw_triangle_fan(lead.chevron, len(lead.chevron), rl.Color(201, 34, 49, lead.fill_alpha))
+
+ @staticmethod
+ def _get_path_length_idx(pos_x_array: np.ndarray, path_height: float) -> int:
+ """Get the index corresponding to the given path height"""
+ if len(pos_x_array) == 0:
+ return 0
+ indices = np.where(pos_x_array <= path_height)[0]
+ return indices[-1] if indices.size > 0 else 0
+
+ def _map_to_screen(self, in_x, in_y, in_z):
+ """Project a point in car space to screen space"""
+ input_pt = np.array([in_x, in_y, in_z])
+ pt = self._car_space_transform @ input_pt
+
+ if abs(pt[2]) < 1e-6:
+ return None
+
+ x, y = pt[0] / pt[2], pt[1] / pt[2]
+
+ clip = self._clip_region
+ if not (clip.x <= x <= clip.x + clip.width and clip.y <= y <= clip.y + clip.height):
+ return None
+
+ return (x, y)
+
+ def _map_line_to_polygon(self, line: np.ndarray, y_off: float, z_off: float, max_idx: int, allow_invert: bool = True) -> np.ndarray:
+ """Convert 3D line to 2D polygon for rendering."""
+ if line.shape[0] == 0:
+ return np.empty((0, 2), dtype=np.float32)
+
+ # Slice points and filter non-negative x-coordinates
+ points = line[:max_idx + 1]
+ points = points[points[:, 0] >= 0]
+ if points.shape[0] == 0:
+ return np.empty((0, 2), dtype=np.float32)
+
+ N = points.shape[0]
+ # Generate left and right 3D points in one array using broadcasting
+ offsets = np.array([[0, -y_off, z_off], [0, y_off, z_off]], dtype=np.float32)
+ points_3d = points[None, :, :] + offsets[:, None, :] # Shape: 2xNx3
+ points_3d = points_3d.reshape(2 * N, 3) # Shape: (2*N)x3
+
+ # Transform all points to projected space in one operation
+ proj = self._car_space_transform @ points_3d.T # Shape: 3x(2*N)
+ proj = proj.reshape(3, 2, N)
+ left_proj = proj[:, 0, :]
+ right_proj = proj[:, 1, :]
+
+ # Filter points where z is sufficiently large
+ valid_proj = (np.abs(left_proj[2]) >= 1e-6) & (np.abs(right_proj[2]) >= 1e-6)
+ if not np.any(valid_proj):
+ return np.empty((0, 2), dtype=np.float32)
+
+ # Compute screen coordinates
+ left_screen = left_proj[:2, valid_proj] / left_proj[2, valid_proj][None, :]
+ right_screen = right_proj[:2, valid_proj] / right_proj[2, valid_proj][None, :]
+
+ # Define clip region bounds
+ clip = self._clip_region
+ x_min, x_max = clip.x, clip.x + clip.width
+ y_min, y_max = clip.y, clip.y + clip.height
+
+ # Filter points within clip region
+ left_in_clip = (
+ (left_screen[0] >= x_min) & (left_screen[0] <= x_max) &
+ (left_screen[1] >= y_min) & (left_screen[1] <= y_max)
+ )
+ right_in_clip = (
+ (right_screen[0] >= x_min) & (right_screen[0] <= x_max) &
+ (right_screen[1] >= y_min) & (right_screen[1] <= y_max)
+ )
+ both_in_clip = left_in_clip & right_in_clip
+
+ if not np.any(both_in_clip):
+ return np.empty((0, 2), dtype=np.float32)
+
+ # Select valid and clipped points
+ left_screen = left_screen[:, both_in_clip]
+ right_screen = right_screen[:, both_in_clip]
+
+ # Handle Y-coordinate inversion on hills
+ if not allow_invert and left_screen.shape[1] > 1:
+ y = left_screen[1, :] # y-coordinates
+ keep = y == np.minimum.accumulate(y)
+ if not np.any(keep):
+ return np.empty((0, 2), dtype=np.float32)
+ left_screen = left_screen[:, keep]
+ right_screen = right_screen[:, keep]
+
+ return np.vstack((left_screen.T, right_screen[:, ::-1].T)).astype(np.float32)
+
+ @staticmethod
+ def _hsla_to_color(h, s, l, a):
+ rgb = colorsys.hls_to_rgb(h, l, s)
+ return rl.Color(
+ int(rgb[0] * 255),
+ int(rgb[1] * 255),
+ int(rgb[2] * 255),
+ int(a * 255)
+ )
+
+ @staticmethod
+ def _blend_colors(begin_colors, end_colors, t):
+ if t >= 1.0:
+ return end_colors
+ if t <= 0.0:
+ return begin_colors
+
+ inv_t = 1.0 - t
+ return [rl.Color(
+ int(inv_t * start.r + t * end.r),
+ int(inv_t * start.g + t * end.g),
+ int(inv_t * start.b + t * end.b),
+ int(inv_t * start.a + t * end.a)
+ ) for start, end in zip(begin_colors, end_colors, strict=True)]
diff --git a/selfdrive/ui/mici/onroad/torque_bar.py b/selfdrive/ui/mici/onroad/torque_bar.py
new file mode 100644
index 0000000000..1f6dffe879
--- /dev/null
+++ b/selfdrive/ui/mici/onroad/torque_bar.py
@@ -0,0 +1,253 @@
+import math
+import time
+from functools import wraps
+from collections import OrderedDict
+
+import numpy as np
+import pyray as rl
+from opendbc.car import ACCELERATION_DUE_TO_GRAVITY
+from openpilot.selfdrive.ui.mici.onroad import blend_colors
+from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.lib.shader_polygon import draw_polygon, Gradient
+from openpilot.system.ui.widgets import Widget
+from openpilot.common.filter_simple import FirstOrderFilter
+
+# TODO: arc_bar_pts doesn't consider rounded end caps part of the angle span
+TORQUE_ANGLE_SPAN = 12.7
+
+DEBUG = False
+
+
+def quantized_lru_cache(maxsize=128):
+ def decorator(func):
+ cache = OrderedDict()
+ @wraps(func)
+ def wrapper(cx, cy, r_mid, thickness, a0_deg, a1_deg, **kwargs):
+ # Quantize inputs: balanced for smoothness vs cache effectiveness
+ key = (round(cx), round(cy), round(r_mid),
+ round(thickness), # 1px precision for smoother height transitions
+ round(a0_deg * 10) / 10, # 0.1° precision for smoother angle transitions
+ round(a1_deg * 10) / 10,
+ tuple(sorted(kwargs.items())))
+
+ if key in cache:
+ cache.move_to_end(key)
+ else:
+ if len(cache) >= maxsize:
+ cache.popitem(last=False)
+
+ result = func(cx, cy, r_mid, thickness, a0_deg, a1_deg, **kwargs)
+ cache[key] = result
+ return cache[key]
+ return wrapper
+ return decorator
+
+
+@quantized_lru_cache(maxsize=256)
+def arc_bar_pts(cx: float, cy: float,
+ r_mid: float, thickness: float,
+ a0_deg: float, a1_deg: float,
+ *, max_points: int = 100, cap_segs: int = 10,
+ cap_radius: float = 7, px_per_seg: float = 2.0) -> np.ndarray:
+ """Return Nx2 np.float32 points for a single closed polygon (rounded thick arc)."""
+
+ def get_cap(left: bool, a_deg: float):
+ # end cap at a1: center (a1), sweep a1→a1+180 (skip endpoints to avoid dupes)
+ # quarter arc (outer corner) at a1 with fixed pixel radius cap_radius
+
+ nx, ny = math.cos(math.radians(a_deg)), math.sin(math.radians(a_deg)) # outward normal
+ tx, ty = -ny, nx # tangent (CCW)
+
+ mx, my = cx + nx * r_mid, cy + ny * r_mid # mid-point at a1
+ if DEBUG:
+ rl.draw_circle(int(mx), int(my), 4, rl.PURPLE)
+
+ ex = mx + nx * (half - cap_radius)
+ ey = my + ny * (half - cap_radius)
+
+ if DEBUG:
+ rl.draw_circle(int(ex), int(ey), 2, rl.WHITE)
+
+ # sweep 90° in the local (t,n) frame: from outer edge toward inside
+ if not left:
+ alpha = np.deg2rad(np.linspace(90, 0, cap_segs + 2))[1:-1]
+ else:
+ alpha = np.deg2rad(np.linspace(180, 90, cap_segs + 2))[1:-1]
+ cap_end = np.c_[ex + np.cos(alpha) * cap_radius * tx + np.sin(alpha) * cap_radius * nx,
+ ey + np.cos(alpha) * cap_radius * ty + np.sin(alpha) * cap_radius * ny]
+
+ # bottom quarter (inner corner) at a1
+ ex2 = mx + nx * (-half + cap_radius)
+ ey2 = my + ny * (-half + cap_radius)
+ if DEBUG:
+ rl.draw_circle(int(ex2), int(ey2), 2, rl.WHITE)
+
+ if not left:
+ alpha2 = np.deg2rad(np.linspace(0, -90, cap_segs + 1))[:-1] # include 0 once, exclude -90
+ else:
+ alpha2 = np.deg2rad(np.linspace(90 - 90 - 90, 0 - 90 - 90, cap_segs + 1))[:-1]
+ cap_end_bot = np.c_[ex2 + np.cos(alpha2) * cap_radius * tx + np.sin(alpha2) * cap_radius * nx,
+ ey2 + np.cos(alpha2) * cap_radius * ty + np.sin(alpha2) * cap_radius * ny]
+
+ # append to the top quarter
+ if not left:
+ cap_end = np.vstack((cap_end, cap_end_bot))
+ else:
+ cap_end = np.vstack((cap_end_bot, cap_end))
+
+ return cap_end
+
+ if a1_deg < a0_deg:
+ a0_deg, a1_deg = a1_deg, a0_deg
+ half = thickness * 0.5
+
+ cap_radius = min(cap_radius, half)
+
+ span = max(1e-3, a1_deg - a0_deg)
+
+ # pick arc segment count from arc length, clamp to shader points[] budget
+ arc_len = r_mid * math.radians(span)
+ arc_segs = max(6, int(arc_len / px_per_seg))
+ max_arc = (max_points - (4 * cap_segs + 3)) // 2
+ arc_segs = max(6, min(arc_segs, max_arc))
+
+ # outer arc a0→a1
+ ang_o = np.deg2rad(np.linspace(a0_deg, a1_deg, arc_segs + 1))
+ outer = np.c_[cx + np.cos(ang_o) * (r_mid + half),
+ cy + np.sin(ang_o) * (r_mid + half)]
+
+ # end cap at a1
+ cap_end = get_cap(False, a1_deg)
+
+ # inner arc a1→a0
+ ang_i = np.deg2rad(np.linspace(a1_deg, a0_deg, arc_segs + 1))
+ inner = np.c_[cx + np.cos(ang_i) * (r_mid - half),
+ cy + np.sin(ang_i) * (r_mid - half)]
+
+ # start cap at a0
+ cap_start = get_cap(True, a0_deg)
+
+ pts = np.vstack((outer, cap_end, inner, cap_start, outer[:1])).astype(np.float32)
+
+ if DEBUG:
+ n = len(pts)
+ idx = int(time.monotonic() * 12) % max(1, n) # speed: 12 pts/sec
+ for i, (x, y) in enumerate(pts):
+ j = (i - idx) % n # rotate the gradient
+ t = j / n
+ color = rl.Color(255, int(255 * (1 - t)), int(255 * t), 255)
+ rl.draw_circle(int(x), int(y), 2, color)
+
+ return pts
+
+
+class TorqueBar(Widget):
+ def __init__(self, demo: bool = False):
+ super().__init__()
+ self._demo = demo
+ self._torque_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
+ self._torque_line_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
+
+ def update_filter(self, value: float):
+ """Update the torque filter value (for demo mode)."""
+ self._torque_filter.update(value)
+
+ def _update_state(self):
+ if self._demo:
+ return
+
+ # torque line
+ if ui_state.sm['controlsState'].lateralControlState.which() == 'angleState':
+ controls_state = ui_state.sm['controlsState']
+ car_state = ui_state.sm['carState']
+ live_parameters = ui_state.sm['liveParameters']
+ lateral_acceleration = controls_state.curvature * car_state.vEgo ** 2 - live_parameters.roll * ACCELERATION_DUE_TO_GRAVITY
+ # TODO: pull from carparams
+ max_lateral_acceleration = 3
+
+ # from selfdrived
+ actual_lateral_accel = controls_state.curvature * car_state.vEgo ** 2
+ desired_lateral_accel = controls_state.desiredCurvature * car_state.vEgo ** 2
+ accel_diff = (desired_lateral_accel - actual_lateral_accel)
+
+ self._torque_filter.update(min(max(lateral_acceleration / max_lateral_acceleration + accel_diff, -1), 1))
+ else:
+ self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque)
+
+ def _render(self, rect: rl.Rectangle) -> None:
+ # adjust y pos with torque
+ torque_line_offset = np.interp(abs(self._torque_filter.x), [0.5, 1], [22, 26])
+ torque_line_height = np.interp(abs(self._torque_filter.x), [0.5, 1], [14, 56])
+
+ # animate alpha and angle span
+ if not self._demo:
+ self._torque_line_alpha_filter.update(ui_state.status != UIStatus.DISENGAGED)
+ else:
+ self._torque_line_alpha_filter.update(1.0)
+
+ torque_line_bg_alpha = np.interp(abs(self._torque_filter.x), [0.5, 1.0], [0.25, 0.5])
+ torque_line_bg_color = rl.Color(255, 255, 255, int(255 * torque_line_bg_alpha * self._torque_line_alpha_filter.x))
+ if ui_state.status != UIStatus.ENGAGED and not self._demo:
+ torque_line_bg_color = rl.Color(255, 255, 255, int(255 * 0.15 * self._torque_line_alpha_filter.x))
+
+ # draw curved line polygon torque bar
+ torque_line_radius = 1200
+ top_angle = -90
+ torque_bg_angle_span = self._torque_line_alpha_filter.x * TORQUE_ANGLE_SPAN
+ torque_start_angle = top_angle - torque_bg_angle_span / 2
+ torque_end_angle = top_angle + torque_bg_angle_span / 2
+ # centerline radius & center (you already have these values)
+ mid_r = torque_line_radius + torque_line_height / 2
+
+ cx = rect.x + rect.width / 2 + 8 # offset 8px to right of camera feed
+ cy = rect.y + rect.height + torque_line_radius - torque_line_offset
+
+ # draw bg torque indicator line
+ bg_pts = arc_bar_pts(cx, cy, mid_r, torque_line_height, torque_start_angle, torque_end_angle)
+ draw_polygon(rect, bg_pts, color=torque_line_bg_color)
+
+ # draw torque indicator line
+ a0s = top_angle
+ a1s = a0s + torque_bg_angle_span / 2 * self._torque_filter.x
+ sl_pts = arc_bar_pts(cx, cy, mid_r, torque_line_height, a0s, a1s)
+
+ # draw beautiful gradient from center to 65% of the bg torque bar width
+ start_grad_pt = cx / rect.width
+ if self._torque_filter.x < 0:
+ end_grad_pt = (cx * (1 - 0.65) + (min(bg_pts[:, 0]) * 0.65)) / rect.width
+ else:
+ end_grad_pt = (cx * (1 - 0.65) + (max(bg_pts[:, 0]) * 0.65)) / rect.width
+
+ # fade to orange as we approach max torque
+ start_color = blend_colors(
+ rl.Color(255, 255, 255, int(255 * 0.9 * self._torque_line_alpha_filter.x)),
+ rl.Color(255, 200, 0, int(255 * self._torque_line_alpha_filter.x)), # yellow
+ max(0, abs(self._torque_filter.x) - 0.75) * 4,
+ )
+ end_color = blend_colors(
+ rl.Color(255, 255, 255, int(255 * 0.9 * self._torque_line_alpha_filter.x)),
+ rl.Color(255, 115, 0, int(255 * self._torque_line_alpha_filter.x)), # orange
+ max(0, abs(self._torque_filter.x) - 0.75) * 4,
+ )
+
+ if ui_state.status != UIStatus.ENGAGED and not self._demo:
+ start_color = end_color = rl.Color(255, 255, 255, int(255 * 0.35 * self._torque_line_alpha_filter.x))
+
+ gradient = Gradient(
+ start=(start_grad_pt, 0),
+ end=(end_grad_pt, 0),
+ colors=[
+ start_color,
+ end_color,
+ ],
+ stops=[0.0, 1.0],
+ )
+
+ draw_polygon(rect, sl_pts, gradient=gradient)
+
+ # draw center torque bar dot
+ if abs(self._torque_filter.x) < 0.5:
+ dot_y = self._rect.y + self._rect.height - torque_line_offset - torque_line_height / 2
+ rl.draw_circle(int(cx), int(dot_y), 10 // 2,
+ rl.Color(182, 182, 182, int(255 * 0.9 * self._torque_line_alpha_filter.x)))
diff --git a/selfdrive/ui/mici/widgets/button.py b/selfdrive/ui/mici/widgets/button.py
new file mode 100644
index 0000000000..be08e0fee3
--- /dev/null
+++ b/selfdrive/ui/mici/widgets/button.py
@@ -0,0 +1,375 @@
+import pyray as rl
+from typing import Union
+from enum import Enum
+from collections.abc import Callable
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.label import MiciLabel
+from openpilot.system.ui.widgets.scroller import DO_ZOOM
+from openpilot.system.ui.lib.text_measure import measure_text_cached
+from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
+from openpilot.common.filter_simple import BounceFilter
+
+try:
+ from openpilot.common.params import Params
+except ImportError:
+ Params = None
+
+SCROLLING_SPEED_PX_S = 50
+COMPLICATION_SIZE = 36
+LABEL_COLOR = rl.WHITE
+LABEL_HORIZONTAL_PADDING = 40
+COMPLICATION_GREY = rl.Color(0xAA, 0xAA, 0xAA, 255)
+PRESSED_SCALE = 1.15 if DO_ZOOM else 1.07
+
+
+class ScrollState(Enum):
+ PRE_SCROLL = 0
+ SCROLLING = 1
+ POST_SCROLL = 2
+
+
+class BigCircleButton(Widget):
+ def __init__(self, icon: str, red: bool = False):
+ super().__init__()
+ self._red = red
+
+ # State
+ self.set_rect(rl.Rectangle(0, 0, 180, 180))
+ self._press_state_enabled = True
+ self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
+
+ # Icons
+ self._txt_icon = gui_app.texture(icon, 64, 53)
+ self._txt_btn_disabled_bg = gui_app.texture("icons_mici/buttons/button_circle_disabled.png", 180, 180)
+
+ self._txt_btn_bg = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180)
+ self._txt_btn_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_hover.png", 180, 180)
+
+ self._txt_btn_red_bg = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180)
+ self._txt_btn_red_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_red_hover.png", 180, 180)
+
+ def set_enable_pressed_state(self, pressed: bool):
+ self._press_state_enabled = pressed
+
+ def _render(self, _):
+ # draw background
+ txt_bg = self._txt_btn_bg if not self._red else self._txt_btn_red_bg
+ if not self.enabled:
+ txt_bg = self._txt_btn_disabled_bg
+ elif self.is_pressed and self._press_state_enabled:
+ txt_bg = self._txt_btn_pressed_bg if not self._red else self._txt_btn_red_pressed_bg
+
+ scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed and self._press_state_enabled else 1.0)
+ btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2
+ btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2
+ rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE)
+
+ # draw icon
+ icon_color = rl.WHITE if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
+ rl.draw_texture(self._txt_icon, int(self._rect.x + (self._rect.width - self._txt_icon.width) / 2),
+ int(self._rect.y + (self._rect.height - self._txt_icon.height) / 2), icon_color)
+
+
+class BigCircleToggle(BigCircleButton):
+ def __init__(self, icon: str, toggle_callback: Callable = None):
+ super().__init__(icon, False)
+ self._toggle_callback = toggle_callback
+
+ # State
+ self._checked = False
+
+ # Icons
+ self._txt_toggle_enabled = gui_app.texture("icons_mici/buttons/toggle_dot_enabled.png", 66, 66)
+ self._txt_toggle_disabled = gui_app.texture("icons_mici/buttons/toggle_dot_disabled.png", 70, 70) # TODO: why discrepancy?
+
+ def set_checked(self, checked: bool):
+ self._checked = checked
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+
+ self._checked = not self._checked
+ if self._toggle_callback:
+ self._toggle_callback(self._checked)
+
+ def _render(self, _):
+ super()._render(_)
+
+ # draw status icon
+ rl.draw_texture(self._txt_toggle_enabled if self._checked else self._txt_toggle_disabled,
+ int(self._rect.x + (self._rect.width - self._txt_toggle_enabled.width) / 2),
+ int(self._rect.y + 5), rl.WHITE)
+
+
+class BigButton(Widget):
+ """A lightweight stand-in for the Qt BigButton, drawn & updated each frame."""
+
+ def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = ""):
+ super().__init__()
+ self.set_rect(rl.Rectangle(0, 0, 402, 180))
+ self.text = text
+ self.value = value
+ self.set_icon(icon)
+
+ self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
+
+ self._rotate_icon_t: float | None = None
+
+ self._label_font = gui_app.font(FontWeight.DISPLAY)
+ self._value_font = gui_app.font(FontWeight.ROMAN)
+
+ self._label = MiciLabel(text, font_size=self._get_label_font_size(), width=int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2),
+ font_weight=FontWeight.DISPLAY, color=LABEL_COLOR,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, wrap_text=True)
+ self._sub_label = MiciLabel(value, font_size=COMPLICATION_SIZE, width=int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2),
+ font_weight=FontWeight.ROMAN, color=COMPLICATION_GREY,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, wrap_text=True)
+
+ self._load_images()
+
+ # internal state
+ self._scroll_offset = 0 # in pixels
+ self._needs_scroll = measure_text_cached(self._label_font, text, self._get_label_font_size()).x + 25 > self._rect.width
+ self._scroll_timer = 0
+ self._scroll_state = ScrollState.PRE_SCROLL
+
+ def set_icon(self, icon: Union[str, rl.Texture]):
+ self._txt_icon = gui_app.texture(icon, 64, 64) if isinstance(icon, str) and len(icon) else icon
+
+ def set_rotate_icon(self, rotate: bool):
+ if rotate and self._rotate_icon_t is not None:
+ return
+ self._rotate_icon_t = rl.get_time() if rotate else None
+
+ def _load_images(self):
+ self._txt_default_bg = gui_app.texture("icons_mici/buttons/button_rectangle.png", 402, 180)
+ self._txt_pressed_bg = gui_app.texture("icons_mici/buttons/button_rectangle_pressed.png", 402, 180)
+ self._txt_disabled_bg = gui_app.texture("icons_mici/buttons/button_rectangle_disabled.png", 402, 180)
+ self._txt_hover_bg = gui_app.texture("icons_mici/buttons/button_rectangle_hover.png", 402, 180)
+
+ def _get_label_font_size(self):
+ if len(self.text) < 12:
+ font_size = 64
+ elif len(self.text) < 17:
+ font_size = 48
+ elif len(self.text) < 20:
+ font_size = 42
+ else:
+ font_size = 36
+
+ if self.value:
+ font_size -= 20
+
+ return font_size
+
+ def set_text(self, text: str):
+ self.text = text
+ self._label.set_text(text)
+
+ def set_value(self, value: str):
+ self.value = value
+ self._sub_label.set_text(value)
+
+ def get_value(self) -> str:
+ return self.value
+
+ def get_text(self):
+ return self.text
+
+ def _update_state(self):
+ # hold on text for a bit, scroll, hold again, reset
+ if self._needs_scroll:
+ """`dt` should be seconds since last frame (rl.get_frame_time())."""
+ # TODO: this comment is generated by GPT, prob wrong and misused
+ dt = rl.get_frame_time()
+
+ self._scroll_timer += dt
+ if self._scroll_state == ScrollState.PRE_SCROLL:
+ if self._scroll_timer < 0.5:
+ return
+ self._scroll_state = ScrollState.SCROLLING
+ self._scroll_timer = 0
+
+ elif self._scroll_state == ScrollState.SCROLLING:
+ self._scroll_offset -= SCROLLING_SPEED_PX_S * dt
+ # reset when text has completely left the button + 50 px gap
+ # TODO: use global constant for 30+30 px gap
+ # TODO: add std Widget padding option integrated into the self._rect
+ full_len = measure_text_cached(self._label_font, self.text, self._get_label_font_size()).x + 30 + 30
+ if self._scroll_offset < (self._rect.width - full_len):
+ self._scroll_state = ScrollState.POST_SCROLL
+ self._scroll_timer = 0
+
+ elif self._scroll_state == ScrollState.POST_SCROLL:
+ # wait for a bit before starting to scroll again
+ if self._scroll_timer < 0.75:
+ return
+ self._scroll_state = ScrollState.PRE_SCROLL
+ self._scroll_timer = 0
+ self._scroll_offset = 0
+
+ def _render(self, _):
+ # draw _txt_default_bg
+ txt_bg = self._txt_default_bg
+ if not self.enabled:
+ txt_bg = self._txt_disabled_bg
+ elif self.is_pressed:
+ txt_bg = self._txt_hover_bg
+
+ scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed else 1.0)
+ btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2
+ btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2
+ rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE)
+
+ # LABEL ------------------------------------------------------------------
+ lx = self._rect.x + LABEL_HORIZONTAL_PADDING
+ ly = btn_y + self._rect.height - 33 # - 40# - self._get_label_font_size() / 2
+
+ if self.value:
+ self._sub_label.set_position(lx, ly)
+ ly -= self._sub_label.font_size + 9
+ self._sub_label.render()
+
+ label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
+ self._label.set_color(label_color)
+ self._label.set_position(lx, ly)
+ self._label.render()
+
+ # ICON -------------------------------------------------------------------
+ if self._txt_icon:
+ rotation = 0
+ if self._rotate_icon_t is not None:
+ rotation = (rl.get_time() - self._rotate_icon_t) * 180
+
+ # drop top right with 30px padding
+ x = self._rect.x + self._rect.width - 30 - self._txt_icon.width / 2
+ y = self._rect.y + 30 + self._txt_icon.height / 2
+ source_rec = rl.Rectangle(0, 0, self._txt_icon.width, self._txt_icon.height)
+ dest_rec = rl.Rectangle(int(x), int(y), self._txt_icon.width, self._txt_icon.height)
+ origin = rl.Vector2(self._txt_icon.width / 2, self._txt_icon.height / 2)
+ rl.draw_texture_pro(self._txt_icon, source_rec, dest_rec, origin, rotation, rl.WHITE)
+
+
+class BigToggle(BigButton):
+ def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable = None):
+ super().__init__(text, value, "")
+ self._checked = initial_state
+ self._toggle_callback = toggle_callback
+
+ self._label.set_font_size(48)
+
+ def _load_images(self):
+ super()._load_images()
+ self._txt_enabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_enabled.png", 84, 66)
+ self._txt_disabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_disabled.png", 84, 66)
+
+ def set_checked(self, checked: bool):
+ self._checked = checked
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+ self._checked = not self._checked
+ if self._toggle_callback:
+ self._toggle_callback(self._checked)
+
+ def _draw_pill(self, x: float, y: float, checked: bool):
+ # draw toggle icon top right
+ if checked:
+ rl.draw_texture(self._txt_enabled_toggle, int(x), int(y), rl.WHITE)
+ else:
+ rl.draw_texture(self._txt_disabled_toggle, int(x), int(y), rl.WHITE)
+
+ def _render(self, _):
+ super()._render(_)
+
+ x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width
+ y = self._rect.y
+ self._draw_pill(x, y, self._checked)
+
+
+class BigMultiToggle(BigToggle):
+ def __init__(self, text: str, options: list[str], toggle_callback: Callable = None,
+ select_callback: Callable = None):
+ super().__init__(text, "", toggle_callback=toggle_callback)
+ assert len(options) > 0
+ self._options = options
+ self._select_callback = select_callback
+
+ self._label.set_width(int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width))
+ # TODO: why isn't this automatic?
+ self._label.set_font_size(self._get_label_font_size())
+
+ self.set_value(self._options[0])
+
+ def _get_label_font_size(self):
+ font_size = super()._get_label_font_size()
+ return font_size - 6
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+ cur_idx = self._options.index(self.value)
+ new_idx = (cur_idx + 1) % len(self._options)
+ self.set_value(self._options[new_idx])
+ if self._select_callback:
+ self._select_callback(self.value)
+
+ def _render(self, _):
+ BigButton._render(self, _)
+
+ checked_idx = self._options.index(self.value)
+
+ x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width
+ y = self._rect.y
+
+ for i in range(len(self._options)):
+ self._draw_pill(x, y, checked_idx == i)
+ y += 35
+
+
+class BigMultiParamToggle(BigMultiToggle):
+ def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable = None,
+ select_callback: Callable = None):
+ super().__init__(text, options, toggle_callback, select_callback)
+ self._param = param
+
+ self._params = Params()
+ self._load_value()
+
+ def _load_value(self):
+ self.set_value(self._options[self._params.get(self._param) or 0])
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+ new_idx = self._options.index(self.value)
+ self._params.put_nonblocking(self._param, new_idx)
+
+
+class BigParamControl(BigToggle):
+ def __init__(self, text: str, param: str, toggle_callback: Callable = None):
+ super().__init__(text, "", toggle_callback=toggle_callback)
+ self.param = param
+ self.params = Params()
+ self.set_checked(self.params.get_bool(self.param, False))
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+ self.params.put_bool(self.param, self._checked)
+
+ def refresh(self):
+ self.set_checked(self.params.get_bool(self.param, False))
+
+
+# TODO: param control base class
+class BigCircleParamControl(BigCircleToggle):
+ def __init__(self, icon: str, param: str, toggle_callback: Callable = None):
+ super().__init__(icon, toggle_callback)
+ self._param = param
+ self.params = Params()
+ self.set_checked(self.params.get_bool(self._param, False))
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+ self.params.put_bool(self._param, self._checked)
+
+ def refresh(self):
+ self.set_checked(self.params.get_bool(self._param, False))
diff --git a/selfdrive/ui/mici/widgets/dialog.py b/selfdrive/ui/mici/widgets/dialog.py
new file mode 100644
index 0000000000..d64ab65ef2
--- /dev/null
+++ b/selfdrive/ui/mici/widgets/dialog.py
@@ -0,0 +1,395 @@
+import abc
+import math
+import pyray as rl
+from typing import Union
+from collections.abc import Callable
+from typing import cast
+from openpilot.selfdrive.ui.mici.widgets.side_button import SideButton
+from openpilot.system.ui.widgets import Widget, NavWidget, DialogResult
+from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label
+from openpilot.system.ui.widgets.mici_keyboard import MiciKeyboard
+from openpilot.system.ui.lib.text_measure import measure_text_cached
+from openpilot.system.ui.lib.wrap_text import wrap_text
+from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
+from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.slider import RedBigSlider, BigSlider
+from openpilot.common.filter_simple import FirstOrderFilter
+from openpilot.selfdrive.ui.mici.widgets.button import BigButton
+
+DEBUG = False
+
+PADDING = 20
+
+
+class BigDialogBase(NavWidget, abc.ABC):
+ def __init__(self, right_btn: str | None = None, right_btn_callback: Callable | None = None):
+ super().__init__()
+ self._ret = DialogResult.NO_ACTION
+ self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+ self.set_back_callback(lambda: setattr(self, '_ret', DialogResult.CANCEL))
+
+ self._right_btn = None
+ if right_btn:
+ def right_btn_callback_wrapper():
+ gui_app.set_modal_overlay(None)
+ if right_btn_callback:
+ right_btn_callback()
+
+ self._right_btn = SideButton(right_btn)
+ self._right_btn.set_click_callback(right_btn_callback_wrapper)
+ # move to right side
+ self._right_btn._rect.x = self._rect.x + self._rect.width - self._right_btn._rect.width
+
+ def _render(self, _) -> DialogResult:
+ """
+ Allows `gui_app.set_modal_overlay(BigDialog(...))`.
+ The overlay runner keeps calling until result != NO_ACTION.
+ """
+ if self._right_btn:
+ self._right_btn.set_position(self._right_btn._rect.x, self._rect.y)
+ self._right_btn.render()
+
+ return self._ret
+
+
+class BigDialog(BigDialogBase):
+ def __init__(self,
+ title: str,
+ description: str,
+ right_btn: str | None = None,
+ right_btn_callback: Callable | None = None):
+ super().__init__(right_btn, right_btn_callback)
+ self._title = title
+ self._description = description
+
+ def _render(self, _) -> DialogResult:
+ super()._render(_)
+
+ # draw title
+ # TODO: we desperately need layouts
+ # TODO: coming up with these numbers manually is a pain and not scalable
+ # TODO: no clue what any of these numbers mean. VBox and HBox would remove all of this shite
+ max_width = self._rect.width - PADDING * 2
+ if self._right_btn:
+ max_width -= self._right_btn._rect.width
+
+ title_wrapped = '\n'.join(wrap_text(gui_app.font(FontWeight.BOLD), self._title, 50, int(max_width)))
+ title_size = measure_text_cached(gui_app.font(FontWeight.BOLD), title_wrapped, 50)
+ text_x_offset = 0
+ title_rect = rl.Rectangle(int(self._rect.x + text_x_offset + PADDING),
+ int(self._rect.y + PADDING),
+ int(max_width),
+ int(title_size.y))
+ gui_label(title_rect, title_wrapped, 50, font_weight=FontWeight.BOLD,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
+
+ # draw description
+ desc_wrapped = '\n'.join(wrap_text(gui_app.font(FontWeight.MEDIUM), self._description, 30, int(max_width)))
+ desc_size = measure_text_cached(gui_app.font(FontWeight.MEDIUM), desc_wrapped, 30)
+ desc_rect = rl.Rectangle(int(self._rect.x + text_x_offset + PADDING),
+ int(self._rect.y + self._rect.height / 3),
+ int(max_width),
+ int(desc_size.y))
+ # TODO: text align doesn't seem to work properly with newlines
+ gui_label(desc_rect, desc_wrapped, 30, font_weight=FontWeight.MEDIUM,
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
+
+ return self._ret
+
+
+class BigConfirmationDialogV2(BigDialogBase):
+ def __init__(self, title: str, icon: str, red: bool = False,
+ exit_on_confirm: bool = True,
+ confirm_callback: Callable | None = None):
+ super().__init__()
+ self._confirm_callback = confirm_callback
+ self._exit_on_confirm = exit_on_confirm
+
+ icon_txt = gui_app.texture(icon, 64, 53)
+ self._slider: BigSlider | RedBigSlider
+ if red:
+ self._slider = RedBigSlider(title, icon_txt, confirm_callback=self._on_confirm)
+ else:
+ self._slider = BigSlider(title, icon_txt, confirm_callback=self._on_confirm)
+ self._slider.set_enabled(lambda: not self._swiping_away)
+
+ def _on_confirm(self):
+ if self._confirm_callback:
+ self._confirm_callback()
+ if self._exit_on_confirm:
+ self._ret = DialogResult.CONFIRM
+
+ def _update_state(self):
+ super()._update_state()
+ if self._swiping_away and not self._slider.confirmed:
+ self._slider.reset()
+
+ def _render(self, _) -> DialogResult:
+ self._slider.render(self._rect)
+ return self._ret
+
+
+class BigInputDialog(BigDialogBase):
+ BACK_TOUCH_AREA_PERCENTAGE = 0.2
+ BACKSPACE_RATE = 25 # hz
+
+ def __init__(self,
+ hint: str,
+ default_text: str = "",
+ minimum_length: int = 1,
+ confirm_callback: Callable[[str], None] = None):
+ super().__init__(None, None)
+ self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)),
+ font_weight=FontWeight.MEDIUM)
+ self._keyboard = MiciKeyboard()
+ self._keyboard.set_text(default_text)
+ self._minimum_length = minimum_length
+
+ self._backspace_held_time: float | None = None
+
+ self._backspace_img = gui_app.texture("icons_mici/settings/keyboard/backspace.png", 44, 44)
+ self._backspace_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
+
+ self._enter_img = gui_app.texture("icons_mici/settings/keyboard/confirm.png", 44, 44)
+ self._enter_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
+
+ # rects for top buttons
+ self._top_left_button_rect = rl.Rectangle(0, 0, 0, 0)
+ self._top_right_button_rect = rl.Rectangle(0, 0, 0, 0)
+
+ def confirm_callback_wrapper():
+ self._ret = DialogResult.CONFIRM
+ if confirm_callback:
+ confirm_callback(self._keyboard.text())
+ self._confirm_callback = confirm_callback_wrapper
+
+ def _update_state(self):
+ super()._update_state()
+
+ last_mouse_event = gui_app.last_mouse_event
+ if last_mouse_event.left_down and rl.check_collision_point_rec(last_mouse_event.pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 1:
+ if self._backspace_held_time is None:
+ self._backspace_held_time = rl.get_time()
+
+ if rl.get_time() - self._backspace_held_time > 0.5:
+ if gui_app.frame % round(gui_app.target_fps / self.BACKSPACE_RATE) == 0:
+ self._keyboard.backspace()
+
+ else:
+ self._backspace_held_time = None
+
+ def _render(self, _):
+ text_input_size = 35
+
+ # draw current text so far below everything. text floats left but always stays in view
+ text = self._keyboard.text()
+ candidate_char = self._keyboard.get_candidate_character()
+ text_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), text + candidate_char or self._hint_label.text, text_input_size)
+ text_x = PADDING * 2 + self._enter_img.width
+
+ # text needs to move left if we're at the end where right button is
+ text_rect = rl.Rectangle(text_x,
+ int(self._rect.y + PADDING),
+ # clip width to right button when in view
+ int(self._rect.width - text_x - PADDING * 2 - self._enter_img.width + 5), # TODO: why 5?
+ int(text_size.y))
+
+ # draw rounded background for text input
+ bg_block_margin = 5
+ text_field_rect = rl.Rectangle(text_rect.x - bg_block_margin, text_rect.y - bg_block_margin,
+ text_rect.width + bg_block_margin * 2, text_input_size + bg_block_margin * 2)
+
+ # draw text input
+ # push text left with a gradient on left side if too long
+ if text_size.x > text_rect.width:
+ text_x -= text_size.x - text_rect.width
+
+ rl.begin_scissor_mode(int(text_rect.x), int(text_rect.y), int(text_rect.width), int(text_rect.height))
+ rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), text, rl.Vector2(text_x, text_rect.y), text_input_size, 0, rl.WHITE)
+
+ # draw grayed out character user is hovering over
+ if candidate_char:
+ candidate_char_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), candidate_char, text_input_size)
+ rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), candidate_char,
+ rl.Vector2(min(text_x + text_size.x, text_rect.x + text_rect.width) - candidate_char_size.x, text_rect.y),
+ text_input_size, 0, rl.Color(255, 255, 255, 128))
+
+ rl.end_scissor_mode()
+
+ # draw gradient on left side to indicate more text
+ if text_size.x > text_rect.width:
+ rl.draw_rectangle_gradient_h(int(text_rect.x), int(text_rect.y), 80, int(text_rect.height),
+ rl.BLACK, rl.BLANK)
+
+ # draw cursor
+ if text:
+ blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2
+ cursor_x = min(text_x + text_size.x + 3, text_rect.x + text_rect.width)
+ rl.draw_rectangle_rounded(rl.Rectangle(int(cursor_x), int(text_rect.y), 4, int(text_size.y)),
+ 1, 4, rl.Color(255, 255, 255, int(255 * blink_alpha)))
+
+ # draw backspace icon with nice fade
+ self._backspace_img_alpha.update(255 * bool(text))
+ if self._backspace_img_alpha.x > 1:
+ color = rl.Color(255, 255, 255, int(self._backspace_img_alpha.x))
+ rl.draw_texture(self._backspace_img, int(self._rect.width - self._enter_img.width - 15), int(text_field_rect.y), color)
+
+ if not text and self._hint_label.text and not candidate_char:
+ # draw description if no text entered yet and not drawing candidate char
+ self._hint_label.render(text_field_rect)
+
+ # TODO: move to update state
+ # make rect take up entire area so it's easier to click
+ self._top_left_button_rect = rl.Rectangle(self._rect.x, self._rect.y, text_field_rect.x, self._rect.height - self._keyboard.get_keyboard_height())
+ self._top_right_button_rect = rl.Rectangle(text_field_rect.x + text_field_rect.width, self._rect.y,
+ self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height)
+
+ self._enter_img_alpha.update(255 if (len(text) >= self._minimum_length) else 255 * 0.35)
+ if self._enter_img_alpha.x > 1:
+ color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x))
+ rl.draw_texture(self._enter_img, int(self._rect.x + 15), int(text_field_rect.y), color)
+
+ # keyboard goes over everything
+ self._keyboard.render(self._rect)
+
+ # draw debugging rect bounds
+ if DEBUG:
+ rl.draw_rectangle_lines_ex(text_field_rect, 1, rl.Color(100, 100, 100, 255))
+ rl.draw_rectangle_lines_ex(text_rect, 1, rl.Color(0, 255, 0, 255))
+ rl.draw_rectangle_lines_ex(self._top_right_button_rect, 1, rl.Color(0, 255, 0, 255))
+ rl.draw_rectangle_lines_ex(self._top_left_button_rect, 1, rl.Color(0, 255, 0, 255))
+
+ return self._ret
+
+ def _handle_mouse_press(self, mouse_pos: MousePos):
+ super()._handle_mouse_press(mouse_pos)
+ # TODO: need to track where press was so enter and back can activate on release rather than press
+ # or turn into icon widgets :eyes_open:
+ # handle backspace icon click
+ if rl.check_collision_point_rec(mouse_pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 254:
+ self._keyboard.backspace()
+ elif rl.check_collision_point_rec(mouse_pos, self._top_left_button_rect) and self._enter_img_alpha.x > 254:
+ # handle enter icon click
+ self._confirm_callback()
+
+
+class BigDialogOptionButton(Widget):
+ def __init__(self, option: str):
+ super().__init__()
+ self.option = option
+ self.set_rect(rl.Rectangle(0, 0, int(gui_app.width / 2 + 220), 64))
+
+ self._selected = False
+
+ self._label = UnifiedLabel(option, font_size=70, text_color=rl.Color(255, 255, 255, int(255 * 0.58)),
+ font_weight=FontWeight.DISPLAY_REGULAR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
+
+ def set_selected(self, selected: bool):
+ self._selected = selected
+
+ def _render(self, _):
+ if DEBUG:
+ rl.draw_rectangle_lines_ex(self._rect, 1, rl.Color(0, 255, 0, 255))
+
+ # FIXME: offset x by -45 because scroller centers horizontally
+ if self._selected:
+ self._label.set_font_size(74)
+ self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.9)))
+ self._label.set_font_weight(FontWeight.DISPLAY)
+ else:
+ self._label.set_font_size(70)
+ self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.58)))
+ self._label.set_font_weight(FontWeight.DISPLAY_REGULAR)
+
+ self._label.render(self._rect)
+
+
+class BigMultiOptionDialog(BigDialogBase):
+ BACK_TOUCH_AREA_PERCENTAGE = 0.1
+
+ def __init__(self, options: list[str], default: str | None,
+ right_btn: str | None = 'check', right_btn_callback: Callable[[], None] = None):
+ super().__init__(right_btn, right_btn_callback=right_btn_callback)
+ self._options = options
+ if default is not None:
+ assert default in options
+
+ self._default_option: str = default or (options[0] if len(options) > 0 else "")
+ self._selected_option: str = self._default_option
+ self._last_selected_option: str = self._selected_option
+
+ self._scroller = Scroller([], horizontal=False, pad_start=100, pad_end=100, spacing=0)
+ if self._right_btn is not None:
+ self._scroller.set_enabled(lambda: not cast(Widget, self._right_btn).is_pressed)
+
+ for option in options:
+ self.add_button(BigDialogOptionButton(option))
+
+ def add_button(self, button: BigDialogOptionButton):
+ og_callback = button._click_callback
+
+ def wrapped_callback(btn=button):
+ self._on_option_selected(btn.option)
+ if og_callback:
+ og_callback()
+
+ button.set_click_callback(wrapped_callback)
+ self._scroller.add_widget(button)
+
+ def show_event(self):
+ super().show_event()
+ self._scroller.show_event()
+ self._on_option_selected(self._default_option)
+
+ def get_selected_option(self) -> str:
+ return self._selected_option
+
+ def _on_option_selected(self, option: str):
+ y_pos = 0.0
+ for btn in self._scroller._items:
+ if cast(BigDialogOptionButton, btn).option == option:
+ y_pos = btn.rect.y
+
+ self._scroller.scroll_to(y_pos, smooth=True)
+
+ def _selected_option_changed(self):
+ pass
+
+ def _update_state(self):
+ super()._update_state()
+
+ # get selection by whichever button is closest to center
+ center_y = self._rect.y + self._rect.height / 2
+ closest_btn = (None, float('inf'))
+ for btn in self._scroller._items:
+ dist_y = abs((btn.rect.y + btn.rect.height / 2) - center_y)
+ if dist_y < closest_btn[1]:
+ closest_btn = (btn, dist_y)
+
+ if closest_btn[0]:
+ for btn in self._scroller._items:
+ btn.set_selected(btn.option == closest_btn[0].option)
+ self._selected_option = closest_btn[0].option
+
+ # Signal to subclasses if selection changed
+ if self._selected_option != self._last_selected_option:
+ self._selected_option_changed()
+ self._last_selected_option = self._selected_option
+
+ def _render(self, _):
+ super()._render(_)
+ self._scroller.render(self._rect)
+
+ return self._ret
+
+
+class BigDialogButton(BigButton):
+ def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", description: str = ""):
+ super().__init__(text, value, icon)
+ self._description = description
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ super()._handle_mouse_release(mouse_pos)
+
+ dlg = BigDialog(self.text, self._description)
+ gui_app.set_modal_overlay(dlg)
diff --git a/selfdrive/ui/mici/widgets/pairing_dialog.py b/selfdrive/ui/mici/widgets/pairing_dialog.py
new file mode 100644
index 0000000000..e064205d59
--- /dev/null
+++ b/selfdrive/ui/mici/widgets/pairing_dialog.py
@@ -0,0 +1,116 @@
+import pyray as rl
+import qrcode
+import numpy as np
+import time
+
+from openpilot.common.api import Api
+from openpilot.common.swaglog import cloudlog
+from openpilot.common.params import Params
+from openpilot.selfdrive.ui.ui_state import ui_state
+from openpilot.system.ui.widgets import NavWidget
+from openpilot.system.ui.lib.application import FontWeight, gui_app
+from openpilot.system.ui.widgets.label import MiciLabel
+
+
+class PairingDialog(NavWidget):
+ """Dialog for device pairing with QR code."""
+
+ QR_REFRESH_INTERVAL = 300 # 5 minutes in seconds
+
+ def __init__(self):
+ super().__init__()
+ self.set_back_callback(lambda: gui_app.set_modal_overlay(None))
+ self._params = Params()
+ self._qr_texture: rl.Texture | None = None
+ self._last_qr_generation = float("-inf")
+
+ self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 84, 64)
+ self._pair_label = MiciLabel("pair with comma connect", 48, font_weight=FontWeight.BOLD,
+ color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=40, wrap_text=True)
+
+ def _get_pairing_url(self) -> str:
+ try:
+ dongle_id = self._params.get("DongleId") or ""
+ token = Api(dongle_id).get_token({'pair': True})
+ except Exception as e:
+ cloudlog.warning(f"Failed to get pairing token: {e}")
+ token = ""
+ return f"https://connect.comma.ai/?pair={token}"
+
+ def _generate_qr_code(self) -> None:
+ try:
+ qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=0)
+ qr.add_data(self._get_pairing_url())
+ qr.make(fit=True)
+
+ pil_img = qr.make_image(fill_color="white", back_color="black").convert('RGBA')
+ img_array = np.array(pil_img, dtype=np.uint8)
+
+ if self._qr_texture and self._qr_texture.id != 0:
+ rl.unload_texture(self._qr_texture)
+
+ rl_image = rl.Image()
+ rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data)
+ rl_image.width = pil_img.width
+ rl_image.height = pil_img.height
+ rl_image.mipmaps = 1
+ rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8
+
+ self._qr_texture = rl.load_texture_from_image(rl_image)
+ except Exception as e:
+ cloudlog.warning(f"QR code generation failed: {e}")
+ self._qr_texture = None
+
+ def _check_qr_refresh(self) -> None:
+ current_time = time.monotonic()
+ if current_time - self._last_qr_generation >= self.QR_REFRESH_INTERVAL:
+ self._generate_qr_code()
+ self._last_qr_generation = current_time
+
+ def _update_state(self):
+ super()._update_state()
+ if ui_state.prime_state.is_paired():
+ self._playing_dismiss_animation = True
+
+ def _render(self, rect: rl.Rectangle) -> int:
+ self._check_qr_refresh()
+
+ self._render_qr_code()
+
+ label_x = self._rect.x + 8 + self._rect.height + 24
+ self._pair_label.set_width(int(self._rect.width - label_x))
+ self._pair_label.set_position(label_x, self._rect.y + 16)
+ self._pair_label.render()
+
+ rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16),
+ 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35)))
+
+ return -1
+
+ def _render_qr_code(self) -> None:
+ if not self._qr_texture:
+ error_font = gui_app.font(FontWeight.BOLD)
+ rl.draw_text_ex(
+ error_font, "QR Code Error", rl.Vector2(self._rect.x + 20, self._rect.y + self._rect.height // 2 - 15), 30, 0.0, rl.RED
+ )
+ return
+
+ scale = self._rect.height / self._qr_texture.height
+ pos = rl.Vector2(self._rect.x + 8, self._rect.y)
+ rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE)
+
+ def __del__(self):
+ if self._qr_texture and self._qr_texture.id != 0:
+ rl.unload_texture(self._qr_texture)
+
+
+if __name__ == "__main__":
+ gui_app.init_window("pairing device")
+ pairing = PairingDialog()
+ try:
+ for _ in gui_app.render():
+ result = pairing.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+ if result != -1:
+ break
+ finally:
+ del pairing
diff --git a/selfdrive/ui/mici/widgets/side_button.py b/selfdrive/ui/mici/widgets/side_button.py
new file mode 100644
index 0000000000..4803b6d208
--- /dev/null
+++ b/selfdrive/ui/mici/widgets/side_button.py
@@ -0,0 +1,31 @@
+import pyray as rl
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.lib.application import gui_app
+
+# ---------------------------------------------------------------------------
+# Constants extracted from the original Qt style
+# ---------------------------------------------------------------------------
+# TODO: this should be corrected, but Scroller relies on this being incorrect :/
+WIDTH, HEIGHT = 112, 240
+
+
+class SideButton(Widget):
+ def __init__(self, btn_type: str):
+ super().__init__()
+ self.type = btn_type
+ self.set_rect(rl.Rectangle(0, 0, WIDTH, HEIGHT))
+
+ # load pre-rendered button images
+ if btn_type not in ("check", "back"):
+ btn_type = "back"
+ btn_img_path = f"icons_mici/buttons/button_side_{btn_type}.png"
+ btn_img_pressed_path = f"icons_mici/buttons/button_side_{btn_type}_pressed.png"
+ self._txt_btn, self._txt_btn_back = gui_app.texture(btn_img_path, 100, 224), gui_app.texture(btn_img_pressed_path, 100, 224)
+
+ def _render(self, _) -> bool:
+ x = int(self._rect.x + 12)
+ y = int(self._rect.y + (self._rect.height - self._txt_btn.height) / 2)
+ rl.draw_texture(self._txt_btn if not self.is_pressed else self._txt_btn_back,
+ x, y, rl.WHITE)
+
+ return False
diff --git a/selfdrive/ui/soundd.py b/selfdrive/ui/soundd.py
index 481b7a12d5..c41fec2676 100644
--- a/selfdrive/ui/soundd.py
+++ b/selfdrive/ui/soundd.py
@@ -12,6 +12,7 @@ from openpilot.common.utils import retry
from openpilot.common.swaglog import cloudlog
from openpilot.system import micd
+from openpilot.system.hardware import HARDWARE
from openpilot.sunnypilot.selfdrive.ui.quiet_mode import QuietMode
@@ -25,6 +26,10 @@ FILTER_DT = 1. / (micd.SAMPLE_RATE / micd.FFT_SAMPLES)
AMBIENT_DB = 30 # DB where MIN_VOLUME is applied
DB_SCALE = 30 # AMBIENT_DB + DB_SCALE is where MAX_VOLUME is applied
+VOLUME_BASE = 20
+if HARDWARE.get_device_type() == "tizi":
+ VOLUME_BASE = 10
+
AudibleAlert = car.CarControl.HUDControl.AudibleAlert
AudibleAlertSP = custom.SelfdriveStateSP.AudibleAlert
@@ -50,6 +55,11 @@ sound_list: dict[int, tuple[str, int | None, float]] = {
**sound_list_sp,
}
+if HARDWARE.get_device_type() == "tizi":
+ sound_list.update({
+ AudibleAlert.engage: ("engage_tizi.wav", 1, MAX_VOLUME),
+ AudibleAlert.disengage: ("disengage_tizi.wav", 1, MAX_VOLUME),
+ })
def check_selfdrive_timeout_alert(sm):
ss_missing = time.monotonic() - sm.recv_time['selfdriveState']
@@ -135,7 +145,7 @@ class Soundd(QuietMode):
def calculate_volume(self, weighted_db):
volume = ((weighted_db - AMBIENT_DB) / DB_SCALE) * (MAX_VOLUME - MIN_VOLUME) + MIN_VOLUME
- return math.pow(10, (np.clip(volume, MIN_VOLUME, MAX_VOLUME) - 1))
+ return math.pow(VOLUME_BASE, (np.clip(volume, MIN_VOLUME, MAX_VOLUME) - 1))
@retry(attempts=10, delay=3)
def get_stream(self, sd):
diff --git a/selfdrive/ui/tests/profile_onroad.py b/selfdrive/ui/tests/profile_onroad.py
index 0294125ceb..b1fa4acc48 100755
--- a/selfdrive/ui/tests/profile_onroad.py
+++ b/selfdrive/ui/tests/profile_onroad.py
@@ -7,10 +7,9 @@ import numpy as np
from msgq.visionipc import VisionIpcServer, VisionStreamType
from openpilot.selfdrive.ui.ui_state import ui_state
-from openpilot.selfdrive.ui.layouts.main import MainLayout
+from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
from openpilot.system.ui.lib.application import gui_app
from openpilot.tools.lib.logreader import LogReader
-from openpilot.tools.plotjuggler.juggle import DEMO_ROUTE
FPS = 60
@@ -56,7 +55,7 @@ def patch_submaster(message_chunks):
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description='Profile openpilot UI rendering and state updates')
- parser.add_argument('route', type=str, nargs='?', default=DEMO_ROUTE + "/1",
+ parser.add_argument('route', type=str, nargs='?', default="302bab07c1511180/00000006--0b9a7005f1/3",
help='Route to use for profiling')
parser.add_argument('--loop', type=int, default=1,
help='Number of times to loop the log (default: 1)')
@@ -82,8 +81,8 @@ if __name__ == "__main__":
if args.headless:
os.environ['SDL_VIDEODRIVER'] = 'dummy'
- gui_app.init_window("UI Profiling")
- main_layout = MainLayout()
+ gui_app.init_window("UI Profiling", fps=600)
+ main_layout = MiciMainLayout()
main_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
print("Running...")
diff --git a/selfdrive/ui/tests/test_ui/raylib_screenshots.py b/selfdrive/ui/tests/test_ui/raylib_screenshots.py
index f36ad1badb..481ac111be 100755
--- a/selfdrive/ui/tests/test_ui/raylib_screenshots.py
+++ b/selfdrive/ui/tests/test_ui/raylib_screenshots.py
@@ -18,6 +18,7 @@ from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.test.helpers import with_processes
from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert
from openpilot.system.updated.updated import parse_release_notes
+from openpilot.system.version import terms_version, training_version
AlertSize = log.SelfdriveState.AlertSize
AlertStatus = log.SelfdriveState.AlertStatus
@@ -246,6 +247,7 @@ CASES = {
class TestUI:
def __init__(self):
os.environ["SCALE"] = os.getenv("SCALE", "1")
+ os.environ["BIG"] = "1"
sys.modules["mouseinfo"] = False
def setup(self):
@@ -297,6 +299,10 @@ def create_screenshots():
params.put("UpdaterCurrentDescription", VERSION)
params.put("UpdaterNewDescription", VERSION)
+ # Set terms and training version (to skip onboarding)
+ params.put("HasAcceptedTerms", terms_version)
+ params.put("CompletedTrainingVersion", training_version)
+
if name == "homescreen_paired":
params.put("PrimeType", 0) # NONE
elif name == "homescreen_prime":
diff --git a/selfdrive/ui/translations/app_pt-BR.po b/selfdrive/ui/translations/app_pt-BR.po
index 8a388d0da0..84b53c6e8d 100644
--- a/selfdrive/ui/translations/app_pt-BR.po
+++ b/selfdrive/ui/translations/app_pt-BR.po
@@ -15,7 +15,9 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+"X-Language: pt_BR\n"
+"X-Source-Language: C\n"
#: selfdrive/ui/layouts/settings/device.py:160
#, python-format
@@ -78,12 +80,12 @@ msgstr ""
#: selfdrive/ui/layouts/settings/device.py:148
#, python-format
msgid "
Steering lag calibration is complete."
-msgstr ""
+msgstr "
A calibração da latência da direção está concluída."
#: selfdrive/ui/layouts/settings/device.py:146
#, python-format
msgid "
Steering lag calibration is {}% complete."
-msgstr ""
+msgstr "
A calibração da latência da direção está {}% concluída."
#: selfdrive/ui/layouts/settings/firehose.py:138
#, python-format
@@ -106,7 +108,7 @@ msgstr "ADICIONAR"
#: system/ui/widgets/network.py:139
#, python-format
msgid "APN Setting"
-msgstr ""
+msgstr "Configuração de APN"
#: selfdrive/ui/widgets/offroad_alerts.py:109
#, python-format
@@ -116,7 +118,7 @@ msgstr "Reconhecer Atuação Excessiva"
#: system/ui/widgets/network.py:74 system/ui/widgets/network.py:95
#, python-format
msgid "Advanced"
-msgstr ""
+msgstr "Avançado"
#: selfdrive/ui/layouts/settings/toggles.py:98
#, python-format
@@ -205,18 +207,19 @@ msgstr "CONECTAR"
#: system/ui/widgets/network.py:369
#, python-format
msgid "CONNECTING..."
-msgstr "CONECTAR"
+msgstr "CONECTANDO..."
-#: system/ui/widgets/confirm_dialog.py:23 system/ui/widgets/option_dialog.py:35
-#: system/ui/widgets/keyboard.py:81 system/ui/widgets/network.py:318
+#: system/ui/widgets/confirm_dialog.py:23
+#: system/ui/widgets/option_dialog.py:35 system/ui/widgets/keyboard.py:81
+#: system/ui/widgets/network.py:318
#, python-format
msgid "Cancel"
-msgstr ""
+msgstr "Cancelar"
#: system/ui/widgets/network.py:134
#, python-format
msgid "Cellular Metered"
-msgstr ""
+msgstr "Dados móveis limitados"
#: selfdrive/ui/layouts/settings/device.py:68
#, python-format
@@ -227,7 +230,7 @@ msgstr "Alterar Idioma"
#, python-format
msgid "Changing this setting will restart openpilot if the car is powered on."
msgstr ""
-" Alterar esta configuração reiniciará o openpilot se o carro estiver ligado."
+"Alterar esta configuração reiniciará o openpilot se o carro estiver ligado."
#: selfdrive/ui/widgets/pairing_dialog.py:129
#, python-format
@@ -261,7 +264,7 @@ msgstr "Recusar, desinstalar o openpilot"
#: selfdrive/ui/layouts/settings/settings.py:67
msgid "Developer"
-msgstr "Desenvolvedor"
+msgstr "Desenvolv"
#: selfdrive/ui/layouts/settings/settings.py:62
msgid "Device"
@@ -309,12 +312,12 @@ msgstr "Câmera do Motorista"
#: selfdrive/ui/layouts/settings/toggles.py:96
#, python-format
msgid "Driving Personality"
-msgstr "Personalidade de Condução"
+msgstr "Personalidade"
#: system/ui/widgets/network.py:123 system/ui/widgets/network.py:139
#, python-format
msgid "EDIT"
-msgstr ""
+msgstr "EDITAR"
#: selfdrive/ui/layouts/sidebar.py:138
msgid "ERROR"
@@ -382,22 +385,22 @@ msgstr ""
#: system/ui/widgets/network.py:204
#, python-format
msgid "Enter APN"
-msgstr ""
+msgstr "Digite APN"
#: system/ui/widgets/network.py:241
#, python-format
msgid "Enter SSID"
-msgstr ""
+msgstr "Digite SSID"
#: system/ui/widgets/network.py:254
#, python-format
msgid "Enter new tethering password"
-msgstr ""
+msgstr "Digite nova senha tethering"
#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314
#, python-format
msgid "Enter password"
-msgstr ""
+msgstr "Digite a senha"
#: selfdrive/ui/widgets/ssh_key.py:89
#, python-format
@@ -407,7 +410,7 @@ msgstr "Digite seu nome de usuário do GitHub"
#: system/ui/widgets/list_view.py:123 system/ui/widgets/list_view.py:160
#, python-format
msgid "Error"
-msgstr ""
+msgstr "Erro"
#: selfdrive/ui/layouts/settings/toggles.py:52
#, python-format
@@ -426,12 +429,12 @@ msgstr ""
#: system/ui/widgets/network.py:373
#, python-format
msgid "FORGETTING..."
-msgstr ""
+msgstr "ESQUECENDO..."
#: selfdrive/ui/widgets/setup.py:44
#, python-format
msgid "Finish Setup"
-msgstr "Concluir Configuração"
+msgstr "Configure"
#: selfdrive/ui/layouts/settings/settings.py:66
msgid "Firehose"
@@ -487,12 +490,12 @@ msgstr ""
#: system/ui/widgets/network.py:318 system/ui/widgets/network.py:451
#, python-format
msgid "Forget"
-msgstr ""
+msgstr "Esquecer"
#: system/ui/widgets/network.py:319
#, python-format
msgid "Forget Wi-Fi Network \"{}\"?"
-msgstr ""
+msgstr "Esquecer rede Wi-Fi \"{}\"?"
#: selfdrive/ui/layouts/sidebar.py:71 selfdrive/ui/layouts/sidebar.py:125
msgid "GOOD"
@@ -526,7 +529,7 @@ msgstr "INSTALAR"
#: system/ui/widgets/network.py:150
#, python-format
msgid "IP Address"
-msgstr ""
+msgstr "Endereço IP"
#: selfdrive/ui/layouts/settings/software.py:53
#, python-format
@@ -568,7 +571,7 @@ msgstr ""
#: selfdrive/ui/layouts/settings/device.py:60
#, python-format
msgid "N/A"
-msgstr ""
+msgstr "N/A"
#: selfdrive/ui/layouts/sidebar.py:142
msgid "NO"
@@ -670,12 +673,12 @@ msgstr "Desligar"
#: system/ui/widgets/network.py:144
#, python-format
msgid "Prevent large data uploads when on a metered Wi-Fi connection"
-msgstr ""
+msgstr "Evitar uploads grandes de dados em conexões Wi-Fi limitadas"
#: system/ui/widgets/network.py:135
#, python-format
msgid "Prevent large data uploads when on a metered cellular connection"
-msgstr ""
+msgstr "Evitar uploads grandes de dados em conexões móveis limitadas"
#: selfdrive/ui/layouts/settings/device.py:25
msgid ""
@@ -795,27 +798,27 @@ msgstr "Revise as regras, recursos e limitações do openpilot"
#: selfdrive/ui/layouts/settings/software.py:61
#, python-format
msgid "SELECT"
-msgstr ""
+msgstr "SELECIONAR"
#: selfdrive/ui/layouts/settings/developer.py:53
#, python-format
msgid "SSH Keys"
-msgstr ""
+msgstr "Chaves SSH"
#: system/ui/widgets/network.py:310
#, python-format
msgid "Scanning Wi-Fi networks..."
-msgstr ""
+msgstr "Procurando redes Wi-Fi..."
#: system/ui/widgets/option_dialog.py:36
#, python-format
msgid "Select"
-msgstr ""
+msgstr "Selecione"
#: selfdrive/ui/layouts/settings/software.py:183
#, python-format
msgid "Select a branch"
-msgstr ""
+msgstr "Selecione uma branch"
#: selfdrive/ui/layouts/settings/device.py:91
#, python-format
@@ -873,16 +876,16 @@ msgstr "TEMP"
#: selfdrive/ui/layouts/settings/software.py:61
#, python-format
msgid "Target Branch"
-msgstr ""
+msgstr "Branch Alvo"
#: system/ui/widgets/network.py:124
#, python-format
msgid "Tethering Password"
-msgstr ""
+msgstr "Senha Tethering"
#: selfdrive/ui/layouts/settings/settings.py:64
msgid "Toggles"
-msgstr "Alternâncias"
+msgstr "Toggles"
#: selfdrive/ui/layouts/settings/software.py:72
#, python-format
@@ -978,12 +981,12 @@ msgstr "Wi‑Fi"
#: system/ui/widgets/network.py:144
#, python-format
msgid "Wi-Fi Network Metered"
-msgstr ""
+msgstr "Rede Wi-Fi limitada"
#: system/ui/widgets/network.py:314
#, python-format
msgid "Wrong password"
-msgstr ""
+msgstr "Senha errada"
#: selfdrive/ui/layouts/onboarding.py:145
#, python-format
@@ -1012,7 +1015,7 @@ msgstr "comma prime"
#: system/ui/widgets/network.py:142
#, python-format
msgid "default"
-msgstr ""
+msgstr "default"
#: selfdrive/ui/layouts/settings/device.py:133
#, python-format
@@ -1027,7 +1030,7 @@ msgstr "falha ao verificar atualização"
#: system/ui/widgets/network.py:237 system/ui/widgets/network.py:314
#, python-format
msgid "for \"{}\""
-msgstr ""
+msgstr "para \"{}\""
#: selfdrive/ui/onroad/hud_renderer.py:177
#, python-format
@@ -1037,7 +1040,7 @@ msgstr "km/h"
#: system/ui/widgets/network.py:204
#, python-format
msgid "leave blank for automatic configuration"
-msgstr ""
+msgstr "deixe em branco para configuração automática"
#: selfdrive/ui/layouts/settings/device.py:134
#, python-format
@@ -1047,7 +1050,7 @@ msgstr "à esquerda"
#: system/ui/widgets/network.py:142
#, python-format
msgid "metered"
-msgstr ""
+msgstr "limitados"
#: selfdrive/ui/onroad/hud_renderer.py:177
#, python-format
@@ -1077,30 +1080,30 @@ msgstr "openpilot Indisponível"
#: selfdrive/ui/layouts/settings/toggles.py:158
#, python-format
msgid ""
-"openpilot defaults to driving in chill mode. Experimental mode enables alpha-"
-"level features that aren't ready for chill mode. Experimental features are "
-"listed below:
End-to-End Longitudinal Control
Let the driving "
-"model control the gas and brakes. openpilot will drive as it thinks a human "
-"would, including stopping for red lights and stop signs. Since the driving "
-"model decides the speed to drive, the set speed will only act as an upper "
-"bound. This is an alpha quality feature; mistakes should be expected."
-"
New Driving Visualization
The driving visualization will "
-"transition to the road-facing wide-angle camera at low speeds to better show "
-"some turns. The Experimental mode logo will also be shown in the top right "
-"corner."
+"openpilot defaults to driving in chill mode. Experimental mode enables "
+"alpha-level features that aren't ready for chill mode. Experimental features "
+"are listed below:
End-to-End Longitudinal Control
Let the "
+"driving model control the gas and brakes. openpilot will drive as it thinks "
+"a human would, including stopping for red lights and stop signs. Since the "
+"driving model decides the speed to drive, the set speed will only act as an "
+"upper bound. This is an alpha quality feature; mistakes should be "
+"expected.
New Driving Visualization
The driving visualization "
+"will transition to the road-facing wide-angle camera at low speeds to better "
+"show some turns. The Experimental mode logo will also be shown in the top "
+"right corner."
msgstr ""
"o openpilot dirige por padrão no modo chill. O Modo Experimental habilita "
"recursos em nível alpha que não estão prontos para o modo chill. Os recursos "
-"experimentais são listados abaixo:
Controle Longitudinal End-to-End"
-"h4>
Permita que o modelo de condução controle o acelerador e os freios. O "
-"openpilot dirigirá como acha que um humano faria, incluindo parar em sinais "
-"e semáforos vermelhos. Como o modelo decide a velocidade, a velocidade "
-"definida atuará apenas como limite superior. Este é um recurso de qualidade "
-"alpha; erros devem ser esperados.
Nova Visualização de Condução"
-"h4>
A visualização de condução mudará para a câmera grande-angular "
-"voltada para a estrada em baixas velocidades para mostrar melhor algumas "
-"curvas. O logotipo do Modo Experimental também será exibido no canto "
-"superior direito."
+"experimentais são listados abaixo:
Controle Longitudinal "
+"End-to-End
Permita que o modelo de condução controle o acelerador e "
+"os freios. O openpilot dirigirá como acha que um humano faria, incluindo "
+"parar em sinais e semáforos vermelhos. Como o modelo decide a velocidade, a "
+"velocidade definida atuará apenas como limite superior. Este é um recurso de "
+"qualidade alpha; erros devem ser esperados.
Nova Visualização de "
+"Condução
A visualização de condução mudará para a câmera "
+"grande-angular voltada para a estrada em baixas velocidades para mostrar "
+"melhor algumas curvas. O logotipo do Modo Experimental também será exibido "
+"no canto superior direito."
#: selfdrive/ui/layouts/settings/device.py:165
#, python-format
@@ -1108,7 +1111,8 @@ msgid ""
"openpilot is continuously calibrating, resetting is rarely required. "
"Resetting calibration will restart openpilot if the car is powered on."
msgstr ""
-" Alterar esta configuração reiniciará o openpilot se o carro estiver ligado."
+"O openpilot está continuamente calibrando, resetar é raramente solicitado. "
+"Alterar esta configuração reiniciará o openpilot se o carro estiver ligado."
#: selfdrive/ui/layouts/settings/firehose.py:20
msgid ""
@@ -1146,7 +1150,7 @@ msgstr "à direita"
#: system/ui/widgets/network.py:142
#, python-format
msgid "unmetered"
-msgstr ""
+msgstr "ilimitados"
#: selfdrive/ui/layouts/settings/device.py:133
#, python-format
diff --git a/selfdrive/ui/ui.py b/selfdrive/ui/ui.py
index a4b99825e9..7fe0dfbbc9 100755
--- a/selfdrive/ui/ui.py
+++ b/selfdrive/ui/ui.py
@@ -6,6 +6,7 @@ from openpilot.system.hardware import TICI
from openpilot.common.realtime import config_realtime_process, set_core_affinity
from openpilot.system.ui.lib.application import gui_app
from openpilot.selfdrive.ui.layouts.main import MainLayout
+from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
from openpilot.selfdrive.ui.ui_state import ui_state
@@ -14,7 +15,10 @@ def main():
config_realtime_process(0, 51)
gui_app.init_window("UI")
- main_layout = MainLayout()
+ if gui_app.big_ui():
+ main_layout = MainLayout()
+ else:
+ main_layout = MiciMainLayout()
main_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
for should_render in gui_app.render():
ui_state.update()
diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py
index b08b8ef28c..ef0696a22c 100644
--- a/selfdrive/ui/ui_state.py
+++ b/selfdrive/ui/ui_state.py
@@ -10,9 +10,9 @@ from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.selfdrive.ui.lib.prime_state import PrimeState
from openpilot.system.ui.lib.application import gui_app
-from openpilot.system.hardware import HARDWARE
+from openpilot.system.hardware import HARDWARE, PC
-BACKLIGHT_OFFROAD = 50
+BACKLIGHT_OFFROAD = 65 if HARDWARE.get_device_type() == "mici" else 50
class UIStatus(Enum):
@@ -36,6 +36,7 @@ class UIState:
[
"modelV2",
"controlsState",
+ "onroadEvents",
"liveCalibration",
"radarState",
"deviceState",
@@ -49,6 +50,10 @@ class UIState:
"managerState",
"selfdriveState",
"longitudinalPlan",
+ "gpsLocationExternal",
+ "carOutput",
+ "carControl",
+ "liveParameters",
"rawAudioData",
]
)
@@ -64,6 +69,8 @@ class UIState:
# Core state variables
self.is_metric: bool = self.params.get_bool("IsMetric")
+ self.is_release = self.params.get_bool("IsReleaseBranch")
+ self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM")
self.started: bool = False
self.ignition: bool = False
self.recording_audio: bool = False
@@ -133,6 +140,7 @@ class UIState:
self.recording_audio = self.params.get_bool("RecordAudio") and self.started
self.is_metric = self.params.get_bool("IsMetric")
+ self.always_on_dm = self.params.get_bool("AlwaysOnDM")
def _update_status(self) -> None:
if self.started and self.sm.updated["selfdriveState"]:
@@ -181,16 +189,21 @@ class Device:
self._interaction_time: float = -1
self._interactive_timeout_callbacks: list[Callable] = []
self._prev_timed_out = False
- self._awake = False
+ self._awake: bool = True
self._offroad_brightness: int = BACKLIGHT_OFFROAD
self._last_brightness: int = 0
self._brightness_filter = FirstOrderFilter(BACKLIGHT_OFFROAD, 10.00, 1 / gui_app.target_fps)
self._brightness_thread: threading.Thread | None = None
+ @property
+ def awake(self) -> bool:
+ return self._awake
+
def reset_interactive_timeout(self, timeout: int = -1) -> None:
if timeout == -1:
- timeout = 10 if ui_state.ignition else 30
+ ignition_timeout = 10 if gui_app.big_ui() else 5
+ timeout = ignition_timeout if ui_state.ignition else 30
self._interaction_time = time.monotonic() + timeout
def add_interactive_timeout_callback(self, callback: Callable):
@@ -204,8 +217,9 @@ class Device:
self._update_brightness()
self._update_wakefulness()
- def set_offroad_brightness(self, brightness: int):
- # TODO: not yet used, should be used in prime widget for QR code, etc.
+ def set_offroad_brightness(self, brightness: int | None):
+ if brightness is None:
+ brightness = BACKLIGHT_OFFROAD
self._offroad_brightness = min(max(brightness, 0), 100)
def _update_brightness(self):
@@ -220,7 +234,7 @@ class Device:
else:
clipped_brightness = ((clipped_brightness + 16.0) / 116.0) ** 3.0
- clipped_brightness = float(np.clip(100 * clipped_brightness, 10, 100))
+ clipped_brightness = float(np.interp(clipped_brightness, [0, 1], [30, 100]))
brightness = round(self._brightness_filter.update(clipped_brightness))
if not self._awake:
@@ -246,7 +260,7 @@ class Device:
callback()
self._prev_timed_out = interaction_timeout
- self._set_awake(ui_state.ignition or not interaction_timeout)
+ self._set_awake(ui_state.ignition or not interaction_timeout or PC)
def _set_awake(self, on: bool):
if on != self._awake:
diff --git a/sunnypilot/selfdrive/selfdrived/events_base.py b/sunnypilot/selfdrive/selfdrived/events_base.py
index 66396c4968..5f9f8edf94 100644
--- a/sunnypilot/selfdrive/selfdrived/events_base.py
+++ b/sunnypilot/selfdrive/selfdrived/events_base.py
@@ -6,6 +6,7 @@ from collections.abc import Callable
from cereal import log, car
import cereal.messaging as messaging
from openpilot.common.realtime import DT_CTRL
+from openpilot.system.hardware import HARDWARE
AlertSize = log.SelfdriveState.AlertSize
AlertStatus = log.SelfdriveState.AlertStatus
@@ -185,6 +186,8 @@ class NoEntryAlert(Alert):
def __init__(self, alert_text_2: str,
alert_text_1: str = "openpilot Unavailable",
visual_alert: car.CarControl.HUDControl.VisualAlert=VisualAlert.none):
+ if HARDWARE.get_device_type() == 'mici':
+ alert_text_1, alert_text_2 = alert_text_2, alert_text_1
super().__init__(alert_text_1, alert_text_2, AlertStatus.normal,
AlertSize.mid, Priority.LOW, visual_alert,
AudibleAlert.refuse, 3.)
@@ -230,6 +233,11 @@ class NormalPermanentAlert(Alert):
class StartupAlert(Alert):
def __init__(self, alert_text_1: str, alert_text_2: str = "Always keep hands on wheel and eyes on road", alert_status=AlertStatus.normal):
+ alert_size = AlertSize.mid
+ if HARDWARE.get_device_type() == 'mici':
+ if alert_text_2 == "Always keep hands on wheel and eyes on road":
+ alert_text_2 = ""
+ alert_size = AlertSize.small
super().__init__(alert_text_1, alert_text_2,
- alert_status, AlertSize.mid,
+ alert_status, alert_size,
Priority.LOWER, VisualAlert.none, AudibleAlert.none, 5.),
diff --git a/system/hardware/tici/agnos.json b/system/hardware/tici/agnos.json
index 58c3d2a4e6..5a2a092aa8 100644
--- a/system/hardware/tici/agnos.json
+++ b/system/hardware/tici/agnos.json
@@ -67,17 +67,17 @@
},
{
"name": "system",
- "url": "https://commadist.azureedge.net/agnosupdate/system-8757f4a9d2489585249970142578029ab1dfdc5851da75fd703d2376b6f2a26b.img.xz",
- "hash": "e9e99988d78c7287f29ad840130f65d5a11fa2301463d5298f1072399406f889",
- "hash_raw": "8757f4a9d2489585249970142578029ab1dfdc5851da75fd703d2376b6f2a26b",
+ "url": "https://commadist.azureedge.net/agnosupdate/system-d9d476b466186014e7ae4b8232bc6fc5e79b122421bdc12ff4eb02d1c3f37818.img.xz",
+ "hash": "a068d4d692ec770884f0a15e1a6d7aba52385ecae138f6d43fb0a9b1643ed5cd",
+ "hash_raw": "d9d476b466186014e7ae4b8232bc6fc5e79b122421bdc12ff4eb02d1c3f37818",
"size": 4718592000,
"sparse": true,
"full_check": false,
"has_ab": true,
- "ondevice_hash": "21d3726fcdd39d126c9ecf05ccc43a104c8486b929045a63bf7e3ac8a8bb7a50",
+ "ondevice_hash": "6ffa02f7113badc122742f33efebc5d17f1cd61dd6358f3e130c162707dbfaf4",
"alt": {
- "hash": "8757f4a9d2489585249970142578029ab1dfdc5851da75fd703d2376b6f2a26b",
- "url": "https://commadist.azureedge.net/agnosupdate/system-8757f4a9d2489585249970142578029ab1dfdc5851da75fd703d2376b6f2a26b.img",
+ "hash": "d9d476b466186014e7ae4b8232bc6fc5e79b122421bdc12ff4eb02d1c3f37818",
+ "url": "https://commadist.azureedge.net/agnosupdate/system-d9d476b466186014e7ae4b8232bc6fc5e79b122421bdc12ff4eb02d1c3f37818.img",
"size": 4718592000
}
}
diff --git a/system/hardware/tici/all-partitions.json b/system/hardware/tici/all-partitions.json
index bac2dfc594..3abf66cdd4 100644
--- a/system/hardware/tici/all-partitions.json
+++ b/system/hardware/tici/all-partitions.json
@@ -350,51 +350,51 @@
},
{
"name": "system",
- "url": "https://commadist.azureedge.net/agnosupdate/system-8757f4a9d2489585249970142578029ab1dfdc5851da75fd703d2376b6f2a26b.img.xz",
- "hash": "e9e99988d78c7287f29ad840130f65d5a11fa2301463d5298f1072399406f889",
- "hash_raw": "8757f4a9d2489585249970142578029ab1dfdc5851da75fd703d2376b6f2a26b",
+ "url": "https://commadist.azureedge.net/agnosupdate/system-d9d476b466186014e7ae4b8232bc6fc5e79b122421bdc12ff4eb02d1c3f37818.img.xz",
+ "hash": "a068d4d692ec770884f0a15e1a6d7aba52385ecae138f6d43fb0a9b1643ed5cd",
+ "hash_raw": "d9d476b466186014e7ae4b8232bc6fc5e79b122421bdc12ff4eb02d1c3f37818",
"size": 4718592000,
"sparse": true,
"full_check": false,
"has_ab": true,
- "ondevice_hash": "21d3726fcdd39d126c9ecf05ccc43a104c8486b929045a63bf7e3ac8a8bb7a50",
+ "ondevice_hash": "6ffa02f7113badc122742f33efebc5d17f1cd61dd6358f3e130c162707dbfaf4",
"alt": {
- "hash": "8757f4a9d2489585249970142578029ab1dfdc5851da75fd703d2376b6f2a26b",
- "url": "https://commadist.azureedge.net/agnosupdate/system-8757f4a9d2489585249970142578029ab1dfdc5851da75fd703d2376b6f2a26b.img",
+ "hash": "d9d476b466186014e7ae4b8232bc6fc5e79b122421bdc12ff4eb02d1c3f37818",
+ "url": "https://commadist.azureedge.net/agnosupdate/system-d9d476b466186014e7ae4b8232bc6fc5e79b122421bdc12ff4eb02d1c3f37818.img",
"size": 4718592000
}
},
{
"name": "userdata_90",
- "url": "https://commadist.azureedge.net/agnosupdate/userdata_90-1d461d8be17827735a28c2588bb9fcad27d4b80fba15cd2740f3a04c8f29cc90.img.xz",
- "hash": "763c7366049b3c0ad71bd19abbbf5c68d2c43597d4da5dafad890507ff489899",
- "hash_raw": "1d461d8be17827735a28c2588bb9fcad27d4b80fba15cd2740f3a04c8f29cc90",
+ "url": "https://commadist.azureedge.net/agnosupdate/userdata_90-f9ea618ac97a86da49733ce66cd5e3aa19aa917666ee90de301cd746664e4d22.img.xz",
+ "hash": "dfc6812e76bd1583ed77a86eedf48cafdc306037d2a85c5d0aa7cdb23033b736",
+ "hash_raw": "f9ea618ac97a86da49733ce66cd5e3aa19aa917666ee90de301cd746664e4d22",
"size": 96636764160,
"sparse": true,
"full_check": true,
"has_ab": false,
- "ondevice_hash": "90a265b8756b18caf1be4b8dc9b8b3104898170104ed87ec3274f77acc6c28e3"
+ "ondevice_hash": "ff95f994e9ed6504632f4b7c6daecef582f0a4e5261b8240d4474f16059faef4"
},
{
"name": "userdata_89",
- "url": "https://commadist.azureedge.net/agnosupdate/userdata_89-ec37fcfb7d707d26d5fbc64994e20cfdbb73a27eeedfe37778559824a2032a27.img.xz",
- "hash": "de475b604b63fbeb1841c6564fb8eb496da46c9a9564ec73e5d7c8045fc88ebc",
- "hash_raw": "ec37fcfb7d707d26d5fbc64994e20cfdbb73a27eeedfe37778559824a2032a27",
+ "url": "https://commadist.azureedge.net/agnosupdate/userdata_89-393956e255c277b895bdb98bf65cfa3907e4b57822740ff82f857ac4e1a2f11e.img.xz",
+ "hash": "b5e2f05d31fc18fff18e82dcebfc2bf04de624baeca0511b93e50b3198b8a9ab",
+ "hash_raw": "393956e255c277b895bdb98bf65cfa3907e4b57822740ff82f857ac4e1a2f11e",
"size": 95563022336,
"sparse": true,
"full_check": true,
"has_ab": false,
- "ondevice_hash": "03f6cbddc3bfbd2d0cd316d87d488434a03095c12870c8c6fe3bc4a2946ff0ef"
+ "ondevice_hash": "db64c6abc72bfcddc1682c73cc73c7230ed2f6e835d292fd38d054a9d242b8fc"
},
{
"name": "userdata_30",
- "url": "https://commadist.azureedge.net/agnosupdate/userdata_30-3501f34c28f0e5ffe224f192b4a3a35a00a039980ca29a5c35d31449f3e918d6.img.xz",
- "hash": "5bda2cb099b14f4944b476995d84dcb943af1858a57fdd62d5920b6e7b74fb80",
- "hash_raw": "3501f34c28f0e5ffe224f192b4a3a35a00a039980ca29a5c35d31449f3e918d6",
+ "url": "https://commadist.azureedge.net/agnosupdate/userdata_30-a4b3e2a2fc3612a37322b7b1a4c5737765841dc3b8d6d3bb58b1e5a271023068.img.xz",
+ "hash": "ecec713cf7d8f1f616f122a16b138931f818290447e36a5925da6a4fc0fc7bf3",
+ "hash_raw": "a4b3e2a2fc3612a37322b7b1a4c5737765841dc3b8d6d3bb58b1e5a271023068",
"size": 32212254720,
"sparse": true,
"full_check": true,
"has_ab": false,
- "ondevice_hash": "d1da6f8d928093dec15590b5c1740c0062031d0068a11962bdb28dca2104d8c6"
+ "ondevice_hash": "48fefa5a1880a4fd3dd50e1f9ddee297122053556816baca310d495129bc8893"
}
]
\ No newline at end of file
diff --git a/system/hardware/tici/updater_magic b/system/hardware/tici/updater_magic
index b4dfa9be2e..ec586dbcb3 100755
--- a/system/hardware/tici/updater_magic
+++ b/system/hardware/tici/updater_magic
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7990262878becdf2eaed40ffcc96835a6fc6bc4bdf52f4df88e8b6fcadd1bff8
-size 13664323
+oid sha256:c44fb88b3b1643b6b44ae8ac9880348bd0257ff90f4084cbe889de91d71653fe
+size 25111329
diff --git a/system/manager/manager.py b/system/manager/manager.py
index 23e8ff7255..d70b4d74b7 100755
--- a/system/manager/manager.py
+++ b/system/manager/manager.py
@@ -17,7 +17,7 @@ from openpilot.system.manager.process import ensure_running
from openpilot.system.manager.process_config import managed_processes
from openpilot.system.athena.registration import register, UNREGISTERED_DONGLE_ID
from openpilot.common.swaglog import cloudlog, add_file_handler
-from openpilot.system.version import get_build_metadata, terms_version, training_version
+from openpilot.system.version import get_build_metadata
from openpilot.system.hardware.hw import Paths
@@ -58,8 +58,6 @@ def manager_init() -> None:
# set params
serial = HARDWARE.get_serial()
params.put("Version", build_metadata.openpilot.version)
- params.put("TermsVersion", terms_version)
- params.put("TrainingVersion", training_version)
params.put("GitCommit", build_metadata.openpilot.git_commit)
params.put("GitCommitDate", build_metadata.openpilot.git_commit_date)
params.put("GitBranch", build_metadata.channel)
diff --git a/system/manager/process_config.py b/system/manager/process_config.py
index 7e4edc6863..23c7a0116c 100644
--- a/system/manager/process_config.py
+++ b/system/manager/process_config.py
@@ -127,7 +127,7 @@ procs = [
PythonProcess("sensord", "system.sensord.sensord", only_onroad, enabled=not PC),
PythonProcess("ui", "selfdrive.ui.ui", always_run),
- PythonProcess("soundd", "selfdrive.ui.soundd", only_onroad),
+ PythonProcess("soundd", "selfdrive.ui.soundd", driverview),
PythonProcess("locationd", "selfdrive.locationd.locationd", only_onroad),
NativeProcess("_pandad", "selfdrive/pandad", ["./pandad"], always_run, enabled=False),
PythonProcess("calibrationd", "selfdrive.locationd.calibrationd", only_onroad),
diff --git a/system/sensord/sensord.py b/system/sensord/sensord.py
index 2b6467fa78..cc0366881b 100755
--- a/system/sensord/sensord.py
+++ b/system/sensord/sensord.py
@@ -11,6 +11,7 @@ from openpilot.common.util import sudo_write
from openpilot.common.realtime import config_realtime_process, Ratekeeper
from openpilot.common.swaglog import cloudlog
from openpilot.common.gpio import gpiochip_get_ro_value_fd, gpioevent_data
+from openpilot.system.hardware import HARDWARE
from openpilot.system.sensord.sensors.i2c_sensor import Sensor
from openpilot.system.sensord.sensors.lsm6ds3_accel import LSM6DS3_Accel
@@ -95,8 +96,11 @@ def main() -> None:
(LSM6DS3_Accel(I2C_BUS_IMU), "accelerometer", True),
(LSM6DS3_Gyro(I2C_BUS_IMU), "gyroscope", True),
(LSM6DS3_Temp(I2C_BUS_IMU), "temperatureSensor", False),
- (MMC5603NJ_Magn(I2C_BUS_IMU), "magnetometer", False),
]
+ if HARDWARE.get_device_type() == "tizi":
+ sensors_cfg.append(
+ (MMC5603NJ_Magn(I2C_BUS_IMU), "magnetometer", False),
+ )
# Reset sensors
for sensor, _, _ in sensors_cfg:
diff --git a/system/ui/README.md b/system/ui/README.md
index b124ae4d85..f81cb5573a 100644
--- a/system/ui/README.md
+++ b/system/ui/README.md
@@ -3,9 +3,13 @@
The user interfaces here are built with [raylib](https://www.raylib.com/).
Quick start:
+* set `BIG=1` to run the comma 3X UI (comma four UI runs by default)
* set `SHOW_FPS=1` to show the FPS
* set `STRICT_MODE=1` to kill the app if it drops too much below 60fps
* set `SCALE=1.5` to scale the entire UI by 1.5x
+* set `BURN_IN=1` to get a burn-in heatmap version of the UI
+* set `GRID=50` to show a 50-pixel alignment grid overlay
+* set `MAGIC_DEBUG=1` to show every dropped frames (only on device)
* https://www.raylib.com/cheatsheet/cheatsheet.html
* https://electronstudio.github.io/raylib-python-cffi/README.html#quickstart
diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py
index e9f5484a17..e3370a5f74 100644
--- a/system/ui/lib/application.py
+++ b/system/ui/lib/application.py
@@ -6,6 +6,7 @@ import signal
import sys
import pyray as rl
import threading
+import platform
from contextlib import contextmanager
from collections.abc import Callable
from collections import deque
@@ -26,20 +27,59 @@ MOUSE_THREAD_RATE = 140 # touch controller runs at 140Hz
MAX_TOUCH_SLOTS = 2
TOUCH_HISTORY_TIMEOUT = 3.0 # Seconds before touch points fade out
+BIG_UI = os.getenv("BIG", "0") == "1"
ENABLE_VSYNC = os.getenv("ENABLE_VSYNC", "0") == "1"
SHOW_FPS = os.getenv("SHOW_FPS") == "1"
SHOW_TOUCHES = os.getenv("SHOW_TOUCHES") == "1"
STRICT_MODE = os.getenv("STRICT_MODE") == "1"
SCALE = float(os.getenv("SCALE", "1.0"))
+GRID_SIZE = int(os.getenv("GRID", "0"))
PROFILE_RENDER = int(os.getenv("PROFILE_RENDER", "0"))
PROFILE_STATS = int(os.getenv("PROFILE_STATS", "100")) # Number of functions to show in profile output
+GL_VERSION = """
+#version 300 es
+precision highp float;
+"""
+if platform.system() == "Darwin":
+ GL_VERSION = """
+ #version 330 core
+ """
+
+BURN_IN_MODE = "BURN_IN" in os.environ
+BURN_IN_VERTEX_SHADER = GL_VERSION + """
+in vec3 vertexPosition;
+in vec2 vertexTexCoord;
+uniform mat4 mvp;
+out vec2 fragTexCoord;
+void main() {
+ fragTexCoord = vertexTexCoord;
+ gl_Position = mvp * vec4(vertexPosition, 1.0);
+}
+"""
+BURN_IN_FRAGMENT_SHADER = GL_VERSION + """
+in vec2 fragTexCoord;
+uniform sampler2D texture0;
+out vec4 fragColor;
+void main() {
+ vec4 sampled = texture(texture0, fragTexCoord);
+ float intensity = sampled.b;
+ // Map blue intensity to green -> yellow -> red to highlight burn-in risk.
+ vec3 start = vec3(0.0, 1.0, 0.0);
+ vec3 middle = vec3(1.0, 1.0, 0.0);
+ vec3 end = vec3(1.0, 0.0, 0.0);
+ vec3 gradient = mix(start, middle, clamp(intensity * 2.0, 0.0, 1.0));
+ gradient = mix(gradient, end, clamp((intensity - 0.5) * 2.0, 0.0, 1.0));
+ fragColor = vec4(gradient, sampled.a);
+}
+"""
+
DEFAULT_TEXT_SIZE = 60
DEFAULT_TEXT_COLOR = rl.WHITE
# Qt draws fonts accounting for ascent/descent differently, so compensate to match old styles
# The real scales for the fonts below range from 1.212 to 1.266
-FONT_SCALE = 1.242
+FONT_SCALE = 1.242 if BIG_UI else 1.16
ASSETS_DIR = files("openpilot.selfdrive").joinpath("assets")
FONT_DIR = ASSETS_DIR.joinpath("fonts")
@@ -47,12 +87,17 @@ FONT_DIR = ASSETS_DIR.joinpath("fonts")
class FontWeight(StrEnum):
LIGHT = "Inter-Light.fnt"
- NORMAL = "Inter-Regular.fnt"
+ NORMAL = "Inter-Regular.fnt" if BIG_UI else "Inter-Medium.fnt"
MEDIUM = "Inter-Medium.fnt"
- SEMI_BOLD = "Inter-SemiBold.fnt"
BOLD = "Inter-Bold.fnt"
+ SEMI_BOLD = "Inter-SemiBold.fnt"
UNIFONT = "unifont.fnt"
+ # Small UI fonts
+ DISPLAY_REGULAR = "Inter-Regular.fnt"
+ ROMAN = "Inter-Regular.fnt"
+ DISPLAY = "Inter-Bold.fnt"
+
def font_fallback(font: rl.Font) -> rl.Font:
"""Fall back to unifont for languages that require it."""
@@ -142,10 +187,10 @@ class MouseState:
class GuiApplication:
- def __init__(self, width: int, height: int):
+ def __init__(self, width: int | None = None, height: int | None = None):
self._fonts: dict[FontWeight, rl.Font] = {}
- self._width = width
- self._height = height
+ self._width = width if width is not None else GuiApplication._default_width()
+ self._height = height if height is not None else GuiApplication._default_height()
if PC and os.getenv("SCALE") is None:
self._scale = self._calculate_auto_scale()
@@ -155,6 +200,7 @@ class GuiApplication:
self._scaled_width = int(self._width * self._scale)
self._scaled_height = int(self._height * self._scale)
self._render_texture: rl.RenderTexture | None = None
+ self._burn_in_shader: rl.Shader | None = None
self._textures: dict[str, rl.Texture] = {}
self._target_fps: int = _DEFAULT_FPS
self._last_fps_log_time: float = time.monotonic()
@@ -174,6 +220,7 @@ class GuiApplication:
self._mouse_history: deque[MousePosWithTime] = deque(maxlen=MOUSE_THREAD_RATE)
self._show_touches = SHOW_TOUCHES
self._show_fps = SHOW_FPS
+ self._grid_size = GRID_SIZE
self._profile_render_frames = PROFILE_RENDER
self._render_profiler = None
self._render_profile_start_time = None
@@ -212,8 +259,10 @@ class GuiApplication:
rl.set_config_flags(flags)
rl.init_window(self._scaled_width, self._scaled_height, title)
+ needs_render_texture = self._scale != 1.0 or BURN_IN_MODE
if self._scale != 1.0:
rl.set_mouse_scale(1 / self._scale, 1 / self._scale)
+ if needs_render_texture:
self._render_texture = rl.load_render_texture(self._width, self._height)
rl.set_texture_filter(self._render_texture.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
rl.set_target_fps(fps)
@@ -222,6 +271,8 @@ class GuiApplication:
self._set_styles()
self._load_fonts()
self._patch_text_functions()
+ if BURN_IN_MODE and self._burn_in_shader is None:
+ self._burn_in_shader = rl.load_shader_from_memory(BURN_IN_VERTEX_SHADER, BURN_IN_FRAGMENT_SHADER)
if not PC:
self._mouse.start()
@@ -337,6 +388,10 @@ class GuiApplication:
rl.unload_render_texture(self._render_texture)
self._render_texture = None
+ if self._burn_in_shader:
+ rl.unload_shader(self._burn_in_shader)
+ self._burn_in_shader = None
+
if not PC:
self._mouse.stop()
@@ -395,7 +450,14 @@ class GuiApplication:
rl.clear_background(rl.BLACK)
src_rect = rl.Rectangle(0, 0, float(self._width), -float(self._height))
dst_rect = rl.Rectangle(0, 0, float(self._scaled_width), float(self._scaled_height))
- rl.draw_texture_pro(self._render_texture.texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
+ texture = self._render_texture.texture
+ if texture:
+ if BURN_IN_MODE and self._burn_in_shader:
+ rl.begin_shader_mode(self._burn_in_shader)
+ rl.draw_texture_pro(texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
+ rl.end_shader_mode()
+ else:
+ rl.draw_texture_pro(texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
if self._show_fps:
rl.draw_fps(10, 10)
@@ -403,6 +465,9 @@ class GuiApplication:
if self._show_touches:
self._draw_touch_points()
+ if self._grid_size > 0:
+ self._draw_grid()
+
rl.end_drawing()
self._monitor_fps()
self._frame += 1
@@ -551,6 +616,19 @@ class GuiApplication:
color = rl.Color(min(int(255 * (1.5 - perc)), 255), int(min(255 * (perc + 0.5), 255)), 50, 255)
rl.draw_circle(int(mouse_pos.x), int(mouse_pos.y), 5, color)
+ def _draw_grid(self):
+ grid_color = rl.Color(60, 60, 60, 255)
+ # Draw vertical lines
+ x = 0
+ while x <= self._scaled_width:
+ rl.draw_line(x, 0, x, self._scaled_height, grid_color)
+ x += self._grid_size
+ # Draw horizontal lines
+ y = 0
+ while y <= self._scaled_height:
+ rl.draw_line(0, y, self._scaled_width, y, grid_color)
+ y += self._grid_size
+
def _output_render_profile(self):
import io
import pstats
@@ -582,5 +660,17 @@ class GuiApplication:
# Apply 0.95 factor for window decorations/taskbar margin
return max(0.3, min(w / self._width, h / self._height) * 0.95)
+ @staticmethod
+ def _default_width() -> int:
+ return 2160 if GuiApplication.big_ui() else 536
-gui_app = GuiApplication(2160, 1080)
+ @staticmethod
+ def _default_height() -> int:
+ return 1080 if GuiApplication.big_ui() else 240
+
+ @staticmethod
+ def big_ui() -> bool:
+ return HARDWARE.get_device_type() in ('tici', 'tizi') or BIG_UI
+
+
+gui_app = GuiApplication()
diff --git a/system/ui/lib/scroll_panel2.py b/system/ui/lib/scroll_panel2.py
new file mode 100644
index 0000000000..8d9caadfdd
--- /dev/null
+++ b/system/ui/lib/scroll_panel2.py
@@ -0,0 +1,219 @@
+import os
+import math
+import pyray as rl
+from collections.abc import Callable
+from enum import Enum
+from typing import cast
+from openpilot.system.ui.lib.application import gui_app, MouseEvent
+from openpilot.system.hardware import TICI
+from collections import deque
+
+MIN_VELOCITY = 2 # px/s, changes from auto scroll to steady state
+MIN_VELOCITY_FOR_CLICKING = 2 * 60 # px/s, accepts clicks while auto scrolling below this velocity
+MIN_DRAG_PIXELS = 12
+AUTO_SCROLL_TC_SNAP = 0.025
+AUTO_SCROLL_TC = 0.18
+BOUNCE_RETURN_RATE = 10.0
+REJECT_DECELERATION_FACTOR = 3
+MAX_SPEED = 10000.0 # px/s
+
+DEBUG = os.getenv("DEBUG_SCROLL", "0") == "1"
+
+
+# from https://ariya.io/2011/10/flick-list-with-its-momentum-scrolling-and-deceleration
+class ScrollState(Enum):
+ STEADY = 0
+ PRESSED = 1
+ MANUAL_SCROLL = 2
+ AUTO_SCROLL = 3
+
+
+class GuiScrollPanel2:
+ def __init__(self, horizontal: bool = True, handle_out_of_bounds: bool = True) -> None:
+ self._horizontal = horizontal
+ self._handle_out_of_bounds = handle_out_of_bounds
+ self._AUTO_SCROLL_TC = AUTO_SCROLL_TC_SNAP if not self._handle_out_of_bounds else AUTO_SCROLL_TC
+ self._state = ScrollState.STEADY
+ self._offset: rl.Vector2 = rl.Vector2(0, 0)
+ self._initial_click_event: MouseEvent | None = None
+ self._previous_mouse_event: MouseEvent | None = None
+ self._velocity = 0.0 # pixels per second
+ self._velocity_buffer: deque[float] = deque(maxlen=12 if TICI else 6)
+ self._enabled: bool | Callable[[], bool] = True
+
+ def set_enabled(self, enabled: bool | Callable[[], bool]) -> None:
+ self._enabled = enabled
+
+ @property
+ def enabled(self) -> bool:
+ return self._enabled() if callable(self._enabled) else self._enabled
+
+ def update(self, bounds: rl.Rectangle, content_size: float) -> float:
+ if DEBUG:
+ print('Old state:', self._state)
+
+ bounds_size = bounds.width if self._horizontal else bounds.height
+
+ for mouse_event in gui_app.mouse_events:
+ self._handle_mouse_event(mouse_event, bounds, bounds_size, content_size)
+ self._previous_mouse_event = mouse_event
+
+ self._update_state(bounds_size, content_size)
+
+ if DEBUG:
+ print('Velocity:', self._velocity)
+ print('Offset X:', self._offset.x, 'Y:', self._offset.y)
+ print('New state:', self._state)
+ print()
+ return self.get_offset()
+
+ def _update_state(self, bounds_size: float, content_size: float) -> None:
+ """Runs per render frame, independent of mouse events. Updates auto-scrolling state and velocity."""
+ if self._state == ScrollState.AUTO_SCROLL:
+ # simple exponential return if out of bounds
+ out_of_bounds = self.get_offset() > 0 or self.get_offset() < (bounds_size - content_size)
+ if out_of_bounds and self._handle_out_of_bounds:
+ if self.get_offset() < (bounds_size - content_size): # too far right
+ target = bounds_size - content_size
+ else: # too far left
+ target = 0.0
+
+ dt = rl.get_frame_time() or 1e-6
+ factor = 1.0 - math.exp(-BOUNCE_RETURN_RATE * dt)
+
+ dist = target - self.get_offset()
+ self.set_offset(self.get_offset() + dist * factor) # ease toward the edge
+ self._velocity *= (1.0 - factor) # damp any leftover fling
+
+ # Steady once we are close enough to the target
+ if abs(dist) < 1 and abs(self._velocity) < MIN_VELOCITY:
+ self.set_offset(target)
+ self._state = ScrollState.STEADY
+
+ elif abs(self._velocity) < MIN_VELOCITY:
+ self._velocity = 0.0
+ self._state = ScrollState.STEADY
+
+ # Update the offset based on the current velocity
+ dt = rl.get_frame_time()
+ self.set_offset(self.get_offset() + self._velocity * dt) # Adjust the offset based on velocity
+ alpha = 1 - (dt / (self._AUTO_SCROLL_TC + dt))
+ self._velocity *= alpha
+
+ def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, bounds_size: float,
+ content_size: float) -> None:
+ out_of_bounds = self.get_offset() > 0 or self.get_offset() < (bounds_size - content_size)
+ if DEBUG:
+ print('Mouse event:', mouse_event)
+
+ mouse_pos = self._get_mouse_pos(mouse_event)
+
+ if not self.enabled:
+ # Reset state if not enabled
+ self._state = ScrollState.STEADY
+ self._velocity = 0.0
+ self._velocity_buffer.clear()
+
+ elif self._state == ScrollState.STEADY:
+ if rl.check_collision_point_rec(mouse_event.pos, bounds):
+ if mouse_event.left_pressed:
+ self._state = ScrollState.PRESSED
+ self._initial_click_event = mouse_event
+
+ elif self._state == ScrollState.PRESSED:
+ initial_click_pos = self._get_mouse_pos(cast(MouseEvent, self._initial_click_event))
+ diff = abs(mouse_pos - initial_click_pos)
+ if mouse_event.left_released:
+ # Special handling for down and up clicks across two frames
+ # TODO: not sure what that means or if it's accurate anymore
+ if out_of_bounds:
+ self._state = ScrollState.AUTO_SCROLL
+ elif diff <= MIN_DRAG_PIXELS:
+ self._state = ScrollState.STEADY
+ else:
+ self._state = ScrollState.MANUAL_SCROLL
+ elif diff > MIN_DRAG_PIXELS:
+ self._state = ScrollState.MANUAL_SCROLL
+
+ elif self._state == ScrollState.MANUAL_SCROLL:
+ if mouse_event.left_released:
+ # Touch rejection: when releasing finger after swiping and stopping, panel
+ # reports a few erroneous touch events with high velocity, try to ignore.
+
+ # If velocity decelerates very quickly, assume user doesn't intend to auto scroll
+ high_decel = False
+ if len(self._velocity_buffer) > 2:
+ # We limit max to first half since final few velocities can surpass first few
+ abs_velocity_buffer = [(abs(v), i) for i, v in enumerate(self._velocity_buffer)]
+ max_idx = max(abs_velocity_buffer[:len(abs_velocity_buffer) // 2])[1]
+ min_idx = min(abs_velocity_buffer)[1]
+ if DEBUG:
+ print('min_idx:', min_idx, 'max_idx:', max_idx, 'velocity buffer:', self._velocity_buffer)
+ if (abs(self._velocity_buffer[min_idx]) * REJECT_DECELERATION_FACTOR < abs(self._velocity_buffer[max_idx]) and
+ max_idx < min_idx):
+ if DEBUG:
+ print('deceleration too high, going to STEADY')
+ high_decel = True
+
+ # If final velocity is below some threshold, switch to steady state too
+ low_speed = abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING * 1.5 # plus some margin
+
+ if out_of_bounds or not (high_decel or low_speed):
+ self._state = ScrollState.AUTO_SCROLL
+ else:
+ # TODO: we should just set velocity and let autoscroll go back to steady. delays one frame but who cares
+ self._velocity = 0.0
+ self._state = ScrollState.STEADY
+ self._velocity_buffer.clear()
+ else:
+ # Update velocity for when we release the mouse button.
+ # Do not update velocity on the same frame the mouse was released
+ previous_mouse_pos = self._get_mouse_pos(cast(MouseEvent, self._previous_mouse_event))
+ delta_x = mouse_pos - previous_mouse_pos
+ self._velocity = delta_x / (mouse_event.t - cast(MouseEvent, self._previous_mouse_event).t)
+ self._velocity = max(-MAX_SPEED, min(MAX_SPEED, self._velocity))
+ self._velocity_buffer.append(self._velocity)
+
+ # rubber-banding: reduce dragging when out of bounds
+ # TODO: this drifts when dragging quickly
+ if out_of_bounds:
+ delta_x *= 0.25
+
+ # Update the offset based on the mouse movement
+ # Use internal _offset directly to preserve precision (don't round via get_offset())
+ # TODO: make get_offset return float
+ current_offset = self._offset.x if self._horizontal else self._offset.y
+ self.set_offset(current_offset + delta_x)
+
+ elif self._state == ScrollState.AUTO_SCROLL:
+ if mouse_event.left_pressed:
+ # Decide whether to click or scroll (block click if moving too fast)
+ if abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING:
+ # Traveling slow enough, click
+ self._state = ScrollState.PRESSED
+ self._initial_click_event = mouse_event
+ else:
+ # Go straight into manual scrolling to block erroneous input
+ self._state = ScrollState.MANUAL_SCROLL
+ # Reset velocity for touch down and up events that happen in back-to-back frames
+ self._velocity = 0.0
+
+ def _get_mouse_pos(self, mouse_event: MouseEvent) -> float:
+ return mouse_event.pos.x if self._horizontal else mouse_event.pos.y
+
+ def get_offset(self) -> int:
+ return round(self._offset.x if self._horizontal else self._offset.y)
+
+ def set_offset(self, value: float) -> None:
+ if self._horizontal:
+ self._offset.x = value
+ else:
+ self._offset.y = value
+
+ @property
+ def state(self) -> ScrollState:
+ return self._state
+
+ def is_touch_valid(self) -> bool:
+ # MIN_VELOCITY_FOR_CLICKING is checked in auto-scroll state
+ return bool(self._state != ScrollState.MANUAL_SCROLL)
diff --git a/system/ui/lib/shader_polygon.py b/system/ui/lib/shader_polygon.py
index 28585b08ba..94af35e157 100644
--- a/system/ui/lib/shader_polygon.py
+++ b/system/ui/lib/shader_polygon.py
@@ -1,9 +1,8 @@
-import platform
import pyray as rl
import numpy as np
from dataclasses import dataclass
from typing import Any, Optional, cast
-from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.lib.application import gui_app, GL_VERSION
MAX_GRADIENT_COLORS = 20 # includes stops as well
@@ -29,16 +28,7 @@ class Gradient:
self.stops = [i / max(1, color_count - 1) for i in range(color_count)]
-VERSION = """
-#version 300 es
-precision highp float;
-"""
-if platform.system() == "Darwin":
- VERSION = """
- #version 330 core
- """
-
-FRAGMENT_SHADER = VERSION + """
+FRAGMENT_SHADER = GL_VERSION + """
in vec2 fragTexCoord;
out vec4 finalColor;
@@ -83,7 +73,7 @@ void main() {
"""
# Default vertex shader
-VERTEX_SHADER = VERSION + """
+VERTEX_SHADER = GL_VERSION + """
in vec3 vertexPosition;
in vec2 vertexTexCoord;
out vec2 fragTexCoord;
@@ -162,7 +152,7 @@ class ShaderState:
self.initialized = False
-def _configure_shader_color(state: ShaderState, color: Optional[rl.Color], # noqa: UP045
+def _configure_shader_color(state: ShaderState, color: Optional[rl.Color],
gradient: Gradient | None, origin_rect: rl.Rectangle):
assert (color is not None) != (gradient is not None), "Either color or gradient must be provided"
@@ -201,7 +191,9 @@ def triangulate(pts: np.ndarray) -> list[tuple[float, float]]:
# TODO: consider deduping close screenspace points
# interleave points to produce a triangle strip
- assert len(pts) % 2 == 0, "Interleaving expects even number of points"
+ # assert len(pts) % 2 == 0, "Interleaving expects even number of points"
+ if len(pts) % 2 != 0:
+ pts = pts[:-1]
tri_strip = []
for i in range(len(pts) // 2):
@@ -212,7 +204,7 @@ def triangulate(pts: np.ndarray) -> list[tuple[float, float]]:
def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray,
- color: Optional[rl.Color] = None, gradient: Gradient | None = None): # noqa: UP045
+ color: Optional[rl.Color] = None, gradient: Gradient | None = None):
"""
Draw a ribbon polygon (two chains) with a triangle strip and gradient.
diff --git a/system/ui/lib/text_measure.py b/system/ui/lib/text_measure.py
index 544ba5b870..dee4b419ff 100644
--- a/system/ui/lib/text_measure.py
+++ b/system/ui/lib/text_measure.py
@@ -5,9 +5,10 @@ from openpilot.system.ui.lib.emoji import find_emoji
_cache: dict[int, rl.Vector2] = {}
-def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: int = 0) -> rl.Vector2:
+def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: float = 0) -> rl.Vector2:
"""Caches text measurements to avoid redundant calculations."""
font = font_fallback(font)
+ spacing = round(spacing, 4)
key = hash((font.texture.id, text, font_size, spacing))
if key in _cache:
return _cache[key]
diff --git a/system/ui/lib/wifi_manager.py b/system/ui/lib/wifi_manager.py
index 4cf2ccebc8..28bd58f226 100644
--- a/system/ui/lib/wifi_manager.py
+++ b/system/ui/lib/wifi_manager.py
@@ -35,7 +35,7 @@ except Exception:
TETHERING_IP_ADDRESS = "192.168.43.1"
DEFAULT_TETHERING_PASSWORD = "swagswagcomma"
SIGNAL_QUEUE_SIZE = 10
-SCAN_PERIOD_SECONDS = 10
+SCAN_PERIOD_SECONDS = 5
class SecurityType(IntEnum):
@@ -75,6 +75,7 @@ class Network:
is_connected: bool
security_type: SecurityType
is_saved: bool
+ ip_address: str = "" # TODO: implement
@classmethod
def from_dbus(cls, ssid: str, aps: list["AccessPoint"], is_saved: bool) -> "Network":
@@ -137,6 +138,8 @@ class WifiManager:
self._nm = DBusAddress(NM_PATH, bus_name=NM, interface=NM_IFACE)
except FileNotFoundError:
cloudlog.exception("Failed to connect to system D-Bus")
+ self._router_main = None
+ self._conn_monitor = None
self._exit = True
# Store wifi device path
@@ -627,7 +630,7 @@ class WifiManager:
known_connections = self._get_connections()
networks = [Network.from_dbus(ssid, ap_list, ssid in known_connections) for ssid, ap_list in aps.items()]
- networks.sort(key=lambda n: (-n.is_connected, -n.strength, n.ssid.lower()))
+ networks.sort(key=lambda n: (-n.is_connected, n.ssid.lower()))
self._networks = networks
self._update_ipv4_address()
@@ -751,6 +754,8 @@ class WifiManager:
if self._state_thread.is_alive():
self._state_thread.join()
- self._router_main.close()
- self._router_main.conn.close()
- self._conn_monitor.close()
+ if self._router_main is not None:
+ self._router_main.close()
+ self._router_main.conn.close()
+ if self._conn_monitor is not None:
+ self._conn_monitor.close()
diff --git a/system/ui/lib/wrap_text.py b/system/ui/lib/wrap_text.py
index 745d37b468..3fabfbb66b 100644
--- a/system/ui/lib/wrap_text.py
+++ b/system/ui/lib/wrap_text.py
@@ -3,7 +3,7 @@ from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import font_fallback
-def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -> list[str]:
+def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int, spacing: float = 0) -> list[str]:
if not word:
return []
@@ -11,7 +11,7 @@ def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -
remaining = word
while remaining:
- if measure_text_cached(font, remaining, font_size).x <= max_width:
+ if measure_text_cached(font, remaining, font_size, spacing).x <= max_width:
parts.append(remaining)
break
@@ -22,7 +22,7 @@ def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -
while left <= right:
mid = (left + right) // 2
substring = remaining[:mid]
- width = measure_text_cached(font, substring, font_size).x
+ width = measure_text_cached(font, substring, font_size, spacing).x
if width <= max_width:
best_fit = mid
@@ -40,9 +40,10 @@ def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -
_cache: dict[int, list[str]] = {}
-def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[str]:
+def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int, spacing: float = 0) -> list[str]:
font = font_fallback(font)
- key = hash((font.texture.id, text, font_size, max_width))
+ spacing = round(spacing, 4)
+ key = hash((font.texture.id, text, font_size, max_width, spacing))
if key in _cache:
return _cache[key]
@@ -69,7 +70,7 @@ def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[
current_line: list[str] = []
for word in words:
- word_width = int(measure_text_cached(font, word, font_size).x)
+ word_width = measure_text_cached(font, word, font_size, spacing).x
# Check if word alone exceeds max width (need to break the word)
if word_width > max_width:
@@ -79,12 +80,12 @@ def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[
current_line = []
# Break the long word into parts
- lines.extend(_break_long_word(font, word, font_size, max_width))
+ lines.extend(_break_long_word(font, word, font_size, max_width, spacing))
continue
# Measure the actual joined string to get accurate width (accounts for kerning, etc.)
test_line = " ".join(current_line + [word]) if current_line else word
- test_width = int(measure_text_cached(font, test_line, font_size).x)
+ test_width = measure_text_cached(font, test_line, font_size, spacing).x
# Check if word fits on current line
if test_width <= max_width:
diff --git a/system/ui/mici_reset.py b/system/ui/mici_reset.py
new file mode 100755
index 0000000000..d9bb45d99a
--- /dev/null
+++ b/system/ui/mici_reset.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+import os
+import sys
+import threading
+import time
+from enum import IntEnum
+
+import pyray as rl
+
+from openpilot.system.hardware import PC
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.slider import SmallSlider
+from openpilot.system.ui.widgets.button import SmallButton, FullRoundedButton
+from openpilot.system.ui.widgets.label import gui_label, gui_text_box
+
+USERDATA = "/dev/disk/by-partlabel/userdata"
+TIMEOUT = 3*60
+
+
+class ResetMode(IntEnum):
+ USER_RESET = 0 # user initiated a factory reset from openpilot
+ RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover
+ FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata
+
+
+class ResetState(IntEnum):
+ NONE = 0
+ RESETTING = 1
+ FAILED = 2
+
+
+class Reset(Widget):
+ def __init__(self, mode):
+ super().__init__()
+ self._mode = mode
+ self._previous_reset_state = None
+ self._reset_state = ResetState.NONE
+
+ self._cancel_button = SmallButton("cancel")
+ self._cancel_button.set_click_callback(self._cancel_callback)
+
+ self._reboot_button = FullRoundedButton("reboot")
+ self._reboot_button.set_click_callback(self._do_reboot)
+
+ self._confirm_slider = SmallSlider("reset", self._confirm)
+
+ self._render_status = True
+
+ def _cancel_callback(self):
+ self._render_status = False
+
+ def _do_reboot(self):
+ if PC:
+ return
+
+ os.system("sudo reboot")
+
+ def _do_erase(self):
+ if PC:
+ return
+
+ # Removing data and formatting
+ rm = os.system("sudo rm -rf /data/*")
+ os.system(f"sudo umount {USERDATA}")
+ fmt = os.system(f"yes | sudo mkfs.ext4 {USERDATA}")
+
+ if rm == 0 or fmt == 0:
+ os.system("sudo reboot")
+ else:
+ self._reset_state = ResetState.FAILED
+
+ def start_reset(self):
+ self._reset_state = ResetState.RESETTING
+ threading.Timer(0.1, self._do_erase).start()
+
+ def _update_state(self):
+ if self._reset_state != self._previous_reset_state:
+ self._previous_reset_state = self._reset_state
+ self._timeout_st = time.monotonic()
+ elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT:
+ exit(0)
+
+ def _render(self, rect: rl.Rectangle):
+ label_rect = rl.Rectangle(rect.x + 8, rect.y + 8, rect.width, 50)
+ gui_label(label_rect, "factory reset", 48, font_weight=FontWeight.BOLD,
+ color=rl.Color(255, 255, 255, int(255 * 0.9)))
+
+ text_rect = rl.Rectangle(rect.x + 8, rect.y + 56, rect.width - 8 * 2, rect.height - 80)
+ gui_text_box(text_rect, self._get_body_text(), 36, font_weight=FontWeight.ROMAN, line_scale=0.9)
+
+ if self._reset_state != ResetState.RESETTING:
+ # fade out cancel button as slider is moved, set visible to prevent pressing invisible cancel
+ self._cancel_button.set_opacity(1.0 - self._confirm_slider.slider_percentage)
+ self._cancel_button.set_visible(self._confirm_slider.slider_percentage < 0.8)
+
+ if self._mode == ResetMode.RECOVER:
+ self._cancel_button.set_text("reboot")
+ self._cancel_button.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y + rect.height - self._cancel_button.rect.height,
+ self._cancel_button.rect.width,
+ self._cancel_button.rect.height))
+ elif self._mode == ResetMode.USER_RESET and self._reset_state != ResetState.FAILED:
+ self._cancel_button.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y + rect.height - self._cancel_button.rect.height,
+ self._cancel_button.rect.width,
+ self._cancel_button.rect.height))
+
+ if self._reset_state != ResetState.FAILED:
+ self._confirm_slider.render(rl.Rectangle(
+ rect.x + rect.width - self._confirm_slider.rect.width,
+ rect.y + rect.height - self._confirm_slider.rect.height,
+ self._confirm_slider.rect.width,
+ self._confirm_slider.rect.height))
+ else:
+ self._reboot_button.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y + rect.height - self._reboot_button.rect.height,
+ self._reboot_button.rect.width,
+ self._reboot_button.rect.height))
+
+ return self._render_status
+
+ def _confirm(self):
+ self.start_reset()
+
+ def _get_body_text(self):
+ if self._reset_state == ResetState.RESETTING:
+ return "Resetting device... This may take up to a minute."
+ if self._reset_state == ResetState.FAILED:
+ return "Reset failed. Reboot to try again."
+ if self._mode == ResetMode.RECOVER:
+ return "Unable to mount data partition. Partition may be corrupted."
+ return "All content and settings will be erased."
+
+
+def main():
+ mode = ResetMode.USER_RESET
+ if len(sys.argv) > 1:
+ if sys.argv[1] == '--recover':
+ mode = ResetMode.RECOVER
+ elif sys.argv[1] == "--format":
+ mode = ResetMode.FORMAT
+
+ gui_app.init_window("System Reset")
+ reset = Reset(mode)
+
+ if mode == ResetMode.FORMAT:
+ reset.start_reset()
+
+ for should_render in gui_app.render():
+ if should_render:
+ if not reset.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)):
+ break
+
+
+if __name__ == "__main__":
+ main()
diff --git a/system/ui/mici_setup.py b/system/ui/mici_setup.py
new file mode 100755
index 0000000000..d7395f9b7a
--- /dev/null
+++ b/system/ui/mici_setup.py
@@ -0,0 +1,732 @@
+#!/usr/bin/env python3
+from abc import abstractmethod
+import os
+import re
+import threading
+import time
+import urllib.request
+import urllib.error
+from urllib.parse import urlparse
+from enum import IntEnum
+import shutil
+from collections.abc import Callable
+
+import pyray as rl
+
+from cereal import log
+from openpilot.common.utils import run_cmd
+from openpilot.system.hardware import HARDWARE
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.lib.wifi_manager import WifiManager
+from openpilot.selfdrive.ui.ui_state import device
+from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
+from openpilot.system.ui.widgets import Widget, DialogResult
+from openpilot.system.ui.widgets.button import (IconButton, SmallButton, WideRoundedButton, SmallerRoundedButton,
+ SmallCircleIconButton, WidishRoundedButton, SmallRedPillButton,
+ FullRoundedButton)
+from openpilot.system.ui.widgets.label import UnifiedLabel
+from openpilot.system.ui.widgets.slider import LargerSlider
+from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiUIMici
+from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog
+
+NetworkType = log.DeviceState.NetworkType
+
+OPENPILOT_URL = "https://openpilot.comma.ai"
+USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
+
+CONTINUE_PATH = "/data/continue.sh"
+TMP_CONTINUE_PATH = "/data/continue.sh.new"
+INSTALL_PATH = "/data/openpilot"
+VALID_CACHE_PATH = "/data/.openpilot_cache"
+INSTALLER_SOURCE_PATH = "/usr/comma/installer"
+INSTALLER_DESTINATION_PATH = "/tmp/installer"
+INSTALLER_URL_PATH = "/tmp/installer_url"
+
+CONTINUE = """#!/usr/bin/env bash
+
+cd /data/openpilot
+exec ./launch_openpilot.sh
+"""
+
+
+class NetworkConnectivityMonitor:
+ def __init__(self, should_check: Callable[[], bool] | None = None, check_interval: float = 0.5):
+ self.network_connected = threading.Event()
+ self.wifi_connected = threading.Event()
+ self._should_check = should_check or (lambda: True)
+ self._check_interval = check_interval
+ self._stop_event = threading.Event()
+ self._thread: threading.Thread | None = None
+
+ def start(self):
+ self._stop_event.clear()
+ if self._thread is None or not self._thread.is_alive():
+ self._thread = threading.Thread(target=self._run, daemon=True)
+ self._thread.start()
+
+ def stop(self):
+ if self._thread is not None:
+ self._stop_event.set()
+ self._thread.join()
+ self._thread = None
+
+ def reset(self):
+ self.network_connected.clear()
+ self.wifi_connected.clear()
+
+ def _run(self):
+ while not self._stop_event.is_set():
+ if self._should_check():
+ try:
+ request = urllib.request.Request(OPENPILOT_URL, method="HEAD")
+ urllib.request.urlopen(request, timeout=0.5)
+ self.network_connected.set()
+ if HARDWARE.get_network_type() == NetworkType.wifi:
+ self.wifi_connected.set()
+ except Exception:
+ self.reset()
+ else:
+ self.reset()
+
+ if self._stop_event.wait(timeout=self._check_interval):
+ break
+
+
+class SetupState(IntEnum):
+ GETTING_STARTED = 0
+ NETWORK_SETUP = 1
+ NETWORK_SETUP_CUSTOM_SOFTWARE = 8
+ SOFTWARE_SELECTION = 2
+ CUSTOM_SOFTWARE = 3
+ DOWNLOADING = 4
+ DOWNLOAD_FAILED = 5
+ CUSTOM_SOFTWARE_WARNING = 6
+
+
+class StartPage(Widget):
+ def __init__(self):
+ super().__init__()
+
+ self._title = UnifiedLabel("start", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ font_weight=FontWeight.DISPLAY, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
+
+ self._start_bg_txt = gui_app.texture("icons_mici/setup/green_button.png", 520, 224)
+ self._start_bg_pressed_txt = gui_app.texture("icons_mici/setup/green_button_pressed.png", 520, 224)
+
+ def _render(self, rect: rl.Rectangle):
+ draw_x = rect.x + (rect.width - self._start_bg_txt.width) / 2
+ draw_y = rect.y + (rect.height - self._start_bg_txt.height) / 2
+ texture = self._start_bg_pressed_txt if self.is_pressed else self._start_bg_txt
+ rl.draw_texture(texture, int(draw_x), int(draw_y), rl.WHITE)
+
+ self._title.render(rect)
+
+
+class SoftwareSelectionPage(Widget):
+ def __init__(self, use_openpilot_callback: Callable,
+ use_custom_software_callback: Callable):
+ super().__init__()
+
+ self._openpilot_slider = LargerSlider("slide to use\nopenpilot", use_openpilot_callback)
+ self._custom_software_slider = LargerSlider("slide to use\ncustom software", use_custom_software_callback, green=False)
+
+ def reset(self):
+ self._openpilot_slider.reset()
+ self._custom_software_slider.reset()
+
+ def _render(self, rect: rl.Rectangle):
+ self._openpilot_slider.set_opacity(1.0 - self._custom_software_slider.slider_percentage)
+ self._custom_software_slider.set_opacity(1.0 - self._openpilot_slider.slider_percentage)
+
+ openpilot_rect = rl.Rectangle(
+ rect.x + (rect.width - self._openpilot_slider.rect.width) / 2,
+ rect.y,
+ self._openpilot_slider.rect.width,
+ rect.height / 2,
+ )
+ self._openpilot_slider.render(openpilot_rect)
+
+ custom_software_rect = rl.Rectangle(
+ rect.x + (rect.width - self._custom_software_slider.rect.width) / 2,
+ rect.y + rect.height / 2,
+ self._custom_software_slider.rect.width,
+ rect.height / 2,
+ )
+ self._custom_software_slider.render(custom_software_rect)
+
+
+class TermsHeader(Widget):
+ def __init__(self, text: str, icon_texture: rl.Texture):
+ super().__init__()
+
+ self._title = UnifiedLabel(text, 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ font_weight=FontWeight.BOLD, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
+ line_height=0.8)
+ self._icon_texture = icon_texture
+
+ self.set_rect(rl.Rectangle(0, 0, gui_app.width - 16 * 2, self._icon_texture.height))
+
+ def set_title(self, text: str):
+ self._title.set_text(text)
+
+ def set_icon(self, icon_texture: rl.Texture):
+ self._icon_texture = icon_texture
+
+ def _render(self, _):
+ rl.draw_texture_ex(self._icon_texture, rl.Vector2(self._rect.x, self._rect.y),
+ 0.0, 1.0, rl.WHITE)
+
+ # May expand outside parent rect
+ title_content_height = self._title.get_content_height(int(self._rect.width - self._icon_texture.width - 16))
+ title_rect = rl.Rectangle(
+ self._rect.x + self._icon_texture.width + 16,
+ self._rect.y + (self._rect.height - title_content_height) / 2,
+ self._rect.width - self._icon_texture.width - 16,
+ title_content_height,
+ )
+ self._title.render(title_rect)
+
+
+class TermsPage(Widget):
+ ITEM_SPACING = 20
+
+ def __init__(self, continue_callback: Callable, back_callback: Callable | None = None,
+ back_text: str = "back", continue_text: str = "accept"):
+ super().__init__()
+
+ # TODO: use Scroller
+ self._scroll_panel = GuiScrollPanel2(horizontal=False)
+
+ self._continue_text = continue_text
+ self._continue_button: WideRoundedButton | FullRoundedButton
+ if back_callback is not None:
+ self._continue_button = WideRoundedButton(continue_text)
+ else:
+ self._continue_button = FullRoundedButton(continue_text)
+ self._continue_button.set_enabled(False)
+ self._continue_button.set_opacity(0.0)
+ self._continue_button.set_touch_valid_callback(self._scroll_panel.is_touch_valid)
+ self._continue_button.set_click_callback(continue_callback)
+
+ self._enable_back = back_callback is not None
+ self._back_button = SmallButton(back_text)
+ self._back_button.set_opacity(0.0)
+ self._back_button.set_touch_valid_callback(self._scroll_panel.is_touch_valid)
+ self._back_button.set_click_callback(back_callback)
+
+ self._scroll_down_indicator = IconButton(gui_app.texture("icons_mici/setup/scroll_down_indicator.png", 64, 78))
+ self._scroll_down_indicator.set_enabled(False)
+
+ def reset(self):
+ self._scroll_panel.set_offset(0)
+ self._continue_button.set_enabled(False)
+ self._continue_button.set_opacity(0.0)
+ self._back_button.set_enabled(False)
+ self._back_button.set_opacity(0.0)
+ self._scroll_down_indicator.set_opacity(1.0)
+
+ def show_event(self):
+ super().show_event()
+ device.reset_interactive_timeout(300)
+
+ @property
+ @abstractmethod
+ def _content_height(self):
+ pass
+
+ @property
+ def _scrolled_down_offset(self):
+ return -self._content_height + (self._continue_button.rect.height + 16 + 30)
+
+ @abstractmethod
+ def _render_content(self, scroll_offset):
+ pass
+
+ def _render(self, _):
+ scroll_offset = self._scroll_panel.update(self._rect, self._content_height + self._continue_button.rect.height + 16)
+
+ if scroll_offset <= self._scrolled_down_offset:
+ # don't show back if not enabled
+ if self._enable_back:
+ self._back_button.set_enabled(True)
+ self._back_button.set_opacity(1.0, smooth=True)
+ self._continue_button.set_enabled(True)
+ self._continue_button.set_opacity(1.0, smooth=True)
+ self._scroll_down_indicator.set_opacity(0.0, smooth=True)
+ else:
+ self._back_button.set_enabled(False)
+ self._back_button.set_opacity(0.0, smooth=True)
+ self._continue_button.set_enabled(False)
+ self._continue_button.set_opacity(0.0, smooth=True)
+ self._scroll_down_indicator.set_opacity(1.0, smooth=True)
+
+ # Render content
+ self._render_content(scroll_offset)
+
+ # black gradient at top and bottom for scrolling content
+ rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y),
+ int(self._rect.width), 20, rl.BLACK, rl.BLANK)
+ rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height - 20),
+ int(self._rect.width), 20, rl.BLANK, rl.BLACK)
+
+ self._back_button.render(rl.Rectangle(
+ self._rect.x + 8,
+ self._rect.y + self._rect.height - self._back_button.rect.height,
+ self._back_button.rect.width,
+ self._back_button.rect.height,
+ ))
+
+ continue_x = self._rect.x + 8
+ if self._enable_back:
+ continue_x = self._rect.x + self._rect.width - self._continue_button.rect.width - 8
+ self._continue_button.render(rl.Rectangle(
+ continue_x,
+ self._rect.y + self._rect.height - self._continue_button.rect.height,
+ self._continue_button.rect.width,
+ self._continue_button.rect.height,
+ ))
+
+ self._scroll_down_indicator.render(rl.Rectangle(
+ self._rect.x + self._rect.width - self._scroll_down_indicator.rect.width - 8,
+ self._rect.y + self._rect.height - self._scroll_down_indicator.rect.height - 8,
+ self._scroll_down_indicator.rect.width,
+ self._scroll_down_indicator.rect.height,
+ ))
+
+
+class CustomSoftwareWarningPage(TermsPage):
+ def __init__(self, continue_callback: Callable, back_callback: Callable):
+ super().__init__(continue_callback, back_callback)
+
+ self._title_header = TermsHeader("use caution installing\n3rd party software",
+ gui_app.texture("icons_mici/setup/warning.png", 66, 60))
+ self._body = UnifiedLabel("• It has not been tested by comma.\n" +
+ "• It may not comply with relevant safety standards.\n" +
+ "• It may cause damage to your device and/or vehicle.\n", 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ font_weight=FontWeight.ROMAN)
+
+ self._restore_header = TermsHeader("how to backup &\nrestore", gui_app.texture("icons_mici/setup/restore.png", 60, 60))
+ self._restore_body = UnifiedLabel("To restore your device to a factory state later, use https://flash.comma.ai",
+ 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ font_weight=FontWeight.ROMAN)
+
+ @property
+ def _content_height(self):
+ return self._restore_body.rect.y + self._restore_body.rect.height - self._scroll_panel.get_offset()
+
+ def _render_content(self, scroll_offset):
+ self._title_header.set_position(self._rect.x + 16, self._rect.y + 8 + scroll_offset)
+ self._title_header.render()
+
+ body_rect = rl.Rectangle(
+ self._rect.x + 8,
+ self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING,
+ self._rect.width - 50,
+ self._body.get_content_height(int(self._rect.width - 50)),
+ )
+ self._body.render(body_rect)
+
+ self._restore_header.set_position(self._rect.x + 16, self._body.rect.y + self._body.rect.height + self.ITEM_SPACING)
+ self._restore_header.render()
+
+ self._restore_body.render(rl.Rectangle(
+ self._rect.x + 8,
+ self._restore_header.rect.y + self._restore_header.rect.height + self.ITEM_SPACING,
+ self._rect.width - 50,
+ self._restore_body.get_content_height(int(self._rect.width - 50)),
+ ))
+
+
+class DownloadingPage(Widget):
+ def __init__(self):
+ super().__init__()
+
+ self._title_label = UnifiedLabel("downloading", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ font_weight=FontWeight.DISPLAY)
+ self._progress_label = UnifiedLabel("", 128, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.35)),
+ font_weight=FontWeight.ROMAN, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
+ self._progress = 0
+
+ def set_progress(self, progress: int):
+ self._progress = progress
+ self._progress_label.set_text(f"{progress}%")
+
+ def _render(self, rect: rl.Rectangle):
+ self._title_label.render(rl.Rectangle(
+ rect.x + 20,
+ rect.y + 10,
+ rect.width,
+ 64,
+ ))
+
+ self._progress_label.render(rl.Rectangle(
+ rect.x + 20,
+ rect.y + 20,
+ rect.width,
+ rect.height,
+ ))
+
+
+class FailedPage(Widget):
+ def __init__(self, reboot_callback: Callable, retry_callback: Callable, title: str = "download failed"):
+ super().__init__()
+
+ self._title_label = UnifiedLabel(title, 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ font_weight=FontWeight.DISPLAY)
+ self._reason_label = UnifiedLabel("", 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)),
+ font_weight=FontWeight.ROMAN)
+
+ self._reboot_button = SmallRedPillButton("reboot")
+ self._reboot_button.set_click_callback(reboot_callback)
+
+ self._retry_button = WideRoundedButton("retry")
+ self._retry_button.set_click_callback(retry_callback)
+
+ def set_reason(self, reason: str):
+ self._reason_label.set_text(reason)
+
+ def _render(self, rect: rl.Rectangle):
+ self._title_label.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y + 10,
+ rect.width,
+ 64,
+ ))
+
+ self._reason_label.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y + 10 + 64,
+ rect.width,
+ 36,
+ ))
+
+ self._reboot_button.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y + rect.height - self._reboot_button.rect.height,
+ self._reboot_button.rect.width,
+ self._reboot_button.rect.height,
+ ))
+
+ self._retry_button.render(rl.Rectangle(
+ rect.x + 8 + self._reboot_button.rect.width + 8,
+ rect.y + rect.height - self._retry_button.rect.height,
+ self._retry_button.rect.width,
+ self._retry_button.rect.height,
+ ))
+
+
+class NetworkSetupState(IntEnum):
+ MAIN = 0
+ WIFI_PANEL = 1
+
+
+class NetworkSetupPage(Widget):
+ def __init__(self, wifi_manager, continue_callback: Callable, back_callback: Callable):
+ super().__init__()
+ self._wifi_ui = WifiUIMici(wifi_manager, back_callback=lambda: self.set_state(NetworkSetupState.MAIN))
+
+ self._no_wifi_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 58, 50)
+ self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 58, 50)
+ self._waiting_text = "waiting for internet..."
+ self._network_header = TermsHeader(self._waiting_text, self._no_wifi_txt)
+
+ back_txt = gui_app.texture("icons_mici/setup/back_new.png", 37, 32)
+ self._back_button = SmallCircleIconButton(back_txt)
+ self._back_button.set_click_callback(back_callback)
+
+ self._wifi_button = SmallerRoundedButton("wifi")
+ self._wifi_button.set_click_callback(lambda: self.set_state(NetworkSetupState.WIFI_PANEL))
+
+ self._continue_button = WidishRoundedButton("continue")
+ self._continue_button.set_enabled(False)
+ self._continue_button.set_click_callback(continue_callback)
+
+ self._state = NetworkSetupState.MAIN
+
+ def set_state(self, state: NetworkSetupState):
+ self._state = state
+
+ def set_has_internet(self, has_internet: bool):
+ if has_internet:
+ self._network_header.set_title("connected to internet")
+ self._network_header.set_icon(self._wifi_full_txt)
+ self._continue_button.set_enabled(True)
+ else:
+ self._network_header.set_title(self._waiting_text)
+ self._network_header.set_icon(self._no_wifi_txt)
+ self._continue_button.set_enabled(False)
+
+ def show_event(self):
+ super().show_event()
+ self._state = NetworkSetupState.MAIN
+ self._wifi_ui.show_event()
+
+ def hide_event(self):
+ super().hide_event()
+ self._wifi_ui.hide_event()
+
+ def _render(self, _):
+ if self._state == NetworkSetupState.MAIN:
+ self._network_header.render(rl.Rectangle(
+ self._rect.x + 16,
+ self._rect.y + 16,
+ self._rect.width - 32,
+ self._network_header.rect.height,
+ ))
+
+ self._back_button.render(rl.Rectangle(
+ self._rect.x + 8,
+ self._rect.y + self._rect.height - self._back_button.rect.height,
+ self._back_button.rect.width,
+ self._back_button.rect.height,
+ ))
+
+ self._wifi_button.render(rl.Rectangle(
+ self._rect.x + 8 + self._back_button.rect.width + 10,
+ self._rect.y + self._rect.height - self._wifi_button.rect.height,
+ self._wifi_button.rect.width,
+ self._wifi_button.rect.height,
+ ))
+
+ self._continue_button.render(rl.Rectangle(
+ self._rect.x + self._rect.width - self._continue_button.rect.width - 8,
+ self._rect.y + self._rect.height - self._continue_button.rect.height,
+ self._continue_button.rect.width,
+ self._continue_button.rect.height,
+ ))
+ else:
+ self._wifi_ui.render(self._rect)
+
+
+class Setup(Widget):
+ def __init__(self):
+ super().__init__()
+ self.state = SetupState.GETTING_STARTED
+ self.failed_url = ""
+ self.failed_reason = ""
+ self.download_url = ""
+ self.download_progress = 0
+ self.download_thread = None
+ self._wifi_manager = WifiManager()
+ self._wifi_manager.set_active(True)
+ self._network_monitor = NetworkConnectivityMonitor(
+ lambda: self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE)
+ )
+
+ self._start_page = StartPage()
+ self._start_page.set_click_callback(self._getting_started_button_callback)
+
+ self._network_setup_page = NetworkSetupPage(self._wifi_manager, self._network_setup_continue_button_callback,
+ self._network_setup_back_button_callback)
+
+ self._software_selection_page = SoftwareSelectionPage(self._software_selection_continue_button_callback,
+ self._software_selection_custom_software_button_callback)
+
+ self._download_failed_page = FailedPage(HARDWARE.reboot, self._download_failed_startover_button_callback)
+
+ self._custom_software_warning_page = CustomSoftwareWarningPage(self._software_selection_custom_software_continue,
+ self._custom_software_warning_back_button_callback)
+
+ self._downloading_page = DownloadingPage()
+
+ def _update_state(self):
+ self._wifi_manager.process_callbacks()
+
+ def _set_state(self, state: SetupState):
+ self.state = state
+ if self.state == SetupState.SOFTWARE_SELECTION:
+ self._software_selection_page.reset()
+ elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING:
+ self._custom_software_warning_page.reset()
+
+ if self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE):
+ self._network_setup_page.show_event()
+ self._network_monitor.reset()
+ self._network_monitor.start()
+ else:
+ self._network_setup_page.hide_event()
+ self._network_monitor.stop()
+
+ def _render(self, rect: rl.Rectangle):
+ if self.state == SetupState.GETTING_STARTED:
+ self._start_page.render(rect)
+ elif self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE):
+ self.render_network_setup(rect)
+ elif self.state == SetupState.SOFTWARE_SELECTION:
+ self._software_selection_page.render(rect)
+ elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING:
+ self._custom_software_warning_page.render(rect)
+ elif self.state == SetupState.CUSTOM_SOFTWARE:
+ self.render_custom_software()
+ elif self.state == SetupState.DOWNLOADING:
+ self.render_downloading(rect)
+ elif self.state == SetupState.DOWNLOAD_FAILED:
+ self._download_failed_page.render(rect)
+
+ def _custom_software_warning_back_button_callback(self):
+ self._set_state(SetupState.SOFTWARE_SELECTION)
+
+ def _custom_software_warning_continue_button_callback(self):
+ self._set_state(SetupState.CUSTOM_SOFTWARE)
+
+ def _getting_started_button_callback(self):
+ self._set_state(SetupState.SOFTWARE_SELECTION)
+
+ def _software_selection_back_button_callback(self):
+ self._set_state(SetupState.GETTING_STARTED)
+
+ def _software_selection_continue_button_callback(self):
+ self.use_openpilot()
+
+ def _software_selection_custom_software_button_callback(self):
+ self._set_state(SetupState.CUSTOM_SOFTWARE_WARNING)
+
+ def _software_selection_custom_software_continue(self):
+ self._set_state(SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE)
+
+ def _download_failed_startover_button_callback(self):
+ self._set_state(SetupState.GETTING_STARTED)
+
+ def _network_setup_back_button_callback(self):
+ self._set_state(SetupState.SOFTWARE_SELECTION)
+
+ def _network_setup_continue_button_callback(self):
+ self._network_monitor.stop()
+ if self.state == SetupState.NETWORK_SETUP:
+ self.download(OPENPILOT_URL)
+ elif self.state == SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE:
+ self._set_state(SetupState.CUSTOM_SOFTWARE)
+
+ def close(self):
+ self._network_monitor.stop()
+
+ def render_network_setup(self, rect: rl.Rectangle):
+ self._network_setup_page.render(rect)
+ self._network_setup_page.set_has_internet(self._network_monitor.network_connected.is_set())
+
+ def render_downloading(self, rect: rl.Rectangle):
+ self._downloading_page.set_progress(self.download_progress)
+ self._downloading_page.render(rect)
+
+ def render_custom_software(self):
+ def handle_keyboard_result(text):
+ url = text.strip()
+ if url:
+ self.download(url)
+
+ def handle_keyboard_exit(result):
+ if result == DialogResult.CANCEL:
+ self._set_state(SetupState.SOFTWARE_SELECTION)
+
+ keyboard = BigInputDialog("custom software URL", confirm_callback=handle_keyboard_result)
+ gui_app.set_modal_overlay(keyboard, callback=handle_keyboard_exit)
+
+ def use_openpilot(self):
+ if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH):
+ os.remove(VALID_CACHE_PATH)
+ with open(TMP_CONTINUE_PATH, "w") as f:
+ f.write(CONTINUE)
+ run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
+ shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
+ shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
+
+ # give time for installer UI to take over
+ time.sleep(0.1)
+ gui_app.request_close()
+ else:
+ self._set_state(SetupState.NETWORK_SETUP)
+
+ def download(self, url: str):
+ # autocomplete incomplete URLs
+ if re.match("^([^/.]+)/([^/]+)$", url):
+ url = f"https://installer.comma.ai/{url}"
+
+ parsed = urlparse(url, scheme='https')
+ self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl()
+
+ self._set_state(SetupState.DOWNLOADING)
+
+ self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
+ self.download_thread.start()
+
+ def _download_thread(self):
+ try:
+ import tempfile
+
+ fd, tmpfile = tempfile.mkstemp(prefix="installer_")
+
+ headers = {"User-Agent": USER_AGENT,
+ "X-openpilot-serial": HARDWARE.get_serial(),
+ "X-openpilot-device-type": HARDWARE.get_device_type()}
+ req = urllib.request.Request(self.download_url, headers=headers)
+
+ with open(tmpfile, 'wb') as f, urllib.request.urlopen(req, timeout=30) as response:
+ total_size = int(response.headers.get('content-length', 0))
+ downloaded = 0
+ block_size = 8192
+
+ while True:
+ buffer = response.read(block_size)
+ if not buffer:
+ break
+
+ downloaded += len(buffer)
+ f.write(buffer)
+
+ if total_size:
+ self.download_progress = int(downloaded * 100 / total_size)
+ self._downloading_page.set_progress(self.download_progress)
+
+ is_elf = False
+ with open(tmpfile, 'rb') as f:
+ header = f.read(4)
+ is_elf = header == b'\x7fELF'
+
+ if not is_elf:
+ self.download_failed(self.download_url, "No custom software found at this URL.")
+ return
+
+ # AGNOS might try to execute the installer before this process exits.
+ # Therefore, important to close the fd before renaming the installer.
+ os.close(fd)
+ os.rename(tmpfile, INSTALLER_DESTINATION_PATH)
+
+ with open(INSTALLER_URL_PATH, "w") as f:
+ f.write(self.download_url)
+
+ # give time for installer UI to take over
+ time.sleep(0.1)
+ gui_app.request_close()
+
+ except urllib.error.HTTPError as e:
+ if e.code == 409:
+ error_msg = "Incompatible openpilot version"
+ self.download_failed(self.download_url, error_msg)
+ except Exception:
+ error_msg = "Invalid URL"
+ self.download_failed(self.download_url, error_msg)
+
+ def download_failed(self, url: str, reason: str):
+ self.failed_url = url
+ self.failed_reason = reason
+ self._download_failed_page.set_reason(reason)
+ self._set_state(SetupState.DOWNLOAD_FAILED)
+
+
+def main():
+ try:
+ gui_app.init_window("Setup")
+ setup = Setup()
+ for should_render in gui_app.render():
+ if should_render:
+ setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+ setup.close()
+ except Exception as e:
+ print(f"Setup error: {e}")
+ finally:
+ gui_app.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/system/ui/mici_updater.py b/system/ui/mici_updater.py
new file mode 100755
index 0000000000..2ae2f7cc19
--- /dev/null
+++ b/system/ui/mici_updater.py
@@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+import sys
+import subprocess
+import threading
+import pyray as rl
+from enum import IntEnum
+
+from openpilot.system.hardware import HARDWARE
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.lib.text_measure import measure_text_cached
+from openpilot.system.ui.lib.wifi_manager import WifiManager, Network
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.label import gui_text_box, gui_label, UnifiedLabel
+from openpilot.system.ui.widgets.button import FullRoundedButton
+from openpilot.system.ui.mici_setup import NetworkSetupPage, FailedPage, NetworkConnectivityMonitor
+
+
+class Screen(IntEnum):
+ PROMPT = 0
+ WIFI = 1
+ PROGRESS = 2
+ FAILED = 3
+
+
+class Updater(Widget):
+ def __init__(self, updater_path, manifest_path):
+ super().__init__()
+ self.updater = updater_path
+ self.manifest = manifest_path
+ self.current_screen = Screen.PROMPT
+ self._current_network_strength = -1
+
+ self.progress_value = 0
+ self.progress_text = "loading"
+ self.process = None
+ self.update_thread = None
+ self._wifi_manager = WifiManager()
+ self._wifi_manager.set_active(True)
+
+ self._network_setup_page = NetworkSetupPage(self._wifi_manager, self._network_setup_continue_callback,
+ self._network_setup_back_callback)
+
+ self._wifi_manager.add_callbacks(networks_updated=self._on_network_updated)
+ self._network_monitor = NetworkConnectivityMonitor()
+ self._network_monitor.start()
+
+ # Buttons
+ self._continue_button = FullRoundedButton("continue")
+ self._continue_button.set_click_callback(lambda: self.set_current_screen(Screen.WIFI))
+
+ self._title_label = UnifiedLabel("update required", 48, text_color=rl.Color(255, 115, 0, 255),
+ font_weight=FontWeight.DISPLAY)
+ self._subtitle_label = UnifiedLabel("The download size is approximately 1GB.", 36,
+ text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ font_weight=FontWeight.ROMAN)
+
+ self._update_failed_page = FailedPage(HARDWARE.reboot, self._update_failed_retry_callback,
+ title="update failed")
+
+ def _network_setup_back_callback(self):
+ self.set_current_screen(Screen.PROMPT)
+
+ def _network_setup_continue_callback(self):
+ self.install_update()
+
+ def _update_failed_retry_callback(self):
+ self.set_current_screen(Screen.PROMPT)
+
+ def _on_network_updated(self, networks: list[Network]):
+ self._current_network_strength = next((net.strength for net in networks if net.is_connected), -1)
+
+ def set_current_screen(self, screen: Screen):
+ if self.current_screen != screen:
+ if screen == Screen.PROGRESS:
+ if self._network_setup_page:
+ self._network_setup_page.hide_event()
+ elif screen == Screen.WIFI:
+ if self._network_setup_page:
+ self._network_setup_page.show_event()
+ elif screen == Screen.PROMPT:
+ if self._network_setup_page:
+ self._network_setup_page.hide_event()
+ elif screen == Screen.FAILED:
+ if self._network_setup_page:
+ self._network_setup_page.hide_event()
+
+ self.current_screen = screen
+
+ def install_update(self):
+ self.set_current_screen(Screen.PROGRESS)
+ self.progress_value = 0
+ self.progress_text = "downloading"
+
+ # Start the update process in a separate thread
+ self.update_thread = threading.Thread(target=self._run_update_process)
+ self.update_thread.daemon = True
+ self.update_thread.start()
+
+ def _run_update_process(self):
+ # TODO: just import it and run in a thread without a subprocess
+ cmd = [self.updater, "--swap", self.manifest]
+ self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ text=True, bufsize=1, universal_newlines=True)
+
+ for line in self.process.stdout:
+ parts = line.strip().split(":")
+ if len(parts) == 2:
+ self.progress_text = parts[0].lower()
+ try:
+ self.progress_value = int(float(parts[1]))
+ except ValueError:
+ pass
+
+ exit_code = self.process.wait()
+ if exit_code == 0:
+ HARDWARE.reboot()
+ else:
+ self.set_current_screen(Screen.FAILED)
+
+ def render_prompt_screen(self, rect: rl.Rectangle):
+ self._title_label.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y - 5,
+ rect.width,
+ 48,
+ ))
+
+ subtitle_width = rect.width - 16
+ subtitle_height = self._subtitle_label.get_content_height(int(subtitle_width))
+ self._subtitle_label.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y + 48,
+ subtitle_width,
+ subtitle_height,
+ ))
+
+ self._continue_button.render(rl.Rectangle(
+ rect.x + 8,
+ rect.y + rect.height - self._continue_button.rect.height,
+ self._continue_button.rect.width,
+ self._continue_button.rect.height,
+ ))
+
+ def render_progress_screen(self, rect: rl.Rectangle):
+ title_rect = rl.Rectangle(self._rect.x + 6, self._rect.y - 5, self._rect.width - 12, self._rect.height - 8)
+ if ' ' in self.progress_text:
+ font_size = 62
+ else:
+ font_size = 82
+ gui_text_box(title_rect, self.progress_text, font_size, font_weight=FontWeight.DISPLAY,
+ color=rl.Color(255, 255, 255, int(255 * 0.9)))
+
+ progress_value = f"{self.progress_value}%"
+ text_height = measure_text_cached(gui_app.font(FontWeight.ROMAN), progress_value, 128).y
+ progress_rect = rl.Rectangle(self._rect.x + 6, self._rect.y + self._rect.height - text_height + 18,
+ self._rect.width - 12, text_height)
+ gui_label(progress_rect, progress_value, 128, font_weight=FontWeight.ROMAN,
+ color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.35)))
+
+ def _update_state(self):
+ self._wifi_manager.process_callbacks()
+
+ def _render(self, rect: rl.Rectangle):
+ if self.current_screen == Screen.PROMPT:
+ self.render_prompt_screen(rect)
+ elif self.current_screen == Screen.WIFI:
+ self._network_setup_page.set_has_internet(self._network_monitor.network_connected.is_set())
+ self._network_setup_page.render(rect)
+ elif self.current_screen == Screen.PROGRESS:
+ self.render_progress_screen(rect)
+ elif self.current_screen == Screen.FAILED:
+ self._update_failed_page.render(rect)
+
+ def close(self):
+ self._network_monitor.stop()
+
+
+def main():
+ if len(sys.argv) < 3:
+ print("Usage: updater.py ")
+ sys.exit(1)
+
+ updater_path = sys.argv[1]
+ manifest_path = sys.argv[2]
+
+ try:
+ gui_app.init_window("System Update")
+ updater = Updater(updater_path, manifest_path)
+ for should_render in gui_app.render():
+ if should_render:
+ updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+ updater.close()
+ except Exception as e:
+ print(f"Updater error: {e}")
+ finally:
+ gui_app.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/system/ui/reset.py b/system/ui/reset.py
index 3922c27aac..c32504a5b8 100755
--- a/system/ui/reset.py
+++ b/system/ui/reset.py
@@ -1,135 +1,14 @@
#!/usr/bin/env python3
-import os
-import sys
-import threading
-import time
-from enum import IntEnum
-
-import pyray as rl
-
-from openpilot.system.hardware import PC
-from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
-from openpilot.system.ui.widgets import Widget
-from openpilot.system.ui.widgets.button import Button, ButtonStyle
-from openpilot.system.ui.widgets.label import gui_label, gui_text_box
-
-USERDATA = "/dev/disk/by-partlabel/userdata"
-TIMEOUT = 3*60
-
-
-class ResetMode(IntEnum):
- USER_RESET = 0 # user initiated a factory reset from openpilot
- RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover
- FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata
-
-
-class ResetState(IntEnum):
- NONE = 0
- CONFIRM = 1
- RESETTING = 2
- FAILED = 3
-
-
-class Reset(Widget):
- def __init__(self, mode):
- super().__init__()
- self._mode = mode
- self._previous_reset_state = None
- self._reset_state = ResetState.NONE
- self._cancel_button = Button("Cancel", self._cancel_callback)
- self._confirm_button = Button("Confirm", self._confirm, button_style=ButtonStyle.PRIMARY)
- self._reboot_button = Button("Reboot", lambda: os.system("sudo reboot"))
- self._render_status = True
-
- def _cancel_callback(self):
- self._render_status = False
-
- def _do_erase(self):
- if PC:
- return
-
- # Removing data and formatting
- rm = os.system("sudo rm -rf /data/*")
- os.system(f"sudo umount {USERDATA}")
- fmt = os.system(f"yes | sudo mkfs.ext4 {USERDATA}")
-
- if rm == 0 or fmt == 0:
- os.system("sudo reboot")
- else:
- self._reset_state = ResetState.FAILED
-
- def start_reset(self):
- self._reset_state = ResetState.RESETTING
- threading.Timer(0.1, self._do_erase).start()
-
- def _update_state(self):
- if self._reset_state != self._previous_reset_state:
- self._previous_reset_state = self._reset_state
- self._timeout_st = time.monotonic()
- elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT:
- exit(0)
-
- def _render(self, rect: rl.Rectangle):
- label_rect = rl.Rectangle(rect.x + 140, rect.y, rect.width - 280, 100 * FONT_SCALE)
- gui_label(label_rect, "System Reset", 100, font_weight=FontWeight.BOLD)
-
- text_rect = rl.Rectangle(rect.x + 140, rect.y + 140, rect.width - 280, rect.height - 90 - 100 * FONT_SCALE)
- gui_text_box(text_rect, self._get_body_text(), 90)
-
- button_height = 160
- button_spacing = 50
- button_top = rect.y + rect.height - button_height
- button_width = (rect.width - button_spacing) / 2.0
-
- if self._reset_state != ResetState.RESETTING:
- if self._mode == ResetMode.RECOVER:
- self._reboot_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height))
- elif self._mode == ResetMode.USER_RESET:
- self._cancel_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height))
-
- if self._reset_state != ResetState.FAILED:
- self._confirm_button.render(rl.Rectangle(rect.x + button_width + 50, button_top, button_width, button_height))
- else:
- self._reboot_button.render(rl.Rectangle(rect.x, button_top, rect.width, button_height))
-
- return self._render_status
-
- def _confirm(self):
- if self._reset_state == ResetState.CONFIRM:
- self.start_reset()
- else:
- self._reset_state = ResetState.CONFIRM
-
- def _get_body_text(self):
- if self._reset_state == ResetState.CONFIRM:
- return "Are you sure you want to reset your device?"
- if self._reset_state == ResetState.RESETTING:
- return "Resetting device...\nThis may take up to a minute."
- if self._reset_state == ResetState.FAILED:
- return "Reset failed. Reboot to try again."
- if self._mode == ResetMode.RECOVER:
- return "Unable to mount data partition. Partition may be corrupted. Press confirm to erase and reset your device."
- return "System reset triggered. Press confirm to erase all content and settings. Press cancel to resume boot."
+from openpilot.system.ui.lib.application import gui_app
+import openpilot.system.ui.tici_reset as tici_reset
+import openpilot.system.ui.mici_reset as mici_reset
def main():
- mode = ResetMode.USER_RESET
- if len(sys.argv) > 1:
- if sys.argv[1] == '--recover':
- mode = ResetMode.RECOVER
- elif sys.argv[1] == "--format":
- mode = ResetMode.FORMAT
-
- gui_app.init_window("System Reset", 20)
- reset = Reset(mode)
-
- if mode == ResetMode.FORMAT:
- reset.start_reset()
-
- for should_render in gui_app.render():
- if should_render:
- if not reset.render(rl.Rectangle(45, 200, gui_app.width - 90, gui_app.height - 245)):
- break
+ if gui_app.big_ui():
+ tici_reset.main()
+ else:
+ mici_reset.main()
if __name__ == "__main__":
diff --git a/system/ui/setup.py b/system/ui/setup.py
index 0045b45417..23ffc26aa2 100755
--- a/system/ui/setup.py
+++ b/system/ui/setup.py
@@ -1,450 +1,14 @@
#!/usr/bin/env python3
-import os
-import re
-import threading
-import time
-import urllib.request
-import urllib.error
-from urllib.parse import urlparse
-from enum import IntEnum
-import shutil
-
-import pyray as rl
-
-from cereal import log
-from openpilot.common.utils import run_cmd
-from openpilot.system.hardware import HARDWARE
-from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
-from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
-from openpilot.system.ui.widgets import Widget
-from openpilot.system.ui.widgets.button import Button, ButtonStyle, ButtonRadio
-from openpilot.system.ui.widgets.keyboard import Keyboard
-from openpilot.system.ui.widgets.label import Label
-from openpilot.system.ui.widgets.network import WifiManagerUI, WifiManager
-
-NetworkType = log.DeviceState.NetworkType
-
-MARGIN = 50
-TITLE_FONT_SIZE = 90
-TITLE_FONT_WEIGHT = FontWeight.MEDIUM
-NEXT_BUTTON_WIDTH = 310
-BODY_FONT_SIZE = 80
-BUTTON_HEIGHT = 160
-BUTTON_SPACING = 50
-
-OPENPILOT_URL = "https://openpilot.comma.ai"
-USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
-
-CONTINUE_PATH = "/data/continue.sh"
-TMP_CONTINUE_PATH = "/data/continue.sh.new"
-INSTALL_PATH = "/data/openpilot"
-VALID_CACHE_PATH = "/data/.openpilot_cache"
-INSTALLER_SOURCE_PATH = "/usr/comma/installer"
-INSTALLER_DESTINATION_PATH = "/tmp/installer"
-INSTALLER_URL_PATH = "/tmp/installer_url"
-
-CONTINUE = """#!/usr/bin/env bash
-
-cd /data/openpilot
-exec ./launch_openpilot.sh
-"""
-
-
-class SetupState(IntEnum):
- LOW_VOLTAGE = 0
- GETTING_STARTED = 1
- NETWORK_SETUP = 2
- SOFTWARE_SELECTION = 3
- CUSTOM_SOFTWARE = 4
- DOWNLOADING = 5
- DOWNLOAD_FAILED = 6
- CUSTOM_SOFTWARE_WARNING = 7
-
-
-class Setup(Widget):
- def __init__(self):
- super().__init__()
- self.state = SetupState.GETTING_STARTED
- self.network_check_thread = None
- self.network_connected = threading.Event()
- self.wifi_connected = threading.Event()
- self.stop_network_check_thread = threading.Event()
- self.failed_url = ""
- self.failed_reason = ""
- self.download_url = ""
- self.download_progress = 0
- self.download_thread = None
- self.wifi_ui = WifiManagerUI(WifiManager())
- self.keyboard = Keyboard()
- self.selected_radio = None
- self.warning = gui_app.texture("icons/warning.png", 150, 150)
- self.checkmark = gui_app.texture("icons/circled_check.png", 100, 100)
-
- self._low_voltage_title_label = Label("WARNING: Low Voltage", TITLE_FONT_SIZE, FontWeight.MEDIUM, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
- text_color=rl.Color(255, 89, 79, 255), text_padding=20)
- self._low_voltage_body_label = Label("Power your device in a car with a harness or proceed at your own risk.", BODY_FONT_SIZE,
- text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
- self._low_voltage_continue_button = Button("Continue", self._low_voltage_continue_button_callback)
- self._low_voltage_poweroff_button = Button("Power Off", HARDWARE.shutdown)
-
- self._getting_started_button = Button("", self._getting_started_button_callback, button_style=ButtonStyle.PRIMARY, border_radius=0)
- self._getting_started_title_label = Label("Getting Started", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
- self._getting_started_body_label = Label("Before we get on the road, let's finish installation and cover some details.",
- BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
-
- self._software_selection_openpilot_button = ButtonRadio("openpilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
- self._software_selection_custom_software_button = ButtonRadio("Custom Software", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
- self._software_selection_continue_button = Button("Continue", self._software_selection_continue_button_callback,
- button_style=ButtonStyle.PRIMARY)
- self._software_selection_continue_button.set_enabled(False)
- self._software_selection_back_button = Button("Back", self._software_selection_back_button_callback)
- self._software_selection_title_label = Label("Choose Software to Use", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
- text_padding=20)
-
- self._download_failed_reboot_button = Button("Reboot device", HARDWARE.reboot)
- self._download_failed_startover_button = Button("Start over", self._download_failed_startover_button_callback, button_style=ButtonStyle.PRIMARY)
- self._download_failed_title_label = Label("Download Failed", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
- self._download_failed_url_label = Label("", 52, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
- self._download_failed_body_label = Label("", BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
-
- self._network_setup_back_button = Button("Back", self._network_setup_back_button_callback)
- self._network_setup_continue_button = Button("Waiting for internet", self._network_setup_continue_button_callback,
- button_style=ButtonStyle.PRIMARY)
- self._network_setup_continue_button.set_enabled(False)
- self._network_setup_title_label = Label("Connect to Wi-Fi", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
-
- self._custom_software_warning_continue_button = Button("Scroll to continue", self._custom_software_warning_continue_button_callback,
- button_style=ButtonStyle.PRIMARY)
- self._custom_software_warning_continue_button.set_enabled(False)
- self._custom_software_warning_back_button = Button("Back", self._custom_software_warning_back_button_callback)
- self._custom_software_warning_title_label = Label("WARNING: Custom Software", 81, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
- text_color=rl.Color(255, 89, 79, 255),
- text_padding=60)
- self._custom_software_warning_body_label = Label("Use caution when installing third-party software.\n\n"
- + "⚠️ It has not been tested by comma.\n\n"
- + "⚠️ It may not comply with relevant safety standards.\n\n"
- + "⚠️ It may cause damage to your device and/or vehicle.\n\n"
- + "If you'd like to proceed, use https://flash.comma.ai "
- + "to restore your device to a factory state later.",
- 68, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=60)
- self._custom_software_warning_body_scroll_panel = GuiScrollPanel()
-
- self._downloading_body_label = Label("Downloading...", TITLE_FONT_SIZE, FontWeight.MEDIUM, text_padding=20)
-
- try:
- with open("/sys/class/hwmon/hwmon1/in1_input") as f:
- voltage = float(f.read().strip()) / 1000.0
- if voltage < 7:
- self.state = SetupState.LOW_VOLTAGE
- except (FileNotFoundError, ValueError):
- self.state = SetupState.LOW_VOLTAGE
-
- def _render(self, rect: rl.Rectangle):
- if self.state == SetupState.LOW_VOLTAGE:
- self.render_low_voltage(rect)
- elif self.state == SetupState.GETTING_STARTED:
- self.render_getting_started(rect)
- elif self.state == SetupState.NETWORK_SETUP:
- self.render_network_setup(rect)
- elif self.state == SetupState.SOFTWARE_SELECTION:
- self.render_software_selection(rect)
- elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING:
- self.render_custom_software_warning(rect)
- elif self.state == SetupState.CUSTOM_SOFTWARE:
- self.render_custom_software()
- elif self.state == SetupState.DOWNLOADING:
- self.render_downloading(rect)
- elif self.state == SetupState.DOWNLOAD_FAILED:
- self.render_download_failed(rect)
-
- def _low_voltage_continue_button_callback(self):
- self.state = SetupState.GETTING_STARTED
-
- def _custom_software_warning_back_button_callback(self):
- self.state = SetupState.SOFTWARE_SELECTION
-
- def _custom_software_warning_continue_button_callback(self):
- self.state = SetupState.NETWORK_SETUP
- self.stop_network_check_thread.clear()
- self.start_network_check()
-
- def _getting_started_button_callback(self):
- self.state = SetupState.SOFTWARE_SELECTION
-
- def _software_selection_back_button_callback(self):
- self.state = SetupState.GETTING_STARTED
-
- def _software_selection_continue_button_callback(self):
- if self._software_selection_openpilot_button.selected:
- self.use_openpilot()
- else:
- self.state = SetupState.CUSTOM_SOFTWARE_WARNING
-
- def _download_failed_startover_button_callback(self):
- self.state = SetupState.GETTING_STARTED
-
- def _network_setup_back_button_callback(self):
- self.state = SetupState.SOFTWARE_SELECTION
-
- def _network_setup_continue_button_callback(self):
- self.stop_network_check_thread.set()
- if self._software_selection_openpilot_button.selected:
- self.download(OPENPILOT_URL)
- else:
- self.state = SetupState.CUSTOM_SOFTWARE
-
- def render_low_voltage(self, rect: rl.Rectangle):
- rl.draw_texture(self.warning, int(rect.x + 150), int(rect.y + 110), rl.WHITE)
-
- self._low_voltage_title_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 100, rect.width - 500 - 150, TITLE_FONT_SIZE * FONT_SCALE))
- self._low_voltage_body_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 150, rect.width - 500, BODY_FONT_SIZE * FONT_SCALE * 3))
-
- button_width = (rect.width - MARGIN * 3) / 2
- button_y = rect.height - MARGIN - BUTTON_HEIGHT
- self._low_voltage_poweroff_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
- self._low_voltage_continue_button.render(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT))
-
- def render_getting_started(self, rect: rl.Rectangle):
- self._getting_started_title_label.render(rl.Rectangle(rect.x + 165, rect.y + 280, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE))
- self._getting_started_body_label.render(rl.Rectangle(rect.x + 165, rect.y + 280 + TITLE_FONT_SIZE * FONT_SCALE, rect.width - 500,
- BODY_FONT_SIZE * FONT_SCALE * 3))
-
- btn_rect = rl.Rectangle(rect.width - NEXT_BUTTON_WIDTH, 0, NEXT_BUTTON_WIDTH, rect.height)
- self._getting_started_button.render(btn_rect)
- triangle = gui_app.texture("images/button_continue_triangle.png", 54, int(btn_rect.height))
- rl.draw_texture_v(triangle, rl.Vector2(btn_rect.x + btn_rect.width / 2 - triangle.width / 2, btn_rect.height / 2 - triangle.height / 2), rl.WHITE)
-
- def check_network_connectivity(self):
- while not self.stop_network_check_thread.is_set():
- if self.state == SetupState.NETWORK_SETUP:
- try:
- urllib.request.urlopen(OPENPILOT_URL, timeout=2)
- self.network_connected.set()
- if HARDWARE.get_network_type() == NetworkType.wifi:
- self.wifi_connected.set()
- else:
- self.wifi_connected.clear()
- except Exception:
- self.network_connected.clear()
- time.sleep(1)
-
- def start_network_check(self):
- if self.network_check_thread is None or not self.network_check_thread.is_alive():
- self.network_check_thread = threading.Thread(target=self.check_network_connectivity, daemon=True)
- self.network_check_thread.start()
-
- def close(self):
- if self.network_check_thread is not None:
- self.stop_network_check_thread.set()
- self.network_check_thread.join()
-
- def render_network_setup(self, rect: rl.Rectangle):
- self._network_setup_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE * FONT_SCALE))
-
- wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN + 25, rect.width - MARGIN * 2,
- rect.height - TITLE_FONT_SIZE * FONT_SCALE - 25 - BUTTON_HEIGHT - MARGIN * 3)
- rl.draw_rectangle_rounded(wifi_rect, 0.05, 10, rl.Color(51, 51, 51, 255))
- wifi_content_rect = rl.Rectangle(wifi_rect.x + MARGIN, wifi_rect.y, wifi_rect.width - MARGIN * 2, wifi_rect.height)
- self.wifi_ui.render(wifi_content_rect)
-
- button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
- button_y = rect.height - BUTTON_HEIGHT - MARGIN
-
- self._network_setup_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
-
- # Check network connectivity status
- continue_enabled = self.network_connected.is_set()
- self._network_setup_continue_button.set_enabled(continue_enabled)
- continue_text = ("Continue" if self.wifi_connected.is_set() else "Continue without Wi-Fi") if continue_enabled else "Waiting for internet"
- self._network_setup_continue_button.set_text(continue_text)
- self._network_setup_continue_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
-
- def render_software_selection(self, rect: rl.Rectangle):
- self._software_selection_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE * FONT_SCALE))
-
- radio_height = 230
- radio_spacing = 30
-
- self._software_selection_continue_button.set_enabled(False)
-
- openpilot_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2, rect.width - MARGIN * 2, radio_height)
- self._software_selection_openpilot_button.render(openpilot_rect)
-
- if self._software_selection_openpilot_button.selected:
- self._software_selection_continue_button.set_enabled(True)
- self._software_selection_custom_software_button.selected = False
-
- custom_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2 + radio_height + radio_spacing, rect.width - MARGIN * 2,
- radio_height)
- self._software_selection_custom_software_button.render(custom_rect)
-
- if self._software_selection_custom_software_button.selected:
- self._software_selection_continue_button.set_enabled(True)
- self._software_selection_openpilot_button.selected = False
-
- button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
- button_y = rect.height - BUTTON_HEIGHT - MARGIN
-
- self._software_selection_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
- self._software_selection_continue_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
-
- def render_downloading(self, rect: rl.Rectangle):
- self._downloading_body_label.render(rl.Rectangle(rect.x, rect.y + rect.height / 2 - TITLE_FONT_SIZE * FONT_SCALE / 2, rect.width,
- TITLE_FONT_SIZE * FONT_SCALE))
-
- def render_download_failed(self, rect: rl.Rectangle):
- self._download_failed_title_label.render(rl.Rectangle(rect.x + 117, rect.y + 185, rect.width - 117, TITLE_FONT_SIZE * FONT_SCALE))
- self._download_failed_url_label.set_text(self.failed_url)
- self._download_failed_url_label.render(rl.Rectangle(rect.x + 117, rect.y + 185 + TITLE_FONT_SIZE * FONT_SCALE + 67, rect.width - 117 - 100, 64))
-
- self._download_failed_body_label.set_text(self.failed_reason)
- self._download_failed_body_label.render(rl.Rectangle(rect.x + 117, rect.y, rect.width - 117 - 100, rect.height))
-
- button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
- button_y = rect.height - BUTTON_HEIGHT - MARGIN
- self._download_failed_reboot_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
- self._download_failed_startover_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
-
- def render_custom_software_warning(self, rect: rl.Rectangle):
- warn_rect = rl.Rectangle(rect.x, rect.y, rect.width, 1500)
- offset = self._custom_software_warning_body_scroll_panel.update(rect, warn_rect)
-
- button_width = (rect.width - MARGIN * 3) / 2
- button_y = rect.height - MARGIN - BUTTON_HEIGHT
-
- rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(button_y - BODY_FONT_SIZE * FONT_SCALE))
- y_offset = rect.y + offset
- self._custom_software_warning_title_label.render(rl.Rectangle(rect.x + 50, y_offset + 150, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE))
- self._custom_software_warning_body_label.render(rl.Rectangle(rect.x + 50, y_offset + 200, rect.width - 50, BODY_FONT_SIZE * FONT_SCALE * 3))
- rl.end_scissor_mode()
-
- self._custom_software_warning_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
- self._custom_software_warning_continue_button.render(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT))
- if offset < (rect.height - warn_rect.height):
- self._custom_software_warning_continue_button.set_enabled(True)
- self._custom_software_warning_continue_button.set_text("Continue")
-
- def render_custom_software(self):
- def handle_keyboard_result(result):
- # Enter pressed
- if result == 1:
- url = self.keyboard.text
- self.keyboard.clear()
- if url:
- self.download(url)
-
- # Cancel pressed
- elif result == 0:
- self.state = SetupState.SOFTWARE_SELECTION
-
- self.keyboard.reset(min_text_size=1)
- self.keyboard.set_title("Enter URL", "for Custom Software")
- gui_app.set_modal_overlay(self.keyboard, callback=handle_keyboard_result)
-
- def use_openpilot(self):
- if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH):
- os.remove(VALID_CACHE_PATH)
- with open(TMP_CONTINUE_PATH, "w") as f:
- f.write(CONTINUE)
- run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
- shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
- shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
-
- # give time for installer UI to take over
- time.sleep(0.1)
- gui_app.request_close()
- else:
- self.state = SetupState.NETWORK_SETUP
- self.stop_network_check_thread.clear()
- self.start_network_check()
-
- def download(self, url: str):
- # autocomplete incomplete URLs
- if re.match("^([^/.]+)/([^/]+)$", url):
- url = f"https://installer.comma.ai/{url}"
-
- parsed = urlparse(url, scheme='https')
- self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl()
-
- self.state = SetupState.DOWNLOADING
-
- self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
- self.download_thread.start()
-
- def _download_thread(self):
- try:
- import tempfile
-
- fd, tmpfile = tempfile.mkstemp(prefix="installer_")
-
- headers = {"User-Agent": USER_AGENT,
- "X-openpilot-serial": HARDWARE.get_serial(),
- "X-openpilot-device-type": HARDWARE.get_device_type()}
- req = urllib.request.Request(self.download_url, headers=headers)
-
- with open(tmpfile, 'wb') as f, urllib.request.urlopen(req, timeout=30) as response:
- total_size = int(response.headers.get('content-length', 0))
- downloaded = 0
- block_size = 8192
-
- while True:
- buffer = response.read(block_size)
- if not buffer:
- break
-
- downloaded += len(buffer)
- f.write(buffer)
-
- if total_size:
- self.download_progress = int(downloaded * 100 / total_size)
-
- is_elf = False
- with open(tmpfile, 'rb') as f:
- header = f.read(4)
- is_elf = header == b'\x7fELF'
-
- if not is_elf:
- self.download_failed(self.download_url, "No custom software found at this URL.")
- return
-
- # AGNOS might try to execute the installer before this process exits.
- # Therefore, important to close the fd before renaming the installer.
- os.close(fd)
- os.rename(tmpfile, INSTALLER_DESTINATION_PATH)
-
- with open(INSTALLER_URL_PATH, "w") as f:
- f.write(self.download_url)
-
- # give time for installer UI to take over
- time.sleep(0.1)
- gui_app.request_close()
-
- except urllib.error.HTTPError as e:
- if e.code == 409:
- error_msg = e.read().decode("utf-8")
- self.download_failed(self.download_url, error_msg)
- except Exception:
- error_msg = "Ensure the entered URL is valid, and the device's internet connection is good."
- self.download_failed(self.download_url, error_msg)
-
- def download_failed(self, url: str, reason: str):
- self.failed_url = url
- self.failed_reason = reason
- self.state = SetupState.DOWNLOAD_FAILED
+from openpilot.system.ui.lib.application import gui_app
+import openpilot.system.ui.tici_setup as tici_setup
+import openpilot.system.ui.mici_setup as mici_setup
def main():
- try:
- gui_app.init_window("Setup", 20)
- setup = Setup()
- for should_render in gui_app.render():
- if should_render:
- setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
- setup.close()
- except Exception as e:
- print(f"Setup error: {e}")
- finally:
- gui_app.close()
+ if gui_app.big_ui():
+ tici_setup.main()
+ else:
+ mici_setup.main()
if __name__ == "__main__":
diff --git a/system/ui/spinner.py b/system/ui/spinner.py
index dd7fadc538..33f4543c3e 100755
--- a/system/ui/spinner.py
+++ b/system/ui/spinner.py
@@ -8,14 +8,21 @@ from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.text import wrap_text
from openpilot.system.ui.widgets import Widget
-from openpilot.system.ui.sunnypilot.lib.application import gui_app_sp
-
# Constants
-PROGRESS_BAR_WIDTH = 1000
-PROGRESS_BAR_HEIGHT = 20
+if gui_app.big_ui():
+ PROGRESS_BAR_WIDTH = 1000
+ PROGRESS_BAR_HEIGHT = 20
+ TEXTURE_SIZE = 360
+ WRAPPED_SPACING = 50
+ CENTERED_SPACING = 150
+else:
+ PROGRESS_BAR_WIDTH = 268
+ PROGRESS_BAR_HEIGHT = 10
+ TEXTURE_SIZE = 140
+ WRAPPED_SPACING = 10
+ CENTERED_SPACING = 20
DEGREES_PER_SECOND = 360.0 # one full rotation per second
MARGIN_H = 100
-TEXTURE_SIZE = 360
FONT_SIZE = 96
LINE_HEIGHT = 104
DARKGRAY = (55, 55, 55, 255)
@@ -28,7 +35,7 @@ def clamp(value, min_value, max_value):
class Spinner(Widget):
def __init__(self):
super().__init__()
- self._comma_texture = gui_app_sp.sp_texture("images/spinner_sunnypilot.png", TEXTURE_SIZE, TEXTURE_SIZE)
+ self._comma_texture = gui_app.texture("../../sunnypilot/selfdrive/assets/images/spinner_sunnypilot.png", TEXTURE_SIZE, TEXTURE_SIZE)
self._spinner_texture = gui_app.texture("images/spinner_track.png", TEXTURE_SIZE, TEXTURE_SIZE, alpha_premultiply=True)
self._rotation = 0.0
self._progress: int | None = None
@@ -45,12 +52,12 @@ class Spinner(Widget):
def _render(self, rect: rl.Rectangle):
if self._wrapped_lines:
# Calculate total height required for spinner and text
- spacing = 50
+ spacing = WRAPPED_SPACING
total_height = TEXTURE_SIZE + spacing + len(self._wrapped_lines) * LINE_HEIGHT
center_y = (rect.height - total_height) / 2.0 + TEXTURE_SIZE / 2.0
else:
# Center spinner vertically
- spacing = 150
+ spacing = CENTERED_SPACING
center_y = rect.height / 2.0
y_pos = center_y + TEXTURE_SIZE / 2.0 + spacing
diff --git a/system/ui/sunnypilot/lib/application.py b/system/ui/sunnypilot/lib/application.py
deleted file mode 100644
index 7440d224ca..0000000000
--- a/system/ui/sunnypilot/lib/application.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from openpilot.system.ui.lib.application import GuiApplication
-from importlib.resources import as_file, files
-
-ASSETS_DIR_SP = files("openpilot.sunnypilot.selfdrive").joinpath("assets")
-
-
-class GuiApplicationSP(GuiApplication):
-
- def __init__(self, width: int, height: int):
- super().__init__(width, height)
-
- def sp_texture(self, asset_path: str, width: int, height: int, alpha_premultiply=False, keep_aspect_ratio=True):
- cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}"
- if cache_key in self._textures:
- return self._textures[cache_key]
-
- with as_file(ASSETS_DIR_SP.joinpath(asset_path)) as fspath:
- image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio)
- texture_obj = self._load_texture_from_image(image_obj)
- self._textures[cache_key] = texture_obj
- return texture_obj
-
-
-gui_app_sp = GuiApplicationSP(2160, 1080)
diff --git a/system/ui/text.py b/system/ui/text.py
index 707b30983b..17e8a507cb 100755
--- a/system/ui/text.py
+++ b/system/ui/text.py
@@ -3,17 +3,24 @@ import re
import sys
import pyray as rl
from openpilot.system.hardware import HARDWARE, PC
-from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.lib.application import BIG_UI, gui_app
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
-MARGIN = 50
-SPACING = 40
-FONT_SIZE = 72
-LINE_HEIGHT = 80
-BUTTON_SIZE = rl.Vector2(310, 160)
+if BIG_UI:
+ MARGIN = 50
+ SPACING = 40
+ FONT_SIZE = 72
+ LINE_HEIGHT = 80
+ BUTTON_SIZE = rl.Vector2(310, 160)
+else:
+ MARGIN = 20
+ SPACING = 30
+ FONT_SIZE = 25
+ LINE_HEIGHT = 25
+ BUTTON_SIZE = rl.Vector2(150, 80)
DEMO_TEXT = """This is a sample text that will be wrapped and scrolled if necessary.
The text is long enough to demonstrate scrolling and word wrapping.""" * 30
@@ -31,7 +38,7 @@ def wrap_text(text, font_size, max_width):
continue
indent = re.match(r"^\s*", paragraph).group()
current_line = indent
- words = re.split(r"(\s+)", paragraph[len(indent):])
+ words = re.split(r"(\s+|-)", paragraph[len(indent):])
while len(words):
word = words.pop(0)
test_line = current_line + word + (words.pop(0) if words else "")
@@ -57,7 +64,7 @@ class TextWindow(Widget):
self._scroll_panel._offset_filter_y.x = -max(self._content_rect.height - self._textarea_rect.height, 0)
button_text = "Exit" if PC else "Reboot"
- self._button = Button(button_text, click_callback=self._on_button_clicked, button_style=ButtonStyle.TRANSPARENT_WHITE_BORDER)
+ self._button = Button(button_text, click_callback=self._on_button_clicked, button_style=ButtonStyle.TRANSPARENT_WHITE_BORDER, font_size=FONT_SIZE)
@staticmethod
def _on_button_clicked():
diff --git a/system/ui/tici_reset.py b/system/ui/tici_reset.py
new file mode 100755
index 0000000000..3922c27aac
--- /dev/null
+++ b/system/ui/tici_reset.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+import os
+import sys
+import threading
+import time
+from enum import IntEnum
+
+import pyray as rl
+
+from openpilot.system.hardware import PC
+from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.button import Button, ButtonStyle
+from openpilot.system.ui.widgets.label import gui_label, gui_text_box
+
+USERDATA = "/dev/disk/by-partlabel/userdata"
+TIMEOUT = 3*60
+
+
+class ResetMode(IntEnum):
+ USER_RESET = 0 # user initiated a factory reset from openpilot
+ RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover
+ FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata
+
+
+class ResetState(IntEnum):
+ NONE = 0
+ CONFIRM = 1
+ RESETTING = 2
+ FAILED = 3
+
+
+class Reset(Widget):
+ def __init__(self, mode):
+ super().__init__()
+ self._mode = mode
+ self._previous_reset_state = None
+ self._reset_state = ResetState.NONE
+ self._cancel_button = Button("Cancel", self._cancel_callback)
+ self._confirm_button = Button("Confirm", self._confirm, button_style=ButtonStyle.PRIMARY)
+ self._reboot_button = Button("Reboot", lambda: os.system("sudo reboot"))
+ self._render_status = True
+
+ def _cancel_callback(self):
+ self._render_status = False
+
+ def _do_erase(self):
+ if PC:
+ return
+
+ # Removing data and formatting
+ rm = os.system("sudo rm -rf /data/*")
+ os.system(f"sudo umount {USERDATA}")
+ fmt = os.system(f"yes | sudo mkfs.ext4 {USERDATA}")
+
+ if rm == 0 or fmt == 0:
+ os.system("sudo reboot")
+ else:
+ self._reset_state = ResetState.FAILED
+
+ def start_reset(self):
+ self._reset_state = ResetState.RESETTING
+ threading.Timer(0.1, self._do_erase).start()
+
+ def _update_state(self):
+ if self._reset_state != self._previous_reset_state:
+ self._previous_reset_state = self._reset_state
+ self._timeout_st = time.monotonic()
+ elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT:
+ exit(0)
+
+ def _render(self, rect: rl.Rectangle):
+ label_rect = rl.Rectangle(rect.x + 140, rect.y, rect.width - 280, 100 * FONT_SCALE)
+ gui_label(label_rect, "System Reset", 100, font_weight=FontWeight.BOLD)
+
+ text_rect = rl.Rectangle(rect.x + 140, rect.y + 140, rect.width - 280, rect.height - 90 - 100 * FONT_SCALE)
+ gui_text_box(text_rect, self._get_body_text(), 90)
+
+ button_height = 160
+ button_spacing = 50
+ button_top = rect.y + rect.height - button_height
+ button_width = (rect.width - button_spacing) / 2.0
+
+ if self._reset_state != ResetState.RESETTING:
+ if self._mode == ResetMode.RECOVER:
+ self._reboot_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height))
+ elif self._mode == ResetMode.USER_RESET:
+ self._cancel_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height))
+
+ if self._reset_state != ResetState.FAILED:
+ self._confirm_button.render(rl.Rectangle(rect.x + button_width + 50, button_top, button_width, button_height))
+ else:
+ self._reboot_button.render(rl.Rectangle(rect.x, button_top, rect.width, button_height))
+
+ return self._render_status
+
+ def _confirm(self):
+ if self._reset_state == ResetState.CONFIRM:
+ self.start_reset()
+ else:
+ self._reset_state = ResetState.CONFIRM
+
+ def _get_body_text(self):
+ if self._reset_state == ResetState.CONFIRM:
+ return "Are you sure you want to reset your device?"
+ if self._reset_state == ResetState.RESETTING:
+ return "Resetting device...\nThis may take up to a minute."
+ if self._reset_state == ResetState.FAILED:
+ return "Reset failed. Reboot to try again."
+ if self._mode == ResetMode.RECOVER:
+ return "Unable to mount data partition. Partition may be corrupted. Press confirm to erase and reset your device."
+ return "System reset triggered. Press confirm to erase all content and settings. Press cancel to resume boot."
+
+
+def main():
+ mode = ResetMode.USER_RESET
+ if len(sys.argv) > 1:
+ if sys.argv[1] == '--recover':
+ mode = ResetMode.RECOVER
+ elif sys.argv[1] == "--format":
+ mode = ResetMode.FORMAT
+
+ gui_app.init_window("System Reset", 20)
+ reset = Reset(mode)
+
+ if mode == ResetMode.FORMAT:
+ reset.start_reset()
+
+ for should_render in gui_app.render():
+ if should_render:
+ if not reset.render(rl.Rectangle(45, 200, gui_app.width - 90, gui_app.height - 245)):
+ break
+
+
+if __name__ == "__main__":
+ main()
diff --git a/system/ui/tici_setup.py b/system/ui/tici_setup.py
new file mode 100755
index 0000000000..bf64361bed
--- /dev/null
+++ b/system/ui/tici_setup.py
@@ -0,0 +1,451 @@
+#!/usr/bin/env python3
+import os
+import re
+import threading
+import time
+import urllib.request
+import urllib.error
+from urllib.parse import urlparse
+from enum import IntEnum
+import shutil
+
+import pyray as rl
+
+from cereal import log
+from openpilot.common.utils import run_cmd
+from openpilot.system.hardware import HARDWARE
+from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
+from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.button import Button, ButtonStyle, ButtonRadio
+from openpilot.system.ui.widgets.keyboard import Keyboard
+from openpilot.system.ui.widgets.label import Label
+from openpilot.system.ui.widgets.network import WifiManagerUI, WifiManager
+
+NetworkType = log.DeviceState.NetworkType
+
+MARGIN = 50
+TITLE_FONT_SIZE = 90
+TITLE_FONT_WEIGHT = FontWeight.MEDIUM
+NEXT_BUTTON_WIDTH = 310
+BODY_FONT_SIZE = 80
+BUTTON_HEIGHT = 160
+BUTTON_SPACING = 50
+
+OPENPILOT_URL = "https://openpilot.comma.ai"
+USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
+
+CONTINUE_PATH = "/data/continue.sh"
+TMP_CONTINUE_PATH = "/data/continue.sh.new"
+INSTALL_PATH = "/data/openpilot"
+VALID_CACHE_PATH = "/data/.openpilot_cache"
+INSTALLER_SOURCE_PATH = "/usr/comma/installer"
+INSTALLER_DESTINATION_PATH = "/tmp/installer"
+INSTALLER_URL_PATH = "/tmp/installer_url"
+
+CONTINUE = """#!/usr/bin/env bash
+
+cd /data/openpilot
+exec ./launch_openpilot.sh
+"""
+
+
+class SetupState(IntEnum):
+ LOW_VOLTAGE = 0
+ GETTING_STARTED = 1
+ NETWORK_SETUP = 2
+ SOFTWARE_SELECTION = 3
+ CUSTOM_SOFTWARE = 4
+ DOWNLOADING = 5
+ DOWNLOAD_FAILED = 6
+ CUSTOM_SOFTWARE_WARNING = 7
+
+
+class Setup(Widget):
+ def __init__(self):
+ super().__init__()
+ self.state = SetupState.GETTING_STARTED
+ self.network_check_thread = None
+ self.network_connected = threading.Event()
+ self.wifi_connected = threading.Event()
+ self.stop_network_check_thread = threading.Event()
+ self.failed_url = ""
+ self.failed_reason = ""
+ self.download_url = ""
+ self.download_progress = 0
+ self.download_thread = None
+ self.wifi_ui = WifiManagerUI(WifiManager())
+ self.keyboard = Keyboard()
+ self.selected_radio = None
+ self.warning = gui_app.texture("icons/warning.png", 150, 150)
+ self.checkmark = gui_app.texture("icons/circled_check.png", 100, 100)
+
+ self._low_voltage_title_label = Label("WARNING: Low Voltage", TITLE_FONT_SIZE, FontWeight.MEDIUM, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ text_color=rl.Color(255, 89, 79, 255), text_padding=20)
+ self._low_voltage_body_label = Label("Power your device in a car with a harness or proceed at your own risk.", BODY_FONT_SIZE,
+ text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
+ self._low_voltage_continue_button = Button("Continue", self._low_voltage_continue_button_callback)
+ self._low_voltage_poweroff_button = Button("Power Off", HARDWARE.shutdown)
+
+ self._getting_started_button = Button("", self._getting_started_button_callback, button_style=ButtonStyle.PRIMARY, border_radius=0)
+ self._getting_started_title_label = Label("Getting Started", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
+ self._getting_started_body_label = Label("Before we get on the road, let's finish installation and cover some details.",
+ BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
+
+ self._software_selection_openpilot_button = ButtonRadio("openpilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
+ self._software_selection_custom_software_button = ButtonRadio("Custom Software", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80)
+ self._software_selection_continue_button = Button("Continue", self._software_selection_continue_button_callback,
+ button_style=ButtonStyle.PRIMARY)
+ self._software_selection_continue_button.set_enabled(False)
+ self._software_selection_back_button = Button("Back", self._software_selection_back_button_callback)
+ self._software_selection_title_label = Label("Choose Software to Use", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ text_padding=20)
+
+ self._download_failed_reboot_button = Button("Reboot device", HARDWARE.reboot)
+ self._download_failed_startover_button = Button("Start over", self._download_failed_startover_button_callback, button_style=ButtonStyle.PRIMARY)
+ self._download_failed_title_label = Label("Download Failed", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
+ self._download_failed_url_label = Label("", 52, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
+ self._download_failed_body_label = Label("", BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
+
+ self._network_setup_back_button = Button("Back", self._network_setup_back_button_callback)
+ self._network_setup_continue_button = Button("Waiting for internet", self._network_setup_continue_button_callback,
+ button_style=ButtonStyle.PRIMARY)
+ self._network_setup_continue_button.set_enabled(False)
+ self._network_setup_title_label = Label("Connect to Wi-Fi", TITLE_FONT_SIZE, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
+
+ self._custom_software_warning_continue_button = Button("Scroll to continue", self._custom_software_warning_continue_button_callback,
+ button_style=ButtonStyle.PRIMARY)
+ self._custom_software_warning_continue_button.set_enabled(False)
+ self._custom_software_warning_back_button = Button("Back", self._custom_software_warning_back_button_callback)
+ self._custom_software_warning_title_label = Label("WARNING: Custom Software", 81, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ text_color=rl.Color(255, 89, 79, 255),
+ text_padding=60)
+ self._custom_software_warning_body_label = Label("Use caution when installing third-party software.\n\n"
+ + "⚠️ It has not been tested by comma.\n\n"
+ + "⚠️ It may not comply with relevant safety standards.\n\n"
+ + "⚠️ It may cause damage to your device and/or vehicle.\n\n"
+ + "If you'd like to proceed, use https://flash.comma.ai "
+ + "to restore your device to a factory state later.",
+ 68, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=60)
+ self._custom_software_warning_body_scroll_panel = GuiScrollPanel()
+
+ self._downloading_body_label = Label("Downloading...", TITLE_FONT_SIZE, FontWeight.MEDIUM, text_padding=20)
+
+ try:
+ with open("/sys/class/hwmon/hwmon1/in1_input") as f:
+ voltage = float(f.read().strip()) / 1000.0
+ if voltage < 7:
+ self.state = SetupState.LOW_VOLTAGE
+ except (FileNotFoundError, ValueError):
+ self.state = SetupState.LOW_VOLTAGE
+
+ def _render(self, rect: rl.Rectangle):
+ if self.state == SetupState.LOW_VOLTAGE:
+ self.render_low_voltage(rect)
+ elif self.state == SetupState.GETTING_STARTED:
+ self.render_getting_started(rect)
+ elif self.state == SetupState.NETWORK_SETUP:
+ self.render_network_setup(rect)
+ elif self.state == SetupState.SOFTWARE_SELECTION:
+ self.render_software_selection(rect)
+ elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING:
+ self.render_custom_software_warning(rect)
+ elif self.state == SetupState.CUSTOM_SOFTWARE:
+ self.render_custom_software()
+ elif self.state == SetupState.DOWNLOADING:
+ self.render_downloading(rect)
+ elif self.state == SetupState.DOWNLOAD_FAILED:
+ self.render_download_failed(rect)
+
+ def _low_voltage_continue_button_callback(self):
+ self.state = SetupState.GETTING_STARTED
+
+ def _custom_software_warning_back_button_callback(self):
+ self.state = SetupState.SOFTWARE_SELECTION
+
+ def _custom_software_warning_continue_button_callback(self):
+ self.state = SetupState.NETWORK_SETUP
+ self.stop_network_check_thread.clear()
+ self.start_network_check()
+
+ def _getting_started_button_callback(self):
+ self.state = SetupState.SOFTWARE_SELECTION
+
+ def _software_selection_back_button_callback(self):
+ self.state = SetupState.GETTING_STARTED
+
+ def _software_selection_continue_button_callback(self):
+ if self._software_selection_openpilot_button.selected:
+ self.use_openpilot()
+ else:
+ self.state = SetupState.CUSTOM_SOFTWARE_WARNING
+
+ def _download_failed_startover_button_callback(self):
+ self.state = SetupState.GETTING_STARTED
+
+ def _network_setup_back_button_callback(self):
+ self.state = SetupState.SOFTWARE_SELECTION
+
+ def _network_setup_continue_button_callback(self):
+ self.stop_network_check_thread.set()
+ if self._software_selection_openpilot_button.selected:
+ self.download(OPENPILOT_URL)
+ else:
+ self.state = SetupState.CUSTOM_SOFTWARE
+
+ def render_low_voltage(self, rect: rl.Rectangle):
+ rl.draw_texture(self.warning, int(rect.x + 150), int(rect.y + 110), rl.WHITE)
+
+ self._low_voltage_title_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 100, rect.width - 500 - 150, TITLE_FONT_SIZE * FONT_SCALE))
+ self._low_voltage_body_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 150, rect.width - 500, BODY_FONT_SIZE * FONT_SCALE * 3))
+
+ button_width = (rect.width - MARGIN * 3) / 2
+ button_y = rect.height - MARGIN - BUTTON_HEIGHT
+ self._low_voltage_poweroff_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
+ self._low_voltage_continue_button.render(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT))
+
+ def render_getting_started(self, rect: rl.Rectangle):
+ self._getting_started_title_label.render(rl.Rectangle(rect.x + 165, rect.y + 280, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE))
+ self._getting_started_body_label.render(rl.Rectangle(rect.x + 165, rect.y + 280 + TITLE_FONT_SIZE * FONT_SCALE, rect.width - 500,
+ BODY_FONT_SIZE * FONT_SCALE * 3))
+
+ btn_rect = rl.Rectangle(rect.width - NEXT_BUTTON_WIDTH, 0, NEXT_BUTTON_WIDTH, rect.height)
+ self._getting_started_button.render(btn_rect)
+ triangle = gui_app.texture("images/button_continue_triangle.png", 54, int(btn_rect.height))
+ rl.draw_texture_v(triangle, rl.Vector2(btn_rect.x + btn_rect.width / 2 - triangle.width / 2, btn_rect.height / 2 - triangle.height / 2), rl.WHITE)
+
+ def check_network_connectivity(self):
+ while not self.stop_network_check_thread.is_set():
+ if self.state == SetupState.NETWORK_SETUP:
+ try:
+ urllib.request.urlopen(OPENPILOT_URL, timeout=2)
+ self.network_connected.set()
+ if HARDWARE.get_network_type() == NetworkType.wifi:
+ self.wifi_connected.set()
+ else:
+ self.wifi_connected.clear()
+ except Exception:
+ self.network_connected.clear()
+ time.sleep(1)
+
+ def start_network_check(self):
+ if self.network_check_thread is None or not self.network_check_thread.is_alive():
+ self.network_check_thread = threading.Thread(target=self.check_network_connectivity, daemon=True)
+ self.network_check_thread.start()
+
+ def close(self):
+ if self.network_check_thread is not None:
+ self.stop_network_check_thread.set()
+ self.network_check_thread.join()
+
+ def render_network_setup(self, rect: rl.Rectangle):
+ self._network_setup_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE * FONT_SCALE))
+
+ wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN + 25, rect.width - MARGIN * 2,
+ rect.height - TITLE_FONT_SIZE * FONT_SCALE - 25 - BUTTON_HEIGHT - MARGIN * 3)
+ rl.draw_rectangle_rounded(wifi_rect, 0.05, 10, rl.Color(51, 51, 51, 255))
+ wifi_content_rect = rl.Rectangle(wifi_rect.x + MARGIN, wifi_rect.y, wifi_rect.width - MARGIN * 2, wifi_rect.height)
+ self.wifi_ui.render(wifi_content_rect)
+
+ button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
+ button_y = rect.height - BUTTON_HEIGHT - MARGIN
+
+ self._network_setup_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
+
+ # Check network connectivity status
+ continue_enabled = self.network_connected.is_set()
+ self._network_setup_continue_button.set_enabled(continue_enabled)
+ continue_text = ("Continue" if self.wifi_connected.is_set() else "Continue without Wi-Fi") if continue_enabled else "Waiting for internet"
+ self._network_setup_continue_button.set_text(continue_text)
+ self._network_setup_continue_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
+
+ def render_software_selection(self, rect: rl.Rectangle):
+ self._software_selection_title_label.render(rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE * FONT_SCALE))
+
+ radio_height = 230
+ radio_spacing = 30
+
+ self._software_selection_continue_button.set_enabled(False)
+
+ openpilot_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2, rect.width - MARGIN * 2, radio_height)
+ self._software_selection_openpilot_button.render(openpilot_rect)
+
+ if self._software_selection_openpilot_button.selected:
+ self._software_selection_continue_button.set_enabled(True)
+ self._software_selection_custom_software_button.selected = False
+
+ custom_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE * FONT_SCALE + MARGIN * 2 + radio_height + radio_spacing, rect.width - MARGIN * 2,
+ radio_height)
+ self._software_selection_custom_software_button.render(custom_rect)
+
+ if self._software_selection_custom_software_button.selected:
+ self._software_selection_continue_button.set_enabled(True)
+ self._software_selection_openpilot_button.selected = False
+
+ button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
+ button_y = rect.height - BUTTON_HEIGHT - MARGIN
+
+ self._software_selection_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
+ self._software_selection_continue_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
+
+ def render_downloading(self, rect: rl.Rectangle):
+ self._downloading_body_label.render(rl.Rectangle(rect.x, rect.y + rect.height / 2 - TITLE_FONT_SIZE * FONT_SCALE / 2, rect.width,
+ TITLE_FONT_SIZE * FONT_SCALE))
+
+ def render_download_failed(self, rect: rl.Rectangle):
+ self._download_failed_title_label.render(rl.Rectangle(rect.x + 117, rect.y + 185, rect.width - 117, TITLE_FONT_SIZE * FONT_SCALE))
+ self._download_failed_url_label.set_text(self.failed_url)
+ self._download_failed_url_label.render(rl.Rectangle(rect.x + 117, rect.y + 185 + TITLE_FONT_SIZE * FONT_SCALE + 67, rect.width - 117 - 100, 64))
+
+ self._download_failed_body_label.set_text(self.failed_reason)
+ self._download_failed_body_label.render(rl.Rectangle(rect.x + 117, rect.y, rect.width - 117 - 100, rect.height))
+
+ button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
+ button_y = rect.height - BUTTON_HEIGHT - MARGIN
+ self._download_failed_reboot_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
+ self._download_failed_startover_button.render(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT))
+
+ def render_custom_software_warning(self, rect: rl.Rectangle):
+ warn_rect = rl.Rectangle(rect.x, rect.y, rect.width, 1500)
+ offset = self._custom_software_warning_body_scroll_panel.update(rect, warn_rect)
+
+ button_width = (rect.width - MARGIN * 3) / 2
+ button_y = rect.height - MARGIN - BUTTON_HEIGHT
+
+ rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(button_y - BODY_FONT_SIZE * FONT_SCALE))
+ y_offset = rect.y + offset
+ self._custom_software_warning_title_label.render(rl.Rectangle(rect.x + 50, y_offset + 150, rect.width - 265, TITLE_FONT_SIZE * FONT_SCALE))
+ self._custom_software_warning_body_label.render(rl.Rectangle(rect.x + 50, y_offset + 400, rect.width - 50, BODY_FONT_SIZE * FONT_SCALE * 3))
+ rl.end_scissor_mode()
+
+ self._custom_software_warning_back_button.render(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT))
+ self._custom_software_warning_continue_button.render(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT))
+ if offset < (rect.height - warn_rect.height):
+ self._custom_software_warning_continue_button.set_enabled(True)
+ self._custom_software_warning_continue_button.set_text("Continue")
+
+ def render_custom_software(self):
+ def handle_keyboard_result(result):
+ # Enter pressed
+ if result == 1:
+ url = self.keyboard.text
+ self.keyboard.clear()
+ if url:
+ self.download(url)
+
+ # Cancel pressed
+ elif result == 0:
+ self.state = SetupState.SOFTWARE_SELECTION
+
+ self.keyboard.reset(min_text_size=1)
+ self.keyboard.set_title("Enter URL", "for Custom Software")
+ gui_app.set_modal_overlay(self.keyboard, callback=handle_keyboard_result)
+
+ def use_openpilot(self):
+ if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH):
+ os.remove(VALID_CACHE_PATH)
+ with open(TMP_CONTINUE_PATH, "w") as f:
+ f.write(CONTINUE)
+ run_cmd(["chmod", "+x", TMP_CONTINUE_PATH])
+ shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH)
+ shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH)
+
+ # give time for installer UI to take over
+ time.sleep(0.1)
+ gui_app.request_close()
+ else:
+ self.state = SetupState.NETWORK_SETUP
+ self.stop_network_check_thread.clear()
+ self.start_network_check()
+
+ def download(self, url: str):
+ # autocomplete incomplete URLs
+ if re.match("^([^/.]+)/([^/]+)$", url):
+ url = f"https://installer.comma.ai/{url}"
+
+ parsed = urlparse(url, scheme='https')
+ self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl()
+
+ self.state = SetupState.DOWNLOADING
+
+ self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
+ self.download_thread.start()
+
+ def _download_thread(self):
+ try:
+ import tempfile
+
+ fd, tmpfile = tempfile.mkstemp(prefix="installer_")
+
+ headers = {"User-Agent": USER_AGENT,
+ "X-openpilot-serial": HARDWARE.get_serial(),
+ "X-openpilot-device-type": HARDWARE.get_device_type()}
+ req = urllib.request.Request(self.download_url, headers=headers)
+
+ with open(tmpfile, 'wb') as f, urllib.request.urlopen(req, timeout=30) as response:
+ total_size = int(response.headers.get('content-length', 0))
+ downloaded = 0
+ block_size = 8192
+
+ while True:
+ buffer = response.read(block_size)
+ if not buffer:
+ break
+
+ downloaded += len(buffer)
+ f.write(buffer)
+
+ if total_size:
+ self.download_progress = int(downloaded * 100 / total_size)
+
+ is_elf = False
+ with open(tmpfile, 'rb') as f:
+ header = f.read(4)
+ is_elf = header == b'\x7fELF'
+
+ if not is_elf:
+ self.download_failed(self.download_url, "No custom software found at this URL.")
+ return
+
+ # AGNOS might try to execute the installer before this process exits.
+ # Therefore, important to close the fd before renaming the installer.
+ os.close(fd)
+ os.rename(tmpfile, INSTALLER_DESTINATION_PATH)
+
+ with open(INSTALLER_URL_PATH, "w") as f:
+ f.write(self.download_url)
+
+ # give time for installer UI to take over
+ time.sleep(0.1)
+ gui_app.request_close()
+
+ except urllib.error.HTTPError as e:
+ if e.code == 409:
+ error_msg = e.read().decode("utf-8")
+ self.download_failed(self.download_url, error_msg)
+ except Exception:
+ error_msg = "Ensure the entered URL is valid, and the device's internet connection is good."
+ self.download_failed(self.download_url, error_msg)
+
+ def download_failed(self, url: str, reason: str):
+ self.failed_url = url
+ self.failed_reason = reason
+ self.state = SetupState.DOWNLOAD_FAILED
+
+
+def main():
+ try:
+ gui_app.init_window("Setup", 20)
+ setup = Setup()
+ for should_render in gui_app.render():
+ if should_render:
+ setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+ setup.close()
+ except Exception as e:
+ print(f"Setup error: {e}")
+ finally:
+ gui_app.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/system/ui/tici_updater.py b/system/ui/tici_updater.py
new file mode 100755
index 0000000000..2e1a8687e1
--- /dev/null
+++ b/system/ui/tici_updater.py
@@ -0,0 +1,173 @@
+#!/usr/bin/env python3
+import sys
+import subprocess
+import threading
+import pyray as rl
+from enum import IntEnum
+
+from openpilot.system.hardware import HARDWARE
+from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
+from openpilot.system.ui.lib.wifi_manager import WifiManager
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.button import Button, ButtonStyle
+from openpilot.system.ui.widgets.label import gui_text_box, gui_label
+from openpilot.system.ui.widgets.network import WifiManagerUI
+
+# Constants
+MARGIN = 50
+BUTTON_HEIGHT = 160
+BUTTON_WIDTH = 400
+PROGRESS_BAR_HEIGHT = 72
+TITLE_FONT_SIZE = 80
+BODY_FONT_SIZE = 65
+BACKGROUND_COLOR = rl.BLACK
+PROGRESS_BG_COLOR = rl.Color(41, 41, 41, 255)
+PROGRESS_COLOR = rl.Color(54, 77, 239, 255)
+
+
+class Screen(IntEnum):
+ PROMPT = 0
+ WIFI = 1
+ PROGRESS = 2
+
+
+class Updater(Widget):
+ def __init__(self, updater_path, manifest_path):
+ super().__init__()
+ self.updater = updater_path
+ self.manifest = manifest_path
+ self.current_screen = Screen.PROMPT
+
+ self.progress_value = 0
+ self.progress_text = "Loading..."
+ self.show_reboot_button = False
+ self.process = None
+ self.update_thread = None
+ self.wifi_manager_ui = WifiManagerUI(WifiManager())
+
+ # Buttons
+ self._wifi_button = Button("Connect to Wi-Fi", click_callback=lambda: self.set_current_screen(Screen.WIFI))
+ self._install_button = Button("Install", click_callback=self.install_update, button_style=ButtonStyle.PRIMARY)
+ self._back_button = Button("Back", click_callback=lambda: self.set_current_screen(Screen.PROMPT))
+ self._reboot_button = Button("Reboot", click_callback=lambda: HARDWARE.reboot())
+
+ def set_current_screen(self, screen: Screen):
+ self.current_screen = screen
+
+ def install_update(self):
+ self.set_current_screen(Screen.PROGRESS)
+ self.progress_value = 0
+ self.progress_text = "Downloading..."
+ self.show_reboot_button = False
+
+ # Start the update process in a separate thread
+ self.update_thread = threading.Thread(target=self._run_update_process)
+ self.update_thread.daemon = True
+ self.update_thread.start()
+
+ def _run_update_process(self):
+ # TODO: just import it and run in a thread without a subprocess
+ cmd = [self.updater, "--swap", self.manifest]
+ self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ text=True, bufsize=1, universal_newlines=True)
+
+ for line in self.process.stdout:
+ parts = line.strip().split(":")
+ if len(parts) == 2:
+ self.progress_text = parts[0]
+ try:
+ self.progress_value = int(float(parts[1]))
+ except ValueError:
+ pass
+
+ exit_code = self.process.wait()
+ if exit_code == 0:
+ HARDWARE.reboot()
+ else:
+ self.progress_text = "Update failed"
+ self.show_reboot_button = True
+
+ def render_prompt_screen(self, rect: rl.Rectangle):
+ # Title
+ title_rect = rl.Rectangle(MARGIN + 50, 250, rect.width - MARGIN * 2 - 100, TITLE_FONT_SIZE * FONT_SCALE)
+ gui_label(title_rect, "Update Required", TITLE_FONT_SIZE, font_weight=FontWeight.BOLD)
+
+ # Description
+ desc_text = ("An operating system update is required. Connect your device to Wi-Fi for the fastest update experience. " +
+ "The download size is approximately 1GB.")
+
+ desc_rect = rl.Rectangle(MARGIN + 50, 250 + TITLE_FONT_SIZE * FONT_SCALE + 75, rect.width - MARGIN * 2 - 100, BODY_FONT_SIZE * FONT_SCALE * 4)
+ gui_text_box(desc_rect, desc_text, BODY_FONT_SIZE)
+
+ # Buttons at the bottom
+ button_y = rect.height - MARGIN - BUTTON_HEIGHT
+ button_width = (rect.width - MARGIN * 3) // 2
+
+ # WiFi button
+ wifi_button_rect = rl.Rectangle(MARGIN, button_y, button_width, BUTTON_HEIGHT)
+ self._wifi_button.render(wifi_button_rect)
+
+ # Install button
+ install_button_rect = rl.Rectangle(MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT)
+ self._install_button.render(install_button_rect)
+
+ def render_wifi_screen(self, rect: rl.Rectangle):
+ # Draw the Wi-Fi manager UI
+ wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2,
+ rect.height - BUTTON_HEIGHT - MARGIN * 3)
+ rl.draw_rectangle_rounded(wifi_rect, 0.035, 10, rl.Color(51, 51, 51, 255))
+ wifi_content_rect = rl.Rectangle(wifi_rect.x + 50, wifi_rect.y, wifi_rect.width - 100, wifi_rect.height)
+ self.wifi_manager_ui.render(wifi_content_rect)
+
+ back_button_rect = rl.Rectangle(MARGIN, rect.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
+ self._back_button.render(back_button_rect)
+
+ def render_progress_screen(self, rect: rl.Rectangle):
+ title_rect = rl.Rectangle(MARGIN + 100, 330, rect.width - MARGIN * 2 - 200, 100)
+ gui_label(title_rect, self.progress_text, 90, font_weight=FontWeight.SEMI_BOLD)
+
+ # Progress bar
+ bar_rect = rl.Rectangle(MARGIN + 100, 330 + 100 + 100, rect.width - MARGIN * 2 - 200, PROGRESS_BAR_HEIGHT)
+ rl.draw_rectangle_rounded(bar_rect, 0.5, 10, PROGRESS_BG_COLOR)
+
+ # Calculate the width of the progress chunk
+ progress_width = (bar_rect.width * self.progress_value) / 100
+ if progress_width > 0:
+ progress_rect = rl.Rectangle(bar_rect.x, bar_rect.y, progress_width, bar_rect.height)
+ rl.draw_rectangle_rounded(progress_rect, 0.5, 10, PROGRESS_COLOR)
+
+ # Show reboot button if needed
+ if self.show_reboot_button:
+ reboot_rect = rl.Rectangle(MARGIN + 100, rect.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
+ self._reboot_button.render(reboot_rect)
+
+ def _render(self, rect: rl.Rectangle):
+ if self.current_screen == Screen.PROMPT:
+ self.render_prompt_screen(rect)
+ elif self.current_screen == Screen.WIFI:
+ self.render_wifi_screen(rect)
+ elif self.current_screen == Screen.PROGRESS:
+ self.render_progress_screen(rect)
+
+
+def main():
+ if len(sys.argv) < 3:
+ print("Usage: updater.py ")
+ sys.exit(1)
+
+ updater_path = sys.argv[1]
+ manifest_path = sys.argv[2]
+
+ try:
+ gui_app.init_window("System Update")
+ updater = Updater(updater_path, manifest_path)
+ for should_render in gui_app.render():
+ if should_render:
+ updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
+ finally:
+ # Make sure we clean up even if there's an error
+ gui_app.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/system/ui/updater.py b/system/ui/updater.py
index 2e1a8687e1..42d12d9090 100755
--- a/system/ui/updater.py
+++ b/system/ui/updater.py
@@ -1,172 +1,14 @@
#!/usr/bin/env python3
-import sys
-import subprocess
-import threading
-import pyray as rl
-from enum import IntEnum
-
-from openpilot.system.hardware import HARDWARE
-from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE
-from openpilot.system.ui.lib.wifi_manager import WifiManager
-from openpilot.system.ui.widgets import Widget
-from openpilot.system.ui.widgets.button import Button, ButtonStyle
-from openpilot.system.ui.widgets.label import gui_text_box, gui_label
-from openpilot.system.ui.widgets.network import WifiManagerUI
-
-# Constants
-MARGIN = 50
-BUTTON_HEIGHT = 160
-BUTTON_WIDTH = 400
-PROGRESS_BAR_HEIGHT = 72
-TITLE_FONT_SIZE = 80
-BODY_FONT_SIZE = 65
-BACKGROUND_COLOR = rl.BLACK
-PROGRESS_BG_COLOR = rl.Color(41, 41, 41, 255)
-PROGRESS_COLOR = rl.Color(54, 77, 239, 255)
-
-
-class Screen(IntEnum):
- PROMPT = 0
- WIFI = 1
- PROGRESS = 2
-
-
-class Updater(Widget):
- def __init__(self, updater_path, manifest_path):
- super().__init__()
- self.updater = updater_path
- self.manifest = manifest_path
- self.current_screen = Screen.PROMPT
-
- self.progress_value = 0
- self.progress_text = "Loading..."
- self.show_reboot_button = False
- self.process = None
- self.update_thread = None
- self.wifi_manager_ui = WifiManagerUI(WifiManager())
-
- # Buttons
- self._wifi_button = Button("Connect to Wi-Fi", click_callback=lambda: self.set_current_screen(Screen.WIFI))
- self._install_button = Button("Install", click_callback=self.install_update, button_style=ButtonStyle.PRIMARY)
- self._back_button = Button("Back", click_callback=lambda: self.set_current_screen(Screen.PROMPT))
- self._reboot_button = Button("Reboot", click_callback=lambda: HARDWARE.reboot())
-
- def set_current_screen(self, screen: Screen):
- self.current_screen = screen
-
- def install_update(self):
- self.set_current_screen(Screen.PROGRESS)
- self.progress_value = 0
- self.progress_text = "Downloading..."
- self.show_reboot_button = False
-
- # Start the update process in a separate thread
- self.update_thread = threading.Thread(target=self._run_update_process)
- self.update_thread.daemon = True
- self.update_thread.start()
-
- def _run_update_process(self):
- # TODO: just import it and run in a thread without a subprocess
- cmd = [self.updater, "--swap", self.manifest]
- self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
- text=True, bufsize=1, universal_newlines=True)
-
- for line in self.process.stdout:
- parts = line.strip().split(":")
- if len(parts) == 2:
- self.progress_text = parts[0]
- try:
- self.progress_value = int(float(parts[1]))
- except ValueError:
- pass
-
- exit_code = self.process.wait()
- if exit_code == 0:
- HARDWARE.reboot()
- else:
- self.progress_text = "Update failed"
- self.show_reboot_button = True
-
- def render_prompt_screen(self, rect: rl.Rectangle):
- # Title
- title_rect = rl.Rectangle(MARGIN + 50, 250, rect.width - MARGIN * 2 - 100, TITLE_FONT_SIZE * FONT_SCALE)
- gui_label(title_rect, "Update Required", TITLE_FONT_SIZE, font_weight=FontWeight.BOLD)
-
- # Description
- desc_text = ("An operating system update is required. Connect your device to Wi-Fi for the fastest update experience. " +
- "The download size is approximately 1GB.")
-
- desc_rect = rl.Rectangle(MARGIN + 50, 250 + TITLE_FONT_SIZE * FONT_SCALE + 75, rect.width - MARGIN * 2 - 100, BODY_FONT_SIZE * FONT_SCALE * 4)
- gui_text_box(desc_rect, desc_text, BODY_FONT_SIZE)
-
- # Buttons at the bottom
- button_y = rect.height - MARGIN - BUTTON_HEIGHT
- button_width = (rect.width - MARGIN * 3) // 2
-
- # WiFi button
- wifi_button_rect = rl.Rectangle(MARGIN, button_y, button_width, BUTTON_HEIGHT)
- self._wifi_button.render(wifi_button_rect)
-
- # Install button
- install_button_rect = rl.Rectangle(MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT)
- self._install_button.render(install_button_rect)
-
- def render_wifi_screen(self, rect: rl.Rectangle):
- # Draw the Wi-Fi manager UI
- wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2,
- rect.height - BUTTON_HEIGHT - MARGIN * 3)
- rl.draw_rectangle_rounded(wifi_rect, 0.035, 10, rl.Color(51, 51, 51, 255))
- wifi_content_rect = rl.Rectangle(wifi_rect.x + 50, wifi_rect.y, wifi_rect.width - 100, wifi_rect.height)
- self.wifi_manager_ui.render(wifi_content_rect)
-
- back_button_rect = rl.Rectangle(MARGIN, rect.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
- self._back_button.render(back_button_rect)
-
- def render_progress_screen(self, rect: rl.Rectangle):
- title_rect = rl.Rectangle(MARGIN + 100, 330, rect.width - MARGIN * 2 - 200, 100)
- gui_label(title_rect, self.progress_text, 90, font_weight=FontWeight.SEMI_BOLD)
-
- # Progress bar
- bar_rect = rl.Rectangle(MARGIN + 100, 330 + 100 + 100, rect.width - MARGIN * 2 - 200, PROGRESS_BAR_HEIGHT)
- rl.draw_rectangle_rounded(bar_rect, 0.5, 10, PROGRESS_BG_COLOR)
-
- # Calculate the width of the progress chunk
- progress_width = (bar_rect.width * self.progress_value) / 100
- if progress_width > 0:
- progress_rect = rl.Rectangle(bar_rect.x, bar_rect.y, progress_width, bar_rect.height)
- rl.draw_rectangle_rounded(progress_rect, 0.5, 10, PROGRESS_COLOR)
-
- # Show reboot button if needed
- if self.show_reboot_button:
- reboot_rect = rl.Rectangle(MARGIN + 100, rect.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
- self._reboot_button.render(reboot_rect)
-
- def _render(self, rect: rl.Rectangle):
- if self.current_screen == Screen.PROMPT:
- self.render_prompt_screen(rect)
- elif self.current_screen == Screen.WIFI:
- self.render_wifi_screen(rect)
- elif self.current_screen == Screen.PROGRESS:
- self.render_progress_screen(rect)
+from openpilot.system.ui.lib.application import gui_app
+import openpilot.system.ui.tici_updater as tici_updater
+import openpilot.system.ui.mici_updater as mici_updater
def main():
- if len(sys.argv) < 3:
- print("Usage: updater.py ")
- sys.exit(1)
-
- updater_path = sys.argv[1]
- manifest_path = sys.argv[2]
-
- try:
- gui_app.init_window("System Update")
- updater = Updater(updater_path, manifest_path)
- for should_render in gui_app.render():
- if should_render:
- updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
- finally:
- # Make sure we clean up even if there's an error
- gui_app.close()
+ if gui_app.big_ui():
+ tici_updater.main()
+ else:
+ mici_updater.main()
if __name__ == "__main__":
diff --git a/system/ui/widgets/__init__.py b/system/ui/widgets/__init__.py
index 562cde39e1..95858ec1b3 100644
--- a/system/ui/widgets/__init__.py
+++ b/system/ui/widgets/__init__.py
@@ -2,7 +2,15 @@ import abc
import pyray as rl
from enum import IntEnum
from collections.abc import Callable
-from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS
+from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter
+from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS, MouseEvent
+
+try:
+ from openpilot.selfdrive.ui.ui_state import device
+except ImportError:
+ class Device:
+ awake = True
+ device = Device() # type: ignore
class DialogResult(IntEnum):
@@ -23,6 +31,7 @@ class Widget(abc.ABC):
self._touch_valid_callback: Callable[[], bool] | None = None
self._click_callback: Callable[[], None] | None = None
self._multi_touch = False
+ self.__was_awake = True
@property
def rect(self) -> rl.Rectangle:
@@ -71,7 +80,7 @@ class Widget(abc.ABC):
def set_position(self, x: float, y: float) -> None:
changed = (self._rect.x != x or self._rect.y != y)
- self._rect.x, self._rect.y = x, y
+ self._rect = rl.Rectangle(x, y, self._rect.width, self._rect.height)
if changed:
self._update_layout_rects()
@@ -94,7 +103,7 @@ class Widget(abc.ABC):
ret = self._render(self._rect)
# Keep track of whether mouse down started within the widget's rectangle
- if self.enabled:
+ if self.enabled and self.__was_awake:
for mouse_event in gui_app.mouse_events:
if not self._multi_touch and mouse_event.slot != 0:
continue
@@ -106,6 +115,7 @@ class Widget(abc.ABC):
self._handle_mouse_press(mouse_event.pos)
self.__is_pressed[mouse_event.slot] = True
self.__tracking_is_pressed[mouse_event.slot] = True
+ self._handle_mouse_event(mouse_event)
# Callback such as scroll panel signifies user is scrolling
elif not self._touch_valid():
@@ -113,6 +123,7 @@ class Widget(abc.ABC):
self.__tracking_is_pressed[mouse_event.slot] = False
elif mouse_event.left_released:
+ self._handle_mouse_event(mouse_event)
if self.__is_pressed[mouse_event.slot] and rl.check_collision_point_rec(mouse_event.pos, self._hit_rect):
self._handle_mouse_release(mouse_event.pos)
self.__is_pressed[mouse_event.slot] = False
@@ -122,10 +133,14 @@ class Widget(abc.ABC):
elif rl.check_collision_point_rec(mouse_event.pos, self._hit_rect):
if self.__tracking_is_pressed[mouse_event.slot]:
self.__is_pressed[mouse_event.slot] = True
+ self._handle_mouse_event(mouse_event)
# Mouse/touch left our rect but may come back into focus later
elif not rl.check_collision_point_rec(mouse_event.pos, self._hit_rect):
self.__is_pressed[mouse_event.slot] = False
+ self._handle_mouse_event(mouse_event)
+
+ self.__was_awake = device.awake
return ret
@@ -149,9 +164,206 @@ class Widget(abc.ABC):
self._click_callback()
return False
+ def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
+ """Optionally handle mouse events. This is called before rendering."""
+ # Default implementation does nothing, can be overridden by subclasses
+
def show_event(self):
"""Optionally handle show event. Parent must manually call this"""
def hide_event(self):
"""Optionally handle hide event. Parent must manually call this"""
+
+SWIPE_AWAY_THRESHOLD = 80 # px to dismiss after releasing
+START_DISMISSING_THRESHOLD = 40 # px to start dismissing while dragging
+BLOCK_SWIPE_AWAY_THRESHOLD = 60 # px horizontal movement to block swipe away
+
+NAV_BAR_MARGIN = 6
+NAV_BAR_WIDTH = 205
+NAV_BAR_HEIGHT = 8
+
+DISMISS_PUSH_OFFSET = 50 + NAV_BAR_MARGIN + NAV_BAR_HEIGHT # px extra to push down when dismissing
+DISMISS_TIME_SECONDS = 1.5
+
+
+class NavBar(Widget):
+ def __init__(self):
+ super().__init__()
+ self.set_rect(rl.Rectangle(0, 0, NAV_BAR_WIDTH, NAV_BAR_HEIGHT))
+ self._alpha = 1.0
+ self._alpha_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
+ self._fade_time = 0.0
+
+ def set_alpha(self, alpha: float) -> None:
+ self._alpha = alpha
+ self._fade_time = rl.get_time()
+
+ def show_event(self):
+ super().show_event()
+ self._alpha = 1.0
+ self._alpha_filter.x = 1.0
+ self._fade_time = rl.get_time()
+
+ def _render(self, _):
+ if rl.get_time() - self._fade_time > DISMISS_TIME_SECONDS:
+ self._alpha = 0.0
+ alpha = self._alpha_filter.update(self._alpha)
+
+ # white bar with black border
+ rl.draw_rectangle_rounded(self._rect, 1.0, 6, rl.Color(255, 255, 255, int(255 * 0.9 * alpha)))
+ rl.draw_rectangle_rounded_lines_ex(self._rect, 1.0, 6, 2, rl.Color(0, 0, 0, int(255 * 0.3 * alpha)))
+
+
+class NavWidget(Widget, abc.ABC):
+ """
+ A full screen widget that supports back navigation by swiping down from the top.
+ """
+ BACK_TOUCH_AREA_PERCENTAGE = 0.65
+
+ def __init__(self):
+ super().__init__()
+ self._back_callback: Callable[[], None] | None = None
+ self._back_button_start_pos: MousePos | None = None
+ self._swiping_away = False # currently swiping away
+ self._can_swipe_away = True # swipe away is blocked after certain horizontal movement
+
+ self._pos_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps, bounce=1)
+ self._playing_dismiss_animation = False
+ self._trigger_animate_in = False
+ self._back_enabled: bool | Callable[[], bool] = True
+ self._nav_bar = NavBar()
+
+ self._nav_bar_y_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
+
+ self._set_up = False
+
+ @property
+ def back_enabled(self) -> bool:
+ return self._back_enabled() if callable(self._back_enabled) else self._back_enabled
+
+ def set_back_enabled(self, enabled: bool | Callable[[], bool]) -> None:
+ self._back_enabled = enabled
+
+ def set_back_callback(self, callback: Callable[[], None]) -> None:
+ self._back_callback = callback
+
+ def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
+ super()._handle_mouse_event(mouse_event)
+
+ if not self.back_enabled:
+ self._back_button_start_pos = None
+ self._swiping_away = False
+ self._can_swipe_away = True
+ return
+
+ if mouse_event.left_pressed:
+ # user is able to swipe away if starting near top of screen, or anywhere if scroller is at top
+ self._pos_filter.update_alpha(0.04)
+ in_dismiss_area = mouse_event.pos.y < self._rect.height * self.BACK_TOUCH_AREA_PERCENTAGE
+
+ scroller_at_top = False
+ # TODO: -20? snapping in WiFi dialog can make offset not be positive at the top
+ if hasattr(self, '_scroller'):
+ scroller_at_top = self._scroller.scroll_panel.get_offset() >= -20 and not self._scroller._horizontal
+ elif hasattr(self, '_scroll_panel'):
+ scroller_at_top = self._scroll_panel.get_offset() >= -20 and not self._scroll_panel._horizontal
+
+ if in_dismiss_area or scroller_at_top:
+ self._can_swipe_away = True
+ self._back_button_start_pos = mouse_event.pos
+
+ elif mouse_event.left_down:
+ if self._back_button_start_pos is not None:
+ # block swiping away if too much horizontal or upward movement
+ horizontal_movement = abs(mouse_event.pos.x - self._back_button_start_pos.x) > BLOCK_SWIPE_AWAY_THRESHOLD
+ upward_movement = mouse_event.pos.y - self._back_button_start_pos.y < -BLOCK_SWIPE_AWAY_THRESHOLD
+ if not self._swiping_away and (horizontal_movement or upward_movement):
+ self._can_swipe_away = False
+ self._back_button_start_pos = None
+
+ # block horizontal swiping if now swiping away
+ if self._can_swipe_away:
+ if mouse_event.pos.y - self._back_button_start_pos.y > START_DISMISSING_THRESHOLD: # type: ignore
+ self._swiping_away = True
+
+ elif mouse_event.left_released:
+ self._pos_filter.update_alpha(0.1)
+ # if far enough, trigger back navigation callback
+ if self._back_button_start_pos is not None:
+ if mouse_event.pos.y - self._back_button_start_pos.y > SWIPE_AWAY_THRESHOLD:
+ self._playing_dismiss_animation = True
+
+ self._back_button_start_pos = None
+ self._swiping_away = False
+
+ def _update_state(self):
+ super()._update_state()
+
+ # Disable self's scroller while swiping away
+ if not self._set_up:
+ self._set_up = True
+ if hasattr(self, '_scroller'):
+ original_enabled = self._scroller._enabled
+ self._scroller.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else
+ original_enabled))
+ elif hasattr(self, '_scroll_panel'):
+ original_enabled = self._scroll_panel.enabled
+ self._scroll_panel.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else
+ original_enabled))
+
+ if self._trigger_animate_in:
+ self._pos_filter.x = self._rect.height
+ self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT
+ self._trigger_animate_in = False
+
+ new_y = 0.0
+
+ if self._back_button_start_pos is not None:
+ last_mouse_event = gui_app.last_mouse_event
+ # push entire widget as user drags it away
+ new_y = max(last_mouse_event.pos.y - self._back_button_start_pos.y, 0)
+ if new_y < SWIPE_AWAY_THRESHOLD:
+ new_y /= 2 # resistance until mouse release would dismiss widget
+
+ if self._swiping_away:
+ self._nav_bar.set_alpha(1.0)
+
+ if self._playing_dismiss_animation:
+ new_y = self._rect.height + DISMISS_PUSH_OFFSET
+
+ new_y = round(self._pos_filter.update(new_y))
+ if abs(new_y) < 1 and self._pos_filter.velocity.x == 0.0:
+ new_y = self._pos_filter.x = 0.0
+
+ if new_y > self._rect.height + DISMISS_PUSH_OFFSET - 10:
+ if self._back_callback is not None:
+ self._back_callback()
+
+ self._playing_dismiss_animation = False
+ self._back_button_start_pos = None
+ self._swiping_away = False
+
+ self.set_position(self._rect.x, new_y)
+
+ def render(self, rect: rl.Rectangle = None) -> bool | int | None:
+ ret = super().render(rect)
+
+ if self.back_enabled:
+ bar_x = self._rect.x + (self._rect.width - self._nav_bar.rect.width) / 2
+ if self._back_button_start_pos is not None or self._playing_dismiss_animation:
+ self._nav_bar_y_filter.x = NAV_BAR_MARGIN + self._pos_filter.x
+ else:
+ self._nav_bar_y_filter.update(NAV_BAR_MARGIN)
+
+ self._nav_bar.set_position(bar_x, round(self._nav_bar_y_filter.x))
+ self._nav_bar.render()
+
+ return ret
+
+ def show_event(self):
+ super().show_event()
+ # FIXME: we don't know the height of the rect at first show_event since it's before the first render :(
+ # so we need this hacky bool for now
+ self._trigger_animate_in = True
+ self._nav_bar.show_event()
diff --git a/system/ui/widgets/button.py b/system/ui/widgets/button.py
index df7a52d1c0..34b2a51a42 100644
--- a/system/ui/widgets/button.py
+++ b/system/ui/widgets/button.py
@@ -3,9 +3,10 @@ from enum import IntEnum
import pyray as rl
-from openpilot.system.ui.lib.application import FontWeight, MousePos
+from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.widgets import Widget
-from openpilot.system.ui.widgets.label import Label
+from openpilot.system.ui.widgets.label import Label, UnifiedLabel
+from openpilot.common.filter_simple import FirstOrderFilter
class ButtonStyle(IntEnum):
@@ -175,9 +176,121 @@ class IconButton(Widget):
def __init__(self, texture: rl.Texture):
super().__init__()
self._texture = texture
+ self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
+ self.set_rect(rl.Rectangle(0, 0, self._texture.width, self._texture.height))
+
+ def set_opacity(self, opacity: float, smooth: bool = False):
+ if smooth:
+ self._opacity_filter.update(opacity)
+ else:
+ self._opacity_filter.x = opacity
def _render(self, rect: rl.Rectangle):
- color = rl.Color(180, 180, 180, 150) if self.is_pressed else rl.WHITE
+ color = rl.Color(180, 180, 180, int(150 * self._opacity_filter.x)) if self.is_pressed else rl.WHITE
+ if not self.enabled:
+ color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.35 * self._opacity_filter.x))
draw_x = rect.x + (rect.width - self._texture.width) / 2
draw_y = rect.y + (rect.height - self._texture.height) / 2
rl.draw_texture(self._texture, int(draw_x), int(draw_y), color)
+
+
+class SmallCircleIconButton(Widget):
+ def __init__(self, icon_txt: rl.Texture):
+ super().__init__()
+ self.set_rect(rl.Rectangle(0, 0, 100, 100))
+ self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
+ self._icon_bg_txt = gui_app.texture("icons_mici/setup/small_button.png", 100, 100)
+ self._icon_bg_pressed_txt = gui_app.texture("icons_mici/setup/small_button_pressed.png", 100, 100)
+ self._icon_txt = icon_txt
+
+ def set_opacity(self, opacity: float, smooth: bool = False):
+ if smooth:
+ self._opacity_filter.update(opacity)
+ else:
+ self._opacity_filter.x = opacity
+
+ def _render(self, _):
+ bg_txt = self._icon_bg_pressed_txt if self.is_pressed else self._icon_bg_txt
+ white = rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))
+ rl.draw_texture(bg_txt, int(self.rect.x), int(self.rect.y), white)
+ icon_x = self.rect.x + (self.rect.width - self._icon_txt.width) / 2
+ icon_y = self.rect.y + (self.rect.height - self._icon_txt.height) / 2
+ rl.draw_texture(self._icon_txt, int(icon_x), int(icon_y), white)
+
+
+class SmallButton(Widget):
+ def __init__(self, text: str):
+ super().__init__()
+ self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps)
+
+ self._load_assets()
+
+ self._label = UnifiedLabel(text, 36, font_weight=FontWeight.MEDIUM,
+ text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
+
+ self._bg_disabled_txt = None
+
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 194, 100))
+ self._bg_txt = gui_app.texture("icons_mici/setup/reset/small_button.png", 194, 100)
+ self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/small_button_pressed.png", 194, 100)
+
+ def set_text(self, text: str):
+ self._label.set_text(text)
+
+ def set_opacity(self, opacity: float, smooth: bool = False):
+ if smooth:
+ self._opacity_filter.update(opacity)
+ else:
+ self._opacity_filter.x = opacity
+
+ def _render(self, _):
+ if not self.enabled and self._bg_disabled_txt is not None:
+ rl.draw_texture(self._bg_disabled_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
+ elif self.is_pressed:
+ rl.draw_texture(self._bg_pressed_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
+ else:
+ rl.draw_texture(self._bg_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)))
+
+ opacity = 0.9 if self.enabled else 0.35
+ self._label.set_color(rl.Color(255, 255, 255, int(255 * opacity * self._opacity_filter.x)))
+ self._label.render(self._rect)
+
+
+class SmallRedPillButton(SmallButton):
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 194, 100))
+ self._bg_txt = gui_app.texture("icons_mici/setup/small_red_pill.png", 194, 100)
+ self._bg_pressed_txt = gui_app.texture("icons_mici/setup/small_red_pill_pressed.png", 194, 100)
+
+
+class SmallerRoundedButton(SmallButton):
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 150, 100))
+ self._bg_txt = gui_app.texture("icons_mici/setup/smaller_button.png", 150, 100)
+ self._bg_disabled_txt = gui_app.texture("icons_mici/setup/smaller_button_disabled.png", 150, 100)
+ self._bg_pressed_txt = gui_app.texture("icons_mici/setup/smaller_button_pressed.png", 150, 100)
+
+
+class WideRoundedButton(SmallButton):
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 316, 100))
+ self._bg_txt = gui_app.texture("icons_mici/setup/medium_button_bg.png", 316, 100)
+ self._bg_pressed_txt = gui_app.texture("icons_mici/setup/medium_button_pressed_bg.png", 316, 100)
+
+
+class WidishRoundedButton(SmallButton):
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 250, 100))
+ self._bg_txt = gui_app.texture("icons_mici/setup/widish_button.png", 250, 100)
+ self._bg_pressed_txt = gui_app.texture("icons_mici/setup/widish_button_pressed.png", 250, 100)
+ self._bg_disabled_txt = gui_app.texture("icons_mici/setup/widish_button_disabled.png", 250, 100)
+
+
+class FullRoundedButton(SmallButton):
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 520, 100))
+ self._bg_txt = gui_app.texture("icons_mici/setup/reset/wide_button.png", 520, 100)
+ self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/wide_button_pressed.png", 520, 100)
diff --git a/system/ui/widgets/confirm_dialog.py b/system/ui/widgets/confirm_dialog.py
index 8c5ae0aa01..97618660bd 100644
--- a/system/ui/widgets/confirm_dialog.py
+++ b/system/ui/widgets/confirm_dialog.py
@@ -6,7 +6,7 @@ from openpilot.system.ui.widgets.button import ButtonStyle, Button
from openpilot.system.ui.widgets.label import Label
from openpilot.system.ui.widgets.html_render import HtmlRenderer, ElementType
from openpilot.system.ui.widgets import Widget
-from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.scroller_tici import Scroller
OUTER_MARGIN = 200
RICH_OUTER_MARGIN = 100
diff --git a/system/ui/widgets/label.py b/system/ui/widgets/label.py
index 7d76802565..35e2708e62 100644
--- a/system/ui/widgets/label.py
+++ b/system/ui/widgets/label.py
@@ -1,14 +1,15 @@
+from enum import IntEnum
from collections.abc import Callable
from itertools import zip_longest
from typing import Union
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR, FONT_SCALE
+from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.utils import GuiStyleContext
from openpilot.system.ui.lib.emoji import find_emoji, emoji_tex
from openpilot.system.ui.lib.wrap_text import wrap_text
-from openpilot.system.ui.widgets import Widget
ICON_PADDING = 15
@@ -20,6 +21,171 @@ def _resolve_value(value, default=""):
return value if value is not None else default
+class ScrollState(IntEnum):
+ STARTING = 0
+ SCROLLING = 1
+
+
+# TODO: merge anything new here to master
+class MiciLabel(Widget):
+ def __init__(self,
+ text: str,
+ font_size: int = DEFAULT_TEXT_SIZE,
+ width: int = None,
+ color: rl.Color = DEFAULT_TEXT_COLOR,
+ font_weight: FontWeight = FontWeight.NORMAL,
+ alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
+ spacing: int = 0,
+ line_height: int = None,
+ elide_right: bool = True,
+ wrap_text: bool = False,
+ scroll: bool = False):
+ super().__init__()
+ self.text = text
+ self.wrapped_text: list[str] = []
+ self.font_size = font_size
+ self.width = width
+ self.color = color
+ self.font_weight = font_weight
+ self.alignment = alignment
+ self.alignment_vertical = alignment_vertical
+ self.spacing = spacing
+ self.line_height = line_height if line_height is not None else font_size
+ self.elide_right = elide_right
+ self.wrap_text = wrap_text
+ self._height = 0
+
+ # Scroll state
+ self.scroll = scroll
+ self._needs_scroll = False
+ self._scroll_offset = 0
+ self._scroll_pause_t: float | None = None
+ self._scroll_state: ScrollState = ScrollState.STARTING
+
+ assert not (self.scroll and self.wrap_text), "Cannot enable both scroll and wrap_text"
+ assert not (self.scroll and self.elide_right), "Cannot enable both scroll and elide_right"
+
+ self.set_text(text)
+
+ @property
+ def text_height(self):
+ return self._height
+
+ def set_font_size(self, font_size: int):
+ self.font_size = font_size
+ self.set_text(self.text)
+
+ def set_width(self, width: int):
+ self.width = width
+ self._rect.width = width
+ self.set_text(self.text)
+
+ def set_text(self, txt: str):
+ self.text = txt
+ text_size = measure_text_cached(gui_app.font(self.font_weight), self.text, self.font_size, self.spacing)
+ if self.width is not None:
+ self._rect.width = self.width
+ else:
+ self._rect.width = text_size.x
+
+ if self.wrap_text:
+ self.wrapped_text = wrap_text(gui_app.font(self.font_weight), self.text, self.font_size, int(self._rect.width))
+ self._height = len(self.wrapped_text) * self.line_height
+ elif self.scroll:
+ self._needs_scroll = self.scroll and text_size.x > self._rect.width
+ self._rect.height = text_size.y
+
+ def set_color(self, color: rl.Color):
+ self.color = color
+
+ def set_font_weight(self, font_weight: FontWeight):
+ self.font_weight = font_weight
+ self.set_text(self.text)
+
+ def _render(self, rect: rl.Rectangle):
+ # Only scissor when we know there is a single scrolling line
+ if self._needs_scroll:
+ rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
+
+ font = gui_app.font(self.font_weight)
+
+ text_y_offset = 0
+ # Draw the text in the specified rectangle
+ lines = self.wrapped_text or [self.text]
+ if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
+ lines = lines[::-1]
+
+ for display_text in lines:
+ text_size = measure_text_cached(font, display_text, self.font_size, self.spacing)
+
+ # Elide text to fit within the rectangle
+ if self.elide_right and text_size.x > rect.width:
+ ellipsis = "..."
+ left, right = 0, len(display_text)
+ while left < right:
+ mid = (left + right) // 2
+ candidate = display_text[:mid] + ellipsis
+ candidate_size = measure_text_cached(font, candidate, self.font_size, self.spacing)
+ if candidate_size.x <= rect.width:
+ left = mid + 1
+ else:
+ right = mid
+ display_text = display_text[: left - 1] + ellipsis if left > 0 else ellipsis
+ text_size = measure_text_cached(font, display_text, self.font_size, self.spacing)
+
+ # Handle scroll state
+ elif self.scroll and self._needs_scroll:
+ if self._scroll_state == ScrollState.STARTING:
+ if self._scroll_pause_t is None:
+ self._scroll_pause_t = rl.get_time() + 2.0
+ if rl.get_time() >= self._scroll_pause_t:
+ self._scroll_state = ScrollState.SCROLLING
+ self._scroll_pause_t = None
+
+ elif self._scroll_state == ScrollState.SCROLLING:
+ self._scroll_offset -= 0.8 / 60. * gui_app.target_fps
+ # don't fully hide
+ if self._scroll_offset <= -text_size.x - self._rect.width / 3:
+ self._scroll_offset = 0
+ self._scroll_state = ScrollState.STARTING
+ self._scroll_pause_t = None
+
+ # Calculate horizontal position based on alignment
+ text_x = rect.x + {
+ rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0,
+ rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2,
+ rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x,
+ }.get(self.alignment, 0) + self._scroll_offset
+
+ # Calculate vertical position based on alignment
+ text_y = rect.y + {
+ rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0,
+ rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2,
+ rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y,
+ }.get(self.alignment_vertical, 0)
+ text_y += text_y_offset
+
+ rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x), text_y), self.font_size, self.spacing, self.color)
+ # Draw 2nd instance for scrolling
+ if self._needs_scroll and self._scroll_state != ScrollState.STARTING:
+ text2_scroll_offset = text_size.x + self._rect.width / 3
+ rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x + text2_scroll_offset), text_y), self.font_size, self.spacing, self.color)
+ if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
+ text_y_offset -= self.line_height
+ else:
+ text_y_offset += self.line_height
+
+ if self._needs_scroll:
+ # draw black fade on left and right
+ fade_width = 20
+ rl.draw_rectangle_gradient_h(int(rect.x + rect.width - fade_width), int(rect.y), fade_width, int(rect.height), rl.Color(0, 0, 0, 0), rl.BLACK)
+ if self._scroll_state != ScrollState.STARTING:
+ rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), fade_width, int(rect.height), rl.BLACK, rl.Color(0, 0, 0, 0))
+
+ rl.end_scissor_mode()
+
+
# TODO: This should be a Widget class
def gui_label(
rect: rl.Rectangle,
@@ -65,6 +231,7 @@ def gui_label(
}.get(alignment_vertical, 0)
# Draw the text in the specified rectangle
+ # TODO: add wrapping and proper centering for multiline text
rl.draw_text_ex(font, display_text, rl.Vector2(text_x, text_y), font_size, 0, color)
@@ -76,11 +243,12 @@ def gui_text_box(
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
font_weight: FontWeight = FontWeight.NORMAL,
+ line_scale: float = 1.0,
):
styles = [
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(color)),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, round(font_size * FONT_SCALE)),
- (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, round(font_size * FONT_SCALE)),
+ (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, round(font_size * FONT_SCALE * line_scale)),
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_ALIGNMENT, alignment),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_ALIGNMENT_VERTICAL, alignment_vertical),
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_WRAP_MODE, rl.GuiTextWrapMode.TEXT_WRAP_WORD)
@@ -105,8 +273,9 @@ class Label(Widget):
text_alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
text_padding: int = 0,
text_color: rl.Color = DEFAULT_TEXT_COLOR,
- icon: Union[rl.Texture, None] = None, # noqa: UP007
+ icon: Union[rl.Texture, None] = None,
elide_right: bool = False,
+ line_scale=1.0,
):
super().__init__()
@@ -119,6 +288,7 @@ class Label(Widget):
self._text_color = text_color
self._icon = icon
self._elide_right = elide_right
+ self._line_scale = line_scale
self._text = text
self.set_text(text)
@@ -217,4 +387,339 @@ class Label(Widget):
line_pos.x += self._font_size * FONT_SCALE
prev_index = end
rl.draw_text_ex(self._font, text[prev_index:], line_pos, self._font_size, 0, self._text_color)
- text_pos.y += text_size.y or self._font_size * FONT_SCALE
+ text_pos.y += (text_size.y or self._font_size * FONT_SCALE) * self._line_scale
+
+
+class UnifiedLabel(Widget):
+ """
+ Unified label widget that combines functionality from gui_label, gui_text_box, Label, and MiciLabel.
+
+ Supports:
+ - Emoji rendering
+ - Text wrapping
+ - Automatic eliding (single-line or multiline)
+ - Proper multiline vertical alignment
+ - Height calculation for layout purposes
+ """
+ def __init__(self,
+ text: str | Callable[[], str],
+ font_size: int = DEFAULT_TEXT_SIZE,
+ font_weight: FontWeight = FontWeight.NORMAL,
+ text_color: rl.Color = DEFAULT_TEXT_COLOR,
+ alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
+ alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
+ text_padding: int = 0,
+ max_width: int | None = None,
+ elide: bool = True,
+ wrap_text: bool = True,
+ line_height: float = 1.0,
+ letter_spacing: float = 0.0):
+ super().__init__()
+ self._text = text
+ self._font_size = font_size
+ self._font_weight = font_weight
+ self._font = gui_app.font(self._font_weight)
+ self._text_color = text_color
+ self._alignment = alignment
+ self._alignment_vertical = alignment_vertical
+ self._text_padding = text_padding
+ self._max_width = max_width
+ self._elide = elide
+ self._wrap_text = wrap_text
+ self._line_height = line_height * 0.9
+ self._letter_spacing = letter_spacing # 0.1 = 10%
+ self._spacing_pixels = font_size * letter_spacing
+
+ # Cached data
+ self._cached_text: str | None = None
+ self._cached_wrapped_lines: list[str] = []
+ self._cached_line_sizes: list[rl.Vector2] = []
+ self._cached_line_emojis: list[list[tuple[int, int, str]]] = []
+ self._cached_total_height: float | None = None
+ self._cached_width: int = -1
+
+ # If max_width is set, initialize rect size for Scroller support
+ if max_width is not None:
+ self._rect.width = max_width
+ self._rect.height = self.get_content_height(max_width)
+
+ def set_text(self, text: str | Callable[[], str]):
+ """Update the text content."""
+ self._text = text
+ self._cached_text = None # Invalidate cache
+
+ @property
+ def text(self) -> str:
+ """Get the current text content."""
+ return str(_resolve_value(self._text))
+
+ def set_text_color(self, color: rl.Color):
+ """Update the text color."""
+ self._text_color = color
+
+ def set_color(self, color: rl.Color):
+ """Update the text color (alias for set_text_color)."""
+ self.set_text_color(color)
+
+ def set_font_size(self, size: int):
+ """Update the font size."""
+ self._font_size = size
+ self._spacing_pixels = size * self._letter_spacing # Recalculate spacing
+ self._cached_text = None # Invalidate cache
+
+ def set_letter_spacing(self, letter_spacing: float):
+ """Update letter spacing (as percentage, e.g., 0.1 = 10%)."""
+ self._letter_spacing = letter_spacing
+ self._spacing_pixels = self._font_size * letter_spacing
+ self._cached_text = None # Invalidate cache
+
+ def set_font_weight(self, font_weight: FontWeight):
+ """Update the font weight."""
+ if self._font_weight != font_weight:
+ self._font_weight = font_weight
+ self._font = gui_app.font(self._font_weight)
+ self._cached_text = None # Invalidate cache
+
+ def set_alignment(self, alignment: int):
+ """Update the horizontal text alignment."""
+ self._alignment = alignment
+
+ def set_alignment_vertical(self, alignment_vertical: int):
+ """Update the vertical text alignment."""
+ self._alignment_vertical = alignment_vertical
+
+ def set_max_width(self, max_width: int | None):
+ """Set the maximum width constraint for wrapping/eliding."""
+ if self._max_width != max_width:
+ self._max_width = max_width
+ self._cached_text = None # Invalidate cache
+ # Update rect size for Scroller support
+ if max_width is not None:
+ self._rect.width = max_width
+ self._rect.height = self.get_content_height(max_width)
+
+ def _update_text_cache(self, available_width: int):
+ """Update cached text processing data."""
+ text = self.text
+
+ # Check if cache is still valid
+ if (self._cached_text == text and
+ self._cached_width == available_width and
+ self._cached_wrapped_lines):
+ return
+
+ self._cached_text = text
+ self._cached_width = available_width
+
+ # Determine wrapping width
+ content_width = available_width - (self._text_padding * 2)
+ if content_width <= 0:
+ content_width = 1
+
+ # Wrap text if enabled
+ if self._wrap_text:
+ self._cached_wrapped_lines = wrap_text(self._font, text, self._font_size, content_width, self._spacing_pixels)
+ else:
+ # Split by newlines but don't wrap
+ self._cached_wrapped_lines = text.split('\n') if text else [""]
+
+ # Elide lines if needed (for width constraint)
+ self._cached_wrapped_lines = [self._elide_line(line, content_width) for line in self._cached_wrapped_lines]
+
+ # Process each line: measure and find emojis
+ self._cached_line_sizes = []
+ self._cached_line_emojis = []
+
+ for line in self._cached_wrapped_lines:
+ emojis = find_emoji(line)
+ self._cached_line_emojis.append(emojis)
+ # Empty lines should still have height (use font size as line height)
+ if not line:
+ size = rl.Vector2(0, self._font_size * FONT_SCALE)
+ else:
+ size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels)
+ self._cached_line_sizes.append(size)
+
+ # Calculate total height
+ # Each line contributes its measured height * line_height (matching Label's behavior)
+ # This includes spacing to the next line
+ if self._cached_line_sizes:
+ # Match the rendering logic: first line doesn't get line_height scaling
+ total_height = 0.0
+ for idx, size in enumerate(self._cached_line_sizes):
+ if idx == 0:
+ total_height += size.y
+ else:
+ total_height += size.y * self._line_height
+ self._cached_total_height = total_height
+ else:
+ self._cached_total_height = 0.0
+
+ def _elide_line(self, line: str, max_width: int, force: bool = False) -> str:
+ """Elide a single line if it exceeds max_width. If force is True, always elide even if it fits."""
+ if not self._elide and not force:
+ return line
+
+ text_size = measure_text_cached(self._font, line, self._font_size, self._spacing_pixels)
+ if text_size.x <= max_width and not force:
+ return line
+
+ ellipsis = "..."
+ # If force=True and line fits, just append ellipsis without truncating
+ if force and text_size.x <= max_width:
+ ellipsis_size = measure_text_cached(self._font, ellipsis, self._font_size, self._spacing_pixels)
+ if text_size.x + ellipsis_size.x <= max_width:
+ return line + ellipsis
+ # If line + ellipsis doesn't fit, need to truncate
+ # Fall through to binary search below
+
+ left, right = 0, len(line)
+ while left < right:
+ mid = (left + right) // 2
+ candidate = line[:mid] + ellipsis
+ candidate_size = measure_text_cached(self._font, candidate, self._font_size, self._spacing_pixels)
+ if candidate_size.x <= max_width:
+ left = mid + 1
+ else:
+ right = mid
+ return line[:left - 1] + ellipsis if left > 0 else ellipsis
+
+ def get_content_height(self, max_width: int) -> float:
+ """
+ Returns the height needed for text at given max_width.
+ Similar to HtmlRenderer.get_total_height().
+ """
+ # Use max_width if provided, otherwise use self._max_width or a default
+ width = max_width if max_width > 0 else (self._max_width if self._max_width else 1000)
+ self._update_text_cache(width)
+
+ if self._cached_total_height is not None:
+ return self._cached_total_height
+ return 0.0
+
+ def _render(self, rect: rl.Rectangle):
+ """Render the label."""
+ if rect.width <= 0 or rect.height <= 0:
+ return
+
+ # Determine available width
+ available_width = rect.width
+ if self._max_width is not None:
+ available_width = min(available_width, self._max_width)
+
+ # Update text cache
+ self._update_text_cache(int(available_width))
+
+ if not self._cached_wrapped_lines:
+ return
+
+ # Calculate which lines fit in the available height
+ visible_lines: list[str] = []
+ visible_sizes: list[rl.Vector2] = []
+ visible_emojis: list[list[tuple[int, int, str]]] = []
+
+ current_height = 0.0
+ broke_early = False
+ for line, size, emojis in zip(
+ self._cached_wrapped_lines,
+ self._cached_line_sizes,
+ self._cached_line_emojis,
+ strict=True):
+
+ # Calculate height needed for this line
+ # Each line contributes its height * line_height (matching Label's behavior)
+ line_height_needed = size.y * self._line_height
+
+ # Check if this line fits
+ if current_height + line_height_needed > rect.height:
+ # This line doesn't fit
+ if len(visible_lines) == 0:
+ # First line doesn't fit by height - still show it (will be clipped by scissor if needed)
+ # Continue to add this line below
+ pass
+ else:
+ # We have visible lines and this one doesn't fit - mark that we broke early
+ broke_early = True
+ break
+
+ visible_lines.append(line)
+ visible_sizes.append(size)
+ visible_emojis.append(emojis)
+
+ current_height += line_height_needed
+
+ # If we broke early (there are more lines that don't fit) and elide is enabled, elide the last visible line
+ if broke_early and len(visible_lines) > 0 and self._elide:
+ content_width = int(available_width - (self._text_padding * 2))
+ if content_width <= 0:
+ content_width = 1
+
+ last_line_idx = len(visible_lines) - 1
+ last_line = visible_lines[last_line_idx]
+ # Force elide the last line to show "..." even if it fits in width (to indicate more content)
+ elided = self._elide_line(last_line, content_width, force=True)
+ visible_lines[last_line_idx] = elided
+ visible_sizes[last_line_idx] = measure_text_cached(self._font, elided, self._font_size, self._spacing_pixels)
+
+ if not visible_lines:
+ return
+
+ # Calculate total visible text block height
+ # First line is not changed by line_height scaling
+ total_visible_height = 0.0
+ for idx, size in enumerate(visible_sizes):
+ if idx == 0:
+ total_visible_height += size.y
+ else:
+ total_visible_height += size.y * self._line_height
+
+ # Calculate vertical alignment offset
+ if self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP:
+ start_y = rect.y
+ elif self._alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM:
+ start_y = rect.y + rect.height - total_visible_height
+ else: # TEXT_ALIGN_MIDDLE
+ start_y = rect.y + (rect.height - total_visible_height) / 2
+
+ # Render each line
+ current_y = start_y
+ for idx, (line, size, emojis) in enumerate(zip(visible_lines, visible_sizes, visible_emojis, strict=True)):
+ # Calculate horizontal position
+ if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT:
+ line_x = rect.x + self._text_padding
+ elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER:
+ line_x = rect.x + (rect.width - size.x) / 2
+ elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT:
+ line_x = rect.x + rect.width - size.x - self._text_padding
+ else:
+ line_x = rect.x + self._text_padding
+
+ # Render line with emojis
+ line_pos = rl.Vector2(line_x, current_y)
+ prev_index = 0
+
+ for start, end, emoji in emojis:
+ # Draw text before emoji
+ text_before = line[prev_index:start]
+ if text_before:
+ rl.draw_text_ex(self._font, text_before, line_pos, self._font_size, self._spacing_pixels, self._text_color)
+ width_before = measure_text_cached(self._font, text_before, self._font_size, self._spacing_pixels)
+ line_pos.x += width_before.x
+
+ # Draw emoji
+ tex = emoji_tex(emoji)
+ emoji_scale = self._font_size / tex.height * FONT_SCALE
+ rl.draw_texture_ex(tex, line_pos, 0.0, emoji_scale, self._text_color)
+ # Emoji width is font_size * FONT_SCALE (as per measure_text_cached)
+ line_pos.x += self._font_size * FONT_SCALE
+ prev_index = end
+
+ # Draw remaining text after last emoji
+ text_after = line[prev_index:]
+ if text_after:
+ rl.draw_text_ex(self._font, text_after, line_pos, self._font_size, self._spacing_pixels, self._text_color)
+
+ # Move to next line (if not last line)
+ if idx < len(visible_lines) - 1:
+ # Use current line's height * line_height for spacing to next line
+ current_y += size.y * self._line_height
diff --git a/system/ui/widgets/mici_keyboard.py b/system/ui/widgets/mici_keyboard.py
new file mode 100644
index 0000000000..a4f4c7d09b
--- /dev/null
+++ b/system/ui/widgets/mici_keyboard.py
@@ -0,0 +1,386 @@
+from enum import IntEnum
+import pyray as rl
+import numpy as np
+from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, MouseEvent
+from openpilot.system.ui.lib.text_measure import measure_text_cached
+from openpilot.system.ui.widgets import Widget
+from openpilot.common.filter_simple import BounceFilter
+
+CHAR_FONT_SIZE = 42
+CHAR_NEAR_FONT_SIZE = CHAR_FONT_SIZE * 2
+SELECTED_CHAR_FONT_SIZE = 128
+CHAR_CAPS_FONT_SIZE = 38 # TODO: implement this
+NUMBER_LAYER_SWITCH_FONT_SIZE = 24
+KEYBOARD_COLUMN_PADDING = 33
+KEYBOARD_ROW_PADDING = {0: 44, 1: 33, 2: 44} # TODO: 2 should be 116 with extra control keys added in
+
+KEY_TOUCH_AREA_OFFSET = 10 # px
+KEY_DRAG_HYSTERESIS = 5 # px
+KEY_MIN_ANIMATION_TIME = 0.075 # s
+
+DEBUG = False
+ANIMATION_SCALE = 0.65
+
+
+def zip_repeat(a, b):
+ la, lb = len(a), len(b)
+ for i in range(max(la, lb)):
+ yield (a[i] if i < la else a[-1],
+ b[i] if i < lb else b[-1])
+
+
+def fast_euclidean_distance(dx, dy):
+ # https://en.wikibooks.org/wiki/Algorithms/Distance_approximations
+ max_d, min_d = abs(dx), abs(dy)
+ if max_d < min_d:
+ max_d, min_d = min_d, max_d
+ return 0.941246 * max_d + 0.41 * min_d
+
+
+class Key(Widget):
+ def __init__(self, char: str):
+ super().__init__()
+ self.char = char
+ self._font = gui_app.font(FontWeight.SEMI_BOLD)
+ self._x_filter = BounceFilter(0.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
+ self._y_filter = BounceFilter(0.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
+ self._size_filter = BounceFilter(CHAR_FONT_SIZE, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
+ self._alpha_filter = BounceFilter(1.0, 0.075 * ANIMATION_SCALE, 1 / gui_app.target_fps)
+
+ self._color = rl.Color(255, 255, 255, 255)
+
+ self._position_initialized = False
+ self.original_position = rl.Vector2(0, 0)
+
+ def set_position(self, x: float, y: float, smooth: bool = True):
+ # TODO: swipe up from NavWidget has the keys lag behind other elements a bit
+ if not self._position_initialized:
+ self._x_filter.x = x
+ self._y_filter.x = y
+ # keep track of original position so dragging around feels consistent. also move touch area down a bit
+ self.original_position = rl.Vector2(x, y + KEY_TOUCH_AREA_OFFSET)
+ self._position_initialized = True
+
+ if not smooth:
+ self._x_filter.x = x
+ self._y_filter.x = y
+
+ self._rect.x = self._x_filter.update(x)
+ self._rect.y = self._y_filter.update(y)
+
+ def set_alpha(self, alpha: float):
+ self._alpha_filter.update(alpha)
+
+ def get_position(self) -> tuple[float, float]:
+ return self._rect.x, self._rect.y
+
+ def _update_state(self):
+ self._color.a = min(int(255 * self._alpha_filter.x), 255)
+
+ def _render(self, _):
+ # center char at rect position
+ text_size = measure_text_cached(self._font, self.char, self._get_font_size())
+ x = self._rect.x + self._rect.width / 2 - text_size.x / 2
+ y = self._rect.y + self._rect.height / 2 - text_size.y / 2
+ rl.draw_text_ex(self._font, self.char, (x, y), self._get_font_size(), 0, self._color)
+
+ if DEBUG:
+ rl.draw_circle(int(self._rect.x), int(self._rect.y), 5, rl.RED) # Debug: draw circle around key
+ rl.draw_rectangle_lines_ex(self._rect, 2, rl.RED)
+
+ def set_font_size(self, size: float):
+ self._size_filter.update(size)
+
+ def _get_font_size(self) -> int:
+ return int(round(self._size_filter.x))
+
+
+class SmallKey(Key):
+ def __init__(self, chars: str):
+ super().__init__(chars)
+ self._size_filter.x = NUMBER_LAYER_SWITCH_FONT_SIZE
+
+ def set_font_size(self, size: float):
+ self._size_filter.update(size * (NUMBER_LAYER_SWITCH_FONT_SIZE / CHAR_FONT_SIZE))
+
+
+class IconKey(Key):
+ def __init__(self, icon: str, vertical_align: str = "center", char: str = ""):
+ super().__init__(char)
+ self._icon = gui_app.texture(icon, 38, 38)
+ self._vertical_align = vertical_align
+
+ def set_icon(self, icon: str):
+ self._icon = gui_app.texture(icon, 38, 38)
+
+ def _render(self, _):
+ scale = np.interp(self._size_filter.x, [CHAR_FONT_SIZE, CHAR_NEAR_FONT_SIZE], [1, 1.5])
+
+ if self._vertical_align == "center":
+ dest_rec = rl.Rectangle(self._rect.x + (self._rect.width - self._icon.width * scale) / 2,
+ self._rect.y + (self._rect.height - self._icon.height * scale) / 2,
+ self._icon.width * scale, self._icon.height * scale)
+ src_rec = rl.Rectangle(0, 0, self._icon.width, self._icon.height)
+ rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color)
+
+ elif self._vertical_align == "bottom":
+ dest_rec = rl.Rectangle(self._rect.x + (self._rect.width - self._icon.width * scale) / 2, self._rect.y,
+ self._icon.width * scale, self._icon.height * scale)
+ src_rec = rl.Rectangle(0, 0, self._icon.width, self._icon.height)
+ rl.draw_texture_pro(self._icon, src_rec, dest_rec, rl.Vector2(0, 0), 0, self._color)
+
+ if DEBUG:
+ rl.draw_circle(int(self._rect.x), int(self._rect.y), 5, rl.RED) # Debug: draw circle around key
+ rl.draw_rectangle_lines_ex(self._rect, 2, rl.RED)
+
+
+class CapsState(IntEnum):
+ LOWER = 0
+ UPPER = 1
+ LOCK = 2
+
+
+class MiciKeyboard(Widget):
+ def __init__(self):
+ super().__init__()
+
+ lower_chars = [
+ "qwertyuiop",
+ "asdfghjkl",
+ "zxcvbnm",
+ ]
+ upper_chars = ["".join([char.upper() for char in row]) for row in lower_chars]
+ special_chars = [
+ "1234567890",
+ "-/:;()$&@\"",
+ "~.,?!'#%",
+ ]
+ super_special_chars = [
+ "1234567890",
+ "`[]{}^*+=_",
+ "\\|<>¥€£•",
+ ]
+
+ self._lower_keys = [[Key(char) for char in row] for row in lower_chars]
+ self._upper_keys = [[Key(char) for char in row] for row in upper_chars]
+ self._special_keys = [[Key(char) for char in row] for row in special_chars]
+ self._super_special_keys = [[Key(char) for char in row] for row in super_special_chars]
+
+ # control keys
+ self._space_key = IconKey("icons_mici/settings/keyboard/space.png", char=" ", vertical_align="bottom")
+ self._caps_key = IconKey("icons_mici/settings/keyboard/caps_lower.png")
+ # these two are in different places on some layouts
+ self._123_key, self._123_key2 = SmallKey("123"), SmallKey("123")
+ self._abc_key = SmallKey("abc")
+ self._super_special_key = SmallKey("#+=")
+
+ # insert control keys
+ for keys in (self._lower_keys, self._upper_keys):
+ keys[2].insert(0, self._caps_key)
+ keys[2].append(self._123_key)
+
+ for keys in (self._lower_keys, self._upper_keys, self._special_keys, self._super_special_keys):
+ keys[1].append(self._space_key)
+
+ for keys in (self._special_keys, self._super_special_keys):
+ keys[2].append(self._abc_key)
+
+ self._special_keys[2].insert(0, self._super_special_key)
+ self._super_special_keys[2].insert(0, self._123_key2)
+
+ # set initial keys
+ self._current_keys: list[list[Key]] = []
+ self._set_keys(self._lower_keys)
+ self._caps_state = CapsState.LOWER
+ self._initialized = False
+
+ self._load_images()
+
+ self._closest_key: tuple[Key | None, float] = None, float('inf')
+ self._selected_key_t: float | None = None # time key was initially selected
+ self._unselect_key_t: float | None = None # time to unselect key after release
+ self._dragging_on_keyboard = False
+
+ self._text: str = ""
+
+ self._bg_scale_filter = BounceFilter(1.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps)
+
+ def get_candidate_character(self) -> str:
+ # return str of character about to be added to text
+ key = self._closest_key[0]
+ return key.char if key is not None and key.__class__ is Key and self._dragging_on_keyboard else ""
+
+ def get_keyboard_height(self) -> int:
+ return int(self._txt_bg.height)
+
+ def _load_images(self):
+ self._txt_bg = gui_app.texture("icons_mici/settings/keyboard/keyboard_background.png", 520, 170, keep_aspect_ratio=False)
+
+ def _set_keys(self, keys: list[list[Key]]):
+ # inherit previous keys' positions to fix switching animation
+ for current_row, row in zip(self._current_keys, keys, strict=False):
+ # not all layouts have the same number of keys
+ for current_key, key in zip_repeat(current_row, row):
+ current_pos = current_key.get_position()
+ key.set_position(current_pos[0], current_pos[1], smooth=False)
+
+ self._current_keys = keys
+
+ def set_text(self, text: str):
+ self._text = text
+
+ def text(self) -> str:
+ return self._text
+
+ def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
+ keyboard_pos_y = self._rect.y + self._rect.height - self._txt_bg.height
+ if mouse_event.left_pressed:
+ if mouse_event.pos.y > keyboard_pos_y:
+ self._dragging_on_keyboard = True
+ elif mouse_event.left_released:
+ self._dragging_on_keyboard = False
+
+ if mouse_event.left_down and self._dragging_on_keyboard:
+ self._closest_key = self._get_closest_key()
+ if self._selected_key_t is None:
+ self._selected_key_t = rl.get_time()
+
+ # unselect key temporarily if mouse goes above keyboard
+ if mouse_event.pos.y <= keyboard_pos_y:
+ self._closest_key = (None, float('inf'))
+
+ if DEBUG:
+ print('HANDLE MOUSE EVENT', mouse_event, self._closest_key[0].char if self._closest_key[0] else 'None')
+
+ def _get_closest_key(self) -> tuple[Key | None, float]:
+ closest_key: tuple[Key | None, float] = (None, float('inf'))
+ for row in self._current_keys:
+ for key in row:
+ mouse_pos = gui_app.last_mouse_event.pos
+ # approximate distance for comparison is accurate enough
+ dist = abs(key.original_position.x - mouse_pos.x) + abs(key.original_position.y - mouse_pos.y)
+ if dist < closest_key[1]:
+ if self._closest_key[0] is None or key is self._closest_key[0] or dist < self._closest_key[1] - KEY_DRAG_HYSTERESIS:
+ closest_key = (key, dist)
+ return closest_key
+
+ def _set_uppercase(self, cycle: bool):
+ self._set_keys(self._upper_keys if cycle else self._lower_keys)
+ if not cycle:
+ self._caps_state = CapsState.LOWER
+ self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lower.png")
+ else:
+ if self._caps_state == CapsState.LOWER:
+ self._caps_state = CapsState.UPPER
+ self._caps_key.set_icon("icons_mici/settings/keyboard/caps_upper.png")
+ elif self._caps_state == CapsState.UPPER:
+ self._caps_state = CapsState.LOCK
+ self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lock.png")
+ else:
+ self._set_uppercase(False)
+
+ def _handle_mouse_release(self, mouse_pos: MousePos):
+ if self._closest_key[0] is not None:
+ if self._closest_key[0] == self._caps_key:
+ self._set_uppercase(True)
+ elif self._closest_key[0] in (self._123_key, self._123_key2):
+ self._set_keys(self._special_keys)
+ elif self._closest_key[0] == self._abc_key:
+ self._set_uppercase(False)
+ elif self._closest_key[0] == self._super_special_key:
+ self._set_keys(self._super_special_keys)
+ else:
+ self._text += self._closest_key[0].char
+
+ # Reset caps state
+ if self._caps_state == CapsState.UPPER:
+ self._set_uppercase(False)
+
+ # ensure minimum selected animation time
+ key_selected_dt = rl.get_time() - (self._selected_key_t or 0)
+ cur_t = rl.get_time()
+ self._unselect_key_t = cur_t + KEY_MIN_ANIMATION_TIME if (key_selected_dt < KEY_MIN_ANIMATION_TIME) else cur_t
+
+ def backspace(self):
+ if self._text:
+ self._text = self._text[:-1]
+
+ def space(self):
+ self._text += ' '
+
+ def _update_state(self):
+ # unselect key after animation plays
+ if self._unselect_key_t is not None and rl.get_time() > self._unselect_key_t:
+ self._closest_key = (None, float('inf'))
+ self._unselect_key_t = None
+ self._selected_key_t = None
+
+ def _lay_out_keys(self, bg_x, bg_y, keys: list[list[Key]]):
+ key_rect = rl.Rectangle(bg_x, bg_y, self._txt_bg.width, self._txt_bg.height)
+ for row_idx, row in enumerate(keys):
+ padding = KEYBOARD_ROW_PADDING[row_idx]
+ step_y = (key_rect.height - 2 * KEYBOARD_COLUMN_PADDING) / (len(keys) - 1)
+ for key_idx, key in enumerate(row):
+ key_x = key_rect.x + padding + key_idx * ((key_rect.width - 2 * padding) / (len(row) - 1))
+ key_y = key_rect.y + KEYBOARD_COLUMN_PADDING + row_idx * step_y
+
+ if self._closest_key[0] is None:
+ key.set_alpha(1.0)
+ key.set_font_size(CHAR_FONT_SIZE)
+ elif key == self._closest_key[0]:
+ # push key up with a max and inward so user can see key easier
+ key_y = max(key_y - 120, 40)
+ key_x += np.interp(key_x, [self._rect.x, self._rect.x + self._rect.width], [100, -100])
+ key.set_alpha(1.0)
+ key.set_font_size(SELECTED_CHAR_FONT_SIZE)
+
+ # draw black circle behind selected key
+ rl.draw_circle_gradient(int(key_x + key.rect.width / 2), int(key_y + key.rect.height / 2),
+ SELECTED_CHAR_FONT_SIZE, rl.Color(0, 0, 0, 225), rl.BLANK)
+ else:
+ # move other keys away from selected key a bit
+ dx = key.original_position.x - self._closest_key[0].original_position.x
+ dy = key.original_position.y - self._closest_key[0].original_position.y
+ distance_from_selected_key = fast_euclidean_distance(dx, dy)
+
+ inv = 1 / (distance_from_selected_key or 1.0)
+ ux = dx * inv
+ uy = dy * inv
+
+ # NOTE: hardcode to 20 to get entire keyboard to move
+ push_pixels = np.interp(distance_from_selected_key, [0, 250], [20, 0])
+ key_x += ux * push_pixels
+ key_y += uy * push_pixels
+
+ # TODO: slow enough to use an approximation or nah? also caching might work
+ font_size = np.interp(distance_from_selected_key, [0, 150], [CHAR_NEAR_FONT_SIZE, CHAR_FONT_SIZE])
+
+ key_alpha = np.interp(distance_from_selected_key, [0, 100], [1.0, 0.35])
+ key.set_alpha(key_alpha)
+ key.set_font_size(font_size)
+
+ # TODO: I like the push amount, so we should clip the pos inside the keyboard rect
+ key.set_position(key_x, key_y)
+
+ def _render(self, _):
+ # draw bg
+ bg_x = self._rect.x + (self._rect.width - self._txt_bg.width) / 2
+ bg_y = self._rect.y + self._rect.height - self._txt_bg.height
+
+ scale = self._bg_scale_filter.update(1.0307692307692307 if self._closest_key[0] is not None else 1.0)
+ src_rec = rl.Rectangle(0, 0, self._txt_bg.width, self._txt_bg.height)
+ dest_rec = rl.Rectangle(self._rect.x + self._rect.width / 2 - self._txt_bg.width * scale / 2, bg_y,
+ self._txt_bg.width * scale, self._txt_bg.height)
+
+ rl.draw_texture_pro(self._txt_bg, src_rec, dest_rec, rl.Vector2(0, 0), 0.0, rl.WHITE)
+
+ # draw keys
+ if not self._initialized:
+ for keys in (self._lower_keys, self._upper_keys, self._special_keys, self._super_special_keys):
+ self._lay_out_keys(bg_x, bg_y, keys)
+ self._initialized = True
+
+ self._lay_out_keys(bg_x, bg_y, self._current_keys)
+ for row in self._current_keys:
+ for key in row:
+ key.render()
diff --git a/system/ui/widgets/network.py b/system/ui/widgets/network.py
index 592c9de971..f41a04c249 100644
--- a/system/ui/widgets/network.py
+++ b/system/ui/widgets/network.py
@@ -12,7 +12,7 @@ from openpilot.system.ui.widgets.button import ButtonStyle, Button
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.widgets.keyboard import Keyboard
from openpilot.system.ui.widgets.label import gui_label
-from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.scroller_tici import Scroller
from openpilot.system.ui.widgets.list_view import ButtonAction, ListItem, MultipleButtonAction, ToggleAction, button_item, text_item
# These are only used for AdvancedNetworkSettings, standalone apps just need WifiManagerUI
diff --git a/system/ui/widgets/option_dialog.py b/system/ui/widgets/option_dialog.py
index 3b2201164a..62578d1cfb 100644
--- a/system/ui/widgets/option_dialog.py
+++ b/system/ui/widgets/option_dialog.py
@@ -4,7 +4,7 @@ from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget, DialogResult
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.label import gui_label
-from openpilot.system.ui.widgets.scroller import Scroller
+from openpilot.system.ui.widgets.scroller_tici import Scroller
# Constants
MARGIN = 50
diff --git a/system/ui/widgets/scroller.py b/system/ui/widgets/scroller.py
index a843010d56..9a04e84257 100644
--- a/system/ui/widgets/scroller.py
+++ b/system/ui/widgets/scroller.py
@@ -1,10 +1,21 @@
import pyray as rl
-from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
+import numpy as np
+from collections.abc import Callable
+
+from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter
+from openpilot.system.ui.lib.application import gui_app
+from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState
from openpilot.system.ui.widgets import Widget
-ITEM_SPACING = 40
+ITEM_SPACING = 20
LINE_COLOR = rl.GRAY
LINE_PADDING = 40
+ANIMATION_SCALE = 0.6
+
+MIN_ZOOM_ANIMATION_TIME = 0.075 # seconds
+DO_ZOOM = False
+DO_JELLO = False
+SCROLL_BAR = False
class LineSeparator(Widget):
@@ -23,24 +34,133 @@ class LineSeparator(Widget):
class Scroller(Widget):
- def __init__(self, items: list[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True):
+ def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = True, spacing: int = ITEM_SPACING,
+ line_separator: bool = False, pad_start: int = ITEM_SPACING, pad_end: int = ITEM_SPACING):
super().__init__()
self._items: list[Widget] = []
+ self._horizontal = horizontal
+ self._snap_items = snap_items
self._spacing = spacing
self._line_separator = LineSeparator() if line_separator else None
+ self._pad_start = pad_start
self._pad_end = pad_end
- self.scroll_panel = GuiScrollPanel()
+ self._reset_scroll_at_show = True
+
+ self._scrolling_to: float | None = None
+ self._scroll_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
+ self._zoom_filter = FirstOrderFilter(1.0, 0.2, 1 / gui_app.target_fps)
+ self._zoom_out_t: float = 0.0
+
+ self._item_pos_filter = BounceFilter(0.0, 0.05, 1 / gui_app.target_fps)
+
+ # when not pressed, snap to closest item to be center
+ self._scroll_snap_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps)
+
+ self.scroll_panel = GuiScrollPanel2(self._horizontal, handle_out_of_bounds=not self._snap_items)
+ self._scroll_enabled: bool | Callable[[], bool] = True
+
+ self._txt_scroll_indicator = gui_app.texture("icons_mici/settings/vertical_scroll_indicator.png", 40, 80)
for item in items:
self.add_widget(item)
+ def set_reset_scroll_at_show(self, scroll: bool):
+ self._reset_scroll_at_show = scroll
+
+ def scroll_to(self, pos: float, smooth: bool = False):
+ # already there
+ if abs(pos) < 1:
+ return
+
+ # FIXME: the padding correction doesn't seem correct
+ scroll_offset = self.scroll_panel.get_offset() - pos + self._pad_end
+ if smooth:
+ self._scrolling_to = scroll_offset
+ else:
+ self.scroll_panel.set_offset(scroll_offset)
+
+ @property
+ def is_auto_scrolling(self) -> bool:
+ return self._scrolling_to is not None
+
def add_widget(self, item: Widget) -> None:
self._items.append(item)
- item.set_touch_valid_callback(self.scroll_panel.is_touch_valid)
+ item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled)
+
+ def set_scrolling_enabled(self, enabled: bool | Callable[[], bool]) -> None:
+ """Set whether scrolling is enabled (does not affect widget enabled state)."""
+ self._scroll_enabled = enabled
+
+ def _update_state(self):
+ if DO_ZOOM:
+ if self._scrolling_to is not None or self.scroll_panel.state != ScrollState.STEADY:
+ self._zoom_out_t = rl.get_time() + MIN_ZOOM_ANIMATION_TIME
+ self._zoom_filter.update(0.85)
+ else:
+ if self._zoom_out_t is not None:
+ if rl.get_time() > self._zoom_out_t:
+ self._zoom_filter.update(1.0)
+ else:
+ self._zoom_filter.update(0.85)
+
+ # Cancel auto-scroll if user starts manually scrolling
+ if self._scrolling_to is not None and (self.scroll_panel.state == ScrollState.PRESSED or self.scroll_panel.state == ScrollState.MANUAL_SCROLL):
+ self._scrolling_to = None
+
+ if self._scrolling_to is not None:
+ self._scroll_filter.update(self._scrolling_to)
+ self.scroll_panel.set_offset(self._scroll_filter.x)
+
+ if abs(self._scroll_filter.x - self._scrolling_to) < 1:
+ self.scroll_panel.set_offset(self._scrolling_to)
+ self._scrolling_to = None
+ else:
+ # keep current scroll position up to date
+ self._scroll_filter.x = self.scroll_panel.get_offset()
+
+ def _get_scroll(self, visible_items: list[Widget], content_size: float) -> float:
+ scroll_enabled = self._scroll_enabled() if callable(self._scroll_enabled) else self._scroll_enabled
+ self.scroll_panel.set_enabled(scroll_enabled and self.enabled)
+ self.scroll_panel.update(self._rect, content_size)
+ if not self._snap_items:
+ return self.scroll_panel.get_offset()
+
+ # Snap closest item to center
+ center_pos = self._rect.x + self._rect.width / 2 if self._horizontal else self._rect.y + self._rect.height / 2
+ closest_delta_pos = float('inf')
+ scroll_snap_idx: int | None = None
+ for idx, item in enumerate(visible_items):
+ if self._horizontal:
+ delta_pos = (item.rect.x + item.rect.width / 2) - center_pos
+ else:
+ delta_pos = (item.rect.y + item.rect.height / 2) - center_pos
+ if abs(delta_pos) < abs(closest_delta_pos):
+ closest_delta_pos = delta_pos
+ scroll_snap_idx = idx
+
+ if scroll_snap_idx is not None:
+ snap_item = visible_items[scroll_snap_idx]
+ if self.is_pressed:
+ # no snapping until released
+ self._scroll_snap_filter.x = 0
+ else:
+ # TODO: this doesn't handle two small buttons at the edges well
+ if self._horizontal:
+ snap_delta_pos = (center_pos - (snap_item.rect.x + snap_item.rect.width / 2)) / 10
+ snap_delta_pos = min(snap_delta_pos, -self.scroll_panel.get_offset() / 10)
+ snap_delta_pos = max(snap_delta_pos, (self._rect.width - self.scroll_panel.get_offset() - content_size) / 10)
+ else:
+ snap_delta_pos = (center_pos - (snap_item.rect.y + snap_item.rect.height / 2)) / 10
+ snap_delta_pos = min(snap_delta_pos, -self.scroll_panel.get_offset() / 10)
+ snap_delta_pos = max(snap_delta_pos, (self._rect.height - self.scroll_panel.get_offset() - content_size) / 10)
+ self._scroll_snap_filter.update(snap_delta_pos)
+
+ self.scroll_panel.set_offset(self.scroll_panel.get_offset() + self._scroll_snap_filter.x)
+
+ return self.scroll_panel.get_offset()
def _render(self, _):
- # TODO: don't draw items that are not in the viewport
visible_items = [item for item in self._items if item.is_visible]
# Add line separator between items
@@ -49,38 +169,82 @@ class Scroller(Widget):
for i in range(1, len(visible_items)):
visible_items.insert(l - i, self._line_separator)
- content_height = sum(item.rect.height for item in visible_items) + self._spacing * (len(visible_items))
- if not self._pad_end:
- content_height -= self._spacing
- scroll = self.scroll_panel.update(self._rect, rl.Rectangle(0, 0, self._rect.width, content_height))
+ content_size = sum(item.rect.width if self._horizontal else item.rect.height for item in visible_items)
+ content_size += self._spacing * (len(visible_items) - 1)
+ content_size += self._pad_start + self._pad_end
+
+ scroll_offset = self._get_scroll(visible_items, content_size)
rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y),
int(self._rect.width), int(self._rect.height))
- cur_height = 0
- for idx, item in enumerate(visible_items):
- if not item.is_visible:
- continue
+ self._item_pos_filter.update(scroll_offset)
- # Nicely lay out items vertically
- x = self._rect.x
- y = self._rect.y + cur_height + self._spacing * (idx != 0)
- cur_height += item.rect.height + self._spacing * (idx != 0)
+ cur_pos = 0
+ for idx, item in enumerate(visible_items):
+ spacing = self._spacing if (idx > 0) else self._pad_start
+ # Nicely lay out items horizontally/vertically
+ if self._horizontal:
+ x = self._rect.x + cur_pos + spacing
+ y = self._rect.y + (self._rect.height - item.rect.height) / 2
+ cur_pos += item.rect.width + spacing
+ else:
+ x = self._rect.x + (self._rect.width - item.rect.width) / 2
+ y = self._rect.y + cur_pos + spacing
+ cur_pos += item.rect.height + spacing
# Consider scroll
- y += scroll
+ if self._horizontal:
+ x += scroll_offset
+ else:
+ y += scroll_offset
+
+ # Add some jello effect when scrolling
+ if DO_JELLO:
+ if self._horizontal:
+ cx = self._rect.x + self._rect.width / 2
+ jello_offset = scroll_offset - np.interp(x + item.rect.width / 2,
+ [self._rect.x, cx, self._rect.x + self._rect.width],
+ [self._item_pos_filter.x, scroll_offset, self._item_pos_filter.x])
+ x -= np.clip(jello_offset, -20, 20)
+ else:
+ cy = self._rect.y + self._rect.height / 2
+ jello_offset = scroll_offset - np.interp(y + item.rect.height / 2,
+ [self._rect.y, cy, self._rect.y + self._rect.height],
+ [self._item_pos_filter.x, scroll_offset, self._item_pos_filter.x])
+ y -= np.clip(jello_offset, -20, 20)
# Update item state
- item.set_position(x, y)
+ item.set_position(round(x), round(y)) # round to prevent jumping when settling
item.set_parent_rect(self._rect)
+
+ # Skip rendering if not in viewport
+ if not rl.check_collision_recs(item.rect, self._rect):
+ continue
+
+ # Scale each element around its own origin when scrolling
+ scale = self._zoom_filter.x
+ rl.rl_push_matrix()
+ rl.rl_scalef(scale, scale, 1.0)
+ rl.rl_translatef((1 - scale) * (x + item.rect.width / 2) / scale,
+ (1 - scale) * (y + item.rect.height / 2) / scale, 0)
item.render()
+ rl.rl_pop_matrix()
+
+ # Draw scroll indicator
+ if SCROLL_BAR and not self._horizontal and len(visible_items) > 0:
+ _real_content_size = content_size - self._rect.height + self._txt_scroll_indicator.height
+ scroll_bar_y = -scroll_offset / _real_content_size * self._rect.height
+ scroll_bar_y = min(max(scroll_bar_y, self._rect.y), self._rect.y + self._rect.height - self._txt_scroll_indicator.height)
+ rl.draw_texture_ex(self._txt_scroll_indicator, rl.Vector2(self._rect.x, scroll_bar_y), 0, 1.0, rl.WHITE)
rl.end_scissor_mode()
def show_event(self):
super().show_event()
- # Reset to top
- self.scroll_panel.set_offset(0)
+ if self._reset_scroll_at_show:
+ self.scroll_to(self.scroll_panel.get_offset())
+
for item in self._items:
item.show_event()
diff --git a/system/ui/widgets/scroller_tici.py b/system/ui/widgets/scroller_tici.py
new file mode 100644
index 0000000000..a843010d56
--- /dev/null
+++ b/system/ui/widgets/scroller_tici.py
@@ -0,0 +1,90 @@
+import pyray as rl
+from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
+from openpilot.system.ui.widgets import Widget
+
+ITEM_SPACING = 40
+LINE_COLOR = rl.GRAY
+LINE_PADDING = 40
+
+
+class LineSeparator(Widget):
+ def __init__(self, height: int = 1):
+ super().__init__()
+ self._rect = rl.Rectangle(0, 0, 0, height)
+
+ def set_parent_rect(self, parent_rect: rl.Rectangle) -> None:
+ super().set_parent_rect(parent_rect)
+ self._rect.width = parent_rect.width
+
+ def _render(self, _):
+ rl.draw_line(int(self._rect.x) + LINE_PADDING, int(self._rect.y),
+ int(self._rect.x + self._rect.width) - LINE_PADDING, int(self._rect.y),
+ LINE_COLOR)
+
+
+class Scroller(Widget):
+ def __init__(self, items: list[Widget], spacing: int = ITEM_SPACING, line_separator: bool = False, pad_end: bool = True):
+ super().__init__()
+ self._items: list[Widget] = []
+ self._spacing = spacing
+ self._line_separator = LineSeparator() if line_separator else None
+ self._pad_end = pad_end
+
+ self.scroll_panel = GuiScrollPanel()
+
+ for item in items:
+ self.add_widget(item)
+
+ def add_widget(self, item: Widget) -> None:
+ self._items.append(item)
+ item.set_touch_valid_callback(self.scroll_panel.is_touch_valid)
+
+ def _render(self, _):
+ # TODO: don't draw items that are not in the viewport
+ visible_items = [item for item in self._items if item.is_visible]
+
+ # Add line separator between items
+ if self._line_separator is not None:
+ l = len(visible_items)
+ for i in range(1, len(visible_items)):
+ visible_items.insert(l - i, self._line_separator)
+
+ content_height = sum(item.rect.height for item in visible_items) + self._spacing * (len(visible_items))
+ if not self._pad_end:
+ content_height -= self._spacing
+ scroll = self.scroll_panel.update(self._rect, rl.Rectangle(0, 0, self._rect.width, content_height))
+
+ rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y),
+ int(self._rect.width), int(self._rect.height))
+
+ cur_height = 0
+ for idx, item in enumerate(visible_items):
+ if not item.is_visible:
+ continue
+
+ # Nicely lay out items vertically
+ x = self._rect.x
+ y = self._rect.y + cur_height + self._spacing * (idx != 0)
+ cur_height += item.rect.height + self._spacing * (idx != 0)
+
+ # Consider scroll
+ y += scroll
+
+ # Update item state
+ item.set_position(x, y)
+ item.set_parent_rect(self._rect)
+ item.render()
+
+ rl.end_scissor_mode()
+
+ def show_event(self):
+ super().show_event()
+ # Reset to top
+ self.scroll_panel.set_offset(0)
+ for item in self._items:
+ item.show_event()
+
+ def hide_event(self):
+ super().hide_event()
+ for item in self._items:
+ item.hide_event()
diff --git a/system/ui/widgets/slider.py b/system/ui/widgets/slider.py
new file mode 100644
index 0000000000..b17d8f3b7c
--- /dev/null
+++ b/system/ui/widgets/slider.py
@@ -0,0 +1,183 @@
+from collections.abc import Callable
+
+import pyray as rl
+
+from openpilot.system.ui.lib.application import gui_app, FontWeight
+from openpilot.system.ui.widgets import Widget
+from openpilot.system.ui.widgets.label import UnifiedLabel
+from openpilot.common.filter_simple import FirstOrderFilter
+
+
+class SmallSlider(Widget):
+ HORIZONTAL_PADDING = 8
+ CONFIRM_DELAY = 0.2
+
+ def __init__(self, title: str, confirm_callback: Callable | None = None):
+ # TODO: unify this with BigConfirmationDialogV2
+ super().__init__()
+ self._confirm_callback = confirm_callback
+
+ self._font = gui_app.font(FontWeight.DISPLAY)
+
+ self._load_assets()
+
+ self._drag_threshold = -self._rect.width // 2
+
+ # State
+ self._opacity = 1.0
+ self._confirmed_time = 0.0
+ self._confirm_callback_called = False # we keep dialog open by default, only call once
+ self._start_x_circle = 0.0
+ self._scroll_x_circle = 0.0
+ self._scroll_x_circle_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
+
+ self._is_dragging_circle = False
+
+ self._label = UnifiedLabel(title, font_size=36, font_weight=FontWeight.MEDIUM, text_color=rl.Color(255, 255, 255, int(255 * 0.65)),
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
+ alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9)
+
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 316 + self.HORIZONTAL_PADDING * 2, 100))
+
+ self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg.png", 316, 100)
+ self._circle_bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_red_circle.png", 100, 100)
+ self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 37, 32)
+
+ @property
+ def confirmed(self) -> bool:
+ return self._confirmed_time > 0.0
+
+ def reset(self):
+ # reset all slider state
+ self._is_dragging_circle = False
+ self._confirmed_time = 0.0
+ self._confirm_callback_called = False
+
+ def set_opacity(self, opacity: float):
+ self._opacity = opacity
+
+ @property
+ def slider_percentage(self):
+ activated_pos = -self._bg_txt.width + self._circle_bg_txt.width
+ return min(max(-self._scroll_x_circle_filter.x / abs(activated_pos), 0.0), 1.0)
+
+ def _on_confirm(self):
+ if self._confirm_callback:
+ self._confirm_callback()
+
+ def _handle_mouse_event(self, mouse_event):
+ super()._handle_mouse_event(mouse_event)
+
+ if mouse_event.left_pressed:
+ # touch rect goes to the padding
+ circle_button_rect = rl.Rectangle(
+ self._rect.x + (self._rect.width - self._circle_bg_txt.width) + self._scroll_x_circle_filter.x - self.HORIZONTAL_PADDING * 2,
+ self._rect.y,
+ self._circle_bg_txt.width + self.HORIZONTAL_PADDING * 2,
+ self._rect.height,
+ )
+ if rl.check_collision_point_rec(mouse_event.pos, circle_button_rect):
+ self._start_x_circle = mouse_event.pos.x
+ self._is_dragging_circle = True
+
+ elif mouse_event.left_released:
+ # swiped to left
+ if self._scroll_x_circle_filter.x < self._drag_threshold:
+ self._confirmed_time = rl.get_time()
+
+ self._is_dragging_circle = False
+
+ if self._is_dragging_circle:
+ self._scroll_x_circle = mouse_event.pos.x - self._start_x_circle
+
+ def _update_state(self):
+ super()._update_state()
+ # TODO: this math can probably be cleaned up to remove duplicate stuff
+ activated_pos = int(-self._bg_txt.width + self._circle_bg_txt.width)
+ self._scroll_x_circle = max(min(self._scroll_x_circle, 0), activated_pos)
+
+ if self._confirmed_time > 0:
+ # swiped left to confirm
+ self._scroll_x_circle_filter.update(activated_pos)
+
+ # activate once animation completes, small threshold for small floats
+ if self._scroll_x_circle_filter.x < (activated_pos + 1):
+ if not self._confirm_callback_called and (rl.get_time() - self._confirmed_time) >= self.CONFIRM_DELAY:
+ self._on_confirm()
+ self._confirm_callback_called = True
+
+ elif not self._is_dragging_circle:
+ # reset back to right
+ self._scroll_x_circle_filter.update(0)
+ else:
+ # not activated yet, keep movement 1:1
+ self._scroll_x_circle_filter.x = self._scroll_x_circle
+
+ def _render(self, _):
+ # TODO: iOS text shimmering animation
+
+ white = rl.Color(255, 255, 255, int(255 * self._opacity))
+
+ bg_txt_x = self._rect.x + (self._rect.width - self._bg_txt.width) / 2
+ bg_txt_y = self._rect.y + (self._rect.height - self._bg_txt.height) / 2
+ rl.draw_texture_ex(self._bg_txt, rl.Vector2(bg_txt_x, bg_txt_y), 0.0, 1.0, white)
+
+ btn_x = bg_txt_x + self._bg_txt.width - self._circle_bg_txt.width + self._scroll_x_circle_filter.x
+ btn_y = self._rect.y + (self._rect.height - self._circle_bg_txt.height) / 2
+
+ if self._confirmed_time == 0.0 or self._scroll_x_circle > 0:
+ self._label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.65 * (1.0 - self.slider_percentage) * self._opacity)))
+ label_rect = rl.Rectangle(
+ self._rect.x + 20,
+ self._rect.y,
+ self._rect.width - self._circle_bg_txt.width - 20 * 3,
+ self._rect.height,
+ )
+ self._label.render(label_rect)
+
+ # circle and arrow
+ rl.draw_texture_ex(self._circle_bg_txt, rl.Vector2(btn_x, btn_y), 0.0, 1.0, white)
+
+ arrow_x = btn_x + (self._circle_bg_txt.width - self._circle_arrow_txt.width) / 2
+ arrow_y = btn_y + (self._circle_bg_txt.height - self._circle_arrow_txt.height) / 2
+ rl.draw_texture_ex(self._circle_arrow_txt, rl.Vector2(arrow_x, arrow_y), 0.0, 1.0, white)
+
+
+class LargerSlider(SmallSlider):
+ def __init__(self, title: str, confirm_callback: Callable | None = None, green: bool = True):
+ self._green = green
+ super().__init__(title, confirm_callback=confirm_callback)
+
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 115))
+
+ self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg_larger.png", 520, 115)
+ circle_fn = "slider_green_rounded_rectangle" if self._green else "slider_black_rounded_rectangle"
+ self._circle_bg_txt = gui_app.texture(f"icons_mici/setup/small_slider/{circle_fn}.png", 180, 115)
+ self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 55)
+
+
+class BigSlider(SmallSlider):
+ def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable | None = None):
+ self._icon = icon
+ super().__init__(title, confirm_callback=confirm_callback)
+ self._label = UnifiedLabel(title, font_size=48, font_weight=FontWeight.DISPLAY, text_color=rl.Color(255, 255, 255, int(255 * 0.65)),
+ alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
+ line_height=0.875)
+
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 180))
+
+ self._bg_txt = gui_app.texture("icons_mici/buttons/slider_bg.png", 520, 180)
+ self._circle_bg_txt = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180)
+ self._circle_arrow_txt = self._icon
+
+
+class RedBigSlider(BigSlider):
+ def _load_assets(self):
+ self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 180))
+
+ self._bg_txt = gui_app.texture("icons_mici/buttons/slider_bg.png", 520, 180)
+ self._circle_bg_txt = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180)
+ self._circle_arrow_txt = self._icon
diff --git a/system/version.py b/system/version.py
index f3de2d1bf2..84d6b75591 100755
--- a/system/version.py
+++ b/system/version.py
@@ -204,10 +204,4 @@ def get_build_metadata(path: str = BASEDIR) -> BuildMetadata:
if __name__ == "__main__":
- from openpilot.common.params import Params
-
- params = Params()
- params.put("TermsVersion", terms_version)
- params.put("TrainingVersion", training_version)
-
print(get_build_metadata())