sunnypilot v2026.002.000
version: sunnypilot v2026.002.000 (staging) date: 2026-07-21T07:44:02 master commit: 2c334ede443d7391d27575af8c854a095ba702a8
This commit is contained in:
commit
de77c89cf6
3995 files changed
+1260688
No files matched your search
@@ -0,0 +1,165 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user