Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CLI: Add 'via2json' subcommand #16468

Merged
merged 10 commits into from
Mar 24, 2022
17 changes: 17 additions & 0 deletions docs/cli_commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,23 @@ This command cleans up the `.build` folder. If `--all` is passed, any .hex or .b
qmk clean [-a]
```

## `qmk via2json`

This command an generate a keymap.json from a VIA keymap backup. Both the layers and the macros are converted, enabling users to easily move away from a VIA-enabled firmware without writing any code or reimplementing their keymaps in QMK Configurator.

**Usage**:

```
qmk via2json -kb KEYBOARD [-l LAYOUT] [-km KEYMAP] [-o OUTPUT] filename
```

**Example:**

```
$ qmk via2json -kb ai03/polaris -o polaris_keymap.json polaris_via_backup.json
Ψ Wrote keymap to /home/you/qmk_firmware/polaris_keymap.json
```

---

# Developer Commands
Expand Down
1 change: 1 addition & 0 deletions lib/python/qmk/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
'qmk.cli.new.keymap',
'qmk.cli.pyformat',
'qmk.cli.pytest',
'qmk.cli.via2json',
]


Expand Down
145 changes: 145 additions & 0 deletions lib/python/qmk/cli/via2json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""Generate a keymap.c from a configurator export.
"""
import json
import re

from milc import cli

import qmk.keyboard
import qmk.path
from qmk.info import info_json
from qmk.json_encoders import KeymapJSONEncoder
from qmk.commands import parse_configurator_json, dump_lines
from qmk.keymap import generate_json, list_keymaps, locate_keymap, parse_keymap_c


def _find_via_layout_macro(keyboard):
keymap_layout = None
if 'via' in list_keymaps(keyboard):
keymap_path = locate_keymap(keyboard, 'via')
if keymap_path.suffix == '.json':
keymap_layout = parse_configurator_json(keymap_path)['layout']
else:
keymap_layout = parse_keymap_c(keymap_path)['layers'][0]['layout']
return keymap_layout


def _convert_macros(via_macros):
via_macros = list(filter(lambda f: bool(f), via_macros))
if len(via_macros) == 0:
return list()
split_regex = re.compile(r'(}\,)|(\,{)')
macros = list()
for via_macro in via_macros:
# Split VIA macro to its elements
macro = split_regex.split(via_macro)
# Remove junk elements (None, '},' and ',{')
macro = list(filter(lambda f: False if f in (None, '},', ',{') else True, macro))
macro_data = list()
for m in macro:
if '{' in m or '}' in m:
# Found keycode(s)
keycodes = m.split(',')
# Remove whitespaces and curly braces from around keycodes
keycodes = list(map(lambda s: s.strip(' {}'), keycodes))
# Remove the KC prefix
keycodes = list(map(lambda s: s.replace('KC_', ''), keycodes))
macro_data.append({"action": "tap", "keycodes": keycodes})
else:
# Found text
macro_data.append(m)
macros.append(macro_data)

return macros


def _fix_macro_keys(keymap_data):
macro_no = re.compile(r'MACRO0?([0-9]{1,2})')
for i in range(0, len(keymap_data)):
for j in range(0, len(keymap_data[i])):
kc = keymap_data[i][j]
m = macro_no.match(kc)
if m:
keymap_data[i][j] = f'MACRO_{m.group(1)}'
return keymap_data


def _via_to_keymap(via_backup, keyboard_data, keymap_layout):
# Check if passed LAYOUT is correct
layout_data = keyboard_data['layouts'].get(keymap_layout)
if not layout_data:
cli.log.error(f'LAYOUT macro {keymap_layout} is not a valid one for keyboard {cli.args.keyboard}!')
exit(1)

layout_data = layout_data['layout']
sorting_hat = list()
for index, data in enumerate(layout_data):
sorting_hat.append([index, data['matrix']])

sorting_hat.sort(key=lambda k: (k[1][0], k[1][1]))

pos = 0
for row_num in range(0, keyboard_data['matrix_size']['rows']):
for col_num in range(0, keyboard_data['matrix_size']['cols']):
if pos >= len(sorting_hat) or sorting_hat[pos][1][0] != row_num or sorting_hat[pos][1][1] != col_num:
sorting_hat.insert(pos, [None, [row_num, col_num]])
else:
sorting_hat.append([None, [row_num, col_num]])
pos += 1

keymap_data = list()
for layer in via_backup['layers']:
pos = 0
layer_data = list()
for key in layer:
if sorting_hat[pos][0] is not None:
layer_data.append([sorting_hat[pos][0], key])
pos += 1
layer_data.sort()
layer_data = [kc[1] for kc in layer_data]
keymap_data.append(layer_data)

return keymap_data


@cli.argument('-o', '--output', arg_only=True, type=qmk.path.normpath, help='File to write to')
@cli.argument('-q', '--quiet', arg_only=True, action='store_true', help="Quiet mode, only output error messages")
@cli.argument('filename', type=qmk.path.FileType('r'), arg_only=True, help='VIA Backup JSON file')
@cli.argument('-kb', '--keyboard', type=qmk.keyboard.keyboard_folder, completer=qmk.keyboard.keyboard_completer, arg_only=True, required=True, help='The keyboard\'s name')
@cli.argument('-km', '--keymap', arg_only=True, default='via2json', help='The keymap\'s name')
@cli.argument('-l', '--layout', arg_only=True, help='The keymap\'s layout')
@cli.subcommand('Convert a VIA backup json to keymap.json format.')
def via2json(cli):
"""Convert a VIA backup json to keymap.json format.
This command uses the `qmk.keymap` module to generate a keymap.json from a VIA backup json. The generated keymap is written to stdout, or to a file if -o is provided.
"""
# Find appropriate layout macro
keymap_layout = cli.args.layout if cli.args.layout else _find_via_layout_macro(cli.args.keyboard)
if not keymap_layout:
cli.log.error(f"Couldn't find LAYOUT macro for keyboard {cli.args.keyboard}. Please specify it with the '-l' argument.")
exit(1)

# Load the VIA backup json
with cli.args.filename.open('r') as fd:
via_backup = json.load(fd)

# Generate keyboard metadata
keyboard_data = info_json(cli.args.keyboard)

# Get keycode array
keymap_data = _via_to_keymap(via_backup, keyboard_data, keymap_layout)

# Convert macros
macro_data = list()
if via_backup.get('macros'):
macro_data = _convert_macros(via_backup['macros'])

# Replace VIA macro keys with JSON keymap ones
keymap_data = _fix_macro_keys(keymap_data)

# Generate the keymap.json
keymap_json = generate_json(cli.args.keymap, cli.args.keyboard, keymap_layout, keymap_data, macro_data)

keymap_lines = [json.dumps(keymap_json, cls=KeymapJSONEncoder)]
dump_lines(cli.args.output, keymap_lines, cli.args.quiet)
8 changes: 7 additions & 1 deletion lib/python/qmk/json_encoders.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,13 @@ def encode_list(self, obj):
if key == 'JSON_NEWLINE':
layer.append([])
else:
layer[-1].append(f'"{key}"')
if isinstance(key, dict):
# We have a macro

# TODO: Add proper support for nicely formatting keymap.json macros
layer[-1].append(f'{self.encode(key)}')
else:
layer[-1].append(f'"{key}"')

layer = [f"{self.indent_str*indent_level}{', '.join(row)}" for row in layer]

Expand Down
7 changes: 6 additions & 1 deletion lib/python/qmk/keymap.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def is_keymap_dir(keymap, c=True, json=True, additional_files=None):
return True


def generate_json(keymap, keyboard, layout, layers):
def generate_json(keymap, keyboard, layout, layers, macros=None):
"""Returns a `keymap.json` for the specified keyboard, layout, and layers.
Args:
Expand All @@ -173,11 +173,16 @@ def generate_json(keymap, keyboard, layout, layers):
layers
An array of arrays describing the keymap. Each item in the inner array should be a string that is a valid QMK keycode.
macros
A sequence of strings containing macros to implement for this keyboard.
"""
new_keymap = template_json(keyboard)
new_keymap['keymap'] = keymap
new_keymap['layout'] = layout
new_keymap['layers'] = layers
if macros:
new_keymap['macros'] = macros

return new_keymap

Expand Down