Skip to content

refactor(motor-control): shared MotorController concept + unified Axis-form API - #764

Merged
finger563 merged 7 commits into
mainfrom
refactor/motor-control-consistency
Sep 4, 2026
Merged

refactor(motor-control): shared MotorController concept + unified Axis-form API#764
finger563 merged 7 commits into
mainfrom
refactor/motor-control-consistency

Conversation

@finger563

Copy link
Copy Markdown
Contributor

Consistency pass — shared MotorController interface, unified Axis-form API

Third and final PR of the basicmicro / canopen / mcp266 review series. Stacked on
#763 (clarity); rebase onto main once #762 (bugs) and #763 (clarity) merge.

The review found that the two drivers modelling the same MCP236/266 hardware —
espp::Basicmicro (packet serial) and espp::Mcp266 (CANopen) — presented
gratuitously different APIs for the same operations, and that Basicmicro's
drive_duty(m1, m2) collided in meaning with Mcp266's drive_duty(axis, duty)
(one meant "both channels", the other "one channel"). This unifies them.

1. Shared espp::MotorController concept + espp::MotorAxis

New base_component/include/motor_controller.hpp: a compile-time concept naming the
common dual-channel surface both drivers should present — drive_duty / drive_speed
by MotorAxis, read_encoder / read_speed (signed int32), reset_estop,
read_main_battery_voltage, read_temperature. Both classes using Axis = MotorAxis
and static_assert conformance, so generic code can drive either transport by axis
and 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 command
byte from the axis.

  • The both-channels commands 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, killing the collision).
  • e_stop_resetreset_estop.
  • read_encoder now returns a signed int32 count (was uint32) — reverse
    quadrature 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. Mcp266 alignment tidy-ups (deferred from the clarity PR)

  • configure_position_loop: ec moved to the last parameter (restoring the
    codebase convention) with a convenience overload using the default position P gain.
  • set_position_limitsset_software_position_limits (disambiguates it from the
    manufacturer position-PID clamp).

Verification

  • basicmicro example builds clean on IDF v6.0.1 (esp32); host test unchanged.
  • mcp266 example builds clean on IDF v6.0.1.
  • Both static_assert(MotorController<...>) pass — the two drivers are now
    interchangeable through the concept.

⚠️ Breaking API changes (both drivers are pre-1.0 components)

  • 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_resetreset_estop.
  • Mcp266: set_position_limitsset_software_position_limits;
    configure_position_loop(...) gains a required position-P argument before ec
    (or use the 4-arg default-gain overload).

Note: the mcp266 WebUSB example (#761) will need a one-line configure_position_loop
call update once this lands; tracked with that PR.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 3, 2026 03:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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.hpp defining espp::MotorAxis and the espp::MotorController concept, and enforced conformance via static_assert in both drivers.
  • Refactored Basicmicro to a unified Axis-form API (drive_duty(Axis, ...), read_encoder(Axis, ...), etc.) and renamed both-channel commands to drive_both_* / buffered_drive_both_*.
  • Aligned Mcp266 naming and signatures (Axis alias to MotorAxis, set_software_position_limits, and configure_position_loop parameter 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.

Comment thread components/mcp266/example/main/mcp266_example.cpp Outdated
@finger563

Copy link
Copy Markdown
Contributor Author

⚠️ Rebase note — #761 (mcp266 webapp) is now merged to main. Two call sites in components/mcp266/webapp_example/main/mcp266_webapp_example.cpp use the old signatures this PR changes, and since this branch does not contain the webapp file, git will not flag a conflictmain would build-break when this lands. When rebasing this PR onto main, fold in:

// :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.

finger563 added a commit that referenced this pull request Sep 3, 2026
…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>
@finger563
finger563 requested a balanced review from Copilot September 3, 2026 14:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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, manifest examples entry, or build-matrix entry. The reference layout (components/task, including task/idf_component.yml:9-10 and build.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.md for 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

Comment thread components/basicmicro/include/basicmicro.hpp
Comment thread components/motor_controller/idf_component.yml
Comment thread doc/Doxyfile Outdated
finger563 added a commit that referenced this pull request Sep 3, 2026
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>
Base automatically changed from refactor/motor-control-clarity to main September 3, 2026 19:21
finger563 and others added 5 commits September 3, 2026 14:25
…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>
@finger563
finger563 force-pushed the refactor/motor-control-consistency branch from 0fa475a to 47c7051 Compare September 3, 2026 19:30
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

✅Static analysis result - no issues found! ✅

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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_raw decodes the four bytes into int32_t. A magnitude with its high bit set therefore arrives negative; forward motion is misreported, while reversing INT32_MIN invokes signed-overflow undefined behavior. Reject unrepresentable magnitudes (or decode as uint32_t and 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 invalid MotorAxis is logged as a valid M2 selection. Use an explicit switch with an "Unknown" fallback, consistent with the enum formatters in components/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_duty is explicitly documented as accepted yet inert on the tested firmware (mcp266.hpp:251-273 and 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

Comment thread components/basicmicro/include/basicmicro.hpp
Comment thread components/motor_controller/CMakeLists.txt
…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>
@finger563

Copy link
Copy Markdown
Contributor Author

Addressed the latest review in c5ddd73be:

  • Out-of-range axis silently targets M2 (basicmicro.hpp, mcp266.hpp) — added a check_axis() guard that every public Axis-form method now calls before any I/O; it rejects anything but M1/M2 with invalid_argument. This also covers Mcp266::axis_state (all its callers validate first), so a MotorAxis decoded from an untrusted byte can no longer fall through the ? : dispatch to M2. Verified: basicmicro + mcp266 examples build clean (IDF v6.0.1).

  • motor_controller packaging — added components/motor_controller/README.md. On the example + build.yml matrix request: motor_controller is a header-only interface/types component, exactly like bldc_types, which ships without an example, examples: manifest entry, or build matrix (and is published fine). A concept has nothing runnable to demonstrate beyond the drivers that implement it, and those are already exercised by the basicmicro/mcp266 examples in CI — so I added the README but intentionally did not add a placeholder example, to match the existing precedent. Happy to add one if you would prefer to diverge from how bldc_types is packaged.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 as INT32_MIN, which cannot be negated as int32_t. Decode the wire value as an unsigned magnitude and explicitly handle values at or beyond the signed range (including the representable reverse INT32_MIN case).
    components/motor_controller/README.md:21
  • A new component is expected to include its own example project and a corresponding build.yml matrix 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

Comment on lines +425 to +428
/// 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.
Comment thread components/motor_controller/include/motor_controller.hpp
…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>
@finger563

Copy link
Copy Markdown
Contributor Author

Follow-ups addressed in 331dbf3f8:

  • MotorAxis formatter printed M2 for invalid values — now an explicit switch that formats an out-of-range value as UNKNOWN, so diagnostics never mislabel malformed input as a valid channel. (Verified with a host test: M1 / M2 / UNKNOWN, clean under -Wall -Wextra.)
  • Mcp266::drive(Axis) bypassed the guard — the advanced-access accessor now returns Ds402Drive* and yields nullptr for anything but M1/M2, so a decoded-byte axis can no longer reach the M2 drive through it. No in-tree callers; pre-1.0, so the reference→pointer change is safe. mcp266 example builds clean (IDF v6.0.1).

@finger563
finger563 merged commit 871fdb3 into main Sep 4, 2026
156 checks passed
@finger563
finger563 deleted the refactor/motor-control-consistency branch September 4, 2026 03:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants