Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
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.
It drives [scara-arm](https://github.com/AndrewwSSS/scara-arm), a 3D-printed
SCARA robot, along with a servo arm and a test rig.

<!-- Demo GIF goes here: ![Homing and moving](docs/media/demo.gif) -->

Expand Down Expand Up @@ -105,6 +106,7 @@ anywhere else. Servos have their own root, `AngularMotor`, and share the same
| [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 |
| [Testing](docs/testing.md) | Host tests, the fake Arduino core and the fake engines |

## Tests

Expand Down
36 changes: 32 additions & 4 deletions docs/engines.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,38 @@ A polled engine is only as steady as the loop that calls it: anything that holds
(`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).
The engine, not the `Stepper`, owns the position, the target and the ramp:
an engine that steps on its own is the only part that knows where the axis is
between two `update()` calls. Positions are signed step counts from the base
position.

## FasEngine

`FasEngine` is an engine that steps on its own. FastAccelStepper makes its
pulses in hardware on the ESP32 (MCPWM, counted by PCNT), so the pace holds
however busy `loop()` is. See [`examples/FasEngine`](../examples/FasEngine/FasEngine.ino).

- **Pulse width:** the STEP pulse is half the step interval, so there is no
pulse width to set. A TB6600's 5 µs is met by any interval of 10 µs or more.
- **DIR setup:** the library's direction-change delay, which rounds
`MC_DIR_SETUP_US` up to its own minimum (200 µs on the ESP32). It is only
spent on a reversal.
- **Ramp:** the library ramps from one constant acceleration, the profile's
`accelerationFor()`.

Where FastAccelStepper and `StepEngine` behave differently, `FasEngine` bridges
the gap:

- **Reversals:** the library ignores a move that reverses a running one. Here
the axis ramps down first, and `service()` sends the new move once it stops.
A polled engine reverses at full speed.
- **Immediate stops:** `stopNow()` drops the library's queue at once, instead of
letting about 20 ms of queued steps run out, which at speed would be a hundred
steps onto a switch.
- **The fleet's pulse gate** does not exist in the library. A move commanded
while it is shut waits; a move running when it shuts stops and resumes later.
- **One library instance** runs every axis. `FasEngine` starts it on first use,
so an axis can be built from its pins alone, like any other engine.

## Driver timing

Expand Down
49 changes: 49 additions & 0 deletions docs/homing.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,52 @@ 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.

### How the search behaves

- **Starting on the switch:** the seek pass is skipped and the axis goes straight
to the back-off. The base position always comes from a fresh closing edge
during the slow re-approach.
- **Back-off:** the axis retreats in `backoffSteps` increments until the switch
releases, not by one fixed distance, because it may start further past the
trip point than any single increment (pushed by hand, or left there by an
earlier failed run). The retreat is bounded by `maxSteps`, so a contact stuck
closed fails instead of driving the axis off the far end.
- **Both switches pressed:** if the switch at the other end stops the retreat,
the axis is pinned between two "pressed" switches. That is a wiring fault, and
homing reports it instead of retrying.
- **A move in flight** is cancelled before the search starts, so it is not added
to the seek.
- **The speed profile** is detached during the search and restored for the move
to the rest step.
- **Soft limits** are switched off during the search, which has to run past them
to find the switch. They are armed when homing succeeds, and restored as they
were when it fails.
- **No reached callback** fires for the search's own stops, only for moves the
sketch commands.
- **Engines that step on their own** are checked once a millisecond during the
search instead of spinning on `update()`, leaving the CPU to other tasks.

## Endstop debounce

A snap-action switch chatters for a few milliseconds around the transition.
`Endstop` acts on the first edge it sees and then ignores the pin until the
contacts settle: waiting for a stable level would delay detection and let the
axis overshoot. A homing run reads the pin directly (`resync()`), which opens no
ignore window, so a press right after it is seen at once.

## Soft limits

Soft limits are the range of steps an axis may be sent to:

```cpp
axes.setLimits(0, /*minStep*/ 0, /*maxStep*/ 20000);
```

- A move whose target is outside the range is refused and logged; the axis keeps
its previous target. An axis already outside the range may still be sent back
towards it, so it never gets stuck.
- Limits only mean something once step 0 is a known place, so they are enforced
only when **armed**: after a successful `home()`, or after `setOrigin()`
defines step 0 by hand.
- A move already running past a new limit is cut short at the limit.
25 changes: 25 additions & 0 deletions docs/ramping.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,31 @@ Two things behave differently once a profile is attached:
snap-action switch is only repeatable at a constant low speed, which is the
whole reason the search has a slow second pass.

## How `LinearRamp` shapes a move

The interval goes linearly from `startIntervalUs` down to the axis's own
interval over the first `rampSteps` steps, and back up over the last
`rampSteps`. It is linear in *interval*, not in speed, so the pull away from
rest is brisker than the approach to cruise. That is a cheap approximation: it
needs no division per step.

The shape comes from two curves, one measured from each end of the move, and the
slower of the two wins at every step. A move too short to reach cruise speed
simply peaks where the curves cross, with no special case.

- `startIntervalUs` must be larger (slower) than the axis's own interval;
otherwise there is nothing to ramp and the profile has no effect.
- Keep `startIntervalUs * rampSteps` within 32 bits.
- `setRampSteps()` and `setStartInterval()` work while running, which is how a
ramp is tuned on a bench. The instance is shared, so a change affects every
axis that uses it.
- For engines that ramp in hardware, `accelerationFor()` turns the ramp into one
constant acceleration, (v_top² − v_start²) / (2 · rampSteps) in steps/s². It
is approximate, because the ramp is linear in interval, but moves start,
cruise and end in the same places: 1000 µs down to 200 µs over 1000 steps
gives 12,000 steps/s². It uses integer maths only, so AVR builds don't pull in
floating point.

To write another profile - constant acceleration, an S-curve, a lookup table -
implement `StepProfile::intervalFor()`; see `LinearRamp.h`.

Expand Down
66 changes: 66 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Testing

The library's logic runs on the host, against fakes, so it is tested without a
board:

```sh
pio test -e native # unit tests only
scripts/check.sh # unit tests, plus every example built for each board
```

CI runs `scripts/check.sh` on every push and pull request.

## The fake Arduino core

[`test/fake/Arduino.h`](../test/fake/Arduino.h) is just enough of the Arduino
core for the headers to compile and run on the host. Only the `native`
environment puts `test/fake/` on the include path; board builds use their real
core. Nothing touches hardware, so a test controls everything a sketch would get
from outside:

| Area | Behaviour |
| --- | --- |
| Time | Stands still until the test moves it (`fake::advanceUs`), or moves a fixed amount on every `micros()` call (`fake::setAutoAdvance`), so busy-wait loops such as homing make progress. |
| Writes | Every `digitalWrite` is recorded with its time, so tests can count step pulses and check their spacing. |
| Reads | `digitalRead` returns what the test scripted, per pin or through a handler that can follow the axis (for example, a switch that closes at some step). |
| Serial | Collects everything printed, log output included. |

`fake::reset()` returns to power-on: time 0, no writes, every input reading HIGH
(a pulled-up pin), Serial empty. Call it from `setUp()`.

`F()` strings keep their own type, as on AVR, so overloads resolve exactly as
they do on a board.

**Known gap:** `unsigned long` is 64 bits on the host and 32 bits on the boards,
so the `micros()` rollover cannot be reproduced. Tests keep time well clear of it.

## FakeEngine

[`test/fake/FakeEngine.h`](../test/fake/FakeEngine.h) is a `StepEngine` with no
pins and no timing, for testing the logic above the engine (`Stepper`, homing,
the fleet) on its own. It records what it was told and moves in one of two
modes:

- **POLLED:** one step per `service()`, which returns true, like `PolledEngine`.
- **OWN:** `stepsPerService` steps per `service()`, which returns false, like an
engine that steps on its own. The steps come in a burst, the worst case for
how late the endstop guard sees a switch.

Each step moves fake time on by the step interval, so endstop debounce runs as
it would on a real axis. `shaft()` is where the motor really is: unlike
`position()`, `setPosition()` does not reset it, so homing tests can check where
step 0 ended up.

## Fake FastAccelStepper

[`test/fake/FastAccelStepper.h`](../test/fake/FastAccelStepper.h) stands in for
FastAccelStepper 1.4.0, with only the calls `FasEngine` makes. It copies the
library's behaviour where that shapes `FasEngine`:

- `moveTo()` while running is ignored when it would reverse the move, or stop it
where it is.
- `stopMove()` ramps down over `stepsToStop()` steps, never past the target.
- `forceStopAndNewPosition()` stops at once.
- Intervals faster than the hardware can step are refused.

Nothing moves on its own: a test calls `run(n)` to let up to n steps go out.
10 changes: 4 additions & 6 deletions examples/FasEngine/FasEngine.ino
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
// Two axes on the ESP32 whose pulses come from FastAccelStepper, in hardware:
// the swing stays smooth however long loop() is held up. Each axis owns its
// engine through EngineStepper; the ramp is the same LinearRamp a polled axis
// uses, which FasEngine hands the library as one acceleration.
// Two axes on the ESP32 with pulses from FastAccelStepper, in hardware, so the
// motion stays smooth however long loop() is held up. Each axis owns its engine
// through EngineStepper and shares one LinearRamp.
//
// ESP32 only, and needs gin66/FastAccelStepper@1.4.0 in lib_deps.
// scripts/check.sh compiles it against the real library.
// ESP32 only; needs gin66/FastAccelStepper@1.4.0 in lib_deps.

#include <Arduino.h>
#include <EngineStepper.h>
Expand Down
5 changes: 1 addition & 4 deletions examples/Servos/Servos.ino
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
// A hobby servo on its own pin and one on a PCA9685 channel, in one fleet,
// sweeping between two angles.
//
// Builds for the Uno and the ESP32; scripts/check.sh compiles it for both.
// On the ESP32 the PCA9685 sits on the default I2C pins, SDA 21 and SCL 22.
// sweeping between two angles. Uno or ESP32 (PCA9685 on SDA 21, SCL 22).

#include <Arduino.h>
#include <Adafruit_PWMServoDriver.h>
Expand Down
12 changes: 4 additions & 8 deletions examples/Steppers/Steppers.ino
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
// Two step+dir axes sharing one EN line: the first homes against a limit
// switch at start-up, then both swing back and forth under a speed ramp. X is
// a StepDirStepper, the one-line form; Y is a Stepper built on an engine of its
// own, the form that lets an axis pick its engine.
//
// Builds for the Uno (CNC Shield pins) and the ESP32 (the pin map from the
// migration plan). scripts/check.sh compiles it for both, which is how the
// library proves it still builds on each board.
// Two step+dir axes sharing one EN line: X homes against a limit switch at
// start-up, then both swing back and forth under a speed ramp. X is a
// StepDirStepper; Y is a Stepper on a separately declared engine.
// Uno (CNC Shield pins) or ESP32.

#include <Arduino.h>
#include <Endstop.h>
Expand Down
9 changes: 3 additions & 6 deletions platformio.ini
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
; Host-side unit tests for the library: pio test -e native
;
; The headers compile against test/fake/Arduino.h instead of a real core, so the
; stepping, endstop and homing logic can be checked on the Mac without a board.
; Sketches that use the library (Ruka, BigHardDick) never read this file - they
; build the library from library.json against their own board.
; Host-side unit tests: pio test -e native
; Builds against test/fake/Arduino.h instead of a real core. Projects using the
; library ignore this file and build it from library.json.

[platformio]
default_envs = native
Expand Down
24 changes: 7 additions & 17 deletions src/AngularMotor.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
#include "Log.h"


// Common interface for any angle-driven actuator (hobby servo, PCA9685-driven
// servo, geared stepper acting as a joint, ...).
// Base for angle-driven actuators (hobby servo, PCA9685 servo, geared joint).
class AngularMotor {
public:
enum State {
Expand All @@ -17,8 +16,7 @@ class AngularMotor {
// Attaches/initializes the underlying hardware and drives it to defaultAngle.
virtual void begin() = 0;

// Advances the motor towards targetAngle. Must be called repeatedly (e.g.
// from loop()).
// Moves towards targetAngle; call from loop().
virtual void update() {
if (currentAngle == targetAngle) {
setState(HOLD);
Expand Down Expand Up @@ -53,8 +51,7 @@ class AngularMotor {
return this->currentAngle;
}

// Per-motor switch for the DEBUG and TRACE messages. Has no effect unless
// MC_LOG_LEVEL compiles them in - see Log.h.
// Per-motor DEBUG/TRACE output, if MC_LOG_LEVEL compiles it in.
void toggleDebug() {
this->debugEnabled = !this->debugEnabled;
}
Expand All @@ -67,19 +64,15 @@ class AngularMotor {
return this->state;
}

// True exactly once, the first time this is polled after the motor
// settles into HOLD. Lets a manager fire a "reached" callback without
// keeping its own shadow copy of every motor's previous state.
// True once, on the first poll after the motor settles into HOLD.
bool reachedTarget() {
bool r = justReached;
justReached = false;
return r;
}

protected:
// rotationDelay is the time between 1-degree steps in the default update()
// loop (lower = faster sweep speed). Subclasses with their own update()
// override can ignore it.
// rotationDelay: ms between 1-degree steps in the default update().
explicit AngularMotor(
uint16_t defaultAngle,
uint16_t rotationDelay = DEFAULT_ROTATION_DELAY,
Expand All @@ -102,18 +95,15 @@ class AngularMotor {
static constexpr uint16_t MAX_ANGLE = 180;
static constexpr uint16_t DEFAULT_ROTATION_DELAY = 20; // ms

// Derived classes report motion state through here instead of writing
// `state` directly, so the MOVING -> HOLD edge (used by reachedTarget())
// is only ever detected in one place.
// The only writer of `state`, so the HOLD edge is detected in one place.
void setState(State newState) {
if (newState == HOLD && state != HOLD) {
justReached = true;
}
state = newState;
}

// Physically drives the actuator to the given angle. Called from
// update() on every step and from begin() to reach the initial position.
// One hardware write to the given angle.
virtual void writeAngle(uint16_t angle) = 0;

bool debugEnabled = false;
Expand Down
Loading
Loading