mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-08-05 13:25:40 +08:00
14318d2f09
# Conflicts: # .github/labeler.yaml # .github/workflows/auto_pr_review.yaml # .github/workflows/docs.yaml # .github/workflows/release.yaml # .github/workflows/repo-maintenance.yaml # .github/workflows/tests.yaml # .github/workflows/ui_preview.yaml # README.md # SConstruct # conftest.py # docs/CARS.md # msgq_repo # opendbc_repo # openpilot/cereal/messaging/tests/validate_sp_cereal_upstream.py # openpilot/common/api.py # openpilot/common/hardware/hw.py # openpilot/common/version.py # openpilot/selfdrive/assets/fonts/Audiowide-Regular.ttf # openpilot/selfdrive/assets/sounds/prompt_single_high.wav # openpilot/selfdrive/assets/sounds/prompt_single_low.wav # openpilot/selfdrive/car/card.py # openpilot/selfdrive/car/helpers.py # openpilot/selfdrive/car/tests/test_car_interfaces.py # openpilot/selfdrive/car/tests/test_cruise_speed.py # openpilot/selfdrive/controls/lib/desire_helper.py # openpilot/selfdrive/controls/lib/longcontrol.py # openpilot/selfdrive/controls/plannerd.py # openpilot/selfdrive/controls/radard.py # openpilot/selfdrive/controls/tests/test_longcontrol.py # openpilot/selfdrive/modeld/compile_warp.py # openpilot/selfdrive/modeld/modeld.py # openpilot/selfdrive/monitoring/policy.py # openpilot/selfdrive/monitoring/test_monitoring.py # openpilot/selfdrive/selfdrived/events.py # openpilot/selfdrive/selfdrived/selfdrived.py # openpilot/selfdrive/ui/layouts/onboarding.py # openpilot/selfdrive/ui/mici/layouts/onboarding.py # openpilot/selfdrive/ui/soundd.py # openpilot/selfdrive/ui/tests/diff/replay.py # openpilot/selfdrive/ui/translations/app.pot # openpilot/system/athena/athenad.py # openpilot/system/athena/manage_athenad.py # openpilot/system/hardware/hardwared.py # openpilot/system/loggerd/config.py # openpilot/system/manager/github_runner.sh # openpilot/system/manager/manager.py # openpilot/system/updated/updated.py # panda # pyproject.toml # scripts/lint/lint.sh # system/manager/process_config.py # system/statsd.py # tinygrad_repo # tools/release/build_release.sh # tools/release/build_stripped.sh # uv.lock
166 lines
5.3 KiB
Python
Executable File
166 lines
5.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate sunnypilot routes are parseable by stock commaai/openpilot.
|
|
|
|
Cap'n Proto is wire-compatible across renames, type relocations, and
|
|
additive fields. The only breaking change is a union variant that
|
|
upstream doesn't recognize — an unknown discriminant makes the entire
|
|
union unreadable.
|
|
|
|
This script checks: for every struct with a union that exists in both
|
|
schemas, does sunnypilot introduce union variants upstream doesn't have?
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from typing import Any
|
|
|
|
NO_DISCRIMINANT = 0xFFFF
|
|
|
|
|
|
def hex_id(value: int) -> str:
|
|
return f"0x{value:016x}"
|
|
|
|
|
|
def encode_struct(schema: Any) -> dict:
|
|
node = schema.node
|
|
fields = []
|
|
for name, field in schema.fields.items():
|
|
proto = field.proto
|
|
ordinal = proto.ordinal.explicit if proto.ordinal.which() == "explicit" else None
|
|
discriminant = proto.discriminantValue if proto.discriminantValue != NO_DISCRIMINANT else None
|
|
fields.append({"name": name, "ordinal": ordinal, "discriminant": discriminant})
|
|
return {
|
|
"typeId": hex_id(node.id),
|
|
"displayName": node.displayName,
|
|
"hasUnion": node.struct.discriminantCount > 0,
|
|
"fields": fields,
|
|
}
|
|
|
|
|
|
def _child_struct_schema(field: Any) -> Any:
|
|
proto = field.proto
|
|
if proto.which() == "group":
|
|
return field.schema
|
|
type_node = proto.slot.type
|
|
which = type_node.which()
|
|
if which == "struct":
|
|
return field.schema
|
|
if which == "list":
|
|
container = field.schema
|
|
element_type = type_node.list.elementType
|
|
while element_type.which() == "list":
|
|
container = container.elementType
|
|
element_type = element_type.list.elementType
|
|
if element_type.which() == "struct":
|
|
return container.elementType
|
|
return None
|
|
|
|
|
|
def collect_schema(root: Any) -> dict[str, dict]:
|
|
structs: dict[str, dict] = {}
|
|
stack = [root]
|
|
while stack:
|
|
schema = stack.pop()
|
|
type_id = hex_id(schema.node.id)
|
|
if type_id in structs:
|
|
continue
|
|
structs[type_id] = encode_struct(schema)
|
|
for _name, field in schema.fields.items():
|
|
try:
|
|
child = _child_struct_schema(field)
|
|
except Exception:
|
|
child = None
|
|
if child is not None:
|
|
stack.append(child)
|
|
return structs
|
|
|
|
|
|
def load_log(cereal_dir: str, extra_imports: list[str] | None = None) -> Any:
|
|
import capnp
|
|
cereal_dir = os.path.abspath(cereal_dir)
|
|
capnp.remove_import_hook()
|
|
imports = [cereal_dir] + [os.path.abspath(p) for p in (extra_imports or [])]
|
|
return capnp.load(os.path.join(cereal_dir, "log.capnp"), imports=imports)
|
|
|
|
|
|
def dump_schema(cereal_dir: str, path: str, extra_imports: list[str] | None = None) -> None:
|
|
log = load_log(cereal_dir, extra_imports)
|
|
payload = {
|
|
"root": hex_id(log.Event.schema.node.id),
|
|
"structs": collect_schema(log.Event.schema),
|
|
}
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
json.dump(payload, handle, indent=2, sort_keys=True)
|
|
print(f"wrote schema dump with {len(payload['structs'])} structs to {path}")
|
|
|
|
|
|
def compare(sunnypilot_dump: dict, upstream_dump: dict) -> list[str]:
|
|
violations: list[str] = []
|
|
sunnypilot_structs = sunnypilot_dump["structs"]
|
|
upstream_structs = upstream_dump["structs"]
|
|
|
|
for type_id, sp_struct in sunnypilot_structs.items():
|
|
if not sp_struct["hasUnion"]:
|
|
continue
|
|
up_struct = upstream_structs.get(type_id)
|
|
if up_struct is None:
|
|
continue
|
|
|
|
up_ordinals = {f["ordinal"] for f in up_struct["fields"] if f.get("discriminant") is not None}
|
|
display = sp_struct["displayName"]
|
|
|
|
for field in sp_struct["fields"]:
|
|
if field.get("discriminant") is None:
|
|
continue
|
|
if field["ordinal"] not in up_ordinals:
|
|
violations.append(
|
|
f"{display} @{field['ordinal']} '{field['name']}': "
|
|
f"union variant not present upstream (discriminant={field['discriminant']})"
|
|
)
|
|
|
|
return violations
|
|
|
|
|
|
def run_read(cereal_dir: str, peer_path: str, extra_imports: list[str] | None = None) -> int:
|
|
log = load_log(cereal_dir, extra_imports)
|
|
with open(peer_path, "r", encoding="utf-8") as f:
|
|
peer_dump = json.load(f)
|
|
local_dump = {
|
|
"root": hex_id(log.Event.schema.node.id),
|
|
"structs": collect_schema(log.Event.schema),
|
|
}
|
|
violations = compare(sunnypilot_dump=peer_dump, upstream_dump=local_dump)
|
|
|
|
if not violations:
|
|
print("cereal compat OK: upstream can parse sunnypilot routes.")
|
|
return 0
|
|
|
|
print(f"cereal compat FAIL ({len(violations)} leaked union variant(s)):")
|
|
for v in violations:
|
|
print(f" {v}")
|
|
return 1
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="sunnypilot cereal upstream compat check")
|
|
mode = parser.add_mutually_exclusive_group(required=True)
|
|
mode.add_argument("-g", "--generate", action="store_true", help="dump local schema to JSON")
|
|
mode.add_argument("-r", "--read", action="store_true", help="validate against peer schema")
|
|
parser.add_argument("-f", "--file", default="schema.json", help="JSON file path")
|
|
parser.add_argument("--cereal-dir", required=True, help="path to cereal directory")
|
|
parser.add_argument("-I", "--import-path", action="append", default=[], help="extra capnp import paths")
|
|
args = parser.parse_args()
|
|
|
|
if args.generate:
|
|
dump_schema(args.cereal_dir, args.file, args.import_path)
|
|
return 0
|
|
return run_read(args.cereal_dir, args.file, args.import_path)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|