Enabling Android Auto in my Renault Zoe using an ESP32

Why Buy an OBDII Dongle When You Have an ESP32 and a Weekend?

Most people with a Renault Zoe and a copy of DDT4ALL do the sensible thing: they go on Amazon, spend $15 on a Konnwei or an OBDLink dongle, plug it in, and get to work.

Not today. Challenge accepted! I thought.

Buying a dongle would have been too easy. I had an M5Stack Atom Lite and a CAN Bus Unit, a weekend to spare, and a healthy disregard for the easy path. I knew next to nothing about ELM327 at the time — and honestly, still don't — but here I am, owning an Android Auto-enabled Zoe.

The "Because It's There" Challenge

The mission was to build a bridge between my laptop and the car's internal network. To do that, I had to make my ESP32 act exactly like an ELM327 — an aging but stubborn serial command protocol that hasn't changed much in twenty years.

To make it work, I dove into the source code of DDT4ALL and used Gemini and Claude to help me translate the Python logic into C++ for the ESP32, effectively using AI as a high-speed tutor to bridge my C++ knowledge gaps.

The Hardware Stack

  • M5Stack Atom Lite — a postage-stamp-sized ESP32 development board.
  • M5Stack CAN Bus Unit — built around an SN65HVD230 CAN transceiver, connecting to the Atom Lite via a GROVE connector.

Wiring it up

The GROVE connector carries four signals: GND, VCC, and two GPIO lines. On the Atom Lite, those data pins are GPIO 12 and GPIO 13, which I configured as CAN-H and CAN-L respectively. GND is on pin 4 (or 5, depending on connector orientation). The CAN Bus Unit's transceiver converts the ESP32's logic-level TX/RX into the differential voltage levels the car expects.

Those CAN-H and CAN-L lines connect to pins 12 and 13 of the car's OBD2 port — Renault's multimedia CAN bus.

A note on the Zoe's two CAN buses

The Zoe exposes two CAN buses at the OBD2 port, and picking the right one matters:

  • Multimedia CAN bus — CAN-H on OBD2 pin 12, CAN-L on pin 13, running at 500 kbps. This is the bus the R-Link infotainment system lives on, and therefore the one DDT4ALL needs to reach in order to toggle the Android Auto setting.
  • Powertrain / motor diagnostic bus — on the standard OBD2 pins 6 and 14. This is where most generic ELM327 dongles connect by default, which is why they won't see the infotainment ECU without modification.

Setting the ESP32's CAN peripheral to 500 kbps and wiring to OBD2 pins 12 and 13 puts you on the multimedia bus — exactly where the R-Link ECU lives.

The Moment of Truth

After hours of staring at Bad ELM response and WRONG RESPONSE: Unknown(), the screen finally turned. An ECU response appeared — a real one, from the car.

By precisely emulating the quirks of the ELM327 protocol, I was able to bypass the need for dedicated hardware entirely. I successfully accessed the Zoe's ECUs through DDT4ALL and toggled the setting in the infotainment module to enable Android Auto.

Was it worth it?

I spent a weekend doing something I could have solved with $15 and two clicks on Amazon. But I didn't just get a working diagnostic tool — I learned how automotive communication actually works under the hood: how CAN frames are structured, how the ELM327 protocol layers on top, and how a car's ECUs negotiate over a shared bus.

I'm still no expert in CAN bus or C++, but this project proved that with a bit of hardware and the right tools to bridge the knowledge gap, you can achieve quite something.

Just don't give up.

Source Code

The full firmware — an Arduino sketch ready to flash to an M5Stack Atom Lite — is available on Github or here:

// ============================================================
//  M5Stack Atom Lite – ELM327 Emulator for Renault Zoe R240
//  Target: R-Link infotainment on CAN2 (500 kbps)
//  Use with DDT4ALL on Windows @ 115200 baud
// ============================================================

#include <M5Atom.h>
#include "driver/twai.h"

#define CAN_TX_PIN  GPIO_NUM_26
#define CAN_RX_PIN  GPIO_NUM_32

// State variables matching ELM327 defaults
bool echo_on = true;
bool headers_on = false;
uint32_t current_sh = 0x744;
uint32_t current_rx = 0x74C;

void setup() {
    M5.begin(true, false, true);
    Serial.begin(115200);
    
    // Setup CAN for Renault Zoe (500k)
    twai_general_config_t g_cfg = TWAI_GENERAL_CONFIG_DEFAULT(CAN_TX_PIN, CAN_RX_PIN, TWAI_MODE_NORMAL);
    twai_timing_config_t t_cfg = TWAI_TIMING_CONFIG_500KBITS();
    twai_filter_config_t f_cfg = TWAI_FILTER_CONFIG_ACCEPT_ALL();
    twai_driver_install(&g_cfg, &t_cfg, &f_cfg);
    twai_start();

    M5.dis.fillpix(0x00FF00); // Green = Ready
    Serial.print("\r\nELM327 v1.5\r\n>"); 
}

void loop() {
    if (Serial.available()) {
        String cmd = Serial.readStringUntil('\r');
        cmd.trim();
        if (cmd.length() == 0) {
            Serial.print("\r>");
            return;
        }

        // 1. Handle ECHO (Must be exactly what was sent + \r)
        if (echo_on) {
            Serial.print(cmd + "\r");
        }

        String upCmd = cmd;
        upCmd.toUpperCase();
        upCmd.replace(" ", "");

        // 2. Handle AT Commands
        if (upCmd.startsWith("AT")) {
            if (upCmd == "ATZ" || upCmd == "ATWS") {
                echo_on = true; headers_on = false;
                current_sh = 0x744; current_rx = 0x74C;
                Serial.print("ELM327 v1.5\r");
            } 
            else if (upCmd == "ATI" || upCmd == "AT@1") Serial.print("ELM327 v1.5\r");
            else if (upCmd == "ATRV") Serial.print("12.6V\r");
            else if (upCmd == "ATDP") Serial.print("ISO 15765-4 (CAN 11/500)\r");
            else if (upCmd == "ATDPN") Serial.print("6\r");
            else if (upCmd.startsWith("ATE")) { echo_on = (upCmd.endsWith("1")); Serial.print("OK\r"); }
            else if (upCmd.startsWith("ATH")) { headers_on = (upCmd.endsWith("1")); Serial.print("OK\r"); }
            else if (upCmd.startsWith("ATSH")) {
                current_sh = strtoul(upCmd.substring(4).c_str(), NULL, 16);
                current_rx = current_sh + 8; // Auto-set RX
                Serial.print("OK\r");
            }
            else if (upCmd.startsWith("ATCRA")) {
                current_rx = strtoul(upCmd.substring(5).c_str(), NULL, 16);
                Serial.print("OK\r");
            }
            else {
                Serial.print("OK\r"); // Silence all other config AT commands
            }
        } 
        // 3. Handle HEX Commands (CAN Requests)
        else {
            uint8_t payload[8];
            int len = 0;
            for (int i = 0; i < upCmd.length() && len < 8; i += 2) {
                payload[len++] = (uint8_t)strtoul(upCmd.substring(i, i + 2).c_str(), NULL, 16);
            }

            if (len > 0) {
                // Send CAN Frame
                twai_message_t tx;
                tx.identifier = current_sh;
                tx.extd = 0;
                tx.data_length_code = 8;
                for (int i = 0; i < 8; i++) tx.data[i] = (i < len) ? payload[i] : 0xAA;
                twai_transmit(&tx, pdMS_TO_TICKS(50));

                // Listen for Response
                unsigned long start = millis();
                bool found = false;
                while (millis() - start < 500) {
                    twai_message_t rx;
                    if (twai_receive(&rx, pdMS_TO_TICKS(10)) == ESP_OK) {
                        if (rx.identifier == current_rx) {
                            M5.dis.fillpix(0xFFFF00); // Flash yellow
                            
                            String response = "";
                            if (headers_on) {
                                char hdr[5];
                                sprintf(hdr, "%03X", rx.identifier);
                                response += String(hdr);
                            }
                            for (int i = 0; i < rx.data_length_code; i++) {
                                char b[4];
                                sprintf(b, "%02X", rx.data[i]);
                                response += String(b);
                                if (i < rx.data_length_code - 1) response += " ";
                            }
                            Serial.print(response + "\r");
                            found = true;
                            break;
                        }
                    }
                }
                if (!found) Serial.print("NO DATA\r");
            }
        }

        // 4. Always end with the prompt
        Serial.print(">");
        M5.dis.fillpix(0x00FF00);
    }
}