iotsploit-protocols 0.0.9__py3-none-any.whl
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.
- iotsploit_protocols/__init__.py +20 -0
- iotsploit_protocols/autosar/__init__.py +12 -0
- iotsploit_protocols/autosar/arxml.py +765 -0
- iotsploit_protocols/canbus/__init__.py +56 -0
- iotsploit_protocols/canbus/bus_match.py +121 -0
- iotsploit_protocols/canbus/catalog.py +425 -0
- iotsploit_protocols/canbus/codec.py +455 -0
- iotsploit_protocols/canbus/definitions.py +252 -0
- iotsploit_protocols/canbus/errorframes.py +154 -0
- iotsploit_protocols/canbus/errors.py +47 -0
- iotsploit_protocols/canbus/logfile.py +753 -0
- iotsploit_protocols/canbus/socketcan.py +385 -0
- iotsploit_protocols/doip/__init__.py +22 -0
- iotsploit_protocols/doip/client.py +267 -0
- iotsploit_protocols/doip/facet.py +51 -0
- iotsploit_protocols/doip/uds.py +262 -0
- iotsploit_protocols/errors.py +45 -0
- iotsploit_protocols/someip/__init__.py +19 -0
- iotsploit_protocols/someip/client.py +322 -0
- iotsploit_protocols/someip/codec.py +11 -0
- iotsploit_protocols/someip/facet.py +71 -0
- iotsploit_protocols/someip/sd.py +355 -0
- iotsploit_protocols-0.0.9.dist-info/METADATA +118 -0
- iotsploit_protocols-0.0.9.dist-info/RECORD +25 -0
- iotsploit_protocols-0.0.9.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
"""Import an AUTOSAR ARXML description as a reviewable vehicle target.
|
|
2
|
+
|
|
3
|
+
``cantools`` is the source of truth for CAN frames and signals. A small XML
|
|
4
|
+
pass supplies the vehicle facts it deliberately does not model: ECU instances,
|
|
5
|
+
communication clusters, connector membership, and Ethernet endpoints. The
|
|
6
|
+
result uses the existing Target wire format and can be inspected before it is
|
|
7
|
+
loaded with ``target_import``.
|
|
8
|
+
|
|
9
|
+
This is intentionally not a general AUTOSAR object model. LIN and Ethernet
|
|
10
|
+
are represented as topology only; CAN definitions are the only communication
|
|
11
|
+
payload converted into the target today.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import codecs
|
|
17
|
+
import gc
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import re
|
|
22
|
+
import xml.etree.ElementTree as ET
|
|
23
|
+
from collections import Counter, defaultdict
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from decimal import Decimal
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple
|
|
28
|
+
|
|
29
|
+
import cantools
|
|
30
|
+
|
|
31
|
+
from iotsploit_core.domain.target_transfer import build_envelope
|
|
32
|
+
|
|
33
|
+
MAX_ARXML_BYTES = 256 * 1024 * 1024
|
|
34
|
+
PARTIAL_SYSTEM_CATEGORY = "ECU_SYSTEM_DESCRIPTION"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ArxmlImportError(ValueError):
|
|
38
|
+
"""An ARXML file cannot be safely or meaningfully imported."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _CantoolsDuplicateIndexFilter(logging.Filter):
|
|
42
|
+
"""Hide lossy convenience-index warnings; this importer keeps the list."""
|
|
43
|
+
|
|
44
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
45
|
+
return not record.getMessage().startswith("Overwriting message")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ArxmlImportResult:
|
|
50
|
+
"""The generated target plus concise information for a CLI summary."""
|
|
51
|
+
|
|
52
|
+
target: Dict[str, Any]
|
|
53
|
+
warnings: Tuple[str, ...]
|
|
54
|
+
counts: Mapping[str, int]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def import_arxml(
|
|
58
|
+
path: str | Path,
|
|
59
|
+
*,
|
|
60
|
+
target_id: str,
|
|
61
|
+
name: str,
|
|
62
|
+
source: Optional[str] = None,
|
|
63
|
+
load_file: Optional[Callable[..., Any]] = None,
|
|
64
|
+
) -> ArxmlImportResult:
|
|
65
|
+
"""Build one vehicle target from ``path`` without changing backend state.
|
|
66
|
+
|
|
67
|
+
Stable ids are derived from AUTOSAR short names, so running the import
|
|
68
|
+
again produces diffable JSON. ``source`` is provenance supplied by the
|
|
69
|
+
caller; when absent only the filename is stored, never a machine-local
|
|
70
|
+
absolute path.
|
|
71
|
+
"""
|
|
72
|
+
arxml_path = Path(path)
|
|
73
|
+
digest, size = _inspect_file(arxml_path)
|
|
74
|
+
topology = _extract_topology(arxml_path)
|
|
75
|
+
# The ElementTree for a production ARXML is hundreds of MiB. Make sure it
|
|
76
|
+
# is gone before cantools builds its own object graph for the same file.
|
|
77
|
+
gc.collect()
|
|
78
|
+
|
|
79
|
+
loader = load_file or cantools.database.load_file
|
|
80
|
+
cantools_logger = logging.getLogger("cantools.database.can.database")
|
|
81
|
+
duplicate_filter = _CantoolsDuplicateIndexFilter()
|
|
82
|
+
cantools_logger.addFilter(duplicate_filter)
|
|
83
|
+
try:
|
|
84
|
+
database = loader(str(arxml_path), database_format="arxml", strict=True)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
raise ArxmlImportError(f"cantools could not parse {arxml_path.name}: {exc}") from exc
|
|
87
|
+
finally:
|
|
88
|
+
cantools_logger.removeFilter(duplicate_filter)
|
|
89
|
+
|
|
90
|
+
warnings: List[str] = []
|
|
91
|
+
category = topology.get("system_category")
|
|
92
|
+
complete_vehicle = category != PARTIAL_SYSTEM_CATEGORY
|
|
93
|
+
if not complete_vehicle:
|
|
94
|
+
warnings.append(
|
|
95
|
+
"SYSTEM category ECU_SYSTEM_DESCRIPTION is an ECU extract; the vehicle target is "
|
|
96
|
+
"draft and must not be treated as a complete vehicle topology."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
buses, buses_by_name = _build_buses(topology, database, warnings)
|
|
100
|
+
components, connector_owners = _build_components(topology, buses, warnings)
|
|
101
|
+
edges = _build_edges(buses, connector_owners)
|
|
102
|
+
|
|
103
|
+
can_messages = 0
|
|
104
|
+
can_signals = 0
|
|
105
|
+
fd_messages = 0
|
|
106
|
+
container_messages = 0
|
|
107
|
+
wide_signals = 0
|
|
108
|
+
message_names: Counter[str] = Counter()
|
|
109
|
+
skipped_by_bus: Counter[str] = Counter()
|
|
110
|
+
for message in getattr(database, "messages", ()) or ():
|
|
111
|
+
bus_name = getattr(message, "bus_name", None)
|
|
112
|
+
bus = buses_by_name.get(bus_name)
|
|
113
|
+
if bus is None or bus.get("type") != "can":
|
|
114
|
+
skipped_by_bus[str(bus_name)] += 1
|
|
115
|
+
continue
|
|
116
|
+
converted = _message_dict(message)
|
|
117
|
+
bus["properties"].setdefault("messages", []).append(converted)
|
|
118
|
+
can_messages += 1
|
|
119
|
+
message_names[converted["name"]] += 1
|
|
120
|
+
can_signals += len(converted["signals"])
|
|
121
|
+
fd_messages += int(converted.get("is_fd") is True)
|
|
122
|
+
container_messages += int(bool(converted.get("contained_messages")))
|
|
123
|
+
wide_signals += sum(int(signal["length"] > 64) for signal in converted["signals"])
|
|
124
|
+
|
|
125
|
+
if container_messages:
|
|
126
|
+
warnings.append(
|
|
127
|
+
f"{container_messages} CAN container frames were preserved with contained_messages; "
|
|
128
|
+
"the current Flutter explorer does not expand their nested payloads."
|
|
129
|
+
)
|
|
130
|
+
if skipped_by_bus:
|
|
131
|
+
summary = ", ".join(f"{bus}: {count}" for bus, count in sorted(skipped_by_bus.items()))
|
|
132
|
+
warnings.append(f"CAN messages naming unknown buses were not imported ({summary}).")
|
|
133
|
+
repeated_names = sum(count - 1 for count in message_names.values() if count > 1)
|
|
134
|
+
if repeated_names:
|
|
135
|
+
warnings.append(
|
|
136
|
+
f"{repeated_names} CAN message occurrences reuse a name on another bus; every "
|
|
137
|
+
"bus-specific occurrence was retained."
|
|
138
|
+
)
|
|
139
|
+
if wide_signals:
|
|
140
|
+
warnings.append(
|
|
141
|
+
f"{wide_signals} CAN signals wider than 64 bits were preserved without truncation; "
|
|
142
|
+
"consumers must support wide raw values."
|
|
143
|
+
)
|
|
144
|
+
if any(bus["type"] in {"lin", "ethernet"} for bus in buses):
|
|
145
|
+
warnings.append(
|
|
146
|
+
"LIN and Ethernet clusters are imported as topology and endpoint metadata only; "
|
|
147
|
+
"this importer converts communication payloads for CAN only."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
counts = {
|
|
151
|
+
"components": len(components),
|
|
152
|
+
"buses": len(buses),
|
|
153
|
+
"edges": len(edges),
|
|
154
|
+
"can_messages": can_messages,
|
|
155
|
+
"can_signals": can_signals,
|
|
156
|
+
"can_fd_messages": fd_messages,
|
|
157
|
+
"can_container_messages": container_messages,
|
|
158
|
+
}
|
|
159
|
+
metadata = {
|
|
160
|
+
"source": source or arxml_path.name,
|
|
161
|
+
"sha256": digest,
|
|
162
|
+
"size_bytes": size,
|
|
163
|
+
"schema": topology.get("schema"),
|
|
164
|
+
"system_name": topology.get("system_name"),
|
|
165
|
+
"system_category": category,
|
|
166
|
+
"scope": "ecu_extract" if not complete_vehicle else "system_description",
|
|
167
|
+
"complete_vehicle": complete_vehicle,
|
|
168
|
+
"cantools_version": cantools.__version__,
|
|
169
|
+
"counts": counts,
|
|
170
|
+
"warnings": warnings,
|
|
171
|
+
}
|
|
172
|
+
target = {
|
|
173
|
+
"target_id": target_id,
|
|
174
|
+
"name": name,
|
|
175
|
+
"type": "vehicle",
|
|
176
|
+
"status": "active" if complete_vehicle else "draft",
|
|
177
|
+
"properties": {"arxml_import": metadata},
|
|
178
|
+
"components": components,
|
|
179
|
+
"buses": buses,
|
|
180
|
+
"edges": edges,
|
|
181
|
+
}
|
|
182
|
+
return ArxmlImportResult(target=target, warnings=tuple(warnings), counts=counts)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# How the DTD scan below decides what it is reading. A UTF-16 ``<!DOCTYPE``
|
|
186
|
+
# is ``<\x00!\x00d\x00...`` and walks straight past a byte-level substring
|
|
187
|
+
# match, so the bytes have to be decoded before they are scanned -- and that
|
|
188
|
+
# means knowing the encoding before the declaration inside them can be read.
|
|
189
|
+
#
|
|
190
|
+
# A byte-order mark answers it when there is one. When there is not, the XML
|
|
191
|
+
# specification's own rule applies: a document begins with ``<``, so the
|
|
192
|
+
# placement of the null bytes around that first character names the encoding.
|
|
193
|
+
# Relying on the BOM alone left a no-BOM UTF-16 file scanned as UTF-8, where
|
|
194
|
+
# a DTD declaration is invisible and ElementTree reads it anyway.
|
|
195
|
+
#
|
|
196
|
+
# UTF-32-LE is tested before UTF-16-LE because its BOM starts with the
|
|
197
|
+
# UTF-16-LE one, and the four-byte patterns before the two-byte ones for the
|
|
198
|
+
# same reason.
|
|
199
|
+
_BOMS: Tuple[Tuple[bytes, str], ...] = (
|
|
200
|
+
(codecs.BOM_UTF32_LE, "utf-32-le"),
|
|
201
|
+
(codecs.BOM_UTF32_BE, "utf-32-be"),
|
|
202
|
+
(codecs.BOM_UTF8, "utf-8-sig"),
|
|
203
|
+
(codecs.BOM_UTF16_LE, "utf-16-le"),
|
|
204
|
+
(codecs.BOM_UTF16_BE, "utf-16-be"),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
#: The first character of an XML document is "<" (0x3C). Where its null
|
|
208
|
+
#: padding falls says how wide the encoding is and which way round it runs.
|
|
209
|
+
_OPENINGS: Tuple[Tuple[bytes, str], ...] = (
|
|
210
|
+
(b"\x3c\x00\x00\x00", "utf-32-le"),
|
|
211
|
+
(b"\x00\x00\x00\x3c", "utf-32-be"),
|
|
212
|
+
(b"\x3c\x00\x3f\x00", "utf-16-le"),
|
|
213
|
+
(b"\x00\x3c\x00\x3f", "utf-16-be"),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _sniff_encoding(head: bytes) -> str:
|
|
218
|
+
"""The encoding of an XML document, from its first few bytes.
|
|
219
|
+
|
|
220
|
+
A byte-order mark first, then the shape of the opening ``<``. Anything
|
|
221
|
+
else is read as UTF-8, which is what an ARXML without either is.
|
|
222
|
+
"""
|
|
223
|
+
for bom, encoding in _BOMS:
|
|
224
|
+
if head.startswith(bom):
|
|
225
|
+
return encoding
|
|
226
|
+
for opening, encoding in _OPENINGS:
|
|
227
|
+
if head.startswith(opening):
|
|
228
|
+
return encoding
|
|
229
|
+
# A bare "<" followed by a null is UTF-16-LE even when the second
|
|
230
|
+
# character is not "?"; the reverse for big-endian.
|
|
231
|
+
if len(head) >= 2:
|
|
232
|
+
if head[0] == 0x3C and head[1] == 0x00:
|
|
233
|
+
return "utf-16-le"
|
|
234
|
+
if head[0] == 0x00 and head[1] == 0x3C:
|
|
235
|
+
return "utf-16-be"
|
|
236
|
+
return "utf-8"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _inspect_file(path: Path) -> Tuple[str, int]:
|
|
240
|
+
try:
|
|
241
|
+
size = path.stat().st_size
|
|
242
|
+
except OSError as exc:
|
|
243
|
+
raise ArxmlImportError(f"cannot read ARXML file {path}: {exc}") from exc
|
|
244
|
+
if size > MAX_ARXML_BYTES:
|
|
245
|
+
raise ArxmlImportError(
|
|
246
|
+
f"ARXML file is {size} bytes; the import limit is {MAX_ARXML_BYTES} bytes"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
digest = hashlib.sha256()
|
|
250
|
+
markup_tail = ""
|
|
251
|
+
decoder = None
|
|
252
|
+
try:
|
|
253
|
+
with path.open("rb") as handle:
|
|
254
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
255
|
+
digest.update(chunk)
|
|
256
|
+
if decoder is None:
|
|
257
|
+
decoder = codecs.getincrementaldecoder(_sniff_encoding(chunk))(errors="ignore")
|
|
258
|
+
markup = markup_tail + decoder.decode(chunk).lower()
|
|
259
|
+
if "<!doctype" in markup or "<!entity" in markup:
|
|
260
|
+
raise ArxmlImportError("ARXML files containing DTD or entity declarations are rejected")
|
|
261
|
+
markup_tail = markup[-16:]
|
|
262
|
+
except ArxmlImportError:
|
|
263
|
+
raise
|
|
264
|
+
except (OSError, UnicodeError, LookupError) as exc:
|
|
265
|
+
raise ArxmlImportError(f"cannot read ARXML file {path}: {exc}") from exc
|
|
266
|
+
return digest.hexdigest(), size
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _extract_topology(path: Path) -> Dict[str, Any]:
|
|
270
|
+
try:
|
|
271
|
+
tree = ET.parse(path)
|
|
272
|
+
except (ET.ParseError, OSError) as exc:
|
|
273
|
+
raise ArxmlImportError(f"invalid ARXML XML in {path.name}: {exc}") from exc
|
|
274
|
+
|
|
275
|
+
root = tree.getroot()
|
|
276
|
+
schema_location = next(
|
|
277
|
+
(value for key, value in root.attrib.items() if _local(key) == "schemaLocation"), ""
|
|
278
|
+
)
|
|
279
|
+
schema = schema_location.split()[-1].rsplit("/", 1)[-1] if schema_location else None
|
|
280
|
+
topology: Dict[str, Any] = {
|
|
281
|
+
"schema": schema,
|
|
282
|
+
"system_name": None,
|
|
283
|
+
"system_category": None,
|
|
284
|
+
"ecus": [],
|
|
285
|
+
"networks": [],
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for reference, element in _package_elements(root):
|
|
289
|
+
kind = _local(element.tag)
|
|
290
|
+
if kind == "SYSTEM" and topology["system_name"] is None:
|
|
291
|
+
topology["system_name"] = _child_text(element, "SHORT-NAME")
|
|
292
|
+
topology["system_category"] = _child_text(element, "CATEGORY")
|
|
293
|
+
elif kind == "ECU-INSTANCE":
|
|
294
|
+
topology["ecus"].append(_ecu_dict(reference, element))
|
|
295
|
+
elif kind in {"CAN-CLUSTER", "LIN-CLUSTER", "ETHERNET-CLUSTER"}:
|
|
296
|
+
topology["networks"].append(_network_dict(reference, element, kind))
|
|
297
|
+
|
|
298
|
+
# Break the large reference cycle promptly; import_arxml invokes gc before
|
|
299
|
+
# asking cantools to parse the file a second time.
|
|
300
|
+
tree = None
|
|
301
|
+
root = None
|
|
302
|
+
return topology
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _package_elements(root: ET.Element) -> Iterable[Tuple[str, ET.Element]]:
|
|
306
|
+
def walk(package: ET.Element, parent: str) -> Iterable[Tuple[str, ET.Element]]:
|
|
307
|
+
package_name = _child_text(package, "SHORT-NAME")
|
|
308
|
+
if not package_name:
|
|
309
|
+
return
|
|
310
|
+
package_ref = f"{parent}/{package_name}"
|
|
311
|
+
elements = _child(package, "ELEMENTS")
|
|
312
|
+
if elements is not None:
|
|
313
|
+
for element in list(elements):
|
|
314
|
+
short_name = _child_text(element, "SHORT-NAME")
|
|
315
|
+
if short_name:
|
|
316
|
+
yield f"{package_ref}/{short_name}", element
|
|
317
|
+
nested = _child(package, "AR-PACKAGES")
|
|
318
|
+
if nested is not None:
|
|
319
|
+
for child_package in _children(nested, "AR-PACKAGE"):
|
|
320
|
+
yield from walk(child_package, package_ref)
|
|
321
|
+
|
|
322
|
+
packages = _child(root, "AR-PACKAGES")
|
|
323
|
+
if packages is None:
|
|
324
|
+
return
|
|
325
|
+
for package in _children(packages, "AR-PACKAGE"):
|
|
326
|
+
yield from walk(package, "")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _ecu_dict(reference: str, element: ET.Element) -> Dict[str, Any]:
|
|
330
|
+
connectors = []
|
|
331
|
+
connector_kinds = {
|
|
332
|
+
"CAN-COMMUNICATION-CONNECTOR": "can",
|
|
333
|
+
"LIN-COMMUNICATION-CONNECTOR": "lin",
|
|
334
|
+
"ETHERNET-COMMUNICATION-CONNECTOR": "ethernet",
|
|
335
|
+
}
|
|
336
|
+
for connector in element.iter():
|
|
337
|
+
connector_type = connector_kinds.get(_local(connector.tag))
|
|
338
|
+
if connector_type is None:
|
|
339
|
+
continue
|
|
340
|
+
connector_name = _child_text(connector, "SHORT-NAME")
|
|
341
|
+
if not connector_name:
|
|
342
|
+
continue
|
|
343
|
+
connectors.append(
|
|
344
|
+
{
|
|
345
|
+
"name": connector_name,
|
|
346
|
+
"type": connector_type,
|
|
347
|
+
"arxml_ref": f"{reference}/{connector_name}",
|
|
348
|
+
"network_endpoint_refs": _descendant_texts(connector, "NETWORK-ENDPOINT-REF"),
|
|
349
|
+
}
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
"name": _child_text(element, "SHORT-NAME") or reference.rsplit("/", 1)[-1],
|
|
354
|
+
"arxml_ref": reference,
|
|
355
|
+
"long_name": _element_text(_child(element, "LONG-NAME")),
|
|
356
|
+
"description": _element_text(_child(element, "DESC")),
|
|
357
|
+
"connectors": connectors,
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _network_dict(reference: str, element: ET.Element, kind: str) -> Dict[str, Any]:
|
|
362
|
+
network_type = kind.split("-", 1)[0].lower()
|
|
363
|
+
channel_tag = {
|
|
364
|
+
"can": "CAN-PHYSICAL-CHANNEL",
|
|
365
|
+
"lin": "LIN-PHYSICAL-CHANNEL",
|
|
366
|
+
"ethernet": "ETHERNET-PHYSICAL-CHANNEL",
|
|
367
|
+
}[network_type]
|
|
368
|
+
channels = [node for node in element.iter() if _local(node.tag) == channel_tag]
|
|
369
|
+
connector_refs = _unique(
|
|
370
|
+
text
|
|
371
|
+
for channel in channels
|
|
372
|
+
for text in _descendant_texts(channel, "COMMUNICATION-CONNECTOR-REF")
|
|
373
|
+
)
|
|
374
|
+
properties: Dict[str, Any] = {
|
|
375
|
+
"arxml_ref": reference,
|
|
376
|
+
"physical_channels": [
|
|
377
|
+
name for channel in channels if (name := _child_text(channel, "SHORT-NAME"))
|
|
378
|
+
],
|
|
379
|
+
}
|
|
380
|
+
baudrate = _first_descendant_text(element, "BAUDRATE")
|
|
381
|
+
if baudrate is not None:
|
|
382
|
+
properties["baudrate"] = _integer(baudrate)
|
|
383
|
+
|
|
384
|
+
if network_type == "lin":
|
|
385
|
+
properties["frame_triggering_count"] = sum(
|
|
386
|
+
1 for node in element.iter() if _local(node.tag) == "LIN-FRAME-TRIGGERING"
|
|
387
|
+
)
|
|
388
|
+
elif network_type == "ethernet":
|
|
389
|
+
endpoints = []
|
|
390
|
+
sockets = []
|
|
391
|
+
for channel in channels:
|
|
392
|
+
channel_name = _child_text(channel, "SHORT-NAME") or "channel"
|
|
393
|
+
channel_ref = f"{reference}/{channel_name}"
|
|
394
|
+
for endpoint in channel.iter():
|
|
395
|
+
if _local(endpoint.tag) != "NETWORK-ENDPOINT":
|
|
396
|
+
continue
|
|
397
|
+
endpoint_name = _child_text(endpoint, "SHORT-NAME")
|
|
398
|
+
if not endpoint_name:
|
|
399
|
+
continue
|
|
400
|
+
logical = _logical_address(endpoint)
|
|
401
|
+
endpoint_row: Dict[str, Any] = {
|
|
402
|
+
"name": endpoint_name,
|
|
403
|
+
"arxml_ref": f"{channel_ref}/{endpoint_name}",
|
|
404
|
+
"addresses": _unique(
|
|
405
|
+
_descendant_texts(endpoint, "IPV-4-ADDRESS")
|
|
406
|
+
+ _descendant_texts(endpoint, "IPV-6-ADDRESS")
|
|
407
|
+
),
|
|
408
|
+
}
|
|
409
|
+
role = _first_descendant_text(endpoint, "DO-IP-ENTITY-ROLE")
|
|
410
|
+
if role:
|
|
411
|
+
endpoint_row["doip_role"] = role
|
|
412
|
+
if logical is not None:
|
|
413
|
+
endpoint_row["doip_logical_address"] = logical
|
|
414
|
+
endpoints.append(endpoint_row)
|
|
415
|
+
|
|
416
|
+
for socket in channel.iter():
|
|
417
|
+
if _local(socket.tag) != "SOCKET-ADDRESS":
|
|
418
|
+
continue
|
|
419
|
+
socket_name = _child_text(socket, "SHORT-NAME")
|
|
420
|
+
if not socket_name:
|
|
421
|
+
continue
|
|
422
|
+
protocol = None
|
|
423
|
+
if any(_local(node.tag) == "TCP-TP" for node in socket.iter()):
|
|
424
|
+
protocol = "tcp"
|
|
425
|
+
elif any(_local(node.tag) == "UDP-TP" for node in socket.iter()):
|
|
426
|
+
protocol = "udp"
|
|
427
|
+
socket_row: Dict[str, Any] = {
|
|
428
|
+
"name": socket_name,
|
|
429
|
+
"arxml_ref": f"{channel_ref}/{socket_name}",
|
|
430
|
+
"protocol": protocol,
|
|
431
|
+
"network_endpoint_ref": _first_descendant_text(socket, "NETWORK-ENDPOINT-REF"),
|
|
432
|
+
"connector_ref": _first_descendant_text(socket, "CONNECTOR-REF"),
|
|
433
|
+
}
|
|
434
|
+
port = _first_descendant_text(socket, "PORT-NUMBER")
|
|
435
|
+
if port is not None:
|
|
436
|
+
socket_row["port"] = _integer(port)
|
|
437
|
+
sockets.append({key: value for key, value in socket_row.items() if value is not None})
|
|
438
|
+
|
|
439
|
+
properties["network_endpoints"] = endpoints
|
|
440
|
+
properties["sockets"] = sockets
|
|
441
|
+
vlan = _first_descendant_text(element, "VLAN-IDENTIFIER")
|
|
442
|
+
if vlan is not None:
|
|
443
|
+
properties["vlan_id"] = _integer(vlan)
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
"name": _child_text(element, "SHORT-NAME") or reference.rsplit("/", 1)[-1],
|
|
447
|
+
"type": network_type,
|
|
448
|
+
"arxml_ref": reference,
|
|
449
|
+
"connector_refs": connector_refs,
|
|
450
|
+
"properties": properties,
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _build_buses(
|
|
455
|
+
topology: Mapping[str, Any], database: Any, warnings: List[str]
|
|
456
|
+
) -> Tuple[List[Dict[str, Any]], Dict[str, Dict[str, Any]]]:
|
|
457
|
+
can_buses = {getattr(bus, "name", None): bus for bus in getattr(database, "buses", ()) or ()}
|
|
458
|
+
buses = []
|
|
459
|
+
by_name = {}
|
|
460
|
+
for network in topology["networks"]:
|
|
461
|
+
if network["name"] in by_name:
|
|
462
|
+
raise ArxmlImportError(
|
|
463
|
+
f"multiple communication clusters use short name {network['name']!r}; "
|
|
464
|
+
"cantools cannot map their messages unambiguously"
|
|
465
|
+
)
|
|
466
|
+
properties = dict(network["properties"])
|
|
467
|
+
can_bus = can_buses.get(network["name"])
|
|
468
|
+
if network["type"] == "can" and can_bus is not None:
|
|
469
|
+
baudrate = getattr(can_bus, "baudrate", None)
|
|
470
|
+
fd_baudrate = getattr(can_bus, "fd_baudrate", None)
|
|
471
|
+
if baudrate is not None:
|
|
472
|
+
properties["baudrate"] = baudrate
|
|
473
|
+
if fd_baudrate is not None:
|
|
474
|
+
properties["fd_baudrate"] = fd_baudrate
|
|
475
|
+
bus = {
|
|
476
|
+
"bus_id": _stable_id("bus", network["type"], network["name"]),
|
|
477
|
+
"name": network["name"],
|
|
478
|
+
"type": network["type"],
|
|
479
|
+
"properties": properties,
|
|
480
|
+
"_connector_refs": network["connector_refs"],
|
|
481
|
+
}
|
|
482
|
+
buses.append(bus)
|
|
483
|
+
by_name[network["name"]] = bus
|
|
484
|
+
|
|
485
|
+
topology_names = {network["name"] for network in topology["networks"] if network["type"] == "can"}
|
|
486
|
+
missing = sorted(name for name in can_buses if name and name not in topology_names)
|
|
487
|
+
if missing:
|
|
488
|
+
warnings.append(f"cantools exposed CAN buses absent from topology: {', '.join(missing)}")
|
|
489
|
+
return buses, by_name
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _build_components(
|
|
493
|
+
topology: Mapping[str, Any], buses: List[Dict[str, Any]], warnings: List[str]
|
|
494
|
+
) -> Tuple[List[Dict[str, Any]], Dict[str, str]]:
|
|
495
|
+
endpoints = {
|
|
496
|
+
endpoint["arxml_ref"]: endpoint
|
|
497
|
+
for bus in buses
|
|
498
|
+
for endpoint in bus["properties"].get("network_endpoints", ())
|
|
499
|
+
}
|
|
500
|
+
sockets_by_connector: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
|
501
|
+
for bus in buses:
|
|
502
|
+
for socket in bus["properties"].get("sockets", ()):
|
|
503
|
+
if connector_ref := socket.get("connector_ref"):
|
|
504
|
+
sockets_by_connector[connector_ref].append(socket)
|
|
505
|
+
|
|
506
|
+
components = []
|
|
507
|
+
connector_owners = {}
|
|
508
|
+
component_ids = set()
|
|
509
|
+
for ecu in topology["ecus"]:
|
|
510
|
+
component_id = _stable_id("c", ecu["name"])
|
|
511
|
+
if component_id in component_ids:
|
|
512
|
+
raise ArxmlImportError(
|
|
513
|
+
f"multiple ECU instances map to component id {component_id!r}; "
|
|
514
|
+
"their short names must be distinct"
|
|
515
|
+
)
|
|
516
|
+
component_ids.add(component_id)
|
|
517
|
+
owned_endpoints = []
|
|
518
|
+
owned_sockets = []
|
|
519
|
+
for connector in ecu["connectors"]:
|
|
520
|
+
connector_owners[connector["arxml_ref"]] = component_id
|
|
521
|
+
owned_endpoints.extend(
|
|
522
|
+
endpoints[reference]
|
|
523
|
+
for reference in connector["network_endpoint_refs"]
|
|
524
|
+
if reference in endpoints
|
|
525
|
+
)
|
|
526
|
+
owned_sockets.extend(sockets_by_connector.get(connector["arxml_ref"], ()))
|
|
527
|
+
|
|
528
|
+
properties: Dict[str, Any] = {
|
|
529
|
+
"arxml_ref": ecu["arxml_ref"],
|
|
530
|
+
"connectors": ecu["connectors"],
|
|
531
|
+
}
|
|
532
|
+
if ecu["long_name"]:
|
|
533
|
+
properties["long_name"] = ecu["long_name"]
|
|
534
|
+
if ecu["description"]:
|
|
535
|
+
properties["description"] = ecu["description"]
|
|
536
|
+
if owned_endpoints:
|
|
537
|
+
properties["network_endpoints"] = owned_endpoints
|
|
538
|
+
addresses = [address for endpoint in owned_endpoints for address in endpoint["addresses"]]
|
|
539
|
+
if addresses:
|
|
540
|
+
properties["ip_address"] = addresses[0]
|
|
541
|
+
if owned_sockets:
|
|
542
|
+
properties["sockets"] = owned_sockets
|
|
543
|
+
|
|
544
|
+
facets: Dict[str, Any] = {}
|
|
545
|
+
logical_endpoints = [
|
|
546
|
+
endpoint for endpoint in owned_endpoints if "doip_logical_address" in endpoint
|
|
547
|
+
]
|
|
548
|
+
if logical_endpoints:
|
|
549
|
+
endpoint = logical_endpoints[0]
|
|
550
|
+
doip_sockets = [
|
|
551
|
+
socket
|
|
552
|
+
for socket in owned_sockets
|
|
553
|
+
if socket.get("network_endpoint_ref") == endpoint["arxml_ref"]
|
|
554
|
+
]
|
|
555
|
+
facet: Dict[str, Any] = {"logical_address": endpoint["doip_logical_address"]}
|
|
556
|
+
if endpoint["addresses"]:
|
|
557
|
+
facet["host"] = endpoint["addresses"][0]
|
|
558
|
+
ports = [socket["port"] for socket in doip_sockets if "port" in socket]
|
|
559
|
+
if ports:
|
|
560
|
+
facet["port"] = ports[0]
|
|
561
|
+
facets["doip"] = facet
|
|
562
|
+
elif any(endpoint.get("doip_role") for endpoint in owned_endpoints):
|
|
563
|
+
warnings.append(
|
|
564
|
+
f"ECU {ecu['name']!r} has a DoIP endpoint but no logical address; no actionable "
|
|
565
|
+
"doip facet was created."
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
components.append(
|
|
569
|
+
{
|
|
570
|
+
"component_id": component_id,
|
|
571
|
+
"name": ecu["name"],
|
|
572
|
+
"type": "ecu",
|
|
573
|
+
"status": "active",
|
|
574
|
+
"facets": facets,
|
|
575
|
+
"properties": properties,
|
|
576
|
+
}
|
|
577
|
+
)
|
|
578
|
+
return components, connector_owners
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _build_edges(
|
|
582
|
+
buses: List[Dict[str, Any]], connector_owners: Mapping[str, str]
|
|
583
|
+
) -> List[Dict[str, Any]]:
|
|
584
|
+
edges = []
|
|
585
|
+
for bus in buses:
|
|
586
|
+
by_owner: Dict[str, List[str]] = defaultdict(list)
|
|
587
|
+
for connector_ref in bus.pop("_connector_refs", ()):
|
|
588
|
+
owner = connector_owners.get(connector_ref)
|
|
589
|
+
if owner is not None:
|
|
590
|
+
by_owner[owner].append(connector_ref)
|
|
591
|
+
for owner, connector_refs in by_owner.items():
|
|
592
|
+
edges.append(
|
|
593
|
+
{
|
|
594
|
+
"source": owner,
|
|
595
|
+
"target": bus["bus_id"],
|
|
596
|
+
"relation": "bus_member",
|
|
597
|
+
"properties": {"source": "arxml", "connector_refs": connector_refs},
|
|
598
|
+
}
|
|
599
|
+
)
|
|
600
|
+
return edges
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _message_dict(message: Any, *, nested: bool = False) -> Dict[str, Any]:
|
|
604
|
+
converted: Dict[str, Any] = {
|
|
605
|
+
"frame_id": getattr(message, "frame_id", 0),
|
|
606
|
+
"name": getattr(message, "name", ""),
|
|
607
|
+
"dlc": getattr(message, "length", 0),
|
|
608
|
+
"is_extended": bool(getattr(message, "is_extended_frame", False)),
|
|
609
|
+
"is_fd": bool(getattr(message, "is_fd", False)),
|
|
610
|
+
"signals": [_signal_dict(signal) for signal in getattr(message, "signals", ()) or ()],
|
|
611
|
+
}
|
|
612
|
+
optional = {
|
|
613
|
+
"cycle_time_ms": getattr(message, "cycle_time", None),
|
|
614
|
+
"senders": list(getattr(message, "senders", ()) or ()),
|
|
615
|
+
"header_id": getattr(message, "header_id", None),
|
|
616
|
+
}
|
|
617
|
+
converted.update({key: value for key, value in optional.items() if value not in (None, [])})
|
|
618
|
+
contained = getattr(message, "contained_messages", ()) or ()
|
|
619
|
+
if contained:
|
|
620
|
+
converted["contained_messages"] = [
|
|
621
|
+
_message_dict(child, nested=True) for child in contained if child is not message
|
|
622
|
+
]
|
|
623
|
+
if nested:
|
|
624
|
+
# A contained PDU's frame id is not its identity; AUTOSAR uses the
|
|
625
|
+
# header id. Keep the value when cantools has one, but do not invent it.
|
|
626
|
+
converted.pop("is_extended", None)
|
|
627
|
+
return _json_value(converted)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _signal_dict(signal: Any) -> Dict[str, Any]:
|
|
631
|
+
conversion = getattr(signal, "conversion", None)
|
|
632
|
+
scale = getattr(conversion, "scale", getattr(signal, "scale", 1.0))
|
|
633
|
+
offset = getattr(conversion, "offset", getattr(signal, "offset", 0.0))
|
|
634
|
+
choices = getattr(conversion, "choices", getattr(signal, "choices", None))
|
|
635
|
+
multiplexer = None
|
|
636
|
+
if getattr(signal, "is_multiplexer", False):
|
|
637
|
+
multiplexer = "M"
|
|
638
|
+
elif getattr(signal, "multiplexer_ids", None):
|
|
639
|
+
multiplexer = ",".join(f"m{value}" for value in signal.multiplexer_ids)
|
|
640
|
+
|
|
641
|
+
converted: Dict[str, Any] = {
|
|
642
|
+
"name": getattr(signal, "name", ""),
|
|
643
|
+
"start_bit": getattr(signal, "start", 0),
|
|
644
|
+
"length": getattr(signal, "length", 0),
|
|
645
|
+
"byte_order": "little" if getattr(signal, "byte_order", "little_endian") == "little_endian" else "big",
|
|
646
|
+
"signed": bool(getattr(signal, "is_signed", False)),
|
|
647
|
+
"factor": scale,
|
|
648
|
+
"offset": offset,
|
|
649
|
+
"minimum": getattr(signal, "minimum", None),
|
|
650
|
+
"maximum": getattr(signal, "maximum", None),
|
|
651
|
+
"unit": getattr(signal, "unit", None) or "",
|
|
652
|
+
"multiplexer": multiplexer,
|
|
653
|
+
# An IEEE-754 payload is not a scaled integer, and factor/offset do not
|
|
654
|
+
# say so. An encoder rebuilding this signal without the flag packs a
|
|
655
|
+
# float as an integer and is wrong by the whole width of the field.
|
|
656
|
+
"is_float": bool(getattr(signal, "is_float", False)),
|
|
657
|
+
}
|
|
658
|
+
receivers = list(getattr(signal, "receivers", ()) or ())
|
|
659
|
+
if receivers:
|
|
660
|
+
converted["receivers"] = receivers
|
|
661
|
+
if choices:
|
|
662
|
+
converted["choices"] = choices
|
|
663
|
+
multiplexer_signal = getattr(signal, "multiplexer_signal", None)
|
|
664
|
+
if multiplexer_signal:
|
|
665
|
+
converted["multiplexer_signal"] = multiplexer_signal
|
|
666
|
+
return _json_value(converted)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _json_value(value: Any) -> Any:
|
|
670
|
+
if isinstance(value, Decimal):
|
|
671
|
+
return int(value) if value == value.to_integral_value() else float(value)
|
|
672
|
+
if isinstance(value, Mapping):
|
|
673
|
+
return {str(key): _json_value(item) for key, item in value.items()}
|
|
674
|
+
if isinstance(value, (list, tuple)):
|
|
675
|
+
return [_json_value(item) for item in value]
|
|
676
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
677
|
+
return value
|
|
678
|
+
# cantools uses NamedSignalValue for textual choice labels.
|
|
679
|
+
return str(value)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _stable_id(prefix: str, *parts: str) -> str:
|
|
683
|
+
slug = "_".join(parts).lower()
|
|
684
|
+
slug = re.sub(r"[^a-z0-9]+", "_", slug).strip("_")
|
|
685
|
+
return f"{prefix}_{slug or 'unnamed'}"
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _logical_address(element: ET.Element) -> Optional[int]:
|
|
689
|
+
for node in element.iter():
|
|
690
|
+
if _local(node.tag) not in {"DO-IP-LOGICAL-ADDRESS", "LOGICAL-ADDRESS"}:
|
|
691
|
+
continue
|
|
692
|
+
text = (node.text or "").strip()
|
|
693
|
+
if not text:
|
|
694
|
+
continue
|
|
695
|
+
try:
|
|
696
|
+
return int(text, 0)
|
|
697
|
+
except ValueError:
|
|
698
|
+
try:
|
|
699
|
+
return int(text, 16)
|
|
700
|
+
except ValueError:
|
|
701
|
+
return None
|
|
702
|
+
return None
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _local(tag: str) -> str:
|
|
706
|
+
return tag.rsplit("}", 1)[-1]
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _child(element: ET.Element, name: str) -> Optional[ET.Element]:
|
|
710
|
+
return next((child for child in list(element) if _local(child.tag) == name), None)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _children(element: ET.Element, name: str) -> Iterable[ET.Element]:
|
|
714
|
+
return (child for child in list(element) if _local(child.tag) == name)
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _child_text(element: ET.Element, name: str) -> Optional[str]:
|
|
718
|
+
child = _child(element, name)
|
|
719
|
+
if child is None or child.text is None:
|
|
720
|
+
return None
|
|
721
|
+
return child.text.strip() or None
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _first_descendant_text(element: ET.Element, name: str) -> Optional[str]:
|
|
725
|
+
for child in element.iter():
|
|
726
|
+
if _local(child.tag) == name and child.text and child.text.strip():
|
|
727
|
+
return child.text.strip()
|
|
728
|
+
return None
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _descendant_texts(element: ET.Element, name: str) -> List[str]:
|
|
732
|
+
return [
|
|
733
|
+
child.text.strip()
|
|
734
|
+
for child in element.iter()
|
|
735
|
+
if _local(child.tag) == name and child.text and child.text.strip()
|
|
736
|
+
]
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _element_text(element: Optional[ET.Element]) -> Optional[str]:
|
|
740
|
+
if element is None:
|
|
741
|
+
return None
|
|
742
|
+
text = "\n".join(part.strip() for part in element.itertext() if part.strip())
|
|
743
|
+
return text or None
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _integer(value: str) -> int | str:
|
|
747
|
+
try:
|
|
748
|
+
return int(value, 0)
|
|
749
|
+
except ValueError:
|
|
750
|
+
return value
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _unique(values: Iterable[str]) -> List[str]:
|
|
754
|
+
return list(dict.fromkeys(values))
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def dump_target(result: ArxmlImportResult, output: str | Path) -> None:
|
|
758
|
+
"""Write a result in the shared ``target_import`` envelope."""
|
|
759
|
+
envelope = build_envelope([result.target], source="import_arxml")
|
|
760
|
+
try:
|
|
761
|
+
with Path(output).open("w", encoding="utf-8") as handle:
|
|
762
|
+
json.dump(envelope, handle, indent=2, ensure_ascii=False)
|
|
763
|
+
handle.write("\n")
|
|
764
|
+
except OSError as exc:
|
|
765
|
+
raise ArxmlImportError(f"cannot write target JSON {output}: {exc}") from exc
|