-
Notifications
You must be signed in to change notification settings - Fork 29
feat(canopen): lightweight CANopen client + DS402 drive helper #730
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
finger563
wants to merge
5
commits into
main
Choose a base branch
from
feat/canopen
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
bfdea7a
feat(canopen): lightweight CANopen client + DS402 drive helper
finger563 d3d7d77
fix(canopen): address PR #730 review comments
finger563 b7c930a
fix(canopen): reject non-SDO frame types + static example lifetimes (…
finger563 9dc0225
fix(canopen): example statics must not be lambda-captured
finger563 4e82a6f
fix(canopen): cap remote-supplied segmented-upload sizes (PR #730 rev…
finger563 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| # CANopen (CiA 301) Client Component | ||
|
|
||
| [](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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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! | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| idf_component_register(SRC_DIRS "." | ||
| INCLUDE_DIRS ".") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: | ||
| - 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' | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.