TWAI (CAN 2.0) APIs

The Twai class provides an idiomatic C++ interface to the ESP TWAI (Two-Wire Automotive Interface, i.e. CAN 2.0) peripheral. It wraps the modern node-based ESP-IDF driver (esp_driver_twai) and takes care of creating an on-chip TWAI node, registering the driver’s ISR event callbacks, and marshaling received frames (and optional error / state-change events) from ISR context into a task-context callback via an internal FreeRTOS queue and an espp::Task.

Because the driver’s on_rx_done / on_error / on_state_change callbacks run in ISR context, the Twai class copies each event into a FreeRTOS queue from the ISR (xQueueSendFromISR) and drains that queue from an internal task. Your on_receive, on_error, and on_state_change callbacks are therefore always invoked from task context - never from the ISR - so they may safely call blocking or non-IRAM-safe APIs. No lock is held while your callback runs.

The class supports the classic CAN 2.0 frame format (up to 8 data bytes). CAN-FD is a possible future extension; the underlying driver and frame types support it, but this wrapper intentionally keeps to classic CAN for a small, clean surface.

The example uses Mode::LOOPBACK (internal loopback + self-test) so it runs on a bare devkit with no CAN transceiver and no other node on the bus. To talk to a real bus, use Mode::NORMAL with a 3.3V CAN transceiver (e.g. SN65HVD230) wired to the TX/RX GPIOs and at least one other node present to acknowledge frames.

Transmit result

transmit(message, ec) waits for the controller to finish with the frame and reports the outcome through its std::error_code parameter: std::errc::io_error when the controller gave up on it after the configured retry limit (Config::tx_retry_count, 0 means one attempt) was exhausted by a missing acknowledgement, a bit error, or arbitration lost (Config::on_error carries the reason) – or std::errc::timed_out when, with tx_retry_count = -1 (retransmit until acknowledged, the standard CAN behaviour), nothing acknowledged the frame within the timeout. A frame for which transmit() returns true was acknowledged by another node in Mode::NORMAL (Mode::LOOPBACK waives the acknowledgement). The default single attempt suits time-critical data that must not arrive late; set a retry count, or -1, when a frame must get through.

Pending transmissions

transmit() is synchronous, so once it returns nothing of yours is normally left with the controller. The exception is a frame whose completion wait timed out: with tx_retry_count = -1 and nothing acknowledging, the controller keeps retransmitting it. The ESP-IDF driver offers no abort for that (disabling the node only pauses the transmission; re-enabling resumes it), so espp::Twai provides flush(ec, timeout_ms) – wait until the controller has no pending transmission, e.g. before a stop that must not be followed by anything older – and abort_pending(ec) – drop the pending transmission(s) by deleting and re-creating the node (same configuration, callbacks and filter; the receive task and everything already received are untouched). Config::auto_abort_on_timeout (default true) makes a transmit() that timed out drop its frame that way; with it off the frame stays with the controller and the next transmit() first waits for it (up to its own timeout, failing with std::errc::timed_out while it is still pending).

API Reference

Header File

Classes

class Twai : public espp::BaseComponent

A class to interface with the ESP TWAI (CAN 2.0) peripheral.

This class wraps the modern node-based ESP-IDF TWAI driver (esp_driver_twai) in an idiomatic C++ interface. It creates an on-chip TWAI node, registers ISR event callbacks, and marshals received frames (and optional error / state-change events) from ISR context into a task-context callback using an internal FreeRTOS queue and an espp::Task.

All user callbacks (on_receive, on_error, on_state_change) are invoked from the internal task context - never from the ISR - so they may safely call blocking / non-IRAM-safe APIs. No lock is held while a user callback is invoked.

The class supports the classic CAN 2.0 frame format (up to 8 data bytes). CAN-FD (up to 64 data bytes, bit-rate switching) is a possible future extension; the underlying driver and frame types support it, but this wrapper intentionally keeps to classic CAN for a small, clean surface.

TWAI (loopback) Example

  // Count the frames we receive back, so we can assert the round-trip.
  static std::atomic<int> num_received{0};

  // The receive callback runs in task context (not ISR). It is invoked for
  // every frame the node receives - in loopback mode that is every frame we
  // transmit.
  auto on_receive = [&](const espp::Twai::Message &msg) {
    num_received++;
    logger.info("RX: {}", msg);
  };

  auto on_state_change = [&](const espp::Twai::StateChange &sc) {
    logger.warn("State change: {} -> {}", static_cast<int>(sc.old_state),
                static_cast<int>(sc.new_state));
  };

  // NOTE: We use Mode::LOOPBACK (internal loopback + self-test) so this example
  // runs on a bare devkit with NO CAN transceiver and NO other node on the bus.
  // To talk to a real bus, use Mode::NORMAL with a transceiver (e.g.
  // SN65HVD230) wired to the tx/rx GPIOs, and make sure at least one other node
  // is present to acknowledge frames.
  espp::Twai twai({
      .tx_gpio = 5, // GPIO5 (change to match your board / transceiver)
      .rx_gpio = 4, // GPIO4 (change to match your board / transceiver)
      .baudrate = 500000,
      .mode = espp::Twai::Mode::LOOPBACK,
      .tx_queue_depth = 5,
      .on_receive = on_receive,
      .on_state_change = on_state_change,
      .log_level = espp::Logger::Verbosity::INFO,
  });

  std::error_code ec;
  if (!twai.initialize(ec)) {
    logger.error("Failed to initialize TWAI: {}", ec.message());
    return;
  }

  // Transmit a few frames; in loopback mode each is received back via on_receive.
  static constexpr int num_to_send = 5;
  for (int i = 0; i < num_to_send; i++) {
    espp::Twai::Message msg;
    msg.id = 0x100 + i;
    msg.extended = false;
    msg.rtr = false;
    msg.dlc = 4;
    msg.data = {static_cast<uint8_t>(i), 0xDE, 0xAD, 0xBE, 0, 0, 0, 0};
    if (twai.transmit(msg, ec)) {
      logger.info("TX: {}", msg);
    } else {
      logger.error("Failed to transmit frame {}: {}", i, ec.message());
    }
    std::this_thread::sleep_for(50ms);
  }

  // Make sure the controller has nothing left to send. transmit() waits for
  // each frame, so this returns at once here; it matters after a transmit()
  // that timed out with Config::auto_abort_on_timeout off, which leaves the
  // frame with the controller (abort_pending() drops such a frame instead).
  if (!twai.flush(ec)) {
    logger.error("Failed to flush pending transmissions: {}", ec.message());
  }

  // give the RX task a moment to drain the queue
  std::this_thread::sleep_for(200ms);

  logger.info("Sent {} frames, received {} frames", num_to_send, num_received.load());

  // print out the node status / statistics
  twai_node_status_t status;
  twai_node_record_t record;
  if (twai.get_info(status, record, ec)) {
    logger.info("Node state={}, tx_err={}, rx_err={}, bus_err_num={}",
                static_cast<int>(status.state), status.tx_error_count, status.rx_error_count,
                record.bus_err_num);
  }

  // assert the round trip
  if (num_received.load() == num_to_send) {
    logger.info("SUCCESS: all {} frames were received back in loopback mode", num_to_send);
  } else {
    logger.error("FAILURE: expected {} frames, got {}", num_to_send, num_received.load());
  }

Public Types

enum class Mode

The operating mode of the TWAI node.

Values:

enumerator NORMAL

Normal mode: transmit, receive, and acknowledge frames on the bus. Requires a transceiver and at least one other acknowledging node.

enumerator LISTEN_ONLY

Listen-only mode: the node only monitors the bus and never transmits or acknowledges. Useful for passive bus monitoring / sniffing.

enumerator LOOPBACK

Loopback self-test mode: the controller receives back the frames it transmits and does not require acknowledgement. This lets the node run with no transceiver and no other node on the bus (used by the example).

typedef std::function<void(const Message&)> receive_callback_fn

Receive callback type.

typedef std::function<void(const StateChange&)> state_change_callback_fn

State-change callback type.

typedef std::function<void(twai_error_flags_t)> error_callback_fn

Error callback type.

Public Functions

inline explicit Twai(const Config &config)

Construct a new Twai object.

Note

This does not touch the hardware; call initialize() to create the node and start the receive task.

Parameters:

config – The configuration for the TWAI node.

inline ~Twai()

Destructor. Stops the receive task and disables + deletes the node.

inline bool initialize(std::error_code &ec)

Initialize the TWAI node.

Creates the on-chip node, registers ISR event callbacks, applies the optional acceptance filter, creates the internal event queue and receive task, and (if Config::auto_start is true) enables the node.

Parameters:

ec – The error code, set if initialization fails.

Returns:

True on success, false on failure.

inline bool flush(std::error_code &ec, int timeout_ms = DEFAULT_TX_TIMEOUT_MS)

Wait until the controller has no pending transmission: its TX queue is empty and the frame in progress (if any) has completed or failed.

transmit() already waits for its own frame, so normally nothing is pending once it returns. What can be pending is a frame whose transmit() timed out with Config::auto_abort_on_timeout off (the controller keeps retransmitting it, tx_retry_count = -1, until something acknowledges); flush() is how a caller waits for such a frame to go out &#8212; e.g. before a stop that must not be followed by anything older. Note the driver offers no way to *drop* a queued frame short of re-creating the node, which is what abort_pending() does.

Parameters:
  • ec – The error code, set if the wait failed: operation_not_permitted (node not initialized, or not enabled while a frame is pending, or bus-off), timed_out (still pending after timeout_ms).

  • timeout_ms – Max time (ms) to wait, -1 = forever. Serialized with transmit() and abort_pending() (a transmit() in progress finishes or times out first; neither can start until this returns). Prefer a bound: a frame nothing acknowledges never completes, so a forever wait on such a bus returns only once another node ACKs it, and stop() / the destructor wait behind it too.

Returns:

True once nothing is pending, false otherwise.

inline bool abort_pending(std::error_code &ec)

Drop every pending transmission: the frame in progress and any queued behind it.

The driver has no abort: disabling the node only pauses the transmission in progress and re-enabling resumes it. The only way to make the controller forget a frame is to delete the node and create it again, which is what this does (same configuration, callbacks and filter; the node comes back enabled if it was). The receive task and event queue are untouched, so nothing already received is lost; frames arriving during the few hundred microseconds the node is gone are lost. Waits for a transmit() or flush() in progress on another task to finish (or time out) first, so it never pulls the node from under one.

Parameters:

ec – The error code, set if the node could not be re-created (it is then gone: transmit() fails with operation_not_permitted until initialize() is called again, which re-creates just the node) or re-enabled.

Returns:

True if nothing is pending any more, false otherwise.

inline bool start(std::error_code &ec)

Enable (start) the TWAI node so it participates on the bus.

Parameters:

ec – The error code, set if the node could not be enabled.

Returns:

True on success, false otherwise.

inline bool stop(std::error_code &ec)

Disable (stop) the TWAI node.

Parameters:

ec – The error code, set if the node could not be disabled.

Returns:

True on success, false otherwise.

inline bool transmit(const Message &message, std::error_code &ec, int timeout_ms = DEFAULT_TX_TIMEOUT_MS)

Transmit a CAN message and wait for it to complete.

twai_node_transmit() only QUEUES the transmission: the driver keeps the passed twai_frame_t pointer (and its data buffer) and formats the frame later, in the TX ISR. The descriptor must therefore stay valid until the on_tx_done callback fires &#8212; a stack-local frame is read after the caller’s stack is gone, transmitting garbage (or tripping the driver’s DLC assert). This method copies the message into member storage that outlives the call, serializes transmitters, and blocks until the driver reports transmission complete.

Parameters:
  • message – The message to transmit.

  • ec – The error code, set if the message was not transmitted: operation_not_permitted (node not initialized / enabled), invalid_argument (DLC > 8), timed_out (the frame could not be queued, or it was not acknowledged within the timeout while the controller kept retransmitting &#8212; Config::tx_retry_count = -1 &#8212;, or an earlier frame left pending by such a timeout, with Config::auto_abort_on_timeout off, is still pending), or io_error (the controller gave up on the frame: the retries of a bounded Config::tx_retry_count were exhausted, a bit error, or arbitration lost; Config::on_error carries the reason). On a completion timeout the pending frame is dropped (Config::auto_abort_on_timeout, the default) or left with the controller; see flush() / abort_pending().

  • timeout_ms – Max time (ms) to wait to queue the frame (-1 = forever for the queueing step). The subsequent wait for transmit completion is always bounded (by this value when >= 0, else by DEFAULT_TX_TIMEOUT_MS) so an unacknowledged frame cannot hang the caller.

Returns:

True if the message was transmitted (in Mode::NORMAL: acknowledged by another node), false otherwise.

inline bool recover(std::error_code &ec)

Begin bus-off recovery.

Starts the recovery process for a node in the bus-off state. Use the on_state_change callback or get_status() to know when recovery has finished.

Parameters:

ec – The error code, set if recovery could not be started.

Returns:

True if recovery was started, false otherwise.

inline bool get_status(twai_node_status_t &status, std::error_code &ec)

Get the current node status (error state, error counters, TX queue space).

Parameters:
  • status – The status structure to fill in.

  • ec – The error code, set on failure.

Returns:

True on success, false otherwise.

inline bool get_statistics(twai_node_record_t &record, std::error_code &ec)

Get the node statistics (cumulative bus error count).

Parameters:
  • record – The statistics structure to fill in.

  • ec – The error code, set on failure.

Returns:

True on success, false otherwise.

inline bool get_info(twai_node_status_t &status, twai_node_record_t &record, std::error_code &ec)

Get both the node status and statistics.

Parameters:
  • status – The status structure to fill in.

  • record – The statistics structure to fill in.

  • ec – The error code, set on failure.

Returns:

True on success, false otherwise.

inline bool is_enabled() const

Whether the node is currently enabled (started).

Returns:

True if the node is enabled, false otherwise.

inline const std::string &get_name() const

Get the name of the component

Note

This is the tag of the logger

Returns:

A const reference to the name of the component

inline void set_log_tag(const std::string_view &tag)

Set the tag for the logger

Parameters:

tag – The tag to use for the logger

inline espp::Logger::Verbosity get_log_level() const

Get the log level for the logger

Returns:

The verbosity level of the logger

inline void set_log_level(espp::Logger::Verbosity level)

Set the log level for the logger

Parameters:

level – The verbosity level to use for the logger

inline void set_log_verbosity(espp::Logger::Verbosity level)

Set the log verbosity for the logger

See also

set_log_level

Note

This is a convenience method that calls set_log_level

Parameters:

level – The verbosity level to use for the logger

inline espp::Logger::Verbosity get_log_verbosity() const

Get the log verbosity for the logger

See also

get_log_level

Note

This is a convenience method that calls get_log_level

Returns:

The verbosity level of the logger

inline void set_log_rate_limit(std::chrono::duration<float> rate_limit)

Set the rate limit for the logger

Note

Only calls to the logger that have _rate_limit suffix will be rate limited

Parameters:

rate_limit – The rate limit to use for the logger

Public Static Attributes

static constexpr size_t MAX_DATA_LEN = 8

Maximum number of data bytes in a classic CAN 2.0 frame.

static constexpr int DEFAULT_TX_TIMEOUT_MS = 100

Default timeout (ms) used when transmitting a frame.

struct Config

Configuration for the TWAI node.

Public Members

int tx_gpio = {-1}

GPIO number for TWAI TX. Must be set (validated).

int rx_gpio = {-1}

GPIO number for TWAI RX. Must be set (validated).

uint32_t baudrate = {500000}

Bus baud rate / bit rate in bits/second (e.g. 500000).

Mode mode = {Mode::NORMAL}

Operating mode of the node.

size_t tx_queue_depth = {5}

Depth of the hardware transmit queue.

int8_t tx_retry_count = {0}

Hardware retransmission limit for a frame that fails (no ACK, bit error, arbitration lost): 0 = single shot (the default: one attempt, then the frame is dropped and transmit() reports the failure &#8212; the behaviour espp::Twai has always had), 1..15 = that many retries, -1 = retransmit until it succeeds (standard CAN behaviour; transmit() bounds the wait with its timeout).

bool auto_abort_on_timeout = {true}

What transmit() does with a frame the controller has not finished with when the completion wait times out (typically: tx_retry_count = -1 and nothing on the bus acknowledges). true (the default): the pending transmission is dropped with abort_pending(), so the controller stops retransmitting it and the next transmit() proceeds at once. false: the frame is left with the controller (it may still go out when the bus comes back); the next transmit() first waits, up to its own timeout, for that frame to finish (as flush() does) and fails with timed_out while it is still pending. Call abort_pending() to drop it explicitly.

std::optional<Filter> filter = {}

Optional acceptance filter (default: accept all).

receive_callback_fn on_receive = {nullptr}

Called (in task context) for each received frame.

error_callback_fn on_error = {nullptr}

Optional: called (in task context) on a bus error.

state_change_callback_fn on_state_change{nullptr}

Optional: called (in task context) on an error-state change.

bool auto_start = {true}

If true, the node is enabled at the end of initialize().

size_t rx_queue_size = {16}

Size (number of events) of the internal ISR->task event queue.

Task::BaseConfig task_config  {.name = "Twai Task",.stack_size_bytes = 4096,.priority = 10,}

Configuration for the internal receive task.

espp::Logger::Verbosity log_level = {espp::Logger::Verbosity::WARN}

Log verbosity.

struct Filter

An acceptance (hardware) filter for received frames.

A frame is accepted if (received_id & mask) == (id & mask). A mask bit of 1 means the corresponding ID bit must match; a mask bit of 0 means “don’t care”. Therefore id = 0, mask = 0 accepts all frames.

Public Members

uint32_t id = {0}

The base ID to match.

uint32_t mask = {0}

The mask (1 = bit must match, 0 = don’t care).

bool extended = {false}

True to filter on extended (29-bit) IDs, false for standard (11-bit).

bool dual = {false}

Configure the filter as a dual 16-bit filter (advanced).

struct Message

A classic CAN 2.0 message / frame.

Public Functions

inline twai_frame_t to_twai_frame() const

Convert this Message into a driver twai_frame_t for transmission.

Note

The returned frame’s buffer points into this Message’s data array, and the driver reads both the frame and the buffer from the TX ISR after twai_node_transmit() queues them &#8212; so both must stay valid until the on_tx_done callback (transmit() guarantees this by using member storage and waiting for completion).

Returns:

A twai_frame_t describing this message.

Public Members

uint32_t id = {0}

Arbitration ID (11-bit standard, or 29-bit if extended is true).

bool extended = {false}

True for an extended (29-bit) ID, false for standard (11-bit) ID.

bool rtr = {false}

True if this is a Remote Transmission Request frame (no data).

uint8_t dlc = {0}

Data Length Code / number of valid data bytes (0-8).

std::array<uint8_t, MAX_DATA_LEN> data = {}

Frame payload (only the first dlc bytes valid).

Public Static Functions

static inline Message from_twai_frame(const twai_frame_t &frame)

Build a Message from a driver twai_frame_t.

Parameters:

frame – The driver frame (as returned by twai_node_receive_from_isr).

Returns:

A Message copy of the frame’s header + data.

struct StateChange

Data passed to the state-change callback.

Public Members

twai_error_state_t old_state

The previous error state.

twai_error_state_t new_state

The new error state.