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.
@@ -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