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
As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.



