Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ jobs:
target: esp32
- path: 'components/byte90/example'
target: esp32s3
- path: 'components/canopen/example'
target: esp32
- path: 'components/chsc6x/example'
target: esp32s3
- path: 'components/cdr/example'
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/upload_components.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ jobs:
components/bmi270
components/button
components/byte90
components/canopen
components/chsc6x
components/cdr
components/cli
Expand Down
7 changes: 7 additions & 0 deletions components/canopen/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# NOTE: like odrive_native, this component's detail/ lives INSIDE include/
# (include/detail/canopen_core.hpp), so registering "include" alone makes
# `#include "detail/canopen_core.hpp"` resolve for consumers.
idf_component_register(
INCLUDE_DIRS "include"
REQUIRES base_component
)
47 changes: 47 additions & 0 deletions components/canopen/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# CANopen (CiA 301) Client Component

[![Badge](https://components.espressif.com/components/espp/canopen/badge.svg)](https://components.espressif.com/components/espp/canopen)

The `CanopenClient` class provides a lightweight, standards-based CANopen
(CiA 301) client / master for talking to a CANopen server node - for example a
Basicmicro MCP236/MCP266 motor controller - over a classic CAN 2.0 bus. The
`Ds402Drive` class layers the CiA 402 (DS402) drive profile on top of it:
statusword state-machine decoding, the enable-operation sequence, fault reset,
mode selection, and profile-velocity / profile-position motion helpers.

Implemented CiA 301 services (deliberately slim):

* **NMT master** commands (COB-ID `0x000`): start / stop / pre-operational /
reset node / reset communication, addressed to one node or all nodes.
* **Heartbeat / boot-up consumption** (COB-ID `0x700` + node id): the NMT state
of every producing node is cached (with an optional callback).
* **SDO client** (COB-IDs `0x600`/`0x580` + node id): expedited upload
(read) and download (write) of 1/2/4-byte objects with typed
`read_u8..read_i32` / `write_u8..write_i32` wrappers, segmented upload for
strings (e.g. manufacturer device name `0x1008`) with toggle-bit handling,
and abort-code parsing with human-readable messages.
* **PDO helpers**: RPDO transmit (pack + send on a COB-ID) and TPDO reception
dispatch via per-COB-ID callbacks.
* **SYNC** (COB-ID `0x080`) transmission.

The component is **transport-agnostic**: it transmits by invoking a
user-provided `send` function with a plain `espp::detail::CanFrame`, and the
application feeds received frames to `process_frame()`. The `CanFrame` struct
mirrors `espp::Twai::Message` field-for-field, so wiring it to the `espp/twai`
component is a two-line conversion (see the example) - but any CAN transport
(external SPI CAN controller, USB-CAN bridge, ...) works just as well.

SDO transactions are blocking with a configurable timeout; `process_frame()`
must be called from a different task than the one performing SDO transfers
(automatic with `espp::Twai`, whose `on_receive` runs in its own task).

The wire core (`include/detail/canopen_core.hpp`) is host-buildable pure C++20
with no ESP dependencies, and is covered by golden-frame unit tests in
`test/canopen_host_test.cpp`.

## Example

The [example](./example) uses an `espp::Twai` transport to NMT-start a node,
read its identity and device type via SDO, and - if the device implements
CiA 402 - switch it to profile velocity mode, enable operation, run a gentle
velocity ramp, and stop.
22 changes: 22 additions & 0 deletions components/canopen/example/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# The following lines of boilerplate have to be in your project's CMakeLists
# in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.20)

set(ENV{IDF_COMPONENT_MANAGER} "0")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)

# add the component directories that we want to use
set(EXTRA_COMPONENT_DIRS
"../../../components/"
)

set(
COMPONENTS
"main esptool_py canopen twai"
CACHE STRING
"List of components to include"
)

project(canopen_example)

set(CMAKE_CXX_STANDARD 20)
68 changes: 68 additions & 0 deletions components/canopen/example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# CANopen Client Example

This example demonstrates the use of the `espp::CanopenClient` and
`espp::Ds402Drive` classes to talk to a CANopen (CiA 301) server node - for
example a Basicmicro MCP236/MCP266 motor controller - over the ESP TWAI
(CAN 2.0) peripheral via the `espp::Twai` class.

It:

* Brings up the TWAI peripheral in `NORMAL` mode and wires its task-context
`on_receive` callback to `CanopenClient::process_frame()` (the
transport-agnostic `CanFrame` struct mirrors `espp::Twai::Message`
field-for-field).
* Sends an **NMT start** to a configurable node id.
* **SDO-reads** the standard identification objects: device type (`0x1000`),
identity (`0x1018` vendor / product / revision / serial), and the
manufacturer device name (`0x1008`, a string read via **segmented** SDO
upload with toggle-bit handling).
* If the device reports the CiA 402 device profile: selects **profile velocity
mode**, walks the CiA 402 state machine to **Operation Enabled** (with an
automatic fault reset if needed), runs a gentle velocity ramp up and back
down while logging the actual velocity, then stops and disables the drive.

## How to use example

### Hardware Required

An ESP chip with a TWAI peripheral (e.g. ESP32 or ESP32-S3), a 3.3V CAN
transceiver (e.g. SN65HVD230, TJA1050, MCP2551) wired between the configured
TX/RX GPIOs and the bus, and a CANopen device on a properly (120Ω) terminated
bus. Update the `node_id`, GPIOs, and baudrate at the top of the example to
match your setup (Basicmicro MCP2xx controllers default to 250 kbit/s).

```
ESP32 GPIO(tx) ---> CTX \
SN65HVD230 ==> CANH / CANL (120R terminated bus)
ESP32 GPIO(rx) <--- CRX /
```

### Build and Flash

```
idf.py set-target esp32
idf.py -p PORT flash monitor
```

(To exit the serial monitor, type ``Ctrl-]``.)

## Example Output

```
[CANopen Example/I][0.518]: Starting CANopen (CiA 301) client example!
[CANopen Example/I][0.530]: Sent NMT start to node 1
[CANopen Example/I][0.735]: Device type (0x1000): 0x00020192
[CANopen Example/I][0.740]: Vendor id (0x1018:1): 0x00000123
[CANopen Example/I][0.746]: Product code (0x1018:2): 0x00000266
[CANopen Example/I][0.752]: Revision (0x1018:3): 0x00010000
[CANopen Example/I][0.758]: Serial number (0x1018:4): 0x0000BEEF
[CANopen Example/I][0.770]: Device name (0x1008): 'MCP266 2x60A'
[CANopen Example/I][0.776]: Drive state: Switch on disabled
[Ds402Drive/I][0.850]: enable_operation: starting from state 'Switch on disabled'
[Ds402Drive/I][1.050]: enable_operation: drive is in Operation Enabled
[CANopen Example/I][1.560]: target= 100, actual= 98
...
[CANopen Example/I][6.560]: target= 0, actual= 1
[CANopen Example/I][6.660]: Motion demo complete
[CANopen Example/I][6.665]: CANopen example complete!
```
2 changes: 2 additions & 0 deletions components/canopen/example/main/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
idf_component_register(SRC_DIRS "."
INCLUDE_DIRS ".")
182 changes: 182 additions & 0 deletions components/canopen/example/main/canopen_example.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#include <chrono>
#include <thread>

#include "canopen_client.hpp"
#include "ds402.hpp"
#include "twai.hpp"

using namespace std::chrono_literals;

extern "C" void app_main(void) {
// static: captured by the (static) client's heartbeat callback, which must
// outlive any early return from app_main()
static espp::Logger logger({.tag = "CANopen Example", .level = espp::Logger::Verbosity::INFO});
logger.info("Starting CANopen (CiA 301) client example!");

//! [canopen example]
// The CANopen node id of the device we want to talk to (e.g. a Basicmicro
// MCP236/MCP266 motor controller). Change to match your device.
static constexpr uint8_t node_id = 1;

// Forward-declared handle so the Twai on_receive callback (registered at
// Twai construction) can feed frames to the client we construct just below.
static espp::CanopenClient *client_ptr = nullptr;

// Bring up the TWAI (CAN 2.0) peripheral. NOTE: talking to a real CANopen
// device requires Mode::NORMAL with a 3.3V CAN transceiver (e.g. SN65HVD230)
// wired to the tx/rx GPIOs, a properly terminated bus, and a matching
// baudrate (Basicmicro MCP2xx default is 250 kbit/s).
//
// The on_receive callback runs in the Twai receive task, i.e. NOT in the
// task performing the (blocking) SDO transactions below -- which is exactly
// what CanopenClient::process_frame() requires. The CanFrame struct mirrors
// espp::Twai::Message field-for-field, so conversion is trivial.
// NOTE: twai and client are function-local STATICS: the Twai receive task
// and the client's send lambda (which captures &twai) reference them, and
// app_main() has early-return error paths -- static storage guarantees they
// outlive every callback regardless of how app_main() exits.
static espp::Twai twai({
.tx_gpio = 5, // GPIO5 (change to match your board / transceiver)
.rx_gpio = 4, // GPIO4 (change to match your board / transceiver)
.baudrate = 250000,
.mode = espp::Twai::Mode::NORMAL,
.tx_queue_depth = 5,
.on_receive =
[](const espp::Twai::Message &msg) {
if (client_ptr) {
client_ptr->process_frame(espp::CanopenClient::CanFrame{
.id = msg.id,
.extended = msg.extended,
.rtr = msg.rtr,
.dlc = msg.dlc,
.data = msg.data,
});
}
},
.log_level = espp::Logger::Verbosity::INFO,
});

// The CANopen client is transport-agnostic: give it a send function which
// transmits an espp::detail::CanFrame (here: over TWAI). The CanFrame struct
// mirrors espp::Twai::Message field-for-field, so conversion is trivial.
static espp::CanopenClient client({
.node_id = node_id,
// captureless: twai has static storage duration and is referenced
// directly (capturing a static is ill-formed under -Werror)
.send =
[](const espp::CanopenClient::CanFrame &frame) {
espp::Twai::Message msg{
.id = frame.id,
.extended = frame.extended,
.rtr = frame.rtr,
.dlc = frame.dlc,
.data = frame.data,
};
std::error_code tx_ec;
return twai.transmit(msg, tx_ec);
},
.sdo_timeout = 100ms,
.on_heartbeat =
// captureless: logger has static storage duration (see above)
[](uint8_t hb_node, espp::CanopenClient::NmtState state) {
logger.info("Heartbeat from node {}: NMT state {}", hb_node, static_cast<int>(state));
},
.log_level = espp::Logger::Verbosity::INFO,
});
client_ptr = &client;

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

// NMT: put the node into Operational so its PDOs (if any) are active.
if (!client.nmt_start(ec)) {
logger.error("Failed to send NMT start: {}", ec.message());
return;
}
logger.info("Sent NMT start to node {}", node_id);
std::this_thread::sleep_for(100ms);

// SDO: read the standard identification objects.
espp::Ds402Drive drive(
client,
{.state_timeout = 1s, .poll_period = 20ms, .log_level = espp::Logger::Verbosity::INFO});

auto device_type = drive.get_device_type(ec);
if (ec) {
logger.error("Failed to read device type (0x1000): {} -- is the node on the bus?",
ec.message());
return;
}
logger.info("Device type (0x1000): 0x{:08X}", device_type);
// device profile number is in the lower 16 bits; 402 => a CiA 402 drive
const bool is_ds402 = (device_type & 0xFFFF) == 402;

logger.info("Vendor id (0x1018:1): 0x{:08X}", drive.get_vendor_id(ec));
logger.info("Product code (0x1018:2): 0x{:08X}", drive.get_product_code(ec));
logger.info("Revision (0x1018:3): 0x{:08X}", drive.get_revision_number(ec));
logger.info("Serial number (0x1018:4): 0x{:08X}", drive.get_serial_number(ec));
// manufacturer device name (0x1008) is a string -> segmented SDO upload
auto name = drive.get_device_name(ec);
if (!ec) {
logger.info("Device name (0x1008): '{}'", name);
}

if (!is_ds402) {
logger.warn("Device does not report the CiA 402 profile; skipping motion demo");
} else {
// DS402: profile velocity mode, enable, gentle ramp, stop, disable.
if (auto state = drive.get_state(ec); !ec) {
logger.info("Drive state: {}", espp::detail::ds402::state_to_string(state));
if (state == espp::Ds402Drive::State::Fault) {
logger.info("Drive is in Fault; attempting fault reset");
if (!drive.fault_reset(ec)) {
logger.error("Fault reset failed: {}", ec.message());
return;
}
}
}

if (!drive.set_mode(espp::Ds402Drive::OperatingMode::ProfileVelocity, ec)) {
logger.error("Failed to set profile velocity mode: {}", ec.message());
return;
}
// conservative profile accel / decel (device units)
drive.set_profile_acceleration(1000, ec);
drive.set_profile_deceleration(1000, ec);

if (!drive.enable_operation(ec)) {
logger.error("Failed to enable operation: {}", ec.message());
return;
}

// gentle velocity ramp up and back down
static constexpr int32_t max_velocity = 500; // device units, keep it gentle
static constexpr int32_t step = 100;
for (int32_t v = step; v <= max_velocity; v += step) {
drive.set_target_velocity(v, ec);
std::this_thread::sleep_for(500ms);
logger.info("target={:4}, actual={:4}", v, drive.get_velocity_actual(ec));
}
for (int32_t v = max_velocity - step; v >= 0; v -= step) {
drive.set_target_velocity(v, ec);
std::this_thread::sleep_for(500ms);
logger.info("target={:4}, actual={:4}", v, drive.get_velocity_actual(ec));
}

// stop and disable the power stage
drive.set_target_velocity(0, ec);
if (!drive.disable(ec)) {
logger.error("Failed to disable drive: {}", ec.message());
}
logger.info("Motion demo complete");
}
//! [canopen example]

logger.info("CANopen example complete!");
while (true) {
std::this_thread::sleep_for(1s);
}
}
4 changes: 4 additions & 0 deletions components/canopen/example/sdkconfig.defaults
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Common ESP-related
#
CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
26 changes: 26 additions & 0 deletions components/canopen/idf_component.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
## IDF Component Manager Manifest File
license: "MIT"
description: "Lightweight CANopen (CiA 301) client / master with a DS402 (CiA 402) drive-profile helper"
url: "https://github.com/esp-cpp/espp/tree/main/components/canopen"
repository: "https://github.com/esp-cpp/espp.git"
maintainers:
Comment thread
finger563 marked this conversation as resolved.
- William Emfinger <waemfinger@gmail.com>
documentation: "https://esp-cpp.github.io/espp/buses/canopen.html"
examples:
- path: example
tags:
- cpp
- Component
- CANopen
- CAN
- DS402
- CiA301
- CiA402
- NMT
- SDO
- PDO
- Motor
dependencies:
idf:
version: '>=5.0'
espp/base_component: '>=1.0'
Loading
Loading