├── .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 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 | ### UDS
120 |
121 | To test the UDS services, the [Caring Caribou](https://github.com/CaringCaribou/caringcaribou) tool was used.
122 |
123 |
124 |
125 |
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 |
--------------------------------------------------------------------------------