can-db-model 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- can_db_model-0.1.0/PKG-INFO +29 -0
- can_db_model-0.1.0/README.md +4 -0
- can_db_model-0.1.0/pyproject.toml +37 -0
- can_db_model-0.1.0/src/can_db_model/__about__.py +1 -0
- can_db_model-0.1.0/src/can_db_model/__init__.py +17 -0
- can_db_model-0.1.0/src/can_db_model/from_kcd.py +122 -0
- can_db_model-0.1.0/src/can_db_model/from_yaml.py +34 -0
- can_db_model-0.1.0/src/can_db_model/load.py +18 -0
- can_db_model-0.1.0/src/can_db_model/model.py +167 -0
- can_db_model-0.1.0/src/can_db_model/to_kcd.py +118 -0
- can_db_model-0.1.0/src/can_db_model/to_yaml.py +17 -0
- can_db_model-0.1.0/tests/__init__.py +0 -0
- can_db_model-0.1.0/tests/data/sample.kcd +14 -0
- can_db_model-0.1.0/tests/data/sample.yaml +11 -0
- can_db_model-0.1.0/tests/test_from_kcd.py +18 -0
- can_db_model-0.1.0/tests/test_from_yaml.py +33 -0
- can_db_model-0.1.0/tests/test_load.py +27 -0
- can_db_model-0.1.0/tests/test_model_candb.py +28 -0
- can_db_model-0.1.0/tests/test_model_doc.py +19 -0
- can_db_model-0.1.0/tests/test_model_message.py +26 -0
- can_db_model-0.1.0/tests/test_model_signal.py +30 -0
- can_db_model-0.1.0/tests/test_model_validation.py +56 -0
- can_db_model-0.1.0/tests/test_package.py +5 -0
- can_db_model-0.1.0/tests/test_to_kcd.py +13 -0
- can_db_model-0.1.0/tests/test_to_yaml.py +27 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: can-db-model
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared Pydantic model + KCD/YAML loaders for CAN databases
|
|
5
|
+
Project-URL: Documentation, https://sr.ht/~laplace/can-db-model#readme
|
|
6
|
+
Project-URL: Issues, https://sr.ht/~laplace/can-db-model/issues
|
|
7
|
+
Project-URL: Source, https://sr.ht/~laplace/can-db-model
|
|
8
|
+
Author-email: Alexander Krishna-Becker <nabla.becker@mailbox.org>
|
|
9
|
+
License-Expression: LGPL-3.0-only
|
|
10
|
+
Keywords: automotive,can,dbc,kcd,pydantic,yaml
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
18
|
+
Classifier: Topic :: Software Development :: Embedded Systems
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: cantools
|
|
21
|
+
Requires-Dist: lxml
|
|
22
|
+
Requires-Dist: pydantic>=2
|
|
23
|
+
Requires-Dist: pyyaml
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# can-db-model
|
|
27
|
+
|
|
28
|
+
Shared Pydantic model + KCD/YAML loaders and serializers for CAN databases.
|
|
29
|
+
Consumed by can-api-generator and kcd-merger.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "can-db-model"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Shared Pydantic model + KCD/YAML loaders for CAN databases"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "LGPL-3.0-only"
|
|
12
|
+
keywords = ["can", "kcd", "dbc", "yaml", "automotive", "pydantic"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Alexander Krishna-Becker", email = "nabla.becker@mailbox.org" },
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Programming Language :: Python",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
24
|
+
"Topic :: Software Development :: Embedded Systems",
|
|
25
|
+
]
|
|
26
|
+
dependencies = ["pydantic>=2", "cantools", "lxml", "pyyaml"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Documentation = "https://sr.ht/~laplace/can-db-model#readme"
|
|
30
|
+
Issues = "https://sr.ht/~laplace/can-db-model/issues"
|
|
31
|
+
Source = "https://sr.ht/~laplace/can-db-model"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.version]
|
|
34
|
+
path = "src/can_db_model/__about__.py"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.envs.hatch-test]
|
|
37
|
+
extra-dependencies = ["pytest"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .__about__ import __version__
|
|
2
|
+
from .model import (
|
|
3
|
+
Bus, CanDb, Document, Documentation, Label, Message, MuxMember, Node, Signal,
|
|
4
|
+
)
|
|
5
|
+
from .load import load
|
|
6
|
+
from .from_kcd import load_kcd
|
|
7
|
+
from .from_yaml import load_yaml
|
|
8
|
+
from .to_kcd import dump_kcd, to_kcd_bytes
|
|
9
|
+
from .to_yaml import dump_yaml, to_yaml_str
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"__version__",
|
|
13
|
+
"Bus", "CanDb", "Document", "Documentation", "Label", "Message",
|
|
14
|
+
"MuxMember", "Node", "Signal",
|
|
15
|
+
"load", "load_kcd", "load_yaml",
|
|
16
|
+
"dump_kcd", "to_kcd_bytes", "dump_yaml", "to_yaml_str",
|
|
17
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import cantools
|
|
6
|
+
from lxml import etree
|
|
7
|
+
|
|
8
|
+
from .model import CanDb
|
|
9
|
+
|
|
10
|
+
_KCD_NS = "http://kayak.2codeornot2code.org/1.0"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KcdLoadError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _xml_facts(path: Path):
|
|
18
|
+
"""Recover nodes/producers/consumers/bus that cantools' KCD reader drops."""
|
|
19
|
+
root = etree.parse(str(path)).getroot()
|
|
20
|
+
ns = {"k": _KCD_NS}
|
|
21
|
+
|
|
22
|
+
doc_el = root.find("k:Document", ns)
|
|
23
|
+
document = None
|
|
24
|
+
if doc_el is not None:
|
|
25
|
+
document = {
|
|
26
|
+
"name": doc_el.get("name") or "",
|
|
27
|
+
"version": doc_el.get("version") or "",
|
|
28
|
+
"author": doc_el.get("author") or "",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
bus = root.find("k:Bus", ns)
|
|
32
|
+
bus_name = bus.get("name")
|
|
33
|
+
bus_baud = int(bus.get("baudrate"))
|
|
34
|
+
|
|
35
|
+
id_to_name: dict[str, str] = {}
|
|
36
|
+
node_names: list[str] = []
|
|
37
|
+
for n in root.findall(".//k:Node", ns):
|
|
38
|
+
id_to_name[n.get("id")] = n.get("name")
|
|
39
|
+
node_names.append(n.get("name"))
|
|
40
|
+
|
|
41
|
+
def _refs(parent, xpath):
|
|
42
|
+
out = []
|
|
43
|
+
for ref in parent.findall(xpath, ns):
|
|
44
|
+
out.append(id_to_name[ref.get("id")])
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
producers: dict[int, list[str]] = {}
|
|
48
|
+
consumers: dict[int, dict[str, list[str]]] = {}
|
|
49
|
+
for msg in bus.findall("k:Message", ns):
|
|
50
|
+
mid = int(msg.get("id"), 0)
|
|
51
|
+
producers[mid] = _refs(msg, "k:Producer/k:NodeRef")
|
|
52
|
+
per_sig: dict[str, list[str]] = {}
|
|
53
|
+
for sig in (msg.findall(".//k:Signal", ns) + msg.findall(".//k:Multiplex", ns)):
|
|
54
|
+
cons = _refs(sig, "k:Consumer/k:NodeRef")
|
|
55
|
+
if cons:
|
|
56
|
+
per_sig[sig.get("name")] = cons
|
|
57
|
+
consumers[mid] = per_sig
|
|
58
|
+
return document, bus_name, bus_baud, node_names, producers, consumers
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _value_type(sig) -> str:
|
|
62
|
+
if sig.is_float:
|
|
63
|
+
return "double" if sig.length == 64 else "single"
|
|
64
|
+
return "signed" if sig.is_signed else "unsigned"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _mux_field(sig):
|
|
68
|
+
if sig.is_multiplexer:
|
|
69
|
+
return "multiplexor"
|
|
70
|
+
if sig.multiplexer_signal is not None:
|
|
71
|
+
return {"of": sig.multiplexer_signal, "group": sig.multiplexer_ids[0]}
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_kcd(path: Path | str) -> CanDb:
|
|
76
|
+
path = Path(path)
|
|
77
|
+
try:
|
|
78
|
+
db = cantools.database.load_file(path, database_format="kcd")
|
|
79
|
+
except Exception as e:
|
|
80
|
+
raise KcdLoadError(f"failed to load KCD {path}: {e}") from e
|
|
81
|
+
document, bus_name, bus_baud, node_names, producers, consumers = _xml_facts(path)
|
|
82
|
+
|
|
83
|
+
messages = []
|
|
84
|
+
for m in db.messages:
|
|
85
|
+
sigs = []
|
|
86
|
+
for s in m.signals:
|
|
87
|
+
sigs.append({
|
|
88
|
+
"name": s.name,
|
|
89
|
+
"start": s.start,
|
|
90
|
+
"length": s.length,
|
|
91
|
+
"endianness": "big" if s.byte_order == "big_endian" else "little",
|
|
92
|
+
"type": _value_type(s),
|
|
93
|
+
"slope": s.scale if s.scale is not None else 1.0,
|
|
94
|
+
"intercept": s.offset if s.offset is not None else 0.0,
|
|
95
|
+
"unit": s.unit,
|
|
96
|
+
"min": s.minimum,
|
|
97
|
+
"max": s.maximum,
|
|
98
|
+
"consumers": consumers.get(m.frame_id, {}).get(s.name, []),
|
|
99
|
+
"labels": ({int(v): str(n) for v, n in s.choices.items()}
|
|
100
|
+
if s.choices else {}),
|
|
101
|
+
"mux": _mux_field(s),
|
|
102
|
+
"doc": ({"kcd_notes": s.comment} if s.comment else None),
|
|
103
|
+
})
|
|
104
|
+
messages.append({
|
|
105
|
+
"name": m.name,
|
|
106
|
+
"id": m.frame_id,
|
|
107
|
+
"extended": m.is_extended_frame,
|
|
108
|
+
"length": m.length,
|
|
109
|
+
"interval": m.cycle_time,
|
|
110
|
+
"producers": producers.get(m.frame_id, []),
|
|
111
|
+
"signals": sigs,
|
|
112
|
+
"doc": ({"kcd_notes": m.comment} if m.comment else None),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
payload = {
|
|
116
|
+
"bus": {"name": bus_name, "baudrate": bus_baud},
|
|
117
|
+
"nodes": node_names,
|
|
118
|
+
"messages": messages,
|
|
119
|
+
}
|
|
120
|
+
if document is not None:
|
|
121
|
+
payload["document"] = document
|
|
122
|
+
return CanDb.model_validate(payload)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
from pydantic import ValidationError
|
|
7
|
+
|
|
8
|
+
from .model import CanDb
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class YamlLoadError(Exception):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _format_validation_error(path: Path, err: ValidationError) -> str:
|
|
16
|
+
lines = [f"{path}: invalid CAN database:"]
|
|
17
|
+
for e in err.errors():
|
|
18
|
+
loc = ".".join(str(p) for p in e["loc"])
|
|
19
|
+
lines.append(f" {loc or '<root>'}: {e['msg']}")
|
|
20
|
+
return "\n".join(lines)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_yaml(path: Path | str) -> CanDb:
|
|
24
|
+
path = Path(path)
|
|
25
|
+
try:
|
|
26
|
+
data = yaml.safe_load(path.read_text())
|
|
27
|
+
except yaml.YAMLError as e:
|
|
28
|
+
raise YamlLoadError(f"{path}: YAML syntax error: {e}") from e
|
|
29
|
+
if not isinstance(data, dict):
|
|
30
|
+
raise YamlLoadError(f"{path}: top level must be a mapping")
|
|
31
|
+
try:
|
|
32
|
+
return CanDb.model_validate(data)
|
|
33
|
+
except ValidationError as e:
|
|
34
|
+
raise YamlLoadError(_format_validation_error(path, e)) from e
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .from_kcd import load_kcd
|
|
6
|
+
from .from_yaml import load_yaml
|
|
7
|
+
from .model import CanDb
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load(path: Path | str) -> CanDb:
|
|
11
|
+
path = Path(path)
|
|
12
|
+
ext = path.suffix.lower()
|
|
13
|
+
if ext == ".kcd":
|
|
14
|
+
return load_kcd(path)
|
|
15
|
+
if ext in (".yaml", ".yml"):
|
|
16
|
+
return load_yaml(path)
|
|
17
|
+
raise ValueError(
|
|
18
|
+
f"{path}: unsupported extension {ext!r} (expected .kcd/.yaml/.yml)")
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal, Optional, Union
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
6
|
+
|
|
7
|
+
Endianness = Literal["little", "big"]
|
|
8
|
+
ValueType = Literal["unsigned", "signed", "single", "double"]
|
|
9
|
+
|
|
10
|
+
_STRICT = ConfigDict(extra="forbid")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Documentation(BaseModel):
|
|
14
|
+
model_config = _STRICT
|
|
15
|
+
kcd_notes: Optional[str] = None
|
|
16
|
+
comments: list[str] = Field(default_factory=list)
|
|
17
|
+
description: Optional[str] = None
|
|
18
|
+
tooltip: Optional[str] = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Label(BaseModel):
|
|
22
|
+
model_config = _STRICT
|
|
23
|
+
value: int
|
|
24
|
+
name: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MuxMember(BaseModel):
|
|
28
|
+
model_config = _STRICT
|
|
29
|
+
of: str
|
|
30
|
+
group: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Signal(BaseModel):
|
|
34
|
+
model_config = _STRICT
|
|
35
|
+
name: str
|
|
36
|
+
start: int
|
|
37
|
+
length: int
|
|
38
|
+
endianness: Endianness = "little"
|
|
39
|
+
type: ValueType = "unsigned"
|
|
40
|
+
slope: float = 1.0
|
|
41
|
+
intercept: float = 0.0
|
|
42
|
+
unit: Optional[str] = None
|
|
43
|
+
min: Optional[float] = None
|
|
44
|
+
max: Optional[float] = None
|
|
45
|
+
consumers: list[str] = Field(default_factory=list)
|
|
46
|
+
labels: list[Label] = Field(default_factory=list)
|
|
47
|
+
mux: Union[Literal["multiplexor"], MuxMember, None] = None
|
|
48
|
+
doc: Optional[Documentation] = None
|
|
49
|
+
|
|
50
|
+
@field_validator("labels", mode="before")
|
|
51
|
+
@classmethod
|
|
52
|
+
def _coerce_labels(cls, v):
|
|
53
|
+
# Accept the terse YAML mapping {value: name} as well as a list.
|
|
54
|
+
if isinstance(v, dict):
|
|
55
|
+
return [{"value": int(val), "name": str(name)} for val, name in v.items()]
|
|
56
|
+
return v
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Message(BaseModel):
|
|
60
|
+
model_config = _STRICT
|
|
61
|
+
name: str
|
|
62
|
+
id: int
|
|
63
|
+
extended: Optional[bool] = None
|
|
64
|
+
length: int
|
|
65
|
+
interval: Optional[int] = None
|
|
66
|
+
producers: list[str] = Field(default_factory=list)
|
|
67
|
+
signals: list[Signal] = Field(default_factory=list)
|
|
68
|
+
doc: Optional[Documentation] = None
|
|
69
|
+
|
|
70
|
+
@field_validator("id", mode="before")
|
|
71
|
+
@classmethod
|
|
72
|
+
def _parse_id(cls, v):
|
|
73
|
+
if isinstance(v, str):
|
|
74
|
+
return int(v, 0)
|
|
75
|
+
return v
|
|
76
|
+
|
|
77
|
+
@model_validator(mode="after")
|
|
78
|
+
def _infer_extended(self):
|
|
79
|
+
if self.extended is None:
|
|
80
|
+
self.extended = self.id > 0x7FF
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Bus(BaseModel):
|
|
85
|
+
model_config = _STRICT
|
|
86
|
+
name: str
|
|
87
|
+
baudrate: int
|
|
88
|
+
doc: Optional[Documentation] = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Node(BaseModel):
|
|
92
|
+
model_config = _STRICT
|
|
93
|
+
name: str
|
|
94
|
+
doc: Optional[Documentation] = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Document(BaseModel):
|
|
98
|
+
model_config = _STRICT
|
|
99
|
+
name: str
|
|
100
|
+
version: str = ""
|
|
101
|
+
author: str = ""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class CanDb(BaseModel):
|
|
105
|
+
model_config = _STRICT
|
|
106
|
+
document: Optional[Document] = None
|
|
107
|
+
bus: Optional[Bus] = None
|
|
108
|
+
nodes: list[Node] = Field(default_factory=list)
|
|
109
|
+
messages: list[Message] = Field(default_factory=list)
|
|
110
|
+
doc: Optional[Documentation] = None
|
|
111
|
+
|
|
112
|
+
@field_validator("nodes", mode="before")
|
|
113
|
+
@classmethod
|
|
114
|
+
def _coerce_nodes(cls, v):
|
|
115
|
+
if isinstance(v, list):
|
|
116
|
+
return [{"name": n} if isinstance(n, str) else n for n in v]
|
|
117
|
+
return v
|
|
118
|
+
|
|
119
|
+
@model_validator(mode="after")
|
|
120
|
+
def _semantic_checks(self):
|
|
121
|
+
node_names = {n.name for n in self.nodes}
|
|
122
|
+
|
|
123
|
+
seen_ids: set[int] = set()
|
|
124
|
+
for msg in self.messages:
|
|
125
|
+
if msg.id in seen_ids:
|
|
126
|
+
raise ValueError(f"duplicate frame id 0x{msg.id:X} ({msg.name})")
|
|
127
|
+
seen_ids.add(msg.id)
|
|
128
|
+
|
|
129
|
+
for pname in msg.producers:
|
|
130
|
+
if pname not in node_names:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"producer node {pname!r} not in nodes ({msg.name})")
|
|
133
|
+
|
|
134
|
+
selectors = {s.name for s in msg.signals if s.mux == "multiplexor"}
|
|
135
|
+
for s in msg.signals:
|
|
136
|
+
for cname in s.consumers:
|
|
137
|
+
if cname not in node_names:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"consumer node {cname!r} not in nodes "
|
|
140
|
+
f"({msg.name}.{s.name})")
|
|
141
|
+
if s.start + s.length > msg.length * 8:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"signal {s.name} exceeds message length ({msg.name})")
|
|
144
|
+
if isinstance(s.mux, MuxMember) and s.mux.of not in selectors:
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"mux selector {s.mux.of!r} not found ({msg.name}.{s.name})")
|
|
147
|
+
|
|
148
|
+
# Bit-overlap only among signals that coexist: plain + selector, and
|
|
149
|
+
# within each individual mux group. Members of different groups may
|
|
150
|
+
# share bits.
|
|
151
|
+
def _overlap(sigs):
|
|
152
|
+
spans = sorted((s.start, s.start + s.length, s.name) for s in sigs)
|
|
153
|
+
for (a0, a1, an), (b0, b1, bn) in zip(spans, spans[1:]):
|
|
154
|
+
if b0 < a1:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
f"signals {an} and {bn} overlap ({msg.name})")
|
|
157
|
+
|
|
158
|
+
base = [s for s in msg.signals
|
|
159
|
+
if s.mux is None or s.mux == "multiplexor"]
|
|
160
|
+
_overlap(base)
|
|
161
|
+
groups: dict[tuple[str, int], list] = {}
|
|
162
|
+
for s in msg.signals:
|
|
163
|
+
if isinstance(s.mux, MuxMember):
|
|
164
|
+
groups.setdefault((s.mux.of, s.mux.group), []).append(s)
|
|
165
|
+
for members in groups.values():
|
|
166
|
+
_overlap(base + members)
|
|
167
|
+
return self
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from lxml import etree
|
|
6
|
+
|
|
7
|
+
from .model import CanDb, MuxMember, Signal
|
|
8
|
+
|
|
9
|
+
_NS = "http://kayak.2codeornot2code.org/1.0"
|
|
10
|
+
_NSMAP = {None: _NS}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _q(tag: str) -> str:
|
|
14
|
+
return f"{{{_NS}}}{tag}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _set_attrs(elem, sig: Signal) -> None:
|
|
18
|
+
elem.set("name", sig.name)
|
|
19
|
+
elem.set("offset", str(sig.start))
|
|
20
|
+
elem.set("length", str(sig.length))
|
|
21
|
+
elem.set("endianess", "big" if sig.endianness == "big" else "little")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _kcd_notes(sig) -> str | None:
|
|
25
|
+
return sig.doc.kcd_notes if sig.doc else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _populate(elem, sig: Signal, node_id):
|
|
29
|
+
# KCD child order: Notes, Consumer, Value, LabelSet.
|
|
30
|
+
_set_attrs(elem, sig)
|
|
31
|
+
if _kcd_notes(sig):
|
|
32
|
+
etree.SubElement(elem, _q("Notes")).text = _kcd_notes(sig)
|
|
33
|
+
if sig.consumers:
|
|
34
|
+
cons = etree.SubElement(elem, _q("Consumer"))
|
|
35
|
+
for c in sig.consumers:
|
|
36
|
+
etree.SubElement(cons, _q("NodeRef"), id=node_id[c])
|
|
37
|
+
v = etree.SubElement(elem, _q("Value"), type=sig.type,
|
|
38
|
+
slope=repr(sig.slope), intercept=repr(sig.intercept))
|
|
39
|
+
if sig.unit:
|
|
40
|
+
v.set("unit", sig.unit)
|
|
41
|
+
if sig.labels:
|
|
42
|
+
ls = etree.SubElement(elem, _q("LabelSet"))
|
|
43
|
+
for lab in sig.labels:
|
|
44
|
+
etree.SubElement(ls, _q("Label"), name=lab.name, value=str(lab.value))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _signal_elem(sig: Signal, node_id):
|
|
48
|
+
s = etree.Element(_q("Signal"))
|
|
49
|
+
_populate(s, sig, node_id)
|
|
50
|
+
return s
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def to_kcd_bytes(db: CanDb) -> bytes:
|
|
54
|
+
if db.bus is None:
|
|
55
|
+
raise ValueError("cannot serialize to KCD without a bus")
|
|
56
|
+
root = etree.Element(_q("NetworkDefinition"), nsmap=_NSMAP)
|
|
57
|
+
doc = db.document
|
|
58
|
+
d = etree.SubElement(root, _q("Document"),
|
|
59
|
+
name=doc.name if doc else "",
|
|
60
|
+
version=doc.version if doc else "",
|
|
61
|
+
author=doc.author if doc else "")
|
|
62
|
+
d.text = doc.name if doc else ""
|
|
63
|
+
|
|
64
|
+
node_id: dict[str, str] = {}
|
|
65
|
+
for idx, node in enumerate(db.nodes, start=1):
|
|
66
|
+
node_id[node.name] = str(idx)
|
|
67
|
+
etree.SubElement(root, _q("Node"), id=str(idx), name=node.name)
|
|
68
|
+
|
|
69
|
+
bus = etree.SubElement(root, _q("Bus"), name=db.bus.name,
|
|
70
|
+
baudrate=str(db.bus.baudrate))
|
|
71
|
+
|
|
72
|
+
for msg in db.messages:
|
|
73
|
+
m = etree.SubElement(bus, _q("Message"), id=f"0x{msg.id:X}",
|
|
74
|
+
length=str(msg.length), name=msg.name)
|
|
75
|
+
if msg.interval is not None:
|
|
76
|
+
m.set("interval", str(msg.interval))
|
|
77
|
+
if msg.doc and msg.doc.kcd_notes:
|
|
78
|
+
etree.SubElement(m, _q("Notes")).text = msg.doc.kcd_notes
|
|
79
|
+
if msg.producers:
|
|
80
|
+
prod = etree.SubElement(m, _q("Producer"))
|
|
81
|
+
for p in msg.producers:
|
|
82
|
+
etree.SubElement(prod, _q("NodeRef"), id=node_id[p])
|
|
83
|
+
|
|
84
|
+
plain = [s for s in msg.signals if s.mux is None]
|
|
85
|
+
selector = next((s for s in msg.signals if s.mux == "multiplexor"), None)
|
|
86
|
+
members = [s for s in msg.signals if isinstance(s.mux, MuxMember)]
|
|
87
|
+
|
|
88
|
+
for sig in plain:
|
|
89
|
+
m.append(_signal_elem(sig, node_id))
|
|
90
|
+
|
|
91
|
+
if selector is not None:
|
|
92
|
+
mux_elem = etree.SubElement(m, _q("Multiplex"))
|
|
93
|
+
_set_attrs(mux_elem, selector)
|
|
94
|
+
by_group: dict[int, list[Signal]] = {}
|
|
95
|
+
for s in members:
|
|
96
|
+
if s.mux.of == selector.name:
|
|
97
|
+
by_group.setdefault(s.mux.group, []).append(s)
|
|
98
|
+
for group, sigs in by_group.items():
|
|
99
|
+
mg = etree.SubElement(mux_elem, _q("MuxGroup"), count=str(group))
|
|
100
|
+
for s in sigs:
|
|
101
|
+
mg.append(_signal_elem(s, node_id))
|
|
102
|
+
if _kcd_notes(selector):
|
|
103
|
+
etree.SubElement(mux_elem, _q("Notes")).text = _kcd_notes(selector)
|
|
104
|
+
if selector.consumers:
|
|
105
|
+
cons = etree.SubElement(mux_elem, _q("Consumer"))
|
|
106
|
+
for c in selector.consumers:
|
|
107
|
+
etree.SubElement(cons, _q("NodeRef"), id=node_id[c])
|
|
108
|
+
etree.SubElement(mux_elem, _q("Value"), type=selector.type,
|
|
109
|
+
slope=repr(selector.slope),
|
|
110
|
+
intercept=repr(selector.intercept))
|
|
111
|
+
|
|
112
|
+
xml = etree.tostring(root, pretty_print=True, xml_declaration=True,
|
|
113
|
+
encoding="UTF-8")
|
|
114
|
+
return xml.replace(b"</Message>\n", b"</Message>\n\n")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def dump_kcd(db: CanDb, path: Path | str) -> None:
|
|
118
|
+
Path(path).write_bytes(to_kcd_bytes(db))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from .model import CanDb
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def to_yaml_str(db: CanDb) -> str:
|
|
11
|
+
# exclude_none/exclude_defaults keep the output terse, mirroring authored YAML.
|
|
12
|
+
data = db.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
|
|
13
|
+
return yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def dump_yaml(db: CanDb, path: Path | str) -> None:
|
|
17
|
+
Path(path).write_text(to_yaml_str(db))
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<NetworkDefinition xmlns="http://kayak.2codeornot2code.org/1.0">
|
|
2
|
+
<Document name="sample" version="1.0" author="test">sample</Document>
|
|
3
|
+
<Node id="1" name="ECM"/>
|
|
4
|
+
<Node id="2" name="Pi"/>
|
|
5
|
+
<Bus name="P-bus" baudrate="500000">
|
|
6
|
+
<Message id="0x123" length="8" name="VehicleSpeed">
|
|
7
|
+
<Producer><NodeRef id="1"/></Producer>
|
|
8
|
+
<Signal name="speed" offset="0" length="16" endianess="little">
|
|
9
|
+
<Consumer><NodeRef id="2"/></Consumer>
|
|
10
|
+
<Value type="unsigned" slope="0.01" intercept="0" unit="km/h"/>
|
|
11
|
+
</Signal>
|
|
12
|
+
</Message>
|
|
13
|
+
</Bus>
|
|
14
|
+
</NetworkDefinition>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
document: {name: sample, version: "1.0", author: test}
|
|
2
|
+
bus: {name: P-bus, baudrate: 500000}
|
|
3
|
+
nodes: [ECM, Pi]
|
|
4
|
+
messages:
|
|
5
|
+
- name: VehicleSpeed
|
|
6
|
+
id: 0x123
|
|
7
|
+
extended: false
|
|
8
|
+
length: 8
|
|
9
|
+
producers: [ECM]
|
|
10
|
+
signals:
|
|
11
|
+
- {name: speed, start: 0, length: 16, type: unsigned, slope: 0.01, intercept: 0, unit: km/h, consumers: [Pi]}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from can_db_model import CanDb
|
|
3
|
+
from can_db_model.from_kcd import load_kcd
|
|
4
|
+
|
|
5
|
+
DATA = Path(__file__).parent / "data" / "sample.kcd"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_load_kcd_basic():
|
|
9
|
+
db = load_kcd(DATA)
|
|
10
|
+
assert isinstance(db, CanDb)
|
|
11
|
+
assert db.bus.name == "P-bus" and db.bus.baudrate == 500000
|
|
12
|
+
assert {n.name for n in db.nodes} == {"ECM", "Pi"}
|
|
13
|
+
m = db.messages[0]
|
|
14
|
+
assert m.name == "VehicleSpeed" and m.id == 0x123 and m.length == 8
|
|
15
|
+
assert m.producers == ["ECM"]
|
|
16
|
+
s = m.signals[0]
|
|
17
|
+
assert s.name == "speed" and s.start == 0 and s.length == 16
|
|
18
|
+
assert s.slope == 0.01 and s.consumers == ["Pi"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from can_db_model import CanDb
|
|
3
|
+
from can_db_model.from_yaml import load_yaml, YamlLoadError
|
|
4
|
+
|
|
5
|
+
GOOD = """
|
|
6
|
+
bus: {name: P-bus, baudrate: 500000}
|
|
7
|
+
nodes: [ECM, Pi]
|
|
8
|
+
messages:
|
|
9
|
+
- name: VehicleSpeed
|
|
10
|
+
id: 0x10210000
|
|
11
|
+
length: 8
|
|
12
|
+
producers: [ECM]
|
|
13
|
+
signals:
|
|
14
|
+
- {name: speed, start: 0, length: 16, type: unsigned, slope: 0.01, unit: km/h, consumers: [Pi]}
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_load_yaml_returns_candb(tmp_path):
|
|
19
|
+
p = tmp_path / "db.yaml"
|
|
20
|
+
p.write_text(GOOD)
|
|
21
|
+
db = load_yaml(p)
|
|
22
|
+
assert isinstance(db, CanDb)
|
|
23
|
+
assert db.messages[0].signals[0].slope == 0.01
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_load_yaml_validation_error_is_friendly(tmp_path):
|
|
27
|
+
p = tmp_path / "bad.yaml"
|
|
28
|
+
p.write_text("nodes: [A]\nmessages:\n - {name: m, id: 1, length: 2, producers: [X]}\n")
|
|
29
|
+
with pytest.raises(YamlLoadError) as exc:
|
|
30
|
+
load_yaml(p)
|
|
31
|
+
msg = str(exc.value)
|
|
32
|
+
assert "bad.yaml" in msg
|
|
33
|
+
assert "producer node 'X'" in msg
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from can_db_model import load
|
|
4
|
+
|
|
5
|
+
DATA = Path(__file__).parent / "data"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_dispatch_kcd():
|
|
9
|
+
db = load(DATA / "sample.kcd")
|
|
10
|
+
assert db.bus.name == "P-bus"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_dispatch_yaml():
|
|
14
|
+
db = load(DATA / "sample.yaml")
|
|
15
|
+
assert db.bus.name == "P-bus"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_unknown_extension_rejected(tmp_path):
|
|
19
|
+
p = tmp_path / "db.txt"
|
|
20
|
+
p.write_text("nope")
|
|
21
|
+
with pytest.raises(ValueError, match="unsupported extension"):
|
|
22
|
+
load(p)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_kcd_and_yaml_equivalent():
|
|
26
|
+
# A KCD and a hand-written YAML describing the same database load equal.
|
|
27
|
+
assert load(DATA / "sample.kcd") == load(DATA / "sample.yaml")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from can_db_model import CanDb
|
|
2
|
+
from can_db_model.model import Node
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_nodes_accept_bare_strings():
|
|
6
|
+
db = CanDb(nodes=["ECM", "Pi"])
|
|
7
|
+
assert all(isinstance(n, Node) for n in db.nodes)
|
|
8
|
+
assert [n.name for n in db.nodes] == ["ECM", "Pi"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_candb_minimal_fragment_has_optional_bus_and_document():
|
|
12
|
+
db = CanDb(nodes=["ECM"], messages=[{"name": "m", "id": 1, "length": 2}])
|
|
13
|
+
assert db.bus is None and db.document is None
|
|
14
|
+
assert db.messages[0].name == "m"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_candb_full():
|
|
18
|
+
db = CanDb(
|
|
19
|
+
document={"name": "saaber", "version": "1.0", "author": "alex"},
|
|
20
|
+
bus={"name": "P-bus", "baudrate": 500000},
|
|
21
|
+
nodes=["ECM", "Pi"],
|
|
22
|
+
messages=[{"name": "VehicleSpeed", "id": "0x10210000", "length": 8,
|
|
23
|
+
"producers": ["ECM"],
|
|
24
|
+
"signals": [{"name": "speed", "start": 0, "length": 16,
|
|
25
|
+
"consumers": ["Pi"]}]}],
|
|
26
|
+
)
|
|
27
|
+
assert db.bus.baudrate == 500000
|
|
28
|
+
assert db.messages[0].signals[0].consumers == ["Pi"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from can_db_model.model import Documentation, Label
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_documentation_defaults_empty():
|
|
5
|
+
d = Documentation()
|
|
6
|
+
assert d.kcd_notes is None
|
|
7
|
+
assert d.comments == []
|
|
8
|
+
assert d.description is None
|
|
9
|
+
assert d.tooltip is None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_documentation_full():
|
|
13
|
+
d = Documentation(kcd_notes="n", comments=["a", "b"], description="d", tooltip="t")
|
|
14
|
+
assert d.comments == ["a", "b"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_label_fields():
|
|
18
|
+
lab = Label(value=3, name="active")
|
|
19
|
+
assert lab.value == 3 and lab.name == "active"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from can_db_model.model import Message
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_message_hex_id_string_parsed():
|
|
5
|
+
m = Message(name="VehicleSpeed", id="0x10210000", length=8)
|
|
6
|
+
assert m.id == 0x10210000
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_message_int_id_kept():
|
|
10
|
+
m = Message(name="m", id=100, length=2)
|
|
11
|
+
assert m.id == 100
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_extended_inferred_true_for_big_id():
|
|
15
|
+
m = Message(name="m", id=0x10210000, length=8)
|
|
16
|
+
assert m.extended is True
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_extended_inferred_false_for_std_id():
|
|
20
|
+
m = Message(name="m", id=0x123, length=8)
|
|
21
|
+
assert m.extended is False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_extended_explicit_wins():
|
|
25
|
+
m = Message(name="m", id=0x123, length=8, extended=True)
|
|
26
|
+
assert m.extended is True
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from can_db_model.model import Signal, Label, MuxMember
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_signal_defaults():
|
|
5
|
+
s = Signal(name="speed", start=0, length=16)
|
|
6
|
+
assert s.endianness == "little"
|
|
7
|
+
assert s.type == "unsigned"
|
|
8
|
+
assert s.slope == 1.0 and s.intercept == 0.0
|
|
9
|
+
assert s.consumers == [] and s.labels == [] and s.mux is None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_signal_terse_labels_dict_is_coerced():
|
|
13
|
+
s = Signal(name="state", start=0, length=2, labels={0: "invalid", 1: "valid"})
|
|
14
|
+
assert sorted([(lab.value, lab.name) for lab in s.labels]) == [(0, "invalid"), (1, "valid")]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_signal_labels_list_form_also_accepted():
|
|
18
|
+
s = Signal(name="state", start=0, length=2, labels=[Label(value=0, name="invalid")])
|
|
19
|
+
assert s.labels[0].name == "invalid"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_signal_mux_selector():
|
|
23
|
+
s = Signal(name="sel", start=0, length=4, mux="multiplexor")
|
|
24
|
+
assert s.mux == "multiplexor"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_signal_mux_member():
|
|
28
|
+
s = Signal(name="rpm", start=8, length=16, mux={"of": "sel", "group": 1})
|
|
29
|
+
assert isinstance(s.mux, MuxMember)
|
|
30
|
+
assert s.mux.of == "sel" and s.mux.group == 1
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pydantic import ValidationError
|
|
3
|
+
from can_db_model import CanDb
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _db(**msg):
|
|
7
|
+
return {"nodes": ["A", "B"], "messages": [msg]}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_duplicate_frame_ids_rejected():
|
|
11
|
+
with pytest.raises(ValidationError, match="duplicate frame id"):
|
|
12
|
+
CanDb(nodes=["A"], messages=[
|
|
13
|
+
{"name": "m1", "id": 1, "length": 2, "producers": ["A"]},
|
|
14
|
+
{"name": "m2", "id": 1, "length": 2, "producers": ["A"]},
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_unknown_producer_node_rejected():
|
|
19
|
+
with pytest.raises(ValidationError, match="producer node 'X' not in nodes"):
|
|
20
|
+
CanDb(**_db(name="m", id=1, length=2, producers=["X"]))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_unknown_consumer_node_rejected():
|
|
24
|
+
with pytest.raises(ValidationError, match="consumer node 'X' not in nodes"):
|
|
25
|
+
CanDb(**_db(name="m", id=1, length=2, producers=["A"],
|
|
26
|
+
signals=[{"name": "s", "start": 0, "length": 8, "consumers": ["X"]}]))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_signal_exceeding_length_rejected():
|
|
30
|
+
with pytest.raises(ValidationError, match="exceeds message length"):
|
|
31
|
+
CanDb(**_db(name="m", id=1, length=1, producers=["A"],
|
|
32
|
+
signals=[{"name": "s", "start": 0, "length": 16, "consumers": ["B"]}]))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_overlapping_signals_rejected():
|
|
36
|
+
with pytest.raises(ValidationError, match="overlap"):
|
|
37
|
+
CanDb(**_db(name="m", id=1, length=8, producers=["A"], signals=[
|
|
38
|
+
{"name": "a", "start": 0, "length": 8, "consumers": ["B"]},
|
|
39
|
+
{"name": "b", "start": 4, "length": 8, "consumers": ["B"]},
|
|
40
|
+
]))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_mux_member_references_missing_selector_rejected():
|
|
44
|
+
with pytest.raises(ValidationError, match="mux selector 'nope'"):
|
|
45
|
+
CanDb(**_db(name="m", id=1, length=8, producers=["A"], signals=[
|
|
46
|
+
{"name": "rpm", "start": 8, "length": 8, "consumers": ["B"],
|
|
47
|
+
"mux": {"of": "nope", "group": 1}},
|
|
48
|
+
]))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_valid_mux_accepted():
|
|
52
|
+
CanDb(**_db(name="m", id=1, length=8, producers=["A"], signals=[
|
|
53
|
+
{"name": "sel", "start": 0, "length": 4, "consumers": ["B"], "mux": "multiplexor"},
|
|
54
|
+
{"name": "rpm", "start": 8, "length": 8, "consumers": ["B"],
|
|
55
|
+
"mux": {"of": "sel", "group": 1}},
|
|
56
|
+
]))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from can_db_model.from_kcd import load_kcd
|
|
2
|
+
from can_db_model.to_kcd import dump_kcd
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
DATA = Path(__file__).parent / "data" / "sample.kcd"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_kcd_round_trip_preserves_model(tmp_path):
|
|
9
|
+
db1 = load_kcd(DATA)
|
|
10
|
+
out = tmp_path / "out.kcd"
|
|
11
|
+
dump_kcd(db1, out)
|
|
12
|
+
db2 = load_kcd(out)
|
|
13
|
+
assert db1 == db2 # KCD -> model -> KCD -> model is lossless
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from can_db_model.from_yaml import load_yaml
|
|
2
|
+
from can_db_model.to_yaml import dump_yaml
|
|
3
|
+
|
|
4
|
+
SRC = """
|
|
5
|
+
bus: {name: P-bus, baudrate: 500000}
|
|
6
|
+
nodes: [ECM, Pi]
|
|
7
|
+
messages:
|
|
8
|
+
- name: VehicleSpeed
|
|
9
|
+
id: 0x10210000
|
|
10
|
+
length: 8
|
|
11
|
+
producers: [ECM]
|
|
12
|
+
doc: {kcd_notes: speed msg, comments: [c1, c2], tooltip: spd}
|
|
13
|
+
signals:
|
|
14
|
+
- {name: speed, start: 0, length: 16, slope: 0.01, unit: km/h, consumers: [Pi], labels: {0: invalid}}
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_yaml_round_trip_preserves_model(tmp_path):
|
|
19
|
+
p = tmp_path / "db.yaml"
|
|
20
|
+
p.write_text(SRC)
|
|
21
|
+
db1 = load_yaml(p)
|
|
22
|
+
|
|
23
|
+
out = tmp_path / "out.yaml"
|
|
24
|
+
dump_yaml(db1, out)
|
|
25
|
+
db2 = load_yaml(out)
|
|
26
|
+
|
|
27
|
+
assert db1 == db2 # extension doc fields survive a YAML->YAML round-trip
|