diff --git a/scapy/contrib/automotive/j1939/__init__.py b/scapy/contrib/automotive/j1939/__init__.py new file mode 100644 index 00000000000..678bf71e7b4 --- /dev/null +++ b/scapy/contrib/automotive/j1939/__init__.py @@ -0,0 +1,162 @@ +# 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, +) + + +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', + '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/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/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 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_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() 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..e392d41f0ca 100644 --- a/tox.ini +++ b/tox.ini @@ -181,6 +181,8 @@ 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/automotive/j1939/__init__.py:F401,E402,E501 scapy/contrib/eigrp.py:E501 scapy/contrib/geneve.py:E501 scapy/contrib/http2.py:F821