├── .gitignore ├── LICENSE ├── README.md ├── addresses.py ├── dtc_utils.py ├── ecu_config.json ├── ecu_config.py ├── ecu_simulator.py ├── img ├── caringcaribou_1.png ├── caringcaribou_2.png ├── caringcaribou_3.png ├── obd.jpg ├── obd_detecting.jpg ├── obd_dtc.jpg ├── obd_info.jpg ├── obd_sbc-can01.jpg └── obd_vin.jpg ├── loggers ├── logger_app.py ├── logger_can.py ├── logger_isotp.py └── logger_utils.py ├── obd ├── listener.py ├── responses.py └── services.py ├── setup_can.sh ├── setup_vcan.sh ├── tests ├── test_obd │ ├── test_responses.py │ └── test_services.py └── test_uds │ └── test_services.py └── uds ├── listener.py └── services.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | .idea/ 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Luis Alberto Benthin Sanguino 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :warning: This project is not maintained anymore :( It is kept here only for reference. Thanks for the feedbacks :) 2 | 3 | # ECU Simulator 4 | 5 | This Python tool simulates some vehicle diagnostic services. It can be used to test OBD-II dongles or tester tools that support the UDS (ISO 14229) and ISO-TP (ISO 15765-2) protocols. 6 | 7 | This tool does NOT implement the ISO-TP protocol. It just simulates a couple of OBD and UDS services. The simulation consists in receiving a diagnostic request (e.g., Request DTCs (0x03)), and responding to it according to the protocol specifications. The data of some responses (e.g., VIN) must be defined in the `ecu_config.json` file. 8 | 9 | I created this project to learn more about the OBD and UDS protocols. I did my best to understand the specifications, however, if you suspect that something is implemented wrongly, please let me know. Any feedback will be very appreciated. 10 | 11 | ## Supported Services 12 | 13 | ### OBD-II 14 | 15 | | Service | PID | Description | 16 | |:-------:|:-----: |:---------------------------------------| 17 | | 0x01 | 0x00 | List of supported PIDs in service 0x01 | 18 | | 0x01 | 0x05 | Engine coolant temperature | 19 | | 0x01 | 0x0D | Vehicle speed | 20 | | 0x01 | 0x2F | Fuel tank level input | 21 | | 0x01 | 0x51 | Fuel type | 22 | | 0x03 | - | Request DTCs | 23 | | 0x09 | 0x00 | List of supported PIDs in service 0x09 | 24 | | 0x09 | 0x02 | Vehicle Identification Number (VIN) | 25 | | 0x09 | 0x0A | ECU name | 26 | 27 | ### UDS (ISO 14229) 28 | 29 | | Service ID | Name | Supported sub-functions | Default parameters (response) | 30 | |:----------:|:-------------------------|:------------------------|:-----------------| 31 | | 0x10 | DiagnosticSessionControl | **session types**

0x01 default
0x02 programming
0x03 extended
0x04 safety | | 32 | | 0x11 | ECUReset | **reset types**

0x01 hardReset
0x02 keyOffOnReset
0x03 softReset
0x04 enableRapidPowerShutDown
0x05 disableRapidPowerShutDown | 0x0F powerDownTime | 33 | | 0x19 | ReadDTCInformation | **report types**

0x02 reportDTCByStatusMask |
0xFF DTCStatusAvailabilityMask
0x2F statusOfDTC | 34 | 35 | ## Addressing 36 | 37 | **OBD:** Functional and physical. See options `obd_broadcast_address` and `obd_ecu_address` in `ecu_config.json`. 38 | 39 | **UDS:** Physical. See option `uds_ecu_address` in `ecu_config.json`. 40 | 41 | In both cases, only ISO-TP **normal addressing** (only CAN arbitration ID is used) is supported. 42 | 43 | ## Requirements 44 | 45 | * Python3 46 | * [SocketCAN](https://www.kernel.org/doc/Documentation/networking/can.txt) Implementation of the CAN protocol. This kernel module is part of Linux. 47 | * [ISO-TP kernel module](https://github.com/hartkopp/can-isotp) It is NOT part of linux. It needs to be loaded before running the `ecu-simulator`. See `isotp_ko_file_path` in `ecu_config.json`. 48 | * [isotp](https://can-isotp.readthedocs.io/en/latest/) The `ecu-simulator` only uses [isotp.socket](https://can-isotp.readthedocs.io/en/latest/isotp/socket.html), which is a wrapper for the ISO-TP kernel module. 49 | * [python-can](https://python-can.readthedocs.io/en/master/installation.html) The `ecu-simulator` uses this library to log CAN messages (see `loggers\logger_can.py`). **Note**: The bus type `socketcan_native` is used. 50 | 51 | ## Usage 52 | 53 | The `ecu-simulator` sets up the CAN interface and loads the ISO-TP linux kernel module (you need to configure `can_interface`, `can_interface_type`, `can_bitrate`, and `isotp_ko_file_path` in `ecu_config.json`). To perform this task, the tool must be started with root privileges: 54 | 55 | ``` 56 | sudo python3 ecu-simulator.py 57 | ``` 58 | 59 | If you do not want to start the tool with root privileges, you can do the following: 60 | 61 | ``` 62 | # set up CAN hardware interface 63 | sudo sh setup_can.sh 64 | 65 | # or set up CAN virtual interface 66 | sudo sh setup_vcan.sh 67 | 68 | # and then start the tool without sudo 69 | python3 ecu_simulator.py 70 | ``` 71 | 72 | ## Logging 73 | 74 | The `ecu-simulator` provides 3 levels of logging: CAN, ISO-TP, and application level. For example, when the VIN is requested, the following is logged: 75 | 76 | * In `can_[Timestamp].log` 77 | 78 | ``` 79 | 2020-02-05T13:08:39.188 can0 0x7df 0x0209020000000000 80 | 2020-02-05T13:08:39.192 can0 0x7e8 0x1014490200544553 81 | 2020-02-05T13:08:39.192 can0 0x7e0 0x3000050000000000 82 | 2020-02-05T13:08:39.198 can0 0x7e8 0x215456494e303132 83 | 2020-02-05T13:08:39.203 can0 0x7e8 0x2233343536373839 84 | ``` 85 | * In `isotp_[Timestamp].log` 86 | 87 | ``` 88 | 2020-02-05T13:08:39.498 can0 0x7df 0x0902 89 | 2020-02-05T13:08:39.499 can0 0x7e8 0x4902005445535456494e30313233343536373839 90 | ``` 91 | * In `ecu_simulator.log` 92 | 93 | ``` 94 | 2020-02-05T13:08:39.189 - ecu_simulator - INFO - Receiving on OBD address 0x7df from 0x7e8 Request: 0x0902 95 | 2020-02-05T13:08:39.190 - ecu_simulator - INFO - Requested OBD SID 0x9: Request vehicle information 96 | 2020-02-05T13:08:39.191 - ecu_simulator - INFO - Requested PID 0x2: Vehicle Identification Number(VIN) 97 | 2020-02-05T13:08:39.191 - ecu_simulator - INFO - Sending to 0x7e8 Response: 0x4902005445535456494e30313233343536373839 98 | ``` 99 | 100 | The log files have a max size of **1.5 M**. A new log file is generated when this size is reached. 101 | 102 | ## Test Environment 103 | 104 | The `ecu-simulator` was tested on a Raspberry Pi (Raspbian, Linux Kernel 4.19) with PiCAN and [SBC-CAN01](http://www.anleitung.joy-it.net/wp-content/uploads/2018/09/SBC-CAN01-Anschlussplan.pdf) (see pic below) as CAN-Bus board. 105 | 106 | ### OBD-II 107 | 108 | The OBD-II services were tested using a real OBD-II scanner. 109 | 110 | OBD-II test - SBC-CAN01 111 | 112 | OBD-II test env 113 | 114 | OBD-II test env 115 | 116 | OBD-II test env 117 | 118 | 119 | ### UDS 120 | 121 | To test the UDS services, the [Caring Caribou](https://github.com/CaringCaribou/caringcaribou) tool was used. 122 | 123 | UDS test env 124 | 125 | UDS test env 126 | 127 | ## License 128 | 129 | MIT License 130 | 131 | Copyright (c) 2020 Luis Alberto Benthin Sanguino 132 | 133 | Permission is hereby granted, free of charge, to any person obtaining a copy 134 | of this software and associated documentation files (the "Software"), to deal 135 | in the Software without restriction, including without limitation the rights 136 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 137 | copies of the Software, and to permit persons to whom the Software is 138 | furnished to do so, subject to the following conditions: 139 | 140 | The above copyright notice and this permission notice shall be included in all 141 | copies or substantial portions of the Software. 142 | 143 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 144 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 145 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 146 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 147 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 148 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 149 | SOFTWARE. 150 | 151 | 152 | 153 | -------------------------------------------------------------------------------- /addresses.py: -------------------------------------------------------------------------------- 1 | import ecu_config 2 | 3 | OBD_BROADCAST_ADDRESS = ecu_config.get_obd_broadcast_address() 4 | 5 | OBD_ECU_ADDRESS = ecu_config.get_obd_ecu_address() 6 | 7 | OBD_TARGET_ADDRESS = OBD_ECU_ADDRESS + 8 8 | 9 | UDS_ECU_ADDRESS = ecu_config.get_uds_ecu_address() 10 | 11 | UDS_TARGET_ADDRESS = UDS_ECU_ADDRESS + 8 12 | 13 | ECU_ADDRESSES = [OBD_BROADCAST_ADDRESS, OBD_ECU_ADDRESS, UDS_ECU_ADDRESS] 14 | 15 | TARGET_ADDRESSES = [OBD_TARGET_ADDRESS, UDS_TARGET_ADDRESS] 16 | -------------------------------------------------------------------------------- /dtc_utils.py: -------------------------------------------------------------------------------- 1 | DTC_GROUP = {"P": "00", "C": "01", "B": "10", "U": "11"} 2 | 3 | DTC_TYPE = {"0": "00", "1": "01", "2": "10", "3": "11"} 4 | 5 | DTC_LENGTH = 5 6 | 7 | BIG_ENDIAN = "big" 8 | 9 | UDS_DTC_HIGH_BYTE = 0x01 10 | 11 | UDS_DTC_DEFAULT_STATUS = 0x2F 12 | 13 | 14 | def encode_obd_dtcs(dtcs): 15 | dtcs_bytes = bytearray() 16 | for dtc in dtcs: 17 | if is_dtc_valid(dtc): 18 | dtcs_bytes += get_dtc_first_byte(dtc) + get_dtc_second_byte(dtc) 19 | return dtcs_bytes 20 | 21 | 22 | def encode_uds_dtcs(dtcs): 23 | dtcs_bytes = bytearray() 24 | for dtc in dtcs: 25 | if is_dtc_valid(dtc): 26 | dtcs_bytes += get_dtc_first_byte(dtc) + get_dtc_second_byte(dtc) + bytes([UDS_DTC_HIGH_BYTE]) + \ 27 | bytes([UDS_DTC_DEFAULT_STATUS]) 28 | return dtcs_bytes 29 | 30 | 31 | def is_dtc_valid(dtc): 32 | return len(dtc) == DTC_LENGTH and DTC_GROUP.get(dtc[0]) is not None and DTC_TYPE.get(dtc[1]) is not None \ 33 | and is_hex_value(dtc[2]) and is_hex_value(dtc[3]) and is_hex_value(dtc[4]) 34 | 35 | 36 | def get_dtc_first_byte(dtc): 37 | bits_0_3 = int(DTC_GROUP.get(dtc[0]) + DTC_TYPE.get(dtc[1]) + "0000", 2) 38 | bits_4_7 = int("0000" + dtc[2], 16) 39 | return (bits_0_3 | bits_4_7).to_bytes(1, BIG_ENDIAN) 40 | 41 | 42 | def get_dtc_second_byte(dtc): 43 | return int((dtc[3] + dtc[4]), 16).to_bytes(1, BIG_ENDIAN) 44 | 45 | 46 | def is_hex_value(value): 47 | try: 48 | int(value, 16) 49 | return True 50 | except ValueError: 51 | return False 52 | -------------------------------------------------------------------------------- /ecu_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "vin": { 3 | "value": "TESTVIN0123456789", 4 | "description": "Vehicle Identification Number. Max 17 characters" 5 | }, 6 | "ecu_name": { 7 | "value": "ECU_SIMULATOR", 8 | "description": "Name of the ECU. Max 20 characters" 9 | }, 10 | "fuel_level": { 11 | "value": 50, 12 | "description": "Fuel Level. Between 0 and 100" 13 | }, 14 | "fuel_type": { 15 | "value": 1, 16 | "description": "Fuel type (e.g., 1=Gasoline). See https://en.wikipedia.org/wiki/OBD-II_PIDs#Fuel_Type_Coding" 17 | }, 18 | "dtcs": { 19 | "value": ["B1477", "P0001"], 20 | "description": "List of Diagnostic Trouble Codes. The list can contain Max 255 DTCs" 21 | }, 22 | "obd_broadcast_address": { 23 | "value": "0x7DF", 24 | "description": "11-Bit broadcast address the ECU uses to receive OBD requests (functional addressing). The target address is: obd_ecu_address + 0x8" 25 | }, 26 | "obd_ecu_address": { 27 | "value": "0x7E0", 28 | "description": "11-Bit physical address the ECU uses to response to an OBD request. The target address is: obd_ecu_address + 0x8" 29 | }, 30 | "uds_ecu_address": { 31 | "value": "0x7E1", 32 | "description": "11-Bit physical address the ECU uses to receive and response to an UDS request (physical addressing). The target address is: uds_ecu_address + 0x8. The UDS module does not use functional addressing" 33 | }, 34 | "can_interface": { 35 | "value": "vcan0", 36 | "description": "CAN Interface used by the ECU simulator" 37 | }, 38 | "can_interface_type": { 39 | "value": "virtual", 40 | "description": "Two types are possible: virtual and hardware. If any other value is provided, the ecu-simulator does not set up the CAN interface and ISO-TP linux kernel module" 41 | }, 42 | "can_bitrate": { 43 | "value": "500000", 44 | "description": "CAN bitrate (refer to the specification of your CAN hardware). Only applicable for can_interface_type: hardware" 45 | }, 46 | "isotp_ko_file_path": { 47 | "value": "/usr/lib/modules/5.3.0-kali2-amd64/kernel/net/can/can-isotp.ko", 48 | "description": "File path of the ISO-TP Kernel module (see https://github.com/hartkopp/can-isotp)" 49 | } 50 | } -------------------------------------------------------------------------------- /ecu_config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | CONFIG_FILE = os.path.join(os.path.dirname(__file__), "ecu_config.json") 5 | 6 | CONFIG = json.load(open(CONFIG_FILE, "r")) 7 | 8 | 9 | def get_vin(): 10 | return CONFIG["vin"].get("value") 11 | 12 | 13 | def get_ecu_name(): 14 | return CONFIG["ecu_name"].get("value") 15 | 16 | 17 | def get_fuel_level(): 18 | return CONFIG["fuel_level"].get("value") 19 | 20 | 21 | def get_fuel_type(): 22 | return CONFIG["fuel_type"].get("value") 23 | 24 | 25 | def get_dtcs(): 26 | return CONFIG["dtcs"].get("value") 27 | 28 | 29 | def get_obd_broadcast_address(): 30 | return create_address(CONFIG["obd_broadcast_address"].get("value")) 31 | 32 | 33 | def get_obd_ecu_address(): 34 | return create_address(CONFIG["obd_ecu_address"].get("value")) 35 | 36 | 37 | def get_uds_ecu_address(): 38 | return create_address(CONFIG["uds_ecu_address"].get("value")) 39 | 40 | 41 | def create_address(address): 42 | try: 43 | return int(address, 16) 44 | except ValueError as error: 45 | print(error) 46 | exit(1) 47 | 48 | 49 | def get_can_interface(): 50 | return CONFIG["can_interface"].get("value") 51 | 52 | 53 | def get_can_interface_type(): 54 | return CONFIG["can_interface_type"].get("value") 55 | 56 | 57 | def get_can_bitrate(): 58 | return CONFIG["can_bitrate"].get("value") 59 | 60 | 61 | def get_isotp_ko_file_path(): 62 | return CONFIG["isotp_ko_file_path"].get("value") 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /ecu_simulator.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from threading import Thread 4 | import ecu_config 5 | from obd import listener as obd_listener 6 | from uds import listener as uds_listener 7 | from loggers import logger_app, logger_can, logger_isotp 8 | 9 | SETUP_VCAN_FILE = "setup_vcan.sh" 10 | 11 | SETUP_CAN_FILE = "setup_can.sh" 12 | 13 | 14 | def main(): 15 | logger_app.configure() 16 | logger_app.logger.info("Starting ECU-Simulator") 17 | set_up_can_interface() 18 | star_can_logger_thread() 19 | star_isotp_logger_thread() 20 | start_obd_listener_thread() 21 | start_uds_listener_thread() 22 | 23 | 24 | def set_up_can_interface(): 25 | interface_type = ecu_config.get_can_interface_type() 26 | can_interface = ecu_config.get_can_interface() 27 | isotp_ko_file_path = ecu_config.get_isotp_ko_file_path() 28 | if interface_type == "virtual": 29 | logger_app.logger.info("Setting up virtual CAN interface: " + can_interface) 30 | os.system("sh " + SETUP_VCAN_FILE + " " + can_interface + " " + isotp_ko_file_path) 31 | elif interface_type == "hardware": 32 | logger_app.logger.info("Setting up CAN interface: " + can_interface) 33 | logger_app.logger.info("Loading ISO-TP module from: " + isotp_ko_file_path) 34 | os.system("sh " + SETUP_CAN_FILE + " " + can_interface + " " + ecu_config.get_can_bitrate() + " " + isotp_ko_file_path) 35 | 36 | 37 | def star_can_logger_thread(): 38 | Thread(target=logger_can.start).start() 39 | 40 | 41 | def star_isotp_logger_thread(): 42 | Thread(target=logger_isotp.start).start() 43 | 44 | 45 | def start_obd_listener_thread(): 46 | Thread(target=obd_listener.start).start() 47 | 48 | 49 | def start_uds_listener_thread(): 50 | Thread(target=uds_listener.start).start() 51 | 52 | 53 | if __name__ == '__main__': 54 | sys.exit(main()) 55 | -------------------------------------------------------------------------------- /img/caringcaribou_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbenthins/ecu-simulator/ce46b877e24dbb852dfb8861ce4307961b6f7e38/img/caringcaribou_1.png -------------------------------------------------------------------------------- /img/caringcaribou_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbenthins/ecu-simulator/ce46b877e24dbb852dfb8861ce4307961b6f7e38/img/caringcaribou_2.png -------------------------------------------------------------------------------- /img/caringcaribou_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbenthins/ecu-simulator/ce46b877e24dbb852dfb8861ce4307961b6f7e38/img/caringcaribou_3.png -------------------------------------------------------------------------------- /img/obd.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbenthins/ecu-simulator/ce46b877e24dbb852dfb8861ce4307961b6f7e38/img/obd.jpg -------------------------------------------------------------------------------- /img/obd_detecting.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbenthins/ecu-simulator/ce46b877e24dbb852dfb8861ce4307961b6f7e38/img/obd_detecting.jpg -------------------------------------------------------------------------------- /img/obd_dtc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbenthins/ecu-simulator/ce46b877e24dbb852dfb8861ce4307961b6f7e38/img/obd_dtc.jpg -------------------------------------------------------------------------------- /img/obd_info.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbenthins/ecu-simulator/ce46b877e24dbb852dfb8861ce4307961b6f7e38/img/obd_info.jpg -------------------------------------------------------------------------------- /img/obd_sbc-can01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbenthins/ecu-simulator/ce46b877e24dbb852dfb8861ce4307961b6f7e38/img/obd_sbc-can01.jpg -------------------------------------------------------------------------------- /img/obd_vin.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbenthins/ecu-simulator/ce46b877e24dbb852dfb8861ce4307961b6f7e38/img/obd_vin.jpg -------------------------------------------------------------------------------- /loggers/logger_app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging import handlers 3 | from loggers.logger_utils import MAX_LOG_FILE_SIZE 4 | 5 | LOGGER_NAME = "ecu_simulator" 6 | 7 | DATE_FORMAT = "%Y-%m-%dT%H:%M:%S" 8 | 9 | LOGGER_FORMAT = "%(asctime)s.%(msecs)03d - %(name)s - %(levelname)s - %(message)s" 10 | 11 | LOG_FILE_NAME = LOGGER_NAME + ".log" 12 | 13 | logger = logging.getLogger(LOGGER_NAME) 14 | 15 | 16 | def configure(): 17 | formatter = logging.Formatter(LOGGER_FORMAT, datefmt=DATE_FORMAT) 18 | __add_file_handler(formatter) 19 | __add_console_handler(formatter) 20 | logger.setLevel(logging.DEBUG) 21 | 22 | 23 | def __add_file_handler(formatter): 24 | fh = handlers.RotatingFileHandler(LOG_FILE_NAME, maxBytes=MAX_LOG_FILE_SIZE, backupCount=5) 25 | fh.setLevel(logging.DEBUG) 26 | fh.setFormatter(formatter) 27 | logger.addHandler(fh) 28 | 29 | 30 | def __add_console_handler(formatter): 31 | ch = logging.StreamHandler() 32 | ch.setLevel(logging.DEBUG) 33 | ch.setFormatter(formatter) 34 | logger.addHandler(ch) 35 | 36 | -------------------------------------------------------------------------------- /loggers/logger_can.py: -------------------------------------------------------------------------------- 1 | import can 2 | from loggers import logger_utils 3 | from addresses import ECU_ADDRESSES, TARGET_ADDRESSES 4 | 5 | LOG_TYPE = "can" 6 | 7 | BUS_TYPE = "socketcan_native" 8 | 9 | CAN_MASK = 0x7FF 10 | 11 | 12 | def start(): 13 | bus = create_can_bus() 14 | file_path = logger_utils.create_file_path(LOG_TYPE) 15 | while True: 16 | file_path = logger_utils.create_new_file_path_if_size_exceeded(file_path, LOG_TYPE) 17 | message = bus.recv() 18 | logger_utils.write_to_file(file_path, message.timestamp, message.arbitration_id, message.data) 19 | 20 | 21 | def create_can_bus(): 22 | return can.interface.Bus(channel=logger_utils.CAN_INTERFACE, bustype=BUS_TYPE, can_filters=get_filters()) 23 | 24 | 25 | def get_filters(): 26 | filters = [] 27 | for can_id in get_can_ids(): 28 | filters.append({"can_id": can_id, "can_mask": CAN_MASK, "extended": False}) 29 | return filters 30 | 31 | 32 | def get_can_ids(): 33 | can_ids = [] 34 | can_ids.extend(ECU_ADDRESSES) 35 | can_ids.extend(TARGET_ADDRESSES) 36 | return can_ids 37 | -------------------------------------------------------------------------------- /loggers/logger_isotp.py: -------------------------------------------------------------------------------- 1 | import isotp 2 | from loggers import logger_utils 3 | from loggers.logger_utils import CAN_INTERFACE 4 | from addresses import UDS_ECU_ADDRESS, UDS_TARGET_ADDRESS 5 | from addresses import OBD_BROADCAST_ADDRESS, OBD_ECU_ADDRESS, OBD_TARGET_ADDRESS 6 | 7 | LOG_TYPE = "isotp" 8 | 9 | 10 | def start(): 11 | uds_socket_req = create_socket(rxid=UDS_ECU_ADDRESS, txid=UDS_TARGET_ADDRESS) 12 | uds_socket_res = create_socket(rxid=UDS_TARGET_ADDRESS, txid=UDS_ECU_ADDRESS) 13 | 14 | obd_socket_req = create_socket(rxid=OBD_BROADCAST_ADDRESS, txid=OBD_TARGET_ADDRESS) 15 | obd_socket_res = create_socket(rxid=OBD_TARGET_ADDRESS, txid=OBD_ECU_ADDRESS) 16 | 17 | file_path = logger_utils.create_file_path(LOG_TYPE) 18 | while True: 19 | uds_request = uds_socket_req.recv() 20 | uds_response = uds_socket_res.recv() 21 | 22 | obd_request = obd_socket_req.recv() 23 | obd_response = obd_socket_res.recv() 24 | 25 | file_path = logger_utils.create_new_file_path_if_size_exceeded(file_path, LOG_TYPE) 26 | if uds_request is not None: 27 | logger_utils.write_to_file(file_path, None, UDS_ECU_ADDRESS, uds_request) 28 | if uds_response is not None: 29 | logger_utils.write_to_file(file_path, None, UDS_TARGET_ADDRESS, uds_response) 30 | if obd_request is not None: 31 | logger_utils.write_to_file(file_path, None, OBD_BROADCAST_ADDRESS, obd_request) 32 | if obd_response is not None: 33 | logger_utils.write_to_file(file_path, None, OBD_TARGET_ADDRESS, obd_response) 34 | 35 | 36 | def create_socket(rxid, txid): 37 | socket = isotp.socket() 38 | socket.set_opts(socket.flags.LISTEN_MODE) 39 | socket.bind(CAN_INTERFACE, isotp.Address(rxid=rxid, txid=txid)) 40 | return socket 41 | 42 | -------------------------------------------------------------------------------- /loggers/logger_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import datetime 3 | import ecu_config 4 | 5 | CAN_INTERFACE = ecu_config.get_can_interface() 6 | 7 | MAX_LOG_FILE_SIZE = 1500000 # bytes 8 | 9 | LOG_FILE_NAME_FORMAT = "_%y%m%d%H%M%S.log" 10 | 11 | 12 | def create_file_path(log_type): 13 | return os.path.join(os.path.dirname("ecu_simulator"), datetime.datetime.now().strftime(log_type + LOG_FILE_NAME_FORMAT)) 14 | 15 | 16 | def create_new_file_path_if_size_exceeded(file_path, log_type): 17 | if os.path.exists(file_path): 18 | if os.path.getsize(file_path) > MAX_LOG_FILE_SIZE: 19 | file_path = create_file_path(log_type) 20 | return file_path 21 | 22 | 23 | def write_to_file(file_path, timestamp, address, data): 24 | log_file = open(file_path, "a") 25 | formatted_log = format_log(get_timestamp(timestamp), address, data) 26 | log_file.write(formatted_log) 27 | log_file.close() 28 | 29 | 30 | def get_timestamp(timestamp): 31 | if timestamp is None: 32 | return create_timestamp() 33 | return to_iso8601(timestamp) 34 | 35 | 36 | def create_timestamp(): 37 | return str(datetime.datetime.now().isoformat(timespec="milliseconds")) 38 | 39 | 40 | def to_iso8601(timestamp): 41 | return str(datetime.datetime.fromtimestamp(timestamp).isoformat(timespec="milliseconds")) 42 | 43 | 44 | def format_log(timestamp, address, data): 45 | return timestamp + " " + CAN_INTERFACE + " " + hex(address) + " " + "0x" + data.hex() + "\n" 46 | 47 | 48 | -------------------------------------------------------------------------------- /obd/listener.py: -------------------------------------------------------------------------------- 1 | import isotp 2 | import ecu_config 3 | from obd import services 4 | from addresses import OBD_BROADCAST_ADDRESS, OBD_ECU_ADDRESS, OBD_TARGET_ADDRESS 5 | from loggers.logger_app import logger 6 | 7 | CAN_INTERFACE = ecu_config.get_can_interface() 8 | 9 | 10 | def start(): 11 | request_socket = create_isotp_socket(OBD_BROADCAST_ADDRESS, OBD_TARGET_ADDRESS) 12 | response_socket = create_isotp_socket(OBD_ECU_ADDRESS, OBD_TARGET_ADDRESS) 13 | while True: 14 | request = request_socket.recv() 15 | requested_pid, requested_sid = get_sid_and_pid(request) 16 | if requested_sid is not None: 17 | log_request(request) 18 | response = services.process_service_request(requested_sid, requested_pid) 19 | if response is not None: 20 | log_response(response) 21 | response_socket.send(response) 22 | 23 | 24 | def create_isotp_socket(receiver_address, target_address): 25 | socket = isotp.socket() 26 | socket.bind(CAN_INTERFACE, isotp.Address(rxid=receiver_address, txid=target_address)) 27 | return socket 28 | 29 | 30 | def get_sid_and_pid(request): 31 | pid, sid = None, None 32 | if request is not None: 33 | request_bytes_length = len(request) 34 | if request_bytes_length >= 1: 35 | sid = request[0] 36 | if request_bytes_length >= 2: 37 | pid = request[1] 38 | return pid, sid 39 | 40 | 41 | def log_request(request): 42 | logger.info("Receiving on OBD address " + hex(OBD_BROADCAST_ADDRESS) + " from " + hex(OBD_TARGET_ADDRESS) 43 | + " Request: 0x" + request.hex()) 44 | 45 | 46 | def log_response(response): 47 | logger.info("Sending to " + hex(OBD_TARGET_ADDRESS) + " Response: 0x" + response.hex()) 48 | -------------------------------------------------------------------------------- /obd/responses.py: -------------------------------------------------------------------------------- 1 | import random 2 | import ecu_config 3 | import dtc_utils 4 | 5 | 6 | DEFAULT_ECU_NAME = "ECU_SIMULATOR" 7 | 8 | DEFAULT_VIN = "TESTVIN0123456789" 9 | 10 | DEFAULT_FUEL_LEVEL = 60 11 | 12 | DEFAULT_FUEL_TYPE = 1 # Gasoline 13 | 14 | MAX_NUMBER_OF_CHARS_ECU_NAME = 20 15 | 16 | MAX_NUMBER_OF_CHARS_VIN = 17 17 | 18 | MAX_NUMBER_OF_FUEL_TYPES = 23 19 | 20 | FUEL_LEVEL_MAX = 100 21 | 22 | VEHICLE_SPEED_MAX = 255 23 | 24 | VEHICLE_SPEED_ACCELERATION = 1 25 | 26 | ENGINE_TEMP_MIN = 130 # 90 C - 40 27 | 28 | ENGINE_TEMP_MAX = 150 # 110 C - 40 29 | 30 | MAX_NUMBER_OF_DTCS_IN_RESPONSE = 255 31 | 32 | BIG_ENDIAN = "big" 33 | 34 | vehicle_speed = 0 35 | 36 | 37 | def get_vehicle_speed(): 38 | global vehicle_speed 39 | current_speed = vehicle_speed.to_bytes(1, BIG_ENDIAN) 40 | increment_vehicle_speed() 41 | return current_speed 42 | 43 | 44 | def increment_vehicle_speed(): 45 | global vehicle_speed 46 | vehicle_speed = (vehicle_speed + VEHICLE_SPEED_ACCELERATION) % (VEHICLE_SPEED_MAX + 1) 47 | 48 | 49 | def get_engine_temperature(): 50 | return random.randrange(ENGINE_TEMP_MIN, ENGINE_TEMP_MAX).to_bytes(1, BIG_ENDIAN) 51 | 52 | 53 | def get_fuel_level(): 54 | # the OBD device calculates the fuel level: (100/255) * fuel level 55 | # therefore, fuel level is multiplied by (255/100) 56 | fuel_level = validate_fuel_level(ecu_config.get_fuel_level()) 57 | return int(fuel_level * (255 / 100)).to_bytes(1, BIG_ENDIAN) 58 | 59 | 60 | def validate_fuel_level(fuel_level): 61 | if isinstance(fuel_level, int) and fuel_level <= FUEL_LEVEL_MAX: 62 | return fuel_level 63 | return DEFAULT_FUEL_LEVEL 64 | 65 | 66 | def get_fuel_type(): 67 | fuel_type = validate_fuel_type(ecu_config.get_fuel_type()) 68 | return fuel_type.to_bytes(1, BIG_ENDIAN) 69 | 70 | 71 | def validate_fuel_type(fuel_type): 72 | if MAX_NUMBER_OF_FUEL_TYPES >= fuel_type > 0: 73 | return fuel_type 74 | return DEFAULT_FUEL_TYPE 75 | 76 | 77 | def get_vin(): 78 | vin = ecu_config.get_vin() 79 | if len(vin) > MAX_NUMBER_OF_CHARS_VIN: 80 | return add_vin_padding(DEFAULT_VIN) 81 | return add_vin_padding(vin) 82 | 83 | 84 | def add_vin_padding(vin): 85 | vin_bytes = vin.encode() 86 | if len(vin_bytes) < MAX_NUMBER_OF_CHARS_VIN: 87 | vin_bytes = bytes(MAX_NUMBER_OF_CHARS_VIN - len(vin_bytes)) + vin_bytes 88 | return bytes(1) + vin_bytes 89 | 90 | 91 | def get_ecu_name(): 92 | ecu_name = ecu_config.get_ecu_name() 93 | if len(ecu_name) > MAX_NUMBER_OF_CHARS_ECU_NAME: 94 | return add_ecu_name_padding(DEFAULT_ECU_NAME) 95 | return add_ecu_name_padding(ecu_name) 96 | 97 | 98 | def add_ecu_name_padding(ecu_name): 99 | ecu_name_bytes = ecu_name.encode() 100 | if len(ecu_name_bytes) < MAX_NUMBER_OF_CHARS_ECU_NAME: 101 | ecu_name_bytes = bytes(MAX_NUMBER_OF_CHARS_ECU_NAME - len(ecu_name_bytes)) + ecu_name_bytes 102 | return ecu_name_bytes 103 | 104 | 105 | def get_dtcs(): 106 | dtcs = ecu_config.get_dtcs() 107 | dtcs_bytes = dtc_utils.encode_obd_dtcs(dtcs) 108 | return add_number_of_dtcs_to_response(dtcs_bytes) 109 | 110 | 111 | def add_number_of_dtcs_to_response(dtcs_bytes): 112 | number_of_dtcs = len(dtcs_bytes) / 2 113 | if MAX_NUMBER_OF_DTCS_IN_RESPONSE >= number_of_dtcs > 0: 114 | return int(number_of_dtcs).to_bytes(1, BIG_ENDIAN) + dtcs_bytes 115 | return bytes(1) -------------------------------------------------------------------------------- /obd/services.py: -------------------------------------------------------------------------------- 1 | from obd import responses 2 | from loggers.logger_app import logger 3 | 4 | SUPPORTED_PIDS_RESPONSE_MASK = 0x80000000 5 | 6 | SUPPORTED_PIDS_RESPONSE_INIT_VALUE = 0x00000001 7 | 8 | SUPPORTED_PIDS_RESPONSE_NUMBER_OF_PIDs = 32 9 | 10 | POSITIVE_RESPONSE_MASK = 0x40 11 | 12 | BIG_ENDIAN = "big" 13 | 14 | FUEL_TYPE = responses.get_fuel_type() 15 | 16 | DTCs = responses.get_dtcs() 17 | 18 | VIN = responses.get_vin() 19 | 20 | ECU_NAME = responses.get_ecu_name() 21 | 22 | 23 | SERVICES = [ 24 | {"id": 0x01, "description": "Show current data", "response": lambda: None, 25 | "pids": [ 26 | {"id": 0x05, "description": "Engine coolant temperature", "response": lambda: responses.get_engine_temperature()}, 27 | {"id": 0x0D, "description": "Vehicle speed", "response": lambda: responses.get_vehicle_speed()}, 28 | {"id": 0x2F, "description": "Fuel tank level input", "response": lambda: responses.get_fuel_level()}, 29 | {"id": 0x51, "description": "Fuel type", "response": lambda: FUEL_TYPE} 30 | ]}, 31 | {"id": 0x03, "description": "Show DTCs", "response": lambda: DTCs}, 32 | {"id": 0x09, "description": "Request vehicle information", "response": lambda: None, 33 | "pids": [ 34 | {"id": 0x02, "description": "Vehicle Identification Number(VIN)", "response": lambda: VIN}, 35 | {"id": 0x0A, "description": "ECU name", "response": lambda: ECU_NAME} 36 | ]} 37 | ] 38 | 39 | 40 | def process_service_request(requested_sid, requested_pid): 41 | if is_service_request_valid(requested_sid, requested_pid): 42 | service_response, service_pids = get_service(requested_sid) 43 | if service_pids is not None and requested_pid is not None: 44 | if is_supported_pids_request(requested_pid): 45 | response = get_supported_pids_response(service_pids, requested_pid) 46 | return add_response_prefix(requested_sid, requested_pid, response) 47 | return add_response_prefix(requested_sid, requested_pid, get_pid_response(requested_pid, service_pids)) 48 | return add_response_prefix(requested_sid, requested_pid, service_response) 49 | else: 50 | logger.warning("Invalid request") 51 | return None 52 | 53 | 54 | def add_response_prefix(requested_sid, requested_pid, response): 55 | if response is not None: 56 | response_sid = bytes([POSITIVE_RESPONSE_MASK + requested_sid]) 57 | if requested_pid is None: 58 | return response_sid + response 59 | return response_sid + bytes([requested_pid]) + response 60 | return None 61 | 62 | 63 | def is_service_request_valid(requested_sid, requested_pid): 64 | is_sid_valid_ = is_sid_valid(requested_sid) 65 | return is_sid_valid_ and is_pid_valid(requested_pid) or (is_sid_valid_ and requested_pid is None) 66 | 67 | 68 | def is_sid_valid(sid): 69 | return isinstance(sid, int) and 10 >= sid >= 1 70 | 71 | 72 | def is_pid_valid(pid): 73 | return isinstance(pid, int) and 255 >= pid >= 0 74 | 75 | 76 | def get_service(requested_sid): 77 | for service in SERVICES: 78 | if service.get("id") == requested_sid: 79 | logger.info("Requested OBD SID " + hex(requested_sid) + ": " + service.get("description")) 80 | return service.get("response")(), service.get("pids") 81 | logger.warning("Requested SID " + hex(requested_sid) + " not supported") 82 | return None, None 83 | 84 | 85 | def get_pid_response(requested_pid, pids): 86 | for pid in pids: 87 | if pid.get("id") == requested_pid: 88 | logger.info("Requested PID " + hex(requested_pid) + ": " + pid.get("description")) 89 | return pid.get("response")() 90 | logger.warning("Requested PID " + hex(requested_pid) + " not supported") 91 | return None 92 | 93 | 94 | def is_supported_pids_request(requested_pid): 95 | return requested_pid % SUPPORTED_PIDS_RESPONSE_NUMBER_OF_PIDs == 0 96 | 97 | 98 | def get_supported_pids_response(supported_pids, requested_pid): 99 | supported_pids_response = init_supported_pids_response(requested_pid) 100 | for pid in supported_pids: 101 | supported_pid = pid.get("id") 102 | if requested_pid < supported_pid < (requested_pid + SUPPORTED_PIDS_RESPONSE_NUMBER_OF_PIDs): 103 | supported_pids_response |= SUPPORTED_PIDS_RESPONSE_MASK >> (supported_pid - requested_pid - 1) 104 | return supported_pids_response.to_bytes(4, BIG_ENDIAN) 105 | 106 | 107 | def init_supported_pids_response(requested_pid): 108 | if requested_pid / SUPPORTED_PIDS_RESPONSE_NUMBER_OF_PIDs > 6: 109 | return SUPPORTED_PIDS_RESPONSE_INIT_VALUE >> 1 110 | return SUPPORTED_PIDS_RESPONSE_INIT_VALUE 111 | -------------------------------------------------------------------------------- /setup_can.sh: -------------------------------------------------------------------------------- 1 | modprobe can 2 | dmesg | grep -i can 3 | /sbin/ip link set $1 up type can bitrate $2 4 | ifconfig | grep $1 5 | insmod $3 6 | dmesg | grep -i isotp 7 | -------------------------------------------------------------------------------- /setup_vcan.sh: -------------------------------------------------------------------------------- 1 | modprobe vcan 2 | dmesg | grep -i vcan 3 | ip link add dev $1 type vcan 4 | ip link set up $1 5 | ifconfig | grep $1 6 | insmod $2 7 | dmesg | grep -i isotp -------------------------------------------------------------------------------- /tests/test_obd/test_responses.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from obd import responses 3 | 4 | BIG_ENDIAN = "big" 5 | 6 | ENGINE_TEMP_MIN = 130 7 | 8 | ENGINE_TEMP_MAX = 150 9 | 10 | 11 | class TestServiceResponses(unittest.TestCase): 12 | 13 | def test_get_vehicle_speed_is_one_byte(self): 14 | self.assertEqual(1, len(responses.get_vehicle_speed())) 15 | 16 | def test_get_vehicle_speed_is_in_range_0_255(self): 17 | initial_vehicle_speed = 0 18 | self.assertEqual(initial_vehicle_speed.to_bytes(1, BIG_ENDIAN), responses.get_vehicle_speed()) 19 | self.assertEqual((initial_vehicle_speed + 1).to_bytes(1, BIG_ENDIAN), responses.get_vehicle_speed()) 20 | for i in range(2, 256, 1): 21 | self.assertEqual(i.to_bytes(1, BIG_ENDIAN), responses.get_vehicle_speed()) 22 | 23 | self.assertEqual(initial_vehicle_speed.to_bytes(1, BIG_ENDIAN), responses.get_vehicle_speed()) 24 | self.assertEqual((initial_vehicle_speed + 1).to_bytes(1, BIG_ENDIAN), responses.get_vehicle_speed()) 25 | for i in range(2, 256, 1): 26 | self.assertEqual(i.to_bytes(1, BIG_ENDIAN), responses.get_vehicle_speed()) 27 | 28 | def test_get_fuel_level_is_one_byte(self): 29 | self.assertEqual(1, len(responses.get_fuel_level())) 30 | 31 | def test_get_engine_temperature_is_one_byte(self): 32 | self.assertEqual(1, len(responses.get_engine_temperature())) 33 | 34 | def test_get_engine_temperature(self): 35 | self.assertAlmostEqual(ENGINE_TEMP_MIN, int(responses.get_engine_temperature().hex(), 16), delta=( 36 | ENGINE_TEMP_MAX - ENGINE_TEMP_MIN)) 37 | self.assertAlmostEqual(ENGINE_TEMP_MAX, int(responses.get_engine_temperature().hex(), 16), delta=( 38 | ENGINE_TEMP_MAX - ENGINE_TEMP_MIN)) 39 | 40 | def test_get_vin_has_18_bytes(self): 41 | self.assertEqual(18, len(responses.get_vin())) 42 | 43 | def test_get_vin_first_byte_is_0(self): 44 | self.assertEqual(0, responses.get_vin()[0]) 45 | 46 | def test_get_vin_has_20_bytes(self): 47 | self.assertEqual(20, len(responses.get_ecu_name())) 48 | 49 | def test_get_fuel_level_has_1_byte(self): 50 | self.assertEqual(1, len(responses.get_fuel_level())) 51 | 52 | def test_get_fuel_level_is_smaller_equal_than_100(self): 53 | fuel_level = int(responses.get_fuel_level().hex(), 16) * (100 / 255) 54 | self.assertTrue(100 >= fuel_level > 0) 55 | 56 | def test_get_fuel_type_has_1_byte(self): 57 | self.assertEqual(1, len(responses.get_fuel_type())) 58 | 59 | def test_get_fuel_type_is_smaller_equal_than_23(self): 60 | # see https://en.wikipedia.org/wiki/OBD-II_PIDs#Fuel_Type_Coding 61 | self.assertTrue(23 >= int(responses.get_fuel_type().hex(), 16) > 0) 62 | 63 | 64 | if __name__ == '__main__': 65 | unittest.main() 66 | -------------------------------------------------------------------------------- /tests/test_obd/test_services.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from obd import services 3 | 4 | INVALID_SID = 0xB 5 | 6 | INVALID_PID = 256 7 | 8 | STRING_SID = "yy" 9 | 10 | STRING_PID = "xx" 11 | 12 | UNSUPPORTED_PID = 0xFF 13 | 14 | UNSUPPORTED_SID = 0x08 15 | 16 | 17 | class TestServices(unittest.TestCase): 18 | 19 | def test_process_service_0x01_pid_0x00(self): 20 | response = services.process_service_request(requested_sid=0x01, requested_pid=0x00) 21 | self.assertIsNotNone(response) 22 | self.assertEqual("410008080001", response.hex()) 23 | 24 | def test_process_service_0x01_pid_0x20(self): 25 | response = services.process_service_request(requested_sid=0x01, requested_pid=0x20) 26 | self.assertIsNotNone(response) 27 | self.assertEqual("412000020001", response.hex()) 28 | 29 | def test_process_service_0x01_pid_0x40(self): 30 | response = services.process_service_request(requested_sid=0x01, requested_pid=0x40) 31 | self.assertIsNotNone(response) 32 | self.assertEqual("414000008001", response.hex()) 33 | 34 | def test_process_service_0x01_pid_0xE0_returns_no_pids(self): 35 | response = services.process_service_request(requested_sid=0x01, requested_pid=0xE0) 36 | self.assertIsNotNone(response) 37 | self.assertEqual("41e000000000", response.hex()) 38 | 39 | def test_process_service_0x09_pid_0x00(self): 40 | response = services.process_service_request(requested_sid=0x09, requested_pid=0x00) 41 | self.assertIsNotNone(response) 42 | self.assertEqual("490040400001", response.hex()) 43 | 44 | def test_process_service_0x03_returns_DTCs(self): 45 | response = services.process_service_request(requested_sid=0x03, requested_pid=None) 46 | self.assertIsNotNone(response) 47 | 48 | def test_process_unsupported_service_returns_none(self): 49 | self.assertIsNone(services.process_service_request(requested_sid=UNSUPPORTED_SID, requested_pid=0x00)) 50 | self.assertIsNone(services.process_service_request(requested_sid=UNSUPPORTED_SID, requested_pid=None)) 51 | 52 | def test_process_service_unknown_pid_returns_none(self): 53 | self.assertIsNone(services.process_service_request(requested_sid=0x01, requested_pid=UNSUPPORTED_PID)) 54 | 55 | def test_process_invalid_service_request_returns_none(self): 56 | self.assertIsNone(services.process_service_request(requested_sid=INVALID_SID, requested_pid=0x00)) 57 | self.assertIsNone(services.process_service_request(requested_sid=0x01, requested_pid=INVALID_PID)) 58 | self.assertIsNone(services.process_service_request(requested_sid=INVALID_SID, requested_pid=INVALID_PID)) 59 | self.assertIsNone(services.process_service_request(requested_sid=STRING_SID, requested_pid=0x00)) 60 | self.assertIsNone(services.process_service_request(requested_sid=0x01, requested_pid=STRING_PID)) 61 | self.assertIsNone(services.process_service_request(requested_sid=STRING_SID, requested_pid=STRING_PID)) 62 | 63 | 64 | if __name__ == '__main__': 65 | unittest.main() 66 | -------------------------------------------------------------------------------- /tests/test_uds/test_services.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from uds import services 3 | import ecu_config as ecu_config 4 | import dtc_utils 5 | 6 | 7 | DIAGNOSTIC_SESSION_CONTROL_SID = 0x10 8 | 9 | DIAGNOSTIC_SESSION_TYPES = [0x01, 0x02, 0x03, 0x04] 10 | 11 | DIAGNOSTIC_SESSION_INVALID_TYPE = 0x05 12 | 13 | DIAGNOSTIC_SESSION_PARAMETER_RECORD = [0x00, 0x1E, 0x0B, 0xB8] 14 | 15 | ECU_RESET_SID = 0x11 16 | 17 | ECU_RESET_HARD = 0x01 18 | 19 | ECU_RESET_ENABLE_RAPID_POWER_DOWN = 0x04 20 | 21 | ECU_RESET_POWER_DOWN_TIME = 0x0f 22 | 23 | READ_DTC_INFO_SID = 0x19 24 | 25 | READ_DTC_INFO_BY_STATUS_MASK = 0x02 26 | 27 | READ_DTC_STATUS_AVAILABILITY_MASK = 0xFF 28 | 29 | POSITIVE_RESPONSE_MASK = 0x40 30 | 31 | NEGATIVE_RESPONSE_ID = 0x7F 32 | 33 | NRC_SUB_FUNCTION_NOT_SUPPORTED = 0x12 34 | 35 | NRC_INCORRECT_MESSAGE_LENGTH_OR_INVALID_FORMAT = 0x13 36 | 37 | 38 | def get_response_sid(sid): 39 | return bytes([POSITIVE_RESPONSE_MASK + sid]) 40 | 41 | 42 | class TestUdsServices(unittest.TestCase): 43 | 44 | def test_process_service_0x10(self): 45 | request = bytes([DIAGNOSTIC_SESSION_CONTROL_SID]) + bytes([DIAGNOSTIC_SESSION_TYPES[0]]) 46 | response = services.process_service_request(request) 47 | expected_response = get_response_sid(DIAGNOSTIC_SESSION_CONTROL_SID) + bytes( 48 | [DIAGNOSTIC_SESSION_TYPES[0]]) + bytes(DIAGNOSTIC_SESSION_PARAMETER_RECORD) 49 | self.assertIsNotNone(response) 50 | self.assertEqual(6, len(response)) 51 | self.assertEqual(expected_response.hex(), response.hex()) 52 | 53 | def test_process_service_0x10_with_unsupported_session_type_returns_negative_response(self): 54 | request = bytes([DIAGNOSTIC_SESSION_CONTROL_SID]) + bytes([DIAGNOSTIC_SESSION_INVALID_TYPE]) 55 | response = services.process_service_request(request) 56 | expected_response = bytes([NEGATIVE_RESPONSE_ID]) + bytes([DIAGNOSTIC_SESSION_CONTROL_SID]) + bytes( 57 | [NRC_SUB_FUNCTION_NOT_SUPPORTED]) 58 | self.assertIsNotNone(response) 59 | self.assertEqual(3, len(response)) 60 | self.assertEqual(expected_response.hex(), response.hex()) 61 | 62 | def test_process_service_0x10_with_invalid_message_length_returns_negative_response(self): 63 | request = bytes([DIAGNOSTIC_SESSION_CONTROL_SID]) 64 | response = services.process_service_request(request) 65 | expected_response = bytes([NEGATIVE_RESPONSE_ID]) + bytes([DIAGNOSTIC_SESSION_CONTROL_SID]) + bytes( 66 | [NRC_INCORRECT_MESSAGE_LENGTH_OR_INVALID_FORMAT]) 67 | self.assertIsNotNone(response) 68 | self.assertEqual(3, len(response)) 69 | self.assertEqual(expected_response.hex(), response.hex()) 70 | 71 | def test_process_service_0x11_with_hard_reset(self): 72 | request = bytes([ECU_RESET_SID]) + bytes([ECU_RESET_HARD]) 73 | response = services.process_service_request(request) 74 | expected_response = get_response_sid(ECU_RESET_SID) + bytes([ECU_RESET_HARD]) 75 | self.assertIsNotNone(response) 76 | self.assertEqual(2, len(response)) 77 | self.assertEqual(expected_response.hex(), response.hex()) 78 | 79 | def test_process_service_0x11_with_enable_power_shut_down(self): 80 | request = bytes([ECU_RESET_SID]) + bytes([ECU_RESET_ENABLE_RAPID_POWER_DOWN]) 81 | response = services.process_service_request(request) 82 | expected_response = get_response_sid(ECU_RESET_SID) + bytes([ECU_RESET_ENABLE_RAPID_POWER_DOWN]) + bytes( 83 | [ECU_RESET_POWER_DOWN_TIME]) 84 | self.assertIsNotNone(response) 85 | self.assertEqual(3, len(response)) 86 | self.assertEqual(expected_response.hex(), response.hex()) 87 | 88 | def test_process_service_0x11_with_unsupported_reset_type_returns_negative_response(self): 89 | request = bytes([ECU_RESET_SID]) + bytes([0x06]) 90 | response = services.process_service_request(request) 91 | expected_response = bytes([NEGATIVE_RESPONSE_ID]) + bytes([ECU_RESET_SID]) + bytes( 92 | [NRC_SUB_FUNCTION_NOT_SUPPORTED]) 93 | self.assertIsNotNone(response) 94 | self.assertEqual(3, len(response)) 95 | self.assertEqual(expected_response.hex(), response.hex()) 96 | 97 | def test_process_service_0x11_with_invalid_message_length_returns_negative_response(self): 98 | request = bytes([ECU_RESET_SID]) 99 | response = services.process_service_request(request) 100 | expected_response = bytes([NEGATIVE_RESPONSE_ID]) + bytes([ECU_RESET_SID]) + bytes( 101 | [NRC_INCORRECT_MESSAGE_LENGTH_OR_INVALID_FORMAT]) 102 | self.assertIsNotNone(response) 103 | self.assertEqual(3, len(response)) 104 | self.assertEqual(expected_response.hex(), response.hex()) 105 | 106 | def test_process_service_0x19(self): 107 | request = bytes([READ_DTC_INFO_SID]) + bytes([READ_DTC_INFO_BY_STATUS_MASK]) 108 | response = services.process_service_request(request) 109 | dtcs = dtc_utils.encode_uds_dtcs(ecu_config.get_dtcs()) 110 | expected_response = get_response_sid(READ_DTC_INFO_SID) + bytes([READ_DTC_INFO_BY_STATUS_MASK]) + bytes( 111 | [READ_DTC_STATUS_AVAILABILITY_MASK]) + dtcs 112 | self.assertIsNotNone(response) 113 | self.assertEqual(3 + len(dtcs), len(response)) 114 | self.assertEqual(expected_response.hex(), response.hex()) 115 | 116 | def test_process_service_0x19_with_unsupported_sub_function_returns_negative_response(self): 117 | request = bytes([READ_DTC_INFO_SID]) + bytes([0x03]) 118 | response = services.process_service_request(request) 119 | expected_response = bytes([NEGATIVE_RESPONSE_ID]) + bytes([READ_DTC_INFO_SID]) + bytes( 120 | [NRC_SUB_FUNCTION_NOT_SUPPORTED]) 121 | self.assertIsNotNone(response) 122 | self.assertEqual(3, len(response)) 123 | self.assertEqual(expected_response.hex(), response.hex()) 124 | 125 | def test_process_service_0x19_with_invalid_message_length_returns_negative_response(self): 126 | expected_response = bytes([NEGATIVE_RESPONSE_ID]) + bytes([READ_DTC_INFO_SID]) + bytes( 127 | [NRC_INCORRECT_MESSAGE_LENGTH_OR_INVALID_FORMAT]) 128 | 129 | request = bytes([READ_DTC_INFO_SID]) 130 | response = services.process_service_request(request) 131 | self.assertIsNotNone(response) 132 | self.assertEqual(3, len(response)) 133 | self.assertEqual(expected_response.hex(), response.hex()) 134 | 135 | request = bytes([READ_DTC_INFO_SID]) + bytes([READ_DTC_INFO_BY_STATUS_MASK]) + bytes([0x01]) 136 | response = services.process_service_request(request) 137 | self.assertIsNotNone(response) 138 | self.assertEqual(3, len(response)) 139 | self.assertEqual(expected_response.hex(), response.hex()) 140 | 141 | 142 | if __name__ == '__main__': 143 | unittest.main() 144 | -------------------------------------------------------------------------------- /uds/listener.py: -------------------------------------------------------------------------------- 1 | import isotp 2 | import ecu_config 3 | from uds import services 4 | from addresses import UDS_ECU_ADDRESS, UDS_TARGET_ADDRESS 5 | from loggers.logger_app import logger 6 | 7 | CAN_INTERFACE = ecu_config.get_can_interface() 8 | 9 | 10 | def start(): 11 | isotp_socket = create_isotp_socket(UDS_ECU_ADDRESS, UDS_TARGET_ADDRESS) 12 | while True: 13 | request = isotp_socket.recv() 14 | if request is not None: 15 | log_request(request) 16 | if len(request) >= 1: 17 | response = services.process_service_request(request) 18 | if response is not None: 19 | log_response(response) 20 | isotp_socket.send(response) 21 | 22 | 23 | def create_isotp_socket(receiver_address, target_address): 24 | socket = isotp.socket() 25 | socket.bind(CAN_INTERFACE, isotp.Address(rxid=receiver_address, txid=target_address)) 26 | return socket 27 | 28 | 29 | def log_request(request): 30 | logger.info("Receiving on UDS address " + hex(UDS_ECU_ADDRESS) + " from " + hex(UDS_TARGET_ADDRESS) 31 | + " Request: 0x" + request.hex()) 32 | 33 | 34 | def log_response(response): 35 | logger.info("Sending to " + hex(UDS_ECU_ADDRESS) + " Response: 0x" + response.hex()) -------------------------------------------------------------------------------- /uds/services.py: -------------------------------------------------------------------------------- 1 | import dtc_utils 2 | import ecu_config as ecu_config 3 | from loggers.logger_app import logger 4 | 5 | DIAGNOSTIC_SESSION_CONTROL_SID = 0x10 6 | 7 | DIAGNOSTIC_SESSION_TYPES = [0x01, 0x02, 0x03, 0x04] 8 | 9 | DIAGNOSTIC_SESSION_PARAMETER_RECORD = [0x00, 0x1E, 0x0B, 0xB8] 10 | 11 | ECU_RESET_SID = 0x11 12 | 13 | ECU_RESET_ENABLE_RAPID_POWER_SHUT_DOWN = 0x04 14 | 15 | ECU_RESET_POWER_DOWN_TIME = 0x0F 16 | 17 | READ_DTC_INFO_BY_STATUS_MASK = 0x2 18 | 19 | READ_DTC_INFO_SID = 0x19 20 | 21 | READ_DTC_STATUS_AVAILABILITY_MASK = 0xFF 22 | 23 | DTCS = dtc_utils.encode_uds_dtcs(ecu_config.get_dtcs()) 24 | 25 | POSITIVE_RESPONSE_SID_MASK = 0x40 26 | 27 | NEGATIVE_RESPONSE_SID = 0x7F 28 | 29 | NRC_SUB_FUNCTION_NOT_SUPPORTED = 0x12 30 | 31 | NRC_INCORRECT_MESSAGE_LENGTH_OR_INVALID_FORMAT = 0x13 32 | 33 | 34 | SERVICES = [ 35 | {"id": ECU_RESET_SID, "description": "ECUReset", "response": lambda request: get_0x11_response(request)}, 36 | {"id": READ_DTC_INFO_SID, "description": "ReadDTCInformation", "response": lambda request: get_0x19_response(request)}, 37 | {"id": DIAGNOSTIC_SESSION_CONTROL_SID, "description": "DiagnosticSessionControl", "response": lambda request: get_0x10_response(request)} 38 | ] 39 | 40 | 41 | def process_service_request(request): 42 | if request is not None and len(request) >= 1: 43 | sid = request[0] 44 | for service in SERVICES: 45 | if service.get("id") == sid: 46 | logger.info("Requested UDS SID " + hex(sid) + ": " + service.get("description")) 47 | return service.get("response")(request) 48 | logger.warning("Requested SID " + hex(sid) + " not supported") 49 | else: 50 | logger.warning("Invalid request") 51 | return None 52 | 53 | 54 | def get_0x10_response(request): 55 | if len(request) == 2: 56 | session_type = request[1] 57 | if session_type in DIAGNOSTIC_SESSION_TYPES: 58 | return get_positive_response_sid(DIAGNOSTIC_SESSION_CONTROL_SID) + bytes([session_type]) \ 59 | + bytes(DIAGNOSTIC_SESSION_PARAMETER_RECORD) 60 | return get_negative_response(DIAGNOSTIC_SESSION_CONTROL_SID, NRC_SUB_FUNCTION_NOT_SUPPORTED) 61 | return get_negative_response(DIAGNOSTIC_SESSION_CONTROL_SID, NRC_INCORRECT_MESSAGE_LENGTH_OR_INVALID_FORMAT) 62 | 63 | 64 | def get_0x11_response(request): 65 | if len(request) == 2: 66 | reset_type = request[1] 67 | if is_reset_type_supported(reset_type): 68 | positive_response = get_positive_response_sid(ECU_RESET_SID) + bytes([reset_type]) 69 | if reset_type == ECU_RESET_ENABLE_RAPID_POWER_SHUT_DOWN: 70 | return positive_response + bytes([ECU_RESET_POWER_DOWN_TIME]) 71 | return positive_response 72 | return get_negative_response(ECU_RESET_SID, NRC_SUB_FUNCTION_NOT_SUPPORTED) 73 | return get_negative_response(ECU_RESET_SID, NRC_INCORRECT_MESSAGE_LENGTH_OR_INVALID_FORMAT) 74 | 75 | 76 | def get_0x19_response(request): 77 | if len(request) == 2: 78 | report_type = request[1] 79 | if report_type == READ_DTC_INFO_BY_STATUS_MASK: 80 | positive_response = get_positive_response_sid(READ_DTC_INFO_SID) + bytes([report_type]) \ 81 | + bytes([READ_DTC_STATUS_AVAILABILITY_MASK]) 82 | return add_dtcs_to_response(positive_response) 83 | return get_negative_response(READ_DTC_INFO_SID, NRC_SUB_FUNCTION_NOT_SUPPORTED) 84 | return get_negative_response(READ_DTC_INFO_SID, NRC_INCORRECT_MESSAGE_LENGTH_OR_INVALID_FORMAT) 85 | 86 | 87 | def is_reset_type_supported(reset_type): 88 | return 0x05 >= reset_type >= 0x01 89 | 90 | 91 | def add_dtcs_to_response(response): 92 | if len(DTCS) > 0: 93 | return response + DTCS 94 | return response 95 | 96 | 97 | def get_positive_response_sid(requested_sid): 98 | return bytes([requested_sid + POSITIVE_RESPONSE_SID_MASK]) 99 | 100 | 101 | def get_negative_response(sid, nrc): 102 | logger.warning("Negative response for SID " + hex(sid) + " will be sent") 103 | return bytes([NEGATIVE_RESPONSE_SID]) + bytes([sid]) + bytes([nrc]) 104 | 105 | 106 | --------------------------------------------------------------------------------