mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-06-09 01:25:11 +08:00
Compare commits
26 Commits
sync+tg
...
accel-cont
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41ce29af86 | ||
|
|
dfc3c98b22 | ||
|
|
107a6f4c00 | ||
|
|
059d0b6c4c | ||
|
|
c51ffe3808 | ||
|
|
a15aed1a79 | ||
|
|
78007e82e0 | ||
|
|
b1a6223b14 | ||
|
|
e771dfa007 | ||
|
|
c28eb95874 | ||
|
|
7ed960f713 | ||
|
|
7e2b8430c5 | ||
|
|
521fa09b0d | ||
|
|
b9aa1962ca | ||
|
|
6b1b6aca05 | ||
|
|
41a8bc3fc4 | ||
|
|
540f4f5933 | ||
|
|
53e5ae0578 | ||
|
|
2182be05ea | ||
|
|
3e44c90c68 | ||
|
|
2d35bd895f | ||
|
|
855d5022ad | ||
|
|
6a363365ab | ||
|
|
ddb9039493 | ||
|
|
0b7df7df10 | ||
|
|
dd3feac854 |
73
.github/workflows/cereal_validation.yaml
vendored
73
.github/workflows/cereal_validation.yaml
vendored
@@ -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
|
||||
|
||||
90
CHANGELOG.md
90
CHANGELOG.md
@@ -1,4 +1,7 @@
|
||||
sunnypilot Version 2026.001.000 (2026-03-xx)
|
||||
sunnypilot Version 2026.002.000 (2026-xx-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 +69,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 +145,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 +173,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)
|
||||
========================
|
||||
|
||||
@@ -194,6 +194,13 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 {
|
||||
aTarget @5 :Float32;
|
||||
events @6 :List(OnroadEventSP.Event);
|
||||
e2eAlerts @7 :E2eAlerts;
|
||||
accelPersonality @8 :AccelerationPersonality;
|
||||
|
||||
enum AccelerationPersonality {
|
||||
sport @0;
|
||||
normal @1;
|
||||
eco @2;
|
||||
}
|
||||
|
||||
struct DynamicExperimentalControl {
|
||||
state @0 :DynamicExperimentalControlState;
|
||||
|
||||
@@ -2054,16 +2054,14 @@ struct DriverStateV2 {
|
||||
facePosition @2 :List(Float32);
|
||||
facePositionStd @3 :List(Float32);
|
||||
faceProb @4 :Float32;
|
||||
eyesVisibleProb @14 :Float32;
|
||||
eyesClosedProb @15 :Float32;
|
||||
leftEyeProb @5 :Float32;
|
||||
rightEyeProb @6 :Float32;
|
||||
leftBlinkProb @7 :Float32;
|
||||
rightBlinkProb @8 :Float32;
|
||||
sunglassesProb @9 :Float32;
|
||||
phoneProb @13 :Float32;
|
||||
|
||||
deprecated :group {
|
||||
leftEyeProb @5 :Float32;
|
||||
rightEyeProb @6 :Float32;
|
||||
leftBlinkProb @7 :Float32;
|
||||
rightBlinkProb @8 :Float32;
|
||||
sunglassesProb @9 :Float32;
|
||||
notReadyProb @12 :List(Float32);
|
||||
occludedProb @10 :Float32;
|
||||
readyProb @11 :List(Float32);
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
#define DEFAULT_MODEL "POP model (Default)"
|
||||
@@ -135,6 +135,8 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"Version", {PERSISTENT, STRING}},
|
||||
|
||||
// --- sunnypilot params --- //
|
||||
{"AccelPersonality", {PERSISTENT | BACKUP, INT, std::to_string(static_cast<int>(cereal::LongitudinalPlanSP::AccelerationPersonality::NORMAL))}},
|
||||
{"AccelPersonalityEnabled", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"ApiCache_DriveStats", {PERSISTENT, JSON}},
|
||||
{"AutoLaneChangeBsmDelay", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"AutoLaneChangeTimer", {PERSISTENT | BACKUP, INT, "0"}},
|
||||
@@ -204,6 +206,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
// sunnylink params
|
||||
{"EnableSunnylinkUploader", {PERSISTENT | BACKUP, BOOL}},
|
||||
{"LastSunnylinkPingTime", {CLEAR_ON_MANAGER_START, INT}},
|
||||
{"ParamsVersion", {PERSISTENT, INT}},
|
||||
{"SunnylinkCache_Roles", {PERSISTENT, STRING}},
|
||||
{"SunnylinkCache_Users", {PERSISTENT, STRING}},
|
||||
{"SunnylinkDongleId", {PERSISTENT, STRING}},
|
||||
|
||||
Submodule opendbc_repo updated: df807f8be3...4dad7b09dd
2
panda
2
panda
Submodule panda updated: 5a90799dac...0a9ef7ab54
BIN
selfdrive/assets/icons_mici/onroad/glasses.png
LFS
Normal file
BIN
selfdrive/assets/icons_mici/onroad/glasses.png
LFS
Normal file
Binary file not shown.
@@ -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:
|
||||
|
||||
@@ -313,11 +313,14 @@ class LongitudinalMpc:
|
||||
lead_xv = self.extrapolate_lead(x_lead, v_lead, a_lead, a_lead_tau)
|
||||
return lead_xv
|
||||
|
||||
def update(self, radarstate, v_cruise, personality=log.LongitudinalPersonality.standard):
|
||||
def update(self, radarstate, v_cruise, personality=log.LongitudinalPersonality.standard, a_cruise_min=None):
|
||||
t_follow = get_T_FOLLOW(personality)
|
||||
v_ego = self.x0[1]
|
||||
self.status = radarstate.leadOne.status or radarstate.leadTwo.status
|
||||
|
||||
if a_cruise_min is None:
|
||||
a_cruise_min = CRUISE_MIN_ACCEL
|
||||
|
||||
lead_xv_0 = self.process_lead(radarstate.leadOne)
|
||||
lead_xv_1 = self.process_lead(radarstate.leadTwo)
|
||||
|
||||
@@ -329,7 +332,7 @@ class LongitudinalMpc:
|
||||
|
||||
# Fake an obstacle for cruise, this ensures smooth acceleration to set speed
|
||||
# when the leads are no factor.
|
||||
v_lower = v_ego + (T_IDXS * CRUISE_MIN_ACCEL * 1.05)
|
||||
v_lower = v_ego + (T_IDXS * a_cruise_min * 1.05)
|
||||
# TODO does this make sense when max_a is negative?
|
||||
v_upper = v_ego + (T_IDXS * CRUISE_MAX_ACCEL * 1.05)
|
||||
v_cruise_clipped = np.clip(v_cruise * np.ones(N+1), v_lower, v_upper)
|
||||
|
||||
@@ -110,7 +110,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
# No change cost when user is controlling the speed, or when standstill
|
||||
prev_accel_constraint = not (reset_state or sm['carState'].standstill)
|
||||
|
||||
accel_clip = [ACCEL_MIN, get_max_accel(v_ego)]
|
||||
accel_clip = self.get_accel_clip(v_ego) or [ACCEL_MIN, get_max_accel(v_ego)]
|
||||
steer_angle_without_offset = sm['carState'].steeringAngleDeg - sm['liveParameters'].angleOffsetDeg
|
||||
accel_clip = limit_accel_in_turns(v_ego, steer_angle_without_offset, accel_clip, self.CP)
|
||||
|
||||
@@ -138,7 +138,8 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
|
||||
self.mpc.set_weights(prev_accel_constraint, personality=sm['selfdriveState'].personality)
|
||||
self.mpc.set_cur_state(self.v_desired_filter.x, self.a_desired)
|
||||
self.mpc.update(sm['radarState'], v_cruise, personality=sm['selfdriveState'].personality)
|
||||
self.mpc.update(sm['radarState'], v_cruise, personality=sm['selfdriveState'].personality,
|
||||
a_cruise_min=self.get_cruise_min_accel(v_ego))
|
||||
|
||||
self.v_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.v_solution)
|
||||
self.a_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.a_solution)
|
||||
|
||||
@@ -83,7 +83,7 @@ def parse_model_output(model_output):
|
||||
face_descs = model_output[f'face_descs_{ds_suffix}']
|
||||
parsed[f'face_descs_{ds_suffix}'] = face_descs[:, :-6]
|
||||
parsed[f'face_descs_{ds_suffix}_std'] = safe_exp(face_descs[:, -6:])
|
||||
for key in ['face_prob', 'eyes_visible_prob', 'eyes_closed_prob', 'using_phone_prob']:
|
||||
for key in ['face_prob', 'left_eye_prob', 'right_eye_prob','left_blink_prob', 'right_blink_prob', 'sunglasses_prob', 'using_phone_prob']:
|
||||
parsed[f'{key}_{ds_suffix}'] = sigmoid(model_output[f'{key}_{ds_suffix}'])
|
||||
return parsed
|
||||
|
||||
@@ -93,8 +93,11 @@ def fill_driver_data(msg, model_output, ds_suffix):
|
||||
msg.facePosition = model_output[f'face_descs_{ds_suffix}'][0, 3:5].tolist()
|
||||
msg.facePositionStd = model_output[f'face_descs_{ds_suffix}_std'][0, 3:5].tolist()
|
||||
msg.faceProb = model_output[f'face_prob_{ds_suffix}'][0, 0].item()
|
||||
msg.eyesVisibleProb = model_output[f'eyes_visible_prob_{ds_suffix}'][0, 0].item()
|
||||
msg.eyesClosedProb = model_output[f'eyes_closed_prob_{ds_suffix}'][0, 0].item()
|
||||
msg.leftEyeProb = model_output[f'left_eye_prob_{ds_suffix}'][0, 0].item()
|
||||
msg.rightEyeProb = model_output[f'right_eye_prob_{ds_suffix}'][0, 0].item()
|
||||
msg.leftBlinkProb = model_output[f'left_blink_prob_{ds_suffix}'][0, 0].item()
|
||||
msg.rightBlinkProb = model_output[f'right_blink_prob_{ds_suffix}'][0, 0].item()
|
||||
msg.sunglassesProb = model_output[f'sunglasses_prob_{ds_suffix}'][0, 0].item()
|
||||
msg.phoneProb = model_output[f'using_phone_prob_{ds_suffix}'][0, 0].item()
|
||||
|
||||
def get_driverstate_packet(model_output, frame_id: int, location_ts: int, exec_time: float, gpu_exec_time: float):
|
||||
|
||||
Binary file not shown.
@@ -32,8 +32,9 @@ class DRIVER_MONITOR_SETTINGS:
|
||||
self._DISTRACTED_PROMPT_TIME_TILL_TERMINAL = 6.
|
||||
|
||||
self._FACE_THRESHOLD = 0.7
|
||||
self._EYE_THRESHOLD = 0.5
|
||||
self._BLINK_THRESHOLD = 0.5
|
||||
self._EYE_THRESHOLD = 0.65
|
||||
self._SG_THRESHOLD = 0.9
|
||||
self._BLINK_THRESHOLD = 0.865
|
||||
self._PHONE_THRESH = 0.5
|
||||
|
||||
self._POSE_PITCH_THRESHOLD = 0.3133
|
||||
@@ -110,6 +111,11 @@ class DriverProb:
|
||||
self.prob_offseter = RunningStatFilter(raw_priors=raw_priors, max_trackable=max_trackable)
|
||||
self.prob_calibrated = False
|
||||
|
||||
class DriverBlink:
|
||||
def __init__(self):
|
||||
self.left = 0.
|
||||
self.right = 0.
|
||||
|
||||
|
||||
# model output refers to center of undistorted+leveled image
|
||||
EFL = 598.0 # focal length in K
|
||||
@@ -144,7 +150,7 @@ class DriverMonitoring:
|
||||
wheelpos_filter_raw_priors = (self.settings._WHEELPOS_DATA_AVG, self.settings._WHEELPOS_DATA_VAR, 2)
|
||||
self.wheelpos = DriverProb(raw_priors=wheelpos_filter_raw_priors, max_trackable=self.settings._WHEELPOS_MAX_COUNT)
|
||||
self.pose = DriverPose(settings=self.settings)
|
||||
self.blink_prob = 0.
|
||||
self.blink = DriverBlink()
|
||||
self.phone_prob = 0.
|
||||
|
||||
self.always_on = always_on
|
||||
@@ -247,7 +253,7 @@ class DriverMonitoring:
|
||||
if pitch_error > pitch_threshold or yaw_error > yaw_threshold:
|
||||
distracted_types.append(DistractedType.DISTRACTED_POSE)
|
||||
|
||||
if self.blink_prob > self.settings._BLINK_THRESHOLD:
|
||||
if (self.blink.left + self.blink.right)*0.5 > self.settings._BLINK_THRESHOLD:
|
||||
distracted_types.append(DistractedType.DISTRACTED_BLINK)
|
||||
|
||||
if self.phone_prob > self.settings._PHONE_THRESH:
|
||||
@@ -288,7 +294,10 @@ class DriverMonitoring:
|
||||
self.pose.yaw_std = driver_data.faceOrientationStd[1]
|
||||
model_std_max = max(self.pose.pitch_std, self.pose.yaw_std)
|
||||
self.pose.low_std = model_std_max < self.settings._POSESTD_THRESHOLD
|
||||
self.blink_prob = driver_data.eyesClosedProb * (driver_data.eyesVisibleProb > self.settings._EYE_THRESHOLD)
|
||||
self.blink.left = driver_data.leftBlinkProb * (driver_data.leftEyeProb > self.settings._EYE_THRESHOLD) \
|
||||
* (driver_data.sunglassesProb < self.settings._SG_THRESHOLD)
|
||||
self.blink.right = driver_data.rightBlinkProb * (driver_data.rightEyeProb > self.settings._EYE_THRESHOLD) \
|
||||
* (driver_data.sunglassesProb < self.settings._SG_THRESHOLD)
|
||||
self.phone_prob = driver_data.phoneProb
|
||||
|
||||
self.distracted_types = self._get_distracted_types()
|
||||
@@ -434,7 +443,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(
|
||||
|
||||
@@ -20,8 +20,10 @@ def make_msg(face_detected, distracted=False, model_uncertain=False):
|
||||
ds.leftDriverData.faceOrientation = [0., 0., 0.]
|
||||
ds.leftDriverData.facePosition = [0., 0.]
|
||||
ds.leftDriverData.faceProb = 1. * face_detected
|
||||
ds.leftDriverData.eyesVisibleProb = 1.
|
||||
ds.leftDriverData.eyesClosedProb = 1. * distracted
|
||||
ds.leftDriverData.leftEyeProb = 1.
|
||||
ds.leftDriverData.rightEyeProb = 1.
|
||||
ds.leftDriverData.leftBlinkProb = 1. * distracted
|
||||
ds.leftDriverData.rightBlinkProb = 1. * distracted
|
||||
ds.leftDriverData.faceOrientationStd = [1.*model_uncertain, 1.*model_uncertain, 1.*model_uncertain]
|
||||
ds.leftDriverData.facePositionStd = [1.*model_uncertain, 1.*model_uncertain]
|
||||
# TODO: test both separately when e2e is used
|
||||
@@ -215,64 +217,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
|
||||
|
||||
@@ -76,7 +76,7 @@ def generate_report(proposed, master, tmp, commit):
|
||||
(lambda x: get_idx_if_non_empty(x.wheelOnRightProb), "wheelOnRightProb"),
|
||||
(lambda x: get_idx_if_non_empty(x.leftDriverData.faceProb), "leftDriverData.faceProb"),
|
||||
(lambda x: get_idx_if_non_empty(x.leftDriverData.faceOrientation, 0), "leftDriverData.faceOrientation0"),
|
||||
(lambda x: get_idx_if_non_empty(x.leftDriverData.eyesClosedProb), "leftDriverData.eyesClosedProb"),
|
||||
(lambda x: get_idx_if_non_empty(x.leftDriverData.leftBlinkProb), "leftDriverData.leftBlinkProb"),
|
||||
(lambda x: get_idx_if_non_empty(x.leftDriverData.phoneProb), "leftDriverData.phoneProb"),
|
||||
(lambda x: get_idx_if_non_empty(x.rightDriverData.faceProb), "rightDriverData.faceProb"),
|
||||
], "driverStateV2")
|
||||
|
||||
@@ -36,7 +36,7 @@ class DeveloperLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
self._is_release = self._params.get_bool("IsReleaseBranch")
|
||||
self._is_release = False # self._params.get_bool("IsReleaseBranch")
|
||||
|
||||
# Build items and keep references for callbacks/state updates
|
||||
self._adb_toggle = toggle_item(
|
||||
|
||||
@@ -42,7 +42,7 @@ class TogglesLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._params = Params()
|
||||
self._is_release = self._params.get_bool("IsReleaseBranch")
|
||||
self._is_release = False # self._params.get_bool("IsReleaseBranch")
|
||||
|
||||
# param, title, desc, icon, needs_restart
|
||||
self._toggle_defs = {
|
||||
|
||||
@@ -39,6 +39,8 @@ class BaseDriverCameraDialog(Widget):
|
||||
self._eye_fill_texture = None
|
||||
self._eye_orange_texture = None
|
||||
self._eye_size = 74
|
||||
self._glasses_texture = None
|
||||
self._glasses_size = 171
|
||||
|
||||
self._load_eye_textures()
|
||||
|
||||
@@ -152,6 +154,8 @@ class BaseDriverCameraDialog(Widget):
|
||||
self._eye_fill_texture = gui_app.texture("icons_mici/onroad/eye_fill.png", self._eye_size, self._eye_size)
|
||||
if self._eye_orange_texture is None:
|
||||
self._eye_orange_texture = gui_app.texture("icons_mici/onroad/eye_orange.png", self._eye_size, self._eye_size)
|
||||
if self._glasses_texture is None:
|
||||
self._glasses_texture = gui_app.texture("icons_mici/onroad/glasses.png", self._glasses_size, self._glasses_size)
|
||||
|
||||
def _draw_face_detection(self, rect: rl.Rectangle):
|
||||
dm_state = ui_state.sm["driverMonitoringState"]
|
||||
@@ -198,21 +202,31 @@ class BaseDriverCameraDialog(Widget):
|
||||
eye_offset_x = 10
|
||||
eye_offset_y = 10
|
||||
eye_spacing = self._eye_size + 15
|
||||
eyes_prob = driver_data.eyesVisibleProb
|
||||
|
||||
left_eye_x = rect.x + eye_offset_x
|
||||
left_eye_y = rect.y + eye_offset_y
|
||||
left_eye_prob = driver_data.leftEyeProb
|
||||
|
||||
right_eye_x = rect.x + eye_offset_x + eye_spacing
|
||||
right_eye_y = rect.y + eye_offset_y
|
||||
right_eye_prob = driver_data.rightEyeProb
|
||||
|
||||
# Draw eyes with opacity based on probability
|
||||
fill_opacity = eyes_prob
|
||||
orange_opacity = 1.0 - eyes_prob
|
||||
for eye_x, eye_y in [(left_eye_x, left_eye_y), (right_eye_x, right_eye_y)]:
|
||||
for eye_x, eye_y, eye_prob in [(left_eye_x, left_eye_y, left_eye_prob), (right_eye_x, right_eye_y, right_eye_prob)]:
|
||||
fill_opacity = eye_prob
|
||||
orange_opacity = 1.0 - eye_prob
|
||||
|
||||
rl.draw_texture_v(self._eye_orange_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * orange_opacity)))
|
||||
rl.draw_texture_v(self._eye_fill_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * fill_opacity)))
|
||||
|
||||
# Draw sunglasses indicator based on sunglasses probability
|
||||
# Position glasses centered between the two eyes at top left
|
||||
glasses_x = rect.x + eye_offset_x - 4
|
||||
glasses_y = rect.y
|
||||
glasses_pos = rl.Vector2(glasses_x, glasses_y)
|
||||
glasses_prob = driver_data.sunglassesProb
|
||||
rl.draw_texture_v(self._glasses_texture, glasses_pos, rl.Color(70, 80, 161, int(255 * glasses_prob)))
|
||||
|
||||
|
||||
class DriverCameraDialog(NavWidget, BaseDriverCameraDialog):
|
||||
def __init__(self):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -120,20 +120,12 @@ class SteeringLayout(Widget):
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
|
||||
torque_allowed = True
|
||||
torque_allowed = ui_state.CP is not None and ui_state.CP.steerControlType != car.CarParams.SteerControlType.angle
|
||||
if ui_state.CP is not None:
|
||||
mads_main_desc = self._mads_limited_desc if self._mads_settings_layout._mads_limited_settings() else self._mads_full_desc
|
||||
self._mads_toggle.set_description(f"<b>{mads_main_desc}</b><br><br>{self._mads_base_desc}")
|
||||
|
||||
if ui_state.CP.steerControlType == car.CarParams.SteerControlType.angle:
|
||||
ui_state.params.remove("EnforceTorqueControl")
|
||||
ui_state.params.remove("NeuralNetworkLateralControl")
|
||||
torque_allowed = False
|
||||
else:
|
||||
self._mads_toggle.set_description(f"<b>{self._mads_check_compat_desc}</b><br><br>{self._mads_base_desc}")
|
||||
ui_state.params.remove("EnforceTorqueControl")
|
||||
ui_state.params.remove("NeuralNetworkLateralControl")
|
||||
torque_allowed = False
|
||||
|
||||
self._mads_toggle.action_item.set_enabled(ui_state.is_offroad())
|
||||
self._mads_settings_button.action_item.set_enabled(ui_state.is_offroad() and self._mads_toggle.action_item.get_state())
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
#define SUNNYPILOT_VERSION "2026.001.000"
|
||||
#define SUNNYPILOT_VERSION "2026.002.000"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
1
sunnypilot/models/model_name.py
Normal file
1
sunnypilot/models/model_name.py
Normal file
@@ -0,0 +1 @@
|
||||
DEFAULT_MODEL = "POP model"
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Copyright (c) 2021-, rav4kumar, 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 cereal import custom
|
||||
import numpy as np
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET
|
||||
|
||||
AccelPersonality = custom.LongitudinalPlanSP.AccelerationPersonality
|
||||
|
||||
|
||||
A_MAX_BP = [0.0, 4.0, 8.0, 16.0, 40.0]
|
||||
A_MAX_V = {
|
||||
AccelPersonality.eco: [1.20, 1.40, 1.20, 0.40, 0.08],
|
||||
AccelPersonality.normal: [1.80, 1.80, 1.35, 0.50, 0.15],
|
||||
AccelPersonality.sport: [2.20, 2.20, 1.60, 0.70, 0.25],
|
||||
}
|
||||
|
||||
COAST_DRAG_BP = [0.0, 10.0, 25.0, 40.0]
|
||||
COAST_DRAG_V = {
|
||||
AccelPersonality.eco: [-0.03, -0.05, -0.08, -0.12],
|
||||
AccelPersonality.normal: [-0.04, -0.07, -0.12, -0.18],
|
||||
AccelPersonality.sport: [-0.06, -0.10, -0.18, -0.28],
|
||||
}
|
||||
|
||||
A_MIN_FLOOR_BP = [0.0, 5.0, 15.0, 40.0]
|
||||
A_MIN_FLOOR_V = {
|
||||
AccelPersonality.eco: [-0.20, -0.35, -0.55, -0.50],
|
||||
AccelPersonality.normal: [-0.25, -0.45, -0.75, -0.65],
|
||||
AccelPersonality.sport: [-0.35, -0.65, -1.00, -0.95],
|
||||
}
|
||||
|
||||
DEFICIT_TO_FLOOR = 8.5
|
||||
COAST_DEADBAND = 1.0
|
||||
RAMP_OFF_RANGE = 5.0
|
||||
|
||||
A_MIN_TIGHTEN_RATE = 0.6
|
||||
A_MIN_RELAX_RATE = 0.9
|
||||
A_MAX_RATE_UP = 1.5
|
||||
A_MAX_RATE_DOWN = 0.6
|
||||
|
||||
MIN_MAX_GAP = 0.05
|
||||
|
||||
PARAM_REFRESH_FRAMES = max(1, int(1.0 / DT_MDL))
|
||||
|
||||
|
||||
class AccelPersonalityController:
|
||||
def __init__(self):
|
||||
self.params = Params()
|
||||
self.frame = 0
|
||||
self._first = True
|
||||
|
||||
val = self.params.get('AccelPersonality')
|
||||
self._personality = val if val is not None else AccelPersonality.normal
|
||||
self._enabled = self.params.get_bool('AccelPersonalityEnabled')
|
||||
|
||||
self._v_cruise = 0.0
|
||||
self._a_min = -0.05
|
||||
self._a_max = 1.50
|
||||
|
||||
self._cache_v: float | None = None
|
||||
self._cache_v_cruise: float | None = None
|
||||
self._cache_a_min = self._a_min
|
||||
self._cache_a_max = self._a_max
|
||||
|
||||
def update(self, sm=None):
|
||||
self.frame += 1
|
||||
self._cache_v = None
|
||||
self._cache_v_cruise = None
|
||||
|
||||
if sm is not None:
|
||||
vc = sm['carState'].vCruise
|
||||
self._v_cruise = float(vc) * (1000.0 / 3600.0) if vc != V_CRUISE_UNSET else 0.0
|
||||
|
||||
if self.frame % PARAM_REFRESH_FRAMES == 0:
|
||||
val = self.params.get('AccelPersonality')
|
||||
self._personality = val if val is not None else AccelPersonality.normal
|
||||
new_enabled = self.params.get_bool('AccelPersonalityEnabled')
|
||||
if new_enabled and not self._enabled:
|
||||
self._first = True
|
||||
self._enabled = new_enabled
|
||||
|
||||
def get_accel_personality(self) -> int:
|
||||
return int(self._personality)
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def get_accel_limits(self, v_ego: float) -> tuple[float, float]:
|
||||
v_ego = max(0.0, v_ego)
|
||||
if (self._cache_v is not None
|
||||
and abs(self._cache_v - v_ego) < 0.01
|
||||
and self._cache_v_cruise == self._v_cruise):
|
||||
return self._cache_a_min, self._cache_a_max
|
||||
self._cache_a_min, self._cache_a_max = self._step(v_ego)
|
||||
self._cache_v = v_ego
|
||||
self._cache_v_cruise = self._v_cruise
|
||||
return self._cache_a_min, self._cache_a_max
|
||||
|
||||
def get_min_accel(self, v_ego: float) -> float:
|
||||
return self.get_accel_limits(v_ego)[0]
|
||||
|
||||
def get_max_accel(self, v_ego: float) -> float:
|
||||
return self.get_accel_limits(v_ego)[1]
|
||||
|
||||
def _ramp_off(self, v_ego: float) -> float:
|
||||
if self._v_cruise <= 0.0:
|
||||
return 1.0
|
||||
return float(np.clip((self._v_cruise - v_ego) / RAMP_OFF_RANGE, 0.0, 1.0))
|
||||
|
||||
def _target_max(self, v_ego: float) -> float:
|
||||
base = float(np.interp(v_ego, A_MAX_BP, A_MAX_V[self._personality]))
|
||||
return base * self._ramp_off(v_ego)
|
||||
|
||||
def _target_min(self, v_ego: float) -> float:
|
||||
coast = float(np.interp(v_ego, COAST_DRAG_BP, COAST_DRAG_V[self._personality]))
|
||||
if self._v_cruise <= 0.0 or v_ego >= self._v_cruise:
|
||||
return coast
|
||||
floor = float(np.interp(v_ego, A_MIN_FLOOR_BP, A_MIN_FLOOR_V[self._personality]))
|
||||
deficit = self._v_cruise - v_ego
|
||||
t = float(np.clip(deficit / DEFICIT_TO_FLOOR, 0.0, 1.0)) ** 1.5
|
||||
return coast + t * (floor - coast)
|
||||
|
||||
def _apply_coast_deadband(self, v_ego: float, t_min: float, t_max: float) -> tuple[float, float]:
|
||||
if self._v_cruise <= 0.0 or abs(v_ego - self._v_cruise) >= COAST_DEADBAND:
|
||||
return t_min, t_max
|
||||
coast = float(np.interp(v_ego, COAST_DRAG_BP, COAST_DRAG_V[self._personality]))
|
||||
return coast, max(0.05, t_max * 0.25)
|
||||
|
||||
def _rate_limit(self, last: float, target: float, rate_down: float, rate_up: float) -> float:
|
||||
rate = rate_up if target > last else rate_down
|
||||
step = rate * DT_MDL
|
||||
return float(np.clip(target, last - step, last + step))
|
||||
|
||||
def _step(self, v_ego: float) -> tuple[float, float]:
|
||||
t_max = self._target_max(v_ego)
|
||||
t_min = self._target_min(v_ego)
|
||||
t_min, t_max = self._apply_coast_deadband(v_ego, t_min, t_max)
|
||||
|
||||
if self._first:
|
||||
self._a_min, self._a_max = t_min, t_max
|
||||
self._first = False
|
||||
return self._a_min, self._a_max
|
||||
|
||||
new_min = self._rate_limit(self._a_min, t_min, rate_down=A_MIN_TIGHTEN_RATE, rate_up=A_MIN_RELAX_RATE)
|
||||
new_max = self._rate_limit(self._a_max, t_max, rate_down=A_MAX_RATE_DOWN, rate_up=A_MAX_RATE_UP)
|
||||
|
||||
new_min = min(new_min, new_max - MIN_MAX_GAP)
|
||||
|
||||
self._a_min, self._a_max = new_min, new_max
|
||||
return self._a_min, self._a_max
|
||||
@@ -17,6 +17,9 @@ from openpilot.sunnypilot.selfdrive.controls.lib.speed_limit.speed_limit_resolve
|
||||
from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP
|
||||
from openpilot.sunnypilot.models.helpers import get_active_bundle
|
||||
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_personality.accel_controller import AccelPersonalityController
|
||||
from opendbc.car.interfaces import ACCEL_MIN
|
||||
|
||||
DecState = custom.LongitudinalPlanSP.DynamicExperimentalControl.DynamicExperimentalControlState
|
||||
LongitudinalPlanSource = custom.LongitudinalPlanSP.LongitudinalPlanSource
|
||||
|
||||
@@ -26,6 +29,7 @@ class LongitudinalPlannerSP:
|
||||
self.events_sp = EventsSP()
|
||||
self.resolver = SpeedLimitResolver()
|
||||
self.dec = DynamicExperimentalController(CP, mpc)
|
||||
self.accel_controller = AccelPersonalityController()
|
||||
self.scc = SmartCruiseControl()
|
||||
self.resolver = SpeedLimitResolver()
|
||||
self.sla = SpeedLimitAssist(CP, CP_SP)
|
||||
@@ -43,6 +47,17 @@ class LongitudinalPlannerSP:
|
||||
|
||||
return experimental_mode and self.dec.mode() == "blended"
|
||||
|
||||
def get_accel_clip(self, v_ego: float) -> list[float] | None:
|
||||
if not self.accel_controller.is_enabled():
|
||||
return None
|
||||
a_max = self.accel_controller.get_max_accel(v_ego)
|
||||
return [ACCEL_MIN, max(ACCEL_MIN, a_max)]
|
||||
|
||||
def get_cruise_min_accel(self, v_ego: float) -> float | None:
|
||||
if self.accel_controller.is_enabled():
|
||||
return self.accel_controller.get_min_accel(v_ego)
|
||||
return None
|
||||
|
||||
def update_targets(self, sm: messaging.SubMaster, v_ego: float, a_ego: float, v_cruise: float) -> tuple[float, float]:
|
||||
CS = sm['carState']
|
||||
v_cruise_cluster_kph = min(CS.vCruiseCluster, V_CRUISE_MAX)
|
||||
@@ -77,6 +92,7 @@ class LongitudinalPlannerSP:
|
||||
self.events_sp.clear()
|
||||
self.dec.update(sm)
|
||||
self.e2e_alerts_helper.update(sm, self.events_sp)
|
||||
self.accel_controller.update(sm)
|
||||
|
||||
def publish_longitudinal_plan_sp(self, sm: messaging.SubMaster, pm: messaging.PubMaster) -> None:
|
||||
plan_sp_send = messaging.new_message('longitudinalPlanSP')
|
||||
@@ -95,6 +111,8 @@ class LongitudinalPlannerSP:
|
||||
dec.enabled = self.dec.enabled()
|
||||
dec.active = self.dec.active()
|
||||
|
||||
longitudinalPlanSP.accelPersonality = int(self.accel_controller.get_accel_personality())
|
||||
|
||||
# Smart Cruise Control
|
||||
smartCruiseControl = longitudinalPlanSP.smartCruiseControl
|
||||
# Vision Control
|
||||
|
||||
147
sunnypilot/selfdrive/controls/lib/tests/test_accel_controller.py
Normal file
147
sunnypilot/selfdrive/controls/lib/tests/test_accel_controller.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Copyright (c) 2021-, rav4kumar, 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.
|
||||
|
||||
Coverage for AccelPersonalityController:
|
||||
- live param flip via auto-refresh (no Python set_enabled() call needed)
|
||||
- V_CRUISE_UNSET guard
|
||||
- enable-transition snap to fresh target
|
||||
- per-personality accel limit deltas vs stock get_max_accel
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from cereal import custom
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from opendbc.car.interfaces import ACCEL_MIN
|
||||
from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET
|
||||
from openpilot.selfdrive.controls.lib.longitudinal_planner import get_max_accel as stock_get_max_accel
|
||||
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_personality.accel_controller import (
|
||||
AccelPersonalityController,
|
||||
PARAM_REFRESH_FRAMES,
|
||||
)
|
||||
|
||||
|
||||
AccelPersonality = custom.LongitudinalPlanSP.AccelerationPersonality
|
||||
|
||||
|
||||
class FakeCarState:
|
||||
def __init__(self, v_cruise=30.0):
|
||||
self.vCruise = v_cruise
|
||||
|
||||
|
||||
class FakeSM:
|
||||
def __init__(self, v_cruise=30.0):
|
||||
self._data = {'carState': FakeCarState(v_cruise)}
|
||||
|
||||
def __getitem__(self, k):
|
||||
return self._data[k]
|
||||
|
||||
|
||||
def _print_table(title, header, rows):
|
||||
print(f"\n--- {title} ---")
|
||||
print(" | ".join(f"{h:>12}" for h in header))
|
||||
print("-" * (15 * len(header)))
|
||||
for row in rows:
|
||||
print(" | ".join(f"{v:>12.3f}" if isinstance(v, float) else f"{v:>12}" for v in row))
|
||||
|
||||
|
||||
class TestAccelLiveFlip:
|
||||
def test_enable_via_param(self):
|
||||
Params().put_bool('AccelPersonalityEnabled', False)
|
||||
c = AccelPersonalityController()
|
||||
assert not c.is_enabled()
|
||||
Params().put_bool('AccelPersonalityEnabled', True)
|
||||
for _ in range(PARAM_REFRESH_FRAMES + 1):
|
||||
c.update(FakeSM())
|
||||
assert c.is_enabled()
|
||||
|
||||
def test_disable_via_param(self):
|
||||
Params().put_bool('AccelPersonalityEnabled', True)
|
||||
c = AccelPersonalityController()
|
||||
assert c.is_enabled()
|
||||
Params().put_bool('AccelPersonalityEnabled', False)
|
||||
for _ in range(PARAM_REFRESH_FRAMES + 1):
|
||||
c.update(FakeSM())
|
||||
assert not c.is_enabled()
|
||||
|
||||
def test_personality_change_via_param(self):
|
||||
Params().put('AccelPersonality', AccelPersonality.normal)
|
||||
c = AccelPersonalityController()
|
||||
assert c.get_accel_personality() == AccelPersonality.normal
|
||||
Params().put('AccelPersonality', AccelPersonality.sport)
|
||||
for _ in range(PARAM_REFRESH_FRAMES + 1):
|
||||
c.update(FakeSM())
|
||||
assert c.get_accel_personality() == AccelPersonality.sport
|
||||
|
||||
def test_refresh_boundary_below_threshold(self):
|
||||
Params().put_bool('AccelPersonalityEnabled', False)
|
||||
c = AccelPersonalityController()
|
||||
Params().put_bool('AccelPersonalityEnabled', True)
|
||||
for _ in range(PARAM_REFRESH_FRAMES - 1):
|
||||
c.update(FakeSM())
|
||||
assert not c.is_enabled()
|
||||
|
||||
def test_enable_transition_snaps_to_target(self):
|
||||
Params().put_bool('AccelPersonalityEnabled', True)
|
||||
Params().put('AccelPersonality', AccelPersonality.sport)
|
||||
c = AccelPersonalityController()
|
||||
for _ in range(PARAM_REFRESH_FRAMES + 1):
|
||||
c.update(FakeSM(v_cruise=35.0))
|
||||
c.get_accel_limits(25.0)
|
||||
|
||||
Params().put_bool('AccelPersonalityEnabled', False)
|
||||
for _ in range(PARAM_REFRESH_FRAMES + 1):
|
||||
c.update(FakeSM(v_cruise=35.0))
|
||||
assert not c.is_enabled()
|
||||
|
||||
Params().put('AccelPersonality', AccelPersonality.eco)
|
||||
Params().put_bool('AccelPersonalityEnabled', True)
|
||||
for _ in range(PARAM_REFRESH_FRAMES + 1):
|
||||
c.update(FakeSM(v_cruise=35.0))
|
||||
assert c._first
|
||||
|
||||
def test_vcruise_unset_treated_as_zero(self):
|
||||
Params().put_bool('AccelPersonalityEnabled', True)
|
||||
c = AccelPersonalityController()
|
||||
c.update(FakeSM(v_cruise=V_CRUISE_UNSET))
|
||||
assert c._v_cruise == 0.0
|
||||
|
||||
|
||||
class TestAccelUsageDiff:
|
||||
def test_accel_clip_per_personality(self, capsys):
|
||||
rows = []
|
||||
speeds = [3.0, 10.0, 20.0, 30.0]
|
||||
personalities = [
|
||||
('eco', AccelPersonality.eco),
|
||||
('normal', AccelPersonality.normal),
|
||||
('sport', AccelPersonality.sport),
|
||||
]
|
||||
|
||||
Params().put_bool('AccelPersonalityEnabled', True)
|
||||
sm = FakeSM(v_cruise=35.0)
|
||||
|
||||
any_delta = False
|
||||
for label, p in personalities:
|
||||
Params().put('AccelPersonality', p)
|
||||
c = AccelPersonalityController()
|
||||
c.update(sm)
|
||||
for v_ego in speeds:
|
||||
stock_hi = float(stock_get_max_accel(v_ego))
|
||||
c_lo, c_hi = c.get_accel_limits(v_ego)
|
||||
delta_hi = c_hi - stock_hi
|
||||
delta_lo = c_lo - ACCEL_MIN
|
||||
if abs(delta_hi) > 0.01 or abs(delta_lo) > 0.01:
|
||||
any_delta = True
|
||||
rows.append((label, v_ego, stock_hi, c_hi, delta_hi, c_lo, delta_lo))
|
||||
|
||||
with capsys.disabled():
|
||||
_print_table(
|
||||
"AccelPersonalityController: a_max stock vs controller",
|
||||
["personality", "v_ego", "stock_hi", "ctrl_hi", "delta_hi", "ctrl_lo", "delta_lo"],
|
||||
rows,
|
||||
)
|
||||
assert any_delta
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -199,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
|
||||
@@ -238,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,
|
||||
@@ -274,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")
|
||||
|
||||
186
sunnypilot/sunnylink/capabilities.py
Normal file
186
sunnypilot/sunnylink/capabilities.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
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 _resolve_brand_capabilities(caps: dict, bundle_platform: str, CP) -> None:
|
||||
"""Set brand-specific capabilities from bundle platform or CarParams fallback.
|
||||
|
||||
Bundle (manual car selection) is a pre-fingerprint approximation.
|
||||
CarParams (auto-fingerprint) is the authoritative post-fingerprint source.
|
||||
Mirrors the per-brand update_settings() logic in device UI layouts.
|
||||
"""
|
||||
brand = caps["brand"]
|
||||
|
||||
if brand == "hyundai":
|
||||
if 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}")
|
||||
elif CP is not None:
|
||||
caps["hyundai_alpha_long_available"] = bool(CP.alphaLongitudinalAvailable)
|
||||
|
||||
elif brand == "subaru":
|
||||
if 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}")
|
||||
elif CP is not None:
|
||||
caps["subaru_has_sng"] = not bool(CP.flags & (SubaruFlags.GLOBAL_GEN2 | SubaruFlags.HYBRID))
|
||||
caps["has_stop_and_go"] = caps["subaru_has_sng"]
|
||||
|
||||
|
||||
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 = None
|
||||
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:
|
||||
CP = None
|
||||
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")
|
||||
|
||||
_resolve_brand_capabilities(caps, bundle_platform, CP)
|
||||
|
||||
return caps
|
||||
|
||||
|
||||
def generate_capabilities_json(params: Params | None = None) -> str:
|
||||
"""Generate SettingsCapabilities as a JSON string."""
|
||||
return json.dumps(generate_capabilities(params), separators=(",", ":"))
|
||||
586
sunnypilot/sunnylink/docs/README.md
Normal file
586
sunnypilot/sunnylink/docs/README.md
Normal file
@@ -0,0 +1,586 @@
|
||||
# sunnylink Settings UI Guide
|
||||
|
||||
> One YAML file per page. Edit, run the compiler, commit. The sunnylink frontend updates automatically.
|
||||
|
||||
## What you edit (and what's generated)
|
||||
|
||||
| File | What | When to edit |
|
||||
|------|------|-------------|
|
||||
| `settings_ui_src/pages/<page>.yaml` | One YAML per page (panel). Contains panel metadata + sections + items + sub_panels inline. | Adding/changing/removing a setting. |
|
||||
| `settings_ui_src/pages/vehicle.yaml` | Per-brand settings page (`kind: vehicle`). Each brand is a section. | Adding/changing a vehicle-specific setting. |
|
||||
| `settings_ui_src/_macros.yaml` | Named rule fragments referenced via `{$ref: "#/macros/<name>"}`. | Adding a reusable rule (e.g. a new platform gate). |
|
||||
| **`settings_ui.json`** | **Generated from src tree by `compile_settings_ui.py`. Do not edit by hand.** | Never. Compiler emits it; frontend reads it. |
|
||||
|
||||
Pages today: `steering, cruise, display, visuals, toggles, device, software, developer, models, vehicle` (10).
|
||||
|
||||
Run `python sunnypilot/sunnylink/tools/compile_settings_ui.py` after edits. Add `--check` in CI to fail on out-of-sync `settings_ui.json`.
|
||||
|
||||
Display metadata (titles, descriptions, options, min/max/step/unit) is inline on each item. There is no separate metadata file.
|
||||
|
||||
## Page file shape
|
||||
|
||||
A page YAML contains the whole panel: metadata at the top, then `sections`. Each section has its own `items` and (optionally) `sub_panels`. Sub-panels are nested inside the section they belong to. Items appear in the order written in the file.
|
||||
|
||||
```yaml
|
||||
# yaml-language-server: $schema=../_schemas/page.schema.json
|
||||
id: steering
|
||||
label: Steering
|
||||
icon: steering_wheel
|
||||
order: 1
|
||||
remote_configurable: true
|
||||
description: Lateral control, lane changes, and steering behavior
|
||||
|
||||
sections:
|
||||
- id: mads
|
||||
title: Modular Assistive Driving System (MADS)
|
||||
items:
|
||||
- key: Mads
|
||||
widget: toggle
|
||||
title: Enable Modular Assistive Driving System (MADS)
|
||||
description: |
|
||||
Enable the beloved MADS feature. Disable toggle to revert back
|
||||
to stock sunnypilot engagement/disengagement.
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
|
||||
sub_panels:
|
||||
- id: mads_settings
|
||||
label: MADS Settings
|
||||
trigger_key: Mads
|
||||
trigger_condition: {type: param, key: Mads, equals: true}
|
||||
items:
|
||||
- key: MadsMainCruiseAllowed
|
||||
widget: toggle
|
||||
title: Toggle with Main Cruise
|
||||
description: |
|
||||
Note: For vehicles without LFA/LKAS button, disabling this will
|
||||
prevent lateral control engagement.
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {$ref: "#/macros/mads_full_platforms"}
|
||||
```
|
||||
|
||||
The vehicle page has the same shape but declares `kind: vehicle`; each section's `id` becomes a brand key under `vehicle_settings` in the compiled JSON.
|
||||
|
||||
## Macros (named rule fragments)
|
||||
|
||||
`_macros.yaml` declares reusable rule lists. Reference them from any rules array via `{$ref: "#/macros/<name>"}`.
|
||||
|
||||
```yaml
|
||||
macros:
|
||||
offroad: [{type: offroad_only}]
|
||||
longitudinal: [{type: capability, field: has_longitudinal_control, equals: true}]
|
||||
mads_full_platforms:
|
||||
- type: not
|
||||
condition:
|
||||
type: any
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: rivian}
|
||||
- type: all
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: tesla}
|
||||
- type: not
|
||||
condition: {type: capability, field: tesla_has_vehicle_bus, equals: true}
|
||||
```
|
||||
|
||||
In an item:
|
||||
|
||||
```yaml
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {$ref: "#/macros/mads_full_platforms"}
|
||||
```
|
||||
|
||||
The compiler splices a list-context `$ref` into its parent list. Macros may reference other macros up to depth 3; cycles are an error.
|
||||
|
||||
## Compiler workflow
|
||||
|
||||
```
|
||||
1. common/params_keys.h — add/remove the C++ param key
|
||||
2. params_metadata.json — automated via update_params_metadata.py
|
||||
3. settings_ui_src/pages/<page>.yaml — add/edit/remove the item in the right section
|
||||
4. python sunnypilot/sunnylink/tools/compile_settings_ui.py
|
||||
5. python sunnypilot/sunnylink/tools/validate_settings_ui.py (or: --check on the compiler)
|
||||
6. uv run python -m pytest sunnypilot/sunnylink/tests/ # run regression + compiler tests
|
||||
7. commit
|
||||
```
|
||||
|
||||
CI runs `compile_settings_ui.py --check` to fail on hand-edited `settings_ui.json`.
|
||||
|
||||
## Compiled output reference (schema contract)
|
||||
|
||||
The tables below describe the **compiled** `settings_ui.json` schema — what the frontend consumes at runtime. JSON snippets show the wire shape; in the src tree you author YAML that compiles to the same shape. Use these as a contract reference for valid fields, their meanings, and rule types.
|
||||
|
||||
## Quick reference: widget types
|
||||
|
||||
| Widget | Use for | Fields needed |
|
||||
|--------|---------|---------------|
|
||||
| `toggle` | On/off boolean | `title` |
|
||||
| `multiple_button` | 2-4 discrete options | `title` + `options` array |
|
||||
| `option` | Numeric range or dropdown | `title` + `min/max/step` or `options` |
|
||||
| `info` | Read-only display | `title` |
|
||||
|
||||
## Quick reference: item fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `key` | Yes | Param key name (must exist in `params_keys.h`) |
|
||||
| `widget` | Yes | `toggle`, `option`, `multiple_button`, `button`, `info` |
|
||||
| `title` | Yes | Display name shown to the user |
|
||||
| `description` | No | Inline explanatory text below the title. May be empty when only `details` is used. |
|
||||
| `details` | No | Extended help text shown in a modal when the user taps an "i" button on the row. Independent of `description`: either, both, or neither may be present. |
|
||||
| `options` | For selectors | Array of `{"value": 0, "label": "Off"}` objects (see per-option enablement below) |
|
||||
| `min`, `max`, `step` | For sliders | Numeric range constraints |
|
||||
| `unit` | No | Unit label. Static: `"seconds"`. Dynamic: `{"metric": "km/h", "imperial": "mph"}` (resolved by IsMetric) |
|
||||
| `visibility` | No | Rules for show/hide. Settings are never hidden, always dimmed with UNAVAILABLE badge when rules fail |
|
||||
| `enablement` | No | Rules for enabled/disabled (all must pass). Dimmed with badge when rules fail |
|
||||
| `blocked` | No | `true` for device-only settings that cannot be modified remotely. Frontend shows as read-only |
|
||||
| `title_param_suffix` | No | Dynamic title suffix. Example: `{"param": "IsMetric", "values": {"0": "mph", "1": "km/h"}}` |
|
||||
| `sub_items` | No | Nested child items |
|
||||
| `needs_onroad_cycle` | No | `true` if changing this param triggers a system restart. Frontend shows a "Restart" badge. See [REFERENCE.md - Remote Onroad Cycle](REFERENCE.md#remote-onroad-cycle) |
|
||||
|
||||
## Quick reference: rule types
|
||||
|
||||
| Rule | Example | Use for |
|
||||
|------|---------|---------|
|
||||
| `offroad_only` | `{"type": "offroad_only"}` | Grey out while driving |
|
||||
| `not_engaged` | `{"type": "not_engaged"}` | Grey out only while engaged (started + selfdrive/MADS active) |
|
||||
| `capability` | `{"type": "capability", "field": "has_longitudinal_control", "equals": true}` | Car-dependent visibility |
|
||||
| `param` | `{"type": "param", "key": "Mads", "equals": true}` | Show/enable based on another setting |
|
||||
| `param_compare` | `{"type": "param_compare", "key": "SpeedLimitMode", "op": ">", "value": 0}` | Numeric comparison |
|
||||
| `not` | `{"type": "not", "condition": {...}}` | Negate a rule |
|
||||
| `any` | `{"type": "any", "conditions": [...]}` | OR logic |
|
||||
| `all` | `{"type": "all", "conditions": [...]}` | AND logic (for nesting inside `any`/`not`) |
|
||||
| `$ref` | `{"$ref": "#/macros/offroad"}` | Reference a named rule fragment in `_macros.yaml` |
|
||||
|
||||
**Visibility design**: Settings are always visible. When visibility rules fail, the setting is dimmed with an UNAVAILABLE badge, so users know it exists but is not applicable.
|
||||
|
||||
**Enablement rules**: Grayed out (disabled) when rules fail. Frontend shows a contextual badge explaining why.
|
||||
|
||||
**Capability fields** (referenced in rules): `has_longitudinal_control`, `has_icbm`, `icbm_available`, `torque_allowed`, `brand`, `pcm_cruise`, `alpha_long_available`, `steer_control_type`, `enable_bsm`, `is_release`, `is_sp_release`, `is_development`, `tesla_has_vehicle_bus`, `has_stop_and_go`, `stock_longitudinal`
|
||||
|
||||
---
|
||||
|
||||
## How to
|
||||
|
||||
### Pick a writability rule (offroad / not_engaged / param-based)
|
||||
|
||||
| Use this | When | Why |
|
||||
|---|---|---|
|
||||
| `offroad_only` | Param can only be safely changed when the car is parked. Most user-facing toggles. | Strictest. Frontend shows "device is driving" badge and disables the row. |
|
||||
| `not_engaged` | Param can be changed while the car is started but only when sunnypilot/MADS is **not** actively driving. | Less strict than offroad. Matches Raylib `engaged = started AND (selfdriveState.enabled OR mads.enabled)`. Use for items the device must apply mid-drive (e.g. test maneuvers, longitudinal stock-vs-OP toggle). |
|
||||
| `param`-based | Behavior depends on another setting's value (parent toggle, mode selector, etc.). | Composes with `not`/`any`/`all` for arbitrary logic. |
|
||||
| `capability`-based | Behavior depends on the connected car or device (brand, longitudinal, hardware). | Resolved on the device from `CarParams` / hardware. See [`capabilities.py`](../capabilities.py) for the full field list. |
|
||||
| (no rule) | Param is always writable, no gating. | Rare. Prefer at least `offroad_only` unless the param is genuinely safe to flip mid-drive. |
|
||||
|
||||
Default for new toggles: `enablement: [{$ref: "#/macros/offroad"}]`. Drop down to `not_engaged` only if you've confirmed mid-drive write is safe in the controls/UI code path.
|
||||
|
||||
### Use `details` for safety notes / extended help
|
||||
|
||||
Inline `description` shows under the title. For longer caveats, safety notes, or "learn more" content, use `details` — the frontend renders an info button that opens a modal. Either field may be present alone or both together.
|
||||
|
||||
```yaml
|
||||
- key: AutoLaneChangeTimer
|
||||
widget: option
|
||||
title: Auto Lane Change by Blinker
|
||||
description: |-
|
||||
Set a timer to delay the auto lane change operation when the blinker is used.
|
||||
No nudge on the steering wheel is required to auto lane change if a timer is set.
|
||||
Default is Nudge.
|
||||
details: |-
|
||||
Please use caution when using this feature. Only use the blinker when traffic
|
||||
and road conditions permit.
|
||||
options: [...]
|
||||
```
|
||||
|
||||
For an item that is intentionally minimal inline (no inline body, only the modal):
|
||||
|
||||
```yaml
|
||||
- key: SomeAdvancedToggle
|
||||
widget: toggle
|
||||
title: Some Advanced Feature
|
||||
details: |-
|
||||
Long-form rationale, caveats, links, etc. — kept entirely behind the info button.
|
||||
```
|
||||
|
||||
### Add a toggle
|
||||
|
||||
1. Register in `common/params_keys.h`:
|
||||
```cpp
|
||||
{"MyToggle", {PERSISTENT | BACKUP, BOOL}},
|
||||
```
|
||||
|
||||
2. Open `settings_ui_src/pages/<page>.yaml`. Add the item to the right section:
|
||||
```yaml
|
||||
- key: MyToggle
|
||||
widget: toggle
|
||||
title: My Feature
|
||||
description: What this feature does.
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
```
|
||||
|
||||
If changing the param requires an onroad cycle to take effect, add `needs_onroad_cycle: true`.
|
||||
|
||||
3. Compile + validate + test:
|
||||
```
|
||||
python sunnypilot/sunnylink/tools/compile_settings_ui.py
|
||||
python sunnypilot/sunnylink/tools/validate_settings_ui.py
|
||||
uv run python -m pytest sunnypilot/sunnylink/tests/
|
||||
```
|
||||
|
||||
### Add a multi-button option
|
||||
|
||||
```yaml
|
||||
- key: MySelector
|
||||
widget: multiple_button
|
||||
title: Mode
|
||||
options:
|
||||
- {value: 0, label: Off}
|
||||
- {value: 1, label: On}
|
||||
- {value: 2, label: Auto}
|
||||
```
|
||||
|
||||
### Add a slider or range
|
||||
|
||||
```yaml
|
||||
- key: MyRange
|
||||
widget: option
|
||||
title: Follow Distance
|
||||
description: Time gap to lead vehicle.
|
||||
min: 0.5
|
||||
max: 3.0
|
||||
step: 0.1
|
||||
unit: seconds
|
||||
```
|
||||
|
||||
### Add a slider with metric/imperial units
|
||||
|
||||
```yaml
|
||||
- key: MinSpeed
|
||||
widget: option
|
||||
title: Minimum Speed
|
||||
min: 0
|
||||
max: 100
|
||||
step: 5
|
||||
unit: {metric: km/h, imperial: mph}
|
||||
```
|
||||
|
||||
Frontend resolves the unit string based on the device's `IsMetric` param. Static units (e.g. `seconds`, `m/s²`) stay plain strings.
|
||||
|
||||
### Add a dynamic title suffix
|
||||
|
||||
```yaml
|
||||
- key: FollowDistance
|
||||
widget: option
|
||||
title: Follow Distance
|
||||
title_param_suffix:
|
||||
param: IsMetric
|
||||
values: {'0': mph, '1': km/h}
|
||||
min: 0.5
|
||||
max: 3.0
|
||||
step: 0.1
|
||||
```
|
||||
|
||||
Renders as "Follow Distance: mph" / "Follow Distance: km/h".
|
||||
|
||||
### Add a device-only read-only setting
|
||||
|
||||
```yaml
|
||||
- key: OnroadCyclePendingRemote
|
||||
widget: info
|
||||
title: Pending Remote Cycle
|
||||
blocked: true
|
||||
```
|
||||
|
||||
Frontend treats `blocked: true` items as read-only.
|
||||
|
||||
### Add a dropdown option
|
||||
|
||||
```yaml
|
||||
- key: MyDropdown
|
||||
widget: option
|
||||
title: Recording Quality
|
||||
options:
|
||||
- {value: 0, label: Low (720p)}
|
||||
- {value: 1, label: Medium (1080p)}
|
||||
- {value: 2, label: High (4K)}
|
||||
```
|
||||
|
||||
### Per-option enablement rules
|
||||
|
||||
```yaml
|
||||
- key: MadsSteeringMode
|
||||
widget: multiple_button
|
||||
title: Steering Mode on Brake Pedal
|
||||
options:
|
||||
- value: 0
|
||||
label: Remain Active
|
||||
enablement:
|
||||
- {$ref: "#/macros/mads_full_platforms"}
|
||||
- value: 1
|
||||
label: Pause
|
||||
enablement:
|
||||
- {$ref: "#/macros/mads_full_platforms"}
|
||||
- value: 2
|
||||
label: Disengage
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
```
|
||||
|
||||
When an option's enablement fails, that option is grayed out but still visible.
|
||||
|
||||
### Show only when another setting is on
|
||||
|
||||
```yaml
|
||||
- key: ChildSetting
|
||||
widget: toggle
|
||||
title: Child Feature
|
||||
visibility:
|
||||
- {type: param, key: ParentToggle, equals: true}
|
||||
```
|
||||
|
||||
(With the "dim instead of hide" design, this setting is dimmed, not hidden, when the rule fails.)
|
||||
|
||||
### Show only for specific brands
|
||||
|
||||
```yaml
|
||||
- key: LongFeature
|
||||
widget: toggle
|
||||
title: Longitudinal Feature
|
||||
visibility:
|
||||
- {$ref: "#/macros/longitudinal"}
|
||||
```
|
||||
|
||||
### Combine multiple conditions
|
||||
|
||||
The `enablement` array is implicit-AND: every entry must pass. Use `any` for OR, `all` for nested AND, `not` for negation. Wrap repeated combinations in a macro so future you doesn't re-derive the logic.
|
||||
|
||||
**AND across two params** (writable only when both Mads is on AND ICBM is enabled):
|
||||
```yaml
|
||||
enablement:
|
||||
- {type: param, key: Mads, equals: true}
|
||||
- {type: param, key: IntelligentCruiseButtonManagement, equals: true}
|
||||
```
|
||||
|
||||
**OR across two params** (writable when either is on):
|
||||
```yaml
|
||||
enablement:
|
||||
- type: any
|
||||
conditions:
|
||||
- {type: param, key: ExperimentalMode, equals: true}
|
||||
- {type: param, key: DynamicExperimentalControl, equals: true}
|
||||
```
|
||||
|
||||
**Mixed: capability AND param** (only on longitudinal cars when ShowAdvancedControls is on):
|
||||
```yaml
|
||||
enablement:
|
||||
- {$ref: "#/macros/longitudinal"}
|
||||
- {$ref: "#/macros/advanced_only"}
|
||||
```
|
||||
|
||||
**Three-way: offroad AND torque-allowed AND not-NNLC** (real example: `EnforceTorqueControl`):
|
||||
```yaml
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {type: capability, field: torque_allowed, equals: true}
|
||||
- {type: param, key: NeuralNetworkLateralControl, equals: false}
|
||||
```
|
||||
|
||||
**Negation across multiple platforms** (everything except Rivian + Tesla-no-bus):
|
||||
```yaml
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {$ref: "#/macros/mads_full_platforms"} # macro encapsulates the not(any(rivian, all(tesla, not(bus)))) logic
|
||||
```
|
||||
|
||||
If the same multi-condition block appears in 2+ items, **promote it to a macro** in `_macros.yaml`. Re-run `python sunnypilot/sunnylink/tools/apply_macros.py` to substitute existing inlined matches automatically.
|
||||
|
||||
### Mutual exclusion
|
||||
|
||||
```yaml
|
||||
- key: FeatureAlpha
|
||||
widget: toggle
|
||||
title: Feature Alpha
|
||||
enablement:
|
||||
- {type: param, key: FeatureBeta, equals: false}
|
||||
|
||||
- key: FeatureBeta
|
||||
widget: toggle
|
||||
title: Feature Beta
|
||||
enablement:
|
||||
- {type: param, key: FeatureAlpha, equals: false}
|
||||
```
|
||||
|
||||
### Add a section
|
||||
|
||||
In the page YAML, add an entry to the `sections` list:
|
||||
```yaml
|
||||
sections:
|
||||
- id: my_section
|
||||
title: My Section
|
||||
description: Optional subtitle
|
||||
enablement:
|
||||
- {$ref: "#/macros/longitudinal"}
|
||||
items:
|
||||
- {key: ..., widget: toggle, title: ...}
|
||||
```
|
||||
|
||||
Sections support `visibility`, `enablement`, and `attestation_required`. When section-level rules fail, all items within are dimmed.
|
||||
|
||||
### Add a sub-panel
|
||||
|
||||
Sub-panels nest inside the section they belong to:
|
||||
```yaml
|
||||
sections:
|
||||
- id: parent_section
|
||||
title: Parent
|
||||
items: [...]
|
||||
sub_panels:
|
||||
- id: my_sub
|
||||
label: Advanced Settings
|
||||
trigger_key: ParentParam
|
||||
trigger_condition: {type: param, key: ParentParam, equals: true}
|
||||
items:
|
||||
- {key: ..., widget: toggle, title: ...}
|
||||
```
|
||||
|
||||
### Add vehicle-brand settings
|
||||
|
||||
Edit `pages/vehicle.yaml`. Each section is a brand:
|
||||
```yaml
|
||||
id: vehicle
|
||||
kind: vehicle
|
||||
sections:
|
||||
- id: rivian
|
||||
title: Rivian Settings
|
||||
description: ''
|
||||
items:
|
||||
- key: RivianFeature
|
||||
widget: toggle
|
||||
title: Rivian One Pedal
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
```
|
||||
|
||||
`kind: vehicle` tells the compiler to emit this page as `vehicle_settings.<brand>` in the wire JSON.
|
||||
|
||||
### Add a feature with toggles, sub-panel, and macro
|
||||
|
||||
Example: "Smart Wipers" with a master toggle, intensity selector, and sub-panel for advanced tuning, gated to torque-steering Hyundais on offroad.
|
||||
|
||||
1. **Param keys** — register all 4 in `common/params_keys.h`.
|
||||
|
||||
2. **Decide on a macro** — if "torque Hyundai" gating is reused, add to `_macros.yaml`:
|
||||
```yaml
|
||||
torque_hyundai:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {type: capability, field: brand, equals: hyundai}
|
||||
- {type: capability, field: torque_allowed, equals: true}
|
||||
```
|
||||
|
||||
3. **Edit the relevant page** — `pages/visuals.yaml` (or wherever the feature lives). Add a new section + sub_panel:
|
||||
```yaml
|
||||
sections:
|
||||
- id: smart_wipers
|
||||
title: Smart Wipers
|
||||
description: Camera-driven wiper control (Hyundai/Kia, torque only)
|
||||
items:
|
||||
- key: SmartWipersEnabled
|
||||
widget: toggle
|
||||
title: Enable Smart Wipers
|
||||
enablement:
|
||||
- {$ref: "#/macros/torque_hyundai"}
|
||||
- key: SmartWipersIntensity
|
||||
widget: multiple_button
|
||||
title: Sensitivity
|
||||
options:
|
||||
- {value: 0, label: Low}
|
||||
- {value: 1, label: Medium}
|
||||
- {value: 2, label: High}
|
||||
visibility:
|
||||
- {type: param, key: SmartWipersEnabled, equals: true}
|
||||
enablement:
|
||||
- {$ref: "#/macros/torque_hyundai"}
|
||||
sub_panels:
|
||||
- id: smart_wipers_tuning
|
||||
label: Smart Wipers Tuning
|
||||
trigger_key: SmartWipersEnabled
|
||||
trigger_condition: {type: param, key: SmartWipersEnabled, equals: true}
|
||||
items:
|
||||
- key: SmartWipersHysteresis
|
||||
widget: option
|
||||
title: Hysteresis (frames)
|
||||
min: 1
|
||||
max: 30
|
||||
step: 1
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {$ref: "#/macros/advanced_only"}
|
||||
```
|
||||
|
||||
4. **Compile / validate / test**:
|
||||
```
|
||||
python sunnypilot/sunnylink/tools/compile_settings_ui.py
|
||||
python sunnypilot/sunnylink/tools/validate_settings_ui.py
|
||||
uv run python -m pytest sunnypilot/sunnylink/tests/
|
||||
```
|
||||
|
||||
`apply_macros.py` is automatic for newly-added items only if you wrote the rule list inline; for greenfield items, you'd write `$ref` directly.
|
||||
|
||||
### Change a toggle's behavior
|
||||
|
||||
1. Find the item in `pages/<page>.yaml`.
|
||||
2. Edit `visibility`/`enablement`/`options[].enablement` directly. Use macros where possible.
|
||||
3. **Add a regression test** in `sunnypilot/sunnylink/tests/test_settings_changes.py` that asserts the new gate exists. Use existing tests (e.g. `TestMadsBrandGates`, `TestNotEngagedReplacement`) as templates: lookup item by key, assert `_references_capability_field(rules, "...")` or `_flatten_rule_types(rules)` contains/excludes a type. This freezes the new behavior so a future edit won't silently revert it.
|
||||
4. Compile + run the full suite. Per-bug test should pass; structural tests should remain green.
|
||||
|
||||
### Change a widget type or options
|
||||
|
||||
Editing `widget:` from `toggle` to `multiple_button` is a frontend behavior change. Whenever you change widget shape:
|
||||
- The param's underlying type (bool / int / string) must match what the new widget writes. `toggle` writes bool; `multiple_button`/`option` write int/string. Update `params_keys.h` if the type changes.
|
||||
- Add an `options:` list when switching to `multiple_button` or `option`.
|
||||
- Old values stored on devices may not be valid for the new widget. Consider a migration in `sunnypilot/system/updated/` if users have stale values.
|
||||
|
||||
### Deprecate or remove a setting
|
||||
|
||||
1. Remove the item from `pages/<page>.yaml`.
|
||||
2. Remove the param key from `common/params_keys.h` **only after** confirming nothing in `selfdrive/`, `sunnypilot/`, or any controls code reads it.
|
||||
3. If the param has been on user devices, drop it via a migration (see `sunnypilot/system/updated/`) so stale values don't linger.
|
||||
4. Compile + validate + test. The validator's "no duplicate keys" + structural checks will fail if anything still references the removed key.
|
||||
|
||||
### Move a setting to another page
|
||||
|
||||
Cut the item block from one page YAML, paste into the target page's section. Compile + validate. The "no duplicate keys" check catches forgotten copies.
|
||||
|
||||
### Change display text
|
||||
|
||||
Edit `title:` or `description:` in the page YAML and recompile to regenerate `settings_ui.json`.
|
||||
|
||||
### Reorder sections, sub-panels, and items
|
||||
|
||||
Reorder them within their parent list in the YAML. The compiler preserves authored order — no `order:` field required at the section/sub_panel/item level (panel-level `order:` controls which page comes first in the side nav).
|
||||
|
||||
---
|
||||
|
||||
### Capability labels and tooltips
|
||||
|
||||
The schema response includes `capability_labels`, which map capability field names to descriptions. The frontend uses these to show contextual tooltips when a capability rule prevents a setting from being used.
|
||||
|
||||
The device defines these labels in `capabilities.py:CAPABILITY_LABELS`. Examples:
|
||||
|
||||
- `has_longitudinal_control` → "sunnypilot longitudinal control"
|
||||
- `torque_allowed` → "torque steering (not available for angle steering vehicles)"
|
||||
- `brand` → "Vehicle brand"
|
||||
|
||||
### Centralized param enforcement
|
||||
|
||||
The device-side UI enforces capability constraints in `selfdrive/ui/sunnypilot/ui_state.py:_enforce_constraints()`, which removes incompatible params based on car capabilities. This is the single source of truth for such constraints.
|
||||
|
||||
Settings layouts should not duplicate these params.remove() calls. Instead, rely on schema rules and centralized enforcement to prevent duplicate logic and ensure consistency.
|
||||
|
||||
Example constraints in `_enforce_constraints()`:
|
||||
- Angle steering cars: remove `EnforceTorqueControl` and `NeuralNetworkLateralControl`
|
||||
- No CarParams: remove all car-dependent params
|
||||
- No longitudinal: remove `ExperimentalMode`
|
||||
- No ICBM: remove `IntelligentCruiseButtonManagement`
|
||||
@@ -1,4 +1,26 @@
|
||||
{
|
||||
"AccelPersonality": {
|
||||
"title": "Acceleration Personality",
|
||||
"description": "Select the acceleration personality profile. Sport provides more aggressive acceleration, Eco provides gentler acceleration.",
|
||||
"options": [
|
||||
{
|
||||
"value": 0,
|
||||
"label": "Sport"
|
||||
},
|
||||
{
|
||||
"value": 1,
|
||||
"label": "Normal"
|
||||
},
|
||||
{
|
||||
"value": 2,
|
||||
"label": "Eco"
|
||||
}
|
||||
]
|
||||
},
|
||||
"AccelPersonalityEnabled": {
|
||||
"title": "Custom Acceleration Personality",
|
||||
"description": "Enable custom acceleration and braking profiles that adjust max acceleration and min deceleration based on speed and selected personality."
|
||||
},
|
||||
"AccessToken": {
|
||||
"title": "AccessTokenIsNice",
|
||||
"description": ""
|
||||
@@ -1071,6 +1093,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",
|
||||
|
||||
2267
sunnypilot/sunnylink/settings_ui.json
Normal file
2267
sunnypilot/sunnylink/settings_ui.json
Normal file
File diff suppressed because it is too large
Load Diff
516
sunnypilot/sunnylink/settings_ui.schema.json
Normal file
516
sunnypilot/sunnylink/settings_ui.schema.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
65
sunnypilot/sunnylink/settings_ui_src/_macros.yaml
Normal file
65
sunnypilot/sunnylink/settings_ui_src/_macros.yaml
Normal file
@@ -0,0 +1,65 @@
|
||||
# Named rule fragments. Reference from items/sections via {$ref: "#/macros/<name>"}.
|
||||
# Macros may $ref other macros (max depth 3 — see compile_settings_ui.py). No template logic.
|
||||
#
|
||||
# Adding a macro: define here once, then reference everywhere. The compiler
|
||||
# resolves $refs into the canonical settings_ui.json output the frontend reads.
|
||||
|
||||
macros:
|
||||
# Most-used: only writable when the device is offroad.
|
||||
offroad:
|
||||
- {type: offroad_only}
|
||||
|
||||
# Writable while not engaged (started, but selfdrive/MADS not active).
|
||||
not_engaged:
|
||||
- {type: not_engaged}
|
||||
|
||||
# sunnypilot longitudinal control is active.
|
||||
longitudinal:
|
||||
- {type: capability, field: has_longitudinal_control, equals: true}
|
||||
|
||||
# Longitudinal + ICBM both available.
|
||||
longitudinal_and_icbm:
|
||||
- {type: capability, field: has_longitudinal_control, equals: true}
|
||||
- {type: capability, field: has_icbm, equals: true}
|
||||
|
||||
# Item only meaningful when "Show Advanced Controls" is enabled by the user.
|
||||
advanced_only:
|
||||
- {type: param, key: ShowAdvancedControls, equals: true}
|
||||
|
||||
# Hide on MICI hardware (no analog HUD support yet).
|
||||
hide_on_mici:
|
||||
- type: not
|
||||
condition: {type: capability, field: device_type, equals: mici}
|
||||
|
||||
# Mirrors selfdrive/ui/sunnypilot/layouts/.../mads_settings.py:_mads_limited_settings()
|
||||
# Rivian + Tesla-without-vehicle-bus get the limited MADS UI (3-toggle subset).
|
||||
# On those platforms these toggles are disabled — full MADS settings are
|
||||
# only writable on platforms NOT in the limited-set.
|
||||
mads_full_platforms:
|
||||
- type: not
|
||||
condition:
|
||||
type: any
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: rivian}
|
||||
- type: all
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: tesla}
|
||||
- type: not
|
||||
condition: {type: capability, field: tesla_has_vehicle_bus, equals: true}
|
||||
|
||||
# Inverse of mads_full_platforms: present only on the limited platforms.
|
||||
# Useful for "show this only on rivian/tesla-no-bus" toggles.
|
||||
mads_limited_platforms:
|
||||
- type: any
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: rivian}
|
||||
- type: all
|
||||
conditions:
|
||||
- {type: capability, field: brand, equals: tesla}
|
||||
- type: not
|
||||
condition: {type: capability, field: tesla_has_vehicle_bus, equals: true}
|
||||
|
||||
# Hide on sunnypilot release branches (is_release is hardcoded False everywhere; is_sp_release is the active gate).
|
||||
release_branches_hide:
|
||||
- type: not
|
||||
condition: {type: capability, field: is_sp_release, equals: true}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sunnypilot.com/schemas/sdui/macros.schema.json",
|
||||
"title": "Settings UI macros (named rule fragments)",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["macros"],
|
||||
"properties": {
|
||||
"macros": {
|
||||
"type": "object",
|
||||
"description": "Named rule fragments. Each value is either a list of rules (typical) or a single rule object. Reference from items/layout via {$ref: '#/macros/<name>'}.",
|
||||
"patternProperties": {
|
||||
"^[A-Za-z_][A-Za-z0-9_]*$": {
|
||||
"oneOf": [
|
||||
{"type": "array", "items": {"$ref": "rule.schema.json"}},
|
||||
{"$ref": "rule.schema.json"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
113
sunnypilot/sunnylink/settings_ui_src/_schemas/page.schema.json
Normal file
113
sunnypilot/sunnylink/settings_ui_src/_schemas/page.schema.json
Normal file
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sunnypilot.com/schemas/sdui/page.schema.json",
|
||||
"title": "Settings UI page (panel) YAML",
|
||||
"description": "Validates pages/<id>.yaml. Each page describes one settings panel (or the vehicle namespace via kind: vehicle).",
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"label": {"type": "string"},
|
||||
"icon": {"type": "string"},
|
||||
"order": {"type": "integer"},
|
||||
"remote_configurable": {"type": "boolean"},
|
||||
"description": {"type": "string"},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["panel", "vehicle"],
|
||||
"description": "panel (default) or vehicle (compiles to settings_ui.json#vehicle_settings)."
|
||||
},
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Section"}
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/Item"}
|
||||
},
|
||||
"sub_panels": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/$defs/SubPanel"}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"Section": {
|
||||
"type": "object",
|
||||
"required": ["id", "title"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"order": {"type": "integer"},
|
||||
"attestation_required": {"type": "boolean"},
|
||||
"visibility": {"type": "array", "items": {"$ref": "rule.schema.json"}},
|
||||
"enablement": {"type": "array", "items": {"$ref": "rule.schema.json"}},
|
||||
"items": {"type": "array", "items": {"$ref": "#/$defs/Item"}},
|
||||
"sub_panels": {"type": "array", "items": {"$ref": "#/$defs/SubPanel"}}
|
||||
}
|
||||
},
|
||||
"SubPanel": {
|
||||
"type": "object",
|
||||
"required": ["id", "label"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"label": {"type": "string"},
|
||||
"trigger_key": {"type": ["string", "null"]},
|
||||
"trigger_condition": {"$ref": "rule.schema.json"},
|
||||
"items": {"type": "array", "items": {"$ref": "#/$defs/Item"}}
|
||||
}
|
||||
},
|
||||
"Item": {
|
||||
"type": "object",
|
||||
"required": ["key", "widget"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]*$"},
|
||||
"widget": {"type": "string", "enum": ["toggle", "option", "multiple_button", "button", "info"]},
|
||||
"title": {"type": "string"},
|
||||
"description": {"type": "string", "description": "Inline body text under the title. May be omitted when only details is used."},
|
||||
"details": {"type": "string", "description": "Extended help shown in a modal when the user taps the info (i) button. Independent of description; either, both, or neither may be present."},
|
||||
"title_param_suffix": {"type": "object"},
|
||||
"min": {"type": "number"},
|
||||
"max": {"type": "number"},
|
||||
"step": {"type": "number"},
|
||||
"unit": {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["metric", "imperial"],
|
||||
"properties": {
|
||||
"metric": {"type": "string"},
|
||||
"imperial": {"type": "string"}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"needs_onroad_cycle": {"type": "boolean"},
|
||||
"requires_attestation": {"type": "boolean"},
|
||||
"blocked": {"type": "boolean"},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["value", "label"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"value": {},
|
||||
"label": {"type": "string"},
|
||||
"enablement": {"type": "array", "items": {"$ref": "rule.schema.json"}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"visibility": {"type": "array", "items": {"$ref": "rule.schema.json"}},
|
||||
"enablement": {"type": "array", "items": {"$ref": "rule.schema.json"}},
|
||||
"sub_items": {"type": "array", "items": {"$ref": "#/$defs/Item"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
101
sunnypilot/sunnylink/settings_ui_src/_schemas/rule.schema.json
Normal file
101
sunnypilot/sunnylink/settings_ui_src/_schemas/rule.schema.json
Normal file
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://sunnypilot.com/schemas/sdui/rule.schema.json",
|
||||
"title": "Rule",
|
||||
"description": "Visibility/enablement rule. Discriminated union on 'type'. Macro reference is also accepted via {$ref: '#/macros/<name>'}.",
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/MacroRef"},
|
||||
{"$ref": "#/$defs/RuleOffroadOnly"},
|
||||
{"$ref": "#/$defs/RuleNotEngaged"},
|
||||
{"$ref": "#/$defs/RuleCapability"},
|
||||
{"$ref": "#/$defs/RuleParam"},
|
||||
{"$ref": "#/$defs/RuleParamCompare"},
|
||||
{"$ref": "#/$defs/RuleNot"},
|
||||
{"$ref": "#/$defs/RuleAny"},
|
||||
{"$ref": "#/$defs/RuleAll"}
|
||||
],
|
||||
"$defs": {
|
||||
"MacroRef": {
|
||||
"type": "object",
|
||||
"required": ["$ref"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$ref": {
|
||||
"type": "string",
|
||||
"pattern": "^#/macros/[A-Za-z_][A-Za-z0-9_]*$",
|
||||
"description": "Reference to a macro defined in _macros.yaml under #/macros/<name>."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RuleOffroadOnly": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {"type": {"const": "offroad_only"}}
|
||||
},
|
||||
"RuleNotEngaged": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"additionalProperties": false,
|
||||
"properties": {"type": {"const": "not_engaged"}}
|
||||
},
|
||||
"RuleCapability": {
|
||||
"type": "object",
|
||||
"required": ["type", "field", "equals"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "capability"},
|
||||
"field": {"type": "string"},
|
||||
"equals": {}
|
||||
}
|
||||
},
|
||||
"RuleParam": {
|
||||
"type": "object",
|
||||
"required": ["type", "key", "equals"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "param"},
|
||||
"key": {"type": "string"},
|
||||
"equals": {}
|
||||
}
|
||||
},
|
||||
"RuleParamCompare": {
|
||||
"type": "object",
|
||||
"required": ["type", "key", "op", "value"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "param_compare"},
|
||||
"key": {"type": "string"},
|
||||
"op": {"type": "string", "enum": [">", "<", ">=", "<="]},
|
||||
"value": {"type": "number"}
|
||||
}
|
||||
},
|
||||
"RuleNot": {
|
||||
"type": "object",
|
||||
"required": ["type", "condition"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "not"},
|
||||
"condition": {"$ref": "#"}
|
||||
}
|
||||
},
|
||||
"RuleAny": {
|
||||
"type": "object",
|
||||
"required": ["type", "conditions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "any"},
|
||||
"conditions": {"type": "array", "items": {"$ref": "#"}, "minItems": 1}
|
||||
}
|
||||
},
|
||||
"RuleAll": {
|
||||
"type": "object",
|
||||
"required": ["type", "conditions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {"const": "all"},
|
||||
"conditions": {"type": "array", "items": {"$ref": "#"}, "minItems": 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
297
sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml
Normal file
297
sunnypilot/sunnylink/settings_ui_src/pages/cruise.yaml
Normal file
@@ -0,0 +1,297 @@
|
||||
# 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: AccelPersonalityEnabled
|
||||
widget: toggle
|
||||
title: Acceleration Personality
|
||||
description: Enable per-personality acceleration profiles. Sport allows stronger acceleration; Eco is gentler.
|
||||
visibility:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: AccelPersonality
|
||||
widget: multiple_button
|
||||
title: Acceleration Profile
|
||||
description: Sport allows the most aggressive acceleration; Eco the gentlest. Normal sits between.
|
||||
options:
|
||||
- value: 0
|
||||
label: Sport
|
||||
- value: 1
|
||||
label: Normal
|
||||
- value: 2
|
||||
label: Eco
|
||||
visibility:
|
||||
- type: param
|
||||
key: AccelPersonalityEnabled
|
||||
equals: true
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- type: param
|
||||
key: AccelPersonalityEnabled
|
||||
equals: true
|
||||
- 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
|
||||
137
sunnypilot/sunnylink/settings_ui_src/pages/developer.yaml
Normal file
137
sunnypilot/sunnylink/settings_ui_src/pages/developer.yaml
Normal file
@@ -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'
|
||||
67
sunnypilot/sunnylink/settings_ui_src/pages/device.yaml
Normal file
67
sunnypilot/sunnylink/settings_ui_src/pages/device.yaml
Normal file
@@ -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
|
||||
130
sunnypilot/sunnylink/settings_ui_src/pages/display.yaml
Normal file
130
sunnypilot/sunnylink/settings_ui_src/pages/display.yaml
Normal file
@@ -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
|
||||
89
sunnypilot/sunnylink/settings_ui_src/pages/models.yaml
Normal file
89
sunnypilot/sunnylink/settings_ui_src/pages/models.yaml
Normal file
@@ -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'
|
||||
20
sunnypilot/sunnylink/settings_ui_src/pages/software.yaml
Normal file
20
sunnypilot/sunnylink/settings_ui_src/pages/software.yaml
Normal file
@@ -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 software updates
|
||||
items:
|
||||
- key: DisableUpdates
|
||||
widget: toggle
|
||||
title: Disable Updates
|
||||
description: When enabled, software updates will be off. This requires a reboot to take effect.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- $ref: '#/macros/advanced_only'
|
||||
257
sunnypilot/sunnylink/settings_ui_src/pages/steering.yaml
Normal file
257
sunnypilot/sunnylink/settings_ui_src/pages/steering.yaml
Normal file
@@ -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
|
||||
49
sunnypilot/sunnylink/settings_ui_src/pages/toggles.yaml
Normal file
49
sunnypilot/sunnylink/settings_ui_src/pages/toggles.yaml
Normal file
@@ -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.
|
||||
81
sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml
Normal file
81
sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml
Normal file
@@ -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
|
||||
111
sunnypilot/sunnylink/settings_ui_src/pages/visuals.yaml
Normal file
111
sunnypilot/sunnylink/settings_ui_src/pages/visuals.yaml
Normal file
@@ -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.
|
||||
92
sunnypilot/sunnylink/tests/test_capabilities.py
Normal file
92
sunnypilot/sunnylink/tests/test_capabilities.py
Normal file
@@ -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)
|
||||
194
sunnypilot/sunnylink/tests/test_compile_settings_ui.py
Normal file
194
sunnypilot/sunnylink/tests/test_compile_settings_ui.py
Normal file
@@ -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"
|
||||
219
sunnypilot/sunnylink/tests/test_settings_changes.py
Normal file
219
sunnypilot/sunnylink/tests/test_settings_changes.py
Normal file
@@ -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"
|
||||
353
sunnypilot/sunnylink/tests/test_settings_schema.py
Normal file
353
sunnypilot/sunnylink/tests/test_settings_schema.py
Normal file
@@ -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
|
||||
211
sunnypilot/sunnylink/tools/apply_macros.py
Executable file
211
sunnypilot/sunnylink/tools/apply_macros.py
Executable file
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
|
||||
Scans all per-page YAML files in settings_ui_src/pages/ and replaces inlined
|
||||
rule blocks that exactly match a macro definition in _macros.yaml with $ref
|
||||
references.
|
||||
|
||||
Match policy:
|
||||
- Whole-list match (exact): replace the entire list with [{$ref}].
|
||||
- Single-rule match: any individual rule whose canonical form equals macro[0]
|
||||
(when macro is exactly one rule) is replaced with {$ref}.
|
||||
|
||||
Re-run is idempotent (already-substituted $refs are skipped).
|
||||
|
||||
Usage:
|
||||
python apply_macros.py [--src DIR] [--dry-run]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_SRC = os.path.join(DIR, "settings_ui_src")
|
||||
|
||||
|
||||
class _BlockDumper(yaml.SafeDumper):
|
||||
pass
|
||||
|
||||
|
||||
def _represent_dict(dumper, data):
|
||||
return dumper.represent_mapping("tag:yaml.org,2002:map", data.items(), flow_style=False)
|
||||
|
||||
|
||||
def _represent_list(dumper, data):
|
||||
flow = all(not isinstance(x, (dict, list)) for x in data) and len(data) <= 8
|
||||
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=flow)
|
||||
|
||||
|
||||
_BlockDumper.add_representer(dict, _represent_dict)
|
||||
_BlockDumper.add_representer(list, _represent_list)
|
||||
|
||||
|
||||
def _dump_yaml(data) -> str:
|
||||
return yaml.dump(data, Dumper=_BlockDumper, sort_keys=False, allow_unicode=True, width=120)
|
||||
|
||||
|
||||
def _canon(node) -> str:
|
||||
return json.dumps(node, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _is_ref(node) -> bool:
|
||||
return isinstance(node, dict) and len(node) == 1 and "$ref" in node
|
||||
|
||||
|
||||
def _make_ref(name: str) -> dict:
|
||||
return {"$ref": f"#/macros/{name}"}
|
||||
|
||||
|
||||
def _substitute_rules(rules: list, whole_list: dict, single_rule: dict) -> list:
|
||||
if not isinstance(rules, list) or not rules:
|
||||
return rules
|
||||
full_canon = _canon(rules)
|
||||
if full_canon in whole_list:
|
||||
return [_make_ref(whole_list[full_canon])]
|
||||
out = []
|
||||
for r in rules:
|
||||
if _is_ref(r):
|
||||
out.append(r)
|
||||
continue
|
||||
rc = _canon(r)
|
||||
if rc in single_rule:
|
||||
out.append(_make_ref(single_rule[rc]))
|
||||
else:
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def _walk_item(item: dict, whole_list: dict, single_rule: dict) -> bool:
|
||||
changed = False
|
||||
for ctx in ("visibility", "enablement"):
|
||||
if ctx in item:
|
||||
new = _substitute_rules(item[ctx], whole_list, single_rule)
|
||||
if new != item[ctx]:
|
||||
item[ctx] = new
|
||||
changed = True
|
||||
if "options" in item:
|
||||
for opt in item["options"]:
|
||||
if not isinstance(opt, dict):
|
||||
continue
|
||||
for ctx in ("visibility", "enablement"):
|
||||
if ctx in opt:
|
||||
new = _substitute_rules(opt[ctx], whole_list, single_rule)
|
||||
if new != opt[ctx]:
|
||||
opt[ctx] = new
|
||||
changed = True
|
||||
if "sub_items" in item:
|
||||
for sub in item["sub_items"]:
|
||||
if _walk_item(sub, whole_list, single_rule):
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _walk_page(page: dict, whole_list: dict, single_rule: dict) -> bool:
|
||||
changed = False
|
||||
for sec in page.get("sections", []) or []:
|
||||
for ctx in ("visibility", "enablement"):
|
||||
if ctx in sec:
|
||||
new = _substitute_rules(sec[ctx], whole_list, single_rule)
|
||||
if new != sec[ctx]:
|
||||
sec[ctx] = new
|
||||
changed = True
|
||||
for it in sec.get("items", []) or []:
|
||||
if _walk_item(it, whole_list, single_rule):
|
||||
changed = True
|
||||
for sp in sec.get("sub_panels", []) or []:
|
||||
for it in sp.get("items", []) or []:
|
||||
if _walk_item(it, whole_list, single_rule):
|
||||
changed = True
|
||||
for it in page.get("items", []) or []:
|
||||
if _walk_item(it, whole_list, single_rule):
|
||||
changed = True
|
||||
for sp in page.get("sub_panels", []) or []:
|
||||
for it in sp.get("items", []) or []:
|
||||
if _walk_item(it, whole_list, single_rule):
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _build_macro_indices(macros: dict) -> tuple[dict, dict]:
|
||||
whole_list = {}
|
||||
single_rule = {}
|
||||
for name, body in macros.items():
|
||||
if isinstance(body, list):
|
||||
whole_list[_canon(body)] = name
|
||||
if len(body) == 1 and isinstance(body[0], dict):
|
||||
single_rule[_canon(body[0])] = name
|
||||
elif isinstance(body, dict):
|
||||
single_rule[_canon(body)] = name
|
||||
return whole_list, single_rule
|
||||
|
||||
|
||||
def _read_yaml_with_header(path: str) -> tuple[list[str], dict]:
|
||||
with open(path) as f:
|
||||
text = f.read()
|
||||
header_lines = []
|
||||
for line in text.splitlines(keepends=True):
|
||||
if line.startswith("#"):
|
||||
header_lines.append(line)
|
||||
elif line.strip() == "":
|
||||
header_lines.append(line)
|
||||
else:
|
||||
break
|
||||
doc = yaml.safe_load(text) or {}
|
||||
return header_lines, doc
|
||||
|
||||
|
||||
def _write_yaml_with_header(path: str, header: list[str], doc: dict) -> None:
|
||||
with open(path, "w") as f:
|
||||
if header:
|
||||
# Keep at most one trailing blank line in header
|
||||
while len(header) > 1 and header[-1].strip() == "" and header[-2].strip() == "":
|
||||
header.pop()
|
||||
for line in header:
|
||||
f.write(line)
|
||||
f.write(_dump_yaml(doc))
|
||||
|
||||
|
||||
def apply(src_dir: str, dry_run: bool) -> int:
|
||||
with open(os.path.join(src_dir, "_macros.yaml")) as f:
|
||||
macros_doc = yaml.safe_load(f) or {}
|
||||
macros = (macros_doc.get("macros") or {}) if isinstance(macros_doc, dict) else {}
|
||||
whole_list, single_rule = _build_macro_indices(macros)
|
||||
|
||||
changed_files = 0
|
||||
pages_dir = os.path.join(src_dir, "pages")
|
||||
if os.path.isdir(pages_dir):
|
||||
for fn in sorted(os.listdir(pages_dir)):
|
||||
if not fn.endswith((".yaml", ".yml")) or fn.startswith("_"):
|
||||
continue
|
||||
path = os.path.join(pages_dir, fn)
|
||||
header, doc = _read_yaml_with_header(path)
|
||||
if _walk_page(doc, whole_list, single_rule):
|
||||
changed_files += 1
|
||||
if not dry_run:
|
||||
_write_yaml_with_header(path, header, doc)
|
||||
else:
|
||||
print(f"would-rewrite: {path}")
|
||||
|
||||
print(f"{'Would rewrite' if dry_run else 'Rewrote'} {changed_files} files")
|
||||
return 0
|
||||
|
||||
|
||||
def _main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Substitute inlined rule blocks with $ref macros across pages/.")
|
||||
parser.add_argument("--src", default=DEFAULT_SRC)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
return apply(args.src, args.dry_run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
300
sunnypilot/sunnylink/tools/compile_settings_ui.py
Executable file
300
sunnypilot/sunnylink/tools/compile_settings_ui.py
Executable file
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
|
||||
Reads settings_ui_src/ (the dev-friendly authoring tree) and emits the
|
||||
canonical settings_ui.json that the device generator + frontend consume.
|
||||
|
||||
Source layout:
|
||||
_macros.yaml # named rule fragments
|
||||
pages/<page_id>.yaml # one file per panel/page
|
||||
pages/vehicle.yaml # special: emits vehicle_settings
|
||||
|
||||
Each page.yaml contains the full panel: metadata + sections + items + sub_panels
|
||||
inline. Sub-panels are nested inside the section they belong to. Items appear
|
||||
in the order written in the file.
|
||||
|
||||
Macro references use JSON-Schema-style $ref pointers:
|
||||
enablement:
|
||||
- {$ref: "#/macros/offroad"}
|
||||
- {$ref: "#/macros/mads_full_platforms"}
|
||||
|
||||
Macros may reference other macros (max depth 3). Cycles raise an error.
|
||||
|
||||
Usage:
|
||||
python compile_settings_ui.py [--src DIR] [--out PATH] [--check]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_SRC = os.path.join(DIR, "settings_ui_src")
|
||||
DEFAULT_OUT = os.path.join(DIR, "settings_ui.json")
|
||||
|
||||
SCHEMA_VERSION = "1.0"
|
||||
MAX_MACRO_DEPTH = 3
|
||||
|
||||
|
||||
class CompileError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _load_yaml(path: str):
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
|
||||
def _is_ref(node) -> bool:
|
||||
return isinstance(node, dict) and len(node) == 1 and "$ref" in node
|
||||
|
||||
|
||||
def _ref_name(node: dict) -> str:
|
||||
ref = node["$ref"]
|
||||
if not isinstance(ref, str) or not ref.startswith("#/macros/"):
|
||||
raise CompileError(f"unsupported $ref: {ref!r} (must start with '#/macros/')")
|
||||
return ref[len("#/macros/"):]
|
||||
|
||||
|
||||
def _resolve_refs(node, macros: dict, visiting: tuple[str, ...] = ()):
|
||||
"""Resolve $ref nodes against macros. Recursive; depth-limited; cycle-safe.
|
||||
|
||||
- $ref in list-context: macro's list spliced into parent list.
|
||||
- $ref in scalar-context (object value): macro's value substitutes.
|
||||
- Non-list macro values may not be referenced from list contexts.
|
||||
"""
|
||||
if _is_ref(node):
|
||||
name = _ref_name(node)
|
||||
if name in visiting:
|
||||
raise CompileError(f"$ref cycle: {' -> '.join(visiting + (name,))}")
|
||||
if len(visiting) >= MAX_MACRO_DEPTH:
|
||||
raise CompileError(f"$ref nesting exceeds depth {MAX_MACRO_DEPTH}: {' -> '.join(visiting + (name,))}")
|
||||
if name not in macros:
|
||||
raise CompileError(f"unknown macro: {name}")
|
||||
return _resolve_refs(copy.deepcopy(macros[name]), macros, visiting + (name,))
|
||||
|
||||
if isinstance(node, dict):
|
||||
return {k: _resolve_refs(v, macros, visiting) for k, v in node.items()}
|
||||
|
||||
if isinstance(node, list):
|
||||
out = []
|
||||
for item in node:
|
||||
if _is_ref(item):
|
||||
resolved = _resolve_refs(item, macros, visiting)
|
||||
if not isinstance(resolved, list):
|
||||
raise CompileError(
|
||||
f"macro '{_ref_name(item)}' must resolve to a list when used in a list context"
|
||||
)
|
||||
out.extend(resolved)
|
||||
else:
|
||||
out.append(_resolve_refs(item, macros, visiting))
|
||||
return out
|
||||
|
||||
return node
|
||||
|
||||
|
||||
# Output JSON key order. Mirrors the conventions in the original hand-written
|
||||
# settings_ui.json so structural diffs after extraction are minimal.
|
||||
_ITEM_KEY_ORDER = [
|
||||
"key",
|
||||
"widget",
|
||||
"needs_onroad_cycle",
|
||||
"requires_attestation",
|
||||
"blocked",
|
||||
"title",
|
||||
"description",
|
||||
"details",
|
||||
"title_param_suffix",
|
||||
"min",
|
||||
"max",
|
||||
"step",
|
||||
"unit",
|
||||
"options",
|
||||
"visibility",
|
||||
"enablement",
|
||||
"sub_items",
|
||||
]
|
||||
|
||||
|
||||
def _canon_item(item: dict, macros: dict) -> dict:
|
||||
resolved = dict(item)
|
||||
for ctx in ("visibility", "enablement"):
|
||||
if ctx in resolved:
|
||||
resolved[ctx] = _resolve_refs(resolved[ctx], macros)
|
||||
if "options" in resolved:
|
||||
new_opts = []
|
||||
for opt in resolved["options"]:
|
||||
if isinstance(opt, dict):
|
||||
opt = dict(opt)
|
||||
for ctx in ("visibility", "enablement"):
|
||||
if ctx in opt:
|
||||
opt[ctx] = _resolve_refs(opt[ctx], macros)
|
||||
new_opts.append(opt)
|
||||
resolved["options"] = new_opts
|
||||
if "sub_items" in resolved:
|
||||
resolved["sub_items"] = [_canon_item(s, macros) for s in resolved["sub_items"]]
|
||||
|
||||
out: dict = {}
|
||||
for k in _ITEM_KEY_ORDER:
|
||||
if k in resolved:
|
||||
out[k] = resolved[k]
|
||||
for k, v in resolved.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _canon_section(section: dict, macros: dict) -> dict:
|
||||
out: dict = {"id": section["id"], "title": section["title"]}
|
||||
if "description" in section:
|
||||
out["description"] = section["description"]
|
||||
for k in ("visibility", "enablement"):
|
||||
if k in section:
|
||||
out[k] = _resolve_refs(section[k], macros)
|
||||
if "attestation_required" in section:
|
||||
out["attestation_required"] = section["attestation_required"]
|
||||
out["items"] = [_canon_item(i, macros) for i in section.get("items", [])]
|
||||
if "sub_panels" in section and section["sub_panels"]:
|
||||
out["sub_panels"] = [_canon_sub_panel(sp, macros) for sp in section["sub_panels"]]
|
||||
for k, v in section.items():
|
||||
if k not in out and k != "order":
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _canon_sub_panel(sp: dict, macros: dict) -> dict:
|
||||
out: dict = {"id": sp["id"], "label": sp["label"], "trigger_key": sp["trigger_key"]}
|
||||
if "trigger_condition" in sp:
|
||||
out["trigger_condition"] = _resolve_refs(sp["trigger_condition"], macros)
|
||||
out["items"] = [_canon_item(i, macros) for i in sp.get("items", [])]
|
||||
for k, v in sp.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _canon_panel(page: dict, macros: dict) -> dict:
|
||||
out: dict = {
|
||||
"id": page["id"],
|
||||
"label": page["label"],
|
||||
"icon": page["icon"],
|
||||
"order": page["order"],
|
||||
}
|
||||
if "remote_configurable" in page:
|
||||
out["remote_configurable"] = page["remote_configurable"]
|
||||
if "description" in page:
|
||||
out["description"] = page["description"]
|
||||
if "sections" in page and page["sections"]:
|
||||
out["sections"] = [_canon_section(s, macros) for s in page["sections"]]
|
||||
if "items" in page and page["items"]:
|
||||
out["items"] = [_canon_item(i, macros) for i in page["items"]]
|
||||
if "sub_panels" in page and page["sub_panels"]:
|
||||
out["sub_panels"] = [_canon_sub_panel(sp, macros) for sp in page["sub_panels"]]
|
||||
return out
|
||||
|
||||
|
||||
def _canon_vehicle(page: dict, macros: dict) -> dict:
|
||||
"""Convert page-shape vehicle.yaml to wire-format vehicle_settings dict."""
|
||||
out: dict = {}
|
||||
for sec in page.get("sections", []):
|
||||
brand = sec["id"]
|
||||
brand_out: dict = {"title": sec.get("title", "")}
|
||||
if "description" in sec:
|
||||
brand_out["description"] = sec["description"]
|
||||
brand_out["items"] = [_canon_item(i, macros) for i in sec.get("items", [])]
|
||||
out[brand] = brand_out
|
||||
return out
|
||||
|
||||
|
||||
def _load_pages(src: str) -> list[dict]:
|
||||
pages_dir = os.path.join(src, "pages")
|
||||
if not os.path.isdir(pages_dir):
|
||||
return []
|
||||
pages = []
|
||||
for fn in sorted(os.listdir(pages_dir)):
|
||||
if not fn.endswith((".yaml", ".yml")):
|
||||
continue
|
||||
if fn.startswith("_"):
|
||||
continue
|
||||
path = os.path.join(pages_dir, fn)
|
||||
page = _load_yaml(path)
|
||||
if not isinstance(page, dict):
|
||||
raise CompileError(f"{path}: page YAML must be an object")
|
||||
if "id" not in page:
|
||||
raise CompileError(f"{path}: page missing 'id'")
|
||||
page["__source"] = path
|
||||
pages.append(page)
|
||||
return pages
|
||||
|
||||
|
||||
def compile_schema(src: str) -> dict:
|
||||
macros_doc = _load_yaml(os.path.join(src, "_macros.yaml"))
|
||||
macros = (macros_doc.get("macros") or {}) if isinstance(macros_doc, dict) else {}
|
||||
|
||||
pages = _load_pages(src)
|
||||
|
||||
panels_out = []
|
||||
vehicle_out: dict = {}
|
||||
|
||||
# Order panels by `order` field (falling back to file position).
|
||||
panel_pages = [p for p in pages if p.get("kind") != "vehicle"]
|
||||
panel_pages.sort(key=lambda p: (p.get("order", 999), p["id"]))
|
||||
|
||||
for page in panel_pages:
|
||||
panels_out.append(_canon_panel(page, macros))
|
||||
|
||||
for page in pages:
|
||||
if page.get("kind") == "vehicle":
|
||||
vehicle_out = _canon_vehicle(page, macros)
|
||||
|
||||
return {
|
||||
"$schema": "./settings_ui.schema.json",
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"panels": panels_out,
|
||||
"vehicle_settings": vehicle_out,
|
||||
}
|
||||
|
||||
|
||||
def _main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Compile settings_ui_src/ -> settings_ui.json")
|
||||
parser.add_argument("--src", default=DEFAULT_SRC)
|
||||
parser.add_argument("--out", default=DEFAULT_OUT)
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="Compile and diff against existing settings_ui.json; exit non-zero on diff.")
|
||||
args = parser.parse_args()
|
||||
|
||||
schema = compile_schema(args.src)
|
||||
rendered = json.dumps(schema, indent=2) + "\n"
|
||||
|
||||
if args.check:
|
||||
if not os.path.exists(args.out):
|
||||
print(f"--check: {args.out} does not exist", file=sys.stderr)
|
||||
return 1
|
||||
with open(args.out) as f:
|
||||
current = f.read()
|
||||
if current.strip() == rendered.strip():
|
||||
print(f"--check: {args.out} matches compiled output")
|
||||
return 0
|
||||
print(f"--check: {args.out} differs from compiled output", file=sys.stderr)
|
||||
cur_obj = json.loads(current)
|
||||
if cur_obj == schema:
|
||||
print("(structurally equal; only formatting differs)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with open(args.out, "w") as f:
|
||||
f.write(rendered)
|
||||
print(f"Wrote {args.out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
214
sunnypilot/sunnylink/tools/extract_settings_ui.py
Executable file
214
sunnypilot/sunnylink/tools/extract_settings_ui.py
Executable file
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
|
||||
One-shot extractor: settings_ui.json -> settings_ui_src/pages/*.yaml.
|
||||
|
||||
Reads the existing monolithic settings_ui.json and produces one YAML file per
|
||||
page (panel) plus pages/vehicle.yaml for the per-brand settings. Macros remain
|
||||
unwritten by extract; populate _macros.yaml separately.
|
||||
|
||||
Usage:
|
||||
python extract_settings_ui.py [--src DIR] [--definition PATH]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_DEFINITION = os.path.join(DIR, "settings_ui.json")
|
||||
DEFAULT_SRC = os.path.join(DIR, "settings_ui_src")
|
||||
|
||||
|
||||
class _BlockDumper(yaml.SafeDumper):
|
||||
pass
|
||||
|
||||
|
||||
def _represent_dict(dumper, data):
|
||||
return dumper.represent_mapping("tag:yaml.org,2002:map", data.items(), flow_style=False)
|
||||
|
||||
|
||||
def _represent_list(dumper, data):
|
||||
flow = all(not isinstance(x, (dict, list)) for x in data) and len(data) <= 8
|
||||
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=flow)
|
||||
|
||||
|
||||
_BlockDumper.add_representer(dict, _represent_dict)
|
||||
_BlockDumper.add_representer(list, _represent_list)
|
||||
|
||||
|
||||
def _dump_yaml(data) -> str:
|
||||
return yaml.dump(data, Dumper=_BlockDumper, sort_keys=False, allow_unicode=True, width=120)
|
||||
|
||||
|
||||
# Item field order in per-page YAML (mirrors settings_ui.json conventions).
|
||||
_ITEM_ORDER = [
|
||||
"key",
|
||||
"widget",
|
||||
"needs_onroad_cycle",
|
||||
"requires_attestation",
|
||||
"blocked",
|
||||
"title",
|
||||
"description",
|
||||
"details",
|
||||
"title_param_suffix",
|
||||
"min",
|
||||
"max",
|
||||
"step",
|
||||
"unit",
|
||||
"options",
|
||||
"visibility",
|
||||
"enablement",
|
||||
"sub_items",
|
||||
]
|
||||
|
||||
|
||||
def _ordered_item(item: dict) -> dict:
|
||||
out: dict = {}
|
||||
for k in _ITEM_ORDER:
|
||||
if k in item:
|
||||
v = item[k]
|
||||
if k == "sub_items":
|
||||
v = [_ordered_item(s) for s in v]
|
||||
out[k] = v
|
||||
for k, v in item.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _ordered_section(section: dict) -> dict:
|
||||
out: dict = {"id": section["id"], "title": section["title"]}
|
||||
for f in ("description", "order", "visibility", "enablement", "attestation_required"):
|
||||
if f in section:
|
||||
out[f] = section[f]
|
||||
if "items" in section:
|
||||
out["items"] = [_ordered_item(i) for i in section["items"]]
|
||||
if "sub_panels" in section and section["sub_panels"]:
|
||||
out["sub_panels"] = [_ordered_sub_panel(sp) for sp in section["sub_panels"]]
|
||||
for k, v in section.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _ordered_sub_panel(sp: dict) -> dict:
|
||||
out: dict = {"id": sp["id"], "label": sp["label"], "trigger_key": sp["trigger_key"]}
|
||||
if "trigger_condition" in sp:
|
||||
out["trigger_condition"] = sp["trigger_condition"]
|
||||
if "items" in sp:
|
||||
out["items"] = [_ordered_item(i) for i in sp["items"]]
|
||||
for k, v in sp.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _ordered_page(panel: dict) -> dict:
|
||||
out: dict = {
|
||||
"id": panel["id"],
|
||||
"label": panel["label"],
|
||||
"icon": panel["icon"],
|
||||
"order": panel["order"],
|
||||
}
|
||||
for f in ("remote_configurable", "description"):
|
||||
if f in panel:
|
||||
out[f] = panel[f]
|
||||
if "sections" in panel and panel["sections"]:
|
||||
out["sections"] = [_ordered_section(s) for s in panel["sections"]]
|
||||
if "items" in panel and panel["items"]:
|
||||
out["items"] = [_ordered_item(i) for i in panel["items"]]
|
||||
if "sub_panels" in panel and panel["sub_panels"]:
|
||||
out["sub_panels"] = [_ordered_sub_panel(sp) for sp in panel["sub_panels"]]
|
||||
for k, v in panel.items():
|
||||
if k not in out:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _ordered_vehicle_page(vehicle_settings: dict) -> dict:
|
||||
"""Convert wire-format vehicle_settings dict into a page-shape YAML.
|
||||
|
||||
Brand becomes a section id. Each brand's items become section items.
|
||||
"""
|
||||
sections = []
|
||||
for brand in sorted(vehicle_settings.keys()):
|
||||
bd = vehicle_settings[brand]
|
||||
items = bd.get("items", []) if isinstance(bd, dict) else bd
|
||||
sec: dict = {
|
||||
"id": brand,
|
||||
"title": bd.get("title", "") if isinstance(bd, dict) else "",
|
||||
}
|
||||
if isinstance(bd, dict) and "description" in bd:
|
||||
sec["description"] = bd["description"]
|
||||
sec["items"] = [_ordered_item(i) for i in items]
|
||||
sections.append(sec)
|
||||
return {
|
||||
"id": "vehicle",
|
||||
"label": "Vehicle",
|
||||
"icon": "vehicle",
|
||||
"order": 99,
|
||||
"kind": "vehicle", # signals to compiler: emit as vehicle_settings, not panels
|
||||
"sections": sections,
|
||||
}
|
||||
|
||||
|
||||
def extract(definition_path: str, src_dir: str) -> None:
|
||||
with open(definition_path) as f:
|
||||
data = json.load(f)
|
||||
|
||||
pages_dir = os.path.join(src_dir, "pages")
|
||||
if os.path.isdir(pages_dir):
|
||||
shutil.rmtree(pages_dir)
|
||||
os.makedirs(pages_dir)
|
||||
|
||||
count = 0
|
||||
for panel in data.get("panels", []):
|
||||
page = _ordered_page(panel)
|
||||
path = os.path.join(pages_dir, f"{panel['id']}.yaml")
|
||||
with open(path, "w") as f:
|
||||
f.write(f"# Page: {panel['id']}\n")
|
||||
f.write("# Edit this file. Run compile_settings_ui.py to emit settings_ui.json.\n")
|
||||
f.write(_dump_yaml(page))
|
||||
count += 1
|
||||
|
||||
vehicle_settings = data.get("vehicle_settings", {})
|
||||
if vehicle_settings:
|
||||
vehicle_page = _ordered_vehicle_page(vehicle_settings)
|
||||
path = os.path.join(pages_dir, "vehicle.yaml")
|
||||
with open(path, "w") as f:
|
||||
f.write("# Page: vehicle (per-brand settings)\n")
|
||||
f.write("# Compiles to settings_ui.json#vehicle_settings (brand = section id).\n")
|
||||
f.write(_dump_yaml(vehicle_page))
|
||||
count += 1
|
||||
|
||||
# Ensure _macros.yaml exists
|
||||
macros_path = os.path.join(src_dir, "_macros.yaml")
|
||||
if not os.path.exists(macros_path):
|
||||
with open(macros_path, "w") as f:
|
||||
f.write("# Named rule fragments. Reference from pages via {$ref: \"#/macros/<name>\"}.\n")
|
||||
f.write(_dump_yaml({"macros": {}}))
|
||||
|
||||
print(f"Extracted: {count} pages -> {pages_dir}")
|
||||
|
||||
|
||||
def _main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Extract settings_ui.json into per-page YAML files.")
|
||||
parser.add_argument("--definition", default=DEFAULT_DEFINITION, help="path to settings_ui.json")
|
||||
parser.add_argument("--src", default=DEFAULT_SRC, help="path to settings_ui_src/ output dir")
|
||||
args = parser.parse_args()
|
||||
extract(args.definition, args.src)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
194
sunnypilot/sunnylink/tools/generate_settings_schema.py
Executable file
194
sunnypilot/sunnylink/tools/generate_settings_schema.py
Executable file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
|
||||
from openpilot.sunnypilot.sunnylink.capabilities import CAPABILITY_FIELDS, CAPABILITY_LABELS
|
||||
|
||||
SCHEMA_VERSION = "1.0"
|
||||
_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFINITION_PATH = os.path.join(_DIR, "settings_ui.json")
|
||||
TORQUE_VERSIONS_PATH = os.path.normpath(
|
||||
os.path.join(_DIR, "..", "selfdrive", "controls", "lib", "latcontrol_torque_versions.json")
|
||||
)
|
||||
|
||||
|
||||
def _load_torque_versions() -> dict:
|
||||
"""Load latcontrol_torque_versions.json so TorqueControlTune options stay in sync."""
|
||||
try:
|
||||
with open(TORQUE_VERSIONS_PATH) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _build_torque_options(versions: dict) -> list[dict]:
|
||||
options: list[dict] = [{"value": "", "label": "Default"}]
|
||||
parsed: list[tuple[float, str]] = []
|
||||
for label, info in versions.items():
|
||||
try:
|
||||
parsed.append((float(info["version"]), label))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
for version, label in sorted(parsed, key=lambda kv: kv[0], reverse=True):
|
||||
options.append({"value": version, "label": label})
|
||||
return options
|
||||
|
||||
|
||||
def _inject_dynamic_options(schema: dict) -> None:
|
||||
versions = _load_torque_versions()
|
||||
if not versions:
|
||||
return
|
||||
options = _build_torque_options(versions)
|
||||
|
||||
def visitor(item: dict) -> None:
|
||||
if item.get("key") == "TorqueControlTune":
|
||||
item["options"] = options
|
||||
|
||||
_walk_all_items(schema, visitor)
|
||||
|
||||
|
||||
def _load_definition() -> dict:
|
||||
"""Load settings_ui.json and inject dynamic options sourced from runtime data files."""
|
||||
with open(DEFINITION_PATH) as f:
|
||||
schema = json.load(f)
|
||||
_inject_dynamic_options(schema)
|
||||
return schema
|
||||
|
||||
|
||||
# Public API
|
||||
def generate_schema() -> dict:
|
||||
"""Return the settings_ui.json content augmented with runtime metadata.
|
||||
|
||||
Adds three top-level fields the frontend consumes:
|
||||
- generated_at: ISO timestamp (drives schema-cache freshness checks)
|
||||
- capability_fields: declared CAPABILITY_FIELDS, used for rule validation
|
||||
- capability_labels: human-readable labels for capability_fields
|
||||
"""
|
||||
schema = _load_definition()
|
||||
schema["generated_at"] = datetime.datetime.now(datetime.UTC).isoformat()
|
||||
schema["capability_fields"] = list(CAPABILITY_FIELDS)
|
||||
schema["capability_labels"] = dict(CAPABILITY_LABELS)
|
||||
return schema
|
||||
|
||||
|
||||
def generate_schema_json() -> str:
|
||||
"""Generate SettingsSchema as a compact JSON string."""
|
||||
return json.dumps(generate_schema(), separators=(",", ":"))
|
||||
|
||||
|
||||
def generate_schema_compressed() -> str:
|
||||
"""Generate SettingsSchema as gzip-compressed, base64-encoded string.
|
||||
|
||||
Compression pipeline:
|
||||
1. JSON serialize (compact, no whitespace)
|
||||
2. UTF-8 encode
|
||||
3. gzip compress
|
||||
4. base64 encode
|
||||
"""
|
||||
raw = json.dumps(generate_schema(), separators=(",", ":")).encode("utf-8")
|
||||
return base64.b64encode(gzip.compress(raw)).decode("utf-8")
|
||||
|
||||
|
||||
# Schema introspection utilities
|
||||
def _walk_rules(rules: list[dict] | None, visitor: Callable[[dict], None]) -> None:
|
||||
"""Recursively walk all rules, calling visitor on each leaf rule."""
|
||||
if not rules:
|
||||
return
|
||||
for rule in rules:
|
||||
visitor(rule)
|
||||
if rule.get("type") == "not" and "condition" in rule:
|
||||
_walk_rules([rule["condition"]], visitor)
|
||||
elif rule.get("type") in ("any", "all") and "conditions" in rule:
|
||||
_walk_rules(rule["conditions"], visitor)
|
||||
|
||||
|
||||
def _walk_all_items(schema: dict, visitor: Callable[[dict], None]) -> None:
|
||||
"""Walk every item in the schema (panels, sections, sub_panels, sub_items, vehicle_settings)."""
|
||||
def _visit_item(item: dict) -> None:
|
||||
visitor(item)
|
||||
for sub in item.get("sub_items", []):
|
||||
_visit_item(sub)
|
||||
|
||||
for panel in schema.get("panels", []):
|
||||
# Walk section items (V2)
|
||||
for section in panel.get("sections", []):
|
||||
for item in section.get("items", []):
|
||||
_visit_item(item)
|
||||
for sp in section.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
_visit_item(item)
|
||||
|
||||
# Walk flat items (V1)
|
||||
for item in panel.get("items", []):
|
||||
_visit_item(item)
|
||||
for sp in panel.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
_visit_item(item)
|
||||
|
||||
for brand_data in schema.get("vehicle_settings", {}).values():
|
||||
items = brand_data.get("items", []) if isinstance(brand_data, dict) else brand_data
|
||||
for item in items:
|
||||
_visit_item(item)
|
||||
|
||||
|
||||
def collect_all_keys(schema: dict) -> set[str]:
|
||||
"""Collect all param keys referenced in the schema (items + rules)."""
|
||||
keys: set[str] = set()
|
||||
|
||||
def _visit_rule(rule: dict) -> None:
|
||||
if rule.get("type") in ("param", "param_compare") and "key" in rule:
|
||||
keys.add(rule["key"])
|
||||
|
||||
def _visit_item(item: dict) -> None:
|
||||
if "key" in item:
|
||||
keys.add(item["key"])
|
||||
_walk_rules(item.get("visibility"), _visit_rule)
|
||||
_walk_rules(item.get("enablement"), _visit_rule)
|
||||
|
||||
_walk_all_items(schema, _visit_item)
|
||||
return keys
|
||||
|
||||
|
||||
def collect_capability_refs(schema: dict) -> set[str]:
|
||||
"""Collect all capability field names referenced in rules."""
|
||||
refs: set[str] = set()
|
||||
|
||||
def _visit_rule(rule: dict) -> None:
|
||||
if rule.get("type") == "capability" and "field" in rule:
|
||||
refs.add(rule["field"])
|
||||
|
||||
def _visit_item(item: dict) -> None:
|
||||
_walk_rules(item.get("visibility"), _visit_rule)
|
||||
_walk_rules(item.get("enablement"), _visit_rule)
|
||||
|
||||
_walk_all_items(schema, _visit_item)
|
||||
return refs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# CLI: print schema for inspection
|
||||
schema = generate_schema()
|
||||
print(json.dumps(schema, indent=2))
|
||||
print(f"\nTotal panels: {len(schema.get('panels', []))}")
|
||||
print(f"Total vehicle brands: {len(schema.get('vehicle_settings', {}))}")
|
||||
keys = collect_all_keys(schema)
|
||||
print(f"Total unique param keys: {len(keys)}")
|
||||
|
||||
# Show compression stats
|
||||
raw_json = json.dumps(schema, separators=(",", ":")).encode("utf-8")
|
||||
compressed = gzip.compress(raw_json)
|
||||
print(f"\nRaw JSON size: {len(raw_json):,} bytes")
|
||||
print(f"Compressed size: {len(compressed):,} bytes")
|
||||
print(f"Compression ratio: {len(compressed)/len(raw_json):.1%}")
|
||||
526
sunnypilot/sunnylink/tools/validate_settings_ui.py
Executable file
526
sunnypilot/sunnylink/tools/validate_settings_ui.py
Executable file
@@ -0,0 +1,526 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
|
||||
Validates settings_ui.json against structural, semantic, and referential integrity constraints.
|
||||
|
||||
Usage:
|
||||
python validate_settings_ui.py
|
||||
python validate_settings_ui.py /path/to/settings_ui.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..'))
|
||||
|
||||
from openpilot.sunnypilot.sunnylink.capabilities import CAPABILITY_FIELDS
|
||||
|
||||
VALID_WIDGETS = {"toggle", "option", "multiple_button", "button", "info"}
|
||||
VALID_COMPARE_OPS = {">", "<", ">=", "<="}
|
||||
|
||||
DEFAULT_PATH = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"settings_ui.json",
|
||||
)
|
||||
|
||||
|
||||
class ValidationResult:
|
||||
"""Tracks pass/fail for each named check."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.passed: list[str] = []
|
||||
self.failed: list[tuple[str, str]] = []
|
||||
self.warnings: list[str] = []
|
||||
|
||||
def ok(self, name: str) -> None:
|
||||
self.passed.append(name)
|
||||
print(f"OK: {name}")
|
||||
|
||||
def error(self, name: str, details: str) -> None:
|
||||
self.failed.append((name, details))
|
||||
print(f"ERROR: {name}: {details}")
|
||||
|
||||
def warn(self, msg: str) -> None:
|
||||
self.warnings.append(msg)
|
||||
print(f"WARNING: {msg}")
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return len(self.failed) == 0
|
||||
|
||||
def summary(self) -> None:
|
||||
total_passed = len(self.passed)
|
||||
total_failed = len(self.failed)
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Summary: {total_passed} checks passed, {total_failed} checks failed")
|
||||
if self.warnings:
|
||||
print(f" {len(self.warnings)} warnings")
|
||||
if self.success:
|
||||
print("Result: PASS")
|
||||
else:
|
||||
print("Result: FAIL")
|
||||
|
||||
|
||||
def validate_rule(rule: dict, path: str, result: ValidationResult,
|
||||
capability_fields: tuple[str, ...]) -> bool:
|
||||
"""Validate a single rule dict. Returns True if valid."""
|
||||
if not isinstance(rule, dict):
|
||||
result.error("rule well-formedness", f"{path}: rule is not a dict: {rule!r}")
|
||||
return False
|
||||
|
||||
rule_type = rule.get("type")
|
||||
if rule_type is None:
|
||||
result.error("rule well-formedness", f"{path}: rule missing 'type' field")
|
||||
return False
|
||||
|
||||
if rule_type in ("offroad_only", "not_engaged"):
|
||||
# Only type required
|
||||
return True
|
||||
|
||||
if rule_type == "capability":
|
||||
valid = True
|
||||
if "field" not in rule or not isinstance(rule["field"], str):
|
||||
result.error("rule well-formedness", f"{path}: capability rule missing/invalid 'field'")
|
||||
valid = False
|
||||
if "equals" not in rule:
|
||||
result.error("rule well-formedness", f"{path}: capability rule missing 'equals'")
|
||||
valid = False
|
||||
return valid
|
||||
|
||||
if rule_type == "param":
|
||||
valid = True
|
||||
if "key" not in rule or not isinstance(rule["key"], str):
|
||||
result.error("rule well-formedness", f"{path}: param rule missing/invalid 'key'")
|
||||
valid = False
|
||||
if "equals" not in rule:
|
||||
result.error("rule well-formedness", f"{path}: param rule missing 'equals'")
|
||||
valid = False
|
||||
return valid
|
||||
|
||||
if rule_type == "param_compare":
|
||||
valid = True
|
||||
if "key" not in rule or not isinstance(rule["key"], str):
|
||||
result.error("rule well-formedness", f"{path}: param_compare rule missing/invalid 'key'")
|
||||
valid = False
|
||||
if "op" not in rule or rule["op"] not in VALID_COMPARE_OPS:
|
||||
result.error("rule well-formedness",
|
||||
f"{path}: param_compare rule missing/invalid 'op' (must be one of {VALID_COMPARE_OPS})")
|
||||
valid = False
|
||||
if "value" not in rule or not isinstance(rule["value"], (int, float)):
|
||||
result.error("rule well-formedness", f"{path}: param_compare rule missing/invalid 'value' (must be number)")
|
||||
valid = False
|
||||
return valid
|
||||
|
||||
if rule_type == "not":
|
||||
if "condition" not in rule or not isinstance(rule["condition"], dict):
|
||||
result.error("rule well-formedness", f"{path}: 'not' rule missing/invalid 'condition'")
|
||||
return False
|
||||
return validate_rule(rule["condition"], f"{path}.not", result, capability_fields)
|
||||
|
||||
if rule_type in ("any", "all"):
|
||||
if "conditions" not in rule or not isinstance(rule["conditions"], list):
|
||||
result.error("rule well-formedness", f"{path}: '{rule_type}' rule missing/invalid 'conditions' array")
|
||||
return False
|
||||
valid = True
|
||||
for i, cond in enumerate(rule["conditions"]):
|
||||
if not validate_rule(cond, f"{path}.{rule_type}[{i}]", result, capability_fields):
|
||||
valid = False
|
||||
return valid
|
||||
|
||||
result.error("rule well-formedness", f"{path}: unknown rule type '{rule_type}'")
|
||||
return False
|
||||
|
||||
|
||||
def collect_rules_from_item(item: dict) -> list[tuple[str, list[dict]]]:
|
||||
"""Return list of (context, rules_list) for an item's visibility + enablement."""
|
||||
result = []
|
||||
key = item.get("key", "?")
|
||||
if "visibility" in item:
|
||||
result.append((f"item '{key}' visibility", item["visibility"]))
|
||||
if "enablement" in item:
|
||||
result.append((f"item '{key}' enablement", item["enablement"]))
|
||||
return result
|
||||
|
||||
|
||||
def walk_rules_flat(rules: list[dict]) -> list[dict]:
|
||||
"""Flatten all rules recursively into a single list."""
|
||||
flat: list[dict] = []
|
||||
for rule in rules:
|
||||
flat.append(rule)
|
||||
if rule.get("type") == "not" and "condition" in rule:
|
||||
flat.extend(walk_rules_flat([rule["condition"]]))
|
||||
elif rule.get("type") in ("any", "all") and "conditions" in rule:
|
||||
flat.extend(walk_rules_flat(rule["conditions"]))
|
||||
return flat
|
||||
|
||||
|
||||
def collect_all_items(data: dict) -> list[tuple[str, dict]]:
|
||||
"""Collect all items with their location path from the schema.
|
||||
|
||||
Returns (path, item_dict) tuples. Traverses sections, sub_panels, sub_items,
|
||||
and vehicle_settings.
|
||||
"""
|
||||
items: list[tuple[str, dict]] = []
|
||||
|
||||
for panel in data.get("panels", []):
|
||||
pid = panel.get("id", "?")
|
||||
|
||||
# Flat items on panel
|
||||
for item in panel.get("items", []):
|
||||
_collect_item(f"panel '{pid}'", item, items)
|
||||
|
||||
# Sections
|
||||
for section in panel.get("sections", []):
|
||||
sid = section.get("id", "?")
|
||||
for item in section.get("items", []):
|
||||
_collect_item(f"panel '{pid}' > section '{sid}'", item, items)
|
||||
for sp in section.get("sub_panels", []):
|
||||
spid = sp.get("id", "?")
|
||||
for item in sp.get("items", []):
|
||||
_collect_item(f"panel '{pid}' > section '{sid}' > sub_panel '{spid}'", item, items)
|
||||
|
||||
# Top-level sub_panels on panel (no section)
|
||||
for sp in panel.get("sub_panels", []):
|
||||
spid = sp.get("id", "?")
|
||||
for item in sp.get("items", []):
|
||||
_collect_item(f"panel '{pid}' > sub_panel '{spid}'", item, items)
|
||||
|
||||
# Vehicle settings (supports both flat list and { title, items } structure)
|
||||
for brand, brand_data in data.get("vehicle_settings", {}).items():
|
||||
brand_items = brand_data.get("items", []) if isinstance(brand_data, dict) else brand_data
|
||||
for item in brand_items:
|
||||
_collect_item(f"vehicle_settings '{brand}'", item, items)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _collect_item(path: str, item: dict, items: list[tuple[str, dict]]) -> None:
|
||||
"""Recursively collect an item and its sub_items."""
|
||||
items.append((path, item))
|
||||
for sub in item.get("sub_items", []):
|
||||
_collect_item(f"{path} > sub_item", sub, items)
|
||||
|
||||
|
||||
def collect_panel_keys(panel: dict) -> set[str]:
|
||||
"""Collect all item keys within a single panel (sections, sub_panels, sub_items)."""
|
||||
keys: set[str] = set()
|
||||
|
||||
def _add(item: dict) -> None:
|
||||
if "key" in item:
|
||||
keys.add(item["key"])
|
||||
for sub in item.get("sub_items", []):
|
||||
_add(sub)
|
||||
|
||||
for item in panel.get("items", []):
|
||||
_add(item)
|
||||
for section in panel.get("sections", []):
|
||||
for item in section.get("items", []):
|
||||
_add(item)
|
||||
for sp in section.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
_add(item)
|
||||
for sp in panel.get("sub_panels", []):
|
||||
for item in sp.get("items", []):
|
||||
_add(item)
|
||||
|
||||
return keys
|
||||
|
||||
|
||||
def check_json_parseable(path: str, result: ValidationResult) -> dict | None:
|
||||
"""Check 1: JSON parseable."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
result.ok("JSON parseable")
|
||||
return data
|
||||
except json.JSONDecodeError as e:
|
||||
result.error("JSON parseable", str(e))
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
result.error("JSON parseable", f"file not found: {path}")
|
||||
return None
|
||||
|
||||
|
||||
def check_structural(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 2: Required fields on panels, sections, items, sub_panels."""
|
||||
errors: list[str] = []
|
||||
|
||||
for i, panel in enumerate(data.get("panels", [])):
|
||||
for field in ("id", "label", "icon", "order"):
|
||||
if field not in panel:
|
||||
errors.append(f"panels[{i}]: missing required field '{field}'")
|
||||
|
||||
for j, section in enumerate(panel.get("sections", [])):
|
||||
for field in ("id", "title"):
|
||||
if field not in section:
|
||||
errors.append(f"panels[{i}].sections[{j}]: missing required field '{field}'")
|
||||
|
||||
for k, sp in enumerate(section.get("sub_panels", [])):
|
||||
for field in ("id", "label", "trigger_key"):
|
||||
if field not in sp:
|
||||
errors.append(f"panels[{i}].sections[{j}].sub_panels[{k}]: missing required field '{field}'")
|
||||
|
||||
for k, sp in enumerate(panel.get("sub_panels", [])):
|
||||
for field in ("id", "label", "trigger_key"):
|
||||
if field not in sp:
|
||||
errors.append(f"panels[{i}].sub_panels[{k}]: missing required field '{field}'")
|
||||
|
||||
# Validate items
|
||||
all_items = collect_all_items(data)
|
||||
for path, item in all_items:
|
||||
if "key" not in item:
|
||||
errors.append(f"{path}: item missing required field 'key'")
|
||||
if "widget" not in item:
|
||||
errors.append(f"{path}: item missing required field 'widget'")
|
||||
elif item["widget"] not in VALID_WIDGETS:
|
||||
errors.append(
|
||||
f"{path}: item '{item.get('key', '?')}' has invalid widget '{item['widget']}'"
|
||||
+ f" (must be one of {VALID_WIDGETS})"
|
||||
)
|
||||
|
||||
if errors:
|
||||
result.error("structural", "; ".join(errors))
|
||||
else:
|
||||
result.ok("structural")
|
||||
|
||||
|
||||
def check_item_completeness(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 3: All items have required metadata (title, options for dropdowns)."""
|
||||
all_items = collect_all_items(data)
|
||||
issues: list[str] = []
|
||||
|
||||
for _path, item in all_items:
|
||||
key = item.get("key", "unknown")
|
||||
if "title" not in item:
|
||||
issues.append(f"{key}: missing 'title'")
|
||||
elif item["title"] == key:
|
||||
issues.append(f"{key}: title must not equal key (use a human-readable title)")
|
||||
widget = item.get("widget")
|
||||
if widget in ("multiple_button", "option") and "options" in item:
|
||||
opts = item["options"]
|
||||
if not isinstance(opts, list):
|
||||
issues.append(f"{key}: options must be a list")
|
||||
else:
|
||||
for opt in opts:
|
||||
if not isinstance(opt, dict) or "value" not in opt or "label" not in opt:
|
||||
issues.append(f"{key}: each option must have 'value' and 'label'")
|
||||
break
|
||||
|
||||
if issues:
|
||||
for issue in issues:
|
||||
result.error("item completeness", issue)
|
||||
else:
|
||||
result.ok("item completeness")
|
||||
|
||||
|
||||
def check_no_duplicate_keys(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 4: No param key appears in more than one panel."""
|
||||
panel_keys: dict[str, list[str]] = {} # key -> list of panel ids
|
||||
|
||||
for panel in data.get("panels", []):
|
||||
pid = panel.get("id", "?")
|
||||
keys = collect_panel_keys(panel)
|
||||
for key in keys:
|
||||
panel_keys.setdefault(key, []).append(pid)
|
||||
|
||||
# Also check vehicle_settings keys don't collide with panel keys
|
||||
for brand, brand_data in data.get("vehicle_settings", {}).items():
|
||||
brand_items = brand_data.get("items", []) if isinstance(brand_data, dict) else brand_data
|
||||
for item in brand_items:
|
||||
key = item.get("key")
|
||||
if key:
|
||||
panel_keys.setdefault(key, []).append(f"vehicle_settings.{brand}")
|
||||
|
||||
duplicates = {k: v for k, v in panel_keys.items() if len(v) > 1}
|
||||
if duplicates:
|
||||
details = "; ".join(f"'{k}' in [{', '.join(v)}]" for k, v in duplicates.items())
|
||||
result.error("no duplicate keys", details)
|
||||
else:
|
||||
result.ok("no duplicate keys")
|
||||
|
||||
|
||||
def check_rule_wellformedness(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 5: All rules have valid structure."""
|
||||
all_items = collect_all_items(data)
|
||||
|
||||
# Save current error count to detect new errors
|
||||
error_count_before = len(result.failed)
|
||||
|
||||
for path, item in all_items:
|
||||
for ctx, rules in collect_rules_from_item(item):
|
||||
for i, rule in enumerate(rules):
|
||||
validate_rule(rule, f"{path} > {ctx}[{i}]", result, CAPABILITY_FIELDS)
|
||||
|
||||
# Also validate trigger_condition rules on sub_panels
|
||||
for panel in data.get("panels", []):
|
||||
pid = panel.get("id", "?")
|
||||
for section in panel.get("sections", []):
|
||||
for sp in section.get("sub_panels", []):
|
||||
if "trigger_condition" in sp:
|
||||
validate_rule(sp["trigger_condition"], f"panel '{pid}' > sub_panel '{sp.get('id', '?')}' trigger_condition",
|
||||
result, CAPABILITY_FIELDS)
|
||||
|
||||
if len(result.failed) == error_count_before:
|
||||
result.ok("rule well-formedness")
|
||||
|
||||
|
||||
def check_capability_refs(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 6: All capability rule field values are in CAPABILITY_FIELDS."""
|
||||
all_items = collect_all_items(data)
|
||||
invalid_refs: list[str] = []
|
||||
cap_set = set(CAPABILITY_FIELDS)
|
||||
|
||||
for _path, item in all_items:
|
||||
for _ctx, rules in collect_rules_from_item(item):
|
||||
for rule in walk_rules_flat(rules):
|
||||
if rule.get("type") == "capability":
|
||||
field = rule.get("field")
|
||||
if field and field not in cap_set:
|
||||
invalid_refs.append(f"'{field}' in item '{item.get('key', '?')}'")
|
||||
|
||||
# Also check trigger_conditions
|
||||
for panel in data.get("panels", []):
|
||||
for section in panel.get("sections", []):
|
||||
for sp in section.get("sub_panels", []):
|
||||
if "trigger_condition" in sp:
|
||||
for rule in walk_rules_flat([sp["trigger_condition"]]):
|
||||
if rule.get("type") == "capability":
|
||||
field = rule.get("field")
|
||||
if field and field not in cap_set:
|
||||
invalid_refs.append(f"'{field}' in sub_panel '{sp.get('id', '?')}' trigger_condition")
|
||||
|
||||
if invalid_refs:
|
||||
result.error("capability refs", f"unknown capability fields: {', '.join(invalid_refs)}")
|
||||
else:
|
||||
result.ok("capability refs")
|
||||
|
||||
|
||||
def check_no_self_reference(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 7: Item's rules must not reference the item's own key."""
|
||||
all_items = collect_all_items(data)
|
||||
self_refs: list[str] = []
|
||||
|
||||
for path, item in all_items:
|
||||
key = item.get("key")
|
||||
if not key:
|
||||
continue
|
||||
for _ctx, rules in collect_rules_from_item(item):
|
||||
for rule in walk_rules_flat(rules):
|
||||
if rule.get("type") in ("param", "param_compare") and rule.get("key") == key:
|
||||
self_refs.append(f"'{key}' at {path}")
|
||||
|
||||
if self_refs:
|
||||
result.error("no self-reference", f"items reference their own key: {', '.join(self_refs)}")
|
||||
else:
|
||||
result.ok("no self-reference")
|
||||
|
||||
|
||||
def check_sub_panel_triggers(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 8: Sub-panel trigger_key must reference a key in the same panel."""
|
||||
errors: list[str] = []
|
||||
|
||||
for panel in data.get("panels", []):
|
||||
pid = panel.get("id", "?")
|
||||
panel_keys = collect_panel_keys(panel)
|
||||
|
||||
# Check sub_panels at section level
|
||||
for section in panel.get("sections", []):
|
||||
for sp in section.get("sub_panels", []):
|
||||
trigger = sp.get("trigger_key")
|
||||
if trigger and trigger not in panel_keys:
|
||||
errors.append(
|
||||
f"sub_panel '{sp.get('id', '?')}' trigger_key '{trigger}'"
|
||||
+ f" not found in panel '{pid}'"
|
||||
)
|
||||
|
||||
# Check top-level sub_panels
|
||||
for sp in panel.get("sub_panels", []):
|
||||
trigger = sp.get("trigger_key")
|
||||
if trigger and trigger not in panel_keys:
|
||||
errors.append(
|
||||
f"sub_panel '{sp.get('id', '?')}' trigger_key '{trigger}'"
|
||||
+ f" not found in panel '{pid}'"
|
||||
)
|
||||
|
||||
if errors:
|
||||
result.error("sub-panel triggers", "; ".join(errors))
|
||||
else:
|
||||
result.ok("sub-panel triggers")
|
||||
|
||||
|
||||
def check_ordering(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 9: Panel order values must be unique."""
|
||||
orders: dict[int, list[str]] = {}
|
||||
for panel in data.get("panels", []):
|
||||
order = panel.get("order")
|
||||
if order is not None:
|
||||
orders.setdefault(order, []).append(panel.get("id", "?"))
|
||||
|
||||
duplicates = {o: ids for o, ids in orders.items() if len(ids) > 1}
|
||||
if duplicates:
|
||||
details = "; ".join(f"order {o}: [{', '.join(ids)}]" for o, ids in duplicates.items())
|
||||
result.error("ordering", f"duplicate order values: {details}")
|
||||
else:
|
||||
result.ok("ordering")
|
||||
|
||||
|
||||
def check_vehicle_brands(data: dict, result: ValidationResult) -> None:
|
||||
"""Check 10: Vehicle settings keys lowercase + each brand has consistent {title, description, items} shape."""
|
||||
vehicle = data.get("vehicle_settings", {})
|
||||
errors: list[str] = []
|
||||
|
||||
for brand, brand_data in vehicle.items():
|
||||
if not isinstance(brand, str) or brand != brand.lower():
|
||||
errors.append(f"non-lowercase brand key: '{brand}'")
|
||||
if not isinstance(brand_data, dict):
|
||||
errors.append(f"brand '{brand}': expected object with {{title, description, items}}, got bare list")
|
||||
continue
|
||||
if "items" not in brand_data:
|
||||
errors.append(f"brand '{brand}': missing 'items'")
|
||||
if "title" not in brand_data:
|
||||
errors.append(f"brand '{brand}': missing 'title' (use empty string for none)")
|
||||
|
||||
if errors:
|
||||
result.error("vehicle brands", "; ".join(errors))
|
||||
else:
|
||||
result.ok("vehicle brands")
|
||||
|
||||
|
||||
def validate(path: str) -> bool:
|
||||
"""Run all validation checks on the given settings_ui.json file.
|
||||
|
||||
Returns True if all checks pass.
|
||||
"""
|
||||
result = ValidationResult()
|
||||
|
||||
# Check 1: JSON parseable
|
||||
data = check_json_parseable(path, result)
|
||||
if data is None:
|
||||
result.summary()
|
||||
return False
|
||||
|
||||
# Checks 2-10
|
||||
check_structural(data, result)
|
||||
check_item_completeness(data, result)
|
||||
check_no_duplicate_keys(data, result)
|
||||
check_rule_wellformedness(data, result)
|
||||
check_capability_refs(data, result)
|
||||
check_no_self_reference(data, result)
|
||||
check_sub_panel_triggers(data, result)
|
||||
check_ordering(data, result)
|
||||
check_vehicle_brands(data, result)
|
||||
|
||||
result.summary()
|
||||
return result.success
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
target = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_PATH
|
||||
success = validate(target)
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -4,7 +4,10 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import json
|
||||
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.sunnypilot.selfdrive.car.sync_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)
|
||||
|
||||
@@ -35,8 +35,8 @@ def manager_init() -> None:
|
||||
params.clear_all(ParamKeyFlag.CLEAR_ON_ONROAD_TRANSITION)
|
||||
params.clear_all(ParamKeyFlag.CLEAR_ON_OFFROAD_TRANSITION)
|
||||
params.clear_all(ParamKeyFlag.CLEAR_ON_IGNITION_ON)
|
||||
if build_metadata.release_channel:
|
||||
params.clear_all(ParamKeyFlag.DEVELOPMENT_ONLY)
|
||||
# if build_metadata.release_channel:
|
||||
# params.clear_all(ParamKeyFlag.DEVELOPMENT_ONLY)
|
||||
|
||||
# device boot mode
|
||||
if params.get("DeviceBootMode") == 1: # start in Always Offroad mode
|
||||
|
||||
@@ -363,7 +363,7 @@ def simple_button_item_sp(button_text: str | Callable[[], str], callback: Callab
|
||||
def toggle_item_sp(title: str | Callable[[], str], description: str | Callable[[], str] | None = None, initial_state: bool = False,
|
||||
callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True, param: str | None = None) -> ListItemSP:
|
||||
action = ToggleActionSP(initial_state=initial_state, enabled=enabled, callback=callback, param=param)
|
||||
return ListItemSP(title=title, description=description, action_item=action, icon=icon, callback=callback)
|
||||
return ListItemSP(title=title, description=description, action_item=action, icon=icon)
|
||||
|
||||
|
||||
def multiple_button_item_sp(title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], str]],
|
||||
|
||||
@@ -57,3 +57,7 @@ class ToggleSP(Toggle):
|
||||
knob_y = self._rect.y + style.TOGGLE_BG_HEIGHT / 2
|
||||
|
||||
rl.draw_circle(int(knob_x), int(knob_y), KNOB_RADIUS, knob_color)
|
||||
|
||||
clicked = self._clicked
|
||||
self._clicked = False
|
||||
return clicked
|
||||
|
||||
343
uv.lock
generated
343
uv.lock
generated
@@ -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#346fa1e479d7324d446f32b2cbe2913897372745" }
|
||||
|
||||
[[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#b4fd14982cbff568be0e021f55c0ef90c29da934" }
|
||||
|
||||
[[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]]
|
||||
@@ -251,26 +251,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.5"
|
||||
version = "7.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -291,41 +291,41 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.7"
|
||||
version = "48.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/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
|
||||
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/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
|
||||
]
|
||||
|
||||
[[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#807295810045d0709f2647ea979ca0bf132f6036" }
|
||||
|
||||
[[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#9198cc2d678678b82b50c68c19208b42198291ef" }
|
||||
|
||||
[[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#a56d64dc1ccec55beb025216cbf798ba24c5d9c5" }
|
||||
|
||||
[[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#dcf637af942bac74898642f2e28389eb30a9e66e" }
|
||||
|
||||
[[package]]
|
||||
name = "google-crc32c"
|
||||
@@ -476,11 +476,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.14"
|
||||
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/05/b1/efac073e0c297ecf2fb33c346989a529d4e19164f1759102dee5953ee17e/idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3", size = 198272, upload-time = "2026-05-10T20:32:15.935Z" }
|
||||
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/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" },
|
||||
]
|
||||
|
||||
[[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#80fe56cf6faa1403103b23c36315c2cc0b3608f3" }
|
||||
|
||||
[[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#61e60dfe431b927cdb5631b43b765294c2b2f7ad" }
|
||||
|
||||
[[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#952e85e35f0402fc6657a4d8697e2abf3c3e82ef" }
|
||||
|
||||
[[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#a6eb8499285016302dfadca3f9df96737c72ee45" }
|
||||
|
||||
[[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#f674840e4f5480a57b7f4eec89ab4b0b8ae295d0" }
|
||||
|
||||
[[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]]
|
||||
@@ -978,26 +978,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "propcache"
|
||||
version = "0.4.1"
|
||||
version = "0.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1133,15 +1135,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyopenssl"
|
||||
version = "26.0.0"
|
||||
version = "26.2.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/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" }
|
||||
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/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1344,27 +1346,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]]
|
||||
@@ -1378,15 +1380,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "2.58.0"
|
||||
version = "2.59.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/b3/fb8291170d0e844173164709fc0fa0c221ed75a5da740c8746f2a83b4eb1/sentry_sdk-2.58.0.tar.gz", hash = "sha256:c1144d947352d54e5b7daa63596d9f848adf684989c06c4f5a659f0c85a18f6f", size = 438764, upload-time = "2026-04-13T17:23:26.265Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/e0/9bf5e5fc7442b10880f3ec0eff0ef4208b84a099606f343ec4f5445227fb/sentry_sdk-2.59.0.tar.gz", hash = "sha256:cd265808ef8bf3f3edf69b527c0a0b2b6b1322762679e55b8987db2e9584aec1", size = 447331, upload-time = "2026-05-04T12:19:06.538Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/eb/d875669993b762556ae8b2efd86219943b4c0864d22204d622a9aee3052b/sentry_sdk-2.58.0-py2.py3-none-any.whl", hash = "sha256:688d1c704ddecf382ea3326f21a67453d4caa95592d722b7c780a36a9d23109e", size = 460919, upload-time = "2026-04-13T17:23:24.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/00/b8cc413748fb6383d1582e7cda51314f99743351c462a92dc690d5b5853b/sentry_sdk-2.59.0-py2.py3-none-any.whl", hash = "sha256:abcf65ee9a9d9cdebf9ad369782408ecca9c1c792686ef06ba34f5ab233527fe", size = 468432, upload-time = "2026-05-04T12:19:04.741Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1468,6 +1470,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 +1502,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.31"
|
||||
version = "0.0.35"
|
||||
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/4e/53/440e7b1212c4b0abbd4adb7aed93f4971aa1f8dca386ac5515930afa9172/ty-0.0.35.tar.gz", hash = "sha256:8375c240ab38138a19db07996c9808fb7a92047c1492e1ce587c2ef5112ad3a9", size = 5629237, upload-time = "2026-05-10T18:25:17.105Z" }
|
||||
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/d4/84/19662ee881675815b7fafff940a365be1985730465afd9b75cb2edd5f8b3/ty-0.0.35-py3-none-linux_armv6l.whl", hash = "sha256:85ae1e59b9fb0b40e9d84fe61b29653c5f2f5e78b487ece371a7a38c20c781cf", size = 11198741, upload-time = "2026-05-10T18:24:49.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/df/7e5b6f83d85b4d2e5b72b5dceb388f440acc10679417bd46f829b9200fab/ty-0.0.35-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:709dbb7af4fcadb1196863c00b8791bbbbcc9dacbe15a0ff17f0af82b35d415b", size = 10948304, upload-time = "2026-05-10T18:24:58.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/94/72d7263aca055cde427f0ebcf08d6a74e5a5fee1d1e7fdd553696089cecb/ty-0.0.35-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2cb0877419ab0c8708b6925cb0c2800b263842bd3c425113f200538772f3a0cc", size = 10407413, upload-time = "2026-05-10T18:24:37.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/23/fda6fae8a81ce0cb5f24cdfe63260e110c7af8844e31fa07d1e6e8ef0232/ty-0.0.35-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7afbcfc61904b7e82e7fe1a1db832a40d8f01e69dee1775f6594e552980536c", size = 10932614, upload-time = "2026-05-10T18:24:47.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/3d/b98d8d4aa1a5ed6daaf15864e838f605ca7b1e8b93b7e17b96ed4bc4dfed/ty-0.0.35-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b61498cc3e4178031c079951257fbdb209a891b4feb10ad6c40f615a51846f41", size = 10962982, upload-time = "2026-05-10T18:24:44.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/c4/2881aad71bf6fb2f8df17fc8e4bc89e904e54490a3ee747b5ef73f98ac85/ty-0.0.35-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:573b1eacda349fc8dba0d767b41631c3a6f66412363127c5bf2b1b40a1d898d2", size = 11476274, upload-time = "2026-05-10T18:24:42.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/0f/7717650adaeaddd23eea70470e2c26d3f0b9b18fdc7f26ec9552d6001f17/ty-0.0.35-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a7209746158d6393c1040aa64b3ca29622e212ea7d8bae22ba50dbcbb4f96f0a", size = 12012027, upload-time = "2026-05-10T18:25:00.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/c9/1a16cb4aab6f4707d8f550772e91abc26d1c8870f19b5e2453ad10bb8209/ty-0.0.35-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4466a1470aa4418d49a9aa45d9da7de42033addd0a2837c5b2b0eb71d3c2bcd3", size = 11648894, upload-time = "2026-05-10T18:25:12.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/a1/a977c0e07e9f88db9c67f90c6342a4dc4422c8091fa07bf26521870687c5/ty-0.0.35-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb44bb742d52c309dcaa6598bcf4d82eb4bf1241b9e4940461e522e30093fe8b", size = 11560482, upload-time = "2026-05-10T18:25:05.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/c1/a5fb11227d5cc4ac3f29a115d8c8bc817578e8ef6907d1e4c914ddbf45ee/ty-0.0.35-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:34b219250736c989b2670a03782c61315f523f3a2be37f1f90b1207e2212c188", size = 11718495, upload-time = "2026-05-10T18:24:54.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/cb/e92e4317388b6d1fd821a46941b448a8a1ff0bf13e22147c5167d8fa1b00/ty-0.0.35-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:88e2ac497decc0940ef1a07571dee8a746112a93a09cdc7f8bca0099752e2e05", size = 10900815, upload-time = "2026-05-10T18:25:02.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/4f/03bd87388a92567f262f35ac64e10d2be047d258f2dfcf1405f500fa2b90/ty-0.0.35-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:02cae51b53e6ec17d5d827ff1a3a76fd119705b56a92156e04399eda6e911596", size = 10998051, upload-time = "2026-05-10T18:25:14.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/60/6edbc375ee6073973200096168f644e1081e5e55a7d42596826465b275de/ty-0.0.35-py3-none-musllinux_1_2_i686.whl", hash = "sha256:11871d730c9400d899ac0b9f3d660ed2e7e433377c8725549f8250a36a7f2620", size = 11148910, upload-time = "2026-05-10T18:24:51.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/b1/a845d2066ed521c477450f436d4bd353d107e7c02dd6536a485944aaf892/ty-0.0.35-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ad0a2f0530d0933dcc99ad36ac556c63e384ea72ab9a18d23ad2e2c9fd61c73", size = 11671005, upload-time = "2026-05-10T18:24:56.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/81/1d5912a54fb66b2f95ac828ae61d422ef5afeae1263e4d231e40796c229f/ty-0.0.35-py3-none-win32.whl", hash = "sha256:0e25d63ec4ab116e7f6757e44d16ca9216bca679d19ecc36d119cf80faada61a", size = 10481096, upload-time = "2026-05-10T18:24:39.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/36/1c7f8632bfec1c321f01581d4c940a3617b24bd3e8b37c8a7363d33fbfc4/ty-0.0.35-py3-none-win_amd64.whl", hash = "sha256:6a0a6d259f6f2f8f2f954c6f013d4e0b5eba68af6b353bf19a47d59ec254a3d5", size = 11555691, upload-time = "2026-05-10T18:25:07.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/fb/59325221bce52f6e833d6865ce8360ef7d5e1e21151b38df6dc77c4327a7/ty-0.0.35-py3-none-win_arm64.whl", hash = "sha256:619c52c0fb2aa21961a848a1995135ad3b6d0a9aa54da0194e60f679cc200e13", size = 10925457, upload-time = "2026-05-10T18:25:10.352Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1515,11 +1536,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1583,36 +1604,38 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "zensical"
|
||||
version = "0.0.33"
|
||||
version = "0.0.41"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "deepmerge" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "markdown" },
|
||||
{ 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/89/d6/b3e931233e53a2377ef5915cc6e786845c3263306874a469af8fb569ef9c/zensical-0.0.41.tar.gz", hash = "sha256:6c3c90301123749dfc26a210d6c080f0691253c7c765ad308a10b4518369a6fe", size = 3927788, upload-time = "2026-05-09T14:35:29.005Z" }
|
||||
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/72/08/ee18207c9b4e3ada74a0f4adf253bea90da39ae43772761cd91072e3a1fc/zensical-0.0.41-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f06a0015dcfdf7aeca73f4998a401db65db0ae2dd72da9629a7be8f9a4d0b7b6", size = 12701539, upload-time = "2026-05-09T14:34:48.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/93/d4635fbbce8171cf71dd64285d9f6d5773a2b624b928f1dd8acaf1ee9f9f/zensical-0.0.41-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:4e524ce68c9ff082ffaded9f742407097cf51bab692b7bc18d3c174b966174fe", size = 12560038, upload-time = "2026-05-09T14:34:51.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/4a/1730a30377bbb0914ed740e0e289d379b0552673b6cf912aefe7a205440c/zensical-0.0.41-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4afe35331cd2394c408cd362458936479cc0ed4fb272478498e4794aafc7414", size = 12942926, upload-time = "2026-05-09T14:34:54.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/e3/d9a0416ef4edc043ce9f404a66f1934f102bcb645b103abb26b180ba5680/zensical-0.0.41-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15a850285050f03aeb3b67ce7d99943093059fe8d32fc7731fa9f27be45c64cc", size = 12912711, upload-time = "2026-05-09T14:34:57.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/d0/775852783bef835425306a2fcd8236ef14fd19160e1b4261e192bf2d9f54/zensical-0.0.41-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35052e9dbefabe3a71c4836cfc4afa6c9469e5eeddc2a3ee750803ae3fe777dc", size = 13275869, upload-time = "2026-05-09T14:34:59.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/95/554273cc09a270ced0213d3e0aac8b3fc2b472fc2b26771d56fc8fd55047/zensical-0.0.41-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a47f459205fb55f64dcb6c65e9f3c2fa00a2b4306c5ef1b71b9a50c45007071d", size = 12980177, upload-time = "2026-05-09T14:35:02.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/b5/d74d5040b3121db5c72b0134f0455641b90b1277fb1330a8e5e0029ca8d3/zensical-0.0.41-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:aa3b3b3a4e6f75f6bb3c1aca1fad7a96cebf54cbd4e31122f6876503b8801666", size = 13119629, upload-time = "2026-05-09T14:35:07.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/9a/93527acd7750092d7fca2e6c43fe2b8f1e85e1c96a1002baf6a08201c6f7/zensical-0.0.41-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:565133fd48b2ce939698c174c0c1c6470407a8fb6a90a2bb0eeec97cd4344444", size = 13182183, upload-time = "2026-05-09T14:35:10.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/7e/d77e4c809bfcbad40db85a6a7beeda2ee5c964232e0186783c3a837a7d0b/zensical-0.0.41-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:cec0a2b05eaaace0c7424bab3f2884da03ade212cac4ba4487c58691ec13ec65", size = 13330444, upload-time = "2026-05-09T14:35:13.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e8/ecbb7e34bff88aa892c676b8b2e2ddf425f94d66cbb84b80016095191b77/zensical-0.0.41-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1736f0cb7686628cc6f53952d208423f20b542f0c16b0c2ddd7e702bf6e41fdd", size = 13263093, upload-time = "2026-05-09T14:35:20.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/6f/48b2f81ce708d19bb807d94716f2772ec4b74389b6d29024669fc470df08/zensical-0.0.41-cp310-abi3-win32.whl", hash = "sha256:34a78645c68fba152faacb66516c895283166154f8b15b61440a6c21c84f0974", size = 12253644, upload-time = "2026-05-09T14:35:23.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/92/5cf943133f61b996965743deeaff467f278135521f58d83ca68d2601ded3/zensical-0.0.41-cp310-abi3-win_amd64.whl", hash = "sha256:00d80cd573152e0efb655143bbdfe8788eb4b33167a802639fdb1b1800b724ac", size = 12483190, upload-time = "2026-05-09T14:35:26.43Z" },
|
||||
]
|
||||
|
||||
[[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#10f97237e00e5fabf3c1fa54a2ca1a1da39de461" }
|
||||
|
||||
[[package]]
|
||||
name = "zstandard"
|
||||
@@ -1642,4 +1665,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#eb147476324db97737c31cd63e71a4f44b0d0723" }
|
||||
|
||||
Reference in New Issue
Block a user