MCP266 CANopen Motor Controller Component

Overview

espp::Mcp266 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. The reverse-engineered object mapping lives in espp::detail inside include/detail/mcp266_core.hpp, a host-buildable core that depends only on the C++20 standard library and is unit-tested off-target.

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. Both the standard target objects and the manufacturer speed/duty command mirror are 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.

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. 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, 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.

API Reference

Header File

Classes

class Mcp266 : public espp::BaseComponent

Dual-channel controller for a Basicmicro MCP266 (RoboClaw family) brushed-DC motor driver over CANopen.

Layered on a CanopenClient (like 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.

Position control uses the standard CiA 402 profile position mode and is the supported, validated capability. The MCP266’s control-loop parameters are NOT standard CiA 402 objects: the MCP mirrors its packet-serial command set into the manufacturer region at object index 0x2000 + command number, which this class uses to configure the position PID (commands 61-64), issue the manufacturer speed/duty commands (32/33, 35/36), and read telemetry (24, 82). See detail/mcp266_core.hpp.

Important: two device quirks must be handled, both done by configure_position_loop():

  • The position PID’s MinPos/MaxPos clamp defaults to [0, 0], which forces every position target to zero.

  • 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 moves P into the D slot and zeros P. The MCP reverts to its EEPROM configuration on power-up, so call configure_position_loop() once per boot before commanding moves.

Note: the manufacturer speed/duty command mirror (drive_speed / drive_duty) is implemented but does NOT produce motion on the MCP266 firmware tested (the command is accepted but the velocity generator stays idle). Use position mode for motion.

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) {
        logger.info("  reached {} (position={})", target, position);
        break;
      }
    }
  }

Position control (CiA 402 profile position mode)

inline bool configure_position_loop(Axis axis, int32_t min_pos, int32_t max_pos, std::error_code &ec, int32_t fallback_p = kDefaultPositionP)

Configure an axis’s position loop for use: widen the MinPos/MaxPos clamp (factory [0, 0] forces every target to zero) and, only if the drive’s stored position P gain reads back as zero, seed a non-zero P so the loop produces output. The record is written through the setter’s D, P, I field order and the clamp verified via the readback. The MCP reverts to EEPROM on power-up, so call once per boot.

Note

fallback_p is a coarse starting value used ONLY when the drive has no stored P gain; it is not motor-tuned. For good motion, tune the position PID in Basicmicro Motion Studio (or pass a value appropriate for your motor / encoder) &#8212; an unsuitable P gain can leave the axis sluggish or make it oscillate. A drive whose P gain is already non-zero keeps its stored gains untouched.

Parameters:
  • axis – The motor channel.

  • min_pos – Minimum commandable position.

  • max_pos – Maximum commandable position.

  • ec – Set on failure.

  • fallback_p – Position P gain to seed when the stored gain is zero.

Returns:

True on success.

inline bool set_position_limits(Axis axis, int32_t min_pos, int32_t max_pos, std::error_code &ec)

Set the CiA 402 software position limits (0x607D:1/:2) for an axis.

Parameters:
  • axis – The motor channel.

  • min_pos – Lower limit.

  • max_pos – Upper limit.

  • ec – Set on failure.

Returns:

True on success.

inline bool move_to_position(Axis axis, int32_t target_position, uint32_t profile_velocity, uint32_t profile_acceleration, uint32_t profile_deceleration, std::error_code &ec)

Command a profile-position move: enable the axis, set the motion profile, and issue the target with the new-set-point handshake.

Parameters:
  • axis – The motor channel.

  • target_position – Absolute target position (encoder counts).

  • profile_velocity – Cruise velocity.

  • profile_acceleration – Acceleration.

  • profile_deceleration – Deceleration.

  • ec – Set on failure.

Returns:

True once the set-point is accepted.

Manufacturer speed / duty command mirror

Note

Accepted by the drive but inert on the MCP266 firmware tested; kept for completeness and in case a firmware update activates them.

inline bool drive_speed(Axis axis, int32_t qpps, std::error_code &ec)

Closed-loop speed via the mirrored packet-serial command (35/36).

inline bool drive_duty(Axis axis, int16_t duty, std::error_code &ec)

Open-loop duty via the mirrored packet-serial command (32/33).

Feedback

inline bool read_encoder(Axis axis, int32_t &count, std::error_code &ec)

Read the actual position (0x6064 / 0x6864).

Parameters:
  • axis – Channel.

  • count – Out: encoder counts.

  • ec – Set on failure.

Returns:

True on success.

inline bool read_speed(Axis axis, int32_t &qpps, std::error_code &ec)

Read the actual velocity (0x606C / 0x686C).

Parameters:
  • axis – Channel.

  • qpps – Out: counts/s.

  • ec – Set on failure.

Returns:

True on success.

inline bool read_statusword(Axis axis, uint16_t &statusword, std::error_code &ec)

Read the CiA 402 statusword (0x6041 / 0x6841).

Parameters:
  • axis – Channel.

  • statusword – Out.

  • ec – Set on failure.

Returns:

True on success.

Device telemetry

inline bool read_main_battery_voltage(float &volts, std::error_code &ec)

Read the main battery voltage (mirrored command 24).

Parameters:
  • volts – Out: volts.

  • ec – Set on failure.

Returns:

True on success.

inline bool read_temperature(float &temp_c, std::error_code &ec)

Read the board temperature (mirrored command 82).

Parameters:
  • temp_c – Out: degrees C.

  • ec – Set on failure.

Returns:

True on success.

inline bool read_device_info(std::string &device_name, uint32_t &device_type, std::error_code &ec)

Read the standard device type (0x1000) and name (0x1008).

Public Types

enum class Axis

Motor channel selector.

Values:

enumerator M1
enumerator M2

Public Functions

inline explicit Mcp266(CanopenClient &client, const Config &config)

Create an MCP266 controller.

Parameters:
  • client – The CANopen client for the MCP266’s node. Must outlive this object, and its process_frame() must be driven from another task (e.g. the transport’s receive task) per CanopenClient’s contract.

  • config – The configuration.

inline explicit Mcp266(CanopenClient &client)

Create an MCP266 controller with the default configuration.

inline bool start(std::error_code &ec)

NMT-start the node and clear any latched CiA 402 faults on both axes. Call once after the transport and client are up.

Parameters:

ec – Set on failure.

Returns:

True on success.

inline bool reset_faults(std::error_code &ec)

Clear any latched CiA 402 fault on both axes.

Parameters:

ec – Set on failure.

Returns:

True on success.

inline bool reset_estop(std::error_code &ec)

Attempt an E-stop reset (mirrored packet-serial command 200 at 0x20C8). Harmless when nothing is latched.

Parameters:

ec – Set on failure.

Returns:

True if accepted.

inline Ds402Drive &drive(Axis axis)

Access an axis’s underlying Ds402Drive for advanced CiA 402 use.

Parameters:

axis – The motor channel.

Returns:

Reference to the axis drive helper.

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

struct Config

Configuration for the Mcp266 controller.

Public Members

std::chrono::milliseconds state_timeout = {1000}

Per-axis CiA 402 transition timeout.

std::chrono::milliseconds poll_period = {25}

Statusword polling period.

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

Logger verbosity.