diff --git a/.github/workflows/cereal_validation.yaml b/.github/workflows/cereal_validation.yaml index e9c0e5d03e..d71354dd59 100644 --- a/.github/workflows/cereal_validation.yaml +++ b/.github/workflows/cereal_validation.yaml @@ -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 diff --git a/.gitignore b/.gitignore index 3434c7254a..ed49682f45 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ bin/ config.json compile_commands.json compare_runtime*.html +selfdrive/modeld/models/tg_compiled_flags.json # build artifacts docs_site/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ad026b12b..74d79aeacd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) ======================== diff --git a/cereal/messaging/tests/validate_sp_cereal_upstream.py b/cereal/messaging/tests/validate_sp_cereal_upstream.py index d673f7db0b..11e39cd6ce 100755 --- a/cereal/messaging/tests/validate_sp_cereal_upstream.py +++ b/cereal/messaging/tests/validate_sp_cereal_upstream.py @@ -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__": diff --git a/common/model.h b/common/model.h deleted file mode 100644 index d134ebd15e..0000000000 --- a/common/model.h +++ /dev/null @@ -1 +0,0 @@ -#define DEFAULT_MODEL "POP model (Default)" diff --git a/common/params_keys.h b/common/params_keys.h index 35a7bc3bc4..c92a6ed89c 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -204,6 +204,7 @@ inline static std::unordered_map 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}}, diff --git a/panda b/panda index 5a90799dac..0a9ef7ab54 160000 --- a/panda +++ b/panda @@ -1 +1 @@ -Subproject commit 5a90799dac68a8549981cc18efe3a60ebbded053 +Subproject commit 0a9ef7ab54224e0df79a77448eda67023b285406 diff --git a/selfdrive/car/card.py b/selfdrive/car/card.py index ca6ca89d7f..c4c81d26e3 100755 --- a/selfdrive/car/card.py +++ b/selfdrive/car/card.py @@ -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: diff --git a/selfdrive/modeld/SConscript b/selfdrive/modeld/SConscript index f02e667228..544fcafc77 100644 --- a/selfdrive/modeld/SConscript +++ b/selfdrive/modeld/SConscript @@ -1,6 +1,9 @@ -import os import glob +import json +import os +from SCons.Script import Value from openpilot.common.file_chunker import chunk_file, get_chunk_paths +from tinygrad import Device Import('env', 'arch') chunker_file = File("#common/file_chunker.py") @@ -13,31 +16,52 @@ tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + " def estimate_pickle_max_size(onnx_size): return 1.2 * onnx_size + 10 * 1024 * 1024 # 20% + 10MB is plenty -# compile warp # THREADS=0 is need to prevent bug: https://github.com/tinygrad/tinygrad/issues/14689 -tg_flags = { - 'larch64': 'DEV=QCOM FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0', - 'Darwin': f'DEV=CPU THREADS=0 HOME={os.path.expanduser("~")}', # tinygrad calls brew which needs a $HOME in the env -}.get(arch, 'DEV=CPU CPU_LLVM=1 THREADS=0') +# get fastest TG config +available = set(Device.get_available_devices()) +# FIXME-SP: reset when we bump tg +if False: # 'CUDA' in available: + tg_backend = 'CUDA' + tg_flags = f'DEV={tg_backend}' +elif 'QCOM' in available: + tg_backend = 'QCOM' + tg_flags = f'DEV={tg_backend} FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0' +else: + tg_backend = 'CPU' if arch == 'Darwin' else 'CPU CPU_LLVM=1' # FIXME-SP: reset when we bump tg + tg_flags = f'DEV={tg_backend} THREADS=0' + +def write_tg_compiled_flags(target, source, env): + with open(str(target[0]), "w") as f: + json.dump({"DEV": tg_backend}, f) + f.write("\n") + +compiled_flags_node = lenv.Command( + File("models/tg_compiled_flags.json").abspath, + tinygrad_files + [Value(tg_backend)], + write_tg_compiled_flags, +) + +# tinygrad calls brew which needs a $HOME in the env +mac_brew_string = f'HOME={os.path.expanduser("~")}' if arch == 'Darwin' else '' # Get model metadata for model_name in ['driving_vision', 'driving_policy', 'dmonitoring_model']: fn = File(f"models/{model_name}").abspath script_files = [File(Dir("#selfdrive/modeld").File("get_model_metadata.py").abspath)] - cmd = f'{tg_flags} python3 {Dir("#selfdrive/modeld").abspath}/get_model_metadata.py {fn}.onnx' - lenv.Command(fn + "_metadata.pkl", [fn + ".onnx"] + tinygrad_files + script_files, cmd) + cmd = f'{tg_flags} {mac_brew_string} python3 {Dir("#selfdrive/modeld").abspath}/get_model_metadata.py {fn}.onnx' + lenv.Command(fn + "_metadata.pkl", [fn + ".onnx"] + tinygrad_files + script_files + [compiled_flags_node], cmd) image_flag = { 'larch64': 'IMAGE=2', }.get(arch, 'IMAGE=0') script_files = [File(Dir("#selfdrive/modeld").File("compile_warp.py").abspath)] -compile_warp_cmd = f'{tg_flags} python3 {Dir("#selfdrive/modeld").abspath}/compile_warp.py ' +compile_warp_cmd = f'{tg_flags} {mac_brew_string} python3 {Dir("#selfdrive/modeld").abspath}/compile_warp.py ' from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye warp_targets = [] for cam in [_ar_ox_fisheye, _os_fisheye]: w, h = cam.width, cam.height warp_targets += [File(f"models/warp_{w}x{h}_tinygrad.pkl").abspath, File(f"models/dm_warp_{w}x{h}_tinygrad.pkl").abspath] -lenv.Command(warp_targets, tinygrad_files + script_files, compile_warp_cmd) +lenv.Command(warp_targets, tinygrad_files + script_files + [compiled_flags_node], compile_warp_cmd) def tg_compile(flags, model_name): pythonpath_string = 'PYTHONPATH="${PYTHONPATH}:' + env.Dir("#tinygrad_repo").abspath + '"' @@ -47,7 +71,7 @@ def tg_compile(flags, model_name): chunk_targets = get_chunk_paths(pkl, estimate_pickle_max_size(os.path.getsize(onnx_path))) compile_node = lenv.Command( pkl, - [onnx_path] + tinygrad_files + [chunker_file], + [onnx_path] + tinygrad_files + [chunker_file, compiled_flags_node], f'{pythonpath_string} {flags} {image_flag} python3 {Dir("#tinygrad_repo").abspath}/examples/openpilot/compile3.py {fn}.onnx {pkl}', ) def do_chunk(target, source, env): diff --git a/selfdrive/modeld/compile_warp.py b/selfdrive/modeld/compile_warp.py index 75cc65f84c..1144fc69c3 100755 --- a/selfdrive/modeld/compile_warp.py +++ b/selfdrive/modeld/compile_warp.py @@ -97,8 +97,8 @@ def make_update_img_input(frame_prepare, model_w, model_h): def update_img_input_tinygrad(tensor, frame, M_inv): M_inv = M_inv.to(Device.DEFAULT) new_img = frame_prepare(frame, M_inv) - full_buffer = tensor[6:].cat(new_img, dim=0).contiguous() - return full_buffer, Tensor.cat(full_buffer[:6], full_buffer[-6:], dim=0).contiguous().reshape(1, 12, model_h//2, model_w//2) + tensor.assign(tensor[6:].cat(new_img, dim=0).contiguous()) + return Tensor.cat(tensor[:6], tensor[-6:], dim=0).contiguous().reshape(1, 12, model_h//2, model_w//2) return update_img_input_tinygrad @@ -107,9 +107,9 @@ def make_update_both_imgs(frame_prepare, model_w, model_h): def update_both_imgs_tinygrad(calib_img_buffer, new_img, M_inv, calib_big_img_buffer, new_big_img, M_inv_big): - calib_img_buffer, calib_img_pair = update_img(calib_img_buffer, new_img, M_inv) - calib_big_img_buffer, calib_big_img_pair = update_img(calib_big_img_buffer, new_big_img, M_inv_big) - return calib_img_buffer, calib_img_pair, calib_big_img_buffer, calib_big_img_pair + calib_img_pair = update_img(calib_img_buffer, new_img, M_inv) + calib_big_img_pair = update_img(calib_big_img_buffer, new_big_img, M_inv_big) + return calib_img_pair, calib_big_img_pair return update_both_imgs_tinygrad @@ -136,29 +136,20 @@ def compile_modeld_warp(cam_w, cam_h): full_buffer = Tensor.zeros(IMG_BUFFER_SHAPE, dtype='uint8').contiguous().realize() big_full_buffer = Tensor.zeros(IMG_BUFFER_SHAPE, dtype='uint8').contiguous().realize() - full_buffer_np = np.zeros(IMG_BUFFER_SHAPE, dtype=np.uint8) - big_full_buffer_np = np.zeros(IMG_BUFFER_SHAPE, dtype=np.uint8) - + new_frame_np = np.random.randint(0, 256, yuv_size, dtype=np.uint8) + new_big_frame_np = np.random.randint(0, 256, yuv_size, dtype=np.uint8) for i in range(10): - new_frame_np = (32 * np.random.randn(yuv_size).astype(np.float32) + 128).clip(0, 255).astype(np.uint8) img_inputs = [full_buffer, Tensor.from_blob(new_frame_np.ctypes.data, (yuv_size,), dtype='uint8').realize(), Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY')] - new_big_frame_np = (32 * np.random.randn(yuv_size).astype(np.float32) + 128).clip(0, 255).astype(np.uint8) big_img_inputs = [big_full_buffer, Tensor.from_blob(new_big_frame_np.ctypes.data, (yuv_size,), dtype='uint8').realize(), Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY')] inputs = img_inputs + big_img_inputs Device.default.synchronize() - inputs_np = [x.numpy() for x in inputs] - inputs_np[0] = full_buffer_np - inputs_np[3] = big_full_buffer_np - st = time.perf_counter() - out = update_img_jit(*inputs) - full_buffer = out[0].contiguous().realize().clone() - big_full_buffer = out[2].contiguous().realize().clone() + _ = update_img_jit(*inputs) mt = time.perf_counter() Device.default.synchronize() et = time.perf_counter() @@ -182,8 +173,9 @@ def compile_dm_warp(cam_w, cam_h): warp_dm = make_warp_dm(cam_w, cam_h, dm_w, dm_h) warp_dm_jit = TinyJit(warp_dm, prune=True) + new_frame_np = np.random.randint(0, 256, yuv_size, dtype=np.uint8) for i in range(10): - inputs = [Tensor.from_blob((32 * Tensor.randn(yuv_size,) + 128).cast(dtype='uint8').realize().numpy().ctypes.data, (yuv_size,), dtype='uint8'), + inputs = [Tensor.from_blob(new_frame_np.ctypes.data, (yuv_size,), dtype='uint8').realize(), Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY')] Device.default.synchronize() st = time.perf_counter() diff --git a/selfdrive/modeld/dmonitoringmodeld.py b/selfdrive/modeld/dmonitoringmodeld.py index efd8214b9f..78749b773a 100755 --- a/selfdrive/modeld/dmonitoringmodeld.py +++ b/selfdrive/modeld/dmonitoringmodeld.py @@ -1,12 +1,16 @@ #!/usr/bin/env python3 import os +from openpilot.selfdrive.modeld.tinygrad_helpers import MODELS_DIR, set_tinygrad_backend_from_compiled_flags +set_tinygrad_backend_from_compiled_flags() + +# FIXME-SP: remove once we bump tg from openpilot.system.hardware import TICI os.environ['DEV'] = 'QCOM' if TICI else 'CPU' + from tinygrad.tensor import Tensor import time import pickle import numpy as np -from pathlib import Path from cereal import messaging from cereal.messaging import PubMaster, SubMaster @@ -21,9 +25,8 @@ from openpilot.selfdrive.modeld.parse_model_outputs import sigmoid, safe_exp PROCESS_NAME = "selfdrive.modeld.dmonitoringmodeld" SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') -MODEL_PKL_PATH = Path(__file__).parent / 'models/dmonitoring_model_tinygrad.pkl' -METADATA_PATH = Path(__file__).parent / 'models/dmonitoring_model_metadata.pkl' -MODELS_DIR = Path(__file__).parent / 'models' +MODEL_PKL_PATH = MODELS_DIR / 'dmonitoring_model_tinygrad.pkl' +METADATA_PATH = MODELS_DIR / 'dmonitoring_model_metadata.pkl' class ModelState: inputs: dict[str, np.ndarray] diff --git a/selfdrive/modeld/modeld.py b/selfdrive/modeld/modeld.py index d774e475d7..4f89058d70 100755 --- a/selfdrive/modeld/modeld.py +++ b/selfdrive/modeld/modeld.py @@ -1,7 +1,12 @@ #!/usr/bin/env python3 import os +from openpilot.selfdrive.modeld.tinygrad_helpers import MODELS_DIR, set_tinygrad_backend_from_compiled_flags +set_tinygrad_backend_from_compiled_flags() + +# FIXME-SP: remove once we bump tg from openpilot.system.hardware import TICI os.environ['DEV'] = 'QCOM' if TICI else 'CPU' + USBGPU = "USBGPU" in os.environ if USBGPU: os.environ['DEV'] = 'AMD' @@ -12,7 +17,6 @@ import pickle import numpy as np import cereal.messaging as messaging from cereal import car, log -from pathlib import Path from cereal.messaging import PubMaster, SubMaster from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf from opendbc.car.car_helpers import get_demo_car_params @@ -37,7 +41,6 @@ from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase PROCESS_NAME = "selfdrive.modeld.modeld" SEND_RAW_PRED = os.getenv('SEND_RAW_PRED') -MODELS_DIR = Path(__file__).parent / 'models' VISION_PKL_PATH = MODELS_DIR / 'driving_vision_tinygrad.pkl' VISION_METADATA_PATH = MODELS_DIR / 'driving_vision_metadata.pkl' POLICY_PKL_PATH = MODELS_DIR / 'driving_policy_tinygrad.pkl' @@ -217,8 +220,7 @@ class ModelState(ModelStateBase): out = self.update_imgs(self.img_queues['img'], self.full_frames['img'], self.transforms['img'], self.img_queues['big_img'], self.full_frames['big_img'], self.transforms['big_img']) - self.img_queues['img'], self.img_queues['big_img'] = out[0].realize(), out[2].realize() - vision_inputs = {'img': out[1], 'big_img': out[3]} + vision_inputs = {'img': out[0], 'big_img': out[1]} if prepare_only: return None diff --git a/selfdrive/modeld/tinygrad_helpers.py b/selfdrive/modeld/tinygrad_helpers.py new file mode 100644 index 0000000000..49a6ed6161 --- /dev/null +++ b/selfdrive/modeld/tinygrad_helpers.py @@ -0,0 +1,12 @@ +import json +import os +from pathlib import Path + +MODELS_DIR = Path(__file__).parent / 'models' +COMPILED_FLAGS_PATH = MODELS_DIR / 'tg_compiled_flags.json' + + +def set_tinygrad_backend_from_compiled_flags() -> None: + if os.path.isfile(COMPILED_FLAGS_PATH): + with open(COMPILED_FLAGS_PATH) as f: + os.environ['DEV'] = str(json.load(f)['DEV']) diff --git a/selfdrive/monitoring/helpers.py b/selfdrive/monitoring/helpers.py index 6c81fac7f0..aea81d336f 100644 --- a/selfdrive/monitoring/helpers.py +++ b/selfdrive/monitoring/helpers.py @@ -434,7 +434,7 @@ class DriverMonitoring: enabled = sm['selfdriveState'].enabled or sm['carControl'].latActive wrong_gear = sm['carState'].gearShifter not in (car.CarState.GearShifter.drive, car.CarState.GearShifter.low) standstill = sm['carState'].standstill - driver_engaged = sm['carState'].steeringPressed or sm['carState'].gasPressed + driver_engaged = sm['carState'].steeringPressed or (sm['selfdriveState'].enabled and sm['carState'].gasPressed) brake_disengage_prob = sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs[0] # brake disengage prob in next 2s rpyCalib = sm['liveCalibration'].rpyCalib self._set_policy( diff --git a/selfdrive/monitoring/test_monitoring.py b/selfdrive/monitoring/test_monitoring.py index ee6028d609..30e81d2dbf 100644 --- a/selfdrive/monitoring/test_monitoring.py +++ b/selfdrive/monitoring/test_monitoring.py @@ -215,64 +215,48 @@ class TestMonitoring: events[int((INVISIBLE_SECONDS_TO_RED-1+DT_DMON*d_status.settings._HI_STD_FALLBACK_TIME+0.1)/DT_DMON)].names -@pytest.mark.parametrize("enabled_state, lat_active_state, expected", [ - (False, False, False), # Both Disabled - (True, False, True), # OP Enabled, Lat Inactive - (False, True, True), # OP Disabled, Lat Active (e.g. MADS) - (True, True, True) # Both Active -]) -def test_enabled_states(enabled_state, lat_active_state, expected): - """ - Test DriverMonitoring.run_step with all 4 combinations of: - - selfdriveState.enabled (True/False) - - carControl.latActive (True/False) - """ +def _build_sm(selfdrive_enabled, lat_active, steering_pressed, gas_pressed): cs = car.CarState.new_message() cs.vEgo = 30.0 cs.gearShifter = car.CarState.GearShifter.drive - cs.standstill = False - cs.steeringPressed = False - cs.gasPressed = False - + cs.steeringPressed = steering_pressed + cs.gasPressed = gas_pressed ss = log.SelfdriveState.new_message() - ss.enabled = enabled_state - + ss.enabled = selfdrive_enabled cc = car.CarControl.new_message() - cc.latActive = lat_active_state - + cc.latActive = lat_active mv2 = log.ModelDataV2.new_message() mv2.meta.disengagePredictions.brakeDisengageProbs = [0.0] - lc = log.LiveCalibrationData.new_message() lc.rpyCalib = [0.0, 0.0, 0.0] - - ds = make_msg(False) - - sm = { - 'carState': cs, - 'selfdriveState': ss, - 'carControl': cc, - 'modelV2': mv2, - 'liveCalibration': lc, - 'driverStateV2': ds + return { + 'carState': cs, 'selfdriveState': ss, 'carControl': cc, + 'modelV2': mv2, 'liveCalibration': lc, 'driverStateV2': make_msg(False), } - driver_monitoring = DriverMonitoring() - # run_test doesn't assign enabled to a variable, so we need to spy on _update_events to see its value - captured_args = [] - original_update_events = driver_monitoring._update_events +@pytest.mark.parametrize("selfdrive_enabled, lat_active, steering, gas, expected_op_engaged, expected_driver_engaged", [ + (False, False, False, False, False, False), # disabled + (True, False, False, False, True, False), # OP enabled + (False, True, False, False, True, False), # MADS lat-only + (True, True, False, False, True, False), # both active + (False, True, False, True, True, False), # MADS lat-only + gas + (True, True, False, True, True, True), # full op + gas: override + (False, True, True, False, True, True), # MADS lat-only + wheel touch: override +]) +def test_run_step_engagement(selfdrive_enabled, lat_active, steering, gas, + expected_op_engaged, expected_driver_engaged): + sm = _build_sm(selfdrive_enabled, lat_active, steering, gas) + dm = DriverMonitoring() + captured = {} + orig = dm._update_events - def spy_update_events(driver_engaged, op_engaged, standstill, wrong_gear, car_speed): - captured_args.append(op_engaged) - return original_update_events(driver_engaged, op_engaged, standstill, wrong_gear, car_speed) + def spy(driver_engaged, op_engaged, standstill, wrong_gear, car_speed): + captured['driver_engaged'] = driver_engaged + captured['op_engaged'] = op_engaged + return orig(driver_engaged, op_engaged, standstill, wrong_gear, car_speed) - driver_monitoring._update_events = spy_update_events - - driver_monitoring.run_step(sm, demo=False) - - # Assertion - assert len(captured_args) == 1, "Expected _update_events to be called exactly once" - actual_enabled = captured_args[0] - - assert actual_enabled == expected, f"Expected op_engaged={expected}, but got {actual_enabled}" + dm._update_events = spy + dm.run_step(sm, demo=False) + assert captured['op_engaged'] == expected_op_engaged + assert captured['driver_engaged'] == expected_driver_engaged diff --git a/selfdrive/ui/layouts/settings/developer.py b/selfdrive/ui/layouts/settings/developer.py index b632e4e0d2..964d28bf9c 100644 --- a/selfdrive/ui/layouts/settings/developer.py +++ b/selfdrive/ui/layouts/settings/developer.py @@ -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( diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py index 9923f3a356..dfd0c73f61 100644 --- a/selfdrive/ui/layouts/settings/toggles.py +++ b/selfdrive/ui/layouts/settings/toggles.py @@ -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 = { diff --git a/selfdrive/ui/sunnypilot/layouts/settings/models.py b/selfdrive/ui/sunnypilot/layouts/settings/models.py index adbd99f1f3..81f1c47db2 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/models.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/models.py @@ -10,6 +10,7 @@ import time import pyray as rl from cereal import custom +from openpilot.sunnypilot.models.default_model import DEFAULT_MODEL from openpilot.common.constants import CV from openpilot.selfdrive.ui.ui_state import device, ui_state from openpilot.system.ui.lib.multilang import tr @@ -207,7 +208,7 @@ class ModelsLayout(Widget): for bundle in bundles: folders.setdefault(next((ov_ride.value for ov_ride in bundle.overrides if ov_ride.key == "folder"), ""), []).append(bundle) - folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': tr("Default Model"), 'short_name': "Default"})])] + folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': f"{DEFAULT_MODEL} (Default)", 'short_name': "Default"})])] for folder, folder_bundles in sorted(folders.items(), key=lambda x: max((bundle.index for bundle in x[1]), default=-1), reverse=True): folder_bundles.sort(key=lambda bundle: bundle.index, reverse=True) name = folder + (f" - (Updated: {m.group(1)})" if folder_bundles and (m := re.search(r'\(([^)]*)\)[^(]*$', folder_bundles[0].displayName)) else "") @@ -243,7 +244,7 @@ class ModelsLayout(Widget): self._update_lagd_description(live_delay) self.model_manager = ui_state.sm["modelManagerSP"] self._handle_bundle_download_progress() - active_name = self.model_manager.activeBundle.internalName if self.model_manager and self.model_manager.activeBundle.ref else tr("Default Model") + active_name = self.model_manager.activeBundle.internalName if self.model_manager and self.model_manager.activeBundle.ref else f"{DEFAULT_MODEL} (Default)" self.current_model_item.action_item.set_value(active_name) if not ui_state.is_offroad(): diff --git a/selfdrive/ui/sunnypilot/layouts/settings/steering.py b/selfdrive/ui/sunnypilot/layouts/settings/steering.py index 14d840138e..e31ee1891c 100644 --- a/selfdrive/ui/sunnypilot/layouts/settings/steering.py +++ b/selfdrive/ui/sunnypilot/layouts/settings/steering.py @@ -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"{mads_main_desc}

{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"{self._mads_check_compat_desc}

{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()) diff --git a/selfdrive/ui/sunnypilot/mici/layouts/models.py b/selfdrive/ui/sunnypilot/mici/layouts/models.py index 331743416f..7f3d9a065b 100644 --- a/selfdrive/ui/sunnypilot/mici/layouts/models.py +++ b/selfdrive/ui/sunnypilot/mici/layouts/models.py @@ -8,6 +8,7 @@ from collections.abc import Callable import pyray as rl from cereal import custom +from openpilot.sunnypilot.models.default_model import DEFAULT_MODEL from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.sunnypilot.layouts.settings.models import ModelsLayout from openpilot.selfdrive.ui.ui_state import ui_state, device @@ -27,7 +28,8 @@ class CurrentModelInfo(Widget): subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) max_width = int(self._rect.width - 20) self.current_model_header = UnifiedLabel(tr("active model"), 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY) - self.current_model_text = UnifiedLabel(tr("default model"), 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, scroll=True) + default_text = f"{DEFAULT_MODEL} (Default)".lower() + self.current_model_text = UnifiedLabel(default_text, 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN, scroll=True) self.info_header = UnifiedLabel("cache size", 48, max_width=max_width, text_color=header_color, font_weight=FontWeight.DISPLAY) self.info_text = UnifiedLabel("0 mb", 32, max_width=max_width, text_color=subheader_color, font_weight=FontWeight.ROMAN) @@ -98,7 +100,7 @@ class ModelsLayoutMici(NavScroller): folders = self._get_grouped_bundles(favorites) folder_buttons = [] - default_btn = BigButton(tr("default model")) + default_btn = BigButton(f"{DEFAULT_MODEL} (Default)".lower()) default_btn.set_click_callback(self._select_default) folder_buttons.append(default_btn) @@ -168,7 +170,8 @@ class ModelsLayoutMici(NavScroller): self._was_downloading = is_downloading self.current_model_info.current_model_header.set_text(tr("active model")) - self.current_model_info.current_model_text.set_text(manager.activeBundle.displayName.lower() if manager.activeBundle.index > 0 else tr("default model")) + model_text = manager.activeBundle.displayName.lower() if manager.activeBundle.index > 0 else f"{DEFAULT_MODEL} (Default)".lower() + self.current_model_info.current_model_text.set_text(model_text) self.current_model_info.info_header.set_text(tr("cache size")) self.current_model_info.info_text.set_text(f"{ModelsLayout.calculate_cache_size():.2f} MB") diff --git a/selfdrive/ui/sunnypilot/mici/layouts/settings.py b/selfdrive/ui/sunnypilot/mici/layouts/settings.py index 85a782ecb7..96a4c789c1 100644 --- a/selfdrive/ui/sunnypilot/mici/layouts/settings.py +++ b/selfdrive/ui/sunnypilot/mici/layouts/settings.py @@ -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) diff --git a/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py b/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py index 8204253d32..889e1737ee 100644 --- a/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py +++ b/selfdrive/ui/sunnypilot/onroad/developer_ui/__init__.py @@ -141,7 +141,8 @@ class DeveloperUiRenderer(Widget): # Add torque-specific elements if using torque control if sm['controlsState'].lateralControlState.which() == 'torqueState': - if sm.valid['liveTorqueParameters']: + override_active = ui_state.enforce_torque_control and ui_state.custom_torque_params and ui_state.torque_override_enabled + if sm.valid['liveTorqueParameters'] or override_active: elements.extend([ self.friction_elem.update(sm, ui_state.is_metric), self.lat_accel_factor_elem.update(sm, ui_state.is_metric), diff --git a/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py b/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py index 94e3af42eb..389692d30b 100644 --- a/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py +++ b/selfdrive/ui/sunnypilot/onroad/developer_ui/elements.py @@ -8,8 +8,7 @@ import pyray as rl from dataclasses import dataclass from openpilot.common.constants import CV - - +from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -248,12 +247,12 @@ class FrictionCoefficientElement: self.unit = "" def update(self, sm, is_metric: bool) -> UiElement: - ltp = sm['liveTorqueParameters'] - friction_coef = ltp.frictionCoefficientFiltered - live_valid = ltp.liveValid + if ui_state.enforce_torque_control and ui_state.custom_torque_params and ui_state.torque_override_enabled: + return UiElement(f"{ui_state.torque_override_friction:.3f}", "FRIC.", self.unit, rl.WHITE) - value = f"{friction_coef:.3f}" - color = rl.Color(0, 255, 0, 255) if live_valid else rl.WHITE + ltp = sm['liveTorqueParameters'] + value = f"{ltp.frictionCoefficientFiltered:.3f}" + color = rl.Color(0, 255, 0, 255) if ltp.liveValid else rl.WHITE return UiElement(value, "FRIC.", self.unit, color) @@ -262,12 +261,12 @@ class LatAccelFactorElement: self.unit = "" def update(self, sm, is_metric: bool) -> UiElement: - ltp = sm['liveTorqueParameters'] - lat_accel_factor = ltp.latAccelFactorFiltered - live_valid = ltp.liveValid + if ui_state.enforce_torque_control and ui_state.custom_torque_params and ui_state.torque_override_enabled: + return UiElement(f"{ui_state.torque_override_lat_accel_factor:.3f}", "L.A.F.", self.unit, rl.WHITE) - value = f"{lat_accel_factor:.3f}" - color = rl.Color(0, 255, 0, 255) if live_valid else rl.WHITE + ltp = sm['liveTorqueParameters'] + value = f"{ltp.latAccelFactorFiltered:.3f}" + color = rl.Color(0, 255, 0, 255) if ltp.liveValid else rl.WHITE return UiElement(value, "L.A.F.", self.unit, color) diff --git a/selfdrive/ui/sunnypilot/ui_state.py b/selfdrive/ui/sunnypilot/ui_state.py index 2a48e9eeef..2cfd5bd4f2 100644 --- a/selfdrive/ui/sunnypilot/ui_state.py +++ b/selfdrive/ui/sunnypilot/ui_state.py @@ -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") @@ -143,11 +143,63 @@ class UIStateSP: self.standstill_timer = self.params.get_bool("StandstillTimer") self.sunnylink_enabled = self.params.get_bool("SunnylinkEnabled") self.torque_bar = self.params.get_bool("TorqueBar") + self.enforce_torque_control = self.params.get_bool("EnforceTorqueControl") + self.custom_torque_params = self.params.get_bool("CustomTorqueParams") + self.torque_override_enabled = self.params.get_bool("TorqueParamsOverrideEnabled") + self.torque_override_lat_accel_factor = float(self.params.get("TorqueParamsOverrideLatAccelFactor", return_default=True)) + self.torque_override_friction = float(self.params.get("TorqueParamsOverrideFriction", return_default=True)) self.true_v_ego_ui = self.params.get_bool("TrueVEgoUI") self.turn_signals = self.params.get_bool("ShowTurnSignals") 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 @@ -163,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 diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index f7a5d44d9a..64c0b88d1f 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -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 diff --git a/sunnypilot/mads/mads.py b/sunnypilot/mads/mads.py index 0c87da4a2a..bd9c8d78ff 100644 --- a/sunnypilot/mads/mads.py +++ b/sunnypilot/mads/mads.py @@ -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) diff --git a/sunnypilot/modeld_v2/warp.py b/sunnypilot/modeld_v2/warp.py index 829cbcca49..fd8be46837 100644 --- a/sunnypilot/modeld_v2/warp.py +++ b/sunnypilot/modeld_v2/warp.py @@ -33,29 +33,20 @@ def compile_v2_warp(cam_w, cam_h, buffer_length): full_buffer = Tensor.zeros(img_buffer_shape, dtype='uint8').contiguous().realize() big_full_buffer = Tensor.zeros(img_buffer_shape, dtype='uint8').contiguous().realize() - full_buffer_np = np.zeros(img_buffer_shape, dtype=np.uint8) - big_full_buffer_np = np.zeros(img_buffer_shape, dtype=np.uint8) - + new_frame_np = np.random.randint(0, 256, yuv_size, dtype=np.uint8) + new_big_frame_np = np.random.randint(0, 256, yuv_size, dtype=np.uint8) for i in range(10): - new_frame_np = (32 * np.random.randn(yuv_size).astype(np.float32) + 128).clip(0, 255).astype(np.uint8) img_inputs = [full_buffer, Tensor.from_blob(new_frame_np.ctypes.data, (yuv_size,), dtype='uint8').realize(), Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY')] - new_big_frame_np = (32 * np.random.randn(yuv_size).astype(np.float32) + 128).clip(0, 255).astype(np.uint8) big_img_inputs = [big_full_buffer, Tensor.from_blob(new_big_frame_np.ctypes.data, (yuv_size,), dtype='uint8').realize(), Tensor(Tensor.randn(3, 3).mul(8).realize().numpy(), device='NPY')] inputs = img_inputs + big_img_inputs Device.default.synchronize() - inputs_np = [x.numpy() for x in inputs] - inputs_np[0] = full_buffer_np - inputs_np[3] = big_full_buffer_np - st = time.perf_counter() - out = update_img_jit(*inputs) - full_buffer = out[0].contiguous().realize().clone() - big_full_buffer = out[2].contiguous().realize().clone() + _ = update_img_jit(*inputs) mt = time.perf_counter() Device.default.synchronize() et = time.perf_counter() @@ -125,8 +116,8 @@ class Warp: self.full_buffers['img'], road_blob, self.transforms['img'], self.full_buffers['big_img'], wide_blob, self.transforms['big_img'], ) - self.full_buffers['img'], out_road = res[0].realize(), res[1].realize() - self.full_buffers['big_img'], out_wide = res[2].realize(), res[3].realize() + out_road = res[0].realize() + out_wide = res[1].realize() return {road: out_road, wide: out_wide} diff --git a/sunnypilot/models/default_model.py b/sunnypilot/models/default_model.py index 0260a3c3bc..f0469a4601 100755 --- a/sunnypilot/models/default_model.py +++ b/sunnypilot/models/default_model.py @@ -4,8 +4,9 @@ import hashlib from openpilot.common.basedir import BASEDIR from openpilot.sunnypilot import get_file_hash +from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL -DEFAULT_MODEL_NAME_PATH = os.path.join(BASEDIR, "common", "model.h") +DEFAULT_MODEL_NAME_PATH = os.path.join(BASEDIR, "sunnypilot", "models", "model_name.py") MODEL_HASH_PATH = os.path.join(BASEDIR, "sunnypilot", "models", "tests", "model_hash") VISION_ONNX_PATH = os.path.join(BASEDIR, "selfdrive", "modeld", "models", "driving_vision.onnx") POLICY_ONNX_PATH = os.path.join(BASEDIR, "selfdrive", "modeld", "models", "driving_policy.onnx") @@ -25,8 +26,7 @@ def update_model_hash(): def get_current_default_model_name(): print("[GET DEFAULT MODEL NAME]") - with open(DEFAULT_MODEL_NAME_PATH) as f: - name = f.read().split('"')[1] + name = DEFAULT_MODEL print(f'Current default model name: "{name}"') return name @@ -35,7 +35,7 @@ def get_current_default_model_name(): def update_default_model_name(name: str): print("[CHANGE DEFAULT MODEL NAME]") with open(DEFAULT_MODEL_NAME_PATH, "w") as f: - f.write(f'#define DEFAULT_MODEL "{name}"\n') + f.write(f'DEFAULT_MODEL = "{name}"\n') print(f'New default model name: "{name}"') print("[DONE]") @@ -51,7 +51,7 @@ if __name__ == "__main__": exit(0) current_name = get_current_default_model_name() - new_name = f"{args.new_name} (Default)" + new_name = args.new_name if current_name == new_name: print(f'Proposed default model name: "{new_name}"') confirm = input("Proposed default model name is the same as the current default model name. Confirm? (y/n): ").upper().strip() diff --git a/sunnypilot/models/model_name.py b/sunnypilot/models/model_name.py new file mode 100644 index 0000000000..2d9c549763 --- /dev/null +++ b/sunnypilot/models/model_name.py @@ -0,0 +1 @@ +DEFAULT_MODEL = "POP model" diff --git a/sunnypilot/selfdrive/car/sync_car_list_param.py b/sunnypilot/selfdrive/car/sync_sunnylink_params.py similarity index 100% rename from sunnypilot/selfdrive/car/sync_car_list_param.py rename to sunnypilot/selfdrive/car/sync_sunnylink_params.py diff --git a/sunnypilot/selfdrive/locationd/torqued_ext.py b/sunnypilot/selfdrive/locationd/torqued_ext.py index 58a23da00e..1e62b7666a 100644 --- a/sunnypilot/selfdrive/locationd/torqued_ext.py +++ b/sunnypilot/selfdrive/locationd/torqued_ext.py @@ -4,7 +4,6 @@ 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 numpy as np from cereal import car @@ -18,7 +17,6 @@ RELAXED_MIN_BUCKET_POINTS = np.array([1, 200, 300, 500, 500, 300, 200, 1]) ALLOWED_CARS = ['toyota', 'hyundai', 'rivian', 'honda'] - class TorqueEstimatorExt: def __init__(self, CP: car.CarParams): self.CP = CP @@ -28,6 +26,7 @@ class TorqueEstimatorExt: self.enforce_torque_control_toggle = self._params.get_bool("EnforceTorqueControl") # only during init self.use_params = self.CP.brand in ALLOWED_CARS and self.CP.lateralTuning.which() == 'torque' self.use_live_torque_params = self._params.get_bool("LiveTorqueParamsToggle") + self.custom_torque_params = self._params.get_bool("CustomTorqueParams") self.torque_override_enabled = self._params.get_bool("TorqueParamsOverrideEnabled") self.min_bucket_points = RELAXED_MIN_BUCKET_POINTS self.factor_sanity = 0.0 @@ -51,13 +50,14 @@ class TorqueEstimatorExt: def _update_params(self): if self.frame % int(PARAMS_UPDATE_PERIOD / DT_MDL) == 0: self.use_live_torque_params = self._params.get_bool("LiveTorqueParamsToggle") + self.custom_torque_params = self._params.get_bool("CustomTorqueParams") self.torque_override_enabled = self._params.get_bool("TorqueParamsOverrideEnabled") def update_use_params(self): self._update_params() if self.enforce_torque_control_toggle: - if self.torque_override_enabled: + if self.custom_torque_params and self.torque_override_enabled: self.use_params = False else: self.use_params = self.use_live_torque_params diff --git a/sunnypilot/sunnylink/api.py b/sunnypilot/sunnylink/api.py index bda97262cb..5260f6bd9d 100644 --- a/sunnypilot/sunnylink/api.py +++ b/sunnypilot/sunnylink/api.py @@ -30,10 +30,6 @@ class SunnylinkApi(BaseApi): return super().api_get(endpoint, method, timeout, access_token, session, json, **kwargs) - def resume_queued(self, timeout=10, **kwargs): - sunnylinkId, commaId = self._resolve_dongle_ids() - return self.api_get(f"ws/{sunnylinkId}/resume_queued", "POST", timeout, access_token=self.get_token(), **kwargs) - def get_token(self, payload_extra=None, expiry_hours=1): # Add your additional data here additional_data = {} diff --git a/sunnypilot/sunnylink/athena/sunnylinkd.py b/sunnypilot/sunnylink/athena/sunnylinkd.py index 39fe5679ce..5e7b400d97 100755 --- a/sunnypilot/sunnylink/athena/sunnylinkd.py +++ b/sunnypilot/sunnylink/athena/sunnylinkd.py @@ -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 @@ -28,11 +28,14 @@ from websocket import (ABNF, WebSocket, WebSocketException, WebSocketTimeoutExce create_connection, WebSocketConnectionClosedException) import cereal.messaging as messaging -from openpilot.sunnypilot.selfdrive.car.sync_car_list_param import update_car_list_param +from openpilot.sunnypilot.models.default_model import DEFAULT_MODEL +from openpilot.sunnypilot.selfdrive.car.sync_sunnylink_params 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 +47,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 } @@ -64,7 +70,6 @@ def handle_long_poll(ws: WebSocket, exit_event: threading.Event | None) -> None: threading.Thread(target=ws_recv, args=(ws, end_event), name='ws_recv'), threading.Thread(target=ws_send, args=(ws, end_event), name='ws_send'), threading.Thread(target=ws_ping, args=(ws, end_event), name='ws_ping'), - threading.Thread(target=ws_queue, args=(end_event,), name='ws_queue'), threading.Thread(target=upload_handler, args=(end_event,), name='upload_handler'), threading.Thread(target=sunny_log_handler, args=(end_event, comma_prime_cellular_end_event), name='log_handler'), threading.Thread(target=stat_handler, args=(end_event, Paths.stats_sp_root(), True), name='stat_handler'), @@ -147,37 +152,6 @@ def ws_ping(ws: WebSocket, end_event: threading.Event) -> None: cloudlog.debug("sunnylinkd.ws_ping.end_event is set, exiting ws_ping thread") -def ws_queue(end_event: threading.Event) -> None: - sunnylink_dongle_id = params.get("SunnylinkDongleId") - sunnylink_api = SunnylinkApi(sunnylink_dongle_id) - resume_requested = False - tries = 0 - - while not end_event.is_set() and not resume_requested: - try: - if not resume_requested: - cloudlog.debug("sunnylinkd.ws_queue.resume_queued") - sunnylink_api.resume_queued(timeout=29) - resume_requested = True - tries = 0 - except Exception as e: - if isinstance(e, (ConnectionError, TimeoutError)): - cloudlog.warning(f"sunnylinkd.ws_queue.resume_queued.{type(e).__name__}") - else: - cloudlog.exception("sunnylinkd.ws_queue.resume_queued.exception") - - resume_requested = False - tries += 1 - time.sleep(backoff(tries)) - - if end_event.is_set(): - cloudlog.debug("end_event is set, exiting ws_queue thread") - elif resume_requested: - cloudlog.debug(f"Resume requested to server after {tries} tries") - else: - cloudlog.error(f"Reached end of ws_queue while end_event is not set and resume_requested is {resume_requested}") - - def sunny_log_handler(end_event: threading.Event, comma_prime_cellular_end_event: threading.Event) -> None: while not end_event.wait(0.1): if not comma_prime_cellular_end_event.is_set(): @@ -231,34 +205,19 @@ 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 + schema["default_model"] = DEFAULT_MODEL + raw = json.dumps(schema, separators=(",", ":")).encode("utf-8") + return base64.b64encode(gzip.compress(raw)).decode("utf-8") except Exception: cloudlog.exception("sunnylinkd.getParamsMetadata.exception") raise @@ -270,12 +229,25 @@ def getParams(params_keys: list[str], compression: bool = False) -> str | dict[s available_keys: list[str] = [k.decode('utf-8') for k in Params().all_keys()] try: + zero_values: dict[int, bytes] = { + ParamKeyType.STRING.value: b"", + ParamKeyType.BOOL.value: b"0", + ParamKeyType.INT.value: b"0", + ParamKeyType.FLOAT.value: b"0.0", + ParamKeyType.TIME.value: b"", + ParamKeyType.JSON.value: b"{}", + ParamKeyType.BYTES.value: b"", + } + param_keys_validated = [key for key in params_keys if key in available_keys] params_dict: dict[str, list[dict[str, str | bool | int]]] = {"params": []} for key in param_keys_validated: value = get_param_as_byte(key) if value is None: - continue + value = get_param_as_byte(key, get_default=True) + if value is None: + param_type = params.get_type(key) + value = zero_values.get(param_type.value, b"") params_dict["params"].append({ "key": key, @@ -306,6 +278,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") diff --git a/sunnypilot/sunnylink/capabilities.py b/sunnypilot/sunnylink/capabilities.py new file mode 100644 index 0000000000..933368713e --- /dev/null +++ b/sunnypilot/sunnylink/capabilities.py @@ -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=(",", ":")) diff --git a/sunnypilot/sunnylink/docs/README.md b/sunnypilot/sunnylink/docs/README.md new file mode 100644 index 0000000000..7327564ace --- /dev/null +++ b/sunnypilot/sunnylink/docs/README.md @@ -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/.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/"}`. | 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/"}`. + +```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/.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/.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.` 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/.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/.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` diff --git a/sunnypilot/sunnylink/params_metadata.json b/sunnypilot/sunnylink/params_metadata.json index 41725883a8..83a0cec94a 100644 --- a/sunnypilot/sunnylink/params_metadata.json +++ b/sunnypilot/sunnylink/params_metadata.json @@ -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", diff --git a/sunnypilot/sunnylink/settings_ui.json b/sunnypilot/sunnylink/settings_ui.json new file mode 100644 index 0000000000..25c3b6335b --- /dev/null +++ b/sunnypilot/sunnylink/settings_ui.json @@ -0,0 +1,2208 @@ +{ + "$schema": "./settings_ui.schema.json", + "schema_version": "1.0", + "panels": [ + { + "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": [ + { + "type": "offroad_only" + } + ] + } + ], + "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": [ + { + "type": "offroad_only" + }, + { + "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 + } + } + ] + } + ] + } + } + ] + }, + { + "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": [ + { + "type": "offroad_only" + }, + { + "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 + } + } + ] + } + ] + } + } + ] + }, + { + "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": [ + { + "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 + } + } + ] + } + ] + } + } + ] + }, + { + "value": 1, + "label": "Pause", + "enablement": [ + { + "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 + } + } + ] + } + ] + } + } + ] + }, + { + "value": 2, + "label": "Disengage" + } + ], + "enablement": [ + { + "type": "offroad_only" + } + ] + } + ] + } + ] + }, + { + "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": [ + { + "type": "offroad_only" + }, + { + "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": [ + { + "type": "offroad_only" + } + ] + }, + { + "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": [ + { + "type": "offroad_only" + }, + { + "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": [ + { + "type": "offroad_only" + } + ] + }, + { + "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": [ + { + "type": "offroad_only" + }, + { + "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\u00b2", + "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": [ + { + "type": "offroad_only" + } + ] + } + ] + } + ] + }, + { + "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 + } + ] + } + ] + } + ] + }, + { + "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": [ + { + "type": "capability", + "field": "has_longitudinal_control", + "equals": true + } + ] + }, + { + "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": [ + { + "type": "capability", + "field": "has_longitudinal_control", + "equals": true + } + ], + "enablement": [ + { + "type": "capability", + "field": "has_longitudinal_control", + "equals": true + } + ] + }, + { + "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": [ + { + "type": "capability", + "field": "has_longitudinal_control", + "equals": true + } + ] + }, + { + "key": "IntelligentCruiseButtonManagement", + "widget": "toggle", + "title": "Intelligent Cruise Button Management (ICBM) (Alpha)", + "visibility": [ + { + "type": "capability", + "field": "icbm_available", + "equals": true + } + ], + "enablement": [ + { + "type": "offroad_only" + }, + { + "type": "not", + "condition": { + "type": "capability", + "field": "has_longitudinal_control", + "equals": true + } + } + ] + } + ] + }, + { + "id": "custom_acc_increments", + "title": "Custom ACC Speed Intervals", + "description": "", + "enablement": [ + { + "type": "offroad_only" + }, + { + "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": [ + { + "type": "offroad_only" + }, + { + "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 + } + ] + } + ] + } + ] + } + ] + }, + { + "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" + } + ] + } + ] + } + ] + }, + { + "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": [ + { + "type": "not", + "condition": { + "type": "capability", + "field": "device_type", + "equals": "mici" + } + } + ] + }, + { + "key": "StandstillTimer", + "widget": "toggle", + "title": "Standstill Timer", + "description": "Show a timer on the HUD when the car is at a standstill.", + "visibility": [ + { + "type": "not", + "condition": { + "type": "capability", + "field": "device_type", + "equals": "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": [ + { + "type": "not", + "condition": { + "type": "capability", + "field": "device_type", + "equals": "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": [ + { + "type": "not", + "condition": { + "type": "capability", + "field": "device_type", + "equals": "mici" + } + } + ], + "enablement": [ + { + "type": "capability", + "field": "has_longitudinal_control", + "equals": true + } + ] + } + ] + }, + { + "id": "developer_ui", + "title": "Developer UI Info", + "description": "Speedometer and debug display options", + "visibility": [ + { + "type": "not", + "condition": { + "type": "capability", + "field": "device_type", + "equals": "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." + } + ] + } + ] + }, + { + "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": [ + { + "type": "offroad_only" + } + ] + }, + { + "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." + } + ] + } + ] + }, + { + "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" + } + ] + } + ] + }, + { + "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": [ + { + "type": "offroad_only" + }, + { + "type": "param", + "key": "ShowAdvancedControls", + "equals": true + } + ] + } + ] + } + ] + }, + { + "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": [ + { + "type": "offroad_only" + } + ] + }, + { + "key": "SshEnabled", + "widget": "toggle", + "blocked": true, + "title": "Enable SSH" + }, + { + "key": "JoystickDebugMode", + "widget": "toggle", + "title": "Joystick Debug Mode", + "enablement": [ + { + "type": "offroad_only" + } + ] + }, + { + "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": [ + { + "type": "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": [ + { + "type": "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": [ + { + "type": "not_engaged" + }, + { + "type": "capability", + "field": "has_longitudinal_control", + "equals": true + } + ] + } + ] + }, + { + "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": [ + { + "type": "not", + "condition": { + "type": "capability", + "field": "is_sp_release", + "equals": true + } + } + ], + "enablement": [ + { + "type": "param", + "key": "ShowAdvancedControls", + "equals": true + } + ] + }, + { + "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": [ + { + "type": "param", + "key": "ShowAdvancedControls", + "equals": true + } + ] + }, + { + "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 + }, + { + "type": "param", + "key": "ShowAdvancedControls", + "equals": true + } + ] + } + ] + } + ] + }, + { + "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 + }, + { + "type": "param", + "key": "ShowAdvancedControls", + "equals": true + } + ] + }, + { + "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 + } + }, + { + "type": "param", + "key": "ShowAdvancedControls", + "equals": true + } + ] + } + ] + }, + { + "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": [ + { + "type": "offroad_only" + }, + { + "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": [ + { + "type": "param", + "key": "ShowAdvancedControls", + "equals": true + } + ] + } + ] + } + ] + } + ], + "vehicle_settings": { + "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": [ + { + "type": "offroad_only" + }, + { + "type": "capability", + "field": "has_longitudinal_control", + "equals": true + } + ] + } + ] + }, + "subaru": { + "title": "Subaru Settings", + "description": "", + "items": [ + { + "key": "SubaruStopAndGo", + "widget": "toggle", + "title": "Stop and Go (Beta)", + "enablement": [ + { + "type": "offroad_only" + }, + { + "type": "capability", + "field": "has_stop_and_go", + "equals": true + } + ] + }, + { + "key": "SubaruStopAndGoManualParkingBrake", + "widget": "toggle", + "title": "Stop and Go for Manual Parking Brake (Beta)", + "enablement": [ + { + "type": "offroad_only" + }, + { + "type": "capability", + "field": "has_stop_and_go", + "equals": true + } + ] + } + ] + }, + "tesla": { + "title": "Tesla Settings", + "description": "", + "items": [ + { + "key": "TeslaCoopSteering", + "widget": "toggle", + "title": "Cooperative Steering (Beta)", + "enablement": [ + { + "type": "offroad_only" + } + ] + } + ] + }, + "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": [ + { + "type": "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": [ + { + "type": "not_engaged" + }, + { + "type": "capability", + "field": "has_longitudinal_control", + "equals": true + }, + { + "type": "param", + "key": "ToyotaEnforceStockLongitudinal", + "equals": false + } + ] + } + ] + } + } +} diff --git a/sunnypilot/sunnylink/settings_ui.schema.json b/sunnypilot/sunnylink/settings_ui.schema.json new file mode 100644 index 0000000000..38c7d1b5ab --- /dev/null +++ b/sunnypilot/sunnylink/settings_ui.schema.json @@ -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 + } + } + } + } +} diff --git a/sunnypilot/sunnylink/settings_ui_src/_macros.yaml b/sunnypilot/sunnylink/settings_ui_src/_macros.yaml new file mode 100644 index 0000000000..a771720831 --- /dev/null +++ b/sunnypilot/sunnylink/settings_ui_src/_macros.yaml @@ -0,0 +1,65 @@ +# Named rule fragments. Reference from items/sections via {$ref: "#/macros/"}. +# 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} diff --git a/sunnypilot/sunnylink/settings_ui_src/_schemas/macros.schema.json b/sunnypilot/sunnylink/settings_ui_src/_schemas/macros.schema.json new file mode 100644 index 0000000000..57629acecb --- /dev/null +++ b/sunnypilot/sunnylink/settings_ui_src/_schemas/macros.schema.json @@ -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/'}.", + "patternProperties": { + "^[A-Za-z_][A-Za-z0-9_]*$": { + "oneOf": [ + {"type": "array", "items": {"$ref": "rule.schema.json"}}, + {"$ref": "rule.schema.json"} + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/sunnypilot/sunnylink/settings_ui_src/_schemas/page.schema.json b/sunnypilot/sunnylink/settings_ui_src/_schemas/page.schema.json new file mode 100644 index 0000000000..0e312dc745 --- /dev/null +++ b/sunnypilot/sunnylink/settings_ui_src/_schemas/page.schema.json @@ -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/.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"}} + } + } + } +} diff --git a/sunnypilot/sunnylink/settings_ui_src/_schemas/rule.schema.json b/sunnypilot/sunnylink/settings_ui_src/_schemas/rule.schema.json new file mode 100644 index 0000000000..92a7dd307c --- /dev/null +++ b/sunnypilot/sunnylink/settings_ui_src/_schemas/rule.schema.json @@ -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/'}.", + "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/." + } + } + }, + "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} + } + } + } +} diff --git a/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml b/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml new file mode 100644 index 0000000000..3ef73e0fb0 --- /dev/null +++ b/sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml @@ -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 diff --git a/sunnypilot/sunnylink/settings_ui_src/pages/developer.yaml b/sunnypilot/sunnylink/settings_ui_src/pages/developer.yaml new file mode 100644 index 0000000000..b42720b436 --- /dev/null +++ b/sunnypilot/sunnylink/settings_ui_src/pages/developer.yaml @@ -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' diff --git a/sunnypilot/sunnylink/settings_ui_src/pages/device.yaml b/sunnypilot/sunnylink/settings_ui_src/pages/device.yaml new file mode 100644 index 0000000000..bf5f7883ab --- /dev/null +++ b/sunnypilot/sunnylink/settings_ui_src/pages/device.yaml @@ -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 diff --git a/sunnypilot/sunnylink/settings_ui_src/pages/display.yaml b/sunnypilot/sunnylink/settings_ui_src/pages/display.yaml new file mode 100644 index 0000000000..39a8cbaf80 --- /dev/null +++ b/sunnypilot/sunnylink/settings_ui_src/pages/display.yaml @@ -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 diff --git a/sunnypilot/sunnylink/settings_ui_src/pages/models.yaml b/sunnypilot/sunnylink/settings_ui_src/pages/models.yaml new file mode 100644 index 0000000000..4ae1fca88b --- /dev/null +++ b/sunnypilot/sunnylink/settings_ui_src/pages/models.yaml @@ -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' diff --git a/sunnypilot/sunnylink/settings_ui_src/pages/software.yaml b/sunnypilot/sunnylink/settings_ui_src/pages/software.yaml new file mode 100644 index 0000000000..8eb6049d61 --- /dev/null +++ b/sunnypilot/sunnylink/settings_ui_src/pages/software.yaml @@ -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' diff --git a/sunnypilot/sunnylink/settings_ui_src/pages/steering.yaml b/sunnypilot/sunnylink/settings_ui_src/pages/steering.yaml new file mode 100644 index 0000000000..697c5f4f21 --- /dev/null +++ b/sunnypilot/sunnylink/settings_ui_src/pages/steering.yaml @@ -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 diff --git a/sunnypilot/sunnylink/settings_ui_src/pages/toggles.yaml b/sunnypilot/sunnylink/settings_ui_src/pages/toggles.yaml new file mode 100644 index 0000000000..1a4186cf30 --- /dev/null +++ b/sunnypilot/sunnylink/settings_ui_src/pages/toggles.yaml @@ -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. diff --git a/sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml b/sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml new file mode 100644 index 0000000000..2f6f20730e --- /dev/null +++ b/sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml @@ -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 diff --git a/sunnypilot/sunnylink/settings_ui_src/pages/visuals.yaml b/sunnypilot/sunnylink/settings_ui_src/pages/visuals.yaml new file mode 100644 index 0000000000..3170991157 --- /dev/null +++ b/sunnypilot/sunnylink/settings_ui_src/pages/visuals.yaml @@ -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. diff --git a/sunnypilot/sunnylink/tests/test_capabilities.py b/sunnypilot/sunnylink/tests/test_capabilities.py new file mode 100644 index 0000000000..4af1462479 --- /dev/null +++ b/sunnypilot/sunnylink/tests/test_capabilities.py @@ -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) diff --git a/sunnypilot/sunnylink/tests/test_compile_settings_ui.py b/sunnypilot/sunnylink/tests/test_compile_settings_ui.py new file mode 100644 index 0000000000..2fcd889ec3 --- /dev/null +++ b/sunnypilot/sunnylink/tests/test_compile_settings_ui.py @@ -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" diff --git a/sunnypilot/sunnylink/tests/test_settings_changes.py b/sunnypilot/sunnylink/tests/test_settings_changes.py new file mode 100644 index 0000000000..07b05d4ac4 --- /dev/null +++ b/sunnypilot/sunnylink/tests/test_settings_changes.py @@ -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" diff --git a/sunnypilot/sunnylink/tests/test_settings_schema.py b/sunnypilot/sunnylink/tests/test_settings_schema.py new file mode 100644 index 0000000000..61cc0131cf --- /dev/null +++ b/sunnypilot/sunnylink/tests/test_settings_schema.py @@ -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 diff --git a/sunnypilot/sunnylink/tools/apply_macros.py b/sunnypilot/sunnylink/tools/apply_macros.py new file mode 100755 index 0000000000..a6b56122ad --- /dev/null +++ b/sunnypilot/sunnylink/tools/apply_macros.py @@ -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()) diff --git a/sunnypilot/sunnylink/tools/compile_settings_ui.py b/sunnypilot/sunnylink/tools/compile_settings_ui.py new file mode 100755 index 0000000000..c13ecd63a0 --- /dev/null +++ b/sunnypilot/sunnylink/tools/compile_settings_ui.py @@ -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/.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()) diff --git a/sunnypilot/sunnylink/tools/extract_settings_ui.py b/sunnypilot/sunnylink/tools/extract_settings_ui.py new file mode 100755 index 0000000000..4b3283f770 --- /dev/null +++ b/sunnypilot/sunnylink/tools/extract_settings_ui.py @@ -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/\"}.\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()) diff --git a/sunnypilot/sunnylink/tools/generate_settings_schema.py b/sunnypilot/sunnylink/tools/generate_settings_schema.py new file mode 100755 index 0000000000..5ace2a8087 --- /dev/null +++ b/sunnypilot/sunnylink/tools/generate_settings_schema.py @@ -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%}") diff --git a/sunnypilot/sunnylink/tools/validate_settings_ui.py b/sunnypilot/sunnylink/tools/validate_settings_ui.py new file mode 100755 index 0000000000..f1e094a742 --- /dev/null +++ b/sunnypilot/sunnylink/tools/validate_settings_ui.py @@ -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) diff --git a/sunnypilot/system/params_migration.py b/sunnypilot/system/params_migration.py index 5e524de06e..2f796c174e 100644 --- a/sunnypilot/system/params_migration.py +++ b/sunnypilot/system/params_migration.py @@ -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_sunnylink_params 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) diff --git a/system/manager/manager.py b/system/manager/manager.py index 3ec554f904..15a09d0a37 100755 --- a/system/manager/manager.py +++ b/system/manager/manager.py @@ -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 diff --git a/system/ui/sunnypilot/widgets/list_view.py b/system/ui/sunnypilot/widgets/list_view.py index 1dd69c24cb..89342572d1 100644 --- a/system/ui/sunnypilot/widgets/list_view.py +++ b/system/ui/sunnypilot/widgets/list_view.py @@ -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]], diff --git a/system/ui/sunnypilot/widgets/toggle.py b/system/ui/sunnypilot/widgets/toggle.py index 2924aec2b4..fbb2ba40ac 100644 --- a/system/ui/sunnypilot/widgets/toggle.py +++ b/system/ui/sunnypilot/widgets/toggle.py @@ -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 diff --git a/uv.lock b/uv.lock index f8278c1368..a3f5d4f928 100644 --- a/uv.lock +++ b/uv.lock @@ -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#13755b73dbcda1b186641fcccce90d55f815d6bc" } +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#eba2fe8b8208b5408fbda1bc0104a91e4375aee3" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=capnproto&rev=release-capnproto#bcd0c43cb9dbd3b48aad36812bae9498fb5c7be1" } [[package]] name = "casadi" @@ -142,11 +142,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.4.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] [[package]] @@ -199,14 +199,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.2" +version = "8.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] [[package]] @@ -291,41 +291,41 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.7" +version = "47.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, - { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, - { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, - { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, - { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, - { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, + { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, + { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, ] [[package]] @@ -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#9157467a9e343d876e85f6187eae8c974fe3d83f" } +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#4be3ad687902199df76b78cc8cf07f61e69ec266" } +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#0e1ae2548977f6cd78c51d4d0c16ebd1863241b8" } +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#ab3064b6e7df110e32aa7748689cb43b26f07b54" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=git-lfs&rev=release-git-lfs#a170e4e7bfed8809673bf1ef6656ba710e084c8c" } [[package]] name = "google-crc32c" @@ -476,11 +476,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] [[package]] @@ -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#58d66087adacabb2bb4e56e74ebdea7d55c78e34" } +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#71f7a3f2aaccdc0612d93fac858b78f35bc2a565" } +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#222120c19c857d6d0a681aff2e335c829ffcf89c" } +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#febc42742ebf25429575caf784adecc6e516b892" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=libyuv&rev=release-libyuv#a5a47cab68fe194e0ee6e8ba694960dd0e8f3ac5" } [[package]] name = "markdown" @@ -628,7 +628,7 @@ wheels = [ [[package]] name = "matplotlib" -version = "3.10.8" +version = "3.10.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy" }, @@ -641,15 +641,15 @@ dependencies = [ { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, - { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, - { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, - { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, ] [[package]] @@ -701,7 +701,7 @@ wheels = [ [[package]] name = "ncurses" version = "6.5" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=ncurses&rev=release-ncurses#e78a693655261b101325aaa5b3cd9f1eb35f496b" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=ncurses&rev=release-ncurses#0e85f809441d49e7b2ec3123ee786fefa95b2052" } [[package]] name = "numpy" @@ -890,11 +890,11 @@ provides-extras = ["docs", "testing", "dev", "tools"] [[package]] name = "packaging" -version = "26.1" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] @@ -1133,15 +1133,15 @@ wheels = [ [[package]] name = "pyopenssl" -version = "26.0.0" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a8/26d36401e3ab8eed9030ad33f381da7856fcfad5691780fccd1b019718fc/pyopenssl-26.1.0.tar.gz", hash = "sha256:737f0a2275c5bc54f3b02137687e1a765931fb3949b9a92a825e4d33b9eec08b", size = 186181, upload-time = "2026-04-24T20:23:48.115Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" }, + { url = "https://files.pythonhosted.org/packages/a8/41/52f3a3e812b816a91e89aa504199d8bf989a1f873192b10762be66cf2009/pyopenssl-26.1.0-py3-none-any.whl", hash = "sha256:115563879b2c8ccb207975705d3e491434d8c9d7c79667c902ecbf5f3bbd2ece", size = 58109, upload-time = "2026-04-24T20:23:46.273Z" }, ] [[package]] @@ -1344,27 +1344,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.10" +version = "0.15.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, - { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, - { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, - { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, - { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, - { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, - { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, - { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, - { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, - { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, - { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, - { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] [[package]] @@ -1468,6 +1468,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "tqdm" version = "4.67.3" @@ -1482,26 +1500,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.31" +version = "0.0.34" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/cc/5ea5d3a72216c8c2bf77d83066dd4f3553532d0aacc03d4a8397dd9845e1/ty-0.0.31.tar.gz", hash = "sha256:4a4094292d9671caf3b510c7edf36991acd9c962bb5d97205374ffed9f541c45", size = 5516619, upload-time = "2026-04-15T15:47:59.87Z" } +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/b0/10/ea805cbbd75d5d50792551a2b383de8521eeab0c44f38c73e12819ced65e/ty-0.0.31-py3-none-linux_armv6l.whl", hash = "sha256:761651dc17ad7bc0abfc1b04b3f0e84df263ed435d34f29760b3da739ab02d35", size = 10834749, upload-time = "2026-04-15T15:48:14.877Z" }, - { url = "https://files.pythonhosted.org/packages/d9/4c/fabf951850401d24d36b21bced088a366c6827e1c37dab4523afff84c4b2/ty-0.0.31-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c529922395a07231c27488f0290651e05d27d149f7e0aa807678f1f7e9c58a5e", size = 10626012, upload-time = "2026-04-15T15:48:22.554Z" }, - { url = "https://files.pythonhosted.org/packages/04/b0/4a5aff88d2544f19514a59c8f693d63144aa7307fe2ee5df608333ab5460/ty-0.0.31-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5f345df2b87d747859e72c2cbc9be607ea1bbc8bc93dd32fa3d03ea091cb4fee", size = 10075790, upload-time = "2026-04-15T15:47:46.959Z" }, - { url = "https://files.pythonhosted.org/packages/d5/73/9d4dcad12cd4e85274014f2c0510ef93f590b2a1e5148de3a9f276098dad/ty-0.0.31-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4b207eddcfbafd376132689d3435b14efcb531289cb59cd961c6a611133bd54", size = 10590286, upload-time = "2026-04-15T15:48:06.222Z" }, - { url = "https://files.pythonhosted.org/packages/47/45/fe40adde18692359ded174ae7ddbfac056e876eb0f43b65be74fde7f6072/ty-0.0.31-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:663778b220f357067488ce68bfc52335ccbd161549776f70dcbde6bbde82f77a", size = 10623824, upload-time = "2026-04-15T15:48:12.965Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e8/0ffa2e09b548e6daa9ebc368d68b767dc2405ca4cbeadb7ede0e2cb21059/ty-0.0.31-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3506cfe87dfade0fb2960dd4fffd4fd8089003587b3445c0a1a295c9d83764fb", size = 11156864, upload-time = "2026-04-15T15:48:08.473Z" }, - { url = "https://files.pythonhosted.org/packages/08/e9/fd44c2075115d569593ee9473d7e2a38b750fd7e783421c95eb528c15df5/ty-0.0.31-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b3f3d8492f08e81916026354c1d1599e9ddfa1241804141a74d5662fc710085", size = 11696401, upload-time = "2026-04-15T15:48:17.355Z" }, - { url = "https://files.pythonhosted.org/packages/4e/50/35aad8eadf964d23e2a4faa5b38a206aa85c78833c8ce335dddd2c34ba63/ty-0.0.31-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a97de32ee6a619393a4c495e056a1c547de7877510f3152e61345c71d774d2d0", size = 11374903, upload-time = "2026-04-15T15:47:55.893Z" }, - { url = "https://files.pythonhosted.org/packages/c8/37/01eccd25d23f5aaa7f7ff1a87b5b215469f6b202cf689a1812b71c1e7f6b/ty-0.0.31-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c906354ce441e342646582bc9b8f48a676f79f3d061e25de15ff870e015ca14e", size = 11206624, upload-time = "2026-04-15T15:47:51.778Z" }, - { url = "https://files.pythonhosted.org/packages/f4/70/baad2914cb097453f127a221f8addb2b41926098059cd773c75e6a662fc4/ty-0.0.31-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:275bb7c82afcbf89fe2dbef1b2692f2bc98451f1ee2c8eb809ddd91317822388", size = 10575089, upload-time = "2026-04-15T15:47:49.448Z" }, - { url = "https://files.pythonhosted.org/packages/83/12/bae3a7bba2e785eb72ce00f9da70eedcb8c5e8299efecbd16e6e436abd82/ty-0.0.31-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:405da247027c6efd1e264886b6ac4a86ab3a4f09200b02e33630efe85f119e53", size = 10642315, upload-time = "2026-04-15T15:48:19.661Z" }, - { url = "https://files.pythonhosted.org/packages/93/9e/cad04d5d839bc60355cea98c7e09d724ea65f47184def0fae8b90dc54591/ty-0.0.31-py3-none-musllinux_1_2_i686.whl", hash = "sha256:54d9835608eed196853d6643f645c50ce83bcc7fe546cdb3e210c1bcf7c58c09", size = 10834473, upload-time = "2026-04-15T15:48:02.091Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ba/84112d280182d37690d3d2b4018b2667e42bc281585e607015635310016a/ty-0.0.31-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ee11be9b07e8c0c6b455ff075a0abe4f194de9476f57624db98eec9df618355", size = 11315785, upload-time = "2026-04-15T15:48:10.754Z" }, - { url = "https://files.pythonhosted.org/packages/50/9f/ac42dc223d7e0950e97a1854567a8b3e7fe09ad7375adbf91bfb43290482/ty-0.0.31-py3-none-win32.whl", hash = "sha256:7286587aacf3eef0956062d6492b893b02f82b0f22c5e230008e13ff0d216a8b", size = 10187657, upload-time = "2026-04-15T15:48:04.264Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/57ba7ea7ecb2f4751644ba91756e2be70e33ef5952c0c41a256a0e4c2437/ty-0.0.31-py3-none-win_amd64.whl", hash = "sha256:81134e25d2a2562ab372f24de8f9bd05034d27d30377a5d7540f259791c6234c", size = 11205258, upload-time = "2026-04-15T15:47:53.759Z" }, - { url = "https://files.pythonhosted.org/packages/88/39/bca669095ccf0a400af941fdf741578d4c2d6719f1b7f10e6dbec10aa862/ty-0.0.31-py3-none-win_arm64.whl", hash = "sha256:e9cb15fad26545c6a608f40f227af3a5513cb376998ca6feddd47ca7d93ffafa", size = 10590392, upload-time = "2026-04-15T15:47:57.968Z" }, + { 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]] @@ -1583,7 +1601,7 @@ wheels = [ [[package]] name = "zensical" -version = "0.0.33" +version = "0.0.39" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1592,27 +1610,28 @@ dependencies = [ { name = "pygments" }, { name = "pymdown-extensions" }, { name = "pyyaml" }, + { name = "tomli" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/c2/dea4b86dc1ca2a7b55414017f12cfb12b5cfdf3a1ed7c77a04c271eb523b/zensical-0.0.33.tar.gz", hash = "sha256:05209cb4f80185c533e0d37c25d084ddc2050e3d5a4dd1b1812961c2ee0c3380", size = 3892278, upload-time = "2026-04-14T11:08:19.895Z" } +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/74/5f/45d5200405420a9d8ac91cf9e7826622ea12f3198e8e6ac4ffb481eb53bf/zensical-0.0.33-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f658e3c241cfbb560bd8811116a9486cff7e04d7d5aed73569dd533c74187450", size = 12416748, upload-time = "2026-04-14T11:07:43.246Z" }, - { url = "https://files.pythonhosted.org/packages/33/1e/aadaf31d6e4d20419ecedaf0b1c804e359ec23dcdb44c8d2bf6d8407080c/zensical-0.0.33-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f9813ac3256c28e2e2f1ba5c9fab1b4bca62bbe0e0f8e85ac22d33b068b1b08a", size = 12293372, upload-time = "2026-04-14T11:07:46.569Z" }, - { url = "https://files.pythonhosted.org/packages/db/e5/838be8451ea8b2aecec39fbec3971060fc705e17f5741249740d9b6a6824/zensical-0.0.33-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3bad7ac71028769c5d1f3f84f448dbb7352db28d77095d1b40a8d1b0aa34ec30", size = 12659832, upload-time = "2026-04-14T11:07:50.754Z" }, - { url = "https://files.pythonhosted.org/packages/1e/5c/dd957d7c83efc13a70a6058d4190a3afcf29942aefb391120bca5466347d/zensical-0.0.33-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:06bb039daf044547c9400a52f9493b3cd486ba9baef3324fdcffd2e26e61105f", size = 12603847, upload-time = "2026-04-14T11:07:53.698Z" }, - { url = "https://files.pythonhosted.org/packages/b7/99/dd6ccc392ece1f34fb20ea339a01717badbbeb2fba1d4f3019a5028d0bcc/zensical-0.0.33-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:260238062b3139ece0edab93f4dbe7a12923453091f5aa580dfd73e799388076", size = 12956236, upload-time = "2026-04-14T11:07:56.728Z" }, - { url = "https://files.pythonhosted.org/packages/f4/76/e0a1b884eadf6afa7e2d56c90c268eec36836ac27e96ef250c0129e55417/zensical-0.0.33-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7dff0f4afda7b8586bc4ab2a5684bce5b282232dd4e0cad3be4c73fedd264425", size = 12701944, upload-time = "2026-04-14T11:07:59.928Z" }, - { url = "https://files.pythonhosted.org/packages/38/38/e1ff13461e406864fa2b23fc828822659a7dbac5c79398f724d17f088540/zensical-0.0.33-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:207b4d81b208d75b97dc7bd318804550b886a3e852ef67429ef0e6b9442839d1", size = 12835444, upload-time = "2026-04-14T11:08:02.998Z" }, - { url = "https://files.pythonhosted.org/packages/41/04/7d24d52d6903fc5c511633afe8b5716fef19da09685327665cc127f61648/zensical-0.0.33-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:06d2f57f7bc8cc8fd904386020ea1365eebc411e8698a871e9525c885abca574", size = 12878419, upload-time = "2026-04-14T11:08:06.054Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ec/87fc9e360c694ab006363c7834639eccafd0d26a487cd63dd609bd68f36a/zensical-0.0.33-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:c2851b82d83aa0b2ae4f8e99731cfeedeecebfa04e6b3fc4d375deca629fa240", size = 13022474, upload-time = "2026-04-14T11:08:09.007Z" }, - { url = "https://files.pythonhosted.org/packages/10/b3/0bf174ab6ceedb31d9af462073b5339c894b2084a27d42cb9f0906050d76/zensical-0.0.33-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:90daaf512b0429d7b9147ad5e6085b455d24803eff18b508aed738ca65444683", size = 12975233, upload-time = "2026-04-14T11:08:12.535Z" }, - { url = "https://files.pythonhosted.org/packages/a9/27/7cc3c2d284698647f60f3b823e0101e619c87edf158d47ee11bf4bfb6228/zensical-0.0.33-cp310-abi3-win32.whl", hash = "sha256:2701820597fe19361a12371129927c58c19633dcaa5f6986d610dce58cecd8c4", size = 12012664, upload-time = "2026-04-14T11:08:14.977Z" }, - { url = "https://files.pythonhosted.org/packages/25/0b/6be5c2fdaf9f1600577e7ba5e235d86b72a26f6af389efb146f978f76ac3/zensical-0.0.33-cp310-abi3-win_amd64.whl", hash = "sha256:a5a0911b4247708a55951b74c459f4d5faec5daaf287d23a2e1f0d96be1e647f", size = 12206255, upload-time = "2026-04-14T11:08:17.375Z" }, + { 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#173fe8e9a0b8cf666bac5363c3376e866a386568" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=zeromq&rev=release-zeromq#39acf5d6cbd996fe61e8e8727e1b403e79bc2b7b" } [[package]] name = "zstandard" @@ -1642,4 +1661,4 @@ wheels = [ [[package]] name = "zstd" version = "1.5.6" -source = { git = "https://github.com/commaai/dependencies.git?subdirectory=zstd&rev=release-zstd#c4b1fdec74010075965d68e2c743055c6ef18d48" } +source = { git = "https://github.com/commaai/dependencies.git?subdirectory=zstd&rev=release-zstd#59e9ca4ecfda299d4861ea53a5fcc1eacadc5524" }