535 lines
20 KiB
Python
535 lines
20 KiB
Python
#!/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 <array>
|
|
#include <charconv>
|
|
#include <initializer_list>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <vector>
|
|
|
|
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<FrameMetadata, {len(frame_rows)}> kFrames{{{{
|
|
{',\n'.join(frame_rows)}
|
|
}}}};
|
|
|
|
inline constexpr std::array<RuleMetadata, {len(rule_rows)}> kRules{{{{
|
|
{',\n'.join(rule_rows)}
|
|
}}}};
|
|
|
|
inline constexpr std::array<StaticPayload, {len(payload_rows)}> 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<std::string_view> 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<std::string>& 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())
|