From 7ef6f0e2d8cf46347f81f55d3455bf7a7d57dbf5 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Thu, 11 Jun 2026 22:11:36 +0200 Subject: [PATCH 01/14] 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 306c8f4845024af7f98908e860011e3b5e56dff2 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Fri, 12 Jun 2026 08:50:02 +0200 Subject: [PATCH 02/14] 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 81eb16166076e84b46acfc620798e591e7b638ab Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Fri, 12 Jun 2026 09:02:45 +0200 Subject: [PATCH 03/14] 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 e2af37f3da1116325db808c24e798c478690a05c Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Fri, 12 Jun 2026 12:11:54 +0200 Subject: [PATCH 04/14] 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 1430e3492d5330f95b8e4b3651749dac54231ada Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Fri, 12 Jun 2026 12:34:47 +0200 Subject: [PATCH 05/14] 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 94fdab8588f63ffad39586f290ca92afae008c38 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Thu, 13 Aug 2026 08:11:49 +0200 Subject: [PATCH 06/14] 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 e474386b87f637687edde043071f36e24f1b9ec3 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Thu, 13 Aug 2026 08:39:32 +0200 Subject: [PATCH 07/14] 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 80fcd165e404b49e763c9b3f7c7a3939059eb5a2 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Thu, 13 Aug 2026 09:38:40 +0200 Subject: [PATCH 08/14] 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 From 4e7eca5d25db883ce3c2aa56f02b00488583c35a Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Wed, 2 Sep 2026 18:39:47 +0200 Subject: [PATCH 09/14] j1939: fix CAN-send, EOM ACK and TP.CM addressing correctness Propagate adapter send failures so TX resets instead of advancing past a frame that never left the host, accept EOM ACK only after every DT was sent with matching size/count, and enforce BAM vs directed control destination rules. AI-Assisted: yes (Cursor) Co-authored-by: Cursor --- scapy/contrib/j1939.py | 52 +++++++--- test/contrib/j1939.uts | 230 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 262 insertions(+), 20 deletions(-) diff --git a/scapy/contrib/j1939.py b/scapy/contrib/j1939.py index 1f89882466b..f068d3aeb0d 100644 --- a/scapy/contrib/j1939.py +++ b/scapy/contrib/j1939.py @@ -1134,9 +1134,8 @@ def on_can_recv(self, pkt): # ── 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 + # Destination rules differ per control command; validate in + # _on_tp_cm rather than accepting every TP.CM to us or broadcast. self._on_tp_cm(j) return @@ -1175,6 +1174,9 @@ def _on_tp_cm(self, j): ts = j.time if ctrl == J1939_TP_CTRL_BAM: + # BAM is broadcast-only; a unicast BAM is malformed. + if j.dst != socket.J1939_NO_ADDR: + return if len(data) < 8: return bam = J1939_TP_CM_BAM(data) @@ -1185,6 +1187,9 @@ def _on_tp_cm(self, j): max_packets=bam.num_packets, is_bam=True, ts=ts) elif ctrl == J1939_TP_CTRL_RTS: + # RTS is directed; broadcast RTS must not start a session or CTS. + if j.dst != self.src_addr: + return if len(data) < 8: return rts = J1939_TP_CM_RTS(data) @@ -1195,6 +1200,8 @@ def _on_tp_cm(self, j): max_packets=rts.max_packets, is_bam=False, ts=ts) elif ctrl == J1939_TP_CTRL_CTS: + if j.dst != self.src_addr: + return if len(data) < 8: return cts = J1939_TP_CM_CTS(data) @@ -1203,15 +1210,26 @@ def _on_tp_cm(self, j): self._tx_handle_cts(cts) elif ctrl == J1939_TP_CTRL_ACK: + if j.dst != self.src_addr: + return 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): + # EOM ACK completes the transfer only after every DT has been + # handed to the CAN layer. Size and packet-count checks reject + # a stale same-PGN ACK from an earlier session. + if (self.tx_state == _J1939_TX_RTS_WAIT_CTS and + self.tx_seq > self.tx_npkts and + self.tx_buf is not None and + sa == self.tx_peer_sa and + ack.pgn == self.tx_pgn and + ack.total_size == len(self.tx_buf) and + ack.num_packets == self.tx_npkts): self._tx_reset() elif ctrl == J1939_TP_CTRL_ABORT: + if j.dst != self.src_addr: + return 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 @@ -1401,11 +1419,7 @@ def _rx_timeout(self, key): 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()) + self.can_socket.send(pkt) def _can_send_tp_cm(self, dst_sa, data, priority=6): # type: (int, bytes, int) -> None @@ -1538,8 +1552,12 @@ 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, - priority=self.tx_priority) + try: + self._can_send_tp_dt(socket.J1939_NO_ADDR, seq, chunk, + priority=self.tx_priority) + except Exception: + self._tx_reset() + raise self.tx_seq += 1 if self.tx_seq > self.tx_npkts: self._tx_reset() @@ -1613,8 +1631,12 @@ 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, - priority=self.tx_priority) + try: + self._can_send_tp_dt(self.tx_dst, seq, chunk, + priority=self.tx_priority) + except Exception: + self._tx_reset() + raise self.tx_seq += 1 sent += 1 diff --git a/test/contrib/j1939.uts b/test/contrib/j1939.uts index 83dae4ecf21..75aabcb39cf 100644 --- a/test/contrib/j1939.uts +++ b/test/contrib/j1939.uts @@ -1757,14 +1757,17 @@ p_wait = J1939_TP_CM_CTS(bytes(cts_wait)) assert p_wait.num_packets == 0, "CTS hold-state must have num_packets=0" assert p_wait.next_packet == 5, "CTS next_packet should be next expected after pause" -= J1939_TP_CM_ACK num_packets less than RTS (partial reception) -# Receiver may ACK fewer packets if some were lost/corrupted; this is valid += J1939_TP_CM_ACK fields round-trip for a complete message +# An End-of-Message ACK names the full transfer: total_size and num_packets +# match the RTS that started the session. Partial counts are still +# encodeable as packet fields, but this soft socket only accepts an ACK +# that matches the active transfer after every TP.DT has been sent. rts = J1939_TP_CM_RTS(total_size=100, num_packets=20, pgn=0xFECA) -# Receiver only got 18 out of 20 packets (2 lost) -ack = J1939_TP_CM_ACK(total_size=100, num_packets=18, pgn=0xFECA) +ack = J1939_TP_CM_ACK(total_size=100, num_packets=20, pgn=0xFECA) p_ack = J1939_TP_CM_ACK(bytes(ack)) -assert p_ack.num_packets == 18, "ACK num_packets should reflect actual received" +assert p_ack.num_packets == 20, "ACK num_packets should match the RTS" assert p_ack.total_size == 100, "ACK total_size should match session total" +assert p_ack.pgn == rts.pgn, "ACK PGN should match the RTS" = J1939_TP_DT maximum data payload per frame # Each TP.DT carries exactly 7 bytes of payload (8 bytes total including seq_num) @@ -4448,6 +4451,223 @@ 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" += CAN send failure on BAM TP.CM resets TX without scheduling DTs +# _can_send used to swallow adapter errors, so a failed BAM announcement +# still armed the DT timer and "completed" the transfer locally. + +import scapy.contrib.j1939 as _rb_j1939 + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + _rb_orig = cans.send + def _rb_fail(_pkt): + raise OSError("simulated CAN send failure") + cans.send = _rb_fail + sock.send(J1939(b'F' * 20, pgn=0xFECA, dst=0xFF)) + _rb_time.sleep(0.3) + cans.send = _rb_orig + assert sock.impl.tx_state == _rb_j1939._J1939_TX_IDLE, \ + "TX must return to IDLE after a failed BAM CM" + assert sock.impl.tx_timeout_handle is None, \ + "no BAM DT timer may remain after the failure" + assert not _rb_dts(_rb_drain(peer, 0.2)), \ + "no TP.DT frames must have been emitted" + += CAN send failure on BAM TP.DT stops the transfer without advancing seq +# The first DT succeeds; the second raises. tx_seq must not advance past +# the failed frame, and no further DTs may be scheduled. + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + _rb_n = [0] + _rb_orig = cans.send + def _rb_fail_dt(pkt): + _rb_n[0] += 1 + if _rb_n[0] > 2: # CM + first DT ok; second DT fails + raise OSError("simulated CAN send failure") + return _rb_orig(pkt) + cans.send = _rb_fail_dt + sock.send(J1939(b'G' * 20, pgn=0xFECA, dst=0xFF)) + _rb_seen = _rb_dts(_rb_drain(peer, 0.8)) + cans.send = _rb_orig + assert len(_rb_seen) == 1, \ + "only the successful DT must be on the bus, got %d" % len(_rb_seen) + assert sock.impl.tx_state == _rb_j1939._J1939_TX_IDLE, \ + "TX must reset after a failed BAM DT" + assert sock.impl.tx_timeout_handle is None + += CAN send failure on RTS TP.CM resets TX + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + _rb_orig = cans.send + cans.send = lambda _pkt: (_ for _ in ()).throw(OSError("fail")) + sock.send(J1939(b'H' * 20, pgn=0xFECA, dst=0x20)) + _rb_time.sleep(0.3) + cans.send = _rb_orig + assert sock.impl.tx_state == _rb_j1939._J1939_TX_IDLE + assert not _rb_cms(_rb_drain(peer, 0.2), 16), "no RTS on the bus" + += CAN send failure during a CTS-authorised block does not advance seq + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + sock.send(J1939(b'I' * 21, pgn=0xFECA, dst=0x20)) + _rb_rts = _rb_cms(_rb_drain(peer, 0.3), 16) + assert _rb_rts, "RTS must leave the socket" + _rb_n = [0] + _rb_orig = cans.send + def _rb_fail_block(pkt): + _rb_n[0] += 1 + if _rb_n[0] > 1: # first DT ok; second fails + raise OSError("simulated CAN send failure") + return _rb_orig(pkt) + cans.send = _rb_fail_block + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_CTS( + num_packets=3, next_packet=1, pgn=0xFECA))) + _rb_seen = _rb_dts(_rb_drain(peer, 0.5)) + cans.send = _rb_orig + assert len(_rb_seen) == 1, \ + "failed block must not emit further DTs, got %d" % len(_rb_seen) + assert sock.impl.tx_state == _rb_j1939._J1939_TX_IDLE + += EOM ACK immediately after RTS is ignored +# A same-PGN ACK before any TP.DT has been sent must not finish the transfer. + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + sock.send(J1939(b'J' * 20, pgn=0xFECA, dst=0x20)) + _rb_drain(peer, 0.2) + assert sock.impl.tx_state == _rb_j1939._J1939_TX_RTS_WAIT_CTS + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_ACK( + total_size=20, num_packets=3, pgn=0xFECA))) + _rb_time.sleep(0.15) + assert sock.impl.tx_state == _rb_j1939._J1939_TX_RTS_WAIT_CTS, \ + "premature EOM ACK must not complete the transfer" + += EOM ACK after a partial DT block 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'K' * 21, pgn=0xFECA, dst=0x20)) + _rb_drain(peer, 0.2) + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_CTS( + num_packets=1, next_packet=1, pgn=0xFECA))) + _rb_drain(peer, 0.3) + assert sock.impl.tx_seq == 2, "one DT must have been sent" + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_ACK( + total_size=21, num_packets=3, pgn=0xFECA))) + _rb_time.sleep(0.15) + assert sock.impl.tx_state == _rb_j1939._J1939_TX_RTS_WAIT_CTS, \ + "ACK before all DTs are sent must be ignored" + += Stale same-PGN EOM ACK with wrong size 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'L' * 14, pgn=0xFECA, dst=0x20)) + _rb_drain(peer, 0.2) + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_CTS( + num_packets=2, next_packet=1, pgn=0xFECA))) + _rb_drain(peer, 0.3) + assert sock.impl.tx_seq > sock.impl.tx_npkts + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_ACK( + total_size=99, num_packets=2, pgn=0xFECA))) + _rb_time.sleep(0.15) + assert sock.impl.tx_state == _rb_j1939._J1939_TX_RTS_WAIT_CTS, \ + "ACK with the wrong total_size must not finish TX" + += Stale same-PGN EOM ACK with wrong packet count 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'M' * 14, pgn=0xFECA, dst=0x20)) + _rb_drain(peer, 0.2) + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_CTS( + num_packets=2, next_packet=1, pgn=0xFECA))) + _rb_drain(peer, 0.3) + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_ACK( + total_size=14, num_packets=1, pgn=0xFECA))) + _rb_time.sleep(0.15) + assert sock.impl.tx_state == _rb_j1939._J1939_TX_RTS_WAIT_CTS, \ + "ACK with the wrong num_packets must not finish TX" + += BAM addressed to local unicast is ignored + +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_BAM( + total_size=14, num_packets=2, pgn=0xFECA))) + _rb_time.sleep(0.2) + assert not sock.impl.rx_sessions, "unicast BAM must be ignored" + += RTS addressed to broadcast is ignored and produces no 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(0xFF, 0x20, J1939_TP_CM_RTS( + total_size=14, num_packets=2, max_packets=0xFF, pgn=0xFECA))) + _rb_out = _rb_drain(peer, 0.3) + assert not sock.impl.rx_sessions, "broadcast RTS must not start RX" + assert not _rb_cms(_rb_out, J1939_TP_CTRL_CTS), \ + "broadcast RTS must not elicit a CTS" + += Broadcast CTS does not affect a directed TX session + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + sock.send(J1939(b'N' * 20, pgn=0xFECA, dst=0x20)) + _rb_drain(peer, 0.2) + peer.send(_rb_cm(0xFF, 0x20, J1939_TP_CM_CTS( + num_packets=3, next_packet=1, pgn=0xFECA))) + _rb_time.sleep(0.2) + assert sock.impl.tx_state == _rb_j1939._J1939_TX_RTS_WAIT_CTS + assert not _rb_dts(_rb_drain(peer, 0.2)), \ + "broadcast CTS must not authorise DT frames" + += Broadcast EOM ACK must not complete TX + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + sock.send(J1939(b'O' * 14, pgn=0xFECA, dst=0x20)) + _rb_drain(peer, 0.2) + peer.send(_rb_cm(0x10, 0x20, J1939_TP_CM_CTS( + num_packets=2, next_packet=1, pgn=0xFECA))) + _rb_drain(peer, 0.3) + assert sock.impl.tx_seq > sock.impl.tx_npkts + peer.send(_rb_cm(0xFF, 0x20, J1939_TP_CM_ACK( + total_size=14, num_packets=2, pgn=0xFECA))) + _rb_time.sleep(0.15) + assert sock.impl.tx_state == _rb_j1939._J1939_TX_RTS_WAIT_CTS, \ + "broadcast EOM ACK must not complete TX" + += Broadcast ABORT must not terminate a directed session + +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 + peer.send(_rb_cm(0xFF, 0x20, J1939_TP_CM_ABORT( + reason=3, pgn=0xFECA))) + _rb_time.sleep(0.15) + assert len(sock.impl.rx_sessions) == 1, \ + "broadcast ABORT must not drop a directed RX session" + = Scheduler teardown from scapy.contrib.isotp.isotp_soft_socket import TimeoutScheduler From cb2117a6584206427626690de70e0144bb087db6 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Wed, 2 Sep 2026 18:46:31 +0200 Subject: [PATCH 10/14] j1939: port ISO-TP adapter drains and harden soft-socket lifecycle Drain the CAN hardware RX FIFO at startup and close, poll with zero delay while TP is active, reject new sends while closing, preserve received TP priority, and reassemble into a bytearray. AI-Assisted: yes (Cursor) Co-authored-by: Cursor --- scapy/contrib/j1939.py | 71 +++++++++++++---- test/contrib/j1939.uts | 171 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 221 insertions(+), 21 deletions(-) diff --git a/scapy/contrib/j1939.py b/scapy/contrib/j1939.py index f068d3aeb0d..257c295c388 100644 --- a/scapy/contrib/j1939.py +++ b/scapy/contrib/j1939.py @@ -879,11 +879,11 @@ class _J1939_RXSession(object): """ __slots__ = ['sa', 'dst', 'pgn', 'total', 'npkts', 'is_bam', 'ts', - 'buf', 'seq', 'block_end', 'block_size', 'start_time', - 'timeout_handle'] + 'priority', '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 + def __init__(self, sa, dst, pgn, total, npkts, is_bam, ts, priority=6): + # type: (int, int, int, int, int, bool, Union[float, EDecimal], int) -> None # noqa: E501 self.sa = sa self.dst = dst self.pgn = pgn @@ -891,7 +891,8 @@ def __init__(self, sa, dst, pgn, total, npkts, is_bam, ts): self.npkts = npkts self.is_bam = is_bam self.ts = ts - self.buf = b'' + self.priority = priority + self.buf = bytearray() 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 @@ -952,6 +953,7 @@ def __init__( self.pgn_filter = pgn_filter # 0 = accept all PGNs self.basecls = basecls or J1939 # type: Type[Packet] self.closed = False + self.closing = False self.rx_tx_poll_rate = 0.005 # ── receive path ────────────────────────────────────────────────────── @@ -978,6 +980,17 @@ def __init__( # Enqueued outgoing messages: each item is a J1939 packet self.tx_queue = ObjectPipe() # type: ignore + # Drain frames that accumulated in the CAN adapter's hardware RX + # buffer while no soft socket was active. USB adapters (candle, + # cantact) have small hardware buffers; if background traffic fills + # them before can_recv starts polling, the next ECU response may be + # dropped by the adapter. + try: + self.can_socket.select([self.can_socket], 0) + except Exception: + log_j1939.debug("Exception during J1939 socket drain select", + exc_info=True) + # ── background polling ──────────────────────────────────────────────── self.rx_handle = TimeoutScheduler.schedule(0, self.can_recv) self.tx_handle = TimeoutScheduler.schedule(0, self._tx_poll) @@ -1036,11 +1049,14 @@ def close(self, timeout=None): so that a large BAM is not truncated; ``0`` shuts down at once. """ - if self.closed: + if self.closed or self.closing: return + self.closing = True # 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. + # New sends are rejected while closing so the drain budget cannot + # be extended by work that did not exist when shutdown started. derived = timeout is None if timeout is None: timeout = self.drain_timeout() @@ -1065,6 +1081,15 @@ def close(self, timeout=None): for handle in handles: self._cancel(handle) + # Final drain: move frames from the CAN adapter's hardware buffer + # into the SocketWrapper software queue so the next soft-socket + # session can consume them instead of overflowing the adapter FIFO. + try: + self.can_socket.select([self.can_socket], 0) + except Exception: + log_j1939.debug("Exception during J1939 socket drain select", + exc_info=True) + try: self.rx_queue.close() except Exception as e: @@ -1112,8 +1137,16 @@ def can_recv(self): if self.closed or self._can_socket_gone(): return + # Zero-delay polling while segmented RX or directed TX is active so + # slow serial/slcan multiplexers do not add backlog latency between + # TP frames. BAM TX does not force fast receive polling. + active = ( + bool(self.rx_sessions) or + self.tx_state in (_J1939_TX_RTS_WAIT_CTS, _J1939_TX_RTS_SENDING) + ) + poll_time = 0.0 if active else self.rx_tx_poll_rate self.rx_handle = self._TimeoutScheduler.schedule( - self.rx_tx_poll_rate, self.can_recv) + poll_time, self.can_recv) def on_can_recv(self, pkt): # type: (Packet) -> None @@ -1184,7 +1217,8 @@ def _on_tp_cm(self, j): return 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) + max_packets=bam.num_packets, is_bam=True, ts=ts, + priority=j.priority) elif ctrl == J1939_TP_CTRL_RTS: # RTS is directed; broadcast RTS must not start a session or CTS. @@ -1197,7 +1231,8 @@ def _on_tp_cm(self, j): return 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) + max_packets=rts.max_packets, is_bam=False, ts=ts, + priority=j.priority) elif ctrl == J1939_TP_CTRL_CTS: if j.dst != self.src_addr: @@ -1264,13 +1299,13 @@ def _on_tp_dt(self, j): self._rx_abort(session, _J1939_ABORT_BAD_SEQ) return - session.buf += dt.data + session.buf.extend(dt.data) session.seq += 1 self._rx_cancel_timer(session) if seq >= session.npkts: # All packets received – finalise the message. - payload = session.buf[:session.total] + payload = bytes(session.buf[:session.total]) if not session.is_bam and not self.listen_only: self._can_send_tp_cm( dst_sa=sa, @@ -1282,7 +1317,7 @@ def _on_tp_dt(self, j): ) msg = self.basecls(payload, pgn=session.pgn, src=session.sa, - dst=session.dst, priority=6) + dst=session.dst, priority=session.priority) self.rx_queue.send((msg, session.ts)) self._rx_forget(session) return @@ -1294,8 +1329,9 @@ def _on_tp_dt(self, j): # ── RX session helpers ──────────────────────────────────────────────────── - 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 + def _rx_start(self, sa, pgn, dst, total, npkts, max_packets, is_bam, ts, + priority=6): + # type: (int, int, int, int, int, int, bool, Union[float, EDecimal], int) -> 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. J1939-21 @@ -1328,7 +1364,8 @@ def _rx_start(self, sa, pgn, dst, total, npkts, max_packets, is_bam, ts): "%d sessions already open" % len(self.rx_sessions)) return - session = _J1939_RXSession(sa, dst, pgn, total, npkts, is_bam, ts) + session = _J1939_RXSession( + sa, dst, pgn, total, npkts, is_bam, ts, priority=priority) self.rx_sessions[session.key] = session if not is_bam: # J1939-21 flow control: never authorise more packets in one @@ -1681,6 +1718,8 @@ def send(self, msg): :data:`_J1939_TP_MAX_DATA` bytes the transport protocol can describe """ + if self.closed or self.closing: + raise Scapy_Exception("J1939 socket is closed") payload = self._payload_of(msg) if len(payload) > _J1939_TP_MAX_DATA: raise Scapy_Exception( @@ -1880,7 +1919,7 @@ def send(self, x): bytes the transport protocol can carry """ if self.closed: - return 0 + raise Scapy_Exception("J1939 socket is closed") if isinstance(x, Packet): x.sent_time = time.time() self.impl.send(x) diff --git a/test/contrib/j1939.uts b/test/contrib/j1939.uts index 75aabcb39cf..96df4acc653 100644 --- a/test/contrib/j1939.uts +++ b/test/contrib/j1939.uts @@ -3215,15 +3215,23 @@ assert len(_pf_cts2_pkt) == 0, \ 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. += J1939SoftSocket – send() on closed socket raises +# After close(), send() must refuse new work rather than queue it. + +from scapy.error import Scapy_Exception as _sc_exc + +def _sc_send_raises(sock, pkt): + try: + sock.send(pkt) + return False + except _sc_exc: + return True 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 + assert _sc_send_raises(_sc_sock, J1939(b'\x01\x02\x03', pgn=0xFECA)), \ + "send() on closed socket must raise Scapy_Exception" = J1939SoftSocket – ABORT from sender resets RTS/CTS RX session # If the sender issues an ABORT while we are waiting for TP.DT frames, @@ -4668,6 +4676,159 @@ with TestSocket(CAN) as cans, TestSocket(CAN) as peer: assert len(sock.impl.rx_sessions) == 1, \ "broadcast ABORT must not drop a directed RX session" += Segmented BAM RX preserves announcement priority + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + _rb_pri = 3 + _rb_payload = b'P' * 14 + peer.send(J1939_CAN(priority=_rb_pri, data_page=0, + pdu_format=J1939_PGN_TP_CM >> 8, + pdu_specific=0xFF, src=0x20, + data=bytes(J1939_TP_CM_BAM( + total_size=14, num_packets=2, pgn=0xFECA)))) + for _rb_i in range(2): + _rb_chunk = _rb_payload[_rb_i * 7:(_rb_i + 1) * 7] + peer.send(J1939_CAN(priority=_rb_pri, data_page=0, + pdu_format=J1939_PGN_TP_DT >> 8, + pdu_specific=0xFF, src=0x20, + data=bytes(J1939_TP_DT( + seq_num=_rb_i + 1, data=_rb_chunk)))) + _rb_msg = sock.sniff(count=1, timeout=2) + assert len(_rb_msg) == 1 + assert _rb_msg[0].priority == _rb_pri, \ + "reassembled priority %d != %d" % (_rb_msg[0].priority, _rb_pri) + assert _rb_msg[0].data == _rb_payload + += Segmented RTS/CTS RX preserves announcement priority + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + _rb_pri = 2 + _rb_payload = b'Q' * 14 + peer.send(J1939_CAN(priority=_rb_pri, data_page=0, + pdu_format=J1939_PGN_TP_CM >> 8, + pdu_specific=0x10, src=0x20, + data=bytes(J1939_TP_CM_RTS( + total_size=14, num_packets=2, + max_packets=0xFF, pgn=0xFECA)))) + _rb_drain(peer, 0.3) + for _rb_i in range(2): + _rb_chunk = _rb_payload[_rb_i * 7:(_rb_i + 1) * 7] + peer.send(J1939_CAN(priority=_rb_pri, data_page=0, + pdu_format=J1939_PGN_TP_DT >> 8, + pdu_specific=0x10, src=0x20, + data=bytes(J1939_TP_DT( + seq_num=_rb_i + 1, data=_rb_chunk)))) + _rb_msg = sock.sniff(count=1, timeout=2) + assert len(_rb_msg) == 1 + assert _rb_msg[0].priority == _rb_pri, \ + "reassembled priority %d != %d" % (_rb_msg[0].priority, _rb_pri) + assert _rb_msg[0].data == _rb_payload + += send() is rejected once close() begins draining +# closing rejects new work while an already-accepted BAM may still finish. + +import threading as _rb_thr + +def _rb_send_raises(sock, pkt): + try: + sock.send(pkt) + return False + except Scapy_Exception: + return True + +_rb_cans4 = TestSocket(CAN) +_rb_peer4 = TestSocket(CAN) +_rb_cans4.pair(_rb_peer4) +_rb_sock4 = J1939SoftSocket(_rb_cans4, src_addr=0x10) +_rb_sock4.send(J1939(b'R' * 70, pgn=0xFECA, dst=0xFF)) + +def _rb_close_drain(): + _rb_sock4.close() + +_rb_t = _rb_thr.Thread(target=_rb_close_drain) +_rb_t.start() +_rb_time.sleep(0.05) +_rb_send_raised = _rb_send_raises(_rb_sock4, J1939(b'X', pgn=0xFECA, dst=0xFF)) +_rb_t.join(timeout=5) +_rb_cans4.close() +_rb_peer4.close() +cleanup_testsockets() +assert _rb_send_raised, "send during close must raise Scapy_Exception" +assert _rb_sock4.closed + += 1785-byte BAM RX reassembles via bytearray + +with TestSocket(CAN) as cans, TestSocket(CAN) as peer: + cans.pair(peer) + with J1939SoftSocket(cans, src_addr=0x10) as sock: + _rb_payload = bytes((i * 7) & 0xFF for i in range(1785)) + peer.send(_rb_cm(0xFF, 0x20, J1939_TP_CM_BAM( + total_size=1785, num_packets=255, pgn=0xFECA))) + for _rb_i in range(255): + _rb_chunk = _rb_payload[_rb_i * 7:(_rb_i + 1) * 7] + _rb_chunk += b'\xff' * (7 - len(_rb_chunk)) + peer.send(_rb_dt(0xFF, 0x20, _rb_i + 1, _rb_chunk)) + _rb_msg = sock.sniff(count=1, timeout=5) + assert len(_rb_msg) == 1 + assert _rb_msg[0].data == _rb_payload + += USBTestSocket: startup drain preserves frames queued before construction +# Background traffic fills the hardware FIFO before the soft socket exists; +# the initial select(..., 0) must move those frames into the software queue +# so the subsequent BAM is not dropped by a saturated adapter FIFO. + +from test.testsocket import USBTestSocket + +_usb_sd_payload = b'\xCD' * 14 +with USBTestSocket(CAN, hw_fifo_size=16) as usb_cans, TestSocket(CAN) as stim: + usb_cans.pair(stim) + for _j in range(12): + stim.send(J1939_CAN(priority=6, pdu_format=0x01, pdu_specific=0x99, + src=0x50, data=bytes(8))) + with J1939SoftSocket(usb_cans, src_addr=0x00) as sock: + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=0x2E, data=bytes(J1939_TP_CM_BAM( + total_size=14, num_packets=2, pgn=0xFECA)))) + for _usbi in range(2): + _usb_chunk = _usb_sd_payload[_usbi * 7:(_usbi + 1) * 7] + stim.send(J1939_CAN(priority=7, pdu_format=0xEB, pdu_specific=0xFF, + src=0x2E, data=bytes(J1939_TP_DT( + seq_num=_usbi + 1, data=_usb_chunk)))) + _usb_pkts = sock.sniff(count=1, timeout=3) + assert len(_usb_pkts) == 1, "startup drain must leave room for the BAM" + assert _usb_pkts[0].data == _usb_sd_payload + += USBTestSocket: final close drain leaves frames for the next soft socket + +_usb_cd_payload = b'\xEF' * 9 +with USBTestSocket(CAN, hw_fifo_size=16) as usb_cans, TestSocket(CAN) as stim: + usb_cans.pair(stim) + with J1939SoftSocket(usb_cans, src_addr=0x00) as sock1: + pass + for _j in range(8): + stim.send(J1939_CAN(priority=6, pdu_format=0x01, pdu_specific=0x99, + src=0x51, data=bytes(8))) + # Those frames sit in the hardware FIFO until close()'s final drain + # (already done) or the next socket's startup drain moves them. + with J1939SoftSocket(usb_cans, src_addr=0x00) as sock2: + stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, + src=0x2F, data=bytes(J1939_TP_CM_BAM( + total_size=9, num_packets=2, pgn=0xFECA)))) + for _usbi in range(2): + _usb_chunk = _usb_cd_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=0x2F, data=bytes(J1939_TP_DT( + seq_num=_usbi + 1, data=_usb_chunk)))) + _usb_pkts = sock2.sniff(count=1, timeout=3) + assert len(_usb_pkts) == 1, \ + "close drain must free adapter FIFO for next socket" + assert _usb_pkts[0].data == _usb_cd_payload + = Scheduler teardown from scapy.contrib.isotp.isotp_soft_socket import TimeoutScheduler From 91a605982d3e4c92aac933be3846c79cf346857b Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Wed, 2 Sep 2026 18:56:02 +0200 Subject: [PATCH 11/14] j1939: align soft-socket PGN filter with NativeJ1939Socket Use J1939_NO_PGN as the accept-all sentinel so real PGN 0 is filterable, drop dead TX state, reuse from_can, narrow recv closure handling, and make the portable soft-socket tests not inherit a campaign linux tag. AI-Assisted: yes (Cursor) Co-authored-by: Cursor --- scapy/contrib/j1939.py | 68 +++++++++++++++++++----------------------- test/contrib/j1939.uts | 36 ++++++++++++++++------ 2 files changed, 58 insertions(+), 46 deletions(-) diff --git a/scapy/contrib/j1939.py b/scapy/contrib/j1939.py index 257c295c388..bf3b872d2c1 100644 --- a/scapy/contrib/j1939.py +++ b/scapy/contrib/j1939.py @@ -924,9 +924,10 @@ class J1939TPImplementation: 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. + :param pgn_filter: when not :data:`socket.J1939_NO_PGN`, only messages + whose PGN matches this value are delivered. + :data:`socket.J1939_NO_PGN` (the default) accepts all + PGNs, matching :class:`NativeJ1939Socket`. :param basecls: packet class used for delivered messages, defaulting to :class:`J1939` @@ -940,7 +941,7 @@ def __init__( can_socket, # type: "CANSocket" src_addr, # type: int listen_only=False, # type: bool - pgn_filter=0, # type: int + pgn_filter=socket.J1939_NO_PGN, # type: int basecls=None, # type: Optional[Type[Packet]] ): # type: (...) -> None @@ -950,7 +951,7 @@ def __init__( 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.pgn_filter = pgn_filter self.basecls = basecls or J1939 # type: Type[Packet] self.closed = False self.closing = False @@ -969,12 +970,9 @@ def __init__( 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 @@ -1152,8 +1150,9 @@ 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() + j = J1939_CAN.from_can(pkt) + if not j.time: + j.time = time.time() except Exception: return @@ -1191,7 +1190,7 @@ def on_can_recv(self, pkt): 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: + if self.pgn_filter != socket.J1939_NO_PGN and j.pgn != self.pgn_filter: return msg = self.basecls(data, pgn=j.pgn, src=j.src, dst=j.dst, priority=j.priority) @@ -1213,7 +1212,7 @@ def _on_tp_cm(self, j): if len(data) < 8: return bam = J1939_TP_CM_BAM(data) - if self.pgn_filter != 0 and bam.pgn != self.pgn_filter: + if self.pgn_filter != socket.J1939_NO_PGN and bam.pgn != self.pgn_filter: return self._rx_start(sa=sa, pgn=bam.pgn, dst=socket.J1939_NO_ADDR, total=bam.total_size, npkts=bam.num_packets, @@ -1227,7 +1226,7 @@ def _on_tp_cm(self, j): if len(data) < 8: return rts = J1939_TP_CM_RTS(data) - if self.pgn_filter != 0 and rts.pgn != self.pgn_filter: + if self.pgn_filter != socket.J1939_NO_PGN and rts.pgn != self.pgn_filter: return self._rx_start(sa=sa, pgn=rts.pgn, dst=self.src_addr, total=rts.total_size, npkts=rts.num_packets, @@ -1530,7 +1529,6 @@ def _begin_send(self, msg): dst = msg.dst priority = msg.priority else: - data = bytes(msg) pgn = 0 dst = socket.J1939_NO_ADDR priority = 6 @@ -1551,18 +1549,18 @@ def _begin_send(self, msg): ) self._can_send(pkt) - elif dst == socket.J1939_NO_ADDR or dst == 0xFF: + elif dst == socket.J1939_NO_ADDR: # Broadcast multi-packet message via BAM. - self._tx_start_bam(data, pgn, dst, priority, data_page) + self._tx_start_bam(data, pgn, dst, priority) else: # Unicast multi-packet message via RTS/CTS. - self._tx_start_rts(data, pgn, dst, priority, data_page) + self._tx_start_rts(data, pgn, dst, priority) # ── BAM TX ─────────────────────────────────────────────────────────────── - def _tx_start_bam(self, data, pgn, dst, priority, data_page): - # type: (bytes, int, int, int, int) -> None + def _tx_start_bam(self, data, pgn, dst, priority): + # type: (bytes, 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 @@ -1572,7 +1570,6 @@ def _tx_start_bam(self, data, pgn, dst, priority, data_page): 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) @@ -1604,8 +1601,8 @@ def _tx_bam_next_dt(self): # ── RTS/CTS TX ─────────────────────────────────────────────────────────── - def _tx_start_rts(self, data, pgn, dst, priority, data_page): - # type: (bytes, int, int, int, int) -> None + def _tx_start_rts(self, data, pgn, dst, priority): + # type: (bytes, 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 @@ -1613,7 +1610,6 @@ def _tx_start_rts(self, data, pgn, dst, priority, data_page): 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 @@ -1649,12 +1645,12 @@ def _tx_handle_cts(self, cts): self.tx_seq = cts.next_packet remaining = self.tx_npkts - self.tx_seq + 1 - self.tx_cts_count = min(cts.num_packets, remaining) + count = min(cts.num_packets, remaining) self.tx_state = _J1939_TX_RTS_SENDING - self._tx_rts_send_block() + self._tx_rts_send_block(count) - def _tx_rts_send_block(self): - # type: () -> None + def _tx_rts_send_block(self, count): + # type: (int) -> 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: @@ -1662,7 +1658,7 @@ def _tx_rts_send_block(self): return sent = 0 - while sent < self.tx_cts_count: + while sent < count: seq = self.tx_seq if seq > self.tx_npkts: break @@ -1741,10 +1737,7 @@ def recv(self): """ try: return self.rx_queue.recv() # type: ignore - except Exception: - if not self.closed: - log_j1939.warning( - "J1939 recv error: %s", traceback.format_exc()) + except (EOFError, OSError): return None @@ -1781,15 +1774,16 @@ class J1939SoftSocket(SuperSocket): :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) + defaults to :data:`socket.J1939_NO_ADDR` (0xFF = 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. + :param pgn: when not :data:`socket.J1939_NO_PGN`, only messages + whose PGN matches this value are delivered; + :data:`socket.J1939_NO_PGN` (the default) accepts every + PGN, matching :class:`NativeJ1939Socket`. """ desc = ("read/write J1939 messages using a software " @@ -1803,7 +1797,7 @@ def __init__( src_addr=socket.J1939_NO_ADDR, # type: int basecls=J1939, # type: Type[Packet] listen_only=False, # type: bool - pgn=0, # type: int + pgn=socket.J1939_NO_PGN, # type: int ): # type: (...) -> None if LINUX and isinstance(can_socket, str): diff --git a/test/contrib/j1939.uts b/test/contrib/j1939.uts index 96df4acc653..bec5ad38558 100644 --- a/test/contrib/j1939.uts +++ b/test/contrib/j1939.uts @@ -1,5 +1,5 @@ % Regression tests for SAE J1939 protocol -~ not_pypy linux +~ not_pypy # More information at http://www.secdev.org/projects/UTscapy/ @@ -1628,7 +1628,7 @@ assert _j2iso_rx.data == _j2iso_isotp_payload, \ ############ ############ + J1939 Segmented Communication – comprehensive J1939_TP_CM tests -~ not_pypy linux +~ not_pypy = J1939_TP_CM_BAM with various payload sizes (7-1785 bytes) # Test BAM with different total sizes and packet counts per J1939 standard @@ -3134,21 +3134,39 @@ 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. += J1939SoftSocket – pgn=J1939_NO_PGN accepts all PGNs +# Soft and native sockets share the same accept-all sentinel. with TestSocket(CAN) as cans, TestSocket(CAN) as stim: cans.pair(stim) - with J1939SoftSocket(cans, src_addr=0x00, pgn=0) as sock: + with J1939SoftSocket(cans, src_addr=0x00, + pgn=_socket.J1939_NO_PGN) 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) + _pgn_all_pkts = sock.sniff(count=2, timeout=0.5) + +assert len(_pgn_all_pkts) == 2, \ + "J1939_NO_PGN: expected both PGNs delivered, got %d" % len(_pgn_all_pkts) + += J1939SoftSocket – pgn=0 delivers only real PGN 0 +# Unlike the old soft-socket convention, 0 is a filterable PGN, not a wildcard. + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with J1939SoftSocket(cans, src_addr=0x00, pgn=0) as sock: + # PGN 0 is PDU1 with PF=0, PS=destination (broadcast here). + stim.send(J1939_CAN(priority=6, data_page=0, pdu_format=0x00, + pdu_specific=0xFF, src=0x01, data=b'\x01')) + stim.send(J1939_CAN(priority=6, pdu_format=0xFE, pdu_specific=0xCA, + src=0x01, data=b'\x02')) + _pgn0_only = sock.sniff(count=2, timeout=0.5) -assert len(_pgn0_pkts) == 2, \ - "pgn=0: expected both PGNs delivered, got %d" % len(_pgn0_pkts) +assert len(_pgn0_only) == 1, \ + "pgn=0 must deliver only PGN 0, got %d" % len(_pgn0_only) +assert _pgn0_only[0].pgn == 0, "got PGN 0x%X" % _pgn0_only[0].pgn +assert _pgn0_only[0].data == b'\x01' = J1939SoftSocket – pgn filter: matching RTS/CTS unicast PGN is delivered # pgn filter must apply to unicast (RTS/CTS) sessions in addition to BAM. From 8d46e224f9d8dd7a26c58eb3d715ceb453ea3081 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Wed, 2 Sep 2026 21:33:23 +0200 Subject: [PATCH 12/14] Import SuperSocket from scapy.supersocket AI-Assisted: yes (GitHub Copilot) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Cursor --- test/contrib/j1939.uts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/contrib/j1939.uts b/test/contrib/j1939.uts index bec5ad38558..2d49fde08af 100644 --- a/test/contrib/j1939.uts +++ b/test/contrib/j1939.uts @@ -2095,6 +2095,7 @@ from scapy.contrib.j1939 import ( J1939_TP_CTRL_ACK, J1939_TP_CTRL_ABORT, ) from scapy.layers.can import CAN +from scapy.supersocket import SuperSocket from test.testsocket import TestSocket, cleanup_testsockets import socket as _socket From af48853a646d76761ce13199b3795f93a3b29515 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Tue, 8 Sep 2026 15:03:30 +0200 Subject: [PATCH 13/14] j1939: restore TP timing overrides with try/finally in soft-socket tests Guarantee _J1939_TP_T1/_J1939_TP_T2 are restored if the inactivity-timeout case fails mid-run, so later tests cannot inherit shortened deadlines. AI-Assisted: yes (Cursor) Co-authored-by: Cursor --- test/contrib/j1939.uts | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/test/contrib/j1939.uts b/test/contrib/j1939.uts index 2d49fde08af..3034771a16e 100644 --- a/test/contrib/j1939.uts +++ b/test/contrib/j1939.uts @@ -3068,23 +3068,28 @@ assert len(_lo_ack_frames) == 0, \ 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) +try: + _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) + # Restore before post-timeout traffic so later timing is normal. + _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) +finally: + _j1939_mod._J1939_TP_T1 = _saved_T1 + _j1939_mod._J1939_TP_T2 = _saved_T2 assert len(_timeout_pkts) == 1, \ "After inactivity timeout state should be IDLE; new msg not received" From 0b2a760d08682cc1ae3de371cb1b3ae37a469e13 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Tue, 8 Sep 2026 15:15:01 +0200 Subject: [PATCH 14/14] j1939: fail fast on missing CAN socket and close interop FDs Reject J1939SoftSocket(can_socket=None) with Scapy_Exception, assert vcan setup succeeded, and close NativeCANSocket adapters after soft / native interop cases so later vcan tests do not inherit leaked FDs. AI-Assisted: yes (Cursor) Co-authored-by: Cursor --- scapy/contrib/j1939.py | 2 ++ test/contrib/j1939.uts | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/scapy/contrib/j1939.py b/scapy/contrib/j1939.py index bf3b872d2c1..bfa7566283c 100644 --- a/scapy/contrib/j1939.py +++ b/scapy/contrib/j1939.py @@ -1806,6 +1806,8 @@ def __init__( elif isinstance(can_socket, str): raise Scapy_Exception( "Provide a CANSocket object instead of an interface name") + if can_socket is None: + raise Scapy_Exception("Provide a CANSocket object instead") self.src_addr = src_addr self.basecls = basecls or J1939 diff --git a/test/contrib/j1939.uts b/test/contrib/j1939.uts index 3034771a16e..7491982595d 100644 --- a/test/contrib/j1939.uts +++ b/test/contrib/j1939.uts @@ -2388,10 +2388,12 @@ 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'" _iop_setup_rc = os.system(_iop_setup_cmd) print("[iop-setup] vcan modprobe+up rc=%d" % _iop_setup_rc) +assert _iop_setup_rc == 0, "vcan0 setup failed with 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) +assert _iop_link_rc == 0, "vcan0 not present (ip link show rc=%d)" % _iop_link_rc from scapy.contrib.cansocket_native import NativeCANSocket from scapy.contrib.j1939 import NativeJ1939Socket @@ -2434,6 +2436,7 @@ 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() +_iop1_cansock.close() assert _iop1_pkts, "NativeJ1939Socket received no packet from J1939SoftSocket" _iop1_rx = _iop1_pkts[0] @@ -2478,6 +2481,7 @@ for _i, _p in enumerate(_iop2_pkts): _iop2_native_tx.close() _iop2_soft_rx.close() +_iop2_cansock.close() assert _iop2_pkts, "J1939SoftSocket received no packet from NativeJ1939Socket" _iop2_rx = _iop2_pkts[0] @@ -2526,6 +2530,7 @@ 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() +_iop3_cansock.close() assert _iop3_pkts, "NativeJ1939Socket received no BAM message from J1939SoftSocket" _iop3_rx = _iop3_pkts[0] @@ -2573,6 +2578,7 @@ for _i, _p in enumerate(_iop4_pkts): _iop4_native_tx.close() _iop4_soft_rx.close() +_iop4_cansock.close() assert _iop4_pkts, "J1939SoftSocket received no reassembled BAM message" _iop4_rx = _iop4_pkts[0] @@ -3257,6 +3263,18 @@ with TestSocket(CAN) as cans: assert _sc_send_raises(_sc_sock, J1939(b'\x01\x02\x03', pgn=0xFECA)), \ "send() on closed socket must raise Scapy_Exception" += J1939SoftSocket – can_socket=None fails fast +# Construction without a CAN socket must raise Scapy_Exception, not AttributeError. + +def _none_sock_raises(): + try: + J1939SoftSocket() + return False + except _sc_exc: + return True + +assert _none_sock_raises(), "J1939SoftSocket() must raise Scapy_Exception" + = 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 @@ -3828,6 +3846,7 @@ _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() +_u1_cansock.close() assert _u1_pkts, "NativeJ1939Socket received no unicast from J1939SoftSocket" _u1_rx = _u1_pkts[0] @@ -3857,6 +3876,7 @@ _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() +_u2_cansock.close() assert _u2_pkts, "J1939SoftSocket received no unicast from NativeJ1939Socket" _u2_rx = _u2_pkts[0] @@ -3891,6 +3911,7 @@ _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() +_rtc_cansock.close() assert _rtc_iop_pkts, \ "NativeJ1939Socket received no RTS/CTS message from J1939SoftSocket" @@ -3923,6 +3944,7 @@ _big_iop_pkts = _big_iop_soft_rx.sniff(timeout=8.0, _big_iop_t.join(timeout=12) _big_iop_native_tx.close() _big_iop_soft_rx.close() +_big_iop_cansock.close() assert _big_iop_pkts, \ "J1939SoftSocket received no 100-byte BAM from NativeJ1939Socket" @@ -3955,6 +3977,7 @@ _bigs_iop_pkts = _bigs_iop_native_rx.sniff(timeout=8.0, count=1) _bigs_iop_t.join(timeout=12) _bigs_iop_native_rx.close() +_bigs_iop_cansock.close() assert _bigs_iop_pkts, \ "NativeJ1939Socket received no 100-byte BAM from J1939SoftSocket" @@ -3992,6 +4015,7 @@ _prio_iop_raw = _prio_iop_cansock_rx.sniff(timeout=3.0, count=2) _prio_iop_t.join(timeout=5) _prio_iop_cansock_rx.close() +_prio_iop_cansock_tx.close() assert len(_prio_iop_raw) == 2, \ "Priority test: expected 2 frames, got %d" % len(_prio_iop_raw) @@ -4058,6 +4082,7 @@ _pp_native_captured = _pp_native.sniff( _pp_t_ss.join(timeout=5); _pp_t_ns.join(timeout=5); _pp_t_sr.join(timeout=5) _pp_soft.close(); _pp_native.close() +_pp_cansock1.close(); _pp_cansock2.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]