From 1e5fc75d925a4d0794e5700bf61ab2ebb96a31f2 Mon Sep 17 00:00:00 2001 From: AndrewwSS Date: Wed, 11 Nov 2026 18:25:36 +0200 Subject: [PATCH 1/4] docs: add MIT license --- LICENSE | 21 +++++++++++++++++++++ library.json | 1 + 2 files changed, 22 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ac565ae --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AndrewwSSS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/library.json b/library.json index f957f4a..048a8a3 100644 --- a/library.json +++ b/library.json @@ -1,6 +1,7 @@ { "name": "MotorControl", "version": "0.2.0", + "license": "MIT", "description": "Actuator abstraction (hobby servo, PCA9685 servo, stepper axes on pluggable step engines) plus a manager that drives a fleet of them non-blockingly, with endstops and homing, idle power release and pluggable speed profiles.", "keywords": "motor, servo, stepper, pca9685, a4988, endstop, homing, robotics", "frameworks": "arduino", From 22b23f598452936d545ad297523e3c315ccf4b19 Mon Sep 17 00:00:00 2001 From: AndrewwSS Date: Wed, 11 Nov 2026 18:28:18 +0200 Subject: [PATCH 2/4] ci: run tests and example builds on every push --- .github/workflows/check.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/check.yml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..25f85e9 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,35 @@ +# Runs scripts/check.sh: the native unit tests, then every example built for +# each board it supports (Uno, ESP32). +name: check + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # Toolchains and platforms are large; keep them between runs. + - uses: actions/cache@v4 + with: + path: ~/.platformio + key: pio-${{ runner.os }}-${{ hashFiles('scripts/check.sh', 'platformio.ini', 'library.json') }} + restore-keys: pio-${{ runner.os }}- + + - name: Install PlatformIO + run: pip install platformio==6.2.0 + + - name: Run checks + run: scripts/check.sh From f325e7a436e47c6c28f7000c1366e20ff38c08f2 Mon Sep 17 00:00:00 2001 From: AndrewwSS Date: Wed, 11 Nov 2026 18:29:44 +0200 Subject: [PATCH 3/4] docs: move reference sections out of the README into docs/ --- README.md | 311 ++----------------------------------------- docs/architecture.md | 73 ++++++++++ docs/engines.md | 64 +++++++++ docs/homing.md | 46 +++++++ docs/logging.md | 33 +++++ docs/power.md | 40 ++++++ docs/ramping.md | 46 +++++++ 7 files changed, 310 insertions(+), 303 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/engines.md create mode 100644 docs/homing.md create mode 100644 docs/logging.md create mode 100644 docs/power.md create mode 100644 docs/ramping.md diff --git a/README.md b/README.md index e66c447..4b1e73f 100644 --- a/README.md +++ b/README.md @@ -4,44 +4,6 @@ Project-private PlatformIO library for the Ruka arm and the CNC-shield test rig. Everything motor-related lives here; each project's `src/main.cpp` only holds its sketch (Bluetooth protocol, playback, serial jog console). -## Layout - -| File | Role | -| --- | --- | -| `src/AngularMotor.h` | Abstract base: target clamping, non-blocking move loop, state + `reachedTarget()` edge | -| `src/ServoMotor.h` | Backed by the Arduino `Servo` library, or `ESP32Servo` on the ESP32 (one PWM pin per motor) | -| `src/PCA9685Servo.h` | Backed by a shared PCA9685 16-channel PWM driver over I2C | -| `src/Stepper.h` | One step-driven axis: what it should do - target, endstop guard, `MOVING`/`HOLD`, `reachedTarget()` - with the pulses left to its `StepEngine` | -| `src/StepEngine.h` | Interface: how an axis's pulses get made. Owns position, target and the ramp | -| `src/SoftEngine.h` | The engine code shared whenever our own code times the pulses: counting, ramp, endstop inhibit, `tick()` | -| `src/PolledEngine.h` | A `SoftEngine` on step+dir pins, timed against `micros()` from `update()`; any board | -| `src/FasEngine.h` | A `StepEngine` on FastAccelStepper: pulses and ramp in hardware. ESP32; the sketch adds the library | -| `src/StepProfile.h` | Interface for a speed profile: the interval before the next pulse | -| `src/LinearRamp.h` | A `StepProfile` that eases each move in and out | -| `src/EngineStepper.h` | A `Stepper` that owns its engine, of any kind: one `new` gives a whole axis | -| `src/StepDirStepper.h` | An `EngineStepper`, for any step+dir driver - A4988/DRV8825 (a CNC Shield socket), TB6600, DM542 | -| `src/Endstop.h` | A limit switch on its own pin: NO/NC wiring, debounce, which end of travel it guards | -| `src/Homing.h` | `homeAxis()`: finds an axis's base position against one of its endstops | -| `src/MotorManager.h` | Owns a fixed-size fleet of actuators, addressed by index; the part that is the same for servos and steppers | -| `src/ServoManager.h` | `MotorManager` + commands in degrees | -| `src/StepperManager.h` | `MotorManager` + commands in steps, and the fleet's enable line | -| `src/Log.h` | Compile-time log levels and the output stream every header logs through | - -Header-only: there are no `.cpp` files, so nothing is compiled unless a -translation unit includes it. - -## The two roots - -`AngularMotor` models an absolute, clamped, unsigned angle written straight to -the hardware - a servo. A stepper's natural unit is a signed, unbounded step count -relative to a base position it has to earn, so it has its own root, `Stepper`. - -What the two share is their lifecycle: `begin()`, a non-blocking `update()`, a -`State` of `MOVING` or `HOLD`, the one-shot `reachedTarget()` edge and the debug -toggle. That is exactly what fleet management needs, and it is all -`MotorManager` touches - hence one manager, parameterised on the root, with -the unit-specific commands added by each subclass. - ## Usage - servos ```cpp @@ -93,273 +55,16 @@ void loop() { } ``` -### Engines - -A `Stepper` decides what the axis does; a `StepEngine` makes the pulses. The -split is there because there is more than one good way to make them, and the -endstops, homing, ramp and fleet should not care which: - -| Engine | Pulses come from | Boards | -| --- | --- | --- | -| `PolledEngine` | `update()`, timed against `micros()` | any | -| `FasEngine` | FastAccelStepper, in hardware | ESP32, with `gin66/FastAccelStepper@1.4.0` in the sketch's `lib_deps` | -| `TimerEngine` (planned) | a hardware timer running our own `tick()` | ESP32 | - -`StepDirStepper` is a `Stepper` with a `PolledEngine` built in, which is why the -examples above need no engine. To choose one, name it in an `EngineStepper`; the -arguments after the steps per revolution go to the engine: - -```cpp -#include "EngineStepper.h" -#include "PolledEngine.h" - -axes.addMotor(new EngineStepper(/*stepsPerRev*/ 3200, - /*step*/ 2, /*dir*/ 5)); -``` - -Or build the two apart, when the engine has to live somewhere else; a plain -`Stepper` borrows its engine, so declare it at file scope like an endstop: - -```cpp -#include "PolledEngine.h" -#include "Stepper.h" - -PolledEngine xEngine(/*step*/ 2, /*dir*/ 5, PolledEngine::NO_ENABLE_PIN, - /*stepIntervalUs*/ 1000); - -axes.addMotor(new Stepper(xEngine, /*stepsPerRev*/ 3200)); -``` - -A polled engine is only as steady as the loop that calls it: anything that holds -`loop()` up holds the next pulse up too. An engine that steps on its own -(`stepsOnItsOwn()`) keeps going in between, and homing then checks on the axis -once a millisecond instead of spinning on `update()`. - -`FasEngine` is such an engine. Its STEP pulse is half the step interval, so it -takes no pulse width, and its ramp is the profile's `accelerationFor()`. A move -that reverses a running one ramps down first and then goes back, where a polled -engine reverses at full speed. See `examples/FasEngine`. - -### Driver timing - -`PolledEngine` (and so `StepDirStepper`) drives any step+dir driver; what differs -between them is timing. - -- **STEP pulse width** is the last constructor argument: `A4988_PULSE_US` (2 µs, - the default, also fine for a DRV8825) or `TB6600_PULSE_US` (5 µs), or any - number from the driver's datasheet. -- **DIR setup** - how long DIR settles before the next STEP edge - is - `MC_DIR_SETUP_US`, 5 µs by default, and only spent when the direction - changes. Set it per project with `build_flags = -D MC_DIR_SETUP_US=10`, or - override `applyDirection()` in a `PolledEngine` subclass for anything other - than a fixed wait. - -An opto-isolated driver (TB6600, DM542) must be wired common-cathode - PUL-, -DIR-, ENA- to GND and the + inputs to the pins - so that HIGH means active, as -the class assumes. - -### Ramping - -Without a profile an axis starts and stops at one flat interval. A stepper can -only be pulled in from rest below some step rate, set by the rotor and the load - -command it faster and the rotor stops following the field, and the counter goes -on counting steps the axis never took. So a flat interval caps the top speed at -whatever the axis can *start* at, well below what it can sustain. - -A profile fixes that. Attach one and it decides the pace of each pulse: - -```cpp -#include "LinearRamp.h" - -LinearRamp ramp(/*startIntervalUs*/ 4000, /*rampSteps*/ 400); - -axes.setProfiles(&ramp); // whole fleet, or setProfile(id, &ramp) -axes.setStepInterval(0, 400); // now the cruise speed, reached over the ramp -``` - -`setStepInterval()` keeps its meaning as the speed the axis is asked for; the -profile decides how it gets there and back. `getCurrentInterval(id)` reports the -pace right now, which is what shows a ramp working on a bench. - -Profiles are stateless and `const` - the move's state lives in the engine - so -one instance serves a whole fleet and costs each axis nothing but a pointer. -`nullptr` (the default) is a flat interval, exactly as before. - -Two things behave differently once a profile is attached: - -- `stopHere()` stays **immediate**, because that is what an endstop hit needs. - `stopRamped()` is the one that slows down over the profile's stopping distance, - for a stop that is asked for rather than forced. -- Homing (see below) detaches the profile for the duration of the search. The trip point of a - snap-action switch is only repeatable at a constant low speed, which is the - whole reason the search has a slow second pass. - -To write another profile - constant acceleration, an S-curve, a lookup table - -implement `StepProfile::intervalFor()`; see `LinearRamp.h`. - -`onReached(cb)` fires once per axis as it settles, instead of polling -`reachedTarget()` per motor. A mixed fleet is fine because the manager only talks -through `Stepper`; reach a subtype's own methods through `operator[]`: - -```cpp -static_cast(axes[0]).getStepPin(); -``` - -### Endstops and homing - -A limit switch is an `Endstop`: its pin, how it is wired, and which end of travel -it sits at. Attach it to any `Stepper` and the axis refuses to step further onto -it once it is pressed; moving away stays allowed. Each axis has a slot at each -end, so a switch at both ends is two `Endstop`s on two pins: - -```cpp -#include "Endstop.h" - -// COM -> GND, NC -> pin. No resistor: the pin uses INPUT_PULLUP. -Endstop xMin(A4, Endstop::NORMALLY_CLOSED, Endstop::MIN); -Endstop xMax(A5, Endstop::NORMALLY_CLOSED, Endstop::MAX); - -auto* x = new StepDirStepper(2, 5, 3200, StepDirStepper::NO_ENABLE_PIN, 1000); -x->setEndstop(&xMin); // goes into the slot for its side -x->setEndstop(&xMax); -axes.addMotor(x); -``` - -Endstops are borrowed like profiles - declare them at file scope. `begin()` sets -up their pins. Prefer `NORMALLY_CLOSED`: a cut wire then reads as a pressed -switch and stops the axis, where `NORMALLY_OPEN` would read "all clear". - -Homing is a separate, explicit, blocking step after `begin()`. It drives the axis -onto the switch at the end you name, makes the trip point step 0 and parks at -the rest step: - -```cpp -#include "Homing.h" - -// towards restStep maxSteps backoffSteps slowIntervalUs -HomingConfig xHoming = { Endstop::MIN, 100, 20000, 40, 4000 }; - -axes.begin(); -HomingResult r = axes.home(0, xHoming); // energises the drivers first -if (r != HOMED) Serial.println(homingResultName(r)); -``` - -The search is two-pass: a fast seek finds the switch, the axis backs off until it -releases, and a slow re-approach takes the reading - a snap-action switch only -trips at a repeatable point at low speed. A failed search stops and says which -stage failed (`HOMING_NEVER_CLOSED`, `HOMING_NEVER_RELEASED`, `HOMING_NOT_FOUND`, -`HOMING_NO_ENDSTOP`). `isHomed(id)` reports the outcome until `setOrigin()` -redefines step 0. Without the manager, `homeAxis(stepper, cfg)` does the same -on a single axis whose driver is already energised. - -### Releasing the coils when idle - -A stepper draws its full phase current standing still, and that is where most of -a rig's idle heat comes from. Hand the manager the EN pin and a delay, and it -releases the drivers once the whole fleet has been at rest that long, taking them -back by itself on the next move: - -```cpp -StepperManager axes(2, /*enablePin*/ 8); -axes.setIdleRelease(2000); // release after 2 s at rest; 0 (default) never -``` - -There is nothing to call before a move. `update()` energises the line as soon as -any axis has steps outstanding and withholds the pulses until the drivers have -come back up - a pulse sent into a driver that is still waking is a step the -counter claims and the axis never took. The hold is a gate in each engine -(`StepEngine::allowPulses()`), shut while the line is released and while it -wakes, so it also stops an engine that steps on its own. - -The line belongs to the manager rather than to an axis because it is one pin for -the whole fleet: the CNC shield wires every socket to the same one, so no axis -can decide to drop it without stopping the others mid-move, and only the manager -knows when everything has gone quiet. Axes on such a board are built with -`NO_ENABLE_PIN` and leave it alone: +## Documentation -```cpp -axes.addMotor(new StepDirStepper(2, 5, 3200, StepDirStepper::NO_ENABLE_PIN, 1000)); -axes.addMotor(new StepDirStepper(3, 6, 3200, StepDirStepper::NO_ENABLE_PIN, 1000)); -``` - -An axis wired to its own EN pin keeps driving it itself, through -`StepDirStepper::enable()` and `disable()`; construct the manager without a pin and -those axes are untouched. A manager without a pin does no power management at -all, which is the default. - -The cost of releasing is holding torque. A released axis can be back-driven by -gravity, a belt or a hand, and nothing notices - the step counter goes on -reporting where the axis was left, and a homed axis's base position becomes a -guess again. Use it for axes that cannot back-drive, or that are re-homed before -their position is trusted; not for one holding a load up. - -## Logging - -Everything the library prints goes through `Log.h`. Each project picks a level -in its `platformio.ini`: - -```ini -build_flags = -D MC_LOG_LEVEL=MC_LOG_DEBUG -``` - -| Level | Prints | +| Guide | Covers | | --- | --- | -| `MC_LOG_NONE` | nothing | -| `MC_LOG_ERROR` | failures, e.g. a homing search that did not find its switch | -| `MC_LOG_INFO` (default) | start-up: servos driven to their default angle, homing progress, a skipped seek pass | -| `MC_LOG_DEBUG` | every commanded target, for motors with `toggleDebug()` on | -| `MC_LOG_TRACE` | every step or degree moved, for motors with `toggleDebug()` on | - -Messages above the chosen level are removed by the preprocessor, text and all. -`toggleDebug()` switches the DEBUG and TRACE messages per motor at runtime, but -only when the level compiles them in. TRACE prints once per pulse, which slows -the step loop down - keep it for the bench. - -Output goes to `Serial` unless the project points it elsewhere. The stream is -not begun by the library; the sketch still calls `begin()` on it: - -```ini -build_flags = -D MC_LOG_LEVEL=MC_LOG_INFO -D MC_LOG_STREAM=Serial1 -``` - -Set both through `build_flags` rather than a `#define` in `main.cpp`, so every -translation unit sees the same values. Inside the library, log with -`MC_ERROR(F("text "), value, ...)` and its siblings: arguments are printed in -turn with `F()` text kept in flash, and no `String` is built. - -## Adding a motor type - -A different step+dir driver needs no code: give `PolledEngine` its pulse width, -and `MC_DIR_SETUP_US` its DIR setup time. A different way of making pulses is a -new `StepEngine`. If our own code still decides when each pulse is due, subclass -`SoftEngine` and supply `begin()`, `service()`, `stepsOnItsOwn()`, -`applyDirection(bool)` and `pulseStep()` - see `PolledEngine.h`. Otherwise -implement `StepEngine` whole. Either way, any `Stepper` can drive it and any -`StepperManager` fleet can hold that `Stepper`. - -For a servo, subclass `AngularMotor` and implement `begin()` (hardware setup + drive to -the default angle) and `writeAngle(uint16_t)` (one hardware write). `AngularMotor`'s -degree-by-degree `update()` suits anything that takes an absolute angle command; -if it does not fit, the actuator probably wants its own root rather than an -`update()` override - as `Stepper` is. - -A stepper that should be commanded in degrees and join a servo fleet is the one -case that crosses over: implement `AngularMotor` and hold a `Stepper` underneath, -converting with `getStepsPerRevolution()`. - -## Adding a manager - -Only if a third root appears. `MotorManager` needs nothing from `T` beyond -`begin()`, `update()`, `getState()`, `reachedTarget()`, `toggleDebug()`, -`getDebug()` and a `State` enum with `MOVING` and `HOLD`; subclass it and add the -commands in whatever unit that actuator speaks. - -The fleet loop itself is `beginMotors()` / `updateMotors()`, which are protected: -the subclass publishes `begin()` and `update()`, wrapping them with whatever it -has to do around the fleet. `StepperManager` is the example - its `update()` is -where the enable line is energised and the pulses withheld while the drivers come -up - and because the base pair is protected, nothing can call past a subclass and -skip that. +| [Engines](docs/engines.md) | Choosing how pulses are made, driver STEP/DIR timing | +| [Ramping](docs/ramping.md) | Speed profiles, ramped stops, `onReached()` | +| [Endstops and homing](docs/homing.md) | Limit switches, the two-pass homing search | +| [Releasing the coils](docs/power.md) | Idle power release on a shared enable line | +| [Logging](docs/logging.md) | Compile-time log levels and the output stream | +| [Architecture](docs/architecture.md) | File layout, the two roots, adding a motor type or manager | ## Tests diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..54ff400 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,73 @@ +# Architecture + +## Layout + +| File | Role | +| --- | --- | +| `src/AngularMotor.h` | Abstract base: target clamping, non-blocking move loop, state + `reachedTarget()` edge | +| `src/ServoMotor.h` | Backed by the Arduino `Servo` library, or `ESP32Servo` on the ESP32 (one PWM pin per motor) | +| `src/PCA9685Servo.h` | Backed by a shared PCA9685 16-channel PWM driver over I2C | +| `src/Stepper.h` | One step-driven axis: what it should do - target, endstop guard, `MOVING`/`HOLD`, `reachedTarget()` - with the pulses left to its `StepEngine` | +| `src/StepEngine.h` | Interface: how an axis's pulses get made. Owns position, target and the ramp | +| `src/SoftEngine.h` | The engine code shared whenever our own code times the pulses: counting, ramp, endstop inhibit, `tick()` | +| `src/PolledEngine.h` | A `SoftEngine` on step+dir pins, timed against `micros()` from `update()`; any board | +| `src/FasEngine.h` | A `StepEngine` on FastAccelStepper: pulses and ramp in hardware. ESP32; the sketch adds the library | +| `src/StepProfile.h` | Interface for a speed profile: the interval before the next pulse | +| `src/LinearRamp.h` | A `StepProfile` that eases each move in and out | +| `src/EngineStepper.h` | A `Stepper` that owns its engine, of any kind: one `new` gives a whole axis | +| `src/StepDirStepper.h` | An `EngineStepper`, for any step+dir driver - A4988/DRV8825 (a CNC Shield socket), TB6600, DM542 | +| `src/Endstop.h` | A limit switch on its own pin: NO/NC wiring, debounce, which end of travel it guards | +| `src/Homing.h` | `homeAxis()`: finds an axis's base position against one of its endstops | +| `src/MotorManager.h` | Owns a fixed-size fleet of actuators, addressed by index; the part that is the same for servos and steppers | +| `src/ServoManager.h` | `MotorManager` + commands in degrees | +| `src/StepperManager.h` | `MotorManager` + commands in steps, and the fleet's enable line | +| `src/Log.h` | Compile-time log levels and the output stream every header logs through | + +Header-only: there are no `.cpp` files, so nothing is compiled unless a +translation unit includes it. + +## The two roots + +`AngularMotor` models an absolute, clamped, unsigned angle written straight to +the hardware - a servo. A stepper's natural unit is a signed, unbounded step count +relative to a base position it has to earn, so it has its own root, `Stepper`. + +What the two share is their lifecycle: `begin()`, a non-blocking `update()`, a +`State` of `MOVING` or `HOLD`, the one-shot `reachedTarget()` edge and the debug +toggle. That is exactly what fleet management needs, and it is all +`MotorManager` touches - hence one manager, parameterised on the root, with +the unit-specific commands added by each subclass. + +## Adding a motor type + +A different step+dir driver needs no code: give `PolledEngine` its pulse width, +and `MC_DIR_SETUP_US` its DIR setup time. A different way of making pulses is a +new `StepEngine`. If our own code still decides when each pulse is due, subclass +`SoftEngine` and supply `begin()`, `service()`, `stepsOnItsOwn()`, +`applyDirection(bool)` and `pulseStep()` - see `PolledEngine.h`. Otherwise +implement `StepEngine` whole. Either way, any `Stepper` can drive it and any +`StepperManager` fleet can hold that `Stepper`. + +For a servo, subclass `AngularMotor` and implement `begin()` (hardware setup + drive to +the default angle) and `writeAngle(uint16_t)` (one hardware write). `AngularMotor`'s +degree-by-degree `update()` suits anything that takes an absolute angle command; +if it does not fit, the actuator probably wants its own root rather than an +`update()` override - as `Stepper` is. + +A stepper that should be commanded in degrees and join a servo fleet is the one +case that crosses over: implement `AngularMotor` and hold a `Stepper` underneath, +converting with `getStepsPerRevolution()`. + +## Adding a manager + +Only if a third root appears. `MotorManager` needs nothing from `T` beyond +`begin()`, `update()`, `getState()`, `reachedTarget()`, `toggleDebug()`, +`getDebug()` and a `State` enum with `MOVING` and `HOLD`; subclass it and add the +commands in whatever unit that actuator speaks. + +The fleet loop itself is `beginMotors()` / `updateMotors()`, which are protected: +the subclass publishes `begin()` and `update()`, wrapping them with whatever it +has to do around the fleet. `StepperManager` is the example - its `update()` is +where the enable line is energised and the pulses withheld while the drivers come +up - and because the base pair is protected, nothing can call past a subclass and +skip that. diff --git a/docs/engines.md b/docs/engines.md new file mode 100644 index 0000000..cce71fb --- /dev/null +++ b/docs/engines.md @@ -0,0 +1,64 @@ +# Engines + +A `Stepper` decides what the axis does; a `StepEngine` makes the pulses. The +split is there because there is more than one good way to make them, and the +endstops, homing, ramp and fleet should not care which: + +| Engine | Pulses come from | Boards | +| --- | --- | --- | +| `PolledEngine` | `update()`, timed against `micros()` | any | +| `FasEngine` | FastAccelStepper, in hardware | ESP32, with `gin66/FastAccelStepper@1.4.0` in the sketch's `lib_deps` | +| `TimerEngine` (planned) | a hardware timer running our own `tick()` | ESP32 | + +`StepDirStepper` is a `Stepper` with a `PolledEngine` built in, which is why the +[README](../README.md) examples need no engine. To choose one, name it in an `EngineStepper`; the +arguments after the steps per revolution go to the engine: + +```cpp +#include "EngineStepper.h" +#include "PolledEngine.h" + +axes.addMotor(new EngineStepper(/*stepsPerRev*/ 3200, + /*step*/ 2, /*dir*/ 5)); +``` + +Or build the two apart, when the engine has to live somewhere else; a plain +`Stepper` borrows its engine, so declare it at file scope like an endstop: + +```cpp +#include "PolledEngine.h" +#include "Stepper.h" + +PolledEngine xEngine(/*step*/ 2, /*dir*/ 5, PolledEngine::NO_ENABLE_PIN, + /*stepIntervalUs*/ 1000); + +axes.addMotor(new Stepper(xEngine, /*stepsPerRev*/ 3200)); +``` + +A polled engine is only as steady as the loop that calls it: anything that holds +`loop()` up holds the next pulse up too. An engine that steps on its own +(`stepsOnItsOwn()`) keeps going in between, and homing then checks on the axis +once a millisecond instead of spinning on `update()`. + +`FasEngine` is such an engine. Its STEP pulse is half the step interval, so it +takes no pulse width, and its ramp is the profile's `accelerationFor()`. A move +that reverses a running one ramps down first and then goes back, where a polled +engine reverses at full speed. See [`examples/FasEngine`](../examples/FasEngine/FasEngine.ino). + +## Driver timing + +`PolledEngine` (and so `StepDirStepper`) drives any step+dir driver; what differs +between them is timing. + +- **STEP pulse width** is the last constructor argument: `A4988_PULSE_US` (2 µs, + the default, also fine for a DRV8825) or `TB6600_PULSE_US` (5 µs), or any + number from the driver's datasheet. +- **DIR setup** - how long DIR settles before the next STEP edge - is + `MC_DIR_SETUP_US`, 5 µs by default, and only spent when the direction + changes. Set it per project with `build_flags = -D MC_DIR_SETUP_US=10`, or + override `applyDirection()` in a `PolledEngine` subclass for anything other + than a fixed wait. + +An opto-isolated driver (TB6600, DM542) must be wired common-cathode - PUL-, +DIR-, ENA- to GND and the + inputs to the pins - so that HIGH means active, as +the class assumes. diff --git a/docs/homing.md b/docs/homing.md new file mode 100644 index 0000000..6cc0785 --- /dev/null +++ b/docs/homing.md @@ -0,0 +1,46 @@ +# Endstops and homing + +A limit switch is an `Endstop`: its pin, how it is wired, and which end of travel +it sits at. Attach it to any `Stepper` and the axis refuses to step further onto +it once it is pressed; moving away stays allowed. Each axis has a slot at each +end, so a switch at both ends is two `Endstop`s on two pins: + +```cpp +#include "Endstop.h" + +// COM -> GND, NC -> pin. No resistor: the pin uses INPUT_PULLUP. +Endstop xMin(A4, Endstop::NORMALLY_CLOSED, Endstop::MIN); +Endstop xMax(A5, Endstop::NORMALLY_CLOSED, Endstop::MAX); + +auto* x = new StepDirStepper(2, 5, 3200, StepDirStepper::NO_ENABLE_PIN, 1000); +x->setEndstop(&xMin); // goes into the slot for its side +x->setEndstop(&xMax); +axes.addMotor(x); +``` + +Endstops are borrowed like profiles - declare them at file scope. `begin()` sets +up their pins. Prefer `NORMALLY_CLOSED`: a cut wire then reads as a pressed +switch and stops the axis, where `NORMALLY_OPEN` would read "all clear". + +Homing is a separate, explicit, blocking step after `begin()`. It drives the axis +onto the switch at the end you name, makes the trip point step 0 and parks at +the rest step: + +```cpp +#include "Homing.h" + +// towards restStep maxSteps backoffSteps slowIntervalUs +HomingConfig xHoming = { Endstop::MIN, 100, 20000, 40, 4000 }; + +axes.begin(); +HomingResult r = axes.home(0, xHoming); // energises the drivers first +if (r != HOMED) Serial.println(homingResultName(r)); +``` + +The search is two-pass: a fast seek finds the switch, the axis backs off until it +releases, and a slow re-approach takes the reading - a snap-action switch only +trips at a repeatable point at low speed. A failed search stops and says which +stage failed (`HOMING_NEVER_CLOSED`, `HOMING_NEVER_RELEASED`, `HOMING_NOT_FOUND`, +`HOMING_NO_ENDSTOP`). `isHomed(id)` reports the outcome until `setOrigin()` +redefines step 0. Without the manager, `homeAxis(stepper, cfg)` does the same +on a single axis whose driver is already energised. diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 0000000..c2d514a --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,33 @@ +# Logging + +Everything the library prints goes through `Log.h`. Each project picks a level +in its `platformio.ini`: + +```ini +build_flags = -D MC_LOG_LEVEL=MC_LOG_DEBUG +``` + +| Level | Prints | +| --- | --- | +| `MC_LOG_NONE` | nothing | +| `MC_LOG_ERROR` | failures, e.g. a homing search that did not find its switch | +| `MC_LOG_INFO` (default) | start-up: servos driven to their default angle, homing progress, a skipped seek pass | +| `MC_LOG_DEBUG` | every commanded target, for motors with `toggleDebug()` on | +| `MC_LOG_TRACE` | every step or degree moved, for motors with `toggleDebug()` on | + +Messages above the chosen level are removed by the preprocessor, text and all. +`toggleDebug()` switches the DEBUG and TRACE messages per motor at runtime, but +only when the level compiles them in. TRACE prints once per pulse, which slows +the step loop down - keep it for the bench. + +Output goes to `Serial` unless the project points it elsewhere. The stream is +not begun by the library; the sketch still calls `begin()` on it: + +```ini +build_flags = -D MC_LOG_LEVEL=MC_LOG_INFO -D MC_LOG_STREAM=Serial1 +``` + +Set both through `build_flags` rather than a `#define` in `main.cpp`, so every +translation unit sees the same values. Inside the library, log with +`MC_ERROR(F("text "), value, ...)` and its siblings: arguments are printed in +turn with `F()` text kept in flash, and no `String` is built. diff --git a/docs/power.md b/docs/power.md new file mode 100644 index 0000000..af0517e --- /dev/null +++ b/docs/power.md @@ -0,0 +1,40 @@ +# Releasing the coils when idle + +A stepper draws its full phase current standing still, and that is where most of +a rig's idle heat comes from. Hand the manager the EN pin and a delay, and it +releases the drivers once the whole fleet has been at rest that long, taking them +back by itself on the next move: + +```cpp +StepperManager axes(2, /*enablePin*/ 8); +axes.setIdleRelease(2000); // release after 2 s at rest; 0 (default) never +``` + +There is nothing to call before a move. `update()` energises the line as soon as +any axis has steps outstanding and withholds the pulses until the drivers have +come back up - a pulse sent into a driver that is still waking is a step the +counter claims and the axis never took. The hold is a gate in each engine +(`StepEngine::allowPulses()`), shut while the line is released and while it +wakes, so it also stops an engine that steps on its own. + +The line belongs to the manager rather than to an axis because it is one pin for +the whole fleet: the CNC shield wires every socket to the same one, so no axis +can decide to drop it without stopping the others mid-move, and only the manager +knows when everything has gone quiet. Axes on such a board are built with +`NO_ENABLE_PIN` and leave it alone: + +```cpp +axes.addMotor(new StepDirStepper(2, 5, 3200, StepDirStepper::NO_ENABLE_PIN, 1000)); +axes.addMotor(new StepDirStepper(3, 6, 3200, StepDirStepper::NO_ENABLE_PIN, 1000)); +``` + +An axis wired to its own EN pin keeps driving it itself, through +`StepDirStepper::enable()` and `disable()`; construct the manager without a pin and +those axes are untouched. A manager without a pin does no power management at +all, which is the default. + +The cost of releasing is holding torque. A released axis can be back-driven by +gravity, a belt or a hand, and nothing notices - the step counter goes on +reporting where the axis was left, and a homed axis's base position becomes a +guess again. Use it for axes that cannot back-drive, or that are re-homed before +their position is trusted; not for one holding a load up. diff --git a/docs/ramping.md b/docs/ramping.md new file mode 100644 index 0000000..9c5267a --- /dev/null +++ b/docs/ramping.md @@ -0,0 +1,46 @@ +# Ramping + +Without a profile an axis starts and stops at one flat interval. A stepper can +only be pulled in from rest below some step rate, set by the rotor and the load - +command it faster and the rotor stops following the field, and the counter goes +on counting steps the axis never took. So a flat interval caps the top speed at +whatever the axis can *start* at, well below what it can sustain. + +A profile fixes that. Attach one and it decides the pace of each pulse: + +```cpp +#include "LinearRamp.h" + +LinearRamp ramp(/*startIntervalUs*/ 4000, /*rampSteps*/ 400); + +axes.setProfiles(&ramp); // whole fleet, or setProfile(id, &ramp) +axes.setStepInterval(0, 400); // now the cruise speed, reached over the ramp +``` + +`setStepInterval()` keeps its meaning as the speed the axis is asked for; the +profile decides how it gets there and back. `getCurrentInterval(id)` reports the +pace right now, which is what shows a ramp working on a bench. + +Profiles are stateless and `const` - the move's state lives in the engine - so +one instance serves a whole fleet and costs each axis nothing but a pointer. +`nullptr` (the default) is a flat interval, exactly as before. + +Two things behave differently once a profile is attached: + +- `stopHere()` stays **immediate**, because that is what an endstop hit needs. + `stopRamped()` is the one that slows down over the profile's stopping distance, + for a stop that is asked for rather than forced. +- Homing (see [Endstops and homing](homing.md)) detaches the profile for the duration of the search. The trip point of a + snap-action switch is only repeatable at a constant low speed, which is the + whole reason the search has a slow second pass. + +To write another profile - constant acceleration, an S-curve, a lookup table - +implement `StepProfile::intervalFor()`; see `LinearRamp.h`. + +`onReached(cb)` fires once per axis as it settles, instead of polling +`reachedTarget()` per motor. A mixed fleet is fine because the manager only talks +through `Stepper`; reach a subtype's own methods through `operator[]`: + +```cpp +static_cast(axes[0]).getStepPin(); +``` From b132f9570d44fc23b39e78dd3d859541085a3dfe Mon Sep 17 00:00:00 2001 From: AndrewwSS Date: Wed, 11 Nov 2026 18:33:15 +0200 Subject: [PATCH 4/4] docs: rewrite the README as a short front page --- README.md | 129 ++++++++++++++++++++++++++++++++---------------- docs/engines.md | 2 +- docs/usage.md | 53 ++++++++++++++++++++ 3 files changed, 140 insertions(+), 44 deletions(-) create mode 100644 docs/usage.md diff --git a/README.md b/README.md index 4b1e73f..a22e78c 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,104 @@ # MotorControl -Project-private PlatformIO library for the Ruka arm and the CNC-shield test rig. -Everything motor-related lives here; each project's `src/main.cpp` only holds -its sketch (Bluetooth protocol, playback, serial jog console). +[![check](https://github.com/AndrewwSSS/MotorControl/actions/workflows/check.yml/badge.svg)](https://github.com/AndrewwSSS/MotorControl/actions/workflows/check.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +![Platforms](https://img.shields.io/badge/platforms-AVR%20%7C%20ESP32-informational) + +A header-only Arduino / PlatformIO library that drives a fleet of servos and +stepper axes without blocking `loop()`: endstops, two-pass homing, speed ramps, +soft limits and idle power release, with the pulse generation swappable per axis. +It runs two robotic arms and a CNC-shield test rig. + + + +## Features + +- **Non-blocking fleet control.** One `update()` per `loop()` moves every axis; + moves on several axes start together and interleave their pulses. +- **Pluggable step engines.** The same axis logic runs on a polled `micros()` + loop on any board, or on [FastAccelStepper](https://github.com/gin66/FastAccelStepper) + with pulses and ramps in hardware on the ESP32. +- **Endstops and homing.** NO/NC switches with debounce. Homing does a fast seek, + backs off, then re-approaches slowly for a repeatable zero, and reports which + stage failed. +- **Speed profiles.** A linear ramp out of the box; write your own (S-curve, + lookup table) by implementing one method. +- **Soft limits** per axis, armed once the position is known. +- **Idle power release.** The shared enable line drops after a quiet period and + comes back before the next move, without sending pulses into a waking driver. +- **Servos too.** Hobby servos (`Servo` / `ESP32Servo`) and PCA9685 boards, + managed the same way as steppers. +- **Driver-agnostic.** A4988, DRV8825, TB6600, DM542: any step+dir driver, with + its pulse width and DIR setup time configurable. + +## Quick start + +`platformio.ini`: + +```ini +lib_deps = https://github.com/AndrewwSSS/MotorControl.git +``` -## Usage - servos +Two axes on a CNC shield, one homed against a limit switch, both under a ramp: ```cpp -#include "ServoManager.h" -#include "PCA9685Servo.h" - -Adafruit_PWMServoDriver pwm; // SDA -> A4, SCL -> A5 on an Uno -ServoManager arm(6); +#include +#include +#include +#include +#include + +Endstop xMin(9, Endstop::NORMALLY_CLOSED, Endstop::MIN); +LinearRamp ramp(/*startIntervalUs*/ 1000, /*rampSteps*/ 1000); +StepperManager axes(2, /*shared enable pin*/ 8); void setup() { - pwm.begin(); - pwm.setPWMFreq(50); // must run before any PCA9685Servo::begin() + auto* x = new StepDirStepper(/*step*/ 2, /*dir*/ 5, /*stepsPerRev*/ 3200, + StepDirStepper::NO_ENABLE_PIN, /*stepIntervalUs*/ 200); + x->setEndstop(&xMin); + axes.addMotor(x); + axes.addMotor(new StepDirStepper(3, 6, 3200, StepDirStepper::NO_ENABLE_PIN, 200)); - for (uint8_t ch = 0; ch < 6; ch++) - arm.addMotor(new PCA9685Servo(pwm, ch, /*defaultAngle*/ 90, /*rotationDelay*/ 10)); + axes.setProfiles(&ramp); + axes.begin(); + axes.home(0, { Endstop::MIN, /*rest*/ 200, /*maxSteps*/ 20000, + /*backoff*/ 40, /*slowIntervalUs*/ 4000 }); - arm.begin(); + axes.moveToStep(0, 3200); // both start now and run together + axes.moveToStep(1, -1600); } void loop() { - arm.update(); // call every iteration; movement is non-blocking + axes.update(); // call every iteration; never blocks } ``` -## Usage - steppers - -```cpp -#include "StepperManager.h" -#include "StepDirStepper.h" - -StepperManager axes(2, /*shared enable pin*/ 8); - -void setup() { - axes.addMotor(new StepDirStepper(/*step*/ 2, /*dir*/ 5, /*stepsPerRev*/ 3200, - StepDirStepper::NO_ENABLE_PIN, - /*stepIntervalUs*/ 1000)); // id 0 - axes.addMotor(new StepDirStepper(3, 6, 3200, - StepDirStepper::NO_ENABLE_PIN, 1000, - StepDirStepper::TB6600_PULSE_US)); // id 1, on a TB6600 +More in [`examples/`](examples): [Steppers](examples/Steppers/Steppers.ino), +[Servos](examples/Servos/Servos.ino) and [FasEngine](examples/FasEngine/FasEngine.ino). - axes.begin(); +## How it is built - axes.moveSteps(0, 400); // both start now and advance together: - axes.moveToStep(1, -800); // update() interleaves their pulses -} - -void loop() { - axes.update(); -} +```mermaid +flowchart LR + M["StepperManager
fleet, enable line, homing"] --> S["Stepper
target, endstops, soft limits"] + S --> E{{"StepEngine
position, ramp, pulses"}} + E --> P["PolledEngine
micros(), any board"] + E --> F["FasEngine
FastAccelStepper, ESP32"] + S -. borrows .-> ES["Endstop"] + E -. asks .-> R["StepProfile
LinearRamp"] ``` +A `Stepper` decides *what* an axis should do; a `StepEngine` decides *how* its +pulses are made. Endstops, homing, ramps and the fleet only talk to the +interfaces, so a new engine (for example a hardware-timer one) needs no change +anywhere else. Servos have their own root, `AngularMotor`, and share the same +`MotorManager`. See [Architecture](docs/architecture.md). + ## Documentation | Guide | Covers | | --- | --- | +| [Usage](docs/usage.md) | Servo and stepper fleets step by step: PCA9685, several axes, TB6600 timing | | [Engines](docs/engines.md) | Choosing how pulses are made, driver STEP/DIR timing | | [Ramping](docs/ramping.md) | Speed profiles, ramped stops, `onReached()` | | [Endstops and homing](docs/homing.md) | Limit switches, the two-pass homing search | @@ -68,8 +108,9 @@ void loop() { ## Tests -The logic runs on the host against a fake Arduino core (`test/fake/`), so it is -checked without a board: +130 unit tests run on the host against a fake Arduino core, so the stepping, +endstop, ramp and homing logic is checked without a board. CI also builds every +example for the Uno and the ESP32. ```sh pio test -e native # unit tests only @@ -77,6 +118,8 @@ scripts/check.sh # unit tests, plus the examples built for each board ``` `test/fake/FakeEngine.h` is a `StepEngine` with no pins, for testing `Stepper`, -homing and the fleet on their own - both as a polled engine and as one that -steps on its own. Projects that use the library have their own -`scripts/check.sh`; run it after a library change too. +homing and the fleet on their own. + +## License + +[MIT](LICENSE) diff --git a/docs/engines.md b/docs/engines.md index cce71fb..f9b389a 100644 --- a/docs/engines.md +++ b/docs/engines.md @@ -11,7 +11,7 @@ endstops, homing, ramp and fleet should not care which: | `TimerEngine` (planned) | a hardware timer running our own `tick()` | ESP32 | `StepDirStepper` is a `Stepper` with a `PolledEngine` built in, which is why the -[README](../README.md) examples need no engine. To choose one, name it in an `EngineStepper`; the +[usage](usage.md) examples need no engine. To choose one, name it in an `EngineStepper`; the arguments after the steps per revolution go to the engine: ```cpp diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..a5cbce0 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,53 @@ +# Usage + +## Servos + +```cpp +#include "ServoManager.h" +#include "PCA9685Servo.h" + +Adafruit_PWMServoDriver pwm; // SDA -> A4, SCL -> A5 on an Uno +ServoManager arm(6); + +void setup() { + pwm.begin(); + pwm.setPWMFreq(50); // must run before any PCA9685Servo::begin() + + for (uint8_t ch = 0; ch < 6; ch++) + arm.addMotor(new PCA9685Servo(pwm, ch, /*defaultAngle*/ 90, /*rotationDelay*/ 10)); + + arm.begin(); +} + +void loop() { + arm.update(); // call every iteration; movement is non-blocking +} +``` + +## Steppers + +```cpp +#include "StepperManager.h" +#include "StepDirStepper.h" + +StepperManager axes(2, /*shared enable pin*/ 8); + +void setup() { + axes.addMotor(new StepDirStepper(/*step*/ 2, /*dir*/ 5, /*stepsPerRev*/ 3200, + StepDirStepper::NO_ENABLE_PIN, + /*stepIntervalUs*/ 1000)); // id 0 + axes.addMotor(new StepDirStepper(3, 6, 3200, + StepDirStepper::NO_ENABLE_PIN, 1000, + StepDirStepper::TB6600_PULSE_US)); // id 1, on a TB6600 + + axes.begin(); + + axes.moveSteps(0, 400); // both start now and advance together: + axes.moveToStep(1, -800); // update() interleaves their pulses +} + +void loop() { + axes.update(); +} +``` +