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

301 lines
9.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.
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())