diff --git a/.config/codespell_ignore.txt b/.config/codespell_ignore.txt index 7ed3f0caa78..5ed4ca8ee2d 100644 --- a/.config/codespell_ignore.txt +++ b/.config/codespell_ignore.txt @@ -7,6 +7,7 @@ ba browseable byteorder cace +cantact cas ciph componet diff --git a/doc/scapy/layers/automotive.rst b/doc/scapy/layers/automotive.rst index 6abee5daa66..f164599650b 100644 --- a/doc/scapy/layers/automotive.rst +++ b/doc/scapy/layers/automotive.rst @@ -28,6 +28,10 @@ function to get all information about one specific protocol. | +----------------------+--------------------------------------------------------+ | | GMLAN | GMLAN, GMLAN_*, GMLAN_[Utilities] | | +----------------------+--------------------------------------------------------+ +| | SAE J1939 | j1939_scan, j1939_scan_passive, j1939_scan_addr_claim, | +| | | j1939_scan_ecu_id, j1939_scan_unicast, | +| | | j1939_scan_rts_probe | +| +----------------------+--------------------------------------------------------+ | | SOME/IP | SOMEIP, SD | | +----------------------+--------------------------------------------------------+ | | BMW HSFZ | HSFZ, HSFZSocket, UDS_HSFZSocket | @@ -1364,7 +1368,331 @@ Request the Vehicle Identification Number (VIN):: .. image:: ../graphics/animations/animation-scapy-obd.svg -Message Authentication (AUTOSAR SecOC) +SAE J1939 +========= + +SAE J1939 is a higher-layer protocol built on top of CAN that is widely used in heavy-duty +vehicles, agricultural machinery, construction equipment, and any application that adopts the +SAE J1939 standard family. The central concept of J1939 is the *Controller Application* +(CA): every logical function on the network claims a unique *Source Address* (SA, 0x00–0xFD) +and communicates with other CAs using *Parameter Group Numbers* (PGNs). + +Scapy provides six complementary active techniques for enumerating active CAs on a J1939 +bus, as well as a passive sniffing helper. All active scanners automatically perform a +passive pre-scan to detect pre-existing bus traffic ("background noise") so that already-known +CAs are not re-probed or re-reported by default. + +.. note:: + The J1939 scanner requires the ``automotive.j1939`` contrib to be loaded and a raw CAN + socket. On Linux, a ``CANSocket`` on a SocketCAN interface is the recommended choice. + +Setup:: + + >>> load_contrib('automotive.j1939') + >>> from scapy.contrib.cansocket import CANSocket + >>> 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, + ... ) + >>> sock = CANSocket("can0") + + +J1939SoftSocket — multi-packet reassembly +------------------------------------------- + +``J1939SoftSocket`` wraps a raw ``CANSocket`` and provides J1939 Transport +Protocol reassembly (BAM and CMDT). Use it to sniff complete J1939 messages +including multi-packet payloads larger than 8 bytes. + +The ``pgn`` parameter controls which PGNs are accepted. Setting ``pgn=0`` +(the default) accepts **all** PGNs — each received packet carries the actual +PGN, source address, and destination address from the CAN frame:: + + >>> from scapy.contrib.automotive.j1939.j1939_soft_socket import ( + ... J1939SoftSocket, + ... ) + >>> with J1939SoftSocket(CANSocket("can0"), src_addr=0xFE, + ... dst_addr=0xFF, pgn=0) as s: + ... pkts = s.sniff(timeout=2) + >>> for p in pkts: + ... print("PGN=0x{:05X} SA=0x{:02X}".format(p.pgn, p.src_addr)) + +To filter for a specific PGN, pass it as ``pgn``:: + + >>> with J1939SoftSocket(CANSocket("can0"), src_addr=0xFE, + ... dst_addr=0xFF, pgn=0xFECA) as s: + ... pkts = s.sniff(timeout=2) + + +j1939_scan_passive — background-noise detection +------------------------------------------------- + +``j1939_scan_passive()`` listens on the bus without sending any frame and returns the +``set`` of source addresses observed. The result can be used as the ``noise_ids`` argument +to all active scanners to suppress already-known CAs. + +:: + + >>> noise = j1939_scan_passive(sock, listen_time=2.0) + >>> print("Pre-existing SAs:", {hex(sa) for sa in noise}) + Pre-existing SAs: {'0x10', '0x27', '0x49'} + + +Active scanning techniques +-------------------------- + +The six active techniques complement each other and are all noise-aware by default. + +**Technique 1 — Global Address Claim Request** (``j1939_scan_addr_claim``) + +Broadcasts a single Request (PGN 59904) for the Address Claimed PGN (60928). +Every J1939-81-compliant CA **must** respond. Best for networks where all nodes implement +the J1939-81 address-claiming procedure:: + + >>> found = j1939_scan_addr_claim(sock, listen_time=1.0) + >>> for sa, pkt in sorted(found.items()): + ... print("SA=0x{:02X}".format(sa)) + SA=0x10 + SA=0x27 + +Suppress pre-existing CAs by passing a noise baseline:: + + >>> noise = j1939_scan_passive(sock, listen_time=1.0) + >>> found = j1939_scan_addr_claim(sock, listen_time=1.0, noise_ids=noise) + +**Technique 2 — Global ECU Identification Request** (``j1939_scan_ecu_id``) + +Broadcasts a Request for the ECU Identification Information PGN (64965). +Responding nodes announce their ECU identification string via a BAM multi-packet +transfer. Identifies CAs that publish an ECU ID:: + + >>> found = j1939_scan_ecu_id(sock, listen_time=1.0) + +**Technique 3 — Unicast Ping Sweep** (``j1939_scan_unicast``) + +Iterates through destination addresses 0x00–0xFD, sending a unicast Request for +Address Claimed to each. Any response whose source address equals the probed DA +is treated as a positive reply. Detects nodes even if they do not respond to +global broadcasts:: + + >>> found = j1939_scan_unicast(sock, scan_range=range(0x00, 0xFE), sniff_time=0.05) + >>> for sa, pkt in sorted(found.items()): + ... print("SA=0x{:02X}".format(sa)) + +**Technique 4 — TP.CM RTS Probing** (``j1939_scan_rts_probe``) + +Sends a minimal TP.CM_RTS (Transport Protocol Connection Management – Request to +Send) frame to each destination address. An active node replies with either +TP.CM_CTS (Clear to Send), TP.Conn_Abort (Connection Abort), or a NACK +Acknowledgment (PGN 0xE800). All three responses confirm the node is present:: + + >>> found = j1939_scan_rts_probe(sock, scan_range=range(0x00, 0xFE), sniff_time=0.05) + +**Technique 5 — UDS TesterPresent** (``j1939_scan_uds``) + +Sends UDS TesterPresent (SID 0x3E) requests over Diagnostic Message A +(PF=0xDA, peer-to-peer) and Diagnostic Message B (PF=0xDB, functional broadcast). +A node that implements UDS responds with a positive response (SID 0x7E) or a +negative response (SID 0x7F). Both subfunctions 0x00 and 0x01 are probed:: + + >>> found = j1939_scan_uds(sock, scan_range=range(0x00, 0xFE), sniff_time=0.05) + >>> for sa, pkts in sorted(found.items()): + ... print("SA=0x{:02X} responded to UDS".format(sa)) + +Skip the functional (broadcast) scan to avoid waking every ECU:: + + >>> found = j1939_scan_uds(sock, scan_range=[0x00], sniff_time=0.1, + ... skip_functional=True) + +**Technique 6 — XCP Connect Probe** (``j1939_scan_xcp``) + +Sends an XCP CONNECT command (command byte 0xFF, mode 0x00) over Proprietary A +(PF=0xEF) to each destination address. A node that implements XCP responds with +a positive response whose first byte is ``0xFF``:: + + >>> found = j1939_scan_xcp(sock, scan_range=range(0x00, 0xFE), sniff_time=0.05) + >>> for sa, pkts in sorted(found.items()): + ... print("SA=0x{:02X} responded to XCP".format(sa)) + + +j1939_scan — combined scanner with automatic noise filtering +------------------------------------------------------------- + +``j1939_scan()`` runs any subset of the six techniques and merges results. Before +starting any active probe it performs a passive pre-scan (``j1939_scan_passive``) to +collect the set of source addresses already present on the bus. Those addresses are +excluded from probing and from the returned results. + +The returned dictionary maps each **newly-discovered** source address to a record with +keys ``"methods"`` (a list of **all** techniques that detected the CA, in order of +first detection), ``"packets"`` (a parallel list of lists of CAN frames), and +``"src_addrs"`` (the scanner source addresses that elicited the response). + +Quick scan using all six techniques:: + + >>> found = j1939_scan(sock) + >>> for sa, info in sorted(found.items()): + ... print("SA=0x{:02X} found_by={}".format(sa, info["methods"])) + SA=0x10 found_by=['addr_claim', 'unicast', 'rts_probe'] + SA=0x49 found_by=['unicast'] + +Select specific techniques and tune timing:: + + >>> found = j1939_scan( + ... sock, + ... methods=["addr_claim", "unicast"], + ... broadcast_listen_time=2.0, + ... sniff_time=0.05, + ... ) + +Adjust the passive pre-scan duration:: + + >>> found = j1939_scan(sock, noise_listen_time=2.0) + +Provide an explicit noise baseline to skip the automatic passive pre-scan:: + + >>> noise = j1939_scan_passive(sock, listen_time=2.0) + >>> found = j1939_scan(sock, noise_ids=noise) + +Format results as human-readable text or JSON:: + + >>> text_output = j1939_scan(sock, output_format="text") + >>> print(text_output) + >>> json_output = j1939_scan(sock, output_format="json") + + +Bus-load pacing +--------------- + +All per-address probe functions (``j1939_scan_unicast``, ``j1939_scan_rts_probe``, +``j1939_scan_uds``, ``j1939_scan_xcp``) and the DM scanner (``j1939_scan_dm``, +``j1939_scan_dm_pgn``) automatically pace their probe rate so that the scanner's +contribution to the bus stays within a configurable fraction of the total bitrate. +The broadcast methods (``j1939_scan_addr_claim``, ``j1939_scan_ecu_id``) accept +the same parameters for API uniformity, but do not apply pacing because they send +only a single probe frame. + +``bitrate`` (``int``, default ``250000``) + CAN bus bitrate in bit/s. SAE J1939 mandates 250 kbit/s; adjust when testing + on 500 kbit/s or 1 Mbit/s buses. + +``busload`` (``float``, default ``0.05``) + Maximum fraction of bus capacity (0 < *busload* ≤ 1.0) the scanner may + consume, counting both the outgoing probe frame and the expected response + frame. The default of ``0.05`` (5 %) is conservative and safe for live + production buses. Increase it for faster scans on quiet or virtual + interfaces. A value of ``1.0`` means no artificial pacing delay is added. + +The pacing formula counts the fixed-field bits of a CAN extended frame +(67 bits of overhead plus 8 bits per data byte — no bit-stuffing overhead), +computes the minimum cycle time for the configured budget, and sleeps for any +remaining time after the per-probe sniff window:: + + >>> # Conservative 2 % bus load scan + >>> found = j1939_scan_unicast(sock, sniff_time=0.05, + ... bitrate=250000, busload=0.02) + + >>> # Fast scan on a test bench – allow 50 % bus load + >>> found = j1939_scan_unicast(sock, sniff_time=0.05, + ... bitrate=250000, busload=0.50) + + >>> # 500 kbit/s bus at 10 % load + >>> found = j1939_scan(sock, sniff_time=0.05, + ... bitrate=500000, busload=0.10) + + +J1939 Diagnostic Message (DM) Scanner +--------------------------------------- + +The ``j1939_scan_dm`` function probes a single ECU (identified by its +Destination Address) to discover which SAE J1939-73 Diagnostic Messages it +supports. For each PGN in the built-in table the scanner sends a unicast +Request (PGN 59904) and classifies the reply: + +- **Positive response** — the ECU replies with the requested PGN. +- **NACK** — the ECU replies with an Acknowledgment (PGN 0xE800), control + byte 0x01 (Negative Acknowledgment). +- **Timeout** — the ECU does not reply within *sniff_time* seconds. + +Probe a target ECU for all eight standard DMs:: + + >>> 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()): + ... status = "supported" if res.supported else res.error + ... print("{} (PGN 0x{:04X}): {}".format(name, res.pgn, status)) + DM1 (PGN 0xFECA): supported + DM2 (PGN 0xFECB): NACK + DM3 (PGN 0xFECC): Timeout + +Scan only a subset of DMs:: + + >>> results = j1939_scan_dm(sock, target_da=0x00, dms=["DM1", "DM5"]) + +Probe a single PGN directly:: + + >>> from scapy.contrib.automotive.j1939.j1939_dm_scanner import ( + ... j1939_scan_dm_pgn, J1939_DM_PGNS, + ... ) + >>> res = j1939_scan_dm_pgn(sock, target_da=0x00, + ... pgn=J1939_DM_PGNS["DM1"], dm_name="DM1") + >>> print(res) + + +Both ``j1939_scan_dm`` and ``j1939_scan_dm_pgn`` accept ``bitrate`` and +``busload`` to pace the probe rate (see `Bus-load pacing`_ above):: + + >>> # Scan at most 10 % of a 250 kbit/s bus + >>> results = j1939_scan_dm(sock, target_da=0x00, + ... bitrate=250000, busload=0.10) + + +Reset and reconnect handlers +----------------------------- + +``j1939_scan_dm`` mirrors the interface of +:class:`~scapy.contrib.automotive.uds_scan.UDS_Scanner` and accepts two +optional handler callbacks to manage ECU state between DM probes: + +``reset_handler`` (``Optional[Callable[[], None]]``, default ``None``) + Called between each pair of DM PGN probes to reset the target ECU to a + known state (e.g. toggle an ignition line, trigger a power cycle, or send a + hardware-reset signal). Called *N − 1* times when scanning *N* PGNs. + +``reconnect_handler`` (``Optional[Callable[[], SuperSocket]]``, default ``None``) + Called immediately after *reset_handler* (when provided) to re-establish the + CAN connection. Must return a new + :class:`~scapy.supersocket.SuperSocket`; all subsequent probes use the + returned socket. Can also be provided without *reset_handler* when the ECU + spontaneously drops the connection after each diagnostic exchange. + On failure, *reconnect_handler* is retried up to ``reconnect_retries`` + times (default 5) with a 1-second backoff between attempts. + +Example — reset and reconnect between every DM probe:: + + >>> def reset(): + ... pass # e.g., toggle GPIO reset pin + >>> def reconnect(): + ... return CANSocket("can0") + >>> results = j1939_scan_dm( + ... reconnect(), target_da=0x00, + ... reset_handler=reset, + ... reconnect_handler=reconnect, + ... ) + >>> for name, res in sorted(results.items()): + ... status = "supported" if res.supported else res.error + ... print("{}: {}".format(name, status)) + + ====================================== AutoSAR SecOC is a security architecture protecting communication between ECUs in a vehicle from cyber-attacks. diff --git a/scapy/contrib/automotive/j1939/__init__.py b/scapy/contrib/automotive/j1939/__init__.py new file mode 100644 index 00000000000..3ed67ac566e --- /dev/null +++ b/scapy/contrib/automotive/j1939/__init__.py @@ -0,0 +1,172 @@ +# SPDX-License-Identifier: GPL-2.0-only +# This file is part of Scapy +# See https://scapy.net/ for more information +# Copyright (C) Ben Gardiner + +# scapy.contrib.description = SAE J1939 (SAE J1939-21) Transport Layer Socket +# scapy.contrib.status = loads + +""" +J1939 transport layer socket for Scapy. + +Provides a socket abstraction for SAE J1939-21 multi-packet communication +over CAN, analogous to ISOTPSocket / ISOTPSoftSocket. + +Currently only a pure-Python soft-socket implementation is provided. +The package is structured to allow a future native Linux j1939 socket +implementation (similar to ISOTPNativeSocket) using the kernel's CAN_J1939 +socket type (available since Linux 5.4). + +Usage: + >>> load_contrib('automotive.j1939') + >>> with J1939Socket("can0", src_addr=0x11, dst_addr=0xFF, pgn=0xFECA) as s: + ... s.send(J1939(data=b"Hello, J1939!")) + +Configuration to enable a future native j1939 kernel socket: + >>> conf.contribs['J1939'] = {'use-j1939-kernel-module': True} +""" + +from scapy.consts import LINUX +from scapy.config import conf +from scapy.error import log_loading + +from scapy.contrib.automotive.j1939.j1939_soft_socket import ( + J1939, + J1939SoftSocket, + J1939SocketImplementation, + TimeoutScheduler, + J1939_GLOBAL_ADDRESS, + J1939_TP_MAX_DLEN, + J1939_MAX_SF_DLEN, + TP_CM_BAM, + TP_CM_RTS, + TP_CM_CTS, + TP_CM_EndOfMsgACK, + TP_Conn_Abort, + TP_CM_MAX_PACKETS_NO_LIMIT, + TP_DT_TIMEOUT_EXTENSION_FACTOR, + PGN_ADDRESS_CLAIMED, + PGN_REQUEST, + J1939_PF_ADDRESS_CLAIMED, + J1939_PF_REQUEST, + J1939_NULL_ADDRESS, + J1939_ADDR_CLAIM_TIMEOUT, + J1939_ADDR_STATE_UNCLAIMED, + J1939_ADDR_STATE_CLAIMING, + J1939_ADDR_STATE_CLAIMED, + J1939_ADDR_STATE_CANNOT_CLAIM, + log_j1939, +) + +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_scan, + j1939_scan_passive, + j1939_scan_addr_claim, + j1939_scan_ecu_id, + j1939_scan_unicast, + j1939_scan_rts_probe, + PGN_ECU_ID, + 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, +) + +__all__ = [ + "J1939", + "J1939SoftSocket", + "J1939SocketImplementation", + "J1939Socket", + "TimeoutScheduler", + "J1939_GLOBAL_ADDRESS", + "J1939_TP_MAX_DLEN", + "J1939_MAX_SF_DLEN", + "TP_CM_BAM", + "TP_CM_RTS", + "TP_CM_CTS", + "TP_CM_EndOfMsgACK", + "TP_Conn_Abort", + "TP_CM_MAX_PACKETS_NO_LIMIT", + "TP_DT_TIMEOUT_EXTENSION_FACTOR", + "PGN_ADDRESS_CLAIMED", + "PGN_REQUEST", + "J1939_PF_ADDRESS_CLAIMED", + "J1939_PF_REQUEST", + "J1939_NULL_ADDRESS", + "J1939_ADDR_CLAIM_TIMEOUT", + "J1939_ADDR_STATE_UNCLAIMED", + "J1939_ADDR_STATE_CLAIMING", + "J1939_ADDR_STATE_CLAIMED", + "J1939_ADDR_STATE_CANNOT_CLAIM", + "USE_J1939_KERNEL_MODULE", + "log_j1939", + # Diagnostic Messages (J1939-73) + "J1939_DTC", + "J1939_DM1", + "J1939_DM13", + "J1939_DM14", + "PGN_DM1", + "PGN_DM13", + "PGN_DM14", + "sniff_dm1", + "send_dm14_request", + # CA Scanner (J1939-73) + "j1939_scan", + "j1939_scan_passive", + "j1939_scan_addr_claim", + "j1939_scan_ecu_id", + "j1939_scan_unicast", + "j1939_scan_rts_probe", + "PGN_ECU_ID", + "SCAN_METHODS", + # DM Scanner (J1939-73) + "DmScanResult", + "J1939_DM_PGNS", + "J1939_PF_ACK", + "PGN_ACK", + "j1939_scan_dm", + "j1939_scan_dm_pgn", +] + +USE_J1939_KERNEL_MODULE = False + +if LINUX: + try: + if conf.contribs["J1939"]["use-j1939-kernel-module"]: + USE_J1939_KERNEL_MODULE = True + except KeyError: + log_loading.info( + "Specify 'conf.contribs['J1939'] = " + "{'use-j1939-kernel-module': True}' " + "to enable usage of the Linux j1939 kernel module (Linux >= 5.4)." + ) + + # Future: import J1939NativeSocket here when implemented + # if USE_J1939_KERNEL_MODULE: + # from scapy.contrib.automotive.j1939.j1939_native_socket import \ + # J1939NativeSocket + # __all__.append("J1939NativeSocket") + +if USE_J1939_KERNEL_MODULE: + # Placeholder — native socket not yet implemented; fall through to soft + J1939Socket = J1939SoftSocket # type: ignore[assignment] +else: + J1939Socket = J1939SoftSocket # type: ignore[assignment] diff --git a/scapy/contrib/automotive/j1939/j1939_dm.py b/scapy/contrib/automotive/j1939/j1939_dm.py new file mode 100644 index 00000000000..880cdfe1a0f --- /dev/null +++ b/scapy/contrib/automotive/j1939/j1939_dm.py @@ -0,0 +1,389 @@ +# SPDX-License-Identifier: GPL-2.0-only +# This file is part of Scapy +# See https://scapy.net/ for more information +# Copyright (C) Ben Gardiner + +# 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 ( + 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.automotive.j1939.j1939_soft_socket import ( + J1939, + 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..4b26d39f64d --- /dev/null +++ b/scapy/contrib/automotive/j1939/j1939_dm_scanner.py @@ -0,0 +1,442 @@ +# SPDX-License-Identifier: GPL-2.0-only +# This file is part of Scapy +# See https://scapy.net/ for more information +# Copyright (C) Ben Gardiner + +# 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 + +# Typing imports +from typing import ( + Callable, + Dict, + List, + Optional, +) + +from scapy.layers.can import CAN +from scapy.supersocket import SuperSocket + +from scapy.contrib.automotive.j1939.j1939_soft_socket import ( + J1939_PF_REQUEST, + _j1939_can_id, + _j1939_decode_can_id, + log_j1939, +) + +from scapy.contrib.automotive.j1939.j1939_scanner import ( + _J1939_DEFAULT_BITRATE, + _J1939_DEFAULT_BUSLOAD, + _inter_probe_delay, + _pre_probe_flush, + _resolve_probe_sock, + SockOrFactory, +) + +# --- 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, + exc_info=True, + ) + 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_scanner.py b/scapy/contrib/automotive/j1939/j1939_scanner.py new file mode 100644 index 00000000000..a27bb9d32ed --- /dev/null +++ b/scapy/contrib/automotive/j1939/j1939_scanner.py @@ -0,0 +1,1542 @@ +# SPDX-License-Identifier: GPL-2.0-only +# This file is part of Scapy +# See https://scapy.net/ for more information +# Copyright (C) Ben Gardiner + +# 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 + +# Typing imports +from typing import ( + Callable, + Dict, + Iterable, + List, + Optional, + Set, + Tuple, + Union, + cast, +) + +from scapy.layers.can import CAN +from scapy.supersocket import SuperSocket + +from scapy.contrib.automotive.j1939.j1939_soft_socket import ( + J1939_GLOBAL_ADDRESS, + J1939_TP_CM_PF, + TP_CM_RTS, + TP_CM_CTS, + TP_Conn_Abort, + PGN_ADDRESS_CLAIMED, + J1939_PF_ADDRESS_CLAIMED, + J1939_PF_REQUEST, + _j1939_can_id, + _j1939_decode_can_id, + log_j1939, +) + +# --- 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: + log_j1939.debug("Exception during _pre_probe_flush select", + exc_info=True) + + +# --- 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: + log_j1939.debug("Exception during _open_sa_filtered_sock", + exc_info=True) + 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: + log_j1939.debug("Exception during callable", + exc_info=True) + 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 + 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("unicast: skipping noise DA=0x%02X", da) + continue + payload = _build_request_payload(PGN_ADDRESS_CLAIMED) + + # Capture the loop variable explicitly to avoid closure capture issues + _da = da + send_sock, rx_sock, close_rx = _resolve_probe_sock(sock, _da) + + def _rx(pkt, _da=_da): + # type: (CAN, int) -> None + if not (pkt.flags & _CAN_EXTENDED_FLAG): + return + _, pf, ps, sa = _j1939_decode_can_id(pkt.identifier) + # Filter for Address Claimed (0xEE) and matching target SA. + # Many nodes broadcast the reply (PS=0xFF) instead of unicast. + if sa == _da and pf == J1939_PF_ADDRESS_CLAIMED and ( + (ps in src_addrs 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] = [] + found[_da].append(pkt) + + def _send_probes(_da=_da): + # type: (int) -> None + _pre_probe_flush(rx_sock) + for _sa in src_addrs: + 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", _da) + + try: + rx_sock.sniff(prn=_rx, timeout=sniff_time, store=False, + started_callback=_send_probes, + stop_filter=lambda _, _da=_da: _da in found) + finally: + if close_rx: + rx_sock.close() + + # Pace the probe rate: len(src_addrs) request frames (DLC 3) + response (DLC 8) + _tx_bits = len(src_addrs) * _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) + + 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 + 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: + return + if ps not in src_addrs 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) + # 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) + + def _send_probes(_da=_da): + # type: (int) -> None + _pre_probe_flush(rx_sock) + for _sa in src_addrs: + # 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", _da) + + try: + rx_sock.sniff(prn=_rx, timeout=sniff_time, store=False, + started_callback=_send_probes, + stop_filter=lambda _, _da=_da: _da in found) + finally: + if close_rx: + rx_sock.close() + + # Pace: len(src_addrs) RTS probes (DLC 8) + one expected response (DLC 8) + _tx_bits = len(src_addrs) * _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) + + 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 + found = {} # type: Dict[int, List[CAN]] + + if not skip_functional: + func_sock, close_func = _resolve_broadcast_sock(sock) + try: + def _rx_functional(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 not force and noise_ids is not None and sa in noise_ids: + return + if pf == diag_pgn | 0x01 and ps in src_addrs: + 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(): + # type: () -> None + _pre_probe_flush(func_sock) + for _sa in src_addrs: + # diag_pgn | 0x01 is functional addressing PF (e.g. 0xDB) + 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 (PF=0x%02X)", 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 + + # Capture the loop variable explicitly to avoid closure capture issues + _da = da + send_sock, rx_sock, close_rx = _resolve_probe_sock(sock, _da) + + def _rx(pkt, _da=_da): + # type: (CAN, 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 in src_addrs 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) + + def _send_probes(_da=_da): + # type: (int) -> None + _pre_probe_flush(rx_sock) + for _sa in src_addrs: + # diag_pgn is physical addressing PF (e.g. 0xDA) + can_id_a = _j1939_can_id(_SCAN_PRIORITY, diag_pgn, _da, _sa) + for req in _UDS_TESTER_PRESENT_REQS: + send_sock.send(CAN(identifier=can_id_a, flags="extended", data=req)) + log_j1939.debug("uds: physical probe DA=0x%02X on PF=0x%02X", _da, diag_pgn) + + try: + rx_sock.sniff(prn=_rx, timeout=sniff_time, store=False, + started_callback=_send_probes, + stop_filter=lambda _, _da=_da: _da in found) + finally: + if close_rx: + rx_sock.close() + + # Pace: len(_UDS_TESTER_PRESENT_REQS) probes per src_addr (Physical), + # DLC=8, + 1 response + _tx_bits = len(src_addrs) * 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) + + 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 + 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 + + # Capture the loop variable explicitly to avoid closure capture issues + _da = da + send_sock, rx_sock, close_rx = _resolve_probe_sock(sock, _da) + + def _rx(pkt, _da=_da): + # type: (CAN, 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 in src_addrs 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) + + def _send_probes(_da=_da): + # type: (int) -> None + _pre_probe_flush(rx_sock) + for _sa in src_addrs: + 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 on PF=0x%02X", _da, diag_pgn) + + try: + rx_sock.sniff(prn=_rx, timeout=sniff_time, store=False, + started_callback=_send_probes, + stop_filter=lambda _, _da=_da: _da in found) + finally: + if close_rx: + rx_sock.close() + + # Pace: 1 probe per src_addr (Physical), DLC=8, + 1 response + _tx_bits = len(src_addrs) * _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) + + 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 + + # 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: + log_j1939.debug("Exception during j1939_scan", + exc_info=True) + 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/scapy/contrib/automotive/j1939/j1939_soft_socket.py b/scapy/contrib/automotive/j1939/j1939_soft_socket.py new file mode 100644 index 00000000000..14a9d254ba1 --- /dev/null +++ b/scapy/contrib/automotive/j1939/j1939_soft_socket.py @@ -0,0 +1,1598 @@ +# SPDX-License-Identifier: GPL-2.0-only +# This file is part of Scapy +# See https://scapy.net/ for more information +# Copyright (C) Ben Gardiner + +# scapy.contrib.description = SAE J1939 (SAE J1939-21) Soft Socket Library +# scapy.contrib.status = library + +""" +J1939SoftSocket + +A Python implementation of the SAE J1939-21 Transport Layer Protocol, +structured to allow future use of the Linux kernel's native j1939 socket +support (similar to ISOTPNativeSocket / ISOTPSoftSocket). + +The SAE J1939 Transport Layer Protocol supports two modes: + +- BAM (Broadcast Announce Message) for broadcast multi-packet messages + (destination address 0xFF / J1939_GLOBAL_ADDRESS) +- CMDT (Connection Mode Data Transfer) for peer-to-peer multi-packet + messages with flow control + +Optionally, SAE J1939-81 Network Management (address claiming) is supported +when the socket is created with a ``name`` (64-bit ECU NAME) parameter. + +Reference: + +- SAE J1939-21 Data Link Layer specification +- SAE J1939-81 Network Management specification +- Linux kernel j1939 socket documentation: + https://www.kernel.org/doc/html/latest/networking/j1939.html +- The structure and state-machine approach in this file were adapted from the + Scapy ISOTPSoftSocket implementation: + scapy/contrib/isotp/isotp_soft_socket.py + Copyright (C) Nils Weiss + Copyright (C) Enrico Pozzobon + SPDX-License-Identifier: GPL-2.0-only +""" + +import heapq +import logging +import socket +import struct +import time +import traceback +from threading import Thread, Event, RLock + +# Typing imports +from typing import ( + Optional, + Union, + List, + Tuple, + Any, + Type, + cast, + Callable, + TYPE_CHECKING, +) + +from scapy.automaton import ObjectPipe, select_objects +from scapy.config import conf +from scapy.consts import LINUX +from scapy.error import Scapy_Exception +from scapy.layers.can import CAN +from scapy.packet import Packet +from scapy.supersocket import SuperSocket +from scapy.utils import EDecimal + +if TYPE_CHECKING: + from scapy.contrib.cansocket import CANSocket + +log_j1939 = logging.getLogger("scapy.contrib.automotive.j1939") + +# --------------------------------------------------------------------------- +# J1939 constants (SAE J1939-21) +# --------------------------------------------------------------------------- + +#: Global (broadcast) address — used as DA in BAM transfers +J1939_GLOBAL_ADDRESS = 0xFF + +#: Maximum data length for a single-frame (unfragmented) J1939 message +J1939_MAX_SF_DLEN = 8 + +#: Maximum data length for a multi-packet J1939 TP message (255 packets × 7 bytes) +J1939_TP_MAX_DLEN = 1785 + +#: PDU Format byte for TP Connection Management (TP.CM), PGN 0xEC00 +J1939_TP_CM_PF = 0xEC + +#: PDU Format byte for TP Data Transfer (TP.DT), PGN 0xEB00 +J1939_TP_DT_PF = 0xEB + +#: Padding byte used in the last TP.DT frame +J1939_TP_DT_PAD = 0xFF + +#: Maximum sequence number in a TP.DT frame (wraps at 255) +J1939_TP_DT_MAX_SN = 255 + +#: TP.DT payload bytes per frame (7 bytes of data per TP.DT frame) +J1939_TP_DT_PAYLOAD = 7 + +# TP.CM control bytes +TP_CM_RTS = 0x10 # Request to Send +TP_CM_CTS = 0x11 # Clear to Send +TP_CM_EndOfMsgACK = 0x13 # End of Message Acknowledgment +TP_CM_BAM = 0x20 # Broadcast Announce Message +TP_Conn_Abort = 0xFF # Connection Abort + +#: Value for the "max packets per CTS" field in a TP.CM_RTS frame that +#: indicates no limit on how many TP.DT frames the receiver may request per CTS +TP_CM_MAX_PACKETS_NO_LIMIT = 0xFF + +# Abort reason codes +TP_ABORT_ALREADY_CONNECTED = 1 +TP_ABORT_NO_RESOURCES = 2 +TP_ABORT_TIMEOUT = 3 +TP_ABORT_CTS_WHILE_DT = 4 + +# Default priority for TP messages (priority 7 is used for TP.CM and TP.DT) +J1939_TP_PRIORITY = 7 + +#: Maximum number of tp_dt_timeout intervals to wait before declaring a +#: TP receive timeout. On slow serial interfaces (slcan), TP.DT frames +#: may be queued behind a large backlog of background CAN frames; this +#: factor gives the mux enough time to drain the backlog. +TP_DT_TIMEOUT_EXTENSION_FACTOR = 10 + +# --------------------------------------------------------------------------- +# J1939-81 Network Management (Address Claiming) +# --------------------------------------------------------------------------- + +#: PGN for Address Claimed / Cannot Claim messages (J1939-81) +PGN_ADDRESS_CLAIMED = 0xEE00 # 60928 + +#: PGN for Request messages (J1939-81) +PGN_REQUEST = 0xEA00 # 59904 + +#: PDU Format byte for Address Claimed (PGN 0xEE00) +J1939_PF_ADDRESS_CLAIMED = 0xEE + +#: PDU Format byte for Request messages (PGN 0xEA00) +J1939_PF_REQUEST = 0xEA + +#: Null (Cannot Claim) address — used as SA when address claiming fails +J1939_NULL_ADDRESS = 0xFE + +#: Duration (seconds) of the 250 ms address claim window (J1939-81 §4.2) +J1939_ADDR_CLAIM_TIMEOUT = 0.250 + +# Address-claim state machine states +J1939_ADDR_STATE_UNCLAIMED = 0 # address claiming not enabled +J1939_ADDR_STATE_CLAIMING = 1 # 250 ms window in progress +J1939_ADDR_STATE_CLAIMED = 2 # address successfully claimed +J1939_ADDR_STATE_CANNOT_CLAIM = 3 # lost arbitration; SA = 0xFE + +# --------------------------------------------------------------------------- +# State machine states (RX) +# --------------------------------------------------------------------------- +J1939_RX_IDLE = 0 +J1939_RX_BAM_WAIT_DATA = 1 # BAM: received TP.CM_BAM, waiting for TP.DT +J1939_RX_CMDT_WAIT_DATA = 2 # CMDT: sent CTS, waiting for TP.DT + +# --------------------------------------------------------------------------- +# State machine states (TX) +# --------------------------------------------------------------------------- +J1939_TX_IDLE = 0 +J1939_TX_BAM_SENDING = 1 # BAM: sending TP.DT frames +J1939_TX_CMDT_WAIT_CTS = 2 # CMDT: sent RTS, waiting for CTS +J1939_TX_CMDT_SENDING = 3 # CMDT: sending TP.DT frames permitted by CTS +J1939_TX_CMDT_WAIT_ACK = 4 # CMDT: sent all DT, waiting for EndOfMsgACK + + +def _j1939_can_id(priority, pf, da, sa): + # type: (int, int, int, int) -> int + """Build a 29-bit J1939 CAN identifier. + + J1939 uses 29-bit extended CAN identifiers structured as: + bits 28-26: Priority (3 bits) + bit 25: Reserved (0) + bit 24: Data Page (0) + bits 23-16: PDU Format (PF, 8 bits) + bits 15-8: PDU Specific (PS = DA for PDU1 where PF < 0xF0) + bits 7-0: Source Address (SA, 8 bits) + + :param priority: message priority (0-7) + :param pf: PDU Format byte (e.g. J1939_TP_CM_PF=0xEC) + :param da: destination address (PS field for PDU1 format) + :param sa: source address + :returns: 29-bit CAN identifier (integer) + """ + return ((priority & 0x7) << 26) | (pf << 16) | (da << 8) | (sa & 0xFF) + + +def _j1939_decode_can_id(can_id): + # type: (int) -> Tuple[int, int, int, int] + """Decode a 29-bit J1939 CAN identifier. + + :param can_id: 29-bit CAN identifier + :returns: (priority, pf, ps, sa) tuple + """ + priority = (can_id >> 26) & 0x7 + pf = (can_id >> 16) & 0xFF + ps = (can_id >> 8) & 0xFF + sa = can_id & 0xFF + return priority, pf, ps, sa + + +class J1939(Packet): + """Packet class for J1939 messages. + + This class holds a reassembled J1939 multi-packet message payload, along + with addressing metadata (PGN, source address, destination address). + + :param args: Arguments for Packet init (e.g. raw bytes) + :param kwargs: Keyword arguments for Packet init + """ + + name = "J1939" + fields_desc = [] # type: ignore[var-annotated] + + __slots__ = Packet.__slots__ + ["pgn", "src_addr", "dst_addr", "data"] + + def __init__(self, *args, **kwargs): + # type: (Any, Any) -> None + self.pgn = kwargs.pop("pgn", 0) # type: int + self.src_addr = kwargs.pop("src_addr", 0) # type: int + self.dst_addr = kwargs.pop("dst_addr", J1939_GLOBAL_ADDRESS) # type: int + self.data = kwargs.pop("data", b"") # type: bytes + Packet.__init__(self, *args, **kwargs) + + def do_dissect(self, s): + # type: (bytes) -> bytes + self.data = s + return b"" + + def do_build(self): + # type: () -> bytes + return self.data + + def __repr__(self): + # type: () -> str + return ( + "".format( + self.pgn, self.src_addr, self.dst_addr, self.data.hex() + ) + ) + + def __eq__(self, other): + # type: (Any) -> bool + if not isinstance(other, J1939): + return False + return ( + self.pgn == other.pgn + and self.src_addr == other.src_addr + and self.dst_addr == other.dst_addr + and self.data == other.data + ) + + +class J1939SoftSocket(SuperSocket): + """J1939 soft socket implementing the SAE J1939-21 transport layer. + + This class is a wrapper around the J1939SocketImplementation, following + the same design pattern as ISOTPSoftSocket. + + The J1939SoftSocket aims to be fully compatible with the Linux j1939 + socket support (CAN_J1939) while being usable on any operating system. + + For messages up to 8 bytes, frames are sent directly as CAN frames. + For messages 9-1785 bytes, the J1939-21 Transport Protocol is used: + + - BAM (Broadcast Announce Message) when dst_addr == J1939_GLOBAL_ADDRESS + - CMDT (Connection Mode Data Transfer) for peer-to-peer messages + + Example (with NativeCANSocket underneath): + + >>> load_contrib('automotive.j1939') + >>> with J1939Socket("can0", src_addr=0x11, dst_addr=0xFF, pgn=0xFECA) as s: + ... s.send(J1939(data=b"Hello, World!")) + + Example (with PythonCANSocket underneath): + + >>> conf.contribs['CANSocket'] = {'use-python-can': True} + >>> load_contrib('automotive.j1939') + >>> with J1939Socket(CANSocket(bustype='socketcan', channel="can0"), + ... src_addr=0x11, dst_addr=0xFF, pgn=0xFECA) as s: + ... s.send(J1939(data=b"Hello, World!")) + + :param can_socket: a CANSocket instance or interface name (Linux only) + :param src_addr: our J1939 source address (SA) + :param dst_addr: destination J1939 address; J1939_GLOBAL_ADDRESS (0xFF) + causes BAM to be used for multi-packet messages + :param pgn: the Parameter Group Number (PGN) for sent messages; + also used to filter received messages (0 to accept all) + :param rx_pgn: override PGN filter for received messages; if None, + the ``pgn`` parameter is used + :param priority: J1939 message priority (0-7, default 6) + :param bs: block size for CMDT (0 = send all in one block) + :param listen_only: if True, never sends TP.CM flow-control frames + :param basecls: base class of the packets emitted by this socket + :param name: optional 64-bit ECU NAME (integer) for J1939-81 address + claiming; if provided the socket broadcasts an Address + Claimed message on startup and blocks ``send()`` until + the address is successfully claimed + :param preferred_address: preferred SA (0-247) for address claiming; + if None, the value of ``src_addr`` is used + """ + + def __init__( + self, + can_socket=None, # type: Optional[Union["CANSocket", str]] + src_addr=0x00, # type: int + dst_addr=J1939_GLOBAL_ADDRESS, # type: int + pgn=0, # type: int + rx_pgn=None, # type: Optional[int] + priority=6, # type: int + bs=0, # type: int + listen_only=False, # type: bool + basecls=J1939, # type: Type[Packet] + name=None, # type: Optional[int] + preferred_address=None, # type: Optional[int] + ): + # type: (...) -> None + if LINUX and isinstance(can_socket, str): + from scapy.contrib.cansocket_native import NativeCANSocket + + can_socket = NativeCANSocket(can_socket) + elif isinstance(can_socket, str): + raise Scapy_Exception("Provide a CANSocket object instead of a string") + + self.src_addr = src_addr + self.dst_addr = dst_addr + self.pgn = pgn + self.rx_pgn = rx_pgn if rx_pgn is not None else pgn + self.priority = priority + self.basecls = basecls + + # Per-message metadata stashed by recv_raw() for recv() to use + self._last_rx_pgn = 0 # type: int + self._last_rx_sa = 0 # type: int + self._last_rx_da = J1939_GLOBAL_ADDRESS # type: int + + impl = J1939SocketImplementation( + can_socket, + src_addr=self.src_addr, + dst_addr=self.dst_addr, + pgn=self.pgn, + rx_pgn=self.rx_pgn, + priority=self.priority, + bs=bs, + listen_only=listen_only, + name=name, + preferred_address=preferred_address, + ) + + # Cast for compatibility with SuperSocket + self.ins = cast(socket.socket, impl) + self.outs = cast(socket.socket, impl) + self.impl = impl + + if basecls is None: + log_j1939.warning("Provide a basecls") + + def close(self): + # type: () -> None + if not self.closed: + if hasattr(self, "impl"): + self.impl.close() + self.closed = True + + def recv_raw(self, x=0xFFFF): + # type: (int) -> Tuple[Optional[Type[Packet]], Optional[bytes], Optional[float]] + """Receive a complete J1939 message (potentially multi-packet).""" + if not self.closed: + tup = self.impl.recv() + if tup is not None: + # Stash per-message metadata for recv() to pick up + self._last_rx_pgn = tup[2] + self._last_rx_sa = tup[3] + self._last_rx_da = tup[4] + return self.basecls, tup[0], float(tup[1]) + return self.basecls, None, None + + def recv(self, x=0xFFFF, **kwargs): + # type: (int, **Any) -> Optional[Packet] + msg = super(J1939SoftSocket, self).recv(x, **kwargs) + if msg is None: + return None + if hasattr(msg, "pgn"): + msg.pgn = self._last_rx_pgn + if hasattr(msg, "src_addr"): + msg.src_addr = self._last_rx_sa + if hasattr(msg, "dst_addr"): + msg.dst_addr = self._last_rx_da + return msg + + @staticmethod + def select(sockets, remain=None): # type: ignore[override] + # type: (List[Union[SuperSocket, ObjectPipe[Any]]], Optional[float]) -> List[Union[SuperSocket, ObjectPipe[Any]]] # noqa: E501 + """Called during sendrecv() to wait for sockets to be ready.""" + obj_pipes = [ # type: ignore[var-annotated] + x.impl.rx_queue + for x in sockets + if isinstance(x, J1939SoftSocket) and not x.closed + ] + obj_pipes += [x for x in sockets if isinstance(x, ObjectPipe) and not x.closed] + + ready_pipes = select_objects(obj_pipes, remain) + + result = [ # type: ignore[var-annotated] + x + for x in sockets + if isinstance(x, J1939SoftSocket) + and not x.closed + and x.impl.rx_queue in ready_pipes + ] + result += [x for x in sockets if isinstance(x, ObjectPipe) and x in ready_pipes] + return result + + +class TimeoutScheduler: + """A timeout scheduler which uses a single thread for all timeouts. + + Identical to the TimeoutScheduler from ISOTPSoftSocket; reproduced here + so that j1939 has no dependency on the isotp contrib. + + Original implementation: + scapy/contrib/isotp/isotp_soft_socket.py + Copyright (C) Nils Weiss + Copyright (C) Enrico Pozzobon + SPDX-License-Identifier: GPL-2.0-only + """ + + GRACE = 0.1 + _mutex = RLock() + _event = Event() + _thread = None # type: Optional[Thread] + + # use heapq functions on _handles! + _handles = [] # type: List[TimeoutScheduler.Handle] + + logger = logging.getLogger("scapy.contrib.automotive.j1939.timeout_scheduler") + + @classmethod + def schedule(cls, timeout, callback): + # type: (float, Callable[[], None]) -> TimeoutScheduler.Handle + """Schedule the execution of ``callback`` in ``timeout`` seconds.""" + when = cls._time() + timeout + handle = cls.Handle(when, callback) + + with cls._mutex: + heapq.heappush(cls._handles, handle) + must_interrupt = cls._handles[0] == handle + + if cls._thread is None: + t = Thread(target=cls._task, name="J1939TimeoutScheduler._task") + t.daemon = True + must_interrupt = False + cls._thread = t + cls._event.clear() + t.start() + + if must_interrupt: + cls._event.set() + time.sleep(0) + return handle + + @classmethod + def cancel(cls, handle): + # type: (TimeoutScheduler.Handle) -> None + """Cancel the execution of a timeout given its handle.""" + with cls._mutex: + if handle in cls._handles: + handle._cb = None + cls._handles.remove(handle) + heapq.heapify(cls._handles) + if len(cls._handles) == 0: + cls._event.set() + else: + raise Scapy_Exception("Handle not found") + + @classmethod + def clear(cls): + # type: () -> None + """Cancel the execution of all timeouts.""" + with cls._mutex: + cls._handles = [] + cls._event.set() + + @classmethod + def _peek_next(cls): + # type: () -> Optional[TimeoutScheduler.Handle] + with cls._mutex: + return cls._handles[0] if cls._handles else None + + @classmethod + def _wait(cls, handle): + # type: (Optional[TimeoutScheduler.Handle]) -> None + now = cls._time() + if handle is None: + to_wait = cls.GRACE + else: + to_wait = handle._when - now + if to_wait > 0: + cls._event.wait(to_wait) + cls._event.clear() + + @classmethod + def _task(cls): + # type: () -> None + time_empty = None + try: + while 1: + handle = cls._peek_next() + if handle is None: + now = cls._time() + if time_empty is None: + time_empty = now + if cls.GRACE < now - time_empty: + return + else: + time_empty = None + cls._wait(handle) + cls._poll() + finally: + cls._thread = None + + @classmethod + def _poll(cls): + # type: () -> None + while 1: + with cls._mutex: + now = cls._time() + if len(cls._handles) == 0 or cls._handles[0]._when > now: + return + handle = heapq.heappop(cls._handles) + callback = None + if handle is not None: + callback = handle._cb + handle._cb = True + if callable(callback): + try: + callback() + except Exception: + traceback.print_exc() + + @staticmethod + def _time(): + # type: () -> float + return time.monotonic() + + class Handle: + """A handle for a scheduled timeout.""" + + __slots__ = ["_when", "_cb"] + + def __init__(self, when, cb): + # type: (float, Optional[Union[Callable[[], None], bool]]) -> None + self._when = when + self._cb = cb + + def cancel(self): + # type: () -> bool + """Cancel this timeout. Returns False if already executed.""" + if self._cb is None: + raise Scapy_Exception("cancel() called on previously cancelled Handle") + with TimeoutScheduler._mutex: + if isinstance(self._cb, bool): + return False + self._cb = None + TimeoutScheduler.cancel(self) + return True + + def __lt__(self, other): + # type: (Any) -> bool + if not isinstance(other, TimeoutScheduler.Handle): + raise TypeError() + return self._when < other._when + + def __le__(self, other): + # type: (Any) -> bool + if not isinstance(other, TimeoutScheduler.Handle): + raise TypeError() + return self._when <= other._when + + def __gt__(self, other): + # type: (Any) -> bool + if not isinstance(other, TimeoutScheduler.Handle): + raise TypeError() + return self._when > other._when + + def __ge__(self, other): + # type: (Any) -> bool + if not isinstance(other, TimeoutScheduler.Handle): + raise TypeError() + return self._when >= other._when + + +class J1939SocketImplementation: + """Implementation of the J1939-21 transport layer state machine. + + This class is separated from J1939SoftSocket so the background thread + cannot hold a reference to J1939SoftSocket, allowing the socket to be + collected by the GC. + + The state machine handles: + + - Direct (single-frame) messages up to 8 bytes + - BAM multi-packet messages (broadcast, dst_addr == J1939_GLOBAL_ADDRESS) + - CMDT multi-packet messages (peer-to-peer, dst_addr != J1939_GLOBAL_ADDRESS) + - J1939-81 address claiming (when ``name`` is provided) + + Reference: SAE J1939-21 and J1939-81 specifications. + + :param can_socket: underlying CANSocket for sending/receiving CAN frames + :param src_addr: our J1939 source address (SA) + :param dst_addr: default destination address for sending + :param pgn: PGN used when sending data + :param rx_pgn: PGN filter for receiving (0 = accept all PGNs) + :param priority: CAN frame priority for sent frames (0-7) + :param bs: CMDT block size (0 = send all in one block) + :param listen_only: if True, do not send TP.CM flow-control responses + :param name: optional 64-bit ECU NAME for J1939-81 address claiming + :param preferred_address: preferred SA (0-247) for address claiming; + defaults to ``src_addr`` when None + """ + + def __init__( + self, + can_socket, # type: "CANSocket" + src_addr=0x00, # type: int + dst_addr=J1939_GLOBAL_ADDRESS, # type: int + pgn=0, # type: int + rx_pgn=0, # type: int + priority=6, # type: int + bs=0, # type: int + listen_only=False, # type: bool + name=None, # type: Optional[int] + preferred_address=None, # type: Optional[int] + ): + # type: (...) -> None + self.can_socket = can_socket + self.src_addr = src_addr + self.dst_addr = dst_addr + self.pgn = pgn + self.rx_pgn = rx_pgn + self.priority = priority + self.listen_only = listen_only + self.closed = False + + # J1939-81 address claiming + self.name = name # type: Optional[int] + self.preferred_address = ( + preferred_address if preferred_address is not None else src_addr + ) + + # Receive state machine + self.rx_state = J1939_RX_IDLE + self.rx_pgn_active = 0 # PGN of message being received + self.rx_src_addr = 0 # SA of sender + self.rx_dst_addr = 0 # DA of message being received + self.rx_total_size = 0 # total bytes expected + self.rx_total_packets = 0 # total TP.DT packets expected + self.rx_buf = b"" # accumulation buffer + self.rx_sn = 1 # next expected sequence number + self.rx_ts = 0.0 # type: Union[float, EDecimal] + self.rx_start_time = 0.0 # time when current TP transfer started + self.rx_bs = bs # configured block size + self.rx_bs_count = 0 # packets received in current block + self.rx_next_packet = 1 # next packet number for CMDT CTS + + # Transmit state machine + self.tx_state = J1939_TX_IDLE + self.tx_buf = b"" # message to send + self.tx_total_size = 0 + self.tx_total_packets = 0 + self.tx_pgn = 0 # PGN of message being sent + self.tx_dst_addr = 0 # DA of message being sent + self.tx_sn = 1 # current sequence number + self.tx_idx = 0 # index into tx_buf (bytes sent so far) + self.tx_packets_to_send = 0 # packets permitted by last CTS + self.tx_packets_sent = 0 # packets sent in current CTS block + self.tx_gap = 0.0 # inter-frame gap (seconds) + + # Protocol timeout values (seconds) + self.tp_dt_timeout = 0.750 # T1: timeout waiting for next TP.DT (750 ms) + self.tp_cm_timeout = 1.250 # T2/T3: timeout waiting for CTS or ACK (1250 ms) + self.bam_dt_gap = 0.050 # inter-frame gap for BAM TP.DT (50 ms) + + # Timer handles (all None-initialised; cancelled in close()) + self.rx_timeout_handle = None # type: Optional[TimeoutScheduler.Handle] + self.tx_timeout_handle = None # type: Optional[TimeoutScheduler.Handle] + self.address_claim_handle = None # type: Optional[TimeoutScheduler.Handle] + + # Miscellaneous state + self.filter_warning_emitted = False + self.last_rx_sa = 0 # last received SA (used by J1939SoftSocket.recv) + + # I/O queues + # rx_queue carries (payload, timestamp, pgn, sa, da) tuples + _RxTuple = Tuple[bytes, Union[float, EDecimal], int, int, int] + self.rx_queue = ObjectPipe[_RxTuple]() + self.tx_queue = ObjectPipe[Tuple[bytes, int, int]]() + # tx_queue carries (payload, pgn, dst_addr) tuples + + # Background polling (5 ms default poll rate) + self.rx_tx_poll_rate = 0.005 + self.rx_handle = TimeoutScheduler.schedule(self.rx_tx_poll_rate, self.can_recv) + self.tx_handle = TimeoutScheduler.schedule( + self.rx_tx_poll_rate, self._send_poll + ) + + # J1939-81 initial address claim broadcast + self.address_state = J1939_ADDR_STATE_UNCLAIMED + if self.name is not None: + self.address_state = J1939_ADDR_STATE_CLAIMING + self._send_address_claimed(self.preferred_address) + self.address_claim_handle = TimeoutScheduler.schedule( + J1939_ADDR_CLAIM_TIMEOUT, self._address_claim_timer_fired + ) + + # ------------------------------------------------------------------ + # Destructor / close + # ------------------------------------------------------------------ + + def __del__(self): + # type: () -> None + self.close() + + def close(self): + # type: () -> None + """Stop background threads and release resources.""" + self.closed = True + for handle in ( + self.rx_timeout_handle, + self.tx_timeout_handle, + self.address_claim_handle, + ): + if handle is not None: + try: + handle.cancel() + except Scapy_Exception: + pass + try: + self.rx_handle.cancel() + except Scapy_Exception: + pass + try: + self.tx_handle.cancel() + except Scapy_Exception: + pass + try: + self.rx_queue.close() + except (OSError, EOFError): + pass + try: + self.tx_queue.close() + except (OSError, EOFError): + pass + + # ------------------------------------------------------------------ + # J1939-81 Network Management (Address Claiming) + # ------------------------------------------------------------------ + + def _send_address_claimed(self, sa): + # type: (int) -> None + """Send an Address Claimed message (PGN 0xEE00) broadcast with our NAME.""" + if self.name is None: + return + can_id = _j1939_can_id( + self.priority, J1939_PF_ADDRESS_CLAIMED, J1939_GLOBAL_ADDRESS, sa + ) + data = struct.pack(" None + """Send a Cannot Claim message (PGN 0xEE00 from SA=0xFE) with our NAME.""" + if self.name is None: + return + can_id = _j1939_can_id( + self.priority, + J1939_PF_ADDRESS_CLAIMED, + J1939_GLOBAL_ADDRESS, + J1939_NULL_ADDRESS, + ) + data = struct.pack(" None + """Called 250 ms after the initial claim broadcast. + + Transitions from CLAIMING to CLAIMED if no conflicting claim was + received during the window. If the state is no longer CLAIMING + (e.g. arbitration was lost) this is a no-op. + """ + if self.closed: + return + if self.address_state == J1939_ADDR_STATE_CLAIMING: + self.address_state = J1939_ADDR_STATE_CLAIMED + log_j1939.info( + "Address 0x%02X claimed successfully", self.preferred_address + ) + self.address_claim_handle = None + + def _on_address_claimed(self, data, sa, da): + # type: (bytes, int, int) -> None + """Handle an incoming Address Claimed / Cannot Claim frame (PGN 0xEE00). + + Only acts when the sender's source address matches our preferred + address — a direct address conflict. + + Arbitration rule (J1939-81): + + - Lower 64-bit NAME has higher priority (wins). + - If we have the lower NAME: re-broadcast our claim. + - If we have the higher NAME: send Cannot Claim and enter + CANNOT_CLAIM state. + """ + if self.name is None: + return + if len(data) < 8: + return + # SA=0xFE (null address) means the sender already lost arbitration. + if sa == J1939_NULL_ADDRESS: + return + if sa != self.preferred_address: + return + received_name = struct.unpack(" " + "theirs=0x%016X, cannot claim address", + sa, + self.name, + received_name, + ) + if self.address_claim_handle is not None: + try: + self.address_claim_handle.cancel() + except Scapy_Exception: + pass + self.address_claim_handle = None + self.address_state = J1939_ADDR_STATE_CANNOT_CLAIM + self._send_cannot_claim() + + def _on_request_pgn(self, data, sa, da): + # type: (bytes, int, int) -> None + """Handle an incoming Request message (PGN 0xEA00). + + If the requested PGN is PGN_ADDRESS_CLAIMED (0xEE00), respond + with our Address Claimed broadcast (or Cannot Claim if we have + not successfully claimed an address). + """ + if self.name is None: + return + if len(data) < 3: + return + requested_pgn = data[0] | (data[1] << 8) | (data[2] << 16) + if requested_pgn != PGN_ADDRESS_CLAIMED: + return + if self.address_state == J1939_ADDR_STATE_CLAIMED: + log_j1939.debug( + "Request for PGN_ADDRESS_CLAIMED from SA=0x%02X; " + "responding with Address Claimed", + sa, + ) + self._send_address_claimed(self.preferred_address) + else: + log_j1939.debug( + "Request for PGN_ADDRESS_CLAIMED from SA=0x%02X; " + "responding with Cannot Claim (state=%d)", + sa, + self.address_state, + ) + self._send_cannot_claim() + + # ------------------------------------------------------------------ + # CAN send helpers + # ------------------------------------------------------------------ + + def _can_send(self, can_id, data): + # type: (int, bytes) -> None + """Send a single CAN frame with the given 29-bit extended ID.""" + self.can_socket.send(CAN(identifier=can_id, flags="extended", data=data)) + + def _tp_cm_can_id(self, da): + # type: (int) -> int + """Return the CAN ID for a TP.CM frame addressed to ``da``.""" + return _j1939_can_id(self.priority, J1939_TP_CM_PF, da, self.src_addr) + + def _tp_dt_can_id(self, da): + # type: (int) -> int + """Return the CAN ID for a TP.DT frame addressed to ``da``.""" + return _j1939_can_id(self.priority, J1939_TP_DT_PF, da, self.src_addr) + + def _pgn_can_id(self, pgn, da, sa): + # type: (int, int, int) -> int + """Build a J1939 CAN ID for a given PGN. + + For PDU1 format (PF < 0xF0), the DA is placed in the PS field and is + NOT part of the PGN itself. For PDU2 format (PF >= 0xF0) the PGN + encodes the group extension and the DA is always 0xFF. + """ + pf = (pgn >> 8) & 0xFF + dp = (pgn >> 16) & 0x3 + if pf < 0xF0: + ps = da + else: + ps = pgn & 0xFF + return ( + ((self.priority & 0x7) << 26) + | (dp << 24) + | (pf << 16) + | (ps << 8) + | (sa & 0xFF) + ) + + # ------------------------------------------------------------------ + # CAN receive dispatch + # ------------------------------------------------------------------ + + def can_recv(self): + # type: () -> None + """Background CAN receive poll -- called periodically by the scheduler.""" + try: + while self.can_socket.select([self.can_socket], 0): + pkt = self.can_socket.recv() + if pkt: + self.on_can_recv(pkt) + else: + break + except Exception: + if not self.closed: + log_j1939.warning("Error in can_recv: %s", traceback.format_exc()) + if not self.closed and not self.can_socket.closed: + # Determine poll_time from J1939 TP state only. + # Avoid calling select() here — on slow serial interfaces + # (slcan), each select() triggers a mux() call that reads + # N frames at ~2.5ms each, wasting time that could be spent + # processing frames already in the rx_queue. + if ( + self.rx_state in (J1939_RX_BAM_WAIT_DATA, J1939_RX_CMDT_WAIT_DATA) + or self.tx_state == J1939_TX_CMDT_WAIT_CTS + ): + poll_time = 0.0 + else: + poll_time = self.rx_tx_poll_rate + self.rx_handle = TimeoutScheduler.schedule(poll_time, self.can_recv) + else: + try: + self.rx_handle.cancel() + except Scapy_Exception: + pass + + def on_can_recv(self, p): + # type: (Packet) -> None + """Dispatch a received CAN frame to the appropriate handler.""" + if not (p.flags & 0x4): # check extended flag bit + # Ignore non-extended (11-bit) CAN frames + return + + can_id = p.identifier + _, pf, ps, sa = _j1939_decode_can_id(can_id) + + # Network management: Address Claimed / Cannot Claim (PGN 0xEE00) + if pf == J1939_PF_ADDRESS_CLAIMED and self.name is not None: + self._on_address_claimed(bytes(p.data), sa, ps) + return + + # Network management: Request (PGN 0xEA00) + if pf == J1939_PF_REQUEST and self.name is not None: + da = ps # PDU1: PS = DA + if da == self.src_addr or da == J1939_GLOBAL_ADDRESS: + self._on_request_pgn(bytes(p.data), sa, da) + return + + if pf == J1939_TP_CM_PF: + # TP Connection Management + da = ps + if da != self.src_addr and da != J1939_GLOBAL_ADDRESS: + return + self._on_tp_cm(bytes(p.data), sa, da, float(p.time)) + elif pf == J1939_TP_DT_PF: + # TP Data Transfer + da = ps + if da != self.src_addr and da != J1939_GLOBAL_ADDRESS: + return + self._on_tp_dt(bytes(p.data), sa, da) + else: + # Check if it's a direct (unfragmented) message for our rx_pgn + pgn = _pgn_from_can_id(can_id) + if self.rx_pgn != 0 and pgn != self.rx_pgn: + if not self.filter_warning_emitted and conf.verb >= 2: + log_j1939.warning( + "Ignoring CAN frame with unexpected PGN 0x%05X " + "(expected 0x%05X)", + pgn, + self.rx_pgn, + ) + self.filter_warning_emitted = True + return + # Check destination + if pf < 0xF0: + da = ps + if da != self.src_addr and da != J1939_GLOBAL_ADDRESS: + return + else: + da = J1939_GLOBAL_ADDRESS + # Direct single-packet message + data = bytes(p.data) + if data: + self.last_rx_sa = sa + self.rx_queue.send((data, p.time, pgn, sa, da)) + + # ------------------------------------------------------------------ + # TP receive state machine + # ------------------------------------------------------------------ + + def _on_tp_cm(self, data, sa, da, ts): + # type: (bytes, int, int, float) -> None + if len(data) < 8: + return + ctrl = data[0] + if ctrl == TP_CM_BAM: + self._recv_bam(data, sa, da, ts) + elif ctrl == TP_CM_RTS: + self._recv_rts(data, sa, da, ts) + elif ctrl == TP_CM_CTS: + self._recv_cts(data, sa, da) + elif ctrl == TP_CM_EndOfMsgACK: + self._recv_eom_ack(data, sa, da) + elif ctrl == TP_Conn_Abort: + self._recv_abort(data, sa, da) + + def _recv_bam(self, data, sa, da, ts): + # type: (bytes, int, int, float) -> None + """Handle a received TP.CM_BAM frame — start of a BAM transfer.""" + log_j1939.debug("Received TP.CM_BAM from SA=0x%02X", sa) + if da != J1939_GLOBAL_ADDRESS: + return + + total_size = struct.unpack_from(" J1939_TP_MAX_DLEN: + return + + # Cancel any existing RX timeout + if self.rx_timeout_handle is not None: + try: + self.rx_timeout_handle.cancel() + except Scapy_Exception: + pass + + self.rx_state = J1939_RX_BAM_WAIT_DATA + self.rx_pgn_active = pgn + self.rx_src_addr = sa + self.rx_dst_addr = da + self.rx_total_size = total_size + self.rx_total_packets = num_packets + self.rx_buf = b"" + self.rx_sn = 1 + self.rx_ts = ts + self.rx_start_time = TimeoutScheduler._time() + + self.rx_timeout_handle = TimeoutScheduler.schedule( + self.tp_dt_timeout, self._rx_timeout_handler + ) + + def _recv_rts(self, data, sa, da, ts): + # type: (bytes, int, int, float) -> None + """Handle a received TP.CM_RTS frame — start of a CMDT transfer.""" + log_j1939.debug("Received TP.CM_RTS from SA=0x%02X to DA=0x%02X", sa, da) + if da != self.src_addr: + return + + total_size = struct.unpack_from(" J1939_TP_MAX_DLEN: + if not self.listen_only: + self._send_abort(sa, pgn, TP_ABORT_NO_RESOURCES) + return + + # Cancel any existing RX timeout + if self.rx_timeout_handle is not None: + try: + self.rx_timeout_handle.cancel() + except Scapy_Exception: + pass + + self.rx_state = J1939_RX_CMDT_WAIT_DATA + self.rx_pgn_active = pgn + self.rx_src_addr = sa + self.rx_dst_addr = da + self.rx_total_size = total_size + self.rx_total_packets = num_packets + self.rx_buf = b"" + self.rx_sn = 1 + self.rx_bs_count = 0 + self.rx_next_packet = 1 + self.rx_ts = ts + self.rx_start_time = TimeoutScheduler._time() + + if not self.listen_only: + packets_this_block = self.rx_bs if self.rx_bs > 0 else num_packets + self._send_cts(sa, pgn, packets_this_block, self.rx_next_packet) + + self.rx_timeout_handle = TimeoutScheduler.schedule( + self.tp_dt_timeout, self._rx_timeout_handler + ) + + def _recv_cts(self, data, sa, da, ts=None): + # type: (bytes, int, int, Optional[float]) -> None + """Handle a received TP.CM_CTS frame (as the sender). + + The ``ts`` parameter is accepted for API consistency with other + TP.CM handlers but is not used for CTS processing. + """ + log_j1939.debug("Received TP.CM_CTS from SA=0x%02X", sa) + if self.tx_state not in (J1939_TX_CMDT_WAIT_CTS,): + return + if self.tx_timeout_handle is not None: + try: + self.tx_timeout_handle.cancel() + self.tx_timeout_handle = None + except Scapy_Exception: + pass + + packets_to_send = data[1] + next_packet = data[2] + # pgn from CTS: bytes 5-7 + pgn = data[5] | (data[6] << 8) | (data[7] << 16) + + if pgn != self.tx_pgn: + return + if packets_to_send == 0: + # Hold — sender must wait for another CTS + self.tx_timeout_handle = TimeoutScheduler.schedule( + self.tp_cm_timeout, self._tx_timeout_handler + ) + return + + self.tx_packets_to_send = packets_to_send + self.tx_packets_sent = 0 + # Adjust tx_idx to resume at the correct packet + self.tx_sn = next_packet + self.tx_idx = (next_packet - 1) * J1939_TP_DT_PAYLOAD + self.tx_state = J1939_TX_CMDT_SENDING + + # Start sending TP.DT frames + self.tx_timeout_handle = TimeoutScheduler.schedule( + self.tx_gap, self._tx_timer_handler + ) + + def _recv_eom_ack(self, data, sa, da): + # type: (bytes, int, int) -> None + """Handle a received TP.CM_EndOfMsgACK frame.""" + log_j1939.debug("Received TP.CM_EndOfMsgACK from SA=0x%02X", sa) + if self.tx_state != J1939_TX_CMDT_WAIT_ACK: + return + if self.tx_timeout_handle is not None: + try: + self.tx_timeout_handle.cancel() + self.tx_timeout_handle = None + except Scapy_Exception: + pass + pgn = data[5] | (data[6] << 8) | (data[7] << 16) + if pgn == self.tx_pgn: + log_j1939.debug("CMDT transfer complete (ACK from SA=0x%02X)", sa) + self.tx_state = J1939_TX_IDLE + + def _recv_abort(self, data, sa, da): + # type: (bytes, int, int) -> None + """Handle a received TP.Conn_Abort frame.""" + log_j1939.warning( + "TP.Conn_Abort received from SA=0x%02X, reason=0x%02X", + sa, + data[1] if len(data) > 1 else 0xFF, + ) + # Reset both TX and RX state machines + if self.rx_timeout_handle is not None: + try: + self.rx_timeout_handle.cancel() + self.rx_timeout_handle = None + except Scapy_Exception: + pass + if self.tx_timeout_handle is not None: + try: + self.tx_timeout_handle.cancel() + self.tx_timeout_handle = None + except Scapy_Exception: + pass + self.rx_state = J1939_RX_IDLE + self.tx_state = J1939_TX_IDLE + + def _on_tp_dt(self, data, sa, da): + # type: (bytes, int, int) -> None + """Process a received TP.DT frame.""" + if self.rx_state not in (J1939_RX_BAM_WAIT_DATA, J1939_RX_CMDT_WAIT_DATA): + return + if sa != self.rx_src_addr: + return + if len(data) < 1: + return + + sn = data[0] + + # Cancel the inactivity timeout; it will be rescheduled below + if self.rx_timeout_handle is not None: + try: + self.rx_timeout_handle.cancel() + self.rx_timeout_handle = None + except Scapy_Exception: + pass + + if sn != self.rx_sn: + log_j1939.warning( + "TP.DT sequence number mismatch (expected %d, got %d) " + "from SA=0x%02X — aborting", + self.rx_sn, + sn, + sa, + ) + if self.rx_state == J1939_RX_CMDT_WAIT_DATA and not self.listen_only: + self._send_abort(sa, self.rx_pgn_active, TP_ABORT_TIMEOUT) + self.rx_state = J1939_RX_IDLE + return + + payload = data[1:] # up to 7 bytes + self.rx_buf += payload + self.rx_sn = (self.rx_sn % J1939_TP_DT_MAX_SN) + 1 # wrap 1-255 + + if self.rx_state == J1939_RX_CMDT_WAIT_DATA: + self.rx_bs_count += 1 + + # Check if we have received all packets + packets_received = sn # sn is the sequence number of this frame + + if packets_received >= self.rx_total_packets: + # All packets received — trim to actual message size + msg = self.rx_buf[: self.rx_total_size] + log_j1939.debug( + "J1939 TP reassembly complete: %d bytes from SA=0x%02X PGN=0x%05X", + len(msg), + self.rx_src_addr, + self.rx_pgn_active, + ) + + if self.rx_state == J1939_RX_CMDT_WAIT_DATA and not self.listen_only: + self._send_eom_ack( + sa, self.rx_pgn_active, self.rx_total_size, self.rx_total_packets + ) + + self.last_rx_sa = self.rx_src_addr + self.rx_state = J1939_RX_IDLE + self.rx_queue.send((msg, self.rx_ts, self.rx_pgn_active, + self.rx_src_addr, self.rx_dst_addr)) + return + + # Not done yet — send CTS for the next block (CMDT only) + if ( + self.rx_state == J1939_RX_CMDT_WAIT_DATA + and self.rx_bs > 0 + and self.rx_bs_count >= self.rx_bs + and not self.listen_only + ): + remaining = self.rx_total_packets - packets_received + packets_next = min(self.rx_bs, remaining) + self.rx_next_packet = packets_received + 1 + self.rx_bs_count = 0 + self._send_cts(sa, self.rx_pgn_active, packets_next, self.rx_next_packet) + + # Reschedule inactivity timeout + self.rx_timeout_handle = TimeoutScheduler.schedule( + self.tp_dt_timeout, self._rx_timeout_handler + ) + + def _rx_timeout_handler(self): + # type: () -> None + """Called when the TP.DT inactivity timer expires.""" + if self.closed: + return + + if self.rx_state in (J1939_RX_BAM_WAIT_DATA, J1939_RX_CMDT_WAIT_DATA): + # On slow serial interfaces (slcan), the mux reads frames + # from an OS serial buffer that may contain hundreds of + # background CAN frames. TP.DT frames from the sender are + # queued behind this backlog and can take several seconds to + # reach the J1939 state machine. Extend the timeout up to + # 10 × tp_dt_timeout to give the mux enough time to drain + # the backlog. + total_wait = TimeoutScheduler._time() - self.rx_start_time + if total_wait < self.tp_dt_timeout * TP_DT_TIMEOUT_EXTENSION_FACTOR: + self.rx_timeout_handle = TimeoutScheduler.schedule( + self.tp_dt_timeout, self._rx_timeout_handler + ) + return + log_j1939.warning( + "J1939 TP RX timeout (state=%d, received %d bytes of %d)", + self.rx_state, + len(self.rx_buf), + self.rx_total_size, + ) + if self.rx_state == J1939_RX_CMDT_WAIT_DATA and not self.listen_only: + self._send_abort(self.rx_src_addr, self.rx_pgn_active, TP_ABORT_TIMEOUT) + self.rx_state = J1939_RX_IDLE + + # ------------------------------------------------------------------ + # TP transmit state machine + # ------------------------------------------------------------------ + + def _send_cts(self, da, pgn, num_packets, next_packet): + # type: (int, int, int, int) -> None + """Send a TP.CM_CTS frame to ``da``.""" + pgn_bytes = struct.pack(" None + """Send a TP.CM_EndOfMsgACK frame to ``da``.""" + pgn_bytes = struct.pack(" None + """Send a TP.Conn_Abort frame to ``da``.""" + pgn_bytes = struct.pack(" None + """Begin sending a J1939 message. + + For messages up to 8 bytes, the payload is sent directly. + For messages 9-1785 bytes, the J1939-21 TP protocol is used. + + :param payload: raw data bytes to send + :param pgn: PGN of the message + :param da: destination address + """ + if self.tx_state != J1939_TX_IDLE: + log_j1939.warning("J1939 TX busy, retry later") + return + + length = len(payload) + + if length > J1939_TP_MAX_DLEN: + log_j1939.warning( + "Payload too large for J1939 TP (%d bytes, max %d)", + length, + J1939_TP_MAX_DLEN, + ) + return + + if length <= J1939_MAX_SF_DLEN: + # Direct single-frame send + can_id = self._pgn_can_id(pgn, da, self.src_addr) + self._can_send(can_id, payload) + return + + # Multi-packet — compute number of TP.DT packets + num_packets = (length + J1939_TP_DT_PAYLOAD - 1) // J1939_TP_DT_PAYLOAD + pgn_bytes = struct.pack(" None + """Send the next TP.DT frame.""" + if self.tx_state not in (J1939_TX_BAM_SENDING, J1939_TX_CMDT_SENDING): + return + + payload_chunk = self.tx_buf[self.tx_idx : self.tx_idx + J1939_TP_DT_PAYLOAD] + # Pad the last frame with 0xFF if needed + if len(payload_chunk) < J1939_TP_DT_PAYLOAD: + payload_chunk = payload_chunk.ljust(J1939_TP_DT_PAYLOAD, b"\xff") + + dt_data = struct.pack("B", self.tx_sn) + payload_chunk + self._can_send(self._tp_dt_can_id(self.tx_dst_addr), dt_data) + + self.tx_sn = (self.tx_sn % J1939_TP_DT_MAX_SN) + 1 + self.tx_idx += J1939_TP_DT_PAYLOAD + if self.tx_state == J1939_TX_CMDT_SENDING: + self.tx_packets_sent += 1 + + def _tx_timer_handler(self): + # type: () -> None + """Called by the scheduler to send the next TP.DT frame(s).""" + if self.tx_state == J1939_TX_BAM_SENDING: + self._send_next_dt() + if self.tx_idx >= self.tx_total_size: + # BAM complete + log_j1939.debug("BAM transfer complete") + self.tx_state = J1939_TX_IDLE + return + # Schedule the next TP.DT + self.tx_timeout_handle = TimeoutScheduler.schedule( + self.bam_dt_gap, self._tx_timer_handler + ) + + elif self.tx_state == J1939_TX_CMDT_SENDING: + self._send_next_dt() + if self.tx_idx >= self.tx_total_size: + # All TP.DT sent — wait for EndOfMsgACK + self.tx_state = J1939_TX_CMDT_WAIT_ACK + self.tx_timeout_handle = TimeoutScheduler.schedule( + self.tp_cm_timeout, self._tx_timeout_handler + ) + return + if self.tx_packets_sent >= self.tx_packets_to_send: + # Block complete — wait for next CTS + self.tx_state = J1939_TX_CMDT_WAIT_CTS + self.tx_timeout_handle = TimeoutScheduler.schedule( + self.tp_cm_timeout, self._tx_timeout_handler + ) + return + # More frames to send in this block + self.tx_timeout_handle = TimeoutScheduler.schedule( + self.tx_gap, self._tx_timer_handler + ) + + def _tx_timeout_handler(self): + # type: () -> None + """Called when a TX-side timeout (waiting for CTS or ACK) expires.""" + if self.closed: + return + + if self.tx_state in (J1939_TX_CMDT_WAIT_CTS, J1939_TX_CMDT_WAIT_ACK): + log_j1939.warning( + "J1939 CMDT TX timeout (state=%d) — aborting", self.tx_state + ) + self._send_abort(self.tx_dst_addr, self.tx_pgn, TP_ABORT_TIMEOUT) + self.tx_state = J1939_TX_IDLE + + def _send_poll(self): + # type: () -> None + """Background TX poll -- dequeues pending messages and begins sending.""" + try: + if self.tx_state == J1939_TX_IDLE: + if select_objects([self.tx_queue], 0): + item = self.tx_queue.recv() + if item: + payload, pgn, da = item + self.begin_send(payload, pgn, da) + except Exception: + if not self.closed: + log_j1939.warning("Error in _send_poll: %s", traceback.format_exc()) + if not self.closed: + self.tx_handle = TimeoutScheduler.schedule( + self.rx_tx_poll_rate, self._send_poll + ) + else: + try: + self.tx_handle.cancel() + except Scapy_Exception: + pass + + # ------------------------------------------------------------------ + # Public send/recv interface + # ------------------------------------------------------------------ + + def send(self, p): + # type: (bytes) -> None + """Enqueue a raw payload for transmission. + + Raises Scapy_Exception if address claiming is enabled but the + address has not yet been successfully claimed. + """ + if self.name is not None and self.address_state != J1939_ADDR_STATE_CLAIMED: + raise Scapy_Exception( + "J1939 address not yet claimed (state=%d); " + "cannot send application data" % self.address_state + ) + self.tx_queue.send((p, self.pgn, self.dst_addr)) + + def recv(self, timeout=None): + # type: (Optional[int]) -> Optional[Tuple[bytes, Union[float, EDecimal], int, int, int]] # noqa: E501 + """Receive a reassembled J1939 message. + + Returns ``(data, timestamp, pgn, src_addr, dst_addr)`` or None. + """ + return self.rx_queue.recv() + + +def _pgn_from_can_id(can_id): + # type: (int) -> int + """Extract the PGN from a 29-bit J1939 CAN identifier. + + For PDU1 format (PF < 0xF0), the PS field carries the DA and is NOT + part of the PGN, so it is zeroed out. + For PDU2 format (PF >= 0xF0), the PS field is the Group Extension and + IS part of the PGN. + """ + dp = (can_id >> 24) & 0x3 + pf = (can_id >> 16) & 0xFF + ps = (can_id >> 8) & 0xFF + if pf < 0xF0: + return (dp << 16) | (pf << 8) + else: + return (dp << 16) | (pf << 8) | ps diff --git a/scapy/contrib/automotive/scanner/executor.py b/scapy/contrib/automotive/scanner/executor.py index 3d0ff3a5991..e3d22075b59 100644 --- a/scapy/contrib/automotive/scanner/executor.py +++ b/scapy/contrib/automotive/scanner/executor.py @@ -152,7 +152,23 @@ def reset_target(self): # type: () -> None log_automotive.info("Target reset") if self.reset_handler: - self.reset_handler() + result = self.reset_handler() + # If the reset_handler returned a socket (e.g. because + # the user passed a socket factory), close it immediately + # to prevent leaked sockets whose background callbacks + # steal CAN frames from the active session. + if result is not None and hasattr(result, 'close'): + log_automotive.warning( + "reset_handler returned a socket-like object " + "(%s). This is probably a misconfiguration " + "(socket factory passed as reset_handler). " + "Closing the leaked socket to prevent frame " + "theft.", type(result).__name__) + try: + result.close() + except Exception: + log_automotive.debug("Exception while closing leaked socket", + exc_info=True) elif self.software_reset_handler: if self.socket and self.socket.closed: self.reconnect() diff --git a/scapy/contrib/cansocket_python_can.py b/scapy/contrib/cansocket_python_can.py index 340104a4abb..3d9da979e56 100644 --- a/scapy/contrib/cansocket_python_can.py +++ b/scapy/contrib/cansocket_python_can.py @@ -41,6 +41,36 @@ __all__ = ["CANSocket", "PythonCANSocket"] +# Interfaces with hardware or kernel-level CAN filtering. +# These keep bus-level filters for efficiency since the device/driver +# handles filtering before frames reach userspace. +# All other interfaces (USB adapters like candle, gs_usb, cantact; +# serial like slcan) only do software filtering in BusABC.recv() and +# need filters cleared at the bus level to avoid starving matching +# frames behind non-matching ones (echo frames, other bus traffic). +_HW_FILTERED_INTERFACES = frozenset([ + 'socketcan', 'kvaser', 'vector', 'pcan', 'ixxat', + 'nican', 'neovi', 'etas', 'systec', 'nixnet', +]) + + +def _is_sw_filtered(interface_key): + # type: (str) -> bool + """Return True if the bus identified by interface_key only does + software filtering (inside BusABC.recv). + + For such interfaces, bus-level filters must be cleared so that + bus.recv(timeout=0) returns ALL frames. Per-socket filtering + is then handled by distribute() via _matches_filters(). + + Without this, BusABC.recv(timeout=0) on software-filtered buses + (candle, gs_usb, cantact, slcan, etc.) can silently consume + one non-matching frame per call and return None, starving matching + frames that sit behind it in the USB/serial buffer. + """ + iface = interface_key.split('_')[0].lower() + return iface not in _HW_FILTERED_INTERFACES + class SocketMapper(object): """Internal Helper class to map a python-can bus object to @@ -57,6 +87,14 @@ def __init__(self, bus, sockets): self.bus = bus self.sockets = sockets self.closing = False + # Per-bus lock serializing read_bus() and send_bus(). + # On serial interfaces (slcan) the python-can Bus uses a single + # serial port for both recv() and send(). Without serialization, + # concurrent calls from different threads (TimeoutScheduler for + # recv, main thread for send) corrupt the serial byte stream, + # causing slcan parsing errors. The lock is per-mapper (per-bus) + # so different CAN buses are not blocked by each other. + self.bus_lock = threading.Lock() # Maximum time (seconds) to spend reading frames in one read_bus() # call. On serial interfaces (slcan) the final bus.recv(timeout=0) @@ -76,28 +114,43 @@ def read_bus(self): slcan serial timeout). This method limits total time spent reading so the TimeoutScheduler thread stays responsive. - This method intentionally does NOT hold pool_mutex so that - concurrent send() calls are not blocked during the serial I/O. + This method does NOT hold pool_mutex but DOES hold bus_lock to + serialize with send_bus(). This prevents concurrent serial I/O + on slcan interfaces while still allowing sends to other buses. """ if self.closing: return [] msgs = [] deadline = time.monotonic() + self.READ_BUS_TIME_LIMIT - while True: - try: - msg = self.bus.recv(timeout=0) - if msg is None: + with self.bus_lock: + while True: + try: + msg = self.bus.recv(timeout=0) + if msg is None: + break + else: + msgs.append(msg) + if time.monotonic() >= deadline: + break + except Exception as e: + if not self.closing: + warning("[MUX] python-can exception caught: %s" % e) break - else: - msgs.append(msg) - if time.monotonic() >= deadline: - break - except Exception as e: - if not self.closing: - warning("[MUX] python-can exception caught: %s" % e) - break return msgs + def send_bus(self, msg): + # type: (can_Message) -> None + """Send a CAN message on the bus. + + Serialized with read_bus() via bus_lock to prevent concurrent + serial I/O on slcan interfaces. + + :param msg: python-can Message to send. + :raises can_CanError: If the underlying python-can Bus raises. + """ + with self.bus_lock: + self.bus.send(msg) + def distribute(self, msgs): # type: (List[can_Message]) -> None """Distribute received messages to all subscribed sockets.""" @@ -122,10 +175,10 @@ def internal_send(self, sender, msg): A given SocketWrapper wants to send a CAN message. The python-can Bus object is obtained from an internal pool of SocketMapper objects. - The given message is sent on the python-can Bus object and also - inserted into the message queues of all other SocketWrapper objects - which are connected to the same python-can bus object - by the SocketMapper. + The message is sent on the python-can Bus object via send_bus() + (serialized with read_bus() by bus_lock) and also inserted into + the message queues of all other SocketWrapper objects connected to + the same python-can bus object by the SocketMapper. :param sender: SocketWrapper which initiated a send of a CAN message :param msg: CAN message to be sent @@ -136,30 +189,43 @@ def internal_send(self, sender, msg): with self.pool_mutex: try: mapper = self.pool[sender.name] - mapper.bus.send(msg) - for sock in mapper.sockets: - if sock == sender: - continue - if not sock._matches_filters(msg): - continue - - with sock.lock: - sock.rx_queue.append(msg) except KeyError: warning("[SND] Socket %s not found in pool" % sender.name) - except can_CanError as e: - warning("[SND] python-can exception caught: %s" % e) + return + # Snapshot the peer sockets while under pool_mutex + peers = [s for s in mapper.sockets if s != sender] + + # Send on the bus outside pool_mutex but inside bus_lock. + # This serializes with read_bus() to prevent concurrent serial + # I/O on slcan interfaces, while still allowing multiplexing + # and sends on other buses to proceed concurrently. + try: + mapper.send_bus(msg) + except can_CanError as e: + warning("[SND] python-can exception caught: %s" % e) + return + + # Distribute to peer sockets (no need for pool_mutex here, + # we already have a snapshot of the peers list). + for sock in peers: + if not sock._matches_filters(msg): + continue + with sock.lock: + sock.rx_queue.append(msg) def multiplex_rx_packets(self): # type: () -> None """This calls the mux() function of all SocketMapper objects in this SocketPool """ - if time.monotonic() - self.last_call < 0.001: + now = time.monotonic() + if now - self.last_call < 0.001: # Avoid starvation if multiple threads are doing selects, since # this object is singleton and all python-CAN sockets are using # the same instance and locking the same locks. return + self.last_call = now + # Snapshot pool entries under the lock, then read from each bus # WITHOUT holding pool_mutex. On slow serial interfaces (slcan) # bus.recv(timeout=0) can take ~2-3ms per frame; holding the @@ -171,7 +237,6 @@ def multiplex_rx_packets(self): msgs = mapper.read_bus() if msgs: mapper.distribute(msgs) - self.last_call = time.monotonic() def register(self, socket, *args, **kwargs): # type: (SocketWrapper, Tuple[Any, ...], Dict[str, Any]) -> None @@ -198,12 +263,13 @@ def register(self, socket, *args, **kwargs): t = self.pool[k] t.sockets.append(socket) # Update bus-level filters to the union of all sockets' - # filters. For non-slcan interfaces (socketcan, kvaser, - # vector), this enables efficient hardware/kernel - # filtering. For slcan, the bus filters were already - # cleared on creation, so this is a no-op (all sockets - # on slcan share the unfiltered bus). - if not k.lower().startswith('slcan'): + # filters. For hardware-filtered interfaces (socketcan, + # kvaser, vector, pcan, ixxat), this enables efficient + # kernel/device filtering. For software-filtered + # interfaces (slcan, candle, gs_usb, cantact), the bus + # filters were already cleared on creation, so this is + # a no-op (all sockets share the unfiltered bus). + if not _is_sw_filtered(k): filters = [s.filters for s in t.sockets if s.filters is not None] if filters: @@ -211,21 +277,23 @@ def register(self, socket, *args, **kwargs): socket.name = k else: bus = can_Bus(*args, **kwargs) - # Serial interfaces like slcan only do software - # filtering inside BusABC.recv(): the recv loop reads - # one frame, finds it doesn't match, and returns - # None -- silently consuming serial bandwidth without - # returning the frame to the mux. This starves the - # mux on busy buses. + # Software-filtered interfaces (slcan, candle, gs_usb, + # cantact, etc.) only filter inside BusABC.recv(): the + # recv loop reads one frame, finds it doesn't match, + # and returns None -- silently consuming serial/USB + # bandwidth without returning the frame to the mux. + # On USB adapters with timeout=0 mapped to ~1ms reads, + # this means only one non-matching frame is consumed + # per poll cycle, starving matching ECU responses that + # sit behind echo frames in the hardware buffer. # - # For slcan, clear the filters from the bus so that - # bus.recv() returns ALL frames. Per-socket filtering - # in distribute() via _matches_filters() handles - # delivery. Other interfaces (socketcan, kvaser, - # vector, candle) perform efficient hardware/kernel - # filtering and should keep their bus-level filters. - if kwargs.get('can_filters') and \ - k.lower().startswith('slcan'): + # For all software-filtered interfaces, clear the bus + # filters so bus.recv() returns ALL frames. Per-socket + # filtering in distribute() via _matches_filters() + # handles delivery. Hardware-filtered interfaces + # (socketcan, kvaser, vector, pcan, ixxat) keep their + # bus-level filters for efficiency. + if kwargs.get('can_filters') and _is_sw_filtered(k): bus.set_filters(None) socket.name = k self.pool[k] = SocketMapper(bus, [socket]) @@ -242,17 +310,25 @@ def unregister(self, socket): if socket.name is None: raise TypeError("SocketWrapper.name should never be None") + mapper_to_shutdown = None with self.pool_mutex: try: t = self.pool[socket.name] t.sockets.remove(socket) if not t.sockets: t.closing = True - t.bus.shutdown() del self.pool[socket.name] + mapper_to_shutdown = t except KeyError: warning("Socket %s already removed from pool" % socket.name) + # Shutdown the bus outside pool_mutex. Acquire bus_lock to + # wait for any in-progress read_bus() or send_bus() to finish + # before shutting down the underlying transport. + if mapper_to_shutdown is not None: + with mapper_to_shutdown.bus_lock: + mapper_to_shutdown.bus.shutdown() + SocketsPool = _SocketsPool() @@ -341,6 +417,9 @@ def recv_raw(self, x=0xffff): """Returns a tuple containing (cls, pkt_data, time)""" msg = self.can_iface.recv() + if msg is None: + return self.basecls, None, None + hdr = msg.is_extended_id << 31 | msg.is_remote_frame << 30 | \ msg.is_error_frame << 29 | msg.arbitration_id @@ -382,7 +461,13 @@ def select(sockets, remain=conf.recv_poll_rate): :returns: an array of sockets that were selected and the function to be called next to get the packets (i.g. recv) """ + # Move kernel-buffered CAN frames into the per-socket rx_queues + # BEFORE checking which sockets are ready. The previous order + # (check, then multiplex) returned a stale ready-list that did + # not include sockets whose data had just been multiplexed, + # causing a one-iteration delay. SocketsPool.multiplex_rx_packets() + ready_sockets = \ [s for s in sockets if isinstance(s, PythonCANSocket) and len(s.can_iface.rx_queue)] @@ -401,6 +486,13 @@ def close(self): """Closes this socket""" if self.closed: return + # Final poll to ensure all kernel-buffered frames are distributed + # to any shared socket instances before we unregister. + try: + SocketsPool.multiplex_rx_packets() + except Exception: + log_runtime.debug("Exception during SocketsPool multiplex in close", + exc_info=True) super(PythonCANSocket, self).close() self.can_iface.shutdown() diff --git a/scapy/contrib/isotp/isotp_soft_socket.py b/scapy/contrib/isotp/isotp_soft_socket.py index e6baa1dbbf7..a5818bc1b67 100644 --- a/scapy/contrib/isotp/isotp_soft_socket.py +++ b/scapy/contrib/isotp/isotp_soft_socket.py @@ -13,7 +13,7 @@ import time import traceback from bisect import bisect_left -from threading import Thread, Event, RLock +from threading import Thread, Event, RLock, current_thread # Typing imports from typing import ( Optional, @@ -253,8 +253,10 @@ def schedule(cls, timeout, callback): heapq.heappush(cls._handles, handle) must_interrupt = cls._handles[0] == handle - # Start the scheduling thread if it is not started already - if cls._thread is None: + # Start the scheduling thread if it is not started already. + # Also recover if the thread reference is stale (thread died + # without clearing _thread — e.g. from a BaseException). + if cls._thread is None or not cls._thread.is_alive(): t = Thread(target=cls._task, name="TimeoutScheduler._task") t.daemon = True must_interrupt = False @@ -357,17 +359,45 @@ def _task(cls): time_empty = now # 100 ms of grace time before killing the thread if cls.GRACE < now - time_empty: - return + # Atomically check whether new handles arrived + # while we were deciding to die. schedule() + # holds _mutex when it checks _thread, so by + # holding _mutex here we ensure that either: + # (a) _handles is still empty → we set + # _thread = None and return, OR + # (b) a new handle was pushed → we stay alive. + # This closes the race window where schedule() + # saw _thread as not-None but the thread was + # about to die. + with cls._mutex: + if not cls._handles: + cls.logger.debug( + "Thread died @ %f", cls._time()) + cls._thread = None + return + # New handle(s) appeared — stay alive + time_empty = None + continue else: time_empty = None cls._wait(handle) cls._poll() - + except Exception: + cls.logger.exception( + "Thread died @ %f (exception)", cls._time()) finally: - # Worst case scenario: if this thread dies, the next scheduled - # timeout will start a new one - cls.logger.debug("Thread died @ %f", cls._time()) - cls._thread = None + # Clear _thread so the next schedule() call can start a + # fresh thread. Only clear if _thread still points to + # *this* thread; if schedule() already started a + # replacement thread between the normal-exit mutex release + # and this finally block, we must not overwrite the new + # reference. The normal-exit path (GRACE expiry) sets + # _thread = None inside the mutex before returning; this + # finally covers unexpected exits (exceptions, + # BaseException subclasses like SystemExit, etc.). + with cls._mutex: + if cls._thread is current_thread(): + cls._thread = None @classmethod def _poll(cls): @@ -551,10 +581,26 @@ def __init__(self, self.rx_tx_poll_rate = 0.005 self.tx_timeout_handle = None # type: Optional[TimeoutScheduler.Handle] # noqa: E501 self.rx_timeout_handle = None # type: Optional[TimeoutScheduler.Handle] # noqa: E501 + + # Drain frames that accumulated in the CAN adapter's hardware + # RX buffer while no ISOTP socket was active. USB adapters + # (candle, cantact) have small hardware buffers; if background + # CAN traffic fills the buffer before can_recv starts polling, + # the ECU's response frame may be silently dropped by the + # adapter. This drain frees buffer space *before* we send. + try: + self.can_socket.select([self.can_socket], 0) + except Exception: + log_isotp.debug("Exception during ISOTP socket drain select", + exc_info=True) + + # Schedule initial callbacks with timeout=0 so they fire on + # the very next TimeoutScheduler._poll() cycle, minimising + # the window during which the adapter buffer is unserviced. self.rx_handle = TimeoutScheduler.schedule( - self.rx_tx_poll_rate, self.can_recv) + 0, self.can_recv) self.tx_handle = TimeoutScheduler.schedule( - self.rx_tx_poll_rate, self._send) + 0, self._send) self.last_rx_call = 0.0 self.rx_start_time = 0.0 @@ -598,9 +644,22 @@ def _get_padding_size(pl_size): def can_recv(self): # type: () -> None + # Early exit for orphan callbacks: when close() races with + # can_recv on the TimeoutScheduler thread, the old handle may + # fire one last time after closed is set. Without this guard + # the orphan callback would consume CAN frames from the shared + # bus — frames that belong to the NEXT ISOTPSocket session. + if self.closed: + return self.last_rx_call = TimeoutScheduler._time() try: while self.can_socket.select([self.can_socket], 0): + # Re-check closed inside the loop: if close() set the + # flag while we were processing the previous frame, + # stop immediately to avoid consuming frames that + # belong to the next session. + if self.closed: + break pkt = self.can_socket.recv() if pkt: self.on_can_recv(pkt) @@ -642,20 +701,32 @@ def on_can_recv(self, p): def close(self): # type: () -> None + if self.closed: + return + + # Set closed flag FIRST to prevent orphan callbacks from + # consuming CAN frames meant for the next ISOTP session. + # The can_recv() and _send() methods check self.closed at + # entry AND inside their loops, so any in-progress callback + # on the TimeoutScheduler thread will exit promptly. + self.closed = True + + # Brief barrier: yield to the TimeoutScheduler thread so any + # currently-executing callback sees self.closed and exits. + time.sleep(0.005) + + # Diagnostic warnings (non-blocking) try: if select_objects([self.tx_queue], 0): log_isotp.warning("TX queue not empty") - time.sleep(0.1) - except OSError: + except (OSError, Scapy_Exception): pass - try: if select_objects([self.rx_queue], 0): log_isotp.warning("RX queue not empty") - except OSError: + except (OSError, Scapy_Exception): pass - self.closed = True try: self.rx_handle.cancel() except Scapy_Exception: @@ -674,6 +745,19 @@ def close(self): self.tx_timeout_handle.cancel() except Scapy_Exception: pass + + # Final drain: move frames from the CAN adapter's hardware + # buffer into the SocketWrapper software queue. This frees + # adapter buffer space so the NEXT ISOTP session's ECU + # response is not dropped due to hardware overflow. The + # frames stay in the SocketWrapper rx_queue (not lost) and + # will be available to the next session's can_recv. + try: + self.can_socket.select([self.can_socket], 0) + except Exception: + log_isotp.debug("Exception during ISOTP socket drain select", + exc_info=True) + try: self.rx_queue.close() except (OSError, EOFError): @@ -1052,6 +1136,9 @@ def begin_send(self, x): def _send(self): # type: () -> None + # Early exit for orphan callbacks (same rationale as can_recv). + if self.closed: + return try: if self.tx_state == ISOTP_IDLE: if select_objects([self.tx_queue], 0): diff --git a/test/contrib/automotive/j1939_dm.uts b/test/contrib/automotive/j1939_dm.uts new file mode 100644 index 00000000000..5ff4bdb8447 --- /dev/null +++ b/test/contrib/automotive/j1939_dm.uts @@ -0,0 +1,283 @@ +% Regression tests for J1939 Diagnostic Messages (DM1, DM13, DM14) +~ automotive_comm + ++ Configuration +~ conf + += Imports +import sys +sys.path.append(".") +sys.path.append("test") +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 + + ++ J1939 DM Extra Coverage + += J1939_DTC do_dissect short data +assert J1939_DTC(b"\x01\x02\x03").SPN == 0 + += J1939_DM1 do_dissect with trailing padding +dm1 = J1939_DM1(b"\x00\x00\x64\x00\x10\x0A\xFF\xFF") +assert len(dm1.dtcs) == 1 + += sniff_dm1 (mocked call) +import scapy.sendrecv +orig_sniff = scapy.sendrecv.sniff +scapy.sendrecv.sniff = lambda **kwargs: [J1939(data=b"\x00\x00\x64\x00\x10\x0A", pgn=PGN_DM1)] +try: + res = sniff_dm1(TestSocket(CAN), timeout=0.1) + assert len(res) == 1 +finally: + scapy.sendrecv.sniff = orig_sniff diff --git a/test/contrib/automotive/j1939_dm_scanner.uts b/test/contrib/automotive/j1939_dm_scanner.uts new file mode 100644 index 00000000000..87eabde97cd --- /dev/null +++ b/test/contrib/automotive/j1939_dm_scanner.uts @@ -0,0 +1,662 @@ +% Regression tests for J1939 Diagnostic Message (DM) Scanner +~ automotive_comm + ++ Configuration +~ conf + += Imports +import sys +sys.path.append(".") +sys.path.append("test") +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.j1939_soft_socket 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() + + ++ J1939 DM Scanner Extra Coverage + += j1939_scan_dm unknown DM +try: + j1939_scan_dm(TestSocket(CAN), target_da=0x00, dms=["INVALID"]) + assert False +except ValueError: + pass diff --git a/test/contrib/automotive/j1939_scanner.uts b/test/contrib/automotive/j1939_scanner.uts new file mode 100644 index 00000000000..62c4d11382c --- /dev/null +++ b/test/contrib/automotive/j1939_scanner.uts @@ -0,0 +1,2233 @@ +% 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.j1939_soft_socket 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() + += 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 +~ automotive_comm + += 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 +~ automotive_comm + += j1939_scan: verbose=True sets log_j1939 to DEBUG +def test_verbose_debug(): + import logging + from scapy.contrib.automotive.j1939.j1939_soft_socket 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.j1939_soft_socket 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() + + ++ J1939 Scanner Extra Coverage + += _inter_probe_delay invalid busload +try: + _inter_probe_delay(250000, 0, 8, 8, 0.1) + assert False +except ValueError: + pass + += _pre_probe_flush exception +from scapy.contrib.automotive.j1939.j1939_scanner import _pre_probe_flush +class BadSocket(TestSocket): + def select(self, *args): + raise Exception("bad") +_pre_probe_flush(BadSocket(CAN)) # Should not raise + += j1939_scan_passive with stop_event +from threading import Event +stop_evt = Event() +stop_evt.set() +res = j1939_scan_passive(TestSocket(CAN), listen_time=0.1, stop_event=stop_evt) +assert res == set() \ No newline at end of file diff --git a/test/contrib/automotive/j1939_soft_socket.uts b/test/contrib/automotive/j1939_soft_socket.uts new file mode 100644 index 00000000000..242bad91e58 --- /dev/null +++ b/test/contrib/automotive/j1939_soft_socket.uts @@ -0,0 +1,1098 @@ +% Regression tests for J1939SoftSocket +~ automotive_comm + ++ Configuration +~ conf + += Imports +import sys +sys.path.append(".") +sys.path.append("test") +import struct +import time +from scapy.layers.can import CAN +from scapy.packet import Packet +from scapy.contrib.automotive.j1939 import J1939, J1939SoftSocket, J1939Socket, J1939SocketImplementation, J1939_GLOBAL_ADDRESS, J1939_TP_MAX_DLEN, J1939_MAX_SF_DLEN, TP_CM_BAM, TP_CM_RTS, TP_CM_CTS, TP_CM_EndOfMsgACK, TP_Conn_Abort, TimeoutScheduler, USE_J1939_KERNEL_MODULE +from scapy.contrib.automotive.j1939 import PGN_ADDRESS_CLAIMED, PGN_REQUEST, J1939_PF_ADDRESS_CLAIMED, J1939_PF_REQUEST, J1939_NULL_ADDRESS, J1939_ADDR_CLAIM_TIMEOUT, J1939_ADDR_STATE_UNCLAIMED, J1939_ADDR_STATE_CLAIMING, J1939_ADDR_STATE_CLAIMED, J1939_ADDR_STATE_CANNOT_CLAIM +from scapy.contrib.automotive.j1939.j1939_soft_socket import _j1939_can_id, _j1939_decode_can_id, _pgn_from_can_id, J1939_TP_CM_PF, J1939_TP_DT_PF, J1939_TP_DT_PAYLOAD +from scapy.error import Scapy_Exception +from test.testsocket import TestSocket, SlowTestSocket, cleanup_testsockets + +dhex = bytes.fromhex + += 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) + + ++ J1939 constants and helpers + += J1939_GLOBAL_ADDRESS is 0xFF +assert J1939_GLOBAL_ADDRESS == 0xFF + += J1939_MAX_SF_DLEN is 8 +assert J1939_MAX_SF_DLEN == 8 + += J1939_TP_MAX_DLEN is 1785 (255 packets * 7 bytes) +assert J1939_TP_MAX_DLEN == 1785 + += TP_CM control byte constants +assert TP_CM_RTS == 0x10 +assert TP_CM_CTS == 0x11 +assert TP_CM_EndOfMsgACK == 0x13 +assert TP_CM_BAM == 0x20 +assert TP_Conn_Abort == 0xFF + += _j1939_can_id builds 29-bit identifiers correctly +can_id = _j1939_can_id(7, 0xEC, 0xFF, 0x11) +assert can_id == 0x1CECFF11, "Expected 0x1CECFF11, got 0x{:08X}".format(can_id) +can_id = _j1939_can_id(6, 0xEC, 0x22, 0x11) +assert can_id == 0x18EC2211, "Expected 0x18EC2211, got 0x{:08X}".format(can_id) +can_id = _j1939_can_id(6, 0xEB, 0xFF, 0x11) +assert can_id == 0x18EBFF11, "Expected 0x18EBFF11, got 0x{:08X}".format(can_id) + += _j1939_decode_can_id round-trips correctly +for prio, pf, ps, sa in [(7, 0xEC, 0xFF, 0x11), (6, 0xEB, 0x22, 0xFE), (3, 0xFE, 0xCA, 0x80)]: + can_id = _j1939_can_id(prio, pf, ps, sa) + decoded = _j1939_decode_can_id(can_id) + assert decoded == (prio, pf, ps, sa), "Round-trip failed: in=({},{},{},{}) out={}".format(prio, pf, ps, sa, decoded) + += _pgn_from_can_id extracts PGN correctly +can_id = _j1939_can_id(7, 0xEC, 0x22, 0x11) +assert _pgn_from_can_id(can_id) == 0xEC00 +can_id = _j1939_can_id(7, 0xEB, 0x22, 0x11) +assert _pgn_from_can_id(can_id) == 0xEB00 +can_id = _j1939_can_id(6, 0xFE, 0xCA, 0x11) +assert _pgn_from_can_id(can_id) == 0xFECA + + ++ J1939 Packet + += J1939 packet construction +p = J1939(pgn=0xFECA, src_addr=0x11, dst_addr=0xFF, data=b"\x01\x02\x03") +assert p.pgn == 0xFECA +assert p.src_addr == 0x11 +assert p.dst_addr == 0xFF +assert p.data == b"\x01\x02\x03" + += J1939 packet default destination is global address +p = J1939(pgn=0xFECA, src_addr=0x11, data=b"\x01") +assert p.dst_addr == J1939_GLOBAL_ADDRESS + += J1939 packet equality +p1 = J1939(pgn=0xFECA, src_addr=0x11, dst_addr=0xFF, data=b"\xAB\xCD") +p2 = J1939(pgn=0xFECA, src_addr=0x11, dst_addr=0xFF, data=b"\xAB\xCD") +assert p1 == p2 +p3 = J1939(pgn=0xFECA, src_addr=0x11, dst_addr=0xFF, data=b"\xAB\xCE") +assert p1 != p3 + + ++ J1939SocketImplementation -- BAM receive (direct injection via on_can_recv) + += BAM receive of a 9-byte message (2 TP.DT packets) +def test_bam_rx_9bytes(): + pgn = 0xFECA + pgn_bytes = struct.pack(" CTS -> DT -> EndOfMsgACK +def test_cmdt_rx_rts_cts_dt_ack(): + with TestSocket(CAN) as can_sock, TestSocket(CAN) as monitor, J1939SoftSocket(can_sock, src_addr=0x22, dst_addr=0x11, pgn=0xFECA) as s: + can_sock.pair(monitor) + pgn = 0xFECA + pgn_bytes = struct.pack(" CTS (block 1) -> DT * bs -> CTS (block 2) -> DT +def test_cmdt_rx_block_size(): + with TestSocket(CAN) as can_sock, TestSocket(CAN) as monitor, J1939SoftSocket(can_sock, src_addr=0x22, dst_addr=0x11, pgn=0xFECA, bs=2) as s: + can_sock.pair(monitor) + pgn = 0xFECA + pgn_bytes = struct.pack(" CTS from peer -> DT -> EndOfMsgACK +def test_cmdt_tx(): + with TestSocket(CAN) as can_sock, TestSocket(CAN) as monitor, J1939SoftSocket(can_sock, src_addr=0x11, dst_addr=0x22, pgn=0xFECA) as s: + can_sock.pair(monitor) + s.impl.tp_cm_timeout = 2.0 + payload = bytes(range(1, 10)) + s.impl.begin_send(payload, 0xFECA, 0x22) + pkts = monitor.sniff(count=1, timeout=1) + assert len(pkts) == 1, "Expected TP.CM_RTS" + rts = pkts[0] + _, pf, ps, sa = _j1939_decode_can_id(rts.identifier) + assert pf == J1939_TP_CM_PF + assert ps == 0x22 + assert sa == 0x11 + assert rts.data[0] == TP_CM_RTS + assert struct.unpack_from(" J1939_TP_MAX_DLEN + s.impl.begin_send(b"A" * 2000, 0xFECA, 0xFF) + # tx_state != J1939_TX_IDLE + s.impl.tx_state = 1 + s.impl.begin_send(b"A" * 10, 0xFECA, 0xFF) + += on_can_recv edge cases +with J1939SoftSocket(TestSocket(CAN), src_addr=0x11, pgn=0xFECA) as s: + # unexpected PGN (emits warning once) + s.impl.on_can_recv(CAN(identifier=_j1939_can_id(6, 0xFE, 0xCB, 0x80), flags="extended", data=b"\x00")) + assert s.impl.filter_warning_emitted + # DA mismatch (PDU1) + s.impl.on_can_recv(CAN(identifier=_j1939_can_id(6, 0x01, 0x22, 0x80), flags="extended", data=b"\x00")) + + += Delete test sockets +cleanup_testsockets() +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + +log_runtime.removeHandler(handler) diff --git a/test/contrib/automotive/scanner/enumerator.uts b/test/contrib/automotive/scanner/enumerator.uts index b1ac0cc8716..5724e3dc99a 100644 --- a/test/contrib/automotive/scanner/enumerator.uts +++ b/test/contrib/automotive/scanner/enumerator.uts @@ -5,6 +5,9 @@ = Load contribution layer +import sys +sys.path.append(".") +sys.path.append("test") from scapy.contrib.automotive.scanner.enumerator import _AutomotiveTestCaseScanResult, ServiceEnumerator, StateGenerator, StateGeneratingServiceEnumerator from scapy.contrib.automotive.scanner.test_case import TestCaseGenerator, AutomotiveTestCase from scapy.contrib.automotive.scanner.executor import AutomotiveTestCaseExecutor @@ -1089,3 +1092,56 @@ assert args["req"] == UDS()/UDS_DSC(b"\x03") assert "diagnosticSessionType" in args["desc"] and "extendedDiagnosticSession" in args["desc"] assert not tce.enter_state(EcuState(session=1), EcuState(session=3)) + + ++ AutomotiveTestCaseExecutor Extra Coverage + += Executor additional properties and reduce +class DummyExecutor6(AutomotiveTestCaseExecutor): + @property + def default_test_case_clss(self): return [] + +s_conv = SingleConversationSocket(MockSock()) +exec_test = DummyExecutor6(s_conv) +assert isinstance(exec_test.socket, SingleConversationSocket) +assert exec_test.final_states == [EcuState(session=1)] +assert exec_test.scan_completed +assert exec_test.progress() == 0.0 +# __reduce__ test +res = exec_test.__reduce__() +assert "socket" not in res[2] + += Executor state generator / test case generator +from scapy.contrib.automotive.scanner.test_case import StateGenerator, TestCaseGenerator, AutomotiveTestCaseABC + +class DummyGen(AutomotiveTestCaseABC, StateGenerator, TestCaseGenerator): + def pre_execute(self, s, st, c): pass + def execute(self, s, st, **k): pass + def post_execute(self, s, st, c): pass + def get_new_edge(self, s, c): return (EcuState(session=1), EcuState(session=2)) + def get_transition_function(self, s, e): return lambda s,c,k: True + def get_generated_test_case(self): return None + def has_completed(self, s): return True + def completed(self, s): return True + def show(self): pass + @property + def supported_responses(self): return [] + +class DummyExecutor7(AutomotiveTestCaseExecutor): + @property + def default_test_case_clss(self): return [DummyGen] + +exec_test = DummyExecutor7(MockSock()) +exec_test.check_new_states(exec_test.configuration.test_cases[0]) +exec_test.check_new_testcases(exec_test.configuration.test_cases[0]) +assert EcuState(session=2) in exec_test.state_graph.nodes + += Executor cleanup robust +class DummyExecutor8(AutomotiveTestCaseExecutor): + @property + def default_test_case_clss(self): return [] + +def bad_cleanup(s, c): raise Scapy_Exception("bad") +exec_test = DummyExecutor8(MockSock()) +exec_test.cleanup_functions = [bad_cleanup, "not callable"] +exec_test.cleanup_state() diff --git a/test/contrib/cansocket_python_can.uts b/test/contrib/cansocket_python_can.uts index 78fa349d899..630c5d706d8 100644 --- a/test/contrib/cansocket_python_can.uts +++ b/test/contrib/cansocket_python_can.uts @@ -23,6 +23,9 @@ bashCommand = "/bin/bash -c 'sudo modprobe vcan; sudo ip link add name vcan0 typ = Load os ~ conf command +import sys +sys.path.append(".") +sys.path.append("test") import os import threading from subprocess import call @@ -618,3 +621,45 @@ if 0 != call(["sudo", "ip" ,"link", "delete", "vcan0"]): if 0 != call(["sudo", "ip" ,"link", "delete", "vcan1"]): raise Exception("vcan1 could not be deleted") + + ++ PythonCANSocket Extra Coverage (Platform Independent) + += _is_sw_filtered logic +from scapy.contrib.cansocket_python_can import _is_sw_filtered +assert _is_sw_filtered("slcan_0") +assert not _is_sw_filtered("socketcan_0") + += SocketsPool register / unregister / internal_send edge cases +import threading +from collections import deque +from scapy.contrib.cansocket_python_can import SocketsPool + +class DummyWrapper: + def __init__(self, name): + self.name = name + self.filters = None + self.lock = threading.Lock() + self.rx_queue = deque() + def _matches_filters(self, msg): return True + def shutdown(self): pass + +# internal_send not in pool +try: + SocketsPool.internal_send(DummyWrapper("none"), None) +except TypeError: + pass + +# register with interface (use virtual to avoid WinError on socketcan) +sock = DummyWrapper(None) +SocketsPool.register(sock, bustype="virtual", channel="vcan0") +assert sock.name == "virtual_vcan0" +SocketsPool.unregister(sock) + += PythonCANSocket.recv_raw with message +s = PythonCANSocket(bustype="virtual", channel="vcan0") +from can import Message as can_Message +msg = can_Message(arbitration_id=0x123, data=[1,2,3], is_extended_id=False) +s.can_iface.rx_queue.append(msg) +assert s.recv_raw()[1] is not None +s.close() diff --git a/test/contrib/isotp_soft_socket.uts b/test/contrib/isotp_soft_socket.uts index f112563d62a..ab273a1dce9 100644 --- a/test/contrib/isotp_soft_socket.uts +++ b/test/contrib/isotp_soft_socket.uts @@ -10,7 +10,7 @@ from io import BytesIO from scapy.layers.can import * from scapy.contrib.isotp import * from scapy.contrib.isotp.isotp_soft_socket import TimeoutScheduler -from test.testsocket import TestSocket, SlowTestSocket, cleanup_testsockets +from test.testsocket import TestSocket, SlowTestSocket, USBTestSocket, cleanup_testsockets with open(scapy_path("test/contrib/automotive/interface_mockup.py")) as f: exec(f.read()) @@ -1658,7 +1658,1339 @@ assert result is not None, "MF response not received (slcan, can_filters, thread assert result.data == expected -+ Cleanup ++ ISOTP socket reuse tests (TimeoutScheduler race fix verification) +# These tests verify the fix for a race condition in TimeoutScheduler +# that caused sr1() timeouts when reusing CAN adapters across multiple +# ISOTP socket open/close cycles (Python 3.12+ on Windows with slcan/candle). +# +# The root cause was a race window in _task(): between _peek_next() +# returning None (GRACE expired) and the `return` statement, schedule() +# could see _thread as not-None and skip starting a new thread. The old +# thread then died, orphaning the newly pushed handles. +# +# The fix: _task() now holds _mutex when deciding to die, atomically +# checking _handles one more time. If schedule() pushed a new handle, +# _task() sees it and stays alive instead of dying. + += TimeoutScheduler race fix: schedule() during GRACE-expiry is handled + +# Verifies the fix for the race condition where schedule() could run +# while _task() was about to die after GRACE expiration. +# +# The fix: _task() now holds _mutex when deciding to die and checks +# _handles one more time. If schedule() pushed a new handle while +# the thread was in the GRACE countdown, _task() sees it and stays +# alive instead of dying. +# +# This test exercises the real (fixed) code path: +# 1. Schedule a handle, let it fire, clear all handles +# 2. Wait for GRACE to expire (thread enters shutdown path) +# 3. Schedule a new handle — with the fix, the thread stays alive +# or schedule() starts a fresh thread +# 4. Assert the callback fires + +import time as _time +from threading import Thread as _Thread, Event as _Event + +# Clean slate +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + +callback_fired = _Event() + +# Schedule a dummy handle to start the thread, let it fire +TimeoutScheduler.schedule(0.001, lambda: None) +_time.sleep(0.02) +# Clear all handles — thread enters GRACE countdown +TimeoutScheduler.clear() +# Wait past GRACE so thread is in the process of dying or already dead +_time.sleep(TimeoutScheduler.GRACE + 0.05) + +# Now schedule a real callback — with the fix, this must succeed +TimeoutScheduler.schedule(0.01, callback_fired.set) + +# The callback MUST fire — this is the fix verification +assert callback_fired.wait(timeout=5.0), \ + "TimeoutScheduler race fix failed: callback did not fire " \ + "after scheduling during GRACE expiry" + +# Cleanup +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + += TimeoutScheduler race fix: rapid schedule after close/reopen cycle + +# Verifies that rapidly closing and reopening ISOTP sockets doesn't +# orphan TimeoutScheduler handles. Schedules handles, cancels them +# (simulating socket close), then immediately schedules new ones +# (simulating socket reopen). All new callbacks must fire. + +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + +for trial in range(5): + fired = _Event() + # Simulate socket open: schedule handles + h1 = TimeoutScheduler.schedule(0.005, lambda: None) + h2 = TimeoutScheduler.schedule(0.005, lambda: None) + _time.sleep(0.02) # let them fire + # Simulate socket close: cancel remaining + try: + h1.cancel() + except Exception: + pass + try: + h2.cancel() + except Exception: + pass + # Simulate socket reopen: schedule new handle immediately + TimeoutScheduler.schedule(0.01, fired.set) + assert fired.wait(timeout=5.0), \ + "Trial %d: callback did not fire after cancel/reschedule" % trial + +# Cleanup +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + += TimeoutScheduler race fix: ISOTP close/reopen with GRACE expiry + +# End-to-end test: open an ISOTPSoftSocket, close it, wait for GRACE +# to expire, then open a new one. With the fix, the second socket's +# callbacks (can_recv, _send) must be executed by the scheduler. + +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + # Iteration 1: works fine + with ISOTPSoftSocket(cans, tx_id=0x7e3, rx_id=0x7eb) as isock: + stop = _Event() + def ecu_r1(_stim=stim, _stop=stop): + while not _stop.is_set(): + if TestSocket.select([_stim], 0.05): + try: + pkt = _stim.recv() + except Exception: + break + if pkt is not None and hasattr(pkt, 'identifier'): + if pkt.identifier == 0x7e3: + _stim.send(CAN(identifier=0x7eb, + data=dhex("02 50 03"))) + t1 = _Thread(target=ecu_r1) + t1.start() + resp1 = isock.sr1(ISOTP(b'\x10\x03'), timeout=5, verbose=0) + stop.set() + t1.join(timeout=5) + assert resp1 is not None, "Iteration 1 should succeed" + # Wait past GRACE so thread fully enters shutdown path + _time.sleep(TimeoutScheduler.GRACE + 0.05) + # Iteration 2: open a new ISOTPSocket — scheduler must handle it + with ISOTPSoftSocket(cans, tx_id=0x7e3, rx_id=0x7eb) as isock2: + stop2 = _Event() + def ecu_r2(_stim=stim, _stop=stop2): + while not _stop.is_set(): + if TestSocket.select([_stim], 0.05): + try: + pkt = _stim.recv() + except Exception: + break + if pkt is not None and hasattr(pkt, 'identifier'): + if pkt.identifier == 0x7e3: + _stim.send(CAN(identifier=0x7eb, + data=dhex("02 50 03"))) + t2 = _Thread(target=ecu_r2) + t2.start() + resp2 = isock2.sr1(ISOTP(b'\x10\x03'), timeout=5, verbose=0) + stop2.set() + t2.join(timeout=5) + assert resp2 is not None, \ + "Iteration 2 timed out — TimeoutScheduler race fix failed" + +# Cleanup +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + += TimeoutScheduler race fix: stress test across GRACE boundaries + +# Stress test: rapidly schedule/wait/clear across many iterations to +# exercise the GRACE-expiry atomic check under real thread scheduling. +# Each iteration lets the thread die (or nearly die), then schedules +# a new callback. All callbacks must fire. + +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + +_missed = [] +for trial in range(20): + fired = _Event() + # Schedule and immediately let it fire + TimeoutScheduler.schedule(0.001, lambda: None) + _time.sleep(0.005) + # Clear handles — thread begins GRACE countdown + TimeoutScheduler.clear() + # Wait approximately GRACE time (sometimes under, sometimes over) + # to exercise both the "thread still alive" and "thread dead" paths + if trial % 2 == 0: + _time.sleep(TimeoutScheduler.GRACE + 0.02) + else: + _time.sleep(TimeoutScheduler.GRACE * 0.8) + # Schedule a new callback — must fire regardless of thread state + TimeoutScheduler.schedule(0.01, fired.set) + if not fired.wait(timeout=5.0): + _missed.append(trial) + +assert not _missed, \ + "Callbacks failed to fire on trials: %s" % _missed + +# Cleanup +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + += ISOTP socket reuse: both CANSocket and ISOTPSocket recreated each iteration + +# Reproduces the user's exact bug pattern: +# for m in messages: +# with CANSocket() as csock: +# with ISOTPSocket(csock) as isock: +# resp = isock.sr1(...) +# +# On Python 3.12+ on Windows, the 2nd/3rd iteration times out because +# the TimeoutScheduler thread dies during the close/reopen gap. +# On Linux, the race window is small so this test exercises the code +# path without deterministically triggering the race. + +def run_isotp_reuse_both_sockets_recreated(num_iterations=3): + """Run sr1 in a loop, recreating both sockets each time.""" + results = [] + for i in range(num_iterations): + with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with ISOTPSoftSocket(cans, tx_id=0x7e3, rx_id=0x7eb) as isock: + stop = _Event() + def ecu_responder(_stim=stim, _stop=stop): + """Echo back a SF response for any received request.""" + while not _stop.is_set(): + if TestSocket.select([_stim], 0.05): + try: + pkt = _stim.recv() + except Exception: + break + if pkt is not None and hasattr(pkt, 'identifier'): + if pkt.identifier == 0x7e3: + _stim.send(CAN(identifier=0x7eb, + data=dhex("02 50 03"))) + ecu_thread = _Thread(target=ecu_responder) + ecu_thread.start() + try: + resp = isock.sr1(ISOTP(b'\x10\x03'), timeout=5, verbose=0) + results.append(resp) + finally: + stop.set() + ecu_thread.join(timeout=5) + # Brief pause between iterations — on 3.11.9 this works fine, + # on 3.12+ the TimeoutScheduler thread may die here + _time.sleep(0.05) + return results + +results = run_isotp_reuse_both_sockets_recreated(3) +for i, r in enumerate(results): + assert r is not None, \ + "Iteration %d timed out (both sockets recreated)" % (i + 1) + +# Cleanup scheduler +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + += ISOTP socket reuse: CANSocket kept open, only ISOTPSocket recreated + +# Same bug but with the CANSocket kept across iterations. +# This proves the bug is in the ISOTP/TimeoutScheduler layer, +# not in the SocketsPool/CANSocket layer. + +def run_isotp_reuse_can_socket_kept(num_iterations=3): + """Run sr1 in a loop, keeping the CANSocket and recreating ISOTPSocket.""" + results = [] + with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + for i in range(num_iterations): + with ISOTPSoftSocket(cans, tx_id=0x7e3, rx_id=0x7eb) as isock: + stop = _Event() + def ecu_responder(_stim=stim, _stop=stop): + while not _stop.is_set(): + if TestSocket.select([_stim], 0.05): + try: + pkt = _stim.recv() + except Exception: + break + if pkt is not None and hasattr(pkt, 'identifier'): + if pkt.identifier == 0x7e3: + _stim.send(CAN(identifier=0x7eb, + data=dhex("02 50 03"))) + ecu_thread = _Thread(target=ecu_responder) + ecu_thread.start() + try: + resp = isock.sr1(ISOTP(b'\x10\x03'), timeout=5, verbose=0) + results.append(resp) + finally: + stop.set() + ecu_thread.join(timeout=5) + # Brief pause — TimeoutScheduler thread may die here + _time.sleep(0.05) + return results + +results = run_isotp_reuse_can_socket_kept(3) +for i, r in enumerate(results): + assert r is not None, \ + "Iteration %d timed out (CANSocket kept, ISOTPSocket recreated)" % (i + 1) + +# Cleanup scheduler +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + += ISOTP socket reuse: rapid open/close/reopen without GRACE delay + +# Variant where we do NOT wait between iterations. This tests the case +# where the TimeoutScheduler thread is still in the GRACE wait when new +# handles are scheduled. + +def run_isotp_reuse_rapid(num_iterations=3): + """Run sr1 in a loop with no delay between close and reopen.""" + results = [] + with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + for i in range(num_iterations): + with ISOTPSoftSocket(cans, tx_id=0x7e3, rx_id=0x7eb) as isock: + stop = _Event() + def ecu_responder(_stim=stim, _stop=stop): + while not _stop.is_set(): + if TestSocket.select([_stim], 0.05): + try: + pkt = _stim.recv() + except Exception: + break + if pkt is not None and hasattr(pkt, 'identifier'): + if pkt.identifier == 0x7e3: + _stim.send(CAN(identifier=0x7eb, + data=dhex("02 50 03"))) + ecu_thread = _Thread(target=ecu_responder) + ecu_thread.start() + try: + resp = isock.sr1(ISOTP(b'\x10\x03'), timeout=5, verbose=0) + results.append(resp) + finally: + stop.set() + ecu_thread.join(timeout=5) + # NO delay — immediate reopen + return results + +results = run_isotp_reuse_rapid(3) +for i, r in enumerate(results): + assert r is not None, \ + "Iteration %d timed out (rapid reopen, no delay)" % (i + 1) + +# Cleanup scheduler +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + += ISOTP socket reuse: CANSocket recreated with delay past GRACE period + +# Variant where we wait longer than GRACE (100ms) between iterations, +# ensuring the TimeoutScheduler thread has fully died. schedule() must +# reliably start a new thread. + +def run_isotp_reuse_post_grace(num_iterations=3): + """Run sr1 in a loop, waiting past GRACE between iterations.""" + results = [] + for i in range(num_iterations): + with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + with ISOTPSoftSocket(cans, tx_id=0x7e3, rx_id=0x7eb) as isock: + stop = _Event() + def ecu_responder(_stim=stim, _stop=stop): + while not _stop.is_set(): + if TestSocket.select([_stim], 0.05): + try: + pkt = _stim.recv() + except Exception: + break + if pkt is not None and hasattr(pkt, 'identifier'): + if pkt.identifier == 0x7e3: + _stim.send(CAN(identifier=0x7eb, + data=dhex("02 50 03"))) + ecu_thread = _Thread(target=ecu_responder) + ecu_thread.start() + try: + resp = isock.sr1(ISOTP(b'\x10\x03'), timeout=5, verbose=0) + results.append(resp) + finally: + stop.set() + ecu_thread.join(timeout=5) + # Wait past GRACE so thread is fully dead + _time.sleep(TimeoutScheduler.GRACE + 0.05) + return results + +results = run_isotp_reuse_post_grace(3) +for i, r in enumerate(results): + assert r is not None, \ + "Iteration %d timed out (post-GRACE delay)" % (i + 1) + +# Cleanup scheduler +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + += ISOTP socket reuse: MF (multi-frame) response across iterations + +# The original bug report uses UDS 0x1003 (DiagnosticSessionControl) +# which returns a multi-frame response. This test verifies that MF +# responses work across multiple open/close cycles, using the same +# ECU simulation pattern as the cartesian product tests above. + +def run_isotp_mf_reuse(num_iterations=3, keep_can_socket=True): + """Run MF sr1 exchange in a loop.""" + response_data = dhex("620001666c61677b5544535f444154415f524541447d") + results = [] + cans_ctx = None + stim_ctx = None + if keep_can_socket: + cans_ctx = TestSocket(CAN) + stim_ctx = TestSocket(CAN) + cans_ctx.pair(stim_ctx) + for i in range(num_iterations): + if not keep_can_socket: + cans_ctx = TestSocket(CAN) + stim_ctx = TestSocket(CAN) + cans_ctx.pair(stim_ctx) + cans = cans_ctx + stim = stim_ctx + with ISOTPSoftSocket(cans, tx_id=0x7e3, rx_id=0x7eb) as isock: + fc_received = _Event() + stop = _Event() + def ecu_mf_responder(_stim=stim, _fc=fc_received, _stop=stop): + """Send a multi-frame response after receiving FC.""" + _time.sleep(0.05) + _stim.send(CAN(identifier=0x7eb, + data=dhex("1016620001666c61"))) + _fc.wait(timeout=10.0) + if not _fc.is_set(): + return + _time.sleep(0.008) + _stim.send(CAN(identifier=0x7eb, + data=dhex("21677b5544535f44"))) + _time.sleep(0.010) + _stim.send(CAN(identifier=0x7eb, + data=dhex("224154415f524541"))) + _time.sleep(0.010) + _stim.send(CAN(identifier=0x7eb, + data=dhex("23447d"))) + with TestSocket(CAN) as ecu_mon: + cans.pair(ecu_mon) + def fc_watcher(_mon=ecu_mon, _fc=fc_received, _stop=stop): + while not _stop.is_set(): + if TestSocket.select([_mon], 0.1): + pkt = _mon.recv() + if pkt is not None and \ + pkt.identifier == 0x7e3 and \ + len(pkt.data) >= 1 and \ + bytes(pkt.data)[0] == 0x30: + _fc.set() + return + ecu_thread = _Thread(target=ecu_mf_responder) + fc_thread = _Thread(target=fc_watcher) + ecu_thread.start() + fc_thread.start() + try: + resp = isock.sr1(ISOTP(data=dhex("220001")), + retry=0, timeout=10.0, verbose=0) + results.append(resp) + finally: + stop.set() + fc_received.set() + ecu_thread.join(timeout=5) + fc_thread.join(timeout=5) + # Unpair ecu_mon before its context manager closes it; + # otherwise stim would try to send to a closed pipe + # on the next iteration since pair() is bidirectional. + try: + cans.paired_sockets.remove(ecu_mon) + except ValueError: + pass + # Cleanup scheduler between iterations to match real-world + # pattern where user code doesn't explicitly manage the scheduler + _time.sleep(0.05) + if not keep_can_socket: + cans_ctx.close() + stim_ctx.close() + if keep_can_socket: + cans_ctx.close() + stim_ctx.close() + return results, response_data + +results, expected = run_isotp_mf_reuse(3, keep_can_socket=True) +for i, r in enumerate(results): + assert r is not None, \ + "MF iteration %d timed out (CANSocket kept)" % (i + 1) + assert r.data == expected, \ + "MF iteration %d data mismatch (CANSocket kept)" % (i + 1) + +# Cleanup scheduler +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + += ISOTP socket reuse: MF response, both sockets recreated each iteration + +results, expected = run_isotp_mf_reuse(3, keep_can_socket=False) +for i, r in enumerate(results): + assert r is not None, \ + "MF iteration %d timed out (both recreated)" % (i + 1) + assert r.data == expected, \ + "MF iteration %d data mismatch (both recreated)" % (i + 1) + +# Cleanup scheduler +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + + += Orphan can_recv guard: closed ISOTP socket must not consume bus frames + +# Verifies the fix for the orphan-callback bug: +# When close() races with can_recv() on the TimeoutScheduler thread, +# the old handle can fire one last time after self.closed is set. +# Without the guard at the top of can_recv(), the orphan callback +# would call select() → multiplex_rx_packets() → recv() and +# consume response frames from the shared CAN bus that belong to +# the NEXT ISOTPSocket session. This causes the next sr1() to +# time out. +# +# This test deterministically reproduces the race: +# 1. Create an ISOTP socket, get its ISOTPSocketImplementation +# 2. Close the ISOTP socket (sets impl.closed = True) +# 3. Inject a response frame on the shared CAN bus +# 4. Call impl.can_recv() as if it were an orphan callback +# 5. Assert the CAN bus frame was NOT consumed + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + isock = ISOTPSoftSocket(cans, tx_id=0x7e3, rx_id=0x7eb) + impl = isock.impl + # Stop the background callbacks so we control timing + _ts = TimeoutScheduler._thread + TimeoutScheduler.clear() + if _ts is not None: + _ts.join(timeout=5) + # Close the ISOTP socket (simulates normal close) + isock.close() + assert impl.closed is True + # Now inject a response frame on the CAN bus + stim.send(CAN(identifier=0x7eb, data=dhex("02 50 03"))) + # Simulate the orphan callback firing after close + impl.can_recv() + # The CAN frame must still be on the bus (not consumed by orphan) + assert TestSocket.select([cans], 0.1), \ + "Frame was consumed by orphan can_recv — not available on bus" + pkt = cans.recv() + assert pkt is not None, "Frame was consumed by orphan can_recv" + assert pkt.identifier == 0x7eb + +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + += Adapter buffer overflow: shared CANSocket, USB adapter FIFO fills between sessions + +# Reproduces the USB adapter hardware buffer overflow bug: +# +# Pattern: with CANSocket(): for msg: with ISOTPSocket(): +# +# Between close() of one ISOTPSocket and __init__() of the next, +# nobody calls select() on the CANSocket. On USB adapters (candle, +# cantact) the hardware endpoint FIFO is small (32-128 frames). +# Background CAN traffic fills it while no ISOTP socket is active. +# When the next ISOTPSocket sends a request, the ECU's response +# frame arrives but the FIFO is already full → silently dropped. +# slcan doesn't have this issue because the OS serial buffer is +# much larger (4096+ bytes). +# +# The fix: __init__() drains the adapter buffer via select() before +# scheduling callbacks, and close() drains again after setting +# self.closed. Both drains call multiplex_rx_packets() which +# moves frames from hardware FIFO to the software rx_queue, +# freeing space for the ECU's response. +# +# This test uses USBTestSocket with a small hw_fifo_size to make +# the overflow deterministic even without real hardware. + +def run_usb_buffer_overflow_test(num_iterations=5, hw_fifo_size=8, + bg_frames_per_gap=12, + disable_drain=False): + """Run sr1 in a loop with a shared USB-like CANSocket. + Between iterations, inject bg_frames_per_gap background frames + to fill/overflow the USB adapter's hardware FIFO. + If disable_drain is True, re-fill the FIFO after __init__ drains + it, reproducing the pre-fix behavior where no drain occurred. + """ + import time as _time + from threading import Thread as _Thread, Event as _Event + from scapy.layers.can import CAN as _CAN + from scapy.contrib.isotp import ISOTP as _ISOTP + from scapy.contrib.isotp.isotp_soft_socket import ISOTPSoftSocket as _ISOTPSoftSocket + from scapy.contrib.isotp.isotp_soft_socket import TimeoutScheduler as _TS + from test.testsocket import TestSocket as _TestSocket, USBTestSocket as _USBTestSocket + _dhex = bytes.fromhex + results = [] + bg_ids = [0x062, 0x024, 0x039, 0x077, 0x098, 0x150] + with _USBTestSocket(_CAN, hw_fifo_size=hw_fifo_size) as cans, \ + _TestSocket(_CAN) as stim: + cans.pair(stim) + for iteration in range(num_iterations): + # Inject background traffic between sessions. + # On real hardware this comes from other ECUs on the bus. + # The frames go into the USB adapter's hardware FIFO. + # If nobody drains it, the FIFO overflows. + if iteration > 0: + for j in range(bg_frames_per_gap): + bid = bg_ids[j % len(bg_ids)] + stim.send(_CAN(identifier=bid, data=bytes(8))) + with _ISOTPSoftSocket(cans, tx_id=0x7e3, rx_id=0x7eb) as isock: + if disable_drain and iteration > 0: + # Undo the drain that __init__ performed by + # re-injecting the same amount of background + # frames back into the FIFO. This simulates + # the old code that didn't drain at all. + for j in range(bg_frames_per_gap): + bid = bg_ids[j % len(bg_ids)] + stim.send(_CAN(identifier=bid, data=bytes(8))) + stop = _Event() + def ecu_responder(_stim=stim, _stop=stop, _it=iteration): + while not _stop.is_set(): + if _TestSocket.select([_stim], 0.05): + try: + pkt = _stim.recv() + except Exception: + break + if pkt is not None and hasattr(pkt, 'identifier'): + if pkt.identifier == 0x7e3: + _stim.send(_CAN(identifier=0x7eb, + data=_dhex("02 50 03"))) + ecu_thread = _Thread(target=ecu_responder) + ecu_thread.start() + try: + resp = isock.sr1(_ISOTP(b'\x10\x03'), timeout=2, verbose=0) + results.append(resp) + finally: + stop.set() + ecu_thread.join(timeout=5) + _time.sleep(0.02) + dropped = cans.dropped_count + return results, dropped + +# Test 1: With the fix (current code), all iterations should succeed. +# The drains in __init__/close() keep the FIFO from overflowing. +results, dropped = run_usb_buffer_overflow_test( + num_iterations=5, hw_fifo_size=8, bg_frames_per_gap=12, + disable_drain=False) +for i, r in enumerate(results): + assert r is not None, \ + "Iteration %d timed out (USB buffer overflow, drain enabled)" % (i + 1) + +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + += Adapter buffer overflow: without drain, USB FIFO overflows and responses are lost + +# Test 2: Simulate the pre-fix behavior by re-filling the FIFO after +# __init__ drains it. This proves that without the drain, the USB +# adapter FIFO overflows and ECU responses are dropped. +results, dropped = run_usb_buffer_overflow_test( + num_iterations=5, hw_fifo_size=8, bg_frames_per_gap=12, + disable_drain=True) +# With the drain disabled (simulated), at least one iteration should +# fail due to FIFO overflow. On real hardware this is the 50-50 +# failure pattern seen with candle/cantact adapters. +failures = sum(1 for r in results if r is None) +assert failures > 0 or dropped > 0, \ + "Expected at least one timeout or FIFO drop when drain is disabled, " \ + "got %d failures, %d drops" % (failures, dropped) + +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + + += Stress test: hundreds of SF exchanges on persistent ISOTPSoftSocket + +# Exercises a single ISOTPSoftSocket for many request-response cycles +# without closing/reopening. The socket must reliably dispatch all +# exchanges via the same TimeoutScheduler callbacks. +# Uses short timeout (2s) per exchange to surface any timing bugs. + +import time as _time +from threading import Thread as _Thread, Event as _Event + +NUM_SF_EXCHANGES = 200 + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + stop = _Event() + exchange_count = [0] + def sf_ecu(_stim=stim, _stop=stop, _count=exchange_count): + """Auto-respond to any SF request with a SF response.""" + while not _stop.is_set(): + if TestSocket.select([_stim], 0.05): + try: + pkt = _stim.recv() + except Exception: + break + if pkt is not None and hasattr(pkt, 'identifier'): + if pkt.identifier == 0x7e3: + raw = bytes(pkt.data) + # Echo back the service ID + 0x40 + if len(raw) >= 2: + sid = raw[1] + _stim.send(CAN(identifier=0x7eb, + data=bytes([0x02, sid + 0x40, 0x03]))) + _count[0] += 1 + ecu_thread = _Thread(target=sf_ecu) + ecu_thread.start() + with ISOTPSoftSocket(cans, tx_id=0x7e3, rx_id=0x7eb) as isock: + failures = [] + for i in range(NUM_SF_EXCHANGES): + resp = isock.sr1(ISOTP(b'\x10\x03'), timeout=2, verbose=0) + if resp is None: + failures.append(i) + stop.set() + ecu_thread.join(timeout=10) + +assert len(failures) == 0, \ + "SF stress test: %d/%d timed out, first failures: %s" % ( + len(failures), NUM_SF_EXCHANGES, failures[:10]) + +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + += Stress test: hundreds of MF exchanges on persistent ISOTPSoftSocket + +# Same pattern but with multi-frame responses (4 CF). This exercises +# the full ISOTP state machine (FF → FC → CF×3) across many cycles +# on the same socket instance. + +NUM_MF_EXCHANGES = 100 + +def run_mf_stress(num_exchanges): + """Run num_exchanges MF request/response cycles on one ISOTPSoftSocket.""" + expected_data = dhex("620001666c61677b5544535f444154415f524541447d") + with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + stop = _Event() + with TestSocket(CAN) as ecu_mon: + cans.pair(ecu_mon) + def mf_ecu(_stim=stim, _mon=ecu_mon, _stop=stop): + """For each request, send an MF response (FF + 3 CF).""" + while not _stop.is_set(): + if TestSocket.select([_stim], 0.05): + try: + pkt = _stim.recv() + except Exception: + break + if pkt is not None and hasattr(pkt, 'identifier'): + if pkt.identifier == 0x7e3: + # Send FF + _stim.send(CAN(identifier=0x7eb, + data=dhex("1016620001666c61"))) + # Wait for FC from tester + fc_seen = False + deadline = _time.monotonic() + 5.0 + while not _stop.is_set() and _time.monotonic() < deadline: + if TestSocket.select([_mon], 0.05): + fp = _mon.recv() + if fp is not None and \ + fp.identifier == 0x7e3 and \ + len(fp.data) >= 1 and \ + bytes(fp.data)[0] == 0x30: + fc_seen = True + break + if not fc_seen: + continue + _time.sleep(0.002) + _stim.send(CAN(identifier=0x7eb, + data=dhex("21677b5544535f44"))) + _time.sleep(0.002) + _stim.send(CAN(identifier=0x7eb, + data=dhex("224154415f524541"))) + _time.sleep(0.002) + _stim.send(CAN(identifier=0x7eb, + data=dhex("23447d"))) + ecu_thread = _Thread(target=mf_ecu) + ecu_thread.start() + with ISOTPSoftSocket(cans, tx_id=0x7e3, rx_id=0x7eb) as isock: + failures = [] + mismatches = [] + for i in range(num_exchanges): + resp = isock.sr1(ISOTP(data=dhex("220001")), + retry=0, timeout=5, verbose=0) + if resp is None: + failures.append(i) + elif resp.data != expected_data: + mismatches.append(i) + stop.set() + ecu_thread.join(timeout=10) + # Unpair ecu_mon to avoid stale references + try: + cans.paired_sockets.remove(ecu_mon) + except ValueError: + pass + return failures, mismatches + +failures, mismatches = run_mf_stress(NUM_MF_EXCHANGES) +assert len(failures) == 0, \ + "MF stress test: %d/%d timed out, first failures: %s" % ( + len(failures), NUM_MF_EXCHANGES, failures[:10]) +assert len(mismatches) == 0, \ + "MF stress test: %d/%d data mismatch, first: %s" % ( + len(mismatches), NUM_MF_EXCHANGES, mismatches[:10]) + +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + += Stress test: UDS-scanner-style varying service IDs on persistent socket (SF) + +# Mimics UDS_ServiceEnumerator: iterates through different service IDs +# on the SAME ISOTPSoftSocket without closing/reopening. Each sr1() +# sends a different UDS service request and the ECU simulator replies +# with the matching positive response. This exercises the rx_queue +# across varying hashret/answers pairs — a late response from service +# N could confuse service N+1 if state isn't handled properly. + +import time as _time +from threading import Thread as _Thread, Event as _Event + +SCAN_RANGE = list(range(0x10, 0x3F)) # 47 services +NUM_SCAN_CYCLES = 5 # Repeat the scan range 5 times = 235 total exchanges + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + stop = _Event() + def uds_ecu_sf(_stim=stim, _stop=stop): + """ECU that responds to each UDS service with positive response.""" + while not _stop.is_set(): + if TestSocket.select([_stim], 0.05): + try: + pkt = _stim.recv() + except Exception: + break + if pkt is not None and hasattr(pkt, 'identifier'): + if pkt.identifier == 0x7e0: + raw = bytes(pkt.data) + if len(raw) >= 2: + pci_len = raw[0] + sid = raw[1] + # Positive response: SID + 0x40 + resp_sid = (sid + 0x40) & 0xFF + _stim.send(CAN(identifier=0x7e8, + data=bytes([0x02, resp_sid, 0x00]))) + ecu_thread = _Thread(target=uds_ecu_sf) + ecu_thread.start() + with ISOTPSoftSocket(cans, tx_id=0x7e0, rx_id=0x7e8) as isock: + failures = [] + wrong_resp = [] + total = 0 + for cycle in range(NUM_SCAN_CYCLES): + for sid in SCAN_RANGE: + req = ISOTP(data=bytes([sid, 0x00])) + resp = isock.sr1(req, timeout=2, verbose=0) + if resp is None: + failures.append((cycle, sid, total)) + elif len(resp.data) < 1 or resp.data[0] != ((sid + 0x40) & 0xFF): + wrong_resp.append((cycle, sid, total, bytes(resp.data))) + total += 1 + stop.set() + ecu_thread.join(timeout=10) + +assert len(failures) == 0, \ + "UDS SF scan stress: %d/%d timed out, first: %s" % ( + len(failures), total, failures[:5]) +assert len(wrong_resp) == 0, \ + "UDS SF scan stress: %d/%d wrong response, first: %s" % ( + len(wrong_resp), total, wrong_resp[:5]) + +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + += Stress test: UDS-scanner-style varying service IDs on persistent socket (MF) + +# Same pattern but some services return multi-frame responses. +# Services 0x22 (ReadDataByIdentifier) and 0x19 (ReadDTCInformation) +# return long MF responses; all others return SF. This tests the +# ISOTP state machine transitioning between SF and MF responses across +# many consecutive sr1() calls on the same socket. + +MF_SIDS = {0x22, 0x19} # Services that return multi-frame responses +NUM_MF_SCAN_CYCLES = 3 +MF_SCAN_RANGE = [0x10, 0x11, 0x19, 0x22, 0x27, 0x2E, 0x31, 0x3E] + +def run_uds_mf_scan_stress(num_cycles, scan_range, mf_sids): + """Scan with mixed SF/MF responses on a persistent socket.""" + mf_payload = dhex("0001AABBCCDD112233445566778899") # 15 bytes + with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + stop = _Event() + with TestSocket(CAN) as ecu_mon: + cans.pair(ecu_mon) + def uds_ecu_mf(_stim=stim, _mon=ecu_mon, _stop=stop): + """ECU with mixed SF/MF responses per service ID.""" + while not _stop.is_set(): + if TestSocket.select([_stim], 0.05): + try: + pkt = _stim.recv() + except Exception: + break + if pkt is None or not hasattr(pkt, 'identifier'): + continue + if pkt.identifier != 0x7e0: + continue + raw = bytes(pkt.data) + if len(raw) < 2: + continue + # Only process Single Frame requests (PCI type 0). + # Ignore FC frames (PCI type 3 = 0x3X) that the + # tester sends back during MF exchanges. + pci_type = (raw[0] >> 4) & 0x0F + if pci_type != 0: + continue + sid = raw[1] + resp_sid = (sid + 0x40) & 0xFF + if sid in mf_sids: + # Multi-frame response: FF + CFs + # Build 16-byte payload: resp_sid + 15 bytes + resp_data = bytes([resp_sid]) + mf_payload + ff_data = bytes([0x10, len(resp_data)]) + resp_data[:6] + _stim.send(CAN(identifier=0x7e8, data=ff_data)) + # Wait for FC + fc_seen = False + deadline = _time.monotonic() + 5.0 + while not _stop.is_set() and _time.monotonic() < deadline: + if TestSocket.select([_mon], 0.05): + fp = _mon.recv() + if fp is not None and \ + fp.identifier == 0x7e0 and \ + len(fp.data) >= 1 and \ + (bytes(fp.data)[0] >> 4) == 3: + fc_seen = True + break + if not fc_seen: + continue + _time.sleep(0.002) + cf1 = bytes([0x21]) + resp_data[6:13] + _stim.send(CAN(identifier=0x7e8, data=cf1)) + _time.sleep(0.002) + cf2 = bytes([0x22]) + resp_data[13:16] + _stim.send(CAN(identifier=0x7e8, data=cf2)) + else: + # Single-frame response + _stim.send(CAN(identifier=0x7e8, + data=bytes([0x02, resp_sid, 0x00]))) + ecu_thread = _Thread(target=uds_ecu_mf) + ecu_thread.start() + with ISOTPSoftSocket(cans, tx_id=0x7e0, rx_id=0x7e8) as isock: + failures = [] + wrong_resp = [] + total = 0 + for cycle in range(num_cycles): + for sid in scan_range: + req = ISOTP(data=bytes([sid, 0x00])) + resp = isock.sr1(req, timeout=5, verbose=0) + expected_sid = (sid + 0x40) & 0xFF + if resp is None: + failures.append((cycle, sid, total)) + elif len(resp.data) < 1 or resp.data[0] != expected_sid: + wrong_resp.append((cycle, sid, total, bytes(resp.data))) + total += 1 + stop.set() + ecu_thread.join(timeout=10) + try: + cans.paired_sockets.remove(ecu_mon) + except ValueError: + pass + return failures, wrong_resp, total + +failures, wrong_resp, total = run_uds_mf_scan_stress( + NUM_MF_SCAN_CYCLES, MF_SCAN_RANGE, MF_SIDS) +assert len(failures) == 0, \ + "UDS MF scan stress: %d/%d timed out, first: %s" % ( + len(failures), total, failures[:5]) +assert len(wrong_resp) == 0, \ + "UDS MF scan stress: %d/%d wrong response, first: %s" % ( + len(wrong_resp), total, wrong_resp[:5]) + +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + += Stress test: close/reopen cycles between service groups (scanner reconnect pattern) + +# Mimics UDS_Scanner reconnect_handler: after scanning one ECU state, +# close the ISOTPSoftSocket and create a new one (same CANSocket). +# This is the pattern that triggers the TimeoutScheduler race and +# orphan-callback bugs at scale. Each group does 10 service probes, +# then close/reopen. + +NUM_GROUPS = 20 +PROBES_PER_GROUP = 10 +GROUP_SCAN_RANGE = list(range(0x10, 0x10 + PROBES_PER_GROUP)) + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + stop = _Event() + def group_ecu(_stim=stim, _stop=stop): + """ECU that responds to any service probe.""" + while not _stop.is_set(): + if TestSocket.select([_stim], 0.05): + try: + pkt = _stim.recv() + except Exception: + break + if pkt is not None and hasattr(pkt, 'identifier'): + if pkt.identifier == 0x7e0: + raw = bytes(pkt.data) + if len(raw) >= 2: + sid = raw[1] + resp_sid = (sid + 0x40) & 0xFF + _stim.send(CAN(identifier=0x7e8, + data=bytes([0x02, resp_sid, 0x00]))) + ecu_thread = _Thread(target=group_ecu) + ecu_thread.start() + failures = [] + total = 0 + for group in range(NUM_GROUPS): + with ISOTPSoftSocket(cans, tx_id=0x7e0, rx_id=0x7e8) as isock: + for sid in GROUP_SCAN_RANGE: + req = ISOTP(data=bytes([sid, 0x00])) + resp = isock.sr1(req, timeout=2, verbose=0) + if resp is None: + failures.append((group, sid, total)) + total += 1 + # Brief pause between groups — mimics scanner state transition + _time.sleep(0.02) + stop.set() + ecu_thread.join(timeout=10) + +assert len(failures) == 0, \ + "Reconnect stress: %d/%d timed out, first: %s" % ( + len(failures), total, failures[:10]) + +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + += Stress test: rapid close/reopen with no delay (worst-case reconnect) + +# Same as above but with ZERO delay between groups. This maximises +# the race window where the TimeoutScheduler thread may die during +# close and the next ISOTPSocket.__init__ needs it alive. + +NUM_RAPID_GROUPS = 30 +RAPID_PROBES = 5 + +with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + stop = _Event() + def rapid_ecu(_stim=stim, _stop=stop): + """ECU for rapid reconnect test.""" + while not _stop.is_set(): + if TestSocket.select([_stim], 0.05): + try: + pkt = _stim.recv() + except Exception: + break + if pkt is not None and hasattr(pkt, 'identifier'): + if pkt.identifier == 0x7e0: + raw = bytes(pkt.data) + if len(raw) >= 2: + sid = raw[1] + _stim.send(CAN(identifier=0x7e8, + data=bytes([0x02, (sid + 0x40) & 0xFF, 0x00]))) + ecu_thread = _Thread(target=rapid_ecu) + ecu_thread.start() + failures = [] + total = 0 + for group in range(NUM_RAPID_GROUPS): + with ISOTPSoftSocket(cans, tx_id=0x7e0, rx_id=0x7e8) as isock: + for probe in range(RAPID_PROBES): + sid = 0x10 + (probe % 0x30) + req = ISOTP(data=bytes([sid, 0x00])) + resp = isock.sr1(req, timeout=2, verbose=0) + if resp is None: + failures.append((group, sid, total)) + total += 1 + # NO delay — immediate close/reopen + stop.set() + ecu_thread.join(timeout=10) + +assert len(failures) == 0, \ + "Rapid reconnect stress: %d/%d timed out, first: %s" % ( + len(failures), total, failures[:10]) + +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +if _ts is not None: + _ts.join(timeout=5) + + += TimeoutScheduler thread death recovery: stale _thread reference must not block new threads + +# Simulates the scenario where the TimeoutScheduler thread dies from a +# BaseException (or any unexpected exit) that leaves _thread as a stale +# reference. The schedule() fix detects the dead thread via is_alive() +# and starts a fresh one, so ISOTP callbacks resume normally. + +import time as _time +from threading import Thread as _Thread, Event as _Event + +# Clean slate +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +_ts and _ts.join(timeout=5) + +# Schedule a simple callback to get the thread running +_fired = _Event() +def _dummy_cb(_e=_fired): + _e.set() + +TimeoutScheduler.schedule(0, _dummy_cb) +_fired.wait(timeout=2) +assert _fired.is_set(), "Initial callback did not fire" + +# Wait for the thread to go idle and die (GRACE = 0.1s) +_time.sleep(0.3) + +# Now simulate a stale _thread: set _thread to a dead Thread object. +# This mimics what would happen if _task() exited without clearing +# _thread (the bug that the finally block fixes). +with TimeoutScheduler._mutex: + _dead = _Thread(target=lambda: None) + _dead.start() + _dead.join() + assert not _dead.is_alive() + TimeoutScheduler._thread = _dead + +# schedule() should detect the dead thread and start a new one +_fired2 = _Event() +def _recovery_cb(_e=_fired2): + _e.set() + +TimeoutScheduler.schedule(0, _recovery_cb) +_fired2.wait(timeout=2) +assert _fired2.is_set(), \ + "Recovery callback did not fire — schedule() failed to detect dead thread" + +# Verify ISOTP sockets still work after thread recovery +def run_isotp_after_recovery(): + with TestSocket(CAN) as cans, TestSocket(CAN) as stim: + cans.pair(stim) + stop = _Event() + def recovery_ecu(_stim=stim, _stop=stop): + while not _stop.is_set(): + if TestSocket.select([_stim], 0.05): + try: + pkt = _stim.recv() + except Exception: + break + if pkt is not None and hasattr(pkt, 'identifier'): + if pkt.identifier == 0x7e0: + raw = bytes(pkt.data) + if len(raw) >= 2: + sid = raw[1] + _stim.send(CAN(identifier=0x7e8, + data=bytes([0x02, (sid + 0x40) & 0xFF, 0x00]))) + ecu_thread = _Thread(target=recovery_ecu) + ecu_thread.start() + with ISOTPSoftSocket(cans, tx_id=0x7e0, rx_id=0x7e8) as isock: + for sid in [0x10, 0x11, 0x22]: + req = ISOTP(data=bytes([sid, 0x00])) + resp = isock.sr1(req, timeout=2, verbose=0) + assert resp is not None, \ + "ISOTP sr1 failed for SID 0x%02x after thread recovery" % sid + stop.set() + ecu_thread.join(timeout=10) + +run_isotp_after_recovery() + +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +_ts and _ts.join(timeout=5) + += UDS_Scanner reset_handler leaked socket causes CAN frame theft + +# When reset_handler is a socket factory, the returned socket must be +# closed by reset_target(). If it is not, its can_recv callback +# steals CAN frames from the active session, causing sr1 timeouts. + +import time as _time +from threading import Thread as _Thread, Event as _Event +from scapy.contrib.automotive.uds import UDS + +COMMON_SIDs = [0x10, 0x11, 0x14, 0x19, 0x22, 0x27, 0x28, 0x3E, 0x85] + +leaked = [] +cans_isotp = TestSocket(CAN) +stim_isotp = TestSocket(CAN) +cans_isotp.pair(stim_isotp) +stop = _Event() + +def uds_ecu_nr_100(): + while not stop.is_set(): + if TestSocket.select([stim_isotp], 0.05): + try: + pkt = stim_isotp.recv() + except Exception: + break + if pkt is None or not hasattr(pkt, 'identifier'): + continue + if pkt.identifier != 0x7e0: + continue + raw_d = bytes(pkt.data) + if len(raw_d) < 2: + continue + if (raw_d[0] >> 4) != 0: + continue + sid = raw_d[1] + stim_isotp.send(CAN(identifier=0x7e8, data=bytes([0x03, 0x7f, sid, 0x13, 0x00, 0x00, 0x00, 0x00]))) + +def socket_factory_100(): + s = ISOTPSoftSocket(cans_isotp, tx_id=0x7e0, rx_id=0x7e8, basecls=UDS) + leaked.append(s) + return s + +ecu_thread = _Thread(target=uds_ecu_nr_100) +ecu_thread.start() +from scapy.contrib.automotive.uds_scan import UDS_ServiceEnumerator, UDS_Scanner +scanner = UDS_Scanner(socket_factory_100(), reconnect_handler=socket_factory_100, reset_handler=socket_factory_100, test_cases=[UDS_ServiceEnumerator], UDS_ServiceEnumerator_kwargs={"scan_range": COMMON_SIDs, "timeout": 2}) +scanner.scan() +stop.set() +ecu_thread.join(timeout=10) +enum = scanner.configuration.test_cases[0] +failures = [r for r in enum.results if r[2] is None] + +def cleanup_leaked(socks): + for s in socks: + try: + if not s.closed: + s.close() + except Exception: + pass + +cleanup_leaked(leaked) +cans_isotp.close() +stim_isotp.close() +assert len(failures) == 0, "UDS scanner had %d/%d timeouts (leaked reset_handler socket): first=%s" % (len(failures), len(enum.results), [(hex(r[1].service),) for r in failures[:5]]) + + ++ ISOTPSoftSocket Extra Coverage + += ISOTPSocketImplementation.can_send FD padding +with ISOTPSoftSocket(TestSocket(CAN), fd=True, padding=True) as s: + s.impl.can_send(b"\x00"*10) # Should pad to 12 + += ISOTPSocketImplementation.on_can_recv identifier mismatch +with ISOTPSoftSocket(TestSocket(CAN), rx_id=0x123) as s: + s.impl.on_can_recv(CAN(identifier=0x124, data=b"\x00")) + assert s.impl.filter_warning_emitted + += _rx_timer_handler timeout reset +with ISOTPSoftSocket(TestSocket(CAN), rx_id=0x123) as s: + s.impl.rx_state = 2 # ISOTP_WAIT_DATA + s.impl.rx_start_time = TimeoutScheduler._time() + s.impl._rx_timer_handler() + assert s.impl.rx_state == 2 # Still waiting due to extension + += _tx_timer_handler edge cases +with ISOTPSoftSocket(TestSocket(CAN), tx_id=0x123) as s: + s.impl.tx_state = 1 # ISOTP_SENDING + s.impl.tx_buf = None + s.impl._tx_timer_handler() + assert s.impl.tx_state == 0 # ISOTP_IDLE + += _recv_fc / _recv_sf / _recv_ff / _recv_cf edge cases +with ISOTPSoftSocket(TestSocket(CAN), rx_id=0x123) as s: + # _recv_fc short + s.impl.tx_state = 3 # ISOTP_WAIT_FC + s.impl._recv_fc(b"\x30\x00") + assert s.impl.tx_state == 0 + # _recv_fc unknown + s.impl.tx_state = 3 + s.impl._recv_fc(b"\x3F\x00\x00") + assert s.impl.tx_state == 0 + # _recv_sf FD + s.impl.fd = True + s.impl._recv_sf(b"\x00\x01\xAA", 1.23) + # _recv_ff short + s.impl._recv_ff(b"\x10\x00\x00\x00\x00\x00", 1.23) + assert s.impl.rx_state == 0 + # _recv_ff 32-bit length + s.impl._recv_ff(b"\x10\x00\x00\x00\x00\x0A\xAA\xBB\xCC\xDD", 1.23) + assert s.impl.rx_len == 10 + # _recv_cf various + s.impl.rx_state = 2 + s.impl.rx_ll_dl = 8 + s.impl._recv_cf(b"\x21\xAA\xBB\xCC\xDD\xEE\xFF\x00\x11") # Too long + assert s.impl.rx_state == 2 + s.impl.rx_sn = 2 + s.impl._recv_cf(b"\x21\xAA") # Wrong SN + assert s.impl.rx_state == 0 + += begin_send busy / too much data +with ISOTPSoftSocket(TestSocket(CAN), tx_id=0x123) as s: + s.impl.tx_state = 1 + s.impl.begin_send(b"data") # Busy + s.impl.tx_state = 0 + s.impl.begin_send(b"A" * 5000) # Too much data + + += Delete testsockets + +_ts = TimeoutScheduler._thread +TimeoutScheduler.clear() +_ts and _ts.join(timeout=5) = Delete testsockets diff --git a/test/testsocket.py b/test/testsocket.py index 1ecd79f4fec..e6577b15fb0 100644 --- a/test/testsocket.py +++ b/test/testsocket.py @@ -183,7 +183,8 @@ class SlowTestSocket(TestSocket): PythonCANSocket on a slow serial interface (like slcan). Frames sent to this socket go into an intermediate serial buffer. - They only become visible to recv()/select() after mux() moves + They only become visible to recv()/select() after _mux() moves + them to the rx ObjectPipe. Key parameters model the real slcan timing bottleneck: @@ -221,6 +222,7 @@ def __init__(self, basecls=None, frame_delay=0.0002, self.interface_name = interface_name from collections import deque self._serial_buffer = deque() # type: deque[bytes] + self._serial_lock = Lock() self._last_mux = 0.0 self._frame_delay = frame_delay @@ -258,6 +260,7 @@ def _mux(self): return # Phase 1: read_bus — read frames from serial buffer + msgs = [] deadline = time.monotonic() + self._read_time_limit \ if self._read_time_limit > 0 else None @@ -281,6 +284,7 @@ def _mux(self): break # Phase 2: distribute — apply per-socket filtering + for frame in msgs: if self._can_filters is not None: can_id = self._extract_can_id(frame) @@ -292,7 +296,8 @@ def _mux(self): def recv_raw(self, x=MTU): # type: (int) -> Tuple[Optional[Type[Packet]], Optional[bytes], Optional[float]] # noqa: E501 - """Read from the rx ObjectPipe (populated by mux via select).""" + """Read from the rx ObjectPipe (populated by _mux via select).""" + return self.basecls, self._real_ins.recv(0), time.time() def send(self, x): @@ -344,6 +349,114 @@ def closed(self): return bool(self._owner._real_ins.closed) # type: ignore[attr-defined] +class USBTestSocket(TestSocket): + """A TestSocket that simulates the hardware RX FIFO of USB CAN + adapters like candle (gs_usb) and cantact. + + USB adapters have a small hardware endpoint buffer (typically + 32-128 frames). Frames that arrive when the buffer is full + are silently dropped by the adapter firmware. + + Frames sent to this socket go into a capacity-limited deque + (the "hardware FIFO"). They only become visible to recv()/select() + after _mux() moves them to the rx ObjectPipe — which happens when + ISOTPSocketImplementation calls can_socket.select() in its + can_recv callback, or in the drain calls in __init__/close. + + When nobody calls select/recv (e.g., between ISOTP socket close + and reopen), frames accumulate in the FIFO. Once the FIFO is + full, new frames are silently dropped, simulating hardware + overflow. + """ + + def __init__(self, basecls=None, hw_fifo_size=32): + # type: (Optional[Type[Packet]], int) -> None + """ + :param hw_fifo_size: Maximum number of frames in the simulated + hardware RX FIFO. Default 32 models a typical USB endpoint + buffer. Frames beyond this limit are silently dropped. + """ + super(USBTestSocket, self).__init__(basecls) + from collections import deque + self._hw_fifo = deque(maxlen=hw_fifo_size) # type: deque[bytes] + self._hw_lock = Lock() + self._real_ins = self.ins + self.ins = _USBPipeWrapper(self) # type: ignore[assignment] + self.dropped_count = 0 + + def _mux(self): + # type: () -> None + """Move frames from hardware FIFO to the rx ObjectPipe. + + This models the read path of PythonCANSocket.select() → + multiplex_rx_packets() → read_bus(). On real hardware this + is the USB bulk transfer that moves frames from the adapter + endpoint buffer into the host-side python-can rx_queue. + """ + with self._hw_lock: + while self._hw_fifo: + frame = self._hw_fifo.popleft() + self._real_ins.send(frame) + + def recv_raw(self, x=MTU): + # type: (int) -> Tuple[Optional[Type[Packet]], Optional[bytes], Optional[float]] # noqa: E501 + return self.basecls, self._real_ins.recv(0), time.time() + + @staticmethod + def select(sockets, remain=conf.recv_poll_rate): + # type: (List[SuperSocket], Optional[float]) -> List[SuperSocket] + for s in sockets: + if isinstance(s, USBTestSocket): + s._mux() + return select_objects(sockets, remain) + + def close(self): + # type: () -> None + self.ins = self._real_ins + super(USBTestSocket, self).close() + + +class _USBPipeWrapper: + """Wrapper that routes incoming frames into the hardware FIFO. + + When the FIFO is full (maxlen reached), deque silently drops the + oldest frame — but real USB adapters drop the *newest* frame. + We track drops via owner.dropped_count so the test can verify + overflow occurred. + """ + def __init__(self, owner): + # type: (USBTestSocket) -> None + self._owner = owner + + def send(self, data): + # type: (bytes) -> None + with self._owner._hw_lock: + was_full = len(self._owner._hw_fifo) >= \ + (self._owner._hw_fifo.maxlen or 0) + if was_full: + # Drop the incoming frame (newest) like real hardware + self._owner.dropped_count += 1 + return + self._owner._hw_fifo.append(data) + + def recv(self, timeout=0): + # type: (int) -> Optional[bytes] + return self._owner._real_ins.recv(timeout) + + def fileno(self): + # type: () -> int + return self._owner._real_ins.fileno() + + def close(self): + # type: () -> None + self._owner._real_ins.close() + + @property + def closed(self): + # type: () -> bool + return bool(self._owner._real_ins.closed) # type: ignore[attr-defined] + + def cleanup_testsockets(): # type: () -> None """ diff --git a/tox.ini b/tox.ini index 442ba9fdab1..eddec6e8723 100644 --- a/tox.ini +++ b/tox.ini @@ -192,5 +192,6 @@ per-file-ignores = scapy/libs/winpcapy.py:F405,F403,E501 scapy/libs/manuf.py:E501 scapy/tools/UTscapy.py:E501 + scapy/contrib/automotive/j1939/j1939_scanner.py:E501 exclude = scapy/libs/ethertypes.py, scapy/layers/msrpce/raw/*