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,455 @@
1
+ """Physical signal values to bytes, and bytes back to physical signal values.
2
+
3
+ ``cantools`` is the authority on bit placement, and this module's job is to
4
+ hand it a faithfully reconstructed message and get out of the way. Big-endian
5
+ straddling, signed two's complement across a byte boundary, and multiplexed
6
+ layouts are exactly the places a hand-rolled packer looks right and is wrong,
7
+ which is why none of that arithmetic appears here.
8
+
9
+ Encoding and decoding share one reconstruction on purpose. The inverse costs a
10
+ function rather than a module, and it buys evidence a golden vector cannot: a
11
+ round trip proves the layout, whereas a hand-written vector and a mistaken
12
+ encoder can agree while both misplace the same bits.
13
+
14
+ They are not symmetric, though, and the asymmetry is the point:
15
+
16
+ * Encoding is strict. An unknown signal name, a missing active signal, or an
17
+ out-of-range value is an operator error and fails loudly.
18
+ * Decoding never raises for bad data. Its input came off a wire and is not
19
+ trusted, so a malformed payload returns a described failure. A capture that
20
+ dies on one corrupt frame is not a capture.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from decimal import Decimal, InvalidOperation
26
+ from typing import Any, Dict, Mapping, Optional, Tuple
27
+
28
+ from cantools.database.can.message import Message
29
+ from cantools.database.can.signal import Signal
30
+ from cantools.database.conversion import BaseConversion
31
+
32
+ from iotsploit_protocols.canbus.definitions import (
33
+ DecodedFrame,
34
+ EncodedFrame,
35
+ FrameDefinition,
36
+ SignalDefinition,
37
+ )
38
+ from iotsploit_protocols.canbus.errors import CanDefinitionError, CanValueError
39
+
40
+
41
+ #: The largest payload either CAN carries. Not a policy choice -- classic CAN
42
+ #: is 8 bytes and CAN FD is 64, and a definition claiming more does not
43
+ #: describe a frame.
44
+ MAX_PAYLOAD_BYTES = 64
45
+
46
+
47
+ def _check_fits_a_frame(definition: FrameDefinition) -> None:
48
+ """Reject a layout no wire could carry, before ``cantools`` prices it.
49
+
50
+ ``strict=True`` below would reject these too, but it computes the layout
51
+ first, and that computation is quadratic in the payload length: a ``dlc``
52
+ of 32768 takes ten seconds and 65536 does not finish. A definition reaches
53
+ here straight from an ARXML import or a hand edit -- ``TargetCanCatalog``
54
+ records an oversized frame as unsupported rather than raising, so nothing
55
+ upstream guarantees these bounds. Checking them costs a comparison.
56
+ """
57
+ limit = MAX_PAYLOAD_BYTES if definition.is_fd else 8
58
+ dlc = definition.dlc
59
+ # The type is checked, not assumed: a definition arrives from an importer
60
+ # or a hand edit, and comparing a str with ``<`` raises a TypeError this
61
+ # function does not declare.
62
+ if not isinstance(dlc, int) or isinstance(dlc, bool) or not 0 <= dlc <= limit:
63
+ raise CanDefinitionError(
64
+ f"frame {definition.name!r} claims a {dlc!r}-byte payload; "
65
+ f"{'CAN FD' if definition.is_fd else 'classic CAN'} carries at most {limit}"
66
+ )
67
+
68
+ bits = max(dlc * 8, 1)
69
+ for signal in definition.signals:
70
+ start, length = signal.start_bit, signal.length
71
+ numbers = all(
72
+ isinstance(value, int) and not isinstance(value, bool)
73
+ for value in (start, length)
74
+ )
75
+ if not numbers or not 0 <= start < bits or not 0 < length <= bits:
76
+ raise CanDefinitionError(
77
+ f"frame {definition.name!r} places signal {signal.name!r} at bit "
78
+ f"{start!r} length {length!r}, outside its {bits}-bit payload"
79
+ )
80
+ # The conversion fields are checked here for the same reason as the
81
+ # layout: cantools raises its own TypeError for a non-numeric scale,
82
+ # and TypeError is not what decode_frame catches -- so a definition
83
+ # whose factor arrived as text escaped a function that promises never
84
+ # to raise at all.
85
+ for field in ("factor", "offset", "minimum", "maximum"):
86
+ value = getattr(signal, field, None)
87
+ if value is None and field in ("minimum", "maximum"):
88
+ continue
89
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
90
+ raise CanDefinitionError(
91
+ f"frame {definition.name!r} signal {signal.name!r} has a "
92
+ f"non-numeric {field} {value!r}"
93
+ )
94
+
95
+
96
+ def build_message(definition: FrameDefinition) -> Message:
97
+ """Reconstruct the ``cantools`` message this definition describes.
98
+
99
+ ``strict=True`` is what makes a signal reaching past the payload a failure
100
+ here rather than a silently truncated frame on the wire. The catalogue does
101
+ the cheap structural checks; this is where the layout itself is judged, by
102
+ the same library that will pack it.
103
+ """
104
+ if definition.contained_messages:
105
+ raise CanDefinitionError(
106
+ f"frame {definition.name!r} is a container frame and cannot be encoded"
107
+ )
108
+ _check_fits_a_frame(definition)
109
+
110
+ signals = [_build_signal(s) for s in definition.signals]
111
+ try:
112
+ return Message(
113
+ frame_id=definition.frame_id,
114
+ name=definition.name or f"frame_{definition.frame_id:X}",
115
+ length=definition.dlc,
116
+ signals=signals,
117
+ is_extended_frame=definition.is_extended,
118
+ is_fd=definition.is_fd,
119
+ senders=list(definition.senders) or None,
120
+ cycle_time=definition.cycle_time_ms,
121
+ strict=True,
122
+ )
123
+ except Exception as error:
124
+ # cantools raises its own error types for overlapping and overlong
125
+ # layouts. Re-spelled here so a caller catches one name, and with the
126
+ # frame named because "signal does not fit" alone is undiagnosable.
127
+ raise CanDefinitionError(
128
+ f"frame {definition.name!r} (0x{definition.frame_id:X}) has an unusable "
129
+ f"layout: {error}"
130
+ ) from error
131
+
132
+
133
+ def _build_signal(definition: SignalDefinition) -> Signal:
134
+ conversion = BaseConversion.factory(
135
+ scale=definition.factor,
136
+ offset=definition.offset,
137
+ choices=dict(definition.choices) if definition.choices else None,
138
+ is_float=definition.is_float,
139
+ )
140
+ return Signal(
141
+ name=definition.name,
142
+ start=definition.start_bit,
143
+ length=definition.length,
144
+ byte_order="big_endian" if definition.byte_order == "big" else "little_endian",
145
+ is_signed=definition.signed,
146
+ conversion=conversion,
147
+ minimum=definition.minimum,
148
+ maximum=definition.maximum,
149
+ unit=definition.unit or None,
150
+ is_multiplexer=definition.is_multiplexer,
151
+ multiplexer_ids=list(definition.multiplexer_ids) or None,
152
+ multiplexer_signal=definition.multiplexer_signal,
153
+ )
154
+
155
+
156
+ class CanCodec:
157
+ """A codec that remembers the messages it has already reconstructed.
158
+
159
+ Rebuilding a ``cantools`` message per frame at a few thousand frames a
160
+ second is the difference between a capture that keeps up and one that drops
161
+ traffic. The cache is keyed on the definition's own encoding fields, never
162
+ on a target id, so a target edited underneath it cannot serve a stale
163
+ layout.
164
+ """
165
+
166
+ def __init__(self) -> None:
167
+ self._messages: Dict[Tuple[Any, ...], Message] = {}
168
+
169
+ def message(self, definition: FrameDefinition) -> Message:
170
+ key = definition.encoding_key()
171
+ message = self._messages.get(key)
172
+ if message is None:
173
+ message = build_message(definition)
174
+ self._messages[key] = message
175
+ return message
176
+
177
+ def encode(
178
+ self, definition: FrameDefinition, values: Mapping[str, Any]
179
+ ) -> EncodedFrame:
180
+ return encode_frame(definition, values, message=self.message(definition))
181
+
182
+ def decode(self, definition: FrameDefinition, data: bytes) -> DecodedFrame:
183
+ return decode_frame(definition, data, message=self.message(definition))
184
+
185
+
186
+ def encode_frame(
187
+ definition: FrameDefinition,
188
+ values: Mapping[str, Any],
189
+ *,
190
+ message: Optional[Message] = None,
191
+ ) -> EncodedFrame:
192
+ """Pack physical signal values into the frame's payload.
193
+
194
+ Every signal active for the selected multiplexer branch must be supplied.
195
+ Nothing is defaulted to zero: a frame sent with an unstated field silently
196
+ filled in is a frame the operator did not compose, and on a live bus that
197
+ distinction is the whole point.
198
+ """
199
+ message = message or build_message(definition)
200
+ if not isinstance(values, Mapping):
201
+ raise CanValueError("signal values must be supplied as a mapping of name to value")
202
+
203
+ active, field_errors = _active_signals(definition, values)
204
+ if field_errors:
205
+ raise CanValueError(_summarize(field_errors), field_errors)
206
+
207
+ normalized: Dict[str, Any] = {}
208
+ for name in active:
209
+ signal = definition.signal(name)
210
+ assert signal is not None # active names come from the definition
211
+ try:
212
+ normalized[name] = _coerce(signal, values[name])
213
+ except CanValueError as error:
214
+ field_errors[f"signals.{name}"] = str(error)
215
+
216
+ if field_errors:
217
+ raise CanValueError(_summarize(field_errors), field_errors)
218
+
219
+ try:
220
+ data = message.encode(normalized, scaling=True, strict=True)
221
+ except Exception as error:
222
+ # cantools reports the offending signal in its message but not as
223
+ # structured data, so the whole frame carries the failure and the
224
+ # editor shows it above the rows rather than against a guessed one.
225
+ raise CanValueError(f"frame {definition.name!r} could not be encoded: {error}") from error
226
+
227
+ return EncodedFrame(
228
+ frame_id=definition.frame_id,
229
+ is_extended=definition.is_extended,
230
+ is_fd=definition.is_fd,
231
+ dlc=len(data),
232
+ data=bytes(data),
233
+ name=definition.name,
234
+ signals=_readable(normalized),
235
+ )
236
+
237
+
238
+ def decode_frame(
239
+ definition: FrameDefinition,
240
+ data: bytes,
241
+ *,
242
+ message: Optional[Message] = None,
243
+ ) -> DecodedFrame:
244
+ """Read a payload into named physical values.
245
+
246
+ Returns a failure rather than raising, for every kind of bad input. The
247
+ multiplexer branch is chosen from the bytes themselves and never from a
248
+ caller's claim about which branch this is -- a decoder that takes that on
249
+ trust reports one branch's names over another branch's bits.
250
+ """
251
+ try:
252
+ message = message or build_message(definition)
253
+ except CanDefinitionError as error:
254
+ return DecodedFrame.failed(definition.name, str(error))
255
+
256
+ if data is None:
257
+ return DecodedFrame.failed(definition.name, "no payload to decode")
258
+ payload = bytes(data)
259
+
260
+ if len(payload) != definition.dlc:
261
+ # Stated, not silently padded or truncated: a frame arriving shorter
262
+ # than its definition is a finding about the bus or the definition, and
263
+ # decoding the bytes that are there would hide it.
264
+ return DecodedFrame.failed(
265
+ definition.name,
266
+ f"payload is {len(payload)} bytes, definition declares {definition.dlc}",
267
+ )
268
+
269
+ try:
270
+ named = message.decode(payload, decode_choices=True, scaling=True)
271
+ raw = message.decode(payload, decode_choices=False, scaling=False)
272
+ except Exception as error:
273
+ return DecodedFrame.failed(definition.name, f"{type(error).__name__}: {error}")
274
+
275
+ if not isinstance(named, Mapping) or not isinstance(raw, Mapping):
276
+ return DecodedFrame.failed(definition.name, "decoder returned no signal values")
277
+
278
+ signals = _readable(named)
279
+ raw_values = {
280
+ key: int(value)
281
+ for key, value in raw.items()
282
+ if isinstance(value, (int, float)) and not isinstance(value, bool)
283
+ }
284
+
285
+ # A code the value table does not label decodes to its number, and saying
286
+ # so beats reporting a bare integer that looks like a scaled value.
287
+ unlabelled = sorted(
288
+ name
289
+ for name, value in signals.items()
290
+ if _has_choices(definition, name) and not isinstance(value, str)
291
+ )
292
+ reason = (
293
+ "no label for the value of " + ", ".join(unlabelled) if unlabelled else None
294
+ )
295
+
296
+ return DecodedFrame(
297
+ ok=True,
298
+ name=definition.name,
299
+ signals=signals,
300
+ raw_values=raw_values,
301
+ reason=reason,
302
+ )
303
+
304
+
305
+ def _has_choices(definition: FrameDefinition, name: str) -> bool:
306
+ signal = definition.signal(name)
307
+ return bool(signal and signal.choices)
308
+
309
+
310
+ def _active_signals(
311
+ definition: FrameDefinition, values: Mapping[str, Any]
312
+ ) -> Tuple[Tuple[str, ...], Dict[str, str]]:
313
+ """Which signals this frame needs, given the multiplexer value supplied.
314
+
315
+ Returns the required names and any errors about the *set* of values --
316
+ missing, unknown, or belonging to a branch that is not selected. Errors
317
+ about a value itself are raised later, once the set is known to be right.
318
+ """
319
+ errors: Dict[str, str] = {}
320
+ switch_name = definition.multiplexer_signal_name
321
+
322
+ common = tuple(s.name for s in definition.signals if not s.multiplexer_ids)
323
+ active = list(common)
324
+ selected: Optional[int] = None
325
+
326
+ if switch_name is not None:
327
+ if switch_name not in values:
328
+ # Resolved before anything else because which signals are even
329
+ # required depends on the answer.
330
+ errors[f"signals.{switch_name}"] = (
331
+ "the multiplexer value is required before the other signals are known"
332
+ )
333
+ return tuple(active), errors
334
+
335
+ switch = definition.signal(switch_name)
336
+ assert switch is not None
337
+ try:
338
+ selected = _raw_choice(switch, values[switch_name])
339
+ except CanValueError as error:
340
+ errors[f"signals.{switch_name}"] = str(error)
341
+ return tuple(active), errors
342
+
343
+ branch = [s.name for s in definition.signals if selected in s.multiplexer_ids]
344
+ active.extend(branch)
345
+
346
+ active_set = set(active)
347
+
348
+ for name in active:
349
+ if name not in values:
350
+ errors[f"signals.{name}"] = "required"
351
+
352
+ for name in values:
353
+ if name in active_set:
354
+ continue
355
+ signal = definition.signal(name)
356
+ if signal is None:
357
+ errors[f"signals.{name}"] = f"frame {definition.name!r} has no signal by this name"
358
+ elif selected is not None:
359
+ branches = ", ".join(f"{i}" for i in signal.multiplexer_ids)
360
+ errors[f"signals.{name}"] = (
361
+ f"only present when {switch_name} is {branches}, not {selected}"
362
+ )
363
+ else:
364
+ errors[f"signals.{name}"] = "not active for this frame"
365
+
366
+ return tuple(active), errors
367
+
368
+
369
+ def _raw_choice(signal: SignalDefinition, value: Any) -> int:
370
+ """The integer behind a multiplexer value, whether given as a label or a number."""
371
+ if isinstance(value, str) and signal.choices:
372
+ for code, label in signal.choices.items():
373
+ if label == value:
374
+ return code
375
+ coerced = _coerce(signal, value)
376
+ if isinstance(coerced, str):
377
+ raise CanValueError(f"{value!r} is not a value of this multiplexer")
378
+ try:
379
+ return int(coerced)
380
+ except (TypeError, ValueError):
381
+ raise CanValueError(f"{value!r} is not a whole number") from None
382
+
383
+
384
+ def _coerce(signal: SignalDefinition, value: Any) -> Any:
385
+ """One operator-supplied value, normalized without losing precision.
386
+
387
+ Numbers arrive as strings from the editor deliberately: Dart rounds an
388
+ integer wider than 53 bits before it ever reaches JSON, so the text is
389
+ parsed here instead. ``Decimal`` does that parse exactly, and only becomes
390
+ a float when the signal's own scaling means it has to.
391
+ """
392
+ if value is None:
393
+ raise CanValueError("required")
394
+
395
+ if isinstance(value, bool):
396
+ return int(value)
397
+
398
+ if isinstance(value, str):
399
+ text = value.strip()
400
+ if not text:
401
+ raise CanValueError("required")
402
+ if signal.choices and any(label == text for label in signal.choices.values()):
403
+ return text
404
+ try:
405
+ number = Decimal(text)
406
+ except InvalidOperation:
407
+ if signal.choices:
408
+ known = ", ".join(sorted(signal.choices.values()))
409
+ raise CanValueError(f"{text!r} is not one of: {known}") from None
410
+ raise CanValueError(f"{text!r} is not a number") from None
411
+ return _from_decimal(number)
412
+
413
+ if isinstance(value, int):
414
+ return value
415
+ if isinstance(value, float):
416
+ return value
417
+
418
+ raise CanValueError(f"{type(value).__name__} is not a usable signal value")
419
+
420
+
421
+ def _from_decimal(number: Decimal) -> Any:
422
+ """An int when the text was integral, else a float.
423
+
424
+ Keeping integers as ``int`` is what preserves a 64-bit value: routing it
425
+ through ``float`` would round it in the last bits and encode a number the
426
+ operator never typed.
427
+ """
428
+ if number == number.to_integral_value():
429
+ return int(number)
430
+ return float(number)
431
+
432
+
433
+ def _readable(values: Mapping[str, Any]) -> Dict[str, Any]:
434
+ """Signal values as JSON-friendly types.
435
+
436
+ ``cantools`` returns ``NamedSignalValue`` for a labelled code, which is not
437
+ a ``str`` and is not JSON serializable, so a response built straight from a
438
+ decode result fails at the transport rather than here.
439
+ """
440
+ readable: Dict[str, Any] = {}
441
+ for name, value in values.items():
442
+ if isinstance(value, (int, float, str)) and not isinstance(value, bool):
443
+ readable[name] = value
444
+ elif isinstance(value, bool):
445
+ readable[name] = int(value)
446
+ else:
447
+ readable[name] = str(value)
448
+ return readable
449
+
450
+
451
+ def _summarize(field_errors: Mapping[str, str]) -> str:
452
+ names = sorted(key.split(".", 1)[-1] for key in field_errors)
453
+ if len(names) == 1:
454
+ return f"signal {names[0]}: {list(field_errors.values())[0]}"
455
+ return f"{len(names)} signals need attention: {', '.join(names)}"