Skip to content

Using encoders with X-Plane

10 November, 2012

Rotary encoders are very useful little components. In this article I’ll explain what they are, and walk through the process of using them to control stuff in X-Plane.

If you’re not sure exactly what an encoder is, look at your mouse scroll wheel: that’s a rotary encoder. It’s a device which you can rotate, usually with sprung detents so you can feel how far you’ve turned it, and encode the rotation such that an Arduino-esque microcontroller can read it. They are used to send a pair of commands to your system, like ‘scroll up/scroll down’, ‘zoom in/zoom out’, ‘volume up/volume down’. For our purposes we can add ‘move heading bug left/right’, ‘move elevator trim nose-up/nose-down’, ‘COM2 fine adjust up/down’, ‘turn ignition switch clockwise/counterclockwise’…

All the above can be controlled with two keyboard buttons, or (the traditional flight sim method) clicking on two slightly different parts of a knob. I was quite fond of the method used by old DreamFleet for Microsoft Flight Sim projects, where left-click was ‘turn left’ and right-click was ‘turn right’, but there appear to be deeply-held philosophical reasons why X-Plane ignores the right mouse button. (I think it’s because Laminar are all Mac users and don’t have a right mouse button – just kidding, just kidding!) It doesn’t matter though, because we can use an encoder to apply the up/down commands instead. An encoder is basically a convenient way to apply up/down-style pairs of commands.

They look similar to potentiometers (pots), but they work in a completely different way. Encoders signal change of position, while pots signal absolute position. You can share input between an encoder and other components – for example, you can scroll this page with your mouse scroll wheel or with the cursor keys – but if a system uses a pot for input it is usually not sensible to take input from anything else.

The main advantage of pots is the input of the pot indicates the state of the input – you can look at the volume control knob, see the white mark is halfway between ‘off’ and ‘max’, and know the volume is set to 50% – and it is easy to make small and coarse inputs with one control. You can’t do either with a single encoder. The angle of an encoder’s shaft has no meaning, it is only changes to angle which have meaning. And you can either set the encoder to make small changes when you turn it, which makes it tedious to make large changes as you have to turn the shaft a lot, or to make larger changes, making it impossible to make small changes at all. (I’ll show how two encoders can circumvent this.)

There is another type of encoder – an absolute encoder – which shows the absolute position of the shaft. (It knows as soon as the system is powered up that the shaft is exactly 85.5° from the datum position.) Typically they have a lot more pins and are a lot more expensive than the relative-position encoders we’re interested in.

An encoder has three pins. The middle one is (usually) the common pin, and the other two connect to the middle pin as the shaft is turned, transmitting the rotation in Gray code to the microcontroller. The simplest way to explain this is to demonstrate. Here, I’ve connected two LEDs to the output pins and the common pin to ground, so the LEDs light when their pin is connected to the common pin:

The encoders I bought also have integrated pushbuttons, which are very useful. The pushbutton is electrically separate from the encoder, and is activated by pressing down on the shaft. It uses the two pins on the other side of the encoder, visible in the photo at the top of the page.

I got mine from eBay for about $0.50 each. Look for phrases like ‘continuous rotary encoder’, ‘for use with microcontrollers’, and ‘2-bit gray code output’. They have little tags on their bases, which if you bend out slightly, let you put the encoder into the middle of a breadboard. I find they keep popping out of the board, though – it’s much better to solder leads onto the pins and attach the encoders to a basic panel of some kind.

Using an encoder with Arduino/Teensy/X-Plane.

The Arduino Encoder library does most of the hard work for us. Let’s look at the ‘Basic’ example sketch which comes with the Arduino IDE:

/* Encoder Library - Basic Example
 * http://www.pjrc.com/teensy/td_libs_Encoder.html
 *
 * This example code is in the public domain.
 */

#include <Encoder.h>

// Change these two numbers to the pins connected to your encoder.
// Best Performance: both pins have interrupt capability
// Good Performance: only the first pin has interrupt capability
// Low Performance: neither pin has interrupt capability
Encoder myEnc(5, 6);
// avoid using pins with LEDs attached

void setup() {
 Serial.begin(9600);
 Serial.println("Basic Encoder Test:");
}

long oldPosition = -999;

void loop() {
 long newPosition = myEnc.read();
 if (newPosition != oldPosition) {
 oldPosition = newPosition;
 Serial.println(newPosition);
 }
}

Don’t quote me on this, but I’m not sure the stuff about interrupt pins is definitely relevant for human-interface encoders. I think that’s for situations where you’re using the encoder to record the motion of fast-moving motorised things, for instance a digital odometer for  car. But here, the input’s coming from our fingers, which are slower than motors. I haven’t used interrupt pins and the encoder works just fine – your mileage may vary!

There’s two important things to notice about the code. We make an Encoder object:

Encoder myEnc(5, 6);

and we read it using myEnc.read(). We also need to store the previous position (oldPosition), because we determine an input by comparing myEnc.read() with oldPosition. If read() is bigger, we’ve turned the encoder one way; if it is smaller, we’ve turned the other way. (Which direction depends on which way round you’ve connected the pins. I don’t try to remember which way is clockwise-positive, I just swap the pins (Encoder myEnc(6, 5)) if I get it wrong the first time.)

Let’s turn this into an X-Plane example, and adjust the heading bug dataref:

#include <Encoder.h>

Encoder myEnc(7, 8);
long myEncPrev = 0; // more descriptive name than oldPosition, IMO

FlightSimFloat headingBug;

void setup() {
 headingBug = XPlaneRef("sim/cockpit2/autopilot/heading_dial_deg_mag_pilot");
}

void loop() {
 FlightSim.update();

 // compare current position to previous position
 int myEncDiff = myEnc.read() - myEncPrev;

 // update previous position
 myEncPrev = myEnc.read();

 // if there was movement, change the dataref
 if (myEncDiff) {
 headingBug = headingBug + myEncDiff;
 // (no += operator for FlightSimFloat yet!)
 }
}

This seems to work just fine! Twiddle the encoder and the bug moves in X-Plane. But, if I look carefully at what’s going on (go to the Plugin menu, then ‘Show Communications’ from the TeensyControls submenu), the dataref is changing by 4 degrees each click.

The reason for this is my encoders have been set up this way. The Gray code output changes by 4 between each detent: it goes (off-off), (off-on), (on-on), (on-off), *click!*(off-off). I’m only interested in how many detents I’ve clicked through, so I need to divide myEncDiff by 4 to get that value. Happily, C++ integer division will round (3/4) down to 0, so if(myEncDiff) is only true when I click through a whole detent.

Another problem is where we pass through 0/360. The encoder sets the dataref to values out of range, like -15 and 368, and after a second or so X-Plane notices and puts the dataref back to the 0-360 range. We shouldn’t rely on X-Plane being nice to us like this, so let’s revise the program with range checking.

Finally, instead of updating myEncPrev with myEnc.read(), let’s set them both to 0 when we reach a detent. This way, we won’t eventually (after many many turns) overflow the integer. Also we can use short instead of long or int and save a couple of bytes of RAM ;-)

#include <Encoder.h>

Encoder myEnc(7, 8);
short myEncPrev = 0;

FlightSimFloat headingBug;

void setup() {
  headingBug = XPlaneRef("sim/cockpit2/autopilot/heading_dial_deg_mag_pilot");
}

void loop() {
  FlightSim.update();

  // divide by 4 to find how many 'clicks' the encoder's gone through
  short myEncDiff = (myEnc.read() - myEncPrev) / 4;

  if (myEncDiff) {
    // only update prev when we've reached a detent!
    myEncPrev = 0;
    myEnc.write(0);

    // copy dataref to temporary value
    float hdg = headingBug;

    // apply changes to temp value
    hdg += myEncDiff;

    // do range checking
    while (hdg < 0.0) hdg += 360.0;
    while (hdg >= 360.0) hdg -= 360.0;

    // write validated new heading back to dataref
    headingBug = hdg;
  }
}

That’s it! We have a working heading-bug controller!

Let’s extend the code a bit. How about also controlling the elevator trim with the same encoder? We’ll use a button to change the modes, and LEDs to indicate which mode we’re in.

#include <Encoder.h>
#include <Bounce.h>

// here I'm using enum to avoid writing 'const int' a lot
enum pins {
  MyEnc_A = 7,
  MyEnc_B = 8,
  ModeSwitchPin = 20,
  HdgModeLED = 12,
  ElevTrimLED = 13
};

Encoder myEnc(MyEnc_A, MyEnc_B);
short myEncPrev = 0;

Bounce modeSwitch = Bounce (ModeSwitchPin, 5);
int mode = 0;
// mode: 0 for heading-bug, 1 for elevator

FlightSimFloat headingBug;
FlightSimFloat elevTrim;

void setup() {
  pinMode(ModeSwitchPin, INPUT_PULLUP);
  pinMode(HdgModeLED, OUTPUT);
  pinMode(ElevTrimLED, OUTPUT);

  headingBug = XPlaneRef("sim/cockpit2/autopilot/heading_dial_deg_mag_pilot");
  elevTrim = XPlaneRef("sim/cockpit2/controls/elevator_trim");
}

void loop() {
  FlightSim.update();
  modeSwitch.update();

  // change mode when switch pressed
  if(modeSwitch.fallingEdge()) {
    ++mode;
    if(mode > 1)
      mode = 0;
  }

  //light status LEDs
  digitalWrite(HdgModeLED, (mode == 0));
  digitalWrite(ElevTrimLED, (mode == 1));

  short myEncDiff = (myEnc.read() - myEncPrev) / 4;

  if (myEncDiff) {
    myEncPrev = 0;
    myEnc.write(0);

    if (mode == 0) {
      float tmp = headingBug;
      tmp += myEncDiff;
      while (tmp < 0.0) tmp += 360.0;
      while (tmp >= 360.0) tmp -= 360.0;
      headingBug = tmp;
    }

    if (mode == 1) {
      float tmp = elevTrim;
      tmp += myEncDiff;
      while (tmp < -1.0) tmp = -1.0;
      while (tmp > 1.0) tmp = 1.0;
      elevTrim = tmp;
    }
  }
}

Oops, this doesn’t work. We’re altering the elevator trim by 1; we ought to scale it. Let there be elevTrimScalar!

Also, picking -1.0 and 1.0 for the trim position limits is naive; it varies by aircraft, and there are datarefs for the limits.

Incidentally, comparing mode to 0 and 1 all the time is poor form. Let’s use another enum to automatically number the modes.

#include <Encoder.h>
#include <Bounce.h>

enum pins {
  MyEnc_A = 7,
  MyEnc_B = 8,
  ModeSwitchPin = 20,
  HdgModeLED = 12,
  ElevTrimLED = 13
};

// Encoder things
Encoder myEnc(MyEnc_A, MyEnc_B);
short myEncPrev = 0;

// Mode things
Bounce modeSwitch = Bounce (ModeSwitchPin, 5);
enum Modes {
  Mode_Heading, // automatically = 0
  Mode_ElevTrim, // automatically = 1
  Mode_Count // automagically = 2, and we have 2 modes, that's convenient!
};
int mode = 0;

// Heading mode things
FlightSimFloat headingBug;

// Elev trim mode things
FlightSimFloat elevTrim;
FlightSimFloat elevMin;
FlightSimFloat elevMax;
float elevTrimScalar = 0.005;

void setup() {
  pinMode(ModeSwitchPin, INPUT_PULLUP);
  pinMode(HdgModeLED, OUTPUT);
  pinMode(ElevTrimLED, OUTPUT);

  headingBug = XPlaneRef("sim/cockpit2/autopilot/heading_dial_deg_mag_pilot");
  elevTrim = XPlaneRef("sim/cockpit2/controls/elevator_trim");
  elevMin = XPlaneRef("sim/aircraft/controls/acf_min_trim_elev");
  elevMax = XPlaneRef("sim/aircraft/controls/acf_max_trim_elev");
}

void loop() {
  FlightSim.update();
  modeSwitch.update();

  // change mode when switch pressed
  if(modeSwitch.fallingEdge()) {
    ++mode;
    if(mode >= Mode_Count)
      mode = 0;
  }

  //light status LEDs
  digitalWrite(HdgModeLED, (mode == Mode_Heading));
  digitalWrite(ElevTrimLED, (mode == Mode_ElevTrim));

  short myEncDiff = (myEnc.read() - myEncPrev) / 4;

  if (myEncDiff) {
    myEncPrev = 0;
    myEnc.write(0);

    if (mode == Mode_Heading) {
      float tmp = headingBug;
      tmp += myEncDiff;
      while (tmp < 0.0) tmp += 360.0;
      while (tmp >= 360.0) tmp -= 360.0;
      headingBug = tmp;
    }

    if (mode == Mode_ElevTrim) {
      float tmp = elevTrim;
      tmp += (myEncDiff * elevTrimScalar);
      while (tmp < -elevMin) tmp = -elevMin;
      while (tmp > elevMax) tmp = elevMax;
      elevTrim = tmp;
    }
  } //if encDiff
} // loop

All is well and good! And using enum to keep track of the modes like this makes it easy to add new datarefs. Ideally, we would build a class, containing a dataref, upper and lower limits, the policy for when the value exceeds limits (do they loop round like the heading, 361 going to 1, or get cut back like the trim?), and some static function which takes the mode and the difference as arguments and does all the calculation for us behind the scenes. But that’s beyond the scope of this article.

Instead, what if we add a second encoder? Remember, earlier, that a disadvantage of encoders is they change a value by a fixed amount, which is generally both too small and too large. We might want to set the heading bug in increments smaller than 1 degree, we might want to turn it faster than 1 degree per increment. Using two encoders, one for coarse change, the other for fine change, we can do this.

I’ve added a second encoder, so we have a coarseEnc and a fineEnc now, and also a coarseEncDiff and a fineEncDiff. We combine these two values to get a final encDiff which we then give to the mode-specific bits exactly like before. The ratio between coarse and fine is defined by CoarsetoFineRatio. For good measure I’ve added a Nav1 OBS setting too.

#include <Encoder.h>
#include <Bounce.h>

enum pins {
  CoarseEnc_A = 7,
  CoarseEnc_B = 8,
  FineEnc_A = 10, // wired this one in backwards. Instead of playing
  FineEnc_B = 9,  // with the wires, let's just swap the order here!
  ModeSwitchPin = 20,
  HdgModeLED = 12,
  Nav1OBSLED = 13,
  ElevTrimLED = 14
};

// Encoder things
Encoder coarseEnc(CoarseEnc_A, CoarseEnc_B);
short coarseEncPrev = 0;

Encoder fineEnc(FineEnc_A, FineEnc_B);
short fineEncPrev = 0;

const int CoarseToFineRatio = 20;

// Mode things
Bounce modeSwitch = Bounce (ModeSwitchPin, 5);
enum Modes {
  Mode_Heading, // automatically = 0
  Mode_Nav1OBS, // let's just add this in here
  Mode_ElevTrim, // automatically now equal to 2 since we put Nav1OBS in front
  Mode_Count // automagically = 3, and we have 3 modes!
};
int mode = 0;

// Heading mode things
FlightSimFloat headingBug;
float headingBugScalar = 0.25;

// Nav1 OBS things
FlightSimFloat nav1OBS;
float nav1OBSScalar = 0.25;

// Elev trim mode things
FlightSimFloat elevTrim;
FlightSimFloat elevMin;
FlightSimFloat elevMax;
float elevTrimScalar = 0.005;

void setup() {
  pinMode(ModeSwitchPin, INPUT_PULLUP);
  pinMode(HdgModeLED, OUTPUT);
  pinMode(Nav1OBSLED, OUTPUT);
  pinMode(ElevTrimLED, OUTPUT);

  headingBug = XPlaneRef("sim/cockpit2/autopilot/heading_dial_deg_mag_pilot");

  nav1OBS = XPlaneRef("sim/cockpit2/radios/actuators/nav1_obs_deg_mag_pilot");

  elevTrim = XPlaneRef("sim/cockpit2/controls/elevator_trim");
  elevMin = XPlaneRef("sim/aircraft/controls/acf_min_trim_elev");
  elevMax = XPlaneRef("sim/aircraft/controls/acf_max_trim_elev");
}

void loop() {
  FlightSim.update();
  modeSwitch.update();

  // change mode when switch pressed
  if(modeSwitch.fallingEdge()) {
    ++mode;
    if(mode >= Mode_Count)
      mode = 0;
  }

  // light status LEDs
  digitalWrite(HdgModeLED, (mode == Mode_Heading));
  digitalWrite(Nav1OBSLED, (mode == Mode_Nav1OBS));
  digitalWrite(ElevTrimLED, (mode == Mode_ElevTrim));

  // find encoder movement
  short coarseEncDiff = (coarseEnc.read() - coarseEncPrev) / 4;
  short fineEncDiff = (fineEnc.read() - fineEncPrev) / 4;

  // reset encoders after they move
  if (coarseEncDiff) {
    coarseEncPrev = 0;
    coarseEnc.write(0);
  }
  if (fineEncDiff) {
    fineEncPrev = 0;
    fineEnc.write(0);
  }

  // combine coarseEncDiff with fineEncDiff
  int encDiff = (CoarseToFineRatio * coarseEncDiff) + fineEncDiff;

  if (encDiff) {
    if (mode == Mode_Heading) {
      float tmp = headingBug;
      tmp += encDiff * headingBugScalar;
      while (tmp < 0.0) tmp += 360.0;
      while (tmp >= 360.0) tmp -= 360.0;
      headingBug = tmp;
    }

    if (mode == Mode_Nav1OBS) {
      float tmp = nav1OBS;
      tmp += encDiff * nav1OBSScalar;
      while (tmp < 0.0) tmp += 360.0;
      while (tmp >= 360.0) tmp -= 360.0;
      nav1OBS = tmp;
    }

    if (mode == Mode_ElevTrim) {
      float tmp = elevTrim;
      tmp += encDiff * elevTrimScalar;
      while (tmp < -elevMin) tmp = -elevMin;
      while (tmp > elevMax) tmp = elevMax;
      elevTrim = tmp;
    }
  } //if encDiff
} // loop

One final note. Don’t delay the main Arduino loop when using encoders. If you need to delay something, put it into a separate function and put a timer or counter in loop() to call it at a suitable interval. (See the OmniTune code for updating the display for an example.) If you delay the main loop(), your system will fail to read() changes in the encoder’s position that happen during the delay. You’ll notice this happening because the encoder doesn’t work. I learned this the hard way…

I think I’ll leave it there. Play around with the scalars and the ratios and find a setting that works for you. I leave mode-specific coarse-to-fine ratios as an exercise for the reader ;-)

If you’d like to see encoders used for tuning the radios, have a look at my OmniTune project – the source code is on GitHub here.

Advertisements

From → Guides

Leave a Comment

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s