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,56 @@
1
+ """Target-defined CAN: resolve a frame, encode it, decode it, put it on a wire.
2
+
3
+ Four separations that the implementation may rename but must not merge:
4
+
5
+ * the **catalogue** is pure target-to-definition logic;
6
+ * the **codec** is pure definition-and-values-to-bytes logic, and its inverse;
7
+ * **SocketCAN** is explicit I/O behind a small client that owns no state
8
+ between calls;
9
+ * **policy** -- what may be sent, and after what confirmation -- belongs to the
10
+ plugin above this package, not here.
11
+
12
+ Nothing in this package reads the database, the current target, or the
13
+ environment, and nothing runs ``sudo`` or changes host networking. A link is
14
+ brought up outside IoTSploit; a client here opens a socket on one that is
15
+ already up, or fails saying so.
16
+
17
+ Importing this package stays cheap. ``python-can`` opens platform sockets and
18
+ reads host configuration on import, so it is imported inside
19
+ :mod:`~iotsploit_protocols.canbus.socketcan` at call time -- which is what lets
20
+ a preview run on a host with no CAN interface at all.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ __all__ = [
26
+ "BusDefinition",
27
+ "CanCodec",
28
+ "CanDefinitionError",
29
+ "CanValueError",
30
+ "DecodedFrame",
31
+ "EncodedFrame",
32
+ "FrameDefinition",
33
+ "SignalDefinition",
34
+ "TargetCanCatalog",
35
+ "build_message",
36
+ "canonical_frame_id",
37
+ "decode_frame",
38
+ "encode_frame",
39
+ ]
40
+
41
+ from iotsploit_protocols.canbus.catalog import TargetCanCatalog
42
+ from iotsploit_protocols.canbus.codec import (
43
+ CanCodec,
44
+ build_message,
45
+ decode_frame,
46
+ encode_frame,
47
+ )
48
+ from iotsploit_protocols.canbus.definitions import (
49
+ BusDefinition,
50
+ DecodedFrame,
51
+ EncodedFrame,
52
+ FrameDefinition,
53
+ SignalDefinition,
54
+ canonical_frame_id,
55
+ )
56
+ from iotsploit_protocols.canbus.errors import CanDefinitionError, CanValueError
@@ -0,0 +1,121 @@
1
+ """Score observed CAN identities against the buses a target documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Iterable, Tuple
7
+
8
+ from iotsploit_protocols.canbus.catalog import TargetCanCatalog
9
+
10
+ Identity = Tuple[int, bool]
11
+
12
+
13
+ def observe_identities(channel: str, seconds: float, *, fd: bool = True) -> set[Identity]:
14
+ """Listen read-only and return distinct data-frame identities."""
15
+ from iotsploit_protocols.canbus.errorframes import is_error_frame, is_remote_frame
16
+ from iotsploit_protocols.canbus.socketcan import (
17
+ CaptureBudget,
18
+ SocketCanConfig,
19
+ SocketCanReceiver,
20
+ )
21
+
22
+ seen: set[Identity] = set()
23
+ with SocketCanReceiver(SocketCanConfig(channel=channel, fd=fd)) as receiver:
24
+ for message in receiver.frames(
25
+ CaptureBudget(duration_s=seconds, max_frames=200_000)
26
+ ):
27
+ if is_error_frame(message) or is_remote_frame(message):
28
+ continue
29
+ seen.add((int(message.arbitration_id), bool(message.is_extended_id)))
30
+ return seen
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class BusMatchRow:
35
+ bus_id: str
36
+ bus_name: str
37
+ matched: int
38
+ heard: int
39
+ documented: int
40
+
41
+ @property
42
+ def coverage(self) -> float:
43
+ return self.matched / self.heard if self.heard else 0.0
44
+
45
+ def as_dict(self) -> dict[str, Any]:
46
+ return {
47
+ "bus_id": self.bus_id,
48
+ "bus_name": self.bus_name,
49
+ "matched": self.matched,
50
+ "heard": self.heard,
51
+ "documented": self.documented,
52
+ "coverage": self.coverage,
53
+ }
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class BusMatchResult:
58
+ outcome: str
59
+ rows: tuple[BusMatchRow, ...]
60
+ best_bus_id: str | None = None
61
+
62
+ def as_dict(self) -> dict[str, Any]:
63
+ messages = {
64
+ "winner": "One documented bus clearly explains the observed traffic.",
65
+ "none": "No bus explains this traffic. Check the target definitions and interface.",
66
+ "tie": "Two or more buses explain nearly the same traffic. Listen longer.",
67
+ "no_frames": "No data-frame identities were heard during the sample.",
68
+ "no_buses": "The target documents no CAN frames.",
69
+ }
70
+ return {
71
+ "outcome": self.outcome,
72
+ "best_bus_id": self.best_bus_id,
73
+ "message": messages[self.outcome],
74
+ "rows": [row.as_dict() for row in self.rows],
75
+ }
76
+
77
+
78
+ def score_buses(
79
+ catalog: TargetCanCatalog,
80
+ observed: Iterable[Identity],
81
+ *,
82
+ near_tie_ratio: float = 0.9,
83
+ ) -> BusMatchResult:
84
+ """Return a winner, no-match, or near-tie without guessing.
85
+
86
+ Coverage is the portion of observed identities a bus documents. A runner-up
87
+ reaching 90% of the winner is deliberately ambiguous: a longer sample is
88
+ safer than selecting a bus whose definitions happen to overlap heavily.
89
+ """
90
+ if not 0 < near_tie_ratio <= 1:
91
+ raise ValueError("near_tie_ratio must be greater than 0 and at most 1")
92
+
93
+ seen = {(int(frame_id), bool(is_extended)) for frame_id, is_extended in observed}
94
+ if not seen:
95
+ return BusMatchResult("no_frames", ())
96
+
97
+ rows = []
98
+ for bus in catalog.buses:
99
+ documented = {(frame.frame_id, frame.is_extended) for frame in bus.frames}
100
+ if documented:
101
+ rows.append(
102
+ BusMatchRow(
103
+ bus_id=bus.bus_id,
104
+ bus_name=bus.name,
105
+ matched=len(seen & documented),
106
+ heard=len(seen),
107
+ documented=len(documented),
108
+ )
109
+ )
110
+ rows.sort(key=lambda row: (-row.matched, row.bus_id))
111
+ ranked = tuple(rows)
112
+ if not ranked:
113
+ return BusMatchResult("no_buses", ranked)
114
+ if ranked[0].matched == 0:
115
+ return BusMatchResult("none", ranked)
116
+
117
+ if len(ranked) > 1:
118
+ runner_up = ranked[1].matched
119
+ if runner_up and runner_up / ranked[0].matched >= near_tie_ratio:
120
+ return BusMatchResult("tie", ranked)
121
+ return BusMatchResult("winner", ranked, best_bus_id=ranked[0].bus_id)
@@ -0,0 +1,425 @@
1
+ """Turning a target into CAN frame definitions, and refusing to guess.
2
+
3
+ A target stores CAN frames in two places. ARXML frames and DBC frames with no
4
+ declared transmitter live under ``bus.properties.messages``; DBC frames that
5
+ name a sender live under ``component.facets.can.messages``, with the facet's
6
+ ``bus_id`` saying which bus they belong to. Both describe the same wire.
7
+
8
+ This module reads both and produces one shape. It never reads the database, the
9
+ current target, or the environment -- a mapping goes in and definitions come
10
+ out -- which is what lets the same code run under a plugin, a test, or a
11
+ capture loop.
12
+
13
+ Two rules are load-bearing and easy to get wrong:
14
+
15
+ *Identity is ``(bus_id, frame_id, is_extended)``.* Not a name: the same frame
16
+ name legitimately appears on two buses, and a standard 0x123 and an extended
17
+ 0x123 are different frames sharing a wire. A resolver keyed on a name or on a
18
+ bare number answers confidently and wrongly.
19
+
20
+ *Disagreement is a finding, not a tie to break.* When two rows claim one
21
+ identity and describe different bytes, this marks the frame conflicted and
22
+ every caller refuses it. Picking the first would silently transmit one
23
+ document's idea of a frame while the operator read the other's.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
29
+
30
+ from iotsploit_protocols.canbus.definitions import (
31
+ BusDefinition,
32
+ FrameDefinition,
33
+ SignalDefinition,
34
+ frame_id_is_valid,
35
+ )
36
+ from iotsploit_protocols.canbus.errors import CanDefinitionError
37
+
38
+ #: The payload lengths CAN FD allows. Classic CAN is 0..8 contiguous; FD keeps
39
+ #: those and then jumps, so a 9-byte FD frame does not exist.
40
+ FD_PAYLOAD_LENGTHS = frozenset({0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64})
41
+
42
+ CAN_FACET_KEY = "can"
43
+
44
+
45
+ def _field(source: Any, name: str, default: Any = None) -> Any:
46
+ """Read ``name`` off a mapping or an object.
47
+
48
+ Targets arrive as pydantic models from the Django path and as plain dicts
49
+ from the Celery and MCP paths. Handling both here keeps every caller from
50
+ having to know which one it got.
51
+ """
52
+ if isinstance(source, Mapping):
53
+ return source.get(name, default)
54
+ return getattr(source, name, default)
55
+
56
+
57
+ def _as_int(value: Any) -> Optional[int]:
58
+ """An int, or ``None`` when the value is not one.
59
+
60
+ Deliberately strict about ``bool``: Python makes ``True`` an ``int``, and a
61
+ frame id of ``True`` would resolve as frame 1.
62
+ """
63
+ if isinstance(value, bool):
64
+ return None
65
+ if isinstance(value, int):
66
+ return value
67
+ if isinstance(value, str):
68
+ text = value.strip()
69
+ try:
70
+ return int(text, 16) if text.lower().startswith("0x") else int(text)
71
+ except ValueError:
72
+ return None
73
+ return None
74
+
75
+
76
+ def _signal_from(raw: Any) -> SignalDefinition:
77
+ """One stored signal, in whichever of the two shapes it was written."""
78
+ choices_raw = _field(raw, "choices") or None
79
+ choices: Optional[Dict[int, str]] = None
80
+ if isinstance(choices_raw, Mapping):
81
+ # The ARXML importer stringifies mapping keys on its way to JSON, so
82
+ # "0" and 0 arrive as different keys for one code unless normalized.
83
+ converted = {}
84
+ for key, label in choices_raw.items():
85
+ code = _as_int(key)
86
+ if code is not None:
87
+ converted[code] = str(label)
88
+ choices = converted or None
89
+
90
+ byte_order = str(_field(raw, "byte_order", "little") or "little")
91
+ return SignalDefinition(
92
+ name=str(_field(raw, "name", "") or ""),
93
+ start_bit=_as_int(_field(raw, "start_bit", 0)) or 0,
94
+ length=_as_int(_field(raw, "length", 0)) or 0,
95
+ byte_order="big" if byte_order.startswith("big") else "little",
96
+ signed=bool(_field(raw, "signed", False)),
97
+ factor=float(_field(raw, "factor", 1.0) or 1.0),
98
+ offset=float(_field(raw, "offset", 0.0) or 0.0),
99
+ minimum=_optional_float(_field(raw, "minimum")),
100
+ maximum=_optional_float(_field(raw, "maximum")),
101
+ unit=str(_field(raw, "unit", "") or ""),
102
+ multiplexer=_optional_str(_field(raw, "multiplexer")),
103
+ multiplexer_signal=_optional_str(_field(raw, "multiplexer_signal")),
104
+ choices=choices,
105
+ is_float=bool(_field(raw, "is_float", False)),
106
+ )
107
+
108
+
109
+ def _optional_float(value: Any) -> Optional[float]:
110
+ if value is None or isinstance(value, bool):
111
+ return None
112
+ try:
113
+ return float(value)
114
+ except (TypeError, ValueError):
115
+ return None
116
+
117
+
118
+ def _optional_str(value: Any) -> Optional[str]:
119
+ if value is None:
120
+ return None
121
+ text = str(value).strip()
122
+ return text or None
123
+
124
+
125
+ def _frame_from(
126
+ raw: Any,
127
+ *,
128
+ bus_id: str,
129
+ owner_kind: str,
130
+ index: int,
131
+ component_id: Optional[str] = None,
132
+ component_name: Optional[str] = None,
133
+ ) -> FrameDefinition:
134
+ """One stored frame, with its unsupported reason already worked out."""
135
+ frame_id = _as_int(_field(raw, "frame_id"))
136
+ is_extended = bool(_field(raw, "is_extended", False))
137
+ signals = tuple(_signal_from(s) for s in (_field(raw, "signals") or ()))
138
+ contained = tuple(
139
+ c for c in (_field(raw, "contained_messages") or ()) if isinstance(c, Mapping)
140
+ )
141
+ dlc = _as_int(_field(raw, "dlc", 0)) or 0
142
+ is_fd = bool(_field(raw, "is_fd", False))
143
+
144
+ frame = FrameDefinition(
145
+ bus_id=bus_id,
146
+ frame_id=frame_id if frame_id is not None else -1,
147
+ is_extended=is_extended,
148
+ name=str(_field(raw, "name", "") or ""),
149
+ dlc=dlc,
150
+ signals=signals,
151
+ is_fd=is_fd,
152
+ senders=tuple(str(s) for s in (_field(raw, "senders") or ())),
153
+ cycle_time_ms=_as_int(_field(raw, "cycle_time_ms")),
154
+ owner_kind=owner_kind,
155
+ component_id=component_id,
156
+ component_name=component_name,
157
+ source_index=index,
158
+ contained_messages=contained,
159
+ )
160
+ reason = _unsupported_reason(frame, raw_frame_id=frame_id)
161
+ if reason is None:
162
+ return frame
163
+ return FrameDefinition(**{**frame.__dict__, "unsupported_reason": reason})
164
+
165
+
166
+ def _unsupported_reason(frame: FrameDefinition, *, raw_frame_id: Optional[int]) -> Optional[str]:
167
+ """Why this frame cannot be composed, or ``None`` when it can.
168
+
169
+ Only the structural checks live here. Whether a signal actually fits inside
170
+ the payload is ``cantools``' answer, given at encode time, because a second
171
+ implementation of bit-layout rules would eventually disagree with the one
172
+ doing the packing.
173
+ """
174
+ if raw_frame_id is None:
175
+ return "frame has no usable numeric id"
176
+ if not frame_id_is_valid(frame.frame_id, frame.is_extended):
177
+ width = "29-bit extended" if frame.is_extended else "11-bit standard"
178
+ return f"frame id 0x{frame.frame_id:X} does not fit a {width} identifier"
179
+ if frame.contained_messages:
180
+ return (
181
+ f"container frame carrying {len(frame.contained_messages)} contained PDUs; "
182
+ "header selection and per-PDU encoding are not supported"
183
+ )
184
+ if frame.is_fd:
185
+ if frame.dlc not in FD_PAYLOAD_LENGTHS:
186
+ return f"CAN FD has no {frame.dlc}-byte payload length"
187
+ elif frame.dlc > 8:
188
+ return f"classic CAN carries at most 8 bytes, not {frame.dlc}"
189
+
190
+ return _multiplexing_reason(frame)
191
+
192
+
193
+ def _multiplexing_reason(frame: FrameDefinition) -> Optional[str]:
194
+ """Whether the frame's multiplexing is complete and self-consistent."""
195
+ switches = [s.name for s in frame.signals if s.is_multiplexer]
196
+ branches = [s for s in frame.signals if s.multiplexer_ids]
197
+
198
+ if len(switches) > 1:
199
+ return f"frame declares {len(switches)} multiplexer switches: {', '.join(sorted(switches))}"
200
+ if branches and not switches:
201
+ names = ", ".join(sorted(s.name for s in branches)[:3])
202
+ return f"multiplexed signals ({names}) but no signal is marked as the switch"
203
+ if switches and not branches:
204
+ # A switch nobody branches on is odd but encodable: it is just an
205
+ # ordinary field. Saying so beats refusing a frame that works.
206
+ return None
207
+ for signal in branches:
208
+ if signal.multiplexer_signal and switches and signal.multiplexer_signal != switches[0]:
209
+ return (
210
+ f"signal {signal.name!r} is multiplexed by {signal.multiplexer_signal!r}, "
211
+ f"which is not this frame's switch {switches[0]!r}"
212
+ )
213
+ return None
214
+
215
+
216
+ def _bus_rows(target: Any) -> List[Any]:
217
+ return list(_field(target, "buses") or ())
218
+
219
+
220
+ def _component_rows(target: Any) -> List[Any]:
221
+ return list(_field(target, "components") or ())
222
+
223
+
224
+ def _can_facet_of(component: Any) -> Optional[Any]:
225
+ facets = _field(component, "facets") or {}
226
+ if isinstance(facets, Mapping):
227
+ return facets.get(CAN_FACET_KEY)
228
+ return getattr(facets, CAN_FACET_KEY, None)
229
+
230
+
231
+ class TargetCanCatalog:
232
+ """Every CAN frame a target documents, indexed by bus and identity.
233
+
234
+ Built once per target snapshot and then read many times. A capture loop
235
+ resolving per frame at thousands of frames a second is the difference
236
+ between keeping up and dropping traffic, so nothing here is lazy.
237
+ """
238
+
239
+ def __init__(self, buses: Sequence[BusDefinition], target_id: Optional[str] = None) -> None:
240
+ self._buses: Tuple[BusDefinition, ...] = tuple(buses)
241
+ self._by_id: Dict[str, BusDefinition] = {bus.bus_id: bus for bus in self._buses}
242
+ self.target_id = target_id
243
+
244
+ @classmethod
245
+ def from_target(cls, target: Any) -> "TargetCanCatalog":
246
+ """Read a target mapping or model into a catalogue.
247
+
248
+ The argument is never modified. Buses that are not CAN are skipped
249
+ rather than rejected: an Ethernet segment on the same target is not an
250
+ error, it is simply not ours.
251
+ """
252
+ if target is None:
253
+ raise CanDefinitionError("no target supplied")
254
+
255
+ components = _component_rows(target)
256
+ buses: List[BusDefinition] = []
257
+
258
+ for bus in _bus_rows(target):
259
+ if str(_field(bus, "type", "") or "").lower() != "can":
260
+ continue
261
+ bus_id = str(_field(bus, "bus_id", "") or "")
262
+ if not bus_id:
263
+ continue
264
+
265
+ collected: List[FrameDefinition] = []
266
+
267
+ properties = _field(bus, "properties") or {}
268
+ for index, raw in enumerate(_field(properties, "messages") or ()):
269
+ collected.append(_frame_from(raw, bus_id=bus_id, owner_kind="bus", index=index))
270
+
271
+ for component in components:
272
+ facet = _can_facet_of(component)
273
+ if facet is None:
274
+ continue
275
+ if str(_field(facet, "bus_id", "") or "") != bus_id:
276
+ continue
277
+ component_id = _optional_str(_field(component, "component_id"))
278
+ component_name = _optional_str(_field(component, "name"))
279
+ for index, raw in enumerate(_field(facet, "messages") or ()):
280
+ collected.append(
281
+ _frame_from(
282
+ raw,
283
+ bus_id=bus_id,
284
+ owner_kind="component",
285
+ index=index,
286
+ component_id=component_id,
287
+ component_name=component_name,
288
+ )
289
+ )
290
+
291
+ frames, conflicts = _resolve_duplicates(collected)
292
+ buses.append(
293
+ BusDefinition(
294
+ bus_id=bus_id,
295
+ name=str(_field(bus, "name", bus_id) or bus_id),
296
+ frames=tuple(frames),
297
+ conflicts=tuple(conflicts),
298
+ )
299
+ )
300
+
301
+ return cls(buses, target_id=_optional_str(_field(target, "target_id")))
302
+
303
+ @property
304
+ def buses(self) -> Tuple[BusDefinition, ...]:
305
+ return self._buses
306
+
307
+ def bus(self, bus_id: str) -> BusDefinition:
308
+ """The named CAN bus, or a failure that says which ids do exist."""
309
+ found = self._by_id.get(bus_id)
310
+ if found is None:
311
+ known = ", ".join(sorted(self._by_id)) or "none"
312
+ raise CanDefinitionError(
313
+ f"target has no CAN bus {bus_id!r} (CAN buses on this target: {known})"
314
+ )
315
+ return found
316
+
317
+ def frames(self, bus_id: str) -> Tuple[FrameDefinition, ...]:
318
+ return self.bus(bus_id).frames
319
+
320
+ def resolve(
321
+ self,
322
+ bus_id: str,
323
+ frame_id: int,
324
+ is_extended: bool = False,
325
+ *,
326
+ expected_name: Optional[str] = None,
327
+ ) -> FrameDefinition:
328
+ """The one frame matching this identity, or a stated reason it is not usable.
329
+
330
+ ``expected_name`` is a staleness check, never a lookup key. A form built
331
+ against a target that has since been re-imported may hold a name that no
332
+ longer belongs to this id, and encoding it anyway would send the right
333
+ bytes for the wrong frame.
334
+ """
335
+ bus = self.bus(bus_id)
336
+
337
+ if not frame_id_is_valid(frame_id, is_extended):
338
+ width = "29-bit extended" if is_extended else "11-bit standard"
339
+ raise CanDefinitionError(
340
+ f"frame id {frame_id!r} does not fit a {width} identifier"
341
+ )
342
+
343
+ if _identity_key(frame_id, is_extended) in bus.conflicts:
344
+ raise CanDefinitionError(
345
+ f"frame 0x{frame_id:X} on bus {bus_id!r} has conflicting definitions "
346
+ "that disagree on how to encode it; fix the target before sending"
347
+ )
348
+
349
+ frame = bus.frame(frame_id, is_extended)
350
+ if frame is None:
351
+ kind = "extended" if is_extended else "standard"
352
+ raise CanDefinitionError(
353
+ f"bus {bus_id!r} documents no {kind} frame 0x{frame_id:X}"
354
+ )
355
+
356
+ if expected_name and frame.name != expected_name:
357
+ raise CanDefinitionError(
358
+ f"frame 0x{frame_id:X} on bus {bus_id!r} is {frame.name!r}, "
359
+ f"not {expected_name!r}; the target changed since this form was built"
360
+ )
361
+
362
+ if not frame.is_supported:
363
+ raise CanDefinitionError(
364
+ f"frame {frame.name!r} (0x{frame_id:X}) cannot be composed: "
365
+ f"{frame.unsupported_reason}"
366
+ )
367
+
368
+ return frame
369
+
370
+
371
+ def _identity_key(frame_id: int, is_extended: bool) -> str:
372
+ return f"{'x' if is_extended else 's'}{frame_id:X}"
373
+
374
+
375
+ def _resolve_duplicates(
376
+ frames: Iterable[FrameDefinition],
377
+ ) -> Tuple[List[FrameDefinition], List[str]]:
378
+ """Collapse identical definitions and mark incompatible ones as conflicts.
379
+
380
+ Two components declaring the same frame the same way is ordinary and
381
+ deduplicates silently. Two documents describing one identity differently is
382
+ a fact about the target that no caller may paper over, so the frame is
383
+ still listed -- with a reason -- and every attempt to use it fails.
384
+ """
385
+ grouped: Dict[Tuple[int, bool], List[FrameDefinition]] = {}
386
+ for frame in frames:
387
+ grouped.setdefault((frame.frame_id, frame.is_extended), []).append(frame)
388
+
389
+ resolved: List[FrameDefinition] = []
390
+ conflicts: List[str] = []
391
+
392
+ for (frame_id, is_extended), candidates in grouped.items():
393
+ distinct: List[FrameDefinition] = []
394
+ for candidate in candidates:
395
+ if not any(candidate.encoding_key() == kept.encoding_key() for kept in distinct):
396
+ distinct.append(candidate)
397
+
398
+ if len(distinct) == 1:
399
+ resolved.append(distinct[0])
400
+ continue
401
+
402
+ conflicts.append(_identity_key(frame_id, is_extended))
403
+ sources = ", ".join(sorted(_describe_source(f) for f in distinct))
404
+ first = distinct[0]
405
+ resolved.append(
406
+ FrameDefinition(
407
+ **{
408
+ **first.__dict__,
409
+ "unsupported_reason": (
410
+ f"{len(distinct)} incompatible definitions for this identity "
411
+ f"({sources}); the target has to say which one is right"
412
+ ),
413
+ }
414
+ )
415
+ )
416
+
417
+ resolved.sort(key=lambda f: (f.frame_id, f.is_extended))
418
+ return resolved, conflicts
419
+
420
+
421
+ def _describe_source(frame: FrameDefinition) -> str:
422
+ if frame.owner_kind == "component":
423
+ who = frame.component_name or frame.component_id or "unnamed component"
424
+ return f"{frame.name!r} from {who}"
425
+ return f"{frame.name!r} from the bus"