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.
@@ -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,4 @@
1
+ # can-db-model
2
+
3
+ Shared Pydantic model + KCD/YAML loaders and serializers for CAN databases.
4
+ 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,5 @@
1
+ def test_version_exposed():
2
+ import can_db_model
3
+
4
+ assert isinstance(can_db_model.__version__, str)
5
+ assert can_db_model.__version__
@@ -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