Created
April 17, 2026 09:50
-
-
Save dexter93/cda882058f3594c042a4f350995f697e to your computer and use it in GitHub Desktop.
qmk keycodes to json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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