Files
github-actions[bot] f9fcc7adab sunnypilot v2026.002.000 release
date: 2026-06-28T09:48:35
master commit: da6313dbe95b3f24bb5d8018b0e5f950f5823ca7
2026-06-28 09:49:29 +08:00

195 lines
6.2 KiB
Python
Executable File

#!/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%}")