Skip to content
Merged
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 @@ -77,6 +77,8 @@ jobs:
target: esp32
- path: 'components/basicmicro/example'
target: esp32
- path: 'components/mcp266/example'
target: esp32
- path: 'components/bdc_driver/example'
target: esp32s3
- path: 'components/binary-log/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 @@ -56,6 +56,7 @@ jobs:
components/base_component
components/base_peripheral
components/basicmicro
components/mcp266
components/bdc_driver
components/binary-log
components/bldc_current_sense
Expand Down
7 changes: 7 additions & 0 deletions components/mcp266/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# NOTE: like canopen, this component's detail/ lives INSIDE include/
# (include/detail/mcp266_core.hpp), so registering "include" alone makes
# `#include "detail/mcp266_core.hpp"` resolve for consumers.
idf_component_register(
INCLUDE_DIRS "include"
REQUIRES base_component canopen
)
63 changes: 63 additions & 0 deletions components/mcp266/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# MCP266 CANopen Motor Controller Component

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

The `Mcp266` class is a dual-channel controller for a Basicmicro **MCP266**
(RoboClaw family) brushed-DC motor driver over **CANopen**. It is layered on
`espp::CanopenClient` (like `espp::Ds402Drive`), so it is transport-agnostic:
the application owns the CAN transport, feeds received frames to the client's
`process_frame()`, and the client's node id selects the MCP266.

Both motor channels (`M1`, `M2`) are driven symmetrically. M2's CiA 402
objects mirror M1's at `+0x800`, handled through `Ds402Drive`'s object offset.

## What works, and what does not

**Position control** uses the standard CiA 402 profile position mode
(`move_to_position`) and is the supported, validated capability. It needs the
position loop configured first (`configure_position_loop`).

**Velocity / duty control is not functional** on the MCP266 firmware tested.
The standard target objects and the manufacturer speed/duty command mirror are
both accepted by the drive but leave the velocity generator idle even with the
drive in Operation Enabled. Supported-drive-modes (`0x6502`) advertises only
the cyclic-sync modes, so velocity likely requires csv mode with cyclic
SYNC/PDO updates, which is undocumented for this device. `drive_speed` /
`drive_duty` are implemented but are currently a no-op for motion; use position
mode.

## Device specifics

The MCP266's control-loop parameters are **not** standard CiA 402 objects. The
MCP mirrors its packet-serial command set into the manufacturer region of the
object dictionary at index `0x2000 + command number` (see
`include/detail/mcp266_core.hpp`, which is host-buildable and unit-tested).
This component uses that to:

* configure the position PID (commands 61-64),
* issue the manufacturer speed/duty commands (32/33, 35/36), and
* read telemetry: main battery (24) and temperature (82).

Two device quirks are handled by `configure_position_loop()`, which must be
called once per boot (the MCP reverts to its EEPROM configuration on power-up):

* The position PID's `MinPos`/`MaxPos` clamp defaults to `[0, 0]`, which forces
every position target to zero — it is widened here.
* The setter (commands 61/62) uses field order `D, P, I` while the readback
(63/64) uses `P, I, D`, so a naive read-modify-write of the record would move
`P` into the `D` slot and zero `P`. The correct field shuffle (and seeding a
non-zero `P` when the record has none) is done here.

## Example

The [example](./example) brings up an `espp::Twai` transport and an
`espp::CanopenClient`, NMT-starts the node, reads telemetry, configures the M1
position loop, and runs a small profile-position sequence.

## Related components

* `espp/canopen` — the CANopen client and the `Ds402Drive` CiA 402 helper this
component builds on.
* `espp/basicmicro` — the Basicmicro **packet serial** protocol driver (a
different transport to the same controller family), including velocity and
position PID configuration over UART.
22 changes: 22 additions & 0 deletions components/mcp266/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 mcp266 canopen twai"
CACHE STRING
"List of components to include"
)

project(mcp266_example)

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

This example demonstrates how to use the `espp::Mcp266` component to drive a
Basicmicro MCP266 (RoboClaw-family) motor controller over **CANopen**. It:

1. brings up an `espp::Twai` transport and an `espp::CanopenClient`, wiring the
received frames into the client,
2. NMT-starts the node and clears any latched CiA 402 faults,
3. reads the main battery voltage and board temperature,
4. configures the M1 position loop (widening the position clamp and seeding a
non-zero P gain — required once per boot), and
5. runs a small profile-position sequence on M1, reporting arrival at each
target.

See the [component README](../README.md) for the device specifics (the
manufacturer command-object mirror, the position-PID field-order quirk, and the
velocity-control limitation).

## Requirements

- An ESP target with a TWAI (CAN) controller.
- A 3.3 V CAN transceiver (e.g. SN65HVD230) wired to the configured TX/RX
GPIOs, on a properly terminated bus.
- A Basicmicro MCP266 configured for CANopen (CAN pins, bit rate, and node id
set in Basicmicro Motion Studio), with an encoder and a tuned velocity PID.

## Hardware

Edit the `tx_gpio`, `rx_gpio`, `baudrate`, and `node_id` in
`main/mcp266_example.cpp` to match your board and MCP266 configuration.

## Build

Build the project and flash it to the board, then run monitor to view the
serial output:

```sh
idf.py -p PORT flash monitor
```

Replace PORT with the serial port of your board.
2 changes: 2 additions & 0 deletions components/mcp266/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 ".")
136 changes: 136 additions & 0 deletions components/mcp266/example/main/mcp266_example.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#include <chrono>
#include <cstdlib>
#include <thread>

#include "canopen_client.hpp"
#include "mcp266.hpp"
#include "twai.hpp"

using namespace std::chrono_literals;

extern "C" void app_main(void) {
static espp::Logger logger({.tag = "MCP266 Example", .level = espp::Logger::Verbosity::INFO});
logger.info("Starting MCP266 CANopen example!");

//! [mcp266 example]
// The CANopen node id configured on the MCP266 (Motion Studio -> CAN
// settings). Change to match your device.
static constexpr uint8_t node_id = 10;

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

// Bring up the TWAI (CAN 2.0) peripheral. Talking to a real MCP266 requires
// Mode::NORMAL with a 3.3 V CAN transceiver on the tx/rx GPIOs, a properly
// terminated bus, and a matching baudrate (set in Motion Studio).
// NOTE: twai / client / mcp are function-local statics: the Twai receive
// task and the client's send lambda reference them, and app_main() has
// early-return paths, so static storage keeps them alive for every callback.
static espp::Twai twai({
.tx_gpio = 17, // change to match your board / transceiver
.rx_gpio = 16,
.baudrate = 1000000,
.mode = espp::Twai::Mode::NORMAL,
.tx_queue_depth = 10,
.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::WARN,
});

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 = 500ms,
.log_level = espp::Logger::Verbosity::WARN,
});
client_ptr = &client;

static espp::Mcp266 mcp(client, {.log_level = espp::Logger::Verbosity::INFO});

std::error_code ec;
if (!twai.initialize(ec)) {
logger.error("Failed to initialize TWAI: {}", ec.message());
return;
}
// NMT-start the node and clear any latched faults on both axes.
if (!mcp.start(ec)) {
logger.error("Failed to start MCP266: {} -- is the node on the bus?", ec.message());
return;
}

// Telemetry sanity check.
float volts = 0.0f, temp_c = 0.0f;
if (mcp.read_main_battery_voltage(volts, ec)) {
logger.info("Main battery: {:.1f} V", volts);
}
if (mcp.read_temperature(temp_c, ec)) {
logger.info("Board temperature: {:.1f} C", temp_c);
}

using Axis = espp::Mcp266::Axis;

// One-time per-boot position-loop setup on M1: widen the MinPos/MaxPos clamp
// (factory [0, 0] forces every target to zero) and ensure a non-zero
// position P gain. The MCP reverts to EEPROM on power-up, so this must run
// every boot before commanding moves. Clear any latched e-stop first.
mcp.reset_estop(ec);
if (!mcp.configure_position_loop(Axis::M1, -2'000'000'000, 2'000'000'000, ec)) {
logger.error("Failed to configure M1 position loop: {}", ec.message());
return;
}
mcp.set_position_limits(Axis::M1, -20'000, 20'000, ec);

// Run a small profile-position sequence and report arrival.
static constexpr int32_t targets[] = {10'000, -10'000, 0};
static constexpr uint32_t profile_velocity = 500; // counts/s
static constexpr uint32_t profile_accel = 500; // counts/s^2
static constexpr uint32_t profile_decel = 500; // counts/s^2
static constexpr int32_t tolerance = 100; // counts
for (int32_t target : targets) {
logger.info("Moving M1 to {}", target);
if (!mcp.move_to_position(Axis::M1, target, profile_velocity, profile_accel, profile_decel,
ec)) {
logger.error("Move command rejected: {}", ec.message());
continue;
}
const auto deadline = std::chrono::steady_clock::now() + 30s;
while (std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(250ms);
int32_t position = 0;
if (mcp.read_encoder(Axis::M1, position, ec) && std::abs(position - target) <= tolerance) {
Comment thread
finger563 marked this conversation as resolved.
logger.info(" reached {} (position={})", target, position);
break;
}
}
}
//! [mcp266 example]

logger.info("MCP266 example complete!");
while (true) {
std::this_thread::sleep_for(1s);
}
}
4 changes: 4 additions & 0 deletions components/mcp266/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
25 changes: 25 additions & 0 deletions components/mcp266/idf_component.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## IDF Component Manager Manifest File
license: "MIT"
description: "Dual-channel Basicmicro MCP266 (RoboClaw family) motor controller over CANopen (CiA 402 profile position)"
url: "https://github.com/esp-cpp/espp/tree/main/components/mcp266"
repository: "https://github.com/esp-cpp/espp.git"
maintainers:
- William Emfinger <waemfinger@gmail.com>
documentation: "https://esp-cpp.github.io/espp/motorcontrol/mcp266.html"
examples:
- path: example
tags:
- cpp
- Component
- Basicmicro
- MCP266
- RoboClaw
- CANopen
- CiA402
- DS402
- Motor
dependencies:
idf:
version: '>=5.0'
espp/base_component: '>=1.0'
espp/canopen: '>=1.0'
Loading
Loading