Building a Custom Macro System for My Planck Keyboard
As someone who spends a lot of time at the computer, I wanted more keyboard shortcuts. My Planck 40% keeb is programmable, but I wanted to turn the entire keyboard into a macro pad. However, hooking up a second keyboard is still just another keyboard. Until I built macroplanck, a small Rust program that intercepts keyboard input and triggers shell scripts.
The Problem
The Planck keyboard connects via USB and appears as a standard HID device. Normally, the operating system handles key events, but to create custom macros that run scripts, I needed to intercept those events before they reach the desktop environment. This meant diving into Linux's input subsystem.
How It Works
The core idea is simple: disable the keyboard in the X11 input system, read raw events from the device file, and when a key is pressed, execute a script named after that key.
Disabling the Keyboard
Before running the macro program, I use xinput to disable the keyboard device. This prevents the OS from processing the key presses, allowing my program to read them exclusively. The below script is for a duckyPad, but it works for any device:
# From disable-keeb.sh
KEEB_ID=`xinput | rg 'duckyPad\(2020\) Keyboard' | rg -v consumer | sed 's/^.*\(id=[0-9]*\).*$/\1/' | tr -d id=`
xinput --disable $KEEB_ID
Reading Input Events
Linux exposes input devices through /dev/input/event* files. Each event is a 24-byte structure containing timestamp, type, code, and value.
I define a struct for InputEvent to convert the raw bytes from the input device into a Rust struct:
pub struct InputEvent {
time: libc::timeval,
type_: InputEventType,
code: u16,
value: u32,
}
impl From<[u8; 24]> for InputEvent {
fn from(bytes: [u8; 24]) -> Self {
Self {
// ignore time for now
time: libc::timeval {
tv_sec: {
let mut bytes = &bytes[0..8];
bytes.read_i64::<LittleEndian>().unwrap()
},
tv_usec: {
let mut bytes = &bytes[8..16];
bytes.read_i64::<LittleEndian>().unwrap()
},
},
type_: {
let mut bytes = &bytes[16..18];
let as_u16 = bytes.read_u16::<LittleEndian>().unwrap();
InputEventType::from_u16(as_u16).unwrap_or_else(|| InputEventType::Cnt)
},
code: {
let mut bytes = &bytes[18..20];
bytes.read_u16::<LittleEndian>().unwrap()
},
value: {
let mut bytes = &bytes[20..24];
bytes.read_u32::<LittleEndian>().unwrap()
},
}
}
}
The program opens the device file and reads in a loop:
let mut buf = [0; 24];
loop {
device.read_exact(&mut buf[..]).unwrap();
let ev = InputEvent::from(buf);
// Process the event
}
Parsing Key Events
Key presses have type InputEventType::Key and values indicating press (1), release (0), or repeat (2). I map key codes to names using a massive match statement based on Linux's input-event-codes.h (I used neovim macro to convert the file to a match block):
impl KeyPress {
fn code_to_str(&self) -> &'static str {
match self.code {
16 => "KEY_Q",
// Hundreds more...
_ => unimplemented!(),
}
}
}
Triggering Macros
Finally, on key press the program runs the associated script:
if let KeyEvent::Pressed = keypress.type_ {
let filename = keypress.code_to_str().to_lowercase();
run_script(&filename, tx.clone());
}
Process Management
To avoid zombie processes, I spawn a "reaper" thread that waits for child processes to finish. Each process gets sent to this thread:
fn spawn_reaper() -> (Sender<ReaperMsg>, Receiver<ReaperMsg>, JoinHandle<()>) {
let (tx, rx) = unbounded();
let reaper = thread::spawn(move || {
while let Ok(msg) = rx.recv() {
match msg {
ReaperMsg::ChildSpawned(mut child) => {
child.wait().unwrap();
}
ReaperMsg::Exit => return,
}
}
});
(tx, rx, reaper)
}
Conclusion
Once the keeb is disabled and the application is running, all keystrokes from the specific keyboard can be mapped to any arbitrary command. This makes it trivial to add shortcuts and automations to a single hotkey without needing to remember specific keystroke combinations.
Comments