Kerbal Control Panel

I thought it would be fun to make a little desktop control panel for Kerbal Space Program.

Requirements

Design and Fabrication

Control Panel

I started by finding a piece of aluminum sheet and spec'ing out cheap parts on amazon. I then designed a layout using inkscape. I thought it would be fun to have handles on either side of the panel to give it an aerospace-grade look. I was originally going to paint the panel white, but my friend Michael convinced me that brushed aluminum is the only way to go.

I drilled out all the holes for the buttons and switches using a cordless drill. That was kind of a pain. My drill bit was also a little bit too small for the push buttons, so I ended up shaving off plastic from the push buttons using an xacto knife. That was terrible, and a couple of the buttons snapped in half so I had to order more.

I brushed the aluminum after drilling by clamping a 2x4 down to my work surface and then wrapping another piece of wood with sandpaper and sliding it on top of the panel using the clamped 2x4 as a guide. This was a bit tricky to get just right because any subtle non-linear motion would result in visible circles on the metal.

I transferred the artwork onto the panel using a laser printer tonor transfer method. By golly this was the most frustrating part of the entire project! I probably tried a dozen times over a 24 hour period to get this to work. First I printed off the mirrored images using a laser printer at work. It was color, but I wasn't able to adjust the tonor density. I tried ironing it over, and only 60% or so of the image transferred. I went over to a friend's house and we used his laser printer to up the density, but even then after 10 minutes of ironing only 90% of the image transferred. It was "good enough" and I went home, but I couldn't let it go and ended up buying a $100 laser printer of my own at 9pm. I proceeded to make half a dozen more attempts before going to bed. At one point I melted a control panel shape into my kitchen floor mat. My girlfriend was very supportive even though I was obviously going insane. The next day I took a fresh approach and was able to devise a procedure for re-working without damaging previous transfers. I did everything piece-wise and managed to end up with a nearly flawless transfer! The transferred tonor had a bit of a washed out look to it, so I rubbed blue ink on it to increase the contrast.

I used an Arduino UNO knock-off to interface to all the switches and LEDs. There were more switches than available GPIO pins after connecting up all the LEDs, so I wired them up into a diode matrix. 5 rows and 5 columns gives me 25 possible buttons using 10 GPIO pins, which is exactly how many I needed.

3-Axis Translation Joystick

I took a gamble and ordered an arcade game cabinet joystick. Those joysticks are of simple construction and use mechanical limit switches. I got lucky and was able to replace the original shaft with a longer carriage bolt, then glob on two additional limit switches axially. I used JB-Weld to attach a cabinet knob to the head of the bolt.

Fuel Guages

I just ordered some cheap 1V analog guages from amazon. It was easy enough to control them using the PWM outputs on a digispark. I used a digispark because I ran out of pins on the Arduino Uno! The trick was to add some capacitance to low-pass filter the PWM and prevent the guages from humming.

Console

I picked up a 2'x2' piece of MDF from Orchard Supply and cut it with a reciprocating saw. I then glued it all together with wood glue and cleaned it up with wood filler. I hadn't worked much with MDF before and I neglected to wear a respirator while cutting; I felt sick the next day due to breathing the formaldehyde laced dust! I wore a mask after that. I drilled out holes for the guages, did some rough cuts with the reciprocating saw, and then carved out the rest with a knife. It wasn't particularly elegant, but MDF does peel away pretty nicely.

The box became a lot less toxic smelling after priming and spray painting. My friend wanted me to paint it an ugly NASA brown. I considered painting it ugly NASA green, but when I got to the hardware store paint aisle I decided to go with a more pleasant SpaceX blue. 

Software

Embedded Side

The Arduino and digispark sketches were pretty straightforward. The Arduino sketch simply periodically iterates through the button matrix and prints out a 32-bit ascii integer on the serial port. Likewise, it listens for an incoming integer and updates the LED outputs.

#define arraysize(x) (sizeof(x) / sizeof((x)[0]))

static const int banks[] = {2,3,4,5,6};
static const int pins[] = {7,8,9,10,11};

static const int leds[] = {A0, A1, A2, A3, A4, 12, 13};

void setup() {
  Serial.begin(115200);
  for (int bank = 0; bank < arraysize(banks); ++bank) {
    pinMode(banks[bank], INPUT);
    digitalWrite(banks[bank], LOW);
  }
  for (int pin = 0; pin < arraysize(pins); ++pin) {
    pinMode(pins[pin], INPUT_PULLUP);
  }

  for (int led = 0; led < arraysize(leds); ++led) {
    pinMode(leds[led], OUTPUT);
    digitalWrite(leds[led], LOW);
  }
}

void loop() {
  while (Serial.available() > 0) {
    uint8_t values = Serial.read();
    for (int led = 0; led < arraysize(leds); ++led) {
      digitalWrite(leds[led], values & (1 << led) ? HIGH : LOW);
    }
  }
  
  Serial.write(0x80);
  for (int bank = 0; bank < arraysize(banks); ++bank) {    
    pinMode(banks[bank], OUTPUT);
    digitalWrite(banks[bank], LOW);
    delay(4);
    char values = 0;
    for (int pin = 0; pin < arraysize(pins); ++pin) {
      values |= digitalRead(pins[pin]) ? (1 << pin) : 0;
    }
    pinMode(banks[bank], INPUT);
    Serial.write(values);
  }
  for (int led = 0; led < arraysize(leds); ++led) {
    digitalWrite(leds[led], LOW);
  }
}

The digispark was a little bit trickier to get going because of the sketchiness of its USB stack. Even then, though, it simply listens for raw 8-bit values for setting its PWM outputs.

#include 
void setup() {                
  // initialize the digital pin as an output.
  SerialUSB.begin(); 
  pinMode(0,OUTPUT);
  pinMode(1,OUTPUT);
}

// the loop routine runs over and over again forever:
void loop() {
  while (!SerialUSB.available() || SerialUSB.read() !='\0') {}
  while (!SerialUSB.available()) {}
  unsigned char value0 = SerialUSB.read();
  analogWrite(0, value0);
  while (!SerialUSB.available()) {}
  unsigned char value1 = SerialUSB.read();
  analogWrite(1, value1);
}

Host Side

I wrote up some code to talk to the panel and guages in python, then used the kRPC mod to connect my python code to the game. Mapping the buttons and joystick to game functions was straight forward, but getting the fuel guages to work required learning a bit about how Kerbal represents parts.

Later on I ended up increasing the scope of my script to also read my USB joystick and inject attitude commands over kRPC. Kerbal's built in joystick support seems to be terrible for linux. I created a bug report, but within 7 hours someone at Squad closed it as "Not a Bug" and said that the game/unity doesn't do any internal calibration. That's dumb and I hope that isn't a common occurance for legitimate bugs to be ignored on Squad's bug reporting site. The problem seems to be that either unity or Kerbal itself tries to "center" joystick axes at start-up (aka calibrate!). Unfortunately, the joystick driver doesn't necessarily output the correct centered value until after an axis is moved, so the axes end up off-center and your ship just starts tumbling.

import krpc
import serial
import socket
import struct
import threading
import time
import queue

import joystick

NUM_BUTTONS = 25

SERIAL_PORT = '/dev/ttyUSB0'

BUTTON_MAP = {
  7: "thrust_forward",
  1: "thrust_aft",
  3: "thrust_starboard",
  4: "thrust_port",
  2: "thrust_up",
  0: "thrust_down",
  5: "abort",
  6: "abort_arm",
  8: "stage",
  9: "stage_arm",
  11: "rcs",
  14: "sas",
  10: "lights",
  12: "gear",
  13: "brakes",
  21: "action1",
  20: "action2",
  22: "action3",
  23: "action4",
  24: "action5",
  16: "action6",
  15: "action7",
  17: "action8",
  18: "action9",
  19: "action10",
}

class ControlPanel(threading.Thread):
  def __init__(self):
    threading.Thread.__init__(self)
    self.port = None
#    self.OpenSerial()

    self.last_buttons = (1 << NUM_BUTTONS) - 1
    self.callbacks = []
    self.leds = 0
    self.running = True

  def OpenSerial(self):
    self.port = serial.Serial(SERIAL_PORT, 115200, timeout=None)
    print("Connected to control panel")

  def AddCallback(self, func):
    self.callbacks.append(func)

  def SetLed(self, led, state):
    bit = 1 << led
    self.leds &= ~bit
    self.leds |= bit if state else 0

  def Stop(self):
    self.running = False

  def run(self):
    while self.running:
      try:
        if self.port is None:
          self.OpenSerial()

        while self.port.read(1) != b'\x80': pass

        buttons = 0
        raw = self.port.read(5)
        for b in raw:
          buttons = (buttons << 5) | b
        changed = buttons ^ self.last_buttons
        self.last_buttons = buttons

        for i in range(NUM_BUTTONS):
          bit = 1 << i;
          if changed & bit:
            state = False if buttons & bit else True
            for callback in self.callbacks:
              callback(i, state)
        self.port.write(struct.pack("B", self.leds))
      except serial.serialutil.SerialException:
        print("Reopening control panel port %s in 1 second..." % (SERIAL_PORT))
        time.sleep(1.0)
        if self.port is not None:
          self.port.close()
          self.port = None

class Guages:
  def __init__(self, port = '/dev/ttyACM0'):
    self.ser = serial.Serial(port, 115200, timeout=1)
    self.levels = [0.0, 0.0]

  def SetLevel(self, guage, fraction):
    self.levels[guage] = fraction

  def Update(self):
    self.ser.write(struct.pack("BBB", 0, int(self.levels[0] * 255), int(self.levels[1] * 255)))

class KscConnection(threading.Thread):
  def __init__(self, event_queue, panel, guages):
    threading.Thread.__init__(self)
    self.panel = panel
    self.guages = guages
    self.running = True
    self.conn = None
    self.events = event_queue
    self.Connect()

    self.abort_armed = False
    self.stage_armed = False

  def Connect(self):
    self.conn = krpc.connect(name='Control Panel')
    print("Connected to krpc")

  def run(self):
    while self.running:
      try:
        event = self.events.get(timeout=0.05)
      except queue.Empty:
        event = None

      try:
        start = time.time()

        vessel = self.conn.space_center.active_vessel

        if event is None:
          self.panel.SetLed(0, vessel.control.sas)
          self.panel.SetLed(4, vessel.control.rcs)


          # 'ElectricCharge', 'LiquidFuel', 'Oxidizer', 'SolidFuel', 'MonoPropellant'
          resources = vessel.resources
          stage_resources = vessel.resources_in_decouple_stage(vessel.control.current_stage, False)

          max_charge = resources.max('ElectricCharge')
          current_charge = resources.amount('ElectricCharge')
          charge_fraction = current_charge / max_charge if max_charge else 0.0
          self.panel.SetLed(1, charge_fraction < 0.2)

          max_fuel = stage_resources.max('LiquidFuel')
          current_fuel = stage_resources.amount('LiquidFuel')
          self.guages.SetLevel(0, current_fuel / max_fuel if max_fuel else 0.0)
          max_mono = resources.max('MonoPropellant')
          current_mono = resources.amount('MonoPropellant')
          self.guages.SetLevel(1, current_mono / max_mono if max_mono else 0.0)

          self.guages.Update()
          continue


        elif event[0] == 'abort_arm':
          self.abort_armed = event[1]
          self.panel.SetLed(5, event[1])
          if not event[1]:
            vessel.control.abort = False
        elif event[0] == 'abort':
          if self.abort_armed: vessel.control.abort = event[1]
        elif event[0] == 'stage_arm':
          self.stage_armed = event[1]
          self.panel.SetLed(6, event[1])
        elif event[0] == 'stage':
          if self.stage_armed and event[1]: vessel.control.activate_next_stage()
        elif event[0] == 'sas':
          vessel.control.sas = event[1]
          self.panel.SetLed(0, event[1])
        elif event[0] == 'rcs':
          vessel.control.rcs = event[1]
          self.panel.SetLed(4, event[1])
        elif event[0] == 'gear':
          vessel.control.gear = event[1]
        elif event[0] == 'lights':
          vessel.control.lights = event[1]
        elif event[0] == 'brakes':
          vessel.control.brakes = event[1]
        elif event[0] == 'action1':
          if event[1]: vessel.control.toggle_action_group(1)
        elif event[0] == 'action2':
          if event[1]: vessel.control.toggle_action_group(2)
        elif event[0] == 'action3':
          if event[1]: vessel.control.toggle_action_group(3)
        elif event[0] == 'action4':
          if event[1]: vessel.control.toggle_action_group(4)
        elif event[0] == 'action5':
          if event[1]: vessel.control.toggle_action_group(5)
        elif event[0] == 'action6':
          if event[1]: vessel.control.toggle_action_group(6)
        elif event[0] == 'action7':
          if event[1]: vessel.control.toggle_action_group(7)
        elif event[0] == 'action8':
          if event[1]: vessel.control.toggle_action_group(8)
        elif event[0] == 'action9':
          if event[1]: vessel.control.toggle_action_group(9)
        elif event[0] == 'action10':
          if event[1]: vessel.control.toggle_action_group(0)
        elif event[0] == 'thrust_forward':
          vessel.control.forward = 1.0 if event[1] else 0.0
        elif event[0] == 'thrust_aft':
          vessel.control.forward = -1.0 if event[1] else 0.0
        elif event[0] == 'thrust_port':
          vessel.control.right = -1.0 if event[1] else 0.0
        elif event[0] == 'thrust_starboard':
          vessel.control.right = 1.0 if event[1] else 0.0
        elif event[0] == 'thrust_up':
          vessel.control.up = 1.0 if event[1] else 0.0
        elif event[0] == 'thrust_down':
          vessel.control.up = -1.0 if event[1] else 0.0
        elif event[0] == 'x':
          vessel.control.yaw = event[1]
        elif event[0] == 'y':
          vessel.control.pitch = event[1]
        elif event[0] == 'rz':
          vessel.control.roll = event[1] * 0.5
        elif event[0] == 'throttle':
          vessel.control.throttle = (-event[1] + 1.0) / 2.0

        print(event, time.time() - start)

      except krpc.error.RPCError:
          pass

if __name__ == "__main__":
    events = queue.Queue()

    panel = ControlPanel()
    panel.AddCallback(lambda x, y: events.put((BUTTON_MAP[x], y)))
    panel.SetLed(0, False)
    panel.start()
    
    guages = Guages()

    joy = joystick.Joystick("/dev/input/js0")
    joy.AddCallback(lambda x, y: events.put((x, y)))
    joy.start()

    ksc = KscConnection(events, panel, guages)
    ksc.start()

    panel.join()
    ksc.join()
    joy.join()
joystick.py
# Released by rdb under the Unlicense (unlicense.org)
# Based on information from:
# https://www.kernel.org/doc/Documentation/input/joystick-api.txt

import threading
import os, struct, array
from fcntl import ioctl

# These constants were borrowed from linux/input.h
AXIS_NAMES = {
    0x00 : 'x',
    0x01 : 'y',
    0x02 : 'z',
    0x03 : 'rx',
    0x04 : 'ry',
    0x05 : 'rz',
    0x06 : 'throttle',
    0x07 : 'rudder',
    0x08 : 'wheel',
    0x09 : 'gas',
    0x0a : 'brake',
    0x10 : 'hat0x',
    0x11 : 'hat0y',
    0x12 : 'hat1x',
    0x13 : 'hat1y',
    0x14 : 'hat2x',
    0x15 : 'hat2y',
    0x16 : 'hat3x',
    0x17 : 'hat3y',
    0x18 : 'pressure',
    0x19 : 'distance',
    0x1a : 'tilt_x',
    0x1b : 'tilt_y',
    0x1c : 'tool_width',
    0x20 : 'volume',
    0x28 : 'misc',
}

BUTTON_NAMES = {
    0x120 : 'trigger',
    0x121 : 'thumb',
    0x122 : 'thumb2',
    0x123 : 'top',
    0x124 : 'top2',
    0x125 : 'pinkie',
    0x126 : 'base',
    0x127 : 'base2',
    0x128 : 'base3',
    0x129 : 'base4',
    0x12a : 'base5',
    0x12b : 'base6',
    0x12f : 'dead',
    0x130 : 'a',
    0x131 : 'b',
    0x132 : 'c',
    0x133 : 'x',
    0x134 : 'y',
    0x135 : 'z',
    0x136 : 'tl',
    0x137 : 'tr',
    0x138 : 'tl2',
    0x139 : 'tr2',
    0x13a : 'select',
    0x13b : 'start',
    0x13c : 'mode',
    0x13d : 'thumbl',
    0x13e : 'thumbr',

    0x220 : 'dpad_up',
    0x221 : 'dpad_down',
    0x222 : 'dpad_left',
    0x223 : 'dpad_right',

    # XBox 360 controller uses these codes.
    0x2c0 : 'dpad_left',
    0x2c1 : 'dpad_right',
    0x2c2 : 'dpad_up',
    0x2c3 : 'dpad_down',
}

class Joystick(threading.Thread):
  def __init__(self, device):
    threading.Thread.__init__(self)
    # We'll store the states here.
    self.axis_states = {}
    self.button_states = {}
    self.callbacks = []

    self.axis_map = []
    self.button_map = []

    print('Opening %s...' % device)
    self.jsdev = open(device, 'rb')

    # Get the device name.
    #buf = bytearray(63)
    buf = array.array('u', ['\0'] * 64)
    ioctl(self.jsdev, 0x80006a13 + (0x10000 * len(buf)), buf) # JSIOCGNAME(len)
    self.js_name = buf.tostring().decode('utf-8').rstrip('\x00')
    print('Device name: %s' % self.js_name)

    # Get number of axes and buttons.
    buf = array.array('B', [0])
    ioctl(self.jsdev, 0x80016a11, buf) # JSIOCGAXES
    num_axes = buf[0]

    buf = array.array('B', [0])
    ioctl(self.jsdev, 0x80016a12, buf) # JSIOCGBUTTONS
    num_buttons = buf[0]

    # Get the axis map.
    buf = array.array('B', [0] * 0x40)
    ioctl(self.jsdev, 0x80406a32, buf) # JSIOCGAXMAP

    for axis in buf[:num_axes]:
        axis_name = AXIS_NAMES.get(axis, 'unknown(0x%02x)' % axis)
        self.axis_map.append(axis_name)
        self.axis_states[axis_name] = 0.0

    # Get the button map.
    buf = array.array('H', [0] * 200)
    ioctl(self.jsdev, 0x80406a34, buf) # JSIOCGBTNMAP

    for btn in buf[:num_buttons]:
        btn_name = BUTTON_NAMES.get(btn, 'unknown(0x%03x)' % btn)
        self.button_map.append(btn_name)
        self.button_states[btn_name] = 0

    print('%d axes found: %s' % (num_axes, ', '.join(self.axis_map)))
    print('%d buttons found: %s' % (num_buttons, ', '.join(self.button_map)))

  def AddCallback(self, func):
    self.callbacks.append(func)

  def GetAxes(self):
    return self.axis_states

  def run(self):

    # Main event loop
    while True:
        evbuf = self.jsdev.read(8)
        if evbuf:
            time, value, type, number = struct.unpack('IhBB', evbuf)

            if type & 0x80:
                 print("(initial)",)

            if type & 0x01:
                button = self.button_map[number]
                if button:
                    self.button_states[button] = value
                    for callback in self.callbacks:
                        callback(button, value)

            if type & 0x02:
                axis = self.axis_map[number]
                if axis:
                    fvalue = value / 32767.0
                    self.axis_states[axis] = fvalue
                    for callback in self.callbacks:
                        callback(axis, fvalue)

if __name__ == "__main__":
  # Iterate over the joystick devices.
  print('Available devices:')

  for fn in os.listdir('/dev/input'):
      if fn.startswith('js'):
          print('  /dev/input/%s' % (fn))

  j = Joystick('/dev/input/js0')
  j.start()
  j.join()