├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.rst ├── doc ├── Makefile ├── make.bat └── source │ ├── automotive_ethernet.rst │ ├── conf.py │ ├── connectors.rst │ ├── index.rst │ └── messages.rst ├── documentation_requirements.txt ├── doipclient ├── __init__.py ├── client.py ├── connectors.py ├── constants.py ├── messages.py └── py.typed ├── pyproject.toml ├── setup.py └── tests ├── __init__.py └── test_client.py /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, windows-latest, ubuntu-20.04] 15 | python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 16 | exclude: 17 | - os: ubuntu-latest 18 | python-version: 3.6 19 | - os: ubuntu-latest 20 | python-version: 3.7 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v4 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install pytest pytest-mock 32 | - name: Test with pytest 33 | run: | 34 | pytest 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | install: 3 | - pip install pytest pytest-mock 4 | script: python -m pytest -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jacob Schaer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | python-doipclient 2 | ################# 3 | 4 | .. image:: https://github.com/jacobschaer/python-doipclient/actions/workflows/tests.yml/badge.svg?branch=main 5 | 6 | doipclient is a pure Python 3 Diagnostic over IP (DoIP) client which can be used 7 | for communicating with modern ECU's over automotive ethernet. It implements the 8 | majority of ISO-13400 (2019) from the perspective of a short-lived synchronous 9 | client. The primary use case is to serve as a transport layer implementation for 10 | the `udsoncan `_ library. The code 11 | is published under MIT license on GitHub (jacobschaer/python-doipclient). 12 | 13 | Documentation 14 | ------------- 15 | 16 | The documentation is available here : https://python-doipclient.readthedocs.io/ 17 | 18 | Requirements 19 | ------------ 20 | 21 | - Python 3.6+ 22 | 23 | Installation 24 | ------------ 25 | 26 | using pip:: 27 | 28 | pip install doipclient 29 | 30 | Running Tests from source 31 | ------------------------- 32 | 33 | using pytest:: 34 | 35 | pip install pytest pytest-mock 36 | pytest 37 | 38 | Example 39 | ------- 40 | Updated version of udsoncan's example using `python_doip` instead of IsoTPSocketConnection 41 | 42 | .. code-block:: python 43 | 44 | import SomeLib.SomeCar.SomeModel as MyCar 45 | 46 | import udsoncan 47 | from doipclient import DoIPClient 48 | from doipclient.connectors import DoIPClientUDSConnector 49 | from udsoncan.client import Client 50 | from udsoncan.exceptions import * 51 | from udsoncan.services import * 52 | 53 | udsoncan.setup_logging() 54 | 55 | ecu_ip = '127.0.0.1' 56 | ecu_logical_address = 0x00E0 57 | doip_client = DoIPClient(ecu_ip, ecu_logical_address) 58 | conn = DoIPClientUDSConnector(doip_client) 59 | with Client(conn, request_timeout=2, config=MyCar.config) as client: 60 | try: 61 | client.change_session(DiagnosticSessionControl.Session.extendedDiagnosticSession) # integer with value of 3 62 | client.unlock_security_access(MyCar.debug_level) # Fictive security level. Integer coming from fictive lib, let's say its value is 5 63 | client.write_data_by_identifier(udsoncan.DataIdentifier.VIN, 'ABC123456789') # Standard ID for VIN is 0xF190. Codec is set in the client configuration 64 | print('Vehicle Identification Number successfully changed.') 65 | client.ecu_reset(ECUReset.ResetType.hardReset) # HardReset = 0x01 66 | except NegativeResponseException as e: 67 | print('Server refused our request for service %s with code "%s" (0x%02x)' % (e.response.service.get_name(), e.response.code_name, e.response.code)) 68 | except (InvalidResponseException, UnexpectedResponseException) as e: 69 | print('Server sent an invalid payload : %s' % e.response.original_payload) 70 | 71 | # Because we reset our UDS server, we must also reconnect/reactivate the DoIP socket 72 | # Alternatively, we could have used the auto_reconnect_tcp flag on the DoIPClient 73 | # Note: ECU's do not restart instantly, so you may need a sleep() before moving on 74 | doip_client.reconnect() 75 | client.tester_present() 76 | 77 | # Cleanup the DoIP Socket when we're done. Alternatively, we could have used the 78 | # close_connection flag on conn so that the udsoncan client would clean it up 79 | doip_client.close() 80 | 81 | python-uds Support 82 | ------------------ 83 | The `python-uds `_ can also be used 84 | but requires a fork until the owner merges this PR 85 | `Doip #63 `_. For now, to use 86 | the port: 87 | 88 | using pip:: 89 | 90 | git clone https://github.com/jacobschaer/python-uds 91 | git checkout doip 92 | cd python-uds 93 | pip install . 94 | 95 | Example: 96 | 97 | .. code-block:: python 98 | 99 | from uds import Uds 100 | 101 | ecu = Uds(transportProtocol="DoIP", ecu_ip="192.168.1.1", ecu_logical_address=1) 102 | try: 103 | response = ecu.send([0x3E, 0x00]) 104 | print(response) # This should be [0x7E, 0x00] 105 | except: 106 | print("Send did not complete") -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /doc/source/automotive_ethernet.rst: -------------------------------------------------------------------------------- 1 | Automotive Ethernet Primer 2 | ########################## 3 | 4 | Diagnostic over IP (DoIP), as the name implies, sits on top of the IP protocol (specifically TCP and/or UDP) and doesn't care too much about the layers below (though they're still described in ISO-13400 for completeness). On vehicles where DoIP is available, it's often exposed in two places: the diagnostic port (OBD2/J1962 connector) and 100BASE-T1/1000BASE-T1 automotive ethernet between ECU's. 5 | 6 | OBD2 Port 7 | --------- 8 | ISO-13400-4 allows for manufacturers to provide DoIP through the OBD2 port using one of two pinouts: 9 | 10 | Option 1 11 | 12 | * Pin 3 (RX+) 13 | * Pin 11 (RX-) 14 | * Pin 12 (TX+) 15 | * Pin 13 (TX-) 16 | * Pin 8 (Activation) 17 | 18 | Option 2 19 | 20 | * Pin 1 (RX+) 21 | * Pin 9 (RX-) 22 | * Pin 12 (TX+) 23 | * Pin 13 (TX-) 24 | * Pin 8 (Activation) 25 | 26 | While the detection algorithm is fairly complex, the general idea is that a tester is supposed to sense the resistance between Pin 8 (Activation) and Pin 5 (Signal Ground) to determine which configuration is in use. 27 | Or, you could just look at a maintenance manual and figure it out that way (assuming you have access to one). 28 | Once the layout is known, the tester is supposed to signal to the DoIP Edge Node that it would like to connect via ethernet by applying +5V to Pin 8. 29 | 30 | As an example, BMW's "ENET" cable appears to use Option 1. A guide on making such a cable can be found at: 31 | `BimmerPost.com ENET Cable Build Guide `_. 32 | 33 | The 2 pairs of Tx/Rx lines provide ordinary IEEE 802.3 100BASE-TX ("Fast ethernet") - the same as what is commonly seen on desktop computers. 34 | 35 | Direct connect to ECU's 36 | ----------------------- 37 | ECU's that communicate over DoIP typically use Automotive Ethernet (100BASE-T1 or BroadR-Reach). 38 | Unlike the normal 100BASE-TX ("fast ethernet") with two twisted pairs (or 1000BASE-T with 4 pairs) that are common on desktop computers, Automotive Ethernet utilizes just two wires and operates in master/slave pairs. 39 | These are often terminated with `TE MATEnet connectors `_. 40 | As such, to connect with an ordinary desktop, a media converter is needed. 41 | A popular choice is the `Intrepid RAD-Moon `_. 42 | No activation line is present or necessary. 43 | 44 | Connecting to computer 45 | ----------------------- 46 | Once a suitable ethernet physical connection has been established between a traditional (Linux/Windows/Mac) computer and either a DoIP enabled ECU or a DoIP edge node, the IP layer needs to be setup. 47 | While the specification doesn't require it, DHCP is likely to be present (especially through the OBD2 connector). 48 | In that case, the client computer need only be configured for DHCP and negotiate an address on the vehicle's DoIP network. 49 | If DHCP isn't present, some sleuthing is likely needed. 50 | You might need to use Wireshark in promiscuous mode and look for UDP broadcast messages or other TCP traffic to determine the correct subnet and an unused IP address. 51 | Once this information is found, manually configure the appropriate network adapter on the test PC. 52 | 53 | Windows specific IP settings 54 | ---------------------------- 55 | If you intend to use `await_vehicle_announcement` or expect UDP broadcasts to be receive, make sure that your subnet is properly configured in the IPV4 settings for your network adapter. 56 | Often this will be 255.255.255.0, where the ECU will broadcast to x.x.x.255. 57 | Use Wireshark to monitor the interface in promiscuous mode can be useful. 58 | 59 | Additionally, you may need to reconfigure/disable the Windows Defender Firewall on your Guest/Public network. 60 | 61 | Notes: 62 | ------ 63 | 64 | * ECU's are free to be as (in)flexible as they want, with respect to handling the IP layers and below. You should not assume that common features like ping (ICMP) will work. 65 | * Unless DoIP is serving as the regulation required diagnostic connection (i.e.: OBD2), vendors may deviate from the specification with custom encryption, etc. 66 | * Production ECU's may filter based on MAC address - in which case the test PC will need to "spoof" the MAC address of a known good address. 67 | * You may have to modify/disable firewalls on your desktop computer to establish a connection with the ECU. 68 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | 16 | sys.path.insert(0, os.path.abspath("../../")) 17 | 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = "doipclient" 22 | copyright = "2020, Jacob Schaer" 23 | author = "Jacob Schaer" 24 | 25 | # The full version, including alpha/beta/rc tags 26 | release = "0.0.1" 27 | 28 | 29 | # -- General configuration --------------------------------------------------- 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | "sphinx.ext.autodoc", 36 | ] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ["_templates"] 40 | 41 | # List of patterns, relative to source directory, that match files and 42 | # directories to ignore when looking for source files. 43 | # This pattern also affects html_static_path and html_extra_path. 44 | exclude_patterns = [] 45 | 46 | 47 | # -- Options for HTML output ------------------------------------------------- 48 | 49 | # The theme to use for HTML and HTML Help pages. See the documentation for 50 | # a list of builtin themes. 51 | # 52 | html_theme = "alabaster" 53 | 54 | # Add any paths that contain custom static files (such as style sheets) here, 55 | # relative to this directory. They are copied after the builtin static files, 56 | # so a file named "default.css" will overwrite the builtin "default.css". 57 | # html_static_path = ['_static'] 58 | -------------------------------------------------------------------------------- /doc/source/connectors.rst: -------------------------------------------------------------------------------- 1 | DoIPClient Connectors 2 | ===================== 3 | 4 | To connect with the udsoncan library, the following connector class is provided. 5 | Eventually, this should be merged into the udsoncan library. 6 | If there's demand, a similar adapter could be made for the python-uds library as well. 7 | 8 | .. automodule:: doipclient.connectors 9 | :members: 10 | :undoc-members: -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to doipclient's documentation! 2 | ======================================= 3 | 4 | doipclient is a pure Python Diagnostic over IP (DoIP) client which can be used 5 | for communicating with modern ECU's over automotive ethernet. 6 | 7 | To discover ECU's on your network, you can use the Vehicle Identification 8 | Announcement broadcast message (sent at powerup) as follows: 9 | 10 | .. toctree:: 11 | :maxdepth: 2 12 | :hidden: 13 | 14 | automotive_ethernet 15 | messages 16 | connectors 17 | 18 | .. code-block:: python 19 | 20 | from doipclient import DoIPClient 21 | address, announcement = DoIPClient.await_vehicle_announcement() 22 | # Power cycle your ECU and wait for a few seconds for the broadcast to be 23 | # received 24 | logical_address = announcement.logical_address 25 | ip, port = address 26 | print(ip, port, logical_address) 27 | 28 | Alternatively, you can request a Vehicle Identification Response message: 29 | 30 | .. code-block:: python 31 | 32 | from doipclient import DoIPClient 33 | address, announcement = DoIPClient.get_entity() 34 | logical_address = announcement.logical_address 35 | ip, port = address 36 | print(ip, port, logical_address) 37 | 38 | Once you have the IP address and Logical Address for your ECU, you can connect 39 | to it and begin interacting. 40 | 41 | .. code-block:: python 42 | 43 | client = DoIPClient(ip, logical_address) 44 | print(client.request_entity_status()) 45 | 46 | You can also use UDS for diagnostic communication with the `udsoncan` library. 47 | 48 | .. code-block:: python 49 | 50 | from doipclient.connectors import DoIPClientUDSConnector 51 | from udsoncan.client import Client 52 | from udsoncan.services import * 53 | 54 | uds_connection = DoIPClientUDSConnector(client) 55 | with Client(uds_connection) as uds_client: 56 | client.ecu_reset(ECUReset.ResetType.hardReset) 57 | 58 | 59 | DoIPClient 60 | ---------- 61 | .. autoclass:: doipclient.DoIPClient 62 | :members: 63 | 64 | 65 | Encrypted Communication 66 | ----------------------- 67 | :abbr:`TLS (Transport Layer Security)`/:abbr:`SSL (Secure Sockets Layer)` can 68 | be enabled by setting the `use_secure` parameter when creating an instance of 69 | `DoIPClient`. 70 | 71 | .. code-block:: python 72 | 73 | client = DoIPClient( 74 | ip, 75 | logical_address, 76 | use_secure=True, # Enable encryption 77 | tcp_port=3496, 78 | ) 79 | 80 | If more control is required, a preconfigured `SSL context`_ can be provided. 81 | For instance, to enforce the use of TLSv1.2, create a context with the desired 82 | protocol version: 83 | 84 | .. code-block:: python 85 | 86 | import ssl 87 | 88 | # Enforce use of TLSv1.2 89 | ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) 90 | 91 | client = DoIPClient( 92 | ip, 93 | logical_address, 94 | use_secure=ssl_context, 95 | tcp_port=3496, 96 | ) 97 | 98 | .. note:: 99 | Since the communication is encrypted, debugging without the pre-master 100 | secret is not possible. To decrypt the TLS traffic for analysis, the 101 | pre-master secret can be dumped to a file and `loaded into Wireshark`_. 102 | This can be done via the `built-in mechanism`_ or with `sslkeylog`_ when 103 | using Python 3.7 and earlier. 104 | 105 | .. _SSL context: https://docs.python.org/3/library/ssl.html#ssl-contexts 106 | .. _loaded into Wireshark: https://wiki.wireshark.org/TLS#using-the-pre-master-secret 107 | .. _built-in mechanism: https://docs.python.org/3/library/ssl.html#ssl.SSLContext.keylog_filename 108 | .. _sslkeylog: https://pypi.org/project/sslkeylog/ 109 | -------------------------------------------------------------------------------- /doc/source/messages.rst: -------------------------------------------------------------------------------- 1 | DoIP Messages 2 | ============= 3 | 4 | .. automodule:: doipclient.messages 5 | :members: -------------------------------------------------------------------------------- /documentation_requirements.txt: -------------------------------------------------------------------------------- 1 | udsoncan -------------------------------------------------------------------------------- /doipclient/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import DoIPClient 2 | -------------------------------------------------------------------------------- /doipclient/client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import ipaddress 3 | import socket 4 | import struct 5 | import time 6 | import ssl 7 | from enum import IntEnum 8 | from typing import Union 9 | from .constants import ( 10 | A_DOIP_CTRL, 11 | TCP_DATA_UNSECURED, 12 | UDP_DISCOVERY, 13 | A_PROCESSING_TIME, 14 | LINK_LOCAL_MULTICAST_ADDRESS, 15 | ) 16 | from .messages import * 17 | 18 | logger = logging.getLogger("doipclient") 19 | 20 | 21 | class Parser: 22 | """Implements state machine for DoIP transport layer. 23 | 24 | See Table 16 "Generic DoIP header structure" of ISO 13400-2:2019 (E). While TCP transport 25 | is reliable, the UDP broadcasts are not, so the state machine is a little more defensive 26 | than one might otherwise expect. When using TCP, reads from the socket aren't guaranteed 27 | to be exactly one DoIP message, so the running buffer needs to be maintained across reads 28 | """ 29 | 30 | class ParserState(IntEnum): 31 | READ_PROTOCOL_VERSION = 1 32 | READ_INVERSE_PROTOCOL_VERSION = 2 33 | READ_PAYLOAD_TYPE = 3 34 | READ_PAYLOAD_SIZE = 4 35 | READ_PAYLOAD = 5 36 | 37 | def __init__(self): 38 | self.reset() 39 | 40 | def reset(self): 41 | self.rx_buffer = bytearray() 42 | self.protocol_version = None 43 | self.payload_type = None 44 | self.payload_size = None 45 | self.payload = bytearray() 46 | self._state = Parser.ParserState.READ_PROTOCOL_VERSION 47 | 48 | def push_bytes(self, data_bytes): 49 | self.rx_buffer += data_bytes 50 | 51 | def read_message(self, data_bytes): 52 | self.rx_buffer += data_bytes 53 | if self._state == Parser.ParserState.READ_PROTOCOL_VERSION: 54 | if len(self.rx_buffer) >= 1: 55 | self.payload = bytearray() 56 | self.payload_type = None 57 | self.payload_size = None 58 | self.protocol_version = int(self.rx_buffer.pop(0)) 59 | self._state = Parser.ParserState.READ_INVERSE_PROTOCOL_VERSION 60 | 61 | if self._state == Parser.ParserState.READ_INVERSE_PROTOCOL_VERSION: 62 | if len(self.rx_buffer) >= 1: 63 | inverse_protocol_version = int(self.rx_buffer.pop(0)) 64 | if inverse_protocol_version != (0xFF ^ self.protocol_version): 65 | logger.warning( 66 | "Bad DoIP Header - Inverse protocol version does not match. Ignoring." 67 | ) 68 | # Bad protocol version inverse - shift the buffer forward 69 | self.protocol_version = inverse_protocol_version 70 | else: 71 | self._state = Parser.ParserState.READ_PAYLOAD_TYPE 72 | 73 | if self._state == Parser.ParserState.READ_PAYLOAD_TYPE: 74 | if len(self.rx_buffer) >= 2: 75 | self.payload_type = self.rx_buffer.pop(0) << 8 76 | self.payload_type |= self.rx_buffer.pop(0) 77 | self._state = Parser.ParserState.READ_PAYLOAD_SIZE 78 | 79 | if self._state == Parser.ParserState.READ_PAYLOAD_SIZE: 80 | if len(self.rx_buffer) >= 4: 81 | self.payload_size = self.rx_buffer.pop(0) << 24 82 | self.payload_size |= self.rx_buffer.pop(0) << 16 83 | self.payload_size |= self.rx_buffer.pop(0) << 8 84 | self.payload_size |= self.rx_buffer.pop(0) 85 | self._state = Parser.ParserState.READ_PAYLOAD 86 | 87 | if self._state == Parser.ParserState.READ_PAYLOAD: 88 | remaining_bytes = self.payload_size - len(self.payload) 89 | self.payload += self.rx_buffer[:remaining_bytes] 90 | self.rx_buffer = self.rx_buffer[remaining_bytes:] 91 | if len(self.payload) == self.payload_size: 92 | self._state = Parser.ParserState.READ_PROTOCOL_VERSION 93 | logger.debug( 94 | "Received DoIP Message. Type: 0x{:X}, Payload Size: {} bytes, Payload: {}".format( 95 | self.payload_type, 96 | self.payload_size, 97 | " ".join(f"{byte:02X}" for byte in self.payload), 98 | ) 99 | ) 100 | try: 101 | return payload_type_to_message[self.payload_type].unpack( 102 | self.payload, self.payload_size 103 | ) 104 | except KeyError: 105 | return ReservedMessage.unpack( 106 | self.payload_type, self.payload, self.payload_size 107 | ) 108 | 109 | 110 | class DoIPClient: 111 | """A Diagnostic over IP (DoIP) Client implementing the majority of ISO-13400-2:2019 (E). 112 | 113 | This is a basic DoIP client which was designed primarily for use with the python-udsoncan package for UDS communication 114 | with ECU's over automotive ethernet. Certain parts of the specification would require threaded operation to 115 | maintain the time-based state described by the ISO document. However, in practice these are rarely important, 116 | particularly for use with UDS - especially with scripts that tend to go through instructions as fast as possible. 117 | 118 | :param ecu_ip_address: This is the IP address of the target ECU. This should be a string representing an IPv4 119 | address like "192.168.1.1" or an IPv6 address like "2001:db8::". Like the logical_address, if you don't know the 120 | value for your ECU, utilize the get_entity() or await_vehicle_announcement() method. 121 | :type ecu_ip_address: str 122 | :param ecu_logical_address: The logical address of the target ECU. This should be an integer. According to the 123 | specification, the correct range is 0x0001 to 0x0DFF ("VM specific"). If you don't know the logical address, 124 | either use the get_entity() method OR the await_vehicle_announcement() method and power 125 | cycle the ECU - it should identify itself on bootup. 126 | :type ecu_logical_address: int 127 | :param tcp_port: The destination TCP port for DoIP data communication. By default this is 13400 for unsecure and 128 | 3496 when using TLS. 129 | :type tcp_port: int, optional 130 | :param activation_type: The activation type to use on initial connection. Most ECU's require an activation request 131 | before they'll respond, and typically the default activation type will do. The type can be changed later using 132 | request_activation() method. Use `None` to disable activation at startup. 133 | :type activation_type: RoutingActivationRequest.ActivationType, optional 134 | :param protocol_version: The DoIP protocol version to use for communication. Represents the version of the ISO 13400 135 | specification to follow. 0x02 (2012) is probably correct for most ECU's at the time of writing, though technically 136 | this implementation is against 0x03 (2019). 137 | :type protocol_version: int 138 | :param client_logical_address: The logical address that this DoIP client will use to identify itself. Per the spec, 139 | this should be 0x0E00 to 0x0FFF. Can typically be left as default. 140 | :type client_logical_address: int 141 | :param client_ip_address: If specified, attempts to bind to this IP as the source for both UDP and TCP communication. 142 | Useful if you have multiple network adapters. Can be an IPv4 or IPv6 address just like `ecu_ip_address`, though 143 | the type should match. 144 | :type client_ip_address: str, optional 145 | :param use_secure: Enables TLS. If set to True, a default SSL context is used. For more control, a preconfigured 146 | SSL context can be passed directly. Untested. Should be combined with changing tcp_port to 3496. 147 | :type use_secure: Union[bool,ssl.SSLContext] 148 | :param log_level: Logging level 149 | :type log_level: int 150 | :param auto_reconnect_tcp: Attempt to automatically reconnect TCP sockets that were closed by peer 151 | :type auto_reconnect_tcp: bool 152 | 153 | :raises ConnectionRefusedError: If the activation request fails 154 | :raises ValueError: If the IPAddress is neither an IPv4 nor an IPv6 address 155 | """ 156 | 157 | def __init__( 158 | self, 159 | ecu_ip_address, 160 | ecu_logical_address, 161 | tcp_port=TCP_DATA_UNSECURED, 162 | udp_port=UDP_DISCOVERY, 163 | activation_type=RoutingActivationRequest.ActivationType.Default, 164 | protocol_version=0x02, 165 | client_logical_address=0x0E00, 166 | client_ip_address=None, 167 | use_secure=False, 168 | auto_reconnect_tcp=False, 169 | ): 170 | self._ecu_logical_address = ecu_logical_address 171 | self._client_logical_address = client_logical_address 172 | self._client_ip_address = client_ip_address 173 | self._use_secure = use_secure 174 | self._ecu_ip_address = ecu_ip_address 175 | self._tcp_port = tcp_port 176 | self._udp_port = udp_port 177 | self._activation_type = activation_type 178 | self._udp_parser = Parser() 179 | self._tcp_parser = Parser() 180 | self._protocol_version = protocol_version 181 | self._auto_reconnect_tcp = auto_reconnect_tcp 182 | self._tcp_close_detected = False 183 | 184 | # Check the ECU IP type to determine socket family 185 | # Will raise ValueError if neither a valid IPv4, nor IPv6 address 186 | if type(ipaddress.ip_address(self._ecu_ip_address)) == ipaddress.IPv6Address: 187 | self._address_family = socket.AF_INET6 188 | else: 189 | self._address_family = socket.AF_INET 190 | 191 | self._connect() 192 | 193 | if self._activation_type is not None: 194 | result = self.request_activation(self._activation_type, disable_retry=True) 195 | if result.response_code != RoutingActivationResponse.ResponseCode.Success: 196 | raise ConnectionRefusedError( 197 | f"Activation Request failed with code {result.response_code}" 198 | ) 199 | 200 | class TransportType(IntEnum): 201 | TRANSPORT_UDP = 1 202 | TRANSPORT_TCP = 2 203 | 204 | def __enter__(self): 205 | return self 206 | 207 | def __exit__(self, type, value, traceback): 208 | self.close() 209 | 210 | @staticmethod 211 | def _create_udp_socket( 212 | ipv6=False, udp_port=UDP_DISCOVERY, timeout=None, source_interface=None 213 | ): 214 | if ipv6: 215 | sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) 216 | 217 | # IPv6 version always uses link-local scope multicast address (FF02 16 ::1) 218 | sock.bind((LINK_LOCAL_MULTICAST_ADDRESS, udp_port)) 219 | 220 | if source_interface is None: 221 | # 0 is the "default multicast interface" which is unlikely to be correct, but it will do 222 | interface_index = 0 223 | else: 224 | interface_index = socket.if_nametoindex(source_interface) 225 | 226 | # Join the group so that packets are delivered 227 | mc_addr = ipaddress.IPv6Address(LINK_LOCAL_MULTICAST_ADDRESS) 228 | join_data = struct.pack("16sI", mc_addr.packed, interface_index) 229 | # IPV6_JOIN_GROUP is also known as IPV6_ADD_MEMBERSHIP, though older Python for Windows doesn't have it 230 | # IPPROTO_IPV6 may be missing in older Windows builds 231 | try: 232 | from socket import IPPROTO_IPV6 233 | except ImportError: 234 | IPPROTO_IPV6 = 41 235 | sock.setsockopt(IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, join_data) 236 | else: 237 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 238 | # IPv4, use INADDR_ANY to listen to all interfaces for broadcasts (not multicast) 239 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 240 | sock.bind(("", udp_port)) 241 | 242 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 243 | if timeout is not None: 244 | sock.settimeout(timeout) 245 | 246 | return sock 247 | 248 | @staticmethod 249 | def _pack_doip(protocol_version, payload_type, payload_data): 250 | data_bytes = struct.pack( 251 | "!BBHL", 252 | protocol_version, 253 | 0xFF ^ protocol_version, 254 | payload_type, 255 | len(payload_data), 256 | ) 257 | data_bytes += payload_data 258 | 259 | return data_bytes 260 | 261 | @classmethod 262 | def await_vehicle_announcement( 263 | cls, 264 | udp_port=UDP_DISCOVERY, 265 | timeout=None, 266 | ipv6=False, 267 | source_interface=None, 268 | sock=None, 269 | ): 270 | """Receive Vehicle Announcement Message 271 | 272 | When an ECU first turns on, it's supposed to broadcast a Vehicle Announcement Message over UDP 3 times 273 | to assist DoIP clients in determining ECU IP's and Logical Addresses. Will use an IPv4 socket by default, 274 | though this can be overridden with the `ipv6` parameter. 275 | 276 | :param udp_port: The UDP port to listen on. Per the spec this should be 13400, but some VM's use a custom 277 | one. 278 | :type udp_port: int, optional 279 | :param timeout: Maximum amount of time to wait for message 280 | :type timeout: float, optional 281 | :param ipv6: Bool forcing IPV6 socket instead of IPV4 socket 282 | :type ipv6: bool, optional 283 | :param source_interface: Interface name (like "eth0") to bind to for use with IPv6. Defaults to None which 284 | will use the default interface (which may not be the one connected to the ECU). Does nothing for IPv4, 285 | which will bind to all interfaces uses INADDR_ANY. 286 | :type source_interface: str, optional 287 | :return: IP Address of ECU and VehicleAnnouncementMessage object 288 | :rtype: tuple 289 | :raises TimeoutError: If vehicle announcement not received in time 290 | """ 291 | start_time = time.time() 292 | 293 | parser = Parser() 294 | 295 | if not sock: 296 | sock = cls._create_udp_socket( 297 | ipv6=ipv6, 298 | udp_port=udp_port, 299 | timeout=timeout, 300 | source_interface=source_interface, 301 | ) 302 | 303 | while True: 304 | remaining = None 305 | if timeout: 306 | duration = time.time() - start_time 307 | if duration >= timeout: 308 | raise TimeoutError( 309 | "Timed out waiting for Vehicle Announcement broadcast" 310 | ) 311 | else: 312 | remaining = timeout - duration 313 | sock.settimeout(remaining) 314 | try: 315 | data, addr = sock.recvfrom(1024) 316 | except socket.timeout: 317 | raise TimeoutError( 318 | "Timed out waiting for Vehicle Announcement broadcast" 319 | ) 320 | # "Only one DoIP message shall be transmitted by any DoIP entity per datagram" 321 | # So, reset the parser after each UDP read 322 | parser.reset() 323 | result = parser.read_message(data) 324 | if result and type(result) == VehicleIdentificationResponse: 325 | return addr, result 326 | 327 | @classmethod 328 | def get_entity( 329 | cls, ecu_ip_address="255.255.255.255", protocol_version=0x02, eid=None, vin=None 330 | ): 331 | """Sends a VehicleIdentificationRequest and awaits a VehicleIdentificationResponse from the ECU, 332 | either with a specified VIN, EIN, or nothing. Equivalent to the request_vehicle_identification() method 333 | but can be called without instantiation. 334 | 335 | :param ecu_ip_address: This is the IP address of the target ECU for unicast. Defaults to broadcast if 336 | the address is not known. 337 | :type ecu_ip_address: str, optional 338 | :param protocol_version: The DoIP protocol version to use for communication. Represents the version of the ISO 13400 339 | specification to follow. 0x02 (2012) is probably correct for most ECU's at the time of writing, though technically 340 | this implementation is against 0x03 (2019). 341 | :type protocol_version: int, optional 342 | :param eid: EID of the Vehicle 343 | :type eid: bytes, optional 344 | :param vin: VIN of the Vehicle 345 | :type vin: str, optional 346 | :return: The vehicle identification response message 347 | :rtype: VehicleIdentificationResponse 348 | """ 349 | 350 | # UDP_TEST_EQUIPMENT_REQUEST is dynamically assigned using udp_port=0 351 | sock = cls._create_udp_socket(udp_port=0, timeout=A_DOIP_CTRL) 352 | 353 | if eid: 354 | message = VehicleIdentificationRequestWithEID(eid) 355 | elif vin: 356 | message = VehicleIdentificationRequestWithVIN(vin) 357 | else: 358 | message = VehicleIdentificationRequest() 359 | 360 | payload_data = message.pack() 361 | payload_type = payload_message_to_type[type(message)] 362 | 363 | data_bytes = cls._pack_doip(protocol_version, payload_type, payload_data) 364 | logger.debug( 365 | "Sending DoIP Vehicle Identification Request: Type: 0x{:X}, Payload Size: {}, Payload: {}".format( 366 | payload_type, 367 | len(payload_data), 368 | " ".join(f"{byte:02X}" for byte in payload_data), 369 | ) 370 | ) 371 | sock.sendto(data_bytes, (ecu_ip_address, UDP_DISCOVERY)) 372 | 373 | return cls.await_vehicle_announcement(timeout=A_DOIP_CTRL, sock=sock) 374 | 375 | def empty_rxqueue(self): 376 | """Implemented for compatibility with udsoncan library. Nothing useful to be done yet""" 377 | pass 378 | 379 | def empty_txqueue(self): 380 | """Implemented for compatibility with udsoncan library. Nothing useful to be done yet""" 381 | pass 382 | 383 | def read_doip( 384 | self, timeout=A_PROCESSING_TIME, transport=TransportType.TRANSPORT_TCP 385 | ): 386 | """Helper function to read from the DoIP socket. 387 | 388 | :param timeout: Maximum time allowed for response from ECU 389 | :type timeout: float, optional 390 | :param transport: The IP transport layer to read from, either UDP or TCP 391 | :type transport: DoIPClient.TransportType, optional 392 | :raises IOError: If DoIP layer fails with negative acknowledgement 393 | :raises TimeoutException: If ECU fails to respond in time 394 | """ 395 | start_time = time.time() 396 | data = bytearray() 397 | while (time.time() - start_time) <= timeout: 398 | if transport == DoIPClient.TransportType.TRANSPORT_TCP: 399 | response = self._tcp_parser.read_message(data) 400 | else: 401 | response = self._udp_parser.read_message(data) 402 | data = bytearray() 403 | if type(response) == GenericDoIPNegativeAcknowledge: 404 | raise IOError( 405 | f"DoIP Negative Acknowledge. NACK Code: {response.nack_code}" 406 | ) 407 | elif type(response) == AliveCheckRequest: 408 | logger.warning("Responding to an alive check") 409 | self.send_doip_message(AliveCheckResponse(self._client_logical_address)) 410 | elif response: 411 | # We got a response that might actually be interesting to the caller, 412 | # so return it. 413 | return response 414 | else: 415 | # There were no responses in the parser, so we need to read off the network 416 | # and feed that to the parser until we find another DoIP message 417 | 418 | if ( 419 | transport == DoIPClient.TransportType.TRANSPORT_TCP 420 | ) and self._tcp_close_detected: 421 | # The caller is looking for TCP responses, but there were no messages 422 | # returned from the parser and the socket has been closed (so no further 423 | # responses are expected). It's safe to stop looking early and raise 424 | # a TimeoutError 425 | break 426 | else: 427 | try: 428 | if transport == DoIPClient.TransportType.TRANSPORT_TCP: 429 | data = self._tcp_sock.recv(1024) 430 | if len(data) == 0: 431 | logger.debug("Peer has closed the connection.") 432 | self._tcp_close_detected = True 433 | else: 434 | # "Only one DoIP message shall be transmitted by any DoIP entity 435 | # per UDP datagram", so reset the UDP parser for each recv() 436 | self._udp_parser.reset() 437 | data = self._udp_sock.recv(1024) 438 | except socket.timeout: 439 | pass 440 | raise TimeoutError("ECU failed to respond in time") 441 | 442 | def _tcp_socket_check(self, first_timeout=0.010): 443 | """Helper function to service a TCP socket and check for disconnects. 444 | 445 | Called from send_doip() before and after TCP socket sends to detect if reconnect 446 | is needed. 447 | 448 | :param first_timeout: Timeout for the first recv() call. This should correspond to 449 | how long you expect the ECU to return an RST after sending to the 450 | socket if the connection was unexpectedly terminated. Too long 451 | and it hurts performance, too short and you run the risk of 452 | missing a socket reconnect opportunity. Normally <1ms, but 453 | allowing 10ms by default to be safe. 454 | :type first_timeout: float 455 | """ 456 | original_timeout = self._tcp_sock.gettimeout() 457 | try: 458 | self._tcp_sock.settimeout(first_timeout) 459 | while True: 460 | data = self._tcp_sock.recv(1024) 461 | if len(data) == 0: 462 | logger.debug("TCP Connection closed by ECU, attempting to reset") 463 | self._tcp_close_detected = True 464 | break 465 | else: 466 | self._tcp_parser.push_bytes(data) 467 | # Subsequent reads, go to 0 timeout 468 | self._tcp_sock.settimeout(0) 469 | except (BlockingIOError, socket.timeout, ssl.SSLError): 470 | pass 471 | except (ConnectionResetError, BrokenPipeError): 472 | logger.debug("TCP Connection broken, attempting to reset") 473 | self._tcp_close_detected = True 474 | finally: 475 | self._tcp_sock.settimeout(original_timeout) 476 | 477 | def send_doip( 478 | self, 479 | payload_type, 480 | payload_data, 481 | transport=TransportType.TRANSPORT_TCP, 482 | disable_retry=False, 483 | ): 484 | """Helper function to send to the DoIP socket. 485 | 486 | Adds the correct DoIP header to the payload and sends to the socket. 487 | 488 | :param payload_type: The payload type (see Table 17 "Overview of DoIP payload types" in ISO-13400 489 | :type payload_type: int 490 | :param transport: The IP transport layer to send to, either UDP or TCP 491 | :type transport: DoIPClient.TransportType, optional 492 | :param disable_retry: Disables retry regardless of auto_reconnect_tcp flag. This is used by activation 493 | requests during connect/reconnect. 494 | :type disable_retry: bool, optional 495 | """ 496 | 497 | retry = self._auto_reconnect_tcp and not disable_retry 498 | 499 | data_bytes = self._pack_doip(self._protocol_version, payload_type, payload_data) 500 | logger.debug( 501 | "Sending DoIP Message: Type: 0x{:X}, Payload Size: {}, Payload: {}".format( 502 | payload_type, 503 | len(payload_data), 504 | " ".join(f"{byte:02X}" for byte in payload_data), 505 | ) 506 | ) 507 | 508 | # The ECU is well within its rights to have closed the socket since we last sent it data - 509 | # particularly if the tester has been quiet for a while. For TCP there's two possibilities 510 | # 1) The ECU closed the connection properly, and there's a FIN/RST waiting to be read 511 | # 2) The ECU force closed the connection - we won't find that out until we try to write 512 | # something and the ECU responds with an RST because the session isn't valid anymore. 513 | # 514 | # For (1) we could easily let the state machine go without a special case, but then we'd 515 | # be pushing a packet that the ECU would have to ignore (if they closed they have no way 516 | # to respond). So, we'll handle before the Tx, but we won't allow it to block. 517 | 518 | if retry: 519 | self._tcp_socket_check(first_timeout=0) 520 | 521 | remaining = len(data_bytes) 522 | attempted_reconnect = False 523 | 524 | # In general, the entire DoIP message should fit in one TCP packet, but it's good practice 525 | # to loop until the whole packet has been written, in case the OS write buffers get backed up 526 | while remaining > 0: 527 | if transport == DoIPClient.TransportType.TRANSPORT_TCP: 528 | if retry and self._tcp_close_detected: 529 | if not attempted_reconnect: 530 | logger.warning("TCP reconnecting") 531 | self.reconnect() 532 | attempted_reconnect = True 533 | else: 534 | logger.warning( 535 | "TCP needs reconnection, but we already attempted once. Send will fail." 536 | ) 537 | 538 | remaining -= self._tcp_sock.send(data_bytes[-remaining:]) 539 | 540 | if retry and not self._tcp_close_detected: 541 | self._tcp_socket_check() 542 | if self._tcp_close_detected: 543 | remaining = len(data_bytes) 544 | 545 | else: 546 | remaining -= self._udp_sock.sendto( 547 | data_bytes[-remaining:], (self._ecu_ip_address, self._udp_port) 548 | ) 549 | 550 | def send_doip_message( 551 | self, 552 | doip_message, 553 | transport=TransportType.TRANSPORT_TCP.TRANSPORT_TCP, 554 | disable_retry=False, 555 | ): 556 | """Helper function to send an unpacked message to the DoIP socket. 557 | 558 | Packs the given message and adds the correct DoIP header before sending to the socket 559 | 560 | :param doip_message: DoIP message object 561 | :type doip_message: object 562 | :param transport: The IP transport layer to send to, either UDP or TCP 563 | :type transport: DoIPClient.TransportType, optional 564 | :param disable_retry: Disables retry regardless of auto_reconnect_tcp flag. This is used by activation 565 | requests during connect/reconnect. 566 | :type disable_retry: bool, optional 567 | """ 568 | payload_type = payload_message_to_type[type(doip_message)] 569 | payload_data = doip_message.pack() 570 | self.send_doip( 571 | payload_type, payload_data, transport=transport, disable_retry=disable_retry 572 | ) 573 | 574 | def request_activation( 575 | self, activation_type, vm_specific=None, disable_retry=False 576 | ): 577 | """Requests a given activation type from the ECU for this connection using payload type 0x0005 578 | 579 | :param activation_type: The type of activation to request - see Table 47 ("Routing 580 | activation request activation types") of ISO-13400, but should generally be 0 (default) 581 | or 1 (regulatory diagnostics) 582 | :type activation_type: RoutingActivationRequest.ActivationType 583 | :param vm_specific: Optional 4 byte long int 584 | :type vm_specific: int, optional 585 | :param disable_retry: Disables retry regardless of auto_reconnect_tcp flag. This is used by activation 586 | requests during connect/reconnect. 587 | :type disable_retry: bool, optional 588 | :return: The resulting activation response object 589 | :rtype: RoutingActivationResponse 590 | """ 591 | message = RoutingActivationRequest( 592 | self._client_logical_address, activation_type, vm_specific=vm_specific 593 | ) 594 | self.send_doip_message(message, disable_retry=disable_retry) 595 | while True: 596 | result = self.read_doip() 597 | if type(result) == RoutingActivationResponse: 598 | return result 599 | elif result: 600 | logger.warning( 601 | "Received unexpected DoIP message type {}. Ignoring".format( 602 | type(result) 603 | ) 604 | ) 605 | 606 | def request_vehicle_identification(self, eid=None, vin=None): 607 | """Sends a VehicleIdentificationRequest and awaits a VehicleIdentificationResponse from the ECU, either with a specified VIN, EIN, 608 | or nothing. 609 | :param eid: EID of the Vehicle 610 | :type eid: bytes, optional 611 | :param vin: VIN of the Vehicle 612 | :type vin: str, optional 613 | :return: The vehicle identification response message 614 | :rtype: VehicleIdentificationResponse 615 | """ 616 | if eid: 617 | message = VehicleIdentificationRequestWithEID(eid) 618 | elif vin: 619 | message = VehicleIdentificationRequestWithVIN(vin) 620 | else: 621 | message = VehicleIdentificationRequest() 622 | self.send_doip_message( 623 | message, transport=DoIPClient.TransportType.TRANSPORT_UDP 624 | ) 625 | while True: 626 | result = self.read_doip(transport=DoIPClient.TransportType.TRANSPORT_UDP) 627 | if type(result) == VehicleIdentificationResponse: 628 | return result 629 | elif result: 630 | logger.warning( 631 | "Received unexpected DoIP message type {}. Ignoring".format( 632 | type(result) 633 | ) 634 | ) 635 | 636 | def request_alive_check(self): 637 | """Request that the ECU send an alive check response 638 | 639 | :return: Alive Check Response object 640 | :rtype: AliveCheckResopnse 641 | """ 642 | message = AliveCheckRequest() 643 | self.send_doip_message( 644 | message, transport=DoIPClient.TransportType.TRANSPORT_TCP 645 | ) 646 | while True: 647 | result = self.read_doip(transport=DoIPClient.TransportType.TRANSPORT_TCP) 648 | if type(result) == AliveCheckResponse: 649 | return result 650 | elif result: 651 | logger.warning( 652 | "Received unexpected DoIP message type {}. Ignoring".format( 653 | type(result) 654 | ) 655 | ) 656 | 657 | def request_diagnostic_power_mode(self): 658 | """Request that the ECU send a Diagnostic Power Mode response 659 | 660 | :return: Diagnostic Power Mode Response object 661 | :rtype: DiagnosticPowerModeResponse 662 | """ 663 | message = DiagnosticPowerModeRequest() 664 | self.send_doip_message( 665 | message, transport=DoIPClient.TransportType.TRANSPORT_UDP 666 | ) 667 | while True: 668 | result = self.read_doip(transport=DoIPClient.TransportType.TRANSPORT_UDP) 669 | if type(result) == DiagnosticPowerModeResponse: 670 | return result 671 | elif result: 672 | logger.warning( 673 | "Received unexpected DoIP message type {}. Ignoring".format( 674 | type(result) 675 | ) 676 | ) 677 | 678 | def request_entity_status(self): 679 | """Request that the ECU send a DoIP Entity Status Response 680 | 681 | :return: DoIP Entity Status Response 682 | :rtype: EntityStatusResponse 683 | """ 684 | message = DoipEntityStatusRequest() 685 | self.send_doip_message( 686 | message, transport=DoIPClient.TransportType.TRANSPORT_UDP 687 | ) 688 | while True: 689 | result = self.read_doip(transport=DoIPClient.TransportType.TRANSPORT_UDP) 690 | if type(result) == EntityStatusResponse: 691 | return result 692 | elif result: 693 | logger.warning( 694 | "Received unexpected DoIP message type {}. Ignoring".format( 695 | type(result) 696 | ) 697 | ) 698 | 699 | def send_diagnostic(self, diagnostic_payload, timeout=A_PROCESSING_TIME): 700 | """Send a raw diagnostic payload (ie: UDS) to the ECU. 701 | 702 | :param diagnostic_payload: UDS payload to transmit to the ECU 703 | :type diagnostic_payload: bytearray 704 | :raises IOError: DoIP negative acknowledgement received 705 | """ 706 | self.send_diagnostic_to_address( 707 | self._ecu_logical_address, diagnostic_payload, timeout 708 | ) 709 | 710 | def send_diagnostic_to_address( 711 | self, address, diagnostic_payload, timeout=A_PROCESSING_TIME 712 | ): 713 | """Send a raw diagnostic payload (ie: UDS) to the specified address. 714 | 715 | :param address: The logical address to send the diagnostic payload to 716 | :type address: int 717 | :param diagnostic_payload: UDS payload to transmit to the ECU 718 | :type diagnostic_payload: bytearray 719 | :raises IOError: DoIP negative acknowledgement received 720 | """ 721 | message = DiagnosticMessage( 722 | self._client_logical_address, address, diagnostic_payload 723 | ) 724 | self.send_doip_message(message) 725 | start_time = time.time() 726 | while True: 727 | ellapsed_time = time.time() - start_time 728 | if timeout and ellapsed_time > timeout: 729 | raise TimeoutError("Timed out waiting for diagnostic response") 730 | if timeout: 731 | result = self.read_doip(timeout=(timeout - ellapsed_time)) 732 | else: 733 | result = self.read_doip() 734 | if type(result) == DiagnosticMessageNegativeAcknowledgement: 735 | raise IOError( 736 | "Diagnostic request rejected with negative acknowledge code: {}".format( 737 | result.nack_code 738 | ) 739 | ) 740 | elif type(result) == DiagnosticMessagePositiveAcknowledgement: 741 | return 742 | elif result: 743 | logger.warning( 744 | "Received unexpected DoIP message type {}. Ignoring".format( 745 | type(result) 746 | ) 747 | ) 748 | 749 | def receive_diagnostic(self, timeout=None): 750 | """Receive a raw diagnostic payload (ie: UDS) from the ECU. 751 | 752 | :return: Raw UDS payload 753 | :rtype: bytearray 754 | :raises TimeoutError: No diagnostic response received in time 755 | """ 756 | start_time = time.time() 757 | while True: 758 | ellapsed_time = time.time() - start_time 759 | if timeout and ellapsed_time > timeout: 760 | raise TimeoutError("Timed out waiting for diagnostic response") 761 | if timeout: 762 | result = self.read_doip(timeout=(timeout - ellapsed_time)) 763 | else: 764 | result = self.read_doip() 765 | if type(result) == DiagnosticMessage: 766 | return result.user_data 767 | elif result: 768 | logger.warning( 769 | "Received unexpected DoIP message type {}. Ignoring".format( 770 | type(result) 771 | ) 772 | ) 773 | 774 | def _connect(self): 775 | """Helper to establish socket communication""" 776 | self._tcp_sock = socket.socket(self._address_family, socket.SOCK_STREAM) 777 | self._tcp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True) 778 | self._tcp_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, True) 779 | if self._client_ip_address is not None: 780 | self._tcp_sock.bind((self._client_ip_address, 0)) 781 | self._tcp_sock.connect((self._ecu_ip_address, self._tcp_port)) 782 | self._tcp_sock.settimeout(A_PROCESSING_TIME) 783 | self._tcp_close_detected = False 784 | 785 | self._udp_sock = socket.socket(self._address_family, socket.SOCK_DGRAM) 786 | self._udp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 787 | self._udp_sock.settimeout(A_PROCESSING_TIME) 788 | if self._client_ip_address is not None: 789 | self._udp_sock.bind((self._client_ip_address, 0)) 790 | 791 | if self._use_secure: 792 | if isinstance(self._use_secure, ssl.SSLContext): 793 | ssl_context = self._use_secure 794 | else: 795 | ssl_context = ssl.create_default_context() 796 | self._wrap_socket(ssl_context) 797 | 798 | def _wrap_socket(self, ssl_context): 799 | """Wrap the underlying socket in a SSL context.""" 800 | self._tcp_sock = ssl_context.wrap_socket(self._tcp_sock) 801 | 802 | def close(self): 803 | """Close the DoIP client""" 804 | self._tcp_sock.close() 805 | self._udp_sock.close() 806 | 807 | def reconnect(self, close_delay=A_PROCESSING_TIME): 808 | """Attempts to re-establish the connection. Useful after an ECU reset 809 | 810 | :param close_delay: Time to wait between closing and re-opening socket 811 | :type close_delay: float, optional 812 | """ 813 | # Close the sockets 814 | self.close() 815 | # Reset the parser state machines 816 | self._udp_parser = Parser() 817 | self._tcp_parser = Parser() 818 | # Allow the ECU time time to cleanup the DoIP session/socket before re-establishing 819 | time.sleep(close_delay) 820 | self._connect() 821 | if self._activation_type is not None: 822 | result = self.request_activation(self._activation_type, disable_retry=True) 823 | if result.response_code != RoutingActivationResponse.ResponseCode.Success: 824 | raise ConnectionRefusedError( 825 | f"Activation Request failed with code {result.response_code}" 826 | ) 827 | -------------------------------------------------------------------------------- /doipclient/connectors.py: -------------------------------------------------------------------------------- 1 | from udsoncan.connections import BaseConnection 2 | 3 | 4 | class DoIPClientUDSConnector(BaseConnection): 5 | """ 6 | A udsoncan connector which uses an existing DoIPClient as a DoIP transport layer for UDS (instead of ISO-TP). 7 | 8 | :param doip_layer: The DoIP Transport layer object coming from the ``doipclient`` package. 9 | :type doip_layer: :class:`doipclient.DoIPClient` 10 | 11 | :param name: This name is included in the logger name so that its output can be redirected. The logger name will be ``Connection[]`` 12 | :type name: string 13 | 14 | :param close_connection: True if the wrapper's close() function should close the associated DoIP client. This is not the default 15 | :type name: bool 16 | 17 | """ 18 | 19 | def __init__(self, doip_layer, name=None, close_connection=False): 20 | BaseConnection.__init__(self, name) 21 | self._connection = doip_layer 22 | self._close_connection = close_connection 23 | self.opened = False 24 | 25 | def open(self): 26 | self.opened = True 27 | 28 | def __enter__(self): 29 | return self 30 | 31 | def __exit__(self, type, value, traceback): 32 | self.close() 33 | 34 | def close(self): 35 | if self._close_connection: 36 | self._connection.close() 37 | self.opened = False 38 | 39 | def is_open(self): 40 | return self.opened 41 | 42 | def specific_send(self, payload): 43 | self._connection.send_diagnostic(bytearray(payload)) 44 | 45 | def specific_wait_frame(self, timeout=2): 46 | return bytes(self._connection.receive_diagnostic(timeout=timeout)) 47 | 48 | def empty_rxqueue(self): 49 | self._connection.empty_rxqueue() 50 | 51 | def empty_txqueue(self): 52 | self._connection.empty_txqueue() 53 | -------------------------------------------------------------------------------- /doipclient/constants.py: -------------------------------------------------------------------------------- 1 | A_DOIP_CTRL = 2 # 2s 2 | A_DOIP_ACCOUNCE_MAX_WAIT = 0.500 # 0..500ms 3 | A_DOIP_ANNOUNCE_INTERVAL = 0.500 # 500ms 4 | A_DOIP_ANNOUNCE_NUM = 3 # 3 times 5 | A_DOIP_DIAGNOSTIC_MESSAGE = 2 # 2s 6 | T_TCP_GENERAL_INACTIVITY = 300 # 5 Min 7 | T_TCP_INITIAL_INACTIVITY = 2 # 2s 8 | T_TCP_ALIVE_CHECK = 0.500 # 500ms 9 | A_PROCESSING_TIME = 2 # 2s 10 | A_VEHICLE_DISCOVERY_TIMER = 5 # 5s 11 | 12 | # Table 41 - UDP ports 13 | UDP_DISCOVERY = 13400 14 | 15 | # Table 39 - Supported TCP ports 16 | TCP_DATA_UNSECURED = 13400 17 | TCP_DATA_SECURED = 3496 18 | 19 | # link-local scope multicast address (FF02 16 ::1) 20 | LINK_LOCAL_MULTICAST_ADDRESS = "ff02::1" 21 | -------------------------------------------------------------------------------- /doipclient/messages.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from enum import IntEnum 3 | 4 | # Quoted descriptions were copied or paraphrased from ISO-13400-2-2019 (E). 5 | 6 | 7 | class DoIPMessage: 8 | """Base class for DoIP messages implementing common features like comparison, 9 | and representation""" 10 | 11 | def __repr__(self): 12 | formatted_field_values = [] 13 | for field in self._fields: 14 | value = getattr(self, "_" + field) 15 | if type(value) == str: 16 | formatted_field_values.append(f'"{value}"') 17 | else: 18 | formatted_field_values.append(str(value)) 19 | args = ", ".join(formatted_field_values) 20 | classname = type(self).__name__ 21 | return f"{classname}({args})" 22 | 23 | def __str__(self): 24 | formatted_field_values = [] 25 | for field in self._fields: 26 | value = getattr(self, field) 27 | if type(value) == str: 28 | formatted_field_values.append(f'{field}: "{value}"') 29 | else: 30 | formatted_field_values.append(f"{field} : {str(value)}") 31 | args = ", ".join(formatted_field_values) 32 | classname = type(self).__name__ 33 | if args: 34 | return f"{classname} (0x{self.payload_type:X}): {{ {args} }}" 35 | else: 36 | return f"{classname} (0x{self.payload_type:X})" 37 | 38 | def __eq__(self, other): 39 | return (type(self) == type(other)) and (self.pack() == other.pack()) 40 | 41 | 42 | class ReservedMessage(DoIPMessage): 43 | """DoIP message whose payload ID is reserved either for manufacturer use or future 44 | expansion of DoIP protocol""" 45 | 46 | @classmethod 47 | def unpack(cls, payload_type, payload_bytes, payload_length): 48 | return ReservedMessage(payload_type, payload_bytes) 49 | 50 | def pack(self): 51 | self._payload 52 | 53 | _fields = ["payload_type", "payload"] 54 | 55 | def __init__(self, payload_type, payload): 56 | self._payload_type = payload_type 57 | self._payload = payload 58 | 59 | @property 60 | def payload(self): 61 | """Raw payload bytes""" 62 | return self._payload 63 | 64 | @property 65 | def payload_type(self): 66 | """Raw payload type (ID)""" 67 | return self._payload_type 68 | 69 | 70 | class GenericDoIPNegativeAcknowledge(DoIPMessage): 71 | """Generic header negative acknowledge structure. See Table 18""" 72 | 73 | payload_type = 0x0000 74 | 75 | class NackCodes(IntEnum): 76 | """Generic DoIP header NACK codes. See Table 19""" 77 | 78 | IncorrectPatternFormat = 0x00 79 | UnknownPayloadType = 0x01 80 | MessageTooLarge = 0x02 81 | OutOfMemory = 0x03 82 | InvalidPayloadLength = 0x04 83 | 84 | @classmethod 85 | def unpack(cls, payload_bytes, payload_length): 86 | return GenericDoIPNegativeAcknowledge(*struct.unpack_from("!B", payload_bytes)) 87 | 88 | def pack(self): 89 | return struct.pack("!B", self._nack_code) 90 | 91 | _fields = ["nack_code"] 92 | 93 | def __init__(self, nack_code): 94 | self._nack_code = nack_code 95 | 96 | @property 97 | def nack_code(self): 98 | """Generic DoIP header NACK code 99 | 100 | Description: "The generic header negative acknowledge code indicates the specific error, 101 | detected in the generic DoIP header, or it indicates an unsupported payload or a memory 102 | overload condition." 103 | """ 104 | return self._nack_code 105 | 106 | 107 | class AliveCheckRequest(DoIPMessage): 108 | """Alive check request - Table 27""" 109 | 110 | payload_type = 0x0007 111 | 112 | _fields = [] 113 | 114 | @classmethod 115 | def unpack(cls, payload_bytes, payload_length): 116 | return AliveCheckRequest() 117 | 118 | def pack(self): 119 | return bytearray() 120 | 121 | 122 | class AliveCheckResponse(DoIPMessage): 123 | """Alive check resopnse - Table 28""" 124 | 125 | payload_type = 0x0008 126 | 127 | @classmethod 128 | def unpack(cls, payload_bytes, payload_length): 129 | return AliveCheckResponse(*struct.unpack_from("!H", payload_bytes)) 130 | 131 | def pack(self): 132 | return struct.pack("!H", self._source_address) 133 | 134 | _fields = ["source_address"] 135 | 136 | def __init__(self, source_address): 137 | self._source_address = source_address 138 | 139 | @property 140 | def source_address(self): 141 | """Source address (SA) 142 | 143 | Description: "Contains the logical address of the client DoIP entity 144 | that is currently active on this TCP_DATA socket" 145 | 146 | Values: From Table 13 147 | 148 | * 0x0000 = ISO/SAE reserved 149 | * 0x0001 to 0x0DFF = VM specific 150 | * 0x0E00 to 0x0FFF = Reserved for addresses of client 151 | * 0x1000 to 0x7FFF = VM Specific 152 | * 0x8000 to 0xE3FF = Reserved 153 | * 0xE400 to 0xE3FF = VM defined functional group logical addresses 154 | * 0xF000 to 0xFFFF = Reserved 155 | """ 156 | return self._source_address 157 | 158 | 159 | class DoipEntityStatusRequest(DoIPMessage): 160 | """DoIP entity status request - Table 10""" 161 | 162 | payload_type = 0x4001 163 | 164 | _fields = [] 165 | 166 | @classmethod 167 | def unpack(cls, payload_bytes, payload_length): 168 | return DoipEntityStatusRequest() 169 | 170 | def pack(self): 171 | return bytearray() 172 | 173 | 174 | class DiagnosticPowerModeRequest(DoIPMessage): 175 | """Diagnostic power mode information request - Table 8""" 176 | 177 | payload_type = 0x4003 178 | 179 | _fields = [] 180 | 181 | @classmethod 182 | def unpack(cls, payload_bytes, payload_length): 183 | return DiagnosticPowerModeRequest() 184 | 185 | def pack(self): 186 | return bytearray() 187 | 188 | 189 | class DiagnosticPowerModeResponse(DoIPMessage): 190 | """Diagnostic power mode information response - Table 9""" 191 | 192 | payload_type = 0x4004 193 | 194 | _fields = ["diagnostic_power_mode"] 195 | 196 | class DiagnosticPowerMode(IntEnum): 197 | """Diagnostic power mode - See Table 9""" 198 | 199 | NotReady = 0x00 200 | Ready = 0x01 201 | NotSupported = 0x02 202 | 203 | @classmethod 204 | def unpack(cls, payload_bytes, payload_length): 205 | return DiagnosticPowerModeResponse(*struct.unpack_from("!B", payload_bytes)) 206 | 207 | def pack(self): 208 | return struct.pack("!B", self._diagnostic_power_mode) 209 | 210 | def __init__(self, diagnostic_power_mode): 211 | self._diagnostic_power_mode = diagnostic_power_mode 212 | 213 | @property 214 | def diagnostic_power_mode(self): 215 | """Diagnostic power mode 216 | 217 | Description: "Identifies whether or not the 218 | vehicle is in diagnostic power mode and ready to perform 219 | reliable diagnostics. 220 | """ 221 | return DiagnosticPowerModeResponse.DiagnosticPowerMode( 222 | self._diagnostic_power_mode 223 | ) 224 | 225 | 226 | class RoutingActivationRequest(DoIPMessage): 227 | """Routing activation request. Table 46""" 228 | 229 | payload_type = 0x0005 230 | 231 | _fields = ["source_address", "activation_type", "reserved", "vm_specific"] 232 | 233 | class ActivationType(IntEnum): 234 | """See Table 47 - Routing activation request activation types""" 235 | 236 | Default = 0x00 237 | DiagnosticRequiredByRegulation = 0x01 238 | CentralSecurity = 0xE1 239 | 240 | @classmethod 241 | def unpack(cls, payload_bytes, payload_length): 242 | if payload_length == 7: 243 | return RoutingActivationRequest(*struct.unpack_from("!HBL", payload_bytes)) 244 | else: 245 | return RoutingActivationRequest(*struct.unpack_from("!HBLL", payload_bytes)) 246 | 247 | def pack(self): 248 | if self._vm_specific is not None: 249 | return struct.pack( 250 | "!HBLL", 251 | self._source_address, 252 | self._activation_type, 253 | self._reserved, 254 | self._vm_specific, 255 | ) 256 | else: 257 | return struct.pack( 258 | "!HBL", self._source_address, self._activation_type, self._reserved 259 | ) 260 | 261 | def __init__(self, source_address, activation_type, reserved=0, vm_specific=None): 262 | self._source_address = source_address 263 | self._activation_type = activation_type 264 | self._reserved = reserved 265 | self._vm_specific = vm_specific 266 | 267 | @property 268 | def source_address(self): 269 | """Source address (SA) 270 | 271 | Description: "Address of the client DoIP entity that requests routing activation. 272 | This is the same address that is used by the client DoIP entity when sending 273 | diagnostic messages on the same TCP_DATA socket." 274 | 275 | Values: From Table 13 276 | 277 | * 0x0000 = ISO/SAE reserved 278 | * 0x0001 to 0x0DFF = VM specific 279 | * 0x0E00 to 0x0FFF = Reserved for addresses of client 280 | * 0x1000 to 0x7FFF = VM Specific 281 | * 0x8000 to 0xE3FF = Reserved 282 | * 0xE400 to 0xE3FF = VM defined functional group logical addresses 283 | * 0xF000 to 0xFFFF = Reserved 284 | """ 285 | return self._source_address 286 | 287 | @property 288 | def activation_type(self): 289 | """Activation type 290 | 291 | Description: "Indicates the specific type of routing activation that may 292 | require different types of authentication and/or confirmation." 293 | """ 294 | return RoutingActivationRequest.ActivationType(self._activation_type) 295 | 296 | @property 297 | def reserved(self): 298 | """Reserved - should be 0x00000000""" 299 | return self._reserved 300 | 301 | @property 302 | def vm_specific(self): 303 | """Reserved for VM-specific use""" 304 | return self._vm_specific 305 | 306 | 307 | class VehicleIdentificationRequest(DoIPMessage): 308 | """Vehicle identification request message. See Table 2""" 309 | 310 | payload_type = 0x0001 311 | 312 | _fields = [] 313 | 314 | @classmethod 315 | def unpack(cls, payload_bytes, payload_length): 316 | return VehicleIdentificationRequest() 317 | 318 | def pack(self): 319 | return bytearray() 320 | 321 | 322 | class VehicleIdentificationRequestWithEID(DoIPMessage): 323 | """Vehicle identification request message with EID. See Table 3""" 324 | 325 | payload_type = 0x0002 326 | 327 | _fields = ["eid"] 328 | 329 | @classmethod 330 | def unpack(cls, payload_bytes, payload_length): 331 | return VehicleIdentificationRequestWithEID( 332 | *struct.unpack_from("!6s", payload_bytes) 333 | ) 334 | 335 | def pack(self): 336 | return struct.pack("!6s", self._eid) 337 | 338 | def __init__(self, eid): 339 | self._eid = eid 340 | 341 | @property 342 | def eid(self): 343 | """EID 344 | 345 | Description: "This is the DoIP entity's unique ID (e.g. network 346 | interface's MAC address) that shall respond to the vehicle 347 | identification request message." 348 | """ 349 | return self._eid 350 | 351 | 352 | class VehicleIdentificationRequestWithVIN(DoIPMessage): 353 | """Vehicle identification request message with VIN. See Table 4""" 354 | 355 | payload_type = 0x0003 356 | 357 | _fields = ["vin"] 358 | 359 | @classmethod 360 | def unpack(cls, payload_bytes, payload_length): 361 | return VehicleIdentificationRequestWithVIN( 362 | *struct.unpack_from("!17s", payload_bytes) 363 | ) 364 | 365 | def pack(self): 366 | return struct.pack("!17s", self._vin.encode("ascii")) 367 | 368 | def __init__(self, vin): 369 | self._vin = vin 370 | 371 | @property 372 | def vin(self): 373 | """VIN 374 | 375 | Description: "This is the vehicle’s identification number asspecified 376 | in ISO 3779. This parameter is only present if the client DoIP entity 377 | intends toidentify the DoIP entities of an individual vehicle, the VIN 378 | of which is known to the client DoIP entity." 379 | 380 | Values: ASCII 381 | """ 382 | if type(self._vin) is bytes: 383 | return self._vin.decode("ascii") 384 | else: 385 | return self._vin 386 | 387 | 388 | class RoutingActivationResponse(DoIPMessage): 389 | """Payload type routing activation response.""" 390 | 391 | payload_type = 0x0006 392 | 393 | _fields = [ 394 | "client_logical_address", 395 | "logical_address", 396 | "response_code", 397 | "reserved", 398 | "vm_specific", 399 | ] 400 | 401 | class ResponseCode(IntEnum): 402 | """See Table 49""" 403 | 404 | DeniedUnknownSourceAddress = 0x00 405 | DeniedAllSocketsRegisteredActive = 0x01 406 | DeniedSADoesNotMatch = 0x02 407 | DeniedSARegistered = 0x03 408 | DeniedMissingAuthentication = 0x04 409 | DeniedRejectedConfirmation = 0x05 410 | DeniedUnsupportedActivationType = 0x06 411 | DeniedRequiresTLS = 0x07 412 | Success = 0x10 413 | SuccessConfirmationRequired = 0x11 414 | 415 | @classmethod 416 | def unpack(cls, payload_bytes, payload_length): 417 | if payload_length == 9: 418 | return RoutingActivationResponse( 419 | *struct.unpack_from("!HHBL", payload_bytes) 420 | ) 421 | else: 422 | return RoutingActivationResponse( 423 | *struct.unpack_from("!HHBLL", payload_bytes) 424 | ) 425 | 426 | def pack(self): 427 | if self._vm_specific is not None: 428 | return struct.pack( 429 | "!HHBLL", 430 | self._client_logical_address, 431 | self._logical_address, 432 | self._response_code, 433 | self._reserved, 434 | self._vm_specific, 435 | ) 436 | else: 437 | return struct.pack( 438 | "!HHBL", 439 | self._client_logical_address, 440 | self._logical_address, 441 | self._response_code, 442 | self._reserved, 443 | ) 444 | 445 | def __init__( 446 | self, 447 | client_logical_address, 448 | logical_address, 449 | response_code, 450 | reserved=0, 451 | vm_specific=None, 452 | ): 453 | self._client_logical_address = client_logical_address 454 | self._logical_address = logical_address 455 | self._response_code = response_code 456 | self._reserved = reserved 457 | self._vm_specific = vm_specific 458 | 459 | @property 460 | def client_logical_address(self): 461 | """Logical address of client DoIP entity 462 | 463 | Description: "Logical address of the client DoIP entity that requested routing activation." 464 | 465 | Values: From Table 13 466 | 467 | * 0x0000 = ISO/SAE reserved 468 | * 0x0001 to 0x0DFF = VM specific 469 | * 0x0E00 to 0x0FFF = Reserved for addresses of client 470 | * 0x1000 to 0x7FFF = VM Specific 471 | * 0x8000 to 0xE3FF = Reserved 472 | * 0xE400 to 0xE3FF = VM defined functional group logical addresses 473 | * 0xF000 to 0xFFFF = Reserved 474 | """ 475 | return self._client_logical_address 476 | 477 | @property 478 | def logical_address(self): 479 | """Logical address of DoIP entity 480 | 481 | Description: "Logical address of the responding DoIP entity." 482 | 483 | Values: See client_logical_address 484 | """ 485 | return self._logical_address 486 | 487 | @property 488 | def response_code(self): 489 | """Routing activation response code 490 | 491 | Description: "Response by the DoIP gateway. Routing activation denial results 492 | in the TCP_DATA connection being reset by the DoIP gateway. Successful routing 493 | activation implies that diagnostic messages can now be routed over the TCP_DATA 494 | connection. 495 | """ 496 | return RoutingActivationResponse.ResponseCode(self._response_code) 497 | 498 | @property 499 | def reserved(self): 500 | """Reserved value - 0x00000000""" 501 | return self._reserved 502 | 503 | @property 504 | def vm_specific(self): 505 | """Reserved for VM-specific use 506 | 507 | Description: "Available for additional VM-specific use." 508 | """ 509 | return self._vm_specific 510 | 511 | 512 | class DiagnosticMessage(DoIPMessage): 513 | """Diagnostic Message - see Table 21 "Payload type diagnostic message structure" 514 | 515 | Description: Wrapper for diagnostic (UDS) payloads. The same message is used for 516 | TX and RX, and the ECU will confirm receipt with either a DiagnosticMessageNegativeAcknowledgement 517 | or a DiagnosticMessagePositiveAcknowledgement message 518 | """ 519 | 520 | payload_type = 0x8001 521 | 522 | _fields = ["source_address", "target_address", "user_data"] 523 | 524 | @classmethod 525 | def unpack(cls, payload_bytes, payload_length): 526 | return DiagnosticMessage( 527 | *struct.unpack_from("!HH", payload_bytes), payload_bytes[4:payload_length] 528 | ) 529 | 530 | def pack(self): 531 | return ( 532 | struct.pack("!HH", self._source_address, self._target_address) 533 | + self._user_data 534 | ) 535 | 536 | def __init__(self, source_address, target_address, user_data): 537 | self._source_address = source_address 538 | self._target_address = target_address 539 | self._user_data = user_data 540 | 541 | @property 542 | def source_address(self): 543 | """Source address (SA) 544 | 545 | Description: "Contains the logical address of the sender of a diagnostic messag 546 | (e.g. the client DoIP entity address)." 547 | 548 | Values: From Table 13 549 | 550 | * 0x0000 = ISO/SAE reserved 551 | * 0x0001 to 0x0DFF = VM specific 552 | * 0x0E00 to 0x0FFF = Reserved for addresses of client 553 | * 0x1000 to 0x7FFF = VM Specific 554 | * 0x8000 to 0xE3FF = Reserved 555 | * 0xE400 to 0xE3FF = VM defined functional group logical addresses 556 | * 0xF000 to 0xFFFF = Reserved 557 | """ 558 | return self._source_address 559 | 560 | @property 561 | def target_address(self): 562 | """Target address (TA) 563 | 564 | Description: "Contains the logical address of the receiver of a diagnostic message 565 | (e.g. a specific server DoIP entity on the vehicle’s networks)." 566 | 567 | Values: From Table 13 568 | 569 | * 0x0000 = ISO/SAE reserved 570 | * 0x0001 to 0x0DFF = VM specific 571 | * 0x0E00 to 0x0FFF = Reserved for addresses of client 572 | * 0x1000 to 0x7FFF = VM Specific 573 | * 0x8000 to 0xE3FF = Reserved 574 | * 0xE400 to 0xE3FF = VM defined functional group logical addresses 575 | * 0xF000 to 0xFFFF = Reserved 576 | """ 577 | return self._target_address 578 | 579 | @property 580 | def user_data(self): 581 | """User data (UD) 582 | 583 | Description: Contains the actual diagnostic data (e.g. ISO 14229-1 diagnostic 584 | request), which shall be routed to the destination (e.g. the ECM). 585 | 586 | Values: Bytes/Bytearray 587 | """ 588 | return self._user_data 589 | 590 | 591 | class DiagnosticMessageNegativeAcknowledgement(DoIPMessage): 592 | """A negative acknowledgement of the previously received diagnostic (UDS) message. 593 | 594 | Indicates that the previously received diagnostic message was rejected. Reasons could 595 | include a message being too large, incorrect logical addresses, etc. 596 | 597 | See Table 25 - "Payload type diagnostic message negative acknowledgment structure" 598 | """ 599 | 600 | payload_type = 0x8003 601 | 602 | _fields = ["source_address", "target_address", "nack_code", "previous_message_data"] 603 | 604 | class NackCodes(IntEnum): 605 | """Diagnostic message negative acknowledge codes (See Table 26)""" 606 | 607 | InvalidSourceAddress = 0x02 608 | UnknownTargetAddress = 0x03 609 | DiagnosticMessageTooLarge = 0x04 610 | OutOfMemory = 0x05 611 | TargetUnreachable = 0x06 612 | UnknownNetwork = 0x07 613 | TransportProtocolError = 0x08 614 | 615 | @classmethod 616 | def unpack(cls, payload_bytes, payload_length): 617 | return DiagnosticMessageNegativeAcknowledgement( 618 | *struct.unpack_from("!HHB", payload_bytes), payload_bytes[5:payload_length] 619 | ) 620 | 621 | def pack(self): 622 | return ( 623 | struct.pack( 624 | "!HHB", self._source_address, self._target_address, self._nack_code 625 | ) 626 | + self._previous_message_data 627 | ) 628 | 629 | def __init__( 630 | self, 631 | source_address, 632 | target_address, 633 | nack_code, 634 | previous_message_data=bytearray(), 635 | ): 636 | self._source_address = source_address 637 | self._target_address = target_address 638 | self._nack_code = nack_code 639 | self._previous_message_data = previous_message_data 640 | 641 | @property 642 | def source_address(self): 643 | """Source address (SA) 644 | 645 | Description: "Contains the logical address of the (intended) receiver of the previous 646 | diagnostic message (e.g. a specific server DoIP entity on the vehicle’s networks)." 647 | 648 | Values: From Table 13 649 | 650 | * 0x0000 = ISO/SAE reserved 651 | * 0x0001 to 0x0DFF = VM specific 652 | * 0x0E00 to 0x0FFF = Reserved for addresses of client 653 | * 0x1000 to 0x7FFF = VM Specific 654 | * 0x8000 to 0xE3FF = Reserved 655 | * 0xE400 to 0xE3FF = VM defined functional group logical addresses 656 | * 0xF000 to 0xFFFF = Reserved 657 | """ 658 | return self._source_address 659 | 660 | @property 661 | def target_address(self): 662 | """Target address (TA) 663 | 664 | Description: "Contains the logical address of the sender of the previous diagnostic 665 | message (i.e. the client DoIP entity address)." 666 | 667 | Values: (See source_address) 668 | """ 669 | return self._target_address 670 | 671 | @property 672 | def nack_code(self): 673 | """NACK code 674 | 675 | Indicates the reason the diagnostic message was rejected 676 | """ 677 | return DiagnosticMessageNegativeAcknowledgement.NackCodes(self._nack_code) 678 | 679 | @property 680 | def previous_message_data(self): 681 | """Previous diagnostic message data 682 | 683 | An optional copy of the diagnostic message which is being acknowledged. 684 | """ 685 | if self._previous_message_data: 686 | return self._previous_message_data 687 | else: 688 | return None 689 | 690 | 691 | class DiagnosticMessagePositiveAcknowledgement(DoIPMessage): 692 | """A positive acknowledgement of the previously received diagnostic (UDS) message. 693 | 694 | "...indicates a correctly received diagnostic message, which is processed and put into the transmission 695 | buffer of the destination network." 696 | 697 | See Table 23 - "Payload type diagnostic message acknowledgement structure" 698 | """ 699 | 700 | payload_type = 0x8002 701 | 702 | _fields = ["source_address", "target_address", "ack_code", "previous_message_data"] 703 | 704 | @classmethod 705 | def unpack(cls, payload_bytes, payload_length): 706 | return DiagnosticMessagePositiveAcknowledgement( 707 | *struct.unpack_from("!HHB", payload_bytes), payload_bytes[5:payload_length] 708 | ) 709 | 710 | def pack(self): 711 | return ( 712 | struct.pack( 713 | "!HHB", self._source_address, self._target_address, self._ack_code 714 | ) 715 | + self._previous_message_data 716 | ) 717 | 718 | def __init__( 719 | self, 720 | source_address, 721 | target_address, 722 | ack_code, 723 | previous_message_data=bytearray(), 724 | ): 725 | self._source_address = source_address 726 | self._target_address = target_address 727 | self._ack_code = ack_code 728 | self._previous_message_data = previous_message_data 729 | 730 | @property 731 | def source_address(self): 732 | """Source address (SA) 733 | 734 | Description: "Contains the logical address of the (intended) receiver of the previous 735 | diagnostic message (e.g. a specific server DoIP entity on the vehicle’s networks)." 736 | 737 | Values: From Table 13 738 | 739 | * 0x0000 = ISO/SAE reserved 740 | * 0x0001 to 0x0DFF = VM specific 741 | * 0x0E00 to 0x0FFF = Reserved for addresses of client 742 | * 0x1000 to 0x7FFF = VM Specific 743 | * 0x8000 to 0xE3FF = Reserved 744 | * 0xE400 to 0xE3FF = VM defined functional group logical addresses 745 | * 0xF000 to 0xFFFF = Reserved 746 | """ 747 | return self._source_address 748 | 749 | @property 750 | def target_address(self): 751 | """Target address (TA) 752 | 753 | Description: "Contains the logical address of the sender of the previous diagnostic 754 | message (i.e. the client DoIP entity address)." 755 | 756 | Values: (See source_address) 757 | """ 758 | return self._target_address 759 | 760 | @property 761 | def ack_code(self): 762 | """ACK code 763 | 764 | Values: Required to be 0x00. All other values are reserved 765 | """ 766 | return self._ack_code 767 | 768 | @property 769 | def previous_message_data(self): 770 | """Previous diagnostic message data 771 | 772 | An optional copy of the diagnostic message which is being acknowledged. 773 | """ 774 | if self._previous_message_data: 775 | return self._previous_message_data 776 | else: 777 | return None 778 | 779 | 780 | class EntityStatusResponse(DoIPMessage): 781 | """DoIP entity status response. Table 11""" 782 | 783 | payload_type = 0x4002 784 | 785 | _fields = [ 786 | "node_type", 787 | "max_concurrent_sockets", 788 | "currently_open_sockets", 789 | "max_data_size", 790 | ] 791 | 792 | @classmethod 793 | def unpack(cls, payload_bytes, payload_length): 794 | if payload_length == 3: 795 | return EntityStatusResponse(*struct.unpack_from("!BBB", payload_bytes)) 796 | else: 797 | return EntityStatusResponse(*struct.unpack_from("!BBBL", payload_bytes)) 798 | 799 | def pack(self): 800 | if self.max_data_size is None: 801 | return struct.pack( 802 | "!BBB", 803 | self._node_type, 804 | self._max_concurrent_sockets, 805 | self._currently_open_sockets, 806 | ) 807 | else: 808 | return struct.pack( 809 | "!BBBL", 810 | self._node_type, 811 | self._max_concurrent_sockets, 812 | self._currently_open_sockets, 813 | self._max_data_size, 814 | ) 815 | 816 | def __init__( 817 | self, 818 | node_type, 819 | max_concurrent_sockets, 820 | currently_open_sockets, 821 | max_data_size=None, 822 | ): 823 | self._node_type = node_type 824 | self._max_concurrent_sockets = max_concurrent_sockets 825 | self._currently_open_sockets = currently_open_sockets 826 | self._max_data_size = max_data_size 827 | 828 | @property 829 | def node_type(self): 830 | """Node type(NT) 831 | 832 | Description: 833 | "Identifies whether the contacted DoIP instance is either a DoIP node or a DoIP gateway." 834 | 835 | Values: 836 | 837 | * 0x00: DoIP gateway 838 | * 0x01: DoIP node 839 | * 0x02 .. 0xFF: reserved 840 | """ 841 | return self._node_type 842 | 843 | @property 844 | def max_concurrent_sockets(self): 845 | """Max. concurrent TCP_DATA sockets (MCTS) 846 | 847 | Description: 848 | "Represents the maximum number of concurrent TCP_DATA sockets allowed with this DoIP entity, 849 | excluding the reserve socket required for socket handling." 850 | 851 | Values: 852 | 1 to 255 853 | """ 854 | return self._max_concurrent_sockets 855 | 856 | @property 857 | def currently_open_sockets(self): 858 | """Currently open TCP_DATA sockets (NCTS) 859 | 860 | Description: "Number of currently established sockets." 861 | 862 | Values: 863 | 0 to 255 864 | """ 865 | return self._currently_open_sockets 866 | 867 | @property 868 | def max_data_size(self): 869 | """Max. data size (MDS) 870 | 871 | Description: "Maximum size of one logical request that this DoIP entity can process." 872 | 873 | Values: 874 | 0 to 4GB 875 | """ 876 | return self._max_data_size 877 | 878 | 879 | class VehicleIdentificationResponse(DoIPMessage): 880 | """Payload type vehicle announcement/identification response message Table 5""" 881 | 882 | payload_type = 0x0004 883 | 884 | _fields = [ 885 | "vin", 886 | "logical_address", 887 | "eid", 888 | "gid", 889 | "further_action_required", 890 | "vin_sync_status", 891 | ] 892 | 893 | class SynchronizationStatusCodes(IntEnum): 894 | """VIN/GID synchronization status code values (Table 7) 895 | 896 | * 0x00 = VIN and/or GID are synchronized 897 | * 0x01 = Reserved 898 | * 0x10 = Incomplete: VIN and GID are not synchronized 899 | * 0x11..0xff = Reserved 900 | """ 901 | 902 | Synchronized = 0x00 903 | Incomplete = 0x10 904 | 905 | class FurtherActionCodes(IntEnum): 906 | """Further Action Code Values (Table 6) 907 | 908 | * 0x00 = No further action required 909 | * 0x01 = Reserved 910 | * 0x10 = Routing activation required to initiate central security 911 | * 0x11..0xff = available for additional VM-specific use""" 912 | 913 | NoFurtherActionRequired = 0x00 914 | RoutingActivationRequired = 0x10 915 | 916 | @classmethod 917 | def unpack(cls, payload_bytes, payload_length): 918 | if payload_length == 33: 919 | return VehicleIdentificationResponse( 920 | *struct.unpack_from("!17sH6s6sBB", payload_bytes) 921 | ) 922 | else: 923 | return VehicleIdentificationResponse( 924 | *struct.unpack_from("!17sH6s6sB", payload_bytes) 925 | ) 926 | 927 | def pack(self): 928 | if self._vin_sync_status is not None: 929 | return struct.pack( 930 | "!17sH6s6sBB", 931 | self._vin.encode("ascii"), 932 | self._logical_address, 933 | self._eid, 934 | self._gid, 935 | self._further_action_required, 936 | self._vin_sync_status, 937 | ) 938 | else: 939 | return struct.pack( 940 | "!17sH6s6sB", 941 | self._vin.encode("ascii"), 942 | self._logical_address, 943 | self._eid, 944 | self._gid, 945 | self._further_action_required, 946 | ) 947 | 948 | def __init__( 949 | self, 950 | vin, 951 | logical_address, 952 | eid, 953 | gid, 954 | further_action_required, 955 | vin_gid_sync_status=None, 956 | ): 957 | self._vin = vin 958 | self._logical_address = logical_address 959 | self._eid = eid 960 | self._gid = gid 961 | self._further_action_required = further_action_required 962 | self._vin_sync_status = vin_gid_sync_status 963 | 964 | @property 965 | def vin(self): 966 | """VIN 967 | 968 | Description: "This is the vehicle’s VIN as specified in ISO 3779. If the VIN is not configured at the time 969 | of transmission of this message, this should be indicated using the invalidity value {0x00 or 0xff}... In 970 | this case, the GID is used to associate DoIP nodes with a certain vehicle..." 971 | 972 | Values: ASCII 973 | """ 974 | if type(self._vin) is bytes: 975 | return self._vin.decode("ascii") 976 | else: 977 | return self._vin 978 | 979 | @property 980 | def logical_address(self): 981 | """Logical Address 982 | 983 | Description: "This is the logical address that is assigned to the responding DoIP entity (see 7. 8 for further 984 | details). The logical address can be used, for example, to address diagnostic requests directly to the DoIP 985 | entity." 986 | 987 | Values: 988 | From Table 13 989 | 990 | * 0x0000 = ISO/SAE reserved 991 | * 0x0001 to 0x0DFF = VM specific 992 | * 0x0E00 to 0x0FFF = Reserved for addresses of client 993 | * 0x1000 to 0x7FFF = VM Specific 994 | * 0x8000 to 0xE3FF = Reserved 995 | * 0xE400 to 0xE3FF = VM defined functional group logical addresses 996 | * 0xF000 to 0xFFFF = Reserved 997 | """ 998 | return self._logical_address 999 | 1000 | @property 1001 | def eid(self): 1002 | """EID 1003 | 1004 | Description: "This is a unique identification of the DoIP entities in order to separate their responses 1005 | even before the VIN is programmed to, or recognized by, the DoIP devices (e.g. during the vehicle assembly 1006 | process). It is recommended that the MAC address information of the DoIP entity's network interface be 1007 | used (one of the interfaces if multiple network interfaces are implemented)." 1008 | 1009 | Values: "Not set" values are 0x00 or 0xff. 1010 | """ 1011 | return self._eid 1012 | 1013 | @property 1014 | def gid(self): 1015 | """GID 1016 | 1017 | Description: "This is a unique identification of a group of DoIP entities within the same vehicle in the 1018 | case that a VIN is not configured for that vehicle... If the GID is not available at the time of 1019 | transmission of this message, this shall be indicated using the specific invalidity" ("not set") value 1020 | of 0x00 or 0xff. 1021 | """ 1022 | return self._gid 1023 | 1024 | @property 1025 | def further_action_required(self): 1026 | """Further action required 1027 | 1028 | Description: "This is the additional information to notify the client DoIP entity that there are either 1029 | DoIP entities with no initial connectivity or that a centralized security approach is used." 1030 | """ 1031 | return VehicleIdentificationResponse.FurtherActionCodes( 1032 | self._further_action_required 1033 | ) 1034 | 1035 | @property 1036 | def vin_sync_status(self): 1037 | """VIN/GID sync. status 1038 | 1039 | Description: "This is the additional information to notify the client DoIP entity that all DoIP entities 1040 | have synchronized their information about the VIN or GID of the vehicle" 1041 | """ 1042 | if self._vin_sync_status is not None: 1043 | return VehicleIdentificationResponse.SynchronizationStatusCodes( 1044 | self._vin_sync_status 1045 | ) 1046 | else: 1047 | return None 1048 | 1049 | 1050 | payload_type_to_message = { 1051 | 0x0000: GenericDoIPNegativeAcknowledge, 1052 | 0x0001: VehicleIdentificationRequest, 1053 | 0x0002: VehicleIdentificationRequestWithEID, 1054 | 0x0003: VehicleIdentificationRequestWithVIN, 1055 | 0x0004: VehicleIdentificationResponse, 1056 | 0x0005: RoutingActivationRequest, 1057 | 0x0006: RoutingActivationResponse, 1058 | 0x0007: AliveCheckRequest, 1059 | 0x0008: AliveCheckResponse, 1060 | 0x4001: DoipEntityStatusRequest, 1061 | 0x4002: EntityStatusResponse, 1062 | 0x4003: DiagnosticPowerModeRequest, 1063 | 0x4004: DiagnosticPowerModeResponse, 1064 | 0x8001: DiagnosticMessage, 1065 | 0x8002: DiagnosticMessagePositiveAcknowledgement, 1066 | 0x8003: DiagnosticMessageNegativeAcknowledgement, 1067 | } 1068 | 1069 | payload_message_to_type = { 1070 | message: payload_type for payload_type, message in payload_type_to_message.items() 1071 | } 1072 | -------------------------------------------------------------------------------- /doipclient/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobschaer/python-doipclient/2b674a3a1766b7c2e85eeff584f5b33a6bd7777e/doipclient/py.typed -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 40.6.0", "wheel"] 3 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import setuptools 4 | 5 | with open("README.rst", "r", encoding="utf-8") as fh: 6 | long_description = fh.read() 7 | 8 | setuptools.setup( 9 | name="doipclient", 10 | version="1.1.5", 11 | description="A Diagnostic over IP (DoIP) client implementing ISO-13400-2.", 12 | long_description=long_description, 13 | long_description_content_type="text/x-rst", 14 | author="Jacob Schaer", 15 | url="https://github.com/jacobschaer/python-doipclient", 16 | packages=["doipclient"], 17 | package_data={"doipclient": ["py.typed"]}, 18 | keywords=[ 19 | "uds", 20 | "14229", 21 | "iso-14229", 22 | "diagnostic", 23 | "automotive", 24 | "13400", 25 | "iso-13400", 26 | "doip", 27 | ], 28 | classifiers=[ 29 | "Programming Language :: Python :: 3", 30 | "License :: OSI Approved :: MIT License", 31 | "Operating System :: OS Independent", 32 | "Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator", 33 | ], 34 | python_requires=">=3.6", 35 | ) 36 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobschaer/python-doipclient/2b674a3a1766b7c2e85eeff584f5b33a6bd7777e/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import ssl 3 | import pytest 4 | import logging 5 | from doipclient import DoIPClient 6 | from doipclient.client import Parser 7 | from doipclient.messages import * 8 | 9 | try: 10 | from socket import IPPROTO_IPV6 11 | except ImportError: 12 | IPPROTO_IPV6 = 41 13 | 14 | test_logical_address = 1 15 | test_ip = "127.0.0.1" 16 | 17 | activation_request = bytearray( 18 | [int(x, 16) for x in "02 fd 00 05 00 00 00 07 0e 00 00 00 00 00 00".split(" ")] 19 | ) 20 | activation_request_with_vm = bytearray( 21 | [ 22 | int(x, 16) 23 | for x in "02 fd 00 05 00 00 00 0b 0e 00 00 00 00 00 00 01 02 03 04".split(" ") 24 | ] 25 | ) 26 | successful_activation_response = bytearray( 27 | [ 28 | int(x, 16) 29 | for x in "02 fd 00 06 00 00 00 09 0e 00 00 37 10 00 00 00 00".split(" ") 30 | ] 31 | ) 32 | unsuccessful_activation_response = bytearray( 33 | [ 34 | int(x, 16) 35 | for x in "02 fd 00 06 00 00 00 09 0e 00 00 37 00 00 00 00 00".split(" ") 36 | ] 37 | ) 38 | successful_activation_response_with_vm = bytearray( 39 | [ 40 | int(x, 16) 41 | for x in "02 fd 00 06 00 00 00 0d 0e 00 00 37 10 00 00 00 00 04 03 02 01".split( 42 | " " 43 | ) 44 | ] 45 | ) 46 | nack_response = bytearray([int(x, 16) for x in "02 fd 00 00 00 00 00 01 04".split(" ")]) 47 | alive_check_request = bytearray( 48 | [int(x, 16) for x in "02 fd 00 07 00 00 00 00".split(" ")] 49 | ) 50 | alive_check_response = bytearray( 51 | [int(x, 16) for x in "02 fd 00 08 00 00 00 02 0e 00".split(" ")] 52 | ) 53 | diagnostic_negative_response = bytearray( 54 | [int(x, 16) for x in "02 fd 80 03 00 00 00 05 00 00 00 00 05".split(" ")] 55 | ) 56 | diagnostic_positive_response = bytearray( 57 | [int(x, 16) for x in "02 fd 80 02 00 00 00 05 00 00 00 00 00".split(" ")] 58 | ) 59 | diagnostic_result = bytearray( 60 | [int(x, 16) for x in "02 fd 80 01 00 00 00 08 00 e0 00 55 00 01 02 03".split(" ")] 61 | ) 62 | entity_status_response = bytearray( 63 | [int(x, 16) for x in "02 fd 40 02 00 00 00 03 01 10 1".split(" ")] 64 | ) 65 | entity_status_response_with_mds = bytearray( 66 | [int(x, 16) for x in "02 fd 40 02 00 00 00 07 01 10 01 00 00 10 00".split(" ")] 67 | ) 68 | entity_status_request = bytearray( 69 | [int(x, 16) for x in "02 fd 40 01 00 00 00 00".split(" ")] 70 | ) 71 | vehicle_identification_request = bytearray( 72 | [int(x, 16) for x in "02 fd 00 01 00 00 00 00".split(" ")] 73 | ) 74 | vehicle_identification_request_with_ein = bytearray( 75 | [int(x, 16) for x in "02 fd 00 02 00 00 00 06 31 31 31 31 31 31".split(" ")] 76 | ) 77 | vehicle_identification_request_with_vin = bytearray( 78 | [ 79 | int(x, 16) 80 | for x in "02 fd 00 03 00 00 00 11 31 31 31 31 31 31 31 31 31 31 31 31 31 31 31 31 31".split( 81 | " " 82 | ) 83 | ] 84 | ) 85 | vehicle_identification_response = bytearray( 86 | [ 87 | int(x, 16) 88 | for x in "02 fd 00 04 00 00 00 21 31 31 31 31 31 31 31 31 31 31 31 31 31 31 31 31 31 12 34 31 31 31 31 31 31 32 32 32 32 32 32 00 00".split( 89 | " " 90 | ) 91 | ] 92 | ) 93 | diagnostic_power_mode_request = bytearray( 94 | [int(x, 16) for x in "02 fd 40 03 00 00 00 00".split(" ")] 95 | ) 96 | diagnostic_power_mode_response = bytearray( 97 | [int(x, 16) for x in "02 fd 40 04 00 00 00 01 01".split(" ")] 98 | ) 99 | diagnostic_request = bytearray( 100 | [int(x, 16) for x in "02 fd 80 01 00 00 00 07 0e 00 00 01 00 01 02".split(" ")] 101 | ) 102 | diagnostic_request_to_address = bytearray( 103 | [int(x, 16) for x in "02 fd 80 01 00 00 00 07 0e 00 12 34 00 01 02".split(" ")] 104 | ) 105 | unknown_mercedes_message = bytearray( 106 | [ 107 | int(x, 16) 108 | for x in "02 fd f0 10 00 00 00 38 00 00 06 00 0c 0c 00 00 00 00 00 00 56 39 34 58 44 30 30 30 31 35 00 00 44 6f 49 50 2d 56 43 49 2d 34 44 35 36 00 00 00 31 32 33 34 35 36 37 38 00 00 00 00 00 00 00 00".split( 109 | " " 110 | ) 111 | ] 112 | ) 113 | 114 | logger = logging.getLogger("doipclient") 115 | logger.setLevel(logging.DEBUG) 116 | ch = logging.StreamHandler() 117 | ch.setLevel(logging.DEBUG) 118 | logger.addHandler(ch) 119 | 120 | 121 | class MockSocket: 122 | def __init__(self): 123 | self.rx_queue = [successful_activation_response] 124 | self.tx_queue = [] 125 | self._bound_ip = None 126 | self._bound_port = None 127 | self.timeout = None 128 | self.opts = {} 129 | 130 | def construct(self, network, type): 131 | self._network = network 132 | self._type = type 133 | 134 | def connect(self, address): 135 | self._ip, self._port = address 136 | 137 | def setsockopt(self, socket_type, opt_type, opt_value): 138 | self.opts[socket_type] = self.opts.get(socket_type, {}) 139 | self.opts[socket_type][opt_type] = opt_value 140 | 141 | def settimeout(self, timeout): 142 | self.timeout = timeout 143 | 144 | def gettimeout(self): 145 | return self.timeout 146 | 147 | def bind(self, address): 148 | self._bound_ip, self._bound_port = address 149 | 150 | def recv(self, bufflen): 151 | try: 152 | result = self.rx_queue.pop(0) 153 | if type(result) == bytearray: 154 | return result 155 | else: 156 | raise (result) 157 | except IndexError: 158 | raise socket.timeout() 159 | 160 | def recvfrom(self, bufflen): 161 | try: 162 | result = self.rx_queue.pop(0) 163 | if type(result) == bytearray: 164 | return result, None 165 | else: 166 | raise (result) 167 | except IndexError: 168 | raise socket.timeout() 169 | 170 | def send(self, buffer): 171 | self.tx_queue.append(buffer) 172 | return len(buffer) 173 | 174 | def sendto(self, data_bytes, destination): 175 | self.tx_queue.append(data_bytes) 176 | return len(data_bytes) 177 | 178 | def close(self): 179 | pass 180 | 181 | 182 | @pytest.fixture 183 | def mock_socket(monkeypatch): 184 | a = MockSocket() 185 | 186 | def mock_construct(*args, **kwargs): 187 | a.construct(*args, **kwargs) 188 | return a 189 | 190 | monkeypatch.setattr(socket, "socket", mock_construct) 191 | yield a 192 | 193 | 194 | parameterized_class_fields = [ 195 | ( 196 | VehicleIdentificationResponse, 197 | [ 198 | ("vin", "1" * 17), 199 | ("logical_address", 1234), 200 | ("eid", b"1" * 6), 201 | ("gid", b"1" * 6), 202 | ("further_action_required", 0x10), 203 | ], 204 | ), 205 | ( 206 | VehicleIdentificationResponse, 207 | [ 208 | ("vin", "1" * 17), 209 | ("logical_address", 1234), 210 | ("eid", b"1" * 6), 211 | ("gid", b"1" * 6), 212 | ("further_action_required", 0x10), 213 | ("vin_sync_status", None), 214 | ], 215 | ), 216 | ( 217 | VehicleIdentificationResponse, 218 | [ 219 | ("vin", "1" * 17), 220 | ("logical_address", 1234), 221 | ("eid", b"2" * 6), 222 | ("gid", b"2" * 6), 223 | ("further_action_required", 0x00), 224 | ("vin_sync_status", 0x10), 225 | ], 226 | ), 227 | ( 228 | EntityStatusResponse, 229 | [ 230 | ("node_type", 0x01), 231 | ("max_concurrent_sockets", 13), 232 | ("currently_open_sockets", 5), 233 | ], 234 | ), 235 | ( 236 | EntityStatusResponse, 237 | [ 238 | ("node_type", 0x00), 239 | ("max_concurrent_sockets", 1), 240 | ("currently_open_sockets", 28), 241 | ("max_data_size", 0xFFF), 242 | ], 243 | ), 244 | (GenericDoIPNegativeAcknowledge, [("nack_code", 1)]), 245 | (VehicleIdentificationRequest, []), 246 | (VehicleIdentificationRequestWithEID, [("eid", b"2" * 6)]), 247 | (VehicleIdentificationRequestWithVIN, [("vin", "1" * 17)]), 248 | ( 249 | RoutingActivationRequest, 250 | [ 251 | ("source_address", 0x00E0), 252 | ("activation_type", 1), 253 | ], 254 | ), 255 | ( 256 | RoutingActivationRequest, 257 | [ 258 | ("source_address", 0x00E0), 259 | ("activation_type", 1), 260 | ("reserved", 0), 261 | ("vm_specific", 0x1234), 262 | ], 263 | ), 264 | ( 265 | RoutingActivationResponse, 266 | [ 267 | ("client_logical_address", 0x00E0), 268 | ("logical_address", 1), 269 | ("response_code", 0), 270 | ], 271 | ), 272 | ( 273 | RoutingActivationResponse, 274 | [ 275 | ("client_logical_address", 0x00E0), 276 | ("logical_address", 1), 277 | ("response_code", 0), 278 | ("reserved", 0), 279 | ("vm_specific", 0x1234), 280 | ], 281 | ), 282 | (AliveCheckRequest, []), 283 | (AliveCheckResponse, [("source_address", 0x00E0)]), 284 | (DoipEntityStatusRequest, []), 285 | (DiagnosticPowerModeRequest, []), 286 | (DiagnosticPowerModeResponse, [("diagnostic_power_mode", 0x01)]), 287 | ( 288 | DiagnosticMessage, 289 | [ 290 | ("source_address", 0x00E0), 291 | ("target_address", 0x00E0), 292 | ("user_data", bytearray([0, 1, 2, 3])), 293 | ], 294 | ), 295 | ( 296 | DiagnosticMessagePositiveAcknowledgement, 297 | [ 298 | ("source_address", 0x00E0), 299 | ("target_address", 0x00E0), 300 | ("ack_code", 0), 301 | ], 302 | ), 303 | ( 304 | DiagnosticMessagePositiveAcknowledgement, 305 | [ 306 | ("source_address", 0x00E0), 307 | ("target_address", 0x00E0), 308 | ("ack_code", 0), 309 | ("previous_message_data", bytearray([1, 2, 3])), 310 | ], 311 | ), 312 | ( 313 | DiagnosticMessageNegativeAcknowledgement, 314 | [ 315 | ("source_address", 0x00E0), 316 | ("target_address", 0x00E0), 317 | ("nack_code", 2), 318 | ], 319 | ), 320 | ( 321 | DiagnosticMessageNegativeAcknowledgement, 322 | [ 323 | ("source_address", 0x00E0), 324 | ("target_address", 0x00E0), 325 | ("nack_code", 2), 326 | ("previous_message_data", bytearray([1, 2, 3])), 327 | ], 328 | ), 329 | ] 330 | 331 | 332 | @pytest.mark.parametrize("message, fields", parameterized_class_fields) 333 | def test_packer_unpackers(mock_socket, message, fields): 334 | values = [x for _, x in fields] 335 | a = message(*values) 336 | packed = a.pack() 337 | b = message.unpack(packed, len(packed)) 338 | for field_name, field_value in fields: 339 | assert getattr(b, field_name) == field_value 340 | 341 | 342 | @pytest.mark.parametrize("message, fields", parameterized_class_fields) 343 | def test_repr(mock_socket, message, fields): 344 | values = [x for _, x in fields] 345 | a = message(*values) 346 | print(repr(a)) 347 | print(str(a)) 348 | assert eval(repr(a)) == a 349 | 350 | 351 | def test_does_not_activate_with_none(mock_socket, mocker): 352 | spy = mocker.spy(DoIPClient, "request_activation") 353 | mock_socket.rx_queue = [] 354 | sut = DoIPClient(test_ip, test_logical_address, activation_type=None) 355 | assert spy.call_count == 0 356 | 357 | 358 | def test_resend_reactivate_closed_socket(mock_socket, mocker): 359 | request_activation_spy = mocker.spy(DoIPClient, "request_activation") 360 | reconnect_spy = mocker.spy(DoIPClient, "reconnect") 361 | sut = DoIPClient(test_ip, test_logical_address, auto_reconnect_tcp=True) 362 | mock_socket.rx_queue.append(bytearray()) 363 | mock_socket.rx_queue.append(successful_activation_response) 364 | mock_socket.rx_queue.append(diagnostic_positive_response) 365 | assert None == sut.send_diagnostic(bytearray([0, 1, 2])) 366 | assert request_activation_spy.call_count == 2 367 | assert reconnect_spy.call_count == 1 368 | assert mock_socket.timeout == 2 369 | 370 | 371 | def test_resend_reactivate_broken_socket(mock_socket, mocker): 372 | request_activation_spy = mocker.spy(DoIPClient, "request_activation") 373 | reconnect_spy = mocker.spy(DoIPClient, "reconnect") 374 | sut = DoIPClient(test_ip, test_logical_address, auto_reconnect_tcp=True) 375 | mock_socket.rx_queue.append(ConnectionResetError("")) 376 | mock_socket.rx_queue.append(successful_activation_response) 377 | mock_socket.rx_queue.append(diagnostic_positive_response) 378 | assert None == sut.send_diagnostic(bytearray([0, 1, 2])) 379 | assert request_activation_spy.call_count == 2 380 | assert reconnect_spy.call_count == 1 381 | 382 | 383 | def test_no_resend_reactivate_broken_socket(mock_socket, mocker): 384 | request_activation_spy = mocker.spy(DoIPClient, "request_activation") 385 | reconnect_spy = mocker.spy(DoIPClient, "reconnect") 386 | sut = DoIPClient(test_ip, test_logical_address) 387 | mock_socket.rx_queue.append(ConnectionResetError("")) 388 | mock_socket.rx_queue.append(successful_activation_response) 389 | mock_socket.rx_queue.append(diagnostic_positive_response) 390 | with pytest.raises(ConnectionResetError): 391 | sut.send_diagnostic(bytearray([0, 1, 2])) 392 | assert request_activation_spy.call_count == 1 393 | assert reconnect_spy.call_count == 0 394 | 395 | 396 | def test_connect_with_bind(mock_socket): 397 | sut = DoIPClient(test_ip, test_logical_address, client_ip_address="192.168.1.1") 398 | assert mock_socket._bound_ip == "192.168.1.1" 399 | assert mock_socket._bound_port == 0 400 | 401 | 402 | def test_context_manager(mock_socket, mocker): 403 | close_spy = mocker.spy(DoIPClient, "close") 404 | mock_socket.rx_queue.append(diagnostic_positive_response) 405 | 406 | with DoIPClient(test_ip, test_logical_address) as sut: 407 | assert None == sut.send_diagnostic(bytearray([0, 1, 2])) 408 | assert close_spy.call_count == 1 409 | 410 | 411 | def test_send_good_activation_request(mock_socket): 412 | sut = DoIPClient(test_ip, test_logical_address) 413 | mock_socket.rx_queue.append(successful_activation_response) 414 | result = sut.request_activation(0) 415 | assert mock_socket._bound_ip == None 416 | assert mock_socket._bound_port == None 417 | assert mock_socket.tx_queue[-1] == activation_request 418 | assert result.client_logical_address == 0x0E00 419 | assert result.logical_address == 55 420 | assert result.response_code == 16 421 | assert result.vm_specific is None 422 | 423 | 424 | def test_send_good_activation_request_with_vm(mock_socket): 425 | sut = DoIPClient(test_ip, test_logical_address) 426 | mock_socket.rx_queue.append(successful_activation_response_with_vm) 427 | result = sut.request_activation(0, 0x01020304) 428 | assert mock_socket.tx_queue[-1] == activation_request_with_vm 429 | assert result.client_logical_address == 0x0E00 430 | assert result.logical_address == 55 431 | assert result.response_code == 16 432 | assert result.vm_specific == 0x04030201 433 | 434 | 435 | def test_activation_with_nack(mock_socket): 436 | sut = DoIPClient(test_ip, test_logical_address) 437 | mock_socket.rx_queue.append(nack_response) 438 | with pytest.raises(IOError, match=r"DoIP Negative Acknowledge. NACK Code: "): 439 | result = sut.request_activation(0) 440 | 441 | 442 | def test_activation_with_alive_check(mock_socket): 443 | sut = DoIPClient(test_ip, test_logical_address) 444 | mock_socket.rx_queue.append(alive_check_request) 445 | mock_socket.rx_queue.append(successful_activation_response) 446 | result = sut.request_activation(0) 447 | assert result.client_logical_address == 0x0E00 448 | assert mock_socket.tx_queue[-1] == alive_check_response 449 | 450 | 451 | def test_request_alive_check(mock_socket): 452 | sut = DoIPClient(test_ip, test_logical_address) 453 | mock_socket.rx_queue.append(alive_check_response) 454 | result = sut.request_alive_check() 455 | assert result.source_address == 0x0E00 456 | assert mock_socket.tx_queue[-1] == alive_check_request 457 | 458 | 459 | def test_alive_check(mock_socket): 460 | sut = DoIPClient(test_ip, test_logical_address) 461 | mock_socket.rx_queue.append(alive_check_request) 462 | with pytest.raises(TimeoutError): 463 | sut.read_doip() 464 | assert len(mock_socket.tx_queue) == 2 465 | assert mock_socket.tx_queue[-1] == alive_check_response 466 | 467 | 468 | def test_request_entity_status_with_mds(mock_socket): 469 | sut = DoIPClient(test_ip, test_logical_address) 470 | mock_socket.rx_queue.append(entity_status_response_with_mds) 471 | result = sut.request_entity_status() 472 | assert mock_socket.tx_queue[-1] == entity_status_request 473 | assert result.node_type == 1 474 | assert result.max_concurrent_sockets == 16 475 | assert result.currently_open_sockets == 1 476 | assert result.max_data_size == 4096 477 | 478 | 479 | def test_request_entity_status(mock_socket): 480 | sut = DoIPClient(test_ip, test_logical_address) 481 | mock_socket.rx_queue.append(entity_status_response) 482 | result = sut.request_entity_status() 483 | assert mock_socket.tx_queue[-1] == entity_status_request 484 | assert result.node_type == 1 485 | assert result.max_concurrent_sockets == 16 486 | assert result.currently_open_sockets == 1 487 | 488 | 489 | def test_send_diagnostic_postive(mock_socket): 490 | sut = DoIPClient(test_ip, test_logical_address) 491 | mock_socket.rx_queue.append(diagnostic_positive_response) 492 | assert None == sut.send_diagnostic(bytearray([0, 1, 2])) 493 | assert mock_socket.tx_queue[-1] == diagnostic_request 494 | 495 | 496 | def test_send_diagnostic_negative(mock_socket): 497 | sut = DoIPClient(test_ip, test_logical_address) 498 | mock_socket.rx_queue.append(diagnostic_negative_response) 499 | with pytest.raises( 500 | IOError, match=r"Diagnostic request rejected with negative acknowledge code" 501 | ): 502 | result = sut.send_diagnostic(bytearray([0, 1, 2])) 503 | assert mock_socket.tx_queue[-1] == diagnostic_request 504 | 505 | 506 | def test_send_diagnostic_to_address_positive(mock_socket): 507 | sut = DoIPClient(test_ip, test_logical_address) 508 | mock_socket.rx_queue.append(diagnostic_positive_response) 509 | assert None == sut.send_diagnostic_to_address(0x1234, bytearray([0, 1, 2])) 510 | assert mock_socket.tx_queue[-1] == diagnostic_request_to_address 511 | 512 | 513 | def test_send_diagnostic_to_address_negative(mock_socket): 514 | sut = DoIPClient(test_ip, test_logical_address) 515 | mock_socket.rx_queue.append(diagnostic_negative_response) 516 | with pytest.raises( 517 | IOError, match=r"Diagnostic request rejected with negative acknowledge code" 518 | ): 519 | result = sut.send_diagnostic_to_address(0x1234, bytearray([0, 1, 2])) 520 | assert mock_socket.tx_queue[-1] == diagnostic_request_to_address 521 | 522 | 523 | def test_receive_diagnostic(mock_socket): 524 | sut = DoIPClient(test_ip, test_logical_address) 525 | mock_socket.rx_queue.append(diagnostic_result) 526 | result = sut.receive_diagnostic() 527 | assert result == bytearray([0, 1, 2, 3]) 528 | 529 | 530 | def test_request_vehicle_identification(mock_socket): 531 | sut = DoIPClient(test_ip, test_logical_address) 532 | mock_socket.rx_queue.append(vehicle_identification_response) 533 | result = sut.request_vehicle_identification() 534 | assert mock_socket.tx_queue[-1] == vehicle_identification_request 535 | assert result.vin == "1" * 17 536 | assert result.logical_address == 0x1234 537 | assert result.eid == b"1" * 6 538 | assert result.gid == b"2" * 6 539 | assert result.further_action_required == 0x00 540 | assert result.vin_sync_status == 0x00 541 | 542 | 543 | def test_request_vehicle_identification_with_ein(mock_socket): 544 | sut = DoIPClient(test_ip, test_logical_address) 545 | mock_socket.rx_queue.append(vehicle_identification_response) 546 | result = sut.request_vehicle_identification(eid=b"1" * 6) 547 | assert mock_socket.tx_queue[-1] == vehicle_identification_request_with_ein 548 | assert result.vin == "1" * 17 549 | assert result.logical_address == 0x1234 550 | 551 | 552 | def test_request_vehicle_identification_with_vin(mock_socket): 553 | sut = DoIPClient(test_ip, test_logical_address) 554 | mock_socket.rx_queue.append(vehicle_identification_response) 555 | result = sut.request_vehicle_identification(vin="1" * 17) 556 | assert mock_socket.tx_queue[-1] == vehicle_identification_request_with_vin 557 | assert result.vin == "1" * 17 558 | assert result.logical_address == 0x1234 559 | assert result.eid == b"1" * 6 560 | assert result.gid == b"2" * 6 561 | assert result.further_action_required == 0x00 562 | assert result.vin_sync_status == 0x00 563 | 564 | 565 | def test_get_entity(mock_socket): 566 | mock_socket.rx_queue.append(vehicle_identification_response) 567 | _, result = DoIPClient.get_entity() 568 | assert mock_socket.tx_queue[-1] == vehicle_identification_request 569 | assert result.vin == "1" * 17 570 | assert result.logical_address == 0x1234 571 | assert result.eid == b"1" * 6 572 | assert result.gid == b"2" * 6 573 | assert result.further_action_required == 0x00 574 | assert result.vin_sync_status == 0x00 575 | 576 | 577 | def test_get_entity_with_ein(mock_socket): 578 | mock_socket.rx_queue.append(vehicle_identification_response) 579 | _, result = DoIPClient.get_entity(eid=b"1" * 6) 580 | assert mock_socket.tx_queue[-1] == vehicle_identification_request_with_ein 581 | assert result.vin == "1" * 17 582 | assert result.logical_address == 0x1234 583 | assert result.eid == b"1" * 6 584 | assert result.gid == b"2" * 6 585 | assert result.further_action_required == 0x00 586 | assert result.vin_sync_status == 0x00 587 | 588 | 589 | def test_get_entity_with_vin(mock_socket): 590 | mock_socket.rx_queue.append(vehicle_identification_response) 591 | _, result = DoIPClient.get_entity(vin="1" * 17) 592 | assert mock_socket.tx_queue[-1] == vehicle_identification_request_with_vin 593 | assert result.vin == "1" * 17 594 | assert result.logical_address == 0x1234 595 | assert result.eid == b"1" * 6 596 | assert result.gid == b"2" * 6 597 | assert result.further_action_required == 0x00 598 | assert result.vin_sync_status == 0x00 599 | 600 | 601 | def test_request_diagnostic_power_mode(mock_socket): 602 | sut = DoIPClient(test_ip, test_logical_address) 603 | mock_socket.rx_queue.append(diagnostic_power_mode_response) 604 | result = sut.request_diagnostic_power_mode() 605 | assert mock_socket.tx_queue[-1] == diagnostic_power_mode_request 606 | assert result.diagnostic_power_mode == 0x01 607 | 608 | 609 | def test_failed_activation_constructor(mock_socket): 610 | # Swap out the default good response with a bad one 611 | mock_socket.rx_queue[-1] = unsuccessful_activation_response 612 | with pytest.raises( 613 | ConnectionRefusedError, match=r"Activation Request failed with code" 614 | ): 615 | sut = DoIPClient(test_ip, test_logical_address) 616 | 617 | 618 | def test_read_generic(mock_socket): 619 | sut = DoIPClient(test_ip, test_logical_address) 620 | mock_socket.rx_queue.append(unknown_mercedes_message) 621 | result = sut.read_doip() 622 | assert type(result) == ReservedMessage 623 | assert result.payload_type == 0xF010 624 | assert result.payload == unknown_mercedes_message[8:] 625 | 626 | 627 | def test_send_generic(mock_socket): 628 | sut = DoIPClient(test_ip, test_logical_address) 629 | result = sut.send_doip(0xF010, unknown_mercedes_message[8:]) 630 | assert mock_socket.tx_queue[-1] == unknown_mercedes_message 631 | 632 | 633 | def test_message_ids(): 634 | for payload_type, message in payload_type_to_message.items(): 635 | assert payload_type == message.payload_type 636 | 637 | 638 | def test_invalid_ip(): 639 | with pytest.raises( 640 | ValueError, match=r"does not appear to be an IPv4 or IPv6 address" 641 | ): 642 | sut = DoIPClient(test_ip + "a", test_logical_address) 643 | 644 | 645 | def test_ipv4(mock_socket): 646 | sut = DoIPClient(test_ip, test_logical_address) 647 | assert mock_socket._network == socket.AF_INET 648 | assert mock_socket.opts == { 649 | socket.SOL_SOCKET: {socket.SO_REUSEADDR: True}, 650 | socket.IPPROTO_TCP: {socket.TCP_NODELAY: True}, 651 | } 652 | 653 | 654 | def test_ipv6(mock_socket): 655 | sut = DoIPClient("2001:db8::", test_logical_address) 656 | assert mock_socket._network == socket.AF_INET6 657 | assert mock_socket.opts == { 658 | socket.SOL_SOCKET: {socket.SO_REUSEADDR: True}, 659 | socket.IPPROTO_TCP: {socket.TCP_NODELAY: True}, 660 | } 661 | 662 | 663 | def test_await_ipv6(mock_socket): 664 | mock_socket.rx_queue.clear() 665 | try: 666 | DoIPClient.await_vehicle_announcement( 667 | udp_port=13400, timeout=0.1, ipv6=True, source_interface=None 668 | ) 669 | except TimeoutError: 670 | pass 671 | assert mock_socket._network == socket.AF_INET6 672 | assert mock_socket._bound_ip == "ff02::1" 673 | assert mock_socket._bound_port == 13400 674 | assert mock_socket.opts == { 675 | socket.SOL_SOCKET: {socket.SO_REUSEADDR: True}, 676 | IPPROTO_IPV6: { 677 | socket.IPV6_JOIN_GROUP: b"\xff\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00" 678 | }, 679 | } 680 | 681 | 682 | def test_await_ipv4(mock_socket): 683 | mock_socket.rx_queue.clear() 684 | try: 685 | DoIPClient.await_vehicle_announcement( 686 | udp_port=13400, timeout=0.1, ipv6=False, source_interface=None 687 | ) 688 | except TimeoutError: 689 | pass 690 | assert mock_socket._network == socket.AF_INET 691 | assert mock_socket._bound_ip == "" 692 | assert mock_socket._bound_port == 13400 693 | assert mock_socket.opts == { 694 | socket.SOL_SOCKET: {socket.SO_REUSEADDR: True, socket.SO_BROADCAST: True}, 695 | } 696 | 697 | 698 | def test_exception_from_blocking_ssl_socket(mock_socket, mocker): 699 | """SSL sockets behave slightly different than regular sockets in 700 | non-blocking mode. They won't raise BlockingIOError but SSLWantWriteError 701 | or SSLWantReadError instead. 702 | 703 | See: https://docs.python.org/3/library/ssl.html#notes-on-non-blocking-sockets 704 | """ 705 | sut = DoIPClient(test_ip, test_logical_address) 706 | 707 | try: 708 | sut._tcp_sock.recv = mocker.Mock(side_effect=ssl.SSLWantReadError) 709 | sut._tcp_socket_check() 710 | sut._tcp_sock.recv = mocker.Mock(side_effect=ssl.SSLWantWriteError) 711 | sut._tcp_socket_check() 712 | except (ssl.SSLWantReadError, ssl.SSLWantWriteError) as exc: 713 | pytest.fail(f"Should not raise exception: {exc.__class__.__name__}") 714 | 715 | 716 | def test_use_secure_uses_default_ssl_context(mock_socket, mocker): 717 | """Wrap socket with default SSL-context when use_secure=True""" 718 | mocked_default_context = mocker.patch.object( 719 | ssl, "create_default_context", autospec=True 720 | ) 721 | sut = DoIPClient( 722 | test_ip, test_logical_address, use_secure=True, activation_type=None 723 | ) 724 | mocked_default_wrap_socket = mocked_default_context.return_value.wrap_socket 725 | mocked_default_wrap_socket.assert_called_once_with(mock_socket) 726 | 727 | 728 | def test_use_secure_with_external_ssl_context(mock_socket, mocker): 729 | """Wrap socket with user provided SSL-context when use_secure=ssl_context""" 730 | original_context = ssl.SSLContext 731 | mocked_external_context = mocker.patch.object(ssl, "SSLContext", autospec=True) 732 | mocked_default_context = mocker.patch.object( 733 | ssl, "create_default_context", autospec=True 734 | ) 735 | 736 | # Unmock the SSLContext 737 | ssl.SSLContext = original_context 738 | 739 | sut = DoIPClient( 740 | test_ip, 741 | test_logical_address, 742 | use_secure=mocked_external_context, 743 | activation_type=None, 744 | ) 745 | 746 | mocked_default_wrap_socket = mocked_default_context.return_value.wrap_socket 747 | assert ( 748 | not mocked_default_wrap_socket.called 749 | ), "Socket should *not* get wrapped using default context." 750 | 751 | mocked_external_wrap_socket = mocked_external_context.wrap_socket 752 | mocked_external_wrap_socket.assert_called_once_with(mock_socket) 753 | --------------------------------------------------------------------------------