mirror of
https://github.com/infiniteCable2/openpilot.git
synced 2026-08-06 00:36:29 +08:00
Merge branch 'master' of https://github.com/sunnypilot/sunnypilot
This commit is contained in:
@@ -23,56 +23,43 @@ env:
|
||||
CI: 1
|
||||
|
||||
jobs:
|
||||
generate_cereal_artifact:
|
||||
name: Generate cereal validation artifacts
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
- run: ./tools/op.sh setup
|
||||
- name: Build openpilot
|
||||
run: scons -j$(nproc) cereal
|
||||
- name: Dump sunnypilot schema
|
||||
run: |
|
||||
export PYTHONPATH=${{ github.workspace }}
|
||||
python3 cereal/messaging/tests/validate_sp_cereal_upstream.py -g -f schema.json
|
||||
- name: 'Prepare artifact'
|
||||
run: |
|
||||
mkdir -p "cereal/messaging/tests/cereal_validations"
|
||||
cp cereal/messaging/tests/validate_sp_cereal_upstream.py "cereal/messaging/tests/cereal_validations/validate_sp_cereal_upstream.py"
|
||||
cp schema.json "cereal/messaging/tests/cereal_validations/schema.json"
|
||||
- name: 'Upload Artifact'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cereal_validations
|
||||
path: cereal/messaging/tests/cereal_validations
|
||||
|
||||
validate_cereal_with_upstream:
|
||||
name: Validate cereal with Upstream
|
||||
runs-on: ubuntu-24.04
|
||||
needs: generate_cereal_artifact
|
||||
steps:
|
||||
- name: Checkout sunnypilot
|
||||
- name: Checkout sunnypilot cereal
|
||||
uses: actions/checkout@v6
|
||||
- name: Checkout upstream openpilot
|
||||
with:
|
||||
sparse-checkout: cereal
|
||||
|
||||
- name: Init sunnypilot opendbc submodule
|
||||
run: git submodule update --init --depth 1 opendbc_repo
|
||||
|
||||
- name: Checkout upstream openpilot cereal
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: 'commaai/openpilot'
|
||||
path: openpilot
|
||||
submodules: true
|
||||
path: upstream_openpilot
|
||||
sparse-checkout: cereal
|
||||
ref: "refs/heads/master"
|
||||
- run: ./tools/op.sh setup
|
||||
- name: Build openpilot
|
||||
working-directory: openpilot
|
||||
run: scons -j$(nproc) cereal
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: cereal_validations
|
||||
path: openpilot/cereal/messaging/tests/cereal_validations
|
||||
- name: 'Validate sunnypilot schema against upstream'
|
||||
|
||||
- name: Init upstream opendbc submodule
|
||||
working-directory: upstream_openpilot
|
||||
run: git submodule update --init --depth 1 opendbc_repo
|
||||
|
||||
- name: Install uv
|
||||
run: pip install uv
|
||||
|
||||
- name: Generate sunnypilot schema
|
||||
run: |
|
||||
export PYTHONPATH=${{ github.workspace }}/openpilot
|
||||
chmod +x openpilot/cereal/messaging/tests/cereal_validations/validate_sp_cereal_upstream.py
|
||||
python3 openpilot/cereal/messaging/tests/cereal_validations/validate_sp_cereal_upstream.py -r -f openpilot/cereal/messaging/tests/cereal_validations/schema.json
|
||||
PYCAPNP_VER=$(python3 -c "import re; m=re.search(r'name = \"pycapnp\"\nversion = \"([^\"]+)\"', open('uv.lock').read()); print(m.group(1))")
|
||||
uv run --isolated --with "pycapnp==${PYCAPNP_VER}" \
|
||||
python3 cereal/messaging/tests/validate_sp_cereal_upstream.py \
|
||||
-g -f /tmp/sp_schema.json --cereal-dir cereal
|
||||
|
||||
- name: Validate against upstream
|
||||
run: |
|
||||
PYCAPNP_VER=$(python3 -c "import re; m=re.search(r'name = \"pycapnp\"\nversion = \"([^\"]+)\"', open('uv.lock').read()); print(m.group(1))")
|
||||
uv run --isolated --with "pycapnp==${PYCAPNP_VER}" \
|
||||
python3 cereal/messaging/tests/validate_sp_cereal_upstream.py \
|
||||
-r -f /tmp/sp_schema.json --cereal-dir upstream_openpilot/cereal
|
||||
|
||||
+86
-1
@@ -1,4 +1,4 @@
|
||||
sunnypilot Version 2026.001.000 (2026-03-xx)
|
||||
sunnypilot Version 2026.001.000 (2026-05-06)
|
||||
========================
|
||||
* What's Changed (sunnypilot/sunnypilot)
|
||||
* Complete rewrite of the user interface from Qt C++ to Raylib Python
|
||||
@@ -66,6 +66,64 @@ sunnypilot Version 2026.001.000 (2026-03-xx)
|
||||
* Pause Lateral Control with Blinker: Post-Blinker Delay by @CHaucke89
|
||||
* SCC-V: Use p97 for predicted lateral accel by @yasu-oh
|
||||
* Controls: Support for Torque Lateral Control v0 Tune by @sunnyhaibin
|
||||
* [TIZI/TICI] ui: ensure null checks for `CarParams` and `CarParamsSP` by @sunnyhaibin
|
||||
* [TIZI/TICI] ui: use `vCruiseCluster` and `vEgoCluster` for SLA `preActive` by @sunnyhaibin
|
||||
* Fix display of values when using use_float_scaling by @CHaucke89
|
||||
* models: fix default & index "0" by @nayan8teen
|
||||
* [TIZI/TICI] visuals: Improved speed limit by @angaz
|
||||
* ICBM: ensure button timers update on disable to clear stale presses by @jamesmikesell
|
||||
* [TIZI/TICI] ui: simplify Smart Cruise Control text rendering by @sunnyhaibin
|
||||
* controlsd: fix steer_limited_by_safety not updating under MADS by @zephleggett
|
||||
* soundd: trigger timeout warning during MADS lateral-only by @zephleggett
|
||||
* pandad: flasher for Rivian long upgrade module by @lukasloetkolben
|
||||
* modeld_v2: tinygrad transformation warp by @Discountchubbs
|
||||
* tools: block `manage_sunnylinkd` in sim startup script by @sunnyhaibin
|
||||
* [MICI] ui: need superclass `_render` in `HudRendererSP` by @sunnyhaibin
|
||||
* [TIZI/TICI] ui: Speed Limit Assist active status by @sunnyhaibin
|
||||
* ui: reimplement "Screen Off" option to Onroad Brightness by @sunnyhaibin
|
||||
* ui: don't hide steering wheel when blindspot disabled by @royjr
|
||||
* ui: Speed Limit Assist `preActive` improvements by @sunnyhaibin
|
||||
* ui: consolidate Speed Limit Assist `preActive` status rendering by @sunnyhaibin
|
||||
* [MICI] ui: Speed Limit Assist `preActive` status by @sunnyhaibin
|
||||
* sunnypilot modeld: remove thneed modeld by @Discountchubbs
|
||||
* modeld_v2: decouple planplus scaling from accel by @Discountchubbs
|
||||
* sunnylink: Handle exceptions in `getParamsAllKeysV1` to log crashes by @devtekve
|
||||
* [TIZI/TICI] ui: Developer UI cleanup by @sunnyhaibin
|
||||
* [TIZI/TICI] ui: dynamic alert size by @nayan8teen
|
||||
* i18n(fr): Add French translations by @didlawowo
|
||||
* Toyota: Stop and Go Hack (Alpha) by @sunnyhaibin
|
||||
* ui: `AlertFadeAnimator` for longitudinal-related statuses by @sunnyhaibin
|
||||
* pandad: gate unsupported pandas before flashing by @sunnyhaibin
|
||||
* Rivian: Flash xnor's Longitudinal Upgrade Kit prior supported panda check by @lukasloetkolben
|
||||
* [TIZI/TICI] ui: add back gate steering arc behind toggle by @sunnyhaibin
|
||||
* ui: gate Onroad Brightness Delay on readiness by @sunnyhaibin
|
||||
* ui: add new timer options for Onroad Brightness Delay by @sunnyhaibin
|
||||
* [TIZI/TICI] ui: branch switcher is always available by @sunnyhaibin
|
||||
* pandad: always prioritize internal panda by @sunnyhaibin
|
||||
* sunnylinkd: fetch compressed params schema by @sunnyhaibin
|
||||
* sunnypilot locationd: remove unused car_ekf filter by @sunnyhaibin
|
||||
* modeld_v2: update deprecated temporalPose ref by @sunnyhaibin
|
||||
* NNLC: restore pre-v1 PID gains in torque extension by @mmmorks
|
||||
* MADS safety: enable heartbeat and lateral controls mismatch checks by @sunnyhaibin
|
||||
* [MICI] ui: models panel enhancements by @nayan8teen
|
||||
* [TIZI/TICI] ui: fix unintended selection while scrolling in TreeOptionDialog by @TheSecurityDev
|
||||
* tools: script for video concatenation by @Discountchubbs
|
||||
* tools: profile memory usage by @Discountchubbs
|
||||
* [TIZI/TICI] ui: remove per-frame param sync by @sunnyhaibin
|
||||
* [MICI] ui: always offroad by @nayan8teen
|
||||
* controls: always default Torque Lateral Control to v0 Tune by @sunnyhaibin
|
||||
* Revert "controls: always default Torque Lateral Control to v0 Tune" by @sunnyhaibin
|
||||
* Reapply "controls: always default Torque Lateral Control to v0 Tune" (#1806) by @sunnyhaibin
|
||||
* [MICI] ui: add sunnylink info & connectivity check by @nayan8teen
|
||||
* sunnylink: Remove unused API endpoint by @devtekve
|
||||
* DM: wheel touch enforcement in MADS by @sunnyhaibin
|
||||
* torque: show static override values in Dev UI & gate `useParams` on custom torque tune by @sunnyhaibin
|
||||
* MADS: suppress espActive event when long is not engaged by @sunnyhaibin
|
||||
* sunnylink: SDUI by @sunnyhaibin
|
||||
* [MICI] ui: align upstream changes with sunnypilot settings buttons by @nayan8teen
|
||||
* ui: fix cellular toggles by @AmyJeanes
|
||||
* sunnylink: switch athena domain by @devtekve
|
||||
* Platform List: dynamically migrate CarPlatformBundle by @sunnyhaibin
|
||||
* What's Changed (sunnypilot/opendbc)
|
||||
* Honda: DBC for Accord 9th Generation by @mvl-boston
|
||||
* FCA: update tire stiffness values for `RAM_HD` by @dparring
|
||||
@@ -84,12 +142,25 @@ sunnypilot Version 2026.001.000 (2026-03-xx)
|
||||
* Honda: add missing `GasInterceptor` messages to Taiwan Odyssey DBC by @mvl-boston
|
||||
* GM: remove `CHEVROLET_EQUINOX_NON_ACC_3RD_GEN` from `dashcamOnly` by @sunnyhaibin
|
||||
* GM: remove `CHEVROLET_BOLT_NON_ACC_2ND_GEN` from `dashcamOnly` by @sunnyhaibin
|
||||
* Hyundai Longitudinal: deprecate ramp update for dynamic tune by @Discountchubbs
|
||||
* Rivian: long upgrade messages on bus 1 by @lukasloetkolben
|
||||
* Toyota: Stop and Go Hack (Alpha) by @sunnyhaibin
|
||||
* Toyota: gate Smart DSU behind Alpha Longitudinal by @sunnyhaibin
|
||||
* Toyota: Gas Interceptor always set `standstill_req` by @sunnyhaibin
|
||||
* MADS safety: dedicated `controls_allowed_lateral` by @sunnyhaibin
|
||||
* Platform List: include community supported platforms by @sunnyhaibin
|
||||
* New Contributors (sunnypilot/sunnypilot)
|
||||
* @TheSecurityDev made their first contribution in "ui: fix sidebar scroll in UI screenshots"
|
||||
* @zikeji made their first contribution in "sunnylink: block remote modification of SSH key parameters"
|
||||
* @Candy0707 made their first contribution in "[TIZI/TICI] ui: Fix misaligned turn signals and blindspot indicators with sidebar"
|
||||
* @CHaucke89 made their first contribution in "Pause Lateral Control with Blinker: Post-Blinker Delay"
|
||||
* @yasu-oh made their first contribution in "SCC-V: Use p97 for predicted lateral accel"
|
||||
* @angaz made their first contribution in "[TIZI/TICI] visuals: Improved speed limit"
|
||||
* @jamesmikesell made their first contribution in "ICBM: ensure button timers update on disable to clear stale presses"
|
||||
* @zephleggett made their first contribution in "controlsd: fix steer_limited_by_safety not updating under MADS"
|
||||
* @lukasloetkolben made their first contribution in "pandad: flasher for Rivian long upgrade module"
|
||||
* @didlawowo made their first contribution in "i18n(fr): Add French translations"
|
||||
* @mmmorks made their first contribution in "NNLC: restore pre-v1 PID gains in torque extension"
|
||||
* New Contributors (sunnypilot/opendbc)
|
||||
* @AmyJeanes made their first contribution in "Tesla: Fix stock LKAS being blocked when MADS is enabled"
|
||||
* @mvl-boston made their first contribution in "Honda: Update Clarity brake to renamed DBC message name"
|
||||
@@ -99,6 +170,20 @@ sunnypilot Version 2026.001.000 (2026-03-xx)
|
||||
* @royjr made their first contribution in "HKG: add KIA_FORTE_2019_NON_SCC fingerprint"
|
||||
* @ssysm made their first contribution in "Tesla: remove `TESLA_MODEL_X` from `dashcamOnly`"
|
||||
* Full Changelog: https://github.com/sunnypilot/sunnypilot/compare/v2025.002.000...v2026.001.000
|
||||
************************
|
||||
* Synced with commaai's openpilot (v0.11.1)
|
||||
* master commit c001f3c9b490a80e69539f0af6022f6e07ceb721 (April 16, 2026)
|
||||
* New driver monitoring model
|
||||
* Improved image processing pipeline for driver camera
|
||||
* Rivian R1S and R1T 2025 support thanks to lukasloetkolben!
|
||||
* New driving model #36798
|
||||
* Fully trained using a learned simulator
|
||||
* Improved longitudinal performance in Experimental mode
|
||||
* Reduce comma four standby power usage by 77% to 52 mW
|
||||
* Kia K7 2017 support thanks to royjr!
|
||||
* Lexus LS 2018 support thanks to Hacheoy!
|
||||
* Improved inter-process communication memory efficiency
|
||||
* comma four support
|
||||
|
||||
sunnypilot Version 2025.002.000 (2025-11-06)
|
||||
========================
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
@@ -104,8 +105,15 @@ def collect_schema(root: Any) -> dict[str, dict]:
|
||||
return structs
|
||||
|
||||
|
||||
def dump_schema(path: str) -> None:
|
||||
from cereal import log
|
||||
def load_log(cereal_dir: str) -> Any:
|
||||
import capnp
|
||||
cereal_dir = os.path.abspath(cereal_dir)
|
||||
capnp.remove_import_hook()
|
||||
return capnp.load(os.path.join(cereal_dir, "log.capnp"), imports=[cereal_dir])
|
||||
|
||||
|
||||
def dump_schema(cereal_dir: str, path: str) -> None:
|
||||
log = load_log(cereal_dir)
|
||||
payload = {
|
||||
"root": hex_id(log.Event.schema.node.id),
|
||||
"structs": collect_schema(log.Event.schema),
|
||||
@@ -206,8 +214,8 @@ def load_peer(path: str) -> dict:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def run_read(peer_path: str) -> int:
|
||||
from cereal import log
|
||||
def run_read(cereal_dir: str, peer_path: str) -> int:
|
||||
log = load_log(cereal_dir)
|
||||
peer_dump = load_peer(peer_path)
|
||||
local_dump = {
|
||||
"root": hex_id(log.Event.schema.node.id),
|
||||
@@ -235,16 +243,13 @@ def main() -> int:
|
||||
mode.add_argument("-g", "--generate", action="store_true", help="dump local schema to JSON")
|
||||
mode.add_argument("-r", "--read", action="store_true", help="load peer JSON and diff against local")
|
||||
parser.add_argument("-f", "--file", default="schema.json", help="JSON file path (default: schema.json)")
|
||||
parser.add_argument("--cereal-dir", required=True, help="path to cereal directory containing log.capnp")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
if args.generate:
|
||||
dump_schema(args.file)
|
||||
return 0
|
||||
return run_read(args.file)
|
||||
except ImportError as exc:
|
||||
print(f"error: cannot import cereal ({exc}). did scons build cereal?")
|
||||
return 2
|
||||
if args.generate:
|
||||
dump_schema(args.cereal_dir, args.file)
|
||||
return 0
|
||||
return run_read(args.cereal_dir, args.file)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -218,6 +218,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}},
|
||||
|
||||
+1
-1
Submodule opendbc_repo updated: 7791360eca...cd5cfdb1ee
@@ -86,7 +86,7 @@ class Car:
|
||||
|
||||
self.can_callbacks = can_comm_callbacks(self.can_sock, self.pm.sock['sendcan'])
|
||||
|
||||
is_release = self.params.get_bool("IsReleaseBranch")
|
||||
is_release = False # self.params.get_bool("IsReleaseBranch")
|
||||
is_release_sp = self.params.get_bool("IsReleaseSpBranch")
|
||||
|
||||
if CI is None:
|
||||
|
||||
@@ -36,7 +36,7 @@ class DeveloperLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
self._is_release = self._params.get_bool("IsReleaseBranch")
|
||||
self._is_release = False # self._params.get_bool("IsReleaseBranch")
|
||||
|
||||
# Build items and keep references for callbacks/state updates
|
||||
self._adb_toggle = toggle_item(
|
||||
|
||||
@@ -42,7 +42,7 @@ class TogglesLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
self._is_release = self._params.get_bool("IsReleaseBranch")
|
||||
self._is_release = False # self._params.get_bool("IsReleaseBranch")
|
||||
|
||||
# param, title, desc, icon, needs_restart
|
||||
self._toggle_defs = {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -5,8 +5,9 @@ This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings import settings as OP
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsBigButton
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigCircleButton
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.models import ModelsLayoutMici
|
||||
@@ -32,11 +33,11 @@ class SettingsLayoutSP(OP.SettingsLayout):
|
||||
self.icon_offroad_slider = gui_app.texture("icons_mici/settings/device/lkas.png", BIG_ICON_SIZE, BIG_ICON_SIZE)
|
||||
|
||||
sunnylink_panel = SunnylinkLayoutMici(back_callback=gui_app.pop_widget)
|
||||
sunnylink_btn = BigButton("sunnylink", "", gui_app.texture("icons_mici/settings/developer/ssh.png", ICON_SIZE, ICON_SIZE))
|
||||
sunnylink_btn = SettingsBigButton(tr("sunnylink"), "", gui_app.texture("icons_mici/settings/developer/ssh.png", 55, 55))
|
||||
sunnylink_btn.set_click_callback(lambda: gui_app.push_widget(sunnylink_panel))
|
||||
|
||||
models_panel = ModelsLayoutMici(back_callback=gui_app.pop_widget)
|
||||
models_btn = BigButton("models", "", gui_app.texture("../../sunnypilot/selfdrive/assets/offroad/icon_models.png", ICON_SIZE, ICON_SIZE))
|
||||
models_btn = SettingsBigButton(tr("models"), "", gui_app.texture("../../sunnypilot/selfdrive/assets/offroad/icon_models.png", ICON_SIZE, ICON_SIZE))
|
||||
models_btn.set_click_callback(lambda: gui_app.push_widget(models_panel))
|
||||
|
||||
# onroad: enable button sits at the front (left of toggles)
|
||||
|
||||
@@ -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,20 @@ 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.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")
|
||||
self.custom_interactive_timeout: int = 0
|
||||
self._sp_initialized: bool = False
|
||||
|
||||
def update(self) -> None:
|
||||
if self.sunnylink_enabled:
|
||||
@@ -128,6 +126,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_constraints()
|
||||
self.active_bundle = self.params.get("ModelManager_ActiveBundle")
|
||||
self.blindspot = self.params.get_bool("BlindSpot")
|
||||
self.chevron_metrics = self.params.get("ChevronInfo")
|
||||
@@ -153,6 +153,53 @@ class UIStateSP:
|
||||
self.boot_offroad_mode = self.params.get("DeviceBootMode", return_default=True)
|
||||
self.always_offroad = self.params.get_bool("OffroadMode")
|
||||
|
||||
if not self._sp_initialized:
|
||||
self._sp_initialized = True
|
||||
self.reset_onroad_sleep_timer()
|
||||
|
||||
def _enforce_constraints(self) -> None:
|
||||
has_long = self.has_longitudinal_control
|
||||
CP = self.CP
|
||||
|
||||
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
|
||||
if not CP.alphaLongitudinalAvailable:
|
||||
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 or DEC
|
||||
if not has_long:
|
||||
self.params.remove("ExperimentalMode")
|
||||
self.params.remove("DynamicExperimentalControl")
|
||||
|
||||
# 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")
|
||||
self.has_icbm = False
|
||||
else:
|
||||
self.params.remove("IntelligentCruiseButtonManagement")
|
||||
self.has_icbm = False
|
||||
|
||||
# Cruise features requiring longitudinal or ICBM
|
||||
if not (has_long or self.has_icbm):
|
||||
self.params.remove("CustomAccIncrementsEnabled")
|
||||
self.params.remove("SmartCruiseControlVision")
|
||||
self.params.remove("SmartCruiseControlMap")
|
||||
|
||||
|
||||
class DeviceSP:
|
||||
@staticmethod
|
||||
@@ -168,7 +215,6 @@ class DeviceSP:
|
||||
if _ui_state.onroad_brightness_timer != 0:
|
||||
if _ui_state.onroad_brightness == OnroadBrightness.AUTO_DARK:
|
||||
return max(30.0, cur_brightness)
|
||||
# For AUTO (Default) and Manual modes (while timer running), use standard brightness
|
||||
return cur_brightness
|
||||
|
||||
# 0: Auto (Default), 1: Auto (Dark), 2: Screen Off
|
||||
|
||||
@@ -74,7 +74,7 @@ class UIState(UIStateSP):
|
||||
|
||||
# Core state variables
|
||||
self.is_metric: bool = self.params.get_bool("IsMetric")
|
||||
self.is_release = self.params.get_bool("IsReleaseBranch")
|
||||
self.is_release = False # self.params.get_bool("IsReleaseBranch")
|
||||
self.always_on_dm: bool = self.params.get_bool("AlwaysOnDM")
|
||||
self.started: bool = False
|
||||
self.ignition: bool = False
|
||||
|
||||
@@ -147,6 +147,7 @@ class ModularAssistiveDrivingSystem:
|
||||
self.events.remove(EventName.speedTooLow)
|
||||
self.events.remove(EventName.cruiseDisabled)
|
||||
self.events.remove(EventName.manualRestart)
|
||||
self.events.remove(EventName.espActive)
|
||||
|
||||
selfdrive_enable_events = self.events.has(EventName.pcmEnable) or self.events.has(EventName.buttonEnable)
|
||||
set_speed_btns_enable = any(be.type in SET_SPEED_BUTTONS for be in CS.buttonEvents)
|
||||
|
||||
@@ -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,8 +31,10 @@ 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')
|
||||
SUNNYLINK_ATHENA_HOST = os.getenv('SUNNYLINK_ATHENA_HOST', 'wss://athena.sunnylink.ai')
|
||||
HANDLER_THREADS = int(os.getenv('HANDLER_THREADS', "4"))
|
||||
LOCAL_PORT_WHITELIST = {8022}
|
||||
SUNNYLINK_LOG_ATTR_NAME = "user.sunny.upload"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -199,34 +204,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 from CarParams, compresses,
|
||||
and returns. Single RPC for the frontend to get the complete settings UI and
|
||||
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
|
||||
@@ -238,12 +227,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,
|
||||
@@ -274,6 +276,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")
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
When CarPlatformBundle is present, brand and platform come from the bundle
|
||||
(mirrors Raylib). CarParams* deserialization is the fallback before the bundle
|
||||
is written (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"] = False # 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=(",", ":"))
|
||||
@@ -0,0 +1,586 @@
|
||||
# sunnylink Settings UI Guide
|
||||
|
||||
> One YAML file per page. Edit, run the compiler, commit. The sunnylink frontend updates automatically.
|
||||
|
||||
## What you edit (and what's generated)
|
||||
|
||||
| File | What | When to edit |
|
||||
|------|------|-------------|
|
||||
| `settings_ui_src/pages/<page>.yaml` | One YAML per page (panel). Contains panel metadata + sections + items + sub_panels inline. | Adding/changing/removing a setting. |
|
||||
| `settings_ui_src/pages/vehicle.yaml` | Per-brand settings page (`kind: vehicle`). Each brand is a section. | Adding/changing a vehicle-specific setting. |
|
||||
| `settings_ui_src/_macros.yaml` | Named rule fragments referenced via `{$ref: "#/macros/<name>"}`. | Adding a reusable rule (e.g. a new platform gate). |
|
||||
| **`settings_ui.json`** | **Generated from src tree by `compile_settings_ui.py`. Do not edit by hand.** | Never. Compiler emits it; frontend reads it. |
|
||||
|
||||
Pages today: `steering, cruise, display, visuals, toggles, device, software, developer, models, vehicle` (10).
|
||||
|
||||
Run `python sunnypilot/sunnylink/tools/compile_settings_ui.py` after edits. Add `--check` in CI to fail on out-of-sync `settings_ui.json`.
|
||||
|
||||
Display metadata (titles, descriptions, options, min/max/step/unit) is inline on each item. There is no separate metadata file.
|
||||
|
||||
## Page file shape
|
||||
|
||||
A page YAML contains the whole panel: metadata at the top, then `sections`. Each section has its own `items` and (optionally) `sub_panels`. Sub-panels are nested inside the section they belong to. Items appear in the order written in the file.
|
||||
|
||||
```yaml
|
||||
# yaml-language-server: $schema=../_schemas/page.schema.json
|
||||
id: steering
|
||||
label: Steering
|
||||
icon: steering_wheel
|
||||
order: 1
|
||||
remote_configurable: true
|
||||
description: Lateral control, lane changes, and steering behavior
|
||||
|
||||
sections:
|
||||
- id: mads
|
||||
title: Modular Assistive Driving System (MADS)
|
||||
items:
|
||||
- key: Mads
|
||||
widget: toggle
|
||||
title: Enable Modular Assistive Driving System (MADS)
|
||||
description: |
|
||||
Enable the beloved MADS feature. Disable toggle to revert back
|
||||
to stock sunnypilot engagement/disengagement.
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
|
||||
sub_panels:
|
||||
- id: mads_settings
|
||||
label: MADS Settings
|
||||
trigger_key: Mads
|
||||
trigger_condition: {type: param, key: Mads, equals: true}
|
||||
items:
|
||||
- key: MadsMainCruiseAllowed
|
||||
widget: toggle
|
||||
title: Toggle with Main Cruise
|
||||
description: |
|
||||
Note: For vehicles without LFA/LKAS button, disabling this will
|
||||
prevent lateral control engagement.
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {$ref: "#/macros/mads_full_platforms"}
|
||||
```
|
||||
|
||||
The vehicle page has the same shape but declares `kind: vehicle`; each section's `id` becomes a brand key under `vehicle_settings` in the compiled JSON.
|
||||
|
||||
## Macros (named rule fragments)
|
||||
|
||||
`_macros.yaml` declares reusable rule lists. Reference them from any rules array via `{$ref: "#/macros/<name>"}`.
|
||||
|
||||
```yaml
|
||||
macros:
|
||||
offroad: [{type: offroad_only}]
|
||||
longitudinal: [{type: capability, field: has_longitudinal_control, equals: true}]
|
||||
mads_full_platforms:
|
||||
- type: not
|
||||
condition:
|
||||
type: any
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: rivian}
|
||||
- type: all
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: tesla}
|
||||
- type: not
|
||||
condition: {type: capability, field: tesla_has_vehicle_bus, equals: true}
|
||||
```
|
||||
|
||||
In an item:
|
||||
|
||||
```yaml
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {$ref: "#/macros/mads_full_platforms"}
|
||||
```
|
||||
|
||||
The compiler splices a list-context `$ref` into its parent list. Macros may reference other macros up to depth 3; cycles are an error.
|
||||
|
||||
## Compiler workflow
|
||||
|
||||
```
|
||||
1. common/params_keys.h — add/remove the C++ param key
|
||||
2. params_metadata.json — automated via update_params_metadata.py
|
||||
3. settings_ui_src/pages/<page>.yaml — add/edit/remove the item in the right section
|
||||
4. python sunnypilot/sunnylink/tools/compile_settings_ui.py
|
||||
5. python sunnypilot/sunnylink/tools/validate_settings_ui.py (or: --check on the compiler)
|
||||
6. uv run python -m pytest sunnypilot/sunnylink/tests/ # run regression + compiler tests
|
||||
7. commit
|
||||
```
|
||||
|
||||
CI runs `compile_settings_ui.py --check` to fail on hand-edited `settings_ui.json`.
|
||||
|
||||
## Compiled output reference (schema contract)
|
||||
|
||||
The tables below describe the **compiled** `settings_ui.json` schema — what the frontend consumes at runtime. JSON snippets show the wire shape; in the src tree you author YAML that compiles to the same shape. Use these as a contract reference for valid fields, their meanings, and rule types.
|
||||
|
||||
## 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 | Inline explanatory text below the title. May be empty when only `details` is used. |
|
||||
| `details` | No | Extended help text shown in a modal when the user taps an "i" button on the row. Independent of `description`: either, both, or neither may be present. |
|
||||
| `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 |
|
||||
| `not_engaged` | `{"type": "not_engaged"}` | Grey out only while engaged (started + selfdrive/MADS active) |
|
||||
| `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`) |
|
||||
| `$ref` | `{"$ref": "#/macros/offroad"}` | Reference a named rule fragment in `_macros.yaml` |
|
||||
|
||||
**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
|
||||
|
||||
### Pick a writability rule (offroad / not_engaged / param-based)
|
||||
|
||||
| Use this | When | Why |
|
||||
|---|---|---|
|
||||
| `offroad_only` | Param can only be safely changed when the car is parked. Most user-facing toggles. | Strictest. Frontend shows "device is driving" badge and disables the row. |
|
||||
| `not_engaged` | Param can be changed while the car is started but only when sunnypilot/MADS is **not** actively driving. | Less strict than offroad. Matches Raylib `engaged = started AND (selfdriveState.enabled OR mads.enabled)`. Use for items the device must apply mid-drive (e.g. test maneuvers, longitudinal stock-vs-OP toggle). |
|
||||
| `param`-based | Behavior depends on another setting's value (parent toggle, mode selector, etc.). | Composes with `not`/`any`/`all` for arbitrary logic. |
|
||||
| `capability`-based | Behavior depends on the connected car or device (brand, longitudinal, hardware). | Resolved on the device from `CarParams` / hardware. See [`capabilities.py`](../capabilities.py) for the full field list. |
|
||||
| (no rule) | Param is always writable, no gating. | Rare. Prefer at least `offroad_only` unless the param is genuinely safe to flip mid-drive. |
|
||||
|
||||
Default for new toggles: `enablement: [{$ref: "#/macros/offroad"}]`. Drop down to `not_engaged` only if you've confirmed mid-drive write is safe in the controls/UI code path.
|
||||
|
||||
### Use `details` for safety notes / extended help
|
||||
|
||||
Inline `description` shows under the title. For longer caveats, safety notes, or "learn more" content, use `details` — the frontend renders an info button that opens a modal. Either field may be present alone or both together.
|
||||
|
||||
```yaml
|
||||
- key: AutoLaneChangeTimer
|
||||
widget: option
|
||||
title: Auto Lane Change by Blinker
|
||||
description: |-
|
||||
Set a timer to delay the auto lane change operation when the blinker is used.
|
||||
No nudge on the steering wheel is required to auto lane change if a timer is set.
|
||||
Default is Nudge.
|
||||
details: |-
|
||||
Please use caution when using this feature. Only use the blinker when traffic
|
||||
and road conditions permit.
|
||||
options: [...]
|
||||
```
|
||||
|
||||
For an item that is intentionally minimal inline (no inline body, only the modal):
|
||||
|
||||
```yaml
|
||||
- key: SomeAdvancedToggle
|
||||
widget: toggle
|
||||
title: Some Advanced Feature
|
||||
details: |-
|
||||
Long-form rationale, caveats, links, etc. — kept entirely behind the info button.
|
||||
```
|
||||
|
||||
### Add a toggle
|
||||
|
||||
1. Register in `common/params_keys.h`:
|
||||
```cpp
|
||||
{"MyToggle", {PERSISTENT | BACKUP, BOOL}},
|
||||
```
|
||||
|
||||
2. Open `settings_ui_src/pages/<page>.yaml`. Add the item to the right section:
|
||||
```yaml
|
||||
- key: MyToggle
|
||||
widget: toggle
|
||||
title: My Feature
|
||||
description: What this feature does.
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
```
|
||||
|
||||
If changing the param requires an onroad cycle to take effect, add `needs_onroad_cycle: true`.
|
||||
|
||||
3. Compile + validate + test:
|
||||
```
|
||||
python sunnypilot/sunnylink/tools/compile_settings_ui.py
|
||||
python sunnypilot/sunnylink/tools/validate_settings_ui.py
|
||||
uv run python -m pytest sunnypilot/sunnylink/tests/
|
||||
```
|
||||
|
||||
### Add a multi-button option
|
||||
|
||||
```yaml
|
||||
- key: MySelector
|
||||
widget: multiple_button
|
||||
title: Mode
|
||||
options:
|
||||
- {value: 0, label: Off}
|
||||
- {value: 1, label: On}
|
||||
- {value: 2, label: Auto}
|
||||
```
|
||||
|
||||
### Add a slider or range
|
||||
|
||||
```yaml
|
||||
- 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 units
|
||||
|
||||
```yaml
|
||||
- key: MinSpeed
|
||||
widget: option
|
||||
title: Minimum Speed
|
||||
min: 0
|
||||
max: 100
|
||||
step: 5
|
||||
unit: {metric: km/h, imperial: mph}
|
||||
```
|
||||
|
||||
Frontend resolves the unit string based on the device's `IsMetric` param. Static units (e.g. `seconds`, `m/s²`) stay plain strings.
|
||||
|
||||
### Add a dynamic title suffix
|
||||
|
||||
```yaml
|
||||
- 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
|
||||
```
|
||||
|
||||
Renders as "Follow Distance: mph" / "Follow Distance: km/h".
|
||||
|
||||
### Add a device-only read-only setting
|
||||
|
||||
```yaml
|
||||
- key: OnroadCyclePendingRemote
|
||||
widget: info
|
||||
title: Pending Remote Cycle
|
||||
blocked: true
|
||||
```
|
||||
|
||||
Frontend treats `blocked: true` items as read-only.
|
||||
|
||||
### Add a dropdown option
|
||||
|
||||
```yaml
|
||||
- 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 rules
|
||||
|
||||
```yaml
|
||||
- key: MadsSteeringMode
|
||||
widget: multiple_button
|
||||
title: Steering Mode on Brake Pedal
|
||||
options:
|
||||
- value: 0
|
||||
label: Remain Active
|
||||
enablement:
|
||||
- {$ref: "#/macros/mads_full_platforms"}
|
||||
- value: 1
|
||||
label: Pause
|
||||
enablement:
|
||||
- {$ref: "#/macros/mads_full_platforms"}
|
||||
- value: 2
|
||||
label: Disengage
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
```
|
||||
|
||||
When an option's enablement fails, that option is grayed out but still visible.
|
||||
|
||||
### Show only when another setting is on
|
||||
|
||||
```yaml
|
||||
- key: ChildSetting
|
||||
widget: toggle
|
||||
title: Child Feature
|
||||
visibility:
|
||||
- {type: param, key: ParentToggle, equals: true}
|
||||
```
|
||||
|
||||
(With the "dim instead of hide" design, this setting is dimmed, not hidden, when the rule fails.)
|
||||
|
||||
### Show only for specific brands
|
||||
|
||||
```yaml
|
||||
- key: LongFeature
|
||||
widget: toggle
|
||||
title: Longitudinal Feature
|
||||
visibility:
|
||||
- {$ref: "#/macros/longitudinal"}
|
||||
```
|
||||
|
||||
### Combine multiple conditions
|
||||
|
||||
The `enablement` array is implicit-AND: every entry must pass. Use `any` for OR, `all` for nested AND, `not` for negation. Wrap repeated combinations in a macro so future you doesn't re-derive the logic.
|
||||
|
||||
**AND across two params** (writable only when both Mads is on AND ICBM is enabled):
|
||||
```yaml
|
||||
enablement:
|
||||
- {type: param, key: Mads, equals: true}
|
||||
- {type: param, key: IntelligentCruiseButtonManagement, equals: true}
|
||||
```
|
||||
|
||||
**OR across two params** (writable when either is on):
|
||||
```yaml
|
||||
enablement:
|
||||
- type: any
|
||||
conditions:
|
||||
- {type: param, key: ExperimentalMode, equals: true}
|
||||
- {type: param, key: DynamicExperimentalControl, equals: true}
|
||||
```
|
||||
|
||||
**Mixed: capability AND param** (only on longitudinal cars when ShowAdvancedControls is on):
|
||||
```yaml
|
||||
enablement:
|
||||
- {$ref: "#/macros/longitudinal"}
|
||||
- {$ref: "#/macros/advanced_only"}
|
||||
```
|
||||
|
||||
**Three-way: offroad AND torque-allowed AND not-NNLC** (real example: `EnforceTorqueControl`):
|
||||
```yaml
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {type: capability, field: torque_allowed, equals: true}
|
||||
- {type: param, key: NeuralNetworkLateralControl, equals: false}
|
||||
```
|
||||
|
||||
**Negation across multiple platforms** (everything except Rivian + Tesla-no-bus):
|
||||
```yaml
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {$ref: "#/macros/mads_full_platforms"} # macro encapsulates the not(any(rivian, all(tesla, not(bus)))) logic
|
||||
```
|
||||
|
||||
If the same multi-condition block appears in 2+ items, **promote it to a macro** in `_macros.yaml`. Re-run `python sunnypilot/sunnylink/tools/apply_macros.py` to substitute existing inlined matches automatically.
|
||||
|
||||
### Mutual exclusion
|
||||
|
||||
```yaml
|
||||
- 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 section
|
||||
|
||||
In the page YAML, add an entry to the `sections` list:
|
||||
```yaml
|
||||
sections:
|
||||
- id: my_section
|
||||
title: My Section
|
||||
description: Optional subtitle
|
||||
enablement:
|
||||
- {$ref: "#/macros/longitudinal"}
|
||||
items:
|
||||
- {key: ..., widget: toggle, title: ...}
|
||||
```
|
||||
|
||||
Sections support `visibility`, `enablement`, and `attestation_required`. When section-level rules fail, all items within are dimmed.
|
||||
|
||||
### Add a sub-panel
|
||||
|
||||
Sub-panels nest inside the section they belong to:
|
||||
```yaml
|
||||
sections:
|
||||
- id: parent_section
|
||||
title: Parent
|
||||
items: [...]
|
||||
sub_panels:
|
||||
- id: my_sub
|
||||
label: Advanced Settings
|
||||
trigger_key: ParentParam
|
||||
trigger_condition: {type: param, key: ParentParam, equals: true}
|
||||
items:
|
||||
- {key: ..., widget: toggle, title: ...}
|
||||
```
|
||||
|
||||
### Add vehicle-brand settings
|
||||
|
||||
Edit `pages/vehicle.yaml`. Each section is a brand:
|
||||
```yaml
|
||||
id: vehicle
|
||||
kind: vehicle
|
||||
sections:
|
||||
- id: rivian
|
||||
title: Rivian Settings
|
||||
description: ''
|
||||
items:
|
||||
- key: RivianFeature
|
||||
widget: toggle
|
||||
title: Rivian One Pedal
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
```
|
||||
|
||||
`kind: vehicle` tells the compiler to emit this page as `vehicle_settings.<brand>` in the wire JSON.
|
||||
|
||||
### Add a feature with toggles, sub-panel, and macro
|
||||
|
||||
Example: "Smart Wipers" with a master toggle, intensity selector, and sub-panel for advanced tuning, gated to torque-steering Hyundais on offroad.
|
||||
|
||||
1. **Param keys** — register all 4 in `common/params_keys.h`.
|
||||
|
||||
2. **Decide on a macro** — if "torque Hyundai" gating is reused, add to `_macros.yaml`:
|
||||
```yaml
|
||||
torque_hyundai:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {type: capability, field: brand, equals: hyundai}
|
||||
- {type: capability, field: torque_allowed, equals: true}
|
||||
```
|
||||
|
||||
3. **Edit the relevant page** — `pages/visuals.yaml` (or wherever the feature lives). Add a new section + sub_panel:
|
||||
```yaml
|
||||
sections:
|
||||
- id: smart_wipers
|
||||
title: Smart Wipers
|
||||
description: Camera-driven wiper control (Hyundai/Kia, torque only)
|
||||
items:
|
||||
- key: SmartWipersEnabled
|
||||
widget: toggle
|
||||
title: Enable Smart Wipers
|
||||
enablement:
|
||||
- {$ref: "#/macros/torque_hyundai"}
|
||||
- key: SmartWipersIntensity
|
||||
widget: multiple_button
|
||||
title: Sensitivity
|
||||
options:
|
||||
- {value: 0, label: Low}
|
||||
- {value: 1, label: Medium}
|
||||
- {value: 2, label: High}
|
||||
visibility:
|
||||
- {type: param, key: SmartWipersEnabled, equals: true}
|
||||
enablement:
|
||||
- {$ref: "#/macros/torque_hyundai"}
|
||||
sub_panels:
|
||||
- id: smart_wipers_tuning
|
||||
label: Smart Wipers Tuning
|
||||
trigger_key: SmartWipersEnabled
|
||||
trigger_condition: {type: param, key: SmartWipersEnabled, equals: true}
|
||||
items:
|
||||
- key: SmartWipersHysteresis
|
||||
widget: option
|
||||
title: Hysteresis (frames)
|
||||
min: 1
|
||||
max: 30
|
||||
step: 1
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {$ref: "#/macros/advanced_only"}
|
||||
```
|
||||
|
||||
4. **Compile / validate / test**:
|
||||
```
|
||||
python sunnypilot/sunnylink/tools/compile_settings_ui.py
|
||||
python sunnypilot/sunnylink/tools/validate_settings_ui.py
|
||||
uv run python -m pytest sunnypilot/sunnylink/tests/
|
||||
```
|
||||
|
||||
`apply_macros.py` is automatic for newly-added items only if you wrote the rule list inline; for greenfield items, you'd write `$ref` directly.
|
||||
|
||||
### Change a toggle's behavior
|
||||
|
||||
1. Find the item in `pages/<page>.yaml`.
|
||||
2. Edit `visibility`/`enablement`/`options[].enablement` directly. Use macros where possible.
|
||||
3. **Add a regression test** in `sunnypilot/sunnylink/tests/test_settings_changes.py` that asserts the new gate exists. Use existing tests (e.g. `TestMadsBrandGates`, `TestNotEngagedReplacement`) as templates: lookup item by key, assert `_references_capability_field(rules, "...")` or `_flatten_rule_types(rules)` contains/excludes a type. This freezes the new behavior so a future edit won't silently revert it.
|
||||
4. Compile + run the full suite. Per-bug test should pass; structural tests should remain green.
|
||||
|
||||
### Change a widget type or options
|
||||
|
||||
Editing `widget:` from `toggle` to `multiple_button` is a frontend behavior change. Whenever you change widget shape:
|
||||
- The param's underlying type (bool / int / string) must match what the new widget writes. `toggle` writes bool; `multiple_button`/`option` write int/string. Update `params_keys.h` if the type changes.
|
||||
- Add an `options:` list when switching to `multiple_button` or `option`.
|
||||
- Old values stored on devices may not be valid for the new widget. Consider a migration in `sunnypilot/system/updated/` if users have stale values.
|
||||
|
||||
### Deprecate or remove a setting
|
||||
|
||||
1. Remove the item from `pages/<page>.yaml`.
|
||||
2. Remove the param key from `common/params_keys.h` **only after** confirming nothing in `selfdrive/`, `sunnypilot/`, or any controls code reads it.
|
||||
3. If the param has been on user devices, drop it via a migration (see `sunnypilot/system/updated/`) so stale values don't linger.
|
||||
4. Compile + validate + test. The validator's "no duplicate keys" + structural checks will fail if anything still references the removed key.
|
||||
|
||||
### Move a setting to another page
|
||||
|
||||
Cut the item block from one page YAML, paste into the target page's section. Compile + validate. The "no duplicate keys" check catches forgotten copies.
|
||||
|
||||
### Change display text
|
||||
|
||||
Edit `title:` or `description:` in the page YAML and recompile to regenerate `settings_ui.json`.
|
||||
|
||||
### Reorder sections, sub-panels, and items
|
||||
|
||||
Reorder them within their parent list in the YAML. The compiler preserves authored order — no `order:` field required at the section/sub_panel/item level (panel-level `order:` controls which page comes first in the side nav).
|
||||
|
||||
---
|
||||
|
||||
### Capability labels and tooltips
|
||||
|
||||
The schema response includes `capability_labels`, which map capability field names to descriptions. The frontend uses these 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_constraints()`, which removes incompatible params based on car capabilities. This is the single source of truth for such constraints.
|
||||
|
||||
Settings layouts should not duplicate these params.remove() calls. Instead, rely on schema rules and centralized enforcement to prevent duplicate logic and ensure consistency.
|
||||
|
||||
Example constraints in `_enforce_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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,516 @@
|
||||
{
|
||||
"$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. Rendered inline below the title. May be empty when only `details` is used."
|
||||
},
|
||||
"details": {
|
||||
"type": "string",
|
||||
"description": "Extended help text shown in a popover/modal when the user taps an info ('i') button on the row. Independent of `description`: either, both, or neither may be present."
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# Named rule fragments. Reference from items/sections via {$ref: "#/macros/<name>"}.
|
||||
# Macros may $ref other macros (max depth 3 — see compile_settings_ui.py). No template logic.
|
||||
#
|
||||
# Adding a macro: define here once, then reference everywhere. The compiler
|
||||
# resolves $refs into the canonical settings_ui.json output the frontend reads.
|
||||
|
||||
macros:
|
||||
# Most-used: only writable when the device is offroad.
|
||||
offroad:
|
||||
- {type: offroad_only}
|
||||
|
||||
# Writable while not engaged (started, but selfdrive/MADS not active).
|
||||
not_engaged:
|
||||
- {type: not_engaged}
|
||||
|
||||
# sunnypilot longitudinal control is active.
|
||||
longitudinal:
|
||||
- {type: capability, field: has_longitudinal_control, equals: true}
|
||||
|
||||
# Longitudinal + ICBM both available.
|
||||
longitudinal_and_icbm:
|
||||
- {type: capability, field: has_longitudinal_control, equals: true}
|
||||
- {type: capability, field: has_icbm, equals: true}
|
||||
|
||||
# Item only meaningful when "Show Advanced Controls" is enabled by the user.
|
||||
advanced_only:
|
||||
- {type: param, key: ShowAdvancedControls, equals: true}
|
||||
|
||||
# Hide on MICI hardware (no analog HUD support yet).
|
||||
hide_on_mici:
|
||||
- type: not
|
||||
condition: {type: capability, field: device_type, equals: mici}
|
||||
|
||||
# Mirrors selfdrive/ui/sunnypilot/layouts/.../mads_settings.py:_mads_limited_settings()
|
||||
# Rivian + Tesla-without-vehicle-bus get the limited MADS UI (3-toggle subset).
|
||||
# On those platforms these toggles are disabled — full MADS settings are
|
||||
# only writable on platforms NOT in the limited-set.
|
||||
mads_full_platforms:
|
||||
- type: not
|
||||
condition:
|
||||
type: any
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: rivian}
|
||||
- type: all
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: tesla}
|
||||
- type: not
|
||||
condition: {type: capability, field: tesla_has_vehicle_bus, equals: true}
|
||||
|
||||
# Inverse of mads_full_platforms: present only on the limited platforms.
|
||||
# Useful for "show this only on rivian/tesla-no-bus" toggles.
|
||||
mads_limited_platforms:
|
||||
- type: any
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: rivian}
|
||||
- type: all
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: tesla}
|
||||
- type: not
|
||||
condition: {type: capability, field: tesla_has_vehicle_bus, equals: true}
|
||||
|
||||
# Hide on sunnypilot release branches (is_release is hardcoded False everywhere; is_sp_release is the active gate).
|
||||
release_branches_hide:
|
||||
- type: not
|
||||
condition: {type: capability, field: is_sp_release, equals: true}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sunnypilot.com/schemas/sdui/macros.schema.json",
|
||||
"title": "Settings UI macros (named rule fragments)",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["macros"],
|
||||
"properties": {
|
||||
"macros": {
|
||||
"type": "object",
|
||||
"description": "Named rule fragments. Each value is either a list of rules (typical) or a single rule object. Reference from items/layout via {$ref: '#/macros/<name>'}.",
|
||||
"patternProperties": {
|
||||
"^[A-Za-z_][A-Za-z0-9_]*$": {
|
||||
"oneOf": [
|
||||
{"type": "array", "items": {"$ref": "rule.schema.json"}},
|
||||
{"$ref": "rule.schema.json"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sunnypilot.com/schemas/sdui/page.schema.json",
|
||||
"title": "Settings UI page (panel) YAML",
|
||||
"description": "Validates pages/<id>.yaml. Each page describes one settings panel (or the vehicle namespace via kind: vehicle).",
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"label": {"type": "string"},
|
||||
"icon": {"type": "string"},
|
||||
"order": {"type": "integer"},
|
||||
"remote_configurable": {"type": "boolean"},
|
||||
"description": {"type": "string"},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["panel", "vehicle"],
|
||||
"description": "panel (default) or vehicle (compiles to settings_ui.json#vehicle_settings)."
|
||||
},
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Section"}
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Item"}
|
||||
},
|
||||
"sub_panels": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/SubPanel"}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"Section": {
|
||||
"type": "object",
|
||||
"required": ["id", "title"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"order": {"type": "integer"},
|
||||
"attestation_required": {"type": "boolean"},
|
||||
"visibility": {"type": "array", "items": {"$ref": "rule.schema.json"}},
|
||||
"enablement": {"type": "array", "items": {"$ref": "rule.schema.json"}},
|
||||
"items": {"type": "array", "items": {"$ref": "#/$defs/Item"}},
|
||||
"sub_panels": {"type": "array", "items": {"$ref": "#/$defs/SubPanel"}}
|
||||
}
|
||||
},
|
||||
"SubPanel": {
|
||||
"type": "object",
|
||||
"required": ["id", "label"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"label": {"type": "string"},
|
||||
"trigger_key": {"type": ["string", "null"]},
|
||||
"trigger_condition": {"$ref": "rule.schema.json"},
|
||||
"items": {"type": "array", "items": {"$ref": "#/$defs/Item"}}
|
||||
}
|
||||
},
|
||||
"Item": {
|
||||
"type": "object",
|
||||
"required": ["key", "widget"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"},
|
||||
"widget": {"type": "string", "enum": ["toggle", "option", "multiple_button", "button", "info"]},
|
||||
"title": {"type": "string"},
|
||||
"description": {"type": "string", "description": "Inline body text under the title. May be omitted when only details is used."},
|
||||
"details": {"type": "string", "description": "Extended help shown in a modal when the user taps the info (i) button. Independent of description; either, both, or neither may be present."},
|
||||
"title_param_suffix": {"type": "object"},
|
||||
"min": {"type": "number"},
|
||||
"max": {"type": "number"},
|
||||
"step": {"type": "number"},
|
||||
"unit": {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["metric", "imperial"],
|
||||
"properties": {
|
||||
"metric": {"type": "string"},
|
||||
"imperial": {"type": "string"}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"needs_onroad_cycle": {"type": "boolean"},
|
||||
"requires_attestation": {"type": "boolean"},
|
||||
"blocked": {"type": "boolean"},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["value", "label"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"value": {},
|
||||
"label": {"type": "string"},
|
||||
"enablement": {"type": "array", "items": {"$ref": "rule.schema.json"}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"visibility": {"type": "array", "items": {"$ref": "rule.schema.json"}},
|
||||
"enablement": {"type": "array", "items": {"$ref": "rule.schema.json"}},
|
||||
"sub_items": {"type": "array", "items": {"$ref": "#/$defs/Item"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sunnypilot.com/schemas/sdui/rule.schema.json",
|
||||
"title": "Rule",
|
||||
"description": "Visibility/enablement rule. Discriminated union on 'type'. Macro reference is also accepted via {$ref: '#/macros/<name>'}.",
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/MacroRef"},
|
||||
{"$ref": "#/$defs/RuleOffroadOnly"},
|
||||
{"$ref": "#/$defs/RuleNotEngaged"},
|
||||
{"$ref": "#/$defs/RuleCapability"},
|
||||
{"$ref": "#/$defs/RuleParam"},
|
||||
{"$ref": "#/$defs/RuleParamCompare"},
|
||||
{"$ref": "#/$defs/RuleNot"},
|
||||
{"$ref": "#/$defs/RuleAny"},
|
||||
{"$ref": "#/$defs/RuleAll"}
|
||||
],
|
||||
"$defs": {
|
||||
"MacroRef": {
|
||||
"type": "object",
|
||||
"required": ["$ref"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$ref": {
|
||||
"type": "string",
|
||||
"pattern": "^#/macros/[A-Za-z_][A-Za-z0-9_]*$",
|
||||
"description": "Reference to a macro defined in _macros.yaml under #/macros/<name>."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleOffroadOnly": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {"type": {"const": "offroad_only"}}
|
||||
},
|
||||
"RuleNotEngaged": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {"type": {"const": "not_engaged"}}
|
||||
},
|
||||
"RuleCapability": {
|
||||
"type": "object",
|
||||
"required": ["type", "field", "equals"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "capability"},
|
||||
"field": {"type": "string"},
|
||||
"equals": {}
|
||||
}
|
||||
},
|
||||
"RuleParam": {
|
||||
"type": "object",
|
||||
"required": ["type", "key", "equals"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "param"},
|
||||
"key": {"type": "string"},
|
||||
"equals": {}
|
||||
}
|
||||
},
|
||||
"RuleParamCompare": {
|
||||
"type": "object",
|
||||
"required": ["type", "key", "op", "value"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "param_compare"},
|
||||
"key": {"type": "string"},
|
||||
"op": {"type": "string", "enum": [">", "<", ">=", "<="]},
|
||||
"value": {"type": "number"}
|
||||
}
|
||||
},
|
||||
"RuleNot": {
|
||||
"type": "object",
|
||||
"required": ["type", "condition"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "not"},
|
||||
"condition": {"$ref": "#"}
|
||||
}
|
||||
},
|
||||
"RuleAny": {
|
||||
"type": "object",
|
||||
"required": ["type", "conditions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "any"},
|
||||
"conditions": {"type": "array", "items": {"$ref": "#"}, "minItems": 1}
|
||||
}
|
||||
},
|
||||
"RuleAll": {
|
||||
"type": "object",
|
||||
"required": ["type", "conditions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "all"},
|
||||
"conditions": {"type": "array", "items": {"$ref": "#"}, "minItems": 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
# Page: cruise
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: cruise
|
||||
label: Cruise
|
||||
icon: cruise_control
|
||||
order: 2
|
||||
remote_configurable: true
|
||||
description: Longitudinal control, speed limits, and cruise behavior
|
||||
sections:
|
||||
- id: core_cruise_features
|
||||
title: ''
|
||||
description: ''
|
||||
items:
|
||||
- key: ExperimentalMode
|
||||
widget: toggle
|
||||
title: Experimental Mode
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: DynamicExperimentalControl
|
||||
widget: toggle
|
||||
title: Dynamic Experimental Control
|
||||
description: Let the model decide when to use sunnypilot ACC or sunnypilot End to End Longitudinal.
|
||||
visibility:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: DisengageOnAccelerator
|
||||
widget: toggle
|
||||
title: Disengage Cruise on Accelerator Pedal
|
||||
description: When enabled, pressing the accelerator pedal will disengage longitudinal control.
|
||||
- key: LongitudinalPersonality
|
||||
widget: multiple_button
|
||||
title: Driving Personality
|
||||
description: Standard is recommended. In aggressive mode, sunnypilot will follow lead cars closer and be more aggressive
|
||||
with the gas and brake. In relaxed mode sunnypilot will stay further away from lead cars. On supported cars, you can
|
||||
cycle through these personalities with your steering wheel distance button.
|
||||
options:
|
||||
- value: 0
|
||||
label: Aggressive
|
||||
- value: 1
|
||||
label: Standard
|
||||
- value: 2
|
||||
label: Relaxed
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: IntelligentCruiseButtonManagement
|
||||
widget: toggle
|
||||
title: Intelligent Cruise Button Management (ICBM) (Alpha)
|
||||
visibility:
|
||||
- type: capability
|
||||
field: icbm_available
|
||||
equals: true
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: not
|
||||
condition:
|
||||
type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- id: custom_acc_increments
|
||||
title: Custom ACC Speed Intervals
|
||||
description: ''
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: any
|
||||
conditions:
|
||||
- type: all
|
||||
conditions:
|
||||
- type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- type: not
|
||||
condition:
|
||||
type: capability
|
||||
field: pcm_cruise
|
||||
equals: true
|
||||
- type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
items:
|
||||
- key: CustomAccIncrementsEnabled
|
||||
widget: toggle
|
||||
title: Enable Custom ACC Speed Intervals
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: any
|
||||
conditions:
|
||||
- type: all
|
||||
conditions:
|
||||
- type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- type: not
|
||||
condition:
|
||||
type: capability
|
||||
field: pcm_cruise
|
||||
equals: true
|
||||
- type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
sub_panels:
|
||||
- id: custom_acc_intervals
|
||||
label: Custom ACC Speed Intervals Settings
|
||||
trigger_key: CustomAccIncrementsEnabled
|
||||
trigger_condition:
|
||||
type: param
|
||||
key: CustomAccIncrementsEnabled
|
||||
equals: true
|
||||
items:
|
||||
- key: CustomAccShortPressIncrement
|
||||
widget: option
|
||||
title: Short Press Increment
|
||||
min: 1
|
||||
max: 10
|
||||
step: 1
|
||||
enablement:
|
||||
- type: param
|
||||
key: CustomAccIncrementsEnabled
|
||||
equals: true
|
||||
- key: CustomAccLongPressIncrement
|
||||
widget: option
|
||||
title: Long Press Increment
|
||||
min: 1
|
||||
max: 10
|
||||
step: 1
|
||||
enablement:
|
||||
- type: param
|
||||
key: CustomAccIncrementsEnabled
|
||||
equals: true
|
||||
- id: speed_limits
|
||||
title: Speed Limits
|
||||
description: Speed limit detection and offset behavior
|
||||
items: []
|
||||
sub_panels:
|
||||
- id: speed_limit_settings
|
||||
label: Speed Limit Settings
|
||||
trigger_key: SpeedLimitMode
|
||||
items:
|
||||
- key: SpeedLimitMode
|
||||
widget: multiple_button
|
||||
title: Speed Limit Assist Mode
|
||||
options:
|
||||
- value: 0
|
||||
label: 'Off'
|
||||
- value: 1
|
||||
label: Information
|
||||
- value: 2
|
||||
label: Warning
|
||||
- value: 3
|
||||
label: Assist
|
||||
enablement:
|
||||
- type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
- type: not
|
||||
condition:
|
||||
type: capability
|
||||
field: brand
|
||||
equals: rivian
|
||||
- type: not
|
||||
condition:
|
||||
type: all
|
||||
conditions:
|
||||
- type: capability
|
||||
field: brand
|
||||
equals: tesla
|
||||
- type: capability
|
||||
field: is_sp_release
|
||||
equals: true
|
||||
- key: SpeedLimitPolicy
|
||||
widget: multiple_button
|
||||
title: Speed Limit Source
|
||||
options:
|
||||
- value: 0
|
||||
label: Car State Only
|
||||
- value: 1
|
||||
label: Map Data Only
|
||||
- value: 2
|
||||
label: Car State Priority
|
||||
- value: 3
|
||||
label: Map Data Priority
|
||||
- value: 4
|
||||
label: Combined
|
||||
- key: SpeedLimitOffsetType
|
||||
widget: multiple_button
|
||||
title: Speed Limit Offset Type
|
||||
options:
|
||||
- value: 0
|
||||
label: 'Off'
|
||||
- value: 1
|
||||
label: Fixed
|
||||
- value: 2
|
||||
label: Percentage
|
||||
- key: SpeedLimitValueOffset
|
||||
widget: option
|
||||
title: Speed Limit Offset Value
|
||||
min: -30
|
||||
max: 30
|
||||
step: 1
|
||||
unit:
|
||||
metric: km/h
|
||||
imperial: mph
|
||||
visibility:
|
||||
- type: param_compare
|
||||
key: SpeedLimitOffsetType
|
||||
op: '>'
|
||||
value: 0
|
||||
- id: smart_cruise
|
||||
title: Smart Cruise Control
|
||||
description: ''
|
||||
enablement:
|
||||
- type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
items:
|
||||
- key: SmartCruiseControlVision
|
||||
widget: toggle
|
||||
title: Vision
|
||||
description: Use vision path predictions to estimate the appropriate speed to drive through turns ahead.
|
||||
visibility:
|
||||
- type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
enablement:
|
||||
- type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
- key: SmartCruiseControlMap
|
||||
widget: toggle
|
||||
title: Map
|
||||
description: Use map data to estimate the appropriate speed to drive through turns ahead.
|
||||
visibility:
|
||||
- type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
enablement:
|
||||
- type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: has_longitudinal_control
|
||||
equals: true
|
||||
- type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
@@ -0,0 +1,137 @@
|
||||
# Page: developer
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: developer
|
||||
label: Developer
|
||||
icon: developer
|
||||
order: 9
|
||||
remote_configurable: true
|
||||
description: Debug tools, remote access, and advanced services
|
||||
sections:
|
||||
- id: connectivity
|
||||
title: Connectivity
|
||||
description: Remote access and debugging interfaces
|
||||
items:
|
||||
- key: AdbEnabled
|
||||
widget: toggle
|
||||
blocked: true
|
||||
title: Enable ADB
|
||||
description: ADB (Android Debug Bridge) allows connecting to your device over USB or over the network. See https://docs.comma.ai/how-to/connect-to-comma
|
||||
for more info.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- key: SshEnabled
|
||||
widget: toggle
|
||||
blocked: true
|
||||
title: Enable SSH
|
||||
- key: JoystickDebugMode
|
||||
widget: toggle
|
||||
title: Joystick Debug Mode
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- key: AlphaLongitudinalEnabled
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: sunnypilot Longitudinal Control (Alpha)
|
||||
description: 'WARNING: sunnypilot longitudinal control is in alpha for this car and will disable Automatic Emergency Braking
|
||||
(AEB). On this car, sunnypilot defaults to the car''s built-in ACC instead of sunnypilot''s longitudinal control. Enable
|
||||
this to switch to sunnypilot longitudinal control. Enabling Experimental mode is recommended when enabling sunnypilot
|
||||
longitudinal control alpha. Changing this setting will restart sunnypilot if the car is powered on.'
|
||||
visibility:
|
||||
- type: all
|
||||
conditions:
|
||||
- type: capability
|
||||
field: alpha_long_available
|
||||
equals: true
|
||||
- type: not
|
||||
condition:
|
||||
type: capability
|
||||
field: has_icbm
|
||||
equals: true
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- key: ShowDebugInfo
|
||||
widget: toggle
|
||||
title: UI Debug Mode
|
||||
- id: test_maneuvers
|
||||
title: Test Maneuvers
|
||||
description: 'DANGER: enabling these maneuvers replaces normal driving behavior with deterministic test sequences. Each
|
||||
toggle requires explicit confirmation per write. Use only in a closed environment.'
|
||||
visibility:
|
||||
- type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: is_development
|
||||
equals: true
|
||||
- type: capability
|
||||
field: is_sp_release
|
||||
equals: true
|
||||
enablement:
|
||||
- type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: is_development
|
||||
equals: true
|
||||
- type: param
|
||||
key: ShowAdvancedControls
|
||||
equals: true
|
||||
attestation_required: true
|
||||
items:
|
||||
- key: LateralManeuverMode
|
||||
widget: toggle
|
||||
title: '[TEST] Lateral Maneuver Mode'
|
||||
description: Replaces normal lateral control with a deterministic test sequence. NOT for road use.
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- type: capability
|
||||
field: torque_allowed
|
||||
equals: true
|
||||
- key: LongitudinalManeuverMode
|
||||
widget: toggle
|
||||
title: '[TEST] Longitudinal Maneuver Mode'
|
||||
description: Replaces normal longitudinal control with a deterministic test sequence. NOT for road use.
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- id: advanced_services
|
||||
title: Advanced Settings
|
||||
description: ''
|
||||
items:
|
||||
- key: ShowAdvancedControls
|
||||
widget: toggle
|
||||
title: Show Advanced Controls
|
||||
description: Toggle visibility of advanced sunnypilot controls. This only changes the visibility of the toggles; it does
|
||||
not change the actual enabled/disabled state.
|
||||
- key: EnableGithubRunner
|
||||
widget: toggle
|
||||
title: GitHub Runner Service
|
||||
description: Enables or disables the GitHub runner service.
|
||||
visibility:
|
||||
- $ref: '#/macros/release_branches_hide'
|
||||
enablement:
|
||||
- $ref: '#/macros/advanced_only'
|
||||
- key: EnableCopyparty
|
||||
widget: toggle
|
||||
title: copyparty Service
|
||||
description: copyparty is a very capable file server, you can use it to download your routes, view your logs and even
|
||||
make some edits on some files from your browser. Requires you to connect to your comma locally via its IP address.
|
||||
enablement:
|
||||
- $ref: '#/macros/advanced_only'
|
||||
- key: QuickBootToggle
|
||||
widget: toggle
|
||||
title: Quickboot Mode
|
||||
visibility:
|
||||
- type: not
|
||||
condition:
|
||||
type: any
|
||||
conditions:
|
||||
- type: capability
|
||||
field: is_sp_release
|
||||
equals: true
|
||||
- type: capability
|
||||
field: is_development
|
||||
equals: true
|
||||
enablement:
|
||||
- type: param
|
||||
key: DisableUpdates
|
||||
equals: true
|
||||
- $ref: '#/macros/advanced_only'
|
||||
@@ -0,0 +1,67 @@
|
||||
# Page: device
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: device
|
||||
label: Device
|
||||
icon: device
|
||||
order: 6
|
||||
remote_configurable: true
|
||||
description: Device behavior, units, and recording settings
|
||||
sections:
|
||||
- id: general
|
||||
title: General
|
||||
description: Power, boot, and unit preferences
|
||||
items:
|
||||
- key: OffroadMode
|
||||
widget: toggle
|
||||
title: Force Offroad Mode
|
||||
- key: DeviceBootMode
|
||||
widget: option
|
||||
title: Wake Up Behavior
|
||||
description: 'Controls state of the device after boot/sleep. Default: Device will boot/wake-up normally and will be ready
|
||||
to engage. Offroad: Device will be in Always Offroad mode after boot/wake-up.'
|
||||
options:
|
||||
- value: 0
|
||||
label: Standard
|
||||
- value: 1
|
||||
label: Always Offroad
|
||||
- key: QuietMode
|
||||
widget: toggle
|
||||
title: Quiet Mode
|
||||
- key: OnroadUploads
|
||||
widget: toggle
|
||||
title: Onroad Uploads
|
||||
- key: MaxTimeOffroad
|
||||
widget: option
|
||||
title: Max Time Offroad
|
||||
description: Device will automatically shutdown after set time once the engine is turned off. 30h is the default.
|
||||
options:
|
||||
- value: 0
|
||||
label: Always On
|
||||
- value: 5
|
||||
label: 5m
|
||||
- value: 10
|
||||
label: 10m
|
||||
- value: 15
|
||||
label: 15m
|
||||
- value: 30
|
||||
label: 30m
|
||||
- value: 60
|
||||
label: 1h
|
||||
- value: 120
|
||||
label: 2h
|
||||
- value: 180
|
||||
label: 3h
|
||||
- value: 300
|
||||
label: 5h
|
||||
- value: 600
|
||||
label: 10h
|
||||
- value: 1440
|
||||
label: 24h
|
||||
- value: 1800
|
||||
label: 30h (Default)
|
||||
- id: language
|
||||
title: Language
|
||||
items:
|
||||
- key: LanguageSetting
|
||||
widget: info
|
||||
title: Language
|
||||
@@ -0,0 +1,130 @@
|
||||
# Page: display
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: display
|
||||
label: Display
|
||||
icon: display
|
||||
order: 3
|
||||
remote_configurable: true
|
||||
description: Screen brightness, timeout, and interactivity settings
|
||||
sections:
|
||||
- id: brightness_timeout
|
||||
title: Brightness & Timeout
|
||||
description: Screen dimming and sleep behavior while driving
|
||||
items:
|
||||
- key: OnroadScreenOffBrightness
|
||||
widget: multiple_button
|
||||
title: Onroad Brightness
|
||||
options:
|
||||
- value: 0
|
||||
label: Auto (Default)
|
||||
- value: 1
|
||||
label: Auto (Dark)
|
||||
- value: 2
|
||||
label: Screen Off
|
||||
- value: 3
|
||||
label: 5 %
|
||||
- value: 4
|
||||
label: 10 %
|
||||
- value: 5
|
||||
label: 15 %
|
||||
- value: 6
|
||||
label: 20 %
|
||||
- value: 7
|
||||
label: 25 %
|
||||
- value: 8
|
||||
label: 30 %
|
||||
- value: 9
|
||||
label: 35 %
|
||||
- value: 10
|
||||
label: 40 %
|
||||
- value: 11
|
||||
label: 45 %
|
||||
- value: 12
|
||||
label: 50 %
|
||||
- value: 13
|
||||
label: 55 %
|
||||
- value: 14
|
||||
label: 60 %
|
||||
- value: 15
|
||||
label: 65 %
|
||||
- value: 16
|
||||
label: 70 %
|
||||
- value: 17
|
||||
label: 75 %
|
||||
- value: 18
|
||||
label: 80 %
|
||||
- value: 19
|
||||
label: 85 %
|
||||
- value: 20
|
||||
label: 90 %
|
||||
- value: 21
|
||||
label: 95 %
|
||||
- value: 22
|
||||
label: 100 %
|
||||
- key: OnroadScreenOffTimer
|
||||
widget: multiple_button
|
||||
title: Onroad Brightness Delay
|
||||
options:
|
||||
- value: 0
|
||||
label: Always On
|
||||
- value: 3
|
||||
label: 3s
|
||||
- value: 5
|
||||
label: 5s
|
||||
- value: 10
|
||||
label: 10s
|
||||
- value: 15
|
||||
label: 15s
|
||||
- value: 30
|
||||
label: 30s
|
||||
- value: 60
|
||||
label: 1m
|
||||
- value: 180
|
||||
label: 3m
|
||||
- value: 300
|
||||
label: 5m
|
||||
- value: 600
|
||||
label: 10m
|
||||
enablement:
|
||||
- type: not
|
||||
condition:
|
||||
type: any
|
||||
conditions:
|
||||
- type: param
|
||||
key: OnroadScreenOffBrightness
|
||||
equals: 0
|
||||
- type: param
|
||||
key: OnroadScreenOffBrightness
|
||||
equals: 1
|
||||
- key: InteractivityTimeout
|
||||
widget: multiple_button
|
||||
title: Interactivity Timeout
|
||||
description: Apply a custom timeout for settings UI. This is the time after which settings UI closes automatically if
|
||||
user is not interacting with the screen.
|
||||
options:
|
||||
- value: 0
|
||||
label: Default
|
||||
- value: 10
|
||||
label: 10 s
|
||||
- value: 20
|
||||
label: 20 s
|
||||
- value: 30
|
||||
label: 30 s
|
||||
- value: 40
|
||||
label: 40 s
|
||||
- value: 50
|
||||
label: 50 s
|
||||
- value: 60
|
||||
label: 1 m
|
||||
- value: 70
|
||||
label: 1 m
|
||||
- value: 80
|
||||
label: 1 m
|
||||
- value: 90
|
||||
label: 1 m
|
||||
- value: 100
|
||||
label: 1 m
|
||||
- value: 110
|
||||
label: 1 m
|
||||
- value: 120
|
||||
label: 2 m
|
||||
@@ -0,0 +1,89 @@
|
||||
# Page: models
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: models
|
||||
label: Models
|
||||
icon: models
|
||||
order: 10
|
||||
remote_configurable: false
|
||||
description: Driving model behavior and camera calibration
|
||||
sections:
|
||||
- id: model_behavior
|
||||
title: Model Behavior
|
||||
description: Lane desire and lead-vehicle awareness tuning
|
||||
items:
|
||||
- key: LaneTurnDesire
|
||||
widget: toggle
|
||||
title: Use Lane Turn Desires
|
||||
description: If you are driving at 20 mph (32 km/h) or below and have your blinker on, the car will plan a turn in that
|
||||
direction at the nearest drivable path. This prevents situations (like at red lights) where the car might plan the wrong
|
||||
turn direction.
|
||||
- key: LaneTurnValue
|
||||
widget: option
|
||||
title: Adjust Lane Turn Speed
|
||||
description: Set the maximum speed for lane turn desires.
|
||||
min: 0
|
||||
max: 20
|
||||
step: 1
|
||||
unit:
|
||||
metric: km/h
|
||||
imperial: mph
|
||||
enablement:
|
||||
- type: param
|
||||
key: LaneTurnDesire
|
||||
equals: true
|
||||
- $ref: '#/macros/advanced_only'
|
||||
- key: LagdToggle
|
||||
widget: toggle
|
||||
title: Live Learning Steer Delay
|
||||
description: Allow device to learn and adapt car's steering response time
|
||||
- key: LagdToggleDelay
|
||||
widget: option
|
||||
title: Adjust Software Delay
|
||||
description: Adjust the software delay when Live Learning Steer Delay is toggled off. The default software delay value
|
||||
is 0.2
|
||||
min: 0.05
|
||||
max: 0.5
|
||||
step: 0.01
|
||||
enablement:
|
||||
- type: not
|
||||
condition:
|
||||
type: param
|
||||
key: LagdToggle
|
||||
equals: true
|
||||
- $ref: '#/macros/advanced_only'
|
||||
- id: lateral_control
|
||||
title: Lateral Control
|
||||
description: Neural network lateral control for supported models
|
||||
items:
|
||||
- key: NeuralNetworkLateralControl
|
||||
widget: toggle
|
||||
title: Neural Network Lateral Control (NNLC)
|
||||
description: Use a neural network for lateral control instead of the default torque controller.
|
||||
visibility:
|
||||
- type: not
|
||||
condition:
|
||||
type: capability
|
||||
field: steer_control_type
|
||||
equals: angle
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: capability
|
||||
field: torque_allowed
|
||||
equals: true
|
||||
- type: param
|
||||
key: EnforceTorqueControl
|
||||
equals: false
|
||||
- id: camera
|
||||
title: Camera
|
||||
description: Camera position and calibration
|
||||
items:
|
||||
- key: CameraOffset
|
||||
widget: option
|
||||
title: Adjust Camera Offset
|
||||
description: Virtually shift camera's perspective to move model's center to Left(+ values) or Right (- values)
|
||||
min: -0.35
|
||||
max: 0.35
|
||||
step: 0.01
|
||||
unit: meters
|
||||
enablement:
|
||||
- $ref: '#/macros/advanced_only'
|
||||
@@ -0,0 +1,20 @@
|
||||
# Page: software
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: software
|
||||
label: Software
|
||||
icon: software
|
||||
order: 7
|
||||
remote_configurable: true
|
||||
description: Software update preferences
|
||||
sections:
|
||||
- id: updates
|
||||
title: Updates
|
||||
description: Control automatic software updates
|
||||
items:
|
||||
- key: DisableUpdates
|
||||
widget: toggle
|
||||
title: Disable Updates
|
||||
description: When enabled, automatic software updates will be off. This requires a reboot to take effect.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- $ref: '#/macros/advanced_only'
|
||||
@@ -0,0 +1,257 @@
|
||||
# Page: steering
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: steering
|
||||
label: Steering
|
||||
icon: steering_wheel
|
||||
order: 1
|
||||
remote_configurable: true
|
||||
description: Lateral control, lane changes, and steering behavior
|
||||
sections:
|
||||
- id: mads
|
||||
title: Modular Assistive Driving System (MADS)
|
||||
description: ''
|
||||
items:
|
||||
- key: Mads
|
||||
widget: toggle
|
||||
title: Enable Modular Assistive Driving System (MADS)
|
||||
description: Enable MADS. Disable toggle to revert back to stock sunnypilot engagement/disengagement.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
sub_panels:
|
||||
- id: mads_settings
|
||||
label: MADS Settings
|
||||
trigger_key: Mads
|
||||
trigger_condition:
|
||||
type: param
|
||||
key: Mads
|
||||
equals: true
|
||||
items:
|
||||
- key: MadsMainCruiseAllowed
|
||||
widget: toggle
|
||||
title: Toggle with Main Cruise
|
||||
description: 'Note: For vehicles without LFA/LKAS button, disabling this will prevent lateral control engagement.'
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- $ref: '#/macros/mads_full_platforms'
|
||||
- key: MadsUnifiedEngagementMode
|
||||
widget: toggle
|
||||
title: Unified Engagement Mode (UEM)
|
||||
description: 'Engage lateral and longitudinal control with cruise control engagement. Note: Once lateral control is
|
||||
engaged via UEM, it will remain engaged until it is manually disabled via the MADS button or car shut off.'
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- $ref: '#/macros/mads_full_platforms'
|
||||
- key: MadsSteeringMode
|
||||
widget: multiple_button
|
||||
title: Steering Mode on Brake Pedal
|
||||
description: Choose how Automatic Lane Centering (ALC) behaves after the brake pedal is manually pressed in sunnypilot.
|
||||
options:
|
||||
- value: 0
|
||||
label: Remain Active
|
||||
enablement:
|
||||
- $ref: '#/macros/mads_full_platforms'
|
||||
- value: 1
|
||||
label: Pause
|
||||
enablement:
|
||||
- $ref: '#/macros/mads_full_platforms'
|
||||
- value: 2
|
||||
label: Disengage
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- id: blinker
|
||||
title: Blinker Control
|
||||
description: Lateral pause behavior during turn signals
|
||||
items:
|
||||
- key: BlinkerPauseLateralControl
|
||||
widget: toggle
|
||||
title: Pause Lateral Control with Blinker
|
||||
description: Pause lateral control with blinker when traveling below the desired speed selected.
|
||||
sub_items:
|
||||
- key: BlinkerMinLateralControlSpeed
|
||||
widget: option
|
||||
title: Minimum Speed to Pause Lateral Control
|
||||
min: 0
|
||||
max: 255
|
||||
step: 5
|
||||
unit:
|
||||
metric: km/h
|
||||
imperial: mph
|
||||
enablement:
|
||||
- type: param
|
||||
key: BlinkerPauseLateralControl
|
||||
equals: true
|
||||
- key: BlinkerLateralReengageDelay
|
||||
widget: option
|
||||
title: Post-Blinker Delay
|
||||
description: Delay before lateral control resumes after the turn signal ends.
|
||||
min: 0
|
||||
max: 10
|
||||
step: 1
|
||||
unit: second
|
||||
enablement:
|
||||
- type: param
|
||||
key: BlinkerPauseLateralControl
|
||||
equals: true
|
||||
- id: torque
|
||||
title: Torque Control
|
||||
description: Steering torque tuning and lateral control method
|
||||
enablement:
|
||||
- type: capability
|
||||
field: torque_allowed
|
||||
equals: true
|
||||
items:
|
||||
- key: EnforceTorqueControl
|
||||
widget: toggle
|
||||
title: Enforce Torque Lateral Control
|
||||
description: Enable this to enforce sunnypilot to steer with Torque lateral control.
|
||||
visibility:
|
||||
- type: not
|
||||
condition:
|
||||
type: capability
|
||||
field: steer_control_type
|
||||
equals: angle
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: capability
|
||||
field: torque_allowed
|
||||
equals: true
|
||||
- type: param
|
||||
key: NeuralNetworkLateralControl
|
||||
equals: false
|
||||
sub_panels:
|
||||
- id: torque_settings
|
||||
label: Torque Settings
|
||||
trigger_key: EnforceTorqueControl
|
||||
trigger_condition:
|
||||
type: param
|
||||
key: EnforceTorqueControl
|
||||
equals: true
|
||||
items:
|
||||
- key: LiveTorqueParamsToggle
|
||||
widget: toggle
|
||||
title: Self-Tune
|
||||
description: Enables self-tune for Torque lateral control for platforms that do not use Torque lateral control by default.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- key: LiveTorqueParamsRelaxedToggle
|
||||
widget: toggle
|
||||
title: Less Restrict Settings for Self-Tune (Beta)
|
||||
description: Less strict settings when using Self-Tune. This allows torqued to be more forgiving when learning values.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: param
|
||||
key: LiveTorqueParamsToggle
|
||||
equals: true
|
||||
- key: CustomTorqueParams
|
||||
widget: toggle
|
||||
title: Enable Custom Tuning
|
||||
description: Enables custom tuning for Torque lateral control. Modifying Lateral Acceleration Factor and Friction below
|
||||
will override the offline values indicated in the YAML files within "opendbc/car/torque_data". The values will also
|
||||
be used live when "Manual Real-Time Tuning" toggle is enabled.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- key: TorqueParamsOverrideEnabled
|
||||
widget: toggle
|
||||
title: Manual Real-Time Tuning
|
||||
description: Enforces the torque lateral controller to use the fixed values instead of the learned values from Self-Tune.
|
||||
Enabling this toggle overrides Self-Tune values.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: param
|
||||
key: CustomTorqueParams
|
||||
equals: true
|
||||
- key: TorqueParamsOverrideLatAccelFactor
|
||||
widget: option
|
||||
title: Lateral Acceleration Factor
|
||||
title_param_suffix:
|
||||
param: TorqueParamsOverrideEnabled
|
||||
values:
|
||||
'true': (Real-Time & Offline)
|
||||
'false': (Offline Only)
|
||||
min: 0.1
|
||||
max: 5.0
|
||||
step: 0.1
|
||||
unit: m/s²
|
||||
enablement:
|
||||
- type: param
|
||||
key: CustomTorqueParams
|
||||
equals: true
|
||||
- type: any
|
||||
conditions:
|
||||
- type: param
|
||||
key: TorqueParamsOverrideEnabled
|
||||
equals: true
|
||||
- type: offroad_only
|
||||
- key: TorqueParamsOverrideFriction
|
||||
widget: option
|
||||
title: Friction
|
||||
title_param_suffix:
|
||||
param: TorqueParamsOverrideEnabled
|
||||
values:
|
||||
'true': (Real-Time & Offline)
|
||||
'false': (Offline Only)
|
||||
min: 0.0
|
||||
max: 1.0
|
||||
step: 0.01
|
||||
enablement:
|
||||
- type: param
|
||||
key: CustomTorqueParams
|
||||
equals: true
|
||||
- type: any
|
||||
conditions:
|
||||
- type: param
|
||||
key: TorqueParamsOverrideEnabled
|
||||
equals: true
|
||||
- type: offroad_only
|
||||
- key: TorqueControlTune
|
||||
widget: multiple_button
|
||||
title: Torque Control Tune Version
|
||||
description: Select the version of Torque Control Tune to use.
|
||||
options:
|
||||
- value: ''
|
||||
label: Default
|
||||
- value: 1.0
|
||||
label: v1.0
|
||||
- value: 0.0
|
||||
label: v0.0
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- id: lane_change
|
||||
title: Lane Change
|
||||
description: Automatic lane change timing and behavior
|
||||
items:
|
||||
- key: AutoLaneChangeTimer
|
||||
widget: option
|
||||
title: Auto Lane Change by Blinker
|
||||
description: |-
|
||||
Set a timer to delay the auto lane change operation when the blinker is used. No nudge on the steering wheel is required to auto lane change if a timer is set. Default is Nudge.
|
||||
details: |-
|
||||
Please use caution when using this feature. Only use the blinker when traffic and road conditions permit.
|
||||
options:
|
||||
- value: -1
|
||||
label: 'Off'
|
||||
- value: 0
|
||||
label: Nudge
|
||||
- value: 1
|
||||
label: Nudgeless
|
||||
- value: 2
|
||||
label: 0.5 second
|
||||
- value: 3
|
||||
label: 1 second
|
||||
- value: 4
|
||||
label: 2 seconds
|
||||
- value: 5
|
||||
label: 3 seconds
|
||||
- key: AutoLaneChangeBsmDelay
|
||||
widget: toggle
|
||||
title: 'Auto Lane Change: Delay with Blind Spot'
|
||||
description: Toggle to enable a delay timer for lane changes when blind spot monitoring (BSM) detects a vehicle in your blind
|
||||
spot.
|
||||
enablement:
|
||||
- type: capability
|
||||
field: enable_bsm
|
||||
equals: true
|
||||
- type: param_compare
|
||||
key: AutoLaneChangeTimer
|
||||
op: '>'
|
||||
value: 0
|
||||
@@ -0,0 +1,49 @@
|
||||
# Page: toggles
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: toggles
|
||||
label: Toggles
|
||||
icon: toggles
|
||||
order: 5
|
||||
remote_configurable: true
|
||||
description: Core openpilot feature toggles
|
||||
sections:
|
||||
- id: core_toggles
|
||||
title: ''
|
||||
description: ''
|
||||
items:
|
||||
- key: OpenpilotEnabledToggle
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: Enable sunnypilot
|
||||
description: Use the sunnypilot system for adaptive cruise control and lane keep driver assistance. Your attention is
|
||||
required at all times to use this feature.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- key: IsLdwEnabled
|
||||
widget: toggle
|
||||
title: Enable Lane Departure Warnings
|
||||
description: Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line without a turn
|
||||
signal activated while driving over 31 mph (50 km/h).
|
||||
- key: AlwaysOnDM
|
||||
widget: toggle
|
||||
title: Always-On Driver Monitoring
|
||||
description: Enable driver monitoring even when sunnypilot is not engaged.
|
||||
- key: IsMetric
|
||||
widget: toggle
|
||||
title: Use Metric System
|
||||
description: Display speed in km/h instead of mph.
|
||||
- id: recording
|
||||
title: Recording
|
||||
description: Camera and audio recording during drives
|
||||
items:
|
||||
- key: RecordFront
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: Record and Upload Driver Camera
|
||||
description: Upload data from the driver facing camera and help improve the driver monitoring algorithm.
|
||||
- key: RecordAudio
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: Record and Upload Microphone Audio
|
||||
description: Record and store microphone audio while driving. The audio will be included in the dashcam video in comma
|
||||
connect.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Page: vehicle (per-brand settings)
|
||||
# Compiles to settings_ui.json#vehicle_settings (brand = section id).
|
||||
id: vehicle
|
||||
label: Vehicle
|
||||
icon: vehicle
|
||||
order: 99
|
||||
kind: vehicle
|
||||
sections:
|
||||
- id: hyundai
|
||||
title: Hyundai / Kia / Genesis Settings
|
||||
description: ''
|
||||
items:
|
||||
- key: HyundaiLongitudinalTuning
|
||||
widget: multiple_button
|
||||
title: Custom Longitudinal Tuning
|
||||
options:
|
||||
- value: 0
|
||||
label: 'Off'
|
||||
- value: 1
|
||||
label: Dynamic
|
||||
- value: 2
|
||||
label: Predictive
|
||||
visibility:
|
||||
- type: capability
|
||||
field: hyundai_alpha_long_available
|
||||
equals: true
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- id: subaru
|
||||
title: Subaru Settings
|
||||
description: ''
|
||||
items:
|
||||
- key: SubaruStopAndGo
|
||||
widget: toggle
|
||||
title: Stop and Go (Beta)
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: capability
|
||||
field: has_stop_and_go
|
||||
equals: true
|
||||
- key: SubaruStopAndGoManualParkingBrake
|
||||
widget: toggle
|
||||
title: Stop and Go for Manual Parking Brake (Beta)
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- type: capability
|
||||
field: has_stop_and_go
|
||||
equals: true
|
||||
- id: tesla
|
||||
title: Tesla Settings
|
||||
description: ''
|
||||
items:
|
||||
- key: TeslaCoopSteering
|
||||
widget: toggle
|
||||
title: Cooperative Steering (Beta)
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- id: toyota
|
||||
title: Toyota / Lexus Settings
|
||||
description: ''
|
||||
items:
|
||||
- key: ToyotaEnforceStockLongitudinal
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: Enforce Factory Longitudinal Control
|
||||
description: sunnypilot will not take over control of gas and brakes. Factory Toyota longitudinal control will be used.
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- key: ToyotaStopAndGoHack
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: Stop and Go Hack (Alpha)
|
||||
description: sunnypilot will allow some Toyota/Lexus cars to auto resume during stop and go traffic. This feature is only
|
||||
applicable to certain models that are able to use longitudinal control. This is an alpha feature. Use at your own risk.
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- type: param
|
||||
key: ToyotaEnforceStockLongitudinal
|
||||
equals: false
|
||||
@@ -0,0 +1,111 @@
|
||||
# Page: visuals
|
||||
# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.
|
||||
id: visuals
|
||||
label: Visuals
|
||||
icon: visuals
|
||||
order: 4
|
||||
remote_configurable: true
|
||||
description: HUD overlays, alerts, and on-screen display elements
|
||||
sections:
|
||||
- id: hud_elements
|
||||
title: HUD Elements
|
||||
description: Overlays shown on the driving screen
|
||||
items:
|
||||
- key: BlindSpot
|
||||
widget: toggle
|
||||
title: Show Blind Spot Warnings
|
||||
description: Enabling this will display warnings when a vehicle is detected in your blind spot as long as your car has
|
||||
BSM supported.
|
||||
- key: TorqueBar
|
||||
widget: toggle
|
||||
title: Steering Arc
|
||||
description: Display steering arc on the driving screen when lateral control is enabled.
|
||||
- key: ShowTurnSignals
|
||||
widget: toggle
|
||||
title: Display Turn Signals
|
||||
description: When enabled, visual turn indicators are drawn on the HUD.
|
||||
- key: RoadNameToggle
|
||||
widget: toggle
|
||||
title: Display Road Name
|
||||
description: Displays the name of the road the car is traveling on. The OpenStreetMap database of the location must be
|
||||
downloaded to fetch the road name.
|
||||
visibility:
|
||||
- $ref: '#/macros/hide_on_mici'
|
||||
- key: StandstillTimer
|
||||
widget: toggle
|
||||
title: Standstill Timer
|
||||
description: Show a timer on the HUD when the car is at a standstill.
|
||||
visibility:
|
||||
- $ref: '#/macros/hide_on_mici'
|
||||
- key: RocketFuel
|
||||
widget: toggle
|
||||
title: Real-time Acceleration Bar
|
||||
description: Show an indicator on the left side of the screen to display real-time vehicle acceleration and deceleration.
|
||||
This displays what the car is currently doing, not what the planner is requesting.
|
||||
visibility:
|
||||
- $ref: '#/macros/hide_on_mici'
|
||||
- key: ChevronInfo
|
||||
widget: option
|
||||
title: Display Metrics Below Chevron
|
||||
options:
|
||||
- value: 0
|
||||
label: 'Off'
|
||||
- value: 1
|
||||
label: Distance
|
||||
- value: 2
|
||||
label: Speed
|
||||
- value: 3
|
||||
label: Time
|
||||
- value: 4
|
||||
label: All
|
||||
visibility:
|
||||
- $ref: '#/macros/hide_on_mici'
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- id: developer_ui
|
||||
title: Developer UI Info
|
||||
description: Speedometer and debug display options
|
||||
visibility:
|
||||
- $ref: '#/macros/hide_on_mici'
|
||||
items:
|
||||
- key: DevUIInfo
|
||||
widget: option
|
||||
title: Developer UI
|
||||
description: Display real-time parameters and metrics from various sources.
|
||||
options:
|
||||
- value: 0
|
||||
label: 'Off'
|
||||
- value: 1
|
||||
label: Bottom
|
||||
- value: 2
|
||||
label: Right
|
||||
- value: 3
|
||||
label: Right & Bottom
|
||||
- key: TrueVEgoUI
|
||||
widget: toggle
|
||||
title: 'Speedometer: Always Display True Speed'
|
||||
description: For applicable vehicles, always display the true vehicle current speed from wheel speed sensors.
|
||||
- key: HideVEgoUI
|
||||
widget: toggle
|
||||
title: 'Speedometer: Hide from Onroad Screen'
|
||||
description: When enabled, the speedometer on the onroad screen is not displayed.
|
||||
- id: alerts_extras
|
||||
title: Alerts & Extras
|
||||
description: Traffic light alerts and visual flair
|
||||
items:
|
||||
- key: GreenLightAlert
|
||||
widget: toggle
|
||||
title: Green Traffic Light Alert (Beta)
|
||||
description: 'A chime and on-screen alert will play when the traffic light you are waiting for turns green and you have
|
||||
no vehicle in front of you. On-screen visual alert is only available on comma 3X. Note: This chime is only designed
|
||||
as a notification. It is the driver''s responsibility to observe their environment and make decisions accordingly.'
|
||||
- key: LeadDepartAlert
|
||||
widget: toggle
|
||||
title: Lead Departure Alert (Beta)
|
||||
description: 'A chime and on-screen alert will play when you are stopped, and the vehicle in front of you start moving.
|
||||
On-screen visual alert is only available on comma 3X. Note: This chime is only designed as a notification. It is the
|
||||
driver''s responsibility to observe their environment and make decisions accordingly.'
|
||||
- key: RainbowMode
|
||||
widget: toggle
|
||||
title: Tesla Rainbow Mode
|
||||
description: Display a rainbow effect on the path the model wants to take. It does not affect driving in any way.
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
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. Edit KNOWN_PROTOCOL_VERSIONS if intentional."""
|
||||
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)
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
Tests for the settings_ui_src/ -> settings_ui.json compiler. Covers:
|
||||
- Roundtrip: compiled output matches the checked-in settings_ui.json
|
||||
- $ref macro resolution semantics (list-splice, scalar-substitute, depth, cycles)
|
||||
- Per-page tree integrity (every page has id; vehicle page emits to vehicle_settings)
|
||||
|
||||
Does not cover device-side generator (test_settings_schema.py) or per-bug
|
||||
regression (test_settings_changes.py); those continue to validate the
|
||||
compiled output once the compiler has produced it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from openpilot.sunnypilot.sunnylink.tools.compile_settings_ui import (
|
||||
CompileError,
|
||||
DEFAULT_OUT,
|
||||
DEFAULT_SRC,
|
||||
_resolve_refs,
|
||||
compile_schema,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def compiled() -> dict:
|
||||
return compile_schema(DEFAULT_SRC)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def committed() -> dict:
|
||||
with open(DEFAULT_OUT) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
class TestRoundtrip:
|
||||
def test_compiled_matches_committed(self, compiled, committed):
|
||||
"""Compiled output must match the checked-in JSON."""
|
||||
if compiled == committed:
|
||||
return
|
||||
diff = "\n".join(difflib.unified_diff(
|
||||
json.dumps(committed, indent=2).splitlines(),
|
||||
json.dumps(compiled, indent=2).splitlines(),
|
||||
fromfile="settings_ui.json (committed)",
|
||||
tofile="settings_ui.json (freshly compiled)",
|
||||
lineterm="",
|
||||
))
|
||||
pytest.fail(f"settings_ui.json schema mismatch — run compile_settings_ui.py\n\n{diff}")
|
||||
|
||||
def test_committed_file_is_canonical(self):
|
||||
"""Compiled output must byte-match the checked-in file (including trailing newline).
|
||||
Drift means someone edited settings_ui.json by hand instead of editing settings_ui_src/."""
|
||||
schema = compile_schema(DEFAULT_SRC)
|
||||
rendered = json.dumps(schema, indent=2) + "\n"
|
||||
with open(DEFAULT_OUT) as f:
|
||||
current = f.read()
|
||||
if current == rendered:
|
||||
return
|
||||
diff = "\n".join(difflib.unified_diff(
|
||||
current.splitlines(),
|
||||
rendered.splitlines(),
|
||||
fromfile="settings_ui.json (on disk)",
|
||||
tofile="settings_ui.json (freshly compiled)",
|
||||
lineterm="",
|
||||
))
|
||||
pytest.fail(f"settings_ui.json out of sync — run compile_settings_ui.py\n\n{diff}")
|
||||
|
||||
|
||||
class TestRefResolution:
|
||||
def test_list_context_splices(self):
|
||||
macros = {"a": [{"type": "offroad_only"}], "b": [{"type": "not_engaged"}]}
|
||||
out = _resolve_refs([{"$ref": "#/macros/a"}, {"$ref": "#/macros/b"}], macros)
|
||||
assert out == [{"type": "offroad_only"}, {"type": "not_engaged"}]
|
||||
|
||||
def test_scalar_context_substitutes(self):
|
||||
macros = {"x": {"type": "capability", "field": "brand", "equals": "tesla"}}
|
||||
out = _resolve_refs({"condition": {"$ref": "#/macros/x"}}, macros)
|
||||
assert out == {"condition": {"type": "capability", "field": "brand", "equals": "tesla"}}
|
||||
|
||||
def test_chained_ref_resolves(self):
|
||||
macros = {
|
||||
"leaf": [{"type": "offroad_only"}],
|
||||
"middle": [{"$ref": "#/macros/leaf"}],
|
||||
}
|
||||
out = _resolve_refs([{"$ref": "#/macros/middle"}], macros)
|
||||
assert out == [{"type": "offroad_only"}]
|
||||
|
||||
def test_unknown_macro_raises(self):
|
||||
with pytest.raises(CompileError, match="unknown macro"):
|
||||
_resolve_refs([{"$ref": "#/macros/missing"}], {})
|
||||
|
||||
def test_cycle_raises(self):
|
||||
macros = {"a": [{"$ref": "#/macros/b"}], "b": [{"$ref": "#/macros/a"}]}
|
||||
with pytest.raises(CompileError, match="cycle"):
|
||||
_resolve_refs([{"$ref": "#/macros/a"}], macros)
|
||||
|
||||
def test_depth_limit(self):
|
||||
# Depth 4 chain should fail (limit is 3).
|
||||
macros = {
|
||||
"l1": [{"$ref": "#/macros/l2"}],
|
||||
"l2": [{"$ref": "#/macros/l3"}],
|
||||
"l3": [{"$ref": "#/macros/l4"}],
|
||||
"l4": [{"type": "offroad_only"}],
|
||||
}
|
||||
with pytest.raises(CompileError, match="depth"):
|
||||
_resolve_refs([{"$ref": "#/macros/l1"}], macros)
|
||||
|
||||
def test_invalid_ref_scheme(self):
|
||||
with pytest.raises(CompileError, match="unsupported"):
|
||||
_resolve_refs([{"$ref": "https://example.com/x"}], {})
|
||||
|
||||
def test_scalar_macro_in_list_context_raises(self):
|
||||
macros = {"x": {"type": "offroad_only"}} # macro is a single rule (dict), not a list
|
||||
with pytest.raises(CompileError, match="must resolve to a list"):
|
||||
_resolve_refs([{"$ref": "#/macros/x"}], macros)
|
||||
|
||||
|
||||
class TestCompiledShape:
|
||||
def test_panels_present(self, compiled):
|
||||
assert isinstance(compiled["panels"], list)
|
||||
assert len(compiled["panels"]) == 9
|
||||
panel_ids = {p["id"] for p in compiled["panels"]}
|
||||
assert {"steering", "cruise", "display", "visuals", "toggles",
|
||||
"device", "software", "developer", "models"} <= panel_ids
|
||||
|
||||
def test_vehicle_settings_consistent_shape(self, compiled):
|
||||
"""Each brand in vehicle_settings must have {title, description, items}."""
|
||||
for brand, data in compiled["vehicle_settings"].items():
|
||||
assert isinstance(data, dict), f"{brand}: expected object, got {type(data).__name__}"
|
||||
assert "title" in data, f"{brand}: missing title"
|
||||
assert "description" in data, f"{brand}: missing description"
|
||||
assert "items" in data, f"{brand}: missing items"
|
||||
|
||||
def test_no_dangling_refs_after_compile(self, compiled):
|
||||
"""All $ref objects must be resolved during compilation."""
|
||||
def walk(node):
|
||||
if isinstance(node, dict):
|
||||
if "$ref" in node:
|
||||
pytest.fail(f"unresolved $ref: {node}")
|
||||
for v in node.values():
|
||||
walk(v)
|
||||
elif isinstance(node, list):
|
||||
for x in node:
|
||||
walk(x)
|
||||
walk(compiled)
|
||||
|
||||
|
||||
class TestSourceTreeIntegrity:
|
||||
def test_macros_yaml_well_formed(self):
|
||||
with open(os.path.join(DEFAULT_SRC, "_macros.yaml")) as f:
|
||||
doc = yaml.safe_load(f)
|
||||
assert "macros" in doc
|
||||
for name, body in doc["macros"].items():
|
||||
assert name.replace("_", "").isalnum(), f"macro name '{name}' must be alphanumeric_"
|
||||
assert body, f"macro '{name}' empty"
|
||||
|
||||
def test_pages_dir_well_formed(self):
|
||||
pages_dir = os.path.join(DEFAULT_SRC, "pages")
|
||||
assert os.path.isdir(pages_dir), "pages/ directory missing"
|
||||
page_files = sorted(fn for fn in os.listdir(pages_dir) if fn.endswith(".yaml"))
|
||||
# 9 panels + 1 vehicle = 10
|
||||
assert len(page_files) == 10, f"expected 10 pages, found {len(page_files)}: {page_files}"
|
||||
|
||||
def test_every_page_has_id(self):
|
||||
pages_dir = os.path.join(DEFAULT_SRC, "pages")
|
||||
for fn in sorted(os.listdir(pages_dir)):
|
||||
if not fn.endswith(".yaml"):
|
||||
continue
|
||||
path = os.path.join(pages_dir, fn)
|
||||
with open(path) as f:
|
||||
doc = yaml.safe_load(f)
|
||||
assert isinstance(doc, dict), f"{path}: top-level must be a mapping"
|
||||
assert "id" in doc, f"{path}: page missing 'id'"
|
||||
# File basename should match page id (modulo .yaml extension).
|
||||
expected_id = os.path.splitext(fn)[0]
|
||||
assert doc["id"] == expected_id, (
|
||||
f"{path}: page id '{doc['id']}' must match filename '{expected_id}'"
|
||||
)
|
||||
|
||||
def test_vehicle_page_kind(self):
|
||||
"""vehicle.yaml must declare kind: vehicle so it routes to vehicle_settings."""
|
||||
path = os.path.join(DEFAULT_SRC, "pages", "vehicle.yaml")
|
||||
with open(path) as f:
|
||||
doc = yaml.safe_load(f)
|
||||
assert doc.get("kind") == "vehicle", "vehicle.yaml must declare kind: vehicle"
|
||||
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
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 from the schema."""
|
||||
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 gate on brand and tesla_has_vehicle_bus."""
|
||||
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"
|
||||
|
||||
|
||||
class TestValidator:
|
||||
def test_validator_accepts_real_json(self):
|
||||
"""settings_ui.json validates 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", [
|
||||
"EnableGithubRunner",
|
||||
"QuickBootToggle",
|
||||
])
|
||||
def test_sp_dev_items_gate_on_is_sp_release(self, schema, key):
|
||||
"""sunnypilot dev items must hide on sunnypilot release branches (is_sp_release gate)."""
|
||||
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_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 should use not_engaged, not offroad_only."""
|
||||
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"
|
||||
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
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 from a panel (non-recursive)."""
|
||||
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 all sub_panels from a panel and its sections."""
|
||||
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]:
|
||||
"""Extract items from vehicle_settings[brand] (handles dict or 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 serializes 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):
|
||||
"""Param keys should appear in at most one panel."""
|
||||
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):
|
||||
"""Schema keys 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):
|
||||
"""Capability fields used in rules must be declared."""
|
||||
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 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 must reference each other in enablement."""
|
||||
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 all items and sub_items from panels and 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):
|
||||
"""All items must have titles."""
|
||||
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):
|
||||
"""Item titles must differ from keys."""
|
||||
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 a 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 any of min/max/step is present, all three must be, 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
|
||||
Executable
+211
@@ -0,0 +1,211 @@
|
||||
#!/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.
|
||||
|
||||
Scans all per-page YAML files in settings_ui_src/pages/ and replaces inlined
|
||||
rule blocks that exactly match a macro definition in _macros.yaml with $ref
|
||||
references.
|
||||
|
||||
Match policy:
|
||||
- Whole-list match (exact): replace the entire list with [{$ref}].
|
||||
- Single-rule match: any individual rule whose canonical form equals macro[0]
|
||||
(when macro is exactly one rule) is replaced with {$ref}.
|
||||
|
||||
Re-run is idempotent (already-substituted $refs are skipped).
|
||||
|
||||
Usage:
|
||||
python apply_macros.py [--src DIR] [--dry-run]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_SRC = os.path.join(DIR, "settings_ui_src")
|
||||
|
||||
|
||||
class _BlockDumper(yaml.SafeDumper):
|
||||
pass
|
||||
|
||||
|
||||
def _represent_dict(dumper, data):
|
||||
return dumper.represent_mapping("tag:yaml.org,2002:map", data.items(), flow_style=False)
|
||||
|
||||
|
||||
def _represent_list(dumper, data):
|
||||
flow = all(not isinstance(x, (dict, list)) for x in data) and len(data) <= 8
|
||||
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=flow)
|
||||
|
||||
|
||||
_BlockDumper.add_representer(dict, _represent_dict)
|
||||
_BlockDumper.add_representer(list, _represent_list)
|
||||
|
||||
|
||||
def _dump_yaml(data) -> str:
|
||||
return yaml.dump(data, Dumper=_BlockDumper, sort_keys=False, allow_unicode=True, width=120)
|
||||
|
||||
|
||||
def _canon(node) -> str:
|
||||
return json.dumps(node, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _is_ref(node) -> bool:
|
||||
return isinstance(node, dict) and len(node) == 1 and "$ref" in node
|
||||
|
||||
|
||||
def _make_ref(name: str) -> dict:
|
||||
return {"$ref": f"#/macros/{name}"}
|
||||
|
||||
|
||||
def _substitute_rules(rules: list, whole_list: dict, single_rule: dict) -> list:
|
||||
if not isinstance(rules, list) or not rules:
|
||||
return rules
|
||||
full_canon = _canon(rules)
|
||||
if full_canon in whole_list:
|
||||
return [_make_ref(whole_list[full_canon])]
|
||||
out = []
|
||||
for r in rules:
|
||||
if _is_ref(r):
|
||||
out.append(r)
|
||||
continue
|
||||
rc = _canon(r)
|
||||
if rc in single_rule:
|
||||
out.append(_make_ref(single_rule[rc]))
|
||||
else:
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def _walk_item(item: dict, whole_list: dict, single_rule: dict) -> bool:
|
||||
changed = False
|
||||
for ctx in ("visibility", "enablement"):
|
||||
if ctx in item:
|
||||
new = _substitute_rules(item[ctx], whole_list, single_rule)
|
||||
if new != item[ctx]:
|
||||
item[ctx] = new
|
||||
changed = True
|
||||
if "options" in item:
|
||||
for opt in item["options"]:
|
||||
if not isinstance(opt, dict):
|
||||
continue
|
||||
for ctx in ("visibility", "enablement"):
|
||||
if ctx in opt:
|
||||
new = _substitute_rules(opt[ctx], whole_list, single_rule)
|
||||
if new != opt[ctx]:
|
||||
opt[ctx] = new
|
||||
changed = True
|
||||
if "sub_items" in item:
|
||||
for sub in item["sub_items"]:
|
||||
if _walk_item(sub, whole_list, single_rule):
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _walk_page(page: dict, whole_list: dict, single_rule: dict) -> bool:
|
||||
changed = False
|
||||
for sec in page.get("sections", []) or []:
|
||||
for ctx in ("visibility", "enablement"):
|
||||
if ctx in sec:
|
||||
new = _substitute_rules(sec[ctx], whole_list, single_rule)
|
||||
if new != sec[ctx]:
|
||||
sec[ctx] = new
|
||||
changed = True
|
||||
for it in sec.get("items", []) or []:
|
||||
if _walk_item(it, whole_list, single_rule):
|
||||
changed = True
|
||||
for sp in sec.get("sub_panels", []) or []:
|
||||
for it in sp.get("items", []) or []:
|
||||
if _walk_item(it, whole_list, single_rule):
|
||||
changed = True
|
||||
for it in page.get("items", []) or []:
|
||||
if _walk_item(it, whole_list, single_rule):
|
||||
changed = True
|
||||
for sp in page.get("sub_panels", []) or []:
|
||||
for it in sp.get("items", []) or []:
|
||||
if _walk_item(it, whole_list, single_rule):
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _build_macro_indices(macros: dict) -> tuple[dict, dict]:
|
||||
whole_list = {}
|
||||
single_rule = {}
|
||||
for name, body in macros.items():
|
||||
if isinstance(body, list):
|
||||
whole_list[_canon(body)] = name
|
||||
if len(body) == 1 and isinstance(body[0], dict):
|
||||
single_rule[_canon(body[0])] = name
|
||||
elif isinstance(body, dict):
|
||||
single_rule[_canon(body)] = name
|
||||
return whole_list, single_rule
|
||||
|
||||
|
||||
def _read_yaml_with_header(path: str) -> tuple[list[str], dict]:
|
||||
with open(path) as f:
|
||||
text = f.read()
|
||||
header_lines = []
|
||||
for line in text.splitlines(keepends=True):
|
||||
if line.startswith("#"):
|
||||
header_lines.append(line)
|
||||
elif line.strip() == "":
|
||||
header_lines.append(line)
|
||||
else:
|
||||
break
|
||||
doc = yaml.safe_load(text) or {}
|
||||
return header_lines, doc
|
||||
|
||||
|
||||
def _write_yaml_with_header(path: str, header: list[str], doc: dict) -> None:
|
||||
with open(path, "w") as f:
|
||||
if header:
|
||||
# Keep at most one trailing blank line in header
|
||||
while len(header) > 1 and header[-1].strip() == "" and header[-2].strip() == "":
|
||||
header.pop()
|
||||
for line in header:
|
||||
f.write(line)
|
||||
f.write(_dump_yaml(doc))
|
||||
|
||||
|
||||
def apply(src_dir: str, dry_run: bool) -> int:
|
||||
with open(os.path.join(src_dir, "_macros.yaml")) as f:
|
||||
macros_doc = yaml.safe_load(f) or {}
|
||||
macros = (macros_doc.get("macros") or {}) if isinstance(macros_doc, dict) else {}
|
||||
whole_list, single_rule = _build_macro_indices(macros)
|
||||
|
||||
changed_files = 0
|
||||
pages_dir = os.path.join(src_dir, "pages")
|
||||
if os.path.isdir(pages_dir):
|
||||
for fn in sorted(os.listdir(pages_dir)):
|
||||
if not fn.endswith((".yaml", ".yml")) or fn.startswith("_"):
|
||||
continue
|
||||
path = os.path.join(pages_dir, fn)
|
||||
header, doc = _read_yaml_with_header(path)
|
||||
if _walk_page(doc, whole_list, single_rule):
|
||||
changed_files += 1
|
||||
if not dry_run:
|
||||
_write_yaml_with_header(path, header, doc)
|
||||
else:
|
||||
print(f"would-rewrite: {path}")
|
||||
|
||||
print(f"{'Would rewrite' if dry_run else 'Rewrote'} {changed_files} files")
|
||||
return 0
|
||||
|
||||
|
||||
def _main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Substitute inlined rule blocks with $ref macros across pages/.")
|
||||
parser.add_argument("--src", default=DEFAULT_SRC)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
return apply(args.src, args.dry_run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
#!/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.
|
||||
|
||||
Reads settings_ui_src/ (the dev-friendly authoring tree) and emits the
|
||||
canonical settings_ui.json that the device generator + frontend consume.
|
||||
|
||||
Source layout:
|
||||
_macros.yaml # named rule fragments
|
||||
pages/<page_id>.yaml # one file per panel/page
|
||||
pages/vehicle.yaml # special: emits vehicle_settings
|
||||
|
||||
Each page.yaml contains the full panel: metadata + sections + items + sub_panels
|
||||
inline. Sub-panels are nested inside the section they belong to. Items appear
|
||||
in the order written in the file.
|
||||
|
||||
Macro references use JSON-Schema-style $ref pointers:
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {$ref: "#/macros/mads_full_platforms"}
|
||||
|
||||
Macros may reference other macros (max depth 3). Cycles raise an error.
|
||||
|
||||
Usage:
|
||||
python compile_settings_ui.py [--src DIR] [--out PATH] [--check]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_SRC = os.path.join(DIR, "settings_ui_src")
|
||||
DEFAULT_OUT = os.path.join(DIR, "settings_ui.json")
|
||||
|
||||
SCHEMA_VERSION = "1.0"
|
||||
MAX_MACRO_DEPTH = 3
|
||||
|
||||
|
||||
class CompileError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _load_yaml(path: str):
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
def _is_ref(node) -> bool:
|
||||
return isinstance(node, dict) and len(node) == 1 and "$ref" in node
|
||||
|
||||
|
||||
def _ref_name(node: dict) -> str:
|
||||
ref = node["$ref"]
|
||||
if not isinstance(ref, str) or not ref.startswith("#/macros/"):
|
||||
raise CompileError(f"unsupported $ref: {ref!r} (must start with '#/macros/')")
|
||||
return ref[len("#/macros/"):]
|
||||
|
||||
|
||||
def _resolve_refs(node, macros: dict, visiting: tuple[str, ...] = ()):
|
||||
"""Resolve $ref nodes against macros. Recursive; depth-limited; cycle-safe.
|
||||
|
||||
- $ref in list-context: macro's list spliced into parent list.
|
||||
- $ref in scalar-context (object value): macro's value substitutes.
|
||||
- Non-list macro values may not be referenced from list contexts.
|
||||
"""
|
||||
if _is_ref(node):
|
||||
name = _ref_name(node)
|
||||
if name in visiting:
|
||||
raise CompileError(f"$ref cycle: {' -> '.join(visiting + (name,))}")
|
||||
if len(visiting) >= MAX_MACRO_DEPTH:
|
||||
raise CompileError(f"$ref nesting exceeds depth {MAX_MACRO_DEPTH}: {' -> '.join(visiting + (name,))}")
|
||||
if name not in macros:
|
||||
raise CompileError(f"unknown macro: {name}")
|
||||
return _resolve_refs(copy.deepcopy(macros[name]), macros, visiting + (name,))
|
||||
|
||||
if isinstance(node, dict):
|
||||
return {k: _resolve_refs(v, macros, visiting) for k, v in node.items()}
|
||||
|
||||
if isinstance(node, list):
|
||||
out = []
|
||||
for item in node:
|
||||
if _is_ref(item):
|
||||
resolved = _resolve_refs(item, macros, visiting)
|
||||
if not isinstance(resolved, list):
|
||||
raise CompileError(
|
||||
f"macro '{_ref_name(item)}' must resolve to a list when used in a list context"
|
||||
)
|
||||
out.extend(resolved)
|
||||
else:
|
||||
out.append(_resolve_refs(item, macros, visiting))
|
||||
return out
|
||||
|
||||
return node
|
||||
|
||||
|
||||
# Output JSON key order. Mirrors the conventions in the original hand-written
|
||||
# settings_ui.json so structural diffs after extraction are minimal.
|
||||
_ITEM_KEY_ORDER = [
|
||||
"key",
|
||||
"widget",
|
||||
"needs_onroad_cycle",
|
||||
"requires_attestation",
|
||||
"blocked",
|
||||
"title",
|
||||
"description",
|
||||
"details",
|
||||
"title_param_suffix",
|
||||
"min",
|
||||
"max",
|
||||
"step",
|
||||
"unit",
|
||||
"options",
|
||||
"visibility",
|
||||
"enablement",
|
||||
"sub_items",
|
||||
]
|
||||
|
||||
|
||||
def _canon_item(item: dict, macros: dict) -> dict:
|
||||
resolved = dict(item)
|
||||
for ctx in ("visibility", "enablement"):
|
||||
if ctx in resolved:
|
||||
resolved[ctx] = _resolve_refs(resolved[ctx], macros)
|
||||
if "options" in resolved:
|
||||
new_opts = []
|
||||
for opt in resolved["options"]:
|
||||
if isinstance(opt, dict):
|
||||
opt = dict(opt)
|
||||
for ctx in ("visibility", "enablement"):
|
||||
if ctx in opt:
|
||||
opt[ctx] = _resolve_refs(opt[ctx], macros)
|
||||
new_opts.append(opt)
|
||||
resolved["options"] = new_opts
|
||||
if "sub_items" in resolved:
|
||||
resolved["sub_items"] = [_canon_item(s, macros) for s in resolved["sub_items"]]
|
||||
|
||||
out: dict = {}
|
||||
for k in _ITEM_KEY_ORDER:
|
||||
if k in resolved:
|
||||
out[k] = resolved[k]
|
||||
for k, v in resolved.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _canon_section(section: dict, macros: dict) -> dict:
|
||||
out: dict = {"id": section["id"], "title": section["title"]}
|
||||
if "description" in section:
|
||||
out["description"] = section["description"]
|
||||
for k in ("visibility", "enablement"):
|
||||
if k in section:
|
||||
out[k] = _resolve_refs(section[k], macros)
|
||||
if "attestation_required" in section:
|
||||
out["attestation_required"] = section["attestation_required"]
|
||||
out["items"] = [_canon_item(i, macros) for i in section.get("items", [])]
|
||||
if "sub_panels" in section and section["sub_panels"]:
|
||||
out["sub_panels"] = [_canon_sub_panel(sp, macros) for sp in section["sub_panels"]]
|
||||
for k, v in section.items():
|
||||
if k not in out and k != "order":
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _canon_sub_panel(sp: dict, macros: dict) -> dict:
|
||||
out: dict = {"id": sp["id"], "label": sp["label"], "trigger_key": sp["trigger_key"]}
|
||||
if "trigger_condition" in sp:
|
||||
out["trigger_condition"] = _resolve_refs(sp["trigger_condition"], macros)
|
||||
out["items"] = [_canon_item(i, macros) for i in sp.get("items", [])]
|
||||
for k, v in sp.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _canon_panel(page: dict, macros: dict) -> dict:
|
||||
out: dict = {
|
||||
"id": page["id"],
|
||||
"label": page["label"],
|
||||
"icon": page["icon"],
|
||||
"order": page["order"],
|
||||
}
|
||||
if "remote_configurable" in page:
|
||||
out["remote_configurable"] = page["remote_configurable"]
|
||||
if "description" in page:
|
||||
out["description"] = page["description"]
|
||||
if "sections" in page and page["sections"]:
|
||||
out["sections"] = [_canon_section(s, macros) for s in page["sections"]]
|
||||
if "items" in page and page["items"]:
|
||||
out["items"] = [_canon_item(i, macros) for i in page["items"]]
|
||||
if "sub_panels" in page and page["sub_panels"]:
|
||||
out["sub_panels"] = [_canon_sub_panel(sp, macros) for sp in page["sub_panels"]]
|
||||
return out
|
||||
|
||||
|
||||
def _canon_vehicle(page: dict, macros: dict) -> dict:
|
||||
"""Convert page-shape vehicle.yaml to wire-format vehicle_settings dict."""
|
||||
out: dict = {}
|
||||
for sec in page.get("sections", []):
|
||||
brand = sec["id"]
|
||||
brand_out: dict = {"title": sec.get("title", "")}
|
||||
if "description" in sec:
|
||||
brand_out["description"] = sec["description"]
|
||||
brand_out["items"] = [_canon_item(i, macros) for i in sec.get("items", [])]
|
||||
out[brand] = brand_out
|
||||
return out
|
||||
|
||||
|
||||
def _load_pages(src: str) -> list[dict]:
|
||||
pages_dir = os.path.join(src, "pages")
|
||||
if not os.path.isdir(pages_dir):
|
||||
return []
|
||||
pages = []
|
||||
for fn in sorted(os.listdir(pages_dir)):
|
||||
if not fn.endswith((".yaml", ".yml")):
|
||||
continue
|
||||
if fn.startswith("_"):
|
||||
continue
|
||||
path = os.path.join(pages_dir, fn)
|
||||
page = _load_yaml(path)
|
||||
if not isinstance(page, dict):
|
||||
raise CompileError(f"{path}: page YAML must be an object")
|
||||
if "id" not in page:
|
||||
raise CompileError(f"{path}: page missing 'id'")
|
||||
page["__source"] = path
|
||||
pages.append(page)
|
||||
return pages
|
||||
|
||||
|
||||
def compile_schema(src: str) -> dict:
|
||||
macros_doc = _load_yaml(os.path.join(src, "_macros.yaml"))
|
||||
macros = (macros_doc.get("macros") or {}) if isinstance(macros_doc, dict) else {}
|
||||
|
||||
pages = _load_pages(src)
|
||||
|
||||
panels_out = []
|
||||
vehicle_out: dict = {}
|
||||
|
||||
# Order panels by `order` field (falling back to file position).
|
||||
panel_pages = [p for p in pages if p.get("kind") != "vehicle"]
|
||||
panel_pages.sort(key=lambda p: (p.get("order", 999), p["id"]))
|
||||
|
||||
for page in panel_pages:
|
||||
panels_out.append(_canon_panel(page, macros))
|
||||
|
||||
for page in pages:
|
||||
if page.get("kind") == "vehicle":
|
||||
vehicle_out = _canon_vehicle(page, macros)
|
||||
|
||||
return {
|
||||
"$schema": "./settings_ui.schema.json",
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"panels": panels_out,
|
||||
"vehicle_settings": vehicle_out,
|
||||
}
|
||||
|
||||
|
||||
def _main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Compile settings_ui_src/ -> settings_ui.json")
|
||||
parser.add_argument("--src", default=DEFAULT_SRC)
|
||||
parser.add_argument("--out", default=DEFAULT_OUT)
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="Compile and diff against existing settings_ui.json; exit non-zero on diff.")
|
||||
args = parser.parse_args()
|
||||
|
||||
schema = compile_schema(args.src)
|
||||
rendered = json.dumps(schema, indent=2) + "\n"
|
||||
|
||||
if args.check:
|
||||
if not os.path.exists(args.out):
|
||||
print(f"--check: {args.out} does not exist", file=sys.stderr)
|
||||
return 1
|
||||
with open(args.out) as f:
|
||||
current = f.read()
|
||||
if current.strip() == rendered.strip():
|
||||
print(f"--check: {args.out} matches compiled output")
|
||||
return 0
|
||||
print(f"--check: {args.out} differs from compiled output", file=sys.stderr)
|
||||
cur_obj = json.loads(current)
|
||||
if cur_obj == schema:
|
||||
print("(structurally equal; only formatting differs)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with open(args.out, "w") as f:
|
||||
f.write(rendered)
|
||||
print(f"Wrote {args.out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
#!/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.
|
||||
|
||||
One-shot extractor: settings_ui.json -> settings_ui_src/pages/*.yaml.
|
||||
|
||||
Reads the existing monolithic settings_ui.json and produces one YAML file per
|
||||
page (panel) plus pages/vehicle.yaml for the per-brand settings. Macros remain
|
||||
unwritten by extract; populate _macros.yaml separately.
|
||||
|
||||
Usage:
|
||||
python extract_settings_ui.py [--src DIR] [--definition PATH]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_DEFINITION = os.path.join(DIR, "settings_ui.json")
|
||||
DEFAULT_SRC = os.path.join(DIR, "settings_ui_src")
|
||||
|
||||
|
||||
class _BlockDumper(yaml.SafeDumper):
|
||||
pass
|
||||
|
||||
|
||||
def _represent_dict(dumper, data):
|
||||
return dumper.represent_mapping("tag:yaml.org,2002:map", data.items(), flow_style=False)
|
||||
|
||||
|
||||
def _represent_list(dumper, data):
|
||||
flow = all(not isinstance(x, (dict, list)) for x in data) and len(data) <= 8
|
||||
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=flow)
|
||||
|
||||
|
||||
_BlockDumper.add_representer(dict, _represent_dict)
|
||||
_BlockDumper.add_representer(list, _represent_list)
|
||||
|
||||
|
||||
def _dump_yaml(data) -> str:
|
||||
return yaml.dump(data, Dumper=_BlockDumper, sort_keys=False, allow_unicode=True, width=120)
|
||||
|
||||
|
||||
# Item field order in per-page YAML (mirrors settings_ui.json conventions).
|
||||
_ITEM_ORDER = [
|
||||
"key",
|
||||
"widget",
|
||||
"needs_onroad_cycle",
|
||||
"requires_attestation",
|
||||
"blocked",
|
||||
"title",
|
||||
"description",
|
||||
"details",
|
||||
"title_param_suffix",
|
||||
"min",
|
||||
"max",
|
||||
"step",
|
||||
"unit",
|
||||
"options",
|
||||
"visibility",
|
||||
"enablement",
|
||||
"sub_items",
|
||||
]
|
||||
|
||||
|
||||
def _ordered_item(item: dict) -> dict:
|
||||
out: dict = {}
|
||||
for k in _ITEM_ORDER:
|
||||
if k in item:
|
||||
v = item[k]
|
||||
if k == "sub_items":
|
||||
v = [_ordered_item(s) for s in v]
|
||||
out[k] = v
|
||||
for k, v in item.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _ordered_section(section: dict) -> dict:
|
||||
out: dict = {"id": section["id"], "title": section["title"]}
|
||||
for f in ("description", "order", "visibility", "enablement", "attestation_required"):
|
||||
if f in section:
|
||||
out[f] = section[f]
|
||||
if "items" in section:
|
||||
out["items"] = [_ordered_item(i) for i in section["items"]]
|
||||
if "sub_panels" in section and section["sub_panels"]:
|
||||
out["sub_panels"] = [_ordered_sub_panel(sp) for sp in section["sub_panels"]]
|
||||
for k, v in section.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _ordered_sub_panel(sp: dict) -> dict:
|
||||
out: dict = {"id": sp["id"], "label": sp["label"], "trigger_key": sp["trigger_key"]}
|
||||
if "trigger_condition" in sp:
|
||||
out["trigger_condition"] = sp["trigger_condition"]
|
||||
if "items" in sp:
|
||||
out["items"] = [_ordered_item(i) for i in sp["items"]]
|
||||
for k, v in sp.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _ordered_page(panel: dict) -> dict:
|
||||
out: dict = {
|
||||
"id": panel["id"],
|
||||
"label": panel["label"],
|
||||
"icon": panel["icon"],
|
||||
"order": panel["order"],
|
||||
}
|
||||
for f in ("remote_configurable", "description"):
|
||||
if f in panel:
|
||||
out[f] = panel[f]
|
||||
if "sections" in panel and panel["sections"]:
|
||||
out["sections"] = [_ordered_section(s) for s in panel["sections"]]
|
||||
if "items" in panel and panel["items"]:
|
||||
out["items"] = [_ordered_item(i) for i in panel["items"]]
|
||||
if "sub_panels" in panel and panel["sub_panels"]:
|
||||
out["sub_panels"] = [_ordered_sub_panel(sp) for sp in panel["sub_panels"]]
|
||||
for k, v in panel.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _ordered_vehicle_page(vehicle_settings: dict) -> dict:
|
||||
"""Convert wire-format vehicle_settings dict into a page-shape YAML.
|
||||
|
||||
Brand becomes a section id. Each brand's items become section items.
|
||||
"""
|
||||
sections = []
|
||||
for brand in sorted(vehicle_settings.keys()):
|
||||
bd = vehicle_settings[brand]
|
||||
items = bd.get("items", []) if isinstance(bd, dict) else bd
|
||||
sec: dict = {
|
||||
"id": brand,
|
||||
"title": bd.get("title", "") if isinstance(bd, dict) else "",
|
||||
}
|
||||
if isinstance(bd, dict) and "description" in bd:
|
||||
sec["description"] = bd["description"]
|
||||
sec["items"] = [_ordered_item(i) for i in items]
|
||||
sections.append(sec)
|
||||
return {
|
||||
"id": "vehicle",
|
||||
"label": "Vehicle",
|
||||
"icon": "vehicle",
|
||||
"order": 99,
|
||||
"kind": "vehicle", # signals to compiler: emit as vehicle_settings, not panels
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
|
||||
def extract(definition_path: str, src_dir: str) -> None:
|
||||
with open(definition_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
pages_dir = os.path.join(src_dir, "pages")
|
||||
if os.path.isdir(pages_dir):
|
||||
shutil.rmtree(pages_dir)
|
||||
os.makedirs(pages_dir)
|
||||
|
||||
count = 0
|
||||
for panel in data.get("panels", []):
|
||||
page = _ordered_page(panel)
|
||||
path = os.path.join(pages_dir, f"{panel['id']}.yaml")
|
||||
with open(path, "w") as f:
|
||||
f.write(f"# Page: {panel['id']}\n")
|
||||
f.write("# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.\n")
|
||||
f.write(_dump_yaml(page))
|
||||
count += 1
|
||||
|
||||
vehicle_settings = data.get("vehicle_settings", {})
|
||||
if vehicle_settings:
|
||||
vehicle_page = _ordered_vehicle_page(vehicle_settings)
|
||||
path = os.path.join(pages_dir, "vehicle.yaml")
|
||||
with open(path, "w") as f:
|
||||
f.write("# Page: vehicle (per-brand settings)\n")
|
||||
f.write("# Compiles to settings_ui.json#vehicle_settings (brand = section id).\n")
|
||||
f.write(_dump_yaml(vehicle_page))
|
||||
count += 1
|
||||
|
||||
# Ensure _macros.yaml exists
|
||||
macros_path = os.path.join(src_dir, "_macros.yaml")
|
||||
if not os.path.exists(macros_path):
|
||||
with open(macros_path, "w") as f:
|
||||
f.write("# Named rule fragments. Reference from pages via {$ref: \"#/macros/<name>\"}.\n")
|
||||
f.write(_dump_yaml({"macros": {}}))
|
||||
|
||||
print(f"Extracted: {count} pages -> {pages_dir}")
|
||||
|
||||
|
||||
def _main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Extract settings_ui.json into per-page YAML files.")
|
||||
parser.add_argument("--definition", default=DEFAULT_DEFINITION, help="path to settings_ui.json")
|
||||
parser.add_argument("--src", default=DEFAULT_SRC, help="path to settings_ui_src/ output dir")
|
||||
args = parser.parse_args()
|
||||
extract(args.definition, args.src)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
+194
@@ -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 datetime
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
|
||||
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.datetime.now(datetime.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%}")
|
||||
+526
@@ -0,0 +1,526 @@
|
||||
#!/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.
|
||||
|
||||
Validates settings_ui.json against structural, semantic, and referential integrity constraints.
|
||||
|
||||
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
|
||||
|
||||
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 in ("offroad_only", "not_engaged"):
|
||||
# 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 lowercase + each brand has consistent {title, description, items} shape."""
|
||||
vehicle = data.get("vehicle_settings", {})
|
||||
errors: list[str] = []
|
||||
|
||||
for brand, brand_data in vehicle.items():
|
||||
if not isinstance(brand, str) or brand != brand.lower():
|
||||
errors.append(f"non-lowercase brand key: '{brand}'")
|
||||
if not isinstance(brand_data, dict):
|
||||
errors.append(f"brand '{brand}': expected object with {{title, description, items}}, got bare list")
|
||||
continue
|
||||
if "items" not in brand_data:
|
||||
errors.append(f"brand '{brand}': missing 'items'")
|
||||
if "title" not in brand_data:
|
||||
errors.append(f"brand '{brand}': missing 'title' (use empty string for none)")
|
||||
|
||||
if errors:
|
||||
result.error("vehicle brands", "; ".join(errors))
|
||||
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)
|
||||
@@ -4,7 +4,10 @@ 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 openpilot.common.swaglog import cloudlog
|
||||
from openpilot.sunnypilot.selfdrive.car.sync_car_list_param import CAR_LIST_JSON_OUT
|
||||
|
||||
ONROAD_BRIGHTNESS_MIGRATION_VERSION: str = "1.0"
|
||||
ONROAD_BRIGHTNESS_TIMER_MIGRATION_VERSION: str = "1.0"
|
||||
@@ -14,6 +17,36 @@ ONROAD_BRIGHTNESS_TIMER_VALUES = {0: 3, 1: 5, 2: 7, 3: 10, 4: 15, 5: 30, **{i: (
|
||||
VALID_TIMER_VALUES = set(ONROAD_BRIGHTNESS_TIMER_VALUES.values())
|
||||
|
||||
|
||||
def _migrate_car_platform_bundle(_params):
|
||||
bundle = _params.get("CarPlatformBundle")
|
||||
if bundle is None:
|
||||
return
|
||||
|
||||
old_platform = bundle.get("platform")
|
||||
if not old_platform:
|
||||
return
|
||||
|
||||
from opendbc.car.fingerprints import MIGRATION # lazy: avoids heavy import at module level
|
||||
if old_platform not in MIGRATION:
|
||||
return
|
||||
|
||||
new_platform = str(MIGRATION[old_platform])
|
||||
|
||||
with open(CAR_LIST_JSON_OUT) as f:
|
||||
car_list = json.load(f)
|
||||
|
||||
candidates = [(k, v) for k, v in car_list.items() if v.get("platform") == new_platform]
|
||||
if candidates:
|
||||
old_model = bundle.get("model")
|
||||
key, data = next(((k, v) for k, v in candidates if v.get("model") == old_model), candidates[0])
|
||||
bundle = {**data, "name": key}
|
||||
else:
|
||||
bundle["platform"] = new_platform
|
||||
|
||||
_params.put("CarPlatformBundle", bundle)
|
||||
cloudlog.info(f"params_migration: CarPlatformBundle migrated {old_platform!r} -> {new_platform!r}")
|
||||
|
||||
|
||||
def run_migration(_params):
|
||||
# migrate OnroadScreenOffBrightness
|
||||
if _params.get("OnroadScreenOffBrightnessMigrated") != ONROAD_BRIGHTNESS_MIGRATION_VERSION:
|
||||
@@ -45,3 +78,5 @@ def run_migration(_params):
|
||||
cloudlog.info(log_str + f" Setting OnroadScreenOffTimerMigrated to {ONROAD_BRIGHTNESS_TIMER_MIGRATION_VERSION}")
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"Error migrating OnroadScreenOffTimer: {e}")
|
||||
|
||||
_migrate_car_platform_bundle(_params)
|
||||
|
||||
@@ -35,8 +35,8 @@ def manager_init() -> None:
|
||||
params.clear_all(ParamKeyFlag.CLEAR_ON_ONROAD_TRANSITION)
|
||||
params.clear_all(ParamKeyFlag.CLEAR_ON_OFFROAD_TRANSITION)
|
||||
params.clear_all(ParamKeyFlag.CLEAR_ON_IGNITION_ON)
|
||||
if build_metadata.release_channel:
|
||||
params.clear_all(ParamKeyFlag.DEVELOPMENT_ONLY)
|
||||
# if build_metadata.release_channel:
|
||||
# params.clear_all(ParamKeyFlag.DEVELOPMENT_ONLY)
|
||||
|
||||
# device boot mode
|
||||
if params.get("DeviceBootMode") == 1: # start in Always Offroad mode
|
||||
|
||||
@@ -363,7 +363,7 @@ def simple_button_item_sp(button_text: str | Callable[[], str], callback: Callab
|
||||
def toggle_item_sp(title: str | Callable[[], str], description: str | Callable[[], str] | None = None, initial_state: bool = False,
|
||||
callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, param: str | None = None) -> ListItemSP:
|
||||
action = ToggleActionSP(initial_state=initial_state, enabled=enabled, callback=callback, param=param)
|
||||
return ListItemSP(title=title, description=description, action_item=action, icon=icon, callback=callback)
|
||||
return ListItemSP(title=title, description=description, action_item=action, icon=icon)
|
||||
|
||||
|
||||
def multiple_button_item_sp(title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], str]],
|
||||
|
||||
@@ -57,3 +57,7 @@ class ToggleSP(Toggle):
|
||||
knob_y = self._rect.y + style.TOGGLE_BG_HEIGHT / 2
|
||||
|
||||
rl.draw_circle(int(knob_x), int(knob_y), KNOB_RADIUS, knob_color)
|
||||
|
||||
clicked = self._clicked
|
||||
self._clicked = False
|
||||
return clicked
|
||||
|
||||
@@ -116,12 +116,12 @@ wheels = [
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "1.0.8"
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=bzip2&rev=release-bzip2#2083ff9525ebb12135fab36711f2d959ad75a848" }
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=bzip2&rev=release-bzip2#7876f40b7a3e9f0d634a60586043395169ef1a82" }
|
||||
|
||||
[[package]]
|
||||
name = "capnproto"
|
||||
version = "1.0.1"
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=capnproto&rev=release-capnproto#437b0c5b5d967e06da8f76ee755d60768bfda34d" }
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=capnproto&rev=release-capnproto#bcd0c43cb9dbd3b48aad36812bae9498fb5c7be1" }
|
||||
|
||||
[[package]]
|
||||
name = "casadi"
|
||||
@@ -380,7 +380,7 @@ wheels = [
|
||||
[[package]]
|
||||
name = "eigen"
|
||||
version = "3.4.0"
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=eigen&rev=release-eigen#a8d3ceab76b9c8c2c6f1540af8ccd3ad863c01e4" }
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=eigen&rev=release-eigen#2549bb084fb942f7861bfd8e0e1f16e619a544cf" }
|
||||
|
||||
[[package]]
|
||||
name = "execnet"
|
||||
@@ -394,7 +394,7 @@ wheels = [
|
||||
[[package]]
|
||||
name = "ffmpeg"
|
||||
version = "7.1.0"
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=ffmpeg&rev=release-ffmpeg#9621ada7dfedb624c2abafbfa6d8e2473af43a96" }
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=ffmpeg&rev=release-ffmpeg#783ee0c31f422295af7b3f8943c9d878603a34e9" }
|
||||
|
||||
[[package]]
|
||||
name = "fonttools"
|
||||
@@ -441,12 +441,12 @@ wheels = [
|
||||
[[package]]
|
||||
name = "gcc-arm-none-eabi"
|
||||
version = "13.2.1"
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=gcc-arm-none-eabi&rev=release-gcc-arm-none-eabi#94c98fa688e8f4bf68e2d96cbb3900925f6c76ff" }
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=gcc-arm-none-eabi&rev=release-gcc-arm-none-eabi#95a94097e4214ba85718bf63ebffd2b4e3427e10" }
|
||||
|
||||
[[package]]
|
||||
name = "git-lfs"
|
||||
version = "3.6.1"
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=git-lfs&rev=release-git-lfs#c2d44b18b87da51300bb84426e809db214290947" }
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=git-lfs&rev=release-git-lfs#a170e4e7bfed8809673bf1ef6656ba710e084c8c" }
|
||||
|
||||
[[package]]
|
||||
name = "google-crc32c"
|
||||
@@ -495,7 +495,7 @@ wheels = [
|
||||
[[package]]
|
||||
name = "imgui"
|
||||
version = "1.92.7"
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=imgui&rev=release-imgui#6ae27e655e1ef47414d85aafe3957d52a87e4eb5" }
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=imgui&rev=release-imgui#47cbeb90ea132652abe7b49b02d657af6adf65e9" }
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
@@ -575,12 +575,12 @@ wheels = [
|
||||
[[package]]
|
||||
name = "libjpeg"
|
||||
version = "3.1.0"
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libjpeg&rev=release-libjpeg#90d0d6a55a7f5edabb19657295ef00cb1a5038ce" }
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libjpeg&rev=release-libjpeg#52c4510d20025fb9daac88acbe08438c9038a60f" }
|
||||
|
||||
[[package]]
|
||||
name = "libusb"
|
||||
version = "1.0.29"
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libusb&rev=release-libusb#1a60c15941939409bdea1b782a17a07ba1754388" }
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libusb&rev=release-libusb#9f64ef9804e51d64b49d75011f4acfc582791645" }
|
||||
|
||||
[[package]]
|
||||
name = "libusb1"
|
||||
@@ -596,7 +596,7 @@ wheels = [
|
||||
[[package]]
|
||||
name = "libyuv"
|
||||
version = "1922.0"
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libyuv&rev=release-libyuv#2abe941ad891c09be217e36e030ad87f90c8256d" }
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libyuv&rev=release-libyuv#a5a47cab68fe194e0ee6e8ba694960dd0e8f3ac5" }
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
@@ -701,7 +701,7 @@ wheels = [
|
||||
[[package]]
|
||||
name = "ncurses"
|
||||
version = "6.5"
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=ncurses&rev=release-ncurses#112e29cccb8683318489484b0946bbc9c8c23049" }
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=ncurses&rev=release-ncurses#0e85f809441d49e7b2ec3123ee786fefa95b2052" }
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
@@ -1500,26 +1500,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.32"
|
||||
version = "0.0.34"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/7e/2aa791c9ae7b8cd5024cd4122e92267f664ca954cea3def3211919fa3c1f/ty-0.0.32.tar.gz", hash = "sha256:8743174c5f920f6700a4a0c9de140109189192ba16226884cd50095b43b8a45c", size = 5522294, upload-time = "2026-04-20T19:29:01.626Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/69/e24eefe2c35c0fdbdec9b60e162727af669bb76d64d993d982eb67b24c38/ty-0.0.34.tar.gz", hash = "sha256:a6efe66b0f13c03a65e6c72ec9abfe2792e2fd063c74fa67e2c4930e29d661be", size = 5585933, upload-time = "2026-05-01T23:06:46.388Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/eb/1075dc6a49d7acbe2584ae4d5b410c41b1f177a5adcc567e09eca4c69000/ty-0.0.32-py3-none-linux_armv6l.whl", hash = "sha256:dacbc2f6cd698d488ae7436838ff929570455bf94bfa4d9fe57a630c552aff83", size = 10902959, upload-time = "2026-04-20T19:28:31.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/d2/c35fc8bc66e98d1ee9b0f8ed319bf743e450e1f1e997574b178fab75670f/ty-0.0.32-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914bbc4f605ce2a9e2a78982e28fae1d3359a169d141f9dc3b4c7749cd5eca81", size = 10726172, upload-time = "2026-04-20T19:28:44.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/32/c827da3ca480456fb02d8cea68a2609273b6c220fea0be9a4c8d8470b86e/ty-0.0.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4787ac9fe1f86b1f3133f5c6732adbe2df5668b50c679ac6e2d98cd284da812f", size = 10163701, upload-time = "2026-04-20T19:28:27.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/9e/2734478fbdb90c160cb2813a3916a16a2af5c1e231f87d635f6131d781fb/ty-0.0.32-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ea0a728af99fe40dd744cba6441a2404f80b7f4bde17aa6da393810af5ea57", size = 10656220, upload-time = "2026-04-20T19:29:03.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/9f/0007da2d35e424debe7e9f86ffbc1ab7f60983cfbc5f0411324ab2de5292/ty-0.0.32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2850561f9b018ae33d7e5bbfa0ac414d3c518513edcffe43877dc9801446b9c5", size = 10696086, upload-time = "2026-04-20T19:28:46.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/5e/ce5fd4ec803222ae3e69a76d2a2db2eed55e19f5b131702b9789ef45f93d/ty-0.0.32-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5fa2fb3c614349ee211d36476b49d88c5ef79a687cdb91b2872ad023b94d2f8", size = 11184800, upload-time = "2026-04-20T19:28:42.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/46/ebcf67a5999421331214aac51a7464db42de2be15bbe929c612a3ed0b039/ty-0.0.32-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b89969307ab2417d41c9be8059dd79feea577234e1e10d35132f5495e0d42c6", size = 11718718, upload-time = "2026-04-20T19:28:36.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/2c/2141c86ed0ce0962b45cefb658a95e734f59759d47f20afdcd9c732910a1/ty-0.0.32-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b59868ede9b1d69a088f0d695df52a0061f95fa7baa1d5e0dc6fc9cf06e1334", size = 11346369, upload-time = "2026-04-20T19:28:48.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/da/ed6f772339cf29bd9a46def9d6db5084689eb574ee4d150ff704224c1ed8/ty-0.0.32-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8300caf35345498e9b9b03e550bba03cee8f5f5f8ab4c83c3b1ff1b7403b7d3a", size = 11280714, upload-time = "2026-04-20T19:28:51.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/9b/c6813987edf4816a40e0c8e408b555f97d3f267c7b3a1688c8bbdf65609c/ty-0.0.32-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:583c7094f4574b02f724db924f98b804d1387a0bd9405ecb5e078cc0f47fbcfb", size = 10638806, upload-time = "2026-04-20T19:28:29.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/d4/0cefcbd2ad0f3d51762ccf58e652ec7da146eb6ae34f87228f6254bbb8be/ty-0.0.32-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e44ebe1bb4143a5628bc4db67ac0dfebe14594af671e4ee66f6f2e983da56501", size = 10726106, upload-time = "2026-04-20T19:29:06.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/ad/2c8a97f91f06311f4367400f7d13534bbda2522c73c99a3e4c0757dff9b8/ty-0.0.32-py3-none-musllinux_1_2_i686.whl", hash = "sha256:06f17ada3e069cba6148342ef88e9929156beca8473e8d4f101b68f66c75643e", size = 10872951, upload-time = "2026-04-20T19:28:34.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/68/42293f9248106dd51875120971a5cc6ea315c2c4dcfb8e59aa063aa0af26/ty-0.0.32-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e96e60fa556cec04f15d7ea62d2ceee5982bd389233e961ab9fd42304e278175", size = 11363334, upload-time = "2026-04-20T19:28:54.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/92/be9abf4d3e589ad5023e2ea965b93e204ec856420d46adf73c5c36c04678/ty-0.0.32-py3-none-win32.whl", hash = "sha256:2ff2ebb4986b24aebcf1444db7db5ca41b36086040e95eea9f8fb851c11e805c", size = 10260689, upload-time = "2026-04-20T19:28:56.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/61/dc86acea899349d2579cb8419aecedd83dc504d7d6a10df65eef546c8300/ty-0.0.32-py3-none-win_amd64.whl", hash = "sha256:ba7284a4a954b598c1b31500352b3ec1f89bff533825592b5958848226fdc7ee", size = 11255371, upload-time = "2026-04-20T19:28:39.917Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/01/beffec56d71ca25b343ede63adb076456b5b3e211f1c066452a44cd120b3/ty-0.0.32-py3-none-win_arm64.whl", hash = "sha256:7e10aadbdbda989a7d567ee6a37f8b98d4d542e31e3b190a2879fd581f75d658", size = 10658087, upload-time = "2026-04-20T19:28:59.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7b/8b85003d6639ef17a97dcbb31f4511cfe78f1c81a964470db100c8c883e7/ty-0.0.34-py3-none-linux_armv6l.whl", hash = "sha256:9ecc3d14f07a95a6ceb88e07f8e62358dbd37325d3d5bd56da7217ff1fef7fb8", size = 11067094, upload-time = "2026-05-01T23:06:21.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/25/b0098f65b020b015c40567c763fc66fffbec88b2ba6f584bca1e92f05ebb/ty-0.0.34-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0dccffd8a9d02321cd2dee3249df205e26d62694e741f4eeca36b157fd8b419f", size = 10840909, upload-time = "2026-05-01T23:06:18.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/55/5e4adcf7d2a1006b844903b27cb81244a9b748d850433a46a6c21776c401/ty-0.0.34-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b0ea47a2998e167ab3b21d2f4b5309a9cf33c297809f6d7e3e753252223174d0", size = 10279378, upload-time = "2026-05-01T23:06:37.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/91/f537dca0db8fe2558e8ab04d8941d687b384fcc1df5eb9023b2db75ac26c/ty-0.0.34-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b37da00b41a118a459ae56d8947e70651073fb33ebfbceb820e4a10b22d5023", size = 10817423, upload-time = "2026-05-01T23:06:26.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/c4/55a3ad1da2815af1009bdc1b8c90dc11a364cd314e4b48c5128ba9d38859/ty-0.0.34-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81cbbb93c2342fe3de43e625d3a9eb149633e9f485e816ebf6395d08685355d8", size = 10851826, upload-time = "2026-05-01T23:06:24.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8c/9c7606af22d73fb43ea4369472d9c66ece11231be73b0efe8e3c61655559/ty-0.0.34-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c5b4dea1594a021289e172582df9cde7089dce14b276fc650e7b212b1772e12", size = 11356318, upload-time = "2026-05-01T23:06:51.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/54/bb423f663721ab4138b216425c6b55eaefd3a068243b24d6d8fe988f4e13/ty-0.0.34-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:030fb00aa2d2a5b5ae9d9183d574e0c82dae80566700a7490c43669d8ece40cd", size = 11902968, upload-time = "2026-05-01T23:06:35.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/22/01122b21ab6b534a2f618c6bbe5f1f7f49fd56f4b2ec8887cd6d40d08fb3/ty-0.0.34-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ae9555e24e36c63a8218e037a5a63f15579eb6aa94f41017e57cd41d335cfb5", size = 11548860, upload-time = "2026-05-01T23:06:42.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/50/86008b1392ec64bed1957bbcc7aaa43b466b50dfc91bb131841c21d7c5c3/ty-0.0.34-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99eb23df9ed129fc26d1ab00d6f0b8dfe5253b09c2ac6abdb11523fa70d67f10", size = 11457097, upload-time = "2026-05-01T23:06:53.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/3e/4558b2296963ba99c58d8409c57d7db4f3061b656c3613cb21c02c1ef4c2/ty-0.0.34-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:85de45382016eceae69e104815eb2cfa200787df104002e262a86cbd43ed2c02", size = 10798192, upload-time = "2026-05-01T23:06:40.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/bf/650d24402be2ef678528d60caac1d9477a40fc37e3792ecef07834fd7a4a/ty-0.0.34-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:14cb575fb8fa5131f5129d100cfe23c1575d23faf5dfc5158432749a3e38c9b5", size = 10890390, upload-time = "2026-05-01T23:06:33.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/ef/ccd2ca13906079f7935fd7e067661b24233017f57d987d51d6a121d85bb5/ty-0.0.34-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c6fc0b69d8450e6910ba9db34572b959b81329a97ae273c391f70e9fb6c1aade", size = 11031564, upload-time = "2026-05-01T23:06:55.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/2d/d27b72005b6f43599e3bcabab0d7135ac0c230b7a307bb99f9eea02c1cda/ty-0.0.34-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:30dfcec2f0fde3993f4f912ed0e057dcbebc8615299f610a4c2ddb7b5a3e1e06", size = 11553430, upload-time = "2026-05-01T23:06:31.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/12/20812e1ad930b8d4af70eebf19ad23cff6e31efcfa613ef884531fcdbaa1/ty-0.0.34-py3-none-win32.whl", hash = "sha256:97b77ddf007271b812a313a8f0a14929bc5590958433e1fb83ef585676f53342", size = 10436048, upload-time = "2026-05-01T23:06:49.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6a/afa095c5987868fbda27c0f731146ac8e3d07b357adfa83daccaee5b1a16/ty-0.0.34-py3-none-win_amd64.whl", hash = "sha256:1f543968accb952705134028d1fda8656882787dbbc667ad4d6c3ba23791d604", size = 11462526, upload-time = "2026-05-01T23:06:28.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/8f/bf041a06260d77662c0605e56dacfe90b786bf824cbe1aed238d15fe5e84/ty-0.0.34-py3-none-win_arm64.whl", hash = "sha256:ea09108cbcb16b6b06d7596312b433bf49681e78d30e4dc7fb3c1b248a95e09a", size = 10846945, upload-time = "2026-05-01T23:06:44.428Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1601,7 +1601,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "zensical"
|
||||
version = "0.0.37"
|
||||
version = "0.0.39"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1612,26 +1612,26 @@ dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
{ name = "tomli" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/57/f499eb86c487953866ab7ff364096b92d9a61fd68d38ca73f740bc42b78e/zensical-0.0.37.tar.gz", hash = "sha256:e43a59e939fbddb50d218aa5b643c24d0e7259155e9e36fb791e5f865af588d5", size = 3903829, upload-time = "2026-04-27T07:59:14.885Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/b1/819add51b42b0aac618622582db631a71cc7c6d91ab4787d73a7bd9badb1/zensical-0.0.39.tar.gz", hash = "sha256:2a8713c54362adb0881e9b0514b5ad9a696324756699dedb55fa1cbf3ccc0eda", size = 3914611, upload-time = "2026-05-01T16:33:28.402Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/66/fb/63175f1d6785616541a29b30a3a749a04a1c334d431bbae2635399612705/zensical-0.0.37-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f75668ec86f4da8302862a272a6f02a32b7384e5518842960de592e0e7b522c7", size = 12509324, upload-time = "2026-04-27T07:58:41.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/19/6c981a60ad0758b4b8601589c5016b5bf8724b066346c933af9600e3a3c0/zensical-0.0.37-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d853c285ff942883a6f93e665e12af2f4d83b682bbff16db02ce7f058f70d7a4", size = 12383773, upload-time = "2026-04-27T07:58:44.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/b0/5fd6d066ca8d9fea9b4ccafddc4b26f938c584864c648d6be29e5515787b/zensical-0.0.37-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfd5d4afa750c785a2671f586b6b82f18c906c066990ab374f76ef7d359221ce", size = 12779684, upload-time = "2026-04-27T07:58:47.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/9f/30daa249ab326d059311c51e802957a1e0bdd0505ae7d8d30c19fb00672c/zensical-0.0.37-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48c3e7e33b15d2602de4670171ab119e2e7500b34aa7a1eb1d8abb26a3725240", size = 12726437, upload-time = "2026-04-27T07:58:50.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/3c/9c1a1de28fa807b81746f58eef5f29cf353873fb7fcc2b6af51645fd4569/zensical-0.0.37-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:649c39cec8b3805b0e4abb8484f1ef85582792cea2f7376b33aa1263fe87ad95", size = 13073605, upload-time = "2026-04-27T07:58:52.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/31/de06d2481581891839f9e2a1395fe32bfb9c9eb98cd1d635c560fb759ac6/zensical-0.0.37-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4e23ef4245b07bde6d6d8dd2ab902f34f6d3cd459fd136f6d0345cb87ccbd6c", size = 12804476, upload-time = "2026-04-27T07:58:55.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/d0/5bbc27820d6cd622ddd2de5df3743a619003dcec01d51365e06ad623d984/zensical-0.0.37-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:21c6b629bc46062f8cb53e5ad575720df384e4b7db34770f3aeb3f097c3eb4cc", size = 12954884, upload-time = "2026-04-27T07:58:58.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/9f/e5c7624ccfe792254f22f8dbc7e2e4c30bf3ed14ad830e838b0009b42a58/zensical-0.0.37-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:a5f95ebe531264b36cacabf208f3ea7cb7e9d874de857bb6bd3328cdabcf9257", size = 12997404, upload-time = "2026-04-27T07:59:00.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/7a/70fe375ce974cdfd4eb42df53340c5b2e790f6a1d20a224f6ab5283c0a32/zensical-0.0.37-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:96375cd6338c2db731ce671e41afc108194552f5ceb75edd14a54eea48adfb89", size = 13139582, upload-time = "2026-04-27T07:59:03.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/49/0a7b27c89d52d0116ac466845f88495acb0b925405527df0079b2ad56508/zensical-0.0.37-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7be0976869317a2e1672f426853183c32a8193f211347d560d6037358a52fe7b", size = 13082714, upload-time = "2026-04-27T07:59:06.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/25/81c3f1de09abf571a7d6fdab3c836e37cc5a093596939d8d3af745251ae7/zensical-0.0.37-cp310-abi3-win32.whl", hash = "sha256:e1295f63230621ccb01ac7cd6be24f0ff079a12e95d9bdde631a13331c7ba67f", size = 12093854, upload-time = "2026-04-27T07:59:09.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/77/419377bee6b91746c5c3de19712eeba6c367c0b14b77c6b9c94e6bf2c3a2/zensical-0.0.37-cp310-abi3-win_amd64.whl", hash = "sha256:c015451bd9af60ee0fb01b95d0b17b751a0790fec58c3e0efd11ee4286ad2724", size = 12317524, upload-time = "2026-04-27T07:59:11.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/f0/f1103a861cd610ae6cf31c4132530b7c11b023301d58f9adcdcac52e62f9/zensical-0.0.39-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:1eb9b79936f968c0746a534ac39ddd138b5e86126843f653afaa84cfa1e8add5", size = 12669448, upload-time = "2026-05-01T16:32:54.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/97/e5a79bb004f834b7618388df99e609e4cee4e4876c81ac1c48991fee4780/zensical-0.0.39-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:093a4763c62ead4fe9cf7bf05857ce37290e4fa1903795b3dcb3006767d6c818", size = 12534151, upload-time = "2026-05-01T16:32:57.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/d4/a02fdf9a68a0229cc96d865816df5799322e8515188ffecb29eef40538d3/zensical-0.0.39-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d86ca51a69dcadd04cc664b13c76663be9067107564276a3acf8ab90c9e0b3aa", size = 12926235, upload-time = "2026-05-01T16:33:00.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/07/c36890d69273fc6bbe61d179641ce616e6e100308298b48a16019a5bd6fd/zensical-0.0.39-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:849426b5a41b58da8cef85f7d248be780b512a88953309b9a7b4879d71a37b9c", size = 12888428, upload-time = "2026-05-01T16:33:02.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/42/beb8c456ef9926dc91b288d27674813078f75dce7b28fc17c2f97168ca5c/zensical-0.0.39-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba187d2d555da85a5cde635ef32f7bee1d3634522f25b21d58a14f41a128656c", size = 13251625, upload-time = "2026-05-01T16:33:05.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/95/b31d67aa17d79048d16fe2da47176fee02eff9a0840df3a583865c0b5048/zensical-0.0.39-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:601c3ba0fc33a8fa5f14470ee99757f9946c370dd4c46c7aacf056dbdcab1df6", size = 12960908, upload-time = "2026-05-01T16:33:08.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/8c/c01c850510931fb247a9d6b6bb0bc3a9c1e7185157551ad9baf31a640e51/zensical-0.0.39-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b275d099b53e179188897037667261c9b6c647d0829ef7dbbd40f0e9ea0a1c4b", size = 13102894, upload-time = "2026-05-01T16:33:11.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/4e/d0d72beedd002986b2932f608a0390973fd50c79e0e74636766cabe4b985/zensical-0.0.39-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:8b670c6a6d8b6d484e83f9b80387d73213549f473cadb19a12026e0edbcafc7f", size = 13159755, upload-time = "2026-05-01T16:33:14.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f1/b0224a8307e806ac5887f7af3b4b5483f5656e952edadb0474ff72dc6373/zensical-0.0.39-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:1fe98c35748bd0b99dc45f843508a174362e77216dbea34321ec06aed2810816", size = 13310315, upload-time = "2026-05-01T16:33:17.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/c0/2c872d7e40030dd2f7b82d78f23ae2b259e5657d9712398968af4ffd542c/zensical-0.0.39-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:aafe2650e2f2814e95f79500cd121e35bb2fdda9b63255bb4ac4e530a05d3524", size = 13245269, upload-time = "2026-05-01T16:33:20.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/18/24cfab771f36576c8fa098c47dbc61fc19f35f6e456169dc18b5d7e6b024/zensical-0.0.39-cp310-abi3-win32.whl", hash = "sha256:c49f006444f356feb2b4067fcf1f0a2050c444c194e0e95bc5e1f53fe3f9655a", size = 12242128, upload-time = "2026-05-01T16:33:23.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/8b/39bb18c55aad6ca413c1a808693a835b19f4173b460865924d04189acac9/zensical-0.0.39-cp310-abi3-win_amd64.whl", hash = "sha256:c1c0befb8ba10aa16f0acdea9373da5eae8932d50bc996fdc54598e9483ab667", size = 12470551, upload-time = "2026-05-01T16:33:26.185Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeromq"
|
||||
version = "4.3.5"
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=zeromq&rev=release-zeromq#d5a1fb0fd7b9e56940c804513ec7d36975246d97" }
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=zeromq&rev=release-zeromq#39acf5d6cbd996fe61e8e8727e1b403e79bc2b7b" }
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
@@ -1661,4 +1661,4 @@ wheels = [
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "1.5.6"
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=zstd&rev=release-zstd#7d375bb6315585cbb81754056b7f65c476cb1ee8" }
|
||||
source = { git = "https://github.com/commaai/dependencies.git?subdirectory=zstd&rev=release-zstd#59e9ca4ecfda299d4861ea53a5fcc1eacadc5524" }
|
||||
|
||||
Reference in New Issue
Block a user