diff --git a/components/mcp266/README.md b/components/mcp266/README.md index a16fbf29fa..35aae2c923 100644 --- a/components/mcp266/README.md +++ b/components/mcp266/README.md @@ -34,7 +34,12 @@ 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), +* configure the position PID (commands 61-64), read the record back + (`read_position_pid`, `read_position_limits`) and install tuned gains + (`set_position_pid`), +* set an axis's encoder count (22/23, `set_encoder`) or zero both (20, + `reset_encoders`) — how a quadrature encoder is homed against a limit switch + or restored to a remembered position after power-up, * issue the manufacturer speed/duty commands (32/33, 35/36), and * read telemetry: main battery (24) and temperature (82). diff --git a/components/mcp266/example/README.md b/components/mcp266/example/README.md index 1279938007..25f07c7dc3 100644 --- a/components/mcp266/example/README.md +++ b/components/mcp266/example/README.md @@ -8,7 +8,8 @@ Basicmicro MCP266 (RoboClaw-family) motor controller over **CANopen**. It: 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 + non-zero P gain — required once per boot) and reads the resulting PID + record back, and 5. runs a small profile-position sequence on M1, reporting arrival at each target. diff --git a/components/mcp266/example/main/mcp266_example.cpp b/components/mcp266/example/main/mcp266_example.cpp index 62431d6cf7..792e678a0e 100644 --- a/components/mcp266/example/main/mcp266_example.cpp +++ b/components/mcp266/example/main/mcp266_example.cpp @@ -102,6 +102,15 @@ extern "C" void app_main(void) { logger.error("Failed to configure M1 position loop: {}", ec.message()); return; } + // The record the controller now holds: gains as tuned in Motion Studio (or + // the seeded fallback), and the clamp just installed. + float p = 0, i = 0, d = 0; + uint32_t max_i = 0, deadzone = 0; + int32_t min_pos = 0, max_pos = 0; + if (mcp.read_position_pid(Axis::M1, p, i, d, max_i, deadzone, min_pos, max_pos, ec)) { + logger.info("M1 position PID: P={:.3f} I={:.3f} D={:.3f} maxI={} deadzone={} clamp=[{}, {}]", p, + i, d, max_i, deadzone, min_pos, max_pos); + } if (!mcp.set_software_position_limits(Axis::M1, -20'000, 20'000, ec)) { logger.error("Failed to set M1 software position limits: {}", ec.message()); return; diff --git a/components/mcp266/include/detail/mcp266_core.hpp b/components/mcp266/include/detail/mcp266_core.hpp index 807068d5d3..c88c84d048 100644 --- a/components/mcp266/include/detail/mcp266_core.hpp +++ b/components/mcp266/include/detail/mcp266_core.hpp @@ -56,8 +56,49 @@ inline constexpr uint16_t kTemperatureObject = command_object(BasicmicroCommand::ReadTemperature); ///< tenths of a degree C (u16) inline constexpr uint16_t kEStopResetObject = command_object(BasicmicroCommand::EStopReset); ///< write-only +inline constexpr uint16_t kResetEncodersObject = + command_object(BasicmicroCommand::ResetEncoders); ///< write-only, zeros both counters /// @} +/// \brief Scale of the position PID gains in the record: the controller stores +/// P, I and D as fixed point x1024 (same as the packet-serial driver). +inline constexpr int32_t kPositionGainScale = 1024; + +/// \brief Convert a floating-point position PID gain to the record's fixed-point +/// representation (x kPositionGainScale). +/// \details Mirrors espp::Basicmicro's scale_pid_gain(): rounds to nearest rather +/// than truncating (truncation biases every gain downward by up to one +/// LSB) and clamps to the full unsigned 32-bit range of the record's +/// gain fields. Gains are non-negative on these controllers, and a raw +/// cast of a negative or non-finite product to an integer is either +/// silently wrong or undefined. The record's P, I, D, MaxI and Deadzone +/// fields are unsigned; only MinPos / MaxPos are signed. The SDO +/// transfer is a 4-byte bit pattern either way, so the value is carried +/// through the i32 helpers unchanged (see position_gain_bits()). +/// \param gain The gain as a float. +/// \return The fixed-point gain, clamped to [0, UINT32_MAX]. +inline constexpr uint32_t scale_position_gain(float gain) { + if (!(gain > 0.0f)) { // false for <= 0 and for NaN + return 0; + } + const double scaled = static_cast(gain) * static_cast(kPositionGainScale); + if (!(scaled < 4294967295.5)) { // also false for +inf; saturate instead of overflowing + return UINT32_MAX; + } + return static_cast(scaled + 0.5); // scaled > 0, so this rounds to nearest +} + +/// \brief Reinterpret an unsigned record field (gain, MaxI, Deadzone) as the +/// int32_t slot of the seven-field record array, preserving the bit +/// pattern for the 4-byte SDO write. +inline constexpr int32_t position_gain_bits(uint32_t raw) { return static_cast(raw); } + +/// \brief Convert a fixed-point gain field read back from the record (an +/// unsigned 32-bit bit pattern carried in an int32_t slot) to a float. +inline constexpr float position_gain_from_bits(int32_t raw) { + return static_cast(static_cast(raw)) / static_cast(kPositionGainScale); +} + /// \brief The manufacturer command objects and CiA 402 offset for one axis. struct AxisObjects { uint16_t object_offset; ///< 0 for M1, 0x800 for M2 (added to 0x60xx objects). @@ -65,21 +106,26 @@ struct AxisObjects { uint16_t position_pid_get; ///< Position PID readback (command 63/64). uint16_t drive_duty; ///< Signed-duty command (32/33). uint16_t drive_speed; ///< Signed-speed command (35/36). + uint16_t encoder_set; ///< Encoder count setter (command 22/23), write-only. }; /// \brief Objects for motor 1 (the standard axis). inline constexpr AxisObjects axis_m1() { - return {kAxisOffsetM1, command_object(BasicmicroCommand::SetPositionPidM1), + return {kAxisOffsetM1, + command_object(BasicmicroCommand::SetPositionPidM1), command_object(BasicmicroCommand::ReadPositionPidM1), command_object(BasicmicroCommand::DriveM1SignedDuty), - command_object(BasicmicroCommand::DriveM1SignedSpeed)}; + command_object(BasicmicroCommand::DriveM1SignedSpeed), + command_object(BasicmicroCommand::SetEncoderM1)}; } /// \brief Objects for motor 2 (mirrored at +0x800 / command n+1). inline constexpr AxisObjects axis_m2() { - return {kAxisOffsetM2, command_object(BasicmicroCommand::SetPositionPidM2), + return {kAxisOffsetM2, + command_object(BasicmicroCommand::SetPositionPidM2), command_object(BasicmicroCommand::ReadPositionPidM2), command_object(BasicmicroCommand::DriveM2SignedDuty), - command_object(BasicmicroCommand::DriveM2SignedSpeed)}; + command_object(BasicmicroCommand::DriveM2SignedSpeed), + command_object(BasicmicroCommand::SetEncoderM2)}; } /// \brief Remap a position-PID record from the readback order to the setter diff --git a/components/mcp266/include/mcp266.hpp b/components/mcp266/include/mcp266.hpp index c246e89374..21e954fac2 100644 --- a/components/mcp266/include/mcp266.hpp +++ b/components/mcp266/include/mcp266.hpp @@ -30,9 +30,10 @@ namespace espp { /// 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. +/// configure and read back the position PID (commands 61-64), set or +/// zero the encoder counts (22/23, 20), issue the manufacturer +/// speed/duty commands (32/33, 35/36), and read telemetry (24, 82). +/// See detail/mcp266_core.hpp. /// /// \b Important: two device quirks must be handled, both done by /// configure_position_loop(): @@ -157,13 +158,8 @@ class Mcp266 : public BaseComponent { return false; AxisState &a = axis_state(axis); std::array readback{}; - for (uint8_t sub = 1; sub <= 7; ++sub) { - readback[sub - 1] = client_.read_i32(a.objects.position_pid_get, sub, ec); - if (ec) { - logger_.error("{}: position PID read 0x{:04X}:{} failed: {}", a.name, - a.objects.position_pid_get, sub, ec.message()); - return false; - } + if (!read_position_pid_raw(a, readback, ec)) { + return false; } // readback order is [P, I, D, MaxI, Deadzone, MinPos, MaxPos] if (readback[0] == 0) { @@ -174,18 +170,14 @@ class Mcp266 : public BaseComponent { } readback[5] = min_pos; readback[6] = max_pos; - const auto setter = detail::mcp266::position_pid_readback_to_setter(readback); - for (uint8_t sub = 1; sub <= 7; ++sub) { - if (!client_.write_i32(a.objects.position_pid_set, sub, setter[sub - 1], ec)) { - logger_.error("{}: position PID write 0x{:04X}:{} rejected: {}", a.name, - a.objects.position_pid_set, sub, ec.message()); - return false; - } + if (!write_position_pid_raw(a, detail::mcp266::position_pid_readback_to_setter(readback), ec)) { + return false; } // verify via the readback's field order (min/max are subs 6/7 there too) - const int32_t got_min = client_.read_i32(a.objects.position_pid_get, 6, ec); - const int32_t got_max = client_.read_i32(a.objects.position_pid_get, 7, ec); - if (ec || got_min != min_pos || got_max != max_pos) { + int32_t got_min = 0; + int32_t got_max = 0; + if (!read_position_limits(axis, got_min, got_max, ec) || got_min != min_pos || + got_max != max_pos) { logger_.error("{}: position clamp did not take (read [{}, {}], wanted [{}, {}])", a.name, got_min, got_max, min_pos, max_pos); ec = std::make_error_code(std::errc::protocol_error); @@ -202,6 +194,114 @@ class Mcp266 : public BaseComponent { return configure_position_loop(axis, min_pos, max_pos, kDefaultPositionP, ec); } + /// \brief Read an axis's position PID record (mirrored command 63/64). Gains + /// are converted to floats (the record stores them x1024), matching + /// espp::Basicmicro::read_position_pid(). + /// \param axis The motor channel. + /// \param p Out: proportional gain. + /// \param i Out: integral gain. + /// \param d Out: derivative gain. + /// \param max_i Out: maximum integral windup. + /// \param deadzone Out: deadzone in encoder counts. + /// \param min_pos Out: minimum commandable position (the clamp). + /// \param max_pos Out: maximum commandable position (the clamp). + /// \param ec Set on failure. + /// \return True on success. + bool read_position_pid(Axis axis, float &p, float &i, float &d, uint32_t &max_i, + uint32_t &deadzone, int32_t &min_pos, int32_t &max_pos, + std::error_code &ec) { + ec.clear(); + if (!check_axis(axis, ec)) + return false; + std::array readback{}; + if (!read_position_pid_raw(axis_state(axis), readback, ec)) { + return false; + } + // gain / MaxI / Deadzone fields are unsigned on the record; the i32 read + // only carried their bit pattern + p = detail::mcp266::position_gain_from_bits(readback[0]); + i = detail::mcp266::position_gain_from_bits(readback[1]); + d = detail::mcp266::position_gain_from_bits(readback[2]); + max_i = static_cast(readback[3]); + deadzone = static_cast(readback[4]); + min_pos = readback[5]; + max_pos = readback[6]; + return true; + } + + /// \brief Write an axis's whole position PID record (mirrored command + /// 61/62), taking care of the setter's D, P, I field order. Gains are + /// floats, stored x1024 (rounded to nearest and clamped non-negative, + /// as espp::Basicmicro::set_position_pid() does). Prefer + /// configure_position_loop() when only the clamp needs setting; this + /// is for installing tuned gains. + /// \param axis The motor channel. + /// \param p Proportional gain. + /// \param i Integral gain. + /// \param d Derivative gain. + /// \param max_i Maximum integral windup. + /// \param deadzone Deadzone in encoder counts. + /// \param min_pos Minimum commandable position (the clamp). + /// \param max_pos Maximum commandable position (the clamp). + /// \param ec Set on failure. + /// \return True on success. + bool set_position_pid(Axis axis, float p, float i, float d, uint32_t max_i, uint32_t deadzone, + int32_t min_pos, int32_t max_pos, std::error_code &ec) { + ec.clear(); + if (!check_axis(axis, ec)) + return false; + if (min_pos > max_pos) { + ec = std::make_error_code(std::errc::invalid_argument); + return false; + } + const AxisState &a = axis_state(axis); + using detail::mcp266::position_gain_bits; + using detail::mcp266::scale_position_gain; + // the unsigned fields travel as 4-byte bit patterns in the i32 slots + const std::array readback_order{position_gain_bits(scale_position_gain(p)), + position_gain_bits(scale_position_gain(i)), + position_gain_bits(scale_position_gain(d)), + position_gain_bits(max_i), + position_gain_bits(deadzone), + min_pos, + max_pos}; + if (!write_position_pid_raw(a, detail::mcp266::position_pid_readback_to_setter(readback_order), + ec)) { + return false; + } + logger_.info("{}: position PID written (P={}, I={}, D={}, clamp=[{}, {}])", a.name, + static_cast(readback_order[0]), static_cast(readback_order[1]), + static_cast(readback_order[2]), min_pos, max_pos); + return true; + } + + /// \brief Read just the MinPos/MaxPos clamp of an axis's position PID record + /// (two SDOs instead of the seven of read_position_pid()). Useful as a + /// cheap check that the controller still holds the configuration it + /// was given: it reverts to its EEPROM (factory clamp [0, 0]) on a + /// power cycle. + /// \param axis The motor channel. + /// \param min_pos Out: minimum commandable position. + /// \param max_pos Out: maximum commandable position. + /// \param ec Set on failure. + /// \return True on success. + bool read_position_limits(Axis axis, int32_t &min_pos, int32_t &max_pos, std::error_code &ec) { + ec.clear(); + if (!check_axis(axis, ec)) + return false; + const AxisState &a = axis_state(axis); + min_pos = client_.read_i32(a.objects.position_pid_get, 6, ec); + if (!ec) { + max_pos = client_.read_i32(a.objects.position_pid_get, 7, ec); + } + if (ec) { + logger_.error("{}: position limits read 0x{:04X} failed: {}", a.name, + a.objects.position_pid_get, ec.message()); + return false; + } + return true; + } + /// \brief Set the CiA 402 software position limits (object 0x607D:1/:2) for an /// axis — a per-move envelope enforced by the drive's trajectory /// generator. Distinct from configure_position_loop()'s min/max, which @@ -300,6 +400,44 @@ class Mcp266 : public BaseComponent { count = axis_state(axis).drive.get_position_actual(ec); return !ec; } + /// \brief Set an axis's encoder count (mirrored packet-serial command 22/23). + /// For a quadrature encoder this defines the count at the current + /// position, e.g. to home against a limit switch or to restore a + /// remembered position after power-up. Takes effect at once; the + /// position loop then sees the new count. + /// \param axis Channel. + /// \param count The count to install. + /// \param ec Set on failure. + /// \return True on success. + bool set_encoder(Axis axis, int32_t count, std::error_code &ec) { + ec.clear(); + if (!check_axis(axis, ec)) + return false; + const AxisState &a = axis_state(axis); + if (!client_.write_i32(a.objects.encoder_set, 0, count, ec)) { + logger_.error("{}: set encoder 0x{:04X} rejected: {}", a.name, a.objects.encoder_set, + ec.message()); + return false; + } + logger_.info("{}: encoder count set to {}", a.name, count); + return true; + } + /// \brief Zero both encoder counters (mirrored packet-serial command 20), + /// matching espp::Basicmicro::reset_encoders(). + /// \param ec Set on failure. + /// \return True on success. + bool reset_encoders(std::error_code &ec) { + ec.clear(); + // Command 20 has no payload, so the SDO scalar width is unknown; try the + // common ones (as reset_estop() does). + bool ok = client_.write_u8(detail::mcp266::kResetEncodersObject, 0, 1, ec); + if (!ok) { + ec.clear(); + ok = client_.write_u32(detail::mcp266::kResetEncodersObject, 0, 1, ec); + } + logger_.info("encoder reset {}", ok ? "accepted" : "rejected"); + return ok; + } /// \brief Read the actual velocity (0x606C / 0x686C). /// \param axis Channel. /// \param qpps Out: counts/s. @@ -441,6 +579,37 @@ class Mcp266 : public BaseComponent { AxisState &axis_state(Axis axis) { return axis == Axis::M1 ? m1_ : m2_; } + /// Read the seven-field position PID record in the readback's order + /// [P, I, D, MaxI, Deadzone, MinPos, MaxPos] (subindices 1..7 of command 63/64). + /// The first five fields are unsigned on the device; the i32 read carries + /// their 4-byte bit pattern, which the callers reinterpret. + bool read_position_pid_raw(const AxisState &a, std::array &readback, + std::error_code &ec) { + for (uint8_t sub = 1; sub <= 7; ++sub) { + readback[sub - 1] = client_.read_i32(a.objects.position_pid_get, sub, ec); + if (ec) { + logger_.error("{}: position PID read 0x{:04X}:{} failed: {}", a.name, + a.objects.position_pid_get, sub, ec.message()); + return false; + } + } + return true; + } + + /// Write the seven-field record already in the setter's order + /// [D, P, I, MaxI, Deadzone, MinPos, MaxPos] (subindices 1..7 of command 61/62). + bool write_position_pid_raw(const AxisState &a, const std::array &setter, + std::error_code &ec) { + for (uint8_t sub = 1; sub <= 7; ++sub) { + if (!client_.write_i32(a.objects.position_pid_set, sub, setter[sub - 1], ec)) { + logger_.error("{}: position PID write 0x{:04X}:{} rejected: {}", a.name, + a.objects.position_pid_set, sub, ec.message()); + return false; + } + } + return true; + } + /// Write the axis mode of operation directly (the MCP does not echo the /// requested mode in 0x6061, so Ds402Drive::set_mode() -- which verifies the /// display -- would time out), clear any fault, and walk to Operation diff --git a/components/mcp266/test/mcp266_host_test.cpp b/components/mcp266/test/mcp266_host_test.cpp index ffde69e1e5..8e6cf16899 100644 --- a/components/mcp266/test/mcp266_host_test.cpp +++ b/components/mcp266/test/mcp266_host_test.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include "detail/mcp266_core.hpp" @@ -37,9 +38,14 @@ static void test_command_object() { CHECK(command_object(24) == 0x2018); // read main battery CHECK(command_object(82) == 0x2052); // read temperature CHECK(command_object(200) == 0x20C8); // e-stop reset + CHECK(command_object(20) == 0x2014); // reset encoders + CHECK(command_object(22) == 0x2016); // set M1 encoder + CHECK(command_object(23) == 0x2017); // set M2 encoder CHECK(kMainBatteryObject == 0x2018); CHECK(kTemperatureObject == 0x2052); CHECK(kEStopResetObject == 0x20C8); + CHECK(kResetEncodersObject == 0x2014); + CHECK(kPositionGainScale == 1024); } static void test_axis_objects() { @@ -54,10 +60,12 @@ static void test_axis_objects() { CHECK(m1.position_pid_get == 0x203F); CHECK(m1.drive_duty == 0x2020); CHECK(m1.drive_speed == 0x2023); + CHECK(m1.encoder_set == 0x2016); CHECK(m2.position_pid_set == 0x203E); CHECK(m2.position_pid_get == 0x2040); CHECK(m2.drive_duty == 0x2021); CHECK(m2.drive_speed == 0x2024); + CHECK(m2.encoder_set == 0x2017); // the CiA 402 offset applied to a device-profile object selects the axis CHECK(static_cast(0x6040 + m2.object_offset) == 0x6840); // controlword CHECK(static_cast(0x607A + m2.object_offset) == 0x687A); // target position @@ -81,10 +89,43 @@ static void test_position_pid_remap() { static_assert(position_pid_readback_to_setter({7, 8, 9, 0, 0, 0, 0})[1] == 7); } +static void test_scale_position_gain() { + std::printf("test_scale_position_gain\n"); + // round-to-nearest (not truncate) and clamp to the full unsigned 32-bit + // range of the record's gain fields, matching espp::Basicmicro's + // scale_pid_gain() + CHECK(scale_position_gain(4.0f) == 4096); + CHECK(scale_position_gain(1.5f) == 1536); + // 102.5 / 1024 scales back to exactly 102.5 -> rounds up to 103 (truncation: 102) + CHECK(scale_position_gain(102.5f / 1024.0f) == 103); + CHECK(scale_position_gain(15491.0f / 1024.0f) == 15491); // the default fallback P round-trips + CHECK(scale_position_gain(0.0f) == 0); + CHECK(scale_position_gain(-1.0f) == 0); // negatives clamp to 0 + CHECK(scale_position_gain(std::numeric_limits::quiet_NaN()) == 0); + CHECK(scale_position_gain(std::numeric_limits::infinity()) == UINT32_MAX); + CHECK(scale_position_gain(1.0e12f) == UINT32_MAX); // >> 2^32, saturates + CHECK(scale_position_gain(4194304.0f) == UINT32_MAX); // 4194304 * 1024 == 2^32, saturates + CHECK(scale_position_gain(4194303.0f) == 4294966272u); // just below 2^32, fits exactly + CHECK(scale_position_gain(2097152.0f) == 2147483648u); // bit 31 set: must not be narrowed + // constexpr-evaluable + static_assert(scale_position_gain(2.0f) == 2048); + static_assert(scale_position_gain(-2.0f) == 0); + + // a gain with bit 31 set survives the trip through the i32 record slot and + // decodes back as a positive float (not a negative one) + const int32_t bits = position_gain_bits(scale_position_gain(4194303.0f)); + CHECK(static_cast(bits) == 0xFFFFFC00u); + CHECK(bits < 0); // the slot itself is negative: only the bit pattern matters + CHECK(position_gain_from_bits(bits) == 4194303.0f); + CHECK(position_gain_from_bits(position_gain_bits(2048)) == 2.0f); + static_assert(position_gain_from_bits(position_gain_bits(UINT32_MAX)) > 0.0f); +} + int main() { test_command_object(); test_axis_objects(); test_position_pid_remap(); + test_scale_position_gain(); if (g_failures) { std::printf("%d FAILURES\n", g_failures); return 1; diff --git a/doc/en/motor_control/mcp266.rst b/doc/en/motor_control/mcp266.rst index 6c3c552490..171d95957e 100644 --- a/doc/en/motor_control/mcp266.rst +++ b/doc/en/motor_control/mcp266.rst @@ -37,9 +37,9 @@ 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). +that to configure and read back the position PID (commands 61-64), set or zero +the encoder counts (22/23, 20), 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):