Raspberry Pi

CAN Bus to MQTT with Raspberry Pi: Read, Decode and Publish Frames

Turn a Raspberry Pi with a CAN HAT into a gateway that reads frames with python-can, decodes signals from a DBC file and publishes rate-limited JSON to an MQTT broker.

Raspberry Pi with a CAN HAT wired to a vehicle bus, illustrating a can bus to mqtt raspberry pi gateway
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

A CAN bus to MQTT Raspberry Pi gateway lets you take signals that normally stay locked inside a machine, vehicle or battery pack and make them available to dashboards, alerts and analytics anywhere. In this tutorial you will wire up an MCP2515-based CAN HAT, bring up the can0 interface, read frames with python-can, decode them with a DBC file and publish clean JSON to an MQTT broker. The examples use the free test broker at broker.connectmq.com, then switch to private credentials for production.

How a CAN to MQTT Gateway Works

CAN is a multi-master bus where every node broadcasts short frames (up to 8 data bytes for classic CAN) identified by an arbitration ID. There is no concept of "topics" or "readings": a single frame such as 0x1F0 might pack engine speed, coolant temperature and a status bit into specific bits of its payload. The meaning lives in a separate description, usually a DBC file supplied by the equipment vendor or reverse-engineered by you.

MQTT is the opposite: named topics, self-describing payloads and consumers that come and go. The gateway's job is to translate between the two worlds:

  1. Receive raw frames from the bus through Linux SocketCAN.
  2. Decode the frames you care about into named physical values (rpm, degrees, volts).
  3. Reduce the data rate, because a CAN bus can carry thousands of frames per second.
  4. Publish values to well-structured topics on an MQTT broker.

Step 3 is where most first attempts go wrong. Forwarding every frame as its own MQTT message floods the network and the broker, and nobody downstream needs engine speed 100 times a second. A gateway should keep the latest value per signal and publish snapshots on a fixed interval or on significant change.

Hardware You Need

Item Notes
Raspberry Pi 3, 4 or 5 Any model with the 40-pin header and SPI works
MCP2515 CAN HAT Waveshare RS485 CAN HAT, PiCAN2 or similar; check the crystal frequency printed on the board
CAN transceiver Usually on the HAT (SN65HVD230 or MCP2551)
120 ohm termination Required at both physical ends of the bus; many HATs have a jumper
Access to the bus OBD-II connector, machine CAN port or a second node for testing

Connect CAN_H to CAN_H and CAN_L to CAN_L, plus a common ground where the HAT provides one. If your Pi sits at the end of the bus, enable its termination resistor. If it taps into the middle of an already terminated bus, leave termination off.

Bring Up the can0 Interface

Enable SPI and the MCP2515 overlay. On Raspberry Pi OS Bookworm the file lives at /boot/firmware/config.txt; older releases use /boot/config.txt.

dtparam=spi=on
dtoverlay=mcp2515-can0,oscillator=12000000,interrupt=25

The oscillator value must match the crystal on your HAT (12 MHz on many Waveshare boards, 16 MHz on PiCAN2 and others) and interrupt must match the GPIO the HAT uses for the MCP2515 interrupt line. A wrong oscillator value is the most common reason for an interface that comes up but never receives anything. Reboot, then bring the interface up at the bus bitrate:

sudo reboot
# after reboot
dmesg | grep -i mcp251
sudo apt install -y can-utils
sudo ip link set can0 up type can bitrate 500000
ip -details link show can0
candump can0

500 kbit/s is common for vehicles and many machines, but 250 kbit/s (J1939 trucks and agricultural equipment) and 125 kbit/s also appear. If candump shows nothing and ip -details -statistics link show can0 reports errors or a BUS-OFF state, the bitrate or wiring is wrong.

To make the interface come up automatically at boot, let systemd-networkd manage it:

sudo tee /etc/systemd/network/80-can.network > /dev/null <<'EOF'
[Match]
Name=can0

[CAN]
BitRate=500K
EOF
sudo systemctl enable --now systemd-networkd

Install python-can, cantools and paho-mqtt

python3 -m venv ~/can-gw/.venv
source ~/can-gw/.venv/bin/activate
pip install python-can cantools "paho-mqtt>=2.0"

python-can talks to SocketCAN, cantools parses DBC files and decodes frames, and paho-mqtt handles the broker connection. If you have no DBC file yet, the script below still works and publishes raw frames in hex for the IDs you whitelist, which is useful while you reverse-engineer signals.

The CAN Bus to MQTT Raspberry Pi Script

The gateway reads frames in a loop, decodes them into a dictionary of latest values and publishes a snapshot once per second. It also publishes a retained online/offline status with a Last Will, so dashboards can tell when the gateway disappears.

import json
import os
import time
import uuid

import can
import cantools
import paho.mqtt.client as mqtt

BROKER = os.getenv("MQTT_HOST", "broker.connectmq.com")
PORT = int(os.getenv("MQTT_PORT", "1883"))
USERNAME = os.getenv("MQTT_USERNAME")          # set for a private broker
PASSWORD = os.getenv("MQTT_PASSWORD")
CLIENT_ID = os.getenv("MQTT_CLIENT_ID", f"can-gw-{uuid.uuid4().hex[:8]}")
PREFIX = os.getenv("TOPIC_PREFIX", "connectmq/demo/k7x2q9/vehicle1")
DBC_PATH = os.getenv("DBC_PATH", "vehicle.dbc")
RAW_IDS = {0x1F0, 0x2A0}                        # forwarded as hex if not in the DBC
PUBLISH_INTERVAL = 1.0

db = cantools.database.load_file(DBC_PATH) if os.path.exists(DBC_PATH) else None
known_ids = {m.frame_id for m in db.messages} if db else set()

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID)
if USERNAME:
    client.username_pw_set(USERNAME, PASSWORD)
if PORT == 8883:
    client.tls_set()
client.will_set(f"{PREFIX}/status", "offline", qos=1, retain=True)
client.reconnect_delay_set(min_delay=1, max_delay=60)


def on_connect(client, userdata, flags, reason_code, properties):
    print("connected:", reason_code)
    client.publish(f"{PREFIX}/status", "online", qos=1, retain=True)


client.on_connect = on_connect
client.connect_async(BROKER, PORT, keepalive=30)
client.loop_start()

latest = {}
raw = {}
bus = can.Bus(interface="socketcan", channel="can0")
next_publish = time.monotonic() + PUBLISH_INTERVAL

try:
    while True:
        msg = bus.recv(timeout=0.2)
        if msg is not None and not msg.is_error_frame:
            if msg.arbitration_id in known_ids:
                try:
                    decoded = db.decode_message(msg.arbitration_id, msg.data)
                    for name, value in decoded.items():
                        latest[name] = value if isinstance(value, (int, float)) else str(value)
                except Exception as exc:
                    print("decode error", hex(msg.arbitration_id), exc)
            elif msg.arbitration_id in RAW_IDS:
                raw[f"{msg.arbitration_id:03X}"] = msg.data.hex()

        now = time.monotonic()
        if now >= next_publish and client.is_connected():
            next_publish = now + PUBLISH_INTERVAL
            if latest:
                payload = {"ts": round(time.time(), 3), "signals": latest}
                client.publish(f"{PREFIX}/signals", json.dumps(payload), qos=1)
            if raw:
                client.publish(f"{PREFIX}/raw", json.dumps(raw), qos=0)
except KeyboardInterrupt:
    pass
finally:
    client.publish(f"{PREFIX}/status", "offline", qos=1, retain=True)
    client.loop_stop()
    client.disconnect()
    bus.shutdown()

Run it and watch the output from another terminal:

python gateway.py
mosquitto_sub -h broker.connectmq.com -p 1883 -i can-viewer-k7x2q9 -t "connectmq/demo/k7x2q9/vehicle1/#" -v

Without a real bus you can test the full chain with a virtual interface: sudo modprobe vcan && sudo ip link add dev vcan0 type vcan && sudo ip link set vcan0 up, change the channel to vcan0, and inject frames with cansend vcan0 1F0#0A1B2C3D.

Rate Limiting and Topic Design

The script publishes one combined snapshot per second instead of one message per frame. That matches the public MQTT test broker limit of 10 messages per second per client and keeps a private broker quiet too. Some patterns that work well in practice:

  • Snapshot topic for dashboards: one JSON document per interval containing all decoded signals.
  • Per-signal topics such as .../signals/EngineSpeed when consumers subscribe to individual values. Publish only when the value changes by more than a deadband.
  • Event topics for discrete changes (fault codes, door open, state machine transitions) published immediately at QoS 1.
  • Raw topic only for IDs under investigation, never the whole bus.

Keep the topic hierarchy stable and descriptive. If the gateway is part of a plant, an ISA-95 style path like site/area/line/machine/can fits naturally into a Unified Namespace, and a Raspberry Pi IoT gateway can combine CAN data with other sources under one prefix.

Moving to Production

The public broker is for testing: anything published there is readable by everyone. For a real vehicle or machine, create a private broker on ConnectMQ, copy the generated username, password and client ID from the portal and set the environment variables:

export MQTT_HOST=broker.connectmq.com
export MQTT_PORT=8883
export MQTT_USERNAME=YOUR_USERNAME
export MQTT_PASSWORD=YOUR_PASSWORD
export MQTT_CLIENT_ID=YOUR_CLIENT_ID
export TOPIC_PREFIX=fleet/truck-17/can
python gateway.py

Port 8883 uses TLS with a Let's Encrypt certificate, so tls_set() with the system CA bundle is enough. A fully managed MQTT service also removes the need to run and patch your own broker on the edge. For a field gateway, combine this with offline buffering in SQLite so cellular dropouts do not create gaps, and run the script as a service so it starts after power loss.

Troubleshooting

Symptom Likely cause Fix
can0 does not exist Overlay not loaded or SPI disabled Check config.txt and dmesg
Interface up, no frames Wrong oscillator or bitrate Match the crystal and bus speed
Error counters climbing, BUS-OFF Missing termination, swapped H/L, wrong bitrate Measure about 60 ohm between H and L with power off
Frames received, decode errors DBC does not match this variant Verify IDs and byte order with candump
MQTT disconnects under load Publishing every frame Aggregate and throttle as shown above
Values look scaled wrong Signed vs unsigned or byte order Check factor, offset and endianness in the DBC

A note on safety: this gateway only listens. The MCP2515 will still acknowledge frames on the bus, which is normal CAN behaviour, but do not add code that transmits onto a vehicle or machine bus unless you fully understand the consequences. If you need a strictly passive tap, put the controller in listen-only mode with sudo ip link set can0 up type can bitrate 500000 listen-only on.

For broader background on the Pi as an MQTT client, start with the Raspberry Pi MQTT tutorial. The python-can documentation covers other interfaces such as USB-CAN adapters if a HAT is not an option.

Stream Your CAN Data to ConnectMQ

Once frames are decoded, the hard part is done; the rest is reliable delivery. Create a free ConnectMQ Serverless broker at app.connectmq.com with no credit card, paste the credentials into the gateway and watch your signals arrive in the live topic explorer. When the fleet grows, the same code moves to a larger plan without changes.

Frequently Asked Questions

Can I use a USB-CAN adapter instead of a HAT?

Yes. Adapters supported by the Linux kernel, such as those using the gs_usb driver, appear as can0 through SocketCAN, so the Python script works unchanged. Other adapters can be used through their python-can interface name.

Does this work with CAN FD?

The MCP2515 supports classic CAN only. For CAN FD you need an MCP2518FD-based HAT and the matching overlay; python-can and cantools both handle FD frames.

How do I decode OBD-II or J1939 data?

OBD-II PIDs are request/response, so you have to send requests rather than only listen. J1939 uses 29-bit identifiers and PGNs, and public DBC files cover many standard parameters.

How many messages should the gateway publish?

Publish what consumers need, not what the bus produces. A one-second snapshot plus immediate event messages is a good default and stays well within broker limits.

Related articles

Pico W vs Pico 2 W for MQTT: RP2040 vs RP2350 Performance, Memory and TLS

The Pico 2 W keeps the Pico W's footprint and Wi-Fi chip but swaps the RP2040 for the faster RP2350. This comparison looks at what that means for MQTT devices: CPU, memory, TLS, security features, software support, and which board to buy for which project.

8 min read

Raspberry Pi RFID MQTT Access Logging with the RC522 Reader

Wire an RC522 RFID reader to a Raspberry Pi, read tags in Python, check them against a local allowlist and publish every access attempt as an MQTT event that loggers, dashboards and alerting tools can consume.

7 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.