Files
onepilot/sunnypilot/sunnylink/tools/validate_settings_ui.py
T
github-actions[bot] 6e2ccc8b15 sunnypilot v2026.002.000
version: sunnypilot v2026.002.000 (staging)
date: 2026-05-27T04:05:25
master commit: dfc3c98b226da57a653daf57131a8a3d66166fcb
2026-05-27 04:05:25 +00:00

527 lines
18 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.
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)