With the release of connector version 0.9.5.5, I've introduced new ways to calibrate your rudder pedals, yokes, and brakes. My main setup uses the Thrustmaster TFRP pedal set (https://amzn.to/3pEFqZd). They are a decent starting point for anyone looking to get a "cheap" (relative to similar products) rudder set. The downside is that I didn't have any custom-made rudder pedals to test whether or not the rudder changes were working.
That is where this project enters the picture. A miniature set that you can control using two fingers. Will it replace my Thrustmaster set? Probably not in the foreseeable future. But it can teach you how to create, code, and calibrate your custom rudder pedals on a larger scale.
*These are affiliate links that help support the channel.
To illustrate the scale, I've put my rudder sets next to each other. The base is rather bulky due to the component versus the pedal size. The components won't be noticeable if you replicate real-life rudders on a 1:1 scale.
The design I've ended up with is based on the same principles entry-level rudder manufacturers use. The crossbeams move perpendicular to the side beams (the iron rods). This construction enables us to move one pedal forward while the other moves backward. For the crossbars, I'd recommend using 2.5mm rods. There's a chance you'll need a small iron handsaw to cut the bars to size. The 2.5mm bars are too thick for me to cut with pliers. This probably has more to do with my lack of strength. We connect the crossbars directly to the potentiometer. A small design leaves us with a small range of movement. But no worries; we'll fix this using the calibration settings.
A simple oversight on my end was not limiting the sideways motion. This still enables us to rotate our pedals in a radial instead of a linear motion. You can fix this by adding a guide rail to your pedals, forcing the construction to move linearly. I'll provide a simple shroud in an updated iteration to fix this issue.
You can use any Arduino (or other microcontrollers) for this project. Wiring up the rest will be a breeze. Grab three wires (preferably red, black/brown, or another third color). The potentiometer has just three pins we need to connect to our controller.
The color of the wires doesn't change the behavior of the cables. A wire will still be a wire. Using red for voltage and black/brown for negative/ground makes it easy to spot what each wire does. This coding is also an investment to please future you. If you open up the project in a year, it'll become much harder to service the device if you don't follow a color-coding scheme. This is the same color scheme most hobbyists use for small consumer electronics.
If you want to read more about potentiometers, check out our back-to-basics potentiometers article!
I've made the code you'll need as simple as possible. It's so simple that you'll only need to add four lines of code. Is coding always that simple? It depends. In this case, most of the magic happens underneath the hood. To save you some time, I've created a function in the library that handles all the magic so you can focus on other things.
cpp//Include the code found in the BitsAndDroidsFlightConnector library #include <BitsAndDroidsFlightConnector.h> //Create a connector object BitsAndDroidsFlightConnector connector = BitsAndDroidsFlightConnector(); void setup(){ //Start serial communication //The connector defaults to a rate of 115200 (the rate at which data gets transmitted) Serial.begin(115200); } void loop(){ //A0 reffers to the pin your potentiometer is connected to. //This could differ if you plugged it into a different hole connector.sendSetRudderPot(A0); }
Now that we've constructed and coded our pedals, we are ready to use them ingame. For some of you, this might be an out-of-the-box experience. For others, a bit of tweaking can go a long way. Let's take our small plastic contraption as an example. The moment we place our fingers down, the rudders start moving. You can counteract this movement with a bit of finger wiggle, but in the end, all these tiny changes will be transmitted to the game.
To calibrate our pedals, we'll open the serial monitor to visualize the values it transmits. These are the steps I use to calibrate my rudders:
We've now defined the range available to us and where the center is located. We can change the sliders to fine-tune the inputs even further.
Let's explore the inner workings of the library and connector. Don't worry—you don't have to copy any of the code in this chapter.
There is a reason we've used a potentiometer. We can determine which direction the wiper faces by reading the analog signal as a value. In theory, closed will always be 0, and open will always be your maximum value (usually 1023). If we place the wiper in the middle, it will be 511, and so on. If we translate this to our ingame rudder, we have a neutral position, a full left position, a full right position, and everything in between. Ultimately, we want to map the position of our potentiometer to the position of our ingame rudder.
When you take off from the centerline of the runway, you'd want to use your rudders to keep you centered during the entire take-off process. If we mapped the potentiometer directly to our ingame rudder, it wouldn't take long before we threw our rudder out of the window. When we don't touch the component, we can still observe some jitter in the readings (i.e., a sequence of readings could return 511, 512, 511, 511, 512). We could create an advanced circuit that eliminates some noise, but filtering out the noise code is easier.
An unfiltered approach is simple to implement. We read the value and pass it to the connector. The connector sends the command to the game, and we're done. This would leave us with a mediocre experience.
To apply the first layer of filtering, the library buffers several readings. It samples ten readings and saves them to an array. When the array is filled, it takes the average of all the values and sends that to the connector.
cppint BitsAndDroidsFlightConnector::smoothPot(byte potPin) { //Samples is defined in the header file. It represents the amount of readings done before averaging //A higher value will result in less jitter but more input lag and vice versa. //Defaults to 10 int readings[samples] = {}; total = 0; for (int &reading: readings) { total = total - reading; reading = analogRead(potPin); total = total + reading; delay(1); } average = total / samples; return average; }
Even after averaging the readings, jitter will still be on the line. The difference between these readings is 99% of the time, a value 1. The simplest solution to filter this out will be to check if the change is bigger than one before sending the command to the connector. We use an if statement to compare the old value to the current one and see if the difference is greater than 1. What happens if we use the following code to filter out jumps bigger than 1?
cppif(oldValue - newValue > 1){ //Do something }
If we have a movement that decreases the value by 2, we could fill in the blanks to see the result.
cpp//OldValue = 4, newValue = 2 if(4 - 2 > 1){ //4-2 = 2. 2 is bigger than 1 so the code in this block gets excecuted }
We could fill in the same blanks with an increase of 2 to illustrate why this "if" statement won't work.
cpp//OldValue = 2, newValue = 4 if(2 - 4 > 1){ //2-4 = -2. -2 is smaller than 1 so the code in this block doesn't get excecuted }
Even though the difference is 2 in both cases, only 1 statement would result in a 'true' statement. Luckily, C++ has a function called abs(//enter your calculation here). abs() Returns the absolute difference between 2 values. This ensures that -2 and +2 both result in a value of 2.
cppvoid BitsAndDroidsFlightConnector::sendSetRudderPot(byte potPin) { currentRudder = smoothPot(potPin); //Analogdif is a value defined in the header file. //It represents the value changes we want to register //If you've got noisy pots or a range of 2048 upping this value might provide further smoothing //More smoothing results in a fidelity loss if (abs(currentRudder - oldRudderAxis) > analogDiff) { packagedData = sprintf(valuesBuffer, "%s %i", "901", currentRudder); oldRudderAxis = currentRudder; //valuesbuffer is a container that holds the formatted command //For the rudder this format is 901 X (X = the rudder value) //This gets translated in the connector. 901 is the identifier used to categorize the data this->serial->println(valuesBuffer); } }
The readings are smooth, the calculations are made, and your board sends out 901 800. The connector reads the command and maps the incoming value to the ingame axis. We call it a day. Almost! In 99% of the cases, the command arrives as 901 800.
However, there is a possibility that data gets lost or corrupted. It would be a shame to receive 901 000 when you should receive 901 800. This command would result in a full yank on your rudders to the left while you wanted to steer slightly to the right. Rudder stomping has never worked out great for me on the runway. In the connector, this gets handled by the following piece of code.
cppvoid InputSwitchHandler::setRudder(int index) { try { //The token represents a string cut up by the delimiter (in this case a space) token = strtok_s(receivedString[index], " ", &next_token); counter = 0; //We take the token above if it is empty we know we've reached the end of the string //A command like 901 800 would be read as 901 -> 800 -> null (nothing) while (token != nullptr && counter < 2) { if (counter == 1) { int analogValue = stoi(token); // minimum to first point rudderAxis = calibratedRange(analogValue, 0); } token = strtok_s(nullptr, " ", &next_token); counter++; } int diff = std::abs(rudderAxis - oldRudderAxis); //The ingame axis runs on a scale of -16383 tot 16383. //If a change is bigger than 10000 it's most likely a faulty reading dropping the incomming value to i.e. 0 //To prevent a hard left turn we filter out these readings here if (diff < 10000 || oldRudderAxis == NULL) { SimConnect_TransmitClientEvent( connect, 0, inputDefinitions.DEFINITION_AXIS_RUDDER_SET, rudderAxis, SIMCONNECT_GROUP_PRIORITY_HIGHEST, SIMCONNECT_EVENT_FLAG_GROUPID_IS_PRIORITY); oldRudderAxis = rudderAxis; } } catch (const std::exception &e) { cout << "error in rudder" << endl; } }
You may have never heard of a try-catch statement. The connector doesn't control which data it receives, so if you decide to replace the value with a string of characters, the entire app could crash.
Let's take 901 "Monkey" as an example command. The connector would try to store "Monkey" as the rudder value. The rudder value is an integer (nondecimal number), preventing the word "Monkey" from being saved. The app freaks out; your computer freaks out; perhaps you even freak out while the app crashes. The try block does what you'd expect. It tries to read your incoming command and execute the necessary calculations. If, for whatever reason, the attempt fails, we enter the catch statement.
The catch statement catches your failed attempt before it wreaks havoc. We still have a failed attempt, but we can print an error message instead of a crash.