def _unittest_transfer_reassembler_anonymous() -> None: ts = Timestamp.now() prio = Priority.LOW assert TransferReassembler.construct_anonymous_transfer( ts, Frame(priority=prio, transfer_id=123456, index=0, end_of_transfer=True, payload=memoryview(b"abcdef")), ) == TransferFrom(timestamp=ts, priority=prio, transfer_id=123456, fragmented_payload=[memoryview(b"abcdef")], source_node_id=None) assert (TransferReassembler.construct_anonymous_transfer( ts, Frame(priority=prio, transfer_id=123456, index=1, end_of_transfer=True, payload=memoryview(b"abcdef")), ) is None) assert (TransferReassembler.construct_anonymous_transfer( ts, Frame(priority=prio, transfer_id=123456, index=0, end_of_transfer=False, payload=memoryview(b"abcdef")), ) is None)
def _unittest_transfer_reassembler_anonymous() -> None: from pyuavcan.transport import Timestamp, Priority, TransferFrom ts = Timestamp.now() prio = Priority.LOW assert TransferReassembler.construct_anonymous_transfer( Frame(timestamp=ts, priority=prio, transfer_id=123456, index=0, end_of_transfer=True, payload=memoryview(b'abcdef'))) == TransferFrom( timestamp=ts, priority=prio, transfer_id=123456, fragmented_payload=[memoryview(b'abcdef')], source_node_id=None) assert TransferReassembler.construct_anonymous_transfer( Frame(timestamp=ts, priority=prio, transfer_id=123456, index=1, end_of_transfer=True, payload=memoryview(b'abcdef'))) is None assert TransferReassembler.construct_anonymous_transfer( Frame(timestamp=ts, priority=prio, transfer_id=123456, index=0, end_of_transfer=False, payload=memoryview(b'abcdef'))) is None
def mk_transfer(fp: typing.Sequence[bytes]) -> TransferFrom: return TransferFrom( timestamp=ts, priority=prio, transfer_id=tid, fragmented_payload=list(map(memoryview, fp)), source_node_id=src_nid, )
def mk_transfer(timestamp: Timestamp, transfer_id: int, fragmented_payload: typing.Sequence[typing.Union[bytes, memoryview]]) -> TransferFrom: return TransferFrom(timestamp=timestamp, priority=prio, transfer_id=transfer_id, fragmented_payload=list(map(memoryview, fragmented_payload)), source_node_id=src_nid)
def package( fragmented_payload: typing.Sequence[memoryview]) -> TransferFrom: return TransferFrom( timestamp=timestamp, priority=priority, transfer_id=transfer_id, fragmented_payload=fragmented_payload, source_node_id=source_node_id, )
def trn( monotonic_ns: int, transfer_id: int, fragmented_payload: typing.Sequence[typing.Union[bytes, str, memoryview]] ) -> TransferFrom: return TransferFrom( timestamp=Timestamp(system_ns=0, monotonic_ns=monotonic_ns), priority=priority, transfer_id=transfer_id, fragmented_payload=[ memoryview(x if isinstance(x, (bytes, memoryview)) else x.encode()) for x in fragmented_payload ], source_node_id=source_node_id, )
def _unittest_issue_198() -> None: source_node_id = 88 transfer_id_timeout_ns = 900 def mk_frame( padded_payload: bytes | str, transfer_id: int, start_of_transfer: bool, end_of_transfer: bool, toggle_bit: bool, ) -> UAVCANFrame: return UAVCANFrame( identifier=0xBADC0FE, padded_payload=memoryview(padded_payload if isinstance( padded_payload, bytes) else padded_payload.encode()), transfer_id=transfer_id, start_of_transfer=start_of_transfer, end_of_transfer=end_of_transfer, toggle_bit=toggle_bit, ) rx = TransferReassembler(source_node_id, 50) # First, ensure that the reassembler is initialized, by feeding it a valid transfer at least once. assert rx.process_frame( timestamp=Timestamp(system_ns=0, monotonic_ns=1000), priority=pyuavcan.transport.Priority.SLOW, frame=mk_frame("123", 0, True, True, True), transfer_id_timeout_ns=transfer_id_timeout_ns, ) == TransferFrom( timestamp=Timestamp(system_ns=0, monotonic_ns=1000), priority=pyuavcan.transport.Priority.SLOW, transfer_id=0, fragmented_payload=[ memoryview(x if isinstance(x, (bytes, memoryview)) else x.encode()) for x in ["123"] ], source_node_id=source_node_id, ) # Next, feed the last frame of another transfer whose TID/TOG match the expected state of the reassembler. # This should be recognized as a CRC error. assert (rx.process_frame( timestamp=Timestamp(system_ns=0, monotonic_ns=1000), priority=pyuavcan.transport.Priority.SLOW, frame=mk_frame("456", 1, False, True, True), transfer_id_timeout_ns=transfer_id_timeout_ns, ) == TransferReassemblyErrorID.TRANSFER_CRC_MISMATCH)
def construct_anonymous_transfer( timestamp: Timestamp, frame: Frame) -> typing.Optional[TransferFrom]: """ A minor helper that validates whether the frame is a valid anonymous transfer (it is if the index is zero and the end-of-transfer flag is set) and constructs a transfer instance if it is. Otherwise, returns None. Observe that this is a static method because anonymous transfers are fundamentally stateless. """ if frame.single_frame_transfer: return TransferFrom( timestamp=timestamp, priority=frame.priority, transfer_id=frame.transfer_id, fragmented_payload=[frame.payload], source_node_id=None, ) return None
def process_frame( self, timestamp: Timestamp, priority: pyuavcan.transport.Priority, frame: UAVCANFrame, transfer_id_timeout_ns: int, ) -> None | TransferReassemblyErrorID | TransferFrom: """ Observe that occasionally newer frames may have lower timestamp values due to error variations in the time recovery algorithms, depending on the methods of timestamping. This class therefore does not check if the timestamp values are monotonically increasing. The timestamp of a transfer will be the lowest (earliest) timestamp value of its frames (ignoring frames with mismatching transfer ID or toggle bit). """ # FIRST STAGE - DETECTION OF NEW TRANSFERS. # Decide if we need to begin a new transfer. tid_timed_out = (timestamp.monotonic_ns - self._timestamp.monotonic_ns > transfer_id_timeout_ns or self._timestamp.monotonic_ns == 0) not_previous_tid = compute_transfer_id_forward_distance( frame.transfer_id, self._transfer_id) > 1 if tid_timed_out or (frame.start_of_transfer and not_previous_tid): self._transfer_id = frame.transfer_id self._toggle_bit = frame.toggle_bit if not frame.start_of_transfer: return TransferReassemblyErrorID.MISSED_START_OF_TRANSFER # SECOND STAGE - DROP UNEXPECTED FRAMES. # A properly functioning CAN bus may occasionally replicate frames (see the Specification for background). # We combat these issues by checking the transfer ID and the toggle bit. if frame.transfer_id != self._transfer_id: return TransferReassemblyErrorID.UNEXPECTED_TRANSFER_ID if frame.toggle_bit != self._toggle_bit: return TransferReassemblyErrorID.UNEXPECTED_TOGGLE_BIT # THIRD STAGE - PAYLOAD REASSEMBLY AND VERIFICATION. # Collect the data and check its correctness. if frame.start_of_transfer: self._crc = pyuavcan.transport.commons.crc.CRC16CCITT() self._payload_truncated = False self._fragmented_payload.clear() self._timestamp = timestamp # Initialization from the first frame if self._timestamp.monotonic_ns > timestamp.monotonic_ns or self._timestamp.system_ns > timestamp.system_ns: # The timestamping algorithm may have corrected the time error since the first frame, accept lower value self._timestamp = Timestamp.combine_oldest(self._timestamp, timestamp) self._toggle_bit = not self._toggle_bit # Implicit truncation rule - discard the unexpected data at the end of the payload but compute the CRC anyway. self._crc.add(frame.padded_payload) if sum(map(len, self._fragmented_payload) ) < self._max_payload_size_bytes_with_crc: self._fragmented_payload.append(frame.padded_payload) else: self._payload_truncated = True if frame.end_of_transfer: fragmented_payload = self._fragmented_payload.copy() self._prepare_for_next_transfer() self._fragmented_payload.clear() if frame.start_of_transfer: assert len( fragmented_payload ) == 1 # Single-frame transfer, additional checks not needed else: # Multi-frame transfer, check and remove the trailing CRC. # We don't bother checking the CRC if we received fewer than 2 frames because that implies that there # was a TID wraparound mid-transfer. # This happens when the reassembler that has just been reset is fed with the last frame of another # transfer, whose TOGGLE and TRANSFER-ID happen to match the expectations of the reassembler: # 1. Wait for the reassembler to be reset. Let: expected transfer-ID = X, expected toggle bit = Y. # 2. Construct a frame with SOF=0, EOF=1, TID=X, TOGGLE=Y. # 3. Feed the frame into the reassembler. # See https://github.com/UAVCAN/pyuavcan/issues/198. There is a dedicated test covering this case. if len(fragmented_payload) < 2 or not self._crc.check_residue( ): return TransferReassemblyErrorID.TRANSFER_CRC_MISMATCH # Cut off the CRC, unless it's already been removed by the implicit payload truncation rule. if not self._payload_truncated: expected_length = sum(map( len, fragmented_payload)) - TRANSFER_CRC_LENGTH_BYTES if len(fragmented_payload[-1]) > TRANSFER_CRC_LENGTH_BYTES: fragmented_payload[-1] = fragmented_payload[ -1][:-TRANSFER_CRC_LENGTH_BYTES] else: cutoff = TRANSFER_CRC_LENGTH_BYTES - len( fragmented_payload[-1]) assert cutoff >= 0 fragmented_payload = fragmented_payload[: -1] # Drop the last fragment if cutoff > 0: fragmented_payload[-1] = fragmented_payload[ -1][:-cutoff] # Truncate the previous fragment assert expected_length == sum(map(len, fragmented_payload)) return TransferFrom( timestamp=self._timestamp, priority=priority, transfer_id=frame.transfer_id, fragmented_payload=fragmented_payload, source_node_id=self._source_node_id, ) return None # Expect more frames to come
def _unittest_input_session() -> None: import asyncio from pytest import raises, approx from pyuavcan.transport import InputSessionSpecifier, MessageDataSpecifier, Priority, TransferFrom from pyuavcan.transport import PayloadMetadata, Timestamp from pyuavcan.transport.commons.high_overhead_transport import TransferCRC ts = Timestamp.now() prio = Priority.SLOW dst_nid = 1234 run_until_complete = asyncio.get_event_loop().run_until_complete get_monotonic = asyncio.get_event_loop().time nihil_supernum = b'nihil supernum' finalized = False def do_finalize() -> None: nonlocal finalized finalized = True session_spec = InputSessionSpecifier(MessageDataSpecifier(12345), None) payload_meta = PayloadMetadata(0xdead_beef_bad_c0ffe, 100) sis = SerialInputSession(specifier=session_spec, payload_metadata=payload_meta, loop=asyncio.get_event_loop(), finalizer=do_finalize) assert sis.specifier == session_spec assert sis.payload_metadata == payload_meta assert sis.sample_statistics() == SerialInputSessionStatistics() assert sis.transfer_id_timeout == approx( SerialInputSession.DEFAULT_TRANSFER_ID_TIMEOUT) sis.transfer_id_timeout = 1.0 with raises(ValueError): sis.transfer_id_timeout = 0.0 assert sis.transfer_id_timeout == approx(1.0) assert run_until_complete(sis.receive_until(get_monotonic() + 0.1)) is None assert run_until_complete(sis.receive_until(0.0)) is None def mk_frame(transfer_id: int, index: int, end_of_transfer: bool, payload: typing.Union[bytes, memoryview], source_node_id: typing.Optional[int]) -> SerialFrame: return SerialFrame(timestamp=ts, priority=prio, transfer_id=transfer_id, index=index, end_of_transfer=end_of_transfer, payload=memoryview(payload), source_node_id=source_node_id, destination_node_id=dst_nid, data_specifier=session_spec.data_specifier, data_type_hash=payload_meta.data_type_hash) # ANONYMOUS TRANSFERS. sis._process_frame( mk_frame(transfer_id=0, index=0, end_of_transfer=False, payload=nihil_supernum, source_node_id=None)) assert sis.sample_statistics() == SerialInputSessionStatistics( frames=1, errors=1, ) sis._process_frame( mk_frame(transfer_id=0, index=1, end_of_transfer=True, payload=nihil_supernum, source_node_id=None)) assert sis.sample_statistics() == SerialInputSessionStatistics( frames=2, errors=2, ) sis._process_frame( mk_frame(transfer_id=0, index=0, end_of_transfer=True, payload=nihil_supernum, source_node_id=None)) assert sis.sample_statistics() == SerialInputSessionStatistics( transfers=1, frames=3, payload_bytes=len(nihil_supernum), errors=2, ) assert run_until_complete(sis.receive_until(0)) == \ TransferFrom(timestamp=ts, priority=prio, transfer_id=0, fragmented_payload=[memoryview(nihil_supernum)], source_node_id=None) assert run_until_complete(sis.receive_until(get_monotonic() + 0.1)) is None assert run_until_complete(sis.receive_until(0.0)) is None # BAD DATA TYPE HASH. sis._process_frame( SerialFrame(timestamp=ts, priority=prio, transfer_id=0, index=0, end_of_transfer=True, payload=memoryview(nihil_supernum), source_node_id=None, destination_node_id=None, data_specifier=session_spec.data_specifier, data_type_hash=0xbad_bad_bad_bad_bad)) assert sis.sample_statistics() == SerialInputSessionStatistics( transfers=1, frames=4, payload_bytes=len(nihil_supernum), errors=3, mismatched_data_type_hashes={0xbad_bad_bad_bad_bad: 1}, )
assert sis.sample_statistics() == SerialInputSessionStatistics( transfers=3, frames=9, payload_bytes=len(nihil_supernum) * 5, errors=3, mismatched_data_type_hashes={0xbad_bad_bad_bad_bad: 1}, reassembly_errors_per_source_node_id={ 1111: {}, 2222: {}, }, ) assert run_until_complete(sis.receive_until(0)) == \ TransferFrom(timestamp=ts, priority=prio, transfer_id=0, fragmented_payload=[memoryview(nihil_supernum)], source_node_id=2222) assert run_until_complete(sis.receive_until(0)) == \ TransferFrom(timestamp=ts, priority=prio, transfer_id=0, fragmented_payload=[memoryview(nihil_supernum)] * 3, source_node_id=1111) assert run_until_complete(sis.receive_until(get_monotonic() + 0.1)) is None assert run_until_complete(sis.receive_until(0.0)) is None # TRANSFERS WITH REASSEMBLY ERRORS. sis._process_frame( mk_frame( transfer_id=1, # EMPTY IN MULTIFRAME
async def _unittest_input_session() -> None: ts = Timestamp.now() prio = Priority.SLOW dst_nid = 1234 get_monotonic = asyncio.get_event_loop().time nihil_supernum = b"nihil supernum" finalized = False def do_finalize() -> None: nonlocal finalized finalized = True session_spec = InputSessionSpecifier(MessageDataSpecifier(2345), None) payload_meta = PayloadMetadata(100) sis = SerialInputSession(specifier=session_spec, payload_metadata=payload_meta, finalizer=do_finalize) assert sis.specifier == session_spec assert sis.payload_metadata == payload_meta assert sis.sample_statistics() == SerialInputSessionStatistics() assert sis.transfer_id_timeout == approx( SerialInputSession.DEFAULT_TRANSFER_ID_TIMEOUT) sis.transfer_id_timeout = 1.0 with raises(ValueError): sis.transfer_id_timeout = 0.0 assert sis.transfer_id_timeout == approx(1.0) assert await (sis.receive(get_monotonic() + 0.1)) is None assert await (sis.receive(0.0)) is None def mk_frame( transfer_id: int, index: int, end_of_transfer: bool, payload: typing.Union[bytes, memoryview], source_node_id: typing.Optional[int], ) -> SerialFrame: return SerialFrame( priority=prio, transfer_id=transfer_id, index=index, end_of_transfer=end_of_transfer, payload=memoryview(payload), source_node_id=source_node_id, destination_node_id=dst_nid, data_specifier=session_spec.data_specifier, ) # ANONYMOUS TRANSFERS. sis._process_frame( # pylint: disable=protected-access ts, mk_frame(transfer_id=0, index=0, end_of_transfer=False, payload=nihil_supernum, source_node_id=None)) assert sis.sample_statistics() == SerialInputSessionStatistics( frames=1, errors=1, ) sis._process_frame( # pylint: disable=protected-access ts, mk_frame(transfer_id=0, index=1, end_of_transfer=True, payload=nihil_supernum, source_node_id=None)) assert sis.sample_statistics() == SerialInputSessionStatistics( frames=2, errors=2, ) sis._process_frame( # pylint: disable=protected-access ts, mk_frame(transfer_id=0, index=0, end_of_transfer=True, payload=nihil_supernum, source_node_id=None)) assert sis.sample_statistics() == SerialInputSessionStatistics( transfers=1, frames=3, payload_bytes=len(nihil_supernum), errors=2, ) assert await (sis.receive(0)) == TransferFrom( timestamp=ts, priority=prio, transfer_id=0, fragmented_payload=[memoryview(nihil_supernum)], source_node_id=None) assert await (sis.receive(get_monotonic() + 0.1)) is None assert await (sis.receive(0.0)) is None # VALID TRANSFERS. Notice that they are unordered on purpose. The reassembler can deal with that. sis._process_frame( # pylint: disable=protected-access ts, mk_frame(transfer_id=0, index=1, end_of_transfer=False, payload=nihil_supernum, source_node_id=1111)) sis._process_frame( # pylint: disable=protected-access ts, mk_frame(transfer_id=0, index=0, end_of_transfer=True, payload=nihil_supernum, source_node_id=2222)) # COMPLETED FIRST assert sis.sample_statistics() == SerialInputSessionStatistics( transfers=2, frames=5, payload_bytes=len(nihil_supernum) * 2, errors=2, reassembly_errors_per_source_node_id={ 1111: {}, 2222: {}, }, ) sis._process_frame( # pylint: disable=protected-access ts, mk_frame( transfer_id=0, index=3, end_of_transfer=True, payload=TransferCRC.new(nihil_supernum * 3).value_as_bytes, source_node_id=1111, ), ) sis._process_frame( # pylint: disable=protected-access ts, mk_frame(transfer_id=0, index=0, end_of_transfer=False, payload=nihil_supernum, source_node_id=1111)) sis._process_frame( # pylint: disable=protected-access ts, mk_frame(transfer_id=0, index=2, end_of_transfer=False, payload=nihil_supernum, source_node_id=1111)) # COMPLETED SECOND assert sis.sample_statistics() == SerialInputSessionStatistics( transfers=3, frames=8, payload_bytes=len(nihil_supernum) * 5, errors=2, reassembly_errors_per_source_node_id={ 1111: {}, 2222: {}, }, ) assert await (sis.receive(0)) == TransferFrom( timestamp=ts, priority=prio, transfer_id=0, fragmented_payload=[memoryview(nihil_supernum)], source_node_id=2222) assert await (sis.receive(0)) == TransferFrom( timestamp=ts, priority=prio, transfer_id=0, fragmented_payload=[memoryview(nihil_supernum)] * 3, source_node_id=1111, ) assert await (sis.receive(get_monotonic() + 0.1)) is None assert await (sis.receive(0.0)) is None # TRANSFERS WITH REASSEMBLY ERRORS. sis._process_frame( # pylint: disable=protected-access ts, mk_frame( transfer_id=1, index=0, end_of_transfer=False, payload=b"", source_node_id=1111 # EMPTY IN MULTIFRAME ), ) sis._process_frame( # pylint: disable=protected-access ts, mk_frame( transfer_id=2, index=0, end_of_transfer=False, payload=b"", source_node_id=1111 # EMPTY IN MULTIFRAME ), ) assert sis.sample_statistics() == SerialInputSessionStatistics( transfers=3, frames=10, payload_bytes=len(nihil_supernum) * 5, errors=4, reassembly_errors_per_source_node_id={ 1111: { TransferReassembler.Error.MULTIFRAME_EMPTY_FRAME: 2, }, 2222: {}, }, ) assert not finalized sis.close() assert finalized sis.close() # Idempotency check
def process_frame( self, timestamp: Timestamp, frame: Frame, transfer_id_timeout: float) -> typing.Optional[TransferFrom]: """ Updates the transfer reassembly state machine with the new frame. :param timestamp: The reception timestamp from the transport layer. :param frame: The new frame. :param transfer_id_timeout: The current value of the transfer-ID timeout. :return: A new transfer if the new frame completed one. None if the new frame did not complete a transfer. :raises: Nothing. """ # DROP MALFORMED FRAMES. A multi-frame transfer cannot contain frames with no payload. if not (frame.index == 0 and frame.end_of_transfer) and not frame.payload: self._on_error_callback(self.Error.MULTIFRAME_EMPTY_FRAME) return None # DETECT NEW TRANSFERS. Either a newer TID or TID-timeout is reached. if (frame.transfer_id > self._transfer_id or timestamp.monotonic - self._timestamp.monotonic > transfer_id_timeout): self._restart( timestamp, frame.transfer_id, self.Error.MULTIFRAME_MISSING_FRAMES if self._payloads else None) # DROP FRAMES FROM NON-MATCHING TRANSFERS. E.g., duplicates. This is not an error. if frame.transfer_id < self._transfer_id: return None assert frame.transfer_id == self._transfer_id # DETERMINE MAX FRAME INDEX FOR THIS TRANSFER. Frame N with EOT, then frame M with EOT, where N != M. if frame.end_of_transfer: if self._max_index is not None and self._max_index != frame.index: self._restart(timestamp, frame.transfer_id + 1, self.Error.MULTIFRAME_EOT_INCONSISTENT) return None assert self._max_index is None or self._max_index == frame.index self._max_index = frame.index # DETECT UNEXPECTED FRAMES PAST THE END OF TRANSFER. If EOT is set on index N, then indexes > N are invalid. if self._max_index is not None and max( frame.index, len(self._payloads) - 1) > self._max_index: self._restart(timestamp, frame.transfer_id + 1, self.Error.MULTIFRAME_EOT_MISPLACED) return None # ACCEPT THE PAYLOAD. Duplicates are accepted too, assuming they carry the same payload. # Implicit truncation is implemented by not limiting the maximum payload size. # Real truncation is hard to implement if frames are delivered out-of-order, although it's not impossible: # instead of storing actual payload fragments above the limit, we can store their CRCs. # When the last fragment is received, CRC of all fragments are then combined to validate the final transfer-CRC. # This method, however, requires knowledge of the MTU to determine which fragments will be above the limit. while len(self._payloads) <= frame.index: self._payloads.append(memoryview(b"")) self._payloads[frame.index] = frame.payload # CHECK IF ALL FRAMES ARE RECEIVED. If not, simply wait for next frame. # Single-frame transfers with empty payload are legal. if self._max_index is None or (self._max_index > 0 and not all(self._payloads)): return None assert self._max_index is not None assert self._max_index == len(self._payloads) - 1 assert all(self._payloads) if self._max_index > 0 else True # FINALIZE THE TRANSFER. All frames are received here. result = _validate_and_finalize_transfer( timestamp=self._timestamp, priority=frame.priority, transfer_id=frame.transfer_id, frame_payloads=self._payloads, source_node_id=self._source_node_id, ) self._restart( timestamp, frame.transfer_id + 1, self.Error.MULTIFRAME_INTEGRITY_ERROR if result is None else None) if result is not None: # Late implicit truncation. Normally, it should be done on-the-fly, by not storing payload fragments # above the maximum expected size, but it is hard to combine with out-of-order frame acceptance. while result.fragmented_payload and sum( map(len, result.fragmented_payload[:-1])) > self._extent_bytes: # TODO: a minor refactoring is needed to avoid re-creating the transfer instance here. result = TransferFrom( timestamp=result.timestamp, priority=result.priority, transfer_id=result.transfer_id, fragmented_payload=result.fragmented_payload[:-1], source_node_id=result.source_node_id, ) return result