Standalone MicroPython driver for the MCP2515 CAN controller over SPI
micropython-mcp2515
A clean, well-tested MicroPython driver for the MCP2515 CAN controller over SPI.
Designed for ESP32 (including ESP32-S2) but should work on any MicroPython port with machine.SPI and machine.Pin.
Features
- Full send + receive support
- Extended (29-bit) ID support for both transmit and receive (TX EID8/EID0 writes included)
- RTR (remote frame) support via CANMessage(rtr=True) -- RTR bit set in DLC on send, extracted on recv
- Multiple bitrates (including 250 kbps and 500 kbps common in vehicles)
- 8 MHz, 16 MHz, 20 MHz, and 4 MHz crystal support (4 MHz common on cheap modules)
- 1 Mbps support on 8/16/20 MHz where timing allows
- Expanded table +
compute_cnfhelper (see Bitrates section) - Proper RX buffer configuration for reliable reception (RXM + BUKT)
- Status / error reporting
- Optional interrupt pin support
- Debug helpers for diagnosing "why am I not seeing frames?"
- Automatic bus-off recovery (check_and_recover) that restores the original mode from begin() + re-verifies (not hardcoded to NORMAL)
- Hardware acceptance filter/mask API: set_acceptance_mask(idx, mask, extended), set_filter(idx, can_id, mask_idx, extended), clear_filters() (default remains promiscuous "receive all" like before)
- Small and dependency-free
- Burst/sequential SPI helpers (_write_registers / _read_registers) + refactored hot paths in send()/recv() for lower latency and fewer MicroPython allocations (see Performance)
- Fast status commands: READ STATUS (0xA0) and RX STATUS (0xB0) via
read_status()/rx_status()(return int + STAT/RXSTAT bitmasks from constants); integrated into recv()/wait_for_rx()/send()/get_status/print_status for cheaper polling (single-byte cmd + read vs full reg accesses) -- see Performance / Status note and examples/basic.py
Recent Improvements (High-Priority Items)
The core high-priority items implemented after initial release (fully integrated, documented, and demonstrated together):
- IRQ-driven receive (use_irq + wait_for_rx) -- see Optional Interrupt Pin section.
- Hardware filters & masks via set_acceptance_mask / set_filter / clear_filters -- see Acceptance Filters section.
- Recovery that preserves begin() mode (check_and_recover, non-NORMAL support) -- see Bus-Off Recovery.
- Expanded bitrates table (4/8/16/20 MHz, more rates incl. 20k/1M edge cases) + compute_cnf() helper in constants -- see Bitrates.
These (and context/close, strerror/last_error, richer status, burst perf, exception hierarchy) are 100% additive. All original behavior, ERROR codes, APIs, and the user's motivation (below) preserved. See examples/basic.py (top basic + new combined advanced section at bottom) for usage.
Performance
Burst I/O optimizations: the driver now performs contiguous register reads/writes (e.g. full RX frame header+data or TX payload) using a single SPI CS assertion + command + address + N bytes, instead of per-register transactions. This reduces bus overhead and heap churn on resource-constrained devices like ESP32. All public APIs, debug hooks, IRQ, filters, recovery, RTR/ext, etc. preserved exactly.
Optional pre-allocated receive buffer: pass rx_buffer=bytearray(8) (or larger) to MCP2515(...) constructor. When a frame arrives in recv(), if the buffer is large enough its payload is copied into the buffer and msg.data uses a memoryview over it (avoids fresh per-frame bytes allocation for CAN data, further reducing heap pressure on hot paths). Falls back to prior allocation behavior if omitted or too small. 100% non-breaking. Example:
rx_buf = bytearray(8)
can = MCP2515(spi, cs, rx_buffer=rx_buf)
# ... begin ...
err, msg = can.recv()
# if ok: msg.data is memoryview into rx_buf (len tells used length); reuse rx_buf for next frames
See mcp2515/__init__.py (recv + init doc) and examples/basic.py (commented demo).
Fast status polling (new): read_status() issues READ STATUS (0xA0) returning a byte (TXB n pending via STAT.TXBnREQ, RX pending via STAT.RX0IF etc); rx_status() for RX STATUS (0xB0) (RX buffer/filter/RTR/ext via RXSTAT.*). These are single-byte-cmd + 1-byte-read (no addr) -- faster for polling than CANINTF/TXBCTRL reads. Integrated into recv/wait_for_rx (RX check), send (TX free), get_status/print_status (new 'read_status'/'rx_status' dict keys, additive). All prior behavior/debug/IRQ/filters/recovery/ext/RTR/API preserved 100%.
Usage:
from mcp2515.constants import STAT, RXSTAT
s = can.read_status()
if s & STAT.RX0IF: ... # or use in wait_for_rx etc
rs = can.rx_status()
if rs & RXSTAT.RTR: ...
print(can.get_status()["read_status"]) # or can.print_status()
See examples/basic.py for live demo and mcp2515/__init__.py + constants.py (CMD/STAT/RXSTAT).
Installation
Recommended (MicroPython mip)
mip install github:cozadizzle/micropython-mcp2515
Manual
Copy the mcp2515/ folder into your project so you can do:
from mcp2515 import MCP2515
Wiring (ESP32-S2 example)
| MCP2515 | ESP32 GPIO | Notes | |---------|------------|------------------------| | SCK | 18 | SPI clock | | MOSI | 11 | SPI data out | | MISO | 9 | SPI data in | | CS | 5 | Chip select (active low) | | CANH | - | To OBD pin 6 | | CANL | - | To OBD pin 14 |
SPI bus: SPI(1)
Important:
- Remove the 120Ω termination jumper on the MCP2515 module when using it on a real vehicle (the car provides termination).
- Use a proper CAN transceiver on the MCP2515 board (SN65HVD230 or equivalent recommended).
Basic Usage
from machine import Pin, SPI
from mcp2515 import MCP2515, CANMessage
spi = SPI(1, baudrate=10_000_000, polarity=0, phase=0,
sck=Pin(18), mosi=Pin(11), miso=Pin(9))
cs = Pin(5, Pin.OUT)
can = MCP2515(spi, cs)
# 500 kbps with 8 MHz crystal (common on vehicles)
can.begin(bitrate=500000, clock=8000000)
# Send a frame
msg = CANMessage(can_id=0x123, data=b'\x01\x02\x03')
can.send(msg)
# Send remote frame (RTR): rtr=True, usually with data=b'' (DLC=0); extended IDs also supported
# rtr_msg = CANMessage(can_id=0x7DF, rtr=True)
# can.send(rtr_msg)
# ext_msg = CANMessage(can_id=0x18DAF110, data=b'\x01', extended=True)
# can.send(ext_msg)
# Receive (non-blocking) -- .rtr and .extended are populated on received msgs
err, msg = can.recv()
if err == 0: # ERROR.OK
print(msg.can_id, msg.data)
Context Manager and Cleanup
The MCP2515 now supports the context manager protocol (__enter__/__exit__) and a public close() method for resource niceties:
with MCP2515(spi, cs) as can:
can.begin(bitrate=500000, clock=8000000)
# ... send/recv, filters, IRQ use_irq, etc. ...
# On exit (normal or exception): close() is called, resetting chip to safe SLEEP state
# and clearing begin() config state. Explicit use also supported: can.close()
# See examples/basic.py for more.
close() (and thus __exit__) performs a reset + sets MODE.SLEEP (low-power safe state), clears recovery state. IRQ pin registration and all other behavior is left for object lifetime / re-use. All prior features (use_irq, filters, recovery, ext-ID, RTR, debug, compute_cnf, etc.) are 100% preserved.
mip packaging
mip install (see above) is supported via a mip.json in the repo root (points to the mcp2515/ package for proper installation as importable module). Version is "0.3.0" (matches __version__ and mcp2515.constants.VERSION).
Combined Example / Advanced Usage
To see IRQ (use_irq=True + wait_for_rx), filters (set_acceptance_mask + set_filter), recovery (begin with non-NORMAL mode e.g. LOOPBACK + check_and_recover), and compute_cnf/expanded bitrates used together in one place, refer to the comprehensive "COMBINED ADVANCED EXAMPLE" section at the bottom of examples/basic.py.
(The very top of examples/basic.py remains the original simple send/recv for basic cases + the combined demo at bottom. Dedicated host mock tests live in examples/test_mock.py; run with plain python examples/test_mock.py (uses importlib+sys.modules mocks for machine etc).)
Optional Interrupt Pin
The driver supports an optional int_pin (for the MCP2515's active-low ~INT output).
Basic wiring (add to your SPI+CS):
int_pin = Pin(21, Pin.IN, Pin.PULL_UP)
can = MCP2515(spi, cs, int_pin=int_pin)
Enabling real IRQ-driven receive (High Priority Item #1)
Pass use_irq=True (new option) and provide int_pin. This:
- Calls
int_pin.irq(trigger=Pin.IRQ_FALLING, handler=...)(falling because ~INT is active-low). - On
begin(), enables RX0IE|RX1IE in CANINTE and clears stale flags. - Sets up a simple
_rx_pendingflag (set from IRQ context). - Re-enables on
check_and_recover()too.
int_pin = Pin(21, Pin.IN, Pin.PULL_UP)
can = MCP2515(spi, cs, int_pin=int_pin, use_irq=True)
can.begin(bitrate=500000, clock=8000000)
# Block until IRQ (or chip status) indicates RX frame ready, with optional timeout
if can.wait_for_rx(timeout_ms=2000):
err, msg = can.recv()
if err == 0:
print("Got via IRQ:", msg.can_id, msg.data, "rtr=", msg.rtr, "ext=", msg.extended)
else:
print("No frame (timeout)")
You can also call recv() directly anytime (it checks the _rx_pending flag first if IRQ mode, then always runs the original CANINTF poll logic to extract/clear the frame).
See examples/basic.py (combined section) for an example mixing use_irq + wait_for_rx with the other recent features (filters, recovery, compute_cnf).
Polling vs IRQ trade-offs
- Default (polled):
MCP2515(spi, cs)or with int_pin but withoutuse_irq=True.recv()is a fast non-blocking poll on CANINTF.RX0IF/RX1IF. Extremely reliable, all debug hooks (enable_debug_recv(True),print_status(), register dumps in recv) work exactly as before, no IRQ context restrictions. Recommended for most car diag / reverse-engineering use. - IRQ mode: Saves CPU by not spinning
recv()in a tight loop when idle.wait_for_rx()blocks (withtime.ticks+ 1ms sleeps) until the flag or CANINTF shows data. Still requires your main code to call wait/recv (IRQ handler only sets a flag; MicroPython IRQ handlers must stay tiny). The actual message read + ext-id/RTR handling + clearing + debug always goes through the proven polled path insiderecv(). - If a bus-off recovery happens, IRQ enables are automatically restored.
- Back-compat: old code passing int_pin positionally or without use_irq continues to get pure polled behavior; the pin is simply initialized but otherwise inert.
All existing APIs, error codes, CANMessage fields, begin args, debug, send, status, RTR/ext fixes, etc. are 100% preserved. wait_for_rx and the use_irq kwarg are additive only.
Bitrates
Common vehicle rates:
- 500000 (most cars, including many Honda powertrains)
- 250000 (some older vehicles, e.g. certain Volvos)
Pass the correct clock for your MCP2515 crystal (usually 8000000).
Supported crystals and bitrates (expanded)
- 8 MHz: 1M, 500k, 250k, 125k, 100k, 50k, 20k
- 16 MHz: 1M, 500k, 250k, 125k, 100k, 50k, 20k
- 20 MHz: 1M, 500k, 250k, 125k, 100k, 50k, 20k
- 4 MHz (cheap modules): 500k, 250k, 125k, 100k, 50k, 20k
All via the BITRATE table in mcp2515/constants.py (lookup by (clock, bitrate) in begin()).
compute_cnf helper
For custom rates or validation:
from mcp2515.constants import compute_cnf
cnf1, cnf2, cnf3 = compute_cnf(4000000, 250000) # 250k @ 4MHz xtal
# or with explicit segments (must yield exact BRP, total TQ 4-25):
cnf = compute_cnf(16000000, 1000000, sjw=1, prop_seg=1, ps1=3, ps2=3, sam=0)
- Validates inputs; raises ValueError for impossible combos (use table entries for legacy non-exact cases).
- Uses integer-only math; sets BTLMODE=1 and matches table style for CNF3 bit7.
- Does not change
begin()behavior (still table-driven for efficiency + no-calc on constrained device); helper is for advanced use or future table growth. - See
compute_cnfsource/docstring for details.
See examples/basic.py (combined advanced example) for usage of compute_cnf together with IRQ, filters, and recovery (begin non-NORMAL).
Debugging Receive Issues
The driver includes helpers that were extremely useful during vehicle development:
can.print_status()
# or
from mcp2515.constants import ERROR
err, msg = can.recv()
Advanced Debug Helpers (ported)
Optional helpers now ported/adapted into the library (top-level debug_can_receive(can, ...) style + debug_dump_registers(can), plus equivalent methods on the instance). These rapidly sample/print INTF/EFLG/RXB state or do extended raw dumps (TEC/REC + full CNF1/2/3 + more) for "why no frames?" diagnosis. They integrate with enable_debug_recv(True) (the per-poll hook inside recv remains unchanged).
from mcp2515 import MCP2515, debug_can_receive, debug_dump_registers
# ...
can.enable_debug_recv(True)
debug_can_receive(can, iterations=50, delay_ms=1) # top-level (or can.debug_can_receive)
debug_dump_registers(can)
# can.debug_dump_registers()
can.enable_debug_recv(False)
See examples/basic.py (comments + combined advanced demo section that calls them) and the docstrings in mcp2515/__init__.py. All are optional, well-documented, and preserve 100% of prior behavior.
Error Handling
Integer ERROR codes from begin/send/recv/set_*/clear_filters are unchanged (0=OK etc; see constants.py) for speed/compat. New richer support:
can.last_erroris set automatically on every error-returning call (init OK).can.strerror(code=None)(orerror_string()) returns human text for a code orlast_error.- Lightweight exceptions in
mcp2515(andconstants):MCP2515Error(base),BusOffError,WrongBitrateError,InitError,AllTxBusyError,NoMessageError. UseMCP2515Error.from_code(err)factory. get_status()/print_status()now richer: includes"tec","rec"(counters at REG 0x1C/0x1D), plustx_warning/rx_warning/error_warning,errif/merrf(plus prior keys likebus_offunchanged).- Usage:
from mcp2515 import MCP2515, MCP2515Error from mcp2515.constants import ERROR err = can.begin(...) if err != ERROR.OK: print(can.strerror(err)) # or can.last_error # raise MCP2515Error.from_code(err) st = can.get_status(); print(st["tec"], st["rec"]) can.print_status() # shows TEC/REC + new flags with colors
Bus-Off Recovery
check_and_recover() (called automatically from send() and recv()) detects bus-off via get_status()["bus_off"] (EFLG.TXBO).
Improvements:
- Remembers the
modepassed tobegin()(e.g.LISTENONLY=0x60,LOOPBACK=0x40,NORMAL=0x00) asself._original_mode. - Recovery sequence (same as before but improved): reset, CONFIG, re-apply CNFs from
_last_cnf, re-apply RXM+BUKT buffers (prevents deafness), set the original mode (notMODE.NORMAL), 50ms sleep. - Re-verifies the mode after recovery using
(CANSTAT & 0xE0) != target_mode(mirrors the check inbegin()); warns on mismatch but proceeds. - Handles pre-
begin()or missing state: prints diagnostic and skips (avoids acting on uninitialized chip). set_mode()/ helpers are for manual runtime changes; recovery targets thebegin()mode.- Manual trigger:
can.check_and_recover(); monitor withcan.print_status()orget_status().
This preserves all prior behavior, recent fixes (ext ID, RTR, RX config), debug features, etc.
Acceptance Filters and Masks (High Priority Item #2)
By default, begin() (and recovery) put the MCP2515 into promiscuous "receive all" mode:
RXM1|RXM0 set in both RXBnCTRL (bypasses filters per datasheet) + masks zeroed.
This is unchanged for backward compat and is great for sniffing/diagnostics.
To enable hardware-level filtering (frames that don't match are dropped by the controller, never reach recv()):
from machine import Pin, SPI
from mcp2515 import MCP2515, CANMessage
# (also: from mcp2515.constants import ERROR)
can = MCP2515(spi, cs)
can.begin(bitrate=500000, clock=8000000)
# Accept *only* std 11-bit ID 0x123 (use full mask for exact match):
can.set_acceptance_mask(0, 0x7FF) # mask for std IDs
can.set_filter(0, 0x123, mask_idx=0) # filter 0 governed by mask 0
# Or for a family: looser mask e.g. 0x7F0 matches 0x120-0x12F range etc.
# can.set_acceptance_mask(0, 0x7F0)
# can.set_filter(0, 0x120, mask_idx=0)
# Extended (29-bit) example (note mask_idx=1 + filter idx>=2 for mask1):
can.set_acceptance_mask(0, 0x1FFFFFFF, extended=True)
can.set_filter(2, 0x18DAF110, mask_idx=1, extended=True)
# Multiple filters ok under one mask (e.g. accept 0x123 or 0x124):
# can.set_filter(1, 0x124, mask_idx=0)
# Return to default promiscuous at any time:
can.clear_filters()
# (or just call begin() again, which resets everything including to receive-all)
Notes:
- Call
set_*/clear_filters()afterbegin()(or after any re-begin). set_acceptance_mask(idx=0|1, mask, extended=False)set_filter(idx=0..5, can_id, mask_idx=0|1, extended=False)mask_idxdocuments the mask association (0 for filters 0/1, 1 for 2-5); hardware enforces it.- The set methods automatically reconfigure RXBnCTRL to RXM=00 (use filters) while keeping BUKT.
- On error (bad idx): returns
ERROR.INVALID_ARG(0 otherwise). Safe to ignore return. - Extended IDs set the EXIDE bit (and mask bit) using the exact same packing as the send/recv ext fixes.
- Default remains receive-all unless you call a set_ method.
clear_filters()restores it explicitly. - Bus-off recovery forces receive-all (existing behavior); re-apply your filter config after if desired.
- All other behavior (ext, RTR, debug, IRQ, recover, status, ...) 100% preserved.
- See
examples/basic.pyandmcp2515/constants.py(FILTER, RX, REG for the RXF*/RXM* + EID addrs).
License
MIT
Background & Motivation
I was sick of other MCP2515 MicroPython repos being abandoned, full of inconsistencies, and requiring heavy troubleshooting and modification just to get basic send/receive working reliably on real vehicles. Many had broken RX logic, incorrect buffer config, or hard-coded assumptions that didn't match the datasheet or actual hardware behavior (especially the critical RXM + BUKT + proper INTF handling).
This library was extracted and hardened from real vehicle reverse-engineering work (see the parent esp32-car-tool project) where reliable CAN was non-negotiable.
Credits
Extracted from the esp32-car-tool project (a portable vehicle diagnostic / reverse engineering tool). Many thanks to the community for the original reverse-engineering that exposed the bugs in other drivers.