Custom 3D Printed Macro Keypad to Manage QEMU VMs

2020.04.28 | Yuki Rea

I have been looking for a solution to manage starting, stopping, pausing, and swapping PCIe devices between the VMs on my workstation. I originally was using a combination of macros on my main keyboard with scripts in each VM that would detect the macro and send commands to the hypervisor over SSH. This was a clunky solution and would not work for VMs I had not already set up. I decided to create a small device that would stay connected to the host system that I could use for this purpose.


Case

At the time of making this project I just got my first 3D printer and decided that this would be a great project to create my first 3D model. I chose OpenSCAD for this model because I dislike the poor UI/UX in most 3D modeling software. OpenSCAD simply allows you to write your model in a text file and then render it in OpenSCAD. I found OpenSCAD to be far faster to create basic models than other modeling software I have used in the past. Having never used the software before it only took me about 15 minutes to learn all the functions and create the model. I chose sharp angles on the outside and put the [null] logo in the top right corner of the case to add a bit of personalization. This case was designed to be printed in one piece and has an open bottom with a cutout at the back for a Mini USB B connector for the Teensy ++ 2.0. I chose to use 12 switches so that I could use the function key key-caps from a standard keyboard. I used Cura to slice the model using default settings and printed the model with the white test PLA that came with my Ender 3 Pro. For my first ever print this model came out great.


Electronics

The electronics used in this project were purely chosen from what I had laying around the office. I salvaged the Teensy ++ 2.0 from a prototype keyboard to repurpose it for this project. The Teensy ++ 2.0 is a small USB development board with the Atmel AT90USB1286 microcontroller on board. This board has way more IO than I really need for this project, a lower cost board like the Teensy 2.0 would be a better choice. I salvaged the Cherry MX Brown switches from the same prototype keyboard and hand wires a 4x3 matrix with some solid core wire using 1N4148 signal diodes for each key. In hindsight the signal diodes are not necessary for such a simple device, I am just used to adding them to every project that has a switch matrix as good practice. I added a white LED to the front of the case which I originally planned to program with different animations depending on what VM was active but decided it was not necessary and just kept as a solid power LED. This may be something I add in the future when I need something to tinker with.


Firmware

I chose to use the QMK keyboard firmware for this project because of how useful and powerful it has been for my projects in the past. As of now I am not using any of it's advanced functionality but it is nice to know that I can easily add complex features in the future if needed. The firmware is configured with simple macros for each key that presses the Super/Meta + Function keys at the same time. This allows me to detect the macro on the host computer and run a script of choice depending on what key was pressed with out initiating other functions that may be bound to the function keys.

QMK firmware source code can be found here:
qmk_firmware - github.com

Below is my basic configuration for QMK firmware that got my keypad up and running.

qmk_firmware/keyboards/null_macropad/config.h
#pragma once

#include "config_common.h"

#define VENDOR_ID       0xFEED
#define PRODUCT_ID      0x0000
#define DEVICE_VER      0x0001
#define MANUFACTURER    [null]
#define PRODUCT         null_macropad
#define DESCRIPTION     A custom keyboard

#define MATRIX_ROWS 3
#define MATRIX_COLS 4

#define MATRIX_ROW_PINS { F4, F5, F6 }
#define MATRIX_COL_PINS { A3, A5, A6, A0 }
#define UNUSED_PINS

#define DIODE_DIRECTION COL2ROW

#define DEBOUNCING_DELAY 5

#define LOCKING_SUPPORT_ENABLE
#define LOCKING_RESYNC_ENABLE

#define IS_COMMAND() ( \
    keyboard_report->mods == (MOD_BIT(KC_LSHIFT) | MOD_BIT(KC_RSHIFT)) \
)
qmk_firmware/keyboards/null_macropad/null_macropad.h
#ifndef NULL_MACROPAD_H
#define NULL_MACROPAD_H

#include "quantum.h"

#define LAYOUT( \
    K00, K01, K02, K03, \
    K10, K11, K12, K13, \
    K20, K21, K22, K23  \
) \
{ \
    { K00,   K01,   K02,   K03   }, \
    { K10,   K11,   K12,   K13   }, \
    { K20,   K21,   K22,   K23   }, \
}

#endif
qmk_firmware/keyboards/null_macropad/keymaps/default/keymap.c
#include QMK_KEYBOARD_H

#define FNC01 LGUI(KC_F1)		
#define FNC02 LGUI(KC_F2)		
#define FNC03 LGUI(KC_F3)		
#define FNC04 LGUI(KC_F4)		
#define FNC05 LGUI(KC_F5)		
#define FNC06 LGUI(KC_F6)		
#define FNC07 LGUI(KC_F7)		
#define FNC08 LGUI(KC_F8)		
#define FNC09 LGUI(KC_F9)		
#define FNC10 LGUI(KC_F10)		
#define FNC11 LGUI(KC_F11)		
#define FNC12 LGUI(KC_F12)		

const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
  [0] = LAYOUT(
    FNC01,FNC02,FNC03,FNC04, \
    FNC05,FNC06,FNC07,FNC08, \
    FNC09,FNC10,FNC11,FNC12 \
  ),
};

void matrix_init_user(void) { }

void matrix_scan_user(void) { }

void led_set_user(uint8_t usb_led) { }

Hotkey Configuration

Most of the functionality of this macropad is configured on the operating system side to make things simple and quick to change. The hypervisor boots into i3 desktop environment by default using my motherboard's onboard GPU. I am using this only to capture key-presses and initiate scripts as i3 makes this very easy to do. Binding keys is as easy as adding one line to the configuration file. Below is an example of how I use i3 to map key-presses to run scripts.

qmk_firmware/keyboards/null_macropad/config.h
...
# hypervisor control
bindsym $mod+F2 exec "/usr/share/null-vm-tools/bin/startvm-1"
bindsym $mod+F3 exec "/usr/share/null-vm-tools/bin/startvm"
bindsym $mod+F6 exec "/usr/share/null-vm-tools/bin/usb-del; /usr/share/null-vm-tools/bin/usb-add_linux"
bindsym $mod+F7 exec "/usr/share/null-vm-tools/bin/usb-del; /usr/share/null-vm-tools/bin/usb-add"
...

Scripting QEMU

I use a variety of scripts to help manage my virtual machines, most of them send commands directly to the QEMU monitor I have set up for each VM. To do this I set the QEMU monitor to listen for a Unix socket that I create for each VM. Here is an example of how I set up the QEMU monitor socket in one of my VM start scripts.

-monitor unix:/run/QEMU-monitor-socket-1,server,nowait

I use socat To issue commands to the QEMU monitor over the Unix socket. Below is a one line script that sends QEMU monitor a command to remove a USB device from the guest system.

#!bin/bash
echo "device_del USB1-1" | socat - unix-connect:/run/QEMU-monitor-socket

In addition to adding or removing USB devices from the guest system, it is also possible to add or remove PCIe devices on the fly using the same method. This offers huge flexibility as you can add or remove devices such as GPUs, network devices, storage controllers, or more with out rebooting the guest or host system. See the example below for how this can be done.

#!bin/bash
echo "device_add vfio-pci,host=84:00.1" | socat - unix-connect:/run/QEMU-monitor-socket
For more information about QEMU monitor commands, see the following documentation provided by RedHat:
QEMU monitor