Skip to content

Instantly share code, notes, and snippets.

@dexter93
Created April 17, 2026 09:50
Show Gist options
  • Select an option

  • Save dexter93/cda882058f3594c042a4f350995f697e to your computer and use it in GitHub Desktop.

Select an option

Save dexter93/cda882058f3594c042a4f350995f697e to your computer and use it in GitHub Desktop.
qmk keycodes to json
#!/usr/bin/env python3
"""
Generate keycodes_<version>.json from QMK's quantum/keycodes.h.
Usage:
python gen_keycodes_json.py path/to/keycodes.h
Output:
keycodes_<version>.json (e.g. keycodes_0.0.8.json)
"""
import re
import sys
import json
from pathlib import Path
# ---------------------------------------------------------------------------
# Group membership – ordered from most-specific to least-specific.
# Each entry: (group_name, start_hex, end_hex_inclusive)
# Derived from the IS_*_KEYCODE macros in keycodes.h
# ---------------------------------------------------------------------------
GROUP_RANGES = [
("internal", 0x0000, 0x0001),
("basic", 0x0004, 0x00A4),
("system", 0x00A5, 0x00A7),
("consumer", 0x00A8, 0x00C2),
("mouse", 0x00CD, 0x00DF),
("modifier", 0x00E0, 0x00E7),
("swap_hands", 0x56F0, 0x56F6),
("magic", 0x7000, 0x7022),
("midi", 0x7100, 0x718F),
("sequencer", 0x7200, 0x7208),
("joystick", 0x7400, 0x741F),
("programmable_button", 0x7440, 0x745F),
("audio", 0x7480, 0x7495),
("steno", 0x74F0, 0x74FC),
("macro", 0x7700, 0x771F),
("connection", 0x7780, 0x7797),
("backlight", 0x7800, 0x7806),
("led_matrix", 0x7810, 0x781A),
("underglow", 0x7820, 0x782A),
("rgb", 0x782B, 0x7834),
("rgb_matrix", 0x7840, 0x784E),
("quantum", 0x7C00, 0x7C7B),
("kb", 0x7E00, 0x7E1F),
("user", 0x7E40, 0x7E5F),
]
def group_for(value: int) -> str:
for name, lo, hi in GROUP_RANGES:
if lo <= value <= hi:
return name
return "unknown"
# ---------------------------------------------------------------------------
# Labels – human-readable display strings for well-known keycodes.
# Anything not listed falls back to the key name itself.
# ---------------------------------------------------------------------------
LABELS = {
"KC_NO": "", "KC_TRANSPARENT": "",
"KC_ENTER": "Enter", "KC_ESCAPE": "Esc", "KC_BACKSPACE": "Backspace",
"KC_TAB": "Tab", "KC_SPACE": "Spacebar", "KC_MINUS": "-",
"KC_EQUAL": "=", "KC_LEFT_BRACKET": "[", "KC_RIGHT_BRACKET": "]",
"KC_BACKSLASH": "\\", "KC_NONUS_HASH": "#", "KC_SEMICOLON": ";",
"KC_QUOTE": "'", "KC_GRAVE": "`", "KC_COMMA": ",", "KC_DOT": ".",
"KC_SLASH": "/", "KC_CAPS_LOCK": "Caps Lock",
"KC_PRINT_SCREEN": "Print Screen", "KC_SCROLL_LOCK": "Scroll Lock",
"KC_PAUSE": "Pause", "KC_INSERT": "Insert", "KC_HOME": "Home",
"KC_PAGE_UP": "Page Up", "KC_DELETE": "Delete", "KC_END": "End",
"KC_PAGE_DOWN": "Page Down", "KC_RIGHT": "Right", "KC_LEFT": "Left",
"KC_DOWN": "Down", "KC_UP": "Up", "KC_NUM_LOCK": "Num Lock",
"KC_KP_SLASH": "/", "KC_KP_ASTERISK": "*", "KC_KP_MINUS": "-",
"KC_KP_PLUS": "+", "KC_KP_ENTER": "Enter", "KC_KP_DOT": ".",
"KC_KP_EQUAL": "=", "KC_KP_COMMA": ",", "KC_KP_EQUAL_AS400": "=",
"KC_NONUS_BACKSLASH": "\\", "KC_APPLICATION": "Application",
"KC_KB_POWER": "KB Power",
**{f"KC_KP_{i}": str(i) for i in range(10)},
**{f"KC_F{i}": f"F{i}" for i in range(1, 25)},
**{f"KC_INTERNATIONAL_{i}": f"INT {i}" for i in range(1, 10)},
**{f"KC_LANGUAGE_{i}": f"LNG {i}" for i in range(1, 10)},
"KC_LOCKING_CAPS_LOCK": "Caps Lock (Lock)", "KC_LOCKING_NUM_LOCK": "Num Lock (Lock)",
"KC_LOCKING_SCROLL_LOCK": "Scroll Lock (Lock)",
"KC_EXECUTE": "Execute", "KC_HELP": "Help", "KC_MENU": "Menu",
"KC_SELECT": "Select", "KC_STOP": "Stop", "KC_AGAIN": "Again",
"KC_UNDO": "Undo", "KC_CUT": "Cut", "KC_COPY": "Copy",
"KC_PASTE": "Paste", "KC_FIND": "Find",
"KC_KB_MUTE": "Mute", "KC_KB_VOLUME_UP": "Volume Up",
"KC_KB_VOLUME_DOWN": "Volume Down",
"KC_ALTERNATE_ERASE": "Erase", "KC_SYSTEM_REQUEST": "SysReq",
"KC_CANCEL": "Cancel", "KC_CLEAR": "Clear", "KC_PRIOR": "Prior",
"KC_RETURN": "Return", "KC_SEPARATOR": "Separator", "KC_OUT": "Out",
"KC_OPER": "Oper", "KC_CLEAR_AGAIN": "Clear Again",
"KC_CRSEL": "CrSel", "KC_EXSEL": "ExSel",
"KC_SYSTEM_POWER": "System Power", "KC_SYSTEM_SLEEP": "System Sleep",
"KC_SYSTEM_WAKE": "System Wake",
"KC_AUDIO_MUTE": "Mute", "KC_AUDIO_VOL_UP": "Volume Up",
"KC_AUDIO_VOL_DOWN": "Volume Down",
"KC_MEDIA_NEXT_TRACK": "Next Track", "KC_MEDIA_PREV_TRACK": "Prev Track",
"KC_MEDIA_STOP": "Stop", "KC_MEDIA_PLAY_PAUSE": "Play/Pause",
"KC_MEDIA_SELECT": "Media Select", "KC_MEDIA_EJECT": "Eject",
"KC_MAIL": "Mail", "KC_CALCULATOR": "Calculator",
"KC_MY_COMPUTER": "My Computer",
"KC_WWW_SEARCH": "WWW Search", "KC_WWW_HOME": "WWW Home",
"KC_WWW_BACK": "WWW Back", "KC_WWW_FORWARD": "WWW Forward",
"KC_WWW_STOP": "WWW Stop", "KC_WWW_REFRESH": "WWW Refresh",
"KC_WWW_FAVORITES": "WWW Favorites",
"KC_MEDIA_FAST_FORWARD": "Fast Forward", "KC_MEDIA_REWIND": "Rewind",
"KC_BRIGHTNESS_UP": "Brightness Up", "KC_BRIGHTNESS_DOWN": "Brightness Down",
"KC_CONTROL_PANEL": "Control Panel", "KC_ASSISTANT": "Assistant",
"KC_MISSION_CONTROL": "Mission Control", "KC_LAUNCHPAD": "Launchpad",
"KC_LEFT_CTRL": "Left Ctrl", "KC_LEFT_SHIFT": "Left Shift",
"KC_LEFT_ALT": "Left Alt", "KC_LEFT_GUI": "Left GUI",
"KC_RIGHT_CTRL": "Right Ctrl", "KC_RIGHT_SHIFT": "Right Shift",
"KC_RIGHT_ALT": "Right Alt", "KC_RIGHT_GUI": "Right GUI",
}
def label_for(key: str, value: int) -> str:
if key in LABELS:
return LABELS[key]
# Single letters
m = re.match(r"^KC_([A-Z])$", key)
if m:
return m.group(1)
# Single digits
m = re.match(r"^KC_(\d)$", key)
if m:
return m.group(1)
# KP digits
m = re.match(r"^KC_KP_(\d+)$", key)
if m:
return m.group(1)
# Mouse
m = re.match(r"^QK_MOUSE_CURSOR_(UP|DOWN|LEFT|RIGHT)$", key)
if m:
return f"Mouse {m.group(1).title()}"
m = re.match(r"^QK_MOUSE_BUTTON_(\d+)$", key)
if m:
return f"Mouse Btn {m.group(1)}"
m = re.match(r"^QK_MOUSE_WHEEL_(UP|DOWN|LEFT|RIGHT)$", key)
if m:
return f"Mouse Wheel {m.group(1).title()}"
m = re.match(r"^QK_MOUSE_ACCELERATION_(\d)$", key)
if m:
return f"Mouse Accel {m.group(1)}"
# MIDI notes
m = re.match(r"^QK_MIDI_NOTE_([A-G](?:_SHARP)?)_(\d)$", key)
if m:
note = m.group(1).replace("_SHARP", "#")
return f"{note}{m.group(2)}"
m = re.match(r"^QK_MIDI_OCTAVE_N(\d)$", key)
if m:
return f"Octave -{m.group(1)}"
m = re.match(r"^QK_MIDI_OCTAVE_(\d+)$", key)
if m:
return f"Octave {m.group(1)}"
m = re.match(r"^QK_MIDI_TRANSPOSE_N(\d)$", key)
if m:
return f"Transpose -{m.group(1)}"
m = re.match(r"^QK_MIDI_TRANSPOSE_(\d+)$", key)
if m:
return f"Transpose +{m.group(1)}"
m = re.match(r"^QK_MIDI_VELOCITY_(\d+)$", key)
if m:
return f"Velocity {m.group(1)}"
m = re.match(r"^QK_MIDI_CHANNEL_(\d+)$", key)
if m:
return f"Channel {m.group(1)}"
# Joystick
m = re.match(r"^QK_JOYSTICK_BUTTON_(\d+)$", key)
if m:
return f"JS Btn {m.group(1)}"
# Programmable buttons
m = re.match(r"^QK_PROGRAMMABLE_BUTTON_(\d+)$", key)
if m:
return f"PB {m.group(1)}"
# Macros
m = re.match(r"^QK_MACRO_(\d+)$", key)
if m:
return f"Macro {m.group(1)}"
# KB / User
m = re.match(r"^QK_KB_(\d+)$", key)
if m:
return f"KB {m.group(1)}"
m = re.match(r"^QK_USER_(\d+)$", key)
if m:
return f"User {m.group(1)}"
# Fallback: clean up the define name
pretty = key.removeprefix("KC_").removeprefix("QK_").replace("_", " ").title()
return pretty
# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------
def parse_version(src: str) -> str:
m = re.search(r'#define\s+QMK_KEYCODES_VERSION\s+"([^"]+)"', src)
return m.group(1) if m else "unknown"
def parse_enum(src: str, enum_name: str) -> list[tuple[str, int]]:
"""Return [(name, value), ...] for all entries in the named enum."""
# Grab the enum body
pattern = rf"enum\s+{enum_name}\s*\{{([^}}]+)\}}"
m = re.search(pattern, src, re.DOTALL)
if not m:
return []
body = m.group(1)
entries: list[tuple[str, int]] = []
current_value = 0
# Strip comments
body = re.sub(r"//[^\n]*", "", body)
body = re.sub(r"/\*.*?\*/", "", body, flags=re.DOTALL)
for line in body.split(","):
line = line.strip()
if not line:
continue
if "=" in line:
name, val_str = line.split("=", 1)
name = name.strip()
val_str = val_str.strip()
try:
current_value = int(val_str, 0)
except ValueError:
# References another define – resolve from already-collected entries
ref = val_str.strip()
resolved = {n: v for n, v in entries}
if ref in resolved:
current_value = resolved[ref]
else:
continue
else:
name = line.strip()
if not name:
continue
entries.append((name, current_value))
current_value += 1
return entries
def parse_aliases(src: str) -> dict[str, list[str]]:
"""Return {canonical_key: [alias1, alias2, ...]}."""
# The alias block looks like:
# ALIAS = CANONICAL_KEY,
alias_to_canonical: dict[str, str] = {}
# Find the alias section (after "// Alias")
alias_section = re.search(r"//\s*Alias\s*(.*)", src, re.DOTALL)
if not alias_section:
return {}
body = alias_section.group(1)
# Only look until next enum or end
body = body.split("};")[0] if "};" in body else body
# Strip comments
body = re.sub(r"//[^\n]*", "", body)
for line in body.split(","):
line = line.strip()
if "=" not in line:
continue
alias, canonical = line.split("=", 1)
alias = alias.strip()
canonical = canonical.strip()
if alias and canonical:
alias_to_canonical[alias] = canonical
# Invert: canonical → list of aliases
result: dict[str, list[str]] = {}
for alias, canonical in alias_to_canonical.items():
result.setdefault(canonical, []).append(alias)
return result
def parse_ranges(src: str) -> dict[str, dict]:
"""Return the ranges dict from enum qk_keycode_ranges."""
entries = parse_enum(src, "qk_keycode_ranges")
name_to_val = {n: v for n, v in entries}
ranges: dict[str, dict] = {}
for name, value in entries:
if name.endswith("_MAX"):
continue
max_name = name + "_MAX"
if max_name not in name_to_val:
continue
start = value
size = name_to_val[max_name] - start
key = f"0x{start:04X}/0x{size:04X}"
ranges[key] = {"define": name}
return ranges
# ---------------------------------------------------------------------------
# Skip-list: range markers and other non-actionable defines
# ---------------------------------------------------------------------------
SKIP_KEYS = {
"QK_BASIC", "QK_BASIC_MAX", "QK_MODS", "QK_MODS_MAX",
"QK_MOD_TAP", "QK_MOD_TAP_MAX", "QK_LAYER_TAP", "QK_LAYER_TAP_MAX",
"QK_LAYER_MOD", "QK_LAYER_MOD_MAX", "QK_TO", "QK_TO_MAX",
"QK_MOMENTARY", "QK_MOMENTARY_MAX", "QK_DEF_LAYER", "QK_DEF_LAYER_MAX",
"QK_TOGGLE_LAYER", "QK_TOGGLE_LAYER_MAX",
"QK_ONE_SHOT_LAYER", "QK_ONE_SHOT_LAYER_MAX",
"QK_ONE_SHOT_MOD", "QK_ONE_SHOT_MOD_MAX",
"QK_LAYER_TAP_TOGGLE", "QK_LAYER_TAP_TOGGLE_MAX",
"QK_PERSISTENT_DEF_LAYER", "QK_PERSISTENT_DEF_LAYER_MAX",
"QK_SWAP_HANDS", "QK_SWAP_HANDS_MAX",
"QK_TAP_DANCE", "QK_TAP_DANCE_MAX",
"QK_MAGIC", "QK_MAGIC_MAX", "QK_MIDI", "QK_MIDI_MAX",
"QK_SEQUENCER", "QK_SEQUENCER_MAX",
"QK_JOYSTICK", "QK_JOYSTICK_MAX",
"QK_PROGRAMMABLE_BUTTON", "QK_PROGRAMMABLE_BUTTON_MAX",
"QK_AUDIO", "QK_AUDIO_MAX", "QK_STENO", "QK_STENO_MAX",
"QK_MACRO", "QK_MACRO_MAX",
"QK_CONNECTION", "QK_CONNECTION_MAX",
"QK_COMMUNITY_MODULE", "QK_COMMUNITY_MODULE_MAX",
"QK_LIGHTING", "QK_LIGHTING_MAX",
"QK_QUANTUM", "QK_QUANTUM_MAX",
"QK_KB", "QK_KB_MAX", "QK_USER", "QK_USER_MAX",
"QK_UNICODEMAP", "QK_UNICODEMAP_MAX",
"QK_UNICODE", "QK_UNICODE_MAX",
"QK_UNICODEMAP_PAIR", "QK_UNICODEMAP_PAIR_MAX",
# steno range marker
"QK_STENO_COMB_MAX",
}
def build_keycodes(src: str) -> dict[str, dict]:
entries = parse_enum(src, "qk_keycode_defines")
aliases_map = parse_aliases(src)
keycodes: dict[str, dict] = {}
seen_values: set[int] = set()
for key, value in entries:
if key in SKIP_KEYS:
continue
if value in seen_values:
# Duplicate value – only keep the first canonical name
continue
seen_values.add(value)
hex_key = f"0x{value:04X}"
entry: dict = {
"group": group_for(value),
"key": key,
"label": label_for(key, value),
}
if key in aliases_map:
entry["aliases"] = aliases_map[key]
keycodes[hex_key] = entry
return keycodes
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} path/to/keycodes.h", file=sys.stderr)
sys.exit(1)
src = Path(sys.argv[1]).read_text(encoding="utf-8")
version = parse_version(src)
print(f"Detected version: {version}", file=sys.stderr)
output = {
"ranges": parse_ranges(src),
"keycodes": build_keycodes(src),
}
out_path = Path(f"keycodes_{version}.json")
out_path.write_text(
json.dumps(output, indent=4, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print(f"Written {out_path} ({out_path.stat().st_size} bytes, "
f"{len(output['keycodes'])} keycodes)", file=sys.stderr)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment