You dont have javascript enabled! Please enable it!

Practical case: ESP32 TWAI/CAN↔MQTT Gateway (SN65HVD230)

Practical case: ESP32 TWAI/CAN↔MQTT Gateway (SN65HVD230) — hero

Objective and use case

What you’ll build: A robust TWAI/CAN-to-MQTT gateway using ESP32-WROOM-32, SN65HVD230, and SSD1306 OLED, enabling seamless communication between CAN networks and MQTT brokers.

Why it matters / Use cases

  • Integrate legacy CAN devices into modern IoT ecosystems, allowing for remote monitoring and control.
  • Utilize the SSD1306 OLED for real-time status updates, enhancing user experience and system observability.
  • Implement a reliable data pipeline for automotive applications, ensuring timely data transmission and reception.
  • Facilitate smart agriculture solutions by connecting CAN-based sensors to cloud platforms via MQTT.
  • Enable remote diagnostics and troubleshooting of CAN networks through MQTT messaging.

Expected outcome

  • Achieve a minimum of 95% message delivery success rate between CAN and MQTT.
  • Maintain latencies under 100ms for message transmission across the gateway.
  • Process up to 100 packets per second in high-load scenarios without data loss.
  • Display real-time CAN bus status on the SSD1306 OLED with less than 1 second update intervals.
  • Ensure system uptime of 99.9% in production environments through robust error handling and reconnection logic.

Audience: Advanced users; Level: Intermediate to Advanced

Architecture/flow: ESP32-WROOM-32 interfaces with SN65HVD230 for CAN communication, bridging data to MQTT topics for cloud integration.

ESP32 TWAI–CAN to MQTT Gateway (Advanced) on ESP32-WROOM-32 DevKitC + SN65HVD230 CAN Transceiver + SSD1306 OLED

This hands-on practical case builds a robust TWAI/CAN-to-MQTT gateway using the exact hardware combination: ESP32-WROOM-32 DevKitC + SN65HVD230 CAN Transceiver + SSD1306 OLED. The gateway bridges CAN frames to MQTT topics and vice versa, with live status on a 128×64 OLED and serial logs. We’ll use PlatformIO for reproducible builds and target advanced users who want a production-style implementation with queues, reconnection logic, and clean topic design.

The implementation prioritizes:
– Reliable TWAI (CAN) RX/TX via the ESP-IDF TWAI driver.
– MQTT publish/subscribe bridging with sane JSON payloads.
– Non-blocking architecture with FreeRTOS tasks and queues.
– On-device observability via SSD1306.
– Clear setup, precise commands, and repeatable validation with mosquitto tools.


Prerequisites

  • Host OS: Windows 10/11, macOS 12+, or Ubuntu 22.04+.
  • PlatformIO Core (CLI) 6.1+ and PlatformIO IDE (VS Code) recommended.
  • Python 3.10+ (installed automatically by PlatformIO on first run).
  • USB drivers for the ESP32-WROOM-32 DevKitC:
  • CP210x (Silicon Labs) or CH34x (WCH), depending on your DevKitC’s USB-UART bridge.
  • Windows: Install CP210x driver from Silicon Labs website or CH340 driver from WCH if needed.
  • macOS: Usually plug-and-play for CP210x; CH340 may require driver.
  • Linux: Uses kernel driver; ensure user is in dialout/uucp group (udev rules).
  • A working MQTT broker (e.g., Mosquitto 2.0+ on your LAN).
  • A CAN bus to connect to, or a second CAN node for test. For standalone local tests, we also include a NO_ACK self-test mode.

Materials (Exact Models)

  • ESP32-WROOM-32 DevKitC (original Espressif DevKitC; USB-C or micro-USB variant is fine)
  • SN65HVD230 CAN Transceiver module (3.3V; RS pin exposed)
  • SSD1306 OLED 0.96″ I2C (128×64, address 0x3C typical)
  • CAN bus wiring (twisted pair), with proper termination (120 Ω at each end)
  • Jumper wires (female-female)
  • USB cable for ESP32 (ensure data-capable)

Optional:
– Second CAN node (e.g., USB-CAN adapter) to inject/observe frames
– 120 Ω resistor if your transceiver board has no onboard termination or it’s disabled


Setup/Connection

Carefully wire the modules before powering up. The SN65HVD230 is 3.3V compatible—do not connect to 5V.

Pin Assignments

  • TWAI (ESP32) pins used:
  • TWAI_TX: GPIO 5
  • TWAI_RX: GPIO 4
  • I2C (ESP32) pins used for OLED:
  • SDA: GPIO 21
  • SCL: GPIO 22

  • SN65HVD230 typical pins (module names vary slightly): VCC, GND, TXD, RXD, RS, CANH, CANL.

  • SSD1306 I2C pins: VCC, GND, SDA, SCL (address typically 0x3C).

Connection Table

Module/Pin Connects To (ESP32-WROOM-32 DevKitC) Notes
SN65HVD230 VCC 3V3 Power 3.3V only
SN65HVD230 GND GND Common ground
SN65HVD230 TXD GPIO 5 TWAI_TX (ESP32 drives out to transceiver TXD)
SN65HVD230 RXD GPIO 4 TWAI_RX (ESP32 reads from transceiver RXD)
SN65HVD230 RS GND High-speed mode (no slope control)
SN65HVD230 CANH CAN High To bus H
SN65HVD230 CANL CAN Low To bus L
SSD1306 VCC 3V3 Power 3.3V
SSD1306 GND GND Common ground
SSD1306 SDA GPIO 21 I2C data
SSD1306 SCL GPIO 22 I2C clock

Additional CAN wiring notes:
– Use a twisted pair for CANH/CANL.
– Ensure 120 Ω termination at both ends of the bus (do not over-terminate).
– If testing with only the ESP32 node and a second CAN node, ensure the two ends are terminated.
– RS to GND puts SN65HVD230 in normal high-speed mode; you can also control RS via GPIO for standby/slope control, but we keep it grounded for simplicity.


Full Code

We use PlatformIO (Arduino framework) but call the ESP-IDF TWAI driver directly. MQTT is via PubSubClient; OLED via Adafruit SSD1306. We handle CAN RX via a FreeRTOS task and queue; CAN TX events come from MQTT and are queued to a TX task. OLED shows Wi-Fi/MQTT state and last CAN ID.

Create this project structure:
– platformio.ini
– src/main.cpp

platformio.ini

[env:esp32dev]
platform = espressif32@6.5.0
board = esp32dev
framework = arduino
monitor_speed = 115200
board_build.partitions = default.csv
build_flags =
  -DCORE_DEBUG_LEVEL=3

lib_deps =
  knolleary/PubSubClient@^2.8
  adafruit/Adafruit SSD1306@^2.5.9
  adafruit/Adafruit GFX Library@^1.11.11
  bblanchon/ArduinoJson@^7.0.4

src/main.cpp

#include <Arduino.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <ArduinoJson.h>
#include "driver/twai.h"

// -------- User Configuration --------
#define WIFI_SSID       "YOUR_WIFI_SSID"
#define WIFI_PASS       "YOUR_WIFI_PASSWORD"
#define MQTT_HOST       "192.168.1.10"   // Your broker IP or hostname
#define MQTT_PORT       1883
#define MQTT_USER       ""               // or set if broker requires
#define MQTT_PASS       ""               // or set if broker requires
#define MQTT_CLIENT_ID  "esp32-can-gw-01"

// Topics
static const char* TOPIC_RX      = "gw/can0/rx";     // publish CAN->MQTT
static const char* TOPIC_TX      = "gw/can0/tx";     // subscribe MQTT->CAN
static const char* TOPIC_STATUS  = "gw/can0/status"; // LWT + status
static const char* TOPIC_STATS   = "gw/can0/stats";  // periodic metrics

// OLED
#define OLED_ADDR       0x3C
#define OLED_WIDTH      128
#define OLED_HEIGHT     64
#define OLED_RESET_PIN  -1 // not used, set to -1 for I2C-only

// I2C pins for ESP32 DevKitC
#define I2C_SDA         21
#define I2C_SCL         22

// TWAI pins (SN65HVD230)
#define TWAI_TX_PIN     GPIO_NUM_5
#define TWAI_RX_PIN     GPIO_NUM_4

// CAN bitrate (pick a config macro)
#define USE_CAN_500K    1

// Test mode: if you want to allow transmit without ACK (single-node lab test)
// Set to 1 to start in TWAI_MODE_NO_ACK; set 0 for normal (bus expected)
#define TWAI_NO_ACK_TEST_MODE  0

// Queue sizes
#define RX_QUEUE_LEN    32
#define TX_QUEUE_LEN    32

// -------- Globals --------
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
Adafruit_SSD1306 display(OLED_WIDTH, OLED_HEIGHT, &Wire, OLED_RESET_PIN);

typedef struct {
  uint32_t id;
  bool ext;
  bool rtr;
  uint8_t dlc;
  uint8_t data[8];
  uint64_t ts_us;
} can_frame_t;

QueueHandle_t rxQueue = nullptr;
QueueHandle_t txQueue = nullptr;

volatile uint32_t can_rx_count = 0;
volatile uint32_t can_tx_count = 0;
volatile uint32_t can_tx_fail  = 0;
volatile uint32_t can_err_warn = 0;

static uint32_t last_id_display = 0;
static bool last_id_ext = false;

void oledPrint(const String& l1, const String& l2, const String& l3, const String& l4) {
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);  display.print(l1);
  display.setCursor(0, 16); display.print(l2);
  display.setCursor(0, 32); display.print(l3);
  display.setCursor(0, 48); display.print(l4);
  display.display();
}

void connectWiFi() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  uint32_t start = millis();
  while (WiFi.status() != WL_CONNECTED) {
    delay(300);
    if (millis() - start > 20000) break;
  }
}

void mqttReconnect() {
  if (mqtt.connected()) return;
  mqtt.setServer(MQTT_HOST, MQTT_PORT);
  mqtt.setSocketTimeout(5);
  mqtt.setKeepAlive(15);
  mqtt.setBufferSize(1024);
  String cid = String(MQTT_CLIENT_ID) + "-" + String((uint32_t)ESP.getEfuseMac(), HEX);

  if (mqtt.connect(cid.c_str(), MQTT_USER, MQTT_PASS, TOPIC_STATUS, 1, true, "offline")) {
    mqtt.publish(TOPIC_STATUS, "online", true);
    mqtt.subscribe(TOPIC_TX);
  }
}

static inline String hexByte(uint8_t b) {
  char buf[3];
  snprintf(buf, sizeof(buf), "%02X", b);
  return String(buf);
}

void publishCanFrame(const can_frame_t& f) {
  StaticJsonDocument<256> doc;
  doc["bus"] = "can0";
  doc["ts_us"] = f.ts_us;
  char idbuf[11];
  if (f.ext) snprintf(idbuf, sizeof(idbuf), "0x%08lX", (unsigned long)f.id);
  else       snprintf(idbuf, sizeof(idbuf), "0x%03lX",  (unsigned long)f.id);
  doc["id"] = idbuf;
  doc["ext"] = f.ext;
  doc["rtr"] = f.rtr;
  doc["dlc"] = f.dlc;

  String dataHex;
  if (!f.rtr) {
    for (uint8_t i = 0; i < f.dlc && i < 8; i++) {
      dataHex += hexByte(f.data[i]);
    }
  } else {
    dataHex = "";
  }
  doc["data"] = dataHex;

  char out[320];
  size_t len = serializeJson(doc, out, sizeof(out));
  if (mqtt.connected()) {
    mqtt.publish(TOPIC_RX, out, len);
  }
}

uint32_t parseHexOrDecId(const String& s, bool* ok) {
  char* endp = nullptr;
  uint32_t val = 0;
  if (s.startsWith("0x") || s.startsWith("0X")) {
    val = strtoul(s.c_str() + 2, &endp, 16);
  } else {
    val = strtoul(s.c_str(), &endp, 10);
  }
  *ok = (endp != nullptr) && (*endp == '\0');
  return val;
}

bool parseHexPayload(const String& hex, uint8_t* out, uint8_t* dlc) {
  size_t n = hex.length();
  if (n == 0) { *dlc = 0; return true; }
  if (n % 2 != 0 || n/2 > 8) return false;
  for (size_t i = 0; i < n/2; i++) {
    char h[3] = { hex[2*i], hex[2*i+1], 0 };
    out[i] = (uint8_t)strtoul(h, nullptr, 16);
  }
  *dlc = n/2;
  return true;
}

void mqttCallback(char* topic, uint8_t* payload, unsigned int length) {
  String t = String(topic);
  String pl;
  pl.reserve(length + 1);
  for (unsigned int i = 0; i < length; i++) pl += (char)payload[i];

  if (t == TOPIC_TX) {
    StaticJsonDocument<256> doc;
    DeserializationError err = deserializeJson(doc, pl);
    if (err) return;

    bool okId = false;
    String idStr = doc["id"] | "";
    uint32_t id = parseHexOrDecId(idStr, &okId);
    bool ext = doc["ext"] | false;
    bool rtr = doc["rtr"] | false;
    String dataHex = doc["data"] | "";
    if (!okId) return;

    can_frame_t f = {};
    f.id = id & (ext ? 0x1FFFFFFF : 0x7FF);
    f.ext = ext;
    f.rtr = rtr;

    if (!rtr) {
      uint8_t dlc = 0;
      if (!parseHexPayload(dataHex, f.data, &dlc)) return;
      f.dlc = dlc;
    } else {
      uint8_t dlc = doc["dlc"] | 0; // allow explicit dlc for RTR
      if (dlc > 8) dlc = 8;
      f.dlc = dlc;
    }
    f.ts_us = (uint64_t)esp_timer_get_time();

    if (txQueue) {
      xQueueSend(txQueue, &f, 0);
    }
  }
}

void setupMQTT() {
  mqtt.setCallback(mqttCallback);
  mqttReconnect();
}

// TWAI setup
bool startTWAI() {
  twai_general_config_t g_config = TWAI_GENERAL_CONFIG_DEFAULT(TWAI_TX_PIN, TWAI_RX_PIN, TWAI_MODE_NORMAL);
#if TWAI_NO_ACK_TEST_MODE
  g_config.mode = TWAI_MODE_NO_ACK;
#endif
#if USE_CAN_500K
  twai_timing_config_t t_config = TWAI_TIMING_CONFIG_500KBITS();
#else
  twai_timing_config_t t_config = TWAI_TIMING_CONFIG_250KBITS();
#endif
  twai_filter_config_t f_config = TWAI_FILTER_CONFIG_ACCEPT_ALL();

  if (twai_driver_install(&g_config, &t_config, &f_config) != ESP_OK) {
    Serial.println("[TWAI] driver install failed");
    return false;
  }
  if (twai_start() != ESP_OK) {
    Serial.println("[TWAI] start failed");
    return false;
  }
  return true;
}

void TwaiRxTask(void* pv) {
  twai_status_info_t st;
  for (;;) {
    twai_message_t msg;
    esp_err_t r = twai_receive(&msg, pdMS_TO_TICKS(100));
    if (r == ESP_OK) {
      can_frame_t f = {};
      f.id = msg.identifier;
      f.ext = msg.extd;
      f.rtr = msg.rtr;
      f.dlc = msg.data_length_code;
      if (!msg.rtr) memcpy(f.data, msg.data, f.dlc);
      f.ts_us = (uint64_t)esp_timer_get_time();
      can_rx_count++;
      if (rxQueue) xQueueSend(rxQueue, &f, 0);

      // Track for OLED
      last_id_display = f.id;
      last_id_ext = f.ext;
    }

    // Periodically check error counters
    if (twai_get_status_info(&st) == ESP_OK) {
      if (st.tx_error_counter > 0 || st.rx_error_counter > 0 || st.bus_state != TWAI_BUS_STATE_RUNNING) {
        can_err_warn++;
      }
    }
  }
}

void TwaiTxTask(void* pv) {
  for (;;) {
    can_frame_t f;
    if (xQueueReceive(txQueue, &f, pdMS_TO_TICKS(100)) == pdTRUE) {
      twai_message_t msg = {};
      msg.identifier = f.id;
      msg.extd = f.ext;
      msg.rtr = f.rtr;
      msg.data_length_code = f.dlc;
      if (!f.rtr && f.dlc > 0) memcpy(msg.data, f.data, f.dlc);
      if (twai_transmit(&msg, pdMS_TO_TICKS(50)) == ESP_OK) {
        can_tx_count++;
      } else {
        can_tx_fail++;
      }
    }
  }
}

unsigned long lastStatsMs = 0;

void publishStats() {
  StaticJsonDocument<192> doc;
  doc["ip"] = WiFi.localIP().toString();
  doc["rx"] = can_rx_count;
  doc["tx"] = can_tx_count;
  doc["tx_fail"] = can_tx_fail;
  doc["warns"] = can_err_warn;
  char out[256];
  size_t len = serializeJson(doc, out, sizeof(out));
  if (mqtt.connected()) mqtt.publish(TOPIC_STATS, out, len, false);
}

// Setup & loop
void setup() {
  Serial.begin(115200);
  delay(100);

  Wire.begin(I2C_SDA, I2C_SCL);
  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
    Serial.println("[OLED] init failed");
  } else {
    oledPrint("ESP32 CAN<->MQTT", "Booting...", "", "");
  }

  connectWiFi();
  oledPrint("WiFi:", WiFi.isConnected() ? WiFi.localIP().toString() : "Not connected",
            "MQTT: connecting", "");

  setupMQTT();

  if (!startTWAI()) {
    oledPrint("TWAI init failed", "", "", "");
  } else {
    oledPrint("TWAI: OK", WiFi.localIP().toString(),
              "MQTT: " + String(mqtt.connected() ? "OK" : "Pending"), "");
  }

  rxQueue = xQueueCreate(RX_QUEUE_LEN, sizeof(can_frame_t));
  txQueue = xQueueCreate(TX_QUEUE_LEN, sizeof(can_frame_t));

  xTaskCreatePinnedToCore(TwaiRxTask, "twai_rx", 4096, nullptr, 10, nullptr, APP_CPU_NUM);
  xTaskCreatePinnedToCore(TwaiTxTask, "twai_tx", 4096, nullptr, 10, nullptr, APP_CPU_NUM);
}

void loop() {
  // Wi-Fi reconnect
  if (WiFi.status() != WL_CONNECTED) {
    connectWiFi();
  }

  // MQTT reconnect
  if (!mqtt.connected()) {
    mqttReconnect();
  }
  mqtt.loop();

  // Drain RX queue and publish to MQTT
  for (int i = 0; i < 8; i++) {
    can_frame_t f;
    if (rxQueue && xQueueReceive(rxQueue, &f, 0) == pdTRUE) {
      publishCanFrame(f);
      // Update OLED with last ID and counters
      char idbuf[16];
      if (f.ext) snprintf(idbuf, sizeof(idbuf), "0x%08lX", (unsigned long)f.id);
      else       snprintf(idbuf, sizeof(idbuf), "0x%03lX",  (unsigned long)f.id);

      String l1 = "IP: " + (WiFi.isConnected() ? WiFi.localIP().toString() : String("No WiFi"));
      String l2 = String("MQTT: ") + (mqtt.connected() ? "OK" : "NC") + " RX:" + String(can_rx_count);
      String l3 = String("TX:") + String(can_tx_count) + " Fail:" + String(can_tx_fail);
      String l4 = String("Last ID ") + idbuf;
      oledPrint(l1, l2, l3, l4);
    } else {
      break;
    }
  }

  // Periodic stats to MQTT
  unsigned long now = millis();
  if (now - lastStatsMs > 5000) {
    publishStats();
    lastStatsMs = now;
  }

  delay(5);
}

Payload conventions:
– CAN-to-MQTT publishes on gw/can0/rx: JSON with fields bus, ts_us, id (hex string), ext, rtr, dlc, data (hex string).
– MQTT-to-CAN expects JSON on gw/can0/tx: id (hex string or decimal), ext, rtr, data (hex string) and optional dlc for RTR.

Example TX message to publish to gw/can0/tx:

{"id":"0x123","ext":false,"rtr":false,"data":"112233AABBCCDD00"}

Build/Flash/Run Commands

Initialize a PlatformIO project (if not using IDE):

mkdir esp32-twai-can-mqtt-gateway
cd esp32-twai-can-mqtt-gateway

# Initialize PlatformIO project for ESP32 DevKitC
pio project init --board esp32dev

Place platformio.ini and src/main.cpp as shown above.

Build, upload, and monitor:

# Build
pio run

# Upload (auto-detects serial port, use --upload-port if needed)
pio run -t upload

# Serial monitor at 115200 baud
pio device monitor -b 115200

Common upload ports:
– Windows: COM3, COM4, …
– macOS: /dev/tty.SLAB_USBtoUART or /dev/tty.usbserial-xxxx
– Linux: /dev/ttyUSB0 or /dev/ttyACM0 (ensure user in dialout group)

Specify upload port explicitly if needed:

pio run -t upload --upload-port /dev/ttyUSB0

Step-by-step Validation

Follow this sequence to validate the gateway incrementally.

1) Power-on and basic bring-up

  • With SN65HVD230 and SSD1306 connected as per the table, power the ESP32 via USB.
  • Open the serial monitor. You should see boot logs.
  • OLED should display:
  • Your IP if Wi-Fi connected, or “Not connected”
  • MQTT status (OK once connected)
  • TWAI: OK if driver started successfully

If Wi-Fi doesn’t connect, verify SSID/password in code and signal strength.

2) Validate MQTT connectivity and topics

On a host machine with mosquitto-clients installed:

# Subscribe to CAN RX and stats
mosquitto_sub -h 192.168.1.10 -t gw/can0/rx -v

In another terminal:

# Subscribe to gateway status and stats
mosquitto_sub -h 192.168.1.10 -t gw/can0/status -v
mosquitto_sub -h 192.168.1.10 -t gw/can0/stats -v
  • You should receive «online» on gw/can0/status after the ESP32 connects.
  • Every 5 seconds you should see a gw/can0/stats JSON with rx/tx counters.

3) Loopback-style test (NO_ACK mode optional)

If you don’t have a second CAN node yet:
– Optionally set TWAI_NO_ACK_TEST_MODE to 1 in main.cpp and rebuild/flash. This allows transmit without a receiver acknowledging (for single-node lab tests). Note: You still won’t see frames “on the bus” without a second node, but transmit calls will succeed.
– Publish a TX frame from MQTT to the ESP32:

mosquitto_pub -h 192.168.1.10 -t gw/can0/tx -m '{"id":"0x123","ext":false,"rtr":false,"data":"11223344"}'
  • Check the serial monitor: you should see TX and no errors (tx counter increments in stats).
  • Since this is NO_ACK, you will not see the same frame on gw/can0/rx unless another node (or internal echo) is present. This step is just to ensure TX path + parsing works.

Switch NO_ACK back to 0 for real bus testing later.

4) Two-node CAN validation

Connect a real CAN bus with proper termination and a second node (e.g., USB-CAN dongle) at 500 kbit/s (matches our TWAI config).

  • Ensure SN65HVD230 RS is tied to GND and the bus is 120 Ω terminated at both ends.
  • On your second node, send a frame:

Example using a Linux system with can-utils (virtual example, your USB-CAN may use slcand or native SocketCAN):

# Example for SocketCAN interface can0 at 500k
# Send standard ID 0x321 with 8 bytes
cansend can0 321#DEADBEEF01020304
  • On your MQTT subscriber (gw/can0/rx), you should now see a JSON like:
gw/can0/rx {"bus":"can0","ts_us":1234567,"id":"0x321","ext":false,"rtr":false,"dlc":8,"data":"DEADBEEF01020304"}
  • OLED should update the Last ID to 0x321 and RX counter should increase.

5) End-to-end bridge test (MQTT -> CAN -> Other Node)

Now send from MQTT to the CAN bus via the gateway:

mosquitto_pub -h 192.168.1.10 -t gw/can0/tx -m '{"id":"0x18FF50E5","ext":true,"rtr":false,"data":"A1B2C3D4E5F60708"}'
  • On your USB-CAN tool, listen for frames (again with can-utils):
candump can0
  • You should see the extended frame with ID 0x18FF50E5 and the given data.
  • Check that gw/can0/stats shows tx increment.

6) Remote frames (RTR) test

Publish an RTR from MQTT:

mosquitto_pub -h 192.168.1.10 -t gw/can0/tx -m '{"id":"0x123","ext":false,"rtr":true,"dlc":4}'
  • On the other node, you should see a remote frame for 0x123 DLC 4.

7) Stress/soak test

  • With your USB-CAN, generate a stream of frames and watch the gateway:
# e.g., transmit 10 frames/second for 1 minute (custom tooling or your CAN dongle GUI)
  • Observe OLED counters and ensure gw/can0/rx receives JSON messages without drops. Serial logs should show no TWAI errors and stats should increment steadily.

Troubleshooting

  • No serial port / upload fails:
  • Check USB cable (data-capable).
  • Install CP210x or CH340 drivers as needed.
  • On Linux, add your user to the dialout group: sudo usermod -aG dialout $USER and re-login.

  • Wi-Fi not connecting:

  • Verify SSID/PASS in code exactly.
  • Check 2.4 GHz band availability; ESP32 does not support 5 GHz.
  • Scan RSSI; move closer to AP.

  • MQTT not connecting:

  • Confirm broker IP and port; test with mosquitto_sub from your PC.
  • If broker requires authentication, fill MQTT_USER/MQTT_PASS.
  • Firewalls may block port 1883.

  • OLED stays blank:

  • Double-check I2C pins (SDA=21, SCL=22) and address (0x3C).
  • Confirm 3.3V power and ground.
  • Try scanning I2C from code (a quick I2C scanner sketch) to verify address.

  • No CAN frames received:

  • Verify SN65HVD230 wiring: TXD->GPIO5, RXD->GPIO4, RS->GND, VCC 3.3V, GND common.
  • Ensure the CAN bus is terminated with 120 Ω at both ends and that another active node is present.
  • Confirm bus speed (500 kbit/s by default). Mismatched bitrate means no frames will be decoded.
  • Check ground reference between all CAN nodes.

  • CAN transmit fails (tx_fail increases):

  • If normal mode (ACK required) and only single node exists: frames may fail due to missing ACK. Use TWAI_NO_ACK_TEST_MODE 1 for solo lab tests.
  • Bus-off or error-passive: power issues or wiring/termination fault. Inspect st.tx_error_counter via code (we increment warns on anomalies).
  • Try slower bitrate (250 kbit/s) by changing timing macro if your bus requires it.

  • Random resets or instability:

  • Power from a reliable USB source; avoid noisy hubs.
  • Ensure no 5V is fed into SN65HVD230 VCC (must be 3.3V).

  • JSON parsing errors on MQTT->CAN:

  • Ensure payload matches schema: id, ext, rtr, data (hex). For RTR, specify dlc if you need >0 DLC.
  • Data hex length must be even and ≤16 hex chars (≤8 bytes).

Improvements

  • Security/TLS:
  • Use MQTT over TLS (port 8883) with WiFiClientSecure and broker CA cert. Increase PubSubClient buffer accordingly.
  • Dynamic configuration:
  • Store Wi-Fi, broker, topics, and CAN bitrate in NVS or a config file. Add a captive portal for provisioning.
  • Advanced filters:
  • Replace TWAI_FILTER_CONFIG_ACCEPT_ALL with tight acceptance filters to reduce CPU and bandwidth.
  • Retained device info:
  • Publish retained metadata: firmware version, build time, CAN bitrate, pin mapping.
  • OTA updates:
  • Add ArduinoOTA or HTTPS OTA for remote firmware updates.
  • Stats and health:
  • Periodic twai_get_status_info details (error counters, bus state) in gw/can0/stats.
  • OLED UI:
  • Add paging and buttons to cycle views; show broker host, RSSI, and queue depths.
  • Asynchronous MQTT:
  • Switch to AsyncMqttClient + AsyncTCP for non-blocking operations if you expect high message rates.
  • Multiple buses:
  • If using ESP32 variants with two TWAI controllers (not on WROOM-32), expand to gw/can1.
  • Protocol-specific:
  • Add decoders for J1939, CANopen, or custom DBC, and publish human-friendly metrics.

Checklist (Before You Start the Bus Test)

  • Materials:
  • ESP32-WROOM-32 DevKitC ready and enumerates on USB
  • SN65HVD230 wired to 3.3V, GND, TXD=GPIO5, RXD=GPIO4, RS=GND
  • SSD1306 wired on I2C: SDA=21, SCL=22, address 0x3C
  • CAN bus has 120 Ω at both ends; ground reference shared
  • Software:
  • PlatformIO installed; project builds with provided platformio.ini
  • Drivers installed for CP210x/CH34x as needed
  • MQTT broker reachable from ESP32 IP (ping broker from PC)
  • Firmware:
  • Wi-Fi SSID/PASS set in main.cpp
  • MQTT host/port (and credentials if needed) set in main.cpp
  • CAN bitrate matches your bus (default 500 kbit/s)
  • Uploaded and serial monitor shows IP and “TWAI: OK”
  • Validation:
  • mosquitto_sub shows gw/can0/status “online”
  • gw/can0/stats updates every ~5 s
  • Injecting frames from second node appears on gw/can0/rx
  • Publishing to gw/can0/tx produces frames on the bus

With the above, your ESP32-WROOM-32 DevKitC + SN65HVD230 + SSD1306 OLED is a functional twai-can-mqtt-gateway suitable for lab use and extensible to production with TLS, OTA, and richer filtering.

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What is the main purpose of the ESP32 TWAI–CAN to MQTT Gateway?




Question 2: Which hardware is NOT mentioned as part of the gateway setup?




Question 3: What is the recommended IDE for this project?




Question 4: Which operating systems are supported for this project?




Question 5: What is required for the ESP32-WROOM-32 DevKitC USB connection?




Question 6: What version of Python is required for this project?




Question 7: What tool is suggested for validating the implementation?




Question 8: What type of architecture does the implementation prioritize?




Question 9: What type of observability does the project provide?




Question 10: What is the main function of the SSD1306 in this project?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:
Scroll to Top