genka

a weirdo

  • he/him

Weird, but not as weird as I aim to be


kojote
@kojote

More than ten years ago, I bought some of the MFDs that Thrustmaster made to go along with their F-16 replica (and later A-10C replica) joystick and throttle. They’re designed to mimic the appearance of the multifunction displays in an actual F-16, albeit being made of plastic, and they look like this:

Product image from Thrustmaster’s website of their MFDs, showing the product box and two examples of the MFDs, which consist of 20 square buttons in a black square empty frame

They come in a set of two identical MFDs. Each of them is its own USB device. They appear in Windows as a USB gaming peripheral with 28 buttons: 20 Option Selection Buttons (OSBs) around the perimeter, in groups of 5, and four two-way rocker switches. They are pretty handy. Actually, about the only thing that could be improved about them is

::YAWN::

hey guys I’m sorry those MFDs are so boring they just put me right to sleep. Where were we? Oh. Right, anyway, the biggest problem with the MFDs is that they are really just empty frames. There’s no screen behind them; they have a slot into which you can put a preprinted card that explains what the functions do:

Straight-on image of the MFD frames as before, this time with two paper inserts added to replicate the look of an actual screen

In the real world, multifunction displays are called that because they can do, well, multiple things. A given display can have lots of different pages, with the resulting commands generally displayed next to the OSB on the screen.

Digital Combat Simulator allows you to export the MFD displays of the aircraft to an external monitor, so what you could do is put a monitor inside the MFD frame. There are only three problems with this:

  1. (Minor problem, because I think it’s a lot better now than it used to be) The way DCS works is that you have to render an entire second canvas, with the MFDs covering a bit of that canvas. But if your main display is, say, 2560*1440, and your MFD monitor is a 1920*1080 monitor, DCS expects you to export one canvas that is either 2560*(1440+1080), or (2560+1920)*1440, pixels. This introduces a performance hit, and it also makes the UI weird sometimes; DCS likes to pop modals up in the “middle” of your screen, which really means halfway between your main display and your secondary one.
  2. (Medium problem) the MFDs are a weird size. The inner display is a square 10.8cm or 4.25in on a side, which would be a 6in diagonal screen. Square LCDs are not very common, as it turns out. There’s a 4” LCD on Aliexpress for €65, which is still the wrong size. Are you wondering, at this point, if I considered the expedient of using a 4” square LCD and then putting a fresnel in the frame to magnify it? You should be! The answer is “yes,” and I may still experiment with something like that (for other reasons I may touch on), but it doesn’t really matter because…
  3. (Major honking problem) even having solved this issue, it still only fixes things in DCS. What am I supposed to do in, say, Microsoft Flight Simulator? Just remember what every button does? And, for that matter, what about DCS planes that have lots of buttons and things but don’t actually have MFDs, like the F-14? Surely, you are thinking, surely there has to be a very stupid way to solve this problem :3

And there is.

My initial thinking was pretty simple, and it went like this: a Raspberry Pi is capable of enumerating as a USB device. So what if I plugged the MFD into the Raspberry Pi, listened for button presses, and then had the Raspberry Pi echo those button presses to Windows (let’s prefix those with “DX,” for DirectX. After this point, OSB will refer to a physical button on the MFD, and DX will refer to a button—real or not—that we tell Windows about)? That way, if you follow, I could define various virtual pages. So, for example, in the “Radio” page…

An MFD “page” with options around the rim reflecting different radio options and frequency change settings

Pressing OSB1 could send DX1 and pressing OSB2 could send DX2, and in game I can bind DX1 to trigger Radio 1, and DX2 to trigger Radio 2. But then, at the very bottom, pressing OSB 15 doesn’t send any button press to the connected PC, it switches the context to the “Engine” page:

An MFD “page” with options around the rim reflecting different engine settings and a checklist in the center

And then we say that pressing OSB1 should send DX… I dunno, let’s say DX21, and pressing OSB2 should send DX22. And in the game, I bind DX21 to the “fuel pump 1 on/off” action, and DX22 to the “fuel pump 2 on/off” action. As far as the game is concerned, I’ll just have pressed a completely separate button.

That’s the idea, anyway.

The first step would be to make the Raspberry Pi pretend to be a USB game controller in the first place. Helpfully, basic steps on how to do this have been provided by Milador, over on Github to handle the actual USB configuration. This runs when the Raspberry Pi starts up. If you ever try this yourself, you can ignore most of it. All this, for instance:

echo 0x1d6b > idVendor # Linux Foundation
echo 0x0104 > idProduct # Multifunction Composite Joystick Gadget
echo 0x0100 > bcdDevice # v1.0.0
echo 0x0200 > bcdUSB # USB2
echo 0x00 > bDeviceClass
echo 0x00 > bDeviceSubClass
echo 0x00 > bDeviceProtocol

That’s all pretty basic. Really, what you need to care about is the report description. This is how a USB device tells the PC it’s connected to what data to expect. So, for example, this bullshit:

echo "05010904A1011581257F \ 
0901A100093009317508 \ 
95028102C0A100093915 \
0025073500463B016514 \
75049501814209391500 \
25073500463B01651475 \
0495018142C0A1000509 \
19012920150025017501 \
95208102C0C0" | xxd -r -ps > functions/hid.usb0/report_desc

Actually breaks down like this:

CodeWhat the code means (?)
05,01This is a generic desktop controller.
09,04Specifically, this is a joystick.
A1,01Begin defining the “Application collection.” Nope, no idea what this means but it only apparently needs to occur once and contains all of the actual data in the report.
15, 81The logical minimum is -127
25,7FThe logical maximum is 127
09,01The usage is “Pointer”
A1,00Begin defining a physical collection (in this case, what would be a joystick itself in the sense of the physical stick you wiggle)
09,30Report includes an X axis
09,31Report includes a Y axis
75,08The report size is 8 bits (i.e. 256 values)
95,02The report count is 2 (one for each axis)
81,02Some parameters about the stick. I have no idea what they mean. “(Data,Var,Abs,No Wrap, Linear, Preferred State,No Null Position)” so there you go.
C0End the previous collection.
A1,00Begin defining another physical collection (in this case, two hat switches—so the below actually occurs twice, but I am only including one set).
09,39This is a hat switch.
15,00The logical minimum is 0
25,07The logical maximum is 7
35,00The physical minimum is 0
46,3B,01The physical maximum is 315
65,14The unit system is “English Rotation” with “Length” given as “Centimeter”
75,04The report size is 4 bits (i.e. 16 values)
95,01The report count is 1
81,42Some parameters about the hat, again I have no idea what they mean, but “(Data,Var,Abs,No Wrap, Linear, Preferred State,Null State)”
C0End the previous collection.
A1,00Begin defining another physical collection.
05,09Begin defining a new usage page for buttons.
19,01The usage minimum is 1
29,20The usage maximum is 32 (i.e. x20)
15,00The logical minimum is 0
25,01The logical maximum is 1 (…because it’s a button)
75,01The report size is 1
95,20The report count is 32
81,02More random meaningless parameters “(Data,Var,Abs,No Wrap, Linear,Preferred State,No Null Position)”
C0End the previous physical collection.
C0End the application collection we started all the way back on line 3

For those of you who do not use many joysticks, a “hat” is a special kind of 4-way switch. It can be in only one state at a time: Up, Up Right, Right, Down Right, Down, Down Left, Left, Up Left, or Centered. There’s not, as such, a reason to define a hat for this virtual controller, but I wanted to have as many button assignments as I could (just in case) and the rocker switches on the MFD natively report as two separate buttons even though only one of them can be pressed at a time.

So in that sense, any given rocker switch is identical to one axis (left/right or up/down) on a hat switch, and if you take as a given that you will never push two of a pair of rocker switches at the same time, you could assign those two buttons to a single hat. Actually, strictly speaking, if you assume that you will never push any two rockers at the same time, you could put every single one on a hat and free up eight DX buttons:

Animated gif showing the possible combinatory states of a hat switch

Also: There’s not a reason to define a joystick axis either, but I had it in my head that I might want to send virtual inputs as, for example, a trim wheel or something. And, again, why not? I haven’t actually wound up doing this, though.

Anyway, once this report is defined, this is basically it. Whenever something changes, we put together a report that consists of 7 bytes:

  1. 16 bits for the joystick X and Y
  2. 8 bits for the two hats (4 bits each)
  3. 32 bits for the buttons

And then we open the USB connection and just write to it:

with open('/dev/hidg0', 'rb+') as fd:
     fd.write(report)

Windows isn’t polling the device. So whenever we want to push a button, we send a report that has the button pushed (i.e. we set its bit to 1), and whenever we want to release that button, we send a report that has the button released (i.e. we reset its bit to 0).

Now, we need to do is plug the MFD into the Raspberry Pi, and start listening for events on /dev/input/event0. This sends a report whenever one of the buttons is pressed or released. Each button has its own code (304 to 319 and 704 to 719, inclusive—I assume for arcane reasons that don’t really matter here).

And if we write a simple 1:1 map, where we say that USB code 304 triggers button 0, and so on, then we have accomplished the first and most critical step, which is that pressing a button on the MFD accurately reports a button being pushed to the device the Raspberry Pi is plugged into.

To make displaying the state easier, we can write a simple webpage that has twenty labels in it around the perimeter of an appropriately sized square. Then, we connect the webpage and the Python script over websockets.

Now, when a button is pressed or released, Python can send the OSB ID and its state to the webpage, which can then style the label appropriately. Conceivably this is not the most efficient way of doing this, however, I have never seen any latency so I don’t really care. We need two Raspberry Pis (one for each MFD) but we can just run the display code on one of them and plug a monitor in to the Pi’s HDMI port, anyway.

At this point, all we have really done is put a pointless computer in between the MFD and our gaming PC (it bears noting: when I started this project in 2020, Raspberry Pis were much, much cheaper). But, honestly, the rest is actually quite simple. We can write a config file that looks like this:

--page0
[physical key],[string],[action],[parameters]

Where “physical key” is the OSB or rocker being pushed, “string” is what we tell the display to use as a label, “action” is either the DX button we send to Windows or the name of a new page, and “parameters” is where we can start to make things behave differently.

I added in a few different “parameters.”

  • latch=# says that the button has to be pushed a certain number of times before it triggers its action. Pressing any other button resets that counter. So, for example, you could require the “Eject” command to be pressed three times before it sends the appropriate DX button to Windows.
  • hold=[1/0] says that when the button is pressed, it stays pressed until it’s released again. If it’s set to 1, then it also starts pressed.
  • toggle=[second_action] says that one action is triggered when the button is pressed, and one is triggered when it’s released
  • delay=500,long=[second_action] says that if the button is pressed for less than 500 milliseconds, it triggers its default action, and if it’s pressed longer than that it triggers a different action instead.
  • sequence=sequence_id,[1/-1] refers to a predefined sequence of virtual buttons, which pressing this button cycles through (so in a fighter plane you could, say, bind DX1 to select guns, DX2 to select heat-seeking missiles, DX3 to select radar-guided missiles, and DX4 to select bombs, then bind one of the physical OSBs to cycle through those button presses.
  • set=# means that when this button is pressed, it also sets (or unsets, if the number is negative) a corresponding virtual key

To keep the config file clean, if there are certain buttons we always want to trigger the same action (for example, navigation between different pages) a special --share page, if included with the profile, is automatically applied to all subpages.

So, concretely, here’s an example:

-DCS_F15C
--conf0 Combat mode switch (left)<br /><br />Radar settings (right)
4,TGT PREV,3
5,TGT NEXT,4
6,RDR ON/OFF,6
7,RWS/TWS,7
8,PRF SEL,8
10,RWR/SPO,10
13,STORES,conf1
14,NAV,conf2
17,FLOOD,17
18,BORE,18
19,VS,19
20,BVR,20
--conf1 Push buttons twice<br /><br />to confirm jettison action
13,MAIN,conf0
14,NAV,conf2
16,DUMP,31,latch=1,hold=1
19,ORD,30,latch=1
20,FUEL,29,latch=1
--conf2
1,NAV,1
4,WPT PREV,3
5,WPT NEXT,4
13,STORES,conf1
14,MAIN,conf0
16,AP DIS,23
19,ATT HOLD,27
20,ALT HOLD,26

This has 3 different pages. conf0 is the first page; pressing OSB 13 switches to the “stores” page (conf1), and also changes OSB 13 so that pressing it again reloads conf0. The external tank and stores jettison buttons, and the fuel dump button, have to be pressed twice before they activate; the dump button stays held in until it’s released.

The state of all the virtual buttons is stored in a Python dictionary, which can then be bitwise summed to produce the state that needs to be sent with any given report update to the computer connected over USB. Here’s another example, with only one page:

-DCS_F5E_L
---seq0 800,801,802 unset=20
---seq1 850,851,852,853 unset=20
--conf0
6,JETTISON,6,latch=2
10,R WING,10,hold=1,toggle=1
11,R OBD,11,hold=0,toggle=2
12,R IBD,12,hold=0,toggle=3
13,CENT,13,hold=0,toggle=4
14,L IBD,14,hold=0,toggle=7
15,L OBD,15,hold=0,toggle=8
16,L WING,16,hold=1,toggle=9
20,ARM,20,hold=1,toggle=17,set=800,set=850
23,JETT_UP,0,sequence=seq0|1
24,JETT_DN,0,sequence=seq0|-1
25,ESTR_CCW,0,sequence=seq1|1
26,ESTR_CW,0,sequence=seq1|-1
27,BOMB_CCW,803
28,BOMB_CW,804

In DCS, I have bound the master armament switch to DX20 for the “on” position, and DX17 for the “off” position. Similarly, as an experiment, I have bound every selector on the rotary dials to a separate action (those are seq0 and seq1) as well as (more logically) to the CW/CCW actions. Here is what that looks like:

The hardpoint buttons on the bottom are “hold” buttons. One of the rockers is used to turn the stores selector clockwise or counterclockwise. The master arm button is both held and a toggle. The release toggle also sets the stores selector to “SAFE” (which I can do because I assigned “safe” to a button as opposed to just an action for turning the dial clockwise/counterclockwise).

In DCS, the AH-64 has 6 buttons on a side, instead of 5. To accomplish this, I sort of awkwardly use a bunch of different pages, where OSB1–5 can either trigger A: OSB1–5 in game, or B: OSB2–6. Short-pressing OSB5 switches from A to B. Long-pressing OSB5 sticks with A, but sends the equivalent virtual key to OSB6 in the game:

I also programmed a few other options to make controlling things easier. Holding down the bottom-right rocker and then pressing the bottom-left rocker brings up a profile selection screen; holding the bottom-right rocker down and pressing OSB1 resets the whole script and checks Github to make sure the code and profiles are up to date, etc.

You could probably do this with any controller; the only thing you’d really need to do is to check which what the event codes are for your buttons (because I assume they are not 304 to 319). The code is on Github although honestly I assume it probably does not work out of the box so if you really want to try your hand at it feel free to ask me for help :P

Two other notes:

  1. The example report code I gave above specifies 32 buttons. This was an old DirectX limit, but there’s no particular reason to stick with it; accordingly, for my own purposes each MFD now enumerates as having 96 buttons, which Windows at least is fine with, and by extension:
  2. I have never used anywhere close to 96 commands. So it should be possible to plug both MFDs into one Raspberry Pi, and enumerate to Windows as one unified controller. The reason why I did not do this originally is because I didn’t know that DCS and MSFS would play nice with button IDs over 32, and also I do not understand asynchronous programming so getting it working with a single device was taxing enough for my little coyote brain :P

Oh, I guess a third note: this was not the end of my madness; I will write a part 2 that explains a few other Dumb Realizations That Are Cool, Actually I had and that are reflected in the product as it currently stands.


You must log in to comment.

in reply to @kojote's post:

Towards the end of writing this up I realized that by writing so much about it I was actually making it seem much more complicated than it actually is, or was. I am not very good at programming—there was one point where I realized I was doing something so inefficiently that changing it noticeably made the MFDs perform faster—but I was able to get it to work anyway :3

That said, if you ever want to try anything like that, and you have a spare Raspberry Pi 4 and a USB controller you want to supercharge like this, I am entirely confident I could talk you through setting everything up in less than half an hour :P