Implement J1939 Soft Socket for SAE J1939 Transport Protocol in Python - #5011
Implement J1939 Soft Socket for SAE J1939 Transport Protocol in Python#5011polybassa wants to merge 12 commits into
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #5011 +/- ##
==========================================
+ Coverage 80.64% 80.78% +0.14%
==========================================
Files 390 390
Lines 96936 97471 +535
==========================================
+ Hits 78175 78743 +568
+ Misses 18761 18728 -33
🚀 New features to boost your workflow:
|
|
@BenGardiner Please have a look. |
|
Hi @polybassa , :) it looks like a much better implementation of what I tried out over in https://github.com/BenGardiner/scapy/tree/j1939_scanning . Thank you. I will try to rebase and port my scanner, DM , DM scanner and address claim implementation onto this branch and see how well those features still work. |
870efc6 to
ef10b34
Compare
|
hi @polybassa I've been testing this with my scanner code rebased and it is working well. See rebase of scanner stuff in https://github.com/BenGardiner/scapy/commits/j1939-soft-sockets-again (last three commits) |
|
Awesome!!! So we can go forward merging this |
|
yes I think merging it is a good idea. the scanning works both with a gs_usb adapter via python-candle . I would like to get the scanning merged too of course :) I am just confirming now that it works also with an slcan adapter via python-con built-in. This takes much longer |
AI-Assisted: yes (GitHub Copilot)
AI-Assisted: yes (GitHub Copilot)
AI-Assisted: yes (Claude Sonnet 4.6)
AI-Assisted: no
…ostile transfers The soft socket handled the transfers its own tests produce, all of them under 100 bytes from a single well-behaved peer. Everything past that was either dropped or actively harmful, and none of it was visible because no test exceeded one CTS block, ran two senders, or sent a malformed frame. A payload of more than 1785 bytes needs more TP.DT packets than a sequence number can express, so building the announcement raised inside the scheduler thread after the TX state had already been set. The message vanished with a log line and the state machine stayed latched, which silently discarded every later send on that socket. send() now refuses such a payload, _J1939_TP_MAX_DATA finally being used for what it was defined for, and a failure anywhere in _begin_send resets the machine instead of wedging it. Reception was a single set of rx_* attributes, so a second announcement threw away the transfer in progress. Since a busy J1939 bus has several ECUs broadcasting at once, a monitor built on this socket lost most of what it saw. Sessions now live in a dict keyed by the (source address, destination) pair the protocol itself uses, capped so a hostile bus cannot grow it without bound, and a peer that asks for a second PGN while one is running is refused with an abort rather than displacing it. Frames from the bus are no longer taken at face value. An announcement whose size and packet count cannot describe a message is refused instead of delivering an empty payload; a CTS naming a packet outside the message is aborted instead of indexing the buffer backwards and emitting sequence number 0; and CTS, acknowledgement and abort frames must now name the PGN of the session they claim to be part of. tx_peer_sa is cleared when a session ends, so a node that took part in an earlier transfer can no longer abort an unrelated broadcast. The rest are smaller: the receiver honours the max_packets of a request and issues a CTS per block instead of authorising everything at once, close() derives its drain budget from what is left to send rather than truncating any broadcast longer than two seconds while __del__ no longer drains at all, basecls is used for delivered messages and recv_raw returns the payload, transport frames carry the caller's priority as single frames already did, a source address of 0xFF warns because it cannot legally appear on the wire, and a CAN socket that goes away closes the J1939 socket instead of leaving a caller blocked in recv() forever. The twelve new cases in the campaign each fail on the code before this commit and pass after it. The existing 185 are untouched and still pass. AI-Assisted: yes (Cursor) Co-authored-by: Cursor <cursoragent@cursor.com>
Four things the first pass got wrong or left rough. send() measured the payload differently from the code that transmits it, so the size guard and the wire could disagree for a message whose data was not bytes; both now go through one helper. The check for a CAN socket that has gone away was written twice in can_recv, once at each end. The new basecls parameter and the per-peer session model were undocumented. And the priority a caller asks for now reaches the TP.CM and TP.DT frames of a multi-packet message, which is a deliberate change of default from the 7 the code used to hardcode for TP.DT, so the docstring says so. AI-Assisted: yes (Cursor) Co-authored-by: Cursor <cursoragent@cursor.com>
… hid Four handlers cancelled a scheduler timeout inside try/except/pass, which Codacy flags and which hides a real failure as readily as the expected one. The expected one is narrow: TimeoutScheduler raises Scapy_Exception when a timeout has already fired or been cancelled, which races normally against the state machine dropping it. One _cancel helper now does that in the five places that needed it, logging anything else at debug level, and send() sets sent_time behind an isinstance check rather than catching the AttributeError a non-packet would raise. Building a connection abort was written out five times and refusing a session three times; both are helpers now, which is what made it obvious that the check for a TP.DT past the authorised block can never fire: the next CTS is sent from the same handler that completes a block, so the window it guards does not exist. Writing the test for it is what showed that, and both the branch and the test are gone. The four new cases cover what had no test: the session table filling up and answering with 'system resources', a stalled reception aborting with 'timeout' once its wall-clock ceiling passes, a sender aborting a reception it started, and close(timeout=0) as the way to give up on a transfer on purpose. AI-Assisted: yes (Cursor) Co-authored-by: Cursor <cursoragent@cursor.com>
Propagate adapter send failures so TX resets instead of advancing past a frame that never left the host, accept EOM ACK only after every DT was sent with matching size/count, and enforce BAM vs directed control destination rules. AI-Assisted: yes (Cursor) Co-authored-by: Cursor <cursoragent@cursor.com>
Drain the CAN hardware RX FIFO at startup and close, poll with zero delay while TP is active, reject new sends while closing, preserve received TP priority, and reassemble into a bytearray. AI-Assisted: yes (Cursor) Co-authored-by: Cursor <cursoragent@cursor.com>
Use J1939_NO_PGN as the accept-all sentinel so real PGN 0 is filterable, drop dead TX state, reuse from_can, narrow recv closure handling, and make the portable soft-socket tests not inherit a campaign linux tag. AI-Assisted: yes (Cursor) Co-authored-by: Cursor <cursoragent@cursor.com>
ec4a1c1 to
4fb1642
Compare
|
@gpotter2 this PR is ready from our side. |
There was a problem hiding this comment.
🟡 Changes recommended
The added UTScapy campaign has at least one definite runtime failure (missing SuperSocket import) plus a global-constant override that is not restored safely on failure, risking cascading test breakage.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR adds a pure-Python “soft socket” implementation of the SAE J1939 Transport Protocol (J1939-21) to Scapy’s J1939 contrib module, enabling segmentation/reassembly over any CANSocket (without relying on the Linux kernel CAN_J1939 stack), and introduces an extensive UTScapy campaign to validate behavior across BAM and RTS/CTS flows.
Changes:
- Implement
J1939SoftSocket+J1939TPImplementationstate machine inscapy/contrib/j1939.py(TP.CM/TP.DT handling, timing, session tracking, background polling, close/drain behavior). - Expand
test/contrib/j1939.utswith comprehensive unit tests for soft-socket RX/TX, edge cases, robustness regressions, and interoperability scenarios.
File summaries
| File | Description |
|---|---|
scapy/contrib/j1939.py |
Adds the J1939 transport-protocol soft-socket implementation and supporting state machine utilities. |
test/contrib/j1939.uts |
Adds a large UTScapy campaign validating soft-socket behavior (single-frame, BAM, RTS/CTS, timeouts, robustness, and interop). |
Review details
- Files reviewed: 1/2 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| import scapy.contrib.j1939 as _j1939_mod | ||
| _saved_T1 = _j1939_mod._J1939_TP_T1 | ||
| _saved_T2 = _j1939_mod._J1939_TP_T2 | ||
| _j1939_mod._J1939_TP_T1 = 0.1 | ||
| _j1939_mod._J1939_TP_T2 = 0.1 | ||
|
|
||
| with TestSocket(CAN) as cans, TestSocket(CAN) as stim: | ||
| cans.pair(stim) | ||
| with J1939SoftSocket(cans, src_addr=0x00) as sock: | ||
| stim.send(J1939_CAN(priority=6, pdu_format=0xEC, pdu_specific=0xFF, | ||
| src=0x0B, | ||
| data=bytes(J1939_TP_CM_BAM(total_size=9, | ||
| num_packets=2, | ||
| pgn=0xFECA)))) | ||
| time.sleep(1.4) | ||
| _j1939_mod._J1939_TP_T1 = _saved_T1 | ||
| _j1939_mod._J1939_TP_T2 = _saved_T2 | ||
| stim.send(J1939_CAN(priority=6, pdu_format=0xFE, pdu_specific=0xCA, | ||
| src=0x0B, data=b'\x42')) | ||
| _timeout_pkts = sock.sniff(count=1, timeout=1) |
AI-Assisted: yes (GitHub Copilot) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
ec810c3 to
292d81a
Compare
AI-Assisted: yes (GitHub Copilot)