From 25fd26600af63b27181d363a2c4ec78e1652cb15 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Thu, 11 Jun 2026 22:11:36 +0200 Subject: [PATCH 1/8] Implement J1939 Soft Socket for SAE J1939 Transport Protocol in Python AI-Assisted: yes (GitHub Copilot) --- scapy/contrib/j1939.py | 814 +++++++++++++++++ test/contrib/j1939.uts | 1899 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 2713 insertions(+) diff --git a/scapy/contrib/j1939.py b/scapy/contrib/j1939.py index e560aadddd0..3a45203a351 100644 --- a/scapy/contrib/j1939.py +++ b/scapy/contrib/j1939.py @@ -35,6 +35,7 @@ import struct import logging import time +import traceback from typing import ( Any, @@ -43,9 +44,14 @@ Optional, Tuple, Type, + Union, + cast, + TYPE_CHECKING, ) +from scapy.automaton import ObjectPipe, select_objects from scapy.config import conf +from scapy.consts import LINUX from scapy.data import SO_TIMESTAMPNS from scapy.error import Scapy_Exception, log_runtime from scapy.fields import ( @@ -65,6 +71,10 @@ from scapy.packet import Packet from scapy.supersocket import SuperSocket from scapy.compat import raw +from scapy.utils import EDecimal + +if TYPE_CHECKING: + from scapy.contrib.cansocket import CANSocket log_j1939 = logging.getLogger("scapy.contrib.j1939") @@ -810,3 +820,807 @@ def send(self, x): except OSError as exc: log_j1939.error("Failed to send J1939 packet: %s", exc) return 0 + + +# --------------------------------------------------------------------------- +# J1939 Soft Socket +# --------------------------------------------------------------------------- +# Implements the SAE J1939 Transport Protocol (segmentation and reassembly) +# entirely in Python over any CANSocket, without requiring the Linux kernel +# CAN_J1939 socket module. The design mirrors ISOTPSoftSocket from +# scapy.contrib.isotp.isotp_soft_socket. + +# J1939-21 transport-protocol timing constants (seconds) +_J1939_TP_BAM_DELAY = 0.050 # minimum inter-packet gap for BAM sender (50 ms) +_J1939_TP_T1 = 0.750 # receiver timeout for first DT after BAM/RTS +_J1939_TP_T2 = 1.250 # receiver timeout between consecutive DT frames +_J1939_TP_T3 = 1.250 # sender timeout waiting for CTS after RTS/block +_J1939_TP_T4 = 1.050 # sender timeout waiting for End-of-Message ACK + +# On slow serial interfaces (slcan) the OS serial buffer may hold hundreds of +# background CAN frames that the mux must drain before the TP.DT frames +# arrive. When the inactivity timer fires, the handler checks the total +# elapsed time; if it is below _J1939_TP_T2 × _J1939_TP_DT_TIMEOUT_EXTENSION +# (i.e. 1.25 s × 10 = 12.5 s), the timer is re-armed and the session +# continues. Only after that wall-clock ceiling is exceeded is the transfer +# declared timed-out. +_J1939_TP_DT_TIMEOUT_EXTENSION = 10 + +# Maximum payload / per-frame data constants +_J1939_TP_DT_DATA = 7 # usable data bytes per TP.DT packet +_J1939_TP_MAX_DATA = 1785 # maximum J1939 TP payload (255 × 7 bytes) + +# Internal RX state codes +_J1939_RX_IDLE = 0 +_J1939_RX_WAIT_DT = 1 # waiting for TP.DT frames + +# Internal TX state codes +_J1939_TX_IDLE = 0 +_J1939_TX_BAM = 1 # BAM TP.DT frames are being sent +_J1939_TX_RTS_WAIT_CTS = 2 # RTS sent; waiting for CTS +_J1939_TX_RTS_SENDING = 3 # CTS received; sending TP.DT block + + +class J1939TPImplementation: + """Software implementation of the SAE J1939 Transport Protocol state machine. + + All state is stored here so that the garbage collector can reclaim a + :class:`J1939SoftSocket` even while the background + :class:`~scapy.contrib.isotp.isotp_soft_socket.TimeoutScheduler` thread + holds a reference to this object. + + :param can_socket: a :class:`~scapy.contrib.cansocket.CANSocket` used for + raw CAN I/O + :param src_addr: this node's J1939 source address (0x00–0xFD) + :param listen_only: when ``True`` the implementation never sends CTS, ACK, + or ABORT frames, allowing passive monitoring of TP + sessions without influencing the bus. Received payloads + are still reassembled and delivered via :meth:`recv`. + :param pgn_filter: when non-zero, only messages whose PGN matches this + value are delivered. ``0`` (the default) accepts all + PGNs. Inspired by BenGardiner's ``rx_pgn`` parameter. + """ + + def __init__( + self, + can_socket, # type: "CANSocket" + src_addr, # type: int + listen_only=False, # type: bool + pgn_filter=0, # type: int + ): + # type: (...) -> None + from scapy.contrib.isotp.isotp_soft_socket import TimeoutScheduler + self._TimeoutScheduler = TimeoutScheduler + + self.can_socket = can_socket + self.src_addr = src_addr + self.listen_only = listen_only + self.pgn_filter = pgn_filter # 0 = accept all PGNs + self.closed = False + self.rx_tx_poll_rate = 0.005 + + # ── receive path ────────────────────────────────────────────────────── + self.rx_state = _J1939_RX_IDLE # type: int + # Active RX session fields (valid when rx_state == _J1939_RX_WAIT_DT) + self.rx_pgn = 0 # PGN being received + self.rx_peer_sa = socket.J1939_NO_ADDR # SA of the sending node + self.rx_dst = socket.J1939_NO_ADDR # DA (our SA or 0xFF broadcast) + self.rx_total = 0 # total payload size (bytes) + self.rx_npkts = 0 # total TP.DT packets expected + self.rx_buf = b'' # accumulated payload bytes + self.rx_seq = 1 # next expected DT seq number + self.rx_ts = 0.0 # type: Union[float, EDecimal] + self.rx_is_bam = True # True=BAM; False=RTS/CTS + self.rx_start_time = 0.0 # wall-clock start of current TP rx + self.rx_timeout_handle = None # type: Optional[Any] + + # Delivered received messages: each item is (J1939, timestamp) + self.rx_queue = ObjectPipe() # type: ignore + + # ── transmit path ───────────────────────────────────────────────────── + self.tx_state = _J1939_TX_IDLE # type: int + self.tx_buf = None # type: Optional[bytes] + self.tx_pgn = 0 + self.tx_dst = socket.J1939_NO_ADDR + self.tx_priority = 6 + self.tx_data_page = 0 + self.tx_npkts = 0 # total TP.DT packets to send + self.tx_seq = 1 # next TP.DT sequence number to send + self.tx_peer_sa = socket.J1939_NO_ADDR # peer SA for RTS/CTS sessions + # CTS block management + self.tx_cts_count = 0 # DTs still to send in current CTS block + self.tx_timeout_handle = None # type: Optional[Any] + + # Enqueued outgoing messages: each item is a J1939 packet + self.tx_queue = ObjectPipe() # type: ignore + + # ── background polling ──────────────────────────────────────────────── + self.rx_handle = TimeoutScheduler.schedule(0, self.can_recv) + self.tx_handle = TimeoutScheduler.schedule(0, self._tx_poll) + + # ── lifecycle ───────────────────────────────────────────────────────────── + + def __del__(self): + # type: () -> None + self.close() + + def close(self): + # type: () -> None + if self.closed: + return + # Wait for any in-progress TX to drain before shutting down. + # This ensures that a send() followed immediately by close() (e.g. + # inside a ``with`` statement) still delivers every queued message. + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline: + if (self.tx_state == _J1939_TX_IDLE + and not select_objects([self.tx_queue], 0)): + break + time.sleep(0.005) + self.closed = True + # Brief pause so any in-flight scheduler callback sees the flag. + time.sleep(0.005) + + for handle in (self.rx_handle, self.tx_handle, + self.rx_timeout_handle, self.tx_timeout_handle): + if handle is not None: + try: + handle.cancel() + except Exception: + pass + + try: + self.rx_queue.close() + except Exception: + pass + try: + self.tx_queue.close() + except Exception: + pass + + # ── CAN receive loop ───────────────────────────────────────────────────── + + def can_recv(self): + # type: () -> None + if self.closed: + return + try: + while self.can_socket.select([self.can_socket], 0): + if self.closed: + break + pkt = self.can_socket.recv() + if pkt: + self.on_can_recv(pkt) + else: + break + except Exception: + if not self.closed: + log_j1939.warning( + "J1939TPImplementation.can_recv error: %s", + traceback.format_exc()) + + if not self.closed and not self.can_socket.closed: + self.rx_handle = self._TimeoutScheduler.schedule( + self.rx_tx_poll_rate, self.can_recv) + + def on_can_recv(self, pkt): + # type: (Packet) -> None + """Decode *pkt* as a :class:`J1939_CAN` frame and route it.""" + try: + j = J1939_CAN(bytes(pkt)) + j.time = getattr(pkt, 'time', None) or time.time() + except Exception: + return + + pf = j.pdu_format + ps = j.pdu_specific + sa = j.src + + # Ignore frames sent by this node (CAN loopback echo guard). + if sa == self.src_addr: + return + + # ── TP.CM (PF = 0xEC) ──────────────────────────────────────────────── + if pf == (J1939_PGN_TP_CM >> 8): # 0xEC + # PS must address us or be broadcast. + if ps != self.src_addr and ps != socket.J1939_NO_ADDR: + return + self._on_tp_cm(j) + return + + # ── TP.DT (PF = 0xEB) ──────────────────────────────────────────────── + if pf == (J1939_PGN_TP_DT >> 8): # 0xEB + if ps != self.src_addr and ps != socket.J1939_NO_ADDR: + return + self._on_tp_dt(j) + return + + # ── Short (≤ 8-byte) data frame ────────────────────────────────────── + # PDU1: ps is the destination address. PDU2: always broadcast. + if pf <= J1939_PDU1_MAX_PF: + if ps != self.src_addr and ps != socket.J1939_NO_ADDR: + return + self._on_short_frame(j) + + # ── RX frame handlers ──────────────────────────────────────────────────── + + def _on_short_frame(self, j): + # type: (J1939_CAN) -> None + data = bytes(j.data) + if self.pgn_filter != 0 and j.pgn != self.pgn_filter: + return + msg = J1939(data, pgn=j.pgn, src=j.src, dst=j.dst, priority=j.priority) + self.rx_queue.send((msg, j.time)) + + def _on_tp_cm(self, j): + # type: (J1939_CAN) -> None + data = bytes(j.data) + if not data: + return + ctrl = data[0] + sa = j.src + ts = j.time + + if ctrl == J1939_TP_CTRL_BAM: + if len(data) < 8: + return + cm = J1939_TP_CM_BAM(data) + if self.pgn_filter != 0 and cm.pgn != self.pgn_filter: + return + if self.rx_state != _J1939_RX_IDLE: + log_j1939.debug("J1939 TP: new BAM overwrites active RX session") + self._rx_reset() + self._rx_start(sa=sa, pgn=cm.pgn, dst=socket.J1939_NO_ADDR, + total=cm.total_size, npkts=cm.num_packets, + is_bam=True, ts=ts) + + elif ctrl == J1939_TP_CTRL_RTS: + if len(data) < 8: + return + cm = J1939_TP_CM_RTS(data) + if self.pgn_filter != 0 and cm.pgn != self.pgn_filter: + return + if self.rx_state != _J1939_RX_IDLE: + log_j1939.debug("J1939 TP: new RTS overwrites active RX session") + self._rx_reset() + self._rx_start(sa=sa, pgn=cm.pgn, dst=self.src_addr, + total=cm.total_size, npkts=cm.num_packets, + is_bam=False, ts=ts) + # Respond with CTS authorising all packets starting at seq 1. + if not self.listen_only: + self._can_send_tp_cm( + dst_sa=sa, + data=bytes(J1939_TP_CM_CTS( + num_packets=cm.num_packets, + next_packet=1, + pgn=cm.pgn, + )), + ) + + elif ctrl == J1939_TP_CTRL_CTS: + if (self.tx_state == _J1939_TX_RTS_WAIT_CTS + and sa == self.tx_peer_sa and len(data) >= 8): + self._tx_handle_cts(J1939_TP_CM_CTS(data)) + + elif ctrl == J1939_TP_CTRL_ACK: + if (self.tx_state in (_J1939_TX_RTS_WAIT_CTS, _J1939_TX_RTS_SENDING) + and sa == self.tx_peer_sa): + self._tx_reset() + + elif ctrl == J1939_TP_CTRL_ABORT: + if sa == self.tx_peer_sa: + reason = data[1] if len(data) > 1 else 0 + log_j1939.warning( + "J1939 TP: TX session aborted by peer (reason %d)", reason) + self._tx_reset() + + def _on_tp_dt(self, j): + # type: (J1939_CAN) -> None + if self.rx_state != _J1939_RX_WAIT_DT: + return + sa = j.src + if sa != self.rx_peer_sa: + return + data = bytes(j.data) + if len(data) < 8: + return + + dt = J1939_TP_DT(data) + seq = dt.seq_num + if seq != self.rx_seq: + log_j1939.warning( + "J1939 TP: bad DT seq %d (expected %d)", seq, self.rx_seq) + if not self.rx_is_bam and not self.listen_only: + self._can_send_tp_cm( + dst_sa=sa, + data=bytes(J1939_TP_CM_ABORT(reason=7, pgn=self.rx_pgn)), + ) + self._rx_reset() + return + + self.rx_buf += dt.data + self.rx_seq += 1 + + # Cancel / reschedule the DT timeout. + if self.rx_timeout_handle is not None: + try: + self.rx_timeout_handle.cancel() + except Exception: + pass + self.rx_timeout_handle = None + + if seq >= self.rx_npkts: + # All packets received – finalise the message. + payload = self.rx_buf[:self.rx_total] + if not self.rx_is_bam and not self.listen_only: + self._can_send_tp_cm( + dst_sa=sa, + data=bytes(J1939_TP_CM_ACK( + total_size=self.rx_total, + num_packets=self.rx_npkts, + pgn=self.rx_pgn, + )), + ) + msg = J1939(payload, + pgn=self.rx_pgn, src=self.rx_peer_sa, + dst=self.rx_dst, priority=6) + self.rx_queue.send((msg, self.rx_ts)) + self._rx_reset() + else: + self.rx_timeout_handle = self._TimeoutScheduler.schedule( + _J1939_TP_T2, self._rx_timeout) + + # ── RX session helpers ──────────────────────────────────────────────────── + + def _rx_start(self, sa, pgn, dst, total, npkts, is_bam, ts): + # type: (int, int, int, int, int, bool, Union[float, EDecimal]) -> None + self.rx_state = _J1939_RX_WAIT_DT + self.rx_peer_sa = sa + self.rx_pgn = pgn + self.rx_dst = dst + self.rx_total = total + self.rx_npkts = npkts + self.rx_buf = b'' + self.rx_seq = 1 + self.rx_ts = ts + self.rx_is_bam = is_bam + self.rx_start_time = time.monotonic() + if self.rx_timeout_handle is not None: + try: + self.rx_timeout_handle.cancel() + except Exception: + pass + self.rx_timeout_handle = self._TimeoutScheduler.schedule( + _J1939_TP_T1, self._rx_timeout) + + def _rx_reset(self): + # type: () -> None + self.rx_state = _J1939_RX_IDLE + if self.rx_timeout_handle is not None: + try: + self.rx_timeout_handle.cancel() + except Exception: + pass + self.rx_timeout_handle = None + + def _rx_timeout(self): + # type: () -> None + if self.closed or self.rx_state == _J1939_RX_IDLE: + return + # On slow serial interfaces (slcan) the OS serial buffer may hold many + # background CAN frames queued ahead of TP.DT frames. Re-arm the + # timer as long as the total elapsed time since the session started is + # below _J1939_TP_T2 × _J1939_TP_DT_TIMEOUT_EXTENSION (12.5 s total). + total_wait = time.monotonic() - self.rx_start_time + if total_wait < _J1939_TP_T2 * _J1939_TP_DT_TIMEOUT_EXTENSION: + self.rx_timeout_handle = self._TimeoutScheduler.schedule( + _J1939_TP_T2, self._rx_timeout) + return + log_j1939.warning( + "J1939 TP: RX timeout – discarding incomplete message " + "(PGN=0x%05X SA=0x%02X)", self.rx_pgn, self.rx_peer_sa) + self._rx_reset() + + # ── CAN send helpers ────────────────────────────────────────────────────── + + def _can_send(self, pkt): + # type: (J1939_CAN) -> None + try: + self.can_socket.send(pkt) + except Exception: + log_j1939.warning( + "J1939 CAN send failed: %s", traceback.format_exc()) + + def _can_send_tp_cm(self, dst_sa, data): + # type: (int, bytes) -> None + pkt = J1939_CAN( + priority=6, data_page=0, + pdu_format=J1939_PGN_TP_CM >> 8, # 0xEC + pdu_specific=dst_sa, + src=self.src_addr, + data=data, + ) + self._can_send(pkt) + + def _can_send_tp_dt(self, dst_sa, seq_num, chunk): + # type: (int, int, bytes) -> None + padded = chunk + b'\xff' * (_J1939_TP_DT_DATA - len(chunk)) + dt = J1939_TP_DT(seq_num=seq_num, data=padded[:_J1939_TP_DT_DATA]) + pkt = J1939_CAN( + priority=7, data_page=0, + pdu_format=J1939_PGN_TP_DT >> 8, # 0xEB + pdu_specific=dst_sa, + src=self.src_addr, + data=bytes(dt), + ) + self._can_send(pkt) + + # ── TX state machine ────────────────────────────────────────────────────── + + def _tx_poll(self): + # type: () -> None + """Dequeue and start transmitting the next J1939 message.""" + if self.closed: + return + try: + if self.tx_state == _J1939_TX_IDLE: + if select_objects([self.tx_queue], 0): + msg = self.tx_queue.recv() + if msg is not None: + self._begin_send(msg) + except Exception: + if not self.closed: + log_j1939.warning( + "J1939 _tx_poll error: %s", traceback.format_exc()) + if not self.closed: + self.tx_handle = self._TimeoutScheduler.schedule( + self.rx_tx_poll_rate, self._tx_poll) + + def _begin_send(self, msg): + # type: (Packet) -> None + """Start transmitting *msg*. Called from _tx_poll in the scheduler thread.""" + if isinstance(msg, J1939): + data = msg.data + if not isinstance(data, (bytes, bytearray)): + data = bytes(msg) + data = bytes(data) + pgn = msg.pgn + dst = msg.dst + priority = msg.priority + else: + data = bytes(msg) + pgn = 0 + dst = socket.J1939_NO_ADDR + priority = 6 + + data_page = (pgn >> 16) & 0x1 + pf = (pgn >> 8) & 0xFF + + if len(data) <= 8: + # Single CAN frame – no TP needed. + if pf <= J1939_PDU1_MAX_PF: + ps = dst & 0xFF + else: + ps = pgn & 0xFF + pkt = J1939_CAN( + priority=priority, data_page=data_page, + pdu_format=pf, pdu_specific=ps, + src=self.src_addr, data=data, + ) + self._can_send(pkt) + + elif dst == socket.J1939_NO_ADDR or dst == 0xFF: + # Broadcast multi-packet message via BAM. + self._tx_start_bam(data, pgn, dst, priority, data_page) + + else: + # Unicast multi-packet message via RTS/CTS. + self._tx_start_rts(data, pgn, dst, priority, data_page) + + # ── BAM TX ─────────────────────────────────────────────────────────────── + + def _tx_start_bam(self, data, pgn, dst, priority, data_page): + # type: (bytes, int, int, int, int) -> None + npkts = (len(data) + _J1939_TP_DT_DATA - 1) // _J1939_TP_DT_DATA + # Set tx_state BEFORE the CAN send so that close() does not see the + # queue empty with state=IDLE and break out of the drain loop early + # (race window: CAN send may block on slow adapters). + self.tx_state = _J1939_TX_BAM + self.tx_buf = data + self.tx_pgn = pgn + self.tx_dst = dst + self.tx_priority = priority + self.tx_data_page = data_page + self.tx_npkts = npkts + self.tx_seq = 1 + bam = J1939_TP_CM_BAM(total_size=len(data), num_packets=npkts, pgn=pgn) + self._can_send_tp_cm(socket.J1939_NO_ADDR, bytes(bam)) + self.tx_timeout_handle = self._TimeoutScheduler.schedule( + _J1939_TP_BAM_DELAY, self._tx_bam_next_dt) + + def _tx_bam_next_dt(self): + # type: () -> None + if self.closed or self.tx_state != _J1939_TX_BAM or self.tx_buf is None: + self._tx_reset() + return + seq = self.tx_seq + start = (seq - 1) * _J1939_TP_DT_DATA + chunk = self.tx_buf[start:start + _J1939_TP_DT_DATA] + self._can_send_tp_dt(socket.J1939_NO_ADDR, seq, chunk) + self.tx_seq += 1 + if self.tx_seq > self.tx_npkts: + self._tx_reset() + else: + self.tx_timeout_handle = self._TimeoutScheduler.schedule( + _J1939_TP_BAM_DELAY, self._tx_bam_next_dt) + + # ── RTS/CTS TX ─────────────────────────────────────────────────────────── + + def _tx_start_rts(self, data, pgn, dst, priority, data_page): + # type: (bytes, int, int, int, int) -> None + npkts = (len(data) + _J1939_TP_DT_DATA - 1) // _J1939_TP_DT_DATA + # Set tx_state BEFORE the CAN send (same race-prevention as _tx_start_bam). + self.tx_state = _J1939_TX_RTS_WAIT_CTS + self.tx_buf = data + self.tx_pgn = pgn + self.tx_dst = dst + self.tx_priority = priority + self.tx_data_page = data_page + self.tx_npkts = npkts + self.tx_seq = 1 + self.tx_peer_sa = dst + rts = J1939_TP_CM_RTS( + total_size=len(data), num_packets=npkts, + max_packets=0xFF, pgn=pgn, + ) + self._can_send_tp_cm(dst, bytes(rts)) + self.tx_timeout_handle = self._TimeoutScheduler.schedule( + _J1939_TP_T3, self._tx_timeout) + + def _tx_handle_cts(self, cts): + # type: (J1939_TP_CM_CTS) -> None + if self.tx_timeout_handle is not None: + try: + self.tx_timeout_handle.cancel() + except Exception: + pass + self.tx_timeout_handle = None + + if cts.num_packets == 0: + # Receiver requested a hold; wait for another CTS. + self.tx_state = _J1939_TX_RTS_WAIT_CTS + self.tx_timeout_handle = self._TimeoutScheduler.schedule( + _J1939_TP_T3, self._tx_timeout) + return + + self.tx_cts_count = cts.num_packets + self.tx_seq = cts.next_packet + self.tx_state = _J1939_TX_RTS_SENDING + self._tx_rts_send_block() + + def _tx_rts_send_block(self): + # type: () -> None + """Send the block of TP.DT frames authorised by the most recent CTS.""" + if self.closed or self.tx_state != _J1939_TX_RTS_SENDING \ + or self.tx_buf is None: + self._tx_reset() + return + + sent = 0 + while sent < self.tx_cts_count: + seq = self.tx_seq + if seq > self.tx_npkts: + break + start = (seq - 1) * _J1939_TP_DT_DATA + chunk = self.tx_buf[start:start + _J1939_TP_DT_DATA] + self._can_send_tp_dt(self.tx_dst, seq, chunk) + self.tx_seq += 1 + sent += 1 + + # After the block, wait for the next CTS (or ACK if all data sent). + self.tx_state = _J1939_TX_RTS_WAIT_CTS + timeout = _J1939_TP_T4 if self.tx_seq > self.tx_npkts else _J1939_TP_T3 + self.tx_timeout_handle = self._TimeoutScheduler.schedule( + timeout, self._tx_timeout) + + def _tx_timeout(self): + # type: () -> None + if self.closed or self.tx_state == _J1939_TX_IDLE: + return + log_j1939.warning( + "J1939 TP: TX timeout (PGN=0x%05X DA=0x%02X)", + self.tx_pgn, self.tx_dst) + self._tx_reset() + + def _tx_reset(self): + # type: () -> None + self.tx_state = _J1939_TX_IDLE + self.tx_buf = None + if self.tx_timeout_handle is not None: + try: + self.tx_timeout_handle.cancel() + except Exception: + pass + self.tx_timeout_handle = None + + # ── public interface ───────────────────────────────────────────────────── + + def send(self, msg): + # type: (Packet) -> None + """Enqueue *msg* for transmission. + + Also schedules an immediate TX poll so the message is picked up + without waiting for the next 5 ms polling interval. This allows + ``send()`` followed immediately by ``close()`` to reliably deliver + the frame (e.g. inside a ``with J1939SoftSocket(...) as s:`` block). + """ + self.tx_queue.send(msg) + # Cancel the pending poll and reschedule it to fire immediately so + # the message is dispatched within microseconds, not up to 5 ms later. + if self.tx_handle is not None: + try: + self.tx_handle.cancel() + except Exception: + pass + self.tx_handle = self._TimeoutScheduler.schedule(0, self._tx_poll) + + def recv(self): + # type: () -> Optional[Tuple[J1939, Union[float, EDecimal]]] + """Return the next received :class:`J1939` message from the queue.""" + return self.rx_queue.recv() # type: ignore + + +class J1939SoftSocket(SuperSocket): + """Software J1939 application-layer socket over a :class:`CANSocket`. + + Implements the SAE J1939 Transport Protocol (segmentation and + reassembly) entirely in Python, without requiring the Linux kernel + ``CAN_J1939`` socket module. It is API-compatible with + :class:`NativeJ1939Socket` and works on any platform that has a CAN + socket layer (Linux SocketCAN via + :class:`~scapy.contrib.cansocket_native.NativeCANSocket`, or any platform + via :class:`~scapy.contrib.cansocket_python_can.PythonCANSocket`). + + The implementation mirrors :class:`~scapy.contrib.isotp.ISOTPSoftSocket`: + a background thread driven by + :class:`~scapy.contrib.isotp.isotp_soft_socket.TimeoutScheduler` polls the + CAN socket and advances the TP state machine, so + :class:`J1939SoftSocket` can send Flow-Control (CTS / ACK / ABORT) frames + even before :meth:`recv` is called. + + Example – broadcast receive:: + + >>> cansock = NativeCANSocket("vcan0") + >>> with J1939SoftSocket(cansock, src_addr=0x00) as s: + ... pkt = s.recv() + + Example – broadcast send:: + + >>> cansock = NativeCANSocket("vcan0") + >>> with J1939SoftSocket(cansock, src_addr=0x00) as s: + ... s.send(J1939(b'\\x01\\x02', pgn=0xFECA, dst=0xFF)) + + :param can_socket: a :class:`~scapy.contrib.cansocket.CANSocket` instance + *or* a CAN interface name string (Linux only) + :param src_addr: this node's J1939 source address (0x00–0xFD); + defaults to :data:`socket.J1939_NO_ADDR` (0xFE = no address) + :param basecls: packet class for received messages + (default: :class:`J1939`) + :param listen_only: when ``True``, never send CTS / ACK / ABORT frames; + all received TP sessions are still reassembled and + delivered. Useful for passive bus monitoring. + :param pgn: when non-zero, only messages whose PGN matches this + value are delivered; ``0`` (the default) accepts every + PGN. Inspired by BenGardiner's ``rx_pgn`` parameter. + """ + + desc = ("read/write J1939 messages using a software " + "transport-protocol implementation") + + def __init__( + self, + can_socket=None, # type: Optional["CANSocket"] + src_addr=socket.J1939_NO_ADDR, # type: int + basecls=J1939, # type: Type[Packet] + listen_only=False, # type: bool + pgn=0, # type: int + ): + # type: (...) -> None + if LINUX and isinstance(can_socket, str): + from scapy.contrib.cansocket_native import NativeCANSocket + can_socket = NativeCANSocket(can_socket) + elif isinstance(can_socket, str): + raise Scapy_Exception( + "Provide a CANSocket object instead of an interface name") + + self.src_addr = src_addr + self.basecls = basecls + + impl = J1939TPImplementation( + can_socket, src_addr, + listen_only=listen_only, + pgn_filter=pgn, + ) + # Cast so SuperSocket internals are satisfied (recv/send are overridden). + self.ins = cast(socket.socket, impl) + self.outs = cast(socket.socket, impl) + self.impl = impl + + if basecls is None: + log_j1939.warning("Provide a basecls") + + # ── lifecycle ───────────────────────────────────────────────────────────── + + def close(self): + # type: () -> None + if not self.closed: + if hasattr(self, "impl"): + self.impl.close() + self.closed = True + + # ── recv / send ────────────────────────────────────────────────────────── + + def recv_raw(self, x=0xffff): + # type: (int) -> Tuple[Optional[Type[Packet]], Optional[bytes], Optional[float]] + # Not used for J1939SoftSocket; recv() is overridden directly. + return self.basecls, None, None + + def recv(self, x=0xffff, **kwargs): + # type: (int, **Any) -> Optional[Packet] + """Receive the next :class:`J1939` message. + + Blocks until a complete message is available or the socket is closed. + Returns ``None`` if the socket is closed before a message arrives. + """ + if self.closed: + return None + tup = self.impl.recv() + if tup is None: + return None + msg, ts = tup + msg.time = float(ts) + return msg + + def send(self, x): + # type: (Packet) -> int + """Enqueue *x* for transmission. + + If *x* is a :class:`J1939` packet its ``pgn``, ``dst``, and + ``priority`` attributes are used. Payloads of 8 bytes or fewer are + sent as a single CAN frame; larger payloads use the J1939 Transport + Protocol automatically (BAM for broadcast, RTS/CTS for unicast). + """ + if self.closed: + return 0 + try: + x.sent_time = time.time() + except AttributeError: + pass + self.impl.send(x) + return len(bytes(x)) + + # ── select ──────────────────────────────────────────────────────────────── + + @staticmethod + def select(sockets, remain=None): # type: ignore[override] + # type: (List[Union[SuperSocket, ObjectPipe[Any]]], Optional[float]) -> List[Union[SuperSocket, ObjectPipe[Any]]] # noqa: E501 + """Support :func:`~scapy.sendrecv.sniff` on :class:`J1939SoftSocket`.""" + obj_pipes = [ + x.impl.rx_queue for x in sockets + if isinstance(x, J1939SoftSocket) and not x.closed + ] + obj_pipes += [ + x for x in sockets + if isinstance(x, ObjectPipe) and not x.closed + ] + ready_pipes = select_objects(obj_pipes, remain) + result = [ + x for x in sockets + if isinstance(x, J1939SoftSocket) and not x.closed + and x.impl.rx_queue in ready_pipes + ] + result += [ + x for x in sockets + if isinstance(x, ObjectPipe) and x in ready_pipes + ] + return result # type: ignore[return-value] diff --git a/test/contrib/j1939.uts b/test/contrib/j1939.uts index 6e0166ba02f..560ce106301 100644 --- a/test/contrib/j1939.uts +++ b/test/contrib/j1939.uts @@ -2072,3 +2072,1902 @@ _r3_sock = NativeJ1939Socket("vcan0", src_addr=0x32, promisc=False) _r3_ret = _r3_sock.send(None) _r3_sock.close() assert _r3_ret == 0, "send(None) must return 0, got %r" % _r3_ret + + +############ +############ ++ J1939SoftSocket tests +~ not_pypy + += J1939SoftSocket imports + +import time +from scapy.contrib.j1939 import ( + J1939SoftSocket, + J1939TPImplementation, + J1939, J1939_CAN, + J1939_TP_CM_BAM, J1939_TP_CM_RTS, J1939_TP_CM_CTS, + J1939_TP_CM_ACK, J1939_TP_CM_ABORT, J1939_TP_DT, + J1939_TP_CTRL_BAM, J1939_TP_CTRL_RTS, J1939_TP_CTRL_CTS, + J1939_TP_CTRL_ACK, J1939_TP_CTRL_ABORT, +) +from scapy.layers.can import CAN +from test.testsocket import TestSocket, cleanup_testsockets +import socket as _socket + += J1939SoftSocket is importable and has the correct type + +assert issubclass(J1939SoftSocket, SuperSocket) + += J1939SoftSocket – context manager and close + +with TestSocket(CAN) as cans: + with J1939SoftSocket(cans, src_addr=0x00) as sock: + assert not sock.closed + assert sock.closed + += J1939SoftSocket – single-frame receive (broadcast PDU2) +# Inject a short J1939_CAN broadcast frame from SA=0x01; the soft socket +# should decode it and deliver a J1939 packet to the application layer. + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x00) as sock: + stim.send(J1939_CAN(priority=6, data_page=0, pdu_format=0xFE, + pdu_specific=0xCA, src=0x01, + data=b'\x11\x22\x33')) + pkts = sock.sniff(count=1, timeout=1) + +assert len(pkts) == 1, "Expected 1 packet, got %d" % len(pkts) +assert pkts[0].pgn == 0xFECA, "PGN mismatch: 0x%05X" % pkts[0].pgn +assert pkts[0].src == 0x01, "SA mismatch: 0x%02X" % pkts[0].src +assert pkts[0].data == b'\x11\x22\x33', "data mismatch: %r" % pkts[0].data + += J1939SoftSocket – single-frame receive (PDU1 unicast to our SA) + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + # PF=0xEF (239 < 240, PDU1), PS=0x10 (our SA) -> unicast to us + stim.send(J1939_CAN(priority=6, data_page=0, pdu_format=0xEF, + pdu_specific=0x10, src=0x05, + data=b'\xAA\xBB\xCC')) + pkts = sock.sniff(count=1, timeout=1) + +assert len(pkts) == 1, "Expected 1 packet, got %d" % len(pkts) +assert pkts[0].src == 0x05 +assert pkts[0].dst == 0x10 +assert pkts[0].data == b'\xAA\xBB\xCC' + += J1939SoftSocket – single-frame receive ignored (unicast to different SA) +# Frame addressed to SA=0x20 must NOT be delivered when our SA is 0x10. + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + stim.send(J1939_CAN(priority=6, data_page=0, pdu_format=0xEF, + pdu_specific=0x20, src=0x05, + data=b'\xAA\xBB\xCC')) + pkts = sock.sniff(count=1, timeout=0.3) + +assert len(pkts) == 0, "Frame not addressed to us should be ignored" + += J1939SoftSocket – single-frame send (broadcast PDU2) +# After calling send(), the underlying CAN socket must receive exactly one +# J1939_CAN frame with the correct PGN and source address. + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x00) as sock: + sock.send(J1939(b'\xAA\xBB', pgn=0xFECA, + dst=_socket.J1939_NO_ADDR, priority=6)) + pkts = peer.sniff(count=1, timeout=1) + +assert len(pkts) == 1, "Expected 1 CAN frame, got %d" % len(pkts) +j = J1939_CAN(bytes(pkts[0])) +assert j.pgn == 0xFECA, "PGN mismatch: 0x%05X" % j.pgn +assert j.src == 0x00, "SA mismatch: 0x%02X" % j.src +assert j.data == b'\xAA\xBB', "data mismatch: %r" % j.data + += J1939SoftSocket – single-frame send (PDU1 unicast) +# Unicast to DA=0x10: pdu_format encodes the PGN base, pdu_specific = DA. + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x01) as sock: + sock.send(J1939(b'\x01\x02\x03', pgn=0xEF00, dst=0x10, priority=6)) + pkts = peer.sniff(count=1, timeout=1) + +assert len(pkts) == 1 +j = J1939_CAN(bytes(pkts[0])) +assert j.pdu_format == 0xEF, "pf=0x%02X" % j.pdu_format +assert j.pdu_specific == 0x10, "ps=0x%02X" % j.pdu_specific +assert j.src == 0x01 +assert j.data == b'\x01\x02\x03' + += J1939SoftSocket – BAM multi-packet receive (20-byte payload, 3 TP.DT frames) + +_bam_payload = bytes(range(0x01, 0x15)) # 20 bytes -> 3 TP.DT + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x00) as sock: + bam = J1939_TP_CM_BAM(total_size=20, num_packets=3, pgn=0xFECA) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=0x01, data=bytes(bam))) + time.sleep(0.05) + for seq in range(1, 4): + start = (seq - 1) * 7 + chunk = _bam_payload[start:start + 7] + chunk += b'\xff' * (7 - len(chunk)) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=0x01, data=bytes(J1939_TP_DT(seq_num=seq, data=chunk)))) + time.sleep(0.01) + pkts = sock.sniff(count=1, timeout=2) + +assert len(pkts) == 1, "Expected 1 reassembled message, got %d" % len(pkts) +assert pkts[0].pgn == 0xFECA, "PGN mismatch" +assert pkts[0].src == 0x01, "SA mismatch" +assert pkts[0].data == _bam_payload, \ + "Payload mismatch: %r != %r" % (pkts[0].data, _bam_payload) + += J1939SoftSocket – BAM multi-packet send (20-byte payload, 3 TP.DT frames) +# The soft socket must emit: 1 TP.CM BAM + 3 TP.DT frames, with the correct +# wire encoding. + +_bam_tx_payload = bytes(range(0x01, 0x15)) # 20 bytes + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x00) as sock: + sock.send(J1939(_bam_tx_payload, pgn=0xFECA, + dst=_socket.J1939_NO_ADDR, priority=6)) + # 1 BAM + 3 DT frames; 50 ms delay between each -> ~200 ms total. + pkts = peer.sniff(count=4, timeout=3) + +assert len(pkts) == 4, "Expected 4 CAN frames (1 BAM + 3 DT), got %d" % len(pkts) + +j0 = J1939_CAN(bytes(pkts[0])) +assert j0.pdu_format == 0xEC, "Frame 0 must be TP.CM, pf=0x%02X" % j0.pdu_format +assert j0.pdu_specific == 0xFF, "BAM DA must be broadcast (0xFF)" +bam_decoded = J1939_TP_CM_BAM(j0.data) +assert bam_decoded.ctrl == J1939_TP_CTRL_BAM +assert bam_decoded.total_size == 20 +assert bam_decoded.num_packets == 3 +assert bam_decoded.pgn == 0xFECA + +_bam_tx_reassembled = b'' +for _i in range(1, 4): + _ji = J1939_CAN(bytes(pkts[_i])) + assert _ji.pdu_format == 0xEB, "Frame %d must be TP.DT, pf=0x%02X" % (_i, _ji.pdu_format) + assert _ji.pdu_specific == 0xFF, "BAM DT DA must be broadcast" + _dt = J1939_TP_DT(_ji.data) + assert _dt.seq_num == _i, "seq_num=%d expected %d" % (_dt.seq_num, _i) + _bam_tx_reassembled += _dt.data + +assert _bam_tx_reassembled[:20] == _bam_tx_payload, \ + "Reassembled payload mismatch: %r" % _bam_tx_reassembled[:20] + += J1939SoftSocket – BAM large payload (100 bytes, 15 TP.DT frames) + +_large_payload = bytes(range(100)) # 100 bytes -> ceil(100/7) = 15 TP.DT + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x00) as sock: + sock.send(J1939(_large_payload, pgn=0xFECA, + dst=_socket.J1939_NO_ADDR, priority=6)) + # 1 BAM + 15 DT with 50 ms spacing -> up to ~800 ms + pkts = peer.sniff(count=16, timeout=5) + +assert len(pkts) == 16, "Expected 16 frames (1 BAM + 15 DT), got %d" % len(pkts) +j0 = J1939_CAN(bytes(pkts[0])) +bam_large = J1939_TP_CM_BAM(j0.data) +assert bam_large.total_size == 100 +assert bam_large.num_packets == 15 + +_large_reassembled = b'' +for _i in range(1, 16): + _ji = J1939_CAN(bytes(pkts[_i])) + _dt = J1939_TP_DT(_ji.data) + _large_reassembled += _dt.data + +assert _large_reassembled[:100] == _large_payload, \ + "Large payload mismatch: %r" % _large_reassembled[:100] + += J1939SoftSocket – soft-to-soft BAM (sender J1939SoftSocket → receiver J1939SoftSocket) +# Two J1939SoftSocket instances connected through paired TestSockets. + +_s2s_payload = bytes(range(0x01, 0x15)) # 20 bytes + +with TestSocket(CAN) as cans1, TestSocket(CAN) as cans2: + cans1.pair(cans2) + with J1939SoftSocket(cans1, src_addr=0x01) as sender, \ + J1939SoftSocket(cans2, src_addr=0x02) as receiver: + sender.send(J1939(_s2s_payload, pgn=0xFECA, + dst=_socket.J1939_NO_ADDR, priority=6)) + pkts = receiver.sniff(count=1, timeout=3) + +assert len(pkts) == 1, "Expected 1 reassembled message, got %d" % len(pkts) +assert pkts[0].pgn == 0xFECA +assert pkts[0].src == 0x01 +assert pkts[0].data == _s2s_payload, \ + "Payload mismatch: %r != %r" % (pkts[0].data, _s2s_payload) + += J1939SoftSocket – soft-to-soft RTS/CTS unicast + +_rtc_payload = bytes(range(0x01, 0x10)) # 15 bytes -> 3 TP.DT + +with TestSocket(CAN) as cans1, TestSocket(CAN) as cans2: + cans1.pair(cans2) + with J1939SoftSocket(cans1, src_addr=0x01) as sender, \ + J1939SoftSocket(cans2, src_addr=0x02) as receiver: + # Unicast to receiver's SA -> triggers RTS/CTS + sender.send(J1939(_rtc_payload, pgn=0xEF00, dst=0x02, priority=6)) + # RTS -> CTS -> 3xDT -> ACK: allow up to 3 s + pkts = receiver.sniff(count=1, timeout=3) + +assert len(pkts) == 1, "Expected 1 RTS/CTS message, got %d" % len(pkts) +assert pkts[0].pgn == 0xEF00 +assert pkts[0].src == 0x01 +assert pkts[0].dst == 0x02 +assert pkts[0].data == _rtc_payload, \ + "RTS/CTS payload mismatch: %r != %r" % (pkts[0].data, _rtc_payload) + += J1939SoftSocket – RX sequence-number error triggers ABORT +# Deliver TP.DT frames out of sequence; the soft socket must abort. + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x02) as sock: + rts = J1939_TP_CM_RTS(total_size=14, num_packets=2, + max_packets=0xFF, pgn=0xEF00) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0x02, + src=0x01, data=bytes(rts))) + time.sleep(0.05) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0x02, + src=0x01, + data=bytes(J1939_TP_DT(seq_num=2, + data=b'\x01\x02\x03\x04\x05\x06\x07')))) + abort_frames = stim.sniff(count=5, timeout=1) + +_abort_found = False +for _af in abort_frames: + _aj = J1939_CAN(bytes(_af)) + if _aj.pdu_format == 0xEC and _aj.pdu_specific == 0x01: + _d = bytes(_aj.data) + if _d and _d[0] == J1939_TP_CTRL_ABORT: + _abort_found = True + +assert _abort_found, "Expected ABORT frame after bad seq number" + += J1939SoftSocket – RX timeout discards incomplete message +# Start a BAM session but deliver no DT; after T1 (750 ms) the session +# should be silently discarded and rx_state reset to idle. + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x00) as sock: + bam = J1939_TP_CM_BAM(total_size=14, num_packets=2, pgn=0xFECA) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=0x03, data=bytes(bam))) + # Wait longer than T1 (750 ms); no DT delivered. + pkts = sock.sniff(count=1, timeout=1.5) + +assert len(pkts) == 0, "No message should be delivered after BAM timeout" + += J1939SoftSocket – send minimal valid packet is safe + +_safe_send_exc = None +with TestSocket(CAN) as cans: + with J1939SoftSocket(cans, src_addr=0x00) as sock: + try: + _send_ret = sock.send(J1939(b'\x00', pgn=0xFECA)) + except Exception as _e: + _safe_send_exc = _e + +assert _safe_send_exc is None, "send raised: %s" % _safe_send_exc + + +############ +############ ++ J1939SoftSocket ↔ NativeJ1939Socket interoperability tests +~ vcan_socket needs_root not_pypy + += Setup interoperability environment + +import os +import threading +from time import sleep +from subprocess import call + +_iop_setup_cmd = "/bin/bash -c 'sudo modprobe vcan; sudo ip link add name vcan0 type vcan 2>/dev/null; sudo ip link set dev vcan0 up'" +os.system(_iop_setup_cmd) # best-effort; vcan0 may already be up + +from scapy.contrib.cansocket_native import NativeCANSocket +from scapy.contrib.j1939 import NativeJ1939Socket + += J1939SoftSocket TX (broadcast) → NativeJ1939Socket RX +# Soft socket sends a short broadcast; native socket receives it. + +_iop1_payload = b'\x01\x02\x03\x04' +_iop1_pgn = 0xFECA +_iop1_sa = 0x30 + +_iop1_cansock = NativeCANSocket("vcan0") +_iop1_native_rx = NativeJ1939Socket("vcan0", promisc=True) +_iop1_native_rx.ins.settimeout(3.0) + +def _iop1_send(): + sleep(0.1) + with J1939SoftSocket(_iop1_cansock, src_addr=_iop1_sa) as s: + s.send(J1939(_iop1_payload, pgn=_iop1_pgn, + dst=_socket.J1939_NO_ADDR, priority=6)) + +_iop1_t = threading.Thread(target=_iop1_send) +_iop1_pkts = _iop1_native_rx.sniff(timeout=3.0, started_callback=_iop1_t.start, count=1) +_iop1_t.join(timeout=5) +_iop1_native_rx.close() + +assert _iop1_pkts, "NativeJ1939Socket received no packet from J1939SoftSocket" +_iop1_rx = _iop1_pkts[0] +assert _iop1_rx.data == _iop1_payload, \ + "Payload mismatch: %r != %r" % (_iop1_rx.data, _iop1_payload) +assert _iop1_rx.pgn == _iop1_pgn, "PGN mismatch: 0x%X" % _iop1_rx.pgn +assert _iop1_rx.src == _iop1_sa, "SA mismatch: 0x%X" % _iop1_rx.src + += NativeJ1939Socket TX (broadcast) → J1939SoftSocket RX +# Native socket sends a short broadcast; soft socket receives and decodes it. + +_iop2_payload = b'\x05\x06\x07\x08' +_iop2_pgn = 0xFECA +_iop2_sa = 0x31 + +_iop2_cansock = NativeCANSocket("vcan0") +_iop2_soft_rx = J1939SoftSocket(_iop2_cansock, src_addr=0x00) +_iop2_native_tx = NativeJ1939Socket("vcan0", src_addr=_iop2_sa, promisc=False) + +def _iop2_send(): + sleep(0.1) + _iop2_native_tx.send( + J1939(_iop2_payload, pgn=_iop2_pgn, + src=_iop2_sa, dst=_socket.J1939_NO_ADDR)) + +_iop2_t = threading.Thread(target=_iop2_send) +_iop2_pkts = _iop2_soft_rx.sniff(timeout=3.0, started_callback=_iop2_t.start, count=1) +_iop2_t.join(timeout=5) +_iop2_native_tx.close() +_iop2_soft_rx.close() + +assert _iop2_pkts, "J1939SoftSocket received no packet from NativeJ1939Socket" +_iop2_rx = _iop2_pkts[0] +assert _iop2_rx.data == _iop2_payload, \ + "Payload mismatch: %r != %r" % (_iop2_rx.data, _iop2_payload) +assert _iop2_rx.pgn == _iop2_pgn, "PGN mismatch: 0x%X" % _iop2_rx.pgn +assert _iop2_rx.src == _iop2_sa, "SA mismatch: 0x%X" % _iop2_rx.src + += J1939SoftSocket TX (BAM, long message) → NativeJ1939Socket RX +# Soft socket sends a 20-byte message via BAM; the kernel J1939 stack +# reassembles it and delivers a single complete message to the native socket. + +_iop3_payload = bytes(range(0x01, 0x15)) # 20 bytes -> BAM + 3 TP.DT +_iop3_pgn = 0xFECA +_iop3_sa = 0x32 + +_iop3_cansock = NativeCANSocket("vcan0") +_iop3_native_rx = NativeJ1939Socket("vcan0", promisc=True) +_iop3_native_rx.ins.settimeout(5.0) + +def _iop3_send(): + sleep(0.1) + with J1939SoftSocket(_iop3_cansock, src_addr=_iop3_sa) as s: + s.send(J1939(_iop3_payload, pgn=_iop3_pgn, + dst=_socket.J1939_NO_ADDR, priority=6)) + +_iop3_t = threading.Thread(target=_iop3_send) +# The kernel reassembles BAM; snap until we see the full message. +_iop3_pkts = _iop3_native_rx.sniff(timeout=5.0, started_callback=_iop3_t.start, count=1) +_iop3_t.join(timeout=10) +_iop3_native_rx.close() + +assert _iop3_pkts, "NativeJ1939Socket received no BAM message from J1939SoftSocket" +_iop3_rx = _iop3_pkts[0] +assert _iop3_rx.data == _iop3_payload, \ + "Payload mismatch: %r != %r" % (_iop3_rx.data, _iop3_payload) +assert _iop3_rx.pgn == _iop3_pgn, "PGN mismatch: 0x%X" % _iop3_rx.pgn +assert _iop3_rx.src == _iop3_sa, "SA mismatch: 0x%X" % _iop3_rx.src + += NativeJ1939Socket TX (BAM, long message) → J1939SoftSocket RX +# Native socket sends a 20-byte broadcast; the soft socket must reassemble +# the BAM sequence and deliver the complete payload. + +_iop4_payload = bytes(range(0x14, 0x28)) # 20 bytes +_iop4_pgn = 0xFECA +_iop4_sa = 0x33 + +_iop4_cansock = NativeCANSocket("vcan0") +_iop4_soft_rx = J1939SoftSocket(_iop4_cansock, src_addr=0x00) +_iop4_native_tx = NativeJ1939Socket("vcan0", src_addr=_iop4_sa, promisc=False) + +def _iop4_send(): + sleep(0.1) + _iop4_native_tx.send( + J1939(_iop4_payload, pgn=_iop4_pgn, + src=_iop4_sa, dst=_socket.J1939_NO_ADDR)) + +_iop4_t = threading.Thread(target=_iop4_send) +_iop4_pkts = _iop4_soft_rx.sniff(timeout=5.0, started_callback=_iop4_t.start, count=1) +_iop4_t.join(timeout=10) +_iop4_native_tx.close() +_iop4_soft_rx.close() + +assert _iop4_pkts, "J1939SoftSocket received no reassembled BAM message" +_iop4_rx = _iop4_pkts[0] +assert _iop4_rx.data == _iop4_payload, \ + "Payload mismatch: %r != %r" % (_iop4_rx.data, _iop4_payload) +assert _iop4_rx.pgn == _iop4_pgn, "PGN mismatch: 0x%X" % _iop4_rx.pgn +assert _iop4_rx.src == _iop4_sa, "SA mismatch: 0x%X" % _iop4_rx.src + + +############ +############ ++ J1939SoftSocket – additional edge-case unit tests +~ not_pypy + += J1939SoftSocket – 8-byte payload is sent as single CAN frame (no TP) +# J1939-21: payloads ≤ 8 bytes must use a single CAN frame; no TP.CM/TP.DT. + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x01) as sock: + _sf8_payload = bytes(range(8)) # exactly 8 bytes + sock.send(J1939(_sf8_payload, pgn=0xFECA, + dst=_socket.J1939_NO_ADDR, priority=6)) + _sf8_pkts = peer.sniff(count=2, timeout=1) + +# Exactly 1 CAN frame; no TP.CM preamble. +assert len(_sf8_pkts) == 1, \ + "8-byte payload should be 1 CAN frame, got %d" % len(_sf8_pkts) +_sf8_j = J1939_CAN(bytes(_sf8_pkts[0])) +assert _sf8_j.pdu_format != 0xEC, \ + "No TP.CM should be emitted for an 8-byte payload" +assert _sf8_j.data == _sf8_payload, \ + "Data mismatch: %r != %r" % (_sf8_j.data, _sf8_payload) + += J1939SoftSocket – 9-byte payload triggers BAM with exactly 2 TP.DT frames +# 9 bytes / 7 = 2 DT frames (first full, second has 2 bytes + 5 padding). + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x01) as sock: + _tp9_payload = bytes(range(9)) # 9 bytes → 2 TP.DT + sock.send(J1939(_tp9_payload, pgn=0xFECA, + dst=_socket.J1939_NO_ADDR, priority=6)) + _tp9_pkts = peer.sniff(count=3, timeout=2) + +assert len(_tp9_pkts) == 3, \ + "9-byte payload: expected 3 frames (BAM + 2 DT), got %d" % len(_tp9_pkts) +_tp9_bam = J1939_TP_CM_BAM(J1939_CAN(bytes(_tp9_pkts[0])).data) +assert _tp9_bam.total_size == 9, "BAM.total_size=%d" % _tp9_bam.total_size +assert _tp9_bam.num_packets == 2, "BAM.num_packets=%d" % _tp9_bam.num_packets + +_tp9_dt1 = J1939_TP_DT(J1939_CAN(bytes(_tp9_pkts[1])).data) +_tp9_dt2 = J1939_TP_DT(J1939_CAN(bytes(_tp9_pkts[2])).data) +assert _tp9_dt1.seq_num == 1 +assert _tp9_dt2.seq_num == 2 +_tp9_reassembled = (_tp9_dt1.data + _tp9_dt2.data)[:9] +assert _tp9_reassembled == _tp9_payload, \ + "9-byte reassembly mismatch: %r" % _tp9_reassembled + += J1939SoftSocket – 9-byte BAM receive and reassembly + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x00) as sock: + _rxtp9_payload = bytes(range(9)) + _rxtp9_bam = J1939_TP_CM_BAM(total_size=9, num_packets=2, pgn=0xFECA) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=0x05, data=bytes(_rxtp9_bam))) + time.sleep(0.05) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=0x05, + data=bytes(J1939_TP_DT(seq_num=1, + data=_rxtp9_payload[:7])))) + time.sleep(0.01) + _rxtp9_chunk2 = _rxtp9_payload[7:] + b'\xff' * 5 + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=0x05, + data=bytes(J1939_TP_DT(seq_num=2, + data=_rxtp9_chunk2)))) + _rxtp9_pkts = sock.sniff(count=1, timeout=2) + +assert len(_rxtp9_pkts) == 1, \ + "Expected 1 reassembled message, got %d" % len(_rxtp9_pkts) +assert _rxtp9_pkts[0].data == _rxtp9_payload, \ + "9-byte RX mismatch: %r" % _rxtp9_pkts[0].data + += J1939SoftSocket – 14-byte payload: exactly 2 TP.DT frames +# 14 bytes / 7 = 2 full TP.DT frames (no padding needed). + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x01) as sock: + _bam2_payload = bytes(range(14)) # 14 bytes → exactly 2 TP.DT + sock.send(J1939(_bam2_payload, pgn=0xFECA, + dst=_socket.J1939_NO_ADDR, priority=6)) + _bam2_pkts = peer.sniff(count=3, timeout=2) + +assert len(_bam2_pkts) == 3, \ + "14-byte payload: expected 3 frames (BAM + 2 DT), got %d" % len(_bam2_pkts) +_bam2_cm = J1939_TP_CM_BAM(J1939_CAN(bytes(_bam2_pkts[0])).data) +assert _bam2_cm.total_size == 14, "BAM.total_size=%d" % _bam2_cm.total_size +assert _bam2_cm.num_packets == 2, "BAM.num_packets=%d" % _bam2_cm.num_packets +_bam2_dt1 = J1939_TP_DT(J1939_CAN(bytes(_bam2_pkts[1])).data) +_bam2_dt2 = J1939_TP_DT(J1939_CAN(bytes(_bam2_pkts[2])).data) +assert _bam2_dt1.seq_num == 1 +assert _bam2_dt2.seq_num == 2 +# Both DT frames are fully used (no padding bytes needed for 14 bytes) +assert _bam2_dt1.data == _bam2_payload[:7], \ + "DT1 data mismatch: %r" % _bam2_dt1.data +assert _bam2_dt2.data == _bam2_payload[7:], \ + "DT2 data mismatch: %r" % _bam2_dt2.data + += J1939SoftSocket – new BAM from same peer overwrites incomplete session +# J1939-21 allows the sender to restart a BAM session; the receiver resets. + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x00) as sock: + # First BAM (never completed) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=0x07, + data=bytes(J1939_TP_CM_BAM(total_size=14, + num_packets=2, + pgn=0xFECA)))) + time.sleep(0.02) + # Second BAM (1-DT message) overwrites the first session + _owrt_payload = bytes(range(1, 8)) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=0x07, + data=bytes(J1939_TP_CM_BAM(total_size=7, + num_packets=1, + pgn=0xFECA)))) + time.sleep(0.02) + # Now deliver the DT for the second BAM session + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=0x07, + data=bytes(J1939_TP_DT(seq_num=1, + data=_owrt_payload)))) + _owrt_pkts = sock.sniff(count=1, timeout=2) + +assert len(_owrt_pkts) == 1, \ + "Expected 1 reassembled message after BAM overwrite, got %d" % len(_owrt_pkts) +assert _owrt_pkts[0].data == _owrt_payload, \ + "Overwrite BAM data mismatch: %r" % _owrt_pkts[0].data + += J1939SoftSocket – TP.DT from wrong SA is ignored during active BAM session +# During a BAM from SA=0x07, TP.DT from SA=0x08 must be dropped. + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x00) as sock: + _wrongsa_payload = bytes(range(1, 8)) + # Start a BAM from SA=0x07 (1 DT needed) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=0x07, + data=bytes(J1939_TP_CM_BAM(total_size=7, + num_packets=1, + pgn=0xFECA)))) + time.sleep(0.02) + # Inject a DT from a DIFFERENT SA=0x08 (must be ignored) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=0x08, + data=bytes(J1939_TP_DT(seq_num=1, + data=_wrongsa_payload)))) + # Correct DT from SA=0x07 must still be accepted + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=0x07, + data=bytes(J1939_TP_DT(seq_num=1, + data=_wrongsa_payload)))) + _wrongsa_pkts = sock.sniff(count=1, timeout=2) + +assert len(_wrongsa_pkts) == 1, \ + "Expected 1 message (DT from wrong SA dropped), got %d" % len(_wrongsa_pkts) +assert _wrongsa_pkts[0].data == _wrongsa_payload, \ + "Wrong-SA test data mismatch: %r" % _wrongsa_pkts[0].data +assert _wrongsa_pkts[0].src == 0x07, \ + "Message src should be 0x07, got 0x%02X" % _wrongsa_pkts[0].src + += J1939SoftSocket – priority preserved on single-frame TX +# J1939-21: the priority field in the CAN ID must match the one in J1939.priority. + +for _prio_val in [0, 3, 6, 7]: + with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x01) as sock: + sock.send(J1939(b'\xAA', pgn=0xFECA, + dst=_socket.J1939_NO_ADDR, priority=_prio_val)) + _prio_pkts = peer.sniff(count=1, timeout=1) + assert len(_prio_pkts) == 1, \ + "Priority %d: expected 1 frame" % _prio_val + _prio_j = J1939_CAN(bytes(_prio_pkts[0])) + assert _prio_j.priority == _prio_val, \ + "Priority mismatch: got %d, expected %d" % (_prio_j.priority, _prio_val) + += J1939SoftSocket – BAM with data_page=1 (PGN in the 0x1xxxx range) +# For PGNs > 0xFFFF the data_page bit is set. The TP.CM (BAM) CAN frame itself +# always uses PGN 0xEC00 (data_page=0 in the CAN ID); the full transported PGN +# including the data_page bit is encoded inside the BAM payload's pgn field. + +_dp1_pgn = 0x1FECA # data_page=1, pf=0xFE, ps=0xCA +_dp1_payload = bytes(range(1, 12)) # 11 bytes → 2 TP.DT + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x01) as sock: + sock.send(J1939(_dp1_payload, pgn=_dp1_pgn, + dst=_socket.J1939_NO_ADDR, priority=6)) + _dp1_pkts = peer.sniff(count=3, timeout=2) + +assert len(_dp1_pkts) == 3, \ + "DP1 BAM: expected 3 frames, got %d" % len(_dp1_pkts) +# The TP.CM frame uses PGN 0xEC00, so its data_page is always 0 in the CAN ID. +_dp1_bam_j = J1939_CAN(bytes(_dp1_pkts[0])) +assert _dp1_bam_j.pdu_format == 0xEC, \ + "Expected TP.CM frame, pf=0x%02X" % _dp1_bam_j.pdu_format +# The transported PGN (with data_page bit) is carried inside the BAM payload. +_dp1_bam_cm = J1939_TP_CM_BAM(_dp1_bam_j.data) +assert _dp1_bam_cm.pgn == _dp1_pgn, \ + "BAM PGN mismatch: 0x%05X != 0x%05X" % (_dp1_bam_cm.pgn, _dp1_pgn) +assert _dp1_bam_cm.total_size == 11, \ + "BAM total_size=%d" % _dp1_bam_cm.total_size +assert _dp1_bam_cm.num_packets == 2, \ + "BAM num_packets=%d" % _dp1_bam_cm.num_packets + +_dp1_reassembled = (J1939_TP_DT(J1939_CAN(bytes(_dp1_pkts[1])).data).data + + J1939_TP_DT(J1939_CAN(bytes(_dp1_pkts[2])).data).data)[:11] +assert _dp1_reassembled == _dp1_payload, \ + "DP1 payload mismatch: %r" % _dp1_reassembled + += J1939SoftSocket – multiple sequential messages through the same socket +# Send three independent messages one after another; all must be received in order. + +_seq_msgs = [ + (b'\x01', 0xFECA, _socket.J1939_NO_ADDR), # 1-byte broadcast + (bytes(range(7)), 0xFECA, _socket.J1939_NO_ADDR), # 7-byte broadcast (still single frame? no, wait 7 > 8? No, 7 <= 8) + (bytes(range(10)), 0xFECA, _socket.J1939_NO_ADDR), # 10-byte -> BAM +] + +with TestSocket(CAN) as cans1, TestSocket(CAN) as cans2: + cans1.pair(cans2) + with J1939SoftSocket(cans1, src_addr=0x01) as sender, \ + J1939SoftSocket(cans2, src_addr=0x02) as receiver: + for _sq_data, _sq_pgn, _sq_dst in _seq_msgs: + sender.send(J1939(_sq_data, pgn=_sq_pgn, dst=_sq_dst, priority=6)) + # Two single frames + 1 BAM (2 DT) = 5 CAN frames, 3 reassembled msgs. + _seq_pkts = receiver.sniff(count=3, timeout=5) + +assert len(_seq_pkts) == 3, \ + "Sequential messages: expected 3, got %d" % len(_seq_pkts) +assert _seq_pkts[0].data == _seq_msgs[0][0], \ + "Msg 0 mismatch: %r" % _seq_pkts[0].data +assert _seq_pkts[1].data == _seq_msgs[1][0], \ + "Msg 1 mismatch: %r" % _seq_pkts[1].data +assert _seq_pkts[2].data == _seq_msgs[2][0], \ + "Msg 2 mismatch: %r" % _seq_pkts[2].data + += J1939SoftSocket – CTS hold (num_packets=0): sender pauses until next CTS +# Simulate a receiver that first sends CTS(0) (hold) then CTS(num_packets). + +import threading as _threading_hold + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x01) as sock: + _hold_payload = bytes(range(14)) + def _hold_bg_send(): + sock.send(J1939(_hold_payload, pgn=0xEF00, dst=0x02, priority=6)) + _hold_t = _threading_hold.Thread(target=_hold_bg_send) + _hold_t.start() + _hold_rts_frames = stim.sniff(count=1, timeout=2) + assert _hold_rts_frames, "No RTS received" + _hold_rts_j = J1939_CAN(bytes(_hold_rts_frames[0])) + assert _hold_rts_j.pdu_format == 0xEC + _hold_rts_cm = J1939_TP_CM_RTS(bytes(_hold_rts_j.data)) + assert _hold_rts_cm.ctrl == J1939_TP_CTRL_RTS + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0x01, + src=0x02, + data=bytes(J1939_TP_CM_CTS(num_packets=0, + next_packet=1, + pgn=0xEF00)))) + _hold_dt_early = stim.sniff(count=1, timeout=0.3) + assert len(_hold_dt_early) == 0, \ + "Sender must not send DT during CTS hold, got %d frames" % len(_hold_dt_early) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0x01, + src=0x02, + data=bytes(J1939_TP_CM_CTS(num_packets=2, + next_packet=1, + pgn=0xEF00)))) + _hold_dt_frames = stim.sniff(count=2, timeout=2) + assert len(_hold_dt_frames) == 2, \ + "Expected 2 DT frames after CTS release, got %d" % len(_hold_dt_frames) + for _hdi, _hdf in enumerate(_hold_dt_frames): + _hdj = J1939_CAN(bytes(_hdf)) + assert _hdj.pdu_format == 0xEB, \ + "Frame %d must be TP.DT, got pf=0x%02X" % (_hdi, _hdj.pdu_format) + assert J1939_TP_DT(_hdj.data).seq_num == _hdi + 1 + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0x01, + src=0x02, + data=bytes(J1939_TP_CM_ACK(total_size=14, + num_packets=2, + pgn=0xEF00)))) + _hold_t.join(timeout=5) + += J1939SoftSocket – TX timeout: no CTS after RTS → sender resets to IDLE +# After _J1939_TP_T3 (1.25 s) without a CTS the TX state machine must +# discard the session and accept a fresh message. + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x01) as sock: + # Unicast send → triggers RTS + sock.send(J1939(bytes(range(9)), pgn=0xEF00, dst=0x02, priority=6)) + # Consume the RTS; respond with nothing (simulate dead peer) + _to_rts = stim.sniff(count=1, timeout=2) + assert _to_rts, "No RTS received" + # Wait > T3 = 1.25 s for the TX state machine to reset + time.sleep(1.4) + # After timeout, the socket should accept a new single-frame message + sock.send(J1939(b'\xAB', pgn=0xFECA, + dst=_socket.J1939_NO_ADDR, priority=6)) + _to_pkts = stim.sniff(count=1, timeout=2) + +assert len(_to_pkts) == 1, \ + "After TX timeout, new message should be sent; got %d frames" % len(_to_pkts) +_to_j = J1939_CAN(bytes(_to_pkts[0])) +assert _to_j.pdu_format != 0xEC, \ + "Frame after TX timeout must not be a TP.CM (got pf=0x%02X)" % _to_j.pdu_format +assert _to_j.data == b'\xAB', \ + "Data mismatch after TX timeout: %r" % _to_j.data + += J1939SoftSocket – 255-byte payload (maximum non-255-DT single BAM) +# 255 bytes → ceil(255/7) = 37 TP.DT frames. + +_big_payload = bytes(range(255)) +_big_npkts = (255 + 6) // 7 # = 37 + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x01) as sock: + sock.send(J1939(_big_payload, pgn=0xFECA, + dst=_socket.J1939_NO_ADDR, priority=6)) + # 1 BAM + 37 DT frames; 50 ms spacing → up to ~1.9 s + _big_pkts = peer.sniff(count=_big_npkts + 1, timeout=5) + +assert len(_big_pkts) == _big_npkts + 1, \ + "255-byte BAM: expected %d frames, got %d" % (_big_npkts + 1, len(_big_pkts)) +_big_bam = J1939_TP_CM_BAM(J1939_CAN(bytes(_big_pkts[0])).data) +assert _big_bam.total_size == 255 +assert _big_bam.num_packets == _big_npkts + +_big_reassembled = b''.join( + J1939_TP_DT(J1939_CAN(bytes(_big_pkts[i])).data).data + for i in range(1, _big_npkts + 1) +)[:255] +assert _big_reassembled == _big_payload, \ + "255-byte reassembly mismatch at index %d" % next( + (i for i in range(255) if _big_reassembled[i] != _big_payload[i]), -1) + += J1939SoftSocket – receive-after-close returns None without raising + +_rac_cansock = TestSocket(CAN) +_rac_sock = J1939SoftSocket(_rac_cansock, src_addr=0x00) +_rac_sock.close() +_rac_cansock.close() +_rac_result = _rac_sock.recv() +assert _rac_result is None, "recv() on closed socket should return None, got %r" % _rac_result + += J1939SoftSocket – loopback echo suppression: own frames not delivered +# A frame with src == our SA must be silently discarded. + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x05) as sock: + # Inject a frame that looks like it came from our own SA + stim.send(J1939_CAN(priority=6, pdu_format=0xFE, pdu_specific=0xCA, + src=0x05, # == sock.src_addr + data=b'\x11\x22')) + _echo_pkts = sock.sniff(count=1, timeout=0.3) + +assert len(_echo_pkts) == 0, \ + "Own-address frame must not be delivered (loopback suppression)" + += J1939SoftSocket – soft-to-soft RTS/CTS with 49-byte payload (7 DT frames) +# Test a larger RTS/CTS session fully handled between two soft sockets. + +_large_rts_payload = bytes(range(49)) # 49 bytes → ceil(49/7) = 7 TP.DT + +with TestSocket(CAN) as cans1, TestSocket(CAN) as cans2: + cans1.pair(cans2) + with J1939SoftSocket(cans1, src_addr=0x10) as sender, \ + J1939SoftSocket(cans2, src_addr=0x20) as receiver: + sender.send(J1939(_large_rts_payload, pgn=0xEF00, dst=0x20, priority=6)) + # RTS → CTS → 7×DT → ACK; allow enough time + _large_rts_pkts = receiver.sniff(count=1, timeout=5) + +assert len(_large_rts_pkts) == 1, \ + "Large RTS/CTS: expected 1 msg, got %d" % len(_large_rts_pkts) +assert _large_rts_pkts[0].data == _large_rts_payload, \ + "Large RTS/CTS payload mismatch: %r" % _large_rts_pkts[0].data +assert _large_rts_pkts[0].src == 0x10 +assert _large_rts_pkts[0].dst == 0x20 + += J1939SoftSocket – listen_only: RTS does not elicit a CTS response +# When listen_only=True the implementation must not send CTS or ACK frames, +# allowing pure passive capture of TP sessions. + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x00, listen_only=True) as sock: + _lo_pgn = 0xEF00 + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0x00, + src=0x07, + data=bytes(J1939_TP_CM_RTS(total_size=9, + num_packets=2, + pgn=_lo_pgn)))) + _lo_cts_frames = stim.sniff(count=1, timeout=0.3) + +assert len(_lo_cts_frames) == 0, \ + "listen_only: RTS must not elicit a CTS, got %d frame(s)" % len(_lo_cts_frames) + += J1939SoftSocket – listen_only: BAM session still reassembled passively +# Even in listen_only mode, received BAM TP.DT frames must be reassembled and +# delivered to the application; the socket just never sends back control frames. + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x00, listen_only=True) as sock: + _lo_bam_payload = bytes(range(1, 10)) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=0x09, + data=bytes(J1939_TP_CM_BAM(total_size=9, + num_packets=2, + pgn=0xFECA)))) + time.sleep(0.05) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=0x09, + data=bytes(J1939_TP_DT(seq_num=1, + data=_lo_bam_payload[:7])))) + time.sleep(0.01) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=0x09, + data=bytes(J1939_TP_DT(seq_num=2, + data=_lo_bam_payload[7:] + b'\xff' * 5)))) + _lo_bam_pkts = sock.sniff(count=1, timeout=2) + +assert len(_lo_bam_pkts) == 1, \ + "listen_only BAM: expected 1 reassembled msg, got %d" % len(_lo_bam_pkts) +assert _lo_bam_pkts[0].data == _lo_bam_payload, \ + "listen_only BAM payload mismatch: %r" % _lo_bam_pkts[0].data +assert _lo_bam_pkts[0].src == 0x09, \ + "listen_only BAM src mismatch: 0x%02X" % _lo_bam_pkts[0].src + += J1939SoftSocket – listen_only: RTS/CTS session reassembled without sending ACK +# listen_only socket passively receives unicast TP.DT frames and reassembles +# without ever sending EndOfMsgACK back to the sender. + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x00, listen_only=True) as sock: + _lo_rts_payload = bytes(range(1, 10)) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0x00, + src=0x0A, + data=bytes(J1939_TP_CM_RTS(total_size=9, + num_packets=2, + pgn=0xEF00)))) + time.sleep(0.05) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0x00, + src=0x0A, + data=bytes(J1939_TP_DT(seq_num=1, + data=_lo_rts_payload[:7])))) + time.sleep(0.01) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0x00, + src=0x0A, + data=bytes(J1939_TP_DT(seq_num=2, + data=_lo_rts_payload[7:] + b'\xff' * 5)))) + _lo_rts_pkts = sock.sniff(count=1, timeout=2) + _lo_ack_frames = stim.sniff(count=1, timeout=0.2) + +assert len(_lo_rts_pkts) == 1, \ + "listen_only RTS/CTS: expected 1 reassembled msg, got %d" % len(_lo_rts_pkts) +assert _lo_rts_pkts[0].data == _lo_rts_payload, \ + "listen_only RTS payload mismatch: %r" % _lo_rts_pkts[0].data +assert len(_lo_ack_frames) == 0, \ + "listen_only: must not send EndOfMsgACK, got %d frame(s)" % len(_lo_ack_frames) + += J1939SoftSocket – inactivity timeout: incomplete BAM resets state machine +# After the TP.DT inactivity timeout (T2 × extension factor) with no DT +# frames arriving, the state machine must reset to IDLE and accept new messages. +# We shorten the timeout extension window to make the test run in < 3 s. +# (T2 = 1.25 s, extension factor = 10 → total default = 12.5 s; we override +# the internal timeout to 0.1 s so total = 1.0 s.) + +import scapy.contrib.j1939 as _j1939_mod +_saved_T1 = _j1939_mod._J1939_TP_T1 +_saved_T2 = _j1939_mod._J1939_TP_T2 +_j1939_mod._J1939_TP_T1 = 0.1 +_j1939_mod._J1939_TP_T2 = 0.1 + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x00) as sock: + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=0x0B, + data=bytes(J1939_TP_CM_BAM(total_size=9, + num_packets=2, + pgn=0xFECA)))) + time.sleep(1.4) + _j1939_mod._J1939_TP_T1 = _saved_T1 + _j1939_mod._J1939_TP_T2 = _saved_T2 + stim.send(J1939_CAN(priority=6, pdu_format=0xFE, pdu_specific=0xCA, + src=0x0B, data=b'\x42')) + _timeout_pkts = sock.sniff(count=1, timeout=1) + +assert len(_timeout_pkts) == 1, \ + "After inactivity timeout state should be IDLE; new msg not received" +assert _timeout_pkts[0].data == b'\x42', \ + "Post-timeout message mismatch: %r" % _timeout_pkts[0].data + += J1939SoftSocket – pgn filter: matching PGN is delivered +# pgn=0xFECA → only 0xFECA frames are delivered; 0xFECB is ignored. + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x00, pgn=0xFECA) as sock: + # Should be delivered (PGN matches) + stim.send(J1939_CAN(priority=6, pdu_format=0xFE, pdu_specific=0xCA, + src=0x01, data=b'\x01\x02\x03')) + # Should be silently dropped (PGN does not match filter) + stim.send(J1939_CAN(priority=6, pdu_format=0xFE, pdu_specific=0xCB, + src=0x01, data=b'\x04\x05\x06')) + _pgn_pkts = sock.sniff(count=2, timeout=0.5) + +assert len(_pgn_pkts) == 1, \ + "pgn filter: expected 1 packet, got %d" % len(_pgn_pkts) +assert _pgn_pkts[0].pgn == 0xFECA, \ + "pgn filter: wrong PGN 0x%05X" % _pgn_pkts[0].pgn + += J1939SoftSocket – pgn filter: BAM with non-matching PGN is silently dropped +# When pgn=0xFECA, a BAM announcing PGN=0xFECB must be completely ignored. + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x00, pgn=0xFECA) as sock: + # BAM for PGN 0xFECB – should NOT be accepted + _bam_bad = J1939_TP_CM_BAM(total_size=9, num_packets=2, pgn=0xFECB) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=0x02, data=bytes(_bam_bad))) + time.sleep(0.05) + stim.send(J1939_CAN(priority=6, pdu_format=0xEB, pdu_specific=0xFF, + src=0x02, + data=bytes(J1939_TP_DT(seq_num=1, + data=b'\x01' * 7)))) + stim.send(J1939_CAN(priority=6, pdu_format=0xEB, pdu_specific=0xFF, + src=0x02, + data=bytes(J1939_TP_DT(seq_num=2, + data=b'\x02' * 7)))) + _pgn_bam_dropped = sock.sniff(count=1, timeout=0.5) + +assert len(_pgn_bam_dropped) == 0, \ + "pgn filter: BAM for non-matching PGN should be dropped, got %d packet(s)" \ + % len(_pgn_bam_dropped) + += J1939SoftSocket – pgn=0 accepts all PGNs (default accept-all behaviour) +# BenGardiner's rx_pgn=0 means "accept all"; our pgn=0 (the default) must +# behave the same way. + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x00, pgn=0) as sock: + stim.send(J1939_CAN(priority=6, pdu_format=0xFE, pdu_specific=0xCA, + src=0x01, data=b'\xAA')) + stim.send(J1939_CAN(priority=6, pdu_format=0xFE, pdu_specific=0xCB, + src=0x01, data=b'\xBB')) + _pgn0_pkts = sock.sniff(count=2, timeout=0.5) + +assert len(_pgn0_pkts) == 2, \ + "pgn=0: expected both PGNs delivered, got %d" % len(_pgn0_pkts) + += J1939SoftSocket – pgn filter: matching RTS/CTS unicast PGN is delivered +# pgn filter must apply to unicast (RTS/CTS) sessions in addition to BAM. + +_pf_rts_pgn = 0xEF00 # PDU1 unicast PGN (pf=0xEF < 240) +_pf_rts_sa = 0x02 +_pf_rts_dst = 0x03 + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=_pf_rts_dst, pgn=_pf_rts_pgn) as sock: + _pf_rts_payload = bytes(range(1, 15)) # 14 bytes → 2 TP.DT frames + _pf_rts_cm = J1939_TP_CM_RTS(total_size=14, num_packets=2, + max_packets=2, pgn=_pf_rts_pgn) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, + pdu_specific=_pf_rts_dst, + src=_pf_rts_sa, data=bytes(_pf_rts_cm))) + # Capture the CTS that the sock sends back (sock responds to RTS). + _pf_cts_pkt = stim.sniff(count=1, timeout=1) + time.sleep(0.01) + # Send 2 DT frames. + for _pf_i in range(2): + _pf_chunk = _pf_rts_payload[_pf_i * 7:(_pf_i + 1) * 7] + _pf_chunk += b'\xff' * (7 - len(_pf_chunk)) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, + pdu_specific=_pf_rts_dst, + src=_pf_rts_sa, + data=bytes(J1939_TP_DT(seq_num=_pf_i + 1, + data=_pf_chunk)))) + time.sleep(0.01) + _pf_rts_pkts = sock.sniff(count=1, timeout=2) + +assert len(_pf_cts_pkt) == 1, \ + "pgn filter: socket should send CTS for matching RTS PGN" +assert len(_pf_rts_pkts) == 1, \ + "pgn filter: matching RTS/CTS PGN should be delivered" +assert _pf_rts_pkts[0].data == _pf_rts_payload, \ + "pgn filter RTS/CTS data mismatch: %r" % _pf_rts_pkts[0].data + += J1939SoftSocket – pgn filter: non-matching RTS/CTS PGN is silently dropped +# When the RTS PGN does not match the filter, no CTS is sent and no message +# is delivered to the application. + +_pf_rts2_pgn_filter = 0xEF00 # the filter +_pf_rts2_pgn_other = 0xED00 # different PGN → should be dropped +_pf_rts2_dst = 0x05 +_pf_rts2_sa = 0x06 + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=_pf_rts2_dst, + pgn=_pf_rts2_pgn_filter) as sock: + _pf_rts2_cm = J1939_TP_CM_RTS(total_size=14, num_packets=2, + max_packets=2, pgn=_pf_rts2_pgn_other) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, + pdu_specific=_pf_rts2_dst, + src=_pf_rts2_sa, data=bytes(_pf_rts2_cm))) + # The sock should NOT send CTS (PGN does not match filter). + _pf_cts2_pkt = stim.sniff(count=1, timeout=0.3) + _pf_rts2_pkts = sock.sniff(count=1, timeout=0.2) + +assert len(_pf_cts2_pkt) == 0, \ + "pgn filter: RTS with non-matching PGN must not elicit a CTS" +assert len(_pf_rts2_pkts) == 0, \ + "pgn filter: RTS/CTS with non-matching PGN must not be delivered" + += J1939SoftSocket – send() on closed socket returns 0 without raising +# After close(), send() must return 0 immediately and not raise. + +with TestSocket(CAN) as cans: + _sc_sock = J1939SoftSocket(cans, src_addr=0x10) + _sc_sock.close() + +_sc_ret = _sc_sock.send(J1939(b'\x01\x02\x03', pgn=0xFECA)) +assert _sc_ret == 0, "send() on closed socket should return 0, got %d" % _sc_ret + += J1939SoftSocket – ABORT from sender resets RTS/CTS RX session +# If the sender issues an ABORT while we are waiting for TP.DT frames, +# the RX session state should be cleared. A subsequent valid RTS/CTS from +# the same SA must start a fresh session. + +_abort_sa = 0x07 +_abort_dst = 0x08 +_abort_pgn = 0xEF00 + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=_abort_dst) as sock: + # Step 1: Start an RTS/CTS session (2 DT frames needed). + _abort_rts = J1939_TP_CM_RTS(total_size=14, num_packets=2, + max_packets=2, pgn=_abort_pgn) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, + pdu_specific=_abort_dst, + src=_abort_sa, data=bytes(_abort_rts))) + # Wait for CTS response (confirms session is active). + _abort_cts = stim.sniff(count=1, timeout=1) + time.sleep(0.01) + # Step 2: Sender issues ABORT (reason 0xFF = other). + _abort_pkt = J1939_TP_CM_ABORT(reason=0xFF, pgn=_abort_pgn) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, + pdu_specific=_abort_dst, + src=_abort_sa, data=bytes(_abort_pkt))) + time.sleep(0.05) + # Step 3: Start a fresh valid RTS/CTS session; sock should accept it. + _abort_payload = bytes(range(1, 15)) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, + pdu_specific=_abort_dst, + src=_abort_sa, data=bytes(_abort_rts))) + _abort_cts2 = stim.sniff(count=1, timeout=1) + time.sleep(0.01) + for _ai in range(2): + _ac = _abort_payload[_ai * 7:(_ai + 1) * 7] + _ac += b'\xff' * (7 - len(_ac)) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, + pdu_specific=_abort_dst, + src=_abort_sa, + data=bytes(J1939_TP_DT(seq_num=_ai + 1, + data=_ac)))) + time.sleep(0.01) + _abort_rx = sock.sniff(count=1, timeout=2) + +assert len(_abort_cts) == 1, "First CTS not received" +assert len(_abort_cts2) == 1, "Second CTS not received after ABORT" +assert len(_abort_rx) == 1, "Fresh session after ABORT should deliver message" +assert _abort_rx[0].data == _abort_payload, \ + "Post-ABORT session payload mismatch: %r" % _abort_rx[0].data + += J1939SoftSocket – duplicate TP.DT sequence number triggers ABORT +# If a TP.DT arrives with a seq_num that is strictly less than the expected +# next seq_num, it is treated as a sequence error and the session is aborted. + +_dup_sa = 0x0A +_dup_dst = 0x0B +_dup_pgn = 0xEF00 + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=_dup_dst) as sock: + # Start an RTS/CTS session for 2 DT frames. + _dup_rts = J1939_TP_CM_RTS(total_size=14, num_packets=2, + max_packets=2, pgn=_dup_pgn) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, + pdu_specific=_dup_dst, + src=_dup_sa, data=bytes(_dup_rts))) + _dup_cts = stim.sniff(count=1, timeout=1) + time.sleep(0.01) + # Send DT #1 (correct). + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, + pdu_specific=_dup_dst, + src=_dup_sa, + data=bytes(J1939_TP_DT(seq_num=1, + data=b'\x01' * 7)))) + time.sleep(0.01) + # Duplicate DT #1 (seq error: expected #2, got #1 again). + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, + pdu_specific=_dup_dst, + src=_dup_sa, + data=bytes(J1939_TP_DT(seq_num=1, + data=b'\x01' * 7)))) + # Sock should emit an ABORT and return no message. + _dup_abort = stim.sniff(count=1, timeout=1) + _dup_rx = sock.sniff(count=1, timeout=0.3) + +assert len(_dup_cts) == 1, "CTS not received before DT injection" +assert len(_dup_abort) == 1, \ + "Duplicate seq_num should trigger ABORT from sock" +_dup_abort_frame = J1939_CAN(bytes(_dup_abort[0])) +assert _dup_abort_frame.pdu_format == 0xEC, \ + "ABORT frame should use pdu_format=0xEC" +assert bytes(_dup_abort_frame.data)[0] == J1939_TP_CTRL_ABORT, \ + "First byte of ABORT frame should be ctrl=0xFF" +assert len(_dup_rx) == 0, \ + "Sequence error should not deliver a (corrupt) message" + += J1939SoftSocket – pgn filter: 17-bit PGN (data_page=1) matched correctly +# The pgn filter must handle PGNs with data_page=1 (> 0xFFFF). BAM payloads +# encode data_page in the high bit; the filter compares the full 17-bit PGN. + +_dpf_pgn = 0x1FECA # data_page=1, pf=0xFE, ps=0xCA → 17-bit PGN +_dpf_sa = 0x30 +_dpf_payload = b'\xA0\xB1\xC2\xD3\xE4\xF5\x01\x02\x03' # 9 bytes → 2 DTs +_dpf_pgn_other = 0xFECA # same pf/ps but data_page=0 → must NOT match filter + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x00, pgn=_dpf_pgn) as sock: + # Send a BAM for the non-matching 16-bit PGN first (should be dropped). + _dpf_bam_other = J1939_TP_CM_BAM(total_size=len(_dpf_payload), + num_packets=2, pgn=_dpf_pgn_other) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=_dpf_sa, data=bytes(_dpf_bam_other))) + # Send a BAM for the matching 17-bit PGN. + _dpf_bam = J1939_TP_CM_BAM(total_size=len(_dpf_payload), + num_packets=2, pgn=_dpf_pgn) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=_dpf_sa, data=bytes(_dpf_bam))) + time.sleep(0.05) + for _dpfi in range(2): + _dpf_chunk = _dpf_payload[_dpfi * 7:(_dpfi + 1) * 7] + _dpf_chunk += b'\xff' * (7 - len(_dpf_chunk)) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=_dpf_sa, + data=bytes(J1939_TP_DT(seq_num=_dpfi + 1, + data=_dpf_chunk)))) + time.sleep(0.01) + _dpf_pkts = sock.sniff(count=1, timeout=2) + +assert len(_dpf_pkts) == 1, \ + "pgn filter: 17-bit PGN (data_page=1) should be delivered" +assert _dpf_pkts[0].data == _dpf_payload, \ + "pgn filter data_page=1 data mismatch: %r" % _dpf_pkts[0].data +assert _dpf_pkts[0].pgn == _dpf_pgn, \ + "pgn filter data_page=1 PGN mismatch: 0x%X" % _dpf_pkts[0].pgn + + +############ +############ ++ J1939SoftSocket – SlowTestSocket tests +~ not_pypy + += J1939SoftSocket – SlowTestSocket imports + +from test.testsocket import SlowTestSocket + += J1939SoftSocket – BAM receive via SlowTestSocket (serial-buffer path) +# J1939SoftSocket uses a SlowTestSocket as its CAN socket. Frames injected +# by the stim go into the SlowTestSocket's serial buffer; they only reach +# J1939SoftSocket after _mux() is called internally by SlowTestSocket.select() +# (which J1939SoftSocket invokes via can_socket.select() in its receive loop). + +with SlowTestSocket(CAN, frame_delay=0, mux_throttle=0) as slow_cans, \ + TestSocket(CAN) as stim: + slow_cans.pair(stim) + with J1939SoftSocket(slow_cans, src_addr=0x00) as sock: + _slbr_payload = bytes(range(20)) # 20 bytes → 3 TP.DT frames + _slbr_pgn = 0xFECA + _slbr_sa = 0x20 + _slbr_bam = J1939_TP_CM_BAM(total_size=20, num_packets=3, pgn=_slbr_pgn) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=_slbr_sa, data=bytes(_slbr_bam))) + time.sleep(0.05) + for _slbri in range(3): + _slbr_chunk = _slbr_payload[_slbri * 7:(_slbri + 1) * 7] + _slbr_chunk += b'\xff' * (7 - len(_slbr_chunk)) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=_slbr_sa, + data=bytes(J1939_TP_DT(seq_num=_slbri + 1, + data=_slbr_chunk)))) + time.sleep(0.01) + _slbr_pkts = sock.sniff(count=1, timeout=3) + +assert len(_slbr_pkts) == 1, \ + "BAM via SlowTestSocket not received (serial buffer path)" +assert _slbr_pkts[0].data == _slbr_payload, \ + "BAM SlowTestSocket data mismatch: %r" % _slbr_pkts[0].data +assert _slbr_pkts[0].pgn == _slbr_pgn +assert _slbr_pkts[0].src == _slbr_sa + += J1939SoftSocket – BAM transmit via SlowTestSocket (frame-delay TX path) +# J1939SoftSocket sends a BAM over a SlowTestSocket. The TX path goes through +# SlowTestSocket.send() which adds frame_delay per frame. + +with SlowTestSocket(CAN, frame_delay=0.001, mux_throttle=0) as slow_cans, \ + TestSocket(CAN) as peer: + slow_cans.pair(peer) + with J1939SoftSocket(slow_cans, src_addr=0x21) as sock: + _slbt_payload = bytes(range(20)) # 20 bytes → BAM + 3 TP.DT + _slbt_pgn = 0xFECA + sock.send(J1939(_slbt_payload, pgn=_slbt_pgn, + dst=_socket.J1939_NO_ADDR, priority=6)) + # close() drains TX queue before returning. + _slbt_pkts = peer.sniff(count=4, timeout=5) # BAM + 3 DTs + +assert len(_slbt_pkts) == 4, \ + "BAM TX via SlowTestSocket: expected 4 frames, got %d" % len(_slbt_pkts) +_slbt_bam_parsed = J1939_TP_CM_BAM(J1939_CAN(bytes(_slbt_pkts[0])).data) +assert _slbt_bam_parsed.total_size == 20 +assert _slbt_bam_parsed.num_packets == 3 +_slbt_reassembled = b''.join( + J1939_TP_DT(J1939_CAN(bytes(p)).data).data + for p in _slbt_pkts[1:] +)[:20] +assert _slbt_reassembled == _slbt_payload, \ + "BAM TX SlowTestSocket payload mismatch: %r" % _slbt_reassembled + += J1939SoftSocket – BAM receive via SlowTestSocket with background CAN traffic +# Background PDU1 frames (unicast to a different address) are injected into +# the serial buffer before the J1939 BAM frames. The soft socket must filter +# out the background traffic and correctly reassemble only the BAM. +# 30 frames is chosen to be comfortably larger than the 4 J1939 frames (BAM + +# 3 DTs), ensuring the filter is exercised under realistic serial-buffer load. + +with SlowTestSocket(CAN, frame_delay=0.001, mux_throttle=0) as slow_cans, \ + TestSocket(CAN) as stim: + slow_cans.pair(stim) + with J1939SoftSocket(slow_cans, src_addr=0x01) as sock: + _slbg_payload = bytes(range(20)) + _slbg_pgn = 0xFECA + _slbg_sa = 0x22 + # 30 background frames: PDU1 unicast to address 0x99 (not our SA=0x01, + # not broadcast 0xFF) – silently dropped by the soft socket. + for _bgi in range(30): + stim.send(J1939_CAN(priority=6, pdu_format=0x01, + pdu_specific=0x99, src=0x50, + data=bytes(8))) + # Now inject the BAM + DT frames into the same serial buffer. + _slbg_bam = J1939_TP_CM_BAM(total_size=20, num_packets=3, pgn=_slbg_pgn) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=_slbg_sa, data=bytes(_slbg_bam))) + time.sleep(0.02) + for _slbgi in range(3): + _slbg_chunk = _slbg_payload[_slbgi * 7:(_slbgi + 1) * 7] + _slbg_chunk += b'\xff' * (7 - len(_slbg_chunk)) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=_slbg_sa, + data=bytes(J1939_TP_DT(seq_num=_slbgi + 1, + data=_slbg_chunk)))) + time.sleep(0.01) + _slbg_pkts = sock.sniff(count=1, timeout=5) + +assert len(_slbg_pkts) == 1, \ + "BAM via SlowTestSocket with background traffic not received" +assert _slbg_pkts[0].data == _slbg_payload, \ + "BAM SlowTestSocket+bg data mismatch: %r" % _slbg_pkts[0].data +assert _slbg_pkts[0].pgn == _slbg_pgn +assert _slbg_pkts[0].src == _slbg_sa + += J1939SoftSocket – RTS/CTS receive via SlowTestSocket +# A unicast RTS/CTS exchange where the CAN socket is a SlowTestSocket. +# The CTS response from the soft socket must reach the stim, and the +# subsequent DT frames must be correctly reassembled. + +with SlowTestSocket(CAN, frame_delay=0, mux_throttle=0) as slow_cans, \ + TestSocket(CAN) as stim: + slow_cans.pair(stim) + _slrc_payload = bytes(range(1, 22)) # 21 bytes → 3 TP.DT frames + _slrc_pgn = 0xEF00 + _slrc_sa = 0x23 + _slrc_dst = 0x24 + with J1939SoftSocket(slow_cans, src_addr=_slrc_dst) as sock: + _slrc_rts = J1939_TP_CM_RTS(total_size=21, num_packets=3, + max_packets=3, pgn=_slrc_pgn) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, + pdu_specific=_slrc_dst, + src=_slrc_sa, data=bytes(_slrc_rts))) + # CTS is emitted via SlowTestSocket.send() → TestSocket.pair → stim.ins + _slrc_cts = stim.sniff(count=1, timeout=2) + time.sleep(0.01) + for _slrci in range(3): + _slrc_chunk = _slrc_payload[_slrci * 7:(_slrci + 1) * 7] + _slrc_chunk += b'\xff' * (7 - len(_slrc_chunk)) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, + pdu_specific=_slrc_dst, + src=_slrc_sa, + data=bytes(J1939_TP_DT(seq_num=_slrci + 1, + data=_slrc_chunk)))) + time.sleep(0.01) + _slrc_pkts = sock.sniff(count=1, timeout=3) + +assert len(_slrc_cts) == 1, "No CTS received over SlowTestSocket" +_slrc_cts_frame = J1939_CAN(bytes(_slrc_cts[0])) +assert _slrc_cts_frame.pdu_format == 0xEC, "CTS frame has wrong pdu_format" +assert len(_slrc_pkts) == 1, "RTS/CTS reassembly over SlowTestSocket failed" +assert _slrc_pkts[0].data == _slrc_payload, \ + "RTS/CTS SlowTestSocket data mismatch: %r" % _slrc_pkts[0].data + += J1939SoftSocket – soft-to-soft BAM via SlowTestSocket (both ends slow) +# Both sockets share a SlowTestSocket as their CAN layer. The sender sends +# a BAM; the receiver (different src_addr, no loopback) reassembles it. +# Exercises the TX serial delay AND the RX serial-buffer path simultaneously. + +with SlowTestSocket(CAN, frame_delay=0.001, mux_throttle=0) as slow_cans, \ + TestSocket(CAN) as peer_can: + slow_cans.pair(peer_can) + with J1939SoftSocket(slow_cans, src_addr=0x25) as sock_tx, \ + J1939SoftSocket(peer_can, src_addr=0x26) as sock_rx: + _s2s_payload = bytes(range(14)) # 14 bytes → 2 TP.DT frames + _s2s_pgn = 0xFECA + sock_tx.send(J1939(_s2s_payload, pgn=_s2s_pgn, + dst=_socket.J1939_NO_ADDR, priority=6)) + _s2s_pkts = sock_rx.sniff(count=1, timeout=5) + +assert len(_s2s_pkts) == 1, \ + "Soft-to-soft BAM via SlowTestSocket not received" +assert _s2s_pkts[0].data == _s2s_payload, \ + "Soft-to-soft SlowTestSocket data mismatch: %r" % _s2s_pkts[0].data +assert _s2s_pkts[0].src == 0x25 + += J1939SoftSocket – listen_only via SlowTestSocket +# When listen_only=True, the socket must reassemble RTS/CTS sessions +# without ever transmitting a CTS or ACK frame – even through a slow socket. + +with SlowTestSocket(CAN, frame_delay=0, mux_throttle=0) as slow_cans, \ + TestSocket(CAN) as stim: + slow_cans.pair(stim) + _lo_payload = bytes(range(1, 22)) # 21 bytes → 3 TP.DT frames + _lo_pgn = 0xEF00 + _lo_sa = 0x27 + _lo_dst = 0x28 + with J1939SoftSocket(slow_cans, src_addr=_lo_dst, listen_only=True) as sock: + _lo_rts = J1939_TP_CM_RTS(total_size=21, num_packets=3, + max_packets=3, pgn=_lo_pgn) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, + pdu_specific=_lo_dst, + src=_lo_sa, data=bytes(_lo_rts))) + # In listen_only mode the sock must NOT emit a CTS. + _lo_cts = stim.sniff(count=1, timeout=0.3) + time.sleep(0.01) + for _loi in range(3): + _lo_chunk = _lo_payload[_loi * 7:(_loi + 1) * 7] + _lo_chunk += b'\xff' * (7 - len(_lo_chunk)) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, + pdu_specific=_lo_dst, + src=_lo_sa, + data=bytes(J1939_TP_DT(seq_num=_loi + 1, + data=_lo_chunk)))) + time.sleep(0.01) + _lo_pkts = sock.sniff(count=1, timeout=2) + +assert len(_lo_cts) == 0, \ + "listen_only via SlowTestSocket: no CTS should be emitted" +assert len(_lo_pkts) == 1, \ + "listen_only via SlowTestSocket: RTS/CTS must be reassembled passively" +assert _lo_pkts[0].data == _lo_payload, \ + "listen_only SlowTestSocket data mismatch: %r" % _lo_pkts[0].data + += J1939SoftSocket – pgn filter via SlowTestSocket (non-matching BAM dropped) +# pgn filter must be applied even when frames arrive through the SlowTestSocket +# serial buffer. A BAM with the wrong PGN must be silently dropped. + +with SlowTestSocket(CAN, frame_delay=0.001, mux_throttle=0) as slow_cans, \ + TestSocket(CAN) as stim: + slow_cans.pair(stim) + _pf_slow_pgn = 0xFECA # filter PGN + _pf_slow_other = 0xFECB # non-matching PGN (same pf=0xFE, different ps) + _pf_slow_sa = 0x29 + _pf_slow_payload = bytes(range(9)) # 9 bytes → 2 DTs + with J1939SoftSocket(slow_cans, src_addr=0x00, pgn=_pf_slow_pgn) as sock: + # Inject a BAM for the non-matching PGN (must be dropped). + _pf_slow_bam_bad = J1939_TP_CM_BAM(total_size=9, num_packets=2, + pgn=_pf_slow_other) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=_pf_slow_sa, data=bytes(_pf_slow_bam_bad))) + time.sleep(0.02) + for _pfsi in range(2): + _pfs_chunk = _pf_slow_payload[_pfsi * 7:(_pfsi + 1) * 7] + _pfs_chunk += b'\xff' * (7 - len(_pfs_chunk)) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=_pf_slow_sa, + data=bytes(J1939_TP_DT(seq_num=_pfsi + 1, + data=_pfs_chunk)))) + time.sleep(0.01) + # Now inject a BAM for the matching PGN (must be delivered). + _pf_slow_bam_ok = J1939_TP_CM_BAM(total_size=9, num_packets=2, + pgn=_pf_slow_pgn) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=_pf_slow_sa, data=bytes(_pf_slow_bam_ok))) + time.sleep(0.02) + for _pfsi2 in range(2): + _pfs_chunk2 = _pf_slow_payload[_pfsi2 * 7:(_pfsi2 + 1) * 7] + _pfs_chunk2 += b'\xff' * (7 - len(_pfs_chunk2)) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=_pf_slow_sa, + data=bytes(J1939_TP_DT(seq_num=_pfsi2 + 1, + data=_pfs_chunk2)))) + time.sleep(0.01) + _pf_slow_pkts = sock.sniff(count=1, timeout=3) + +assert len(_pf_slow_pkts) == 1, \ + "pgn filter via SlowTestSocket: only matching BAM should be delivered" +assert _pf_slow_pkts[0].data == _pf_slow_payload, \ + "pgn filter SlowTestSocket data mismatch: %r" % _pf_slow_pkts[0].data +assert _pf_slow_pkts[0].pgn == _pf_slow_pgn, \ + "pgn filter SlowTestSocket PGN mismatch: 0x%X" % _pf_slow_pkts[0].pgn + += J1939SoftSocket – RTS/CTS TX via SlowTestSocket (sock as sender) +# The soft socket sends a multi-packet unicast to a peer over a SlowTestSocket. +# The peer (stim) detects the RTS, responds with CTS, collects DTs, +# and confirms with ACK. Tests the full TX side of the RTS/CTS state machine. + +import threading as _threading_slow_rts + +_srts_payload = bytes(range(1, 22)) # 21 bytes → 3 TP.DT frames +_srts_pgn = 0xEF00 +_srts_src = 0x2A +_srts_dst = 0x2B +_srts_peer_ref = [] # mutable cell so the nested function can capture peer + +def _srts_peer_respond(): + # Wait for RTS, send CTS, wait for DTs, send ACK. + _peer = _srts_peer_ref[0] + _rts_pkt = _peer.sniff(count=1, timeout=3) + if not _rts_pkt: + return + _rts_frame = J1939_CAN(bytes(_rts_pkt[0])) + _rts_cm = J1939_TP_CM_RTS(_rts_frame.data) + _cts = J1939_TP_CM_CTS(num_packets=_rts_cm.num_packets, + next_packet=1, pgn=_srts_pgn) + _peer.send(J1939_CAN(priority=6, pdu_format=0xEC, + pdu_specific=_srts_src, + src=_srts_dst, data=bytes(_cts))) + _dts = _peer.sniff(count=_rts_cm.num_packets, timeout=5) + _ack = J1939_TP_CM_ACK(total_size=21, num_packets=3, pgn=_srts_pgn) + _peer.send(J1939_CAN(priority=6, pdu_format=0xEC, + pdu_specific=_srts_src, + src=_srts_dst, data=bytes(_ack))) + +with SlowTestSocket(CAN, frame_delay=0.001, mux_throttle=0) as slow_cans, \ + TestSocket(CAN) as _srts_peer: + slow_cans.pair(_srts_peer) + _srts_peer_ref.append(_srts_peer) + _srts_t = _threading_slow_rts.Thread(target=_srts_peer_respond) + _srts_t.start() + with J1939SoftSocket(slow_cans, src_addr=_srts_src) as sock: + sock.send(J1939(_srts_payload, pgn=_srts_pgn, + dst=_srts_dst, priority=6)) + _srts_t.join(timeout=10) + +assert not _srts_t.is_alive(), "Peer thread did not complete in time" + += J1939SoftSocket – USBTestSocket: BAM receive through hardware FIFO +# USBTestSocket simulates a USB CAN adapter with a small hardware endpoint +# FIFO. Frames are buffered until J1939SoftSocket.select() drains the FIFO +# into the receive path. All BAM frames must survive and be reassembled. + +from test.testsocket import USBTestSocket + +with USBTestSocket(CAN, hw_fifo_size=16) as usb_cans, \ + TestSocket(CAN) as stim: + usb_cans.pair(stim) + _usb_payload = bytes(range(20)) # 20 bytes → 3 TP.DT frames + _usb_pgn = 0xFECA + _usb_sa = 0x2C + with J1939SoftSocket(usb_cans, src_addr=0x00) as sock: + _usb_bam = J1939_TP_CM_BAM(total_size=20, num_packets=3, pgn=_usb_pgn) + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=_usb_sa, data=bytes(_usb_bam))) + time.sleep(0.05) + for _usbi in range(3): + _usb_chunk = _usb_payload[_usbi * 7:(_usbi + 1) * 7] + _usb_chunk += b'\xff' * (7 - len(_usb_chunk)) + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=_usb_sa, + data=bytes(J1939_TP_DT(seq_num=_usbi + 1, + data=_usb_chunk)))) + time.sleep(0.01) + _usb_pkts = sock.sniff(count=1, timeout=3) + +assert len(_usb_pkts) == 1, \ + "USBTestSocket: BAM not received (FIFO drain not working)" +assert _usb_pkts[0].data == _usb_payload, \ + "USBTestSocket BAM data mismatch: %r" % _usb_pkts[0].data +assert _usb_pkts[0].pgn == _usb_pgn +assert _usb_pkts[0].src == _usb_sa + += J1939SoftSocket – USBTestSocket: multiple sequential sessions survive FIFO +# Between two J1939SoftSocket sessions, background traffic is injected into the +# USB FIFO (simulating a busy bus during adapter reconnect). The second session +# must still receive its BAM correctly because J1939SoftSocket's CAN receive +# loop drains the FIFO on each select() call. + +_usb2_payload1 = b'\xAA' * 9 # first session +_usb2_payload2 = b'\xBB' * 14 # second session +_usb2_pgn = 0xFECA +_usb2_sa = 0x2D +_usb2_bg_ids = [0x062, 0x024, 0x039, 0x077, 0x098, 0x150] + +with USBTestSocket(CAN, hw_fifo_size=32) as usb2_cans, \ + TestSocket(CAN) as stim2: + usb2_cans.pair(stim2) + # First session. + with J1939SoftSocket(usb2_cans, src_addr=0x00) as sock1: + _usb2_bam1 = J1939_TP_CM_BAM(total_size=9, num_packets=2, pgn=_usb2_pgn) + stim2.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=_usb2_sa, data=bytes(_usb2_bam1))) + time.sleep(0.05) + for _u2i in range(2): + _u2c = _usb2_payload1[_u2i * 7:(_u2i + 1) * 7] + _u2c += b'\xff' * (7 - len(_u2c)) + stim2.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=_usb2_sa, + data=bytes(J1939_TP_DT(seq_num=_u2i + 1, + data=_u2c)))) + time.sleep(0.01) + _usb2_r1 = sock1.sniff(count=1, timeout=3) + # Between sessions: inject background frames into the FIFO (max ~8). + for _j in range(8): + stim2.send(J1939_CAN(priority=6, pdu_format=0x01, pdu_specific=0x99, + src=0x50, data=bytes(8))) + # Second session: J1939SoftSocket drains the FIFO in its receive loop. + with J1939SoftSocket(usb2_cans, src_addr=0x00) as sock2: + _usb2_bam2 = J1939_TP_CM_BAM(total_size=14, num_packets=2, pgn=_usb2_pgn) + stim2.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=_usb2_sa, data=bytes(_usb2_bam2))) + time.sleep(0.05) + for _u2j in range(2): + _u2cj = _usb2_payload2[_u2j * 7:(_u2j + 1) * 7] + _u2cj += b'\xff' * (7 - len(_u2cj)) + stim2.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=_usb2_sa, + data=bytes(J1939_TP_DT(seq_num=_u2j + 1, + data=_u2cj)))) + time.sleep(0.01) + _usb2_r2 = sock2.sniff(count=1, timeout=3) + +assert len(_usb2_r1) == 1, "USBTestSocket session 1 failed" +assert _usb2_r1[0].data == _usb2_payload1, \ + "USBTestSocket session 1 data mismatch: %r" % _usb2_r1[0].data +assert len(_usb2_r2) == 1, "USBTestSocket session 2 failed after background frames" +assert _usb2_r2[0].data == _usb2_payload2, \ + "USBTestSocket session 2 data mismatch: %r" % _usb2_r2[0].data + + +############ +############ ++ J1939SoftSocket ↔ NativeJ1939Socket – additional interoperability tests +~ vcan_socket needs_root not_pypy + += Setup (already done in earlier section; just import what we need) + +import threading as _threading_iop2 +from time import sleep as _sleep_iop2 +from scapy.contrib.cansocket_native import NativeCANSocket +from scapy.contrib.j1939 import NativeJ1939Socket + += Soft TX PDU1 unicast (≤ 8 bytes) → NativeJ1939Socket RX at specific SA +# Unicast from soft socket (SA=0x41) to native socket bound at SA=0x42. + +_u1_payload = b'\x0A\x0B\x0C\x0D' +_u1_pgn = 0xEF00 # PDU1 (PF=0xEF=239 < 240), unicast +_u1_src = 0x41 +_u1_dst = 0x42 + +_u1_cansock = NativeCANSocket("vcan0") +_u1_native_rx = NativeJ1939Socket("vcan0", src_addr=_u1_dst, + pgn=socket.J1939_NO_PGN, promisc=False) +_u1_native_rx.ins.settimeout(3.0) + +def _u1_send(): + _sleep_iop2(0.1) + with J1939SoftSocket(_u1_cansock, src_addr=_u1_src) as s: + s.send(J1939(_u1_payload, pgn=_u1_pgn, dst=_u1_dst, priority=6)) + +_u1_t = _threading_iop2.Thread(target=_u1_send) +_u1_pkts = _u1_native_rx.sniff(timeout=3.0, started_callback=_u1_t.start, count=1) +_u1_t.join(timeout=5) +_u1_native_rx.close() + +assert _u1_pkts, "NativeJ1939Socket received no unicast from J1939SoftSocket" +_u1_rx = _u1_pkts[0] +assert _u1_rx.data == _u1_payload, \ + "Unicast payload mismatch: %r != %r" % (_u1_rx.data, _u1_payload) +assert _u1_rx.pgn == _u1_pgn, "Unicast PGN mismatch: 0x%X" % _u1_rx.pgn +assert _u1_rx.src == _u1_src, "Unicast SA mismatch: 0x%X" % _u1_rx.src + += NativeJ1939Socket TX PDU1 unicast (≤ 8 bytes) → J1939SoftSocket RX at our SA +# Native socket (SA=0x43) sends a unicast to soft socket at SA=0x44. + +_u2_payload = b'\x10\x20\x30\x40' +_u2_pgn = 0xEF00 +_u2_src = 0x43 +_u2_dst = 0x44 + +_u2_cansock = NativeCANSocket("vcan0") +_u2_soft_rx = J1939SoftSocket(_u2_cansock, src_addr=_u2_dst) +_u2_native_tx = NativeJ1939Socket("vcan0", src_addr=_u2_src, promisc=False) + +def _u2_send(): + _sleep_iop2(0.1) + _u2_native_tx.send(J1939(_u2_payload, pgn=_u2_pgn, src=_u2_src, dst=_u2_dst)) + +_u2_t = _threading_iop2.Thread(target=_u2_send) +_u2_pkts = _u2_soft_rx.sniff(timeout=3.0, started_callback=_u2_t.start, count=1) +_u2_t.join(timeout=5) +_u2_native_tx.close() +_u2_soft_rx.close() + +assert _u2_pkts, "J1939SoftSocket received no unicast from NativeJ1939Socket" +_u2_rx = _u2_pkts[0] +assert _u2_rx.data == _u2_payload, \ + "Unicast payload mismatch: %r != %r" % (_u2_rx.data, _u2_payload) +assert _u2_rx.pgn == _u2_pgn, "Unicast PGN mismatch: 0x%X" % _u2_rx.pgn +assert _u2_rx.src == _u2_src, "Unicast SA mismatch: 0x%X" % _u2_rx.src +assert _u2_rx.dst == _u2_dst, "Unicast DA mismatch: 0x%X" % _u2_rx.dst + += J1939SoftSocket TX RTS/CTS unicast (long) → NativeJ1939Socket RX +# Soft socket sends a 20-byte unicast message (triggers RTS/CTS). +# NativeJ1939Socket bound at the destination SA receives the reassembled payload. + +_rtc_iop_payload = bytes(range(0x20, 0x34)) # 20 bytes +_rtc_iop_pgn = 0xEF00 +_rtc_iop_src = 0x50 +_rtc_iop_dst = 0x51 + +_rtc_cansock = NativeCANSocket("vcan0") +_rtc_native_rx = NativeJ1939Socket("vcan0", src_addr=_rtc_iop_dst, + pgn=socket.J1939_NO_PGN, promisc=False) +_rtc_native_rx.ins.settimeout(5.0) + +def _rtc_iop_send(): + _sleep_iop2(0.1) + with J1939SoftSocket(_rtc_cansock, src_addr=_rtc_iop_src) as s: + s.send(J1939(_rtc_iop_payload, pgn=_rtc_iop_pgn, + dst=_rtc_iop_dst, priority=6)) + +_rtc_iop_t = _threading_iop2.Thread(target=_rtc_iop_send) +_rtc_iop_pkts = _rtc_native_rx.sniff(timeout=5.0, + started_callback=_rtc_iop_t.start, count=1) +_rtc_iop_t.join(timeout=10) +_rtc_native_rx.close() + +assert _rtc_iop_pkts, \ + "NativeJ1939Socket received no RTS/CTS message from J1939SoftSocket" +_rtc_iop_rx = _rtc_iop_pkts[0] +assert _rtc_iop_rx.data == _rtc_iop_payload, \ + "RTS/CTS payload mismatch: %r != %r" % (_rtc_iop_rx.data, _rtc_iop_payload) +assert _rtc_iop_rx.pgn == _rtc_iop_pgn, "RTS/CTS PGN mismatch" +assert _rtc_iop_rx.src == _rtc_iop_src, "RTS/CTS SA mismatch" + += NativeJ1939Socket TX large BAM (100 bytes) → J1939SoftSocket RX +# Native socket sends 100-byte broadcast; soft socket reassembles 15 TP.DT frames. + +_big_iop_payload = bytes(range(100)) +_big_iop_pgn = 0xFECA +_big_iop_sa = 0x60 + +_big_iop_cansock = NativeCANSocket("vcan0") +_big_iop_soft_rx = J1939SoftSocket(_big_iop_cansock, src_addr=0x00) +_big_iop_native_tx = NativeJ1939Socket("vcan0", src_addr=_big_iop_sa, promisc=False) + +def _big_iop_send(): + _sleep_iop2(0.1) + _big_iop_native_tx.send( + J1939(_big_iop_payload, pgn=_big_iop_pgn, + src=_big_iop_sa, dst=_socket.J1939_NO_ADDR)) + +_big_iop_t = _threading_iop2.Thread(target=_big_iop_send) +_big_iop_pkts = _big_iop_soft_rx.sniff(timeout=8.0, + started_callback=_big_iop_t.start, count=1) +_big_iop_t.join(timeout=12) +_big_iop_native_tx.close() +_big_iop_soft_rx.close() + +assert _big_iop_pkts, \ + "J1939SoftSocket received no 100-byte BAM from NativeJ1939Socket" +_big_iop_rx = _big_iop_pkts[0] +assert _big_iop_rx.data == _big_iop_payload, \ + "100-byte BAM payload mismatch: %r != %r" % (_big_iop_rx.data, _big_iop_payload) +assert _big_iop_rx.pgn == _big_iop_pgn +assert _big_iop_rx.src == _big_iop_sa + += J1939SoftSocket TX large BAM (100 bytes) → NativeJ1939Socket RX +# Soft socket sends 100-byte broadcast via BAM; kernel J1939 stack reassembles. + +_bigs_iop_payload = bytes(range(50, 150)) +_bigs_iop_pgn = 0xFECA +_bigs_iop_sa = 0x61 + +_bigs_iop_cansock = NativeCANSocket("vcan0") +_bigs_iop_native_rx = NativeJ1939Socket("vcan0", promisc=True) +_bigs_iop_native_rx.ins.settimeout(8.0) + +def _bigs_iop_send(): + _sleep_iop2(0.1) + with J1939SoftSocket(_bigs_iop_cansock, src_addr=_bigs_iop_sa) as s: + s.send(J1939(_bigs_iop_payload, pgn=_bigs_iop_pgn, + dst=_socket.J1939_NO_ADDR, priority=6)) + +_bigs_iop_t = _threading_iop2.Thread(target=_bigs_iop_send) +_bigs_iop_pkts = _bigs_iop_native_rx.sniff(timeout=8.0, + started_callback=_bigs_iop_t.start, + count=1) +_bigs_iop_t.join(timeout=12) +_bigs_iop_native_rx.close() + +assert _bigs_iop_pkts, \ + "NativeJ1939Socket received no 100-byte BAM from J1939SoftSocket" +_bigs_iop_rx = _bigs_iop_pkts[0] +assert _bigs_iop_rx.data == _bigs_iop_payload, \ + "100-byte soft→native BAM payload mismatch: %r != %r" % \ + (_bigs_iop_rx.data, _bigs_iop_payload) +assert _bigs_iop_rx.pgn == _bigs_iop_pgn +assert _bigs_iop_rx.src == _bigs_iop_sa + += J1939SoftSocket TX → NativeJ1939Socket RX: priority preserved on wire +# Send frames with different J1939 priorities; the native side should receive +# them with a matching source address (the kernel doesn't necessarily expose +# priority to user space, but the CAN frame ID must carry it). +# We verify at the CAN level via a NativeCANSocket sniffer. + +_prio_iop_cansock_tx = NativeCANSocket("vcan0") +_prio_iop_cansock_rx = NativeCANSocket("vcan0", basecls=J1939_CAN) +_prio_iop_cansock_rx.ins.settimeout(3.0) + +_prio_iop_sa = 0x70 +_prio_iop_pgn = 0xFECA + +def _prio_iop_send(): + _sleep_iop2(0.1) + with J1939SoftSocket(_prio_iop_cansock_tx, src_addr=_prio_iop_sa) as s: + for _pv in [3, 6]: + s.send(J1939(b'\xBB', pgn=_prio_iop_pgn, + dst=_socket.J1939_NO_ADDR, priority=_pv)) + _sleep_iop2(0.05) + +_prio_iop_t = _threading_iop2.Thread(target=_prio_iop_send) +_prio_iop_raw = _prio_iop_cansock_rx.sniff(timeout=3.0, + started_callback=_prio_iop_t.start, + count=2) +_prio_iop_t.join(timeout=5) +_prio_iop_cansock_rx.close() + +assert len(_prio_iop_raw) == 2, \ + "Priority test: expected 2 frames, got %d" % len(_prio_iop_raw) +_prio_iop_frames = [J1939_CAN(bytes(f)) for f in _prio_iop_raw] +assert _prio_iop_frames[0].priority == 3, \ + "Frame 0 priority: %d" % _prio_iop_frames[0].priority +assert _prio_iop_frames[1].priority == 6, \ + "Frame 1 priority: %d" % _prio_iop_frames[1].priority + += Multiple consecutive messages Soft → Native and back (ping-pong) +# Soft socket sends 3 messages; native socket sends 3 back; both sides verify. + +_pp_soft_sa = 0x72 +_pp_native_sa = 0x73 +_pp_pgn = 0xFECA + +_pp_soft_msgs = [b'\x01', b'\x02\x03', b'\x04\x05\x06\x07'] +_pp_native_msgs = [b'\xAA', b'\xBB\xCC', b'\xDD\xEE\xFF\x00'] + +_pp_cansock1 = NativeCANSocket("vcan0") +_pp_cansock2 = NativeCANSocket("vcan0") +_pp_soft = J1939SoftSocket(_pp_cansock1, src_addr=_pp_soft_sa) +_pp_native = NativeJ1939Socket("vcan0", src_addr=_pp_native_sa, promisc=True) +_pp_native.ins.settimeout(5.0) + +_pp_soft_rx_results = [] +_pp_native_rx_results = [] + +def _pp_native_send(): + _sleep_iop2(0.3) + for _pp_msg in _pp_native_msgs: + _pp_native.send( + J1939(_pp_msg, pgn=_pp_pgn, src=_pp_native_sa, + dst=_socket.J1939_NO_ADDR)) + _sleep_iop2(0.05) + +def _pp_soft_recv(): + # Collect 3 messages sent by the native socket (filter by src) + _collected = [] + _deadline = time.time() + 5.0 + while len(_collected) < 3 and time.time() < _deadline: + _p = _pp_soft.sniff(count=1, timeout=0.5) + if _p and _p[0].src == _pp_native_sa: + _collected.append(_p[0]) + _pp_soft_rx_results.extend(_collected) + +# Soft sends first; native sends concurrently +def _pp_soft_send(): + for _pp_msg in _pp_soft_msgs: + _pp_soft.send(J1939(_pp_msg, pgn=_pp_pgn, + dst=_socket.J1939_NO_ADDR, priority=6)) + _sleep_iop2(0.05) + +_pp_t_ss = _threading_iop2.Thread(target=_pp_soft_send) +_pp_t_ns = _threading_iop2.Thread(target=_pp_native_send) +_pp_t_sr = _threading_iop2.Thread(target=_pp_soft_recv) + +_pp_native_captured = _pp_native.sniff( + timeout=5.0, + started_callback=lambda: ( + _pp_t_ss.start(), _pp_t_ns.start(), _pp_t_sr.start()), + count=len(_pp_soft_msgs), +) + +_pp_t_ss.join(timeout=5); _pp_t_ns.join(timeout=5); _pp_t_sr.join(timeout=5) +_pp_soft.close(); _pp_native.close() + +# The native socket captured at least the soft-socket messages +_pp_from_soft = [p for p in _pp_native_captured if p.src == _pp_soft_sa] +assert len(_pp_from_soft) == len(_pp_soft_msgs), \ + "Native captured %d msgs from soft, expected %d" % ( + len(_pp_from_soft), len(_pp_soft_msgs)) +for _pi, (_pp_got, _pp_exp) in enumerate(zip(_pp_from_soft, _pp_soft_msgs)): + assert _pp_got.data == _pp_exp, \ + "Ping-pong msg %d: %r != %r" % (_pi, _pp_got.data, _pp_exp) + +# Soft socket received the native messages +assert len(_pp_soft_rx_results) == len(_pp_native_msgs), \ + "Soft received %d msgs from native, expected %d" % ( + len(_pp_soft_rx_results), len(_pp_native_msgs)) +for _pi, (_pp_got, _pp_exp) in enumerate(zip(_pp_soft_rx_results, _pp_native_msgs)): + assert _pp_got.data == _pp_exp, \ + "Soft rx msg %d: %r != %r" % (_pi, _pp_got.data, _pp_exp) From c7c564a961ec02d87da63fb8c53a6e85cf38b23e Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Fri, 12 Jun 2026 08:50:02 +0200 Subject: [PATCH 2/8] Add debug logging for J1939SoftSocket operations and packet handling AI-Assisted: yes (GitHub Copilot) --- test/contrib/j1939.uts | 114 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 101 insertions(+), 13 deletions(-) diff --git a/test/contrib/j1939.uts b/test/contrib/j1939.uts index 560ce106301..f6489510297 100644 --- a/test/contrib/j1939.uts +++ b/test/contrib/j1939.uts @@ -2382,11 +2382,18 @@ from time import sleep from subprocess import call _iop_setup_cmd = "/bin/bash -c 'sudo modprobe vcan; sudo ip link add name vcan0 type vcan 2>/dev/null; sudo ip link set dev vcan0 up'" -os.system(_iop_setup_cmd) # best-effort; vcan0 may already be up +_iop_setup_rc = os.system(_iop_setup_cmd) +print("[iop-setup] vcan modprobe+up rc=%d" % _iop_setup_rc) + +# Show vcan0 link state for debugging +_iop_link_rc = os.system("ip link show vcan0") +print("[iop-setup] ip link show vcan0 rc=%d" % _iop_link_rc) from scapy.contrib.cansocket_native import NativeCANSocket from scapy.contrib.j1939 import NativeJ1939Socket +print("[iop-setup] NativeCANSocket and NativeJ1939Socket imported OK") + = J1939SoftSocket TX (broadcast) → NativeJ1939Socket RX # Soft socket sends a short broadcast; native socket receives it. @@ -2394,19 +2401,39 @@ _iop1_payload = b'\x01\x02\x03\x04' _iop1_pgn = 0xFECA _iop1_sa = 0x30 +print("[iop1] creating NativeCANSocket and NativeJ1939Socket(promisc=True)") _iop1_cansock = NativeCANSocket("vcan0") _iop1_native_rx = NativeJ1939Socket("vcan0", promisc=True) _iop1_native_rx.ins.settimeout(3.0) +print("[iop1] sockets created; NativeCANSocket fd=%r, NativeJ1939Socket fd=%r" % ( + getattr(_iop1_cansock, 'ins', None), getattr(_iop1_native_rx, 'ins', None))) + +_iop1_send_exc = [None] def _iop1_send(): sleep(0.1) - with J1939SoftSocket(_iop1_cansock, src_addr=_iop1_sa) as s: - s.send(J1939(_iop1_payload, pgn=_iop1_pgn, - dst=_socket.J1939_NO_ADDR, priority=6)) + try: + print("[iop1-tx] J1939SoftSocket opening on NativeCANSocket") + with J1939SoftSocket(_iop1_cansock, src_addr=_iop1_sa) as s: + print("[iop1-tx] sending J1939 pgn=0x%X sa=0x%X dst=0xFF data=%r" % ( + _iop1_pgn, _iop1_sa, _iop1_payload)) + s.send(J1939(_iop1_payload, pgn=_iop1_pgn, + dst=_socket.J1939_NO_ADDR, priority=6)) + print("[iop1-tx] send() returned") + except Exception as _e: + _iop1_send_exc[0] = _e + print("[iop1-tx] EXCEPTION in sender: %r" % _e) _iop1_t = threading.Thread(target=_iop1_send) +print("[iop1] starting sniff (timeout=3.0, count=1) and sender thread") _iop1_pkts = _iop1_native_rx.sniff(timeout=3.0, started_callback=_iop1_t.start, count=1) _iop1_t.join(timeout=5) +print("[iop1] sniff done; received %d packet(s); sender exc=%r" % ( + len(_iop1_pkts), _iop1_send_exc[0])) +for _i, _p in enumerate(_iop1_pkts): + print("[iop1] pkt[%d]: pgn=0x%X src=0x%X dst=0x%X data=%r" % ( + _i, getattr(_p, 'pgn', None), getattr(_p, 'src', None), + getattr(_p, 'dst', None), getattr(_p, 'data', None))) _iop1_native_rx.close() assert _iop1_pkts, "NativeJ1939Socket received no packet from J1939SoftSocket" @@ -2423,19 +2450,38 @@ _iop2_payload = b'\x05\x06\x07\x08' _iop2_pgn = 0xFECA _iop2_sa = 0x31 +print("[iop2] creating NativeCANSocket, J1939SoftSocket, NativeJ1939Socket") _iop2_cansock = NativeCANSocket("vcan0") _iop2_soft_rx = J1939SoftSocket(_iop2_cansock, src_addr=0x00) _iop2_native_tx = NativeJ1939Socket("vcan0", src_addr=_iop2_sa, promisc=False) +print("[iop2] sockets created; NativeCANSocket fd=%r, NativeJ1939Socket fd=%r" % ( + getattr(_iop2_cansock, 'ins', None), getattr(_iop2_native_tx, 'ins', None))) + +_iop2_send_exc = [None] def _iop2_send(): sleep(0.1) - _iop2_native_tx.send( - J1939(_iop2_payload, pgn=_iop2_pgn, - src=_iop2_sa, dst=_socket.J1939_NO_ADDR)) + try: + print("[iop2-tx] sending J1939 pgn=0x%X sa=0x%X dst=NO_ADDR data=%r" % ( + _iop2_pgn, _iop2_sa, _iop2_payload)) + _iop2_native_tx.send( + J1939(_iop2_payload, pgn=_iop2_pgn, + src=_iop2_sa, dst=_socket.J1939_NO_ADDR)) + print("[iop2-tx] send() returned") + except Exception as _e: + _iop2_send_exc[0] = _e + print("[iop2-tx] EXCEPTION in sender: %r" % _e) _iop2_t = threading.Thread(target=_iop2_send) +print("[iop2] starting sniff (timeout=3.0, count=1) and sender thread") _iop2_pkts = _iop2_soft_rx.sniff(timeout=3.0, started_callback=_iop2_t.start, count=1) _iop2_t.join(timeout=5) +print("[iop2] sniff done; received %d packet(s); sender exc=%r" % ( + len(_iop2_pkts), _iop2_send_exc[0])) +for _i, _p in enumerate(_iop2_pkts): + print("[iop2] pkt[%d]: pgn=0x%X src=0x%X dst=0x%X data=%r" % ( + _i, getattr(_p, 'pgn', None), getattr(_p, 'src', None), + getattr(_p, 'dst', None), getattr(_p, 'data', None))) _iop2_native_tx.close() _iop2_soft_rx.close() @@ -2454,20 +2500,42 @@ _iop3_payload = bytes(range(0x01, 0x15)) # 20 bytes -> BAM + 3 TP.DT _iop3_pgn = 0xFECA _iop3_sa = 0x32 +print("[iop3] creating NativeCANSocket and NativeJ1939Socket(promisc=True)") _iop3_cansock = NativeCANSocket("vcan0") _iop3_native_rx = NativeJ1939Socket("vcan0", promisc=True) _iop3_native_rx.ins.settimeout(5.0) +print("[iop3] sockets created; NativeCANSocket fd=%r, NativeJ1939Socket fd=%r" % ( + getattr(_iop3_cansock, 'ins', None), getattr(_iop3_native_rx, 'ins', None))) + +_iop3_send_exc = [None] +_iop3_bam_frames = [] def _iop3_send(): sleep(0.1) - with J1939SoftSocket(_iop3_cansock, src_addr=_iop3_sa) as s: - s.send(J1939(_iop3_payload, pgn=_iop3_pgn, - dst=_socket.J1939_NO_ADDR, priority=6)) + try: + print("[iop3-tx] J1939SoftSocket opening; will send %d bytes via BAM" % len(_iop3_payload)) + with J1939SoftSocket(_iop3_cansock, src_addr=_iop3_sa) as s: + print("[iop3-tx] sending J1939 pgn=0x%X sa=0x%X payload_len=%d" % ( + _iop3_pgn, _iop3_sa, len(_iop3_payload))) + s.send(J1939(_iop3_payload, pgn=_iop3_pgn, + dst=_socket.J1939_NO_ADDR, priority=6)) + print("[iop3-tx] send() returned") + except Exception as _e: + _iop3_send_exc[0] = _e + print("[iop3-tx] EXCEPTION in sender: %r" % _e) _iop3_t = threading.Thread(target=_iop3_send) # The kernel reassembles BAM; snap until we see the full message. +print("[iop3] starting sniff (timeout=5.0, count=1) and sender thread") _iop3_pkts = _iop3_native_rx.sniff(timeout=5.0, started_callback=_iop3_t.start, count=1) _iop3_t.join(timeout=10) +print("[iop3] sniff done; received %d packet(s); sender exc=%r" % ( + len(_iop3_pkts), _iop3_send_exc[0])) +for _i, _p in enumerate(_iop3_pkts): + print("[iop3] pkt[%d]: pgn=0x%X src=0x%X dst=0x%X data_len=%d data=%r" % ( + _i, getattr(_p, 'pgn', None), getattr(_p, 'src', None), + getattr(_p, 'dst', None), len(getattr(_p, 'data', b'')), + getattr(_p, 'data', None))) _iop3_native_rx.close() assert _iop3_pkts, "NativeJ1939Socket received no BAM message from J1939SoftSocket" @@ -2485,19 +2553,39 @@ _iop4_payload = bytes(range(0x14, 0x28)) # 20 bytes _iop4_pgn = 0xFECA _iop4_sa = 0x33 +print("[iop4] creating NativeCANSocket, J1939SoftSocket(src=0x00), NativeJ1939Socket") _iop4_cansock = NativeCANSocket("vcan0") _iop4_soft_rx = J1939SoftSocket(_iop4_cansock, src_addr=0x00) _iop4_native_tx = NativeJ1939Socket("vcan0", src_addr=_iop4_sa, promisc=False) +print("[iop4] sockets created; NativeCANSocket fd=%r, NativeJ1939Socket fd=%r" % ( + getattr(_iop4_cansock, 'ins', None), getattr(_iop4_native_tx, 'ins', None))) + +_iop4_send_exc = [None] def _iop4_send(): sleep(0.1) - _iop4_native_tx.send( - J1939(_iop4_payload, pgn=_iop4_pgn, - src=_iop4_sa, dst=_socket.J1939_NO_ADDR)) + try: + print("[iop4-tx] sending J1939 pgn=0x%X sa=0x%X payload_len=%d via native socket" % ( + _iop4_pgn, _iop4_sa, len(_iop4_payload))) + _iop4_native_tx.send( + J1939(_iop4_payload, pgn=_iop4_pgn, + src=_iop4_sa, dst=_socket.J1939_NO_ADDR)) + print("[iop4-tx] send() returned") + except Exception as _e: + _iop4_send_exc[0] = _e + print("[iop4-tx] EXCEPTION in sender: %r" % _e) _iop4_t = threading.Thread(target=_iop4_send) +print("[iop4] starting sniff (timeout=5.0, count=1) and sender thread") _iop4_pkts = _iop4_soft_rx.sniff(timeout=5.0, started_callback=_iop4_t.start, count=1) _iop4_t.join(timeout=10) +print("[iop4] sniff done; received %d packet(s); sender exc=%r" % ( + len(_iop4_pkts), _iop4_send_exc[0])) +for _i, _p in enumerate(_iop4_pkts): + print("[iop4] pkt[%d]: pgn=0x%X src=0x%X dst=0x%X data_len=%d data=%r" % ( + _i, getattr(_p, 'pgn', None), getattr(_p, 'src', None), + getattr(_p, 'dst', None), len(getattr(_p, 'data', b'')), + getattr(_p, 'data', None))) _iop4_native_tx.close() _iop4_soft_rx.close() From d14dd709c59c80e30c12f1319e12c19bd935404e Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Fri, 12 Jun 2026 09:02:45 +0200 Subject: [PATCH 3/8] Add debug logging for J1939SoftSocket operations and packet handling AI-Assisted: no --- test/contrib/j1939.uts | 62 ++++++++++++++---------------------------- 1 file changed, 20 insertions(+), 42 deletions(-) diff --git a/test/contrib/j1939.uts b/test/contrib/j1939.uts index f6489510297..ac01e0eaa59 100644 --- a/test/contrib/j1939.uts +++ b/test/contrib/j1939.uts @@ -2405,8 +2405,7 @@ print("[iop1] creating NativeCANSocket and NativeJ1939Socket(promisc=True)") _iop1_cansock = NativeCANSocket("vcan0") _iop1_native_rx = NativeJ1939Socket("vcan0", promisc=True) _iop1_native_rx.ins.settimeout(3.0) -print("[iop1] sockets created; NativeCANSocket fd=%r, NativeJ1939Socket fd=%r" % ( - getattr(_iop1_cansock, 'ins', None), getattr(_iop1_native_rx, 'ins', None))) +print("[iop1] sockets created; NativeCANSocket fd=%r, NativeJ1939Socket fd=%r" % (getattr(_iop1_cansock, 'ins', None), getattr(_iop1_native_rx, 'ins', None))) _iop1_send_exc = [None] @@ -2415,10 +2414,8 @@ def _iop1_send(): try: print("[iop1-tx] J1939SoftSocket opening on NativeCANSocket") with J1939SoftSocket(_iop1_cansock, src_addr=_iop1_sa) as s: - print("[iop1-tx] sending J1939 pgn=0x%X sa=0x%X dst=0xFF data=%r" % ( - _iop1_pgn, _iop1_sa, _iop1_payload)) - s.send(J1939(_iop1_payload, pgn=_iop1_pgn, - dst=_socket.J1939_NO_ADDR, priority=6)) + print("[iop1-tx] sending J1939 pgn=0x%X sa=0x%X dst=0xFF data=%r" % (_iop1_pgn, _iop1_sa, _iop1_payload)) + s.send(J1939(_iop1_payload, pgn=_iop1_pgn, dst=_socket.J1939_NO_ADDR, priority=6)) print("[iop1-tx] send() returned") except Exception as _e: _iop1_send_exc[0] = _e @@ -2428,12 +2425,9 @@ _iop1_t = threading.Thread(target=_iop1_send) print("[iop1] starting sniff (timeout=3.0, count=1) and sender thread") _iop1_pkts = _iop1_native_rx.sniff(timeout=3.0, started_callback=_iop1_t.start, count=1) _iop1_t.join(timeout=5) -print("[iop1] sniff done; received %d packet(s); sender exc=%r" % ( - len(_iop1_pkts), _iop1_send_exc[0])) +print("[iop1] sniff done; received %d packet(s); sender exc=%r" % (len(_iop1_pkts), _iop1_send_exc[0])) for _i, _p in enumerate(_iop1_pkts): - print("[iop1] pkt[%d]: pgn=0x%X src=0x%X dst=0x%X data=%r" % ( - _i, getattr(_p, 'pgn', None), getattr(_p, 'src', None), - getattr(_p, 'dst', None), getattr(_p, 'data', None))) + print("[iop1] pkt[%d]: pgn=0x%X src=0x%X dst=0x%X data=%r" % (_i, getattr(_p, 'pgn', None), getattr(_p, 'src', None), getattr(_p, 'dst', None), getattr(_p, 'data', None))) _iop1_native_rx.close() assert _iop1_pkts, "NativeJ1939Socket received no packet from J1939SoftSocket" @@ -2454,19 +2448,16 @@ print("[iop2] creating NativeCANSocket, J1939SoftSocket, NativeJ1939Socket") _iop2_cansock = NativeCANSocket("vcan0") _iop2_soft_rx = J1939SoftSocket(_iop2_cansock, src_addr=0x00) _iop2_native_tx = NativeJ1939Socket("vcan0", src_addr=_iop2_sa, promisc=False) -print("[iop2] sockets created; NativeCANSocket fd=%r, NativeJ1939Socket fd=%r" % ( - getattr(_iop2_cansock, 'ins', None), getattr(_iop2_native_tx, 'ins', None))) +print("[iop2] sockets created; NativeCANSocket fd=%r, NativeJ1939Socket fd=%r" % (getattr(_iop2_cansock, 'ins', None), getattr(_iop2_native_tx, 'ins', None))) _iop2_send_exc = [None] def _iop2_send(): sleep(0.1) try: - print("[iop2-tx] sending J1939 pgn=0x%X sa=0x%X dst=NO_ADDR data=%r" % ( - _iop2_pgn, _iop2_sa, _iop2_payload)) + print("[iop2-tx] sending J1939 pgn=0x%X sa=0x%X dst=NO_ADDR data=%r" % (_iop2_pgn, _iop2_sa, _iop2_payload)) _iop2_native_tx.send( - J1939(_iop2_payload, pgn=_iop2_pgn, - src=_iop2_sa, dst=_socket.J1939_NO_ADDR)) + J1939(_iop2_payload, pgn=_iop2_pgn, src=_iop2_sa, dst=_socket.J1939_NO_ADDR)) print("[iop2-tx] send() returned") except Exception as _e: _iop2_send_exc[0] = _e @@ -2476,12 +2467,9 @@ _iop2_t = threading.Thread(target=_iop2_send) print("[iop2] starting sniff (timeout=3.0, count=1) and sender thread") _iop2_pkts = _iop2_soft_rx.sniff(timeout=3.0, started_callback=_iop2_t.start, count=1) _iop2_t.join(timeout=5) -print("[iop2] sniff done; received %d packet(s); sender exc=%r" % ( - len(_iop2_pkts), _iop2_send_exc[0])) +print("[iop2] sniff done; received %d packet(s); sender exc=%r" % (len(_iop2_pkts), _iop2_send_exc[0])) for _i, _p in enumerate(_iop2_pkts): - print("[iop2] pkt[%d]: pgn=0x%X src=0x%X dst=0x%X data=%r" % ( - _i, getattr(_p, 'pgn', None), getattr(_p, 'src', None), - getattr(_p, 'dst', None), getattr(_p, 'data', None))) + print("[iop2] pkt[%d]: pgn=0x%X src=0x%X dst=0x%X data=%r" % (_i, getattr(_p, 'pgn', None), getattr(_p, 'src', None), getattr(_p, 'dst', None), getattr(_p, 'data', None))) _iop2_native_tx.close() _iop2_soft_rx.close() @@ -2504,8 +2492,7 @@ print("[iop3] creating NativeCANSocket and NativeJ1939Socket(promisc=True)") _iop3_cansock = NativeCANSocket("vcan0") _iop3_native_rx = NativeJ1939Socket("vcan0", promisc=True) _iop3_native_rx.ins.settimeout(5.0) -print("[iop3] sockets created; NativeCANSocket fd=%r, NativeJ1939Socket fd=%r" % ( - getattr(_iop3_cansock, 'ins', None), getattr(_iop3_native_rx, 'ins', None))) +print("[iop3] sockets created; NativeCANSocket fd=%r, NativeJ1939Socket fd=%r" % (getattr(_iop3_cansock, 'ins', None), getattr(_iop3_native_rx, 'ins', None))) _iop3_send_exc = [None] _iop3_bam_frames = [] @@ -2515,10 +2502,8 @@ def _iop3_send(): try: print("[iop3-tx] J1939SoftSocket opening; will send %d bytes via BAM" % len(_iop3_payload)) with J1939SoftSocket(_iop3_cansock, src_addr=_iop3_sa) as s: - print("[iop3-tx] sending J1939 pgn=0x%X sa=0x%X payload_len=%d" % ( - _iop3_pgn, _iop3_sa, len(_iop3_payload))) - s.send(J1939(_iop3_payload, pgn=_iop3_pgn, - dst=_socket.J1939_NO_ADDR, priority=6)) + print("[iop3-tx] sending J1939 pgn=0x%X sa=0x%X payload_len=%d" % (_iop3_pgn, _iop3_sa, len(_iop3_payload))) + s.send(J1939(_iop3_payload, pgn=_iop3_pgn, dst=_socket.J1939_NO_ADDR, priority=6)) print("[iop3-tx] send() returned") except Exception as _e: _iop3_send_exc[0] = _e @@ -2529,13 +2514,10 @@ _iop3_t = threading.Thread(target=_iop3_send) print("[iop3] starting sniff (timeout=5.0, count=1) and sender thread") _iop3_pkts = _iop3_native_rx.sniff(timeout=5.0, started_callback=_iop3_t.start, count=1) _iop3_t.join(timeout=10) -print("[iop3] sniff done; received %d packet(s); sender exc=%r" % ( - len(_iop3_pkts), _iop3_send_exc[0])) +print("[iop3] sniff done; received %d packet(s); sender exc=%r" % (len(_iop3_pkts), _iop3_send_exc[0])) + for _i, _p in enumerate(_iop3_pkts): - print("[iop3] pkt[%d]: pgn=0x%X src=0x%X dst=0x%X data_len=%d data=%r" % ( - _i, getattr(_p, 'pgn', None), getattr(_p, 'src', None), - getattr(_p, 'dst', None), len(getattr(_p, 'data', b'')), - getattr(_p, 'data', None))) + print("[iop3] pkt[%d]: pgn=0x%X src=0x%X dst=0x%X data_len=%d data=%r" % (_i, getattr(_p, 'pgn', None), getattr(_p, 'src', None), getattr(_p, 'dst', None), len(getattr(_p, 'data', b'')), getattr(_p, 'data', None))) _iop3_native_rx.close() assert _iop3_pkts, "NativeJ1939Socket received no BAM message from J1939SoftSocket" @@ -2557,8 +2539,7 @@ print("[iop4] creating NativeCANSocket, J1939SoftSocket(src=0x00), NativeJ1939So _iop4_cansock = NativeCANSocket("vcan0") _iop4_soft_rx = J1939SoftSocket(_iop4_cansock, src_addr=0x00) _iop4_native_tx = NativeJ1939Socket("vcan0", src_addr=_iop4_sa, promisc=False) -print("[iop4] sockets created; NativeCANSocket fd=%r, NativeJ1939Socket fd=%r" % ( - getattr(_iop4_cansock, 'ins', None), getattr(_iop4_native_tx, 'ins', None))) +print("[iop4] sockets created; NativeCANSocket fd=%r, NativeJ1939Socket fd=%r" % (getattr(_iop4_cansock, 'ins', None), getattr(_iop4_native_tx, 'ins', None))) _iop4_send_exc = [None] @@ -2579,13 +2560,10 @@ _iop4_t = threading.Thread(target=_iop4_send) print("[iop4] starting sniff (timeout=5.0, count=1) and sender thread") _iop4_pkts = _iop4_soft_rx.sniff(timeout=5.0, started_callback=_iop4_t.start, count=1) _iop4_t.join(timeout=10) -print("[iop4] sniff done; received %d packet(s); sender exc=%r" % ( - len(_iop4_pkts), _iop4_send_exc[0])) +print("[iop4] sniff done; received %d packet(s); sender exc=%r" % (len(_iop4_pkts), _iop4_send_exc[0])) for _i, _p in enumerate(_iop4_pkts): - print("[iop4] pkt[%d]: pgn=0x%X src=0x%X dst=0x%X data_len=%d data=%r" % ( - _i, getattr(_p, 'pgn', None), getattr(_p, 'src', None), - getattr(_p, 'dst', None), len(getattr(_p, 'data', b'')), - getattr(_p, 'data', None))) + print("[iop4] pkt[%d]: pgn=0x%X src=0x%X dst=0x%X data_len=%d data=%r" % (_i, getattr(_p, 'pgn', None), getattr(_p, 'src', None), getattr(_p, 'dst', None), len(getattr(_p, 'data', b'')), getattr(_p, 'data', None))) + _iop4_native_tx.close() _iop4_soft_rx.close() From e4f9d0b627fb7bf094b9fb9bc7eb01da64a03c40 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Fri, 12 Jun 2026 12:11:54 +0200 Subject: [PATCH 4/8] Fix Unit-Tests AI-Assisted: yes (Claude Sonnet 4.6) --- test/contrib/j1939.uts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/contrib/j1939.uts b/test/contrib/j1939.uts index ac01e0eaa59..4174d42f1a4 100644 --- a/test/contrib/j1939.uts +++ b/test/contrib/j1939.uts @@ -2428,6 +2428,7 @@ _iop1_t.join(timeout=5) print("[iop1] sniff done; received %d packet(s); sender exc=%r" % (len(_iop1_pkts), _iop1_send_exc[0])) for _i, _p in enumerate(_iop1_pkts): print("[iop1] pkt[%d]: pgn=0x%X src=0x%X dst=0x%X data=%r" % (_i, getattr(_p, 'pgn', None), getattr(_p, 'src', None), getattr(_p, 'dst', None), getattr(_p, 'data', None))) + _iop1_native_rx.close() assert _iop1_pkts, "NativeJ1939Socket received no packet from J1939SoftSocket" @@ -2470,6 +2471,7 @@ _iop2_t.join(timeout=5) print("[iop2] sniff done; received %d packet(s); sender exc=%r" % (len(_iop2_pkts), _iop2_send_exc[0])) for _i, _p in enumerate(_iop2_pkts): print("[iop2] pkt[%d]: pgn=0x%X src=0x%X dst=0x%X data=%r" % (_i, getattr(_p, 'pgn', None), getattr(_p, 'src', None), getattr(_p, 'dst', None), getattr(_p, 'data', None))) + _iop2_native_tx.close() _iop2_soft_rx.close() @@ -2518,6 +2520,7 @@ print("[iop3] sniff done; received %d packet(s); sender exc=%r" % (len(_iop3_pkt for _i, _p in enumerate(_iop3_pkts): print("[iop3] pkt[%d]: pgn=0x%X src=0x%X dst=0x%X data_len=%d data=%r" % (_i, getattr(_p, 'pgn', None), getattr(_p, 'src', None), getattr(_p, 'dst', None), len(getattr(_p, 'data', b'')), getattr(_p, 'data', None))) + _iop3_native_rx.close() assert _iop3_pkts, "NativeJ1939Socket received no BAM message from J1939SoftSocket" From 183336e49790c02e029e34082368d7aa121b768a Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Fri, 12 Jun 2026 12:34:47 +0200 Subject: [PATCH 5/8] Fix Codacy AI-Assisted: no --- scapy/contrib/j1939.py | 197 ++++++++++++++++++++--------------------- 1 file changed, 98 insertions(+), 99 deletions(-) diff --git a/scapy/contrib/j1939.py b/scapy/contrib/j1939.py index 3a45203a351..d64bf6ee41a 100644 --- a/scapy/contrib/j1939.py +++ b/scapy/contrib/j1939.py @@ -31,12 +31,11 @@ https://www.kernel.org/doc/html/latest/networking/j1939.html """ +import logging import socket import struct -import logging import time import traceback - from typing import ( Any, Dict, @@ -50,6 +49,7 @@ ) from scapy.automaton import ObjectPipe, select_objects +from scapy.compat import raw from scapy.config import conf from scapy.consts import LINUX from scapy.data import SO_TIMESTAMPNS @@ -70,11 +70,10 @@ from scapy.layers.can import CAN from scapy.packet import Packet from scapy.supersocket import SuperSocket -from scapy.compat import raw from scapy.utils import EDecimal if TYPE_CHECKING: - from scapy.contrib.cansocket import CANSocket + pass log_j1939 = logging.getLogger("scapy.contrib.j1939") @@ -130,17 +129,17 @@ socket.SCM_J1939_ERRQUEUE = 4 #: Global broadcast address -J1939_BROADCAST_ADDR = socket.J1939_NO_ADDR # 0xFF +J1939_BROADCAST_ADDR = socket.J1939_NO_ADDR # 0xFF #: Transport Protocol – Connection Management J1939_PGN_TP_CM = 0xEC00 #: Transport Protocol – Data Transfer J1939_PGN_TP_DT = 0xEB00 # TP control byte values (integer constants; the classes share the prefix name) -J1939_TP_CTRL_RTS = 16 # Request To Send -J1939_TP_CTRL_CTS = 17 # Clear To Send -J1939_TP_CTRL_ACK = 19 # End of Message Acknowledge -J1939_TP_CTRL_BAM = 32 # Broadcast Announce Message +J1939_TP_CTRL_RTS = 16 # Request To Send +J1939_TP_CTRL_CTS = 17 # Clear To Send +J1939_TP_CTRL_ACK = 19 # End of Message Acknowledge +J1939_TP_CTRL_BAM = 32 # Broadcast Announce Message J1939_TP_CTRL_ABORT = 255 # Connection Abort # PDU format threshold: PF < 240 → PDU1 (peer-to-peer), PF ≥ 240 → PDU2 (broadcast) @@ -206,12 +205,12 @@ def j1939_to_can_id(priority, reserved, data_page, pdu_format, pdu_specific, src :returns: 29-bit CAN identifier value """ return ( - (priority & 0x7) << 26 | - (reserved & 0x1) << 25 | - (data_page & 0x1) << 24 | - (pdu_format & 0xFF) << 16 | - (pdu_specific & 0xFF) << 8 | - (src & 0xFF) + (priority & 0x7) << 26 | + (reserved & 0x1) << 25 | + (data_page & 0x1) << 24 | + (pdu_format & 0xFF) << 16 | + (pdu_specific & 0xFF) << 8 | + (src & 0xFF) ) @@ -279,8 +278,8 @@ class J1939(Packet): def __init__(self, *args, **kwargs): # type: (*Any, **Any) -> None - self.priority = kwargs.pop('priority', 6) # type: int - self.pgn = kwargs.pop('pgn', 0) # type: int + self.priority = kwargs.pop('priority', 6) # type: int + self.pgn = kwargs.pop('pgn', 0) # type: int self.src = kwargs.pop('src', socket.J1939_NO_ADDR) # type: int self.dst = kwargs.pop('dst', socket.J1939_NO_ADDR) # type: int Packet.__init__(self, *args, **kwargs) @@ -346,12 +345,12 @@ class J1939_CAN(CAN): # ── first 32 bits: CAN flags(3) + J1939 identifier fields(29) ────── FlagsField('flags', 0b100, 3, ['error', 'remote_transmission_request', 'extended']), - BitField('priority', 6, 3), # J1939 priority - BitField('reserved', 0, 1), # Reserved bit - BitField('data_page', 0, 1), # Data Page (DP) - ByteField('pdu_format', 0xFE), # PDU Format (PF) + BitField('priority', 6, 3), # J1939 priority + BitField('reserved', 0, 1), # Reserved bit + BitField('data_page', 0, 1), # Data Page (DP) + ByteField('pdu_format', 0xFE), # PDU Format (PF) ByteField('pdu_specific', 0xFF), # PDU Specific (PS): DA or GE - ByteField('src', 0xFE), # Source Address (SA) + ByteField('src', 0xFE), # Source Address (SA) # ── standard CAN data-length + padding ──────────────────────────── FieldLenField('length', None, length_of='data', fmt='B'), ThreeBytesField('reserved2', 0), @@ -442,11 +441,11 @@ class J1939_TP_CM_RTS(Packet): """ name = 'J1939_TP_CM_RTS' fields_desc = [ - ByteField('ctrl', J1939_TP_CTRL_RTS), # 16 - LEShortField('total_size', 0), # total message size (bytes) - ByteField('num_packets', 0), # total number of TP.DT packets - ByteField('max_packets', 0xFF), # max packets per CTS (0xFF = no limit) - XLE3BytesField('pgn', 0), # PGN of the message being transferred + ByteField('ctrl', J1939_TP_CTRL_RTS), # 16 + LEShortField('total_size', 0), # total message size (bytes) + ByteField('num_packets', 0), # total number of TP.DT packets + ByteField('max_packets', 0xFF), # max packets per CTS (0xFF = no limit) + XLE3BytesField('pgn', 0), # PGN of the message being transferred ] @@ -458,11 +457,11 @@ class J1939_TP_CM_CTS(Packet): """ name = 'J1939_TP_CM_CTS' fields_desc = [ - ByteField('ctrl', J1939_TP_CTRL_CTS), # 17 - ByteField('num_packets', 0), # number of packets to send now - ByteField('next_packet', 1), # next expected sequence number + ByteField('ctrl', J1939_TP_CTRL_CTS), # 17 + ByteField('num_packets', 0), # number of packets to send now + ByteField('next_packet', 1), # next expected sequence number ShortField('reserved', 0xFFFF), - XLE3BytesField('pgn', 0), # PGN of the message + XLE3BytesField('pgn', 0), # PGN of the message ] @@ -473,11 +472,11 @@ class J1939_TP_CM_ACK(Packet): """ name = 'J1939_TP_CM_ACK' fields_desc = [ - ByteField('ctrl', J1939_TP_CTRL_ACK), # 19 - LEShortField('total_size', 0), # total message size - ByteField('num_packets', 0), # total TP.DT packets received + ByteField('ctrl', J1939_TP_CTRL_ACK), # 19 + LEShortField('total_size', 0), # total message size + ByteField('num_packets', 0), # total TP.DT packets received ByteField('reserved', 0xFF), - XLE3BytesField('pgn', 0), # PGN of the message + XLE3BytesField('pgn', 0), # PGN of the message ] @@ -488,11 +487,11 @@ class J1939_TP_CM_BAM(Packet): """ name = 'J1939_TP_CM_BAM' fields_desc = [ - ByteField('ctrl', J1939_TP_CTRL_BAM), # 32 - LEShortField('total_size', 0), # total message size (bytes) - ByteField('num_packets', 0), # total number of TP.DT packets + ByteField('ctrl', J1939_TP_CTRL_BAM), # 32 + LEShortField('total_size', 0), # total message size (bytes) + ByteField('num_packets', 0), # total number of TP.DT packets ByteField('reserved', 0xFF), - XLE3BytesField('pgn', 0), # PGN of the message + XLE3BytesField('pgn', 0), # PGN of the message ] @@ -500,11 +499,11 @@ class J1939_TP_CM_ABORT(Packet): """J1939 TP Connection Management – Connection Abort.""" name = 'J1939_TP_CM_ABORT' fields_desc = [ - ByteField('ctrl', J1939_TP_CTRL_ABORT), # 255 - ByteField('reason', 0), # abort reason + ByteField('ctrl', J1939_TP_CTRL_ABORT), # 255 + ByteField('reason', 0), # abort reason ShortField('reserved', 0xFFFF), ByteField('reserved2', 0xFF), - XLE3BytesField('pgn', 0), # PGN of the aborted message + XLE3BytesField('pgn', 0), # PGN of the aborted message ] @@ -550,8 +549,8 @@ class J1939_TP_DT(Packet): """ name = 'J1939_TP_DT' fields_desc = [ - ByteField('seq_num', 1), # sequence number 1-255 - StrFixedLenField('data', b'\xff' * 7, 7), # 7 data bytes (0xFF = unused) + ByteField('seq_num', 1), # sequence number 1-255 + StrFixedLenField('data', b'\xff' * 7, 7), # 7 data bytes (0xFF = unused) ] @@ -615,14 +614,14 @@ class NativeJ1939Socket(SuperSocket): def __init__( self, - channel=None, # type: Optional[str] + channel=None, # type: Optional[str] src_name=socket.J1939_NO_NAME, # type: int src_addr=socket.J1939_NO_ADDR, # type: int - pgn=socket.J1939_NO_PGN, # type: int - promisc=True, # type: bool - filters=None, # type: Optional[List[Dict[str, int]]] - basecls=J1939, # type: Type[Packet] - **kwargs # type: Any + pgn=socket.J1939_NO_PGN, # type: int + promisc=True, # type: bool + filters=None, # type: Optional[List[Dict[str, int]]] + basecls=J1939, # type: Type[Packet] + **kwargs # type: Any ): # type: (...) -> None self.channel = channel or conf.contribs['J1939']['channel'] @@ -831,11 +830,11 @@ def send(self, x): # scapy.contrib.isotp.isotp_soft_socket. # J1939-21 transport-protocol timing constants (seconds) -_J1939_TP_BAM_DELAY = 0.050 # minimum inter-packet gap for BAM sender (50 ms) -_J1939_TP_T1 = 0.750 # receiver timeout for first DT after BAM/RTS -_J1939_TP_T2 = 1.250 # receiver timeout between consecutive DT frames -_J1939_TP_T3 = 1.250 # sender timeout waiting for CTS after RTS/block -_J1939_TP_T4 = 1.050 # sender timeout waiting for End-of-Message ACK +_J1939_TP_BAM_DELAY = 0.050 # minimum inter-packet gap for BAM sender (50 ms) +_J1939_TP_T1 = 0.750 # receiver timeout for first DT after BAM/RTS +_J1939_TP_T2 = 1.250 # receiver timeout between consecutive DT frames +_J1939_TP_T3 = 1.250 # sender timeout waiting for CTS after RTS/block +_J1939_TP_T4 = 1.050 # sender timeout waiting for End-of-Message ACK # On slow serial interfaces (slcan) the OS serial buffer may hold hundreds of # background CAN frames that the mux must drain before the TP.DT frames @@ -847,18 +846,18 @@ def send(self, x): _J1939_TP_DT_TIMEOUT_EXTENSION = 10 # Maximum payload / per-frame data constants -_J1939_TP_DT_DATA = 7 # usable data bytes per TP.DT packet -_J1939_TP_MAX_DATA = 1785 # maximum J1939 TP payload (255 × 7 bytes) +_J1939_TP_DT_DATA = 7 # usable data bytes per TP.DT packet +_J1939_TP_MAX_DATA = 1785 # maximum J1939 TP payload (255 × 7 bytes) # Internal RX state codes _J1939_RX_IDLE = 0 -_J1939_RX_WAIT_DT = 1 # waiting for TP.DT frames +_J1939_RX_WAIT_DT = 1 # waiting for TP.DT frames # Internal TX state codes _J1939_TX_IDLE = 0 -_J1939_TX_BAM = 1 # BAM TP.DT frames are being sent -_J1939_TX_RTS_WAIT_CTS = 2 # RTS sent; waiting for CTS -_J1939_TX_RTS_SENDING = 3 # CTS received; sending TP.DT block +_J1939_TX_BAM = 1 # BAM TP.DT frames are being sent +_J1939_TX_RTS_WAIT_CTS = 2 # RTS sent; waiting for CTS +_J1939_TX_RTS_SENDING = 3 # CTS received; sending TP.DT block class J1939TPImplementation: @@ -883,10 +882,10 @@ class J1939TPImplementation: def __init__( self, - can_socket, # type: "CANSocket" - src_addr, # type: int - listen_only=False, # type: bool - pgn_filter=0, # type: int + can_socket, # type: "CANSocket" + src_addr, # type: int + listen_only=False, # type: bool + pgn_filter=0, # type: int ): # type: (...) -> None from scapy.contrib.isotp.isotp_soft_socket import TimeoutScheduler @@ -902,37 +901,37 @@ def __init__( # ── receive path ────────────────────────────────────────────────────── self.rx_state = _J1939_RX_IDLE # type: int # Active RX session fields (valid when rx_state == _J1939_RX_WAIT_DT) - self.rx_pgn = 0 # PGN being received - self.rx_peer_sa = socket.J1939_NO_ADDR # SA of the sending node - self.rx_dst = socket.J1939_NO_ADDR # DA (our SA or 0xFF broadcast) - self.rx_total = 0 # total payload size (bytes) - self.rx_npkts = 0 # total TP.DT packets expected - self.rx_buf = b'' # accumulated payload bytes - self.rx_seq = 1 # next expected DT seq number - self.rx_ts = 0.0 # type: Union[float, EDecimal] - self.rx_is_bam = True # True=BAM; False=RTS/CTS - self.rx_start_time = 0.0 # wall-clock start of current TP rx - self.rx_timeout_handle = None # type: Optional[Any] + self.rx_pgn = 0 # PGN being received + self.rx_peer_sa = socket.J1939_NO_ADDR # SA of the sending node + self.rx_dst = socket.J1939_NO_ADDR # DA (our SA or 0xFF broadcast) + self.rx_total = 0 # total payload size (bytes) + self.rx_npkts = 0 # total TP.DT packets expected + self.rx_buf = b'' # accumulated payload bytes + self.rx_seq = 1 # next expected DT seq number + self.rx_ts = 0.0 # type: Union[float, EDecimal] + self.rx_is_bam = True # True=BAM; False=RTS/CTS + self.rx_start_time = 0.0 # wall-clock start of current TP rx + self.rx_timeout_handle = None # type: Optional[Any] # Delivered received messages: each item is (J1939, timestamp) - self.rx_queue = ObjectPipe() # type: ignore + self.rx_queue = ObjectPipe() # type: ignore # ── transmit path ───────────────────────────────────────────────────── self.tx_state = _J1939_TX_IDLE # type: int - self.tx_buf = None # type: Optional[bytes] + self.tx_buf = None # type: Optional[bytes] self.tx_pgn = 0 self.tx_dst = socket.J1939_NO_ADDR self.tx_priority = 6 self.tx_data_page = 0 - self.tx_npkts = 0 # total TP.DT packets to send - self.tx_seq = 1 # next TP.DT sequence number to send + self.tx_npkts = 0 # total TP.DT packets to send + self.tx_seq = 1 # next TP.DT sequence number to send self.tx_peer_sa = socket.J1939_NO_ADDR # peer SA for RTS/CTS sessions # CTS block management - self.tx_cts_count = 0 # DTs still to send in current CTS block - self.tx_timeout_handle = None # type: Optional[Any] + self.tx_cts_count = 0 # DTs still to send in current CTS block + self.tx_timeout_handle = None # type: Optional[Any] # Enqueued outgoing messages: each item is a J1939 packet - self.tx_queue = ObjectPipe() # type: ignore + self.tx_queue = ObjectPipe() # type: ignore # ── background polling ──────────────────────────────────────────────── self.rx_handle = TimeoutScheduler.schedule(0, self.can_recv) @@ -966,17 +965,17 @@ def close(self): if handle is not None: try: handle.cancel() - except Exception: - pass + except Exception as e: + log_runtime.debug(str(e)) try: self.rx_queue.close() - except Exception: - pass + except Exception as e: + log_runtime.debug(str(e)) try: self.tx_queue.close() - except Exception: - pass + except Exception as e: + log_runtime.debug(str(e)) # ── CAN receive loop ───────────────────────────────────────────────────── @@ -1021,7 +1020,7 @@ def on_can_recv(self, pkt): return # ── TP.CM (PF = 0xEC) ──────────────────────────────────────────────── - if pf == (J1939_PGN_TP_CM >> 8): # 0xEC + if pf == (J1939_PGN_TP_CM >> 8): # 0xEC # PS must address us or be broadcast. if ps != self.src_addr and ps != socket.J1939_NO_ADDR: return @@ -1029,7 +1028,7 @@ def on_can_recv(self, pkt): return # ── TP.DT (PF = 0xEB) ──────────────────────────────────────────────── - if pf == (J1939_PGN_TP_DT >> 8): # 0xEB + if pf == (J1939_PGN_TP_DT >> 8): # 0xEB if ps != self.src_addr and ps != socket.J1939_NO_ADDR: return self._on_tp_dt(j) @@ -1235,7 +1234,7 @@ def _can_send_tp_cm(self, dst_sa, data): # type: (int, bytes) -> None pkt = J1939_CAN( priority=6, data_page=0, - pdu_format=J1939_PGN_TP_CM >> 8, # 0xEC + pdu_format=J1939_PGN_TP_CM >> 8, # 0xEC pdu_specific=dst_sa, src=self.src_addr, data=data, @@ -1248,7 +1247,7 @@ def _can_send_tp_dt(self, dst_sa, seq_num, chunk): dt = J1939_TP_DT(seq_num=seq_num, data=padded[:_J1939_TP_DT_DATA]) pkt = J1939_CAN( priority=7, data_page=0, - pdu_format=J1939_PGN_TP_DT >> 8, # 0xEB + pdu_format=J1939_PGN_TP_DT >> 8, # 0xEB pdu_specific=dst_sa, src=self.src_addr, data=bytes(dt), @@ -1519,11 +1518,11 @@ class J1939SoftSocket(SuperSocket): def __init__( self, - can_socket=None, # type: Optional["CANSocket"] - src_addr=socket.J1939_NO_ADDR, # type: int - basecls=J1939, # type: Type[Packet] - listen_only=False, # type: bool - pgn=0, # type: int + can_socket=None, # type: Optional["CANSocket"] + src_addr=socket.J1939_NO_ADDR, # type: int + basecls=J1939, # type: Type[Packet] + listen_only=False, # type: bool + pgn=0, # type: int ): # type: (...) -> None if LINUX and isinstance(can_socket, str): @@ -1617,7 +1616,7 @@ def select(sockets, remain=None): # type: ignore[override] result = [ x for x in sockets if isinstance(x, J1939SoftSocket) and not x.closed - and x.impl.rx_queue in ready_pipes + and x.impl.rx_queue in ready_pipes ] result += [ x for x in sockets From 319929c3e20e5306df1cf1443d50b626d6952308 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Thu, 13 Aug 2026 08:11:49 +0200 Subject: [PATCH 6/8] j1939: fix the transport protocol against oversized, concurrent and hostile transfers The soft socket handled the transfers its own tests produce, all of them under 100 bytes from a single well-behaved peer. Everything past that was either dropped or actively harmful, and none of it was visible because no test exceeded one CTS block, ran two senders, or sent a malformed frame. A payload of more than 1785 bytes needs more TP.DT packets than a sequence number can express, so building the announcement raised inside the scheduler thread after the TX state had already been set. The message vanished with a log line and the state machine stayed latched, which silently discarded every later send on that socket. send() now refuses such a payload, _J1939_TP_MAX_DATA finally being used for what it was defined for, and a failure anywhere in _begin_send resets the machine instead of wedging it. Reception was a single set of rx_* attributes, so a second announcement threw away the transfer in progress. Since a busy J1939 bus has several ECUs broadcasting at once, a monitor built on this socket lost most of what it saw. Sessions now live in a dict keyed by the (source address, destination) pair the protocol itself uses, capped so a hostile bus cannot grow it without bound, and a peer that asks for a second PGN while one is running is refused with an abort rather than displacing it. Frames from the bus are no longer taken at face value. An announcement whose size and packet count cannot describe a message is refused instead of delivering an empty payload; a CTS naming a packet outside the message is aborted instead of indexing the buffer backwards and emitting sequence number 0; and CTS, acknowledgement and abort frames must now name the PGN of the session they claim to be part of. tx_peer_sa is cleared when a session ends, so a node that took part in an earlier transfer can no longer abort an unrelated broadcast. The rest are smaller: the receiver honours the max_packets of a request and issues a CTS per block instead of authorising everything at once, close() derives its drain budget from what is left to send rather than truncating any broadcast longer than two seconds while __del__ no longer drains at all, basecls is used for delivered messages and recv_raw returns the payload, transport frames carry the caller's priority as single frames already did, a source address of 0xFF warns because it cannot legally appear on the wire, and a CAN socket that goes away closes the J1939 socket instead of leaving a caller blocked in recv() forever. The twelve new cases in the campaign each fail on the code before this commit and pass after it. The existing 185 are untouched and still pass. AI-Assisted: yes (Cursor) Co-authored-by: Cursor --- scapy/contrib/j1939.py | 589 +++++++++++++++++++++++++++++------------ test/contrib/j1939.uts | 325 +++++++++++++++++++++++ 2 files changed, 749 insertions(+), 165 deletions(-) diff --git a/scapy/contrib/j1939.py b/scapy/contrib/j1939.py index d64bf6ee41a..754a2c0999d 100644 --- a/scapy/contrib/j1939.py +++ b/scapy/contrib/j1939.py @@ -73,7 +73,7 @@ from scapy.utils import EDecimal if TYPE_CHECKING: - pass + from scapy.contrib.cansocket import CANSocket log_j1939 = logging.getLogger("scapy.contrib.j1939") @@ -205,12 +205,12 @@ def j1939_to_can_id(priority, reserved, data_page, pdu_format, pdu_specific, src :returns: 29-bit CAN identifier value """ return ( - (priority & 0x7) << 26 | - (reserved & 0x1) << 25 | - (data_page & 0x1) << 24 | - (pdu_format & 0xFF) << 16 | - (pdu_specific & 0xFF) << 8 | - (src & 0xFF) + (priority & 0x7) << 26 | + (reserved & 0x1) << 25 | + (data_page & 0x1) << 24 | + (pdu_format & 0xFF) << 16 | + (pdu_specific & 0xFF) << 8 | + (src & 0xFF) ) @@ -847,11 +847,20 @@ def send(self, x): # Maximum payload / per-frame data constants _J1939_TP_DT_DATA = 7 # usable data bytes per TP.DT packet +_J1939_TP_MAX_PACKETS = 255 # sequence numbers are a single byte _J1939_TP_MAX_DATA = 1785 # maximum J1939 TP payload (255 × 7 bytes) -# Internal RX state codes -_J1939_RX_IDLE = 0 -_J1939_RX_WAIT_DT = 1 # waiting for TP.DT frames +# A node may run one TP session per (source address, destination address) +# pair, so several ECUs can be received at once. The cap only exists to +# bound memory on a hostile bus. +_J1939_MAX_RX_SESSIONS = 16 + +# J1939-21 connection abort reasons +_J1939_ABORT_IN_SESSION = 1 # already in a connection-managed session +_J1939_ABORT_RESOURCES = 2 # system resources needed for another task +_J1939_ABORT_TIMEOUT = 3 # a timeout occurred +_J1939_ABORT_BAD_SEQ = 7 # bad sequence number +_J1939_ABORT_OTHER = 250 # any other reason # Internal TX state codes _J1939_TX_IDLE = 0 @@ -860,6 +869,45 @@ def send(self, x): _J1939_TX_RTS_SENDING = 3 # CTS received; sending TP.DT block +class _J1939_RXSession(object): + """One TP reception in progress. + + J1939-21 allows a node to run one session per (source address, + destination address) pair, so sessions are keyed by that pair: several + ECUs may broadcast at the same time, which is the normal state of a + busy bus. + """ + + __slots__ = ['sa', 'dst', 'pgn', 'total', 'npkts', 'is_bam', 'ts', + 'buf', 'seq', 'block_end', 'block_size', 'start_time', + 'timeout_handle'] + + def __init__(self, sa, dst, pgn, total, npkts, is_bam, ts): + # type: (int, int, int, int, int, bool, Union[float, EDecimal]) -> None + self.sa = sa + self.dst = dst + self.pgn = pgn + self.total = total + self.npkts = npkts + self.is_bam = is_bam + self.ts = ts + self.buf = b'' + self.seq = 1 # next expected TP.DT sequence number + # Last sequence number the peer is currently allowed to send. For a + # BAM the whole message is authorised; for RTS/CTS it is the end of + # the block named by our most recent CTS. + self.block_end = npkts + # Packets per CTS, bounded by the max_packets the sender announced. + self.block_size = npkts + self.start_time = time.monotonic() + self.timeout_handle = None # type: Optional[Any] + + @property + def key(self): + # type: () -> Tuple[int, int] + return self.sa, self.dst + + class J1939TPImplementation: """Software implementation of the SAE J1939 Transport Protocol state machine. @@ -886,6 +934,7 @@ def __init__( src_addr, # type: int listen_only=False, # type: bool pgn_filter=0, # type: int + basecls=None, # type: Optional[Type[Packet]] ): # type: (...) -> None from scapy.contrib.isotp.isotp_soft_socket import TimeoutScheduler @@ -895,23 +944,13 @@ def __init__( self.src_addr = src_addr self.listen_only = listen_only self.pgn_filter = pgn_filter # 0 = accept all PGNs + self.basecls = basecls or J1939 # type: Type[Packet] self.closed = False self.rx_tx_poll_rate = 0.005 # ── receive path ────────────────────────────────────────────────────── - self.rx_state = _J1939_RX_IDLE # type: int - # Active RX session fields (valid when rx_state == _J1939_RX_WAIT_DT) - self.rx_pgn = 0 # PGN being received - self.rx_peer_sa = socket.J1939_NO_ADDR # SA of the sending node - self.rx_dst = socket.J1939_NO_ADDR # DA (our SA or 0xFF broadcast) - self.rx_total = 0 # total payload size (bytes) - self.rx_npkts = 0 # total TP.DT packets expected - self.rx_buf = b'' # accumulated payload bytes - self.rx_seq = 1 # next expected DT seq number - self.rx_ts = 0.0 # type: Union[float, EDecimal] - self.rx_is_bam = True # True=BAM; False=RTS/CTS - self.rx_start_time = 0.0 # wall-clock start of current TP rx - self.rx_timeout_handle = None # type: Optional[Any] + # In-progress receptions, keyed by (source address, destination). + self.rx_sessions = {} # type: Dict[Tuple[int, int], _J1939_RXSession] # Delivered received messages: each item is (J1939, timestamp) self.rx_queue = ObjectPipe() # type: ignore @@ -941,27 +980,58 @@ def __init__( def __del__(self): # type: () -> None - self.close() + # Never drain from the garbage collector: a pending BAM can take + # seconds, and __del__ may run at interpreter shutdown. + self.close(timeout=0) - def close(self): - # type: () -> None + def drain_timeout(self): + # type: () -> float + """Time a pending transmission still needs, as a close() budget. + + A BAM is paced at :data:`_J1939_TP_BAM_DELAY` per packet, so a full + 1785-byte message legitimately takes 12.75 s; an RTS/CTS session is + bounded by its own timeouts. + """ + pending = max(self.tx_npkts - self.tx_seq + 1, 0) + return max(pending * _J1939_TP_BAM_DELAY + 0.5, + _J1939_TP_T3 + _J1939_TP_T4) + + def close(self, timeout=None): + # type: (Optional[float]) -> None + """Stop the state machine. + + :param timeout: how long to let a transmission in progress finish. + ``None`` derives a budget from what is still queued, + so that a large BAM is not truncated; ``0`` shuts + down at once. + """ if self.closed: return # Wait for any in-progress TX to drain before shutting down. # This ensures that a send() followed immediately by close() (e.g. # inside a ``with`` statement) still delivers every queued message. - deadline = time.monotonic() + 2.0 + derived = timeout is None + if timeout is None: + timeout = self.drain_timeout() + deadline = time.monotonic() + timeout while time.monotonic() < deadline: if (self.tx_state == _J1939_TX_IDLE and not select_objects([self.tx_queue], 0)): break + if derived and self.tx_state != _J1939_TX_IDLE: + # A message that was still queued when close() was called + # gets its own budget once it starts. + deadline = max(deadline, + time.monotonic() + self.drain_timeout()) time.sleep(0.005) self.closed = True # Brief pause so any in-flight scheduler callback sees the flag. time.sleep(0.005) - for handle in (self.rx_handle, self.tx_handle, - self.rx_timeout_handle, self.tx_timeout_handle): + handles = [self.rx_handle, self.tx_handle, self.tx_timeout_handle] + handles += [s.timeout_handle for s in self.rx_sessions.values()] + self.rx_sessions.clear() + for handle in handles: if handle is not None: try: handle.cancel() @@ -983,6 +1053,11 @@ def can_recv(self): # type: () -> None if self.closed: return + if self.can_socket.closed: + log_j1939.warning( + "J1939 TP: underlying CAN socket closed, closing socket") + self.close(timeout=0) + return try: while self.can_socket.select([self.can_socket], 0): if self.closed: @@ -998,9 +1073,17 @@ def can_recv(self): "J1939TPImplementation.can_recv error: %s", traceback.format_exc()) - if not self.closed and not self.can_socket.closed: - self.rx_handle = self._TimeoutScheduler.schedule( - self.rx_tx_poll_rate, self.can_recv) + if self.closed: + return + if self.can_socket.closed: + # The CAN socket went away: without this the pump would simply + # stop and a caller blocked in recv() would wait forever. + log_j1939.warning( + "J1939 TP: underlying CAN socket closed, closing socket") + self.close(timeout=0) + return + self.rx_handle = self._TimeoutScheduler.schedule( + self.rx_tx_poll_rate, self.can_recv) def on_can_recv(self, pkt): # type: (Packet) -> None @@ -1048,7 +1131,8 @@ def _on_short_frame(self, j): data = bytes(j.data) if self.pgn_filter != 0 and j.pgn != self.pgn_filter: return - msg = J1939(data, pgn=j.pgn, src=j.src, dst=j.dst, priority=j.priority) + msg = self.basecls(data, pgn=j.pgn, src=j.src, dst=j.dst, + priority=j.priority) self.rx_queue.send((msg, j.time)) def _on_tp_cm(self, j): @@ -1063,62 +1147,62 @@ def _on_tp_cm(self, j): if ctrl == J1939_TP_CTRL_BAM: if len(data) < 8: return - cm = J1939_TP_CM_BAM(data) - if self.pgn_filter != 0 and cm.pgn != self.pgn_filter: + bam = J1939_TP_CM_BAM(data) + if self.pgn_filter != 0 and bam.pgn != self.pgn_filter: return - if self.rx_state != _J1939_RX_IDLE: - log_j1939.debug("J1939 TP: new BAM overwrites active RX session") - self._rx_reset() - self._rx_start(sa=sa, pgn=cm.pgn, dst=socket.J1939_NO_ADDR, - total=cm.total_size, npkts=cm.num_packets, - is_bam=True, ts=ts) + self._rx_start(sa=sa, pgn=bam.pgn, dst=socket.J1939_NO_ADDR, + total=bam.total_size, npkts=bam.num_packets, + max_packets=bam.num_packets, is_bam=True, ts=ts) elif ctrl == J1939_TP_CTRL_RTS: if len(data) < 8: return - cm = J1939_TP_CM_RTS(data) - if self.pgn_filter != 0 and cm.pgn != self.pgn_filter: + rts = J1939_TP_CM_RTS(data) + if self.pgn_filter != 0 and rts.pgn != self.pgn_filter: return - if self.rx_state != _J1939_RX_IDLE: - log_j1939.debug("J1939 TP: new RTS overwrites active RX session") - self._rx_reset() - self._rx_start(sa=sa, pgn=cm.pgn, dst=self.src_addr, - total=cm.total_size, npkts=cm.num_packets, - is_bam=False, ts=ts) - # Respond with CTS authorising all packets starting at seq 1. - if not self.listen_only: - self._can_send_tp_cm( - dst_sa=sa, - data=bytes(J1939_TP_CM_CTS( - num_packets=cm.num_packets, - next_packet=1, - pgn=cm.pgn, - )), - ) + self._rx_start(sa=sa, pgn=rts.pgn, dst=self.src_addr, + total=rts.total_size, npkts=rts.num_packets, + max_packets=rts.max_packets, is_bam=False, ts=ts) elif ctrl == J1939_TP_CTRL_CTS: - if (self.tx_state == _J1939_TX_RTS_WAIT_CTS - and sa == self.tx_peer_sa and len(data) >= 8): - self._tx_handle_cts(J1939_TP_CM_CTS(data)) + if len(data) < 8: + return + cts = J1939_TP_CM_CTS(data) + if (self.tx_state == _J1939_TX_RTS_WAIT_CTS and + sa == self.tx_peer_sa and cts.pgn == self.tx_pgn): + self._tx_handle_cts(cts) elif ctrl == J1939_TP_CTRL_ACK: - if (self.tx_state in (_J1939_TX_RTS_WAIT_CTS, _J1939_TX_RTS_SENDING) - and sa == self.tx_peer_sa): + if len(data) < 8: + return + ack = J1939_TP_CM_ACK(data) + if (self.tx_state in (_J1939_TX_RTS_WAIT_CTS, + _J1939_TX_RTS_SENDING) and + sa == self.tx_peer_sa and ack.pgn == self.tx_pgn): self._tx_reset() elif ctrl == J1939_TP_CTRL_ABORT: - if sa == self.tx_peer_sa: - reason = data[1] if len(data) > 1 else 0 + abort = J1939_TP_CM_ABORT(data) if len(data) >= 8 else None + # Only the peer of a session actually in progress may abort it, + # and only for the PGN being transferred: an address left over + # from an earlier session must not tear down the current one. + if (self.tx_state != _J1939_TX_IDLE and sa == self.tx_peer_sa and + abort is not None and abort.pgn == self.tx_pgn): log_j1939.warning( - "J1939 TP: TX session aborted by peer (reason %d)", reason) + "J1939 TP: TX session aborted by peer (reason %d)", + abort.reason) self._tx_reset() + # A peer may equally abort a reception it started. + session = self.rx_sessions.get((sa, self.src_addr)) + if session is not None and abort is not None and \ + abort.pgn == session.pgn: + self._rx_drop(session, "aborted by peer") def _on_tp_dt(self, j): # type: (J1939_CAN) -> None - if self.rx_state != _J1939_RX_WAIT_DT: - return sa = j.src - if sa != self.rx_peer_sa: + session = self.rx_sessions.get((sa, j.pdu_specific)) + if session is None: return data = bytes(j.data) if len(data) < 8: @@ -1126,99 +1210,183 @@ def _on_tp_dt(self, j): dt = J1939_TP_DT(data) seq = dt.seq_num - if seq != self.rx_seq: + if seq != session.seq: log_j1939.warning( - "J1939 TP: bad DT seq %d (expected %d)", seq, self.rx_seq) - if not self.rx_is_bam and not self.listen_only: - self._can_send_tp_cm( - dst_sa=sa, - data=bytes(J1939_TP_CM_ABORT(reason=7, pgn=self.rx_pgn)), - ) - self._rx_reset() + "J1939 TP: bad DT seq %d (expected %d)", seq, session.seq) + self._rx_abort(session, _J1939_ABORT_BAD_SEQ) + return + if seq > session.block_end: + # More data than our CTS authorised. + log_j1939.warning( + "J1939 TP: DT seq %d beyond the authorised block (%d)", + seq, session.block_end) + self._rx_abort(session, _J1939_ABORT_OTHER) return - self.rx_buf += dt.data - self.rx_seq += 1 - - # Cancel / reschedule the DT timeout. - if self.rx_timeout_handle is not None: - try: - self.rx_timeout_handle.cancel() - except Exception: - pass - self.rx_timeout_handle = None + session.buf += dt.data + session.seq += 1 + self._rx_cancel_timer(session) - if seq >= self.rx_npkts: + if seq >= session.npkts: # All packets received – finalise the message. - payload = self.rx_buf[:self.rx_total] - if not self.rx_is_bam and not self.listen_only: + payload = session.buf[:session.total] + if not session.is_bam and not self.listen_only: self._can_send_tp_cm( dst_sa=sa, data=bytes(J1939_TP_CM_ACK( - total_size=self.rx_total, - num_packets=self.rx_npkts, - pgn=self.rx_pgn, + total_size=session.total, + num_packets=session.npkts, + pgn=session.pgn, )), ) - msg = J1939(payload, - pgn=self.rx_pgn, src=self.rx_peer_sa, - dst=self.rx_dst, priority=6) - self.rx_queue.send((msg, self.rx_ts)) - self._rx_reset() - else: - self.rx_timeout_handle = self._TimeoutScheduler.schedule( - _J1939_TP_T2, self._rx_timeout) + msg = self.basecls(payload, + pgn=session.pgn, src=session.sa, + dst=session.dst, priority=6) + self.rx_queue.send((msg, session.ts)) + self._rx_forget(session) + return + + if seq >= session.block_end and not session.is_bam: + # The block we authorised is complete: authorise the next one. + self._rx_send_cts(session) + self._rx_arm_timer(session, _J1939_TP_T2) # ── RX session helpers ──────────────────────────────────────────────────── - def _rx_start(self, sa, pgn, dst, total, npkts, is_bam, ts): - # type: (int, int, int, int, int, bool, Union[float, EDecimal]) -> None - self.rx_state = _J1939_RX_WAIT_DT - self.rx_peer_sa = sa - self.rx_pgn = pgn - self.rx_dst = dst - self.rx_total = total - self.rx_npkts = npkts - self.rx_buf = b'' - self.rx_seq = 1 - self.rx_ts = ts - self.rx_is_bam = is_bam - self.rx_start_time = time.monotonic() - if self.rx_timeout_handle is not None: - try: - self.rx_timeout_handle.cancel() - except Exception: - pass - self.rx_timeout_handle = self._TimeoutScheduler.schedule( - _J1939_TP_T1, self._rx_timeout) + def _rx_start(self, sa, pgn, dst, total, npkts, max_packets, is_bam, ts): + # type: (int, int, int, int, int, int, bool, Union[float, EDecimal]) -> None # noqa: E501 + """Open a reception for *sa*, replacing any session that peer had.""" + # An announcement that cannot describe a real message is refused + # rather than turned into an empty or truncated delivery. + if not 1 <= npkts <= _J1939_TP_MAX_PACKETS or \ + not 1 <= total <= _J1939_TP_MAX_DATA or \ + npkts != (total + _J1939_TP_DT_DATA - 1) // _J1939_TP_DT_DATA: + log_j1939.warning( + "J1939 TP: refusing session from SA=0x%02X with " + "total_size=%d num_packets=%d", sa, total, npkts) + if not is_bam and not self.listen_only: + self._can_send_tp_cm( + dst_sa=sa, + data=bytes(J1939_TP_CM_ABORT( + reason=_J1939_ABORT_OTHER, pgn=pgn)), + ) + return - def _rx_reset(self): - # type: () -> None - self.rx_state = _J1939_RX_IDLE - if self.rx_timeout_handle is not None: + old = self.rx_sessions.get((sa, dst)) + if old is not None: + if not is_bam and old.pgn != pgn: + # J1939-21: a peer gets one connection with us at a time, so + # a request for a second PGN is refused and the transfer + # already running is kept. + log_j1939.warning( + "J1939 TP: SA=0x%02X is already in a session for " + "PGN 0x%05X", sa, old.pgn) + if not self.listen_only: + self._can_send_tp_cm( + dst_sa=sa, + data=bytes(J1939_TP_CM_ABORT( + reason=_J1939_ABORT_IN_SESSION, pgn=pgn)), + ) + return + log_j1939.debug( + "J1939 TP: SA=0x%02X restarts its session", sa) + self._rx_forget(old) + elif len(self.rx_sessions) >= _J1939_MAX_RX_SESSIONS: + log_j1939.warning( + "J1939 TP: %d concurrent sessions, refusing SA=0x%02X", + len(self.rx_sessions), sa) + if not is_bam and not self.listen_only: + self._can_send_tp_cm( + dst_sa=sa, + data=bytes(J1939_TP_CM_ABORT( + reason=_J1939_ABORT_RESOURCES, pgn=pgn)), + ) + return + + session = _J1939_RXSession(sa, dst, pgn, total, npkts, is_bam, ts) + self.rx_sessions[session.key] = session + if not is_bam: + # J1939-21 flow control: never authorise more packets in one + # block than the sender said it can send. + session.block_size = min(max_packets or npkts, npkts) + self._rx_send_cts(session) + self._rx_arm_timer(session, _J1939_TP_T1) + + def _rx_send_cts(self, session): + # type: (_J1939_RXSession) -> None + """Authorise the next block of TP.DT packets.""" + remaining = session.npkts - session.seq + 1 + count = min(remaining, session.block_size) + if count <= 0: + return + session.block_end = session.seq + count - 1 + if self.listen_only: + # Passive monitoring: say nothing, but still accept whatever the + # sender chooses to send. + session.block_end = session.npkts + return + self._can_send_tp_cm( + dst_sa=session.sa, + data=bytes(J1939_TP_CM_CTS( + num_packets=count, + next_packet=session.seq, + pgn=session.pgn, + )), + ) + + def _rx_cancel_timer(self, session): + # type: (_J1939_RXSession) -> None + if session.timeout_handle is not None: try: - self.rx_timeout_handle.cancel() + session.timeout_handle.cancel() except Exception: pass - self.rx_timeout_handle = None + session.timeout_handle = None + + def _rx_arm_timer(self, session, delay): + # type: (_J1939_RXSession, float) -> None + self._rx_cancel_timer(session) + key = session.key + session.timeout_handle = self._TimeoutScheduler.schedule( + delay, lambda: self._rx_timeout(key)) + + def _rx_forget(self, session): + # type: (_J1939_RXSession) -> None + self._rx_cancel_timer(session) + self.rx_sessions.pop(session.key, None) + + def _rx_drop(self, session, why): + # type: (_J1939_RXSession, str) -> None + log_j1939.warning( + "J1939 TP: discarding incomplete message %s " + "(PGN=0x%05X SA=0x%02X)", why, session.pgn, session.sa) + self._rx_forget(session) + + def _rx_abort(self, session, reason): + # type: (_J1939_RXSession, int) -> None + """Drop a reception, telling the peer why when the protocol allows.""" + if not session.is_bam and not self.listen_only: + self._can_send_tp_cm( + dst_sa=session.sa, + data=bytes(J1939_TP_CM_ABORT( + reason=reason, pgn=session.pgn)), + ) + self._rx_drop(session, "(abort reason %d)" % reason) - def _rx_timeout(self): - # type: () -> None - if self.closed or self.rx_state == _J1939_RX_IDLE: + def _rx_timeout(self, key): + # type: (Tuple[int, int]) -> None + session = self.rx_sessions.get(key) + if self.closed or session is None: return # On slow serial interfaces (slcan) the OS serial buffer may hold many # background CAN frames queued ahead of TP.DT frames. Re-arm the # timer as long as the total elapsed time since the session started is # below _J1939_TP_T2 × _J1939_TP_DT_TIMEOUT_EXTENSION (12.5 s total). - total_wait = time.monotonic() - self.rx_start_time + total_wait = time.monotonic() - session.start_time if total_wait < _J1939_TP_T2 * _J1939_TP_DT_TIMEOUT_EXTENSION: - self.rx_timeout_handle = self._TimeoutScheduler.schedule( - _J1939_TP_T2, self._rx_timeout) + self._rx_arm_timer(session, _J1939_TP_T2) return - log_j1939.warning( - "J1939 TP: RX timeout – discarding incomplete message " - "(PGN=0x%05X SA=0x%02X)", self.rx_pgn, self.rx_peer_sa) - self._rx_reset() + self._rx_abort(session, _J1939_ABORT_TIMEOUT) # ── CAN send helpers ────────────────────────────────────────────────────── @@ -1230,10 +1398,10 @@ def _can_send(self, pkt): log_j1939.warning( "J1939 CAN send failed: %s", traceback.format_exc()) - def _can_send_tp_cm(self, dst_sa, data): - # type: (int, bytes) -> None + def _can_send_tp_cm(self, dst_sa, data, priority=6): + # type: (int, bytes, int) -> None pkt = J1939_CAN( - priority=6, data_page=0, + priority=priority, data_page=0, pdu_format=J1939_PGN_TP_CM >> 8, # 0xEC pdu_specific=dst_sa, src=self.src_addr, @@ -1241,12 +1409,12 @@ def _can_send_tp_cm(self, dst_sa, data): ) self._can_send(pkt) - def _can_send_tp_dt(self, dst_sa, seq_num, chunk): - # type: (int, int, bytes) -> None + def _can_send_tp_dt(self, dst_sa, seq_num, chunk, priority=7): + # type: (int, int, bytes, int) -> None padded = chunk + b'\xff' * (_J1939_TP_DT_DATA - len(chunk)) dt = J1939_TP_DT(seq_num=seq_num, data=padded[:_J1939_TP_DT_DATA]) pkt = J1939_CAN( - priority=7, data_page=0, + priority=priority, data_page=0, pdu_format=J1939_PGN_TP_DT >> 8, # 0xEB pdu_specific=dst_sa, src=self.src_addr, @@ -1266,7 +1434,14 @@ def _tx_poll(self): if select_objects([self.tx_queue], 0): msg = self.tx_queue.recv() if msg is not None: - self._begin_send(msg) + try: + self._begin_send(msg) + except Exception: + # A message that cannot be sent must not leave the + # state machine latched: that would silently + # discard every later send on this socket. + self._tx_reset() + raise except Exception: if not self.closed: log_j1939.warning( @@ -1333,7 +1508,8 @@ def _tx_start_bam(self, data, pgn, dst, priority, data_page): self.tx_npkts = npkts self.tx_seq = 1 bam = J1939_TP_CM_BAM(total_size=len(data), num_packets=npkts, pgn=pgn) - self._can_send_tp_cm(socket.J1939_NO_ADDR, bytes(bam)) + self._can_send_tp_cm(socket.J1939_NO_ADDR, bytes(bam), + priority=priority) self.tx_timeout_handle = self._TimeoutScheduler.schedule( _J1939_TP_BAM_DELAY, self._tx_bam_next_dt) @@ -1345,7 +1521,8 @@ def _tx_bam_next_dt(self): seq = self.tx_seq start = (seq - 1) * _J1939_TP_DT_DATA chunk = self.tx_buf[start:start + _J1939_TP_DT_DATA] - self._can_send_tp_dt(socket.J1939_NO_ADDR, seq, chunk) + self._can_send_tp_dt(socket.J1939_NO_ADDR, seq, chunk, + priority=self.tx_priority) self.tx_seq += 1 if self.tx_seq > self.tx_npkts: self._tx_reset() @@ -1372,7 +1549,7 @@ def _tx_start_rts(self, data, pgn, dst, priority, data_page): total_size=len(data), num_packets=npkts, max_packets=0xFF, pgn=pgn, ) - self._can_send_tp_cm(dst, bytes(rts)) + self._can_send_tp_cm(dst, bytes(rts), priority=priority) self.tx_timeout_handle = self._TimeoutScheduler.schedule( _J1939_TP_T3, self._tx_timeout) @@ -1386,14 +1563,29 @@ def _tx_handle_cts(self, cts): self.tx_timeout_handle = None if cts.num_packets == 0: - # Receiver requested a hold; wait for another CTS. + # Receiver requested a hold; wait for another CTS (J1939-21 T4). self.tx_state = _J1939_TX_RTS_WAIT_CTS self.tx_timeout_handle = self._TimeoutScheduler.schedule( - _J1939_TP_T3, self._tx_timeout) + _J1939_TP_T4, self._tx_timeout) + return + + if not 1 <= cts.next_packet <= self.tx_npkts: + # A sequence number outside the message would index the buffer + # from the wrong end and put a seq-0 frame on the bus. + log_j1939.warning( + "J1939 TP: CTS asks for packet %d of %d, aborting", + cts.next_packet, self.tx_npkts) + self._can_send_tp_cm( + dst_sa=self.tx_peer_sa, + data=bytes(J1939_TP_CM_ABORT( + reason=_J1939_ABORT_OTHER, pgn=self.tx_pgn)), + ) + self._tx_reset() return - self.tx_cts_count = cts.num_packets self.tx_seq = cts.next_packet + remaining = self.tx_npkts - self.tx_seq + 1 + self.tx_cts_count = min(cts.num_packets, remaining) self.tx_state = _J1939_TX_RTS_SENDING self._tx_rts_send_block() @@ -1412,7 +1604,8 @@ def _tx_rts_send_block(self): break start = (seq - 1) * _J1939_TP_DT_DATA chunk = self.tx_buf[start:start + _J1939_TP_DT_DATA] - self._can_send_tp_dt(self.tx_dst, seq, chunk) + self._can_send_tp_dt(self.tx_dst, seq, chunk, + priority=self.tx_priority) self.tx_seq += 1 sent += 1 @@ -1435,6 +1628,10 @@ def _tx_reset(self): # type: () -> None self.tx_state = _J1939_TX_IDLE self.tx_buf = None + # Forget the peer: an address left behind here would let a node that + # took part in an earlier session abort an unrelated one. + self.tx_peer_sa = socket.J1939_NO_ADDR + self.tx_pgn = 0 if self.tx_timeout_handle is not None: try: self.tx_timeout_handle.cancel() @@ -1452,7 +1649,17 @@ def send(self, msg): without waiting for the next 5 ms polling interval. This allows ``send()`` followed immediately by ``close()`` to reliably deliver the frame (e.g. inside a ``with J1939SoftSocket(...) as s:`` block). + + :raises Scapy_Exception: if the payload exceeds the + :data:`_J1939_TP_MAX_DATA` bytes the + transport protocol can describe """ + payload = msg.data if isinstance(msg, J1939) else bytes(msg) + if len(payload) > _J1939_TP_MAX_DATA: + raise Scapy_Exception( + "J1939 payload of %d bytes exceeds the %d bytes the " + "transport protocol can carry" % + (len(payload), _J1939_TP_MAX_DATA)) self.tx_queue.send(msg) # Cancel the pending poll and reschedule it to fire immediately so # the message is dispatched within microseconds, not up to 5 ms later. @@ -1465,8 +1672,18 @@ def send(self, msg): def recv(self): # type: () -> Optional[Tuple[J1939, Union[float, EDecimal]]] - """Return the next received :class:`J1939` message from the queue.""" - return self.rx_queue.recv() # type: ignore + """Return the next received :class:`J1939` message from the queue. + + Returns ``None`` when the socket is closed while a caller is waiting, + rather than letting the closed queue raise into the caller. + """ + try: + return self.rx_queue.recv() # type: ignore + except Exception: + if not self.closed: + log_j1939.warning( + "J1939 recv error: %s", traceback.format_exc()) + return None class J1939SoftSocket(SuperSocket): @@ -1516,6 +1733,8 @@ class J1939SoftSocket(SuperSocket): desc = ("read/write J1939 messages using a software " "transport-protocol implementation") + _closed = False # type: bool + def __init__( self, can_socket=None, # type: Optional["CANSocket"] @@ -1533,12 +1752,13 @@ def __init__( "Provide a CANSocket object instead of an interface name") self.src_addr = src_addr - self.basecls = basecls + self.basecls = basecls or J1939 impl = J1939TPImplementation( can_socket, src_addr, listen_only=listen_only, pgn_filter=pgn, + basecls=self.basecls, ) # Cast so SuperSocket internals are satisfied (recv/send are overridden). self.ins = cast(socket.socket, impl) @@ -1546,23 +1766,62 @@ def __init__( self.impl = impl if basecls is None: - log_j1939.warning("Provide a basecls") + log_j1939.warning("No basecls provided, defaulting to J1939") + if src_addr == socket.J1939_NO_ADDR and not listen_only: + # 0xFF is the global destination address; it is never a legal + # source address, so anything this socket transmits - including + # the CTS and ACK frames the state machine emits by itself - + # would be malformed on a real bus. + log_j1939.warning( + "src_addr 0x%02X is the global address: set a real source " + "address (0x00-0xFD) to transmit, or pass listen_only=True", + src_addr) # ── lifecycle ───────────────────────────────────────────────────────────── - def close(self): - # type: () -> None - if not self.closed: + @property + def closed(self): # type: ignore[override] + # type: () -> bool + # The implementation closes itself when the CAN socket underneath it + # goes away, and a caller must be able to see that. + return self._closed or getattr(self, "impl", None) is None or \ + self.impl.closed + + @closed.setter + def closed(self, value): + # type: (bool) -> None + self._closed = value + + def close(self, timeout=None): + # type: (Optional[float]) -> None + """Close the socket. + + :param timeout: how long a transmission in progress may still take; + ``None`` derives it from what is left to send, so a + broadcast is not truncated, and ``0`` closes at once. + """ + if not self._closed: if hasattr(self, "impl"): - self.impl.close() - self.closed = True + self.impl.close(timeout=timeout) + self._closed = True # ── recv / send ────────────────────────────────────────────────────────── def recv_raw(self, x=0xffff): # type: (int) -> Tuple[Optional[Type[Packet]], Optional[bytes], Optional[float]] - # Not used for J1939SoftSocket; recv() is overridden directly. - return self.basecls, None, None + """Receive the payload of the next message, without its addressing. + + :meth:`recv` is what callers normally want, since a J1939 message is + only meaningful together with its PGN and addresses; this exists so + that the :class:`~scapy.supersocket.SuperSocket` contract holds. + """ + if self.closed: + return self.basecls, None, None + tup = self.impl.recv() + if tup is None: + return self.basecls, None, None + msg, ts = tup + return self.basecls, bytes(msg.data), float(ts) def recv(self, x=0xffff, **kwargs): # type: (int, **Any) -> Optional[Packet] @@ -1615,8 +1874,8 @@ def select(sockets, remain=None): # type: ignore[override] ready_pipes = select_objects(obj_pipes, remain) result = [ x for x in sockets - if isinstance(x, J1939SoftSocket) and not x.closed - and x.impl.rx_queue in ready_pipes + if isinstance(x, J1939SoftSocket) and not x.closed and + x.impl.rx_queue in ready_pipes ] result += [ x for x in sockets diff --git a/test/contrib/j1939.uts b/test/contrib/j1939.uts index 4174d42f1a4..281f4c147a5 100644 --- a/test/contrib/j1939.uts +++ b/test/contrib/j1939.uts @@ -4040,3 +4040,328 @@ assert len(_pp_soft_rx_results) == len(_pp_native_msgs), \ for _pi, (_pp_got, _pp_exp) in enumerate(zip(_pp_soft_rx_results, _pp_native_msgs)): assert _pp_got.data == _pp_exp, \ "Soft rx msg %d: %r != %r" % (_pi, _pp_got.data, _pp_exp) + +############ +############ ++ J1939SoftSocket – transport-protocol robustness regression tests +~ not_pypy +# Each case here reproduces a defect that shipped in the first version of +# J1939SoftSocket. They deliberately use payloads, peers and frames that the +# rest of the campaign never produces: transfers larger than one CTS block, +# two ECUs talking at once, and TP.CM frames with values a hostile or buggy +# node can put on the bus. + += Robustness helpers + +import time as _rb_time +from scapy.contrib.j1939 import ( + J1939SoftSocket, J1939, J1939_CAN, J1939_TP_DT, + J1939_TP_CM_BAM, J1939_TP_CM_RTS, J1939_TP_CM_CTS, + J1939_TP_CM_ACK, J1939_TP_CM_ABORT, + J1939_PGN_TP_CM, J1939_PGN_TP_DT, + J1939_TP_CTRL_CTS, J1939_TP_CTRL_ACK, J1939_TP_CTRL_ABORT, +) +from scapy.error import Scapy_Exception +from scapy.layers.can import CAN +from test.testsocket import TestSocket, cleanup_testsockets +import socket as _rb_socket + +def _rb_cm(dst, src, payload): + return J1939_CAN(priority=6, data_page=0, + pdu_format=J1939_PGN_TP_CM >> 8, pdu_specific=dst, + src=src, data=bytes(payload)) + +def _rb_dt(dst, src, seq, data): + return J1939_CAN(priority=7, data_page=0, + pdu_format=J1939_PGN_TP_DT >> 8, pdu_specific=dst, + src=src, data=bytes(J1939_TP_DT(seq_num=seq, data=data))) + +def _rb_drain(sock, seconds): + _out = [] + _end = _rb_time.monotonic() + seconds + while _rb_time.monotonic() < _end: + if sock.select([sock], 0): + _p = sock.recv() + if _p is not None: + _out.append(J1939_CAN(bytes(_p))) + else: + _rb_time.sleep(0.005) + return _out + +def _rb_dts(frames): + return [f for f in frames if f.pdu_format == (J1939_PGN_TP_DT >> 8)] + +def _rb_cms(frames, ctrl): + return [f for f in frames if f.pdu_format == (J1939_PGN_TP_CM >> 8) and + bytes(f.data)[0] == ctrl] + +True + += A payload larger than the transport protocol can carry is refused +# 1786 bytes needs 256 TP.DT packets, one more than a sequence number can +# express. Building the announcement used to raise inside the scheduler +# thread, which left the TX machine latched and silently discarded every +# later send on the socket. + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + _rb_raised = False + try: + sock.send(J1939(b'X' * 1786, pgn=0xFECA, dst=0xFF)) + except Scapy_Exception: + _rb_raised = True + assert _rb_raised, "an oversized payload must be refused" + sock.send(J1939(b'\x01\x02', pgn=0xFECA, dst=0xFF)) + _rb_after = _rb_drain(peer, 0.3) + assert len(_rb_after) == 1, \ + "socket must still transmit, got %d frames" % len(_rb_after) + += The largest payload the transport protocol can carry is accepted + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + sock.send(J1939(b'X' * 1785, pgn=0xFECA, dst=0xFF)) + _rb_bam = _rb_cms(_rb_drain(peer, 0.3), 32) + assert len(_rb_bam) == 1, "expected one BAM announcement" + _rb_ann = J1939_TP_CM_BAM(bytes(_rb_bam[0].data)) + assert _rb_ann.num_packets == 255, \ + "num_packets=%d" % _rb_ann.num_packets + assert _rb_ann.total_size == 1785, \ + "total_size=%d" % _rb_ann.total_size + += An abort from a former peer does not kill an unrelated broadcast +# tx_peer_sa used to survive _tx_reset(), so a node that took part in an +# earlier unicast session could abort a later BAM it has nothing to do with. + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + sock.send(J1939(b'A' * 20, pgn=0xFECA, dst=0x20)) + _rb_drain(peer, 0.2) + peer.send(_rb_cm(0x10, 0x20, + J1939_TP_CM_ABORT(reason=3, pgn=0xFECA))) + _rb_drain(peer, 0.1) + sock.send(J1939(b'B' * 70, pgn=0xFECA, dst=0xFF)) + _rb_time.sleep(0.15) + peer.send(_rb_cm(0xFF, 0x20, + J1939_TP_CM_ABORT(reason=3, pgn=0xFECA))) + _rb_seen = _rb_dts(_rb_drain(peer, 1.2)) + assert len(_rb_seen) == 10, \ + "BAM must complete, got %d of 10 TP.DT" % len(_rb_seen) + += Two ECUs may run broadcast sessions at the same time +# RX state used to be a single session, so a second BAM discarded the first. + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + peer.send(_rb_cm(0xFF, 0x20, J1939_TP_CM_BAM(total_size=14, + num_packets=2, + pgn=0xFECA))) + peer.send(_rb_cm(0xFF, 0x30, J1939_TP_CM_BAM(total_size=14, + num_packets=2, + pgn=0xFECB))) + for _rb_sa in (0x20, 0x30): + peer.send(_rb_dt(0xFF, _rb_sa, 1, bytes([_rb_sa]) * 7)) + peer.send(_rb_dt(0xFF, _rb_sa, 2, bytes([_rb_sa]) * 7)) + _rb_msgs = sock.sniff(count=2, timeout=2) + assert len(_rb_msgs) == 2, \ + "both sessions must be reassembled, got %d" % len(_rb_msgs) + _rb_by_sa = dict((m.src, m) for m in _rb_msgs) + assert set(_rb_by_sa) == {0x20, 0x30}, "sources: %s" % set(_rb_by_sa) + assert _rb_by_sa[0x20].data == b'\x20' * 14 + assert _rb_by_sa[0x30].data == b'\x30' * 14 + += close() lets a transmission longer than its old budget finish +# The drain budget was a fixed two seconds; a 350-byte BAM is paced over +# 2.5 s and used to be cut in half. + +_rb_cans = TestSocket(CAN) +_rb_peer = TestSocket(CAN) +_rb_cans.pair(_rb_peer) +_rb_sock = J1939SoftSocket(_rb_cans, src_addr=0x10) +_rb_sock.send(J1939(b'D' * 350, pgn=0xFECA, dst=0xFF)) +_rb_time.sleep(0.2) +_rb_sock.close() +_rb_done = _rb_dts(_rb_drain(_rb_peer, 0.3)) +_rb_cans.close() +_rb_peer.close() +cleanup_testsockets() +assert len(_rb_done) == 50, \ + "close() must not truncate the BAM, got %d of 50 TP.DT" % len(_rb_done) + += A CTS naming a packet outside the message is refused +# next_packet=0 used to index the buffer from the wrong end and put a +# sequence number of 0 on the bus. + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + sock.send(J1939(b'C' * 20, pgn=0xFECA, dst=0x20)) + _rb_drain(peer, 0.2) + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_CTS(num_packets=1, + next_packet=0, + pgn=0xFECA))) + _rb_reply = _rb_drain(peer, 0.4) + _rb_seqs = [J1939_TP_DT(bytes(f.data)).seq_num + for f in _rb_dts(_rb_reply)] + assert 0 not in _rb_seqs, "sequence number 0 must never be sent" + assert _rb_cms(_rb_reply, J1939_TP_CTRL_ABORT), \ + "an out-of-range CTS must be answered with an abort" + += A CTS or an acknowledgement for another PGN is ignored + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + sock.send(J1939(b'F' * 20, pgn=0xFECA, dst=0x20)) + _rb_drain(peer, 0.2) + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_CTS(num_packets=3, + next_packet=1, + pgn=0x00FFFF))) + assert not _rb_dts(_rb_drain(peer, 0.3)), \ + "a CTS for a foreign PGN must not start the transfer" + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_ACK(total_size=20, + num_packets=3, + pgn=0x00FFFF))) + _rb_time.sleep(0.15) + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_CTS(num_packets=3, + next_packet=1, + pgn=0xFECA))) + assert len(_rb_dts(_rb_drain(peer, 0.4))) == 3, \ + "the session must survive a foreign-PGN acknowledgement" + += An announcement that cannot describe a message is refused + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + peer.send(_rb_cm(0xFF, 0x20, J1939_TP_CM_BAM(total_size=0, + num_packets=0, + pgn=0xFECA))) + _rb_time.sleep(0.05) + peer.send(_rb_dt(0xFF, 0x20, 1, b'\x00' * 7)) + assert not sock.sniff(count=1, timeout=0.4), \ + "a zero-packet announcement must not deliver a message" + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_RTS(total_size=9, + num_packets=200, + max_packets=0xFF, + pgn=0xFECA))) + assert _rb_cms(_rb_drain(peer, 0.3), J1939_TP_CTRL_ABORT), \ + "an inconsistent RTS must be answered with an abort" + += A second connection request from the same peer is refused +# J1939-21 allows one connection per peer at a time: a request for another +# PGN must be aborted, and the transfer already running must survive it. + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_RTS(total_size=14, + num_packets=2, + max_packets=0xFF, + pgn=0xFECA))) + _rb_drain(peer, 0.2) + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_RTS(total_size=14, + num_packets=2, + max_packets=0xFF, + pgn=0xFECB))) + _rb_reply2 = _rb_drain(peer, 0.3) + assert _rb_cms(_rb_reply2, J1939_TP_CTRL_ABORT), \ + "the second request must be aborted" + assert not _rb_cms(_rb_reply2, J1939_TP_CTRL_CTS), \ + "the second request must not be authorised" + peer.send(_rb_dt(0x10, 0x20, 1, b'\x01' * 7)) + peer.send(_rb_dt(0x10, 0x20, 2, b'\x02' * 7)) + _rb_kept = sock.sniff(count=1, timeout=2) + assert len(_rb_kept) == 1, "the first transfer must still complete" + assert _rb_kept[0].pgn == 0xFECA, "PGN 0x%05X" % _rb_kept[0].pgn + += The clear-to-send honours the max_packets of the request +# The receiver used to authorise the whole transfer at once, whatever the +# sender said it could handle, and never sent a second CTS. + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_RTS(total_size=35, + num_packets=5, + max_packets=2, + pgn=0xFECA))) + _rb_first = _rb_cms(_rb_drain(peer, 0.3), J1939_TP_CTRL_CTS) + assert len(_rb_first) == 1, "expected one CTS" + _rb_c1 = J1939_TP_CM_CTS(bytes(_rb_first[0].data)) + assert _rb_c1.num_packets == 2, \ + "CTS authorised %d packets, max_packets was 2" % _rb_c1.num_packets + assert _rb_c1.next_packet == 1 + peer.send(_rb_dt(0x10, 0x20, 1, b'\x01' * 7)) + peer.send(_rb_dt(0x10, 0x20, 2, b'\x02' * 7)) + _rb_second = _rb_cms(_rb_drain(peer, 0.4), J1939_TP_CTRL_CTS) + assert len(_rb_second) == 1, "expected a CTS for the next block" + _rb_c2 = J1939_TP_CM_CTS(bytes(_rb_second[0].data)) + assert _rb_c2.next_packet == 3, "next_packet=%d" % _rb_c2.next_packet + assert _rb_c2.num_packets == 2, "num_packets=%d" % _rb_c2.num_packets + for _rb_i in (3, 4): + peer.send(_rb_dt(0x10, 0x20, _rb_i, bytes([_rb_i]) * 7)) + _rb_drain(peer, 0.3) + peer.send(_rb_dt(0x10, 0x20, 5, b'\x05' * 7)) + _rb_got = sock.sniff(count=1, timeout=2) + assert len(_rb_got) == 1, "the block-by-block transfer must complete" + assert len(_rb_got[0].data) == 35, "got %d bytes" % len(_rb_got[0].data) + += Transport-protocol frames carry the requested priority + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + sock.send(J1939(b'P' * 20, pgn=0xFECA, dst=0xFF, priority=3)) + _rb_prio = _rb_drain(peer, 0.5) + _rb_ann = _rb_cms(_rb_prio, 32) + assert _rb_ann and _rb_ann[0].priority == 3, \ + "BAM priority %s" % (_rb_ann[0].priority if _rb_ann else None) + _rb_data = _rb_dts(_rb_prio) + assert _rb_data and _rb_data[0].priority == 3, \ + "TP.DT priority %s" % (_rb_data[0].priority if _rb_data else None) + += A caller-supplied basecls is used, and recv_raw returns the payload + +class _RbJ1939(J1939): + name = "RbJ1939" + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10, basecls=_RbJ1939) as sock: + peer.send(J1939_CAN(priority=6, data_page=0, pdu_format=0xFE, + pdu_specific=0xCA, src=0x20, data=b'\x01\x02')) + _rb_pkt = sock.recv() + assert isinstance(_rb_pkt, _RbJ1939), \ + "basecls ignored, got %s" % type(_rb_pkt).__name__ + peer.send(J1939_CAN(priority=6, data_page=0, pdu_format=0xFE, + pdu_specific=0xCA, src=0x20, data=b'\x03\x04')) + _rb_cls, _rb_data, _rb_ts = sock.recv_raw() + assert _rb_cls is _RbJ1939, "recv_raw class %s" % _rb_cls + assert _rb_data == b'\x03\x04', "recv_raw data %r" % _rb_data + assert _rb_ts is not None + += Losing the CAN socket closes the J1939 socket + +_rb_cans2 = TestSocket(CAN) +_rb_peer2 = TestSocket(CAN) +_rb_cans2.pair(_rb_peer2) +_rb_sock2 = J1939SoftSocket(_rb_cans2, src_addr=0x10) +assert not _rb_sock2.closed +_rb_cans2.close() +_rb_time.sleep(0.2) +assert _rb_sock2.closed, "the J1939 socket must notice its CAN socket died" +assert _rb_sock2.recv() is None, "recv() must not block on a dead socket" +_rb_sock2.close() +_rb_peer2.close() +cleanup_testsockets() + += Scheduler teardown + +from scapy.contrib.isotp.isotp_soft_socket import TimeoutScheduler +TimeoutScheduler.clear() +True From ef10b34fdbec0ea6b3bde8f6822cdd0a0127f2fc Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Thu, 13 Aug 2026 08:39:32 +0200 Subject: [PATCH 7/8] j1939: tighten the transport-protocol fixes after review Four things the first pass got wrong or left rough. send() measured the payload differently from the code that transmits it, so the size guard and the wire could disagree for a message whose data was not bytes; both now go through one helper. The check for a CAN socket that has gone away was written twice in can_recv, once at each end. The new basecls parameter and the per-peer session model were undocumented. And the priority a caller asks for now reaches the TP.CM and TP.DT frames of a multi-packet message, which is a deliberate change of default from the 7 the code used to hardcode for TP.DT, so the docstring says so. AI-Assisted: yes (Cursor) Co-authored-by: Cursor --- scapy/contrib/j1939.py | 67 +++++++++++++++++++++++++++++------------- test/contrib/j1939.uts | 2 +- 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/scapy/contrib/j1939.py b/scapy/contrib/j1939.py index 754a2c0999d..834fd1b9d6f 100644 --- a/scapy/contrib/j1939.py +++ b/scapy/contrib/j1939.py @@ -926,6 +926,12 @@ class J1939TPImplementation: :param pgn_filter: when non-zero, only messages whose PGN matches this value are delivered. ``0`` (the default) accepts all PGNs. Inspired by BenGardiner's ``rx_pgn`` parameter. + :param basecls: packet class used for delivered messages, defaulting to + :class:`J1939` + + Receptions are tracked per (source address, destination) pair, so + several ECUs may transfer at the same time, up to + :data:`_J1939_MAX_RX_SESSIONS`. """ def __init__( @@ -1049,14 +1055,24 @@ def close(self, timeout=None): # ── CAN receive loop ───────────────────────────────────────────────────── + def _can_socket_gone(self): + # type: () -> bool + """Close ourselves if the CAN socket underneath has gone away. + + Without this the receive pump would simply stop rescheduling and a + caller blocked in recv() would wait forever on a socket that still + looks open. + """ + if not self.can_socket.closed: + return False + log_j1939.warning( + "J1939 TP: underlying CAN socket closed, closing socket") + self.close(timeout=0) + return True + def can_recv(self): # type: () -> None - if self.closed: - return - if self.can_socket.closed: - log_j1939.warning( - "J1939 TP: underlying CAN socket closed, closing socket") - self.close(timeout=0) + if self.closed or self._can_socket_gone(): return try: while self.can_socket.select([self.can_socket], 0): @@ -1073,14 +1089,7 @@ def can_recv(self): "J1939TPImplementation.can_recv error: %s", traceback.format_exc()) - if self.closed: - return - if self.can_socket.closed: - # The CAN socket went away: without this the pump would simply - # stop and a caller blocked in recv() would wait forever. - log_j1939.warning( - "J1939 TP: underlying CAN socket closed, closing socket") - self.close(timeout=0) + if self.closed or self._can_socket_gone(): return self.rx_handle = self._TimeoutScheduler.schedule( self.rx_tx_poll_rate, self.can_recv) @@ -1257,7 +1266,9 @@ def _rx_start(self, sa, pgn, dst, total, npkts, max_packets, is_bam, ts): # type: (int, int, int, int, int, int, bool, Union[float, EDecimal]) -> None # noqa: E501 """Open a reception for *sa*, replacing any session that peer had.""" # An announcement that cannot describe a real message is refused - # rather than turned into an empty or truncated delivery. + # rather than turned into an empty or truncated delivery. J1939-21 + # fixes the packet count at ceil(size / 7), so anything else is + # either malformed or an attempt to hold a session slot open. if not 1 <= npkts <= _J1939_TP_MAX_PACKETS or \ not 1 <= total <= _J1939_TP_MAX_DATA or \ npkts != (total + _J1939_TP_DT_DATA - 1) // _J1939_TP_DT_DATA: @@ -1450,14 +1461,22 @@ def _tx_poll(self): self.tx_handle = self._TimeoutScheduler.schedule( self.rx_tx_poll_rate, self._tx_poll) - def _begin_send(self, msg): - # type: (Packet) -> None - """Start transmitting *msg*. Called from _tx_poll in the scheduler thread.""" + @staticmethod + def _payload_of(msg): + # type: (Packet) -> bytes + """The bytes *msg* puts on the bus, however it was constructed.""" if isinstance(msg, J1939): data = msg.data if not isinstance(data, (bytes, bytearray)): data = bytes(msg) - data = bytes(data) + return bytes(data) + return bytes(msg) + + def _begin_send(self, msg): + # type: (Packet) -> None + """Start transmitting *msg*. Called from _tx_poll in the scheduler thread.""" + data = self._payload_of(msg) + if isinstance(msg, J1939): pgn = msg.pgn dst = msg.dst priority = msg.priority @@ -1654,7 +1673,7 @@ def send(self, msg): :data:`_J1939_TP_MAX_DATA` bytes the transport protocol can describe """ - payload = msg.data if isinstance(msg, J1939) else bytes(msg) + payload = self._payload_of(msg) if len(payload) > _J1939_TP_MAX_DATA: raise Scapy_Exception( "J1939 payload of %d bytes exceeds the %d bytes the " @@ -1847,6 +1866,14 @@ def send(self, x): ``priority`` attributes are used. Payloads of 8 bytes or fewer are sent as a single CAN frame; larger payloads use the J1939 Transport Protocol automatically (BAM for broadcast, RTS/CTS for unicast). + + The TP.CM and TP.DT frames of a multi-packet message carry the + priority of the message itself, so a caller controls the whole + transfer with one value. J1939-21 suggests 7 for both, which is + what ``priority=7`` gives; the class default is 6. + + :raises Scapy_Exception: if the payload is larger than the 1785 + bytes the transport protocol can carry """ if self.closed: return 0 diff --git a/test/contrib/j1939.uts b/test/contrib/j1939.uts index 281f4c147a5..99ca8b7aa13 100644 --- a/test/contrib/j1939.uts +++ b/test/contrib/j1939.uts @@ -4250,7 +4250,7 @@ with TestSocket(CAN) as cans, TestSocket(CAN) as peer: max_packets=0xFF, pgn=0xFECA))) assert _rb_cms(_rb_drain(peer, 0.3), J1939_TP_CTRL_ABORT), \ - "an inconsistent RTS must be answered with an abort" + "an RTS whose size and packet count disagree must be aborted" = A second connection request from the same peer is refused # J1939-21 allows one connection per peer at a time: a request for another From ec4a1c11c3b343fdaf3c6cb5dc1dc307a54589d4 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Thu, 13 Aug 2026 09:38:40 +0200 Subject: [PATCH 8/8] j1939: replace the silent except/pass blocks and cover the paths they hid Four handlers cancelled a scheduler timeout inside try/except/pass, which Codacy flags and which hides a real failure as readily as the expected one. The expected one is narrow: TimeoutScheduler raises Scapy_Exception when a timeout has already fired or been cancelled, which races normally against the state machine dropping it. One _cancel helper now does that in the five places that needed it, logging anything else at debug level, and send() sets sent_time behind an isinstance check rather than catching the AttributeError a non-packet would raise. Building a connection abort was written out five times and refusing a session three times; both are helpers now, which is what made it obvious that the check for a TP.DT past the authorised block can never fire: the next CTS is sent from the same handler that completes a block, so the window it guards does not exist. Writing the test for it is what showed that, and both the branch and the test are gone. The four new cases cover what had no test: the session table filling up and answering with 'system resources', a stalled reception aborting with 'timeout' once its wall-clock ceiling passes, a sender aborting a reception it started, and close(timeout=0) as the way to give up on a transfer on purpose. AI-Assisted: yes (Cursor) Co-authored-by: Cursor --- scapy/contrib/j1939.py | 130 +++++++++++++++++------------------------ test/contrib/j1939.uts | 88 ++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 75 deletions(-) diff --git a/scapy/contrib/j1939.py b/scapy/contrib/j1939.py index 834fd1b9d6f..1f89882466b 100644 --- a/scapy/contrib/j1939.py +++ b/scapy/contrib/j1939.py @@ -990,6 +990,31 @@ def __del__(self): # seconds, and __del__ may run at interpreter shutdown. self.close(timeout=0) + @staticmethod + def _cancel(handle): + # type: (Optional[Any]) -> None + """Cancel a scheduled timeout, tolerating one that already fired. + + :class:`~scapy.contrib.isotp.isotp_soft_socket.TimeoutScheduler` + raises once a timeout has run or been cancelled, and that is a + normal race here: a timer can fire while the state machine is + deciding to drop it. + """ + if handle is None: + return + try: + handle.cancel() + except Scapy_Exception as e: + log_j1939.debug("J1939 TP: timer already gone: %s", e) + + def _send_abort(self, dst_sa, reason, pgn): + # type: (int, int, int) -> None + """Tell *dst_sa* that a connection-managed session is over.""" + self._can_send_tp_cm( + dst_sa=dst_sa, + data=bytes(J1939_TP_CM_ABORT(reason=reason, pgn=pgn)), + ) + def drain_timeout(self): # type: () -> float """Time a pending transmission still needs, as a close() budget. @@ -1038,11 +1063,7 @@ def close(self, timeout=None): handles += [s.timeout_handle for s in self.rx_sessions.values()] self.rx_sessions.clear() for handle in handles: - if handle is not None: - try: - handle.cancel() - except Exception as e: - log_runtime.debug(str(e)) + self._cancel(handle) try: self.rx_queue.close() @@ -1224,13 +1245,6 @@ def _on_tp_dt(self, j): "J1939 TP: bad DT seq %d (expected %d)", seq, session.seq) self._rx_abort(session, _J1939_ABORT_BAD_SEQ) return - if seq > session.block_end: - # More data than our CTS authorised. - log_j1939.warning( - "J1939 TP: DT seq %d beyond the authorised block (%d)", - seq, session.block_end) - self._rx_abort(session, _J1939_ABORT_OTHER) - return session.buf += dt.data session.seq += 1 @@ -1272,15 +1286,9 @@ def _rx_start(self, sa, pgn, dst, total, npkts, max_packets, is_bam, ts): if not 1 <= npkts <= _J1939_TP_MAX_PACKETS or \ not 1 <= total <= _J1939_TP_MAX_DATA or \ npkts != (total + _J1939_TP_DT_DATA - 1) // _J1939_TP_DT_DATA: - log_j1939.warning( - "J1939 TP: refusing session from SA=0x%02X with " - "total_size=%d num_packets=%d", sa, total, npkts) - if not is_bam and not self.listen_only: - self._can_send_tp_cm( - dst_sa=sa, - data=bytes(J1939_TP_CM_ABORT( - reason=_J1939_ABORT_OTHER, pgn=pgn)), - ) + self._rx_refuse( + sa, pgn, is_bam, _J1939_ABORT_OTHER, + "total_size=%d and num_packets=%d disagree" % (total, npkts)) return old = self.rx_sessions.get((sa, dst)) @@ -1289,29 +1297,17 @@ def _rx_start(self, sa, pgn, dst, total, npkts, max_packets, is_bam, ts): # J1939-21: a peer gets one connection with us at a time, so # a request for a second PGN is refused and the transfer # already running is kept. - log_j1939.warning( - "J1939 TP: SA=0x%02X is already in a session for " - "PGN 0x%05X", sa, old.pgn) - if not self.listen_only: - self._can_send_tp_cm( - dst_sa=sa, - data=bytes(J1939_TP_CM_ABORT( - reason=_J1939_ABORT_IN_SESSION, pgn=pgn)), - ) + self._rx_refuse( + sa, pgn, is_bam, _J1939_ABORT_IN_SESSION, + "already in a session for PGN 0x%05X" % old.pgn) return log_j1939.debug( "J1939 TP: SA=0x%02X restarts its session", sa) self._rx_forget(old) elif len(self.rx_sessions) >= _J1939_MAX_RX_SESSIONS: - log_j1939.warning( - "J1939 TP: %d concurrent sessions, refusing SA=0x%02X", - len(self.rx_sessions), sa) - if not is_bam and not self.listen_only: - self._can_send_tp_cm( - dst_sa=sa, - data=bytes(J1939_TP_CM_ABORT( - reason=_J1939_ABORT_RESOURCES, pgn=pgn)), - ) + self._rx_refuse( + sa, pgn, is_bam, _J1939_ABORT_RESOURCES, + "%d sessions already open" % len(self.rx_sessions)) return session = _J1939_RXSession(sa, dst, pgn, total, npkts, is_bam, ts) @@ -1323,6 +1319,16 @@ def _rx_start(self, sa, pgn, dst, total, npkts, max_packets, is_bam, ts): self._rx_send_cts(session) self._rx_arm_timer(session, _J1939_TP_T1) + def _rx_refuse(self, sa, pgn, is_bam, reason, why): + # type: (int, int, bool, int, str) -> None + """Turn down a reception, telling the peer when the protocol allows. + + A broadcast has nobody to answer, so a BAM is only dropped. + """ + log_j1939.warning("J1939 TP: refusing SA=0x%02X: %s", sa, why) + if not is_bam and not self.listen_only: + self._send_abort(sa, reason, pgn) + def _rx_send_cts(self, session): # type: (_J1939_RXSession) -> None """Authorise the next block of TP.DT packets.""" @@ -1347,12 +1353,8 @@ def _rx_send_cts(self, session): def _rx_cancel_timer(self, session): # type: (_J1939_RXSession) -> None - if session.timeout_handle is not None: - try: - session.timeout_handle.cancel() - except Exception: - pass - session.timeout_handle = None + self._cancel(session.timeout_handle) + session.timeout_handle = None def _rx_arm_timer(self, session, delay): # type: (_J1939_RXSession, float) -> None @@ -1377,11 +1379,7 @@ def _rx_abort(self, session, reason): # type: (_J1939_RXSession, int) -> None """Drop a reception, telling the peer why when the protocol allows.""" if not session.is_bam and not self.listen_only: - self._can_send_tp_cm( - dst_sa=session.sa, - data=bytes(J1939_TP_CM_ABORT( - reason=reason, pgn=session.pgn)), - ) + self._send_abort(session.sa, reason, session.pgn) self._rx_drop(session, "(abort reason %d)" % reason) def _rx_timeout(self, key): @@ -1574,12 +1572,8 @@ def _tx_start_rts(self, data, pgn, dst, priority, data_page): def _tx_handle_cts(self, cts): # type: (J1939_TP_CM_CTS) -> None - if self.tx_timeout_handle is not None: - try: - self.tx_timeout_handle.cancel() - except Exception: - pass - self.tx_timeout_handle = None + self._cancel(self.tx_timeout_handle) + self.tx_timeout_handle = None if cts.num_packets == 0: # Receiver requested a hold; wait for another CTS (J1939-21 T4). @@ -1594,11 +1588,7 @@ def _tx_handle_cts(self, cts): log_j1939.warning( "J1939 TP: CTS asks for packet %d of %d, aborting", cts.next_packet, self.tx_npkts) - self._can_send_tp_cm( - dst_sa=self.tx_peer_sa, - data=bytes(J1939_TP_CM_ABORT( - reason=_J1939_ABORT_OTHER, pgn=self.tx_pgn)), - ) + self._send_abort(self.tx_peer_sa, _J1939_ABORT_OTHER, self.tx_pgn) self._tx_reset() return @@ -1651,12 +1641,8 @@ def _tx_reset(self): # took part in an earlier session abort an unrelated one. self.tx_peer_sa = socket.J1939_NO_ADDR self.tx_pgn = 0 - if self.tx_timeout_handle is not None: - try: - self.tx_timeout_handle.cancel() - except Exception: - pass - self.tx_timeout_handle = None + self._cancel(self.tx_timeout_handle) + self.tx_timeout_handle = None # ── public interface ───────────────────────────────────────────────────── @@ -1682,11 +1668,7 @@ def send(self, msg): self.tx_queue.send(msg) # Cancel the pending poll and reschedule it to fire immediately so # the message is dispatched within microseconds, not up to 5 ms later. - if self.tx_handle is not None: - try: - self.tx_handle.cancel() - except Exception: - pass + self._cancel(self.tx_handle) self.tx_handle = self._TimeoutScheduler.schedule(0, self._tx_poll) def recv(self): @@ -1877,10 +1859,8 @@ def send(self, x): """ if self.closed: return 0 - try: + if isinstance(x, Packet): x.sent_time = time.time() - except AttributeError: - pass self.impl.send(x) return len(bytes(x)) diff --git a/test/contrib/j1939.uts b/test/contrib/j1939.uts index 99ca8b7aa13..83dae4ecf21 100644 --- a/test/contrib/j1939.uts +++ b/test/contrib/j1939.uts @@ -4360,6 +4360,94 @@ _rb_sock2.close() _rb_peer2.close() cleanup_testsockets() += A peer may abort a reception it started + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_RTS(total_size=14, + num_packets=2, + max_packets=0xFF, + pgn=0xFECA))) + _rb_drain(peer, 0.3) + assert len(sock.impl.rx_sessions) == 1, "the session must be open" + peer.send(_rb_cm(0x10, 0x20, + J1939_TP_CM_ABORT(reason=3, pgn=0xFECA))) + _rb_time.sleep(0.15) + assert not sock.impl.rx_sessions, \ + "an abort from the sender must end the reception" + += Too many concurrent senders are turned away with a reason +# The session table is capped, and a request that does not fit is refused +# with "system resources" rather than dropped in silence. + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + for _rb_n in range(16): + peer.send(_rb_cm(0xFF, 0x20 + _rb_n, + J1939_TP_CM_BAM(total_size=14, num_packets=2, + pgn=0xFECA))) + _rb_drain(peer, 0.4) + assert len(sock.impl.rx_sessions) == 16, \ + "%d sessions open" % len(sock.impl.rx_sessions) + peer.send(_rb_cm(0x10, 0x99, J1939_TP_CM_RTS(total_size=14, + num_packets=2, + max_packets=0xFF, + pgn=0xFECA))) + _rb_full = _rb_cms(_rb_drain(peer, 0.3), J1939_TP_CTRL_ABORT) + assert _rb_full, "the 17th sender must be told the table is full" + assert J1939_TP_CM_ABORT(bytes(_rb_full[0].data)).reason == 2, \ + "reason %d" % J1939_TP_CM_ABORT(bytes(_rb_full[0].data)).reason + assert len(sock.impl.rx_sessions) == 16, "the cap must hold" + += A stalled reception times out and tells the sender +# The wall-clock ceiling that lets slow serial links finish is shortened +# here so the test does not have to wait 12.5 s for it. + +import scapy.contrib.j1939 as _rb_mod +_rb_saved_ext = _rb_mod._J1939_TP_DT_TIMEOUT_EXTENSION +_rb_mod._J1939_TP_DT_TIMEOUT_EXTENSION = 0 +try: + with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_RTS(total_size=14, + num_packets=2, + max_packets=0xFF, + pgn=0xFECA))) + _rb_drain(peer, 0.3) + peer.send(_rb_dt(0x10, 0x20, 1, b'\x01' * 7)) + _rb_late = _rb_drain(peer, 2.0) + assert _rb_cms(_rb_late, J1939_TP_CTRL_ABORT), \ + "a stalled reception must be aborted" + assert J1939_TP_CM_ABORT( + bytes(_rb_cms(_rb_late, J1939_TP_CTRL_ABORT)[0].data) + ).reason == 3, "the abort reason must be 'timeout'" + assert not sock.impl.rx_sessions, "the session must be gone" +finally: + _rb_mod._J1939_TP_DT_TIMEOUT_EXTENSION = _rb_saved_ext + += close(timeout=0) gives up on a transfer deliberately +# The derived budget protects a transfer by default; a caller in a hurry +# can still say so. + +_rb_cans3 = TestSocket(CAN) +_rb_peer3 = TestSocket(CAN) +_rb_cans3.pair(_rb_peer3) +_rb_sock3 = J1939SoftSocket(_rb_cans3, src_addr=0x10) +_rb_sock3.send(J1939(b'D' * 350, pgn=0xFECA, dst=0xFF)) +_rb_time.sleep(0.2) +_rb_t0 = _rb_time.monotonic() +_rb_sock3.close(timeout=0) +_rb_elapsed = _rb_time.monotonic() - _rb_t0 +_rb_cut = _rb_dts(_rb_drain(_rb_peer3, 0.3)) +_rb_cans3.close() +_rb_peer3.close() +cleanup_testsockets() +assert _rb_elapsed < 0.5, "close(timeout=0) took %.2fs" % _rb_elapsed +assert len(_rb_cut) < 50, "the transfer should have been cut short" + = Scheduler teardown from scapy.contrib.isotp.isotp_soft_socket import TimeoutScheduler