Introduction
The flight simulator industry has spawned dozens of custom controllers for those who are looking for a more realistic and entertaining experience. These controllers range from yolks and throttles to communication and GPS systems and their price can rival the costs of actual aviation equipment.
Thankfully there is a way to create your own controllers using low cost microcontrollers, buttons, encoders and many other input devices. There are even ways to output settings to LEDs, LED segments and displays but this guide does not cover output scenarios.
For this hookup guide I am using a controller I made for myself. The G1000 glass cockpit has a dual-rotary encoder in the lower right corner labeled FMS (Flight Management System) that is a pain to control with a mouse, even more so while the plane is in the air. So I decided to build my own controller to simulate the G1000 corner. My controller includes the FMS encoder knobs and the 6 buttons that tend to be used at the same time.
You can find the source files and STL files I used on GitHub.
The Controller
For my FMS controller I found the PEC11D-4120F-H0015, a dual concentric rotary encoder (meaning it has an inner and outer encoder ring that can turn). This is not the FAA approved version on the actual G1000 but works the same and cheaper. The dual rotary encoder is similar to a single encoder except has two sets of A/B/C pins. The encoder also has a push switch. The push buttons above the encoder are standard push buttons.
The encoder and buttons are wired to a Feather board but almost any board that can run CircuitPython with USB HID enabled will work.
There are two steps to setting up the code, creating the HID report descriptor in boot.py and the python code to read and send reports to the host computer in code.py.
BOOT.PY
Boot.py is ran once when the microcontroller first powers up (and does not run on resets caused with the reset button or through code). So remember that if you change boot.py you will have to power cycle your controller to have the change take effect.
This file is where the USB HID report descriptor is defined and enabled. The report descriptor tells the host operating system what information to expect from your game controller. This may include status on buttons, joystick movement, throttle settings and more. I wrote a quick guide on how to create your own report descriptor that can be used to aid in this step.
The FMS controller creates two sets of buttons. The first set of five buttons are for the encoder and the second are for the six push buttons above. This results in a report that is 2 bytes in size. The first byte using 5 bits (with 3 padding) and the second byte using 6 bits (with 2 padding).
After defining the report descriptor, boot.py creates and enables the descriptor with the USB_HID library. This HID device will later be available to code.py. The descriptor must be created at boot time so it correctly registers with the host operating system as CircuitPython starts up.
# boot.py import usb_hid SIM_JOYSTICK_REPORT_DESCRIPTOR = bytes(( 0x05, 0x01, # UsagePage(Generic Desktop[0x0001]) 0x09, 0x04, # UsageId(Joystick[0x0004]) 0xA1, 0x01, # Collection(Application) 0x85, 0x01, # ReportId(1) 0x05, 0x09, # UsagePage(Button[0x0009]) 0x19, 0x01, # UsageIdMin(Button 1[0x0001]) 0x29, 0x05, #// UsageIdMax(Button 5[0x0005]) 0x15, 0x00, #// LogicalMinimum(0) 0x25, 0x01, #// LogicalMaximum(1) 0x95, 0x05, #// ReportCount(5) 0x75, 0x01, #// ReportSize(1) 0x81, 0x02, #// Input(Data, Variable, Absolute, NoWrap, Linear, PreferredState, NoNullPosition, BitField) 0x95, 0x01, #// ReportCount(1) 0x75, 0x03, #// ReportSize(3) 0x81, 0x03, #// Input(Constant, Variable, Absolute, NoWrap, Linear, PreferredState, NoNullPosition, BitField) 0x19, 0x06, #// UsageIdMin(Button 6[0x0006]) 0x29, 0x0B, #// UsageIdMax(Button 11[0x000B]) 0x95, 0x06, #// ReportCount(6) 0x75, 0x01, #// ReportSize(1) 0x81, 0x02, #// Input(Data, Variable, Absolute, NoWrap, Linear, PreferredState, NoNullPosition, BitField) 0x95, 0x01, #// ReportCount(1) 0x75, 0x02, #// ReportSize(2) 0x81, 0x03, #// Input(Constant, Variable, Absolute, NoWrap, Linear, PreferredState, NoNullPosition, BitField) 0xC0, #// EndCollection() )) sim_joystick = usb_hid.Device( report_descriptor=SIM_JOYSTICK_REPORT_DESCRIPTOR, usage_page=0x01, # Generic Desktop Control usage=0x04, # Joystick report_ids=(1,), # Descriptor uses report ID 1. in_report_lengths=(2,), # This controller sends 2 bytes in its report. out_report_lengths=(0,), # It does not receive any reports. ) usb_hid.enable( (sim_joystick, ) )
CODE.PY
Code.py sets up and monitors the encoder and push buttons on the controller. It then sends that information using HID reports to the host operating system.
There is no set rule on how you send these updates. You can send the updates when a change is detected in the inputs or you can send the updates continuously every few milliseconds. It is important to ensure the program using the controller does have enough time to read the current report and does not miss the change or ignore it thinking it changed too fast.
The CircuitPython program used for my FMS example controller is relatively simple. The first section initializes the encoder and the push buttons.
The remaining code reads the current state of encoder and push buttons and sets the correct bits in the report descriptor if required.
For example if the encoder button is pressed, the 5th bit is toggled on (0001 0000)
if button.value is False:
report[0] |= 0x10
Ensure you remember which bits from the HID descriptor map to which physical controller inputs. The button (or other input) names will be used when mapping to MobiFlight.
As in the example above as the encoder button is the 5th bit, it will be Button 5.
How to Send Encoder Information
Compared to buttons, sending encoder data is trickier. There are two potential ways to send the changes of an encoder.
Just like with a throttle or joystick you can send a value range (e.g. from -1024 to +1024) in the report description based on the position of the encoder. The problem with this method is if the encoder is turned to hit the maximum or minimum values. It would be like hitting a hard stop where the encoder no longer turns. On the other hand this method ensures if you turn the encoder quickly no position changes are missed.
The other way to send encoder data is to treat turning clockwise and counter-clockwise as button presses, one button for each direction. The host program can interpret the button press as turning the encoder by one. The problem with this method is you must ensure that the "button" is pressed long enough for the host program to read it and if the encoder is turned too quickly it is possible a "button" press may be missed.
In this example controller the second method is used.
# code.py import usb_hid import time import rotaryio import digitalio import board time.sleep(10) # ensure the host OS is ready print("Starting") in_rot = rotaryio.IncrementalEncoder(board.IO5, board.IO6) in_rot.divisor = 2 out_rot = rotaryio.IncrementalEncoder(board.IO12, board.IO14) out_rot.divisor = 2 button = digitalio.DigitalInOut(board.IO18) button.direction = digitalio.Direction.INPUT button.pull = digitalio.Pull.UP buttons = [] for pin in [board.IO33, board.IO38, board.IO1, board.IO3, board.IO7, board.IO10]: b = digitalio.DigitalInOut(pin) b.direction = digitalio.Direction.INPUT b.pull = digitalio.Pull.UP buttons.append(b) device = usb_hid.devices[0] report = bytearray(2) last_in_position = 0 last_out_position = 0 while True: report[0] = 0 report[1] = 0 position = in_rot.position if position > last_in_position: report[0] |= 0x01 last_in_position = position elif position < last_in_position: report[0] |= 0x02 last_in_position = position position = out_rot.position if position > last_out_position: report[0] |= 0x04 last_out_position = position elif position < last_out_position: report[0] |= 0x08 last_out_position = position if button.value is False: report[0] |= 0x10 for i in range(0,len(buttons)): if buttons[i].value is False: report[1] |= (1 << i) device.send_report(report) # ensure the program using the controller has time to realize the encoder "button" was pressed time.sleep(0.03)
Connecting to the Sim - Enter MobiFlight
MobiFlight is an open source project to assist in integrating custom controllers with flight simulators. This integration allows you to control items (like the FMS knob) in the simulator that do not have an entry in the bindings menu.
The alternative to using MobiFlight would be writing your own custom plugin for each flight simulator you wish to control with your custom controller. Not a small or easy task.
MobiFlight offers two modes. For certain microcontrollers, such as the Raspberry Pi Pico, MobiFlight can upload custom firmware (written in C) that allows you to configure attached pins, buttons, encoders, etc. through a no-code menu system. That mode of operation is not covered in this guide but lots of examples can be found on the MobiFlight site.
The second mode, that we are interested in, is used to map a game controller to in-game items. This allows you to use your controller to affect items that are not exposed in the standard bindings menu in an easy way without writing a custom simulator plugin.
Once MobiFlight is started go to the "Input configs" tab to start mapping your controller buttons to flight sim controls.
To start enter a description of the input you are about to map. In my example I named them after the labels on the actual buttons with In and Out for the encoder inner and outer rings turning left or right. Then click on the 3 dots under edit to do the mapping.
On the Edit screen you choose which Module you want to map. In our example this is the CircuitPython controller. Device refers to the button, movement axis or other HID object you are mapping.
MobiFlight offers a lot of potential actions. For a button it can trigger upon the press, release, a hold of a predefined amount of time and a hold and release.
In this example the Action Type specifies that we are changing a specific flight sim (MS Flight Sim). But MobiFlight also has advanced methods to alter and control internal variables that may affect other controls. The MobiFlight wiki and documentation cover these cases.
The last section of this screen will change depending on the Action Type selected. In this example we want to trigger an action within the simulator. There are thousands of potential actions and the Search box and filters beside will help narrow down the selections.
Time to Fly
After everything is configured you are ready for takeoff! Ensure your controller is plugged in, your seat belt is fastened and tray table is in the upright position, start the flight simulator, and press "Run" on MobiFlight (which will automatically connect to your simulator).
For this example I started up a flight in a Diamond DA40 and a Cessna 172, looked at the G1000 display, pressed the "FPL" button started and started to enter my flight plan.
This is just a brief introduction to creating your own flight controls. Both CircuitPython HID devices and MobiFlight offer much more functionality and will allow you to create almost any flight controls that exist or you can imagine.
Have fun and safe flights!