diff --git a/examples/somersault.pdx b/examples/somersault.pdx index 92419f8d..61e12398 100644 Binary files a/examples/somersault.pdx and b/examples/somersault.pdx differ diff --git a/odxtools/decodestate.py b/odxtools/decodestate.py index e414bf1d..29464138 100644 --- a/odxtools/decodestate.py +++ b/odxtools/decodestate.py @@ -15,18 +15,6 @@ if TYPE_CHECKING: from .tablerow import TableRow -# format specifiers for the data type using the bitstruct module -ODX_TYPE_TO_FORMAT_LETTER = { - DataType.A_INT32: "s", - DataType.A_UINT32: "u", - DataType.A_FLOAT32: "f", - DataType.A_FLOAT64: "f", - DataType.A_BYTEFIELD: "r", - DataType.A_UNICODE2STRING: "r", # UTF-16 strings must be converted explicitly - DataType.A_ASCIISTRING: "r", - DataType.A_UTF8STRING: "r", -} - @dataclass class DecodeState: @@ -94,7 +82,7 @@ def extract_atomic_value( padding = (8 - (bit_length + self.cursor_bit_position) % 8) % 8 internal_value, = bitstruct.unpack_from( - f"{ODX_TYPE_TO_FORMAT_LETTER[base_data_type]}{bit_length}", + f"{base_data_type.bitstruct_format_letter}{bit_length}", extracted_bytes, offset=padding) diff --git a/odxtools/diagcodedtype.py b/odxtools/diagcodedtype.py index 495be50f..56894cbc 100644 --- a/odxtools/diagcodedtype.py +++ b/odxtools/diagcodedtype.py @@ -1,19 +1,14 @@ # SPDX-License-Identifier: MIT import abc from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Union -from .decodestate import ODX_TYPE_TO_FORMAT_LETTER, DecodeState +from .decodestate import DecodeState from .encodestate import EncodeState -from .exceptions import EncodeError, odxassert, odxraise +from .exceptions import odxassert, odxraise from .odxlink import OdxLinkDatabase, OdxLinkId from .odxtypes import AtomicOdxType, DataType -try: - import bitstruct.c as bitstruct -except ImportError: - import bitstruct - if TYPE_CHECKING: from .diaglayer import DiagLayer @@ -56,90 +51,6 @@ def dct_type(self) -> DctType: def is_highlow_byte_order(self) -> bool: return self.is_highlow_byte_order_raw in [None, True] - @staticmethod - def _encode_internal_value( - *, - internal_value: AtomicOdxType, - bit_position: int, - bit_length: int, - base_data_type: DataType, - is_highlow_byte_order: bool, - ) -> bytes: - """Convert the internal_value to bytes and emplace this into the PDU""" - # Check that bytes and strings actually fit into the bit length - if base_data_type == DataType.A_BYTEFIELD: - if isinstance(internal_value, bytearray): - internal_value = bytes(internal_value) - if not isinstance(internal_value, bytes): - odxraise() - if 8 * len(internal_value) > bit_length: - raise EncodeError(f"The bytefield {internal_value.hex()} is too large " - f"({len(internal_value)} bytes)." - f" The maximum length is {bit_length//8}.") - if base_data_type == DataType.A_ASCIISTRING: - if not isinstance(internal_value, str): - odxraise() - - # The spec says ASCII, meaning only byte values 0-127. - # But in practice, vendors use iso-8859-1, aka latin-1 - # reason being iso-8859-1 never fails since it has a valid - # character mapping for every possible byte sequence. - internal_value = internal_value.encode("iso-8859-1") - - if 8 * len(internal_value) > bit_length: - raise EncodeError(f"The string {repr(internal_value)} is too large." - f" The maximum number of characters is {bit_length//8}.") - elif base_data_type == DataType.A_UTF8STRING: - if not isinstance(internal_value, str): - odxraise() - - internal_value = internal_value.encode("utf-8") - - if 8 * len(internal_value) > bit_length: - raise EncodeError(f"The string {repr(internal_value)} is too large." - f" The maximum number of bytes is {bit_length//8}.") - - elif base_data_type == DataType.A_UNICODE2STRING: - if not isinstance(internal_value, str): - odxraise() - - text_encoding = "utf-16-be" if is_highlow_byte_order else "utf-16-le" - internal_value = internal_value.encode(text_encoding) - - if 8 * len(internal_value) > bit_length: - raise EncodeError(f"The string {repr(internal_value)} is too large." - f" The maximum number of characters is {bit_length//16}.") - - # If the bit length is zero, return empty bytes - if bit_length == 0: - if (base_data_type.value in [ - DataType.A_INT32, DataType.A_UINT32, DataType.A_FLOAT32, DataType.A_FLOAT64 - ] and base_data_type.value != 0): - odxraise( - f"The number {repr(internal_value)} cannot be encoded into {bit_length} bits.", - EncodeError) - return b'' - - char = ODX_TYPE_TO_FORMAT_LETTER[base_data_type] - padding = (8 - ((bit_length + bit_position) % 8)) % 8 - odxassert((0 <= padding and padding < 8 and (padding + bit_length + bit_position) % 8 == 0), - f"Incorrect padding {padding}") - left_pad = f"p{padding}" if padding > 0 else "" - - # actually encode the value - coded = bitstruct.pack(f"{left_pad}{char}{bit_length}", internal_value) - - # apply byte order for numeric objects - if not is_highlow_byte_order and base_data_type in [ - DataType.A_INT32, - DataType.A_UINT32, - DataType.A_FLOAT32, - DataType.A_FLOAT64, - ]: - coded = coded[::-1] - - return cast(bytes, coded) - def _minimal_byte_length_of(self, internal_value: Union[bytes, str]) -> int: """Helper method to get the minimal byte length. (needed for LeadingLength- and MinMaxLengthType) diff --git a/odxtools/dynamiclengthfield.py b/odxtools/dynamiclengthfield.py index acd5f924..68c793c0 100644 --- a/odxtools/dynamiclengthfield.py +++ b/odxtools/dynamiclengthfield.py @@ -82,7 +82,7 @@ def encode_into_pdu(self, physical_value: ParameterValue, encode_state: EncodeSt # ensure the correct message size if the field is empty if len(physical_value) == 0: - encode_state.emplace_bytes(b"", "") + encode_state.emplace_bytes(b"") # move cursor and origin positions encode_state.origin_byte_position = orig_origin diff --git a/odxtools/encodestate.py b/odxtools/encodestate.py index 69caedd8..1ba58d21 100644 --- a/odxtools/encodestate.py +++ b/odxtools/encodestate.py @@ -3,7 +3,13 @@ from dataclasses import dataclass, field from typing import Dict, Optional -from .exceptions import OdxWarning +from .exceptions import EncodeError, OdxWarning, odxassert, odxraise +from .odxtypes import AtomicOdxType, DataType + +try: + import bitstruct.c as bitstruct +except ImportError: + import bitstruct @dataclass @@ -47,7 +53,92 @@ class EncodeState: #: (needed for MinMaxLengthType, EndOfPduField, etc.) is_end_of_pdu: bool = True - def emplace_bytes(self, new_data: bytes, param_name: Optional[str] = None) -> None: + def emplace_atomic_value( + self, + *, + internal_value: AtomicOdxType, + bit_length: int, + base_data_type: DataType, + is_highlow_byte_order: bool, + ) -> None: + """Convert the internal_value to bytes and emplace this into the PDU""" + # Check that bytes and strings actually fit into the bit length + if base_data_type == DataType.A_BYTEFIELD: + if isinstance(internal_value, bytearray): + internal_value = bytes(internal_value) + if not isinstance(internal_value, bytes): + odxraise() + if 8 * len(internal_value) > bit_length: + raise EncodeError(f"The bytefield {internal_value.hex()} is too large " + f"({len(internal_value)} bytes)." + f" The maximum length is {bit_length//8}.") + if base_data_type == DataType.A_ASCIISTRING: + if not isinstance(internal_value, str): + odxraise() + + # The spec says ASCII, meaning only byte values 0-127. + # But in practice, vendors use iso-8859-1, aka latin-1 + # reason being iso-8859-1 never fails since it has a valid + # character mapping for every possible byte sequence. + internal_value = internal_value.encode("iso-8859-1") + + if 8 * len(internal_value) > bit_length: + raise EncodeError(f"The string {repr(internal_value)} is too large." + f" The maximum number of characters is {bit_length//8}.") + elif base_data_type == DataType.A_UTF8STRING: + if not isinstance(internal_value, str): + odxraise() + + internal_value = internal_value.encode("utf-8") + + if 8 * len(internal_value) > bit_length: + raise EncodeError(f"The string {repr(internal_value)} is too large." + f" The maximum number of bytes is {bit_length//8}.") + + elif base_data_type == DataType.A_UNICODE2STRING: + if not isinstance(internal_value, str): + odxraise() + + text_encoding = "utf-16-be" if is_highlow_byte_order else "utf-16-le" + internal_value = internal_value.encode(text_encoding) + + if 8 * len(internal_value) > bit_length: + raise EncodeError(f"The string {repr(internal_value)} is too large." + f" The maximum number of characters is {bit_length//16}.") + + # If the bit length is zero, return empty bytes + if bit_length == 0: + if (base_data_type.value in [ + DataType.A_INT32, DataType.A_UINT32, DataType.A_FLOAT32, DataType.A_FLOAT64 + ] and base_data_type.value != 0): + odxraise( + f"The number {repr(internal_value)} cannot be encoded into {bit_length} bits.", + EncodeError) + self.emplace_bytes(b'') + return + + char = base_data_type.bitstruct_format_letter + padding = (8 - ((bit_length + self.cursor_bit_position) % 8)) % 8 + odxassert((0 <= padding and padding < 8 and + (padding + bit_length + self.cursor_bit_position) % 8 == 0), + f"Incorrect padding {padding}") + left_pad = f"p{padding}" if padding > 0 else "" + + # actually encode the value + coded = bitstruct.pack(f"{left_pad}{char}{bit_length}", internal_value) + + # apply byte order for numeric objects + if not is_highlow_byte_order and base_data_type in [ + DataType.A_INT32, + DataType.A_UINT32, + DataType.A_FLOAT32, + DataType.A_FLOAT64, + ]: + coded = coded[::-1] + + self.emplace_bytes(coded) + + def emplace_bytes(self, new_data: bytes, obj_name: Optional[str] = None) -> None: pos = self.cursor_byte_position # Make blob longer if necessary @@ -60,12 +151,19 @@ def emplace_bytes(self, new_data: bytes, param_name: Optional[str] = None) -> No # the value to be inserted is bitwise "disjoint" from the # value which is already in the PDU... if self.coded_message[pos + i] & new_data[i] != 0: - param_name = "" if param_name is None else param_name - warnings.warn( - f"Object '{param_name}' overlaps with another parameter (bits are already set)", - OdxWarning, - stacklevel=1, - ) + if obj_name is not None: + warnings.warn( + f"'{obj_name}' overlaps with another object (bits to be set are already set)", + OdxWarning, + stacklevel=1, + ) + else: + warnings.warn( + "Object overlap (bits to be set are already set)", + OdxWarning, + stacklevel=1, + ) + self.coded_message[pos + i] |= new_data[i] self.cursor_byte_position += len(new_data) diff --git a/odxtools/leadinglengthinfotype.py b/odxtools/leadinglengthinfotype.py index a52f7fc8..70d1b3ec 100644 --- a/odxtools/leadinglengthinfotype.py +++ b/odxtools/leadinglengthinfotype.py @@ -55,24 +55,20 @@ def encode_into_pdu(self, internal_value: AtomicOdxType, encode_state: EncodeSta byte_length = self._minimal_byte_length_of(internal_value) - length_bytes = self._encode_internal_value( + encode_state.emplace_atomic_value( internal_value=byte_length, - bit_position=encode_state.cursor_bit_position, bit_length=self.bit_length, base_data_type=DataType.A_UINT32, is_highlow_byte_order=self.is_highlow_byte_order, ) - value_bytes = self._encode_internal_value( + encode_state.emplace_atomic_value( internal_value=internal_value, - bit_position=0, bit_length=8 * byte_length, base_data_type=self.base_data_type, is_highlow_byte_order=self.is_highlow_byte_order, ) - encode_state.emplace_bytes(length_bytes + value_bytes, "") - @override def decode_from_pdu(self, decode_state: DecodeState) -> AtomicOdxType: diff --git a/odxtools/minmaxlengthtype.py b/odxtools/minmaxlengthtype.py index 10be50c9..39027c9e 100644 --- a/odxtools/minmaxlengthtype.py +++ b/odxtools/minmaxlengthtype.py @@ -65,14 +65,14 @@ def encode_into_pdu(self, internal_value: AtomicOdxType, encode_state: EncodeSta else: data_length = len(internal_value) - value_bytes = bytearray( - self._encode_internal_value( - internal_value=internal_value, - bit_position=0, - bit_length=8 * data_length, - base_data_type=self.base_data_type, - is_highlow_byte_order=self.is_highlow_byte_order, - )) + orig_cursor = encode_state.cursor_byte_position + encode_state.emplace_atomic_value( + internal_value=internal_value, + bit_length=8 * data_length, + base_data_type=self.base_data_type, + is_highlow_byte_order=self.is_highlow_byte_order, + ) + value_len = encode_state.cursor_byte_position - orig_cursor # TODO: ensure that the termination delimiter is not # encountered within the encoded value. @@ -80,8 +80,8 @@ def encode_into_pdu(self, internal_value: AtomicOdxType, encode_state: EncodeSta odxassert( self.termination != "END-OF-PDU" or encode_state.is_end_of_pdu, "Encountered a MIN-MAX-LENGTH type with END-OF-PDU termination " - "which is not at the end of the PDU") - if encode_state.is_end_of_pdu or len(value_bytes) == self.max_length: + "which is not located at the end of the PDU") + if encode_state.is_end_of_pdu or value_len == self.max_length: # All termination types may be ended by the end of the PDU # or once reaching the maximum length. In this case, we # must not add the termination sequence @@ -91,25 +91,24 @@ def encode_into_pdu(self, internal_value: AtomicOdxType, encode_state: EncodeSta # ensure that we don't try to encode an odd-length # value when using a two-byte terminator - odxassert(len(value_bytes) % len(termination_sequence) == 0) + odxassert(value_len % len(termination_sequence) == 0) - value_bytes.extend(termination_sequence) + value_len += len(termination_sequence) + encode_state.emplace_bytes(termination_sequence) - if len(value_bytes) < self.min_length: + if value_len < self.min_length: odxraise( f"Encoded value for MinMaxLengthType " f"must be at least {self.min_length} bytes long. " - f"(Is: {len(value_bytes)} bytes.)", EncodeError) + f"(Is: {value_len} bytes.)", EncodeError) return - elif self.max_length is not None and len(value_bytes) > self.max_length: + elif self.max_length is not None and value_len > self.max_length: odxraise( f"Encoded value for MinMaxLengthType " f"must not be longer than {self.max_length} bytes. " - f"(Is: {len(value_bytes)} bytes.)", EncodeError) + f"(Is: {value_len} bytes.)", EncodeError) return - encode_state.emplace_bytes(value_bytes, "") - def decode_from_pdu(self, decode_state: DecodeState) -> AtomicOdxType: odxassert(decode_state.cursor_bit_position == 0, "No bit position can be specified for MIN-MAX-LENGTH-TYPE values.") diff --git a/odxtools/odxtypes.py b/odxtools/odxtypes.py index a79dd360..a8baad6c 100644 --- a/odxtools/odxtypes.py +++ b/odxtools/odxtypes.py @@ -154,6 +154,19 @@ def compare_odx_values(a: AtomicOdxType, b: AtomicOdxType) -> int: f"and {type(b).__name__}") +# format specifiers for the data type using the bitstruct module +_BITSTRUCT_FORMAT_LETTER_MAP__ = { + "A_INT32": "s", + "A_UINT32": "u", + "A_FLOAT32": "f", + "A_FLOAT64": "f", + "A_BYTEFIELD": "r", + "A_UNICODE2STRING": "r", # UTF-16 strings must be converted explicitly + "A_ASCIISTRING": "r", + "A_UTF8STRING": "r", +} + + class DataType(Enum): """Types for the physical and internal value. @@ -181,6 +194,10 @@ class DataType(Enum): def python_type(self) -> Type[AtomicOdxType]: return _ODX_TYPE_TO_PYTHON_TYPE[self.value] + @property + def bitstruct_format_letter(self) -> str: + return _BITSTRUCT_FORMAT_LETTER_MAP__[self.value] + def from_string(self, value: str) -> AtomicOdxType: return _PARSE_ODX_TYPE[self.value](value) diff --git a/odxtools/parameters/nrcconstparameter.py b/odxtools/parameters/nrcconstparameter.py index 487c75f0..3419dbd3 100644 --- a/odxtools/parameters/nrcconstparameter.py +++ b/odxtools/parameters/nrcconstparameter.py @@ -22,13 +22,19 @@ @dataclass class NrcConstParameter(Parameter): - """A param of type NRC-CONST defines a set of values to be matched. + """A param of type NRC-CONST defines a set of values to be matched for a negative response to apply. - An NRC-CONST can only be used in a negative response. - Its encoding behaviour is similar to a VALUE parameter with a TEXTTABLE. - However, an NRC-CONST is used for matching a response (similar to a CODED-CONST). + The behaviour of NRC-CONST parameters is similar to CODED-CONST + parameters in that they allow to specify which coding objects + apply to a binary string, but in contrast to CODED-CONST + parameters they allow to specify multiple values. Thus, the value + of a CODED-CONST parameter is usually set using an overlapping + VALUE parameter. Since NRC-CONST parameters can only be specified + for negative responses, they can thus be regarded as a multiplexer + mechanism that is specific to negative responses. See ASAM MCD-2 D (ODX), p. 77-79. + """ diag_coded_type: DiagCodedType @@ -83,12 +89,12 @@ def internal_data_type(self) -> DataType: @property @override def is_required(self) -> bool: - return len(self.coded_values) > 1 + return False @property @override def is_settable(self) -> bool: - return True + return False @override def _encode_positioned_into_pdu(self, physical_value: Optional[ParameterValue], @@ -103,12 +109,20 @@ def _encode_positioned_into_pdu(self, physical_value: Optional[ParameterValue], else: coded_value = physical_value else: - # If the user does not select a value, just select - # any. (This branch should only be taken if there is only - # one possible coded value because if there are more, - # specifying a parameter value is mandatory, - # cf. the `.is_required` property.) - coded_value = self.coded_values[0] + # If the user did not select a value, the value of the + # this parameter is set by another parameter which + # overlaps with it. We thus just move the cursor. + bit_pos = encode_state.cursor_bit_position + bit_len = self.diag_coded_type.get_static_bit_length() + + if bit_len is None: + odxraise("The diag coded type of NRC-CONST parameters must " + "exhibit a static size") + return + + encode_state.cursor_byte_position += (bit_pos + bit_len + 7) // 8 + encode_state.cursor_bit_position = 0 + return self.diag_coded_type.encode_into_pdu(cast(AtomicOdxType, coded_value), encode_state) diff --git a/odxtools/paramlengthinfotype.py b/odxtools/paramlengthinfotype.py index 67ead7a8..c7e4c1cd 100644 --- a/odxtools/paramlengthinfotype.py +++ b/odxtools/paramlengthinfotype.py @@ -79,16 +79,13 @@ def encode_into_pdu(self, internal_value: AtomicOdxType, encode_state: EncodeSta encode_state.length_keys[self.length_key.short_name] = bit_length - raw_data = self._encode_internal_value( + encode_state.emplace_atomic_value( internal_value=internal_value, - bit_position=encode_state.cursor_bit_position, bit_length=bit_length, base_data_type=self.base_data_type, is_highlow_byte_order=self.is_highlow_byte_order, ) - encode_state.emplace_bytes(raw_data, "") - def decode_from_pdu(self, decode_state: DecodeState) -> AtomicOdxType: # First, we need to find a length key with matching ID. if self.length_key.short_name not in decode_state.length_keys: diff --git a/odxtools/standardlengthtype.py b/odxtools/standardlengthtype.py index beb42e3c..273b1820 100644 --- a/odxtools/standardlengthtype.py +++ b/odxtools/standardlengthtype.py @@ -34,7 +34,8 @@ def __apply_mask(self, internal_value: AtomicOdxType) -> AtomicOdxType: if self.bit_mask is None: return internal_value if self.is_condensed_raw is True: - raise NotImplementedError("Serialization of condensed bit mask is not supported") + odxraise("Serialization of condensed bit mask is not supported", NotImplementedError) + return if isinstance(internal_value, int): return internal_value & self.bit_mask if isinstance(internal_value, bytes): @@ -50,14 +51,12 @@ def get_static_bit_length(self) -> Optional[int]: @override def encode_into_pdu(self, internal_value: AtomicOdxType, encode_state: EncodeState) -> None: - raw_data = self._encode_internal_value( + encode_state.emplace_atomic_value( internal_value=self.__apply_mask(internal_value), - bit_position=encode_state.cursor_bit_position, bit_length=self.bit_length, base_data_type=self.base_data_type, is_highlow_byte_order=self.is_highlow_byte_order, ) - encode_state.emplace_bytes(raw_data, "") @override def decode_from_pdu(self, decode_state: DecodeState) -> AtomicOdxType: diff --git a/odxtools/staticfield.py b/odxtools/staticfield.py index ae028770..a0c492bd 100644 --- a/odxtools/staticfield.py +++ b/odxtools/staticfield.py @@ -72,8 +72,8 @@ def encode_into_pdu(self, physical_value: ParameterValue, encode_state: EncodeSt encode_state.cursor_byte_position = pos_before + self.item_byte_size elif pos_after - pos_before < self.item_byte_size: # add some padding bytes - encode_state.emplace_bytes( - b'\x00' * (self.item_byte_size - (pos_after - pos_before)), "") + encode_state.emplace_bytes(b'\x00' * (self.item_byte_size - + (pos_after - pos_before))) @override def decode_from_pdu(self, decode_state: DecodeState) -> ParameterValue: diff --git a/tests/test_encoding.py b/tests/test_encoding.py index 8eeb3b9b..8eebe829 100644 --- a/tests/test_encoding.py +++ b/tests/test_encoding.py @@ -190,6 +190,23 @@ def test_encode_nrc_const(self) -> None: is_highlow_byte_order_raw=None, is_condensed_raw=None, ) + dop = DataObjectProperty( + odx_id=OdxLinkId("dop.id", doc_frags), + short_name="dop_sn", + long_name="example dop", + description=None, + admin_data=None, + diag_coded_type=diag_coded_type, + physical_type=PhysicalType(DataType.A_UINT32, display_radix=None, precision=None), + compu_method=IdenticalCompuMethod( + internal_type=DataType.A_UINT32, physical_type=DataType.A_UINT32), + unit_ref=None, + sdgs=[], + internal_constr=None, + physical_constr=None, + ) + odxlinks = OdxLinkDatabase() + odxlinks.update(dop._build_odxlinks()) param1 = CodedConstParameter( short_name="param1", long_name=None, @@ -212,6 +229,19 @@ def test_encode_nrc_const(self) -> None: bit_position=None, sdgs=[], ) + param3 = ValueParameter( + short_name="param3", + long_name=None, + description=None, + semantic=None, + dop_ref=OdxLinkRef.from_id(dop.odx_id), + dop_snref=None, + physical_default_value_raw=None, + byte_position=1, + bit_position=None, + sdgs=[], + ) + param3._resolve_odxlinks(odxlinks) resp = Response( odx_id=OdxLinkId("response_id", doc_frags), short_name="response_sn", @@ -220,13 +250,13 @@ def test_encode_nrc_const(self) -> None: admin_data=None, sdgs=[], response_type=ResponseType.POSITIVE, - parameters=NamedItemList([param1, param2]), + parameters=NamedItemList([param1, param2, param3]), byte_size=None, ) with self.assertRaises(EncodeError): - resp.encode() # "No value for required parameter param2 specified" - self.assertEqual(resp.encode(param2=0xAB), bytearray([0x12, 0xAB])) + resp.encode() # "No value for required parameter param3 specified" + self.assertEqual(resp.encode(param3=0xAB), bytearray([0x12, 0xAB])) self.assertRaises(EncodeError, resp.encode, param2=0xEF) def test_encode_overlapping(self) -> None: diff --git a/tests/test_somersault.py b/tests/test_somersault.py index 085fcb11..7c79f197 100644 --- a/tests/test_somersault.py +++ b/tests/test_somersault.py @@ -322,10 +322,9 @@ def test_free_param_info(self) -> None: expected_output = ("forward_soberness_check: uint\n" "num_flips: uint\n" "sault_time: uint\n" - "reason: NRC_const; choices = [0, 1, 2]\n" "flips_successfully_done: uint\n") actual_output = stdout.getvalue() - self.assertEqual(actual_output, expected_output) + self.assertEqual(expected_output, actual_output) def test_decode_response(self) -> None: ecu = odxdb.ecus.somersault_lazy