From d833043722574ef1acd5e88196333e8d39efb529 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Thu, 11 Jun 2026 22:11:36 +0200 Subject: [PATCH 01/18] 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 e0626fec02f4becf249a2e18859b62fe0afce8e4 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Fri, 12 Jun 2026 08:50:02 +0200 Subject: [PATCH 02/18] 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 90570fee54d8de57c51833f75d45436a16447cb9 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Fri, 12 Jun 2026 09:02:45 +0200 Subject: [PATCH 03/18] 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 e59199f48262d4c44b42b3aca3ed06bbf075eb61 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Fri, 12 Jun 2026 12:11:54 +0200 Subject: [PATCH 04/18] 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 3414c3b508e334cf7b91b8a4e37bc3630d671a91 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Fri, 12 Jun 2026 12:34:47 +0200 Subject: [PATCH 05/18] 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 9e2556bd26fcd555aba8c8420f55b28a01c031bd Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Thu, 13 Aug 2026 08:11:49 +0200 Subject: [PATCH 06/18] 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 19212ea525174c15e9f3be37f576006543781736 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Thu, 13 Aug 2026 08:39:32 +0200 Subject: [PATCH 07/18] 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 8965e37a0b893ea666e2c0c4d6d7141ddba6538c Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Thu, 13 Aug 2026 09:38:40 +0200 Subject: [PATCH 08/18] 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 626eae08a6ed23eee4a6ca6d9a7aae98e9e73ae8 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Wed, 2 Sep 2026 18:39:47 +0200 Subject: [PATCH 09/18] 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 fafddb9e56e584fde61ed14f410bb8aba5a19f4a Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Wed, 2 Sep 2026 18:46:31 +0200 Subject: [PATCH 10/18] 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 7a0ee7e374f6413ebd70d58c0ebde19efa49c49b Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Wed, 2 Sep 2026 18:56:02 +0200 Subject: [PATCH 11/18] 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 cfec25eff0081c6794a845710828802cefe2afc6 Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Wed, 2 Sep 2026 21:33:23 +0200 Subject: [PATCH 12/18] 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 60cfb114217c648eec05ae864a5daf02869f94ed Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Tue, 8 Sep 2026 15:03:30 +0200 Subject: [PATCH 13/18] 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 0b2d6efc20dbb63f909ac957500a3efdd7c198fb Mon Sep 17 00:00:00 2001 From: Nils Weiss Date: Tue, 8 Sep 2026 15:15:01 +0200 Subject: [PATCH 14/18] 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] From 8347e30765a0eb55fd3f461202762f4b3d1f603e Mon Sep 17 00:00:00 2001 From: Ben Gardiner Date: Wed, 2 Sep 2026 15:02:23 -0400 Subject: [PATCH 15/18] automotive: J1939 Diagnostic Messages (J1939-73) and DM scanner --- scapy/contrib/automotive/j1939/j1939_dm.py | 390 +++++++++++ .../automotive/j1939/j1939_dm_scanner.py | 441 ++++++++++++ test/contrib/automotive/j1939_dm.uts | 260 +++++++ test/contrib/automotive/j1939_dm_scanner.uts | 649 ++++++++++++++++++ 4 files changed, 1740 insertions(+) create mode 100644 scapy/contrib/automotive/j1939/j1939_dm.py create mode 100644 scapy/contrib/automotive/j1939/j1939_dm_scanner.py create mode 100644 test/contrib/automotive/j1939_dm.uts create mode 100644 test/contrib/automotive/j1939_dm_scanner.uts diff --git a/scapy/contrib/automotive/j1939/j1939_dm.py b/scapy/contrib/automotive/j1939/j1939_dm.py new file mode 100644 index 00000000000..6000cf71402 --- /dev/null +++ b/scapy/contrib/automotive/j1939/j1939_dm.py @@ -0,0 +1,390 @@ +# SPDX-License-Identifier: GPL-2.0-only +# This file is part of Scapy +# See https://scapy.net/ for more information +# Copyright (C) National Motor Freight Traffic Association Inc. +# + +# scapy.contrib.description = SAE J1939 Diagnostic Messages (J1939-73) +# scapy.contrib.status = loads + +""" +J1939 Diagnostic Messages (DMs) for Scapy. + +Implements Scapy packet classes for the most common SAE J1939-73 Diagnostic +Messages: + +- ``J1939_DTC`` -- 4-byte Diagnostic Trouble Code (SPN / FMI / CM / OC) +- ``J1939_DM1`` -- Active DTCs, PGN 0xFECA (65226) +- ``J1939_DM13`` -- Stop/Start Broadcast, PGN 0xE000 (57344) +- ``J1939_DM14`` -- Memory Access Request, PGN 0xD900 (55552) + +All J1939 payload bytes are in little-endian (LE) byte order. The +``J1939_DTC`` class performs a 4-byte reversal in ``do_dissect`` / +``do_build`` so that Scapy's big-endian ``BitField`` machinery can parse +the LE wire format transparently. + +Usage example:: + + >>> load_contrib('automotive.j1939') + >>> from scapy.contrib.automotive.j1939.j1939_dm import ( + ... J1939_DTC, J1939_DM1, J1939_DM13, J1939_DM14, PGN_DM1 + ... ) + >>> dtc = J1939_DTC(SPN=100, FMI=2, CM=0, OC=5) + >>> dm1 = J1939_DM1(mil_status=1, dtcs=[dtc]) + >>> len(bytes(dm1)) # padded to 8 bytes + 8 +""" + +# Typing imports +from typing import ( # noqa: F401 + Any, + List, + Tuple, +) + +from scapy.error import Scapy_Exception +from scapy.fields import ( + BitEnumField, + BitField, + ByteField, + StrFixedLenField, + XLEIntField, + XShortField, +) +from scapy.packet import Packet + +from scapy.contrib.j1939 import ( + J1939, + J1939_BROADCAST_ADDR as J1939_GLOBAL_ADDRESS, +) + +# --------------------------------------------------------------------------- +# PGN constants for Diagnostic Messages (J1939-73) +# --------------------------------------------------------------------------- + +#: PGN for DM1 Active Diagnostic Trouble Codes +PGN_DM1 = 0xFECA # 65226 + +#: PGN for DM13 Stop/Start Broadcast Command +PGN_DM13 = 0xE000 # 57344 + +#: PGN for DM14 Memory Access Request +PGN_DM14 = 0xD900 # 55552 + +# Lamp status encoding (2-bit values per lamp) +_LAMP_STATUS = { + 0b00: "off", + 0b01: "on", + 0b10: "reserved", + 0b11: "not_available", +} + +# DM14 command type encoding +_DM14_COMMAND = { + 0: "erase", + 1: "read", + 2: "write", + 3: "reserved", +} + +# DM14 pointer type encoding +_DM14_POINTER_TYPE = { + 0: "direct", + 1: "indirect", + 2: "copy", + 3: "reserved", +} + + +class J1939_DTC(Packet): + """J1939-73 Diagnostic Trouble Code (4 bytes, little-endian). + + A DTC is a 32-bit little-endian integer with the following bit layout: + + - bits 18-0: SPN (Suspect Parameter Number, 19 bits) + - bits 23-19: FMI (Failure Mode Indicator, 5 bits) + - bit 24: CM (SPN Conversion Method, 1 bit) + - bits 31-25: OC (Occurrence Count, 7 bits) + + Wire bytes (LSB first):: + + byte 0: SPN[7:0] + byte 1: SPN[15:8] + byte 2: FMI[4:0] | SPN[18:16] (bits 7-3 = FMI, bits 2-0 = SPN MSBs) + byte 3: OC[6:0] | CM (bits 7-1 = OC, bit 0 = CM) + + :param SPN: Suspect Parameter Number (0-524287) + :param FMI: Failure Mode Indicator (0-31) + :param CM: SPN Conversion Method (0-1) + :param OC: Occurrence Count (0-127) + """ + + name = "J1939_DTC" + + fields_desc = [ + # Declared in big-endian (MSB-first) order for BitField processing. + # do_dissect / do_build reverse the 4 bytes to convert between + # J1939 little-endian wire format and Scapy's big-endian BitField. + BitField("OC", 0, 7), # bits 31-25 (MSB side) + BitField("CM", 0, 1), # bit 24 + BitField("FMI", 0, 5), # bits 23-19 + BitField("SPN", 0, 19), # bits 18-0 (LSB side) + ] + + def do_dissect(self, s): + # type: (bytes) -> bytes + """Dissect a 4-byte LE DTC from *s*; return remaining bytes.""" + if len(s) >= 4: + # J1939 DTC is a LE 32-bit word; reverse bytes so that + # Scapy's BE BitField machinery sees the MSB first. + super(J1939_DTC, self).do_dissect(s[:4][::-1]) + return s[4:] + return b"" + + def do_build(self): + # type: () -> bytes + """Build 4 LE bytes from the current field values.""" + # BitField builds in BE order; reverse to produce J1939 LE wire bytes. + return super(J1939_DTC, self).do_build()[::-1] + + def extract_padding(self, s): + # type: (bytes) -> Tuple[bytes, bytes] + """No sub-layer payload; all remaining bytes returned as padding.""" + return b"", s + + +class J1939_DM1(Packet): + """DM1 Active Diagnostic Trouble Codes (PGN 0xFECA = 65226). + + Wire format: + + - Bytes 0-1: Lamp Status (4 lamps × 2 bits on/off + 4 lamps × 2 bits + flash pattern). + - Bytes 2+: Variable list of :class:`J1939_DTC` records (4 bytes each). + + Single-frame DM1 messages (up to 8 bytes) are zero-padded with ``0xFF`` + to exactly 8 bytes. Multi-packet messages (>8 bytes) are sent via the + J1939-21 Transport Protocol, handled automatically by + :class:`J1939SoftSocket`. + + :param mil_status: Malfunction Indicator Lamp on/off (0=off, 1=on, 3=N/A) + :param rsl_status: Red Stop Lamp on/off + :param awl_status: Amber Warning Lamp on/off + :param pl_status: Protect Lamp on/off + :param mil_flash: MIL flash pattern + :param rsl_flash: RSL flash pattern + :param awl_flash: AWL flash pattern + :param pl_flash: PL flash pattern + :param dtcs: list of :class:`J1939_DTC` objects + """ + + name = "J1939_DM1" + + #: PGN for DM1 Active DTCs (J1939-73) + PGN = PGN_DM1 + + __slots__ = Packet.__slots__ + ["dtcs"] + + fields_desc = [ + # Byte 0: Lamp on/off status (bits 7-6 = MIL, 5-4 = RSL, 3-2 = AWL, 1-0 = PL) + BitEnumField("mil_status", 3, 2, _LAMP_STATUS), + BitEnumField("rsl_status", 3, 2, _LAMP_STATUS), + BitEnumField("awl_status", 3, 2, _LAMP_STATUS), + BitEnumField("pl_status", 3, 2, _LAMP_STATUS), + # Byte 1: Lamp flash patterns (same 2-bit encoding) + BitEnumField("mil_flash", 3, 2, _LAMP_STATUS), + BitEnumField("rsl_flash", 3, 2, _LAMP_STATUS), + BitEnumField("awl_flash", 3, 2, _LAMP_STATUS), + BitEnumField("pl_flash", 3, 2, _LAMP_STATUS), + ] + + def __init__(self, *args, **kwargs): + # type: (*Any, **Any) -> None + self.dtcs = kwargs.pop("dtcs", []) # type: List[J1939_DTC] + Packet.__init__(self, *args, **kwargs) + + def do_dissect(self, s): + # type: (bytes) -> bytes + """Parse 2-byte lamp status then consume 4-byte DTC records.""" + remain = super(J1939_DM1, self).do_dissect(s) + # Trailing bytes shorter than a full DTC (< 4 bytes) are treated as + # 0xFF padding and silently ignored, per J1939-21 single-frame rules. + self.dtcs = [] + while len(remain) >= 4: + self.dtcs.append(J1939_DTC(remain[:4])) + remain = remain[4:] + return b"" + + def do_build(self): + # type: () -> bytes + """Build lamp status bytes + DTC bytes, padded to 8 bytes if needed.""" + lamp_bytes = super(J1939_DM1, self).do_build() + dtc_bytes = b"".join(bytes(dtc) for dtc in self.dtcs) + result = lamp_bytes + dtc_bytes + if len(result) < 8: + result += b"\xff" * (8 - len(result)) + return result + + def extract_padding(self, s): + # type: (bytes) -> Tuple[bytes, bytes] + return b"", s + + def __repr__(self): + # type: () -> str + return ( + "".format( + self.mil_status, + self.rsl_status, + self.awl_status, + self.pl_status, + self.dtcs, + ) + ) + + +class J1939_DM13(Packet): + """DM13 Stop/Start Broadcast Command (PGN 0xE000 = 57344). + + Broadcast to all ECUs on the bus to start or stop periodic diagnostic + broadcast. The ``hold_signal`` byte uses the J1939-73 convention: + ``0xFE`` = start broadcasting, ``0xFF`` = stop broadcasting. + + :param hold_signal: broadcast control (0xFE=start, 0xFF=stop) + :param data: remaining 7 bytes (optional override; default all 0xFF) + """ + + name = "J1939_DM13" + + #: PGN for DM13 Stop/Start Broadcast + PGN = PGN_DM13 + + _hold_signal_enum = {0xFE: "start", 0xFF: "stop"} + + fields_desc = [ + ByteField("hold_signal", 0xFF), + StrFixedLenField("data", b"\xff" * 7, 7), + ] + + def extract_padding(self, s): + # type: (bytes) -> Tuple[bytes, bytes] + return b"", s + + +class J1939_DM14(Packet): + """DM14 Memory Access Request (PGN 0xD900 = 55552). + + Peer-to-peer request to read, write, or erase ECU memory. DM14 must + always be addressed to a specific ECU (not the global broadcast address + ``0xFF``). + + Wire format (8 bytes): + + - Byte 0: bits 7-6 = reserved (1), bits 5-4 = command, bits 3-2 = + pointer type, bits 1-0 = access level + - Bytes 1-4: memory address (32-bit LE) + - Byte 5: data length (number of bytes to read/write) + - Bytes 6-7: reserved (0xFFFF) + + :param command_type: memory operation (0=erase, 1=read, 2=write) + :param pointer_type: addressing mode (0=direct, 1=indirect, 2=copy) + :param access_level: security access level (0-3) + :param address: 32-bit LE memory address + :param length: number of bytes to access + """ + + name = "J1939_DM14" + + #: PGN for DM14 Memory Access Request + PGN = PGN_DM14 + + fields_desc = [ + # Byte 0: control fields + BitField("reserved", 0b11, 2), + BitEnumField("command_type", 1, 2, _DM14_COMMAND), + BitEnumField("pointer_type", 0, 2, _DM14_POINTER_TYPE), + BitField("access_level", 0, 2), + # Bytes 1-4: memory address (little-endian) + XLEIntField("address", 0), + # Byte 5: data length + ByteField("length", 0), + # Bytes 6-7: reserved + XShortField("reserved2", 0xFFFF), + ] + + def extract_padding(self, s): + # type: (bytes) -> Tuple[bytes, bytes] + return b"", s + + +# --------------------------------------------------------------------------- +# Socket utility functions +# --------------------------------------------------------------------------- + + +def sniff_dm1( + interface="can0", # type: str + timeout=10, # type: float +): + # type: (...) -> List[J1939_DM1] + """Sniff DM1 Active DTC messages from the J1939 bus. + + Opens a :class:`J1939Socket` filtered to PGN 0xFECA (65226) and sniffs + for ``timeout`` seconds. Each received payload is dissected into a + :class:`J1939_DM1` packet. + + :param interface: CAN interface name (e.g. ``"can0"``) + :param timeout: sniff duration in seconds + :returns: list of :class:`J1939_DM1` packets received + """ + from scapy.sendrecv import sniff + from scapy.contrib.automotive.j1939 import J1939Socket # type: ignore[attr-defined] + + with J1939Socket(interface, rx_pgn=PGN_DM1) as sock: + pkts = sniff(opened_socket=sock, timeout=timeout) + return [J1939_DM1(p.data) for p in pkts if hasattr(p, "data")] + + +def send_dm14_request( + interface, # type: str + dest_addr, # type: int + memory_address, # type: int + length=1, # type: int +): + # type: (...) -> None + """Send a DM14 Memory Access Request to a specific ECU. + + :param interface: CAN interface name (e.g. ``"can0"``) + :param dest_addr: destination ECU address (must not be + :data:`J1939_GLOBAL_ADDRESS`) + :param memory_address: 32-bit memory address to access + :param length: number of bytes to read + :raises Scapy_Exception: if *dest_addr* equals + :data:`J1939_GLOBAL_ADDRESS` + """ + if dest_addr == J1939_GLOBAL_ADDRESS: + raise Scapy_Exception( + "DM14 is a peer-to-peer message; " + "dst_addr must not be the broadcast address (0xFF)" + ) + from scapy.contrib.automotive.j1939 import J1939Socket # type: ignore[attr-defined] + + dm14 = J1939_DM14(address=memory_address, length=length) + pkt = J1939(data=bytes(dm14), pgn=PGN_DM14) + with J1939Socket( + interface, src_addr=0xFA, dst_addr=dest_addr, pgn=PGN_DM14 + ) as sock: + sock.send(pkt) + + +__all__ = [ + "J1939_DTC", + "J1939_DM1", + "J1939_DM13", + "J1939_DM14", + "PGN_DM1", + "PGN_DM13", + "PGN_DM14", + "sniff_dm1", + "send_dm14_request", +] diff --git a/scapy/contrib/automotive/j1939/j1939_dm_scanner.py b/scapy/contrib/automotive/j1939/j1939_dm_scanner.py new file mode 100644 index 00000000000..99aa573b088 --- /dev/null +++ b/scapy/contrib/automotive/j1939/j1939_dm_scanner.py @@ -0,0 +1,441 @@ +# SPDX-License-Identifier: GPL-2.0-only +# This file is part of Scapy +# See https://scapy.net/ for more information +# Copyright (C) National Motor Freight Traffic Association Inc. +# + +# scapy.contrib.description = SAE J1939 Diagnostic Message (DM) Scanner +# scapy.contrib.status = library + +""" +J1939 Diagnostic Message (DM) Scanner. + +Probes a single J1939 ECU (identified by its Destination Address) to discover +which SAE J1939-73 Diagnostic Messages it supports. For each PGN in +:data:`J1939_DM_PGNS` the scanner sends a unicast Request (PGN 59904) and +classifies the response: + +- **Positive response** — ECU replies with the requested PGN. +- **NACK** — ECU replies with an Acknowledgment (PGN 0xE800), control byte + 0x01 (Negative Acknowledgment). +- **Timeout** — ECU does not reply within *sniff_time* seconds. + +Usage:: + + >>> load_contrib('automotive.j1939') + >>> from scapy.contrib.cansocket import CANSocket + >>> from scapy.contrib.automotive.j1939.j1939_dm_scanner import ( + ... j1939_scan_dm, + ... ) + >>> sock = CANSocket("can0") + >>> results = j1939_scan_dm(sock, target_da=0x00) + >>> for name, res in sorted(results.items()): + ... print("{}: supported={} error={}".format( + ... name, res.supported, res.error)) +""" + +import struct +import time +from threading import Event # noqa: F401 + +# Typing imports +from typing import ( # noqa: F401 + Callable, + Dict, + List, + Optional, +) + +from scapy.layers.can import CAN +from scapy.supersocket import SuperSocket # noqa: F401 + +from scapy.contrib.j1939 import log_j1939 +from scapy.contrib.automotive.j1939.j1939_scanner import ( # noqa: F401 + _j1939_can_id, + _j1939_decode_can_id, + _J1939_DEFAULT_BITRATE, + _J1939_DEFAULT_BUSLOAD, + _inter_probe_delay, + _pre_probe_flush, + _resolve_probe_sock, + J1939_PF_REQUEST, + SockOrFactory, +) + +J1939_NULL_ADDRESS = 0xFE + +# --- DM scanner constants + +#: PDU Format byte for Acknowledgment messages (J1939-21 §5.4.4, PGN 0xE800) +J1939_PF_ACK = 0xE8 # 232 + +#: PGN for Acknowledgment / NACK messages (J1939-21 §5.4.4) +PGN_ACK = 0xE800 # 59392 + +#: NACK control byte in an Acknowledgment message data payload (byte 0) +_ACK_CTRL_NACK = 0x01 + +#: Bitmask for the CAN extended-frame flag (29-bit identifier) +_CAN_EXTENDED_FLAG = 0x4 + +#: Default priority for request frames sent by the DM scanner +_DM_SCAN_PRIORITY = 6 + +#: Ordered mapping from DM name (str) to PGN number (int). +#: Most entries are PDU2 (PF byte >= 0xF0) broadcast-capable messages; +#: some higher DMs use PDU1 (peer-to-peer) PGNs. +J1939_DM_PGNS = { + "DM1": 0xFECA, # Active Diagnostic Trouble Codes + "DM2": 0xFECB, # Previously Active Diagnostic Trouble Codes + "DM3": 0xFECC, # Diagnostic Data Clear/Reset for Previously Active DTCs + "DM4": 0xFECD, # Freeze Frame Parameters + "DM5": 0xFECE, # Diagnostic Readiness 1 + "DM6": 0xFECF, # Emission-Related Pending DTCs + "DM7": 0xE300, # Command Noncontinuously Monitored Test + "DM8": 0xFED0, # Test Results for Noncontinuously Monitored Systems + "DM9": 0xFED1, # Oxygen Sensor Test Results + "DM10": 0xFED2, # Non-continuously Monitored Systems Test Identifiers Support + "DM11": 0xFED3, # Diagnostic Data Clear/Reset for Active DTCs + "DM12": 0xFED4, # Emission-Related Active DTCs + "DM13": 0xDF00, # Stop Start Broadcast + "DM14": 0xD900, # Memory Access Request + "DM15": 0xD800, # Memory Access Response + "DM16": 0xD700, # Binary Data Transfer + "DM17": 0xD600, # Boot Load Data + "DM18": 0xD400, # Data Security + "DM19": 0xD300, # Calibration Information + "DM20": 0xC200, # Monitor Performance Ratio + "DM21": 0xC100, # Diagnostic Readiness 2 + "DM22": 0xC300, # Individual Clear/Reset of Active and Previously Active DTC + "DM23": 0xFDB5, # Emission-Related Previously Active DTCs + "DM24": 0xFDB6, # SPN Support + "DM25": 0xFDB7, # Expanded Freeze Frame + "DM26": 0xFDB8, # Diagnostic Readiness 3 + "DM27": 0xFD82, # All Pending DTCs + "DM28": 0xFD80, # Permanent DTCs + "DM29": 0x9E00, # Regulated DTC Counts (Pending, Permanent, MIL-On, PMIL-On) + "DM30": 0xA400, # Scaled Test Results + "DM31": 0xA300, # DTC to Lamp Association + "DM32": 0xA200, # Regulated Exhaust Emission Level Exceedance + "DM33": 0xA100, # Emission Increasing Auxiliary Emission Control Device Active Time + "DM34": 0xA000, # NTE Status + "DM35": 0x9F00, # Immediate Fault Status + "DM36": 0xFD64, # Harmonized Roadworthiness - Vehicle (HRWV) + "DM37": 0xFD63, # Harmonized Roadworthiness - System (HRWS) + "DM38": 0xFD62, # Harmonized Global Regulation Description (HGRD) + "DM39": 0xFD61, # Harmonized Cumulative Continuous Malfunction Indicator - System + "DM40": 0xFD60, # Harmonized B1 Failure Counts (HB1C) + "DM41": 0xFD5F, # DTCs - A, Pending + "DM42": 0xFD5E, # DTCs - A, Confirmed and Active + "DM43": 0xFD5D, # DTCs - A, Previously Active + "DM44": 0xFD5C, # DTCs - B1, Pending + "DM45": 0xFD5B, # DTCs - B1, Confirmed and Active + "DM46": 0xFD5A, # DTCs - B1, Previously Active + "DM47": 0xFD59, # DTCs - B2, Pending + "DM48": 0xFD58, # DTCs - B2, Confirmed and Active + "DM49": 0xFD57, # DTCs - B2, Previously Active + "DM50": 0xFD56, # DTCs - C, Pending + "DM51": 0xFD55, # DTCs - C, Confirmed and Active + "DM52": 0xFD54, # DTCs - C, Previously Active + "DM53": 0xFCD1, # Active Service Only DTCs + "DM54": 0xFCD2, # Previously Active Service Only DTCs + "DM55": 0xFCD3, # Diagnostic Data Clear/Reset for All Service Only DTCs + "DM56": 0xFCC7, # Engine Emissions Certification Information + "DM57": 0xFCC6, # OBD Information +} + + +# --- Result container + + +class DmScanResult(object): + """Result record for a single DM PGN probe sent by :func:`j1939_scan_dm_pgn`. + + :param dm_name: human-readable DM name (e.g. ``"DM1"``) + :param pgn: PGN number that was requested + :param supported: ``True`` if the ECU replied with the requested PGN + :param packet: the first CAN response received (``None`` on timeout) + :param error: ``None`` when supported; ``"NACK"`` for negative ack; + ``"Timeout"`` when no reply + """ + + __slots__ = ("dm_name", "pgn", "supported", "packet", "error") + + def __init__( + self, + dm_name, # type: str + pgn, # type: int + supported, # type: bool + packet=None, # type: Optional[CAN] + error=None, # type: Optional[str] + ): + # type: (...) -> None + self.dm_name = dm_name + self.pgn = pgn + self.supported = supported + self.packet = packet + self.error = error + + def __repr__(self): + # type: () -> str + return "".format( + self.dm_name, self.pgn, self.supported, self.error + ) + + +# --- Internal helpers + + +def _pgn_matches(pf, ps, pgn): + # type: (int, int, int) -> bool + """Return True if (*pf*, *ps*) decoded from a CAN-ID match *pgn*.""" + if pf >= 0xF0: + # PDU2: PS is the low byte of the PGN (group extension) + return pf * 256 + ps == pgn + # PDU1: PS is the DA; PGN family is pf * 256 (low byte of pgn must be 0) + return pf * 256 == (pgn & 0xFF00) + + +# --- Technique: unicast DM PGN probe + + +def j1939_scan_dm_pgn( + sock, # type: SockOrFactory + target_da, # type: int + pgn, # type: int + dm_name="Unknown", # type: str + src_addr=0xF9, # type: int + + sniff_time=1.0, # type: float + stop_event=None, # type: Optional[Event] + bitrate=_J1939_DEFAULT_BITRATE, # type: int + busload=_J1939_DEFAULT_BUSLOAD, # type: float +): + # type: (...) -> DmScanResult + """Probe *target_da* for support of a single Diagnostic Message PGN. + + Sends a unicast Request (PGN 59904) to *target_da* asking for *pgn* and + waits up to *sniff_time* seconds for a reply. The ECU is considered to + support the PGN if it replies with that PGN. A NACK (PGN 0xE800, control + byte 0x01) means the ECU does not support it. Silence is a Timeout. + + The inter-probe gap is automatically paced so that the scanner contributes + at most *busload* × *bitrate* bits per second to the bus, counting both + the outgoing probe frame (3-byte payload) and the expected response frame + (8-byte payload). + + :param sock: raw CAN socket **or** zero-argument callable returning one + :param target_da: destination address of the ECU to probe (0x00–0xFD) + :param pgn: the Diagnostic Message PGN to request + :param dm_name: human-readable DM name included in the returned result + :param src_addr: source address used in outgoing probes (default 0xF9) + :param sniff_time: seconds to wait for a response after sending the probe + :param stop_event: optional :class:`threading.Event` to abort early + :param bitrate: CAN bus bitrate in bit/s (default 250000 for J1939) + :param busload: maximum fraction of bus capacity the scanner may consume + (default 0.05 = 5 %) + :returns: :class:`DmScanResult` describing the outcome for this PGN + """ + if stop_event is not None and stop_event.is_set(): + return DmScanResult(dm_name, pgn, False, error="Aborted") + + can_id = _j1939_can_id(_DM_SCAN_PRIORITY, J1939_PF_REQUEST, target_da, src_addr) + payload = struct.pack(" None + if result: + return + if stop_event is not None and stop_event.is_set(): + return + if not (pkt.flags & _CAN_EXTENDED_FLAG): + return + _, pf, ps, sa = _j1939_decode_can_id(pkt.identifier) + if sa != target_da: + return + if _pgn_matches(pf, ps, pgn): + log_j1939.debug("dm_scan: positive response SA=0x%02X PGN=0x%04X", sa, pgn) + result.append(DmScanResult(dm_name, pgn, True, packet=pkt)) + return + if pf == J1939_PF_ACK: + data = bytes(pkt.data) + if data and data[0] == _ACK_CTRL_NACK: + log_j1939.debug("dm_scan: NACK from SA=0x%02X PGN=0x%04X", sa, pgn) + result.append( + DmScanResult(dm_name, pgn, False, packet=pkt, error="NACK") + ) + + def _send_probe(): + # type: () -> None + _pre_probe_flush(rx_sock) + send_sock.send(CAN(identifier=can_id, flags="extended", data=payload)) + log_j1939.debug( + "dm_scan: probing DA=0x%02X PGN=0x%04X (%s)", target_da, pgn, dm_name + ) + + try: + rx_sock.sniff(prn=_rx, timeout=sniff_time, store=False, + started_callback=_send_probe, + stop_filter=lambda _: bool(result)) + finally: + if close_rx: + rx_sock.close() + + # Pace the probe rate: request=3 bytes (DLC 3), response=8 bytes (DLC 8) + _extra = _inter_probe_delay(bitrate, busload, 3, 8, sniff_time) + if _extra > 0.0: + time.sleep(_extra) + + if result: + return result[0] + + log_j1939.debug("dm_scan: timeout waiting for DA=0x%02X PGN=0x%04X", target_da, pgn) + return DmScanResult(dm_name, pgn, False, error="Timeout") + + +# --- Top-level DM scanner + + +def j1939_scan_dm( + sock, # type: SockOrFactory + target_da, # type: int + dms=None, # type: Optional[List[str]] + src_addr=0xF9, # type: int + + sniff_time=1.0, # type: float + stop_event=None, # type: Optional[Event] + bitrate=_J1939_DEFAULT_BITRATE, # type: int + busload=_J1939_DEFAULT_BUSLOAD, # type: float + reset_handler=None, # type: Optional[Callable[[], None]] + reconnect_handler=None, # type: Optional[Callable[[], SuperSocket]] + reconnect_retries=5, # type: int +): + # type: (...) -> Dict[str, DmScanResult] + """Probe *target_da* for all (or a selected subset of) Diagnostic Message PGNs. + + Iterates over the DM names in *dms* (or all entries in + :data:`J1939_DM_PGNS` when *dms* is ``None``), calling + :func:`j1939_scan_dm_pgn` for each one and collecting the results. + + If *reset_handler* is provided it is called between each pair of DM PGN + probes to reset the target ECU to a known state. If *reconnect_handler* + is also provided it is called immediately after the reset to obtain a fresh + socket; subsequent probes will use the returned socket. This mirrors the + interface of :class:`~scapy.contrib.automotive.uds_scan.UDS_Scanner` where + ``reset_handler`` and ``reconnect_handler`` serve the same role. + + When *reconnect_handler* is provided the call is retried up to + *reconnect_retries* times (with a 1-second pause between attempts) if it + raises an exception. This mirrors the retry logic in + :class:`~scapy.contrib.automotive.scanner.executor.AutomotiveTestCaseExecutor`. + + :param sock: raw CAN socket **or** zero-argument callable returning one + :param target_da: destination address of the ECU to probe (0x00–0xFD) + :param dms: list of DM names to scan; must be keys of + :data:`J1939_DM_PGNS`. Default is all entries. + :param src_addr: source address used in outgoing probes (default 0xF9) + :param sniff_time: per-PGN listen time in seconds (default 1.0) + :param stop_event: optional :class:`threading.Event` to abort early + :param bitrate: CAN bus bitrate in bit/s (default 250000 for J1939) + :param busload: maximum fraction of bus capacity the scanner may consume + (default 0.05 = 5 %) + :param reset_handler: optional callable (no arguments, no return value) + to reset the target ECU between DM probes. Called + after each probe except the last. + :param reconnect_handler: optional callable (no arguments) that returns a + new :class:`~scapy.supersocket.SuperSocket`. + Called after *reset_handler* when provided; + the returned socket is used for all subsequent + probes. + :param reconnect_retries: maximum number of attempts when calling + *reconnect_handler* (default 5). A 1-second + pause is inserted between retries. + :returns: dict mapping each DM name (str) to its :class:`DmScanResult` + + Example:: + + >>> results = j1939_scan_dm(sock, target_da=0x00) + >>> for name, res in sorted(results.items()): + ... if res.supported: + ... print("[+] {} (PGN 0x{:04X})".format(name, res.pgn)) + + Example with reset and reconnect:: + + >>> def reset(): + ... pass # reset ECU via HW reset line or similar + >>> def reconnect(): + ... return CANSocket("can0") + >>> results = j1939_scan_dm( + ... reconnect(), target_da=0x00, + ... reset_handler=reset, + ... reconnect_handler=reconnect, + ... ) + """ + if dms is None: + dms = list(J1939_DM_PGNS.keys()) + + for name in dms: + if name not in J1939_DM_PGNS: + raise ValueError( + "Unknown DM name {!r}; valid names: {}".format( + name, list(J1939_DM_PGNS.keys()) + ) + ) + + results = {} # type: Dict[str, DmScanResult] + active_sock = sock # may be replaced if reconnect_handler is used + num_pgns = len(dms) + + for i, dm_name in enumerate(dms): + if stop_event is not None and stop_event.is_set(): + break + results[dm_name] = j1939_scan_dm_pgn( + active_sock, + target_da=target_da, + pgn=J1939_DM_PGNS[dm_name], + dm_name=dm_name, + src_addr=src_addr, + sniff_time=sniff_time, + stop_event=stop_event, + bitrate=bitrate, + busload=busload, + ) + # Between probes: reset target and/or reconnect if handlers provided + if i < num_pgns - 1: + if reset_handler is not None: + log_j1939.debug("dm_scan: calling reset_handler between probes") + reset_handler() + if reconnect_handler is not None: + log_j1939.debug("dm_scan: calling reconnect_handler") + for attempt in range(max(1, reconnect_retries)): + try: + active_sock = reconnect_handler() + break + except Exception: + if attempt == reconnect_retries - 1: + raise + log_j1939.debug( + "dm_scan: reconnect attempt %d/%d failed, " + "retrying in 1 s", + attempt + 1, + reconnect_retries, + ) + if stop_event is not None: + stop_event.wait(1) + else: + time.sleep(1) + + return results + + +__all__ = [ + "DmScanResult", + "J1939_DM_PGNS", + "J1939_PF_ACK", + "PGN_ACK", + "j1939_scan_dm", + "j1939_scan_dm_pgn", +] diff --git a/test/contrib/automotive/j1939_dm.uts b/test/contrib/automotive/j1939_dm.uts new file mode 100644 index 00000000000..7d80ee8a6f7 --- /dev/null +++ b/test/contrib/automotive/j1939_dm.uts @@ -0,0 +1,260 @@ +% Regression tests for J1939 Diagnostic Messages (DM1, DM13, DM14) +~ automotive_comm + ++ Configuration +~ conf + += Imports +import struct +from scapy.layers.can import CAN +from scapy.contrib.automotive.j1939 import ( + J1939, J1939SoftSocket, J1939_GLOBAL_ADDRESS, +) +from scapy.contrib.automotive.j1939.j1939_dm import ( + J1939_DTC, J1939_DM1, J1939_DM13, J1939_DM14, + PGN_DM1, PGN_DM13, PGN_DM14, sniff_dm1, send_dm14_request, +) +from scapy.error import Scapy_Exception +from test.testsocket import TestSocket, cleanup_testsockets + += Redirect logging +import logging +from scapy.error import log_runtime +from io import StringIO +log_stream = StringIO() +handler = logging.StreamHandler(log_stream) +log_runtime.addHandler(handler) + + ++ J1939_DTC — Bit-boundary and packing tests + += DTC size is exactly 4 bytes (bit-boundary checkpoint) +assert len(J1939_DTC()) == 4, \ + "J1939_DTC must be exactly 4 bytes (32 bits); got {}".format(len(J1939_DTC())) + += DTC packing: known raw bytes produce correct field values +# SPN=100 (0x64), FMI=2, CM=0, OC=5 +# LE bytes: [SPN[7:0], SPN[15:8], FMI|SPN[18:16], OC|CM] +# = [0x64, 0x00, 0x10, 0x0A] +raw = b'\x64\x00\x10\x0a' +dtc = J1939_DTC(raw) +assert dtc.SPN == 100, "SPN: expected 100, got {}".format(dtc.SPN) +assert dtc.FMI == 2, "FMI: expected 2, got {}".format(dtc.FMI) +assert dtc.CM == 0, "CM: expected 0, got {}".format(dtc.CM) +assert dtc.OC == 5, "OC: expected 5, got {}".format(dtc.OC) + += DTC unpacking: field values produce correct raw bytes +dtc = J1939_DTC(SPN=100, FMI=2, CM=0, OC=5) +assert bytes(dtc) == b'\x64\x00\x10\x0a', \ + "Expected 64 00 10 0a, got {}".format(bytes(dtc).hex()) + += DTC round-trip: build then parse recovers all fields +for spn, fmi, cm, oc in [ + (100, 2, 0, 5), + (0x7FFFF, 0x1F, 1, 0x7F), # all-ones (max values) + (0, 0, 0, 0), # all-zeros (min values) + (512, 7, 0, 10), +]: + built = bytes(J1939_DTC(SPN=spn, FMI=fmi, CM=cm, OC=oc)) + parsed = J1939_DTC(built) + assert parsed.SPN == spn, "SPN round-trip failed: {} != {}".format(parsed.SPN, spn) + assert parsed.FMI == fmi, "FMI round-trip failed: {} != {}".format(parsed.FMI, fmi) + assert parsed.CM == cm, "CM round-trip failed: {} != {}".format(parsed.CM, cm) + assert parsed.OC == oc, "OC round-trip failed: {} != {}".format(parsed.OC, oc) + += DTC: little-endian byte order is correct (SPN LSB in byte 0) +dtc = J1939_DTC(SPN=0x100, FMI=0, CM=0, OC=0) +raw = bytes(dtc) +# SPN=0x100=256: byte0=0x00 (SPN[7:0]), byte1=0x01 (SPN[15:8]), rest=0x00 +assert raw[0] == 0x00, "byte0 should be SPN[7:0]=0x00, got 0x{:02X}".format(raw[0]) +assert raw[1] == 0x01, "byte1 should be SPN[15:8]=0x01, got 0x{:02X}".format(raw[1]) + + ++ J1939_DM1 — Single-frame tests + += DM1 PGN is 65226 (0xFECA) +assert J1939_DM1.PGN == 65226, "PGN: {}".format(J1939_DM1.PGN) +assert PGN_DM1 == 65226 + += DM1 single-frame: 1 DTC is padded to exactly 8 bytes +dtc = J1939_DTC(SPN=100, FMI=2, CM=0, OC=5) +dm1 = J1939_DM1(dtcs=[dtc]) +raw = bytes(dm1) +assert len(raw) == 8, \ + "DM1 with 1 DTC must be 8 bytes; got {}".format(len(raw)) + += DM1 single-frame: padding bytes are 0xFF +dtc = J1939_DTC(SPN=100, FMI=2, CM=0, OC=5) +dm1 = J1939_DM1(dtcs=[dtc]) +raw = bytes(dm1) +assert raw[-2:] == b'\xff\xff', \ + "Trailing padding must be 0xFF 0xFF, got {}".format(raw[-2:].hex()) + += DM1 single-frame: lamp status fields are parsed correctly +dm1 = J1939_DM1( + mil_status=1, rsl_status=0, awl_status=0, pl_status=0, + dtcs=[J1939_DTC(SPN=100, FMI=2, CM=0, OC=5)], +) +assert dm1.mil_status == 1, "MIL: expected 1, got {}".format(dm1.mil_status) +assert dm1.rsl_status == 0, "RSL: expected 0, got {}".format(dm1.rsl_status) +assert dm1.awl_status == 0, "AWL: expected 0, got {}".format(dm1.awl_status) +assert dm1.pl_status == 0, "PL: expected 0, got {}".format(dm1.pl_status) + + ++ J1939_DM1 — Multi-frame tests + += DM1 multi-frame: 5 DTCs produce 22 bytes (no padding) +dtcs = [J1939_DTC(SPN=i * 100, FMI=2, CM=0, OC=1) for i in range(5)] +dm1 = J1939_DM1(dtcs=dtcs) +raw = bytes(dm1) +assert len(raw) == 22, \ + "DM1 with 5 DTCs must be 22 bytes; got {}".format(len(raw)) + += DM1 multi-frame: payload is not truncated +dtcs = [J1939_DTC(SPN=i * 100, FMI=2, CM=0, OC=1) for i in range(5)] +dm1 = J1939_DM1(dtcs=dtcs) +raw = bytes(dm1) +# Verify each DTC survives the round-trip through the raw byte string +parsed = J1939_DM1(raw) +assert len(parsed.dtcs) == 5, \ + "Expected 5 DTCs after dissection, got {}".format(len(parsed.dtcs)) +for i, dtc in enumerate(parsed.dtcs): + assert dtc.SPN == i * 100, \ + "DTC[{}] SPN: expected {}, got {}".format(i, i * 100, dtc.SPN) + + ++ J1939_DM1 — Dissection round-trip tests + += DM1 dissection round-trip with 2 DTCs preserves lamp status and DTC values +dtcs_in = [ + J1939_DTC(SPN=100, FMI=2, CM=0, OC=5), + J1939_DTC(SPN=200, FMI=3, CM=1, OC=10), +] +dm1_orig = J1939_DM1(mil_status=1, awl_status=1, dtcs=dtcs_in) +raw = bytes(dm1_orig) +dm1_p = J1939_DM1(raw) +assert dm1_p.mil_status == 1, "mil_status: {}".format(dm1_p.mil_status) +assert dm1_p.awl_status == 1, "awl_status: {}".format(dm1_p.awl_status) +assert len(dm1_p.dtcs) == 2 +assert dm1_p.dtcs[0].SPN == 100 +assert dm1_p.dtcs[1].SPN == 200 +assert dm1_p.dtcs[1].CM == 1 + += DM1 dissection: trailing 0xFF padding bytes are not parsed as DTCs +dtc = J1939_DTC(SPN=100, FMI=2, CM=0, OC=5) +dm1 = J1939_DM1(dtcs=[dtc]) +raw = bytes(dm1) # 8 bytes: 2 lamp + 4 DTC + 2 padding +dm1_p = J1939_DM1(raw) +assert len(dm1_p.dtcs) == 1, \ + "Expected 1 DTC (not 2), got {}".format(len(dm1_p.dtcs)) + + ++ J1939_DM13 — Creation and PGN tests + += DM13 PGN is 57344 (0xE000) +assert J1939_DM13.PGN == 57344, "PGN: {}".format(J1939_DM13.PGN) +assert PGN_DM13 == 57344 + += DM13 instantiation with default values +dm13 = J1939_DM13() +assert dm13.PGN == 57344 +assert dm13.hold_signal == 0xFF + += DM13 instantiation with custom hold_signal +dm13 = J1939_DM13(hold_signal=0xFE) +assert dm13.hold_signal == 0xFE + += DM13 with dummy payload data (8 bytes total) +dm13 = J1939_DM13(hold_signal=0xFF, data=b'\xfe\xff\xff\xff\xff\xff\xff') +assert len(bytes(dm13)) == 8 + + ++ J1939_DM14 — Creation and PGN tests + += DM14 PGN is 55552 (0xD900) +assert J1939_DM14.PGN == 55552, "PGN: {}".format(J1939_DM14.PGN) +assert PGN_DM14 == 55552 + += DM14 size is exactly 8 bytes +assert len(J1939_DM14()) == 8, \ + "J1939_DM14 must be 8 bytes; got {}".format(len(J1939_DM14())) + += DM14 instantiation with dummy data verifies PGN default +dm14 = J1939_DM14(address=0x1000, length=4, command_type=1) +assert dm14.PGN == 55552 +assert dm14.address == 0x1000 +assert dm14.length == 4 +assert dm14.command_type == 1 + += DM14 command_type field values +dm14_read = J1939_DM14(command_type=1) +dm14_write = J1939_DM14(command_type=2) +dm14_erase = J1939_DM14(command_type=0) +assert dm14_read.command_type == 1 +assert dm14_write.command_type == 2 +assert dm14_erase.command_type == 0 + += DM14 address is stored in little-endian byte order +dm14 = J1939_DM14(address=0x00001234) +raw = bytes(dm14) +# XLEIntField: byte1=0x34, byte2=0x12, byte3=0x00, byte4=0x00 +assert raw[1] == 0x34, "LE address byte1=0x{:02X}".format(raw[1]) +assert raw[2] == 0x12, "LE address byte2=0x{:02X}".format(raw[2]) + + ++ Socket integration — DM routing via J1939 base class + += Socket integration: wrap DM1 in J1939 frame then re-dissect +# Build a DM1 payload, wrap in a J1939 frame, and verify the +# DM1 Scapy class correctly dissects the payload. +from scapy.contrib.automotive.j1939.j1939_soft_socket import _j1939_can_id +dtc = J1939_DTC(SPN=100, FMI=2, CM=0, OC=5) +dm1_orig = J1939_DM1(mil_status=1, dtcs=[dtc]) +j1939_frame = J1939(pgn=PGN_DM1, data=bytes(dm1_orig)) +# Dissect payload into DM1 class +dm1_parsed = J1939_DM1(j1939_frame.data) +assert dm1_parsed.mil_status == 1 +assert len(dm1_parsed.dtcs) == 1 +assert dm1_parsed.dtcs[0].SPN == 100 + += Socket integration: receive DM1 via J1939SoftSocket +from scapy.contrib.automotive.j1939.j1939_soft_socket import _j1939_can_id + +# Build DM1 payload +dtc = J1939_DTC(SPN=100, FMI=2, CM=0, OC=5) +dm1_data = bytes(J1939_DM1(mil_status=1, dtcs=[dtc])) + +# PGN 0xFECA: PF=0xFE >= 0xF0 (PDU2), PS=0xCA, SA=0x80 +can_id = _j1939_can_id(6, 0xFE, 0xCA, 0x80) + +with J1939SoftSocket(TestSocket(CAN), pgn=0xFECA, src_addr=0x11) as s: + # Inject CAN frame directly (bypasses background polling thread) + s.impl.on_can_recv( + CAN(identifier=can_id, flags="extended", data=dm1_data) + ) + result = s.impl.rx_queue.recv() + +assert result is not None, "rx_queue.recv() returned None" +raw_data, _ts, _pgn, _sa, _da = result +dm1_rx = J1939_DM1(raw_data) +assert dm1_rx.mil_status == 1, "mil_status: {}".format(dm1_rx.mil_status) +assert len(dm1_rx.dtcs) == 1 +assert dm1_rx.dtcs[0].SPN == 100 +cleanup_testsockets() + + ++ DM14 destination address validation + += send_dm14_request raises Scapy_Exception for broadcast destination +try: + send_dm14_request("can0", J1939_GLOBAL_ADDRESS, 0x1000) + assert False, "Expected Scapy_Exception for broadcast dst_addr" +except Scapy_Exception as e: + assert "broadcast" in str(e).lower() or "peer" in str(e).lower(), str(e) + += send_dm14_request raises Scapy_Exception for dst_addr 0xFF +try: + send_dm14_request("can0", 0xFF, 0x2000) + assert False, "Expected Scapy_Exception for dst_addr=0xFF" +except Scapy_Exception: + pass # expected diff --git a/test/contrib/automotive/j1939_dm_scanner.uts b/test/contrib/automotive/j1939_dm_scanner.uts new file mode 100644 index 00000000000..4107c39c4bb --- /dev/null +++ b/test/contrib/automotive/j1939_dm_scanner.uts @@ -0,0 +1,649 @@ +% Regression tests for J1939 Diagnostic Message (DM) Scanner +~ automotive_comm + ++ Configuration +~ conf + += Imports +import struct +from threading import Event +from scapy.layers.can import CAN +from scapy.contrib.automotive.j1939 import ( + J1939_GLOBAL_ADDRESS, J1939_NULL_ADDRESS, +) +from scapy.contrib.automotive.j1939 import ( + _j1939_can_id, _j1939_decode_can_id, + J1939_PF_REQUEST, +) +from scapy.contrib.automotive.j1939.j1939_dm_scanner import ( + DmScanResult, + J1939_DM_PGNS, + J1939_PF_ACK, + PGN_ACK, + j1939_scan_dm, + j1939_scan_dm_pgn, + _pgn_matches, +) +from scapy.contrib.automotive.j1939.j1939_scanner import ( + _J1939_DEFAULT_BITRATE, + _J1939_DEFAULT_BUSLOAD, + _inter_probe_delay, +) +from test.testsocket import TestSocket, SlowTestSocket, cleanup_testsockets + += Redirect logging +import logging +from scapy.error import log_runtime +from io import StringIO +log_stream = StringIO() +handler = logging.StreamHandler(log_stream) +log_runtime.addHandler(handler) +log_j1939_logger = logging.getLogger("scapy.contrib.automotive.j1939") +log_j1939_logger.addHandler(handler) + + ++ DM PGN constants + += J1939_DM_PGNS contains all standard DM entries +assert len(J1939_DM_PGNS) == 57 +for name in ("DM1", "DM2", "DM3", "DM4", "DM5", "DM6", "DM11", "DM12"): + assert name in J1939_DM_PGNS, "Missing {}".format(name) + += DM PGN values are correct (spot-check DM1 and DM6) +assert J1939_DM_PGNS["DM1"] == 0xFECA, "DM1 PGN: 0x{:04X}".format(J1939_DM_PGNS["DM1"]) +assert J1939_DM_PGNS["DM6"] == 0xFECF, "DM6 PGN: 0x{:04X}".format(J1939_DM_PGNS["DM6"]) + += PDU2 DM PGNs have PF byte >= 0xF0 +for name, pgn in J1939_DM_PGNS.items(): + pf = (pgn >> 8) & 0xFF + if pf >= 0xF0: + assert pf >= 0xF0, "{} PF=0x{:02X} should be PDU2".format(name, pf) + += J1939_PF_ACK and PGN_ACK have correct values +assert J1939_PF_ACK == 0xE8, "J1939_PF_ACK: 0x{:02X}".format(J1939_PF_ACK) +assert PGN_ACK == 0xE800, "PGN_ACK: 0x{:04X}".format(PGN_ACK) + += _pgn_matches: PDU2 positive match +# DM1: PF=0xFE, PS=0xCA -> PGN = 0xFECA +assert _pgn_matches(0xFE, 0xCA, 0xFECA), "PDU2 match should succeed" + += _pgn_matches: PDU2 mismatch (wrong PS) +assert not _pgn_matches(0xFE, 0xCB, 0xFECA), "Different PS should not match" + += _pgn_matches: PDU1 positive match (PF < 0xF0, low byte of PGN is 0x00) +# ACK: PF=0xE8, PS=DA (not part of PGN), PGN=0xE800 +assert _pgn_matches(0xE8, 0x00, 0xE800), "PDU1 match should succeed" + += _pgn_matches: PDU1 mismatch (different PF) +assert not _pgn_matches(0xE9, 0x00, 0xE800), "Different PDU1 PF should not match" + + ++ DmScanResult class + += DmScanResult: supported result sets correct attributes +res = DmScanResult("DM1", 0xFECA, True) +assert res.dm_name == "DM1" +assert res.pgn == 0xFECA +assert res.supported is True +assert res.packet is None +assert res.error is None + += DmScanResult: NACK result sets correct attributes +res = DmScanResult("DM2", 0xFECB, False, error="NACK") +assert res.supported is False +assert res.error == "NACK" + += DmScanResult: Timeout result +res = DmScanResult("DM5", 0xFECE, False, error="Timeout") +assert res.supported is False +assert res.error == "Timeout" +assert res.packet is None + += DmScanResult: __repr__ contains dm_name and pgn +res = DmScanResult("DM1", 0xFECA, True) +r = repr(res) +assert "DM1" in r, "repr should contain dm_name" +assert "FECA" in r.upper(), "repr should contain pgn" + + ++ j1939_scan_dm_pgn - probe frame format + += dm_pgn_probe: sends a unicast Request frame to target_da +def test_dm_pgn_probe_frame(): + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + j1939_scan_dm_pgn(tx_sock, target_da=0x00, pgn=J1939_DM_PGNS["DM1"], + dm_name="DM1", sniff_time=0.0) + pkts = monitor.sniff(count=1, timeout=0.2) + assert len(pkts) == 1, "Expected 1 probe frame, got {}".format(len(pkts)) + probe = pkts[0] + _, pf, ps, sa = _j1939_decode_can_id(probe.identifier) + assert probe.flags & 0x4, "Expected extended CAN frame" + assert pf == J1939_PF_REQUEST, "PF should be 0xEA (Request)" + assert ps == 0x00, "DA should be target_da=0x00" + assert sa == 0xF9, "SA should be 0xF9 (diag adapter)" + expected_payload = struct.pack(" one reset call between them +assert call_log == ["reset"], "Expected 1 reset call, got: {}".format(call_log) +cleanup_testsockets() + += scan_dm: reset_handler called N-1 times for N pgns +call_log2 = [] + +def my_reset2(): + call_log2.append("reset") + +sock = TestSocket(CAN) +results = j1939_scan_dm(sock, target_da=0x00, dms=["DM1", "DM2", "DM3"], + reset_handler=my_reset2, sniff_time=0.02) +# 3 PGNs -> 2 reset calls +assert len(call_log2) == 2, "Expected 2 reset calls, got: {}".format(call_log2) +cleanup_testsockets() + += scan_dm: reset_handler not called for single-pgn scan +call_log3 = [] + +def my_reset3(): + call_log3.append("reset") + +sock = TestSocket(CAN) +results = j1939_scan_dm(sock, target_da=0x00, dms=["DM1"], + reset_handler=my_reset3, sniff_time=0.02) +assert call_log3 == [], "Expected no reset calls for single pgn" +cleanup_testsockets() + += scan_dm: reconnect_handler is called after reset_handler and returns new socket +call_log4 = [] + +def my_reset4(): + call_log4.append("reset") + +new_sock_holder = [] + +def my_reconnect(): + call_log4.append("reconnect") + s = TestSocket(CAN) + new_sock_holder.append(s) + return s + +sock = TestSocket(CAN) +results = j1939_scan_dm(sock, target_da=0x00, dms=["DM1", "DM2"], + reset_handler=my_reset4, reconnect_handler=my_reconnect, + sniff_time=0.02) +assert call_log4 == ["reset", "reconnect"], \ + "Expected reset+reconnect, got: {}".format(call_log4) +assert len(new_sock_holder) == 1 +cleanup_testsockets() + += scan_dm: reconnect_handler alone (no reset_handler) is accepted +reconnect_log = [] + +def my_reconnect_only(): + reconnect_log.append("reconnect") + return TestSocket(CAN) + +sock = TestSocket(CAN) +results = j1939_scan_dm(sock, target_da=0x00, dms=["DM1", "DM2"], + reconnect_handler=my_reconnect_only, sniff_time=0.02) +assert reconnect_log == ["reconnect"], \ + "Expected 1 reconnect call, got: {}".format(reconnect_log) +cleanup_testsockets() + += scan_dm: no reset/reconnect handlers -> same behaviour as before +sock = TestSocket(CAN) +results = j1939_scan_dm(sock, target_da=0x00, dms=["DM1"], + sniff_time=0.02) +assert "DM1" in results +cleanup_testsockets() + + ++ Send-then-sniff race condition regression tests +~ conf + += scan_dm_pgn: immediate ECU reply is captured (sniff-before-send regression) +def test_dm_pgn_immediate_reply(): + import threading + with TestSocket(CAN) as sock, TestSocket(CAN) as monitor: + sock.pair(monitor) + dm1_pgn = J1939_DM_PGNS["DM1"] + dm1_pf = (dm1_pgn >> 8) & 0xFF + dm1_ps = dm1_pgn & 0xFF + def simulate_ecu(): + while True: + pkts = monitor.sniff(count=1, timeout=1.0) + if not pkts: + break + p = pkts[0] + _, pf, ps, sa = _j1939_decode_can_id(p.identifier) + if pf == J1939_PF_REQUEST: + resp_id = _j1939_can_id(6, dm1_pf, dm1_ps, ps) + sock.ins.send(bytes(CAN(identifier=resp_id, flags="extended", data=b'\x00' * 8))) + t = threading.Thread(target=simulate_ecu) + t.start() + result = j1939_scan_dm_pgn(sock, target_da=0x42, pgn=dm1_pgn, + dm_name="DM1", sniff_time=0.3) + t.join(timeout=2.0) + assert result.supported, "Immediate DM1 reply must be captured, got: {}".format(result) + cleanup_testsockets() + +test_dm_pgn_immediate_reply() + += scan_dm_pgn: sniff exits early when response found (stop_filter) +def test_dm_pgn_early_exit(): + import time + sock = TestSocket(CAN) + dm1_pgn = J1939_DM_PGNS["DM1"] + dm1_pf = (dm1_pgn >> 8) & 0xFF + dm1_ps = dm1_pgn & 0xFF + resp_id = _j1939_can_id(6, dm1_pf, dm1_ps, 0x42) + sock.ins.send(bytes(CAN(identifier=resp_id, flags="extended", data=b'\x00' * 8))) + t0 = time.monotonic() + result = j1939_scan_dm_pgn(sock, target_da=0x42, pgn=dm1_pgn, + dm_name="DM1", sniff_time=5.0) + elapsed = time.monotonic() - t0 + assert result.supported, "Expected DM1 supported, got: {}".format(result) + assert elapsed < 2.0, "Sniff should exit early, took {:.1f}s (max 2.0s)".format(elapsed) + cleanup_testsockets() + +test_dm_pgn_early_exit() + += scan_dm_pgn: response found despite stale frames (kernel buffer flush) +~ slow_test +def test_dm_pgn_stale_frames(): + import time + sock = SlowTestSocket(CAN, frame_delay=0.0002, mux_throttle=0.001) + dm1_pgn = J1939_DM_PGNS["DM1"] + dm1_pf = (dm1_pgn >> 8) & 0xFF + dm1_ps = dm1_pgn & 0xFF + stale_id = _j1939_can_id(6, dm1_pf, dm1_ps, 0x99) + for _ in range(50): + with sock._serial_lock: + sock._serial_buffer.append( + bytes(CAN(identifier=stale_id, flags="extended", data=b'\xAA' * 8)) + ) + resp_id = _j1939_can_id(6, dm1_pf, dm1_ps, 0x42) + with sock._serial_lock: + sock._serial_buffer.append( + bytes(CAN(identifier=resp_id, flags="extended", data=b'\x00' * 8)) + ) + result = j1939_scan_dm_pgn(sock, target_da=0x42, pgn=dm1_pgn, + dm_name="DM1", sniff_time=2.0) + assert result.supported, "Expected DM1 supported despite stale frames, got: {}".format(result) + cleanup_testsockets() + +test_dm_pgn_stale_frames() + += scan_dm: multiple DAs with stale traffic (simulates slow embedded system) +~ slow_test +def test_dm_multi_da_stale(): + import time + sock = SlowTestSocket(CAN, frame_delay=0.0002, mux_throttle=0.001) + dm1_pgn = J1939_DM_PGNS["DM1"] + dm1_pf = (dm1_pgn >> 8) & 0xFF + dm1_ps = dm1_pgn & 0xFF + target_das = [0x10, 0x20, 0x30] + for da in target_das: + stale_id = _j1939_can_id(6, 0xFE, 0x00, 0xEE) + for _ in range(20): + with sock._serial_lock: + sock._serial_buffer.append( + bytes(CAN(identifier=stale_id, flags="extended", data=b'\xBB' * 8)) + ) + resp_id = _j1939_can_id(6, dm1_pf, dm1_ps, da) + with sock._serial_lock: + sock._serial_buffer.append( + bytes(CAN(identifier=resp_id, flags="extended", data=b'\x00' * 8)) + ) + results = {} + for da in target_das: + results[da] = j1939_scan_dm(sock, target_da=da, dms=["DM1"], + sniff_time=2.0) + found_das = [da for da in target_das if results[da]["DM1"].supported] + assert len(found_das) == len(target_das), \ + "Expected all DAs found, got: {}".format([hex(d) for d in found_das]) + cleanup_testsockets() + +test_dm_multi_da_stale() + + ++ Factory (reconnect) API + += j1939_scan_dm: callable factory works for DM scanner +def test_dm_factory(): + pgn = J1939_DM_PGNS["DM1"] + dm1_pf = (pgn >> 8) & 0xFF + dm1_ps = pgn & 0xFF + sock = TestSocket(CAN) + resp_id = _j1939_can_id(6, dm1_pf, dm1_ps, 0x42) + sock.ins.send(bytes(CAN(identifier=resp_id, flags="extended", data=b'\x00' * 8))) + def _factory(): + return sock + results = j1939_scan_dm(_factory, target_da=0x42, dms=["DM1"], sniff_time=0.5) + assert "DM1" in results + assert results["DM1"].supported, "Factory DM scan should find DM1 supported" + cleanup_testsockets() + +test_dm_factory() + += j1939_scan_dm_pgn: callable factory works for individual DM PGN probe +def test_dm_pgn_factory(): + pgn = J1939_DM_PGNS["DM5"] + dm5_pf = (pgn >> 8) & 0xFF + dm5_ps = pgn & 0xFF + sock = TestSocket(CAN) + resp_id = _j1939_can_id(6, dm5_pf, dm5_ps, 0x10) + sock.ins.send(bytes(CAN(identifier=resp_id, flags="extended", data=b'\x00' * 8))) + def _factory(): + return sock + result = j1939_scan_dm_pgn(_factory, target_da=0x10, pgn=pgn, + dm_name="DM5", sniff_time=0.5) + assert result.supported, "Factory DM PGN probe should find DM5" + assert result.dm_name == "DM5" + cleanup_testsockets() + +test_dm_pgn_factory() + + ++ reconnect_handler retry logic + += scan_dm: reconnect_handler retries on failure and succeeds +def test_reconnect_retry_success(): + fail_count = [0] + def failing_reconnect(): + fail_count[0] += 1 + if fail_count[0] < 3: + raise OSError("simulated reconnect failure") + return TestSocket(CAN) + sock = TestSocket(CAN) + results = j1939_scan_dm(sock, target_da=0x00, dms=["DM1", "DM2"], + reconnect_handler=failing_reconnect, + reconnect_retries=5, sniff_time=0.02) + assert "DM1" in results and "DM2" in results + assert fail_count[0] == 3, "Expected 3 attempts (2 fail + 1 success), got {}".format(fail_count[0]) + cleanup_testsockets() + +test_reconnect_retry_success() + += scan_dm: reconnect_handler raises after exhausting retries +def test_reconnect_retry_exhausted(): + def always_fail(): + raise OSError("always fails") + sock = TestSocket(CAN) + raised = False + try: + j1939_scan_dm(sock, target_da=0x00, dms=["DM1", "DM2"], + reconnect_handler=always_fail, + reconnect_retries=2, sniff_time=0.02) + except OSError: + raised = True + assert raised, "Should raise after exhausting retries" + cleanup_testsockets() + +test_reconnect_retry_exhausted() + += scan_dm: reconnect_retries=1 means single attempt (no retry) +def test_reconnect_retries_one(): + call_count = [0] + def counting_reconnect(): + call_count[0] += 1 + if call_count[0] == 1: + raise OSError("fail") + return TestSocket(CAN) + sock = TestSocket(CAN) + raised = False + try: + j1939_scan_dm(sock, target_da=0x00, dms=["DM1", "DM2"], + reconnect_handler=counting_reconnect, + reconnect_retries=1, sniff_time=0.02) + except OSError: + raised = True + assert raised, "reconnect_retries=1 means single attempt, should raise" + assert call_count[0] == 1 + cleanup_testsockets() + +test_reconnect_retries_one() + += scan_dm: reconnect retry uses stop_event.wait for sleep +def test_reconnect_retry_uses_stop_event(): + from threading import Event + evt = Event() + fail_count = [0] + def failing_reconnect(): + fail_count[0] += 1 + if fail_count[0] < 2: + raise OSError("simulated failure") + return TestSocket(CAN) + sock = TestSocket(CAN) + results = j1939_scan_dm(sock, target_da=0x00, dms=["DM1", "DM2"], + reconnect_handler=failing_reconnect, + reconnect_retries=5, sniff_time=0.02, + stop_event=evt) + assert fail_count[0] == 2 + cleanup_testsockets() + +test_reconnect_retry_uses_stop_event() From c2063fadac3cedd5a260d50359c4e405496a2240 Mon Sep 17 00:00:00 2001 From: Ben Gardiner Date: Wed, 2 Sep 2026 15:02:37 -0400 Subject: [PATCH 16/18] automotive: J1939 Controller Application (CA) enumeration scanner --- .../contrib/automotive/j1939/j1939_scanner.py | 1609 ++++++++++++ test/contrib/automotive/j1939_scanner.uts | 2252 +++++++++++++++++ tox.ini | 1 + 3 files changed, 3862 insertions(+) create mode 100644 scapy/contrib/automotive/j1939/j1939_scanner.py create mode 100644 test/contrib/automotive/j1939_scanner.uts diff --git a/scapy/contrib/automotive/j1939/j1939_scanner.py b/scapy/contrib/automotive/j1939/j1939_scanner.py new file mode 100644 index 00000000000..94f3626a237 --- /dev/null +++ b/scapy/contrib/automotive/j1939/j1939_scanner.py @@ -0,0 +1,1609 @@ +# SPDX-License-Identifier: GPL-2.0-only +# This file is part of Scapy +# See https://scapy.net/ for more information +# Copyright (C) National Motor Freight Traffic Association Inc. +# + +# scapy.contrib.description = SAE J1939 Controller Application (CA) Scanner +# scapy.contrib.status = library + +""" +J1939 Controller Application (CA) Scanner. + +Implements five complementary techniques for enumerating active J1939 +Controller Applications (CAs / ECUs) on a CAN bus, modelled after the +Scapy ``isotp_scan`` API. + +Technique 1 — Global Address Claim Request + Broadcasts a single Request (PGN 59904) for the Address Claimed PGN + (60928). Every active CA that implements J1939-81 address claiming must + respond. Best for networks where all nodes are J1939-81 compliant. + +Technique 2 — Global ECU Identification Request + Broadcasts a single Request (PGN 59904) for the ECU Identification Info + PGN (64965). Responding nodes announce their ECU ID via a BAM transfer. + Identifies nodes that publish an ECU Identification string. + +Technique 3 — Unicast Ping Sweep + Iterates through destination addresses 0x00–0xFD, sending a Request for + Address Claimed to each. Nodes that are active reply. Detects nodes + even if they do not respond to the broadcast in Technique 1. + +Technique 4 — TP.CM RTS Probing + Iterates through destination addresses 0x00–0xFD, sending a minimal + TP.CM_RTS frame to each. Active nodes reply with CTS, Conn_Abort, + or a NACK on the Acknowledgment PGN (0xE800), all of which confirm + the node is present. + +Technique 5 — UDS TesterPresent Probe + Iterates through destination addresses 0x00–0xFD, sending padded UDS + TesterPresent requests (SID 0x3E, sub-functions 0x00 and 0x01, + 5 x 0xFF padding) over both J1939 Diagnostic Message A (Physical) and + Diagnostic Message B (Functional), once for every source + address in *src_addrs*. Nodes that implement UDS reply with a positive + response (SID 0x7E) or a negative response (SID 0x7F). + +Technique 6 — XCP Connect Probe + Iterates through destination addresses 0x00–0xFD, sending an XCP CONNECT + command (command code 0xFF, mode 0x00, 6 x 0xFF padding) over J1939 + Diagnostic Message A (Physical), once for every source address in + *src_addrs*. Nodes that implement XCP reply with a positive response + (status byte 0xFF). + +Detection Matrix +---------------- + +The following table shows the probe each technique sends and the CAN +response it expects from an active CA in order to detect it. + ++------------+-----------------------------------------+------------------------------------------+ +| Technique | Probe (sent by scanner) | Expected response (from ECU) | ++============+=========================================+==========================================+ +| addr_claim | Broadcast Request (PF=0xEA, DA=0xFF) | Address Claimed (PF=0xEE, DA=0xFF) | +| | for PGN 60928 (0xEE00) | SA=ECU-SA, 8-byte J1939 NAME payload | ++------------+-----------------------------------------+------------------------------------------+ +| ecu_id | Broadcast Request (PF=0xEA, DA=0xFF) | TP.CM BAM (PF=0xEC, DA=0xFF, | +| | for PGN 64965 (0xFDC5) | ctrl=0x20) announcing PGN 64965 | ++------------+-----------------------------------------+------------------------------------------+ +| unicast | Unicast Request (PF=0xEA, DA=ECU-SA) | Any CAN frame (extended) whose | +| | for PGN 60928, addressed to each DA | SA equals the probed DA | ++------------+-----------------------------------------+------------------------------------------+ +| rts_probe | TP.CM_RTS (PF=0xEC, DA=ECU-SA) | TP.CM_CTS (ctrl=0x11) **or** | +| | sent to each DA | TP_Conn_Abort (ctrl=0xFF) **or** | +| | | NACK on ACK PGN (PF=0xE8) from probed DA| ++------------+-----------------------------------------+------------------------------------------+ +| uds | Physical (PF=diag_pgn, DA=ECU-SA) AND | UDS response (positive 02 7E xx | +| | Functional (PF=diag_pgn+1, DA=0xFF) | or negative 03 7F 3E xx) | +| | payload 02 3E {00,01} padded | from responding DA | +| | once per SA in src_addrs | | ++------------+-----------------------------------------+------------------------------------------+ +| xcp | Physical (PF=diag_pgn, DA=ECU-SA) | XCP positive response (byte 0 == 0xFF) | +| | payload FF 00 FF FF FF FF FF FF | from responding DA | +| | once per SA in src_addrs | | ++------------+-----------------------------------------+------------------------------------------+ + +Usage:: + + >>> load_contrib('automotive.j1939') + >>> from scapy.contrib.cansocket import CANSocket + >>> from scapy.contrib.automotive.j1939.j1939_scanner import j1939_scan + >>> sock = CANSocket("can0") + >>> found = j1939_scan(sock, methods=["addr_claim", "unicast"]) + >>> for sa, info in found.items(): + ... print("SA=0x{:02X} found_by={} pkts={}".format( + ... sa, info["methods"], len(info["packets"]))) +""" + +import json +import logging +import struct +import time +from threading import Event # noqa: F401 + +# Typing imports +from typing import ( # noqa: F401 + Callable, + Dict, + Iterable, + List, + Optional, + Set, + Tuple, + Union, + cast, +) + +from scapy.layers.can import CAN +from scapy.supersocket import SuperSocket + +from scapy.contrib.j1939 import ( + J1939_BROADCAST_ADDR as J1939_GLOBAL_ADDRESS, + J1939_PGN_TP_CM, + J1939_TP_CTRL_RTS as TP_CM_RTS, + J1939_TP_CTRL_CTS as TP_CM_CTS, + J1939_TP_CTRL_ABORT as TP_Conn_Abort, + can_id_to_j1939, + j1939_to_can_id, + log_j1939, +) + +J1939_TP_CM_PF = (J1939_PGN_TP_CM >> 8) & 0xFF +PGN_ADDRESS_CLAIMED = 0xEE00 +J1939_PF_ADDRESS_CLAIMED = 0xEE +PGN_REQUEST = 0xEA00 +J1939_PF_REQUEST = 0xEA + + +def _j1939_can_id(priority, pf, da, sa): + return j1939_to_can_id( + priority=priority, reserved=0, data_page=0, + pdu_format=pf, pdu_specific=da, src=sa) + + +def _j1939_decode_can_id(can_id): + f = can_id_to_j1939(can_id) + return (f['priority'], f['pdu_format'], + f['pdu_specific'], f['src']) + + +# --- Scanner constants + +#: PGN for ECU Identification Information (J1939-73 §5.7.5) +PGN_ECU_ID = 0xFDC5 # 64965 + +#: Bitmask for the CAN extended-frame flag (29-bit identifier) +_CAN_EXTENDED_FLAG = 0x4 + +#: Default priority for request frames sent by the scanner +_SCAN_PRIORITY = 6 + +#: Scan address range for unicast / RTS sweeps (0x00 – 0xFD inclusive) +_SCAN_ADDR_RANGE = range(0x00, 0xFE) # 0xFE = null / 0xFF = broadcast + +#: Candidate diagnostic source addresses (SAE J1939 reserved diagnostic range). +#: Used as the default for *src_addrs* in all scan functions. +J1939_DIAGADAPTERS_ADDRESSES = list(range(0xF1, 0xFE)) # [0xF1 .. 0xFD] + +#: PGN for J1939 Diagnostic Message A (PDU1 peer-to-peer, PF=0xDA) +PGN_DIAG_A = 0xDA00 + +#: PF byte for Diagnostic Message A +J1939_PF_DIAG_A = 0xDA + +#: PGN for J1939 Diagnostic Message B (PDU1 peer-to-peer, PF=0xDB) +PGN_DIAG_B = 0xDB00 + +#: PF byte for Diagnostic Message B +J1939_PF_DIAG_B = 0xDB + +#: UDS TesterPresent request payloads: length=2, SID=0x3E, followed by 5 +#: padding bytes (0xFF) to fill an 8-byte CAN frame. +#: Subfunction 0x00 asks for a response; 0x01 suppresses it (but some ECUs +#: respond anyway, confirming UDS support). +_UDS_TESTER_PRESENT_REQS = [ + b"\x02\x3e\x00\xff\xff\xff\xff\xff", + b"\x02\x3e\x01\xff\xff\xff\xff\xff", +] + +#: Expected UDS responses for TesterPresent (SID=0x3E). +#: Includes positive responses (SID=0x7E, subfunctions 0x00 and 0x01) and +#: negative responses (SID=0x7F, original SID=0x3E). +_UDS_TESTER_PRESENT_RESPS = [ + b"\x02\x7e\x00", + b"\x02\x7e\x01", + b"\x03\x7f\x3e", +] + +#: PF byte for XCP Messages (Proprietary A, PDU1 peer-to-peer, PF=0xEF) +J1939_PF_XCP = 0xEF + +#: Default source addresses used by the XCP scanner. +J1939_XCP_SRC_ADDRS = ( + [0x3F, 0x5A] + list(range(0x01, 0x10)) + [0xAC] + list(range(0xF1, 0xFE)) +) + +#: XCP CONNECT command payload: command byte 0xFF, mode 0x00 (normal connection), +#: followed by 6 padding bytes (0xFF) to fill an 8-byte CAN frame. +_XCP_CONNECT_REQ = b"\xff\x00\xff\xff\xff\xff\xff\xff" + +#: XCP positive response byte (status byte 0xFF = OK in XCP protocol) +_XCP_POSITIVE_RESPONSE = 0xFF + +#: PDU Format byte for the Acknowledgment PGN (0xE800 / 59392; J1939-21 §5.4.4). +#: ECUs that do not implement TP may respond to an RTS with a NACK on this PGN +#: instead of a TP.CM Abort. +_J1939_PF_ACK = 0xE8 + +#: Acknowledgment control-byte values (J1939-21 §5.4.4, data byte 0). +_ACK_CTRL_NACK = 0x01 # Negative Acknowledgment +_ACK_CTRL_ACCESS_DENIED = 0x02 # Access Denied +_ACK_CTRL_CANNOT_RESPOND = 0x03 # Cannot Respond + +#: All valid CA scan method names +SCAN_METHODS = ("addr_claim", "ecu_id", "unicast", "rts_probe", "uds", "xcp") + + +def _build_request_payload(pgn): + # type: (int) -> bytes + """Encode *pgn* as a 3-byte little-endian payload for a J1939 Request (PF=0xEA) frame.""" + return struct.pack(" int + """Return the bit count of a CAN extended frame with *dlc* data bytes. + + Uses the fixed-field formula for a 29-bit extended frame (no bit-stuffing + overhead): + + SOF(1) + base-ID(11) + SRR(1) + IDE(1) + ext-ID(18) + RTR(1) + + r1(1) + r0(1) + DLC(4) + data(dlc×8) + CRC(15) + CRC-del(1) + + ACK(1) + ACK-del(1) + EOF(7) + IFS(3) = 67 + dlc×8 bits. + + :param dlc: number of data bytes (0–8) + :returns: total frame bit count + """ + return 67 + dlc * 8 + + +def _inter_probe_delay(bitrate, busload, tx_dlc, rx_dlc, sniff_time): + # type: (int, float, int, int, float) -> float + """Compute the extra sleep needed after a probe-response cycle. + + Each probe cycle occupies *tx_dlc*-frame bits (outgoing probe) plus + *rx_dlc*-frame bits (expected response). The scanner's bandwidth budget + is ``bitrate × busload`` bits per second. If the probe-response exchange + completes in less time than the budget requires, the caller should sleep for + the returned value before transmitting the next probe. + + :param bitrate: CAN bus bitrate in bit/s (e.g. 250000 for 250 kbit/s) + :param busload: fraction of bus capacity the scanner may consume + (0 < busload ≤ 1.0) + :param tx_dlc: DLC of the outgoing probe frame (0–8) + :param rx_dlc: DLC of the expected response frame (0–8) + :param sniff_time: seconds already spent waiting for the response + :returns: non-negative seconds to sleep before the next probe + :raises ValueError: when *busload* is not in (0, 1.0] + """ + if not 0.0 < busload <= 1.0: + raise ValueError("busload must be in (0, 1.0]; got {!r}".format(busload)) + bits = _can_frame_bits(tx_dlc) + _can_frame_bits(rx_dlc) + min_cycle = bits / (bitrate * busload) + return max(0.0, min_cycle - sniff_time) + + +def _pre_probe_flush(sock): + # type: (SuperSocket) -> None + """Flush the kernel CAN receive buffer before sending a probe. + + On :class:`~scapy.contrib.cansocket_python_can.PythonCANSocket` the + kernel CAN socket buffer is only drained by ``multiplex_rx_packets()`` + which is called from within ``select()``. Between successive + ``sniff()`` calls the buffer is **not** read, so background CAN + traffic accumulates. On resource-constrained embedded systems the + kernel buffer may be small enough to overflow, causing *response* + frames to be silently dropped. + + Calling ``sock.select([sock], 0)`` with a zero timeout triggers a + non-blocking ``multiplex_rx_packets()`` pass, moving any + kernel-buffered frames into the unbounded Python ``rx_queue``. This + frees space in the kernel buffer for the upcoming response. + + For :class:`~scapy.contrib.cansocket_native.NativeCANSocket` and test + sockets this call is a harmless no-op (it checks readiness without + consuming data). + """ + try: + sock.select([sock], 0) + except Exception: + pass + + +# --- Socketcan filter helpers + +#: CAN Extended Frame Format flag for socketcan ``CAN_RAW_FILTER`` entries. +#: Set in the ``can_id`` field of ``struct can_filter`` so the kernel matches +#: only 29-bit extended identifiers. Value equals ``socket.CAN_EFF_FLAG``. +_SOCKETCAN_EFF_FLAG = 0x80000000 + + +def _j1939_sa_filter(target_sa): + # type: (int) -> List[Dict[str, int]] + """Return socketcan ``can_filters`` matching extended frames with SA=*target_sa*. + + In a 29-bit J1939 CAN identifier the source address (SA) occupies bits + 7–0. The returned filter passes only extended-format frames whose low + byte equals *target_sa*, dramatically reducing the number of frames + delivered to the socket's kernel receive buffer on a busy bus. + + :param target_sa: source address to match (0x00–0xFF) + :returns: list with one ``can_filters`` dict suitable for + :class:`~scapy.contrib.cansocket_native.NativeCANSocket` + """ + return [{ + "can_id": _SOCKETCAN_EFF_FLAG | (target_sa & 0xFF), + "can_mask": _SOCKETCAN_EFF_FLAG | 0xFF, + }] + + +def _open_sa_filtered_sock(sock, target_sa): + # type: (SuperSocket, int) -> Tuple[SuperSocket, bool] + """Try to open a CAN socket filtered to receive only SA=*target_sa*. + + On Linux with :class:`~scapy.contrib.cansocket_native.NativeCANSocket`, + this creates a **new** raw PF_CAN socket on the same interface with a + hardware-level ``CAN_RAW_FILTER`` that passes only extended frames + whose source-address byte matches *target_sa*. The kernel discards + non-matching frames before they enter the socket receive buffer, + preventing buffer overflow on resource-constrained embedded systems + with busy J1939 buses. + + For any other socket type (``PythonCANSocket``, test sockets, etc.) + the function returns the original *sock* unchanged as a safe + fallback — the existing ``_pre_probe_flush`` mechanism handles + those cases. + + :param sock: original CAN socket (used for sending) + :param target_sa: source address expected in response frames + :returns: ``(rx_sock, close_needed)`` — *rx_sock* is the socket to + use for ``sniff()``, and *close_needed* is ``True`` when + the caller must call ``rx_sock.close()`` after use. + """ + channel = getattr(sock, "channel", None) + if channel is None: + return sock, False + try: + from scapy.contrib.cansocket_native import NativeCANSocket + if not isinstance(sock, NativeCANSocket): + return sock, False + rx = NativeCANSocket( + channel=channel, + can_filters=_j1939_sa_filter(target_sa), + ) + return rx, True + except Exception: + return sock, False + + +#: Type alias for the first parameter of all scan functions: either a live +#: CAN socket or a zero-argument callable that creates a new one. +SockOrFactory = Union[SuperSocket, Callable[[], SuperSocket]] + + +def _resolve_probe_sock(sock_or_factory, target_sa): + # type: (SockOrFactory, int) -> Tuple[SuperSocket, SuperSocket, bool] + """Resolve a socket-or-factory into ``(send_sock, rx_sock, close_rx)``. + + When *sock_or_factory* is **callable** (a socket factory), it is called + to create a fresh per-probe socket. On + :class:`~scapy.contrib.cansocket_native.NativeCANSocket` the new socket + is transparently upgraded to one with a ``CAN_RAW_FILTER`` that passes + only extended frames whose source-address byte equals *target_sa*. + Both *send_sock* and *rx_sock* point to the same new socket; the + caller **must** close it via *close_rx=True*. + + When *sock_or_factory* is a **SuperSocket**, the original socket is + used for sending and a separate filtered receive socket is opened if + possible; otherwise *rx_sock* equals *send_sock*. + + :param sock_or_factory: CAN socket or zero-argument callable that + returns a new CAN socket + :param target_sa: source address expected in response frames + :returns: ``(send_sock, rx_sock, close_rx)`` — the caller must call + ``rx_sock.close()`` after the probe iff *close_rx* is True. + *send_sock* is **never** closed by the caller. + """ + if callable(sock_or_factory): + probe = sock_or_factory() + channel = getattr(probe, "channel", None) + if channel is not None: + try: + from scapy.contrib.cansocket_native import NativeCANSocket + if isinstance(probe, NativeCANSocket): + probe.close() + filtered = NativeCANSocket( + channel=channel, + can_filters=_j1939_sa_filter(target_sa), + ) + return filtered, filtered, True + except Exception: + pass + return probe, probe, True + rx_sock, close_rx = _open_sa_filtered_sock(sock_or_factory, target_sa) + return sock_or_factory, rx_sock, close_rx + + +def _resolve_broadcast_sock(sock_or_factory): + # type: (SockOrFactory) -> Tuple[SuperSocket, bool] + """Resolve a socket-or-factory for broadcast (non-filtered) use. + + When *sock_or_factory* is callable, it is called once to create a + socket. When it is a SuperSocket, it is returned as-is. + + :returns: ``(sock, close_needed)`` + """ + if callable(sock_or_factory): + return sock_or_factory(), True + return sock_or_factory, False + + +# --- Passive scan — background noise detection + + +def j1939_scan_passive( + sock, # type: SockOrFactory + listen_time=2.0, # type: float + stop_event=None, # type: Optional[Event] +): + # type: (...) -> Set[int] + """Passively listen to the bus and return the set of observed source addresses. + + Listens for *listen_time* seconds without sending any probe frames and + records every source address (SA) seen in an extended CAN frame. The + returned set can be passed as the ``noise_ids`` argument to the active + scan functions so that already-known CAs are not re-probed or re-reported. + + :param sock: raw CAN socket **or** zero-argument callable returning one + :param listen_time: seconds to collect background traffic + :param stop_event: optional :class:`threading.Event` to abort early + :returns: set of observed source addresses (integers) + """ + active_sock, close_sock = _resolve_broadcast_sock(sock) + try: + seen = set() # type: Set[int] + + def _rx(pkt): + # type: (CAN) -> None + if stop_event is not None and stop_event.is_set(): + return + if not (pkt.flags & _CAN_EXTENDED_FLAG): + return + _, _, _, sa = _j1939_decode_can_id(pkt.identifier) + seen.add(sa) + + active_sock.sniff(prn=_rx, timeout=listen_time, store=False) + log_j1939.debug( + "passive: observed %d SA(s): %s", len(seen), [hex(s) for s in sorted(seen)] + ) + return seen + finally: + if close_sock: + active_sock.close() + + +# --- Technique 1 – Global Address Claim Request + + +def j1939_scan_addr_claim( + sock, # type: SockOrFactory + src_addrs=None, # type: Optional[List[int]] + listen_time=1.0, # type: float + noise_ids=None, # type: Optional[Set[int]] + force=False, # type: bool + stop_event=None, # type: Optional[Event] + bitrate=_J1939_DEFAULT_BITRATE, # type: int + busload=_J1939_DEFAULT_BUSLOAD, # type: float +): + # type: (...) -> Dict[int, List[CAN]] + """Enumerate CAs via a global Request for Address Claimed (PGN 60928). + + For each address in *src_addrs*, sends a broadcast Request frame and + listens for Address Claimed replies. Every J1939-81-compliant CA must + respond. + + :param sock: raw CAN socket **or** zero-argument callable returning one + :param src_addrs: list of source addresses to use in requests; defaults + to :data:`J1939_DIAGADAPTERS_ADDRESSES` ([0xF1..0xFD]) + :param listen_time: seconds to collect responses after sending each probe + :param noise_ids: set of source addresses already seen on the bus + (from :func:`j1939_scan_passive`). SAs in this set + are suppressed from the results unless *force* is True. + :param force: if True, report all responding SAs even if they appear in + *noise_ids* + :param stop_event: optional :class:`threading.Event` to abort early + :param bitrate: CAN bus bitrate in bit/s (default 250000). + :param busload: maximum scanner bus-load fraction (default 0.05). + :returns: dict mapping responder source address (int) to a list of + matching CAN replies + """ + if src_addrs is None: + src_addrs = J1939_DIAGADAPTERS_ADDRESSES + payload = _build_request_payload(PGN_ADDRESS_CLAIMED) + found = {} # type: Dict[int, List[CAN]] + + active_sock, close_sock = _resolve_broadcast_sock(sock) + try: + for _sa in src_addrs: + if stop_event is not None and stop_event.is_set(): + break + can_id = _j1939_can_id( + _SCAN_PRIORITY, J1939_PF_REQUEST, J1939_GLOBAL_ADDRESS, _sa + ) + _pre_probe_flush(active_sock) + active_sock.send(CAN(identifier=can_id, flags="extended", data=payload)) + log_j1939.debug( + "addr_claim: broadcast request sent SA=0x%02X (CAN-ID=0x%08X)", _sa, can_id + ) + + def _rx(pkt): + # type: (CAN) -> None + if not (pkt.flags & _CAN_EXTENDED_FLAG): + return + if stop_event is not None and stop_event.is_set(): + return + _, pf, ps, sa = _j1939_decode_can_id(pkt.identifier) + if pf == J1939_PF_ADDRESS_CLAIMED and ps == J1939_GLOBAL_ADDRESS: + if not force and noise_ids is not None and sa in noise_ids: + log_j1939.debug("addr_claim: suppressing noise SA=0x%02X", sa) + return + log_j1939.debug("addr_claim: response from SA=0x%02X", sa) + if sa not in found: + found[sa] = [] + # Record which scanner SA elicited this broadcast + setattr(pkt, "src_addrs", [_sa]) + found[sa].append(pkt) + + active_sock.sniff(prn=_rx, timeout=listen_time, store=False) + + # Pace: 1 broadcast Request (DLC 3) + 1 typical response (DLC 8) + _extra = _inter_probe_delay(bitrate, busload, 3, 8, listen_time) + if _extra > 0.0: + time.sleep(_extra) + + return found + finally: + if close_sock: + active_sock.close() + + +# --- Technique 2 – Global ECU ID Request + + +def j1939_scan_ecu_id( + sock, # type: SockOrFactory + src_addrs=None, # type: Optional[List[int]] + listen_time=1.0, # type: float + noise_ids=None, # type: Optional[Set[int]] + force=False, # type: bool + stop_event=None, # type: Optional[Event] + bitrate=_J1939_DEFAULT_BITRATE, # type: int + busload=_J1939_DEFAULT_BUSLOAD, # type: float +): + # type: (...) -> Dict[int, List[CAN]] + """Enumerate CAs via a global Request for ECU Identification (PGN 64965). + + For each address in *src_addrs*, sends a broadcast Request frame and + listens for BAM announce headers whose PGN field matches 64965. + + :param sock: raw CAN socket **or** zero-argument callable returning one + :param src_addrs: list of source addresses to use in requests; defaults + to :data:`J1939_DIAGADAPTERS_ADDRESSES` ([0xF1..0xFD]) + :param listen_time: seconds to collect responses after sending each probe + :param noise_ids: set of source addresses to suppress from results + (see :func:`j1939_scan_passive`) + :param force: if True, report all responding SAs even if in *noise_ids* + :param stop_event: optional :class:`threading.Event` to abort early + :param bitrate: CAN bus bitrate in bit/s (default 250000). + :param busload: maximum scanner bus-load fraction (default 0.05). + :returns: dict mapping responder source address (int) to a list of + matching CAN replies + """ + if src_addrs is None: + src_addrs = J1939_DIAGADAPTERS_ADDRESSES + payload = _build_request_payload(PGN_ECU_ID) + found = {} # type: Dict[int, List[CAN]] + + active_sock, close_sock = _resolve_broadcast_sock(sock) + try: + for _sa in src_addrs: + if stop_event is not None and stop_event.is_set(): + break + can_id = _j1939_can_id( + _SCAN_PRIORITY, J1939_PF_REQUEST, J1939_GLOBAL_ADDRESS, _sa + ) + _pre_probe_flush(active_sock) + active_sock.send(CAN(identifier=can_id, flags="extended", data=payload)) + log_j1939.debug( + "ecu_id: broadcast request sent SA=0x%02X (CAN-ID=0x%08X)", _sa, can_id + ) + + def _rx(pkt): + # type: (CAN) -> None + if not (pkt.flags & _CAN_EXTENDED_FLAG): + return + if stop_event is not None and stop_event.is_set(): + return + _, pf, ps, sa = _j1939_decode_can_id(pkt.identifier) + # We expect a BAM header (TP.CM, DA=0xFF) announcing PGN 64965 + if pf != J1939_TP_CM_PF: + return + if ps != J1939_GLOBAL_ADDRESS: + return + data = bytes(pkt.data) + if len(data) < 8: + return + # BAM control byte = 0x20, PGN at bytes 5-7 (LE) + if data[0] == 0x20 and data[5:8] == payload: + if not force and noise_ids is not None and sa in noise_ids: + log_j1939.debug("ecu_id: suppressing noise SA=0x%02X", sa) + return + log_j1939.debug("ecu_id: BAM from SA=0x%02X", sa) + if sa not in found: + found[sa] = [] + # Record which scanner SA elicited this broadcast + setattr(pkt, "src_addrs", [_sa]) + found[sa].append(pkt) + + active_sock.sniff(prn=_rx, timeout=listen_time, store=False) + + # Pace: 1 broadcast Request (DLC 3) + 1 typical BAM header (DLC 8) + _extra = _inter_probe_delay(bitrate, busload, 3, 8, listen_time) + if _extra > 0.0: + time.sleep(_extra) + + return found + finally: + if close_sock: + active_sock.close() + + +# --- Technique 3 – Unicast Ping Sweep + + +def j1939_scan_unicast( + sock, # type: SockOrFactory + scan_range=_SCAN_ADDR_RANGE, # type: Iterable[int] + src_addrs=None, # type: Optional[List[int]] + sniff_time=0.1, # type: float + noise_ids=None, # type: Optional[Set[int]] + force=False, # type: bool + stop_event=None, # type: Optional[Event] + bitrate=_J1939_DEFAULT_BITRATE, # type: int + busload=_J1939_DEFAULT_BUSLOAD, # type: float +): + # type: (...) -> Dict[int, List[CAN]] + """Enumerate CAs by sending unicast Address Claim Requests to each DA. + + For each destination address *da* in *scan_range*, sends a Request for + Address Claimed (PGN 60928) addressed to *da* once for each address in + *src_addrs*. Any CAN frame whose source address equals *da* is counted + as a positive response. + + When *noise_ids* is provided (and *force* is False), destination addresses + that appear in *noise_ids* are skipped entirely — no probe is sent and no + response is recorded for those addresses. This prevents re-reporting CAs + already known from background bus traffic. + + The inter-probe gap is automatically paced so that the scanner contributes + at most *busload* × *bitrate* bits per second to the bus, counting both + the outgoing probe frames and the expected response frame. + + :param sock: raw CAN socket **or** zero-argument callable returning one + :param scan_range: iterable of destination addresses to probe + :param src_addrs: list of source addresses to use in requests; defaults + to :data:`J1939_DIAGADAPTERS_ADDRESSES` ([0xF1..0xF9]) + :param sniff_time: seconds to wait for a response after each probe + :param noise_ids: set of source addresses already known from background + traffic (see :func:`j1939_scan_passive`). DAs whose + value appears in this set are not probed. + :param force: if True, probe all DAs in *scan_range* regardless of + *noise_ids* + :param stop_event: optional :class:`threading.Event` to abort early + :param bitrate: CAN bus bitrate in bit/s (default 250000 for J1939) + :param busload: maximum fraction of bus capacity the scanner may consume + (default 0.05 = 5 %) + :returns: dict mapping responder source address (int) to a list of + matching CAN replies + """ + if src_addrs is None: + src_addrs = J1939_DIAGADAPTERS_ADDRESSES + else: + src_addrs = list(src_addrs) + found = {} # type: Dict[int, List[CAN]] + payload = _build_request_payload(PGN_ADDRESS_CLAIMED) + + for da in scan_range: + if stop_event is not None and stop_event.is_set(): + break + if not force and noise_ids is not None and da in noise_ids: + log_j1939.debug("unicast: skipping noise DA=0x%02X", da) + continue + + _da = da + send_sock, rx_sock, close_rx = _resolve_probe_sock(sock, _da) + + try: + for _sa in src_addrs: + if stop_event is not None and stop_event.is_set(): + break + + _sa_resps = [] # type: List[CAN] + + def _rx(pkt, _da=_da, _sa=_sa): + # type: (CAN, int, int) -> None + if not (pkt.flags & _CAN_EXTENDED_FLAG): + return + _, pf, ps, sa = _j1939_decode_can_id(pkt.identifier) + if sa == _da and pf == J1939_PF_ADDRESS_CLAIMED and ( + (ps == _sa or ps == J1939_GLOBAL_ADDRESS) and sa != ps + ): + log_j1939.debug( + "unicast: response from SA=0x%02X to scanner SA=0x%02X", sa, ps + ) + if _da not in found: + found[_da] = [] + if ps == J1939_GLOBAL_ADDRESS: + setattr(pkt, "src_addrs", [_sa]) + found[_da].append(pkt) + _sa_resps.append(pkt) + + def _send_probe(_da=_da, _sa=_sa): + # type: (int, int) -> None + _pre_probe_flush(rx_sock) + can_id = _j1939_can_id(_SCAN_PRIORITY, J1939_PF_REQUEST, _da, _sa) + send_sock.send(CAN(identifier=can_id, flags="extended", data=payload)) + log_j1939.debug("unicast: probing DA=0x%02X from SA=0x%02X", _da, _sa) + + rx_sock.sniff( + prn=_rx, + timeout=sniff_time, + store=False, + started_callback=_send_probe, + stop_filter=lambda _: bool(_sa_resps), + ) + + # Pace the probe rate + _tx_bits = _can_frame_bits(3) + _extra = max( + 0.0, (_tx_bits + _can_frame_bits(8)) / (bitrate * busload) - sniff_time + ) + if _extra > 0.0: + time.sleep(_extra) + finally: + if close_rx: + rx_sock.close() + + return found + + +# --- Technique 4 – TP.CM RTS Probing + + +def j1939_scan_rts_probe( + sock, # type: SockOrFactory + scan_range=_SCAN_ADDR_RANGE, # type: Iterable[int] + src_addrs=None, # type: Optional[List[int]] + sniff_time=0.1, # type: float + noise_ids=None, # type: Optional[Set[int]] + force=False, # type: bool + stop_event=None, # type: Optional[Event] + bitrate=_J1939_DEFAULT_BITRATE, # type: int + busload=_J1939_DEFAULT_BUSLOAD, # type: float +): + # type: (...) -> Dict[int, List[CAN]] + """Enumerate CAs by sending minimal TP.CM_RTS frames to each DA. + + For each destination address *da* in *scan_range*, sends a TP.CM_RTS + (Connection Management – Request to Send) frame once per address in + *src_addrs*. An active node replies with either TP.CM_CTS (clear to + send), ``TP_Conn_Abort`` (connection abort), or a NACK on the + Acknowledgment PGN (0xE800). All three responses confirm the node is + present. Nodes that do not implement the Transport Protocol layer + typically respond with a NACK rather than a TP.CM Abort. + + The inter-probe gap is automatically paced so that the scanner contributes + at most *busload* × *bitrate* bits per second to the bus. + + :param sock: raw CAN socket **or** zero-argument callable returning one + :param scan_range: iterable of destination addresses to probe + :param src_addrs: list of source addresses to use in probes; defaults + to :data:`J1939_DIAGADAPTERS_ADDRESSES` ([0xF1..0xF9]) + :param sniff_time: seconds to wait for a response after each probe + :param noise_ids: set of source addresses already known from background + traffic (see :func:`j1939_scan_passive`). DAs whose + value appears in this set are not probed. + :param force: if True, probe all DAs in *scan_range* regardless of + *noise_ids* + :param stop_event: optional :class:`threading.Event` to abort early + :param bitrate: CAN bus bitrate in bit/s (default 250000 for J1939) + :param busload: maximum fraction of bus capacity the scanner may consume + (default 0.05 = 5 %) + :returns: dict mapping responder source address (int) to a list of + matching CAN replies + """ + if src_addrs is None: + src_addrs = J1939_DIAGADAPTERS_ADDRESSES + else: + src_addrs = list(src_addrs) + found = {} # type: Dict[int, List[CAN]] + + for da in scan_range: + if stop_event is not None and stop_event.is_set(): + break + if not force and noise_ids is not None and da in noise_ids: + log_j1939.debug("rts_probe: skipping noise DA=0x%02X", da) + continue + # TP.CM_RTS payload (8 bytes): + # byte 0: 0x10 = RTS control + # bytes 1-2 LE: total message size = 9 + # byte 3: total packets = 2 + # byte 4: max packets per CTS = 0xFF (no limit) + # bytes 5-7: PGN being transferred (probe PGN = 0x0000FF) + rts_payload = struct.pack( + " None + if not (pkt.flags & _CAN_EXTENDED_FLAG): + return + _, pf, ps, sa = _j1939_decode_can_id(pkt.identifier) + if sa != _da or ps != _sa or sa == ps: + return + d = bytes(pkt.data) + if not d: + return + # TP.CM response from the probed node (CTS or Abort) + if pf == J1939_TP_CM_PF and d[0] in (TP_CM_CTS, TP_Conn_Abort): + log_j1939.debug( + "rts_probe: TP.CM response (ctrl=0x%02X) from SA=0x%02X" + " to scanner SA=0x%02X", + d[0], sa, ps, + ) + if _da not in found: + found[_da] = [] + found[_da].append(pkt) + _sa_resps.append(pkt) + # Acknowledgment (NACK / Access Denied / Cannot Respond). + # Nodes that do not implement TP may respond with a NACK on the + # Acknowledgment PGN (0xE800) instead of a TP.CM Abort. + elif pf == _J1939_PF_ACK and d[0] in ( + _ACK_CTRL_NACK, _ACK_CTRL_ACCESS_DENIED, + _ACK_CTRL_CANNOT_RESPOND, + ): + log_j1939.debug( + "rts_probe: ACK response (ctrl=0x%02X) from SA=0x%02X" + " to scanner SA=0x%02X", + d[0], sa, ps, + ) + if _da not in found: + found[_da] = [] + found[_da].append(pkt) + _sa_resps.append(pkt) + + def _send_probe(_da=_da, _sa=_sa): + # type: (int, int) -> None + _pre_probe_flush(rx_sock) + # CAN-ID: priority=7, PF=0xEC (TP.CM), DA=da, SA=_sa + can_id = _j1939_can_id(7, J1939_TP_CM_PF, _da, _sa) + send_sock.send(CAN(identifier=can_id, flags="extended", data=rts_payload)) + log_j1939.debug("rts_probe: probing DA=0x%02X from SA=0x%02X", _da, _sa) + + rx_sock.sniff( + prn=_rx, + timeout=sniff_time, + store=False, + started_callback=_send_probe, + stop_filter=lambda _: bool(_sa_resps), + ) + + # Pace: 1 RTS probe (DLC 8) + one expected response (DLC 8) + _tx_bits = _can_frame_bits(8) + _extra = max( + 0.0, (_tx_bits + _can_frame_bits(8)) / (bitrate * busload) - sniff_time + ) + if _extra > 0.0: + time.sleep(_extra) + finally: + if close_rx: + rx_sock.close() + + return found + + +# --- Technique 5 – UDS TesterPresent Probe + + +def j1939_scan_uds( + sock, # type: SockOrFactory + scan_range=_SCAN_ADDR_RANGE, # type: Iterable[int] + src_addrs=None, # type: Optional[List[int]] + sniff_time=0.1, # type: float + noise_ids=None, # type: Optional[Set[int]] + force=False, # type: bool + stop_event=None, # type: Optional[Event] + bitrate=_J1939_DEFAULT_BITRATE, # type: int + busload=_J1939_DEFAULT_BUSLOAD, # type: float + skip_functional=False, # type: bool + broadcast_listen_time=1.0, # type: float + diag_pgn=J1939_PF_DIAG_A, # type: int +): + # type: (...) -> Dict[int, List[CAN]] + """Enumerate CAs by sending a UDS TesterPresent request to each DA. + + First, if *skip_functional* is False, sends broadcast UDS TesterPresent + requests over Diagnostic Message B (PF=diag_pgn | 0x01, DA=0xFF). + Attempts both subfunctions 0x00 and 0x01. Any responding source addresses + are recorded. + + Then, for each destination address *da* in *scan_range* and each source + address in *src_addrs*, sends padded UDS TesterPresent requests over + Diagnostic Message A (PF=diag_pgn). Attempts both subfunctions 0x00 + and 0x01. A node that implements UDS replies with a positive response + frame whose first three payload bytes are ``02 7E 00`` or ``02 7E 01``. + Only well-formed positive responses are recorded. + + The inter-probe gap is automatically paced so that the scanner contributes + at most *busload* × *bitrate* bits per second to the bus. + + :param sock: raw CAN socket **or** zero-argument callable returning one + :param scan_range: iterable of destination addresses to probe + :param src_addrs: list of source addresses to use in requests; defaults + to :data:`J1939_DIAGADAPTERS_ADDRESSES` ([0xF1..0xF9]) + :param sniff_time: seconds to wait for a response after each probe + :param noise_ids: set of source addresses already known from background + traffic (see :func:`j1939_scan_passive`). DAs whose + value appears in this set are not probed. + :param force: if True, probe all DAs in *scan_range* regardless of + *noise_ids* + :param stop_event: optional :class:`threading.Event` to abort early + :param bitrate: CAN bus bitrate in bit/s (default 250000 for J1939) + :param busload: maximum fraction of bus capacity the scanner may consume + (default 0.05 = 5 %) + :param skip_functional: if True, skip the broadcast functional scan + :param broadcast_listen_time: seconds to wait for responses after the + broadcast functional probe + :param diag_pgn: PF byte for UDS diagnostic messages (default 0xDA). + Functional addressing uses ``diag_pgn | 0x01``. + :returns: dict mapping responder source address (int) to a list of + matching CAN replies + """ + if src_addrs is None: + src_addrs = J1939_DIAGADAPTERS_ADDRESSES + else: + src_addrs = list(src_addrs) + found = {} # type: Dict[int, List[CAN]] + + if not skip_functional: + func_sock, close_func = _resolve_broadcast_sock(sock) + try: + for _sa in src_addrs: + if stop_event is not None and stop_event.is_set(): + break + + def _rx_functional(pkt, _sa=_sa): + # type: (CAN, int) -> None + if not (pkt.flags & _CAN_EXTENDED_FLAG): + return + if stop_event is not None and stop_event.is_set(): + return + _, pf, ps, sa = _j1939_decode_can_id(pkt.identifier) + if not force and noise_ids is not None and sa in noise_ids: + return + if pf == diag_pgn | 0x01 and ps == _sa: + data = bytes(pkt.data) + if data[:3] in _UDS_TESTER_PRESENT_RESPS: + log_j1939.debug( + "uds: functional response from SA=0x%02X to scanner SA=0x%02X", + sa, + ps, + ) + if sa not in found: + found[sa] = [] + found[sa].append(pkt) + + def _send_functional(_sa=_sa): + # type: (int) -> None + _pre_probe_flush(func_sock) + can_id_f = _j1939_can_id( + _SCAN_PRIORITY, diag_pgn | 0x01, J1939_GLOBAL_ADDRESS, _sa + ) + for req in _UDS_TESTER_PRESENT_REQS: + func_sock.send(CAN(identifier=can_id_f, flags="extended", data=req)) + log_j1939.debug( + "uds: broadcast functional probe sent SA=0x%02X (PF=0x%02X)", + _sa, diag_pgn | 0x01 + ) + + func_sock.sniff(prn=_rx_functional, timeout=broadcast_listen_time, store=False, + started_callback=_send_functional) + finally: + if close_func: + func_sock.close() + + for da in scan_range: + if stop_event is not None and stop_event.is_set(): + break + if not force and noise_ids is not None and da in noise_ids: + log_j1939.debug("uds: skipping noise DA=0x%02X", da) + continue + + _da = da + send_sock, rx_sock, close_rx = _resolve_probe_sock(sock, _da) + + try: + for _sa in src_addrs: + if stop_event is not None and stop_event.is_set(): + break + + _sa_resps = [] # type: List[CAN] + + def _rx(pkt, _da=_da, _sa=_sa): + # type: (CAN, int, int) -> None + if not (pkt.flags & _CAN_EXTENDED_FLAG): + return + _, pf, ps, sa = _j1939_decode_can_id(pkt.identifier) + if sa == _da and pf == diag_pgn and ps == _sa and sa != ps: + data = bytes(pkt.data) + if data[:3] in _UDS_TESTER_PRESENT_RESPS: + log_j1939.debug( + "uds: response from SA=0x%02X to scanner SA=0x%02X", sa, ps + ) + if _da not in found: + found[_da] = [] + found[_da].append(pkt) + _sa_resps.append(pkt) + + for req in _UDS_TESTER_PRESENT_REQS: + if stop_event is not None and stop_event.is_set(): + break + if _sa_resps: + break + + def _send_probe(_da=_da, _sa=_sa, _req=req): + # type: (int, int, bytes) -> None + _pre_probe_flush(rx_sock) + can_id_a = _j1939_can_id(_SCAN_PRIORITY, diag_pgn, _da, _sa) + send_sock.send(CAN(identifier=can_id_a, flags="extended", data=_req)) + log_j1939.debug( + "uds: physical probe DA=0x%02X SA=0x%02X on PF=0x%02X", + _da, _sa, diag_pgn + ) + + rx_sock.sniff( + prn=_rx, + timeout=sniff_time, + store=False, + started_callback=_send_probe, + stop_filter=lambda _: bool(_sa_resps), + ) + + # Pace: probes per src_addr + 1 response + _tx_bits = len(_UDS_TESTER_PRESENT_REQS) * _can_frame_bits(8) + _extra = max( + 0.0, (_tx_bits + _can_frame_bits(8)) / (bitrate * busload) - sniff_time + ) + if _extra > 0.0: + time.sleep(_extra) + finally: + if close_rx: + rx_sock.close() + + return found + + +# --- Technique 6 – XCP Connect Probe + + +def j1939_scan_xcp( + sock, # type: SockOrFactory + scan_range=_SCAN_ADDR_RANGE, # type: Iterable[int] + src_addrs=None, # type: Optional[List[int]] + sniff_time=0.1, # type: float + noise_ids=None, # type: Optional[Set[int]] + force=False, # type: bool + stop_event=None, # type: Optional[Event] + bitrate=_J1939_DEFAULT_BITRATE, # type: int + busload=_J1939_DEFAULT_BUSLOAD, # type: float + diag_pgn=J1939_PF_XCP, # type: int +): + # type: (...) -> Dict[int, List[CAN]] + """Enumerate CAs by sending an XCP CONNECT command to each DA. + + For each destination address *da* in *scan_range* and each source address + in *src_addrs*, sends a padded XCP CONNECT request (command byte 0xFF, + mode 0x00, 6 x 0xFF padding) over Diagnostic Message A (PF=diag_pgn). + A node that implements XCP replies with a positive response frame whose + first byte is ``0xFF``. Only well-formed positive responses are recorded. + + The inter-probe gap is automatically paced so that the scanner contributes + at most *busload* × *bitrate* bits per second to the bus. + + :param sock: raw CAN socket **or** zero-argument callable returning one + :param scan_range: iterable of destination addresses to probe + :param src_addrs: list of source addresses to use in requests; defaults + to :data:`J1939_XCP_SRC_ADDRS` ([0x3F, 0x5A]) + :param sniff_time: seconds to wait for a response after each probe + :param noise_ids: set of source addresses already known from background + traffic (see :func:`j1939_scan_passive`). DAs whose + value appears in this set are not probed. + :param force: if True, probe all DAs in *scan_range* regardless of + *noise_ids* + :param stop_event: optional :class:`threading.Event` to abort early + :param bitrate: CAN bus bitrate in bit/s (default 250000 for J1939) + :param busload: maximum fraction of bus capacity the scanner may consume + (default 0.05 = 5 %) + :param diag_pgn: PF byte for XCP diagnostic messages (default 0xEF, + Proprietary A peer-to-peer addressing) + :returns: dict mapping responder source address (int) to a list of + matching CAN replies + """ + if src_addrs is None: + src_addrs = J1939_XCP_SRC_ADDRS + else: + src_addrs = list(src_addrs) + found = {} # type: Dict[int, List[CAN]] + + for da in scan_range: + if stop_event is not None and stop_event.is_set(): + break + if not force and noise_ids is not None and da in noise_ids: + log_j1939.debug("xcp: skipping noise DA=0x%02X", da) + continue + + _da = da + send_sock, rx_sock, close_rx = _resolve_probe_sock(sock, _da) + + try: + for _sa in src_addrs: + if stop_event is not None and stop_event.is_set(): + break + + _sa_resps = [] # type: List[CAN] + + def _rx(pkt, _da=_da, _sa=_sa): + # type: (CAN, int, int) -> None + if not (pkt.flags & _CAN_EXTENDED_FLAG): + return + _, pf, ps, sa = _j1939_decode_can_id(pkt.identifier) + if sa == _da and pf == diag_pgn and ps == _sa and sa != ps: + data = bytes(pkt.data) + if data and data[0] == _XCP_POSITIVE_RESPONSE: + log_j1939.debug( + "xcp: response from SA=0x%02X to scanner SA=0x%02X", sa, ps + ) + if _da not in found: + found[_da] = [] + found[_da].append(pkt) + _sa_resps.append(pkt) + + def _send_probe(_da=_da, _sa=_sa): + # type: (int, int) -> None + _pre_probe_flush(rx_sock) + can_id = _j1939_can_id(_SCAN_PRIORITY, diag_pgn, _da, _sa) + send_sock.send( + CAN(identifier=can_id, flags="extended", data=_XCP_CONNECT_REQ) + ) + log_j1939.debug( + "xcp: probing DA=0x%02X SA=0x%02X on PF=0x%02X", _da, _sa, diag_pgn + ) + + rx_sock.sniff( + prn=_rx, + timeout=sniff_time, + store=False, + started_callback=_send_probe, + stop_filter=lambda _: bool(_sa_resps), + ) + + _tx_bits = _can_frame_bits(8) + _extra = max( + 0.0, (_tx_bits + _can_frame_bits(8)) / (bitrate * busload) - sniff_time + ) + if _extra > 0.0: + time.sleep(_extra) + finally: + if close_rx: + rx_sock.close() + + return found + + +# --- Top-level combined scanner + + +def j1939_scan( + sock, # type: SockOrFactory + scan_range=_SCAN_ADDR_RANGE, # type: Iterable[int] + methods=None, # type: Optional[List[str]] + src_addrs=None, # type: Optional[List[int]] + sniff_time=0.1, # type: float + broadcast_listen_time=1.0, # type: float + noise_listen_time=1.0, # type: float + noise_ids=None, # type: Optional[Set[int]] + force=False, # type: bool + stop_event=None, # type: Optional[Event] + verbose=False, # type: bool + bitrate=_J1939_DEFAULT_BITRATE, # type: int + busload=_J1939_DEFAULT_BUSLOAD, # type: float + skip_functional=False, # type: bool + diag_pgn=None, # type: Optional[int] + output_format=None, # type: Optional[str] +): + # type: (...) -> Union[Dict[int, Dict[str, object]], str] + """Scan for J1939 Controller Applications using one or more techniques. + + Runs each requested scan method and merges the results. The returned + dictionary maps each discovered source address to a dict with keys: + + - ``"methods"`` (List[str]): list of all techniques that found this CA, + in the order they detected it. A CA discovered by more than one + technique will appear in all of their names. + - ``"packets"`` (List[List[CAN]]): list of lists of CAN response frames, + one inner list per entry in ``"methods"``, in the same order. + - ``"src_addrs"`` (List[List[int]]): list of scanner source addresses, + one entry per technique in ``"methods"``. For techniques that use + physical addressing (``"uds"`` and ``"xcp"``), this records which + scanner source address produced the response — i.e. which SA must be + used for further access. An empty list is stored for techniques where + no scanner SA could be definitively identified (e.g. broadcast methods + without explicit stamping). + + By default, before running any active probe the function performs a + passive bus listen (via :func:`j1939_scan_passive`) for *noise_listen_time* + seconds to detect pre-existing source addresses. Those addresses are then + excluded from active probing and from the results. Pass *force=True* to + disable this filtering, or supply an explicit *noise_ids* set to bypass the + passive pre-scan. + + :param sock: raw CAN socket **or** zero-argument callable returning one. + Passing a callable enables per-probe socket creation with + socketcan hardware filters, preventing kernel buffer overflow + on busy buses. + :param scan_range: DA range for unicast / RTS sweeps (default 0x00–0xFD) + :param methods: list of method names to run; valid values are + ``"addr_claim"``, ``"ecu_id"``, ``"unicast"``, + ``"rts_probe"``, ``"uds"``, ``"xcp"``. Default is all six. + :param src_addrs: list of source addresses to use in outgoing probes; + defaults to :data:`J1939_DIAGADAPTERS_ADDRESSES` ([0xF1..0xF9]) + :param sniff_time: per-address listen time for unicast / RTS methods + :param broadcast_listen_time: listen time for broadcast methods + :param noise_listen_time: seconds for the passive pre-scan (default 1.0). + Only used when *noise_ids* is None and *force* + is False. + :param noise_ids: explicit set of source addresses to exclude from + probing and results. When provided the passive pre-scan + is skipped. + :param force: if True, disable noise filtering entirely (no passive pre-scan, + all addresses are probed and reported) + :param stop_event: :class:`threading.Event` to abort the scan early + :param verbose: if True, set the ``log_j1939`` logger to + :data:`logging.DEBUG` and log discovered CAs to the + console. Matches the verbose pattern used by + :func:`~scapy.contrib.isotp.isotp_scanner.isotp_scan` and + :class:`~scapy.contrib.automotive.xcp.scanner.XCPOnCANScanner`. + :param bitrate: CAN bus bitrate in bit/s passed to unicast / RTS / UDS / XCP + methods. When not specified the scanner tries to read the + ``bitrate`` attribute of *sock* automatically, and falls + back to ``_J1939_DEFAULT_BITRATE`` (250 kbps) if the + attribute is not available. + :param busload: maximum scanner bus-load fraction passed to unicast / RTS / + UDS / XCP methods (default 0.05 = 5 %) + :param skip_functional: passed to :func:`j1939_scan_uds` + :param diag_pgn: passed to :func:`j1939_scan_uds` and :func:`j1939_scan_xcp` + :param output_format: controls the return type. ``None`` (default) returns + the raw results dict. ``"text"`` returns a + human-readable string. ``"json"`` returns a JSON + string. + :returns: dict mapping SA (int) to + ``{"methods": List[str], "packets": List[List[CAN]], + "src_addrs": List[List[int]]}``; + or a ``str`` when *output_format* is ``"text"`` or ``"json"`` + + Example:: + + >>> found = j1939_scan(sock) + >>> for sa, info in sorted(found.items()): + ... for method, src_addrs in zip(info["methods"], info["src_addrs"]): + ... s_sas = ", ".join("0x{:02X}".format(s) for s in src_addrs) + ... print("SA=0x{:02X} via {} (scanner SA={})".format( + ... sa, method, s_sas if s_sas else "broadcast")) + """ + if verbose: + log_j1939.setLevel(logging.DEBUG) + if methods is None: + methods = list(SCAN_METHODS) + + for m in methods: + if m not in SCAN_METHODS: + raise ValueError( + "Unknown scan method {!r}; valid methods: {}".format(m, SCAN_METHODS) + ) + + if src_addrs is None: + src_addrs = J1939_DIAGADAPTERS_ADDRESSES + else: + src_addrs = list(src_addrs) + + # If the caller left bitrate at the sentinel default, try to pull the real + # value from the socket (e.g. CANSocket stores it as sock.bitrate). + # When sock is a callable, probe a temporary socket for the attribute. + if bitrate == _J1939_DEFAULT_BITRATE: + _probe = sock() if callable(sock) else sock + sock_bitrate = getattr(_probe, "bitrate", None) + if sock_bitrate is not None: + try: + bitrate = int(sock_bitrate) + except (TypeError, ValueError): + pass + if callable(sock) and _probe is not sock: + try: + _probe.close() + except Exception: + pass + + # Step 0: passive pre-scan to detect background noise unless disabled. + if not force and noise_ids is None: + if stop_event is not None and stop_event.is_set(): + return {} + noise_ids = j1939_scan_passive( + sock, listen_time=noise_listen_time, stop_event=stop_event + ) + if verbose and noise_ids: + log_j1939.info( + "j1939_scan: %d noise SA(s) detected, will skip: %s", + len(noise_ids), + [hex(s) for s in sorted(noise_ids)], + ) + + results = {} # type: Dict[int, Dict[str, object]] + scan_range_list = list(scan_range) + + def _merge(found, method_name, with_src_addr=False): + # type: (Dict[int, List[CAN]], str, bool) -> None + for sa, pkts in found.items(): + # For methods that use physical addressing (uds, xcp, etc.), the + # scanner's source address is embedded as the DA field (ps) of + # the response CAN frame. Extract all unique successful scanner + # source addresses from the response packets so callers can tell + # which scanner SAs are authorized or required for further access. + src_addr = [] # type: List[int] + if with_src_addr and pkts: + for p in pkts: + # Check for explicit stamp from iterative scan methods + s_sa_list = getattr(p, "src_addrs", None) + if s_sa_list is not None: + for s_sa in s_sa_list: + if s_sa not in src_addr: + src_addr.append(s_sa) + continue + + _, _, ps, _ = _j1939_decode_can_id(p.identifier) + if ps != J1939_GLOBAL_ADDRESS and ps not in src_addr: + src_addr.append(ps) + if sa not in results: + if verbose: + log_j1939.info( + "j1939_scan: found SA=0x%02X via %s", sa, method_name + ) + results[sa] = { + "methods": [method_name], + "packets": [pkts], + "src_addrs": [src_addr], + } + else: + if verbose: + log_j1939.info( + "j1939_scan: SA=0x%02X also detected via %s", sa, method_name + ) + cast(List[str], results[sa]["methods"]).append(method_name) + cast(List[List[CAN]], results[sa]["packets"]).append(pkts) + cast(List, results[sa]["src_addrs"]).append(src_addr) + + if "addr_claim" in methods: + if stop_event is not None and stop_event.is_set(): + return results + _merge( + j1939_scan_addr_claim( + sock, + src_addrs=src_addrs, + listen_time=broadcast_listen_time, + noise_ids=noise_ids, + force=force, + stop_event=stop_event, + bitrate=bitrate, + busload=busload, + ), + "addr_claim", + with_src_addr=True, + ) + + if "ecu_id" in methods: + if stop_event is not None and stop_event.is_set(): + return results + _merge( + j1939_scan_ecu_id( + sock, + src_addrs=src_addrs, + listen_time=broadcast_listen_time, + noise_ids=noise_ids, + force=force, + stop_event=stop_event, + bitrate=bitrate, + busload=busload, + ), + "ecu_id", + with_src_addr=True, + ) + + if "unicast" in methods: + if stop_event is not None and stop_event.is_set(): + return results + _merge( + j1939_scan_unicast( + sock, + scan_range=scan_range_list, + src_addrs=src_addrs, + sniff_time=sniff_time, + noise_ids=noise_ids, + force=force, + stop_event=stop_event, + bitrate=bitrate, + busload=busload, + ), + "unicast", + with_src_addr=True, + ) + + if "rts_probe" in methods: + if stop_event is not None and stop_event.is_set(): + return results + _merge( + j1939_scan_rts_probe( + sock, + scan_range=scan_range_list, + src_addrs=src_addrs, + sniff_time=sniff_time, + noise_ids=noise_ids, + force=force, + stop_event=stop_event, + bitrate=bitrate, + busload=busload, + ), + "rts_probe", + with_src_addr=True, + ) + + if "uds" in methods: + if stop_event is not None and stop_event.is_set(): + return results + uds_kwargs = { + "sock": sock, + "scan_range": scan_range_list, + "src_addrs": src_addrs, + "sniff_time": sniff_time, + "noise_ids": noise_ids, + "force": force, + "stop_event": stop_event, + "bitrate": bitrate, + "busload": busload, + "skip_functional": skip_functional, + "broadcast_listen_time": broadcast_listen_time, + } + if diag_pgn is not None: + uds_kwargs["diag_pgn"] = diag_pgn + _merge(j1939_scan_uds(**uds_kwargs), "uds", with_src_addr=True) + + if "xcp" in methods: + if stop_event is not None and stop_event.is_set(): + return results + xcp_kwargs = { + "sock": sock, + "scan_range": scan_range_list, + "src_addrs": src_addrs, + "sniff_time": sniff_time, + "noise_ids": noise_ids, + "force": force, + "stop_event": stop_event, + "bitrate": bitrate, + "busload": busload, + } + if diag_pgn is not None: + xcp_kwargs["diag_pgn"] = diag_pgn + _merge(j1939_scan_xcp(**xcp_kwargs), "xcp", with_src_addr=True) + + if output_format == "text": + return _generate_text_output(results) + if output_format == "json": + return _generate_json_output(results) + return results + + +def _generate_text_output(results): + # type: (Dict[int, Dict[str, object]]) -> str + """Format *results* as a human-readable string. + + :param results: dict returned by :func:`j1939_scan` + :returns: multiline text summary + """ + if not results: + return "No J1939 Controller Applications found." + lines = [ + "Found {} J1939 Controller Application(s):".format(len(results)) + ] + for sa in sorted(results): + info = results[sa] + methods = cast(List[str], info["methods"]) + src_addrs = cast(List, info["src_addrs"]) + lines.append( + "\nSA: 0x{:02X}".format(sa) + ) + for method, s_addrs in zip(methods, src_addrs): + s_sas = ", ".join("0x{:02X}".format(s) for s in s_addrs) + lines.append( + " Method: {}{}".format( + method, + " (scanner SA: {})".format(s_sas) if s_sas else "", + ) + ) + return "\n".join(lines) + + +def _generate_json_output(results): + # type: (Dict[int, Dict[str, object]]) -> str + """Format *results* as a JSON string. + + Packet objects are not JSON-serialisable and are omitted; the output + contains SA, methods, and src_addrs only. + + :param results: dict returned by :func:`j1939_scan` + :returns: JSON string + """ + out = [] # type: List[Dict[str, object]] + for sa in sorted(results): + info = results[sa] + entry = { + "sa": sa, + "methods": list(cast(List[str], info["methods"])), + "src_addrs": [list(s) for s in cast(List, info["src_addrs"])], + } # type: Dict[str, object] + out.append(entry) + return json.dumps(out) + + +__all__ = [ + "SockOrFactory", + "j1939_scan", + "j1939_scan_passive", + "j1939_scan_addr_claim", + "j1939_scan_ecu_id", + "j1939_scan_unicast", + "j1939_scan_rts_probe", + "j1939_scan_uds", + "j1939_scan_xcp", + "J1939_DIAGADAPTERS_ADDRESSES", + "J1939_XCP_SRC_ADDRS", + "PGN_ECU_ID", + "PGN_DIAG_A", + "J1939_PF_DIAG_A", + "PGN_DIAG_B", + "J1939_PF_DIAG_B", + "J1939_PF_XCP", + "SCAN_METHODS", +] diff --git a/test/contrib/automotive/j1939_scanner.uts b/test/contrib/automotive/j1939_scanner.uts new file mode 100644 index 00000000000..c0c1637084f --- /dev/null +++ b/test/contrib/automotive/j1939_scanner.uts @@ -0,0 +1,2252 @@ +% Regression tests for J1939 CA Scanner +~ automotive_comm + ++ Configuration +~ conf + += Imports +import struct +from scapy.layers.can import CAN +from scapy.contrib.automotive.j1939 import ( + J1939_GLOBAL_ADDRESS, J1939_NULL_ADDRESS, +) +from scapy.contrib.automotive.j1939 import ( + _j1939_can_id, _j1939_decode_can_id, + J1939_PF_ADDRESS_CLAIMED, J1939_PF_REQUEST, + J1939_TP_CM_PF, + PGN_ADDRESS_CLAIMED, PGN_REQUEST, + TP_CM_BAM, TP_CM_CTS, TP_Conn_Abort, +) +from scapy.contrib.automotive.j1939.j1939_scanner import ( + j1939_scan, + j1939_scan_passive, + j1939_scan_addr_claim, + j1939_scan_ecu_id, + j1939_scan_unicast, + j1939_scan_rts_probe, + j1939_scan_uds, + j1939_scan_xcp, + J1939_DIAGADAPTERS_ADDRESSES, + J1939_XCP_SRC_ADDRS, + PGN_ECU_ID, + PGN_DIAG_A, + J1939_PF_DIAG_A, + PGN_DIAG_B, + J1939_PF_DIAG_B, + J1939_PF_XCP, + SCAN_METHODS, + _build_request_payload, + _can_frame_bits, + _inter_probe_delay, + _j1939_sa_filter, + _open_sa_filtered_sock, + _resolve_probe_sock, + _resolve_broadcast_sock, + _J1939_DEFAULT_BITRATE, + _J1939_DEFAULT_BUSLOAD, + _XCP_CONNECT_REQ, + _XCP_POSITIVE_RESPONSE, + _J1939_PF_ACK, + _ACK_CTRL_NACK, + _ACK_CTRL_ACCESS_DENIED, + _ACK_CTRL_CANNOT_RESPOND, + _generate_text_output, + _generate_json_output, +) +from scapy.error import Scapy_Exception +from test.testsocket import TestSocket, SlowTestSocket, cleanup_testsockets + += Redirect logging +import logging +from scapy.error import log_runtime +from io import StringIO +log_stream = StringIO() +handler = logging.StreamHandler(log_stream) +log_runtime.addHandler(handler) +log_j1939_logger = logging.getLogger("scapy.contrib.automotive.j1939") +log_j1939_logger.addHandler(handler) + + ++ Scanner constants + += PGN_ECU_ID is 64965 (0xFDC5) +assert PGN_ECU_ID == 64965 +assert PGN_ECU_ID == 0xFDC5 + += SCAN_METHODS contains all six technique names +assert set(SCAN_METHODS) == {"addr_claim", "ecu_id", "unicast", "rts_probe", "uds", "xcp"} + += J1939_DIAGADAPTERS_ADDRESSES is [0xF1..0xFD] (13 diagnostic source addresses) +assert J1939_DIAGADAPTERS_ADDRESSES == list(range(0xF1, 0xFE)) +assert len(J1939_DIAGADAPTERS_ADDRESSES) == 13 + += J1939_XCP_SRC_ADDRS is the expanded list of diagnostic source addresses +assert J1939_XCP_SRC_ADDRS == ([0x3F, 0x5A] + list(range(0x01, 0x10)) + [0xAC] + list(range(0xF1, 0xFE))) +assert len(J1939_XCP_SRC_ADDRS) == 31 + += J1939_PF_XCP is 0xEF (XCP Proprietary A PF byte) +assert J1939_PF_XCP == 0xEF + += _build_request_payload encodes PGN_ADDRESS_CLAIMED correctly +# PGN 60928 = 0xEE00: LE 3 bytes = \x00 \xEE \x00 +payload = _build_request_payload(PGN_ADDRESS_CLAIMED) +assert payload == b'\x00\xee\x00', "Got {}".format(payload.hex()) + += _build_request_payload encodes PGN_ECU_ID correctly +# PGN 64965 = 0xFDC5: LE 3 bytes = \xC5 \xFD \x00 +payload = _build_request_payload(PGN_ECU_ID) +assert payload == b'\xc5\xfd\x00', "Got {}".format(payload.hex()) + + ++ Technique 1 – Global Address Claim Request + += addr_claim: sends a broadcast Request for PGN_ADDRESS_CLAIMED +def test_addr_claim_probe_frame(): + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + j1939_scan_addr_claim(tx_sock, listen_time=0.0, + src_addrs=[0xF9]) + pkts = monitor.sniff(count=1, timeout=0.2) + assert len(pkts) == 1, "Expected 1 probe frame, got {}".format(len(pkts)) + probe = pkts[0] + _, pf, ps, sa = _j1939_decode_can_id(probe.identifier) + assert probe.flags & 0x4, "Expected extended CAN frame" + assert pf == J1939_PF_REQUEST, "PF should be 0xEA (Request)" + assert ps == J1939_GLOBAL_ADDRESS, "DA should be 0xFF (global)" + assert sa == 0xF9, "SA should be 0xF9" + assert bytes(probe.data) == _build_request_payload(PGN_ADDRESS_CLAIMED) + cleanup_testsockets() + +test_addr_claim_probe_frame() + += addr_claim: receives Address Claimed reply and returns SA +sock = TestSocket(CAN) +# Inject a fake Address Claimed reply from SA=0x10 +resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, 0x10) +sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x01\x02\x03\x04\x05\x06\x07\x08'))) +found = j1939_scan_addr_claim(sock, listen_time=0.1) +assert 0x10 in found, "Expected SA=0x10, got: {}".format([hex(k) for k in found]) +assert isinstance(found[0x10], list) +assert len(found[0x10]) == 1 +cleanup_testsockets() + += addr_claim: multiple replies from different SAs are all captured +def test_addr_claim_multiple(): + sock = TestSocket(CAN) + for sa_val in [0x10, 0x20, 0x30]: + resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, sa_val) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x00' * 8))) + found = j1939_scan_addr_claim(sock, listen_time=0.2) + assert set(found.keys()) == {0x10, 0x20, 0x30}, \ + "Expected three SAs, got: {}".format([hex(k) for k in found]) + assert all(isinstance(v, list) and len(v) == 1 for v in found.values()) + cleanup_testsockets() + +test_addr_claim_multiple() + += addr_claim: non-extended (11-bit) frames are ignored +sock = TestSocket(CAN) +# 11-bit frame has flags=0 (no extended bit) +non_ext_can_id = 0x040 # some 11-bit id +sock.ins.send(bytes(CAN(identifier=non_ext_can_id, data=b'\x01\x02\x03'))) +found = j1939_scan_addr_claim(sock, listen_time=0.1) +assert len(found) == 0, "Should not detect 11-bit CAN frames" +cleanup_testsockets() + += addr_claim: returns empty dict when no responses +sock = TestSocket(CAN) +found = j1939_scan_addr_claim(sock, listen_time=0.05) +assert found == {}, "Expected empty result" +cleanup_testsockets() + + ++ Technique 2 – Global ECU ID Request + += ecu_id: sends a broadcast Request for PGN_ECU_ID +def test_ecu_id_probe_frame(): + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + j1939_scan_ecu_id(tx_sock, listen_time=0.0, + src_addrs=[0xF9]) + pkts = monitor.sniff(count=1, timeout=0.2) + assert len(pkts) == 1 + probe = pkts[0] + _, pf, ps, sa = _j1939_decode_can_id(probe.identifier) + assert probe.flags & 0x4, "Expected extended CAN frame" + assert pf == J1939_PF_REQUEST, "PF should be Request (0xEA)" + assert ps == J1939_GLOBAL_ADDRESS, "DA should be global (0xFF)" + assert bytes(probe.data) == _build_request_payload(PGN_ECU_ID) + cleanup_testsockets() + +test_ecu_id_probe_frame() + += ecu_id: detects BAM announce header for PGN_ECU_ID +sock = TestSocket(CAN) +ecu_pgn_le = _build_request_payload(PGN_ECU_ID) +bam_can_id = _j1939_can_id(6, J1939_TP_CM_PF, J1939_GLOBAL_ADDRESS, 0x20) +# BAM payload: [ctrl=0x20][size LE2][num_pkts][0xFF][pgn 3 bytes LE] +bam_payload = bytes([0x20, 0x0A, 0x00, 0x02, 0xFF]) + ecu_pgn_le +sock.ins.send(bytes(CAN(identifier=bam_can_id, flags="extended", + data=bam_payload))) +found = j1939_scan_ecu_id(sock, listen_time=0.1) +assert 0x20 in found, "Expected SA=0x20" +assert isinstance(found[0x20], list) +assert len(found[0x20]) == 1 +cleanup_testsockets() + += ecu_id: ignores BAM for a different PGN +sock = TestSocket(CAN) +other_pgn_le = _build_request_payload(PGN_ADDRESS_CLAIMED) # different PGN +bam_can_id = _j1939_can_id(6, J1939_TP_CM_PF, J1939_GLOBAL_ADDRESS, 0x22) +bam_payload = bytes([0x20, 0x09, 0x00, 0x02, 0xFF]) + other_pgn_le +sock.ins.send(bytes(CAN(identifier=bam_can_id, flags="extended", + data=bam_payload))) +found = j1939_scan_ecu_id(sock, listen_time=0.1) +assert len(found) == 0, "Should not match BAM for a different PGN" +cleanup_testsockets() + += ecu_id: ignores non-TP.CM frames +sock = TestSocket(CAN) +# Inject an Address Claimed frame (not a TP.CM) +resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, 0x25) +sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x00' * 8))) +found = j1939_scan_ecu_id(sock, listen_time=0.1) +assert len(found) == 0, "ecu_id should ignore non-TP.CM frames" +cleanup_testsockets() + += addr_claim: firewall simulation - only SA=0xAC can elicit response +def test_addr_claim_firewall(): + import threading + import time + with TestSocket(CAN) as sock, TestSocket(CAN) as monitor: + sock.pair(monitor) + + def simulate_ecu(): + # Listen for requests and only reply if SA is 0xAC + while True: + pkts = monitor.sniff(count=1, timeout=0.5) + if not pkts: break + p = pkts[0] + _, pf, ps, sa = _j1939_decode_can_id(p.identifier) + if pf == J1939_PF_REQUEST and ps == J1939_GLOBAL_ADDRESS and sa == 0xAC: + # ECU at 0x10 responds + resp = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, 0x10) + sock.ins.send(bytes(CAN(identifier=resp, flags="extended", data=b'\x00'*8))) + + t = threading.Thread(target=simulate_ecu) + t.start() + + # Probe from multiple SAs including 0xAC + found = j1939_scan_addr_claim(sock, src_addrs=[0xF1, 0xAC, 0xF2], listen_time=0.1) + t.join() + + assert 0x10 in found, "ECU 0x10 should be found via SA 0xAC" + # Since the scanner is iterative, it should have recorded 0xAC as the successful SA + assert found[0x10][0].src_addrs == [0xAC], "Expected successful SA 0xAC, got: {}".format(found[0x10][0].src_addrs) + cleanup_testsockets() + +test_addr_claim_firewall() + += ecu_id: firewall simulation - only SA=0xAC can elicit response +def test_ecu_id_firewall(): + import threading + import time + with TestSocket(CAN) as sock, TestSocket(CAN) as monitor: + sock.pair(monitor) + ecu_pgn_le = _build_request_payload(PGN_ECU_ID) + + def simulate_ecu(): + while True: + pkts = monitor.sniff(count=1, timeout=0.5) + if not pkts: break + p = pkts[0] + _, pf, ps, sa = _j1939_decode_can_id(p.identifier) + if pf == J1939_PF_REQUEST and ps == J1939_GLOBAL_ADDRESS and sa == 0xAC: + # ECU at 0x20 responds with BAM header + resp = _j1939_can_id(6, J1939_TP_CM_PF, J1939_GLOBAL_ADDRESS, 0x20) + payload = bytes([0x20, 0x0A, 0x00, 0x02, 0xFF]) + ecu_pgn_le + sock.ins.send(bytes(CAN(identifier=resp, flags="extended", data=payload))) + + t = threading.Thread(target=simulate_ecu) + t.start() + + found = j1939_scan_ecu_id(sock, src_addrs=[0xF1, 0xAC, 0xF2], listen_time=0.1) + t.join() + + assert 0x20 in found, "ECU 0x20 should be found via SA 0xAC" + # Since the scanner is iterative, it should have recorded 0xAC as the successful SA + # Wait, the results of j1939_scan_addr_claim / ecu_id return Dict[int, List[CAN]]. + # The top-level j1939_scan merges them into Dict[int, Dict[str, object]] with 'src_addrs'. + # I should test j1939_scan directly to see the 'src_addrs' field. + cleanup_testsockets() + +test_ecu_id_firewall() + + ++ Technique 3 – Unicast Ping Sweep + += unicast: sends a Request to each DA in scan_range +def test_unicast_probe_frames(): + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + j1939_scan_unicast(tx_sock, scan_range=[0x01, 0x02, 0x03], + sniff_time=0.0, + src_addrs=[0xF9]) + pkts = monitor.sniff(count=3, timeout=0.5) + assert len(pkts) == 3, "Expected 3 probe frames" + das = set() + for pkt in pkts: + _, pf, ps, sa = _j1939_decode_can_id(pkt.identifier) + assert pf == J1939_PF_REQUEST, "PF should be Request" + das.add(ps) + assert das == {0x01, 0x02, 0x03}, "DA values should match scan_range" + cleanup_testsockets() + +test_unicast_probe_frames() + += unicast: detects reply from the probed SA directed to 0xF9 +sock = TestSocket(CAN) +# Response from SA=0x30 (Address Claimed from that node) directed to 0xF9 +resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, 0xF9, 0x30) +sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x00' * 8))) +found = j1939_scan_unicast(sock, scan_range=[0x30], sniff_time=0.1, src_addrs=[0xF9]) +assert 0x30 in found, "Expected SA=0x30 in results" +assert isinstance(found[0x30], list) +assert len(found[0x30]) == 1 +cleanup_testsockets() + += unicast: ignores echoes of Request probes (PF=0xEA) +sock = TestSocket(CAN) +# Simulated echo: Request from 0xF9 to 0xF9 (PF=0xEA, PS=0xF9, SA=0xF9) +# This frame has sa == _da if we are probing 0xF9, but pf == 0xEA +echo_can_id = _j1939_can_id(6, J1939_PF_REQUEST, 0xF9, 0xF9) +sock.ins.send(bytes(CAN(identifier=echo_can_id, flags="extended", + data=b'\x00\xee\x00'))) +found = j1939_scan_unicast(sock, scan_range=[0xF9], sniff_time=0.1, src_addrs=[0xF9]) +assert 0xF9 not in found, "Scanner must ignore echoes of its own Request probes" +cleanup_testsockets() + += unicast: accepts broadcast Address Claimed response (PS=0xFF) to unicast probe +sock = TestSocket(CAN) +# Response from SA=0x30 directed to Global Address (0xFF) +resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, 0x30) +sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x00' * 8))) +found = j1939_scan_unicast(sock, scan_range=[0x30], sniff_time=0.1, src_addrs=[0xF9]) +assert 0x30 in found, "Expected SA=0x30 via broadcast Address Claimed hit" +cleanup_testsockets() + += unicast: does not report SA that is not in scan_range +sock = TestSocket(CAN) +# Inject response from SA=0x55 while we only probe 0x30, directed to 0xF9 +resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, 0xF9, 0x55) +sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x00' * 8))) +found = j1939_scan_unicast(sock, scan_range=[0x30], sniff_time=0.1, src_addrs=[0xF9]) +assert 0x55 not in found, "SA=0x55 is not in scan_range" +cleanup_testsockets() + += unicast: payload of probe is Request for PGN_ADDRESS_CLAIMED +def test_unicast_probe_payload(): + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + j1939_scan_unicast(tx_sock, scan_range=[0x42], sniff_time=0.0, + src_addrs=[0xF9]) + pkts = monitor.sniff(count=1, timeout=0.2) + assert len(pkts) == 1 + assert bytes(pkts[0].data) == _build_request_payload(PGN_ADDRESS_CLAIMED) + cleanup_testsockets() + +test_unicast_probe_payload() + += unicast: returns empty dict when no responses +sock = TestSocket(CAN) +found = j1939_scan_unicast(sock, scan_range=[0x10, 0x11], sniff_time=0.02, src_addrs=[0xF9]) +assert found == {} +cleanup_testsockets() + + ++ Technique 4 – TP.CM RTS Probing + += rts_probe: sends a TP.CM_RTS frame to each DA in scan_range +def test_rts_probe_frames(): + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + j1939_scan_rts_probe(tx_sock, scan_range=[0x05, 0x06], sniff_time=0.0, + src_addrs=[0xF9]) + pkts = monitor.sniff(count=2, timeout=0.5) + assert len(pkts) == 2, "Expected 2 RTS probe frames" + for pkt in pkts: + _, pf, _, _ = _j1939_decode_can_id(pkt.identifier) + assert pf == J1939_TP_CM_PF, "PF should be TP.CM (0xEC)" + assert bytes(pkt.data)[0] == 0x10, "First byte should be TP_CM_RTS (0x10)" + cleanup_testsockets() + +test_rts_probe_frames() + += rts_probe: detects CTS reply from probed node +sock = TestSocket(CAN) +# CTS response from SA=0x40, to SA=0xF9 (our probe SA) +cts_can_id = _j1939_can_id(7, J1939_TP_CM_PF, 0xF9, 0x40) +sock.ins.send(bytes(CAN(identifier=cts_can_id, flags="extended", + data=bytes([TP_CM_CTS, 0x02, 0x01, 0xFF, 0xFF, 0xFF, 0x00, 0x00])))) +found = j1939_scan_rts_probe(sock, scan_range=[0x40], sniff_time=0.1, src_addrs=[0xF9]) +assert 0x40 in found, "Expected SA=0x40 via CTS reply" +assert isinstance(found[0x40], list) +assert len(found[0x40]) == 1 +cleanup_testsockets() + += rts_probe: detects Conn_Abort reply from probed node +sock = TestSocket(CAN) +# Conn_Abort response (SA=0x41 sent an abort) directed to 0xF9 +abort_can_id = _j1939_can_id(7, J1939_TP_CM_PF, 0xF9, 0x41) +sock.ins.send(bytes(CAN(identifier=abort_can_id, flags="extended", + data=bytes([TP_Conn_Abort, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00])))) +found = j1939_scan_rts_probe(sock, scan_range=[0x41], sniff_time=0.1, src_addrs=[0xF9]) +assert 0x41 in found, "Expected SA=0x41 via Conn_Abort reply" +assert isinstance(found[0x41], list) +assert len(found[0x41]) == 1 +cleanup_testsockets() + += rts_probe: detects NACK on Acknowledgment PGN from probed node +sock = TestSocket(CAN) +# NACK response (SA=0x42, ctrl=0x01 NACK) directed to scanner SA 0xF9 +nack_can_id = _j1939_can_id(6, _J1939_PF_ACK, 0xF9, 0x42) +sock.ins.send(bytes(CAN(identifier=nack_can_id, flags="extended", + data=bytes([_ACK_CTRL_NACK, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00])))) +found = j1939_scan_rts_probe(sock, scan_range=[0x42], sniff_time=0.1, src_addrs=[0xF9]) +assert 0x42 in found, "Expected SA=0x42 via NACK reply on ACK PGN" +assert isinstance(found[0x42], list) +assert len(found[0x42]) == 1 +cleanup_testsockets() + += rts_probe: detects Access Denied on Acknowledgment PGN from probed node +sock = TestSocket(CAN) +# Access Denied response (SA=0x43, ctrl=0x02) directed to scanner SA 0xF9 +ack_can_id = _j1939_can_id(6, _J1939_PF_ACK, 0xF9, 0x43) +sock.ins.send(bytes(CAN(identifier=ack_can_id, flags="extended", + data=bytes([_ACK_CTRL_ACCESS_DENIED, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00])))) +found = j1939_scan_rts_probe(sock, scan_range=[0x43], sniff_time=0.1, src_addrs=[0xF9]) +assert 0x43 in found, "Expected SA=0x43 via Access Denied reply on ACK PGN" +assert len(found[0x43]) == 1 +cleanup_testsockets() + += rts_probe: detects Cannot Respond on Acknowledgment PGN from probed node +sock = TestSocket(CAN) +# Cannot Respond (SA=0x44, ctrl=0x03) directed to scanner SA 0xF9 +ack_can_id = _j1939_can_id(6, _J1939_PF_ACK, 0xF9, 0x44) +sock.ins.send(bytes(CAN(identifier=ack_can_id, flags="extended", + data=bytes([_ACK_CTRL_CANNOT_RESPOND, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00])))) +found = j1939_scan_rts_probe(sock, scan_range=[0x44], sniff_time=0.1, src_addrs=[0xF9]) +assert 0x44 in found, "Expected SA=0x44 via Cannot Respond reply on ACK PGN" +assert len(found[0x44]) == 1 +cleanup_testsockets() + += rts_probe: ignores positive ACK (ctrl=0x00) on Acknowledgment PGN +sock = TestSocket(CAN) +# Positive ACK (ctrl=0x00) should NOT be treated as presence confirmation +ack_can_id = _j1939_can_id(6, _J1939_PF_ACK, 0xF9, 0x45) +sock.ins.send(bytes(CAN(identifier=ack_can_id, flags="extended", + data=bytes([0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00])))) +found = j1939_scan_rts_probe(sock, scan_range=[0x45], sniff_time=0.1, src_addrs=[0xF9]) +assert len(found) == 0, "Positive ACK should not trigger RTS probe detection" +cleanup_testsockets() + += rts_probe: ignores non-TP.CM / non-ACK responses +sock = TestSocket(CAN) +# An Address Claimed frame from SA=0x45 should NOT trigger detection +resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, 0xF9, 0x45) +sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x00' * 8))) +found = j1939_scan_rts_probe(sock, scan_range=[0x45], sniff_time=0.1, src_addrs=[0xF9]) +assert len(found) == 0, "rts_probe should only respond to TP.CM or ACK frames" +cleanup_testsockets() + += rts_probe: RTS payload has correct format (8 bytes, ctrl=0x10) +def test_rts_probe_payload_format(): + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + j1939_scan_rts_probe(tx_sock, scan_range=[0x50], sniff_time=0.0, + src_addrs=[0xF9]) + pkts = monitor.sniff(count=1, timeout=0.2) + assert len(pkts) == 1 + payload = bytes(pkts[0].data) + assert len(payload) == 8, "RTS payload must be 8 bytes" + assert payload[0] == 0x10, "Byte 0 must be TP_CM_RTS (0x10)" + # Bytes 1-2 LE: message size = 9 + size = struct.unpack_from(" should return immediately without any probes + with TestSocket(CAN) as sock, TestSocket(CAN) as monitor: + sock.pair(monitor) + found = j1939_scan(sock, methods=["addr_claim", "unicast"], + scan_range=range(0xFE), broadcast_listen_time=0.1, + stop_event=ev) + # stop_event was set before any method ran; no probes should have been sent + probes = monitor.sniff(count=1, timeout=0.1) + assert len(probes) == 0, "No probes should be sent when stop_event is set" + cleanup_testsockets() + +test_scan_stop_event() + += j1939_scan: single-method call (unicast only) +sock = TestSocket(CAN) +# Use 0xF9 as destination for Address Claimed response to match scanner default src_addr +resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, 0xF9, 0x30) +sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x00' * 8))) +found = j1939_scan(sock, methods=["unicast"], scan_range=[0x30], sniff_time=0.1, + noise_ids=set(), src_addrs=[0xF9]) +assert 0x30 in found +assert found[0x30]["methods"] == ["unicast"] +assert isinstance(found[0x30]["packets"], list) +assert len(found[0x30]["packets"]) == 1 +assert isinstance(found[0x30]["packets"][0], list) +cleanup_testsockets() + += j1939_scan: rts_probe NACK on ACK PGN is detected and merged +sock = TestSocket(CAN) +# NACK response (SA=0x31, ctrl=0x01 NACK) directed to scanner SA 0xF9 +nack_can_id = _j1939_can_id(6, _J1939_PF_ACK, 0xF9, 0x31) +sock.ins.send(bytes(CAN(identifier=nack_can_id, flags="extended", + data=bytes([_ACK_CTRL_NACK, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00])))) +found = j1939_scan(sock, methods=["rts_probe"], scan_range=[0x31], sniff_time=0.1, + noise_ids=set(), src_addrs=[0xF9]) +assert 0x31 in found, "Expected SA=0x31 via NACK on ACK PGN through j1939_scan" +assert found[0x31]["methods"] == ["rts_probe"] +assert isinstance(found[0x31]["packets"], list) +assert len(found[0x31]["packets"]) == 1 +cleanup_testsockets() + + ++ Passive Scan + += passive: collects observed SAs from bus traffic +def test_passive_collects_sas(): + sock = TestSocket(CAN) + for sa_val in [0x10, 0x20, 0x30]: + noise_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, sa_val) + sock.ins.send(bytes(CAN(identifier=noise_can_id, flags="extended", + data=b'\x00' * 8))) + noise_ids = j1939_scan_passive(sock, listen_time=0.1) + assert noise_ids == {0x10, 0x20, 0x30}, \ + "Expected {{0x10, 0x20, 0x30}}, got: {}".format(noise_ids) + cleanup_testsockets() + +test_passive_collects_sas() + += passive: ignores non-extended (11-bit) frames +def test_passive_ignores_11bit(): + sock = TestSocket(CAN) + sock.ins.send(bytes(CAN(identifier=0x040, data=b'\x00' * 4))) + noise_ids = j1939_scan_passive(sock, listen_time=0.1) + assert len(noise_ids) == 0, "Should not collect 11-bit frame SA" + cleanup_testsockets() + +test_passive_ignores_11bit() + += passive: returns empty set when no traffic +sock = TestSocket(CAN) +noise_ids = j1939_scan_passive(sock, listen_time=0.05) +assert noise_ids == set(), "Expected empty set, got: {}".format(noise_ids) +cleanup_testsockets() + += passive: multiple different frames give distinct SAs +def test_passive_multiple_frames(): + sock = TestSocket(CAN) + # Three separate SA-DA flows + flows = [(0xA0, 0x01), (0xB0, 0x02), (0xC0, 0x03)] + for sa_val, da_val in flows: + can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, da_val, sa_val) + sock.ins.send(bytes(CAN(identifier=can_id, flags="extended", + data=b'\x00' * 8))) + noise_ids = j1939_scan_passive(sock, listen_time=0.1) + expected = {sa for sa, _ in flows} + assert noise_ids == expected, \ + "Expected {}, got: {}".format({hex(s) for s in expected}, + {hex(s) for s in noise_ids}) + cleanup_testsockets() + +test_passive_multiple_frames() + + ++ Unicast – noise filtering + += unicast: 3 pre-existing SA-DA flows are not reported (main noise test) +def test_unicast_noise_three_flows(): + # Step 1: simulate 3 pre-existing SA-DA traffic flows using passive scan + sock = TestSocket(CAN) + noise_sas = [0x10, 0x20, 0x30] + for sa_val in noise_sas: + noise_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, sa_val) + sock.ins.send(bytes(CAN(identifier=noise_can_id, flags="extended", + data=b'\x00' * 8))) + noise_ids = j1939_scan_passive(sock, listen_time=0.1) + assert noise_ids == set(noise_sas), \ + "Passive scan should collect noise SAs, got: {}".format(noise_ids) + # Step 2: inject response frames for SAME SAs and for new SA=0x40 not in noise, directed to 0xF9 + for sa_val in noise_sas: + resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, 0xF9, sa_val) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x00' * 8))) + new_sa = 0x40 + resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, 0xF9, new_sa) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x00' * 8))) + # Step 3: unicast sweep with noise filtering + found = j1939_scan_unicast(sock, scan_range=[0x10, 0x20, 0x30, 0x40], + noise_ids=noise_ids, sniff_time=0.1, src_addrs=[0xF9]) + # Pre-existing SAs must NOT be reported + for sa_val in noise_sas: + assert sa_val not in found, \ + "SA=0x{:02X} is noise and must not be reported".format(sa_val) + # New SA must be reported + assert new_sa in found, "SA=0x{:02X} (not noise) should be found".format(new_sa) + cleanup_testsockets() + +test_unicast_noise_three_flows() + += unicast: noise SAs are not probed (no probe frames sent for noise DAs) +def test_unicast_noise_no_probe_sent(): + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + # noise_ids = {0x01, 0x02}, scan_range = [0x01, 0x02, 0x03] + j1939_scan_unicast(tx_sock, scan_range=[0x01, 0x02, 0x03], + noise_ids={0x01, 0x02}, sniff_time=0.0, + src_addrs=[0xF9]) + pkts = monitor.sniff(count=5, timeout=0.3) + # Only DA=0x03 should have been probed (1 probe frame) + assert len(pkts) == 1, "Expected 1 probe (only DA=0x03), got {}".format(len(pkts)) + _, pf, ps, _ = _j1939_decode_can_id(pkts[0].identifier) + assert pf == J1939_PF_REQUEST + assert ps == 0x03, "Probe DA should be 0x03, got 0x{:02X}".format(ps) + cleanup_testsockets() + +test_unicast_noise_no_probe_sent() + += unicast: force=True probes noise SAs despite noise_ids +def test_unicast_force_probes_noise(): + sock = TestSocket(CAN) + # Inject response from SA=0x10 (which is in noise_ids) directed to 0xF9 + resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, 0xF9, 0x10) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x00' * 8))) + found = j1939_scan_unicast(sock, scan_range=[0x10], noise_ids={0x10}, + force=True, sniff_time=0.1, src_addrs=[0xF9]) + assert 0x10 in found, "force=True should report SA=0x10 even though it is in noise_ids" + cleanup_testsockets() + +test_unicast_force_probes_noise() + + ++ addr_claim – noise filtering + += addr_claim: noise SAs are filtered from broadcast results +def test_addr_claim_noise_filtering(): + sock = TestSocket(CAN) + # Inject Address Claimed from SA=0x10 (noise) and SA=0x11 (new) + for sa_val in [0x10, 0x11]: + resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, sa_val) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x00' * 8))) + found = j1939_scan_addr_claim(sock, listen_time=0.1, noise_ids={0x10}) + assert 0x10 not in found, "SA=0x10 is noise and must be suppressed" + assert 0x11 in found, "SA=0x11 (not noise) should be found" + cleanup_testsockets() + +test_addr_claim_noise_filtering() + += addr_claim: force=True reports noise SAs +def test_addr_claim_force(): + sock = TestSocket(CAN) + resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, 0x15) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x00' * 8))) + found = j1939_scan_addr_claim(sock, listen_time=0.1, noise_ids={0x15}, force=True) + assert 0x15 in found, "force=True must report SA=0x15 even though it is in noise_ids" + cleanup_testsockets() + +test_addr_claim_force() + + ++ ecu_id – noise filtering + += ecu_id: noise SAs are filtered from broadcast results +def test_ecu_id_noise_filtering(): + sock = TestSocket(CAN) + ecu_pgn_le = _build_request_payload(PGN_ECU_ID) + # SA=0x20 is noise; SA=0x21 is new + for sa_val in [0x20, 0x21]: + bam_can_id = _j1939_can_id(6, J1939_TP_CM_PF, J1939_GLOBAL_ADDRESS, sa_val) + bam_payload = bytes([0x20, 0x0A, 0x00, 0x02, 0xFF]) + ecu_pgn_le + sock.ins.send(bytes(CAN(identifier=bam_can_id, flags="extended", + data=bam_payload))) + found = j1939_scan_ecu_id(sock, listen_time=0.1, noise_ids={0x20}) + assert 0x20 not in found, "SA=0x20 is noise and must be suppressed" + assert 0x21 in found, "SA=0x21 (not noise) should be found" + cleanup_testsockets() + +test_ecu_id_noise_filtering() + += ecu_id: force=True reports noise SAs +def test_ecu_id_force(): + sock = TestSocket(CAN) + ecu_pgn_le = _build_request_payload(PGN_ECU_ID) + bam_can_id = _j1939_can_id(6, J1939_TP_CM_PF, J1939_GLOBAL_ADDRESS, 0x22) + bam_payload = bytes([0x20, 0x0A, 0x00, 0x02, 0xFF]) + ecu_pgn_le + sock.ins.send(bytes(CAN(identifier=bam_can_id, flags="extended", + data=bam_payload))) + found = j1939_scan_ecu_id(sock, listen_time=0.1, noise_ids={0x22}, force=True) + assert 0x22 in found, "force=True must report SA=0x22 even though it is in noise_ids" + cleanup_testsockets() + +test_ecu_id_force() + + ++ rts_probe – noise filtering + += rts_probe: noise SAs are not probed +def test_rts_probe_noise_no_probe(): + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + # noise_ids = {0x50}, scan_range = [0x50, 0x51] + j1939_scan_rts_probe(tx_sock, scan_range=[0x50, 0x51], + noise_ids={0x50}, sniff_time=0.0, + src_addrs=[0xF9]) + pkts = monitor.sniff(count=5, timeout=0.3) + # Only DA=0x51 should have received an RTS probe + assert len(pkts) == 1, "Expected 1 RTS probe (DA=0x51 only), got {}".format(len(pkts)) + _, pf, ps, _ = _j1939_decode_can_id(pkts[0].identifier) + assert pf == J1939_TP_CM_PF + assert ps == 0x51, "RTS probe DA should be 0x51, got 0x{:02X}".format(ps) + cleanup_testsockets() + +test_rts_probe_noise_no_probe() + += rts_probe: force=True probes noise SAs +def test_rts_probe_force(): + sock = TestSocket(CAN) + # CTS from SA=0x60 (which is in noise_ids) directed to 0xF9 + cts_can_id = _j1939_can_id(7, J1939_TP_CM_PF, 0xF9, 0x60) + sock.ins.send(bytes(CAN(identifier=cts_can_id, flags="extended", + data=bytes([TP_CM_CTS, 0x02, 0x01, 0xFF, 0xFF, 0xFF, 0x00, 0x00])))) + found = j1939_scan_rts_probe(sock, scan_range=[0x60], noise_ids={0x60}, + force=True, sniff_time=0.1, src_addrs=[0xF9]) + assert 0x60 in found, "force=True should report SA=0x60 even though it is in noise_ids" + cleanup_testsockets() + +test_rts_probe_force() + + ++ j1939_scan – noise_ids integration + += j1939_scan: explicit noise_ids filters results from all methods +def test_j1939_scan_explicit_noise_ids(): + sock = TestSocket(CAN) + # Inject addr_claim response from SA=0x70 (noise) and SA=0x71 (new) + for sa_val in [0x70, 0x71]: + resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, sa_val) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x00' * 8))) + # Pass explicit noise_ids (bypasses passive pre-scan) + found = j1939_scan(sock, methods=["addr_claim"], broadcast_listen_time=0.1, + noise_ids={0x70}) + assert 0x70 not in found, "SA=0x70 is in explicit noise_ids and must be suppressed" + assert 0x71 in found, "SA=0x71 (not noise) should be found" + cleanup_testsockets() + +test_j1939_scan_explicit_noise_ids() + += j1939_scan: force=True bypasses noise filtering across all methods +def test_j1939_scan_force(): + sock = TestSocket(CAN) + resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, 0x72) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x00' * 8))) + found = j1939_scan(sock, methods=["addr_claim"], broadcast_listen_time=0.1, + noise_ids={0x72}, force=True) + assert 0x72 in found, "force=True must report SA=0x72 even though it is in noise_ids" + cleanup_testsockets() + +test_j1939_scan_force() + += j1939_scan: auto passive pre-scan (noise_listen_time) suppresses pre-existing SAs +def test_j1939_scan_auto_passive(): + # Inject noise frames first (will be consumed by the passive pre-scan) + sock = TestSocket(CAN) + for sa_val in [0x80, 0x81, 0x82]: + noise_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, sa_val) + sock.ins.send(bytes(CAN(identifier=noise_can_id, flags="extended", + data=b'\x00' * 8))) + # Call j1939_scan with a very short noise_listen_time so the passive pre-scan + # reads the pre-injected noise frames; the subsequent active scan sees an empty bus. + found = j1939_scan(sock, methods=["addr_claim"], broadcast_listen_time=0.05, + noise_listen_time=0.05) + # The noise SAs should not appear in results + for sa_val in [0x80, 0x81, 0x82]: + assert sa_val not in found, \ + "SA=0x{:02X} was noise; auto passive should have suppressed it".format(sa_val) + cleanup_testsockets() + +test_j1939_scan_auto_passive() + + ++ j1939_scan – multi-method accumulation + += j1939_scan: SA detected by two methods accumulates both in methods list +def test_multi_method_accumulation(): + import threading + import time + sock = TestSocket(CAN) + _BROADCAST_TIME = 0.05 + _INJECT_OFFSET = 0.02 # inject 20 ms after the broadcast window closes + def inject_unicast_response(): + time.sleep(_BROADCAST_TIME + _INJECT_OFFSET) + # Use Address Claimed directed to 0xF9 + resp_unicast = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, 0xF9, 0x15) + sock.ins.send(bytes(CAN(identifier=resp_unicast, flags="extended", + data=b'\x00' * 8))) + resp_addr_claim = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, + J1939_GLOBAL_ADDRESS, 0x15) + sock.ins.send(bytes(CAN(identifier=resp_addr_claim, flags="extended", + data=b'\x00' * 8))) + t = threading.Thread(target=inject_unicast_response) + t.start() + found = j1939_scan(sock, scan_range=[0x15], + methods=["addr_claim", "unicast"], + broadcast_listen_time=_BROADCAST_TIME, sniff_time=0.1, + noise_ids=set(), src_addrs=[0xF9]) + t.join() + assert 0x15 in found + assert "addr_claim" in found[0x15]["methods"], \ + "addr_claim should be in methods: {}".format(found[0x15]["methods"]) + assert "unicast" in found[0x15]["methods"], \ + "unicast should be in methods: {}".format(found[0x15]["methods"]) + assert found[0x15]["methods"][0] == "addr_claim", \ + "First detection should be addr_claim" + assert len(found[0x15]["packets"]) == 2 + assert isinstance(found[0x15]["packets"][0], list) + assert isinstance(found[0x15]["packets"][1], list) + # Check src_addrs - [[0xF9]] for addr_claim (broadcast), [[0xF9]] for unicast (physical) + assert found[0x15]["src_addrs"] == [[0xF9], [0xF9]], \ + "Expected [[0xF9], [0xF9]], got: {}".format(found[0x15]["src_addrs"]) + cleanup_testsockets() + +test_multi_method_accumulation() + += j1939_scan: methods list has no duplicates when SA detected once +def test_single_detection_methods_list(): + sock = TestSocket(CAN) + resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, 0x16) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x00' * 8))) + found = j1939_scan(sock, methods=["addr_claim"], + broadcast_listen_time=0.1, noise_ids=set()) + assert 0x16 in found + assert found[0x16]["methods"] == ["addr_claim"], \ + "Single detection should give ['addr_claim'], got: {}".format( + found[0x16]["methods"]) + cleanup_testsockets() + +test_single_detection_methods_list() + += j1939_scan: two different SAs detected by different methods have separate lists +def test_two_sas_different_methods(): + import threading + import time + sock = TestSocket(CAN) + _BROADCAST_TIME = 0.05 + _INJECT_OFFSET = 0.02 # inject 20 ms after the broadcast window closes + def inject_unicast_response(): + time.sleep(_BROADCAST_TIME + _INJECT_OFFSET) + # Use Address Claimed directed to 0xF9 + resp18 = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, 0xF9, 0x18) + sock.ins.send(bytes(CAN(identifier=resp18, flags="extended", + data=b'\x00' * 8))) + resp17 = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, 0x17) + sock.ins.send(bytes(CAN(identifier=resp17, flags="extended", + data=b'\x00' * 8))) + t = threading.Thread(target=inject_unicast_response) + t.start() + found = j1939_scan(sock, scan_range=[0x18], + methods=["addr_claim", "unicast"], + broadcast_listen_time=_BROADCAST_TIME, sniff_time=0.1, + noise_ids=set(), src_addrs=[0xF9]) + t.join() + assert 0x17 in found + assert 0x18 in found + assert found[0x17]["methods"] == ["addr_claim"], \ + "SA=0x17 methods: {}".format(found[0x17]["methods"]) + assert found[0x18]["methods"] == ["unicast"], \ + "SA=0x18 methods: {}".format(found[0x18]["methods"]) + cleanup_testsockets() + +test_two_sas_different_methods() + + ++ j1939_scan – src_addrs and packets + += addr_claim: default src_addrs sends one probe per address in J1939_DIAGADAPTERS_ADDRESSES +def test_addr_claim_multi_src_addrs(): + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + j1939_scan_addr_claim(tx_sock, listen_time=0.0) + pkts = monitor.sniff(count=len(J1939_DIAGADAPTERS_ADDRESSES), timeout=0.5) + assert len(pkts) == len(J1939_DIAGADAPTERS_ADDRESSES), \ + "Expected {} probe frames, got {}".format( + len(J1939_DIAGADAPTERS_ADDRESSES), len(pkts)) + sas = [_j1939_decode_can_id(p.identifier)[3] for p in pkts] + assert sorted(sas) == sorted(J1939_DIAGADAPTERS_ADDRESSES), \ + "Probe SAs should be J1939_DIAGADAPTERS_ADDRESSES, got: {}".format( + [hex(s) for s in sas]) + cleanup_testsockets() + +test_addr_claim_multi_src_addrs() + += uds: default src_addrs sends 2*len(J1939_DIAGADAPTERS_ADDRESSES) probes per DA +def test_uds_multi_src_addrs_count(): + # With the new functional then physical scan, if no responses are received: + # 1 broadcast per src_addr (functional) + 1 unicast per src_addr (physical) + # Total = 2 * len(src_addrs) + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + expected = 2 * len(J1939_DIAGADAPTERS_ADDRESSES) + j1939_scan_uds(tx_sock, scan_range=[0x50], sniff_time=0.0, + noise_ids=set()) + pkts = monitor.sniff(count=expected, timeout=0.5) + assert len(pkts) == expected, \ + "Expected {} probes (2 * {} SAs), got {}".format( + expected, len(J1939_DIAGADAPTERS_ADDRESSES), len(pkts)) + probe_sas = {_j1939_decode_can_id(p.identifier)[3] for p in pkts} + assert probe_sas == set(J1939_DIAGADAPTERS_ADDRESSES), \ + "Probe SAs must cover all J1939_DIAGADAPTERS_ADDRESSES" + cleanup_testsockets() + +test_uds_multi_src_addrs_count() + += j1939_scan: packets list is parallel to methods list (one entry per method) +def test_packets_list_parallel_to_methods(): + import threading + import time + sock = TestSocket(CAN) + _BROADCAST_TIME = 0.05 + _INJECT_OFFSET = 0.02 + def inject_unicast_response(): + time.sleep(_BROADCAST_TIME + _INJECT_OFFSET) + # Use Address Claimed directed to 0xF9 + resp = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, 0xF9, 0x19) + sock.ins.send(bytes(CAN(identifier=resp, flags="extended", + data=b'\x00' * 8))) + resp_addr = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, + J1939_GLOBAL_ADDRESS, 0x19) + sock.ins.send(bytes(CAN(identifier=resp_addr, flags="extended", + data=b'\x00' * 8))) + t = threading.Thread(target=inject_unicast_response) + t.start() + found = j1939_scan(sock, scan_range=[0x19], + methods=["addr_claim", "unicast"], + broadcast_listen_time=_BROADCAST_TIME, sniff_time=0.1, + noise_ids=set(), src_addrs=[0xF9]) + t.join() + assert 0x19 in found + methods = found[0x19]["methods"] + packets = found[0x19]["packets"] + assert isinstance(packets, list), "'packets' must be a list" + assert len(packets) == len(methods), \ + "packets and methods must have the same length" + assert methods[0] == "addr_claim" + assert methods[1] == "unicast" + assert isinstance(packets[0], list) + assert isinstance(packets[1], list) + cleanup_testsockets() + +test_packets_list_parallel_to_methods() + + +~ automotive_comm + += _can_frame_bits: DLC=0 -> 67 overhead bits only +assert _can_frame_bits(0) == 67 + += _can_frame_bits: DLC=3 -> 91 bits (3-byte request payload) +assert _can_frame_bits(3) == 91 + += _can_frame_bits: DLC=8 -> 131 bits (8-byte standard CAN frame) +assert _can_frame_bits(8) == 131 + += _J1939_DEFAULT_BITRATE is 250000 and _J1939_DEFAULT_BUSLOAD is 0.05 +assert _J1939_DEFAULT_BITRATE == 250000 +assert _J1939_DEFAULT_BUSLOAD == 0.05 + += _inter_probe_delay: no extra sleep when sniff_time covers the budget +# 250 kbps, 5 % busload, tx=3-byte request (91 bits), rx=8-byte response (131 bits) +# budget_cycle = (91+131) / (250000 * 0.05) = 222 / 12500 = 0.01776 s +# sniff_time=0.1 >> 0.01776 -> delay = 0 +d = _inter_probe_delay(250000, 0.05, 3, 8, 0.1) +assert d == 0.0, "Expected 0, got {}".format(d) + += _inter_probe_delay: positive delay when busload is very low +# 250 kbps, 0.1 % busload, tx=3 bytes, rx=8 bytes, sniff_time=0 +# budget_cycle = 222 / (250000 * 0.001) = 0.888 s +d = _inter_probe_delay(250000, 0.001, 3, 8, 0.0) +expected = (91 + 131) / (250000 * 0.001) +assert abs(d - expected) < 1e-9, "{} != {}".format(d, expected) + += _inter_probe_delay: higher busload yields smaller delay +d_low = _inter_probe_delay(250000, 0.05, 8, 8, 0.0) +d_high = _inter_probe_delay(250000, 0.50, 8, 8, 0.0) +assert d_low > d_high, "Lower busload must give longer delay" + += _inter_probe_delay: raises ValueError for busload <= 0 +def test_pacing_invalid_busload(): + try: + _inter_probe_delay(250000, 0.0, 3, 8, 0.1) + assert False, "Expected ValueError" + except ValueError as e: + assert "busload" in str(e).lower(), str(e) + +test_pacing_invalid_busload() + += _inter_probe_delay: raises ValueError for negative busload +def test_pacing_negative_busload(): + try: + _inter_probe_delay(250000, -0.1, 3, 8, 0.1) + assert False, "Expected ValueError" + except ValueError as e: + assert "busload" in str(e).lower(), str(e) + +test_pacing_negative_busload() + += _inter_probe_delay: busload=1.0 accepted and yields minimal delay +d = _inter_probe_delay(250000, 1.0, 3, 8, 0.0) +expected = (91 + 131) / (250000 * 1.0) +assert abs(d - expected) < 1e-9 + += unicast: bitrate and busload params accepted without error +sock = TestSocket(CAN) +found = j1939_scan_unicast(sock, scan_range=[], bitrate=250000, busload=0.05, + sniff_time=0.02) +assert found == {} +cleanup_testsockets() + += rts_probe: bitrate and busload params accepted without error +sock = TestSocket(CAN) +found = j1939_scan_rts_probe(sock, scan_range=[], bitrate=250000, busload=0.05, + sniff_time=0.02) +assert found == {} +cleanup_testsockets() + += j1939_scan: bitrate and busload params accepted without error +def test_j1939_scan_pacing_params(): + sock = TestSocket(CAN) + found = j1939_scan(sock, methods=["unicast"], scan_range=[], + bitrate=250000, busload=0.05, noise_ids=set(), + sniff_time=0.02) + assert found == {} + cleanup_testsockets() + +test_j1939_scan_pacing_params() + + ++ Technique 5 – UDS TesterPresent Probe + += PGN_DIAG_A is 0xDA00, J1939_PF_DIAG_A is 0xDA; PGN_DIAG_B is 0xDB00, J1939_PF_DIAG_B is 0xDB +assert PGN_DIAG_A == 0xDA00 +assert J1939_PF_DIAG_A == 0xDA +assert PGN_DIAG_B == 0xDB00 +assert J1939_PF_DIAG_B == 0xDB + += uds: sends Functional (broadcast PF|0x01) and Physical (unicast PF) probe frames +def test_uds_probe_frames(): + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + # scan_range=[0x20] with 1 src_addr should send 4 probes total + # Functional: 3E00 and 3E01 to 0xFF (PF=0xDB) + # Physical: 3E00 and 3E01 to 0x20 (PF=0xDA) + j1939_scan_uds(tx_sock, scan_range=[0x20], sniff_time=0.0, + noise_ids=set(), src_addrs=[0xF9]) + pkts = monitor.sniff(count=4, timeout=0.2) + assert len(pkts) == 4, "Expected 4 probe frames, got {}".format(len(pkts)) + + # Check payloads + payloads = [bytes(p.data) for p in pkts] + assert b"\x02\x3e\x00\xff\xff\xff\xff\xff" in payloads + assert b"\x02\x3e\x01\xff\xff\xff\xff\xff" in payloads + + # Check targets + can_ids = [p.identifier for p in pkts] + # Two functional (PF=0xDB, PS=0xFF) + func_can_ids = [cid for cid in can_ids if ((cid >> 8) & 0xFFFF) == (0xDB00 | J1939_GLOBAL_ADDRESS)] + assert len(func_can_ids) == 2 + # Two physical (PF=0xDA, PS=0x20) + phys_can_ids = [cid for cid in can_ids if ((cid >> 8) & 0xFFFF) == (0xDA00 | 0x20)] + assert len(phys_can_ids) == 2 + + cleanup_testsockets() + +test_uds_probe_frames() + += uds: records SA when UDS positive response (02 7E 00) is received (Physical) +def test_uds_positive_response(): + sock = TestSocket(CAN) + # Inject a fake UDS TesterPresent positive response from SA=0x30 + # directed to scanner src_addr=0xF9 + resp_can_id = _j1939_can_id(6, J1939_PF_DIAG_A, 0xF9, 0x30) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x02\x7e\x00' + b'\xff' * 5))) + found = j1939_scan_uds(sock, scan_range=[0x30], sniff_time=0.1, + noise_ids=set(), skip_functional=True, + src_addrs=[0xF9]) + assert 0x30 in found, "Expected SA=0x30, got: {}".format( + [hex(k) for k in found]) + assert isinstance(found[0x30], list) + assert len(found[0x30]) == 1 + cleanup_testsockets() + +test_uds_positive_response() + + += uds: records SA when UDS positive response (02 7E 01) is received (Physical) +def test_uds_positive_response_3e01(): + sock = TestSocket(CAN) + # Inject a fake UDS TesterPresent positive response (3E 01) from SA=0x31 + # directed to scanner src_addr=0xF9 + resp_can_id = _j1939_can_id(6, J1939_PF_DIAG_A, 0xF9, 0x31) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x02\x7e\x01' + b'\xff' * 5))) + found = j1939_scan_uds(sock, scan_range=[0x31], sniff_time=0.1, + noise_ids=set(), skip_functional=True, + src_addrs=[0xF9]) + assert 0x31 in found, "Expected SA=0x31, got: {}".format( + [hex(k) for k in found]) + assert len(found[0x31]) == 1 + cleanup_testsockets() + +test_uds_positive_response_3e01() + + += uds: records SA when UDS negative response (03 7F 3E) is received (Physical) +def test_uds_negative_response(): + sock = TestSocket(CAN) + # Inject a fake UDS TesterPresent negative response (NRC 0x12) from SA=0x32 + # directed to scanner src_addr=0xF9 + resp_can_id = _j1939_can_id(6, J1939_PF_DIAG_A, 0xF9, 0x32) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x03\x7f\x3e\x12' + b'\xff' * 4))) + found = j1939_scan_uds(sock, scan_range=[0x32], sniff_time=0.1, + noise_ids=set(), skip_functional=True, + src_addrs=[0xF9]) + assert 0x32 in found, "Expected SA=0x32 (Negative Response), got: {}".format( + [hex(k) for k in found]) + assert len(found[0x32]) == 1 + cleanup_testsockets() + +test_uds_negative_response() + += uds: records SA when UDS positive response is from Functional PGN (0xDB00) +def test_uds_positive_response_pgn_db(): + sock = TestSocket(CAN) + # Response from SA=0x3A to Functional probe back to scanner SA J1939_DIAGADAPTERS_ADDRESSES[0] + resp_can_id = _j1939_can_id(6, J1939_PF_DIAG_B, J1939_DIAGADAPTERS_ADDRESSES[0], 0x3A) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x02\x7e\x00'))) + found = j1939_scan_uds(sock, scan_range=[0x3A], sniff_time=0.1, + noise_ids=set()) + assert 0x3A in found, \ + "Expected SA=0x3A (Functional response), got: {}".format( + [hex(k) for k in found]) + assert isinstance(found[0x3A], list) + assert len(found[0x3A]) == 1 + cleanup_testsockets() + +test_uds_positive_response_pgn_db() + += uds: both functional and physical responses are captured +def test_uds_both_functional_and_physical(): + import threading + import time + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + # Inject response for functional broadcast (PF=0xDB, DA=0xFF) from SA=0x55 + # back to scanner SA 0xF9 + resp_f = _j1939_can_id(6, J1939_PF_DIAG_B, 0xF9, 0x55) + tx_sock.ins.send(bytes(CAN(identifier=resp_f, flags="extended", + data=b'\x02\x7e\x00'))) + + # Inject response for physical unicast (PF=0xDA, DA=0x55) in a thread + # so it arrives during the physical scan phase + def inject_physical(): + time.sleep(0.2) # wait for functional scan to start/finish + resp_p = _j1939_can_id(6, J1939_PF_DIAG_A, 0xF9, 0x55) + tx_sock.ins.send(bytes(CAN(identifier=resp_p, flags="extended", + data=b'\x02\x7e\x00'))) + + t = threading.Thread(target=inject_physical) + t.start() + + found = j1939_scan_uds(tx_sock, scan_range=[0x55], sniff_time=0.1, + broadcast_listen_time=0.1, + noise_ids=set(), src_addrs=[0xF9]) + t.join() + assert 0x55 in found + # Should have captured both responses + assert len(found[0x55]) == 2 + # Check PFs + pfs = {_j1939_decode_can_id(p.identifier)[1] for p in found[0x55]} + assert pfs == {J1939_PF_DIAG_A, J1939_PF_DIAG_B} + cleanup_testsockets() + +test_uds_both_functional_and_physical() + += uds: skip_functional=True avoids broadcast probes +def test_uds_skip_functional(): + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + j1939_scan_uds(tx_sock, scan_range=[0x20], sniff_time=0.0, + skip_functional=True, + noise_ids=set(), src_addrs=[0xF9]) + pkts = monitor.sniff(count=10, timeout=0.2) + # Should only see 2 probe frames (the physical unicast ones for 3E00 and 3E01) + assert len(pkts) == 2, "Expected 2 probes (physical), got {}".format(len(pkts)) + for p in pkts: + _, pf, ps, _ = _j1939_decode_can_id(p.identifier) + assert pf == J1939_PF_DIAG_A + cleanup_testsockets() + +test_uds_skip_functional() + += uds: custom diag_pgn uses expected PF and PF|0x01 +def test_uds_custom_diag_pgn(): + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + custom_pgn = 0xD0 + j1939_scan_uds(tx_sock, scan_range=[0x20], sniff_time=0.0, + diag_pgn=custom_pgn, + noise_ids=set(), src_addrs=[0xF9]) + pkts = monitor.sniff(count=4, timeout=0.2) + assert len(pkts) == 4 + + # Functional probes + func_pfs = [_j1939_decode_can_id(p.identifier)[1] for p in pkts[:2]] + assert all(pf == custom_pgn | 0x01 for pf in func_pfs) + # Physical probes + phys_pfs = [_j1939_decode_can_id(p.identifier)[1] for p in pkts[2:]] + assert all(pf == custom_pgn for pf in phys_pfs) + cleanup_testsockets() + +test_uds_custom_diag_pgn() + += uds: ignores frames with wrong UDS response SID +def test_uds_wrong_response_ignored(): + sock = TestSocket(CAN) + # Inject a frame from SA=0x31 with incorrect UDS response bytes + # back to scanner SA J1939_DIAGADAPTERS_ADDRESSES[0] + resp_can_id = _j1939_can_id(6, J1939_PF_DIAG_A, J1939_DIAGADAPTERS_ADDRESSES[0], 0x31) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x02\x3e\x00'))) # request, not response + found = j1939_scan_uds(sock, scan_range=[0x31], sniff_time=0.1, + noise_ids=set()) + assert 0x31 not in found, \ + "SA=0x31 returned wrong payload and must NOT be recorded" + cleanup_testsockets() + +test_uds_wrong_response_ignored() + += uds: noise_ids suppresses probing +def test_uds_noise_suppression(): + sock = TestSocket(CAN) + # back to scanner SA J1939_DIAGADAPTERS_ADDRESSES[0] + resp_can_id = _j1939_can_id(6, J1939_PF_DIAG_A, J1939_DIAGADAPTERS_ADDRESSES[0], 0x32) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x02\x7e\x00'))) + # SA=0x32 in noise_ids and force=False -> must be skipped + found = j1939_scan_uds(sock, scan_range=[0x32], sniff_time=0.1, + noise_ids={0x32}, force=False, skip_functional=True) + assert 0x32 not in found, \ + "SA=0x32 is in noise_ids and must be suppressed" + cleanup_testsockets() + +test_uds_noise_suppression() + += uds: force=True bypasses noise_ids +def test_uds_force(): + sock = TestSocket(CAN) + # back to scanner SA J1939_DIAGADAPTERS_ADDRESSES[0] + resp_can_id = _j1939_can_id(6, J1939_PF_DIAG_A, J1939_DIAGADAPTERS_ADDRESSES[0], 0x33) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x02\x7e\x00'))) + found = j1939_scan_uds(sock, scan_range=[0x33], sniff_time=0.1, + noise_ids={0x33}, force=True, skip_functional=True) + assert 0x33 in found, "force=True must probe SA=0x33 despite noise_ids" + cleanup_testsockets() + +test_uds_force() + += uds: multiple SAs with positive responses are all captured +def test_uds_multiple_responses(): + import threading + import time + sock = TestSocket(CAN) + _SCAN_DAS = [0x34, 0x35, 0x36] + _SNIFF_TIME = 0.1 + _MIDPOINT_FACTOR = 0.5 # inject at midpoint of each sniff window + def inject_responses(): + t_start = time.time() + for i, da in enumerate(_SCAN_DAS): + target = t_start + (i + _MIDPOINT_FACTOR) * _SNIFF_TIME + remaining = target - time.time() + if remaining > 0: + time.sleep(remaining) + # back to scanner SA 0xF9 + resp_can_id = _j1939_can_id(6, J1939_PF_DIAG_A, + 0xF9, da) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x02\x7e\x00'))) + t = threading.Thread(target=inject_responses) + t.start() + found = j1939_scan_uds(sock, scan_range=_SCAN_DAS, + sniff_time=_SNIFF_TIME, noise_ids=set(), + src_addrs=[0xF9], + skip_functional=True) + t.join() + for sa_val in _SCAN_DAS: + assert sa_val in found, \ + "Expected SA=0x{:02X} in results, got {}".format( + sa_val, [hex(k) for k in found]) + cleanup_testsockets() + +test_uds_multiple_responses() + += uds: stop_event aborts scan early +def test_uds_stop_event(): + from threading import Event + sock = TestSocket(CAN) + stop = Event() + stop.set() + found = j1939_scan_uds(sock, scan_range=range(0x00, 0xFF), + sniff_time=0.0, stop_event=stop, skip_functional=True) + assert found == {}, "stop_event set: no probes should be sent" + cleanup_testsockets() + +test_uds_stop_event() + += j1939_scan: uds technique is invoked and finds UDS-responding CAs +def test_j1939_scan_uds_technique(): + sock = TestSocket(CAN) + # back to scanner SA J1939_DIAGADAPTERS_ADDRESSES[0] + resp_can_id = _j1939_can_id(6, J1939_PF_DIAG_A, + J1939_DIAGADAPTERS_ADDRESSES[0], 0x40) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x02\x7e\x00'))) + found = j1939_scan(sock, methods=["uds"], scan_range=[0x40], + sniff_time=0.1, noise_ids=set(), skip_functional=True) + assert 0x40 in found, \ + "SA=0x40 should be found by uds method, got: {}".format( + [hex(k) for k in found]) + assert found[0x40]["methods"] == ["uds"], \ + "methods should be ['uds'], got: {}".format(found[0x40]["methods"]) + assert isinstance(found[0x40]["packets"], list) + assert len(found[0x40]["packets"]) == 1 + assert isinstance(found[0x40]["packets"][0], list) + cleanup_testsockets() + +test_j1939_scan_uds_technique() + += j1939_scan: SA found by addr_claim and uds accumulates both methods and packets +def test_j1939_scan_addr_claim_and_uds(): + import threading + import time + sock = TestSocket(CAN) + _BROADCAST_TIME = 0.05 + _INJECT_OFFSET = 0.02 # inject 20 ms after the broadcast window closes + def inject_uds_response(): + time.sleep(_BROADCAST_TIME + _INJECT_OFFSET) + # back to scanner SA 0xF9 + resp_uds = _j1939_can_id(6, J1939_PF_DIAG_A, + 0xF9, 0x41) + sock.ins.send(bytes(CAN(identifier=resp_uds, flags="extended", + data=b'\x02\x7e\x00'))) + resp_addr_claim = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, + J1939_GLOBAL_ADDRESS, 0x41) + sock.ins.send(bytes(CAN(identifier=resp_addr_claim, flags="extended", + data=b'\x00' * 8))) + t = threading.Thread(target=inject_uds_response) + t.start() + found = j1939_scan(sock, methods=["addr_claim", "uds"], + scan_range=[0x41], + broadcast_listen_time=_BROADCAST_TIME, sniff_time=0.1, + noise_ids=set(), src_addrs=[0xF9], + skip_functional=True) + t.join() + assert 0x41 in found + assert "addr_claim" in found[0x41]["methods"] + assert "uds" in found[0x41]["methods"] + assert found[0x41]["methods"][0] == "addr_claim" + assert len(found[0x41]["packets"]) == 2, \ + "Two methods found SA=0x41; packets list must have 2 entries" + assert isinstance(found[0x41]["packets"][0], list) + assert isinstance(found[0x41]["packets"][1], list) + # Check src_addrs - [[0xF9]] for addr_claim (broadcast), [[0xF9]] for unicast (physical) + assert found[0x41]["src_addrs"] == [[0xF9], [0xF9]], \ + "Expected [[0xF9], [0xF9]], got: {}".format(found[0x41]["src_addrs"]) + cleanup_testsockets() + +test_j1939_scan_addr_claim_and_uds() + += uds: captures all responding scanner source addresses in range +def test_uds_captures_all_responding_src_addrs(): + sock = TestSocket(CAN) + _TARGET_DA = 0x45 + _SRC_ADDRS = list(range(0xF1, 0xFB)) + for sa in _SRC_ADDRS: + resp_can_id = _j1939_can_id(6, J1939_PF_DIAG_A, sa, _TARGET_DA) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x02\x7e\x00'))) + found = j1939_scan_uds(sock, scan_range=[_TARGET_DA], + src_addrs=range(0xF1, 0xFB), + sniff_time=0.2, noise_ids=set(), + skip_functional=True) + assert _TARGET_DA in found + captured_sas = sorted({ + _j1939_decode_can_id(p.identifier)[2] for p in found[_TARGET_DA] + }) + assert captured_sas == _SRC_ADDRS, \ + "Expected all SAs 0xF1..0xFA, got: {}".format([hex(x) for x in captured_sas]) + cleanup_testsockets() + +test_uds_captures_all_responding_src_addrs() + += j1939_scan: uds method discovers full range of responding scanner source addresses +def test_j1939_scan_uds_range_src_addrs(): + sock = TestSocket(CAN) + _TARGET_DA = 0x46 + _SRC_ADDRS = list(range(0xF1, 0xFB)) + for sa in _SRC_ADDRS: + resp_can_id = _j1939_can_id(6, J1939_PF_DIAG_A, sa, _TARGET_DA) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x02\x7e\x00'))) + found = j1939_scan(sock, methods=["uds"], scan_range=[_TARGET_DA], + src_addrs=range(0xF1, 0xFB), + sniff_time=0.2, noise_ids=set(), skip_functional=True) + assert _TARGET_DA in found, "Target DA should be found" + assert "uds" in found[_TARGET_DA]["methods"] + discovered_src_addrs = found[_TARGET_DA]["src_addrs"][0] + assert sorted(discovered_src_addrs) == _SRC_ADDRS, \ + "Expected all SAs 0xF1..0xFA in src_addrs, got: {}".format( + [hex(x) for x in discovered_src_addrs]) + cleanup_testsockets() + +test_j1939_scan_uds_range_src_addrs() + += j1939_scan: bitrate is read from socket.bitrate attribute when available +def test_j1939_scan_bitrate_from_socket(): + sock = TestSocket(CAN) + # Attach a bitrate attribute to the socket to simulate a CANSocket + sock.bitrate = 500000 + found = j1939_scan(sock, methods=["unicast"], scan_range=[], + noise_ids=set(), sniff_time=0.02) + assert found == {} + cleanup_testsockets() + +test_j1939_scan_bitrate_from_socket() + += uds: bitrate and busload params accepted without error +sock = TestSocket(CAN) +found = j1939_scan_uds(sock, scan_range=[], bitrate=250000, busload=0.05, + sniff_time=0.02) +assert found == {} +cleanup_testsockets() + ++ XCP scanner tests + += xcp: _XCP_CONNECT_REQ has correct format (command byte 0xFF, mode 0x00, padded) +assert _XCP_CONNECT_REQ == b'\xff\x00\xff\xff\xff\xff\xff\xff' +assert len(_XCP_CONNECT_REQ) == 8 +assert _XCP_CONNECT_REQ[0] == 0xFF +assert _XCP_CONNECT_REQ[1] == 0x00 +assert _XCP_POSITIVE_RESPONSE == 0xFF + += xcp: probe frame sent with correct PF and DA +def test_xcp_probe_frames(): + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + j1939_scan_xcp(tx_sock, scan_range=[0x20], sniff_time=0.0, + noise_ids=set(), src_addrs=[0xF9]) + pkts = monitor.sniff(count=1, timeout=0.2) + assert len(pkts) == 1, \ + "Expected 1 XCP probe frame, got {}".format(len(pkts)) + _, pf, ps, sa = _j1939_decode_can_id(pkts[0].identifier) + assert pf == J1939_PF_XCP, \ + "XCP probe should use Physical PF=0xEF, got 0x{:02X}".format(pf) + assert ps == 0x20, \ + "DA should be 0x20, got 0x{:02X}".format(ps) + assert bytes(pkts[0].data) == _XCP_CONNECT_REQ, \ + "XCP probe payload mismatch" + cleanup_testsockets() + +test_xcp_probe_frames() + += xcp: records SA when XCP positive response (byte 0 == 0xFF) is received +def test_xcp_positive_response(): + sock = TestSocket(CAN) + # ECU at SA=0x35 responds back to scanner SA J1939_XCP_SRC_ADDRS[0] + resp_can_id = _j1939_can_id(6, J1939_PF_XCP, J1939_XCP_SRC_ADDRS[0], 0x35) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\xff\x00\x00\x00\x00\x00\x00\x00'))) + found = j1939_scan_xcp(sock, scan_range=[0x35], sniff_time=0.1, + noise_ids=set()) + assert 0x35 in found, \ + "Expected SA=0x35 via XCP, got: {}".format([hex(k) for k in found]) + assert isinstance(found[0x35], list) + assert len(found[0x35]) == 1 + cleanup_testsockets() + +test_xcp_positive_response() + += xcp: ignores frames where byte 0 is not 0xFF (not a positive response) +def test_xcp_wrong_response_ignored(): + sock = TestSocket(CAN) + # 0xFE = XCP negative response (ERR_*) back to scanner SA J1939_XCP_SRC_ADDRS[0] + resp_can_id = _j1939_can_id(6, J1939_PF_XCP, J1939_XCP_SRC_ADDRS[0], 0x36) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\xfe\x10\x00\x00\x00\x00\x00\x00'))) + found = j1939_scan_xcp(sock, scan_range=[0x36], sniff_time=0.1, + noise_ids=set()) + assert 0x36 not in found, \ + "SA=0x36 returned XCP negative response and must NOT be recorded" + cleanup_testsockets() + +test_xcp_wrong_response_ignored() + += xcp: ignores echoes of CONNECT probes +sock = TestSocket(CAN) +# Simulated echo: CONNECT from 0xF1 to 0xF1 (PF=0xEF, PS=0xF1, SA=0xF1) +# This frame has sa == _da if we are probing 0xF1, and ps in src_addrs, +# and data[0] == 0xFF. It must be ignored. +echo_can_id = _j1939_can_id(6, J1939_PF_XCP, 0xF1, 0xF1) +sock.ins.send(bytes(CAN(identifier=echo_can_id, flags="extended", + data=_XCP_CONNECT_REQ))) +found = j1939_scan_xcp(sock, scan_range=[0xF1], sniff_time=0.1, src_addrs=[0xF1]) +assert 0xF1 not in found, "Scanner must ignore echoes of its own XCP CONNECT probes" +cleanup_testsockets() + += xcp: noise_ids suppresses probing +def test_xcp_noise_suppression(): + sock = TestSocket(CAN) + # Response back to scanner SA J1939_XCP_SRC_ADDRS[0] + resp_can_id = _j1939_can_id(6, J1939_PF_XCP, J1939_XCP_SRC_ADDRS[0], 0x37) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\xff\x00\x00\x00\x00\x00\x00\x00'))) + found = j1939_scan_xcp(sock, scan_range=[0x37], sniff_time=0.1, + noise_ids={0x37}) + assert 0x37 not in found, \ + "SA=0x37 is in noise_ids and must be suppressed" + cleanup_testsockets() + +test_xcp_noise_suppression() + += xcp: custom diag_pgn uses that PF for probes +def test_xcp_custom_diag_pgn(): + with TestSocket(CAN) as tx_sock, TestSocket(CAN) as monitor: + tx_sock.pair(monitor) + custom_pgn = 0xEF + j1939_scan_xcp(tx_sock, scan_range=[0x20], sniff_time=0.0, + diag_pgn=custom_pgn, + noise_ids=set(), src_addrs=[0xF9]) + pkts = monitor.sniff(count=1, timeout=0.2) + assert len(pkts) == 1 + _, pf, _, _ = _j1939_decode_can_id(pkts[0].identifier) + assert pf == custom_pgn, \ + "Expected PF=0x{:02X}, got 0x{:02X}".format(custom_pgn, pf) + cleanup_testsockets() + +test_xcp_custom_diag_pgn() + += j1939_scan: xcp technique is invoked and finds XCP-responding CAs +def test_j1939_scan_xcp_technique(): + sock = TestSocket(CAN) + # Response simulates ECU 0x42 answering back to scanner SA 0xF9 + resp_can_id = _j1939_can_id(6, J1939_PF_XCP, + 0xF9, 0x42) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\xff\x00\x00\x00\x00\x00\x00\x00'))) + found = j1939_scan(sock, methods=["xcp"], scan_range=[0x42], + sniff_time=0.1, noise_ids=set(), + src_addrs=[0xF9]) + assert 0x42 in found, \ + "SA=0x42 should be found by xcp method, got: {}".format( + [hex(k) for k in found]) + assert found[0x42]["methods"] == ["xcp"], \ + "methods should be ['xcp'], got: {}".format(found[0x42]["methods"]) + assert isinstance(found[0x42]["packets"], list) + assert len(found[0x42]["packets"]) == 1 + assert isinstance(found[0x42]["packets"][0], list) + assert "src_addrs" in found[0x42], "'src_addrs' key missing from result" + cleanup_testsockets() + +test_j1939_scan_xcp_technique() + += j1939_scan: src_addrs key present in results for all techniques +def test_j1939_scan_src_addrs_key_present(): + sock = TestSocket(CAN) + resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, + J1939_GLOBAL_ADDRESS, 0x43) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x00' * 8))) + found = j1939_scan(sock, methods=["addr_claim"], + broadcast_listen_time=0.1, noise_ids=set()) + assert 0x43 in found + assert "src_addrs" in found[0x43], "'src_addrs' key missing from result" + assert len(found[0x43]["src_addrs"]) == len(found[0x43]["methods"]) + cleanup_testsockets() + +test_j1939_scan_src_addrs_key_present() + += j1939_scan: uds result carries scanner src_addr that produced the response +def test_j1939_scan_uds_src_addr_recorded(): + sock = TestSocket(CAN) + # UDS response from ECU 0x44 back to scanner src_addr=0xF1 + resp_can_id = _j1939_can_id(6, J1939_PF_DIAG_A, 0xF1, 0x44) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x02\x7e\x00'))) + found = j1939_scan(sock, methods=["uds"], scan_range=[0x44], + sniff_time=0.1, noise_ids=set(), skip_functional=True, + src_addrs=[0xF1]) + assert 0x44 in found + uds_idx = found[0x44]["methods"].index("uds") + assert found[0x44]["src_addrs"][uds_idx] == [0xF1], \ + "Expected src_addr=[0xF1], got: 0x{:02X}".format( + found[0x44]["src_addrs"][uds_idx]) + cleanup_testsockets() + +test_j1939_scan_uds_src_addr_recorded() + += j1939_scan: xcp result carries scanner src_addr that produced the response +def test_j1939_scan_xcp_src_addr_recorded(): + sock = TestSocket(CAN) + # XCP response from ECU 0x45 back to scanner src_addr=0xB5 + resp_can_id = _j1939_can_id(6, J1939_PF_XCP, 0xB5, 0x45) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\xff\x00\x00\x00\x00\x00\x00\x00'))) + found = j1939_scan(sock, methods=["xcp"], scan_range=[0x45], + sniff_time=0.1, noise_ids=set(), + src_addrs=[0xB5]) + assert 0x45 in found + xcp_idx = found[0x45]["methods"].index("xcp") + assert found[0x45]["src_addrs"][xcp_idx] == [0xB5], \ + "Expected src_addr=[0xB5], got: {}".format( + found[0x45]["src_addrs"][xcp_idx]) + cleanup_testsockets() + +test_j1939_scan_xcp_src_addr_recorded() + += j1939_scan: xcp result carries successful scanner src_addr +def test_j1939_scan_xcp_multi_src_addr_recorded(): + sock = TestSocket(CAN) + # ECU at SA=0x45 responds back to scanner SA 0xB5 + resp1 = _j1939_can_id(6, J1939_PF_XCP, 0xB5, 0x45) + sock.ins.send(bytes(CAN(identifier=resp1, flags="extended", + data=b'\xff\x00\x00\x00\x00\x00\x00\x00'))) + # ECU at SA=0x45 also responds back to scanner SA 0x3F + resp2 = _j1939_can_id(6, J1939_PF_XCP, 0x3F, 0x45) + sock.ins.send(bytes(CAN(identifier=resp2, flags="extended", + data=b'\xff\x00\x00\x00\x00\x00\x00\x00'))) + found = j1939_scan(sock, methods=["xcp"], scan_range=[0x45], + sniff_time=0.1, noise_ids=set(), + src_addrs=[0xB5, 0x3F]) + assert 0x45 in found + xcp_idx = found[0x45]["methods"].index("xcp") + src_addrs_result = found[0x45]["src_addrs"][xcp_idx] + assert isinstance(src_addrs_result, list), "src_addrs entry should be a list" + # stop_filter exits after the first response so at least one SA is captured + assert len(src_addrs_result) >= 1, "Expected at least 1 src_addr, got {}".format(src_addrs_result) + assert src_addrs_result[0] in (0xB5, 0x3F), "Expected 0xB5 or 0x3F, got 0x{:02X}".format(src_addrs_result[0]) + cleanup_testsockets() + +test_j1939_scan_xcp_multi_src_addr_recorded() + += j1939_scan: mock ECU responds UDS from SA 0xF1 and XCP from SA 0xB5 on PGN 0xEF/DA 0x33 +def test_j1939_scan_uds_and_xcp_src_addr_discrimination(): + sock = TestSocket(CAN) + _CUSTOM_PGN = 0xEF + _TARGET_DA = 0x33 + _UDS_SRC = 0xF1 + _XCP_SRC = 0xB5 + # Pre-inject UDS response: ECU at DA=0x33 responds to UDS from SA=0xF1 + uds_resp_id = _j1939_can_id(6, _CUSTOM_PGN, _UDS_SRC, _TARGET_DA) + sock.ins.send(bytes(CAN(identifier=uds_resp_id, flags="extended", + data=b'\x02\x7e\x00'))) + # Pre-inject XCP response: ECU at DA=0x33 responds to XCP from SA=0xB5. + # The UDS sniff exits early (stop_filter) after reading the UDS response, + # leaving the XCP response in the buffer for the XCP scan. + xcp_resp_id = _j1939_can_id(6, _CUSTOM_PGN, _XCP_SRC, _TARGET_DA) + sock.ins.send(bytes(CAN(identifier=xcp_resp_id, flags="extended", + data=b'\xff\x00\x00\x00\x00\x00\x00\x00'))) + found = j1939_scan( + sock, + methods=["uds", "xcp"], + scan_range=[_TARGET_DA], + src_addrs=[_UDS_SRC, _XCP_SRC], + sniff_time=0.1, + noise_ids=set(), + skip_functional=True, + diag_pgn=_CUSTOM_PGN, + ) + assert _TARGET_DA in found, \ + "ECU at DA=0x{:02X} not found, got: {}".format( + _TARGET_DA, [hex(k) for k in found]) + assert "uds" in found[_TARGET_DA]["methods"], \ + "UDS method missing from result" + assert "xcp" in found[_TARGET_DA]["methods"], \ + "XCP method missing from result" + uds_idx = found[_TARGET_DA]["methods"].index("uds") + xcp_idx = found[_TARGET_DA]["methods"].index("xcp") + assert found[_TARGET_DA]["src_addrs"][uds_idx] == [_UDS_SRC], \ + "UDS: expected scanner SA=[0x{:02X}], got: {}".format( + _UDS_SRC, found[_TARGET_DA]["src_addrs"][uds_idx]) + assert found[_TARGET_DA]["src_addrs"][xcp_idx] == [_XCP_SRC], \ + "XCP: expected scanner SA=[0x{:02X}], got: {}".format( + _XCP_SRC, found[_TARGET_DA]["src_addrs"][xcp_idx]) + cleanup_testsockets() + +test_j1939_scan_uds_and_xcp_src_addr_discrimination() + += xcp: bitrate and busload params accepted without error +sock = TestSocket(CAN) +found = j1939_scan_xcp(sock, scan_range=[], bitrate=250000, busload=0.05, + sniff_time=0.02) +assert found == {} +cleanup_testsockets() + + ++ Send-then-sniff race condition regression tests +~ conf + += unicast: immediate ECU reply is captured (sniff-before-send regression) +def test_unicast_immediate_reply(): + import threading + with TestSocket(CAN) as sock, TestSocket(CAN) as monitor: + sock.pair(monitor) + def simulate_ecu(): + while True: + pkts = monitor.sniff(count=1, timeout=1.0) + if not pkts: + break + p = pkts[0] + _, pf, ps, sa = _j1939_decode_can_id(p.identifier) + if pf == J1939_PF_REQUEST: + resp_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, ps) + sock.ins.send(bytes(CAN(identifier=resp_id, flags="extended", data=b'\x00' * 8))) + t = threading.Thread(target=simulate_ecu) + t.start() + found = j1939_scan_unicast(sock, scan_range=[0x42], sniff_time=0.3, src_addrs=[0xF9]) + t.join(timeout=2.0) + assert 0x42 in found, "Immediate ECU reply must be captured, got: {}".format([hex(k) for k in found]) + cleanup_testsockets() + +test_unicast_immediate_reply() + += rts_probe: immediate ECU reply is captured (sniff-before-send regression) +def test_rts_probe_immediate_reply(): + import threading + with TestSocket(CAN) as sock, TestSocket(CAN) as monitor: + sock.pair(monitor) + def simulate_ecu(): + while True: + pkts = monitor.sniff(count=1, timeout=1.0) + if not pkts: + break + p = pkts[0] + _, pf, ps, sa = _j1939_decode_can_id(p.identifier) + if pf == J1939_TP_CM_PF: + cts_data = bytes([TP_CM_CTS, 0x01, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) + resp_id = _j1939_can_id(7, J1939_TP_CM_PF, sa, ps) + sock.ins.send(bytes(CAN(identifier=resp_id, flags="extended", data=cts_data))) + t = threading.Thread(target=simulate_ecu) + t.start() + found = j1939_scan_rts_probe(sock, scan_range=[0x42], sniff_time=0.3, src_addrs=[0xF9]) + t.join(timeout=2.0) + assert 0x42 in found, "Immediate CTS reply must be captured, got: {}".format([hex(k) for k in found]) + cleanup_testsockets() + +test_rts_probe_immediate_reply() + += uds: immediate ECU reply is captured (sniff-before-send regression) +def test_uds_immediate_reply(): + import threading + with TestSocket(CAN) as sock, TestSocket(CAN) as monitor: + sock.pair(monitor) + def simulate_ecu(): + while True: + pkts = monitor.sniff(count=1, timeout=1.0) + if not pkts: + break + p = pkts[0] + _, pf, ps, sa = _j1939_decode_can_id(p.identifier) + if pf == J1939_PF_DIAG_A and ps != J1939_GLOBAL_ADDRESS: + resp_data = b'\x02\x7e\x00' + b'\xff' * 5 + resp_id = _j1939_can_id(6, J1939_PF_DIAG_A, sa, ps) + sock.ins.send(bytes(CAN(identifier=resp_id, flags="extended", data=resp_data))) + t = threading.Thread(target=simulate_ecu) + t.start() + found = j1939_scan_uds(sock, scan_range=[0x42], sniff_time=0.3, + skip_functional=True, src_addrs=[0xF9]) + t.join(timeout=2.0) + assert 0x42 in found, "Immediate UDS reply must be captured, got: {}".format([hex(k) for k in found]) + cleanup_testsockets() + +test_uds_immediate_reply() + += xcp: immediate ECU reply is captured (sniff-before-send regression) +def test_xcp_immediate_reply(): + import threading + with TestSocket(CAN) as sock, TestSocket(CAN) as monitor: + sock.pair(monitor) + def simulate_ecu(): + while True: + pkts = monitor.sniff(count=1, timeout=1.0) + if not pkts: + break + p = pkts[0] + _, pf, ps, sa = _j1939_decode_can_id(p.identifier) + if pf == J1939_PF_XCP: + resp_data = bytes([_XCP_POSITIVE_RESPONSE]) + b'\x00' * 7 + resp_id = _j1939_can_id(6, J1939_PF_XCP, sa, ps) + sock.ins.send(bytes(CAN(identifier=resp_id, flags="extended", data=resp_data))) + t = threading.Thread(target=simulate_ecu) + t.start() + found = j1939_scan_xcp(sock, scan_range=[0x42], sniff_time=0.3, + src_addrs=[0x3F]) + t.join(timeout=2.0) + assert 0x42 in found, "Immediate XCP reply must be captured, got: {}".format([hex(k) for k in found]) + cleanup_testsockets() + +test_xcp_immediate_reply() + + ++ Early exit (stop_filter) regression tests +~ conf + += unicast: sniff exits early when response found (stop_filter) +def test_unicast_early_exit(): + import time + sock = TestSocket(CAN) + resp_can_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, 0x42) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", data=b'\x00' * 8))) + t0 = time.monotonic() + found = j1939_scan_unicast(sock, scan_range=[0x42], sniff_time=5.0, src_addrs=[0xF9]) + elapsed = time.monotonic() - t0 + assert 0x42 in found, "Expected SA=0x42, got: {}".format([hex(k) for k in found]) + assert elapsed < 2.0, "Sniff should exit early, took {:.1f}s (max 2.0s)".format(elapsed) + cleanup_testsockets() + +test_unicast_early_exit() + += rts_probe: sniff exits early when response found (stop_filter) +def test_rts_probe_early_exit(): + import time + sock = TestSocket(CAN) + cts_data = bytes([TP_CM_CTS, 0x01, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) + resp_can_id = _j1939_can_id(7, J1939_TP_CM_PF, 0xF9, 0x42) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", data=cts_data))) + t0 = time.monotonic() + found = j1939_scan_rts_probe(sock, scan_range=[0x42], sniff_time=5.0, src_addrs=[0xF9]) + elapsed = time.monotonic() - t0 + assert 0x42 in found, "Expected SA=0x42, got: {}".format([hex(k) for k in found]) + assert elapsed < 2.0, "Sniff should exit early, took {:.1f}s (max 2.0s)".format(elapsed) + cleanup_testsockets() + +test_rts_probe_early_exit() + += uds: sniff exits early when response found (stop_filter) +def test_uds_early_exit(): + import time + sock = TestSocket(CAN) + resp_can_id = _j1939_can_id(6, J1939_PF_DIAG_A, 0xF9, 0x42) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=b'\x02\x7e\x00' + b'\xff' * 5))) + t0 = time.monotonic() + found = j1939_scan_uds(sock, scan_range=[0x42], sniff_time=5.0, + skip_functional=True, src_addrs=[0xF9]) + elapsed = time.monotonic() - t0 + assert 0x42 in found, "Expected SA=0x42, got: {}".format([hex(k) for k in found]) + assert elapsed < 2.0, "Sniff should exit early, took {:.1f}s (max 2.0s)".format(elapsed) + cleanup_testsockets() + +test_uds_early_exit() + += xcp: sniff exits early when response found (stop_filter) +def test_xcp_early_exit(): + import time + sock = TestSocket(CAN) + resp_can_id = _j1939_can_id(6, J1939_PF_XCP, 0x3F, 0x42) + sock.ins.send(bytes(CAN(identifier=resp_can_id, flags="extended", + data=bytes([_XCP_POSITIVE_RESPONSE]) + b'\x00' * 7))) + t0 = time.monotonic() + found = j1939_scan_xcp(sock, scan_range=[0x42], sniff_time=5.0, src_addrs=[0x3F]) + elapsed = time.monotonic() - t0 + assert 0x42 in found, "Expected SA=0x42, got: {}".format([hex(k) for k in found]) + assert elapsed < 2.0, "Sniff should exit early, took {:.1f}s (max 2.0s)".format(elapsed) + cleanup_testsockets() + +test_xcp_early_exit() + += unicast: multiple DAs found despite stale traffic (kernel buffer flush) +~ slow_test +def test_unicast_stale_frames(): + import time + sock = SlowTestSocket(CAN, frame_delay=0.0002, mux_throttle=0.001) + target_das = [0x10, 0x20, 0x30] + for da in target_das: + stale_id = _j1939_can_id(6, 0xFE, 0x00, 0xEE) + for _ in range(30): + with sock._serial_lock: + sock._serial_buffer.append( + bytes(CAN(identifier=stale_id, flags="extended", data=b'\xCC' * 8)) + ) + resp_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, da) + with sock._serial_lock: + sock._serial_buffer.append( + bytes(CAN(identifier=resp_id, flags="extended", data=b'\x00' * 8)) + ) + found = j1939_scan_unicast(sock, scan_range=target_das, + src_addrs=[0xF9], sniff_time=2.0) + found_das = [da for da in target_das if da in found] + assert len(found_das) == len(target_das), \ + "Expected all DAs found despite stale traffic, got: {}".format( + [hex(d) for d in found_das]) + cleanup_testsockets() + +test_unicast_stale_frames() + + +# Socketcan filter helpers + += _j1939_sa_filter: returns correct socketcan filter for target SA + +f = _j1939_sa_filter(0x42) +assert len(f) == 1 +assert f[0]["can_id"] == 0x80000042, "got 0x{:08X}".format(f[0]["can_id"]) +assert f[0]["can_mask"] == 0x800000FF, "got 0x{:08X}".format(f[0]["can_mask"]) + += _j1939_sa_filter: edge cases SA=0x00 and SA=0xFF + +f0 = _j1939_sa_filter(0x00) +assert f0[0]["can_id"] == 0x80000000 +assert f0[0]["can_mask"] == 0x800000FF +fFF = _j1939_sa_filter(0xFF) +assert fFF[0]["can_id"] == 0x800000FF +assert fFF[0]["can_mask"] == 0x800000FF + += _open_sa_filtered_sock: falls back to original socket for non-NativeCANSocket + +sock = TestSocket(CAN) +rx_sock, close_rx = _open_sa_filtered_sock(sock, 0x42) +assert rx_sock is sock, "Expected fallback to original socket" +assert close_rx is False, "Expected close_rx=False for fallback" +cleanup_testsockets() + += _resolve_probe_sock: falls back for test sockets (non-NativeCANSocket) + +sock = TestSocket(CAN) +send_sock, rx_sock, close_rx = _resolve_probe_sock(sock, 0x42) +assert send_sock is sock, "Expected send_sock is original" +assert rx_sock is sock, "Expected rx_sock is original (fallback)" +assert close_rx is False +cleanup_testsockets() + += _resolve_probe_sock: callable creates a per-probe socket + +def _factory(): + return TestSocket(CAN) + +send_sock, rx_sock, close_rx = _resolve_probe_sock(_factory, 0x42) +assert close_rx is True, "Expected close_rx=True for factory-created socket" +assert send_sock is rx_sock, "Factory path should use same socket for send and receive" +rx_sock.close() +cleanup_testsockets() + += _resolve_broadcast_sock: falls back for test sockets + +sock = TestSocket(CAN) +active_sock, close_sock = _resolve_broadcast_sock(sock) +assert active_sock is sock +assert close_sock is False +cleanup_testsockets() + += _resolve_broadcast_sock: callable creates a socket + +def _factory(): + return TestSocket(CAN) + +active_sock, close_sock = _resolve_broadcast_sock(_factory) +assert close_sock is True +active_sock.close() +cleanup_testsockets() + + +# Factory (reconnect) API + += unicast: callable factory produces same results as direct socket +def test_unicast_factory(): + def _factory(): + return TestSocket(CAN) + sock = _factory() + resp_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, 0x42) + sock.ins.send(bytes(CAN(identifier=resp_id, flags="extended", data=b'\x00' * 8))) + found_direct = j1939_scan_unicast(sock, scan_range=[0x42], + src_addrs=[0xF9], sniff_time=0.1) + cleanup_testsockets() + factory_sock = _factory() + factory_sock.ins.send(bytes(CAN(identifier=resp_id, flags="extended", data=b'\x00' * 8))) + def _factory_with_data(): + return factory_sock + found_factory = j1939_scan_unicast(_factory_with_data, scan_range=[0x42], + src_addrs=[0xF9], sniff_time=0.1) + assert set(found_direct.keys()) == set(found_factory.keys()), \ + "Factory should find same SAs: direct={} factory={}".format( + list(found_direct.keys()), list(found_factory.keys())) + cleanup_testsockets() + +test_unicast_factory() + += dm_pgn: callable factory works for DM scanner +def test_dm_pgn_factory(): + from scapy.contrib.automotive.j1939.j1939_dm_scanner import ( + j1939_scan_dm_pgn, J1939_DM_PGNS, + ) + pgn = J1939_DM_PGNS["DM1"] + dm1_pf = (pgn >> 8) & 0xFF + dm1_ps = pgn & 0xFF + sock = TestSocket(CAN) + resp_id = _j1939_can_id(6, dm1_pf, dm1_ps, 0x42) + sock.ins.send(bytes(CAN(identifier=resp_id, flags="extended", data=b'\x00' * 8))) + def _factory(): + return sock + result = j1939_scan_dm_pgn(_factory, target_da=0x42, pgn=pgn, + dm_name="DM1", sniff_time=0.5) + assert result.supported, "Factory should find DM1 supported" + assert result.dm_name == "DM1" + cleanup_testsockets() + +test_dm_pgn_factory() + += rts_probe: callable factory works for RTS probe scanner +def test_rts_factory(): + sock = TestSocket(CAN) + cts_id = _j1939_can_id(7, J1939_TP_CM_PF, 0xF9, 0x42) + sock.ins.send(bytes(CAN(identifier=cts_id, flags="extended", + data=bytes([TP_CM_CTS, 1, 1, 0xFF, 0xFF, 0x00, 0x00, 0xFF])))) + def _factory(): + return sock + found = j1939_scan_rts_probe(_factory, scan_range=[0x42], + src_addrs=[0xF9], sniff_time=0.5) + assert 0x42 in found, "Factory RTS probe should find DA=0x42" + cleanup_testsockets() + +test_rts_factory() + += addr_claim: callable factory works for broadcast scan +def test_addr_claim_factory(): + sock = TestSocket(CAN) + resp_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, 0x42) + sock.ins.send(bytes(CAN(identifier=resp_id, flags="extended", data=b'\x00' * 8))) + def _factory(): + return sock + found = j1939_scan_addr_claim(_factory, src_addrs=[0xF9], listen_time=0.5) + assert 0x42 in found, "Factory addr_claim should find SA=0x42" + cleanup_testsockets() + +test_addr_claim_factory() + + +# output_format parameter + += j1939_scan: output_format=None returns raw dict (default) +sock = TestSocket(CAN) +resp_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, 0x42) +sock.ins.send(bytes(CAN(identifier=resp_id, flags="extended", data=b'\x00' * 8))) +found = j1939_scan(sock, methods=["addr_claim"], src_addrs=[0xF9], + broadcast_listen_time=0.1, noise_ids=set()) +assert isinstance(found, dict), "Default output should be dict" +assert 0x42 in found +cleanup_testsockets() + += j1939_scan: output_format="text" returns string with SA info +sock = TestSocket(CAN) +resp_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, 0x42) +sock.ins.send(bytes(CAN(identifier=resp_id, flags="extended", data=b'\x00' * 8))) +found = j1939_scan(sock, methods=["addr_claim"], src_addrs=[0xF9], + broadcast_listen_time=0.1, noise_ids=set(), + output_format="text") +assert isinstance(found, str), "text output should be str" +assert "0x42" in found.lower() or "0x42" in found, "SA should appear in text" +assert "addr_claim" in found, "method name should appear in text" +cleanup_testsockets() + += j1939_scan: output_format="json" returns valid JSON string +import json as _json +sock = TestSocket(CAN) +resp_id = _j1939_can_id(6, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, 0x42) +sock.ins.send(bytes(CAN(identifier=resp_id, flags="extended", data=b'\x00' * 8))) +found = j1939_scan(sock, methods=["addr_claim"], src_addrs=[0xF9], + broadcast_listen_time=0.1, noise_ids=set(), + output_format="json") +assert isinstance(found, str), "json output should be str" +parsed = _json.loads(found) +assert isinstance(parsed, list) +assert len(parsed) == 1 +assert parsed[0]["sa"] == 0x42 +assert parsed[0]["methods"] == ["addr_claim"] +cleanup_testsockets() + += j1939_scan: output_format="text" with empty results +sock = TestSocket(CAN) +found = j1939_scan(sock, methods=["addr_claim"], src_addrs=[0xF9], + broadcast_listen_time=0.02, noise_ids=set(), + output_format="text") +assert isinstance(found, str) +assert "No J1939" in found +cleanup_testsockets() + += j1939_scan: output_format="json" with empty results +sock = TestSocket(CAN) +found = j1939_scan(sock, methods=["addr_claim"], src_addrs=[0xF9], + broadcast_listen_time=0.02, noise_ids=set(), + output_format="json") +assert isinstance(found, str) +parsed = _json.loads(found) +assert parsed == [] +cleanup_testsockets() + += _generate_text_output: formats multiple SAs +results = { + 0x10: {"methods": ["unicast", "rts_probe"], + "packets": [[], []], "src_addrs": [[0xF1], [0xF1]]}, + 0x20: {"methods": ["addr_claim"], + "packets": [[]], "src_addrs": [[]]}, +} +text = _generate_text_output(results) +assert "Found 2" in text +assert "0x10" in text.lower() or "0x10" in text +assert "0x20" in text.lower() or "0x20" in text +assert "unicast" in text +assert "rts_probe" in text +assert "addr_claim" in text + += _generate_json_output: contains SA, methods, src_addrs +results = { + 0x10: {"methods": ["unicast"], + "packets": [[]], "src_addrs": [[0xF1]]}, +} +j = _generate_json_output(results) +parsed = _json.loads(j) +assert len(parsed) == 1 +assert parsed[0]["sa"] == 0x10 +assert parsed[0]["methods"] == ["unicast"] +assert parsed[0]["src_addrs"] == [[0xF1]] + + +# verbose log level control + += j1939_scan: verbose=True sets log_j1939 to DEBUG +def test_verbose_debug(): + import logging + from scapy.contrib.automotive.j1939 import log_j1939 + old_level = log_j1939.level + sock = TestSocket(CAN) + j1939_scan(sock, methods=["addr_claim"], src_addrs=[0xF9], + broadcast_listen_time=0.02, noise_ids=set(), + verbose=True) + assert log_j1939.level == logging.DEBUG, "Expected DEBUG(10), got {}".format(log_j1939.level) + log_j1939.setLevel(old_level) + cleanup_testsockets() + +test_verbose_debug() + += j1939_scan: verbose=False does not change log level +def test_verbose_false(): + import logging + from scapy.contrib.automotive.j1939 import log_j1939 + old_level = log_j1939.level + log_j1939.setLevel(logging.WARNING) + sock = TestSocket(CAN) + j1939_scan(sock, methods=["addr_claim"], src_addrs=[0xF9], + broadcast_listen_time=0.02, noise_ids=set(), + verbose=False) + assert log_j1939.level == logging.WARNING, "Expected WARNING(30), got {}".format(log_j1939.level) + log_j1939.setLevel(old_level) + cleanup_testsockets() + +test_verbose_false() \ No newline at end of file diff --git a/tox.ini b/tox.ini index 495672a8e9d..d162e80c949 100644 --- a/tox.ini +++ b/tox.ini @@ -181,6 +181,7 @@ per-file-ignores = scapy/contrib/automotive/obd/pid/pids.py:F405,F403 scapy/contrib/automotive/obd/scanner.py:F405,F403,E501 scapy/contrib/automotive/volkswagen/definitions.py:E501 + scapy/contrib/automotive/j1939/j1939_scanner.py:E501 scapy/contrib/eigrp.py:E501 scapy/contrib/geneve.py:E501 scapy/contrib/http2.py:F821 From 9917b2cfaee4f2303defbd4674751e3b13714752 Mon Sep 17 00:00:00 2001 From: Ben Gardiner Date: Wed, 2 Sep 2026 15:02:46 -0400 Subject: [PATCH 17/18] automotive: J1939 package __init__ integration with scapy.contrib.j1939 --- scapy/contrib/automotive/j1939/__init__.py | 136 +++++++++++++++++++++ tox.ini | 1 + 2 files changed, 137 insertions(+) create mode 100644 scapy/contrib/automotive/j1939/__init__.py diff --git a/scapy/contrib/automotive/j1939/__init__.py b/scapy/contrib/automotive/j1939/__init__.py new file mode 100644 index 00000000000..a970588965f --- /dev/null +++ b/scapy/contrib/automotive/j1939/__init__.py @@ -0,0 +1,136 @@ +# SPDX-License-Identifier: GPL-2.0-only +# This file is part of Scapy +# See https://scapy.net/ for more information +# Copyright (C) National Motor Freight Traffic Association Inc. +# + +# scapy.contrib.description = SAE J1939 (SAE J1939-21) Transport Layer Socket & Diagnostics +# scapy.contrib.status = loads + +from scapy.consts import LINUX +from scapy.config import conf + +from scapy.contrib.j1939 import ( + J1939, + J1939_CAN, + J1939SoftSocket, + NativeJ1939Socket, + J1939TPImplementation, + J1939_BROADCAST_ADDR, + J1939_PGN_TP_CM, + J1939_PGN_TP_DT, + J1939_TP_CTRL_RTS, + J1939_TP_CTRL_CTS, + J1939_TP_CTRL_ACK, + J1939_TP_CTRL_BAM, + J1939_TP_CTRL_ABORT, + can_id_to_j1939, + j1939_to_can_id, + pgn_from_fields, + dst_from_fields, + pgn_is_pdu1, + log_j1939, +) + +J1939_GLOBAL_ADDRESS = J1939_BROADCAST_ADDR +J1939_NULL_ADDRESS = 0xFE +PGN_ADDRESS_CLAIMED = 0xEE00 +PGN_REQUEST = 0xEA00 +J1939_PF_ADDRESS_CLAIMED = 0xEE +J1939_PF_REQUEST = 0xEA +TP_CM_RTS = J1939_TP_CTRL_RTS +TP_CM_CTS = J1939_TP_CTRL_CTS +TP_CM_EndOfMsgACK = J1939_TP_CTRL_ACK +TP_CM_BAM = J1939_TP_CTRL_BAM +TP_Conn_Abort = J1939_TP_CTRL_ABORT + +from scapy.contrib.automotive.j1939.j1939_dm import ( + J1939_DTC, + J1939_DM1, + J1939_DM13, + J1939_DM14, + PGN_DM1, + PGN_DM13, + PGN_DM14, + sniff_dm1, + send_dm14_request, +) + +from scapy.contrib.automotive.j1939.j1939_scanner import ( + _j1939_can_id, + _j1939_decode_can_id, + J1939_TP_CM_PF, + j1939_scan, + j1939_scan_passive, + j1939_scan_addr_claim, + j1939_scan_ecu_id, + j1939_scan_unicast, + j1939_scan_rts_probe, + j1939_scan_uds, + j1939_scan_xcp, + J1939_DIAGADAPTERS_ADDRESSES, + J1939_XCP_SRC_ADDRS, + PGN_ECU_ID, + PGN_DIAG_A, + J1939_PF_DIAG_A, + PGN_DIAG_B, + J1939_PF_DIAG_B, + J1939_PF_XCP, + SCAN_METHODS, +) + +from scapy.contrib.automotive.j1939.j1939_dm_scanner import ( + DmScanResult, + J1939_DM_PGNS, + J1939_PF_ACK, + PGN_ACK, + j1939_scan_dm, + j1939_scan_dm_pgn, +) + +J1939Socket = J1939SoftSocket + +__all__ = [ + 'J1939', + 'J1939_CAN', + 'J1939SoftSocket', + 'NativeJ1939Socket', + 'J1939TPImplementation', + 'J1939Socket', + 'J1939_BROADCAST_ADDR', + 'J1939_GLOBAL_ADDRESS', + 'J1939_NULL_ADDRESS', + 'log_j1939', + 'J1939_DTC', + 'J1939_DM1', + 'J1939_DM13', + 'J1939_DM14', + 'PGN_DM1', + 'PGN_DM13', + 'PGN_DM14', + 'sniff_dm1', + 'send_dm14_request', + 'j1939_scan', + 'j1939_scan_passive', + 'j1939_scan_addr_claim', + 'j1939_scan_ecu_id', + 'j1939_scan_unicast', + 'j1939_scan_rts_probe', + 'j1939_scan_uds', + 'j1939_scan_xcp', + 'J1939_DIAGADAPTERS_ADDRESSES', + 'J1939_XCP_SRC_ADDRS', + 'PGN_ECU_ID', + 'PGN_DIAG_A', + 'J1939_PF_DIAG_A', + 'PGN_DIAG_B', + 'J1939_PF_DIAG_B', + 'J1939_PF_XCP', + 'SCAN_METHODS', + 'DmScanResult', + 'J1939_DM_PGNS', + 'J1939_PF_ACK', + 'PGN_ACK', + 'j1939_scan_dm', + 'j1939_scan_dm_pgn', +] diff --git a/tox.ini b/tox.ini index d162e80c949..e392d41f0ca 100644 --- a/tox.ini +++ b/tox.ini @@ -182,6 +182,7 @@ per-file-ignores = scapy/contrib/automotive/obd/scanner.py:F405,F403,E501 scapy/contrib/automotive/volkswagen/definitions.py:E501 scapy/contrib/automotive/j1939/j1939_scanner.py:E501 + scapy/contrib/automotive/j1939/__init__.py:F401,E402,E501 scapy/contrib/eigrp.py:E501 scapy/contrib/geneve.py:E501 scapy/contrib/http2.py:F821 From 93ffd77b82cc048c65da657ffe498b52445ee751 Mon Sep 17 00:00:00 2001 From: Ben Gardiner Date: Tue, 8 Sep 2026 22:40:14 -0400 Subject: [PATCH 18/18] automotive: J1939 64-bit NAME decoder and protocol support (J1939-81) --- scapy/contrib/automotive/j1939/__init__.py | 26 + scapy/contrib/automotive/j1939/j1939_name.py | 736 +++++++++++++++++++ 2 files changed, 762 insertions(+) create mode 100644 scapy/contrib/automotive/j1939/j1939_name.py diff --git a/scapy/contrib/automotive/j1939/__init__.py b/scapy/contrib/automotive/j1939/__init__.py index a970588965f..678bf71e7b4 100644 --- a/scapy/contrib/automotive/j1939/__init__.py +++ b/scapy/contrib/automotive/j1939/__init__.py @@ -88,9 +88,35 @@ j1939_scan_dm_pgn, ) + +from scapy.contrib.automotive.j1939.j1939_name import ( + INDUSTRY_GROUPS, + PRE_ASSIGNED_FUNCTIONS, + INDUSTRY_SPECIFIC_FUNCTIONS, + INDUSTRY_SPECIFIC_VEHICLE_SYSTEMS, + MANUFACTURERS, + J1939NameDecoder, + J1939_NAME, + decode_j1939_name, + simulate_arbitration, + j1939_request_name, + j1939_request_names, +) + J1939Socket = J1939SoftSocket __all__ = [ + 'INDUSTRY_GROUPS', + 'PRE_ASSIGNED_FUNCTIONS', + 'INDUSTRY_SPECIFIC_FUNCTIONS', + 'INDUSTRY_SPECIFIC_VEHICLE_SYSTEMS', + 'MANUFACTURERS', + 'J1939NameDecoder', + 'J1939_NAME', + 'decode_j1939_name', + 'simulate_arbitration', + 'j1939_request_name', + 'j1939_request_names', 'J1939', 'J1939_CAN', 'J1939SoftSocket', diff --git a/scapy/contrib/automotive/j1939/j1939_name.py b/scapy/contrib/automotive/j1939/j1939_name.py new file mode 100644 index 00000000000..8062f0a8b26 --- /dev/null +++ b/scapy/contrib/automotive/j1939/j1939_name.py @@ -0,0 +1,736 @@ +# SPDX-License-Identifier: GPL-2.0-only +# This file is part of Scapy +# See https://scapy.net/ for more information +# Copyright (C) National Motor Freight Traffic Association Inc. +# + +# scapy.contrib.description = SAE J1939 64-bit NAME Decoder (J1939-81) +# scapy.contrib.status = loads + +""" +SAE J1939 NAME Protocol & Decoder (J1939-81). + +This module decodes the 64-bit (8-byte) J1939 NAME used in Address Claiming +and Network Management (PGN 60928, 0xEE00). According to SAE J1939-81 §4.2.1, +the 64-bit NAME is transmitted in CAN data bytes 1 through 8, with Byte 1 +being the Least Significant Byte (LSB, Identity Number LSB) and Byte 8 being +the Most Significant Byte (MSB, Arbitrary Address Capable, Industry Group, etc.). + +Key Architecture Rules: +- Function values 0 to 127 are lower 128 pre-assigned functions defined by SAE J1939 (SPN 2841). + These values are strictly INDEPENDENT of Vehicle System and Industry Group, + and apply universally across all 8 Industry Groups (0-7). +- Function values 128 to 253 are Industry Group / Vehicle System dependent. + +It maps the extracted bit fields to standard registries such as Industry Groups, +Pre-defined and Industry-Specific Functions, Vehicle Systems, and Manufacturer Codes. + +It provides: +- Scapy packet class ``J1939_NAME`` +- Functional decoder ``J1939NameDecoder`` +- Address arbitration simulator ``simulate_arbitration`` +- Active scanning helpers ``j1939_request_name`` and ``j1939_request_names`` +""" + +import struct +import time +from typing import ( + Any, + Dict, + Iterable, + List, + Optional, + Tuple, + Union, +) + +from scapy.fields import BitField +from scapy.layers.can import CAN +from scapy.packet import Packet +from scapy.contrib.j1939 import ( + J1939_BROADCAST_ADDR, + log_j1939, +) +from scapy.contrib.automotive.j1939.j1939_scanner import ( + _CAN_EXTENDED_FLAG, + _J1939_DEFAULT_BITRATE, + _J1939_DEFAULT_BUSLOAD, + _build_request_payload, + _inter_probe_delay, + _j1939_can_id, + _j1939_decode_can_id, + _pre_probe_flush, + _resolve_broadcast_sock, + _resolve_probe_sock, + J1939_GLOBAL_ADDRESS, + J1939_PF_ADDRESS_CLAIMED, + J1939_PF_REQUEST, + PGN_ADDRESS_CLAIMED, + SockOrFactory, +) + + +# --------------------------------------------------------------------------- +# J1939 Standard Registries +# --------------------------------------------------------------------------- + +INDUSTRY_GROUPS = { + 0: "Global / Common across industries", + 1: "On-Highway Equipment (Trucks, Buses, Coaches)", + 2: "Agricultural and Forestry Equipment", + 3: "Construction Equipment", + 4: "Marine Equipment", + 5: "Industrial, Process Control, Stationary Equipment", + 6: "Fleet Management Systems", + 7: "Reserved for future SAE assignment", +} + +# Standard pre-assigned functions (Lower 128 values: 0 to 127) +# These values are universal and independent of Industry Group and Vehicle System. +""" +================================================================================ +OPEN-SOURCE & PUBLIC DATABASE REFERENCES FOR J1939 NAME FUNCTIONS (SPN 2841: 0-127) +================================================================================ +Public Standards & Specifications: + - ISO 11783-7: Tractors and machinery for agriculture and forestry - + Implement messages application layer (Public ISOBUS Data Dictionary) + +Permissive / Open-Source Implementations & Registries: + 1. Linux Kernel SocketCAN Subsystem (include/uapi/linux/can/j1939.h) + - License: Dual GPL-2.0 / MIT + - Reference: Native kernel header defining name_t struct layout and address claiming + + 2. Open-SAE-J1939 C Stack (https://github.com/DanielMartensson/Open-SAE-J1939) + - License: MIT License + - Reference: Embedded C stack for address claiming, PGN/SPN handling & ISO 11783-7 + + 3. jackm/j1939decode C Library (https://github.com/jackm/j1939decode) + - License: MIT License + - Reference: C-based decoder utilizing J1939db.json for PGN, SPN 2841, and SA lookups + + 4. famez/J1939-Framework (https://github.com/famez/J1939-Framework) + - License: MIT License + - Reference: C++ frame parsing, Wireshark dissectors, and address claim simulation + + 5. andrewdodd/decoda (https://github.com/andrewdodd/decoda) + - License: MIT License + - Reference: Python spec conversion and application payload decoding library + + 6. CSS Electronics CAN Decoder API & ISOBUS DBC (https://github.com/CSS-Electronics/can_decoder) + - License: MIT License + - Reference: Python API for parsing raw CAN frames against open DBC databases +""" +PRE_ASSIGNED_FUNCTIONS = { + 0: "Engine", + 1: "Auxiliary Power Unit (APU)", + 2: "Electric Propulsion Control", + 3: "Transmission", + 4: "Battery Pack Monitor", + 5: "Shift Control/Console", + 6: "Power TakeOff - (Main or Rear)", + 7: "Axle - Steering", + 8: "Axle - Drive", + 9: "Brakes - System Controller", + 10: "", + 11: "Brakes - Drive axle", + 12: "Retarder - Engine", + 13: "Retarder - Driveline", + 14: "Cruise Control", + 15: "Fuel System", + 16: "Steering Controller", + 17: "Suspension - Steer Axle", + 18: "Suspension - Drive Axle", + 19: "Instrument Cluster", + 20: "Trip Recorder", + 21: "Cab Climate Control", + 22: "Aerodynamic Control", + 23: "Vehicle Navigation", + 24: "Vehicle Security", + 25: "Network Interconnect ECU", + 26: "Body Controller", + 27: "Power TakeOff (Secondary or Front)", + 28: "Off Vehicle Gateway", + 29: "Virtual Terminal (in cab)", + 30: "Management Computer", + 31: "Propulsion Battery Charger", + 32: "Headway Controller", + 33: "System Monitor", + 34: "Hydraulic Pump Controller", + 35: "Suspension - System Controller", + 36: "Pneumatic - System Controller", + 37: "Cab Controller", + 38: "Tire Pressure Control", + 39: "Ignition Control Module", + 40: "Seat Control", + 41: "Lighting - Operator Controls", + 42: "Water Pump Control", + 43: "Transmission Display", + 44: "Exhaust Emission Control", + 45: "Vehicle Dynamic Stability Control", + 46: "Oil Sensor Unit", + 47: "Information System Controller", + 48: "Ramp Control", + 49: "Clutch/Converter Control", + 50: "Auxiliary Heater", + 51: "Forward-Looking Collision Warning System", + 52: "Chassis Controller", + 53: "Alternator/Charging System", + 54: "Communications Unit, Cellular", + 55: "Communications Unit, Satellite", + 56: "Communications Unit, Radio", + 57: "Steering Column Unit", + 58: "Fan Drive Control", + 59: "Starter", + 60: "Cab Display", + 61: "File Server / Printer", + 62: "On-Board Diagnostic Unit", + 63: "Engine Valve Controller", + 64: "Endurance Braking", + 65: "Gas Flow Measurement", + 66: "I/O Controller", + 67: "Electrical System Controller", + 68: "Aftertreatment system gas measurement", + 69: "Engine Emission Aftertreatment System", + 70: "Auxiliary Regeneration Device", + 71: "Transfer Case Control", + 72: "Coolant Valve Controller", + 73: "Rollover Detection Control", + 74: "Lubrication System", + 75: "Supplemental Fan", + 76: "Temperature Sensor", + 77: "Fuel Properties Sensor", + 78: "Fire Suppression System", + 79: "Power Systems Manager", + 80: "Electric Powertrain", + 81: "Hydraulic Powertrain", +} + +# Populate default descriptions for reserved lower 128 function values (85 through 127) +for _f in range(85, 128): + PRE_ASSIGNED_FUNCTIONS.setdefault(_f, f"Pre-Assigned / Reserved Function ({_f})") + +# Add standard sentinel states +PRE_ASSIGNED_FUNCTIONS[254] = "Error State" +PRE_ASSIGNED_FUNCTIONS[255] = "Not Available State" + +# Industry group specific function mappings (values 128 to 253) +INDUSTRY_SPECIFIC_FUNCTIONS = { + 1: { # On-Highway Equipment + 130: "Cab Display / Operator Interface (On-Highway Specific)", + 131: "Tachograph Device", + 132: "Vehicle Gateway Interface", + }, + 2: { # Agricultural and Forestry Equipment (ISOBUS / ISO 11783) + 130: "Task Controller / Mapping Computer (ISO 11783-10)", + 135: "Virtual Terminal (ISO 11783-6)", + 140: "Implement / Working Set Master", + 141: "Auxiliary Valve Control / Implement Bridge", + }, + 3: { # Construction Equipment + 130: "Grade Control System", + 131: "Payload Scale System", + }, + 4: { # Marine Equipment (NMEA 2000) + 130: "Autopilot / Heading Control", + 131: "Radar / Sonar System", + 132: "VHF Radio / Communications", + }, +} + +# Industry group specific vehicle system mappings (values 0 to 127) +INDUSTRY_SPECIFIC_VEHICLE_SYSTEMS = { + 1: { # On-Highway Equipment + 1: "Tractor", + 2: "Trailer", + 3: "Public Transit Bus", + 4: "Specialized Construction Support Vehicle", + }, + 2: { # Agricultural and Forestry Equipment + 1: "Agricultural Tractor", + 2: "Tillage Implement", + 3: "Planter / Seeder Implement", + 4: "Fertilizer Implement", + 5: "Sprayer Implement", + 6: "Combine Harvester", + 7: "Forage Harvester", + }, + 3: { # Construction Equipment + 1: "Crawler Dozer", + 2: "Wheel Loader", + 3: "Hydraulic Excavator", + 4: "Off-Highway Haul Truck", + }, + 4: { # Marine Equipment + 1: "Vessel System", + 2: "Engine Room Monitor", + 3: "Bridge Navigation Display", + }, +} + +# Industry-assigned or common manufacturer codes (11 bits) +MANUFACTURERS = { + 8: "Caterpillar Inc.", + 15: "Cummins Inc.", + 35: "Detroit Diesel Corporation", + 49: "Deere & Company (John Deere)", + 88: "Eaton Corporation", + 117: "Allison Transmission", + 140: "Volvo Powertrain Corporation", + 154: "Bendix Commercial Vehicle Systems", + 161: "Volvo Lastvagnar AB", + 174: "Wabco Vehicle Control Systems", + 184: "PACCAR Inc.", + 275: "Detroit Diesel", + 345: "Robert Bosch GmbH", +} + + +# --------------------------------------------------------------------------- +# Decoder class & helpers +# --------------------------------------------------------------------------- + +class J1939NameDecoder: + """Decoder and report formatter for the 64-bit SAE J1939 NAME.""" + + @staticmethod + def decode(payload: Union[bytes, bytearray, int, str, Packet]) -> Dict[str, Any]: + """Extract bitfields and map registries from a 64-bit J1939 NAME. + + The payload bytes are read Least Significant Byte (LSB) first as + transmitted in J1939 Address Claimed (PGN 60928) CAN frames: + Byte 1 (LSB) is bits 0-7, up to Byte 8 (MSB) which is bits 56-63. + + :param payload: 8-byte LE bytes/bytearray, 64-bit int, hex string, + or CAN/J1939/J1939_NAME packet. + :returns: dictionary containing all raw bitfields and mapped descriptions. + """ + if isinstance(payload, str): + clean_hex = payload.strip().lower().replace(" ", "") + if clean_hex.startswith("0x"): + clean_hex = clean_hex[2:] + payload_bytes = bytes.fromhex(clean_hex) + elif isinstance(payload, int): + # 64-bit integer packed in Little Endian (LSB at byte 0) + payload_bytes = struct.pack("= 8: + payload_bytes = raw_bytes[:8] + else: + payload_bytes = raw_bytes + else: + payload_bytes = bytes(payload) + + if len(payload_bytes) != 8: + raise ValueError(f"Payload must be exactly 8 bytes, got {len(payload_bytes)} bytes") + + # Unpack as an unsigned 64-bit integer in little-endian format (LSB first) + name_val = struct.unpack("> 21) & 0x7FF # Bits 21-31 (11 bits) + ecu_inst = (name_val >> 32) & 0x07 # Bits 32-34 (3 bits) + func_inst = (name_val >> 35) & 0x1F # Bits 35-39 (5 bits) + func = (name_val >> 40) & 0xFF # Bits 40-47 (8 bits) + reserved = (name_val >> 48) & 0x01 # Bit 48 (1 bit) + vehicle_sys = (name_val >> 49) & 0x7F # Bits 49-55 (7 bits) + vehicle_sys_inst = (name_val >> 56) & 0x0F # Bits 56-59 (4 bits) + industry_group = (name_val >> 60) & 0x07 # Bits 60-62 (3 bits) + arbitrary_addr_capable = (name_val >> 63) & 0x01 # Bit 63 (1 bit) + + # Lookups + ig_desc = INDUSTRY_GROUPS.get( + industry_group, f"Unknown / Proprietary Industry Group ({industry_group})" + ) + mfg_desc = MANUFACTURERS.get(mfg_code, f"Unknown Manufacturer (Code: {mfg_code})") + + vs_table = INDUSTRY_SPECIFIC_VEHICLE_SYSTEMS.get(industry_group, {}) + vehicle_sys_desc = vs_table.get( + vehicle_sys, + f"Industry Group Specific Vehicle System {vehicle_sys}" + if industry_group != 0 + else f"Common Vehicle System {vehicle_sys}", + ) + + # Function description lookup + # RULE: Lower 128 function values (0 to 127) are PRE-ASSIGNED and strictly + # INDEPENDENT of Industry Group or Vehicle System. They apply to all 8 Industry Groups. + if func <= 127: + # Function values 0 to 127 are pre-assigned and strictly + # independent of Vehicle System or Industry Group across all 8 Industry Groups + func_desc = PRE_ASSIGNED_FUNCTIONS.get(func, f"Pre-Assigned / Reserved Function ({func})") + elif 128 <= func <= 253: + # Values 128-253 are dependent on Industry Group and Vehicle System + func_table = INDUSTRY_SPECIFIC_FUNCTIONS.get(industry_group, {}) + func_desc = func_table.get( + func, + f"Industry Group Specific Function {func} (No table entry for Industry Group {industry_group})" + if not func_table + else f"Industry Group Specific Function {func} (Not mapped in Industry Group {industry_group} table)", + ) + elif func == 254: + func_desc = "Error State" + elif func == 255: + func_desc = "Not Available State" + else: + func_desc = f"Reserved State ({func})" + + return { + "raw_value": name_val, + "arbitrary_address_capable": arbitrary_addr_capable, + "industry_group": industry_group, + "industry_group_description": ig_desc, + "vehicle_system_instance": vehicle_sys_inst, + "vehicle_system": vehicle_sys, + "vehicle_system_description": vehicle_sys_desc, + "reserved": reserved, + "function": func, + "function_description": func_desc, + "function_instance": func_inst, + "ecu_instance": ecu_inst, + "manufacturer_code": mfg_code, + "manufacturer_name": mfg_desc, + "identity_number": identity_num, + } + + @staticmethod + def format_report(info: Dict[str, Any]) -> str: + """Format decoded fields as a structured console report.""" + lines = [ + "=" * 60, + " SAE J1939 NAME DECODER REPORT", + "=" * 60, + f"Raw 64-bit Integer : 0x{info['raw_value']:016X} ({info['raw_value']})", + "-" * 60, + f"Arbitrary Addr Cap : {info['arbitrary_address_capable']} ({'Yes' if info['arbitrary_address_capable'] else 'No'})", + f"Industry Group : {info['industry_group']} - {info['industry_group_description']}", + f"Vehicle Sys Inst : {info['vehicle_system_instance']}", + f"Vehicle System : {info['vehicle_system']} - {info['vehicle_system_description']}", + f"Reserved Bit : {info['reserved']} (Should be 0)", + f"Function : {info['function']} - {info['function_description']}", + f"Function Instance : {info['function_instance']}", + f"ECU Instance : {info['ecu_instance']}", + f"Manufacturer Code : {info['manufacturer_code']} - {info['manufacturer_name']}", + f"Identity Number : {info['identity_number']}", + "=" * 60, + ] + return "\n".join(lines) + + +def decode_j1939_name(payload: Union[bytes, bytearray, int, str, Packet]) -> Dict[str, Any]: + """Convenience function to decode a 64-bit J1939 NAME payload.""" + return J1939NameDecoder.decode(payload) + + +def simulate_arbitration(name1: Any, name2: Any) -> Dict[str, Any]: + """Simulate J1939 address claim arbitration between two ECUs. + + In J1939, when two ECUs claim the same address, the ECU with the lower + numerical 64-bit NAME value wins arbitration and retains the address. + + :param name1: NAME of ECU A (hex str, bytes, int, or J1939_NAME) + :param name2: NAME of ECU B (hex str, bytes, int, or J1939_NAME) + :returns: dict with arbitration results (winner, loser, winner_name, loser_name) + """ + dec1 = J1939NameDecoder.decode(name1) + dec2 = J1939NameDecoder.decode(name2) + val1 = dec1["raw_value"] + val2 = dec2["raw_value"] + + print("\n" + "#" * 60) + print(" SAE J1939 ADDRESS ARBITRATION SIMULATOR") + print("#" * 60) + print(f"ECU A: NAME = 0x{val1:016X} (Function: {dec1['function_description']}, Identity: {dec1['identity_number']})") + print(f"ECU B: NAME = 0x{val2:016X} (Function: {dec2['function_description']}, Identity: {dec2['identity_number']})") + print("-" * 60) + + if val1 == val2: + print("CRITICAL COLLISION: Both NAMEs are mathematically identical!") + print("This is a protocol violation. ECUs must have unique identity numbers.") + print("#" * 60 + "\n") + return {"winner": None, "loser": None, "collision": True} + + if val1 < val2: + winner, loser = "ECU A", "ECU B" + win_dec, lose_dec = dec1, dec2 + win_val, lose_val = val1, val2 + else: + winner, loser = "ECU B", "ECU A" + win_dec, lose_dec = dec2, dec1 + win_val, lose_val = val2, val1 + + print(f"Winner: {winner} (Lower numerical value: 0x{win_val:016X} < 0x{lose_val:016X})") + print("Outcome:") + print(f" - {winner} retains its claimed address and can start network communications.") + + if lose_dec["arbitrary_address_capable"]: + print(f" - {loser} is Arbitrary Address Capable (Bit 63 = 1).") + print(f" Action: {loser} must select a different address (normally between 128 and 247)") + print(" and transmit a new Address Claim message.") + else: + print(f" - {loser} is Single Address / Non-Arbitrary Capable (Bit 63 = 0).") + print(" Action: {loser} MUST send a 'Cannot Claim Address' message (Source Address = 254/0xFE)") + print(" and cease transmitting regular message frames on the network.") + print("#" * 60 + "\n") + + return { + "winner": winner, + "loser": loser, + "win_dec": win_dec, + "lose_dec": lose_dec, + "collision": False, + } + + +# --------------------------------------------------------------------------- +# Scapy Packet Class for J1939 64-bit NAME +# --------------------------------------------------------------------------- + +class J1939_NAME(Packet): + """SAE J1939 64-bit NAME (J1939-81 Address Claiming). + + The 64-bit NAME is sent as the 8-byte payload in Address Claimed messages + (PGN 60928, 0xEE00). According to SAE J1939-81 §4.2.1, the 64-bit NAME is + transmitted Least Significant Byte (LSB) first over CAN: + - Byte 1 (CAN data[0]): Identity Number bits 0-7 (LSB) + - Byte 2 (CAN data[1]): Identity Number bits 8-15 + - Byte 3 (CAN data[2]): Identity Number bits 16-20, Manufacturer Code bits 21-23 + - Byte 4 (CAN data[3]): Manufacturer Code bits 24-31 + - Byte 5 (CAN data[4]): ECU Instance bits 32-34, Function Instance bits 35-39 + - Byte 6 (CAN data[5]): Function bits 40-47 + - Byte 7 (CAN data[6]): Reserved bit 48, Vehicle System bits 49-55 + - Byte 8 (CAN data[7]): Vehicle System Instance bits 56-59, Industry Group bits 60-62, + Arbitrary Address Capable bit 63 (MSB) + + Fields (MSB to LSB): + - ``arbitrary_address_capable``: 1 bit (bit 63) + - ``industry_group``: 3 bits (bits 62-60) + - ``vehicle_system_instance``: 4 bits (bits 59-56) + - ``vehicle_system``: 7 bits (bits 55-49) + - ``reserved``: 1 bit (bit 48) + - ``function``: 8 bits (bits 47-40) + - ``function_instance``: 5 bits (bits 39-35) + - ``ecu_instance``: 3 bits (bits 34-32) + - ``manufacturer_code``: 11 bits (bits 31-21) + - ``identity_number``: 21 bits (bits 20-0) + """ + + name = "J1939_NAME" + + fields_desc = [ + # Declared in big-endian (MSB-first) order for BitField processing. + # do_dissect / do_build reverse the 8 bytes to convert between + # J1939 little-endian wire format (LSB transmitted first) and + # Scapy's big-endian BitField machinery. + BitField("arbitrary_address_capable", 0, 1), + BitField("industry_group", 0, 3), + BitField("vehicle_system_instance", 0, 4), + BitField("vehicle_system", 0, 7), + BitField("reserved", 0, 1), + BitField("function", 0, 8), + BitField("function_instance", 0, 5), + BitField("ecu_instance", 0, 3), + BitField("manufacturer_code", 0, 11), + BitField("identity_number", 0, 21), + ] + + def do_dissect(self, s: bytes) -> bytes: + """Dissect 8 LE bytes into J1939_NAME bitfields (reading LSB first).""" + if len(s) >= 8: + super(J1939_NAME, self).do_dissect(s[:8][::-1]) + return s[8:] + return b"" + + def do_build(self) -> bytes: + """Build 8 LE bytes from current field values (emitting LSB first).""" + return super(J1939_NAME, self).do_build()[::-1] + + def extract_padding(self, s: bytes) -> Tuple[bytes, bytes]: + return b"", s + + def decode(self) -> Dict[str, Any]: + """Return the dictionary of decoded fields and registry descriptions.""" + return J1939NameDecoder.decode(bytes(self)) + + def format_report(self) -> str: + """Format the decoded NAME as a structured console report.""" + return J1939NameDecoder.format_report(self.decode()) + + @property + def raw_value(self) -> int: + """Return the 64-bit integer representation of this NAME.""" + return struct.unpack(" str: + """Return the human-readable manufacturer name.""" + return MANUFACTURERS.get(self.manufacturer_code, f"Unknown Manufacturer ({self.manufacturer_code})") + + @property + def function_description(self) -> str: + """Return the human-readable function description.""" + return self.decode()["function_description"] + + @property + def vehicle_system_description(self) -> str: + """Return the human-readable vehicle system description.""" + return self.decode()["vehicle_system_description"] + + @property + def industry_group_description(self) -> str: + """Return the human-readable industry group description.""" + return INDUSTRY_GROUPS.get(self.industry_group, f"Unknown Industry Group ({self.industry_group})") + + def mysummary(self) -> str: + return ( + f"J1939_NAME: mfg='{self.manufacturer_name}' func='{self.function_description}' " + f"identity={self.identity_number} arb_addr={bool(self.arbitrary_address_capable)}" + ) + + +# --------------------------------------------------------------------------- +# Active NAME Request Functions +# --------------------------------------------------------------------------- + +def j1939_request_name( + sock: SockOrFactory, + target_da: int = J1939_GLOBAL_ADDRESS, + src_addr: int = 0xF1, + sniff_time: float = 0.3, + bitrate: Optional[int] = None, + busload: float = _J1939_DEFAULT_BUSLOAD, +) -> Union[Optional[J1939_NAME], Dict[int, J1939_NAME]]: + """Request and decode J1939 NAME for a given destination address (or broadcast). + + Sends a Request (PGN 59904, 0xEA00) for Address Claimed (PGN 60928, 0xEE00) + to *target_da* using source address *src_addr*. + + If *target_da* is a specific address (0x00..0xFD), waits for the Address + Claimed response from that address and returns a :class:`J1939_NAME` object + (or ``None`` if timed out). + + If *target_da* is the broadcast address (:data:`J1939_GLOBAL_ADDRESS` = 0xFF), + sniffs for all responses and returns a dictionary ``{sa: J1939_NAME}``. + """ + if bitrate is None: + bitrate = getattr(sock, "bitrate", None) + if bitrate is None: + can_bus = getattr(sock, "ins", None) + bitrate = getattr(can_bus, "bitrate", None) + if bitrate is None: + bitrate = _J1939_DEFAULT_BITRATE + + payload = _build_request_payload(PGN_ADDRESS_CLAIMED) + + if target_da == J1939_GLOBAL_ADDRESS: + # Broadcast request + active_sock, close_sock = _resolve_broadcast_sock(sock) + found: Dict[int, J1939_NAME] = {} + try: + can_id = _j1939_can_id(6, J1939_PF_REQUEST, J1939_GLOBAL_ADDRESS, src_addr) + _pre_probe_flush(active_sock) + active_sock.send(CAN(identifier=can_id, flags="extended", data=payload)) + log_j1939.debug("j1939_request_name: broadcast request sent (CAN-ID=0x%08X)", can_id) + + def _rx_broadcast(pkt: CAN) -> None: + if not (pkt.flags & _CAN_EXTENDED_FLAG): + return + _, pf, ps, sa = _j1939_decode_can_id(pkt.identifier) + if pf == J1939_PF_ADDRESS_CLAIMED and len(pkt.data) == 8: + if sa not in found: + # CAN payload bytes are received LSB first (Byte 1 = LSB) + found[sa] = J1939_NAME(pkt.data) + + active_sock.sniff(prn=_rx_broadcast, timeout=sniff_time, store=False) + return found + finally: + if close_sock: + active_sock.close() + else: + # Unicast request to target_da + send_sock, rx_sock, close_rx = _resolve_probe_sock(sock, target_da) + resp_name: List[J1939_NAME] = [] + try: + can_id = _j1939_can_id(6, J1939_PF_REQUEST, target_da, src_addr) + _pre_probe_flush(rx_sock) + + def _send_probe() -> None: + send_sock.send(CAN(identifier=can_id, flags="extended", data=payload)) + log_j1939.debug( + "j1939_request_name: unicast request sent to DA=0x%02X (CAN-ID=0x%08X)", + target_da, + can_id, + ) + + def _rx_unicast(pkt: CAN) -> None: + if not (pkt.flags & _CAN_EXTENDED_FLAG): + return + _, pf, ps, sa = _j1939_decode_can_id(pkt.identifier) + if sa == target_da and pf == J1939_PF_ADDRESS_CLAIMED and len(pkt.data) == 8: + # CAN payload bytes are received LSB first (Byte 1 = LSB) + resp_name.append(J1939_NAME(pkt.data)) + + rx_sock.sniff( + prn=_rx_unicast, + timeout=sniff_time, + store=False, + started_callback=_send_probe, + stop_filter=lambda _: bool(resp_name), + ) + + _extra = _inter_probe_delay(bitrate, busload, 3, 8, sniff_time) + if _extra > 0.0: + time.sleep(_extra) + + return resp_name[0] if resp_name else None + finally: + if close_rx: + rx_sock.close() + + +def j1939_request_names( + sock: SockOrFactory, + target_das: Optional[Iterable[int]] = None, + src_addr: int = 0xF1, + sniff_time: float = 0.3, + bitrate: Optional[int] = None, + busload: float = _J1939_DEFAULT_BUSLOAD, +) -> Dict[int, Optional[J1939_NAME]]: + """Request and decode J1939 NAMEs for multiple target Destination Addresses. + + :param sock: raw CAN socket or socket factory + :param target_das: iterable of destination addresses to probe. If None, + a single broadcast request is issued. + :param src_addr: scanner source address + :param sniff_time: timeout per probe + :param bitrate: bus bitrate in bit/s (optional, auto-detected from socket) + :param busload: max busload fraction + :returns: mapping ``{da: J1939_NAME or None}`` + """ + if target_das is None: + broadcast_res = j1939_request_name( + sock, + target_da=J1939_GLOBAL_ADDRESS, + src_addr=src_addr, + sniff_time=sniff_time, + bitrate=bitrate, + busload=busload, + ) + return broadcast_res if isinstance(broadcast_res, dict) else {} + + results: Dict[int, Optional[J1939_NAME]] = {} + for da in target_das: + res = j1939_request_name( + sock, + target_da=da, + src_addr=src_addr, + sniff_time=sniff_time, + bitrate=bitrate, + busload=busload, + ) + results[da] = res if isinstance(res, J1939_NAME) else None + return results