refactor(motor-control): shared MotorController concept + unified Axis-form API - #764
Conversation
There was a problem hiding this comment.
🔵 Needs a closer look
It introduces breaking API refactors across two motor drivers plus a new shared concept contract, which warrants final human review despite only a minor issue found.
Pull request overview
This PR unifies the dual-channel motor-controller APIs across espp::Basicmicro (packet-serial) and espp::Mcp266 (CANopen) by introducing a shared MotorAxis selector and enforcing a common MotorController concept contract, while refactoring Basicmicro’s API surface to be axis-driven and disambiguating “both channels” operations.
Changes:
- Added
components/base_component/include/motor_controller.hppdefiningespp::MotorAxisand theespp::MotorControllerconcept, and enforced conformance viastatic_assertin both drivers. - Refactored
Basicmicroto a unified Axis-form API (drive_duty(Axis, ...),read_encoder(Axis, ...), etc.) and renamed both-channel commands todrive_both_*/buffered_drive_both_*. - Aligned
Mcp266naming and signatures (Axisalias toMotorAxis,set_software_position_limits, andconfigure_position_loopparameter order + convenience overload), and updated docs/examples accordingly.
File summaries
| File | Description |
|---|---|
| doc/en/motor_control/basicmicro.rst | Updates the Basicmicro documentation snippet to use Axis-form API and signed encoder reads. |
| components/base_component/include/motor_controller.hpp | Introduces shared MotorAxis and MotorController concept for compile-time API conformance. |
| components/basicmicro/include/basicmicro.hpp | Refactors Basicmicro public API to Axis-form, renames both-channel commands, and adds concept conformance static_assert. |
| components/basicmicro/example/main/basicmicro_example.cpp | Updates example usage to the new Axis-form Basicmicro API and signed encoder reads. |
| components/mcp266/include/mcp266.hpp | Aligns Mcp266 to shared MotorAxis, adjusts configure_position_loop signature/overload, renames software limit API, and adds concept conformance static_assert. |
| components/mcp266/example/main/mcp266_example.cpp | Updates example to renamed software position-limits API. |
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
// :278 configure_position_loop — ec moves to LAST, fallback_p before it
mcp.configure_position_loop(axis_of(pl[0]), rd_i32(pl, 1), rd_i32(pl, 5), rd_i32(pl, 9), ec)
// :285 set_position_limits -> set_software_position_limits
mcp.set_software_position_limits(axis_of(pl[0]), rd_i32(pl, 1), rd_i32(pl, 5), ec)No protocol/wire change — same payload byte offsets, just the C++ argument order / method name. |
…ddress review Two #764 review follow-ups: 1. Relocate the shared interface. Per review, base_component is the CRTP/logger base-class home, not a shared-interface bucket. Move MotorAxis + the MotorController concept out to a new header-only component `motor_controller` -- mirroring how `bldc_types` holds the shared concepts for the BLDC family -- so the two sibling drivers share the contract without either depending on the other. New components/motor_controller: motor_controller.hpp (MotorAxis + the concept + an fmt formatter for MotorAxis), CMakeLists (REQUIRES format), manifest, and a doc page; basicmicro & mcp266 now REQUIRE motor_controller (CMake + idf_component.yml) -- the include and static_asserts are unchanged. Doxyfile + motor_control doc index updated. 2. mcp266 example: check set_software_position_limits()'s return (it was dropped, so an SDO timeout / invalid arg would leave limits unset while the example commanded moves regardless) and bail with a logged error like the neighboring configure_position_loop() call. Builds clean: basicmicro + mcp266 examples on IDF v6.0.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The signed-speed implementation and component publication ordering must be corrected before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
components/motor_controller/CMakeLists.txt:4
- The new component is missing the repository-standard component scaffolding: there is no
README.md, dedicated example, manifestexamplesentry, or build-matrix entry. The reference layout (components/task, includingtask/idf_component.yml:9-10andbuild.yml:310-311) supplies all of these; add the corresponding files/registrations so this public component is documented and independently exercised.
idf_component_register(
INCLUDE_DIRS "include"
REQUIRES format
)
components/motor_controller/idf_component.yml:4
- Add
components/motor_controller/README.mdfor this new published component. The repository's component layout requires a component overview at the registry root, and the existing published components consistently provide one; the RST page alone does not populate the component's repository/registry README.
url: "https://github.com/esp-cpp/espp/tree/main/components/motor_controller"
- Files reviewed: 15/15 changed files
- Comments generated: 3
- Review effort level: Balanced
Address the #764 review round: - read_speed(Axis, qpps, ec): commands 18/19 report an unsigned magnitude plus a separate direction byte, so the no-direction overload returned reverse motion as positive -- violating the MotorController signed-speed contract. Fold the direction into the sign (negate when backward). The direction-byte overload still exposes the raw magnitude + flag. - upload_components.yml: list components/motor_controller ahead of its first-time dependents basicmicro / mcp266 so the registry can resolve it (per the documented dependency-order rule). - Doxyfile: move the motor_controller.hpp INPUT entry to its alphabetical spot (after monitor), per the file-order requirement. Builds clean: basicmicro example on IDF v6.0.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…p266 Introduce espp::MotorAxis + the espp::MotorController compile-time concept (base_component/motor_controller.hpp): the common dual-channel surface both the serial (Basicmicro) and CANopen (Mcp266) drivers should present -- drive_duty/ drive_speed(Axis), read_encoder/read_speed(Axis, int32_t&), reset_estop, read_main_battery_voltage/read_temperature. Mcp266 now aliases Axis = MotorAxis and static_asserts conformance. Also the deferred mcp266 clarity/consistency tweaks: - configure_position_loop: ec moved to LAST (fallback_p now precedes it), with a 4-arg convenience overload using the default gain -- restoring the ec-trailing convention the rest of the codebase holds to. - set_position_limits -> set_software_position_limits, to disambiguate it from configure_position_loop's manufacturer PID clamp (docs cross-reference both). Basicmicro migration to the Axis-form surface + collision rename follows on this branch. Builds clean: mcp266 example on IDF v6.0.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate Basicmicro's per-channel commands from the drive_m1_*/drive_m2_* method pairs to a single Axis-form taking espp::MotorAxis, so it presents the same channel-selected surface as espp::Mcp266 and satisfies espp::MotorController (static_assert added). Generic code can now drive either transport by axis. Per-channel commands collapsed (M1/M2 pair -> one Axis method that selects the command byte from the axis): drive_duty, drive_speed, drive_speed_accel, buffered_drive_speed_distance, buffered_drive_speed_accel_distance, set/read_velocity_pid, set/read_position_pid, read_encoder, read_speed. This removes ~200 lines of near-verbatim duplication. Naming / correctness: - The both-channels commands, which previously shared the drive_duty/drive_speed names with the (new) single-channel meaning, are renamed drive_both_duty / drive_both_speed / drive_both_speed_accel / buffered_drive_both_* so the unqualified name always means "one channel" (matching Mcp266 and killing the cross-component name collision). - e_stop_reset -> reset_estop (matches Mcp266 / the concept). - read_encoder now returns a SIGNED int32 count (was uint32): a quadrature encoder run in reverse reads as negative, as callers expect. A status-byte overload is retained; read_speed likewise keeps a direction-byte overload. - Protected wire helpers that collided by name with the new public methods are renamed *_raw (read_count_raw, read_speed_raw, set/read_velocity_pid_raw, set/read_position_pid_raw). Wire format is unchanged: every Axis method emits the exact same command byte and payload the old M1/M2 method did. Example + docs updated. Builds clean: basicmicro example on IDF v6.0.1 (esp32); host test unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ddress review Two #764 review follow-ups: 1. Relocate the shared interface. Per review, base_component is the CRTP/logger base-class home, not a shared-interface bucket. Move MotorAxis + the MotorController concept out to a new header-only component `motor_controller` -- mirroring how `bldc_types` holds the shared concepts for the BLDC family -- so the two sibling drivers share the contract without either depending on the other. New components/motor_controller: motor_controller.hpp (MotorAxis + the concept + an fmt formatter for MotorAxis), CMakeLists (REQUIRES format), manifest, and a doc page; basicmicro & mcp266 now REQUIRE motor_controller (CMake + idf_component.yml) -- the include and static_asserts are unchanged. Doxyfile + motor_control doc index updated. 2. mcp266 example: check set_software_position_limits()'s return (it was dropped, so an SDO timeout / invalid arg would leave limits unset while the example commanded moves regardless) and bail with a logged error like the neighboring configure_position_loop() call. Builds clean: basicmicro + mcp266 examples on IDF v6.0.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address the #764 review round: - read_speed(Axis, qpps, ec): commands 18/19 report an unsigned magnitude plus a separate direction byte, so the no-direction overload returned reverse motion as positive -- violating the MotorController signed-speed contract. Fold the direction into the sign (negate when backward). The direction-byte overload still exposes the raw magnitude + flag. - upload_components.yml: list components/motor_controller ahead of its first-time dependents basicmicro / mcp266 so the registry can resolve it (per the documented dependency-order rule). - Doxyfile: move the motor_controller.hpp INPUT entry to its alphabetical spot (after monitor), per the file-order requirement. Builds clean: basicmicro example on IDF v6.0.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#761 (webapp) merged to main while this branch was in review; now that this branch rebases onto main, update the webapp for the mcp266 API changes here: - configure_position_loop: ec is now the last argument (fallback_p precedes it). - set_position_limits -> set_software_position_limits. - add the new motor_controller component (a transitive mcp266 dependency) to the manager-off component closure (EXTRA_COMPONENT_DIRS + COMPONENTS) so the firmware still builds with IDF_COMPONENT_MANAGER=0. Builds clean: webapp firmware (esp32s3, manager-off) + basicmicro/mcp266 examples on IDF v6.0.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0fa475a to
47c7051
Compare
|
✅Static analysis result - no issues found! ✅ |
There was a problem hiding this comment.
🟡 Changes recommended
Invalid axes can silently target M2, and raw speed decoding can misreport values or trigger signed-overflow undefined behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
components/basicmicro/include/basicmicro.hpp:428
- Commands 18/19 are documented as returning an unsigned magnitude, but
read_speed_rawdecodes the four bytes intoint32_t. A magnitude with its high bit set therefore arrives negative; forward motion is misreported, while reversingINT32_MINinvokes signed-overflow undefined behavior. Reject unrepresentable magnitudes (or decode asuint32_tand range-check) before applying the direction.
if (!read_speed(axis, qpps, direction, ec))
return false;
if (direction != 0)
qpps = -qpps;
components/motor_controller/include/motor_controller.hpp:57
- The formatter maps every value other than M1 to
"M2", so an invalidMotorAxisis logged as a valid M2 selection. Use an explicit switch with an"Unknown"fallback, consistent with the enum formatters incomponents/canopen/include/canopen_format_helpers.hpp:29-44.
template <typename FormatContext> auto format(espp::MotorAxis axis, FormatContext &ctx) const {
return fmt::formatter<std::string_view>::format(axis == espp::MotorAxis::M1 ? "M1" : "M2", ctx);
doc/en/motor_control/motor_controller.rst:24
- This example says the duty command works for either driver, but
Mcp266::drive_dutyis explicitly documented as accepted yet inert on the tested firmware (mcp266.hpp:251-273and the concept note itself). As written, readers can expect this generic function to actuate both transports; qualify the example so the behavioral limitation is visible.
// works for either espp::Basicmicro or espp::Mcp266
bool ramp(espp::MotorController auto &mc, std::error_code &ec) {
return mc.drive_duty(espp::MotorAxis::M1, 4096, ec);
}
- Files reviewed: 18/18 changed files
- Comments generated: 2
- Review effort level: Balanced
…README Address the latest #764 review: - Axis validation (basicmicro + mcp266): the Axis-form API dispatches with `axis == Axis::M1 ? ... : ...`, so an out-of-range MotorAxis (e.g. one decoded from an untrusted byte) silently targeted M2 -- a footgun the old per-axis methods could not hit. Each public axis method now calls a check_axis() guard that rejects anything but M1/M2 with invalid_argument before any I/O; the guard also covers Mcp266::axis_state (every caller validates first). - Add components/motor_controller/README.md documenting the component. On the reviewer's example/CI-matrix request: motor_controller is a header-only interface/types component like bldc_types, which ships with no example or build.yml matrix entry -- a concept has nothing runnable to demonstrate beyond the drivers that implement it, which are already exercised by the basicmicro/mcp266 examples in CI. Added the README (good practice) but intentionally no example, matching the bldc_types precedent. Verified: basicmicro + mcp266 examples build clean on IDF v6.0.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed the latest review in
|
There was a problem hiding this comment.
🟡 Changes recommended
Two moderate correctness issues must be resolved before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
components/basicmicro/include/basicmicro.hpp:440
- This negation has undefined behavior when the 32-bit magnitude is
0x80000000:read_speed_raw()decodes it asINT32_MIN, which cannot be negated asint32_t. Decode the wire value as an unsigned magnitude and explicitly handle values at or beyond the signed range (including the representable reverseINT32_MINcase).
components/motor_controller/README.md:21 - A new component is expected to include its own example project and a corresponding
build.ymlmatrix entry; relying only on downstream examples leaves the component's documented standalone integration path absent. Add a minimal compile-only example (plus its README/doc include) that exercises the concept and register it in CI.
- Files reviewed: 19/19 changed files
- Comments generated: 2
- Review effort level: Balanced
| /// Reject an out-of-range axis selector. MotorAxis is a two-value enum, but a | ||
| /// value decoded from an untrusted byte could be neither M1 nor M2, and | ||
| /// axis_state()'s dispatch would otherwise silently select M2. Public axis | ||
| /// methods call this before any SDO I/O. Sets ec = invalid_argument on failure. |
…w-up) - MotorAxis fmt formatter: format an out-of-range value as "UNKNOWN" (via an explicit switch) instead of "M2" -- the ternary made diagnostics mislabel malformed input as a valid channel. - Mcp266::drive(Axis): the public advanced-access accessor still routed an invalid selector through axis_state() to the M2 drive. It now returns a Ds402Drive* and yields nullptr for anything but M1/M2, so a decoded-byte axis cannot silently command the wrong drive. (No callers in-tree; pre-1.0.) Verified: MotorAxis formatter host test prints M1/M2/UNKNOWN and compiles clean under -Wall -Wextra; mcp266 example builds on IDF v6.0.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Follow-ups addressed in
|
Consistency pass — shared
MotorControllerinterface, unified Axis-form APIThird and final PR of the basicmicro / canopen / mcp266 review series. Stacked on
#763 (clarity); rebase onto
mainonce #762 (bugs) and #763 (clarity) merge.The review found that the two drivers modelling the same MCP236/266 hardware —
espp::Basicmicro(packet serial) andespp::Mcp266(CANopen) — presentedgratuitously different APIs for the same operations, and that
Basicmicro'sdrive_duty(m1, m2)collided in meaning withMcp266'sdrive_duty(axis, duty)(one meant "both channels", the other "one channel"). This unifies them.
1. Shared
espp::MotorControllerconcept +espp::MotorAxisNew
base_component/include/motor_controller.hpp: a compile-time concept naming thecommon dual-channel surface both drivers should present —
drive_duty/drive_speedby
MotorAxis,read_encoder/read_speed(signedint32),reset_estop,read_main_battery_voltage,read_temperature. Both classesusing Axis = MotorAxisand
static_assertconformance, so generic code can drive either transport by axisand the contract is enforced at compile time.
2.
Basicmicro: Axis-form API (collapses ~200 lines of M1/M2 duplication)Every per-channel command pair (
drive_m1_*/drive_m2_*,read_encoder_m1/m2,read_encoder_speed_m1/m2,set/read_velocity_pid_m1/m2,set/read_position_pid_m1/m2,buffered variants) collapses to a single method taking
Axis, selecting the commandbyte from the axis.
drive_both_duty/drive_both_speed/drive_both_speed_accel/buffered_drive_both_*so the unqualified name alwaysmeans "one channel" (matching
Mcp266, killing the collision).e_stop_reset→reset_estop.read_encodernow returns a signedint32count (wasuint32) — reversequadrature reads negative, as callers expect; a status-byte overload is retained.
Wire format is unchanged — each Axis method emits the exact command byte + payload
the old M1/M2 method did.
3.
Mcp266alignment tidy-ups (deferred from the clarity PR)configure_position_loop:ecmoved to the last parameter (restoring thecodebase convention) with a convenience overload using the default position P gain.
set_position_limits→set_software_position_limits(disambiguates it from themanufacturer position-PID clamp).
Verification
basicmicroexample builds clean on IDF v6.0.1 (esp32); host test unchanged.mcp266example builds clean on IDF v6.0.1.static_assert(MotorController<...>)pass — the two drivers are nowinterchangeable through the concept.
Basicmicro:drive_m1_duty(d)→drive_duty(Axis::M1, d);drive_duty(m1, m2)→
drive_both_duty(m1, m2); same pattern for speed / PID / buffered / encoder reads;read_encoder_m1(uint32_t&, ...)→read_encoder(Axis::M1, int32_t&, ...);e_stop_reset→reset_estop.Mcp266:set_position_limits→set_software_position_limits;configure_position_loop(...)gains a required position-P argument beforeec(or use the 4-arg default-gain overload).
🤖 Generated with Claude Code