#!/usr/bin/env python3 """Validate the shared CAN semantic protocol and generate Python/C++ views.""" from __future__ import annotations import argparse import hashlib import json import pprint import re import sys from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] DEFAULT_SOURCE = ROOT / "config" / "can_protocol" / "semantic_protocol.json" DEFAULT_PYTHON = ( ROOT / "src" / "cognitive_agent" / "generated" / "can_semantic_protocol.py" ) DEFAULT_CPP = ( ROOT / "tools" / "can_read_trans" / "generated" / "can_semantic_protocol.hpp" ) class ProtocolError(ValueError): pass def _payload_bytes(value: str, *, where: str, dlc: int) -> bytes: try: payload = bytes.fromhex(value) except ValueError as exc: raise ProtocolError(f"{where}: invalid payload hex") from exc if len(payload) != dlc: raise ProtocolError(f"{where}: payload has {len(payload)} bytes, expected {dlc}") return payload def _frame_id(value: Any, *, where: str) -> int: if not isinstance(value, str) or not re.fullmatch(r"0x[0-9A-Fa-f]{1,3}", value): raise ProtocolError(f"{where}: frame ID must be 0x000..0x7FF") parsed = int(value, 16) if not 0 <= parsed <= 0x7FF: raise ProtocolError(f"{where}: only standard 11-bit IDs are supported") return parsed def load_and_validate(path: Path) -> dict[str, Any]: try: document = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ProtocolError(f"cannot load {path}: {exc}") from exc if document.get("schema") != "cognitive-agent-can-semantic-protocol/v1": raise ProtocolError("unsupported or missing protocol schema") if not re.fullmatch(r"\d+\.\d+\.\d+", str(document.get("version", ""))): raise ProtocolError("version must be semantic x.y.z") rules = document.get("rules") if not isinstance(rules, list) or not rules: raise ProtocolError("rules must be a non-empty list") seen_rules: set[str] = set() capability_channels: dict[str, set[int]] = {} for rule_index, rule in enumerate(rules): where = f"rules[{rule_index}]" rule_id = rule.get("id") if not isinstance(rule_id, str) or not re.fullmatch(r"can[01]\.[a-z0-9_.]+", rule_id): raise ProtocolError(f"{where}: invalid id") if rule_id in seen_rules: raise ProtocolError(f"{where}: duplicate id {rule_id}") seen_rules.add(rule_id) channel = rule.get("channel") if channel not in {0, 1} or not rule_id.startswith(f"can{channel}."): raise ProtocolError(f"{where}: channel does not match rule id") if not isinstance(rule.get("domain"), str) or not re.fullmatch( r"[a-z][a-z0-9_]*", rule["domain"] ): raise ProtocolError(f"{where}: invalid domain") if not isinstance(rule.get("help"), str) or not rule["help"].strip(): raise ProtocolError(f"{where}: help must be non-empty") capabilities = rule.get("capabilities") if not isinstance(capabilities, list) or not capabilities or not all( isinstance(item, str) and "." in item for item in capabilities ): raise ProtocolError(f"{where}: capabilities must be non-empty names") for capability in capabilities: capability_channels.setdefault(capability, set()).add(channel) patterns = rule.get("patterns") if not isinstance(patterns, list) or not patterns: raise ProtocolError(f"{where}: patterns must be non-empty") for pattern_index, pattern in enumerate(patterns): pattern_where = f"{where}.patterns[{pattern_index}]" if not isinstance(pattern, list) or not pattern: raise ProtocolError(f"{pattern_where}: pattern must contain tokens") for token_index, token in enumerate(pattern): token_where = f"{pattern_where}[{token_index}]" if not isinstance(token, dict) or len(token) == 0: raise ProtocolError(f"{token_where}: token must be an object") kinds = {"literal", "enum", "integer"}.intersection(token) if len(kinds) != 1: raise ProtocolError(f"{token_where}: token needs one token kind") kind = next(iter(kinds)) if kind == "literal" and not isinstance(token["literal"], str): raise ProtocolError(f"{token_where}: literal must be text") if kind == "enum": values = token.get("values") if not isinstance(values, list) or not values or not all( isinstance(item, str) for item in values ): raise ProtocolError(f"{token_where}: enum values are invalid") if kind == "integer": minimum = token.get("minimum") maximum = token.get("maximum") if not isinstance(minimum, int) or not isinstance(maximum, int) or minimum > maximum: raise ProtocolError(f"{token_where}: integer range is invalid") frames = rule.get("frames") if not isinstance(frames, list) or not frames: raise ProtocolError(f"{where}: frames must be non-empty") seen_keys: set[str] = set() for frame_index, frame in enumerate(frames): frame_where = f"{where}.frames[{frame_index}]" key = frame.get("key") if not isinstance(key, str) or not key or key in seen_keys: raise ProtocolError(f"{frame_where}: frame key is invalid or duplicate") seen_keys.add(key) _frame_id(frame.get("frame_id"), where=frame_where) frame_format = frame.get("format") if frame_format not in {"classic_can", "can_fd"}: raise ProtocolError(f"{frame_where}: unsupported frame format") if bool(frame.get("brs")) and frame_format != "can_fd": raise ProtocolError(f"{frame_where}: BRS requires CAN FD") dlc = frame.get("dlc") if not isinstance(dlc, int) or not 1 <= dlc <= 64: raise ProtocolError(f"{frame_where}: invalid DLC") _payload_bytes(frame.get("base_payload_hex", ""), where=frame_where, dlc=dlc) if not isinstance(frame.get("template_kind"), str) or not frame[ "template_kind" ]: raise ProtocolError(f"{frame_where}: template_kind is required") variants = frame.get("payload_variants", {}) if not isinstance(variants, dict): raise ProtocolError(f"{frame_where}: payload_variants must be an object") for selector, payload in variants.items(): if not isinstance(selector, str): raise ProtocolError(f"{frame_where}: payload selector must be text") _payload_bytes(payload, where=f"{frame_where}.{selector}", dlc=dlc) repeat = frame.get("repeat") if not isinstance(repeat, dict) or repeat.get("kind") not in { "fixed", "special", "duration_ceiling", "sequence" }: raise ProtocolError(f"{frame_where}: invalid repeat policy") count = repeat.get("count") period_ms = repeat.get("period_ms") if not isinstance(count, int) or count < 0 or not isinstance(period_ms, int) or period_ms < 0: raise ProtocolError(f"{frame_where}: invalid repeat count/period") if repeat["kind"] in {"fixed", "sequence"} and count < 1: raise ProtocolError( f"{frame_where}: {repeat['kind']} repeat count must be positive" ) feedback_ids = frame.get("feedback_ids") if not isinstance(feedback_ids, list) or not feedback_ids: raise ProtocolError(f"{frame_where}: feedback_ids must be non-empty") for feedback_index, feedback_id in enumerate(feedback_ids): _frame_id(feedback_id, where=f"{frame_where}.feedback_ids[{feedback_index}]") document["_capability_channels"] = { key: sorted(value) for key, value in sorted(capability_channels.items()) } return document def _canonical_source(document: dict[str, Any]) -> bytes: source = {key: value for key, value in document.items() if not key.startswith("_")} return json.dumps(source, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") def render_python(document: dict[str, Any]) -> str: digest = hashlib.sha256(_canonical_source(document)).hexdigest() source = {key: value for key, value in document.items() if not key.startswith("_")} literal = pprint.pformat(source, width=100, sort_dicts=False) return f'''# Generated by tools/generate_can_semantic_protocol.py; do not edit. """Generated Python view of the shared CAN semantic protocol.""" from __future__ import annotations from typing import Any, Iterable PROTOCOL_SOURCE_SHA256 = "{digest}" PROTOCOL_MANIFEST: dict[str, Any] = {literal} PROTOCOL_VERSION = str(PROTOCOL_MANIFEST["version"]) RULES: tuple[dict[str, Any], ...] = tuple(PROTOCOL_MANIFEST["rules"]) RULE_BY_ID = {{str(rule["id"]): rule for rule in RULES}} CAN0_SUPPORTED_CAPABILITIES = frozenset( capability for rule in RULES if int(rule["channel"]) == 0 for capability in rule["capabilities"] ) CAN1_SUPPORTED_CAPABILITIES = frozenset( capability for rule in RULES if int(rule["channel"]) == 1 for capability in rule["capabilities"] ) def rule(rule_id: str) -> dict[str, Any]: try: return RULE_BY_ID[rule_id] except KeyError as exc: raise KeyError(f"unknown generated CAN protocol rule {{rule_id}}") from exc def frame(rule_id: str, key: str = "command") -> dict[str, Any]: for item in rule(rule_id)["frames"]: if item["key"] == key: return item raise KeyError(f"rule {{rule_id}} has no frame named {{key}}") def command_contract(rule_id: str, key: str = "command") -> dict[str, Any]: metadata = frame(rule_id, key) return {{ "source_version": PROTOCOL_VERSION, "rule_id": rule_id, "frame_key": key, "frame_id": metadata["frame_id"], "format": metadata["format"], "brs": bool(metadata["brs"]), "dlc": int(metadata["dlc"]), "repeat": dict(metadata["repeat"]), "feedback_ids": list(metadata["feedback_ids"]), "feedback_policy": str(metadata.get("feedback_policy", "")), }} def payload_variant(rule_id: str, selector: str, key: str = "command") -> bytes: item = frame(rule_id, key) try: value = item["payload_variants"][selector] except KeyError as exc: raise KeyError(f"rule {{rule_id}} frame {{key}} has no payload {{selector}}") from exc return bytes.fromhex(str(value)) def rules_for_capability(capability: str, channel: int | None = None) -> tuple[dict[str, Any], ...]: return tuple( item for item in RULES if capability in item["capabilities"] and (channel is None or int(item["channel"]) == channel) ) def _matches_token(word: str, token: dict[str, Any]) -> bool: if "literal" in token: return word == token["literal"] if "enum" in token: return word in token["values"] try: value = int(word, 10) except ValueError: return False return str(value) == word or (word.startswith("-") and str(value) == word) def _matches_integer(word: str, token: dict[str, Any]) -> bool: try: value = int(word, 10) except ValueError: return False return token["minimum"] <= value <= token["maximum"] and str(value) == word def match_semantic_command(words: str | Iterable[str], channel: int | None = None) -> dict[str, Any] | None: normalized = tuple( part.lower() for part in (words.split() if isinstance(words, str) else words) ) for item in RULES: if channel is not None and int(item["channel"]) != channel: continue for pattern in item["patterns"]: if len(pattern) != len(normalized): continue if all( _matches_integer(word, token) if "integer" in token else _matches_token(word, token) for word, token in zip(normalized, pattern) ): return item return None def whitelist_help(channel: int) -> tuple[str, ...]: return tuple(str(item["help"]) for item in RULES if int(item["channel"]) == channel) ''' def _cpp_escape(value: str) -> str: return value.replace("\\", "\\\\").replace('"', '\\"') def _cpp_condition(pattern: list[dict[str, Any]]) -> str: conditions = [f"words.size() == {len(pattern)}"] for index, token in enumerate(pattern): if "literal" in token: conditions.append(f'words[{index}] == "{_cpp_escape(token["literal"])}"') elif "enum" in token: values = ", ".join(f'"{_cpp_escape(value)}"' for value in token["values"]) conditions.append(f"protocol_contains(words[{index}], {{{values}}})") else: conditions.append( f"protocol_integer_in_range(words[{index}], {token['minimum']}, {token['maximum']})" ) return " &&\n ".join(conditions) def render_cpp(document: dict[str, Any]) -> str: digest = hashlib.sha256(_canonical_source(document)).hexdigest() rules = document["rules"] frames: list[tuple[str, dict[str, Any]]] = [] payloads: list[tuple[str, str, str, str]] = [] for rule in rules: for item in rule["frames"]: frames.append((rule["id"], item)) for selector, payload in item.get("payload_variants", {}).items(): payloads.append((rule["id"], item["key"], selector, payload)) frame_rows = [] for rule_id, item in frames: repeat = item["repeat"] feedback_csv = ",".join(item["feedback_ids"]) template_document = { key: item[key] for key in ( "base_payload_hex", "fields", "payload_variants", "optional_when", ) if key in item } template_json = json.dumps( template_document, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ) frame_rows.append( " FrameMetadata{" f'"{_cpp_escape(rule_id)}", "{_cpp_escape(item["key"])}", ' f'{int(item["frame_id"], 16)}, "{item["format"]}", ' f'{str(bool(item["brs"])).lower()}, {item["dlc"]}, ' f'"{_cpp_escape(item["template_kind"])}", ' f'"{_cpp_escape(item["base_payload_hex"])}", ' f'"{_cpp_escape(template_json)}", ' f'"{_cpp_escape(repeat["kind"])}", {repeat["count"]}, {repeat["period_ms"]}, ' f'"{_cpp_escape(feedback_csv)}", "{_cpp_escape(item.get("feedback_policy", ""))}"' "}" ) rule_rows = [] for rule in rules: capabilities = ",".join(rule["capabilities"]) rule_rows.append( " RuleMetadata{" f'"{_cpp_escape(rule["id"])}", {rule["channel"]}, ' f'"{_cpp_escape(rule["domain"])}", "{_cpp_escape(capabilities)}", ' f'"{_cpp_escape(rule["help"])}", "{_cpp_escape(rule.get("special_handler", ""))}"' "}" ) payload_rows = [ " StaticPayload{" f'"{_cpp_escape(rule_id)}", "{_cpp_escape(key)}", "{_cpp_escape(selector)}", ' f'"{_cpp_escape(payload)}"' "}" for rule_id, key, selector, payload in payloads ] match_blocks = [] for rule in rules: conditions = [f"({ _cpp_condition(pattern) })" for pattern in rule["patterns"]] joined = " ||\n ".join(conditions) match_blocks.append( f' if ((channel < 0 || channel == {rule["channel"]}) &&\n' f" ({joined}))\n" f' return find_rule("{_cpp_escape(rule["id"])}");' ) return f'''// Generated by tools/generate_can_semantic_protocol.py; do not edit. #pragma once #include #include #include #include #include #include #include namespace cognitive_agent_can_protocol {{ inline constexpr std::string_view kProtocolVersion = "{document["version"]}"; inline constexpr std::string_view kProtocolSourceSha256 = "{digest}"; struct FrameMetadata {{ std::string_view rule_id; std::string_view key; unsigned frame_id; std::string_view format; bool brs; unsigned dlc; std::string_view template_kind; std::string_view base_payload_hex; std::string_view template_json; std::string_view repeat_kind; unsigned repeat_count; unsigned period_ms; std::string_view feedback_ids_csv; std::string_view feedback_policy; }}; struct RuleMetadata {{ std::string_view id; int channel; std::string_view domain; std::string_view capabilities_csv; std::string_view help; std::string_view special_handler; }}; struct StaticPayload {{ std::string_view rule_id; std::string_view frame_key; std::string_view selector; std::string_view payload_hex; }}; inline constexpr std::array kFrames{{{{ {',\n'.join(frame_rows)} }}}}; inline constexpr std::array kRules{{{{ {',\n'.join(rule_rows)} }}}}; inline constexpr std::array kStaticPayloads{{{{ {',\n'.join(payload_rows)} }}}}; inline const RuleMetadata* find_rule(std::string_view id) {{ for (const auto& item : kRules) if (item.id == id) return &item; return nullptr; }} inline const FrameMetadata* find_frame(std::string_view rule_id, std::string_view key = "command") {{ for (const auto& item : kFrames) if (item.rule_id == rule_id && item.key == key) return &item; return nullptr; }} inline const StaticPayload* find_static_payload(std::string_view rule_id, std::string_view selector, std::string_view key = "command") {{ for (const auto& item : kStaticPayloads) if (item.rule_id == rule_id && item.frame_key == key && item.selector == selector) return &item; return nullptr; }} inline bool protocol_contains(std::string_view value, std::initializer_list allowed) {{ for (const auto candidate : allowed) if (value == candidate) return true; return false; }} inline bool protocol_integer_in_range(std::string_view text, int minimum, int maximum) {{ int value = 0; const auto result = std::from_chars(text.data(), text.data() + text.size(), value); return result.ec == std::errc{{}} && result.ptr == text.data() + text.size() && std::to_string(value) == text && value >= minimum && value <= maximum; }} inline const RuleMetadata* match_semantic_command( const std::vector& words, int channel = -1) {{ {chr(10).join(match_blocks)} return nullptr; }} }} // namespace cognitive_agent_can_protocol ''' def _write_or_check(path: Path, rendered: str, *, check: bool) -> bool: current = path.read_text(encoding="utf-8") if path.exists() else None if current == rendered: return True if check: print(f"generated file is stale: {path}", file=sys.stderr) return False path.parent.mkdir(parents=True, exist_ok=True) path.write_text(rendered, encoding="utf-8") print(f"generated {path.relative_to(ROOT)}") return True def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE) parser.add_argument("--python-output", type=Path, default=DEFAULT_PYTHON) parser.add_argument("--cpp-output", type=Path, default=DEFAULT_CPP) parser.add_argument("--check", action="store_true") args = parser.parse_args() try: document = load_and_validate(args.source) except ProtocolError as exc: print(f"CAN protocol error: {exc}", file=sys.stderr) return 2 ok_python = _write_or_check( args.python_output, render_python(document), check=args.check ) ok_cpp = _write_or_check(args.cpp_output, render_cpp(document), check=args.check) return 0 if ok_python and ok_cpp else 1 if __name__ == "__main__": raise SystemExit(main())