mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-06-08 11:25:51 +08:00
Compare commits
360 Commits
82d39601e1
...
hkg-angle-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a16b0d0cc | ||
|
|
c9d0f0af8c | ||
|
|
b81b5e51e7 | ||
|
|
461c134a23 | ||
|
|
83d405dc2b | ||
|
|
3bc818a206 | ||
|
|
b57c593d92 | ||
|
|
7bee798c28 | ||
|
|
5790ba37d9 | ||
|
|
0674c42866 | ||
|
|
63c6b4fea2 | ||
|
|
20842be3db | ||
|
|
ab0061eac1 | ||
|
|
cc196b20b5 | ||
|
|
e537a28512 | ||
|
|
c35b73ee90 | ||
|
|
dc410c57fa | ||
|
|
dcf932e212 | ||
|
|
1f7bcf246a | ||
|
|
cffa673ca8 | ||
|
|
609d66e151 | ||
|
|
b0512dc523 | ||
|
|
f3dc00eb55 | ||
|
|
cd88fd3850 | ||
|
|
ff78eaeba1 | ||
|
|
92c8aeeb92 | ||
|
|
ecaa20e7be | ||
|
|
859c98c9d8 | ||
|
|
69cbe8c6ed | ||
|
|
1aafe92fc0 | ||
|
|
adb6b9fb12 | ||
|
|
f67a9f4624 | ||
|
|
094e834ac4 | ||
|
|
e4067060b9 | ||
|
|
86a14640b5 | ||
|
|
59fb84f0d5 | ||
|
|
78de201ce7 | ||
|
|
17e49f69cc | ||
|
|
bec75ebdd8 | ||
|
|
473832efa7 | ||
|
|
2cceeb3552 | ||
|
|
923a47f713 | ||
|
|
790a762a05 | ||
|
|
221c219fca | ||
|
|
8b60649eed | ||
|
|
ded0b506d6 | ||
|
|
b51d9af9a0 | ||
|
|
e3c4db68d1 | ||
|
|
bcdf26258b | ||
|
|
53a2adb45a | ||
|
|
aa91030972 | ||
|
|
dcfc48cba5 | ||
|
|
44a5c1c5d0 | ||
|
|
5ab792c5b7 | ||
|
|
a3a3478c62 | ||
|
|
50504246e7 | ||
|
|
9f68541a77 | ||
|
|
52465215c1 | ||
|
|
de5f22f659 | ||
|
|
2563dca0eb | ||
|
|
5af3b9c1a1 | ||
|
|
1a8ccf46fd | ||
|
|
cadb0b1868 | ||
|
|
371a58a9b7 | ||
|
|
23d0f391f8 | ||
|
|
08bc66f6db | ||
|
|
a2e137b54e | ||
|
|
08ad38cfdf | ||
|
|
a0069e5966 | ||
|
|
49bdd832ee | ||
|
|
cd0563622a | ||
|
|
80bf7ad066 | ||
|
|
4124053716 | ||
|
|
b0b2c7becf | ||
|
|
c53e8aec3c | ||
|
|
1c1af6133f | ||
|
|
c2fb074a90 | ||
|
|
550c3b3a69 | ||
|
|
454d69f58d | ||
|
|
1cd1dc3f2c | ||
|
|
45ca9f39b8 | ||
|
|
5b88ec1e5c | ||
|
|
48f003ee6c | ||
|
|
614f2de8ae | ||
|
|
bec33e52ce | ||
|
|
058b03bcdd | ||
|
|
574c0379ed | ||
|
|
8a873d9107 | ||
|
|
cf8d001612 | ||
|
|
a610ff250f | ||
|
|
9f6369d610 | ||
|
|
068baca464 | ||
|
|
8dcdee5fa0 | ||
|
|
4460ce8166 | ||
|
|
3ce5835c0d | ||
|
|
e380c0b606 | ||
|
|
556009edfe | ||
|
|
69b098c1f2 | ||
|
|
f1aa0c7f78 | ||
|
|
e7c8126fd9 | ||
|
|
125999c364 | ||
|
|
5b25ea7f99 | ||
|
|
b7e2631286 | ||
|
|
a838871189 | ||
|
|
1827331599 | ||
|
|
5c777bbe01 | ||
|
|
fb97f993d1 | ||
|
|
94b67077e3 | ||
|
|
14f17699b9 | ||
|
|
80e27d5cbb | ||
|
|
039dbcd877 | ||
|
|
8c134ae555 | ||
|
|
276c7a2b34 | ||
|
|
0e2dbcebfa | ||
|
|
bbb7760a95 | ||
|
|
23a27b2fbf | ||
|
|
8b78107a40 | ||
|
|
c27b6007de | ||
|
|
36c2dce247 | ||
|
|
8035039731 | ||
|
|
8aa6c9440f | ||
|
|
34ef40fd81 | ||
|
|
4d044d7618 | ||
|
|
6247e3dc84 | ||
|
|
b1131289b7 | ||
|
|
35dc7d661e | ||
|
|
c39b2dad94 | ||
|
|
e6b769245c | ||
|
|
8b94f8b2f8 | ||
|
|
dec014cd17 | ||
|
|
7b40272866 | ||
|
|
506456e7f0 | ||
|
|
4ea4b9d177 | ||
|
|
0e2313dc31 | ||
|
|
79ea7db103 | ||
|
|
f9ae9192fa | ||
|
|
7ec23006c6 | ||
|
|
2be9447a6c | ||
|
|
cfd926778e | ||
|
|
a97a67e3d0 | ||
|
|
518b6de08d | ||
|
|
6933e3bcdb | ||
|
|
05e0ca8bee | ||
|
|
410614fcf3 | ||
|
|
e2bc0996ef | ||
|
|
1be0c20cf5 | ||
|
|
839143b9ed | ||
|
|
bce86637ae | ||
|
|
b833d3ee89 | ||
|
|
e0441dfb4b | ||
|
|
62ec40bba6 | ||
|
|
15c6d38028 | ||
|
|
56eb9f555c | ||
|
|
2f9951df02 | ||
|
|
6030bf4da3 | ||
|
|
074694d660 | ||
|
|
df35f48f3b | ||
|
|
4fb9704540 | ||
|
|
48cbe266fc | ||
|
|
21aa7ff367 | ||
|
|
7caf05dd51 | ||
|
|
2d779f5db9 | ||
|
|
18208f1da0 | ||
|
|
5a3c6ddf57 | ||
|
|
eaa8732ab0 | ||
|
|
6f0284c84f | ||
|
|
999ea03f23 | ||
|
|
0975db3ff1 | ||
|
|
8d70a8b80a | ||
|
|
da93f92887 | ||
|
|
9117f6c071 | ||
|
|
3567ff9691 | ||
|
|
f44ae2ced9 | ||
|
|
376e0ca615 | ||
|
|
32b7686468 | ||
|
|
b9e0f52ea9 | ||
|
|
a3163b680f | ||
|
|
8a927d808f | ||
|
|
bc7d5e474d | ||
|
|
36f192b5fe | ||
|
|
ffd5cd4ac2 | ||
|
|
e4a00fcd6c | ||
|
|
0bdcb41103 | ||
|
|
78051085ca | ||
|
|
4910d5809a | ||
|
|
8dd862ff28 | ||
|
|
1b57497da9 | ||
|
|
8d8d1ffc7a | ||
|
|
c5919d5495 | ||
|
|
da71951c95 | ||
|
|
25a152cd8b | ||
|
|
9077a1082a | ||
|
|
7ae7000254 | ||
|
|
7029455706 | ||
|
|
3a71a62215 | ||
|
|
00622e8c33 | ||
|
|
549da3ee92 | ||
|
|
e038a65ef8 | ||
|
|
8d0513c657 | ||
|
|
2cea48f4cd | ||
|
|
8855a9ab65 | ||
|
|
32321c01cc | ||
|
|
52cd65fefb | ||
|
|
1fcdeccd40 | ||
|
|
3517c36978 | ||
|
|
fb30c3c1e8 | ||
|
|
1c25e568d5 | ||
|
|
f172122b7c | ||
|
|
67d6cdc7cd | ||
|
|
6724085cfd | ||
|
|
6774f34eee | ||
|
|
6d0402896d | ||
|
|
344021a3d9 | ||
|
|
a9b85ab27d | ||
|
|
17204a46e4 | ||
|
|
7c4d415462 | ||
|
|
b8985b6d72 | ||
|
|
c669473f88 | ||
|
|
8751435bf5 | ||
|
|
30ae210761 | ||
|
|
3eb693f58b | ||
|
|
7b4a31c5ac | ||
|
|
1527c8cf88 | ||
|
|
ab0a7ae666 | ||
|
|
11ec2f1f21 | ||
|
|
2c6808d37e | ||
|
|
2e96382c49 | ||
|
|
0239e440ca | ||
|
|
76b972daff | ||
|
|
bc3ef3e7dd | ||
|
|
9839291dd0 | ||
|
|
17cba328d6 | ||
|
|
86093765d8 | ||
|
|
05fa1c8ae8 | ||
|
|
e8a40d6b85 | ||
|
|
c265e0bb85 | ||
|
|
c6b118788b | ||
|
|
b004f6dbdc | ||
|
|
cd4930b680 | ||
|
|
f861aca628 | ||
|
|
1ff0d8e2ee | ||
|
|
d76d70764e | ||
|
|
dc73ce0b71 | ||
|
|
3f666748af | ||
|
|
8de8a8838c | ||
|
|
f563b7eb71 | ||
|
|
3c18b83708 | ||
|
|
bd35f5904b | ||
|
|
474f2737f6 | ||
|
|
ea1ac4a212 | ||
|
|
165d7c7b36 | ||
|
|
b53cbb2e18 | ||
|
|
b95f8c5929 | ||
|
|
e564bb0b85 | ||
|
|
e2ec8a7b13 | ||
|
|
75c6f0f10e | ||
|
|
af38044b42 | ||
|
|
684fa846d8 | ||
|
|
416e722855 | ||
|
|
9fd4613bbb | ||
|
|
a0362e3c5f | ||
|
|
e32ef1cdc0 | ||
|
|
20673ec8a6 | ||
|
|
53fbdf7329 | ||
|
|
6f72b74fac | ||
|
|
e83705a32e | ||
|
|
95d36b9ba2 | ||
|
|
b9e74254bd | ||
|
|
b4405b200d | ||
|
|
a8a3fdac54 | ||
|
|
4eddc622a7 | ||
|
|
4e42ada240 | ||
|
|
6266217655 | ||
|
|
ea09d32e98 | ||
|
|
15958c88d3 | ||
|
|
b80d7fb5ea | ||
|
|
1c3d25c6ff | ||
|
|
c9f22b32c7 | ||
|
|
c0524985bb | ||
|
|
83839c7ea7 | ||
|
|
c1a1d4b4c3 | ||
|
|
b5af7a905a | ||
|
|
96b1b2f55f | ||
|
|
9361ba5d70 | ||
|
|
4e9014311e | ||
|
|
232873fc70 | ||
|
|
0a61fca9c9 | ||
|
|
480bdc34dc | ||
|
|
716b475a13 | ||
|
|
b1ec5ec034 | ||
|
|
470613c2b7 | ||
|
|
336c5b4154 | ||
|
|
abdb9dc750 | ||
|
|
ab98683973 | ||
|
|
186c24dbe6 | ||
|
|
9cdf6340a1 | ||
|
|
2855b1341c | ||
|
|
cf28f99976 | ||
|
|
a39d67dc47 | ||
|
|
7e75257f12 | ||
|
|
df38449553 | ||
|
|
1385ef3bc5 | ||
|
|
7c23c11c51 | ||
|
|
aeff2e12ec | ||
|
|
7274899671 | ||
|
|
6c00fd608f | ||
|
|
df6a034c11 | ||
|
|
acb109c290 | ||
|
|
b227b00249 | ||
|
|
3ec9d6c18a | ||
|
|
86db8b95f0 | ||
|
|
4cfff8a35f | ||
|
|
962fedf48c | ||
|
|
04494414d1 | ||
|
|
0b83576e9b | ||
|
|
ce4ef0f817 | ||
|
|
f0b15c1c56 | ||
|
|
f898e9fdfe | ||
|
|
8ee7804b0e | ||
|
|
923228194e | ||
|
|
8837b2e3f6 | ||
|
|
f48c9dc1c2 | ||
|
|
74aa07a8cd | ||
|
|
5236e4860f | ||
|
|
3d174da1c3 | ||
|
|
8faa40f3a3 | ||
|
|
3e03275f28 | ||
|
|
7595cf8a25 | ||
|
|
2675d43adb | ||
|
|
d9f4ce82e6 | ||
|
|
648a1845d8 | ||
|
|
a87eff6d1c | ||
|
|
dd6ad37e23 | ||
|
|
c5e778b939 | ||
|
|
a9ab81a77a | ||
|
|
2d40e1d8e5 | ||
|
|
7c8f367a5d | ||
|
|
ff4cf558aa | ||
|
|
0e151e51bc | ||
|
|
60cc0031b0 | ||
|
|
d24cac0998 | ||
|
|
45d110830c | ||
|
|
ea3a9ae911 | ||
|
|
6bff8c0e7c | ||
|
|
bc6b8802b8 | ||
|
|
252ef572d3 | ||
|
|
414d397e3f | ||
|
|
11b7b3789d | ||
|
|
871ac53717 | ||
|
|
64ea66b6e6 | ||
|
|
6d7c6759b3 | ||
|
|
4cea013570 | ||
|
|
eb375c0587 | ||
|
|
7fd8a5a4bd | ||
|
|
b3c90216bb | ||
|
|
10f345f956 | ||
|
|
956d2c36d0 | ||
|
|
55e688b6f2 | ||
|
|
f017954027 | ||
|
|
7e992d11b1 |
@@ -2,5 +2,6 @@ Wen
|
||||
REGIST
|
||||
PullRequest
|
||||
cancelled
|
||||
indeces
|
||||
FOF
|
||||
NoO
|
||||
|
||||
7
.idea/customTargets.xml
generated
7
.idea/customTargets.xml
generated
@@ -21,5 +21,12 @@
|
||||
</clean>
|
||||
</configuration>
|
||||
</target>
|
||||
<target id="f2590b2b-9b93-49f9-8510-da3f3724a2ae" name="replay" defaultType="TOOL">
|
||||
<configuration id="d475264f-6f4c-4092-9b4e-6773309f38b7" name="replay" toolchainName="Default">
|
||||
<build type="TOOL">
|
||||
<tool actionId="Tool_External Tools_uv build tools replay" />
|
||||
</build>
|
||||
</configuration>
|
||||
</target>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/tools/External Tools.xml
generated
7
.idea/tools/External Tools.xml
generated
@@ -20,4 +20,11 @@
|
||||
<option name="WORKING_DIRECTORY" value="$ProjectFileDir$" />
|
||||
</exec>
|
||||
</tool>
|
||||
<tool name="uv build tools replay" showInMainMenu="false" showInEditor="false" showInProject="false" showInSearchPopup="false" disabled="false" useConsole="true" showConsoleOnStdOut="false" showConsoleOnStdErr="false" synchronizeAfterRun="true">
|
||||
<exec>
|
||||
<option name="COMMAND" value="bash" />
|
||||
<option name="PARAMETERS" value="-c "source .venv/bin/activate && scons -u -j$(nproc) tools/replay/"" />
|
||||
<option name="WORKING_DIRECTORY" value="$ProjectFileDir$" />
|
||||
</exec>
|
||||
</tool>
|
||||
</toolSet>
|
||||
@@ -1,5 +1,5 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Build Debug" type="CLionExternalRunConfiguration" factoryName="Application" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" EMULATE_TERMINAL="false" WORKING_DIR="file://$ProjectFileDir$/selfdrive/ui" PASS_PARENT_ENVS_2="true" PROJECT_NAME="sunnypilot" TARGET_NAME="uv Scons Build Debug" CONFIG_NAME="uv Scons Build Debug" RUN_PATH="ui">
|
||||
<configuration default="false" name="Build Debug" type="CLionExternalRunConfiguration" factoryName="Application" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" EMULATE_TERMINAL="false" WORKING_DIR="file://$ProjectFileDir$/selfdrive/ui" PASS_PARENT_ENVS_2="true" PROJECT_NAME="openpilot-special" TARGET_NAME="uv Scons Build Debug" CONFIG_NAME="uv Scons Build Debug" RUN_PATH="ui">
|
||||
<envs>
|
||||
<env name="QT_DBL_CLICK_DIST" value="150" />
|
||||
</envs>
|
||||
|
||||
27
.run/Debug Route Controls.run.xml
Normal file
27
.run/Debug Route Controls.run.xml
Normal file
@@ -0,0 +1,27 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Debug Route Controls" type="PythonConfigurationType" factoryName="Python">
|
||||
<module name="openpilot-special" />
|
||||
<option name="ENV_FILES" value="" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="PARENT_ENVS" value="true" />
|
||||
<envs>
|
||||
<env name="PYTHONUNBUFFERED" value="1" />
|
||||
<env name="FINGERPRINT" value="KIA_EV9" />
|
||||
<env name="SKIP_FW_QUERY" value="1" />
|
||||
</envs>
|
||||
<option name="SDK_HOME" value="" />
|
||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/selfdrive/car" />
|
||||
<option name="IS_MODULE_SDK" value="true" />
|
||||
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||
<option name="ADD_SOURCE_ROOTS" value="true" />
|
||||
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
||||
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/selfdrive/car/card.py" />
|
||||
<option name="PARAMETERS" value="" />
|
||||
<option name="SHOW_COMMAND_LINE" value="false" />
|
||||
<option name="EMULATE_TERMINAL" value="true" />
|
||||
<option name="MODULE_MODE" value="false" />
|
||||
<option name="REDIRECT_INPUT" value="false" />
|
||||
<option name="INPUT_FILE" value="" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
7
.run/Replay for controls + ui.run.xml
Normal file
7
.run/Replay for controls + ui.run.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Replay for controls + ui" type="Multirun" separateTabs="false" reuseTabsWithFailures="false" startOneByOne="true" markFailedProcess="true" hideSuccessProcess="false" delayTime="0.0">
|
||||
<runConfiguration name="replay for controls" type="Native Application" />
|
||||
<runConfiguration name="Build Debug" type="Custom Build Application" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
7
.run/replay for controls.run.xml
Normal file
7
.run/replay for controls.run.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="replay for controls" type="CLionNativeAppRunConfigurationType" focusToolWindowBeforeRun="true" PROGRAM_PARAMS=""$Prompt$" --block "sendcan,carState,carParams,carOutput,liveTracks,carParamsSP,carStateSP,bookmarkButton"" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" EMULATE_TERMINAL="true" WORKING_DIR="file://$ProjectFileDir$/tools/replay" PASS_PARENT_ENVS_2="true" PROJECT_NAME="openpilot-special" TARGET_NAME="replay" CONFIG_NAME="replay" version="1" RUN_PATH="replay">
|
||||
<method v="2">
|
||||
<option name="CLION.COMPOUND.BUILD" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -204,6 +204,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
// sunnylink params
|
||||
{"EnableSunnylinkUploader", {PERSISTENT | BACKUP, BOOL}},
|
||||
{"LastSunnylinkPingTime", {CLEAR_ON_MANAGER_START, INT}},
|
||||
{"ParamsVersion", {PERSISTENT, INT}},
|
||||
{"SunnylinkCache_Roles", {PERSISTENT, STRING}},
|
||||
{"SunnylinkCache_Users", {PERSISTENT, STRING}},
|
||||
{"SunnylinkDongleId", {PERSISTENT, STRING}},
|
||||
|
||||
@@ -227,6 +227,7 @@ exclude = [
|
||||
"teleoprtc/",
|
||||
"teleoprtc_repo/",
|
||||
"third_party/",
|
||||
"**/*.ipynb",
|
||||
]
|
||||
|
||||
[tool.ty.rules]
|
||||
|
||||
@@ -13,7 +13,7 @@ cd $ROOT
|
||||
|
||||
FAILED=0
|
||||
|
||||
IGNORED_FILES="uv\.lock|docs\/CARS.md|LICENSE\.md"
|
||||
IGNORED_FILES="uv\.lock|docs\/CARS.md|LICENSE\.md|layouts\/.*\.xml|.*\.ipynb"
|
||||
IGNORED_DIRS="^third_party.*|^msgq.*|^msgq_repo.*|^opendbc.*|^opendbc_repo.*|^cereal.*|^panda.*|^rednose.*|^rednose_repo.*|^tinygrad.*|^tinygrad_repo.*|^teleoprtc.*|^teleoprtc_repo.*"
|
||||
|
||||
function run() {
|
||||
|
||||
@@ -120,20 +120,12 @@ class SteeringLayout(Widget):
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
torque_allowed = True
|
||||
torque_allowed = ui_state.CP is not None and ui_state.CP.steerControlType != car.CarParams.SteerControlType.angle
|
||||
if ui_state.CP is not None:
|
||||
mads_main_desc = self._mads_limited_desc if self._mads_settings_layout._mads_limited_settings() else self._mads_full_desc
|
||||
self._mads_toggle.set_description(f"<b>{mads_main_desc}</b><br><br>{self._mads_base_desc}")
|
||||
|
||||
if ui_state.CP.steerControlType == car.CarParams.SteerControlType.angle:
|
||||
ui_state.params.remove("EnforceTorqueControl")
|
||||
ui_state.params.remove("NeuralNetworkLateralControl")
|
||||
torque_allowed = False
|
||||
else:
|
||||
self._mads_toggle.set_description(f"<b>{self._mads_check_compat_desc}</b><br><br>{self._mads_base_desc}")
|
||||
ui_state.params.remove("EnforceTorqueControl")
|
||||
ui_state.params.remove("NeuralNetworkLateralControl")
|
||||
torque_allowed = False
|
||||
|
||||
self._mads_toggle.action_item.set_enabled(ui_state.is_offroad())
|
||||
self._mads_settings_button.action_item.set_enabled(ui_state.is_offroad() and self._mads_toggle.action_item.get_state())
|
||||
|
||||
@@ -6,7 +6,7 @@ See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
from enum import Enum
|
||||
|
||||
from cereal import messaging, log, custom
|
||||
from cereal import messaging, log, car, custom
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.selfdrive.ui.sunnypilot.layouts.settings.display import OnroadBrightness
|
||||
from openpilot.sunnypilot.sunnylink.sunnylink_state import SunnylinkState
|
||||
@@ -26,22 +26,22 @@ class OnroadTimerStatus(Enum):
|
||||
|
||||
class UIStateSP:
|
||||
def __init__(self):
|
||||
self.CP_SP: custom.CarParamsSP | None = None
|
||||
self.params = Params()
|
||||
self.CP_SP: custom.CarParamsSP | None = None
|
||||
self.has_icbm: bool = False
|
||||
self.is_sp_release: bool = self.params.get_bool("IsReleaseSpBranch")
|
||||
self.sm_services_ext = [
|
||||
"modelManagerSP", "selfdriveStateSP", "longitudinalPlanSP", "backupManagerSP",
|
||||
"gpsLocation", "liveTorqueParameters", "carStateSP", "liveMapDataSP", "carParamsSP", "liveDelay"
|
||||
]
|
||||
|
||||
self.sunnylink_state = SunnylinkState()
|
||||
self.update_params()
|
||||
|
||||
self.onroad_brightness_timer: int = 0
|
||||
self.custom_interactive_timeout: int = self.params.get("InteractivityTimeout", return_default=True)
|
||||
|
||||
self.update_params()
|
||||
self.reset_onroad_sleep_timer()
|
||||
self.CP_SP: custom.CarParamsSP | None = None
|
||||
self.has_icbm: bool = False
|
||||
self.is_sp_release: bool = self.params.get_bool("IsReleaseSpBranch")
|
||||
|
||||
def update(self) -> None:
|
||||
if self.sunnylink_enabled:
|
||||
@@ -128,6 +128,8 @@ class UIStateSP:
|
||||
if CP_SP_bytes is not None:
|
||||
self.CP_SP = messaging.log_from_bytes(CP_SP_bytes, custom.CarParamsSP)
|
||||
self.has_icbm = self.CP_SP.intelligentCruiseButtonManagementAvailable and self.params.get_bool("IntelligentCruiseButtonManagement")
|
||||
|
||||
self._enforce_sp_constraints()
|
||||
self.active_bundle = self.params.get("ModelManager_ActiveBundle")
|
||||
self.blindspot = self.params.get_bool("BlindSpot")
|
||||
self.chevron_metrics = self.params.get("ChevronInfo")
|
||||
@@ -148,6 +150,48 @@ class UIStateSP:
|
||||
self.boot_offroad_mode = self.params.get("DeviceBootMode", return_default=True)
|
||||
self.always_offroad = self.params.get_bool("OffroadMode")
|
||||
|
||||
def _enforce_sp_constraints(self) -> None:
|
||||
has_long = getattr(self, 'has_longitudinal_control', False)
|
||||
has_icbm = self.has_icbm
|
||||
CP = getattr(self, 'CP', None)
|
||||
|
||||
if CP is not None:
|
||||
# Angle steering: no torque-based lateral controls
|
||||
if CP.steerControlType == car.CarParams.SteerControlType.angle:
|
||||
self.params.remove("EnforceTorqueControl")
|
||||
self.params.remove("NeuralNetworkLateralControl")
|
||||
|
||||
# Alpha longitudinal: clear if not available or on release branch
|
||||
if not CP.alphaLongitudinalAvailable or self.params.get_bool("IsReleaseBranch"):
|
||||
self.params.remove("AlphaLongitudinalEnabled")
|
||||
|
||||
# BSM not available: clear BSM-dependent settings
|
||||
if not CP.enableBsm:
|
||||
self.params.remove("AutoLaneChangeBsmDelay")
|
||||
else:
|
||||
# No CarParams: clear all car-dependent params as safety default
|
||||
self.params.remove("EnforceTorqueControl")
|
||||
self.params.remove("NeuralNetworkLateralControl")
|
||||
self.params.remove("AlphaLongitudinalEnabled")
|
||||
|
||||
# No longitudinal control: no experimental mode
|
||||
if not has_long:
|
||||
self.params.remove("ExperimentalMode")
|
||||
|
||||
# ICBM: clear if not available or if full longitudinal control is active
|
||||
if self.CP_SP is not None:
|
||||
if not self.CP_SP.intelligentCruiseButtonManagementAvailable or has_long:
|
||||
self.params.remove("IntelligentCruiseButtonManagement")
|
||||
else:
|
||||
self.params.remove("IntelligentCruiseButtonManagement")
|
||||
|
||||
# Cruise features requiring longitudinal or ICBM
|
||||
if not (has_long or has_icbm):
|
||||
self.params.remove("CustomAccIncrementsEnabled")
|
||||
self.params.remove("DynamicExperimentalControl")
|
||||
self.params.remove("SmartCruiseControlVision")
|
||||
self.params.remove("SmartCruiseControlMap")
|
||||
|
||||
|
||||
class DeviceSP:
|
||||
@staticmethod
|
||||
|
||||
@@ -12,6 +12,9 @@ from openpilot.system.hardware import HARDWARE
|
||||
from openpilot.system.hardware.hw import Paths
|
||||
|
||||
API_HOST = os.getenv('SUNNYLINK_API_HOST', 'https://stg.api.sunnypilot.ai')
|
||||
# Athena HTTP gateway (serves ws/settings/navigation paths). CloudFront proxy
|
||||
# from stg.api.sunnypilot.ai is being retired; clients must call athena direct.
|
||||
ATHENA_HOST = 'https://athena.sunnylink.ai'
|
||||
UNREGISTERED_SUNNYLINK_DONGLE_ID = "UnregisteredDevice"
|
||||
MAX_RETRIES = 6
|
||||
CRASH_LOG_DIR = Paths.crash_log_root()
|
||||
@@ -32,7 +35,12 @@ class SunnylinkApi(BaseApi):
|
||||
|
||||
def resume_queued(self, timeout=10, **kwargs):
|
||||
sunnylinkId, commaId = self._resolve_dongle_ids()
|
||||
return self.api_get(f"ws/{sunnylinkId}/resume_queued", "POST", timeout, access_token=self.get_token(), **kwargs)
|
||||
saved_host = self.api_host
|
||||
self.api_host = ATHENA_HOST
|
||||
try:
|
||||
return self.api_get(f"ws/{sunnylinkId}/resume_queued", "POST", timeout, access_token=self.get_token(), **kwargs)
|
||||
finally:
|
||||
self.api_host = saved_host
|
||||
|
||||
def get_token(self, payload_extra=None, expiry_hours=1):
|
||||
# Add your additional data here
|
||||
|
||||
@@ -18,7 +18,7 @@ import time
|
||||
|
||||
from jsonrpc import dispatcher
|
||||
from functools import partial
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.params import Params, ParamKeyType
|
||||
from openpilot.common.realtime import set_core_affinity
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.system.hardware.hw import Paths
|
||||
@@ -31,6 +31,8 @@ import cereal.messaging as messaging
|
||||
from openpilot.sunnypilot.selfdrive.car.sync_car_list_param import update_car_list_param
|
||||
from openpilot.sunnypilot.sunnylink.api import SunnylinkApi
|
||||
from openpilot.sunnypilot.sunnylink.utils import sunnylink_need_register, sunnylink_ready, get_param_as_byte, save_param_from_base64_encoded_string
|
||||
from openpilot.sunnypilot.sunnylink.capabilities import generate_capabilities, CAPABILITY_LABELS
|
||||
from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import generate_schema
|
||||
|
||||
SUNNYLINK_ATHENA_HOST = os.getenv('SUNNYLINK_ATHENA_HOST', 'wss://ws.stg.api.sunnypilot.ai')
|
||||
HANDLER_THREADS = int(os.getenv('HANDLER_THREADS', "4"))
|
||||
@@ -44,12 +46,15 @@ params = Params()
|
||||
|
||||
# Parameters that should never be remotely modified
|
||||
BLOCKED_PARAMS = {
|
||||
"AdbEnabled",
|
||||
"CompletedSunnylinkConsentVersion",
|
||||
"CompletedTrainingVersion",
|
||||
"GithubUsername", # Could grant SSH access
|
||||
"GithubSshKeys", # Direct SSH key injection
|
||||
"HasAcceptedTerms",
|
||||
"HasAcceptedTermsSP",
|
||||
"OnroadCycleRequested", # Prevent remote cycle trigger
|
||||
"ParamsVersion", # Device-managed version counter
|
||||
}
|
||||
|
||||
|
||||
@@ -231,34 +236,18 @@ def getParamsAllKeysV1() -> dict[str, str]:
|
||||
|
||||
@dispatcher.add_method
|
||||
def getParamsMetadata() -> str:
|
||||
"""Compressed equivalent of getParamsAllKeysV1 — same struct, gzipped + base64."""
|
||||
"""Return settings_ui.json + live capabilities as gzip-compressed, base64-encoded string.
|
||||
|
||||
Reads settings_ui.json, injects live capabilities derived from CarParams,
|
||||
compresses, and returns. This is the single RPC for the frontend to get
|
||||
the complete settings UI definition + runtime capabilities.
|
||||
"""
|
||||
try:
|
||||
with open(METADATA_PATH) as f:
|
||||
metadata = json.load(f)
|
||||
except Exception:
|
||||
cloudlog.exception("sunnylinkd.getParamsMetadata.exception")
|
||||
metadata = {}
|
||||
|
||||
try:
|
||||
available_keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()]
|
||||
|
||||
params_list: list[dict] = []
|
||||
for key in available_keys:
|
||||
value = get_param_as_byte(key, get_default=True)
|
||||
|
||||
param_entry: dict = {
|
||||
"key": key,
|
||||
"type": int(params.get_type(key).value),
|
||||
"default_value": base64.b64encode(value).decode('utf-8') if value else None,
|
||||
}
|
||||
|
||||
if key in metadata:
|
||||
param_entry["_extra"] = metadata[key]
|
||||
|
||||
params_list.append(param_entry)
|
||||
|
||||
raw = json.dumps(params_list, separators=(',', ':')).encode('utf-8')
|
||||
return base64.b64encode(gzip.compress(raw)).decode('utf-8')
|
||||
schema = generate_schema()
|
||||
schema["capabilities"] = generate_capabilities()
|
||||
schema["capability_labels"] = CAPABILITY_LABELS
|
||||
raw = json.dumps(schema, separators=(",", ":")).encode("utf-8")
|
||||
return base64.b64encode(gzip.compress(raw)).decode("utf-8")
|
||||
except Exception:
|
||||
cloudlog.exception("sunnylinkd.getParamsMetadata.exception")
|
||||
raise
|
||||
@@ -270,12 +259,25 @@ def getParams(params_keys: list[str], compression: bool = False) -> str | dict[s
|
||||
available_keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()]
|
||||
|
||||
try:
|
||||
zero_values: dict[int, bytes] = {
|
||||
ParamKeyType.STRING.value: b"",
|
||||
ParamKeyType.BOOL.value: b"0",
|
||||
ParamKeyType.INT.value: b"0",
|
||||
ParamKeyType.FLOAT.value: b"0.0",
|
||||
ParamKeyType.TIME.value: b"",
|
||||
ParamKeyType.JSON.value: b"{}",
|
||||
ParamKeyType.BYTES.value: b"",
|
||||
}
|
||||
|
||||
param_keys_validated = [key for key in params_keys if key in available_keys]
|
||||
params_dict: dict[str, list[dict[str, str | bool | int]]] = {"params": []}
|
||||
for key in param_keys_validated:
|
||||
value = get_param_as_byte(key)
|
||||
if value is None:
|
||||
continue
|
||||
value = get_param_as_byte(key, get_default=True)
|
||||
if value is None:
|
||||
param_type = params.get_type(key)
|
||||
value = zero_values.get(param_type.value, b"")
|
||||
|
||||
params_dict["params"].append({
|
||||
"key": key,
|
||||
@@ -306,6 +308,13 @@ def saveParams(params_to_update: dict[str, str], compression: bool = False) -> N
|
||||
except Exception as e:
|
||||
cloudlog.error(f"sunnylinkd.saveParams.exception {e}")
|
||||
|
||||
# Increment version counter for frontend change detection
|
||||
try:
|
||||
current = int(params.get("ParamsVersion") or "0")
|
||||
params.put("ParamsVersion", str(current + 1))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def startLocalProxy(global_end_event: threading.Event, remote_ws_uri: str, local_port: int) -> dict[str, int]:
|
||||
sunnylink_dongle_id = params.get("SunnylinkDongleId")
|
||||
|
||||
169
sunnypilot/sunnylink/capabilities.py
Normal file
169
sunnypilot/sunnylink/capabilities.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import json
|
||||
|
||||
from cereal import car, custom, messaging
|
||||
from opendbc.car.hyundai.values import CAR as HYUNDAI_CAR, UNSUPPORTED_LONGITUDINAL_CAR
|
||||
from opendbc.car.subaru.values import CAR as SUBARU_CAR, SubaruFlags
|
||||
from opendbc.sunnypilot.car.tesla.values import TeslaFlagsSP
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.system.hardware import HARDWARE
|
||||
|
||||
|
||||
# Wire-protocol version for the capabilities payload. Bump on breaking changes
|
||||
# only; additive fields are backward-compatible and do not require a bump.
|
||||
PROTOCOL_VERSION = 1
|
||||
|
||||
# All capability fields that rules may reference.
|
||||
# Non-boolean fields must have defaults in CAPABILITY_DEFAULTS.
|
||||
CAPABILITY_FIELDS = (
|
||||
"protocol_version",
|
||||
"has_longitudinal_control",
|
||||
"has_icbm",
|
||||
"icbm_available",
|
||||
"torque_allowed",
|
||||
"brand",
|
||||
"pcm_cruise",
|
||||
"alpha_long_available",
|
||||
"steer_control_type",
|
||||
"enable_bsm",
|
||||
"is_release",
|
||||
"is_sp_release",
|
||||
"is_development",
|
||||
"tesla_has_vehicle_bus",
|
||||
"has_stop_and_go",
|
||||
"stock_longitudinal",
|
||||
"device_type",
|
||||
"subaru_has_sng",
|
||||
"hyundai_alpha_long_available",
|
||||
)
|
||||
|
||||
CAPABILITY_LABELS: dict[str, str] = {
|
||||
"protocol_version": "Capabilities protocol version",
|
||||
"has_longitudinal_control": "sunnypilot longitudinal control",
|
||||
"has_icbm": "ICBM enabled",
|
||||
"icbm_available": "ICBM available",
|
||||
"torque_allowed": "torque steering (not available for angle steering vehicles)",
|
||||
"brand": "Vehicle brand",
|
||||
"pcm_cruise": "PCM cruise",
|
||||
"alpha_long_available": "Alpha Longitudinal available",
|
||||
"steer_control_type": "Steer control type",
|
||||
"enable_bsm": "BSM available",
|
||||
"is_release": "Release branch",
|
||||
"is_sp_release": "SP release branch",
|
||||
"is_development": "Development branch",
|
||||
"tesla_has_vehicle_bus": "Tesla vehicle bus",
|
||||
"has_stop_and_go": "Stop and Go",
|
||||
"stock_longitudinal": "stock longitudinal",
|
||||
"device_type": "Device type",
|
||||
"subaru_has_sng": "Subaru Stop-and-Go available",
|
||||
"hyundai_alpha_long_available": "Hyundai Alpha Longitudinal available",
|
||||
}
|
||||
|
||||
# Explicit defaults for non-boolean capability fields
|
||||
CAPABILITY_DEFAULTS: dict[str, bool | str | int] = {
|
||||
"brand": "",
|
||||
"steer_control_type": "",
|
||||
"device_type": "",
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
}
|
||||
|
||||
|
||||
def _bundle_field(bundle: dict | None, key: str) -> str:
|
||||
return bundle.get(key, "") if isinstance(bundle, dict) else ""
|
||||
|
||||
|
||||
def generate_capabilities(params: Params | None = None) -> dict:
|
||||
"""Generate a SettingsCapabilities dict from CarParams + boolean params.
|
||||
|
||||
Bundle-first source of truth: when CarPlatformBundle is present, brand and
|
||||
platform derive from the bundle (mirrors Raylib settings code paths). The
|
||||
CarParams* deserialization is the fallback when the bundle has not yet been
|
||||
written (very early after first pairing).
|
||||
"""
|
||||
params = params or Params()
|
||||
|
||||
caps: dict = {field: CAPABILITY_DEFAULTS.get(field, False) for field in CAPABILITY_FIELDS}
|
||||
|
||||
# Wire-protocol version is always set explicitly.
|
||||
caps["protocol_version"] = PROTOCOL_VERSION
|
||||
|
||||
# Hardware + boolean params (no CarParams dependency)
|
||||
caps["device_type"] = HARDWARE.get_device_type()
|
||||
caps["is_release"] = params.get_bool("IsReleaseBranch")
|
||||
caps["is_sp_release"] = params.get_bool("IsReleaseSpBranch")
|
||||
caps["is_development"] = params.get_bool("IsDevelopmentBranch")
|
||||
caps["stock_longitudinal"] = params.get_bool("ToyotaEnforceStockLongitudinal")
|
||||
|
||||
bundle = params.get("CarPlatformBundle")
|
||||
bundle_brand = _bundle_field(bundle, "brand")
|
||||
bundle_platform = _bundle_field(bundle, "platform")
|
||||
|
||||
# Bundle-first brand resolution; CP is fallback only.
|
||||
if bundle_brand:
|
||||
caps["brand"] = bundle_brand
|
||||
|
||||
# CarParams-derived capabilities
|
||||
CP_bytes = params.get("CarParamsPersistent")
|
||||
if CP_bytes is not None:
|
||||
try:
|
||||
CP = messaging.log_from_bytes(CP_bytes, car.CarParams)
|
||||
caps["alpha_long_available"] = bool(CP.alphaLongitudinalAvailable)
|
||||
if CP.alphaLongitudinalAvailable:
|
||||
caps["has_longitudinal_control"] = params.get_bool("AlphaLongitudinalEnabled")
|
||||
else:
|
||||
caps["has_longitudinal_control"] = bool(CP.openpilotLongitudinalControl)
|
||||
# CP.steerControlType is the physical control mode (angle / torque).
|
||||
# CP.lateralTuning.which() returns the tuning class (pid / torque / indi)
|
||||
# which is a separate concept and is not interchangeable.
|
||||
caps["steer_control_type"] = str(CP.steerControlType)
|
||||
caps["torque_allowed"] = CP.steerControlType != car.CarParams.SteerControlType.angle
|
||||
if not caps["brand"] and CP.brand:
|
||||
caps["brand"] = str(CP.brand)
|
||||
caps["pcm_cruise"] = bool(CP.pcmCruise)
|
||||
caps["enable_bsm"] = bool(CP.enableBsm)
|
||||
# Generic SnG fallback. Brand-specific opaque flags below override.
|
||||
caps["has_stop_and_go"] = bool(CP.openpilotLongitudinalControl)
|
||||
except Exception:
|
||||
cloudlog.exception("capabilities: failed to deserialize CarParamsPersistent")
|
||||
|
||||
# CarParamsSP-derived capabilities
|
||||
CP_SP_bytes = params.get("CarParamsSPPersistent")
|
||||
if CP_SP_bytes is not None:
|
||||
try:
|
||||
CP_SP = messaging.log_from_bytes(CP_SP_bytes, custom.CarParamsSP)
|
||||
caps["icbm_available"] = bool(CP_SP.intelligentCruiseButtonManagementAvailable)
|
||||
caps["has_icbm"] = bool(CP_SP.intelligentCruiseButtonManagementAvailable) and params.get_bool("IntelligentCruiseButtonManagement")
|
||||
caps["tesla_has_vehicle_bus"] = bool(CP_SP.flags & TeslaFlagsSP.HAS_VEHICLE_BUS)
|
||||
except Exception:
|
||||
cloudlog.exception("capabilities: failed to deserialize CarParamsSPPersistent")
|
||||
|
||||
# Brand-specific opaque flags. Mirror Raylib brand-settings logic so the
|
||||
# device and the dashboard agree on per-platform availability without
|
||||
# leaking the platform identifier over the wire.
|
||||
if caps["brand"] == "subaru" and bundle_platform:
|
||||
try:
|
||||
flags = SUBARU_CAR[bundle_platform].config.flags
|
||||
caps["subaru_has_sng"] = not bool(flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID))
|
||||
caps["has_stop_and_go"] = caps["subaru_has_sng"]
|
||||
except KeyError:
|
||||
cloudlog.exception(f"capabilities: unknown subaru platform {bundle_platform!r}")
|
||||
|
||||
if caps["brand"] == "hyundai" and bundle_platform:
|
||||
try:
|
||||
unsupported = set().union(*UNSUPPORTED_LONGITUDINAL_CAR.values())
|
||||
caps["hyundai_alpha_long_available"] = HYUNDAI_CAR[bundle_platform] not in unsupported
|
||||
except KeyError:
|
||||
cloudlog.exception(f"capabilities: unknown hyundai platform {bundle_platform!r}")
|
||||
|
||||
return caps
|
||||
|
||||
|
||||
def generate_capabilities_json(params: Params | None = None) -> str:
|
||||
"""Generate SettingsCapabilities as a JSON string."""
|
||||
return json.dumps(generate_capabilities(params), separators=(",", ":"))
|
||||
353
sunnypilot/sunnylink/docs/README.md
Normal file
353
sunnypilot/sunnylink/docs/README.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# sunnylink Settings UI Guide
|
||||
|
||||
> Edit one JSON file, run the validator, commit. The sunnylink frontend updates automatically.
|
||||
|
||||
For detailed architecture, capability fields, parity analysis, and dialog mappings, see [REFERENCE.md](REFERENCE.md).
|
||||
|
||||
## The File You Edit
|
||||
|
||||
| File | What | When to edit |
|
||||
|------|------|-------------|
|
||||
| `settings_ui.json` | Structure, widget types, display text, options, rules - everything | Adding/moving/removing/renaming a setting |
|
||||
|
||||
All metadata (titles, descriptions, options, min/max/step/unit) lives **inline on each item**. There is no separate metadata file.
|
||||
|
||||
## Quick Reference: Widget Types
|
||||
|
||||
| Widget | Use for | Fields needed |
|
||||
|--------|---------|---------------|
|
||||
| `toggle` | On/off boolean | `title` |
|
||||
| `multiple_button` | 2-4 discrete options | `title` + `options` array |
|
||||
| `option` | Numeric range or dropdown | `title` + `min/max/step` or `options` |
|
||||
| `info` | Read-only display | `title` |
|
||||
|
||||
## Quick Reference: Item Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `key` | Yes | Param key name (must exist in `params_keys.h`) |
|
||||
| `widget` | Yes | `toggle`, `option`, `multiple_button`, `button`, `info` |
|
||||
| `title` | Yes | Display name shown to the user |
|
||||
| `description` | No | Explanatory text below the title |
|
||||
| `options` | For selectors | Array of `{"value": 0, "label": "Off"}` objects (see per-option enablement below) |
|
||||
| `min`, `max`, `step` | For sliders | Numeric range constraints |
|
||||
| `unit` | No | Unit label. Static: `"seconds"`. Dynamic: `{"metric": "km/h", "imperial": "mph"}` (resolved by IsMetric) |
|
||||
| `visibility` | No | Rules for show/hide. Settings are **never hidden**, always dimmed with UNAVAILABLE badge when rules fail |
|
||||
| `enablement` | No | Rules for enabled/disabled (all must pass). Dimmed with badge when rules fail |
|
||||
| `blocked` | No | `true` for device-only settings that cannot be modified remotely. Frontend shows as read-only |
|
||||
| `title_param_suffix` | No | Dynamic title suffix. Example: `{"param": "IsMetric", "values": {"0": "mph", "1": "km/h"}}` |
|
||||
| `sub_items` | No | Nested child items |
|
||||
| `needs_onroad_cycle` | No | `true` if changing this param triggers a system restart. Frontend shows a "Restart" badge. See [REFERENCE.md - Remote Onroad Cycle](REFERENCE.md#remote-onroad-cycle) |
|
||||
|
||||
## Quick Reference: Rule Types
|
||||
|
||||
| Rule | Example | Use for |
|
||||
|------|---------|---------|
|
||||
| `offroad_only` | `{"type": "offroad_only"}` | Grey out while driving |
|
||||
| `capability` | `{"type": "capability", "field": "has_longitudinal_control", "equals": true}` | Car-dependent visibility |
|
||||
| `param` | `{"type": "param", "key": "Mads", "equals": true}` | Show/enable based on another setting |
|
||||
| `param_compare` | `{"type": "param_compare", "key": "SpeedLimitMode", "op": ">", "value": 0}` | Numeric comparison |
|
||||
| `not` | `{"type": "not", "condition": {...}}` | Negate a rule |
|
||||
| `any` | `{"type": "any", "conditions": [...]}` | OR logic |
|
||||
| `all` | `{"type": "all", "conditions": [...]}` | AND logic (for nesting inside `any`/`not`) |
|
||||
|
||||
**Visibility design**: Settings are always visible. When visibility rules fail, the setting is dimmed with an UNAVAILABLE badge, so users know it exists but is not applicable.
|
||||
|
||||
**Enablement rules**: Grayed out (disabled) when rules fail. Frontend shows a contextual badge explaining why.
|
||||
|
||||
**Capability fields** (referenced in rules): `has_longitudinal_control`, `has_icbm`, `icbm_available`, `torque_allowed`, `brand`, `pcm_cruise`, `alpha_long_available`, `steer_control_type`, `enable_bsm`, `is_release`, `is_sp_release`, `is_development`, `tesla_has_vehicle_bus`, `has_stop_and_go`, `stock_longitudinal`
|
||||
|
||||
---
|
||||
|
||||
## How To
|
||||
|
||||
### Add a new toggle
|
||||
|
||||
1. Register in `common/params_keys.h`:
|
||||
```cpp
|
||||
{"MyToggle", {PERSISTENT | BACKUP, BOOL}},
|
||||
```
|
||||
|
||||
2. Add to `settings_ui.json` in the right panel/section `items` array:
|
||||
```json
|
||||
{
|
||||
"key": "MyToggle",
|
||||
"widget": "toggle",
|
||||
"title": "My Feature",
|
||||
"description": "What this feature does.",
|
||||
"enablement": [{"type": "offroad_only"}]
|
||||
}
|
||||
```
|
||||
|
||||
If the toggle requires an onroad cycle (system restart) to take effect:
|
||||
```json
|
||||
{
|
||||
"key": "MyToggle",
|
||||
"widget": "toggle",
|
||||
"title": "My Feature",
|
||||
"description": "What this feature does.",
|
||||
"needs_onroad_cycle": true,
|
||||
"enablement": [{"type": "offroad_only"}]
|
||||
}
|
||||
```
|
||||
|
||||
3. Validate: `python sunnypilot/sunnylink/tools/validate_settings_ui.py`
|
||||
|
||||
### Add a multi-button selector
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "MySelector",
|
||||
"widget": "multiple_button",
|
||||
"title": "Mode",
|
||||
"options": [
|
||||
{"value": 0, "label": "Off"},
|
||||
{"value": 1, "label": "On"},
|
||||
{"value": 2, "label": "Auto"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Add a slider/range
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "MyRange",
|
||||
"widget": "option",
|
||||
"title": "Follow Distance",
|
||||
"description": "Time gap to lead vehicle.",
|
||||
"min": 0.5,
|
||||
"max": 3.0,
|
||||
"step": 0.1,
|
||||
"unit": "seconds"
|
||||
}
|
||||
```
|
||||
|
||||
### Add a slider with metric/imperial unit
|
||||
|
||||
For speed or distance values that change based on the user's `IsMetric` preference:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "MinSpeed",
|
||||
"widget": "option",
|
||||
"title": "Minimum Speed",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"step": 5,
|
||||
"unit": {"metric": "km/h", "imperial": "mph"}
|
||||
}
|
||||
```
|
||||
|
||||
The frontend resolves the correct unit string based on the device's `IsMetric` param value. Static units (like `"seconds"`, `"m/s²"`) remain plain strings.
|
||||
|
||||
### Add a title with dynamic suffix
|
||||
|
||||
Use `title_param_suffix` to append a param value to the title:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "FollowDistance",
|
||||
"widget": "option",
|
||||
"title": "Follow Distance",
|
||||
"title_param_suffix": {
|
||||
"param": "IsMetric",
|
||||
"values": {"0": "mph", "1": "km/h"}
|
||||
},
|
||||
"min": 0.5,
|
||||
"max": 3.0,
|
||||
"step": 0.1
|
||||
}
|
||||
```
|
||||
|
||||
The title will display as "Follow Distance: mph" or "Follow Distance: km/h" based on the `IsMetric` param value.
|
||||
|
||||
### Add a device-only (read-only) setting
|
||||
|
||||
Use `blocked: true` for settings that cannot be modified remotely:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "OnroadCyclePendingRemote",
|
||||
"widget": "info",
|
||||
"title": "Pending Remote Cycle",
|
||||
"blocked": true
|
||||
}
|
||||
```
|
||||
|
||||
The frontend will display this as read-only and prevent any changes.
|
||||
|
||||
### Add a dropdown
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "MyDropdown",
|
||||
"widget": "option",
|
||||
"title": "Recording Quality",
|
||||
"options": [
|
||||
{"value": 0, "label": "Low (720p)"},
|
||||
{"value": 1, "label": "Medium (1080p)"},
|
||||
{"value": 2, "label": "High (4K)"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Per-option enablement
|
||||
|
||||
Individual options within `multiple_button` or `option` widgets can have their own enablement rules:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "MadsSteeringMode",
|
||||
"widget": "multiple_button",
|
||||
"title": "Steering Mode on Brake Pedal",
|
||||
"options": [
|
||||
{
|
||||
"value": 0,
|
||||
"label": "Remain Active",
|
||||
"enablement": [{"type": "capability", "field": "brand", "equals": "tesla"}]
|
||||
},
|
||||
{
|
||||
"value": 1,
|
||||
"label": "Pause",
|
||||
"enablement": [{"type": "offroad_only"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
When an option's enablement fails, that option is grayed out (disabled) but still visible.
|
||||
|
||||
### Show only when another setting is on
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "ChildSetting",
|
||||
"widget": "toggle",
|
||||
"title": "Child Feature",
|
||||
"visibility": [{"type": "param", "key": "ParentToggle", "equals": true}]
|
||||
}
|
||||
```
|
||||
|
||||
Note: Due to the "dim instead of hide" design, this setting will be dimmed (not hidden) when the rule fails.
|
||||
|
||||
### Show only for certain cars
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "LongFeature",
|
||||
"widget": "toggle",
|
||||
"title": "Longitudinal Feature",
|
||||
"visibility": [{"type": "capability", "field": "has_longitudinal_control", "equals": true}]
|
||||
}
|
||||
```
|
||||
|
||||
### Mutual exclusion (only one can be on)
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "FeatureAlpha",
|
||||
"widget": "toggle",
|
||||
"title": "Feature Alpha",
|
||||
"enablement": [{"type": "param", "key": "FeatureBeta", "equals": false}]
|
||||
},
|
||||
{
|
||||
"key": "FeatureBeta",
|
||||
"widget": "toggle",
|
||||
"title": "Feature Beta",
|
||||
"enablement": [{"type": "param", "key": "FeatureAlpha", "equals": false}]
|
||||
}
|
||||
```
|
||||
|
||||
### Add a new section
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_section",
|
||||
"title": "My Section",
|
||||
"description": "Optional subtitle",
|
||||
"items": [...],
|
||||
"enablement": [{"type": "capability", "field": "has_longitudinal_control", "equals": true}]
|
||||
}
|
||||
```
|
||||
|
||||
Sections can have visibility and enablement rules (optional). When section-level rules fail, all items within are dimmed.
|
||||
|
||||
### Add section-level enablement
|
||||
|
||||
Sections can be conditionally available or enabled via `visibility` or `enablement`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "longitudinal_tuning",
|
||||
"title": "Longitudinal Tuning",
|
||||
"description": "Advanced control parameters",
|
||||
"visibility": [{"type": "capability", "field": "has_longitudinal_control", "equals": true}],
|
||||
"items": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### Add a sub-panel (drill-down page)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my_sub",
|
||||
"label": "Advanced Settings",
|
||||
"trigger_key": "ParentParam",
|
||||
"trigger_condition": {"type": "param", "key": "ParentParam", "equals": true},
|
||||
"items": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### Add vehicle-specific settings
|
||||
|
||||
Add to `vehicle_settings` in `settings_ui.json`:
|
||||
```json
|
||||
"rivian": {
|
||||
"title": "Rivian Settings",
|
||||
"items": [
|
||||
{
|
||||
"key": "RivianFeature",
|
||||
"widget": "toggle",
|
||||
"title": "Rivian One Pedal",
|
||||
"enablement": [{"type": "offroad_only"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Change display text
|
||||
|
||||
Edit the `title` or `description` on the item in `settings_ui.json`.
|
||||
|
||||
### Move a setting between panels
|
||||
|
||||
Remove from source panel, add to target panel. Validator catches duplicates.
|
||||
|
||||
### Reorder sections
|
||||
|
||||
Set `order` field on sections, or reorder the JSON array.
|
||||
|
||||
---
|
||||
|
||||
### Capability labels and tooltips
|
||||
|
||||
The schema response includes `capability_labels` which map capability field names to human-readable descriptions. These are used by the frontend to show contextual tooltips when a capability rule prevents a setting from being used.
|
||||
|
||||
The device defines these labels in `capabilities.py:CAPABILITY_LABELS`. Examples:
|
||||
|
||||
- `has_longitudinal_control` → "sunnypilot longitudinal control"
|
||||
- `torque_allowed` → "torque steering (not available for angle steering vehicles)"
|
||||
- `brand` → "Vehicle brand"
|
||||
|
||||
### Centralized param enforcement
|
||||
|
||||
The device-side UI enforces capability constraints in `selfdrive/ui/sunnypilot/ui_state.py:_enforce_sp_constraints()`. This method removes incompatible params based on car capabilities, and should be the single source of truth for such constraints.
|
||||
|
||||
**Settings layouts should NOT duplicate these params.remove() calls.** Instead, they should rely on schema rules and the centralized enforcement. This prevents duplicate logic and ensures consistency.
|
||||
|
||||
Example constraints in `_enforce_sp_constraints()`:
|
||||
- Angle steering cars: remove `EnforceTorqueControl` and `NeuralNetworkLateralControl`
|
||||
- No CarParams: remove all car-dependent params
|
||||
- No longitudinal: remove `ExperimentalMode`
|
||||
- No ICBM: remove `IntelligentCruiseButtonManagement`
|
||||
@@ -1071,6 +1071,10 @@
|
||||
"title": "Panda Som Reset Triggered",
|
||||
"description": ""
|
||||
},
|
||||
"ParamsVersion": {
|
||||
"title": "Params Version",
|
||||
"description": ""
|
||||
},
|
||||
"PlanplusControl": {
|
||||
"title": "Plan Plus Controls",
|
||||
"description": "Adjust planplus model recentering strength. The higher this number the more aggressively the model will recover to lanecenter, too high and it will ping-pong",
|
||||
|
||||
2209
sunnypilot/sunnylink/settings_ui.json
Normal file
2209
sunnypilot/sunnylink/settings_ui.json
Normal file
File diff suppressed because it is too large
Load Diff
512
sunnypilot/sunnylink/settings_ui.schema.json
Normal file
512
sunnypilot/sunnylink/settings_ui.schema.json
Normal file
@@ -0,0 +1,512 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sunnypilot.com/schemas/settings_ui.schema.json",
|
||||
"title": "sunnypilot Settings UI Schema",
|
||||
"description": "Defines the structure of the sunnypilot settings UI panels, items, rules, and vehicle-specific settings.",
|
||||
"type": "object",
|
||||
"required": ["schema_version", "panels", "vehicle_settings"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"description": "JSON Schema reference for editor support."
|
||||
},
|
||||
"schema_version": {
|
||||
"type": "string",
|
||||
"description": "Version of the settings UI schema format.",
|
||||
"examples": ["1.0"]
|
||||
},
|
||||
"panels": {
|
||||
"type": "array",
|
||||
"description": "Top-level settings panels displayed in the UI.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Panel"
|
||||
}
|
||||
},
|
||||
"vehicle_settings": {
|
||||
"type": "object",
|
||||
"description": "Brand-keyed vehicle-specific settings. Each key is a car brand (e.g. 'hyundai', 'toyota').",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/VehicleBrandSettings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"Panel": {
|
||||
"type": "object",
|
||||
"description": "A top-level settings panel (tab) in the UI.",
|
||||
"required": ["id", "label", "icon", "order"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for this panel."
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Display label shown in the UI."
|
||||
},
|
||||
"icon": {
|
||||
"type": "string",
|
||||
"description": "Icon identifier for this panel."
|
||||
},
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"description": "Sort order for panel display.",
|
||||
"minimum": 0
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional description shown below the panel label."
|
||||
},
|
||||
"remote_configurable": {
|
||||
"type": "boolean",
|
||||
"description": "Whether this panel's settings can be changed remotely via sunnylink.",
|
||||
"default": false
|
||||
},
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"description": "Grouped sections within this panel.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/PanelSection"
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "Settings items directly in this panel (no section grouping).",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SchemaItem"
|
||||
}
|
||||
},
|
||||
"sub_panels": {
|
||||
"type": "array",
|
||||
"description": "Nested sub-panels triggered by a setting.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SubPanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PanelSection": {
|
||||
"type": "object",
|
||||
"description": "A grouped section within a panel.",
|
||||
"required": ["id", "title"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for this section."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Display title for this section."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional description shown below the section title."
|
||||
},
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"description": "Sort order within the parent panel.",
|
||||
"minimum": 0
|
||||
},
|
||||
"visibility": {
|
||||
"type": "array",
|
||||
"description": "Rules that determine whether this section is visible. All rules must pass.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Rule"
|
||||
}
|
||||
},
|
||||
"enablement": {
|
||||
"type": "array",
|
||||
"description": "Rules that determine whether items in this section are enabled. All rules must pass.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Rule"
|
||||
}
|
||||
},
|
||||
"attestation_required": {
|
||||
"type": "boolean",
|
||||
"description": "When true, the UI must show an attestation modal before any write to items in this section.",
|
||||
"default": false
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "Settings items within this section.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SchemaItem"
|
||||
}
|
||||
},
|
||||
"sub_panels": {
|
||||
"type": "array",
|
||||
"description": "Nested sub-panels within this section.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SubPanel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"VehicleBrandSettings": {
|
||||
"type": "object",
|
||||
"description": "Brand-specific settings group inside vehicle_settings.",
|
||||
"required": ["items"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Display title for this brand's settings group."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional description shown below the brand title."
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "Settings items for this brand.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SchemaItem"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SchemaItem": {
|
||||
"type": "object",
|
||||
"description": "A single settings item (toggle, option selector, button group, etc.).",
|
||||
"required": ["key", "widget"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The param key this item reads/writes."
|
||||
},
|
||||
"widget": {
|
||||
"type": "string",
|
||||
"description": "The UI widget type to render.",
|
||||
"enum": ["toggle", "option", "multiple_button", "button", "info"]
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Override display title (defaults to metadata lookup by key)."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Override description text."
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"description": "Available options for 'option' or 'multiple_button' widgets.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SchemaOption"
|
||||
}
|
||||
},
|
||||
"min": {
|
||||
"type": "number",
|
||||
"description": "Minimum value for numeric option widgets."
|
||||
},
|
||||
"max": {
|
||||
"type": "number",
|
||||
"description": "Maximum value for numeric option widgets."
|
||||
},
|
||||
"step": {
|
||||
"type": "number",
|
||||
"description": "Step increment for numeric option widgets."
|
||||
},
|
||||
"unit": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Static unit label (e.g. 'seconds', 'm/s²')."
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"description": "Dynamic unit that changes based on IsMetric param.",
|
||||
"required": ["metric", "imperial"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"metric": {
|
||||
"type": "string",
|
||||
"description": "Unit label when IsMetric is true (e.g. 'km/h')."
|
||||
},
|
||||
"imperial": {
|
||||
"type": "string",
|
||||
"description": "Unit label when IsMetric is false (e.g. 'mph')."
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "Unit label for numeric values. Use a string for static units or an object with metric/imperial variants for units that depend on the IsMetric param."
|
||||
},
|
||||
"value_map": {
|
||||
"type": "object",
|
||||
"description": "Maps stored values to display labels.",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"visibility": {
|
||||
"type": "array",
|
||||
"description": "Rules that determine whether this item is visible. All rules must pass.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Rule"
|
||||
}
|
||||
},
|
||||
"enablement": {
|
||||
"type": "array",
|
||||
"description": "Rules that determine whether this item is enabled/interactive. All rules must pass.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Rule"
|
||||
}
|
||||
},
|
||||
"sub_items": {
|
||||
"type": "array",
|
||||
"description": "Child items nested under this item (e.g. options revealed by a toggle).",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SchemaItem"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "Action identifier for button widgets."
|
||||
},
|
||||
"title_param_suffix": {
|
||||
"type": "object",
|
||||
"description": "Renders an extra suffix in the item title chosen by the value of another param.",
|
||||
"required": ["param", "values"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"param": {
|
||||
"type": "string",
|
||||
"description": "Param key whose value selects the suffix label."
|
||||
},
|
||||
"values": {
|
||||
"type": "object",
|
||||
"description": "Map from stringified param value to suffix label.",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"needs_onroad_cycle": {
|
||||
"type": "boolean",
|
||||
"description": "When true, the device must cycle onroad/offroad for the new value to take effect.",
|
||||
"default": false
|
||||
},
|
||||
"blocked": {
|
||||
"type": "boolean",
|
||||
"description": "When true, this item is treated as DEVICE_ONLY and the dashboard must not write it remotely.",
|
||||
"default": false
|
||||
},
|
||||
"requires_attestation": {
|
||||
"type": "boolean",
|
||||
"description": "When true, writes to this item require an explicit per-write confirmation modal.",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"SubPanel": {
|
||||
"type": "object",
|
||||
"description": "A nested panel that opens when triggered by a parent item.",
|
||||
"required": ["id", "label", "trigger_key"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Unique identifier for this sub-panel."
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Display label for the sub-panel header."
|
||||
},
|
||||
"trigger_key": {
|
||||
"type": "string",
|
||||
"description": "The param key that triggers opening this sub-panel."
|
||||
},
|
||||
"trigger_condition": {
|
||||
"$ref": "#/$defs/Rule",
|
||||
"description": "Optional rule that must evaluate to true for the sub-panel trigger to be active."
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "Settings items within this sub-panel.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/SchemaItem"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SchemaOption": {
|
||||
"type": "object",
|
||||
"description": "A selectable option for option/multiple_button widgets.",
|
||||
"required": ["value", "label"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"value": {
|
||||
"oneOf": [
|
||||
{ "type": "number" },
|
||||
{ "type": "string" }
|
||||
],
|
||||
"description": "The stored value when this option is selected."
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "The display label for this option."
|
||||
},
|
||||
"enablement": {
|
||||
"type": "array",
|
||||
"description": "Rules that determine whether this option is selectable. All rules must pass.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Rule"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Rule": {
|
||||
"description": "A visibility or enablement rule. Discriminated union on the 'type' field.",
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/RuleOffroadOnly" },
|
||||
{ "$ref": "#/$defs/RuleNotEngaged" },
|
||||
{ "$ref": "#/$defs/RuleCapability" },
|
||||
{ "$ref": "#/$defs/RuleParam" },
|
||||
{ "$ref": "#/$defs/RuleParamCompare" },
|
||||
{ "$ref": "#/$defs/RuleNot" },
|
||||
{ "$ref": "#/$defs/RuleAny" },
|
||||
{ "$ref": "#/$defs/RuleAll" }
|
||||
]
|
||||
},
|
||||
"RuleOffroadOnly": {
|
||||
"type": "object",
|
||||
"description": "Rule that passes only when the device is offroad.",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "offroad_only"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleNotEngaged": {
|
||||
"type": "object",
|
||||
"description": "Rule that passes when the vehicle is not engaged (matches Raylib `engaged = started AND (selfdriveState.enabled OR selfdriveStateSP.mads.enabled)`).",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "not_engaged"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleCapability": {
|
||||
"type": "object",
|
||||
"description": "Rule that checks a vehicle capability field against an expected value.",
|
||||
"required": ["type", "field", "equals"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "capability"
|
||||
},
|
||||
"field": {
|
||||
"type": "string",
|
||||
"description": "The capability field name to check."
|
||||
},
|
||||
"equals": {
|
||||
"description": "The expected value to match against."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleParam": {
|
||||
"type": "object",
|
||||
"description": "Rule that checks a param value against an expected value.",
|
||||
"required": ["type", "key", "equals"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "param"
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The param key to read."
|
||||
},
|
||||
"equals": {
|
||||
"description": "The expected value to match against."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleParamCompare": {
|
||||
"type": "object",
|
||||
"description": "Rule that compares a numeric param value using a comparison operator.",
|
||||
"required": ["type", "key", "op", "value"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "param_compare"
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The param key to read."
|
||||
},
|
||||
"op": {
|
||||
"type": "string",
|
||||
"description": "Comparison operator.",
|
||||
"enum": [">", "<", ">=", "<="]
|
||||
},
|
||||
"value": {
|
||||
"type": "number",
|
||||
"description": "The numeric value to compare against."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleNot": {
|
||||
"type": "object",
|
||||
"description": "Rule that negates a single child condition.",
|
||||
"required": ["type", "condition"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "not"
|
||||
},
|
||||
"condition": {
|
||||
"$ref": "#/$defs/Rule",
|
||||
"description": "The rule to negate."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleAny": {
|
||||
"type": "object",
|
||||
"description": "Rule that passes if ANY of the child conditions pass (logical OR).",
|
||||
"required": ["type", "conditions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "any"
|
||||
},
|
||||
"conditions": {
|
||||
"type": "array",
|
||||
"description": "Child rules; at least one must pass.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Rule"
|
||||
},
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleAll": {
|
||||
"type": "object",
|
||||
"description": "Rule that passes only if ALL child conditions pass (logical AND).",
|
||||
"required": ["type", "conditions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "all"
|
||||
},
|
||||
"conditions": {
|
||||
"type": "array",
|
||||
"description": "Child rules; all must pass.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/Rule"
|
||||
},
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
93
sunnypilot/sunnylink/tests/test_capabilities.py
Normal file
93
sunnypilot/sunnylink/tests/test_capabilities.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
|
||||
Sentinel tests for the capabilities payload contract. PROTOCOL_VERSION is the
|
||||
wire-protocol version observable by the dashboard; bumping it is a breaking
|
||||
change and must be intentional. KNOWN_PROTOCOL_VERSIONS pins the set we
|
||||
explicitly support — when the constant is bumped, this list must be edited in
|
||||
the same commit so the bump shows up in code review.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.sunnypilot.sunnylink.capabilities import (
|
||||
CAPABILITY_DEFAULTS,
|
||||
CAPABILITY_FIELDS,
|
||||
CAPABILITY_LABELS,
|
||||
PROTOCOL_VERSION,
|
||||
generate_capabilities,
|
||||
)
|
||||
|
||||
|
||||
KNOWN_PROTOCOL_VERSIONS = (1,)
|
||||
LATEST_KNOWN = max(KNOWN_PROTOCOL_VERSIONS)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def caps():
|
||||
return generate_capabilities()
|
||||
|
||||
|
||||
class TestProtocolVersion:
|
||||
def test_protocol_version_in_capability_fields(self):
|
||||
assert "protocol_version" in CAPABILITY_FIELDS
|
||||
|
||||
def test_protocol_version_has_label(self):
|
||||
assert "protocol_version" in CAPABILITY_LABELS
|
||||
|
||||
def test_protocol_version_default_is_set(self):
|
||||
assert CAPABILITY_DEFAULTS.get("protocol_version") == PROTOCOL_VERSION
|
||||
|
||||
def test_protocol_version_emitted(self, caps):
|
||||
assert "protocol_version" in caps
|
||||
assert isinstance(caps["protocol_version"], int)
|
||||
assert caps["protocol_version"] >= 1
|
||||
|
||||
def test_protocol_version_matches_constant(self, caps):
|
||||
assert caps["protocol_version"] == PROTOCOL_VERSION
|
||||
|
||||
def test_protocol_version_is_known(self):
|
||||
"""Sentinel against accidental bumps. If you intentionally bumped the
|
||||
protocol, edit KNOWN_PROTOCOL_VERSIONS in the same commit."""
|
||||
assert PROTOCOL_VERSION in KNOWN_PROTOCOL_VERSIONS, (
|
||||
f"PROTOCOL_VERSION={PROTOCOL_VERSION} is not in KNOWN_PROTOCOL_VERSIONS={KNOWN_PROTOCOL_VERSIONS}. "
|
||||
"If this bump is intentional, add it to KNOWN_PROTOCOL_VERSIONS."
|
||||
)
|
||||
|
||||
def test_protocol_version_matches_latest_known(self):
|
||||
assert PROTOCOL_VERSION == LATEST_KNOWN, (
|
||||
"Test invariant: PROTOCOL_VERSION must equal max(KNOWN_PROTOCOL_VERSIONS)."
|
||||
)
|
||||
|
||||
|
||||
class TestOpaquePerBrandFlags:
|
||||
def test_subaru_has_sng_field_present(self):
|
||||
assert "subaru_has_sng" in CAPABILITY_FIELDS
|
||||
|
||||
def test_hyundai_alpha_long_available_field_present(self):
|
||||
assert "hyundai_alpha_long_available" in CAPABILITY_FIELDS
|
||||
|
||||
def test_subaru_has_sng_default_false(self, caps):
|
||||
assert caps["subaru_has_sng"] is False
|
||||
|
||||
def test_hyundai_alpha_long_available_default_false(self, caps):
|
||||
assert caps["hyundai_alpha_long_available"] is False
|
||||
|
||||
|
||||
class TestCapabilitiesShape:
|
||||
def test_all_fields_present(self, caps):
|
||||
for field in CAPABILITY_FIELDS:
|
||||
assert field in caps, f"capabilities missing {field}"
|
||||
|
||||
def test_all_fields_have_labels(self):
|
||||
for field in CAPABILITY_FIELDS:
|
||||
assert field in CAPABILITY_LABELS, f"CAPABILITY_LABELS missing {field}"
|
||||
|
||||
def test_string_defaults_are_strings(self, caps):
|
||||
assert isinstance(caps["brand"], str)
|
||||
assert isinstance(caps["steer_control_type"], str)
|
||||
assert isinstance(caps["device_type"], str)
|
||||
222
sunnypilot/sunnylink/tests/test_settings_changes.py
Normal file
222
sunnypilot/sunnylink/tests/test_settings_changes.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
|
||||
Per-bug regression tests for the Raylib-vs-schema parity audit. Each test
|
||||
isolates one of the gating bugs that the design-overhaul branch fixes so a
|
||||
future regression is loud and obvious. These tests are intentionally narrow
|
||||
and additive — they do not replace the broader test_settings_schema.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import (
|
||||
DEFINITION_PATH,
|
||||
TORQUE_VERSIONS_PATH,
|
||||
_build_torque_options,
|
||||
_load_torque_versions,
|
||||
generate_schema,
|
||||
)
|
||||
|
||||
|
||||
SCHEMA_VALIDATOR_PATH = os.path.join(os.path.dirname(DEFINITION_PATH), "settings_ui.schema.json")
|
||||
|
||||
|
||||
def _walk_items(schema: dict[str, Any]):
|
||||
"""Yield every item dict reachable from the schema (panels, sections, sub_panels, sub_items, vehicle_settings)."""
|
||||
def _yield(item: dict[str, Any]):
|
||||
yield item
|
||||
for sub in item.get("sub_items", []):
|
||||
yield from _yield(sub)
|
||||
|
||||
for panel in schema.get("panels", []):
|
||||
for section in panel.get("sections", []):
|
||||
for item in section.get("items", []):
|
||||
yield from _yield(item)
|
||||
for sp in section.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
yield from _yield(item)
|
||||
for item in panel.get("items", []):
|
||||
yield from _yield(item)
|
||||
for sp in panel.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
yield from _yield(item)
|
||||
for brand in schema.get("vehicle_settings", {}).values():
|
||||
items = brand.get("items", []) if isinstance(brand, dict) else brand
|
||||
for item in items:
|
||||
yield from _yield(item)
|
||||
|
||||
|
||||
def _find_item(schema: dict[str, Any], key: str) -> dict[str, Any] | None:
|
||||
for item in _walk_items(schema):
|
||||
if item.get("key") == key:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def _find_section(schema: dict[str, Any], panel_id: str, section_id: str) -> dict[str, Any] | None:
|
||||
for panel in schema.get("panels", []):
|
||||
if panel.get("id") != panel_id:
|
||||
continue
|
||||
for section in panel.get("sections", []):
|
||||
if section.get("id") == section_id:
|
||||
return section
|
||||
return None
|
||||
|
||||
|
||||
def _flatten_rule_types(rules: list[dict[str, Any]] | None) -> set[str]:
|
||||
out: set[str] = set()
|
||||
|
||||
def _walk(rule: dict[str, Any]) -> None:
|
||||
out.add(rule.get("type", ""))
|
||||
if rule.get("type") == "not" and "condition" in rule:
|
||||
_walk(rule["condition"])
|
||||
elif rule.get("type") in ("any", "all"):
|
||||
for c in rule.get("conditions", []):
|
||||
_walk(c)
|
||||
|
||||
for rule in rules or []:
|
||||
_walk(rule)
|
||||
return out
|
||||
|
||||
|
||||
def _references_capability_field(rules: list[dict[str, Any]] | None, field: str) -> bool:
|
||||
found = False
|
||||
|
||||
def _walk(rule: dict[str, Any]) -> None:
|
||||
nonlocal found
|
||||
if rule.get("type") == "capability" and rule.get("field") == field:
|
||||
found = True
|
||||
elif rule.get("type") == "not" and "condition" in rule:
|
||||
_walk(rule["condition"])
|
||||
elif rule.get("type") in ("any", "all"):
|
||||
for c in rule.get("conditions", []):
|
||||
_walk(c)
|
||||
|
||||
for rule in rules or []:
|
||||
_walk(rule)
|
||||
return found
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def schema():
|
||||
return generate_schema()
|
||||
|
||||
|
||||
class TestMadsBrandGates:
|
||||
def test_mads_main_cruise_has_brand_gate(self, schema):
|
||||
"""MadsMainCruiseAllowed must be disabled on rivian + tesla-no-bus to match Raylib _mads_limited_settings."""
|
||||
item = _find_item(schema, "MadsMainCruiseAllowed")
|
||||
assert item is not None
|
||||
assert _references_capability_field(item.get("enablement"), "brand")
|
||||
assert _references_capability_field(item.get("enablement"), "tesla_has_vehicle_bus")
|
||||
|
||||
def test_mads_unified_engagement_has_brand_gate(self, schema):
|
||||
"""MadsUnifiedEngagementMode must mirror MadsMainCruiseAllowed brand-gate."""
|
||||
item = _find_item(schema, "MadsUnifiedEngagementMode")
|
||||
assert item is not None
|
||||
assert _references_capability_field(item.get("enablement"), "brand")
|
||||
assert _references_capability_field(item.get("enablement"), "tesla_has_vehicle_bus")
|
||||
|
||||
|
||||
class TestTestManeuversSection:
|
||||
def test_lateral_maneuver_mode_in_test_maneuvers(self, schema):
|
||||
section = _find_section(schema, "developer", "test_maneuvers")
|
||||
assert section is not None, "developer.test_maneuvers section missing"
|
||||
keys = {item["key"] for item in section.get("items", [])}
|
||||
assert "LateralManeuverMode" in keys
|
||||
assert "LongitudinalManeuverMode" in keys
|
||||
|
||||
def test_test_maneuvers_section_requires_attestation(self, schema):
|
||||
section = _find_section(schema, "developer", "test_maneuvers")
|
||||
assert section is not None
|
||||
assert section.get("attestation_required") is True
|
||||
|
||||
def test_test_maneuvers_section_visibility_gate(self, schema):
|
||||
section = _find_section(schema, "developer", "test_maneuvers")
|
||||
assert section is not None
|
||||
visibility = section.get("visibility")
|
||||
assert visibility, "test_maneuvers must have visibility gate"
|
||||
vis_refs = json.dumps(visibility)
|
||||
assert "is_development" in vis_refs
|
||||
assert "is_sp_release" in vis_refs
|
||||
enablement = section.get("enablement") or []
|
||||
enable_refs = json.dumps(enablement)
|
||||
assert "ShowAdvancedControls" in enable_refs, \
|
||||
"test_maneuvers must gate ShowAdvancedControls via enablement (disabled, not hidden)"
|
||||
|
||||
|
||||
class TestValidator:
|
||||
def test_validator_accepts_real_json(self):
|
||||
"""settings_ui.json must validate against settings_ui.schema.json."""
|
||||
jsonschema = pytest.importorskip("jsonschema")
|
||||
with open(DEFINITION_PATH) as f:
|
||||
data = json.load(f)
|
||||
with open(SCHEMA_VALIDATOR_PATH) as f:
|
||||
validator = json.load(f)
|
||||
jsonschema.validate(instance=data, schema=validator)
|
||||
|
||||
|
||||
class TestTorqueOptionGeneration:
|
||||
def test_torque_versions_match_generated_options(self, schema):
|
||||
versions = _load_torque_versions()
|
||||
assert versions, "latcontrol_torque_versions.json must have at least one version"
|
||||
expected = _build_torque_options(versions)
|
||||
item = _find_item(schema, "TorqueControlTune")
|
||||
assert item is not None, "TorqueControlTune item must be present"
|
||||
assert item.get("options") == expected
|
||||
|
||||
def test_torque_versions_path_resolves(self):
|
||||
assert os.path.exists(TORQUE_VERSIONS_PATH), (
|
||||
f"latcontrol_torque_versions.json not found at {TORQUE_VERSIONS_PATH}"
|
||||
)
|
||||
|
||||
|
||||
class TestReleaseBranchGates:
|
||||
@pytest.mark.parametrize("key", [
|
||||
"JoystickDebugMode",
|
||||
"AlphaLongitudinalEnabled",
|
||||
"EnableGithubRunner",
|
||||
"QuickBootToggle",
|
||||
])
|
||||
def test_sp_dev_items_gate_on_is_sp_release(self, schema, key):
|
||||
"""SP-side dev items must hide on EITHER release branch (matches Raylib _is_release_branch = is_release OR IsReleaseSpBranch)."""
|
||||
item = _find_item(schema, key)
|
||||
assert item is not None, f"{key} not found in schema"
|
||||
rules = (item.get("visibility") or []) + (item.get("enablement") or [])
|
||||
assert _references_capability_field(rules, "is_release"), f"{key} missing is_release gate"
|
||||
assert _references_capability_field(rules, "is_sp_release"), f"{key} missing is_sp_release gate"
|
||||
|
||||
|
||||
class TestSpuriousOffroadGatesDropped:
|
||||
def test_disengage_on_accelerator_has_no_offroad_only(self, schema):
|
||||
item = _find_item(schema, "DisengageOnAccelerator")
|
||||
assert item is not None
|
||||
assert "offroad_only" not in _flatten_rule_types(item.get("enablement"))
|
||||
|
||||
def test_dynamic_experimental_has_no_offroad_only(self, schema):
|
||||
item = _find_item(schema, "DynamicExperimentalControl")
|
||||
assert item is not None
|
||||
assert "offroad_only" not in _flatten_rule_types(item.get("enablement"))
|
||||
|
||||
|
||||
class TestNotEngagedReplacement:
|
||||
@pytest.mark.parametrize("key", [
|
||||
"AlphaLongitudinalEnabled",
|
||||
"ToyotaEnforceStockLongitudinal",
|
||||
"ToyotaStopAndGoHack",
|
||||
])
|
||||
def test_offroad_only_replaced_with_not_engaged(self, schema, key):
|
||||
"""These items can be changed safely when not engaged; must not require full offroad."""
|
||||
item = _find_item(schema, key)
|
||||
assert item is not None, f"{key} not found"
|
||||
rule_types = _flatten_rule_types(item.get("enablement"))
|
||||
assert "offroad_only" not in rule_types, f"{key} still uses offroad_only"
|
||||
assert "not_engaged" in rule_types, f"{key} missing not_engaged"
|
||||
354
sunnypilot/sunnylink/tests/test_settings_schema.py
Normal file
354
sunnypilot/sunnylink/tests/test_settings_schema.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import (
|
||||
SCHEMA_VERSION,
|
||||
generate_schema,
|
||||
generate_schema_json,
|
||||
collect_all_keys,
|
||||
collect_capability_refs,
|
||||
)
|
||||
from openpilot.sunnypilot.sunnylink.capabilities import CAPABILITY_FIELDS
|
||||
|
||||
|
||||
VALID_WIDGET_TYPES = {"toggle", "option", "multiple_button", "button", "info"}
|
||||
VALID_RULE_TYPES = {"offroad_only", "not_engaged", "capability", "param", "param_compare", "not", "any", "all"}
|
||||
VALID_COMPARE_OPS = {">", "<", ">=", "<="}
|
||||
MAX_ALLOWED_MISSING_TITLES = 0 # All items must have titles (metadata is inline in settings_ui.json)
|
||||
|
||||
|
||||
def _iter_panel_items(panel: dict):
|
||||
"""Yield top-level items reachable from a panel: panel.items, panel.sub_panels.items,
|
||||
panel.sections.items, panel.sections.sub_panels.items. Does not recurse sub_items."""
|
||||
for item in panel.get("items", []):
|
||||
yield item
|
||||
for sp in panel.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
yield item
|
||||
for section in panel.get("sections", []):
|
||||
for item in section.get("items", []):
|
||||
yield item
|
||||
for sp in section.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
yield item
|
||||
|
||||
|
||||
def _iter_all_sub_panels(panel: dict):
|
||||
"""Yield every sub_panel (panel-level + section-nested)."""
|
||||
yield from panel.get("sub_panels", [])
|
||||
for section in panel.get("sections", []):
|
||||
yield from section.get("sub_panels", [])
|
||||
|
||||
|
||||
def _brand_items(brand_data) -> list[dict]:
|
||||
"""vehicle_settings[brand] is dict {title, description?, items}; tolerate raw list."""
|
||||
if isinstance(brand_data, dict):
|
||||
return brand_data.get("items", [])
|
||||
if isinstance(brand_data, list):
|
||||
return brand_data
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def schema():
|
||||
return generate_schema()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def all_param_keys():
|
||||
"""All keys registered in the device param store."""
|
||||
return {k.decode("utf-8") for k in Params().all_keys()}
|
||||
|
||||
|
||||
class TestSchemaStructure:
|
||||
def test_schema_is_valid_json(self):
|
||||
"""Schema can be serialized to valid JSON."""
|
||||
raw = generate_schema_json()
|
||||
parsed = json.loads(raw)
|
||||
assert isinstance(parsed, dict)
|
||||
|
||||
def test_has_required_top_level_fields(self, schema):
|
||||
assert "schema_version" in schema
|
||||
assert schema["schema_version"] == SCHEMA_VERSION
|
||||
assert "generated_at" in schema
|
||||
assert "panels" in schema
|
||||
assert "vehicle_settings" in schema
|
||||
assert "capability_fields" in schema
|
||||
|
||||
def test_panels_are_list(self, schema):
|
||||
assert isinstance(schema["panels"], list)
|
||||
assert len(schema["panels"]) > 0
|
||||
|
||||
def test_all_panels_have_required_fields(self, schema):
|
||||
for panel in schema["panels"]:
|
||||
assert "id" in panel, f"Panel missing 'id': {panel}"
|
||||
assert "label" in panel, f"Panel {panel.get('id')} missing 'label'"
|
||||
assert "order" in panel, f"Panel {panel.get('id')} missing 'order'"
|
||||
has_sections = "sections" in panel
|
||||
has_items = "items" in panel
|
||||
assert has_sections or has_items, \
|
||||
f"Panel {panel['id']} must have 'sections' or 'items'"
|
||||
if has_sections:
|
||||
assert isinstance(panel["sections"], list)
|
||||
for sec in panel["sections"]:
|
||||
assert "id" in sec, f"Section in panel {panel['id']} missing 'id'"
|
||||
assert "items" in sec, f"Section {panel['id']}.{sec.get('id')} missing 'items'"
|
||||
assert isinstance(sec["items"], list)
|
||||
if has_items:
|
||||
assert isinstance(panel["items"], list)
|
||||
|
||||
def test_all_items_have_key_and_widget(self, schema):
|
||||
for panel in schema["panels"]:
|
||||
for item in _iter_panel_items(panel):
|
||||
assert "key" in item, f"Item in panel {panel['id']} missing 'key'"
|
||||
assert "widget" in item, f"Item {item.get('key')} missing 'widget'"
|
||||
assert item["widget"] in VALID_WIDGET_TYPES, \
|
||||
f"Item {item['key']} has invalid widget type: {item['widget']}"
|
||||
|
||||
def test_sub_panel_items_have_key_and_widget(self, schema):
|
||||
for panel in schema["panels"]:
|
||||
for sp in _iter_all_sub_panels(panel):
|
||||
assert "id" in sp
|
||||
assert "items" in sp
|
||||
for item in sp["items"]:
|
||||
assert "key" in item
|
||||
assert "widget" in item
|
||||
assert item["widget"] in VALID_WIDGET_TYPES
|
||||
|
||||
def test_vehicle_settings_structure(self, schema):
|
||||
vs = schema["vehicle_settings"]
|
||||
assert isinstance(vs, dict)
|
||||
for brand, data in vs.items():
|
||||
assert isinstance(brand, str)
|
||||
assert isinstance(data, dict), \
|
||||
f"vehicle_settings[{brand}] must be a dict {{title, items, ...}}"
|
||||
assert "items" in data, f"vehicle_settings[{brand}] missing 'items'"
|
||||
assert isinstance(data["items"], list)
|
||||
for item in data["items"]:
|
||||
assert "key" in item, f"Vehicle item for {brand} missing 'key'"
|
||||
assert "widget" in item, f"Vehicle item {item.get('key')} missing 'widget'"
|
||||
|
||||
def test_no_duplicate_keys_across_panels(self, schema):
|
||||
"""Each param key should appear in at most one panel to avoid double-rendering."""
|
||||
seen: dict[str, str] = {} # key -> panel_id
|
||||
for panel in schema["panels"]:
|
||||
for item in _iter_panel_items(panel):
|
||||
key = item["key"]
|
||||
if key in seen:
|
||||
pytest.fail(f"Key '{key}' appears in both panel '{seen[key]}' and '{panel['id']}'")
|
||||
seen[key] = panel["id"]
|
||||
for sub in item.get("sub_items", []):
|
||||
sub_key = sub["key"]
|
||||
if sub_key in seen:
|
||||
pytest.fail(f"Sub-item key '{sub_key}' appears in both '{seen[sub_key]}' and '{panel['id']}'")
|
||||
seen[sub_key] = panel["id"]
|
||||
|
||||
|
||||
class TestSchemaCoverage:
|
||||
def test_all_schema_keys_exist_in_params(self, schema, all_param_keys):
|
||||
"""Every key referenced in the schema must exist in Params().all_keys()."""
|
||||
schema_keys = collect_all_keys(schema)
|
||||
missing = schema_keys - all_param_keys
|
||||
assert not missing, f"Schema references keys not in Params: {missing}"
|
||||
|
||||
def test_all_capability_fields_are_declared(self, schema):
|
||||
"""Every capability field used in rules must be in capability_fields."""
|
||||
declared = set(schema["capability_fields"])
|
||||
referenced = collect_capability_refs(schema)
|
||||
undeclared = referenced - declared
|
||||
assert not undeclared, f"Rules reference undeclared capability fields: {undeclared}"
|
||||
|
||||
def test_capability_fields_match_constant(self, schema):
|
||||
"""Schema capability_fields must match the CAPABILITY_FIELDS constant."""
|
||||
assert set(schema["capability_fields"]) == set(CAPABILITY_FIELDS)
|
||||
|
||||
|
||||
class TestRuleWellFormedness:
|
||||
def _validate_rule(self, rule: dict, context: str = ""):
|
||||
"""Recursively validate a single rule dict."""
|
||||
assert "type" in rule, f"Rule missing 'type' in {context}"
|
||||
rtype = rule["type"]
|
||||
assert rtype in VALID_RULE_TYPES, f"Invalid rule type '{rtype}' in {context}"
|
||||
|
||||
if rtype == "capability":
|
||||
assert "field" in rule, f"Capability rule missing 'field' in {context}"
|
||||
assert "equals" in rule, f"Capability rule missing 'equals' in {context}"
|
||||
elif rtype == "param":
|
||||
assert "key" in rule, f"Param rule missing 'key' in {context}"
|
||||
assert "equals" in rule, f"Param rule missing 'equals' in {context}"
|
||||
elif rtype == "param_compare":
|
||||
assert "key" in rule, f"Param compare rule missing 'key' in {context}"
|
||||
assert "op" in rule, f"Param compare rule missing 'op' in {context}"
|
||||
assert rule["op"] in VALID_COMPARE_OPS, f"Invalid op '{rule['op']}' in {context}"
|
||||
assert "value" in rule, f"Param compare rule missing 'value' in {context}"
|
||||
elif rtype == "not":
|
||||
assert "condition" in rule, f"Not rule missing 'condition' in {context}"
|
||||
self._validate_rule(rule["condition"], context=f"{context} > not")
|
||||
elif rtype in ("any", "all"):
|
||||
assert "conditions" in rule, f"{rtype} rule missing 'conditions' in {context}"
|
||||
assert isinstance(rule["conditions"], list)
|
||||
for c in rule["conditions"]:
|
||||
self._validate_rule(c, context=f"{context} > {rtype}")
|
||||
|
||||
def _validate_items(self, items: list[dict], context: str):
|
||||
for item in items:
|
||||
key = item.get("key", "unknown")
|
||||
for rules_field in ("visibility", "enablement"):
|
||||
rules = item.get(rules_field)
|
||||
if rules:
|
||||
assert isinstance(rules, list), f"{key}.{rules_field} must be a list"
|
||||
for rule in rules:
|
||||
self._validate_rule(rule, context=f"{context}.{key}.{rules_field}")
|
||||
for sub in item.get("sub_items", []):
|
||||
self._validate_items([sub], context=f"{context}.{key}")
|
||||
|
||||
def _validate_section_rules(self, section: dict, context: str):
|
||||
for rules_field in ("visibility", "enablement"):
|
||||
rules = section.get(rules_field) or []
|
||||
for rule in rules:
|
||||
self._validate_rule(rule, context=f"{context}.{rules_field}")
|
||||
|
||||
def test_all_panel_rules_well_formed(self, schema):
|
||||
for panel in schema["panels"]:
|
||||
self._validate_items(list(_iter_panel_items(panel)), context=f"panel:{panel['id']}")
|
||||
for sp in _iter_all_sub_panels(panel):
|
||||
self._validate_items(sp["items"], context=f"subpanel:{sp['id']}")
|
||||
for section in panel.get("sections", []):
|
||||
self._validate_section_rules(section, context=f"section:{panel['id']}.{section['id']}")
|
||||
|
||||
def test_all_vehicle_rules_well_formed(self, schema):
|
||||
for brand, data in schema["vehicle_settings"].items():
|
||||
self._validate_items(_brand_items(data), context=f"vehicle:{brand}")
|
||||
|
||||
def test_no_self_referencing_visibility(self, schema):
|
||||
"""An item's visibility/enablement rules should not depend on its own key."""
|
||||
def _check_self_ref(item: dict, rules_field: str):
|
||||
key = item.get("key")
|
||||
for rule in item.get(rules_field, []):
|
||||
if rule.get("type") == "param" and rule.get("key") == key:
|
||||
pytest.fail(f"Item {key} has self-referencing {rules_field} rule")
|
||||
|
||||
for panel in schema["panels"]:
|
||||
for item in _iter_panel_items(panel):
|
||||
_check_self_ref(item, "visibility")
|
||||
_check_self_ref(item, "enablement")
|
||||
|
||||
for brand_data in schema.get("vehicle_settings", {}).values():
|
||||
for item in _brand_items(brand_data):
|
||||
_check_self_ref(item, "visibility")
|
||||
_check_self_ref(item, "enablement")
|
||||
|
||||
|
||||
class TestKnownPanels:
|
||||
def test_expected_panels_exist(self, schema):
|
||||
panel_ids = {p["id"] for p in schema["panels"]}
|
||||
expected = {"steering", "cruise", "display", "visuals", "device", "software", "developer"}
|
||||
assert expected.issubset(panel_ids), f"Missing panels: {expected - panel_ids}"
|
||||
|
||||
def test_mads_sub_panel_exists(self, schema):
|
||||
steering = next(p for p in schema["panels"] if p["id"] == "steering")
|
||||
sub_ids = {sp["id"] for sp in _iter_all_sub_panels(steering)}
|
||||
assert "mads_settings" in sub_ids
|
||||
|
||||
def test_mutual_exclusion_torque_nnlc(self, schema):
|
||||
"""EnforceTorqueControl and NNLC should have cross-param rules."""
|
||||
torque = nnlc = None
|
||||
for panel in schema["panels"]:
|
||||
for item in _iter_panel_items(panel):
|
||||
if item["key"] == "EnforceTorqueControl":
|
||||
torque = item
|
||||
elif item["key"] == "NeuralNetworkLateralControl":
|
||||
nnlc = item
|
||||
assert torque is not None, "EnforceTorqueControl item missing"
|
||||
assert nnlc is not None, "NeuralNetworkLateralControl item missing"
|
||||
torque_enable_keys = {r.get("key") for r in torque.get("enablement", []) if r.get("type") == "param"}
|
||||
assert "NeuralNetworkLateralControl" in torque_enable_keys
|
||||
nnlc_enable_keys = {r.get("key") for r in nnlc.get("enablement", []) if r.get("type") == "param"}
|
||||
assert "EnforceTorqueControl" in nnlc_enable_keys
|
||||
|
||||
|
||||
class TestKnownVehicleSettings:
|
||||
def test_hyundai_has_longitudinal_tuning(self, schema):
|
||||
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("hyundai"))}
|
||||
assert "HyundaiLongitudinalTuning" in keys
|
||||
|
||||
def test_toyota_has_enforce_stock_and_stop_go(self, schema):
|
||||
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("toyota"))}
|
||||
assert "ToyotaEnforceStockLongitudinal" in keys
|
||||
assert "ToyotaStopAndGoHack" in keys
|
||||
|
||||
def test_tesla_has_coop_steering(self, schema):
|
||||
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("tesla"))}
|
||||
assert "TeslaCoopSteering" in keys
|
||||
|
||||
def test_subaru_has_stop_and_go(self, schema):
|
||||
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("subaru"))}
|
||||
assert "SubaruStopAndGo" in keys
|
||||
assert "SubaruStopAndGoManualParkingBrake" in keys
|
||||
|
||||
|
||||
class TestItemCompleteness:
|
||||
def _collect_all_items(self, schema):
|
||||
"""Collect every item, including sub_items, across panels (sections + flat) + vehicle_settings."""
|
||||
items = []
|
||||
for panel in schema["panels"]:
|
||||
for item in _iter_panel_items(panel):
|
||||
items.append(item)
|
||||
for sub in item.get("sub_items", []):
|
||||
items.append(sub)
|
||||
for brand_data in schema.get("vehicle_settings", {}).values():
|
||||
for item in _brand_items(brand_data):
|
||||
items.append(item)
|
||||
for sub in item.get("sub_items", []):
|
||||
items.append(sub)
|
||||
return items
|
||||
|
||||
def test_all_items_have_titles(self, schema):
|
||||
"""Every item must have a title (metadata is inline, no enrichment fallback)."""
|
||||
missing = [i["key"] for i in self._collect_all_items(schema) if "title" not in i]
|
||||
if len(missing) > MAX_ALLOWED_MISSING_TITLES:
|
||||
pytest.fail(f"Items without titles ({len(missing)}): {missing[:10]}")
|
||||
|
||||
def test_no_default_titles(self, schema):
|
||||
"""No item should have title == key (forces human-readable titles)."""
|
||||
defaults = [i["key"] for i in self._collect_all_items(schema) if i.get("title") == i["key"]]
|
||||
assert not defaults, f"Items with default titles (title == key): {defaults}"
|
||||
|
||||
def test_options_structure(self, schema):
|
||||
"""Options must be list of {value, label} dicts."""
|
||||
for item in self._collect_all_items(schema):
|
||||
opts = item.get("options")
|
||||
if opts is None:
|
||||
continue
|
||||
assert isinstance(opts, list), f"{item['key']}: options must be a list"
|
||||
for opt in opts:
|
||||
assert isinstance(opt, dict), f"{item['key']}: each option must be a dict"
|
||||
assert "value" in opt, f"{item['key']}: option missing 'value': {opt}"
|
||||
assert "label" in opt, f"{item['key']}: option missing 'label': {opt}"
|
||||
|
||||
def test_numeric_constraints(self, schema):
|
||||
"""If min/max/step present, all three must be present and min < max."""
|
||||
for item in self._collect_all_items(schema):
|
||||
has_min = "min" in item
|
||||
has_max = "max" in item
|
||||
has_step = "step" in item
|
||||
if has_min or has_max or has_step:
|
||||
assert has_min and has_max and has_step, \
|
||||
f"{item['key']}: must have all of min/max/step or none"
|
||||
assert item["min"] < item["max"], \
|
||||
f"{item['key']}: min ({item['min']}) must be < max ({item['max']})"
|
||||
|
||||
def test_known_param_has_options(self, schema):
|
||||
"""LongitudinalPersonality should have 3 options."""
|
||||
cruise = next(p for p in schema["panels"] if p["id"] == "cruise")
|
||||
lp = next((i for i in _iter_panel_items(cruise) if i["key"] == "LongitudinalPersonality"), None)
|
||||
assert lp is not None
|
||||
assert "options" in lp
|
||||
assert len(lp["options"]) == 3
|
||||
194
sunnypilot/sunnylink/tools/generate_settings_schema.py
Executable file
194
sunnypilot/sunnylink/tools/generate_settings_schema.py
Executable file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from openpilot.sunnypilot.sunnylink.capabilities import CAPABILITY_FIELDS, CAPABILITY_LABELS
|
||||
|
||||
SCHEMA_VERSION = "1.0"
|
||||
_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFINITION_PATH = os.path.join(_DIR, "settings_ui.json")
|
||||
TORQUE_VERSIONS_PATH = os.path.normpath(
|
||||
os.path.join(_DIR, "..", "selfdrive", "controls", "lib", "latcontrol_torque_versions.json")
|
||||
)
|
||||
|
||||
|
||||
def _load_torque_versions() -> dict:
|
||||
"""Load latcontrol_torque_versions.json so TorqueControlTune options stay in sync."""
|
||||
try:
|
||||
with open(TORQUE_VERSIONS_PATH) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _build_torque_options(versions: dict) -> list[dict]:
|
||||
options: list[dict] = [{"value": "", "label": "Default"}]
|
||||
parsed: list[tuple[float, str]] = []
|
||||
for label, info in versions.items():
|
||||
try:
|
||||
parsed.append((float(info["version"]), label))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
for version, label in sorted(parsed, key=lambda kv: kv[0], reverse=True):
|
||||
options.append({"value": version, "label": label})
|
||||
return options
|
||||
|
||||
|
||||
def _inject_dynamic_options(schema: dict) -> None:
|
||||
versions = _load_torque_versions()
|
||||
if not versions:
|
||||
return
|
||||
options = _build_torque_options(versions)
|
||||
|
||||
def visitor(item: dict) -> None:
|
||||
if item.get("key") == "TorqueControlTune":
|
||||
item["options"] = options
|
||||
|
||||
_walk_all_items(schema, visitor)
|
||||
|
||||
|
||||
def _load_definition() -> dict:
|
||||
"""Load settings_ui.json and inject dynamic options sourced from runtime data files."""
|
||||
with open(DEFINITION_PATH) as f:
|
||||
schema = json.load(f)
|
||||
_inject_dynamic_options(schema)
|
||||
return schema
|
||||
|
||||
|
||||
# Public API
|
||||
def generate_schema() -> dict:
|
||||
"""Return the settings_ui.json content augmented with runtime metadata.
|
||||
|
||||
Adds three top-level fields the frontend consumes:
|
||||
- generated_at: ISO timestamp (drives schema-cache freshness checks)
|
||||
- capability_fields: declared CAPABILITY_FIELDS, used for rule validation
|
||||
- capability_labels: human-readable labels for capability_fields
|
||||
"""
|
||||
schema = _load_definition()
|
||||
schema["generated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
schema["capability_fields"] = list(CAPABILITY_FIELDS)
|
||||
schema["capability_labels"] = dict(CAPABILITY_LABELS)
|
||||
return schema
|
||||
|
||||
|
||||
def generate_schema_json() -> str:
|
||||
"""Generate SettingsSchema as a compact JSON string."""
|
||||
return json.dumps(generate_schema(), separators=(",", ":"))
|
||||
|
||||
|
||||
def generate_schema_compressed() -> str:
|
||||
"""Generate SettingsSchema as gzip-compressed, base64-encoded string.
|
||||
|
||||
Compression pipeline:
|
||||
1. JSON serialize (compact, no whitespace)
|
||||
2. UTF-8 encode
|
||||
3. gzip compress
|
||||
4. base64 encode
|
||||
"""
|
||||
raw = json.dumps(generate_schema(), separators=(",", ":")).encode("utf-8")
|
||||
return base64.b64encode(gzip.compress(raw)).decode("utf-8")
|
||||
|
||||
|
||||
# Schema introspection utilities
|
||||
def _walk_rules(rules: list[dict] | None, visitor: Callable[[dict], None]) -> None:
|
||||
"""Recursively walk all rules, calling visitor on each leaf rule."""
|
||||
if not rules:
|
||||
return
|
||||
for rule in rules:
|
||||
visitor(rule)
|
||||
if rule.get("type") == "not" and "condition" in rule:
|
||||
_walk_rules([rule["condition"]], visitor)
|
||||
elif rule.get("type") in ("any", "all") and "conditions" in rule:
|
||||
_walk_rules(rule["conditions"], visitor)
|
||||
|
||||
|
||||
def _walk_all_items(schema: dict, visitor: Callable[[dict], None]) -> None:
|
||||
"""Walk every item in the schema (panels, sections, sub_panels, sub_items, vehicle_settings)."""
|
||||
def _visit_item(item: dict) -> None:
|
||||
visitor(item)
|
||||
for sub in item.get("sub_items", []):
|
||||
_visit_item(sub)
|
||||
|
||||
for panel in schema.get("panels", []):
|
||||
# Walk section items (V2)
|
||||
for section in panel.get("sections", []):
|
||||
for item in section.get("items", []):
|
||||
_visit_item(item)
|
||||
for sp in section.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
_visit_item(item)
|
||||
|
||||
# Walk flat items (V1)
|
||||
for item in panel.get("items", []):
|
||||
_visit_item(item)
|
||||
for sp in panel.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
_visit_item(item)
|
||||
|
||||
for brand_data in schema.get("vehicle_settings", {}).values():
|
||||
items = brand_data.get("items", []) if isinstance(brand_data, dict) else brand_data
|
||||
for item in items:
|
||||
_visit_item(item)
|
||||
|
||||
|
||||
def collect_all_keys(schema: dict) -> set[str]:
|
||||
"""Collect all param keys referenced in the schema (items + rules)."""
|
||||
keys: set[str] = set()
|
||||
|
||||
def _visit_rule(rule: dict) -> None:
|
||||
if rule.get("type") in ("param", "param_compare") and "key" in rule:
|
||||
keys.add(rule["key"])
|
||||
|
||||
def _visit_item(item: dict) -> None:
|
||||
if "key" in item:
|
||||
keys.add(item["key"])
|
||||
_walk_rules(item.get("visibility"), _visit_rule)
|
||||
_walk_rules(item.get("enablement"), _visit_rule)
|
||||
|
||||
_walk_all_items(schema, _visit_item)
|
||||
return keys
|
||||
|
||||
|
||||
def collect_capability_refs(schema: dict) -> set[str]:
|
||||
"""Collect all capability field names referenced in rules."""
|
||||
refs: set[str] = set()
|
||||
|
||||
def _visit_rule(rule: dict) -> None:
|
||||
if rule.get("type") == "capability" and "field" in rule:
|
||||
refs.add(rule["field"])
|
||||
|
||||
def _visit_item(item: dict) -> None:
|
||||
_walk_rules(item.get("visibility"), _visit_rule)
|
||||
_walk_rules(item.get("enablement"), _visit_rule)
|
||||
|
||||
_walk_all_items(schema, _visit_item)
|
||||
return refs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# CLI: print schema for inspection
|
||||
schema = generate_schema()
|
||||
print(json.dumps(schema, indent=2))
|
||||
print(f"\nTotal panels: {len(schema.get('panels', []))}")
|
||||
print(f"Total vehicle brands: {len(schema.get('vehicle_settings', {}))}")
|
||||
keys = collect_all_keys(schema)
|
||||
print(f"Total unique param keys: {len(keys)}")
|
||||
|
||||
# Show compression stats
|
||||
raw_json = json.dumps(schema, separators=(",", ":")).encode("utf-8")
|
||||
compressed = gzip.compress(raw_json)
|
||||
print(f"\nRaw JSON size: {len(raw_json):,} bytes")
|
||||
print(f"Compressed size: {len(compressed):,} bytes")
|
||||
print(f"Compression ratio: {len(compressed)/len(raw_json):.1%}")
|
||||
525
sunnypilot/sunnylink/tools/validate_settings_ui.py
Executable file
525
sunnypilot/sunnylink/tools/validate_settings_ui.py
Executable file
@@ -0,0 +1,525 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
|
||||
Settings UI Validator
|
||||
=====================
|
||||
|
||||
Validates settings_ui.json against structural, semantic, and referential
|
||||
integrity constraints. Ensures the file is well-formed for consumption by
|
||||
the sunnylink frontend and the device-side schema generator.
|
||||
|
||||
Usage:
|
||||
python validate_settings_ui.py
|
||||
python validate_settings_ui.py /path/to/settings_ui.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add repo root to path for imports
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||
|
||||
from openpilot.sunnypilot.sunnylink.capabilities import CAPABILITY_FIELDS
|
||||
|
||||
VALID_WIDGETS = {"toggle", "option", "multiple_button", "button", "info"}
|
||||
VALID_COMPARE_OPS = {">", "<", ">=", "<="}
|
||||
|
||||
DEFAULT_PATH = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"settings_ui.json",
|
||||
)
|
||||
|
||||
|
||||
class ValidationResult:
|
||||
"""Tracks pass/fail for each named check."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.passed: list[str] = []
|
||||
self.failed: list[tuple[str, str]] = []
|
||||
self.warnings: list[str] = []
|
||||
|
||||
def ok(self, name: str) -> None:
|
||||
self.passed.append(name)
|
||||
print(f"OK: {name}")
|
||||
|
||||
def error(self, name: str, details: str) -> None:
|
||||
self.failed.append((name, details))
|
||||
print(f"ERROR: {name}: {details}")
|
||||
|
||||
def warn(self, msg: str) -> None:
|
||||
self.warnings.append(msg)
|
||||
print(f"WARNING: {msg}")
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return len(self.failed) == 0
|
||||
|
||||
def summary(self) -> None:
|
||||
total_passed = len(self.passed)
|
||||
total_failed = len(self.failed)
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Summary: {total_passed} checks passed, {total_failed} checks failed")
|
||||
if self.warnings:
|
||||
print(f" {len(self.warnings)} warnings")
|
||||
if self.success:
|
||||
print("Result: PASS")
|
||||
else:
|
||||
print("Result: FAIL")
|
||||
|
||||
|
||||
def validate_rule(rule: dict, path: str, result: ValidationResult,
|
||||
capability_fields: tuple[str, ...]) -> bool:
|
||||
"""Validate a single rule dict. Returns True if valid."""
|
||||
if not isinstance(rule, dict):
|
||||
result.error("rule well-formedness", f"{path}: rule is not a dict: {rule!r}")
|
||||
return False
|
||||
|
||||
rule_type = rule.get("type")
|
||||
if rule_type is None:
|
||||
result.error("rule well-formedness", f"{path}: rule missing 'type' field")
|
||||
return False
|
||||
|
||||
if rule_type == "offroad_only":
|
||||
# Only type required
|
||||
return True
|
||||
|
||||
if rule_type == "capability":
|
||||
valid = True
|
||||
if "field" not in rule or not isinstance(rule["field"], str):
|
||||
result.error("rule well-formedness", f"{path}: capability rule missing/invalid 'field'")
|
||||
valid = False
|
||||
if "equals" not in rule:
|
||||
result.error("rule well-formedness", f"{path}: capability rule missing 'equals'")
|
||||
valid = False
|
||||
return valid
|
||||
|
||||
if rule_type == "param":
|
||||
valid = True
|
||||
if "key" not in rule or not isinstance(rule["key"], str):
|
||||
result.error("rule well-formedness", f"{path}: param rule missing/invalid 'key'")
|
||||
valid = False
|
||||
if "equals" not in rule:
|
||||
result.error("rule well-formedness", f"{path}: param rule missing 'equals'")
|
||||
valid = False
|
||||
return valid
|
||||
|
||||
if rule_type == "param_compare":
|
||||
valid = True
|
||||
if "key" not in rule or not isinstance(rule["key"], str):
|
||||
result.error("rule well-formedness", f"{path}: param_compare rule missing/invalid 'key'")
|
||||
valid = False
|
||||
if "op" not in rule or rule["op"] not in VALID_COMPARE_OPS:
|
||||
result.error("rule well-formedness",
|
||||
f"{path}: param_compare rule missing/invalid 'op' (must be one of {VALID_COMPARE_OPS})")
|
||||
valid = False
|
||||
if "value" not in rule or not isinstance(rule["value"], (int, float)):
|
||||
result.error("rule well-formedness", f"{path}: param_compare rule missing/invalid 'value' (must be number)")
|
||||
valid = False
|
||||
return valid
|
||||
|
||||
if rule_type == "not":
|
||||
if "condition" not in rule or not isinstance(rule["condition"], dict):
|
||||
result.error("rule well-formedness", f"{path}: 'not' rule missing/invalid 'condition'")
|
||||
return False
|
||||
return validate_rule(rule["condition"], f"{path}.not", result, capability_fields)
|
||||
|
||||
if rule_type in ("any", "all"):
|
||||
if "conditions" not in rule or not isinstance(rule["conditions"], list):
|
||||
result.error("rule well-formedness", f"{path}: '{rule_type}' rule missing/invalid 'conditions' array")
|
||||
return False
|
||||
valid = True
|
||||
for i, cond in enumerate(rule["conditions"]):
|
||||
if not validate_rule(cond, f"{path}.{rule_type}[{i}]", result, capability_fields):
|
||||
valid = False
|
||||
return valid
|
||||
|
||||
result.error("rule well-formedness", f"{path}: unknown rule type '{rule_type}'")
|
||||
return False
|
||||
|
||||
|
||||
def collect_rules_from_item(item: dict) -> list[tuple[str, list[dict]]]:
|
||||
"""Return list of (context, rules_list) for an item's visibility + enablement."""
|
||||
result = []
|
||||
key = item.get("key", "?")
|
||||
if "visibility" in item:
|
||||
result.append((f"item '{key}' visibility", item["visibility"]))
|
||||
if "enablement" in item:
|
||||
result.append((f"item '{key}' enablement", item["enablement"]))
|
||||
return result
|
||||
|
||||
|
||||
def walk_rules_flat(rules: list[dict]) -> list[dict]:
|
||||
"""Flatten all rules recursively into a single list."""
|
||||
flat: list[dict] = []
|
||||
for rule in rules:
|
||||
flat.append(rule)
|
||||
if rule.get("type") == "not" and "condition" in rule:
|
||||
flat.extend(walk_rules_flat([rule["condition"]]))
|
||||
elif rule.get("type") in ("any", "all") and "conditions" in rule:
|
||||
flat.extend(walk_rules_flat(rule["conditions"]))
|
||||
return flat
|
||||
|
||||
|
||||
def collect_all_items(data: dict) -> list[tuple[str, dict]]:
|
||||
"""Collect all items with their location path from the schema.
|
||||
|
||||
Returns (path, item_dict) tuples. Traverses sections, sub_panels, sub_items,
|
||||
and vehicle_settings.
|
||||
"""
|
||||
items: list[tuple[str, dict]] = []
|
||||
|
||||
for panel in data.get("panels", []):
|
||||
pid = panel.get("id", "?")
|
||||
|
||||
# Flat items on panel
|
||||
for item in panel.get("items", []):
|
||||
_collect_item(f"panel '{pid}'", item, items)
|
||||
|
||||
# Sections
|
||||
for section in panel.get("sections", []):
|
||||
sid = section.get("id", "?")
|
||||
for item in section.get("items", []):
|
||||
_collect_item(f"panel '{pid}' > section '{sid}'", item, items)
|
||||
for sp in section.get("sub_panels", []):
|
||||
spid = sp.get("id", "?")
|
||||
for item in sp.get("items", []):
|
||||
_collect_item(f"panel '{pid}' > section '{sid}' > sub_panel '{spid}'", item, items)
|
||||
|
||||
# Top-level sub_panels on panel (no section)
|
||||
for sp in panel.get("sub_panels", []):
|
||||
spid = sp.get("id", "?")
|
||||
for item in sp.get("items", []):
|
||||
_collect_item(f"panel '{pid}' > sub_panel '{spid}'", item, items)
|
||||
|
||||
# Vehicle settings (supports both flat list and { title, items } structure)
|
||||
for brand, brand_data in data.get("vehicle_settings", {}).items():
|
||||
brand_items = brand_data.get("items", []) if isinstance(brand_data, dict) else brand_data
|
||||
for item in brand_items:
|
||||
_collect_item(f"vehicle_settings '{brand}'", item, items)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _collect_item(path: str, item: dict, items: list[tuple[str, dict]]) -> None:
|
||||
"""Recursively collect an item and its sub_items."""
|
||||
items.append((path, item))
|
||||
for sub in item.get("sub_items", []):
|
||||
_collect_item(f"{path} > sub_item", sub, items)
|
||||
|
||||
|
||||
def collect_panel_keys(panel: dict) -> set[str]:
|
||||
"""Collect all item keys within a single panel (sections, sub_panels, sub_items)."""
|
||||
keys: set[str] = set()
|
||||
|
||||
def _add(item: dict) -> None:
|
||||
if "key" in item:
|
||||
keys.add(item["key"])
|
||||
for sub in item.get("sub_items", []):
|
||||
_add(sub)
|
||||
|
||||
for item in panel.get("items", []):
|
||||
_add(item)
|
||||
for section in panel.get("sections", []):
|
||||
for item in section.get("items", []):
|
||||
_add(item)
|
||||
for sp in section.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
_add(item)
|
||||
for sp in panel.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
_add(item)
|
||||
|
||||
return keys
|
||||
|
||||
|
||||
def check_json_parseable(path: str, result: ValidationResult) -> dict | None:
|
||||
"""Check 1: JSON parseable."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
result.ok("JSON parseable")
|
||||
return data
|
||||
except json.JSONDecodeError as e:
|
||||
result.error("JSON parseable", str(e))
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
result.error("JSON parseable", f"file not found: {path}")
|
||||
return None
|
||||
|
||||
|
||||
def check_structural(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 2: Required fields on panels, sections, items, sub_panels."""
|
||||
errors: list[str] = []
|
||||
|
||||
for i, panel in enumerate(data.get("panels", [])):
|
||||
for field in ("id", "label", "icon", "order"):
|
||||
if field not in panel:
|
||||
errors.append(f"panels[{i}]: missing required field '{field}'")
|
||||
|
||||
for j, section in enumerate(panel.get("sections", [])):
|
||||
for field in ("id", "title"):
|
||||
if field not in section:
|
||||
errors.append(f"panels[{i}].sections[{j}]: missing required field '{field}'")
|
||||
|
||||
for k, sp in enumerate(section.get("sub_panels", [])):
|
||||
for field in ("id", "label", "trigger_key"):
|
||||
if field not in sp:
|
||||
errors.append(f"panels[{i}].sections[{j}].sub_panels[{k}]: missing required field '{field}'")
|
||||
|
||||
for k, sp in enumerate(panel.get("sub_panels", [])):
|
||||
for field in ("id", "label", "trigger_key"):
|
||||
if field not in sp:
|
||||
errors.append(f"panels[{i}].sub_panels[{k}]: missing required field '{field}'")
|
||||
|
||||
# Validate items
|
||||
all_items = collect_all_items(data)
|
||||
for path, item in all_items:
|
||||
if "key" not in item:
|
||||
errors.append(f"{path}: item missing required field 'key'")
|
||||
if "widget" not in item:
|
||||
errors.append(f"{path}: item missing required field 'widget'")
|
||||
elif item["widget"] not in VALID_WIDGETS:
|
||||
errors.append(
|
||||
f"{path}: item '{item.get('key', '?')}' has invalid widget '{item['widget']}'"
|
||||
+ f" (must be one of {VALID_WIDGETS})"
|
||||
)
|
||||
|
||||
if errors:
|
||||
result.error("structural", "; ".join(errors))
|
||||
else:
|
||||
result.ok("structural")
|
||||
|
||||
|
||||
def check_item_completeness(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 3: All items have required metadata (title, options for dropdowns)."""
|
||||
all_items = collect_all_items(data)
|
||||
issues: list[str] = []
|
||||
|
||||
for _path, item in all_items:
|
||||
key = item.get("key", "unknown")
|
||||
if "title" not in item:
|
||||
issues.append(f"{key}: missing 'title'")
|
||||
elif item["title"] == key:
|
||||
issues.append(f"{key}: title must not equal key (use a human-readable title)")
|
||||
widget = item.get("widget")
|
||||
if widget in ("multiple_button", "option") and "options" in item:
|
||||
opts = item["options"]
|
||||
if not isinstance(opts, list):
|
||||
issues.append(f"{key}: options must be a list")
|
||||
else:
|
||||
for opt in opts:
|
||||
if not isinstance(opt, dict) or "value" not in opt or "label" not in opt:
|
||||
issues.append(f"{key}: each option must have 'value' and 'label'")
|
||||
break
|
||||
|
||||
if issues:
|
||||
for issue in issues:
|
||||
result.error("item completeness", issue)
|
||||
else:
|
||||
result.ok("item completeness")
|
||||
|
||||
|
||||
def check_no_duplicate_keys(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 4: No param key appears in more than one panel."""
|
||||
panel_keys: dict[str, list[str]] = {} # key -> list of panel ids
|
||||
|
||||
for panel in data.get("panels", []):
|
||||
pid = panel.get("id", "?")
|
||||
keys = collect_panel_keys(panel)
|
||||
for key in keys:
|
||||
panel_keys.setdefault(key, []).append(pid)
|
||||
|
||||
# Also check vehicle_settings keys don't collide with panel keys
|
||||
for brand, brand_data in data.get("vehicle_settings", {}).items():
|
||||
brand_items = brand_data.get("items", []) if isinstance(brand_data, dict) else brand_data
|
||||
for item in brand_items:
|
||||
key = item.get("key")
|
||||
if key:
|
||||
panel_keys.setdefault(key, []).append(f"vehicle_settings.{brand}")
|
||||
|
||||
duplicates = {k: v for k, v in panel_keys.items() if len(v) > 1}
|
||||
if duplicates:
|
||||
details = "; ".join(f"'{k}' in [{', '.join(v)}]" for k, v in duplicates.items())
|
||||
result.error("no duplicate keys", details)
|
||||
else:
|
||||
result.ok("no duplicate keys")
|
||||
|
||||
|
||||
def check_rule_wellformedness(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 5: All rules have valid structure."""
|
||||
all_items = collect_all_items(data)
|
||||
|
||||
# Save current error count to detect new errors
|
||||
error_count_before = len(result.failed)
|
||||
|
||||
for path, item in all_items:
|
||||
for ctx, rules in collect_rules_from_item(item):
|
||||
for i, rule in enumerate(rules):
|
||||
validate_rule(rule, f"{path} > {ctx}[{i}]", result, CAPABILITY_FIELDS)
|
||||
|
||||
# Also validate trigger_condition rules on sub_panels
|
||||
for panel in data.get("panels", []):
|
||||
pid = panel.get("id", "?")
|
||||
for section in panel.get("sections", []):
|
||||
for sp in section.get("sub_panels", []):
|
||||
if "trigger_condition" in sp:
|
||||
validate_rule(sp["trigger_condition"], f"panel '{pid}' > sub_panel '{sp.get('id', '?')}' trigger_condition",
|
||||
result, CAPABILITY_FIELDS)
|
||||
|
||||
if len(result.failed) == error_count_before:
|
||||
result.ok("rule well-formedness")
|
||||
|
||||
|
||||
def check_capability_refs(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 6: All capability rule field values are in CAPABILITY_FIELDS."""
|
||||
all_items = collect_all_items(data)
|
||||
invalid_refs: list[str] = []
|
||||
cap_set = set(CAPABILITY_FIELDS)
|
||||
|
||||
for _path, item in all_items:
|
||||
for _ctx, rules in collect_rules_from_item(item):
|
||||
for rule in walk_rules_flat(rules):
|
||||
if rule.get("type") == "capability":
|
||||
field = rule.get("field")
|
||||
if field and field not in cap_set:
|
||||
invalid_refs.append(f"'{field}' in item '{item.get('key', '?')}'")
|
||||
|
||||
# Also check trigger_conditions
|
||||
for panel in data.get("panels", []):
|
||||
for section in panel.get("sections", []):
|
||||
for sp in section.get("sub_panels", []):
|
||||
if "trigger_condition" in sp:
|
||||
for rule in walk_rules_flat([sp["trigger_condition"]]):
|
||||
if rule.get("type") == "capability":
|
||||
field = rule.get("field")
|
||||
if field and field not in cap_set:
|
||||
invalid_refs.append(f"'{field}' in sub_panel '{sp.get('id', '?')}' trigger_condition")
|
||||
|
||||
if invalid_refs:
|
||||
result.error("capability refs", f"unknown capability fields: {', '.join(invalid_refs)}")
|
||||
else:
|
||||
result.ok("capability refs")
|
||||
|
||||
|
||||
def check_no_self_reference(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 7: Item's rules must not reference the item's own key."""
|
||||
all_items = collect_all_items(data)
|
||||
self_refs: list[str] = []
|
||||
|
||||
for path, item in all_items:
|
||||
key = item.get("key")
|
||||
if not key:
|
||||
continue
|
||||
for _ctx, rules in collect_rules_from_item(item):
|
||||
for rule in walk_rules_flat(rules):
|
||||
if rule.get("type") in ("param", "param_compare") and rule.get("key") == key:
|
||||
self_refs.append(f"'{key}' at {path}")
|
||||
|
||||
if self_refs:
|
||||
result.error("no self-reference", f"items reference their own key: {', '.join(self_refs)}")
|
||||
else:
|
||||
result.ok("no self-reference")
|
||||
|
||||
|
||||
def check_sub_panel_triggers(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 8: Sub-panel trigger_key must reference a key in the same panel."""
|
||||
errors: list[str] = []
|
||||
|
||||
for panel in data.get("panels", []):
|
||||
pid = panel.get("id", "?")
|
||||
panel_keys = collect_panel_keys(panel)
|
||||
|
||||
# Check sub_panels at section level
|
||||
for section in panel.get("sections", []):
|
||||
for sp in section.get("sub_panels", []):
|
||||
trigger = sp.get("trigger_key")
|
||||
if trigger and trigger not in panel_keys:
|
||||
errors.append(
|
||||
f"sub_panel '{sp.get('id', '?')}' trigger_key '{trigger}'"
|
||||
+ f" not found in panel '{pid}'"
|
||||
)
|
||||
|
||||
# Check top-level sub_panels
|
||||
for sp in panel.get("sub_panels", []):
|
||||
trigger = sp.get("trigger_key")
|
||||
if trigger and trigger not in panel_keys:
|
||||
errors.append(
|
||||
f"sub_panel '{sp.get('id', '?')}' trigger_key '{trigger}'"
|
||||
+ f" not found in panel '{pid}'"
|
||||
)
|
||||
|
||||
if errors:
|
||||
result.error("sub-panel triggers", "; ".join(errors))
|
||||
else:
|
||||
result.ok("sub-panel triggers")
|
||||
|
||||
|
||||
def check_ordering(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 9: Panel order values must be unique."""
|
||||
orders: dict[int, list[str]] = {}
|
||||
for panel in data.get("panels", []):
|
||||
order = panel.get("order")
|
||||
if order is not None:
|
||||
orders.setdefault(order, []).append(panel.get("id", "?"))
|
||||
|
||||
duplicates = {o: ids for o, ids in orders.items() if len(ids) > 1}
|
||||
if duplicates:
|
||||
details = "; ".join(f"order {o}: [{', '.join(ids)}]" for o, ids in duplicates.items())
|
||||
result.error("ordering", f"duplicate order values: {details}")
|
||||
else:
|
||||
result.ok("ordering")
|
||||
|
||||
|
||||
def check_vehicle_brands(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 10: Vehicle settings keys should be lowercase strings."""
|
||||
vehicle = data.get("vehicle_settings", {})
|
||||
bad_brands: list[str] = []
|
||||
|
||||
for brand in vehicle:
|
||||
if not isinstance(brand, str) or brand != brand.lower():
|
||||
bad_brands.append(brand)
|
||||
|
||||
if bad_brands:
|
||||
result.error("vehicle brands", f"non-lowercase brand keys: {', '.join(bad_brands)}")
|
||||
else:
|
||||
result.ok("vehicle brands")
|
||||
|
||||
|
||||
def validate(path: str) -> bool:
|
||||
"""Run all validation checks on the given settings_ui.json file.
|
||||
|
||||
Returns True if all checks pass.
|
||||
"""
|
||||
result = ValidationResult()
|
||||
|
||||
# Check 1: JSON parseable
|
||||
data = check_json_parseable(path, result)
|
||||
if data is None:
|
||||
result.summary()
|
||||
return False
|
||||
|
||||
# Checks 2-10
|
||||
check_structural(data, result)
|
||||
check_item_completeness(data, result)
|
||||
check_no_duplicate_keys(data, result)
|
||||
check_rule_wellformedness(data, result)
|
||||
check_capability_refs(data, result)
|
||||
check_no_self_reference(data, result)
|
||||
check_sub_panel_triggers(data, result)
|
||||
check_ordering(data, result)
|
||||
check_vehicle_brands(data, result)
|
||||
|
||||
result.summary()
|
||||
return result.success
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
target = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_PATH
|
||||
success = validate(target)
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -28,6 +28,8 @@ SP_BRANCH_MIGRATIONS = {
|
||||
("tizi", "release3-staging"): "release-tizi-staging",
|
||||
("mici", "release3"): "release-mici",
|
||||
("mici", "release3-staging"): "release-mici-staging",
|
||||
("tici", "hkg-angle-steering-2025"): "hkg-angle-steering-2025-tici",
|
||||
("tici", "hkg-angle-steering-2025-prebuilt"): "hkg-angle-steering-2025-tici-prebuilt"
|
||||
}
|
||||
|
||||
BUILD_METADATA_FILENAME = "build.json"
|
||||
|
||||
2196
tools/car_porting/examples/hkg_canfd_angle_steering.ipynb
Normal file
2196
tools/car_porting/examples/hkg_canfd_angle_steering.ipynb
Normal file
File diff suppressed because it is too large
Load Diff
228
tools/car_porting/examples/hyundai_angle_steering.ipynb
Normal file
228
tools/car_porting/examples/hyundai_angle_steering.ipynb
Normal file
File diff suppressed because one or more lines are too long
183
tools/plotjuggler/layouts/analyzing-panda-block-angle-hkg.xml
Normal file
183
tools/plotjuggler/layouts/analyzing-panda-block-angle-hkg.xml
Normal file
@@ -0,0 +1,183 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<root>
|
||||
<tabbed_widget parent="main_window" name="Main Window">
|
||||
<Tab containers="1" tab_name="actuator data">
|
||||
<Container>
|
||||
<DockSplitter orientation="-" sizes="0.25;0.25;0.25;0.25" count="4">
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range bottom="-0.100000" left="301.990881" top="0.100000" right="462.962504"/>
|
||||
<limitY/>
|
||||
<curve name="/carControl/actuators/torque" color="#17becf"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range bottom="-130.920132" left="301.990881" top="103.993176" right="462.962504"/>
|
||||
<limitY/>
|
||||
<curve name="/carControl/actuators/steeringAngleDeg" color="#1f77b4"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range bottom="-0.604818" left="301.990881" top="24.797527" right="462.962504"/>
|
||||
<limitY/>
|
||||
<curve name="/carState/vEgoRaw" color="#ff7f0e"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range bottom="-0.037812" left="301.990881" top="0.047261" right="462.962504"/>
|
||||
<limitY/>
|
||||
<curve name="/carControl/actuators/curvature" color="#f14cc1"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
</DockSplitter>
|
||||
</Container>
|
||||
</Tab>
|
||||
<Tab containers="1" tab_name="steering messages (CAN)">
|
||||
<Container>
|
||||
<DockSplitter orientation="-" sizes="0.142857;0.142857;0.142857;0.142857;0.142857;0.142857;0.142857" count="7">
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range bottom="-0.025000" left="301.990881" top="1.025000" right="462.962504"/>
|
||||
<limitY/>
|
||||
<curve name="/can/128/LKAS_ALT/ADAS_ACIAnglTqRedcGainVal" color="#1f77b4"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range bottom="-128.740000" left="301.990881" top="103.940000" right="462.962504"/>
|
||||
<limitY/>
|
||||
<curve name="/can/128/LKAS_ALT/ADAS_StrAnglReqVal" color="#29d627"/>
|
||||
<curve name="/can/192/LKAS_ALT/ADAS_StrAnglReqVal" color="#b41f32"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range bottom="-0.025000" left="301.990881" top="1.025000" right="462.962504"/>
|
||||
<limitY/>
|
||||
<curve name="/carState/steeringPressed" color="#d62728"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range bottom="0.975000" left="301.990881" top="2.025000" right="462.962504"/>
|
||||
<limitY/>
|
||||
<curve name="/sendcan/0/LKAS_ALT/LKAS_ANGLE_ACTIVE" color="#1f77b4"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range bottom="-0.025000" left="301.990881" top="1.025000" right="462.962504"/>
|
||||
<limitY/>
|
||||
<curve name="/carState/steerFaultTemporary" color="#d62728"/>
|
||||
<curve name="/carControl/latActive" color="#1ac938"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range bottom="-0.025000" left="301.990881" top="1.025000" right="462.962504"/>
|
||||
<limitY/>
|
||||
<curve name="/can/1/CCNC_0x161/DAW_ICON" color="#f14cc1"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range bottom="-14.375000" left="301.990881" top="589.375000" right="462.962504"/>
|
||||
<limitY/>
|
||||
<curve name="/pandaStates/0/safetyTxBlocked" color="#1f77b4"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
</DockSplitter>
|
||||
</Container>
|
||||
</Tab>
|
||||
<Tab containers="1" tab_name="Understanding Torque">
|
||||
<Container>
|
||||
<DockSplitter orientation="-" sizes="0.5;0.5" count="2">
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range bottom="-6.175000" left="429.901019" top="253.175000" right="590.696728"/>
|
||||
<limitY/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range bottom="-24.190000" left="301.990881" top="21.590000" right="462.962504"/>
|
||||
<limitY/>
|
||||
<curve name="/carState/steeringTorqueEps" color="#1ac938"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
</DockSplitter>
|
||||
</Container>
|
||||
</Tab>
|
||||
<Tab containers="1" tab_name="tab2">
|
||||
<Container>
|
||||
<DockSplitter orientation="-" sizes="0.25;0.25;0.25;0.25" count="4">
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Dots">
|
||||
<range bottom="-0.025000" left="301.990881" top="1.025000" right="462.962504"/>
|
||||
<limitY/>
|
||||
<curve name="/sendcan/0/LKAS_ALT/ADAS_ACIAnglTqRedcGainVal" color="#9467bd"/>
|
||||
<curve name="/can/1/LFA_ALT/ADAS_ACIAnglTqRedcGainVal" color="#17becf"/>
|
||||
<curve name="/can/128/LKAS_ALT/ADAS_ACIAnglTqRedcGainVal" color="#1f77b4"/>
|
||||
<curve name="/can/192/LKAS_ALT/ADAS_ACIAnglTqRedcGainVal" color="#d62728"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Dots">
|
||||
<range bottom="0.975000" left="301.990881" top="2.025000" right="462.962504"/>
|
||||
<limitY/>
|
||||
<curve name="/can/128/LKAS_ALT/LKAS_ANGLE_ACTIVE" color="#b8c91a"/>
|
||||
<curve name="/can/192/LKAS_ALT/LKAS_ANGLE_ACTIVE" color="#ff7f0e"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range bottom="-0.025000" left="301.990881" top="1.025000" right="462.962504"/>
|
||||
<limitY/>
|
||||
<curve name="/can/1/CCNC_0x161/DAW_ICON" color="#f14cc1"/>
|
||||
<curve name="/carState/steerFaultTemporary" color="#1f77b4"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Dots">
|
||||
<range bottom="-14.375000" left="301.990881" top="589.375000" right="462.962504"/>
|
||||
<limitY/>
|
||||
<curve name="/pandaStates/0/safetyTxBlocked" color="#bcbd22"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
</DockSplitter>
|
||||
</Container>
|
||||
</Tab>
|
||||
<currentTabIndex index="3"/>
|
||||
</tabbed_widget>
|
||||
<use_relative_time_offset enabled="1"/>
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
<Plugins>
|
||||
<plugin ID="DataLoad CSV">
|
||||
<default time_axis="" delimiter="0"/>
|
||||
</plugin>
|
||||
<plugin ID="DataLoad MCAP"/>
|
||||
<plugin ID="DataLoad Rlog"/>
|
||||
<plugin ID="DataLoad ULog"/>
|
||||
<plugin ID="Cereal Subscriber"/>
|
||||
<plugin ID="UDP Server"/>
|
||||
<plugin ID="WebSocket Server"/>
|
||||
<plugin ID="ZMQ Subscriber"/>
|
||||
<plugin ID="Fast Fourier Transform"/>
|
||||
<plugin ID="Quaternion to RPY"/>
|
||||
<plugin ID="Reactive Script Editor">
|
||||
<library code="--[[ Helper function to create a series from arrays

 new_series: a series previously created with ScatterXY.new(name)
 prefix: prefix of the timeseries, before the index of the array
 suffix_X: suffix to complete the name of the series containing the X value. If [nil], use the index of the array.
 suffix_Y: suffix to complete the name of the series containing the Y value
 timestamp: usually the tracker_time variable
 
 Example:
 
 Assuming we have multiple series in the form:
 
 /trajectory/node.{X}/position/x
 /trajectory/node.{X}/position/y
 
 where {N} is the index of the array (integer). We can create a reactive series from the array with:
 
 new_series = ScatterXY.new("my_trajectory") 
 CreateSeriesFromArray( new_series, "/trajectory/node", "position/x", "position/y", tracker_time );
--]]

function CreateSeriesFromArray( new_series, prefix, suffix_X, suffix_Y, timestamp )
 
 --- clear previous values
 new_series:clear()
 
 --- Append points to new_series
 index = 0
 while(true) do

 x = index;
 -- if not nil, get the X coordinate from a series
 if suffix_X ~= nil then 
 series_x = TimeseriesView.find( string.format( "%s.%d/%s", prefix, index, suffix_X) )
 if series_x == nil then break end
 x = series_x:atTime(timestamp)	 
 end
 
 series_y = TimeseriesView.find( string.format( "%s.%d/%s", prefix, index, suffix_Y) )
 if series_y == nil then break end 
 y = series_y:atTime(timestamp)
 
 new_series:push_back(x,y)
 index = index+1
 end
end

--[[ Similar to the built-in function GetSeriesNames(), but select only the names with a give prefix. --]]

function GetSeriesNamesByPrefix(prefix)
 -- GetSeriesNames(9 is a built-in function
 all_names = GetSeriesNames()
 filtered_names = {}
 for i, name in ipairs(all_names) do
 -- check the prefix
 if name:find(prefix, 1, #prefix) then
 table.insert(filtered_names, name);
 end
 end
 return filtered_names
end

--[[ Modify an existing series, applying offsets to all their X and Y values

 series: an existing timeseries, obtained with TimeseriesView.find(name)
 delta_x: offset to apply to each x value
 delta_y: offset to apply to each y value 
 
--]]

function ApplyOffsetInPlace(series, delta_x, delta_y)
 -- use C++ indeces, not Lua indeces
 for index=0, series:size()-1 do
 x,y = series:at(index)
 series:set(index, x + delta_x, y + delta_y)
 end
end
"/>
|
||||
<scripts/>
|
||||
</plugin>
|
||||
<plugin ID="CSV Exporter"/>
|
||||
</Plugins>
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
<customMathEquations/>
|
||||
<snippets/>
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
</root>
|
||||
|
||||
702
tools/plotjuggler/layouts/analyzing-torque-angle-hkg.xml
Normal file
702
tools/plotjuggler/layouts/analyzing-torque-angle-hkg.xml
Normal file
@@ -0,0 +1,702 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<root>
|
||||
<tabbed_widget name="Main Window" parent="main_window">
|
||||
<Tab containers="1" tab_name="actuator data">
|
||||
<Container>
|
||||
<DockSplitter sizes="0.25;0.25;0.25;0.25" count="4" orientation="-">
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="0.100000" bottom="-0.100000" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="/carControl/actuators/torque" color="#17becf"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="21.512506" bottom="-143.133163" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="/carControl/actuators/steeringAngleDeg" color="#1f77b4"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="17.919758" bottom="7.125814" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="/carState/vEgoRaw" color="#ff7f0e"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="0.055486" bottom="-0.008455" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="/carControl/actuators/curvature" color="#f14cc1"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
</DockSplitter>
|
||||
</Container>
|
||||
</Tab>
|
||||
<Tab containers="1" tab_name="steering messages (CAN)">
|
||||
<Container>
|
||||
<DockSplitter sizes="0.25;0.25;0.25;0.25" count="4" orientation="-">
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="1.020200" bottom="0.171800" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="/sendcan/0/LKAS_ALT/ADAS_ACIAnglTqRedcGainVal" color="#1f77b4"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="3.417986" bottom="-1.676466" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="IMU_LatAccelVal_ms^2_roll_compensated" color="#ff7f0e"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="1.025000" bottom="-0.025000" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="/carState/steeringPressed" color="#d62728"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="0.100000" bottom="-0.100000" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="/can/1/CCNC_0x161/DAW_ICON" color="#f14cc1"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
</DockSplitter>
|
||||
</Container>
|
||||
</Tab>
|
||||
<Tab containers="1" tab_name="Understanding Torque">
|
||||
<Container>
|
||||
<DockSplitter sizes="0.5;0.5" count="2" orientation="-">
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="378.825000" bottom="-1125.825000" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="/carState/steeringTorque" color="#1f77b4"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="17.092500" bottom="-32.992499" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="/carState/steeringTorqueEps" color="#1ac938"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
</DockSplitter>
|
||||
</Container>
|
||||
</Tab>
|
||||
<Tab containers="1" tab_name="Angle Error and Saturation TQ">
|
||||
<Container>
|
||||
<DockSplitter sizes="0.166667;0.166667;0.166667;0.166667;0.166667;0.166667" count="6" orientation="-">
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="21.512506" bottom="-143.133163" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="/carControl/actuators/steeringAngleDeg" color="#d62728"/>
|
||||
<curve name="/carState/steeringAngleDeg" color="#1ac938"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="48.052530" bottom="-34.508142" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="Angle Error" color="#ff7f0e"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="1.020200" bottom="0.171800" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="/sendcan/0/LKAS_ALT/ADAS_ACIAnglTqRedcGainVal" color="#1f77b4"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="1.025000" bottom="-0.025000" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="Angle Staturation" color="#f14cc1"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="1.025000" bottom="-0.025000" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="/carControl/latActive" color="#9467bd"/>
|
||||
<curve name="/carState/steeringPressed" color="#17becf"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="64.446359" bottom="25.681587" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="carState.vEgo kmh" color="#1f77b4"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
</DockSplitter>
|
||||
</Container>
|
||||
</Tab>
|
||||
<Tab containers="1" tab_name="Smoothing and Torque celings">
|
||||
<Container>
|
||||
<DockSplitter sizes="0.5;0.5" count="2" orientation="-">
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="4.075000" bottom="0.925000" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="subject_to_angle_smoothing" color="#d62728"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||
<range top="3.025000" bottom="1.975000" left="256.448992" right="302.818689"/>
|
||||
<limitY/>
|
||||
<curve name="base_ceiling_bracket" color="#1f77b4"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
</DockSplitter>
|
||||
</Container>
|
||||
</Tab>
|
||||
<currentTabIndex index="4"/>
|
||||
</tabbed_widget>
|
||||
<use_relative_time_offset enabled="1"/>
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
<Plugins>
|
||||
<plugin ID="DataLoad CSV">
|
||||
<default time_axis="" delimiter="0"/>
|
||||
</plugin>
|
||||
<plugin ID="DataLoad MCAP"/>
|
||||
<plugin ID="DataLoad Rlog"/>
|
||||
<plugin ID="DataLoad ULog"/>
|
||||
<plugin ID="Cereal Subscriber"/>
|
||||
<plugin ID="UDP Server"/>
|
||||
<plugin ID="WebSocket Server"/>
|
||||
<plugin ID="ZMQ Subscriber"/>
|
||||
<plugin ID="Fast Fourier Transform"/>
|
||||
<plugin ID="Quaternion to RPY"/>
|
||||
<plugin ID="Reactive Script Editor">
|
||||
<library code="--[[ Helper function to create a series from arrays

 new_series: a series previously created with ScatterXY.new(name)
 prefix: prefix of the timeseries, before the index of the array
 suffix_X: suffix to complete the name of the series containing the X value. If [nil], use the index of the array.
 suffix_Y: suffix to complete the name of the series containing the Y value
 timestamp: usually the tracker_time variable
 
 Example:
 
 Assuming we have multiple series in the form:
 
 /trajectory/node.{X}/position/x
 /trajectory/node.{X}/position/y
 
 where {N} is the index of the array (integer). We can create a reactive series from the array with:
 
 new_series = ScatterXY.new("my_trajectory") 
 CreateSeriesFromArray( new_series, "/trajectory/node", "position/x", "position/y", tracker_time );
--]]

function CreateSeriesFromArray( new_series, prefix, suffix_X, suffix_Y, timestamp )
 
 --- clear previous values
 new_series:clear()
 
 --- Append points to new_series
 index = 0
 while(true) do

 x = index;
 -- if not nil, get the X coordinate from a series
 if suffix_X ~= nil then 
 series_x = TimeseriesView.find( string.format( "%s.%d/%s", prefix, index, suffix_X) )
 if series_x == nil then break end
 x = series_x:atTime(timestamp)	 
 end
 
 series_y = TimeseriesView.find( string.format( "%s.%d/%s", prefix, index, suffix_Y) )
 if series_y == nil then break end 
 y = series_y:atTime(timestamp)
 
 new_series:push_back(x,y)
 index = index+1
 end
end

--[[ Similar to the built-in function GetSeriesNames(), but select only the names with a give prefix. --]]

function GetSeriesNamesByPrefix(prefix)
 -- GetSeriesNames(9 is a built-in function
 all_names = GetSeriesNames()
 filtered_names = {}
 for i, name in ipairs(all_names) do
 -- check the prefix
 if name:find(prefix, 1, #prefix) then
 table.insert(filtered_names, name);
 end
 end
 return filtered_names
end

--[[ Modify an existing series, applying offsets to all their X and Y values

 series: an existing timeseries, obtained with TimeseriesView.find(name)
 delta_x: offset to apply to each x value
 delta_y: offset to apply to each y value 
 
--]]

function ApplyOffsetInPlace(series, delta_x, delta_y)
 -- use C++ indeces, not Lua indeces
 for index=0, series:size()-1 do
 x,y = series:at(index)
 series:set(index, x + delta_x, y + delta_y)
 end
end
"/>
|
||||
<scripts/>
|
||||
</plugin>
|
||||
<plugin ID="CSV Exporter"/>
|
||||
</Plugins>
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
<customMathEquations>
|
||||
<snippet name="subject_to_angle_smoothing">
|
||||
<global></global>
|
||||
<function>if v1 == 0 then
|
||||
return 0
|
||||
end
|
||||
|
||||
if value < 8.5 then
|
||||
return 1
|
||||
elseif value < 11 then
|
||||
return 2
|
||||
elseif value <= 13.8 then
|
||||
return 3
|
||||
elseif value <= 18 then
|
||||
return 4
|
||||
end
|
||||
|
||||
return 0</function>
|
||||
<linked_source>/carState/vEgo</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carControl/latActive</v1>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="base_ceiling_bracket">
|
||||
<global></global>
|
||||
<function>if v1 == 0 then
|
||||
return 0
|
||||
end
|
||||
|
||||
if value < 20 then
|
||||
return 1
|
||||
elseif value < 40 then
|
||||
return 2
|
||||
elseif value <= 120 then
|
||||
return 3
|
||||
end
|
||||
|
||||
|
||||
return 0</function>
|
||||
<linked_source>carState.vEgo kmh</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carControl/latActive</v1>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="max torque lj adj">
|
||||
<global>min=0
|
||||
max=250
|
||||
max_from_speed=96
|
||||
|
||||
k1=200
|
||||
k2=30
|
||||
k3=1
|
||||
k4=1
|
||||
k5=10
|
||||
|
||||
function sign(number)
|
||||
return number > 0 and 1 or (number == 0 and 0 or -1)
|
||||
end</global>
|
||||
<function>return 250 - value * 20</function>
|
||||
<linked_source>desired lateral jark</linked_source>
|
||||
</snippet>
|
||||
<snippet name="Angle Staturation">
|
||||
<global></global>
|
||||
<function>if value > .3 then
|
||||
return 1
|
||||
end
|
||||
|
||||
return 0</function>
|
||||
<linked_source>Angle Error</linked_source>
|
||||
</snippet>
|
||||
<snippet name="ang_cmd rate">
|
||||
<global>firstX = 0
|
||||
firstY = 0
|
||||
is_first = true
|
||||
secondX = 0
|
||||
secondY = 0
|
||||
is_second = false</global>
|
||||
<function>-- Wait for initial values
|
||||
if (is_first) then
|
||||
is_first = false
|
||||
is_second = true
|
||||
firstX = time
|
||||
firstY = value
|
||||
end
|
||||
|
||||
if (is_second) then
|
||||
is_second = false
|
||||
secondX = time
|
||||
secondY = value
|
||||
end
|
||||
|
||||
-- Central derivative: dy/dx ~= f(x+delta_x)-f(x-delta_x)/(2*delta_x)
|
||||
dx = time - firstX
|
||||
dy = value - firstY
|
||||
-- Increment
|
||||
firstX = secondX
|
||||
firstY = secondY
|
||||
secondX = time
|
||||
secondY = value
|
||||
|
||||
return dy/dx</function>
|
||||
<linked_source>/can/1/LFA_ALT/LKAS_ANGLE_CMD</linked_source>
|
||||
</snippet>
|
||||
<snippet name="IMU_LatAccelVal_Ms^3">
|
||||
<global></global>
|
||||
<function>return value * -9.8</function>
|
||||
<linked_source>/can/1/IMU_01_10ms/IMU_LatAccelVal</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/steeringPressed</v1>
|
||||
<v2>/carControl/latActive</v2>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="IMU_LatAccelVal_Ms^2">
|
||||
<global></global>
|
||||
<function>if (v1 == 0 and v2 == 1) then
|
||||
return value * -9.8
|
||||
end
|
||||
return 0</function>
|
||||
<linked_source>/can/1/IMU_01_10ms/IMU_LatAccelVal</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/steeringPressed</v1>
|
||||
<v2>/carControl/latActive</v2>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="IMU_LatAccelVal_ms^2_roll_compensated">
|
||||
<global></global>
|
||||
<function>if (v1 == 0 and v2 == 1) then
|
||||
return (value * -9.8) - (v3 * 9.81)
|
||||
end
|
||||
--return 0
|
||||
return (value * -9.8) - (v3 * 9.81)</function>
|
||||
<linked_source>/can/1/IMU_01_10ms/IMU_LatAccelVal</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/steeringPressed</v1>
|
||||
<v2>/carControl/latActive</v2>
|
||||
<v3>/liveParameters/roll</v3>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="abs(ang_cmd)">
|
||||
<global></global>
|
||||
<function>return math.abs(value)</function>
|
||||
<linked_source>/can/1/LFA_ALT/LKAS_ANGLE_CMD</linked_source>
|
||||
</snippet>
|
||||
<snippet name="zero">
|
||||
<global>min=0
|
||||
max=250
|
||||
max_from_speed=96
|
||||
|
||||
rate_lim = 500
|
||||
|
||||
la_deadzone = 0.38
|
||||
|
||||
k1=200
|
||||
k2=20
|
||||
k3=1.0
|
||||
k4=1
|
||||
k5=10
|
||||
|
||||
old = 0
|
||||
|
||||
function sign(number)
|
||||
return number > 0 and 1 or (number == 0 and 0 or -1)
|
||||
end
|
||||
|
||||
function apply_rate_limit(old, new, limit)
|
||||
return math.min(math.max(new, old - limit), old + limit)
|
||||
end
|
||||
|
||||
function apply_deadzone(val, deadzone)
|
||||
if math.abs(val) <= deadzone then
|
||||
return 0.0
|
||||
elseif val < 0.0 then
|
||||
return val + deadzone
|
||||
else
|
||||
return val - deadzone
|
||||
end
|
||||
end</global>
|
||||
<function>return 0</function>
|
||||
<linked_source>/carState/aEgo</linked_source>
|
||||
</snippet>
|
||||
<snippet name="engaged curvature vehicle model">
|
||||
<global>engage_delay = 5
|
||||
last_bad_time = -engage_delay</global>
|
||||
<function>curvature = value
|
||||
pressed = v1
|
||||
enabled = v2
|
||||
|
||||
if (pressed == 1 or enabled == 0) then
|
||||
last_bad_time = time
|
||||
end
|
||||
|
||||
if (time > last_bad_time + engage_delay) then
|
||||
return value
|
||||
else
|
||||
return 0
|
||||
end</function>
|
||||
<linked_source>/controlsState/curvature</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/steeringPressed</v1>
|
||||
<v2>/carControl/enabled</v2>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="engaged curvature plan">
|
||||
<global>engage_delay = 5
|
||||
last_bad_time = -engage_delay</global>
|
||||
<function>curvature = value
|
||||
pressed = v1
|
||||
enabled = v2
|
||||
|
||||
if (pressed == 1 or enabled == 0) then
|
||||
last_bad_time = time
|
||||
end
|
||||
|
||||
if (time > last_bad_time + engage_delay) then
|
||||
return value
|
||||
else
|
||||
return 0
|
||||
end</function>
|
||||
<linked_source>/lateralPlan/curvatures/0</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/steeringPressed</v1>
|
||||
<v2>/carControl/enabled</v2>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="desired lateral jark">
|
||||
<global>firstX = 0
|
||||
firstY = 0
|
||||
is_first = true
|
||||
secondX = 0
|
||||
secondY = 0
|
||||
is_second = false</global>
|
||||
<function>-- Wait for initial values
|
||||
if (is_first) then
|
||||
is_first = false
|
||||
is_second = true
|
||||
firstX = time
|
||||
firstY = value
|
||||
end
|
||||
|
||||
if (is_second) then
|
||||
is_second = false
|
||||
secondX = time
|
||||
secondY = value
|
||||
end
|
||||
|
||||
-- Central derivative: dy/dx ~= f(x+delta_x)-f(x-delta_x)/(2*delta_x)
|
||||
dx = time - firstX
|
||||
dy = value - firstY
|
||||
-- Increment
|
||||
firstX = secondX
|
||||
firstY = secondY
|
||||
secondX = time
|
||||
secondY = value
|
||||
|
||||
return dy/dx</function>
|
||||
<linked_source>desired lat accel</linked_source>
|
||||
</snippet>
|
||||
<snippet name="carState.vEgo kmh">
|
||||
<global></global>
|
||||
<function>return value * 3.6</function>
|
||||
<linked_source>/carState/vEgo</linked_source>
|
||||
</snippet>
|
||||
<snippet name="engaged_accel_plan">
|
||||
<global>engage_delay = 5
|
||||
last_bad_time = -engage_delay</global>
|
||||
<function>accel = value
|
||||
brake = v1
|
||||
gas = v2
|
||||
enabled = v3
|
||||
|
||||
if (brake ~= 0 or gas ~= 0 or enabled == 0) then
|
||||
last_bad_time = time
|
||||
end
|
||||
|
||||
if (time > last_bad_time + engage_delay) then
|
||||
return value
|
||||
else
|
||||
return 0
|
||||
end</function>
|
||||
<linked_source>/longitudinalPlan/accels/0</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/brakePressed</v1>
|
||||
<v2>/carState/gasPressed</v2>
|
||||
<v3>/carControl/enabled</v3>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="Angle Error">
|
||||
<global>last_angle_requested = 0</global>
|
||||
<function>angle_error = last_angle_requested - v1
|
||||
|
||||
|
||||
last_angle_requested = value
|
||||
return angle_error</function>
|
||||
<linked_source>/carControl/actuators/steeringAngleDeg</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/steeringAngleDeg</v1>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="IMU_LatAccelVal_ms^2">
|
||||
<global></global>
|
||||
<function>return value * -9.8</function>
|
||||
<linked_source>/can/1/IMU_01_10ms/IMU_LatAccelVal</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/steeringPressed</v1>
|
||||
<v2>/carControl/latActive</v2>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="desired lat accel">
|
||||
<global></global>
|
||||
<function>return value * v1^2</function>
|
||||
<linked_source>/controlsState/desiredCurvature</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/vEgo</v1>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="engaged_accel_actuator">
|
||||
<global>engage_delay = 5
|
||||
last_bad_time = -engage_delay</global>
|
||||
<function>accel = value
|
||||
brake = v1
|
||||
gas = v2
|
||||
enabled = v3
|
||||
|
||||
if (brake ~= 0 or gas ~= 0 or enabled == 0) then
|
||||
last_bad_time = time
|
||||
end
|
||||
|
||||
if (time > last_bad_time + engage_delay) then
|
||||
return value
|
||||
else
|
||||
return 0
|
||||
end</function>
|
||||
<linked_source>/carControl/actuators/accel</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/brakePressed</v1>
|
||||
<v2>/carState/gasPressed</v2>
|
||||
<v3>/carControl/enabled</v3>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="engaged_accel_actual">
|
||||
<global>engage_delay = 5
|
||||
last_bad_time = -engage_delay</global>
|
||||
<function>accel = value
|
||||
brake = v1
|
||||
gas = v2
|
||||
enabled = v3
|
||||
|
||||
if (brake ~= 0 or gas ~= 0 or enabled == 0) then
|
||||
last_bad_time = time
|
||||
end
|
||||
|
||||
if (time > last_bad_time + engage_delay) then
|
||||
return value
|
||||
else
|
||||
return 0
|
||||
end</function>
|
||||
<linked_source>/carState/aEgo</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/brakePressed</v1>
|
||||
<v2>/carState/gasPressed</v2>
|
||||
<v3>/carControl/enabled</v3>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="roll compensated lateral acceleration">
|
||||
<global></global>
|
||||
<function>if (v3 == 0 and v4 == 1) then
|
||||
return (value * v1 ^ 2) - (v2 * 9.81)
|
||||
end
|
||||
return 0</function>
|
||||
<linked_source>/controlsState/curvature</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/vEgo</v1>
|
||||
<v2>/liveParameters/roll</v2>
|
||||
<v3>/carState/steeringPressed</v3>
|
||||
<v4>/carControl/latActive</v4>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="Zero">
|
||||
<global></global>
|
||||
<function>return (0)</function>
|
||||
<linked_source>/carState/canValid</linked_source>
|
||||
</snippet>
|
||||
<snippet name="abs(des la accel)">
|
||||
<global></global>
|
||||
<function>return math.abs(value)</function>
|
||||
<linked_source>desired lat accel</linked_source>
|
||||
</snippet>
|
||||
<snippet name="Desired lateral accel (roll compensated)">
|
||||
<global></global>
|
||||
<function>return (value * v1 ^ 2) - (v2 * 9.81)</function>
|
||||
<linked_source>/controlsState/desiredCurvature</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/vEgo</v1>
|
||||
<v2>/liveParameters/roll</v2>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="Actual lateral accel (roll compensated)">
|
||||
<global></global>
|
||||
<function>return (value * v1 ^ 2) - (v2 * 9.81)</function>
|
||||
<linked_source>/controlsState/curvature</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/vEgo</v1>
|
||||
<v2>/liveParameters/roll</v2>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="max torque(calc)">
|
||||
<global>min=0
|
||||
max=250
|
||||
max_from_speed=96
|
||||
|
||||
rate_lim = 500
|
||||
|
||||
la_deadzone = 0.38
|
||||
|
||||
k1=200
|
||||
k2=20
|
||||
k3=1.0
|
||||
k4=1
|
||||
k5=10
|
||||
|
||||
old = 0
|
||||
|
||||
function sign(number)
|
||||
return number > 0 and 1 or (number == 0 and 0 or -1)
|
||||
end
|
||||
|
||||
function apply_rate_limit(old, new, limit)
|
||||
return math.min(math.max(new, old - limit), old + limit)
|
||||
end
|
||||
|
||||
function apply_deadzone(val, deadzone)
|
||||
if math.abs(val) <= deadzone then
|
||||
return 0.0
|
||||
elseif val < 0.0 then
|
||||
return val + deadzone
|
||||
else
|
||||
return val - deadzone
|
||||
end
|
||||
end</global>
|
||||
<function>la = apply_deadzone(v2, la_deadzone)
|
||||
lj = v3
|
||||
|
||||
if la == 0.0 then
|
||||
lj = 0.0
|
||||
end
|
||||
|
||||
fla = math.min(math.abs(k1 * la)^k3, max)
|
||||
flj = math.min(math.abs(k2 * lj)^k4, max)
|
||||
|
||||
out = fla
|
||||
|
||||
flv = math.min(max_from_speed, k5 * v4)
|
||||
|
||||
out = out + flv
|
||||
|
||||
out = math.max(math.min(out, max), min)
|
||||
|
||||
if sign(la) == sign(lj) then
|
||||
out = out - flj
|
||||
else
|
||||
out = out + flj
|
||||
end
|
||||
|
||||
|
||||
if v5 == 1.0 then
|
||||
out = 0.0
|
||||
end
|
||||
|
||||
out = math.max(math.min(out, max), min)
|
||||
out = apply_rate_limit(old, out, rate_lim)
|
||||
old = out
|
||||
|
||||
return out</function>
|
||||
<linked_source>/can/1/LFA_ALT/LKAS_ANGLE_CMD</linked_source>
|
||||
<additional_sources>
|
||||
<v1>ang_cmd rate</v1>
|
||||
<v2>desired lat accel</v2>
|
||||
<v3>desired lateral jark</v3>
|
||||
<v4>/carState/vEgo</v4>
|
||||
<v5>/can/1/LFA_ALT/LKAS_ANGLE_ACTIVE</v5>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="carState.vEgo mph">
|
||||
<global></global>
|
||||
<function>return value * 2.23694</function>
|
||||
<linked_source>/carState/vEgo</linked_source>
|
||||
</snippet>
|
||||
<snippet name="engaged curvature yaw">
|
||||
<global>engage_delay = 5
|
||||
last_bad_time = -engage_delay</global>
|
||||
<function>curvature = value / v3
|
||||
pressed = v1
|
||||
enabled = v2
|
||||
|
||||
if (pressed == 1 or enabled == 0) then
|
||||
last_bad_time = time
|
||||
end
|
||||
|
||||
if (time > last_bad_time + engage_delay) then
|
||||
return curvature
|
||||
else
|
||||
return 0
|
||||
end</function>
|
||||
<linked_source>/liveLocationKalman/angularVelocityCalibrated/value/2</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/steeringPressed</v1>
|
||||
<v2>/carControl/enabled</v2>
|
||||
<v3>/liveLocationKalman/velocityCalibrated/value/0</v3>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
</customMathEquations>
|
||||
<snippets/>
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
</root>
|
||||
|
||||
274
tools/plotjuggler/layouts/hkg_angle_control.xml
Normal file
274
tools/plotjuggler/layouts/hkg_angle_control.xml
Normal file
@@ -0,0 +1,274 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<root>
|
||||
<tabbed_widget name="Main Window" parent="main_window">
|
||||
<Tab containers="1" tab_name="tab1">
|
||||
<Container>
|
||||
<DockSplitter sizes="0.500397;0.499603" count="2" orientation="-">
|
||||
<DockArea name="...">
|
||||
<plot flip_x="false" flip_y="false" mode="TimeSeries" style="Lines">
|
||||
<range top="256.250000" left="0.000000" right="421.102293" bottom="-6.250000"/>
|
||||
<limitY/>
|
||||
<curve color="#1ac938" name="/can/1/LFA_ALT/LKAS_ANGLE_MAX_TORQUE"/>
|
||||
<curve color="#17becf" name="max torque(calc)">
|
||||
<transform name="Moving Average" alias="max torque(calc)[Moving Average]">
|
||||
<options compensate_offset="true" value="10"/>
|
||||
</transform>
|
||||
</curve>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot flip_x="false" flip_y="false" mode="TimeSeries" style="Lines">
|
||||
<range top="2.776497" left="0.000000" right="421.102293" bottom="-2.918548"/>
|
||||
<limitY/>
|
||||
<curve color="#f14cc1" name="desired lat accel"/>
|
||||
<curve color="#888888" name="zero"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
</DockSplitter>
|
||||
</Container>
|
||||
</Tab>
|
||||
<currentTabIndex index="0"/>
|
||||
</tabbed_widget>
|
||||
<use_relative_time_offset enabled="1"/>
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
<Plugins>
|
||||
<plugin ID="DataLoad CSV">
|
||||
<default time_axis="" delimiter="0"/>
|
||||
</plugin>
|
||||
<plugin ID="DataLoad MCAP"/>
|
||||
<plugin ID="DataLoad Rlog"/>
|
||||
<plugin ID="DataLoad ULog"/>
|
||||
<plugin ID="Cereal Subscriber"/>
|
||||
<plugin ID="UDP Server"/>
|
||||
<plugin ID="WebSocket Server"/>
|
||||
<plugin ID="ZMQ Subscriber"/>
|
||||
<plugin ID="Fast Fourier Transform"/>
|
||||
<plugin ID="Quaternion to RPY"/>
|
||||
<plugin ID="Reactive Script Editor">
|
||||
<library code="--[[ Helper function to create a series from arrays

 new_series: a series previously created with ScatterXY.new(name)
 prefix: prefix of the timeseries, before the index of the array
 suffix_X: suffix to complete the name of the series containing the X value. If [nil], use the index of the array.
 suffix_Y: suffix to complete the name of the series containing the Y value
 timestamp: usually the tracker_time variable
 
 Example:
 
 Assuming we have multiple series in the form:
 
 /trajectory/node.{X}/position/x
 /trajectory/node.{X}/position/y
 
 where {N} is the index of the array (integer). We can create a reactive series from the array with:
 
 new_series = ScatterXY.new("my_trajectory") 
 CreateSeriesFromArray( new_series, "/trajectory/node", "position/x", "position/y", tracker_time );
--]]

function CreateSeriesFromArray( new_series, prefix, suffix_X, suffix_Y, timestamp )
 
 --- clear previous values
 new_series:clear()
 
 --- Append points to new_series
 index = 0
 while(true) do

 x = index;
 -- if not nil, get the X coordinate from a series
 if suffix_X ~= nil then 
 series_x = TimeseriesView.find( string.format( "%s.%d/%s", prefix, index, suffix_X) )
 if series_x == nil then break end
 x = series_x:atTime(timestamp)	 
 end
 
 series_y = TimeseriesView.find( string.format( "%s.%d/%s", prefix, index, suffix_Y) )
 if series_y == nil then break end 
 y = series_y:atTime(timestamp)
 
 new_series:push_back(x,y)
 index = index+1
 end
end

--[[ Similar to the built-in function GetSeriesNames(), but select only the names with a give prefix. --]]

function GetSeriesNamesByPrefix(prefix)
 -- GetSeriesNames(9 is a built-in function
 all_names = GetSeriesNames()
 filtered_names = {}
 for i, name in ipairs(all_names) do
 -- check the prefix
 if name:find(prefix, 1, #prefix) then
 table.insert(filtered_names, name);
 end
 end
 return filtered_names
end

--[[ Modify an existing series, applying offsets to all their X and Y values

 series: an existing timeseries, obtained with TimeseriesView.find(name)
 delta_x: offset to apply to each x value
 delta_y: offset to apply to each y value 
 
--]]

function ApplyOffsetInPlace(series, delta_x, delta_y)
 -- use C++ indeces, not Lua indeces
 for index=0, series:size()-1 do
 x,y = series:at(index)
 series:set(index, x + delta_x, y + delta_y)
 end
end
"/>
|
||||
<scripts/>
|
||||
</plugin>
|
||||
<plugin ID="CSV Exporter"/>
|
||||
</Plugins>
|
||||
<customMathEquations>
|
||||
<snippet name="zero">
|
||||
<global>min=0
|
||||
max=250
|
||||
max_from_speed=96
|
||||
|
||||
rate_lim = 500
|
||||
|
||||
la_deadzone = 0.38
|
||||
|
||||
k1=200
|
||||
k2=20
|
||||
k3=1.0
|
||||
k4=1
|
||||
k5=10
|
||||
|
||||
old = 0
|
||||
|
||||
function sign(number)
|
||||
return number > 0 and 1 or (number == 0 and 0 or -1)
|
||||
end
|
||||
|
||||
function apply_rate_limit(old, new, limit)
|
||||
return math.min(math.max(new, old - limit), old + limit)
|
||||
end
|
||||
|
||||
function apply_deadzone(val, deadzone)
|
||||
if math.abs(val) <= deadzone then
|
||||
return 0.0
|
||||
elseif val < 0.0 then
|
||||
return val + deadzone
|
||||
else
|
||||
return val - deadzone
|
||||
end
|
||||
end</global>
|
||||
<function>return 0</function>
|
||||
<linked_source>/carState/aEgo</linked_source>
|
||||
</snippet>
|
||||
<snippet name="max torque lj adj">
|
||||
<global>min=0
|
||||
max=250
|
||||
max_from_speed=96
|
||||
|
||||
k1=200
|
||||
k2=30
|
||||
k3=1
|
||||
k4=1
|
||||
k5=10
|
||||
|
||||
function sign(number)
|
||||
return number > 0 and 1 or (number == 0 and 0 or -1)
|
||||
end</global>
|
||||
<function>return 250 - value * 20</function>
|
||||
<linked_source>desired lateral jark</linked_source>
|
||||
</snippet>
|
||||
<snippet name="ang_cmd rate">
|
||||
<global>firstX = 0
|
||||
firstY = 0
|
||||
is_first = true
|
||||
secondX = 0
|
||||
secondY = 0
|
||||
is_second = false</global>
|
||||
<function>-- Wait for initial values
|
||||
if (is_first) then
|
||||
is_first = false
|
||||
is_second = true
|
||||
firstX = time
|
||||
firstY = value
|
||||
end
|
||||
|
||||
if (is_second) then
|
||||
is_second = false
|
||||
secondX = time
|
||||
secondY = value
|
||||
end
|
||||
|
||||
-- Central derivative: dy/dx ~= f(x+delta_x)-f(x-delta_x)/(2*delta_x)
|
||||
dx = time - firstX
|
||||
dy = value - firstY
|
||||
-- Increment
|
||||
firstX = secondX
|
||||
firstY = secondY
|
||||
secondX = time
|
||||
secondY = value
|
||||
|
||||
return dy/dx</function>
|
||||
<linked_source>/can/1/LFA_ALT/LKAS_ANGLE_CMD</linked_source>
|
||||
</snippet>
|
||||
<snippet name="max torque(calc)">
|
||||
<global>min=0
|
||||
max=250
|
||||
max_from_speed=96
|
||||
|
||||
rate_lim = 500
|
||||
|
||||
la_deadzone = 0.38
|
||||
|
||||
k1=200
|
||||
k2=20
|
||||
k3=1.0
|
||||
k4=1
|
||||
k5=10
|
||||
|
||||
old = 0
|
||||
|
||||
function sign(number)
|
||||
return number > 0 and 1 or (number == 0 and 0 or -1)
|
||||
end
|
||||
|
||||
function apply_rate_limit(old, new, limit)
|
||||
return math.min(math.max(new, old - limit), old + limit)
|
||||
end
|
||||
|
||||
function apply_deadzone(val, deadzone)
|
||||
if math.abs(val) <= deadzone then
|
||||
return 0.0
|
||||
elseif val < 0.0 then
|
||||
return val + deadzone
|
||||
else
|
||||
return val - deadzone
|
||||
end
|
||||
end</global>
|
||||
<function>la = apply_deadzone(v2, la_deadzone)
|
||||
lj = v3
|
||||
|
||||
if la == 0.0 then
|
||||
lj = 0.0
|
||||
end
|
||||
|
||||
fla = math.min(math.abs(k1 * la)^k3, max)
|
||||
flj = math.min(math.abs(k2 * lj)^k4, max)
|
||||
|
||||
out = fla
|
||||
|
||||
flv = math.min(max_from_speed, k5 * v4)
|
||||
|
||||
out = out + flv
|
||||
|
||||
out = math.max(math.min(out, max), min)
|
||||
|
||||
if sign(la) == sign(lj) then
|
||||
out = out - flj
|
||||
else
|
||||
out = out + flj
|
||||
end
|
||||
|
||||
|
||||
if v5 == 1.0 then
|
||||
out = 0.0
|
||||
end
|
||||
|
||||
out = math.max(math.min(out, max), min)
|
||||
out = apply_rate_limit(old, out, rate_lim)
|
||||
old = out
|
||||
|
||||
return out</function>
|
||||
<linked_source>/can/1/LFA_ALT/LKAS_ANGLE_CMD</linked_source>
|
||||
<additional_sources>
|
||||
<v1>ang_cmd rate</v1>
|
||||
<v2>desired lat accel</v2>
|
||||
<v3>desired lateral jark</v3>
|
||||
<v4>/carState/vEgo</v4>
|
||||
<v5>/can/1/LFA_ALT/LKAS_ANGLE_ACTIVE</v5>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="desired lateral jark">
|
||||
<global>firstX = 0
|
||||
firstY = 0
|
||||
is_first = true
|
||||
secondX = 0
|
||||
secondY = 0
|
||||
is_second = false</global>
|
||||
<function>-- Wait for initial values
|
||||
if (is_first) then
|
||||
is_first = false
|
||||
is_second = true
|
||||
firstX = time
|
||||
firstY = value
|
||||
end
|
||||
|
||||
if (is_second) then
|
||||
is_second = false
|
||||
secondX = time
|
||||
secondY = value
|
||||
end
|
||||
|
||||
-- Central derivative: dy/dx ~= f(x+delta_x)-f(x-delta_x)/(2*delta_x)
|
||||
dx = time - firstX
|
||||
dy = value - firstY
|
||||
-- Increment
|
||||
firstX = secondX
|
||||
firstY = secondY
|
||||
secondX = time
|
||||
secondY = value
|
||||
|
||||
return dy/dx</function>
|
||||
<linked_source>desired lat accel</linked_source>
|
||||
</snippet>
|
||||
<snippet name="abs(des la accel)">
|
||||
<global></global>
|
||||
<function>return math.abs(value)</function>
|
||||
<linked_source>desired lat accel</linked_source>
|
||||
</snippet>
|
||||
<snippet name="desired lat accel">
|
||||
<global></global>
|
||||
<function>return value * v1^2</function>
|
||||
<linked_source>/controlsState/desiredCurvature</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/vEgo</v1>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="abs(ang_cmd)">
|
||||
<global></global>
|
||||
<function>return math.abs(value)</function>
|
||||
<linked_source>/can/1/LFA_ALT/LKAS_ANGLE_CMD</linked_source>
|
||||
</snippet>
|
||||
</customMathEquations>
|
||||
<snippets/>
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
</root>
|
||||
|
||||
175
tools/plotjuggler/layouts/safety-limits-angle-kkg.xml
Normal file
175
tools/plotjuggler/layouts/safety-limits-angle-kkg.xml
Normal file
@@ -0,0 +1,175 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<root>
|
||||
<tabbed_widget parent="main_window" name="Main Window">
|
||||
<Tab tab_name="tab1" containers="1">
|
||||
<Container>
|
||||
<DockSplitter sizes="0.25;0.25;0.25;0.25" count="4" orientation="-">
|
||||
<DockArea name="...">
|
||||
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries">
|
||||
<range bottom="-3.582051" right="1431.113121" top="5.314632" left="5.322399"/>
|
||||
<limitY/>
|
||||
<curve name="Actual lateral accel (roll compensated)" color="#1ac938"/>
|
||||
<curve name="Desired lateral accel (roll compensated)" color="#ff7f0e"/>
|
||||
<curve name="IMU_LatAccelVal_ms^2" color="#f14cc1"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries">
|
||||
<range bottom="-3.741271" right="1431.113121" top="3.756006" left="5.322399"/>
|
||||
<limitY/>
|
||||
<curve name="roll compensated lateral acceleration" color="#ff7f0e"/>
|
||||
<curve name="IMU_LatAccelVal_Ms^2" color="#1ac938"/>
|
||||
<curve name="IMU_LatAccelVal_ms^2_roll_compensated" color="#9467bd"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries">
|
||||
<range bottom="-0.025000" right="1431.113121" top="1.025000" left="5.322399"/>
|
||||
<limitY/>
|
||||
<curve name="/carState/steeringPressed" color="#0097ff"/>
|
||||
<curve name="/carOutput/actuatorsOutput/torque" color="#d62728"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries">
|
||||
<range bottom="-1.660728" right="1431.113121" top="67.942958" left="5.322399"/>
|
||||
<limitY/>
|
||||
<curve name="/carState/vEgo" color="#f14cc1">
|
||||
<transform name="Scale/Offset" alias="/carState/vEgo[Scale/Offset]">
|
||||
<options value_scale="2.23694" time_offset="0" value_offset="0"/>
|
||||
</transform>
|
||||
</curve>
|
||||
</plot>
|
||||
</DockArea>
|
||||
</DockSplitter>
|
||||
</Container>
|
||||
</Tab>
|
||||
<Tab tab_name="tab2" containers="1">
|
||||
<Container>
|
||||
<DockSplitter sizes="0.5;0.5" count="2" orientation="-">
|
||||
<DockArea name="...">
|
||||
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries">
|
||||
<range bottom="30595.900000" right="1431.113121" top="34884.100000" left="5.322399"/>
|
||||
<limitY/>
|
||||
<curve name="/can/1/IMU_01_10ms/IMU_RollRtVal" color="#17becf"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
<DockArea name="...">
|
||||
<plot flip_x="false" style="Lines" flip_y="false" mode="TimeSeries">
|
||||
<range bottom="-0.058795" right="1431.113121" top="0.072448" left="5.322399"/>
|
||||
<limitY/>
|
||||
<curve name="/liveParameters/roll" color="#bcbd22"/>
|
||||
</plot>
|
||||
</DockArea>
|
||||
</DockSplitter>
|
||||
</Container>
|
||||
</Tab>
|
||||
<currentTabIndex index="1"/>
|
||||
</tabbed_widget>
|
||||
<use_relative_time_offset enabled="1"/>
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
<Plugins>
|
||||
<plugin ID="DataLoad CSV">
|
||||
<default time_axis="" delimiter="0"/>
|
||||
</plugin>
|
||||
<plugin ID="DataLoad MCAP"/>
|
||||
<plugin ID="DataLoad Rlog"/>
|
||||
<plugin ID="DataLoad ULog"/>
|
||||
<plugin ID="Cereal Subscriber"/>
|
||||
<plugin ID="UDP Server"/>
|
||||
<plugin ID="WebSocket Server"/>
|
||||
<plugin ID="ZMQ Subscriber"/>
|
||||
<plugin ID="Fast Fourier Transform"/>
|
||||
<plugin ID="Quaternion to RPY"/>
|
||||
<plugin ID="Reactive Script Editor">
|
||||
<library code="--[[ Helper function to create a series from arrays

 new_series: a series previously created with ScatterXY.new(name)
 prefix: prefix of the timeseries, before the index of the array
 suffix_X: suffix to complete the name of the series containing the X value. If [nil], use the index of the array.
 suffix_Y: suffix to complete the name of the series containing the Y value
 timestamp: usually the tracker_time variable
 
 Example:
 
 Assuming we have multiple series in the form:
 
 /trajectory/node.{X}/position/x
 /trajectory/node.{X}/position/y
 
 where {N} is the index of the array (integer). We can create a reactive series from the array with:
 
 new_series = ScatterXY.new("my_trajectory") 
 CreateSeriesFromArray( new_series, "/trajectory/node", "position/x", "position/y", tracker_time );
--]]

function CreateSeriesFromArray( new_series, prefix, suffix_X, suffix_Y, timestamp )
 
 --- clear previous values
 new_series:clear()
 
 --- Append points to new_series
 index = 0
 while(true) do

 x = index;
 -- if not nil, get the X coordinate from a series
 if suffix_X ~= nil then 
 series_x = TimeseriesView.find( string.format( "%s.%d/%s", prefix, index, suffix_X) )
 if series_x == nil then break end
 x = series_x:atTime(timestamp)	 
 end
 
 series_y = TimeseriesView.find( string.format( "%s.%d/%s", prefix, index, suffix_Y) )
 if series_y == nil then break end 
 y = series_y:atTime(timestamp)
 
 new_series:push_back(x,y)
 index = index+1
 end
end

--[[ Similar to the built-in function GetSeriesNames(), but select only the names with a give prefix. --]]

function GetSeriesNamesByPrefix(prefix)
 -- GetSeriesNames(9 is a built-in function
 all_names = GetSeriesNames()
 filtered_names = {}
 for i, name in ipairs(all_names) do
 -- check the prefix
 if name:find(prefix, 1, #prefix) then
 table.insert(filtered_names, name);
 end
 end
 return filtered_names
end

--[[ Modify an existing series, applying offsets to all their X and Y values

 series: an existing timeseries, obtained with TimeseriesView.find(name)
 delta_x: offset to apply to each x value
 delta_y: offset to apply to each y value 
 
--]]

function ApplyOffsetInPlace(series, delta_x, delta_y)
 -- use C++ indeces, not Lua indeces
 for index=0, series:size()-1 do
 x,y = series:at(index)
 series:set(index, x + delta_x, y + delta_y)
 end
end
"/>
|
||||
<scripts/>
|
||||
</plugin>
|
||||
<plugin ID="CSV Exporter"/>
|
||||
</Plugins>
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
<customMathEquations>
|
||||
<snippet name="IMU_LatAccelVal_ms^2">
|
||||
<global></global>
|
||||
<function>return value * -9.8</function>
|
||||
<linked_source>/can/1/IMU_01_10ms/IMU_LatAccelVal</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/steeringPressed</v1>
|
||||
<v2>/carControl/latActive</v2>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="roll compensated lateral acceleration">
|
||||
<global></global>
|
||||
<function>if (v3 == 0 and v4 == 1) then
|
||||
return (value * v1 ^ 2) - (v2 * 9.81)
|
||||
end
|
||||
return 0</function>
|
||||
<linked_source>/controlsState/curvature</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/vEgo</v1>
|
||||
<v2>/liveParameters/roll</v2>
|
||||
<v3>/carState/steeringPressed</v3>
|
||||
<v4>/carControl/latActive</v4>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="IMU_LatAccelVal_Ms^2">
|
||||
<global></global>
|
||||
<function>if (v1 == 0 and v2 == 1) then
|
||||
return value * -9.8
|
||||
end
|
||||
return 0</function>
|
||||
<linked_source>/can/1/IMU_01_10ms/IMU_LatAccelVal</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/steeringPressed</v1>
|
||||
<v2>/carControl/latActive</v2>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="IMU_LatAccelVal_Ms^3">
|
||||
<global></global>
|
||||
<function>return value * -9.8</function>
|
||||
<linked_source>/can/1/IMU_01_10ms/IMU_LatAccelVal</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/steeringPressed</v1>
|
||||
<v2>/carControl/latActive</v2>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="IMU_LatAccelVal_ms^2_roll_compensated">
|
||||
<global></global>
|
||||
<function>
|
||||
|
||||
if (v1 == 0 and v2 == 1) then
|
||||
return (value * -9.8) - (v3 * 9.81)
|
||||
end
|
||||
return 0</function>
|
||||
<linked_source>/can/1/IMU_01_10ms/IMU_LatAccelVal</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/steeringPressed</v1>
|
||||
<v2>/carControl/latActive</v2>
|
||||
<v3>/liveParameters/roll</v3>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="Actual lateral accel (roll compensated)">
|
||||
<global></global>
|
||||
<function>return (value * v1 ^ 2) - (v2 * 9.81)</function>
|
||||
<linked_source>/controlsState/curvature</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/vEgo</v1>
|
||||
<v2>/liveParameters/roll</v2>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
<snippet name="Desired lateral accel (roll compensated)">
|
||||
<global></global>
|
||||
<function>return (value * v1 ^ 2) - (v2 * 9.81)</function>
|
||||
<linked_source>/controlsState/desiredCurvature</linked_source>
|
||||
<additional_sources>
|
||||
<v1>/carState/vEgo</v1>
|
||||
<v2>/liveParameters/roll</v2>
|
||||
</additional_sources>
|
||||
</snippet>
|
||||
</customMathEquations>
|
||||
<snippets/>
|
||||
<!-- - - - - - - - - - - - - - - -->
|
||||
</root>
|
||||
|
||||
Reference in New Issue
Block a user