-
Notifications
You must be signed in to change notification settings - Fork 356
Motor control
This discussion assumes you understand the principles of digital I/O and PWM. If not, read about them here and here.
One of the most common uses for microcontrollers is to drive motors and other kinds of actuators. An important family of applications under that category is motion control, in which a number of actuators need to be controlled in synchronization with each other and with high speed and accuracy. Such applications include CNC machines of all sorts, 3D printers and various robots.
The Motor Control API, which was added to the IOIO library in version 5.00 makes it easy to control a large variety of such setups with very high accuracy and speed. What it does essentially is generate digital waveforms of various kinds, which correspond to different kinds of actuators. To understand how it works, we should first familiarize ourselves with some of the commonly-used actuators that the library can support.
Important: The IOIO I/O pins are not intended to drive high-current loads directly. The control signals produced by those pins are intended to go into appropriate driver circuitry, which amplifies the power and sometimes add some additional logic, specific to the actuator it is driving. Small indicator LEDs are OK to connect directly to the I/O pins.
These are the simplest type of actuators, which only have two states. A simple digital output is used to control it. When it is low, they will go to one state (such as turn an LED off or contract a solenoid armature) and when it is high, to the other (such as turn an LED on or extend a solenoid armature). Although such actuators could be controlled with a DigitalOutput, in the context of this API, we can control them with very precise timing and with synchronization to other signals.
Brushed DC motors (or simply, DC motors) can turn in either direction with a speed that is proportional to the amount of voltage applied to their leads. They are typically driven by circuits called H-bridges, which are commonly available for different motor ratings as small modules or IC. These drivers will typically have either one PWM input for speed and one digital input for direction or two PWM inputs, where each one would drive in a different direction. The PWM signal serves the purpose of effectively scaling the (average) input voltage applied on the motor leads. We would often use frequencies that are above the hearing threshold (20KHz) to avoid having audible beeping. However, higher frequencies are often associated with reduction in driver efficiency, where some of the energy goes into heating the driver instead of into mechanical energy.
Hobby (or R/C) servo motors, or simply "servos" are very popular among electronics hobbyists. The most common type has a little arm or wheel that can keep a precise position in a 180-degree range. They are very frequently used for actuating control surfaces on R/C airplanes, steers the wheels of R/C cars, move robot joints or any other kind of motion that has a limited range and requires us to precisely control the position. A slightly less common type of servos are called continuous-rotation servos, in which the shaft can turn continuously any number of turns, and instead of controlling its position we control its speed. Hobby servos come in a variety of (standard) sizes, torques and prices.
One thing which makes servos very attractive to use is the simplicity of wiring them. Servos comes with their driver circuitry built into their package (as well as gears, by the way), and have a standard connector which requires a voltage supply (most frequently 5V, which can be taken from the IOIO 5V pins) and a single digital control signal.
The control signal is a PWM signal. However, unlike the DC-motor case, where this signal is directly used to switch the voltage applied to the motor, in this case the signal just acts as a protocol to tell the motor to which angle we want it to go or how fast (in the case of continuous rotation). The specifications for this PWM signal are also standard: the frequency should always be 50Hz and the pulse-width should vary between 1ms to 2ms. Typically, a 1ms pulse-width would move the arm to angle 0, a 2ms pulse-width to angle 180, a 1.5ms pulse-width to angle 90, etc. In the case of continuous rotation, 1.5ms is typically stopped, where 2ms is full-speed forward and 1ms is full-speed backward. Of course, every value in between is valid.
Other kinds of drivers in the hobby world use the same kind of PWM control signal to drive different kinds of motors, usually in speed-control mode. The common name for these devices is Electronic Speed Controllers (ESC), and they are often used to drive brushless or brushed DC motors. Commonly, these will be propeller motors for planes and helicopters, wheels for race cars or other kinds of high-power motors. From the perspective of the control signals, these are identical to continuous-rotation servos.
Stepper motors, or "steppers", somehow provide the best of both worlds of the two servo types: we can make them turn a precise angle like normal servos and they can turn continuously like continuous rotation servos. Very often, their angular resolution is a lot better than that of hobby servos, with a resolution of few tenths being very common. One drawback in comparison to servos are that they can be told to rotate a certain angle relative to their current position, whereas the absolute position is not generally known (unless using some additional sensor). That also means, that if an external force prevents the motor from turning or forces it to turn, the stepper will "skip" steps and will forever carry this cummulative error. In practice, however, it is often feasible to mechanically make sure this does not happen. Another drawback is that they do not typically come with their driver electronics built-in. However, stepper motor drivers are widely available as small modules or IC.
Stepper motors drivers vary by their voltage and current ratings, but their control interface is almost always the same. One control signal will move the motor a single step, of a known angle on every rising edge and a second control signal will control the direction of rotation (high is one direction, low is the other). The step angle is a property of the motor itself (1.8 degrees is typical), while many drivers support a feature called micro-stepping, which divides the step angle by some amount (up to 16x is pretty typical) to increase the resolution. Configuring the amount of micro-stepping, the current and various other parameters of the driver are typically configured using separate pins statically and do not need to be changed in run-time by the microcontroller.
The IOIO motor control API introduces the notion of channels. Each channel generates a single waveform of a given type, typically (but not always) driving a single output pin. On a given application, you would normally have multiple channels of different types, which are then controlled in synchronization to achieve a complex motion.
A single state or commands for all the channels at a certain moment is called a cue. For example, in a setup comprising an LED hooked up to a single channel and a DC motor to another, a cue might be "the LED is on and the motor is moving at 50% speed forward". In other words, a cue is a complete set of instructions for what each channel needs to be doing at a given point in time.
There are two distinct ways to use cues in the motor control API. The first one, called manual mode, allows you to explicitly specify a cue to be executed immediately. This is useful, for example, if your application has a user interface that allows the user to directly control motion of a machine. Imagine a joystick that can move a gantry system in XY coordinates. The second one, called sequenced mode, allows the user to stream in a list of <cue, duration> pairs, which will be executed sequentially with precise timing, by an entity called a sequencer. Such a pair is referred to as a timed cue. For example, to extend our previous example, a sequence for our LED / motor machine might be something like:
- Turn the LED on and drive the motor forward at 50% speed for 1 second.
- Turn the LED off and drive the motor forward at 40% speed for 100 milliseconds.
- Turn the LED on and stop the motor.
- ...
Or a more practical example comprising two stepper motors driving an XY system:
- Move the X axis 10 steps forward and the Y axis 5 steps forward during 160 microseconds.
- Move the X axis 5 steps forward and stop the Y axis during 320 microseconds.
- ...
The fundamental difference between both operating modes is who controls the timing. If you were to attempt to execute the above sequences using manual mode, the client (i.e. your client code) would be controlling the timing. This will result in very imprecise timing, since your code is not running on a real-time operating system and also because the communication between your host and the IOIO introduces some latency and jitter. In sequenced mode, the sequencer, running on the IOIO is in charge of timing, which guarantees that the next cue execution will begin precisely at the specified duration after the current cue.
As mentioned previously, sequences are streamed to the library. This means that rather than providing one big list of <cue, duration> pairs to the library, they would be provided one at a time, in a possibly infinite stream, and executed in order. But what happens if your code is not keeping up with execution? For example, let's assume you pushed a cue of duration 1 millisecond, started its execution, and then failed to provide the next one within a millisecond? This condition is called a stall or an underflow - the sequencer has finished executing all the timed cues it was provided and does not know what to do next. Stalls are handled gracefully in our library: upon a stall, the sequencer will set the channel outputs to pre-determined states (see below, per channel type) and as soon as a new timed cue arrives, will resume execution. However, this is normally a situation we want to avoid. For that reason, the library features a buffering system, in which a small number of cues can be submitted ahead of time and made available for execution when their time comes. As soon as one completes, we can immediately submit another one to keep the execution queue full.
It is finally time to review the various channel types that are supported and how they can be used in a real application.
A binary channel is the simplest kind of channel: its cue is either "drive the pin low" or "drive the pin high". Binary channels are useful for driving binary actuators (solenoids, LEDs) as well as for various control signals, such as the "direction" pin of a stepper/DC motor driver.
When the channel is configured, the user can specify the behavior of this channel during a stall. Either "go to initial state" or "retain last state".
This example shows two binary channels. Both were configured with "low" as their initial state. The first channel has been configured to go to its initial state when idle (stall / pause / etc.); the second has been configured to retain the last stae. The sequence has 3 timed cues:
- 2[ms]: channel 1 is high, channel 2 is low.
- 4[ms]: channel 1 is low, channel 2 is low.
- 2[ms]: channel 1 is high, channel 2 is high.
- [STALL]
Notice the following:
- The signal timing.
- Upon stall, the state is retained on the second, but not on the first.
The PWM channels, as their name suggests, will generate edge-aligned PWM signals on their output. The frequency is determined once per-channel, during configuration. The pulse width can then be controlled on a cue-by-cue basis. These channel types are useful for driving DC motors, servo motors and ESC. The pulses generated by PWM channels will always be complete. For example, if a new cue starts executing in the middle of a pulse, the current pulse will complete its cycle with the old pulse width, and the next cycle will reflect the new pulse width. This is desirable and important in order to guarantee that there is never an illegal intermediate pulse width or period on the output. The only difference between PWM Speed and PWM Position channels are their behavior during a stall condition (see below). You would typically want to use PWM Position in cases where the pulse width is associated with the actuator's position (such as servo motors) and PWM Speed in cases where the pulse width is associated with the actuator's speed (such as continuous-rotation servos, DC motors or ESCs).
PWM Speed channels will set the pulse width to their initial, determined on channel configuration (intended to be the proper pulse-width for stopping the motion), and PWM Position channels will retain the last pulse-width. The intention in both cases is to achieve a "don't move" behavior during stall.
This example shows a PWM Speed and a PWM Position channel. Both were configured with a 640[us] period and an initial pulse width of 0. The sequence has 3 timed cues:
- 2[ms]: pulse width of 320[us] on both channels.
- 4[ms]: pulse width of 560[us] on both channels.
- 2[ms]: pulse width of 80[us] on both channels.
- [STALL]
Notice the following:
- On the 6[ms] mark, the pulse width doesn't change immediately. The new pulse width takes into effect on the following cycle. This actually also happens on the 0[ms] mark, when a full 0-pulse-width cycle needs to complete before the 320[us] setting is applied.
- Upon stall, the position channel retains the last pulse-width while the speed channel goes back to its initial state.
FM (frequency modulation) Speed channels will generate a pulse-train, in which the pulse-width is predetermined during channel configuration and the cycle duration can be varied on every cue. The pulses will be edge-aligned and, like PWM channels, a new cycle period of a pulse will take effect only when the previous period is complete. FM speed channels are useful for driving stepper motors in speed control mode, i.e. when we don't care about the precise angle the motor is at, but rather about how fast it is moving. A special case is when we want a cue to designate "do not generate any pulses", in which case the pulse width will be set to 0 and the period to "very very short" (two time-base units, as described later).
FM Speed channels will stop generating any pulses during a stall condition.
This example shows an FM Speed channel. It was configured with a 80[us] pulse width. The sequence has 3 timed cues:
- 2[ms]: period of 640[us].
- 4[ms]: period of 960[us].
- 2[ms]: period of 320[us].
- [STALL]
Notice the following:
- On the 2[ms] mark, the period doesn't change immediately. The period takes into effect on the following cycle.
- Upon stall, no pulses are generated.
Steps channels produce center-aligned pulse-trains with predictable rate as well as counts. As their name suggests, they are primarily intended for generating waveforms that command a "step" input signal on stepper motor drivers. This channel type has some in common with PWM and FM channels with some key differences. Each cue for these channels allows you to specify the pulse width as well as the period and the timing resolution (time-base). The pulse width is often of little practical importance, as long as it is wide enough to be detected by the driver. Pulses will be aligned, so that their rising edge is at the center of the period. Unlike PWM and FM channels, Steps channels do not wait until the end of a period for updating the values when a new cue starts. This allows for easily predictable pulse counts with timed cues: for example, setting the period to 100 microseconds on a cue that lasts 200 microseconds will always produce exactly two pulses during that period: one starts at the 50 microsecond mark, and the other at the 150 microsecond mark. Likewise, if the cue duration were 190 microseconds: we would still have pulses at 50 and 150 microseconds, for a total or two pulses, or steps. However, this brings up a delicate point: what if the cue duration for the above example were 160 microseconds and the pulse width is 20 microseconds?
To avoid such race-conditions and non-determinism, there is a simple rule, which places some onus on the user, but, if used correctly, will prevent any uncertainties. Always program your pulse width and duration numbers, so that the last pulse in a timed cue ends at least 6 microseconds before the end of the timed cue. In rare cases when this can't work out because of precision limitations, it is always possible to subdivide a long timed cue into two or more shorter ones, or at worst (and more rare) case, slow down the motion. Note that this limitation effectively limits that maximum pulse rate that we can generate. For example assuming a 1-microsecond pulse width with at least 6-microsecond gap between pulses will allow a maximum rate of 1 / 7[us] or about 143[KHz]. In practice, this maximum rate will often be effectively smaller, since we won't always be able to have the numeric precision to align the gap between pulses exactly with the beginning of this "danger zone", but still, the practical pulse rates one might expect are in the order of 10's of [KHz], which is very high for most applications.
Steps channels will stop generating any pulses during a stall condition.
This example shows a steps channel. The sequence has 3 timed cues:
- 2[ms]: pulse width of 80[us] period of 400[us] (5 pulses during cue).
- 4[ms]: pulse width of 80[us] period of 688[us] (6 pulses during cue).
- 2[ms]: pulse width of 80[us] period of 240[us] (8 pulses during cue).
- [STALL]
Notice the following:
- Pulses are aligned so that the rising edge is at the center of the period. The correct number of pulses is generated.
- On the 6[ms] mark, a new period took into effect and the new pulses for that period are re-aligned. This is unlike the other channel types, where a cycle always completes before the new setting kicks in. This is what makes this channel type easy to reason about the number of pulses that fit in a cue duration, regardless of what happened on this channel on the previous cues.
- Upon stall, no pulses are generated.
Timing is key to the motion control library. We specify durations for timed cues as well as for pulse widths and periods. In this API, all those durations are expressed by integers, relative to some time-base unit. For timed cue periods, the time-base is always 16 microseconds, so all timed cue durations are always integral multiples of this unit. Other durations have a user-selectable time-base, which is either:
- 1/16 microseconds = 62.5 nanoseconds.
- 1/2 microseconds = 500 nanoseconds.
- 4 microseconds
- 16 microseconds
PWM and FM channels require you to configure the time-base once (per-channel), then use this same time-base for all the cues (different channels may still have different time-bases). Steps channels allow you to specify the time-base on a per-cue basis, for a maximum dynamic range of pulse rates.
There are limitations to the number of channels of various types that can be used concurrently:
- There can be at most 1 sequencer active at any given time (with however many channels).
- As many binary channels as you'd like (constrained by the number of physical pins).
- Up to a total of 9 PWM/FM/Steps channels. Note, however, that these 9 resources are shared with the 9 PWM outputs, so if you are using, say 4 PWM outputs, you can have up to 5 PWM/FM/Steps channels controlled by the sequencer at the same time.
To slightly increase the flexibility given the resource constraints, if you happen to need to duplicate the output of a single PWM/FM/Steps channel across multiple pins, you can assign a list of pins to a single channel of these types. An interesting case when this can become handy is controlling H-bridge-type motor drivers: instead of "wasting" two PWM channels on a single motor, you can duplicate the PWM channel output to two pins operating in open-drain mode and have two additional binary channels acting as pull-ups / pull-downs for each one of these, effectively gating the PWM and thus controlling the motor direction.
You cannot do this (nor need to) with Binary channels.
Disclaimer: This section covers some very subtle implementation details that you almost certainly don't need to care about and can possibly confuse you more than help you get the big picture. You have been warned, now read on or skip to the next one :)
Under the hood, setting of new values to channels is done sequentially. If we have a timed cue CUE1 of duration T1, followed by another timed cue CUE2, for any given channel, CUE2 settings will take effect exactly T1 time after CUE1 settings took effect. However, for two different channels CH1 and CH2, the settings will take effect at not precisely the same time. The skew is always deterministic in the sense that for a given channel configuration, CH1 will always lag behind CH2 by the same amount.
Binary channels will always be set before any other channels (so, for example, you never need to worry about going a step in the wrong direction with a stepper motor). The lag between a binary channel and a PWM/FM/Steps channel is at most 6 microseconds. The lag between any two PWM/FM/Steps channels is at most 4 microseconds. To remove any doubt, if you have 9 PWM/FM/Steps channels, the lag between the first and the second will be at most 0.5 microseconds, the second and the third 0.5 microseconds, etc. so that the lag between the first and the last is at most 4 microseconds.
In practice, this effect is very rarely of any significant importance, but now you know...
In the heart of the motor control API is the ioio.lib.api.Sequencer
interface (hereby Sequencer
). Despite its name, the Sequencer
also governs manual mode.
Obtaining a Sequencer
instance is done via:
ChannelConfig[] config = ...;
...
Sequencer sequencer = ioio.openSequencer(config);
Any further actions will be done on this instance. When we are done with the instance, we may call
sequencer.close();
to release the pins and other resource it claims. In most cases, this is not required, as the resource will be automatically released when the connection to the IOIO drops or when the application is exited.
The ChannelConfig[]
argument passed to openSequencer()
declares which types of channels we are going to use and which pins they should be mapped to. ChannelConfig
is really just a marker interface, where in practice you would actually put one of the following class into this array: ChannelConfigBinary
, ChannelConfigPwmSpeed
, ChannelConfigPwmPosition
, ChannelConfigFmSpeed
, ChannelConfigSteps
.
The order of the channels in this array is important: when you later will define cues for those channels, you will use an array of the same size, in which the first element corresponds to the first channel defined here, the second to the second, etc.
The Sequencer
is a state-machine with three states:
- Idle: This is the initial state. It means that the sequencer is currently not processing any cues and is outputting its default waveforms.
- Running: The sequencer is in sequenced mode. It is now executing the sequence or timed cues (or stalled).
- Manual: The sequencer is in manual mode. It is now executing a manual (un-timed) cue.
Transitions between states are achieved by method calls. Some methods may only be called when at a certain state, for example, you cannot call start()
when in Manual state (see below).
Once we have a sequencer in Idle state, we can command it to execute a cue in manual mode. This is done using:
sequencer.manualStart(cues);
Cues is an array of type ChannelCue[]
. Similar to the ChannelConfig
type we saw earlier, ChannelCue
is just a marker interface and the actual elements in the array will have one of the following types: ChannelCueBinary
, ChannelCuePwmSpeed
, ChannelCuePwmPosition
, ChannelCueFmSpeed
, ChannelCueSteps
. The order and type of elements in this array must correspond to that of the configuration array used for opening the sequencer. For example, if element 4 of the configuration array was of type ChannelConfigFmSpeed, element 4 of the cues
argument above must be of type ChannelCueFmSpeed
, or a runtime exception will be thrown. Needless to say, these arrays need to have the same length.
As a performance tip, consider allocating the ChannelCue[]
array once and then modify its values every time before calling manualStart()
. The elements are all mutable, and their values are not read after this method returns.
You can now go ahead and execute additional manualStart()
calls to change the cue being executed.
A bug in V5.00 of IOIOLib prevented you from being able to do that. V5.02 fixes this issue.
Once you want to stop the manual operation and go back to Idle state, call:
sequencer.manualStop();
The same semantics for declaring a cue in manual mode are used in sequenced mode. However, in this case, instead of sending those cues for immediate execution, we want to set a duration for them, and queue them for later execution. This is done by calling:
sequencer.push(cue, duration);
As suggested above, it is possible and recommended to reuse the same instances of the ChannelCue[]
array and its elements - they are no longer accessed by the sequencer after push()
returns.
The sequencer has an internal fixed-size buffer of cues intended for preventing stalls during execution. Once this buffer gets full, the push()
method will block until there is at least one free slot for the cue, which may be forever if the sequencer is not running. This is normally a non-issue, since we would typically structure our code as a loop in which cues are being pushed as fast as possible, while the sequencer is running, until the end of the sequence. However, you can avoid blocking by making sure there is available space prior to writing by checking:
int numCanPush = sequencer.available();
One case when this is probably not exactly what we want is during prefilling of the buffer.
Once we have pushed some cues to the sequence, we can command the sequencer to being executing them:
sequencer.start();
At this point, if no events have been pushed to the cue, the sequencer will immediately stall. This is not always a problem, but if it is, consider prefilling the buffer prior to starting. Now, the sequencer is at the Running state. We can push more timed cues to it as we'd like and it they will get executed in the order pushed.
During execution (Running state), we can pause the sequencer:
sequencer.pause();
The sequencer will continue execution the currently executed cue to completion and then move to idle state. The cue buffer will not be cleared, so we could later call start()
again and the sequencer will resume from exactly where it stopped.
Alternately, we can cause an immediate stop and clearing of the cue buffer via:
sequencer.stop();
This will also cause the sequencer to transition to Idle state.
A lot of the sequencer behavior is asynchronous. In some cases it is obvious, such as when pushing timed-cues for later execution. In other cases it is a little less obvious. For example when the sequencer stalls or even when opening the sequencer.
To support cases such as having a user interface for tracking actual execution progress or the prefill, the sequencer features a queue of incoming events that can be read by the client. The following kinds of events will be reported, represented by the Sequencer.Event
type:
-
Type.STOPPED
: The sequencer has stopped OR has just been opened (always first event to appear on the queue). -
Type.CUE_STARTED
: A new timed cue from the sequence has just started execution. -
Type.PAUSED
: Execution has been paused. ACUE_STARTED
event will later signal resumption. -
Type.STALLED
: Execution has stalled as result of the cue buffer underflowing. ACUE_STARTED
event will later signal resumption. Note: This event is mostly for "FYI". A stalled sequencer is still in the Running state, and will resume execution automatically as soon as more cues are pushed. -
Type.CLOSED
: The sequencer has been closed or has not yet finished opening.
In addition to the event type, the Event
contains a field called numCuesStarted
, which will designate how many cues have been started execution since the sequence was last started. This is useful for tracking how far along into the sequence we are if there is any risk of the client not keep up with the CUE_STARTED
events.
Events can be accessed in one of two ways. If you only care about the most recent event, you can call:
Event e = sequencer.getLastEvent();
If you need a finer-grained control, you can use the queue mechanism, which exposes the following methods. To block until a new event arrives and pop it from the queue, use:
Event e = sequencer.waitEvent();
If all you need is to wait for a specific type of event to occur, you can use the short-cut:
sequencer.waitEventType(type);
The queue is initially of size 32. If your event rate is very high, or you are reading from this queue infrequently, the queue may overflow, causing older events to get discarded. In other words, the queue will always contain the 32 most recent events. If you want to reduce the chance of overflow, you can set the queue size via:
sequencer.setEventQueueSize(newSize);
Note the this call will discard any messages currently in the queue.
In some cases, it is important to avoid a stall condition immediately following start()
. The way to achieve that is to make sure the cue buffer is first filled, and only then calling start()
. There is a pitfall that needs to be avoided in such a case:
- First, you cannot assume anything about how deep is the cue buffer. If you attempt to
push()
a pre-defined number of cues before callingstart()
, it might work, but it might also break some day, if the size of the underlying buffer is changed in future versions of the library. Specifically, if the buffer becomes smaller than it used to, your prefill might block indefinitely. - Second, if you were to call
available()
immediately after opening the sequencer, you may discover that you are getting zero in return. The reason is the theopenSequencer()
method is asynchronous, and it takes time for the sequencer to actually open and report back the number of available slots in its buffer.
The correct procedure in this case is thus:
sequencer = ioio_.openSequencer(config);
// Wait until we get the initial STOPPED event. Once this returns, the buffer
// will be at its maximum capacity.
sequencer.waitEventType(Sequencer.Event.Type.STOPPED);
// Pre-fill.
while (sequencer.available() > 0 && hasMoreCues()) {
pushNextCue(); // Generates the next cue and pushes to the sequencer.
}
// Now we can start without stalling.
sequencer.start();
// Need to keep pushing now.
The software release bundle includes a simple example program called HelloSequencer
. Hopefully, reading through it can provide some additional orientation.